Skip to content
Merged
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
48 changes: 48 additions & 0 deletions automem/api/recall.py
Original file line number Diff line number Diff line change
Expand Up @@ -605,6 +605,52 @@ def _apply_current_state_filter(
return filtered_results, state_filter


def _hydrate_missing_summaries_from_graph(
results: List[Dict[str, Any]], graph: Any, logger: Any
) -> None:
"""Attach existing graph summaries to result payloads that came from Qdrant."""
if graph is None or not results:
return

results_by_id: Dict[str, Dict[str, Any]] = {}
memory_ids: List[str] = []
for result in results:
memory = result.get("memory")
if not isinstance(memory, dict) or memory.get("summary"):
continue
Comment on lines +619 to +620
memory_id = _result_memory_id(result)
if not memory_id or memory_id in results_by_id:
continue
results_by_id[memory_id] = result
memory_ids.append(memory_id)

if not memory_ids:
return

try:
records = graph.query(
"""
MATCH (m:Memory)
WHERE m.id IN $ids AND m.summary IS NOT NULL
RETURN m.id, m.summary
""",
{"ids": memory_ids},
)
except Exception:
logger.debug("Failed to hydrate recall summaries from graph", exc_info=True)
return

for row in getattr(records, "result_set", []) or []:
if len(row) < 2 or row[1] is None:
continue
result = results_by_id.get(str(row[0] or ""))
if result is None:
continue
memory = dict(result.get("memory") or {})
memory["summary"] = row[1]
result["memory"] = memory


def _split_multi_value(raw: Any) -> List[str]:
if raw is None:
return []
Expand Down Expand Up @@ -1979,6 +2025,8 @@ def _run_single_query(
if min_score is not None and min_score > 0:
results = [r for r in results if float(r.get("final_score", 0.0)) >= min_score]

_hydrate_missing_summaries_from_graph(results, graph, logger)

# JIT-enrich unenriched memories inline (cheap: entities + summary ~50ms each)
jit_enriched_count = 0
if jit_enrich_fn is not None:
Expand Down
13 changes: 13 additions & 0 deletions tests/support/fake_graph.py
Original file line number Diff line number Diff line change
Expand Up @@ -298,6 +298,19 @@ def query(self, query: str, params: Dict[str, Any] | None = None, **kwargs: Any)
deleted.append(memory_id)
return FakeResult([[memory_id] for memory_id in deleted])

if (
"MATCH (m:Memory)" in query
and "WHERE m.id IN $ids" in query
and "RETURN m.id, m.summary" in query
):
rows = []
for memory_id in params.get("ids") or []:
memory = self.memories.get(str(memory_id))
if memory is None or memory.get("summary") is None:
continue
rows.append([str(memory_id), memory.get("summary")])
return FakeResult(rows)

# Search by exact tag pattern
if "MATCH (m:Memory)" in query and "$tag IN m.tags" in query:
tag = str(params.get("tag") or "").strip().lower()
Expand Down
85 changes: 85 additions & 0 deletions tests/test_api_endpoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -1277,6 +1277,91 @@ def custom_search(
assert data["results"][0]["score_components"]["keyword"] == 1.0


def test_recall_semantic_query_hydrates_summary_from_graph(mock_state):
memory_with_summary_id = "11111111-1111-1111-1111-111111111111"
memory_without_summary_id = "22222222-2222-2222-2222-222222222222"

for memory_id, summary in (
(memory_with_summary_id, "Stored graph summary for issue 180."),
(memory_without_summary_id, None),
):
mock_state.memory_graph.memories[memory_id] = {
"id": memory_id,
"content": f"Issue 180 semantic recall memory {memory_id}",
"summary": summary,
"tags": ["issue180"],
"tag_prefixes": ["issue180"],
"importance": 0.8,
"timestamp": utc_now(),
"type": "Context",
"confidence": 0.9,
"metadata": "{}",
}
mock_state.qdrant.points[memory_id] = {
"vector": [0.1] * 3,
"payload": {
"id": memory_id,
"content": f"Issue 180 semantic recall memory {memory_id}",
"tags": ["issue180"],
"tag_prefixes": ["issue180"],
"importance": 0.8,
"timestamp": utc_now(),
"type": "Context",
"confidence": 0.9,
"metadata": {},
},
}

def custom_search(
collection_name: str,
query_vector: list[float],
limit: int = 5,
*,
with_payload: bool = True,
with_vectors: bool = False,
query_filter=None,
) -> list[Any]:
_ = collection_name, query_vector, limit, with_payload, with_vectors, query_filter
return [
SimpleNamespace(
id=memory_with_summary_id,
score=0.91,
payload=mock_state.qdrant.points[memory_with_summary_id]["payload"],
),
SimpleNamespace(
id=memory_without_summary_id,
score=0.9,
payload=mock_state.qdrant.points[memory_without_summary_id]["payload"],
),
]

mock_state.qdrant.search = custom_search

with app.app.test_request_context("/recall?query=issue180&limit=2&current_only=false"):
response = handle_recall(
get_memory_graph=lambda: mock_state.memory_graph,
get_qdrant_client=lambda: mock_state.qdrant,
normalize_tag_list=app._normalize_tag_list,
normalize_timestamp=app._normalize_timestamp,
parse_time_expression=app._parse_time_expression,
extract_keywords=app._extract_keywords,
compute_metadata_score=app._compute_metadata_score,
result_passes_filters=app._result_passes_filters,
graph_keyword_search=app._graph_keyword_search,
vector_search=app._vector_search,
vector_filter_only_tag_search=app._vector_filter_only_tag_search,
recall_max_limit=50,
logger=Mock(),
jit_enrich_fn=None,
)

data = response.get_json()
results_by_id = {result["id"]: result["memory"] for result in data["results"]}

assert results_by_id[memory_with_summary_id]["summary"] == "Stored graph summary for issue 180."
assert "summary" not in results_by_id[memory_without_summary_id]


def test_recall_adaptive_floor_keeps_clustered_relevant_tail():
data = _call_handle_recall_for_scores(
"AutoJack",
Expand Down
Loading