POST /v1/lead-scoring/score now evaluates in the request, applies an overall AI deadline, and stores the completed run only after scoring. Idempotency is kept by (app_id, account_id, request_id) with a body fingerprint. Removes the webhook outbox, scheduler/recovery loop, app lead_scoring permission, credential audit events, seed_demo and generate_secret scripts, and the tests that covered them. Trims tests to behavior of the new flow.
191 lines
7.2 KiB
Python
191 lines
7.2 KiB
Python
import asyncio
|
|
import json
|
|
import threading
|
|
from pathlib import Path
|
|
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 ScoringRun
|
|
|
|
FIXTURE = Path(__file__).parent / "fixtures" / "mock_omni_run.json"
|
|
URL = "/v1/lead-scoring/score"
|
|
|
|
|
|
def _completion(content: dict) -> httpx.Response:
|
|
return httpx.Response(
|
|
200, json={"choices": [{"message": {"content": json.dumps(content, ensure_ascii=False)}}]}
|
|
)
|
|
|
|
|
|
def _ai_handler(calls: list[httpx.Request]):
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
calls.append(request)
|
|
if "Trích xuất bằng chứng" in json.loads(request.content)["messages"][1]["content"]:
|
|
item = {"signal": "ý định", "detail": "Hỏi hồ sơ.", "source": "mock-message-003"}
|
|
return _completion({"items": [item]})
|
|
return _completion({"score": 40, "confidence": 0.9, "reasoning": "Nhu cầu rõ ràng."})
|
|
|
|
return handler
|
|
|
|
|
|
def _payload(*, rule_weight: float = 1, ai_weight: float = 0) -> dict:
|
|
payload = json.loads(FIXTURE.read_text())
|
|
payload["request_id"] = str(uuid4())
|
|
payload["scoring_config"].update(rule_weight=rule_weight, ai_weight=ai_weight)
|
|
return payload
|
|
|
|
|
|
def _post(client: TestClient, env: dict, payload: dict, *, vkey: bool = False, **headers):
|
|
all_headers = {
|
|
"Authorization": f"Bearer {env['app_key']}",
|
|
"Idempotency-Key": payload["request_id"],
|
|
**({"X-Tenant-VKey": env["vkey"]} if vkey else {}),
|
|
**headers,
|
|
}
|
|
return client.post(URL, headers=all_headers, json=payload)
|
|
|
|
|
|
def _row_count(env: dict, request_id: str) -> int:
|
|
async def count() -> int:
|
|
async with env["factory"]() as db:
|
|
total = await db.scalar(
|
|
select(func.count())
|
|
.select_from(ScoringRun)
|
|
.where(ScoringRun.request_id == request_id)
|
|
)
|
|
await env["factory"].kw["bind"].dispose()
|
|
return total or 0
|
|
|
|
return asyncio.run(count())
|
|
|
|
|
|
def test_rules_only_returns_completed_result_without_calling_ai(aihub_test_environment: dict):
|
|
calls: list[httpx.Request] = []
|
|
env = aihub_test_environment
|
|
with TestClient(env["app_for"](_ai_handler(calls))) as client:
|
|
response = _post(client, env, _payload())
|
|
body = response.json()
|
|
assert response.status_code == 200
|
|
assert (body["status"], body["result_mode"], body["error_code"]) == (
|
|
"completed",
|
|
"rules_only",
|
|
None,
|
|
)
|
|
assert body["final_score"] == body["rule_score"] == 80
|
|
assert body["metrics"]["message_count"] == 5
|
|
assert not calls
|
|
|
|
|
|
def test_hybrid_scores_in_one_request_and_never_stores_or_logs_the_ai_key(
|
|
aihub_test_environment: dict, caplog: pytest.LogCaptureFixture
|
|
):
|
|
calls: list[httpx.Request] = []
|
|
env = aihub_test_environment
|
|
payload = _payload(rule_weight=0.25, ai_weight=0.75)
|
|
with TestClient(env["app_for"](_ai_handler(calls))) as client:
|
|
response = _post(client, env, payload, vkey=True)
|
|
body = response.json()
|
|
assert response.status_code == 200
|
|
assert (body["result_mode"], body["ai_score"], body["final_score"]) == ("hybrid", 40, 50)
|
|
assert body["ai_evidence"][0]["source"] == "mock-message-003"
|
|
assert len(calls) == 2
|
|
assert all(c.headers["authorization"] == f"Bearer {env['vkey']}" for c in calls)
|
|
|
|
async def stored() -> str:
|
|
async with env["factory"]() as db:
|
|
row = await db.scalar(select(ScoringRun).where(ScoringRun.id == body["run_id"]))
|
|
await env["factory"].kw["bind"].dispose()
|
|
return json.dumps([row.request, row.ai_evidence, row.ai_reasoning])
|
|
|
|
assert env["vkey"] not in response.text + asyncio.run(stored()) + caplog.text
|
|
assert env["app_key"] not in response.text + caplog.text
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("vkey", "handler", "error_code"),
|
|
[
|
|
(False, lambda r: httpx.Response(500), "vkey_missing"),
|
|
(True, lambda r: httpx.Response(500), "aihub_http_error"),
|
|
(True, lambda r: _completion({"items": "bad"}), "aihub_invalid_evidence"),
|
|
],
|
|
)
|
|
def test_ai_failure_degrades_to_rule_score(aihub_test_environment: dict, vkey, handler, error_code):
|
|
env = aihub_test_environment
|
|
with TestClient(env["app_for"](handler)) as client:
|
|
response = _post(client, env, _payload(rule_weight=0.5, ai_weight=0.5), vkey=vkey)
|
|
body = response.json()
|
|
assert response.status_code == 200
|
|
assert (body["result_mode"], body["error_code"]) == ("rules_only_fallback", error_code)
|
|
assert body["final_score"] == body["rule_score"] == 80
|
|
assert body["ai_score"] is None
|
|
|
|
|
|
def test_same_key_replays_the_stored_result_without_calling_ai_again(
|
|
aihub_test_environment: dict,
|
|
):
|
|
calls: list[httpx.Request] = []
|
|
env = aihub_test_environment
|
|
payload = _payload(rule_weight=0.5, ai_weight=0.5)
|
|
with TestClient(env["app_for"](_ai_handler(calls))) as client:
|
|
first = _post(client, env, payload, vkey=True)
|
|
again = _post(client, env, payload, vkey=True)
|
|
assert first.status_code == again.status_code == 200
|
|
assert again.json()["run_id"] == first.json()["run_id"]
|
|
assert len(calls) == 2
|
|
|
|
|
|
def test_same_key_with_a_different_body_is_422(aihub_test_environment: dict):
|
|
env = aihub_test_environment
|
|
payload = _payload()
|
|
changed = {
|
|
**payload,
|
|
"scoring_config": {**payload["scoring_config"], "rule_weight": 0.9, "ai_weight": 0.1},
|
|
}
|
|
with TestClient(env["app_for"](_ai_handler([]))) as client:
|
|
assert _post(client, env, payload).status_code == 200
|
|
response = _post(client, env, changed)
|
|
assert response.status_code == 422
|
|
|
|
|
|
def test_concurrent_requests_with_one_key_store_one_run(aihub_test_environment: dict):
|
|
env = aihub_test_environment
|
|
payload = _payload(rule_weight=0.5, ai_weight=0.5)
|
|
statuses: list[int] = []
|
|
|
|
def send() -> None:
|
|
with TestClient(env["app_for"](_ai_handler([]))) as client:
|
|
statuses.append(_post(client, env, payload, vkey=True).status_code)
|
|
|
|
threads = [threading.Thread(target=send) for _ in range(2)]
|
|
[t.start() for t in threads]
|
|
[t.join() for t in threads]
|
|
assert statuses == [200, 200]
|
|
assert _row_count(env, payload["request_id"]) == 1
|
|
|
|
|
|
def test_authentication_and_validation_errors(aihub_test_environment: dict):
|
|
env = aihub_test_environment
|
|
payload = _payload()
|
|
with TestClient(env["app_for"](_ai_handler([]))) as client:
|
|
no_key = client.post(URL, headers={"Idempotency-Key": payload["request_id"]}, json=payload)
|
|
bad_key = _post(client, env, payload, Authorization="Bearer wrong")
|
|
invalid = _post(client, env, {**payload, "account_id": ""})
|
|
long_vkey = client.post(
|
|
URL,
|
|
headers={
|
|
"Authorization": f"Bearer {env['app_key']}",
|
|
"Idempotency-Key": _payload()["request_id"],
|
|
"X-Tenant-VKey": "k" * 5_000,
|
|
},
|
|
json=_payload(),
|
|
)
|
|
assert (no_key.status_code, bad_key.status_code) == (401, 401)
|
|
assert invalid.status_code == 422
|
|
assert all(set(error) == {"loc", "msg"} for error in invalid.json()["detail"])
|
|
assert long_vkey.status_code == 422
|
|
assert _row_count(env, payload["request_id"]) == 0
|