""" Test OpenAI Batch API integration for retain fact extraction. Tests cover: - Normal batch API flow (submit, poll, complete) - Crash recovery (resume from existing batch_id) - Provider fallback (when batch API not supported) - Worker recovery on restart """ import pytest import asyncio import logging import json import uuid from datetime import datetime, timezone from unittest.mock import AsyncMock, MagicMock, patch from hindsight_api import RequestContext from hindsight_api.engine.retain.fact_extraction import ( extract_facts_from_contents_batch_api, extract_facts_from_contents, RetainContent, ) from hindsight_api.config import HindsightConfig from hindsight_api.engine.llm_wrapper import create_llm_provider from hindsight_api.worker.poller import WorkerPoller logger = logging.getLogger(__name__) @pytest.fixture def mock_llm_config(): """Create a mock LLM config with batch API support.""" mock = MagicMock() mock.provider = "openai" mock.model = "gpt-4o-mini" mock._provider_impl = AsyncMock() return mock @pytest.fixture def test_contents(): """Create test content for fact extraction.""" return [ RetainContent( content="Alice is a senior software engineer at TechCorp. She specializes in distributed systems.", event_date=datetime(2024, 1, 15, tzinfo=timezone.utc), context="team overview", ), RetainContent( content="Bob joined the team last month as a junior developer. He is learning React.", event_date=datetime(2024, 1, 15, tzinfo=timezone.utc), context="team overview", ), ] @pytest.fixture def hindsight_config(): """Create test config with batch API enabled.""" config = HindsightConfig.from_env() config.retain_batch_enabled = True config.retain_batch_poll_interval_seconds = 1 # Fast polling for tests config.retain_chunk_size = 4000 config.retain_extraction_mode = "concise" config.retain_extract_causal_links = False return config @pytest.mark.asyncio async def test_batch_api_normal_flow(mock_llm_config, test_contents, hindsight_config, memory, request_context): """Test normal batch API flow: submit, poll, complete.""" bank_id = f"test_batch_{datetime.now(timezone.utc).timestamp()}" try: # Mock batch API responses batch_id = "batch_test123" # Mock supports_batch_api mock_llm_config._provider_impl.supports_batch_api = AsyncMock(return_value=True) # Mock submit_batch - returns batch metadata mock_llm_config._provider_impl.submit_batch = AsyncMock( return_value={ "batch_id": batch_id, "status": "validating", "request_counts": {"total": 2, "completed": 0, "failed": 0}, } ) # Mock get_batch_status - simulate polling sequence status_sequence = [ {"status": "in_progress", "request_counts": {"total": 2, "completed": 1, "failed": 0}}, {"status": "completed", "request_counts": {"total": 2, "completed": 2, "failed": 0}}, ] mock_llm_config._provider_impl.get_batch_status = AsyncMock(side_effect=status_sequence) # Mock retrieve_batch_results - returns fact extraction results mock_results = [ { "custom_id": "chunk_0", "response": { "body": { "choices": [ { "message": { "content": json.dumps({ "facts": [ { "what": "Alice is a senior software engineer at TechCorp", "when": "present", "where": "TechCorp", "who": "Alice", "why": "Professional background information", "fact_type": "world", "fact_kind": "conversation", } ] }) } } ], "usage": {"prompt_tokens": 100, "completion_tokens": 50, "total_tokens": 150}, } }, }, { "custom_id": "chunk_1", "response": { "body": { "choices": [ { "message": { "content": json.dumps({ "facts": [ { "what": "Bob joined the team last month as a junior developer", "when": "last month", "where": "team", "who": "Bob", "why": "New team member information", "fact_type": "world", "fact_kind": "conversation", } ] }) } } ], "usage": {"prompt_tokens": 100, "completion_tokens": 50, "total_tokens": 150}, } }, }, ] mock_llm_config._provider_impl.retrieve_batch_results = AsyncMock(return_value=mock_results) # Call batch API extraction facts, chunks, usage = await extract_facts_from_contents_batch_api( contents=test_contents, llm_config=mock_llm_config, agent_name="test_agent", config=hindsight_config, pool=None, # No DB pool for this test operation_id=None, schema=None, ) # Verify results assert len(facts) == 2, "Should extract 2 facts (one per chunk)" # Facts are ExtractedFact objects with .fact_text field assert "Alice" in facts[0].fact_text and "senior software engineer" in facts[0].fact_text assert "Bob" in facts[1].fact_text and "junior developer" in facts[1].fact_text # Verify chunks metadata assert len(chunks) == 2, "Should have 2 chunks metadata" assert chunks[0].fact_count == 1 assert chunks[1].fact_count == 1 # Verify token usage assert usage.input_tokens == 200 # 100 per chunk assert usage.output_tokens == 100 # 50 per chunk assert usage.total_tokens == 300 # Verify API calls mock_llm_config._provider_impl.submit_batch.assert_called_once() assert mock_llm_config._provider_impl.get_batch_status.call_count == 2 mock_llm_config._provider_impl.retrieve_batch_results.assert_called_once_with(batch_id) logger.info("✅ Normal batch API flow test passed") finally: # Cleanup try: await memory.delete_bank(bank_id, request_context=request_context) except Exception: pass @pytest.mark.asyncio async def test_batch_api_crash_recovery(mock_llm_config, test_contents, hindsight_config, memory, request_context): """Test crash recovery: resume polling from existing batch_id.""" bank_id = f"test_crash_{datetime.now(timezone.utc).timestamp()}" operation_id = str(uuid.uuid4()) # Must be UUID for async_operations table try: # Ensure bank exists await memory.get_bank_profile(bank_id, request_context=request_context) # Setup: Store batch_id in async_operations table (simulates partial execution) batch_id = "batch_recovered_456" pool = memory._pool schema = request_context.tenant_id from hindsight_api.engine.task_backend import fq_table table = fq_table("async_operations", schema) # Create operation with batch_id already stored await pool.execute( f""" INSERT INTO {table} (operation_id, operation_type, bank_id, status, result_metadata) VALUES ($1, 'retain', $2, 'processing', $3::jsonb) """, operation_id, bank_id, json.dumps({ "batch_id": batch_id, "batch_provider": "openai", "chunk_count": 2, }), ) # Mock batch API responses for resume scenario mock_llm_config._provider_impl.supports_batch_api = AsyncMock(return_value=True) # Mock get_batch_status - batch already in progress mock_llm_config._provider_impl.get_batch_status = AsyncMock( return_value={ "status": "completed", "request_counts": {"total": 2, "completed": 2, "failed": 0}, } ) # Mock retrieve_batch_results mock_results = [ { "custom_id": "chunk_0", "response": { "body": { "choices": [ { "message": { "content": json.dumps({ "facts": [ { "what": "Alice is a senior software engineer", "when": "present", "where": "TechCorp", "who": "Alice", "why": "Background", "fact_type": "world", "fact_kind": "conversation", } ] }) } } ], "usage": {"prompt_tokens": 100, "completion_tokens": 50, "total_tokens": 150}, } }, }, { "custom_id": "chunk_1", "response": { "body": { "choices": [ { "message": { "content": json.dumps({ "facts": [ { "what": "Bob is a junior developer", "when": "last month", "where": "team", "who": "Bob", "why": "New member", "fact_type": "world", "fact_kind": "conversation", } ] }) } } ], "usage": {"prompt_tokens": 100, "completion_tokens": 50, "total_tokens": 150}, } }, }, ] mock_llm_config._provider_impl.retrieve_batch_results = AsyncMock(return_value=mock_results) # Call batch API extraction with operation_id (crash recovery scenario) facts, chunks, usage = await extract_facts_from_contents_batch_api( contents=test_contents, llm_config=mock_llm_config, agent_name="test_agent", config=hindsight_config, pool=pool, operation_id=operation_id, # Provides crash recovery context schema=schema, ) # Verify results assert len(facts) == 2, "Should extract 2 facts after recovery" # CRITICAL: Verify submit_batch was NOT called (because batch_id already exists) mock_llm_config._provider_impl.submit_batch.assert_not_called() # Verify get_batch_status WAS called (polling resumed) mock_llm_config._provider_impl.get_batch_status.assert_called() # Verify retrieve_batch_results was called with the recovered batch_id mock_llm_config._provider_impl.retrieve_batch_results.assert_called_once_with(batch_id) logger.info("✅ Crash recovery test passed - resumed polling without re-submission") finally: # Cleanup try: await memory.delete_bank(bank_id, request_context=request_context) except Exception: pass @pytest.mark.asyncio async def test_batch_api_fallback_unsupported_provider(mock_llm_config, test_contents, hindsight_config): """Test fallback to sync mode when provider doesn't support batch API.""" # Mock provider that doesn't support batch API mock_llm_config._provider_impl.supports_batch_api = AsyncMock(return_value=False) mock_llm_config.provider = "groq" # Example of provider # Patch the sync mode function to verify it's called with patch( "hindsight_api.engine.retain.fact_extraction.extract_facts_from_contents" ) as mock_sync_extract: mock_sync_extract.return_value = ([], [], MagicMock()) # Call batch API extraction (should fallback to sync) await extract_facts_from_contents_batch_api( contents=test_contents, llm_config=mock_llm_config, agent_name="test_agent", config=hindsight_config, pool=None, operation_id=None, schema=None, ) # Verify fallback occurred mock_sync_extract.assert_called_once() # Verify batch API methods were NOT called mock_llm_config._provider_impl.submit_batch.assert_not_called() logger.info("✅ Fallback to sync mode test passed") @pytest.mark.asyncio async def test_worker_batch_recovery(memory, request_context): """Test that WorkerPoller._recover_batch_operations finds and resets orphaned batches.""" bank_id = f"test_worker_recovery_{datetime.now(timezone.utc).timestamp()}" operation_id = str(uuid.uuid4()) # Must be UUID for async_operations table try: # Ensure bank exists await memory.get_bank_profile(bank_id, request_context=request_context) pool = memory._pool schema = request_context.tenant_id from hindsight_api.engine.task_backend import fq_table table = fq_table("async_operations", schema) # Create orphaned batch operation (simulates worker crash during polling) batch_id = "batch_orphaned_999" task_payload = { "operation_type": "retain", "bank_id": bank_id, "contents": [{"content": "test", "event_date": "2024-01-15T00:00:00Z"}], } await pool.execute( f""" INSERT INTO {table} (operation_id, operation_type, bank_id, status, worker_id, result_metadata, task_payload) VALUES ($1, 'retain', $2, 'processing', 'worker_crashed', $3::jsonb, $4::jsonb) """, operation_id, bank_id, json.dumps({ "batch_id": batch_id, "batch_provider": "openai", "chunk_count": 1, }), json.dumps(task_payload), ) # Create WorkerPoller from hindsight_api.extensions.builtin.tenant import DefaultTenantExtension tenant_extension = DefaultTenantExtension(config={"schema": schema} if schema else {}) poller = WorkerPoller( pool=pool, worker_id="test_worker_recovery", executor=memory, poll_interval_ms=100, schema=schema, tenant_extension=tenant_extension, max_slots=5, consolidation_max_slots=2, ) # Run recovery recovered_count = await poller._recover_batch_operations(schema) # Verify recovery assert recovered_count == 1, "Should recover 1 batch operation" # Verify operation was reset to pending row = await pool.fetchrow( f"SELECT status, worker_id FROM {table} WHERE operation_id = $1", operation_id, ) assert row["status"] == "pending", "Operation should be reset to pending" assert row["worker_id"] is None, "Worker ID should be cleared" logger.info("✅ Worker batch recovery test passed") finally: # Cleanup try: await memory.delete_bank(bank_id, request_context=request_context) except Exception: pass @pytest.mark.asyncio async def test_batch_api_via_extract_facts_from_contents( mock_llm_config, test_contents, hindsight_config, memory, request_context ): """Test that extract_facts_from_contents routes to batch API when enabled.""" bank_id = f"test_routing_{datetime.now(timezone.utc).timestamp()}" try: # Enable batch API in config hindsight_config.retain_batch_enabled = True # Mock batch API support mock_llm_config._provider_impl.supports_batch_api = AsyncMock(return_value=True) mock_llm_config._provider_impl.submit_batch = AsyncMock( return_value={"batch_id": "batch_123", "status": "validating", "request_counts": {}} ) mock_llm_config._provider_impl.get_batch_status = AsyncMock( return_value={"status": "completed", "request_counts": {"total": 1, "completed": 1, "failed": 0}} ) mock_llm_config._provider_impl.retrieve_batch_results = AsyncMock( return_value=[ { "custom_id": "chunk_0", "response": { "body": { "choices": [ { "message": { "content": json.dumps({"facts": []}) } } ], "usage": {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15}, } }, } ] ) # Call main extract_facts_from_contents (should route to batch API) facts, chunks, usage = await extract_facts_from_contents( contents=test_contents, llm_config=mock_llm_config, agent_name="test_agent", config=hindsight_config, pool=None, operation_id=None, schema=None, ) # Verify batch API was called mock_llm_config._provider_impl.submit_batch.assert_called_once() logger.info("✅ Routing to batch API test passed") finally: # Cleanup try: await memory.delete_bank(bank_id, request_context=request_context) except Exception: pass