690525:2327 ADR-023-229 dynamic prompt #01

This commit is contained in:
2026-05-25 23:27:33 +07:00
parent 1139e54086
commit 82a0444013
29 changed files with 2468 additions and 770 deletions
+4 -241
View File
@@ -23,6 +23,7 @@ import { useAiStatus, useToggleAiFeatures, useAiHealth } from '@/hooks/use-ai-st
import { projectService } from '@/lib/services/project.service';
import { adminAiService, AiSandboxJobResult, AiAvailableModel } from '@/lib/services/admin-ai.service';
import { toast } from 'sonner';
import OcrSandboxPromptManager from '@/components/admin/ai/OcrSandboxPromptManager';
interface SandboxProject {
publicId: string;
@@ -43,12 +44,7 @@ export default function AiAdminConsolePage() {
const [isSandboxPolling, setIsSandboxPolling] = useState<boolean>(false);
const [sandboxProgress, setSandboxProgress] = useState<number>(0);
const [sandboxStatusText, setSandboxStatusText] = useState<string>('');
const [ocrFile, setOcrFile] = useState<File | null>(null);
const [ocrJobId, setOcrJobId] = useState<string | null>(null);
const [ocrJobResult, setOcrJobResult] = useState<AiSandboxJobResult | null>(null);
const [isOcrPolling, setIsOcrPolling] = useState<boolean>(false);
const [ocrProgress, setOcrProgress] = useState<number>(0);
const [ocrStatusText, setOcrStatusText] = useState<string>('');
// AI Model Management State (ADR-027)
const { data: aiModelsData, refetch: refetchModels } = useQuery<{ models: AiAvailableModel[]; activeModel: string }>({
@@ -174,80 +170,7 @@ export default function AiAdminConsolePage() {
clearInterval(timer);
};
}, [sandboxJobId]);
const handleSubmitOcr = async (e: React.FormEvent): Promise<void> => {
e.preventDefault();
if (!ocrFile) {
toast.error('กรุณาเลือกไฟล์ PDF สำหรับทำ OCR');
return;
}
if (ocrFile.size > 50 * 1024 * 1024) {
toast.error('ขนาดไฟล์เกินกว่า 50MB');
return;
}
if (ocrFile.type !== 'application/pdf' && !ocrFile.name.toLowerCase().endsWith('.pdf')) {
toast.error('กรุณาอัปโหลดไฟล์ในรูปแบบ PDF เท่านั้น');
return;
}
try {
setOcrJobResult(null);
setOcrProgress(10);
setOcrStatusText('กำลังอัปโหลดไฟล์ไปยังระบบเซิร์ฟเวอร์...');
const response = await adminAiService.submitSandboxExtract(ocrFile);
setOcrJobId(response.requestPublicId);
setIsOcrPolling(true);
toast.success('อัปโหลดไฟล์สำเร็จและเข้าสู่คิว sandbox OCR');
} catch (err) {
const error = err as { response?: { data?: { message?: string } } };
toast.error(error.response?.data?.message || 'เกิดข้อผิดพลาดในการทำ OCR Sandbox');
setOcrProgress(0);
setOcrStatusText('');
}
};
useEffect(() => {
if (!ocrJobId) return;
let timer: NodeJS.Timeout;
const pollOcrJob = async () => {
try {
const res = await adminAiService.getSandboxJobStatus(ocrJobId);
setOcrJobResult(res);
if (res.status === 'pending') {
setOcrProgress(30);
setOcrStatusText('อยู่ในคิวรอดำเนินการ (Pending in BullMQ)...');
} else if (res.status === 'processing') {
setOcrProgress(70);
setOcrStatusText('กำลังอ่านไฟล์ PDF และสกัดข้อความด้วย OCR & LLM...');
} else if (res.status === 'completed') {
setOcrProgress(100);
setOcrStatusText('การทำ OCR และสกัดข้อมูลเมตาดาต้าเสร็จสิ้น');
setIsOcrPolling(false);
setOcrJobId(null);
toast.success('ทำ OCR Sandbox สำเร็จ');
} else if (res.status === 'failed') {
setOcrProgress(100);
setOcrStatusText('การทำ OCR ล้มเหลว');
setIsOcrPolling(false);
setOcrJobId(null);
toast.error(res.errorMessage || 'การทำ OCR Sandbox เกิดข้อผิดพลาด');
} else if (res.status === 'cancelled') {
setOcrProgress(100);
setOcrStatusText('การทำ OCR ถูกยกเลิก');
setIsOcrPolling(false);
setOcrJobId(null);
toast.error('OCR sandbox job ถูกยกเลิก');
} else if (res.status === 'not_found') {
setOcrProgress(20);
setOcrStatusText('กำลังตรวจสอบสถานะคิวงาน...');
}
} catch {
// เงียบข้อผิดพลาดตามนโยบาย UI
}
};
pollOcrJob();
timer = setInterval(pollOcrJob, 5000);
return () => {
clearInterval(timer);
};
}, [ocrJobId]);
const renderStatusBadge = (status?: 'HEALTHY' | 'DEGRADED' | 'DOWN') => {
if (!status) return <Badge variant="outline">Unknown</Badge>;
switch (status) {
@@ -745,167 +668,7 @@ export default function AiAdminConsolePage() {
)}
</TabsContent>
<TabsContent value="ocr" className="space-y-6">
<Card className="border border-border/50 bg-background/50 backdrop-blur-md">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-lg">
<Brain className="h-5 w-5 text-primary" />
OCR Sandbox Playground (isolated)
</CardTitle>
<p className="text-sm text-muted-foreground">
PDF OCR Metadata JSON
</p>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmitOcr} className="space-y-4">
<div className="space-y-2">
<label className="text-sm font-medium text-foreground">
PDF ( 50MB)
</label>
<div
className={`flex flex-col items-center justify-center rounded-lg border border-dashed p-8 transition-colors ${
ocrFile
? 'border-primary/50 bg-primary/5'
: 'border-muted-foreground/20 hover:bg-muted/10'
}`}
onDragOver={(e) => {
e.preventDefault();
}}
onDrop={(e) => {
e.preventDefault();
if (isOcrPolling) return;
const file = e.dataTransfer.files?.[0];
if (file) {
if (file.type === 'application/pdf' || file.name.toLowerCase().endsWith('.pdf')) {
setOcrFile(file);
} else {
toast.error('กรุณาเลือกไฟล์ PDF เท่านั้น');
}
}
}}
>
<Activity className="h-10 w-10 text-muted-foreground/60 mb-2" />
{ocrFile ? (
<div className="text-center space-y-1">
<p className="text-sm font-medium text-foreground">{ocrFile.name}</p>
<p className="text-xs text-muted-foreground">
({(ocrFile.size / 1024 / 1024).toFixed(2)} MB)
</p>
<Button
type="button"
variant="ghost"
size="sm"
disabled={isOcrPolling}
onClick={() => setOcrFile(null)}
className="mt-2 text-xs text-destructive hover:text-destructive hover:bg-destructive/10"
>
</Button>
</div>
) : (
<div className="text-center space-y-1">
<p className="text-sm text-muted-foreground">
PDF
</p>
<input
type="file"
accept=".pdf"
disabled={isOcrPolling}
onChange={(e) => {
const file = e.target.files?.[0];
if (file) setOcrFile(file);
}}
className="hidden"
id="ocr-file-upload"
/>
<label
htmlFor="ocr-file-upload"
className="mt-2 inline-flex h-8 items-center justify-center rounded-md bg-secondary px-3 text-xs font-medium text-secondary-foreground ring-offset-background transition-colors hover:bg-secondary/80 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 cursor-pointer"
>
</label>
</div>
)}
</div>
</div>
<div className="flex justify-end gap-3 pt-2">
<Button
type="submit"
disabled={isOcrPolling || !ocrFile}
className="flex items-center gap-2"
>
{isOcrPolling ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
OCR...
</>
) : (
<>
<Power className="h-4 w-4" />
OCR Sandbox
</>
)}
</Button>
</div>
</form>
</CardContent>
</Card>
{isOcrPolling && (
<Card className="border border-amber-500/20 bg-amber-500/5">
<CardContent className="pt-6 space-y-4">
<div className="flex items-center justify-between text-sm font-medium">
<div className="flex items-center gap-2">
<Loader2 className="h-4 w-4 animate-spin text-amber-500" />
<span>{ocrStatusText}</span>
</div>
<span className="text-xs text-muted-foreground">{ocrProgress}%</span>
</div>
<Progress value={ocrProgress} className="h-2" />
<div className="rounded bg-background/50 p-2 text-[11px] text-muted-foreground font-mono flex items-center gap-2">
<Info className="h-3 w-3" />
ID : {ocrJobId}
</div>
</CardContent>
</Card>
)}
{ocrJobResult && (
<div className="space-y-6">
{ocrJobResult.status === 'completed' && (
<Card className="border border-emerald-500/20 bg-background/50 backdrop-blur-md">
<CardHeader className="border-b border-border/30 pb-3 flex flex-row items-center justify-between">
<CardTitle className="text-base text-emerald-600 dark:text-emerald-400 flex items-center gap-2">
<Brain className="h-4 w-4" />
Metadata (JSON Output)
</CardTitle>
</CardHeader>
<CardContent className="pt-4">
<div className="relative rounded-md bg-muted p-4 font-mono text-xs overflow-auto max-h-[400px]">
<pre className="text-emerald-600 dark:text-emerald-400 select-text">
{ocrJobResult.answer}
</pre>
</div>
{ocrJobResult.completedAt && (
<div className="mt-4 text-right text-[10px] text-muted-foreground">
: {new Date(ocrJobResult.completedAt).toLocaleString()}
</div>
)}
</CardContent>
</Card>
)}
{ocrJobResult.status === 'failed' && (
<Card className="border border-destructive/20 bg-destructive/5">
<CardHeader className="flex flex-row items-center gap-2 pb-2 text-destructive">
<AlertCircle className="h-5 w-5" />
<CardTitle className="text-sm font-medium"> OCR Sandbox </CardTitle>
</CardHeader>
<CardContent>
<p className="text-xs text-muted-foreground leading-relaxed">
{ocrJobResult.errorMessage || 'เกิดข้อผิดพลาดขึ้นระหว่างการอ่านไฟล์เอกสาร PDF หรือการเรียก LLM Sandbox สำหรับถอดความเมตาดาต้า กรุณาตรวจสอบสถานะสุขภาพของตัวบริการ'}
</p>
</CardContent>
</Card>
)}
</div>
)}
<OcrSandboxPromptManager />
</TabsContent>
</Tabs>
</div>
@@ -0,0 +1,415 @@
// 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)
'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,
} 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';
/**
* 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 versions = versionsQuery.data ?? [];
const activePrompt = versions.find((v) => v.isActive);
const [templateText, setTemplateText] = useState<string>('');
const [ocrFile, setOcrFile] = useState<File | null>(null);
const [manualNote, setManualNote] = useState<string>('');
const [activeTab, setActiveTab] = useState<'editor' | 'sandbox'>('editor');
const { state: sandboxState, jobId: sandboxJobId, submit: submitSandbox, reset: resetSandbox } =
useSandboxRun(() => {
// เมื่อ sandbox เสร็จสิ้น: รีเฟรชรายการเวอร์ชัน
versionsQuery.refetch();
toast.success(t('ai.prompt.sandboxSuccess'));
});
useEffect(() => {
if (activePrompt && !templateText) {
setTemplateText(activePrompt.template);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activePrompt]);
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'));
}
};
const handleSubmitOcr = async (e: React.FormEvent) => {
e.preventDefault();
if (!activePrompt) {
toast.error(t('ai.prompt.noActivePrompt'));
return;
}
if (!ocrFile) {
toast.error(t('ai.prompt.noFile'));
return;
}
try {
resetSandbox();
await submitSandbox(ocrFile);
toast.success(t('ai.prompt.uploadSuccess'));
} catch (err: unknown) {
const error = err as { response?: { data?: { message?: string } } };
toast.error(error.response?.data?.message || t('ai.prompt.uploadError'));
}
};
// แปล status key เป็นข้อความตาม locale ปัจจุบัน
const statusLabel = sandboxState.statusText ? t(sandboxState.statusText) : '';
return (
<div className="grid gap-6 lg:grid-cols-12 items-start">
<div className="lg:col-span-8 space-y-6">
<div className="flex border-b border-border/20">
<button
onClick={() => setActiveTab('editor')}
className={cn(
'px-4 py-2 text-sm font-semibold border-b-2 -mb-[2px] transition-all',
activeTab === 'editor'
? 'border-primary text-primary'
: 'border-transparent text-muted-foreground hover:text-foreground'
)}
>
{t('ai.prompt.tabEditor')}
</button>
<button
onClick={() => setActiveTab('sandbox')}
className={cn(
'px-4 py-2 text-sm font-semibold border-b-2 -mb-[2px] transition-all',
activeTab === 'sandbox'
? 'border-primary text-primary'
: 'border-transparent text-muted-foreground hover:text-foreground'
)}
>
{t('ai.prompt.tabSandbox')}
</button>
</div>
{activeTab === 'editor' ? (
<Card className="border border-border/50 bg-background/50 backdrop-blur-md">
<CardHeader className="pb-3 flex flex-row justify-between items-center">
<CardTitle className="text-base font-semibold flex items-center gap-2">
<ScrollText className="h-4 w-4 text-primary" />
{t('ai.prompt.cardTitle')}
</CardTitle>
{activePrompt && (
<Badge variant="outline" className="text-xs">
{t('ai.prompt.activeLabel', { version: String(activePrompt.versionNumber) })}
</Badge>
)}
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Textarea
value={templateText}
onChange={(e) => setTemplateText(e.target.value)}
disabled={createMutation.isPending}
rows={15}
placeholder={t('ai.prompt.editorPlaceholder')}
className="font-mono text-xs leading-relaxed resize-none border border-input bg-background/30"
/>
<div className="flex justify-between items-center text-[10px] text-muted-foreground">
<span className={cn(templateText.includes('{{ocr_text}}') ? 'text-emerald-500' : 'text-amber-500')}>
{templateText.includes('{{ocr_text}}')
? t('ai.prompt.placeholderOk')
: t('ai.prompt.placeholderMissing')}
</span>
<span className={cn(templateText.length > 4000 ? 'text-destructive font-bold' : '')}>
{t('ai.prompt.charCount', { count: String(templateText.length) })}
</span>
</div>
</div>
<div className="flex justify-end pt-2">
<Button
onClick={handleSaveVersion}
disabled={createMutation.isPending || templateText.length === 0}
className="flex items-center gap-2"
>
{createMutation.isPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Save className="h-4 w-4" />
)}
{t('ai.prompt.saveVersion')}
</Button>
</div>
</CardContent>
</Card>
) : (
<div className="space-y-6">
<Card className="border border-border/50 bg-background/50 backdrop-blur-md">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base font-semibold">
<Upload className="h-4 w-4 text-primary" />
{t('ai.prompt.sandboxCardTitle')}
</CardTitle>
<p className="text-xs text-muted-foreground">
{t('ai.prompt.sandboxCardDesc')}
</p>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmitOcr} className="space-y-4">
<div className="space-y-2">
<div
className={cn(
'flex flex-col items-center justify-center rounded-lg border border-dashed p-8 transition-all',
ocrFile ? 'border-primary/50 bg-primary/5' : 'border-muted-foreground/20 hover:bg-muted/10'
)}
onDragOver={(e) => e.preventDefault()}
onDrop={(e) => {
e.preventDefault();
if (sandboxState.isRunning) return;
const file = e.dataTransfer.files?.[0];
if (file?.name.toLowerCase().endsWith('.pdf')) {
setOcrFile(file);
} else {
toast.error(t('ai.prompt.dropzonePdfOnly'));
}
}}
>
<Brain className="h-9 w-9 text-muted-foreground/50 mb-2 animate-bounce" />
{ocrFile ? (
<div className="text-center space-y-1">
<p className="text-sm font-semibold">{ocrFile.name}</p>
<p className="text-xs text-muted-foreground">
({(ocrFile.size / 1024 / 1024).toFixed(2)} MB)
</p>
<Button
type="button"
variant="ghost"
size="sm"
disabled={sandboxState.isRunning}
onClick={() => setOcrFile(null)}
className="mt-2 text-xs text-destructive hover:bg-destructive/10"
>
{t('ai.prompt.removeFile')}
</Button>
</div>
) : (
<div className="text-center space-y-1">
<p className="text-xs text-muted-foreground">
{t('ai.prompt.dropzoneDrag')}
</p>
<input
type="file"
accept=".pdf"
disabled={sandboxState.isRunning}
onChange={(e) => {
const file = e.target.files?.[0];
if (file) setOcrFile(file);
}}
className="hidden"
id="ocr-sandbox-file"
/>
<label
htmlFor="ocr-sandbox-file"
className="mt-2.5 inline-flex h-8 items-center justify-center rounded-md bg-secondary px-3.5 text-xs font-semibold cursor-pointer hover:bg-secondary/85 transition-colors"
>
{t('ai.prompt.dropzoneChoose')}
</label>
</div>
)}
</div>
</div>
<div className="flex justify-end gap-3 pt-2">
<Button
type="submit"
disabled={sandboxState.isRunning || !ocrFile || !activePrompt}
className="flex items-center gap-2"
>
{sandboxState.isRunning ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
{t('ai.prompt.running')}
</>
) : (
<>
<Play className="h-4 w-4" />
{t('ai.prompt.runSandbox')}
</>
)}
</Button>
</div>
</form>
</CardContent>
</Card>
{sandboxState.isRunning && (
<Card className="border border-amber-500/20 bg-amber-500/5">
<CardContent className="pt-6 space-y-4">
<div className="flex items-center justify-between text-xs font-medium">
<span className="flex items-center gap-1.5">
<Loader2 className="h-3.5 w-3.5 animate-spin text-amber-500" />
{statusLabel}
</span>
<span>{sandboxState.progress}%</span>
</div>
<Progress value={sandboxState.progress} className="h-1.5" />
<div className="text-[10px] text-muted-foreground font-mono bg-background/50 p-2 rounded">
Request ID: {sandboxJobId}
</div>
</CardContent>
</Card>
)}
{sandboxState.result && sandboxState.result.status === 'completed' && (
<div className="space-y-6">
<Card className="border border-emerald-500/20 bg-background/50 backdrop-blur-md">
<CardHeader className="border-b border-border/30 pb-3 flex flex-row items-center justify-between">
<CardTitle className="text-base text-emerald-600 dark:text-emerald-400 flex items-center gap-2">
<FileJson className="h-4 w-4" />
{t('ai.prompt.resultTitle')}
</CardTitle>
{activePrompt && (
<Badge variant="outline" className="text-xs text-emerald-500 border-emerald-500/20 bg-emerald-500/5">
{t('ai.prompt.resultVersionBadge', { version: String(activePrompt.versionNumber) })}
</Badge>
)}
</CardHeader>
<CardContent className="pt-4 space-y-4">
<div className="relative rounded-md bg-muted p-4 font-mono text-xs overflow-auto max-h-[300px] border border-border/10">
<pre className="text-emerald-600 dark:text-emerald-400 select-text leading-relaxed">
{sandboxState.result.answer}
</pre>
</div>
</CardContent>
</Card>
{activePrompt && (
<Card className="border border-border/50 bg-background/50 backdrop-blur-md">
<CardHeader className="pb-3">
<CardTitle className="text-sm font-semibold flex items-center gap-2">
<StickyNote className="h-4 w-4 text-amber-500 animate-pulse" />
{t('ai.prompt.noteCardTitle')}
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<Textarea
value={manualNote}
onChange={(e) => setManualNote(e.target.value)}
placeholder={t('ai.prompt.notePlaceholder')}
rows={3}
className="text-xs leading-relaxed resize-none bg-background/30"
/>
<div className="flex justify-end">
<Button
disabled={updateNoteMutation.isPending || !manualNote.trim()}
onClick={() => handleSaveManualNote(activePrompt.versionNumber)}
className="flex items-center gap-2 text-xs"
size="sm"
>
{updateNoteMutation.isPending ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Save className="h-3.5 w-3.5" />
)}
{t('ai.prompt.saveNote', { version: String(activePrompt.versionNumber) })}
</Button>
</div>
</CardContent>
</Card>
)}
</div>
)}
{sandboxState.result && sandboxState.result.status === 'failed' && (
<Card className="border border-destructive/20 bg-destructive/5">
<CardHeader className="flex flex-row items-center gap-2 pb-2 text-destructive">
<AlertCircle className="h-4 w-4" />
<CardTitle className="text-sm font-medium">{t('ai.prompt.sandboxErrorTitle')}</CardTitle>
</CardHeader>
<CardContent>
<p className="text-xs text-muted-foreground leading-relaxed">
{sandboxState.result.errorMessage || t('ai.prompt.sandboxErrorDefault')}
</p>
</CardContent>
</Card>
)}
</div>
)}
</div>
<div className="lg:col-span-4">
<PromptVersionHistory
versions={versions}
isLoading={versionsQuery.isLoading}
onLoadTemplate={handleLoadTemplate}
onActivateVersion={handleActivateVersion}
onDeleteVersion={handleDeleteVersion}
isActivating={activateMutation.isPending}
isDeleting={deleteMutation.isPending}
/>
</div>
</div>
);
}
@@ -0,0 +1,141 @@
// File: frontend/components/admin/ai/PromptVersionHistory.tsx
// Change Log
// - 2026-05-25: Created PromptVersionHistory component (ADR-029)
import React from 'react';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { CheckCircle2, Trash2, BookOpen, Clock, StickyNote } from 'lucide-react';
import { AiPrompt } from '@/types/ai-prompts';
import { cn } from '@/lib/utils';
interface PromptVersionHistoryProps {
versions: AiPrompt[];
isLoading: boolean;
onLoadTemplate: (version: AiPrompt) => void;
onActivateVersion: (versionNumber: number) => void;
onDeleteVersion: (versionNumber: number) => void;
isActivating: boolean;
isDeleting: boolean;
}
/**
* แผงประวัติและเวอร์ชันของ AI Prompts ทางฝั่งขวามือ
* แสดงรายการเวอร์ชันพร้อมปุ่มเรียกใช้ เปิดทำงาน หรือลบทิ้ง
*/
export default function PromptVersionHistory({
versions,
isLoading,
onLoadTemplate,
onActivateVersion,
onDeleteVersion,
isActivating,
isDeleting,
}: PromptVersionHistoryProps) {
if (isLoading) {
return (
<div className="flex h-[400px] items-center justify-center text-sm text-muted-foreground">
<Clock className="mr-2 h-4 w-4 animate-spin text-primary" />
...
</div>
);
}
return (
<Card className="border border-border/50 bg-background/30 backdrop-blur-md transition-all duration-300 hover:shadow-md">
<CardHeader className="pb-3 border-b border-border/10">
<CardTitle className="flex items-center gap-2 text-sm font-semibold tracking-wide text-foreground">
<BookOpen className="h-4 w-4 text-primary animate-pulse" />
(Version History)
</CardTitle>
</CardHeader>
<CardContent className="pt-4 px-3 sm:px-4 max-h-[600px] overflow-y-auto space-y-3">
{versions.length === 0 ? (
<div className="flex flex-col items-center justify-center py-10 text-center text-xs text-muted-foreground italic">
</div>
) : (
versions.map((version) => (
<div
key={version.versionNumber}
className={cn(
'group relative rounded-lg border border-border/30 bg-background/50 p-3.5 transition-all duration-200 hover:border-primary/30 hover:bg-background/80',
version.isActive && 'border-emerald-500/20 bg-emerald-500/[0.02] shadow-[inset_0_1px_3px_rgba(16,185,129,0.03)]'
)}
>
<div className="flex items-start justify-between gap-3">
<div className="space-y-1">
<div className="flex items-center gap-2">
<span className="font-mono text-sm font-bold text-foreground">
v{version.versionNumber}
</span>
{version.isActive ? (
<Badge className="border-emerald-500/20 bg-emerald-500/10 text-emerald-500 hover:bg-emerald-500/20 text-[10px] py-0 px-1.5 flex items-center gap-1 select-none">
<CheckCircle2 className="h-3 w-3" />
(Active)
</Badge>
) : (
<Badge variant="outline" className="text-[10px] text-muted-foreground border-border/50 bg-background/40 select-none">
(Inactive)
</Badge>
)}
</div>
<div className="flex flex-col gap-1 text-[11px] text-muted-foreground">
<span className="flex items-center gap-1">
<Clock className="h-3 w-3" />
: {new Date(version.createdAt).toLocaleString('th-TH')}
</span>
{version.lastTestedAt && (
<span className="flex items-center gap-1 text-emerald-500/90">
<CheckCircle2 className="h-3 w-3 text-emerald-500" />
: {new Date(version.lastTestedAt).toLocaleString('th-TH')}
</span>
)}
</div>
</div>
<div className="flex items-center gap-1.5 opacity-90 sm:opacity-0 group-hover:opacity-100 transition-opacity duration-200">
<Button
variant="ghost"
size="sm"
className="h-7 text-[10px] text-muted-foreground hover:bg-secondary"
onClick={() => onLoadTemplate(version)}
>
(Load)
</Button>
{!version.isActive && (
<>
<Button
variant="ghost"
size="sm"
disabled={isActivating}
className="h-7 text-[10px] text-emerald-500 hover:text-emerald-600 hover:bg-emerald-500/10"
onClick={() => onActivateVersion(version.versionNumber)}
>
(Activate)
</Button>
<Button
variant="ghost"
size="sm"
disabled={isDeleting}
className="h-7 w-7 text-destructive hover:bg-destructive/10"
onClick={() => onDeleteVersion(version.versionNumber)}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</>
)}
</div>
</div>
{version.manualNote && (
<div className="mt-2.5 rounded bg-muted/30 p-2 border border-border/10 flex gap-1.5 items-start text-[11px] text-muted-foreground select-text">
<StickyNote className="h-3.5 w-3.5 text-amber-500 shrink-0 mt-0.5" />
<p className="leading-relaxed whitespace-pre-wrap">{version.manualNote}</p>
</div>
)}
</div>
))
)}
</CardContent>
</Card>
);
}
+5
View File
@@ -1,3 +1,7 @@
// File: frontend/eslint.config.mjs
// Change Log
// - 2026-05-25: Added coverage/** to ignored directories to prevent linting auto-generated test coverage files
import js from '@eslint/js';
import globals from 'globals';
import typescriptParser from '@typescript-eslint/parser';
@@ -79,6 +83,7 @@ const eslintConfig = [
'out/**',
'dist/**',
'build/**',
'coverage/**',
'*.config.js',
'*.config.mjs',
'*.config.ts',
+165
View File
@@ -0,0 +1,165 @@
// File: frontend/hooks/use-ai-prompts.ts
// Change Log
// - 2026-05-25: Created useAiPrompts unified hook for React Query prompt operations (ADR-029)
// - 2026-05-25: Added useSandboxRun hook to encapsulate submit + polling logic (Obs #2 fix)
import { useCallback, useEffect, useRef, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { aiPromptsService } from '@/lib/services/ai-prompts.service';
import { adminAiService, AiSandboxJobResult } from '@/lib/services/admin-ai.service';
/** สถานะการรัน OCR Sandbox */
export interface SandboxRunState {
/** กำลังอัปโหลดหรือ polling อยู่ */
isRunning: boolean;
/** ความคืบหน้า 0-100 */
progress: number;
/** ข้อความสถานะที่แสดงต่อผู้ใช้ */
statusText: string;
/** ผลลัพธ์สุดท้ายจาก job (null ก่อนเสร็จสิ้น) */
result: AiSandboxJobResult | null;
}
/**
* Unified hook สำหรับการจัดการประวัติและการเปิดใช้งาน Prompt Versions ผ่าน React Query
*/
export function useAiPrompts(promptType: string) {
const queryClient = useQueryClient();
const queryKey = ['ai', 'prompts', promptType] as const;
const versionsQuery = useQuery({
queryKey,
queryFn: () => aiPromptsService.listVersions(promptType),
enabled: !!promptType,
});
const createMutation = useMutation({
mutationFn: (template: string) => aiPromptsService.createVersion(promptType, template),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey });
},
});
const activateMutation = useMutation({
mutationFn: (versionNumber: number) =>
aiPromptsService.activateVersion(promptType, versionNumber),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey });
},
});
const deleteMutation = useMutation({
mutationFn: (versionNumber: number) =>
aiPromptsService.deleteVersion(promptType, versionNumber),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey });
},
});
const updateNoteMutation = useMutation({
mutationFn: ({ versionNumber, note }: { versionNumber: number; note: string | null }) =>
aiPromptsService.updateNote(promptType, versionNumber, note),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey });
},
});
return {
versionsQuery,
createMutation,
activateMutation,
deleteMutation,
updateNoteMutation,
};
}
/**
* Hook แยกสำหรับการส่ง OCR Sandbox job และ polling ผลลัพธ์
* ให้ใช้แทนการเขียน polling logic โดยตรงในหน้า Component
*/
export function useSandboxRun(onCompleted?: () => void) {
const [state, setState] = useState<SandboxRunState>({
isRunning: false,
progress: 0,
statusText: '',
result: null,
});
const [jobId, setJobId] = useState<string | null>(null);
const timerRef = useRef<NodeJS.Timeout | null>(null);
// หยุด polling เมื่อ unmount
useEffect(() => {
return () => {
if (timerRef.current) clearInterval(timerRef.current);
};
}, []);
// เริ่ม polling เมื่อมี jobId
useEffect(() => {
if (!jobId) return;
const poll = async () => {
try {
const res = await adminAiService.getSandboxJobStatus(jobId);
setState((prev) => ({ ...prev, result: res }));
if (res.status === 'pending') {
setState((prev) => ({ ...prev, progress: 30, statusText: 'ai.prompt.statusPending' }));
} else if (res.status === 'processing') {
setState((prev) => ({
...prev,
progress: 70,
statusText: 'ai.prompt.statusProcessing',
}));
} else if (res.status === 'completed') {
if (timerRef.current) clearInterval(timerRef.current);
setJobId(null);
setState((prev) => ({
...prev,
isRunning: false,
progress: 100,
statusText: 'ai.prompt.statusCompleted',
}));
onCompleted?.();
} else if (res.status === 'failed') {
if (timerRef.current) clearInterval(timerRef.current);
setJobId(null);
setState((prev) => ({
...prev,
isRunning: false,
progress: 100,
statusText: 'ai.prompt.statusFailed',
}));
} else if (res.status === 'cancelled') {
if (timerRef.current) clearInterval(timerRef.current);
setJobId(null);
setState((prev) => ({
...prev,
isRunning: false,
progress: 100,
statusText: 'ai.prompt.statusCancelled',
}));
}
} catch {
// เงียบข้อผิดพลาดระหว่าง polling
}
};
poll();
timerRef.current = setInterval(poll, 4000);
return () => {
if (timerRef.current) clearInterval(timerRef.current);
};
}, [jobId, onCompleted]);
/**
* ส่ง PDF file เข้า sandbox queue และเริ่ม polling อัตโนมัติ
* @returns requestPublicId หรือ throw Error เมื่อล้มเหลว
*/
const submit = useCallback(async (file: File): Promise<string> => {
setState({
isRunning: true,
progress: 10,
statusText: 'ai.prompt.uploading',
result: null,
});
const response = await adminAiService.submitSandboxExtract(file);
setJobId(response.requestPublicId);
return response.requestPublicId;
}, []);
/** รีเซ็ตสถานะทั้งหมด (ใช้ก่อนรันใหม่) */
const reset = useCallback(() => {
if (timerRef.current) clearInterval(timerRef.current);
setJobId(null);
setState({ isRunning: false, progress: 0, statusText: '', result: null });
}, []);
return { state, jobId, submit, reset };
}
@@ -0,0 +1,66 @@
// File: frontend/lib/services/ai-prompts.service.ts
// Change Log
// - 2026-05-25: Created aiPromptsService for prompt versioning and sandbox operations (ADR-029)
import api from '../api/client';
import { AiPrompt } from '../../types/ai-prompts';
const extractData = <T>(value: unknown): T => {
if (value && typeof value === 'object' && 'data' in value) {
return (value as { data: T }).data;
}
return value as T;
};
/**
* Service สำหรับเรียก API ในการจัดการ AI prompt templates ทางฝั่งหน้าบ้าน
*/
export const aiPromptsService = {
/**
* ดึงรายการ Prompt Versions ทั้งหมดสำหรับ prompt_type ที่กำหนด
*/
listVersions: async (promptType: string): Promise<AiPrompt[]> => {
const { data } = await api.get(`/ai/prompts/${encodeURIComponent(promptType)}`);
return extractData<AiPrompt[]>(data);
},
/**
* สร้าง Prompt Version ใหม่ (เริ่มต้นเป็น inactive)
*/
createVersion: async (promptType: string, template: string): Promise<AiPrompt> => {
const { data } = await api.post(`/ai/prompts/${encodeURIComponent(promptType)}`, { template });
return extractData<AiPrompt>(data);
},
/**
* เปิดใช้งาน Prompt Version เพื่อใช้เป็น active version
*/
activateVersion: async (promptType: string, versionNumber: number): Promise<AiPrompt> => {
const { data } = await api.post(
`/ai/prompts/${encodeURIComponent(promptType)}/${versionNumber}/activate`
);
return extractData<AiPrompt>(data);
},
/**
* ลบ Prompt Version (ต้องไม่เป็น active version)
*/
deleteVersion: async (promptType: string, versionNumber: number): Promise<void> => {
await api.delete(`/ai/prompts/${encodeURIComponent(promptType)}/${versionNumber}`);
},
/**
* อัปเดต manual note ของเวอร์ชันที่กำหนด
*/
updateNote: async (
promptType: string,
versionNumber: number,
manualNote: string | null
): Promise<AiPrompt> => {
const { data } = await api.patch(
`/ai/prompts/${encodeURIComponent(promptType)}/${versionNumber}/note`,
{ manualNote }
);
return extractData<AiPrompt>(data);
},
};
+53 -1
View File
@@ -91,5 +91,57 @@
"ai.staging.thresholdWarning": "Improvement Recommended",
"ai.staging.thresholdWarningDesc": "Override rate reached {{rate}}% in recent records.",
"ai.staging.thresholdNote": "* Threshold values must be set via Backend Environment Variables.",
"ai.staging.thresholdDocs": "View Configuration Guide"
"ai.staging.thresholdDocs": "View Configuration Guide",
"ai.prompt.tabEditor": "Prompt Template Editor",
"ai.prompt.tabSandbox": "OCR Sandbox Runner",
"ai.prompt.cardTitle": "Prompt Template",
"ai.prompt.activeLabel": "Active: v{{version}}",
"ai.prompt.editorPlaceholder": "Write the Prompt template with {{ocr_text}} here...",
"ai.prompt.placeholderOk": "✓ {{ocr_text}} placeholder present",
"ai.prompt.placeholderMissing": "✗ Missing {{ocr_text}} placeholder",
"ai.prompt.charCount": "{{count}} / 4000 characters",
"ai.prompt.saveVersion": "Save as New Version (Draft)",
"ai.prompt.saveVersionSuccess": "New version saved successfully (draft)",
"ai.prompt.saveVersionError": "Failed to save Prompt version",
"ai.prompt.placeholderError": "Template must contain {{ocr_text}} placeholder",
"ai.prompt.charLimitError": "Template exceeds 4,000 character limit",
"ai.prompt.loadSuccess": "Loaded content of v{{version}} into Editor",
"ai.prompt.activateSuccess": "Prompt Version v{{version}} is now active",
"ai.prompt.activateError": "Failed to activate prompt version",
"ai.prompt.deleteConfirm": "Delete v{{version}}?",
"ai.prompt.deleteSuccess": "Prompt Version v{{version}} deleted",
"ai.prompt.deleteError": "Failed to delete prompt version",
"ai.prompt.deleteActiveError": "Cannot delete the active version",
"ai.prompt.saveNote": "Save Note for v{{version}}",
"ai.prompt.saveNoteSuccess": "Manual note saved successfully",
"ai.prompt.saveNoteError": "Failed to save note",
"ai.prompt.sandboxCardTitle": "Test OCR Sandbox with Active Prompt",
"ai.prompt.sandboxCardDesc": "Upload a PDF to extract and evaluate metadata structure using the active prompt.",
"ai.prompt.dropzoneDrag": "Drag & drop a PDF or click below to upload",
"ai.prompt.dropzoneChoose": "Choose PDF File",
"ai.prompt.dropzonePdfOnly": "Please select a PDF file only",
"ai.prompt.removeFile": "Remove file",
"ai.prompt.runSandbox": "Run OCR Sandbox",
"ai.prompt.running": "Extracting data...",
"ai.prompt.noActivePrompt": "No active prompt found. Please configure and activate a prompt before running sandbox.",
"ai.prompt.noFile": "Please select a PDF file to test",
"ai.prompt.uploadSuccess": "File uploaded — queued for sandbox OCR",
"ai.prompt.uploadError": "Failed to start sandbox",
"ai.prompt.uploading": "Uploading file for Sandbox run...",
"ai.prompt.statusPending": "Queued (Pending in BullMQ)...",
"ai.prompt.statusProcessing": "Reading file and extracting metadata with Active Prompt (Ollama running)...",
"ai.prompt.statusCompleted": "OCR Sandbox completed",
"ai.prompt.statusFailed": "OCR Sandbox failed",
"ai.prompt.statusCancelled": "Sandbox job cancelled",
"ai.prompt.sandboxSuccess": "OCR Sandbox completed (result saved to version history)",
"ai.prompt.sandboxFailed": "OCR Sandbox run failed",
"ai.prompt.sandboxCancelled": "Sandbox job was cancelled",
"ai.prompt.resultTitle": "Extracted JSON Metadata",
"ai.prompt.resultVersionBadge": "Extracted with v{{version}}",
"ai.prompt.noteCardTitle": "Add Evaluation Note for This Version (Manual Annotation)",
"ai.prompt.notePlaceholder": "Write analysis, differences, or suggestions for this prompt version...",
"ai.prompt.sandboxErrorTitle": "Sandbox Run Failed",
"ai.prompt.sandboxErrorDefault": "Processing timed out or an error occurred while loading the model.",
"ai.prompt.timeoutInfo": "System waits up to 120 seconds — Ollama may take time to load on cold start"
}
+53 -1
View File
@@ -91,5 +91,57 @@
"ai.staging.thresholdWarning": "ควรปรับปรุง Model หรือ Threshold",
"ai.staging.thresholdWarningDesc": "ตรวจพบอัตราการแก้ไขสูงถึง {{rate}}% ในช่วงที่ผ่านมา",
"ai.staging.thresholdNote": "* การเปลี่ยนค่า Threshold ต้องทำผ่าน Environment Variables ของ Backend",
"ai.staging.thresholdDocs": "อ่านคู่มือการตั้งค่า"
"ai.staging.thresholdDocs": "อ่านคู่มือการตั้งค่า",
"ai.prompt.tabEditor": "Prompt Template Editor",
"ai.prompt.tabSandbox": "OCR Sandbox Runner",
"ai.prompt.cardTitle": "Prompt Template",
"ai.prompt.activeLabel": "Active: v{{version}}",
"ai.prompt.editorPlaceholder": "เขียน Prompt template พร้อม {{ocr_text}} ที่นี่...",
"ai.prompt.placeholderOk": "✓ มี {{ocr_text}} placeholder ครบถ้วน",
"ai.prompt.placeholderMissing": "✗ ขาด {{ocr_text}} placeholder",
"ai.prompt.charCount": "{{count}} / 4000 ตัวอักษร",
"ai.prompt.saveVersion": "บันทึก Version ใหม่ (Save Draft)",
"ai.prompt.saveVersionSuccess": "บันทึก Version ใหม่สำเร็จ (ร่าง)",
"ai.prompt.saveVersionError": "เกิดข้อผิดพลาดในการบันทึก Prompt",
"ai.prompt.placeholderError": "template ต้องมี {{ocr_text}} placeholder",
"ai.prompt.charLimitError": "Template exceeds 4,000 character limit",
"ai.prompt.loadSuccess": "โหลดเนื้อหาของ v{{version}} เข้าสู่ Editor แล้ว",
"ai.prompt.activateSuccess": "เปิดใช้งาน Prompt Version v{{version}} เป็นหลักแล้ว",
"ai.prompt.activateError": "เกิดข้อผิดพลาดในการ activate",
"ai.prompt.deleteConfirm": "ต้องการลบ v{{version}} ใช่หรือไม่?",
"ai.prompt.deleteSuccess": "ลบ Prompt Version v{{version}} สำเร็จ",
"ai.prompt.deleteError": "เกิดข้อผิดพลาดในการลบ",
"ai.prompt.deleteActiveError": "ไม่สามารถลบ active version ได้",
"ai.prompt.saveNote": "บันทึกหมายเหตุ v{{version}}",
"ai.prompt.saveNoteSuccess": "บันทึก Manual Note สำเร็จ",
"ai.prompt.saveNoteError": "ไม่สามารถบันทึกหมายเหตุได้",
"ai.prompt.sandboxCardTitle": "ทดสอบ OCR Sandbox ด้วย Active Prompt",
"ai.prompt.sandboxCardDesc": "สุ่มและอัปโหลดไฟล์ PDF เพื่อเปรียบเทียบหรือสกัดโครงสร้างเมตาดาต้า และประเมินผล",
"ai.prompt.dropzoneDrag": "ลากและวางไฟล์ PDF หรือคลิกด้านล่างเพื่ออัปโหลด",
"ai.prompt.dropzoneChoose": "เลือกไฟล์ PDF",
"ai.prompt.dropzonePdfOnly": "กรุณาเลือกไฟล์ PDF เท่านั้น",
"ai.prompt.removeFile": "ลบไฟล์",
"ai.prompt.runSandbox": "เริ่มประมวลผล OCR Sandbox",
"ai.prompt.running": "กำลังสกัดข้อมูล...",
"ai.prompt.noActivePrompt": "ไม่พบ active prompt กรุณาตั้งค่าและเปิดใช้งาน prompt ก่อนรัน sandbox",
"ai.prompt.noFile": "กรุณาเลือกไฟล์ PDF สำหรับทดสอบ",
"ai.prompt.uploadSuccess": "อัปโหลดไฟล์สำเร็จ เข้าสู่คิว sandbox OCR",
"ai.prompt.uploadError": "เกิดข้อผิดพลาดในการเริ่ม sandbox",
"ai.prompt.uploading": "กำลังอัปโหลดไฟล์สำหรับรัน Sandbox...",
"ai.prompt.statusPending": "อยู่ในคิวรอดำเนินการ (Pending in BullMQ)...",
"ai.prompt.statusProcessing": "กำลังอ่านไฟล์และใช้ Active Prompt สกัดเมตาดาต้า (สิทธิ์รัน Ollama)...",
"ai.prompt.statusCompleted": "ประมวลผล OCR Sandbox เสร็จสิ้น",
"ai.prompt.statusFailed": "OCR Sandbox ล้มเหลว",
"ai.prompt.statusCancelled": "การทำงานถูกยกเลิก",
"ai.prompt.sandboxSuccess": "ทำ OCR Sandbox สำเร็จ (ข้อมูลเซฟลงประวัติเวอร์ชันแล้ว)",
"ai.prompt.sandboxFailed": "การรัน OCR Sandbox เกิดข้อผิดพลาด",
"ai.prompt.sandboxCancelled": "Sandbox job ถูกยกเลิก",
"ai.prompt.resultTitle": "ผลลัพธ์โครงสร้างข้อมูล JSON ที่ถอดออกมาได้",
"ai.prompt.resultVersionBadge": "ถอดด้วย v{{version}}",
"ai.prompt.noteCardTitle": "เพิ่มข้อเขียนประเมินสำหรับเวอร์ชันนี้ (Manual Annotation Note)",
"ai.prompt.notePlaceholder": "เขียนวิเคราะห์ความแตกต่างหรือข้อเสนอแนะเกี่ยวกับผลลัพธ์ของ prompt vนี้...",
"ai.prompt.sandboxErrorTitle": "รัน Sandbox ล้มเหลว",
"ai.prompt.sandboxErrorDefault": "ระบบใช้เวลาประมวลผลนานเกินกำหนดหรือเกิดข้อผิดพลาดในการโหลดโมเดล",
"ai.prompt.timeoutInfo": "ระบบรอผลสูงสุด 120 วินาที — Ollama อาจใช้เวลาโหลดโมเดลเมื่อเริ่มต้นใหม่"
}
+29
View File
@@ -0,0 +1,29 @@
// File: frontend/types/ai-prompts.ts
// Change Log
// - 2026-05-25: Created types for dynamic prompt management (ADR-029)
/**
* Interface Prompt Version
*/
export interface AiPrompt {
promptType: string;
versionNumber: number;
template: string;
isActive: boolean;
testResultJson: Record<string, unknown> | null;
manualNote: string | null;
lastTestedAt: string | null;
activatedAt: string | null;
createdAt: string;
}
/**
* Interface Sandbox OCR
*/
export interface SandboxResult {
requestPublicId: string;
status: 'processing' | 'completed' | 'failed';
answer?: string;
errorMessage?: string;
completedAt?: string;
}