feat(ai): add ADR-036 unified OCR architecture and frontend test coverage
- Add ADR-036 unified OCR architecture (typhoon-ocr via Ollama) - Extend AI execution profiles for OCR sandbox configuration - Add comprehensive frontend test coverage (components, hooks, services) - Add backend test coverage for document-numbering services - Update OCR sidecar with typhoon-ocr integration - Add AI policy service and execution profile management - Update AGENTS.md and architecture documentation
This commit is contained in:
@@ -3,16 +3,23 @@
|
||||
// - 2026-05-25: Created OcrSandboxPromptManager component for dynamic prompt editing, version control, and sandbox testing (ADR-029)
|
||||
// - 2026-05-25: Extracted inline strings to i18n keys via useTranslations() (Obs #1 fix)
|
||||
// - 2026-05-25: Refactored sandbox polling to useSandboxRun hook (Obs #2 fix)
|
||||
// - 2026-05-26: เพิ่มการตรวจสอบ versionsQuery.data แบบทนทานเพื่อป้องกัน Error N.find is not a function ในกรณีที่ API ส่งข้อมูลแบบ wrapped object มา
|
||||
// - 2026-05-26: เพิ่มการตรวจสอบ versionsQuery.data แบบทนทานเพื่อป้องกัน Error N.find is not a function
|
||||
// - 2026-05-29: เพิ่ม OCR Raw Text section ในผล sandbox
|
||||
// - 2026-05-29: ปรับปรุงการโหลด Active Prompt ให้ทนทานต่อ race conditions และรูปแบบประเภทข้อมูลที่ส่งมาจาก API (boolean, number, string)
|
||||
// - 2026-05-29: ปรับปรุงการโหลด Active Prompt ให้ทนทานต่อ race conditions และรูปแบบประเภทข้อมูล
|
||||
// - 2026-05-30: Refactor เป็น 2-step flow (Step 1: OCR-only → Step 2: AI Extraction) ตาม spec 231
|
||||
// - 2026-06-02: ปรับปรุงลำดับปุ่มแท็บเริ่มต้นให้เริ่มที่ OCR Sandbox และเปลี่ยน dropdown labels ของตัวเลือกโมเดล Typhoon OCR ให้แสดงหน่วยความจำ VRAM แม่นยำ (T012, T013, ADR-033)
|
||||
// - 2026-06-04: เปลี่ยน OCR Engine dropdown จาก hardcoded เป็น dynamic โดยดึงจาก getOcrEngines() API และ map engineType → SandboxOcrEngineType
|
||||
// - 2026-06-04: เพิ่ม UI sliders (temperature/topP/repeatPenalty) สำหรับ typhoon-np-dms-ocr engine; ส่งเป็น optional override ไปยัง sidecar
|
||||
// - 2026-06-02: ปรับปรุงลำดับปุ่มแท็บเริ่มต้นให้เริ่มที่ OCR Sandbox และเปลี่ยน dropdown labels ของ Typhoon OCR
|
||||
// - 2026-06-04: เปลี่ยน OCR Engine dropdown จาก hardcoded เป็น dynamic โดยดึงจาก getOcrEngines() API
|
||||
// - 2026-06-04: เพิ่ม UI sliders (temperature/topP/repeatPenalty) สำหรับ OCR engine
|
||||
// - 2026-06-13: ADR-036 — เปลี่ยน sandbox OCR engine key เป็น np-dms-ocr
|
||||
// - 2026-06-13: T030 — เพิ่ม Sandbox Parameter Panel สำหรับ tuning production profile draft
|
||||
// - 2026-06-13: T044-T045 — เพิ่มปุ่ม Apply to Production และแสดงผลแผงพารามิเตอร์ของระบบ Production แบบอ่านอย่างเดียว
|
||||
// - 2026-06-13: US4 — เพิ่ม project/contract selectors สำหรับ sandbox context parity
|
||||
// - 2026-06-13: US5 — เพิ่มลิงก์สลับไปยังหน้าจัดการ Prompt Version (Editor tab) จากส่วนเลือกเวอร์ชันใน Sandbox
|
||||
// - 2026-06-13: US9 — แก้ไข ESLint errors: ลบ parseInt และแก้ไข unsafe any type casting ของ projects/contracts
|
||||
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import React, { useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -34,10 +41,23 @@ import {
|
||||
} from 'lucide-react';
|
||||
import { useAiPrompts, useSandboxRun } from '@/hooks/use-ai-prompts';
|
||||
import { useTranslations } from '@/hooks/use-translations';
|
||||
import { useProjects, useContracts } from '@/hooks/use-master-data';
|
||||
import PromptVersionHistory from './PromptVersionHistory';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { AiPrompt } from '@/types/ai-prompts';
|
||||
import { adminAiService, OcrEngineResponse } from '@/lib/services/admin-ai.service';
|
||||
import { adminAiService, OcrEngineResponse, SandboxProfileParams } from '@/lib/services/admin-ai.service';
|
||||
|
||||
interface SandboxProjectOption {
|
||||
publicId: string;
|
||||
projectCode: string;
|
||||
projectName: string;
|
||||
}
|
||||
|
||||
interface SandboxContractOption {
|
||||
publicId: string;
|
||||
contractCode: string;
|
||||
contractName: string;
|
||||
}
|
||||
|
||||
const DEFAULT_OCR_TEMPLATE = `คุณคือเอนจิ้นสกัดข้อมูลอัจฉริยะ (Document Intelligence Engine)
|
||||
วิเคราะห์ข้อความ OCR ที่ได้รับจากเอกสารของโครงการ Laem Chabang Port Phase 3 และสกัดข้อมูลเมตาดาต้าให้ออกมาเป็น JSON object ที่ถูกต้องตามโครงสร้างที่กำหนด
|
||||
@@ -120,6 +140,110 @@ export default function OcrSandboxPromptManager() {
|
||||
queryFn: () => adminAiService.getOcrEngines(),
|
||||
staleTime: 60_000,
|
||||
});
|
||||
// --- Sandbox Parameter Panel state (T030, ADR-036) ---
|
||||
const [selectedModel, setSelectedModel] = useState<'np-dms-ai' | 'np-dms-ocr'>('np-dms-ai');
|
||||
const profileName = selectedModel === 'np-dms-ai' ? 'standard' : 'ocr-extract';
|
||||
const [sandboxParams, setSandboxParams] = useState<SandboxProfileParams | null>(null);
|
||||
const [sandboxParamsDraft, setSandboxParamsDraft] = useState<Partial<SandboxProfileParams>>({});
|
||||
const [isSavingParams, setIsSavingParams] = useState(false);
|
||||
const [isResettingParams, setIsResettingParams] = useState(false);
|
||||
const [showParamPanel, setShowParamPanel] = useState(false);
|
||||
|
||||
// --- US4 states ---
|
||||
const [selectedProjectPublicId, setSelectedProjectPublicId] = useState<string>('');
|
||||
const [selectedContractPublicId, setSelectedContractPublicId] = useState<string>('');
|
||||
const { data: projectsData } = useProjects();
|
||||
const projects = Array.isArray(projectsData) ? (projectsData as SandboxProjectOption[]) : [];
|
||||
const { data: contractsData } = useContracts(selectedProjectPublicId);
|
||||
const contracts = Array.isArray(contractsData) ? (contractsData as SandboxContractOption[]) : [];
|
||||
|
||||
const handleProjectChange = (projectId: string) => {
|
||||
setSelectedProjectPublicId(projectId);
|
||||
setSelectedContractPublicId('');
|
||||
};
|
||||
|
||||
// --- Phase 4 apply and production defaults states (T044, T045) ---
|
||||
const [prodParams, setProdParams] = useState<SandboxProfileParams | null>(null);
|
||||
const [isApplyingParams, setIsApplyingParams] = useState(false);
|
||||
|
||||
const fetchProdParams = useCallback(async () => {
|
||||
try {
|
||||
const params = await adminAiService.getProductionDefaults(profileName);
|
||||
setProdParams(params);
|
||||
} catch {
|
||||
// Ignored
|
||||
}
|
||||
}, [profileName]);
|
||||
|
||||
useEffect(() => {
|
||||
adminAiService.getSandboxProfile(profileName)
|
||||
.then((params) => {
|
||||
setSandboxParams(params);
|
||||
setSandboxParamsDraft({
|
||||
temperature: params.temperature,
|
||||
topP: params.topP,
|
||||
repeatPenalty: params.repeatPenalty,
|
||||
maxTokens: params.maxTokens,
|
||||
numCtx: params.numCtx,
|
||||
keepAliveSeconds: params.keepAliveSeconds,
|
||||
});
|
||||
})
|
||||
.catch(() => { /* ไม่ต้องแสดง error — อาจเป็น 403 หาก feature ยังไม่เปิด */ });
|
||||
|
||||
fetchProdParams();
|
||||
}, [profileName, fetchProdParams]);
|
||||
|
||||
const handleSaveParams = useCallback(async () => {
|
||||
setIsSavingParams(true);
|
||||
try {
|
||||
const key = `sandbox-params-${profileName}-${Date.now()}`;
|
||||
const updated = await adminAiService.saveSandboxProfile(profileName, sandboxParamsDraft, key);
|
||||
setSandboxParams(updated);
|
||||
toast.success('Sandbox parameters saved');
|
||||
} catch {
|
||||
toast.error('Failed to save sandbox parameters');
|
||||
} finally {
|
||||
setIsSavingParams(false);
|
||||
}
|
||||
}, [profileName, sandboxParamsDraft]);
|
||||
|
||||
const handleApplyParams = useCallback(async () => {
|
||||
if (!confirm(`Are you sure you want to apply sandbox draft parameters for ${profileName} to production? This will immediately affect live production jobs.`)) {
|
||||
return;
|
||||
}
|
||||
setIsApplyingParams(true);
|
||||
try {
|
||||
const idempotencyKey = `apply-params-${profileName}-${Date.now()}`;
|
||||
await adminAiService.applyProfile(profileName, idempotencyKey);
|
||||
toast.success('Parameters successfully applied to production!');
|
||||
await fetchProdParams();
|
||||
} catch {
|
||||
toast.error('Failed to apply parameters to production');
|
||||
} finally {
|
||||
setIsApplyingParams(false);
|
||||
}
|
||||
}, [profileName, fetchProdParams]);
|
||||
|
||||
const handleResetParams = useCallback(async () => {
|
||||
setIsResettingParams(true);
|
||||
try {
|
||||
const reset = await adminAiService.resetSandboxProfile(profileName);
|
||||
setSandboxParams(reset);
|
||||
setSandboxParamsDraft({
|
||||
temperature: reset.temperature,
|
||||
topP: reset.topP,
|
||||
repeatPenalty: reset.repeatPenalty,
|
||||
maxTokens: reset.maxTokens,
|
||||
numCtx: reset.numCtx,
|
||||
keepAliveSeconds: reset.keepAliveSeconds,
|
||||
});
|
||||
toast.success('Sandbox parameters reset to production values');
|
||||
} catch {
|
||||
toast.error('Failed to reset sandbox parameters');
|
||||
} finally {
|
||||
setIsResettingParams(false);
|
||||
}
|
||||
}, [profileName]);
|
||||
const ocrEngineOptions = useMemo(() => {
|
||||
const base = [{ value: 'auto', label: 'Auto (Current Baseline)' }];
|
||||
if (!ocrEnginesData) return base;
|
||||
@@ -128,7 +252,7 @@ export default function OcrSandboxPromptManager() {
|
||||
e.engineType === 'tesseract'
|
||||
? 'tesseract'
|
||||
: e.engineType === 'typhoon_ocr'
|
||||
? 'typhoon-np-dms-ocr'
|
||||
? 'np-dms-ocr'
|
||||
: e.engineType;
|
||||
const vramLabel =
|
||||
e.vramRequirementMB > 0
|
||||
@@ -222,6 +346,10 @@ export default function OcrSandboxPromptManager() {
|
||||
// Step 1: OCR-only handler
|
||||
const handleStep1Ocr = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!selectedProjectPublicId) {
|
||||
toast.error('Please select a project first');
|
||||
return;
|
||||
}
|
||||
if (!ocrFile) {
|
||||
toast.error(t('ai.prompt.noFile'));
|
||||
return;
|
||||
@@ -229,7 +357,7 @@ export default function OcrSandboxPromptManager() {
|
||||
try {
|
||||
resetSandbox();
|
||||
setSandboxStep('ocr');
|
||||
const typhoonOptions = selectedOcrEngine === 'typhoon-np-dms-ocr'
|
||||
const typhoonOptions = selectedOcrEngine === 'np-dms-ocr'
|
||||
? { temperature: typhoonTemperature, topP: typhoonTopP, repeatPenalty: typhoonRepeatPenalty }
|
||||
: undefined;
|
||||
const { requestPublicId } = await adminAiService.submitSandboxOcr(
|
||||
@@ -270,6 +398,10 @@ export default function OcrSandboxPromptManager() {
|
||||
// Step 2: AI Extraction handler
|
||||
const handleStep2AiExtract = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!selectedProjectPublicId) {
|
||||
toast.error('Please select a project first');
|
||||
return;
|
||||
}
|
||||
if (!ocrResult) {
|
||||
toast.error('Please run Step 1 (OCR) first');
|
||||
return;
|
||||
@@ -282,7 +414,9 @@ export default function OcrSandboxPromptManager() {
|
||||
resetSandbox();
|
||||
const { requestPublicId } = await adminAiService.submitSandboxAiExtract(
|
||||
ocrResult.requestPublicId,
|
||||
selectedPromptVersion
|
||||
selectedPromptVersion,
|
||||
selectedProjectPublicId,
|
||||
selectedContractPublicId || undefined
|
||||
);
|
||||
toast.success('AI Extraction started');
|
||||
// เริ่ม polling ผ่าน useSandboxRun hook
|
||||
@@ -302,6 +436,8 @@ export default function OcrSandboxPromptManager() {
|
||||
setTyphoonTopP(0.1);
|
||||
setTyphoonRepeatPenalty(1.1);
|
||||
setOcrFile(null);
|
||||
setSelectedProjectPublicId('');
|
||||
setSelectedContractPublicId('');
|
||||
resetSandbox();
|
||||
};
|
||||
// แปล status key เป็นข้อความตาม locale ปัจจุบัน
|
||||
@@ -396,10 +532,140 @@ export default function OcrSandboxPromptManager() {
|
||||
: 'Step 2: Test AI prompt with OCR text'}
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Project and Contract Selectors (US4) */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-semibold text-foreground flex items-center gap-1">
|
||||
Project <span className="text-destructive">*</span>
|
||||
</label>
|
||||
<select
|
||||
value={selectedProjectPublicId}
|
||||
onChange={(e) => handleProjectChange(e.target.value)}
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-xs"
|
||||
disabled={sandboxState.isRunning}
|
||||
>
|
||||
<option value="">-- Select Project --</option>
|
||||
{projects.map((proj) => (
|
||||
<option key={proj.publicId} value={proj.publicId}>
|
||||
{proj.projectCode} - {proj.projectName}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-semibold text-foreground">
|
||||
Contract
|
||||
</label>
|
||||
<select
|
||||
value={selectedContractPublicId}
|
||||
onChange={(e) => setSelectedContractPublicId(e.target.value)}
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-xs"
|
||||
disabled={sandboxState.isRunning || !selectedProjectPublicId}
|
||||
>
|
||||
<option value="">-- Select Contract (Optional) --</option>
|
||||
{contracts.map((ctr) => (
|
||||
<option key={ctr.publicId} value={ctr.publicId}>
|
||||
{ctr.contractCode} - {ctr.contractName}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border/10 my-4" />
|
||||
|
||||
{sandboxStep === 'ocr' ? (
|
||||
<form onSubmit={handleStep1Ocr} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-4">
|
||||
{/* --- Sandbox Parameter Panel (T030) --- */}
|
||||
{sandboxParams && (
|
||||
<div className="rounded-md border border-border/30 bg-muted/10">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowParamPanel((v) => !v)}
|
||||
className="flex w-full items-center justify-between px-3 py-2 text-xs font-medium text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<span>LLM Sandbox Parameters (production profile draft)</span>
|
||||
<span className="text-[10px]">{showParamPanel ? '\u25b2' : '\u25bc'}</span>
|
||||
</button>
|
||||
{showParamPanel && (
|
||||
<div className="px-3 pb-3 space-y-3 border-t border-border/20 pt-3">
|
||||
<div className="space-y-1">
|
||||
<label className="text-[10px] font-medium text-muted-foreground">Model Profile (T050)</label>
|
||||
<select
|
||||
value={selectedModel}
|
||||
onChange={(e) => setSelectedModel(e.target.value as 'np-dms-ai' | 'np-dms-ocr')}
|
||||
className="w-full rounded border border-input bg-background px-2.5 py-1 text-xs"
|
||||
>
|
||||
<option value="np-dms-ai">LLM Engine (np-dms-ai / standard)</option>
|
||||
<option value="np-dms-ocr">OCR Engine (np-dms-ocr / ocr-extract)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 text-xs">
|
||||
<div className="space-y-1">
|
||||
<div className="flex justify-between"><label>Temperature</label><span className="font-mono text-muted-foreground">{((sandboxParamsDraft.temperature ?? sandboxParams?.temperature) ?? 0).toFixed(2)}</span></div>
|
||||
<input type="range" min={0} max={1} step={0.01} value={(sandboxParamsDraft.temperature ?? sandboxParams?.temperature) ?? 0} onChange={(e) => setSandboxParamsDraft((p) => ({ ...p, temperature: parseFloat(e.target.value) }))} className="w-full h-1.5 accent-primary" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="flex justify-between"><label>Top-P</label><span className="font-mono text-muted-foreground">{((sandboxParamsDraft.topP ?? sandboxParams?.topP) ?? 0).toFixed(2)}</span></div>
|
||||
<input type="range" min={0} max={1} step={0.01} value={(sandboxParamsDraft.topP ?? sandboxParams?.topP) ?? 0} onChange={(e) => setSandboxParamsDraft((p) => ({ ...p, topP: parseFloat(e.target.value) }))} className="w-full h-1.5 accent-primary" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="flex justify-between"><label>Repeat Penalty</label><span className="font-mono text-muted-foreground">{((sandboxParamsDraft.repeatPenalty ?? sandboxParams?.repeatPenalty) ?? 1).toFixed(2)}</span></div>
|
||||
<input type="range" min={1} max={2} step={0.01} value={(sandboxParamsDraft.repeatPenalty ?? sandboxParams?.repeatPenalty) ?? 1} onChange={(e) => setSandboxParamsDraft((p) => ({ ...p, repeatPenalty: parseFloat(e.target.value) }))} className="w-full h-1.5 accent-primary" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="flex justify-between"><label>Keep-Alive (s)</label><span className="font-mono text-muted-foreground">{(sandboxParamsDraft.keepAliveSeconds ?? sandboxParams?.keepAliveSeconds) ?? 0}</span></div>
|
||||
<input type="range" min={0} max={3600} step={60} value={(sandboxParamsDraft.keepAliveSeconds ?? sandboxParams?.keepAliveSeconds) ?? 0} onChange={(e) => setSandboxParamsDraft((p) => ({ ...p, keepAliveSeconds: Number(e.target.value) }))} className="w-full h-1.5 accent-primary" />
|
||||
</div>
|
||||
{selectedModel === 'np-dms-ai' && (
|
||||
<>
|
||||
<div className="space-y-1">
|
||||
<div className="flex justify-between"><label>Max Tokens</label><span className="font-mono text-muted-foreground">{(sandboxParamsDraft.maxTokens ?? sandboxParams?.maxTokens) ?? 4096}</span></div>
|
||||
<input type="range" min={256} max={16384} step={256} value={(sandboxParamsDraft.maxTokens ?? sandboxParams?.maxTokens) ?? 4096} onChange={(e) => setSandboxParamsDraft((p) => ({ ...p, maxTokens: Number(e.target.value) }))} className="w-full h-1.5 accent-primary" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="flex justify-between"><label>Ctx Size</label><span className="font-mono text-muted-foreground">{(sandboxParamsDraft.numCtx ?? sandboxParams?.numCtx) ?? 8192}</span></div>
|
||||
<input type="range" min={1024} max={32768} step={1024} value={(sandboxParamsDraft.numCtx ?? sandboxParams?.numCtx) ?? 8192} onChange={(e) => setSandboxParamsDraft((p) => ({ ...p, numCtx: Number(e.target.value) }))} className="w-full h-1.5 accent-primary" />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Production Defaults Read-Only Panel (T045) */}
|
||||
{prodParams && (
|
||||
<div className="rounded border border-emerald-500/20 bg-emerald-500/5 p-2.5 text-xs space-y-1">
|
||||
<p className="font-semibold text-emerald-600 dark:text-emerald-400">Current Production Parameters (Read-only)</p>
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-1 text-[11px] font-mono text-muted-foreground font-semibold">
|
||||
<div>Model: {prodParams.canonicalModel}</div>
|
||||
<div>Temperature: {prodParams.temperature.toFixed(2)}</div>
|
||||
<div>Top-P: {prodParams.topP.toFixed(2)}</div>
|
||||
<div>Repeat Penalty: {prodParams.repeatPenalty.toFixed(2)}</div>
|
||||
<div>Keep-Alive: {prodParams.keepAliveSeconds}s</div>
|
||||
{prodParams.maxTokens !== null && <div>Max Tokens: {prodParams.maxTokens}</div>}
|
||||
{prodParams.numCtx !== null && <div>Ctx Size: {prodParams.numCtx}</div>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-2 pt-1 flex-wrap">
|
||||
<Button type="button" variant="outline" size="sm" disabled={isResettingParams} onClick={handleResetParams} className="text-xs h-7 px-3">
|
||||
{isResettingParams ? <Loader2 className="h-3 w-3 animate-spin" /> : 'Reset to Production'}
|
||||
</Button>
|
||||
<Button type="button" variant="secondary" size="sm" disabled={isSavingParams} onClick={handleSaveParams} className="text-xs h-7 px-3">
|
||||
{isSavingParams ? <Loader2 className="h-3 w-3 animate-spin" /> : 'Save Draft'}
|
||||
</Button>
|
||||
<Button type="button" variant="destructive" size="sm" disabled={isApplyingParams} onClick={handleApplyParams} className="text-xs h-7 px-3 flex items-center gap-1">
|
||||
{isApplyingParams ? <Loader2 className="h-3 w-3 animate-spin" /> : <Save className="h-3 w-3" />}
|
||||
Apply to Production
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-medium">OCR Engine</label>
|
||||
<select
|
||||
@@ -407,14 +673,12 @@ export default function OcrSandboxPromptManager() {
|
||||
onChange={(e) => setSelectedOcrEngine(e.target.value)}
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-xs"
|
||||
>
|
||||
{ocrEngineOptions.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
{ocrEngineOptions.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
{selectedOcrEngine === 'typhoon-np-dms-ocr' && (
|
||||
{selectedOcrEngine === 'np-dms-ocr' && (
|
||||
<div className="space-y-3 rounded-md border border-dashed border-amber-500/30 bg-amber-500/5 p-3">
|
||||
<p className="text-xs font-medium text-amber-600 dark:text-amber-400">Typhoon OCR Options <span className="font-normal text-muted-foreground">(override Modelfile defaults)</span></p>
|
||||
<div className="space-y-1">
|
||||
@@ -516,7 +780,7 @@ export default function OcrSandboxPromptManager() {
|
||||
<div className="flex justify-end gap-3 pt-2">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={sandboxState.isRunning || !ocrFile}
|
||||
disabled={sandboxState.isRunning || !ocrFile || !selectedProjectPublicId}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
{sandboxState.isRunning ? (
|
||||
@@ -537,7 +801,16 @@ export default function OcrSandboxPromptManager() {
|
||||
<form onSubmit={handleStep2AiExtract} className="space-y-4">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-medium">Prompt Version:</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-xs font-medium">Prompt Version:</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTab('editor')}
|
||||
className="text-[10px] text-primary hover:underline font-semibold"
|
||||
>
|
||||
(Manage/Edit Prompts)
|
||||
</button>
|
||||
</div>
|
||||
<select
|
||||
value={selectedPromptVersion ?? (activePrompt?.versionNumber ?? '')}
|
||||
onChange={(e) => setSelectedPromptVersion(e.target.value ? Number(e.target.value) : undefined)}
|
||||
@@ -562,7 +835,7 @@ export default function OcrSandboxPromptManager() {
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={sandboxState.isRunning || !activePrompt}
|
||||
disabled={sandboxState.isRunning || !activePrompt || !selectedProjectPublicId}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
{sandboxState.isRunning ? (
|
||||
@@ -591,7 +864,7 @@ export default function OcrSandboxPromptManager() {
|
||||
OCR Raw Text (Step 1 Result)
|
||||
</CardTitle>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{ocrResult.engineUsed === 'typhoon-np-dms-ocr'
|
||||
{ocrResult.engineUsed === 'np-dms-ocr'
|
||||
? 'np-dms-ocr'
|
||||
: ocrResult.ocrUsed
|
||||
? 'Tesseract'
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
// File: frontend/components/admin/ai/__tests__/ocr-engine-selector.test.tsx
|
||||
// Change Log
|
||||
// - 2026-06-13: Add coverage for OCR engine loading, display, and selection flows.
|
||||
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { toast } from 'sonner';
|
||||
import OcrEngineSelector from '../OcrEngineSelector';
|
||||
import { adminAiService, type OcrEngineResponse } from '@/lib/services/admin-ai.service';
|
||||
|
||||
vi.mock('@/lib/services/admin-ai.service', () => ({
|
||||
adminAiService: {
|
||||
getOcrEngines: vi.fn(),
|
||||
selectOcrEngine: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const engines: OcrEngineResponse[] = [
|
||||
{
|
||||
engineId: 'tesseract',
|
||||
engineName: 'Tesseract OCR',
|
||||
engineType: 'tesseract',
|
||||
isCurrentActive: true,
|
||||
concurrentLimit: 4,
|
||||
vramRequirementMB: 0,
|
||||
},
|
||||
{
|
||||
engineId: 'typhoon',
|
||||
engineName: 'Typhoon OCR',
|
||||
engineType: 'typhoon_ocr',
|
||||
isCurrentActive: false,
|
||||
concurrentLimit: 1,
|
||||
vramRequirementMB: 6144,
|
||||
},
|
||||
];
|
||||
|
||||
describe('OcrEngineSelector', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(adminAiService.getOcrEngines).mockResolvedValue(engines);
|
||||
vi.mocked(adminAiService.selectOcrEngine).mockResolvedValue({ success: true });
|
||||
});
|
||||
|
||||
it('renders OCR engine data from admin service', async () => {
|
||||
render(<OcrEngineSelector />);
|
||||
expect(await screen.findByText('Tesseract OCR')).toBeInTheDocument();
|
||||
expect(screen.getByText('Typhoon OCR')).toBeInTheDocument();
|
||||
expect(screen.getByText('AI Powered')).toBeInTheDocument();
|
||||
expect(adminAiService.getOcrEngines).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('selects a non-active OCR engine and refreshes list', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<OcrEngineSelector />);
|
||||
await user.click(await screen.findByRole('button', { name: 'สลับใช้งาน' }));
|
||||
await waitFor(() => {
|
||||
expect(adminAiService.selectOcrEngine).toHaveBeenCalledWith('typhoon');
|
||||
});
|
||||
expect(toast.success).toHaveBeenCalledWith('เปลี่ยนเอนจิน OCR หลักเป็น Typhoon OCR สำเร็จ');
|
||||
expect(adminAiService.getOcrEngines).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('shows an error toast when loading engines fails', async () => {
|
||||
vi.mocked(adminAiService.getOcrEngines).mockRejectedValue(new Error('API error'));
|
||||
render(<OcrEngineSelector />);
|
||||
await waitFor(() => {
|
||||
expect(toast.error).toHaveBeenCalledWith('ไม่สามารถดึงข้อมูล OCR Engines ได้');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,206 @@
|
||||
// File: frontend/components/admin/ai/__tests__/ocr-sandbox-prompt-manager.test.tsx
|
||||
// Change Log:
|
||||
// - 2026-06-14: Add smoke coverage for OcrSandboxPromptManager sandbox/editor paths
|
||||
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import OcrSandboxPromptManager from '../OcrSandboxPromptManager';
|
||||
import { AiPrompt } from '@/types/ai-prompts';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
toastSuccess: vi.fn(),
|
||||
toastError: vi.fn(),
|
||||
refetchVersions: vi.fn(),
|
||||
createVersion: vi.fn(),
|
||||
activateVersion: vi.fn(),
|
||||
deleteVersion: vi.fn(),
|
||||
updateNote: vi.fn(),
|
||||
resetSandbox: vi.fn(),
|
||||
startPolling: vi.fn(),
|
||||
}));
|
||||
|
||||
const prompts: AiPrompt[] = [
|
||||
{
|
||||
promptType: 'ocr_extraction',
|
||||
versionNumber: 2,
|
||||
template: 'Extract {{ocr_text}} with {{master_data_context}}',
|
||||
isActive: true,
|
||||
testResultJson: null,
|
||||
manualNote: null,
|
||||
lastTestedAt: null,
|
||||
activatedAt: '2026-06-01T00:00:00Z',
|
||||
createdAt: '2026-06-01T00:00:00Z',
|
||||
},
|
||||
];
|
||||
|
||||
vi.mock('sonner', () => ({
|
||||
toast: {
|
||||
success: mocks.toastSuccess,
|
||||
error: mocks.toastError,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/hooks/use-translations', () => ({
|
||||
useTranslations: () => (key: string, params?: Record<string, string>) => {
|
||||
if (key === 'ai.prompt.activeLabel') return `Active: v${params?.version}`;
|
||||
if (key === 'ai.prompt.charCount') return `${params?.count} / 4000 ตัวอักษร`;
|
||||
return key;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/hooks/use-ai-prompts', () => ({
|
||||
useAiPrompts: () => ({
|
||||
versionsQuery: {
|
||||
data: prompts,
|
||||
isSuccess: true,
|
||||
isLoading: false,
|
||||
refetch: mocks.refetchVersions,
|
||||
},
|
||||
createMutation: { mutateAsync: mocks.createVersion, isPending: false },
|
||||
activateMutation: { mutateAsync: mocks.activateVersion, isPending: false },
|
||||
deleteMutation: { mutateAsync: mocks.deleteVersion, isPending: false },
|
||||
updateNoteMutation: { mutateAsync: mocks.updateNote, isPending: false },
|
||||
}),
|
||||
useSandboxRun: () => ({
|
||||
state: {
|
||||
isRunning: false,
|
||||
progress: 0,
|
||||
statusText: '',
|
||||
result: null,
|
||||
},
|
||||
jobId: null,
|
||||
reset: mocks.resetSandbox,
|
||||
startPolling: mocks.startPolling,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/hooks/use-master-data', () => ({
|
||||
useProjects: () => ({
|
||||
data: [
|
||||
{
|
||||
publicId: '019505a1-7c3e-7000-8000-abc123def201',
|
||||
projectCode: 'LCB3',
|
||||
projectName: 'Laem Chabang Phase 3',
|
||||
},
|
||||
],
|
||||
}),
|
||||
useContracts: () => ({
|
||||
data: [
|
||||
{
|
||||
publicId: '019505a1-7c3e-7000-8000-abc123def301',
|
||||
contractCode: 'C01',
|
||||
contractName: 'Marine Works',
|
||||
},
|
||||
],
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/services/admin-ai.service', () => ({
|
||||
adminAiService: {
|
||||
getOcrEngines: vi.fn().mockResolvedValue([
|
||||
{
|
||||
engineType: 'typhoon_ocr',
|
||||
engineName: 'np-dms-ocr',
|
||||
vramRequirementMB: 4096,
|
||||
isCurrentActive: true,
|
||||
},
|
||||
]),
|
||||
getSandboxProfile: vi.fn().mockResolvedValue({
|
||||
temperature: 0.1,
|
||||
topP: 0.2,
|
||||
repeatPenalty: 1.1,
|
||||
maxTokens: 1024,
|
||||
numCtx: 4096,
|
||||
keepAliveSeconds: 0,
|
||||
}),
|
||||
getProductionDefaults: vi.fn().mockResolvedValue({
|
||||
temperature: 0.2,
|
||||
topP: 0.3,
|
||||
repeatPenalty: 1.2,
|
||||
maxTokens: 2048,
|
||||
numCtx: 8192,
|
||||
keepAliveSeconds: 60,
|
||||
}),
|
||||
saveSandboxProfile: vi.fn().mockResolvedValue({
|
||||
temperature: 0.2,
|
||||
topP: 0.3,
|
||||
repeatPenalty: 1.2,
|
||||
maxTokens: 2048,
|
||||
numCtx: 8192,
|
||||
keepAliveSeconds: 60,
|
||||
}),
|
||||
resetSandboxProfile: vi.fn().mockResolvedValue({
|
||||
temperature: 0.1,
|
||||
topP: 0.2,
|
||||
repeatPenalty: 1.1,
|
||||
maxTokens: 1024,
|
||||
numCtx: 4096,
|
||||
keepAliveSeconds: 0,
|
||||
}),
|
||||
applyProfile: vi.fn().mockResolvedValue({ ok: true }),
|
||||
submitSandboxOcr: vi.fn().mockResolvedValue({
|
||||
requestPublicId: '019505a1-7c3e-7000-8000-abc123def401',
|
||||
}),
|
||||
getSandboxJobStatus: vi.fn().mockResolvedValue({
|
||||
status: 'pending',
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../PromptVersionHistory', () => ({
|
||||
default: ({ versions, onLoadTemplate }: { versions: AiPrompt[]; onLoadTemplate: (version: AiPrompt) => void }) => (
|
||||
<div data-testid="prompt-version-history">
|
||||
<span>{versions.length} versions</span>
|
||||
<button type="button" onClick={() => onLoadTemplate(versions[0])}>
|
||||
Load version
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
const renderManager = () => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<OcrSandboxPromptManager />
|
||||
</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
|
||||
describe('OcrSandboxPromptManager', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.stubGlobal('confirm', vi.fn().mockReturnValue(true));
|
||||
});
|
||||
|
||||
it('ควร render sandbox tab พร้อม project, contract, engine และ history', async () => {
|
||||
renderManager();
|
||||
expect(screen.getByText('ai.prompt.tabSandbox')).toBeInTheDocument();
|
||||
expect(screen.getByText('Step 1: Run OCR Only')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('prompt-version-history')).toHaveTextContent('1 versions');
|
||||
await waitFor(() => expect(screen.getByText(/np-dms-ocr/)).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('ควรสลับไป editor และบันทึก prompt version ได้', async () => {
|
||||
mocks.createVersion.mockResolvedValueOnce(prompts[0]);
|
||||
renderManager();
|
||||
fireEvent.click(screen.getByText('ai.prompt.tabEditor'));
|
||||
expect(await screen.findByDisplayValue('Extract {{ocr_text}} with {{master_data_context}}')).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByText('ai.prompt.saveVersion'));
|
||||
await waitFor(() => expect(mocks.createVersion).toHaveBeenCalledWith('Extract {{ocr_text}} with {{master_data_context}}'));
|
||||
expect(mocks.toastSuccess).toHaveBeenCalledWith('ai.prompt.saveVersionSuccess');
|
||||
});
|
||||
|
||||
it('ควร load template จาก history เข้า editor', async () => {
|
||||
renderManager();
|
||||
fireEvent.click(screen.getByText('Load version'));
|
||||
expect(await screen.findByDisplayValue('Extract {{ocr_text}} with {{master_data_context}}')).toBeInTheDocument();
|
||||
expect(mocks.toastSuccess).toHaveBeenCalledWith('ai.prompt.loadSuccess');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
// File: frontend/components/admin/ai/__tests__/prompt-version-history.test.tsx
|
||||
// Change Log
|
||||
// - 2026-06-13: Add coverage for prompt version history rendering and actions.
|
||||
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import PromptVersionHistory from '../PromptVersionHistory';
|
||||
import type { AiPrompt } from '@/types/ai-prompts';
|
||||
|
||||
const versions: AiPrompt[] = [
|
||||
{
|
||||
publicId: '019505a1-7c3e-7000-8000-abc123defa01',
|
||||
promptType: 'metadata_extraction',
|
||||
versionNumber: 3,
|
||||
template: 'active prompt',
|
||||
isActive: true,
|
||||
createdAt: '2026-06-13T08:00:00.000Z',
|
||||
lastTestedAt: '2026-06-13T09:00:00.000Z',
|
||||
manualNote: 'ผ่านการทดสอบกับ RFA แล้ว',
|
||||
},
|
||||
{
|
||||
publicId: '019505a1-7c3e-7000-8000-abc123defa02',
|
||||
promptType: 'metadata_extraction',
|
||||
versionNumber: 2,
|
||||
template: 'draft prompt',
|
||||
isActive: false,
|
||||
createdAt: '2026-06-12T08:00:00.000Z',
|
||||
},
|
||||
];
|
||||
|
||||
describe('PromptVersionHistory', () => {
|
||||
it('renders loading and empty states', () => {
|
||||
const callbacks = {
|
||||
onLoadTemplate: vi.fn(),
|
||||
onActivateVersion: vi.fn(),
|
||||
onDeleteVersion: vi.fn(),
|
||||
};
|
||||
const { rerender } = render(
|
||||
<PromptVersionHistory versions={[]} isLoading {...callbacks} isActivating={false} isDeleting={false} />
|
||||
);
|
||||
expect(screen.getByText('กำลังโหลดประวัติเวอร์ชัน...')).toBeInTheDocument();
|
||||
rerender(<PromptVersionHistory versions={[]} isLoading={false} {...callbacks} isActivating={false} isDeleting={false} />);
|
||||
expect(screen.getByText('ไม่พบเวอร์ชันอื่นในระบบ')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders versions and triggers version actions', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onLoadTemplate = vi.fn();
|
||||
const onActivateVersion = vi.fn();
|
||||
const onDeleteVersion = vi.fn();
|
||||
render(
|
||||
<PromptVersionHistory
|
||||
versions={versions}
|
||||
isLoading={false}
|
||||
onLoadTemplate={onLoadTemplate}
|
||||
onActivateVersion={onActivateVersion}
|
||||
onDeleteVersion={onDeleteVersion}
|
||||
isActivating={false}
|
||||
isDeleting={false}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText('v3')).toBeInTheDocument();
|
||||
expect(screen.getByText('ใช้งานจริง (Active)')).toBeInTheDocument();
|
||||
expect(screen.getByText('ผ่านการทดสอบกับ RFA แล้ว')).toBeInTheDocument();
|
||||
await user.click(screen.getAllByRole('button', { name: 'โหลด (Load)' })[1]);
|
||||
await user.click(screen.getByRole('button', { name: 'ใช้งาน (Activate)' }));
|
||||
await user.click(screen.getByRole('button', { name: '' }));
|
||||
expect(onLoadTemplate).toHaveBeenCalledWith(versions[1]);
|
||||
expect(onActivateVersion).toHaveBeenCalledWith(2);
|
||||
expect(onDeleteVersion).toHaveBeenCalledWith(2);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user