personabot-api / tests /integration /test_raptor.py
GitHub Actions
Deploy c75f65a
a9c06ad
# backend/tests/integration/test_raptor.py
# Integration tests for RAPTOR hierarchical summarisation.
#
# Task 8: Validates that the RAPTOR builder produces coherent hierarchies
# with proper clustering, summarisation, and embedding integration.
#
# Tests run with synthetic corpus fixtures to avoid dependency on real
# knowledge base content.
import os
import sys
import pytest
import numpy as np
from unittest.mock import AsyncMock, MagicMock, patch
# Add parent directory to path so ingestion module is accessible
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '../../..'))
from ingestion.raptor import RaptorBuilder, _n_clusters, _gmm_soft_assign
class TestRaptorClustering:
"""Unit tests for RAPTOR clustering logic."""
def test_n_clusters_formula(self):
"""sqrt(N) heuristic with bounds."""
assert _n_clusters(4) == 2
assert _n_clusters(100) == 10
assert _n_clusters(400) == 20
assert _n_clusters(500) == 20
assert _n_clusters(1) == 2
def test_gmm_soft_assign_shape(self):
"""GMM returns correct shapes for responsibilities and labels."""
rng = np.random.default_rng(seed=42)
embeddings = rng.standard_normal((20, 384))
labels, responsibilities = _gmm_soft_assign(embeddings, n_components=3)
assert labels.shape == (20,)
assert responsibilities.shape == (20, 3)
assert np.all((labels >= 0) & (labels < 3))
assert np.allclose(responsibilities.sum(axis=1), 1.0)
def test_gmm_cluster_determinism(self):
"""GMM with fixed random_state is deterministic."""
rng = np.random.default_rng(seed=42)
embeddings = rng.standard_normal((15, 384))
labels1, _ = _gmm_soft_assign(embeddings, n_components=2, random_state=42)
labels2, _ = _gmm_soft_assign(embeddings, n_components=2, random_state=42)
np.testing.assert_array_equal(labels1, labels2)
class TestRaptorSummarisation:
"""Integration tests for RAPTOR cluster summarisation."""
@pytest.fixture
def synthetic_chunks(self):
"""10-item fixture: 5 project chunks + 5 blog chunks."""
return [
{
"id": f"chunk_{i}",
"text": f"Project {i}: Built a Python async service using FastAPI and PostgreSQL. "
f"Key features include real-time validation, caching layers, and REST API.",
"metadata": {
"doc_id": f"project_{i % 3}",
"source_title": f"Project {i % 3}",
"source_type": "project",
"chunk_index": i,
},
}
for i in range(5)
] + [
{
"id": f"blog_{i}",
"text": f"Blog Post {i}: Exploring RAG systems with LangGraph, semantic caching, "
f"and multi-modal retrieval. Discusses production challenges and solutions.",
"metadata": {
"doc_id": f"blog_{i}",
"source_title": f"Blog {i}",
"source_type": "blog",
"chunk_index": i,
},
}
for i in range(5)
]
@pytest.fixture
def synthetic_embeddings(self):
"""10 random 384-dim vectors (BGE-small dimension)."""
rng = np.random.default_rng(seed=42)
return rng.standard_normal((10, 384)).astype(np.float32)
def test_raptor_builder_initialization(self):
"""RaptorBuilder instantiates without errors."""
mock_vector_store = MagicMock()
mock_embedder = MagicMock()
mock_gemini = MagicMock()
builder = RaptorBuilder(
store=mock_vector_store,
embedder=mock_embedder,
gemini_client=mock_gemini,
)
assert builder._store is mock_vector_store
@pytest.mark.asyncio
async def test_raptor_build_creates_hierarchy(
self,
synthetic_chunks,
synthetic_embeddings,
):
"""
RAPTOR build produces hierarchical summary nodes.
Assertions:
• Cluster count is sqrt(N) within bounds
• No degenerate single-item clusters
• Summary nodes are created and upserted
"""
mock_vector_store = MagicMock()
mock_embedder = MagicMock()
mock_gemini = MagicMock()
def mock_summarise(text: str):
return "Summary of cluster content"
mock_gemini.summarise = AsyncMock(side_effect=mock_summarise)
# Mock embedder to return synthetic vectors
def mock_embed(texts, is_query=False):
rng = np.random.default_rng(seed=42)
return rng.standard_normal((len(texts), 384)).astype(np.float32)
mock_embedder.embed = AsyncMock(side_effect=mock_embed)
mock_embedder.embed_texts_async = mock_embedder.embed
# Mock vector store to capture upserts
upserted_count = [0]
def capture_upsert(nodes, dense_embeddings, sparse_embeddings=None):
# Detect raptor_summary nodes by inspecting their metadata.
raptor_nodes = [
n for n in nodes
if n.get("metadata", {}).get("chunk_type") == "raptor_summary"
]
if raptor_nodes:
upserted_count[0] = len(raptor_nodes)
return [f"uuid_{i}" for i in range(len(nodes))]
mock_vector_store.upsert_chunks = MagicMock(side_effect=capture_upsert)
builder = RaptorBuilder(
store=mock_vector_store,
embedder=mock_embedder,
gemini_client=mock_gemini,
)
leaf_uuids = [f"uuid_chunk_{i}" for i in range(len(synthetic_chunks))]
await builder.build(
leaf_chunks=synthetic_chunks,
dense_embeddings=synthetic_embeddings.tolist(),
leaf_uuids=leaf_uuids,
)
# At least one summary node should be created
assert upserted_count[0] > 0 or len(synthetic_chunks) < 2
@pytest.mark.asyncio
async def test_raptor_child_leaf_mapping(self, synthetic_chunks, synthetic_embeddings):
"""Child leaf IDs correctly reference original chunks."""
mock_vector_store = MagicMock()
mock_embedder = MagicMock()
mock_gemini = MagicMock()
def mock_summarise(text: str):
return "Cluster summary"
mock_gemini.summarise = AsyncMock(side_effect=mock_summarise)
def mock_embed(texts, is_query=False):
rng = np.random.default_rng(seed=43)
return rng.standard_normal((len(texts), 384)).astype(np.float32)
mock_embedder.embed = AsyncMock(side_effect=mock_embed)
mock_embedder.embed_texts_async = mock_embedder.embed
# Capture child_leaf_ids for validation
captured_mappings = []
def capture_upsert(nodes, dense_embeddings, sparse_embeddings=None):
for node in nodes:
if node.get("metadata", {}).get("chunk_type") == "raptor_summary":
child_ids = node.get("metadata", {}).get("child_leaf_ids", [])
captured_mappings.append(child_ids)
return [f"uuid_{i}" for i in range(len(nodes))]
mock_vector_store.upsert_chunks = MagicMock(side_effect=capture_upsert)
builder = RaptorBuilder(
store=mock_vector_store,
embedder=mock_embedder,
gemini_client=mock_gemini,
)
leaf_uuids = [f"uuid_chunk_{i}" for i in range(len(synthetic_chunks))]
await builder.build(
leaf_chunks=synthetic_chunks,
dense_embeddings=synthetic_embeddings.tolist(),
leaf_uuids=leaf_uuids,
)
# All child references should use leaf UUIDs
for child_list in captured_mappings:
for child_uuid in child_list:
assert child_uuid in leaf_uuids
def test_raptor_builder_store_reference(self):
"""RaptorBuilder stores reference to vector store."""
mock_vector_store = MagicMock()
mock_embedder = MagicMock()
builder = RaptorBuilder(
store=mock_vector_store,
embedder=mock_embedder,
)
assert builder._store is mock_vector_store
class TestRaptorErrorHandling:
"""Robustness tests for RAPTOR failure modes."""
@pytest.mark.asyncio
async def test_raptor_graceful_gemini_failure(self):
"""If Gemini fails, RAPTOR continues with fallback summary."""
mock_vector_store = MagicMock()
mock_embedder = MagicMock()
mock_gemini = MagicMock()
def mock_summarise_fail(text: str):
raise RuntimeError("Gemini API timeout")
mock_gemini.summarise = AsyncMock(side_effect=mock_summarise_fail)
def mock_embed(texts, is_query=False):
rng = np.random.default_rng(seed=44)
return rng.standard_normal((len(texts), 384)).astype(np.float32)
mock_embedder.embed = AsyncMock(side_effect=mock_embed)
mock_embedder.embed_texts_async = mock_embedder.embed
mock_vector_store.upsert_chunks = MagicMock(return_value=[])
builder = RaptorBuilder(
store=mock_vector_store,
embedder=mock_embedder,
gemini_client=mock_gemini,
)
chunks = [
{
"id": "c1",
"text": "Sample chunk about project architecture",
"metadata": {"doc_id": "d1", "source_type": "blog"},
}
]
rng = np.random.default_rng(seed=42)
embeddings = rng.standard_normal((1, 384)).astype(np.float32)
# Should handle gracefully
try:
await builder.build(
leaf_chunks=chunks,
dense_embeddings=embeddings.tolist(),
leaf_uuids=["uuid_c1"],
)
except Exception:
pytest.fail("RAPTOR should handle Gemini failure gracefully")
@pytest.mark.asyncio
async def test_raptor_empty_corpus(self):
"""Empty chunk list skips RAPTOR."""
mock_vector_store = MagicMock()
mock_embedder = MagicMock()
mock_vector_store.upsert_chunks = MagicMock(return_value={})
builder = RaptorBuilder(
store=mock_vector_store,
embedder=mock_embedder,
)
await builder.build(
leaf_chunks=[],
dense_embeddings=[],
leaf_uuids=[],
)
# Should complete without error
assert mock_vector_store.upsert_chunks.call_count == 0 or len(
mock_vector_store.upsert_chunks.call_args_list[0][0][0]
) == 0