Skip to content

Commit e97eafa

Browse files
phernandezclaude
andauthored
fix: build_context related_results schema validation failure (#631)
Signed-off-by: phernandez <paul@basicmachines.co> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 254e304 commit e97eafa

15 files changed

Lines changed: 352 additions & 205 deletions

src/basic_memory/alembic/versions/j3d4e5f6g7h8_rename_entity_type_to_note_type.py

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,7 @@ def table_exists(connection, table_name: str) -> bool:
2222
"""Check if a table exists (idempotent migration support)."""
2323
if connection.dialect.name == "postgresql":
2424
result = connection.execute(
25-
text(
26-
"SELECT 1 FROM information_schema.tables "
27-
"WHERE table_name = :table_name"
28-
),
25+
text("SELECT 1 FROM information_schema.tables WHERE table_name = :table_name"),
2926
{"table_name": table_name},
3027
)
3128
return result.fetchone() is not None

src/basic_memory/mcp/prompts/continue_conversation.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,7 @@ def _format_continuation_results(result: SearchResponse, topic: str) -> str:
125125
lines.append(f"### {title}")
126126
if permalink:
127127
lines.append(f"permalink: {permalink}")
128-
lines.append(f"Read with: `read_note(\"{permalink}\")`")
128+
lines.append(f'Read with: `read_note("{permalink}")`')
129129
if item.content:
130130
content = item.content[:300] + "..." if len(item.content) > 300 else item.content
131131
lines.append(f"\n{content}")

src/basic_memory/mcp/tools/build_context.py

Lines changed: 4 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -22,74 +22,6 @@
2222
RelationSummary,
2323
)
2424

25-
# --- Fields to strip from each model (redundant with parent entity) ---
26-
27-
_OBSERVATION_STRIP = {
28-
"observation_id",
29-
"entity_id",
30-
"entity_external_id",
31-
"title",
32-
"file_path",
33-
"created_at",
34-
}
35-
_RELATION_STRIP = {
36-
"relation_id",
37-
"entity_id",
38-
"from_entity_id",
39-
"from_entity_external_id",
40-
"to_entity_id",
41-
"to_entity_external_id",
42-
"title",
43-
"file_path",
44-
"created_at",
45-
}
46-
_ENTITY_STRIP = {"entity_id", "created_at"}
47-
_METADATA_STRIP = {"total_results", "generated_at"}
48-
49-
50-
def _slim_summary(summary: EntitySummary | RelationSummary | ObservationSummary) -> dict:
51-
"""Strip redundant fields from a summary model based on its type."""
52-
if isinstance(summary, ObservationSummary):
53-
strip = _OBSERVATION_STRIP
54-
elif isinstance(summary, RelationSummary):
55-
strip = _RELATION_STRIP
56-
else:
57-
strip = _ENTITY_STRIP
58-
59-
data = summary.model_dump()
60-
for key in strip:
61-
data.pop(key, None)
62-
return data
63-
64-
65-
def _slim_context(graph: GraphContext) -> dict:
66-
"""Transform GraphContext into a slimmed dict, stripping redundant fields.
67-
68-
Reduces payload size ~40% by removing fields on nested objects that
69-
duplicate information already present on the parent entity (IDs,
70-
timestamps, file paths).
71-
"""
72-
slimmed_results = []
73-
for result in graph.results:
74-
slimmed_results.append(
75-
{
76-
"primary_result": _slim_summary(result.primary_result),
77-
"observations": [_slim_summary(obs) for obs in result.observations],
78-
"related_results": [_slim_summary(rel) for rel in result.related_results],
79-
}
80-
)
81-
82-
metadata = graph.metadata.model_dump()
83-
for key in _METADATA_STRIP:
84-
metadata.pop(key, None)
85-
86-
return {
87-
"results": slimmed_results,
88-
"metadata": metadata,
89-
"page": graph.page,
90-
"page_size": graph.page_size,
91-
}
92-
9325

