From e4aafd753105f72eea9a6f38075da549c19ccb18 Mon Sep 17 00:00:00 2001 From: hungtran24 Date: Fri, 16 Oct 2026 09:00:40 +0700 Subject: [PATCH] feat: add editable lead scoring scenario forms --- README.md | 8 +- .../web/src/lib/server/fixture-runner.test.ts | 19 +- apps/web/src/lib/server/fixture-runner.ts | 15 +- .../lead-scoring/fixtures/+page.server.ts | 30 +- .../lead-scoring/fixtures/+page.svelte | 274 +++++++++++++----- .../fixtures/ConversationEditor.svelte | 160 ++++++++++ .../lead-scoring/fixtures/LeadEditor.svelte | 70 +++++ .../lead-scoring/fixtures/RuleEditor.svelte | 186 ++++++++++++ .../fixtures/RuleValueList.svelte | 39 +++ .../fixtures/ScenarioResult.svelte | 45 +++ .../lead-scoring/fixtures/ValueInput.svelte | 105 +++++++ .../lead-scoring/fixtures/fixtures.test.ts | 67 ++++- .../lead-scoring/fixtures/scenario.test.ts | 66 +++++ .../(admin)/lead-scoring/fixtures/scenario.ts | 124 ++++++++ .../routes/(admin)/platform/apps/apps.test.ts | 14 +- docs/specs/ai-platform-lead-scoring.md | 4 +- tests/test_admin_session_application.py | 4 +- tests/test_app_admin_api.py | 1 + tests/test_app_credential_application.py | 99 ++----- tests/test_initial_migration.py | 14 - tests/test_request_contract.py | 3 +- tests/test_score_endpoint.py | 3 +- tests/test_security_primitives.py | 16 - 23 files changed, 1122 insertions(+), 244 deletions(-) create mode 100644 apps/web/src/routes/(admin)/lead-scoring/fixtures/ConversationEditor.svelte create mode 100644 apps/web/src/routes/(admin)/lead-scoring/fixtures/LeadEditor.svelte create mode 100644 apps/web/src/routes/(admin)/lead-scoring/fixtures/RuleEditor.svelte create mode 100644 apps/web/src/routes/(admin)/lead-scoring/fixtures/RuleValueList.svelte create mode 100644 apps/web/src/routes/(admin)/lead-scoring/fixtures/ScenarioResult.svelte create mode 100644 apps/web/src/routes/(admin)/lead-scoring/fixtures/ValueInput.svelte create mode 100644 apps/web/src/routes/(admin)/lead-scoring/fixtures/scenario.test.ts create mode 100644 apps/web/src/routes/(admin)/lead-scoring/fixtures/scenario.ts delete mode 100644 tests/test_security_primitives.py diff --git a/README.md b/README.md index da7faa2..c41ba2a 100644 --- a/README.md +++ b/README.md @@ -66,13 +66,15 @@ AI_PLATFORM_OPENAI_BASE_URL=http://127.0.0.1:8317 # AI_PLATFORM_OPENAI_API_KEY=... # chỉ cần nếu CLIProxy yêu cầu xác thực ``` -Chạy `make backend-dev` trên máy host: `127.0.0.1:8317` bên trong Docker không trỏ tới CLIProxy trên host. Trong Admin Web (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” cho sửa kịch bản mẫu (JSON: lead, hội thoại, quy tắc, metadata) rồi gửi; bật “Chấm thêm bằng AI” thì trọng số quy tắc/AI là 0.5/0.5 (ghi đè trọng số trong JSON); với AIHub, đặt `LEAD_SCORING_FIXTURE_AI_KEY` trên server Web, nếu không lần chấm dự phòng bằng điểm quy tắc. Chế độ này có thể phát sinh chi phí. +Chạy `make backend-dev` trên máy host: `127.0.0.1:8317` bên trong Docker không trỏ tới CLIProxy trên host. Trong Admin Web (Cấu hình AI), soạn một revision mới với `OpenAI-compatible`, URL `http://127.0.0.1:8317`, model `gemini-3.5-flash-lite`, hướng dẫn chấm và timeout, chạy thử rồi Kích hoạt. `AI_PLATFORM_OPENAI_API_KEY` chỉ được gửi tới `AI_PLATFORM_OPENAI_BASE_URL`; URL khác nhập trên Web không nhận key đó. Profile AIHub dùng khóa AI của client trong header `X-AI-Key` (không lưu). + +Trang “Thử chấm điểm” có form sửa lead/custom fields, thêm/sửa/xóa hội thoại và tin nhắn (thời gian UTC), dựng rule với field/operator/giá trị/điểm và chọn tỷ lệ AI. Trọng số quy tắc tự bổ sung để tổng bằng 100%; AI 0% không gọi model. JSON nâng cao đồng bộ cùng kịch bản và gửi nguyên trọng số. Kết quả hiện điểm, breakdown và fallback ngay trên trang, kèm liên kết chi tiết. Ngưỡng ở đây là điều kiện rule; phân loại lead vẫn do client quyết định. Kịch bản không lưu thành chính sách và không ghi đè fixture chuẩn. Với AIHub, nhập “Khóa AI cho lần thử (không lưu)” trên trang này, hoặc đặt `LEAD_SCORING_FIXTURE_AI_KEY` trên server Web. Khóa nhập được ưu tiên, chỉ chuyển trong header `X-AI-Key` khi AI > 0%, không nằm trong JSON/kết quả và xóa khỏi ô nhập khi gửi. Khóa dùng thử ở trang Cấu hình AI không được lưu hay chuyển sang trang này. Thiếu khóa sẽ fallback bằng điểm quy tắc. Chế độ AI có thể phát sinh chi phí. Với endpoint OpenAI-compatible chạy ở production, URL phải dùng HTTPS. Key được cấu hình trên server chỉ gửi tới URL được gắn với key đó; thay URL trên Web không tự chuyển key. Không đặt key vào Web hoặc file versioned. CLIProxy local chỉ xác minh được đường gọi thử; chưa chứng minh các điều kiện phát hành production. ## App credentials -Mỗi installation/môi trường Omni dùng một `app_id` ổn định và một backend app key chung cho các account. Vận hành cấp/rotate/revoke qua CLI; raw key chỉ được xuất khi cấp/rotate, chuyển an toàn sang backend Omni, không qua browser/log/Git: +Mỗi installation/môi trường Omni dùng một `app_id` ổn định (tên đăng ký phía AI Platform, Omni không cần biết hay gửi) và một backend app key chung cho các account. Vận hành cấp/rotate/revoke qua CLI; raw key chỉ được xuất khi cấp/rotate, chuyển an toàn sang backend Omni, không qua browser/log/Git: ```sh uv run --no-sync ai-platform create-app omni-local @@ -80,7 +82,7 @@ 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 `, `Idempotency-Key` (UUID) và tùy chọn `X-AI-Key`. Dữ liệu riêng của client đi trong `metadata` và được lưu nguyên. Omni dùng `LEAD_SCORING_API_URL`, `AI_PLATFORM_APP_ID`, `AI_PLATFORM_APP_API_KEY` ở backend. Không có webhook hay đăng ký account ở AI Platform. +Admin Web `/platform/apps` chỉ xem trạng thái và thu hồi, không hiển thị hay cấp/rotate key. POST `/v1/lead-scoring/score` đồng bộ, dùng `Authorization: Bearer `, `Idempotency-Key` (UUID) và tùy chọn `X-AI-Key`. Dữ liệu riêng của client đi trong `metadata` và được lưu nguyên. Omni chỉ dùng `AI_PLATFORM_URL` và `AI_PLATFORM_APP_API_KEY` ở backend. Không có webhook hay đăng ký account ở AI Platform. 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. diff --git a/apps/web/src/lib/server/fixture-runner.test.ts b/apps/web/src/lib/server/fixture-runner.test.ts index 2b02e74..8a75af4 100644 --- a/apps/web/src/lib/server/fixture-runner.test.ts +++ b/apps/web/src/lib/server/fixture-runner.test.ts @@ -15,19 +15,24 @@ describe('server-only fixture transport', () => { it('sends the canonical fixture with a fresh idempotency key and keys only in headers', async () => { const fetch = vi.fn().mockResolvedValue(Response.json({}, { status: 200 })); const event = { fetch } as unknown as RequestEvent; - await submitScoringFixture(event, scoringFixture, true); - await submitScoringFixture(event, scoringFixture, false); + 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'); - const [[url, hybrid], [, rulesOnly]] = fetch.mock.calls; + 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({ - ...scoringFixture, - scoring_config: { ...scoringFixture.scoring_config, rule_weight: 0.5, ai_weight: 0.5 }, - }); + expect(JSON.parse(hybrid.body)).toEqual(scenario); + expect(JSON.parse(rulesOnly.body)).toEqual(scoringFixture); expect(hybrid.body).not.toMatch(/test-only/); }); diff --git a/apps/web/src/lib/server/fixture-runner.ts b/apps/web/src/lib/server/fixture-runner.ts index bb83ee4..4bd93e0 100644 --- a/apps/web/src/lib/server/fixture-runner.ts +++ b/apps/web/src/lib/server/fixture-runner.ts @@ -15,7 +15,7 @@ export function fixtureRunnerConfigured(): boolean { export async function submitScoringFixture( event: RequestEvent, scenario: RunRequest, - useAI = false, + requestAIKey = '', ): Promise { const base = env.BACKEND_INTERNAL_URL; const appKey = env.LEAD_SCORING_FIXTURE_APP_API_KEY?.trim(); @@ -24,15 +24,8 @@ export async function submitScoringFixture( throw new Error('BACKEND_INTERNAL_URL must be configured on the server'); } - const request: RunRequest = { - ...scenario, - scoring_config: { - ...scenario.scoring_config, - rule_weight: useAI ? 0.5 : 1, - ai_weight: useAI ? 0.5 : 0, - }, - }; - const aiKey = env.LEAD_SCORING_FIXTURE_AI_KEY?.trim(); + 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', @@ -44,6 +37,6 @@ export async function submitScoringFixture( 'Idempotency-Key': crypto.randomUUID(), ...(useAI && aiKey ? { 'X-AI-Key': aiKey } : {}), }, - body: JSON.stringify(request), + body: JSON.stringify(scenario), }); } diff --git a/apps/web/src/routes/(admin)/lead-scoring/fixtures/+page.server.ts b/apps/web/src/routes/(admin)/lead-scoring/fixtures/+page.server.ts index 560f914..cba52bb 100644 --- a/apps/web/src/routes/(admin)/lead-scoring/fixtures/+page.server.ts +++ b/apps/web/src/routes/(admin)/lead-scoring/fixtures/+page.server.ts @@ -48,8 +48,14 @@ export const actions: Actions = { } const form = await event.request.formData(); - const useAI = form.get('use_ai') === 'true'; 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; @@ -59,31 +65,28 @@ export const actions: Actions = { } catch { return fail(400, { message: 'Kịch bản phải là một JSON object hợp lệ.', - useAI, payload, }); } let response: Response; try { - response = await submitScoringFixture(event, scenario, useAI); + 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.', - useAI, payload, }); } - return fail(502, { message: 'Không gửi được dữ liệu mẫu.', useAI, payload }); + 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ệ.', useAI, payload }); + return fail(502, { message: 'Mã truy cập của mẫu không còn hợp lệ.', payload }); } if (!response.ok) { 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}` : ''}.`, - useAI, payload, }); } @@ -92,19 +95,16 @@ export const actions: Actions = { const run = (await response.json()) as ScoreResponse; return { success: true, - useAI, payload, - run_id: run.run_id, - result_mode: run.result_mode, - error_code: run.error_code, - message: useAI - ? 'Đã 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.', + 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ệ.', - useAI, payload, }); } diff --git a/apps/web/src/routes/(admin)/lead-scoring/fixtures/+page.svelte b/apps/web/src/routes/(admin)/lead-scoring/fixtures/+page.svelte index 26a7729..33c6eea 100644 --- a/apps/web/src/routes/(admin)/lead-scoring/fixtures/+page.svelte +++ b/apps/web/src/routes/(admin)/lead-scoring/fixtures/+page.svelte @@ -1,93 +1,219 @@ Thử chấm điểm · AI Platform

