feat: add Lead Scoring v2 backend and contract

This commit is contained in:
hungtran24 2026-09-25 09:55:25 +07:00
parent 81c3de8087
commit 5f77f68100
49 changed files with 4750 additions and 652 deletions

22
Dockerfile.backend Normal file
View File

@ -0,0 +1,22 @@
# syntax=docker/dockerfile:1
FROM python:3.13-slim AS runtime
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
UV_LINK_MODE=copy \
PATH="/app/.venv/bin:$PATH"
WORKDIR /app
COPY --from=ghcr.io/astral-sh/uv:0.12.2 /uv /uvx /bin/
COPY pyproject.toml uv.lock ./
COPY src ./src
COPY alembic.ini ./alembic.ini
COPY migrations ./migrations
RUN uv sync --locked --no-dev \
&& useradd --create-home --uid 10001 appuser \
&& chown -R appuser:appuser /app
USER appuser
EXPOSE 8000
CMD ["uvicorn", "ai_platform.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"]

View File

@ -1,49 +1,8 @@
# A generic, single database configuration.
[alembic] [alembic]
# path to migration scripts script_location = migrations
script_location = %(here)s/alembic prepend_sys_path = .
sqlalchemy.url = postgresql://postgres:postgres@localhost/ai_platform
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
# file_template = %%(rev)s_%%(slug)s
# sys.path path, will be prepended to sys.path if present.
prepend_sys_path = %(here)s
path_separator = os
# timezone to use when rendering the date within the migration file
# as well as the filename.
# string value is passed to dateutil.tz.gettz()
# leave blank for localtime
# timezone =
# max length of characters to apply to the
# "slug" field
# truncate_slug_length = 40
# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false
# set to 'true' to allow .pyc and .pyo files without
# a source .py file to be detected as revisions in the
# versions/ directory
# sourceless = false
# version location specification; This defaults
# to alembic/versions. When using multiple version
# directories, initial revisions must be specified with --version-path.
# The path separator used here should be the separator specified by "version_path_separator"
# version_locations = %(here)s/bar:%(here)s/bat:alembic/versions
# version path separator
version_path_separator = os
# the output encoding used when revision files
# are written from script.py.mako
# output_encoding = utf-8
# Logging configuration
[loggers] [loggers]
keys = root,sqlalchemy,alembic keys = root,sqlalchemy,alembic

View File

@ -0,0 +1 @@
{"version":"2.0.0","status":"implementation draft; generated OpenAPI not yet available","fixtures":["fixtures/run-request.json","fixtures/tenant-key-provision-response.json"],"idempotency":{"identity":"request_id in body, scoped to authenticated tenant","fingerprint":"SHA-256 of sorted-key compact JSON request body","same_body":"return existing run without scheduling another task","changed_body":"409 Conflict"},"auth":{"tenant":"Authorization: Bearer tenant key","aihub":"X-Tenant-VKey header on POST only; in-memory task input; AIHub integration disabled pending verified protocol","admin_login":{"method":"POST","path":"/v1/admin/login","body":{"email":"admin@example.test","password":"string"},"success":{"email":"admin@example.test","csrf_token":"opaque-token","expires_in":28800},"cookie":"ai_platform_session; HttpOnly; SameSite=Lax; Path=/; Secure in production"}},"scoring_request":{"required":["request_id","subject_ref","snapshot","scoring_config","ai_profile"],"scoring_config":{"required":["version","rules","rule_weight","ai_weight"],"weights_sum_to":1}},"run_status":["pending","running","completed","failed"]}

View File

@ -0,0 +1,51 @@
{
"request_id": "f81c7c4e-a92a-4b19-8d7a-3a5c08eb4e6e",
"subject_ref": "ems-lead-123",
"snapshot": {
"captured_at": "2025-09-25T10:00:00Z",
"lead_profile": {
"ems_lead_id": "ems-lead-123",
"custom_fields": {
"inquiry_type": "course_dates",
"region": "north"
}
},
"conversations": [
{
"conversation_id": "conversation-456",
"channel": "webchat",
"assigned_agent_id": "agent-12",
"messages": [
{
"message_id": "message-789",
"sender_type": "contact",
"content": "I would like to know the available course dates.",
"sent_at": "2025-09-24T09:30:00Z"
}
]
}
]
},
"scoring_config": {
"version": "omni-config-17",
"rules": [
{
"id": "asked-course-dates",
"field": "custom_fields.inquiry_type",
"operator": "eq",
"value": "course_dates",
"points": 35
},
{
"id": "north-region",
"field": "custom_fields.region",
"operator": "eq",
"value": "north",
"points": 15
}
],
"rule_weight": 1.0,
"ai_weight": 0.0
},
"ai_profile": "light"
}

View File

@ -0,0 +1 @@
{"tenant_id":"acme","tenant_key":"tnt_<returned-once>","note":"Provision response example only; never use a real credential in this fixture."}

File diff suppressed because one or more lines are too long

48
migrations/env.py Normal file
View File

@ -0,0 +1,48 @@
from logging.config import fileConfig
from alembic import context
from sqlalchemy import pool
from sqlalchemy.ext.asyncio import async_engine_from_config
from ai_platform.infrastructure.config.settings import Settings
from ai_platform.infrastructure.persistence import models # noqa: F401
from ai_platform.infrastructure.persistence.database import Base
config = context.config
if config.config_file_name:
fileConfig(config.config_file_name)
target_metadata = Base.metadata
def run_migrations_offline() -> None:
context.configure(
url=Settings.load().database_url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def run_migrations(connection) -> None:
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
async def run_migrations_online() -> None:
section = config.get_section(config.config_ini_section) or {}
section["sqlalchemy.url"] = Settings.load().database_url
engine = async_engine_from_config(section, prefix="sqlalchemy.", poolclass=pool.NullPool)
async with engine.connect() as connection:
await connection.run_sync(run_migrations)
await engine.dispose()
if context.is_offline_mode():
run_migrations_offline()
else:
import asyncio
asyncio.run(run_migrations_online())

View File

@ -0,0 +1,70 @@
import sqlalchemy as sa
from alembic import op
revision = "0001_platform_v2"
down_revision = None
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"tenant_keys",
sa.Column("tenant_id", sa.String(128), primary_key=True),
sa.Column("verifier", sa.String(64), nullable=False),
sa.Column("active", sa.Boolean(), nullable=False, server_default=sa.true()),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
)
op.create_table(
"admin_users",
sa.Column("email", sa.String(254), primary_key=True),
sa.Column("password_hash", sa.String(512), nullable=False),
)
op.create_table(
"admin_sessions",
sa.Column("id", sa.String(64), primary_key=True),
sa.Column(
"admin_email", sa.String(254), sa.ForeignKey("admin_users.email"), nullable=False
),
sa.Column("csrf_hash", sa.String(64), nullable=False),
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
)
op.create_table(
"ai_profiles",
sa.Column("version", sa.String(64), primary_key=True),
sa.Column("model", sa.String(128), nullable=False),
sa.Column("system_prompt", sa.String(8000), nullable=False),
sa.Column("timeout_seconds", sa.Integer(), nullable=False),
sa.Column("limits", sa.JSON(), nullable=False),
sa.Column("active", sa.Boolean(), nullable=False, server_default=sa.false()),
)
op.create_table(
"scoring_runs",
sa.Column("id", sa.String(36), primary_key=True),
sa.Column(
"tenant_id", sa.String(128), sa.ForeignKey("tenant_keys.tenant_id"), nullable=False
),
sa.Column("request_id", sa.String(36), nullable=False),
sa.Column("fingerprint", sa.String(64), nullable=False),
sa.Column("request", sa.JSON(), nullable=False),
sa.Column("profile_version", sa.String(64), nullable=False),
sa.Column("status", sa.String(24), nullable=False),
sa.Column("rule_score", sa.Integer()),
sa.Column("ai_score", sa.Integer()),
sa.Column("final_score", sa.Integer()),
sa.Column("degraded", sa.Boolean(), nullable=False, server_default=sa.false()),
sa.Column("breakdown", sa.JSON()),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
sa.Column("completed_at", sa.DateTime(timezone=True)),
sa.UniqueConstraint("tenant_id", "request_id", name="uq_run_tenant_request"),
)
for column in ("tenant_id", "status", "created_at"):
op.create_index(f"ix_scoring_runs_{column}", "scoring_runs", [column])
def downgrade() -> None:
op.drop_table("scoring_runs")
op.drop_table("ai_profiles")
op.drop_table("admin_sessions")
op.drop_table("admin_users")
op.drop_table("tenant_keys")

View File

@ -0,0 +1,21 @@
import sqlalchemy as sa
from alembic import op
revision = "0002_ai_result_fields"
down_revision = "0001_platform_v2"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("scoring_runs", sa.Column("ai_confidence", sa.Float(), nullable=True))
op.add_column("scoring_runs", sa.Column("ai_reasoning", sa.String(2000), nullable=True))
op.add_column("scoring_runs", sa.Column("error_code", sa.String(64), nullable=True))
op.add_column("scoring_runs", sa.Column("result_mode", sa.String(32), nullable=True))
def downgrade() -> None:
op.drop_column("scoring_runs", "result_mode")
op.drop_column("scoring_runs", "error_code")
op.drop_column("scoring_runs", "ai_reasoning")
op.drop_column("scoring_runs", "ai_confidence")

View File

@ -0,0 +1,15 @@
import sqlalchemy as sa
from alembic import op
revision = "0003_ai_evidence"
down_revision = "0002_ai_result_fields"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("scoring_runs", sa.Column("ai_evidence", sa.JSON(), nullable=True))
def downgrade() -> None:
op.drop_column("scoring_runs", "ai_evidence")

View File

@ -1,41 +1,38 @@
[project] [project]
name = "lead-scoring" name = "dotb-ai-platform"
version = "0.1.0" version = "2.0.0"
description = "Lead Scoring service for AI Platform" description = "Tenant-isolated lead scoring platform"
readme = "README.md"
requires-python = ">=3.13" requires-python = ">=3.13"
dependencies = [ dependencies = [
"fastapi>=0.115.0", "fastapi>=0.115,<1",
"uvicorn[standard]>=0.30.0", "uvicorn[standard]>=0.30,<1",
"pydantic>=2.8.0", "pydantic>=2.8,<3",
"pydantic-settings>=2.4.0", "pydantic-settings>=2.4,<3",
"sqlalchemy[asyncio]>=2.0.30", "sqlalchemy[asyncio]>=2.0.30,<3",
"asyncpg>=0.29.0", "asyncpg>=0.30,<1",
"alembic>=1.13.0", "alembic>=1.13,<2",
"httpx>=0.27.0", "pwdlib[argon2]>=0.2,<1",
"httpx>=0.27",
] ]
[project.scripts] [project.scripts]
generate-openapi = "lead_scoring.cli.openapi:main" ai-platform = "ai_platform.cli:main"
generate-webhook-contract = "lead_scoring.cli.webhook_contract:main"
generate-polling-contract = "lead_scoring.cli.polling_contract:main"
generate-contract-pack = "lead_scoring.cli.contract_pack:main"
[build-system] [build-system]
requires = ["hatchling"] requires = ["hatchling"]
build-backend = "hatchling.build" build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel] [tool.hatch.build.targets.wheel]
packages = ["src/lead_scoring"] packages = ["src/ai_platform"]
[dependency-groups] [dependency-groups]
dev = [ dev = [
"pytest>=8.0.0", "pytest>=8",
"pytest-asyncio>=0.23.0", "pytest-asyncio>=0.23",
"testcontainers[postgres]>=4.0.0", "ruff>=0.5",
"ruff>=0.5.0", "pyright>=1.1.414",
"pyright>=1.1.370", "import-linter>=2.15",
"ty>=0.0.74", "pre-commit==4.3.0",
] ]
[tool.pytest.ini_options] [tool.pytest.ini_options]
@ -48,20 +45,5 @@ line-length = 100
target-version = "py313" target-version = "py313"
src = ["src", "tests"] src = ["src", "tests"]
[tool.ruff.lint] [tool.ruff.lint.per-file-ignores]
select = [ "src/ai_platform/presentation/http/*.py" = ["B008"]
"E", # pycodestyle errors
"W", # pycodestyle warnings
"F", # pyflakes
"I", # isort
"B", # flake8-bugbear
"UP", # pyupgrade
]
ignore = []
[tool.pyright]
include = ["src", "tests"]
pythonVersion = "3.13"
typeCheckingMode = "standard"
venvPath = "."
venv = ".venv"

7
pyrightconfig.json Normal file
View File

@ -0,0 +1,7 @@
{
"include": ["src", "tests", "migrations"],
"exclude": ["old_repo", "**/__pycache__"],
"pythonVersion": "3.13",
"venvPath": ".",
"venv": ".venv"
}

155
scripts/seed_demo.py Normal file
View File

@ -0,0 +1,155 @@
"""Seed one local rules-only demo run through the public HTTP API."""
import json
import os
import time
from pathlib import Path
from typing import Any
from urllib.parse import urlsplit
import httpx
from ai_platform.infrastructure.config.settings import Settings
from ai_platform.presentation.http.schemas import RunRequest
FIXTURE = Path(__file__).resolve().parents[1] / "tests" / "fixtures" / "mock_omni_run.json"
BASE_URL = "http://127.0.0.1:8000"
DEMO_TENANT = "local-demo-seed"
DEMO_TENANT_KEY_ENV = "AI_PLATFORM_DEMO_TENANT_KEY"
POLL_ATTEMPTS = 100
POLL_INTERVAL_SECONDS = 0.1
def load_payload() -> dict[str, Any]:
return json.loads(FIXTURE.read_text())
def validate_local_target(settings: Settings) -> None:
database_name = urlsplit(settings.database_url).path.rstrip("/").rsplit("/", 1)[-1]
database_host = urlsplit(settings.database_url).hostname
if settings.environment != "development":
raise SystemExit("Refusing demo seed: AI_PLATFORM_ENVIRONMENT must be development")
if database_name != "ai_platform" or database_host not in {
"127.0.0.1",
"localhost",
"::1",
"postgres",
}:
raise SystemExit(
"Refusing demo seed: API target must use the local ai_platform development database"
)
def tenant_headers(
client: httpx.Client,
settings: Settings,
existing_tenant_key: str | None,
) -> dict[str, str]:
if existing_tenant_key:
return {"Authorization": f"Bearer {existing_tenant_key}"}
if settings.tenant_provisioning_secret is None:
raise SystemExit(
"Demo seed blocked: tenant provisioning secret and existing demo tenant key are required"
)
response = client.post(
"/v1/platform/tenant-keys",
headers={
"Authorization": ("Bearer " + settings.tenant_provisioning_secret.get_secret_value())
},
json={"tenant_id": DEMO_TENANT},
)
if response.status_code == 409:
raise SystemExit(
"Demo seed blocked: the demo tenant already exists and its one-time key is unavailable; "
f"set {DEMO_TENANT_KEY_ENV} to its existing key. No key was rotated or data changed."
)
if response.status_code != 201:
raise SystemExit(
f"Demo seed blocked: tenant provisioning returned HTTP {response.status_code}"
)
tenant_key = response.json().get("tenant_key")
if not isinstance(tenant_key, str) or not tenant_key:
raise SystemExit("Demo seed blocked: tenant provisioning response omitted its key")
return {"Authorization": f"Bearer {tenant_key}"}
def read_run(client: httpx.Client, run_id: str, headers: dict[str, str]) -> dict[str, Any]:
for _ in range(POLL_ATTEMPTS):
response = client.get(f"/v1/lead-scoring/runs/{run_id}", headers=headers)
if response.status_code != 200:
raise SystemExit(
f"Demo seed blocked: run readback returned HTTP {response.status_code}"
)
run = response.json()
if run.get("status") in {"completed", "failed"}:
return run
time.sleep(POLL_INTERVAL_SECONDS)
raise SystemExit("Demo seed blocked: run did not reach a terminal state")
def persisted_snapshot(run: dict[str, Any]) -> dict[str, Any] | None:
snapshot = run.get("snapshot")
if isinstance(snapshot, dict):
return snapshot
request = run.get("request")
if isinstance(request, dict) and isinstance(request.get("snapshot"), dict):
return request["snapshot"]
return None
def seed_demo(
client: httpx.Client,
settings: Settings,
existing_tenant_key: str | None = None,
) -> tuple[str, dict[str, Any]]:
payload = load_payload()
if payload["scoring_config"]["ai_weight"] != 0:
raise SystemExit("Demo seed requires a rules-only fixture; it must not claim AI success")
headers = tenant_headers(client, settings, existing_tenant_key)
response = client.post("/v1/lead-scoring/runs", headers=headers, json=payload)
if response.status_code not in {200, 202}:
raise SystemExit(f"Demo seed blocked: run admission returned HTTP {response.status_code}")
run_id = response.json().get("run_id")
if not isinstance(run_id, str) or not run_id:
raise SystemExit("Demo seed blocked: run admission response omitted run_id")
run = read_run(client, run_id, headers)
if run.get("status") != "completed":
raise SystemExit("Demo seed blocked: the persisted run did not complete")
if run.get("ai_score") is not None or run.get("degraded") is not False:
raise SystemExit("Demo seed blocked: rules-only run returned an unexpected AI result")
expected_snapshot = RunRequest.model_validate(payload).model_dump(mode="json")["snapshot"]
if persisted_snapshot(run) != expected_snapshot:
raise SystemExit(
"Demo seed blocked: run readback did not return the persisted conversation snapshot"
)
return run_id, run
def main() -> None:
settings = Settings.load()
validate_local_target(settings)
existing_tenant_key = os.environ.get(DEMO_TENANT_KEY_ENV)
try:
with httpx.Client(base_url=BASE_URL, timeout=5) as client:
run_id, run = seed_demo(client, settings, existing_tenant_key)
except httpx.HTTPError as exc:
raise SystemExit(
"Demo seed blocked: local backend at 127.0.0.1:8000 is unavailable"
) from exc
snapshot = load_payload()["snapshot"]
conversations = len(snapshot["conversations"])
messages = sum(len(conversation["messages"]) for conversation in snapshot["conversations"])
print(
f"Seeded or reused local demo run {run_id}: status={run['status']}, "
f"score={run.get('final_score')}, persisted conversations={conversations}, messages={messages}."
)
print("AI execution was not requested; the run uses deterministic rules only.")
if __name__ == "__main__":
main()