9426
def _format_entity_block(result: ContextResult) -> str:
9527
"""Format a single context result as a markdown block."""
@@ -194,7 +126,7 @@ def _format_context_markdown(graph: GraphContext, project: str) -> str:
194126
- Or standard formats like "7d", "24h"
195127
196128
Format options:
197-
- "json" (default): Slimmed JSON with redundant fields removed
129+
- "json" (default): Structured JSON with internal fields excluded
198130
- "text": Compact markdown text for LLM consumption
199131
""",
200132
annotations={"readOnlyHint": True, "openWorldHint": False},
@@ -231,12 +163,12 @@ async def build_context(
231163
page: Page number of results to return (default: 1)
232164
page_size: Number of results to return per page (default: 10)
233165
max_related: Maximum number of related results to return (default: 10)
234-
output_format: Response format - "json" for slimmed JSON dict,
166+
output_format: Response format - "json" for structured JSON dict,
235167
"text" for compact markdown text
236168
context: Optional FastMCP context for performance caching.
237169
238170
Returns:
239-
dict (output_format="json"): Slimmed JSON with redundant fields removed
171+
dict (output_format="json"): Structured JSON with internal fields excluded
240172
str (output_format="text"): Compact markdown representation
241173
242174
Examples:
@@ -292,4 +224,4 @@ async def build_context(
292224
if output_format == "text":
293225
return _format_context_markdown(graph, active_project.name)
294226

295-
return _slim_context(graph)
227+
return graph.model_dump()

src/basic_memory/mcp/tools/search.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -466,7 +466,12 @@ async def search_notes(
466466
effective_query = (query or "").strip()
467467
if effective_query:
468468
valid_search_types = {
469-
"text", "title", "permalink", "vector", "semantic", "hybrid",
469+
"text",
470+
"title",
471+
"permalink",
472+
"vector",
473+
"semantic",
474+
"hybrid",
470475
}
471476
if effective_search_type == "text":
472477
search_query.text = effective_query

src/basic_memory/repository/observation_repository.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,10 @@
22

33
from typing import Dict, List, Sequence
44

5-
65
from sqlalchemy import select
76
from sqlalchemy.ext.asyncio import async_sessionmaker
7+
from sqlalchemy.orm import selectinload
8+
from sqlalchemy.orm.interfaces import LoaderOption
89

910
from basic_memory.models import Observation
1011
from basic_memory.repository.repository import Repository
@@ -22,6 +23,10 @@ def __init__(self, session_maker: async_sessionmaker, project_id: int):
2223
"""
2324
super().__init__(session_maker, Observation, project_id=project_id)
2425

26+
def get_load_options(self) -> List[LoaderOption]:
27+
"""Eager-load parent entity to prevent N+1 if obs.entity is accessed."""
28+
return [selectinload(Observation.entity)]
29+
2530
async def find_by_entity(self, entity_id: int) -> Sequence[Observation]:
2631
"""Find all observations for a specific entity."""
2732
query = select(Observation).filter(Observation.entity_id == entity_id)

src/basic_memory/repository/postgres_search_repository.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -689,9 +689,7 @@ async def search(
689689
for idx, note_type in enumerate(note_types):
690690
param_name = f"note_type_{idx}"
691691
params[param_name] = json.dumps({"note_type": note_type})
692-
type_conditions.append(
693-
f"search_index.metadata @> CAST(:{param_name} AS jsonb)"
694-
)
692+
type_conditions.append(f"search_index.metadata @> CAST(:{param_name} AS jsonb)")
695693
conditions.append(f"({' OR '.join(type_conditions)})")
696694

697695
# Handle date filter

src/basic_memory/schemas/memory.py

Lines changed: 13 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,7 @@ class EntitySummary(BaseModel):
125125

126126
type: Literal["entity"] = "entity"
127127
external_id: str # UUID for v2 API routing
128-
entity_id: int # Database ID for v2 API consistency
128+
entity_id: Optional[int] = Field(None, exclude=True) # Internal DB ID
129129
permalink: Optional[str]
130130
title: str
131131
content: Optional[str] = None
@@ -143,18 +143,18 @@ class RelationSummary(BaseModel):
143143
"""Simplified relation representation."""
144144

145145
type: Literal["relation"] = "relation"
146-
relation_id: int # Database ID for v2 API consistency
147-
entity_id: Optional[int] = None # ID of the entity this relation belongs to
146+
relation_id: Optional[int] = Field(None, exclude=True) # Internal DB ID
147+
entity_id: Optional[int] = Field(None, exclude=True) # Internal FK
148148
title: str
149149
file_path: str
150150
permalink: str
151151
relation_type: str
152152
from_entity: Optional[str] = None
153-
from_entity_id: Optional[int] = None # ID of source entity
154-
from_entity_external_id: Optional[str] = None # UUID of source entity for v2 API routing
153+
from_entity_id: Optional[int] = Field(None, exclude=True) # Internal FK
154+
from_entity_external_id: Optional[str] = Field(None, exclude=True) # Internal routing ID
155155
to_entity: Optional[str] = None
156-
to_entity_id: Optional[int] = None # ID of target entity
157-
to_entity_external_id: Optional[str] = None # UUID of target entity for v2 API routing
156+
to_entity_id: Optional[int] = Field(None, exclude=True) # Internal FK
157+
to_entity_external_id: Optional[str] = Field(None, exclude=True) # Internal routing ID
158158
created_at: Annotated[
159159
datetime, Field(json_schema_extra={"type": "string", "format": "date-time"})
160160
]
@@ -168,10 +168,10 @@ class ObservationSummary(BaseModel):
168168
"""Simplified observation representation."""
169169

170170
type: Literal["observation"] = "observation"
171-
observation_id: int # Database ID for v2 API consistency
172-
entity_id: Optional[int] = None # ID of the entity this observation belongs to
173-
entity_external_id: Optional[str] = None # UUID of parent entity for v2 API routing
174-
title: str
171+
observation_id: Optional[int] = Field(None, exclude=True) # Internal DB ID
172+
entity_id: Optional[int] = Field(None, exclude=True) # Internal FK
173+
entity_external_id: Optional[str] = Field(None, exclude=True) # Internal routing ID
174+
title: Optional[str] = Field(None, exclude=True) # Redundant with parent entity
175175
file_path: str
176176
permalink: str
177177
category: str
@@ -192,19 +192,13 @@ class MemoryMetadata(BaseModel):
192192
types: Optional[List[SearchItemType]] = None
193193
depth: int
194194
timeframe: Optional[str] = None
195-
generated_at: Annotated[
196-
datetime, Field(json_schema_extra={"type": "string", "format": "date-time"})
197-
]
195+
generated_at: Optional[datetime] = Field(None, exclude=True) # Internal timing
198196
primary_count: Optional[int] = None # Changed field name
199197
related_count: Optional[int] = None # Changed field name
200-
total_results: Optional[int] = None # For backward compatibility
198+
total_results: Optional[int] = Field(None, exclude=True) # Internal counter
201199
total_relations: Optional[int] = None
202200
total_observations: Optional[int] = None
203201

204-
@field_serializer("generated_at")
205-
def serialize_generated_at(self, dt: datetime) -> str:
206-
return dt.isoformat()
207-
208202

209203
class ContextResult(BaseModel):
210204
"""Context result containing a primary item with its observations and related items."""

src/basic_memory/services/project_service.py

Lines changed: 5 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -977,16 +977,13 @@ async def get_embedding_status(self, project_id: int) -> EmbeddingStatus:
977977
total_indexed_entities=total_indexed_entities,
978978
vector_tables_exist=False,
979979
reindex_recommended=True,
980-
reindex_reason=(
981-
"Vector tables not initialized — run: bm reindex --embeddings"
982-
),
980+
reindex_reason=("Vector tables not initialized — run: bm reindex --embeddings"),
983981
)
984982

