memory-os/scripts/backfill_decay_metadata.py

291 lines
9.9 KiB
Python

#!/usr/bin/env python3
"""Backfill decay metadata for Qdrant knowledge_base.
Populates missing fields (created_at, last_accessed_at, importance_score,
confidence_score, archived) so the decay_scanner can actually work.
Modes:
--dry-run Simulate everything, print stats, no writes (DEFAULT)
--commit Actually update points via REST API
--pilot N Process only N points (for validation)
Usage:
python3 backfill_decay_metadata.py --dry-run --pilot 50
python3 backfill_decay_metadata.py --dry-run
python3 backfill_decay_metadata.py --commit
Heuristics:
- created_at / last_accessed_at:
Session points: from payload.timestamp (Unix epoch → ISO 8601)
Wiki points: from file mtime if file exists, else now()
- importance_score: 0.5 flat (conservative)
- confidence_score:
wiki-* → 0.85
session → 0.70
unknown → 0.75
- archived: false (explicit)
Safety:
- Dry-run by default — no writes without --commit
- Only touches points MISSING the target fields (no overwrites)
- Batched via REST API POST /points/payload
- Uses same pattern as migrate_strength.py (2026-05-29)
"""
import argparse
import hashlib
import json
import os
import subprocess
import sys
import time
from datetime import datetime, timezone
from pathlib import Path
from urllib.request import Request, urlopen
from urllib.error import URLError
# ─── Config ──────────────────────────────────────────────────────────────────
QDRANT_URL = "http://localhost:6333"
COLLECTION = "knowledge_base"
BATCH_SIZE = 200
SCROLL_LIMIT = 200
LOG_FILE = Path.home() / ".hermes/logs/decay_scanner.log"
VAULT_ROOT = Path(os.environ.get("VAULT_PATH", str(Path.home() / "vault")))
# ─── Heuristics ──────────────────────────────────────────────────────────────
CONFIDENCE_BY_SOURCE = {
"wiki-concepts": 0.85,
"wiki-entities": 0.85,
"wiki-comparisons": 0.85,
"wiki-raw": 0.85,
"session": 0.70,
}
CONFIDENCE_DEFAULT = 0.75
IMPORTANCE_SCORE = 0.5
ARCHIVED = False
def log(msg: str) -> None:
"""Append timestamped message to log."""
ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
line = f"[backfill-decay] {ts} {msg}"
print(line)
LOG_FILE.parent.mkdir(parents=True, exist_ok=True)
with open(LOG_FILE, "a") as f:
f.write(line + "\n")
def scroll_all() -> list[dict]:
"""Scroll entire collection, returning all points with payloads."""
points = []
offset = None
while True:
body = {"limit": SCROLL_LIMIT, "with_payload": True, "with_vector": False}
if offset:
body["offset"] = offset
req = Request(
f"{QDRANT_URL}/collections/{COLLECTION}/points/scroll",
data=json.dumps(body).encode(),
headers={"Content-Type": "application/json"},
)
try:
with urlopen(req, timeout=30) as resp:
data = json.loads(resp.read().decode())
except URLError as e:
log(f"ERROR: scroll failed: {e}")
break
result = data.get("result", {})
batch = result.get("points", [])
points.extend(batch)
offset = result.get("next_page_offset")
if offset is None or len(batch) == 0:
break
return points
def resolve_timestamp(point: dict) -> str:
"""Return ISO 8601 string for created_at/last_accessed_at.
Session points: use payload.timestamp (Unix epoch float).
Wiki points: locate file via payload.file_path → file mtime.
Fallback: now().
"""
pl = point.get("payload", {})
ts_raw = pl.get("timestamp")
if ts_raw is not None:
try:
ts_float = float(ts_raw)
return datetime.fromtimestamp(ts_float, tz=timezone.utc).isoformat()
except (ValueError, TypeError, OSError):
pass
# Wiki point — resolve file_path to filesystem
file_path = pl.get("file_path", "")
source = pl.get("source", "")
if file_path and source.startswith("wiki-"):
candidate = Path(file_path)
try:
mtime = candidate.stat().st_mtime
return datetime.fromtimestamp(mtime, tz=timezone.utc).isoformat()
except OSError:
pass
# Absolute fallback
return datetime.now(timezone.utc).isoformat()
def compute_confidence(point: dict) -> float:
"""Heuristic confidence_score by source."""
source = point.get("payload", {}).get("source", "")
return CONFIDENCE_BY_SOURCE.get(source, CONFIDENCE_DEFAULT)
def needs_backfill(point: dict) -> list[str]:
"""Return list of fields this point is missing (that we backfill)."""
TARGETS = ["created_at", "last_accessed_at", "importance_score", "confidence_score", "archived"]
pl = point.get("payload", {})
return [f for f in TARGETS if f not in pl]
def build_payload(point: dict) -> dict:
"""Build the payload fragment to backfill for this point."""
ts = resolve_timestamp(point)
return {
"created_at": ts,
"last_accessed_at": ts,
"importance_score": IMPORTANCE_SCORE,
"confidence_score": compute_confidence(point),
"archived": ARCHIVED,
}
def upsert_batch(point_ids: list[str], payload: dict) -> bool:
"""Update payload for a batch of points via REST API. Fail-open."""
body = {"payload": payload, "points": point_ids}
req = Request(
f"{QDRANT_URL}/collections/{COLLECTION}/points/payload",
data=json.dumps(body).encode(),
headers={"Content-Type": "application/json"},
)
try:
with urlopen(req, timeout=30) as resp:
result = json.loads(resp.read().decode())
return result.get("status") == "ok"
except URLError as e:
log(f"ERROR: upsert batch failed: {e}")
return False
def run(dry_run: bool = True, pilot: int = 0, commit: bool = False):
"""Main execution."""
mode = "DRY-RUN" if dry_run else "COMMIT"
log(f"=== BACKFILL DECAY METADATA ({mode}) ===")
log(f"Collection: {COLLECTION}")
log(f"Pilot: {pilot if pilot else 'all'}")
# ── Scroll all points ──
log("Scrolling all points...")
all_points = scroll_all()
total = len(all_points)
log(f"Total points: {total}")
# ── Identify points needing backfill ──
to_update: list[tuple[str, dict]] = [] # (point_id, payload_fragment)
skipped_already_have = 0
stats_by_source = {}
for p in all_points:
missing = needs_backfill(p)
if not missing:
skipped_already_have += 1
continue
pid = str(p["id"])
pl = build_payload(p)
to_update.append((pid, pl))
src = p.get("payload", {}).get("source", "unknown")
stats_by_source[src] = stats_by_source.get(src, 0) + 1
log(f"Points needing backfill: {len(to_update)}/{total}")
log(f"Points already complete: {skipped_already_have}")
if pilot and pilot < len(to_update):
to_update = to_update[:pilot]
log(f"Pilot mode: processing first {pilot}")
# ── Stats by source ──
log("Backfill candidates by source:")
for src, count in sorted(stats_by_source.items(), key=lambda x: -x[1]):
ts_sample = ""
for pid, pl in to_update:
if pid and len(ts_sample) < 3:
p = next((pp for pp in all_points if str(pp["id"]) == pid), None)
if p and p.get("payload", {}).get("source") == src:
ts_sample = pl.get("created_at", "")[:19]
break
log(f" {src:<25} {count:>6} pts ts_sample={ts_sample}")
if dry_run:
log("DRY-RUN complete. No changes made. Use --commit to apply.")
return
# ── Commit ──
if not commit:
log("ERROR: --commit flag required for writes. Aborting.")
return
log(f"Committing in batches of {BATCH_SIZE}...")
success = 0
failed = 0
for i in range(0, len(to_update), BATCH_SIZE):
batch = to_update[i : i + BATCH_SIZE]
# Group by identical payload (same timestamp, same confidence)
# For simplicity, each point gets its own upsert — but we batch IDs
# with identical payloads where possible
payload_groups: dict[str, tuple[list[str], dict]] = {}
for pid, pl in batch:
key = json.dumps(pl, sort_keys=True)
if key not in payload_groups:
payload_groups[key] = ([], pl)
payload_groups[key][0].append(pid)
for ids, pl in payload_groups.values():
ok = upsert_batch(ids, pl)
if ok:
success += len(ids)
else:
failed += len(ids)
log(f" FAILED batch: {len(ids)} points, first id={ids[0]}")
batch_num = i // BATCH_SIZE + 1
total_batches = (len(to_update) + BATCH_SIZE - 1) // BATCH_SIZE
log(f" Batch {batch_num}/{total_batches}: {success} ok, {failed} failed")
if i + BATCH_SIZE < len(to_update):
time.sleep(0.5) # gentle rate limit
log(f"COMMIT complete. success={success}, failed={failed}")
if failed > 0:
log("WARNING: some batches failed. Check logs.")
def main():
parser = argparse.ArgumentParser(description="Backfill decay metadata for Qdrant knowledge_base")
parser.add_argument("--dry-run", action="store_true", default=True,
help="Simulate, no writes (default)")
parser.add_argument("--commit", action="store_true",
help="Actually write to Qdrant")
parser.add_argument("--pilot", type=int, default=0,
help="Process only N points (for validation)")
args = parser.parse_args()
if args.commit:
args.dry_run = False
run(dry_run=args.dry_run, pilot=args.pilot, commit=args.commit)
if __name__ == "__main__":
main()