Skip to content

Commit 9b23557

Browse files
authored
feat(cli): add inspect chunks note-level retrieval view (#1249)
Signed-off-by: phernandez <paul@basicmachines.co>
1 parent a7a8bfb commit 9b23557

24 files changed

Lines changed: 3332 additions & 41 deletions

src/basic_memory/api/app.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
prompt_router as v2_prompt,
2020
importer_router as v2_importer,
2121
schema_router as v2_schema,
22+
inspect_router as v2_inspect,
2223
)
2324
import logfire
2425
from basic_memory.index.note_content_materialization import drain_pending_materializations
@@ -131,6 +132,7 @@ async def workspace_permalink_context_middleware(request: Request, call_next):
131132
app.include_router(v2_prompt, prefix="/v2/projects/{project_id}")
132133
app.include_router(v2_importer, prefix="/v2/projects/{project_id}")
133134
app.include_router(v2_schema, prefix="/v2/projects/{project_id}")
135+
app.include_router(v2_inspect, prefix="/v2/projects/{project_id}")
134136
app.include_router(v2_project, prefix="/v2")
135137

136138
# Legacy web app proxy paths (compat with /proxy/projects/projects)

src/basic_memory/api/v2/routers/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from basic_memory.api.v2.routers.prompt_router import router as prompt_router
1010
from basic_memory.api.v2.routers.importer_router import router as importer_router
1111
from basic_memory.api.v2.routers.schema_router import router as schema_router
12+
from basic_memory.api.v2.routers.inspect_router import router as inspect_router
1213

