Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/api/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
from src.api.routes.enterprise import router as enterprise_router
from src.api.routes.health import router as health_router
from src.api.routes.memory import router as memory_router
from src.api.routes.memory import search_router as memory_search_router
from src.api.routes.memory import scrape_router as memory_scrape_router
from src.api.routes.memory_graph import router as memory_graph_router
from src.api.routes.scanner import router as scanner_router
Expand Down Expand Up @@ -155,6 +156,7 @@ async def lifespan(app: FastAPI):
# ── Routes ────────────────────────────────────────────────────────
app.include_router(health_router)
app.include_router(memory_scrape_router)
app.include_router(memory_search_router)
app.include_router(memory_router)
app.include_router(memory_graph_router)
app.include_router(code_router)
Expand Down
3 changes: 2 additions & 1 deletion src/api/routes/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from .health import router as health_router
from .memory import router as memory_router
from .memory import search_router as memory_search_router

__all__ = ["health_router", "memory_router"]
__all__ = ["health_router", "memory_router", "memory_search_router"]
68 changes: 52 additions & 16 deletions src/api/routes/memory.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@

import asyncio
import logging
import math
import threading
import time
from typing import Any, Dict, List

Expand Down Expand Up @@ -63,6 +65,11 @@
dependencies=[Depends(enforce_rate_limit)],
)

search_router = APIRouter(
tags=["memory"],
dependencies=[Depends(require_ready), Depends(enforce_rate_limit)],
)


# Helpers
def _model_name(model: Any) -> str:
Expand Down Expand Up @@ -108,6 +115,14 @@ def _error(request: Request, detail: str, code: int, elapsed_ms: float = 0) -> J
return JSONResponse(content=body.model_dump(), status_code=code)


def _safe_score(score: Any) -> float:
try:
value = float(score)
except (TypeError, ValueError):
return 0.0
return value if math.isfinite(value) else 0.0


def _detect_chat_provider(*urls: str) -> str:
for url in urls:
lowered = (url or "").lower()
Expand Down Expand Up @@ -145,8 +160,6 @@ async def _render_chat_share(url: str) -> tuple[str, str]:
# reuse it across scrape requests. The browser is thread-safe when each
# request uses its own BrowserContext.

import threading

_browser_lock = threading.Lock()
_pw_instance = None
_browser_instance = None
Expand Down Expand Up @@ -660,13 +673,14 @@ async def retrieve_memory(req: RetrieveRequest, request: Request, user: dict = D
sources=[
SourceRecord(
domain=s.domain, content=s.content,
score=round(s.score, 3), metadata=s.metadata,
score=round(_safe_score(s.score), 3), metadata=s.metadata,
)
for s in result.sources
],
confidence=result.confidence,
)
elapsed = round((time.perf_counter() - start) * 1000, 2)
pipeline.record_latency("agentic", elapsed)
return _wrap(request, data, elapsed)

