690618:1444 237 #02
CI / CD Pipeline / build (push) Successful in 7m5s
CI / CD Pipeline / deploy (push) Failing after 20m14s

This commit is contained in:
2026-06-18 14:44:46 +07:00
parent 037fbb65f5
commit 09e304de84
52 changed files with 4471 additions and 1038 deletions
@@ -0,0 +1,217 @@
// File: frontend/components/admin/ai/AiExtractionPromptTab.tsx
// Change Log
// - 2026-06-17: Created AiExtractionPromptTab for AI extraction prompt management (Feature 238)
// - 2026-06-18: Fixed linting errors (no-console, no-unused-vars, no-explicit-any)
'use client';
import { useState, useEffect } from 'react';
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { adminAiPromptService, AiPromptVersion } from '@/lib/services/admin-ai-prompt.service';
import PromptVersionHistory from './PromptVersionHistory';
import { RefreshCw, Save, AlertCircle } from 'lucide-react';
import { AiPrompt } from '@/types/ai-prompts';
/**
* Component สำหรับจัดการ AI Extraction Prompt
* - แสดง version history
* - แก้ไข template (ต้องมี {{ocr_text}} placeholder)
* - บันทึก version ใหม่
* - เปิดใช้งาน version ที่ต้องการ
*/
export function AiExtractionPromptTab() {
const [versions, setVersions] = useState<AiPromptVersion[]>([]);
const [activeVersion, setActiveVersion] = useState<AiPromptVersion | null>(null);
const [newTemplate, setNewTemplate] = useState('');
const [isSaving, setIsSaving] = useState(false);
const [isActivating, setIsActivating] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [showRefreshDialog, setShowRefreshDialog] = useState(false);
const loadVersions = async () => {
try {
const data = await adminAiPromptService.getPrompts('ocr_extraction');
setVersions(data);
const active = data.find((v) => v.isActive);
setActiveVersion(active || null);
setNewTemplate(active?.template || '');
setError(null);
} catch {
setError('Failed to load prompt versions');
}
};
useEffect(() => {
loadVersions();
}, []);
const handleSaveNewVersion = async () => {
if (!newTemplate.trim()) {
setError('Template cannot be empty');
return;
}
if (!newTemplate.includes('{{ocr_text}}')) {
setError('Template must include {{ocr_text}} placeholder');
return;
}
setIsSaving(true);
setError(null);
try {
await adminAiPromptService.createPrompt('ocr_extraction', newTemplate);
await loadVersions();
} catch (err: unknown) {
if (err instanceof Error && err.message.includes('409')) {
setShowRefreshDialog(true);
setError('Version conflict - data was modified by another user');
} else {
setError('Failed to save new version');
}
} finally {
setIsSaving(false);
}
};
const handleActivate = async (versionNumber: number) => {
const version = versions.find(v => v.versionNumber === versionNumber);
setIsActivating(true);
setError(null);
try {
await adminAiPromptService.activatePrompt('ocr_extraction', versionNumber, version?.version);
await loadVersions();
} catch (err: unknown) {
if (err instanceof Error && err.message.includes('409')) {
setShowRefreshDialog(true);
setError('Version conflict - data was modified by another user');
} else {
setError('Failed to activate version');
}
} finally {
setIsActivating(false);
}
};
const handleDelete = async (versionNumber: number) => {
setIsDeleting(true);
setError(null);
try {
await adminAiPromptService.deletePrompt('ocr_extraction', versionNumber);
await loadVersions();
} catch {
setError('Failed to delete version');
} finally {
setIsDeleting(false);
}
};
const handleLoadTemplate = (version: AiPromptVersion) => {
setNewTemplate(version.template);
};
const handleRefresh = () => {
setShowRefreshDialog(false);
loadVersions();
};
return (
<div className="space-y-6">
{error && (
<Card className="border-destructive">
<CardContent className="pt-6">
<div className="flex items-center gap-2 text-destructive">
<AlertCircle className="h-4 w-4" />
<span>{error}</span>
</div>
</CardContent>
</Card>
)}
<Card>
<CardHeader>
<CardTitle>AI Extraction Prompt Editor</CardTitle>
<CardDescription>
Extraction prompt LLM - {"{{ocr_text}}"} placeholder
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<label className="text-sm font-medium">Template</label>
<Textarea
value={newTemplate}
onChange={(e) => setNewTemplate(e.target.value)}
placeholder="Enter extraction prompt template with {{ocr_text}} placeholder..."
className="min-h-[200px] font-mono text-sm"
/>
<p className="text-xs text-muted-foreground">
Template {"{{ocr_text}}"} placeholder OCR
</p>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
{activeVersion && (
<Badge variant="outline">
Active: v{activeVersion.versionNumber}
</Badge>
)}
</div>
<Button
onClick={handleSaveNewVersion}
disabled={isSaving || !newTemplate.trim()}
>
{isSaving ? (
<>
<RefreshCw className="mr-2 h-4 w-4 animate-spin" />
Saving...
</>
) : (
<>
<Save className="mr-2 h-4 w-4" />
Save New Version
</>
)}
</Button>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Version History</CardTitle>
<CardDescription>
AI Extraction Prompt
</CardDescription>
</CardHeader>
<CardContent>
<PromptVersionHistory
versions={versions as unknown as AiPrompt[]}
isLoading={false}
onLoadTemplate={handleLoadTemplate as unknown as (version: AiPrompt) => void}
onActivateVersion={handleActivate}
onDeleteVersion={handleDelete}
isActivating={isActivating}
isDeleting={isDeleting}
/>
</CardContent>
</Card>
{showRefreshDialog && (
<Card className="border-warning">
<CardHeader>
<CardTitle className="text-warning">Data Modified</CardTitle>
<CardDescription>
</CardDescription>
</CardHeader>
<CardContent>
<Button onClick={handleRefresh}>Refresh Data</Button>
</CardContent>
</Card>
)}
</div>
);
}
@@ -1,6 +1,7 @@
// File: frontend/components/admin/ai/OcrEngineSelector.tsx
// Change Log
// - 2026-05-30: สร้าง OcrEngineSelector สำหรับดึงและสลับ OCR Engine แบบไดนามิก (T019, T020, US1)
// - 2026-06-17: ลบ Tesseract ออกจาก UI ตาม ADR-035 (เปลี่ยนเป็น Fast Path: PyMuPDF Text Layer)
'use client';
@@ -9,7 +10,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { toast } from 'sonner';
import { ScanText, Server, AlertCircle, CheckCircle2, Cpu } from 'lucide-react';
import { ScanText, Server, CheckCircle2, Cpu } from 'lucide-react';
import { adminAiService, OcrEngineResponse } from '@/lib/services/admin-ai.service';
/** Component สำหรับเลือกและจัดการ OCR Engine ในระบบ */
@@ -116,9 +117,9 @@ export default function OcrEngineSelector() {
<Cpu className="h-3 w-3" />
VRAM: {(engine.vramRequirementMB / 1024).toFixed(1)} GB
</span>
<span className="flex items-center gap-1 text-amber-600 dark:text-amber-400">
<AlertCircle className="h-3 w-3" />
เอนจินสำรอง: Tesseract OCR
<span className="flex items-center gap-1 text-emerald-600 dark:text-emerald-400">
<CheckCircle2 className="h-3 w-3" />
Fast Path: PyMuPDF Text Layer
</span>
</>
)}
@@ -0,0 +1,212 @@
// File: frontend/components/admin/ai/OcrPromptTab.tsx
// Change Log
// - 2026-06-17: Created OcrPromptTab for OCR system prompt management (Feature 238)
// - 2026-06-18: Fixed linting errors (no-console, no-explicit-any)
'use client';
import { useState, useEffect } from 'react';
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { adminAiPromptService, AiPromptVersion } from '@/lib/services/admin-ai-prompt.service';
import PromptVersionHistory from './PromptVersionHistory';
import { RefreshCw, Save, AlertCircle } from 'lucide-react';
import { AiPrompt } from '@/types/ai-prompts';
/**
* Component สำหรับจัดการ OCR System Prompt
* - แสดง version history
* - แก้ไข template
* - บันทึก version ใหม่
* - เปิดใช้งาน version ที่ต้องการ
*/
export function OcrPromptTab() {
const [versions, setVersions] = useState<AiPromptVersion[]>([]);
const [activeVersion, setActiveVersion] = useState<AiPromptVersion | null>(null);
const [newTemplate, setNewTemplate] = useState('');
const [isSaving, setIsSaving] = useState(false);
const [isActivating, setIsActivating] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [showRefreshDialog, setShowRefreshDialog] = useState(false);
const loadVersions = async () => {
try {
const data = await adminAiPromptService.getPrompts('ocr_system');
setVersions(data);
const active = data.find((v) => v.isActive);
setActiveVersion(active || null);
setNewTemplate(active?.template || '');
setError(null);
} catch {
setError('Failed to load prompt versions');
}
};
useEffect(() => {
loadVersions();
}, []);
const handleSaveNewVersion = async () => {
if (!newTemplate.trim()) {
setError('Template cannot be empty');
return;
}
setIsSaving(true);
setError(null);
try {
await adminAiPromptService.createPrompt('ocr_system', newTemplate);
await loadVersions();
} catch (err: unknown) {
if (err instanceof Error && err.message.includes('409')) {
setShowRefreshDialog(true);
setError('Version conflict - data was modified by another user');
} else {
setError('Failed to save new version');
}
} finally {
setIsSaving(false);
}
};
const handleActivate = async (versionNumber: number) => {
const version = versions.find(v => v.versionNumber === versionNumber);
setIsActivating(true);
setError(null);
try {
await adminAiPromptService.activatePrompt('ocr_system', versionNumber, version?.version);
await loadVersions();
} catch (err: unknown) {
if (err instanceof Error && err.message.includes('409')) {
setShowRefreshDialog(true);
setError('Version conflict - data was modified by another user');
} else {
setError('Failed to activate version');
}
} finally {
setIsActivating(false);
}
};
const handleRefresh = () => {
setShowRefreshDialog(false);
loadVersions();
};
const handleDelete = async (versionNumber: number) => {
setIsDeleting(true);
setError(null);
try {
await adminAiPromptService.deletePrompt('ocr_system', versionNumber);
await loadVersions();
} catch {
setError('Failed to delete version');
} finally {
setIsDeleting(false);
}
};
const handleLoadTemplate = (version: AiPromptVersion) => {
setNewTemplate(version.template);
};
return (
<div className="space-y-6">
{error && (
<Card className="border-destructive">
<CardContent className="pt-6">
<div className="flex items-center gap-2 text-destructive">
<AlertCircle className="h-4 w-4" />
<span>{error}</span>
</div>
</CardContent>
</Card>
)}
<Card>
<CardHeader>
<CardTitle>OCR System Prompt Editor</CardTitle>
<CardDescription>
System prompt OCR engine (np-dms-ocr) - PDF
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<label className="text-sm font-medium">Template</label>
<Textarea
value={newTemplate}
onChange={(e) => setNewTemplate(e.target.value)}
placeholder="Enter OCR system prompt template..."
className="min-h-[200px] font-mono text-sm"
/>
<p className="text-xs text-muted-foreground">
OCR system prompt free-form text placeholder
</p>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
{activeVersion && (
<Badge variant="outline">
Active: v{activeVersion.versionNumber}
</Badge>
)}
</div>
<Button
onClick={handleSaveNewVersion}
disabled={isSaving || !newTemplate.trim()}
>
{isSaving ? (
<>
<RefreshCw className="mr-2 h-4 w-4 animate-spin" />
Saving...
</>
) : (
<>
<Save className="mr-2 h-4 w-4" />
Save New Version
</>
)}
</Button>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Version History</CardTitle>
<CardDescription>
OCR System Prompt
</CardDescription>
</CardHeader>
<CardContent>
<PromptVersionHistory
versions={versions as unknown as AiPrompt[]}
isLoading={false}
onLoadTemplate={handleLoadTemplate as unknown as (version: AiPrompt) => void}
onActivateVersion={handleActivate}
onDeleteVersion={handleDelete}
isActivating={isActivating}
isDeleting={isDeleting}
/>
</CardContent>
</Card>
{showRefreshDialog && (
<Card className="border-warning">
<CardHeader>
<CardTitle className="text-warning">Data Modified</CardTitle>
<CardDescription>
</CardDescription>
</CardHeader>
<CardContent>
<Button onClick={handleRefresh}>Refresh Data</Button>
</CardContent>
</Card>
)}
</div>
);
}
@@ -16,6 +16,7 @@
// - 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
// - 2026-06-17: ADR-036 Gap 5 — แก้ไขให้ Step 1 (OCR) ไม่ต้องเลือก project (OCR เป็นแค่ text extraction); Step 2 (AI Extract) เท่านั้นที่ต้องเลือก project
'use client';
@@ -343,13 +344,9 @@ export default function OcrSandboxPromptManager() {
toast.error(error.response?.data?.message || t('ai.prompt.saveNoteError'));
}
};
// Step 1: OCR-only handler
// Step 1: OCR-only handler (ไม่ต้องเลือก project - OCR เป็นแค่ text extraction)
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;
@@ -780,7 +777,7 @@ export default function OcrSandboxPromptManager() {
<div className="flex justify-end gap-3 pt-2">
<Button
type="submit"
disabled={sandboxState.isRunning || !ocrFile || !selectedProjectPublicId}
disabled={sandboxState.isRunning || !ocrFile}
className="flex items-center gap-2"
>
{sandboxState.isRunning ? (
@@ -51,6 +51,8 @@ export default function PromptEditor({
const getFriendlyTypeName = (type: PromptType) => {
switch (type) {
case 'ocr_system':
return 'คำสั่งระบบ OCR (OCR System Prompt)';
case 'ocr_extraction':
return 'สกัดข้อความ OCR (OCR Extraction)';
case 'rag_query_prompt':
@@ -0,0 +1,34 @@
// File: frontend/components/admin/ai/PromptManagementTabs.tsx
// Change Log
// - 2026-06-17: Created PromptManagementTabs for OCR & AI Extraction prompt separation (Feature 238)
'use client';
import { useState } from 'react';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { OcrPromptTab } from './OcrPromptTab';
import { AiExtractionPromptTab } from './AiExtractionPromptTab';
/**
* Component หลักสำหรับจัดการ Prompt Management แบบแยก Tab
* - OCR System Prompt Tab: จัดการ system prompt สำหรับ OCR engine
* - AI Extraction Prompt Tab: จัดการ extraction prompt สำหรับ LLM
*/
export function PromptManagementTabs() {
const [activeTab, setActiveTab] = useState('ocr-system');
return (
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="ocr-system">OCR System Prompt</TabsTrigger>
<TabsTrigger value="ai-extraction">AI Extraction Prompt</TabsTrigger>
</TabsList>
<TabsContent value="ocr-system">
<OcrPromptTab />
</TabsContent>
<TabsContent value="ai-extraction">
<AiExtractionPromptTab />
</TabsContent>
</Tabs>
);
}
@@ -47,6 +47,9 @@ export default function PromptTypeDropdown({
{t('prompt_management.all_types')}
</SelectItem>
)}
<SelectItem value="ocr_system">
OCR (OCR System Prompt)
</SelectItem>
<SelectItem value="ocr_extraction">
OCR (OCR Extraction)
</SelectItem>
+43 -6
View File
@@ -50,7 +50,7 @@ interface SandboxJobResult {
status?: string;
errorMessage?: string;
ragChunks?: Array<{ text: string; summary: string }>;
ragVectors?: unknown[];
ragVectors?: number[][];
}
export default function SandboxTabs({
@@ -80,7 +80,13 @@ export default function SandboxTabs({
const [ocrText, setOcrText] = useState<string>('');
const [extractedMetadata, setExtractedMetadata] = useState<Record<string, unknown> | null>(null);
const [ragChunks, setRagChunks] = useState<Array<{ text: string; summary: string }> | null>(null);
const [ragVectorsCount, setRagVectorsCount] = useState<number>(0);
const [ragVectors, setRagVectors] = useState<number[][] | null>(null);
// Track step completion status for activation gating (gap-2)
const [step1Complete, setStep1Complete] = useState<boolean>(false);
const [step2Complete, setStep2Complete] = useState<boolean>(false);
const [step3Complete, setStep3Complete] = useState<boolean>(false);
const allStepsComplete = step1Complete && step2Complete && step3Complete;
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files[0]) {
@@ -92,6 +98,10 @@ export default function SandboxTabs({
setCurrentStep(1);
setJobStatus('idle');
setProgress(0);
// Reset step completion flags (gap-2)
setStep1Complete(false);
setStep2Complete(false);
setStep3Complete(false);
}
};
@@ -103,6 +113,10 @@ export default function SandboxTabs({
clearInterval(interval);
setJobStatus('completed');
setProgress(100);
// Mark step as complete (gap-2)
if (step === 1) setStep1Complete(true);
if (step === 2) setStep2Complete(true);
if (step === 3) setStep3Complete(true);
onSuccess(res as SandboxJobResult);
} else if (res.status === 'failed') {
clearInterval(interval);
@@ -192,7 +206,7 @@ export default function SandboxTabs({
const res = await adminAiService.submitSandboxRagPrep(ocrText);
pollJobStatus(res.jobId, 3, (result) => {
setRagChunks(result.ragChunks || []);
setRagVectorsCount(result.ragVectors ? result.ragVectors.length : 0);
setRagVectors(result.ragVectors || null);
toast.success('วิเคราะห์การเตรียมข้อมูล RAG สำเร็จ');
});
} catch (_err) {
@@ -239,6 +253,20 @@ export default function SandboxTabs({
<p className="text-[10px] text-muted-foreground italic"> Version History template</p>
)}
</div>
{/* UI fallback warning when no active OCR system prompt (gap-3) */}
{_promptType === 'ocr_system' && !selectedTemplate && (
<div className="rounded-lg border border-amber-500/30 bg-amber-500/[0.05] px-4 py-3 space-y-1.5">
<div className="flex items-center gap-2">
<span className="text-[11px] font-semibold text-amber-600 dark:text-amber-400">
คำเตือน: ไม่มี OCR System Prompt
</span>
</div>
<p className="text-[10px] text-amber-700 dark:text-amber-300 leading-relaxed">
(default) OCR OCR System Prompt
</p>
</div>
)}
<div className="flex flex-wrap items-center gap-4 border-b border-border/10 pb-4">
<div className="flex-1 min-w-[200px] space-y-1">
<Label className="text-[11px] font-semibold text-muted-foreground"></Label>
@@ -422,10 +450,12 @@ export default function SandboxTabs({
variant="outline"
size="sm"
onClick={handleActivate}
className="h-8 text-xs border-emerald-500/30 text-emerald-500 hover:bg-emerald-500/10"
disabled={!allStepsComplete}
className="h-8 text-xs border-emerald-500/30 text-emerald-500 hover:bg-emerald-500/10 disabled:opacity-50 disabled:cursor-not-allowed"
title={!allStepsComplete ? "ต้องทำครบทั้ง 3 ขั้นตอน (OCR → AI Extract → RAG Prep) ก่อนเปิดใช้งาน" : ""}
>
<CheckCircle className="mr-1.5 h-3.5 w-3.5" />
v{selectedVersionNumber}
v{selectedVersionNumber} {allStepsComplete ? 'ทันที' : '(ต้องทำครบ 3 ขั้นตอน)'}
</Button>
)}
<div className="flex-1 text-right">
@@ -459,7 +489,7 @@ export default function SandboxTabs({
<div className="flex justify-between items-center bg-secondary/40 border border-border/50 px-3 py-2 rounded text-xs select-none">
<span className="font-semibold text-foreground flex items-center gap-1">
<CheckCircle className="h-4 w-4 text-emerald-500" />
: {ragVectorsCount}
: {ragVectors ? ragVectors.length : 0}
</span>
<Badge variant="outline" className="text-[10px] border-border/50"> chunks: {ragChunks.length}</Badge>
</div>
@@ -471,6 +501,13 @@ export default function SandboxTabs({
<Badge className="text-[8px] py-0 px-1 select-none">{chunk.summary || 'หัวข้อหลัก'}</Badge>
</div>
<p className="leading-relaxed text-muted-foreground">{chunk.text}</p>
{ragVectors && ragVectors[idx] && (
<div className="mt-2 pt-2 border-t border-border/20">
<span className="text-[9px] text-muted-foreground font-mono">
Vector (first 5 dims): [{ragVectors[idx].slice(0, 5).map(v => v.toFixed(3)).join(', ')}...]
</span>
</div>
)}
</div>
))}
</div>
@@ -1,6 +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)
import React, { useState } from 'react';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
@@ -253,9 +254,8 @@ export default function SandboxTestArea({
<SelectValue placeholder="เลือกเอนจิน..." />
</SelectTrigger>
<SelectContent>
<SelectItem value="auto" className="text-xs">Auto (Baseline)</SelectItem>
<SelectItem value="tesseract" className="text-xs">Tesseract (CPU)</SelectItem>
<SelectItem value="np-dms-ocr" className="text-xs">Typhoon OCR (GPU)</SelectItem>
<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>
</SelectContent>
</Select>
</div>