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}
+
+
+
+
+ Trọng số quy tắc/AI trong JSON bị ghi đè theo lựa chọn ở trên. Quy tắc phải theo
+ hợp đồng (field, operator, value, points); backend báo lỗi nếu sai.
+
-
- {#each conversation.messages ?? [] as message (message.message_id)}
-
-
- {senderLabels[message.sender_type]}
-
-
-
- {message.content}
-
-
- {/each}
-
-
- {/each}
-
-
-
diff --git a/apps/web/src/routes/(admin)/lead-scoring/fixtures/fixtures.test.ts b/apps/web/src/routes/(admin)/lead-scoring/fixtures/fixtures.test.ts
index 8d9e5d6..da733e8 100644
--- a/apps/web/src/routes/(admin)/lead-scoring/fixtures/fixtures.test.ts
+++ b/apps/web/src/routes/(admin)/lead-scoring/fixtures/fixtures.test.ts
@@ -26,13 +26,15 @@ const runId = 'f4a5d7c8-f55f-47d2-b2ea-611a2b78e59b';
type ActionEvent = Parameters>[0];
-function eventFor(useAI = false): { event: ActionEvent; setHeaders: ReturnType } {
+function eventFor(
+ useAI = false,
+ payload = '{"metadata":{"edited":true}}',
+): { event: ActionEvent; setHeaders: ReturnType } {
const setHeaders = vi.fn();
const data = new FormData();
if (useAI) data.set('use_ai', 'true');
// Other submitted values cannot replace the server-owned fixture or weights.
- data.set('ai_weight', '1');
- data.set('payload', '{"metadata":"tampered"}');
+ data.set('payload', payload);
const request = { formData: vi.fn().mockResolvedValue(data) };
return { event: { setHeaders, request } as unknown as ActionEvent, setHeaders };
}
@@ -44,10 +46,13 @@ beforeEach(() => {
});
describe('fixture runner page', () => {
- it('returns the canonical preview without credentials even when the runner is unconfigured', 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[0]);
- expect(page).toEqual({ fixtureRunnerConfigured: false, fixture: scoringFixture });
+ expect(page).toEqual({
+ fixtureRunnerConfigured: false,
+ payload: JSON.stringify(scoringFixture, null, 2),
+ });
expect(JSON.stringify(page)).not.toMatch(/API_KEY|BACKEND_INTERNAL_URL|Bearer/);
});
@@ -72,7 +77,7 @@ describe('fixture runner page', () => {
});
expect(setHeaders).toHaveBeenCalledWith({ 'cache-control': 'no-store' });
expect(backendMock).toHaveBeenCalledWith(event, '/v1/admin/me');
- expect(submitMock).toHaveBeenCalledWith(event, useAI);
+ expect(submitMock).toHaveBeenCalledWith(event, { metadata: { edited: true } }, useAI);
expect(backendMock.mock.invocationCallOrder[0]).toBeLessThan(
submitMock.mock.invocationCallOrder[0],
);
@@ -82,6 +87,16 @@ describe('fixture runner page', () => {
},
);
+ 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');
+ 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;
diff --git a/docs/specs/ai-platform-lead-scoring.md b/docs/specs/ai-platform-lead-scoring.md
index 34e8e28..4b5167e 100644
--- a/docs/specs/ai-platform-lead-scoring.md
+++ b/docs/specs/ai-platform-lead-scoring.md
@@ -11,7 +11,7 @@ Dịch vụ AI scoring dùng chung, không giữ trạng thái nghiệp vụ. Cl
## Sở hữu
- 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, chạy fixtures, xem runs và thu hồi app access.
+- 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, chạy kịch bản thử (sửa được từ một mẫu JSON), xem runs và thu hồi app access.
## Endpoint
@@ -46,6 +46,8 @@ Một lần gọi AI trả JSON `{signals[], score, summary, suggested_action}`.
## Nghiệm thu và chưa xác minh
+Đã 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).
+
Đã 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.
-Chưa xác minh: hành vi AI thật (AIHub, model, `reasoning_effort`, `temperature`, độ trễ, 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ộ.
+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ộ.
diff --git a/src/ai_platform/infrastructure/chat_completions.py b/src/ai_platform/infrastructure/chat_completions.py
index 7d15d25..ac8e8af 100644
--- a/src/ai_platform/infrastructure/chat_completions.py
+++ b/src/ai_platform/infrastructure/chat_completions.py
@@ -70,8 +70,7 @@ class ChatCompletionsGateway:
}
if reasoning_effort:
body["reasoning_effort"] = reasoning_effort
- else:
- body["temperature"] = 0 # reasoning models reject it, so only without effort
+ # No temperature: reasoning models (gpt-6-luna on AIHub) reject any non-default value.
payload = await self._post(body, timeout)
try:
parsed = _Assessment.model_validate(payload)
diff --git a/tests/test_chat_completions.py b/tests/test_chat_completions.py
index 8425867..f2d17e8 100644
--- a/tests/test_chat_completions.py
+++ b/tests/test_chat_completions.py
@@ -72,13 +72,13 @@ async def test_request_shape_and_trimmed_response():
body = json.loads(seen[0].content)
assert str(seen[0].url) == "https://ai.example/v1/chat/completions"
assert seen[0].headers["authorization"] == "Bearer caller-key"
- assert (body["temperature"], "reasoning_effort" in body) == (0, False)
+ assert ("temperature" in body, "reasoning_effort" in body) == (False, False)
assert body["response_format"] == {"type": "json_object"}
assert (len(result.summary), len(result.signals[0].signal), result.score) == (300, 80, 30)
_, seen = await _assess(_ok(), _profile(reasoning_effort="low"))
body = json.loads(seen[0].content)
- assert (body["reasoning_effort"], "temperature" in body) == ("low", False)
+ assert body["reasoning_effort"] == "low"
@pytest.mark.asyncio