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 d57b909..a305fad 100644 --- a/.env.example +++ b/.env.example @@ -14,17 +14,11 @@ WEB_PORT=3000 # Backend: read by ai_platform.infrastructure.config.settings.Settings (AI_PLATFORM_ prefix) AI_PLATFORM_ENVIRONMENT=development AI_PLATFORM_DATABASE_URL=postgresql+asyncpg://ai_platform:local-change-this-password@127.0.0.1:55432/ai_platform -# Per-app terminal callback configuration (JSON). New admission is blocked without a mapping. -# Loopback example below requires backend and Omni running natively on the same host. -# In Compose, use a reachable HTTPS Omni endpoint; localhost is the backend container itself. -# Generate a secret with: uv run --no-sync python scripts/generate_secret.py LEAD_SCORING_WEBHOOK_SIGNING_SECRET -# Store the same value in Omni and signing_secret below; use the app ID that owns your key. -# AI_PLATFORM_APP_WEBHOOKS='{"omni-local":{"url":"http://localhost:3000/api/v1/internal/lead-scoring/notifications","signing_secret":"replace-with-generated-webhook-secret"}}' 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 e6b13ad..c41ba2a 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,13 +66,15 @@ 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” có form sửa lead/custom fields, thêm/sửa/xóa hội thoại và tin nhắn (thời gian UTC), dựng rule với field/operator/giá trị/điểm và chọn tỷ lệ AI. Trọng số quy tắc tự bổ sung để tổng bằng 100%; AI 0% không gọi model. JSON nâng cao đồng bộ cùng kịch bản và gửi nguyên trọng số. Kết quả hiện điểm, breakdown và fallback ngay trên trang, kèm liên kết chi tiết. Ngưỡng ở đây là điều kiện rule; phân loại lead vẫn do client quyết định. Kịch bản không lưu thành chính sách và không ghi đè fixture chuẩn. Với AIHub, nhập “Khóa AI cho lần thử (không lưu)” trên trang này, hoặc đặt `LEAD_SCORING_FIXTURE_AI_KEY` trên server Web. Khóa nhập được ưu tiên, chỉ chuyển trong header `X-AI-Key` khi AI > 0%, không nằm trong JSON/kết quả và xóa khỏi ô nhập khi gửi. Khóa dùng thử ở trang Cấu hình AI không được lưu hay chuyển sang trang này. Thiếu khóa sẽ fallback bằng điểm quy tắc. Chế độ AI 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. -## App credentials và terminal webhook +## App credentials -Mỗi installation/môi trường Omni dùng một `app_id` ổn định và một backend app key chung cho các account. Vận hành cấp/rotate/revoke qua CLI; raw key chỉ được xuất khi cấp/rotate, chuyển an toàn sang backend Omni, không qua browser/log/Git: +Mỗi installation/môi trường Omni dùng một `app_id` ổn định (tên đăng ký phía AI Platform, Omni không cần biết hay gửi) và một backend app key chung cho các account. Vận hành cấp/rotate/revoke qua CLI; raw key chỉ được xuất khi cấp/rotate, chuyển an toàn sang backend Omni, không qua browser/log/Git: ```sh uv run --no-sync ai-platform create-app omni-local @@ -80,23 +82,15 @@ uv run --no-sync ai-platform rotate-app-key omni-local uv run --no-sync ai-platform revoke-app-key omni-local ``` -`create-app --no-lead-scoring` cấp app chưa có quyền Lead Scoring. 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/GET dùng `Authorization: Bearer `; POST có thêm `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 chỉ dùng `AI_PLATFORM_URL` và `AI_PLATFORM_APP_API_KEY` ở backend. Không có webhook hay đăng ký account ở AI Platform. -Tạo secret webhook bằng `uv run --no-sync python scripts/generate_secret.py LEAD_SCORING_WEBHOOK_SIGNING_SECRET`. Script chỉ in `TEN_BIEN=SECRET` với secret ngẫu nhiên 64 ký tự, không sửa file hay tạo app key. Lưu cùng giá trị secret vào cấu hình Omni và `signing_secret` của app tương ứng ở AI Platform; không đưa output vào Git/chat/log dùng chung. Mỗi lần chạy tạo secret mới. +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. -Cấu hình `AI_PLATFORM_APP_WEBHOOKS` bằng JSON mapping app ID tới URL/signing secret, ví dụ trong `.env.example`. Mẫu localhost chỉ áp dụng khi backend/Omni chạy trực tiếp cùng host; với Compose phải dùng HTTPS endpoint Omni truy cập được từ container, không dùng localhost để trỏ tới host. Thiếu mapping chặn run mới với 503, nhưng replay vẫn trả run cũ. URL HTTPS, chỉ HTTP loopback development; secret ít nhất 32 ký tự. Webhook body có đúng `event_id`, `app_id`, `account_id`, `request_id`, `run_id`, `status`, `occurred_at`. Chữ ký HMAC-SHA256 trên `timestamp + "." + raw body` trong `X-Webhook-Signature`, timestamp mới mỗi attempt trong `X-Webhook-Timestamp`; không follow redirect. Retry tối đa 5 attempts rồi dead. Vận hành redeliver cùng event, không rescore: +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ữ. -```sh -uv run --no-sync ai-platform redeliver-notification -``` +Fixture runner nội bộ dùng `LEAD_SCORING_FIXTURE_APP_API_KEY` server-only. -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 callback/GET/source request trước writeback; 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 và outbox. 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ữ. - -`scripts/seed_demo.py` yêu cầu app key đã cấp qua `AI_PLATFORM_DEMO_APP_API_KEY`, không tự provisioning. Fixture runner nội bộ dùng `LEAD_SCORING_FIXTURE_APP_API_KEY` server-only, ưu tiên app test riêng có webhook mock. - -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 f9fc752..8a75af4 100644 --- a/apps/web/src/lib/server/fixture-runner.test.ts +++ b/apps/web/src/lib/server/fixture-runner.test.ts @@ -1,78 +1,49 @@ 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: 'pending' }, { status: 202 })); - 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; + const scenario = { + ...scoringFixture, + scoring_config: { ...scoringFixture.scoring_config, rule_weight: 0.7, ai_weight: 0.3 }, + }; + await submitScoringFixture(event, scenario); + await submitScoringFixture(event, scenario, 'request-only-key'); + await submitScoringFixture(event, scoringFixture, 'request-only-key'); - expect(fetch).toHaveBeenCalledTimes(2); - const ids = fetch.mock.calls.map(([url, init]) => { - expect(url).toBe('http://backend:8000/v1/lead-scoring/runs'); - 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; - }); - 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); + const [[url, hybrid], [, requestKey], [, 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(requestKey.headers['X-AI-Key']).toBe('request-only-key'); + expect(requestKey.body).not.toContain('request-only-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(scenario); + expect(JSON.parse(rulesOnly.body)).toEqual(scoringFixture); + 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, scoringFixture), + ).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 b71ac71..4bd93e0 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; @@ -12,7 +12,11 @@ export function fixtureRunnerConfigured(): boolean { return Boolean(env.LEAD_SCORING_FIXTURE_APP_API_KEY?.trim()); } -export async function submitScoringFixture(event: RequestEvent, useAI = false): Promise { +export async function submitScoringFixture( + event: RequestEvent, + scenario: RunRequest, + requestAIKey = '', +): Promise { const base = env.BACKEND_INTERNAL_URL; const appKey = env.LEAD_SCORING_FIXTURE_APP_API_KEY?.trim(); if (!appKey) throw new FixtureRunnerNotConfigured(); @@ -20,17 +24,9 @@ export async function submitScoringFixture(event: RequestEvent, useAI = false): throw new Error('BACKEND_INTERNAL_URL must be configured on the server'); } - 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, - }; - return event.fetch(`${base.replace(/\/$/, '')}/v1/lead-scoring/runs`, { + const useAI = scenario.scoring_config?.ai_weight > 0; + const aiKey = requestAIKey.trim() || env.LEAD_SCORING_FIXTURE_AI_KEY?.trim(); + return event.fetch(`${base.replace(/\/$/, '')}/v1/lead-scoring/score`, { method: 'POST', cache: 'no-store', redirect: 'error', @@ -38,8 +34,9 @@ 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), + body: JSON.stringify(scenario), }); } 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..8f733a3 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,89 @@ 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, + values: profile, + }; }, }; 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..959915b 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 trial's values (kept after Chạy thử), else the active or newest revision. + const base = $derived(form?.values ?? 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} -
- +
+ + +
-