forked from enderofwings/NexusOS
Initial commit: NexusOS - local AI assistant platform
This commit is contained in:
@@ -0,0 +1,208 @@
|
||||
"""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}
|
||||
Reference in New Issue
Block a user