From 6577bb74be3069f78e7c0fba1f77dfb631909715 Mon Sep 17 00:00:00 2001 From: hungtran24 Date: Fri, 9 Oct 2026 08:23:21 +0700 Subject: [PATCH] refactor: simplify scoring service, AI profile revisions and test suite Phase 2 and 3 of the platform rework. - Neutral request/response contract: external_id, metadata, X-AI-Key, run_id in the response; client-specific fields removed. - AI profile is a revision table: saving creates an inactive revision, activation is separate, try-run does not store a run. One chat-completions gateway serves AIHub and OpenAI-compatible profiles. - Add between and in rule operators. - Contracts moved to contracts/lead-scoring/ with a score-response fixture. - Admin Web updated for revisions, runs and the neutral fixture. - Consolidate tests; remove rewrite_architect.md and the stale handoff doc; rewrite spec, CONTEXT and ARCHITECTURE for the current design. Not verified: live AIHub/OpenAI behavior, Omni/EMS integration. --- .dockerignore | 1 - .env.example | 6 +- AGENTS.md | 4 +- ARCHITECTURE.md | 66 +- CONTEXT.md | 38 +- Makefile | 2 +- README.md | 10 +- apps/web/Dockerfile | 4 +- apps/web/package.json | 2 +- .../web/src/lib/server/fixture-runner.test.ts | 78 +- apps/web/src/lib/server/fixture-runner.ts | 8 +- .../lead-scoring/ai-profiles/+page.server.ts | 127 +- .../lead-scoring/ai-profiles/+page.svelte | 275 +- .../ai-profiles/ai-profiles.test.ts | 77 +- .../lead-scoring/fixtures/+page.server.ts | 7 +- .../lead-scoring/fixtures/+page.svelte | 13 +- .../fixtures/FixturePreview.svelte | 10 +- .../fixtures/FixturePreview.test.ts | 68 - .../lead-scoring/fixtures/fixtures.test.ts | 11 +- .../(admin)/lead-scoring/runs/+page.server.ts | 16 +- .../(admin)/lead-scoring/runs/+page.svelte | 125 +- .../lead-scoring/runs/[run_id]/+page.svelte | 109 +- .../runs/[run_id]/RunAnalysis.svelte | 193 +- .../runs/[run_id]/RunAnalysis.test.ts | 114 - compose.yaml | 1 - contracts/lead-scoring/README.md | 25 + .../{v2 => }/fixtures/run-request.json | 14 +- .../lead-scoring/fixtures/score-response.json | 47 + contracts/lead-scoring/openapi.json | 1 + contracts/lead-scoring/v2/README.md | 35 - contracts/lead-scoring/v2/contract.json | 1 - .../lead-scoring/v2/fixtures/run-detail.json | 143 - .../lead-scoring/v2/fixtures/run-summary.json | 28 - contracts/lead-scoring/v2/openapi.json | 1 - docs/contracts/app-auth-provider-handoff.md | 67 - docs/project_quality.md | 1 - docs/specs/ai-platform-lead-scoring.md | 71 +- migrations/versions/0001_initial.py | 63 +- pyproject.toml | 2 +- rewrite_architect.md | 2682 ----------------- scripts/export_contract_fixtures.py | 93 +- src/ai_platform/bootstrap/app.py | 69 +- src/ai_platform/bootstrap/wiring.py | 27 +- src/ai_platform/infrastructure/aihub.py | 182 -- .../infrastructure/aihub_scoring.py | 51 - .../infrastructure/chat_completions.py | 146 + .../infrastructure/config/settings.py | 68 +- .../openai_compatible_scoring.py | 139 - .../infrastructure/persistence/models.py | 60 +- .../infrastructure/persistence/scoring.py | 145 +- .../lead_scoring/application/admin.py | 110 +- .../lead_scoring/application/ai_provider.py | 34 +- .../lead_scoring/application/repository.py | 79 +- .../lead_scoring/application/run_use_cases.py | 53 +- .../lead_scoring/application/scoring.py | 1 - .../application/scoring_execution.py | 115 +- .../application/scoring_prompts.py | 67 +- .../lead_scoring/domain/safe_http_url.py | 27 + .../lead_scoring/domain/scoring.py | 15 +- .../presentation/http/admin_routes.py | 112 +- src/ai_platform/presentation/http/routes.py | 56 +- .../presentation/http/rule_schemas.py | 54 +- .../presentation/http/run_mapping.py | 57 + .../presentation/http/run_responses.py | 118 +- src/ai_platform/presentation/http/schemas.py | 105 +- stack.md | 3 +- tests/conftest.py | 173 +- tests/fixtures/mock_omni_run.json | 53 - tests/test_admin_profile_use_cases.py | 207 -- tests/test_ai_profile_admin_api.py | 204 +- tests/test_ai_profile_persistence.py | 78 - tests/test_aihub_adapter.py | 97 - tests/test_aihub_settings.py | 97 - tests/test_aihub_two_pass.py | 104 - tests/test_api_surface.py | 152 - tests/test_app_admin_api.py | 138 +- tests/test_chat_completions.py | 114 + tests/test_contract.py | 43 + tests/test_database_error_mapping.py | 6 +- tests/test_derived_metrics.py | 213 +- tests/test_dev_commands.py | 68 - tests/test_domain_scoring.py | 72 +- tests/test_evaluate.py | 135 +- tests/test_initial_migration.py | 31 +- tests/test_openai_compatible_adapter.py | 59 - tests/test_request_contract.py | 183 ++ tests/test_rule_descriptions.py | 74 - tests/test_rule_validation.py | 91 - tests/test_run_contract.py | 95 - tests/test_score_endpoint.py | 229 +- tests/test_settings_and_url.py | 45 + tests/test_snapshot_contract.py | 149 - 92 files changed, 2399 insertions(+), 7043 deletions(-) delete mode 100644 apps/web/src/routes/(admin)/lead-scoring/fixtures/FixturePreview.test.ts delete mode 100644 apps/web/src/routes/(admin)/lead-scoring/runs/[run_id]/RunAnalysis.test.ts create mode 100644 contracts/lead-scoring/README.md rename contracts/lead-scoring/{v2 => }/fixtures/run-request.json (94%) create mode 100644 contracts/lead-scoring/fixtures/score-response.json create mode 100644 contracts/lead-scoring/openapi.json delete mode 100644 contracts/lead-scoring/v2/README.md delete mode 100644 contracts/lead-scoring/v2/contract.json delete mode 100644 contracts/lead-scoring/v2/fixtures/run-detail.json delete mode 100644 contracts/lead-scoring/v2/fixtures/run-summary.json delete mode 100644 contracts/lead-scoring/v2/openapi.json delete mode 100644 docs/contracts/app-auth-provider-handoff.md delete mode 100644 rewrite_architect.md delete mode 100644 src/ai_platform/infrastructure/aihub.py delete mode 100644 src/ai_platform/infrastructure/aihub_scoring.py create mode 100644 src/ai_platform/infrastructure/chat_completions.py delete mode 100644 src/ai_platform/infrastructure/openai_compatible_scoring.py create mode 100644 src/ai_platform/lead_scoring/domain/safe_http_url.py create mode 100644 src/ai_platform/presentation/http/run_mapping.py delete mode 100644 tests/fixtures/mock_omni_run.json delete mode 100644 tests/test_admin_profile_use_cases.py delete mode 100644 tests/test_ai_profile_persistence.py delete mode 100644 tests/test_aihub_adapter.py delete mode 100644 tests/test_aihub_settings.py delete mode 100644 tests/test_aihub_two_pass.py delete mode 100644 tests/test_api_surface.py create mode 100644 tests/test_chat_completions.py create mode 100644 tests/test_contract.py delete mode 100644 tests/test_dev_commands.py delete mode 100644 tests/test_openai_compatible_adapter.py create mode 100644 tests/test_request_contract.py delete mode 100644 tests/test_rule_descriptions.py delete mode 100644 tests/test_rule_validation.py delete mode 100644 tests/test_run_contract.py create mode 100644 tests/test_settings_and_url.py delete mode 100644 tests/test_snapshot_contract.py diff --git a/.dockerignore b/.dockerignore index 06f1b51..693c97d 100644 --- a/.dockerignore +++ b/.dockerignore @@ -29,4 +29,3 @@ test-results/ .codex/ .scratch/ vault/ -rewrite_architect.md diff --git a/.env.example b/.env.example index 674bb2d..a305fad 100644 --- a/.env.example +++ b/.env.example @@ -16,9 +16,9 @@ AI_PLATFORM_ENVIRONMENT=development AI_PLATFORM_DATABASE_URL=postgresql+asyncpg://ai_platform:local-change-this-password@127.0.0.1:55432/ai_platform AI_PLATFORM_ADMIN_COOKIE_SECURE=false AI_PLATFORM_SESSION_TTL_SECONDS=28800 -# Lead Scoring AI profile selects AIHub by default. Omni supplies X-Tenant-VKey per AI request. -# AI_PLATFORM_AIHUB_BASE_URL=http://localhost:8080 -# Alternative for an internal Lead Scoring AI profile: server-only OpenAI-compatible endpoint. +# Lead Scoring AI profile (provider, URL, model) is edited in Admin Web. AIHub profiles use the +# client key from the X-AI-Key request header; no server setting is needed. +# Optional server-only OpenAI-compatible endpoint for an openai_compatible profile. # 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. diff --git a/AGENTS.md b/AGENTS.md index b22fad8..99c7e5f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,8 +4,8 @@ This repository is the rebuilt AI Platform. Historical implementation is retaine 1. Before changing Lead Scoring behavior, read `docs/specs/ai-platform-lead-scoring.md`, `CONTEXT.md`, and `ARCHITECTURE.md`. Read `stack.md` when choosing a runtime or dependency. Generated OpenAPI and small Omni fixtures own exact wire shapes. 2. Put app/admin authentication, configuration and shared DB setup in `platform`; put deterministic rules/metrics and run lifecycle in `lead_scoring/domain`; orchestration in `application`, I/O in `infrastructure`, HTTP mapping in `presentation`, and wiring in `bootstrap`. Churn follows the same layout only when a specification exists. -3. Omni owns rules/weights/HITL/CRM; AI Platform owns AI profile and technical run status. V-Key is request-scoped RAM only. One app key per Omni installation authenticates the platform; run context includes account_id. Admin Web uses a distinct session. Terminal webhook triggers Omni GET. -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. +3. The client (Omni) owns rules/weights/thresholds/HITL/CRM; AI Platform owns AI profile revisions and the scoring run record. The AI key (`X-AI-Key`) is request-scoped RAM only. One app key per installation authenticates the platform. Admin Web uses a distinct session. +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 trong `contracts/lead-scoring/`, 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. 5. File trên 200 dòng là tín hiệu review: kiểm tra trách nhiệm trộn lẫn, tách tại boundary có nghĩa. Giữ một OpenAPI generated và vài Omni fixtures thật, không tạo hệ schema thứ hai. 6. Trước bàn giao, chạy Ruff/Pyright/Import Linter/pytest, Svelte checks/build/tests, migration và contract generation có liên quan. Báo chính xác pass/fail; hook opt-in, CI là gate. Không stage `.env`, `.env.test`, ignored assets hoặc secrets. 7. Thay đổi ranh giới kiến trúc, dependency direction, DI/composition root hoặc ownership transaction do main agent trực tiếp phân tích, quyết định và triển khai. Có thể giao review/test chỉ đọc độc lập, nhưng không giao phần thiết kế và implementation các thay đổi này. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index d123f03..d19c1c7 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -2,59 +2,53 @@ ## Status and scope -This document records boundaries for the approved AI Platform rebuild and app-auth baseline `omni-ai-platform-app-auth` revision 2. The local implementation is covered by provider checks documented in `docs/contracts/app-auth-provider-handoff.md`; cross-service and production acceptance remain pending. The root Lead Scoring provider specification `docs/specs/ai-platform-lead-scoring.md` remains `review_ready` for that acceptance. Historical implementation is available in Git history only; the current backend and Admin Web live under `src/ai_platform/` and `apps/web/`. +This document records module boundaries for the AI Platform Lead Scoring service. Behavior is in `docs/specs/ai-platform-lead-scoring.md`; wire shapes are in `contracts/lead-scoring/`. The current backend and Admin Web live under `src/ai_platform/` and `apps/web/`. Local checks do not establish client integration, live AI behavior or production readiness. -AI Platform is a provider service: it executes bounded rule scoring and a selected AI profile, maintains scoring-run lifecycle, and exposes result retrieval. DotB-Omni owns customer-configured rules/weights, HITL and customer actions, snapshot construction, user authorization, and CRM writeback. Omni is notified of terminal outcome through an agreed signed HMAC webhook and then calls the authenticated GET endpoint for run details. DotB EMS remains the CRM system of record. There is no customer-facing settings or review UI in this service scope. +AI Platform is a shared scoring service. A client sends a snapshot with rules and weights to one synchronous endpoint and receives the score, breakdown, summary, signals and suggested action. The client owns thresholds, human review, user authorization and CRM writeback. There is no customer-facing UI in this service. -## Intended backend boundaries +## Backend boundaries -The modular monolith has one runtime and one deployable backend. These boundaries apply to the implementation under `src/ai_platform/`. +One runtime, one deployable backend, under `src/ai_platform/`. -- **Bootstrap/composition:** loads validated settings and composes framework, database, provider client, and application workflows. Resource lifetime is established here. -- **Platform:** app-key verification, Lead Scoring permission and app-scoped run access, shared persistence/runtime concerns, and operational interfaces. It does not own Omni customer configuration or user permissions. -- **Lead Scoring domain:** rule execution, scoring-run state/invariants, and profile semantics. Deterministic scoring rules should be pure functions; run identity and lifecycle belong in an entity/value model where useful. -- **Lead Scoring application:** admission, idempotency, execution coordination, result retrieval, and notification workflow. Use cases own transaction meaning; infrastructure supplies mechanisms. Its versioned AI profile selects AIHub or OpenAI-compatible for the service and contains model, prompts and timeout; OpenAI-compatible URL is also versioned, while API key stays in server settings. App-provided rules and weights arrive in each run body. Bootstrap constructs the AIHub adapter and an OpenAI-compatible adapter for the URL captured in the run's profile; raw V-Key is forwarded only for AIHub. -- **Infrastructure:** database mappings/repositories, separate AIHub and OpenAI-compatible HTTP adapters, HMAC signing, and delivery adapters. Provider details stop at these boundaries. -- **Presentation:** HTTP request/response mapping, app-auth dependency at the edge, health/readiness, and API error mapping. Transport schemas do not become domain objects by accident. +- **Bootstrap:** loads validated settings and composes framework, database, one shared HTTP client and use cases. Resource lifetime is established here. +- **Platform:** app-key verification, admin auth and sessions, shared persistence and settings. +- **Lead Scoring domain:** pure rule execution and metrics, canonical fingerprinting, `safe_http_url`. No framework, network, environment or clock access. +- **Lead Scoring application:** the score use case (idempotent lookup, AI evaluation under a deadline, persistence), prompt building, the AI profile admin use cases and the ports they need. Use cases own commit and rollback. +- **Infrastructure:** SQLAlchemy repositories and one chat-completions HTTP gateway used for both AIHub and OpenAI-compatible profiles. Provider details stop here. +- **Presentation:** HTTP schemas, mapping, auth dependencies and error mapping. Transport schemas do not become domain objects. -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 and enforced by Import Linter: domain depends on nothing; application must not import infrastructure, presentation or bootstrap; presentation must not import infrastructure or SQLAlchemy. Avoid generic base services, empty ports, service locators and extra processes unless evidence requires them. ## Lifecycle and data invariants -1. Authenticate the Omni installation as a stable app and require its Lead Scoring permission before accessing a run. -2. Validate bounded request input and calculate a stable canonical-body fingerprint. -3. Scope client-supplied UUID idempotency to `(app_id, account_id, request_id)`. Same identity/same canonical body resolves to the existing run; same identity/different body conflicts. The service generates a distinct run UUID. Omni owns account/user authorization; the provider does not register accounts. -4. Commit admitted run before acknowledging acceptance. Execution holds raw V-Key only in current process memory; it is never persisted. -5. Rules-only requires no AIHub or V-Key; medium bounded two-pass is the default when AI is requested. Light/heavy are deferred until verified demand, not placeholders. -6. Persist terminal outcome independently from notification delivery. A server-configured per-app URL and signing secret route the signed webhook, which carries app/account/request/run identity. Missing callback configuration blocks new admission after replay lookup. Omni performs authenticated GET for the authoritative result. Key issuance/rotation stays in operator CLI; Admin Web lists status and revokes without receiving keys. -7. One-process recovery terminalizes work that cannot resume after interruption. It does not restore the lost V-Key or promise an automatic retry. -8. Delete/redact sensitive run material at 90 days under an approved policy; app offboarding and backup treatment need operational decisions. - -Local provider tests cover these constraints within AI Platform. Consumer behavior, external AIHub protocol and production operations still require the acceptance checks in the spec. +1. Authenticate the app by its key; the app ID comes from the key, never the body. +2. Validate bounded input and fingerprint the canonical body. +3. Look up `(app_id, idempotency_key)` before any AI call. Same fingerprint replays the stored result; a different one is rejected. A concurrent insert race re-reads the winner. +4. Commit the read transaction before the AI call. The AI call has a deadline from the active profile; failure or timeout degrades to the rule score with an error code. +5. The AI key lives in memory for the call only and is never persisted, logged or returned. +6. Persist the run with the profile revision used, then return the result. Replay returns the stored result. +7. AI profile saves create inactive revisions; activation is separate and revisions are never edited. ## Transaction and effect boundaries -- Application use cases define atomic boundaries, including run transition and any durable notification intent where selected. Persistence adapters implement transactions. -- AIHub requests are network effects with explicit timeout and bounded policy once AIHub behavior has been verified. Never add speculative provider retries. -- HMAC delivery is an effect with bounded retry and observable terminal delivery state. Exact signing and replay rules are contract decisions with Omni. -- Clock, UUID generation, provider client, and persistence are explicit inputs at the application boundary when deterministic testing or lifecycle behavior requires them. -- Domain functions are deterministic and I/O-free. Stateful objects are justified by identity, invariant, or lifecycle rather than used as namespaces. +- Use cases define atomic boundaries; persistence adapters implement them. +- The AI call is a network effect with an explicit timeout and no automatic retry. +- Clock, provider factory and persistence are explicit inputs at the application boundary. +- Domain functions are deterministic and I/O-free. ## Admin Web design system Admin Web dùng SvelteKit 5, Tailwind CSS v4 semantic tokens, shadcn-svelte component source cho button/input/card/badge/table khi UI cần, Bits UI cho dialog/dropdown/popover hoặc tương tác phức tạp, và một icon family Lucide nếu cần icon. Chỉ thêm component đang dùng, không scaffold cả catalog. `src/lib/components/ui/` chỉ chứa primitives không biết domain; route giữ thành phần chỉ dùng tại route. HTML native là lựa chọn đúng cho form, label và bảng đơn giản khi không cần behavior phức tạp. Style tokens có một nguồn trong `apps/web/src/app.css`; UI phải kiểm tra focus, keyboard, validation, responsive và contrast. Browser chỉ gọi SvelteKit; backend auth/credentials và `BACKEND_INTERNAL_URL` thuộc `$lib/server/`. Không tạo frontend Clean Architecture giả lập. -## Verification structure (target) +## Verification structure -- Unit: domain calculations, validation and transitions without network/database. -- Integration: persistence/migrations on the selected production database and AIHub HTTP adapter with controlled transport. -- Functional: HTTP request through application/persistence and back, including app isolation and account-scoped idempotency. -- Architecture: import direction, framework isolation, and composition-root rules. -- Contract: shared provider/Omni fixtures for requests, replay conflicts, result GET, HMAC delivery, acknowledgements, and errors. -- Operational: process interruption, recovery terminality, 90-day redaction, secret-leak sentinels, and deployment restore drills. - -Actual test commands, directories, and enforcement must follow checked-in tooling once present; do not describe this target taxonomy as an existing test suite. +- Domain: rule operators, metrics and score formulas without network or database. +- Endpoint: the score route through application and persistence on PostgreSQL (`ai_platform_test`) with a mock AI transport, covering result modes, replay, concurrency, auth and error shapes. +- Adapter: request shape and error mapping of the chat-completions gateway. +- Contract: committed OpenAPI and fixtures match the generator; the initial migration matches the models. +- Architecture: Import Linter contracts. +- Web: server-only logic with Vitest; `svelte-check` against types generated from OpenAPI. ## Deferred decisions -Production hosting/orchestrator, network and TLS topology, secret manager and rotation ownership, database backup/restore and deletion guarantees, alerting/observability destination, load/SLO targets, exact contract publication/versioning, and AIHub protocol/model/timeouts remain deferred. Resolve them with accountable owners before the corresponding implementation or deployment. No production-readiness claim is warranted while these remain open. +Production hosting, TLS topology, secret management, database backup and retention, alerting, load targets and the real AIHub protocol and model behavior remain open. Resolve them with accountable owners before deployment. No production-readiness claim is warranted while these remain open. diff --git a/CONTEXT.md b/CONTEXT.md index a40a4df..8011a30 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -1,34 +1,28 @@ # Context -Canonical language for the AI Platform Lead Scoring provider. The approved rebuild and app-auth baseline `omni-ai-platform-app-auth` revision 2 are implemented and covered by local provider tests. The provider specification `docs/specs/ai-platform-lead-scoring.md` remains `review_ready` for cross-service acceptance. Local checks do not establish Omni/EMS integration or production readiness; see `docs/contracts/app-auth-provider-handoff.md` for evidence and remaining checks. Current implementation is under `src/ai_platform/` and `apps/web/`; historical code and contracts are available in Git history only. +Canonical language for the AI Platform Lead Scoring service. Behavior is specified in `docs/specs/ai-platform-lead-scoring.md`; exact wire shapes are in `contracts/lead-scoring/`. Local checks do not establish client (Omni/EMS) integration, live AI behavior or production readiness. ## Systems and ownership -- **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. +- **AI Platform:** a shared, stateless-in-business-terms scoring service. It executes rules, calls an AI model, returns the result and stores the run for replay and the Admin Web. +- **Client (currently DotB-Omni):** owns rules, weights, score thresholds, human review, user authorization, lead snapshot construction and CRM writeback. The service has no client-specific code; client data travels in `metadata`. - **DotB EMS:** CRM system of record for leads. -- **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. +- **AIHub:** external AI gateway that receives the client's AI key from the `X-AI-Key` header. An AI profile may instead select an OpenAI-compatible endpoint with a server-side key. AIHub's real protocol and behavior are not independently verified. ## Domain terms -- **App / installation:** one Omni deployment/environment with a stable `app_id`; one backend API key shared by its accounts. Provider checks active state and the simple Lead Scoring permission, and stores only a verifier. Rotation keeps run ownership. Admin Web uses an independent session; issuance/rotation is operator CLI only. -- **Account:** Omni workspace identified by `account_id` in the request. Omni owns user/account/EMS authorization; AI Platform does not register accounts. `(app_id, account_id)` distinguishes workspaces across installations. -- **V-Key:** sensitive AIHub credential material. Target rule: keep raw value in memory only for the active execution attempt; never persist, log, trace, or send it to the browser. Source and exact forwarding protocol are unresolved. -- **Canonical snapshot:** bounded input assembled by Omni from the lead and permitted evidence. It is an input snapshot, not an AI Platform-owned CRM record. -- **Scoring configuration:** customer-authored rule/weight and HITL configuration owned by Omni. The provider may consume an immutable version with the request for reproducibility; it does not become the customer configuration authority. -- **AI profile:** `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 app/account context, 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 `(app_id, account_id, request_id)`. Same UUID and same canonical body replays the existing run; same UUID and different body conflicts. A new scoring attempt uses a new UUID. Fingerprint normalization uses the HTTP-validated body serialized as sorted-key compact UTF-8 JSON; generated OpenAPI and fixtures define the input contract. -- **Result GET:** app-authenticated read of the authoritative run result. Omni calls it after receiving a signed terminal notification. -- **HITL (human-in-the-loop):** human review workflow and customer actions owned by Omni. The provider does not authorize end users or own the review interface. -- **Terminal notification:** provider-to-Omni signed HMAC webhook carrying app/account/request/run identity and terminal status. Server-side `AI_PLATFORM_APP_WEBHOOKS` chooses each app's destination and secret. The notification is not the result source of truth; Omni GETs the run. Signature/replay and consumer writeback still require cross-service acceptance. -- **Recovery:** after interruption of the single-process runtime, terminalize work that cannot continue because its volatile task/V-Key was lost. Recovery is not resumption and does not imply automatic re-execution. -- **Retention:** proposed deletion/redaction of sensitive run material after 90 days. Backup deletion, audit retention, and app offboarding behavior need explicit approval. +- **App / installation:** one client deployment with a stable `app_id` and one backend API key. The service stores only a verifier. Rotation keeps run ownership. Admin Web uses an independent session; issuance and rotation are operator CLI only. +- **Snapshot:** bounded input assembled by the client from a lead and its conversations. +- **Scoring configuration:** rules, `rule_weight` and `ai_weight`, supplied per request by the client. +- **Rule score / AI score / final score:** the deterministic rule result, the model's score, and `clamp(round(rule*rule_weight + ai*ai_weight), 0, 100)`. +- **AI profile:** a service-owned, revisioned configuration (provider, URL, model, timeout, reasoning effort, rubric instructions, prompt switches). Saving creates an inactive revision; activating is a separate step. Each run records the revision used. +- **AI key:** the client's credential for AIHub, sent per request in `X-AI-Key` and held in memory only. +- **Idempotency key:** a client UUID, unique per `(app_id, idempotency_key)`. Same key and body replays the stored result; same key and different body is rejected. +- **Result mode:** `rules_only`, `hybrid` or `rules_only_fallback`; the fallback carries an `error_code`. +- **Metadata:** an opaque client JSON object (max 10KB) stored and returned unchanged. ## Disambiguation -- **Rules and weights** are customer policy owned by Omni; **rule execution** is provider behavior owned by AI Platform. -- **Customer action** means Omni-authorized configuration/review/initiation behavior; an API caller's identity does not establish end-user permission. -- **Webhook delivery state** is separate from scoring-run state. A notification failure does not by itself rewrite a completed scoring result. -- **Idempotency** prevents duplicate admission for a body UUID; it does not guarantee exactly-once AIHub invocation or webhook delivery. -- **Draft/review_ready** means prepared for stakeholder review only. It does not mean approved, released, or implemented. +- **Rules and weights** are client policy; **rule execution** is service behavior. +- **Idempotency** prevents duplicate runs for a key; it does not guarantee exactly-once AI invocation. +- **Draft/review_ready** means prepared for review only, not approved or implemented. diff --git a/Makefile b/Makefile index c7fc1ad..8c68426 100644 --- a/Makefile +++ b/Makefile @@ -32,7 +32,7 @@ web-dev: ## Run SvelteKit with the shared backend address cd apps/web && BACKEND_INTERNAL_URL=http://127.0.0.1:$(BACKEND_PORT) bun run dev -- --host 127.0.0.1 contract: ## Generate the backend OpenAPI consumed by Admin Web - uv run --no-sync ai-platform export-openapi contracts/lead-scoring/v2/openapi.json + uv run --no-sync ai-platform export-openapi contracts/lead-scoring/openapi.json uv run --no-sync python scripts/export_contract_fixtures.py check-fast: ## Check staged/all tracked source with opt-in pre-commit rules diff --git a/README.md b/README.md index 9075fbd..f45e524 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # AI Platform -AI Platform provides Lead Scoring with a Python backend and internal SvelteKit Admin Web. Local app-auth verification and integration limitations are recorded in `docs/contracts/app-auth-provider-handoff.md`; local checks do not establish production readiness. +AI Platform provides Lead Scoring with a Python backend and internal SvelteKit Admin Web. Behavior is in `docs/specs/ai-platform-lead-scoring.md` and the client contract in `contracts/lead-scoring/`; local checks do not establish client integration or production readiness. ## Runtime layout @@ -66,7 +66,7 @@ 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à app key; OpenAI-compatible không dùng `X-Tenant-VKey`. Trang “Thử chấm điểm” trên Web cho xem trước hội thoại và quy tắc mẫu. Mặc định chỉ chấm quy tắc (`ai_weight=0`); bật “Chấm thêm bằng AI” để gửi `ai_profile=medium` với trọng số quy tắc/AI 0.5/0.5. Chế độ này dùng provider đang active và có thể phát sinh chi phí. Mẫu không gửi V-Key nên với AIHub sẽ dự phòng bằng điểm quy tắc; xem trang chi tiết run để kiểm tra kết quả thực tế. +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 (Cấu hình AI), soạn một revision mới với `OpenAI-compatible`, URL `http://127.0.0.1:8317`, model `gemini-3.5-flash-lite`, hướng dẫn chấm và timeout, chạy thử rồi Kích hoạt. `AI_PLATFORM_OPENAI_API_KEY` chỉ được gửi tới `AI_PLATFORM_OPENAI_BASE_URL`; URL khác nhập trên Web không nhận key đó. Profile AIHub dùng khóa AI của client trong header `X-AI-Key` (không lưu). Trang “Thử chấm điểm” gửi mẫu với trọng số quy tắc/AI 0.5/0.5 khi bật “Chấm thêm bằng AI”; với AIHub, đặt `LEAD_SCORING_FIXTURE_AI_KEY` trên server Web, nếu không lần chấm dự phòng bằng điểm quy tắc. Chế độ này có thể phát sinh chi phí. 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. @@ -80,15 +80,15 @@ uv run --no-sync ai-platform rotate-app-key omni-local uv run --no-sync ai-platform revoke-app-key omni-local ``` -Admin Web `/platform/apps` chỉ xem trạng thái và thu hồi, không hiển thị hay cấp/rotate key. POST `/v1/lead-scoring/score` dùng `Authorization: Bearer `; body có `account_id` từ Omni. Omni dùng `LEAD_SCORING_API_URL`, `AI_PLATFORM_APP_ID`, `AI_PLATFORM_APP_API_KEY` ở backend. Đã bỏ tenant provisioning API/secret; không đăng ký account ở AI Platform. +Admin Web `/platform/apps` chỉ xem trạng thái và thu hồi, không hiển thị hay cấp/rotate key. POST `/v1/lead-scoring/score` đồng bộ, dùng `Authorization: Bearer `, `Idempotency-Key` (UUID) và tùy chọn `X-AI-Key`. Dữ liệu riêng của client đi trong `metadata` và được lưu nguyên. Omni dùng `LEAD_SCORING_API_URL`, `AI_PLATFORM_APP_ID`, `AI_PLATFORM_APP_API_KEY` ở backend. Không có webhook hay đăng ký account ở AI Platform. -Generated OpenAPI và các fixture tại `contracts/lead-scoring/v2/`; chạy `make contract` để tái sinh và `uv run --no-sync python scripts/export_contract_fixtures.py --check` để kiểm tra drift fixture mà không sửa file. Omni cần đối chiếu response/source request trước writeback; provider checks chưa chứng minh tích hợp Omni/EMS. +Generated OpenAPI và các fixture tại `contracts/lead-scoring/`; chạy `make contract` để tái sinh và `uv run --no-sync python scripts/export_contract_fixtures.py --check` để kiểm tra drift fixture mà không sửa file. Provider checks chưa chứng minh tích hợp Omni/EMS. Schema chưa phát hành hiện có một migration `0001_initial`, tạo thẳng app credentials, admin, AI profiles, runs. Database mới chạy `make migrate`. Database local còn revision cũ không tự upgrade sang baseline: dùng database mới, hoặc xác nhận phạm vi dữ liệu cần giữ/bỏ trước khi dựng lại hay stamp sau kiểm tra schema; không chạy `alembic stamp head` để che schema khác biệt. Startup vẫn kiểm tra revision và không tự migrate. Lịch sử migrations sau lần phát hành đầu tiên phải được giữ. Fixture runner nội bộ dùng `LEAD_SCORING_FIXTURE_APP_API_KEY` server-only. -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. Backup và app/account offboarding cần chính sách riêng trước production. +Backup và app/account offboarding cần chính sách riêng trước production. ## Quality checks diff --git a/apps/web/Dockerfile b/apps/web/Dockerfile index 4cc0fea..995bbd9 100644 --- a/apps/web/Dockerfile +++ b/apps/web/Dockerfile @@ -4,8 +4,8 @@ WORKDIR /app COPY apps/web/package.json apps/web/bun.lock ./ RUN bun install --frozen-lockfile COPY apps/web/ ./ -COPY contracts/lead-scoring/v2/openapi.json /contracts/lead-scoring/v2/openapi.json -COPY contracts/lead-scoring/v2/fixtures/run-request.json /contracts/lead-scoring/v2/fixtures/run-request.json +COPY contracts/lead-scoring/openapi.json /contracts/lead-scoring/openapi.json +COPY contracts/lead-scoring/fixtures/run-request.json /contracts/lead-scoring/fixtures/run-request.json RUN bun run build FROM oven/bun:1.4.0 AS runtime diff --git a/apps/web/package.json b/apps/web/package.json index 9705d2a..5276f15 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -5,7 +5,7 @@ "type": "module", "scripts": { "dev": "vite dev", - "generate-api": "openapi-typescript ../../contracts/lead-scoring/v2/openapi.json -o src/lib/generated/api.d.ts", + "generate-api": "openapi-typescript ../../contracts/lead-scoring/openapi.json -o src/lib/generated/api.d.ts", "check": "bun run generate-api && svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", "test": "vitest run", "format:check": "prettier --check 'src/**/*.{ts,svelte,css}' '!src/lib/components/ui/**' '!src/lib/generated/**'", diff --git a/apps/web/src/lib/server/fixture-runner.test.ts b/apps/web/src/lib/server/fixture-runner.test.ts index 4e0720a..a6448fb 100644 --- a/apps/web/src/lib/server/fixture-runner.test.ts +++ b/apps/web/src/lib/server/fixture-runner.test.ts @@ -1,80 +1,44 @@ import { describe, expect, it, vi } from 'vitest'; import { env } from '$env/dynamic/private'; -import { - fixtureRunnerConfigured, - FixtureRunnerNotConfigured, - scoringFixture, - submitScoringFixture, -} from './fixture-runner'; +import { FixtureRunnerNotConfigured, scoringFixture, submitScoringFixture } from './fixture-runner'; import type { RequestEvent } from '@sveltejs/kit'; vi.mock('$env/dynamic/private', () => ({ env: { BACKEND_INTERNAL_URL: 'http://backend:8000', LEAD_SCORING_FIXTURE_APP_API_KEY: 'test-only-app-key', + LEAD_SCORING_FIXTURE_AI_KEY: 'test-only-ai-key', }, })); -function mockEvent() { - const fetch = vi - .fn() - .mockResolvedValue(Response.json({ status: 'completed' }, { status: 200 })); - return { fetch, event: { fetch } as unknown as RequestEvent }; -} - describe('server-only fixture transport', () => { - it.each([false, true])('sends the canonical fixture with AI enabled: %s', async (useAI) => { - const { fetch, event } = mockEvent(); - expect(fixtureRunnerConfigured()).toBe(true); - await submitScoringFixture(event, useAI); - await submitScoringFixture(event, useAI); + it('sends the canonical fixture with a fresh idempotency key and keys only in headers', async () => { + const fetch = vi.fn().mockResolvedValue(Response.json({}, { status: 200 })); + const event = { fetch } as unknown as RequestEvent; + await submitScoringFixture(event, true); + await submitScoringFixture(event, false); - expect(fetch).toHaveBeenCalledTimes(2); - const ids = fetch.mock.calls.map(([url, init]) => { - expect(url).toBe('http://backend:8000/v1/lead-scoring/score'); - expect(init.headers.authorization).toBe('Bearer test-only-app-key'); - const body = JSON.parse(init.body); - expect(init.headers['Idempotency-Key']).toBe(body.request_id); - expect(init.headers).not.toHaveProperty('X-Tenant-VKey'); - expect(init.cache).toBe('no-store'); - expect(init.redirect).toBe('error'); - expect(init.body).not.toContain(env.LEAD_SCORING_FIXTURE_APP_API_KEY); - expect(body).toEqual({ - ...scoringFixture, - request_id: expect.any(String), - ai_profile: useAI ? 'medium' : scoringFixture.ai_profile, - scoring_config: { - ...scoringFixture.scoring_config, - rule_weight: useAI ? 0.5 : 1, - ai_weight: useAI ? 0.5 : 0, - }, - }); - return body.request_id; + const [[url, hybrid], [, rulesOnly]] = fetch.mock.calls; + expect(url).toBe('http://backend:8000/v1/lead-scoring/score'); + expect(hybrid.headers.authorization).toBe('Bearer test-only-app-key'); + expect(hybrid.headers['X-AI-Key']).toBe('test-only-ai-key'); + expect(rulesOnly.headers).not.toHaveProperty('X-AI-Key'); + expect(hybrid.headers['Idempotency-Key']).not.toBe(rulesOnly.headers['Idempotency-Key']); + expect(JSON.parse(hybrid.body)).toEqual({ + ...scoringFixture, + scoring_config: { ...scoringFixture.scoring_config, rule_weight: 0.5, ai_weight: 0.5 }, }); - expect(ids[0]).not.toBe(ids[1]); - expect( - ids.every((id: string) => - /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(id), - ), - ).toBe(true); - expect(scoringFixture.scoring_config.ai_weight).toBe(0); - }); - - it('keeps the default request rules-only', async () => { - const { fetch, event } = mockEvent(); - await submitScoringFixture(event); - expect(JSON.parse(fetch.mock.calls[0][1].body).scoring_config.ai_weight).toBe(0); + expect(hybrid.body).not.toMatch(/test-only/); }); it('rejects a missing app key before sending any request', async () => { - const { fetch, event } = mockEvent(); + const fetch = vi.fn(); const original = env.LEAD_SCORING_FIXTURE_APP_API_KEY; try { env.LEAD_SCORING_FIXTURE_APP_API_KEY = ' '; - expect(fixtureRunnerConfigured()).toBe(false); - await expect(submitScoringFixture(event, true)).rejects.toBeInstanceOf( - FixtureRunnerNotConfigured, - ); + await expect( + submitScoringFixture({ fetch } as unknown as RequestEvent), + ).rejects.toBeInstanceOf(FixtureRunnerNotConfigured); expect(fetch).not.toHaveBeenCalled(); } finally { env.LEAD_SCORING_FIXTURE_APP_API_KEY = original; diff --git a/apps/web/src/lib/server/fixture-runner.ts b/apps/web/src/lib/server/fixture-runner.ts index 0e0e095..35c8500 100644 --- a/apps/web/src/lib/server/fixture-runner.ts +++ b/apps/web/src/lib/server/fixture-runner.ts @@ -1,7 +1,7 @@ import { env } from '$env/dynamic/private'; import type { RequestEvent } from '@sveltejs/kit'; import type { components } from '$lib/generated/api'; -import fixture from '../../../../../contracts/lead-scoring/v2/fixtures/run-request.json'; +import fixture from '../../../../../contracts/lead-scoring/fixtures/run-request.json'; export type RunRequest = components['schemas']['RunRequest']; export const scoringFixture = fixture as RunRequest; @@ -22,14 +22,13 @@ export async function submitScoringFixture(event: RequestEvent, useAI = false): const request: RunRequest = { ...scoringFixture, - request_id: crypto.randomUUID(), scoring_config: { ...scoringFixture.scoring_config, rule_weight: useAI ? 0.5 : 1, ai_weight: useAI ? 0.5 : 0, }, - ai_profile: useAI ? 'medium' : scoringFixture.ai_profile, }; + const aiKey = env.LEAD_SCORING_FIXTURE_AI_KEY?.trim(); return event.fetch(`${base.replace(/\/$/, '')}/v1/lead-scoring/score`, { method: 'POST', cache: 'no-store', @@ -38,7 +37,8 @@ export async function submitScoringFixture(event: RequestEvent, useAI = false): accept: 'application/json', authorization: `Bearer ${appKey}`, 'content-type': 'application/json', - 'Idempotency-Key': request.request_id, + 'Idempotency-Key': crypto.randomUUID(), + ...(useAI && aiKey ? { 'X-AI-Key': aiKey } : {}), }, body: JSON.stringify(request), }); diff --git a/apps/web/src/routes/(admin)/lead-scoring/ai-profiles/+page.server.ts b/apps/web/src/routes/(admin)/lead-scoring/ai-profiles/+page.server.ts index 8a46683..055e71b 100644 --- a/apps/web/src/routes/(admin)/lead-scoring/ai-profiles/+page.server.ts +++ b/apps/web/src/routes/(admin)/lead-scoring/ai-profiles/+page.server.ts @@ -1,19 +1,15 @@ import { error, fail } from '@sveltejs/kit'; import { backend, backendJson } from '$lib/server/backend'; +import { scoringFixture } from '$lib/server/fixture-runner'; +import type { components } from '$lib/generated/api'; import type { Actions, PageServerLoad } from './$types'; -export type AIProfile = { - version: string; - provider: 'aihub' | 'openai_compatible'; - model: string; - system_prompt: string; - timeout_seconds: number; - active: boolean; - base_url: string | null; -}; +type ProfileInput = components['schemas']['AIProfileInput']; +type Efforts = NonNullable; +const efforts: Efforts[] = ['minimal', 'low', 'medium', 'high']; export const load: PageServerLoad = async (event) => { - const { response, data } = await backendJson<{ profiles: AIProfile[] }>( + const { response, data } = await backendJson( event, '/v1/admin/ai-profiles', ); @@ -21,46 +17,85 @@ export const load: PageServerLoad = async (event) => { return { profiles: data.profiles }; }; +function parseProfile(form: FormData): ProfileInput | null { + const text = (name: string) => String(form.get(name) ?? '').trim(); + const provider = text('provider'); + const effort = text('reasoning_effort'); + const timeout_seconds = Number(form.get('timeout_seconds')); + if ( + (provider !== 'aihub' && provider !== 'openai_compatible') || + !text('base_url') || + !text('model') || + !text('instructions') || + !text('timezone') || + !Number.isInteger(timeout_seconds) || + (effort && !efforts.includes(effort as Efforts)) + ) { + return null; + } + return { + provider, + base_url: text('base_url'), + model: text('model'), + timeout_seconds, + reasoning_effort: (effort || null) as ProfileInput['reasoning_effort'], + instructions: text('instructions'), + include_reference_date: form.get('include_reference_date') === 'on', + timezone: text('timezone'), + suggested_action_enabled: form.get('suggested_action_enabled') === 'on', + }; +} + +const invalid = 'Nhập đủ nhà cung cấp, URL, model, hướng dẫn chấm, múi giờ và thời gian chờ.'; +const rejected = 'URL (https hoặc loopback khi dev), múi giờ hoặc thời gian chờ không hợp lệ.'; + +const json = { 'content-type': 'application/json' }; + export const actions: Actions = { - default: async (event) => { - const form = await event.request.formData(); - const version = String(form.get('version') ?? '').trim(); - const provider = String(form.get('provider') ?? 'aihub'); - const base_url = - provider === 'openai_compatible' ? String(form.get('base_url') ?? '').trim() : null; - const model = String(form.get('model') ?? '').trim(); - const system_prompt = String(form.get('system_prompt') ?? '').trim(); - const timeout_seconds = Number(form.get('timeout_seconds')); - if ( - !version || - !model || - !system_prompt || - !Number.isFinite(timeout_seconds) || - (provider === 'openai_compatible' && !base_url) || - !['aihub', 'openai_compatible'].includes(provider) - ) { - return fail(400, { - message: - 'Nhập đủ phiên bản, nhà cung cấp, URL (nếu dùng OpenAI-compatible), model và prompt.', - }); - } + save: async (event) => { + const profile = parseProfile(await event.request.formData()); + if (!profile) return fail(400, { message: invalid }); const response = await backend(event, '/v1/admin/ai-profiles', { - method: 'PUT', - headers: { 'content-type': 'application/json' }, + method: 'POST', + headers: json, + body: JSON.stringify(profile), + }); + if (response.status === 422) return fail(422, { message: rejected }); + if (!response.ok) return fail(response.status, { message: 'Không lưu được cấu hình AI.' }); + return { message: 'Đã lưu revision mới. Bấm Kích hoạt để dùng.', success: true }; + }, + activate: async (event) => { + const revision = Number((await event.request.formData()).get('revision')); + if (!Number.isInteger(revision)) return fail(400, { message: 'Revision không hợp lệ.' }); + const response = await backend(event, `/v1/admin/ai-profiles/${revision}/activate`, { + method: 'POST', + }); + if (!response.ok) return fail(response.status, { message: 'Không kích hoạt được.' }); + return { message: `Đã kích hoạt revision ${revision}.`, success: true }; + }, + try: async (event) => { + const form = await event.request.formData(); + const profile = parseProfile(form); + if (!profile) return fail(400, { message: invalid }); + const aiKey = String(form.get('ai_key') ?? '').trim(); + const response = await backend(event, '/v1/admin/ai-profiles/try', { + method: 'POST', + headers: { ...json, ...(aiKey ? { 'x-ai-key': aiKey } : {}) }, body: JSON.stringify({ - version, - provider, - base_url, - model, - system_prompt, - timeout_seconds, + profile, + request: { + ...scoringFixture, + scoring_config: { + ...scoringFixture.scoring_config, + rule_weight: 0.5, + ai_weight: 0.5, + }, + }, }), }); - if (response.status === 409) - return fail(409, { message: 'Phiên bản đã tồn tại với nội dung khác.' }); - if (response.status === 422) - return fail(422, { message: 'URL không hợp lệ hoặc nhà cung cấp chưa sẵn sàng.' }); - if (!response.ok) return fail(response.status, { message: 'Không lưu được cấu hình AI.' }); - return { message: 'Đã kích hoạt phiên bản mới.', success: true }; + if (response.status === 422) return fail(422, { message: rejected }); + if (!response.ok) return fail(response.status, { message: 'Chạy thử không thành công.' }); + const trial = (await response.json()) as components['schemas']['ScoreResponse']; + return { message: 'Đã chạy thử trên dữ liệu mẫu, không lưu lần chấm nào.', trial }; }, }; diff --git a/apps/web/src/routes/(admin)/lead-scoring/ai-profiles/+page.svelte b/apps/web/src/routes/(admin)/lead-scoring/ai-profiles/+page.svelte index e4f1df9..1060c03 100644 --- a/apps/web/src/routes/(admin)/lead-scoring/ai-profiles/+page.svelte +++ b/apps/web/src/routes/(admin)/lead-scoring/ai-profiles/+page.svelte @@ -6,150 +6,219 @@ import { Input } from '$lib/components/ui/input/index.js'; import { Label } from '$lib/components/ui/label/index.js'; import { Textarea } from '$lib/components/ui/textarea/index.js'; - import * as Resizable from '$lib/components/ui/resizable/index.js'; let { data, form }: PageProps = $props(); - let selectedProvider = $state('aihub'); - const providerLabels: Record = { - aihub: 'AIHub', - openai_compatible: 'OpenAI-compatible', - }; + // Start from the active revision (or the newest), so a change is a small edit. + const base = $derived(data.profiles.find((p) => p.active) ?? data.profiles[0]); + const field = + 'h-9 w-full rounded-md border border-input bg-background px-3 text-sm focus-visible:outline-2 focus-visible:outline-ring'; -{#snippet versionList()} - -

