feat: complete Lead Scoring backend and Omni notification contract

This commit is contained in:
hungtran24 2026-09-28 23:10:28 +07:00
parent 156e7af7d8
commit 9cd27caa24
79 changed files with 4766 additions and 569 deletions

View File

@ -19,10 +19,22 @@ AI_PLATFORM_ADMIN_COOKIE_SECURE=false
AI_PLATFORM_SESSION_TTL_SECONDS=28800
# Optional, required together when sending webhook to a local Omni receiver.
# AI_PLATFORM_WEBHOOK_URL=http://localhost:8080/api/v1/internal/lead-scoring/events
# AI_PLATFORM_WEBHOOK_SIGNING_SECRET=local-replace-before-use
# Optional; no global V-Key: Omni sends X-Tenant-VKey per scoring request.
# AI_PLATFORM_WEBHOOK_SIGNING_SECRET=local-replace-with-at-least-32-characters
# Lead Scoring AI profile selects AIHub by default. Omni supplies X-Tenant-VKey per AI request.
# AI_PLATFORM_AIHUB_BASE_URL=http://localhost:8080
# Alternative for an internal Lead Scoring AI profile: server-only OpenAI-compatible endpoint.
# Run the backend natively when CLIProxy binds only to 127.0.0.1 on the host.
# Optional default OpenAI-compatible URL and key for this URL only.
# Admin Web may save another URL per AI profile; this key is never forwarded to other URLs.
# AI_PLATFORM_OPENAI_BASE_URL=http://127.0.0.1:8317
# Optional on local development loopback; configure separately before using a protected URL.
# AI_PLATFORM_OPENAI_API_KEY=
# Choose provider, URL and model in Admin Web Cấu hình AI; keys never go in the browser.
# Compose web origin only; native SvelteKit dev reads apps/web/.env.
WEB_ORIGIN=http://localhost:3000
# Web header label; set test only when the backend uses an isolated test database.
WEB_ENVIRONMENT=development
# Optional server-only key for the internal fixture runner; provision a separate test tenant.
# LEAD_SCORING_FIXTURE_TENANT_KEY=
# Do not place tenant keys, V-Keys, admin sessions or backend URLs in PUBLIC_* variables.

View File

@ -1,5 +1,6 @@
[importlinter]
root_package = ai_platform
include_external_packages = True
[importlinter:contract:domain]
name = Lead Scoring domain stays independent of effects and HTTP
@ -21,3 +22,12 @@ forbidden_modules =
ai_platform.infrastructure
ai_platform.presentation
ai_platform.bootstrap
[importlinter:contract:presentation]
name = HTTP presentation does not import infrastructure adapters
type = forbidden
source_modules =
ai_platform.presentation
forbidden_modules =
ai_platform.infrastructure
sqlalchemy

View File

@ -8,5 +8,7 @@ This repository is the rebuilt AI Platform. `old_repo/` is historical and exclud
4. **Kiểm tra tính nhất quán trước khi tối ưu một phần:** đối chiếu API với fixture Omni, tên env với `Settings`/Compose/CI, lệnh build với package/lockfile và UI với design system ở `ARCHITECTURE.md`. Web dùng Tailwind CSS v4 cho semantic tokens, shadcn-svelte cho component cần thiết, Bits UI cho tương tác phức tạp; dùng HTML native cho form/bảng đơn giản, không tự tái tạo primitives. Xem `rewrite_architect.md` để hiểu lý do chọn stack, không dùng nó làm contract song song.
5. File trên 200 dòng là tín hiệu review: kiểm tra trách nhiệm trộn lẫn, tách tại boundary có nghĩa. Giữ một OpenAPI generated và vài Omni fixtures thật, không tạo hệ schema thứ hai.
6. Trước bàn giao, chạy Ruff/Pyright/Import Linter/pytest, Svelte checks/build/tests, migration và contract generation có liên quan. Báo chính xác pass/fail; hook opt-in, CI là gate. Không stage `.env`, `.env.test`, ignored assets hoặc secrets.
7. Thay đổi ranh giới kiến trúc, dependency direction, DI/composition root hoặc ownership transaction do main agent trực tiếp phân tích, quyết định và triển khai. Có thể giao review/test chỉ đọc độc lập, nhưng không giao phần thiết kế và implementation các thay đổi này.
8. Sau khi code hoàn tất và trước mỗi commit, review toàn bộ tracked/untracked changes theo `docs/project_quality.md`; báo chính xác các kiểm tra đã chạy, phát hiện còn lại và phần chưa xác minh.
Use `README.md` for current local commands; `stack.md` for decisions and deferred production hosting/TLS/secret/backup/alerts. Preserve the user's existing ignored files unless explicitly authorized to replace them. No global tool installs or system configuration changes during repo work.

View File

@ -13,8 +13,8 @@ The proposed modular monolith has one runtime and one deployable backend. These
- **Bootstrap/composition:** loads validated settings and composes framework, database, provider client, and application workflows. Resource lifetime is established here.
- **Platform:** tenant-key verification and tenant isolation, shared persistence/runtime concerns, and operational interfaces. It does not own Omni customer configuration or user permissions.
- **Lead Scoring domain:** rule execution, scoring-run state/invariants, and profile semantics. Deterministic scoring rules should be pure functions; run identity and lifecycle belong in an entity/value model where useful.
- **Lead Scoring application:** admission, idempotency, execution coordination, result retrieval, and notification workflow. Use cases own transaction meaning; infrastructure supplies mechanisms. AI profile contains model, prompts and timeout; app-provided rules and weights arrive in each run body.
- **Infrastructure:** database mappings/repositories, AIHub HTTP client, HMAC signing, and delivery adapters. Provider details stop at these boundaries.
- **Lead Scoring application:** admission, idempotency, execution coordination, result retrieval, and notification workflow. Use cases own transaction meaning; infrastructure supplies mechanisms. Its versioned AI profile selects AIHub or OpenAI-compatible for the service and contains model, prompts and timeout; OpenAI-compatible URL is also versioned, while API key stays in server settings. App-provided rules and weights arrive in each run body. Bootstrap constructs the AIHub adapter and an OpenAI-compatible adapter for the URL captured in the run's profile; raw V-Key is forwarded only for AIHub.
- **Infrastructure:** database mappings/repositories, separate AIHub and OpenAI-compatible HTTP adapters, HMAC signing, and delivery adapters. Provider details stop at these boundaries.
- **Presentation:** HTTP request/response mapping, tenant-auth dependency at the edge, health/readiness, and API error mapping. Transport schemas do not become domain objects by accident.
Dependency direction is inward: presentation and infrastructure may depend on application/domain contracts; domain does not depend on frameworks, persistence, network, environment, or application orchestration. Bootstrap is the composition root. Avoid generic base services, empty ports, service locators, and multiple deployment processes unless evidence requires them.

View File

@ -7,7 +7,7 @@ Canonical language for the AI Platform Lead Scoring provider. The implementation
- **AI Platform:** provider service for bounded AI profiles, rule execution, scoring-run lifecycle, result retrieval, and terminal notification.
- **DotB-Omni / Omni:** customer-facing authority for configured rules and weights, HITL policy and actions, caller-side authorization, lead identity/snapshot construction, and CRM writeback. Omni reads details from the provider after a signed notification.
- **DotB EMS:** CRM system of record for leads.
- **AIHub:** external AI gateway expected to validate/use V-Key and own model access, quota, and provider behavior. Its protocol and integration have not yet been independently verified; never infer endpoints, headers, or payloads.
- **AIHub:** default external AI gateway expected to validate/use per-request V-Key and own model access, quota, and provider behavior. An internal, versioned Lead Scoring AI profile may instead select a separate OpenAI-compatible adapter with a server-side key. AIHub's protocol and integration have not yet been independently verified; never infer endpoints, headers, or payloads.
## Domain terms
@ -16,7 +16,7 @@ Canonical language for the AI Platform Lead Scoring provider. The implementation
- **V-Key:** sensitive AIHub credential material. Target rule: keep raw value in memory only for the active execution attempt; never persist, log, trace, or send it to the browser. Source and exact forwarding protocol are unresolved.
- **Canonical snapshot:** bounded input assembled by Omni from the lead and permitted evidence. It is an input snapshot, not an AI Platform-owned CRM record.
- **Scoring configuration:** customer-authored rule/weight and HITL configuration owned by Omni. The provider may consume an immutable version with the request for reproducibility; it does not become the customer configuration authority.
- **AI profile:** provider execution choice `light`, `medium`, or `heavy`. These are three bounded orchestration modes, not confirmed AIHub-native offerings. Concrete models, passes, limits, and timeouts are unresolved.
- **AI profile:** `light`, `medium`, or `heavy` in an Omni request denotes the bounded orchestration mode. Separately, the internal versioned Lead Scoring configuration selects the provider, model, prompt and timeout; each run freezes its version. Provider choice is not an Omni request field. Concrete models and AIHub behavior still require verification.
- **Scoring run:** one provider execution for one tenant, request UUID, subject, snapshot, configuration version, and AI profile. The server-generated run UUID identifies the persisted run.
- **Request UUID / idempotency identity:** client-provided UUID included in the request body, scoped to a tenant. Same UUID and same canonical body replays the existing run; same UUID and different body conflicts. A new scoring attempt uses a new UUID. Exact fingerprint normalization remains contract work.
- **Result GET:** tenant-authenticated read of the authoritative run result. Omni calls it after receiving a signed terminal notification.

View File

@ -50,6 +50,26 @@ AI_PLATFORM_DATABASE_URL=postgresql+asyncpg://ai_platform:local-change-this-pass
`make test` rejects a database URL that does not contain `ai_platform_test`. CI provisions its own isolated PostgreSQL 16 test database.
## Chấm điểm AI qua CLIProxy khi phát triển local
Không cần chạy AIHub để thử adapter OpenAI-compatible. Cấu hình URL trên backend (trong môi trường hoặc `.env` local đã có):
```sh
AI_PLATFORM_ENVIRONMENT=development
AI_PLATFORM_OPENAI_BASE_URL=http://127.0.0.1:8317
# AI_PLATFORM_OPENAI_API_KEY=... # chỉ cần nếu CLIProxy yêu cầu xác thực
```
Chạy `make backend-dev` trên máy host: `127.0.0.1:8317` bên trong Docker không trỏ tới CLIProxy trên host. Trong Admin Web, tạo và kích hoạt **một phiên bản Cấu hình AI mới**, chọn `OpenAI-compatible`, nhập URL `http://127.0.0.1:8317`, model `gemini-3.5-flash-lite`, prompt và timeout. `AI_PLATFORM_OPENAI_BASE_URL` là URL mặc định cho profile cũ và là địa chỉ duy nhất nhận `AI_PLATFORM_OPENAI_API_KEY`; URL khác nhập trong Web không được gửi key đó. Profile cũ mặc định AIHub; URL AIHub vẫn cấu hình bằng `AI_PLATFORM_AIHUB_BASE_URL`. Lựa chọn này áp dụng cho các run mới của Lead Scoring, không đổi các run đã nhận trước đó. Gửi `POST /v1/lead-scoring/runs` với `ai_profile=medium`, `ai_weight>0` và tenant key; OpenAI-compatible không dùng `X-Tenant-VKey`. Nút “Chạy mẫu” trên Web hiện chỉ gửi mẫu quy tắc (`ai_weight=0`), nên hãy dùng API để thử AI.
Với endpoint OpenAI-compatible chạy ở production, URL phải dùng HTTPS. Key được cấu hình trên server chỉ gửi tới URL được gắn với key đó; thay URL trên Web không tự chuyển key. Không đặt key vào Web hoặc file versioned. CLIProxy local chỉ xác minh được đường gọi thử; chưa chứng minh các điều kiện phát hành production.
## Terminal webhook và retention
Khi cấu hình đồng thời `AI_PLATFORM_WEBHOOK_URL` và `AI_PLATFORM_WEBHOOK_SIGNING_SECRET` (ít nhất 32 ký tự), backend gửi thông báo kết thúc run tới một URL Omni cố định. URL HTTP chỉ hợp lệ trên loopback khi phát triển. Body chỉ có `event_id`, `run_id`, `tenant_id`, `status`, `occurred_at`; header `X-Webhook-Timestamp` và `X-Webhook-Signature` chứa chữ ký HMAC-SHA256 trên `timestamp + "." + raw body`. Phản hồi 2xx xác nhận giao; lỗi được thử lại tối đa 5 lần rồi giữ bản ghi đánh dấu `dead`. Chỉ bật gửi thực tế sau khi Omni xác nhận fixture chữ ký, chống replay và GET/writeback.
Backend dọn run quá 90 ngày (gồm snapshot, fingerprint và thông báo liên quan) khi khởi động và mỗi ngày. Không áp dụng chính sách này cho backup; cần chốt vòng đời backup và tenant offboarding trước production.
## Quality checks
The opt-in pre-commit runner is installed by `make install` as a project-local development dependency. Run:

View File

@ -41,6 +41,8 @@ services:
AI_PLATFORM_WEBHOOK_URL: ${AI_PLATFORM_WEBHOOK_URL:-}
AI_PLATFORM_WEBHOOK_SIGNING_SECRET: ${AI_PLATFORM_WEBHOOK_SIGNING_SECRET:-}
AI_PLATFORM_AIHUB_BASE_URL: ${AI_PLATFORM_AIHUB_BASE_URL:-}
AI_PLATFORM_OPENAI_BASE_URL: ${AI_PLATFORM_OPENAI_BASE_URL:-}
AI_PLATFORM_OPENAI_API_KEY: ${AI_PLATFORM_OPENAI_API_KEY:-}
ports:
- "127.0.0.1:${BACKEND_PORT:-8000}:8000"
depends_on:
@ -65,6 +67,8 @@ services:
PORT: 3000
ORIGIN: ${WEB_ORIGIN:-http://localhost:3000}
BACKEND_INTERNAL_URL: http://backend:8000
WEB_ENVIRONMENT: ${WEB_ENVIRONMENT:-development}
LEAD_SCORING_FIXTURE_TENANT_KEY: ${LEAD_SCORING_FIXTURE_TENANT_KEY:-}
ports:
- "127.0.0.1:${WEB_PORT:-3000}:3000"
depends_on:

View File

@ -0,0 +1,13 @@
# Lead Scoring v2: Omni integration
AI Platform publishes this provider contract; Omni implements its consumer. `openapi.json` is generated from backend routes. The JSON files under `fixtures/` are examples, not another validation schema. Cross-service acceptance still requires Omni to run them.
## Terminal notification
When `AI_PLATFORM_WEBHOOK_URL` and `AI_PLATFORM_WEBHOOK_SIGNING_SECRET` are both configured, AI Platform sends `POST` to that one server-side URL with `Content-Type: application/json`. The raw UTF-8 body contains exactly `event_id` (stable across retries), `run_id`, `tenant_id`, `status` (`completed` or `failed`), and `occurred_at` (UTC ISO 8601). It contains no run result, snapshot, tenant key, V-Key, or AI evidence. `occurred_at` is the terminal event time; it is not the send time.
`X-Webhook-Timestamp` is the UTC ISO 8601 time of **this delivery attempt**. `X-Webhook-Signature` is `sha256=` followed by the lowercase hex HMAC-SHA256 of the UTF-8 bytes `timestamp + "." + raw_request_body`, using the shared signing secret. Verify the signature over the raw body before parsing JSON, compare in constant time, and reject timestamps outside a five-minute window. The sample in `fixtures/webhook-delivery.json` uses the **test-only** secret `test-only-webhook-secret-32-characters-long` and fixes the exact bytes and signature.
Omni must deduplicate by `event_id` and handle delivery retries without rescoring. Return any 2xx only after durably accepting the event; AI Platform retries non-2xx or network failures up to five total attempts, then marks the outbox event dead. The receiver uses its tenant key to `GET /v1/lead-scoring/runs/{run_id}`, checks tenant and terminal status, and applies its own latest-run-per-`(tenant, subject)` policy before writing to DotB EMS. The GET response is the result source of truth. Delivery may be repeated after a crash even if Omni previously replied 2xx.
Only enable the integration after Omni verifies this fixture, timestamp/replay handling, duplicate delivery and GET/writeback against its consumer. The webhook URL, signing secret and tenant key belong in server-side configuration; never put them in browser code.

View File

@ -1 +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"]}
{"version":"2.0.0","status":"implementation draft; OpenAPI generated; cross-service acceptance pending","fixtures":["fixtures/run-request.json","fixtures/tenant-key-provision-response.json","fixtures/webhook-delivery.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 sandbox protocol not yet verified","admin_login":{"method":"POST","path":"/v1/admin/login","body":{"email":"admin@example.test","password":"string"},"success":{"email":"admin@example.test","csrf_token":"opaque-token","expires_in":28800},"cookie":"ai_platform_session; HttpOnly; SameSite=Lax; Path=/; Secure in production"}},"scoring_request":{"required":["request_id","subject_ref","snapshot","scoring_config","ai_profile"],"scoring_config":{"required":["version","rules","rule_weight","ai_weight"],"weights_sum_to":1}},"run_status":["pending","running","completed","failed"]}

View File

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

View File

@ -0,0 +1,7 @@
{
"method": "POST",
"content_type": "application/json",
"timestamp": "2026-09-28T00:00:00+00:00",
"body": "{\"event_id\":\"11111111-1111-4111-8111-111111111111\",\"run_id\":\"22222222-2222-4222-8222-222222222222\",\"tenant_id\":\"tenant-demo\",\"status\":\"completed\",\"occurred_at\":\"2026-09-28T00:00:00+00:00\"}",
"signature": "sha256=ecf4109742e1d3901f5a065f11375d6a523dbecda468787e6695880b4be12c48"
}

File diff suppressed because one or more lines are too long

86
docs/project_quality.md Normal file
View File

@ -0,0 +1,86 @@
---
title: Project quality gate
description: Checklist for reviewing completed changes against requirements, architecture, code quality, security, and verification before commit.
---
# Project quality gate
Run this review after implementation and before every commit. Review the full worktree, including untracked files, not only `git diff` output. This checklist supports the project specifications and architecture documents; it does not replace them.
## Source of truth
- Use the relevant approved specification for required behavior and acceptance criteria.
- Use `ARCHITECTURE.md` and `AGENTS.md` for module boundaries, dependency direction, and repository workflow.
- Use generated OpenAPI and shared fixtures for exact API shapes.
- Treat `rewrite_architect.md` as design rationale, not a parallel contract. When it conflicts with an approved specification or current architecture, follow the more specific approved source and record the conflict.
- Do not claim cross-service or production behavior is verified unless the agreed fixture, integration test, or operational check was run.
## Architecture and dependency review
- Trace each changed capability end to end: presentation, application, domain, infrastructure, and bootstrap where applicable.
- Keep deterministic rules and calculations pure. Keep orchestration and transaction meaning in application use cases.
- Keep SQL, network access, environment access, clocks, and secure randomness at explicit boundaries. Pass volatile values through dependencies when they affect application behavior.
- Keep concrete adapters in the bootstrap composition root. Application code depends on focused ports; presentation maps transport and calls already-wired use cases. Do not introduce a DI container or service locator without evidence.
- Keep commits and rollbacks at the application use-case boundary; persistence adapters implement storage and transaction mechanisms without deciding use-case boundaries.
- Use classes when identity, state, lifecycle, or a framework contract needs one. Use functions for stateless transformations. Do not force an OOP or functional style where it adds no value.
- Use established patterns only to solve a real boundary or lifecycle problem. Avoid generic base classes, speculative factories, duplicate repositories, wrappers, and abstractions with no meaningful substitution or behavior.
- Architecture-boundary, dependency-direction, DI/composition-root, or transaction-ownership changes are designed and implemented by the main agent. Independent agents may review or test them.
## Requirements and consistency review
- Map each changed behavior to a requirement and acceptance check. Identify missing, partial, or newly invented behavior.
- Check tenant isolation, authentication, authorization, CSRF, audit, secret handling, and failure behavior at the boundaries they apply to.
- Check API routes against Pydantic models, generated OpenAPI, and shared fixtures. Regenerate artifacts through the approved generator; do not hand-edit generated output.
- Validate canonical JSON fixtures imported by Admin Web against the backend Pydantic model, especially when TypeScript JSON imports need a type assertion.
- Check configuration names across `Settings`, example environment files, Compose, CI, and runtime entrypoints.
- Check build and test commands against the package manifests and lockfiles.
- Check Admin Web against `ARCHITECTURE.md` and the existing design system. Prefer native HTML for simple forms and tables; use project components and interaction primitives where they add needed behavior.
- Check database model changes against Alembic migrations. Startup remains read-only and must not apply migrations. Run migration verification only against an isolated test database.
## Red flags to investigate
- Hard-coded migration revisions, secrets, service URLs, environment names, or changing operational values.
- Business rules or persistence decisions in HTTP routes; adapter construction outside bootstrap; `Depends` or framework types in domain/application.
- Global mutable state, hidden I/O, implicit time/randomness, or broad exception handling that hides failure categories.
- Persistence adapters that commit outside the use-case transaction boundary.
- Raw secrets in logs, list responses, browser-visible configuration, or cacheable responses. One-time key responses use `Cache-Control: no-store`; secret storage follows the approved specification.
- Generated files edited manually, a second schema source, tests pointed at development data, or tests that assert only implementation details rather than behavior.
- Web source imports that escape `apps/web` through repository-relative paths. Keep intentional shared-contract imports limited to canonical contracts/fixtures, and verify the Docker build stage copies every imported source asset at the path the bundler resolves.
- Large or multi-purpose modules, duplicated validation/mapping, unnecessary indirection, or local optimization that conflicts with end-to-end contracts.
A red flag is a prompt to verify the relevant requirement, not an automatic ban. Keep useful complexity when a concrete invariant, security boundary, or measured need requires it.
## Verification before commit
Run the checks relevant to the changed paths and report exact pass/fail status. Do not report a command as passed if it was skipped or blocked.
Backend:
```sh
uv run ruff check src tests
uv run ruff format --check src tests
uv run pyright
uv run lint-imports
make test
```
Web:
```sh
cd apps/web
bun run format:check
bun run check
bun run test
bun run build
```
Contracts and migrations:
```sh
make contract
uv run --no-sync alembic heads
```
Use the repository's isolated test database for database tests. Do not run destructive migration commands against development or production data.
Finally inspect `git status --short`, the tracked diff, and all untracked files; run `git diff --check`. Before committing, summarize findings, checks, remaining uncertainty, and intentionally deferred work. Fix correctness, security, contract, and architecture-boundary findings introduced by the change before commit. Report pre-existing coupling or refactor candidates separately; do not expand into a broad refactor without an explicit main-agent scope decision.

View File

@ -11,7 +11,7 @@ status: review_ready
## Sở hữu
- DotB-Omni tạo snapshot, định nghĩa rules/points và rule/AI weights, score bands, HITL/review/override, quyền người dùng và hành động CRM. Omni gửi tenant key và V-Key từ backend của mình, không từ browser.
- AI Platform xác thực tenant, thực thi rules của Omni, gọi AIHub khi cần, tính rule_score/ai_score/final_score, lưu run và gửi thông báo kết thúc. Admin Web nội bộ chỉ quản lý AI profile và xem/chạy fixtures; không quản lý customer rules/review.
- AI Platform xác thực tenant, thực thi rules của Omni, gọi nhà cung cấp AI đã chọn khi cần, tính rule_score/ai_score/final_score, lưu run và gửi thông báo kết thúc. Lead Scoring mặc định dùng AIHub; quản trị nội bộ có thể kích hoạt một AI profile có phiên bản chọn AIHub hoặc OpenAI-compatible cho toàn service. Omni không chọn provider trong từng request. Admin Web nội bộ chỉ quản lý AI profile và xem/chạy fixtures; không quản lý customer rules/review.
- DotB EMS là nguồn sự thật CRM. Omni nhận webhook terminal rồi GET chi tiết run trước khi ghi EMS. Webhook không chứa snapshot, V-Key hay AI evidence.
## Admission và idempotency
@ -19,20 +19,20 @@ status: review_ready
`POST /v1/lead-scoring/runs` nhận `Authorization: Bearer <tenant_key>`, `Idempotency-Key: <UUID>` và `X-Tenant-VKey` tùy chọn. Body chứa `request_id` cùng UUID, `subject_ref`, snapshot, app-defined scoring configuration (`version`, rules/points, `rule_weight`, `ai_weight`) và AI profile. Tenant được suy ra từ key xác thực, không từ body. `GET /v1/lead-scoring/runs/{run_id}` chỉ trả dữ liệu của tenant đó.
- Fingerprint SHA-256 của canonical **body Omni**; không bao gồm tenant key, V-Key hoặc AI profile do server chọn. Lookup `(tenant, request_id)` trước khi chọn profile. Cùng key/body trả run cũ mà không schedule AI; cùng key/body khác trả 409. PostgreSQL unique constraint giải quyết concurrent requests; retry admission đã commit nhưng mất response dùng cùng UUID/body.
- Run mới đóng băng AI profile đang active, INSERT + COMMIT trước khi schedule và trả 202; `Location` trỏ đến GET. Nếu không schedule được, run thành failed và response phản ánh trạng thái thực, không để pending giả. Chấm lại hoặc thử profile mới dùng UUID mới. Không reuse UUID quá 90 ngày: run và fingerprint bị xóa, không có tombstone.
- Run mới đóng băng AI profile đang active (gồm provider và model), INSERT + COMMIT trước khi schedule và trả 202; `Location` trỏ đến GET. Nếu không schedule được, run thành failed và response phản ánh trạng thái thực, không để pending giả. Chấm lại hoặc thử profile mới dùng UUID mới. Không reuse UUID quá 90 ngày: run và fingerprint bị xóa, không có tombstone.
- Validate chặt loại rule/operator/value, tổng weights bằng 1, score/points và dữ liệu snapshot; đầu vào sai trả 422. Snapshot tối đa 10 conversations, 50 messages, 20.000 ký tự nội dung và 90 ngày evidence; giữ thứ tự deterministic. Kích thước toàn body có hard cap. Không hard-code rule giáo dục hay category/action/review state.
## Kết quả và lỗi
- `ai_weight=0`: `rules_only`, `degraded=false`, `ai_score=null`, không gọi AIHub và không cần V-Key.
- `ai_weight>0` nhưng thiếu V-Key hoặc AIHub lỗi: `rules_only_fallback`, `degraded=true`, `ai_score/ai_confidence=null`, `final_score=rule_score`, `error_code` đã chuẩn hóa. Không tự AI retry.
- `ai_weight>0` nhưng thiếu V-Key khi profile chọn AIHub, hoặc nhà cung cấp đã chọn lỗi: `rules_only_fallback`, `degraded=true`, `ai_score/ai_confidence=null`, `final_score=rule_score`, `error_code` đã chuẩn hóa. Không tự AI retry.
- AI thành công: `hybrid`, `degraded=false`, `final_score=clamp(round(rule_score*rule_weight + ai_score*ai_weight), 0, 100)`; trả breakdown, reasoning/evidence/confidence được giới hạn. Cùng raw signals có thể ảnh hưởng cả rules lẫn AI; không gửi app rules/points vào AI prompt để hạn chế chấm hai lần.
- Bug/code/DB failure thành `failed`, không giả là AI fallback. AI profile gồm versioned model, prompts và timeout/token limits, không sở hữu app rules hoặc weights. Medium two-pass là mặc định; Light/Heavy chỉ bổ sung khi có nhu cầu và được xác minh với AIHub.
- Bug/code/DB failure thành `failed`, không giả là AI fallback. AI profile gồm provider, versioned model, prompts và timeout/token limits, không sở hữu app rules hoặc weights. Profile cũ mặc định AIHub; đổi provider cần phiên bản mới, ghi audit actor/version/provider cùng transaction, không ghi secret. Medium two-pass là mặc định; Light/Heavy chỉ bổ sung khi có nhu cầu và được xác minh với nhà cung cấp.
## Key, session và delivery
- Platform có một active tenant key/tenant dùng chung cho LS và feature sau này: provisioner secret server-side cấp qua `POST /v1/platform/tenant-keys`, raw trả một lần, DB chỉ có verifier/digest; cấp trùng 409. Nếu response mất sau commit, admin CLI rotate cũ/thay mới atomically; không tự rotate khi POST bị replay.
- `X-Tenant-VKey` chỉ giữ trong RAM của current scoring task và forward `Authorization: Bearer` tới AIHub. Không persist/log/trace/error/browser. V-Key không phải platform authentication. Admin session độc lập, password hash, CSRF cho mutation, cookie bảo vệ, logout/expiry và audit thay đổi config/key không kèm secrets.
- `X-Tenant-VKey` chỉ giữ trong RAM của current scoring task và forward `Authorization: Bearer` tới AIHub khi profile chọn AIHub. OpenAI-compatible dùng URL được lưu theo phiên bản profile; API key tùy chọn vẫn cấu hình ở server và chỉ được gửi tới URL đã cấu hình cho key đó, không tự gửi sang URL mới nhập ở Web. HTTP chỉ cho loopback khi development, ngoài development yêu cầu HTTPS. Chưa xác nhận quản lý key cho các URL production khác nhau. Không persist/log/trace/error/browser bất kỳ raw secret nào. V-Key không phải platform authentication. Admin session độc lập, password hash, CSRF cho mutation, cookie bảo vệ, logout/expiry và audit thay đổi config/key không kèm secrets.
- Một backend process + asyncio tasks. Startup trước readiness terminalize pending/running cũ thành `failed/worker_interrupted`; task exception cũng terminalize. V-Key không durable nên không resume AI sau crash; caller muốn làm lại dùng UUID mới.
- Terminal transition và PostgreSQL outbox event commit cùng transaction. Dispatcher gửi HMAC-signed webhook tới một Omni URL cố định với event ID/timestamp, bounded retry và dead-letter. Omni xác minh chữ ký/chống replay/dedupe, GET chi tiết và áp dụng writeback theo run mới nhất của `(tenant, subject)`; delivery replay không chấm AI lại. Không broker, dynamic subscription, polling scheduler tại Omni.

View File

@ -0,0 +1,40 @@
import sqlalchemy as sa
from alembic import op
revision = "0004_tenant_key_admin"
down_revision = "0003_ai_evidence"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"tenant_keys",
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True),
)
op.execute("UPDATE tenant_keys SET updated_at = created_at WHERE updated_at IS NULL")
op.alter_column(
"tenant_keys",
"updated_at",
existing_type=sa.DateTime(timezone=True),
nullable=False,
server_default=sa.func.now(),
)
op.create_table(
"tenant_key_audit_events",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("tenant_id", sa.String(128), nullable=False),
sa.Column("actor", sa.String(254), nullable=False),
sa.Column("action", sa.String(16), nullable=False),
sa.Column(
"occurred_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.func.now(),
),
)
def downgrade() -> None:
op.drop_table("tenant_key_audit_events")
op.drop_column("tenant_keys", "updated_at")