985983
# --- Count queries (tables exist) ---
986984
si_result = await self.repository.execute_query(
987985
text(
988-
"SELECT COUNT(DISTINCT entity_id) FROM search_index "
989-
"WHERE project_id = :project_id"
986+
"SELECT COUNT(DISTINCT entity_id) FROM search_index WHERE project_id = :project_id"
990987
),
991988
{"project_id": project_id},
992989
)
@@ -1040,9 +1037,7 @@ async def get_embedding_status(self, project_id: int) -> EmbeddingStatus:
10401037
"WHERE c.project_id = :project_id AND e.rowid IS NULL"
10411038
)
10421039

1043-
orphan_result = await self.repository.execute_query(
1044-
orphan_sql, {"project_id": project_id}
1045-
)
1040+
orphan_result = await self.repository.execute_query(orphan_sql, {"project_id": project_id})
10461041
orphaned_chunks = orphan_result.scalar() or 0
10471042

10481043
# --- Reindex recommendation logic (priority order) ---
@@ -1051,9 +1046,7 @@ async def get_embedding_status(self, project_id: int) -> EmbeddingStatus:
10511046

10521047
if total_indexed_entities > 0 and total_chunks == 0:
10531048
reindex_recommended = True
1054-
reindex_reason = (
1055-
"Embeddings have never been built — run: bm reindex --embeddings"
1056-
)
1049+
reindex_reason = "Embeddings have never been built — run: bm reindex --embeddings"
10571050
elif orphaned_chunks > 0:
10581051
reindex_recommended = True
10591052
reindex_reason = (
@@ -1063,9 +1056,7 @@ async def get_embedding_status(self, project_id: int) -> EmbeddingStatus:
10631056
elif total_indexed_entities > total_entities_with_chunks:
10641057
missing = total_indexed_entities - total_entities_with_chunks
10651058
reindex_recommended = True
1066-
reindex_reason = (
1067-
f"{missing} entities missing embeddings — run: bm reindex --embeddings"
1068-
)
1059+
reindex_reason = f"{missing} entities missing embeddings — run: bm reindex --embeddings"
10691060

10701061
return EmbeddingStatus(
10711062
semantic_search_enabled=True,

test-int/cli/test_cli_tool_edit_note_integration.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -262,7 +262,14 @@ def test_edit_note_json_format_contract(app, app_config, test_project, config_ma
262262

263263
assert result.exit_code == 0, result.output
264264
data = json.loads(result.stdout)
265-
assert set(data.keys()) == {"title", "permalink", "file_path", "operation", "checksum", "fileCreated"}
265+
assert set(data.keys()) == {
266+
"title",
267+
"permalink",
268+
"file_path",
269+
"operation",
270+
"checksum",
271+
"fileCreated",
272+
}
266273
assert data["operation"] == "append"
267274
assert data["fileCreated"] is False
268275
assert data["title"] == "Edit JSON Note"

tests/api/v2/test_knowledge_router.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -836,9 +836,7 @@ async def test_delete_directory_v2_nested_structure(client: AsyncClient, v2_proj
836836

837837

838838
@pytest.mark.asyncio
839-
async def test_entity_response_includes_user_tracking_fields(
840-
client: AsyncClient, v2_project_url
841-
):
839+
async def test_entity_response_includes_user_tracking_fields(client: AsyncClient, v2_project_url):
842840
"""EntityResponseV2 includes created_by and last_updated_by fields (null for local)."""
843841
entity_data = {
844842
"title": "UserTrackingTest",

0 commit comments

Comments
 (0)