// File: frontend/components/admin/ai/OcrSandboxPromptManager.tsx // Change Log // - 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 // - 2026-05-29: เพิ่ม OCR Raw Text section ในผล sandbox // - 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 // - 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, useCallback } from 'react'; import { useQuery } from '@tanstack/react-query'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; import { Textarea } from '@/components/ui/textarea'; import { Progress } from '@/components/ui/progress'; import { Badge } from '@/components/ui/badge'; import { toast } from 'sonner'; import { Brain, Save, AlertCircle, Upload, Play, FileJson, ScrollText, Loader2, StickyNote, ScanText, } 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, 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 ที่ถูกต้องตามโครงสร้างที่กำหนด ข้อความ OCR ที่สกัดได้: {{ocr_text}} ข้อมูลอ้างอิงของระบบ (Master Data Context): {{master_data_context}} กฎการสกัดข้อมูล: 1. วิเคราะห์และจับคู่ข้อมูลจากข้อความ OCR กับข้อมูลอ้างอิงที่ระบุใน Master Data Context เสมอ 2. สำหรับโครงการ (project) ให้ค้นหาและสกัดส่งกลับเป็น UUID ของโครงการ (projectPublicId) 3. สำหรับประเภทเอกสารโต้ตอบ (correspondence type) ให้สกัดรหัสส่งกลับมา (correspondenceTypeCode) เช่น RFA, Transmittal 4. สำหรับสาขางาน (discipline) ให้ส่งคืนรหัสส่งกลับมา (disciplineCode) เช่น GEN, STR 5. สำหรับหน่วยงานผู้ส่ง (originator) ค้นหาจาก availableOrganizations และส่งกลับมาเป็น UUID (originatorOrganizationPublicId) 6. สำหรับหน่วยงานผู้รับ (recipients) ให้ส่งกลับมาเป็นรายการ Array ของออบเจกต์ ซึ่งมี UUID ขององค์กร (organizationPublicId) และประเภทผู้รับ (recipientType: "TO" หรือ "CC") เสมอ 7. สำหรับหัวข้อเอกสาร (subject) ให้สกัดหัวข้อหรือชื่อเรื่องของเอกสารภาษาไทยหรือภาษาอังกฤษ 8. วันที่ของเอกสาร (documentDate) ให้ส่งคืนในรูปแบบ YYYY-MM-DD 9. รายการแท็ก (tags) สกัดคำสำคัญหรือคำแนะนำ Tags (สอดคล้องกับ availableTags หากมี) 10. สรุปความเนื้อหา (summary) เขียนสรุปรายละเอียดเอกสารสั้นกระชับ 4-5 ประโยคเป็นภาษาไทยอย่างสละสลวย 11. confidence: ค่าความมั่นใจในการสกัดข้อมูลนี้ (ทศนิยมระหว่าง 0.0 ถึง 1.0) ส่งคืนคำตอบเฉพาะ JSON Object ที่ถูกต้องเท่านั้น ห้ามใส่บล็อกโค้ด markdown หรือคำอธิบายเพิ่มเติมใดๆ โครงสร้าง JSON ผลลัพธ์: { "projectPublicId": "string หรือ null", "correspondenceTypeCode": "string หรือ null", "disciplineCode": "string หรือ null", "originatorOrganizationPublicId": "string หรือ null", "recipients": [ { "organizationPublicId": "string", "recipientType": "TO หรือ CC" } ], "subject": "string หรือ null", "documentDate": "string:YYYY-MM-DD หรือ null", "tags": ["string"], "summary": "string หรือ null", "confidence": 0.95 }`; /** * Component หลักสำหรับจัดการ Prompt versions ของ OCR sandbox และ Migration * ประกอบไปด้วยตัวแก้ไข Prompt, รายการเวอร์ชัน, และส่วนสกัดทดสอบ (Sandbox run) */ export default function OcrSandboxPromptManager() { const t = useTranslations(); const promptType = 'ocr_extraction'; const { versionsQuery, createMutation, activateMutation, deleteMutation, updateNoteMutation, } = useAiPrompts(promptType); const versionsData = versionsQuery.data; const versions = Array.isArray(versionsData) ? versionsData : (versionsData && typeof versionsData === 'object' && 'data' in versionsData && Array.isArray((versionsData as { data: unknown }).data)) ? (versionsData as { data: AiPrompt[] }).data : []; const activePrompt = versions.find( (v) => v.isActive === true || (v.isActive as unknown) === 1 || (v.isActive as unknown) === '1' ) || versions[0]; const [templateText, setTemplateText] = useState(''); const [loadedPromptKey, setLoadedPromptKey] = useState(null); const [ocrFile, setOcrFile] = useState(null); const [manualNote, setManualNote] = useState(''); const [activeTab, setActiveTab] = useState<'editor' | 'sandbox'>('sandbox'); // 2-step flow states const [sandboxStep, setSandboxStep] = useState<'ocr' | 'ai'>('ocr'); const [selectedOcrEngine, setSelectedOcrEngine] = useState('auto'); const [typhoonTemperature, setTyphoonTemperature] = useState(0.1); const [typhoonTopP, setTyphoonTopP] = useState(0.1); const [typhoonRepeatPenalty, setTyphoonRepeatPenalty] = useState(1.1); const { data: ocrEnginesData } = useQuery({ queryKey: ['ocr-engines'], 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(null); const [sandboxParamsDraft, setSandboxParamsDraft] = useState>({}); const [isSavingParams, setIsSavingParams] = useState(false); const [isResettingParams, setIsResettingParams] = useState(false); const [showParamPanel, setShowParamPanel] = useState(false); // --- US4 states --- const [selectedProjectPublicId, setSelectedProjectPublicId] = useState(''); const [selectedContractPublicId, setSelectedContractPublicId] = useState(''); 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(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; const mapped = ocrEnginesData.map((e: OcrEngineResponse) => { const value = e.engineType === 'tesseract' ? 'tesseract' : e.engineType === 'typhoon_ocr' ? 'np-dms-ocr' : e.engineType; const vramLabel = e.vramRequirementMB > 0 ? ` (${(e.vramRequirementMB / 1024).toFixed(1)} GB VRAM)` : ''; const activeLabel = e.isCurrentActive ? ' ✓' : ''; return { value, label: `${e.engineName}${vramLabel}${activeLabel}` }; }); return [...base, ...mapped]; }, [ocrEnginesData]); const [ocrResult, setOcrResult] = useState<{ requestPublicId: string; ocrText: string; ocrUsed: boolean; engineUsed?: string; fallbackUsed?: boolean; } | null>(null); const [selectedPromptVersion, setSelectedPromptVersion] = useState(undefined); const { state: sandboxState, jobId: sandboxJobId, reset: resetSandbox, startPolling } = useSandboxRun(() => { // เมื่อ sandbox เสร็จสิ้น: รีเฟรชรายการเวอร์ชัน versionsQuery.refetch(); toast.success(t('ai.prompt.sandboxSuccess')); }); useEffect(() => { if (!versionsQuery.isSuccess) return; if (activePrompt) { const promptKey = `${activePrompt.promptType}:${activePrompt.versionNumber}`; if (loadedPromptKey !== promptKey) { setTemplateText(activePrompt.template); setLoadedPromptKey(promptKey); } return; } if (versions.length === 0 && loadedPromptKey === null) { setTemplateText(DEFAULT_OCR_TEMPLATE); setLoadedPromptKey('default'); } }, [activePrompt, versions.length, versionsQuery.isSuccess, loadedPromptKey]); const handleSaveVersion = async () => { if (!templateText.includes('{{ocr_text}}')) { toast.error(t('ai.prompt.placeholderError')); return; } if (templateText.length > 4000) { toast.error(t('ai.prompt.charLimitError')); return; } try { await createMutation.mutateAsync(templateText); toast.success(t('ai.prompt.saveVersionSuccess')); } catch (err: unknown) { const error = err as { response?: { data?: { message?: string } } }; toast.error(error.response?.data?.message || t('ai.prompt.saveVersionError')); } }; const handleLoadTemplate = (version: AiPrompt) => { setTemplateText(version.template); setActiveTab('editor'); toast.success(t('ai.prompt.loadSuccess', { version: String(version.versionNumber) })); }; const handleActivateVersion = async (versionNumber: number) => { try { await activateMutation.mutateAsync(versionNumber); toast.success(t('ai.prompt.activateSuccess', { version: String(versionNumber) })); } catch (err: unknown) { const error = err as { response?: { data?: { message?: string } } }; toast.error(error.response?.data?.message || t('ai.prompt.activateError')); } }; const handleDeleteVersion = async (versionNumber: number) => { if (!confirm(t('ai.prompt.deleteConfirm', { version: String(versionNumber) }))) return; try { await deleteMutation.mutateAsync(versionNumber); toast.success(t('ai.prompt.deleteSuccess', { version: String(versionNumber) })); } catch (err: unknown) { const error = err as { response?: { data?: { message?: string } } }; toast.error(error.response?.data?.message || t('ai.prompt.deleteError')); } }; const handleSaveManualNote = async (versionNumber: number) => { try { await updateNoteMutation.mutateAsync({ versionNumber, note: manualNote }); toast.success(t('ai.prompt.saveNoteSuccess')); setManualNote(''); } catch (err: unknown) { const error = err as { response?: { data?: { message?: string } } }; toast.error(error.response?.data?.message || t('ai.prompt.saveNoteError')); } }; // 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; } try { resetSandbox(); setSandboxStep('ocr'); const typhoonOptions = selectedOcrEngine === 'np-dms-ocr' ? { temperature: typhoonTemperature, topP: typhoonTopP, repeatPenalty: typhoonRepeatPenalty } : undefined; const { requestPublicId } = await adminAiService.submitSandboxOcr( ocrFile, selectedOcrEngine, typhoonOptions ); toast.success(t('ai.prompt.uploadSuccess')); // Poll สำหรับผลลัพธ์ OCR const pollInterval = setInterval(async () => { try { const result = await adminAiService.getSandboxJobStatus(requestPublicId); if (result.status === 'completed') { clearInterval(pollInterval); setOcrResult({ requestPublicId, ocrText: result.ocrText || '', ocrUsed: result.ocrUsed || false, engineUsed: result.engineUsed, fallbackUsed: result.fallbackUsed, }); setSandboxStep('ai'); toast.success('OCR completed successfully'); } else if (result.status === 'failed') { clearInterval(pollInterval); toast.error(result.errorMessage || 'OCR failed'); } } catch (_err) { clearInterval(pollInterval); toast.error('Poll error occurred'); } }, 1000); } catch (err: unknown) { const error = err as { response?: { data?: { message?: string } } }; toast.error(error.response?.data?.message || t('ai.prompt.uploadError')); } }; // 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; } if (!activePrompt) { toast.error(t('ai.prompt.noActivePrompt')); return; } try { resetSandbox(); const { requestPublicId } = await adminAiService.submitSandboxAiExtract( ocrResult.requestPublicId, selectedPromptVersion, selectedProjectPublicId, selectedContractPublicId || undefined ); toast.success('AI Extraction started'); // เริ่ม polling ผ่าน useSandboxRun hook startPolling(requestPublicId); } catch (err: unknown) { const error = err as { response?: { data?: { message?: string } } }; toast.error(error.response?.data?.message || 'AI Extraction failed'); } }; // Reset 2-step flow const handleResetSandbox = () => { setSandboxStep('ocr'); setOcrResult(null); setSelectedPromptVersion(undefined); setSelectedOcrEngine('auto'); setTyphoonTemperature(0.1); setTyphoonTopP(0.1); setTyphoonRepeatPenalty(1.1); setOcrFile(null); setSelectedProjectPublicId(''); setSelectedContractPublicId(''); resetSandbox(); }; // แปล status key เป็นข้อความตาม locale ปัจจุบัน return (
{activeTab === 'editor' ? ( {t('ai.prompt.cardTitle')} {activePrompt && ( {t('ai.prompt.activeLabel', { version: String(activePrompt.versionNumber) })} )}