Skip to content
2 changes: 2 additions & 0 deletions src/basic_memory/api/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
prompt_router as v2_prompt,
importer_router as v2_importer,
schema_router as v2_schema,
inspect_router as v2_inspect,
)
import logfire
from basic_memory.index.note_content_materialization import drain_pending_materializations
Expand Down Expand Up @@ -131,6 +132,7 @@ async def workspace_permalink_context_middleware(request: Request, call_next):
app.include_router(v2_prompt, prefix="/v2/projects/{project_id}")
app.include_router(v2_importer, prefix="/v2/projects/{project_id}")
app.include_router(v2_schema, prefix="/v2/projects/{project_id}")
app.include_router(v2_inspect, prefix="/v2/projects/{project_id}")
app.include_router(v2_project, prefix="/v2")

# Legacy web app proxy paths (compat with /proxy/projects/projects)
Expand Down
2 changes: 2 additions & 0 deletions src/basic_memory/api/v2/routers/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from basic_memory.api.v2.routers.prompt_router import router as prompt_router
from basic_memory.api.v2.routers.importer_router import router as importer_router
from basic_memory.api.v2.routers.schema_router import router as schema_router
from basic_memory.api.v2.routers.inspect_router import router as inspect_router

__all__ = [
"knowledge_router",
Expand All @@ -20,4 +21,5 @@
"prompt_router",
"importer_router",
"schema_router",
"inspect_router",
]
150 changes: 150 additions & 0 deletions src/basic_memory/api/v2/routers/inspect_router.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
"""V2 router for read-only retrieval inspection."""

from typing import assert_never

from fastapi import APIRouter, HTTPException

from basic_memory.deps import (
FileServiceV2ExternalDep,
LinkResolverV2ExternalDep,
ProjectExternalIdPathDep,
SearchRepositoryV2ExternalDep,
)
from basic_memory.schemas.inspect import (
InspectChunk,
InspectChunkReadiness,
InspectChunksRequest,
InspectChunksResponse,
InspectDetachedSearchRow,
InspectIndexBehindRowsDetail,
InspectRowsBehindFileDetail,
InspectSearchRow,
)
from basic_memory.services.retrieval_inspect import (
ChunkFresh,
ChunkFreshnessUnknown,
ChunkNotIndexed,
ChunkIndexBehindRows,
ChunkRowsBehindFile,
inspect_entity_chunks,
)

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


@router.post("/chunks", response_model=InspectChunksResponse)
async def inspect_chunks(
data: InspectChunksRequest,
project_id: ProjectExternalIdPathDep,
link_resolver: LinkResolverV2ExternalDep,
search_repository: SearchRepositoryV2ExternalDep,
file_service: FileServiceV2ExternalDep,
) -> InspectChunksResponse:
"""Show how one note is represented by search rows and vector chunks."""
entity = await link_resolver.resolve_entity(
data.identifier,
load_relations=False,
)
if entity is None or entity.project_id != project_id:
raise HTTPException(status_code=404, detail=f"Entity not found: '{data.identifier}'")

inspection = await inspect_entity_chunks(search_repository, entity, file_service)
freshness_detail: InspectIndexBehindRowsDetail | InspectRowsBehindFileDetail | None
match inspection.freshness:
case ChunkFresh() | ChunkNotIndexed():
freshness_detail = None
case ChunkIndexBehindRows():
freshness_detail = InspectIndexBehindRowsDetail(
entity_fingerprint_indexed=(
list(inspection.freshness.entity_fingerprint_indexed)
if isinstance(
inspection.freshness.entity_fingerprint_indexed,
tuple,
)
else inspection.freshness.entity_fingerprint_indexed
),
entity_fingerprint_current=(inspection.freshness.entity_fingerprint_current),
missing_chunk_count=inspection.freshness.missing_chunk_count,
)
case ChunkRowsBehindFile() | ChunkFreshnessUnknown():
evidence = inspection.freshness.evidence
freshness_detail = InspectRowsBehindFileDetail(
entity_checksum=evidence.entity_checksum,
current_file_checksum=evidence.current_file_checksum,
db_checksum=evidence.db_checksum,
file_checksum=evidence.file_checksum,
file_write_status=evidence.file_write_status,
)
case unexpected: # pragma: no cover - EntityChunkFreshness is exhaustive
assert_never(unexpected)
return InspectChunksResponse(
entity_id=entity.id,
external_id=entity.external_id,
permalink=entity.permalink,
file_path=entity.file_path,
title=entity.title,
entity_checksum=entity.checksum,
configured_embedding_model=inspection.configured_identity.embedding_model,
configured_vector_index=inspection.configured_identity.vector_index,
readiness=InspectChunkReadiness(
total=inspection.readiness.total,
ready=inspection.readiness.ready,
pending=inspection.readiness.pending,
stale=inspection.readiness.stale,
orphaned=inspection.readiness.orphaned,
missing=inspection.readiness.missing,
),
entity_fingerprint_indexed=(
list(inspection.entity_fingerprint_indexed)
if isinstance(inspection.entity_fingerprint_indexed, tuple)
else inspection.entity_fingerprint_indexed
),
entity_fingerprint_current=inspection.entity_fingerprint_current,
stale=inspection.stale,
freshness=inspection.freshness.value,
freshness_detail=freshness_detail,
rows=[
InspectSearchRow(
type=inspected_row.search_row.type,
id=inspected_row.search_row.id,
title=inspected_row.search_row.title,
category=inspected_row.search_row.category,
relation_type=inspected_row.search_row.relation_type,
content_preview=inspected_row.search_row.content_snippet,
chunks=[
InspectChunk(
chunk_key=chunk.stored_row.chunk_key,
ordinal=chunk.ordinal,
text=chunk.stored_row.chunk_text,
source_hash=chunk.stored_row.source_hash,
embedding_model=chunk.stored_row.embedding_model,
vector_index=chunk.stored_row.vector_index,
status=chunk.status,
updated_at=chunk.stored_row.updated_at,
)
for chunk in inspected_row.chunks
],
)
for inspected_row in inspection.rows
],
detached=[
InspectDetachedSearchRow(
type=detached_row.row_type,
id=detached_row.row_id,
chunks=[
InspectChunk(
chunk_key=chunk.stored_row.chunk_key,
ordinal=chunk.ordinal,
text=chunk.stored_row.chunk_text,
source_hash=chunk.stored_row.source_hash,
embedding_model=chunk.stored_row.embedding_model,
vector_index=chunk.stored_row.vector_index,
status=chunk.status,
updated_at=chunk.stored_row.updated_at,
)
for chunk in detached_row.chunks
],
)
for detached_row in inspection.detached
],
)
1 change: 1 addition & 0 deletions src/basic_memory/cli/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,7 @@ def _post_command_messages() -> None:
# ('hook' returns above, before this point.)
skip_init_commands = {
"doctor",
"inspect",
"man",
"mcp",
"status",
Expand Down
2 changes: 2 additions & 0 deletions src/basic_memory/cli/commands/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import_chatgpt,
man,
tool,
inspect,
project,
config,
format,
Expand All @@ -26,6 +27,7 @@
"import_claude_projects",
"import_chatgpt",
"tool",
"inspect",
"project",
"config",
"format",
Expand Down
Loading
Loading