fleet-memory/hindsight-api-slim/tests/test_document_tracking.py
Nicolò Boschi 15ea23d5d6
feat: introduce hindsight-api-slim and hindsight-all-slim packages (#560)
* feat: introduce hindsight-api-slim and hindsight-all-slim packages

Closes #552

- Move all source code from hindsight-api/ to new hindsight-api-slim/
- hindsight-api-slim has heavy ML deps (torch, sentence-transformers,
  transformers, einops, flashrank, mlx, mlx-lm, safetensors) and
  pg0-embedded as optional extras: [local-ml], [embedded-db], [all]
- hindsight-api becomes a zero-code meta-package depending on
  hindsight-api-slim[all] for full backward compatibility
- Add hindsight-all-slim meta-package: hindsight-api-slim + client + embed
- hindsight-all updated to depend on hindsight-api-slim[all]
- pg0.py: lazy-import pg0 with clear ImportError pointing to [embedded-db]
- Dockerfile: replace sed hack with proper uv sync --extra flags
- Update release.yml, test.yml, lint.sh, release.sh, CLAUDE.md and
  all path references throughout the repo

* refactor: rename hindsight/ directory to hindsight-all/

* docs: document hindsight-api-slim and hindsight-all-slim package variants

Add package variants table and extras explanation to installation.md

* docs: remove emojis from installation.md, use professional tone

* docs: link Docker slim variant to pip package variants section

* docs: consolidate Docker image variants into single table

* ci: fix working-directory paths after package restructure

- Replace all hindsight-api → hindsight-api-slim in test.yml
- Replace hindsight → hindsight-all in test.yml
- Add --extra embedded-db to test-embed API install step

* ci: add local-ml and embedded-db extras to API sync steps

These extras were previously implicit in the old hindsight-api package
(which bundled everything). Now that hindsight-api-slim uses optional
extras, we must explicitly request local-ml and embedded-db in CI.

* ci: add API install step with embedded-db to test-embed smoke test

The smoke test starts hindsight-api as a daemon, which requires pg0-embedded.
Add a dedicated install step for hindsight-api-slim with embedded-db extra
so the daemon can start successfully.

* ci: remove --no-install-project when using optional extras

When --no-install-project is combined with --extra, the optional deps
are not installed because extras require the project to be active.
Remove --no-install-project from steps that need local-ml or embedded-db.

* ci: fix ordering of uv sync steps to preserve optional extras

When uv sync runs for a different workspace member, it removes optional
extras installed for other members. Fix by always running extra-requiring
API sync last, after other workspace member syncs.

Also remove --no-install-project from embedded-db sync in test-embed,
as --no-install-project prevents optional extras from being active.

* ci: add local-ml extra to test-embed API install for smoke test

The smoke test starts the full API server which needs sentence-transformers
for local embeddings (default provider). Add local-ml extra to the install.

* ci: simplify extras with --all-extras and add slim pip smoke test

- Replace explicit --extra local-ml --extra embedded-db with --all-extras
  for cleaner, more maintainable sync steps
- Add test-pip-slim job: tests hindsight-api-slim[embedded-db] without
  local ML models, using Cohere for embeddings/reranking (mirrors Docker
  slim smoke test approach)

* ci: simplify slim smoke test to health check only (mirrors Docker test)
2026-03-13 13:50:03 +01:00

366 lines
14 KiB
Python

"""
Tests for document tracking and upsert functionality.
"""
import logging
from datetime import datetime, timezone
from unittest.mock import patch
import pytest
from hindsight_api import RequestContext
from hindsight_api.engine.response_models import TokenUsage
@pytest.mark.asyncio
async def test_document_creation_and_retrieval(memory, request_context):
"""Test that documents are created and can be retrieved."""
bank_id = f"test_doc_{datetime.now(timezone.utc).timestamp()}"
try:
document_id = "meeting-001"
# Store memory with document tracking
await memory.retain_async(
bank_id=bank_id,
content="Alice works at Google. Bob works at Microsoft.",
context="Team meeting",
document_id=document_id,
request_context=request_context,
)
# Retrieve document
doc = await memory.get_document(document_id, bank_id, request_context=request_context)
assert doc is not None
assert doc["id"] == document_id
assert doc["bank_id"] == bank_id
assert "Alice works at Google" in doc["original_text"]
assert doc["memory_unit_count"] > 0
finally:
await memory.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
async def test_document_upsert(memory, request_context):
"""Test that providing the same document_id automatically upserts (deletes old units and creates new ones)."""
bank_id = f"test_upsert_{datetime.now(timezone.utc).timestamp()}"
try:
document_id = "meeting-002"
# First version
units_v1 = await memory.retain_async(
bank_id=bank_id,
content="Alice works at Google.",
context="Initial",
document_id=document_id,
request_context=request_context,
)
# Get document stats
doc_v1 = await memory.get_document(document_id, bank_id, request_context=request_context)
count_v1 = doc_v1["memory_unit_count"]
# Update with different content (automatic upsert when same document_id is provided)
units_v2 = await memory.retain_async(
bank_id=bank_id,
content="Alice works at Microsoft. Bob works at Apple.",
context="Updated",
document_id=document_id,
request_context=request_context,
)
# Get updated document stats
doc_v2 = await memory.get_document(document_id, bank_id, request_context=request_context)
count_v2 = doc_v2["memory_unit_count"]
# Verify old units were replaced
assert "Microsoft" in doc_v2["original_text"]
assert doc_v2["updated_at"] > doc_v1["created_at"]
# Different unit IDs (old ones deleted, new ones created)
assert set(units_v1).isdisjoint(set(units_v2))
finally:
await memory.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
async def test_document_deletion(memory, request_context):
"""Test that deleting a document cascades to memory units."""
bank_id = f"test_delete_{datetime.now(timezone.utc).timestamp()}"
try:
document_id = "meeting-003"
# Create document
await memory.retain_async(
bank_id=bank_id,
content="Alice works at Google.",
context="Test",
document_id=document_id,
request_context=request_context,
)
# Verify it exists
doc = await memory.get_document(document_id, bank_id, request_context=request_context)
assert doc is not None
assert doc["memory_unit_count"] > 0
# Delete document
result = await memory.delete_document(document_id, bank_id, request_context=request_context)
assert result["document_deleted"] == 1
assert result["memory_units_deleted"] > 0
# Verify it's gone
doc_after = await memory.get_document(document_id, bank_id, request_context=request_context)
assert doc_after is None
finally:
await memory.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
async def test_memory_without_document(memory, request_context):
"""Test that memories can still be created without document tracking."""
bank_id = f"test_no_doc_{datetime.now(timezone.utc).timestamp()}"
try:
# Create memory without document_id (backward compatibility)
units = await memory.retain_async(
bank_id=bank_id,
content="Alice works at Google.",
context="Test",
request_context=request_context,
)
assert len(units) > 0
finally:
await memory.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
async def test_document_persisted_with_zero_facts(memory, request_context):
"""
Test that documents are persisted even when zero facts are extracted.
This is a regression test for issue #324 where documents with no extractable
facts were reported as disappearing from the system.
"""
bank_id = f"test_zero_facts_{datetime.now(timezone.utc).timestamp()}"
try:
document_id = "doc-zero-facts"
# Retain content that produces zero facts (gibberish/random characters)
units = await memory.retain_async(
bank_id=bank_id,
content="xyzabc123 !!!### @@@ $$$", # Random characters unlikely to produce facts
context="Test zero facts",
document_id=document_id,
request_context=request_context,
)
# Should return empty unit list (no facts extracted)
assert len(units) == 0, "Should extract zero facts from gibberish content"
# But document should still be persisted and retrievable
doc = await memory.get_document(document_id, bank_id, request_context=request_context)
assert doc is not None, "Document should be persisted even with zero facts"
assert doc["id"] == document_id
assert doc["bank_id"] == bank_id
assert doc["memory_unit_count"] == 0, "Should have zero memory units"
assert len(doc["original_text"]) > 0, "Should have non-zero text length"
assert "xyzabc123" in doc["original_text"], "Should contain original content"
# Document should also appear in list
docs_list = await memory.list_documents(
bank_id=bank_id,
search_query=None,
limit=100,
offset=0,
request_context=request_context,
)
assert docs_list["total"] == 1, "Document should appear in list"
assert any(d["id"] == document_id for d in docs_list["items"]), "Document should be in items"
listed_doc = next(d for d in docs_list["items"] if d["id"] == document_id)
assert listed_doc["memory_unit_count"] == 0, "Listed document should show zero memory units"
finally:
await memory.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
async def test_document_persisted_with_zero_facts_batch(memory, request_context):
"""
Test that documents are persisted with zero facts in batch retain operations.
This tests the async batch code path to ensure it also handles zero facts correctly.
"""
bank_id = f"test_zero_facts_batch_{datetime.now(timezone.utc).timestamp()}"
try:
# Mix of content: some produces facts, some produces zero facts
contents = [
{
"content": "Alice works at Google",
"document_id": "doc-with-facts",
},
{
"content": "!@# $$$ %%% ^^^ &&& ***", # Gibberish - zero facts expected
"document_id": "doc-zero-facts",
},
]
unit_ids = await memory.retain_batch_async(
bank_id=bank_id,
contents=contents,
request_context=request_context,
)
# First content should produce facts, second should not
assert len(unit_ids[0]) > 0, "First content should produce facts"
assert len(unit_ids[1]) == 0, "Second content should produce zero facts"
# Both documents should be persisted
doc_with_facts = await memory.get_document("doc-with-facts", bank_id, request_context=request_context)
assert doc_with_facts is not None
assert doc_with_facts["memory_unit_count"] > 0
doc_zero_facts = await memory.get_document("doc-zero-facts", bank_id, request_context=request_context)
assert doc_zero_facts is not None, "Document with zero facts should be persisted"
assert doc_zero_facts["memory_unit_count"] == 0, "Should have zero memory units"
assert "!@#" in doc_zero_facts["original_text"]
# Both should appear in list
docs_list = await memory.list_documents(
bank_id=bank_id,
search_query=None,
limit=100,
offset=0,
request_context=request_context,
)
assert docs_list["total"] == 2, "Both documents should appear in list"
finally:
await memory.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
async def test_document_persisted_with_zero_facts_async_submit(memory, request_context):
"""
Test that documents are persisted with zero facts in fire-and-forget async retain.
This tests the submit_async_retain (background task) code path to ensure it also
handles zero facts correctly.
"""
import asyncio
bank_id = f"test_zero_facts_async_{datetime.now(timezone.utc).timestamp()}"
try:
# Submit async retain with gibberish content
result = await memory.submit_async_retain(
bank_id=bank_id,
contents=[
{
"content": "!@# $$$ %%% ^^^ &&& ***", # Gibberish - zero facts expected
"document_id": "doc-async-zero-facts",
}
],
request_context=request_context,
)
operation_id = result["operation_id"]
assert operation_id is not None, "Should return operation_id"
# Wait for background task to complete
max_wait = 60 # 60 seconds max
wait_interval = 0.5
elapsed = 0
while elapsed < max_wait:
await asyncio.sleep(wait_interval)
elapsed += wait_interval
# Check if document exists
doc = await memory.get_document(
"doc-async-zero-facts", bank_id, request_context=request_context
)
if doc is not None:
break
# Document should be persisted even with zero facts
assert doc is not None, "Document should be persisted after async task completes"
assert doc["id"] == "doc-async-zero-facts"
assert doc["memory_unit_count"] == 0, "Should have zero memory units"
assert "!@#" in doc["original_text"]
# Document should appear in list
docs_list = await memory.list_documents(
bank_id=bank_id,
search_query=None,
limit=100,
offset=0,
request_context=request_context,
)
assert docs_list["total"] == 1, "Document should appear in list"
assert any(d["id"] == "doc-async-zero-facts" for d in docs_list["items"])
listed_doc = next(d for d in docs_list["items"] if d["id"] == "doc-async-zero-facts")
assert listed_doc["memory_unit_count"] == 0, "Listed document should show zero memory units"
finally:
await memory.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
async def test_document_stored_without_chunks_when_zero_facts(memory_no_llm_verify, request_context):
"""
Regression test: when 0 facts are extracted from chunked content, the document row
must be stored but no chunk rows should be written.
"""
bank_id = f"test_zero_facts_no_chunks_{datetime.now(timezone.utc).timestamp()}"
document_id = "doc-zero-facts-chunked"
# Content large enough to exceed default retain_chunk_size (3000 chars) so chunking is triggered
content = "Alice works at Google. " * 200 # ~4600 chars
async def mock_llm_zero_facts(*args, **kwargs):
response = {"facts": []}
if kwargs.get("return_usage", False):
return response, TokenUsage(input_tokens=10, output_tokens=2)
return response
try:
with patch("hindsight_api.engine.llm_wrapper.LLMProvider.call", new=mock_llm_zero_facts):
units = await memory_no_llm_verify.retain_async(
bank_id=bank_id,
content=content,
document_id=document_id,
request_context=request_context,
)
assert units == [], "Should return no memory units when LLM extracts zero facts"
# Document row must exist
doc = await memory_no_llm_verify.get_document(document_id, bank_id, request_context=request_context)
assert doc is not None, "Document row must be stored even when zero facts are extracted"
assert doc["id"] == document_id
assert doc["memory_unit_count"] == 0
# No chunk rows should be stored
pool = await memory_no_llm_verify._get_pool()
async with pool.acquire() as conn:
chunk_count = await conn.fetchval(
"SELECT COUNT(*) FROM chunks WHERE document_id = $1 AND bank_id = $2",
document_id,
bank_id,
)
assert chunk_count == 0, "No chunk rows should be stored when zero facts are extracted"
finally:
await memory_no_llm_verify.delete_bank(bank_id, request_context=request_context)