except Exception as exc:
Expand All @@ -676,10 +690,15 @@ async def retrieve_memory(req: RetrieveRequest, request: Request, user: dict = D


# POST /v1/memory/search
@search_router.post(
"/search",
response_model=APIResponse,
summary="Raw semantic search across memory domains with optional answer synthesis",
)
@router.post(
"/search",
response_model=APIResponse,
summary="Raw semantic search across memory domains (no LLM answer)",
summary="Raw semantic search across memory domains with optional answer synthesis",
)
async def search_memory(req: SearchRequest, request: Request, user: dict = Depends(require_api_key)):
start = time.perf_counter()
Expand All @@ -689,17 +708,34 @@ async def search_memory(req: SearchRequest, request: Request, user: dict = Depen
user_id = user.get("username") or user.get("name") or user["id"]

try:
all_results: List[SourceRecord] = []

if "profile" in req.domains:
all_results.extend(_search_profile(pipeline, user_id))
if "temporal" in req.domains:
all_results.extend(_search_temporal(pipeline, req.query, user_id, req.top_k))
if "summary" in req.domains:
all_results.extend(await _search_summary(pipeline, req.query, user_id, req.top_k))
all_results = await pipeline.search_raw(
query=req.query,
user_id=user_id,
domains=req.domains,
top_k=req.top_k,
)
answer = ""
if req.answer:
answer = await pipeline.answer_from_sources(req.query, all_results)

data = SearchResponse(results=all_results, total=len(all_results))
elapsed = round((time.perf_counter() - start) * 1000, 2)
pipeline.record_latency("answer" if req.answer else "raw", elapsed)
data = SearchResponse(
results=[
SourceRecord(
domain=s.domain,
content=s.content,
score=round(_safe_score(s.score), 3),
metadata=s.metadata,
)
for s in all_results
],
total=len(all_results),
answer=answer,
model=_model_name(pipeline.model) if req.answer else "",
confidence=min(1.0, len(all_results) * 0.2) if answer else 0.0,
latency=pipeline.get_latency_snapshot(),
)
return _wrap(request, data, elapsed)

except Exception as exc:
Expand All @@ -713,7 +749,7 @@ def _search_profile(pipeline: RetrievalPipeline, user_id: str) -> List[SourceRec
raw = pipeline.vector_store.search_by_metadata(
filters={"user_id": user_id, "domain": "profile"}, top_k=100,
)
return [SourceRecord(domain="profile", content=r.content, score=r.score, metadata=r.metadata) for r in raw]
return [SourceRecord(domain="profile", content=r.content, score=_safe_score(r.score), metadata=r.metadata) for r in raw]
except Exception as exc:
logger.warning("Profile search error: %s", exc)
return []
Expand All @@ -740,7 +776,7 @@ def _search_temporal(pipeline: RetrievalPipeline, query: str, user_id: str, top_
parts.append(f"Time: {ev['time']}")
results.append(SourceRecord(
domain="temporal", content=" | ".join(parts),
score=ev.get("similarity_score", 0.0), metadata=ev,
score=_safe_score(ev.get("similarity_score", 0.0)), metadata=ev,
))
return results
except Exception as exc:
Expand All @@ -755,7 +791,7 @@ async def _search_summary(pipeline: RetrievalPipeline, query: str, user_id: str,
filters={"user_id": user_id, "domain": "summary"},
)
return [
SourceRecord(domain="summary", content=r.content, score=r.score, metadata={"id": r.id, **r.metadata})
SourceRecord(domain="summary", content=r.content, score=_safe_score(r.score), metadata={"id": r.id, **r.metadata})
for r in raw
]
except Exception as exc:
Expand Down
13 changes: 10 additions & 3 deletions src/api/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@

from __future__ import annotations

from datetime import datetime
from enum import Enum
from typing import Any, Dict, List, Optional

Expand Down Expand Up @@ -159,15 +158,19 @@ class SearchRequest(BaseModel):
..., min_length=1, max_length=256, pattern=r"^[\w.\-@]+$",
)
domains: List[str] = Field(
default=["profile", "temporal", "summary"],
default=["profile", "temporal", "summary", "snippet", "code"],
description="Which memory domains to search",
)
top_k: int = Field(default=10, ge=1, le=100)
answer: bool = Field(
default=False,
description="When true, synthesize an answer from the raw hits without agentic tool selection.",
)

@field_validator("domains")
@classmethod
def validate_domains(cls, v: List[str]) -> List[str]:
allowed = {"profile", "temporal", "summary"}
allowed = {"profile", "temporal", "summary", "snippet", "code"}
for d in v:
if d not in allowed:
raise ValueError(f"Invalid domain '{d}'. Allowed: {allowed}")
Expand All @@ -177,6 +180,10 @@ def validate_domains(cls, v: List[str]) -> List[str]:
class SearchResponse(BaseModel):
results: List[SourceRecord] = Field(default_factory=list)
total: int = 0
answer: str = ""
model: str = ""
confidence: float = 0.0
latency: Dict[str, Dict[str, float | int]] = Field(default_factory=dict)


# ── Scrape (extract from shared chat links) ────────────────────────────────
Expand Down
Loading
Loading