feat: add editable lead scoring scenario forms

This commit is contained in:
hungtran24 2026-10-16 09:00:40 +07:00
parent 5c61227e6f
commit e4aafd7531
23 changed files with 1122 additions and 244 deletions

View File

@ -66,13 +66,15 @@ AI_PLATFORM_OPENAI_BASE_URL=http://127.0.0.1:8317
# AI_PLATFORM_OPENAI_API_KEY=... # chỉ cần nếu CLIProxy yêu cầu xác thực
```
Chạy `make backend-dev` trên máy host: `127.0.0.1:8317` bên trong Docker không trỏ tới CLIProxy trên host. Trong Admin Web (Cấu hình AI), soạn một revision mới với `OpenAI-compatible`, URL `http://127.0.0.1:8317`, model `gemini-3.5-flash-lite`, hướng dẫn chấm và timeout, chạy thử rồi Kích hoạt. `AI_PLATFORM_OPENAI_API_KEY` chỉ được gửi tới `AI_PLATFORM_OPENAI_BASE_URL`; URL khác nhập trên Web không nhận key đó. Profile AIHub dùng khóa AI của client trong header `X-AI-Key` (không lưu). Trang “Thử chấm điểm” cho sửa kịch bản mẫu (JSON: lead, hội thoại, quy tắc, metadata) rồi gửi; bật “Chấm thêm bằng AI” thì trọng số quy tắc/AI là 0.5/0.5 (ghi đè trọng số trong JSON); với AIHub, đặt `LEAD_SCORING_FIXTURE_AI_KEY` trên server Web, nếu không lần chấm dự phòng bằng điểm quy tắc. Chế độ này có thể phát sinh chi phí.
Chạy `make backend-dev` trên máy host: `127.0.0.1:8317` bên trong Docker không trỏ tới CLIProxy trên host. Trong Admin Web (Cấu hình AI), soạn một revision mới với `OpenAI-compatible`, URL `http://127.0.0.1:8317`, model `gemini-3.5-flash-lite`, hướng dẫn chấm và timeout, chạy thử rồi Kích hoạt. `AI_PLATFORM_OPENAI_API_KEY` chỉ được gửi tới `AI_PLATFORM_OPENAI_BASE_URL`; URL khác nhập trên Web không nhận key đó. Profile AIHub dùng khóa AI của client trong header `X-AI-Key` (không lưu).
Trang “Thử chấm điểm” có form sửa lead/custom fields, thêm/sửa/xóa hội thoại và tin nhắn (thời gian UTC), dựng rule với field/operator/giá trị/điểm và chọn tỷ lệ AI. Trọng số quy tắc tự bổ sung để tổng bằng 100%; AI 0% không gọi model. JSON nâng cao đồng bộ cùng kịch bản và gửi nguyên trọng số. Kết quả hiện điểm, breakdown và fallback ngay trên trang, kèm liên kết chi tiết. Ngưỡng ở đây là điều kiện rule; phân loại lead vẫn do client quyết định. Kịch bản không lưu thành chính sách và không ghi đè fixture chuẩn. Với AIHub, nhập “Khóa AI cho lần thử (không lưu)” trên trang này, hoặc đặt `LEAD_SCORING_FIXTURE_AI_KEY` trên server Web. Khóa nhập được ưu tiên, chỉ chuyển trong header `X-AI-Key` khi AI > 0%, không nằm trong JSON/kết quả và xóa khỏi ô nhập khi gửi. Khóa dùng thử ở trang Cấu hình AI không được lưu hay chuyển sang trang này. Thiếu khóa sẽ fallback bằng điểm quy tắc. Chế độ AI có thể phát sinh chi phí.
Với endpoint OpenAI-compatible chạy ở production, URL phải dùng HTTPS. Key được cấu hình trên server chỉ gửi tới URL được gắn với key đó; thay URL trên Web không tự chuyển key. Không đặt key vào Web hoặc file versioned. CLIProxy local chỉ xác minh được đường gọi thử; chưa chứng minh các điều kiện phát hành production.
## App credentials
Mỗi installation/môi trường Omni dùng một `app_id` ổn định và một backend app key chung cho các account. Vận hành cấp/rotate/revoke qua CLI; raw key chỉ được xuất khi cấp/rotate, chuyển an toàn sang backend Omni, không qua browser/log/Git:
Mỗi installation/môi trường Omni dùng một `app_id` ổn định (tên đăng ký phía AI Platform, Omni không cần biết hay gửi) và một backend app key chung cho các account. Vận hành cấp/rotate/revoke qua CLI; raw key chỉ được xuất khi cấp/rotate, chuyển an toàn sang backend Omni, không qua browser/log/Git:
```sh
uv run --no-sync ai-platform create-app omni-local
@ -80,7 +82,7 @@ uv run --no-sync ai-platform rotate-app-key omni-local
uv run --no-sync ai-platform revoke-app-key omni-local
```
Admin Web `/platform/apps` chỉ xem trạng thái và thu hồi, không hiển thị hay cấp/rotate key. POST `/v1/lead-scoring/score` đồng bộ, dùng `Authorization: Bearer <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.

View File

@ -15,19 +15,24 @@ describe('server-only fixture transport', () => {
it('sends the canonical fixture with a fresh idempotency key and keys only in headers', async () => {
const fetch = vi.fn().mockResolvedValue(Response.json({}, { status: 200 }));
const event = { fetch } as unknown as RequestEvent;
await submitScoringFixture(event, scoringFixture, true);
await submitScoringFixture(event, scoringFixture, false);
const scenario = {
...scoringFixture,
scoring_config: { ...scoringFixture.scoring_config, rule_weight: 0.7, ai_weight: 0.3 },
};
await submitScoringFixture(event, scenario);
await submitScoringFixture(event, scenario, 'request-only-key');
await submitScoringFixture(event, scoringFixture, 'request-only-key');
const [[url, hybrid], [, rulesOnly]] = fetch.mock.calls;
const [[url, hybrid], [, requestKey], [, rulesOnly]] = fetch.mock.calls;
expect(url).toBe('http://backend:8000/v1/lead-scoring/score');
expect(hybrid.headers.authorization).toBe('Bearer test-only-app-key');
expect(hybrid.headers['X-AI-Key']).toBe('test-only-ai-key');
expect(requestKey.headers['X-AI-Key']).toBe('request-only-key');
expect(requestKey.body).not.toContain('request-only-key');
expect(rulesOnly.headers).not.toHaveProperty('X-AI-Key');
expect(hybrid.headers['Idempotency-Key']).not.toBe(rulesOnly.headers['Idempotency-Key']);
expect(JSON.parse(hybrid.body)).toEqual({
...scoringFixture,
scoring_config: { ...scoringFixture.scoring_config, rule_weight: 0.5, ai_weight: 0.5 },
});
expect(JSON.parse(hybrid.body)).toEqual(scenario);
expect(JSON.parse(rulesOnly.body)).toEqual(scoringFixture);
expect(hybrid.body).not.toMatch(/test-only/);
});

View File

@ -15,7 +15,7 @@ export function fixtureRunnerConfigured(): boolean {
export async function submitScoringFixture(
event: RequestEvent,
scenario: RunRequest,
useAI = false,
requestAIKey = '',
): Promise<Response> {
const base = env.BACKEND_INTERNAL_URL;
const appKey = env.LEAD_SCORING_FIXTURE_APP_API_KEY?.trim();
@ -24,15 +24,8 @@ export async function submitScoringFixture(
throw new Error('BACKEND_INTERNAL_URL must be configured on the server');
}
const request: RunRequest = {
...scenario,
scoring_config: {
...scenario.scoring_config,
rule_weight: useAI ? 0.5 : 1,
ai_weight: useAI ? 0.5 : 0,
},
};
const aiKey = env.LEAD_SCORING_FIXTURE_AI_KEY?.trim();
const useAI = scenario.scoring_config?.ai_weight > 0;
const aiKey = requestAIKey.trim() || env.LEAD_SCORING_FIXTURE_AI_KEY?.trim();
return event.fetch(`${base.replace(/\/$/, '')}/v1/lead-scoring/score`, {
method: 'POST',
cache: 'no-store',
@ -44,6 +37,6 @@ export async function submitScoringFixture(
'Idempotency-Key': crypto.randomUUID(),
...(useAI && aiKey ? { 'X-AI-Key': aiKey } : {}),
},
body: JSON.stringify(request),
body: JSON.stringify(scenario),
});
}

View File

@ -48,8 +48,14 @@ export const actions: Actions = {
}
const form = await event.request.formData();
const useAI = form.get('use_ai') === 'true';
const payload = String(form.get('payload') ?? '');
const aiKey = String(form.get('ai_key') ?? '');
if (aiKey.length > 4096 || /[\r\n]/.test(aiKey)) {
return fail(400, {
message: 'Khóa AI tối đa 4096 ký tự, không chứa xuống dòng.',
payload,
});
}
let scenario: RunRequest;
try {
scenario = JSON.parse(payload) as RunRequest;
@ -59,31 +65,28 @@ export const actions: Actions = {
} catch {
return fail(400, {
message: 'Kịch bản phải là một JSON object hợp lệ.',
useAI,
payload,
});
}
let response: Response;
try {
response = await submitScoringFixture(event, scenario, useAI);
response = await submitScoringFixture(event, scenario, aiKey.trim());
} catch (error) {
if (error instanceof FixtureRunnerNotConfigured) {
return fail(503, {
message: 'Chưa có mã truy cập cho mẫu này trên server.',
useAI,
payload,
});
}
return fail(502, { message: 'Không gửi được dữ liệu mẫu.', useAI, payload });
return fail(502, { message: 'Không gửi được dữ liệu mẫu.', payload });
}
if (response.status === 401) {
return fail(502, { message: 'Mã truy cập của mẫu không còn hợp lệ.', useAI, payload });
return fail(502, { message: 'Mã truy cập của mẫu không còn hợp lệ.', payload });
}
if (!response.ok) {
const reason = response.status === 422 ? await validationMessage(response) : '';
return fail(response.status, {
message: `Backend từ chối dữ liệu mẫu (HTTP ${response.status})${reason ? `: ${reason}` : ''}.`,
useAI,
payload,
});
}
@ -92,19 +95,16 @@ export const actions: Actions = {
const run = (await response.json()) as ScoreResponse;
return {
success: true,
useAI,
payload,
run_id: run.run_id,
result_mode: run.result_mode,
error_code: run.error_code,
message: useAI
result: run,
message:
scenario.scoring_config?.ai_weight > 0
? 'Đã gửi mẫu quy tắc và AI. Xem chi tiết để kiểm tra kết quả AI hoặc fallback.'
: 'Đã gửi mẫu chỉ chấm quy tắc.',
};
} catch {
return fail(502, {
message: 'AI Platform trả về phản hồi không hợp lệ.',
useAI,
payload,
});
}

View File

@ -1,93 +1,219 @@
<script lang="ts">
import { onMount, untrack } from 'svelte';
import { enhance } from '$app/forms';
import type { PageProps } from './$types';
import { Button } from '$lib/components/ui/button/index.js';
import * as Card from '$lib/components/ui/card/index.js';
import { Label } from '$lib/components/ui/label/index.js';
import { Input } from '$lib/components/ui/input/index.js';
import { Textarea } from '$lib/components/ui/textarea/index.js';
import * as Card from '$lib/components/ui/card/index.js';
import LeadEditor from './LeadEditor.svelte';
import ConversationEditor from './ConversationEditor.svelte';
import RuleEditor from './RuleEditor.svelte';
import ValueInput from './ValueInput.svelte';
import ScenarioResult from './ScenarioResult.svelte';
import { parseScenario, setAIWeight } from './scenario';
let { data, form }: PageProps = $props();
let 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>
<svelte:head><title>Thử chấm điểm · AI Platform</title></svelte:head>
<header class="mb-8">
<p class="mb-2 text-xs font-semibold text-primary">Lead Scoring / Kiểm thử</p>
<h1 class="text-3xl font-bold">Thử 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">
Sửa kịch bản mẫu (lead, hội thoại, quy tắc, metadata) rồi gửi. Dữ liệu mặc định hoàn toàn
giả lập; bấm tải lại trang để về mẫu gốc.
Dựng lead, hội thoại và quy tắc để thử chấm điểm. Đây là kịch bản thử, không lưu chính sách
cho Omni. Tải lại trang để về mẫu gốc.
</p>
</header>
<Card.Root class="mb-5">
<Card.Header><h2 class="text-base font-semibold">Chế độ chấm điểm</h2></Card.Header>
<Card.Content class="grid gap-4">
{#if !data.fixtureRunnerConfigured}
<p class="text-sm text-muted-foreground" role="status">
Chưa có mã truy cập cho mẫu này trên server.
</p>
{/if}
{#if form?.message}
<p
class={form.success ? 'text-sm text-primary' : 'text-sm text-destructive'}
role={form.success ? 'status' : 'alert'}
<Card.Root>
<Card.Content class="pt-6">
<form
bind:this={formElement}
method="POST"
class="grid gap-6"
use:enhance={({ formData, cancel }) => {
if (pending) {
cancel();
return;
}
formData.set('payload', jsonMode ? payload : JSON.stringify(scenario));
const keyInput =
formElement.querySelector<HTMLInputElement>('input[name="ai_key"]');
if (keyInput) keyInput.value = '';
pending = true;
return async ({ update }) => {
try {
await update({ reset: false });
} finally {
pending = false;
}
};
}}
>
{form.message}
</p>
{/if}
{#if form?.success && form.run_id}
<p class="text-sm">
Kết quả: {form.result_mode}{form.error_code ? ` (${form.error_code})` : ''}.
<a
class="font-medium text-primary underline-offset-4 hover:underline focus-visible:outline-2"
href={`/lead-scoring/runs/${form.run_id}`}>Xem lần chấm điểm</a
<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>
</div>
{#if error}<p class="text-sm text-destructive" role="alert">{error}</p>{/if}
<fieldset
disabled={!ready || pending}
class="grid min-w-0 gap-6 divide-y divide-border"
>
{#if jsonMode}
<label class="grid gap-2 text-sm font-medium"
>Kịch bản (JSON)
<Textarea
id="payload"
name="payload"
rows={24}
required
spellcheck={false}
class="font-mono text-xs"
value={form?.payload ?? data.payload}
bind:value={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.
</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>
<Button type="submit" disabled={!data.fixtureRunnerConfigured} class="w-fit">
Gửi dữ liệu mẫu
</Button>
<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 &gt; 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>
</Card.Content>
</Card.Root>
{#if form?.success && form.result}
<ScenarioResult result={form.result} />
{/if}

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -28,12 +28,13 @@ type ActionEvent = Parameters<NonNullable<typeof actions.default>>[0];
function eventFor(
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> } {
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('payload', payload);
const request = { formData: vi.fn().mockResolvedValue(data) };
return { event: { setHeaders, request } as unknown as ActionEvent, setHeaders };
@ -68,16 +69,28 @@ describe('fixture runner page', () => {
const action = actions.default;
if (!action) throw new Error('fixture action missing');
const { event, setHeaders } = eventFor(useAI);
const aiKey = useAI ? 'request-only-key' : '';
(await event.request.formData()).set('ai_key', ` ${aiKey} `);
const result = await action(event);
expect(result).toMatchObject({
success: true,
useAI,
run_id: runId,
result_mode: 'hybrid',
result: { run_id: runId, result_mode: 'hybrid', error_code: null },
});
expect(setHeaders).toHaveBeenCalledWith({ 'cache-control': 'no-store' });
expect(backendMock).toHaveBeenCalledWith(event, '/v1/admin/me');
expect(submitMock).toHaveBeenCalledWith(event, { metadata: { edited: true } }, 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(
submitMock.mock.invocationCallOrder[0],
);
@ -133,8 +146,46 @@ describe('fixture runner page', () => {
const result = await action(eventFor(true).event);
expect(result).toMatchObject({
status: 503,
data: { message: 'Backend từ chối dữ liệu mẫu (HTTP 503).', useAI: true },
data: { message: 'Backend từ chối dữ liệu mẫu (HTTP 503).' },
});
expect(JSON.stringify(result)).not.toContain('private backend detail');
});
it('keeps the scenario and displays the backend validation location', async () => {
submitMock.mockResolvedValueOnce(
Response.json(
{
detail: [
{
loc: ['body', 'scoring_config', 'rules', 0],
msg: 'between min cannot exceed max',
},
],
},
{ status: 422 },
),
);
const payload = '{"scoring_config":{"rules":[]}}';
const result = await actions.default!(eventFor(false, payload).event);
expect(result).toMatchObject({
status: 422,
data: {
payload,
message: expect.stringContaining(
'scoring_config.rules.0: between min cannot exceed max',
),
},
});
});
it('rejects oversized or multiline AI keys without sending or echoing them', async () => {
for (const key of ['private-key\r\n', 'k'.repeat(4097)]) {
const { event } = eventFor(true);
(await event.request.formData()).set('ai_key', key);
const result = await actions.default!(event);
expect(result).toMatchObject({ status: 400 });
expect(JSON.stringify(result)).not.toContain(key);
}
expect(submitMock).not.toHaveBeenCalled();
});
});

View File

@ -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');
});
});

View 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;
}

View File

@ -1,6 +1,6 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
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];
vi.mock('$lib/server/backend', () => ({ backendJson: vi.fn() }));
@ -20,18 +20,6 @@ describe('app credential administration', () => {
it('only allows revocation from the browser', () => {
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 () => {
mock.mockResolvedValueOnce({
response: Response.json({}),

View File

@ -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 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
@ -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
Admin Web “Thử chấm điểm” nhận khóa AI riêng cho từng lần thử. Khóa không nằm trong kịch bản JSON, không lưu/echo; được chuyển qua `X-AI-Key` khi trọng số AI > 0 và xóa khỏi ô nhập khi gửi. Nếu ô trống, runner dùng `LEAD_SCORING_FIXTURE_AI_KEY` trên server nếu có. Khóa nhập khi thử AI profile không được lưu vào revision hoặc tự chuyển sang lần thử kịch bản.
Admin Web lưu AI profile theo revision tự tăng. Lưu tạo revision mới (không active), kích hoạt là bước riêng và kích hoạt revision cũ là rollback. Profile gồm `provider` (`aihub` hoặc `openai_compatible`), `base_url`, `model`, `timeout_seconds` (1 đến 60), `reasoning_effort`, `instructions` (rubric do admin viết), `include_reference_date`, `timezone`, `suggested_action_enabled`. Khung kỹ thuật của prompt nằm trong code. Mỗi run lưu `profile_revision`. Chạy thử một profile nháp không lưu run và không đổi revision active. Đổi URL có hiệu lực ngay, không restart.
Một lần gọi AI trả JSON `{signals[], score, summary, suggested_action}`. `base_url` phải qua `safe_http_url`: HTTPS, hoặc HTTP chỉ với loopback ở development, không userinfo, query hay fragment. AIHub dùng khóa trong `X-AI-Key`. OpenAI-compatible dùng `AI_PLATFORM_OPENAI_API_KEY`, chỉ gửi tới `AI_PLATFORM_OPENAI_BASE_URL`.

View File

@ -85,8 +85,8 @@ async def test_admin_login_rejects_invalid_credentials_without_mutating_session(
async def test_expired_admin_session_is_not_authenticated():
repository = FakeAdminSessionRepository()
now = datetime(2026, 9, 25, tzinfo=UTC)
repository.sessions["session-hash"] = AdminSessionRecord(
id="session-hash",
repository.sessions[digest("raw-session")] = AdminSessionRecord(
id=digest("raw-session"),
admin_email=repository.user.email,
csrf_hash="csrf-hash",
expires_at=now - timedelta(seconds=1),

View File

@ -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):
csrf = _login(env)
assert _probe(env, env.initial_key) == 422
assert env.verifier() == digest(env.initial_key) != env.initial_key
rotated = env.rotate()
assert (_probe(env, env.initial_key), _probe(env, rotated)) == (401, 422)

View File

@ -1,97 +1,42 @@
from dataclasses import replace
from datetime import UTC, datetime
from types import SimpleNamespace
from typing import cast
from unittest.mock import AsyncMock
import pytest
from ai_platform.platform.application.app_credentials import (
AppCredentialConflict,
AppCredentialNotFound,
AppCredentialRecord,
AppCredentialRepository,
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(
"error", [AppCredentialConflict(), AppCredentialNotFound(), RuntimeError("storage")]
("operation", "error"),
[
("create", AppCredentialConflict()),
("rotate", AppCredentialNotFound()),
("revoke", RuntimeError("storage")),
],
)
async def test_repository_error_rolls_back(operation, error):
repo, tx = Repository(), Transaction()
repo.error = error
cases = AppCredentialUseCases(repo, tx.commit, tx.rollback, lambda: "key")
repo = cast(
AppCredentialRepository, SimpleNamespace(**{operation: AsyncMock(side_effect=error)})
)
commit, rollback = AsyncMock(), AsyncMock()
cases = AppCredentialUseCases(repo, commit, rollback, lambda: "key")
with pytest.raises(type(error)):
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():
repo, tx = Repository(), Transaction()
tx.error = RuntimeError("commit failed")
cases = AppCredentialUseCases(repo, tx.commit, tx.rollback, lambda: "key")
repo = cast(AppCredentialRepository, SimpleNamespace(rotate=AsyncMock()))
commit, rollback = AsyncMock(side_effect=RuntimeError("commit failed")), AsyncMock()
cases = AppCredentialUseCases(repo, commit, rollback, lambda: "key")
with pytest.raises(RuntimeError, match="commit failed"):
await cases.rotate("omni-local")
assert tx.commits == 1 and tx.rollbacks == 1
commit.assert_awaited_once()
rollback.assert_awaited_once()

View File

@ -5,10 +5,8 @@ from uuid import uuid4
import pytest
from alembic.autogenerate import compare_metadata
from alembic.config import Config
from alembic.migration import MigrationContext
from alembic.operations import Operations
from alembic.script import ScriptDirectory
from sqlalchemy import inspect, text
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]
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():
settings = Settings.load()
if urlsplit(settings.database_url).path.rstrip("/").rsplit("/", 1)[-1] != "ai_platform_test":

View File

@ -131,7 +131,7 @@ def test_rule_description_is_optional_and_never_changes_the_score(canonical_requ
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):
with pytest.raises(ValidationError):
RunRequest.model_validate(
@ -160,7 +160,6 @@ def test_between_and_in_rules_are_accepted(canonical_request, rule):
("rule", "error"),
[
({"operator": "between", "value": [1]}, "min, max"),
({"operator": "between", "value": [1, 2, 3]}, "min, max"),
({"operator": "between", "value": [5, 1]}, "exceed"),
({"operator": "between", "value": [1, "2"]}, "min, max"),
({"operator": "between", "value": [True, 2]}, "min, max"),

View File

@ -130,7 +130,8 @@ def test_same_key_replays_without_calling_ai_and_a_changed_body_is_422(
changed = {**request, "metadata": {"other": 1}}
with TestClient(score_env["app_for"](_ai(calls))) as client:
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)
assert first.status_code == again.status_code == 200
assert again.json() == first.json()

View File

@ -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)