feat: complete Lead Scoring backend and Omni notification contract
This commit is contained in:
parent
156e7af7d8
commit
9cd27caa24
16
.env.example
16
.env.example
@ -19,10 +19,22 @@ AI_PLATFORM_ADMIN_COOKIE_SECURE=false
|
|||||||
AI_PLATFORM_SESSION_TTL_SECONDS=28800
|
AI_PLATFORM_SESSION_TTL_SECONDS=28800
|
||||||
# Optional, required together when sending webhook to a local Omni receiver.
|
# 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_URL=http://localhost:8080/api/v1/internal/lead-scoring/events
|
||||||
# AI_PLATFORM_WEBHOOK_SIGNING_SECRET=local-replace-before-use
|
# AI_PLATFORM_WEBHOOK_SIGNING_SECRET=local-replace-with-at-least-32-characters
|
||||||
# Optional; no global V-Key: Omni sends X-Tenant-VKey per scoring request.
|
# Lead Scoring AI profile selects AIHub by default. Omni supplies X-Tenant-VKey per AI request.
|
||||||
# AI_PLATFORM_AIHUB_BASE_URL=http://localhost:8080
|
# 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.
|
# Compose web origin only; native SvelteKit dev reads apps/web/.env.
|
||||||
WEB_ORIGIN=http://localhost:3000
|
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.
|
# Do not place tenant keys, V-Keys, admin sessions or backend URLs in PUBLIC_* variables.
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
[importlinter]
|
[importlinter]
|
||||||
root_package = ai_platform
|
root_package = ai_platform
|
||||||
|
include_external_packages = True
|
||||||
|
|
||||||
[importlinter:contract:domain]
|
[importlinter:contract:domain]
|
||||||
name = Lead Scoring domain stays independent of effects and HTTP
|
name = Lead Scoring domain stays independent of effects and HTTP
|
||||||
@ -21,3 +22,12 @@ forbidden_modules =
|
|||||||
ai_platform.infrastructure
|
ai_platform.infrastructure
|
||||||
ai_platform.presentation
|
ai_platform.presentation
|
||||||
ai_platform.bootstrap
|
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
|
||||||
|
|||||||
@ -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.
|
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.
|
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.
|
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.
|
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.
|
||||||
|
|||||||
@ -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.
|
- **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.
|
- **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 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.
|
- **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, AIHub HTTP client, HMAC signing, and delivery adapters. Provider details stop at these boundaries.
|
- **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.
|
- **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.
|
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.
|
||||||
|
|||||||
@ -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.
|
- **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-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.
|
- **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
|
## 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.
|
- **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.
|
- **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.
|
- **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.
|
- **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.
|
- **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.
|
- **Result GET:** tenant-authenticated read of the authoritative run result. Omni calls it after receiving a signed terminal notification.
|
||||||
|
|||||||
20
README.md
20
README.md
@ -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.
|
`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
|
## Quality checks
|
||||||
|
|
||||||
The opt-in pre-commit runner is installed by `make install` as a project-local development dependency. Run:
|
The opt-in pre-commit runner is installed by `make install` as a project-local development dependency. Run:
|
||||||
|
|||||||
@ -41,6 +41,8 @@ services:
|
|||||||
AI_PLATFORM_WEBHOOK_URL: ${AI_PLATFORM_WEBHOOK_URL:-}
|
AI_PLATFORM_WEBHOOK_URL: ${AI_PLATFORM_WEBHOOK_URL:-}
|
||||||
AI_PLATFORM_WEBHOOK_SIGNING_SECRET: ${AI_PLATFORM_WEBHOOK_SIGNING_SECRET:-}
|
AI_PLATFORM_WEBHOOK_SIGNING_SECRET: ${AI_PLATFORM_WEBHOOK_SIGNING_SECRET:-}
|
||||||
AI_PLATFORM_AIHUB_BASE_URL: ${AI_PLATFORM_AIHUB_BASE_URL:-}
|
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:
|
ports:
|
||||||
- "127.0.0.1:${BACKEND_PORT:-8000}:8000"
|
- "127.0.0.1:${BACKEND_PORT:-8000}:8000"
|
||||||
depends_on:
|
depends_on:
|
||||||
@ -65,6 +67,8 @@ services:
|
|||||||
PORT: 3000
|
PORT: 3000
|
||||||
ORIGIN: ${WEB_ORIGIN:-http://localhost:3000}
|
ORIGIN: ${WEB_ORIGIN:-http://localhost:3000}
|
||||||
BACKEND_INTERNAL_URL: http://backend:8000
|
BACKEND_INTERNAL_URL: http://backend:8000
|
||||||
|
WEB_ENVIRONMENT: ${WEB_ENVIRONMENT:-development}
|
||||||
|
LEAD_SCORING_FIXTURE_TENANT_KEY: ${LEAD_SCORING_FIXTURE_TENANT_KEY:-}
|
||||||
ports:
|
ports:
|
||||||
- "127.0.0.1:${WEB_PORT:-3000}:3000"
|
- "127.0.0.1:${WEB_PORT:-3000}:3000"
|
||||||
depends_on:
|
depends_on:
|
||||||
|
|||||||
13
contracts/lead-scoring/v2/README.md
Normal file
13
contracts/lead-scoring/v2/README.md
Normal 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.
|
||||||
@ -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"]}
|
||||||
|
|||||||
@ -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>"}
|
||||||
|
|||||||
7
contracts/lead-scoring/v2/fixtures/webhook-delivery.json
Normal file
7
contracts/lead-scoring/v2/fixtures/webhook-delivery.json
Normal 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
86
docs/project_quality.md
Normal 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.
|
||||||
@ -11,7 +11,7 @@ status: review_ready
|
|||||||
## Sở hữu
|
## 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.
|
- 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.
|
- 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
|
## 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 đó.
|
`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.
|
- 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.
|
- 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
|
## 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`: `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.
|
- 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
|
## 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.
|
- 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.
|
- 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.
|
- 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.
|
||||||
|
|
||||||
|
|||||||
40
migrations/versions/0004_tenant_key_admin.py
Normal file
40
migrations/versions/0004_tenant_key_admin.py
Normal 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")
|
||||||
32
migrations/versions/0005_ai_profile_provider.py
Normal file
32
migrations/versions/0005_ai_profile_provider.py
Normal 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")
|
||||||
15
migrations/versions/0006_ai_profile_base_url.py
Normal file
15
migrations/versions/0006_ai_profile_base_url.py
Normal 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")
|
||||||
38
migrations/versions/0007_scoring_notifications.py
Normal file
38
migrations/versions/0007_scoring_notifications.py
Normal 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")
|
||||||
@ -109,7 +109,11 @@ def seed_demo(
|
|||||||
raise SystemExit("Demo seed requires a rules-only fixture; it must not claim AI success")
|
raise SystemExit("Demo seed requires a rules-only fixture; it must not claim AI success")
|
||||||
|
|
||||||
headers = tenant_headers(client, settings, existing_tenant_key)
|
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}:
|
if response.status_code not in {200, 202}:
|
||||||
raise SystemExit(f"Demo seed blocked: run admission returned HTTP {response.status_code}")
|
raise SystemExit(f"Demo seed blocked: run admission returned HTTP {response.status_code}")
|
||||||
run_id = response.json().get("run_id")
|
run_id = response.json().get("run_id")
|
||||||
|
|||||||
@ -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
|
import httpx
|
||||||
|
from alembic.config import Config
|
||||||
|
from alembic.script import ScriptDirectory
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from sqlalchemy import text
|
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 import AiHubClient
|
||||||
|
from ai_platform.infrastructure.aihub_scoring import AiHubScoringGateway
|
||||||
from ai_platform.infrastructure.config.settings import Settings
|
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.persistence.database import make_session_factory
|
||||||
from ai_platform.infrastructure.scoring import recover_interrupted, wait_for_tasks
|
from ai_platform.infrastructure.webhook import OmniWebhook
|
||||||
from ai_platform.presentation.http.admin_routes import router as admin_router
|
from ai_platform.lead_scoring.application.admin import openai_url_allowed
|
||||||
from ai_platform.presentation.http.routes import router
|
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(
|
def create_app(
|
||||||
settings: Settings | None = None,
|
settings: Settings | None = None,
|
||||||
*,
|
*,
|
||||||
aihub_transport: httpx.AsyncBaseTransport | None = None,
|
aihub_transport: httpx.AsyncBaseTransport | None = None,
|
||||||
|
openai_transport: httpx.AsyncBaseTransport | None = None,
|
||||||
|
webhook_transport: httpx.AsyncBaseTransport | None = None,
|
||||||
) -> FastAPI:
|
) -> FastAPI:
|
||||||
config = settings or Settings.load()
|
config = settings or Settings.load()
|
||||||
sessions = make_session_factory(config.database_url)
|
sessions = make_session_factory(config.database_url)
|
||||||
tasks = set()
|
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
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
|
try:
|
||||||
async with sessions() as db:
|
async with sessions() as db:
|
||||||
revisions = (
|
revisions = set(
|
||||||
(await db.execute(text("SELECT version_num FROM alembic_version"))).scalars().all()
|
(await db.execute(text("SELECT version_num FROM alembic_version")))
|
||||||
|
.scalars()
|
||||||
|
.all()
|
||||||
)
|
)
|
||||||
if revisions != ["0003_ai_evidence"]:
|
except ProgrammingError as exc:
|
||||||
raise RuntimeError(
|
raise RuntimeError(_MIGRATIONS_MISSING) from exc
|
||||||
"Database migrations are missing or not at head; run alembic upgrade head"
|
validate_migration_heads(revisions)
|
||||||
)
|
await recover_interrupted_runs(sessions)
|
||||||
await recover_interrupted(sessions)
|
|
||||||
app.state.sessions = sessions
|
app.state.sessions = sessions
|
||||||
app.state.settings = config
|
app.state.settings = config
|
||||||
app.state.tasks = tasks
|
app.state.tasks = tasks
|
||||||
if config.aihub_base_url is None:
|
clients: list[httpx.AsyncClient] = []
|
||||||
app.state.aihub_client = None
|
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:
|
else:
|
||||||
client = httpx.AsyncClient(transport=aihub_transport)
|
app.state.schedule_scoring_run = create_run_scheduler(sessions, tasks, providers)
|
||||||
app.state.aihub_client = AiHubClient(config.aihub_base_url, client)
|
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:
|
try:
|
||||||
yield
|
yield
|
||||||
finally:
|
finally:
|
||||||
|
notification_task.cancel()
|
||||||
|
with suppress(asyncio.CancelledError):
|
||||||
|
await notification_task
|
||||||
await wait_for_tasks(tasks)
|
await wait_for_tasks(tasks)
|
||||||
if app.state.aihub_client is not None:
|
for client in clients:
|
||||||
await app.state.aihub_client.aclose()
|
await client.aclose()
|
||||||
await sessions.kw["bind"].dispose()
|
await sessions.kw["bind"].dispose()
|
||||||
|
|
||||||
app = FastAPI(title="AI Platform API", version="2.0.0", lifespan=lifespan)
|
app = FastAPI(title="AI Platform API", version="2.0.0", lifespan=lifespan)
|
||||||
app.include_router(router)
|
app.state.settings = config
|
||||||
app.include_router(admin_router)
|
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
|
return app
|
||||||
|
|||||||
90
src/ai_platform/bootstrap/runtime.py
Normal file
90
src/ai_platform/bootstrap/runtime.py
Normal 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()
|
||||||
158
src/ai_platform/bootstrap/wiring.py
Normal file
158
src/ai_platform/bootstrap/wiring.py
Normal 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()
|
||||||
@ -4,39 +4,40 @@ import argparse
|
|||||||
import asyncio
|
import asyncio
|
||||||
import getpass
|
import getpass
|
||||||
import json
|
import json
|
||||||
import secrets
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from pwdlib import PasswordHash
|
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.config.settings import Settings
|
||||||
from ai_platform.infrastructure.persistence.database import make_session_factory
|
from ai_platform.infrastructure.persistence.database import make_session_factory
|
||||||
from ai_platform.infrastructure.persistence.models import AdminUser, TenantKey
|
from ai_platform.platform.application.admin_users import AdminUserAlreadyExists
|
||||||
from ai_platform.infrastructure.security import digest
|
from ai_platform.platform.application.tenant_key_repository import TenantKeyNotFound
|
||||||
|
|
||||||
|
|
||||||
async def create_admin(email: str, password: str) -> None:
|
async def create_admin(email: str, password: str) -> None:
|
||||||
sessions = make_session_factory(Settings.load().database_url)
|
sessions = make_session_factory(Settings.load().database_url)
|
||||||
|
try:
|
||||||
async with sessions() as db:
|
async with sessions() as db:
|
||||||
email = email.strip().lower()
|
use_cases = build_admin_user_use_cases(db, PasswordHash.recommended().hash)
|
||||||
if await db.get(AdminUser, email):
|
try:
|
||||||
raise SystemExit("admin already exists")
|
await use_cases.create(email, password)
|
||||||
db.add(AdminUser(email=email, password_hash=PasswordHash.recommended().hash(password)))
|
except AdminUserAlreadyExists as exc:
|
||||||
await db.commit()
|
raise SystemExit("admin already exists") from exc
|
||||||
|
finally:
|
||||||
await sessions.kw["bind"].dispose()
|
await sessions.kw["bind"].dispose()
|
||||||
|
|
||||||
|
|
||||||
async def rotate_tenant_key(tenant_id: str) -> str:
|
async def rotate_tenant_key(tenant_id: str) -> str:
|
||||||
sessions = make_session_factory(Settings.load().database_url)
|
sessions = make_session_factory(Settings.load().database_url)
|
||||||
raw = "tnt_" + secrets.token_urlsafe(32)
|
try:
|
||||||
async with sessions() as db:
|
async with sessions() as db:
|
||||||
row = await db.get(TenantKey, tenant_id, with_for_update=True)
|
try:
|
||||||
if row is None:
|
return await build_tenant_key_use_cases(db).rotate(tenant_id, "cli")
|
||||||
raise SystemExit(f"unknown tenant: {tenant_id}")
|
except TenantKeyNotFound as exc:
|
||||||
row.verifier = digest(raw)
|
raise SystemExit(f"unknown tenant: {tenant_id}") from exc
|
||||||
await db.commit()
|
finally:
|
||||||
await sessions.kw["bind"].dispose()
|
await sessions.kw["bind"].dispose()
|
||||||
return raw
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
|
|||||||
51
src/ai_platform/infrastructure/aihub_scoring.py
Normal file
51
src/ai_platform/infrastructure/aihub_scoring.py
Normal 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)
|
||||||
@ -18,8 +18,12 @@ class Settings(BaseSettings):
|
|||||||
environment: str = "development"
|
environment: str = "development"
|
||||||
tenant_provisioning_secret: SecretStr | None = None
|
tenant_provisioning_secret: SecretStr | None = None
|
||||||
aihub_base_url: str | 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
|
admin_cookie_secure: bool = False
|
||||||
session_ttl_seconds: int = 28800
|
session_ttl_seconds: int = 28800
|
||||||
|
webhook_url: str | None = None
|
||||||
|
webhook_signing_secret: SecretStr | None = None
|
||||||
|
|
||||||
@model_validator(mode="after")
|
@model_validator(mode="after")
|
||||||
def validate_settings(self):
|
def validate_settings(self):
|
||||||
@ -53,6 +57,63 @@ class Settings(BaseSettings):
|
|||||||
raise ValueError(
|
raise ValueError(
|
||||||
"AI_PLATFORM_AIHUB_BASE_URL must use HTTPS outside local development"
|
"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
|
return self
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
|
|||||||
9
src/ai_platform/infrastructure/credentials.py
Normal file
9
src/ai_platform/infrastructure/credentials.py
Normal 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)
|
||||||
154
src/ai_platform/infrastructure/openai_compatible_scoring.py
Normal file
154
src/ai_platform/infrastructure/openai_compatible_scoring.py
Normal 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
|
||||||
45
src/ai_platform/infrastructure/persistence/admin_sessions.py
Normal file
45
src/ai_platform/infrastructure/persistence/admin_sessions.py
Normal 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)
|
||||||
23
src/ai_platform/infrastructure/persistence/admin_users.py
Normal file
23
src/ai_platform/infrastructure/persistence/admin_users.py
Normal 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
|
||||||
11
src/ai_platform/infrastructure/persistence/health.py
Normal file
11
src/ai_platform/infrastructure/persistence/health.py
Normal 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
|
||||||
@ -24,6 +24,20 @@ class TenantKey(Base):
|
|||||||
verifier: Mapped[str] = mapped_column(String(64), nullable=False)
|
verifier: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||||
active: Mapped[bool] = mapped_column(Boolean, default=True, 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())
|
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):
|
class AdminUser(Base):
|
||||||
@ -44,12 +58,27 @@ class AIProfile(Base):
|
|||||||
__tablename__ = "ai_profiles"
|
__tablename__ = "ai_profiles"
|
||||||
version: Mapped[str] = mapped_column(String(64), primary_key=True)
|
version: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||||
model: Mapped[str] = mapped_column(String(128), nullable=False)
|
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)
|
system_prompt: Mapped[str] = mapped_column(String(8000), nullable=False)
|
||||||
timeout_seconds: Mapped[int] = mapped_column(Integer, nullable=False)
|
timeout_seconds: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||||
limits: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False)
|
limits: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False)
|
||||||
active: Mapped[bool] = mapped_column(Boolean, default=False, 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):
|
class ScoringRun(Base):
|
||||||
__tablename__ = "scoring_runs"
|
__tablename__ = "scoring_runs"
|
||||||
__table_args__ = (UniqueConstraint("tenant_id", "request_id", name="uq_run_tenant_request"),)
|
__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
|
DateTime(timezone=True), server_default=func.now(), index=True
|
||||||
)
|
)
|
||||||
completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=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)
|
||||||
|
|||||||
46
src/ai_platform/infrastructure/persistence/notifications.py
Normal file
46
src/ai_platform/infrastructure/persistence/notifications.py
Normal 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))
|
||||||
173
src/ai_platform/infrastructure/persistence/scoring.py
Normal file
173
src/ai_platform/infrastructure/persistence/scoring.py
Normal 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
|
||||||
124
src/ai_platform/infrastructure/persistence/scoring_execution.py
Normal file
124
src/ai_platform/infrastructure/persistence/scoring_execution.py
Normal 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,
|
||||||
|
)
|
||||||
|
)
|
||||||
70
src/ai_platform/infrastructure/persistence/tenant_keys.py
Normal file
70
src/ai_platform/infrastructure/persistence/tenant_keys.py
Normal 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)
|
||||||
@ -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()
|
|
||||||
49
src/ai_platform/infrastructure/webhook.py
Normal file
49
src/ai_platform/infrastructure/webhook.py
Normal 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
|
||||||
110
src/ai_platform/lead_scoring/application/admin.py
Normal file
110
src/ai_platform/lead_scoring/application/admin.py
Normal 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)
|
||||||
64
src/ai_platform/lead_scoring/application/notifications.py
Normal file
64
src/ai_platform/lead_scoring/application/notifications.py
Normal 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
|
||||||
91
src/ai_platform/lead_scoring/application/repository.py
Normal file
91
src/ai_platform/lead_scoring/application/repository.py
Normal 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: ...
|
||||||
126
src/ai_platform/lead_scoring/application/run_use_cases.py
Normal file
126
src/ai_platform/lead_scoring/application/run_use_cases.py
Normal 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)
|
||||||
165
src/ai_platform/lead_scoring/application/scoring_execution.py
Normal file
165
src/ai_platform/lead_scoring/application/scoring_execution.py
Normal 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()
|
||||||
@ -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: ...
|
||||||
28
src/ai_platform/lead_scoring/application/scoring_prompts.py
Normal file
28
src/ai_platform/lead_scoring/application/scoring_prompts.py
Normal 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=(",", ":"))
|
||||||
|
)
|
||||||
95
src/ai_platform/platform/application/admin_sessions.py
Normal file
95
src/ai_platform/platform/application/admin_sessions.py
Normal 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
|
||||||
43
src/ai_platform/platform/application/admin_users.py
Normal file
43
src/ai_platform/platform/application/admin_users.py
Normal 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
|
||||||
@ -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))
|
||||||
@ -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: ...
|
||||||
106
src/ai_platform/platform/application/tenant_keys.py
Normal file
106
src/ai_platform/platform/application/tenant_keys.py
Normal 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,
|
||||||
|
)
|
||||||
25
src/ai_platform/presentation/http/admin_auth.py
Normal file
25
src/ai_platform/presentation/http/admin_auth.py
Normal 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")
|
||||||
@ -1,156 +1,159 @@
|
|||||||
import secrets
|
from collections.abc import Awaitable, Callable
|
||||||
from datetime import UTC, datetime, timedelta
|
from typing import Any
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response
|
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 (
|
from ai_platform.lead_scoring.application.admin import (
|
||||||
AdminSession,
|
AdminScoringUseCases,
|
||||||
AdminUser,
|
AIProfileVersionConflict,
|
||||||
AIProfile,
|
AIProviderUnavailable,
|
||||||
ScoringRun,
|
AIProviderURLInvalid,
|
||||||
)
|
)
|
||||||
from ai_platform.infrastructure.security import digest
|
from ai_platform.lead_scoring.application.repository import AIProfileRecord, NewAIProfile
|
||||||
from ai_platform.presentation.http.dependencies import db_session
|
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.routes import to_detail_json, to_json
|
||||||
from ai_platform.presentation.http.run_responses import RunDetail, RunList
|
from ai_platform.presentation.http.run_responses import RunDetail, RunList
|
||||||
from ai_platform.presentation.http.schemas import AdminLoginRequest, AIProfileInput
|
from ai_platform.presentation.http.schemas import AdminLoginRequest, AIProfileInput
|
||||||
|
|
||||||
router = APIRouter()
|
|
||||||
COOKIE = "ai_platform_session"
|
|
||||||
password_hash = PasswordHash.recommended()
|
|
||||||
|
|
||||||
|
def profile_json(row: AIProfileRecord) -> dict[str, Any]:
|
||||||
async def get_admin_session(request: Request, db: AsyncSession) -> AdminSession:
|
|
||||||
raw = request.cookies.get(COOKIE)
|
|
||||||
row = await db.get(AdminSession, digest(raw)) if raw else None
|
|
||||||
if row is None or row.expires_at <= datetime.now(UTC):
|
|
||||||
raise HTTPException(401, "Admin session expired")
|
|
||||||
return row
|
|
||||||
|
|
||||||
|
|
||||||
async def require_csrf(request: Request, session: AdminSession) -> None:
|
|
||||||
candidate = request.headers.get("x-csrf-token", "")
|
|
||||||
if not candidate or not secrets.compare_digest(digest(candidate), session.csrf_hash):
|
|
||||||
raise HTTPException(403, "CSRF token required")
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/v1/admin/login")
|
|
||||||
async def login(
|
|
||||||
body: AdminLoginRequest,
|
|
||||||
request: Request,
|
|
||||||
response: Response,
|
|
||||||
db: AsyncSession = Depends(db_session),
|
|
||||||
):
|
|
||||||
user = await db.get(AdminUser, body.email.strip().lower())
|
|
||||||
if user is None or not password_hash.verify(body.password, user.password_hash):
|
|
||||||
raise HTTPException(401, "Invalid email or password")
|
|
||||||
raw, csrf_token = secrets.token_urlsafe(32), secrets.token_urlsafe(32)
|
|
||||||
ttl = request.app.state.settings.session_ttl_seconds
|
|
||||||
db.add(
|
|
||||||
AdminSession(
|
|
||||||
id=digest(raw),
|
|
||||||
admin_email=user.email,
|
|
||||||
csrf_hash=digest(csrf_token),
|
|
||||||
expires_at=datetime.now(UTC) + timedelta(seconds=ttl),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
await db.commit()
|
|
||||||
response.set_cookie(
|
|
||||||
COOKIE,
|
|
||||||
raw,
|
|
||||||
httponly=True,
|
|
||||||
secure=request.app.state.settings.admin_cookie_secure
|
|
||||||
or request.app.state.settings.environment == "production",
|
|
||||||
samesite="lax",
|
|
||||||
max_age=ttl,
|
|
||||||
path="/",
|
|
||||||
)
|
|
||||||
return {"email": user.email, "csrf_token": csrf_token, "expires_in": ttl}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/v1/admin/me")
|
|
||||||
async def me(request: Request, db: AsyncSession = Depends(db_session)):
|
|
||||||
session = await get_admin_session(request, db)
|
|
||||||
return {"email": session.admin_email, "authenticated": True}
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/v1/admin/logout")
|
|
||||||
async def logout(request: Request, response: Response, db: AsyncSession = Depends(db_session)):
|
|
||||||
session = await get_admin_session(request, db)
|
|
||||||
await require_csrf(request, session)
|
|
||||||
await db.delete(session)
|
|
||||||
await db.commit()
|
|
||||||
response.delete_cookie(
|
|
||||||
COOKIE,
|
|
||||||
path="/",
|
|
||||||
httponly=True,
|
|
||||||
secure=request.app.state.settings.admin_cookie_secure
|
|
||||||
or request.app.state.settings.environment == "production",
|
|
||||||
samesite="lax",
|
|
||||||
)
|
|
||||||
return {"logged_out": True}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/v1/admin/ai-profiles")
|
|
||||||
async def list_profiles(request: Request, db: AsyncSession = Depends(db_session)):
|
|
||||||
await get_admin_session(request, db)
|
|
||||||
rows = (await db.scalars(select(AIProfile).order_by(AIProfile.version))).all()
|
|
||||||
return {"profiles": [profile_json(row) for row in rows]}
|
|
||||||
|
|
||||||
|
|
||||||
@router.put("/v1/admin/ai-profiles")
|
|
||||||
async def create_profile(
|
|
||||||
body: AIProfileInput, request: Request, db: AsyncSession = Depends(db_session)
|
|
||||||
):
|
|
||||||
session = await get_admin_session(request, db)
|
|
||||||
await require_csrf(request, session)
|
|
||||||
existing = await db.get(AIProfile, body.version)
|
|
||||||
values = body.model_dump(mode="json")
|
|
||||||
if existing and any(
|
|
||||||
getattr(existing, key) != values[key]
|
|
||||||
for key in ("model", "system_prompt", "timeout_seconds", "limits")
|
|
||||||
):
|
|
||||||
raise HTTPException(409, "Profile versions are immutable; use a new version")
|
|
||||||
rows = (await db.scalars(select(AIProfile).with_for_update())).all()
|
|
||||||
for row in rows:
|
|
||||||
row.active = False
|
|
||||||
if existing is None:
|
|
||||||
existing = AIProfile(**values, active=True)
|
|
||||||
db.add(existing)
|
|
||||||
else:
|
|
||||||
existing.active = True
|
|
||||||
await db.commit()
|
|
||||||
return profile_json(existing)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/v1/admin/runs", response_model=RunList)
|
|
||||||
async def list_runs(request: Request, db: AsyncSession = Depends(db_session)):
|
|
||||||
await get_admin_session(request, db)
|
|
||||||
rows = (
|
|
||||||
await db.scalars(select(ScoringRun).order_by(ScoringRun.created_at.desc()).limit(50))
|
|
||||||
).all()
|
|
||||||
return {"runs": [to_json(row) for row in rows]}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/v1/admin/runs/{run_id}", response_model=RunDetail)
|
|
||||||
async def admin_run(run_id: UUID, request: Request, db: AsyncSession = Depends(db_session)):
|
|
||||||
await get_admin_session(request, db)
|
|
||||||
row = await db.get(ScoringRun, str(run_id))
|
|
||||||
if row is None:
|
|
||||||
raise HTTPException(404, "Run not found")
|
|
||||||
return to_detail_json(row)
|
|
||||||
|
|
||||||
|
|
||||||
def profile_json(row: AIProfile) -> dict:
|
|
||||||
return {
|
return {
|
||||||
"version": row.version,
|
"version": row.version,
|
||||||
|
"provider": row.provider,
|
||||||
|
"base_url": row.base_url,
|
||||||
"model": row.model,
|
"model": row.model,
|
||||||
"system_prompt": row.system_prompt,
|
"system_prompt": row.system_prompt,
|
||||||
"timeout_seconds": row.timeout_seconds,
|
"timeout_seconds": row.timeout_seconds,
|
||||||
"limits": row.limits,
|
"limits": row.limits,
|
||||||
"active": row.active,
|
"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
|
||||||
|
|||||||
146
src/ai_platform/presentation/http/admin_tenant_key_routes.py
Normal file
146
src/ai_platform/presentation/http/admin_tenant_key_routes.py
Normal 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
|
||||||
@ -1,31 +1,45 @@
|
|||||||
import asyncio
|
from collections.abc import Awaitable, Callable
|
||||||
import hashlib
|
from typing import Any
|
||||||
import json
|
|
||||||
import secrets
|
|
||||||
from datetime import UTC, datetime
|
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Header, HTTPException, Request, Response
|
from fastapi import APIRouter, Depends, Header, HTTPException, Request, Response
|
||||||
from sqlalchemy import select
|
from fastapi.routing import APIRoute
|
||||||
from sqlalchemy.exc import IntegrityError
|
from starlette.responses import JSONResponse
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from ai_platform.infrastructure.persistence.models import AIProfile, ScoringRun, TenantKey
|
from ai_platform.lead_scoring.application.repository import ScoringRunRecord
|
||||||
from ai_platform.infrastructure.scoring import execute_run
|
from ai_platform.lead_scoring.application.run_use_cases import (
|
||||||
from ai_platform.infrastructure.security import digest
|
InvalidScoringPayload,
|
||||||
from ai_platform.lead_scoring.domain.scoring import derive_metrics
|
ScoringRunConflictError,
|
||||||
from ai_platform.presentation.http.dependencies import db_session
|
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.run_responses import RunDetail, RunSummary
|
||||||
from ai_platform.presentation.http.schemas import RunRequest
|
from ai_platform.presentation.http.schemas import RunRequest
|
||||||
|
|
||||||
router = APIRouter()
|
_MAX_RUN_BODY_BYTES = 1_000_000
|
||||||
|
|
||||||
|
|
||||||
def canonical(value: object) -> bytes:
|
class BoundedRunRoute(APIRoute):
|
||||||
return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode()
|
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 {
|
return {
|
||||||
"run_id": row.id,
|
"run_id": row.id,
|
||||||
"request_id": row.request_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 {
|
return {
|
||||||
**to_json(row),
|
**to_json(run),
|
||||||
"snapshot": row.request["snapshot"],
|
"snapshot": detail.snapshot,
|
||||||
"metrics": derive_metrics(row.request["snapshot"]),
|
"metrics": detail.metrics,
|
||||||
"ai_confidence": row.ai_confidence,
|
"ai_confidence": run.ai_confidence,
|
||||||
"ai_reasoning": row.ai_reasoning,
|
"ai_reasoning": run.ai_reasoning,
|
||||||
"ai_evidence": row.ai_evidence,
|
"ai_evidence": run.ai_evidence,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
async def tenant_id(
|
def create_scoring_router(
|
||||||
authorization: str | None = Header(default=None), db: AsyncSession = Depends(db_session)
|
get_tenant_keys: Callable[..., Awaitable[TenantAuthenticationUseCases]],
|
||||||
) -> str:
|
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 "):
|
if not authorization or not authorization.startswith("Bearer "):
|
||||||
raise HTTPException(401, "Tenant authentication required")
|
raise HTTPException(401, "Tenant authentication required")
|
||||||
key = authorization[7:]
|
tenant = await use_cases.authenticate(authorization[7:])
|
||||||
row = (
|
if tenant is None:
|
||||||
await db.execute(
|
|
||||||
select(TenantKey).where(TenantKey.verifier == digest(key), TenantKey.active.is_(True))
|
|
||||||
)
|
|
||||||
).scalar_one_or_none()
|
|
||||||
if row is None:
|
|
||||||
raise HTTPException(401, "Invalid tenant key")
|
raise HTTPException(401, "Invalid tenant key")
|
||||||
return row.tenant_id
|
return tenant
|
||||||
|
|
||||||
|
@router.get("/health/live")
|
||||||
@router.get("/health/live")
|
async def live() -> dict[str, str]:
|
||||||
async def live():
|
|
||||||
return {"status": "ok"}
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
@router.get("/health/ready")
|
||||||
@router.get("/health/ready")
|
async def ready(available: bool = Depends(get_database_readiness)) -> dict[str, str]:
|
||||||
async def ready(db: AsyncSession = Depends(db_session)):
|
if not available:
|
||||||
try:
|
raise HTTPException(503, "Database unavailable")
|
||||||
await db.execute(select(1))
|
|
||||||
except Exception as exc:
|
|
||||||
raise HTTPException(503, "Database unavailable") from exc
|
|
||||||
return {"status": "ready"}
|
return {"status": "ready"}
|
||||||
|
|
||||||
|
@router.post(
|
||||||
@router.post("/v1/platform/tenant-keys", status_code=201)
|
|
||||||
async def provision_key(
|
|
||||||
request: Request,
|
|
||||||
body: dict,
|
|
||||||
authorization: str | None = Header(default=None),
|
|
||||||
db: AsyncSession = Depends(db_session),
|
|
||||||
):
|
|
||||||
secret = request.app.state.settings.tenant_provisioning_secret
|
|
||||||
if (
|
|
||||||
secret is None
|
|
||||||
or not authorization
|
|
||||||
or not secrets.compare_digest(authorization, "Bearer " + secret.get_secret_value())
|
|
||||||
):
|
|
||||||
raise HTTPException(401, "Provisioner authentication required")
|
|
||||||
tenant = body.get("tenant_id")
|
|
||||||
if not isinstance(tenant, str) or not tenant.strip() or len(tenant) > 128:
|
|
||||||
raise HTTPException(422, "tenant_id must be a non-empty string up to 128 characters")
|
|
||||||
if await db.get(TenantKey, tenant):
|
|
||||||
raise HTTPException(409, "Tenant key already provisioned")
|
|
||||||
raw = "tnt_" + secrets.token_urlsafe(32)
|
|
||||||
db.add(TenantKey(tenant_id=tenant, verifier=digest(raw)))
|
|
||||||
try:
|
|
||||||
await db.commit()
|
|
||||||
except IntegrityError as exc:
|
|
||||||
await db.rollback()
|
|
||||||
raise HTTPException(409, "Tenant key already provisioned") from exc
|
|
||||||
return {"tenant_id": tenant, "tenant_key": raw}
|
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
|
||||||
"/v1/lead-scoring/runs",
|
"/v1/lead-scoring/runs",
|
||||||
status_code=202,
|
status_code=202,
|
||||||
response_model=RunSummary,
|
response_model=RunSummary,
|
||||||
responses={200: {"model": RunSummary}},
|
responses={200: {"model": RunSummary}},
|
||||||
)
|
)
|
||||||
async def create_run(
|
async def create_run(
|
||||||
body: RunRequest,
|
body: RunRequest,
|
||||||
request: Request,
|
|
||||||
response: Response,
|
response: Response,
|
||||||
tenant: str = Depends(tenant_id),
|
tenant: str = Depends(tenant_id),
|
||||||
raw_vkey: str | None = Header(default=None, alias="X-Tenant-VKey"),
|
raw_vkey: str | None = Header(default=None, alias="X-Tenant-VKey"),
|
||||||
db: AsyncSession = Depends(db_session),
|
idempotency_key: UUID = Header(alias="Idempotency-Key"),
|
||||||
):
|
use_cases: ScoringUseCases = Depends(get_scoring_use_cases),
|
||||||
payload = body.model_dump(mode="json")
|
):
|
||||||
canonical_body = canonical(payload)
|
if idempotency_key != body.request_id:
|
||||||
if raw_vkey and raw_vkey.encode() in canonical_body:
|
raise HTTPException(422, "Idempotency-Key must match request_id")
|
||||||
raise HTTPException(422, "V-Key must not appear in the scoring payload")
|
|
||||||
fingerprint = hashlib.sha256(canonical_body).hexdigest()
|
|
||||||
request_key = str(body.request_id)
|
|
||||||
old = (
|
|
||||||
await db.execute(
|
|
||||||
select(ScoringRun).where(
|
|
||||||
ScoringRun.tenant_id == tenant, ScoringRun.request_id == request_key
|
|
||||||
)
|
|
||||||
)
|
|
||||||
).scalar_one_or_none()
|
|
||||||
if old:
|
|
||||||
if old.fingerprint != fingerprint:
|
|
||||||
raise HTTPException(409, "Request UUID already used with a different request")
|
|
||||||
response.status_code = 200
|
|
||||||
return to_json(old)
|
|
||||||
profile = (
|
|
||||||
await db.execute(select(AIProfile).where(AIProfile.active.is_(True)))
|
|
||||||
).scalar_one_or_none()
|
|
||||||
row = ScoringRun(
|
|
||||||
tenant_id=tenant,
|
|
||||||
request_id=request_key,
|
|
||||||
fingerprint=fingerprint,
|
|
||||||
request=payload,
|
|
||||||
profile_version=profile.version if profile else "unconfigured",
|
|
||||||
)
|
|
||||||
db.add(row)
|
|
||||||
try:
|
try:
|
||||||
await db.commit()
|
result = await use_cases.create_run(tenant, body.model_dump(mode="json"), raw_vkey)
|
||||||
except IntegrityError as exc:
|
except InvalidScoringPayload as exc:
|
||||||
await db.rollback()
|
raise HTTPException(422, "V-Key must not appear in the scoring payload") from exc
|
||||||
raise HTTPException(409, "Request UUID conflict") from exc
|
except ScoringRunConflictError as exc:
|
||||||
try:
|
raise HTTPException(409, "Request UUID already used with a different request") from exc
|
||||||
task = asyncio.create_task(
|
except ScoringSchedulingFailed as exc:
|
||||||
execute_run(
|
|
||||||
request.app.state.sessions,
|
|
||||||
row.id,
|
|
||||||
raw_vkey,
|
|
||||||
request.app.state.aihub_client,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
except RuntimeError as exc:
|
|
||||||
row.status, row.completed_at, row.error_code = (
|
|
||||||
"failed",
|
|
||||||
datetime.now(UTC),
|
|
||||||
"execution_failed",
|
|
||||||
)
|
|
||||||
await db.commit()
|
|
||||||
raise HTTPException(503, "Run could not be scheduled") from exc
|
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)
|
@router.get("/v1/lead-scoring/runs/{run_id}", response_model=RunDetail)
|
||||||
async def get_run(
|
async def get_run(
|
||||||
run_id: UUID, tenant: str = Depends(tenant_id), db: AsyncSession = Depends(db_session)
|
run_id: UUID,
|
||||||
):
|
tenant: str = Depends(tenant_id),
|
||||||
row = (
|
use_cases: ScoringUseCases = Depends(get_scoring_use_cases),
|
||||||
await db.execute(
|
):
|
||||||
select(ScoringRun).where(ScoringRun.id == str(run_id), ScoringRun.tenant_id == tenant)
|
try:
|
||||||
)
|
detail = await use_cases.get_run(tenant, str(run_id))
|
||||||
).scalar_one_or_none()
|
except ScoringRunNotFound as exc:
|
||||||
if row is None:
|
raise HTTPException(404, "Run not found") from exc
|
||||||
raise HTTPException(404, "Run not found")
|
return to_detail_json(detail)
|
||||||
return to_detail_json(row)
|
|
||||||
|
return router
|
||||||
|
|||||||
@ -135,6 +135,8 @@ class RunRequest(BaseModel):
|
|||||||
class AIProfileInput(BaseModel):
|
class AIProfileInput(BaseModel):
|
||||||
model_config = ConfigDict(extra="forbid", frozen=True)
|
model_config = ConfigDict(extra="forbid", frozen=True)
|
||||||
version: str = Field(min_length=1, max_length=64)
|
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)
|
model: str = Field(min_length=1, max_length=128)
|
||||||
system_prompt: str = Field(min_length=1, max_length=8000)
|
system_prompt: str = Field(min_length=1, max_length=8000)
|
||||||
timeout_seconds: int = Field(ge=1, le=120)
|
timeout_seconds: int = Field(ge=1, le=120)
|
||||||
|
|||||||
2
stack.md
2
stack.md
@ -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.
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
- `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.
|
||||||
|
|||||||
@ -15,7 +15,7 @@ from ai_platform.bootstrap.app import create_app
|
|||||||
from ai_platform.infrastructure.config.settings import Settings
|
from ai_platform.infrastructure.config.settings import Settings
|
||||||
from ai_platform.infrastructure.persistence.database import make_session_factory
|
from ai_platform.infrastructure.persistence.database import make_session_factory
|
||||||
from ai_platform.infrastructure.persistence.models import AIProfile, ScoringRun, TenantKey
|
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
|
@pytest.fixture
|
||||||
|
|||||||
97
tests/test_admin_session_application.py
Normal file
97
tests/test_admin_session_application.py
Normal 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
|
||||||
80
tests/test_admin_user_application.py
Normal file
80
tests/test_admin_user_application.py
Normal 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
|
||||||
124
tests/test_ai_profile_admin_api.py
Normal file
124
tests/test_ai_profile_admin_api.py
Normal 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())
|
||||||
78
tests/test_ai_profile_persistence.py
Normal file
78
tests/test_ai_profile_persistence.py
Normal 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()
|
||||||
@ -7,12 +7,15 @@ import httpx
|
|||||||
import pytest
|
import pytest
|
||||||
from sqlalchemy import delete
|
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 import AiHubClient
|
||||||
|
from ai_platform.infrastructure.aihub_scoring import AiHubScoringGateway
|
||||||
from ai_platform.infrastructure.config.settings import Settings
|
from ai_platform.infrastructure.config.settings import Settings
|
||||||
from ai_platform.infrastructure.persistence.database import make_session_factory
|
from ai_platform.infrastructure.persistence.database import make_session_factory
|
||||||
from ai_platform.infrastructure.persistence.models import AIProfile, ScoringRun, TenantKey
|
from ai_platform.infrastructure.persistence.models import AIProfile, ScoringRun, TenantKey
|
||||||
from ai_platform.infrastructure.scoring import execute_run
|
from ai_platform.infrastructure.persistence.scoring import SqlAlchemyScoringRepository
|
||||||
from ai_platform.infrastructure.security import digest
|
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.routes import to_detail_json
|
||||||
from ai_platform.presentation.http.run_responses import RunDetail
|
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))
|
http_client = httpx.AsyncClient(transport=httpx.MockTransport(handler))
|
||||||
aihub = AiHubClient("https://aihub.test", http_client)
|
aihub = AiHubClient("https://aihub.test", http_client)
|
||||||
try:
|
try:
|
||||||
await execute_run(
|
async with factory() as db:
|
||||||
factory,
|
use_cases = build_scoring_execution_use_cases(
|
||||||
|
db,
|
||||||
|
{} if case == "missing_client" else {"aihub": AiHubScoringGateway(aihub)},
|
||||||
|
)
|
||||||
|
await use_cases.execute(
|
||||||
run_id,
|
run_id,
|
||||||
None if case == "missing_key" else VKEY,
|
None if case == "missing_key" else VKEY,
|
||||||
None if case == "missing_client" else aihub,
|
|
||||||
)
|
)
|
||||||
async with factory() as db:
|
async with factory() as db:
|
||||||
row = await db.get(ScoringRun, run_id)
|
row = await db.get(ScoringRun, run_id)
|
||||||
assert row is not None
|
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()
|
serialized = detail.model_dump_json()
|
||||||
assert VKEY not in serialized
|
assert VKEY not in serialized
|
||||||
assert len(calls) == expected_calls
|
assert len(calls) == expected_calls
|
||||||
|
|||||||
@ -71,7 +71,7 @@ def test_medium_two_pass_scoring_persists_weighted_result(
|
|||||||
with TestClient(app) as client:
|
with TestClient(app) as client:
|
||||||
created = client.post(
|
created = client.post(
|
||||||
"/v1/lead-scoring/runs",
|
"/v1/lead-scoring/runs",
|
||||||
headers={**headers, "X-Tenant-VKey": vkey},
|
headers={**headers, "X-Tenant-VKey": vkey, "Idempotency-Key": payload["request_id"]},
|
||||||
json=payload,
|
json=payload,
|
||||||
)
|
)
|
||||||
assert created.status_code == 202
|
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 = json.loads(FIXTURE.read_text())
|
||||||
fallback_payload["request_id"] = str(uuid4())
|
fallback_payload["request_id"] = str(uuid4())
|
||||||
fallback_payload["scoring_config"].update(rule_weight=0.25, ai_weight=0.75)
|
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
|
assert fallback.status_code == 202
|
||||||
fallback_summary = wait_for_terminal(client, fallback.json()["run_id"], headers)
|
fallback_summary = wait_for_terminal(client, fallback.json()["run_id"], headers)
|
||||||
assert fallback_summary["error_code"] == "vkey_missing"
|
assert fallback_summary["error_code"] == "vkey_missing"
|
||||||
|
|||||||
@ -6,6 +6,63 @@ from ai_platform.infrastructure.config.settings import Settings
|
|||||||
_TEST_DATABASE_URL = "postgresql+asyncpg://test:test@localhost/ai_platform_test"
|
_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():
|
def test_aihub_base_url_accepts_local_http():
|
||||||
settings = Settings(
|
settings = Settings(
|
||||||
database_url=_TEST_DATABASE_URL,
|
database_url=_TEST_DATABASE_URL,
|
||||||
|
|||||||
@ -4,7 +4,11 @@ import httpx
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from ai_platform.infrastructure.aihub import AiHubClient, AiHubError
|
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"
|
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(
|
score = await client.score(
|
||||||
model="openai/gpt-4o-mini",
|
model="openai/gpt-4o-mini",
|
||||||
system_prompt=_scoring_system_prompt("Đánh giá theo hồ sơ AI đã cấu hình."),
|
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")),
|
user_prompt=scoring_user_prompt(
|
||||||
|
snapshot,
|
||||||
|
[Evidence(item.signal, item.detail, item.source) for item in evidence.items],
|
||||||
|
),
|
||||||
vkey=VKEY,
|
vkey=VKEY,
|
||||||
timeout=7,
|
timeout=7,
|
||||||
)
|
)
|
||||||
|
|||||||
@ -7,7 +7,8 @@ from pydantic import ValidationError
|
|||||||
|
|
||||||
from ai_platform.bootstrap.app import create_app
|
from ai_platform.bootstrap.app import create_app
|
||||||
from ai_platform.infrastructure.config.settings import Settings
|
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.routes import to_detail_json
|
||||||
from ai_platform.presentation.http.run_responses import RunDetail, RunMetrics
|
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"] == {
|
assert schemas["RunList"]["properties"]["runs"]["items"] == {
|
||||||
"$ref": "#/components/schemas/RunSummary"
|
"$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():
|
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,
|
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.conversation_count == 3
|
||||||
assert detail.metrics.contact_message_recency_days == 0.04
|
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/ai-profiles",
|
||||||
"/v1/admin/runs",
|
"/v1/admin/runs",
|
||||||
"/v1/admin/runs/{run_id}",
|
"/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",
|
||||||
"/v1/lead-scoring/runs/{run_id}",
|
"/v1/lead-scoring/runs/{run_id}",
|
||||||
"/v1/platform/tenant-keys",
|
"/v1/platform/tenant-keys",
|
||||||
|
|||||||
28
tests/test_migration_heads.py
Normal file
28
tests/test_migration_heads.py
Normal 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"})
|
||||||
@ -85,10 +85,29 @@ def test_postgres_http_mock_lead_scoring_flow():
|
|||||||
assert provisioned.status_code == 201
|
assert provisioned.status_code == 201
|
||||||
tenant_keys[tenant] = provisioned.json()["tenant_key"]
|
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]}"}
|
auth_b = {"Authorization": f"Bearer {tenant_keys[tenant_b]}"}
|
||||||
created = client.post("/v1/lead-scoring/runs", headers=auth_a, json=payload)
|
created = client.post("/v1/lead-scoring/runs", headers=auth_a, json=payload)
|
||||||
assert created.status_code == 202
|
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()
|
assert "snapshot" not in created.json()
|
||||||
run_id = created.json()["run_id"]
|
run_id = created.json()["run_id"]
|
||||||
rules_only = wait_for_terminal(client, run_id, auth_a)
|
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"] = (
|
invalid["snapshot"]["conversations"][0]["messages"][0]["sent_at"] = (
|
||||||
"2025-12-31T10:00:00"
|
"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
|
assert rejected.status_code == 422
|
||||||
|
|
||||||
ai_requested = deepcopy(payload)
|
ai_requested = deepcopy(payload)
|
||||||
@ -158,7 +181,11 @@ def test_postgres_http_mock_lead_scoring_flow():
|
|||||||
ai_requested["scoring_config"]["ai_weight"] = 0.4
|
ai_requested["scoring_config"]["ai_weight"] = 0.4
|
||||||
fallback_response = client.post(
|
fallback_response = client.post(
|
||||||
"/v1/lead-scoring/runs",
|
"/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,
|
json=ai_requested,
|
||||||
)
|
)
|
||||||
assert fallback_response.status_code == 202
|
assert fallback_response.status_code == 202
|
||||||
|
|||||||
206
tests/test_notifications.py
Normal file
206
tests/test_notifications.py
Normal 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()
|
||||||
79
tests/test_openai_compatible_adapter.py
Normal file
79
tests/test_openai_compatible_adapter.py
Normal 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)
|
||||||
92
tests/test_openai_compatible_local.py
Normal file
92
tests/test_openai_compatible_local.py
Normal 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)
|
||||||
103
tests/test_profile_provider_execution.py
Normal file
103
tests/test_profile_provider_execution.py
Normal 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 == []
|
||||||
@ -1,10 +1,24 @@
|
|||||||
from copy import deepcopy
|
from copy import deepcopy
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from pydantic import ValidationError
|
from pydantic import ValidationError
|
||||||
|
|
||||||
|
from ai_platform.presentation.http.admin_tenant_key_routes import TenantKeySecret
|
||||||
from ai_platform.presentation.http.schemas import RunRequest
|
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):
|
def test_run_request_contains_caller_rules_and_weights(valid_run_payload):
|
||||||
request = RunRequest.model_validate(valid_run_payload)
|
request = RunRequest.model_validate(valid_run_payload)
|
||||||
|
|||||||
391
tests/test_scoring_use_cases.py
Normal file
391
tests/test_scoring_use_cases.py
Normal 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"
|
||||||
@ -1,7 +1,7 @@
|
|||||||
import hashlib
|
import hashlib
|
||||||
|
|
||||||
from ai_platform.infrastructure.security import digest
|
from ai_platform.lead_scoring.application.run_use_cases import canonical
|
||||||
from ai_platform.presentation.http.routes import canonical
|
from ai_platform.platform.security import digest
|
||||||
|
|
||||||
|
|
||||||
def test_canonical_body_fingerprint_ignores_mapping_key_order():
|
def test_canonical_body_fingerprint_ignores_mapping_key_order():
|
||||||
|
|||||||
@ -98,6 +98,7 @@ def test_seed_creates_one_run_and_reads_persisted_conversation_snapshot():
|
|||||||
f"/v1/lead-scoring/runs/{run_id}",
|
f"/v1/lead-scoring/runs/{run_id}",
|
||||||
]
|
]
|
||||||
assert client.calls[1][2]["json"] == payload
|
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"
|
assert client.calls[2][2]["headers"]["Authorization"] == "Bearer tenant-secret"
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -64,7 +64,10 @@ def test_api_seed_persists_and_reads_back_conversation_snapshot():
|
|||||||
json={"tenant_id": tenant_id},
|
json={"tenant_id": tenant_id},
|
||||||
)
|
)
|
||||||
assert provisioned.status_code == 201
|
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)
|
admitted = client.post("/v1/lead-scoring/runs", headers=tenant_headers, json=payload)
|
||||||
assert admitted.status_code == 202
|
assert admitted.status_code == 202
|
||||||
|
|||||||
209
tests/test_tenant_key_admin_api.py
Normal file
209
tests/test_tenant_key_admin_api.py
Normal 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())
|
||||||
164
tests/test_tenant_key_application.py
Normal file
164
tests/test_tenant_key_application.py
Normal 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
|
||||||
Loading…
x
Reference in New Issue
Block a user