1596 lines
60 KiB
Python
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)
|