View File

@ -0,0 +1,32 @@
import sqlalchemy as sa
from alembic import op
revision = "0005_ai_profile_provider"
down_revision = "0004_tenant_key_admin"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"ai_profiles",
sa.Column("provider", sa.String(32), nullable=False, server_default="aihub"),
)
op.create_table(
"ai_profile_audit_events",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("actor", sa.String(254), nullable=False),
sa.Column("version", sa.String(64), nullable=False),
sa.Column("provider", sa.String(32), nullable=False),
sa.Column(
"occurred_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.func.now(),
),
)
def downgrade() -> None:
op.drop_table("ai_profile_audit_events")
op.drop_column("ai_profiles", "provider")

View File

@ -0,0 +1,15 @@
import sqlalchemy as sa
from alembic import op
revision = "0006_ai_profile_base_url"
down_revision = "0005_ai_profile_provider"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("ai_profiles", sa.Column("base_url", sa.String(2048), nullable=True))
def downgrade() -> None:
op.drop_column("ai_profiles", "base_url")

View File

@ -0,0 +1,38 @@
import sqlalchemy as sa
from alembic import op
revision = "0007_scoring_notifications"
down_revision = "0006_ai_profile_base_url"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"scoring_notifications",
sa.Column("id", sa.String(36), primary_key=True),
sa.Column(
"run_id",
sa.String(36),
sa.ForeignKey("scoring_runs.id", ondelete="CASCADE"),
nullable=False,
unique=True,
),
sa.Column("tenant_id", sa.String(128), nullable=False),
sa.Column("status", sa.String(24), nullable=False),
sa.Column("attempts", sa.Integer(), nullable=False),
sa.Column("next_attempt_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("delivered_at", sa.DateTime(timezone=True)),
sa.Column("dead", sa.Boolean(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
)
op.create_index(
"ix_scoring_notifications_due",
"scoring_notifications",
["dead", "next_attempt_at"],
)
def downgrade() -> None:
op.drop_index("ix_scoring_notifications_due", table_name="scoring_notifications")
op.drop_table("scoring_notifications")

View File

@ -109,7 +109,11 @@ def seed_demo(
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)
response = client.post(
"/v1/lead-scoring/runs",
headers={**headers, "Idempotency-Key": payload["request_id"]},
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")

View File

@ -1,54 +1,165 @@
from contextlib import asynccontextmanager
import asyncio
from collections.abc import Iterable
from contextlib import asynccontextmanager, suppress
from datetime import UTC, datetime
from pathlib import Path
import httpx
from alembic.config import Config
from alembic.script import ScriptDirectory
from fastapi import FastAPI
from sqlalchemy import text
from sqlalchemy.exc import ProgrammingError
from ai_platform.bootstrap.runtime import (
create_run_scheduler,
notification_loop,
recover_interrupted_runs,
wait_for_tasks,
)
from ai_platform.bootstrap.wiring import (
get_admin_scoring_use_cases,
get_admin_session_use_cases,
get_database_readiness,
get_scoring_use_cases,
get_tenant_authentication_use_cases,
get_tenant_key_use_cases,
new_password_hasher,
)
from ai_platform.infrastructure.aihub import AiHubClient
from ai_platform.infrastructure.aihub_scoring import AiHubScoringGateway
from ai_platform.infrastructure.config.settings import Settings
from ai_platform.infrastructure.openai_compatible_scoring import OpenAICompatibleScoringGateway
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
from ai_platform.infrastructure.webhook import OmniWebhook
from ai_platform.lead_scoring.application.admin import openai_url_allowed
from ai_platform.lead_scoring.application.scoring_execution_repository import (
AIProviderFailure,
ScoringAIProvider,
)
from ai_platform.presentation.http.admin_routes import create_admin_router
from ai_platform.presentation.http.admin_tenant_key_routes import create_tenant_key_router
from ai_platform.presentation.http.routes import create_scoring_router
_MIGRATIONS_MISSING = "Database migrations are missing or not at head; run alembic upgrade head"
def _current_migration_heads() -> frozenset[str]:
project_root = Path(__file__).resolve().parents[3]
config = Config(str(project_root / "alembic.ini"))
config.set_main_option("path_separator", "os")
config.set_main_option("script_location", str(project_root / "migrations"))
return frozenset(ScriptDirectory.from_config(config).get_heads())
def validate_migration_heads(applied_heads: Iterable[str]) -> None:
if frozenset(applied_heads) != _current_migration_heads():
raise RuntimeError(_MIGRATIONS_MISSING)
def create_app(
settings: Settings | None = None,
*,
aihub_transport: httpx.AsyncBaseTransport | None = None,
openai_transport: httpx.AsyncBaseTransport | None = None,
webhook_transport: httpx.AsyncBaseTransport | None = None,
) -> FastAPI:
config = settings or Settings.load()
sessions = make_session_factory(config.database_url)
tasks = set()
available_providers = frozenset(
name
for name, available in (
("aihub", config.aihub_base_url is not None),
(
"openai_compatible",
config.openai_base_url is not None
or (config.environment == "development" and config.openai_api_key is None),
),
)
if available
)
@asynccontextmanager
async def lifespan(app: FastAPI):
try:
async with sessions() as db:
revisions = (
(await db.execute(text("SELECT version_num FROM alembic_version"))).scalars().all()
revisions = set(
(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)
except ProgrammingError as exc:
raise RuntimeError(_MIGRATIONS_MISSING) from exc
validate_migration_heads(revisions)
await recover_interrupted_runs(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
clients: list[httpx.AsyncClient] = []
providers: dict[str, ScoringAIProvider] = {}
if config.aihub_base_url is not None:
aihub_http = httpx.AsyncClient(transport=aihub_transport)
clients.append(aihub_http)
providers["aihub"] = AiHubScoringGateway(AiHubClient(config.aihub_base_url, aihub_http))
if "openai_compatible" in available_providers:
openai_http = httpx.AsyncClient(transport=openai_transport)
clients.append(openai_http)
key = config.openai_api_key.get_secret_value() if config.openai_api_key else None
if config.openai_base_url is not None:
providers["openai_compatible"] = OpenAICompatibleScoringGateway(
config.openai_base_url, key, openai_http
)
def provider_for_url(url: str) -> ScoringAIProvider:
if not openai_url_allowed(url, config.environment):
raise AIProviderFailure("openai_url_not_allowed")
return OpenAICompatibleScoringGateway(
url, key if url == config.openai_base_url else None, openai_http
)
app.state.schedule_scoring_run = create_run_scheduler(
sessions, tasks, providers, provider_for_url
)
else:
client = httpx.AsyncClient(transport=aihub_transport)
app.state.aihub_client = AiHubClient(config.aihub_base_url, client)
app.state.schedule_scoring_run = create_run_scheduler(sessions, tasks, providers)
send_notification = None
if config.webhook_url is not None and config.webhook_signing_secret is not None:
webhook_http = httpx.AsyncClient(transport=webhook_transport)
clients.append(webhook_http)
send_notification = OmniWebhook(
webhook_http,
config.webhook_url,
config.webhook_signing_secret.get_secret_value(),
lambda: datetime.now(UTC),
).send
notification_task = asyncio.create_task(notification_loop(sessions, send_notification))
try:
yield
finally:
notification_task.cancel()
with suppress(asyncio.CancelledError):
await notification_task
await wait_for_tasks(tasks)
if app.state.aihub_client is not None:
await app.state.aihub_client.aclose()
for client in clients:
await 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)
app.state.settings = config
app.state.available_providers = available_providers
app.state.password_hasher = new_password_hasher()
app.include_router(
create_scoring_router(
get_tenant_authentication_use_cases,
get_scoring_use_cases,
get_database_readiness,
)
)
app.include_router(
create_admin_router(get_admin_session_use_cases, get_admin_scoring_use_cases)
)
app.include_router(
create_tenant_key_router(get_tenant_key_use_cases, get_admin_session_use_cases)
)
return app

View File

@ -0,0 +1,90 @@
import asyncio
import logging
from collections.abc import Awaitable, Callable, Mapping
from datetime import UTC, datetime
from typing import Any
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from ai_platform.bootstrap.wiring import build_scoring_execution_use_cases
from ai_platform.infrastructure.persistence.notifications import SqlAlchemyNotificationRepository
from ai_platform.lead_scoring.application.notifications import Notification, NotificationUseCases
from ai_platform.lead_scoring.application.scoring_execution_repository import ScoringAIProvider
logger = logging.getLogger(__name__)
def create_run_scheduler(
sessions: async_sessionmaker[AsyncSession],
tasks: set[asyncio.Task[Any]],
providers: Mapping[str, ScoringAIProvider],
provider_for_url: Callable[[str], ScoringAIProvider] | None = None,
) -> Callable[[str, str | None], None]:
def schedule(run_id: str, raw_vkey: str | None) -> None:
task = asyncio.create_task(
_execute_run(sessions, run_id, raw_vkey, providers, provider_for_url)
)
tasks.add(task)
task.add_done_callback(tasks.discard)
return schedule
async def _execute_run(
sessions: async_sessionmaker[AsyncSession],
run_id: str,
raw_vkey: str | None,
providers: Mapping[str, ScoringAIProvider],
provider_for_url: Callable[[str], ScoringAIProvider] | None,
) -> None:
async with sessions() as db:
use_cases = build_scoring_execution_use_cases(db, providers, provider_for_url)
await use_cases.execute(run_id, raw_vkey)
async def recover_interrupted_runs(
sessions: async_sessionmaker[AsyncSession],
) -> None:
async with sessions() as db:
use_cases = build_scoring_execution_use_cases(db, {})
await use_cases.recover_interrupted()
async def notification_loop(
sessions: async_sessionmaker[AsyncSession],
send: Callable[[Notification], Awaitable[bool]] | None,
) -> None:
last_cleanup: datetime | None = None
while True:
try:
async with sessions() as db:
cases = NotificationUseCases(
SqlAlchemyNotificationRepository(db),
send if send is not None else _delivery_disabled,
db.commit,
db.rollback,
lambda: datetime.now(UTC),
)
now = datetime.now(UTC)
if last_cleanup is None or (now - last_cleanup).total_seconds() >= 86400:
await cases.delete_expired()
last_cleanup = now
dispatched = await cases.dispatch_one() if send is not None else False
if not dispatched:
await asyncio.sleep(1)
except asyncio.CancelledError:
raise
except Exception:
logger.exception("Notification processing failed")
await asyncio.sleep(5)
async def _delivery_disabled(_event: Notification) -> bool:
return False
async def wait_for_tasks(tasks: set[asyncio.Task[Any]]) -> None:
if tasks:
_done, pending = await asyncio.wait(tasks, timeout=3)
for task in pending:
task.cancel()

View File

@ -0,0 +1,158 @@
from collections.abc import Callable, Mapping
from datetime import UTC, datetime
from typing import Annotated
from fastapi import Depends, Request
from pwdlib import PasswordHash
from sqlalchemy.ext.asyncio import AsyncSession
from ai_platform.infrastructure.config.settings import Settings
from ai_platform.infrastructure.credentials import new_admin_session_token, new_tenant_key
from ai_platform.infrastructure.persistence.admin_sessions import SqlAlchemyAdminSessionRepository
from ai_platform.infrastructure.persistence.admin_users import SqlAlchemyAdminUserRepository
from ai_platform.infrastructure.persistence.health import database_available
from ai_platform.infrastructure.persistence.scoring import SqlAlchemyScoringRepository
from ai_platform.infrastructure.persistence.scoring_execution import (
SqlAlchemyScoringExecutionRepository,
)
from ai_platform.infrastructure.persistence.tenant_keys import SqlAlchemyTenantKeyRepository
from ai_platform.lead_scoring.application.admin import AdminScoringUseCases
from ai_platform.lead_scoring.application.run_use_cases import ScoringUseCases
from ai_platform.lead_scoring.application.scoring_execution import ScoringExecutionUseCases
from ai_platform.lead_scoring.application.scoring_execution_repository import ScoringAIProvider
from ai_platform.platform.application.admin_sessions import AdminSessionUseCases
from ai_platform.platform.application.admin_users import AdminUserUseCases
from ai_platform.platform.application.tenant_authentication import TenantAuthenticationUseCases
from ai_platform.platform.application.tenant_keys import TenantKeyUseCases
from ai_platform.presentation.http.dependencies import db_session
def build_tenant_key_use_cases(db: AsyncSession) -> TenantKeyUseCases:
return TenantKeyUseCases(
repository=SqlAlchemyTenantKeyRepository(db),
commit=db.commit,
rollback=db.rollback,
key_generator=new_tenant_key,
)
def build_tenant_authentication_use_cases(db: AsyncSession) -> TenantAuthenticationUseCases:
return TenantAuthenticationUseCases(SqlAlchemyTenantKeyRepository(db))
def build_scoring_use_cases(
db: AsyncSession,
schedule: Callable[[str, str | None], None],
clock: Callable[[], datetime],
) -> ScoringUseCases:
return ScoringUseCases(
repository=SqlAlchemyScoringRepository(db),
commit=db.commit,
rollback=db.rollback,
schedule=schedule,
clock=clock,
)
def build_scoring_execution_use_cases(
db: AsyncSession,
providers: Mapping[str, ScoringAIProvider],
provider_for_url: Callable[[str], ScoringAIProvider] | None = None,
) -> ScoringExecutionUseCases:
return ScoringExecutionUseCases(
repository=SqlAlchemyScoringExecutionRepository(db),
providers=providers,
commit=db.commit,
rollback=db.rollback,
clock=lambda: datetime.now(UTC),
provider_for_url=provider_for_url,
)
def build_admin_scoring_use_cases(
db: AsyncSession, available_providers: frozenset[str], settings: Settings
) -> AdminScoringUseCases:
return AdminScoringUseCases(
repository=SqlAlchemyScoringRepository(db),
commit=db.commit,
rollback=db.rollback,
available_providers=available_providers,
clock=lambda: datetime.now(UTC),
openai_base_url=settings.openai_base_url,
environment=settings.environment,
)
def build_admin_user_use_cases(
db: AsyncSession,
hash_password: Callable[[str], str],
) -> AdminUserUseCases:
return AdminUserUseCases(
repository=SqlAlchemyAdminUserRepository(db),
hash_password=hash_password,
commit=db.commit,
rollback=db.rollback,
)
def build_admin_session_use_cases(
db: AsyncSession,
password_hasher: PasswordHash,
) -> AdminSessionUseCases:
return AdminSessionUseCases(
repository=SqlAlchemyAdminSessionRepository(db),
verify_password=password_hasher.verify,
commit=db.commit,
rollback=db.rollback,
token_generator=new_admin_session_token,
clock=lambda: datetime.now(UTC),
)
async def get_tenant_key_use_cases(
db: Annotated[AsyncSession, Depends(db_session)],
) -> TenantKeyUseCases:
return build_tenant_key_use_cases(db)
async def get_tenant_authentication_use_cases(
db: Annotated[AsyncSession, Depends(db_session)],
) -> TenantAuthenticationUseCases:
return build_tenant_authentication_use_cases(db)
async def get_scoring_use_cases(
request: Request,
db: Annotated[AsyncSession, Depends(db_session)],
) -> ScoringUseCases:
return build_scoring_use_cases(
db,
request.app.state.schedule_scoring_run,
lambda: datetime.now(UTC),
)
async def get_admin_scoring_use_cases(
request: Request,
db: Annotated[AsyncSession, Depends(db_session)],
) -> AdminScoringUseCases:
return build_admin_scoring_use_cases(
db, request.app.state.available_providers, request.app.state.settings
)
async def get_admin_session_use_cases(
request: Request,
db: Annotated[AsyncSession, Depends(db_session)],
) -> AdminSessionUseCases:
return build_admin_session_use_cases(db, request.app.state.password_hasher)
async def get_database_readiness(
db: Annotated[AsyncSession, Depends(db_session)],
) -> bool:
return await database_available(db)
def new_password_hasher() -> PasswordHash:
return PasswordHash.recommended()

View File

@ -4,39 +4,40 @@ import argparse
import asyncio
import getpass
import json
import secrets
from pathlib import Path
from pwdlib import PasswordHash
from ai_platform.bootstrap.wiring import build_admin_user_use_cases, build_tenant_key_use_cases
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
from ai_platform.platform.application.admin_users import AdminUserAlreadyExists
from ai_platform.platform.application.tenant_key_repository import TenantKeyNotFound
async def create_admin(email: str, password: str) -> None:
sessions = make_session_factory(Settings.load().database_url)
try:
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()
use_cases = build_admin_user_use_cases(db, PasswordHash.recommended().hash)
try:
await use_cases.create(email, password)
except AdminUserAlreadyExists as exc:
raise SystemExit("admin already exists") from exc
finally:
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)
try:
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()
try:
return await build_tenant_key_use_cases(db).rotate(tenant_id, "cli")
except TenantKeyNotFound as exc:
raise SystemExit(f"unknown tenant: {tenant_id}") from exc
finally:
await sessions.kw["bind"].dispose()
return raw
def main() -> None:

View File

@ -0,0 +1,51 @@
from ai_platform.infrastructure.aihub import AiHubClient, AiHubError
from ai_platform.lead_scoring.application.scoring_execution_repository import (
AIProviderFailure,
Evidence,
Scoring,
)
class AiHubScoringGateway:
def __init__(self, client: AiHubClient) -> None:
self._client = client
async def extract_evidence(
self,
*,
model: str,
snapshot: dict,
vkey: str,
timeout: float,
) -> list[Evidence]:
try:
result = await self._client.extract_evidence(
model=model,
snapshot=snapshot,
vkey=vkey,
timeout=timeout,
)
except AiHubError as exc:
raise AIProviderFailure(exc.error_code) from None
return [Evidence(item.signal, item.detail, item.source) for item in result.items]
async def score(
self,
*,
model: str,
system_prompt: str,
user_prompt: str,
vkey: str,
timeout: float,
) -> Scoring:
try:
result = await self._client.score(
model=model,
system_prompt=system_prompt,
user_prompt=user_prompt,
vkey=vkey,
timeout=timeout,
)
except AiHubError as exc:
raise AIProviderFailure(exc.error_code) from None
return Scoring(result.score, result.confidence, result.reasoning)

View File

@ -18,8 +18,12 @@ class Settings(BaseSettings):
environment: str = "development"
tenant_provisioning_secret: SecretStr | None = None
aihub_base_url: str | None = None
openai_base_url: str | None = None
openai_api_key: SecretStr | None = None
admin_cookie_secure: bool = False
session_ttl_seconds: int = 28800
webhook_url: str | None = None
webhook_signing_secret: SecretStr | None = None
@model_validator(mode="after")
def validate_settings(self):
@ -53,6 +57,63 @@ class Settings(BaseSettings):
raise ValueError(
"AI_PLATFORM_AIHUB_BASE_URL must use HTTPS outside local development"
)
if self.openai_base_url is not None or self.openai_api_key is not None:
if not self.openai_base_url:
raise ValueError("OpenAI-compatible provider requires a base URL")
try:
parsed = urlsplit(self.openai_base_url)
if parsed.port == 0:
raise ValueError("Port zero is invalid")
except ValueError:
raise ValueError("OpenAI-compatible provider requires a valid base URL") from None
local = parsed.hostname in {"localhost", "127.0.0.1", "::1"}
key = self.openai_api_key.get_secret_value() if self.openai_api_key else None
if key is not None and (
not key.strip() or len(key) > 4_096 or "\r" in key or "\n" in key
):
raise ValueError("OpenAI-compatible provider key is invalid")
if (
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
or parsed.path not in {"", "/"}
or any(char.isspace() for char in self.openai_base_url)
or (parsed.scheme == "http" and (not local or self.environment != "development"))
or (not key and (not local or self.environment != "development"))
):
raise ValueError("OpenAI-compatible provider requires a safe base URL and key")
if (self.webhook_url is None) != (self.webhook_signing_secret is None):
raise ValueError("Webhook URL and signing secret must be configured together")
if self.webhook_url is not None and self.webhook_signing_secret is not None:
parsed = urlsplit(self.webhook_url)
try:
valid_port = parsed.port != 0
except ValueError:
valid_port = False
if (
not valid_port
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
or any(char.isspace() for char in self.webhook_url)
or (
parsed.scheme == "http"
and (
self.environment != "development"
or parsed.hostname not in {"localhost", "127.0.0.1", "::1"}
)
)
):
raise ValueError("Webhook URL must use HTTPS outside local development")
secret = self.webhook_signing_secret.get_secret_value()
if not secret.strip() or len(secret) < 32:
raise ValueError("Webhook signing secret must have at least 32 characters")
return self
@classmethod

View File

@ -0,0 +1,9 @@
import secrets
def new_tenant_key() -> str:
return "tnt_" + secrets.token_urlsafe(32)
def new_admin_session_token() -> str:
return secrets.token_urlsafe(32)

View File

@ -0,0 +1,154 @@
import json
from typing import Any
import httpx
from pydantic import (
BaseModel,
ConfigDict,
Field,
StrictFloat,
StrictInt,
StrictStr,
ValidationError,
)
from ai_platform.lead_scoring.application.scoring_execution_repository import (
AIProviderFailure,
Evidence,
Scoring,
)
_MAX_RESPONSE_BYTES = 1_000_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)
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)
def _unique_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
class OpenAICompatibleScoringGateway:
"""Server-key scoring adapter; never uses a caller's AIHub V-Key."""
def __init__(self, base_url: str, api_key: str | None, client: httpx.AsyncClient) -> None:
self._url = f"{base_url.rstrip('/')}/v1/chat/completions"
self._api_key = api_key
self._client = client
async def extract_evidence(
self, *, model: str, snapshot: dict, vkey: str, timeout: float
) -> list[Evidence]:
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."
)
payload = await self._chat_json(
model,
system_prompt,
"Trích xuất bằng chứng từ snapshot sau:\n"
+ json.dumps(snapshot, ensure_ascii=False, separators=(",", ":")),
timeout,
)
try:
evidence = _ExtractedEvidence.model_validate(payload)
except ValidationError:
raise AIProviderFailure("openai_invalid_evidence") from None
return [Evidence(item.signal, item.detail, item.source) for item in evidence.items]
async def score(
self, *, model: str, system_prompt: str, user_prompt: str, vkey: str, timeout: float
) -> Scoring:
payload = await self._chat_json(model, system_prompt, user_prompt, timeout)
try:
score = _AiScore.model_validate(payload)
except ValidationError:
raise AIProviderFailure("openai_invalid_score") from None
return Scoring(score.score, score.confidence, score.reasoning)
async def _chat_json(
self, model: str, system_prompt: str, user_prompt: str, timeout: float
) -> Any:
if not 0 < timeout <= 120:
raise AIProviderFailure("openai_invalid_timeout")
try:
async with self._client.stream(
"POST",
self._url,
headers={"Authorization": f"Bearer {self._api_key}"} if self._api_key else {},
json={
"model": model,
"messages": [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt},
],
"stream": False,
"response_format": {"type": "json_object"},
},
timeout=timeout,
) as response:
if response.status_code != 200:
raise AIProviderFailure("openai_http_error")
chunks: list[bytes] = []
size = 0
async for chunk in response.aiter_bytes():
size += len(chunk)
if size > _MAX_RESPONSE_BYTES:
raise AIProviderFailure("openai_response_too_large")
chunks.append(chunk)
except httpx.TimeoutException:
raise AIProviderFailure("openai_timeout") from None
except httpx.RequestError:
raise AIProviderFailure("openai_request_error") from None
try:
envelope = json.loads(b"".join(chunks), object_pairs_hook=_unique_keys)
content = envelope["choices"][0]["message"]["content"]
if not isinstance(content, str):
raise TypeError("invalid completion content")
content = content.strip()
if content.startswith("```json\n") and content.endswith("\n```"):
content = content.removeprefix("```json\n").removesuffix("\n```")
payload = json.loads(content, object_pairs_hook=_unique_keys)
if self._api_key and _contains_secret(payload, self._api_key):
raise ValueError("response contained API credential")
return payload
except (KeyError, IndexError, TypeError, ValueError, json.JSONDecodeError):
raise AIProviderFailure("openai_invalid_response") from None

