lifeos / rag.py
awaisaziz's picture
Add config, model status, and VLM support
0c4cd3b
Raw
History Blame Contribute Delete
7.13 kB
"""Long-term memory for LifeOS — fully local RAG.
Embeddings run through llama.cpp (nomic-embed-text-v1.5 GGUF, 146MB) — the
same runtime as the chat model, so the whole stack stays on-device. Notes
live in data/longterm.json with their vectors; retrieval is numpy cosine
top-k. No vector DB, no cloud.
"""
import hashlib
import json
import os
import threading
import time
import uuid
import numpy as np
import config
DATA_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "data")
STORE_PATH = os.path.join(DATA_DIR, "longterm.json")
EMBED_REPO = config.EMBED_REPO
EMBED_FILE = config.EMBED_FILE
_lock = threading.Lock()
_embedder = None
def warmup() -> None:
"""Load the embedder eagerly. Called at startup so nomic-embed is resident
before the vision model ever loads — loading a model *after* the VLM can
fail on small GPUs, and every chat needs the embedder for recall anyway."""
_get_embedder()
def _get_embedder():
global _embedder
if _embedder is None:
import cuda_bootstrap
cuda_bootstrap.ensure()
from llama_cpp import Llama
_embedder = Llama.from_pretrained(
repo_id=EMBED_REPO,
filename=EMBED_FILE,
embedding=True,
n_ctx=2048,
n_gpu_layers=config.GPU_LAYERS,
verbose=False,
)
return _embedder
def embed(text: str, is_query: bool = False) -> np.ndarray:
# nomic-embed expects task prefixes
prefixed = ("search_query: " if is_query else "search_document: ") + text
with _lock:
out = _get_embedder().create_embedding(prefixed)
vec = np.asarray(out["data"][0]["embedding"], dtype=np.float32)
norm = np.linalg.norm(vec)
return vec / norm if norm > 0 else vec
def _load_store() -> list[dict]:
if not os.path.exists(STORE_PATH):
return []
with open(STORE_PATH, "r", encoding="utf-8") as f:
notes = json.load(f)
# Backfill ids on notes written before note management existed.
if any(not n.get("id") for n in notes):
for n in notes:
if not n.get("id"):
n["id"] = uuid.uuid4().hex[:8]
_save_store(notes)
return notes
def _save_store(notes: list[dict]) -> None:
os.makedirs(DATA_DIR, exist_ok=True)
with open(STORE_PATH, "w", encoding="utf-8") as f:
json.dump(notes, f, ensure_ascii=False)
def remember(text: str, kind: str = "fact", meta: dict | None = None) -> dict:
"""Embed and persist a long-term note. kind: fact|event|preference."""
note = {
"id": hashlib.md5(f"{text}{time.time()}".encode()).hexdigest()[:12],
"text": text,
"kind": kind,
"ts": time.time(),
"meta": meta or {},
"vec": embed(text).tolist(),
}
with _lock:
notes = _load_store()
# skip exact-duplicate texts
if any(n["text"] == text for n in notes):
return note
notes.append(note)
_save_store(notes)
return note
def recall(query: str, k: int = 5, kind: str | None = None) -> list[dict]:
"""Top-k notes by cosine similarity; returns [{text, kind, score}]."""
with _lock:
notes = _load_store()
if kind:
notes = [n for n in notes if n["kind"] == kind]
if not notes:
return []
q = embed(query, is_query=True)
# Self-heal: notes embedded under a different embedder (e.g. a store from
# an older build) have the wrong vector dimension — re-embed them.
stale = [n for n in notes if len(n["vec"]) != len(q)]
if stale:
for n in stale:
n["vec"] = embed(n["text"]).tolist()
with _lock:
current = _load_store()
by_id = {n["id"]: n["vec"] for n in notes}
for n in current:
if n["id"] in by_id:
n["vec"] = by_id[n["id"]]
_save_store(current)
mat = np.asarray([n["vec"] for n in notes], dtype=np.float32)
scores = mat @ q
order = np.argsort(-scores)[:k]
return [
{"text": notes[i]["text"], "kind": notes[i]["kind"], "score": float(scores[i])}
for i in order
if scores[i] > 0
]
def list_notes() -> list[dict]:
"""All long-term notes without vectors: [{id, text, kind}]."""
with _lock:
notes = _load_store()
return [{"id": n["id"], "text": n["text"], "kind": n["kind"]} for n in notes]
def update_note(note_id: str, text: str) -> bool:
"""Replace a note's text (re-embeds; kind/meta preserved). True if found."""
vec = embed(text).tolist()
with _lock:
notes = _load_store()
for n in notes:
if n["id"] == note_id:
n["text"] = text
n["vec"] = vec
n["ts"] = time.time()
_save_store(notes)
return True
return False
def delete_note(note_id: str) -> bool:
with _lock:
notes = _load_store()
kept = [n for n in notes if n["id"] != note_id]
if len(kept) != len(notes):
_save_store(kept)
return True
return False
SEED_NOTES = [
("Awais is lactose-sensitive — fine with yogurt and hard cheese, avoid milk-heavy dishes", "preference"),
("Awais eats halal only and skips pork entirely", "preference"),
("Awais hates cilantro", "preference"),
("Awais aims for at least 120g of protein per day", "preference"),
("Awais's weekly grocery budget is $80 and he shops on Saturdays", "fact"),
("Awais shares streaming accounts with his roommate Hamza — Disney+ was Hamza's idea", "fact"),
("Awais signed up for FitnessPal Pro during a January resolution and stopped using it in March", "event"),
("Awais is training for a 10K in September; longest run so far is 6km", "fact"),
("Awais's left knee gets sore if he runs two days in a row — needs a rest day between runs", "preference"),
("Awais prefers lifting in the evening after classes, runs in the morning", "preference"),
("Awais's go-to cheap protein is chicken thighs and canned chickpeas", "preference"),
("Awais cooked too much pasta lately and wants more variety in meals", "event"),
("Awais gets paid around the 1st of each month — roughly $1400 from his part-time job", "fact"),
("Awais wants to save $150 a month toward a climbing gym membership", "fact"),
("Awais usually meal-preps rice in bulk on Sundays", "preference"),
]
def ensure_seeded() -> int:
"""Seed the long-term store on first run. Returns note count."""
with _lock:
existing = _load_store()
if existing:
return len(existing)
for text, kind in SEED_NOTES:
remember(text, kind=kind, meta={"seed": True})
with _lock:
return len(_load_store())
def reset_to_seed() -> int:
with _lock:
if os.path.exists(STORE_PATH):
os.remove(STORE_PATH)
return ensure_seeded()
def reset_to_empty() -> int:
"""Delete all long-term notes (real-user reset). Returns 0."""
with _lock:
if os.path.exists(STORE_PATH):
os.remove(STORE_PATH)
return 0