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.
174 lines
6.4 KiB
Python
174 lines
6.4 KiB
Python
import json
|
|
import threading
|
|
from uuid import uuid4
|
|
|
|
import httpx
|
|
import pytest
|
|
from fastapi.testclient import TestClient
|
|
from sqlalchemy import func, select
|
|
|
|
from ai_platform.infrastructure.persistence.models import AIProfile, ScoringRun
|
|
|
|
URL = "/v1/lead-scoring/score"
|
|
ASSESSMENT = {
|
|
"signals": [
|
|
{
|
|
"polarity": "positive",
|
|
"signal": "Hỏi giá",
|
|
"detail": "Hỏi học phí.",
|
|
"source": "message-789",
|
|
}
|
|
],
|
|
"score": 90,
|
|
"summary": "Khách quan tâm rõ ràng.",
|
|
"suggested_action": "Gọi lại trong ngày.",
|
|
}
|
|
|
|
|
|
def _completion(content: dict) -> httpx.Response:
|
|
return httpx.Response(
|
|
200, json={"choices": [{"message": {"content": json.dumps(content, ensure_ascii=False)}}]}
|
|
)
|
|
|
|
|
|
def _ai(calls: list[httpx.Request]):
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
calls.append(request)
|
|
return _completion(ASSESSMENT)
|
|
|
|
return handler
|
|
|
|
|
|
def _post(client, env, body, *, key=None, ai_key=False, **headers):
|
|
headers = {**env["headers"](key), **({"X-AI-Key": env["ai_key"]} if ai_key else {}), **headers}
|
|
return client.post(URL, headers=headers, json=body)
|
|
|
|
|
|
def _hybrid(request: dict) -> dict:
|
|
request["scoring_config"].update(rule_weight=0.5, ai_weight=0.5)
|
|
return request
|
|
|
|
|
|
def test_rules_only_never_calls_ai(score_env, canonical_request):
|
|
calls: list[httpx.Request] = []
|
|
with TestClient(score_env["app_for"](_ai(calls))) as client:
|
|
response = _post(client, score_env, canonical_request)
|
|
body = response.json()
|
|
assert response.status_code == 200
|
|
assert (body["result_mode"], body["final_score"], body["rule_score"]) == ("rules_only", 50, 50)
|
|
assert (body["ai"], body["metrics"]["message_count"]) == (None, 12)
|
|
assert body["metadata"] == canonical_request["metadata"]
|
|
assert not calls
|
|
|
|
|
|
def test_hybrid_uses_one_ai_call_and_never_stores_or_logs_the_ai_key(
|
|
score_env, canonical_request, caplog
|
|
):
|
|
calls: list[httpx.Request] = []
|
|
with TestClient(score_env["app_for"](_ai(calls))) as client:
|
|
response = _post(client, score_env, _hybrid(canonical_request), ai_key=True)
|
|
body = response.json()
|
|
assert response.status_code == 200
|
|
assert (body["result_mode"], body["ai_score"], body["final_score"]) == ("hybrid", 90, 70)
|
|
assert body["ai"]["signals"][0]["source"] == "message-789"
|
|
assert len(calls) == 1
|
|
assert calls[0].headers["authorization"] == f"Bearer {score_env['ai_key']}"
|
|
assert (
|
|
"Thời điểm chấm: Thứ Năm, 25/09/2025 17:00"
|
|
in json.loads(calls[0].content)["messages"][1]["content"]
|
|
)
|
|
|
|
stored = score_env["read"](
|
|
lambda db: db.scalar(select(ScoringRun.request).where(ScoringRun.id == body["run_id"]))
|
|
)
|
|
assert score_env["ai_key"] not in response.text + json.dumps(stored) + caplog.text
|
|
assert score_env["app_key"] not in response.text + caplog.text
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("ai_key", "handler", "error_code"),
|
|
[
|
|
(False, lambda r: httpx.Response(500), "ai_key_missing"),
|
|
(True, lambda r: httpx.Response(500), "ai_http_error"),
|
|
(True, lambda r: _completion({"score": "bad"}), "ai_invalid_assessment"),
|
|
],
|
|
)
|
|
def test_ai_failure_degrades_to_rule_score(
|
|
score_env, canonical_request, ai_key, handler, error_code
|
|
):
|
|
with TestClient(score_env["app_for"](handler)) as client:
|
|
response = _post(client, score_env, _hybrid(canonical_request), ai_key=ai_key)
|
|
body = response.json()
|
|
assert response.status_code == 200
|
|
assert (body["result_mode"], body["error_code"], body["ai_score"]) == (
|
|
"rules_only_fallback",
|
|
error_code,
|
|
None,
|
|
)
|
|
assert body["final_score"] == body["rule_score"] == 50
|
|
|
|
|
|
def test_missing_active_profile_degrades_to_rule_score(score_env, canonical_request):
|
|
async def deactivate(db):
|
|
for profile in (await db.scalars(select(AIProfile))).all():
|
|
profile.active = False
|
|
|
|
score_env["read"](deactivate)
|
|
with TestClient(score_env["app_for"](_ai([]))) as client:
|
|
body = _post(client, score_env, _hybrid(canonical_request), ai_key=True).json()
|
|
assert (body["result_mode"], body["error_code"]) == (
|
|
"rules_only_fallback",
|
|
"ai_profile_missing",
|
|
)
|
|
|
|
|
|
def test_same_key_replays_without_calling_ai_and_a_changed_body_is_422(
|
|
score_env, canonical_request
|
|
):
|
|
calls: list[httpx.Request] = []
|
|
request, key = _hybrid(canonical_request), str(uuid4())
|
|
changed = {**request, "metadata": {"other": 1}}
|
|
with TestClient(score_env["app_for"](_ai(calls))) as client:
|
|
first = _post(client, score_env, request, key=key, ai_key=True)
|
|
again = _post(client, score_env, request, key=key, ai_key=True)
|
|
conflict = _post(client, score_env, changed, key=key, ai_key=True)
|
|
assert first.status_code == again.status_code == 200
|
|
assert again.json() == first.json()
|
|
assert len(calls) == 1
|
|
assert conflict.status_code == 422
|
|
|
|
|
|
def test_concurrent_requests_with_one_key_store_one_run(score_env, canonical_request):
|
|
request, key = _hybrid(canonical_request), str(uuid4())
|
|
statuses: list[int] = []
|
|
|
|
def send() -> None:
|
|
with TestClient(score_env["app_for"](_ai([]))) as client:
|
|
statuses.append(_post(client, score_env, request, key=key, ai_key=True).status_code)
|
|
|
|
threads = [threading.Thread(target=send) for _ in range(2)]
|
|
[t.start() for t in threads]
|
|
[t.join() for t in threads]
|
|
count = score_env["read"](
|
|
lambda db: db.scalar(
|
|
select(func.count()).select_from(ScoringRun).where(ScoringRun.idempotency_key == key)
|
|
)
|
|
)
|
|
assert (statuses, count) == ([200, 200], 1)
|
|
|
|
|
|
def test_auth_and_input_errors(score_env, canonical_request):
|
|
legacy = {**canonical_request, "account_id": "3"}
|
|
with TestClient(score_env["app_for"](_ai([]))) as client:
|
|
no_key = client.post(URL, headers={"Idempotency-Key": str(uuid4())}, json=canonical_request)
|
|
bad_key = _post(client, score_env, canonical_request, Authorization="Bearer wrong")
|
|
extra_field = _post(client, score_env, legacy)
|
|
long_ai_key = client.post(
|
|
URL,
|
|
headers={**score_env["headers"](), "X-AI-Key": "k" * 5_000},
|
|
json=canonical_request,
|
|
)
|
|
assert (no_key.status_code, bad_key.status_code) == (401, 401)
|
|
assert (extra_field.status_code, long_ai_key.status_code) == (422, 422)
|
|
assert all(set(error) == {"loc", "msg"} for error in extra_field.json()["detail"])
|