View File

@ -0,0 +1,45 @@
from sqlalchemy.ext.asyncio import AsyncSession
from ai_platform.infrastructure.persistence.models import AdminSession, AdminUser
from ai_platform.platform.application.admin_sessions import (
AdminSessionRecord,
AdminSessionRepository,
AdminUserRecord,
)
def _to_record(row: AdminSession) -> AdminSessionRecord:
return AdminSessionRecord(
id=row.id,
admin_email=row.admin_email,
csrf_hash=row.csrf_hash,
expires_at=row.expires_at,
)
class SqlAlchemyAdminSessionRepository(AdminSessionRepository):
def __init__(self, db: AsyncSession) -> None:
self._db = db
async def find_user(self, email: str) -> AdminUserRecord | None:
row = await self._db.get(AdminUser, email)
return AdminUserRecord(email=row.email, password_hash=row.password_hash) if row else None
async def find(self, session_id: str) -> AdminSessionRecord | None:
row = await self._db.get(AdminSession, session_id)
return _to_record(row) if row is not None else None
async def create(self, session: AdminSessionRecord) -> None:
self._db.add(
AdminSession(
id=session.id,
admin_email=session.admin_email,
csrf_hash=session.csrf_hash,
expires_at=session.expires_at,
)
)
async def delete(self, session_id: str) -> None:
row = await self._db.get(AdminSession, session_id)
if row is not None:
await self._db.delete(row)

View File

@ -0,0 +1,23 @@
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from ai_platform.infrastructure.persistence.models import AdminUser
from ai_platform.platform.application.admin_users import (
AdminUserAlreadyExists,
AdminUserRepository,
)
class SqlAlchemyAdminUserRepository(AdminUserRepository):
def __init__(self, db: AsyncSession) -> None:
self._db = db
async def exists(self, email: str) -> bool:
return await self._db.get(AdminUser, email) is not None
async def create(self, email: str, password_hash: str) -> None:
self._db.add(AdminUser(email=email, password_hash=password_hash))
try:
await self._db.flush()
except IntegrityError as exc:
raise AdminUserAlreadyExists from exc

View File

@ -0,0 +1,11 @@
from sqlalchemy import text
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.ext.asyncio import AsyncSession
async def database_available(db: AsyncSession) -> bool:
try:
await db.execute(text("SELECT 1"))
except SQLAlchemyError:
return False
return True

View File

@ -24,6 +24,20 @@ class TenantKey(Base):
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())
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False
)
class TenantKeyAuditEvent(Base):
__tablename__ = "tenant_key_audit_events"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
tenant_id: Mapped[str] = mapped_column(String(128), nullable=False)
actor: Mapped[str] = mapped_column(String(254), nullable=False)
action: Mapped[str] = mapped_column(String(16), nullable=False)
occurred_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
class AdminUser(Base):
@ -44,12 +58,27 @@ 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)
provider: Mapped[str] = mapped_column(
String(32), default="aihub", server_default="aihub", nullable=False
)
base_url: Mapped[str | None] = mapped_column(String(2048))
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 AIProfileAuditEvent(Base):
__tablename__ = "ai_profile_audit_events"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
actor: Mapped[str] = mapped_column(String(254), nullable=False)
version: Mapped[str] = mapped_column(String(64), nullable=False)
provider: Mapped[str] = mapped_column(String(32), nullable=False)
occurred_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
class ScoringRun(Base):
__tablename__ = "scoring_runs"
__table_args__ = (UniqueConstraint("tenant_id", "request_id", name="uq_run_tenant_request"),)
@ -74,3 +103,18 @@ class ScoringRun(Base):
DateTime(timezone=True), server_default=func.now(), index=True
)
completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
class ScoringNotification(Base):
__tablename__ = "scoring_notifications"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4()))
run_id: Mapped[str] = mapped_column(
ForeignKey("scoring_runs.id", ondelete="CASCADE"), unique=True, nullable=False
)
tenant_id: Mapped[str] = mapped_column(String(128), nullable=False)
status: Mapped[str] = mapped_column(String(24), nullable=False)
attempts: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
next_attempt_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
delivered_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
dead: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)

View File

@ -0,0 +1,46 @@
from datetime import datetime
from sqlalchemy import delete, select
from sqlalchemy.ext.asyncio import AsyncSession
from ai_platform.infrastructure.persistence.models import ScoringNotification, ScoringRun
from ai_platform.lead_scoring.application.notifications import Notification
class SqlAlchemyNotificationRepository:
def __init__(self, db: AsyncSession) -> None:
self._db = db
async def next_due(self, now: datetime) -> Notification | None:
row = await self._db.scalar(
select(ScoringNotification)
.where(
ScoringNotification.dead.is_(False),
ScoringNotification.delivered_at.is_(None),
ScoringNotification.next_attempt_at <= now,
)
.order_by(ScoringNotification.next_attempt_at, ScoringNotification.created_at)
.limit(1)
.with_for_update(skip_locked=True)
)
if row is None:
return None
return Notification(
row.id, row.run_id, row.tenant_id, row.status, row.created_at, row.attempts
)
async def delivered(self, event_id: str, now: datetime) -> None:
row = await self._db.get(ScoringNotification, event_id)
if row is not None:
row.attempts += 1
row.delivered_at = now
async def retry(self, event_id: str, attempts: int, next_at: datetime, dead: bool) -> None:
row = await self._db.get(ScoringNotification, event_id)
if row is not None:
row.attempts = attempts
row.next_attempt_at = next_at
row.dead = dead
async def delete_expired_runs(self, cutoff: datetime) -> None:
await self._db.execute(delete(ScoringRun).where(ScoringRun.created_at < cutoff))

View File

@ -0,0 +1,173 @@
from collections.abc import Sequence
from datetime import datetime
from typing import Any, cast
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from ai_platform.infrastructure.persistence.models import (
AIProfile,
AIProfileAuditEvent,
ScoringNotification,
ScoringRun,
)
from ai_platform.lead_scoring.application.repository import (
AIProfileRecord,
NewAIProfile,
ProviderName,
ScoringRepository,
ScoringRunConflict,
ScoringRunRecord,
)
def _to_profile(row: AIProfile) -> AIProfileRecord:
return AIProfileRecord(
version=row.version,
model=row.model,
system_prompt=row.system_prompt,
timeout_seconds=row.timeout_seconds,
limits=row.limits,
active=row.active,
provider=cast(ProviderName, row.provider),
base_url=row.base_url,
)
def _to_run(row: ScoringRun) -> ScoringRunRecord:
return ScoringRunRecord(
id=row.id,
request_id=row.request_id,
tenant_id=row.tenant_id,
status=row.status,
fingerprint=row.fingerprint,
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,
completed_at=row.completed_at,
request=row.request,
ai_confidence=row.ai_confidence,
ai_reasoning=row.ai_reasoning,
ai_evidence=row.ai_evidence,
)
class SqlAlchemyScoringRepository(ScoringRepository):
def __init__(self, db: AsyncSession) -> None:
self._db = db
async def find_by_request(self, tenant_id: str, request_id: str) -> ScoringRunRecord | None:
row = await self._db.scalar(
select(ScoringRun).where(
ScoringRun.tenant_id == tenant_id,
ScoringRun.request_id == request_id,
)
)
return _to_run(row) if row is not None else None
async def active_profile_version(self) -> str | None:
return await self._db.scalar(select(AIProfile.version).where(AIProfile.active.is_(True)))
async def create_run(
self,
tenant_id: str,
request_id: str,
fingerprint: str,
request: dict[str, Any],
profile_version: str,
) -> ScoringRunRecord:
row = ScoringRun(
tenant_id=tenant_id,
request_id=request_id,
fingerprint=fingerprint,
request=request,
profile_version=profile_version,
)
self._db.add(row)
try:
await self._db.flush()
except IntegrityError as exc:
raise ScoringRunConflict from exc
return _to_run(row)
async def mark_scheduling_failed(
self, run_id: str, completed_at: datetime, error_code: str
) -> None:
row = await self._db.get(ScoringRun, run_id)
if row is not None:
row.status = "failed"
row.completed_at = completed_at
row.error_code = error_code
self._db.add(
ScoringNotification(
run_id=row.id,
tenant_id=row.tenant_id,
status="failed",
next_attempt_at=completed_at,
created_at=completed_at,
)
)
async def find_tenant_run(self, tenant_id: str, run_id: str) -> ScoringRunRecord | None:
row = await self._db.scalar(
select(ScoringRun).where(
ScoringRun.id == run_id,
ScoringRun.tenant_id == tenant_id,
)
)
return _to_run(row) if row is not None else None
async def list_profiles(self) -> Sequence[AIProfileRecord]:
rows = await self._db.scalars(select(AIProfile).order_by(AIProfile.version))
return [_to_profile(row) for row in rows.all()]
async def find_profile(self, version: str) -> AIProfileRecord | None:
row = await self._db.get(AIProfile, version)
return _to_profile(row) if row is not None else None
async def activate_profile(self, profile: NewAIProfile) -> AIProfileRecord:
rows = (await self._db.scalars(select(AIProfile).with_for_update())).all()
active_profile = next((row for row in rows if row.version == profile.version), None)
for row in rows:
row.active = row.version == profile.version
if active_profile is None:
active_profile = AIProfile(
version=profile.version,
model=profile.model,
provider=profile.provider,
base_url=profile.base_url,
system_prompt=profile.system_prompt,
timeout_seconds=profile.timeout_seconds,
limits=profile.limits,
active=True,
)
self._db.add(active_profile)
await self._db.flush()
return _to_profile(active_profile)
async def audit_profile_activation(
self, actor: str, version: str, provider: ProviderName, occurred_at: datetime
) -> None:
self._db.add(
AIProfileAuditEvent(
actor=actor, version=version, provider=provider, occurred_at=occurred_at
)
)
await self._db.flush()
async def list_recent_runs(self, limit: int) -> Sequence[ScoringRunRecord]:
rows = await self._db.scalars(
select(ScoringRun).order_by(ScoringRun.created_at.desc()).limit(limit)
)
return [_to_run(row) for row in rows.all()]
async def find_run(self, run_id: str) -> ScoringRunRecord | None:
row = await self._db.get(ScoringRun, run_id)
return _to_run(row) if row is not None else None

View File

