fleet-memory/hindsight-api-slim/tests/test_document_tracking.py
Nicolò Boschi 627ec5d524
feat: expose document_metadata in API and control plane (#798)
* feat: expose document_metadata in API and control plane

Add document_metadata (sourced from retain_params.metadata) to both
list and get document endpoints. Display it in the control plane
documents table and detail panel. Drop the unused metadata column
from the documents table (was always stored as empty {}).

* fix: code review fixes for document_metadata feature

- Remove unnecessary `import json as _json` (json already imported at module level)
- Simplify redundant truthiness checks in retain_params parsing
- Regenerate OpenAPI spec and client SDKs (Python, TypeScript, Go)
- Add tests for document_metadata in get_document and list_documents

* feat(ui): improve documents table and detail panel

- Relative timestamps with full date on hover
- Remove context column from table
- Metadata shown as k=v badges (blue, like tags)
- Size in bytes instead of chars
- Document IDs wrap instead of truncating
- Detail panel wider (560px)
- Retain params: context, event_date, metadata badges
2026-03-31 11:42:02 +02:00

430 lines
16 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_metadata_from_retain_params(memory, request_context):
"""Test that document_metadata is returned from retain_params.metadata in both get and list."""
bank_id = f"test_doc_meta_{datetime.now(timezone.utc).timestamp()}"
try:
document_id = "doc-with-metadata"
metadata = {"source": "slack", "channel": "#general"}
await memory.retain_batch_async(
bank_id=bank_id,
contents=[{"content": "Alice works at Google.", "context": "Team meeting", "metadata": metadata}],
document_id=document_id,
request_context=request_context,
)
# get_document should include document_metadata
doc = await memory.get_document(document_id, bank_id, request_context=request_context)
assert doc is not None
assert doc["document_metadata"] == metadata
assert doc["retain_params"] is not None
assert doc["retain_params"]["metadata"] == metadata
# list_documents should also include document_metadata
docs_list = await memory.list_documents(
bank_id=bank_id, search_query=None, limit=100, offset=0, request_context=request_context
)
listed_doc = next(d for d in docs_list["items"] if d["id"] == document_id)
assert listed_doc["document_metadata"] == metadata
finally:
await memory.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
async def test_document_without_metadata(memory, request_context):
"""Test that document_metadata is None when no metadata was provided during retain."""
bank_id = f"test_doc_no_meta_{datetime.now(timezone.utc).timestamp()}"
try:
document_id = "doc-no-metadata"
await memory.retain_async(
bank_id=bank_id,
content="Bob works at Microsoft.",
context="Meeting",
document_id=document_id,
request_context=request_context,
)
doc = await memory.get_document(document_id, bank_id, request_context=request_context)
assert doc is not None
assert doc["document_metadata"] is None
docs_list = await memory.list_documents(
bank_id=bank_id, search_query=None, limit=100, offset=0, request_context=request_context
)
listed_doc = next(d for d in docs_list["items"] if d["id"] == document_id)
assert listed_doc["document_metadata"] is None
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)