odysseus/routes/memory/memory_graph_routes.py
yakamoz221 4e422619e1 fix(memory-graph): align owner resolution with rest of memory routes
get_memory_graph/neighbors used require_user() (returns "" when auth
is disabled/bypassed) while every other memory route uses
get_current_user() via a local _owner() helper (returns None in that
case). MemoryManager.load() and _verify_memory_owner() both special-case
None as "no filter / bypass" but treat "" as a real owner to match
against — and add_entry() never stamps owner="" on new entries. Net
effect: in single-user/no-auth mode the graph endpoint always returned
zero nodes and the links endpoints always 404'd. Found while seeding
real data through the API to visually verify Milestone 2 — switching
to _owner() (get_current_user) matches memory_routes.py's convention
and fixes both endpoints.
2026-07-31 00:49:59 +03:00

150 lines
6.2 KiB
Python

# routes/memory/memory_graph_routes.py
"""Memory Graph View endpoints: read-only graph derivation plus manual
relationship (link) editing between a user's own memories.
Kept as a separate router (not folded into memory_routes.py's wildcard-heavy
router) but MUST be included in app.py before that router — see the comment
at the include_router call site. `GET /api/memory/graph` would otherwise be
swallowed by memory_routes.py's `GET /api/memory/{memory_id}` wildcard if
that router's routes were checked first.
"""
from typing import Dict, List, Optional
import logging
from fastapi import APIRouter, HTTPException, Query, Request
from services.memory import MemoryManager
from src.auth_helpers import get_current_user, require_privilege
from src.memory_graph import (
DEFAULT_MAX_EDGES_PER_NODE,
DEFAULT_MIN_SIMILARITY,
build_graph,
)
logger = logging.getLogger(__name__)
def setup_memory_graph_routes(memory_manager: MemoryManager, memory_vector=None):
"""Set up Memory Graph View routes."""
router = APIRouter(prefix="/api/memory", tags=["memory-graph"])
def _owner(request: Request) -> Optional[str]:
return get_current_user(request)
def _verify_memory_owner(memory: dict, user: Optional[str]):
"""Raise 404 if user doesn't own this memory. Mirrors
memory_routes.py's _verify_memory_owner: strict ownership so a
legacy/null-owner memory never leaks across accounts."""
if user is None:
return # Auth disabled
if memory.get("owner") != user:
raise HTTPException(404, "Memory not found")
@router.get("/graph")
def get_memory_graph(
request: Request,
category: Optional[List[str]] = Query(None),
min_similarity: float = Query(DEFAULT_MIN_SIMILARITY, ge=0.0, le=1.0),
max_edges_per_node: int = Query(DEFAULT_MAX_EDGES_PER_NODE, ge=1, le=50),
include_session_edges: bool = Query(True),
include_manual_edges: bool = Query(True),
limit: int = Query(1000, ge=1, le=5000),
):
"""Return the caller's own memories as a derived node/edge graph."""
user = _owner(request)
memories = memory_manager.load(owner=user)
return build_graph(
memories,
memory_vector,
categories=category,
min_similarity=min_similarity,
max_edges_per_node=max_edges_per_node,
include_session_edges=include_session_edges,
include_manual_edges=include_manual_edges,
limit=limit,
)
@router.get("/graph/{memory_id}/neighbors")
def get_memory_graph_neighbors(
request: Request,
memory_id: str,
min_similarity: float = Query(DEFAULT_MIN_SIMILARITY, ge=0.0, le=1.0),
max_edges_per_node: int = Query(DEFAULT_MAX_EDGES_PER_NODE, ge=1, le=50),
):
"""Lazy drill-down: one node plus its immediate derived neighbors.
For graphs too large to render whole (see build_graph's `limit`/
`truncated`), the frontend can expand a single node on demand instead
of the server ever needing to compute/return the entire graph.
"""
user = _owner(request)
memories = memory_manager.load(owner=user)
target = next((m for m in memories if m.get("id") == memory_id), None)
if target is None:
raise HTTPException(404, "Memory not found")
_verify_memory_owner(target, user)
full = build_graph(
memories,
memory_vector,
min_similarity=min_similarity,
max_edges_per_node=max_edges_per_node,
limit=len(memories) or 1,
)
neighbor_ids = {
(e["target"] if e["source"] == memory_id else e["source"])
for e in full["edges"]
if memory_id in (e["source"], e["target"])
}
neighbor_ids.add(memory_id)
nodes = [n for n in full["nodes"] if n["id"] in neighbor_ids]
edges = [e for e in full["edges"] if e["source"] in neighbor_ids and e["target"] in neighbor_ids]
return {"nodes": nodes, "edges": edges, "meta": {"node_count": len(nodes), "edge_count": len(edges)}}
@router.post("/{memory_id}/links")
def add_memory_link(request: Request, memory_id: str, target_id: str = Query(...)):
"""Create an explicit manual relationship between two of the caller's
own memories (the Memory Graph View's "draw a link" affordance)."""
require_privilege(request, "can_manage_memory")
user = _owner(request)
if target_id == memory_id:
raise HTTPException(400, "A memory cannot link to itself")
all_mem = memory_manager.load_all()
source = next((m for m in all_mem if m.get("id") == memory_id), None)
if source is None:
raise HTTPException(404, "Memory not found")
_verify_memory_owner(source, user)
target = next((m for m in all_mem if m.get("id") == target_id), None)
if target is None:
raise HTTPException(404, "Target memory not found")
_verify_memory_owner(target, user)
links = list(source.get("links") or [])
if target_id not in links:
links.append(target_id)
source["links"] = links
memory_manager.save(all_mem)
return {"ok": True, "links": links}
@router.delete("/{memory_id}/links/{target_id}")
def remove_memory_link(request: Request, memory_id: str, target_id: str):
"""Remove a manual relationship. Idempotent — removing a link that
doesn't exist is not an error, matching how memory delete/pin already
treat repeat calls as harmless in this codebase."""
require_privilege(request, "can_manage_memory")
user = _owner(request)
all_mem = memory_manager.load_all()
source = next((m for m in all_mem if m.get("id") == memory_id), None)
if source is None:
raise HTTPException(404, "Memory not found")
_verify_memory_owner(source, user)
links = list(source.get("links") or [])
if target_id in links:
links = [l for l in links if l != target_id]
source["links"] = links
memory_manager.save(all_mem)
return {"ok": True, "links": links}
return router