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.
162 lines
5.7 KiB
Python
162 lines
5.7 KiB
Python
import asyncio
|
|
import os
|
|
from collections.abc import Callable, Iterator
|
|
from typing import Any
|
|
from urllib.parse import urlsplit
|
|
from uuid import uuid4
|
|
|
|
import httpx
|
|
import pytest
|
|
from fastapi import FastAPI
|
|
from sqlalchemy import delete, select
|
|
|
|
from ai_platform.bootstrap.app import create_app
|
|
from ai_platform.infrastructure.config.settings import Settings
|
|
from ai_platform.infrastructure.persistence.database import make_session_factory
|
|
from ai_platform.infrastructure.persistence.models import AIProfile, AppCredential, ScoringRun
|
|
from ai_platform.platform.security import digest
|
|
|
|
|
|
@pytest.fixture
|
|
def isolated_settings(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""Keep pure configuration tests independent of operator credentials and local URLs."""
|
|
monkeypatch.setitem(Settings.model_config, "env_file", None)
|
|
for name in tuple(os.environ):
|
|
if name.startswith("AI_PLATFORM_"):
|
|
monkeypatch.delenv(name)
|
|
|
|
|
|
@pytest.fixture
|
|
def valid_run_payload():
|
|
return {
|
|
"request_id": "f81c7c4e-a92a-4b19-8d7a-3a5c08eb4e6e",
|
|
"account_id": "3",
|
|
"subject_ref": "lead-1",
|
|
"snapshot": {
|
|
"version": "1.0.0",
|
|
"scope": "linked_contact_history",
|
|
"captured_at": "2026-01-01T12:00:00Z",
|
|
"lead_profile": {
|
|
"ems_lead_id": "lead-1",
|
|
"name": "A. Example",
|
|
"custom_fields": {"region": "north"},
|
|
},
|
|
"conversations": [
|
|
{
|
|
"conversation_id": "conversation-2",
|
|
"channel": "webchat",
|
|
"assigned_agent_id": "agent-1",
|
|
"messages": [
|
|
{
|
|
"message_id": "message-2",
|
|
"sender_type": "agent",
|
|
"content": "Thanks for contacting us.",
|
|
"sent_at": "2025-12-31T11:00:00+00:00",
|
|
},
|
|
{
|
|
"message_id": "message-1",
|
|
"sender_type": "contact",
|
|
"content": "I am interested.",
|
|
"sent_at": "2025-12-31T10:00:00+00:00",
|
|
},
|
|
],
|
|
}
|
|
],
|
|
"identity_context": {
|
|
"match_quality": "phone_only",
|
|
"contact_id": "contact-1",
|
|
"details": {"source": "phone"},
|
|
},
|
|
"trigger_metadata": {
|
|
"trigger_type": "manual_omni",
|
|
"trigger_conversation_id": "conversation-2",
|
|
"actor_id": "user-1",
|
|
},
|
|
},
|
|
"scoring_config": {
|
|
"version": "omni-v1",
|
|
"rules": [
|
|
{
|
|
"id": "region",
|
|
"field": "custom_fields.region",
|
|
"operator": "eq",
|
|
"value": "north",
|
|
"points": 25,
|
|
}
|
|
],
|
|
"rule_weight": 1,
|
|
"ai_weight": 0,
|
|
},
|
|
"ai_profile": "light",
|
|
}
|
|
|
|
|
|
@pytest.fixture
|
|
def aihub_test_environment() -> Iterator[dict[str, Any]]:
|
|
loaded = Settings.load()
|
|
database_name = urlsplit(loaded.database_url).path.rstrip("/").rsplit("/", 1)[-1]
|
|
if database_name != "ai_platform_test":
|
|
raise RuntimeError("AIHub scoring integration tests require /ai_platform_test")
|
|
settings = Settings(
|
|
database_url=loaded.database_url,
|
|
environment="development",
|
|
aihub_base_url="http://localhost:8080",
|
|
)
|
|
factory = make_session_factory(settings.database_url)
|
|
suffix = uuid4().hex
|
|
app_id = f"aihub-test-{suffix}"
|
|
profile_version = f"aihub-test-{suffix}"
|
|
app_key = f"test-only-app-key-{suffix}"
|
|
vkey = "test-only-vkey-do-not-leak"
|
|
original_active: set[str] = set()
|
|
|
|
async def setup() -> None:
|
|
nonlocal original_active
|
|
async with factory() as db:
|
|
profiles = (await db.scalars(select(AIProfile))).all()
|
|
original_active = {profile.version for profile in profiles if profile.active}
|
|
for profile in profiles:
|
|
profile.active = False
|
|
db.add(
|
|
AIProfile(
|
|
version=profile_version,
|
|
model="openai/gpt-4o-mini",
|
|
system_prompt="Dùng tiếng Việt. Chỉ phân tích dữ liệu được cung cấp.",
|
|
timeout_seconds=4,
|
|
limits={},
|
|
active=True,
|
|
)
|
|
)
|
|
db.add(AppCredential(app_id=app_id, verifier=digest(app_key)))
|
|
await db.commit()
|
|
await factory.kw["bind"].dispose()
|
|
|
|
async def cleanup() -> None:
|
|
async with factory() as db:
|
|
await db.execute(delete(ScoringRun).where(ScoringRun.app_id == app_id))
|
|
await db.execute(delete(AppCredential).where(AppCredential.app_id == app_id))
|
|
await db.execute(delete(AIProfile).where(AIProfile.version == profile_version))
|
|
profiles = (await db.scalars(select(AIProfile))).all()
|
|
for profile in profiles:
|
|
profile.active = profile.version in original_active
|
|
await db.commit()
|
|
await factory.kw["bind"].dispose()
|
|
|
|
asyncio.run(setup())
|
|
|
|
def app_for(handler: Callable[[httpx.Request], httpx.Response]) -> FastAPI:
|
|
return create_app(settings, aihub_transport=httpx.MockTransport(handler))
|
|
|
|
try:
|
|
yield {
|
|
"app_for": app_for,
|
|
"factory": factory,
|
|
"settings": settings,
|
|
"profile_version": profile_version,
|
|
"app_id": app_id,
|
|
"app_key": app_key,
|
|
"vkey": vkey,
|
|
}
|
|
finally:
|
|
asyncio.run(cleanup())
|