dotb_ai-platform/tests/test_chat_completions.py
hungtran24 6577bb74be refactor: simplify scoring service, AI profile revisions and test suite
Phase 2 and 3 of the platform rework.

- Neutral request/response contract: external_id, metadata, X-AI-Key,
  run_id in the response; client-specific fields removed.
- AI profile is a revision table: saving creates an inactive revision,
  activation is separate, try-run does not store a run. One
  chat-completions gateway serves AIHub and OpenAI-compatible profiles.
- Add between and in rule operators.
- Contracts moved to contracts/lead-scoring/ with a score-response fixture.
- Admin Web updated for revisions, runs and the neutral fixture.
- Consolidate tests; remove rewrite_architect.md and the stale handoff doc;
  rewrite spec, CONTEXT and ARCHITECTURE for the current design.

Not verified: live AIHub/OpenAI behavior, Omni/EMS integration.
2026-10-09 08:23:21 +07:00

115 lines
4.1 KiB
Python

import json
from datetime import UTC, datetime
import httpx
import pytest
from ai_platform.infrastructure.chat_completions import make_provider_factory
from ai_platform.lead_scoring.application.ai_provider import AIProviderFailure
from ai_platform.lead_scoring.application.repository import AIProfileRecord
GOOD = {
"signals": [{"polarity": "negative", "signal": "s" * 200, "detail": "d", "source": "m1"}],
"score": 30,
"summary": "x" * 400,
"suggested_action": None,
}
def _profile(provider="aihub", base_url="https://ai.example/v1", **overrides) -> AIProfileRecord:
fields = dict(
provider=provider,
base_url=base_url,
model="m",
timeout_seconds=5,
reasoning_effort=None,
instructions="i",
include_reference_date=False,
timezone="UTC",
suggested_action_enabled=True,
revision=1,
active=True,
created_by="a",
created_at=datetime(2026, 1, 1, tzinfo=UTC),
)
return AIProfileRecord(**{**fields, **overrides})
async def _assess(handler, profile, ai_key="caller-key", **factory_args):
seen: list[httpx.Request] = []
def record(request: httpx.Request) -> httpx.Response:
seen.append(request)
return handler(request)
async with httpx.AsyncClient(transport=httpx.MockTransport(record)) as client:
factory = make_provider_factory(
client,
environment=factory_args.get("environment", "production"),
server_base_url=factory_args.get("server_base_url"),
server_key=factory_args.get("server_key"),
)
provider = factory(profile, ai_key)
result = await provider.assess(
model=profile.model,
system_prompt="s",
user_prompt="u",
reasoning_effort=profile.reasoning_effort,
timeout=5,
)
return result, seen
def _ok(content=GOOD):
return lambda request: httpx.Response(
200, json={"choices": [{"message": {"content": json.dumps(content)}}]}
)
@pytest.mark.asyncio
async def test_request_shape_and_trimmed_response():
result, seen = await _assess(_ok(), _profile())
body = json.loads(seen[0].content)
assert str(seen[0].url) == "https://ai.example/v1/chat/completions"
assert seen[0].headers["authorization"] == "Bearer caller-key"
assert (body["temperature"], "reasoning_effort" in body) == (0, False)
assert body["response_format"] == {"type": "json_object"}
assert (len(result.summary), len(result.signals[0].signal), result.score) == (300, 80, 30)
_, seen = await _assess(_ok(), _profile(reasoning_effort="low"))
body = json.loads(seen[0].content)
assert (body["reasoning_effort"], "temperature" in body) == ("low", False)
@pytest.mark.asyncio
async def test_openai_compatible_sends_the_server_key_only_to_its_own_url():
own = _profile("openai_compatible", "https://proxy.example/v1")
other = _profile("openai_compatible", "https://other.example/v1")
args = {"server_base_url": "https://proxy.example/v1/", "server_key": "server-key"}
_, seen_own = await _assess(_ok(), own, **args)
_, seen_other = await _assess(_ok(), other, **args)
assert seen_own[0].headers["authorization"] == "Bearer server-key"
assert "authorization" not in seen_other[0].headers
@pytest.mark.asyncio
@pytest.mark.parametrize(
("handler", "error_code"),
[
(lambda r: httpx.Response(500), "ai_http_error"),
(lambda r: httpx.Response(200, json={"choices": []}), "ai_invalid_response"),
(_ok({**GOOD, "score": 101}), "ai_invalid_assessment"),
(_ok({**GOOD, "extra": 1}), "ai_invalid_assessment"),
],
)
async def test_bad_responses_become_stable_error_codes(handler, error_code):
with pytest.raises(AIProviderFailure) as raised:
await _assess(handler, _profile())
assert raised.value.error_code == error_code
@pytest.mark.asyncio
async def test_unsafe_profile_url_is_refused_before_any_request():
with pytest.raises(AIProviderFailure, match="ai_url_not_allowed"):
await _assess(_ok(), _profile(base_url="http://remote.example/v1"))