From 5c61227e6f42e6540a027a87ab2f63dbd5ec7544 Mon Sep 17 00:00:00 2001 From: hungtran24 Date: Fri, 9 Oct 2026 08:32:19 +0700 Subject: [PATCH] fix: drop temperature from AI requests and make the trial scenario editable - gpt-6-luna on AIHub rejects temperature=0 (HTTP 400), which made every call without reasoning_effort fall back; no longer send temperature. - Fixtures page edits the scenario as JSON instead of a fixed preview; backend validation errors are shown. - AI profile form keeps submitted values after a trial run. - Record the live AIHub check in the spec and README. --- README.md | 2 +- .../web/src/lib/server/fixture-runner.test.ts | 6 +- apps/web/src/lib/server/fixture-runner.ts | 10 ++- .../lead-scoring/ai-profiles/+page.server.ts | 6 +- .../lead-scoring/ai-profiles/+page.svelte | 4 +- .../lead-scoring/fixtures/+page.server.ts | 51 +++++++++-- .../lead-scoring/fixtures/+page.svelte | 25 +++++- .../fixtures/FixturePreview.svelte | 88 ------------------- .../lead-scoring/fixtures/fixtures.test.ts | 27 ++++-- docs/specs/ai-platform-lead-scoring.md | 6 +- .../infrastructure/chat_completions.py | 3 +- tests/test_chat_completions.py | 4 +- 12 files changed, 111 insertions(+), 121 deletions(-) delete mode 100644 apps/web/src/routes/(admin)/lead-scoring/fixtures/FixturePreview.svelte diff --git a/README.md b/README.md index f45e524..da7faa2 100644 --- a/README.md +++ b/README.md @@ -66,7 +66,7 @@ AI_PLATFORM_OPENAI_BASE_URL=http://127.0.0.1:8317 # AI_PLATFORM_OPENAI_API_KEY=... # chỉ cần nếu CLIProxy yêu cầu xác thực ``` -Chạy `make backend-dev` trên máy host: `127.0.0.1:8317` bên trong Docker không trỏ tới CLIProxy trên host. Trong Admin Web (Cấu hình AI), soạn một revision mới với `OpenAI-compatible`, URL `http://127.0.0.1:8317`, model `gemini-3.5-flash-lite`, hướng dẫn chấm và timeout, chạy thử rồi Kích hoạt. `AI_PLATFORM_OPENAI_API_KEY` chỉ được gửi tới `AI_PLATFORM_OPENAI_BASE_URL`; URL khác nhập trên Web không nhận key đó. Profile AIHub dùng khóa AI của client trong header `X-AI-Key` (không lưu). Trang “Thử chấm điểm” gửi mẫu với trọng số quy tắc/AI 0.5/0.5 khi bật “Chấm thêm bằng AI”; với AIHub, đặt `LEAD_SCORING_FIXTURE_AI_KEY` trên server Web, nếu không lần chấm dự phòng bằng điểm quy tắc. Chế độ này có thể phát sinh chi phí. +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í. 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. diff --git a/apps/web/src/lib/server/fixture-runner.test.ts b/apps/web/src/lib/server/fixture-runner.test.ts index a6448fb..2b02e74 100644 --- a/apps/web/src/lib/server/fixture-runner.test.ts +++ b/apps/web/src/lib/server/fixture-runner.test.ts @@ -15,8 +15,8 @@ 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, true); - await submitScoringFixture(event, false); + await submitScoringFixture(event, scoringFixture, true); + await submitScoringFixture(event, scoringFixture, false); const [[url, hybrid], [, rulesOnly]] = fetch.mock.calls; expect(url).toBe('http://backend:8000/v1/lead-scoring/score'); @@ -37,7 +37,7 @@ describe('server-only fixture transport', () => { try { env.LEAD_SCORING_FIXTURE_APP_API_KEY = ' '; await expect( - submitScoringFixture({ fetch } as unknown as RequestEvent), + submitScoringFixture({ fetch } as unknown as RequestEvent, scoringFixture), ).rejects.toBeInstanceOf(FixtureRunnerNotConfigured); expect(fetch).not.toHaveBeenCalled(); } finally { diff --git a/apps/web/src/lib/server/fixture-runner.ts b/apps/web/src/lib/server/fixture-runner.ts index 35c8500..bb83ee4 100644 --- a/apps/web/src/lib/server/fixture-runner.ts +++ b/apps/web/src/lib/server/fixture-runner.ts @@ -12,7 +12,11 @@ export function fixtureRunnerConfigured(): boolean { return Boolean(env.LEAD_SCORING_FIXTURE_APP_API_KEY?.trim()); } -export async function submitScoringFixture(event: RequestEvent, useAI = false): Promise { +export async function submitScoringFixture( + event: RequestEvent, + scenario: RunRequest, + useAI = false, +): Promise { const base = env.BACKEND_INTERNAL_URL; const appKey = env.LEAD_SCORING_FIXTURE_APP_API_KEY?.trim(); if (!appKey) throw new FixtureRunnerNotConfigured(); @@ -21,9 +25,9 @@ export async function submitScoringFixture(event: RequestEvent, useAI = false): } const request: RunRequest = { - ...scoringFixture, + ...scenario, scoring_config: { - ...scoringFixture.scoring_config, + ...scenario.scoring_config, rule_weight: useAI ? 0.5 : 1, ai_weight: useAI ? 0.5 : 0, }, diff --git a/apps/web/src/routes/(admin)/lead-scoring/ai-profiles/+page.server.ts b/apps/web/src/routes/(admin)/lead-scoring/ai-profiles/+page.server.ts index 055e71b..8f733a3 100644 --- a/apps/web/src/routes/(admin)/lead-scoring/ai-profiles/+page.server.ts +++ b/apps/web/src/routes/(admin)/lead-scoring/ai-profiles/+page.server.ts @@ -96,6 +96,10 @@ export const actions: Actions = { 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 }; + return { + message: 'Đã chạy thử trên dữ liệu mẫu, không lưu lần chấm nào.', + trial, + values: profile, + }; }, }; diff --git a/apps/web/src/routes/(admin)/lead-scoring/ai-profiles/+page.svelte b/apps/web/src/routes/(admin)/lead-scoring/ai-profiles/+page.svelte index 1060c03..959915b 100644 --- a/apps/web/src/routes/(admin)/lead-scoring/ai-profiles/+page.svelte +++ b/apps/web/src/routes/(admin)/lead-scoring/ai-profiles/+page.svelte @@ -8,8 +8,8 @@ import { Textarea } from '$lib/components/ui/textarea/index.js'; let { data, form }: PageProps = $props(); - // Start from the active revision (or the newest), so a change is a small edit. - const base = $derived(data.profiles.find((p) => p.active) ?? data.profiles[0]); + // 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'; 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 5162051..560f914 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 @@ -10,12 +10,26 @@ import type { components } from '$lib/generated/api'; import type { Actions, PageServerLoad } from './$types'; type ScoreResponse = components['schemas']['ScoreResponse']; +type RunRequest = components['schemas']['RunRequest']; export const load: PageServerLoad = async () => ({ fixtureRunnerConfigured: fixtureRunnerConfigured(), - fixture: scoringFixture, + 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 { + 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' }); @@ -33,26 +47,44 @@ export const actions: Actions = { }); } - const useAI = (await event.request.formData()).get('use_ai') === 'true'; + const form = await event.request.formData(); + const useAI = form.get('use_ai') === 'true'; + const payload = String(form.get('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ệ.', + useAI, + payload, + }); + } let response: Response; try { - response = await submitScoringFixture(event, useAI); + response = await submitScoringFixture(event, scenario, useAI); } 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 }); + return fail(502, { message: 'Không gửi được dữ liệu mẫu.', useAI, 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 }); + return fail(502, { message: 'Mã truy cập của mẫu không còn hợp lệ.', useAI, 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}).`, + message: `Backend từ chối dữ liệu mẫu (HTTP ${response.status})${reason ? `: ${reason}` : ''}.`, useAI, + payload, }); } @@ -61,6 +93,7 @@ export const actions: Actions = { return { success: true, useAI, + payload, run_id: run.run_id, result_mode: run.result_mode, error_code: run.error_code, @@ -69,7 +102,11 @@ export const actions: Actions = { : 'Đã 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 }); + 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 9a864ce..26a7729 100644 --- a/apps/web/src/routes/(admin)/lead-scoring/fixtures/+page.svelte +++ b/apps/web/src/routes/(admin)/lead-scoring/fixtures/+page.svelte @@ -3,7 +3,8 @@ import { Button } from '$lib/components/ui/button/index.js'; import * as Card from '$lib/components/ui/card/index.js'; - import FixturePreview from './FixturePreview.svelte'; + import { Label } from '$lib/components/ui/label/index.js'; + import { Textarea } from '$lib/components/ui/textarea/index.js'; let { data, form }: PageProps = $props(); let useAI = $derived(form?.useAI ?? false); @@ -14,7 +15,8 @@

Lead Scoring / Kiểm thử

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

- Xem hội thoại và quy tắc trước khi gửi. Dữ liệu dưới đây hoàn toàn giả lập. + 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.

@@ -64,13 +66,28 @@ 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. Các quy tắc mẫu và dữ liệu khách hàng được giữ nguyên. + Không gọi model AI. Chỉ chạy các quy tắc trong kịch bản. {/if}

+
+ +