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.
113 lines
4.2 KiB
Python
113 lines
4.2 KiB
Python
from uuid import uuid4
|
|
|
|
import json
|
|
|
|
import httpx
|
|
import pytest
|
|
from fastapi.testclient import TestClient
|
|
from pwdlib import PasswordHash
|
|
from sqlalchemy import delete, func, select
|
|
|
|
from ai_platform.infrastructure.persistence.models import (
|
|
AdminSession,
|
|
AdminUser,
|
|
AIProfile,
|
|
ScoringRun,
|
|
)
|
|
|
|
PROFILE = {
|
|
"provider": "aihub",
|
|
"base_url": "http://localhost:8080/v1",
|
|
"model": "openai/gpt-6-luna",
|
|
"reasoning_effort": "low",
|
|
"instructions": "Chấm điểm lead giáo dục.",
|
|
}
|
|
COMPLETION = {
|
|
"signals": [],
|
|
"score": 70,
|
|
"summary": "Tạm ổn.",
|
|
"suggested_action": "Gọi lại.",
|
|
}
|
|
|
|
|
|
@pytest.fixture
|
|
def admin(score_env):
|
|
email, password = f"admin-{uuid4().hex}@example.invalid", "test-only-password"
|
|
|
|
async def add(db):
|
|
db.add(AdminUser(email=email, password_hash=PasswordHash.recommended().hash(password)))
|
|
|
|
async def drop(db):
|
|
await db.execute(delete(AdminSession).where(AdminSession.admin_email == email))
|
|
await db.execute(delete(AdminUser).where(AdminUser.email == email))
|
|
|
|
score_env["read"](add)
|
|
yield {"email": email, "password": password}
|
|
score_env["read"](drop)
|
|
|
|
|
|
def _login(client, admin):
|
|
login = client.post("/v1/admin/login", json=admin)
|
|
assert login.status_code == 200
|
|
return {"x-csrf-token": login.json()["csrf_token"]}
|
|
|
|
|
|
def test_save_creates_a_revision_and_activation_is_a_separate_step(score_env, admin):
|
|
app = score_env["app_for"](lambda r: httpx.Response(500))
|
|
with TestClient(app) as client:
|
|
assert client.post("/v1/admin/ai-profiles", json=PROFILE).status_code == 401
|
|
csrf = _login(client, admin)
|
|
no_csrf = client.post("/v1/admin/ai-profiles", json=PROFILE)
|
|
invalid = [
|
|
{**PROFILE, "base_url": "http://remote.example/v1"},
|
|
{**PROFILE, "timezone": "Not/AZone"},
|
|
{**PROFILE, "reasoning_effort": "extreme"},
|
|
]
|
|
rejected = [client.post("/v1/admin/ai-profiles", json=p, headers=csrf) for p in invalid]
|
|
first = client.post("/v1/admin/ai-profiles", json=PROFILE, headers=csrf).json()
|
|
second = client.post(
|
|
"/v1/admin/ai-profiles", json={**PROFILE, "model": "other"}, headers=csrf
|
|
).json()
|
|
activated = client.post(f"/v1/admin/ai-profiles/{first['revision']}/activate", headers=csrf)
|
|
missing = client.post("/v1/admin/ai-profiles/999999999/activate", headers=csrf)
|
|
listed = client.get("/v1/admin/ai-profiles").json()["profiles"]
|
|
score_env["read"](
|
|
lambda db: db.execute(
|
|
delete(AIProfile).where(AIProfile.revision.in_([first["revision"], second["revision"]]))
|
|
)
|
|
)
|
|
assert no_csrf.status_code == 403
|
|
assert [r.status_code for r in rejected] == [422, 422, 422]
|
|
assert second["revision"] > first["revision"] and not first["active"]
|
|
assert (activated.status_code, activated.json()["active"], missing.status_code) == (
|
|
200,
|
|
True,
|
|
404,
|
|
)
|
|
active = [p["revision"] for p in listed if p["active"]]
|
|
assert active == [first["revision"]]
|
|
|
|
|
|
def test_try_runs_a_draft_without_saving_a_run_or_changing_the_active_profile(
|
|
score_env, admin, canonical_request
|
|
):
|
|
canonical_request["scoring_config"].update(rule_weight=0.5, ai_weight=0.5)
|
|
before = score_env["read"](lambda db: db.scalar(select(func.count()).select_from(ScoringRun)))
|
|
handler = lambda r: httpx.Response( # noqa: E731
|
|
200, json={"choices": [{"message": {"content": json.dumps(COMPLETION)}}]}
|
|
)
|
|
with TestClient(score_env["app_for"](handler)) as client:
|
|
csrf = _login(client, admin)
|
|
response = client.post(
|
|
"/v1/admin/ai-profiles/try",
|
|
headers={**csrf, "X-AI-Key": score_env["ai_key"]},
|
|
json={"profile": PROFILE, "request": canonical_request},
|
|
)
|
|
active = [p for p in client.get("/v1/admin/ai-profiles").json()["profiles"] if p["active"]]
|
|
body = response.json()
|
|
assert (response.status_code, body["result_mode"], body["ai_score"]) == (200, "hybrid", 70)
|
|
assert before == score_env["read"](
|
|
lambda db: db.scalar(select(func.count()).select_from(ScoringRun))
|
|
)
|
|
assert [p["model"] for p in active] == ["test-model"]
|