@ -0,0 +1,124 @@
from datetime import datetime
from typing import cast
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from ai_platform.infrastructure.persistence.models import AIProfile, ScoringNotification, ScoringRun
from ai_platform.lead_scoring.application.repository import AIProfileRecord, ProviderName
from ai_platform.lead_scoring.application.scoring_execution_repository import (
ScoringExecutionRepository,
ScoringExecutionRun,
ScoringResult,
)
class SqlAlchemyScoringExecutionRepository(ScoringExecutionRepository):
def __init__(self, db: AsyncSession) -> None:
self._db = db
async def find_run(self, run_id: str) -> ScoringExecutionRun | None:
row = await self._db.get(ScoringRun, run_id)
if row is None:
return None
return ScoringExecutionRun(
id=row.id,
status=row.status,
request=row.request,
profile_version=row.profile_version,
)
async def find_profile(self, version: str) -> AIProfileRecord | None:
row = await self._db.get(AIProfile, version)
if row is None:
return None
return AIProfileRecord(
version=row.version,
model=row.model,
system_prompt=row.system_prompt,
timeout_seconds=row.timeout_seconds,
limits=row.limits,
active=row.active,
provider=cast(ProviderName, row.provider),
base_url=row.base_url,
)
async def mark_running(self, run_id: str) -> None:
row = await self._db.get(ScoringRun, run_id)
if row is not None:
row.status = "running"
async def complete(self, run_id: str, result: ScoringResult, completed_at: datetime) -> None:
row = await self._db.get(ScoringRun, run_id)
if row is None:
return
row.status = "completed"
row.rule_score = result.rule_score
row.breakdown = result.breakdown
row.ai_score = result.ai_score
row.ai_confidence = result.ai_confidence
row.ai_reasoning = result.ai_reasoning
row.ai_evidence = result.ai_evidence
row.final_score = result.final_score
row.degraded = result.degraded
row.result_mode = result.result_mode
row.error_code = result.error_code
row.completed_at = completed_at
self._db.add(
ScoringNotification(
run_id=row.id,
tenant_id=row.tenant_id,
status="completed",
next_attempt_at=completed_at,
created_at=completed_at,
)
)
async def fail(self, run_id: str, completed_at: datetime, error_code: str) -> None:
row = await self._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 = error_code
row.result_mode = None
row.completed_at = completed_at
self._db.add(
ScoringNotification(
run_id=row.id,
tenant_id=row.tenant_id,
status="failed",
next_attempt_at=completed_at,
created_at=completed_at,
)
)
async def mark_interrupted(self, completed_at: datetime) -> None:
rows = (
await self._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 = completed_at
self._db.add(
ScoringNotification(
run_id=row.id,
tenant_id=row.tenant_id,
status="failed",
next_attempt_at=completed_at,
created_at=completed_at,
)
)

View File

@ -0,0 +1,70 @@
from collections.abc import Sequence
from datetime import UTC, datetime
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from ai_platform.infrastructure.persistence.models import TenantKey, TenantKeyAuditEvent
from ai_platform.platform.application.tenant_authentication import TenantAuthenticator
from ai_platform.platform.application.tenant_key_repository import (
TenantKeyConflict,
TenantKeyNotFound,
TenantKeyRecord,
TenantKeyRepository,
)
def _to_record(row: TenantKey) -> TenantKeyRecord:
return TenantKeyRecord(
tenant_id=row.tenant_id,
active=row.active,
created_at=row.created_at,
updated_at=row.updated_at,
)
class SqlAlchemyTenantKeyRepository(TenantKeyRepository, TenantAuthenticator):
def __init__(self, db: AsyncSession) -> None:
self._db = db
async def list(self) -> Sequence[TenantKeyRecord]:
rows = await self._db.scalars(select(TenantKey).order_by(TenantKey.tenant_id))
return [_to_record(row) for row in rows.all()]
async def active_tenant_for_verifier(self, verifier: str) -> str | None:
return await self._db.scalar(
select(TenantKey.tenant_id).where(
TenantKey.verifier == verifier,
TenantKey.active.is_(True),
)
)
async def create(self, tenant_id: str, verifier: str, actor: str) -> None:
if await self._db.get(TenantKey, tenant_id) is not None:
raise TenantKeyConflict
self._db.add(TenantKey(tenant_id=tenant_id, verifier=verifier))
self._db.add(TenantKeyAuditEvent(tenant_id=tenant_id, actor=actor, action="create"))
try:
await self._db.flush()
except IntegrityError as exc:
raise TenantKeyConflict from exc
async def rotate(self, tenant_id: str, verifier: str, actor: str) -> TenantKeyRecord:
row = await self._db.get(TenantKey, tenant_id, with_for_update=True)
if row is None:
raise TenantKeyNotFound
row.verifier = verifier
row.active = True
row.updated_at = datetime.now(UTC)
self._db.add(TenantKeyAuditEvent(tenant_id=tenant_id, actor=actor, action="rotate"))
return _to_record(row)
async def revoke(self, tenant_id: str, actor: str) -> TenantKeyRecord:
row = await self._db.get(TenantKey, tenant_id, with_for_update=True)
if row is None:
raise TenantKeyNotFound
row.active = False
row.updated_at = datetime.now(UTC)
self._db.add(TenantKeyAuditEvent(tenant_id=tenant_id, actor=actor, action="revoke"))
return _to_record(row)

View File

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

View File

@ -0,0 +1,49 @@
import hashlib
import hmac
import json
from collections.abc import Callable
from datetime import datetime
import httpx
from ai_platform.lead_scoring.application.notifications import Notification
class OmniWebhook:
def __init__(
self, client: httpx.AsyncClient, url: str, secret: str, clock: Callable[[], datetime]
) -> None:
self._client = client
self._url = url
self._secret = secret.encode()
self._clock = clock
async def send(self, event: Notification) -> bool:
timestamp = self._clock().isoformat()
body = json.dumps(
{
"event_id": event.id,
"run_id": event.run_id,
"tenant_id": event.tenant_id,
"status": event.status,
"occurred_at": event.created_at.isoformat(),
},
separators=(",", ":"),
).encode()
signature = hmac.new(
self._secret, timestamp.encode() + b"." + body, hashlib.sha256
).hexdigest()
try:
response = await self._client.post(
self._url,
content=body,
headers={
"Content-Type": "application/json",
"X-Webhook-Timestamp": timestamp,
"X-Webhook-Signature": f"sha256={signature}",
},
timeout=5,
)
return 200 <= response.status_code < 300
except httpx.RequestError:
return False

View File

@ -0,0 +1,110 @@
from collections.abc import Awaitable, Callable
from dataclasses import dataclass, replace
from datetime import datetime
from urllib.parse import urlsplit
from ai_platform.lead_scoring.application.repository import (
AIProfileRecord,
NewAIProfile,
ScoringRepository,
ScoringRunRecord,
)
from ai_platform.lead_scoring.application.run_use_cases import (
ScoringRunDetail,
ScoringRunNotFound,
to_run_detail,
)
class AIProfileVersionConflict(Exception):
pass
class AIProviderUnavailable(Exception):
pass
class AIProviderURLInvalid(Exception):
pass
def openai_url_allowed(url: str | None, environment: str) -> bool:
if not url or len(url) > 2048 or url != url.strip():
return False
try:
parsed = urlsplit(url)
if parsed.port == 0:
return False
except ValueError:
return False
if (
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
or parsed.path not in {"", "/"}
or any(char.isspace() for char in url)
):
return False
if environment != "development":
return parsed.scheme == "https"
return parsed.scheme == "https" or parsed.hostname in {"localhost", "127.0.0.1", "::1"}
TransactionAction = Callable[[], Awaitable[None]]
@dataclass(frozen=True, slots=True)
class AdminScoringUseCases:
repository: ScoringRepository
commit: TransactionAction
rollback: TransactionAction
available_providers: frozenset[str]
clock: Callable[[], datetime]
openai_base_url: str | None = None
environment: str = "development"
async def list_profiles(self) -> list[AIProfileRecord]:
return list(await self.repository.list_profiles())
async def activate_profile(self, profile: NewAIProfile, actor: str) -> AIProfileRecord:
try:
if profile.provider not in self.available_providers:
raise AIProviderUnavailable
if profile.provider == "openai_compatible":
url = profile.base_url or self.openai_base_url
if not openai_url_allowed(url, self.environment):
raise AIProviderURLInvalid
profile = replace(profile, base_url=url)
elif profile.base_url is not None:
raise AIProviderURLInvalid
existing = await self.repository.find_profile(profile.version)
if existing is not None and (
existing.provider != profile.provider
or existing.base_url != profile.base_url
or existing.model != profile.model
or existing.system_prompt != profile.system_prompt
or existing.timeout_seconds != profile.timeout_seconds
or existing.limits != profile.limits
):
raise AIProfileVersionConflict
result = await self.repository.activate_profile(profile)
await self.repository.audit_profile_activation(
actor, result.version, result.provider, self.clock()
)
await self.commit()
except BaseException:
await self.rollback()
raise
return result
async def list_runs(self) -> list[ScoringRunRecord]:
return list(await self.repository.list_recent_runs(limit=50))
async def get_run(self, run_id: str) -> ScoringRunDetail:
run = await self.repository.find_run(run_id)
if run is None:
raise ScoringRunNotFound
return to_run_detail(run)

View File

@ -0,0 +1,64 @@
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from datetime import datetime, timedelta
from typing import Protocol
@dataclass(frozen=True, slots=True)
class Notification:
id: str
run_id: str
tenant_id: str
status: str
created_at: datetime
attempts: int
class NotificationRepository(Protocol):
async def next_due(self, now: datetime) -> Notification | None: ...
async def delivered(self, event_id: str, now: datetime) -> None: ...
async def retry(self, event_id: str, attempts: int, next_at: datetime, dead: bool) -> None: ...
async def delete_expired_runs(self, cutoff: datetime) -> None: ...
@dataclass(frozen=True, slots=True)
class NotificationUseCases:
repository: NotificationRepository
send: Callable[[Notification], Awaitable[bool]]
commit: Callable[[], Awaitable[None]]
rollback: Callable[[], Awaitable[None]]
clock: Callable[[], datetime]
async def dispatch_one(self) -> bool:
try:
now = self.clock()
event = await self.repository.next_due(now)
if event is None:
await self.rollback()
return False
if await self.send(event):
await self.repository.delivered(event.id, self.clock())
else:
attempts = event.attempts + 1
await self.repository.retry(
event.id,
attempts,
self.clock() + timedelta(seconds=min(60 * 2 ** (attempts - 1), 3600)),
attempts >= 5,
)
await self.commit()
return True
except BaseException:
await self.rollback()
raise
async def delete_expired(self) -> None:
try:
await self.repository.delete_expired_runs(self.clock() - timedelta(days=90))
await self.commit()
except BaseException:
await self.rollback()
raise

View File

@ -0,0 +1,91 @@
from collections.abc import Sequence
from dataclasses import dataclass
from datetime import datetime
from typing import Any, Literal, Protocol
ProviderName = Literal["aihub", "openai_compatible"]
class ScoringRunConflict(Exception):
pass
@dataclass(frozen=True, slots=True)
class AIProfileRecord:
version: str
model: str
system_prompt: str
timeout_seconds: int
limits: dict[str, Any]
active: bool
provider: ProviderName = "aihub"
base_url: str | None = None
@dataclass(frozen=True, slots=True)
class NewAIProfile:
version: str
model: str
system_prompt: str
timeout_seconds: int
limits: dict[str, Any]
provider: ProviderName = "aihub"
base_url: str | None = None
@dataclass(frozen=True, slots=True)
class ScoringRunRecord:
id: str
request_id: str
tenant_id: str
status: str
fingerprint: str
profile_version: str
rule_score: int | None
ai_score: int | None
final_score: int | None
degraded: bool
result_mode: str | None
error_code: str | None
breakdown: list[dict[str, Any]] | None
created_at: datetime | None
completed_at: datetime | None
request: dict[str, Any]
ai_confidence: float | None
ai_reasoning: str | None
ai_evidence: list[dict[str, Any]] | None
class ScoringRepository(Protocol):
async def find_by_request(self, tenant_id: str, request_id: str) -> ScoringRunRecord | None: ...
async def active_profile_version(self) -> str | None: ...
async def create_run(
self,
tenant_id: str,
request_id: str,
fingerprint: str,
request: dict[str, Any],
profile_version: str,
) -> ScoringRunRecord: ...
async def mark_scheduling_failed(
self, run_id: str, completed_at: datetime, error_code: str
) -> None: ...
async def find_tenant_run(self, tenant_id: str, run_id: str) -> ScoringRunRecord | None: ...
async def list_profiles(self) -> Sequence[AIProfileRecord]: ...
async def find_profile(self, version: str) -> AIProfileRecord | None: ...
async def activate_profile(self, profile: NewAIProfile) -> AIProfileRecord: ...
async def audit_profile_activation(
self, actor: str, version: str, provider: ProviderName, occurred_at: datetime
) -> None: ...
async def list_recent_runs(self, limit: int) -> Sequence[ScoringRunRecord]: ...
async def find_run(self, run_id: str) -> ScoringRunRecord | None: ...

View File

@ -0,0 +1,126 @@
import hashlib
import json
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from datetime import datetime
from typing import Any
from ai_platform.lead_scoring.application.repository import (
ScoringRepository,
ScoringRunConflict,
ScoringRunRecord,
)
from ai_platform.lead_scoring.domain.scoring import derive_metrics
class InvalidScoringPayload(Exception):
pass
class ScoringRunConflictError(Exception):
pass
class ScoringRunNotFound(Exception):
pass
class ScoringSchedulingFailed(Exception):
pass
@dataclass(frozen=True, slots=True)
class CreatedScoringRun:
run: ScoringRunRecord
replayed: bool
@dataclass(frozen=True, slots=True)
class ScoringRunDetail:
run: ScoringRunRecord
snapshot: dict[str, Any]
metrics: dict[str, Any]
Clock = Callable[[], datetime]
TransactionAction = Callable[[], Awaitable[None]]
RunScheduler = Callable[[str, str | None], None]
def canonical(value: object) -> bytes:
return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode()
def _to_detail(run: ScoringRunRecord) -> ScoringRunDetail:
snapshot = run.request["snapshot"]
return ScoringRunDetail(
run=run,
snapshot=snapshot,
metrics=derive_metrics(snapshot),
)
@dataclass(frozen=True, slots=True)
class ScoringUseCases:
repository: ScoringRepository
commit: TransactionAction
rollback: TransactionAction
schedule: RunScheduler
clock: Clock
async def create_run(
self,
tenant_id: str,
payload: dict[str, Any],
raw_vkey: str | None,
) -> CreatedScoringRun:
canonical_body = canonical(payload)
if raw_vkey and raw_vkey.encode() in canonical_body:
raise InvalidScoringPayload
fingerprint = hashlib.sha256(canonical_body).hexdigest()
request_id = str(payload["request_id"])
existing = await self.repository.find_by_request(tenant_id, request_id)
if existing is not None:
if existing.fingerprint != fingerprint:
raise ScoringRunConflictError
return CreatedScoringRun(existing, replayed=True)
profile_version = await self.repository.active_profile_version() or "unconfigured"
try:
run = await self.repository.create_run(
tenant_id, request_id, fingerprint, payload, profile_version
)
await self.commit()
except ScoringRunConflict as exc:
await self.rollback()
winner = await self.repository.find_by_request(tenant_id, request_id)
if winner is not None and winner.fingerprint == fingerprint:
return CreatedScoringRun(winner, replayed=True)
raise ScoringRunConflictError from exc
except BaseException:
await self.rollback()
raise
try:
self.schedule(run.id, raw_vkey)
except RuntimeError as exc:
try:
await self.repository.mark_scheduling_failed(
run.id, self.clock(), "execution_failed"
)
await self.commit()
except BaseException:
await self.rollback()
raise
raise ScoringSchedulingFailed from exc
return CreatedScoringRun(run, replayed=False)
async def get_run(self, tenant_id: str, run_id: str) -> ScoringRunDetail:
run = await self.repository.find_tenant_run(tenant_id, run_id)
if run is None:
raise ScoringRunNotFound
return _to_detail(run)
def to_run_detail(run: ScoringRunRecord) -> ScoringRunDetail:
return _to_detail(run)

View File

@ -0,0 +1,165 @@
import json
from collections.abc import Awaitable, Callable, Mapping
from dataclasses import dataclass
from datetime import datetime
from typing import Any
from ai_platform.lead_scoring.application.scoring import calculate_run
from ai_platform.lead_scoring.application.scoring_execution_repository import (
AIProviderFailure,
ScoringAIProvider,
ScoringExecutionRepository,
ScoringResult,
)
from ai_platform.lead_scoring.application.scoring_prompts import (
scoring_system_prompt,
scoring_user_prompt,
)
TransactionAction = Callable[[], Awaitable[None]]
Clock = Callable[[], datetime]
def _fallback(rule_score: int, breakdown: list[dict[str, Any]], error_code: str) -> ScoringResult:
return ScoringResult(
rule_score=rule_score,
breakdown=breakdown,
ai_score=None,
ai_confidence=None,
ai_reasoning=None,
ai_evidence=None,
final_score=rule_score,
degraded=True,
result_mode="rules_only_fallback",
error_code=error_code,
)
@dataclass(frozen=True, slots=True)
class ScoringExecutionUseCases:
repository: ScoringExecutionRepository
providers: Mapping[str, ScoringAIProvider]
commit: TransactionAction
rollback: TransactionAction
clock: Clock
provider_for_url: Callable[[str], ScoringAIProvider] | None = None
async def execute(self, run_id: str, raw_vkey: str | None) -> None:
try:
run = await self.repository.find_run(run_id)
if run is None or run.status != "pending":
return
await self.repository.mark_running(run_id)
await self.commit()
result = await self._result_for(run.request, run.profile_version, raw_vkey)
await self.repository.complete(run_id, result, self.clock())
await self.commit()
except Exception: # noqa: BLE001
await self._mark_failed(run_id)
async def recover_interrupted(self) -> None:
try:
await self.repository.mark_interrupted(self.clock())
await self.commit()
except BaseException:
await self.rollback()
raise
async def _result_for(
self,
request: dict[str, Any],
profile_version: str,
raw_vkey: str | None,
) -> ScoringResult:
rule_score, breakdown = calculate_run(request)
scoring_config = request["scoring_config"]
if scoring_config["ai_weight"] == 0:
return ScoringResult(
rule_score=rule_score,
breakdown=breakdown,
ai_score=None,
ai_confidence=None,
ai_reasoning=None,
ai_evidence=None,
final_score=rule_score,
degraded=False,
result_mode="rules_only",
error_code=None,
)
if request["ai_profile"] != "medium":
return _fallback(rule_score, breakdown, "ai_profile_deferred")
profile = await self.repository.find_profile(profile_version)
if profile is None:
return _fallback(rule_score, breakdown, "ai_profile_missing")
if profile.provider == "aihub":
if raw_vkey is None or not raw_vkey.strip():
return _fallback(rule_score, breakdown, "vkey_missing")
if len(raw_vkey) > 4_096 or "\r" in raw_vkey or "\n" in raw_vkey:
return _fallback(rule_score, breakdown, "vkey_invalid")
provider = self.providers.get(profile.provider)
if profile.provider == "openai_compatible" and profile.base_url is not None:
if self.provider_for_url is None:
return _fallback(rule_score, breakdown, "openai_compatible_not_configured")
try:
provider = self.provider_for_url(profile.base_url)
except AIProviderFailure as exc:
return _fallback(rule_score, breakdown, exc.error_code)
if provider is None:
return _fallback(rule_score, breakdown, f"{profile.provider}_not_configured")
snapshot = request["snapshot"]
if (
profile.provider == "aihub"
and raw_vkey
and (
raw_vkey in json.dumps(snapshot, ensure_ascii=False)
or raw_vkey in profile.system_prompt
)
):
return _fallback(rule_score, breakdown, "vkey_prompt_conflict")
await self.commit()
try:
evidence = await provider.extract_evidence(
model=profile.model,
snapshot=snapshot,
vkey=raw_vkey if profile.provider == "aihub" and raw_vkey else "",
timeout=profile.timeout_seconds,
)
score = await provider.score(
model=profile.model,
system_prompt=scoring_system_prompt(profile.system_prompt),
user_prompt=scoring_user_prompt(snapshot, evidence),
vkey=raw_vkey if profile.provider == "aihub" and raw_vkey else "",
timeout=profile.timeout_seconds,
)
except AIProviderFailure as exc:
return _fallback(rule_score, breakdown, exc.error_code)
final_score = round(
rule_score * scoring_config["rule_weight"] + score.score * scoring_config["ai_weight"]
)
return ScoringResult(
rule_score=rule_score,
breakdown=breakdown,
ai_score=score.score,
ai_confidence=score.confidence,
ai_reasoning=score.reasoning,
ai_evidence=[
{"signal": item.signal, "detail": item.detail, "source": item.source}
for item in evidence
],
final_score=max(0, min(100, final_score)),
degraded=False,
result_mode="hybrid",
error_code=None,
)
async def _mark_failed(self, run_id: str) -> None:
try:
await self.rollback()
await self.repository.fail(run_id, self.clock(), "execution_failed")
await self.commit()
except Exception: # noqa: BLE001
await self.rollback()

View File

@ -0,0 +1,85 @@
from collections.abc import Sequence
from dataclasses import dataclass
from datetime import datetime
from typing import Any, Protocol
from ai_platform.lead_scoring.application.repository import AIProfileRecord
@dataclass(frozen=True, slots=True)
class ScoringExecutionRun:
id: str
status: str
request: dict[str, Any]
profile_version: str
@dataclass(frozen=True, slots=True)
class ScoringResult:
rule_score: int
breakdown: list[dict[str, Any]]
ai_score: int | None
ai_confidence: float | None
ai_reasoning: str | None
ai_evidence: list[dict[str, str]] | None
final_score: int
degraded: bool
result_mode: str
error_code: str | None
@dataclass(frozen=True, slots=True)
class Evidence:
signal: str
detail: str
source: str
@dataclass(frozen=True, slots=True)
class Scoring:
score: int
confidence: float
reasoning: str
class AIProviderFailure(Exception):
def __init__(self, error_code: str) -> None:
super().__init__(error_code)
self.error_code = error_code
class ScoringAIProvider(Protocol):
async def extract_evidence(
self,
*,
model: str,
snapshot: dict[str, Any],
vkey: str,
timeout: float,
) -> Sequence[Evidence]: ...
async def score(
self,
*,
model: str,
system_prompt: str,
user_prompt: str,
vkey: str,
timeout: float,
) -> Scoring: ...
class ScoringExecutionRepository(Protocol):
async def find_run(self, run_id: str) -> ScoringExecutionRun | None: ...
async def find_profile(self, version: str) -> AIProfileRecord | None: ...
async def mark_running(self, run_id: str) -> None: ...
async def complete(
self, run_id: str, result: ScoringResult, completed_at: datetime
) -> None: ...
async def fail(self, run_id: str, completed_at: datetime, error_code: str) -> None: ...
async def mark_interrupted(self, completed_at: datetime) -> None: ...

View File

@ -0,0 +1,28 @@
import json
from collections.abc import Sequence
from typing import Any
from ai_platform.lead_scoring.application.scoring_execution_repository import Evidence
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: Sequence[Evidence]) -> str:
evidence_payload = [
{"signal": item.signal, "detail": item.detail, "source": item.source} for item in evidence
]
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_payload, ensure_ascii=False, separators=(",", ":"))
)