Lead Scoring / Kiểm thử

-

Thử chấm điểm với dữ liệu mẫu

+

Thử chấm điểm

- Sửa kịch bản mẫu (lead, hội thoại, quy tắc, metadata) rồi gửi. Dữ liệu mặc định hoàn toàn - giả lập; bấm tải lại trang để về mẫu gốc. + 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.

- -

Chế độ chấm điểm

- - {#if !data.fixtureRunnerConfigured} -

- Chưa có mã truy cập cho mẫu này trên server. -

- {/if} - {#if form?.message} -

- {form.message} -

- {/if} - {#if form?.success && form.run_id} -

- Kết quả: {form.result_mode}{form.error_code ? ` (${form.error_code})` : ''}. - Xem lần chấm điểm + +

{ + if (pending) { + cancel(); + return; + } + formData.set('payload', jsonMode ? payload : JSON.stringify(scenario)); + const keyInput = + formElement.querySelector('input[name="ai_key"]'); + if (keyInput) keyInput.value = ''; + pending = true; + return async ({ update }) => { + try { + await update({ reset: false }); + } finally { + pending = false; + } + }; + }} + > +
+

+ {jsonMode ? 'Kịch bản JSON' : 'Dựng kịch bản'} +

+ -

- {/if} - - -

- {useAI ? 'Quy tắc + AI · Quy tắc 50%, AI 50%' : 'Chỉ quy tắc · Quy tắc 100%, AI 0%'} -

-

- {#if useAI} - AI dùng cấu hình đang active và có thể phát sinh chi phí. Với AIHub cần khóa AI - (LEAD_SCORING_FIXTURE_AI_KEY trên server), nếu không sẽ dự phòng bằng điểm quy - tắc. Thiếu cấu hình hoặc provider lỗi cũng dẫn đến fallback; xem kết quả ở trang - chi tiết. - {:else} - Không gọi model AI. Chỉ chạy các quy tắc trong kịch bản. - {/if} -

-
- -