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
51 changes: 51 additions & 0 deletions src/basic_memory/api/v2/routers/inspect_router.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,34 +4,85 @@

from fastapi import APIRouter, HTTPException

from basic_memory.api.v2.utils import get_entities_by_id_lookup
from basic_memory.deps import (
EntityServiceV2ExternalDep,
FileServiceV2ExternalDep,
LinkResolverV2ExternalDep,
ProjectExternalIdPathDep,
SearchRepositoryV2ExternalDep,
SearchServiceV2ExternalDep,
)
from basic_memory.repository.semantic_errors import (
RerankProviderContractError,
RerankTransientError,
SemanticDependenciesMissingError,
SemanticSearchDisabledError,
)
from basic_memory.repository.search_trace import HybridQueryTrace, VectorQueryTrace
from basic_memory.schemas.inspect import (
InspectChunk,
InspectChunkReadiness,
InspectChunksRequest,
InspectChunksResponse,
InspectDetachedSearchRow,
InspectIndexBehindRowsDetail,
InspectQueryRequest,
InspectQueryResponse,
InspectRowsBehindFileDetail,
InspectSearchRow,
query_trace_response,
)
from basic_memory.services.retrieval_inspect import (
ChunkFresh,
ChunkFreshnessUnknown,
ChunkNotIndexed,
ChunkIndexBehindRows,
ChunkRowsBehindFile,
explain_query,
inspect_entity_chunks,
)

router = APIRouter(prefix="/inspect", tags=["inspect"])


@router.post("/query", response_model=InspectQueryResponse)
async def inspect_query(
data: InspectQueryRequest,
project_id: ProjectExternalIdPathDep,
entity_service: EntityServiceV2ExternalDep,
search_service: SearchServiceV2ExternalDep,
) -> InspectQueryResponse:
"""Run one search and return the trace captured by that exact execution."""
del project_id # Route resolution scopes the injected search service.
try:
trace = await explain_query(
search_service,
data.query,
limit=data.limit,
offset=data.offset,
)
except (SemanticSearchDisabledError, SemanticDependenciesMissingError, ValueError) as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except RerankTransientError as exc:
raise HTTPException(status_code=503, detail=str(exc)) from exc
except RerankProviderContractError as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc

# Nullable-owner rows stay inspectable; only known owners get external-id enrichment.
entity_ids = {result.entity_id for result in trace.final if result.entity_id is not None}
if isinstance(trace, (VectorQueryTrace, HybridQueryTrace)):
entity_ids.update(rejection.entity_id for rejection in trace.vector.drops)
entity_ids.update(
match.entity_id for match in trace.vector.chunk_matches if match.entity_id is not None
)
entities_by_id = await get_entities_by_id_lookup(entity_service, sorted(entity_ids))
external_ids_by_entity_id = {
entity_id: str(entity.external_id) for entity_id, entity in entities_by_id.items()
}
return query_trace_response(trace, external_ids_by_entity_id)


@router.post("/chunks", response_model=InspectChunksResponse)
async def inspect_chunks(
data: InspectChunksRequest,
Expand Down
18 changes: 15 additions & 3 deletions src/basic_memory/api/v2/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,17 @@ class EntityServiceBatchLookup(Protocol):
async def get_entities_by_id(self, ids: List[int]) -> Sequence[Any]: ...


async def get_entities_by_id_lookup(
entity_service: EntityServiceBatchLookup,
entity_ids: Sequence[int],
) -> dict[int, Any]:
"""Fetch an entity batch once and index it by internal identity."""
if not entity_ids:
return {}
entities = await entity_service.get_entities_by_id(list(entity_ids))
return {entity.id: entity for entity in entities}


def _required_str(value: str | None, field_name: str) -> str:
"""Return a required search field or fail before producing invalid response data."""
if value is None:
Expand Down Expand Up @@ -233,9 +244,10 @@ async def to_search_results(
phase="fetch_entities",
result_count=len(all_entity_ids),
):
if all_entity_ids:
entities = await entity_service.get_entities_by_id(list(all_entity_ids))
entities_by_id = {e.id: e for e in entities}
entities_by_id = await get_entities_by_id_lookup(
entity_service,
list(all_entity_ids),
)

search_results = []
with logfire.span(
Expand Down
Loading
Loading