View File

@ -0,0 +1,95 @@
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from datetime import datetime, timedelta
from typing import Protocol
from ai_platform.platform.security import digest
@dataclass(frozen=True, slots=True)
class AdminUserRecord:
email: str
password_hash: str
@dataclass(frozen=True, slots=True)
class AdminSessionRecord:
id: str
admin_email: str
csrf_hash: str
expires_at: datetime
@dataclass(frozen=True, slots=True)
class AdminLoginResult:
email: str
session_token: str
csrf_token: str
expires_in: int
class AdminSessionRepository(Protocol):
async def find_user(self, email: str) -> AdminUserRecord | None: ...
async def find(self, session_id: str) -> AdminSessionRecord | None: ...
async def create(self, session: AdminSessionRecord) -> None: ...
async def delete(self, session_id: str) -> None: ...
PasswordVerifier = Callable[[str, str], bool]
TokenGenerator = Callable[[], str]
Clock = Callable[[], datetime]
TransactionAction = Callable[[], Awaitable[None]]
@dataclass(frozen=True, slots=True)
class AdminSessionUseCases:
repository: AdminSessionRepository
verify_password: PasswordVerifier
commit: TransactionAction
rollback: TransactionAction
token_generator: TokenGenerator
clock: Clock
async def login(self, email: str, password: str, ttl_seconds: int) -> AdminLoginResult | None:
normalized_email = email.strip().lower()
user = await self.repository.find_user(normalized_email)
if user is None or not self.verify_password(password, user.password_hash):
return None
session_token = self.token_generator()
csrf_token = self.token_generator()
session = AdminSessionRecord(
id=digest(session_token),
admin_email=user.email,
csrf_hash=digest(csrf_token),
expires_at=self.clock() + timedelta(seconds=ttl_seconds),
)
try:
await self.repository.create(session)
await self.commit()
except BaseException:
await self.rollback()
raise
return AdminLoginResult(
email=user.email,
session_token=session_token,
csrf_token=csrf_token,
expires_in=ttl_seconds,
)
async def find(self, session_token: str) -> AdminSessionRecord | None:
session = await self.repository.find(digest(session_token))
if session is None or session.expires_at <= self.clock():
return None
return session
async def logout(self, session_id: str) -> None:
try:
await self.repository.delete(session_id)
await self.commit()
except BaseException:
await self.rollback()
raise

View File

@ -0,0 +1,43 @@
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from typing import Protocol
class AdminUserAlreadyExists(Exception):
pass
class AdminUserRepository(Protocol):
async def exists(self, email: str) -> bool: ...
async def create(self, email: str, password_hash: str) -> None: ...
PasswordHasher = Callable[[str], str]
TransactionAction = Callable[[], Awaitable[None]]
@dataclass(frozen=True, slots=True)
class AdminUserUseCases:
repository: AdminUserRepository
hash_password: PasswordHasher
commit: TransactionAction
rollback: TransactionAction
async def create(self, email: str, password: str) -> None:
normalized_email = email.strip().lower()
if not normalized_email or not password:
raise ValueError("admin email and password must not be empty")
if await self.repository.exists(normalized_email):
raise AdminUserAlreadyExists
password_hash = self.hash_password(password)
try:
await self.repository.create(normalized_email, password_hash)
await self.commit()
except AdminUserAlreadyExists:
await self.rollback()
raise
except BaseException:
await self.rollback()
raise

View File

@ -0,0 +1,16 @@
from dataclasses import dataclass
from typing import Protocol
from ai_platform.platform.security import digest
class TenantAuthenticator(Protocol):
async def active_tenant_for_verifier(self, verifier: str) -> str | None: ...
@dataclass(frozen=True, slots=True)
class TenantAuthenticationUseCases:
authenticator: TenantAuthenticator
async def authenticate(self, raw_key: str) -> str | None:
return await self.authenticator.active_tenant_for_verifier(digest(raw_key))

View File

@ -0,0 +1,30 @@
from collections.abc import Sequence
from dataclasses import dataclass
from datetime import datetime
from typing import Protocol
class TenantKeyConflict(Exception):
pass
class TenantKeyNotFound(Exception):
pass
@dataclass(frozen=True, slots=True)
class TenantKeyRecord:
tenant_id: str
active: bool
created_at: datetime
updated_at: datetime
class TenantKeyRepository(Protocol):
async def list(self) -> Sequence[TenantKeyRecord]: ...
async def create(self, tenant_id: str, verifier: str, actor: str) -> None: ...
async def rotate(self, tenant_id: str, verifier: str, actor: str) -> TenantKeyRecord: ...
async def revoke(self, tenant_id: str, actor: str) -> TenantKeyRecord: ...

View File

@ -0,0 +1,106 @@
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from ai_platform.platform.application.tenant_key_repository import (
TenantKeyRecord,
TenantKeyRepository,
)
from ai_platform.platform.security import digest
TransactionAction = Callable[[], Awaitable[None]]
TenantKeyGenerator = Callable[[], str]
async def create_tenant_key(
repository: TenantKeyRepository,
tenant_id: str,
actor: str,
*,
key_generator: TenantKeyGenerator,
commit: TransactionAction,
rollback: TransactionAction,
) -> str:
raw = key_generator()
try:
await repository.create(tenant_id, digest(raw), actor)
await commit()
except BaseException:
await rollback()
raise
return raw
async def rotate_tenant_key(
repository: TenantKeyRepository,
tenant_id: str,
actor: str,
*,
key_generator: TenantKeyGenerator,
commit: TransactionAction,
rollback: TransactionAction,
) -> str:
raw = key_generator()
try:
await repository.rotate(tenant_id, digest(raw), actor)
await commit()
except BaseException:
await rollback()
raise
return raw
async def revoke_tenant_key(
repository: TenantKeyRepository,
tenant_id: str,
actor: str,
*,
commit: TransactionAction,
rollback: TransactionAction,
) -> TenantKeyRecord:
try:
row = await repository.revoke(tenant_id, actor)
await commit()
except BaseException:
await rollback()
raise
return row
@dataclass(frozen=True, slots=True)
class TenantKeyUseCases:
repository: TenantKeyRepository
commit: TransactionAction
rollback: TransactionAction
key_generator: TenantKeyGenerator
async def list_keys(self) -> list[TenantKeyRecord]:
return list(await self.repository.list())
async def create(self, tenant_id: str, actor: str) -> str:
return await create_tenant_key(
self.repository,
tenant_id,
actor,
key_generator=self.key_generator,
commit=self.commit,
rollback=self.rollback,
)
async def rotate(self, tenant_id: str, actor: str) -> str:
return await rotate_tenant_key(
self.repository,
tenant_id,
actor,
key_generator=self.key_generator,
commit=self.commit,
rollback=self.rollback,
)
async def revoke(self, tenant_id: str, actor: str) -> TenantKeyRecord:
return await revoke_tenant_key(
self.repository,
tenant_id,
actor,
commit=self.commit,
rollback=self.rollback,
)

View File

@ -0,0 +1,25 @@
import secrets
from fastapi import HTTPException, Request
from ai_platform.platform.application.admin_sessions import (
AdminSessionRecord,
AdminSessionUseCases,
)
from ai_platform.platform.security import digest
COOKIE = "ai_platform_session"
async def get_admin_session(request: Request, sessions: AdminSessionUseCases) -> AdminSessionRecord:
raw = request.cookies.get(COOKIE)
session = await sessions.find(raw) if raw else None
if session is None:
raise HTTPException(401, "Admin session expired")
return session
async def require_csrf(request: Request, session: AdminSessionRecord) -> 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")

View File

@ -1,156 +1,159 @@
import secrets
from datetime import UTC, datetime, timedelta
from collections.abc import Awaitable, Callable
from typing import Any
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.lead_scoring.application.admin import (
AdminScoringUseCases,
AIProfileVersionConflict,
AIProviderUnavailable,
AIProviderURLInvalid,
)
from ai_platform.infrastructure.security import digest
from ai_platform.presentation.http.dependencies import db_session
from ai_platform.lead_scoring.application.repository import AIProfileRecord, NewAIProfile
from ai_platform.lead_scoring.application.run_use_cases import ScoringRunNotFound
from ai_platform.platform.application.admin_sessions import AdminSessionUseCases
from ai_platform.presentation.http.admin_auth import COOKIE, get_admin_session, require_csrf
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:
def profile_json(row: AIProfileRecord) -> dict[str, Any]:
return {
"version": row.version,
"provider": row.provider,
"base_url": row.base_url,
"model": row.model,
"system_prompt": row.system_prompt,
"timeout_seconds": row.timeout_seconds,
"limits": row.limits,
"active": row.active,
}
def create_admin_router(
get_sessions: Callable[..., Awaitable[AdminSessionUseCases]],
get_scoring_use_cases: Callable[..., Awaitable[AdminScoringUseCases]],
) -> APIRouter:
router = APIRouter()
@router.post("/v1/admin/login")
async def login(
body: AdminLoginRequest,
request: Request,
response: Response,
sessions: AdminSessionUseCases = Depends(get_sessions),
):
ttl = request.app.state.settings.session_ttl_seconds
result = await sessions.login(body.email, body.password, ttl)
if result is None:
raise HTTPException(401, "Invalid email or password")
settings = request.app.state.settings
response.set_cookie(
COOKIE,
result.session_token,
httponly=True,
secure=settings.admin_cookie_secure or settings.environment == "production",
samesite="lax",
max_age=result.expires_in,
path="/",
)
return {
"email": result.email,
"csrf_token": result.csrf_token,
"expires_in": result.expires_in,
}
@router.get("/v1/admin/me")
async def me(
request: Request,
sessions: AdminSessionUseCases = Depends(get_sessions),
):
session = await get_admin_session(request, sessions)
return {"email": session.admin_email, "authenticated": True}
@router.post("/v1/admin/logout")
async def logout(
request: Request,
response: Response,
sessions: AdminSessionUseCases = Depends(get_sessions),
):
session = await get_admin_session(request, sessions)
await require_csrf(request, session)
await sessions.logout(session.id)
settings = request.app.state.settings
response.delete_cookie(
COOKIE,
path="/",
httponly=True,
secure=settings.admin_cookie_secure or settings.environment == "production",
samesite="lax",
)
return {"logged_out": True}
@router.get("/v1/admin/ai-profiles")
async def list_profiles(
request: Request,
sessions: AdminSessionUseCases = Depends(get_sessions),
use_cases: AdminScoringUseCases = Depends(get_scoring_use_cases),
):
await get_admin_session(request, sessions)
rows = await use_cases.list_profiles()
return {"profiles": [profile_json(row) for row in rows]}
@router.put("/v1/admin/ai-profiles")
async def create_profile(
body: AIProfileInput,
request: Request,
sessions: AdminSessionUseCases = Depends(get_sessions),
use_cases: AdminScoringUseCases = Depends(get_scoring_use_cases),
):
session = await get_admin_session(request, sessions)
await require_csrf(request, session)
try:
profile = NewAIProfile(
version=body.version,
provider=body.provider,
base_url=body.base_url,
model=body.model,
system_prompt=body.system_prompt,
timeout_seconds=body.timeout_seconds,
limits=body.limits,
)
profile = await use_cases.activate_profile(profile, actor=session.admin_email)
except AIProfileVersionConflict as exc:
raise HTTPException(409, "Profile versions are immutable; use a new version") from exc
except AIProviderUnavailable as exc:
raise HTTPException(
422, "Selected AI provider is not configured on this server"
) from exc
except AIProviderURLInvalid as exc:
raise HTTPException(422, "Selected provider URL is not allowed") from exc
return profile_json(profile)
@router.get("/v1/admin/runs", response_model=RunList)
async def list_runs(
request: Request,
sessions: AdminSessionUseCases = Depends(get_sessions),
use_cases: AdminScoringUseCases = Depends(get_scoring_use_cases),
):
await get_admin_session(request, sessions)
rows = await use_cases.list_runs()
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,
sessions: AdminSessionUseCases = Depends(get_sessions),
use_cases: AdminScoringUseCases = Depends(get_scoring_use_cases),
):
await get_admin_session(request, sessions)
try:
detail = await use_cases.get_run(str(run_id))
except ScoringRunNotFound as exc:
raise HTTPException(404, "Run not found") from exc
return to_detail_json(detail)
return router

View File

@ -0,0 +1,146 @@
import secrets
from collections.abc import Awaitable, Callable
from datetime import datetime
from typing import Annotated
from fastapi import APIRouter, Depends, Header, HTTPException, Path, Request, Response
from pydantic import BaseModel, ConfigDict, Field, field_validator
from ai_platform.platform.application.admin_sessions import AdminSessionUseCases
from ai_platform.platform.application.tenant_key_repository import (
TenantKeyConflict,
TenantKeyNotFound,
TenantKeyRecord,
)
from ai_platform.platform.application.tenant_keys import TenantKeyUseCases
from ai_platform.presentation.http.admin_auth import get_admin_session, require_csrf
class TenantKeyCreateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
tenant_id: str = Field(min_length=1, max_length=128)
@field_validator("tenant_id")
@classmethod
def tenant_id_is_not_blank(cls, value: str) -> str:
if not value.strip():
raise ValueError("tenant_id must not be blank")
return value
class TenantKeySummary(BaseModel):
model_config = ConfigDict(extra="forbid")
tenant_id: str
active: bool
created_at: datetime
updated_at: datetime
class TenantKeyList(BaseModel):
model_config = ConfigDict(extra="forbid")
tenants: list[TenantKeySummary]
class TenantKeySecret(BaseModel):
model_config = ConfigDict(extra="forbid")
tenant_id: str
tenant_key: str
def key_summary(row: TenantKeyRecord) -> TenantKeySummary:
return TenantKeySummary(
tenant_id=row.tenant_id,
active=row.active,
created_at=row.created_at,
updated_at=row.updated_at,
)
def create_tenant_key_router(
get_use_cases: Callable[..., Awaitable[TenantKeyUseCases]],
get_sessions: Callable[..., Awaitable[AdminSessionUseCases]],
) -> APIRouter:
router = APIRouter()
@router.get("/v1/admin/tenant-keys", response_model=TenantKeyList)
async def list_keys(
request: Request,
use_cases: TenantKeyUseCases = Depends(get_use_cases),
sessions: AdminSessionUseCases = Depends(get_sessions),
):
await get_admin_session(request, sessions)
rows = await use_cases.list_keys()
return TenantKeyList(tenants=[key_summary(row) for row in rows])
@router.post("/v1/admin/tenant-keys", status_code=201, response_model=TenantKeySecret)
async def create_key(
body: TenantKeyCreateRequest,
request: Request,
response: Response,
use_cases: TenantKeyUseCases = Depends(get_use_cases),
sessions: AdminSessionUseCases = Depends(get_sessions),
):
session = await get_admin_session(request, sessions)
await require_csrf(request, session)
try:
raw = await use_cases.create(body.tenant_id, session.admin_email)
except TenantKeyConflict as exc:
raise HTTPException(409, "Tenant key already provisioned") from exc
response.headers["Cache-Control"] = "no-store"
return TenantKeySecret(tenant_id=body.tenant_id, tenant_key=raw)
@router.post("/v1/admin/tenant-keys/{tenant_id:path}/rotate", response_model=TenantKeySecret)
async def rotate_key(
tenant_id: Annotated[str, Path(min_length=1, max_length=128)],
request: Request,
response: Response,
use_cases: TenantKeyUseCases = Depends(get_use_cases),
sessions: AdminSessionUseCases = Depends(get_sessions),
):
session = await get_admin_session(request, sessions)
await require_csrf(request, session)
try:
raw = await use_cases.rotate(tenant_id, session.admin_email)
except TenantKeyNotFound as exc:
raise HTTPException(404, "Tenant key not found") from exc
response.headers["Cache-Control"] = "no-store"
return TenantKeySecret(tenant_id=tenant_id, tenant_key=raw)
@router.delete("/v1/admin/tenant-keys/{tenant_id:path}", response_model=TenantKeySummary)
async def revoke_key(
tenant_id: Annotated[str, Path(min_length=1, max_length=128)],
request: Request,
use_cases: TenantKeyUseCases = Depends(get_use_cases),
sessions: AdminSessionUseCases = Depends(get_sessions),
):
session = await get_admin_session(request, sessions)
await require_csrf(request, session)
try:
row = await use_cases.revoke(tenant_id, session.admin_email)
except TenantKeyNotFound as exc:
raise HTTPException(404, "Tenant key not found") from exc
return key_summary(row)
@router.post("/v1/platform/tenant-keys", status_code=201, response_model=TenantKeySecret)
async def provision_key(
request: Request,
body: TenantKeyCreateRequest,
response: Response,
authorization: str | None = Header(default=None),
use_cases: TenantKeyUseCases = Depends(get_use_cases),
):
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")
try:
raw = await use_cases.create(body.tenant_id, "provisioner")
except TenantKeyConflict as exc:
raise HTTPException(409, "Tenant key already provisioned") from exc
response.headers["Cache-Control"] = "no-store"
return TenantKeySecret(tenant_id=body.tenant_id, tenant_key=raw)
return router

View File

@ -1,31 +1,45 @@
import asyncio
import hashlib
import json
import secrets
from datetime import UTC, datetime
from collections.abc import Awaitable, Callable
from typing import Any
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 fastapi.routing import APIRoute
from starlette.responses import JSONResponse
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.lead_scoring.application.repository import ScoringRunRecord
from ai_platform.lead_scoring.application.run_use_cases import (
InvalidScoringPayload,
ScoringRunConflictError,
ScoringRunDetail,
ScoringRunNotFound,
ScoringSchedulingFailed,
ScoringUseCases,
)
from ai_platform.platform.application.tenant_authentication import TenantAuthenticationUseCases
from ai_platform.presentation.http.run_responses import RunDetail, RunSummary
from ai_platform.presentation.http.schemas import RunRequest
router = APIRouter()
_MAX_RUN_BODY_BYTES = 1_000_000
def canonical(value: object) -> bytes:
return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode()
class BoundedRunRoute(APIRoute):
def get_route_handler(self):
handler = super().get_route_handler()
async def bounded(request: Request):
if request.method == "POST" and request.url.path == "/v1/lead-scoring/runs":
body = bytearray()
async for chunk in request.stream():
body.extend(chunk)
if len(body) > _MAX_RUN_BODY_BYTES:
return JSONResponse({"detail": "Request body too large"}, status_code=413)
request._body = bytes(body)
return await handler(request)
return bounded
def to_json(row: ScoringRun) -> dict:
def to_json(row: ScoringRunRecord) -> dict[str, Any]:
return {
"run_id": row.id,
"request_id": row.request_id,
@ -44,156 +58,86 @@ def to_json(row: ScoringRun) -> dict:
}
def to_detail_json(row: ScoringRun) -> dict:
def to_detail_json(detail: ScoringRunDetail) -> dict[str, Any]:
run = detail.run
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,
**to_json(run),
"snapshot": detail.snapshot,
"metrics": detail.metrics,
"ai_confidence": run.ai_confidence,
"ai_reasoning": run.ai_reasoning,
"ai_evidence": run.ai_evidence,
}
async def tenant_id(
authorization: str | None = Header(default=None), db: AsyncSession = Depends(db_session)
) -> str:
def create_scoring_router(
get_tenant_keys: Callable[..., Awaitable[TenantAuthenticationUseCases]],
get_scoring_use_cases: Callable[..., Awaitable[ScoringUseCases]],
get_database_readiness: Callable[..., Awaitable[bool]],
) -> APIRouter:
router = APIRouter(route_class=BoundedRunRoute)
async def tenant_id(
authorization: str | None = Header(default=None),
use_cases: TenantAuthenticationUseCases = Depends(get_tenant_keys),
) -> 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:
tenant = await use_cases.authenticate(authorization[7:])
if tenant is None:
raise HTTPException(401, "Invalid tenant key")
return row.tenant_id
return tenant
@router.get("/health/live")
async def live():
@router.get("/health/live")
async def live() -> dict[str, str]:
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
@router.get("/health/ready")
async def ready(available: bool = Depends(get_database_readiness)) -> dict[str, str]:
if not available:
raise HTTPException(503, "Database unavailable")
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(
@router.post(
"/v1/lead-scoring/runs",
status_code=202,
response_model=RunSummary,
responses={200: {"model": RunSummary}},
)
async def create_run(
)
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)
idempotency_key: UUID = Header(alias="Idempotency-Key"),
use_cases: ScoringUseCases = Depends(get_scoring_use_cases),
):
if idempotency_key != body.request_id:
raise HTTPException(422, "Idempotency-Key must match request_id")
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()
result = await use_cases.create_run(tenant, body.model_dump(mode="json"), raw_vkey)
except InvalidScoringPayload as exc:
raise HTTPException(422, "V-Key must not appear in the scoring payload") from exc
except ScoringRunConflictError as exc:
raise HTTPException(409, "Request UUID already used with a different request") from exc
except ScoringSchedulingFailed as exc:
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)
response.status_code = 200 if result.replayed else 202
if not result.replayed:
response.headers["Location"] = f"/v1/lead-scoring/runs/{result.run.id}"
return to_json(result.run)
@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)
@router.get("/v1/lead-scoring/runs/{run_id}", response_model=RunDetail)
async def get_run(
run_id: UUID,
tenant: str = Depends(tenant_id),
use_cases: ScoringUseCases = Depends(get_scoring_use_cases),
):
try:
detail = await use_cases.get_run(tenant, str(run_id))
except ScoringRunNotFound as exc:
raise HTTPException(404, "Run not found") from exc
return to_detail_json(detail)
return router

