Compare commits
10 Commits
2268b6a77a
...
8f2e9e631b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8f2e9e631b | ||
|
|
e4aafd7531 | ||
|
|
5c61227e6f | ||
|
|
6577bb74be | ||
|
|
b1f6643796 | ||
|
|
655202455b | ||
|
|
2193d7c8e1 | ||
|
|
d5e096b952 | ||
|
|
8082a9f968 | ||
|
|
ec9f0f911e |
@ -1,5 +1,4 @@
|
||||
# Never send the archived codebase or local configuration to a build context.
|
||||
old_repo/
|
||||
# Never send local configuration to a build context.
|
||||
.env
|
||||
.env.*
|
||||
|
||||
@ -30,4 +29,3 @@ test-results/
|
||||
.codex/
|
||||
.scratch/
|
||||
vault/
|
||||
rewrite_architect.md
|
||||
|
||||
12
.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.
|
||||
|
||||
@ -1,12 +1,16 @@
|
||||
exclude: ^old_repo/
|
||||
|
||||
repos:
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.12.11
|
||||
- repo: local
|
||||
hooks:
|
||||
- id: ruff
|
||||
name: ruff (uv.lock)
|
||||
entry: uv run --no-sync ruff check
|
||||
language: system
|
||||
types_or: [python, pyi]
|
||||
- id: ruff-format
|
||||
args: [--check]
|
||||
name: ruff format (uv.lock)
|
||||
entry: uv run --no-sync ruff format --check
|
||||
language: system
|
||||
types_or: [python, pyi]
|
||||
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: v6.0.0
|
||||
|
||||
@ -1,11 +1,11 @@
|
||||
# Repository workflow
|
||||
|
||||
This repository is the rebuilt AI Platform. `old_repo/` is historical and excluded from builds. The approved rebuild plan authorizes implementation; `docs/specs/ai-platform-lead-scoring.md` is review-ready for cross-service acceptance, so unverified Omni/AIHub wire details are not release claims.
|
||||
This repository is the rebuilt AI Platform. Historical implementation is retained only in Git history; current code lives under `src/ai_platform/` and `apps/web/`. The approved rebuild plan authorizes implementation; `docs/specs/ai-platform-lead-scoring.md` is review-ready for cross-service acceptance, so unverified Omni/AIHub wire details are not release claims.
|
||||
|
||||
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.
|
||||
|
||||
@ -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. The archived code and documents under `old_repo/` are historical evidence, not the new architecture or implementation.
|
||||
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.
|
||||
|
||||
38
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.
|
||||
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.
|
||||
|
||||
23
Makefile
@ -1,7 +1,10 @@
|
||||
SHELL := /bin/sh
|
||||
.DEFAULT_GOAL := help
|
||||
|
||||
.PHONY: help install db-up db-down migrate backend-dev web-dev contract check-fast check test build
|
||||
# Parse only the shared port; importing .env as Make syntax would misread secrets and JSON.
|
||||
BACKEND_PORT ?= $(shell uv run --no-sync python -c 'from dotenv import dotenv_values; print(dotenv_values(".env").get("BACKEND_PORT") or "8000")')
|
||||
|
||||
.PHONY: help install dev db-up db-down migrate backend-dev web-dev contract check-fast check test build
|
||||
|
||||
help: ## List repository commands
|
||||
@grep -E '^[a-zA-Z_-]+:.*## ' $(MAKEFILE_LIST) | sort | awk -F ':.*## ' '{printf "%-16s %s\n", $$1, $$2}'
|
||||
@ -10,8 +13,11 @@ install: ## Sync project-local dependencies from both lockfiles
|
||||
uv sync --locked --all-groups
|
||||
cd apps/web && bun install --frozen-lockfile
|
||||
|
||||
db-up: ## Start only the new local PostgreSQL volume
|
||||
docker compose up -d postgres
|
||||
dev: ## Start PostgreSQL and run the native backend and web together
|
||||
$(MAKE) --jobs=2 backend-dev web-dev BACKEND_PORT=$(BACKEND_PORT)
|
||||
|
||||
db-up: ## Start local PostgreSQL and wait until ready
|
||||
docker compose up -d --wait postgres
|
||||
|
||||
db-down: ## Stop local services without deleting the database
|
||||
docker compose down
|
||||
@ -19,14 +25,15 @@ db-down: ## Stop local services without deleting the database
|
||||
migrate: ## Apply migrations to the configured database
|
||||
uv run --no-sync alembic upgrade head
|
||||
|
||||
backend-dev: ## Run a single backend process bound to localhost
|
||||
uv run --no-sync uvicorn ai_platform.main:app --reload --host 127.0.0.1 --port 8000
|
||||
backend-dev: db-up ## Ensure PostgreSQL is ready, then run backend on the shared port
|
||||
uv run --no-sync uvicorn ai_platform.main:app --reload --host 127.0.0.1 --port $(BACKEND_PORT)
|
||||
|
||||
web-dev: ## Run SvelteKit bound to localhost
|
||||
cd apps/web && bun run dev -- --host 127.0.0.1
|
||||
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
|
||||
uv run --no-sync pre-commit run --all-files
|
||||
|
||||
44
README.md
@ -1,6 +1,6 @@
|
||||
# AI Platform
|
||||
|
||||
New AI Platform repository scaffold. The backend and SvelteKit implementation are being developed concurrently; this document records the intended local workflow, not a claim that every command already works.
|
||||
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
|
||||
|
||||
@ -8,13 +8,13 @@ New AI Platform repository scaffold. The backend and SvelteKit implementation ar
|
||||
- PostgreSQL 16 with a new, project-specific Compose volume named `ai_platform_pgdata`.
|
||||
- Alembic schema changes are applied as a one-shot Compose service before the backend starts.
|
||||
- SvelteKit is a separate web image under `apps/web`.
|
||||
- `old_repo/` is archived source only and is excluded from Docker build contexts and local checks.
|
||||
- Historical implementation is retained in Git history, not as a separate source tree.
|
||||
|
||||
The local Compose stack runs one backend process. It is for development only; default credentials and loopback port mappings are not deployment settings. Keep production secrets outside `.env` and inject them through the deployment environment or a secrets manager.
|
||||
|
||||
## Local setup
|
||||
|
||||
Copy the backend/Compose template and the separate SvelteKit server-only template. Both `.env` files are ignored by Git; the previous root `.env` is preserved under `old_repo/.env` as historical local data, not loaded by the rebuilt service.
|
||||
Copy the backend/Compose template and the separate SvelteKit server-only template only when setting up a new workspace. Both `.env` files are ignored by Git; preserve any existing `.env` and `apps/web/.env` instead of overwriting them.
|
||||
|
||||
```sh
|
||||
cp .env.example .env
|
||||
@ -22,15 +22,21 @@ cp apps/web/.env.example apps/web/.env
|
||||
make install
|
||||
```
|
||||
|
||||
Start the database and run the backend and web servers in separate terminals:
|
||||
For a new database, prepare its schema once before starting the application:
|
||||
|
||||
```sh
|
||||
make db-up
|
||||
make migrate
|
||||
make backend-dev
|
||||
make web-dev
|
||||
```
|
||||
|
||||
Then start PostgreSQL and both native servers from one terminal:
|
||||
|
||||
```sh
|
||||
make dev
|
||||
```
|
||||
|
||||
Set `BACKEND_PORT` once in the root `.env` (default `8000`; use `8002` to avoid a local conflict). `make backend-dev` uses that port, and `make web-dev` passes the matching server-only `BACKEND_INTERNAL_URL` to Web, overriding any old URL in `apps/web/.env`. An exported `BACKEND_PORT` or `make dev BACKEND_PORT=8002` can override the root value. Other root secrets are not passed to Web. The web development server normally listens at `127.0.0.1:5173` (see its terminal output). `Ctrl+C` stops the native servers; PostgreSQL stays running. `make dev` does not run migrations or start Omni/AIHub. To run the servers in separate terminals instead, use `make backend-dev` and `make web-dev`.
|
||||
|
||||
The local web server should call the backend through server-only configuration. Do not expose `BACKEND_INTERNAL_URL`, database credentials, or provider keys as browser-visible `PUBLIC_*` variables.
|
||||
|
||||
Compose can instead build and start all services, including the one-shot migration step:
|
||||
@ -39,7 +45,7 @@ Compose can instead build and start all services, including the one-shot migrati
|
||||
docker compose up --build
|
||||
```
|
||||
|
||||
The database is reachable from the host at `127.0.0.1:55432`, backend at `127.0.0.1:8000`, and web at `127.0.0.1:3000` by default. To discard only this new database and its data, run `docker compose down -v`; this does not target archived or unrelated volumes.
|
||||
The database is reachable from the host at `127.0.0.1:55432`, backend at `127.0.0.1:8000`, and web at `127.0.0.1:3000` by default. To discard this Compose stack's database and its data, run `docker compose down -v`; unrelated project volumes are not targeted.
|
||||
|
||||
The local database starts with only the development database. To run tests safely, create a separate database in that same new PostgreSQL instance, then pass its URL explicitly:
|
||||
|
||||
@ -60,15 +66,31 @@ AI_PLATFORM_OPENAI_BASE_URL=http://127.0.0.1:8317
|
||||
# AI_PLATFORM_OPENAI_API_KEY=... # chỉ cần nếu CLIProxy yêu cầu xác thực
|
||||
```
|
||||
|
||||
Chạy `make backend-dev` trên máy host: `127.0.0.1:8317` bên trong Docker không trỏ tới CLIProxy trên host. Trong Admin Web, tạo và kích hoạt **một phiên bản Cấu hình AI mới**, chọn `OpenAI-compatible`, nhập URL `http://127.0.0.1:8317`, model `gemini-3.5-flash-lite`, prompt và timeout. `AI_PLATFORM_OPENAI_BASE_URL` là URL mặc định cho profile cũ và là địa chỉ duy nhất nhận `AI_PLATFORM_OPENAI_API_KEY`; URL khác nhập trong Web không được gửi key đó. Profile cũ mặc định AIHub; URL AIHub vẫn cấu hình bằng `AI_PLATFORM_AIHUB_BASE_URL`. Lựa chọn này áp dụng cho các run mới của Lead Scoring, không đổi các run đã nhận trước đó. Gửi `POST /v1/lead-scoring/runs` với `ai_profile=medium`, `ai_weight>0` và tenant key; OpenAI-compatible không dùng `X-Tenant-VKey`. Nút “Chạy mẫu” trên Web hiện chỉ gửi mẫu quy tắc (`ai_weight=0`), nên hãy dùng API để thử AI.
|
||||
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.
|
||||
|
||||
## Terminal webhook và retention
|
||||
## App credentials
|
||||
|
||||
Khi cấu hình đồng thời `AI_PLATFORM_WEBHOOK_URL` và `AI_PLATFORM_WEBHOOK_SIGNING_SECRET` (ít nhất 32 ký tự), backend gửi thông báo kết thúc run tới một URL Omni cố định. URL HTTP chỉ hợp lệ trên loopback khi phát triển. Body chỉ có `event_id`, `run_id`, `tenant_id`, `status`, `occurred_at`; header `X-Webhook-Timestamp` và `X-Webhook-Signature` chứa chữ ký HMAC-SHA256 trên `timestamp + "." + raw body`. Phản hồi 2xx xác nhận giao; lỗi được thử lại tối đa 5 lần rồi giữ bản ghi đánh dấu `dead`. Chỉ bật gửi thực tế sau khi Omni xác nhận fixture chữ ký, chống replay và GET/writeback.
|
||||
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:
|
||||
|
||||
Backend dọn run quá 90 ngày (gồm snapshot, fingerprint và thông báo liên quan) khi khởi động và mỗi ngày. Không áp dụng chính sách này cho backup; cần chốt vòng đời backup và tenant offboarding trước production.
|
||||
```sh
|
||||
uv run --no-sync ai-platform create-app omni-local
|
||||
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` đồng bộ, dùng `Authorization: Bearer <app_api_key>`, `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.
|
||||
|
||||
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.
|
||||
|
||||
Backup và app/account offboarding cần chính sách riêng trước production.
|
||||
|
||||
## Quality checks
|
||||
|
||||
|
||||
@ -1,8 +1,10 @@
|
||||
# Admin Web server only. SvelteKit reads apps/web/.env when run from apps/web/.
|
||||
# Docker Compose passes BACKEND_INTERNAL_URL directly to the web container instead.
|
||||
BACKEND_INTERNAL_URL=http://127.0.0.1:8000
|
||||
# Make web-dev/dev derives BACKEND_INTERNAL_URL from BACKEND_PORT in the root .env.
|
||||
# Docker Compose passes its internal backend URL directly to the web container.
|
||||
# Only set this manually when running Bun directly without Make:
|
||||
# BACKEND_INTERNAL_URL=http://127.0.0.1:8000
|
||||
# Label for this Web instance: development, test, or production. Set test when using ai_platform_test.
|
||||
WEB_ENVIRONMENT=development
|
||||
# Optional server-only key for the internal fixture runner (use a separate test tenant).
|
||||
# LEAD_SCORING_FIXTURE_TENANT_KEY=
|
||||
# Optional server-only key for the internal fixture runner (use a separate test app).
|
||||
# LEAD_SCORING_FIXTURE_APP_API_KEY=
|
||||
# Never put credentials or backend URLs in PUBLIC_* variables.
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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/**'",
|
||||
|
||||
@ -43,18 +43,18 @@
|
||||
<Sidebar.Menu>
|
||||
<Sidebar.MenuItem>
|
||||
<Sidebar.MenuButton
|
||||
tooltipContent="Mã truy cập"
|
||||
isActive={page.url.pathname.startsWith('/platform/tenant-keys')}
|
||||
tooltipContent="Ứng dụng"
|
||||
isActive={page.url.pathname.startsWith('/platform/apps')}
|
||||
>
|
||||
{#snippet child({ props })}
|
||||
<a
|
||||
{...props}
|
||||
href="/platform/tenant-keys"
|
||||
aria-current={page.url.pathname.startsWith('/platform/tenant-keys')
|
||||
href="/platform/apps"
|
||||
aria-current={page.url.pathname.startsWith('/platform/apps')
|
||||
? 'page'
|
||||
: undefined}
|
||||
>
|
||||
<KeyRound /> <span>Mã truy cập</span>
|
||||
<KeyRound /> <span>Ứng dụng</span>
|
||||
</a>
|
||||
{/snippet}
|
||||
</Sidebar.MenuButton>
|
||||
|
||||
@ -1,40 +1,52 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { env } from '$env/dynamic/private';
|
||||
import { fixtureRunnerConfigured, 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_TENANT_KEY: 'test-only-tenant-key',
|
||||
LEAD_SCORING_FIXTURE_APP_API_KEY: 'test-only-app-key',
|
||||
LEAD_SCORING_FIXTURE_AI_KEY: 'test-only-ai-key',
|
||||
},
|
||||
}));
|
||||
|
||||
describe('server-only fixture transport', () => {
|
||||
it('sends the canonical fixture with a new request ID and no V-Key', async () => {
|
||||
const fetch = vi
|
||||
.fn()
|
||||
.mockResolvedValue(Response.json({ status: 'pending' }, { status: 202 }));
|
||||
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;
|
||||
expect(fixtureRunnerConfigured()).toBe(true);
|
||||
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');
|
||||
|
||||
await submitScoringFixture(event);
|
||||
await submitScoringFixture(event);
|
||||
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/);
|
||||
});
|
||||
|
||||
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-tenant-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.body).not.toContain(env.LEAD_SCORING_FIXTURE_TENANT_KEY);
|
||||
expect(body.subject_ref).toBe('ems-lead-123');
|
||||
expect(body.scoring_config.ai_weight).toBe(0);
|
||||
return body.request_id;
|
||||
});
|
||||
expect(ids[0]).not.toBe(ids[1]);
|
||||
expect(ids.every((id: string) => /^[0-9a-f-]{36}$/.test(id))).toBe(true);
|
||||
it('rejects a missing app key before sending any request', async () => {
|
||||
const fetch = vi.fn();
|
||||
const original = env.LEAD_SCORING_FIXTURE_APP_API_KEY;
|
||||
try {
|
||||
env.LEAD_SCORING_FIXTURE_APP_API_KEY = ' ';
|
||||
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;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,37 +1,42 @@
|
||||
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;
|
||||
|
||||
export class FixtureRunnerNotConfigured extends Error {}
|
||||
|
||||
export function fixtureRunnerConfigured(): boolean {
|
||||
return Boolean(env.LEAD_SCORING_FIXTURE_TENANT_KEY?.trim());
|
||||
return Boolean(env.LEAD_SCORING_FIXTURE_APP_API_KEY?.trim());
|
||||
}
|
||||
|
||||
export async function submitScoringFixture(event: RequestEvent): Promise<Response> {
|
||||
export async function submitScoringFixture(
|
||||
event: RequestEvent,
|
||||
scenario: RunRequest,
|
||||
requestAIKey = '',
|
||||
): Promise<Response> {
|
||||
const base = env.BACKEND_INTERNAL_URL;
|
||||
const tenantKey = env.LEAD_SCORING_FIXTURE_TENANT_KEY?.trim();
|
||||
if (!tenantKey) throw new FixtureRunnerNotConfigured();
|
||||
const appKey = env.LEAD_SCORING_FIXTURE_APP_API_KEY?.trim();
|
||||
if (!appKey) throw new FixtureRunnerNotConfigured();
|
||||
if (!base || !/^https?:\/\//.test(base)) {
|
||||
throw new Error('BACKEND_INTERNAL_URL must be configured on the server');
|
||||
}
|
||||
|
||||
const request: RunRequest = {
|
||||
...(fixture as RunRequest),
|
||||
request_id: crypto.randomUUID(),
|
||||
};
|
||||
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',
|
||||
headers: {
|
||||
accept: 'application/json',
|
||||
authorization: `Bearer ${tenantKey}`,
|
||||
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),
|
||||
});
|
||||
}
|
||||
|
||||
@ -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<ProfileInput['reasoning_effort']>;
|
||||
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<components['schemas']['AIProfileList']>(
|
||||
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,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
@ -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<PageProps['data']['profiles'][number]['provider']>('aihub');
|
||||
const providerLabels: Record<PageProps['data']['profiles'][number]['provider'], string> = {
|
||||
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';
|
||||
</script>
|
||||
|
||||
{#snippet versionList()}
|
||||
<Card.Root class="h-full">
|
||||
<Card.Header><h2 class="text-base font-semibold">Phiên bản</h2></Card.Header>
|
||||
<header class="mb-8">
|
||||
<p class="mb-2 text-xs font-semibold text-primary">Lead Scoring / Cấu hình</p>
|
||||
<h1 class="text-3xl font-bold">Cấu hình AI</h1>
|
||||
<p class="mt-2 max-w-prose text-sm text-muted-foreground">
|
||||
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.
|
||||
</p>
|
||||
</header>
|
||||
{#if form?.message}
|
||||
<p
|
||||
role="status"
|
||||
class={form.success || form.trial
|
||||
? 'mb-4 text-sm text-primary'
|
||||
: 'mb-4 text-sm text-destructive'}
|
||||
>
|
||||
{form.message}
|
||||
</p>
|
||||
{/if}
|
||||
<div class="grid gap-5 lg:grid-cols-[2fr_3fr]">
|
||||
<Card.Root>
|
||||
<Card.Header><h2 class="text-base font-semibold">Revision</h2></Card.Header>
|
||||
<Card.Content>
|
||||
{#if data.profiles.length === 0}
|
||||
<p class="text-sm text-muted-foreground">
|
||||
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.
|
||||
</p>
|
||||
{:else}
|
||||
<ul class="divide-y divide-border">
|
||||
{#each data.profiles as profile (profile.version)}
|
||||
{#each data.profiles as profile (profile.revision)}
|
||||
<li class="flex flex-wrap items-center gap-2 py-3 first:pt-0 last:pb-0">
|
||||
<strong class="text-sm">{profile.version}</strong>
|
||||
<Badge variant="outline">{providerLabels[profile.provider]}</Badge>
|
||||
<strong class="text-sm">#{profile.revision}</strong>
|
||||
<Badge variant="outline">{profile.provider}</Badge>
|
||||
<span class="min-w-0 break-all text-sm text-muted-foreground"
|
||||
>{profile.model}</span
|
||||
>
|
||||
{#if profile.base_url}<span
|
||||
class="w-full break-all text-xs text-muted-foreground"
|
||||
>{profile.base_url}</span
|
||||
>{/if}
|
||||
{#if profile.active}<Badge variant="secondary" class="ml-auto"
|
||||
>Đang dùng</Badge
|
||||
>{/if}
|
||||
<span class="w-full break-all text-xs text-muted-foreground"
|
||||
>{profile.base_url} · {profile.created_by} ·
|
||||
{new Date(profile.created_at).toLocaleString('vi-VN')}</span
|
||||
>
|
||||
{#if profile.active}
|
||||
<Badge variant="secondary" class="ml-auto">Đang dùng</Badge>
|
||||
{:else}
|
||||
<form method="POST" action="?/activate" class="ml-auto">
|
||||
<input type="hidden" name="revision" value={profile.revision} />
|
||||
<Button type="submit" size="sm" variant="outline"
|
||||
>Kích hoạt</Button
|
||||
>
|
||||
</form>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
{/snippet}
|
||||
|
||||
{#snippet profileEditor(prefix: string)}
|
||||
<Card.Root class="h-full">
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<h2 class="text-base font-semibold">Tạo và kích hoạt phiên bản</h2>
|
||||
<Card.Description>Mỗi lần đổi cấu hình, tạo phiên bản mới.</Card.Description>
|
||||
<h2 class="text-base font-semibold">Soạn revision mới</h2>
|
||||
<Card.Description>
|
||||
URL gồm cả đường dẫn gốc, ví dụ https://aihub.example/v1. Server nối thêm
|
||||
/chat/completions.
|
||||
</Card.Description>
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
<form method="POST" class="grid gap-4">
|
||||
<div class="grid gap-2">
|
||||
<Label for={`${prefix}-version`}>Phiên bản</Label><Input
|
||||
id={`${prefix}-version`}
|
||||
name="version"
|
||||
required
|
||||
maxlength={64}
|
||||
placeholder="2026-09-25-1"
|
||||
/>
|
||||
</div>
|
||||
<div class="grid gap-2">
|
||||
<Label for={`${prefix}-provider`}>Nhà cung cấp</Label>
|
||||
<select
|
||||
id={`${prefix}-provider`}
|
||||
name="provider"
|
||||
bind:value={selectedProvider}
|
||||
required
|
||||
class="h-9 w-full rounded-md border border-input bg-background px-3 text-sm focus-visible:outline-2 focus-visible:outline-ring"
|
||||
>
|
||||
<option value="aihub">{providerLabels.aihub}</option>
|
||||
<option value="openai_compatible">{providerLabels.openai_compatible}</option
|
||||
<Label for="provider">Nhà cung cấp</Label>
|
||||
<select id="provider" name="provider" class={field} required>
|
||||
<option value="aihub" selected={base?.provider !== 'openai_compatible'}
|
||||
>AIHub (dùng khóa AI của client)</option
|
||||
>
|
||||
<option
|
||||
value="openai_compatible"
|
||||
selected={base?.provider === 'openai_compatible'}
|
||||
>OpenAI-compatible (khóa server)</option
|
||||
>
|
||||
</select>
|
||||
</div>
|
||||
{#if selectedProvider === 'openai_compatible'}
|
||||
<div class="grid gap-2">
|
||||
<Label for={`${prefix}-base-url`}>URL OpenAI-compatible</Label>
|
||||
<div class="grid gap-2">
|
||||
<Label for="base_url">URL</Label>
|
||||
<Input
|
||||
id="base_url"
|
||||
name="base_url"
|
||||
type="url"
|
||||
required
|
||||
maxlength={2048}
|
||||
value={base?.base_url ?? ''}
|
||||
/>
|
||||
</div>
|
||||
<div class="grid gap-2 sm:grid-cols-3">
|
||||
<div class="grid gap-2 sm:col-span-2">
|
||||
<Label for="model">Model</Label>
|
||||
<Input
|
||||
id={`${prefix}-base-url`}
|
||||
name="base_url"
|
||||
type="url"
|
||||
id="model"
|
||||
name="model"
|
||||
required
|
||||
maxlength={2048}
|
||||
placeholder="http://127.0.0.1:8317"
|
||||
value={base?.model ?? ''}
|
||||
placeholder="openai/gpt-6-luna"
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="grid gap-2">
|
||||
<Label for={`${prefix}-model`}>Model của nhà cung cấp</Label><Input
|
||||
id={`${prefix}-model`}
|
||||
name="model"
|
||||
required
|
||||
placeholder="Nhập model của nhà cung cấp"
|
||||
/>
|
||||
<div class="grid gap-2">
|
||||
<Label for="reasoning_effort">Reasoning effort</Label>
|
||||
<select id="reasoning_effort" name="reasoning_effort" class={field}>
|
||||
<option value="">Mặc định</option>
|
||||
{#each ['minimal', 'low', 'medium', 'high'] as effort (effort)}
|
||||
<option value={effort} selected={base?.reasoning_effort === effort}
|
||||
>{effort}</option
|
||||
>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid gap-2">
|
||||
<Label for={`${prefix}-prompt`}>System prompt</Label><Textarea
|
||||
id={`${prefix}-prompt`}
|
||||
name="system_prompt"
|
||||
rows={7}
|
||||
<Label for="instructions">Hướng dẫn chấm (rubric)</Label>
|
||||
<Textarea
|
||||
id="instructions"
|
||||
name="instructions"
|
||||
rows={8}
|
||||
required
|
||||
value={base?.instructions ?? ''}
|
||||
/>
|
||||
</div>
|
||||
<div class="grid gap-2 sm:grid-cols-2">
|
||||
<div class="grid gap-2">
|
||||
<Label for="timeout_seconds">Thời gian chờ AI (giây, tối đa 60)</Label>
|
||||
<Input
|
||||
id="timeout_seconds"
|
||||
name="timeout_seconds"
|
||||
type="number"
|
||||
min="1"
|
||||
max="60"
|
||||
value={base?.timeout_seconds ?? 50}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div class="grid gap-2">
|
||||
<Label for="timezone">Múi giờ</Label>
|
||||
<Input
|
||||
id="timezone"
|
||||
name="timezone"
|
||||
required
|
||||
value={base?.timezone ?? 'Asia/Ho_Chi_Minh'}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<label class="flex items-center gap-3 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="include_reference_date"
|
||||
class="size-4 accent-primary"
|
||||
checked={base?.include_reference_date ?? true}
|
||||
/>
|
||||
Đưa thời điểm chấm (theo captured_at) vào prompt
|
||||
</label>
|
||||
<label class="flex items-center gap-3 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="suggested_action_enabled"
|
||||
class="size-4 accent-primary"
|
||||
checked={base?.suggested_action_enabled ?? true}
|
||||
/>
|
||||
Cho AI gợi ý hành động tiếp theo
|
||||
</label>
|
||||
<div class="grid gap-2">
|
||||
<Label for={`${prefix}-timeout`}>Thời gian chờ (giây)</Label><Input
|
||||
id={`${prefix}-timeout`}
|
||||
name="timeout_seconds"
|
||||
type="number"
|
||||
min="1"
|
||||
max="120"
|
||||
value="12"
|
||||
required
|
||||
<Label for="ai_key">Khóa AI cho lần chạy thử (không lưu)</Label>
|
||||
<Input
|
||||
id="ai_key"
|
||||
name="ai_key"
|
||||
type="password"
|
||||
autocomplete="off"
|
||||
placeholder="Chỉ cần với AIHub"
|
||||
/>
|
||||
</div>
|
||||
{#if form?.message}<p
|
||||
role="status"
|
||||
class={form.success ? 'text-sm text-primary' : 'text-sm text-destructive'}
|
||||
>
|
||||
{form.message}
|
||||
</p>{/if}
|
||||
<Button type="submit" class="mt-1 w-fit">Lưu và kích hoạt</Button>
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<Button type="submit" formaction="?/save">Lưu revision</Button>
|
||||
<Button type="submit" formaction="?/try" variant="outline">Chạy thử</Button>
|
||||
</div>
|
||||
</form>
|
||||
{#if form?.trial}
|
||||
<section
|
||||
class="mt-6 grid gap-2 border-t border-border pt-4 text-sm"
|
||||
aria-label="Kết quả chạy thử"
|
||||
>
|
||||
<p>
|
||||
<strong>Điểm cuối {form.trial.final_score}</strong> (quy tắc {form.trial
|
||||
.rule_score}, AI {form.trial.ai_score ?? '—'}) ·
|
||||
{form.trial.result_mode}{form.trial.error_code
|
||||
? ` · ${form.trial.error_code}`
|
||||
: ''}
|
||||
</p>
|
||||
{#if form.trial.ai}
|
||||
<p class="whitespace-pre-wrap">{form.trial.ai.summary}</p>
|
||||
{#if form.trial.ai.suggested_action}
|
||||
<p><strong>Gợi ý:</strong> {form.trial.ai.suggested_action}</p>
|
||||
{/if}
|
||||
<ul class="list-disc pl-5">
|
||||
{#each form.trial.ai.signals as signal, index (`${signal.source}-${index}`)}
|
||||
<li>
|
||||
{signal.polarity === 'positive' ? '+' : '−'}
|
||||
{signal.signal}: {signal.detail}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</section>
|
||||
{/if}
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
{/snippet}
|
||||
|
||||
<header class="mb-8">
|
||||
<p class="mb-2 text-xs font-semibold text-primary">Lead Scoring / Cấu hình</p>
|
||||
<h1 class="text-3xl font-bold">Cấu hình AI</h1>
|
||||
<p class="mt-2 max-w-prose text-sm text-muted-foreground">
|
||||
Chọn nhà cung cấp, nhập URL nếu dùng OpenAI-compatible và chọn model cho Lead Scoring.
|
||||
</p>
|
||||
</header>
|
||||
<div class="grid gap-5 lg:hidden">
|
||||
{@render versionList()}
|
||||
{@render profileEditor('mobile')}
|
||||
</div>
|
||||
<div class="hidden min-h-[580px] lg:block">
|
||||
<Resizable.PaneGroup direction="horizontal" class="min-h-[580px] gap-0">
|
||||
<Resizable.Pane defaultSize={38} minSize={27} class="min-w-0 pr-3"
|
||||
>{@render versionList()}</Resizable.Pane
|
||||
>
|
||||
<Resizable.Handle withHandle class="bg-transparent after:w-3" />
|
||||
<Resizable.Pane defaultSize={62} minSize={40} class="min-w-0 pl-3"
|
||||
>{@render profileEditor('desktop')}</Resizable.Pane
|
||||
>
|
||||
</Resizable.PaneGroup>
|
||||
</div>
|
||||
|
||||
@ -3,41 +3,52 @@ import { backend } from '$lib/server/backend';
|
||||
import { actions } from './+page.server';
|
||||
|
||||
vi.mock('$lib/server/backend', () => ({ backend: vi.fn(), backendJson: vi.fn() }));
|
||||
vi.mock('$lib/server/fixture-runner', () => ({
|
||||
scoringFixture: { snapshot: {}, scoring_config: { rules: [] }, metadata: {} },
|
||||
}));
|
||||
|
||||
const backendMock = vi.mocked(backend);
|
||||
type ActionEvent = Parameters<NonNullable<typeof actions.default>>[0];
|
||||
type ActionEvent = Parameters<NonNullable<typeof actions.save>>[0];
|
||||
|
||||
describe('AI profile provider selection', () => {
|
||||
it('sends only a selected provider and profile settings to backend', async () => {
|
||||
const form = new FormData();
|
||||
form.set('version', 'profile-v2');
|
||||
form.set('provider', 'openai_compatible');
|
||||
form.set('base_url', 'http://127.0.0.1:8317');
|
||||
form.set('model', 'gemini-3.5-flash-lite');
|
||||
form.set('system_prompt', 'Only use provided evidence.');
|
||||
form.set('timeout_seconds', '30');
|
||||
const event = {
|
||||
request: { formData: async () => form },
|
||||
} as unknown as ActionEvent;
|
||||
backendMock.mockResolvedValueOnce(new Response('{}', { status: 200 }));
|
||||
const action = actions.default;
|
||||
if (!action) throw new Error('AI profile action missing');
|
||||
expect(await action(event)).toMatchObject({ success: true });
|
||||
expect(backendMock).toHaveBeenCalledWith(
|
||||
event,
|
||||
'/v1/admin/ai-profiles',
|
||||
expect.objectContaining({
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
version: 'profile-v2',
|
||||
provider: 'openai_compatible',
|
||||
base_url: 'http://127.0.0.1:8317',
|
||||
model: 'gemini-3.5-flash-lite',
|
||||
system_prompt: 'Only use provided evidence.',
|
||||
timeout_seconds: 30,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(backendMock.mock.calls[0][2]?.body).not.toContain('api_key');
|
||||
function eventFor(extra: Record<string, string> = {}): ActionEvent {
|
||||
const form = new FormData();
|
||||
const values = {
|
||||
provider: 'aihub',
|
||||
base_url: 'https://aihub.example/v1',
|
||||
model: 'openai/gpt-6-luna',
|
||||
reasoning_effort: 'low',
|
||||
instructions: 'Chấm điểm lead.',
|
||||
timeout_seconds: '50',
|
||||
timezone: 'Asia/Ho_Chi_Minh',
|
||||
include_reference_date: 'on',
|
||||
ai_key: 'typed-ai-key',
|
||||
...extra,
|
||||
};
|
||||
for (const [key, value] of Object.entries(values)) form.set(key, value);
|
||||
return { request: { formData: async () => form } } as unknown as ActionEvent;
|
||||
}
|
||||
|
||||
describe('AI profile actions', () => {
|
||||
it('saves a revision without the AI key and sends the key only on a trial', async () => {
|
||||
backendMock.mockResolvedValue(new Response('{}', { status: 200 }));
|
||||
const save = actions.save;
|
||||
const run = actions.try;
|
||||
if (!save || !run) throw new Error('actions missing');
|
||||
|
||||
expect(await save(eventFor())).toMatchObject({ success: true });
|
||||
const [, path, init] = backendMock.mock.calls[0];
|
||||
expect(path).toBe('/v1/admin/ai-profiles');
|
||||
expect(JSON.parse(String(init?.body))).toMatchObject({
|
||||
reasoning_effort: 'low',
|
||||
include_reference_date: true,
|
||||
suggested_action_enabled: false,
|
||||
});
|
||||
expect(String(init?.body)).not.toContain('typed-ai-key');
|
||||
|
||||
await run(eventFor());
|
||||
const [, tryPath, tryInit] = backendMock.mock.calls[1];
|
||||
expect(tryPath).toBe('/v1/admin/ai-profiles/try');
|
||||
expect(tryInit?.headers).toMatchObject({ 'x-ai-key': 'typed-ai-key' });
|
||||
expect(String(tryInit?.body)).not.toContain('typed-ai-key');
|
||||
});
|
||||
});
|
||||
|
||||
@ -3,17 +3,33 @@ import { backend, clearAdminSession } from '$lib/server/backend';
|
||||
import {
|
||||
fixtureRunnerConfigured,
|
||||
FixtureRunnerNotConfigured,
|
||||
scoringFixture,
|
||||
submitScoringFixture,
|
||||
} from '$lib/server/fixture-runner';
|
||||
import type { components } from '$lib/generated/api';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
|
||||
type RunSummary = components['schemas']['RunSummary'];
|
||||
type ScoreResponse = components['schemas']['ScoreResponse'];
|
||||
type RunRequest = components['schemas']['RunRequest'];
|
||||
|
||||
export const load: PageServerLoad = async () => ({
|
||||
fixtureRunnerConfigured: fixtureRunnerConfigured(),
|
||||
payload: JSON.stringify(scoringFixture, null, 2),
|
||||
});
|
||||
|
||||
// Show the first validation message so the scenario can be fixed; the backend never echoes secrets.
|
||||
async function validationMessage(response: Response): Promise<string> {
|
||||
try {
|
||||
const { detail } = (await response.json()) as {
|
||||
detail?: { loc?: unknown[]; msg?: string }[];
|
||||
};
|
||||
const first = detail?.[0];
|
||||
return first?.msg ? `${(first.loc ?? []).slice(1).join('.')}: ${first.msg}` : '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
export const actions: Actions = {
|
||||
default: async (event) => {
|
||||
event.setHeaders({ 'cache-control': 'no-store' });
|
||||
@ -31,34 +47,66 @@ export const actions: Actions = {
|
||||
});
|
||||
}
|
||||
|
||||
const form = await event.request.formData();
|
||||
const payload = String(form.get('payload') ?? '');
|
||||
const aiKey = String(form.get('ai_key') ?? '');
|
||||
if (aiKey.length > 4096 || /[\r\n]/.test(aiKey)) {
|
||||
return fail(400, {
|
||||
message: 'Khóa AI tối đa 4096 ký tự, không chứa xuống dòng.',
|
||||
payload,
|
||||
});
|
||||
}
|
||||
let scenario: RunRequest;
|
||||
try {
|
||||
scenario = JSON.parse(payload) as RunRequest;
|
||||
if (typeof scenario !== 'object' || scenario === null || Array.isArray(scenario)) {
|
||||
throw new Error('not an object');
|
||||
}
|
||||
} catch {
|
||||
return fail(400, {
|
||||
message: 'Kịch bản phải là một JSON object hợp lệ.',
|
||||
payload,
|
||||
});
|
||||
}
|
||||
let response: Response;
|
||||
try {
|
||||
response = await submitScoringFixture(event);
|
||||
response = await submitScoringFixture(event, scenario, aiKey.trim());
|
||||
} catch (error) {
|
||||
if (error instanceof FixtureRunnerNotConfigured) {
|
||||
return fail(503, {
|
||||
message: 'Chưa có mã truy cập cho mẫu này trên server.',
|
||||
payload,
|
||||
});
|
||||
}
|
||||
return fail(502, { message: 'Không gửi được dữ liệu mẫu.' });
|
||||
return fail(502, { message: 'Không gửi được dữ liệu mẫu.', payload });
|
||||
}
|
||||
if (response.status === 401) {
|
||||
return fail(502, { message: 'Mã truy cập của mẫu không còn hợp lệ.' });
|
||||
return fail(502, { message: 'Mã truy cập của mẫu không còn hợp lệ.', payload });
|
||||
}
|
||||
if (!response.ok) {
|
||||
return fail(response.status, { message: 'Không nhận được dữ liệu mẫu.' });
|
||||
const reason = response.status === 422 ? await validationMessage(response) : '';
|
||||
return fail(response.status, {
|
||||
message: `Backend từ chối dữ liệu mẫu (HTTP ${response.status})${reason ? `: ${reason}` : ''}.`,
|
||||
payload,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const run = (await response.json()) as RunSummary;
|
||||
const run = (await response.json()) as ScoreResponse;
|
||||
return {
|
||||
success: true,
|
||||
run_id: run.run_id,
|
||||
status: run.status,
|
||||
message: 'Đã gửi dữ liệu mẫu.',
|
||||
payload,
|
||||
result: run,
|
||||
message:
|
||||
scenario.scoring_config?.ai_weight > 0
|
||||
? 'Đã gửi mẫu quy tắc và AI. Xem chi tiết để kiểm tra kết quả AI hoặc fallback.'
|
||||
: 'Đã gửi mẫu chỉ chấm quy tắc.',
|
||||
};
|
||||
} catch {
|
||||
return fail(502, { message: 'AI Platform trả về phản hồi không hợp lệ.' });
|
||||
return fail(502, {
|
||||
message: 'AI Platform trả về phản hồi không hợp lệ.',
|
||||
payload,
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
@ -1,48 +1,219 @@
|
||||
<script lang="ts">
|
||||
import { onMount, untrack } from 'svelte';
|
||||
import { enhance } from '$app/forms';
|
||||
import type { PageProps } from './$types';
|
||||
import { Button } from '$lib/components/ui/button/index.js';
|
||||
import { Input } from '$lib/components/ui/input/index.js';
|
||||
import { Textarea } from '$lib/components/ui/textarea/index.js';
|
||||
import * as Card from '$lib/components/ui/card/index.js';
|
||||
import LeadEditor from './LeadEditor.svelte';
|
||||
import ConversationEditor from './ConversationEditor.svelte';
|
||||
import RuleEditor from './RuleEditor.svelte';
|
||||
import ValueInput from './ValueInput.svelte';
|
||||
import ScenarioResult from './ScenarioResult.svelte';
|
||||
import { parseScenario, setAIWeight } from './scenario';
|
||||
|
||||
let { data, form }: PageProps = $props();
|
||||
let scenario = $state(untrack(() => parseScenario(data.payload)));
|
||||
let jsonMode = $state(false);
|
||||
let payload = $state(untrack(() => data.payload));
|
||||
let error = $state('');
|
||||
let pending = $state(false);
|
||||
let ready = $state(false);
|
||||
onMount(() => {
|
||||
ready = true;
|
||||
});
|
||||
let formElement: HTMLFormElement;
|
||||
let aiPercent = $derived(scenario.scoring_config.ai_weight * 100);
|
||||
|
||||
function toggleMode() {
|
||||
error = '';
|
||||
if (jsonMode) {
|
||||
try {
|
||||
scenario = parseScenario(payload);
|
||||
} catch {
|
||||
error =
|
||||
'Không thể mở form: JSON cần có snapshot, lead_profile, conversations và scoring_config đúng cấu trúc. Dữ liệu của bạn vẫn được giữ trong JSON.';
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
if (!formElement.reportValidity()) return;
|
||||
payload = JSON.stringify(scenario, null, 2);
|
||||
}
|
||||
jsonMode = !jsonMode;
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head><title>Thử quy tắc · AI Platform</title></svelte:head>
|
||||
<svelte:head><title>Thử chấm điểm · AI Platform</title></svelte:head>
|
||||
<header class="mb-8">
|
||||
<p class="mb-2 text-xs font-semibold text-primary">Lead Scoring / Kiểm thử</p>
|
||||
<h1 class="text-3xl font-bold">Thử tính điểm theo quy tắc</h1>
|
||||
<h1 class="text-3xl font-bold">Thử chấm điểm</h1>
|
||||
<p class="mt-2 max-w-prose text-sm text-muted-foreground">
|
||||
Gửi dữ liệu mẫu để kiểm tra điểm quy tắc. Trang này chưa thử model AI.
|
||||
Dựng lead, hội thoại và quy tắc để thử chấm điểm. Đây là kịch bản thử, không lưu chính sách
|
||||
cho Omni. Tải lại trang để về mẫu gốc.
|
||||
</p>
|
||||
</header>
|
||||
<Card.Root class="max-w-2xl">
|
||||
<Card.Header>
|
||||
<h2 class="text-base font-semibold">Dữ liệu mẫu</h2>
|
||||
</Card.Header>
|
||||
<Card.Content class="grid gap-4">
|
||||
{#if !data.fixtureRunnerConfigured}
|
||||
<p class="text-sm text-muted-foreground" role="status">
|
||||
Chưa có mã truy cập cho mẫu này trên server.
|
||||
</p>
|
||||
{/if}
|
||||
{#if form?.message}
|
||||
<p
|
||||
class={form.success ? 'text-sm text-primary' : 'text-sm text-destructive'}
|
||||
role={form.success ? 'status' : 'alert'}
|
||||
>
|
||||
{form.message}
|
||||
</p>
|
||||
{/if}
|
||||
{#if form?.success && form.run_id}
|
||||
<p class="text-sm">
|
||||
Trạng thái: {form.status}.
|
||||
<a
|
||||
class="font-medium text-primary underline-offset-4 hover:underline focus-visible:outline-2"
|
||||
href={`/lead-scoring/runs/${form.run_id}`}>Xem lần chấm điểm</a
|
||||
|
||||
<Card.Root>
|
||||
<Card.Content class="pt-6">
|
||||
<form
|
||||
bind:this={formElement}
|
||||
method="POST"
|
||||
class="grid gap-6"
|
||||
use:enhance={({ formData, cancel }) => {
|
||||
if (pending) {
|
||||
cancel();
|
||||
return;
|
||||
}
|
||||
formData.set('payload', jsonMode ? payload : JSON.stringify(scenario));
|
||||
const keyInput =
|
||||
formElement.querySelector<HTMLInputElement>('input[name="ai_key"]');
|
||||
if (keyInput) keyInput.value = '';
|
||||
pending = true;
|
||||
return async ({ update }) => {
|
||||
try {
|
||||
await update({ reset: false });
|
||||
} finally {
|
||||
pending = false;
|
||||
}
|
||||
};
|
||||
}}
|
||||
>
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<h2 class="text-base font-semibold">
|
||||
{jsonMode ? 'Kịch bản JSON' : 'Dựng kịch bản'}
|
||||
</h2>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={!ready || pending}
|
||||
onclick={toggleMode}>{jsonMode ? 'Mở form' : 'JSON nâng cao'}</Button
|
||||
>
|
||||
</div>
|
||||
{#if error}<p class="text-sm text-destructive" role="alert">{error}</p>{/if}
|
||||
<fieldset
|
||||
disabled={!ready || pending}
|
||||
class="grid min-w-0 gap-6 divide-y divide-border"
|
||||
>
|
||||
{#if jsonMode}
|
||||
<label class="grid gap-2 text-sm font-medium"
|
||||
>Kịch bản (JSON)
|
||||
<Textarea
|
||||
name="payload"
|
||||
rows={24}
|
||||
required
|
||||
spellcheck={false}
|
||||
class="font-mono text-xs"
|
||||
bind:value={payload}
|
||||
/>
|
||||
</label>
|
||||
<p class="pt-4 text-xs text-muted-foreground">
|
||||
Trọng số trong JSON được gửi nguyên vẹn. Backend kiểm tra contract và báo
|
||||
lỗi nếu sai.
|
||||
</p>
|
||||
{:else}
|
||||
<input type="hidden" name="payload" value={JSON.stringify(scenario)} />
|
||||
<LeadEditor bind:profile={scenario.snapshot.lead_profile} />
|
||||
<div class="pt-6">
|
||||
<ConversationEditor bind:snapshot={scenario.snapshot} />
|
||||
</div>
|
||||
<div class="pt-6">
|
||||
<RuleEditor
|
||||
bind:rules={scenario.scoring_config.rules}
|
||||
customFields={Object.keys(
|
||||
scenario.snapshot.lead_profile.custom_fields ?? {},
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<section class="grid gap-4 pt-6" aria-labelledby="weights-heading">
|
||||
<h2 id="weights-heading" class="text-lg font-semibold">
|
||||
Trọng số chấm điểm
|
||||
</h2>
|
||||
<label class="grid max-w-sm gap-2 text-sm font-medium"
|
||||
>Tỷ lệ AI (%)
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
max={100}
|
||||
step="any"
|
||||
required
|
||||
value={aiPercent}
|
||||
oninput={(e) =>
|
||||
setAIWeight(
|
||||
scenario,
|
||||
e.currentTarget.value === ''
|
||||
? NaN
|
||||
: Number(e.currentTarget.value),
|
||||
)}
|
||||
/>
|
||||
</label>
|
||||
<p class="text-sm" aria-live="polite">
|
||||
Quy tắc: {Number.isFinite(aiPercent) ? 100 - aiPercent : '?'}% · AI: {Number.isFinite(
|
||||
aiPercent,
|
||||
)
|
||||
? aiPercent
|
||||
: '?'}%. Tổng luôn bằng 100%.
|
||||
</p>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
AI 0%: không gọi model. AI > 0%: dùng profile đang active, có thể
|
||||
phát sinh chi phí. AIHub dùng khóa cho lần thử ở dưới, hoặc khóa cấu
|
||||
hình trên server. Thiếu key/profile hoặc provider lỗi sẽ fallback về
|
||||
điểm quy tắc.
|
||||
</p>
|
||||
</section>
|
||||
<details class="pt-6">
|
||||
<summary class="cursor-pointer text-sm font-medium"
|
||||
>Metadata (JSON nâng cao)</summary
|
||||
>
|
||||
<div class="mt-4">
|
||||
<ValueInput label="Metadata" json bind:value={scenario.metadata} />
|
||||
</div>
|
||||
</details>
|
||||
{/if}
|
||||
</fieldset>
|
||||
<label class="grid max-w-lg gap-2 text-sm font-medium">
|
||||
Khóa AI cho lần thử (không lưu)
|
||||
<Input
|
||||
name="ai_key"
|
||||
type="password"
|
||||
autocomplete="off"
|
||||
maxlength={4096}
|
||||
disabled={!ready || pending}
|
||||
placeholder="Chỉ dùng với AIHub khi tỷ lệ AI > 0%"
|
||||
aria-describedby="ai-key-note"
|
||||
/>
|
||||
</label>
|
||||
<p id="ai-key-note" class="text-xs text-muted-foreground">
|
||||
Ưu tiên khóa bạn nhập; để trống dùng LEAD_SCORING_FIXTURE_AI_KEY trên server nếu có.
|
||||
Khóa chỉ gửi trong lần thử này, không nằm trong JSON hay kết quả và được xóa khỏi ô
|
||||
nhập khi gửi. Khóa ở trang Cấu hình AI không tự chuyển sang đây.
|
||||
</p>
|
||||
{/if}
|
||||
<form method="POST">
|
||||
<Button type="submit" disabled={!data.fixtureRunnerConfigured}>Gửi dữ liệu mẫu</Button>
|
||||
{#if !data.fixtureRunnerConfigured}<p
|
||||
class="text-sm text-muted-foreground"
|
||||
role="status"
|
||||
>
|
||||
Chưa có mã truy cập cho mẫu này trên server.
|
||||
</p>{/if}
|
||||
{#if form?.message}<p
|
||||
class={form.success ? 'text-sm text-primary' : 'text-sm text-destructive'}
|
||||
role={form.success ? 'status' : 'alert'}
|
||||
>
|
||||
{form.message}
|
||||
</p>{/if}
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={!ready || pending || !data.fixtureRunnerConfigured}
|
||||
class="w-fit">{pending ? 'Đang chấm điểm...' : 'Chạy thử'}</Button
|
||||
>
|
||||
<noscript
|
||||
><p class="text-sm text-muted-foreground">
|
||||
Bật JavaScript để dựng và đồng bộ kịch bản bằng form.
|
||||
</p></noscript
|
||||
>
|
||||
</form>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
|
||||
{#if form?.success && form.result}
|
||||
<ScenarioResult result={form.result} />
|
||||
{/if}
|
||||
|
||||
@ -0,0 +1,160 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '$lib/components/ui/button/index.js';
|
||||
import { Input } from '$lib/components/ui/input/index.js';
|
||||
import { Textarea } from '$lib/components/ui/textarea/index.js';
|
||||
import { uniqueId, type Scenario } from './scenario';
|
||||
|
||||
let { snapshot = $bindable() }: { snapshot: Scenario['snapshot'] } = $props();
|
||||
let conversations = $derived(snapshot.conversations ?? []);
|
||||
let messageCount = $derived(
|
||||
conversations.reduce((count, c) => count + (c.messages?.length ?? 0), 0),
|
||||
);
|
||||
|
||||
function utcInput(value: string): string {
|
||||
const date = new Date(value);
|
||||
return Number.isNaN(date.getTime()) ? '' : date.toISOString().slice(0, 23);
|
||||
}
|
||||
|
||||
function addConversation() {
|
||||
snapshot.conversations = [
|
||||
...conversations,
|
||||
{
|
||||
conversation_id: uniqueId(
|
||||
'conversation',
|
||||
conversations.map((c) => c.conversation_id),
|
||||
),
|
||||
channel: 'webchat',
|
||||
messages: [],
|
||||
},
|
||||
];
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="grid gap-4" aria-labelledby="conversation-heading">
|
||||
<h2 id="conversation-heading" class="text-lg font-semibold">Hội thoại giả lập</h2>
|
||||
<label class="grid max-w-sm gap-2 text-sm font-medium">
|
||||
Thời điểm chụp dữ liệu (UTC)
|
||||
<Input
|
||||
type="datetime-local"
|
||||
step="0.001"
|
||||
required
|
||||
value={utcInput(snapshot.captured_at)}
|
||||
oninput={(e) =>
|
||||
(snapshot.captured_at = e.currentTarget.value ? `${e.currentTarget.value}Z` : '')}
|
||||
/>
|
||||
</label>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
Tối đa 10 hội thoại, tổng 50 tin nhắn và 20.000 ký tự. Tin nhắn không được sau thời điểm
|
||||
chụp dữ liệu. Thời gian trên form dùng UTC.
|
||||
</p>
|
||||
{#each conversations as conversation, ci (conversation)}
|
||||
<fieldset class="grid min-w-0 gap-4 rounded-md border p-4">
|
||||
<legend class="px-2 text-sm font-semibold">Hội thoại {ci + 1}</legend>
|
||||
<div class="grid items-end gap-3 sm:grid-cols-[1fr_1fr_auto]">
|
||||
<label class="grid gap-2 text-sm font-medium"
|
||||
>Mã hội thoại<Input
|
||||
required
|
||||
maxlength={255}
|
||||
bind:value={conversation.conversation_id}
|
||||
/></label
|
||||
>
|
||||
<label class="grid gap-2 text-sm font-medium"
|
||||
>Kênh<Input
|
||||
maxlength={64}
|
||||
value={conversation.channel ?? ''}
|
||||
oninput={(e) => (conversation.channel = e.currentTarget.value || null)}
|
||||
/></label
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onclick={() =>
|
||||
(snapshot.conversations = conversations.filter((_, index) => index !== ci))}
|
||||
>Xóa hội thoại {ci + 1}</Button
|
||||
>
|
||||
</div>
|
||||
{#each conversation.messages ?? [] as message, mi (message)}
|
||||
<fieldset class="grid min-w-0 gap-3 border-t pt-3">
|
||||
<legend class="text-sm font-medium">Tin nhắn {mi + 1}</legend>
|
||||
<div class="grid gap-3 sm:grid-cols-3">
|
||||
<label class="grid gap-2 text-sm font-medium"
|
||||
>Mã tin nhắn<Input
|
||||
required
|
||||
maxlength={255}
|
||||
bind:value={message.message_id}
|
||||
/></label
|
||||
>
|
||||
<label class="grid gap-2 text-sm font-medium"
|
||||
>Người gửi
|
||||
<select
|
||||
class="rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||
bind:value={message.sender_type}
|
||||
>
|
||||
<option value="contact">Khách</option><option value="agent"
|
||||
>Nhân viên</option
|
||||
><option value="bot">Bot</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="grid gap-2 text-sm font-medium"
|
||||
>Thời gian gửi (UTC)
|
||||
<Input
|
||||
type="datetime-local"
|
||||
step="0.001"
|
||||
required
|
||||
max={utcInput(snapshot.captured_at)}
|
||||
value={utcInput(message.sent_at)}
|
||||
oninput={(e) =>
|
||||
(message.sent_at = e.currentTarget.value
|
||||
? `${e.currentTarget.value}Z`
|
||||
: '')}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<label class="grid gap-2 text-sm font-medium"
|
||||
>Nội dung<Textarea rows={3} required bind:value={message.content} /></label
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
class="w-fit"
|
||||
onclick={() =>
|
||||
(conversation.messages = conversation.messages?.filter(
|
||||
(_, index) => index !== mi,
|
||||
))}>Xóa tin nhắn {mi + 1}</Button
|
||||
>
|
||||
</fieldset>
|
||||
{:else}<p class="text-sm text-muted-foreground">
|
||||
Chưa có tin nhắn. Thêm tin để thử metrics và AI.
|
||||
</p>{/each}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
class="w-fit"
|
||||
disabled={messageCount >= 50}
|
||||
onclick={() => {
|
||||
conversation.messages = [
|
||||
...(conversation.messages ?? []),
|
||||
{
|
||||
message_id: uniqueId(
|
||||
'message',
|
||||
conversations.flatMap((c) =>
|
||||
(c.messages ?? []).map((m) => m.message_id),
|
||||
),
|
||||
),
|
||||
sender_type: 'contact',
|
||||
content: '',
|
||||
sent_at: snapshot.captured_at,
|
||||
},
|
||||
];
|
||||
}}>Thêm tin nhắn</Button
|
||||
>
|
||||
</fieldset>
|
||||
{:else}<p class="text-sm text-muted-foreground">Chưa có hội thoại.</p>{/each}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
class="w-fit"
|
||||
disabled={conversations.length >= 10}
|
||||
onclick={addConversation}>Thêm hội thoại</Button
|
||||
>
|
||||
</section>
|
||||
@ -0,0 +1,70 @@
|
||||
<script lang="ts">
|
||||
import type { components } from '$lib/generated/api';
|
||||
import { Button } from '$lib/components/ui/button/index.js';
|
||||
import { Input } from '$lib/components/ui/input/index.js';
|
||||
import ValueInput from './ValueInput.svelte';
|
||||
import { profileFields } from './scenario';
|
||||
|
||||
let { profile = $bindable() }: { profile: components['schemas']['SnapshotLeadProfile'] } =
|
||||
$props();
|
||||
let newField = $state('');
|
||||
let custom = $derived(profile.custom_fields ?? {});
|
||||
|
||||
function addField() {
|
||||
const key = newField.trim();
|
||||
if (!key || Object.hasOwn(custom, key)) return;
|
||||
profile.custom_fields = { ...custom, [key]: '' };
|
||||
newField = '';
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="grid gap-4" aria-labelledby="lead-heading">
|
||||
<h2 id="lead-heading" class="text-lg font-semibold">Thông tin lead</h2>
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
{#each Object.entries(profileFields) as [key, label]}
|
||||
<label class="grid gap-2 text-sm font-medium">
|
||||
{label}
|
||||
<Input
|
||||
required={key === 'external_id'}
|
||||
maxlength={key === 'phone' || key === 'status' ? 64 : 255}
|
||||
value={profile[key as keyof typeof profileFields] ?? ''}
|
||||
oninput={(e) => {
|
||||
if (key === 'external_id') profile.external_id = e.currentTarget.value;
|
||||
else
|
||||
profile[key as Exclude<keyof typeof profileFields, 'external_id'>] =
|
||||
e.currentTarget.value || null;
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
<h3 class="text-sm font-semibold">Custom fields</h3>
|
||||
{#each Object.keys(custom) as key (key)}
|
||||
<div class="grid items-end gap-3 sm:grid-cols-[1fr_auto]">
|
||||
<ValueInput label={key} bind:value={custom[key]} />
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onclick={() => {
|
||||
profile.custom_fields = Object.fromEntries(
|
||||
Object.entries(custom).filter(([name]) => name !== key),
|
||||
);
|
||||
}}
|
||||
>
|
||||
Xóa trường {key}
|
||||
</Button>
|
||||
</div>
|
||||
{:else}<p class="text-sm text-muted-foreground">Chưa có custom field.</p>{/each}
|
||||
<div class="flex flex-wrap items-end gap-3">
|
||||
<label class="grid gap-2 text-sm font-medium">
|
||||
Tên custom field mới
|
||||
<Input bind:value={newField} />
|
||||
</label>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={!newField.trim() || Object.hasOwn(custom, newField.trim())}
|
||||
onclick={addField}>Thêm trường</Button
|
||||
>
|
||||
</div>
|
||||
</section>
|
||||
@ -0,0 +1,186 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '$lib/components/ui/button/index.js';
|
||||
import { Input } from '$lib/components/ui/input/index.js';
|
||||
import ValueInput from './ValueInput.svelte';
|
||||
import RuleValueList from './RuleValueList.svelte';
|
||||
import {
|
||||
availableOperators,
|
||||
changeRule,
|
||||
metricFields,
|
||||
operators,
|
||||
profileFields,
|
||||
uniqueId,
|
||||
type Rule,
|
||||
} from './scenario';
|
||||
|
||||
let { rules = $bindable(), customFields }: { rules: Rule[]; customFields: string[] } = $props();
|
||||
let fields = $derived([
|
||||
...Object.entries(profileFields).map(([key, label]) => [`lead_profile.${key}`, label]),
|
||||
...Object.entries(metricFields).map(([key, label]) => [`metrics.${key}`, label]),
|
||||
...customFields.map((key) => [`custom_fields.${key}`, `Custom: ${key}`]),
|
||||
]);
|
||||
</script>
|
||||
|
||||
<section class="grid gap-4" aria-labelledby="rule-heading">
|
||||
<h2 id="rule-heading" class="text-lg font-semibold">Quy tắc chấm điểm</h2>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
Mỗi rule khớp sẽ cộng hoặc trừ điểm. Điểm quy tắc được giới hạn từ 0 đến 100. Điều kiện sai
|
||||
kiểu dữ liệu không khớp.
|
||||
</p>
|
||||
{#each rules as rule, index (rule)}
|
||||
<fieldset class="grid min-w-0 gap-4 rounded-md border p-4">
|
||||
<legend class="px-2 text-sm font-semibold">Quy tắc {index + 1}</legend>
|
||||
<div class="grid items-end gap-3 sm:grid-cols-[1fr_1fr_auto]">
|
||||
<label class="grid gap-2 text-sm font-medium"
|
||||
>Mã quy tắc<Input required maxlength={64} bind:value={rule.id} /></label
|
||||
>
|
||||
<label class="grid gap-2 text-sm font-medium"
|
||||
>Điểm khi khớp<Input
|
||||
type="number"
|
||||
step="1"
|
||||
min={-100}
|
||||
max={100}
|
||||
required
|
||||
bind:value={rule.points}
|
||||
/></label
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onclick={() => (rules = rules.filter((_, ri) => ri !== index))}
|
||||
>Xóa quy tắc {index + 1}</Button
|
||||
>
|
||||
</div>
|
||||
<div class="grid gap-3 sm:grid-cols-2">
|
||||
<label class="grid gap-2 text-sm font-medium"
|
||||
>Trường dữ liệu
|
||||
<select
|
||||
class="min-w-0 rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||
value={rule.field}
|
||||
onchange={(e) => changeRule(rule, e.currentTarget.value)}
|
||||
>
|
||||
{#if !fields.some(([field]) => field === rule.field)}<option
|
||||
value={rule.field}>{rule.field}</option
|
||||
>{/if}
|
||||
{#each fields as [field, label]}<option value={field}
|
||||
>{label} ({field})</option
|
||||
>{/each}
|
||||
<option value="custom_fields.">Custom field khác (đường dẫn)</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="grid gap-2 text-sm font-medium"
|
||||
>Operator
|
||||
<select
|
||||
class="rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||
value={rule.operator}
|
||||
onchange={(e) =>
|
||||
changeRule(rule, rule.field, e.currentTarget.value as Rule['operator'])}
|
||||
>
|
||||
{#each availableOperators(rule.field) as operator}<option value={operator}
|
||||
>{operators[operator]} ({operator})</option
|
||||
>{/each}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
{#if rule.field.startsWith('custom_fields.')}
|
||||
<label class="grid gap-2 text-sm font-medium"
|
||||
>Đường dẫn custom field
|
||||
<Input
|
||||
required
|
||||
maxlength={128}
|
||||
pattern="custom_fields\..+"
|
||||
bind:value={rule.field}
|
||||
/>
|
||||
</label>
|
||||
{/if}
|
||||
{#key `${rule.field}-${rule.operator}`}
|
||||
{#if rule.operator === 'exists'}
|
||||
<p class="text-sm text-muted-foreground">
|
||||
Không cần giá trị. Kiểm tra trường có giá trị khác null.
|
||||
</p>
|
||||
{:else if rule.operator === 'between'}
|
||||
{@const bounds = Array.isArray(rule.value) ? rule.value : []}
|
||||
<div class="grid gap-3 sm:grid-cols-2">
|
||||
<label class="grid gap-2 text-sm font-medium"
|
||||
>Ngưỡng dưới<Input
|
||||
type="number"
|
||||
step="any"
|
||||
required
|
||||
value={bounds[0]}
|
||||
oninput={(e) =>
|
||||
(rule.value = [
|
||||
e.currentTarget.value === ''
|
||||
? null
|
||||
: Number(e.currentTarget.value),
|
||||
bounds[1],
|
||||
])}
|
||||
/></label
|
||||
>
|
||||
<label class="grid gap-2 text-sm font-medium"
|
||||
>Ngưỡng trên<Input
|
||||
type="number"
|
||||
step="any"
|
||||
required
|
||||
min={bounds[0]}
|
||||
value={bounds[1]}
|
||||
oninput={(e) =>
|
||||
(rule.value = [
|
||||
bounds[0],
|
||||
e.currentTarget.value === ''
|
||||
? null
|
||||
: Number(e.currentTarget.value),
|
||||
])}
|
||||
/></label
|
||||
>
|
||||
</div>
|
||||
{:else if rule.operator === 'in'}
|
||||
<RuleValueList
|
||||
bind:value={rule.value}
|
||||
numeric={rule.field.startsWith('metrics.')}
|
||||
/>
|
||||
{:else}
|
||||
<ValueInput
|
||||
label="Giá trị điều kiện"
|
||||
bind:value={rule.value}
|
||||
boolean={rule.field === 'metrics.customer_initiated_return'}
|
||||
numeric={(rule.field.startsWith('metrics.') &&
|
||||
rule.field !== 'metrics.customer_initiated_return') ||
|
||||
['gt', 'gte', 'lt', 'lte'].includes(rule.operator)}
|
||||
/>
|
||||
{/if}
|
||||
{/key}
|
||||
<label class="grid gap-2 text-sm font-medium"
|
||||
>Mô tả (ngữ cảnh cho AI, tùy chọn)
|
||||
<Input
|
||||
maxlength={99}
|
||||
value={rule.description ?? ''}
|
||||
oninput={(e) => (rule.description = e.currentTarget.value || null)}
|
||||
/>
|
||||
</label>
|
||||
</fieldset>
|
||||
{:else}<p class="text-sm text-muted-foreground">
|
||||
Chưa có quy tắc. Điểm quy tắc sẽ bằng 0.
|
||||
</p>{/each}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
class="w-fit"
|
||||
disabled={rules.length >= 100}
|
||||
onclick={() =>
|
||||
(rules = [
|
||||
...rules,
|
||||
{
|
||||
id: uniqueId(
|
||||
'rule',
|
||||
rules.map((r) => r.id),
|
||||
),
|
||||
field: 'metrics.contact_message_count',
|
||||
operator: 'gte',
|
||||
value: 1,
|
||||
points: 10,
|
||||
},
|
||||
])}
|
||||
>
|
||||
Thêm quy tắc
|
||||
</Button>
|
||||
</section>
|
||||
@ -0,0 +1,39 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from 'svelte';
|
||||
import { Button } from '$lib/components/ui/button/index.js';
|
||||
import ValueInput from './ValueInput.svelte';
|
||||
|
||||
let { value = $bindable(), numeric }: { value: unknown; numeric: boolean } = $props();
|
||||
let items = $derived(Array.isArray(value) ? value : []);
|
||||
// Keep input identity when removing another item, including its type and unfinished JSON.
|
||||
let keys = $state(untrack(() => items.map(() => ({}))));
|
||||
</script>
|
||||
|
||||
<div class="grid gap-3">
|
||||
{#each items as _, index (keys[index])}
|
||||
<div class="grid items-end gap-3 sm:grid-cols-[1fr_auto]">
|
||||
<ValueInput label={`Giá trị ${index + 1}`} bind:value={items[index]} {numeric} />
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onclick={() => {
|
||||
keys = keys.filter((_, i) => i !== index);
|
||||
value = items.filter((_, i) => i !== index);
|
||||
}}>Xóa giá trị {index + 1}</Button
|
||||
>
|
||||
</div>
|
||||
{/each}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
class="w-fit"
|
||||
disabled={items.length >= 100}
|
||||
onclick={() => {
|
||||
keys = [...keys, {}];
|
||||
value = [...items, numeric ? 0 : ''];
|
||||
}}>Thêm giá trị</Button
|
||||
>
|
||||
{#if !items.length}<p class="text-sm text-destructive">
|
||||
Danh sách cần ít nhất một giá trị.
|
||||
</p>{/if}
|
||||
</div>
|
||||
@ -0,0 +1,45 @@
|
||||
<script lang="ts">
|
||||
import type { components } from '$lib/generated/api';
|
||||
import * as Card from '$lib/components/ui/card/index.js';
|
||||
|
||||
let { result }: { result: components['schemas']['ScoreResponse'] } = $props();
|
||||
</script>
|
||||
|
||||
<Card.Root class="mt-5">
|
||||
<Card.Header><h2 class="text-lg font-semibold">Kết quả lần chạy vừa rồi</h2></Card.Header>
|
||||
<Card.Content class="grid gap-4 text-sm" aria-live="polite">
|
||||
<dl class="grid grid-cols-3 gap-4">
|
||||
{#each [['Quy tắc', result.rule_score], ['AI', result.ai_score ?? 'Không có'], ['Điểm cuối', result.final_score]] as [label, score]}
|
||||
<div>
|
||||
<dt class="text-muted-foreground">{label}</dt>
|
||||
<dd class="mt-1 text-xl font-semibold tabular-nums">{score}</dd>
|
||||
</div>
|
||||
{/each}
|
||||
</dl>
|
||||
<p>{result.result_mode}{result.error_code ? ` (${result.error_code})` : ''}</p>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-left">
|
||||
<caption class="sr-only">Kết quả từng quy tắc</caption>
|
||||
<thead
|
||||
><tr class="border-b"
|
||||
><th class="p-2">Quy tắc</th><th class="p-2">Khớp</th><th class="p-2"
|
||||
>Điểm</th
|
||||
></tr
|
||||
></thead
|
||||
>
|
||||
<tbody
|
||||
>{#each result.breakdown ?? [] as item}<tr class="border-b"
|
||||
><td class="break-all p-2">{item.rule_id}</td><td class="p-2"
|
||||
>{item.matched ? 'Có' : 'Không'}</td
|
||||
><td class="p-2 tabular-nums">{item.points}</td></tr
|
||||
>{/each}</tbody
|
||||
>
|
||||
</table>
|
||||
</div>
|
||||
{#if result.ai}<p class="whitespace-pre-wrap">{result.ai.summary}</p>{/if}
|
||||
<a
|
||||
class="w-fit font-medium text-primary underline underline-offset-4"
|
||||
href={`/lead-scoring/runs/${result.run_id}`}>Xem chi tiết, tín hiệu AI và metrics</a
|
||||
>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
@ -0,0 +1,105 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from 'svelte';
|
||||
import { Input } from '$lib/components/ui/input/index.js';
|
||||
import { Textarea } from '$lib/components/ui/textarea/index.js';
|
||||
|
||||
let {
|
||||
value = $bindable(),
|
||||
label,
|
||||
numeric = false,
|
||||
boolean = false,
|
||||
json = false,
|
||||
}: {
|
||||
value: unknown;
|
||||
label: string;
|
||||
numeric?: boolean;
|
||||
boolean?: boolean;
|
||||
json?: boolean;
|
||||
} = $props();
|
||||
let selectedKind = $state(
|
||||
untrack(() =>
|
||||
typeof value === 'boolean'
|
||||
? 'boolean'
|
||||
: typeof value === 'number'
|
||||
? 'number'
|
||||
: typeof value === 'string'
|
||||
? 'string'
|
||||
: 'json',
|
||||
),
|
||||
);
|
||||
let kind = $derived(json ? 'json' : numeric ? 'number' : boolean ? 'boolean' : selectedKind);
|
||||
let text = $state(untrack(() => JSON.stringify(value ?? null)));
|
||||
|
||||
function readJSON(event: Event) {
|
||||
const input = event.currentTarget as HTMLTextAreaElement;
|
||||
text = input.value;
|
||||
try {
|
||||
const parsed = JSON.parse(text);
|
||||
value = parsed;
|
||||
input.setCustomValidity('');
|
||||
} catch {
|
||||
input.setCustomValidity('Nhập JSON hợp lệ.');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="grid gap-2">
|
||||
{#if !numeric && !boolean && !json}
|
||||
<label class="grid gap-2 text-xs text-muted-foreground">
|
||||
Kiểu giá trị ({label})
|
||||
<select
|
||||
class="rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground"
|
||||
value={kind}
|
||||
onchange={(e) => {
|
||||
selectedKind = e.currentTarget.value;
|
||||
value =
|
||||
e.currentTarget.value === 'number'
|
||||
? 0
|
||||
: e.currentTarget.value === 'boolean'
|
||||
? false
|
||||
: e.currentTarget.value === 'json'
|
||||
? null
|
||||
: '';
|
||||
text = JSON.stringify(value);
|
||||
}}
|
||||
>
|
||||
<option value="string">Văn bản</option><option value="number">Số</option>
|
||||
<option value="boolean">Đúng / sai</option><option value="json">JSON</option>
|
||||
</select>
|
||||
</label>
|
||||
{/if}
|
||||
<label class="grid gap-2 text-sm font-medium">
|
||||
{label}
|
||||
{#if kind === 'boolean'}
|
||||
<select
|
||||
class="rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||
value={String(value)}
|
||||
onchange={(e) => (value = e.currentTarget.value === 'true')}
|
||||
>
|
||||
<option value="true">Đúng</option><option value="false">Sai</option>
|
||||
</select>
|
||||
{:else if kind === 'number'}
|
||||
<Input
|
||||
type="number"
|
||||
step="any"
|
||||
required
|
||||
value={Number(value)}
|
||||
oninput={(e) => {
|
||||
value =
|
||||
e.currentTarget.value === '' ? undefined : Number(e.currentTarget.value);
|
||||
}}
|
||||
/>
|
||||
{:else if kind === 'json'}
|
||||
<Textarea
|
||||
rows={2}
|
||||
required
|
||||
spellcheck={false}
|
||||
class="font-mono text-xs"
|
||||
value={text}
|
||||
oninput={readJSON}
|
||||
/>
|
||||
{:else}
|
||||
<Input value={String(value ?? '')} oninput={(e) => (value = e.currentTarget.value)} />
|
||||
{/if}
|
||||
</label>
|
||||
</div>
|
||||
@ -1,6 +1,10 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { backend, clearAdminSession } from '$lib/server/backend';
|
||||
import { fixtureRunnerConfigured, submitScoringFixture } from '$lib/server/fixture-runner';
|
||||
import {
|
||||
fixtureRunnerConfigured,
|
||||
scoringFixture,
|
||||
submitScoringFixture,
|
||||
} from '$lib/server/fixture-runner';
|
||||
import { actions, load } from './+page.server';
|
||||
|
||||
vi.mock('$lib/server/backend', () => ({
|
||||
@ -9,6 +13,7 @@ vi.mock('$lib/server/backend', () => ({
|
||||
}));
|
||||
vi.mock('$lib/server/fixture-runner', () => ({
|
||||
fixtureRunnerConfigured: vi.fn(),
|
||||
scoringFixture: { metadata: { account_id: 'fixture' } },
|
||||
submitScoringFixture: vi.fn(),
|
||||
FixtureRunnerNotConfigured: class extends Error {},
|
||||
}));
|
||||
@ -21,9 +26,18 @@ const runId = 'f4a5d7c8-f55f-47d2-b2ea-611a2b78e59b';
|
||||
|
||||
type ActionEvent = Parameters<NonNullable<typeof actions.default>>[0];
|
||||
|
||||
function eventFor(): { event: ActionEvent; setHeaders: ReturnType<typeof vi.fn> } {
|
||||
function eventFor(
|
||||
useAI = false,
|
||||
payload = JSON.stringify({
|
||||
metadata: { edited: true },
|
||||
scoring_config: { ai_weight: useAI ? 0.3 : 0, rule_weight: useAI ? 0.7 : 1, rules: [] },
|
||||
}),
|
||||
): { event: ActionEvent; setHeaders: ReturnType<typeof vi.fn> } {
|
||||
const setHeaders = vi.fn();
|
||||
return { event: { setHeaders } as unknown as ActionEvent, setHeaders };
|
||||
const data = new FormData();
|
||||
data.set('payload', payload);
|
||||
const request = { formData: vi.fn().mockResolvedValue(data) };
|
||||
return { event: { setHeaders, request } as unknown as ActionEvent, setHeaders };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
@ -33,34 +47,74 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
describe('fixture runner page', () => {
|
||||
it('shows whether a server-only fixture key is configured', async () => {
|
||||
it('returns the editable canonical scenario without credentials even when the runner is unconfigured', async () => {
|
||||
configuredMock.mockReturnValue(false);
|
||||
const page = await load({} as Parameters<typeof load>[0]);
|
||||
expect(page).toEqual({ fixtureRunnerConfigured: false });
|
||||
expect(page).toEqual({
|
||||
fixtureRunnerConfigured: false,
|
||||
payload: JSON.stringify(scoringFixture, null, 2),
|
||||
});
|
||||
expect(JSON.stringify(page)).not.toMatch(/API_KEY|BACKEND_INTERNAL_URL|Bearer/);
|
||||
});
|
||||
|
||||
it('submits the fixture only after checking the admin session', async () => {
|
||||
submitMock.mockResolvedValueOnce(
|
||||
Response.json({ run_id: runId, status: 'pending' }, { status: 202 }),
|
||||
);
|
||||
it.each([false, true])(
|
||||
'submits the chosen AI mode (%s) only after checking the admin session',
|
||||
async (useAI) => {
|
||||
submitMock.mockResolvedValueOnce(
|
||||
Response.json(
|
||||
{ run_id: runId, result_mode: 'hybrid', error_code: null },
|
||||
{ status: 200 },
|
||||
),
|
||||
);
|
||||
const action = actions.default;
|
||||
if (!action) throw new Error('fixture action missing');
|
||||
const { event, setHeaders } = eventFor(useAI);
|
||||
const aiKey = useAI ? 'request-only-key' : '';
|
||||
(await event.request.formData()).set('ai_key', ` ${aiKey} `);
|
||||
const result = await action(event);
|
||||
expect(result).toMatchObject({
|
||||
success: true,
|
||||
result: { run_id: runId, result_mode: 'hybrid', error_code: null },
|
||||
});
|
||||
expect(setHeaders).toHaveBeenCalledWith({ 'cache-control': 'no-store' });
|
||||
expect(backendMock).toHaveBeenCalledWith(event, '/v1/admin/me');
|
||||
expect(submitMock).toHaveBeenCalledWith(
|
||||
event,
|
||||
{
|
||||
metadata: { edited: true },
|
||||
scoring_config: {
|
||||
ai_weight: useAI ? 0.3 : 0,
|
||||
rule_weight: useAI ? 0.7 : 1,
|
||||
rules: [],
|
||||
},
|
||||
},
|
||||
aiKey,
|
||||
);
|
||||
expect(JSON.stringify(result)).not.toContain('request-only-key');
|
||||
expect(backendMock.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
submitMock.mock.invocationCallOrder[0],
|
||||
);
|
||||
if (useAI) {
|
||||
expect(result).toMatchObject({ message: expect.stringContaining('fallback') });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it('rejects a scenario that is not a JSON object without submitting', async () => {
|
||||
const action = actions.default;
|
||||
if (!action) throw new Error('fixture action missing');
|
||||
const { event, setHeaders } = eventFor();
|
||||
const result = await action(event);
|
||||
expect(result).toMatchObject({ success: true, run_id: runId, status: 'pending' });
|
||||
expect(setHeaders).toHaveBeenCalledWith({ 'cache-control': 'no-store' });
|
||||
expect(backendMock).toHaveBeenCalledWith(event, '/v1/admin/me');
|
||||
expect(submitMock).toHaveBeenCalledWith(event);
|
||||
expect(backendMock.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
submitMock.mock.invocationCallOrder[0],
|
||||
);
|
||||
for (const bad of ['not json', '[1]', 'null']) {
|
||||
const result = await action(eventFor(false, bad).event);
|
||||
expect(result).toMatchObject({ status: 400 });
|
||||
}
|
||||
expect(submitMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects an expired admin session without submitting', async () => {
|
||||
backendMock.mockResolvedValueOnce(new Response('{}', { status: 401 }));
|
||||
const action = actions.default;
|
||||
if (!action) throw new Error('fixture action missing');
|
||||
const { event } = eventFor();
|
||||
const { event } = eventFor(true);
|
||||
const result = await action(event);
|
||||
expect(result).toMatchObject({ status: 401 });
|
||||
expect(clearSessionMock).toHaveBeenCalledWith(event);
|
||||
@ -76,12 +130,62 @@ describe('fixture runner page', () => {
|
||||
expect(submitMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not expose a rejected tenant key in the response', async () => {
|
||||
submitMock.mockResolvedValueOnce(new Response('{}', { status: 401 }));
|
||||
it('does not expose a rejected app key in the response', async () => {
|
||||
submitMock.mockResolvedValueOnce(new Response('private app key', { status: 401 }));
|
||||
const action = actions.default;
|
||||
if (!action) throw new Error('fixture action missing');
|
||||
const result = await action(eventFor().event);
|
||||
expect(result).toMatchObject({ status: 502 });
|
||||
expect(JSON.stringify(result)).not.toContain('Bearer');
|
||||
expect(JSON.stringify(result)).not.toMatch(/Bearer|private app key/);
|
||||
});
|
||||
|
||||
it('reports backend rejection without claiming that the fixture is missing', async () => {
|
||||
submitMock.mockResolvedValueOnce(new Response('private backend detail', { status: 503 }));
|
||||
const action = actions.default;
|
||||
if (!action) throw new Error('fixture action missing');
|
||||
const result = await action(eventFor(true).event);
|
||||
expect(result).toMatchObject({
|
||||
status: 503,
|
||||
data: { message: 'Backend từ chối dữ liệu mẫu (HTTP 503).' },
|
||||
});
|
||||
expect(JSON.stringify(result)).not.toContain('private backend detail');
|
||||
});
|
||||
|
||||
it('keeps the scenario and displays the backend validation location', async () => {
|
||||
submitMock.mockResolvedValueOnce(
|
||||
Response.json(
|
||||
{
|
||||
detail: [
|
||||
{
|
||||
loc: ['body', 'scoring_config', 'rules', 0],
|
||||
msg: 'between min cannot exceed max',
|
||||
},
|
||||
],
|
||||
},
|
||||
{ status: 422 },
|
||||
),
|
||||
);
|
||||
const payload = '{"scoring_config":{"rules":[]}}';
|
||||
const result = await actions.default!(eventFor(false, payload).event);
|
||||
expect(result).toMatchObject({
|
||||
status: 422,
|
||||
data: {
|
||||
payload,
|
||||
message: expect.stringContaining(
|
||||
'scoring_config.rules.0: between min cannot exceed max',
|
||||
),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects oversized or multiline AI keys without sending or echoing them', async () => {
|
||||
for (const key of ['private-key\r\n', 'k'.repeat(4097)]) {
|
||||
const { event } = eventFor(true);
|
||||
(await event.request.formData()).set('ai_key', key);
|
||||
const result = await actions.default!(event);
|
||||
expect(result).toMatchObject({ status: 400 });
|
||||
expect(JSON.stringify(result)).not.toContain(key);
|
||||
}
|
||||
expect(submitMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@ -0,0 +1,66 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import fixture from '../../../../../../../contracts/lead-scoring/fixtures/run-request.json';
|
||||
import {
|
||||
availableOperators,
|
||||
changeRule,
|
||||
parseScenario,
|
||||
setAIWeight,
|
||||
uniqueId,
|
||||
type Rule,
|
||||
} from './scenario';
|
||||
|
||||
describe('scenario form', () => {
|
||||
it('round-trips the canonical request without losing conversation, custom field or metadata values', () => {
|
||||
const scenario = parseScenario(JSON.stringify(fixture));
|
||||
scenario.snapshot.lead_profile.custom_fields = { nested: { budget: 20 }, active: true };
|
||||
scenario.snapshot.conversations![0].messages![0].content = 'Nội dung mới';
|
||||
const roundtrip = parseScenario(JSON.stringify(scenario));
|
||||
expect(roundtrip).toEqual(scenario);
|
||||
expect(roundtrip.metadata).toEqual(fixture.metadata);
|
||||
expect(roundtrip.snapshot.conversations![0].messages![0].content).toBe('Nội dung mới');
|
||||
});
|
||||
|
||||
it('rejects structures that would break form bindings and does not replace backend validation', () => {
|
||||
for (const value of [
|
||||
null,
|
||||
[],
|
||||
{},
|
||||
{ ...fixture, snapshot: { ...fixture.snapshot, conversations: [null] } },
|
||||
]) {
|
||||
expect(() => parseScenario(JSON.stringify(value))).toThrow();
|
||||
}
|
||||
const scenario = structuredClone(fixture);
|
||||
scenario.scoring_config.rules[0].field = 'invalid.field';
|
||||
expect(parseScenario(JSON.stringify(scenario))).toEqual(scenario);
|
||||
});
|
||||
|
||||
it('uses suitable values for operators and restricts boolean metrics', () => {
|
||||
const rule: Rule = {
|
||||
id: 'a',
|
||||
field: 'custom_fields.budget',
|
||||
operator: 'eq',
|
||||
value: '20',
|
||||
points: 10,
|
||||
};
|
||||
changeRule(rule, rule.field, 'between');
|
||||
expect(rule.value).toEqual([0, 10]);
|
||||
changeRule(rule, rule.field, 'exists');
|
||||
expect(rule.value).toBeNull();
|
||||
changeRule(rule, 'metrics.contact_message_count', 'in');
|
||||
expect(rule.value).toEqual([1]);
|
||||
changeRule(rule, 'metrics.customer_initiated_return', 'gt');
|
||||
expect(rule).toMatchObject({ operator: 'eq', value: false });
|
||||
expect(availableOperators('metrics.contact_message_count')).not.toContain('contains');
|
||||
expect(availableOperators('metrics.customer_initiated_return')).toEqual(['eq', 'ne']);
|
||||
});
|
||||
|
||||
it('keeps weights complementary and generates unused identifiers after deletion', () => {
|
||||
const scenario = parseScenario(JSON.stringify(fixture));
|
||||
for (const percent of [0, 30, 30.25, 100]) {
|
||||
setAIWeight(scenario, percent);
|
||||
expect(scenario.scoring_config.ai_weight).toBe(percent / 100);
|
||||
expect(scenario.scoring_config.rule_weight + scenario.scoring_config.ai_weight).toBe(1);
|
||||
}
|
||||
expect(uniqueId('rule', ['rule-1', 'rule-3'])).toBe('rule-2');
|
||||
});
|
||||
});
|
||||
124
apps/web/src/routes/(admin)/lead-scoring/fixtures/scenario.ts
Normal file
@ -0,0 +1,124 @@
|
||||
import type { components } from '$lib/generated/api';
|
||||
|
||||
export type Scenario = components['schemas']['RunRequest'];
|
||||
export type Rule = components['schemas']['RuleInput'];
|
||||
|
||||
export const operators: Record<Rule['operator'], string> = {
|
||||
eq: 'Bằng',
|
||||
ne: 'Khác',
|
||||
gt: 'Lớn hơn',
|
||||
gte: 'Lớn hơn hoặc bằng',
|
||||
lt: 'Nhỏ hơn',
|
||||
lte: 'Nhỏ hơn hoặc bằng',
|
||||
contains: 'Chứa',
|
||||
exists: 'Tồn tại',
|
||||
between: 'Trong khoảng (gồm hai đầu)',
|
||||
in: 'Thuộc danh sách',
|
||||
};
|
||||
|
||||
export const profileFields = {
|
||||
external_id: 'Mã lead',
|
||||
name: 'Tên',
|
||||
phone: 'Điện thoại',
|
||||
email: 'Email',
|
||||
status: 'Trạng thái',
|
||||
} as const;
|
||||
|
||||
export const metricFields: Record<keyof components['schemas']['RunMetrics'], string> = {
|
||||
profile_field_count: 'Số trường thông tin lead',
|
||||
conversation_count: 'Số cuộc hội thoại',
|
||||
message_count: 'Tổng số tin nhắn',
|
||||
contact_message_count: 'Số tin nhắn của khách',
|
||||
conversation_turn_count: 'Số lượt hội thoại (nhóm người gửi liên tiếp)',
|
||||
contact_started_conversation_count: 'Số hội thoại khách bắt đầu',
|
||||
contact_message_recency_days: 'Số ngày từ tin nhắn khách gần nhất',
|
||||
contact_message_recency_hours: 'Số giờ từ tin nhắn khách gần nhất',
|
||||
customer_initiated_return: 'Khách chủ động quay lại',
|
||||
};
|
||||
|
||||
export function availableOperators(field: string): Rule['operator'][] {
|
||||
if (field === 'metrics.customer_initiated_return') return ['eq', 'ne'];
|
||||
return (Object.keys(operators) as Rule['operator'][]).filter(
|
||||
(operator) => !field.startsWith('metrics.') || operator !== 'contains',
|
||||
);
|
||||
}
|
||||
|
||||
export function changeRule(rule: Rule, field: string, operator = rule.operator): void {
|
||||
rule.field = field;
|
||||
rule.operator = availableOperators(field).includes(operator) ? operator : 'eq';
|
||||
if (field === 'metrics.customer_initiated_return') rule.value = false;
|
||||
else if (rule.operator === 'exists') rule.value = null;
|
||||
else if (rule.operator === 'between') rule.value = [0, 10];
|
||||
else if (rule.operator === 'in') rule.value = field.startsWith('metrics.') ? [1] : [''];
|
||||
else if (field.startsWith('metrics.') || ['gt', 'gte', 'lt', 'lte'].includes(rule.operator))
|
||||
rule.value = 0;
|
||||
else rule.value = '';
|
||||
}
|
||||
|
||||
// This only protects the form's bindings. The backend owns contract validation.
|
||||
export function parseScenario(payload: string): Scenario {
|
||||
const value = JSON.parse(payload);
|
||||
const object = (v: unknown): v is Record<string, unknown> =>
|
||||
typeof v === 'object' && v !== null && !Array.isArray(v);
|
||||
const optionalString = (v: unknown) => v === undefined || v === null || typeof v === 'string';
|
||||
if (!object(value) || !object(value.snapshot) || !object(value.scoring_config))
|
||||
throw new Error('shape');
|
||||
const snapshot = value.snapshot;
|
||||
const profile = snapshot.lead_profile;
|
||||
const config = value.scoring_config;
|
||||
if (
|
||||
!object(profile) ||
|
||||
typeof profile.external_id !== 'string' ||
|
||||
typeof snapshot.captured_at !== 'string' ||
|
||||
!Object.keys(profileFields).every((key) => optionalString(profile[key])) ||
|
||||
(profile.custom_fields !== undefined && !object(profile.custom_fields)) ||
|
||||
(value.metadata !== undefined && !object(value.metadata)) ||
|
||||
typeof config.rule_weight !== 'number' ||
|
||||
typeof config.ai_weight !== 'number' ||
|
||||
!Array.isArray(config.rules) ||
|
||||
!config.rules.every(
|
||||
(r) =>
|
||||
object(r) &&
|
||||
typeof r.id === 'string' &&
|
||||
typeof r.field === 'string' &&
|
||||
typeof r.operator === 'string' &&
|
||||
Object.hasOwn(operators, r.operator) &&
|
||||
typeof r.points === 'number' &&
|
||||
optionalString(r.description),
|
||||
) ||
|
||||
(snapshot.conversations !== undefined &&
|
||||
(!Array.isArray(snapshot.conversations) ||
|
||||
!snapshot.conversations.every(
|
||||
(c) =>
|
||||
object(c) &&
|
||||
typeof c.conversation_id === 'string' &&
|
||||
optionalString(c.channel) &&
|
||||
(c.messages === undefined ||
|
||||
(Array.isArray(c.messages) &&
|
||||
c.messages.every(
|
||||
(m) =>
|
||||
object(m) &&
|
||||
typeof m.message_id === 'string' &&
|
||||
['contact', 'agent', 'bot'].includes(
|
||||
String(m.sender_type),
|
||||
) &&
|
||||
typeof m.content === 'string' &&
|
||||
typeof m.sent_at === 'string',
|
||||
))),
|
||||
)))
|
||||
) {
|
||||
throw new Error('shape');
|
||||
}
|
||||
return value as Scenario;
|
||||
}
|
||||
|
||||
export function uniqueId(prefix: string, existing: string[]): string {
|
||||
let number = 1;
|
||||
while (existing.includes(`${prefix}-${number}`)) number++;
|
||||
return `${prefix}-${number}`;
|
||||
}
|
||||
|
||||
export function setAIWeight(scenario: Scenario, percent: number): void {
|
||||
scenario.scoring_config.ai_weight = percent / 100;
|
||||
scenario.scoring_config.rule_weight = (100 - percent) / 100;
|
||||
}
|
||||
@ -1,18 +1,13 @@
|
||||
import { error } from '@sveltejs/kit';
|
||||
import { backendJson } from '$lib/server/backend';
|
||||
import type { components } from '$lib/generated/api';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export type RunSummary = {
|
||||
run_id: string;
|
||||
tenant_id: string;
|
||||
status: string;
|
||||
final_score: number | null;
|
||||
degraded: boolean;
|
||||
created_at: string | null;
|
||||
};
|
||||
|
||||
export const load: PageServerLoad = async (event) => {
|
||||
const { response, data } = await backendJson<{ runs: RunSummary[] }>(event, '/v1/admin/runs');
|
||||
const { response, data } = await backendJson<components['schemas']['RunList']>(
|
||||
event,
|
||||
'/v1/admin/runs',
|
||||
);
|
||||
if (!response.ok || !data) error(502, 'Không tải được danh sách lần chấm điểm.');
|
||||
return { runs: data.runs };
|
||||
};
|
||||
|
||||
@ -4,13 +4,15 @@
|
||||
import { Badge } from '$lib/components/ui/badge/index.js';
|
||||
import * as Table from '$lib/components/ui/table/index.js';
|
||||
let { data }: PageProps = $props();
|
||||
|
||||
const when = (value: string) => new Date(value).toLocaleString('vi-VN');
|
||||
</script>
|
||||
|
||||
<header class="mb-8">
|
||||
<p class="mb-2 text-xs font-semibold text-primary">Lead Scoring / Vận hành</p>
|
||||
<h1 class="text-3xl font-bold">Lần chấm điểm</h1>
|
||||
<p class="mt-2 max-w-prose text-sm text-muted-foreground">
|
||||
Kiểm tra trạng thái kỹ thuật và kết quả của từng lần chấm điểm.
|
||||
50 lần chấm gần nhất: kết quả và mã lỗi dự phòng nếu có.
|
||||
</p>
|
||||
</header>
|
||||
<Card.Root>
|
||||
@ -23,84 +25,50 @@
|
||||
Chưa có lần chấm điểm nào.
|
||||
</p>
|
||||
{:else}
|
||||
<ul class="grid gap-3 md:hidden" aria-label="Các lần chấm điểm gần đây">
|
||||
{#each data.runs as run (run.run_id)}
|
||||
<li>
|
||||
<Card.Root>
|
||||
<Card.Content class="grid gap-3">
|
||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||
<Badge variant={run.degraded ? 'destructive' : 'secondary'}
|
||||
>{run.status}{run.degraded ? ' · dự phòng' : ''}</Badge
|
||||
>
|
||||
<strong class="tabular-nums"
|
||||
>Điểm: {run.final_score ?? '—'}</strong
|
||||
>
|
||||
</div>
|
||||
<time
|
||||
class="text-sm text-muted-foreground"
|
||||
datetime={run.created_at ?? undefined}
|
||||
>{run.created_at
|
||||
? new Date(run.created_at).toLocaleString('vi-VN')
|
||||
: '—'}</time
|
||||
>
|
||||
<p
|
||||
class="truncate font-mono text-xs text-muted-foreground"
|
||||
title={run.tenant_id}
|
||||
>
|
||||
{run.tenant_id}
|
||||
</p>
|
||||
<a
|
||||
class="w-fit font-medium text-primary underline-offset-4 hover:underline focus-visible:outline-2"
|
||||
<Table.Root>
|
||||
<Table.Header
|
||||
><Table.Row
|
||||
><Table.Head>Thời điểm</Table.Head><Table.Head>App / Lead</Table.Head
|
||||
><Table.Head>Kết quả</Table.Head><Table.Head>Điểm</Table.Head><Table.Head
|
||||
><span class="sr-only">Chi tiết</span></Table.Head
|
||||
></Table.Row
|
||||
></Table.Header
|
||||
>
|
||||
<Table.Body>
|
||||
{#each data.runs as run (run.run_id)}
|
||||
<Table.Row>
|
||||
<Table.Cell
|
||||
><time datetime={run.created_at}>{when(run.created_at)}</time
|
||||
></Table.Cell
|
||||
>
|
||||
<Table.Cell
|
||||
class="max-w-48 truncate font-mono text-xs"
|
||||
title={`${run.app_id} / ${run.external_id}`}
|
||||
>{run.app_id} / {run.external_id}</Table.Cell
|
||||
>
|
||||
<Table.Cell
|
||||
><Badge
|
||||
variant={run.result_mode === 'rules_only_fallback'
|
||||
? 'destructive'
|
||||
: 'secondary'}
|
||||
>{run.result_mode}{run.error_code
|
||||
? ` · ${run.error_code}`
|
||||
: ''}</Badge
|
||||
></Table.Cell
|
||||
>
|
||||
<Table.Cell class="font-semibold tabular-nums"
|
||||
>{run.final_score}</Table.Cell
|
||||
>
|
||||
<Table.Cell
|
||||
><a
|
||||
class="font-medium text-primary underline-offset-4 hover:underline"
|
||||
href={`/lead-scoring/runs/${run.run_id}`}>Xem chi tiết</a
|
||||
>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
<div class="hidden md:block">
|
||||
<Table.Root>
|
||||
<Table.Header
|
||||
><Table.Row
|
||||
><Table.Head>Thời điểm</Table.Head><Table.Head>Tenant</Table.Head
|
||||
><Table.Head>Trạng thái</Table.Head><Table.Head>Điểm</Table.Head
|
||||
><Table.Head><span class="sr-only">Chi tiết</span></Table.Head
|
||||
></Table.Row
|
||||
></Table.Header
|
||||
>
|
||||
<Table.Body>
|
||||
{#each data.runs as run (run.run_id)}
|
||||
<Table.Row>
|
||||
<Table.Cell
|
||||
><time datetime={run.created_at ?? undefined}
|
||||
>{run.created_at
|
||||
? new Date(run.created_at).toLocaleString('vi-VN')
|
||||
: '—'}</time
|
||||
></Table.Cell
|
||||
>
|
||||
<Table.Cell
|
||||
class="max-w-40 truncate font-mono text-xs"
|
||||
title={run.tenant_id}>{run.tenant_id}</Table.Cell
|
||||
>
|
||||
<Table.Cell
|
||||
><Badge variant={run.degraded ? 'destructive' : 'secondary'}
|
||||
>{run.status}{run.degraded ? ' · dự phòng' : ''}</Badge
|
||||
></Table.Cell
|
||||
>
|
||||
<Table.Cell class="font-semibold tabular-nums"
|
||||
>{run.final_score ?? '—'}</Table.Cell
|
||||
>
|
||||
<Table.Cell
|
||||
><a
|
||||
class="font-medium text-primary underline-offset-4 hover:underline"
|
||||
href={`/lead-scoring/runs/${run.run_id}`}>Xem chi tiết</a
|
||||
></Table.Cell
|
||||
>
|
||||
</Table.Row>
|
||||
{/each}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
</div>
|
||||
></Table.Cell
|
||||
>
|
||||
</Table.Row>
|
||||
{/each}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
{/if}
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
|
||||
@ -5,6 +5,11 @@
|
||||
import * as Card from '$lib/components/ui/card/index.js';
|
||||
import RunAnalysis from './RunAnalysis.svelte';
|
||||
let { data }: PageProps = $props();
|
||||
const result = $derived(data.run.result);
|
||||
const request = $derived(data.run.request);
|
||||
const rules = $derived(
|
||||
new Map(request.scoring_config.rules.map((rule) => [rule.id, rule] as const)),
|
||||
);
|
||||
|
||||
type SenderType = components['schemas']['SnapshotMessage']['sender_type'];
|
||||
const senderLabels: Record<SenderType, string> = {
|
||||
@ -29,91 +34,87 @@
|
||||
<p class="mt-2 break-all font-mono text-xs text-muted-foreground">{data.run.run_id}</p>
|
||||
</header>
|
||||
<div class="mb-5 grid gap-4 sm:grid-cols-3" aria-label="Kết quả chấm điểm">
|
||||
<Card.Root
|
||||
><Card.Content
|
||||
><span class="text-sm text-muted-foreground">Điểm quy tắc</span><strong
|
||||
class="mt-1 block text-3xl tabular-nums">{data.run.rule_score ?? '—'}</strong
|
||||
></Card.Content
|
||||
></Card.Root
|
||||
>
|
||||
<Card.Root
|
||||
><Card.Content
|
||||
><span class="text-sm text-muted-foreground">Điểm AI</span><strong
|
||||
class="mt-1 block text-3xl tabular-nums">{data.run.ai_score ?? '—'}</strong
|
||||
></Card.Content
|
||||
></Card.Root
|
||||
>
|
||||
<Card.Root
|
||||
><Card.Content
|
||||
><span class="text-sm text-muted-foreground">Điểm cuối</span><strong
|
||||
class="mt-1 block text-3xl tabular-nums">{data.run.final_score ?? '—'}</strong
|
||||
></Card.Content
|
||||
></Card.Root
|
||||
>
|
||||
{#each [['Điểm quy tắc', result.rule_score], ['Điểm AI', result.ai_score], ['Điểm cuối', result.final_score]] as [label, value] (label)}
|
||||
<Card.Root
|
||||
><Card.Content
|
||||
><span class="text-sm text-muted-foreground">{label}</span><strong
|
||||
class="mt-1 block text-3xl tabular-nums">{value ?? '—'}</strong
|
||||
></Card.Content
|
||||
></Card.Root
|
||||
>
|
||||
{/each}
|
||||
</div>
|
||||
<RunAnalysis run={data.run} />
|
||||
<div class="mt-5 grid gap-5 lg:grid-cols-2">
|
||||
<div class="grid gap-5 lg:grid-cols-2">
|
||||
<Card.Root>
|
||||
<Card.Header><h2 class="text-base font-semibold">Trạng thái</h2></Card.Header>
|
||||
<Card.Header><h2 class="text-base font-semibold">Thông tin lần chấm</h2></Card.Header>
|
||||
<Card.Content>
|
||||
<dl class="grid grid-cols-[minmax(100px,0.4fr)_minmax(0,1fr)] gap-x-4 gap-y-3 text-sm">
|
||||
<dt class="text-muted-foreground">Tenant</dt>
|
||||
<dd class="min-w-0 break-all">{data.run.tenant_id}</dd>
|
||||
<dt class="text-muted-foreground">Trạng thái</dt>
|
||||
<dt class="text-muted-foreground">App</dt>
|
||||
<dd class="min-w-0 break-all">{data.run.app_id}</dd>
|
||||
<dt class="text-muted-foreground">Lead</dt>
|
||||
<dd class="min-w-0 break-all">{request.snapshot.lead_profile.external_id}</dd>
|
||||
<dt class="text-muted-foreground">Cách tính</dt>
|
||||
<dd>
|
||||
<Badge variant={data.run.degraded ? 'destructive' : 'secondary'}
|
||||
>{data.run.status}</Badge
|
||||
<Badge
|
||||
variant={result.result_mode === 'rules_only_fallback'
|
||||
? 'destructive'
|
||||
: 'secondary'}>{result.result_mode}</Badge
|
||||
>
|
||||
</dd>
|
||||
<dt class="text-muted-foreground">Chế độ dự phòng</dt>
|
||||
<dd>{data.run.degraded ? 'Có' : 'Không'}</dd>
|
||||
<dt class="text-muted-foreground">Phiên bản AI</dt>
|
||||
<dd class="break-all">{data.run.profile_version ?? '—'}</dd>
|
||||
<dt class="text-muted-foreground">Mã lỗi</dt>
|
||||
<dd class="break-all">{data.run.error_code ?? '—'}</dd>
|
||||
<dd class="break-all">{result.error_code ?? '—'}</dd>
|
||||
<dt class="text-muted-foreground">Cấu hình AI</dt>
|
||||
<dd class="break-all">
|
||||
{data.run.profile_revision
|
||||
? `#${data.run.profile_revision} · ${data.run.profile_model ?? '—'}`
|
||||
: '—'}
|
||||
</dd>
|
||||
<dt class="text-muted-foreground">Thời gian xử lý</dt>
|
||||
<dd>{data.run.duration_ms} ms</dd>
|
||||
<dt class="text-muted-foreground">Metadata</dt>
|
||||
<dd class="break-all font-mono text-xs">{JSON.stringify(result.metadata)}</dd>
|
||||
</dl>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
<Card.Root>
|
||||
<Card.Header><h2 class="text-base font-semibold">Quy tắc đã áp dụng</h2></Card.Header>
|
||||
<Card.Content>
|
||||
{#if data.run.breakdown?.length}
|
||||
<ul class="divide-y divide-border">
|
||||
{#each data.run.breakdown as item}
|
||||
<li
|
||||
class="flex flex-wrap items-center justify-between gap-2 py-3 first:pt-0 last:pb-0 text-sm"
|
||||
>
|
||||
<ul class="divide-y divide-border">
|
||||
{#each result.breakdown as item (item.rule_id)}
|
||||
{@const rule = rules.get(item.rule_id)}
|
||||
<li class="grid gap-1 py-3 text-sm first:pt-0 last:pb-0">
|
||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||
<strong class="break-all font-medium">{item.rule_id}</strong>
|
||||
<span class="text-muted-foreground"
|
||||
>{item.matched ? 'Khớp' : 'Không khớp'} · {item.points} điểm</span
|
||||
>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{:else}
|
||||
<p class="text-sm text-muted-foreground">
|
||||
Chưa có chi tiết quy tắc cho lần chấm điểm này.
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
{#if rule}
|
||||
<code class="break-all text-xs"
|
||||
>{rule.field} {rule.operator} {JSON.stringify(rule.value)}</code
|
||||
>
|
||||
{#if rule.description}
|
||||
<p class="text-muted-foreground">{rule.description}</p>
|
||||
{/if}
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
</div>
|
||||
<RunAnalysis {result} />
|
||||
<Card.Root class="mt-5">
|
||||
<Card.Header>
|
||||
<h2 class="text-base font-semibold">Cuộc trò chuyện</h2>
|
||||
<Card.Description>Hội thoại được gửi trong snapshot của lần chấm điểm.</Card.Description>
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
{#if !data.run.snapshot}
|
||||
<p role="status" class="text-sm text-muted-foreground">
|
||||
Không có snapshot trong phản hồi này. Các lần chấm cũ có thể chưa cung cấp lịch sử
|
||||
hội thoại.
|
||||
</p>
|
||||
{:else if !data.run.snapshot.conversations?.length}
|
||||
{#if !request.snapshot.conversations?.length}
|
||||
<p class="text-sm text-muted-foreground">Snapshot này không có cuộc trò chuyện.</p>
|
||||
{:else}
|
||||
<div class="grid gap-5">
|
||||
{#each data.run.snapshot.conversations as conversation, conversationIndex (`${conversation.conversation_id}-${conversationIndex}`)}
|
||||
{#each request.snapshot.conversations as conversation, conversationIndex (`${conversation.conversation_id}-${conversationIndex}`)}
|
||||
<article
|
||||
class="min-w-0 border-t border-border pt-5 first:border-t-0 first:pt-0"
|
||||
>
|
||||
|
||||
@ -1,146 +1,75 @@
|
||||
<script lang="ts">
|
||||
import type { components } from '$lib/generated/api';
|
||||
import * as Card from '$lib/components/ui/card/index.js';
|
||||
|
||||
let { run }: { run: components['schemas']['RunDetail'] } = $props();
|
||||
|
||||
const metricRows = $derived([
|
||||
{ label: 'Thông tin hồ sơ có giá trị', value: String(run.metrics.profile_field_count) },
|
||||
{ label: 'Số cuộc trò chuyện', value: String(run.metrics.conversation_count) },
|
||||
{ label: 'Số tin nhắn', value: String(run.metrics.message_count) },
|
||||
{ label: 'Tin nhắn từ khách hàng', value: String(run.metrics.contact_message_count) },
|
||||
{ label: 'Số lượt hội thoại', value: String(run.metrics.conversation_turn_count) },
|
||||
{
|
||||
label: 'Hội thoại bắt đầu từ khách hàng',
|
||||
value: String(run.metrics.contact_started_conversation_count),
|
||||
},
|
||||
{
|
||||
label: 'Số ngày từ tin nhắn khách hàng gần nhất',
|
||||
value:
|
||||
run.metrics.contact_message_recency_days == null
|
||||
? '—'
|
||||
: `${run.metrics.contact_message_recency_days} ngày`,
|
||||
},
|
||||
{
|
||||
label: 'Số giờ từ tin nhắn khách hàng gần nhất',
|
||||
value:
|
||||
run.metrics.contact_message_recency_hours == null
|
||||
? '—'
|
||||
: `${run.metrics.contact_message_recency_hours} giờ`,
|
||||
},
|
||||
{
|
||||
label: 'Hội thoại tiếp theo bắt đầu từ khách hàng',
|
||||
value: run.metrics.customer_initiated_return ? 'Có' : 'Không',
|
||||
},
|
||||
let { result }: { result: components['schemas']['ScoreResponse'] } = $props();
|
||||
const signals = $derived(result.ai?.signals ?? []);
|
||||
const groups = $derived([
|
||||
{ label: 'Tín hiệu tích cực', items: signals.filter((s) => s.polarity === 'positive') },
|
||||
{ label: 'Tín hiệu tiêu cực', items: signals.filter((s) => s.polarity === 'negative') },
|
||||
]);
|
||||
|
||||
const resultState = $derived(
|
||||
run.status === 'failed'
|
||||
? 'Thất bại'
|
||||
: run.degraded
|
||||
? 'Dự phòng bằng điểm quy tắc'
|
||||
: run.result_mode === 'rules_only'
|
||||
? 'Chỉ dùng quy tắc'
|
||||
: run.result_mode === 'hybrid'
|
||||
? 'AI và quy tắc'
|
||||
: '—',
|
||||
);
|
||||
|
||||
const confidence = $derived(
|
||||
run.ai_confidence === null ? '—' : `${Math.round(run.ai_confidence * 100)}%`,
|
||||
);
|
||||
const metrics = $derived(Object.entries(result.metrics));
|
||||
</script>
|
||||
|
||||
<div class="mt-5 grid gap-5 lg:grid-cols-2">
|
||||
<section class="rounded-xl border border-border bg-card text-card-foreground">
|
||||
<header class="border-b border-border p-5">
|
||||
<h2 class="text-base font-semibold">Phân tích AI</h2>
|
||||
<p class="mt-1 text-sm text-muted-foreground">
|
||||
Kết quả điểm và bằng chứng trích xuất từ lượt AI.
|
||||
</p>
|
||||
</header>
|
||||
<div class="p-5">
|
||||
<dl class="grid grid-cols-[minmax(120px,0.5fr)_minmax(0,1fr)] gap-x-4 gap-y-3 text-sm">
|
||||
<dt class="text-muted-foreground">Độ tin cậy</dt>
|
||||
<dd>{confidence}</dd>
|
||||
<dt class="text-muted-foreground">Cách tính</dt>
|
||||
<dd>{resultState}</dd>
|
||||
<dt class="text-muted-foreground">Mã lỗi</dt>
|
||||
<dd class="break-all">{run.error_code ?? '—'}</dd>
|
||||
</dl>
|
||||
<div class="mt-5 border-t border-border pt-4">
|
||||
<h3 class="text-sm font-semibold">Lý do chấm điểm</h3>
|
||||
{#if run.ai_reasoning}
|
||||
<p class="mt-2 whitespace-pre-wrap break-words text-sm leading-6">
|
||||
{run.ai_reasoning}
|
||||
</p>
|
||||
{:else if run.status === 'failed' || run.degraded}
|
||||
<p role="status" class="mt-2 text-sm text-muted-foreground">
|
||||
AI không hoàn tất lượt chấm này. Điểm cuối dùng kết quả quy tắc nếu có.
|
||||
</p>
|
||||
{:else if run.result_mode === 'rules_only'}
|
||||
<p class="mt-2 text-sm text-muted-foreground">
|
||||
Lần chấm này chỉ dùng quy tắc, không yêu cầu kết quả AI.
|
||||
</p>
|
||||
{:else}
|
||||
<p class="mt-2 text-sm text-muted-foreground">
|
||||
Chưa có lý do AI trong kết quả này.
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="rounded-xl border border-border bg-card text-card-foreground">
|
||||
<header class="border-b border-border p-5">
|
||||
<h2 class="text-base font-semibold">Bằng chứng AI trích xuất</h2>
|
||||
<p class="mt-1 text-sm text-muted-foreground">
|
||||
Kết quả lượt đầu, lưu cùng lần chấm thành công.
|
||||
</p>
|
||||
</header>
|
||||
<div class="p-5">
|
||||
{#if run.ai_evidence?.length}
|
||||
<ul class="divide-y divide-border">
|
||||
{#each run.ai_evidence as evidence, index (`${evidence.source}-${index}`)}
|
||||
<li class="py-3 first:pt-0 last:pb-0">
|
||||
<h3 class="text-sm font-semibold">{evidence.signal}</h3>
|
||||
<p class="mt-1 whitespace-pre-wrap break-words text-sm leading-6">
|
||||
{evidence.detail}
|
||||
</p>
|
||||
<p class="mt-2 break-all font-mono text-xs text-muted-foreground">
|
||||
Nguồn: {evidence.source}
|
||||
</p>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{:else if run.status === 'failed' || run.degraded}
|
||||
<p role="status" class="text-sm text-muted-foreground">
|
||||
Không có bằng chứng được lưu vì lượt AI dự phòng hoặc thất bại.
|
||||
{#if run.error_code}Mã lỗi: {run.error_code}.{/if}
|
||||
<Card.Root class="mt-5">
|
||||
<Card.Header><h2 class="text-base font-semibold">Phân tích AI</h2></Card.Header>
|
||||
<Card.Content class="grid gap-4 text-sm">
|
||||
{#if result.ai}
|
||||
<p class="whitespace-pre-wrap break-words leading-6">{result.ai.summary}</p>
|
||||
{#if result.ai.suggested_action}
|
||||
<p>
|
||||
<strong>Hành động gợi ý:</strong>
|
||||
{result.ai.suggested_action}
|
||||
</p>
|
||||
{:else if run.result_mode === 'rules_only'}
|
||||
<p class="text-sm text-muted-foreground">
|
||||
Không có bằng chứng AI vì lần chấm này chỉ dùng quy tắc.
|
||||
</p>
|
||||
{:else}
|
||||
<p class="text-sm text-muted-foreground">Không có bằng chứng AI trong phản hồi.</p>
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="rounded-xl border border-border bg-card text-card-foreground lg:col-span-2">
|
||||
<header class="border-b border-border p-5">
|
||||
<h2 class="text-base font-semibold">Chỉ số cuộc trò chuyện</h2>
|
||||
<p class="mt-1 text-sm text-muted-foreground">
|
||||
Các giá trị được backend tính từ snapshot và dùng cho quy tắc Omni.
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
{#each groups as group (group.label)}
|
||||
<section>
|
||||
<h3 class="font-semibold">{group.label}</h3>
|
||||
{#if group.items.length === 0}
|
||||
<p class="mt-2 text-muted-foreground">Không có.</p>
|
||||
{:else}
|
||||
<ul class="mt-2 divide-y divide-border">
|
||||
{#each group.items as item, index (`${item.source}-${index}`)}
|
||||
<li class="py-3 first:pt-0 last:pb-0">
|
||||
<strong>{item.signal}</strong>
|
||||
<p class="mt-1 whitespace-pre-wrap break-words">
|
||||
{item.detail}
|
||||
</p>
|
||||
<p
|
||||
class="mt-1 break-all font-mono text-xs text-muted-foreground"
|
||||
>
|
||||
Nguồn: {item.source}
|
||||
</p>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</section>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if result.result_mode === 'rules_only_fallback'}
|
||||
<p role="status" class="text-muted-foreground">
|
||||
AI không hoàn tất lượt chấm này ({result.error_code}). Điểm cuối dùng điểm quy tắc.
|
||||
</p>
|
||||
</header>
|
||||
<dl class="grid gap-x-6 gap-y-4 p-5 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{#each metricRows as metric (metric.label)}
|
||||
{:else}
|
||||
<p class="text-muted-foreground">Lần chấm này chỉ dùng quy tắc, không gọi AI.</p>
|
||||
{/if}
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
<Card.Root class="mt-5">
|
||||
<Card.Header>
|
||||
<h2 class="text-base font-semibold">Chỉ số cuộc trò chuyện</h2>
|
||||
<Card.Description>Backend tính từ snapshot, dùng được trong quy tắc.</Card.Description>
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
<dl class="grid gap-x-6 gap-y-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{#each metrics as [name, value] (name)}
|
||||
<div>
|
||||
<dt class="text-sm text-muted-foreground">{metric.label}</dt>
|
||||
<dd class="mt-1 font-medium tabular-nums">{metric.value}</dd>
|
||||
<dt class="break-all font-mono text-xs text-muted-foreground">{name}</dt>
|
||||
<dd class="mt-1 font-medium tabular-nums">{value ?? '—'}</dd>
|
||||
</div>
|
||||
{/each}
|
||||
</dl>
|
||||
</section>
|
||||
</div>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
|
||||
@ -1,113 +0,0 @@
|
||||
import { render } from 'svelte/server';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { components } from '$lib/generated/api';
|
||||
import RunAnalysis from './RunAnalysis.svelte';
|
||||
|
||||
const successfulRun: components['schemas']['RunDetail'] = {
|
||||
run_id: '7d5fe45b-0816-42ca-807a-3869a0227949',
|
||||
request_id: '8a67f3d4-f2a4-40e1-9b9b-4e2913060cf1',
|
||||
tenant_id: 'mock-tenant',
|
||||
status: 'completed',
|
||||
profile_version: 'medium-v1',
|
||||
rule_score: 40,
|
||||
ai_score: 80,
|
||||
final_score: 70,
|
||||
degraded: false,
|
||||
result_mode: 'hybrid',
|
||||
error_code: null,
|
||||
breakdown: [],
|
||||
created_at: null,
|
||||
completed_at: null,
|
||||
snapshot: {
|
||||
captured_at: '2026-01-01T12:00:00Z',
|
||||
lead_profile: { ems_lead_id: 'mock-lead' },
|
||||
conversations: [],
|
||||
},
|
||||
metrics: {
|
||||
profile_field_count: 2,
|
||||
conversation_count: 2,
|
||||
message_count: 3,
|
||||
contact_message_count: 1,
|
||||
customer_message_count: 1,
|
||||
conversation_turn_count: 2,
|
||||
contact_started_conversation_count: 1,
|
||||
contact_message_recency_days: 0.5,
|
||||
contact_message_recency_hours: 12,
|
||||
customer_initiated_return: true,
|
||||
},
|
||||
ai_confidence: 0.91,
|
||||
ai_reasoning: 'Thể hiện nhu cầu rõ ràng.',
|
||||
ai_evidence: [
|
||||
{
|
||||
signal: 'Ý định đăng ký',
|
||||
detail: 'Đã hỏi về hồ sơ đăng ký.',
|
||||
source: 'mock-message-001',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
function renderAnalysis(run: components['schemas']['RunDetail']): string {
|
||||
return render(RunAnalysis, { props: { run } }).body;
|
||||
}
|
||||
|
||||
describe('run analysis', () => {
|
||||
it('shows extracted evidence, AI result context, and backend metrics', () => {
|
||||
const html = renderAnalysis(successfulRun);
|
||||
|
||||
expect(html).toContain('Ý định đăng ký');
|
||||
expect(html).toContain('Đã hỏi về hồ sơ đăng ký.');
|
||||
expect(html).toContain('mock-message-001');
|
||||
expect(html).toContain('91%');
|
||||
expect(html).toContain('Thể hiện nhu cầu rõ ràng.');
|
||||
expect(html).toContain('AI và quy tắc');
|
||||
expect(html).toContain('Thông tin hồ sơ có giá trị');
|
||||
expect(html).toContain('Số lượt hội thoại');
|
||||
expect(html).toContain('Hội thoại bắt đầu từ khách hàng');
|
||||
expect(html).toContain('Số ngày từ tin nhắn khách hàng gần nhất');
|
||||
expect(html).toContain('Số giờ từ tin nhắn khách hàng gần nhất');
|
||||
expect(html).toContain('12 giờ');
|
||||
expect(html).toContain('Hội thoại tiếp theo bắt đầu từ khách hàng');
|
||||
expect(html).toContain('Có');
|
||||
});
|
||||
|
||||
it('shows missing recency and a false return metric when the snapshot has no contact messages', () => {
|
||||
const html = renderAnalysis({
|
||||
...successfulRun,
|
||||
snapshot: {
|
||||
...successfulRun.snapshot,
|
||||
conversations: [{ conversation_id: 'empty', messages: [] }],
|
||||
},
|
||||
metrics: {
|
||||
...successfulRun.metrics,
|
||||
contact_message_count: 0,
|
||||
customer_message_count: 0,
|
||||
contact_message_recency_days: null,
|
||||
contact_message_recency_hours: null,
|
||||
customer_initiated_return: false,
|
||||
},
|
||||
});
|
||||
|
||||
expect(html).toContain('Số giờ từ tin nhắn khách hàng gần nhất');
|
||||
expect(html).toContain('—');
|
||||
expect(html).toContain('Hội thoại tiếp theo bắt đầu từ khách hàng');
|
||||
expect(html).toContain('Không');
|
||||
});
|
||||
|
||||
it('explains fallback and error state when extracted evidence is cleared', () => {
|
||||
const html = renderAnalysis({
|
||||
...successfulRun,
|
||||
ai_score: null,
|
||||
ai_confidence: null,
|
||||
ai_reasoning: null,
|
||||
ai_evidence: null,
|
||||
result_mode: 'rules_only_fallback',
|
||||
degraded: true,
|
||||
error_code: 'aihub_timeout',
|
||||
});
|
||||
|
||||
expect(html).toContain('Dự phòng bằng điểm quy tắc');
|
||||
expect(html).toContain('aihub_timeout');
|
||||
expect(html).toContain('Không có bằng chứng được lưu vì lượt AI dự phòng hoặc thất bại.');
|
||||
expect(html).not.toContain('Ý định đăng ký');
|
||||
});
|
||||
});
|
||||
34
apps/web/src/routes/(admin)/platform/apps/+page.server.ts
Normal file
@ -0,0 +1,34 @@
|
||||
import { error, fail } from '@sveltejs/kit';
|
||||
import { backendJson } from '$lib/server/backend';
|
||||
import type { components } from '$lib/generated/api';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
|
||||
type AppSummary = components['schemas']['AppSummary'];
|
||||
type AppList = components['schemas']['AppList'];
|
||||
|
||||
export const load: PageServerLoad = async (event) => {
|
||||
const { response, data } = await backendJson<AppList>(event, '/v1/admin/apps');
|
||||
if (!response.ok || !data) error(502, 'Không tải được danh sách ứng dụng.');
|
||||
return { apps: data.apps };
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
revoke: async (event) => {
|
||||
const form = await event.request.formData();
|
||||
const app_id = String(form.get('app_id') ?? '');
|
||||
if (!app_id.trim() || app_id.length > 128)
|
||||
return fail(400, { message: 'App ID không hợp lệ.' });
|
||||
const path = `/v1/admin/apps/${encodeURIComponent(app_id)}` as const;
|
||||
const { response, data } = await backendJson<AppSummary>(event, path, { method: 'DELETE' });
|
||||
if (!response.ok || !data) {
|
||||
const message =
|
||||
response.status === 404
|
||||
? 'Không tìm thấy ứng dụng.'
|
||||
: response.status === 401 || response.status === 403
|
||||
? 'Phiên quản trị hết hạn hoặc CSRF không hợp lệ.'
|
||||
: 'Không thể thu hồi quyền truy cập. Vui lòng thử lại.';
|
||||
return fail(response.status, { message });
|
||||
}
|
||||
return { success: true, message: `Đã thu hồi quyền truy cập của ${data.app_id}.` };
|
||||
},
|
||||
};
|
||||
75
apps/web/src/routes/(admin)/platform/apps/+page.svelte
Normal file
@ -0,0 +1,75 @@
|
||||
<script lang="ts">
|
||||
import type { PageProps } from './$types';
|
||||
import { Badge } from '$lib/components/ui/badge/index.js';
|
||||
import { Button } from '$lib/components/ui/button/index.js';
|
||||
import * as Card from '$lib/components/ui/card/index.js';
|
||||
|
||||
let { data, form }: PageProps = $props();
|
||||
|
||||
function confirmRevoke(event: SubmitEvent, appId: string) {
|
||||
if (
|
||||
!window.confirm(
|
||||
`Thu hồi quyền truy cập của ${appId}? Ứng dụng này sẽ không gọi được API.`,
|
||||
)
|
||||
)
|
||||
event.preventDefault();
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head><title>Ứng dụng · AI Platform</title></svelte:head>
|
||||
<header class="mb-8">
|
||||
<p class="mb-2 text-xs font-semibold text-primary">Platform / Truy cập</p>
|
||||
<h1 class="text-3xl font-bold">Ứng dụng tích hợp</h1>
|
||||
<p class="mt-2 max-w-prose text-sm text-muted-foreground">
|
||||
Mỗi bản triển khai Omni dùng một app key chung cho các account. Vận hành cấp hoặc xoay vòng
|
||||
key qua CLI; Web chỉ hiển thị trạng thái và thu hồi quyền truy cập, không nhận raw key.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{#if form?.message}
|
||||
<p
|
||||
role={form.success ? 'status' : 'alert'}
|
||||
class={form.success ? 'mb-5 text-sm text-primary' : 'mb-5 text-sm text-destructive'}
|
||||
>
|
||||
{form.message}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<Card.Root>
|
||||
<Card.Header><h2 class="text-base font-semibold">Ứng dụng đã được cấp quyền</h2></Card.Header>
|
||||
<Card.Content>
|
||||
{#if data.apps.length === 0}
|
||||
<p class="text-sm text-muted-foreground">Chưa có ứng dụng nào được cấp quyền.</p>
|
||||
{:else}
|
||||
<ul class="divide-y divide-border">
|
||||
{#each data.apps as app (app.app_id)}
|
||||
<li class="grid gap-3 py-4 first:pt-0 last:pb-0">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<code class="min-w-0 flex-1 break-all text-sm">{app.app_id}</code>
|
||||
<Badge variant={app.active ? 'secondary' : 'outline'}
|
||||
>{app.active ? 'Đang hoạt động' : 'Đã thu hồi'}</Badge
|
||||
>
|
||||
</div>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
Tạo {new Date(app.created_at).toLocaleString('vi-VN')} · Cập nhật {new Date(
|
||||
app.updated_at,
|
||||
).toLocaleString('vi-VN')}
|
||||
</p>
|
||||
{#if app.active}
|
||||
<form
|
||||
method="POST"
|
||||
action="?/revoke"
|
||||
onsubmit={(event) => confirmRevoke(event, app.app_id)}
|
||||
>
|
||||
<input type="hidden" name="app_id" value={app.app_id} />
|
||||
<Button type="submit" variant="destructive" size="sm"
|
||||
>Thu hồi</Button
|
||||
>
|
||||
</form>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
51
apps/web/src/routes/(admin)/platform/apps/apps.test.ts
Normal file
@ -0,0 +1,51 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { backendJson } from '$lib/server/backend';
|
||||
import { actions } from './+page.server';
|
||||
|
||||
type AppEvent = Parameters<NonNullable<typeof actions.revoke>>[0];
|
||||
vi.mock('$lib/server/backend', () => ({ backendJson: vi.fn() }));
|
||||
const mock = vi.mocked(backendJson);
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
function eventFor(app_id: string): AppEvent {
|
||||
return {
|
||||
request: new Request('http://localhost/platform/apps', {
|
||||
method: 'POST',
|
||||
body: new URLSearchParams({ app_id }),
|
||||
}),
|
||||
} as AppEvent;
|
||||
}
|
||||
|
||||
describe('app credential administration', () => {
|
||||
it('only allows revocation from the browser', () => {
|
||||
expect(Object.keys(actions)).toEqual(['revoke']);
|
||||
});
|
||||
it('revokes through the server helper and encodes the app ID', async () => {
|
||||
mock.mockResolvedValueOnce({
|
||||
response: Response.json({}),
|
||||
data: {
|
||||
app_id: 'omni/local',
|
||||
active: false,
|
||||
created_at: '',
|
||||
updated_at: '',
|
||||
},
|
||||
});
|
||||
expect(await actions.revoke!(eventFor('omni/local'))).toMatchObject({ success: true });
|
||||
expect(mock).toHaveBeenCalledWith(expect.anything(), '/v1/admin/apps/omni%2Flocal', {
|
||||
method: 'DELETE',
|
||||
});
|
||||
});
|
||||
it('rejects blank IDs before making a backend request', async () => {
|
||||
expect(await actions.revoke!(eventFor(' '))).toMatchObject({ status: 400 });
|
||||
expect(mock).not.toHaveBeenCalled();
|
||||
});
|
||||
it('uses a safe failure message instead of backend error contents', async () => {
|
||||
mock.mockResolvedValueOnce({
|
||||
response: new Response('sensitive', { status: 403 }),
|
||||
data: null,
|
||||
});
|
||||
const result = await actions.revoke!(eventFor('omni-local'));
|
||||
expect(result).toMatchObject({ status: 403 });
|
||||
expect(JSON.stringify(result)).not.toContain('sensitive');
|
||||
});
|
||||
});
|
||||
@ -1,84 +0,0 @@
|
||||
import { error, fail } from '@sveltejs/kit';
|
||||
import { backendJson } from '$lib/server/backend';
|
||||
import type { components } from '$lib/generated/api';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
|
||||
type TenantKeySummary = components['schemas']['TenantKeySummary'];
|
||||
type TenantKeyList = components['schemas']['TenantKeyList'];
|
||||
type TenantKeySecret = components['schemas']['TenantKeySecret'];
|
||||
|
||||
export const load: PageServerLoad = async (event) => {
|
||||
const { response, data } = await backendJson<TenantKeyList>(event, '/v1/admin/tenant-keys');
|
||||
if (!response.ok || !data) error(502, 'Không tải được danh sách tenant key.');
|
||||
return { tenants: data.tenants };
|
||||
};
|
||||
|
||||
function mutationMessage(status: number, action: 'create' | 'rotate' | 'revoke'): string {
|
||||
if (status === 409 && action === 'create')
|
||||
return 'Tenant này đã có key. Hãy xoay vòng key hiện tại.';
|
||||
if (status === 404) return 'Không tìm thấy tenant key.';
|
||||
if (status === 422) return 'Tenant ID không hợp lệ.';
|
||||
if (status === 401 || status === 403) return 'Phiên quản trị hết hạn hoặc CSRF không hợp lệ.';
|
||||
return 'Không thể cập nhật tenant key. Vui lòng thử lại.';
|
||||
}
|
||||
|
||||
export const actions: Actions = {
|
||||
create: async (event) => {
|
||||
event.setHeaders({ 'cache-control': 'no-store' });
|
||||
const form = await event.request.formData();
|
||||
const tenant_id = String(form.get('tenant_id') ?? '').trim();
|
||||
if (!tenant_id || tenant_id.length > 128) {
|
||||
return fail(400, { message: 'Nhập Tenant ID từ 1 đến 128 ký tự.' });
|
||||
}
|
||||
const { response, data } = await backendJson<TenantKeySecret>(
|
||||
event,
|
||||
'/v1/admin/tenant-keys',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ tenant_id }),
|
||||
},
|
||||
);
|
||||
if (!response.ok || !data) {
|
||||
return fail(response.status, { message: mutationMessage(response.status, 'create') });
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
message: 'Đã tạo tenant key.',
|
||||
tenant_id: data.tenant_id,
|
||||
tenant_key: data.tenant_key,
|
||||
};
|
||||
},
|
||||
rotate: async (event) => {
|
||||
event.setHeaders({ 'cache-control': 'no-store' });
|
||||
const form = await event.request.formData();
|
||||
const tenant_id = String(form.get('tenant_id') ?? '');
|
||||
if (!tenant_id) return fail(400, { message: 'Thiếu Tenant ID.' });
|
||||
const path = `/v1/admin/tenant-keys/${encodeURIComponent(tenant_id)}/rotate` as const;
|
||||
const { response, data } = await backendJson<TenantKeySecret>(event, path, {
|
||||
method: 'POST',
|
||||
});
|
||||
if (!response.ok || !data) {
|
||||
return fail(response.status, { message: mutationMessage(response.status, 'rotate') });
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
message: `Đã xoay vòng key cho ${data.tenant_id}.`,
|
||||
tenant_id: data.tenant_id,
|
||||
tenant_key: data.tenant_key,
|
||||
};
|
||||
},
|
||||
revoke: async (event) => {
|
||||
const form = await event.request.formData();
|
||||
const tenant_id = String(form.get('tenant_id') ?? '');
|
||||
if (!tenant_id) return fail(400, { message: 'Thiếu Tenant ID.' });
|
||||
const path = `/v1/admin/tenant-keys/${encodeURIComponent(tenant_id)}` as const;
|
||||
const { response, data } = await backendJson<TenantKeySummary>(event, path, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
if (!response.ok || !data) {
|
||||
return fail(response.status, { message: mutationMessage(response.status, 'revoke') });
|
||||
}
|
||||
return { success: true, message: `Đã thu hồi key của ${data.tenant_id}.` };
|
||||
},
|
||||
};
|
||||
@ -1,159 +0,0 @@
|
||||
<script lang="ts">
|
||||
import type { PageProps } from './$types';
|
||||
import { Badge } from '$lib/components/ui/badge/index.js';
|
||||
import { Button } from '$lib/components/ui/button/index.js';
|
||||
import * as Card from '$lib/components/ui/card/index.js';
|
||||
|
||||
let { data, form }: PageProps = $props();
|
||||
let copied = $state(false);
|
||||
let copyError = $state(false);
|
||||
|
||||
async function copyKey(value: string) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(value);
|
||||
copied = true;
|
||||
copyError = false;
|
||||
} catch {
|
||||
copied = false;
|
||||
copyError = true;
|
||||
}
|
||||
}
|
||||
|
||||
function confirmAction(event: SubmitEvent, action: 'rotate' | 'revoke', tenant: string) {
|
||||
const message =
|
||||
action === 'rotate'
|
||||
? `Thay mã cho ${tenant}? Mã cũ sẽ hết hiệu lực ngay.`
|
||||
: `Thu hồi mã của ${tenant}? Tổ chức này sẽ không gọi được API.`;
|
||||
if (!window.confirm(message)) event.preventDefault();
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head><title>Mã truy cập · AI Platform</title></svelte:head>
|
||||
<header class="mb-8">
|
||||
<p class="mb-2 text-xs font-semibold text-primary">Platform / Truy cập</p>
|
||||
<h1 class="text-3xl font-bold">Mã truy cập của tổ chức</h1>
|
||||
<p class="mt-2 max-w-prose text-sm text-muted-foreground">
|
||||
Tạo mã cho tổ chức gửi yêu cầu chấm điểm.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{#if form?.message}
|
||||
<p
|
||||
role={form.success ? 'status' : 'alert'}
|
||||
class={form.success ? 'mb-5 text-sm text-primary' : 'mb-5 text-sm text-destructive'}
|
||||
>
|
||||
{form.message}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
{#if form?.tenant_key}
|
||||
<section
|
||||
aria-labelledby="new-key-title"
|
||||
class="mb-6 rounded-lg border border-destructive/40 bg-card p-5"
|
||||
>
|
||||
<h2 id="new-key-title" class="text-base font-semibold">Sao chép mã vừa tạo</h2>
|
||||
<p class="mt-2 text-sm text-destructive">
|
||||
Sao chép mã này. Nếu làm mất, hãy dùng “Thay mã” ở danh sách bên dưới.
|
||||
</p>
|
||||
<div class="mt-4 flex flex-wrap items-center gap-3">
|
||||
<code class="min-w-0 flex-1 break-all rounded-md bg-muted px-3 py-2 text-sm"
|
||||
>{form.tenant_key}</code
|
||||
>
|
||||
<Button type="button" variant="outline" onclick={() => copyKey(form.tenant_key)}>
|
||||
Sao chép mã
|
||||
</Button>
|
||||
</div>
|
||||
{#if copied}<p class="mt-2 text-sm text-primary" role="status">Đã sao chép mã.</p>{/if}
|
||||
{#if copyError}<p class="mt-2 text-sm text-destructive" role="alert">
|
||||
Chọn và sao chép mã thủ công.
|
||||
</p>{/if}
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<div class="grid gap-6 lg:grid-cols-[minmax(18rem,0.75fr)_minmax(0,1.25fr)]">
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<h2 class="text-base font-semibold">Tạo mã truy cập</h2>
|
||||
<Card.Description>Dành cho tổ chức chưa có mã.</Card.Description>
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
<form method="POST" action="?/create" class="grid gap-3">
|
||||
<label for="tenant-id" class="text-sm font-medium">Mã tổ chức</label>
|
||||
<input
|
||||
id="tenant-id"
|
||||
name="tenant_id"
|
||||
required
|
||||
maxlength="128"
|
||||
autocomplete="off"
|
||||
class="h-9 rounded-md border border-input bg-background px-3 text-sm"
|
||||
/>
|
||||
<Button type="submit" class="mt-1 w-fit">Tạo mã</Button>
|
||||
</form>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<h2 class="text-base font-semibold">Tổ chức đã có mã</h2>
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
{#if data.tenants.length === 0}
|
||||
<p class="text-sm text-muted-foreground">Chưa có tổ chức nào được cấp mã.</p>
|
||||
{:else}
|
||||
<ul class="divide-y divide-border">
|
||||
{#each data.tenants as tenant (tenant.tenant_id)}
|
||||
<li class="grid gap-3 py-4 first:pt-0 last:pb-0">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<code class="min-w-0 flex-1 break-all text-sm"
|
||||
>{tenant.tenant_id}</code
|
||||
>
|
||||
<Badge variant={tenant.active ? 'secondary' : 'outline'}>
|
||||
{tenant.active ? 'Đang hoạt động' : 'Đã thu hồi'}
|
||||
</Badge>
|
||||
</div>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
Tạo {new Date(tenant.created_at).toLocaleString('vi-VN')} · Cập nhật {new Date(
|
||||
tenant.updated_at,
|
||||
).toLocaleString('vi-VN')}
|
||||
</p>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<form
|
||||
method="POST"
|
||||
action="?/rotate"
|
||||
onsubmit={(event) =>
|
||||
confirmAction(event, 'rotate', tenant.tenant_id)}
|
||||
>
|
||||
<input
|
||||
type="hidden"
|
||||
name="tenant_id"
|
||||
value={tenant.tenant_id}
|
||||
/>
|
||||
<Button type="submit" variant="outline" size="sm">
|
||||
Thay mã
|
||||
</Button>
|
||||
</form>
|
||||
{#if tenant.active}
|
||||
<form
|
||||
method="POST"
|
||||
action="?/revoke"
|
||||
onsubmit={(event) =>
|
||||
confirmAction(event, 'revoke', tenant.tenant_id)}
|
||||
>
|
||||
<input
|
||||
type="hidden"
|
||||
name="tenant_id"
|
||||
value={tenant.tenant_id}
|
||||
/>
|
||||
<Button type="submit" variant="destructive" size="sm">
|
||||
Thu hồi
|
||||
</Button>
|
||||
</form>
|
||||
{/if}
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
</div>
|
||||
@ -1,108 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { backend, backendJson } from '$lib/server/backend';
|
||||
import { actions } from './+page.server';
|
||||
|
||||
type TenantKeyRequestEvent = Parameters<NonNullable<typeof actions.create>>[0];
|
||||
|
||||
vi.mock('$lib/server/backend', () => ({
|
||||
backend: vi.fn(),
|
||||
backendJson: vi.fn(),
|
||||
}));
|
||||
|
||||
function eventFor(values: Record<string, string>): {
|
||||
event: TenantKeyRequestEvent;
|
||||
setHeaders: ReturnType<typeof vi.fn>;
|
||||
} {
|
||||
const setHeaders = vi.fn();
|
||||
return {
|
||||
event: {
|
||||
request: new Request('http://localhost/platform/tenant-keys', {
|
||||
method: 'POST',
|
||||
body: new URLSearchParams(values),
|
||||
}),
|
||||
setHeaders,
|
||||
} as unknown as TenantKeyRequestEvent,
|
||||
setHeaders,
|
||||
};
|
||||
}
|
||||
|
||||
const backendJsonMock = vi.mocked(backendJson);
|
||||
const backendMock = vi.mocked(backend);
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('tenant key server actions', () => {
|
||||
it('returns a newly-created key only in the action result', async () => {
|
||||
backendJsonMock.mockResolvedValueOnce({
|
||||
response: { ok: true, status: 201 } as Response,
|
||||
data: { tenant_id: 'tenant-a', tenant_key: 'tnt-once' },
|
||||
});
|
||||
const action = actions.create;
|
||||
if (!action) throw new Error('create action missing');
|
||||
const { event, setHeaders } = eventFor({ tenant_id: 'tenant-a' });
|
||||
const result = await action(event);
|
||||
expect(result).toMatchObject({ tenant_id: 'tenant-a', tenant_key: 'tnt-once' });
|
||||
expect(setHeaders).toHaveBeenCalledWith({ 'cache-control': 'no-store' });
|
||||
expect(backendJsonMock).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'/v1/admin/tenant-keys',
|
||||
expect.objectContaining({ method: 'POST' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('rotates a key through the Admin API and returns the new value once', async () => {
|
||||
backendJsonMock.mockResolvedValueOnce({
|
||||
response: { ok: true, status: 200 } as Response,
|
||||
data: { tenant_id: 'tenant-a', tenant_key: 'tnt-rotated' },
|
||||
});
|
||||
const action = actions.rotate;
|
||||
if (!action) throw new Error('rotate action missing');
|
||||
const { event, setHeaders } = eventFor({ tenant_id: 'tenant-a' });
|
||||
const result = await action(event);
|
||||
expect(result).toMatchObject({ tenant_id: 'tenant-a', tenant_key: 'tnt-rotated' });
|
||||
expect(setHeaders).toHaveBeenCalledWith({ 'cache-control': 'no-store' });
|
||||
expect(backendJsonMock).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'/v1/admin/tenant-keys/tenant-a/rotate',
|
||||
{ method: 'POST' },
|
||||
);
|
||||
});
|
||||
|
||||
it('reports an existing tenant conflict without returning a key', async () => {
|
||||
backendJsonMock.mockResolvedValueOnce({
|
||||
response: { ok: false, status: 409 } as Response,
|
||||
data: null,
|
||||
});
|
||||
const action = actions.create;
|
||||
if (!action) throw new Error('create action missing');
|
||||
const result = await action(eventFor({ tenant_id: 'tenant-a' }).event);
|
||||
expect(result).toMatchObject({
|
||||
status: 409,
|
||||
data: { message: expect.stringContaining('đã có key') },
|
||||
});
|
||||
});
|
||||
|
||||
it('sends revoke only through the backend server helper', async () => {
|
||||
backendJsonMock.mockResolvedValueOnce({
|
||||
response: { ok: true, status: 200 } as Response,
|
||||
data: {
|
||||
tenant_id: 'tenant/a',
|
||||
active: false,
|
||||
created_at: '2026-01-01T00:00:00Z',
|
||||
updated_at: '2026-01-02T00:00:00Z',
|
||||
},
|
||||
});
|
||||
const action = actions.revoke;
|
||||
if (!action) throw new Error('revoke action missing');
|
||||
const result = await action(eventFor({ tenant_id: 'tenant/a' }).event);
|
||||
expect(result).toMatchObject({ success: true });
|
||||
expect(backendJsonMock).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'/v1/admin/tenant-keys/tenant%2Fa',
|
||||
{ method: 'DELETE' },
|
||||
);
|
||||
expect(backendMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@ -37,8 +37,6 @@ services:
|
||||
environment:
|
||||
AI_PLATFORM_DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-ai_platform}:${POSTGRES_PASSWORD:-local-change-this-password}@postgres:5432/${POSTGRES_DB:-ai_platform}
|
||||
AI_PLATFORM_ENVIRONMENT: ${AI_PLATFORM_ENVIRONMENT:-development}
|
||||
AI_PLATFORM_APP_WEBHOOKS: '${AI_PLATFORM_APP_WEBHOOKS:-{}}'
|
||||
AI_PLATFORM_AIHUB_BASE_URL: ${AI_PLATFORM_AIHUB_BASE_URL:-}
|
||||
AI_PLATFORM_OPENAI_BASE_URL: ${AI_PLATFORM_OPENAI_BASE_URL:-}
|
||||
AI_PLATFORM_OPENAI_API_KEY: ${AI_PLATFORM_OPENAI_API_KEY:-}
|
||||
ports:
|
||||
|
||||
25
contracts/lead-scoring/README.md
Normal file
@ -0,0 +1,25 @@
|
||||
# Lead Scoring: client contract
|
||||
|
||||
`openapi.json` is generated from the backend routes (`make contract`). `fixtures/run-request.json` and `fixtures/score-response.json` are generated examples; `scripts/export_contract_fixtures.py --check` rejects drift. The service knows no specific client: client-owned data travels in `metadata`.
|
||||
|
||||
## Request
|
||||
|
||||
`POST /v1/lead-scoring/score`, synchronous.
|
||||
|
||||
| Header | Meaning |
|
||||
|---|---|
|
||||
| `Authorization: Bearer <app_key>` | App credential (one per installation). 401 if invalid or revoked, 403 without the `lead_scoring` permission. |
|
||||
| `Idempotency-Key` | Required UUID chosen by the client. Same key with the same body replays the stored result; same key with a different body returns 422. |
|
||||
| `X-AI-Key` | Optional AI provider key for AIHub profiles. Held in memory for the call only, never stored or logged. Max 4096 chars, no CR/LF (else 422). |
|
||||
|
||||
Body: `snapshot` (lead profile with `external_id`, conversations, custom fields), `scoring_config` (`rule_weight`, `ai_weight`, `rules[]`) and optional `metadata` (any JSON object up to 10KB, stored and returned unchanged). Rule `path` may be `lead_profile.<name|phone|email|status|external_id>`, `custom_fields.<key>` or `metrics.<key>`.
|
||||
|
||||
## Response (200)
|
||||
|
||||
`run_id`, `final_score`, `rule_score`, `ai_score`, `result_mode`, `error_code`, `breakdown[]`, `ai {summary, suggested_action, signals[]}`, `metrics`, `metadata`.
|
||||
|
||||
`result_mode` is `rules_only`, `hybrid` or `rules_only_fallback`. In the fallback case `error_code` says why (`ai_timeout`, `ai_key_missing`, `ai_profile_missing`, or a provider error code) and `final_score` equals `rule_score`. The AI model, prompt and timeout belong to the active AI profile, not the request.
|
||||
|
||||
## Boundaries
|
||||
|
||||
No webhook: the caller waits for the response (the AI deadline is the profile `timeout_seconds`, default 50). Provider tests do not prove client persistence or writeback; verify both sides with a mock AI before enabling a real integration.
|
||||
@ -1,21 +1,17 @@
|
||||
{
|
||||
"request_id": "f81c7c4e-a92a-4b19-8d7a-3a5c08eb4e6e",
|
||||
"account_id": "3",
|
||||
"subject_ref": "ems-lead-123",
|
||||
"snapshot": {
|
||||
"captured_at": "2025-09-25T10:00:00Z",
|
||||
"lead_profile": {
|
||||
"ems_lead_id": "ems-lead-123",
|
||||
"custom_fields": {
|
||||
"inquiry_type": "course_dates",
|
||||
"region": "north"
|
||||
}
|
||||
},
|
||||
"external_id": "lead-123"
|
||||
},
|
||||
"conversations": [
|
||||
{
|
||||
"conversation_id": "conversation-456",
|
||||
"channel": "webchat",
|
||||
"assigned_agent_id": "agent-12",
|
||||
"messages": [
|
||||
{
|
||||
"message_id": "message-789",
|
||||
@ -94,7 +90,6 @@
|
||||
]
|
||||
},
|
||||
"scoring_config": {
|
||||
"version": "omni-config-17",
|
||||
"rules": [
|
||||
{
|
||||
"id": "asked-course-dates",
|
||||
@ -115,5 +110,8 @@
|
||||
"rule_weight": 1.0,
|
||||
"ai_weight": 0.0
|
||||
},
|
||||
"ai_profile": "light"
|
||||
"metadata": {
|
||||
"account_id": "3",
|
||||
"config_version": "17"
|
||||
}
|
||||
}
|
||||
47
contracts/lead-scoring/fixtures/score-response.json
Normal file
@ -0,0 +1,47 @@
|
||||
{
|
||||
"run_id": "22222222-2222-4222-8222-222222222222",
|
||||
"final_score": 65,
|
||||
"rule_score": 50,
|
||||
"ai_score": 80,
|
||||
"result_mode": "hybrid",
|
||||
"error_code": null,
|
||||
"breakdown": [
|
||||
{
|
||||
"rule_id": "asked-course-dates",
|
||||
"matched": true,
|
||||
"points": 35
|
||||
},
|
||||
{
|
||||
"rule_id": "north-region",
|
||||
"matched": true,
|
||||
"points": 15
|
||||
}
|
||||
],
|
||||
"ai": {
|
||||
"summary": "Khách hỏi lịch khai giảng và mục tiêu điểm, phản hồi nhanh.",
|
||||
"suggested_action": "Gửi lịch khai giảng và hẹn gọi tư vấn trong ngày.",
|
||||
"signals": [
|
||||
{
|
||||
"polarity": "positive",
|
||||
"signal": "Hỏi lịch khai giảng",
|
||||
"detail": "Khách chủ động hỏi tháng tới có lớp khai giảng không.",
|
||||
"source": "message-789"
|
||||
}
|
||||
]
|
||||
},
|
||||
"metrics": {
|
||||
"profile_field_count": 0,
|
||||
"conversation_count": 1,
|
||||
"message_count": 12,
|
||||
"contact_message_count": 6,
|
||||
"conversation_turn_count": 12,
|
||||
"contact_started_conversation_count": 1,
|
||||
"contact_message_recency_days": 1.01,
|
||||
"contact_message_recency_hours": 24.33,
|
||||
"customer_initiated_return": false
|
||||
},
|
||||
"metadata": {
|
||||
"account_id": "3",
|
||||
"config_version": "17"
|
||||
}
|
||||
}
|
||||
1
contracts/lead-scoring/openapi.json
Normal file
@ -1,35 +0,0 @@
|
||||
# Lead Scoring: Omni app-auth integration
|
||||
|
||||
Provider baseline: `omni-ai-platform-app-auth`, revision 2, 2026-09-30. `openapi.json` is generated from backend routes; `fixtures/` contains examples, not a second validation schema. The existing `v2` directory is retained without legacy compatibility. Cross-service acceptance remains pending until Omni verifies the same artifacts.
|
||||
|
||||
## Authentication and identity
|
||||
|
||||
POST/GET use `Authorization: Bearer <app_api_key>` for one Omni installation/environment. AI Platform resolves a stable `app_id`, requires active state and `lead_scoring` permission. Missing/invalid/inactive/revoked credentials return 401; missing permission returns 403 on both routes. Omni owns user/account/EMS authorization; AI Platform does not register accounts.
|
||||
|
||||
POST `/v1/lead-scoring/runs` has exactly `request_id`, `account_id`, `subject_ref`, `snapshot`, `scoring_config`, `ai_profile`. Account ID is a nonblank string of 1–255 characters, preserved unchanged. Subject must match the snapshot EMS lead ID. `Idempotency-Key` represents the body request UUID. Self-supplied app/tenant/callback fields return 422.
|
||||
|
||||
Each `scoring_config.rules[]` may include `description`: an optional string of at most 99 Unicode characters. Omitted or null descriptions preserve legacy normalized requests and replay fingerprints. Empty/whitespace descriptions are accepted but omitted from AI context. Nonblank descriptions are stored with the request, included in its fingerprint and sent as supplemental JSON context in the AI scoring pass, not as evidence or instructions. Rule matching, points and weights are unchanged; rule IDs, conditions, points and weights are not sent to AI. The request fixture includes a described rule and a legacy rule without this field.
|
||||
|
||||
Deduplication is `(app_id, account_id, request_id)`: 202 and Location for new admission, 200 same canonical body/run, 409 changed body. Summary/detail carry app/account/request/run identity. GET another app's run returns 404. Key rotation preserves ownership/replay. Missing callback configuration blocks new admission with 503 after replay lookup; DB or scheduling unavailability also returns 503.
|
||||
|
||||
Generate OpenAPI and serializer-derived summary/detail/signature examples with `make contract`; `uv run --no-sync python scripts/export_contract_fixtures.py --check` rejects fixture drift without modifying artifacts. Validate examples in `tests/test_run_contract.py` and `tests/test_notifications.py`.
|
||||
|
||||
## Credentials and configuration
|
||||
|
||||
Operator CLI issues, rotates or revokes keys: `uv run --no-sync ai-platform create-app <app_id>`, `rotate-app-key <app_id>`, `revoke-app-key <app_id>`. Raw key is output only for issuance/rotation and must be transferred securely to Omni backend. Admin Web lists status and revokes access without receiving raw keys. Old tenant provisioning endpoints and provisioning-secret settings are removed.
|
||||
|
||||
Omni backend configuration: `LEAD_SCORING_API_URL`, `AI_PLATFORM_APP_ID` (expected identity), `AI_PLATFORM_APP_API_KEY`. Provider configuration: `AI_PLATFORM_APP_WEBHOOKS`, JSON mapping app ID to `{"url":"https://omni.example.invalid/api/v1/internal/lead-scoring/notifications","signing_secret":"<server-secret-at-least-32-characters>"}`. Each app has its own URL/secret. Only development loopback HTTP is allowed; other environments require HTTPS. URL/secret/key never belong in browser-visible configuration. `X-Tenant-VKey` retains its existing name and RAM-only AIHub role.
|
||||
|
||||
## Terminal notification
|
||||
|
||||
Webhook contains exactly `event_id`, `app_id`, `account_id`, `request_id`, `run_id`, `status` (`completed`/`failed`), `occurred_at` (UTC ISO 8601). It contains no scores, snapshot or evidence. Event ID/body remain stable across retry; `occurred_at` is terminal event time, not send time.
|
||||
|
||||
`X-Webhook-Timestamp` is UTC ISO 8601 for this delivery attempt. `X-Webhook-Signature` is `sha256=` plus lowercase hex HMAC-SHA256 over UTF-8 bytes `timestamp + "." + raw_request_body`. Verify raw bytes before parsing, compare constant-time, reject timestamps outside ±5 minutes and body above 8192 bytes. `fixtures/webhook-delivery.json` uses the test-only secret `test-only-webhook-secret-32-characters-long` to fix exact bytes/signature.
|
||||
|
||||
Omni commits the immutable source request before POST and durably accepts callback before ACK 202; an exact duplicate returns 200, conflicting identity/body returns 409. Dedupe `(app_id,event_id)`, bind run atomically even when callback precedes admission response, GET with app key and compare app/account/request/run/subject to the stored source before EMS writeback. Account authorization, enabled/EMS rechecks and latest-run ordering belong to Omni.
|
||||
|
||||
Provider retries network/non-2xx up to five attempts, then keeps a dead event. `uv run --no-sync ai-platform redeliver-notification <event_id>` retries the same event without rescoring. A crash may repeat a previously ACKed event. Webhook transport rejects redirects. GET is the result source of truth.
|
||||
|
||||
## Migration and verification boundary
|
||||
|
||||
The unreleased schema is consolidated in `0001_initial`, creating the current app-auth schema directly. Databases with the old local revisions need separate reconciliation or an explicitly approved rebuild; the baseline does not automatically stamp, reset or map legacy data. Provider tests do not prove Omni request persistence, callback receiver or EMS writeback. Run the shared validation checklist on both applications with mock AI/EMS before enabling real integration.
|
||||
@ -1 +0,0 @@
|
||||
{"version":"2.0.0","baseline":"omni-ai-platform-app-auth revision 2 (2026-09-30)","status":"provider implementation; OpenAPI generated; cross-service acceptance pending","fixtures":["fixtures/run-request.json","fixtures/run-summary.json","fixtures/run-detail.json","fixtures/webhook-delivery.json"],"idempotency":{"identity":"(authenticated app_id, account_id, request_id)","fingerprint":"SHA-256 of sorted-key compact JSON body normalized by HTTP models","new":"202 with Location","same_body":"200 existing run without scheduling another task","changed_body":"409 Conflict"},"auth":{"app":"Authorization: Bearer app_api_key; active app with lead_scoring permission; issuance/rotation via operator CLI only","aihub":"X-Tenant-VKey header on POST only; in-memory task input; AIHub sandbox protocol not yet verified","admin_login":{"method":"POST","path":"/v1/admin/login","body":{"email":"admin@example.test","password":"string"},"success":{"email":"admin@example.test","csrf_token":"opaque-token","expires_in":28800},"cookie":"ai_platform_session; HttpOnly; SameSite=Lax; Path=/; Secure in production"}},"scoring_request":{"required":["request_id","account_id","subject_ref","snapshot","scoring_config","ai_profile"],"scoring_config":{"required":["version","rules","rule_weight","ai_weight"],"weights_sum_to":1}},"run_status":["pending","running","completed","failed"]}
|
||||
@ -1,143 +0,0 @@
|
||||
{
|
||||
"run_id": "22222222-2222-4222-8222-222222222222",
|
||||
"request_id": "f81c7c4e-a92a-4b19-8d7a-3a5c08eb4e6e",
|
||||
"app_id": "omni-local",
|
||||
"account_id": "3",
|
||||
"status": "completed",
|
||||
"profile_version": "profile-example",
|
||||
"rule_score": 50,
|
||||
"ai_score": null,
|
||||
"final_score": 50,
|
||||
"degraded": false,
|
||||
"result_mode": "rules_only",
|
||||
"error_code": null,
|
||||
"breakdown": [
|
||||
{
|
||||
"rule_id": "asked-course-dates",
|
||||
"matched": true,
|
||||
"points": 35
|
||||
},
|
||||
{
|
||||
"rule_id": "north-region",
|
||||
"matched": true,
|
||||
"points": 15
|
||||
}
|
||||
],
|
||||
"created_at": "2026-09-28T00:00:00Z",
|
||||
"completed_at": "2026-09-28T00:00:00Z",
|
||||
"snapshot": {
|
||||
"version": null,
|
||||
"scope": null,
|
||||
"captured_at": "2025-09-25T10:00:00Z",
|
||||
"lead_profile": {
|
||||
"ems_lead_id": "ems-lead-123",
|
||||
"name": null,
|
||||
"phone": null,
|
||||
"email": null,
|
||||
"status": null,
|
||||
"custom_fields": {
|
||||
"inquiry_type": "course_dates",
|
||||
"region": "north"
|
||||
}
|
||||
},
|
||||
"conversations": [
|
||||
{
|
||||
"conversation_id": "conversation-456",
|
||||
"channel": "webchat",
|
||||
"assigned_agent_id": "agent-12",
|
||||
"messages": [
|
||||
{
|
||||
"message_id": "message-789",
|
||||
"sender_type": "contact",
|
||||
"content": "Chào trung tâm, mình muốn tìm khóa IELTS buổi tối ở Hà Nội. Tháng tới có lớp khai giảng không?",
|
||||
"sent_at": "2025-09-24T09:30:00Z"
|
||||
},
|
||||
{
|
||||
"message_id": "message-790",
|
||||
"sender_type": "agent",
|
||||
"content": "Chào bạn, lịch mẫu có lớp khai giảng ngày 6/10. Bạn đang ở trình độ nào và muốn đạt mục tiêu bao nhiêu?",
|
||||
"sent_at": "2025-09-24T09:31:00Z"
|
||||
},
|
||||
{
|
||||
"message_id": "message-791",
|
||||
"sender_type": "contact",
|
||||
"content": "Mình khoảng 4.5, muốn đạt 6.0 để chuẩn bị hồ sơ du học trong sáu tháng tới.",
|
||||
"sent_at": "2025-09-24T09:32:00Z"
|
||||
},
|
||||
{
|
||||
"message_id": "message-792",
|
||||
"sender_type": "agent",
|
||||
"content": "Bạn có thể làm bài kiểm tra đầu vào để xác định lớp phù hợp. Lớp mẫu học thứ Hai, Tư, Sáu từ 19 giờ đến 21 giờ.",
|
||||
"sent_at": "2025-09-24T09:33:00Z"
|
||||
},
|
||||
{
|
||||
"message_id": "message-793",
|
||||
"sender_type": "contact",
|
||||
"content": "Khung giờ đó phù hợp với công việc của mình. Khóa học kéo dài bao lâu và học phí thế nào?",
|
||||
"sent_at": "2025-09-24T09:34:00Z"
|
||||
},
|
||||
{
|
||||
"message_id": "message-794",
|
||||
"sender_type": "agent",
|
||||
"content": "Khóa mẫu kéo dài 12 tuần, học phí minh họa là 6 triệu đồng, đã gồm tài liệu. Đây là thông tin giả lập để kiểm thử.",
|
||||
"sent_at": "2025-09-24T09:35:00Z"
|
||||
},
|
||||
{
|
||||
"message_id": "message-795",
|
||||
"sender_type": "contact",
|
||||
"content": "Ngân sách của mình khoảng 6 đến 7 triệu. Có thể chia học phí thành hai đợt không?",
|
||||
"sent_at": "2025-09-24T09:36:00Z"
|
||||
},
|
||||
{
|
||||
"message_id": "message-796",
|
||||
"sender_type": "agent",
|
||||
"content": "Trong tình huống mẫu này, bạn có thể đóng hai đợt. Bạn muốn kiểm tra đầu vào vào tối thứ Năm hay sáng thứ Bảy?",
|
||||
"sent_at": "2025-09-24T09:37:00Z"
|
||||
},
|
||||
{
|
||||
"message_id": "message-797",
|
||||
"sender_type": "contact",
|
||||
"content": "Mình chọn sáng thứ Bảy. Sau khi kiểm tra mình muốn đăng ký lớp khai giảng ngày 6/10.",
|
||||
"sent_at": "2025-09-24T09:38:00Z"
|
||||
},
|
||||
{
|
||||
"message_id": "message-798",
|
||||
"sender_type": "agent",
|
||||
"content": "Mình ghi nhận lịch kiểm tra mẫu lúc 9 giờ thứ Bảy tại cơ sở Hà Nội. Bạn có cần thông tin gì thêm trước khi đăng ký không?",
|
||||
"sent_at": "2025-09-24T09:39:00Z"
|
||||
},
|
||||
{
|
||||
"message_id": "message-799",
|
||||
"sender_type": "contact",
|
||||
"content": "Bạn gửi giúp mình hướng dẫn kiểm tra và thủ tục đăng ký nhé. Nếu kết quả đầu vào phù hợp, mình sẽ đăng ký trong tuần này.",
|
||||
"sent_at": "2025-09-24T09:40:00Z"
|
||||
},
|
||||
{
|
||||
"message_id": "message-800",
|
||||
"sender_type": "agent",
|
||||
"content": "Mình sẽ gửi hướng dẫn theo tình huống mẫu. Cảm ơn bạn, hẹn gặp bạn vào buổi kiểm tra đầu vào.",
|
||||
"sent_at": "2025-09-24T09:41:00Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"identity_context": null,
|
||||
"trigger_metadata": null,
|
||||
"truncation": null
|
||||
},
|
||||
"metrics": {
|
||||
"profile_field_count": 0,
|
||||
"conversation_count": 1,
|
||||
"message_count": 12,
|
||||
"contact_message_count": 6,
|
||||
"customer_message_count": 6,
|
||||
"conversation_turn_count": 12,
|
||||
"contact_started_conversation_count": 1,
|
||||
"contact_message_recency_days": 1.01,
|
||||
"contact_message_recency_hours": 24.33,
|
||||
"customer_initiated_return": false
|
||||
},
|
||||
"ai_confidence": null,
|
||||
"ai_reasoning": null,
|
||||
"ai_evidence": null
|
||||
}
|
||||
@ -1,28 +0,0 @@
|
||||
{
|
||||
"run_id": "22222222-2222-4222-8222-222222222222",
|
||||
"request_id": "f81c7c4e-a92a-4b19-8d7a-3a5c08eb4e6e",
|
||||
"app_id": "omni-local",
|
||||
"account_id": "3",
|
||||
"status": "completed",
|
||||
"profile_version": "profile-example",
|
||||
"rule_score": 50,
|
||||
"ai_score": null,
|
||||
"final_score": 50,
|
||||
"degraded": false,
|
||||
"result_mode": "rules_only",
|
||||
"error_code": null,
|
||||
"breakdown": [
|
||||
{
|
||||
"rule_id": "asked-course-dates",
|
||||
"matched": true,
|
||||
"points": 35
|
||||
},
|
||||
{
|
||||
"rule_id": "north-region",
|
||||
"matched": true,
|
||||
"points": 15
|
||||
}
|
||||
],
|
||||
"created_at": "2026-09-28T00:00:00Z",
|
||||
"completed_at": "2026-09-28T00:00:00Z"
|
||||
}
|
||||
@ -1,7 +0,0 @@
|
||||
{
|
||||
"method": "POST",
|
||||
"content_type": "application/json",
|
||||
"timestamp": "2026-09-28T00:00:00+00:00",
|
||||
"body": "{\"event_id\":\"11111111-1111-4111-8111-111111111111\",\"run_id\":\"22222222-2222-4222-8222-222222222222\",\"app_id\":\"omni-local\",\"account_id\":\"3\",\"request_id\":\"f81c7c4e-a92a-4b19-8d7a-3a5c08eb4e6e\",\"status\":\"completed\",\"occurred_at\":\"2026-09-28T00:00:00+00:00\"}",
|
||||
"signature": "sha256=d0c144010a4537020d1563d06f5de333cb3afa3d1abe3aa422086a411d81582b"
|
||||
}
|
||||
@ -1,67 +0,0 @@
|
||||
---
|
||||
title: App auth provider handoff
|
||||
description: Bằng chứng kiểm thử AI Platform theo app-auth revision 2 và các bước còn cần nghiệm thu với Omni.
|
||||
status: draft
|
||||
---
|
||||
|
||||
# Bàn giao app auth phía AI Platform
|
||||
|
||||
Phần provider đã triển khai theo baseline `omni-ai-platform-app-auth`, revision 2, ngày 2026-09-30. Spec trong repo là `docs/specs/ai-platform-lead-scoring.md`; OpenAPI và fixtures tại `contracts/lead-scoring/v2/` là artifact provider-generated. Trạng thái `review_ready` của spec dành cho nghiệm thu liên dịch vụ, không phủ nhận quyền triển khai đã được phê duyệt.
|
||||
|
||||
## Phạm vi hoàn tất
|
||||
|
||||
- POST/GET dùng Bearer app key, suy ra app ID ổn định; kiểm tra active và quyền `lead_scoring`. Thiếu/sai/revoked/inactive trả 401, thiếu quyền trả 403; GET run của app khác trả 404.
|
||||
- Request có account ID không rỗng, UUID chuẩn và subject khớp snapshot lead. Run/replay unique theo `(app_id, account_id, request_id)`; 202 mới, 200 replay, 409 body khác. PostgreSQL test kiểm tra POST đồng thời chỉ schedule một run.
|
||||
- Summary/detail/outbox/webhook dùng app/account/request/run identity. Rotate key giữ ownership/replay và permission. Tenant auth/provisioning runtime, endpoints, UI và env cũ đã thay trực tiếp. Theo quyết định gộp schema chưa phát hành của người dùng, source hiện chỉ còn baseline `0001_initial`.
|
||||
- Mapping backend `AI_PLATFORM_APP_WEBHOOKS` định tuyến URL/secret theo app. Thiếu mapping chặn run mới với 503 sau replay lookup. Event/body ổn định qua retry, timestamp gửi mới mỗi attempt, chữ ký HMAC trên raw bytes, không follow redirect. Hết năm attempts giữ dead event; CLI redeliver cùng event, không rescore.
|
||||
- CLI cấp/rotate/revoke key; DB chỉ lưu verifier. Admin session/CSRF độc lập; Web chỉ list/revoke, không nhận raw app key. Fixture runner dùng app key server-only; demo seed yêu cầu key đã cấp, không tự provisioning.
|
||||
- Generated OpenAPI khai báo Bearer auth và lỗi HTTP. Generator xuất summary/detail/webhook từ serializers; điểm và breakdown của examples được tính bằng scoring hiện tại từ request fixture. Chế độ `--check` phát hiện fixture drift mà không ghi file.
|
||||
- Scoring pipeline, giới hạn snapshot, rules-only/hybrid/fallback/failed và RAM-only `X-Tenant-VKey` được giữ trong regression suite. Lỗi validation/DB trên scoring API trả thông báo an toàn, không echo credential hoặc SQL.
|
||||
|
||||
## Bằng chứng provider local
|
||||
|
||||
Chạy tại root AI Platform, dùng môi trường project-local Python 3.13.14 và PostgreSQL test database `ai_platform_test` cho database tests:
|
||||
|
||||
- `uv run --no-sync ruff check src tests scripts migrations`: PASS.
|
||||
- `uv run --no-sync ruff format --check src tests scripts migrations`: PASS, 78 files đã đúng format.
|
||||
- `uv run --no-sync pyright`: PASS, 0 errors / 0 warnings.
|
||||
- `uv run --no-sync lint-imports`: PASS, 3 contracts kept / 0 broken.
|
||||
- `make contract`: PASS; sinh OpenAPI và serializer-derived fixtures.
|
||||
- `uv run --no-sync python scripts/export_contract_fixtures.py --check`: PASS.
|
||||
- `set -a; . ./.env.test; set +a; uv run --no-sync python /tmp/verify_ai_platform_baseline.py`: PASS, 193 passed; 1 cảnh báo deprecation Starlette/httpx đã có. Harness tạm tạo schema mới, chạy Alembic upgrade/check rồi pytest, sau cùng chỉ xóa schema nó tạo và xác nhận public revision không đổi. `make test` trực tiếp trên public schema cũ chưa chạy lại vì public vẫn mang revision đã gộp bỏ; cần xử lý DB local riêng trước.
|
||||
- `make check-fast`: PASS, Ruff/format/YAML/TOML/conflict/private-key hooks. Lệnh này chỉ xử lý tracked files; untracked source được kiểm tra bằng các lệnh Ruff/Pyright/pytest và review trực tiếp.
|
||||
- `docker compose config --quiet`: PASS.
|
||||
- `uv run --no-sync alembic heads`: PASS, `0001_initial` là head duy nhất.
|
||||
- `make build`: PASS ở phiên app-auth trước khi gộp migration, tạo `ai-platform-backend:local` và `ai-platform-web:local`. Chưa rebuild Docker images sau gộp; không deploy hoặc chứng minh runtime toàn Compose stack.
|
||||
- `git diff --check`: PASS.
|
||||
|
||||
Chạy tại `apps/web`:
|
||||
|
||||
- `bun run format:check`: PASS.
|
||||
- `bun run check`: PASS, 0 errors / 0 warnings; sinh lại types từ OpenAPI.
|
||||
- `bun run test`: PASS, 6 files / 16 tests.
|
||||
- `bun run build`: PASS. Có thông báo empty chunk và plugin timing; exit 0. Docker Web build cũng pass với artifact/fixture được copy vào build context.
|
||||
|
||||
Các tests auth/replay/outbox sử dụng PostgreSQL thật và HTTP AI/webhook giả lập. Chúng không gọi hai backend AI Platform và Omni qua mạng thực tế, không xác minh EMS writeback hoặc AIHub sandbox.
|
||||
|
||||
## Migration baseline và database local
|
||||
|
||||
Người dùng đã cho phép gộp source migration vì AI Platform chưa phát hành. Source hiện chỉ có `migrations/versions/0001_initial.py`, tạo đủ tám bảng của schema hiện tại. Đã bỏ chuỗi tenant→app cùng guard tests; `tests/test_initial_migration.py` kiểm tra khởi tạo, schema/model drift gồm server defaults và indexes, FK/unique theo app-account-request, unique notification/run và cascade xóa outbox, rồi downgrade/upgrade trong schema tạm.
|
||||
|
||||
Đồng bộ metadata ORM với các defaults/index đã có trong DDL: AI profile active=false, run degraded=false và index dispatch outbox. Không đổi ownership, API hoặc hành vi scoring.
|
||||
|
||||
Database test hiện hữu vẫn ở `0009_app_auth`; việc dọn đúng 97 legacy audit rows đã được phép diễn ra ở phiên trước và không được lặp lại. Phiên gộp source này không reset/stamp hoặc thay dữ liệu public. Full suite chạy trên schema mới tạm trong `ai_platform_test`; Alembic upgrade/check và 193 tests đều pass, schema tạm đã dọn, public revision giữ nguyên.
|
||||
|
||||
Database local có revision cũ cần xử lý riêng trước khi chạy lại backend/migrate/tests trực tiếp. Có thể dùng database mới; việc dựng lại/xóa hoặc stamp database hiện hữu cần kiểm tra schema và xác nhận phạm vi dữ liệu cần giữ/bỏ. Startup vẫn từ chối revision không khớp, không tự migrate. Các migration phát hành sau baseline này được giữ lịch sử như bình thường.
|
||||
|
||||
## Review và giới hạn nghiệm thu
|
||||
|
||||
Review áp dụng `docs/project_quality.md` cho tracked/untracked changes, gồm HTTP/application/persistence/bootstrap, schema/migrations, env/Compose/CI, serializers/fixtures và Admin Web. Các module lớn hiện hữu như wiring, persistence models và HTTP schemas vẫn giữ boundary hiện tại; không mở rộng thành refactor ngoài phạm vi app auth.
|
||||
|
||||
Phần Omni có hồ sơ kiểm thử riêng trong tài liệu phối hợp. Phiên provider không sửa repo Omni và không chạy lại hoặc xác nhận các kết quả consumer đó. Những việc sau còn pending:
|
||||
|
||||
1. Omni import đúng artifact provider revision 2 và đối chiếu request/summary/detail/webhook; không coi contract provisional riêng là artifact đã thống nhất.
|
||||
2. Chạy hai backend local cùng credential test và AI/EMS giả lập: admission, terminal callback, GET, đối chiếu request nguồn và ghi đúng lead. Kiểm tra early callback, timeout/replay, duplicate/conflict, account authorization và ordering trong flow thực tế.
|
||||
3. Xác minh AIHub sandbox về protocol/model/timeout/quota và các điều kiện production: TLS, secret distribution, backup/restore/retention, alerting và deployment runtime.
|
||||
|
||||
Các kết quả trên chỉ xác nhận phần provider local. Chưa commit, stage, push hoặc deploy trong phiên triển khai này.
|
||||
@ -12,7 +12,6 @@ Run this review after implementation and before every commit. Review the full wo
|
||||
- Use the relevant approved specification for required behavior and acceptance criteria.
|
||||
- Use `ARCHITECTURE.md` and `AGENTS.md` for module boundaries, dependency direction, and repository workflow.
|
||||
- Use generated OpenAPI and shared fixtures for exact API shapes.
|
||||
- Treat `rewrite_architect.md` as design rationale, not a parallel contract. When it conflicts with an approved specification or current architecture, follow the more specific approved source and record the conflict.
|
||||
- Do not claim cross-service or production behavior is verified unless the agreed fixture, integration test, or operational check was run.
|
||||
|
||||
## Architecture and dependency review
|
||||
|
||||
@ -1,58 +1,55 @@
|
||||
---
|
||||
title: AI Platform Lead Scoring v2
|
||||
description: Hành vi nhà cung cấp Lead Scoring và ranh giới với DotB-Omni trong bản rebuild.
|
||||
title: AI Platform Lead Scoring
|
||||
description: Hành vi dịch vụ chấm điểm lead đồng bộ và ranh giới với client.
|
||||
status: review_ready
|
||||
---
|
||||
|
||||
# Lead Scoring v2
|
||||
# Lead Scoring
|
||||
|
||||
Thiết kế app auth được người dùng cho phép triển khai theo baseline chung `omni-ai-platform-app-auth`, revision 2, ngày 2026-09-30. Đặc tả này ghi lại phần provider trong repo; generated OpenAPI và các fixture tại `contracts/lead-scoring/v2/` xác định wire shape. Tên thư mục `v2` chỉ là vị trí artifact hiện có, không tạo yêu cầu tương thích tenant auth cũ. Nghiệm thu liên dịch vụ với Omni/AIHub vẫn cần bằng chứng riêng.
|
||||
Dịch vụ AI scoring dùng chung, không giữ trạng thái nghiệp vụ. Client gửi snapshot lead cùng rules/weights, dịch vụ trả điểm, breakdown, tóm tắt, tín hiệu và gợi ý hành động; client tự quyết định phần còn lại (ngưỡng, HITL, CRM writeback). Wire shape do `contracts/lead-scoring/openapi.json` và `fixtures/` xác định; `contracts/lead-scoring/README.md` là tóm tắt cho client.
|
||||
|
||||
## Sở hữu
|
||||
|
||||
- DotB-Omni quản lý users/accounts, quyền EMS, connection, enabled/config, snapshot, rules/points, weights, score bands, HITL/review/override và CRM writeback. Omni gửi app key và V-Key từ backend, không từ browser.
|
||||
- AI Platform xác thực installation bằng app key, kiểm tra active và quyền `lead_scoring`, thực thi scoring, lưu run và thông báo kết thúc. Không đăng ký account hoặc xác thực user/credential EMS.
|
||||
- AI profile nội bộ có phiên bản chọn AIHub hoặc OpenAI-compatible cho service; Omni không chọn provider từng request. Admin Web chỉ quản lý AI profile, xem/chạy fixtures và xem/thu hồi app access; không quản lý nghiệp vụ khách hàng hoặc hiển thị raw app key.
|
||||
- DotB EMS là nguồn sự thật CRM. Webhook chỉ thông báo; Omni GET chi tiết, đối chiếu identity/lead/request nguồn trước khi ghi EMS.
|
||||
- Client (hiện là DotB-Omni) sở hữu user/account, quyền EMS, snapshot, rules, weights, ngưỡng điểm, HITL và CRM writeback. Dịch vụ không biết client cụ thể; dữ liệu riêng của client đi trong `metadata`, được lưu và trả lại nguyên vẹn.
|
||||
- AI Platform xác thực app bằng app key, chấm điểm, lưu lần chấm để replay và để Admin Web xem. Admin Web quản lý AI profile, dựng kịch bản thử bằng form (lead/custom fields, hội thoại/tin nhắn, rules/operator và trọng số), xem runs và thu hồi app access. JSON là chế độ nâng cao, đồng bộ cùng kịch bản; runner gửi nguyên trọng số, không ghi đè. Ngưỡng trong form là giá trị điều kiện rule, không phải chính sách phân loại tổng điểm của client. Kịch bản thử không thay đổi fixture chuẩn hay lưu chính sách khách hàng.
|
||||
|
||||
## Admission và idempotency
|
||||
## Endpoint
|
||||
|
||||
`POST /v1/lead-scoring/runs` nhận `Authorization: Bearer <app_api_key>`, `Idempotency-Key: <UUID>` và `X-Tenant-VKey` tùy chọn. App ID được suy ra từ key, không từ body. Body có đúng `request_id`, `account_id`, `subject_ref`, `snapshot`, `scoring_config`, `ai_profile`.
|
||||
`POST /v1/lead-scoring/score`, đồng bộ, trả 200 khi chấm xong.
|
||||
|
||||
- `request_id` là UUID chuẩn lowercase có dấu gạch ngang; header phải biểu diễn cùng UUID. `account_id` là chuỗi 1–255 ký tự không chỉ whitespace, giữ nguyên case/whitespace, Omni dùng `account.id.to_s`. Provider không kiểm tra account tồn tại ở Omni. `subject_ref` phải khớp `snapshot.lead_profile.ems_lead_id`. Field dư gồm app/tenant/callback bị từ chối 422.
|
||||
- Thiếu/sai/revoked key hoặc inactive app trả 401; app hợp lệ thiếu quyền Lead Scoring trả 403, cả POST và GET. GET run thuộc app khác hoặc không tồn tại trả 404. Omni sở hữu account/user isolation trong app của mình.
|
||||
- Fingerprint SHA-256 của canonical body sau HTTP validation, không chứa secret headers. Lookup `(app_id, account_id, request_id)` trước khi chọn AI profile hoặc kiểm tra webhook availability. Cùng identity/body trả 200 cùng run hiện tại, không schedule lại; body khác trả 409. PostgreSQL unique constraint và transaction giải quyết POST đồng thời. Account/app khác được dùng cùng request UUID.
|
||||
- Run mới đóng băng profile đang active, INSERT + COMMIT trước schedule, trả 202 và `Location`. Thiếu mapping callback trả 503 trước INSERT; replay vẫn hoạt động khi mapping bị gỡ. DB unavailable hoặc scheduling failure trả 503; scheduling failure terminalize run thay vì để pending giả. Omni retry cùng UUID/body vì timeout/503 có thể xảy ra sau commit.
|
||||
- Summary/detail có app/account/request/run identity, không tenant ID. Chấm lại hoặc dùng profile mới cần UUID mới. Không reuse UUID quá retention 90 ngày: run và fingerprint bị xóa, không có tombstone.
|
||||
- Giữ rule/operator/value validation, tổng weights bằng 1, score/points và snapshot limits: 10 conversations, 50 messages, 20.000 ký tự nội dung, 90 ngày evidence, snapshot 100KB và hard cap toàn body. Không hard-code category/action/review state.
|
||||
- Mỗi `scoring_config.rules[]` có `description` tùy chọn, chuỗi tối đa 99 ký tự Unicode. Thiếu hoặc `null` giữ wire body chuẩn hóa cũ để bảo toàn fingerprint/replay; chuỗi rỗng hoặc chỉ whitespace được nhận nhưng không thêm vào AI context. Mô tả có nội dung được lưu cùng request và tham gia fingerprint, nên đổi mô tả với cùng identity trả 409. Mô tả không thay đổi điều kiện khớp, points hoặc breakdown của bộ chấm quy tắc.
|
||||
- `Authorization: Bearer <app_key>`: thiếu, sai hoặc bị revoke trả 401.
|
||||
- `Idempotency-Key`: UUID bắt buộc. Unique theo `(app_id, idempotency_key)`, kèm SHA-256 của body chuẩn hóa. Cùng key, cùng body trả kết quả đã lưu và không gọi AI lần hai. Cùng key, khác body trả 422. Hai POST đồng thời cùng key chỉ tạo một bản ghi.
|
||||
- `X-AI-Key` (tùy chọn): khóa AI cho profile AIHub, tối đa 4096 ký tự, không CR/LF (sai thì 422). Chỉ giữ trong RAM trong lúc gọi; không lưu, log hay trả về.
|
||||
- Body: `snapshot`, `scoring_config` (`rule_weight`, `ai_weight`, `rules[]`, tổng weight bằng 1), `metadata` (tối đa 10KB). Snapshot tối đa 10 conversations, 50 messages, 20.000 ký tự nội dung, 100KB. Field lạ bị 422.
|
||||
- Rule: `id`, `field`, `operator`, `value`, `points`, `description` tùy chọn (tối đa 99 ký tự, chỉ gửi cho AI làm ngữ cảnh, không đổi kết quả khớp). `field` là `lead_profile.<name|phone|email|status|external_id>`, `custom_fields.<key>` hoặc `metrics.<key>`. Operator: `eq ne gt gte lt lte contains exists between in`. `between` nhận `[min, max]` số, gồm cả hai đầu. `in` nhận list scalar (1 đến 100 phần tử), so khớp nguyên giá trị. Field sai kiểu thì không khớp, không lỗi.
|
||||
|
||||
## Kết quả và lỗi
|
||||
## Kết quả
|
||||
|
||||
- `ai_weight=0`: `rules_only`, `degraded=false`, `ai_score=null`, không gọi AIHub và không cần V-Key.
|
||||
- `ai_weight>0` thiếu V-Key với AIHub hoặc provider lỗi: `rules_only_fallback`, `degraded=true`, `ai_score/ai_confidence=null`, `final_score=rule_score`, mã lỗi chuẩn hóa. Không tự AI retry.
|
||||
- AI thành công: `hybrid`, `degraded=false`, `final_score=clamp(round(rule_score*rule_weight + ai_score*ai_weight), 0, 100)`; breakdown/reasoning/evidence/confidence có giới hạn. Chỉ gửi các `description` có nội dung làm ngữ cảnh bổ sung ở bước chấm AI cho cả AIHub và OpenAI-compatible; không gửi rule IDs, điều kiện, points hoặc weights. Mô tả được đóng gói như dữ liệu JSON, không phải chỉ dẫn hệ thống hay bằng chứng về khách hàng; snapshot và evidence vẫn là căn cứ chấm điểm.
|
||||
- Bug/code/DB failure thành `failed`, không giả là AI fallback. Đổi profile/provider cần version mới và audit trong cùng transaction. Medium two-pass là mặc định khi dùng AI; Light/Heavy cần nhu cầu và xác minh riêng.
|
||||
`run_id`, `final_score`, `rule_score`, `ai_score`, `result_mode`, `error_code`, `breakdown[]`, `ai {summary, suggested_action, signals[]}`, `metrics`, `metadata`.
|
||||
|
||||
- `ai_weight=0`: `rules_only`, không gọi AI.
|
||||
- `ai_weight>0` và AI thành công: `hybrid`, `final_score = clamp(round(rule_score*rule_weight + ai_score*ai_weight), 0, 100)`.
|
||||
- Thiếu AI key (AIHub), không có profile active, AI lỗi hoặc quá `timeout_seconds`: `rules_only_fallback`, `final_score = rule_score`, `error_code` là `ai_key_missing`, `ai_profile_missing`, `ai_timeout` hoặc mã lỗi provider. Không tự retry.
|
||||
- Lỗi code hoặc DB trả 5xx; DB không sẵn sàng trả 503 với thông báo an toàn.
|
||||
|
||||
## AI profile
|
||||
|
||||
Admin Web “Thử chấm điểm” nhận khóa AI riêng cho từng lần thử. Khóa không nằm trong kịch bản JSON, không lưu/echo; được chuyển qua `X-AI-Key` khi trọng số AI > 0 và xóa khỏi ô nhập khi gửi. Nếu ô trống, runner dùng `LEAD_SCORING_FIXTURE_AI_KEY` trên server nếu có. Khóa nhập khi thử AI profile không được lưu vào revision hoặc tự chuyển sang lần thử kịch bản.
|
||||
|
||||
Admin Web lưu AI profile theo revision tự tăng. Lưu tạo revision mới (không active), kích hoạt là bước riêng và kích hoạt revision cũ là rollback. Profile gồm `provider` (`aihub` hoặc `openai_compatible`), `base_url`, `model`, `timeout_seconds` (1 đến 60), `reasoning_effort`, `instructions` (rubric do admin viết), `include_reference_date`, `timezone`, `suggested_action_enabled`. Khung kỹ thuật của prompt nằm trong code. Mỗi run lưu `profile_revision`. Chạy thử một profile nháp không lưu run và không đổi revision active. Đổi URL có hiệu lực ngay, không restart.
|
||||
|
||||
Một lần gọi AI trả JSON `{signals[], score, summary, suggested_action}`. `base_url` phải qua `safe_http_url`: HTTPS, hoặc HTTP chỉ với loopback ở development, không userinfo, query hay fragment. AIHub dùng khóa trong `X-AI-Key`. OpenAI-compatible dùng `AI_PLATFORM_OPENAI_API_KEY`, chỉ gửi tới `AI_PLATFORM_OPENAI_BASE_URL`.
|
||||
|
||||
## Credential và runtime
|
||||
|
||||
- Một app ID ổn định và một key cho mỗi Omni installation/environment, dùng chung các account. DB chỉ lưu SHA-256 verifier, active và quyền Lead Scoring. Rotate giữ app ID, GET/replay run cũ và permission; key cũ hết hiệu lực. Không tenant provisioning endpoint/secret, account registration hoặc dual auth.
|
||||
- Vận hành dùng CLI `create-app`, `rotate-app-key`, `revoke-app-key`; raw key chỉ trả ở thao tác CLI cấp/rotate. Admin session độc lập, password hash, CSRF mutation, cookie bảo vệ, logout/expiry và audit không chứa secret. Web chỉ list/revoke app access.
|
||||
- `X-Tenant-VKey` giữ tên hiện tại vì là giao tiếp provider AI riêng; chỉ giữ trong RAM current task, forward Bearer tới AIHub. OpenAI-compatible dùng URL versioned trong profile, server API key chỉ gửi tới URL được cấu hình cho key đó. HTTP chỉ cho loopback development, production yêu cầu HTTPS. Không persist/log/trace/error/browser raw secret.
|
||||
- Một backend process với asyncio tasks. Startup đọc migration head, không tự migrate; terminalize pending/running bị mất thành `failed/worker_interrupted`. Không resume V-Key hoặc tự chấm lại sau crash.
|
||||
- Schema chưa phát hành được gộp thành một migration `0001_initial`, tạo thẳng app auth hiện tại; không giữ chuỗi chuyển đổi tenant cũ. Database local mang revision cũ cần xử lý riêng trước khi chạy lại, không tự stamp/reset hoặc xóa dữ liệu. Sau lần phát hành đầu tiên, giữ lịch sử migration đã phát hành.
|
||||
- Một app ID ổn định và một key cho mỗi installation. DB chỉ lưu SHA-256 verifier. Rotate giữ app ID và các run cũ; key cũ hết hiệu lực. Cấp, rotate, revoke qua CLI `create-app`, `rotate-app-key`, `revoke-app-key`; raw key chỉ xuất ở thao tác CLI. Admin session độc lập, password hash, CSRF; Web chỉ list và revoke.
|
||||
- App key và AI key không xuất hiện trong DB, log, response hay lỗi.
|
||||
- Một backend process. Startup đọc migration head, không tự migrate. Schema chưa phát hành là một migration `0001_initial`; database local mang revision cũ cần dựng lại hoặc xử lý riêng.
|
||||
|
||||
## Terminal delivery
|
||||
## Nghiệm thu và chưa xác minh
|
||||
|
||||
- Terminal transition và outbox commit cùng transaction. `AI_PLATFORM_APP_WEBHOOKS` là JSON mapping `app_id → {"url":"...","signing_secret":"..."}`; secret tối thiểu 32 ký tự, HTTPS hoặc development loopback HTTP, không credentials/query/fragment trong URL. Không request callback hoặc subscription framework.
|
||||
- Webhook tới endpoint Omni `/api/v1/internal/lead-scoring/notifications`, body đúng `event_id`, `app_id`, `account_id`, `request_id`, `run_id`, `status`, `occurred_at`. Identity lấy từ run đã lưu; không điểm/snapshot/evidence/secret. Event/body bất biến qua retry; timestamp header mới cho từng attempt.
|
||||
- HMAC-SHA256 trên byte UTF-8 `timestamp + "." + raw_body`, header `X-Webhook-Signature: sha256=<lowercase hex>`, `X-Webhook-Timestamp` ISO UTC. Omni xác minh raw bytes, constant-time, ±5 phút, giới hạn body 8192 bytes rồi parse/schema.
|
||||
- Omni lưu event trước ACK 202, duplicate đúng body trả 200, dedupe `(app_id,event_id)`, identity/conflicting duplicate trả 409. App/request nguồn không khớp trả 404. Omni GET bằng app key và đối chiếu app/account/request/run/lead trước writeback. Callback sớm tra được request nguồn đã commit trước POST.
|
||||
- Provider retry non-2xx/network tối đa 5 attempts rồi dead. `redeliver-notification <event_id>` đặt lại delivery của cùng event, không tạo run hoặc rescore. Không follow redirect khi gửi webhook.
|
||||
Đã kiểm tra trực tiếp (2026-10-09): AIHub `openai/gpt-6-luna` với `reasoning_effort=low` hoặc mặc định cho kết quả `hybrid` trong khoảng 5 đến 8 giây; model từ chối `temperature=0` nên request không gửi `temperature`. AIHub tại URL thử không kiểm tra giá trị Bearer (khóa sai hoặc thiếu vẫn trả 200). Admin Web đã chạy thử qua trình duyệt (lưu, kích hoạt, chạy thử, kịch bản sửa được, trang runs).
|
||||
|
||||
## Privacy và nghiệm thu
|
||||
Đã kiểm tra bằng test cục bộ: các chế độ kết quả, replay và xung đột idempotency, truy cập đồng thời, xác thực, hình dạng prompt và request tới provider qua transport giả, contract khớp code, migration khớp models.
|
||||
|
||||
- Retention xóa run/snapshot/fingerprint/evidence và outbox liên quan sau 90 ngày. App/account offboarding và backup lifecycle phải chốt trước production; DB deletion không chứng minh backup đã xóa.
|
||||
- Omni lưu request nguồn bất biến trước HTTP, retry cùng UUID/body, bind run atomically, giữ account/subject ordering namespace `lead-scoring`, recheck enabled/EMS trước writeback. Đây là trách nhiệm consumer cần nghiệm thu ở Omni, không được suy ra từ tests provider.
|
||||
- Nghiệm thu: app auth/permission, cross-app GET, account/app replay isolation, concurrent POST, rotation, callback availability, HMAC và redelivery, rules-only/hybrid/fallback/failed regression, migration khởi tạo/schema/constraints/lifecycle, secret safety và Web checks. Một test liên ứng dụng với AI/EMS giả lập vẫn bắt buộc trước kết luận integration chạy đúng.
|
||||
- AIHub sandbox protocol/model/timeout/quota và production hosting/TLS/secret injection/backup/alerts chưa được xác minh. Không tuyên bố production-ready từ local checks.
|
||||
Chưa xác minh: các model khác ngoài `gpt-6-luna`, quota, tích hợp Omni/EMS, chất lượng điểm bằng dữ liệu có nhãn, và hạ tầng production (hosting, TLS, secret, backup, alerting, retention). Không tuyên bố production-ready từ kiểm tra cục bộ.
|
||||
|
||||
@ -15,7 +15,6 @@ def upgrade() -> None:
|
||||
sa.Column("app_id", sa.String(128), primary_key=True),
|
||||
sa.Column("verifier", sa.String(64), nullable=False, unique=True),
|
||||
sa.Column("active", sa.Boolean(), nullable=False),
|
||||
sa.Column("lead_scoring", sa.Boolean(), nullable=False),
|
||||
sa.Column(
|
||||
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||
),
|
||||
@ -23,16 +22,6 @@ def upgrade() -> None:
|
||||
"updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||
),
|
||||
)
|
||||
op.create_table(
|
||||
"app_credential_audit_events",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("app_id", sa.String(128), nullable=False),
|
||||
sa.Column("actor", sa.String(254), nullable=False),
|
||||
sa.Column("action", sa.String(16), nullable=False),
|
||||
sa.Column(
|
||||
"occurred_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||
),
|
||||
)
|
||||
op.create_table(
|
||||
"admin_users",
|
||||
sa.Column("email", sa.String(254), primary_key=True),
|
||||
@ -49,24 +38,19 @@ def upgrade() -> None:
|
||||
)
|
||||
op.create_table(
|
||||
"ai_profiles",
|
||||
sa.Column("version", sa.String(64), primary_key=True),
|
||||
sa.Column("model", sa.String(128), nullable=False),
|
||||
sa.Column("provider", sa.String(32), nullable=False, server_default="aihub"),
|
||||
sa.Column("base_url", sa.String(2048)),
|
||||
sa.Column("system_prompt", sa.String(8000), nullable=False),
|
||||
sa.Column("timeout_seconds", sa.Integer(), nullable=False),
|
||||
sa.Column("limits", sa.JSON(), nullable=False),
|
||||
sa.Column("active", sa.Boolean(), nullable=False, server_default=sa.false()),
|
||||
)
|
||||
op.create_table(
|
||||
"ai_profile_audit_events",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("actor", sa.String(254), nullable=False),
|
||||
sa.Column("version", sa.String(64), nullable=False),
|
||||
sa.Column("revision", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("provider", sa.String(32), nullable=False),
|
||||
sa.Column(
|
||||
"occurred_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||
),
|
||||
sa.Column("base_url", sa.String(2048), nullable=False),
|
||||
sa.Column("model", sa.String(128), nullable=False),
|
||||
sa.Column("timeout_seconds", sa.Integer(), nullable=False),
|
||||
sa.Column("reasoning_effort", sa.String(16)),
|
||||
sa.Column("instructions", sa.String(8000), nullable=False),
|
||||
sa.Column("include_reference_date", sa.Boolean(), nullable=False),
|
||||
sa.Column("timezone", sa.String(64), nullable=False),
|
||||
sa.Column("suggested_action_enabled", sa.Boolean(), nullable=False),
|
||||
sa.Column("created_by", sa.String(254), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("active", sa.Boolean(), nullable=False, server_default=sa.false()),
|
||||
)
|
||||
op.create_table(
|
||||
"scoring_runs",
|
||||
@ -74,63 +58,30 @@ def upgrade() -> None:
|
||||
sa.Column(
|
||||
"app_id", sa.String(128), sa.ForeignKey("app_credentials.app_id"), nullable=False
|
||||
),
|
||||
sa.Column("account_id", sa.String(255), nullable=False),
|
||||
sa.Column("request_id", sa.String(36), nullable=False),
|
||||
sa.Column("idempotency_key", sa.String(36), nullable=False),
|
||||
sa.Column("fingerprint", sa.String(64), nullable=False),
|
||||
sa.Column("request", sa.JSON(), nullable=False),
|
||||
sa.Column("profile_version", sa.String(64), nullable=False),
|
||||
sa.Column("status", sa.String(24), nullable=False),
|
||||
sa.Column("rule_score", sa.Integer()),
|
||||
sa.Column("ai_score", sa.Integer()),
|
||||
sa.Column("ai_confidence", sa.Float()),
|
||||
sa.Column("ai_reasoning", sa.String(2000)),
|
||||
sa.Column("error_code", sa.String(64)),
|
||||
sa.Column("final_score", sa.Integer()),
|
||||
sa.Column("degraded", sa.Boolean(), nullable=False, server_default=sa.false()),
|
||||
sa.Column("result_mode", sa.String(32)),
|
||||
sa.Column("breakdown", sa.JSON()),
|
||||
sa.Column("ai_evidence", sa.JSON()),
|
||||
sa.Column(
|
||||
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||
),
|
||||
sa.Column("completed_at", sa.DateTime(timezone=True)),
|
||||
sa.UniqueConstraint(
|
||||
"app_id", "account_id", "request_id", name="uq_run_app_account_request"
|
||||
),
|
||||
sa.Column("request", sa.JSON(), nullable=False),
|
||||
sa.Column("rule_score", sa.Integer(), nullable=False),
|
||||
sa.Column("ai_score", sa.Integer()),
|
||||
sa.Column("final_score", sa.Integer(), nullable=False),
|
||||
sa.Column("result_mode", sa.String(32), nullable=False),
|
||||
sa.Column("error_code", sa.String(64)),
|
||||
sa.Column("breakdown", sa.JSON(), nullable=False),
|
||||
sa.Column("ai", sa.JSON()),
|
||||
sa.Column("profile_revision", sa.Integer(), sa.ForeignKey("ai_profiles.revision")),
|
||||
sa.Column("duration_ms", sa.Integer(), nullable=False),
|
||||
sa.UniqueConstraint("app_id", "idempotency_key", name="uq_run_app_key"),
|
||||
)
|
||||
for column in ("app_id", "status", "created_at"):
|
||||
for column in ("app_id", "created_at"):
|
||||
op.create_index(f"ix_scoring_runs_{column}", "scoring_runs", [column])
|
||||
op.create_table(
|
||||
"scoring_notifications",
|
||||
sa.Column("id", sa.String(36), primary_key=True),
|
||||
sa.Column(
|
||||
"run_id",
|
||||
sa.String(36),
|
||||
sa.ForeignKey("scoring_runs.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
unique=True,
|
||||
),
|
||||
sa.Column("app_id", sa.String(128), nullable=False),
|
||||
sa.Column("account_id", sa.String(255), nullable=False),
|
||||
sa.Column("request_id", sa.String(36), nullable=False),
|
||||
sa.Column("status", sa.String(24), nullable=False),
|
||||
sa.Column("attempts", sa.Integer(), nullable=False),
|
||||
sa.Column("next_attempt_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("delivered_at", sa.DateTime(timezone=True)),
|
||||
sa.Column("dead", sa.Boolean(), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_scoring_notifications_due", "scoring_notifications", ["dead", "next_attempt_at"]
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("scoring_notifications")
|
||||
op.drop_table("scoring_runs")
|
||||
op.drop_table("ai_profile_audit_events")
|
||||
op.drop_table("ai_profiles")
|
||||
op.drop_table("admin_sessions")
|
||||
op.drop_table("admin_users")
|
||||
op.drop_table("app_credential_audit_events")
|
||||
op.drop_table("app_credentials")
|
||||
|
||||
@ -1,19 +0,0 @@
|
||||
# Local development only. Replace all credentials in deployed environments.
|
||||
ENVIRONMENT=development
|
||||
DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:5433/lead_scoring
|
||||
AIHUB_BASE_URL=http://localhost:8080
|
||||
DEBUG=false
|
||||
|
||||
# JSON arrays consumed by pydantic-settings. These values work with the Dev Console example.
|
||||
SERVICE_CALLERS=[{"caller_id":"dev-console","token":"local-dev-token","allowed_apps":["dev-console"],"allowed_features":["lead-scoring"],"allowed_tenants":["omni-account-42","omni-test-tenant-42"]}]
|
||||
TENANT_ALLOWLIST=["omni-account-42","omni-test-tenant-42"]
|
||||
SCORING_CONFIGS=[{"version":"1.0.0","confidence":0.8,"confidence_threshold":0.7,"hitl_policy_version":"1.0.0","effort_profile":"medium","fallback_policy":"rules_only_review","ai_model":"openai/gpt-4o-mini"}]
|
||||
|
||||
WORKER_LEASE_SECONDS=30
|
||||
WORKER_RECOVERY_INTERVAL_SECONDS=5
|
||||
KILL_SWITCH_ENABLED=false
|
||||
|
||||
# Enable webhook delivery by setting both values.
|
||||
# WEBHOOK_SUBSCRIBER_URL=http://localhost:8080/api/v1/internal/lead-scoring/events
|
||||
# WEBHOOK_SIGNING_SECRET=replace-with-local-signing-secret
|
||||
WEBHOOK_SIGNING_KEY_ID=pilot-2026-01
|
||||
76
old_repo/.gitignore
vendored
@ -1,76 +0,0 @@
|
||||
# Python & Virtual Environments
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
share/python-wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
.venv/
|
||||
venv/
|
||||
ENV/
|
||||
env/
|
||||
.uv/
|
||||
|
||||
# Linters, Type Checkers & Test Caches
|
||||
.ruff_cache/
|
||||
.pytest_cache/
|
||||
.mypy_cache/
|
||||
.pyre/
|
||||
.pytype/
|
||||
.pyright/
|
||||
.coverage
|
||||
.coverage.*
|
||||
htmlcov/
|
||||
nosetests.xml
|
||||
coverage.xml
|
||||
*.cover
|
||||
*.py,cover
|
||||
|
||||
# Node / Bun / Frontend
|
||||
node_modules/
|
||||
node_modules
|
||||
apps/dev-console/node_modules
|
||||
.bun/
|
||||
dist-ssr/
|
||||
*.local
|
||||
.svelte-kit/
|
||||
|
||||
# Playwright & Testing Reports
|
||||
playwright-report/
|
||||
test-results/
|
||||
blob-report/
|
||||
|
||||
# Environment Variables & Local Secrets
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# Project-local
|
||||
vault/
|
||||
.scratch/
|
||||
.codex/
|
||||
docs/agents/
|
||||
.claude/
|
||||
CLAUDE.LOCAL.md
|
||||
# Generated artifacts
|
||||
/openapi.json
|
||||
|
||||
# IDE, OS & Editor Artifacts
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
@ -1 +0,0 @@
|
||||
3.13
|
||||
@ -1,172 +0,0 @@
|
||||
# AGENTS.md
|
||||
|
||||
This file provides guidance to Codex (Codex.ai/code) when working with this repository.
|
||||
|
||||
## Purpose and status
|
||||
|
||||
This repository is the Python-first AI Platform workspace. Lead Scoring is the first service; related systems are `DotB-Omni`, `lead-scoring-agent`, and `aihub` under `/home/hung/code/dotb_workspace/`.
|
||||
|
||||
The repository contains specifications, project documentation, and the initialized baseline for the Lead Scoring service (`src/lead_scoring/`).
|
||||
|
||||
Before implementing or changing Lead Scoring Customer Pilot behavior, read:
|
||||
|
||||
- `docs/specs/lead-scoring-customer-pilot.md`: authoritative product behavior, scope, invariants, acceptance boundaries, and cross-service ownership.
|
||||
- `stack.md`: technology decisions, candidates, evidence, and unresolved stack questions. It does not override the approved specification.
|
||||
|
||||
The DotB Vault RFC, initial brief, Grill Brief, legacy reports, drafts, and agent messages are historical/supporting context rather than implementation authority. Exact wire formats become authoritative through released Pydantic models, generated OpenAPI/JSON Schemas, and shared fixtures.
|
||||
|
||||
## Communication
|
||||
|
||||
- Keep responses short, specific, and decision-oriented.
|
||||
- Put the decision or conclusion first, followed by only the evidence and next action needed.
|
||||
- Keep the main context high-signal. Put raw logs, scratch data, and long intermediate reports in `/tmp/` or the appropriate durable document.
|
||||
- Use lists and compact structure when they make the result easier to scan.
|
||||
- State assumptions, unresolved decisions, and verification status explicitly.
|
||||
|
||||
## Writing
|
||||
|
||||
- Write directly and precisely. Avoid buzzwords, filler, and repeated explanations.
|
||||
- Comments explain intent, invariants, trade-offs, or non-obvious constraints, not syntax or line-by-line behavior.
|
||||
- Do not add speculative documentation, abstractions, or metadata that the repository does not need.
|
||||
- Do not use emoji or em dashes in new prose, comments, or reports. Preserve emoji and punctuation that are part of existing paths or quoted source text.
|
||||
|
||||
## Delegation
|
||||
|
||||
- Prioritize standard subagents over teammates to minimize token overhead. Spawn multiple subagents when parallel execution is genuinely needed.
|
||||
- Pick the agent type matching the task from available configured agents (e.g., `explorer`, `implementer`, `tester`, `researcher`, `adversarial-reviewer`, `general`, `codebase-memory`).
|
||||
- Delegate context-heavy or tool-heavy work with a clear scope, completion criteria, and evidence requirements.
|
||||
- Require condensed handoffs containing conclusions, changed paths, verification, and uncertainty. Do not paste raw logs into the main context.
|
||||
- The main context retains final decisions and integration responsibility. Agent messages are evidence, not user approval.
|
||||
|
||||
## Agent skills
|
||||
|
||||
### Issue tracker
|
||||
|
||||
Local Markdown issues are stored under `.scratch/<feature-slug>/`. See `docs/agents/issue-tracker.md`.
|
||||
|
||||
### Domain docs
|
||||
|
||||
The repository uses a single-context domain layout. See `docs/agents/domain.md`.
|
||||
|
||||
## Project knowledge
|
||||
|
||||
- `AGENTS.md` is the project map for conventions, verified commands, boundaries, and gotchas.
|
||||
- `docs/specs/lead-scoring-customer-pilot.md` is the approved source of truth for Lead Scoring Customer Pilot product behavior and build boundaries.
|
||||
- `stack.md` is the primary record for technology decisions and unresolved stack questions; it cannot override the approved spec.
|
||||
- `docs/` contains shared project knowledge intended for version control.
|
||||
- `/tmp/` is for scratch data, raw logs, and temporary handoffs.
|
||||
- Released Pydantic models, generated OpenAPI/JSON Schemas, tests, and shared fixtures become the exact executable contract when they exist.
|
||||
|
||||
## Documentation
|
||||
|
||||
- Put temporary reports, raw logs, and scratch material in `/tmp/`.
|
||||
- Put durable team knowledge in `docs/` and include it in version control.
|
||||
- Use `vault/` only for durable project-local knowledge that must not be committed.
|
||||
- New durable Markdown documents under `docs/` or `vault/` use minimal YAML frontmatter:
|
||||
|
||||
```yaml
|
||||
---
|
||||
title: Document title
|
||||
description: A short description of what the document contains and when it is useful.
|
||||
status: draft
|
||||
tags: []
|
||||
---
|
||||
```
|
||||
|
||||
- `title` and `description` are required. For governed specs, `status` may be `draft`, `review_ready`, `approved`, or `deprecated`; other existing document schemas keep their established status vocabulary. `tags` is optional.
|
||||
- Do not add metadata that Git, the filesystem, or existing tooling can derive.
|
||||
- Preserve the format and metadata schema of existing documents. Do not add frontmatter, reformat, move, or normalize existing files unless the task explicitly requests migration.
|
||||
- `AGENTS.md`, README files, and files with an existing schema keep their established format.
|
||||
|
||||
## Repository structure
|
||||
|
||||
- `src/lead_scoring/`: Lead Scoring production service package and CLI tools.
|
||||
- `alembic/`: database migrations for PostgreSQL.
|
||||
- `tests/`: test suite including health check, OpenAPI CLI, and migration lifecycle.
|
||||
- `docs/specs/lead-scoring-customer-pilot.md`: approved Lead Scoring Customer Pilot specification.
|
||||
- `docs/`: durable shared project documentation.
|
||||
- `.scratch/`: local untracked draft specs and issue tickets used by agent skills.
|
||||
- `stack.md`: technology decisions and unresolved stack questions.
|
||||
- `.codex/settings.json`: project hook configuration.
|
||||
- `.codex/hooks/post-edit-check.py`: Python-only PostToolUse feedback hook.
|
||||
|
||||
## Product and frontend boundary
|
||||
|
||||
The frontend boundary is approved for Customer Pilot: DotB-Omni owns all customer-facing Lead Scoring configuration and HITL UI. AI Platform may provide only an internal Dev Console for fixtures, contract testing, failure testing, and allowlisted integration verification; it must not duplicate Omni business UI.
|
||||
|
||||
When the internal Dev Console is implemented:
|
||||
|
||||
- Use Svelte 5 and TypeScript with Bun, Vite, and Vitest. Add SvelteKit only when routing, SSR, or deployment requires it.
|
||||
- Generate types from backend OpenAPI and consume them through `openapi-fetch`.
|
||||
- Keep shared transport concerns such as authentication and error handling outside generated code.
|
||||
- Use the official Svelte tooling, including `@sveltejs/mcp` autofixer feedback and `svelte-check`.
|
||||
- Use Playwright only for important end-to-end user flows.
|
||||
|
||||
TypeScript 7 native compiler and language server are pilot candidates, not the adopted project default. Test them project-locally before changing global runtimes or the shared `mcpls` configuration.
|
||||
|
||||
## Engineering principles
|
||||
|
||||
- Follow YAGNI. Implement only what the current requirement needs; do not add speculative abstractions, extension points, compatibility layers, or infrastructure.
|
||||
- Prefer the simplest design that preserves clear boundaries and can be changed safely.
|
||||
- Reuse the standard library and established, actively maintained packages when they reduce code and operational burden. Avoid dependencies for trivial behavior.
|
||||
- Keep code explicit. Use precise names, short control flow, and direct data transformations. Avoid cleverness, hidden side effects, global mutable state, and premature optimization.
|
||||
- Prefer composition over inheritance and functions over classes. Introduce a class only when state, lifecycle, or a framework contract genuinely requires one.
|
||||
- Keep a functional core with side effects at the boundaries. Isolate I/O, persistence, network calls, clocks, and environment access.
|
||||
- Organize modules by capability or domain responsibility, not by arbitrary technical layers.
|
||||
- Avoid wrappers that only rename another API. Every abstraction must remove meaningful complexity or enforce a real boundary.
|
||||
- Preserve one clear source of truth for contracts, configuration, and domain rules.
|
||||
|
||||
## AI-native code
|
||||
|
||||
Write code that humans and coding agents can understand with minimal hidden context:
|
||||
|
||||
- Make dependencies, inputs, outputs, errors, and side effects explicit.
|
||||
- Use typed, stable contracts at module and service boundaries.
|
||||
- Keep related implementation and tests easy to locate.
|
||||
- Prefer deterministic behavior and small composable functions.
|
||||
- Document decisions and non-obvious constraints, not syntax or line-by-line behavior.
|
||||
- Match existing conventions before introducing a new pattern.
|
||||
- Make the smallest coherent change and remove superseded code instead of leaving parallel implementations.
|
||||
|
||||
## Verified commands
|
||||
|
||||
- `uv sync`: sync workspace dependencies and environment.
|
||||
- `uv run pytest`: run pytest test suite against Python 3.13.
|
||||
- `uv run ruff check src tests`: run lint checks across source and tests.
|
||||
- `uv run ruff format --check src tests`: check code formatting.
|
||||
- `uv run pyright`: run Pyright static type checker.
|
||||
- `uv run ty check`: run project-local ty type checker.
|
||||
- `uv run generate-openapi`: deterministically generate root `openapi.json`.
|
||||
- `uv run generate-openapi --check`: verify that `openapi.json` is present and matches the current backend contract.
|
||||
- `uv run generate-webhook-contract`: deterministically generate terminal webhook contract pack.
|
||||
- `uv run generate-webhook-contract --check`: verify that webhook contract pack is present and matches current models.
|
||||
- `uv run generate-polling-contract`: deterministically generate scoring run polling contract pack.
|
||||
- `uv run generate-polling-contract --check`: verify that polling contract pack is present and matches current models.
|
||||
- `uv run generate-contract-pack`: deterministically generate the versioned shared Contract Pack.
|
||||
- `uv run generate-contract-pack --validate-fixtures`: validate provider and consumer fixture expectations.
|
||||
- `uv run generate-contract-pack --check`: verify the versioned Contract Pack and checksums are present and current.
|
||||
- `uv run alembic upgrade head`: apply database migrations up to head.
|
||||
- `uv run alembic downgrade base`: revert all database migrations.
|
||||
|
||||
## Deterministic generation
|
||||
|
||||
- When a verified project generator exists, use it instead of manually editing derived files.
|
||||
- Treat OpenAPI output and generated frontend types as derived artifacts. Regenerate them from the backend contract and require the authoritative verification command to detect drift.
|
||||
- Do not edit generated files manually unless the project workflow explicitly requires it.
|
||||
|
||||
## Safety and scope
|
||||
|
||||
- Inspect a path before overwriting or deleting it. Preserve existing configuration, documentation, code, and data unless the task explicitly authorizes removal or migration.
|
||||
- Do not expose secrets in logs, reports, handoffs, or generated artifacts.
|
||||
- Do not install packages, modify system configuration, or create permissions and background services without explicit approval.
|
||||
- Bootstrap production code only after the relevant product and implementation decision. Customer-facing Lead Scoring UI remains owned by DotB-Omni.
|
||||
|
||||
## Tooling and verification gotchas
|
||||
|
||||
- Root `openapi.json` and frontend build output remain covered by the ignore policy; the versioned Contract Pack OpenAPI artifact is generated and committed, and must not be edited manually.
|
||||
- The project PostToolUse hook is configured in `.codex/settings.json` for `Write` and `Edit`. It handles Python files via `ruff` (safe fix and format). Full type-checking (`pyright`, `ty`) and test suites belong to explicit verification steps. Preserve the working-tree modification to `.codex/hooks/post-edit-check.py` unless explicitly asked to change or reset it.
|
||||
- Do not modify `/etc`, package-manager state, system services, drivers, kernel settings, global Node/TypeScript versions, or the shared `mcpls` configuration as part of repository work unless explicitly approved.
|
||||
|
||||
## Quality of life
|
||||
|
||||
Pyright LSP is available for semantic navigation. Use it for definitions, references, hover types, document and workspace symbols, and call hierarchy; then read the relevant implementation to understand business rules and side effects.
|
||||
@ -1,159 +0,0 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with this repository.
|
||||
|
||||
## Purpose and status
|
||||
|
||||
This repository is the Python-first AI Platform workspace. Lead Scoring is the first service; related systems are `DotB-Omni`, `lead-scoring-agent`, and `aihub` under `/home/hung/code/dotb_workspace/`.
|
||||
|
||||
The repository contains specifications, project documentation, and the initialized baseline for the Lead Scoring service (`src/lead_scoring/`).
|
||||
|
||||
Before implementing or changing Lead Scoring Customer Pilot behavior, read:
|
||||
|
||||
- `docs/specs/lead-scoring-customer-pilot.md`: authoritative product behavior, scope, invariants, acceptance boundaries, and cross-service ownership.
|
||||
- `stack.md`: technology decisions, candidates, evidence, and unresolved stack questions. It does not override the approved specification.
|
||||
|
||||
The DotB Vault RFC, initial brief, Grill Brief, legacy reports, drafts, and agent messages are historical/supporting context rather than implementation authority. Exact wire formats become authoritative through released Pydantic models, generated OpenAPI/JSON Schemas, and shared fixtures.
|
||||
|
||||
## Communication
|
||||
|
||||
- Keep responses short, specific, and decision-oriented.
|
||||
- Put the decision or conclusion first, followed by only the evidence and next action needed.
|
||||
- Keep the main context high-signal. Put raw logs, scratch data, and long intermediate reports in `/tmp/` or the appropriate durable document.
|
||||
- Use lists and compact structure when they make the result easier to scan.
|
||||
- State assumptions, unresolved decisions, and verification status explicitly.
|
||||
|
||||
## Writing
|
||||
|
||||
- Write directly and precisely. Avoid buzzwords, filler, and repeated explanations.
|
||||
- Comments explain intent, invariants, trade-offs, or non-obvious constraints, not syntax or line-by-line behavior.
|
||||
- Do not add speculative documentation, abstractions, or metadata that the repository does not need.
|
||||
- Do not use emoji or em dashes in new prose, comments, or reports. Preserve emoji and punctuation that are part of existing paths or quoted source text.
|
||||
|
||||
## Delegation
|
||||
|
||||
- Use the configured `researcher` agent for read-only exploration and research.
|
||||
- Use the configured `primary-engineer` agent for implementation, debugging, and multi-step engineering work. If it is unavailable, use the narrowest suitable implementation agent.
|
||||
- Implementation agents own TDD tests; use the `tester` agent for independent verification before completion.
|
||||
- Delegate context-heavy or tool-heavy work with a clear scope, completion criteria, and evidence requirements.
|
||||
- Require condensed handoffs containing conclusions, changed paths, verification, and uncertainty. Do not paste raw logs into the main context.
|
||||
- The main context retains final decisions and integration responsibility. Agent messages are evidence, not user approval.
|
||||
|
||||
## Project knowledge
|
||||
|
||||
- `CLAUDE.md` is the project map for conventions, verified commands, boundaries, and gotchas.
|
||||
- `docs/specs/lead-scoring-customer-pilot.md` is the approved source of truth for Lead Scoring Customer Pilot product behavior and build boundaries.
|
||||
- `stack.md` is the primary record for technology decisions and unresolved stack questions; it cannot override the approved spec.
|
||||
- `docs/` contains shared project knowledge intended for version control.
|
||||
- `/tmp/` is for scratch data, raw logs, and temporary handoffs.
|
||||
- Released Pydantic models, generated OpenAPI/JSON Schemas, tests, and shared fixtures become the exact executable contract when they exist.
|
||||
|
||||
## Documentation
|
||||
|
||||
- Put temporary reports, raw logs, and scratch material in `/tmp/`.
|
||||
- Put durable team knowledge in `docs/` and include it in version control.
|
||||
- Use `vault/` only for durable project-local knowledge that must not be committed.
|
||||
- New durable Markdown documents under `docs/` or `vault/` use minimal YAML frontmatter:
|
||||
|
||||
```yaml
|
||||
---
|
||||
title: Document title
|
||||
description: A short description of what the document contains and when it is useful.
|
||||
status: draft
|
||||
tags: []
|
||||
---
|
||||
```
|
||||
|
||||
- `title` and `description` are required. For governed specs, `status` may be `draft`, `review_ready`, `approved`, or `deprecated`; other existing document schemas keep their established status vocabulary. `tags` is optional.
|
||||
- Do not add metadata that Git, the filesystem, or existing tooling can derive.
|
||||
- Preserve the format and metadata schema of existing documents. Do not add frontmatter, reformat, move, or normalize existing files unless the task explicitly requests migration.
|
||||
- `CLAUDE.md`, README files, and files with an existing schema keep their established format.
|
||||
|
||||
## Repository structure
|
||||
|
||||
- `src/lead_scoring/`: Lead Scoring production service package and CLI tools.
|
||||
- `alembic/`: database migrations for PostgreSQL.
|
||||
- `tests/`: test suite including health check, OpenAPI CLI, and migration lifecycle.
|
||||
- `docs/specs/lead-scoring-customer-pilot.md`: approved Lead Scoring Customer Pilot specification.
|
||||
- `docs/`: durable shared project documentation.
|
||||
- `stack.md`: technology decisions and unresolved stack questions.
|
||||
- `.claude/settings.json`: project hook configuration.
|
||||
- `.claude/hooks/post-edit-check.py`: Python-only PostToolUse feedback hook.
|
||||
|
||||
## Product and frontend boundary
|
||||
|
||||
The frontend boundary is approved for Customer Pilot: DotB-Omni owns all customer-facing Lead Scoring configuration and HITL UI. AI Platform may provide only an internal Dev Console for fixtures, contract testing, failure testing, and allowlisted integration verification; it must not duplicate Omni business UI.
|
||||
|
||||
When the internal Dev Console is implemented:
|
||||
|
||||
- Use Svelte 5 and TypeScript with Bun, Vite, and Vitest. Add SvelteKit only when routing, SSR, or deployment requires it.
|
||||
- Generate types from backend OpenAPI and consume them through `openapi-fetch`.
|
||||
- Keep shared transport concerns such as authentication and error handling outside generated code.
|
||||
- Use the official Svelte tooling, including `@sveltejs/mcp` autofixer feedback and `svelte-check`.
|
||||
- Use Playwright only for important end-to-end user flows.
|
||||
|
||||
TypeScript 7 native compiler and language server are pilot candidates, not the adopted project default. Test them project-locally before changing global runtimes or the shared `mcpls` configuration.
|
||||
|
||||
## Engineering principles
|
||||
|
||||
- Follow YAGNI. Implement only what the current requirement needs; do not add speculative abstractions, extension points, compatibility layers, or infrastructure.
|
||||
- Prefer the simplest design that preserves clear boundaries and can be changed safely.
|
||||
- Reuse the standard library and established, actively maintained packages when they reduce code and operational burden. Avoid dependencies for trivial behavior.
|
||||
- Keep code explicit. Use precise names, short control flow, and direct data transformations. Avoid cleverness, hidden side effects, global mutable state, and premature optimization.
|
||||
- Prefer composition over inheritance and functions over classes. Introduce a class only when state, lifecycle, or a framework contract genuinely requires one.
|
||||
- Keep a functional core with side effects at the boundaries. Isolate I/O, persistence, network calls, clocks, and environment access.
|
||||
- Organize modules by capability or domain responsibility, not by arbitrary technical layers.
|
||||
- Avoid wrappers that only rename another API. Every abstraction must remove meaningful complexity or enforce a real boundary.
|
||||
- Preserve one clear source of truth for contracts, configuration, and domain rules.
|
||||
|
||||
## AI-native code
|
||||
|
||||
Write code that humans and coding agents can understand with minimal hidden context:
|
||||
|
||||
- Make dependencies, inputs, outputs, errors, and side effects explicit.
|
||||
- Use typed, stable contracts at module and service boundaries.
|
||||
- Keep related implementation and tests easy to locate.
|
||||
- Prefer deterministic behavior and small composable functions.
|
||||
- Document decisions and non-obvious constraints, not syntax or line-by-line behavior.
|
||||
- Match existing conventions before introducing a new pattern.
|
||||
- Make the smallest coherent change and remove superseded code instead of leaving parallel implementations.
|
||||
|
||||
## Verified commands
|
||||
|
||||
- `uv sync`: sync workspace dependencies and environment.
|
||||
- `uv run pytest`: run pytest test suite against Python 3.13.
|
||||
- `uv run ruff check src tests`: run lint checks across source and tests.
|
||||
- `uv run ruff format --check src tests`: check code formatting.
|
||||
- `uv run pyright`: run Pyright static type checker.
|
||||
- `uv run ty check`: run project-local ty type checker.
|
||||
- `uv run generate-openapi`: deterministically generate root `openapi.json`.
|
||||
- `uv run generate-openapi --check`: verify that `openapi.json` is present and matches the current backend contract.
|
||||
- `uv run generate-webhook-contract`: deterministically generate terminal webhook contract pack.
|
||||
- `uv run generate-webhook-contract --check`: verify that webhook contract pack is present and matches current models.
|
||||
- `uv run generate-polling-contract`: deterministically generate scoring run polling contract pack.
|
||||
- `uv run generate-polling-contract --check`: verify that polling contract pack is present and matches current models.
|
||||
- `uv run alembic upgrade head`: apply database migrations up to head.
|
||||
- `uv run alembic downgrade base`: revert all database migrations.
|
||||
|
||||
## Deterministic generation
|
||||
|
||||
- When a verified project generator exists, use it instead of manually editing derived files.
|
||||
- Treat OpenAPI output and generated frontend types as derived artifacts. Regenerate them from the backend contract and require the authoritative verification command to detect drift.
|
||||
- Do not edit generated files manually unless the project workflow explicitly requires it.
|
||||
|
||||
## Safety and scope
|
||||
|
||||
- Inspect a path before overwriting or deleting it. Preserve existing configuration, documentation, code, and data unless the task explicitly authorizes removal or migration.
|
||||
- Do not expose secrets in logs, reports, handoffs, or generated artifacts.
|
||||
- Do not install packages, modify system configuration, or create permissions and background services without explicit approval.
|
||||
- Bootstrap production code only after the relevant product and implementation decision. Customer-facing Lead Scoring UI remains owned by DotB-Omni.
|
||||
|
||||
## Tooling and verification gotchas
|
||||
|
||||
- Generated OpenAPI and frontend build output must remain covered by the existing ignore policy and should not be edited manually.
|
||||
- The project PostToolUse hook is configured in `.claude/settings.json` for `Write` and `Edit`. It currently handles Python files only and invokes the existing `ruff` and `ty` tools. Preserve the pre-existing working-tree modification to `.claude/hooks/post-edit-check.py` unless explicitly asked to change or reset it.
|
||||
- Do not modify `/etc`, package-manager state, system services, drivers, kernel settings, global Node/TypeScript versions, or the shared `mcpls` configuration as part of repository work unless explicitly approved.
|
||||
|
||||
## Quality of life
|
||||
|
||||
Pyright LSP is available for semantic navigation. Use it for definitions, references, hover types, document and workspace symbols, and call hierarchy; then read the relevant implementation to understand business rules and side effects.
|
||||
@ -1,133 +0,0 @@
|
||||
# AI Platform - Lead Scoring Service
|
||||
|
||||
Lead Scoring service and internal Dev Console for the AI Platform.
|
||||
|
||||
## What runs locally
|
||||
|
||||
| Component | Purpose | Default address |
|
||||
| --- | --- | --- |
|
||||
| PostgreSQL 16 | Stores scoring runs and migrations | `localhost:5433` |
|
||||
| Lead Scoring API | REST API, async scoring worker, OpenAPI | `http://localhost:8000` |
|
||||
| Dev Console | Internal fixture and contract-testing UI | `http://localhost:5173` |
|
||||
| AIHub | Optional external AI inference dependency | `http://localhost:8080` |
|
||||
|
||||
The service still runs if local AIHub is unavailable: the default config uses `rules_only_review` fallback. A reachable AIHub and a valid tenant VKey are required for live AI scoring.
|
||||
|
||||
## Requirements
|
||||
|
||||
Install before starting:
|
||||
|
||||
- Git
|
||||
- Python 3.13 or newer
|
||||
- [uv](https://docs.astral.sh/uv/)
|
||||
- [Bun](https://bun.sh/)
|
||||
- Docker with Docker Compose plugin
|
||||
|
||||
## Start from a fresh clone
|
||||
|
||||
```bash
|
||||
git clone https://github.com/HungTran24/dotb_ai-platform.git
|
||||
cd dotb_ai-platform
|
||||
cp .env.example .env
|
||||
cp apps/dev-console/.env.example apps/dev-console/.env
|
||||
```
|
||||
|
||||
The copied `.env` files are local only and ignored by Git. Their example values are intentionally for development only.
|
||||
|
||||
### 1. Start PostgreSQL
|
||||
|
||||
```bash
|
||||
docker compose up -d postgres
|
||||
docker compose ps
|
||||
```
|
||||
|
||||
The included Compose service exposes PostgreSQL on port `5433` to avoid conflicting with other local projects. If port `5433` is occupied, choose another port and update both values before migration:
|
||||
|
||||
```bash
|
||||
POSTGRES_PORT=15432 docker compose up -d postgres
|
||||
# Then set DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:15432/lead_scoring in .env
|
||||
```
|
||||
|
||||
### 2. Install backend dependencies and migrate the database
|
||||
|
||||
```bash
|
||||
uv sync
|
||||
uv run alembic upgrade head
|
||||
```
|
||||
|
||||
### 3. Start the Lead Scoring API
|
||||
|
||||
Run in a separate terminal from the repository root:
|
||||
|
||||
```bash
|
||||
uv run uvicorn lead_scoring.main:app --reload --host 0.0.0.0 --port 8000
|
||||
```
|
||||
|
||||
Check the API:
|
||||
|
||||
```bash
|
||||
curl http://localhost:8000/health
|
||||
```
|
||||
|
||||
OpenAPI UI: `http://localhost:8000/docs`
|
||||
|
||||
### 4. Start the Dev Console
|
||||
|
||||
Run in a second terminal:
|
||||
|
||||
```bash
|
||||
cd apps/dev-console
|
||||
bun install --frozen-lockfile
|
||||
bun run dev
|
||||
```
|
||||
|
||||
Open `http://localhost:5173`.
|
||||
|
||||
## Local configuration
|
||||
|
||||
Root `.env` configures the backend. `apps/dev-console/.env` configures the Vite development server.
|
||||
|
||||
| Variable | Default | Purpose |
|
||||
| --- | --- | --- |
|
||||
| `DATABASE_URL` | PostgreSQL at `localhost:5433` | Backend database connection |
|
||||
| `AIHUB_BASE_URL` | `http://localhost:8080` | Optional AIHub endpoint |
|
||||
| `DEV_CONSOLE_API_URL` | `http://localhost:8000` | Dev Console API proxy target |
|
||||
| `DEV_CONSOLE_PORT` | `5173` | Dev Console development port |
|
||||
| `DEV_CONSOLE_PREVIEW_PORT` | `4173` | Dev Console preview port |
|
||||
| `DEV_CONSOLE_SERVICE_TOKEN` | `local-dev-token` | Must match the local backend `SERVICE_CALLERS` entry |
|
||||
| `DEV_CONSOLE_TEST_VKEY` | `local-test-vkey` | Forwarded only to AIHub calls; replace for real AIHub use |
|
||||
|
||||
The local backend template includes an allowlisted `dev-console` caller and baseline scoring config version `1.0.0`, so the Dev Console can open immediately after startup.
|
||||
|
||||
## Stop local services
|
||||
|
||||
```bash
|
||||
docker compose down
|
||||
```
|
||||
|
||||
To remove the local database data too:
|
||||
|
||||
```bash
|
||||
docker compose down -v
|
||||
```
|
||||
|
||||
## Verification
|
||||
|
||||
Backend:
|
||||
|
||||
```bash
|
||||
uv run pytest
|
||||
uv run ruff check src tests
|
||||
uv run ruff format --check src tests
|
||||
uv run pyright
|
||||
uv run ty check
|
||||
```
|
||||
|
||||
Frontend:
|
||||
|
||||
```bash
|
||||
cd apps/dev-console
|
||||
bun run check
|
||||
bun run test
|
||||
bun run build
|
||||
```
|
||||
@ -1,79 +0,0 @@
|
||||
# A generic, single database configuration.
|
||||
|
||||
[alembic]
|
||||
# path to migration scripts
|
||||
script_location = %(here)s/alembic
|
||||
|
||||
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
|
||||
# file_template = %%(rev)s_%%(slug)s
|
||||
|
||||
# sys.path path, will be prepended to sys.path if present.
|
||||
prepend_sys_path = %(here)s
|
||||
path_separator = os
|
||||
|
||||
# timezone to use when rendering the date within the migration file
|
||||
# as well as the filename.
|
||||
# string value is passed to dateutil.tz.gettz()
|
||||
# leave blank for localtime
|
||||
# timezone =
|
||||
|
||||
# max length of characters to apply to the
|
||||
# "slug" field
|
||||
# truncate_slug_length = 40
|
||||
|
||||
# set to 'true' to run the environment during
|
||||
# the 'revision' command, regardless of autogenerate
|
||||
# revision_environment = false
|
||||
|
||||
# set to 'true' to allow .pyc and .pyo files without
|
||||
# a source .py file to be detected as revisions in the
|
||||
# versions/ directory
|
||||
# sourceless = false
|
||||
|
||||
# version location specification; This defaults
|
||||
# to alembic/versions. When using multiple version
|
||||
# directories, initial revisions must be specified with --version-path.
|
||||
# The path separator used here should be the separator specified by "version_path_separator"
|
||||
# version_locations = %(here)s/bar:%(here)s/bat:alembic/versions
|
||||
|
||||
# version path separator
|
||||
version_path_separator = os
|
||||
|
||||
# the output encoding used when revision files
|
||||
# are written from script.py.mako
|
||||
# output_encoding = utf-8
|
||||
|
||||
# Logging configuration
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARN
|
||||
handlers = console
|
||||
qualname =
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARN
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
datefmt = %H:%M:%S
|
||||
@ -1,83 +0,0 @@
|
||||
import asyncio
|
||||
from logging.config import fileConfig
|
||||
|
||||
from alembic import context
|
||||
from sqlalchemy import pool
|
||||
from sqlalchemy.engine import Connection
|
||||
from sqlalchemy.ext.asyncio import create_async_engine
|
||||
|
||||
from lead_scoring.config import Settings
|
||||
from lead_scoring.db import Base
|
||||
|
||||
# this is the Alembic Config object, which provides
|
||||
# access to the values within the .ini file in use.
|
||||
config = context.config
|
||||
|
||||
# Interpret the config file for Python logging.
|
||||
if config.config_file_name is not None:
|
||||
fileConfig(config.config_file_name)
|
||||
|
||||
# add your model's MetaData object here
|
||||
# for 'autogenerate' support
|
||||
target_metadata = Base.metadata
|
||||
|
||||
|
||||
def get_database_url() -> str:
|
||||
attr_url = config.attributes.get("sqlalchemy_url")
|
||||
if attr_url:
|
||||
return str(attr_url)
|
||||
opt_url = config.get_main_option("sqlalchemy.url")
|
||||
if opt_url:
|
||||
return opt_url
|
||||
return Settings().database_url
|
||||
|
||||
|
||||
def run_migrations_offline() -> None:
|
||||
"""Run migrations in 'offline' mode."""
|
||||
url = get_database_url()
|
||||
context.configure(
|
||||
url=url,
|
||||
target_metadata=target_metadata,
|
||||
literal_binds=True,
|
||||
dialect_opts={"paramstyle": "named"},
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def do_run_migrations(connection: Connection) -> None:
|
||||
context.configure(connection=connection, target_metadata=target_metadata)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
async def run_async_migrations() -> None:
|
||||
"""Run migrations in async engine scenario."""
|
||||
url = get_database_url()
|
||||
connectable = create_async_engine(
|
||||
url,
|
||||
poolclass=pool.NullPool,
|
||||
)
|
||||
|
||||
async with connectable.connect() as connection:
|
||||
await connection.run_sync(do_run_migrations)
|
||||
|
||||
await connectable.dispose()
|
||||
|
||||
|
||||
def run_migrations_online() -> None:
|
||||
"""Run migrations in 'online' mode."""
|
||||
connectable = config.attributes.get("connection", None)
|
||||
|
||||
if connectable is not None:
|
||||
do_run_migrations(connectable)
|
||||
else:
|
||||
asyncio.run(run_async_migrations())
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
||||
@ -1,26 +0,0 @@
|
||||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
|
||||
"""
|
||||
from collections.abc import Sequence
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
${imports if imports else ""}
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = ${repr(up_revision)}
|
||||
down_revision: str | None = ${repr(down_revision)}
|
||||
branch_labels: str | Sequence[str] | None = ${repr(branch_labels)}
|
||||
depends_on: str | Sequence[str] | None = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
${downgrades if downgrades else "pass"}
|
||||
@ -1,21 +0,0 @@
|
||||
"""Initial baseline migration.
|
||||
|
||||
Revision ID: 0001_baseline
|
||||
Revises:
|
||||
Create Date: 2026-08-27
|
||||
|
||||
"""
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "0001_baseline"
|
||||
down_revision: str | None = None
|
||||
branch_labels: str | None = None
|
||||
depends_on: str | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
pass
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
pass
|
||||
@ -1,53 +0,0 @@
|
||||
"""Create scoring_runs table.
|
||||
|
||||
Revision ID: 0002_create_scoring_runs
|
||||
Revises: 0001_baseline
|
||||
Create Date: 2026-08-27
|
||||
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "0002_create_scoring_runs"
|
||||
down_revision: str | None = "0001_baseline"
|
||||
branch_labels: str | None = None
|
||||
depends_on: str | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"scoring_runs",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("tenant_id", sa.String(length=255), nullable=False),
|
||||
sa.Column("subject_ref", sa.String(length=255), nullable=False),
|
||||
sa.Column("scoring_config_version", sa.String(length=64), nullable=False),
|
||||
sa.Column("idempotency_key", sa.String(length=255), nullable=False),
|
||||
sa.Column("payload_hash", sa.String(length=64), nullable=False),
|
||||
sa.Column("status", sa.String(length=32), nullable=False, server_default="pending"),
|
||||
sa.Column("snapshot_data", postgresql.JSONB(astext_type=sa.Text()), nullable=False),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.text("now()"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.text("now()"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint(
|
||||
"tenant_id",
|
||||
"idempotency_key",
|
||||
name="uq_scoring_runs_tenant_idempotency",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("scoring_runs")
|
||||
@ -1,59 +0,0 @@
|
||||
"""Add rule-only execution state and transactional outbox.
|
||||
|
||||
Revision ID: 0003_rule_only_execution
|
||||
Revises: 0002_create_scoring_runs
|
||||
Create Date: 2026-08-27
|
||||
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
revision: str = "0003_rule_only_execution"
|
||||
down_revision: str | None = "0002_create_scoring_runs"
|
||||
branch_labels: str | None = None
|
||||
depends_on: str | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("scoring_runs", sa.Column("effective_config", postgresql.JSONB(), nullable=True))
|
||||
op.add_column("scoring_runs", sa.Column("confidence_threshold", sa.Float(), nullable=True))
|
||||
op.add_column("scoring_runs", sa.Column("rule_score", sa.Integer(), nullable=True))
|
||||
op.add_column("scoring_runs", sa.Column("confidence", sa.Float(), nullable=True))
|
||||
op.add_column("scoring_runs", sa.Column("error_code", sa.String(length=64), nullable=True))
|
||||
op.add_column("scoring_runs", sa.Column("retryable", sa.Boolean(), nullable=True))
|
||||
op.add_column(
|
||||
"scoring_runs", sa.Column("started_at", sa.DateTime(timezone=True), nullable=True)
|
||||
)
|
||||
op.add_column(
|
||||
"scoring_runs", sa.Column("completed_at", sa.DateTime(timezone=True), nullable=True)
|
||||
)
|
||||
op.add_column(
|
||||
"scoring_runs", sa.Column("lease_expires_at", sa.DateTime(timezone=True), nullable=True)
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"outbox_events",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("event_type", sa.String(length=128), nullable=False),
|
||||
sa.Column("event_version", sa.String(length=32), nullable=False),
|
||||
sa.Column("run_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("payload", postgresql.JSONB(), nullable=False),
|
||||
sa.Column("occurred_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("run_id", "event_type", name="uq_outbox_run_event_type"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("outbox_events")
|
||||
op.drop_column("scoring_runs", "lease_expires_at")
|
||||
op.drop_column("scoring_runs", "completed_at")
|
||||
op.drop_column("scoring_runs", "started_at")
|
||||
op.drop_column("scoring_runs", "retryable")
|
||||
op.drop_column("scoring_runs", "error_code")
|
||||
op.drop_column("scoring_runs", "confidence")
|
||||
op.drop_column("scoring_runs", "rule_score")
|
||||
op.drop_column("scoring_runs", "confidence_threshold")
|
||||
op.drop_column("scoring_runs", "effective_config")
|
||||
@ -1,32 +0,0 @@
|
||||
"""Add medium AI pipeline scoring fields and audit metadata.
|
||||
|
||||
Revision ID: 0004_medium_ai_pipeline
|
||||
Revises: 0003_rule_only_execution
|
||||
Create Date: 2026-08-27
|
||||
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
revision: str = "0004_medium_ai_pipeline"
|
||||
down_revision: str | None = "0003_rule_only_execution"
|
||||
branch_labels: str | None = None
|
||||
depends_on: str | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("scoring_runs", sa.Column("ai_score", sa.Integer(), nullable=True))
|
||||
op.add_column("scoring_runs", sa.Column("final_score", sa.Integer(), nullable=True))
|
||||
op.add_column("scoring_runs", sa.Column("scoring_mode", sa.String(length=32), nullable=True))
|
||||
op.add_column("scoring_runs", sa.Column("degraded", sa.Boolean(), nullable=True))
|
||||
op.add_column("scoring_runs", sa.Column("ai_metadata", postgresql.JSONB(), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("scoring_runs", "ai_metadata")
|
||||
op.drop_column("scoring_runs", "degraded")
|
||||
op.drop_column("scoring_runs", "scoring_mode")
|
||||
op.drop_column("scoring_runs", "final_score")
|
||||
op.drop_column("scoring_runs", "ai_score")
|
||||
@ -1,87 +0,0 @@
|
||||
"""Add review API fields and review audits table.
|
||||
|
||||
Revision ID: 0005_review_api_and_audit
|
||||
Revises: 0004_medium_ai_pipeline
|
||||
Create Date: 2026-08-28
|
||||
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0005_review_api_and_audit"
|
||||
down_revision: str | None = "0004_medium_ai_pipeline"
|
||||
branch_labels: str | None = None
|
||||
depends_on: str | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"scoring_runs",
|
||||
sa.Column("version", sa.Integer(), nullable=False, server_default="1"),
|
||||
)
|
||||
op.add_column("scoring_runs", sa.Column("effective_score", sa.Integer(), nullable=True))
|
||||
op.add_column("scoring_runs", sa.Column("review_decision", sa.String(length=32), nullable=True))
|
||||
op.add_column("scoring_runs", sa.Column("reviewed_by", sa.String(length=255), nullable=True))
|
||||
op.add_column(
|
||||
"scoring_runs", sa.Column("reviewed_at", sa.DateTime(timezone=True), nullable=True)
|
||||
)
|
||||
op.add_column("scoring_runs", sa.Column("review_note", sa.String(length=2000), nullable=True))
|
||||
|
||||
op.drop_constraint("uq_outbox_run_event_type", "outbox_events", type_="unique")
|
||||
op.create_index(
|
||||
"uq_outbox_terminal_run",
|
||||
"outbox_events",
|
||||
["run_id", "event_type"],
|
||||
unique=True,
|
||||
postgresql_where=sa.text("event_type = 'lead_scoring.run.terminal'"),
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"review_audits",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("run_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("tenant_id", sa.String(length=255), nullable=False),
|
||||
sa.Column("decision", sa.String(length=32), nullable=False),
|
||||
sa.Column("actor_id", sa.String(length=255), nullable=False),
|
||||
sa.Column("actor_role", sa.String(length=32), nullable=False),
|
||||
sa.Column("idempotency_key", sa.String(length=255), nullable=False),
|
||||
sa.Column("payload_hash", sa.String(length=64), nullable=False),
|
||||
sa.Column("previous_version", sa.Integer(), nullable=False),
|
||||
sa.Column("new_version", sa.Integer(), nullable=False),
|
||||
sa.Column("calculated_score", sa.Integer(), nullable=True),
|
||||
sa.Column("effective_score", sa.Integer(), nullable=False),
|
||||
sa.Column("note", sa.String(length=2000), nullable=True),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.text("now()"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint(
|
||||
"tenant_id",
|
||||
"idempotency_key",
|
||||
name="uq_review_audits_tenant_idempotency",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("review_audits")
|
||||
op.drop_index(
|
||||
"uq_outbox_terminal_run",
|
||||
"outbox_events",
|
||||
postgresql_where=sa.text("event_type = 'lead_scoring.run.terminal'"),
|
||||
)
|
||||
# Clean up non-terminal review outbox events before restoring pre-0005 unique constraint
|
||||
op.execute(sa.text("DELETE FROM outbox_events WHERE event_type = 'lead_scoring.run.reviewed'"))
|
||||
op.create_unique_constraint(
|
||||
"uq_outbox_run_event_type", "outbox_events", ["run_id", "event_type"]
|
||||
)
|
||||
op.drop_column("scoring_runs", "review_note")
|
||||
op.drop_column("scoring_runs", "reviewed_at")
|
||||
op.drop_column("scoring_runs", "reviewed_by")
|
||||
op.drop_column("scoring_runs", "review_decision")
|
||||
op.drop_column("scoring_runs", "effective_score")
|
||||
op.drop_column("scoring_runs", "version")
|
||||
@ -1,57 +0,0 @@
|
||||
"""Add parent_run_id lineage and child_run_id audit for re-evaluate.
|
||||
|
||||
Revision ID: 0006_reevaluate_child_lineage
|
||||
Revises: 0005_review_api_and_audit
|
||||
Create Date: 2026-08-28
|
||||
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0006_reevaluate_child_lineage"
|
||||
down_revision: str | None = "0005_review_api_and_audit"
|
||||
branch_labels: str | None = None
|
||||
depends_on: str | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"scoring_runs",
|
||||
sa.Column(
|
||||
"parent_run_id",
|
||||
sa.String(length=36),
|
||||
sa.ForeignKey("scoring_runs.id"),
|
||||
nullable=True,
|
||||
),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_scoring_runs_parent_run_id",
|
||||
"scoring_runs",
|
||||
["parent_run_id"],
|
||||
)
|
||||
op.add_column(
|
||||
"review_audits",
|
||||
sa.Column("child_run_id", sa.String(length=36), nullable=True),
|
||||
)
|
||||
op.alter_column(
|
||||
"review_audits",
|
||||
"effective_score",
|
||||
existing_type=sa.Integer(),
|
||||
nullable=True,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute(
|
||||
sa.text("UPDATE review_audits SET effective_score = 0 WHERE effective_score IS NULL")
|
||||
)
|
||||
op.alter_column(
|
||||
"review_audits",
|
||||
"effective_score",
|
||||
existing_type=sa.Integer(),
|
||||
nullable=False,
|
||||
)
|
||||
op.drop_column("review_audits", "child_run_id")
|
||||
op.drop_index("ix_scoring_runs_parent_run_id", table_name="scoring_runs")
|
||||
op.drop_column("scoring_runs", "parent_run_id")
|
||||
@ -1,87 +0,0 @@
|
||||
"""Add outbox delivery state, retry and lease tracking.
|
||||
|
||||
Revision ID: 0007_outbox_delivery_state
|
||||
Revises: 0006_reevaluate_child_lineage
|
||||
Create Date: 2026-08-28
|
||||
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0007_outbox_delivery_state"
|
||||
down_revision: str | None = "0006_reevaluate_child_lineage"
|
||||
branch_labels: str | None = None
|
||||
depends_on: str | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"outbox_events",
|
||||
sa.Column(
|
||||
"delivery_status",
|
||||
sa.String(length=32),
|
||||
nullable=False,
|
||||
server_default="pending",
|
||||
),
|
||||
)
|
||||
op.add_column(
|
||||
"outbox_events",
|
||||
sa.Column(
|
||||
"attempt_count",
|
||||
sa.Integer(),
|
||||
nullable=False,
|
||||
server_default="0",
|
||||
),
|
||||
)
|
||||
op.add_column(
|
||||
"outbox_events",
|
||||
sa.Column("next_attempt_at", sa.DateTime(timezone=True), nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
"outbox_events",
|
||||
sa.Column("last_attempt_at", sa.DateTime(timezone=True), nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
"outbox_events",
|
||||
sa.Column("delivered_at", sa.DateTime(timezone=True), nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
"outbox_events",
|
||||
sa.Column("lease_expires_at", sa.DateTime(timezone=True), nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
"outbox_events",
|
||||
sa.Column("claim_token", sa.String(length=36), nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
"outbox_events",
|
||||
sa.Column("last_error_code", sa.String(length=64), nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
"outbox_events",
|
||||
sa.Column("last_error_detail", sa.String(length=2000), nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
"outbox_events",
|
||||
sa.Column("last_http_status", sa.Integer(), nullable=True),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_outbox_events_delivery_claim",
|
||||
"outbox_events",
|
||||
["delivery_status", "next_attempt_at"],
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_outbox_events_delivery_claim", table_name="outbox_events")
|
||||
op.drop_column("outbox_events", "last_http_status")
|
||||
op.drop_column("outbox_events", "last_error_detail")
|
||||
op.drop_column("outbox_events", "last_error_code")
|
||||
op.drop_column("outbox_events", "lease_expires_at")
|
||||
op.drop_column("outbox_events", "claim_token")
|
||||
op.drop_column("outbox_events", "delivered_at")
|
||||
op.drop_column("outbox_events", "last_attempt_at")
|
||||
op.drop_column("outbox_events", "next_attempt_at")
|
||||
op.drop_column("outbox_events", "attempt_count")
|
||||
op.drop_column("outbox_events", "delivery_status")
|
||||
@ -1,45 +0,0 @@
|
||||
"""Add retention and redaction state to scoring_runs and review_audits.
|
||||
|
||||
Revision ID: 0008_retention_redaction_state
|
||||
Revises: 0007_outbox_delivery_state
|
||||
Create Date: 2026-08-28
|
||||
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0008_retention_redaction_state"
|
||||
down_revision: str | None = "0007_outbox_delivery_state"
|
||||
branch_labels: str | None = None
|
||||
depends_on: str | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"scoring_runs",
|
||||
sa.Column("redacted_at", sa.DateTime(timezone=True), nullable=True),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_scoring_runs_retention",
|
||||
"scoring_runs",
|
||||
["created_at", "redacted_at"],
|
||||
)
|
||||
|
||||
op.add_column(
|
||||
"review_audits",
|
||||
sa.Column("redacted_at", sa.DateTime(timezone=True), nullable=True),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_review_audits_retention",
|
||||
"review_audits",
|
||||
["created_at", "redacted_at"],
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_review_audits_retention", table_name="review_audits")
|
||||
op.drop_column("review_audits", "redacted_at")
|
||||
|
||||
op.drop_index("ix_scoring_runs_retention", table_name="scoring_runs")
|
||||
op.drop_column("scoring_runs", "redacted_at")
|
||||
@ -1,27 +0,0 @@
|
||||
"""Add execution_trace to scoring_runs.
|
||||
|
||||
Revision ID: 0009_execution_trace
|
||||
Revises: 0008_retention_redaction_state
|
||||
Create Date: 2026-09-04
|
||||
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
revision: str = "0009_execution_trace"
|
||||
down_revision: str | None = "0008_retention_redaction_state"
|
||||
branch_labels: str | None = None
|
||||
depends_on: str | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"scoring_runs",
|
||||
sa.Column("execution_trace", postgresql.JSONB(astext_type=sa.Text()), nullable=True),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("scoring_runs", "execution_trace")
|
||||
@ -1,25 +0,0 @@
|
||||
"""Persist originating DotB headers for AIHub forwarding.
|
||||
|
||||
Revision ID: 0010_dotb_headers
|
||||
Revises: 0009_execution_trace
|
||||
Create Date: 2026-09-07
|
||||
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0010_dotb_headers"
|
||||
down_revision: str | None = "0009_execution_trace"
|
||||
branch_labels: str | None = None
|
||||
depends_on: str | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("scoring_runs", sa.Column("dotb_app", sa.String(length=64), nullable=True))
|
||||
op.add_column("scoring_runs", sa.Column("dotb_feature", sa.String(length=64), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("scoring_runs", "dotb_feature")
|
||||
op.drop_column("scoring_runs", "dotb_app")
|
||||
@ -1,7 +0,0 @@
|
||||
DEV_CONSOLE_API_URL=http://localhost:8000
|
||||
DEV_CONSOLE_PORT=5173
|
||||
DEV_CONSOLE_PREVIEW_PORT=4173
|
||||
# Must match SERVICE_CALLERS in the root .env for the local setup.
|
||||
DEV_CONSOLE_SERVICE_TOKEN=local-dev-token
|
||||
# Used only to call a local AIHub. Any non-empty value exercises rules-only fallback when AIHub is absent.
|
||||
DEV_CONSOLE_TEST_VKEY=local-test-vkey
|
||||
@ -1,326 +0,0 @@
|
||||
{
|
||||
"lockfileVersion": 2,
|
||||
"configVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "dev-console",
|
||||
"dependencies": {
|
||||
"openapi-fetch": "^0.13.4",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/vite-plugin-svelte": "^4.0.4",
|
||||
"openapi-typescript": "^7.4.4",
|
||||
"svelte": "^5.2.7",
|
||||
"svelte-check": "^4.1.1",
|
||||
"typescript": "^5.7.2",
|
||||
"vite": "^5.4.11",
|
||||
"vitest": "^2.1.8",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@babel/code-frame": ["@babel/code-frame@7.29.7", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="],
|
||||
|
||||
"@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="],
|
||||
|
||||
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.21.5", "", { "os": "aix", "cpu": "ppc64" }, "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ=="],
|
||||
|
||||
"@esbuild/android-arm": ["@esbuild/android-arm@0.21.5", "", { "os": "android", "cpu": "arm" }, "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg=="],
|
||||
|
||||
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.21.5", "", { "os": "android", "cpu": "arm64" }, "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A=="],
|
||||
|
||||
"@esbuild/android-x64": ["@esbuild/android-x64@0.21.5", "", { "os": "android", "cpu": "x64" }, "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA=="],
|
||||
|
||||
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.21.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ=="],
|
||||
|
||||
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.21.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw=="],
|
||||
|
||||
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.21.5", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g=="],
|
||||
|
||||
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.21.5", "", { "os": "freebsd", "cpu": "x64" }, "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ=="],
|
||||
|
||||
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.21.5", "", { "os": "linux", "cpu": "arm" }, "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA=="],
|
||||
|
||||
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.21.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q=="],
|
||||
|
||||
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.21.5", "", { "os": "linux", "cpu": "ia32" }, "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg=="],
|
||||
|
||||
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg=="],
|
||||
|
||||
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg=="],
|
||||
|
||||
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.21.5", "", { "os": "linux", "cpu": "ppc64" }, "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w=="],
|
||||
|
||||
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA=="],
|
||||
|
||||
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.21.5", "", { "os": "linux", "cpu": "s390x" }, "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A=="],
|
||||
|
||||
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.21.5", "", { "os": "linux", "cpu": "x64" }, "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ=="],
|
||||
|
||||
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.21.5", "", { "os": "none", "cpu": "x64" }, "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg=="],
|
||||
|
||||
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.21.5", "", { "os": "openbsd", "cpu": "x64" }, "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow=="],
|
||||
|
||||
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.21.5", "", { "os": "sunos", "cpu": "x64" }, "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg=="],
|
||||
|
||||
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.21.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A=="],
|
||||
|
||||
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.21.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA=="],
|
||||
|
||||
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.21.5", "", { "os": "win32", "cpu": "x64" }, "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw=="],
|
||||
|
||||
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
|
||||
|
||||
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
|
||||
|
||||
"@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
|
||||
|
||||
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
|
||||
|
||||
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
|
||||
|
||||
"@napi-rs/lzma-linux-x64-gnu": ["@napi-rs/lzma-linux-x64-gnu@1.5.1", "", { "os": "linux", "cpu": "x64" }, "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ=="],
|
||||
|
||||
"@redocly/ajv": ["@redocly/ajv@8.11.2", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2", "uri-js-replace": "^1.0.1" } }, "sha512-io1JpnwtIcvojV7QKDUSIuMN/ikdOUd1ReEnUnMKGfDVridQZ31J0MmIuqwuRjWDZfmvr+Q0MqCcfHM2gTivOg=="],
|
||||
|
||||
"@redocly/config": ["@redocly/config@0.22.0", "", {}, "sha512-gAy93Ddo01Z3bHuVdPWfCwzgfaYgMdaZPcfL7JZ7hWJoK9V0lXDbigTWkhiPFAaLWzbOJ+kbUQG1+XwIm0KRGQ=="],
|
||||
|
||||
"@redocly/openapi-core": ["@redocly/openapi-core@1.34.19", "", { "dependencies": { "@redocly/ajv": "8.11.2", "@redocly/config": "0.22.0", "colorette": "1.4.0", "https-proxy-agent": "7.0.6", "js-levenshtein": "1.1.6", "js-yaml": "4.3.1", "minimatch": "5.1.9", "pluralize": "8.0.0", "yaml-ast-parser": "0.0.43" } }, "sha512-o/0VgsBXgwcY1lyeqcVtSGdTQAPnVggo0fbFVPlxl5XVDKUcVH0OLRqt3CbkwByT5FU305E0iE0O7MzThjDblw=="],
|
||||
|
||||
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.63.0", "", { "os": "android", "cpu": "arm" }, "sha512-70TeIFezKKy65LgAVyQh+w94/gjWhvPWaLaGGeMEgVrPkQhuj/M5bAYYZzIFUj9Y69oHyTm5Um/R6gcLh4A8JA=="],
|
||||
|
||||
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.63.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YC86tYIHK6M1IV+wbzO+Bxk8RCBr6ZyWYgWxUCzaZD8mc8rrFoIJDNzDrkHBYRc/wKdrsIXmm6/F7NzrAO+OrA=="],
|
||||
|
||||
"@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.63.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-oI+ECtUcli0y0fi4xpW82GdPIXdTkI8G8DSjG2LRuw09fPAGykaWYH/hXxiKuTxiAjiPSTIIuYUqof5Z2hShWw=="],
|
||||
|
||||
"@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.63.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-NwV+1s7TiKrMe4owHyKB/dTLD7ZJD0YEBEhIz+hvav1Cu1GReJjF+rsdNwjzENQeIAbE/CoNiaAc5Vz2h5DPAA=="],
|
||||
|
||||
"@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.63.0", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-tWtHBTu5gOPK4u4Urtk4qAHW3zZ9rQAmbssO8gp7ELvGTGI3aCiq6NqyTQ0PCIg7KbHJF2UkGDDs77YZGxfjCA=="],
|
||||
|
||||
"@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.63.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-2qPoJiwTvtHQ27NnYvTnsgk8laXWYuVmNESG8WFZBcEPKLfZ3I27qBJarjVRQtwGeYyRfq5ZowHXih9lm2BItw=="],
|
||||
|
||||
"@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.63.0", "", { "os": "linux", "cpu": "arm" }, "sha512-FQwsTRvLNuHoTdICABJQfbPUSEueISGmnpT06tXTMpfprf5NiKLSXKA0A+w45wJnCmZAnzgqBwbt6ARFuyOi5w=="],
|
||||
|
||||
"@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.63.0", "", { "os": "linux", "cpu": "arm" }, "sha512-BBVTXziw8mY1a4ZbWME9tZyfzqXCDPqaC7Z3heQ29p5dkvXzwL0NwelO8zLa8c3RBKvl3YTuSnBgsBhYBtwjIw=="],
|
||||
|
||||
"@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.63.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-w2Iyy9+RqKwx3d9qWMKsJg0FfRBsY0/pXNv0mCQ3ueRvJI6+QAScfD4nrMlzFLs2HNVW6Ew+mtZfDl9b7Ew5/Q=="],
|
||||
|
||||
"@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.63.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-YK++KtrFRHYE0P6/RtYEAy9t8F37znP+K03RrIuLPYOL6SVlObRumf/0OE4V/h63xL9DwkWbNssZfmA9hawuDA=="],
|
||||
|
||||
"@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.63.0", "", { "os": "linux", "cpu": "none" }, "sha512-aBfOG6fP7YkkPmTqPwufRJeFyz7WPpECv9XNbnsk9+vg7rxdih0lbtEel7jcRng4LZrrmU3FfitCFyEj4BWDWg=="],
|
||||
|
||||
"@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.63.0", "", { "os": "linux", "cpu": "none" }, "sha512-LGaHEOeHNAag9VuS1Crs5DFg4RrU9MPi2nVnNJk9DTePx/B6RRYKVmrIXt2h7YOJlwjaFJ6lwtFDliZxScTLrQ=="],
|
||||
|
||||
"@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.63.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-jClvk+J0FC3b7Udvegiw5/4hErbHtmsNsQgENnKXDWtNCJXsJYZH5WURvu7imDOO38xYml24eeh5x3A04ppwCw=="],
|
||||
|
||||
"@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.63.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-0OJlaGK+8+B777Ql5okIpD7ua5Ro9+VB9Ve0OKa28OQJZ1RbuUBVNHK/e3pr4BROqsyPl1JrPO1ZxJseCNffcA=="],
|
||||
|
||||
"@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.63.0", "", { "os": "linux", "cpu": "none" }, "sha512-Ygsx+HoNH7afwi1bTIXbnTvVnsO+zurPLSYxybV1hHFVU72OWOCl6v05ql/z0hkpAPx+DK7Kn9Bi7MayCcjLTA=="],
|
||||
|
||||
"@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.63.0", "", { "os": "linux", "cpu": "none" }, "sha512-pDQxtMGb+OvG3fLwR2OkZlSd47hW+kWg4BYMG/++sR6RqorQccwPTDsxda5hPwiIeIErAnCF9ma3SAU06bdQtQ=="],
|
||||
|
||||
"@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.63.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-0BnUG9mS8I4SSHr3XsxVhuCMEiu+rX61xxZF5vujso4LaiAGFZFxvDjg6Xn6tLPNTUAfuCvQYas4LMQMVsKRSQ=="],
|
||||
|
||||
"@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.63.0", "", { "os": "linux", "cpu": "x64" }, "sha512-Adu/VttB1dpPNW+FEacrZ+xVm9tFty84+RrFzsqlFaPxoJB+9XXyDGtp5dCOoBwGBIEVH0To7lExFXEx0BIF4A=="],
|
||||
|
||||
"@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.63.0", "", { "os": "linux", "cpu": "x64" }, "sha512-NQ3bDvjUbFKmP23671xUlXtKmqVsUBd6M4PQCvbmNtOy06hnQIdKHy8oG/6S3R/S6He1JgPk6A5VT+prAJMYEw=="],
|
||||
|
||||
"@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.63.0", "", { "os": "openbsd", "cpu": "x64" }, "sha512-u2eDAl4+0aFvA13GxlGBtTI3SS3sdgwgtV0HyjZ0QaQVCgNE+jqNGey+GtxWiq+wxr/UycAx/OnfJzApCFamvA=="],
|
||||
|
||||
"@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.63.0", "", { "os": "none", "cpu": "arm64" }, "sha512-XvRb5vfW3wAZQ+ZUG21AnHHDKtNcw99eigzEhjr//NZ3u7SoBaPP0seSc7FgP7p1epAEdAoZckMW9WY/+4w70w=="],
|
||||
|
||||
"@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.63.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-iZPmniy4kNBf5yo2RezbkYNNK5HPbXE9+g+twnbqSng7dtLEJy1SKoxiE/ni4FDacjyuZpEeb9U054N4EoKHYw=="],
|
||||
|
||||
"@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.63.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-mFBBd+LF37fnE8JnYUOH+imj0aPFPK30vpar4ehJkgnLj9sZn8ZxiRENmLtgIwxK7TC8klF6N57fxdNBwQoqOA=="],
|
||||
|
||||
"@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.63.0", "", { "os": "win32", "cpu": "x64" }, "sha512-ujeqEY3B+zbGn3Z4Q03cUBG/LGWnBJncVT36WER31LcOsQk9+1dmINKKtvmmfChUvRbK1G0R8OhMWFgHgaZtAw=="],
|
||||
|
||||
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.63.0", "", { "os": "win32", "cpu": "x64" }, "sha512-hncn90N4sOky0L2LKE5oESKLbxCPeVo4eLA2LSMoDzM+879ml4WSr+Rr4DWknNIVVvS1Hirkc9hx02W6YxS8rQ=="],
|
||||
|
||||
"@sveltejs/acorn-typescript": ["@sveltejs/acorn-typescript@1.0.13", "", { "peerDependencies": { "acorn": "^8.9.0" } }, "sha512-wgKggnhZVL9Bfx1OaKKTrYY9BFRk6C8UAkQNUcIv1+llzYrIqy+RZm5HPKzn0NpEBvTVhTqB4kQyllZywsRBRQ=="],
|
||||
|
||||
"@sveltejs/load-config": ["@sveltejs/load-config@0.2.3", "", {}, "sha512-VT3qmUb8pRV2QrZjd8iAmtg8lf4W0TIjZbvXtz5MKei/q96teWZgGJyyidJzOjzZzvdq616eSRVeMYIQChUTAQ=="],
|
||||
|
||||
"@sveltejs/vite-plugin-svelte": ["@sveltejs/vite-plugin-svelte@4.0.4", "", { "dependencies": { "@sveltejs/vite-plugin-svelte-inspector": "^3.0.0-next.0||^3.0.0", "debug": "^4.3.7", "deepmerge": "^4.3.1", "kleur": "^4.1.5", "magic-string": "^0.30.12", "vitefu": "^1.0.3" }, "peerDependencies": { "svelte": "^5.0.0-next.96 || ^5.0.0", "vite": "^5.0.0" } }, "sha512-0ba1RQ/PHen5FGpdSrW7Y3fAMQjrXantECALeOiOdBdzR5+5vPP6HVZRLmZaQL+W8m++o+haIAKq5qT+MiZ7VA=="],
|
||||
|
||||
"@sveltejs/vite-plugin-svelte-inspector": ["@sveltejs/vite-plugin-svelte-inspector@3.0.1", "", { "dependencies": { "debug": "^4.3.7" }, "peerDependencies": { "@sveltejs/vite-plugin-svelte": "^4.0.0-next.0||^4.0.0", "svelte": "^5.0.0-next.96 || ^5.0.0", "vite": "^5.0.0" } }, "sha512-2CKypmj1sM4GE7HjllT7UKmo4Q6L5xFRd7VMGEWhYnZ+wc6AUVU01IBd7yUi6WnFndEwWoMNOd6e8UjoN0nbvQ=="],
|
||||
|
||||
"@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="],
|
||||
|
||||
"@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="],
|
||||
|
||||
"@vitest/expect": ["@vitest/expect@2.1.9", "", { "dependencies": { "@vitest/spy": "2.1.9", "@vitest/utils": "2.1.9", "chai": "^5.1.2", "tinyrainbow": "^1.2.0" } }, "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw=="],
|
||||
|
||||
"@vitest/mocker": ["@vitest/mocker@2.1.9", "", { "dependencies": { "@vitest/spy": "2.1.9", "estree-walker": "^3.0.3", "magic-string": "^0.30.12" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^5.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg=="],
|
||||
|
||||
"@vitest/pretty-format": ["@vitest/pretty-format@2.1.9", "", { "dependencies": { "tinyrainbow": "^1.2.0" } }, "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ=="],
|
||||
|
||||
"@vitest/runner": ["@vitest/runner@2.1.9", "", { "dependencies": { "@vitest/utils": "2.1.9", "pathe": "^1.1.2" } }, "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g=="],
|
||||
|
||||
"@vitest/snapshot": ["@vitest/snapshot@2.1.9", "", { "dependencies": { "@vitest/pretty-format": "2.1.9", "magic-string": "^0.30.12", "pathe": "^1.1.2" } }, "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ=="],
|
||||
|
||||
"@vitest/spy": ["@vitest/spy@2.1.9", "", { "dependencies": { "tinyspy": "^3.0.2" } }, "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ=="],
|
||||
|
||||
"@vitest/utils": ["@vitest/utils@2.1.9", "", { "dependencies": { "@vitest/pretty-format": "2.1.9", "loupe": "^3.1.2", "tinyrainbow": "^1.2.0" } }, "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ=="],
|
||||
|
||||
"acorn": ["acorn@8.18.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ=="],
|
||||
|
||||
"agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
|
||||
|
||||
"ansi-colors": ["ansi-colors@4.1.3", "", {}, "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw=="],
|
||||
|
||||
"argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
|
||||
|
||||
"aria-query": ["aria-query@5.3.1", "", {}, "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g=="],
|
||||
|
||||
"assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="],
|
||||
|
||||
"axobject-query": ["axobject-query@4.1.0", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="],
|
||||
|
||||
"balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
|
||||
|
||||
"brace-expansion": ["brace-expansion@2.1.4", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg=="],
|
||||
|
||||
"cac": ["cac@6.7.14", "", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="],
|
||||
|
||||
"chai": ["chai@5.3.3", "", { "dependencies": { "assertion-error": "^2.0.1", "check-error": "^2.1.1", "deep-eql": "^5.0.1", "loupe": "^3.1.0", "pathval": "^2.0.0" } }, "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw=="],
|
||||
|
||||
"change-case": ["change-case@5.4.4", "", {}, "sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w=="],
|
||||
|
||||
"check-error": ["check-error@2.1.3", "", {}, "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA=="],
|
||||
|
||||
"chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="],
|
||||
|
||||
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
|
||||
|
||||
"colorette": ["colorette@1.4.0", "", {}, "sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g=="],
|
||||
|
||||
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||
|
||||
"deep-eql": ["deep-eql@5.0.2", "", {}, "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q=="],
|
||||
|
||||
"deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="],
|
||||
|
||||
"devalue": ["devalue@5.9.2", "", {}, "sha512-po4PAY5c53tw5XMocSnf8A/5OHhbbUftpr93aEN6BBoAdntUmK7vu7wOATqvt7cXO7m1Cl4gMVn6p7n6n4mj0w=="],
|
||||
|
||||
"es-module-lexer": ["es-module-lexer@1.7.0", "", {}, "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA=="],
|
||||
|
||||
"esbuild": ["esbuild@0.21.5", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.21.5", "@esbuild/android-arm": "0.21.5", "@esbuild/android-arm64": "0.21.5", "@esbuild/android-x64": "0.21.5", "@esbuild/darwin-arm64": "0.21.5", "@esbuild/darwin-x64": "0.21.5", "@esbuild/freebsd-arm64": "0.21.5", "@esbuild/freebsd-x64": "0.21.5", "@esbuild/linux-arm": "0.21.5", "@esbuild/linux-arm64": "0.21.5", "@esbuild/linux-ia32": "0.21.5", "@esbuild/linux-loong64": "0.21.5", "@esbuild/linux-mips64el": "0.21.5", "@esbuild/linux-ppc64": "0.21.5", "@esbuild/linux-riscv64": "0.21.5", "@esbuild/linux-s390x": "0.21.5", "@esbuild/linux-x64": "0.21.5", "@esbuild/netbsd-x64": "0.21.5", "@esbuild/openbsd-x64": "0.21.5", "@esbuild/sunos-x64": "0.21.5", "@esbuild/win32-arm64": "0.21.5", "@esbuild/win32-ia32": "0.21.5", "@esbuild/win32-x64": "0.21.5" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw=="],
|
||||
|
||||
"esm-env": ["esm-env@1.2.2", "", {}, "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA=="],
|
||||
|
||||
"esrap": ["esrap@2.3.6", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15" }, "peerDependencies": { "@typescript-eslint/types": "^8.2.0" }, "optionalPeers": ["@typescript-eslint/types"] }, "sha512-yc0OC12UjPqLoc+fe+v5GNs4TOjAigUw3sTikfC+xeBPGUw7gDRz3DtYaqEhxyMVJojcSWJw7jT0QWR+CbuE/A=="],
|
||||
|
||||
"estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="],
|
||||
|
||||
"expect-type": ["expect-type@1.4.0", "", {}, "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA=="],
|
||||
|
||||
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
|
||||
|
||||
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
|
||||
|
||||
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
||||
|
||||
"https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="],
|
||||
|
||||
"index-to-position": ["index-to-position@1.2.0", "", {}, "sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw=="],
|
||||
|
||||
"is-reference": ["is-reference@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.6" } }, "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw=="],
|
||||
|
||||
"js-levenshtein": ["js-levenshtein@1.1.6", "", {}, "sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g=="],
|
||||
|
||||
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
|
||||
|
||||
"js-yaml": ["js-yaml@4.3.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ=="],
|
||||
|
||||
"json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
|
||||
|
||||
"kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="],
|
||||
|
||||
"locate-character": ["locate-character@3.0.0", "", {}, "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA=="],
|
||||
|
||||
"loupe": ["loupe@3.2.1", "", {}, "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ=="],
|
||||
|
||||
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
|
||||
|
||||
"minimatch": ["minimatch@5.1.9", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw=="],
|
||||
|
||||
"mri": ["mri@1.2.0", "", {}, "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA=="],
|
||||
|
||||
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||
|
||||
"nanoid": ["nanoid@3.3.18", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w=="],
|
||||
|
||||
"openapi-fetch": ["openapi-fetch@0.13.8", "", { "dependencies": { "openapi-typescript-helpers": "^0.0.15" } }, "sha512-yJ4QKRyNxE44baQ9mY5+r/kAzZ8yXMemtNAOFwOzRXJscdjSxxzWSNlyBAr+o5JjkUw9Lc3W7OIoca0cY3PYnQ=="],
|
||||
|
||||
"openapi-typescript": ["openapi-typescript@7.13.0", "", { "dependencies": { "@redocly/openapi-core": "^1.34.6", "ansi-colors": "^4.1.3", "change-case": "^5.4.4", "parse-json": "^8.3.0", "supports-color": "^10.2.2", "yargs-parser": "^21.1.1" }, "peerDependencies": { "typescript": "^5.x" }, "bin": { "openapi-typescript": "bin/cli.js" } }, "sha512-EFP392gcqXS7ntPvbhBzbF8TyBA+baIYEm791Hy5YkjDYKTnk/Tn5OQeKm5BIZvJihpp8Zzr4hzx0Irde1LNGQ=="],
|
||||
|
||||
"openapi-typescript-helpers": ["openapi-typescript-helpers@0.0.15", "", {}, "sha512-opyTPaunsklCBpTK8JGef6mfPhLSnyy5a0IN9vKtx3+4aExf+KxEqYwIy3hqkedXIB97u357uLMJsOnm3GVjsw=="],
|
||||
|
||||
"parse-json": ["parse-json@8.3.0", "", { "dependencies": { "@babel/code-frame": "^7.26.2", "index-to-position": "^1.1.0", "type-fest": "^4.39.1" } }, "sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ=="],
|
||||
|
||||
"pathe": ["pathe@1.1.2", "", {}, "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ=="],
|
||||
|
||||
"pathval": ["pathval@2.0.1", "", {}, "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ=="],
|
||||
|
||||
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
||||
|
||||
"pluralize": ["pluralize@8.0.0", "", {}, "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA=="],
|
||||
|
||||
"postcss": ["postcss@8.5.26", "", { "dependencies": { "nanoid": "^3.3.17", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ=="],
|
||||
|
||||
"readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="],
|
||||
|
||||
"require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="],
|
||||
|
||||
"rollup": ["rollup@4.63.0", "", { "dependencies": { "@types/estree": "1.0.9" }, "optionalDependencies": { "@napi-rs/lzma-linux-x64-gnu": "1.5.1", "@rollup/rollup-android-arm-eabi": "4.63.0", "@rollup/rollup-android-arm64": "4.63.0", "@rollup/rollup-darwin-arm64": "4.63.0", "@rollup/rollup-darwin-x64": "4.63.0", "@rollup/rollup-freebsd-arm64": "4.63.0", "@rollup/rollup-freebsd-x64": "4.63.0", "@rollup/rollup-linux-arm-gnueabihf": "4.63.0", "@rollup/rollup-linux-arm-musleabihf": "4.63.0", "@rollup/rollup-linux-arm64-gnu": "4.63.0", "@rollup/rollup-linux-arm64-musl": "4.63.0", "@rollup/rollup-linux-loong64-gnu": "4.63.0", "@rollup/rollup-linux-loong64-musl": "4.63.0", "@rollup/rollup-linux-ppc64-gnu": "4.63.0", "@rollup/rollup-linux-ppc64-musl": "4.63.0", "@rollup/rollup-linux-riscv64-gnu": "4.63.0", "@rollup/rollup-linux-riscv64-musl": "4.63.0", "@rollup/rollup-linux-s390x-gnu": "4.63.0", "@rollup/rollup-linux-x64-gnu": "4.63.0", "@rollup/rollup-linux-x64-musl": "4.63.0", "@rollup/rollup-openbsd-x64": "4.63.0", "@rollup/rollup-openharmony-arm64": "4.63.0", "@rollup/rollup-win32-arm64-msvc": "4.63.0", "@rollup/rollup-win32-ia32-msvc": "4.63.0", "@rollup/rollup-win32-x64-gnu": "4.63.0", "@rollup/rollup-win32-x64-msvc": "4.63.0", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-T5vnZ2y4QqC3/4P+w2+JO+Q/OVdnPsv4XcSYJYMEn0R9/jjl5AgLwO9LAZMzP2lN71O6pypn91rB7lDstUkfrQ=="],
|
||||
|
||||
"sade": ["sade@1.8.1", "", { "dependencies": { "mri": "^1.1.0" } }, "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A=="],
|
||||
|
||||
"siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="],
|
||||
|
||||
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
||||
|
||||
"stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="],
|
||||
|
||||
"std-env": ["std-env@3.10.0", "", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="],
|
||||
|
||||
"supports-color": ["supports-color@10.2.2", "", {}, "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g=="],
|
||||
|
||||
"svelte": ["svelte@5.56.10", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", "@sveltejs/acorn-typescript": "^1.0.10", "@types/estree": "^1.0.5", "@types/trusted-types": "^2.0.7", "acorn": "^8.12.1", "aria-query": "5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", "devalue": "^5.8.1", "esm-env": "^1.2.1", "esrap": "^2.2.12", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", "zimmerframe": "^1.1.2" } }, "sha512-Lcxbj8I/KAbpY+VjtY4ENQBV0dDCipfGAhqb51XQZ67CIQqXgsv/8dPkbILaj4Fb6/b6JAEM/PIVbILXgDQy2g=="],
|
||||
|
||||
"svelte-check": ["svelte-check@4.7.6", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "@sveltejs/load-config": "^0.2.3", "chokidar": "^4.0.1", "fdir": "^6.2.0", "picocolors": "^1.0.0", "sade": "^1.7.4" }, "peerDependencies": { "svelte": "^4.0.0 || ^5.0.0-next.0", "typescript": "^5.0.0 || ^6.0.0" }, "bin": { "svelte-check": "bin/svelte-check" } }, "sha512-t2scM//ZuVbSY/T2w6FSBw1v9s2NEmh/g+sy1lqtosW5ylBV5AF4wFb1Ts9Kf3MbfPDUDJDZ9L436YT0SPTdvw=="],
|
||||
|
||||
"tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="],
|
||||
|
||||
"tinyexec": ["tinyexec@0.3.2", "", {}, "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA=="],
|
||||
|
||||
"tinypool": ["tinypool@1.1.1", "", {}, "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg=="],
|
||||
|
||||
"tinyrainbow": ["tinyrainbow@1.2.0", "", {}, "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ=="],
|
||||
|
||||
"tinyspy": ["tinyspy@3.0.2", "", {}, "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q=="],
|
||||
|
||||
"type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="],
|
||||
|
||||
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||
|
||||
"uri-js-replace": ["uri-js-replace@1.0.1", "", {}, "sha512-W+C9NWNLFOoBI2QWDp4UT9pv65r2w5Cx+3sTYFvtMdDBxkKt1syCqsUdSFAChbEe1uK5TfS04wt/nGwmaeIQ0g=="],
|
||||
|
||||
"vite": ["vite@5.4.21", "", { "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", "rollup": "^4.20.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || >=20.0.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.4.0" }, "optionalPeers": ["@types/node", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser"], "bin": { "vite": "bin/vite.js" } }, "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw=="],
|
||||
|
||||
"vite-node": ["vite-node@2.1.9", "", { "dependencies": { "cac": "^6.7.14", "debug": "^4.3.7", "es-module-lexer": "^1.5.4", "pathe": "^1.1.2", "vite": "^5.0.0" }, "bin": { "vite-node": "vite-node.mjs" } }, "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA=="],
|
||||
|
||||
"vitefu": ["vitefu@1.1.3", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["vite"] }, "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg=="],
|
||||
|
||||
"vitest": ["vitest@2.1.9", "", { "dependencies": { "@vitest/expect": "2.1.9", "@vitest/mocker": "2.1.9", "@vitest/pretty-format": "^2.1.9", "@vitest/runner": "2.1.9", "@vitest/snapshot": "2.1.9", "@vitest/spy": "2.1.9", "@vitest/utils": "2.1.9", "chai": "^5.1.2", "debug": "^4.3.7", "expect-type": "^1.1.0", "magic-string": "^0.30.12", "pathe": "^1.1.2", "std-env": "^3.8.0", "tinybench": "^2.9.0", "tinyexec": "^0.3.1", "tinypool": "^1.0.1", "tinyrainbow": "^1.2.0", "vite": "^5.0.0", "vite-node": "2.1.9", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@types/node": "^18.0.0 || >=20.0.0", "@vitest/browser": "2.1.9", "@vitest/ui": "2.1.9", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@types/node", "@vitest/browser", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q=="],
|
||||
|
||||
"why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="],
|
||||
|
||||
"yaml-ast-parser": ["yaml-ast-parser@0.0.43", "", {}, "sha512-2PTINUwsRqSd+s8XxKaJWQlUuEMHJQyEuh2edBbW8KNJz0SJPwUSD2zRWqezFEdN7IzAgeuYHFUCF7o8zRdZ0A=="],
|
||||
|
||||
"yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="],
|
||||
|
||||
"zimmerframe": ["zimmerframe@1.1.4", "", {}, "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ=="],
|
||||
}
|
||||
}
|
||||
@ -1,13 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="vi">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>DotB AI Platform</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
@ -1,26 +0,0 @@
|
||||
{
|
||||
"name": "dev-console",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"check": "svelte-check --tsconfig ./tsconfig.json",
|
||||
"test": "vitest run",
|
||||
"generate-api": "openapi-typescript ../../openapi.json -o ./src/lib/api/schema.d.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"openapi-fetch": "^0.13.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/vite-plugin-svelte": "^4.0.4",
|
||||
"openapi-typescript": "^7.4.4",
|
||||
"svelte": "^5.2.7",
|
||||
"svelte-check": "^4.1.1",
|
||||
"typescript": "^5.7.2",
|
||||
"vite": "^5.4.11",
|
||||
"vitest": "^2.1.8"
|
||||
}
|
||||
}
|
||||
@ -1,124 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||
xmlns:cc="http://creativecommons.org/ns#"
|
||||
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 1884.0267 967.99597"
|
||||
height="967.99597"
|
||||
width="1884.0267"
|
||||
xml:space="preserve"
|
||||
id="svg2"
|
||||
version="1.1"><metadata
|
||||
id="metadata8"><rdf:RDF><cc:Work
|
||||
rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type
|
||||
rdf:resource="http://purl.org/dc/dcmitype/StillImage" /></cc:Work></rdf:RDF></metadata><defs
|
||||
id="defs6"><linearGradient
|
||||
id="linearGradient28"
|
||||
spreadMethod="pad"
|
||||
gradientTransform="matrix(305.44366,0,0,305.44366,581.11853,367.54431)"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
y2="0"
|
||||
x2="1"
|
||||
y1="0"
|
||||
x1="0"><stop
|
||||
id="stop24"
|
||||
offset="0"
|
||||
style="stop-opacity:1;stop-color:#00c9fd" /><stop
|
||||
id="stop26"
|
||||
offset="1"
|
||||
style="stop-opacity:1;stop-color:#81ee8e" /></linearGradient><linearGradient
|
||||
id="linearGradient48"
|
||||
spreadMethod="pad"
|
||||
gradientTransform="matrix(347.46957,0,0,347.46957,565.36786,367.3493)"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
y2="0"
|
||||
x2="1"
|
||||
y1="0"
|
||||
x1="0"><stop
|
||||
id="stop44"
|
||||
offset="0"
|
||||
style="stop-opacity:1;stop-color:#00c9fd" /><stop
|
||||
id="stop46"
|
||||
offset="1"
|
||||
style="stop-opacity:1;stop-color:#81ee8e" /></linearGradient><linearGradient
|
||||
id="linearGradient68"
|
||||
spreadMethod="pad"
|
||||
gradientTransform="matrix(347.45862,0,0,347.45862,563.54041,299.44431)"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
y2="0"
|
||||
x2="1"
|
||||
y1="0"
|
||||
x1="0"><stop
|
||||
id="stop64"
|
||||
offset="0"
|
||||
style="stop-opacity:1;stop-color:#00c9fd" /><stop
|
||||
id="stop66"
|
||||
offset="1"
|
||||
style="stop-opacity:1;stop-color:#81ee8e" /></linearGradient><linearGradient
|
||||
id="linearGradient88"
|
||||
spreadMethod="pad"
|
||||
gradientTransform="matrix(347.46759,0,0,-347.46759,558.06158,366.5343)"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
y2="0"
|
||||
x2="1"
|
||||
y1="0"
|
||||
x1="0"><stop
|
||||
id="stop84"
|
||||
offset="0"
|
||||
style="stop-opacity:1;stop-color:#00c9fd" /><stop
|
||||
id="stop86"
|
||||
offset="1"
|
||||
style="stop-opacity:1;stop-color:#81ee8e" /></linearGradient><linearGradient
|
||||
id="linearGradient108"
|
||||
spreadMethod="pad"
|
||||
gradientTransform="matrix(105.03998,0,0,-105.03998,989.06964,267.11432)"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
y2="0"
|
||||
x2="1"
|
||||
y1="0"
|
||||
x1="0"><stop
|
||||
id="stop104"
|
||||
offset="0"
|
||||
style="stop-opacity:1;stop-color:#ae3ba4" /><stop
|
||||
id="stop106"
|
||||
offset="1"
|
||||
style="stop-opacity:1;stop-color:#ed0a04" /></linearGradient></defs><g
|
||||
transform="matrix(1.3333333,0,0,-1.3333333,0,967.996)"
|
||||
id="g10"><g
|
||||
id="g12"><g
|
||||
id="g14"><g
|
||||
id="g20"><g
|
||||
id="g22"><path
|
||||
id="path30"
|
||||
style="fill:url(#linearGradient28);stroke:none"
|
||||
d="M 748.82,594.175 V 304.434 c -0.08,-1.51 -0.13,-3.01 -0.16,-4.52 v 0 c 0.039,-1.07 0.099,-2.129 0.16,-3.199 v 0 -1.18 h 0.06 c 2.37,-86.011 72.73,-154.531 158.78,-154.62 v 0 c 44.259,0 84.289,18.08 113.12,47.269 v 0 c 2.57,2.59 5.04,5.27 7.42,8.04 v 0 c -29.401,5.24 -52.66,28.17 -58.41,57.38 v 0 c -1.961,-2.529 -4.081,-4.93 -6.34,-7.189 v 0 c -2.75,-2.751 -5.71,-5.291 -8.861,-7.58 v 0 c -12.5,-9.16 -27.909,-14.561 -44.59,-14.561 v 0 c -20.879,0 -39.769,8.46 -53.45,22.141 v 0 c -13.679,13.68 -22.139,32.58 -22.139,53.45 v 0 c 0,1.059 0.02,2.109 0.07,3.159 v 0 137.91 c -0.101,-0.05 -0.201,-0.1 -0.3,-0.15 v 0 17.52 h 0.3 v 0.48 h 88.71 v 70.681 h -89.01 v 64.71 z" /></g></g></g></g><g
|
||||
id="g32"><g
|
||||
id="g34"><g
|
||||
id="g40"><g
|
||||
id="g42"><path
|
||||
id="path50"
|
||||
style="fill:url(#linearGradient48);stroke:none"
|
||||
d="m 286.03,594.255 v -153.71 c -22.61,11.739 -47.7,17.879 -73.18,17.9 v 0 c -87.81,0 -159,-71.181 -159,-159.001 v 0 c 0,-87.81 71.19,-158.999 159,-158.999 v 0 c 87.809,0 158.999,71.189 158.999,158.999 v 0 c -0.06,3.06 -0.219,6.111 -0.46,9.15 v 0 285.661 z M 136.55,298.534 c 0,20.88 8.46,39.77 22.14,53.45 v 0 c 13.67,13.681 32.57,22.141 53.44,22.141 v 0 h 0.01 c 35.73,-0.041 66.559,-25.111 73.89,-60.081 v 0 -23.719 h 1.219 C 283.06,251.995 250.69,222.964 212.14,222.944 v 0 c -41.75,0 -75.59,33.84 -75.59,75.59" /></g></g></g></g><g
|
||||
id="g52"><g
|
||||
id="g54"><g
|
||||
id="g60"><g
|
||||
id="g62"><path
|
||||
id="path70"
|
||||
style="fill:url(#linearGradient68);stroke:none"
|
||||
d="m 402.349,299.444 c 0,-87.81 71.181,-158.999 159.001,-158.999 v 0 c 87.809,0 158.99,71.189 158.99,158.999 v 0 c 0,87.82 -71.181,159.001 -158.99,159.001 v 0 c -87.82,0 -159.001,-71.181 -159.001,-159.001 m 83.871,0.16 c 0,20.87 8.46,39.771 22.14,53.45 v 0 c 13.68,13.68 32.58,22.141 53.45,22.141 v 0 c 41.75,0 75.59,-33.841 75.59,-75.591 v 0 c 0,-41.75 -33.84,-75.59 -75.59,-75.59 v 0 c -41.75,0 -75.59,33.84 -75.59,75.59" /></g></g></g></g><g
|
||||
id="g72"><g
|
||||
id="g74"><g
|
||||
id="g80"><g
|
||||
id="g82"><path
|
||||
id="path90"
|
||||
style="fill:url(#linearGradient88);stroke:none"
|
||||
d="m 1041.81,593.324 v -252.99 c 7.099,-0.07 13.949,-1.16 20.42,-3.139 v 0 -0.011 c 29.77,-9.059 51.42,-36.739 51.42,-69.47 v 0 c 0,-20.75 -8.71,-39.47 -22.67,-52.709 v 0 c -5.05,-4.791 -10.8,-8.871 -17.08,-12.06 v 0 c 4.21,-5.571 8.78,-10.861 13.68,-15.821 v 0 c 28.839,-29.25 68.919,-47.379 113.23,-47.379 v 0 c 87.819,-0.01 159,71.179 159,158.988 v 0 c 0,43.911 -17.79,83.661 -46.57,112.431 v 0 c -28.77,28.77 -68.52,46.571 -112.43,46.571 v 0 c -12.83,-0.011 -25.55,-1.581 -37.91,-4.621 v 0 c -12.361,-3.06 -24.36,-7.59 -35.72,-13.54 v 0 153.75 z m 82.7,-294.129 c 0,41.75 33.84,75.589 75.59,75.589 v 0 c 41.739,0 75.59,-33.839 75.59,-75.589 v 0 c 0,-41.75 -33.851,-75.591 -75.59,-75.591 v 0 c -41.75,0 -75.59,33.841 -75.59,75.591" /></g></g></g></g><g
|
||||
id="g92"><g
|
||||
id="g94"><g
|
||||
id="g100"><g
|
||||
id="g102"><path
|
||||
id="path110"
|
||||
style="fill:url(#linearGradient108);stroke:none"
|
||||
d="m 1041.33,319.634 c -0.71,0 -1.421,-0.02 -2.121,-0.059 v 0 c -15.789,-0.701 -29.759,-8.37 -38.909,-20.011 v 0 c -7.041,-8.94 -11.231,-20.21 -11.231,-32.449 v 0 c 0,-15.75 6.93,-29.871 17.911,-39.5 v 0 h 0.01 c 0.991,-0.882 2.01,-1.711 3.071,-2.501 v 0 c 0.04,-0.029 0.069,-0.06 0.109,-0.09 v 0 c 8.761,-6.55 19.641,-10.43 31.42,-10.43 v 0 h 0.261 c 7.57,0.04 14.77,1.68 21.26,4.601 v 0 c 18.27,8.21 31,26.579 31,47.92 v 0 c 0,2.799 -0.22,5.54 -0.64,8.219 v 0 h -0.011 c -2.6,16.63 -13.029,30.68 -27.389,38.25 v 0 c -6.82,3.611 -14.531,5.75 -22.71,6.01 v 0 c -0.59,0.03 -1.18,0.04 -1.771,0.04 v 0 h -0.259 z" /></g></g></g></g></g></svg>
|
||||
|
Before Width: | Height: | Size: 6.9 KiB |
@ -1,124 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||
xmlns:cc="http://creativecommons.org/ns#"
|
||||
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 1884.0267 967.99597"
|
||||
height="967.99597"
|
||||
width="1884.0267"
|
||||
xml:space="preserve"
|
||||
id="svg2"
|
||||
version="1.1"><metadata
|
||||
id="metadata8"><rdf:RDF><cc:Work
|
||||
rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type
|
||||
rdf:resource="http://purl.org/dc/dcmitype/StillImage" /></cc:Work></rdf:RDF></metadata><defs
|
||||
id="defs6"><linearGradient
|
||||
id="linearGradient28"
|
||||
spreadMethod="pad"
|
||||
gradientTransform="matrix(305.44366,0,0,305.44366,581.11853,367.54431)"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
y2="0"
|
||||
x2="1"
|
||||
y1="0"
|
||||
x1="0"><stop
|
||||
id="stop24"
|
||||
offset="0"
|
||||
style="stop-opacity:1;stop-color:#00c9fd" /><stop
|
||||
id="stop26"
|
||||
offset="1"
|
||||
style="stop-opacity:1;stop-color:#81ee8e" /></linearGradient><linearGradient
|
||||
id="linearGradient48"
|
||||
spreadMethod="pad"
|
||||
gradientTransform="matrix(347.46957,0,0,347.46957,565.36786,367.3493)"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
y2="0"
|
||||
x2="1"
|
||||
y1="0"
|
||||
x1="0"><stop
|
||||
id="stop44"
|
||||
offset="0"
|
||||
style="stop-opacity:1;stop-color:#00c9fd" /><stop
|
||||
id="stop46"
|
||||
offset="1"
|
||||
style="stop-opacity:1;stop-color:#81ee8e" /></linearGradient><linearGradient
|
||||
id="linearGradient68"
|
||||
spreadMethod="pad"
|
||||
gradientTransform="matrix(347.45862,0,0,347.45862,563.54041,299.44431)"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
y2="0"
|
||||
x2="1"
|
||||
y1="0"
|
||||
x1="0"><stop
|
||||
id="stop64"
|
||||
offset="0"
|
||||
style="stop-opacity:1;stop-color:#00c9fd" /><stop
|
||||
id="stop66"
|
||||
offset="1"
|
||||
style="stop-opacity:1;stop-color:#81ee8e" /></linearGradient><linearGradient
|
||||
id="linearGradient88"
|
||||
spreadMethod="pad"
|
||||
gradientTransform="matrix(347.46759,0,0,-347.46759,558.06158,366.5343)"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
y2="0"
|
||||
x2="1"
|
||||
y1="0"
|
||||
x1="0"><stop
|
||||
id="stop84"
|
||||
offset="0"
|
||||
style="stop-opacity:1;stop-color:#00c9fd" /><stop
|
||||
id="stop86"
|
||||
offset="1"
|
||||
style="stop-opacity:1;stop-color:#81ee8e" /></linearGradient><linearGradient
|
||||
id="linearGradient108"
|
||||
spreadMethod="pad"
|
||||
gradientTransform="matrix(105.03998,0,0,-105.03998,989.06964,267.11432)"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
y2="0"
|
||||
x2="1"
|
||||
y1="0"
|
||||
x1="0"><stop
|
||||
id="stop104"
|
||||
offset="0"
|
||||
style="stop-opacity:1;stop-color:#ae3ba4" /><stop
|
||||
id="stop106"
|
||||
offset="1"
|
||||
style="stop-opacity:1;stop-color:#ed0a04" /></linearGradient></defs><g
|
||||
transform="matrix(1.3333333,0,0,-1.3333333,0,967.996)"
|
||||
id="g10"><g
|
||||
id="g12"><g
|
||||
id="g14"><g
|
||||
id="g20"><g
|
||||
id="g22"><path
|
||||
id="path30"
|
||||
style="fill:url(#linearGradient28);stroke:none"
|
||||
d="M 748.82,594.175 V 304.434 c -0.08,-1.51 -0.13,-3.01 -0.16,-4.52 v 0 c 0.039,-1.07 0.099,-2.129 0.16,-3.199 v 0 -1.18 h 0.06 c 2.37,-86.011 72.73,-154.531 158.78,-154.62 v 0 c 44.259,0 84.289,18.08 113.12,47.269 v 0 c 2.57,2.59 5.04,5.27 7.42,8.04 v 0 c -29.401,5.24 -52.66,28.17 -58.41,57.38 v 0 c -1.961,-2.529 -4.081,-4.93 -6.34,-7.189 v 0 c -2.75,-2.751 -5.71,-5.291 -8.861,-7.58 v 0 c -12.5,-9.16 -27.909,-14.561 -44.59,-14.561 v 0 c -20.879,0 -39.769,8.46 -53.45,22.141 v 0 c -13.679,13.68 -22.139,32.58 -22.139,53.45 v 0 c 0,1.059 0.02,2.109 0.07,3.159 v 0 137.91 c -0.101,-0.05 -0.201,-0.1 -0.3,-0.15 v 0 17.52 h 0.3 v 0.48 h 88.71 v 70.681 h -89.01 v 64.71 z" /></g></g></g></g><g
|
||||
id="g32"><g
|
||||
id="g34"><g
|
||||
id="g40"><g
|
||||
id="g42"><path
|
||||
id="path50"
|
||||
style="fill:url(#linearGradient48);stroke:none"
|
||||
d="m 286.03,594.255 v -153.71 c -22.61,11.739 -47.7,17.879 -73.18,17.9 v 0 c -87.81,0 -159,-71.181 -159,-159.001 v 0 c 0,-87.81 71.19,-158.999 159,-158.999 v 0 c 87.809,0 158.999,71.189 158.999,158.999 v 0 c -0.06,3.06 -0.219,6.111 -0.46,9.15 v 0 285.661 z M 136.55,298.534 c 0,20.88 8.46,39.77 22.14,53.45 v 0 c 13.67,13.681 32.57,22.141 53.44,22.141 v 0 h 0.01 c 35.73,-0.041 66.559,-25.111 73.89,-60.081 v 0 -23.719 h 1.219 C 283.06,251.995 250.69,222.964 212.14,222.944 v 0 c -41.75,0 -75.59,33.84 -75.59,75.59" /></g></g></g></g><g
|
||||
id="g52"><g
|
||||
id="g54"><g
|
||||
id="g60"><g
|
||||
id="g62"><path
|
||||
id="path70"
|
||||
style="fill:url(#linearGradient68);stroke:none"
|
||||
d="m 402.349,299.444 c 0,-87.81 71.181,-158.999 159.001,-158.999 v 0 c 87.809,0 158.99,71.189 158.99,158.999 v 0 c 0,87.82 -71.181,159.001 -158.99,159.001 v 0 c -87.82,0 -159.001,-71.181 -159.001,-159.001 m 83.871,0.16 c 0,20.87 8.46,39.771 22.14,53.45 v 0 c 13.68,13.68 32.58,22.141 53.45,22.141 v 0 c 41.75,0 75.59,-33.841 75.59,-75.591 v 0 c 0,-41.75 -33.84,-75.59 -75.59,-75.59 v 0 c -41.75,0 -75.59,33.84 -75.59,75.59" /></g></g></g></g><g
|
||||
id="g72"><g
|
||||
id="g74"><g
|
||||
id="g80"><g
|
||||
id="g82"><path
|
||||
id="path90"
|
||||
style="fill:url(#linearGradient88);stroke:none"
|
||||
d="m 1041.81,593.324 v -252.99 c 7.099,-0.07 13.949,-1.16 20.42,-3.139 v 0 -0.011 c 29.77,-9.059 51.42,-36.739 51.42,-69.47 v 0 c 0,-20.75 -8.71,-39.47 -22.67,-52.709 v 0 c -5.05,-4.791 -10.8,-8.871 -17.08,-12.06 v 0 c 4.21,-5.571 8.78,-10.861 13.68,-15.821 v 0 c 28.839,-29.25 68.919,-47.379 113.23,-47.379 v 0 c 87.819,-0.01 159,71.179 159,158.988 v 0 c 0,43.911 -17.79,83.661 -46.57,112.431 v 0 c -28.77,28.77 -68.52,46.571 -112.43,46.571 v 0 c -12.83,-0.011 -25.55,-1.581 -37.91,-4.621 v 0 c -12.361,-3.06 -24.36,-7.59 -35.72,-13.54 v 0 153.75 z m 82.7,-294.129 c 0,41.75 33.84,75.589 75.59,75.589 v 0 c 41.739,0 75.59,-33.839 75.59,-75.589 v 0 c 0,-41.75 -33.851,-75.591 -75.59,-75.591 v 0 c -41.75,0 -75.59,33.841 -75.59,75.591" /></g></g></g></g><g
|
||||
id="g92"><g
|
||||
id="g94"><g
|
||||
id="g100"><g
|
||||
id="g102"><path
|
||||
id="path110"
|
||||
style="fill:url(#linearGradient108);stroke:none"
|
||||
d="m 1041.33,319.634 c -0.71,0 -1.421,-0.02 -2.121,-0.059 v 0 c -15.789,-0.701 -29.759,-8.37 -38.909,-20.011 v 0 c -7.041,-8.94 -11.231,-20.21 -11.231,-32.449 v 0 c 0,-15.75 6.93,-29.871 17.911,-39.5 v 0 h 0.01 c 0.991,-0.882 2.01,-1.711 3.071,-2.501 v 0 c 0.04,-0.029 0.069,-0.06 0.109,-0.09 v 0 c 8.761,-6.55 19.641,-10.43 31.42,-10.43 v 0 h 0.261 c 7.57,0.04 14.77,1.68 21.26,4.601 v 0 c 18.27,8.21 31,26.579 31,47.92 v 0 c 0,2.799 -0.22,5.54 -0.64,8.219 v 0 h -0.011 c -2.6,16.63 -13.029,30.68 -27.389,38.25 v 0 c -6.82,3.611 -14.531,5.75 -22.71,6.01 v 0 c -0.59,0.03 -1.18,0.04 -1.771,0.04 v 0 h -0.259 z" /></g></g></g></g></g></svg>
|
||||
|
Before Width: | Height: | Size: 6.9 KiB |
@ -1,61 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||
xmlns:cc="http://creativecommons.org/ns#"
|
||||
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 566.92932 566.92932"
|
||||
height="566.92932"
|
||||
width="566.92932"
|
||||
xml:space="preserve"
|
||||
id="svg2"
|
||||
version="1.1"><metadata
|
||||
id="metadata8"><rdf:RDF><cc:Work
|
||||
rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type
|
||||
rdf:resource="http://purl.org/dc/dcmitype/StillImage" /></cc:Work></rdf:RDF></metadata><defs
|
||||
id="defs6"><linearGradient
|
||||
id="linearGradient28"
|
||||
spreadMethod="pad"
|
||||
gradientTransform="matrix(261.43005,0,0,-261.43005,107.24294,217.74225)"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
y2="0"
|
||||
x2="1"
|
||||
y1="0"
|
||||
x1="0"><stop
|
||||
id="stop24"
|
||||
offset="0"
|
||||
style="stop-opacity:1;stop-color:#00c9fd" /><stop
|
||||
id="stop26"
|
||||
offset="1"
|
||||
style="stop-opacity:1;stop-color:#81ee8e" /></linearGradient><linearGradient
|
||||
id="linearGradient48"
|
||||
spreadMethod="pad"
|
||||
gradientTransform="matrix(86.359985,0,0,-86.359985,63.88295,136.00223)"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
y2="0"
|
||||
x2="1"
|
||||
y1="0"
|
||||
x1="0"><stop
|
||||
id="stop44"
|
||||
offset="0"
|
||||
style="stop-opacity:1;stop-color:#ae3ba4" /><stop
|
||||
id="stop46"
|
||||
offset="1"
|
||||
style="stop-opacity:1;stop-color:#ed0a04" /></linearGradient></defs><g
|
||||
transform="matrix(1.3333333,0,0,-1.3333333,0,566.92933)"
|
||||
id="g10"><g
|
||||
id="g12"><g
|
||||
id="g14"><g
|
||||
id="g20"><g
|
||||
id="g22"><path
|
||||
id="path30"
|
||||
style="fill:url(#linearGradient28);stroke:none"
|
||||
d="m 107.243,404.187 v -208.48 c 32.89,-0.1 59.53,-26.79 59.53,-59.7 v 0 c 0,-23.269 -13.32,-43.43 -32.75,-53.28 v 0 c 23.88,-31.26 61.55,-51.43 103.93,-51.43 v 0 c 34.67,0 67.92,13.771 92.43,38.28 v 0 c 24.519,24.51 38.289,57.76 38.289,92.429 v 0 c 0,34.672 -13.77,67.922 -38.289,92.431 v 0 c -24.51,24.51 -57.76,38.28 -92.43,38.28 v 0 c -21.08,-0.02 -41.85,-5.14 -60.531,-14.93 v 0 126.4 z m 67.98,-241.81 c 0,34.32 27.82,62.15 62.15,62.15 v 0 c 34.32,0 62.14,-27.83 62.14,-62.15 v 0 c 0,-34.32 -27.82,-62.139 -62.14,-62.139 v 0 c -34.33,0 -62.15,27.819 -62.15,62.139" /></g></g></g></g><g
|
||||
id="g32"><g
|
||||
id="g34"><g
|
||||
id="g40"><g
|
||||
id="g42"><path
|
||||
id="path50"
|
||||
style="fill:url(#linearGradient48);stroke:none"
|
||||
d="m 63.883,136.007 c 0,-23.85 19.34,-43.18 43.18,-43.18 v 0 c 6.31,0 12.3,1.351 17.7,3.78 v 0 c 15.02,6.76 25.48,21.861 25.48,39.4 v 0 c 0,23.36 -18.551,42.38 -41.73,43.15 v 0 c -0.48,0.01 -0.97,0.02 -1.45,0.02 v 0 c -23.84,0 -43.18,-19.33 -43.18,-43.17" /></g></g></g></g></g></svg>
|
||||
|
Before Width: | Height: | Size: 2.9 KiB |
|
Before Width: | Height: | Size: 15 KiB |
@ -1,61 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||
xmlns:cc="http://creativecommons.org/ns#"
|
||||
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 566.92932 566.92932"
|
||||
height="566.92932"
|
||||
width="566.92932"
|
||||
xml:space="preserve"
|
||||
id="svg2"
|
||||
version="1.1"><metadata
|
||||
id="metadata8"><rdf:RDF><cc:Work
|
||||
rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type
|
||||
rdf:resource="http://purl.org/dc/dcmitype/StillImage" /></cc:Work></rdf:RDF></metadata><defs
|
||||
id="defs6"><linearGradient
|
||||
id="linearGradient28"
|
||||
spreadMethod="pad"
|
||||
gradientTransform="matrix(261.43005,0,0,-261.43005,107.24294,217.74225)"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
y2="0"
|
||||
x2="1"
|
||||
y1="0"
|
||||
x1="0"><stop
|
||||
id="stop24"
|
||||
offset="0"
|
||||
style="stop-opacity:1;stop-color:#00c9fd" /><stop
|
||||
id="stop26"
|
||||
offset="1"
|
||||
style="stop-opacity:1;stop-color:#81ee8e" /></linearGradient><linearGradient
|
||||
id="linearGradient48"
|
||||
spreadMethod="pad"
|
||||
gradientTransform="matrix(86.359985,0,0,-86.359985,63.88295,136.00223)"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
y2="0"
|
||||
x2="1"
|
||||
y1="0"
|
||||
x1="0"><stop
|
||||
id="stop44"
|
||||
offset="0"
|
||||
style="stop-opacity:1;stop-color:#ae3ba4" /><stop
|
||||
id="stop46"
|
||||
offset="1"
|
||||
style="stop-opacity:1;stop-color:#ed0a04" /></linearGradient></defs><g
|
||||
transform="matrix(1.3333333,0,0,-1.3333333,0,566.92933)"
|
||||
id="g10"><g
|
||||
id="g12"><g
|
||||
id="g14"><g
|
||||
id="g20"><g
|
||||
id="g22"><path
|
||||
id="path30"
|
||||
style="fill:url(#linearGradient28);stroke:none"
|
||||
d="m 107.243,404.187 v -208.48 c 32.89,-0.1 59.53,-26.79 59.53,-59.7 v 0 c 0,-23.269 -13.32,-43.43 -32.75,-53.28 v 0 c 23.88,-31.26 61.55,-51.43 103.93,-51.43 v 0 c 34.67,0 67.92,13.771 92.43,38.28 v 0 c 24.519,24.51 38.289,57.76 38.289,92.429 v 0 c 0,34.672 -13.77,67.922 -38.289,92.431 v 0 c -24.51,24.51 -57.76,38.28 -92.43,38.28 v 0 c -21.08,-0.02 -41.85,-5.14 -60.531,-14.93 v 0 126.4 z m 67.98,-241.81 c 0,34.32 27.82,62.15 62.15,62.15 v 0 c 34.32,0 62.14,-27.83 62.14,-62.15 v 0 c 0,-34.32 -27.82,-62.139 -62.14,-62.139 v 0 c -34.33,0 -62.15,27.819 -62.15,62.139" /></g></g></g></g><g
|
||||
id="g32"><g
|
||||
id="g34"><g
|
||||
id="g40"><g
|
||||
id="g42"><path
|
||||
id="path50"
|
||||
style="fill:url(#linearGradient48);stroke:none"
|
||||
d="m 63.883,136.007 c 0,-23.85 19.34,-43.18 43.18,-43.18 v 0 c 6.31,0 12.3,1.351 17.7,3.78 v 0 c 15.02,6.76 25.48,21.861 25.48,39.4 v 0 c 0,23.36 -18.551,42.38 -41.73,43.15 v 0 c -0.48,0.01 -0.97,0.02 -1.45,0.02 v 0 c -23.84,0 -43.18,-19.33 -43.18,-43.17" /></g></g></g></g></g></svg>
|
||||
|
Before Width: | Height: | Size: 2.9 KiB |
|
Before Width: | Height: | Size: 125 KiB |
|
Before Width: | Height: | Size: 98 KiB |
|
Before Width: | Height: | Size: 155 KiB |
|
Before Width: | Height: | Size: 108 KiB |
@ -1,436 +0,0 @@
|
||||
<script lang="ts">
|
||||
import DemoFlow from './lib/components/DemoFlow.svelte';
|
||||
import ChurnPrediction from './lib/components/ChurnPrediction.svelte';
|
||||
import Icon from './lib/components/Icon.svelte';
|
||||
|
||||
let currentNav = $state<'lead-scoring' | 'churn-prediction'>('lead-scoring');
|
||||
</script>
|
||||
|
||||
<div class="app-layout">
|
||||
<!-- Left Sidebar -->
|
||||
<aside class="sidebar" aria-label="Menu điều hướng chính">
|
||||
<!-- Brand Header -->
|
||||
<div class="brand-header">
|
||||
<div class="brand-logo-wrap">
|
||||
<img src="/brand-assets/logo_thumbnail.svg" alt="DotB" class="brand-logo" width="28" height="28" />
|
||||
</div>
|
||||
<div class="brand-title-wrap">
|
||||
<span class="brand-name">DotB</span>
|
||||
<span class="brand-product">AI Platform</span>
|
||||
</div>
|
||||
<span class="env-chip">DEV</span>
|
||||
</div>
|
||||
|
||||
<!-- Navigation Section: AI Services -->
|
||||
<div class="nav-group">
|
||||
<span class="nav-heading">DỊCH VỤ AI</span>
|
||||
<nav class="nav-list">
|
||||
<button
|
||||
class="nav-item"
|
||||
class:active={currentNav === 'lead-scoring'}
|
||||
type="button"
|
||||
onclick={() => (currentNav = 'lead-scoring')}
|
||||
>
|
||||
<div class="nav-item-content">
|
||||
<Icon name="target" size={15} />
|
||||
<span class="nav-label">Lead Scoring</span>
|
||||
</div>
|
||||
<span class="badge-live">Live</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="nav-item"
|
||||
class:active={currentNav === 'churn-prediction'}
|
||||
type="button"
|
||||
onclick={() => (currentNav = 'churn-prediction')}
|
||||
>
|
||||
<div class="nav-item-content">
|
||||
<Icon name="trending-down" size={15} />
|
||||
<span class="nav-label">Churn Prediction</span>
|
||||
</div>
|
||||
<span class="badge-dev">In Dev</span>
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<!-- Navigation Section: Developer Resources -->
|
||||
<div class="nav-group">
|
||||
<span class="nav-heading">TÀI NGUYÊN KỸ THUẬT</span>
|
||||
<nav class="nav-list">
|
||||
<a class="nav-item nav-link" href="/lead-scoring-api-docs.html" target="_blank" rel="noopener noreferrer">
|
||||
<div class="nav-item-content">
|
||||
<Icon name="book-open" size={15} />
|
||||
<span class="nav-label">API Reference</span>
|
||||
</div>
|
||||
<span class="nav-ext-icon">↗</span>
|
||||
</a>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<!-- Sidebar Footer: Status & Tenant -->
|
||||
<div class="sidebar-footer">
|
||||
<div class="footer-status-row">
|
||||
<span class="status-dot"></span>
|
||||
<span class="status-text">AIHub Gateway Online</span>
|
||||
</div>
|
||||
<div class="footer-meta-row">
|
||||
<span class="meta-label">Tenant:</span>
|
||||
<code class="meta-value">omni-account-42</code>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Main Content Area -->
|
||||
<main class="main-wrapper">
|
||||
<!-- Top Bar -->
|
||||
<header class="topbar">
|
||||
<div class="breadcrumb">
|
||||
<span class="breadcrumb-root">AI Platform</span>
|
||||
<span class="breadcrumb-separator">/</span>
|
||||
<span class="breadcrumb-current">
|
||||
{currentNav === 'lead-scoring' ? 'Lead Scoring Engine' : 'Student Churn Prediction'}
|
||||
</span>
|
||||
</div>
|
||||
<div class="topbar-actions">
|
||||
<div class="backend-pill">
|
||||
<span class="pill-dot"></span>
|
||||
<span>Core API: <code>127.0.0.1</code></span>
|
||||
</div>
|
||||
<a class="doc-btn" href="/lead-scoring-api-docs.html" target="_blank" rel="noopener noreferrer">
|
||||
Tài liệu API (Redoc) ↗
|
||||
</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Content Body -->
|
||||
<div class="content-body">
|
||||
{#if currentNav === 'lead-scoring'}
|
||||
<DemoFlow />
|
||||
{:else if currentNav === 'churn-prediction'}
|
||||
<ChurnPrediction />
|
||||
{/if}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.app-layout {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
background-color: var(--bg-canvas);
|
||||
}
|
||||
|
||||
/* Sidebar */
|
||||
.sidebar {
|
||||
width: 250px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--bg-surface);
|
||||
border-right: 1px solid var(--border-subtle);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
height: 100vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.brand-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 18px 20px;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.brand-logo-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.brand-logo {
|
||||
display: block;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.brand-title-wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
line-height: 1.15;
|
||||
}
|
||||
|
||||
.brand-name {
|
||||
font-size: 0.92rem;
|
||||
font-weight: 800;
|
||||
color: var(--text-primary);
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.brand-product {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
color: var(--accent-blue-strong);
|
||||
}
|
||||
|
||||
.env-chip {
|
||||
margin-left: auto;
|
||||
padding: 2px 6px;
|
||||
border-radius: var(--radius-xs);
|
||||
background: var(--accent-blue-subtle);
|
||||
color: var(--accent-blue-strong);
|
||||
font-size: 0.62rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.04em;
|
||||
border: 1px solid var(--accent-blue-border);
|
||||
}
|
||||
|
||||
/* Nav Groups */
|
||||
.nav-group {
|
||||
padding: 16px 14px 8px;
|
||||
}
|
||||
|
||||
.nav-heading {
|
||||
display: block;
|
||||
padding: 0 8px 8px;
|
||||
font-size: 0.65rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.nav-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
text-decoration: none;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.nav-item:hover {
|
||||
background: var(--bg-surface-subtle);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.nav-item.active {
|
||||
background: var(--accent-blue-subtle);
|
||||
color: var(--accent-blue-strong);
|
||||
border-color: var(--accent-blue-border);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.nav-item-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.badge-live {
|
||||
padding: 1px 6px;
|
||||
border-radius: var(--radius-full);
|
||||
background: #ecfdf5;
|
||||
color: #047857;
|
||||
font-size: 0.65rem;
|
||||
font-weight: 700;
|
||||
border: 1px solid #a7f3d0;
|
||||
}
|
||||
|
||||
.badge-dev {
|
||||
padding: 1px 6px;
|
||||
border-radius: var(--radius-full);
|
||||
background: #fef3c7;
|
||||
color: #92400e;
|
||||
font-size: 0.65rem;
|
||||
font-weight: 700;
|
||||
border: 1px solid #fde68a;
|
||||
}
|
||||
|
||||
.nav-ext-icon {
|
||||
font-size: 0.72rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Sidebar Footer */
|
||||
.sidebar-footer {
|
||||
margin-top: auto;
|
||||
padding: 14px 16px;
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
background: var(--bg-surface-subtle);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.footer-status-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: #10b981;
|
||||
box-shadow: 0 0 0 2px rgba(16, 185, 129, 0.2);
|
||||
}
|
||||
|
||||
.status-text {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.footer-meta-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.meta-label {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.meta-value {
|
||||
color: var(--text-primary);
|
||||
font-size: 0.68rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Main Wrapper */
|
||||
.main-wrapper {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Topbar */
|
||||
.topbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 28px;
|
||||
background: var(--bg-surface);
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.breadcrumb {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.breadcrumb-root {
|
||||
color: var(--text-muted);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.breadcrumb-separator {
|
||||
color: var(--border-medium);
|
||||
}
|
||||
|
||||
.breadcrumb-current {
|
||||
color: var(--text-primary);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.topbar-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.backend-pill {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 10px;
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--bg-surface-subtle);
|
||||
border: 1px solid var(--border-subtle);
|
||||
font-size: 0.72rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.pill-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: #10b981;
|
||||
}
|
||||
|
||||
.backend-pill code {
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.doc-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 5px 12px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--accent-blue-subtle);
|
||||
border: 1px solid var(--accent-blue-border);
|
||||
color: var(--accent-blue-strong);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.doc-btn:hover {
|
||||
background: var(--bg-surface-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.content-body {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.sidebar {
|
||||
width: 200px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.app-layout {
|
||||
flex-direction: column;
|
||||
}
|
||||
.sidebar {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
position: relative;
|
||||
}
|
||||
.sidebar-footer {
|
||||
display: none;
|
||||
}
|
||||
.topbar {
|
||||
padding: 12px 16px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -1,134 +0,0 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500;600;700&display=swap');
|
||||
|
||||
:root {
|
||||
/* Canvas & Atmospheric Light Grounding */
|
||||
--bg-canvas: #f8fafc;
|
||||
--bg-canvas-subtle: #f1f5f9;
|
||||
--bg-grid-line: rgba(15, 23, 42, 0.03);
|
||||
|
||||
/* Surface System (Clean White & Subtle Tiers) */
|
||||
--bg-surface: #ffffff;
|
||||
--bg-surface-raised: #f1f5f9;
|
||||
--bg-surface-subtle: #f8fafc;
|
||||
--bg-surface-hover: #e2e8f0;
|
||||
--bg-surface-active: #cbd5e1;
|
||||
--bg-code: #0f172a;
|
||||
|
||||
/* Borders & Dividers */
|
||||
--border-subtle: rgba(15, 23, 42, 0.08);
|
||||
--border-medium: rgba(15, 23, 42, 0.15);
|
||||
--border-strong: rgba(15, 23, 42, 0.25);
|
||||
--border-focus: #0284c7;
|
||||
--border-highlight: rgba(2, 132, 199, 0.35);
|
||||
|
||||
/* High-end Subtle Shadows */
|
||||
--shadow-card: 0 1px 3px 0 rgba(0, 0, 0, 0.03), 0 1px 2px -1px rgba(0, 0, 0, 0.03), 0 0 0 1px rgba(15, 23, 42, 0.07);
|
||||
--shadow-card-hover: 0 4px 12px -2px rgba(15, 23, 42, 0.08), 0 0 0 1px rgba(15, 23, 42, 0.1);
|
||||
--shadow-popup: 0 20px 25px -5px rgba(15, 23, 42, 0.1), 0 8px 10px -6px rgba(15, 23, 42, 0.06), 0 0 0 1px rgba(15, 23, 42, 0.09);
|
||||
--shadow-btn: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
|
||||
|
||||
/* Typography Colors (High Contrast) */
|
||||
--text-primary: #0f172a;
|
||||
--text-secondary: #475569;
|
||||
--text-muted: #64748b;
|
||||
--text-disabled: #94a3b8;
|
||||
--text-inverse: #ffffff;
|
||||
|
||||
/* Accent Colors: DotB/Omni light theme. Bright blues are for large accents
|
||||
and focus decoration only; small text uses the strong/dark variants. */
|
||||
--accent-cyan: #0284c7;
|
||||
--accent-cyan-hover: #0369a1;
|
||||
--accent-cyan-subtle: rgba(2, 132, 199, 0.08);
|
||||
--accent-indigo: #4f46e5;
|
||||
--accent-indigo-subtle: rgba(79, 70, 229, 0.08);
|
||||
--accent-blue: #1f93ff;
|
||||
--accent-blue-strong: #075985;
|
||||
--accent-blue-dark: #075985;
|
||||
--accent-blue-deep: #0c4a6e;
|
||||
--accent-blue-subtle: rgba(31, 147, 255, 0.08);
|
||||
--accent-blue-border: #bae6fd;
|
||||
--focus-ring: rgba(31, 147, 255, 0.35);
|
||||
|
||||
/* Status Colors: text variants meet WCAG AA on light surfaces. */
|
||||
--status-good: #047857;
|
||||
--status-good-text: #047857;
|
||||
--status-good-bg: #ecfdf5;
|
||||
--status-good-border: #a7f3d0;
|
||||
|
||||
--status-warning: #92400e;
|
||||
--status-warning-text: #92400e;
|
||||
--status-warning-bg: #fffbeb;
|
||||
--status-warning-border: #fde68a;
|
||||
|
||||
--status-critical: #be123c;
|
||||
--status-critical-text: #be123c;
|
||||
--status-critical-bg: #fff1f2;
|
||||
--status-critical-border: #fecdd3;
|
||||
|
||||
--status-info: #075985;
|
||||
--status-info-bg: #f0f9ff;
|
||||
--status-info-border: #bae6fd;
|
||||
|
||||
/* Font Families: Inter + System UI fallback (Aligned with DotB-Omni) */
|
||||
--font-sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
--font-mono: 'JetBrains Mono', 'Fira Code', ui-monospace, monospace;
|
||||
|
||||
/* Radii */
|
||||
--radius-xs: 4px;
|
||||
--radius-sm: 6px;
|
||||
--radius-md: 10px;
|
||||
--radius-lg: 14px;
|
||||
--radius-xl: 20px;
|
||||
--radius-full: 9999px;
|
||||
|
||||
/* Transitions */
|
||||
--transition-fast: 150ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
--transition-smooth: 250ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
--transition-spring: 400ms cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
}
|
||||
|
||||
*, *::before, *::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
min-height: 100vh;
|
||||
font-family: var(--font-sans);
|
||||
background-color: var(--bg-canvas);
|
||||
color: var(--text-primary);
|
||||
line-height: 1.5;
|
||||
letter-spacing: -0.01em;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
code, pre, kbd, samp, .font-mono {
|
||||
font-family: var(--font-mono);
|
||||
font-feature-settings: "tnum" 1, "zero" 1;
|
||||
}
|
||||
|
||||
button {
|
||||
font-family: inherit;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* Custom crisp scrollbars */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: rgba(15, 23, 42, 0.15);
|
||||
border-radius: var(--radius-full);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(15, 23, 42, 0.3);
|
||||
}
|
||||
@ -1,209 +0,0 @@
|
||||
import createClient from 'openapi-fetch';
|
||||
import type { paths } from './schema.d';
|
||||
import type {
|
||||
CreateScoringRunRequest,
|
||||
CreateScoringRunResponse,
|
||||
DevConsoleConfig,
|
||||
ListScoringRunsResult,
|
||||
ScoringConfig,
|
||||
ScoringRunResponse,
|
||||
} from './types';
|
||||
|
||||
export const DEFAULT_CONFIG: DevConsoleConfig = {
|
||||
tenantId: 'omni-account-42',
|
||||
dotbApp: 'dev-console',
|
||||
dotbFeature: 'lead-scoring',
|
||||
pollIntervalMs: 1000,
|
||||
};
|
||||
|
||||
export function createApiClient() {
|
||||
return createClient<paths>({
|
||||
baseUrl: '/dev-console-api',
|
||||
});
|
||||
}
|
||||
|
||||
export interface RequestHeaders {
|
||||
tenantId?: string;
|
||||
idempotencyKey?: string;
|
||||
dotbApp?: string;
|
||||
dotbFeature?: string;
|
||||
}
|
||||
|
||||
export async function admitScoringRun(
|
||||
client: ReturnType<typeof createApiClient>,
|
||||
payload: CreateScoringRunRequest,
|
||||
headers: RequestHeaders = {},
|
||||
config: DevConsoleConfig = DEFAULT_CONFIG,
|
||||
): Promise<{ data?: CreateScoringRunResponse; error?: unknown; status: number }> {
|
||||
const idemKey = headers.idempotencyKey ?? crypto.randomUUID();
|
||||
const app = headers.dotbApp ?? config.dotbApp;
|
||||
const feature = headers.dotbFeature ?? config.dotbFeature;
|
||||
|
||||
const res = await client.POST('/v1/scoring-runs', {
|
||||
params: {
|
||||
header: {
|
||||
'X-Tenant-VKey': '',
|
||||
'Idempotency-Key': idemKey,
|
||||
'X-DotB-App': app,
|
||||
'X-DotB-Feature': feature,
|
||||
},
|
||||
},
|
||||
body: payload,
|
||||
});
|
||||
|
||||
return {
|
||||
data: res.data,
|
||||
error: res.error,
|
||||
status: res.response.status,
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchScoringRun(
|
||||
client: ReturnType<typeof createApiClient>,
|
||||
runId: string,
|
||||
tenantId: string = DEFAULT_CONFIG.tenantId,
|
||||
): Promise<{ data?: ScoringRunResponse; error?: unknown; status: number }> {
|
||||
const res = await client.GET('/v1/scoring-runs/{run_id}', {
|
||||
params: {
|
||||
path: { run_id: runId },
|
||||
header: {
|
||||
'X-Tenant-Id': tenantId,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
data: res.data,
|
||||
error: res.error,
|
||||
status: res.response.status,
|
||||
};
|
||||
}
|
||||
|
||||
export async function listScoringRuns(
|
||||
client: ReturnType<typeof createApiClient>,
|
||||
tenantId: string = DEFAULT_CONFIG.tenantId,
|
||||
limit: number = 50,
|
||||
offset: number = 0,
|
||||
): Promise<ListScoringRunsResult> {
|
||||
const res = await client.GET('/v1/scoring-runs', {
|
||||
params: {
|
||||
header: {
|
||||
'X-Tenant-Id': tenantId,
|
||||
},
|
||||
query: {
|
||||
limit,
|
||||
offset,
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!res.data) throw new Error(`Không tải được danh sách phiên chấm điểm: HTTP ${res.response?.status}`);
|
||||
const headerTotal =
|
||||
res.response?.headers?.get?.('x-total-count') ??
|
||||
res.response?.headers?.get?.('X-Total-Count');
|
||||
const parsedTotal = headerTotal ? parseInt(headerTotal, 10) : NaN;
|
||||
const total = Number.isNaN(parsedTotal) ? res.data.length : parsedTotal;
|
||||
return {
|
||||
items: res.data,
|
||||
total,
|
||||
};
|
||||
}
|
||||
|
||||
export async function listScoringConfigs(
|
||||
client: ReturnType<typeof createApiClient>,
|
||||
config: DevConsoleConfig = DEFAULT_CONFIG,
|
||||
): Promise<ScoringConfig[]> {
|
||||
const res = await client.GET('/v1/scoring-runs/configs', {
|
||||
params: {
|
||||
header: {
|
||||
'X-Tenant-Id': config.tenantId,
|
||||
'X-DotB-App': config.dotbApp,
|
||||
'X-DotB-Feature': config.dotbFeature,
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!res.data) throw new Error(`Không tải được cấu hình: HTTP ${res.response.status}`);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function saveScoringConfig(
|
||||
client: ReturnType<typeof createApiClient>,
|
||||
scoringConfig: ScoringConfig,
|
||||
config: DevConsoleConfig = DEFAULT_CONFIG,
|
||||
): Promise<ScoringConfig> {
|
||||
const res = await client.POST('/v1/scoring-runs/configs', {
|
||||
params: {
|
||||
header: {
|
||||
'X-Tenant-Id': config.tenantId,
|
||||
'X-DotB-App': config.dotbApp,
|
||||
'X-DotB-Feature': config.dotbFeature,
|
||||
},
|
||||
},
|
||||
body: scoringConfig,
|
||||
});
|
||||
if (res.response.status === 409) {
|
||||
throw new Error(
|
||||
`Phiên bản "${scoringConfig.version}" đã tồn tại. Mỗi cấu hình là bất biến (immutable), vui lòng đổi tên phiên bản mới (hoặc bấm "Tạo mã phiên bản mới").`,
|
||||
);
|
||||
}
|
||||
if (!res.data) {
|
||||
const errorDetail =
|
||||
(res.error as Record<string, unknown>)?.detail ??
|
||||
JSON.stringify(res.error) ??
|
||||
`HTTP ${res.response.status}`;
|
||||
throw new Error(`Không lưu được cấu hình: ${String(errorDetail)}`);
|
||||
}
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function fetchExecutionTrace(
|
||||
client: ReturnType<typeof createApiClient>,
|
||||
runId: string,
|
||||
tenantId: string,
|
||||
): Promise<Record<string, unknown>> {
|
||||
const res = await client.GET('/v1/scoring-runs/{run_id}/trace', {
|
||||
params: { path: { run_id: runId }, header: { 'X-Tenant-Id': tenantId } },
|
||||
});
|
||||
if (!res.data) throw new Error(`Không tải được pipeline trace: HTTP ${res.response.status}`);
|
||||
return res.data as Record<string, unknown>;
|
||||
}
|
||||
|
||||
export const TERMINAL_STATUSES = new Set([
|
||||
'completed',
|
||||
'review_required',
|
||||
'failed',
|
||||
'superseded',
|
||||
]);
|
||||
|
||||
export async function pollUntilTerminal(
|
||||
client: ReturnType<typeof createApiClient>,
|
||||
runId: string,
|
||||
options: {
|
||||
tenantId?: string;
|
||||
intervalMs?: number;
|
||||
maxAttempts?: number;
|
||||
onTick?: (run: ScoringRunResponse) => void;
|
||||
} = {},
|
||||
): Promise<ScoringRunResponse> {
|
||||
const intervalMs = options.intervalMs ?? DEFAULT_CONFIG.pollIntervalMs;
|
||||
const maxAttempts = options.maxAttempts ?? 30;
|
||||
const tenantId = options.tenantId ?? DEFAULT_CONFIG.tenantId;
|
||||
|
||||
for (let i = 0; i < maxAttempts; i++) {
|
||||
const res = await fetchScoringRun(client, runId, tenantId);
|
||||
if (!res.data) {
|
||||
throw new Error(`Failed to fetch run: ${JSON.stringify(res.error)} (HTTP ${res.status})`);
|
||||
}
|
||||
|
||||
if (options.onTick) {
|
||||
options.onTick(res.data);
|
||||
}
|
||||
|
||||
if (TERMINAL_STATUSES.has(res.data.status)) {
|
||||
return res.data;
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, intervalMs));
|
||||
}
|
||||
|
||||
throw new Error(`Polling timed out after ${maxAttempts} attempts for run ${runId}`);
|
||||
}
|
||||
1122
old_repo/apps/dev-console/src/lib/api/schema.d.ts
vendored
@ -1,38 +0,0 @@
|
||||
import type { components, paths } from './schema.d';
|
||||
|
||||
export type CreateScoringRunRequest = components['schemas']['CreateScoringRunRequest'];
|
||||
export type CreateScoringRunResponse = components['schemas']['CreateScoringRunResponse'];
|
||||
export type ScoringRunResponse = components['schemas']['ScoringRunResponse'];
|
||||
export type CanonicalSnapshot = components['schemas']['CanonicalSnapshot'];
|
||||
export type ScoringConfig = components['schemas']['ScoringConfig'];
|
||||
export type BehavioralMetric = components['schemas']['BehavioralMetric'];
|
||||
export type DynamicRule = components['schemas']['DynamicRule'];
|
||||
export type DynamicRuleOperator = components['schemas']['DynamicRuleOperator'];
|
||||
export type DynamicRuleTarget = components['schemas']['DynamicRuleTarget'];
|
||||
export type ScoringRunStatus = components['schemas']['ScoringRunStatus'];
|
||||
export type ErrorResponse = components['schemas']['ErrorResponse'];
|
||||
export type AiMetadataSuccess = components['schemas']['AiMetadataSuccess'];
|
||||
export type AiMetadataFailure = components['schemas']['AiMetadataFailure'];
|
||||
|
||||
export interface DevConsoleConfig {
|
||||
tenantId: string;
|
||||
dotbApp: string;
|
||||
dotbFeature: string;
|
||||
pollIntervalMs: number;
|
||||
}
|
||||
|
||||
export interface ListScoringRunsResult {
|
||||
items: ScoringRunResponse[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface GoldenFixture {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
expectedStatus: ScoringRunStatus;
|
||||
expectedMode?: string;
|
||||
category?: string;
|
||||
coverImage?: string;
|
||||
payload: CreateScoringRunRequest;
|
||||
}
|
||||
@ -1,302 +0,0 @@
|
||||
<script lang="ts">
|
||||
import Icon from './Icon.svelte';
|
||||
|
||||
const plannedSignals = [
|
||||
{ title: 'Tần suất điểm danh & Nghỉ học', desc: 'Số buổi nghỉ liên tiếp, tỷ lệ đi học giảm sút so với tháng trước từ DOTB EMS.', weight: '35%' },
|
||||
{ title: 'Tiến độ học tập & Điểm số LMS', desc: 'Điểm bài kiểm tra định kỳ, tỷ lệ nộp bài tập về nhà và thời gian truy cập portal.', weight: '30%' },
|
||||
{ title: 'Tương tác phụ huynh & Đóng phí', desc: 'Độ trễ đóng học phí tái tục, cảm xúc tin nhắn phàn nàn trên kênh Zalo/Facebook.', weight: '25%' },
|
||||
{ title: 'Biến động giáo viên & Lớp học', desc: 'Lớp thay đổi giáo viên hoặc ghép lớp làm giảm trải nghiệm học tập của học viên.', weight: '10%' },
|
||||
];
|
||||
</script>
|
||||
|
||||
<div class="churn-container">
|
||||
<div class="panel churn-panel">
|
||||
<div class="panel-header">
|
||||
<div>
|
||||
<div class="status-badge-row">
|
||||
<span class="status-badge dev">In Development</span>
|
||||
<span class="phase-tag">Dự án giai đoạn tiếp theo · Q4/2026</span>
|
||||
</div>
|
||||
<h2>Dự đoán học viên rời bỏ (Student Churn Prediction)</h2>
|
||||
<p class="panel-desc">Mô hình AI dự báo sớm nguy cơ học viên nghỉ học hoặc không tái tục trước 30 ngày để can thiệp kịp thời.</p>
|
||||
</div>
|
||||
<div class="target-crm-tag">
|
||||
<span>Tích hợp:</span>
|
||||
<code>DOTB EMS · LMS · Omni</code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sơ đồ quy trình dự báo -->
|
||||
<div class="workflow-preview">
|
||||
<div class="workflow-step">
|
||||
<span class="step-num">01</span>
|
||||
<strong>Thu thập tín hiệu</strong>
|
||||
<p>Hệ thống tự động đồng bộ sự kiện điểm danh, kết quả bài tập và hội thoại từ EMS & Omni.</p>
|
||||
</div>
|
||||
<div class="workflow-arrow">→</div>
|
||||
<div class="workflow-step">
|
||||
<span class="step-num">02</span>
|
||||
<strong>Mô hình AI phân tích</strong>
|
||||
<p>Mô hình kết hợp ML phân tích chuỗi thời gian + LLM đánh giá cảm xúc phụ huynh.</p>
|
||||
</div>
|
||||
<div class="workflow-arrow">→</div>
|
||||
<div class="workflow-step highlight">
|
||||
<span class="step-num">03</span>
|
||||
<strong>Cảnh báo & Can thiệp</strong>
|
||||
<p>Gắn nhãn rủi ro Churn (High / Medium / Low) và tự động tạo việc cho tư vấn viên chăm sóc.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bảng trọng số tín hiệu dự kiến -->
|
||||
<div class="signals-section">
|
||||
<h3>Bộ chỉ số tín hiệu dự báo sớm</h3>
|
||||
<div class="signals-grid">
|
||||
{#each plannedSignals as signal}
|
||||
<div class="signal-card">
|
||||
<div class="signal-header">
|
||||
<strong>{signal.title}</strong>
|
||||
<span class="signal-weight">{signal.weight}</span>
|
||||
</div>
|
||||
<p>{signal.desc}</p>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Thông báo lộ trình -->
|
||||
<div class="roadmap-notice">
|
||||
<div class="roadmap-icon">
|
||||
<Icon name="git-branch" size={18} />
|
||||
</div>
|
||||
<div class="roadmap-text">
|
||||
<strong>Lộ trình triển khai</strong>
|
||||
<p>Tính năng này sẽ được phát triển tiếp nối sau khi hoàn tất giai đoạn Customer Pilot của phân hệ Lead Scoring. Bộ API và Webhook sẽ kế thừa chuẩn kiến trúc bảo mật dùng chung của AI Platform.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.churn-container {
|
||||
max-width: 1440px;
|
||||
margin: 0 auto;
|
||||
padding: 24px 28px 48px;
|
||||
}
|
||||
|
||||
.churn-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 28px;
|
||||
padding: 32px;
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow-card);
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 20px;
|
||||
padding-bottom: 20px;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.status-badge-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.status-badge.dev {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px 8px;
|
||||
border-radius: var(--radius-full);
|
||||
background: #fef3c7;
|
||||
color: #92400e;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.02em;
|
||||
border: 1px solid #fde68a;
|
||||
}
|
||||
|
||||
.phase-tag {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.76rem;
|
||||
}
|
||||
|
||||
.panel-header h2 {
|
||||
margin: 0 0 6px;
|
||||
color: var(--text-primary);
|
||||
font-size: 1.4rem;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.panel-desc {
|
||||
margin: 0;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.88rem;
|
||||
max-width: 70ch;
|
||||
}
|
||||
|
||||
.target-crm-tag {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 12px;
|
||||
background: var(--bg-surface-subtle);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.target-crm-tag code {
|
||||
color: var(--accent-blue-strong);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Workflow Preview */
|
||||
.workflow-preview {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto 1fr auto 1fr;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 20px;
|
||||
background: var(--bg-surface-subtle);
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.workflow-step {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 16px;
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.workflow-step.highlight {
|
||||
border-color: var(--accent-blue);
|
||||
background: var(--accent-blue-subtle);
|
||||
}
|
||||
|
||||
.step-num {
|
||||
font-size: 0.68rem;
|
||||
font-weight: 800;
|
||||
color: var(--accent-blue-strong);
|
||||
}
|
||||
|
||||
.workflow-step strong {
|
||||
font-size: 0.88rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.workflow-step p {
|
||||
margin: 0;
|
||||
font-size: 0.76rem;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.workflow-arrow {
|
||||
color: var(--border-medium);
|
||||
font-size: 1.25rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
/* Signals Section */
|
||||
.signals-section h3 {
|
||||
margin: 0 0 16px;
|
||||
font-size: 1rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.signals-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.signal-card {
|
||||
padding: 16px;
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.signal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.signal-header strong {
|
||||
font-size: 0.82rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.signal-weight {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
color: var(--accent-blue-strong);
|
||||
padding: 2px 6px;
|
||||
background: var(--accent-blue-subtle);
|
||||
border-radius: var(--radius-xs);
|
||||
}
|
||||
|
||||
.signal-card p {
|
||||
margin: 0;
|
||||
font-size: 0.76rem;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
/* Roadmap Notice */
|
||||
.roadmap-notice {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 14px;
|
||||
padding: 16px 20px;
|
||||
background: #f8fafc;
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-left: 3px solid var(--accent-blue);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.roadmap-icon {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.roadmap-text strong {
|
||||
display: block;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.roadmap-text p {
|
||||
margin: 0;
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.workflow-preview {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.workflow-arrow {
|
||||
display: none;
|
||||
}
|
||||
.signals-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -1,616 +0,0 @@
|
||||
<script lang="ts">
|
||||
import Icon from './Icon.svelte';
|
||||
import type { DynamicRule, DynamicRuleOperator, DynamicRuleTarget } from '../api/types';
|
||||
|
||||
interface Props {
|
||||
rules: DynamicRule[];
|
||||
sampleData?: {
|
||||
studentAge?: number;
|
||||
leadSource?: string;
|
||||
courseInterest?: string;
|
||||
studyArea?: string;
|
||||
customFields?: Record<string, any>;
|
||||
contactRecencyDays?: number;
|
||||
conversationCount?: number;
|
||||
messageCount?: number;
|
||||
};
|
||||
}
|
||||
|
||||
let { rules = $bindable([]), sampleData = {} }: Props = $props();
|
||||
|
||||
const targetOptions: { value: DynamicRuleTarget; label: string; isCustom?: boolean }[] = [
|
||||
{ value: 'student_age', label: 'Tuổi học viên (student_age)' },
|
||||
{ value: 'lead_source', label: 'Nguồn tiếp cận (lead_source)' },
|
||||
{ value: 'course_interest', label: 'Khóa học quan tâm (course_interest)' },
|
||||
{ value: 'study_area', label: 'Khu vực / Cơ sở (study_area)' },
|
||||
{ value: 'contact_message_recency_days', label: 'Số ngày từ tin nhắn gần nhất' },
|
||||
{ value: 'conversation_count', label: 'Số cuộc hội thoại' },
|
||||
{ value: 'message_count', label: 'Số tin nhắn tương tác' },
|
||||
{ value: 'custom_field', label: 'Thuộc tính mở rộng CRM (custom_fields)', isCustom: true },
|
||||
];
|
||||
|
||||
const operatorOptions: { value: DynamicRuleOperator; label: string }[] = [
|
||||
{ value: 'gte', label: '≥ (Lớn hơn hoặc bằng)' },
|
||||
{ value: 'gt', label: '> (Lớn hơn)' },
|
||||
{ value: 'lte', label: '≤ (Nhỏ hơn hoặc bằng)' },
|
||||
{ value: 'lt', label: '< (Nhỏ hơn)' },
|
||||
{ value: 'eq', label: '= (Bằng chính xác)' },
|
||||
{ value: 'neq', label: '≠ (Khác)' },
|
||||
{ value: 'contains', label: 'chứa (Contains text)' },
|
||||
{ value: 'in', label: 'nằm trong danh sách (In)' },
|
||||
];
|
||||
|
||||
const presets = [
|
||||
{
|
||||
name: 'Ngân sách trên 5 triệu',
|
||||
target: 'custom_field' as DynamicRuleTarget,
|
||||
custom_field_key: 'budget',
|
||||
operator: 'gte' as DynamicRuleOperator,
|
||||
value: 5000000,
|
||||
points: 15,
|
||||
},
|
||||
{
|
||||
name: 'Cơ sở Quận 3',
|
||||
target: 'study_area' as DynamicRuleTarget,
|
||||
operator: 'contains' as DynamicRuleOperator,
|
||||
value: 'Quận 3',
|
||||
points: 10,
|
||||
},
|
||||
{
|
||||
name: 'Nhu cầu học cấp bách',
|
||||
target: 'custom_field' as DynamicRuleTarget,
|
||||
custom_field_key: 'urgency',
|
||||
operator: 'in' as DynamicRuleOperator,
|
||||
value: 'high, urgent, gap',
|
||||
points: 10,
|
||||
},
|
||||
{
|
||||
name: 'Học viên 6 đến 12 tuổi',
|
||||
target: 'student_age' as DynamicRuleTarget,
|
||||
operator: 'gte' as DynamicRuleOperator,
|
||||
value: 6,
|
||||
points: 10,
|
||||
},
|
||||
];
|
||||
|
||||
function generateRuleId() {
|
||||
return `rule_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`;
|
||||
}
|
||||
|
||||
function addEmptyRule() {
|
||||
const newId = generateRuleId();
|
||||
rules = [
|
||||
...rules,
|
||||
{
|
||||
id: newId,
|
||||
name: `Quy tắc mới #${rules.length + 1}`,
|
||||
target: 'custom_field',
|
||||
custom_field_key: 'budget',
|
||||
operator: 'gte',
|
||||
value: '5000000',
|
||||
points: 10,
|
||||
enabled: true,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function applyPreset(preset: (typeof presets)[0]) {
|
||||
const newId = generateRuleId();
|
||||
rules = [
|
||||
...rules,
|
||||
{
|
||||
id: newId,
|
||||
name: preset.name,
|
||||
target: preset.target,
|
||||
custom_field_key: preset.custom_field_key,
|
||||
operator: preset.operator,
|
||||
value: preset.value,
|
||||
points: preset.points,
|
||||
enabled: true,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function deleteRule(index: number) {
|
||||
rules = rules.filter((_, i) => i !== index);
|
||||
}
|
||||
|
||||
function stripAccents(s: string): string {
|
||||
return s
|
||||
.replace(/đ/g, 'd')
|
||||
.replace(/Đ/g, 'd')
|
||||
.normalize('NFD')
|
||||
.replace(/[̀-ͯ]/g, '');
|
||||
}
|
||||
|
||||
// Client-side quick preview of rule against sampleData
|
||||
function evaluateRuleLive(rule: DynamicRule): { status: 'matched' | 'not_matched' | 'missing' | 'disabled'; detail: string } {
|
||||
if (!rule.enabled) return { status: 'disabled', detail: 'Tạm tắt' };
|
||||
|
||||
let actualVal: any = undefined;
|
||||
if (rule.target === 'student_age') actualVal = sampleData.studentAge;
|
||||
else if (rule.target === 'lead_source') actualVal = sampleData.leadSource;
|
||||
else if (rule.target === 'course_interest') actualVal = sampleData.courseInterest;
|
||||
else if (rule.target === 'study_area') actualVal = sampleData.studyArea;
|
||||
else if (rule.target === 'contact_message_recency_days') actualVal = sampleData.contactRecencyDays;
|
||||
else if (rule.target === 'conversation_count') actualVal = sampleData.conversationCount ?? 1;
|
||||
else if (rule.target === 'message_count') actualVal = sampleData.messageCount ?? 1;
|
||||
else if (rule.target === 'custom_field' && rule.custom_field_key) {
|
||||
actualVal = sampleData.customFields?.[rule.custom_field_key];
|
||||
}
|
||||
|
||||
if (actualVal === undefined || actualVal === null || actualVal === '') {
|
||||
return { status: 'missing', detail: 'Thiếu dữ liệu trên Lead mẫu' };
|
||||
}
|
||||
|
||||
try {
|
||||
const actStr = String(actualVal).trim().toLowerCase();
|
||||
const tgtStr = String(rule.value).trim().toLowerCase();
|
||||
const actStripped = stripAccents(actStr);
|
||||
const tgtStripped = stripAccents(tgtStr);
|
||||
|
||||
const actNum = Number(actualVal);
|
||||
const tgtNum = Number(rule.value);
|
||||
|
||||
if (['gt', 'gte', 'lt', 'lte'].includes(rule.operator) && !isNaN(actNum) && !isNaN(tgtNum)) {
|
||||
let isMatch = false;
|
||||
if (rule.operator === 'gt') isMatch = actNum > tgtNum;
|
||||
if (rule.operator === 'gte') isMatch = actNum >= tgtNum;
|
||||
if (rule.operator === 'lt') isMatch = actNum < tgtNum;
|
||||
if (rule.operator === 'lte') isMatch = actNum <= tgtNum;
|
||||
return isMatch
|
||||
? { status: 'matched', detail: `Khớp: ${actualVal} ${rule.operator} ${rule.value}` }
|
||||
: { status: 'not_matched', detail: `Không khớp: ${actualVal}` };
|
||||
}
|
||||
|
||||
if (rule.operator === 'eq') {
|
||||
const isMatch = actStr === tgtStr || actStripped === tgtStripped;
|
||||
return isMatch
|
||||
? { status: 'matched', detail: `Khớp: '${actualVal}'` }
|
||||
: { status: 'not_matched', detail: `Khác: '${actualVal}'` };
|
||||
}
|
||||
|
||||
if (rule.operator === 'neq') {
|
||||
const isMatch = actStr !== tgtStr && actStripped !== tgtStripped;
|
||||
return isMatch
|
||||
? { status: 'matched', detail: `Khác: '${actualVal}' ≠ '${rule.value}'` }
|
||||
: { status: 'not_matched', detail: `Trùng: '${actualVal}'` };
|
||||
}
|
||||
|
||||
if (rule.operator === 'contains') {
|
||||
const isMatch = actStr.includes(tgtStr) || actStripped.includes(tgtStripped);
|
||||
return isMatch
|
||||
? { status: 'matched', detail: `Chứa '${rule.value}'` }
|
||||
: { status: 'not_matched', detail: `Không chứa '${rule.value}'` };
|
||||
}
|
||||
|
||||
if (rule.operator === 'in') {
|
||||
const items = tgtStr.split(',').map((s) => s.trim());
|
||||
const isMatch = items.some(
|
||||
(item) => item === actStr || stripAccents(item) === actStripped
|
||||
);
|
||||
return isMatch
|
||||
? { status: 'matched', detail: `Nằm trong danh sách` }
|
||||
: { status: 'not_matched', detail: `Không thuộc danh sách` };
|
||||
}
|
||||
} catch {
|
||||
return { status: 'not_matched', detail: 'Lỗi so sánh dữ liệu' };
|
||||
}
|
||||
|
||||
return { status: 'not_matched', detail: 'Không khớp' };
|
||||
}
|
||||
|
||||
let totalLiveBonus = $derived(
|
||||
rules.reduce((acc, r) => {
|
||||
const res = evaluateRuleLive(r);
|
||||
return res.status === 'matched' ? acc + r.points : acc;
|
||||
}, 0),
|
||||
);
|
||||
</script>
|
||||
|
||||
<div class="dynamic-rules-wrap">
|
||||
<div class="rules-header-row">
|
||||
<div>
|
||||
<h4 class="rules-title">Customer rules mô phỏng request Omni</h4>
|
||||
<p class="rules-desc">
|
||||
Chỉ dùng cho phiên thử nghiệm hiện tại, mô phỏng rule_overrides do Omni gửi và không lưu vào AI config.
|
||||
</p>
|
||||
</div>
|
||||
<div class="rules-header-actions">
|
||||
{#if sampleData && Object.keys(sampleData).length > 0}
|
||||
<span class="live-bonus-chip" class:positive={totalLiveBonus > 0}>
|
||||
Test trên Lead mẫu: <strong>+{totalLiveBonus}đ</strong>
|
||||
</span>
|
||||
{/if}
|
||||
<button class="add-rule-btn" type="button" onclick={addEmptyRule}>
|
||||
<Icon name="plus" size={13} />
|
||||
<span>Thêm quy tắc</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Preset Suggestions -->
|
||||
<div class="presets-bar">
|
||||
<span class="preset-label">Mẫu gợi ý:</span>
|
||||
{#each presets as preset}
|
||||
<button class="preset-chip" type="button" onclick={() => applyPreset(preset)}>
|
||||
+ {preset.name} (+{preset.points}đ)
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Rules List -->
|
||||
{#if rules.length === 0}
|
||||
<div class="empty-rules-box">
|
||||
<Icon name="sliders" size={20} class="text-muted" />
|
||||
<p>Chưa có quy tắc mở rộng nào. Bấm <strong>"Thêm quy tắc"</strong> hoặc chọn mẫu gợi ý phía trên.</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="rules-table-wrap">
|
||||
<table class="rules-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 40px;">Bật</th>
|
||||
<th style="width: 180px;">Tên quy tắc</th>
|
||||
<th style="width: 220px;">Thuộc tính mục tiêu</th>
|
||||
<th style="width: 160px;">Toán tử</th>
|
||||
<th style="width: 140px;">Giá trị so sánh</th>
|
||||
<th style="width: 80px;">Điểm</th>
|
||||
<th>Kiểm thử Lead mẫu</th>
|
||||
<th style="width: 40px;"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each rules as rule, index (rule.id || index)}
|
||||
{@const testRes = evaluateRuleLive(rule)}
|
||||
<tr class:rule-disabled={!rule.enabled}>
|
||||
<td class="cell-center">
|
||||
<input type="checkbox" bind:checked={rule.enabled} aria-label="Bật/Tắt quy tắc" />
|
||||
</td>
|
||||
<td>
|
||||
<input class="rule-input name-input" type="text" bind:value={rule.name} placeholder="Tên quy tắc..." />
|
||||
</td>
|
||||
<td>
|
||||
<div class="target-field-stack">
|
||||
<select class="rule-select" bind:value={rule.target}>
|
||||
{#each targetOptions as opt}
|
||||
<option value={opt.value}>{opt.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
{#if rule.target === 'custom_field'}
|
||||
<input
|
||||
class="rule-input custom-key-input"
|
||||
type="text"
|
||||
bind:value={rule.custom_field_key}
|
||||
placeholder="Tên custom_field (ví dụ: budget, urgency)..."
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<select class="rule-select" bind:value={rule.operator}>
|
||||
{#each operatorOptions as op}
|
||||
<option value={op.value}>{op.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</td>
|
||||
<td>
|
||||
<input class="rule-input" type="text" bind:value={rule.value} placeholder="Giá trị..." />
|
||||
</td>
|
||||
<td>
|
||||
<input class="rule-input points-input" type="number" min="-100" max="100" bind:value={rule.points} />
|
||||
</td>
|
||||
<td>
|
||||
<div class="live-test-cell">
|
||||
{#if testRes.status === 'matched'}
|
||||
<span class="test-badge matched" title={testRes.detail}>
|
||||
<Icon name="check" size={12} />
|
||||
<span>+{rule.points}đ</span>
|
||||
</span>
|
||||
{:else if testRes.status === 'not_matched'}
|
||||
<span class="test-badge not-matched" title={testRes.detail}>0đ</span>
|
||||
{:else if testRes.status === 'missing'}
|
||||
<span class="test-badge missing" title={testRes.detail}>Chưa có</span>
|
||||
{:else}
|
||||
<span class="test-badge disabled">Tắt</span>
|
||||
{/if}
|
||||
<span class="test-detail-text">{testRes.detail}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="cell-center">
|
||||
<button
|
||||
class="del-btn"
|
||||
type="button"
|
||||
title="Xóa quy tắc"
|
||||
onclick={() => deleteRule(index)}
|
||||
>
|
||||
<Icon name="trash" size={13} />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.dynamic-rules-wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
background: #ffffff;
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.rules-header-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.rules-title {
|
||||
margin: 0 0 2px;
|
||||
font-size: 0.92rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.rules-desc {
|
||||
margin: 0;
|
||||
font-size: 0.76rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.rules-header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.live-bonus-chip {
|
||||
font-size: 0.72rem;
|
||||
padding: 3px 8px;
|
||||
border-radius: var(--radius-full);
|
||||
background: #f1f5f9;
|
||||
color: var(--text-secondary);
|
||||
border: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.live-bonus-chip.positive {
|
||||
background: #ecfdf5;
|
||||
color: #047857;
|
||||
border-color: #a7f3d0;
|
||||
}
|
||||
|
||||
.add-rule-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 4px 10px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--accent-blue-subtle);
|
||||
border: 1px solid var(--accent-blue-border);
|
||||
color: var(--accent-blue-strong);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.add-rule-btn:hover {
|
||||
background: var(--bg-surface-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* Presets Bar */
|
||||
.presets-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
padding: 8px 10px;
|
||||
background: var(--bg-surface-subtle);
|
||||
border-radius: var(--radius-xs);
|
||||
border: 1px dashed var(--border-medium);
|
||||
}
|
||||
|
||||
.preset-label {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.preset-chip {
|
||||
padding: 2px 8px;
|
||||
border-radius: var(--radius-xs);
|
||||
background: #ffffff;
|
||||
border: 1px solid var(--border-subtle);
|
||||
font-size: 0.72rem;
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.preset-chip:hover {
|
||||
border-color: var(--accent-blue);
|
||||
color: var(--accent-blue-strong);
|
||||
background: var(--accent-blue-subtle);
|
||||
}
|
||||
|
||||
/* Empty state */
|
||||
.empty-rules-box {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 24px;
|
||||
background: var(--bg-surface-subtle);
|
||||
border-radius: var(--radius-xs);
|
||||
text-align: center;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.empty-rules-box p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Table */
|
||||
.rules-table-wrap {
|
||||
overflow-x: auto;
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-xs);
|
||||
}
|
||||
|
||||
.rules-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.76rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.rules-table th {
|
||||
background: var(--bg-surface-subtle);
|
||||
padding: 8px 10px;
|
||||
font-weight: 700;
|
||||
color: var(--text-secondary);
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.rules-table td {
|
||||
padding: 6px 8px;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.rules-table tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.rule-disabled td {
|
||||
opacity: 0.55;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.cell-center {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.rule-input,
|
||||
.rule-select {
|
||||
width: 100%;
|
||||
padding: 5px 8px;
|
||||
font-size: 0.75rem;
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-xs);
|
||||
background: #ffffff;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.rule-input:focus,
|
||||
.rule-select:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent-blue);
|
||||
}
|
||||
|
||||
.name-input {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.points-input {
|
||||
width: 65px;
|
||||
font-family: var(--font-mono);
|
||||
font-weight: 700;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.target-field-stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.custom-key-input {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.7rem;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
/* Live test cell */
|
||||
.live-test-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.test-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
padding: 1px 6px;
|
||||
border-radius: var(--radius-xs);
|
||||
font-size: 0.68rem;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.test-badge.matched {
|
||||
background: #ecfdf5;
|
||||
color: #047857;
|
||||
border: 1px solid #a7f3d0;
|
||||
}
|
||||
|
||||
.test-badge.not-matched {
|
||||
background: #f1f5f9;
|
||||
color: #64748b;
|
||||
border: 1px solid #cbd5e1;
|
||||
}
|
||||
|
||||
.test-badge.missing {
|
||||
background: #fffbeb;
|
||||
color: #b45309;
|
||||
border: 1px solid #fde68a;
|
||||
}
|
||||
|
||||
.test-badge.disabled {
|
||||
background: #e2e8f0;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.test-detail-text {
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-muted);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 140px;
|
||||
}
|
||||
|
||||
.del-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
border-radius: var(--radius-xs);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.del-btn:hover {
|
||||
color: var(--status-critical);
|
||||
background: var(--status-critical-bg);
|
||||
}
|
||||
</style>
|
||||
@ -1,518 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { GOLDEN_FIXTURES } from '../fixtures/golden';
|
||||
import { consoleState } from '../state/consoleState.svelte';
|
||||
import Icon from './Icon.svelte';
|
||||
|
||||
let isEditingJson = $state(false);
|
||||
let jsonError = $state<string | null>(null);
|
||||
let copiedPayload = $state(false);
|
||||
|
||||
// Validate JSON on the fly
|
||||
$effect(() => {
|
||||
try {
|
||||
JSON.parse(consoleState.customPayloadJson);
|
||||
jsonError = null;
|
||||
} catch (e) {
|
||||
jsonError = e instanceof Error ? e.message : 'Invalid JSON syntax';
|
||||
}
|
||||
});
|
||||
|
||||
function formatJson() {
|
||||
try {
|
||||
const parsed = JSON.parse(consoleState.customPayloadJson);
|
||||
consoleState.customPayloadJson = JSON.stringify(parsed, null, 2);
|
||||
jsonError = null;
|
||||
} catch {
|
||||
jsonError = 'Cannot format invalid JSON';
|
||||
}
|
||||
}
|
||||
|
||||
function resetToFixture() {
|
||||
consoleState.customPayloadJson = JSON.stringify(consoleState.selectedFixture.payload, null, 2);
|
||||
jsonError = null;
|
||||
}
|
||||
|
||||
async function copyPayload() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(consoleState.customPayloadJson);
|
||||
copiedPayload = true;
|
||||
setTimeout(() => (copiedPayload = false), 2000);
|
||||
} catch {}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="fixture-panel">
|
||||
<!-- Card Header -->
|
||||
<div class="fixture-header">
|
||||
<div class="header-left">
|
||||
<div class="icon-bubble">
|
||||
<Icon name="layers" size={17} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="panel-title">Kịch bản kiểm thử chuẩn</h3>
|
||||
<span class="panel-subtitle">Bộ dữ liệu lead mẫu và ảnh chụp hội thoại chuẩn</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="btn-toggle-editor {isEditingJson ? 'active' : ''}"
|
||||
onclick={() => (isEditingJson = !isEditingJson)}
|
||||
title="Bật tắt trình chỉnh sửa dữ liệu JSON"
|
||||
>
|
||||
<Icon name="sliders" size={13} />
|
||||
<span>{isEditingJson ? 'Ẩn trình sửa' : 'Sửa payload'}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Scenario Catalog List with Architectural Thumbnails -->
|
||||
<div class="scenario-list" role="radiogroup" aria-label="Danh sách kịch bản">
|
||||
{#each GOLDEN_FIXTURES as fixture}
|
||||
{@const isSelected = consoleState.selectedFixture.id === fixture.id}
|
||||
<button
|
||||
class="scenario-card {isSelected ? 'active' : ''}"
|
||||
role="radio"
|
||||
aria-checked={isSelected}
|
||||
onclick={() => consoleState.selectFixture(fixture)}
|
||||
>
|
||||
<div class="scenario-layout">
|
||||
{#if fixture.coverImage}
|
||||
<div class="scenario-thumb-wrap">
|
||||
<img src={fixture.coverImage} alt={fixture.name} class="scenario-thumb" />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="scenario-details">
|
||||
<div class="scenario-top">
|
||||
<div class="scenario-cat-group">
|
||||
<span class="scenario-cat font-mono">{fixture.category ?? 'KỊCH BẢN'}</span>
|
||||
</div>
|
||||
<span class="expect-pill status-{fixture.expectedStatus} font-mono">
|
||||
{fixture.expectedStatus}
|
||||
</span>
|
||||
</div>
|
||||
<strong class="scenario-name">{fixture.name}</strong>
|
||||
<p class="scenario-desc">{fixture.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Payload Editor Drawer -->
|
||||
{#if isEditingJson}
|
||||
<div class="editor-drawer">
|
||||
<div class="editor-bar">
|
||||
<div class="editor-status">
|
||||
{#if jsonError}
|
||||
<span class="status-err font-mono">
|
||||
<Icon name="alert-circle" size={12} />
|
||||
<span>JSON không hợp lệ</span>
|
||||
</span>
|
||||
{:else}
|
||||
<span class="status-ok font-mono">
|
||||
<Icon name="check" size={12} />
|
||||
<span>Cấu trúc hợp lệ</span>
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="editor-tools">
|
||||
<button class="btn-tool" onclick={formatJson} title="Căn chỉnh thụt dòng JSON">
|
||||
Định dạng
|
||||
</button>
|
||||
<button class="btn-tool" onclick={resetToFixture} title="Đặt lại theo mặc định kịch bản">
|
||||
Đặt lại
|
||||
</button>
|
||||
<button class="btn-tool" onclick={copyPayload} title="Sao chép JSON payload">
|
||||
<Icon name={copiedPayload ? 'check' : 'copy'} size={11} />
|
||||
<span>{copiedPayload ? 'Đã chép' : 'Sao chép'}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
id="json-payload"
|
||||
bind:value={consoleState.customPayloadJson}
|
||||
rows="9"
|
||||
spellcheck="false"
|
||||
class="payload-textarea font-mono {jsonError ? 'error-ring' : ''}"
|
||||
placeholder="Dán dữ liệu JSON payload..."
|
||||
></textarea>
|
||||
|
||||
{#if jsonError}
|
||||
<span class="err-hint font-mono">{jsonError}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Trigger Run Action -->
|
||||
<button
|
||||
class="btn-trigger {consoleState.isLoading ? 'loading' : ''}"
|
||||
disabled={consoleState.isLoading || !!jsonError}
|
||||
onclick={() => consoleState.triggerRun()}
|
||||
>
|
||||
{#if consoleState.isLoading}
|
||||
<span class="spinner"></span>
|
||||
<span>Đang tiếp nhận và theo dõi tiến trình...</span>
|
||||
{:else}
|
||||
<Icon name="play" size={15} />
|
||||
<span>Chạy đánh giá điểm lead</span>
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.fixture-panel {
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 20px 24px;
|
||||
box-shadow: var(--shadow-card);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.fixture-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
padding-bottom: 14px;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.icon-bubble {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--accent-cyan-subtle);
|
||||
border: 1px solid var(--status-info-border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--accent-cyan);
|
||||
}
|
||||
|
||||
.panel-title {
|
||||
margin: 0;
|
||||
font-size: 1.02rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.panel-subtitle {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.btn-toggle-editor {
|
||||
background: var(--bg-surface-raised);
|
||||
border: 1px solid var(--border-subtle);
|
||||
color: var(--text-secondary);
|
||||
padding: 6px 12px;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.btn-toggle-editor:hover {
|
||||
color: var(--text-primary);
|
||||
border-color: var(--border-medium);
|
||||
background: var(--bg-surface-hover);
|
||||
}
|
||||
|
||||
.btn-toggle-editor.active {
|
||||
background: var(--accent-cyan-subtle);
|
||||
border-color: var(--accent-cyan);
|
||||
color: var(--accent-cyan);
|
||||
}
|
||||
|
||||
.scenario-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.scenario-card {
|
||||
background: var(--bg-surface-subtle);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 10px 12px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
color: var(--text-primary);
|
||||
position: relative;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.scenario-card:hover {
|
||||
border-color: var(--border-medium);
|
||||
background: #ffffff;
|
||||
box-shadow: 0 4px 12px rgba(15, 23, 42, 0.06);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.scenario-card.active {
|
||||
border-color: var(--accent-cyan);
|
||||
background: #ffffff;
|
||||
box-shadow: 0 0 0 1px var(--accent-cyan), 0 4px 12px rgba(2, 132, 199, 0.1);
|
||||
}
|
||||
|
||||
.scenario-layout {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.scenario-thumb-wrap {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: var(--radius-sm);
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
border: 1px solid var(--border-subtle);
|
||||
background: var(--bg-surface-raised);
|
||||
}
|
||||
|
||||
.scenario-thumb {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
transition: transform 0.4s ease;
|
||||
}
|
||||
|
||||
.scenario-card:hover .scenario-thumb {
|
||||
transform: scale(1.06);
|
||||
}
|
||||
|
||||
.scenario-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.scenario-top {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.scenario-cat-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.scenario-cat {
|
||||
font-size: 0.62rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
.scenario-name {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.expect-pill {
|
||||
font-size: 0.62rem;
|
||||
font-weight: 700;
|
||||
padding: 1px 6px;
|
||||
border-radius: var(--radius-full);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.expect-pill.status-completed {
|
||||
background: var(--status-good-bg);
|
||||
color: var(--status-good);
|
||||
border: 1px solid var(--status-good-border);
|
||||
}
|
||||
|
||||
|
||||
.expect-pill.status-degraded {
|
||||
background: var(--status-warning-bg);
|
||||
color: var(--status-warning);
|
||||
border: 1px solid var(--status-warning-border);
|
||||
}
|
||||
|
||||
.expect-pill.status-failed {
|
||||
background: var(--status-critical-bg);
|
||||
color: var(--status-critical);
|
||||
border: 1px solid var(--status-critical-border);
|
||||
}
|
||||
|
||||
.scenario-desc {
|
||||
margin: 0;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.4;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.editor-drawer {
|
||||
background: #0f172a;
|
||||
border: 1px solid #1e293b;
|
||||
border-radius: var(--radius-md);
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.editor-bar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.status-ok {
|
||||
font-size: 0.7rem;
|
||||
color: #34d399;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.status-err {
|
||||
font-size: 0.7rem;
|
||||
color: #fb7185;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.editor-tools {
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.btn-tool {
|
||||
background: #1e293b;
|
||||
border: 1px solid #334155;
|
||||
color: #cbd5e1;
|
||||
padding: 3px 8px;
|
||||
border-radius: var(--radius-xs);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.btn-tool:hover {
|
||||
color: #ffffff;
|
||||
border-color: #475569;
|
||||
}
|
||||
|
||||
.payload-textarea {
|
||||
background: #020617;
|
||||
border: 1px solid #1e293b;
|
||||
border-radius: var(--radius-sm);
|
||||
color: #93c5fd;
|
||||
font-size: 0.76rem;
|
||||
padding: 10px 12px;
|
||||
resize: vertical;
|
||||
width: 100%;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.payload-textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent-cyan);
|
||||
}
|
||||
|
||||
.payload-textarea.error-ring {
|
||||
border-color: var(--status-critical);
|
||||
}
|
||||
|
||||
.err-hint {
|
||||
font-size: 0.72rem;
|
||||
color: #fb7185;
|
||||
}
|
||||
|
||||
.btn-trigger {
|
||||
background: linear-gradient(135deg, #0284c7, #0ea5e9);
|
||||
color: #ffffff;
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
padding: 13px 20px;
|
||||
font-weight: 700;
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
box-shadow: 0 2px 8px rgba(2, 132, 199, 0.25), inset 0 1px 0 rgba(255, 255, 255, 0.2);
|
||||
transition: all var(--transition-fast);
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.btn-trigger:hover:not(:disabled) {
|
||||
background: linear-gradient(135deg, #0369a1, #0284c7);
|
||||
box-shadow: 0 4px 14px rgba(2, 132, 199, 0.35);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.btn-trigger:active:not(:disabled) {
|
||||
transform: scale(0.99);
|
||||
}
|
||||
|
||||
.btn-trigger:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.btn-trigger.loading {
|
||||
background: var(--bg-surface-raised);
|
||||
color: var(--accent-cyan);
|
||||
border: 1px solid var(--accent-cyan);
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
border: 2px solid rgba(2, 132, 199, 0.3);
|
||||
border-top-color: var(--accent-cyan);
|
||||
border-radius: var(--radius-full);
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -1,159 +0,0 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
name:
|
||||
| 'sparkles'
|
||||
| 'play'
|
||||
| 'refresh'
|
||||
| 'check'
|
||||
| 'check-circle'
|
||||
| 'alert-triangle'
|
||||
| 'alert-circle'
|
||||
| 'sliders'
|
||||
| 'code'
|
||||
| 'copy'
|
||||
| 'settings'
|
||||
| 'chevron-down'
|
||||
| 'chevron-right'
|
||||
| 'chevron-up'
|
||||
| 'activity'
|
||||
| 'shield'
|
||||
| 'git-branch'
|
||||
| 'clock'
|
||||
| 'layers'
|
||||
| 'cpu'
|
||||
| 'zap'
|
||||
| 'crosshair'
|
||||
| 'target'
|
||||
| 'trending-down'
|
||||
| 'book-open'
|
||||
| 'file-text'
|
||||
| 'plus'
|
||||
| 'trash'
|
||||
| 'close';
|
||||
size?: number;
|
||||
strokeWidth?: number;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let { name, size = 16, strokeWidth = 1.75, class: className = '' }: Props = $props();
|
||||
</script>
|
||||
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width={strokeWidth}
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="svg-icon {className}"
|
||||
>
|
||||
{#if name === 'sparkles'}
|
||||
<path d="m12 3-1.912 5.813a2 2 0 0 1-1.275 1.275L3 12l5.813 1.912a2 2 0 0 1 1.275 1.275L12 21l1.912-5.813a2 2 0 0 1 1.275-1.275L21 12l-5.813-1.912a2 2 0 0 1-1.275-1.275L12 3Z" />
|
||||
<path d="M5 3v4" /><path d="M19 17v4" /><path d="M3 5h4" /><path d="M17 19h4" />
|
||||
{:else if name === 'play'}
|
||||
<polygon points="6 3 20 12 6 21 6 3" />
|
||||
{:else if name === 'refresh'}
|
||||
<path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8" />
|
||||
<path d="M21 3v5h-5" />
|
||||
<path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16" />
|
||||
<path d="M8 16H3v5" />
|
||||
{:else if name === 'check'}
|
||||
<polyline points="20 6 9 17 4 12" />
|
||||
{:else if name === 'check-circle'}
|
||||
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" />
|
||||
<polyline points="22 4 12 14.01 9 11.01" />
|
||||
{:else if name === 'alert-triangle'}
|
||||
<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z" />
|
||||
<line x1="12" y1="9" x2="12" y2="13" />
|
||||
<line x1="12" y1="17" x2="12.01" y2="17" />
|
||||
{:else if name === 'alert-circle'}
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line x1="12" y1="8" x2="12" y2="12" />
|
||||
<line x1="12" y1="16" x2="12.01" y2="16" />
|
||||
{:else if name === 'sliders'}
|
||||
<line x1="4" y1="21" x2="4" y2="14" /><line x1="4" y1="10" x2="4" y2="3" />
|
||||
<line x1="12" y1="21" x2="12" y2="12" /><line x1="12" y1="8" x2="12" y2="3" />
|
||||
<line x1="20" y1="21" x2="20" y2="16" /><line x1="20" y1="12" x2="20" y2="3" />
|
||||
<line x1="1" y1="14" x2="7" y2="14" /><line x1="9" y1="8" x2="15" y2="8" />
|
||||
<line x1="17" y1="16" x2="23" y2="16" />
|
||||
{:else if name === 'code'}
|
||||
<polyline points="16 18 22 12 16 6" />
|
||||
<polyline points="8 6 2 12 8 18" />
|
||||
{:else if name === 'copy'}
|
||||
<rect width="14" height="14" x="8" y="8" rx="2" ry="2" />
|
||||
<path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2" />
|
||||
{:else if name === 'settings'}
|
||||
<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z" />
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
{:else if name === 'chevron-down'}
|
||||
<polyline points="6 9 12 15 18 9" />
|
||||
{:else if name === 'chevron-right'}
|
||||
<polyline points="9 18 15 12 9 6" />
|
||||
{:else if name === 'chevron-up'}
|
||||
<polyline points="18 15 12 9 6 15" />
|
||||
{:else if name === 'activity'}
|
||||
<polyline points="22 12 18 12 15 21 9 3 6 12 2 12" />
|
||||
{:else if name === 'shield'}
|
||||
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
|
||||
{:else if name === 'git-branch'}
|
||||
<line x1="6" y1="3" x2="6" y2="15" />
|
||||
<circle cx="18" cy="6" r="3" />
|
||||
<circle cx="6" cy="18" r="3" />
|
||||
<path d="M18 9a9 9 0 0 1-9 9" />
|
||||
{:else if name === 'clock'}
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<polyline points="12 6 12 12 16 14" />
|
||||
{:else if name === 'layers'}
|
||||
<polygon points="12 2 2 7 12 12 22 7 12 2" />
|
||||
<polyline points="2 17 12 22 22 17" />
|
||||
<polyline points="2 12 12 17 22 12" />
|
||||
{:else if name === 'cpu'}
|
||||
<rect width="16" height="16" x="4" y="4" rx="2" />
|
||||
<rect width="6" height="6" x="9" y="9" rx="1" />
|
||||
<path d="M15 2v2" /><path d="M15 20v2" /><path d="M2 15h2" /><path d="M2 9h2" />
|
||||
<path d="M20 15h2" /><path d="M20 9h2" /><path d="M9 2v2" /><path d="M9 20v2" />
|
||||
{:else if name === 'zap'}
|
||||
<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2" />
|
||||
{:else if name === 'crosshair'}
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line x1="22" y1="12" x2="18" y2="12" />
|
||||
<line x1="6" y1="12" x2="2" y2="12" />
|
||||
<line x1="12" y1="6" x2="12" y2="2" />
|
||||
<line x1="12" y1="22" x2="12" y2="18" />
|
||||
{:else if name === 'target'}
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<circle cx="12" cy="12" r="6" />
|
||||
<circle cx="12" cy="12" r="2" />
|
||||
{:else if name === 'trending-down'}
|
||||
<polyline points="23 18 13.5 8.5 8.5 13.5 1 6" />
|
||||
<polyline points="17 18 23 18 23 12" />
|
||||
{:else if name === 'book-open'}
|
||||
<path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z" />
|
||||
<path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z" />
|
||||
{:else if name === 'file-text'}
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||
<polyline points="14 2 14 8 20 8" />
|
||||
<line x1="16" y1="13" x2="8" y2="13" />
|
||||
<line x1="16" y1="17" x2="8" y2="17" />
|
||||
{:else if name === 'plus'}
|
||||
<line x1="12" y1="5" x2="12" y2="19" />
|
||||
<line x1="5" y1="12" x2="19" y2="12" />
|
||||
{:else if name === 'trash'}
|
||||
<polyline points="3 6 5 6 21 6" />
|
||||
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
|
||||
{:else if name === 'close'}
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
{/if}
|
||||
</svg>
|
||||
|
||||
<style>
|
||||
.svg-icon {
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
@ -1,747 +0,0 @@
|
||||
<script lang="ts">
|
||||
import type { ScoringRunResponse } from '../api/types';
|
||||
import { consoleState } from '../state/consoleState.svelte';
|
||||
import Icon from './Icon.svelte';
|
||||
import RunStatusBadge from './RunStatusBadge.svelte';
|
||||
|
||||
interface Props {
|
||||
run: ScoringRunResponse | null;
|
||||
}
|
||||
|
||||
let { run }: Props = $props();
|
||||
let showRawJson = $state(false);
|
||||
let copiedRunId = $state(false);
|
||||
let copiedJson = $state(false);
|
||||
|
||||
async function copyRunId() {
|
||||
if (!run?.run_id) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(run.run_id);
|
||||
copiedRunId = true;
|
||||
setTimeout(() => (copiedRunId = false), 2000);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async function copyJson() {
|
||||
if (!run) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(JSON.stringify(run, null, 2));
|
||||
copiedJson = true;
|
||||
setTimeout(() => (copiedJson = false), 2000);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function calculateDuration(startStr?: string | null, endStr?: string | null): string | null {
|
||||
if (!startStr || !endStr) return null;
|
||||
try {
|
||||
const start = new Date(startStr).getTime();
|
||||
const end = new Date(endStr).getTime();
|
||||
if (isNaN(start) || isNaN(end)) return null;
|
||||
const diffMs = end - start;
|
||||
if (diffMs < 1000) return `${diffMs}ms`;
|
||||
return `${(diffMs / 1000).toFixed(2)}s`;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
let duration = $derived(calculateDuration(run?.started_at, run?.completed_at));
|
||||
let currentFixture = $derived(consoleState.selectedFixture);
|
||||
|
||||
// Default lead scoring hero image
|
||||
let defaultHeroImage = '/images/lead-ielts-student.jpg';
|
||||
let activeHeroImage = $derived(currentFixture?.coverImage ?? defaultHeroImage);
|
||||
</script>
|
||||
|
||||
<div class="inspector-card">
|
||||
<!-- Top Inspector Header -->
|
||||
<div class="inspector-header">
|
||||
<div class="title-row">
|
||||
<div class="icon-bubble">
|
||||
<Icon name="activity" size={17} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="card-title">Chi tiết tiến trình đánh giá</h3>
|
||||
<span class="card-subtitle">Trạng thái thực thi, máy trạng thái và dòng thời gian vòng đời</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="header-actions">
|
||||
{#if run}
|
||||
<RunStatusBadge status={run.status} degraded={run.degraded} />
|
||||
<button
|
||||
class="btn-refresh {consoleState.isLoading ? 'spinning' : ''}"
|
||||
disabled={consoleState.isLoading}
|
||||
onclick={() => consoleState.refreshRun()}
|
||||
title="Làm mới trạng thái từ backend"
|
||||
>
|
||||
<Icon name="refresh" size={13} class="refresh-icon" />
|
||||
<span>Làm mới</span>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Editorial Visual Hero Showcase (SiteInspire Style Anchor) -->
|
||||
<div class="editorial-hero-frame">
|
||||
<div class="hero-image-wrap">
|
||||
<img src={activeHeroImage} alt="Kịch bản đánh giá lead" class="hero-image" />
|
||||
<div class="hero-scrim"></div>
|
||||
|
||||
<div class="hero-meta-overlay">
|
||||
<div class="hero-meta-top">
|
||||
<span class="hero-category font-mono">{currentFixture.category ?? 'ĐÁNH GIÁ LEAD'}</span>
|
||||
{#if run}
|
||||
<span class="hero-status-tag font-mono">{run.status.toUpperCase()}</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="hero-meta-bot">
|
||||
<h2 class="hero-headline">{run ? `Mã Lead: ${run.subject_ref}` : currentFixture.name}</h2>
|
||||
<p class="hero-subtext">
|
||||
{run
|
||||
? `Tenant: ${run.tenant_id} · Phiên bản cấu hình: ${run.scoring_config_version}`
|
||||
: currentFixture.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if !run}
|
||||
<!-- Empty State Guide -->
|
||||
<div class="empty-guide-bar">
|
||||
<div class="empty-guide-left">
|
||||
<Icon name="crosshair" size={18} class="text-secondary" />
|
||||
<div class="empty-guide-text">
|
||||
<strong>Sẵn sàng chạy kiểm thử</strong>
|
||||
<span>Chọn một kịch bản kiểm thử ở cột bên trái và nhấn <strong>Chạy đánh giá điểm lead</strong> để kiểm tra.</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Run Identifier Bar -->
|
||||
<div class="id-banner">
|
||||
<div class="id-cluster">
|
||||
<span class="id-tag">MÃ PHIÊN CHẠY:</span>
|
||||
<code class="id-val font-mono">{run.run_id}</code>
|
||||
</div>
|
||||
<button class="btn-copy-id" onclick={copyRunId} title="Sao chép Run ID">
|
||||
<Icon name={copiedRunId ? 'check' : 'copy'} size={12} />
|
||||
<span>{copiedRunId ? 'Đã chép' : 'Chép mã'}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Lineage Link if parent exists -->
|
||||
{#if run.parent_run_id}
|
||||
<div class="lineage-banner">
|
||||
<Icon name="git-branch" size={14} class="text-indigo" />
|
||||
<div class="lineage-text">
|
||||
<span class="lineage-label">Nguồn gốc phiên bản cha:</span>
|
||||
<code class="lineage-id font-mono">{run.parent_run_id}</code>
|
||||
</div>
|
||||
<span class="lineage-tag font-mono">v{run.version - 1} → v{run.version}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Key Details Grid -->
|
||||
<div class="details-grid">
|
||||
<div class="detail-cell">
|
||||
<span class="cell-label">Mã Tenant</span>
|
||||
<span class="cell-val font-mono">{run.tenant_id}</span>
|
||||
</div>
|
||||
<div class="detail-cell">
|
||||
<span class="cell-label">Tham chiếu Lead</span>
|
||||
<span class="cell-val font-mono">{run.subject_ref}</span>
|
||||
</div>
|
||||
<div class="detail-cell">
|
||||
<span class="cell-label">Phiên bản cấu hình</span>
|
||||
<span class="cell-val font-mono">{run.scoring_config_version}</span>
|
||||
</div>
|
||||
<div class="detail-cell">
|
||||
<span class="cell-label">Số hiệu phiên bản</span>
|
||||
<span class="cell-val font-mono">v{run.version}</span>
|
||||
</div>
|
||||
{#if duration}
|
||||
<div class="detail-cell highlight">
|
||||
<span class="cell-label">Tổng thời gian thực thi</span>
|
||||
<span class="cell-val font-mono text-cyan">{duration}</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Lifecycle Timeline -->
|
||||
<div class="timeline-container">
|
||||
<div class="timeline-head">
|
||||
<Icon name="clock" size={13} class="text-secondary" />
|
||||
<span class="timeline-title">Dấu thời gian vòng đời thực thi</span>
|
||||
</div>
|
||||
|
||||
<div class="timeline-track">
|
||||
<div class="timeline-step">
|
||||
<span class="step-point created"></span>
|
||||
<div class="step-data">
|
||||
<span class="step-name">Khởi tạo</span>
|
||||
<code class="step-timestamp font-mono">{run.created_at}</code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if run.started_at}
|
||||
<div class="step-line"></div>
|
||||
<div class="timeline-step">
|
||||
<span class="step-point started"></span>
|
||||
<div class="step-data">
|
||||
<span class="step-name">Đang xử lý</span>
|
||||
<code class="step-timestamp font-mono">{run.started_at}</code>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if run.completed_at}
|
||||
<div class="step-line"></div>
|
||||
<div class="timeline-step">
|
||||
<span class="step-point completed"></span>
|
||||
<div class="step-data">
|
||||
<span class="step-name">Hoàn thành</span>
|
||||
<code class="step-timestamp font-mono">{run.completed_at}</code>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Raw JSON Inspector -->
|
||||
<div class="raw-drawer">
|
||||
<div class="raw-top">
|
||||
<button
|
||||
class="btn-raw-toggle"
|
||||
onclick={() => (showRawJson = !showRawJson)}
|
||||
aria-expanded={showRawJson}
|
||||
>
|
||||
<Icon name={showRawJson ? 'chevron-down' : 'chevron-right'} size={14} />
|
||||
<span>{showRawJson ? 'Ẩn dữ liệu JSON đầy đủ' : 'Xem toàn bộ bản ghi JSON'}</span>
|
||||
</button>
|
||||
|
||||
{#if showRawJson}
|
||||
<button class="btn-copy-raw" onclick={copyJson}>
|
||||
<Icon name={copiedJson ? 'check' : 'copy'} size={12} />
|
||||
<span>{copiedJson ? 'Đã chép' : 'Chép JSON'}</span>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if showRawJson}
|
||||
<pre class="raw-json-code font-mono">{JSON.stringify(run, null, 2)}</pre>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.inspector-card {
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 20px 24px;
|
||||
box-shadow: var(--shadow-card);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.inspector-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
padding-bottom: 14px;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.icon-bubble {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--accent-cyan-subtle);
|
||||
border: 1px solid var(--status-info-border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--accent-cyan);
|
||||
}
|
||||
|
||||
.card-title {
|
||||
margin: 0;
|
||||
font-size: 1.02rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.card-subtitle {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.btn-refresh {
|
||||
background: var(--bg-surface-raised);
|
||||
border: 1px solid var(--border-subtle);
|
||||
color: var(--text-secondary);
|
||||
padding: 5px 12px;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 0.76rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.btn-refresh:hover:not(:disabled) {
|
||||
border-color: var(--border-medium);
|
||||
background: var(--bg-surface-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.btn-refresh:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-refresh.spinning :global(.refresh-icon) {
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* Editorial Hero Showcase (SiteInspire Style Frame) */
|
||||
.editorial-hero-frame {
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 6px;
|
||||
background: #ffffff;
|
||||
box-shadow: 0 4px 16px rgba(15, 23, 42, 0.06);
|
||||
}
|
||||
|
||||
.hero-image-wrap {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 220px;
|
||||
border-radius: var(--radius-sm);
|
||||
overflow: hidden;
|
||||
background: #0f172a;
|
||||
}
|
||||
|
||||
@media (max-width: 800px) {
|
||||
.hero-image-wrap {
|
||||
height: 180px;
|
||||
}
|
||||
}
|
||||
|
||||
.hero-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
object-position: center;
|
||||
transition: transform 0.6s ease;
|
||||
}
|
||||
|
||||
.hero-image-wrap:hover .hero-image {
|
||||
transform: scale(1.02);
|
||||
}
|
||||
|
||||
.hero-scrim {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(180deg, rgba(15, 23, 42, 0.25) 0%, rgba(15, 23, 42, 0.85) 100%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.hero-meta-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
padding: 16px 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
color: #ffffff;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.hero-meta-top {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.hero-category {
|
||||
font-size: 0.68rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
backdrop-filter: blur(8px);
|
||||
padding: 3px 10px;
|
||||
border-radius: var(--radius-full);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.hero-status-tag {
|
||||
font-size: 0.65rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.06em;
|
||||
background: #0284c7;
|
||||
padding: 3px 8px;
|
||||
border-radius: var(--radius-xs);
|
||||
}
|
||||
|
||||
.hero-meta-bot {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.hero-headline {
|
||||
margin: 0;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 800;
|
||||
color: #ffffff;
|
||||
letter-spacing: -0.02em;
|
||||
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.hero-subtext {
|
||||
margin: 0;
|
||||
font-size: 0.8rem;
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
max-width: 600px;
|
||||
line-height: 1.4;
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.empty-guide-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
background: var(--bg-surface-subtle);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.empty-guide-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.empty-guide-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.empty-guide-text strong {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.empty-guide-text span {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.id-banner {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
background: var(--bg-surface-subtle);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 10px 14px;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.id-cluster {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.id-tag {
|
||||
font-size: 0.68rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-muted);
|
||||
letter-spacing: 0.06em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.id-val {
|
||||
font-size: 0.84rem;
|
||||
font-weight: 600;
|
||||
color: var(--accent-cyan);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.btn-copy-id {
|
||||
background: var(--bg-surface-raised);
|
||||
border: 1px solid var(--border-subtle);
|
||||
color: var(--text-secondary);
|
||||
padding: 4px 10px;
|
||||
border-radius: var(--radius-xs);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
white-space: nowrap;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.btn-copy-id:hover {
|
||||
color: var(--text-primary);
|
||||
border-color: var(--border-medium);
|
||||
}
|
||||
|
||||
.lineage-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
background: var(--accent-indigo-subtle);
|
||||
border: 1px solid rgba(79, 70, 229, 0.2);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 8px 14px;
|
||||
}
|
||||
|
||||
.lineage-banner :global(.text-indigo) {
|
||||
color: var(--accent-indigo);
|
||||
}
|
||||
|
||||
.lineage-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.lineage-label {
|
||||
font-size: 0.65rem;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.lineage-id {
|
||||
font-size: 0.78rem;
|
||||
color: var(--accent-indigo);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.lineage-tag {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
padding: 2px 8px;
|
||||
background: rgba(79, 70, 229, 0.12);
|
||||
color: var(--accent-indigo);
|
||||
border-radius: var(--radius-full);
|
||||
}
|
||||
|
||||
.details-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.detail-cell {
|
||||
background: var(--bg-surface-subtle);
|
||||
padding: 10px 14px;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--border-subtle);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.detail-cell.highlight {
|
||||
border-color: var(--status-info-border);
|
||||
background: var(--accent-cyan-subtle);
|
||||
}
|
||||
|
||||
.cell-label {
|
||||
font-size: 0.66rem;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.cell-val {
|
||||
font-size: 0.84rem;
|
||||
color: var(--text-primary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.text-cyan {
|
||||
color: var(--accent-cyan);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.timeline-container {
|
||||
background: var(--bg-surface-subtle);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 12px 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.timeline-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.timeline-title {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
.timeline-track {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.timeline-step {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.step-point {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: var(--radius-full);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.step-point.created { background: var(--text-muted); }
|
||||
.step-point.started { background: var(--accent-cyan); }
|
||||
.step-point.completed { background: var(--status-good); }
|
||||
|
||||
.step-data {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.step-name {
|
||||
font-size: 0.62rem;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.step-timestamp {
|
||||
font-size: 0.74rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.step-line {
|
||||
width: 18px;
|
||||
height: 1px;
|
||||
background: var(--border-medium);
|
||||
}
|
||||
|
||||
.raw-drawer {
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
padding-top: 12px;
|
||||
}
|
||||
|
||||
.raw-top {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.btn-raw-toggle {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 0;
|
||||
transition: color var(--transition-fast);
|
||||
}
|
||||
|
||||
.btn-raw-toggle:hover {
|
||||
color: var(--accent-cyan);
|
||||
}
|
||||
|
||||
.btn-copy-raw {
|
||||
background: var(--bg-surface-raised);
|
||||
border: 1px solid var(--border-subtle);
|
||||
color: var(--text-secondary);
|
||||
padding: 3px 8px;
|
||||
border-radius: var(--radius-xs);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.btn-copy-raw:hover {
|
||||
color: var(--text-primary);
|
||||
border-color: var(--border-medium);
|
||||
}
|
||||
|
||||
.raw-json-code {
|
||||
margin-top: 10px;
|
||||
background: #0f172a;
|
||||
border: 1px solid #1e293b;
|
||||
border-radius: var(--radius-md);
|
||||
padding: 12px 14px;
|
||||
font-size: 0.74rem;
|
||||
color: #93c5fd;
|
||||
overflow-x: auto;
|
||||
max-height: 240px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
</style>
|
||||
@ -1,134 +0,0 @@
|
||||
<script lang="ts">
|
||||
import type { ScoringRunStatus } from '../api/types';
|
||||
|
||||
interface Props {
|
||||
status: ScoringRunStatus | string;
|
||||
degraded?: boolean | null;
|
||||
}
|
||||
|
||||
let { status, degraded = false }: Props = $props();
|
||||
|
||||
const statusConfig: Record<string, { bg: string; text: string; border: string; label: string; pulse?: boolean }> = {
|
||||
pending: {
|
||||
bg: 'var(--status-warning-bg)',
|
||||
text: 'var(--status-warning-text)',
|
||||
border: 'var(--status-warning-border)',
|
||||
label: 'ĐANG CHỜ',
|
||||
pulse: true,
|
||||
},
|
||||
running: {
|
||||
bg: 'var(--status-info-bg)',
|
||||
text: 'var(--status-info)',
|
||||
border: 'var(--status-info-border)',
|
||||
label: 'ĐANG XỬ LÝ',
|
||||
pulse: true,
|
||||
},
|
||||
completed: {
|
||||
bg: 'var(--status-good-bg)',
|
||||
text: 'var(--status-good-text)',
|
||||
border: 'var(--status-good-border)',
|
||||
label: 'HOÀN THÀNH',
|
||||
},
|
||||
review_required: {
|
||||
bg: 'var(--status-warning-bg)',
|
||||
text: 'var(--status-warning-text)',
|
||||
border: 'var(--status-warning-border)',
|
||||
label: 'CẦN KIỂM TRA',
|
||||
},
|
||||
failed: {
|
||||
bg: 'var(--status-critical-bg)',
|
||||
text: 'var(--status-critical-text)',
|
||||
border: 'var(--status-critical-border)',
|
||||
label: 'THẤT BẠI',
|
||||
},
|
||||
superseded: {
|
||||
bg: 'var(--bg-surface-raised)',
|
||||
text: 'var(--text-muted)',
|
||||
border: 'var(--border-subtle)',
|
||||
label: 'ĐÃ THAY THẾ',
|
||||
},
|
||||
};
|
||||
|
||||
let badge = $derived(
|
||||
statusConfig[status] ?? {
|
||||
bg: 'var(--bg-surface-raised)',
|
||||
text: 'var(--text-secondary)',
|
||||
border: 'var(--border-subtle)',
|
||||
label: status.toUpperCase(),
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
<div class="status-wrapper">
|
||||
<span
|
||||
class="badge"
|
||||
style="background: {badge.bg}; color: {badge.text}; border: 1px solid {badge.border};"
|
||||
>
|
||||
<span class="dot {badge.pulse ? 'pulsing' : ''}" style="background: {badge.text};"></span>
|
||||
<span>{badge.label}</span>
|
||||
</span>
|
||||
|
||||
{#if degraded}
|
||||
<span class="degraded-badge" title="Chế độ dự phòng: AIHub gặp sự cố, hệ thống chuyển về tính điểm quy tắc">
|
||||
DỰ PHÒNG
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.status-wrapper {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 3px 9px;
|
||||
border-radius: var(--radius-full);
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.05em;
|
||||
font-family: var(--font-mono);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: var(--radius-full);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.dot.pulsing {
|
||||
animation: pulse-glow 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse-glow {
|
||||
0%, 100% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.3);
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
|
||||
.degraded-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 3px 8px;
|
||||
border-radius: var(--radius-xs);
|
||||
background: var(--status-warning-bg);
|
||||
color: var(--status-warning-text);
|
||||
border: 1px solid var(--status-warning-border);
|
||||
font-size: 0.66rem;
|
||||
font-weight: 700;
|
||||
font-family: var(--font-mono);
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
</style>
|
||||
@ -1,587 +0,0 @@
|
||||
<script lang="ts">
|
||||
import type { ScoringRunResponse } from '../api/types';
|
||||
import Icon from './Icon.svelte';
|
||||
import ScoreMeter from './ScoreMeter.svelte';
|
||||
|
||||
interface Props {
|
||||
run: ScoringRunResponse;
|
||||
}
|
||||
|
||||
let { run }: Props = $props();
|
||||
|
||||
let effectiveScore = $derived(run.effective_score ?? run.final_score ?? run.rule_score ?? null);
|
||||
let ruleScore = $derived(run.rule_score ?? null);
|
||||
let aiScore = $derived(run.ai_score ?? null);
|
||||
|
||||
let confidencePercent = $derived(run.confidence !== null ? Math.round(run.confidence * 100) : null);
|
||||
let thresholdPercent = $derived(
|
||||
run.confidence_threshold !== null ? Math.round(run.confidence_threshold * 100) : null
|
||||
);
|
||||
|
||||
let confidenceMet = $derived(
|
||||
run.confidence !== null && run.confidence_threshold !== null
|
||||
? run.confidence >= run.confidence_threshold
|
||||
: null
|
||||
);
|
||||
|
||||
let showMetadata = $state(false);
|
||||
let copiedMeta = $state(false);
|
||||
|
||||
async function copyMetadata() {
|
||||
if (!run.ai_metadata) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(JSON.stringify(run.ai_metadata, null, 2));
|
||||
copiedMeta = true;
|
||||
setTimeout(() => (copiedMeta = false), 2000);
|
||||
} catch {}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="score-card">
|
||||
<!-- Card Header -->
|
||||
<div class="card-header">
|
||||
<div class="header-left">
|
||||
<div class="icon-bubble">
|
||||
<Icon name="layers" size={17} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="card-title">Phân tích điểm chi tiết</h3>
|
||||
<span class="card-subtitle">Chỉ số đánh giá từ bộ quy tắc xác định và mô hình AIHub</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="header-badges">
|
||||
{#if run.scoring_mode}
|
||||
<span class="mode-badge">
|
||||
<span class="mode-dot"></span>
|
||||
<span>{run.scoring_mode.toUpperCase()}</span>
|
||||
</span>
|
||||
{/if}
|
||||
{#if run.version > 1}
|
||||
<span class="version-badge font-mono">
|
||||
PHIÊN BẢN v{run.version}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Hero Score Row: Radial Meter + Metric Breakdown Bars -->
|
||||
<div class="hero-score-layout">
|
||||
<!-- Left: Score Ring Meter -->
|
||||
<div class="meter-panel">
|
||||
<ScoreMeter score={effectiveScore} size={155} label="Điểm hiệu lực" />
|
||||
{#if run.effective_score !== null && run.final_score !== null && run.effective_score !== run.final_score}
|
||||
<div class="override-notice font-mono">
|
||||
<Icon name="sliders" size={12} />
|
||||
<span>Đã hiệu chỉnh từ {run.final_score}</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Right: Detailed Metric Bars -->
|
||||
<div class="bars-panel">
|
||||
<!-- Rule Engine Score Bar -->
|
||||
<div class="bar-item">
|
||||
<div class="bar-header">
|
||||
<div class="bar-title">
|
||||
<Icon name="cpu" size={14} class="text-secondary" />
|
||||
<span>Điểm quy tắc xác định</span>
|
||||
</div>
|
||||
<span class="bar-value font-mono">{ruleScore !== null ? `${ruleScore}/100` : '-'}</span>
|
||||
</div>
|
||||
<div class="progress-track">
|
||||
<div
|
||||
class="progress-fill rule-gradient"
|
||||
style="width: {ruleScore !== null ? `${ruleScore}%` : '0%'};"
|
||||
></div>
|
||||
</div>
|
||||
<div class="bar-footer">
|
||||
<span>Kiểm tra điều kiện biên và quy tắc chấm điểm cứng</span>
|
||||
<span class="font-mono text-muted">{ruleScore !== null ? (ruleScore >= 70 ? 'Đạt cao' : ruleScore >= 40 ? 'Đạt TB' : 'Thấp') : 'N/A'}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- AI LLM Score Bar -->
|
||||
<div class="bar-item">
|
||||
<div class="bar-header">
|
||||
<div class="bar-title">
|
||||
<Icon name="sparkles" size={14} class="text-secondary" />
|
||||
<span>Điểm đánh giá mô hình AI</span>
|
||||
</div>
|
||||
<span class="bar-value font-mono">{aiScore !== null ? `${aiScore}/100` : '-'}</span>
|
||||
</div>
|
||||
<div class="progress-track">
|
||||
<div
|
||||
class="progress-fill ai-gradient"
|
||||
style="width: {aiScore !== null ? `${aiScore}%` : '0%'};"
|
||||
></div>
|
||||
</div>
|
||||
<div class="bar-footer">
|
||||
<span>Đánh giá ngữ nghĩa hội thoại và ý định của lead</span>
|
||||
<span class="font-mono text-muted">{aiScore !== null ? 'Đã đánh giá' : run.degraded ? 'Dự phòng quy tắc' : 'N/A'}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- AI Confidence vs Threshold Bar -->
|
||||
<div class="bar-item {confidenceMet === false ? 'warn-border' : ''}">
|
||||
<div class="bar-header">
|
||||
<div class="bar-title">
|
||||
<Icon name="crosshair" size={14} class="text-secondary" />
|
||||
<span>Độ tin cậy mô hình & Ngưỡng kiểm soát</span>
|
||||
</div>
|
||||
<div class="bar-value font-mono">
|
||||
<span>{confidencePercent !== null ? `${confidencePercent}%` : '-'}</span>
|
||||
{#if thresholdPercent !== null}
|
||||
<span class="threshold-tag">Yêu cầu: {thresholdPercent}%</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="progress-track double-track">
|
||||
<!-- Confidence fill -->
|
||||
<div
|
||||
class="progress-fill conf-gradient {confidenceMet === false ? 'below-gate' : ''}"
|
||||
style="width: {confidencePercent !== null ? `${confidencePercent}%` : '0%'};"
|
||||
></div>
|
||||
<!-- Threshold marker line -->
|
||||
{#if thresholdPercent !== null}
|
||||
<div
|
||||
class="threshold-marker"
|
||||
style="left: {thresholdPercent}%;"
|
||||
title="Ngưỡng kiểm soát chất lượng: {thresholdPercent}%"
|
||||
></div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="bar-footer">
|
||||
<span>
|
||||
{#if confidenceMet === true}
|
||||
<span class="status-pass font-mono">✓ Đạt ngưỡng kiểm soát ({confidencePercent}% ≥ {thresholdPercent}%)</span>
|
||||
{:else if confidenceMet === false}
|
||||
<span class="status-warn font-mono">⚠ Dưới ngưỡng kiểm soát ({confidencePercent}% < {thresholdPercent}%)</span>
|
||||
{:else}
|
||||
<span>Chưa kích hoạt đánh giá độ tin cậy</span>
|
||||
{/if}
|
||||
</span>
|
||||
{#if thresholdPercent !== null}
|
||||
<span class="font-mono text-muted">Ngưỡng: {thresholdPercent}%</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Fault / Error Banner -->
|
||||
{#if run.error_code}
|
||||
<div class="fault-banner">
|
||||
<div class="fault-content">
|
||||
<Icon name="alert-circle" size={18} class="text-critical" />
|
||||
<div>
|
||||
<span class="fault-title">Sự cố thực thi: <code class="font-mono">{run.error_code}</code></span>
|
||||
<p class="fault-msg">Tiến trình xử lý bị gián đoạn hoặc gặp lỗi từ máy chủ upstream.</p>
|
||||
</div>
|
||||
</div>
|
||||
{#if run.retryable !== null}
|
||||
<span class="retry-badge font-mono {run.retryable ? 'retry-yes' : 'retry-no'}">
|
||||
{run.retryable ? 'TỰ ĐỘNG THỬ LẠI' : 'KHÔNG THỬ LẠI'}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- AI Execution Metadata Section -->
|
||||
{#if run.ai_metadata}
|
||||
<div class="meta-wrapper">
|
||||
<div class="meta-toolbar">
|
||||
<button
|
||||
class="btn-meta-toggle"
|
||||
onclick={() => (showMetadata = !showMetadata)}
|
||||
aria-expanded={showMetadata}
|
||||
>
|
||||
<Icon name={showMetadata ? 'chevron-down' : 'chevron-right'} size={14} />
|
||||
<span>Dữ liệu thực thi AI & Lượng token sử dụng ({Object.keys(run.ai_metadata).length} tham số)</span>
|
||||
</button>
|
||||
|
||||
{#if showMetadata}
|
||||
<button class="btn-copy-code" onclick={copyMetadata}>
|
||||
<Icon name={copiedMeta ? 'check' : 'copy'} size={13} />
|
||||
<span>{copiedMeta ? 'Đã chép' : 'Chép JSON'}</span>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if showMetadata}
|
||||
<pre class="meta-code font-mono">{JSON.stringify(run.ai_metadata, null, 2)}</pre>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.score-card {
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 20px 24px;
|
||||
box-shadow: var(--shadow-card);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
padding-bottom: 14px;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.icon-bubble {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--accent-cyan-subtle);
|
||||
border: 1px solid var(--status-info-border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--accent-cyan);
|
||||
}
|
||||
|
||||
.card-title {
|
||||
margin: 0;
|
||||
font-size: 1.02rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.card-subtitle {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.header-badges {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.mode-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 3px 10px;
|
||||
background: var(--accent-cyan-subtle);
|
||||
border: 1px solid var(--status-info-border);
|
||||
border-radius: var(--radius-full);
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
font-family: var(--font-mono);
|
||||
color: var(--accent-cyan);
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.mode-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--accent-cyan);
|
||||
}
|
||||
|
||||
.version-badge {
|
||||
padding: 3px 8px;
|
||||
background: var(--bg-surface-raised);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.hero-score-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 180px 1fr;
|
||||
gap: 24px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.hero-score-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.meter-panel {
|
||||
background: var(--bg-surface-subtle);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 16px 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.override-notice {
|
||||
font-size: 0.68rem;
|
||||
color: var(--status-warning);
|
||||
background: var(--status-warning-bg);
|
||||
border: 1px solid var(--status-warning-border);
|
||||
padding: 3px 8px;
|
||||
border-radius: var(--radius-xs);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.bars-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.bar-item {
|
||||
background: var(--bg-surface-subtle);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 10px 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.bar-item:hover {
|
||||
border-color: var(--border-medium);
|
||||
background: #ffffff;
|
||||
box-shadow: 0 2px 6px rgba(15, 23, 42, 0.04);
|
||||
}
|
||||
|
||||
.bar-item.warn-border {
|
||||
border-color: var(--status-warning-border);
|
||||
}
|
||||
|
||||
.bar-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.bar-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.bar-value {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.threshold-tag {
|
||||
font-size: 0.68rem;
|
||||
color: var(--text-muted);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.progress-track {
|
||||
height: 6px;
|
||||
background: rgba(15, 23, 42, 0.07);
|
||||
border-radius: var(--radius-full);
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.progress-track.double-track {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
border-radius: var(--radius-full);
|
||||
transition: width 0.6s var(--transition-spring);
|
||||
}
|
||||
|
||||
.rule-gradient {
|
||||
background: linear-gradient(90deg, #0284c7, #38bdf8);
|
||||
}
|
||||
|
||||
.ai-gradient {
|
||||
background: linear-gradient(90deg, #4f46e5, #818cf8);
|
||||
}
|
||||
|
||||
.conf-gradient {
|
||||
background: linear-gradient(90deg, #059669, #10b981);
|
||||
}
|
||||
|
||||
.conf-gradient.below-gate {
|
||||
background: linear-gradient(90deg, #d97706, #f59e0b);
|
||||
}
|
||||
|
||||
.threshold-marker {
|
||||
position: absolute;
|
||||
top: -3px;
|
||||
bottom: -3px;
|
||||
width: 2px;
|
||||
background: var(--status-critical);
|
||||
box-shadow: 0 0 4px var(--status-critical);
|
||||
border-radius: 1px;
|
||||
transform: translateX(-50%);
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.bar-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.status-pass {
|
||||
color: var(--status-good);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.status-warn {
|
||||
color: var(--status-warning);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.fault-banner {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--status-critical-bg);
|
||||
border: 1px solid var(--status-critical-border);
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.fault-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.fault-title {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 700;
|
||||
color: var(--status-critical);
|
||||
}
|
||||
|
||||
.fault-title code {
|
||||
background: #ffffff;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--status-critical-border);
|
||||
}
|
||||
|
||||
.fault-msg {
|
||||
margin: 2px 0 0 0;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.retry-badge {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
padding: 4px 8px;
|
||||
border-radius: var(--radius-xs);
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.retry-badge.retry-yes {
|
||||
background: var(--status-good-bg);
|
||||
color: var(--status-good);
|
||||
border: 1px solid var(--status-good-border);
|
||||
}
|
||||
|
||||
.retry-badge.retry-no {
|
||||
background: var(--bg-surface-raised);
|
||||
color: var(--text-muted);
|
||||
border: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.meta-wrapper {
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
padding-top: 12px;
|
||||
}
|
||||
|
||||
.meta-toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.btn-meta-toggle {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 0;
|
||||
transition: color var(--transition-fast);
|
||||
}
|
||||
|
||||
.btn-meta-toggle:hover {
|
||||
color: var(--accent-cyan);
|
||||
}
|
||||
|
||||
.btn-copy-code {
|
||||
background: var(--bg-surface-raised);
|
||||
border: 1px solid var(--border-subtle);
|
||||
color: var(--text-secondary);
|
||||
padding: 4px 10px;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.btn-copy-code:hover {
|
||||
color: var(--text-primary);
|
||||
border-color: var(--border-medium);
|
||||
}
|
||||
|
||||
.meta-code {
|
||||
margin-top: 10px;
|
||||
background: #0f172a;
|
||||
border: 1px solid #1e293b;
|
||||
border-radius: var(--radius-md);
|
||||
padding: 12px 14px;
|
||||
font-size: 0.75rem;
|
||||
color: #93c5fd;
|
||||
overflow-x: auto;
|
||||
max-height: 200px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
</style>
|
||||