refactor(ai): OCR sidecar canonical naming cleanup — typhoon→np-dms, remove hardcoded keys, asyncio.to_thread, ADR-040/041
This commit is contained in:
@@ -75,9 +75,9 @@ function normalizeLoadedModels(value: unknown): VramLoadedModelView[] {
|
||||
if (typeof item === 'string') {
|
||||
const name = item.toLowerCase();
|
||||
let normName = item;
|
||||
if (name.includes('ocr') || name.includes('typhoon-np-dms-ocr')) {
|
||||
if (name.includes(OCR_MODEL_NAME)) {
|
||||
normName = OCR_MODEL_NAME;
|
||||
} else if (name.includes('typhoon') || name.includes(MAIN_MODEL_NAME)) {
|
||||
} else if (name.includes(MAIN_MODEL_NAME)) {
|
||||
normName = MAIN_MODEL_NAME;
|
||||
}
|
||||
return {
|
||||
@@ -95,9 +95,9 @@ function normalizeLoadedModels(value: unknown): VramLoadedModelView[] {
|
||||
const rawName = model.modelName ?? model.name ?? `model-${index + 1}`;
|
||||
const name = rawName.toLowerCase();
|
||||
let normName = rawName;
|
||||
if (name.includes('ocr') || name.includes('typhoon-np-dms-ocr')) {
|
||||
if (name.includes(OCR_MODEL_NAME)) {
|
||||
normName = OCR_MODEL_NAME;
|
||||
} else if (name.includes('typhoon') || name.includes(MAIN_MODEL_NAME)) {
|
||||
} else if (name.includes(MAIN_MODEL_NAME)) {
|
||||
normName = MAIN_MODEL_NAME;
|
||||
}
|
||||
return {
|
||||
@@ -115,8 +115,8 @@ function normalizeLoadedModels(value: unknown): VramLoadedModelView[] {
|
||||
|
||||
function toCanonicalModel(rawName: string): string {
|
||||
const name = rawName.toLowerCase();
|
||||
if (name.includes('ocr') || name.includes('typhoon-np-dms-ocr')) return OCR_MODEL_NAME;
|
||||
if (name.includes('typhoon') || name.includes(MAIN_MODEL_NAME)) return MAIN_MODEL_NAME;
|
||||
if (name.includes(OCR_MODEL_NAME)) return OCR_MODEL_NAME;
|
||||
if (name.includes(MAIN_MODEL_NAME)) return MAIN_MODEL_NAME;
|
||||
return rawName;
|
||||
}
|
||||
|
||||
@@ -193,8 +193,8 @@ export default function AiAdminConsolePage() {
|
||||
new Set(
|
||||
rawHealthOllamaModels.map((m) => {
|
||||
const name = m.toLowerCase();
|
||||
if (name.includes('ocr') || name.includes('typhoon-np-dms-ocr')) return OCR_MODEL_NAME;
|
||||
if (name.includes('typhoon') || name.includes(MAIN_MODEL_NAME)) return MAIN_MODEL_NAME;
|
||||
if (name.includes(OCR_MODEL_NAME)) return OCR_MODEL_NAME;
|
||||
if (name.includes(MAIN_MODEL_NAME)) return MAIN_MODEL_NAME;
|
||||
return m;
|
||||
})
|
||||
)
|
||||
|
||||
@@ -76,7 +76,7 @@ export default function OcrEngineSelector() {
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{engines.map((engine) => {
|
||||
const isTyphoon = engine.engineType === 'typhoon_ocr';
|
||||
const isAiPowered = engine.engineType === 'np_dms_ocr';
|
||||
return (
|
||||
<div
|
||||
key={engine.engineId}
|
||||
@@ -95,14 +95,14 @@ export default function OcrEngineSelector() {
|
||||
กำลังใช้งาน
|
||||
</Badge>
|
||||
)}
|
||||
{isTyphoon && (
|
||||
{isAiPowered && (
|
||||
<Badge variant="secondary" className="text-[10px] h-4 bg-purple-500/10 text-purple-600 dark:text-purple-400 border-purple-500/20">
|
||||
AI Powered
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||
{isTyphoon
|
||||
{isAiPowered
|
||||
? 'สกัดภาษาไทยความแม่นยำสูง (95%+) เหมาะสำหรับภาษาไทยผสมอังกฤษ'
|
||||
: 'เอนจินมาตรฐานเบสไลน์ ประมวลผลรวดเร็วและใช้ทรัพยากรต่ำ'}
|
||||
</p>
|
||||
@@ -111,7 +111,7 @@ export default function OcrEngineSelector() {
|
||||
<Server className="h-3 w-3" />
|
||||
จำกัดพร้อมกัน: {engine.concurrentLimit} งาน
|
||||
</span>
|
||||
{isTyphoon && (
|
||||
{isAiPowered && (
|
||||
<>
|
||||
<span className="flex items-center gap-1 text-purple-600 dark:text-purple-400">
|
||||
<Cpu className="h-3 w-3" />
|
||||
|
||||
@@ -133,9 +133,9 @@ export default function OcrSandboxPromptManager() {
|
||||
// 2-step flow states
|
||||
const [sandboxStep, setSandboxStep] = useState<'ocr' | 'ai'>('ocr');
|
||||
const [selectedOcrEngine, setSelectedOcrEngine] = useState<string>('auto');
|
||||
const [typhoonTemperature, setTyphoonTemperature] = useState<number>(0.1);
|
||||
const [typhoonTopP, setTyphoonTopP] = useState<number>(0.1);
|
||||
const [typhoonRepeatPenalty, setTyphoonRepeatPenalty] = useState<number>(1.1);
|
||||
const [ocrTemperature, setOcrTemperature] = useState<number>(0.1);
|
||||
const [ocrTopP, setOcrTopP] = useState<number>(0.1);
|
||||
const [ocrRepeatPenalty, setOcrRepeatPenalty] = useState<number>(1.1);
|
||||
const { data: ocrEnginesData } = useQuery<OcrEngineResponse[]>({
|
||||
queryKey: ['ocr-engines'],
|
||||
queryFn: () => adminAiService.getOcrEngines(),
|
||||
@@ -250,9 +250,9 @@ export default function OcrSandboxPromptManager() {
|
||||
if (!ocrEnginesData) return base;
|
||||
const mapped = ocrEnginesData.map((e: OcrEngineResponse) => {
|
||||
const value =
|
||||
e.engineType === 'tesseract'
|
||||
? 'tesseract'
|
||||
: e.engineType === 'typhoon_ocr'
|
||||
e.engineType === 'fast_path'
|
||||
? 'auto'
|
||||
: e.engineType === 'np_dms_ocr'
|
||||
? 'np-dms-ocr'
|
||||
: e.engineType;
|
||||
const vramLabel =
|
||||
@@ -354,13 +354,13 @@ export default function OcrSandboxPromptManager() {
|
||||
try {
|
||||
resetSandbox();
|
||||
setSandboxStep('ocr');
|
||||
const typhoonOptions = selectedOcrEngine === 'np-dms-ocr'
|
||||
? { temperature: typhoonTemperature, topP: typhoonTopP, repeatPenalty: typhoonRepeatPenalty }
|
||||
const ocrOptions = selectedOcrEngine === 'np-dms-ocr'
|
||||
? { temperature: ocrTemperature, topP: ocrTopP, repeatPenalty: ocrRepeatPenalty }
|
||||
: undefined;
|
||||
const { requestPublicId } = await adminAiService.submitSandboxOcr(
|
||||
ocrFile,
|
||||
selectedOcrEngine,
|
||||
typhoonOptions
|
||||
ocrOptions
|
||||
);
|
||||
toast.success(t('ai.prompt.uploadSuccess'));
|
||||
// Poll สำหรับผลลัพธ์ OCR
|
||||
@@ -429,9 +429,9 @@ export default function OcrSandboxPromptManager() {
|
||||
setOcrResult(null);
|
||||
setSelectedPromptVersion(undefined);
|
||||
setSelectedOcrEngine('auto');
|
||||
setTyphoonTemperature(0.1);
|
||||
setTyphoonTopP(0.1);
|
||||
setTyphoonRepeatPenalty(1.1);
|
||||
setOcrTemperature(0.1);
|
||||
setOcrTopP(0.1);
|
||||
setOcrRepeatPenalty(1.1);
|
||||
setOcrFile(null);
|
||||
setSelectedProjectPublicId('');
|
||||
setSelectedContractPublicId('');
|
||||
@@ -677,37 +677,37 @@ export default function OcrSandboxPromptManager() {
|
||||
</div>
|
||||
{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>
|
||||
<p className="text-xs font-medium text-amber-600 dark:text-amber-400">OCR Options <span className="font-normal text-muted-foreground">(override Modelfile defaults)</span></p>
|
||||
<div className="space-y-1">
|
||||
<div className="flex justify-between text-xs">
|
||||
<label>Temperature</label>
|
||||
<span className="font-mono text-muted-foreground">{typhoonTemperature.toFixed(2)}</span>
|
||||
<span className="font-mono text-muted-foreground">{ocrTemperature.toFixed(2)}</span>
|
||||
</div>
|
||||
<input type="range" min={0} max={1} step={0.01}
|
||||
value={typhoonTemperature}
|
||||
onChange={(e) => setTyphoonTemperature(parseFloat(e.target.value))}
|
||||
value={ocrTemperature}
|
||||
onChange={(e) => setOcrTemperature(parseFloat(e.target.value))}
|
||||
className="w-full h-1.5 accent-amber-500"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="flex justify-between text-xs">
|
||||
<label>Top-P</label>
|
||||
<span className="font-mono text-muted-foreground">{typhoonTopP.toFixed(2)}</span>
|
||||
<span className="font-mono text-muted-foreground">{ocrTopP.toFixed(2)}</span>
|
||||
</div>
|
||||
<input type="range" min={0} max={1} step={0.01}
|
||||
value={typhoonTopP}
|
||||
onChange={(e) => setTyphoonTopP(parseFloat(e.target.value))}
|
||||
value={ocrTopP}
|
||||
onChange={(e) => setOcrTopP(parseFloat(e.target.value))}
|
||||
className="w-full h-1.5 accent-amber-500"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="flex justify-between text-xs">
|
||||
<label>Repeat Penalty</label>
|
||||
<span className="font-mono text-muted-foreground">{typhoonRepeatPenalty.toFixed(2)}</span>
|
||||
<span className="font-mono text-muted-foreground">{ocrRepeatPenalty.toFixed(2)}</span>
|
||||
</div>
|
||||
<input type="range" min={1} max={2} step={0.01}
|
||||
value={typhoonRepeatPenalty}
|
||||
onChange={(e) => setTyphoonRepeatPenalty(parseFloat(e.target.value))}
|
||||
value={ocrRepeatPenalty}
|
||||
onChange={(e) => setOcrRepeatPenalty(parseFloat(e.target.value))}
|
||||
className="w-full h-1.5 accent-amber-500"
|
||||
/>
|
||||
</div>
|
||||
@@ -864,14 +864,14 @@ export default function OcrSandboxPromptManager() {
|
||||
{ocrResult.engineUsed === 'np-dms-ocr'
|
||||
? 'np-dms-ocr'
|
||||
: ocrResult.ocrUsed
|
||||
? 'Tesseract'
|
||||
? 'Fast Path (OCR)'
|
||||
: 'Fast Path (Text Layer)'}
|
||||
</Badge>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-4">
|
||||
{ocrResult.fallbackUsed && (
|
||||
<div className="mb-3 rounded-md border border-amber-500/20 bg-amber-500/5 px-3 py-2 text-xs text-amber-600 dark:text-amber-400">
|
||||
np-dms-ocr unavailable. Fallback to Tesseract was used for this run.
|
||||
np-dms-ocr unavailable. Fallback to Fast Path was used for this run.
|
||||
</div>
|
||||
)}
|
||||
<div className="relative rounded-md bg-muted p-4 font-mono text-xs overflow-auto max-h-[200px] border border-border/10">
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// File: frontend/components/admin/ai/SandboxTestArea.tsx
|
||||
// Change Log:
|
||||
// - 2026-06-15: Created SandboxTestArea component with UI elements for 3-step sandbox testing (T038)
|
||||
// - 2026-06-17: ลบ Tesseract ออกจาก OCR Engine dropdown ตาม ADR-035 (ใช้ Typhoon OCR ผ่าน Ollama)
|
||||
// - 2026-06-17: ลบ Tesseract ออกจาก OCR Engine dropdown ตาม ADR-035 (ใช้ np-dms-ocr ผ่าน Ollama)
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||
@@ -254,8 +254,8 @@ export default function SandboxTestArea({
|
||||
<SelectValue placeholder="เลือกเอนจิน..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="auto" className="text-xs">Auto (Fast Path / Typhoon OCR)</SelectItem>
|
||||
<SelectItem value="np-dms-ocr" className="text-xs">Typhoon OCR (AI Vision)</SelectItem>
|
||||
<SelectItem value="auto" className="text-xs">Auto (Fast Path / np-dms-ocr)</SelectItem>
|
||||
<SelectItem value="np-dms-ocr" className="text-xs">np-dms-ocr (AI Vision)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
@@ -28,16 +28,16 @@ vi.mock('sonner', () => ({
|
||||
const mockEngines = [
|
||||
{
|
||||
engineId: 'engine-1',
|
||||
engineName: 'Tesseract OCR',
|
||||
engineType: 'tesseract',
|
||||
engineName: 'Fast Path (PyMuPDF)',
|
||||
engineType: 'fast_path',
|
||||
isCurrentActive: true,
|
||||
concurrentLimit: 4,
|
||||
concurrentLimit: 10,
|
||||
vramRequirementMB: 0,
|
||||
},
|
||||
{
|
||||
engineId: 'engine-2',
|
||||
engineName: 'Typhoon OCR',
|
||||
engineType: 'typhoon_ocr',
|
||||
engineName: 'np-dms-ocr',
|
||||
engineType: 'np_dms_ocr',
|
||||
isCurrentActive: false,
|
||||
concurrentLimit: 1,
|
||||
vramRequirementMB: 4096,
|
||||
@@ -52,7 +52,7 @@ describe('OcrEngineSelector', () => {
|
||||
it('renders loading state initially', () => {
|
||||
// Return a promise that doesn't resolve immediately to keep it in loading state
|
||||
(adminAiService.getOcrEngines as any).mockReturnValue(new Promise(() => {}));
|
||||
|
||||
|
||||
const { container } = render(<OcrEngineSelector />);
|
||||
// Card with animate-pulse
|
||||
expect(container.querySelector('.animate-pulse')).toBeInTheDocument();
|
||||
@@ -60,24 +60,24 @@ describe('OcrEngineSelector', () => {
|
||||
|
||||
it('renders engines list successfully after loading', async () => {
|
||||
(adminAiService.getOcrEngines as any).mockResolvedValue(mockEngines);
|
||||
|
||||
|
||||
render(<OcrEngineSelector />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('ระบบจัดการ OCR Engine')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.getByText('Tesseract OCR')).toBeInTheDocument();
|
||||
expect(screen.getByText('Typhoon OCR')).toBeInTheDocument();
|
||||
expect(screen.getByText('Fast Path (PyMuPDF)')).toBeInTheDocument();
|
||||
expect(screen.getByText('np-dms-ocr')).toBeInTheDocument();
|
||||
expect(screen.getByText('กำลังใช้งาน')).toBeInTheDocument(); // Badge for active engine
|
||||
expect(screen.getByText('AI Powered')).toBeInTheDocument(); // Badge for typhoon
|
||||
expect(screen.getByText('AI Powered')).toBeInTheDocument(); // Badge for np-dms-ocr
|
||||
});
|
||||
|
||||
it('calls selectOcrEngine and shows success toast when changing engine', async () => {
|
||||
const user = userEvent.setup();
|
||||
(adminAiService.getOcrEngines as any).mockResolvedValue(mockEngines);
|
||||
(adminAiService.selectOcrEngine as any).mockResolvedValue({});
|
||||
|
||||
|
||||
render(<OcrEngineSelector />);
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -86,21 +86,21 @@ describe('OcrEngineSelector', () => {
|
||||
|
||||
// The active engine will have "เลือกอยู่แล้ว", the inactive will have "สลับใช้งาน"
|
||||
const switchButton = screen.getByRole('button', { name: /สลับใช้งาน/i });
|
||||
|
||||
|
||||
await act(async () => {
|
||||
await user.click(switchButton);
|
||||
});
|
||||
|
||||
expect(adminAiService.selectOcrEngine).toHaveBeenCalledWith('engine-2');
|
||||
expect(toast.success).toHaveBeenCalledWith('เปลี่ยนเอนจิน OCR หลักเป็น Typhoon OCR สำเร็จ');
|
||||
|
||||
expect(toast.success).toHaveBeenCalledWith('เปลี่ยนเอนจิน OCR หลักเป็น np-dms-ocr สำเร็จ');
|
||||
|
||||
// It should fetch engines again
|
||||
expect(adminAiService.getOcrEngines).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('shows error toast if fetching fails', async () => {
|
||||
(adminAiService.getOcrEngines as any).mockRejectedValue(new Error('Network error'));
|
||||
|
||||
|
||||
render(<OcrEngineSelector />);
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -112,7 +112,7 @@ describe('OcrEngineSelector', () => {
|
||||
const user = userEvent.setup();
|
||||
(adminAiService.getOcrEngines as any).mockResolvedValue(mockEngines);
|
||||
(adminAiService.selectOcrEngine as any).mockRejectedValue(new Error('Select error'));
|
||||
|
||||
|
||||
render(<OcrEngineSelector />);
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -120,7 +120,7 @@ describe('OcrEngineSelector', () => {
|
||||
});
|
||||
|
||||
const switchButton = screen.getByRole('button', { name: /สลับใช้งาน/i });
|
||||
|
||||
|
||||
await act(async () => {
|
||||
await user.click(switchButton);
|
||||
});
|
||||
|
||||
@@ -18,17 +18,17 @@ vi.mock('@/lib/services/admin-ai.service', () => ({
|
||||
|
||||
const engines: OcrEngineResponse[] = [
|
||||
{
|
||||
engineId: 'tesseract',
|
||||
engineName: 'Tesseract OCR',
|
||||
engineType: 'tesseract',
|
||||
engineId: 'fast-path',
|
||||
engineName: 'Fast Path (PyMuPDF)',
|
||||
engineType: 'fast_path',
|
||||
isCurrentActive: true,
|
||||
concurrentLimit: 4,
|
||||
concurrentLimit: 10,
|
||||
vramRequirementMB: 0,
|
||||
},
|
||||
{
|
||||
engineId: 'typhoon',
|
||||
engineName: 'Typhoon OCR',
|
||||
engineType: 'typhoon_ocr',
|
||||
engineId: 'np-dms-ocr',
|
||||
engineName: 'np-dms-ocr',
|
||||
engineType: 'np_dms_ocr',
|
||||
isCurrentActive: false,
|
||||
concurrentLimit: 1,
|
||||
vramRequirementMB: 6144,
|
||||
@@ -44,8 +44,8 @@ describe('OcrEngineSelector', () => {
|
||||
|
||||
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(await screen.findByText('Fast Path (PyMuPDF)')).toBeInTheDocument();
|
||||
expect(screen.getByText('np-dms-ocr')).toBeInTheDocument();
|
||||
expect(screen.getByText('AI Powered')).toBeInTheDocument();
|
||||
expect(adminAiService.getOcrEngines).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
@@ -55,9 +55,9 @@ describe('OcrEngineSelector', () => {
|
||||
render(<OcrEngineSelector />);
|
||||
await user.click(await screen.findByRole('button', { name: 'สลับใช้งาน' }));
|
||||
await waitFor(() => {
|
||||
expect(adminAiService.selectOcrEngine).toHaveBeenCalledWith('typhoon');
|
||||
expect(adminAiService.selectOcrEngine).toHaveBeenCalledWith('np-dms-ocr');
|
||||
});
|
||||
expect(toast.success).toHaveBeenCalledWith('เปลี่ยนเอนจิน OCR หลักเป็น Typhoon OCR สำเร็จ');
|
||||
expect(toast.success).toHaveBeenCalledWith('เปลี่ยนเอนจิน OCR หลักเป็น np-dms-ocr สำเร็จ');
|
||||
expect(adminAiService.getOcrEngines).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
|
||||
@@ -100,7 +100,7 @@ vi.mock('@/lib/services/admin-ai.service', () => ({
|
||||
adminAiService: {
|
||||
getOcrEngines: vi.fn().mockResolvedValue([
|
||||
{
|
||||
engineType: 'typhoon_ocr',
|
||||
engineType: 'np_dms_ocr',
|
||||
engineName: 'np-dms-ocr',
|
||||
vramRequirementMB: 4096,
|
||||
isCurrentActive: true,
|
||||
|
||||
@@ -281,19 +281,19 @@ export const adminAiService = {
|
||||
submitSandboxOcr: async (
|
||||
file: File,
|
||||
engineType: string = 'auto',
|
||||
typhoonOptions?: { temperature?: number; topP?: number; repeatPenalty?: number }
|
||||
ocrOptions?: { temperature?: number; topP?: number; repeatPenalty?: number }
|
||||
): Promise<{ requestPublicId: string; jobId: string; status: string }> => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('engineType', engineType);
|
||||
if (typhoonOptions?.temperature !== undefined) {
|
||||
formData.append('temperature', String(typhoonOptions.temperature));
|
||||
if (ocrOptions?.temperature !== undefined) {
|
||||
formData.append('temperature', String(ocrOptions.temperature));
|
||||
}
|
||||
if (typhoonOptions?.topP !== undefined) {
|
||||
formData.append('topP', String(typhoonOptions.topP));
|
||||
if (ocrOptions?.topP !== undefined) {
|
||||
formData.append('topP', String(ocrOptions.topP));
|
||||
}
|
||||
if (typhoonOptions?.repeatPenalty !== undefined) {
|
||||
formData.append('repeatPenalty', String(typhoonOptions.repeatPenalty));
|
||||
if (ocrOptions?.repeatPenalty !== undefined) {
|
||||
formData.append('repeatPenalty', String(ocrOptions.repeatPenalty));
|
||||
}
|
||||
const { data } = await api.post('/ai/admin/sandbox/ocr', formData, {
|
||||
headers: {
|
||||
|
||||
Reference in New Issue
Block a user