View File

@ -135,6 +135,8 @@ class RunRequest(BaseModel):
class AIProfileInput(BaseModel):
model_config = ConfigDict(extra="forbid", frozen=True)
version: str = Field(min_length=1, max_length=64)
provider: Literal["aihub", "openai_compatible"] = "aihub"
base_url: str | None = Field(default=None, max_length=2048)
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)

View File

@ -4,7 +4,7 @@ This records adopted rebuild decisions, not proof that deployment or integration
- Backend: Python 3.13, uv, FastAPI/Pydantic at HTTP boundary, PostgreSQL 16+ with SQLAlchemy async + **asyncpg**, Alembic. Domain uses Python values and pure functions; no transport/ORM models inside it.
- Runtime: one backend process, ephemeral asyncio tasks; PostgreSQL owns run durability, unique idempotency and transactional outbox. Startup terminalizes lost work before readiness. No broker/Redis or distributed leases in v1.
- AIHub: V-Key supplied per request in RAM, Bearer forwarding, medium bounded two-pass when AI is required. Actual provider protocol/model/timeout and quotas require sandbox verification; no server-wide tenant V-Key.
- AIHub: V-Key supplied per request in RAM, Bearer forwarding, medium bounded two-pass when AI is required. Lead Scoring's versioned AI profile defaults to AIHub and may select a separate OpenAI-compatible adapter; its URL is stored with the version and its model comes from the profile. The optional server-side API key is sent only to the URL configured for that key; changing the URL in Admin Web does not forward that key. Only development permits loopback HTTP; production URLs require HTTPS. Actual provider protocol/model/timeout and quotas require sandbox verification; no server-wide tenant V-Key.
- Admin Web: SvelteKit 5, TypeScript 5, Bun, adapter-node after runtime verification; generated types from FastAPI OpenAPI, server-only session/backend access. DotB-Omni owns business UI.
- Tooling: pytest, Ruff, Pyright, Import Linter, Alembic lifecycle, Svelte check/Vitest and one browser flow; pre-commit opt-in, GitHub CI authoritative. uv/Bun lockfiles required. Dockerfiles separate for backend and web; Compose local only.
- `rewrite_architect.md` is a draft: its proposed psycopg/pnpm, key re-display, dynamic subscription and speculative catalog are **not** adopted. Tenant key raw is returned once; rotate if lost.

View File

@ -15,7 +15,7 @@ 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
from ai_platform.platform.security import digest
@pytest.fixture

View File

@ -0,0 +1,97 @@
from datetime import UTC, datetime, timedelta
from ai_platform.platform.application.admin_sessions import (
AdminSessionRecord,
AdminSessionUseCases,
AdminUserRecord,
)
from ai_platform.platform.security import digest
class FakeAdminSessionRepository:
def __init__(self) -> None:
self.user = AdminUserRecord("admin@example.invalid", "stored-hash")
self.sessions: dict[str, AdminSessionRecord] = {}
async def find_user(self, email: str) -> AdminUserRecord | None:
return self.user if email == self.user.email else None
async def find(self, session_id: str) -> AdminSessionRecord | None:
return self.sessions.get(session_id)
async def create(self, session: AdminSessionRecord) -> None:
self.sessions[session.id] = session
async def delete(self, session_id: str) -> None:
self.sessions.pop(session_id, None)
class Transaction:
def __init__(self) -> None:
self.commits = 0
self.rollbacks = 0
async def commit(self) -> None:
self.commits += 1
async def rollback(self) -> None:
self.rollbacks += 1
def make_use_cases(repository, transaction, tokens) -> AdminSessionUseCases:
return AdminSessionUseCases(
repository=repository,
verify_password=lambda password, stored: password == "correct" and stored == "stored-hash",
commit=transaction.commit,
rollback=transaction.rollback,
token_generator=lambda: next(tokens),
clock=lambda: datetime(2026, 9, 25, tzinfo=UTC),
)
async def test_admin_login_stores_only_token_digests_and_logout_commits():
repository = FakeAdminSessionRepository()
transaction = Transaction()
tokens = iter(("one-time-session-token", "one-time-csrf-token"))
use_cases = make_use_cases(repository, transaction, tokens)
login = await use_cases.login(" ADMIN@example.invalid ", "correct", 300)
assert login is not None
stored = repository.sessions[digest(login.session_token)]
assert stored.csrf_hash == digest(login.csrf_token)
assert login.session_token not in repository.sessions
assert transaction.commits == 1
authenticated = await use_cases.find(login.session_token)
assert authenticated == stored
await use_cases.logout(stored.id)
assert await use_cases.find(login.session_token) is None
assert transaction.commits == 2
assert transaction.rollbacks == 0
async def test_admin_login_rejects_invalid_credentials_without_mutating_session():
repository = FakeAdminSessionRepository()
transaction = Transaction()
use_cases = make_use_cases(repository, transaction, iter(("unused-1", "unused-2")))
assert await use_cases.login("admin@example.invalid", "wrong", 300) is None
assert repository.sessions == {}
assert transaction.commits == 0
assert transaction.rollbacks == 0
async def test_expired_admin_session_is_not_authenticated():
repository = FakeAdminSessionRepository()
now = datetime(2026, 9, 25, tzinfo=UTC)
repository.sessions["session-hash"] = AdminSessionRecord(
id="session-hash",
admin_email=repository.user.email,
csrf_hash="csrf-hash",
expires_at=now - timedelta(seconds=1),
)
transaction = Transaction()
use_cases = make_use_cases(repository, transaction, iter(()))
assert await use_cases.find("raw-session") is None

View File

@ -0,0 +1,80 @@
import pytest
from ai_platform.platform.application.admin_users import (
AdminUserAlreadyExists,
AdminUserUseCases,
)
class Repository:
def __init__(self, *, existing: bool = False, conflict_on_create: bool = False) -> None:
self.existing = existing
self.conflict_on_create = conflict_on_create
self.created: tuple[str, str] | None = None
async def exists(self, email: str) -> bool:
assert email == "admin@example.invalid"
return self.existing
async def create(self, email: str, password_hash: str) -> None:
if self.conflict_on_create:
raise AdminUserAlreadyExists
self.created = (email, password_hash)
class Transaction:
def __init__(self) -> None:
self.commits = 0
self.rollbacks = 0
async def commit(self) -> None:
self.commits += 1
async def rollback(self) -> None:
self.rollbacks += 1
def make_use_case(repository: Repository, transaction: Transaction) -> AdminUserUseCases:
return AdminUserUseCases(
repository=repository,
hash_password=lambda password: f"hashed:{password}",
commit=transaction.commit,
rollback=transaction.rollback,
)
async def test_create_normalizes_email_hashes_password_and_commits():
repository = Repository()
transaction = Transaction()
use_case = make_use_case(repository, transaction)
await use_case.create(" Admin@Example.Invalid ", "test-password")
assert repository.created == ("admin@example.invalid", "hashed:test-password")
assert transaction.commits == 1
assert transaction.rollbacks == 0
async def test_existing_admin_is_rejected_without_mutation():
repository = Repository(existing=True)
transaction = Transaction()
use_case = make_use_case(repository, transaction)
with pytest.raises(AdminUserAlreadyExists):
await use_case.create("admin@example.invalid", "test-password")
assert repository.created is None
assert transaction.commits == 0
assert transaction.rollbacks == 0
async def test_concurrent_duplicate_admin_creation_rolls_back():
repository = Repository(conflict_on_create=True)
transaction = Transaction()
use_case = make_use_case(repository, transaction)
with pytest.raises(AdminUserAlreadyExists):
await use_case.create("admin@example.invalid", "test-password")
assert transaction.commits == 0
assert transaction.rollbacks == 1

View File

@ -0,0 +1,124 @@
import asyncio
from urllib.parse import urlsplit
from uuid import uuid4
import pytest
from fastapi.testclient import TestClient
from pwdlib import PasswordHash
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 (
AdminSession,
AdminUser,
AIProfile,
AIProfileAuditEvent,
)
def test_admin_selects_provider_with_csrf_and_audit():
loaded = Settings.load()
parsed = urlsplit(loaded.database_url)
if parsed.path != "/ai_platform_test" or parsed.hostname not in {"localhost", "127.0.0.1"}:
pytest.skip("requires isolated local test database")
settings = Settings(
database_url=loaded.database_url,
environment="development",
aihub_base_url="http://localhost:8080",
openai_base_url="http://127.0.0.1:8317",
)
factory = make_session_factory(settings.database_url)
version = f"provider-admin-{uuid4().hex}"
email = f"provider-admin-{uuid4().hex}@example.invalid"
password = "test-only-password"
original_active: set[str] = set()
async def setup() -> None:
nonlocal original_active
async with factory() as db:
original_active = {
row.version for row in (await db.scalars(select(AIProfile))).all() if row.active
}
db.add(AdminUser(email=email, password_hash=PasswordHash.recommended().hash(password)))
await db.commit()
await factory.kw["bind"].dispose()
async def cleanup() -> None:
try:
async with factory() as db:
await db.execute(
delete(AIProfileAuditEvent).where(AIProfileAuditEvent.version == version)
)
await db.execute(delete(AIProfile).where(AIProfile.version == version))
await db.execute(delete(AdminSession).where(AdminSession.admin_email == email))
await db.execute(delete(AdminUser).where(AdminUser.email == email))
for row in (await db.scalars(select(AIProfile))).all():
row.active = row.version in original_active
await db.commit()
finally:
await factory.kw["bind"].dispose()
asyncio.run(setup())
try:
profile = {
"version": version,
"provider": "openai_compatible",
"base_url": "http://127.0.0.1:8317",
"model": "gemini-3.5-flash-lite",
"system_prompt": "Chỉ dùng dữ liệu có trong snapshot.",
"timeout_seconds": 30,
}
with TestClient(create_app(settings)) as client:
assert client.put("/v1/admin/ai-profiles", json=profile).status_code == 401
login = client.post("/v1/admin/login", json={"email": email, "password": password})
assert login.status_code == 200
assert client.put("/v1/admin/ai-profiles", json=profile).status_code == 403
csrf = {"x-csrf-token": login.json()["csrf_token"]}
assert (
client.put(
"/v1/admin/ai-profiles", json={**profile, "provider": "unknown"}, headers=csrf
).status_code
== 422
)
created = client.put("/v1/admin/ai-profiles", json=profile, headers=csrf)
assert created.status_code == 200
assert created.json()["provider"] == "openai_compatible"
assert created.json()["base_url"] == profile["base_url"]
assert (
client.put(
"/v1/admin/ai-profiles",
json={**profile, "base_url": "http://127.0.0.1:8318"},
headers=csrf,
).status_code
== 409
)
assert (
client.put(
"/v1/admin/ai-profiles",
json={**profile, "provider": "aihub", "base_url": None},
headers=csrf,
).status_code
== 409
)
listed = client.get("/v1/admin/ai-profiles")
assert listed.status_code == 200
assert (
next(p for p in listed.json()["profiles"] if p["version"] == version)["provider"]
== "openai_compatible"
)
assert "api_key" not in listed.text.lower()
async def verify() -> None:
async with factory() as db:
event = await db.scalar(
select(AIProfileAuditEvent).where(AIProfileAuditEvent.version == version)
)
assert event is not None
assert (event.actor, event.provider) == (email, "openai_compatible")
await factory.kw["bind"].dispose()
asyncio.run(verify())
finally:
asyncio.run(cleanup())

View File

@ -0,0 +1,78 @@
from datetime import UTC, datetime
from urllib.parse import urlsplit
from uuid import uuid4
import pytest
from sqlalchemy import delete, select
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, AIProfileAuditEvent
from ai_platform.infrastructure.persistence.scoring import SqlAlchemyScoringRepository
from ai_platform.lead_scoring.application.admin import AdminScoringUseCases
from ai_platform.lead_scoring.application.repository import NewAIProfile
@pytest.mark.asyncio
async def test_profile_provider_and_activation_audit_commit_together():
settings = Settings.load()
parsed = urlsplit(settings.database_url)
if parsed.path != "/ai_platform_test" or parsed.hostname not in {"localhost", "127.0.0.1"}:
pytest.skip("requires isolated local test database")
factory = make_session_factory(settings.database_url)
version = f"provider-audit-{uuid4().hex}"
actor = "test-admin@example.invalid"
occurred_at = datetime(2026, 9, 28, tzinfo=UTC)
original_active: set[str] = set()
try:
async with factory() as db:
profiles = (await db.scalars(select(AIProfile))).all()
original_active = {row.version for row in profiles if row.active}
cases = AdminScoringUseCases(
SqlAlchemyScoringRepository(db),
db.commit,
db.rollback,
frozenset({"openai_compatible"}),
lambda: occurred_at,
)
created = await cases.activate_profile(
NewAIProfile(
version,
"gemini-3.5-flash-lite",
"prompt",
30,
{},
"openai_compatible",
"http://127.0.0.1:8317",
),
actor,
)
assert created.provider == "openai_compatible"
async with factory() as db:
profile = await db.get(AIProfile, version)
assert (
profile is not None
and profile.active
and profile.provider == "openai_compatible"
and profile.base_url == "http://127.0.0.1:8317"
)
event = await db.scalar(
select(AIProfileAuditEvent).where(AIProfileAuditEvent.version == version)
)
assert event is not None
assert (event.actor, event.provider, event.occurred_at) == (
actor,
"openai_compatible",
occurred_at,
)
finally:
async with factory() as db:
await db.execute(
delete(AIProfileAuditEvent).where(AIProfileAuditEvent.version == version)
)
await db.execute(delete(AIProfile).where(AIProfile.version == version))
profiles = (await db.scalars(select(AIProfile))).all()
for row in profiles:
row.active = row.version in original_active
await db.commit()
await factory.kw["bind"].dispose()

View File

@ -7,12 +7,15 @@ import httpx
import pytest
from sqlalchemy import delete
from ai_platform.bootstrap.wiring import build_scoring_execution_use_cases
from ai_platform.infrastructure.aihub import AiHubClient
from ai_platform.infrastructure.aihub_scoring import AiHubScoringGateway
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.infrastructure.persistence.scoring import SqlAlchemyScoringRepository
from ai_platform.lead_scoring.application.run_use_cases import to_run_detail
from ai_platform.platform.security import digest
from ai_platform.presentation.http.routes import to_detail_json
from ai_platform.presentation.http.run_responses import RunDetail
@ -149,16 +152,21 @@ async def test_scoring_execution_persists_hybrid_fallback_and_failed_results(
http_client = httpx.AsyncClient(transport=httpx.MockTransport(handler))
aihub = AiHubClient("https://aihub.test", http_client)
try:
await execute_run(
factory,
async with factory() as db:
use_cases = build_scoring_execution_use_cases(
db,
{} if case == "missing_client" else {"aihub": AiHubScoringGateway(aihub)},
)
await use_cases.execute(
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))
record = await SqlAlchemyScoringRepository(db).find_run(run_id)
assert record is not None
detail = RunDetail.model_validate(to_detail_json(to_run_detail(record)))
serialized = detail.model_dump_json()
assert VKEY not in serialized
assert len(calls) == expected_calls

View File

@ -71,7 +71,7 @@ def test_medium_two_pass_scoring_persists_weighted_result(
with TestClient(app) as client:
created = client.post(
"/v1/lead-scoring/runs",
headers={**headers, "X-Tenant-VKey": vkey},
headers={**headers, "X-Tenant-VKey": vkey, "Idempotency-Key": payload["request_id"]},
json=payload,
)
assert created.status_code == 202
@ -115,7 +115,11 @@ def test_medium_two_pass_scoring_persists_weighted_result(
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)
fallback = client.post(
"/v1/lead-scoring/runs",
headers={**headers, "Idempotency-Key": fallback_payload["request_id"]},
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"

View File

@ -6,6 +6,63 @@ from ai_platform.infrastructure.config.settings import Settings
_TEST_DATABASE_URL = "postgresql+asyncpg://test:test@localhost/ai_platform_test"
@pytest.mark.parametrize(
"base_url",
["http://remote.example/v1", "http://localhost:8317/path", "https://user:pass@example.com"],
)
def test_openai_base_url_rejects_unsafe_url(base_url: str):
with pytest.raises(ValidationError):
Settings(database_url=_TEST_DATABASE_URL, openai_base_url=base_url)
def test_openai_local_http_allows_no_key_only_in_development():
settings = Settings(
database_url=_TEST_DATABASE_URL,
environment="development",
openai_base_url="http://127.0.0.1:8317",
)
assert settings.openai_api_key is None
with pytest.raises(ValidationError):
Settings(
database_url=_TEST_DATABASE_URL,
environment="production",
tenant_provisioning_secret=SecretStr("provisioner"),
openai_base_url="http://127.0.0.1:8317",
openai_api_key=SecretStr("proxy-key"),
)
def test_openai_production_requires_https_and_key():
with pytest.raises(ValidationError):
Settings(
database_url=_TEST_DATABASE_URL,
environment="production",
tenant_provisioning_secret=SecretStr("provisioner"),
openai_base_url="https://proxy.example",
)
settings = Settings(
database_url=_TEST_DATABASE_URL,
environment="production",
tenant_provisioning_secret=SecretStr("provisioner"),
openai_base_url="https://proxy.example",
openai_api_key=SecretStr("proxy-key"),
)
assert settings.openai_base_url == "https://proxy.example"
assert "proxy-key" not in repr(settings)
def test_openai_rejects_key_without_url_and_header_injection():
with pytest.raises(ValidationError):
Settings(database_url=_TEST_DATABASE_URL, openai_api_key=SecretStr("key"))
with pytest.raises(ValidationError):
Settings(
database_url=_TEST_DATABASE_URL,
environment="development",
openai_base_url="http://localhost:8317",
openai_api_key=SecretStr("key\r\nX-Evil: test"),
)
def test_aihub_base_url_accepts_local_http():
settings = Settings(
database_url=_TEST_DATABASE_URL,

View File

@ -4,7 +4,11 @@ import httpx
import pytest
from ai_platform.infrastructure.aihub import AiHubClient, AiHubError
from ai_platform.infrastructure.scoring import _scoring_system_prompt, _scoring_user_prompt
from ai_platform.lead_scoring.application.scoring_execution_repository import Evidence
from ai_platform.lead_scoring.application.scoring_prompts import (
scoring_system_prompt,
scoring_user_prompt,
)
VKEY = "request-only-vkey-test-secret"
@ -56,8 +60,11 @@ async def test_two_pass_adapter_uses_same_model_timeout_and_vkey_without_putting
)
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")),
system_prompt=scoring_system_prompt("Đánh giá theo hồ sơ AI đã cấu hình."),
user_prompt=scoring_user_prompt(
snapshot,
[Evidence(item.signal, item.detail, item.source) for item in evidence.items],
),
vkey=VKEY,
timeout=7,
)

View File

@ -7,7 +7,8 @@ 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.lead_scoring.application.repository import ScoringRunRecord
from ai_platform.lead_scoring.application.run_use_cases import to_run_detail
from ai_platform.presentation.http.routes import to_detail_json
from ai_platform.presentation.http.run_responses import RunDetail, RunMetrics
@ -52,6 +53,39 @@ def test_run_detail_and_summary_responses_are_documented():
assert schemas["RunList"]["properties"]["runs"]["items"] == {
"$ref": "#/components/schemas/RunSummary"
}
admin_key_paths = {
"/v1/admin/tenant-keys": paths["/v1/admin/tenant-keys"],
"/v1/admin/tenant-keys/{tenant_id}/rotate": paths[
"/v1/admin/tenant-keys/{tenant_id}/rotate"
],
"/v1/admin/tenant-keys/{tenant_id}": paths["/v1/admin/tenant-keys/{tenant_id}"],
}
assert admin_key_paths["/v1/admin/tenant-keys"]["get"]["responses"]["200"]["content"][
"application/json"
]["schema"] == {"$ref": "#/components/schemas/TenantKeyList"}
assert admin_key_paths["/v1/admin/tenant-keys"]["post"]["requestBody"]["content"][
"application/json"
]["schema"] == {"$ref": "#/components/schemas/TenantKeyCreateRequest"}
assert admin_key_paths["/v1/admin/tenant-keys"]["post"]["responses"]["201"]["content"][
"application/json"
]["schema"] == {"$ref": "#/components/schemas/TenantKeySecret"}
assert admin_key_paths["/v1/admin/tenant-keys/{tenant_id}/rotate"]["post"]["responses"]["200"][
"content"
]["application/json"]["schema"] == {"$ref": "#/components/schemas/TenantKeySecret"}
assert admin_key_paths["/v1/admin/tenant-keys/{tenant_id}"]["delete"]["responses"]["200"][
"content"
]["application/json"]["schema"] == {"$ref": "#/components/schemas/TenantKeySummary"}
provisioner = paths["/v1/platform/tenant-keys"]["post"]
assert provisioner["parameters"][0]["name"] == "authorization"
assert provisioner["requestBody"]["content"]["application/json"]["schema"] == {
"$ref": "#/components/schemas/TenantKeyCreateRequest"
}
assert provisioner["responses"]["201"]["content"]["application/json"]["schema"] == {
"$ref": "#/components/schemas/TenantKeySecret"
}
assert "verifier" not in schemas["TenantKeySummary"]["properties"]
assert "tenant_key" not in schemas["TenantKeySummary"]["properties"]
assert "tenant_key" in schemas["TenantKeySecret"]["properties"]
def test_run_detail_serializes_new_metrics_and_requires_boolean_values():
@ -105,7 +139,7 @@ def test_run_detail_serializes_new_metrics_and_requires_boolean_values():
ai_evidence=None,
)
detail = RunDetail.model_validate(to_detail_json(cast(ScoringRun, row)))
detail = RunDetail.model_validate(to_detail_json(to_run_detail(cast(ScoringRunRecord, row))))
assert detail.metrics.conversation_count == 3
assert detail.metrics.contact_message_recency_days == 0.04
@ -126,6 +160,9 @@ def test_required_routes_are_registered():
"/v1/admin/ai-profiles",
"/v1/admin/runs",
"/v1/admin/runs/{run_id}",
"/v1/admin/tenant-keys",
"/v1/admin/tenant-keys/{tenant_id}/rotate",
"/v1/admin/tenant-keys/{tenant_id}",
"/v1/lead-scoring/runs",
"/v1/lead-scoring/runs/{run_id}",
"/v1/platform/tenant-keys",

View File

@ -0,0 +1,28 @@
from pathlib import Path
import pytest
from alembic.config import Config
from alembic.script import ScriptDirectory
from ai_platform.bootstrap.app import _current_migration_heads, validate_migration_heads
def test_startup_migration_heads_come_from_alembic_metadata():
project_root = Path(__file__).resolve().parents[1]
config = Config(str(project_root / "alembic.ini"))
config.set_main_option("path_separator", "os")
config.set_main_option("script_location", str(project_root / "migrations"))
expected_heads = frozenset(ScriptDirectory.from_config(config).get_heads())
assert _current_migration_heads() == expected_heads
validate_migration_heads(expected_heads)
def test_startup_rejects_missing_applied_migration_heads():
with pytest.raises(RuntimeError, match="migrations are missing or not at head"):
validate_migration_heads(frozenset())
def test_startup_rejects_stale_applied_migration_heads():
with pytest.raises(RuntimeError, match="migrations are missing or not at head"):
validate_migration_heads({"stale-head"})

View File

@ -85,10 +85,29 @@ def test_postgres_http_mock_lead_scoring_flow():
assert provisioned.status_code == 201
tenant_keys[tenant] = provisioned.json()["tenant_key"]
auth_a = {"Authorization": f"Bearer {tenant_keys[tenant_a]}"}
auth_a = {
"Authorization": f"Bearer {tenant_keys[tenant_a]}",
"Idempotency-Key": payload["request_id"],
}
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 (
client.post(
"/v1/lead-scoring/runs",
headers={**auth_a, "Idempotency-Key": str(uuid4())},
json=payload,
).status_code
== 422
)
assert (
client.post(
"/v1/lead-scoring/runs",
headers=auth_a,
content=b" " * 1_000_001,
).status_code
== 413
)
assert "snapshot" not in created.json()
run_id = created.json()["run_id"]
rules_only = wait_for_terminal(client, run_id, auth_a)
@ -149,7 +168,11 @@ def test_postgres_http_mock_lead_scoring_flow():
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)
rejected = client.post(
"/v1/lead-scoring/runs",
headers={**auth_a, "Idempotency-Key": invalid["request_id"]},
json=invalid,
)
assert rejected.status_code == 422
ai_requested = deepcopy(payload)
@ -158,7 +181,11 @@ def test_postgres_http_mock_lead_scoring_flow():
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"},
headers={
**auth_a,
"Idempotency-Key": ai_requested["request_id"],
"X-Tenant-VKey": "mock-vkey-not-a-credential",
},
json=ai_requested,
)
assert fallback_response.status_code == 202