1314
__all__ = [
1415
"knowledge_router",
@@ -20,4 +21,5 @@
2021
"prompt_router",
2122
"importer_router",
2223
"schema_router",
24+
"inspect_router",
2325
]
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
"""V2 router for read-only retrieval inspection."""
2+
3+
from typing import assert_never
4+
5+
from fastapi import APIRouter, HTTPException
6+
7+
from basic_memory.deps import (
8+
FileServiceV2ExternalDep,
9+
LinkResolverV2ExternalDep,
10+
ProjectExternalIdPathDep,
11+
SearchRepositoryV2ExternalDep,
12+
)
13+
from basic_memory.schemas.inspect import (
14+
InspectChunk,
15+
InspectChunkReadiness,
16+
InspectChunksRequest,
17+
InspectChunksResponse,
18+
InspectDetachedSearchRow,
19+
InspectIndexBehindRowsDetail,
20+
InspectRowsBehindFileDetail,
21+
InspectSearchRow,
22+
)
23+
from basic_memory.services.retrieval_inspect import (
24+
ChunkFresh,
25+
ChunkFreshnessUnknown,
26+
ChunkNotIndexed,
27+
ChunkIndexBehindRows,
28+
ChunkRowsBehindFile,
29+
inspect_entity_chunks,
30+
)
31+
32+
router = APIRouter(prefix="/inspect", tags=["inspect"])
33+
34+
35+
@router.post("/chunks", response_model=InspectChunksResponse)
36+
async def inspect_chunks(
37+
data: InspectChunksRequest,
38+
project_id: ProjectExternalIdPathDep,
39+
link_resolver: LinkResolverV2ExternalDep,
40+
search_repository: SearchRepositoryV2ExternalDep,
41+
file_service: FileServiceV2ExternalDep,
42+
) -> InspectChunksResponse:
43+
"""Show how one note is represented by search rows and vector chunks."""
44+
entity = await link_resolver.resolve_entity(
45+
data.identifier,
46+
load_relations=False,
47+
)
48+
if entity is None or entity.project_id != project_id:
49+
raise HTTPException(status_code=404, detail=f"Entity not found: '{data.identifier}'")
50+
51+
inspection = await inspect_entity_chunks(search_repository, entity, file_service)
52+
freshness_detail: InspectIndexBehindRowsDetail | InspectRowsBehindFileDetail | None
53+
match inspection.freshness:
54+
case ChunkFresh() | ChunkNotIndexed():
55+
freshness_detail = None
56+
case ChunkIndexBehindRows():
57+
freshness_detail = InspectIndexBehindRowsDetail(
58+
entity_fingerprint_indexed=(
59+
list(inspection.freshness.entity_fingerprint_indexed)
60+
if isinstance(
61+
inspection.freshness.entity_fingerprint_indexed,
62+
tuple,
63+
)
64+
else inspection.freshness.entity_fingerprint_indexed
65+
),
66+
entity_fingerprint_current=(inspection.freshness.entity_fingerprint_current),
67+
missing_chunk_count=inspection.freshness.missing_chunk_count,
68+
)
69+
case ChunkRowsBehindFile() | ChunkFreshnessUnknown():
70+
evidence = inspection.freshness.evidence
71+
freshness_detail = InspectRowsBehindFileDetail(
72+
entity_checksum=evidence.entity_checksum,
73+
current_file_checksum=evidence.current_file_checksum,
74+
db_checksum=evidence.db_checksum,
75+
file_checksum=evidence.file_checksum,
76+
file_write_status=evidence.file_write_status,
77+
)
78+
case unexpected: # pragma: no cover - EntityChunkFreshness is exhaustive
79+
assert_never(unexpected)
80+
return InspectChunksResponse(
81+
entity_id=entity.id,
82+
external_id=entity.external_id,
83+
permalink=entity.permalink,
84+
file_path=entity.file_path,
85+
title=entity.title,
86+
entity_checksum=entity.checksum,
87+
configured_embedding_model=inspection.configured_identity.embedding_model,
88+
configured_vector_index=inspection.configured_identity.vector_index,
89+
readiness=InspectChunkReadiness(
90+
total=inspection.readiness.total,
91+
ready=inspection.readiness.ready,
92+
pending=inspection.readiness.pending,
93+
stale=inspection.readiness.stale,
94+
orphaned=inspection.readiness.orphaned,
95+
missing=inspection.readiness.missing,
96+
),
97+
entity_fingerprint_indexed=(
98+
list(inspection.entity_fingerprint_indexed)
99+
if isinstance(inspection.entity_fingerprint_indexed, tuple)
100+
else inspection.entity_fingerprint_indexed
101+
),
102+
entity_fingerprint_current=inspection.entity_fingerprint_current,
103+
stale=inspection.stale,
104+
freshness=inspection.freshness.value,
105+
freshness_detail=freshness_detail,
106+
rows=[
107+
InspectSearchRow(
108+
type=inspected_row.search_row.type,
109+
id=inspected_row.search_row.id,
110+
title=inspected_row.search_row.title,
111+
category=inspected_row.search_row.category,
112+
relation_type=inspected_row.search_row.relation_type,
113+
content_preview=inspected_row.search_row.content_snippet,
114+
chunks=[
115+
InspectChunk(
116+
chunk_key=chunk.stored_row.chunk_key,
117+
ordinal=chunk.ordinal,
118+
text=chunk.stored_row.chunk_text,
119+
source_hash=chunk.stored_row.source_hash,
120+
embedding_model=chunk.stored_row.embedding_model,
121+
vector_index=chunk.stored_row.vector_index,
122+
status=chunk.status,
123+
updated_at=chunk.stored_row.updated_at,
124+
)
125+
for chunk in inspected_row.chunks
126+
],
127+
)
128+
for inspected_row in inspection.rows
129+
],
130+
detached=[
131+
InspectDetachedSearchRow(
132+
type=detached_row.row_type,
133+
id=detached_row.row_id,
134+
chunks=[
135+
InspectChunk(
136+
chunk_key=chunk.stored_row.chunk_key,
137+
ordinal=chunk.ordinal,
138+
text=chunk.stored_row.chunk_text,
139+
source_hash=chunk.stored_row.source_hash,
140+
embedding_model=chunk.stored_row.embedding_model,
141+
vector_index=chunk.stored_row.vector_index,
142+
status=chunk.status,
143+
updated_at=chunk.stored_row.updated_at,
144+
)
145+
for chunk in detached_row.chunks
146+
],
147+
)
148+
for detached_row in inspection.detached
149+
],
150+
)

src/basic_memory/cli/app.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,7 @@ def _post_command_messages() -> None:
127127
# ('hook' returns above, before this point.)
128128
skip_init_commands = {
129129
"doctor",
130+
"inspect",
130131
"man",
131132
"mcp",
132133
"status",

src/basic_memory/cli/commands/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import_chatgpt,
77
man,
88
tool,
9+
inspect,
910
project,
1011
config,
1112
format,
@@ -26,6 +27,7 @@
2627
"import_claude_projects",
2728
"import_chatgpt",
2829
"tool",
30+
"inspect",
2931
"project",
3032
"config",
3133
"format",

0 commit comments

Comments
 (0)