Phiên bản

+
+

Lead Scoring / Cấu hình

+

Cấu hình AI

+

+ Mỗi lần Lưu tạo một revision mới. Chạy thử trên dữ liệu mẫu trước, rồi Kích hoạt revision. +

+
+{#if form?.message} +

+ {form.message} +

+{/if} +
+ +

Revision

{#if data.profiles.length === 0}

- Chưa có cấu hình AI. Các lần chấm điểm chỉ dùng quy tắc không cần AI. + Chưa có cấu hình AI. Các lần chấm cần AI sẽ dự phòng bằng điểm quy tắc.

{:else}
    - {#each data.profiles as profile (profile.version)} + {#each data.profiles as profile (profile.revision)}
  • - {profile.version} - {providerLabels[profile.provider]} + #{profile.revision} + {profile.provider} {profile.model} - {#if profile.base_url}{profile.base_url}{/if} - {#if profile.active}Đang dùng{/if} + {profile.base_url} · {profile.created_by} · + {new Date(profile.created_at).toLocaleString('vi-VN')} + {#if profile.active} + Đang dùng + {:else} +
    + + +
    + {/if}
  • {/each}
{/if}
-{/snippet} - -{#snippet profileEditor(prefix: string)} - + -

Tạo và kích hoạt phiên bản

- Mỗi lần đổi cấu hình, tạo phiên bản mới. +

Soạn revision mới

+ + URL gồm cả đường dẫn gốc, ví dụ https://aihub.example/v1. Server nối thêm + /chat/completions. +
- -
-
- - + +
- {#if selectedProvider === 'openai_compatible'} -
- +
+ + +
+
+
+
- {/if} -
- +
+ + +
-