206
tests/test_notifications.py Normal file
View File

@ -0,0 +1,206 @@
import asyncio
import hashlib
import hmac
import json
from datetime import UTC, datetime, timedelta
from pathlib import Path
from uuid import uuid4
import httpx
import pytest
from pydantic import SecretStr, ValidationError
from sqlalchemy import delete
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 ScoringNotification, ScoringRun, TenantKey
from ai_platform.infrastructure.persistence.notifications import SqlAlchemyNotificationRepository
from ai_platform.infrastructure.webhook import OmniWebhook
from ai_platform.lead_scoring.application.notifications import Notification, NotificationUseCases
from ai_platform.platform.security import digest
NOW = datetime(2026, 9, 28, tzinfo=UTC)
SECRET = "test-only-webhook-secret-32-characters-long"
def test_webhook_configuration_requires_pair_and_safe_url():
with pytest.raises(ValidationError, match="configured together"):
Settings(webhook_url="https://omni.example.invalid/events")
with pytest.raises(ValidationError, match="HTTPS outside local development"):
Settings(
webhook_url="http://omni.example.invalid/events",
webhook_signing_secret=SecretStr(SECRET),
)
with pytest.raises(ValidationError, match="HTTPS outside local development"):
Settings(
webhook_url="https://omni.example.invalid/events?token=secret",
webhook_signing_secret=SecretStr(SECRET),
)
assert Settings(
webhook_url="https://omni.example.invalid/events", webhook_signing_secret=SecretStr(SECRET)
).webhook_url
async def test_signature_contains_only_notification_identifiers_and_status():
fixture = json.loads(
(
Path(__file__).resolve().parents[1]
/ "contracts/lead-scoring/v2/fixtures/webhook-delivery.json"
).read_text()
)
body = json.loads(fixture["body"])
sent_at = datetime.fromisoformat(fixture["timestamp"])
requests: list[httpx.Request] = []
def receiver(request: httpx.Request) -> httpx.Response:
requests.append(request)
return httpx.Response(204)
async with httpx.AsyncClient(transport=httpx.MockTransport(receiver)) as client:
webhook = OmniWebhook(
client, "https://omni.example.invalid/events", SECRET, lambda: sent_at
)
event = Notification(
body["event_id"],
body["run_id"],
body["tenant_id"],
body["status"],
datetime.fromisoformat(body["occurred_at"]),
0,
)
assert await webhook.send(event)
request = requests[0]
assert request.method == fixture["method"]
assert request.headers["Content-Type"] == fixture["content_type"]
assert request.headers["X-Webhook-Timestamp"] == fixture["timestamp"]
assert request.content.decode() == fixture["body"]
assert request.headers["X-Webhook-Signature"] == fixture["signature"]
assert hmac.new(
SECRET.encode(), fixture["timestamp"].encode() + b"." + request.content, hashlib.sha256
).hexdigest() == fixture["signature"].removeprefix("sha256=")
assert "Authorization" not in request.headers
async def test_retry_is_bounded_and_preserves_dead_letter():
class Repository:
def __init__(self):
self.event = Notification("event-1", "run-1", "tenant-1", "completed", NOW, 0)
self.dead = False
self.next_at = NOW
async def next_due(self, now):
return self.event if not self.dead and self.next_at <= now else None
async def delivered(self, event_id, now):
pytest.fail("unexpected success")
async def retry(self, event_id, attempts, next_at, dead):
self.event = Notification("event-1", "run-1", "tenant-1", "completed", NOW, attempts)
self.next_at = next_at
self.dead = dead
async def delete_expired_runs(self, cutoff):
pass
repo = Repository()
async def failed(_event):
return False
async def noop():
pass
current = NOW
cases = NotificationUseCases(repo, failed, noop, noop, lambda: current)
for attempt in range(1, 6):
assert await cases.dispatch_one()
assert repo.event.attempts == attempt
current = repo.next_at
assert repo.dead
assert not await cases.dispatch_one()
async def test_retention_deletes_old_run_and_its_outbox_but_keeps_recent_data():
settings = Settings.load()
if not settings.database_url.rstrip("/").endswith("/ai_platform_test"):
pytest.fail("Retention test requires isolated ai_platform_test database")
factory = make_session_factory(settings.database_url)
suffix = uuid4().hex
tenant_id = f"retention-{suffix}"
old_id, new_id = str(uuid4()), str(uuid4())
old_event_id, new_event_id = str(uuid4()), str(uuid4())
async def run():
try:
async with factory() as db:
db.add(TenantKey(tenant_id=tenant_id, verifier=digest("test-key")))
await db.flush()
for run_id, event_id, created_at in (
(old_id, old_event_id, NOW - timedelta(days=91)),
(new_id, new_event_id, NOW - timedelta(days=89)),
):
db.add(
ScoringRun(
id=run_id,
tenant_id=tenant_id,
request_id=run_id,
fingerprint="0" * 64,
request={"private": "snapshot"},
profile_version="test",
status="failed",
created_at=created_at,
completed_at=created_at,
)
)
await db.flush()
db.add(
ScoringNotification(
id=event_id,
run_id=run_id,
tenant_id=tenant_id,
status="failed",
attempts=5 if run_id == old_id else 0,
next_attempt_at=created_at,
created_at=created_at,
dead=run_id == old_id,
)
)
await db.commit()
async with factory() as db:
cases = NotificationUseCases(
SqlAlchemyNotificationRepository(db),
lambda _event: asyncio.sleep(0, result=True),
db.commit,
db.rollback,
lambda: NOW,
)
await cases.delete_expired()
async with factory() as db:
assert await db.get(ScoringRun, old_id) is None
assert await db.get(ScoringNotification, old_event_id) is None
assert await db.get(ScoringRun, new_id) is not None
assert await db.get(ScoringNotification, new_event_id) is not None
sent: list[Notification] = []
async def send(event: Notification) -> bool:
sent.append(event)
return True
async with factory() as db:
cases = NotificationUseCases(
SqlAlchemyNotificationRepository(db), send, db.commit, db.rollback, lambda: NOW
)
assert await cases.dispatch_one()
assert [event.id for event in sent] == [new_event_id]
async with factory() as db:
delivered = await db.get(ScoringNotification, new_event_id)
assert delivered is not None and delivered.delivered_at == NOW
assert delivered.attempts == 1
finally:
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.commit()
await factory.kw["bind"].dispose()
await run()

View File

@ -0,0 +1,79 @@
import json
import httpx
import pytest
from ai_platform.infrastructure.openai_compatible_scoring import OpenAICompatibleScoringGateway
from ai_platform.lead_scoring.application.scoring_execution_repository import AIProviderFailure
def _completion(content: str) -> httpx.Response:
return httpx.Response(200, json={"choices": [{"message": {"content": content}}]})
@pytest.mark.asyncio
async def test_compatible_adapter_accepts_json_fence_without_request_vkey():
requests: list[httpx.Request] = []
def handler(request: httpx.Request) -> httpx.Response:
requests.append(request)
assert "authorization" not in request.headers
assert json.loads(request.content)["model"] == "gemini-3.5-flash-lite"
return _completion('```json\n{"items":[]}\n```')
http = httpx.AsyncClient(transport=httpx.MockTransport(handler))
provider = OpenAICompatibleScoringGateway("http://127.0.0.1:8317", None, http)
try:
evidence = await provider.extract_evidence(
model="gemini-3.5-flash-lite", snapshot={}, vkey="", timeout=7
)
finally:
await http.aclose()
assert evidence == []
assert len(requests) == 1
@pytest.mark.parametrize(
("response", "error_code"),
[
(
_completion('{"score":80,"score":1,"confidence":0.8,"reasoning":"a"}'),
"openai_invalid_response",
),
(_completion('{"score":"80","confidence":0.8,"reasoning":"a"}'), "openai_invalid_score"),
(httpx.Response(429, text="sensitive upstream error"), "openai_http_error"),
],
)
@pytest.mark.asyncio
async def test_compatible_adapter_rejects_unsafe_outputs(response: httpx.Response, error_code: str):
http = httpx.AsyncClient(transport=httpx.MockTransport(lambda _: response))
provider = OpenAICompatibleScoringGateway("https://proxy.example", "test-key", http)
try:
with pytest.raises(AIProviderFailure) as error:
await provider.score(
model="ignored", system_prompt="system", user_prompt="user", vkey="", timeout=7
)
finally:
await http.aclose()
assert error.value.error_code == error_code
assert "sensitive upstream error" not in str(error.value)
@pytest.mark.asyncio
async def test_compatible_adapter_does_not_return_echoed_key():
secret = "test/secret"
http = httpx.AsyncClient(
transport=httpx.MockTransport(
lambda _: _completion(json.dumps({"score": 80, "confidence": 0.8, "reasoning": secret}))
)
)
provider = OpenAICompatibleScoringGateway("https://proxy.example", secret, http)
try:
with pytest.raises(AIProviderFailure) as error:
await provider.score(
model="ignored", system_prompt="system", user_prompt="user", vkey="", timeout=7
)
finally:
await http.aclose()
assert error.value.error_code == "openai_invalid_response"
assert secret not in str(error.value)

View File

@ -0,0 +1,92 @@
import asyncio
import json
from pathlib import Path
from uuid import uuid4
import httpx
import pytest
from fastapi.testclient import TestClient
from pydantic import SecretStr
from ai_platform.bootstrap.app import create_app
from ai_platform.infrastructure.config.settings import Settings
from ai_platform.infrastructure.persistence.models import AIProfile
FIXTURE = Path(__file__).parent / "fixtures" / "mock_omni_run.json"
@pytest.mark.parametrize(
("api_key", "selected_url"),
[
(None, "http://127.0.0.1:8317"),
("test-local-proxy-key", "http://127.0.0.1:8317"),
("test-local-proxy-key", "http://127.0.0.1:8318"),
],
)
def test_local_provider_ignores_request_vkey_and_runs_hybrid(
aihub_test_environment: dict,
wait_for_terminal,
api_key: str | None,
selected_url: str,
):
requests: list[httpx.Request] = []
def handler(request: httpx.Request) -> httpx.Response:
requests.append(request)
assert request.url == f"{selected_url}/v1/chat/completions"
assert json.loads(request.content)["model"] == "gemini-3.5-flash-lite"
assert "caller-vkey-must-not-reach-local-provider" not in request.content.decode()
if api_key and selected_url == "http://127.0.0.1:8317":
assert request.headers["authorization"] == f"Bearer {api_key}"
else:
assert "authorization" not in request.headers
content = (
'{"items":[{"signal":"ý định","detail":"Đang tìm hiểu","source":"message-1"}]}'
if len(requests) == 1
else '{"score":80,"confidence":0.91,"reasoning":"Quan tâm rõ ràng."}'
)
return httpx.Response(200, json={"choices": [{"message": {"content": content}}]})
loaded = Settings.load()
settings = Settings(
database_url=loaded.database_url,
environment="development",
tenant_provisioning_secret=loaded.tenant_provisioning_secret,
openai_base_url="http://127.0.0.1:8317",
openai_api_key=SecretStr(api_key) if api_key else None,
)
async def select_local_profile():
try:
async with aihub_test_environment["factory"]() as db:
profile = await db.get(AIProfile, aihub_test_environment["profile_version"])
assert profile is not None
profile.provider = "openai_compatible"
profile.base_url = selected_url
profile.model = "gemini-3.5-flash-lite"
await db.commit()
finally:
await aihub_test_environment["factory"].kw["bind"].dispose()
asyncio.run(select_local_profile())
payload = json.loads(FIXTURE.read_text())
payload["request_id"] = str(uuid4())
payload["ai_profile"] = "medium"
payload["scoring_config"].update(rule_weight=0.5, ai_weight=0.5)
headers = {
"Authorization": f"Bearer {aihub_test_environment['tenant_key']}",
"X-Tenant-VKey": "caller-vkey-must-not-reach-local-provider",
"Idempotency-Key": payload["request_id"],
}
with TestClient(create_app(settings, openai_transport=httpx.MockTransport(handler))) as client:
created = client.post("/v1/lead-scoring/runs", headers=headers, json=payload)
assert created.status_code == 202
result = wait_for_terminal(client, created.json()["run_id"], headers)
assert result["status"] == "completed"
assert result["result_mode"] == "hybrid"
assert result["ai_score"] == 80
assert "caller-vkey-must-not-reach-local-provider" not in json.dumps(result)
assert len(requests) == 2
if api_key:
assert api_key not in json.dumps(result)
assert all(api_key not in request.content.decode() for request in requests)

View File

@ -0,0 +1,103 @@
import json
from datetime import UTC, datetime
from pathlib import Path
import pytest
from ai_platform.lead_scoring.application.repository import AIProfileRecord
from ai_platform.lead_scoring.application.scoring_execution import ScoringExecutionUseCases
from ai_platform.lead_scoring.application.scoring_execution_repository import (
Evidence,
Scoring,
ScoringExecutionRun,
ScoringResult,
)
FIXTURE = Path(__file__).parent / "fixtures" / "mock_omni_run.json"
class RunRepository:
def __init__(self, run: ScoringExecutionRun, profile: AIProfileRecord):
self.run = run
self.profile = profile
self.result: ScoringResult | None = None
async def find_run(self, run_id: str):
return self.run
async def find_profile(self, version: str):
assert version == self.run.profile_version
return self.profile
async def mark_running(self, run_id: str):
pass
async def complete(self, run_id: str, result: ScoringResult, completed_at: datetime):
self.result = result
async def fail(self, run_id: str, completed_at: datetime, error_code: str):
pytest.fail(f"execution failed: {error_code}")
async def mark_interrupted(self, completed_at: datetime):
pytest.fail("unexpected recovery")
class StubProvider:
def __init__(self):
self.models: list[str] = []
self.keys: list[str] = []
async def extract_evidence(self, *, model, snapshot, vkey, timeout):
self.models.append(model)
self.keys.append(vkey)
return [Evidence("interest", "Asked about enrollment", "mock-message-001")]
async def score(self, *, model, system_prompt, user_prompt, vkey, timeout):
self.models.append(model)
self.keys.append(vkey)
return Scoring(80, 0.8, "Evidence supports intent")
@pytest.mark.asyncio
async def test_execution_uses_frozen_profile_provider_and_never_forwards_vkey_to_openai():
payload = json.loads(FIXTURE.read_text())
payload["ai_profile"] = "medium"
payload["scoring_config"].update(rule_weight=0.5, ai_weight=0.5)
run = ScoringExecutionRun("run-1", "pending", payload, "old-profile")
profile = AIProfileRecord(
"old-profile",
"gemini-3.5-flash-lite",
"prompt",
30,
{},
False,
"openai_compatible",
"http://127.0.0.1:8318",
)
repository = RunRepository(run, profile)
old_provider = StubProvider()
active_aihub_provider = StubProvider()
async def no_op():
pass
selected_urls: list[str] = []
def provider_for_url(url: str) -> StubProvider:
selected_urls.append(url)
return old_provider
cases = ScoringExecutionUseCases(
repository,
{"aihub": active_aihub_provider},
no_op,
no_op,
lambda: datetime(2026, 9, 28, tzinfo=UTC),
provider_for_url,
)
await cases.execute(run.id, "caller-vkey-should-not-reach-openai")
assert repository.result is not None and repository.result.result_mode == "hybrid"
assert selected_urls == ["http://127.0.0.1:8318"]
assert old_provider.models == ["gemini-3.5-flash-lite"] * 2
assert old_provider.keys == ["", ""]
assert active_aihub_provider.models == []

View File

@ -1,10 +1,24 @@
from copy import deepcopy
from pathlib import Path
import pytest
from pydantic import ValidationError
from ai_platform.presentation.http.admin_tenant_key_routes import TenantKeySecret
from ai_platform.presentation.http.schemas import RunRequest
FIXTURE_DIR = Path(__file__).resolve().parents[1] / "contracts/lead-scoring/v2/fixtures"
def test_web_fixture_matches_run_request_contract():
RunRequest.model_validate_json((FIXTURE_DIR / "run-request.json").read_text(encoding="utf-8"))
def test_tenant_key_provision_fixture_matches_one_time_response():
TenantKeySecret.model_validate_json(
(FIXTURE_DIR / "tenant-key-provision-response.json").read_text(encoding="utf-8")
)
def test_run_request_contains_caller_rules_and_weights(valid_run_payload):
request = RunRequest.model_validate(valid_run_payload)

View File

