feat: add editable lead scoring scenario forms
This commit is contained in:
parent
5c61227e6f
commit
e4aafd7531
@ -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
|
# 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.
|
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
|
## 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
|
```sh
|
||||||
uv run --no-sync ai-platform create-app omni-local
|
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
|
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 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 <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.
|
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.
|
||||||
|
|
||||||
|
|||||||
@ -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 () => {
|
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 fetch = vi.fn().mockResolvedValue(Response.json({}, { status: 200 }));
|
||||||
const event = { fetch } as unknown as RequestEvent;
|
const event = { fetch } as unknown as RequestEvent;
|
||||||
await submitScoringFixture(event, scoringFixture, true);
|
const scenario = {
|
||||||
await submitScoringFixture(event, scoringFixture, false);
|
...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(url).toBe('http://backend:8000/v1/lead-scoring/score');
|
||||||
expect(hybrid.headers.authorization).toBe('Bearer test-only-app-key');
|
expect(hybrid.headers.authorization).toBe('Bearer test-only-app-key');
|
||||||
expect(hybrid.headers['X-AI-Key']).toBe('test-only-ai-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(rulesOnly.headers).not.toHaveProperty('X-AI-Key');
|
||||||
expect(hybrid.headers['Idempotency-Key']).not.toBe(rulesOnly.headers['Idempotency-Key']);
|
expect(hybrid.headers['Idempotency-Key']).not.toBe(rulesOnly.headers['Idempotency-Key']);
|
||||||
expect(JSON.parse(hybrid.body)).toEqual({
|
expect(JSON.parse(hybrid.body)).toEqual(scenario);
|
||||||
...scoringFixture,
|
expect(JSON.parse(rulesOnly.body)).toEqual(scoringFixture);
|
||||||
scoring_config: { ...scoringFixture.scoring_config, rule_weight: 0.5, ai_weight: 0.5 },
|
|
||||||
});
|
|
||||||
expect(hybrid.body).not.toMatch(/test-only/);
|
expect(hybrid.body).not.toMatch(/test-only/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -15,7 +15,7 @@ export function fixtureRunnerConfigured(): boolean {
|
|||||||
export async function submitScoringFixture(
|
export async function submitScoringFixture(
|
||||||
event: RequestEvent,
|
event: RequestEvent,
|
||||||
scenario: RunRequest,
|
scenario: RunRequest,
|
||||||
useAI = false,
|
requestAIKey = '',
|
||||||
): Promise<Response> {
|
): Promise<Response> {
|
||||||
const base = env.BACKEND_INTERNAL_URL;
|
const base = env.BACKEND_INTERNAL_URL;
|
||||||
const appKey = env.LEAD_SCORING_FIXTURE_APP_API_KEY?.trim();
|
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');
|
throw new Error('BACKEND_INTERNAL_URL must be configured on the server');
|
||||||
}
|
}
|
||||||
|
|
||||||
const request: RunRequest = {
|
const useAI = scenario.scoring_config?.ai_weight > 0;
|
||||||
...scenario,
|
const aiKey = requestAIKey.trim() || env.LEAD_SCORING_FIXTURE_AI_KEY?.trim();
|
||||||
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();
|
|
||||||
return event.fetch(`${base.replace(/\/$/, '')}/v1/lead-scoring/score`, {
|
return event.fetch(`${base.replace(/\/$/, '')}/v1/lead-scoring/score`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
cache: 'no-store',
|
cache: 'no-store',
|
||||||
@ -44,6 +37,6 @@ export async function submitScoringFixture(
|
|||||||
'Idempotency-Key': crypto.randomUUID(),
|
'Idempotency-Key': crypto.randomUUID(),
|
||||||
...(useAI && aiKey ? { 'X-AI-Key': aiKey } : {}),
|
...(useAI && aiKey ? { 'X-AI-Key': aiKey } : {}),
|
||||||
},
|
},
|
||||||
body: JSON.stringify(request),
|
body: JSON.stringify(scenario),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -48,8 +48,14 @@ export const actions: Actions = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const form = await event.request.formData();
|
const form = await event.request.formData();
|
||||||
const useAI = form.get('use_ai') === 'true';
|
|
||||||
const payload = String(form.get('payload') ?? '');
|
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;
|
let scenario: RunRequest;
|
||||||
try {
|
try {
|
||||||
scenario = JSON.parse(payload) as RunRequest;
|
scenario = JSON.parse(payload) as RunRequest;
|
||||||
@ -59,31 +65,28 @@ export const actions: Actions = {
|
|||||||
} catch {
|
} catch {
|
||||||
return fail(400, {
|
return fail(400, {
|
||||||
message: 'Kịch bản phải là một JSON object hợp lệ.',
|
message: 'Kịch bản phải là một JSON object hợp lệ.',
|
||||||
useAI,
|
|
||||||
payload,
|
payload,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
let response: Response;
|
let response: Response;
|
||||||
try {
|
try {
|
||||||
response = await submitScoringFixture(event, scenario, useAI);
|
response = await submitScoringFixture(event, scenario, aiKey.trim());
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof FixtureRunnerNotConfigured) {
|
if (error instanceof FixtureRunnerNotConfigured) {
|
||||||
return fail(503, {
|
return fail(503, {
|
||||||
message: 'Chưa có mã truy cập cho mẫu này trên server.',
|
message: 'Chưa có mã truy cập cho mẫu này trên server.',
|
||||||
useAI,
|
|
||||||
payload,
|
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) {
|
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) {
|
if (!response.ok) {
|
||||||
const reason = response.status === 422 ? await validationMessage(response) : '';
|
const reason = response.status === 422 ? await validationMessage(response) : '';
|
||||||
return fail(response.status, {
|
return fail(response.status, {
|
||||||
message: `Backend từ chối dữ liệu mẫu (HTTP ${response.status})${reason ? `: ${reason}` : ''}.`,
|
message: `Backend từ chối dữ liệu mẫu (HTTP ${response.status})${reason ? `: ${reason}` : ''}.`,
|
||||||
useAI,
|
|
||||||
payload,
|
payload,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -92,19 +95,16 @@ export const actions: Actions = {
|
|||||||
const run = (await response.json()) as ScoreResponse;
|
const run = (await response.json()) as ScoreResponse;
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
useAI,
|
|
||||||
payload,
|
payload,
|
||||||
run_id: run.run_id,
|
result: run,
|
||||||
result_mode: run.result_mode,
|
message:
|
||||||
error_code: run.error_code,
|
scenario.scoring_config?.ai_weight > 0
|
||||||
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 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.',
|
||||||
: 'Đã gửi mẫu chỉ chấm quy tắc.',
|
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
return fail(502, {
|
return fail(502, {
|
||||||
message: 'AI Platform trả về phản hồi không hợp lệ.',
|
message: 'AI Platform trả về phản hồi không hợp lệ.',
|
||||||
useAI,
|
|
||||||
payload,
|
payload,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,93 +1,219 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { onMount, untrack } from 'svelte';
|
||||||
|
import { enhance } from '$app/forms';
|
||||||
import type { PageProps } from './$types';
|
import type { PageProps } from './$types';
|
||||||
import { Button } from '$lib/components/ui/button/index.js';
|
import { Button } from '$lib/components/ui/button/index.js';
|
||||||
import * as Card from '$lib/components/ui/card/index.js';
|
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 { 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 { data, form }: PageProps = $props();
|
||||||
let useAI = $derived(form?.useAI ?? false);
|
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>
|
</script>
|
||||||
|
|
||||||
<svelte:head><title>Thử chấm điểm · AI Platform</title></svelte:head>
|
<svelte:head><title>Thử chấm điểm · AI Platform</title></svelte:head>
|
||||||
<header class="mb-8">
|
<header class="mb-8">
|
||||||
<p class="mb-2 text-xs font-semibold text-primary">Lead Scoring / Kiểm thử</p>
|
<p class="mb-2 text-xs font-semibold text-primary">Lead Scoring / Kiểm thử</p>
|
||||||
<h1 class="text-3xl font-bold">Thử chấm điểm với dữ liệu mẫu</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">
|
<p class="mt-2 max-w-prose text-sm text-muted-foreground">
|
||||||
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
|
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
|
||||||
giả lập; bấm tải lại trang để về mẫu gốc.
|
cho Omni. Tải lại trang để về mẫu gốc.
|
||||||
</p>
|
</p>
|
||||||
</header>
|
</header>
|
||||||
<Card.Root class="mb-5">
|
|
||||||
<Card.Header><h2 class="text-base font-semibold">Chế độ chấm điểm</h2></Card.Header>
|
<Card.Root>
|
||||||
<Card.Content class="grid gap-4">
|
<Card.Content class="pt-6">
|
||||||
{#if !data.fixtureRunnerConfigured}
|
<form
|
||||||
<p class="text-sm text-muted-foreground" role="status">
|
bind:this={formElement}
|
||||||
Chưa có mã truy cập cho mẫu này trên server.
|
method="POST"
|
||||||
</p>
|
class="grid gap-6"
|
||||||
{/if}
|
use:enhance={({ formData, cancel }) => {
|
||||||
{#if form?.message}
|
if (pending) {
|
||||||
<p
|
cancel();
|
||||||
class={form.success ? 'text-sm text-primary' : 'text-sm text-destructive'}
|
return;
|
||||||
role={form.success ? 'status' : 'alert'}
|
}
|
||||||
>
|
formData.set('payload', jsonMode ? payload : JSON.stringify(scenario));
|
||||||
{form.message}
|
const keyInput =
|
||||||
</p>
|
formElement.querySelector<HTMLInputElement>('input[name="ai_key"]');
|
||||||
{/if}
|
if (keyInput) keyInput.value = '';
|
||||||
{#if form?.success && form.run_id}
|
pending = true;
|
||||||
<p class="text-sm">
|
return async ({ update }) => {
|
||||||
Kết quả: {form.result_mode}{form.error_code ? ` (${form.error_code})` : ''}.
|
try {
|
||||||
<a
|
await update({ reset: false });
|
||||||
class="font-medium text-primary underline-offset-4 hover:underline focus-visible:outline-2"
|
} finally {
|
||||||
href={`/lead-scoring/runs/${form.run_id}`}>Xem lần chấm điểm</a
|
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
|
||||||
>
|
>
|
||||||
</p>
|
|
||||||
{/if}
|
|
||||||
<form method="POST" class="grid gap-4">
|
|
||||||
<label class="flex items-center gap-3 text-sm font-medium">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
name="use_ai"
|
|
||||||
value="true"
|
|
||||||
bind:checked={useAI}
|
|
||||||
aria-describedby="scoring-mode ai-note"
|
|
||||||
class="size-4 accent-primary focus-visible:outline-2 focus-visible:outline-primary"
|
|
||||||
/>
|
|
||||||
Chấm thêm bằng AI
|
|
||||||
</label>
|
|
||||||
<p id="scoring-mode" class="text-sm" aria-live="polite">
|
|
||||||
{useAI ? 'Quy tắc + AI · Quy tắc 50%, AI 50%' : 'Chỉ quy tắc · Quy tắc 100%, AI 0%'}
|
|
||||||
</p>
|
|
||||||
<p id="ai-note" class="max-w-prose text-sm text-muted-foreground">
|
|
||||||
{#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}
|
|
||||||
</p>
|
|
||||||
<div class="grid gap-2">
|
|
||||||
<Label for="payload">Kịch bản (JSON)</Label>
|
|
||||||
<Textarea
|
|
||||||
id="payload"
|
|
||||||
name="payload"
|
|
||||||
rows={24}
|
|
||||||
required
|
|
||||||
spellcheck={false}
|
|
||||||
class="font-mono text-xs"
|
|
||||||
value={form?.payload ?? data.payload}
|
|
||||||
/>
|
|
||||||
<p class="max-w-prose text-xs text-muted-foreground">
|
|
||||||
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.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<Button type="submit" disabled={!data.fixtureRunnerConfigured} class="w-fit">
|
{#if error}<p class="text-sm text-destructive" role="alert">{error}</p>{/if}
|
||||||
Gửi dữ liệu mẫu
|
<fieldset
|
||||||
</Button>
|
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 !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>
|
</form>
|
||||||
</Card.Content>
|
</Card.Content>
|
||||||
</Card.Root>
|
</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>
|
||||||
@ -28,12 +28,13 @@ type ActionEvent = Parameters<NonNullable<typeof actions.default>>[0];
|
|||||||
|
|
||||||
function eventFor(
|
function eventFor(
|
||||||
useAI = false,
|
useAI = false,
|
||||||
payload = '{"metadata":{"edited":true}}',
|
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> } {
|
): { event: ActionEvent; setHeaders: ReturnType<typeof vi.fn> } {
|
||||||
const setHeaders = vi.fn();
|
const setHeaders = vi.fn();
|
||||||
const data = new FormData();
|
const data = new FormData();
|
||||||
if (useAI) data.set('use_ai', 'true');
|
|
||||||
// Other submitted values cannot replace the server-owned fixture or weights.
|
|
||||||
data.set('payload', payload);
|
data.set('payload', payload);
|
||||||
const request = { formData: vi.fn().mockResolvedValue(data) };
|
const request = { formData: vi.fn().mockResolvedValue(data) };
|
||||||
return { event: { setHeaders, request } as unknown as ActionEvent, setHeaders };
|
return { event: { setHeaders, request } as unknown as ActionEvent, setHeaders };
|
||||||
@ -68,16 +69,28 @@ describe('fixture runner page', () => {
|
|||||||
const action = actions.default;
|
const action = actions.default;
|
||||||
if (!action) throw new Error('fixture action missing');
|
if (!action) throw new Error('fixture action missing');
|
||||||
const { event, setHeaders } = eventFor(useAI);
|
const { event, setHeaders } = eventFor(useAI);
|
||||||
|
const aiKey = useAI ? 'request-only-key' : '';
|
||||||
|
(await event.request.formData()).set('ai_key', ` ${aiKey} `);
|
||||||
const result = await action(event);
|
const result = await action(event);
|
||||||
expect(result).toMatchObject({
|
expect(result).toMatchObject({
|
||||||
success: true,
|
success: true,
|
||||||
useAI,
|
result: { run_id: runId, result_mode: 'hybrid', error_code: null },
|
||||||
run_id: runId,
|
|
||||||
result_mode: 'hybrid',
|
|
||||||
});
|
});
|
||||||
expect(setHeaders).toHaveBeenCalledWith({ 'cache-control': 'no-store' });
|
expect(setHeaders).toHaveBeenCalledWith({ 'cache-control': 'no-store' });
|
||||||
expect(backendMock).toHaveBeenCalledWith(event, '/v1/admin/me');
|
expect(backendMock).toHaveBeenCalledWith(event, '/v1/admin/me');
|
||||||
expect(submitMock).toHaveBeenCalledWith(event, { metadata: { edited: true } }, useAI);
|
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(
|
expect(backendMock.mock.invocationCallOrder[0]).toBeLessThan(
|
||||||
submitMock.mock.invocationCallOrder[0],
|
submitMock.mock.invocationCallOrder[0],
|
||||||
);
|
);
|
||||||
@ -133,8 +146,46 @@ describe('fixture runner page', () => {
|
|||||||
const result = await action(eventFor(true).event);
|
const result = await action(eventFor(true).event);
|
||||||
expect(result).toMatchObject({
|
expect(result).toMatchObject({
|
||||||
status: 503,
|
status: 503,
|
||||||
data: { message: 'Backend từ chối dữ liệu mẫu (HTTP 503).', useAI: true },
|
data: { message: 'Backend từ chối dữ liệu mẫu (HTTP 503).' },
|
||||||
});
|
});
|
||||||
expect(JSON.stringify(result)).not.toContain('private backend detail');
|
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
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,6 +1,6 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { backendJson } from '$lib/server/backend';
|
import { backendJson } from '$lib/server/backend';
|
||||||
import { actions, load } from './+page.server';
|
import { actions } from './+page.server';
|
||||||
|
|
||||||
type AppEvent = Parameters<NonNullable<typeof actions.revoke>>[0];
|
type AppEvent = Parameters<NonNullable<typeof actions.revoke>>[0];
|
||||||
vi.mock('$lib/server/backend', () => ({ backendJson: vi.fn() }));
|
vi.mock('$lib/server/backend', () => ({ backendJson: vi.fn() }));
|
||||||
@ -20,18 +20,6 @@ describe('app credential administration', () => {
|
|||||||
it('only allows revocation from the browser', () => {
|
it('only allows revocation from the browser', () => {
|
||||||
expect(Object.keys(actions)).toEqual(['revoke']);
|
expect(Object.keys(actions)).toEqual(['revoke']);
|
||||||
});
|
});
|
||||||
it('loads public app status without secrets', async () => {
|
|
||||||
const apps = [
|
|
||||||
{
|
|
||||||
app_id: 'omni-local',
|
|
||||||
active: true,
|
|
||||||
created_at: '2026-01-01T00:00:00Z',
|
|
||||||
updated_at: '2026-01-01T00:00:00Z',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
mock.mockResolvedValueOnce({ response: Response.json({ apps }), data: { apps } });
|
|
||||||
expect(await load({} as Parameters<typeof load>[0])).toEqual({ apps });
|
|
||||||
});
|
|
||||||
it('revokes through the server helper and encodes the app ID', async () => {
|
it('revokes through the server helper and encodes the app ID', async () => {
|
||||||
mock.mockResolvedValueOnce({
|
mock.mockResolvedValueOnce({
|
||||||
response: Response.json({}),
|
response: Response.json({}),
|
||||||
|
|||||||
@ -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
|
## 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.
|
- 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 kịch bản thử (sửa được từ một mẫu JSON), 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, 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.
|
||||||
|
|
||||||
## Endpoint
|
## Endpoint
|
||||||
|
|
||||||
@ -34,6 +34,8 @@ Dịch vụ AI scoring dùng chung, không giữ trạng thái nghiệp vụ. Cl
|
|||||||
|
|
||||||
## AI profile
|
## 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.
|
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`.
|
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`.
|
||||||
|
|||||||
@ -85,8 +85,8 @@ async def test_admin_login_rejects_invalid_credentials_without_mutating_session(
|
|||||||
async def test_expired_admin_session_is_not_authenticated():
|
async def test_expired_admin_session_is_not_authenticated():
|
||||||
repository = FakeAdminSessionRepository()
|
repository = FakeAdminSessionRepository()
|
||||||
now = datetime(2026, 9, 25, tzinfo=UTC)
|
now = datetime(2026, 9, 25, tzinfo=UTC)
|
||||||
repository.sessions["session-hash"] = AdminSessionRecord(
|
repository.sessions[digest("raw-session")] = AdminSessionRecord(
|
||||||
id="session-hash",
|
id=digest("raw-session"),
|
||||||
admin_email=repository.user.email,
|
admin_email=repository.user.email,
|
||||||
csrf_hash="csrf-hash",
|
csrf_hash="csrf-hash",
|
||||||
expires_at=now - timedelta(seconds=1),
|
expires_at=now - timedelta(seconds=1),
|
||||||
|
|||||||
@ -99,6 +99,7 @@ def test_admin_apps_require_a_session_and_never_expose_keys(env):
|
|||||||
def test_rotate_and_revoke_change_which_key_authenticates(env):
|
def test_rotate_and_revoke_change_which_key_authenticates(env):
|
||||||
csrf = _login(env)
|
csrf = _login(env)
|
||||||
assert _probe(env, env.initial_key) == 422
|
assert _probe(env, env.initial_key) == 422
|
||||||
|
assert env.verifier() == digest(env.initial_key) != env.initial_key
|
||||||
|
|
||||||
rotated = env.rotate()
|
rotated = env.rotate()
|
||||||
assert (_probe(env, env.initial_key), _probe(env, rotated)) == (401, 422)
|
assert (_probe(env, env.initial_key), _probe(env, rotated)) == (401, 422)
|
||||||
|
|||||||
@ -1,97 +1,42 @@
|
|||||||
from dataclasses import replace
|
from types import SimpleNamespace
|
||||||
from datetime import UTC, datetime
|
from typing import cast
|
||||||
|
from unittest.mock import AsyncMock
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from ai_platform.platform.application.app_credentials import (
|
from ai_platform.platform.application.app_credentials import (
|
||||||
AppCredentialConflict,
|
AppCredentialConflict,
|
||||||
AppCredentialNotFound,
|
AppCredentialNotFound,
|
||||||
AppCredentialRecord,
|
AppCredentialRepository,
|
||||||
AppCredentialUseCases,
|
AppCredentialUseCases,
|
||||||
)
|
)
|
||||||
from ai_platform.platform.security import digest
|
|
||||||
|
|
||||||
|
|
||||||
class Repository:
|
|
||||||
def __init__(self):
|
|
||||||
self.record = AppCredentialRecord("omni-local", True, datetime.now(UTC), datetime.now(UTC))
|
|
||||||
self.verifier = digest("initial-key")
|
|
||||||
self.error: Exception | None = None
|
|
||||||
|
|
||||||
async def list(self):
|
|
||||||
return [self.record]
|
|
||||||
|
|
||||||
async def active_app_for_verifier(self, verifier):
|
|
||||||
return self.record if self.record.active and verifier == self.verifier else None
|
|
||||||
|
|
||||||
async def create(self, app_id, verifier):
|
|
||||||
if self.error:
|
|
||||||
raise self.error
|
|
||||||
self.record = replace(self.record, app_id=app_id)
|
|
||||||
self.verifier = verifier
|
|
||||||
|
|
||||||
async def rotate(self, app_id, verifier):
|
|
||||||
if self.error:
|
|
||||||
raise self.error
|
|
||||||
self.verifier = verifier
|
|
||||||
self.record = replace(self.record, active=True)
|
|
||||||
|
|
||||||
async def revoke(self, app_id):
|
|
||||||
if self.error:
|
|
||||||
raise self.error
|
|
||||||
self.record = replace(self.record, active=False)
|
|
||||||
return self.record
|
|
||||||
|
|
||||||
|
|
||||||
class Transaction:
|
|
||||||
def __init__(self):
|
|
||||||
self.commits = 0
|
|
||||||
self.rollbacks = 0
|
|
||||||
self.error: Exception | None = None
|
|
||||||
|
|
||||||
async def commit(self):
|
|
||||||
self.commits += 1
|
|
||||||
if self.error:
|
|
||||||
raise self.error
|
|
||||||
|
|
||||||
async def rollback(self):
|
|
||||||
self.rollbacks += 1
|
|
||||||
|
|
||||||
|
|
||||||
async def test_lifecycle_preserves_identity_and_stores_only_verifier():
|
|
||||||
repo, tx = Repository(), Transaction()
|
|
||||||
keys = iter(["created-key", "rotated-key", "reactivated-key"])
|
|
||||||
cases = AppCredentialUseCases(repo, tx.commit, tx.rollback, lambda: next(keys))
|
|
||||||
assert await cases.create("omni-local") == "created-key"
|
|
||||||
assert repo.verifier == digest("created-key")
|
|
||||||
assert await cases.authenticate("created-key") == "omni-local"
|
|
||||||
assert await cases.rotate("omni-local") == "rotated-key"
|
|
||||||
assert await cases.authenticate("created-key") is None
|
|
||||||
assert await cases.authenticate("rotated-key") == "omni-local"
|
|
||||||
assert not (await cases.revoke("omni-local")).active
|
|
||||||
assert await cases.authenticate("rotated-key") is None
|
|
||||||
await cases.rotate("omni-local")
|
|
||||||
assert await cases.authenticate("reactivated-key") == "omni-local"
|
|
||||||
assert tx.commits == 4 and tx.rollbacks == 0
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("operation", ["create", "rotate", "revoke"])
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
"error", [AppCredentialConflict(), AppCredentialNotFound(), RuntimeError("storage")]
|
("operation", "error"),
|
||||||
|
[
|
||||||
|
("create", AppCredentialConflict()),
|
||||||
|
("rotate", AppCredentialNotFound()),
|
||||||
|
("revoke", RuntimeError("storage")),
|
||||||
|
],
|
||||||
)
|
)
|
||||||
async def test_repository_error_rolls_back(operation, error):
|
async def test_repository_error_rolls_back(operation, error):
|
||||||
repo, tx = Repository(), Transaction()
|
repo = cast(
|
||||||
repo.error = error
|
AppCredentialRepository, SimpleNamespace(**{operation: AsyncMock(side_effect=error)})
|
||||||
cases = AppCredentialUseCases(repo, tx.commit, tx.rollback, lambda: "key")
|
)
|
||||||
|
commit, rollback = AsyncMock(), AsyncMock()
|
||||||
|
cases = AppCredentialUseCases(repo, commit, rollback, lambda: "key")
|
||||||
with pytest.raises(type(error)):
|
with pytest.raises(type(error)):
|
||||||
await getattr(cases, operation)("omni-local")
|
await getattr(cases, operation)("omni-local")
|
||||||
assert tx.commits == 0 and tx.rollbacks == 1
|
commit.assert_not_awaited()
|
||||||
|
rollback.assert_awaited_once()
|
||||||
|
|
||||||
|
|
||||||
async def test_failed_commit_rolls_back():
|
async def test_failed_commit_rolls_back():
|
||||||
repo, tx = Repository(), Transaction()
|
repo = cast(AppCredentialRepository, SimpleNamespace(rotate=AsyncMock()))
|
||||||
tx.error = RuntimeError("commit failed")
|
commit, rollback = AsyncMock(side_effect=RuntimeError("commit failed")), AsyncMock()
|
||||||
cases = AppCredentialUseCases(repo, tx.commit, tx.rollback, lambda: "key")
|
cases = AppCredentialUseCases(repo, commit, rollback, lambda: "key")
|
||||||
with pytest.raises(RuntimeError, match="commit failed"):
|
with pytest.raises(RuntimeError, match="commit failed"):
|
||||||
await cases.rotate("omni-local")
|
await cases.rotate("omni-local")
|
||||||
assert tx.commits == 1 and tx.rollbacks == 1
|
commit.assert_awaited_once()
|
||||||
|
rollback.assert_awaited_once()
|
||||||
|
|||||||
@ -5,10 +5,8 @@ from uuid import uuid4
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from alembic.autogenerate import compare_metadata
|
from alembic.autogenerate import compare_metadata
|
||||||
from alembic.config import Config
|
|
||||||
from alembic.migration import MigrationContext
|
from alembic.migration import MigrationContext
|
||||||
from alembic.operations import Operations
|
from alembic.operations import Operations
|
||||||
from alembic.script import ScriptDirectory
|
|
||||||
from sqlalchemy import inspect, text
|
from sqlalchemy import inspect, text
|
||||||
|
|
||||||
from ai_platform.infrastructure.config.settings import Settings
|
from ai_platform.infrastructure.config.settings import Settings
|
||||||
@ -17,18 +15,6 @@ from ai_platform.infrastructure.persistence.database import Base, make_session_f
|
|||||||
ROOT = Path(__file__).resolve().parents[1]
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
|
||||||
|
|
||||||
def test_one_initial_migration_with_no_legacy_chain():
|
|
||||||
config = Config(str(ROOT / "alembic.ini"))
|
|
||||||
config.set_main_option("path_separator", "os")
|
|
||||||
config.set_main_option("script_location", str(ROOT / "migrations"))
|
|
||||||
scripts = ScriptDirectory.from_config(config)
|
|
||||||
revisions = list(scripts.walk_revisions())
|
|
||||||
assert len(revisions) == 1
|
|
||||||
assert revisions[0].revision == "0001_initial"
|
|
||||||
assert revisions[0].down_revision is None
|
|
||||||
assert scripts.get_heads() == ["0001_initial"]
|
|
||||||
|
|
||||||
|
|
||||||
async def test_initial_schema_matches_models_and_round_trips():
|
async def test_initial_schema_matches_models_and_round_trips():
|
||||||
settings = Settings.load()
|
settings = Settings.load()
|
||||||
if urlsplit(settings.database_url).path.rstrip("/").rsplit("/", 1)[-1] != "ai_platform_test":
|
if urlsplit(settings.database_url).path.rstrip("/").rsplit("/", 1)[-1] != "ai_platform_test":
|
||||||
|
|||||||
@ -131,7 +131,7 @@ def test_rule_description_is_optional_and_never_changes_the_score(canonical_requ
|
|||||||
assert calculate_run(described) == calculate_run(plain)
|
assert calculate_run(described) == calculate_run(plain)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("description", ["x" * 100, 99, []])
|
@pytest.mark.parametrize("description", ["x" * 100, 99])
|
||||||
def test_rule_description_rejects_overlong_or_non_strings(canonical_request, description):
|
def test_rule_description_rejects_overlong_or_non_strings(canonical_request, description):
|
||||||
with pytest.raises(ValidationError):
|
with pytest.raises(ValidationError):
|
||||||
RunRequest.model_validate(
|
RunRequest.model_validate(
|
||||||
@ -160,7 +160,6 @@ def test_between_and_in_rules_are_accepted(canonical_request, rule):
|
|||||||
("rule", "error"),
|
("rule", "error"),
|
||||||
[
|
[
|
||||||
({"operator": "between", "value": [1]}, "min, max"),
|
({"operator": "between", "value": [1]}, "min, max"),
|
||||||
({"operator": "between", "value": [1, 2, 3]}, "min, max"),
|
|
||||||
({"operator": "between", "value": [5, 1]}, "exceed"),
|
({"operator": "between", "value": [5, 1]}, "exceed"),
|
||||||
({"operator": "between", "value": [1, "2"]}, "min, max"),
|
({"operator": "between", "value": [1, "2"]}, "min, max"),
|
||||||
({"operator": "between", "value": [True, 2]}, "min, max"),
|
({"operator": "between", "value": [True, 2]}, "min, max"),
|
||||||
|
|||||||
@ -130,7 +130,8 @@ def test_same_key_replays_without_calling_ai_and_a_changed_body_is_422(
|
|||||||
changed = {**request, "metadata": {"other": 1}}
|
changed = {**request, "metadata": {"other": 1}}
|
||||||
with TestClient(score_env["app_for"](_ai(calls))) as client:
|
with TestClient(score_env["app_for"](_ai(calls))) as client:
|
||||||
first = _post(client, score_env, request, key=key, ai_key=True)
|
first = _post(client, score_env, request, key=key, ai_key=True)
|
||||||
again = _post(client, score_env, request, key=key, ai_key=True)
|
reordered = dict(reversed(list(request.items())))
|
||||||
|
again = _post(client, score_env, reordered, key=key, ai_key=True)
|
||||||
conflict = _post(client, score_env, changed, key=key, ai_key=True)
|
conflict = _post(client, score_env, changed, key=key, ai_key=True)
|
||||||
assert first.status_code == again.status_code == 200
|
assert first.status_code == again.status_code == 200
|
||||||
assert again.json() == first.json()
|
assert again.json() == first.json()
|
||||||
|
|||||||
@ -1,16 +0,0 @@
|
|||||||
import hashlib
|
|
||||||
|
|
||||||
from ai_platform.lead_scoring.application.run_use_cases import canonical
|
|
||||||
from ai_platform.platform.security import digest
|
|
||||||
|
|
||||||
|
|
||||||
def test_canonical_body_fingerprint_ignores_mapping_key_order():
|
|
||||||
left = hashlib.sha256(canonical({"a": 1, "b": 2})).digest()
|
|
||||||
right = hashlib.sha256(canonical({"b": 2, "a": 1})).digest()
|
|
||||||
assert left == right
|
|
||||||
|
|
||||||
|
|
||||||
def test_secret_digest_is_one_way_verifier():
|
|
||||||
raw = "tnt_example"
|
|
||||||
assert digest(raw) != raw
|
|
||||||
assert digest(raw) == digest(raw)
|
|
||||||
Loading…
x
Reference in New Issue
Block a user