Files

209 lines
7.8 KiB
Python
Executable File

"""Dedicated memory curator service — run alongside Synapse on port 8001.
Endpoints:
GET / health check
GET /memories list all memory items (optional ?section= filter)
POST /memories direct write — no LLM, saves immediately
PATCH /memories/{id} update a memory item
DELETE /memories/{id} delete a memory item
POST /memories/extract LLM-curated: evaluate a conversation exchange and
optionally save a new permanent fact
"""
from __future__ import annotations
import asyncio
import math
import uuid
from typing import Any, Dict, List, Optional
from fastapi import FastAPI, HTTPException, Body
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel
from .store import store, MemoryItem
from .extractor import extract_memory
from ..ollama_manager import get_ollama_manager
app = FastAPI(title="Nexus Memory Service", version="1.0")
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=False,
allow_methods=["*"],
allow_headers=["*"],
)
@app.on_event("startup")
async def _warm_curator():
"""Preload the curator model (in RAM, num_gpu=0 by default) so the first
extraction isn't a cold load that blows the timeout. Runs in the background
so it never delays startup. keep_alive then holds it warm between messages."""
async def _bg():
try:
mgr = get_ollama_manager()
# Ollama is started by the Synapse backend (a separate process), so
# at our startup it usually isn't reachable yet. Wait for it before
# warming instead of failing with "All connection attempts failed" —
# which leaves the curator cold and makes the first extraction slow.
for _ in range(60): # up to ~2 min
if await asyncio.to_thread(mgr.is_running):
break
await asyncio.sleep(2)
else:
return
settings = store.get_settings()
model = settings.get("memory_model") or await mgr.select_best_model()
num_gpu = await mgr.resolve_num_gpu(settings.get("memory_gpu_offload", 0), model)
await mgr.warm(model, num_gpu=num_gpu)
except Exception:
pass
asyncio.create_task(_bg())
@app.get("/")
async def health():
return {"status": "ok", "count": len(store.all())}
@app.get("/memories")
async def list_memories(section: Optional[str] = None):
items = store.all()
if section:
items = [i for i in items if i.section.lower() == section.lower()]
return {"items": [{"id": i.id, "section": i.section, "text": i.text, "tags": i.tags} for i in items]}
@app.post("/memories")
async def add_memory(payload: Dict[str, Any] = Body(...)):
text = (payload.get("text") or "").strip()
if not text:
raise HTTPException(status_code=400, detail="Missing 'text'")
item = MemoryItem(
id=str(uuid.uuid4()),
section=(payload.get("section") or "General").strip(),
text=text,
tags=payload.get("tags", []),
)
store.add(item)
return {"id": item.id, "section": item.section, "text": item.text, "tags": item.tags}
@app.patch("/memories/{item_id}")
async def update_memory(item_id: str, payload: Dict[str, Any] = Body(...)):
existing = store.get(item_id)
if not existing:
raise HTTPException(status_code=404, detail="Not found")
updated = MemoryItem(
id=item_id,
section=(payload.get("section") or existing.section).strip(),
text=(payload.get("text") or existing.text).strip(),
tags=payload.get("tags", existing.tags),
)
store.update(updated)
return {"id": updated.id, "section": updated.section, "text": updated.text, "tags": updated.tags}
@app.delete("/memories/{item_id}")
async def delete_memory(item_id: str):
if not store.get(item_id):
raise HTTPException(status_code=404, detail="Not found")
store.delete(item_id)
return {"status": "deleted"}
def _cosine(a: list, b: list) -> float:
dot = sum(x * y for x, y in zip(a, b))
na = math.sqrt(sum(x * x for x in a))
nb = math.sqrt(sum(y * y for y in b))
return dot / (na * nb) if na and nb else 0.0
class ExtractRequest(BaseModel):
user_message: str
assistant_response: str
@app.post("/memories/extract")
async def extract_and_save(req: ExtractRequest):
"""LLM-curated extraction — asks Mistral to evaluate the exchange against all
existing memory items and save only new, permanent personal facts."""
existing = store.all()
existing_sections = list({i.section for i in existing})
existing_texts = [i.text for i in existing]
# Adaptable per-machine curator config (see store _SETTINGS_DEFAULTS):
# which model does extraction, and whether it runs on CPU/RAM or the GPU.
settings = store.get_settings()
mgr = get_ollama_manager()
model = settings.get("memory_model") or await mgr.select_best_model()
num_gpu = await mgr.resolve_num_gpu(settings.get("memory_gpu_offload", 0), model)
try:
merge_threshold = float(settings.get("memory_merge_threshold", 0.88))
except (TypeError, ValueError):
merge_threshold = 0.88
try:
results = await asyncio.wait_for(
extract_memory(
req.user_message,
req.assistant_response,
existing_sections,
existing_texts,
mgr,
model=model,
num_gpu=num_gpu,
),
timeout=300.0,
)
# Embed existing facts once so each new fact can be matched against them.
# A near-duplicate UPDATES the matched fact in place (edit with new info)
# rather than appending a copy. Best effort: if embeddings are down we
# fall back to plain append. Merge disabled unless 0 < threshold < 1.
existing_embeds: dict = {}
if results and 0 < merge_threshold < 1:
vecs = await asyncio.gather(*(mgr.embed(it.text) for it in existing))
existing_embeds = {it.id: v for it, v in zip(existing, vecs) if v}
saved = []
for result in results:
new_vec = await mgr.embed(result["text"]) if existing_embeds else None
match_id, best = None, 0.0
if new_vec:
for eid, ev in existing_embeds.items():
sim = _cosine(new_vec, ev)
if sim > best:
best, match_id = sim, eid
if best < merge_threshold:
match_id = None
target = store.get(match_id) if match_id else None
if target:
# Near-duplicate of an existing fact — overwrite with the newer
# statement, keeping the original id/section/position.
updated = MemoryItem(id=target.id, section=target.section,
text=result["text"], tags=target.tags)
store.update(updated)
if new_vec:
existing_embeds[updated.id] = new_vec # keep cache fresh for later facts in this batch
saved.append({"id": updated.id, "section": updated.section,
"text": updated.text, "updated": True})
else:
item = MemoryItem(id=str(uuid.uuid4()),
section=result["section"], text=result["text"])
store.add(item)
if new_vec:
existing_embeds[item.id] = new_vec
saved.append({"id": item.id, "section": item.section, "text": item.text})
if saved:
first = {k: saved[0][k] for k in ("id", "section", "text")}
return {"saved": True, "items": saved, **first}
except asyncio.TimeoutError:
pass
except Exception:
pass
return {"saved": False}