fix: drop temperature from AI requests and make the trial scenario editable
- gpt-6-luna on AIHub rejects temperature=0 (HTTP 400), which made every call without reasoning_effort fall back; no longer send temperature. - Fixtures page edits the scenario as JSON instead of a fixed preview; backend validation errors are shown. - AI profile form keeps submitted values after a trial run. - Record the live AIHub check in the spec and README.
This commit is contained in:
parent
6577bb74be
commit
5c61227e6f
@ -66,7 +66,7 @@ AI_PLATFORM_OPENAI_BASE_URL=http://127.0.0.1:8317
|
|||||||
# AI_PLATFORM_OPENAI_API_KEY=... # chỉ cần nếu CLIProxy yêu cầu xác thực
|
# AI_PLATFORM_OPENAI_API_KEY=... # chỉ cần nếu CLIProxy yêu cầu xác thực
|
||||||
```
|
```
|
||||||
|
|
||||||
Chạy `make backend-dev` trên máy host: `127.0.0.1:8317` bên trong Docker không trỏ tới CLIProxy trên host. Trong Admin Web (Cấu hình AI), soạn một revision mới với `OpenAI-compatible`, URL `http://127.0.0.1:8317`, model `gemini-3.5-flash-lite`, hướng dẫn chấm và timeout, chạy thử rồi Kích hoạt. `AI_PLATFORM_OPENAI_API_KEY` chỉ được gửi tới `AI_PLATFORM_OPENAI_BASE_URL`; URL khác nhập trên Web không nhận key đó. Profile AIHub dùng khóa AI của client trong header `X-AI-Key` (không lưu). Trang “Thử chấm điểm” gửi mẫu với trọng số quy tắc/AI 0.5/0.5 khi bật “Chấm thêm bằng AI”; với AIHub, đặt `LEAD_SCORING_FIXTURE_AI_KEY` trên server Web, nếu không lần chấm dự phòng bằng điểm quy tắc. Chế độ này có thể phát sinh chi phí.
|
Chạy `make backend-dev` trên máy host: `127.0.0.1:8317` bên trong Docker không trỏ tới CLIProxy trên host. Trong Admin Web (Cấu hình AI), soạn một revision mới với `OpenAI-compatible`, URL `http://127.0.0.1:8317`, model `gemini-3.5-flash-lite`, hướng dẫn chấm và timeout, chạy thử rồi Kích hoạt. `AI_PLATFORM_OPENAI_API_KEY` chỉ được gửi tới `AI_PLATFORM_OPENAI_BASE_URL`; URL khác nhập trên Web không nhận key đó. Profile AIHub dùng khóa AI của client trong header `X-AI-Key` (không lưu). Trang “Thử chấm điểm” cho sửa kịch bản mẫu (JSON: lead, hội thoại, quy tắc, metadata) rồi gửi; bật “Chấm thêm bằng AI” thì trọng số quy tắc/AI là 0.5/0.5 (ghi đè trọng số trong JSON); với AIHub, đặt `LEAD_SCORING_FIXTURE_AI_KEY` trên server Web, nếu không lần chấm dự phòng bằng điểm quy tắc. Chế độ này có thể phát sinh chi phí.
|
||||||
|
|
||||||
Với endpoint OpenAI-compatible chạy ở production, URL phải dùng HTTPS. Key được cấu hình trên server chỉ gửi tới URL được gắn với key đó; thay URL trên Web không tự chuyển key. Không đặt key vào Web hoặc file versioned. CLIProxy local chỉ xác minh được đường gọi thử; chưa chứng minh các điều kiện phát hành production.
|
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.
|
||||||
|
|
||||||
|
|||||||
@ -15,8 +15,8 @@ describe('server-only fixture transport', () => {
|
|||||||
it('sends the canonical fixture with a fresh idempotency key and keys only in headers', async () => {
|
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, true);
|
await submitScoringFixture(event, scoringFixture, true);
|
||||||
await submitScoringFixture(event, false);
|
await submitScoringFixture(event, scoringFixture, false);
|
||||||
|
|
||||||
const [[url, hybrid], [, rulesOnly]] = fetch.mock.calls;
|
const [[url, hybrid], [, rulesOnly]] = fetch.mock.calls;
|
||||||
expect(url).toBe('http://backend:8000/v1/lead-scoring/score');
|
expect(url).toBe('http://backend:8000/v1/lead-scoring/score');
|
||||||
@ -37,7 +37,7 @@ describe('server-only fixture transport', () => {
|
|||||||
try {
|
try {
|
||||||
env.LEAD_SCORING_FIXTURE_APP_API_KEY = ' ';
|
env.LEAD_SCORING_FIXTURE_APP_API_KEY = ' ';
|
||||||
await expect(
|
await expect(
|
||||||
submitScoringFixture({ fetch } as unknown as RequestEvent),
|
submitScoringFixture({ fetch } as unknown as RequestEvent, scoringFixture),
|
||||||
).rejects.toBeInstanceOf(FixtureRunnerNotConfigured);
|
).rejects.toBeInstanceOf(FixtureRunnerNotConfigured);
|
||||||
expect(fetch).not.toHaveBeenCalled();
|
expect(fetch).not.toHaveBeenCalled();
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@ -12,7 +12,11 @@ export function fixtureRunnerConfigured(): boolean {
|
|||||||
return Boolean(env.LEAD_SCORING_FIXTURE_APP_API_KEY?.trim());
|
return Boolean(env.LEAD_SCORING_FIXTURE_APP_API_KEY?.trim());
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function submitScoringFixture(event: RequestEvent, useAI = false): Promise<Response> {
|
export async function submitScoringFixture(
|
||||||
|
event: RequestEvent,
|
||||||
|
scenario: RunRequest,
|
||||||
|
useAI = false,
|
||||||
|
): 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();
|
||||||
if (!appKey) throw new FixtureRunnerNotConfigured();
|
if (!appKey) throw new FixtureRunnerNotConfigured();
|
||||||
@ -21,9 +25,9 @@ export async function submitScoringFixture(event: RequestEvent, useAI = false):
|
|||||||
}
|
}
|
||||||
|
|
||||||
const request: RunRequest = {
|
const request: RunRequest = {
|
||||||
...scoringFixture,
|
...scenario,
|
||||||
scoring_config: {
|
scoring_config: {
|
||||||
...scoringFixture.scoring_config,
|
...scenario.scoring_config,
|
||||||
rule_weight: useAI ? 0.5 : 1,
|
rule_weight: useAI ? 0.5 : 1,
|
||||||
ai_weight: useAI ? 0.5 : 0,
|
ai_weight: useAI ? 0.5 : 0,
|
||||||
},
|
},
|
||||||
|
|||||||
@ -96,6 +96,10 @@ export const actions: Actions = {
|
|||||||
if (response.status === 422) return fail(422, { message: rejected });
|
if (response.status === 422) return fail(422, { message: rejected });
|
||||||
if (!response.ok) return fail(response.status, { message: 'Chạy thử không thành công.' });
|
if (!response.ok) return fail(response.status, { message: 'Chạy thử không thành công.' });
|
||||||
const trial = (await response.json()) as components['schemas']['ScoreResponse'];
|
const trial = (await response.json()) as components['schemas']['ScoreResponse'];
|
||||||
return { message: 'Đã chạy thử trên dữ liệu mẫu, không lưu lần chấm nào.', trial };
|
return {
|
||||||
|
message: 'Đã chạy thử trên dữ liệu mẫu, không lưu lần chấm nào.',
|
||||||
|
trial,
|
||||||
|
values: profile,
|
||||||
|
};
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@ -8,8 +8,8 @@
|
|||||||
import { Textarea } from '$lib/components/ui/textarea/index.js';
|
import { Textarea } from '$lib/components/ui/textarea/index.js';
|
||||||
|
|
||||||
let { data, form }: PageProps = $props();
|
let { data, form }: PageProps = $props();
|
||||||
// Start from the active revision (or the newest), so a change is a small edit.
|
// Start from the trial's values (kept after Chạy thử), else the active or newest revision.
|
||||||
const base = $derived(data.profiles.find((p) => p.active) ?? data.profiles[0]);
|
const base = $derived(form?.values ?? data.profiles.find((p) => p.active) ?? data.profiles[0]);
|
||||||
const field =
|
const field =
|
||||||
'h-9 w-full rounded-md border border-input bg-background px-3 text-sm focus-visible:outline-2 focus-visible:outline-ring';
|
'h-9 w-full rounded-md border border-input bg-background px-3 text-sm focus-visible:outline-2 focus-visible:outline-ring';
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -10,12 +10,26 @@ import type { components } from '$lib/generated/api';
|
|||||||
import type { Actions, PageServerLoad } from './$types';
|
import type { Actions, PageServerLoad } from './$types';
|
||||||
|
|
||||||
type ScoreResponse = components['schemas']['ScoreResponse'];
|
type ScoreResponse = components['schemas']['ScoreResponse'];
|
||||||
|
type RunRequest = components['schemas']['RunRequest'];
|
||||||
|
|
||||||
export const load: PageServerLoad = async () => ({
|
export const load: PageServerLoad = async () => ({
|
||||||
fixtureRunnerConfigured: fixtureRunnerConfigured(),
|
fixtureRunnerConfigured: fixtureRunnerConfigured(),
|
||||||
fixture: scoringFixture,
|
payload: JSON.stringify(scoringFixture, null, 2),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Show the first validation message so the scenario can be fixed; the backend never echoes secrets.
|
||||||
|
async function validationMessage(response: Response): Promise<string> {
|
||||||
|
try {
|
||||||
|
const { detail } = (await response.json()) as {
|
||||||
|
detail?: { loc?: unknown[]; msg?: string }[];
|
||||||
|
};
|
||||||
|
const first = detail?.[0];
|
||||||
|
return first?.msg ? `${(first.loc ?? []).slice(1).join('.')}: ${first.msg}` : '';
|
||||||
|
} catch {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export const actions: Actions = {
|
export const actions: Actions = {
|
||||||
default: async (event) => {
|
default: async (event) => {
|
||||||
event.setHeaders({ 'cache-control': 'no-store' });
|
event.setHeaders({ 'cache-control': 'no-store' });
|
||||||
@ -33,26 +47,44 @@ export const actions: Actions = {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const useAI = (await event.request.formData()).get('use_ai') === 'true';
|
const form = await event.request.formData();
|
||||||
|
const useAI = form.get('use_ai') === 'true';
|
||||||
|
const payload = String(form.get('payload') ?? '');
|
||||||
|
let scenario: RunRequest;
|
||||||
|
try {
|
||||||
|
scenario = JSON.parse(payload) as RunRequest;
|
||||||
|
if (typeof scenario !== 'object' || scenario === null || Array.isArray(scenario)) {
|
||||||
|
throw new Error('not an object');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return fail(400, {
|
||||||
|
message: 'Kịch bản phải là một JSON object hợp lệ.',
|
||||||
|
useAI,
|
||||||
|
payload,
|
||||||
|
});
|
||||||
|
}
|
||||||
let response: Response;
|
let response: Response;
|
||||||
try {
|
try {
|
||||||
response = await submitScoringFixture(event, useAI);
|
response = await submitScoringFixture(event, scenario, useAI);
|
||||||
} 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,
|
useAI,
|
||||||
|
payload,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return fail(502, { message: 'Không gửi được dữ liệu mẫu.', useAI });
|
return fail(502, { message: 'Không gửi được dữ liệu mẫu.', useAI, payload });
|
||||||
}
|
}
|
||||||
if (response.status === 401) {
|
if (response.status === 401) {
|
||||||
return fail(502, { message: 'Mã truy cập của mẫu không còn hợp lệ.', useAI });
|
return fail(502, { message: 'Mã truy cập của mẫu không còn hợp lệ.', useAI, payload });
|
||||||
}
|
}
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
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}).`,
|
message: `Backend từ chối dữ liệu mẫu (HTTP ${response.status})${reason ? `: ${reason}` : ''}.`,
|
||||||
useAI,
|
useAI,
|
||||||
|
payload,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -61,6 +93,7 @@ export const actions: Actions = {
|
|||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
useAI,
|
useAI,
|
||||||
|
payload,
|
||||||
run_id: run.run_id,
|
run_id: run.run_id,
|
||||||
result_mode: run.result_mode,
|
result_mode: run.result_mode,
|
||||||
error_code: run.error_code,
|
error_code: run.error_code,
|
||||||
@ -69,7 +102,11 @@ export const actions: Actions = {
|
|||||||
: 'Đã 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, { message: 'AI Platform trả về phản hồi không hợp lệ.', useAI });
|
return fail(502, {
|
||||||
|
message: 'AI Platform trả về phản hồi không hợp lệ.',
|
||||||
|
useAI,
|
||||||
|
payload,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@ -3,7 +3,8 @@
|
|||||||
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 * as Card from '$lib/components/ui/card/index.js';
|
||||||
|
|
||||||
import FixturePreview from './FixturePreview.svelte';
|
import { Label } from '$lib/components/ui/label/index.js';
|
||||||
|
import { Textarea } from '$lib/components/ui/textarea/index.js';
|
||||||
|
|
||||||
let { data, form }: PageProps = $props();
|
let { data, form }: PageProps = $props();
|
||||||
let useAI = $derived(form?.useAI ?? false);
|
let useAI = $derived(form?.useAI ?? false);
|
||||||
@ -14,7 +15,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 với dữ liệu mẫu</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">
|
||||||
Xem hội thoại và quy tắc trước khi gửi. Dữ liệu dưới đây hoàn toàn giả lập.
|
Sửa kịch bản mẫu (lead, hội thoại, quy tắc, metadata) rồi gửi. Dữ liệu mặc định hoàn toàn
|
||||||
|
giả lập; bấm tải lại trang để về mẫu gốc.
|
||||||
</p>
|
</p>
|
||||||
</header>
|
</header>
|
||||||
<Card.Root class="mb-5">
|
<Card.Root class="mb-5">
|
||||||
@ -64,13 +66,28 @@
|
|||||||
tắc. Thiếu cấu hình hoặc provider lỗi cũng dẫn đến fallback; xem kết quả ở trang
|
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.
|
chi tiết.
|
||||||
{:else}
|
{:else}
|
||||||
Không gọi model AI. Các quy tắc mẫu và dữ liệu khách hàng được giữ nguyên.
|
Không gọi model AI. Chỉ chạy các quy tắc trong kịch bản.
|
||||||
{/if}
|
{/if}
|
||||||
</p>
|
</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>
|
||||||
<Button type="submit" disabled={!data.fixtureRunnerConfigured} class="w-fit">
|
<Button type="submit" disabled={!data.fixtureRunnerConfigured} class="w-fit">
|
||||||
Gửi dữ liệu mẫu
|
Gửi dữ liệu mẫu
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
</Card.Content>
|
</Card.Content>
|
||||||
</Card.Root>
|
</Card.Root>
|
||||||
<FixturePreview fixture={data.fixture} />
|
|
||||||
|
|||||||
@ -1,88 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import type { components } from '$lib/generated/api';
|
|
||||||
import * as Card from '$lib/components/ui/card/index.js';
|
|
||||||
|
|
||||||
let { fixture }: { fixture: components['schemas']['RunRequest'] } = $props();
|
|
||||||
const senderLabels = { contact: 'Khách hàng', agent: 'Nhân viên', bot: 'Bot' };
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="grid items-start gap-5 lg:grid-cols-2">
|
|
||||||
<div class="grid min-w-0 gap-5">
|
|
||||||
<Card.Root>
|
|
||||||
<Card.Header><h2 class="text-base font-semibold">Thông tin mẫu</h2></Card.Header>
|
|
||||||
<Card.Content class="grid gap-4 text-sm">
|
|
||||||
<p>
|
|
||||||
Lead: <span class="font-mono">{fixture.snapshot.lead_profile.external_id}</span>
|
|
||||||
</p>
|
|
||||||
<dl class="grid grid-cols-[minmax(0,1fr)_minmax(0,1fr)] gap-x-4 gap-y-3">
|
|
||||||
{#each Object.entries(fixture.snapshot.lead_profile.custom_fields ?? {}) as [key, value] (key)}
|
|
||||||
<dt class="break-all font-mono text-xs text-muted-foreground">{key}</dt>
|
|
||||||
<dd class="break-words">{JSON.stringify(value)}</dd>
|
|
||||||
{/each}
|
|
||||||
</dl>
|
|
||||||
</Card.Content>
|
|
||||||
</Card.Root>
|
|
||||||
<Card.Root>
|
|
||||||
<Card.Header><h2 class="text-base font-semibold">Quy tắc mẫu</h2></Card.Header>
|
|
||||||
<Card.Content>
|
|
||||||
<ul class="divide-y divide-border">
|
|
||||||
{#each fixture.scoring_config.rules as rule (rule.id)}
|
|
||||||
<li class="grid min-w-0 gap-2 py-4 first:pt-0 last:pb-0 text-sm">
|
|
||||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
|
||||||
<strong class="break-all font-medium">{rule.id}</strong>
|
|
||||||
<span class="text-muted-foreground"
|
|
||||||
>{rule.points} điểm khi khớp</span
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
<code class="break-all text-xs">
|
|
||||||
{rule.field}
|
|
||||||
{rule.operator}
|
|
||||||
{JSON.stringify(rule.value)}
|
|
||||||
</code>
|
|
||||||
{#if rule.description}
|
|
||||||
<p class="text-muted-foreground">{rule.description}</p>
|
|
||||||
{/if}
|
|
||||||
</li>
|
|
||||||
{/each}
|
|
||||||
</ul>
|
|
||||||
</Card.Content>
|
|
||||||
</Card.Root>
|
|
||||||
</div>
|
|
||||||
<Card.Root class="min-w-0">
|
|
||||||
<Card.Header><h2 class="text-base font-semibold">Hội thoại mẫu</h2></Card.Header>
|
|
||||||
<Card.Content class="grid gap-5">
|
|
||||||
{#each fixture.snapshot.conversations ?? [] as conversation (conversation.conversation_id)}
|
|
||||||
<article class="min-w-0">
|
|
||||||
<h3 class="text-sm font-semibold">{conversation.channel ?? 'Hội thoại'}</h3>
|
|
||||||
<p class="mt-1 text-xs text-muted-foreground">
|
|
||||||
{conversation.messages?.length ?? 0} tin nhắn
|
|
||||||
</p>
|
|
||||||
<ol class="mt-4 grid gap-3" aria-label="Tin nhắn mẫu">
|
|
||||||
{#each conversation.messages ?? [] as message (message.message_id)}
|
|
||||||
<li class="min-w-0 rounded-lg bg-muted/50 p-3">
|
|
||||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
|
||||||
<strong class="text-sm"
|
|
||||||
>{senderLabels[message.sender_type]}</strong
|
|
||||||
>
|
|
||||||
<time
|
|
||||||
datetime={message.sent_at}
|
|
||||||
class="text-xs text-muted-foreground"
|
|
||||||
>
|
|
||||||
{new Date(message.sent_at).toLocaleTimeString('vi-VN', {
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit',
|
|
||||||
timeZone: 'Asia/Ho_Chi_Minh',
|
|
||||||
})}
|
|
||||||
</time>
|
|
||||||
</div>
|
|
||||||
<p class="mt-2 whitespace-pre-wrap break-words text-sm leading-6">
|
|
||||||
{message.content}
|
|
||||||
</p>
|
|
||||||
</li>
|
|
||||||
{/each}
|
|
||||||
</ol>
|
|
||||||
</article>
|
|
||||||
{/each}
|
|
||||||
</Card.Content>
|
|
||||||
</Card.Root>
|
|
||||||
</div>
|
|
||||||
@ -26,13 +26,15 @@ const runId = 'f4a5d7c8-f55f-47d2-b2ea-611a2b78e59b';
|
|||||||
|
|
||||||
type ActionEvent = Parameters<NonNullable<typeof actions.default>>[0];
|
type ActionEvent = Parameters<NonNullable<typeof actions.default>>[0];
|
||||||
|
|
||||||
function eventFor(useAI = false): { event: ActionEvent; setHeaders: ReturnType<typeof vi.fn> } {
|
function eventFor(
|
||||||
|
useAI = false,
|
||||||
|
payload = '{"metadata":{"edited":true}}',
|
||||||
|
): { 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');
|
if (useAI) data.set('use_ai', 'true');
|
||||||
// Other submitted values cannot replace the server-owned fixture or weights.
|
// Other submitted values cannot replace the server-owned fixture or weights.
|
||||||
data.set('ai_weight', '1');
|
data.set('payload', payload);
|
||||||
data.set('payload', '{"metadata":"tampered"}');
|
|
||||||
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 };
|
||||||
}
|
}
|
||||||
@ -44,10 +46,13 @@ beforeEach(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('fixture runner page', () => {
|
describe('fixture runner page', () => {
|
||||||
it('returns the canonical preview without credentials even when the runner is unconfigured', async () => {
|
it('returns the editable canonical scenario without credentials even when the runner is unconfigured', async () => {
|
||||||
configuredMock.mockReturnValue(false);
|
configuredMock.mockReturnValue(false);
|
||||||
const page = await load({} as Parameters<typeof load>[0]);
|
const page = await load({} as Parameters<typeof load>[0]);
|
||||||
expect(page).toEqual({ fixtureRunnerConfigured: false, fixture: scoringFixture });
|
expect(page).toEqual({
|
||||||
|
fixtureRunnerConfigured: false,
|
||||||
|
payload: JSON.stringify(scoringFixture, null, 2),
|
||||||
|
});
|
||||||
expect(JSON.stringify(page)).not.toMatch(/API_KEY|BACKEND_INTERNAL_URL|Bearer/);
|
expect(JSON.stringify(page)).not.toMatch(/API_KEY|BACKEND_INTERNAL_URL|Bearer/);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -72,7 +77,7 @@ describe('fixture runner page', () => {
|
|||||||
});
|
});
|
||||||
expect(setHeaders).toHaveBeenCalledWith({ 'cache-control': 'no-store' });
|
expect(setHeaders).toHaveBeenCalledWith({ 'cache-control': 'no-store' });
|
||||||
expect(backendMock).toHaveBeenCalledWith(event, '/v1/admin/me');
|
expect(backendMock).toHaveBeenCalledWith(event, '/v1/admin/me');
|
||||||
expect(submitMock).toHaveBeenCalledWith(event, useAI);
|
expect(submitMock).toHaveBeenCalledWith(event, { metadata: { edited: true } }, useAI);
|
||||||
expect(backendMock.mock.invocationCallOrder[0]).toBeLessThan(
|
expect(backendMock.mock.invocationCallOrder[0]).toBeLessThan(
|
||||||
submitMock.mock.invocationCallOrder[0],
|
submitMock.mock.invocationCallOrder[0],
|
||||||
);
|
);
|
||||||
@ -82,6 +87,16 @@ describe('fixture runner page', () => {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
it('rejects a scenario that is not a JSON object without submitting', async () => {
|
||||||
|
const action = actions.default;
|
||||||
|
if (!action) throw new Error('fixture action missing');
|
||||||
|
for (const bad of ['not json', '[1]', 'null']) {
|
||||||
|
const result = await action(eventFor(false, bad).event);
|
||||||
|
expect(result).toMatchObject({ status: 400 });
|
||||||
|
}
|
||||||
|
expect(submitMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it('rejects an expired admin session without submitting', async () => {
|
it('rejects an expired admin session without submitting', async () => {
|
||||||
backendMock.mockResolvedValueOnce(new Response('{}', { status: 401 }));
|
backendMock.mockResolvedValueOnce(new Response('{}', { status: 401 }));
|
||||||
const action = actions.default;
|
const action = actions.default;
|
||||||
|
|||||||
@ -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 fixtures, xem runs và thu hồi app access.
|
- AI Platform xác thực app bằng app key, chấm điểm, lưu lần chấm để replay và để Admin Web xem. Admin Web quản lý AI profile, chạy kịch bản thử (sửa được từ một mẫu JSON), xem runs và thu hồi app access.
|
||||||
|
|
||||||
## Endpoint
|
## Endpoint
|
||||||
|
|
||||||
@ -46,6 +46,8 @@ Một lần gọi AI trả JSON `{signals[], score, summary, suggested_action}`.
|
|||||||
|
|
||||||
## Nghiệm thu và chưa xác minh
|
## Nghiệm thu và chưa xác minh
|
||||||
|
|
||||||
|
Đã kiểm tra trực tiếp (2026-10-09): AIHub `openai/gpt-6-luna` với `reasoning_effort=low` hoặc mặc định cho kết quả `hybrid` trong khoảng 5 đến 8 giây; model từ chối `temperature=0` nên request không gửi `temperature`. AIHub tại URL thử không kiểm tra giá trị Bearer (khóa sai hoặc thiếu vẫn trả 200). Admin Web đã chạy thử qua trình duyệt (lưu, kích hoạt, chạy thử, kịch bản sửa được, trang runs).
|
||||||
|
|
||||||
Đã kiểm tra bằng test cục bộ: các chế độ kết quả, replay và xung đột idempotency, truy cập đồng thời, xác thực, hình dạng prompt và request tới provider qua transport giả, contract khớp code, migration khớp models.
|
Đã kiểm tra bằng test cục bộ: các chế độ kết quả, replay và xung đột idempotency, truy cập đồng thời, xác thực, hình dạng prompt và request tới provider qua transport giả, contract khớp code, migration khớp models.
|
||||||
|
|
||||||
Chưa xác minh: hành vi AI thật (AIHub, model, `reasoning_effort`, `temperature`, độ trễ, quota), tích hợp Omni/EMS, chất lượng điểm bằng dữ liệu có nhãn, và hạ tầng production (hosting, TLS, secret, backup, alerting, retention). Không tuyên bố production-ready từ kiểm tra cục bộ.
|
Chưa xác minh: các model khác ngoài `gpt-6-luna`, quota, tích hợp Omni/EMS, chất lượng điểm bằng dữ liệu có nhãn, và hạ tầng production (hosting, TLS, secret, backup, alerting, retention). Không tuyên bố production-ready từ kiểm tra cục bộ.
|
||||||
|
|||||||
@ -70,8 +70,7 @@ class ChatCompletionsGateway:
|
|||||||
}
|
}
|
||||||
if reasoning_effort:
|
if reasoning_effort:
|
||||||
body["reasoning_effort"] = reasoning_effort
|
body["reasoning_effort"] = reasoning_effort
|
||||||
else:
|
# No temperature: reasoning models (gpt-6-luna on AIHub) reject any non-default value.
|
||||||
body["temperature"] = 0 # reasoning models reject it, so only without effort
|
|
||||||
payload = await self._post(body, timeout)
|
payload = await self._post(body, timeout)
|
||||||
try:
|
try:
|
||||||
parsed = _Assessment.model_validate(payload)
|
parsed = _Assessment.model_validate(payload)
|
||||||
|
|||||||
@ -72,13 +72,13 @@ async def test_request_shape_and_trimmed_response():
|
|||||||
body = json.loads(seen[0].content)
|
body = json.loads(seen[0].content)
|
||||||
assert str(seen[0].url) == "https://ai.example/v1/chat/completions"
|
assert str(seen[0].url) == "https://ai.example/v1/chat/completions"
|
||||||
assert seen[0].headers["authorization"] == "Bearer caller-key"
|
assert seen[0].headers["authorization"] == "Bearer caller-key"
|
||||||
assert (body["temperature"], "reasoning_effort" in body) == (0, False)
|
assert ("temperature" in body, "reasoning_effort" in body) == (False, False)
|
||||||
assert body["response_format"] == {"type": "json_object"}
|
assert body["response_format"] == {"type": "json_object"}
|
||||||
assert (len(result.summary), len(result.signals[0].signal), result.score) == (300, 80, 30)
|
assert (len(result.summary), len(result.signals[0].signal), result.score) == (300, 80, 30)
|
||||||
|
|
||||||
_, seen = await _assess(_ok(), _profile(reasoning_effort="low"))
|
_, seen = await _assess(_ok(), _profile(reasoning_effort="low"))
|
||||||
body = json.loads(seen[0].content)
|
body = json.loads(seen[0].content)
|
||||||
assert (body["reasoning_effort"], "temperature" in body) == ("low", False)
|
assert body["reasoning_effort"] == "low"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user