import asyncio import json from typing import Any import httpx import pytest from pydantic import ValidationError from lead_scoring.aihub.client import ( CHAT_COMPLETIONS_PATH, DEFAULT_AI_MODEL, AIHubClient, run_heavy_ai_pipeline, run_light_ai_pipeline, run_medium_ai_pipeline, to_strict_json_schema, ) from lead_scoring.aihub.errors import ( AIHubAuthError, AIHubError, AIHubInvalidOutputError, AIHubQuotaError, AIHubServerError, AIHubTimeoutError, normalize_aihub_error, ) from lead_scoring.aihub.schemas import ( AiCritiqueResult, AiScoringResult, EvidenceExtractionResult, HeavyAiResult, LightAiResult, MediumAiResult, ) from lead_scoring.schemas.snapshot import CanonicalSnapshot def make_test_snapshot() -> CanonicalSnapshot: return CanonicalSnapshot.model_validate( { "captured_at": "2026-08-27T12:00:00Z", "lead_profile": { "ems_lead_id": "lead-101", "name": "Tran Van B", "phone": "+84912345678", "email": "tvb@example.com", "status": "New", }, "conversations": [ { "conversation_id": "conv-101", "messages": [ { "message_id": "msg-101", "sender_type": "contact", "content": ( "Toi quan tam toi khoa hoc AI Platform cho doanh nghiep 50 nguoi" ), "sent_at": "2026-08-27T11:30:00Z", } ], } ], "identity_context": { "match_quality": "direct", "contact_id": "cnt-101", }, "trigger_metadata": { "trigger_type": "manual_omni", "trigger_conversation_id": "conv-101", "actor_id": "user-1", }, "hitl_policy": { "confidence_threshold": 0.80, "policy_version": "1.0.0", }, } ) def test_evidence_extraction_result_validation(): valid_data = { "summary": "Customer is looking for enterprise AI course for 50 people", "intent": "high", "key_signals": ["50 people enterprise", "immediate interest"], "budget_signal": "enterprise budget", "authority_signal": "decision maker", "need_signal": "AI training for staff", "timeline_signal": "next quarter", } result = EvidenceExtractionResult.model_validate(valid_data) assert result.intent == "high" assert len(result.key_signals) == 2 assert result.budget_signal == "enterprise budget" # Summary is required with pytest.raises(ValidationError): EvidenceExtractionResult.model_validate({"intent": "high"}) # Extra fields are forbidden with pytest.raises(ValidationError): EvidenceExtractionResult.model_validate({**valid_data, "unauthorized_extra_field": "val"}) # Type coercion is rejected (strict=True) with pytest.raises(ValidationError): EvidenceExtractionResult.model_validate({**valid_data, "key_signals": "not_a_list"}) # Individual list item max_length (100 chars) with pytest.raises(ValidationError): EvidenceExtractionResult.model_validate({**valid_data, "key_signals": ["a" * 101]}) def test_ai_scoring_result_validation(): valid_data = { "score": 85, "confidence": 0.92, "reasoning": "High budget and clear timeline for 50 people.", "positive_factors": ["large team size", "clear requirements"], "negative_factors": [], } result = AiScoringResult.model_validate(valid_data) assert result.score == 85 assert result.confidence == 0.92 assert len(result.positive_factors) == 2 # Bounds validation with pytest.raises(ValidationError): AiScoringResult.model_validate({**valid_data, "score": 105}) with pytest.raises(ValidationError): AiScoringResult.model_validate({**valid_data, "confidence": 1.5}) # Extra fields are forbidden with pytest.raises(ValidationError): AiScoringResult.model_validate({**valid_data, "hallucinated_field": True}) # String-to-number coercion is rejected (strict=True) with pytest.raises(ValidationError): AiScoringResult.model_validate({**valid_data, "score": "85"}) with pytest.raises(ValidationError): AiScoringResult.model_validate({**valid_data, "confidence": "0.92"}) # Individual list item max_length (100 chars) with pytest.raises(ValidationError): AiScoringResult.model_validate({**valid_data, "positive_factors": ["b" * 101]}) with pytest.raises(ValidationError): AiScoringResult.model_validate({**valid_data, "negative_factors": ["c" * 101]}) @pytest.mark.asyncio async def test_aihub_client_two_pass_chat_completions_happy_path(): captured_requests: list[dict[str, Any]] = [] def handler(request: httpx.Request) -> httpx.Response: assert request.url.path == CHAT_COMPLETIONS_PATH body = json.loads(request.content.decode("utf-8")) captured_requests.append( { "url": str(request.url), "headers": dict(request.headers), "body": body, } ) response_format = body.get("response_format", {}) schema_name = response_format.get("json_schema", {}).get("name") if schema_name == "evidence_extraction": return httpx.Response( 200, json={ "choices": [ { "message": { "content": json.dumps( { "summary": "Enterprise lead looking for AI course", "intent": "high", "key_signals": ["enterprise"], } ) } } ] }, ) elif schema_name == "lead_scoring": return httpx.Response( 200, json={ "choices": [ { "message": { "content": json.dumps( { "score": 88, "confidence": 0.95, "reasoning": "Strong intent and clear enterprise scale.", "positive_factors": ["enterprise scale"], "negative_factors": [], } ) } } ] }, ) return httpx.Response(400) transport = httpx.MockTransport(handler) async with httpx.AsyncClient(transport=transport) as http_client: client = AIHubClient(base_url="https://aihub.internal", http_client=http_client) snapshot = make_test_snapshot() result = await run_medium_ai_pipeline( client, snapshot, vkey="test-vkey-secret-123", run_id="run-uuid-456", model=DEFAULT_AI_MODEL, timeout_seconds=5.0, app="dotb_omni", feature="lead-scoring", ) assert isinstance(result, MediumAiResult) assert result.ai_score == 88 assert result.confidence == 0.95 assert result.evidence.intent == "high" assert result.scoring.score == 88 assert result.metadata["pass_count"] == 2 assert result.metadata["model"] == DEFAULT_AI_MODEL # Verify exactly 2 calls occurred to POST /v1/chat/completions assert len(captured_requests) == 2 for req in captured_requests: assert req["url"] == "https://aihub.internal/v1/chat/completions" assert req["headers"]["authorization"] == "Bearer test-vkey-secret-123" assert req["headers"]["x-request-id"] == "run-uuid-456" assert req["headers"]["x-bf-dim-app"] == "dotb_omni" assert req["headers"]["x-bf-dim-feature"] == "lead-scoring" assert req["body"]["model"] == DEFAULT_AI_MODEL assert req["body"]["stream"] is False assert req["body"]["response_format"]["type"] == "json_schema" assert req["body"]["response_format"]["json_schema"]["strict"] is True # Pass 1 sent canonical snapshot pass1_body = captured_requests[0]["body"] assert pass1_body["response_format"]["json_schema"]["name"] == "evidence_extraction" assert "lead_profile" in pass1_body["messages"][1]["content"] # Pass 2 sent ONLY validated evidence without raw profile or conversations pass2_body = captured_requests[1]["body"] assert pass2_body["response_format"]["json_schema"]["name"] == "lead_scoring" pass2_user_content = json.loads(pass2_body["messages"][1]["content"]) assert "evidence" in pass2_user_content assert pass2_user_content["evidence"]["summary"] == "Enterprise lead looking for AI course" assert "lead_profile" not in pass2_user_content assert "identity_context" not in pass2_user_content assert "conversations" not in pass2_user_content @pytest.mark.asyncio async def test_aihub_client_rejects_empty_choices(): def handler(request: httpx.Request) -> httpx.Response: return httpx.Response(200, json={"choices": []}) transport = httpx.MockTransport(handler) async with httpx.AsyncClient(transport=transport) as http_client: client = AIHubClient(base_url="https://aihub.internal", http_client=http_client) with pytest.raises(AIHubInvalidOutputError): await client.extract_evidence( make_test_snapshot(), vkey="valid-vkey", run_id="run-1", ) @pytest.mark.asyncio async def test_aihub_client_rejects_model_refusal(): def handler(request: httpx.Request) -> httpx.Response: return httpx.Response( 200, json={ "choices": [ { "message": { "refusal": "I cannot fulfill this request due to safety policy.", "content": None, } } ] }, ) transport = httpx.MockTransport(handler) async with httpx.AsyncClient(transport=transport) as http_client: client = AIHubClient(base_url="https://aihub.internal", http_client=http_client) with pytest.raises(AIHubInvalidOutputError): await client.extract_evidence( make_test_snapshot(), vkey="valid-vkey", run_id="run-1", ) @pytest.mark.asyncio async def test_aihub_client_rejects_null_or_empty_content(): def handler(request: httpx.Request) -> httpx.Response: return httpx.Response( 200, json={"choices": [{"message": {"content": None}}]}, ) transport = httpx.MockTransport(handler) async with httpx.AsyncClient(transport=transport) as http_client: client = AIHubClient(base_url="https://aihub.internal", http_client=http_client) with pytest.raises(AIHubInvalidOutputError): await client.extract_evidence( make_test_snapshot(), vkey="valid-vkey", run_id="run-1", ) @pytest.mark.asyncio async def test_aihub_client_rejects_extra_fields_in_pass1_and_pass2(): def handler_pass1(request: httpx.Request) -> httpx.Response: return httpx.Response( 200, json={ "choices": [ { "message": { "content": json.dumps( { "summary": "Valid summary", "extra_unauthorized_key": "injected", } ) } } ] }, ) transport = httpx.MockTransport(handler_pass1) async with httpx.AsyncClient(transport=transport) as http_client: client = AIHubClient(base_url="https://aihub.internal", http_client=http_client) with pytest.raises(AIHubInvalidOutputError) as exc_info: await client.extract_evidence( make_test_snapshot(), vkey="valid-vkey", run_id="run-1", ) assert exc_info.value.error_code == "aihub_invalid_output" def handler_pass2(request: httpx.Request) -> httpx.Response: return httpx.Response( 200, json={ "choices": [ { "message": { "content": json.dumps( { "score": 80, "confidence": 0.9, "reasoning": "Valid", "extra_field": "bad", } ) } } ] }, ) transport2 = httpx.MockTransport(handler_pass2) async with httpx.AsyncClient(transport=transport2) as http_client: client = AIHubClient(base_url="https://aihub.internal", http_client=http_client) valid_evidence = EvidenceExtractionResult(summary="Valid") with pytest.raises(AIHubInvalidOutputError) as exc_info: await client.score_lead( valid_evidence, vkey="valid-vkey", run_id="run-1", ) assert exc_info.value.error_code == "aihub_invalid_output" @pytest.mark.asyncio async def test_aihub_client_rejects_type_coercion_in_pass1_and_pass2(): def handler_pass2_coercion(request: httpx.Request) -> httpx.Response: return httpx.Response( 200, json={ "choices": [ { "message": { "content": json.dumps( { "score": "80", # String instead of int "confidence": "0.9", # String instead of float "reasoning": "Valid reasoning", } ) } } ] }, ) transport = httpx.MockTransport(handler_pass2_coercion) async with httpx.AsyncClient(transport=transport) as http_client: client = AIHubClient(base_url="https://aihub.internal", http_client=http_client) valid_evidence = EvidenceExtractionResult(summary="Valid") with pytest.raises(AIHubInvalidOutputError) as exc_info: await client.score_lead( valid_evidence, vkey="valid-vkey", run_id="run-1", ) assert exc_info.value.error_code == "aihub_invalid_output" @pytest.mark.asyncio async def test_aihub_client_normalizes_401_403_auth_errors(): def handler(request: httpx.Request) -> httpx.Response: return httpx.Response(401, json={"error": "Invalid V-Key credential"}) transport = httpx.MockTransport(handler) async with httpx.AsyncClient(transport=transport) as http_client: client = AIHubClient(base_url="https://aihub.internal", http_client=http_client) with pytest.raises(AIHubAuthError) as exc_info: await client.extract_evidence( make_test_snapshot(), vkey="bad-vkey", run_id="run-1", ) err = exc_info.value assert err.error_code == "aihub_auth_failed" assert err.status_code == 401 assert err.retryable is False assert err.fallback_allowed is True @pytest.mark.asyncio async def test_aihub_client_normalizes_429_quota_error(): def handler(request: httpx.Request) -> httpx.Response: return httpx.Response(429, json={"error": "Tenant monthly credit exhausted"}) transport = httpx.MockTransport(handler) async with httpx.AsyncClient(transport=transport) as http_client: client = AIHubClient(base_url="https://aihub.internal", http_client=http_client) with pytest.raises(AIHubQuotaError) as exc_info: await client.extract_evidence( make_test_snapshot(), vkey="valid-vkey", run_id="run-1", ) err = exc_info.value assert err.error_code == "aihub_quota_exceeded" assert err.status_code == 429 assert err.retryable is False assert err.fallback_allowed is True @pytest.mark.asyncio async def test_aihub_client_normalizes_timeout(): def handler(request: httpx.Request) -> httpx.Response: raise httpx.ReadTimeout("Request timed out after 10s") transport = httpx.MockTransport(handler) async with httpx.AsyncClient(transport=transport) as http_client: client = AIHubClient(base_url="https://aihub.internal", http_client=http_client) with pytest.raises(AIHubTimeoutError) as exc_info: await client.extract_evidence( make_test_snapshot(), vkey="valid-vkey", run_id="run-1", ) err = exc_info.value assert err.error_code == "aihub_timeout" assert err.retryable is False assert err.fallback_allowed is True @pytest.mark.asyncio async def test_aihub_client_normalizes_server_error_5xx(): def handler(request: httpx.Request) -> httpx.Response: return httpx.Response(502, text="Bad Gateway") transport = httpx.MockTransport(handler) async with httpx.AsyncClient(transport=transport) as http_client: client = AIHubClient(base_url="https://aihub.internal", http_client=http_client) with pytest.raises(AIHubServerError) as exc_info: await client.extract_evidence( make_test_snapshot(), vkey="valid-vkey", run_id="run-1", ) err = exc_info.value assert err.error_code == "aihub_server_error" assert err.status_code == 502 assert err.retryable is False assert err.fallback_allowed is True @pytest.mark.asyncio async def test_aihub_client_normalizes_malformed_json(): def handler(request: httpx.Request) -> httpx.Response: return httpx.Response( 200, json={"choices": [{"message": {"content": "Not a JSON {malformed"}}]}, ) transport = httpx.MockTransport(handler) async with httpx.AsyncClient(transport=transport) as http_client: client = AIHubClient(base_url="https://aihub.internal", http_client=http_client) with pytest.raises(AIHubInvalidOutputError) as exc_info: await client.extract_evidence( make_test_snapshot(), vkey="valid-vkey", run_id="run-1", ) err = exc_info.value assert err.error_code == "aihub_invalid_output" assert err.retryable is False assert err.fallback_allowed is True @pytest.mark.asyncio async def test_run_medium_ai_pipeline_pass_1_failure_prevents_pass_2(): call_count = 0 def handler(request: httpx.Request) -> httpx.Response: nonlocal call_count call_count += 1 return httpx.Response(500, json={"error": "Internal AIHub Error"}) transport = httpx.MockTransport(handler) async with httpx.AsyncClient(transport=transport) as http_client: client = AIHubClient(base_url="https://aihub.internal", http_client=http_client) with pytest.raises(AIHubServerError): await run_medium_ai_pipeline( client, make_test_snapshot(), vkey="vkey-val", run_id="run-1", ) # Exactly 1 call was attempted (Pass 1 failed, Pass 2 was NOT called) assert call_count == 1 @pytest.mark.asyncio async def test_run_medium_ai_pipeline_pass_2_failure_discards_partial_results(): call_count = 0 def handler(request: httpx.Request) -> httpx.Response: nonlocal call_count call_count += 1 body = json.loads(request.content.decode("utf-8")) schema_name = body.get("response_format", {}).get("json_schema", {}).get("name") if schema_name == "evidence_extraction": return httpx.Response( 200, json={ "choices": [ { "message": { "content": json.dumps( {"summary": "Extracted evidence", "intent": "high"} ) } } ] }, ) elif schema_name == "lead_scoring": return httpx.Response(503, json={"error": "Service Unavailable"}) return httpx.Response(404) transport = httpx.MockTransport(handler) async with httpx.AsyncClient(transport=transport) as http_client: client = AIHubClient(base_url="https://aihub.internal", http_client=http_client) with pytest.raises(AIHubServerError) as exc_info: await run_medium_ai_pipeline( client, make_test_snapshot(), vkey="vkey-val", run_id="run-1", ) assert exc_info.value.error_code == "aihub_server_error" assert call_count == 2 def test_vkey_sentinel_never_leaks_in_normalized_error_or_string_repr(): sentinel_vkey = "sentinel-vkey-super-secret-999" # Simulate an HTTP error where the request header contains the secret request = httpx.Request( "POST", "https://aihub.internal/v1/chat/completions", headers={"Authorization": f"Bearer {sentinel_vkey}", "x-tenant-vkey": sentinel_vkey}, ) response = httpx.Response(401, request=request, json={"detail": "Unauthorized"}) http_error = httpx.HTTPStatusError("401 Unauthorized", request=request, response=response) normalized = normalize_aihub_error(http_error) normalized_str = str(normalized) normalized_repr = repr(normalized) assert sentinel_vkey not in normalized_str assert sentinel_vkey not in normalized_repr assert sentinel_vkey not in normalized.detail assert normalized.error_code == "aihub_auth_failed" @pytest.mark.asyncio async def test_aihub_client_uses_injected_base_url(): captured_urls: list[str] = [] def handler(request: httpx.Request) -> httpx.Response: captured_urls.append(str(request.url)) return httpx.Response( 200, json={ "choices": [ { "message": { "content": json.dumps( {"summary": "Injected base URL evidence", "intent": "high"} ) } } ] }, ) transport = httpx.MockTransport(handler) async with httpx.AsyncClient(transport=transport) as http_client: injected_url = "https://custom-gateway.corp.dotb.vn/aihub-api" client = AIHubClient(base_url=injected_url, http_client=http_client) await client.extract_evidence( make_test_snapshot(), vkey="test-vkey", run_id="run-base-url", ) assert len(captured_urls) == 1 assert captured_urls[0] == f"{injected_url}/v1/chat/completions" @pytest.mark.asyncio async def test_aihub_client_rejects_oversized_response_body(): # 65 KB response body exceeds 64 KB limit oversized_summary = "A" * (65 * 1024) def handler(request: httpx.Request) -> httpx.Response: return httpx.Response( 200, json={ "choices": [ { "message": { "content": json.dumps({"summary": oversized_summary, "intent": "high"}) } } ] }, ) transport = httpx.MockTransport(handler) async with httpx.AsyncClient(transport=transport) as http_client: client = AIHubClient(base_url="https://aihub.internal", http_client=http_client) with pytest.raises(AIHubInvalidOutputError) as exc_info: await client.extract_evidence( make_test_snapshot(), vkey="valid-vkey", run_id="run-oversized", ) assert exc_info.value.error_code == "aihub_invalid_output" assert "exceeds limit" in str(exc_info.value) class CustomStreamingByteStream(httpx.AsyncByteStream): def __init__(self, chunks: list[bytes]): self.chunks = chunks self.consumed_chunks_count = 0 async def __aiter__(self): for chunk in self.chunks: self.consumed_chunks_count += 1 yield chunk @pytest.mark.asyncio async def test_aihub_client_streaming_aborts_early_without_consuming_entire_stream(): # 5 chunks of 32 KB = 160 KB total (> 64 KB limit) chunks = [b"x" * (32 * 1024) for _ in range(5)] stream = CustomStreamingByteStream(chunks) def handler(request: httpx.Request) -> httpx.Response: return httpx.Response(200, stream=stream) transport = httpx.MockTransport(handler) async with httpx.AsyncClient(transport=transport) as http_client: client = AIHubClient(base_url="https://aihub.internal", http_client=http_client) with pytest.raises(AIHubInvalidOutputError) as exc_info: await client.extract_evidence( make_test_snapshot(), vkey="valid-vkey", run_id="run-streaming-cap", ) assert exc_info.value.error_code == "aihub_invalid_output" assert "exceeded maximum limit" in str(exc_info.value) # Aborted after chunk 3 (96 KB > 64 KB), so chunks 4 and 5 were NEVER consumed! assert stream.consumed_chunks_count == 3 assert stream.consumed_chunks_count < len(chunks) @pytest.mark.asyncio async def test_aihub_client_rejects_oversized_content_length_header(): def handler(request: httpx.Request) -> httpx.Response: return httpx.Response( 200, headers={"Content-Length": "70000"}, json={"choices": []}, ) transport = httpx.MockTransport(handler) async with httpx.AsyncClient(transport=transport) as http_client: client = AIHubClient(base_url="https://aihub.internal", http_client=http_client) with pytest.raises(AIHubInvalidOutputError) as exc_info: await client.extract_evidence( make_test_snapshot(), vkey="valid-vkey", run_id="run-content-length", ) assert exc_info.value.error_code == "aihub_invalid_output" assert "exceeds limit" in str(exc_info.value) def test_aihub_client_validates_base_url_shapes(): # 1. Missing scheme or netloc with pytest.raises(ValueError, match="missing scheme or host"): AIHubClient(base_url="invalid-no-scheme") # 2. User credentials in base_url with pytest.raises(ValueError, match="user credentials"): AIHubClient(base_url="https://user:password@aihub.internal") # 3. Query params in base_url with pytest.raises(ValueError, match="query parameters"): AIHubClient(base_url="https://aihub.internal?v=1") # 4. Fragments in base_url with pytest.raises(ValueError, match="fragments"): AIHubClient(base_url="https://aihub.internal#section") def test_ai_critique_result_validation(): valid_data = { "critique_summary": "Lead has solid budget and authority signals but unclear timeline.", "risks": ["Timeline not stated", "Implementation dependency on vendor"], "strengths": ["Enterprise scale", "Decision maker direct contact"], "cross_check_notes": "Needs match enterprise scale mentioned in previous interactions.", } result = AiCritiqueResult.model_validate(valid_data) assert result.critique_summary.startswith("Lead has solid budget") assert len(result.risks) == 2 assert len(result.strengths) == 2 assert result.cross_check_notes is not None # Required field missing with pytest.raises(ValidationError): AiCritiqueResult.model_validate({"risks": ["Risk 1"]}) # Extra fields forbidden with pytest.raises(ValidationError): AiCritiqueResult.model_validate({**valid_data, "unauthorized_extra": "rejected"}) # Strict type enforcement with pytest.raises(ValidationError): AiCritiqueResult.model_validate({**valid_data, "risks": "not_a_list"}) def test_light_and_heavy_ai_result_models(): scoring_result = AiScoringResult( score=85, confidence=0.90, reasoning="Good potential", positive_factors=["Strong need"], negative_factors=[], ) evidence_result = EvidenceExtractionResult(summary="Evidence summary") critique_result = AiCritiqueResult( critique_summary="Critique summary", risks=[], strengths=["Strong need"], ) light_result = LightAiResult( ai_score=85, confidence=0.90, scoring=scoring_result, metadata={"model": "gpt-4o-mini", "pass_count": 1, "effort_profile": "light"}, ) assert light_result.ai_score == 85 assert light_result.metadata["pass_count"] == 1 heavy_result = HeavyAiResult( ai_score=85, confidence=0.90, evidence=evidence_result, critique=critique_result, scoring=scoring_result, metadata={"model": "gpt-4o-mini", "pass_count": 3, "effort_profile": "heavy"}, ) assert heavy_result.ai_score == 85 assert heavy_result.critique.critique_summary == "Critique summary" assert heavy_result.metadata["pass_count"] == 3 @pytest.mark.asyncio async def test_aihub_client_analyze_and_score_lead_light_pass(): captured_requests: list[dict[str, Any]] = [] def handler(request: httpx.Request) -> httpx.Response: assert request.url.path == CHAT_COMPLETIONS_PATH assert request.headers.get("Authorization") == "Bearer vkey-light-1" assert request.headers.get("x-request-id") == "run-light-1" body = json.loads(request.content.decode("utf-8")) captured_requests.append(body) schema_name = body.get("response_format", {}).get("json_schema", {}).get("name") assert schema_name == "light_lead_scoring" return httpx.Response( 200, json={ "choices": [ { "message": { "content": json.dumps( { "score": 75, "confidence": 0.88, "reasoning": "Single pass light scoring.", "positive_factors": ["Direct contact"], "negative_factors": [], } ) } } ] }, ) transport = httpx.MockTransport(handler) async with httpx.AsyncClient(transport=transport) as http_client: client = AIHubClient(base_url="https://aihub.internal", http_client=http_client) result = await client.analyze_and_score_lead( make_test_snapshot(), vkey="vkey-light-1", run_id="run-light-1", model="custom-model", timeout_seconds=5.0, ) assert result.score == 75 assert result.confidence == 0.88 assert len(captured_requests) == 1 assert captured_requests[0]["model"] == "custom-model" @pytest.mark.asyncio async def test_aihub_client_critique_evidence_heavy_pass(): captured_requests: list[dict[str, Any]] = [] def handler(request: httpx.Request) -> httpx.Response: assert request.url.path == CHAT_COMPLETIONS_PATH assert request.headers.get("Authorization") == "Bearer vkey-heavy-1" assert request.headers.get("x-request-id") == "run-heavy-1" body = json.loads(request.content.decode("utf-8")) captured_requests.append(body) schema_name = body.get("response_format", {}).get("json_schema", {}).get("name") assert schema_name == "evidence_critique" return httpx.Response( 200, json={ "choices": [ { "message": { "content": json.dumps( { "critique_summary": "Critique evaluation completed.", "risks": ["Budget unclear"], "strengths": ["Clear need"], "cross_check_notes": "Cross-check consistent.", } ) } } ] }, ) transport = httpx.MockTransport(handler) async with httpx.AsyncClient(transport=transport) as http_client: client = AIHubClient(base_url="https://aihub.internal", http_client=http_client) evidence = EvidenceExtractionResult(summary="Evidence summary", intent="high") result = await client.critique_evidence( make_test_snapshot(), evidence, vkey="vkey-heavy-1", run_id="run-heavy-1", model="custom-model", timeout_seconds=5.0, ) assert result.critique_summary == "Critique evaluation completed." assert result.risks == ["Budget unclear"] assert len(captured_requests) == 1 user_content = captured_requests[0]["messages"][1]["content"] assert "snapshot" in user_content assert "evidence" in user_content @pytest.mark.asyncio async def test_aihub_client_score_lead_heavy_pass(): captured_requests: list[dict[str, Any]] = [] def handler(request: httpx.Request) -> httpx.Response: assert request.url.path == CHAT_COMPLETIONS_PATH assert request.headers.get("Authorization") == "Bearer vkey-heavy-1" assert request.headers.get("x-request-id") == "run-heavy-1" body = json.loads(request.content.decode("utf-8")) captured_requests.append(body) schema_name = body.get("response_format", {}).get("json_schema", {}).get("name") assert schema_name == "heavy_lead_scoring" return httpx.Response( 200, json={ "choices": [ { "message": { "content": json.dumps( { "score": 92, "confidence": 0.94, "reasoning": "Synthesized evidence and critique.", "positive_factors": ["High demand", "Validated strength"], "negative_factors": ["Minor risk"], } ) } } ] }, ) transport = httpx.MockTransport(handler) async with httpx.AsyncClient(transport=transport) as http_client: client = AIHubClient(base_url="https://aihub.internal", http_client=http_client) evidence = EvidenceExtractionResult(summary="Evidence summary", intent="high") critique = AiCritiqueResult( critique_summary="Critique summary", risks=["Minor risk"], strengths=["Validated strength"], ) result = await client.score_lead_heavy( evidence, critique, vkey="vkey-heavy-1", run_id="run-heavy-1", model="custom-model", timeout_seconds=5.0, ) assert result.score == 92 assert result.confidence == 0.94 assert len(captured_requests) == 1 # Validate Pass 3 payload does NOT contain raw snapshot fields user_content = captured_requests[0]["messages"][1]["content"] assert "lead_profile" not in user_content assert "conversations" not in user_content assert "evidence" in user_content assert "critique" in user_content @pytest.mark.asyncio async def test_run_light_ai_pipeline_single_pass_success(): call_count = 0 captured_headers: list[dict[str, str]] = [] def handler(request: httpx.Request) -> httpx.Response: nonlocal call_count call_count += 1 captured_headers.append(dict(request.headers)) return httpx.Response( 200, json={ "choices": [ { "message": { "content": json.dumps( { "score": 78, "confidence": 0.86, "reasoning": "Light pipeline completed.", "positive_factors": ["Fast response"], "negative_factors": [], } ) } } ] }, ) transport = httpx.MockTransport(handler) async with httpx.AsyncClient(transport=transport) as http_client: client = AIHubClient(base_url="https://aihub.internal", http_client=http_client) result = await run_light_ai_pipeline( client, make_test_snapshot(), vkey="vkey-light", run_id="run-light", model="openai/gpt-4o-mini", app="dotb_omni", feature="lead-scoring", ) assert result.ai_score == 78 assert result.confidence == 0.86 assert result.metadata["pass_count"] == 1 assert result.metadata["effort_profile"] == "light" assert call_count == 1 assert len(captured_headers) == 1 assert captured_headers[0]["x-bf-dim-app"] == "dotb_omni" assert captured_headers[0]["x-bf-dim-feature"] == "lead-scoring" assert captured_headers[0]["x-request-id"] == "run-light" @pytest.mark.asyncio async def test_run_heavy_ai_pipeline_three_pass_success(): call_count = 0 passed_schemas: list[str] = [] captured_headers: list[dict[str, str]] = [] def handler(request: httpx.Request) -> httpx.Response: nonlocal call_count call_count += 1 captured_headers.append(dict(request.headers)) body = json.loads(request.content.decode("utf-8")) schema_name = body.get("response_format", {}).get("json_schema", {}).get("name") passed_schemas.append(schema_name) if schema_name == "evidence_extraction": return httpx.Response( 200, json={ "choices": [ { "message": { "content": json.dumps( { "summary": "Pass 1 evidence extraction", "intent": "high", "key_signals": ["Signal 1"], } ) } } ] }, ) elif schema_name == "evidence_critique": return httpx.Response( 200, json={ "choices": [ { "message": { "content": json.dumps( { "critique_summary": "Pass 2 critique", "risks": ["Risk 1"], "strengths": ["Strength 1"], } ) } } ] }, ) elif schema_name == "heavy_lead_scoring": return httpx.Response( 200, json={ "choices": [ { "message": { "content": json.dumps( { "score": 95, "confidence": 0.96, "reasoning": "Pass 3 heavy scoring complete.", "positive_factors": ["Strength 1"], "negative_factors": ["Risk 1"], } ) } } ] }, ) return httpx.Response(404) transport = httpx.MockTransport(handler) async with httpx.AsyncClient(transport=transport) as http_client: client = AIHubClient(base_url="https://aihub.internal", http_client=http_client) result = await run_heavy_ai_pipeline( client, make_test_snapshot(), vkey="vkey-heavy", run_id="run-heavy", model="openai/gpt-4o-mini", app="dotb_omni", feature="lead-scoring", ) assert result.ai_score == 95 assert result.confidence == 0.96 assert result.metadata["pass_count"] == 3 assert result.metadata["effort_profile"] == "heavy" assert call_count == 3 assert passed_schemas == ["evidence_extraction", "evidence_critique", "heavy_lead_scoring"] assert len(captured_headers) == 3 for h in captured_headers: assert h["x-bf-dim-app"] == "dotb_omni" assert h["x-bf-dim-feature"] == "lead-scoring" assert h["x-request-id"] == "run-heavy" @pytest.mark.asyncio async def test_run_heavy_ai_pipeline_pass_1_failure_prevents_subsequent_passes(): call_count = 0 def handler(request: httpx.Request) -> httpx.Response: nonlocal call_count call_count += 1 return httpx.Response(500, json={"error": "Pass 1 failed"}) transport = httpx.MockTransport(handler) async with httpx.AsyncClient(transport=transport) as http_client: client = AIHubClient(base_url="https://aihub.internal", http_client=http_client) with pytest.raises(AIHubServerError): await run_heavy_ai_pipeline( client, make_test_snapshot(), vkey="vkey-heavy", run_id="run-heavy", ) assert call_count == 1 @pytest.mark.asyncio async def test_run_heavy_ai_pipeline_pass_2_failure_prevents_pass_3(): call_count = 0 def handler(request: httpx.Request) -> httpx.Response: nonlocal call_count call_count += 1 body = json.loads(request.content.decode("utf-8")) schema_name = body.get("response_format", {}).get("json_schema", {}).get("name") if schema_name == "evidence_extraction": return httpx.Response( 200, json={"choices": [{"message": {"content": json.dumps({"summary": "Pass 1 ok"})}}]}, ) elif schema_name == "evidence_critique": return httpx.Response(429, json={"error": "Rate limit in Pass 2"}) return httpx.Response(404) transport = httpx.MockTransport(handler) async with httpx.AsyncClient(transport=transport) as http_client: client = AIHubClient(base_url="https://aihub.internal", http_client=http_client) with pytest.raises(AIHubQuotaError): await run_heavy_ai_pipeline( client, make_test_snapshot(), vkey="vkey-heavy", run_id="run-heavy", ) assert call_count == 2 @pytest.mark.asyncio async def test_run_heavy_ai_pipeline_pass_3_failure_discards_partial_results(): call_count = 0 def handler(request: httpx.Request) -> httpx.Response: nonlocal call_count call_count += 1 body = json.loads(request.content.decode("utf-8")) schema_name = body.get("response_format", {}).get("json_schema", {}).get("name") if schema_name == "evidence_extraction": return httpx.Response( 200, json={"choices": [{"message": {"content": json.dumps({"summary": "Pass 1 ok"})}}]}, ) elif schema_name == "evidence_critique": return httpx.Response( 200, json={ "choices": [ { "message": { "content": json.dumps( { "critique_summary": "Pass 2 ok", "risks": [], "strengths": [], } ) } } ] }, ) elif schema_name == "heavy_lead_scoring": return httpx.Response(502, text="Bad Gateway on Pass 3") return httpx.Response(404) transport = httpx.MockTransport(handler) async with httpx.AsyncClient(transport=transport) as http_client: client = AIHubClient(base_url="https://aihub.internal", http_client=http_client) with pytest.raises(AIHubServerError): await run_heavy_ai_pipeline( client, make_test_snapshot(), vkey="vkey-heavy", run_id="run-heavy", ) assert call_count == 3 def test_to_strict_json_schema_recursively_requires_all_properties_and_forbids_additional(): def assert_no_default_keywords(node: Any) -> None: if isinstance(node, dict): assert "default" not in node, f"Found 'default' keyword in strict schema node: {node}" for v in node.values(): assert_no_default_keywords(v) elif isinstance(node, list): for item in node: assert_no_default_keywords(item) # 1. EvidenceExtractionResult schema_evidence = to_strict_json_schema(EvidenceExtractionResult) assert set(schema_evidence["required"]) == set(schema_evidence["properties"].keys()) assert schema_evidence["additionalProperties"] is False assert_no_default_keywords(schema_evidence) # 2. AiCritiqueResult schema_critique = to_strict_json_schema(AiCritiqueResult) assert set(schema_critique["required"]) == set(schema_critique["properties"].keys()) assert schema_critique["additionalProperties"] is False assert_no_default_keywords(schema_critique) # 3. AiScoringResult schema_scoring = to_strict_json_schema(AiScoringResult) assert set(schema_scoring["required"]) == set(schema_scoring["properties"].keys()) assert schema_scoring["additionalProperties"] is False assert_no_default_keywords(schema_scoring) class SlowDripAsyncStream(httpx.AsyncByteStream): def __init__(self, chunks: list[bytes], delay: float = 0.05): self.chunks = chunks self.delay = delay async def __aiter__(self): for chunk in self.chunks: await asyncio.sleep(self.delay) yield chunk @pytest.mark.asyncio async def test_aihub_client_wall_clock_timeout_aborts_slow_drip_stream(): # 10 chunks of 100 bytes with 0.05s delay each = 0.5s total time # With timeout_seconds = 0.1s, hard wall-clock timeout MUST abort chunks = [b"x" * 100 for _ in range(10)] stream = SlowDripAsyncStream(chunks, delay=0.05) def handler(request: httpx.Request) -> httpx.Response: return httpx.Response(200, stream=stream) transport = httpx.MockTransport(handler) async with httpx.AsyncClient(transport=transport) as http_client: client = AIHubClient(base_url="https://aihub.internal", http_client=http_client) with pytest.raises(AIHubTimeoutError) as exc_info: await client.extract_evidence( make_test_snapshot(), vkey="valid-vkey", run_id="run-slow-drip", timeout_seconds=0.1, ) assert exc_info.value.error_code == "aihub_timeout" @pytest.mark.asyncio async def test_aihub_client_trust_env_false_by_default(monkeypatch: pytest.MonkeyPatch): monkeypatch.setenv("HTTP_PROXY", "http://untrusted-proxy.local:8080") monkeypatch.setenv("HTTPS_PROXY", "http://untrusted-proxy.local:8080") # When no http_client is passed, internal httpx.AsyncClient is created with trust_env=False client = AIHubClient(base_url="https://aihub.internal") # Direct connection without proxy routing fails with network error/timeout with pytest.raises(AIHubError) as exc_info: await client.extract_evidence( make_test_snapshot(), vkey="valid-vkey", run_id="run-trust-env", timeout_seconds=0.1, ) # The error must be normalized and not route to the fake proxy assert exc_info.value.error_code in ("aihub_network_error", "aihub_timeout") def test_reduce_snapshot_bounds_oversized_custom_fields_and_details(): from lead_scoring.aihub.client import reduce_snapshot_for_prompt # Create oversized custom fields (> 1.2MB with 200 keys and huge strings) large_custom_fields = {f"field_{i:03d}": "X" * 10000 for i in range(150)} large_details = {f"detail_{i:03d}": "Y" * 5000 for i in range(50)} raw_snapshot_data = { "captured_at": "2026-08-27T12:00:00Z", "lead_profile": { "ems_lead_id": "lead-oversized-1", "name": "Nguyen Van C", "custom_fields": large_custom_fields, }, "conversations": [ { "conversation_id": "conv-1", "messages": [ { "message_id": "msg-1", "sender_type": "contact", "content": "Can tu van khoa hoc AI", "sent_at": "2026-08-27T11:59:00Z", } ], } ], "identity_context": { "match_quality": "direct", "contact_id": "cnt-1", "details": large_details, }, "truncation": { "truncated": True, "dropped_conversations_count": 2, "dropped_messages_count": 5, "reason": "Omni structural caps reached", }, } snapshot = CanonicalSnapshot.model_validate(raw_snapshot_data) # Validate that the original snapshot still holds the full data assert len(snapshot.lead_profile.custom_fields) == 150 assert len(snapshot.identity_context.details) == 50 # type: ignore # Reduce snapshot for prompt serialization reduced = reduce_snapshot_for_prompt(snapshot) # Validate that reduced prompt content is strictly bounded reduced_custom_fields = reduced["lead_profile"]["custom_fields"] assert len(reduced_custom_fields) <= 50 assert "_truncated_lineage" in reduced_custom_fields # Total serialized size of custom_fields is well within token bounds (< 6KB) serialized_cf = json.dumps(reduced_custom_fields) assert len(serialized_cf) < 6000 # Details in identity_context are also bounded reduced_details = reduced["identity_context"]["details"] assert len(reduced_details) <= 50 assert "_truncated_lineage" in reduced_details assert len(json.dumps(reduced_details)) < 6000 # Truncation lineage is preserved assert reduced["truncation_lineage"]["truncated"] is True assert reduced["truncation_lineage"]["dropped_conversations_count"] == 2 # Original snapshot object in memory is NOT mutated assert len(snapshot.lead_profile.custom_fields) == 150 assert len(snapshot.lead_profile.custom_fields["field_000"]) == 10000 @pytest.mark.asyncio async def test_pipelines_forward_customer_rules_and_behavioral_metrics(): # Test light, medium, and heavy pipeline execution with customer_rules and behavioral_metrics customer_rules = [{"field": "age", "operator": "gte", "value": 18, "points": 20}] behavioral_metrics = {"message_count": 5, "conversation_count": 2, "conversation_turn_count": 3} captured_payloads: list[dict[str, Any]] = [] def handler(request: httpx.Request) -> httpx.Response: body = json.loads(request.content.decode("utf-8")) captured_payloads.append(body) schema_name = body.get("response_format", {}).get("json_schema", {}).get("name") if schema_name == "light_lead_scoring": return httpx.Response( 200, json={ "choices": [ { "message": { "content": json.dumps( { "score": 80, "confidence": 0.9, "reasoning": "ok", "positive_factors": [], "negative_factors": [], } ) } } ] }, ) elif schema_name == "evidence_extraction": return httpx.Response( 200, json={ "choices": [ { "message": { "content": json.dumps({"summary": "extracted", "intent": "high"}) } } ] }, ) elif schema_name == "lead_scoring": return httpx.Response( 200, json={ "choices": [ { "message": { "content": json.dumps( { "score": 85, "confidence": 0.9, "reasoning": "ok", "positive_factors": [], "negative_factors": [], } ) } } ] }, ) elif schema_name == "evidence_critique": return httpx.Response( 200, json={ "choices": [ { "message": { "content": json.dumps( {"critique_summary": "critique", "risks": [], "strengths": []} ) } } ] }, ) elif schema_name == "heavy_lead_scoring": return httpx.Response( 200, json={ "choices": [ { "message": { "content": json.dumps( { "score": 90, "confidence": 0.95, "reasoning": "ok", "positive_factors": [], "negative_factors": [], } ) } } ] }, ) return httpx.Response(404) transport = httpx.MockTransport(handler) async with httpx.AsyncClient(transport=transport) as http_client: client = AIHubClient(base_url="https://aihub.internal", http_client=http_client) # 1. Light pipeline await run_light_ai_pipeline( client, make_test_snapshot(), vkey="vkey-test", run_id="run-light-b", customer_rules=customer_rules, behavioral_metrics=behavioral_metrics, ) light_content = json.loads(captured_payloads[-1]["messages"][1]["content"]) assert light_content["customer_rules"] == customer_rules assert light_content["behavioral_metrics"] == behavioral_metrics # 2. Medium pipeline await run_medium_ai_pipeline( client, make_test_snapshot(), vkey="vkey-test", run_id="run-medium-b", customer_rules=customer_rules, behavioral_metrics=behavioral_metrics, ) # Pass 1 evidence extraction has reduced snapshot with rules and metrics p1_content = json.loads(captured_payloads[-2]["messages"][1]["content"]) assert p1_content["customer_rules"] == customer_rules assert p1_content["behavioral_metrics"] == behavioral_metrics # Pass 2 scoring has rules and metrics in payload p2_content = json.loads(captured_payloads[-1]["messages"][1]["content"]) assert p2_content["customer_rules"] == customer_rules assert p2_content["behavioral_metrics"] == behavioral_metrics # 3. Heavy pipeline await run_heavy_ai_pipeline( client, make_test_snapshot(), vkey="vkey-test", run_id="run-heavy-b", customer_rules=customer_rules, behavioral_metrics=behavioral_metrics, ) # Pass 3 heavy scoring has rules and metrics p3_content = json.loads(captured_payloads[-1]["messages"][1]["content"]) assert p3_content["customer_rules"] == customer_rules assert p3_content["behavioral_metrics"] == behavioral_metrics def test_reduce_snapshot_exactly_51_short_entries_sets_lineage_and_metadata(): from lead_scoring.aihub.client import reduce_snapshot_for_prompt # Exactly 51 short entries (each entry is small and fits in token chars) short_custom_fields = {f"k_{i:02d}": f"v_{i:02d}" for i in range(51)} raw_snapshot_data = { "captured_at": "2026-08-27T12:00:00Z", "lead_profile": { "ems_lead_id": "lead-51-entries", "name": "Short Fields Lead", "custom_fields": short_custom_fields, }, "conversations": [], } snapshot = CanonicalSnapshot.model_validate(raw_snapshot_data) reduced = reduce_snapshot_for_prompt(snapshot) # Validate that custom_fields is bounded to 50 items plus the _truncated_lineage marker reduced_cf = reduced["lead_profile"]["custom_fields"] assert "_truncated_lineage" in reduced_cf # 50 entries retained + 1 marker = 51 keys in reduced_cf assert len(reduced_cf) == 51 # Top-level truncation_lineage is populated and marked truncated assert reduced["truncation_lineage"] is not None assert reduced["truncation_lineage"]["truncated"] is True assert "token budget" in reduced["truncation_lineage"]["reason"] def test_httpx_is_in_project_runtime_dependencies(): import tomllib from pathlib import Path pyproject_path = Path(__file__).parent.parent / "pyproject.toml" with open(pyproject_path, "rb") as f: data = tomllib.load(f) runtime_deps = data.get("project", {}).get("dependencies", []) assert any(dep.startswith("httpx") for dep in runtime_deps)