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.
117 lines
4.2 KiB
Python
117 lines
4.2 KiB
Python
import asyncio
|
|
import json
|
|
import os
|
|
from collections.abc import Callable, Iterator
|
|
from copy import deepcopy
|
|
from datetime import UTC, datetime
|
|
from pathlib import Path
|
|
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
|
|
|
|
FIXTURE = Path(__file__).parents[1] / "contracts/lead-scoring/fixtures/run-request.json"
|
|
|
|
|
|
@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 canonical_request() -> dict[str, Any]:
|
|
"""The shared contract fixture: rules only, rule score 50, 12 messages."""
|
|
return deepcopy(json.loads(FIXTURE.read_text()))
|
|
|
|
|
|
@pytest.fixture
|
|
def score_env() -> Iterator[dict[str, Any]]:
|
|
"""An app key plus an active AIHub profile in ai_platform_test; AI calls use a mock transport."""
|
|
loaded = Settings.load()
|
|
database_name = urlsplit(loaded.database_url).path.rstrip("/").rsplit("/", 1)[-1]
|
|
if database_name != "ai_platform_test":
|
|
raise RuntimeError("Scoring integration tests require /ai_platform_test")
|
|
settings = Settings(database_url=loaded.database_url, environment="development")
|
|
factory = make_session_factory(settings.database_url)
|
|
app_id = f"test-{uuid4().hex}"
|
|
app_key = f"test-only-app-key-{app_id}"
|
|
state: dict[str, Any] = {"revisions": [], "previously_active": []}
|
|
|
|
async def run(action: Callable[..., Any]) -> Any:
|
|
async with factory() as db:
|
|
result = await action(db)
|
|
await db.commit()
|
|
await factory.kw["bind"].dispose()
|
|
return result
|
|
|
|
async def setup(db) -> None:
|
|
profiles = (await db.scalars(select(AIProfile))).all()
|
|
state["previously_active"] = [p.revision for p in profiles if p.active]
|
|
for profile in profiles:
|
|
profile.active = False
|
|
db.add(AppCredential(app_id=app_id, verifier=digest(app_key)))
|
|
row = _profile_row(active=True)
|
|
db.add(row)
|
|
await db.flush()
|
|
state["revisions"].append(row.revision)
|
|
|
|
async def cleanup(db) -> None:
|
|
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.revision.in_(state["revisions"])))
|
|
for profile in (await db.scalars(select(AIProfile))).all():
|
|
profile.active = profile.revision in state["previously_active"]
|
|
|
|
def app_for(handler: Callable[[httpx.Request], httpx.Response]) -> FastAPI:
|
|
return create_app(settings, ai_transport=httpx.MockTransport(handler))
|
|
|
|
def read(query):
|
|
return asyncio.run(run(lambda db: query(db)))
|
|
|
|
asyncio.run(run(setup))
|
|
try:
|
|
yield {
|
|
"app_for": app_for,
|
|
"app_id": app_id,
|
|
"app_key": app_key,
|
|
"ai_key": "test-only-ai-key-do-not-leak",
|
|
"headers": lambda key=None: {
|
|
"Authorization": f"Bearer {app_key}",
|
|
"Idempotency-Key": key or str(uuid4()),
|
|
},
|
|
"read": read,
|
|
}
|
|
finally:
|
|
asyncio.run(run(cleanup))
|
|
|
|
|
|
def _profile_row(*, active: bool) -> AIProfile:
|
|
return AIProfile(
|
|
provider="aihub",
|
|
base_url="http://localhost:8080/v1",
|
|
model="test-model",
|
|
timeout_seconds=4,
|
|
reasoning_effort=None,
|
|
instructions="Chấm điểm lead giáo dục.",
|
|
include_reference_date=True,
|
|
timezone="Asia/Ho_Chi_Minh",
|
|
suggested_action_enabled=True,
|
|
created_by="test@example.invalid",
|
|
created_at=datetime(2026, 1, 1, tzinfo=UTC),
|
|
active=active,
|
|
)
|