feat: add Lead Scoring v2 backend and contract
This commit is contained in:
parent
81c3de8087
commit
5f77f68100
22
Dockerfile.backend
Normal file
22
Dockerfile.backend
Normal 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"]
|
||||
47
alembic.ini
47
alembic.ini
@ -1,49 +1,8 @@
|
||||
# A generic, single database configuration.
|
||||
|
||||
[alembic]
|
||||
# path to migration scripts
|
||||
script_location = %(here)s/alembic
|
||||
script_location = migrations
|
||||
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]
|
||||
keys = root,sqlalchemy,alembic
|
||||
|
||||
|
||||
1
contracts/lead-scoring/v2/contract.json
Normal file
1
contracts/lead-scoring/v2/contract.json
Normal 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"]}
|
||||
51
contracts/lead-scoring/v2/fixtures/run-request.json
Normal file
51
contracts/lead-scoring/v2/fixtures/run-request.json
Normal 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"
|
||||
}
|
||||
@ -0,0 +1 @@
|
||||
{"tenant_id":"acme","tenant_key":"tnt_<returned-once>","note":"Provision response example only; never use a real credential in this fixture."}
|
||||
1
contracts/lead-scoring/v2/openapi.json
Normal file
1
contracts/lead-scoring/v2/openapi.json
Normal file
File diff suppressed because one or more lines are too long
48
migrations/env.py
Normal file
48
migrations/env.py
Normal 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())
|
||||
70
migrations/versions/0001_platform_v2.py
Normal file
70
migrations/versions/0001_platform_v2.py
Normal 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")
|
||||
21
migrations/versions/0002_ai_result_fields.py
Normal file
21
migrations/versions/0002_ai_result_fields.py
Normal 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")
|
||||
15
migrations/versions/0003_ai_evidence.py
Normal file
15
migrations/versions/0003_ai_evidence.py
Normal 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")
|
||||
@ -1,41 +1,38 @@
|
||||
[project]
|
||||
name = "lead-scoring"
|
||||
version = "0.1.0"
|
||||
description = "Lead Scoring service for AI Platform"
|
||||
readme = "README.md"
|
||||
name = "dotb-ai-platform"
|
||||
version = "2.0.0"
|
||||
description = "Tenant-isolated lead scoring platform"
|
||||
requires-python = ">=3.13"
|
||||
dependencies = [
|
||||
"fastapi>=0.115.0",
|
||||
"uvicorn[standard]>=0.30.0",
|
||||
"pydantic>=2.8.0",
|
||||
"pydantic-settings>=2.4.0",
|
||||
"sqlalchemy[asyncio]>=2.0.30",
|
||||
"asyncpg>=0.29.0",
|
||||
"alembic>=1.13.0",
|
||||
"httpx>=0.27.0",
|
||||
"fastapi>=0.115,<1",
|
||||
"uvicorn[standard]>=0.30,<1",
|
||||
"pydantic>=2.8,<3",
|
||||
"pydantic-settings>=2.4,<3",
|
||||
"sqlalchemy[asyncio]>=2.0.30,<3",
|
||||
"asyncpg>=0.30,<1",
|
||||
"alembic>=1.13,<2",
|
||||
"pwdlib[argon2]>=0.2,<1",
|
||||
"httpx>=0.27",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
generate-openapi = "lead_scoring.cli.openapi: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"
|
||||
ai-platform = "ai_platform.cli:main"
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["src/lead_scoring"]
|
||||
packages = ["src/ai_platform"]
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"pytest>=8.0.0",
|
||||
"pytest-asyncio>=0.23.0",
|
||||
"testcontainers[postgres]>=4.0.0",
|
||||
"ruff>=0.5.0",
|
||||
"pyright>=1.1.370",
|
||||
"ty>=0.0.74",
|
||||
"pytest>=8",
|
||||
"pytest-asyncio>=0.23",
|
||||
"ruff>=0.5",
|
||||
"pyright>=1.1.414",
|
||||
"import-linter>=2.15",
|
||||
"pre-commit==4.3.0",
|
||||
]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
@ -48,20 +45,5 @@ line-length = 100
|
||||
target-version = "py313"
|
||||
src = ["src", "tests"]
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = [
|
||||
"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"
|
||||
[tool.ruff.lint.per-file-ignores]
|
||||
"src/ai_platform/presentation/http/*.py" = ["B008"]
|
||||
|
||||
7
pyrightconfig.json
Normal file
7
pyrightconfig.json
Normal 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
155
scripts/seed_demo.py
Normal 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()
|
||||
54
src/ai_platform/bootstrap/app.py
Normal file
54
src/ai_platform/bootstrap/app.py
Normal 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
69
src/ai_platform/cli.py
Normal 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()
|
||||
199
src/ai_platform/infrastructure/aihub.py
Normal file
199
src/ai_platform/infrastructure/aihub.py
Normal 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
|
||||
60
src/ai_platform/infrastructure/config/settings.py
Normal file
60
src/ai_platform/infrastructure/config/settings.py
Normal 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()
|
||||
21
src/ai_platform/infrastructure/persistence/database.py
Normal file
21
src/ai_platform/infrastructure/persistence/database.py
Normal 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
|
||||
76
src/ai_platform/infrastructure/persistence/models.py
Normal file
76
src/ai_platform/infrastructure/persistence/models.py
Normal 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))
|
||||
194
src/ai_platform/infrastructure/scoring.py
Normal file
194
src/ai_platform/infrastructure/scoring.py
Normal 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()
|
||||
5
src/ai_platform/infrastructure/security.py
Normal file
5
src/ai_platform/infrastructure/security.py
Normal file
@ -0,0 +1,5 @@
|
||||
import hashlib
|
||||
|
||||
|
||||
def digest(value: str) -> str:
|
||||
return hashlib.sha256(value.encode()).hexdigest()
|
||||
14
src/ai_platform/lead_scoring/application/scoring.py
Normal file
14
src/ai_platform/lead_scoring/application/scoring.py
Normal 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)
|
||||
202
src/ai_platform/lead_scoring/domain/scoring.py
Normal file
202
src/ai_platform/lead_scoring/domain/scoring.py
Normal 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
3
src/ai_platform/main.py
Normal file
@ -0,0 +1,3 @@
|
||||
from ai_platform.bootstrap.app import create_app
|
||||
|
||||
app = create_app()
|
||||
156
src/ai_platform/presentation/http/admin_routes.py
Normal file
156
src/ai_platform/presentation/http/admin_routes.py
Normal 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,
|
||||
}
|
||||
6
src/ai_platform/presentation/http/dependencies.py
Normal file
6
src/ai_platform/presentation/http/dependencies.py
Normal 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
|
||||
199
src/ai_platform/presentation/http/routes.py
Normal file
199
src/ai_platform/presentation/http/routes.py
Normal 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)
|
||||
128
src/ai_platform/presentation/http/rule_schemas.py
Normal file
128
src/ai_platform/presentation/http/rule_schemas.py
Normal 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
|
||||
96
src/ai_platform/presentation/http/run_responses.py
Normal file
96
src/ai_platform/presentation/http/run_responses.py
Normal 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]
|
||||
147
src/ai_platform/presentation/http/schemas.py
Normal file
147
src/ai_platform/presentation/http/schemas.py
Normal 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)
|
||||
@ -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
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import delete, select
|
||||
|
||||
# Attempt import of testcontainers
|
||||
postgres_container_cls = None
|
||||
try:
|
||||
try:
|
||||
from testcontainers.community.postgres import PostgresContainer as _PostgresContainer
|
||||
|
||||
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
|
||||
from ai_platform.bootstrap.app import create_app
|
||||
from ai_platform.infrastructure.config.settings import Settings
|
||||
from ai_platform.infrastructure.persistence.database import make_session_factory
|
||||
from ai_platform.infrastructure.persistence.models import AIProfile, ScoringRun, TenantKey
|
||||
from ai_platform.infrastructure.security import digest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def temp_postgres_db(postgres_container_url: str) -> str:
|
||||
"""Yields disposable PostgreSQL container database URL."""
|
||||
return postgres_container_url
|
||||
def valid_run_payload():
|
||||
return {
|
||||
"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
52
tests/fixtures/mock_omni_run.json
vendored
Normal 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
118
tests/test_aihub_adapter.py
Normal 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()
|
||||
39
tests/test_aihub_echo_safety.py
Normal file
39
tests/test_aihub_echo_safety.py
Normal 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)
|
||||
193
tests/test_aihub_scoring_integration.py
Normal file
193
tests/test_aihub_scoring_integration.py
Normal 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()
|
||||
171
tests/test_aihub_scoring_success.py
Normal file
171
tests/test_aihub_scoring_success.py
Normal 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
|
||||
43
tests/test_aihub_settings.py
Normal file
43
tests/test_aihub_settings.py
Normal 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",
|
||||
)
|
||||
119
tests/test_aihub_two_pass.py
Normal file
119
tests/test_aihub_two_pass.py
Normal 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
132
tests/test_api_surface.py
Normal 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
|
||||
185
tests/test_derived_metrics.py
Normal file
185
tests/test_derived_metrics.py
Normal 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
|
||||
144
tests/test_domain_scoring.py
Normal file
144
tests/test_domain_scoring.py
Normal 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
|
||||
197
tests/test_mock_lead_scoring_flow.py
Normal file
197
tests/test_mock_lead_scoring_flow.py
Normal 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())
|
||||
91
tests/test_rule_validation.py
Normal file
91
tests/test_rule_validation.py
Normal 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)
|
||||
20
tests/test_run_contract.py
Normal file
20
tests/test_run_contract.py
Normal 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)
|
||||
16
tests/test_security_primitives.py
Normal file
16
tests/test_security_primitives.py
Normal 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
179
tests/test_seed_demo.py
Normal 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",
|
||||
)
|
||||
)
|
||||
107
tests/test_seed_demo_persistence.py
Normal file
107
tests/test_seed_demo_persistence.py
Normal 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())
|
||||
149
tests/test_snapshot_contract.py
Normal file
149
tests/test_snapshot_contract.py
Normal 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)
|
||||
Loading…
x
Reference in New Issue
Block a user