@ -0,0 +1,391 @@
from datetime import UTC, datetime
from typing import Any
import pytest
from ai_platform.lead_scoring.application.admin import (
AdminScoringUseCases,
AIProfileVersionConflict,
AIProviderUnavailable,
)
from ai_platform.lead_scoring.application.repository import (
AIProfileRecord,
NewAIProfile,
ScoringRunRecord,
)
from ai_platform.lead_scoring.application.run_use_cases import (
InvalidScoringPayload,
ScoringRunConflictError,
ScoringSchedulingFailed,
ScoringUseCases,
)
class FakeScoringRepository:
def __init__(self, *, conflict_on_create: bool = False) -> None:
self.conflict_on_create = conflict_on_create
self.runs: dict[tuple[str, str], ScoringRunRecord] = {}
self.profiles: dict[str, AIProfileRecord] = {}
self.audit_events: list[tuple[str, str, str, datetime]] = []
self.scheduling_failures: list[tuple[str, datetime, str]] = []
self.create_count = 0
async def find_by_request(self, tenant_id: str, request_id: str):
return self.runs.get((tenant_id, request_id))
async def active_profile_version(self) -> str | None:
return "profile-v1"
async def create_run(
self,
tenant_id: str,
request_id: str,
fingerprint: str,
request: dict[str, Any],
profile_version: str,
) -> ScoringRunRecord:
if self.conflict_on_create:
from ai_platform.lead_scoring.application.repository import ScoringRunConflict
raise ScoringRunConflict
self.create_count += 1
run = ScoringRunRecord(
id=f"run-{self.create_count}",
request_id=request_id,
tenant_id=tenant_id,
status="pending",
fingerprint=fingerprint,
profile_version=profile_version,
rule_score=None,
ai_score=None,
final_score=None,
degraded=False,
result_mode=None,
error_code=None,
breakdown=None,
created_at=None,
completed_at=None,
request=request,
ai_confidence=None,
ai_reasoning=None,
ai_evidence=None,
)
self.runs[(tenant_id, request_id)] = run
return run
async def mark_scheduling_failed(
self, run_id: str, completed_at: datetime, error_code: str
) -> None:
self.scheduling_failures.append((run_id, completed_at, error_code))
async def find_tenant_run(self, tenant_id: str, run_id: str):
return next(
(run for run in self.runs.values() if run.id == run_id and run.tenant_id == tenant_id),
None,
)
async def list_profiles(self):
return list(self.profiles.values())
async def find_profile(self, version: str):
return self.profiles.get(version)
async def activate_profile(self, profile: NewAIProfile):
for version, current in list(self.profiles.items()):
self.profiles[version] = AIProfileRecord(
version=current.version,
model=current.model,
system_prompt=current.system_prompt,
timeout_seconds=current.timeout_seconds,
limits=current.limits,
active=False,
provider=current.provider,
base_url=current.base_url,
)
active = AIProfileRecord(
version=profile.version,
model=profile.model,
system_prompt=profile.system_prompt,
timeout_seconds=profile.timeout_seconds,
limits=profile.limits,
active=True,
provider=profile.provider,
base_url=profile.base_url,
)
self.profiles[profile.version] = active
return active
async def audit_profile_activation(self, actor, version, provider, occurred_at):
self.audit_events.append((actor, version, provider, occurred_at))
async def list_recent_runs(self, limit: int):
return []
async def find_run(self, run_id: str):
return None
class Transaction:
def __init__(self) -> None:
self.commits = 0
self.rollbacks = 0
async def commit(self) -> None:
self.commits += 1
async def rollback(self) -> None:
self.rollbacks += 1
def run_payload(**changes: Any) -> dict[str, Any]:
payload = {
"request_id": "b38de277-872f-48ac-8d4a-5791e8eb734a",
"snapshot": {"version": "1.0.0"},
"subject_ref": "lead-1",
}
return {**payload, **changes}
def use_cases(repository, transaction, schedule) -> ScoringUseCases:
return ScoringUseCases(
repository=repository,
commit=transaction.commit,
rollback=transaction.rollback,
schedule=schedule,
clock=lambda: datetime(2026, 9, 25, tzinfo=UTC),
)
async def test_create_commits_before_scheduling_and_replay_does_not_schedule_twice():
repository = FakeScoringRepository()
transaction = Transaction()
scheduled: list[tuple[str, str | None]] = []
cases = use_cases(repository, transaction, lambda run_id, key: scheduled.append((run_id, key)))
created = await cases.create_run("tenant-1", run_payload(), "test-vkey")
replay = await cases.create_run("tenant-1", run_payload(), "test-vkey")
assert created.run.status == "pending"
assert created.replayed is False
assert replay.run.id == created.run.id
assert replay.replayed is True
assert transaction.commits == 1
assert transaction.rollbacks == 0
assert scheduled == [(created.run.id, "test-vkey")]
async def test_request_id_conflict_does_not_create_or_schedule_another_run():
repository = FakeScoringRepository()
transaction = Transaction()
scheduled: list[str] = []
cases = use_cases(repository, transaction, lambda run_id, _key: scheduled.append(run_id))
await cases.create_run("tenant-1", run_payload(), None)
with pytest.raises(ScoringRunConflictError):
await cases.create_run("tenant-1", run_payload(subject_ref="lead-2"), None)
assert repository.create_count == 1
assert transaction.commits == 1
assert transaction.rollbacks == 0
assert len(scheduled) == 1
async def test_concurrent_unique_conflict_rolls_back_and_maps_to_request_conflict():
repository = FakeScoringRepository(conflict_on_create=True)
transaction = Transaction()
cases = use_cases(repository, transaction, lambda _run_id, _key: None)
with pytest.raises(ScoringRunConflictError):
await cases.create_run("tenant-1", run_payload(), None)
assert transaction.commits == 0
assert transaction.rollbacks == 1
async def test_concurrent_same_body_returns_existing_run_without_scheduling():
from ai_platform.lead_scoring.application.repository import ScoringRunConflict
class RacingRepository(FakeScoringRepository):
async def find_by_request(self, tenant_id, request_id):
if self.conflict_on_create:
return None
return await super().find_by_request(tenant_id, request_id)
async def create_run(self, tenant_id, request_id, fingerprint, request, profile_version):
await super().create_run(tenant_id, request_id, fingerprint, request, profile_version)
self.conflict_on_create = False
raise ScoringRunConflict
repository = RacingRepository()
transaction = Transaction()
scheduled: list[str] = []
cases = use_cases(repository, transaction, lambda run_id, _key: scheduled.append(run_id))
replay = await cases.create_run("tenant-1", run_payload(), None)
assert replay.replayed
assert replay.run.id == "run-1"
assert scheduled == []
assert transaction.rollbacks == 1
async def test_schedule_failure_records_terminal_state_and_returns_service_error():
repository = FakeScoringRepository()
transaction = Transaction()
def fail_schedule(_run_id: str, _key: str | None) -> None:
raise RuntimeError("task queue is unavailable")
cases = use_cases(repository, transaction, fail_schedule)
with pytest.raises(ScoringSchedulingFailed):
await cases.create_run("tenant-1", run_payload(), None)
assert transaction.commits == 2
assert transaction.rollbacks == 0
assert len(repository.scheduling_failures) == 1
assert repository.scheduling_failures[0][2] == "execution_failed"
assert repository.scheduling_failures[0][1] == datetime(2026, 9, 25, tzinfo=UTC)
async def test_vkey_in_payload_is_rejected_before_persistence():
repository = FakeScoringRepository()
transaction = Transaction()
cases = use_cases(repository, transaction, lambda _run_id, _key: None)
with pytest.raises(InvalidScoringPayload):
await cases.create_run(
"tenant-1",
run_payload(subject_ref="test-vkey"),
"test-vkey",
)
assert repository.create_count == 0
assert transaction.commits == 0
assert transaction.rollbacks == 0
async def test_admin_profile_versions_are_immutable():
repository = FakeScoringRepository()
repository.profiles["profile-v1"] = AIProfileRecord(
version="profile-v1",
model="model-old",
system_prompt="prompt",
timeout_seconds=30,
limits={},
active=True,
)
transaction = Transaction()
use_cases = AdminScoringUseCases(
repository,
transaction.commit,
transaction.rollback,
frozenset({"aihub", "openai_compatible"}),
lambda: datetime(2026, 9, 25, tzinfo=UTC),
)
with pytest.raises(AIProfileVersionConflict):
await use_cases.activate_profile(
NewAIProfile(
version="profile-v1",
model="model-new",
system_prompt="prompt",
timeout_seconds=30,
limits={},
),
actor="admin@example.invalid",
)
assert transaction.commits == 0
assert transaction.rollbacks == 1
assert repository.profiles["profile-v1"].model == "model-old"
async def test_admin_profile_activation_commits_through_use_case():
repository = FakeScoringRepository()
transaction = Transaction()
use_cases = AdminScoringUseCases(
repository,
transaction.commit,
transaction.rollback,
frozenset({"aihub", "openai_compatible"}),
lambda: datetime(2026, 9, 25, tzinfo=UTC),
)
requested = NewAIProfile(
version="profile-v2",
model="model",
system_prompt="prompt",
timeout_seconds=30,
limits={"max_tokens": 100},
)
activated = await use_cases.activate_profile(requested, actor="admin@example.invalid")
assert activated.active is True
assert activated.version == requested.version
assert repository.audit_events == [
("admin@example.invalid", requested.version, "aihub", datetime(2026, 9, 25, tzinfo=UTC))
]
assert transaction.commits == 1
assert transaction.rollbacks == 0
async def test_default_openai_url_is_frozen_in_profile_version():
repository = FakeScoringRepository()
transaction = Transaction()
cases = AdminScoringUseCases(
repository,
transaction.commit,
transaction.rollback,
frozenset({"openai_compatible"}),
lambda: datetime(2026, 9, 28, tzinfo=UTC),
openai_base_url="http://127.0.0.1:8317",
)
requested = NewAIProfile("v1", "model", "prompt", 30, {}, "openai_compatible")
activated = await cases.activate_profile(requested, "admin@example.invalid")
assert activated.base_url == "http://127.0.0.1:8317"
assert repository.profiles["v1"].base_url == activated.base_url
async def test_provider_change_requires_new_profile_version_and_configured_adapter():
repository = FakeScoringRepository()
transaction = Transaction()
use_cases = AdminScoringUseCases(
repository,
transaction.commit,
transaction.rollback,
frozenset({"aihub"}),
lambda: datetime(2026, 9, 28, tzinfo=UTC),
)
requested = NewAIProfile(
"v1",
"gemini-3.5-flash-lite",
"prompt",
30,
{},
"openai_compatible",
"http://127.0.0.1:8317",
)
with pytest.raises(AIProviderUnavailable):
await use_cases.activate_profile(requested, "admin@example.invalid")
assert transaction.commits == 0
assert repository.audit_events == []
use_cases = AdminScoringUseCases(
repository,
transaction.commit,
transaction.rollback,
frozenset({"aihub", "openai_compatible"}),
lambda: datetime(2026, 9, 28, tzinfo=UTC),
)
await use_cases.activate_profile(requested, "admin@example.invalid")
with pytest.raises(AIProfileVersionConflict):
await use_cases.activate_profile(
NewAIProfile("v1", requested.model, requested.system_prompt, 30, {}, "aihub"),
"admin@example.invalid",
)
assert transaction.commits == 1
assert len(repository.audit_events) == 1
assert repository.profiles["v1"].provider == "openai_compatible"

View File

@ -1,7 +1,7 @@
import hashlib
from ai_platform.infrastructure.security import digest
from ai_platform.presentation.http.routes import canonical
from ai_platform.lead_scoring.application.run_use_cases import canonical
from ai_platform.platform.security import digest
def test_canonical_body_fingerprint_ignores_mapping_key_order():

View File

@ -98,6 +98,7 @@ def test_seed_creates_one_run_and_reads_persisted_conversation_snapshot():
f"/v1/lead-scoring/runs/{run_id}",
]
assert client.calls[1][2]["json"] == payload
assert client.calls[1][2]["headers"]["Idempotency-Key"] == payload["request_id"]
assert client.calls[2][2]["headers"]["Authorization"] == "Bearer tenant-secret"

View File

@ -64,7 +64,10 @@ def test_api_seed_persists_and_reads_back_conversation_snapshot():
json={"tenant_id": tenant_id},
)
assert provisioned.status_code == 201
tenant_headers = {"Authorization": f"Bearer {provisioned.json()['tenant_key']}"}
tenant_headers = {
"Authorization": f"Bearer {provisioned.json()['tenant_key']}",
"Idempotency-Key": payload["request_id"],
}
admitted = client.post("/v1/lead-scoring/runs", headers=tenant_headers, json=payload)
assert admitted.status_code == 202

View File

@ -0,0 +1,209 @@
import asyncio
from urllib.parse import quote, urlsplit
from uuid import uuid4
from fastapi.testclient import TestClient
from httpx import Response
from pwdlib import PasswordHash
from pydantic import SecretStr
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 (
AdminSession,
AdminUser,
TenantKey,
TenantKeyAuditEvent,
)
from ai_platform.platform.security import digest
def expect_status(response: Response, expected: int) -> None:
assert response.status_code == expected
def assert_secret_absent(secret: str, body: str) -> None:
if secret in body:
raise AssertionError("tenant key appeared in list response")
def test_admin_and_provisioner_tenant_key_lifecycle():
loaded = Settings.load()
database_name = urlsplit(loaded.database_url).path.rstrip("/").rsplit("/", 1)[-1]
if database_name != "ai_platform_test":
raise RuntimeError("Tenant key API tests require /ai_platform_test")
settings = Settings(
database_url=loaded.database_url,
environment="development",
tenant_provisioning_secret=SecretStr("test-only-provisioner-secret"),
)
sessions = make_session_factory(settings.database_url)
tenant_id, admin_tenant = f"tenant/{uuid4().hex}", f"tenant-admin-{uuid4().hex}"
tenant_route_id = quote(tenant_id, safe="")
tenant_key_path = f"/v1/admin/tenant-keys/{tenant_route_id}"
admin_email, password = f"tenant-admin-{uuid4().hex}@example.invalid", "test-only-password"
app = create_app(settings)
async def create_admin() -> None:
async with sessions() as db:
db.add(
AdminUser(
email=admin_email,
password_hash=PasswordHash.recommended().hash(password),
)
)
await db.commit()
await sessions.kw["bind"].dispose()
async def cleanup() -> None:
async with sessions() as db:
tenants = [tenant_id, admin_tenant]
await db.execute(
delete(TenantKeyAuditEvent).where(TenantKeyAuditEvent.tenant_id.in_(tenants))
)
await db.execute(delete(AdminSession).where(AdminSession.admin_email == admin_email))
await db.execute(delete(AdminUser).where(AdminUser.email == admin_email))
await db.execute(delete(TenantKey).where(TenantKey.tenant_id.in_(tenants)))
await db.commit()
await sessions.kw["bind"].dispose()
asyncio.run(create_admin())
try:
with TestClient(app) as client:
expect_status(client.get("/v1/admin/tenant-keys"), 401)
expect_status(client.post("/v1/admin/tenant-keys", json={"tenant_id": tenant_id}), 401)
provision_path = "/v1/platform/tenant-keys"
expect_status(
client.post(
provision_path,
headers={"Authorization": "Bearer incorrect"},
json={"tenant_id": tenant_id},
),
401,
)
provision_headers = {"Authorization": "Bearer test-only-provisioner-secret"}
expect_status(
client.post(provision_path, headers=provision_headers, json={"tenant_id": " "}),
422,
)
expect_status(
client.post(
provision_path,
headers=provision_headers,
json={"tenant_id": tenant_id, "unexpected": True},
),
422,
)
created = client.post(
provision_path, headers=provision_headers, json={"tenant_id": tenant_id}
)
expect_status(created, 201)
assert created.headers["cache-control"] == "no-store"
initial_key = created.json()["tenant_key"]
expect_status(
client.post(
provision_path, headers=provision_headers, json={"tenant_id": tenant_id}
),
409,
)
login = client.post(
"/v1/admin/login", json={"email": admin_email, "password": password}
)
expect_status(login, 200)
csrf = {"x-csrf-token": login.json()["csrf_token"]}
listed = client.get("/v1/admin/tenant-keys")
expect_status(listed, 200)
item = next(x for x in listed.json()["tenants"] if x["tenant_id"] == tenant_id)
assert set(item) == {"tenant_id", "active", "created_at", "updated_at"}
assert item["active"] and item["created_at"] and item["updated_at"]
assert_secret_absent(initial_key, listed.text)
assert "verifier" not in listed.text
expect_status(client.post("/v1/admin/tenant-keys", json={"tenant_id": "no-csrf"}), 403)
expect_status(client.post(f"{tenant_key_path}/rotate"), 403)
expect_status(client.delete(tenant_key_path), 403)
expect_status(
client.post("/v1/admin/tenant-keys", headers=csrf, json={"tenant_id": tenant_id}),
409,
)
run_url = f"/v1/lead-scoring/runs/{uuid4()}"
def tenant_auth(key: str) -> dict[str, str]:
return {"Authorization": f"Bearer {key}"}
expect_status(client.get(run_url, headers=tenant_auth(initial_key)), 404)
admin_created = client.post(
"/v1/admin/tenant-keys", headers=csrf, json={"tenant_id": admin_tenant}
)
expect_status(admin_created, 201)
assert admin_created.headers["cache-control"] == "no-store"
admin_key = admin_created.json()["tenant_key"]
assert_secret_absent(admin_key, client.get("/v1/admin/tenant-keys").text)
rotated = client.post(f"{tenant_key_path}/rotate", headers=csrf)
expect_status(rotated, 200)
assert rotated.headers["cache-control"] == "no-store"
rotated_key = rotated.json()["tenant_key"]
if rotated_key == initial_key:
raise AssertionError("rotation reused the previous key")
expect_status(client.get(run_url, headers=tenant_auth(initial_key)), 401)
expect_status(client.get(run_url, headers=tenant_auth(rotated_key)), 404)
assert_secret_absent(rotated_key, client.get("/v1/admin/tenant-keys").text)
revoked = client.delete(tenant_key_path, headers=csrf)
expect_status(revoked, 200)
assert revoked.json()["active"] is False
expect_status(client.get(run_url, headers=tenant_auth(rotated_key)), 401)
reactivated = client.post(f"{tenant_key_path}/rotate", headers=csrf)
expect_status(reactivated, 200)
assert reactivated.headers["cache-control"] == "no-store"
active_key = reactivated.json()["tenant_key"]
if active_key == rotated_key:
raise AssertionError("rotation reused a revoked key")
expect_status(client.get(run_url, headers=tenant_auth(active_key)), 404)
async def assert_audit_and_verifier() -> None:
try:
async with sessions() as db:
row = await db.get(TenantKey, tenant_id)
assert row is not None and row.active and row.verifier == digest(active_key)
events = (
await db.scalars(
select(TenantKeyAuditEvent)
.where(TenantKeyAuditEvent.tenant_id == tenant_id)
.order_by(TenantKeyAuditEvent.id)
)
).all()
assert [(e.actor, e.action) for e in events] == [
("provisioner", "create"),
(admin_email, "rotate"),
(admin_email, "revoke"),
(admin_email, "rotate"),
]
assert all(e.occurred_at is not None for e in events)
created_by_admin = await db.scalar(
select(TenantKeyAuditEvent).where(
TenantKeyAuditEvent.tenant_id == admin_tenant
)
)
assert created_by_admin is not None
assert (created_by_admin.actor, created_by_admin.action) == (
admin_email,
"create",
)
assert {c.name for c in TenantKeyAuditEvent.__table__.columns} == {
"id",
"tenant_id",
"actor",
"action",
"occurred_at",
}
finally:
await sessions.kw["bind"].dispose()
asyncio.run(assert_audit_and_verifier())
finally:
asyncio.run(cleanup())

View File

@ -0,0 +1,164 @@
from datetime import UTC, datetime
import pytest
from ai_platform.platform.application.tenant_key_repository import (
TenantKeyConflict,
TenantKeyNotFound,
TenantKeyRecord,
)
from ai_platform.platform.application.tenant_keys import (
create_tenant_key,
revoke_tenant_key,
rotate_tenant_key,
)
from ai_platform.platform.security import digest
class FakeTenantKeyRepository:
def __init__(self, *, operation_error: Exception | None = None) -> None:
self.operation_error = operation_error
self.mutations: list[tuple[str, str, str | None]] = []
self.record = TenantKeyRecord(
tenant_id="tenant-1",
active=True,
created_at=datetime(2025, 1, 1, tzinfo=UTC),
updated_at=datetime(2025, 1, 1, tzinfo=UTC),
)
async def list(self) -> list[TenantKeyRecord]:
return [self.record]
async def create(self, tenant_id: str, verifier: str, actor: str) -> None:
self.mutations.append(("create", tenant_id, verifier))
if self.operation_error:
raise self.operation_error
assert actor == "admin@example.invalid"
async def rotate(self, tenant_id: str, verifier: str, actor: str) -> TenantKeyRecord:
self.mutations.append(("rotate", tenant_id, verifier))
if self.operation_error:
raise self.operation_error
assert actor == "admin@example.invalid"
return self.record
async def revoke(self, tenant_id: str, actor: str) -> TenantKeyRecord:
self.mutations.append(("revoke", tenant_id, None))
if self.operation_error:
raise self.operation_error
assert actor == "admin@example.invalid"
return self.record
class FakeTenantKeyGenerator:
def __init__(self, *values: str) -> None:
self._values = iter(values)
def __call__(self) -> str:
return next(self._values)
class FakeTenantKeyTransaction:
def __init__(self, *, commit_error: Exception | None = None) -> None:
self.commit_error = commit_error
self.commits = 0
self.rollbacks = 0
async def commit(self) -> None:
self.commits += 1
if self.commit_error:
raise self.commit_error
async def rollback(self) -> None:
self.rollbacks += 1
async def test_tenant_key_use_cases_commit_each_successful_mutation():
repository = FakeTenantKeyRepository()
transaction = FakeTenantKeyTransaction()
created_key = await create_tenant_key(
repository,
"tenant-1",
"admin@example.invalid",
key_generator=FakeTenantKeyGenerator("tnt-created"),
commit=transaction.commit,
rollback=transaction.rollback,
)
rotated_key = await rotate_tenant_key(
repository,
"tenant-1",
"admin@example.invalid",
key_generator=FakeTenantKeyGenerator("tnt-rotated"),
commit=transaction.commit,
rollback=transaction.rollback,
)
revoked_record = await revoke_tenant_key(
repository,
"tenant-1",
"admin@example.invalid",
commit=transaction.commit,
rollback=transaction.rollback,
)
assert created_key == "tnt-created"
assert rotated_key == "tnt-rotated"
assert digest(created_key) == repository.mutations[0][2]
assert digest(rotated_key) == repository.mutations[1][2]
assert [mutation[0] for mutation in repository.mutations] == ["create", "rotate", "revoke"]
assert transaction.commits == 3
assert transaction.rollbacks == 0
assert revoked_record is repository.record
async def test_tenant_key_use_case_rolls_back_repository_errors():
repository = FakeTenantKeyRepository(operation_error=TenantKeyConflict())
transaction = FakeTenantKeyTransaction()
with pytest.raises(TenantKeyConflict):
await create_tenant_key(
repository,
"tenant-1",
"admin@example.invalid",
key_generator=FakeTenantKeyGenerator("tnt-unused"),
commit=transaction.commit,
rollback=transaction.rollback,
)
assert transaction.commits == 0
assert transaction.rollbacks == 1
async def test_tenant_key_use_case_rolls_back_failed_commit():
repository = FakeTenantKeyRepository()
transaction = FakeTenantKeyTransaction(commit_error=RuntimeError("commit failed"))
with pytest.raises(RuntimeError, match="commit failed"):
await rotate_tenant_key(
repository,
"tenant-1",
"admin@example.invalid",
key_generator=FakeTenantKeyGenerator("tnt-unused"),
commit=transaction.commit,
rollback=transaction.rollback,
)
assert transaction.commits == 1
assert transaction.rollbacks == 1
async def test_tenant_key_use_case_rolls_back_missing_key():
repository = FakeTenantKeyRepository(operation_error=TenantKeyNotFound())
transaction = FakeTenantKeyTransaction()
with pytest.raises(TenantKeyNotFound):
await revoke_tenant_key(
repository,
"tenant-1",
"admin@example.invalid",
commit=transaction.commit,
rollback=transaction.rollback,
)
assert transaction.commits == 0
assert transaction.rollbacks == 1