View File

@ -0,0 +1,54 @@
from contextlib import asynccontextmanager
import httpx
from fastapi import FastAPI
from sqlalchemy import text
from ai_platform.infrastructure.aihub import AiHubClient
from ai_platform.infrastructure.config.settings import Settings
from ai_platform.infrastructure.persistence.database import make_session_factory
from ai_platform.infrastructure.scoring import recover_interrupted, wait_for_tasks
from ai_platform.presentation.http.admin_routes import router as admin_router
from ai_platform.presentation.http.routes import router
def create_app(
settings: Settings | None = None,
*,
aihub_transport: httpx.AsyncBaseTransport | None = None,
) -> FastAPI:
config = settings or Settings.load()
sessions = make_session_factory(config.database_url)
tasks = set()
@asynccontextmanager
async def lifespan(app: FastAPI):
async with sessions() as db:
revisions = (
(await db.execute(text("SELECT version_num FROM alembic_version"))).scalars().all()
)
if revisions != ["0003_ai_evidence"]:
raise RuntimeError(
"Database migrations are missing or not at head; run alembic upgrade head"
)
await recover_interrupted(sessions)
app.state.sessions = sessions
app.state.settings = config
app.state.tasks = tasks
if config.aihub_base_url is None:
app.state.aihub_client = None
else:
client = httpx.AsyncClient(transport=aihub_transport)
app.state.aihub_client = AiHubClient(config.aihub_base_url, client)
try:
yield
finally:
await wait_for_tasks(tasks)
if app.state.aihub_client is not None:
await app.state.aihub_client.aclose()
await sessions.kw["bind"].dispose()
app = FastAPI(title="AI Platform API", version="2.0.0", lifespan=lifespan)
app.include_router(router)
app.include_router(admin_router)
return app

69
src/ai_platform/cli.py Normal file
View File

@ -0,0 +1,69 @@
from __future__ import annotations
import argparse
import asyncio
import getpass
import json
import secrets
from pathlib import Path
from pwdlib import PasswordHash
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 AdminUser, TenantKey
from ai_platform.infrastructure.security import digest
async def create_admin(email: str, password: str) -> None:
sessions = make_session_factory(Settings.load().database_url)
async with sessions() as db:
email = email.strip().lower()
if await db.get(AdminUser, email):
raise SystemExit("admin already exists")
db.add(AdminUser(email=email, password_hash=PasswordHash.recommended().hash(password)))
await db.commit()
await sessions.kw["bind"].dispose()
async def rotate_tenant_key(tenant_id: str) -> str:
sessions = make_session_factory(Settings.load().database_url)
raw = "tnt_" + secrets.token_urlsafe(32)
async with sessions() as db:
row = await db.get(TenantKey, tenant_id, with_for_update=True)
if row is None:
raise SystemExit(f"unknown tenant: {tenant_id}")
row.verifier = digest(raw)
await db.commit()
await sessions.kw["bind"].dispose()
return raw
def main() -> None:
parser = argparse.ArgumentParser(prog="ai-platform")
sub = parser.add_subparsers(dest="command", required=True)
sub.add_parser("create-admin")
export = sub.add_parser("export-openapi")
export.add_argument("path", type=Path)
rotate = sub.add_parser("rotate-tenant-key")
rotate.add_argument("tenant_id")
args = parser.parse_args()
if args.command == "create-admin":
email = input("Admin email: ").strip()
password = getpass.getpass("Admin password: ")
if not password or password != getpass.getpass("Confirm password: "):
raise SystemExit("passwords are empty or do not match")
asyncio.run(create_admin(email, password))
elif args.command == "rotate-tenant-key":
print(asyncio.run(rotate_tenant_key(args.tenant_id)))
elif args.command == "export-openapi":
from ai_platform.bootstrap.app import create_app
args.path.parent.mkdir(parents=True, exist_ok=True)
args.path.write_text(
json.dumps(create_app().openapi(), ensure_ascii=False, separators=(",", ":")) + "\n"
)
if __name__ == "__main__":
main()

View File

@ -0,0 +1,199 @@
import json
from typing import Any
import httpx
from pydantic import (
BaseModel,
ConfigDict,
Field,
StrictFloat,
StrictInt,
StrictStr,
ValidationError,
)
_MAX_RESPONSE_BYTES = 1_000_000
_MAX_TIMEOUT_SECONDS = 120.0
class AiHubError(Exception):
"""Sanitized AIHub failure; never includes request credentials or response bodies."""
def __init__(self, error_code: str, message: str) -> None:
super().__init__(message)
self.error_code = error_code
class AiScore(BaseModel):
model_config = ConfigDict(extra="forbid", strict=True)
score: StrictInt = Field(ge=0, le=100)
confidence: StrictFloat = Field(ge=0, le=1)
reasoning: StrictStr = Field(min_length=1, max_length=2_000)
class EvidenceItem(BaseModel):
model_config = ConfigDict(extra="forbid", strict=True)
signal: StrictStr = Field(min_length=1, max_length=80)
detail: StrictStr = Field(min_length=1, max_length=500)
source: StrictStr = Field(min_length=1, max_length=120)
class ExtractedEvidence(BaseModel):
model_config = ConfigDict(extra="forbid", strict=True)
items: list[EvidenceItem] = Field(max_length=20)
def _reject_duplicate_keys(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
result: dict[str, Any] = {}
for key, value in pairs:
if key in result:
raise ValueError("duplicate JSON object key")
result[key] = value
return result
def _contains_secret(value: Any, secret: str) -> bool:
if isinstance(value, str):
return secret in value
if isinstance(value, dict):
return any(
_contains_secret(key, secret) or _contains_secret(item, secret)
for key, item in value.items()
)
if isinstance(value, list):
return any(_contains_secret(item, secret) for item in value)
return False
def _model_validate[Model: BaseModel](model: type[Model], payload: Any, error_code: str) -> Model:
try:
return model.model_validate(payload)
except ValidationError:
raise AiHubError(error_code, "AIHub returned an invalid JSON response") from None
class AiHubClient:
"""Small non-streaming adapter for the AIHub chat completions endpoint."""
def __init__(self, base_url: str, client: httpx.AsyncClient, timeout: float = 30.0) -> None:
if (
not base_url.startswith(("http://", "https://"))
or not 0 < timeout <= _MAX_TIMEOUT_SECONDS
):
raise ValueError("AIHub requires an HTTP(S) base URL and timeout up to 120 seconds")
self._url = f"{base_url.rstrip('/')}/v1/chat/completions"
self._client = client
self._timeout = timeout
async def aclose(self) -> None:
await self._client.aclose()
async def extract_evidence(
self,
*,
model: str,
snapshot: dict[str, Any],
vkey: str,
timeout: float | None = None,
) -> ExtractedEvidence:
system_prompt = (
"Bạn chỉ trích xuất bằng chứng có thể xác định trực tiếp từ snapshot. "
"Không suy đoán, không đánh giá, không chấm điểm và không tạo quy tắc. "
'Trả về JSON đúng dạng {"items":[{"signal":string,"detail":string,'
'"source":string}]}; tối đa 20 mục. Mỗi source phải là đường dẫn trường '
"snapshot hoặc message_id có thật. Viết signal và detail bằng tiếng Việt."
)
user_prompt = "Trích xuất bằng chứng từ snapshot sau:\n" + json.dumps(
snapshot, ensure_ascii=False, separators=(",", ":")
)
payload = await self._chat_json(
model=model,
system_prompt=system_prompt,
user_prompt=user_prompt,
vkey=vkey,
timeout=timeout,
)
return _model_validate(ExtractedEvidence, payload, "aihub_invalid_evidence")
async def score(
self,
*,
model: str,
system_prompt: str,
user_prompt: str,
vkey: str,
timeout: float | None = None,
) -> AiScore:
payload = await self._chat_json(
model=model,
system_prompt=system_prompt,
user_prompt=user_prompt,
vkey=vkey,
timeout=timeout,
)
return _model_validate(AiScore, payload, "aihub_invalid_score")
async def _chat_json(
self,
*,
model: str,
system_prompt: str,
user_prompt: str,
vkey: str,
timeout: float | None,
) -> Any:
if not all((model.strip(), system_prompt.strip(), user_prompt.strip(), vkey)):
raise ValueError("AIHub request fields must not be empty")
if len(vkey) > 4_096 or "\r" in vkey or "\n" in vkey:
raise ValueError("AIHub credential is invalid")
request_timeout = self._timeout if timeout is None else timeout
if not 0 < request_timeout <= _MAX_TIMEOUT_SECONDS:
raise ValueError("AIHub request timeout is outside the supported range")
try:
async with self._client.stream(
"POST",
self._url,
headers={"Authorization": f"Bearer {vkey}"},
json={
"model": model,
"messages": [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt},
],
"stream": False,
"response_format": {"type": "json_object"},
},
timeout=request_timeout,
) as response:
if response.status_code != 200:
raise AiHubError(
"aihub_http_error", f"AIHub returned HTTP {response.status_code}"
)
chunks: list[bytes] = []
size = 0
async for chunk in response.aiter_bytes():
size += len(chunk)
if size > _MAX_RESPONSE_BYTES:
raise AiHubError("aihub_response_too_large", "AIHub response was too large")
chunks.append(chunk)
except httpx.TimeoutException:
raise AiHubError("aihub_timeout", "AIHub request timed out") from None
except httpx.RequestError:
raise AiHubError("aihub_request_error", "AIHub request failed") from None
try:
envelope = json.loads(b"".join(chunks), object_pairs_hook=_reject_duplicate_keys)
content = envelope["choices"][0]["message"]["content"]
if not isinstance(content, str):
raise TypeError("completion content must be text")
payload = json.loads(content, object_pairs_hook=_reject_duplicate_keys)
if _contains_secret(payload, vkey):
raise ValueError("response contained request credentials")
return payload
except (KeyError, IndexError, TypeError, ValueError, json.JSONDecodeError):
raise AiHubError(
"aihub_invalid_response", "AIHub returned an invalid JSON response"
) from None

View File

@ -0,0 +1,60 @@
from urllib.parse import urlsplit
from pydantic import SecretStr, model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_prefix="AI_PLATFORM_",
env_file=".env",
env_ignore_empty=True,
extra="ignore",
)
database_url: str = (
"postgresql+asyncpg://ai_platform:local-change-this-password@127.0.0.1:55432/ai_platform"
)
environment: str = "development"
tenant_provisioning_secret: SecretStr | None = None
aihub_base_url: str | None = None
admin_cookie_secure: bool = False
session_ttl_seconds: int = 28800
@model_validator(mode="after")
def validate_settings(self):
if self.environment != "development" and self.tenant_provisioning_secret is None:
raise ValueError("AI_PLATFORM_TENANT_PROVISIONING_SECRET must be set")
if not self.database_url.startswith("postgresql+asyncpg://"):
raise ValueError("AI_PLATFORM_DATABASE_URL must use postgresql+asyncpg://")
if self.aihub_base_url is not None:
try:
parsed = urlsplit(self.aihub_base_url)
if parsed.port == 0:
raise ValueError("Port zero is invalid")
except ValueError:
raise ValueError(
"AI_PLATFORM_AIHUB_BASE_URL must be a plain HTTP(S) base URL"
) from None
if (
any(char.isspace() for char in self.aihub_base_url)
or parsed.scheme not in {"http", "https"}
or not parsed.hostname
or parsed.username is not None
or parsed.password is not None
or parsed.query
or parsed.fragment
):
raise ValueError("AI_PLATFORM_AIHUB_BASE_URL must be a plain HTTP(S) base URL")
if parsed.scheme == "http" and (
self.environment != "development"
or parsed.hostname not in {"localhost", "127.0.0.1", "::1"}
):
raise ValueError(
"AI_PLATFORM_AIHUB_BASE_URL must use HTTPS outside local development"
)
return self
@classmethod
def load(cls) -> "Settings":
return cls()

View File

@ -0,0 +1,21 @@
from collections.abc import AsyncIterator
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.orm import DeclarativeBase
class Base(DeclarativeBase):
pass
def make_session_factory(database_url: str) -> async_sessionmaker[AsyncSession]:
return async_sessionmaker(
create_async_engine(database_url, pool_pre_ping=True), expire_on_commit=False
)
async def session_dependency(
factory: async_sessionmaker[AsyncSession],
) -> AsyncIterator[AsyncSession]:
async with factory() as session:
yield session

View File

@ -0,0 +1,76 @@
from datetime import datetime
from typing import Any
from uuid import uuid4
from sqlalchemy import (
JSON,
Boolean,
DateTime,
Float,
ForeignKey,
Integer,
String,
UniqueConstraint,
func,
)
from sqlalchemy.orm import Mapped, mapped_column
from ai_platform.infrastructure.persistence.database import Base
class TenantKey(Base):
__tablename__ = "tenant_keys"
tenant_id: Mapped[str] = mapped_column(String(128), primary_key=True)
verifier: Mapped[str] = mapped_column(String(64), nullable=False)
active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
class AdminUser(Base):
__tablename__ = "admin_users"
email: Mapped[str] = mapped_column(String(254), primary_key=True)
password_hash: Mapped[str] = mapped_column(String(512), nullable=False)
class AdminSession(Base):
__tablename__ = "admin_sessions"
id: Mapped[str] = mapped_column(String(64), primary_key=True)
admin_email: Mapped[str] = mapped_column(ForeignKey("admin_users.email"), nullable=False)
csrf_hash: Mapped[str] = mapped_column(String(64), nullable=False)
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
class AIProfile(Base):
__tablename__ = "ai_profiles"
version: Mapped[str] = mapped_column(String(64), primary_key=True)
model: Mapped[str] = mapped_column(String(128), nullable=False)
system_prompt: Mapped[str] = mapped_column(String(8000), nullable=False)
timeout_seconds: Mapped[int] = mapped_column(Integer, nullable=False)
limits: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False)
active: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
class ScoringRun(Base):
__tablename__ = "scoring_runs"
__table_args__ = (UniqueConstraint("tenant_id", "request_id", name="uq_run_tenant_request"),)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4()))
tenant_id: Mapped[str] = mapped_column(ForeignKey("tenant_keys.tenant_id"), index=True)
request_id: Mapped[str] = mapped_column(String(36), nullable=False)
fingerprint: Mapped[str] = mapped_column(String(64), nullable=False)
request: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False)
profile_version: Mapped[str] = mapped_column(String(64), nullable=False)
status: Mapped[str] = mapped_column(String(24), default="pending", index=True)
rule_score: Mapped[int | None] = mapped_column(Integer)
ai_score: Mapped[int | None] = mapped_column(Integer)
ai_confidence: Mapped[float | None] = mapped_column(Float)
ai_reasoning: Mapped[str | None] = mapped_column(String(2_000))
error_code: Mapped[str | None] = mapped_column(String(64))
final_score: Mapped[int | None] = mapped_column(Integer)
degraded: Mapped[bool] = mapped_column(Boolean, default=False)
result_mode: Mapped[str | None] = mapped_column(String(32))
breakdown: Mapped[list[dict[str, Any]] | None] = mapped_column(JSON)
ai_evidence: Mapped[list[dict[str, Any]] | None] = mapped_column(JSON)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), index=True
)
completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))

View File

