dotb_ai-platform/tests/test_aihub_client.py
2026-09-14 09:57:12 +07:00

1596 lines
60 KiB
Python

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)