// 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 ในกรณีที่ API ส่งข้อมูลแบบ wrapped object มา // - 2026-05-29: เพิ่ม OCR Raw Text section ในผล sandbox // - 2026-05-29: ปรับปรุงการโหลด Active Prompt ให้ทนทานต่อ race conditions และรูปแบบประเภทข้อมูลที่ส่งมาจาก API (boolean, number, string) // - 2026-05-30: Refactor เป็น 2-step flow (Step 1: OCR-only → Step 2: AI Extraction) ตาม spec 231 'use client'; import React, { useState, useEffect } from 'react'; 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 PromptVersionHistory from './PromptVersionHistory'; import { cn } from '@/lib/utils'; import { AiPrompt } from '@/types/ai-prompts'; import { adminAiService } from '@/lib/services/admin-ai.service'; 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'>('editor'); // 2-step flow states const [sandboxStep, setSandboxStep] = useState<'ocr' | 'ai'>('ocr'); const [ocrResult, setOcrResult] = useState<{ requestPublicId: string; ocrText: string; ocrUsed: boolean; } | null>(null); const [selectedPromptVersion, setSelectedPromptVersion] = useState(undefined); const { state: sandboxState, jobId: sandboxJobId, reset: resetSandbox } = 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 (!ocrFile) { toast.error(t('ai.prompt.noFile')); return; } try { resetSandbox(); setSandboxStep('ocr'); const { requestPublicId } = await adminAiService.submitSandboxOcr(ocrFile); 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, }); 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 (!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 ); toast.success('AI Extraction started'); // Poll สำหรับผลลัพธ์ AI const pollInterval = setInterval(async () => { try { const result = await adminAiService.getSandboxJobStatus(requestPublicId); if (result.status === 'completed') { clearInterval(pollInterval); // Trigger sandbox state update via useSandboxRun toast.success(t('ai.prompt.sandboxSuccess')); versionsQuery.refetch(); } else if (result.status === 'failed') { clearInterval(pollInterval); toast.error(result.errorMessage || 'AI Extraction 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 || 'AI Extraction failed'); } }; // Reset 2-step flow const handleResetSandbox = () => { setSandboxStep('ocr'); setOcrResult(null); setSelectedPromptVersion(undefined); setOcrFile(null); resetSandbox(); }; // แปล status key เป็นข้อความตาม locale ปัจจุบัน return (
{activeTab === 'editor' ? ( {t('ai.prompt.cardTitle')} {activePrompt && ( {t('ai.prompt.activeLabel', { version: String(activePrompt.versionNumber) })} )}