@ -0,0 +1,194 @@
import asyncio
import json
from datetime import UTC, datetime
from typing import Any
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from ai_platform.infrastructure.aihub import AiHubClient, AiHubError
from ai_platform.infrastructure.persistence.models import AIProfile, ScoringRun
from ai_platform.lead_scoring.application.scoring import calculate_run
def _scoring_system_prompt(profile_prompt: str) -> str:
return (
profile_prompt.strip() + "\n\nChấm điểm chỉ dựa trên snapshot và bằng chứng được cung cấp. "
"Không sử dụng hoặc suy đoán quy tắc, điểm hay trọng số của khách hàng. "
'Trả JSON đúng dạng {"score":số nguyên 0-100,"confidence":số 0-1,'
'"reasoning":chuỗi tiếng Việt}; reasoning tối đa 2000 ký tự.'
)
def _scoring_user_prompt(snapshot: dict[str, Any], evidence: dict[str, Any]) -> str:
return (
"Đánh giá snapshot cùng bằng chứng trích xuất sau đây. "
"Chỉ trả kết quả theo định dạng JSON trong chỉ dẫn hệ thống.\n"
"SNAPSHOT:\n"
+ json.dumps(snapshot, ensure_ascii=False, separators=(",", ":"))
+ "\nEVIDENCE:\n"
+ json.dumps(evidence, ensure_ascii=False, separators=(",", ":"))
)
async def execute_run(
factory: async_sessionmaker[AsyncSession],
run_id: str,
raw_vkey: str | None,
aihub: AiHubClient | None,
) -> None:
# The request-scoped key exists only in this process-local scoring task.
try:
await _execute_run(factory, run_id, raw_vkey, aihub)
except Exception: # noqa: BLE001
await _mark_failed(factory, run_id)
async def _execute_run(
factory: async_sessionmaker[AsyncSession],
run_id: str,
raw_vkey: str | None,
aihub: AiHubClient | None,
) -> None:
async with factory() as db:
row = await db.get(ScoringRun, run_id)
if row is None or row.status != "pending":
return
row.status = "running"
await db.commit()
rule_score, breakdown = calculate_run(row.request)
row.rule_score = rule_score
row.breakdown = breakdown
row.ai_score = None
row.ai_confidence = None
row.ai_reasoning = None
row.ai_evidence = None
row.error_code = None
ai_weight = row.request["scoring_config"]["ai_weight"]
if ai_weight == 0:
row.final_score = rule_score
row.degraded = False
row.result_mode = "rules_only"
else:
await _score_with_ai(db, row, raw_vkey, aihub)
row.status = "completed"
row.completed_at = datetime.now(UTC)
await db.commit()
async def _score_with_ai(
db: AsyncSession,
row: ScoringRun,
raw_vkey: str | None,
aihub: AiHubClient | None,
) -> None:
def fallback(error_code: str) -> None:
row.ai_score = None
row.ai_confidence = None
row.ai_reasoning = None
row.ai_evidence = None
row.final_score = row.rule_score
row.degraded = True
row.error_code = error_code
row.result_mode = "rules_only_fallback"
if row.request["ai_profile"] != "medium":
fallback("ai_profile_deferred")
return
if raw_vkey is None or not raw_vkey.strip():
fallback("vkey_missing")
return
if len(raw_vkey) > 4_096 or "\r" in raw_vkey or "\n" in raw_vkey:
fallback("vkey_invalid")
return
profile = await db.get(AIProfile, row.profile_version)
if profile is None:
fallback("ai_profile_missing")
return
if aihub is None:
fallback("aihub_not_configured")
return
snapshot = row.request["snapshot"]
if raw_vkey in json.dumps(snapshot, ensure_ascii=False) or raw_vkey in profile.system_prompt:
fallback("vkey_prompt_conflict")
return
try:
evidence = await aihub.extract_evidence(
model=profile.model,
snapshot=snapshot,
vkey=raw_vkey,
timeout=profile.timeout_seconds,
)
score = await aihub.score(
model=profile.model,
system_prompt=_scoring_system_prompt(profile.system_prompt),
user_prompt=_scoring_user_prompt(
snapshot, evidence.model_dump(mode="json", exclude_none=True)
),
vkey=raw_vkey,
timeout=profile.timeout_seconds,
)
except AiHubError as exc:
fallback(exc.error_code)
return
config = row.request["scoring_config"]
final_score = round(row.rule_score * config["rule_weight"] + score.score * config["ai_weight"])
row.ai_score = score.score
row.ai_confidence = score.confidence
row.ai_reasoning = score.reasoning
row.ai_evidence = [item.model_dump(mode="json", exclude_none=True) for item in evidence.items]
row.final_score = max(0, min(100, final_score))
row.degraded = False
row.error_code = None
row.result_mode = "hybrid"
async def _mark_failed(factory: async_sessionmaker[AsyncSession], run_id: str) -> None:
try:
async with factory() as db:
row = await db.get(ScoringRun, run_id)
if row is None or row.status in {"completed", "failed"}:
return
row.status = "failed"
row.rule_score = None
row.ai_score = None
row.ai_confidence = None
row.ai_reasoning = None
row.ai_evidence = None
row.final_score = None
row.degraded = False
row.breakdown = None
row.error_code = "execution_failed"
row.result_mode = None
row.completed_at = datetime.now(UTC)
await db.commit()
except Exception: # noqa: BLE001
# A database outage can prevent recording the failure itself.
return
async def recover_interrupted(factory: async_sessionmaker[AsyncSession]) -> None:
async with factory() as db:
rows = (
await db.scalars(
select(ScoringRun)
.where(ScoringRun.status.in_(["pending", "running"]))
.with_for_update()
)
).all()
for row in rows:
row.status = "failed"
row.error_code = "worker_interrupted"
row.ai_evidence = None
row.completed_at = datetime.now(UTC)
await db.commit()
async def wait_for_tasks(tasks: set[asyncio.Task[Any]]) -> None:
if tasks:
_done, pending = await asyncio.wait(tasks, timeout=3)
for task in pending:
task.cancel()

View File

@ -0,0 +1,5 @@
import hashlib
def digest(value: str) -> str:
return hashlib.sha256(value.encode()).hexdigest()

View File

@ -0,0 +1,14 @@
from typing import Any
from ai_platform.lead_scoring.domain.scoring import Rule, ScoringConfiguration, score_rules
def calculate_run(request: dict[str, Any]) -> tuple[int, list[dict[str, Any]]]:
config_data = request["scoring_config"]
config = ScoringConfiguration(
version=config_data["version"],
rules=tuple(Rule(**rule) for rule in config_data["rules"]),
rule_weight=config_data["rule_weight"],
ai_weight=config_data["ai_weight"],
)
return score_rules(request["snapshot"], config)

View File

@ -0,0 +1,202 @@
import math
from dataclasses import dataclass
from datetime import UTC, datetime
from itertools import pairwise
from typing import Any
@dataclass(frozen=True)
class Rule:
id: str
field: str
operator: str
value: Any
points: int
@dataclass(frozen=True)
class ScoringConfiguration:
version: str
rules: tuple[Rule, ...]
rule_weight: float
ai_weight: float
def derive_metrics(snapshot: dict[str, Any]) -> dict[str, int | float | bool | None]:
"""Derive v2 metrics using valid sent_at values for temporal ordering."""
conversations = snapshot.get("conversations", [])
messages = [
message for conversation in conversations for message in conversation.get("messages", [])
]
contact_messages = [message for message in messages if message.get("sender_type") == "contact"]
ordered_conversations = [
_messages_in_time_order(conversation) for conversation in conversations
]
turn_count = sum(
1
+ sum(
current["sender_type"] != previous["sender_type"]
for previous, current in pairwise(conversation)
)
for conversation in ordered_conversations
if conversation
)
contact_started_conversation_count = sum(
bool(conversation) and conversation[0].get("sender_type") == "contact"
for conversation in ordered_conversations
)
nonempty_conversations = []
for position, conversation in enumerate(ordered_conversations):
if conversation:
started_at = _timestamp(conversation[0].get("sent_at"))
if started_at is not None:
nonempty_conversations.append((started_at, position, conversation))
nonempty_conversations.sort(key=lambda item: (item[0], item[1]))
customer_initiated_return = len(nonempty_conversations) > 1 and any(
conversation[0].get("sender_type") == "contact"
for _started_at, _position, conversation in nonempty_conversations[1:]
)
captured_at = _timestamp(snapshot.get("captured_at"))
recency_days: float | None = None
recency_hours: float | None = None
if contact_messages and captured_at is not None:
timestamps = [
timestamp
for message in contact_messages
if (timestamp := _timestamp(message.get("sent_at"))) is not None
]
newest_contact_at = max(timestamps, default=None)
if newest_contact_at is not None:
elapsed_seconds = max(0.0, (captured_at - newest_contact_at).total_seconds())
recency_days = round(elapsed_seconds / 86400, 2)
recency_hours = round(elapsed_seconds / 3600, 2)
contact_message_count = len(contact_messages)
profile = snapshot.get("lead_profile", {})
profile_field_count = sum(
profile.get(field) is not None for field in ("name", "phone", "email", "status")
)
return {
"profile_field_count": profile_field_count,
"conversation_count": len(conversations),
"message_count": len(messages),
"contact_message_count": contact_message_count,
"customer_message_count": contact_message_count,
"conversation_turn_count": turn_count,
"contact_started_conversation_count": contact_started_conversation_count,
"contact_message_recency_days": recency_days,
"contact_message_recency_hours": recency_hours,
"customer_initiated_return": customer_initiated_return,
}
def _messages_in_time_order(conversation: dict[str, Any]) -> list[dict[str, Any]]:
timestamped = []
for message in conversation.get("messages", []):
timestamp = _timestamp(message.get("sent_at"))
if timestamp is not None:
timestamped.append((timestamp, message))
return [message for _timestamp_value, message in sorted(timestamped, key=lambda item: item[0])]
def _timestamp(value: Any) -> datetime | None:
if isinstance(value, datetime):
parsed = value
elif isinstance(value, str):
try:
parsed = datetime.fromisoformat(value)
except ValueError:
return None
else:
return None
if parsed.tzinfo is None or parsed.utcoffset() is None:
return None
return parsed.astimezone(UTC)
def score_rules(
snapshot: dict[str, Any], config: ScoringConfiguration
) -> tuple[int, list[dict[str, Any]]]:
"""Evaluate caller-supplied rules against the caller-supplied snapshot."""
profile = snapshot.get("lead_profile", {})
root = {
"lead_profile": profile,
"custom_fields": profile.get("custom_fields", {}),
"metrics": derive_metrics(snapshot),
}
total = 0
breakdown = []
for rule in config.rules:
current: Any = root
for part in rule.field.split("."):
current = current.get(part) if isinstance(current, dict) else None
try:
matched = _matches(current, rule)
except TypeError:
matched = False
points = rule.points if matched else 0
total += points
breakdown.append({"rule_id": rule.id, "matched": matched, "points": points})
return max(0, min(100, total)), breakdown
def _equality(actual: Any, expected: Any) -> bool | None:
if isinstance(actual, bool) or isinstance(expected, bool):
return (
actual == expected if isinstance(actual, bool) and isinstance(expected, bool) else None
)
if isinstance(actual, (int, float)) or isinstance(expected, (int, float)):
if not isinstance(actual, (int, float)) or not isinstance(expected, (int, float)):
return None
if isinstance(actual, float) and not math.isfinite(actual):
return None
if isinstance(expected, float) and not math.isfinite(expected):
return None
return actual == expected
if isinstance(actual, str) and isinstance(expected, str):
return actual == expected
if isinstance(actual, (list, tuple)) and isinstance(expected, (list, tuple)):
if len(actual) != len(expected):
return False
comparisons = [_equality(left, right) for left, right in zip(actual, expected, strict=True)]
if any(result is None for result in comparisons):
return None
return all(comparisons)
return None
def _matches(current: Any, rule: Rule) -> bool:
if rule.operator == "exists":
return current is not None
if rule.operator in {"eq", "ne"}:
equal = _equality(current, rule.value)
if equal is None:
return False
return equal if rule.operator == "eq" else not equal
if rule.operator in {"gt", "gte", "lt", "lte"}:
value = rule.value
if (
isinstance(current, bool)
or isinstance(value, bool)
or not isinstance(current, (int, float))
or not isinstance(value, (int, float))
):
return False
if isinstance(current, float) and not math.isfinite(current):
return False
if isinstance(value, float) and not math.isfinite(value):
return False
if rule.operator == "gt":
return current > value
if rule.operator == "gte":
return current >= value
if rule.operator == "lt":
return current < value
return current <= value
if rule.operator == "contains":
if isinstance(current, str) and isinstance(rule.value, str):
return rule.value in current
if isinstance(current, (list, tuple)):
return any(_equality(item, rule.value) is True for item in current)
return False

3
src/ai_platform/main.py Normal file
View File

@ -0,0 +1,3 @@
from ai_platform.bootstrap.app import create_app
app = create_app()

View File

@ -0,0 +1,156 @@
import secrets
from datetime import UTC, datetime, timedelta
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Request, Response
from pwdlib import PasswordHash
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from ai_platform.infrastructure.persistence.models import (
AdminSession,
AdminUser,
AIProfile,
ScoringRun,
)
from ai_platform.infrastructure.security import digest
from ai_platform.presentation.http.dependencies import db_session
from ai_platform.presentation.http.routes import to_detail_json, to_json
from ai_platform.presentation.http.run_responses import RunDetail, RunList
from ai_platform.presentation.http.schemas import AdminLoginRequest, AIProfileInput
router = APIRouter()
COOKIE = "ai_platform_session"
password_hash = PasswordHash.recommended()
async def get_admin_session(request: Request, db: AsyncSession) -> AdminSession:
raw = request.cookies.get(COOKIE)
row = await db.get(AdminSession, digest(raw)) if raw else None
if row is None or row.expires_at <= datetime.now(UTC):
raise HTTPException(401, "Admin session expired")
return row
async def require_csrf(request: Request, session: AdminSession) -> None:
candidate = request.headers.get("x-csrf-token", "")
if not candidate or not secrets.compare_digest(digest(candidate), session.csrf_hash):
raise HTTPException(403, "CSRF token required")
@router.post("/v1/admin/login")
async def login(
body: AdminLoginRequest,
request: Request,
response: Response,
db: AsyncSession = Depends(db_session),
):
user = await db.get(AdminUser, body.email.strip().lower())
if user is None or not password_hash.verify(body.password, user.password_hash):
raise HTTPException(401, "Invalid email or password")
raw, csrf_token = secrets.token_urlsafe(32), secrets.token_urlsafe(32)
ttl = request.app.state.settings.session_ttl_seconds
db.add(
AdminSession(
id=digest(raw),
admin_email=user.email,
csrf_hash=digest(csrf_token),
expires_at=datetime.now(UTC) + timedelta(seconds=ttl),
)
)
await db.commit()
response.set_cookie(
COOKIE,
raw,
httponly=True,
secure=request.app.state.settings.admin_cookie_secure
or request.app.state.settings.environment == "production",
samesite="lax",
max_age=ttl,
path="/",
)
return {"email": user.email, "csrf_token": csrf_token, "expires_in": ttl}
@router.get("/v1/admin/me")
async def me(request: Request, db: AsyncSession = Depends(db_session)):
session = await get_admin_session(request, db)
return {"email": session.admin_email, "authenticated": True}
@router.post("/v1/admin/logout")
async def logout(request: Request, response: Response, db: AsyncSession = Depends(db_session)):
session = await get_admin_session(request, db)
await require_csrf(request, session)
await db.delete(session)
await db.commit()
response.delete_cookie(
COOKIE,
path="/",
httponly=True,
secure=request.app.state.settings.admin_cookie_secure
or request.app.state.settings.environment == "production",
samesite="lax",
)
return {"logged_out": True}
@router.get("/v1/admin/ai-profiles")
async def list_profiles(request: Request, db: AsyncSession = Depends(db_session)):
await get_admin_session(request, db)
rows = (await db.scalars(select(AIProfile).order_by(AIProfile.version))).all()
return {"profiles": [profile_json(row) for row in rows]}
@router.put("/v1/admin/ai-profiles")
async def create_profile(
body: AIProfileInput, request: Request, db: AsyncSession = Depends(db_session)
):
session = await get_admin_session(request, db)
await require_csrf(request, session)
existing = await db.get(AIProfile, body.version)
values = body.model_dump(mode="json")
if existing and any(
getattr(existing, key) != values[key]
for key in ("model", "system_prompt", "timeout_seconds", "limits")
):
raise HTTPException(409, "Profile versions are immutable; use a new version")
rows = (await db.scalars(select(AIProfile).with_for_update())).all()
for row in rows:
row.active = False
if existing is None:
existing = AIProfile(**values, active=True)
db.add(existing)
else:
existing.active = True
await db.commit()
return profile_json(existing)
@router.get("/v1/admin/runs", response_model=RunList)
async def list_runs(request: Request, db: AsyncSession = Depends(db_session)):
await get_admin_session(request, db)
rows = (
await db.scalars(select(ScoringRun).order_by(ScoringRun.created_at.desc()).limit(50))
).all()
return {"runs": [to_json(row) for row in rows]}
@router.get("/v1/admin/runs/{run_id}", response_model=RunDetail)
async def admin_run(run_id: UUID, request: Request, db: AsyncSession = Depends(db_session)):
await get_admin_session(request, db)
row = await db.get(ScoringRun, str(run_id))
if row is None:
raise HTTPException(404, "Run not found")
return to_detail_json(row)
def profile_json(row: AIProfile) -> dict:
return {
"version": row.version,
"model": row.model,
"system_prompt": row.system_prompt,
"timeout_seconds": row.timeout_seconds,
"limits": row.limits,
"active": row.active,
}

View File

@ -0,0 +1,6 @@
from fastapi import Request
async def db_session(request: Request):
async with request.app.state.sessions() as db:
yield db

View File

