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.
115 lines
4.1 KiB
Python
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"))
|