@ -0,0 +1,199 @@
import asyncio
import hashlib
import json
import secrets
from datetime import UTC, datetime
from uuid import UUID
from fastapi import APIRouter, Depends, Header, HTTPException, Request, Response
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from ai_platform.infrastructure.persistence.models import AIProfile, ScoringRun, TenantKey
from ai_platform.infrastructure.scoring import execute_run
from ai_platform.infrastructure.security import digest
from ai_platform.lead_scoring.domain.scoring import derive_metrics
from ai_platform.presentation.http.dependencies import db_session
from ai_platform.presentation.http.run_responses import RunDetail, RunSummary
from ai_platform.presentation.http.schemas import RunRequest
router = APIRouter()
def canonical(value: object) -> bytes:
return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode()
def to_json(row: ScoringRun) -> dict:
return {
"run_id": row.id,
"request_id": row.request_id,
"tenant_id": row.tenant_id,
"status": row.status,
"profile_version": row.profile_version,
"rule_score": row.rule_score,
"ai_score": row.ai_score,
"final_score": row.final_score,
"degraded": row.degraded,
"result_mode": row.result_mode,
"error_code": row.error_code,
"breakdown": row.breakdown,
"created_at": row.created_at.isoformat() if row.created_at else None,
"completed_at": row.completed_at.isoformat() if row.completed_at else None,
}
def to_detail_json(row: ScoringRun) -> dict:
return {
**to_json(row),
"snapshot": row.request["snapshot"],
"metrics": derive_metrics(row.request["snapshot"]),
"ai_confidence": row.ai_confidence,
"ai_reasoning": row.ai_reasoning,
"ai_evidence": row.ai_evidence,
}
async def tenant_id(
authorization: str | None = Header(default=None), db: AsyncSession = Depends(db_session)
) -> str:
if not authorization or not authorization.startswith("Bearer "):
raise HTTPException(401, "Tenant authentication required")
key = authorization[7:]
row = (
await db.execute(
select(TenantKey).where(TenantKey.verifier == digest(key), TenantKey.active.is_(True))
)
).scalar_one_or_none()
if row is None:
raise HTTPException(401, "Invalid tenant key")
return row.tenant_id
@router.get("/health/live")
async def live():
return {"status": "ok"}
@router.get("/health/ready")
async def ready(db: AsyncSession = Depends(db_session)):
try:
await db.execute(select(1))
except Exception as exc:
raise HTTPException(503, "Database unavailable") from exc
return {"status": "ready"}
@router.post("/v1/platform/tenant-keys", status_code=201)
async def provision_key(
request: Request,
body: dict,
authorization: str | None = Header(default=None),
db: AsyncSession = Depends(db_session),
):
secret = request.app.state.settings.tenant_provisioning_secret
if (
secret is None
or not authorization
or not secrets.compare_digest(authorization, "Bearer " + secret.get_secret_value())
):
raise HTTPException(401, "Provisioner authentication required")
tenant = body.get("tenant_id")
if not isinstance(tenant, str) or not tenant.strip() or len(tenant) > 128:
raise HTTPException(422, "tenant_id must be a non-empty string up to 128 characters")
if await db.get(TenantKey, tenant):
raise HTTPException(409, "Tenant key already provisioned")
raw = "tnt_" + secrets.token_urlsafe(32)
db.add(TenantKey(tenant_id=tenant, verifier=digest(raw)))
try:
await db.commit()
except IntegrityError as exc:
await db.rollback()
raise HTTPException(409, "Tenant key already provisioned") from exc
return {"tenant_id": tenant, "tenant_key": raw}
@router.post(
"/v1/lead-scoring/runs",
status_code=202,
response_model=RunSummary,
responses={200: {"model": RunSummary}},
)
async def create_run(
body: RunRequest,
request: Request,
response: Response,
tenant: str = Depends(tenant_id),
raw_vkey: str | None = Header(default=None, alias="X-Tenant-VKey"),
db: AsyncSession = Depends(db_session),
):
payload = body.model_dump(mode="json")
canonical_body = canonical(payload)
if raw_vkey and raw_vkey.encode() in canonical_body:
raise HTTPException(422, "V-Key must not appear in the scoring payload")
fingerprint = hashlib.sha256(canonical_body).hexdigest()
request_key = str(body.request_id)
old = (
await db.execute(
select(ScoringRun).where(
ScoringRun.tenant_id == tenant, ScoringRun.request_id == request_key
)
)
).scalar_one_or_none()
if old:
if old.fingerprint != fingerprint:
raise HTTPException(409, "Request UUID already used with a different request")
response.status_code = 200
return to_json(old)
profile = (
await db.execute(select(AIProfile).where(AIProfile.active.is_(True)))
).scalar_one_or_none()
row = ScoringRun(
tenant_id=tenant,
request_id=request_key,
fingerprint=fingerprint,
request=payload,
profile_version=profile.version if profile else "unconfigured",
)
db.add(row)
try:
await db.commit()
except IntegrityError as exc:
await db.rollback()
raise HTTPException(409, "Request UUID conflict") from exc
try:
task = asyncio.create_task(
execute_run(
request.app.state.sessions,
row.id,
raw_vkey,
request.app.state.aihub_client,
)
)
except RuntimeError as exc:
row.status, row.completed_at, row.error_code = (
"failed",
datetime.now(UTC),
"execution_failed",
)
await db.commit()
raise HTTPException(503, "Run could not be scheduled") from exc
request.app.state.tasks.add(task)
task.add_done_callback(request.app.state.tasks.discard)
response.headers["Location"] = f"/v1/lead-scoring/runs/{row.id}"
return to_json(row)
@router.get("/v1/lead-scoring/runs/{run_id}", response_model=RunDetail)
async def get_run(
run_id: UUID, tenant: str = Depends(tenant_id), db: AsyncSession = Depends(db_session)
):
row = (
await db.execute(
select(ScoringRun).where(ScoringRun.id == str(run_id), ScoringRun.tenant_id == tenant)
)
).scalar_one_or_none()
if row is None:
raise HTTPException(404, "Run not found")
return to_detail_json(row)

View File

@ -0,0 +1,128 @@
import math
from typing import Any, Literal
from pydantic import (
BaseModel,
ConfigDict,
Field,
StrictFloat,
StrictInt,
field_validator,
model_validator,
)
RuleOperator = Literal["eq", "ne", "gt", "gte", "lt", "lte", "contains", "exists"]
_NUMERIC_OPERATORS = {"gt", "gte", "lt", "lte"}
_INTEGER_METRICS = {
"profile_field_count",
"conversation_count",
"message_count",
"contact_message_count",
"customer_message_count",
"conversation_turn_count",
"contact_started_conversation_count",
}
_BOOLEAN_METRICS = {"customer_initiated_return"}
_RECENCY_METRICS = {"contact_message_recency_days", "contact_message_recency_hours"}
_METRICS = _INTEGER_METRICS | _BOOLEAN_METRICS | _RECENCY_METRICS
_PROFILE_FIELDS = {"ems_lead_id", "name", "phone", "email", "status"}
def _valid_scalar(value: Any) -> bool:
if value is None or isinstance(value, (str, bool, int)):
return not isinstance(value, float)
return isinstance(value, float) and math.isfinite(value)
class RuleInput(BaseModel):
model_config = ConfigDict(extra="forbid", frozen=True)
id: str = Field(min_length=1, max_length=64)
field: str = Field(min_length=1, max_length=128)
operator: RuleOperator
value: Any = None
points: StrictInt = Field(ge=-100, le=100)
@field_validator("field")
@classmethod
def validate_field_path(cls, value: str) -> str:
parts = value.split(".")
if any(not part for part in parts):
raise ValueError("field must be a dotted path without empty segments")
if parts[0] == "custom_fields" and len(parts) >= 2:
return value
if parts[0] == "lead_profile":
if len(parts) == 2 and parts[1] in _PROFILE_FIELDS:
return value
if len(parts) >= 3 and parts[1] == "custom_fields":
return value
if parts[0] == "metrics" and len(parts) == 2 and parts[1] in _METRICS:
return value
raise ValueError("field must address lead_profile, custom_fields, or a supported metric")
@field_validator("value")
@classmethod
def validate_bounded_value(cls, value: Any) -> Any:
if value is None or _valid_scalar(value):
if isinstance(value, str) and len(value) > 1_000:
raise ValueError("rule string values cannot exceed 1000 characters")
return value
if isinstance(value, list):
if len(value) > 100:
raise ValueError("rule list values cannot exceed 100 items")
if any(not _valid_scalar(item) for item in value):
raise ValueError("rule lists may contain only scalar JSON values")
if any(isinstance(item, str) and len(item) > 1_000 for item in value):
raise ValueError("rule list strings cannot exceed 1000 characters")
return value
raise ValueError("rule values must be scalar JSON values or a flat list")
@model_validator(mode="after")
def validate_operator_value(self):
if self.field == "metrics.customer_initiated_return":
if self.operator not in {"eq", "ne"}:
raise ValueError("customer_initiated_return supports only eq/ne operators")
if type(self.value) is not bool:
raise ValueError("customer_initiated_return rules require a boolean value")
return self
if self.operator == "exists":
if self.value is not None:
raise ValueError("exists rules require a null value")
return self
if self.value is None:
raise ValueError("only exists rules may use a null value")
if self.field.startswith("metrics."):
metric = self.field.removeprefix("metrics.")
if self.operator == "contains":
raise ValueError("contains is not supported for numeric metrics")
if metric in _INTEGER_METRICS and type(self.value) is not int:
raise ValueError("count metrics require an integer rule value")
if metric in _RECENCY_METRICS and (
isinstance(self.value, bool) or not isinstance(self.value, (int, float))
):
raise ValueError("recency rules require a numeric value")
if self.operator in _NUMERIC_OPERATORS and (
isinstance(self.value, bool) or not isinstance(self.value, (int, float))
):
raise ValueError("numeric comparison rules require a numeric value")
if self.operator == "contains" and isinstance(self.value, list):
raise ValueError("contains rules require a scalar value")
return self
class ScoringConfigurationInput(BaseModel):
model_config = ConfigDict(extra="forbid", frozen=True)
version: str = Field(min_length=1, max_length=64)
rules: list[RuleInput] = Field(max_length=100)
rule_weight: StrictFloat = Field(ge=0, le=1, allow_inf_nan=False)
ai_weight: StrictFloat = Field(ge=0, le=1, allow_inf_nan=False)
@model_validator(mode="after")
def valid_weights_and_rules(self):
if abs(self.rule_weight + self.ai_weight - 1) > 1e-9:
raise ValueError("rule_weight and ai_weight must sum to 1")
ids = [rule.id for rule in self.rules]
if len(ids) != len(set(ids)):
raise ValueError("rule IDs must be unique")
return self

View File

@ -0,0 +1,96 @@
from datetime import datetime
from typing import Any, Literal
from uuid import UUID
from pydantic import BaseModel, ConfigDict, Field, StrictBool
from ai_platform.presentation.http.schemas import LeadScoringSnapshot
class RunSummary(BaseModel):
"""Scoring outcome fields returned without the stored snapshot."""
model_config = ConfigDict(extra="forbid", frozen=True)
run_id: UUID
request_id: UUID
tenant_id: str
status: Literal["pending", "running", "completed", "failed"]
profile_version: str
rule_score: int | None
ai_score: int | None
final_score: int | None
degraded: bool
result_mode: Literal["rules_only", "rules_only_fallback", "hybrid"] | None
error_code: str | None
breakdown: list[dict[str, Any]] | None
created_at: datetime | None
completed_at: datetime | None
class RunEvidenceItem(BaseModel):
model_config = ConfigDict(extra="forbid", frozen=True, strict=True)
signal: str = Field(min_length=1, max_length=80)
detail: str = Field(min_length=1, max_length=500)
source: str = Field(min_length=1, max_length=120)
class RunMetrics(BaseModel):
model_config = ConfigDict(extra="forbid", frozen=True, strict=True)
profile_field_count: int = Field(
ge=0, description="Count of non-null name, phone, email, and status fields."
)
conversation_count: int = Field(ge=0)
message_count: int = Field(ge=0)
contact_message_count: int = Field(ge=0)
customer_message_count: int = Field(
ge=0, description="Compatibility alias for contact_message_count."
)
conversation_turn_count: int = Field(
ge=0,
description="Consecutive sender groups after ordering conversation messages by sent_at.",
)
contact_started_conversation_count: int = Field(
ge=0,
description=(
"Count of non-empty conversations whose earliest valid sent_at message is from "
"contact; equal message timestamps retain message snapshot order."
),
)
contact_message_recency_days: float | None = Field(default=None, ge=0)
contact_message_recency_hours: float | None = Field(
ge=0,
description=(
"Hours from the newest contact message with a valid sent_at to captured_at, "
"clamped to zero and rounded to two decimal places; null when either timestamp "
"is unavailable."
),
)
customer_initiated_return: StrictBool = Field(
description=(
"True only when more than one non-empty conversation has a valid message sent_at "
"and at least one conversation after the first starts with a contact message. "
"Conversations are ordered by earliest valid message sent_at, with equal start "
"times retaining original snapshot position. A conversation starts with the sender "
"of its earliest valid message; equal message timestamps retain message snapshot "
"order. Empty conversations are ignored."
),
)
class RunDetail(RunSummary):
"""Scoring result, metrics, extracted evidence, and bounded snapshot."""
snapshot: LeadScoringSnapshot
metrics: RunMetrics
ai_confidence: float | None = Field(ge=0, le=1)
ai_reasoning: str | None = Field(max_length=2_000)
ai_evidence: list[RunEvidenceItem] | None = Field(default=None, max_length=20)
class RunList(BaseModel):
model_config = ConfigDict(extra="forbid", frozen=True)
runs: list[RunSummary]

View File

@ -0,0 +1,147 @@
from datetime import UTC, datetime, timedelta
from typing import Any, Literal
from uuid import UUID
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
from ai_platform.presentation.http.rule_schemas import ScoringConfigurationInput
def require_aware_utc(value: datetime) -> datetime:
if value.tzinfo is None or value.utcoffset() is None:
raise ValueError("timestamp must be timezone-aware")
return value.astimezone(UTC)
class SnapshotLeadProfile(BaseModel):
model_config = ConfigDict(extra="forbid", frozen=True)
ems_lead_id: str = Field(min_length=1, max_length=255)
name: str | None = Field(default=None, max_length=255)
phone: str | None = Field(default=None, max_length=64)
email: str | None = Field(default=None, max_length=255)
status: str | None = Field(default=None, max_length=64)
custom_fields: dict[str, Any] = Field(default_factory=dict)
class SnapshotMessage(BaseModel):
model_config = ConfigDict(extra="forbid", frozen=True)
message_id: str = Field(min_length=1, max_length=255)
sender_type: Literal["contact", "agent", "bot"]
content: str = Field(min_length=1)
sent_at: datetime
@field_validator("sent_at")
@classmethod
def sent_at_is_aware_utc(cls, value: datetime) -> datetime:
return require_aware_utc(value)
class SnapshotConversation(BaseModel):
model_config = ConfigDict(extra="forbid", frozen=True)
conversation_id: str = Field(min_length=1, max_length=255)
channel: str | None = Field(default=None, max_length=64)
assigned_agent_id: str | None = Field(default=None, max_length=255)
messages: list[SnapshotMessage] = Field(default_factory=list)
class SnapshotIdentityContext(BaseModel):
model_config = ConfigDict(extra="forbid", frozen=True)
match_quality: Literal["direct", "phone_and_email", "phone_and_name", "phone_only"] | None = (
None
)
contact_id: str | None = Field(default=None, max_length=255)
details: dict[str, Any] = Field(default_factory=dict)
class SnapshotTriggerMetadata(BaseModel):
model_config = ConfigDict(extra="forbid", frozen=True)
trigger_type: Literal["manual_omni", "captain_tool", "dev_console"] | None = None
trigger_conversation_id: str | None = Field(default=None, max_length=255)
actor_id: str | None = Field(default=None, max_length=255)
class SnapshotTruncation(BaseModel):
model_config = ConfigDict(extra="forbid", frozen=True)
truncated: bool = False
dropped_conversations_count: int = Field(default=0, ge=0)
dropped_messages_count: int = Field(default=0, ge=0)
reason: str | None = Field(default=None, max_length=255)
class LeadScoringSnapshot(BaseModel):
"""Bounded HTTP representation of Omni's captured lead evidence."""
model_config = ConfigDict(extra="forbid", frozen=True)
version: str | None = Field(default=None, max_length=64)
scope: Literal["trigger_only", "linked_contact_history", "multi_source_history"] | None = None
captured_at: datetime
lead_profile: SnapshotLeadProfile
conversations: list[SnapshotConversation] = Field(default_factory=list, max_length=10)
identity_context: SnapshotIdentityContext | None = None
trigger_metadata: SnapshotTriggerMetadata | None = None
truncation: SnapshotTruncation | None = None
@field_validator("captured_at")
@classmethod
def captured_at_is_aware_utc(cls, value: datetime) -> datetime:
return require_aware_utc(value)
@model_validator(mode="after")
def validate_evidence(self):
messages = [
message for conversation in self.conversations for message in conversation.messages
]
if len(messages) > 50:
raise ValueError("total messages exceed maximum cap of 50")
if sum(len(message.content) for message in messages) > 20_000:
raise ValueError("total message content exceeds maximum cap of 20000 characters")
oldest_allowed = self.captured_at - timedelta(days=90)
for message in messages:
if message.sent_at > self.captured_at:
raise ValueError("message sent_at cannot be after captured_at")
if message.sent_at < oldest_allowed:
raise ValueError("message sent_at exceeds 90-day evidence window")
return self
class RunRequest(BaseModel):
model_config = ConfigDict(extra="forbid", frozen=True)
request_id: UUID
subject_ref: str = Field(min_length=1, max_length=255)
snapshot: LeadScoringSnapshot
scoring_config: ScoringConfigurationInput
ai_profile: Literal["light", "medium", "heavy"]
@model_validator(mode="after")
def snapshot_size(self):
import json
if (
len(json.dumps(self.snapshot.model_dump(mode="json"), ensure_ascii=False).encode())
> 100_000
):
raise ValueError("snapshot exceeds 100KB")
return self
class AIProfileInput(BaseModel):
model_config = ConfigDict(extra="forbid", frozen=True)
version: str = Field(min_length=1, max_length=64)
model: str = Field(min_length=1, max_length=128)
system_prompt: str = Field(min_length=1, max_length=8000)
timeout_seconds: int = Field(ge=1, le=120)
limits: dict[str, int] = Field(default_factory=dict)
class AdminLoginRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
email: str = Field(min_length=3, max_length=254)
password: str = Field(min_length=1, max_length=1024)

View File

@ -1,57 +1,168 @@
from collections.abc import Generator import asyncio
import time
from collections.abc import Callable, Iterator
from typing import Any
from urllib.parse import urlsplit
from uuid import uuid4
import httpx
import pytest import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from sqlalchemy import delete, select
# Attempt import of testcontainers from ai_platform.bootstrap.app import create_app
postgres_container_cls = None from ai_platform.infrastructure.config.settings import Settings
try: from ai_platform.infrastructure.persistence.database import make_session_factory
try: from ai_platform.infrastructure.persistence.models import AIProfile, ScoringRun, TenantKey
from testcontainers.community.postgres import PostgresContainer as _PostgresContainer from ai_platform.infrastructure.security import digest
postgres_container_cls = _PostgresContainer
except ImportError:
from testcontainers.postgres import PostgresContainer as _PostgresContainer
postgres_container_cls = _PostgresContainer
except ImportError:
postgres_container_cls = None
@pytest.fixture(scope="session")
def postgres_container_url() -> Generator[str]:
"""Provides a disposable PostgreSQL 16 container URL strictly via Docker testcontainers.
Scoped to session to avoid duplicating container startup across tests.
No ambient admin or SQLite fallbacks.
"""
if postgres_container_cls is None:
raise RuntimeError(
"testcontainers package is required for PostgreSQL integration tests. "
"SQLite/localhost fallbacks are strictly prohibited."
)
try:
container = postgres_container_cls("postgres:16-alpine")
container.start()
host = container.get_container_host_ip()
port = container.get_exposed_port(5432)
user = container.username
password = container.password
dbname = container.dbname
url = f"postgresql+asyncpg://{user}:{password}@{host}:{port}/{dbname}"
try:
yield url
finally:
container.stop()
except Exception as exc:
raise RuntimeError(
f"Failed to start PostgreSQL testcontainer: {exc}. "
"Docker daemon must be running and accessible for integration tests. "
"SQLite/localhost fallbacks are prohibited."
) from exc
@pytest.fixture @pytest.fixture
def temp_postgres_db(postgres_container_url: str) -> str: def valid_run_payload():
"""Yields disposable PostgreSQL container database URL.""" return {
return postgres_container_url "request_id": "f81c7c4e-a92a-4b19-8d7a-3a5c08eb4e6e",
"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",
tenant_provisioning_secret=loaded.tenant_provisioning_secret,
aihub_base_url="http://localhost:8080",
)
factory = make_session_factory(settings.database_url)
suffix = uuid4().hex
tenant_id = f"aihub-test-{suffix}"
profile_version = f"aihub-test-{suffix}"
tenant_key = "test-only-tenant-key"
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(TenantKey(tenant_id=tenant_id, verifier=digest(tenant_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.tenant_id == tenant_id))
await db.execute(delete(TenantKey).where(TenantKey.tenant_id == tenant_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,
"profile_version": profile_version,
"tenant_id": tenant_id,
"tenant_key": tenant_key,
"vkey": vkey,
}
finally:
asyncio.run(cleanup())
def _wait_for_terminal(client: TestClient, run_id: str, headers: dict[str, str]) -> dict:
for _ in range(100):
response = client.get(f"/v1/lead-scoring/runs/{run_id}", headers=headers)
assert response.status_code == 200
result = response.json()
if result["status"] in {"completed", "failed"}:
return result
time.sleep(0.02)
pytest.fail("Scoring run did not reach a terminal state")
@pytest.fixture
def wait_for_terminal():
return _wait_for_terminal

52
tests/fixtures/mock_omni_run.json vendored Normal file
View File

@ -0,0 +1,52 @@
{
"request_id": "f81c7c4e-a92a-4b19-8d7a-3a5c08eb4e6e",
"subject_ref": "mock-lead-001",
"snapshot": {
"version": "1.0.0",
"scope": "linked_contact_history",
"captured_at": "2026-01-01T12:00:00Z",
"lead_profile": {
"ems_lead_id": "mock-lead-001",
"name": "Nguyen Minh An",
"phone": "+84000000001",
"email": "an@example.invalid",
"status": "new",
"custom_fields": {"region": "north", "program_interest": "data science"}
},
"conversations": [
{
"conversation_id": "mock-conversation-001",
"channel": "webchat",
"assigned_agent_id": "mock-agent-01",
"messages": [
{"message_id": "mock-message-001", "sender_type": "contact", "content": "I am interested in the data science program.", "sent_at": "2025-12-31T10:00:00Z"},
{"message_id": "mock-message-002", "sender_type": "agent", "content": "The next cohort starts in March.", "sent_at": "2025-12-31T10:05:00Z"},
{"message_id": "mock-message-003", "sender_type": "contact", "content": "Please send me the application requirements.", "sent_at": "2025-12-31T10:10:00Z"}
]
},
{
"conversation_id": "mock-conversation-002",
"channel": "email",
"assigned_agent_id": "mock-agent-02",
"messages": [
{"message_id": "mock-message-004", "sender_type": "agent", "content": "I have sent the program brochure.", "sent_at": "2025-12-31T11:00:00Z"},
{"message_id": "mock-message-005", "sender_type": "contact", "content": "Thank you, I will review it today.", "sent_at": "2025-12-31T11:15:00Z"}
]
}
],
"identity_context": {"match_quality": "phone_and_email", "contact_id": "mock-contact-001", "details": {"source": "local_mock_fixture"}},
"trigger_metadata": {"trigger_type": "manual_omni", "trigger_conversation_id": "mock-conversation-002", "actor_id": "mock-user-01"}
},
"scoring_config": {
"version": "mock-omni-v1",
"rules": [
{"id": "region-north", "field": "custom_fields.region", "operator": "eq", "value": "north", "points": 25},
{"id": "program-interest", "field": "custom_fields.program_interest", "operator": "contains", "value": "data", "points": 35},
{"id": "conversation-count", "field": "metrics.conversation_count", "operator": "eq", "value": 2, "points": 10},
{"id": "contact-message-count", "field": "metrics.contact_message_count", "operator": "eq", "value": 3, "points": 10}
],
"rule_weight": 1,
"ai_weight": 0
},
"ai_profile": "medium"
}

118
tests/test_aihub_adapter.py Normal file
View File

@ -0,0 +1,118 @@
import json
import httpx
import pytest
from ai_platform.infrastructure.aihub import AiHubClient, AiHubError
VKEY = "request-only-vkey-test"
def client_for(handler, *, timeout: float = 0.2) -> tuple[AiHubClient, httpx.AsyncClient]:
transport = httpx.MockTransport(handler)
http_client = httpx.AsyncClient(transport=transport)
return AiHubClient("https://aihub.test", http_client, timeout), http_client
@pytest.mark.asyncio
async def test_sends_scoring_request_and_parses_scored_json():
def handler(request: httpx.Request) -> httpx.Response:
assert request.method == "POST"
assert request.url.path == "/v1/chat/completions"
assert request.headers["authorization"] == f"Bearer {VKEY}"
assert "x-tenant-vkey" not in request.headers
body = json.loads(request.content)
assert body["stream"] is False
assert body["response_format"] == {"type": "json_object"}
return httpx.Response(
200,
json={
"choices": [
{
"message": {
"content": '{"score": 82, "confidence": 0.93, "reasoning": "Strong fit"}'
}
}
]
},
)
adapter, http_client = client_for(handler)
try:
result = await adapter.score(
model="catalog-model", system_prompt="System", user_prompt="Snapshot", vkey=VKEY
)
assert result.score == 82
assert result.confidence == 0.93
assert result.reasoning == "Strong fit"
finally:
await http_client.aclose()
@pytest.mark.parametrize("status", [401, 402, 429, 500, 503])
@pytest.mark.asyncio
async def test_http_errors_are_sanitized(status: int):
adapter, http_client = client_for(lambda _request: httpx.Response(status, text=VKEY))
try:
with pytest.raises(AiHubError, match=f"HTTP {status}") as error:
await adapter.score(model="m", system_prompt="s", user_prompt="u", vkey=VKEY)
assert VKEY not in str(error.value)
assert error.value.error_code == "aihub_http_error"
finally:
await http_client.aclose()
@pytest.mark.parametrize(
"body",
[
b"not-json",
b'{"choices": []}',
b'{"choices": [{"message": {"content": "not-json"}}]}',
b'{"choices": [{"message": {"content": "{\\"score\\": 5}"}}]}',
b'{"choices": [{"message": {"content": "{\\"score\\": 5, \\"score\\": 6, \\"confidence\\": 0.5, \\"reasoning\\": \\"duplicate\\"}"}}]}',
],
)
@pytest.mark.asyncio
async def test_malformed_scoring_responses_are_rejected(body: bytes):
adapter, http_client = client_for(lambda _request: httpx.Response(200, content=body))
try:
with pytest.raises(AiHubError, match="invalid JSON response"):
await adapter.score(model="m", system_prompt="s", user_prompt="u", vkey=VKEY)
finally:
await http_client.aclose()
@pytest.mark.asyncio
async def test_timeout_is_sanitized():
def handler(_request: httpx.Request) -> httpx.Response:
raise httpx.ReadTimeout("upstream error includes " + VKEY)
adapter, http_client = client_for(handler)
try:
with pytest.raises(AiHubError, match="timed out") as error:
await adapter.score(model="m", system_prompt="s", user_prompt="u", vkey=VKEY)
assert VKEY not in str(error.value)
assert error.value.error_code == "aihub_timeout"
finally:
await http_client.aclose()
@pytest.mark.asyncio
async def test_response_echoing_vkey_is_rejected_without_exposing_it():
response = {
"choices": [
{
"message": {
"content": json.dumps({"score": 70, "confidence": 0.8, "reasoning": VKEY})
}
}
]
}
adapter, http_client = client_for(lambda _request: httpx.Response(200, json=response))
try:
with pytest.raises(AiHubError) as error:
await adapter.score(model="m", system_prompt="s", user_prompt="u", vkey=VKEY)
assert VKEY not in str(error.value)
assert error.value.error_code == "aihub_invalid_response"
finally:
await http_client.aclose()

View File

@ -0,0 +1,39 @@
import json
import httpx
import pytest
from ai_platform.infrastructure.aihub import AiHubClient, AiHubError
@pytest.mark.asyncio
async def test_rejects_vkey_echo_after_json_unescaping():
vkey = 'mock-key-with-"-and-\\'
response = {
"choices": [
{
"message": {
"content": json.dumps({"score": 70, "confidence": 0.8, "reasoning": vkey})
}
}
]
}
client = AiHubClient(
"https://aihub.test",
httpx.AsyncClient(
transport=httpx.MockTransport(lambda _request: httpx.Response(200, json=response))
),
)
try:
with pytest.raises(AiHubError) as error:
await client.score(
model="openai/gpt-4o-mini",
system_prompt="prompt",
user_prompt="snapshot",
vkey=vkey,
)
finally:
await client.aclose()
assert error.value.error_code == "aihub_invalid_response"
assert vkey not in str(error.value)

View File

@ -0,0 +1,193 @@
import json
from pathlib import Path
from urllib.parse import urlsplit
from uuid import uuid4
import httpx
import pytest
from sqlalchemy import delete
from ai_platform.infrastructure.aihub import AiHubClient
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, ScoringRun, TenantKey
from ai_platform.infrastructure.scoring import execute_run
from ai_platform.infrastructure.security import digest
from ai_platform.presentation.http.routes import to_detail_json
from ai_platform.presentation.http.run_responses import RunDetail
FIXTURE = Path(__file__).parent / "fixtures" / "mock_omni_run.json"
VKEY = "mock-vkey-for-aihub-integration-only"
def _test_database_settings() -> Settings:
settings = Settings.load()
parsed = urlsplit(settings.database_url)
database_name = parsed.path.rstrip("/").rsplit("/", 1)[-1]
if database_name != "ai_platform_test" or parsed.hostname not in {
"127.0.0.1",
"localhost",
"::1",
"postgres",
}:
pytest.skip("requires the isolated ai_platform_test database")
return settings
def _response(content: str) -> httpx.Response:
return httpx.Response(
200,
json={"choices": [{"message": {"content": content}}]},
)
@pytest.mark.parametrize(
("case", "status", "error_code", "expected_calls"),
[
("rules_only", "completed", None, 0),
("missing_key", "completed", "vkey_missing", 0),
("missing_profile", "completed", "ai_profile_missing", 0),
("deferred_light", "completed", "ai_profile_deferred", 0),
("deferred_heavy", "completed", "ai_profile_deferred", 0),
("missing_client", "completed", "aihub_not_configured", 0),
("http_error", "completed", "aihub_http_error", 1),
("timeout", "completed", "aihub_timeout", 1),
("malformed_evidence", "completed", "aihub_invalid_evidence", 1),
("malformed_score", "completed", "aihub_invalid_score", 2),
("unexpected_provider_error", "failed", "execution_failed", 1),
("invalid_stored_request", "failed", "execution_failed", 0),
],
)
@pytest.mark.asyncio
async def test_scoring_execution_persists_hybrid_fallback_and_failed_results(
case: str, status: str, error_code: str | None, expected_calls: int
):
settings = _test_database_settings()
factory = make_session_factory(settings.database_url)
suffix = uuid4().hex
tenant_id = f"two-pass-{suffix}"
run_id = str(uuid4())
request_id = str(uuid4())
profile_version = f"test-medium-{suffix}"
payload = json.loads(FIXTURE.read_text())
payload["request_id"] = request_id
payload["ai_profile"] = "medium"
payload["scoring_config"].update(
rules=[
{
"id": "private-customer-rule-id",
"field": "custom_fields.region",
"operator": "eq",
"value": "north",
"points": 25,
}
],
rule_weight=0.6,
ai_weight=0.4,
)
if case == "rules_only":
payload["scoring_config"].update(rule_weight=1, ai_weight=0)
if case == "deferred_light":
payload["ai_profile"] = "light"
if case == "deferred_heavy":
payload["ai_profile"] = "heavy"
if case == "invalid_stored_request":
payload["scoring_config"] = {"ai_weight": 0.4}
async with factory() as db:
db.add(TenantKey(tenant_id=tenant_id, verifier=digest("tenant-key-" + suffix)))
if case not in {"missing_profile", "invalid_stored_request"}:
db.add(
AIProfile(
version=profile_version,
model="openai/gpt-4o-mini",
system_prompt="Đánh giá mức độ phù hợp dựa trên hồ sơ và bằng chứng.",
timeout_seconds=7,
limits={},
active=True,
)
)
await db.flush()
db.add(
ScoringRun(
id=run_id,
tenant_id=tenant_id,
request_id=request_id,
fingerprint="a" * 64,
request=payload,
profile_version=(
"unconfigured"
if case in {"missing_profile", "invalid_stored_request"}
else profile_version
),
status="pending",
)
)
await db.commit()
calls: list[httpx.Request] = []
def handler(request: httpx.Request) -> httpx.Response:
calls.append(request)
if case == "http_error":
return httpx.Response(429, text=f"private provider response {VKEY}")
if case == "timeout":
raise httpx.ReadTimeout(f"private provider error {VKEY}")
if case == "unexpected_provider_error":
raise RuntimeError(f"provider programming failure {VKEY}")
if case == "malformed_evidence":
return _response('{"items":[{"signal":"interest"}]}')
if len(calls) == 1:
return _response(
'{"items":[{"signal":"ý định","detail":"Hỏi yêu cầu đăng ký",'
'"source":"mock-message-001"}]}'
)
if case == "malformed_score":
return _response('{"score":"81","confidence":0.91,"reasoning":"Không hợp lệ"}')
return _response('{"score":81,"confidence":0.91,"reasoning":"Quan tâm rõ ràng"}')
http_client = httpx.AsyncClient(transport=httpx.MockTransport(handler))
aihub = AiHubClient("https://aihub.test", http_client)
try:
await execute_run(
factory,
run_id,
None if case == "missing_key" else VKEY,
None if case == "missing_client" else aihub,
)
async with factory() as db:
row = await db.get(ScoringRun, run_id)
assert row is not None
detail = RunDetail.model_validate(to_detail_json(row))
serialized = detail.model_dump_json()
assert VKEY not in serialized
assert len(calls) == expected_calls
assert row.status == status
assert row.error_code == error_code
assert row.ai_evidence is None
assert detail.ai_evidence is None
if case == "rules_only" or status == "completed":
assert row.ai_score is None
assert row.ai_confidence is None
assert row.ai_reasoning is None
assert row.final_score == row.rule_score == 25
assert (
row.result_mode == "rules_only"
if case == "rules_only"
else row.result_mode == "rules_only_fallback"
)
assert row.degraded is (case != "rules_only")
else:
assert row.ai_score is None
assert row.ai_confidence is None
assert row.ai_reasoning is None
assert row.final_score is None
assert row.result_mode is None
finally:
await aihub.aclose()
async with factory() as db:
await db.execute(delete(ScoringRun).where(ScoringRun.id == run_id))
await db.execute(delete(AIProfile).where(AIProfile.version == profile_version))
await db.execute(delete(TenantKey).where(TenantKey.tenant_id == tenant_id))
await db.commit()
await factory.kw["bind"].dispose()

View File

@ -0,0 +1,171 @@
import asyncio
import json
from pathlib import Path
from uuid import uuid4
import httpx
import pytest
from fastapi.testclient import TestClient
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from ai_platform.infrastructure.persistence.models import ScoringRun
from ai_platform.presentation.http.schemas import RunRequest
FIXTURE = Path(__file__).parent / "fixtures" / "mock_omni_run.json"
def _completion(content: dict) -> httpx.Response:
return httpx.Response(
200,
json={"choices": [{"message": {"content": json.dumps(content, ensure_ascii=False)}}]},
)
def test_medium_two_pass_scoring_persists_weighted_result(
aihub_test_environment: dict,
wait_for_terminal,
caplog: pytest.LogCaptureFixture,
):
vkey = aihub_test_environment["vkey"]
requests: list[httpx.Request] = []
def handler(request: httpx.Request) -> httpx.Response:
requests.append(request)
body = json.loads(request.content)
if "Trích xuất bằng chứng" in body["messages"][1]["content"]:
return _completion(
{
"items": [
{
"signal": "ý định đăng ký",
"detail": "Đã hỏi về hồ sơ đăng ký.",
"source": "mock-message-003",
}
]
}
)
return _completion(
{"score": 80, "confidence": 0.91, "reasoning": "Thể hiện nhu cầu rõ ràng."}
)
app = aihub_test_environment["app_for"](handler)
headers = {"Authorization": f"Bearer {aihub_test_environment['tenant_key']}"}
payload = json.loads(FIXTURE.read_text())
payload["request_id"] = str(uuid4())
payload["scoring_config"] = {
"version": "test-rules-v1",
"rules": [
{
"id": "customer-rule-secret",
"field": "custom_fields.region",
"operator": "eq",
"value": "north",
"points": 40,
}
],
"rule_weight": 0.25,
"ai_weight": 0.75,
}
payload["ai_profile"] = "medium"
with TestClient(app) as client:
created = client.post(
"/v1/lead-scoring/runs",
headers={**headers, "X-Tenant-VKey": vkey},
json=payload,
)
assert created.status_code == 202
summary = wait_for_terminal(client, created.json()["run_id"], headers)
assert summary["status"] == "completed"
assert summary["rule_score"] == 40
assert summary["ai_score"] == 80
assert summary["final_score"] == 70
assert summary["error_code"] is None
assert "snapshot" not in created.json()
assert "ai_confidence" not in created.json()
assert "ai_reasoning" not in created.json()
assert "metrics" not in created.json()
detail = client.get(f"/v1/lead-scoring/runs/{summary['run_id']}", headers=headers)
expected_snapshot = RunRequest.model_validate(payload).model_dump(mode="json")["snapshot"]
assert detail.status_code == 200
assert detail.json()["snapshot"] == expected_snapshot
assert detail.json()["ai_confidence"] == 0.91
assert detail.json()["ai_reasoning"] == "Thể hiện nhu cầu rõ ràng."
assert detail.json()["ai_evidence"] == [
{
"signal": "ý định đăng ký",
"detail": "Đã hỏi về hồ sơ đăng ký.",
"source": "mock-message-003",
}
]
assert detail.json()["metrics"] == {
"profile_field_count": 4,
"conversation_count": 2,
"message_count": 5,
"contact_message_count": 3,
"customer_message_count": 3,
"conversation_turn_count": 5,
"contact_started_conversation_count": 1,
"contact_message_recency_days": 1.03,
"contact_message_recency_hours": 24.75,
"customer_initiated_return": False,
}
fallback_payload = json.loads(FIXTURE.read_text())
fallback_payload["request_id"] = str(uuid4())
fallback_payload["scoring_config"].update(rule_weight=0.25, ai_weight=0.75)
fallback = client.post("/v1/lead-scoring/runs", headers=headers, json=fallback_payload)
assert fallback.status_code == 202
fallback_summary = wait_for_terminal(client, fallback.json()["run_id"], headers)
assert fallback_summary["error_code"] == "vkey_missing"
fallback_detail = client.get(
f"/v1/lead-scoring/runs/{fallback_summary['run_id']}", headers=headers
)
assert fallback_detail.status_code == 200
assert fallback_detail.json()["ai_evidence"] is None
assert fallback_detail.json()["ai_reasoning"] is None
assert fallback_detail.json()["metrics"] == detail.json()["metrics"]
assert vkey not in fallback_detail.text
assert len(requests) == 2
bodies = [json.loads(request.content) for request in requests]
assert all(body["model"] == "openai/gpt-4o-mini" for body in bodies)
assert bodies[0]["messages"][0]["content"].startswith("Bạn chỉ trích xuất")
assert bodies[1]["messages"][0]["content"].startswith("Dùng tiếng Việt")
for request, body in zip(requests, bodies, strict=True):
prompt = json.dumps(body["messages"], ensure_ascii=False)
assert request.headers["authorization"] == f"Bearer {vkey}"
assert "x-tenant-vkey" not in request.headers
assert request.extensions["timeout"]["read"] == 4
assert "customer-rule-secret" not in prompt
assert '"points"' not in prompt
assert "scoring_config" not in prompt
assert vkey not in prompt
assert "lead_profile" in bodies[0]["messages"][1]["content"]
assert "SNAPSHOT:" in bodies[1]["messages"][1]["content"]
assert "EVIDENCE:" in bodies[1]["messages"][1]["content"]
async def verify_persisted_run(
factory: async_sessionmaker[AsyncSession],
) -> None:
async with factory() as db:
row = await db.get(ScoringRun, summary["run_id"])
assert row is not None
assert row.profile_version == aihub_test_environment["profile_version"]
assert row.request["snapshot"] == expected_snapshot
assert row.ai_score == 80
assert row.ai_confidence == 0.91
assert row.ai_reasoning == "Thể hiện nhu cầu rõ ràng."
assert row.ai_evidence == detail.json()["ai_evidence"]
assert vkey not in json.dumps(
{"request": row.request, "ai_evidence": row.ai_evidence}
)
assert vkey not in json.dumps(row.request)
fallback_row = await db.get(ScoringRun, fallback_summary["run_id"])
assert fallback_row is not None
assert fallback_row.ai_evidence is None
await factory.kw["bind"].dispose()
asyncio.run(verify_persisted_run(aihub_test_environment["factory"]))
assert vkey not in detail.text
assert vkey not in caplog.text

View File

@ -0,0 +1,43 @@
import pytest
from pydantic import SecretStr, ValidationError
from ai_platform.infrastructure.config.settings import Settings
_TEST_DATABASE_URL = "postgresql+asyncpg://test:test@localhost/ai_platform_test"
def test_aihub_base_url_accepts_local_http():
settings = Settings(
database_url=_TEST_DATABASE_URL,
environment="development",
aihub_base_url="http://localhost:8080",
)
assert settings.aihub_base_url == "http://localhost:8080"
@pytest.mark.parametrize(
"base_url",
[
"ftp://localhost:8080",
"http://user:password@localhost:8080",
"http://localhost:8080?token=secret",
"http://aihub:8080",
],
)
def test_aihub_base_url_rejects_invalid_or_nonlocal_http(base_url: str):
with pytest.raises(ValidationError):
Settings(
database_url=_TEST_DATABASE_URL,
environment="development",
aihub_base_url=base_url,
)
def test_aihub_base_url_requires_https_outside_development():
with pytest.raises(ValidationError):
Settings(
database_url=_TEST_DATABASE_URL,
environment="production",
tenant_provisioning_secret=SecretStr("a-provisioner-secret"),
aihub_base_url="http://localhost:8080",
)

View File

@ -0,0 +1,119 @@
import json
import httpx
import pytest
from ai_platform.infrastructure.aihub import AiHubClient, AiHubError
from ai_platform.infrastructure.scoring import _scoring_system_prompt, _scoring_user_prompt
VKEY = "request-only-vkey-test-secret"
def completion(content: str) -> httpx.Response:
return httpx.Response(
200,
json={"choices": [{"message": {"content": content}}]},
)
@pytest.mark.asyncio
async def test_two_pass_adapter_uses_same_model_timeout_and_vkey_without_putting_key_in_prompts():
requests: list[httpx.Request] = []
def handler(request: httpx.Request) -> httpx.Response:
requests.append(request)
body = json.loads(request.content)
assert body["model"] == "openai/gpt-4o-mini"
assert body["stream"] is False
assert body["response_format"] == {"type": "json_object"}
assert request.headers["authorization"] == f"Bearer {VKEY}"
assert request.extensions["timeout"]["read"] == 7
return completion(
'{"items":[{"signal":"ý định","detail":"Hỏi điều kiện đăng ký","source":"message-1"}]}'
if len(requests) == 1
else '{"score":81,"confidence":0.91,"reasoning":"Quan tâm rõ ràng"}'
)
http_client = httpx.AsyncClient(transport=httpx.MockTransport(handler))
client = AiHubClient("https://aihub.test", http_client)
snapshot = {
"lead_profile": {"name": "Example", "custom_fields": {"region": "north"}},
"conversations": [
{
"messages": [
{
"message_id": "message-1",
"sender_type": "contact",
"content": "Please send me requirements.",
}
]
}
],
}
try:
evidence = await client.extract_evidence(
model="openai/gpt-4o-mini", snapshot=snapshot, vkey=VKEY, timeout=7
)
score = await client.score(
model="openai/gpt-4o-mini",
system_prompt=_scoring_system_prompt("Đánh giá theo hồ sơ AI đã cấu hình."),
user_prompt=_scoring_user_prompt(snapshot, evidence.model_dump(mode="json")),
vkey=VKEY,
timeout=7,
)
finally:
await client.aclose()
assert score.score == 81
assert score.confidence == 0.91
assert len(requests) == 2
for request in requests:
body = json.loads(request.content)
prompt = " ".join(message["content"] for message in body["messages"])
assert VKEY not in prompt
assert '"points"' not in prompt
assert '"rule_weight"' not in prompt
assert '"ai_weight"' not in prompt
assert "message-1" in json.loads(requests[0].content)["messages"][1]["content"]
assert "EVIDENCE" in json.loads(requests[1].content)["messages"][1]["content"]
@pytest.mark.asyncio
async def test_aihub_errors_have_stable_codes_and_do_not_expose_response_or_request_secret():
def handler(_request: httpx.Request) -> httpx.Response:
return httpx.Response(429, text=f"private response mentions {VKEY}")
client = AiHubClient(
"https://aihub.test", httpx.AsyncClient(transport=httpx.MockTransport(handler))
)
try:
with pytest.raises(AiHubError) as error:
await client.extract_evidence(model="openai/gpt-4o-mini", snapshot={}, vkey=VKEY)
finally:
await client.aclose()
assert error.value.error_code == "aihub_http_error"
assert VKEY not in str(error.value)
assert "private response" not in str(error.value)
@pytest.mark.asyncio
async def test_aihub_rejects_response_that_echoes_request_vkey():
client = AiHubClient(
"https://aihub.test",
httpx.AsyncClient(
transport=httpx.MockTransport(
lambda _request: completion(
json.dumps({"items": [{"signal": "s", "detail": VKEY, "source": "m"}]})
)
)
),
)
try:
with pytest.raises(AiHubError) as error:
await client.extract_evidence(model="openai/gpt-4o-mini", snapshot={}, vkey=VKEY)
finally:
await client.aclose()
assert error.value.error_code == "aihub_invalid_response"
assert VKEY not in str(error.value)

132
tests/test_api_surface.py Normal file
View File

@ -0,0 +1,132 @@
from types import SimpleNamespace
from typing import cast
from uuid import uuid4
import pytest
from pydantic import ValidationError
from ai_platform.bootstrap.app import create_app
from ai_platform.infrastructure.config.settings import Settings
from ai_platform.infrastructure.persistence.models import ScoringRun
from ai_platform.presentation.http.routes import to_detail_json
from ai_platform.presentation.http.run_responses import RunDetail, RunMetrics
def test_run_detail_and_summary_responses_are_documented():
openapi = create_app(Settings()).openapi()
paths = openapi["paths"]
schemas = openapi["components"]["schemas"]
assert paths["/v1/lead-scoring/runs"]["post"]["responses"]["202"]["content"][
"application/json"
]["schema"] == {"$ref": "#/components/schemas/RunSummary"}
assert paths["/v1/lead-scoring/runs"]["post"]["responses"]["200"]["content"][
"application/json"
]["schema"] == {"$ref": "#/components/schemas/RunSummary"}
assert paths["/v1/lead-scoring/runs/{run_id}"]["get"]["responses"]["200"]["content"][
"application/json"
]["schema"] == {"$ref": "#/components/schemas/RunDetail"}
assert paths["/v1/admin/runs"]["get"]["responses"]["200"]["content"]["application/json"][
"schema"
] == {"$ref": "#/components/schemas/RunList"}
assert paths["/v1/admin/runs/{run_id}"]["get"]["responses"]["200"]["content"][
"application/json"
]["schema"] == {"$ref": "#/components/schemas/RunDetail"}
assert "snapshot" not in schemas["RunSummary"]["properties"]
assert "metrics" not in schemas["RunSummary"]["properties"]
assert "snapshot" in schemas["RunDetail"]["properties"]
assert "metrics" in schemas["RunDetail"]["properties"]
assert "ai_evidence" in schemas["RunDetail"]["properties"]
assert "profile_field_count" in schemas["RunMetrics"]["properties"]
assert "conversation_turn_count" in schemas["RunMetrics"]["properties"]
assert "contact_started_conversation_count" in schemas["RunMetrics"]["properties"]
assert "contact_message_recency_hours" in schemas["RunMetrics"]["properties"]
assert "contact_message_recency_hours" in schemas["RunMetrics"]["required"]
assert schemas["RunMetrics"]["properties"]["customer_initiated_return"]["type"] == "boolean"
assert (
"original snapshot position"
in schemas["RunMetrics"]["properties"]["customer_initiated_return"]["description"]
)
assert schemas["RunDetail"]["properties"]["ai_evidence"]["anyOf"][0]["maxItems"] == 20
assert schemas["RunList"]["properties"]["runs"]["items"] == {
"$ref": "#/components/schemas/RunSummary"
}
def test_run_detail_serializes_new_metrics_and_requires_boolean_values():
snapshot = {
"captured_at": "2025-09-25T10:00:00Z",
"lead_profile": {"ems_lead_id": "lead-1"},
"conversations": [
{"conversation_id": "empty", "messages": []},
{
"conversation_id": "first",
"messages": [
{
"message_id": "m1",
"sender_type": "agent",
"content": "Hello",
"sent_at": "2025-09-25T08:00:00Z",
}
],
},
{
"conversation_id": "second",
"messages": [
{
"message_id": "m2",
"sender_type": "contact",
"content": "Thanks",
"sent_at": "2025-09-25T09:00:00Z",
}
],
},
],
}
row = SimpleNamespace(
id=str(uuid4()),
request_id=str(uuid4()),
tenant_id="tenant-1",
status="completed",
profile_version="profile-v1",
rule_score=80,
ai_score=None,
final_score=80,
degraded=False,
result_mode="rules_only",
error_code=None,
breakdown=[],
created_at=None,
completed_at=None,
request={"snapshot": snapshot},
ai_confidence=None,
ai_reasoning=None,
ai_evidence=None,
)
detail = RunDetail.model_validate(to_detail_json(cast(ScoringRun, row)))
assert detail.metrics.conversation_count == 3
assert detail.metrics.contact_message_recency_days == 0.04
assert detail.metrics.contact_message_recency_hours == 1.0
assert detail.metrics.contact_started_conversation_count == 1
assert detail.metrics.customer_initiated_return is True
with pytest.raises(ValidationError):
RunMetrics.model_validate({**detail.metrics.model_dump(), "customer_initiated_return": 1})
def test_required_routes_are_registered():
app = create_app(Settings())
paths = set(app.openapi()["paths"])
assert {
"/v1/admin/login",
"/v1/admin/logout",
"/v1/admin/me",
"/v1/admin/ai-profiles",
"/v1/admin/runs",
"/v1/admin/runs/{run_id}",
"/v1/lead-scoring/runs",
"/v1/lead-scoring/runs/{run_id}",
"/v1/platform/tenant-keys",
} <= paths

View File

@ -0,0 +1,185 @@
from copy import deepcopy
from ai_platform.lead_scoring.domain.scoring import (
Rule,
ScoringConfiguration,
derive_metrics,
score_rules,
)
def test_profile_and_conversation_metrics_are_available_to_caller_rules():
snapshot = {
"captured_at": "2025-09-25T10:00:00Z",
"lead_profile": {
"name": "Example",
"email": "example@example.invalid",
"custom_fields": {"region": "north"},
},
"conversations": [
{
"messages": [
{"sender_type": "contact", "sent_at": "2025-09-24T10:00:00Z"},
{"sender_type": "agent", "sent_at": "2025-09-25T09:00:00Z"},
]
},
{"messages": [{"sender_type": "bot", "sent_at": "2025-09-25T09:30:00Z"}]},
],
}
before = deepcopy(snapshot)
policy = ScoringConfiguration(
version="omni-8",
rules=(
Rule("region", "custom_fields.region", "eq", "north", 10),
Rule("profile-fields", "metrics.profile_field_count", "eq", 2, 1),
Rule("conversations", "metrics.conversation_count", "eq", 2, 20),
Rule("messages", "metrics.message_count", "eq", 3, 20),
Rule("contacts", "metrics.contact_message_count", "eq", 1, 20),
Rule("legacy-customer-messages", "metrics.customer_message_count", "eq", 1, 1),
Rule("turns", "metrics.conversation_turn_count", "eq", 3, 1),
Rule("contact-started", "metrics.contact_started_conversation_count", "eq", 1, 1),
Rule("recency", "metrics.contact_message_recency_days", "eq", 1.0, 20),
),
rule_weight=1.0,
ai_weight=0.0,
)
score, breakdown = score_rules(snapshot, policy)
assert score == 94
assert all(item["matched"] for item in breakdown)
assert snapshot == before
assert derive_metrics(snapshot)["customer_initiated_return"] is False
def test_turn_and_contact_start_metrics_use_message_timestamps_not_array_order():
conversations = [
{
"messages": [
{"sender_type": "agent", "sent_at": "2025-09-25T11:00:00Z"},
{"sender_type": "contact", "sent_at": "2025-09-25T09:00:00Z"},
{"sender_type": "agent", "sent_at": "2025-09-25T10:00:00Z"},
]
},
{
"messages": [
{"sender_type": "contact", "sent_at": "2025-09-25T11:00:00Z"},
{"sender_type": "agent", "sent_at": "2025-09-25T10:00:00Z"},
]
},
]
snapshot = {"conversations": conversations}
metrics = derive_metrics(snapshot)
for conversation in conversations:
conversation["messages"].reverse()
assert metrics["conversation_turn_count"] == 4
assert metrics["contact_started_conversation_count"] == 1
assert metrics["customer_message_count"] == metrics["contact_message_count"] == 2
assert derive_metrics(snapshot) == metrics
def test_contact_recency_hours_uses_newest_valid_message_and_rounds_to_two_decimals():
metrics = derive_metrics(
{
"captured_at": "2025-09-25T10:00:00Z",
"conversations": [
{
"messages": [
{"sender_type": "contact", "sent_at": "not-a-timestamp"},
{"sender_type": "contact", "sent_at": "2025-09-25T08:46:00Z"},
]
}
],
}
)
assert metrics["contact_message_recency_hours"] == 1.23
assert metrics["contact_message_recency_days"] == 0.05
assert metrics["contact_message_count"] == 2
def test_contact_recency_hours_clamps_future_contact_timestamp_to_zero():
metrics = derive_metrics(
{
"captured_at": "2025-09-25T10:00:00Z",
"conversations": [
{"messages": [{"sender_type": "contact", "sent_at": "2025-09-25T11:00:00Z"}]}
],
}
)
assert metrics["contact_message_recency_hours"] == 0.0
assert metrics["contact_message_recency_days"] == 0.0
def test_empty_conversations_and_no_contact_messages_have_no_recency_or_return():
metrics = derive_metrics(
{
"captured_at": "2025-09-25T10:00:00Z",
"conversations": [
{"messages": []},
{"messages": [{"sender_type": "agent", "sent_at": "2025-09-25T09:00:00Z"}]},
],
}
)
assert metrics["conversation_count"] == 2
assert metrics["contact_started_conversation_count"] == 0
assert metrics["contact_message_count"] == 0
assert metrics["contact_message_recency_hours"] is None
assert metrics["contact_message_recency_days"] is None
assert metrics["customer_initiated_return"] is False
def test_customer_initiated_return_uses_chronology_not_array_order_and_skips_empty_conversations():
agent_start = {"messages": [{"sender_type": "agent", "sent_at": "2025-09-25T08:00:00Z"}]}
contact_middle = {"messages": [{"sender_type": "contact", "sent_at": "2025-09-25T09:00:00Z"}]}
contact_latest = {"messages": [{"sender_type": "contact", "sent_at": "2025-09-25T10:00:00Z"}]}
empty = {"messages": []}
first = derive_metrics({"conversations": [contact_latest, empty, agent_start, contact_middle]})
shuffled = derive_metrics(
{"conversations": [contact_middle, contact_latest, agent_start, empty]}
)
assert first["customer_initiated_return"] is True
assert shuffled["customer_initiated_return"] is True
assert first["contact_started_conversation_count"] == 2
assert shuffled["contact_started_conversation_count"] == 2
def test_customer_initiated_return_breaks_equal_start_times_by_snapshot_position():
agent_start = {"messages": [{"sender_type": "agent", "sent_at": "2025-09-25T08:00:00Z"}]}
contact_start = {"messages": [{"sender_type": "contact", "sent_at": "2025-09-25T08:00:00Z"}]}
assert (
derive_metrics({"conversations": [agent_start, contact_start]})["customer_initiated_return"]
is True
)
assert (
derive_metrics({"conversations": [contact_start, agent_start]})["customer_initiated_return"]
is False
)
def test_boolean_metric_does_not_match_integer_rule_value():
config = ScoringConfiguration(
version="omni-15",
rules=(Rule("boolean-as-number", "metrics.customer_initiated_return", "eq", 1, 10),),
rule_weight=1.0,
ai_weight=0.0,
)
snapshot = {
"conversations": [
{"messages": [{"sender_type": "agent", "sent_at": "2025-09-25T08:00:00Z"}]},
{"messages": [{"sender_type": "contact", "sent_at": "2025-09-25T09:00:00Z"}]},
]
}
score, breakdown = score_rules(snapshot, config)
assert derive_metrics(snapshot)["customer_initiated_return"] is True
assert score == 0
assert breakdown[0]["matched"] is False

View File

@ -0,0 +1,144 @@
from ai_platform.lead_scoring.domain.scoring import Rule, ScoringConfiguration, score_rules
def test_caller_supplied_rules_are_deterministic_and_bounded():
snapshot = {"lead_profile": {"custom_fields": {"region": "north", "age": 22}}}
policy = ScoringConfiguration(
version="omni-7",
rules=(
Rule("region", "custom_fields.region", "eq", "north", 65),
Rule("age", "custom_fields.age", "gte", 18, 20),
Rule("miss", "custom_fields.age", "gt", 30, 40),
),
rule_weight=1.0,
ai_weight=0.0,
)
first = score_rules(snapshot, policy)
assert first == score_rules(snapshot, policy)
assert first[0] == 85
assert [item["rule_id"] for item in first[1]] == ["region", "age", "miss"]
def test_contact_recency_uses_latest_instant_across_timezones_and_message_order():
base = {
"captured_at": "2025-09-25T10:00:00Z",
"lead_profile": {"custom_fields": {}},
"conversations": [
{
"messages": [
{"sender_type": "contact", "sent_at": "2025-09-25T08:00:00+02:00"},
{"sender_type": "contact", "sent_at": "2025-09-24T09:00:00Z"},
{"sender_type": "agent", "sent_at": "2025-09-25T09:59:00Z"},
]
}
],
}
policy = ScoringConfiguration(
version="omni-9",
rules=(Rule("recency", "metrics.contact_message_recency_days", "eq", 0.17, 1),),
rule_weight=1.0,
ai_weight=0.0,
)
assert score_rules(base, policy)[1][0]["matched"]
base["conversations"][0]["messages"].reverse()
assert score_rules(base, policy)[1][0]["matched"]
def test_contact_recency_is_none_without_contact_messages():
snapshot = {
"captured_at": "2025-09-25T10:00:00Z",
"lead_profile": {"custom_fields": {}},
"conversations": [
{"messages": [{"sender_type": "agent", "sent_at": "2025-09-25T09:00:00Z"}]}
],
}
policy = ScoringConfiguration(
version="omni-10",
rules=(Rule("missing", "metrics.contact_message_recency_days", "exists", None, 1),),
rule_weight=1.0,
ai_weight=0.0,
)
assert score_rules(snapshot, policy)[1][0]["matched"] is False
def test_missing_customer_fields_do_not_match_neq_or_null_and_unknown_operators_fail():
snapshot = {"lead_profile": {"custom_fields": {}}}
policy = ScoringConfiguration(
version="omni-11",
rules=(
Rule("neq-missing", "custom_fields.region", "ne", "north", 10),
Rule("null-missing", "custom_fields.region", "eq", None, 10),
Rule("unknown", "custom_fields.region", "unsupported", "north", 10),
),
rule_weight=1.0,
ai_weight=0.0,
)
score, breakdown = score_rules(snapshot, policy)
assert score == 0
assert all(not item["matched"] and item["points"] == 0 for item in breakdown)
def test_rule_comparisons_do_not_coerce_boolean_or_string_numbers():
snapshot = {
"lead_profile": {
"custom_fields": {
"flag": True,
"age_text": "18",
"tags": [True],
"nested": {"region": "north"},
}
}
}
policy = ScoringConfiguration(
version="omni-12",
rules=(
Rule("bool-eq-int", "custom_fields.flag", "eq", 1, 20),
Rule("bool-ne-int", "custom_fields.flag", "ne", 1, 20),
Rule("bool-gte", "custom_fields.flag", "gte", 1, 20),
Rule("string-gte-number", "custom_fields.age_text", "gte", 18, 20),
Rule("contains-int-in-bool-list", "custom_fields.tags", "contains", 1, 20),
Rule("nested-field", "custom_fields.nested.region", "eq", "north", 20),
Rule("int-eq-float", "metrics.conversation_count", "eq", 0.0, 20),
),
rule_weight=1.0,
ai_weight=0.0,
)
score, breakdown = score_rules(snapshot, policy)
assert score == 40
assert [item["matched"] for item in breakdown] == [
False,
False,
False,
False,
False,
True,
True,
]
def test_rule_points_are_bounded_after_all_customer_rules():
snapshot = {"lead_profile": {"custom_fields": {"match": True}}}
positive = ScoringConfiguration(
version="omni-13",
rules=(
Rule("a", "custom_fields.match", "eq", True, 100),
Rule("b", "custom_fields.match", "eq", True, 100),
),
rule_weight=1.0,
ai_weight=0.0,
)
negative = ScoringConfiguration(
version="omni-14",
rules=(Rule("a", "custom_fields.match", "eq", True, -100),),
rule_weight=1.0,
ai_weight=0.0,
)
assert score_rules(snapshot, positive)[0] == 100
assert score_rules(snapshot, negative)[0] == 0

View File

@ -0,0 +1,197 @@
import asyncio
import json
from copy import deepcopy
from pathlib import Path
from urllib.parse import urlsplit
from uuid import uuid4
import pytest
from fastapi.testclient import TestClient
from pwdlib import PasswordHash
from sqlalchemy import delete
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 (
AdminSession,
AdminUser,
ScoringRun,
TenantKey,
)
from ai_platform.presentation.http.schemas import RunRequest
FIXTURE = Path(__file__).parent / "fixtures" / "mock_omni_run.json"
def require_test_database(settings: Settings) -> None:
database_name = urlsplit(settings.database_url).path.rstrip("/").rsplit("/", 1)[-1]
if database_name != "ai_platform_test":
raise RuntimeError("HTTP integration tests require a database ending in /ai_platform_test")
def wait_for_terminal(client: TestClient, run_id: str, headers: dict[str, str]) -> dict:
for _ in range(100):
response = client.get(f"/v1/lead-scoring/runs/{run_id}", headers=headers)
assert response.status_code == 200
result = response.json()
if result["status"] in {"completed", "failed"}:
return result
import time
time.sleep(0.02)
pytest.fail("Scoring run did not reach a terminal state")
def test_postgres_http_mock_lead_scoring_flow():
settings = Settings.load()
require_test_database(settings)
assert settings.tenant_provisioning_secret is not None, (
"HTTP integration tests require AI_PLATFORM_TENANT_PROVISIONING_SECRET"
)
payload = json.loads(FIXTURE.read_text())
suffix = uuid4().hex
tenant_a, tenant_b = f"mock-flow-a-{suffix}", f"mock-flow-b-{suffix}"
admin_email = f"mock-flow-{suffix}@example.invalid"
admin_password = "mock-admin-password"
app = create_app(settings)
session_factory = make_session_factory(settings.database_url)
async def create_test_admin() -> None:
async with session_factory() as session:
session.add(
AdminUser(
email=admin_email,
password_hash=PasswordHash.recommended().hash(admin_password),
)
)
await session.commit()
await session_factory.kw["bind"].dispose()
asyncio.run(create_test_admin())
try:
with TestClient(app) as client:
provision_headers = {
"Authorization": f"Bearer {settings.tenant_provisioning_secret.get_secret_value()}"
}
tenant_keys: dict[str, str] = {}
for tenant in (tenant_a, tenant_b):
provisioned = client.post(
"/v1/platform/tenant-keys",
headers=provision_headers,
json={"tenant_id": tenant},
)
assert provisioned.status_code == 201
tenant_keys[tenant] = provisioned.json()["tenant_key"]
auth_a = {"Authorization": f"Bearer {tenant_keys[tenant_a]}"}
auth_b = {"Authorization": f"Bearer {tenant_keys[tenant_b]}"}
created = client.post("/v1/lead-scoring/runs", headers=auth_a, json=payload)
assert created.status_code == 202
assert "snapshot" not in created.json()
run_id = created.json()["run_id"]
rules_only = wait_for_terminal(client, run_id, auth_a)
assert rules_only["status"] == "completed"
assert rules_only["degraded"] is False
assert rules_only["ai_score"] is None
assert rules_only["rule_score"] is not None
assert rules_only["final_score"] == rules_only["rule_score"]
persisted = client.get(f"/v1/lead-scoring/runs/{run_id}", headers=auth_a)
assert persisted.status_code == 200
assert persisted.json() == rules_only
expected_snapshot = RunRequest.model_validate(payload).model_dump(mode="json")[
"snapshot"
]
assert persisted.json()["snapshot"] == expected_snapshot
assert [
conversation["conversation_id"]
for conversation in persisted.json()["snapshot"]["conversations"]
] == ["mock-conversation-001", "mock-conversation-002"]
assert [
message["message_id"]
for message in persisted.json()["snapshot"]["conversations"][0]["messages"]
] == ["mock-message-001", "mock-message-002", "mock-message-003"]
assert client.get(f"/v1/lead-scoring/runs/{run_id}").status_code == 401
assert client.get(f"/v1/admin/runs/{run_id}").status_code == 401
admin_login = client.post(
"/v1/admin/login", json={"email": admin_email, "password": admin_password}
)
assert admin_login.status_code == 200
admin_list = client.get("/v1/admin/runs")
assert admin_list.status_code == 200
admin_summaries = admin_list.json()["runs"]
assert all("snapshot" not in summary for summary in admin_summaries)
admin_detail = client.get(f"/v1/admin/runs/{run_id}")
assert admin_detail.status_code == 200
assert admin_detail.json()["snapshot"] == expected_snapshot
assert admin_detail.json()["metrics"] == persisted.json()["metrics"]
assert persisted.json()["metrics"]["contact_message_recency_hours"] == 24.75
assert persisted.json()["metrics"]["customer_initiated_return"] is False
replay = client.post("/v1/lead-scoring/runs", headers=auth_a, json=payload)
assert replay.status_code == 200
assert replay.json()["run_id"] == run_id
assert "snapshot" not in replay.json()
changed = deepcopy(payload)
changed["scoring_config"]["rules"][0]["points"] = 24
conflict = client.post("/v1/lead-scoring/runs", headers=auth_a, json=changed)
assert conflict.status_code == 409
isolated = client.get(f"/v1/lead-scoring/runs/{run_id}", headers=auth_b)
assert isolated.status_code == 404
invalid = deepcopy(payload)
invalid["request_id"] = str(uuid4())
invalid["snapshot"]["conversations"][0]["messages"][0]["sent_at"] = (
"2025-12-31T10:00:00"
)
rejected = client.post("/v1/lead-scoring/runs", headers=auth_a, json=invalid)
assert rejected.status_code == 422
ai_requested = deepcopy(payload)
ai_requested["request_id"] = str(uuid4())
ai_requested["scoring_config"]["rule_weight"] = 0.6
ai_requested["scoring_config"]["ai_weight"] = 0.4
fallback_response = client.post(
"/v1/lead-scoring/runs",
headers={**auth_a, "X-Tenant-VKey": "mock-vkey-not-a-credential"},
json=ai_requested,
)
assert fallback_response.status_code == 202
fallback = wait_for_terminal(client, fallback_response.json()["run_id"], auth_a)
assert fallback["status"] == "completed"
assert fallback["degraded"] is True
assert fallback["ai_score"] is None
assert fallback["rule_score"] is not None
assert fallback["final_score"] == fallback["rule_score"]
assert fallback["snapshot"] == expected_snapshot
assert "mock-vkey-not-a-credential" not in json.dumps(fallback)
fallback_admin_detail = client.get(
f"/v1/admin/runs/{fallback_response.json()['run_id']}"
)
assert fallback_admin_detail.status_code == 200
assert fallback_admin_detail.json()["snapshot"] == expected_snapshot
assert "mock-vkey-not-a-credential" not in fallback_admin_detail.text
finally:
async def cleanup() -> None:
async with session_factory() as session:
await session.execute(
delete(ScoringRun).where(ScoringRun.tenant_id.in_([tenant_a, tenant_b]))
)
await session.execute(
delete(AdminSession).where(AdminSession.admin_email == admin_email)
)
await session.execute(delete(AdminUser).where(AdminUser.email == admin_email))
await session.execute(
delete(TenantKey).where(TenantKey.tenant_id.in_([tenant_a, tenant_b]))
)
await session.commit()
await session_factory.kw["bind"].dispose()
asyncio.run(cleanup())

View File

@ -0,0 +1,91 @@
from copy import deepcopy
import pytest
from pydantic import ValidationError
from ai_platform.presentation.http.schemas import RunRequest
@pytest.mark.parametrize(
("field", "operator", "value"),
[
("custom_fields.interest.region", "eq", "north"),
("lead_profile.email", "exists", None),
("lead_profile.custom_fields.course", "contains", "IELTS"),
("metrics.profile_field_count", "gte", 1),
("metrics.conversation_count", "gte", 1),
("metrics.conversation_turn_count", "gte", 3),
("metrics.contact_started_conversation_count", "eq", 1),
("metrics.contact_message_recency_hours", "gte", 1.5),
("metrics.customer_initiated_return", "eq", True),
("metrics.customer_initiated_return", "ne", False),
("metrics.customer_message_count", "exists", None),
],
)
def test_rules_accept_supported_customer_field_paths(valid_run_payload, field, operator, value):
data = deepcopy(valid_run_payload)
data["scoring_config"]["rules"][0].update(field=field, operator=operator, value=value)
request = RunRequest.model_validate(data)
assert request.scoring_config.rules[0].field == field
@pytest.mark.parametrize(
("field", "operator", "value", "points"),
[
("snapshot.conversations", "exists", None, 1),
("custom_fields..region", "eq", "north", 1),
("metrics.unknown_metric", "eq", 1, 1),
("metrics.conversation_turn_count", "eq", 1.0, 1),
("metrics.contact_started_conversation_count", "contains", 1, 1),
("metrics.customer_message_count", "gt", True, 1),
("metrics.contact_message_recency_days", "contains", 1, 1),
("metrics.contact_message_recency_hours", "contains", 1, 1),
("metrics.customer_initiated_return", "eq", 1, 1),
("metrics.customer_initiated_return", "ne", 0, 1),
("metrics.customer_initiated_return", "gt", True, 1),
("metrics.customer_initiated_return", "contains", True, 1),
("metrics.customer_initiated_return", "exists", None, 1),
("custom_fields.age", "gte", "18", 1),
("custom_fields.age", "gt", True, 1),
("custom_fields.age", "eq", None, 1),
("custom_fields.age", "exists", "not-null", 1),
("custom_fields.region", "eq", {"value": "north"}, 1),
("custom_fields.tags", "contains", ["nested"], 1),
("custom_fields.age", "eq", 18, True),
("custom_fields.age", "eq", 18, 1.5),
],
)
def test_rules_reject_invalid_paths_values_and_points(
valid_run_payload, field, operator, value, points
):
data = deepcopy(valid_run_payload)
data["scoring_config"]["rules"][0].update(
field=field, operator=operator, value=value, points=points
)
with pytest.raises(ValidationError):
RunRequest.model_validate(data)
def test_rule_values_enforce_size_limits(valid_run_payload):
data = deepcopy(valid_run_payload)
data["scoring_config"]["rules"][0]["value"] = "x" * 1_001
with pytest.raises(ValidationError, match="1000 characters"):
RunRequest.model_validate(data)
data["scoring_config"]["rules"][0].update(operator="contains", value=["x"] * 101)
with pytest.raises(ValidationError, match="100 items"):
RunRequest.model_validate(data)
def test_scoring_weights_reject_boolean_and_string_types(valid_run_payload):
for rule_weight, ai_weight in ((True, 0), (0.5, "0.5")):
data = deepcopy(valid_run_payload)
data["scoring_config"]["rule_weight"] = rule_weight
data["scoring_config"]["ai_weight"] = ai_weight
with pytest.raises(ValidationError):
RunRequest.model_validate(data)

View File

@ -0,0 +1,20 @@
from copy import deepcopy
import pytest
from pydantic import ValidationError
from ai_platform.presentation.http.schemas import RunRequest
def test_run_request_contains_caller_rules_and_weights(valid_run_payload):
request = RunRequest.model_validate(valid_run_payload)
assert request.scoring_config.rules[0].points == 25
assert request.scoring_config.ai_weight == 0
def test_run_request_requires_weights_to_sum_to_one(valid_run_payload):
data = deepcopy(valid_run_payload)
data["scoring_config"].update(rule_weight=0.5, ai_weight=0.4)
with pytest.raises(ValidationError):
RunRequest.model_validate(data)

View File

@ -0,0 +1,16 @@
import hashlib
from ai_platform.infrastructure.security import digest
from ai_platform.presentation.http.routes import canonical
def test_canonical_body_fingerprint_ignores_mapping_key_order():
left = hashlib.sha256(canonical({"a": 1, "b": 2})).digest()
right = hashlib.sha256(canonical({"b": 2, "a": 1})).digest()
assert left == right
def test_secret_digest_is_one_way_verifier():
raw = "tnt_example"
assert digest(raw) != raw
assert digest(raw) == digest(raw)

179
tests/test_seed_demo.py Normal file
View File

@ -0,0 +1,179 @@
from importlib.util import module_from_spec, spec_from_file_location
from pathlib import Path
from typing import Any
import pytest
from pydantic import SecretStr
from ai_platform.infrastructure.config.settings import Settings
from ai_platform.presentation.http.schemas import RunRequest
SEED_SCRIPT = Path(__file__).resolve().parents[1] / "scripts" / "seed_demo.py"
SEED_SPEC = spec_from_file_location("seed_demo_script", SEED_SCRIPT)
assert SEED_SPEC is not None and SEED_SPEC.loader is not None
seed_demo = module_from_spec(SEED_SPEC)
SEED_SPEC.loader.exec_module(seed_demo)
class FakeResponse:
def __init__(self, status_code: int, body: dict[str, Any]):
self.status_code = status_code
self._body = body
def json(self) -> dict[str, Any]:
return self._body
class FakeClient:
def __init__(self, responses: list[FakeResponse]):
self.responses = responses
self.calls: list[tuple[str, str, dict[str, Any]]] = []
def post(self, path: str, **kwargs: Any) -> FakeResponse:
self.calls.append(("POST", path, kwargs))
return self.responses.pop(0)
def get(self, path: str, **kwargs: Any) -> FakeResponse:
self.calls.append(("GET", path, kwargs))
return self.responses.pop(0)
def settings() -> Settings:
return Settings(
environment="development",
database_url="postgresql+asyncpg://local@127.0.0.1:55432/ai_platform",
tenant_provisioning_secret=SecretStr("provision-secret"),
)
def complete_run_response(run_id: str, payload: dict[str, Any]) -> FakeResponse:
request = RunRequest.model_validate(payload).model_dump(mode="json")
return FakeResponse(
200,
{
"run_id": run_id,
"status": "completed",
"rule_score": 80,
"ai_score": None,
"final_score": 80,
"degraded": False,
"request": {"snapshot": request["snapshot"]},
},
)
def test_fixture_has_realistic_conversations_and_metrics_rules():
payload = seed_demo.load_payload()
assert len(payload["snapshot"]["conversations"]) == 2
assert sum(len(c["messages"]) for c in payload["snapshot"]["conversations"]) == 5
rules = payload["scoring_config"]["rules"]
assert {rule["field"] for rule in rules} >= {
"metrics.conversation_count",
"metrics.contact_message_count",
}
assert sum(rule["points"] for rule in rules) == 80
assert payload["scoring_config"]["ai_weight"] == 0
def test_seed_creates_one_run_and_reads_persisted_conversation_snapshot():
payload = seed_demo.load_payload()
run_id = "123e4567-e89b-12d3-a456-426614174000"
client = FakeClient(
[
FakeResponse(201, {"tenant_key": "tenant-secret"}),
FakeResponse(202, {"run_id": run_id}),
complete_run_response(run_id, payload),
]
)
actual_id, result = seed_demo.seed_demo(client, settings())
assert actual_id == run_id
assert result["status"] == "completed"
assert result["ai_score"] is None
assert [call[1] for call in client.calls] == [
"/v1/platform/tenant-keys",
"/v1/lead-scoring/runs",
f"/v1/lead-scoring/runs/{run_id}",
]
assert client.calls[1][2]["json"] == payload
assert client.calls[2][2]["headers"]["Authorization"] == "Bearer tenant-secret"
def test_existing_tenant_without_key_stops_without_submitting_a_run():
client = FakeClient([FakeResponse(409, {"detail": "Tenant key already provisioned"})])
with pytest.raises(SystemExit, match="one-time key is unavailable"):
seed_demo.seed_demo(client, settings())
assert [call[1] for call in client.calls] == ["/v1/platform/tenant-keys"]
def test_existing_tenant_key_skips_provisioning_and_accepts_idempotent_replay():
payload = seed_demo.load_payload()
run_id = "123e4567-e89b-12d3-a456-426614174000"
client = FakeClient(
[
FakeResponse(200, {"run_id": run_id}),
complete_run_response(run_id, payload),
]
)
actual_id, _result = seed_demo.seed_demo(client, settings(), "existing-tenant-secret")
assert actual_id == run_id
assert [call[1] for call in client.calls] == [
"/v1/lead-scoring/runs",
f"/v1/lead-scoring/runs/{run_id}",
]
assert client.calls[0][2]["headers"]["Authorization"] == "Bearer existing-tenant-secret"
def test_seed_rejects_readback_without_persisted_snapshot():
run_id = "123e4567-e89b-12d3-a456-426614174000"
client = FakeClient(
[
FakeResponse(202, {"run_id": run_id}),
FakeResponse(
200,
{
"run_id": run_id,
"status": "completed",
"ai_score": None,
"degraded": False,
},
),
]
)
with pytest.raises(SystemExit, match="did not return the persisted conversation snapshot"):
seed_demo.seed_demo(client, settings(), "existing-tenant-secret")
def test_seed_refuses_non_development_or_nonlocal_database_names():
with pytest.raises(SystemExit, match="must be development"):
seed_demo.validate_local_target(
Settings(environment="production", tenant_provisioning_secret=SecretStr("secret"))
)
with pytest.raises(SystemExit, match="API target must use the local"):
seed_demo.validate_local_target(
Settings(
environment="development",
database_url="postgresql+asyncpg://local@127.0.0.1:55432/ai_platform_test",
)
)
with pytest.raises(SystemExit, match="API target must use the local"):
seed_demo.validate_local_target(
Settings(
environment="development",
database_url="postgresql+asyncpg://local@remote.example:5432/ai_platform",
)
)
with pytest.raises(SystemExit, match="API target must use the local"):
seed_demo.validate_local_target(
Settings(
environment="development",
database_url="postgresql+asyncpg://local@db:5432/customer_data",
)
)

View File

@ -0,0 +1,107 @@
import asyncio
import json
import time
from pathlib import Path
from urllib.parse import urlsplit
from uuid import uuid4
import pytest
from fastapi.testclient import TestClient
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 ScoringRun, TenantKey
from ai_platform.presentation.http.schemas import RunRequest
FIXTURE = Path(__file__).parent / "fixtures" / "mock_omni_run.json"
def isolated_test_settings() -> Settings:
settings = Settings.load()
parsed = urlsplit(settings.database_url)
database_name = parsed.path.rstrip("/").rsplit("/", 1)[-1]
if database_name != "ai_platform_test" or parsed.hostname not in {
"127.0.0.1",
"localhost",
"::1",
"postgres",
}:
pytest.skip("requires the dedicated local ai_platform_test database")
if settings.tenant_provisioning_secret is None:
pytest.skip("requires the local tenant provisioning secret")
return settings
def terminal_run(client: TestClient, run_id: str, headers: dict[str, str]) -> dict:
for _ in range(100):
response = client.get(f"/v1/lead-scoring/runs/{run_id}", headers=headers)
assert response.status_code == 200
result = response.json()
if result["status"] in {"completed", "failed"}:
return result
time.sleep(0.02)
pytest.fail("Scoring run did not reach a terminal state")
def test_api_seed_persists_and_reads_back_conversation_snapshot():
settings = isolated_test_settings()
provisioning_secret = settings.tenant_provisioning_secret
assert provisioning_secret is not None
payload = json.loads(FIXTURE.read_text())
expected_payload = RunRequest.model_validate(payload).model_dump(mode="json")
tenant_id = f"seed-persistence-{uuid4().hex}"
app = create_app(settings)
session_factory = make_session_factory(settings.database_url)
run_id: str | None = None
try:
with TestClient(app) as client:
provisioned = client.post(
"/v1/platform/tenant-keys",
headers={"Authorization": ("Bearer " + provisioning_secret.get_secret_value())},
json={"tenant_id": tenant_id},
)
assert provisioned.status_code == 201
tenant_headers = {"Authorization": f"Bearer {provisioned.json()['tenant_key']}"}
admitted = client.post("/v1/lead-scoring/runs", headers=tenant_headers, json=payload)
assert admitted.status_code == 202
run_id = admitted.json()["run_id"]
assert isinstance(run_id, str)
completed = terminal_run(client, run_id, tenant_headers)
assert completed["status"] == "completed"
assert completed["ai_score"] is None
assert completed["degraded"] is False
assert completed["rule_score"] == 80
assert completed["final_score"] == 80
readback = client.get(f"/v1/lead-scoring/runs/{run_id}", headers=tenant_headers)
assert readback.status_code == 200
assert readback.json() == completed
api_snapshot = readback.json().get("snapshot")
if api_snapshot is None:
api_snapshot = readback.json().get("request", {}).get("snapshot")
assert api_snapshot == expected_payload["snapshot"]
finally:
async def verify_and_cleanup_created_rows() -> None:
try:
async with session_factory() as session:
if run_id is not None:
row = await session.scalar(
select(ScoringRun).where(
ScoringRun.id == run_id,
ScoringRun.tenant_id == tenant_id,
)
)
assert row is not None
assert row.request["snapshot"] == expected_payload["snapshot"]
await session.execute(delete(ScoringRun).where(ScoringRun.id == run_id))
await session.execute(delete(TenantKey).where(TenantKey.tenant_id == tenant_id))
await session.commit()
finally:
await session_factory.kw["bind"].dispose()
asyncio.run(verify_and_cleanup_created_rows())

View File

@ -0,0 +1,149 @@
import json
from copy import deepcopy
import pytest
from pydantic import ValidationError
from ai_platform.presentation.http.schemas import RunRequest
def test_snapshot_preserves_known_omni_metadata_and_array_order(valid_run_payload):
request = RunRequest.model_validate(valid_run_payload)
snapshot = request.snapshot
assert snapshot.version == "1.0.0"
assert snapshot.scope == "linked_contact_history"
assert snapshot.identity_context is not None
assert (
snapshot.identity_context.model_dump() == valid_run_payload["snapshot"]["identity_context"]
)
assert snapshot.trigger_metadata is not None
assert (
snapshot.trigger_metadata.model_dump() == valid_run_payload["snapshot"]["trigger_metadata"]
)
assert snapshot.conversations[0].channel == "webchat"
assert [message.message_id for message in snapshot.conversations[0].messages] == [
"message-2",
"message-1",
]
def test_message_order_remains_significant_to_canonical_fingerprint(valid_run_payload):
original = RunRequest.model_validate(valid_run_payload).model_dump(mode="json")
reordered_payload = deepcopy(valid_run_payload)
reordered_payload["snapshot"]["conversations"][0]["messages"].reverse()
reordered = RunRequest.model_validate(reordered_payload).model_dump(mode="json")
assert json.dumps(original, sort_keys=True, separators=(",", ":")) != json.dumps(
reordered, sort_keys=True, separators=(",", ":")
)
def test_conversation_order_is_preserved(valid_run_payload):
conversations = valid_run_payload["snapshot"]["conversations"]
conversations.append(
{
"conversation_id": "conversation-1",
"messages": [
{
"message_id": "older-message",
"sender_type": "contact",
"content": "Earlier in time, later in the array.",
"sent_at": "2025-12-30T10:00:00Z",
}
],
}
)
snapshot = RunRequest.model_validate(valid_run_payload).snapshot
assert [conversation.conversation_id for conversation in snapshot.conversations] == [
"conversation-2",
"conversation-1",
]
@pytest.mark.parametrize(
("change_snapshot", "error"),
[
(
lambda snapshot: snapshot.update(
conversations=[
{"conversation_id": f"conversation-{index}", "messages": []}
for index in range(11)
]
),
"10",
),
(
lambda snapshot: snapshot["conversations"][0].update(
messages=[
{
"message_id": f"message-{index}",
"sender_type": "contact",
"content": "x",
"sent_at": "2025-12-31T10:00:00Z",
}
for index in range(51)
]
),
"50",
),
(
lambda snapshot: snapshot["conversations"][0]["messages"][0].update(
content="x" * 20_001
),
"20000",
),
(
lambda snapshot: snapshot["conversations"][0]["messages"][0].update(
sent_at="2025-12-31T10:00:00"
),
"timezone-aware",
),
(
lambda snapshot: snapshot["conversations"][0]["messages"][0].update(
sent_at="2025-09-30T12:00:00Z"
),
"90-day",
),
],
)
def test_snapshot_rejects_invalid_evidence(valid_run_payload, change_snapshot, error):
data = deepcopy(valid_run_payload)
change_snapshot(data["snapshot"])
with pytest.raises(ValidationError, match=error):
RunRequest.model_validate(data)
@pytest.mark.parametrize(
("target", "extra"),
[
("snapshot", "unsupported_snapshot_field"),
("lead_profile", "unsupported_profile_field"),
("conversation", "unsupported_conversation_field"),
("message", "unsupported_message_field"),
],
)
def test_snapshot_rejects_unmodeled_fields(valid_run_payload, target, extra):
snapshot = deepcopy(valid_run_payload)["snapshot"]
if target == "snapshot":
snapshot[extra] = "not silently discarded"
elif target == "lead_profile":
snapshot["lead_profile"][extra] = "not silently discarded"
elif target == "conversation":
snapshot["conversations"][0][extra] = "not silently discarded"
else:
snapshot["conversations"][0]["messages"][0][extra] = "not silently discarded"
with pytest.raises(ValidationError, match="Extra inputs are not permitted"):
RunRequest.model_validate({**valid_run_payload, "snapshot": snapshot})
def test_snapshot_rejects_naive_capture_timestamp(valid_run_payload):
data = deepcopy(valid_run_payload)
data["snapshot"]["captured_at"] = "2026-01-01T12:00:00"
with pytest.raises(ValidationError, match="timezone-aware"):
RunRequest.model_validate(data)

1104
uv.lock generated

File diff suppressed because it is too large Load Diff