16 KiB
Feature Specification: OCR Sandbox Two-Step Flow (OCR-First → AI-Second)
Feature Branch: main
Created: 2026-05-30
Status: Draft
Input: User requirement: แยก OCR Sandbox เป็น 2 step — Step 1 OCR เท่านั้นเพื่อตรวจคุณภาพ OCR ก่อน → Step 2 AI Extraction เพื่อทดสอบ prompt
User Scenarios & Testing (mandatory)
User Story 1 - OCR Quality Check Before AI Testing (Priority: P1)
ในฐานะ ผู้ดูแลระบบ (Superadmin) ข้าพเจ้าต้องการรัน OCR บน PDF เพื่อตรวจสอบคุณภาพข้อความที่สกัดได้ก่อน เพื่อยืนยันว่า OCR ทำงานถูกต้องและข้อความสมบูรณ์ ก่อนที่จะใช้ข้อความนั้นทดสอบ AI prompt template
Why this priority: การแยก step ช่วยให้ admin แยกปัญหาได้ชัดเจน — ถ้า OCR แย่/ไม่สมบูรณ์ ไม่ต้องเสียเวลาทดสอบ prompt ให้เสียทรัพยากร AI
Independent Test: upload PDF → กด "Step 1: Run OCR" → เห็น OCR Raw Text → ตรวจคุณภาพ → ถ้าพอใจ → กด "Step 2: Run AI Extraction" → เห็น LLM Result
Acceptance Scenarios:
- Given admin upload PDF ใน OCR Sandbox, When กด "Step 1: Run OCR", Then ระบบรัน OCR (PaddleOCR/Fast Path) และแสดง OCR Raw Text เท่านั้น ยังไม่เรียก LLM
- Given OCR Raw Text ปรากฏแล้ว, When admin ตรวจและพอใจกับคุณภาพ, Then admin สามารถกด "Step 2: Run AI Extraction" เพื่อส่ง OCR text ไป LLM ต่อ
- Given OCR Raw Text แย่/ไม่สมบูรณ์, When admin ไม่พอใจ, Then admin สามารถ upload PDF ใหม่และรัน OCR ใหม่โดยไม่เสียทรัพยากร AI
- Given admin อยู่ใน Step 2, When admin เปลี่ยนใจต้องการแก้ prompt version, Then admin สามารถเลือก prompt version อื่นจาก dropdown และรัน AI Extraction ใหม่ด้วย OCR text เดิม
User Story 2 - Prompt Version Testing with Same OCR Text (Priority: P2)
ในฐานะ ผู้ดูแลระบบ (Superadmin) ข้าพเจ้าต้องการทดสอบ prompt version หลาย version ด้วย OCR text เดียวกัน เพื่อเปรียบเทียบคุณภาพของ prompt versions ที่ต่างกัน
Why this priority: ช่วยให้ admin evaluate prompt versions ได้รวดเร็วโดยไม่ต้องรัน OCR ซ้ำหลายครั้ง
Independent Test: run OCR ครั้งเดียว → เลือก prompt v1 → run AI → เลือก prompt v2 → run AI → เปรียบเทียบผลลัพธ์
Acceptance Scenarios:
- Given OCR Raw Text ถูกสกัดแล้ว, When admin เลือก prompt version v1 และกด "Run AI Extraction", Then ระบบใช้ prompt v1 กับ OCR text เดิม
- Given ผลลัพธ์จาก v1 ปรากฏ, When admin เลือก prompt version v2 และกด "Run AI Extraction" อีกครั้ง, Then ระบบใช้ prompt v2 กับ OCR text เดิม (ไม่รัน OCR ซ้ำ)
- Given admin อยากเปลี่ยน OCR text, When admin upload PDF ใหม่และกด "Step 1: Run OCR", Then OCR text ใหม่แทนที่เดิมและ step 2 ถูก reset
Requirements (mandatory)
Functional Requirements
- FR-001: ระบบ MUST มี job type ใหม่
sandbox-ocr-onlyที่ทำ OCR เท่านั้น ไม่เรียก LLM - FR-002: ระบบ MUST มี job type ใหม่
sandbox-ai-extractที่รับ OCR text + prompt version แล้ว run LLM - FR-003: ระบบ MUST เก็บ OCR text ใน Redis (TTL 3600s) หลังจาก Step 1 เสร็จ เพื่อใช้ใน Step 2
- FR-004: Frontend MUST แสดง UI แบบ 2 step แยกกัน — Step 1: OCR, Step 2: AI Extraction
- FR-005: Step 2 MUST มี dropdown เลือก prompt version (default = active version)
- FR-006: ระบบ MUST อนุญาตให้รัน Step 2 ซ้ำด้วย prompt version ต่างกันโดยใช้ OCR text เดิม
- FR-007: ระบบ MUST invalidate OCR text cache เมื่อ admin upload PDF ใหม่และรัน Step 1 ใหม่
Key Entities
- OCR Cache: Redis key
ai:sandbox:ocr:{requestPublicId}TTL 3600s — เก็บ OCR text และ metadata (ocrUsed, timestamp) - Sandbox OCR Job: BullMQ job type
sandbox-ocr-only— รัน OCR เท่านั้น - Sandbox AI Job: BullMQ job type
sandbox-ai-extract— รัน LLM ด้วย OCR text + prompt version
Success Criteria (mandatory)
Measurable Outcomes
- SC-001: Step 1 (OCR) ใช้เวลา < 10 วินาทีสำหรับ PDF ทั่วไป
- SC-002: Step 2 (AI) ใช้เวลา < 120 วินาที (เหมือน sandbox-extract เดิม)
- SC-003: Admin สามารถทดสอบ prompt version 3 version ด้วย OCR text เดิมภายใน 5 นาที
- SC-004: OCR text cache ถูก invalidate อัตโนมัติเมื่อ upload PDF ใหม่
API Design
POST /ai/admin/sandbox/ocr (Step 1)
Request:
file: PDF (multipart/form-data)
Response:
{
"requestPublicId": "uuid",
"jobId": "uuid",
"status": "queued"
}
Behavior:
- Upload PDF → storage temp
- Enqueue job
sandbox-ocr-only - Return requestPublicId สำหรับ polling
POST /ai/admin/sandbox/ai-extract (Step 2)
Request:
{
"requestPublicId": "uuid",
"promptVersion": 2 // optional, default = active
}
Response:
{
"requestPublicId": "uuid",
"jobId": "uuid",
"status": "queued"
}
Behavior:
- ดึง OCR text จาก Redis cache (
ai:sandbox:ocr:{requestPublicId}) - ถ้าไม่มี → throw 404 "OCR text not found or expired, please run Step 1 first"
- ดึง prompt version (default = active)
- Enqueue job
sandbox-ai-extract - Return requestPublicId สำหรับ polling
Backend Implementation
New Job Types
export type AiBatchJobType =
| 'ocr'
| 'extract-metadata'
| 'embed-document'
| 'sandbox-rag'
| 'sandbox-extract' // legacy (OCR + AI in one job)
| 'sandbox-ocr-only' // NEW: Step 1 - OCR only
| 'sandbox-ai-extract' // NEW: Step 2 - AI extraction with cached OCR
| 'migrate-document';
processSandboxOcrOnly()
private async processSandboxOcrOnly(data: AiBatchJobData): Promise<void> {
const { idempotencyKey, payload } = data;
const pdfPath = payload.pdfPath as string;
const ocrResult = await this.ocrService.detectAndExtract({ pdfPath });
// Cache OCR text for Step 2
await this.redis.setex(
`ai:sandbox:ocr:${idempotencyKey}`,
3600,
JSON.stringify({
ocrText: ocrResult.text,
ocrUsed: ocrResult.ocrUsed,
timestamp: new Date().toISOString(),
})
);
await this.redis.setex(
`ai:rag:result:${idempotencyKey}`,
3600,
JSON.stringify({
requestPublicId: idempotencyKey,
status: 'completed',
ocrText: ocrResult.text,
ocrUsed: ocrResult.ocrUsed,
completedAt: new Date().toISOString(),
})
);
}
processSandboxAiExtract()
private async processSandboxAiExtract(data: AiBatchJobData): Promise<void> {
const { idempotencyKey, payload, projectPublicId } = data;
const promptVersion = (payload.promptVersion as number) || undefined;
// ดึง OCR text จาก cache
const cachedOcr = await this.redis.get(`ai:sandbox:ocr:${idempotencyKey}`);
if (!cachedOcr) {
throw new Error('OCR text not found or expired, please run Step 1 first');
}
const { ocrText } = JSON.parse(cachedOcr);
// ดึง prompt version
const activePrompt = await this.aiPromptsService.getActive('ocr_extraction');
if (!activePrompt) {
throw new Error('No active ocr_extraction prompt version found');
}
// ถ้าระบุ promptVersion ให้ใช้ version นั้น (แต่ต้อง validate ว่ามีอยู่)
const targetPrompt = promptVersion
? await this.aiPromptsService.findByVersion('ocr_extraction', promptVersion)
: activePrompt;
if (!targetPrompt) {
throw new Error(`Prompt version ${promptVersion} not found`);
}
// Resolve context และ run LLM (เหมือน processSandboxExtract เดิม)
const masterDataContext = await this.aiPromptsService.resolveContext(
targetPrompt,
projectPublicId
);
const resolvedPrompt = targetPrompt.template
.replace('{{ocr_text}}', ocrText)
.replace('{{master_data_context}}', JSON.stringify(masterDataContext, null, 2));
const response = await this.ollamaService.generate(resolvedPrompt, {
timeoutMs: 120000,
});
const cleanedResponse = response
.replace(/```json/g, '')
.replace(/```/g, '')
.trim();
let extractedMetadata: Record<string, unknown>;
try {
extractedMetadata = JSON.parse(cleanedResponse) as Record<string, unknown>;
} catch {
throw new Error(`Failed to parse LLM response as JSON: ${cleanedResponse}`);
}
await this.redis.setex(
`ai:rag:result:${idempotencyKey}`,
3600,
JSON.stringify({
requestPublicId: idempotencyKey,
status: 'completed',
answer: JSON.stringify(extractedMetadata, null, 2),
ocrText,
ocrUsed: JSON.parse(cachedOcr).ocrUsed,
promptVersionUsed: targetPrompt.versionNumber,
completedAt: new Date().toISOString(),
})
);
}
Frontend Implementation
UI Layout
┌─────────────────────────────────────────────────────┐
│ OCR Sandbox Playground │
├──────────────────────┬──────────────────────────────┤
│ Prompt Editor │ Version History │
│ ┌────────────────┐ │ ┌────────────────────────┐ │
│ │ textarea │ │ │ v3 (active) ✅ │ │
│ │ {{ocr_text}} │ │ │ v2 - 2026-05-24 │ │
│ │ ... │ │ │ v1 - 2026-05-22 │ │
│ └────────────────┘ │ └────────────────────────┘ │
│ [บันทึก Version ใหม่]│ [Load] [Activate] [Delete] │
├──────────────────────┴──────────────────────────────┤
│ Step 1: OCR Quality Check │
│ ┌──────────────────────────────────────────────┐ │
│ │ File Upload: [เลือก PDF] │ │
│ │ [Step 1: Run OCR] │ │
│ └──────────────────────────────────────────────┘ │
│ [OCR Raw Text Display] │
├─────────────────────────────────────────────────────┤
│ Step 2: AI Extraction (disabled until Step 1) │
│ ┌──────────────────────────────────────────────┐ │
│ │ Prompt Version: [v3 (active) ▼] │ │
│ │ [Step 2: Run AI Extraction] │ │
│ └──────────────────────────────────────────────┘ │
│ [LLM Result Display] │
└─────────────────────────────────────────────────────┘
State Management
const [ocrRequestPublicId, setOcrRequestPublicId] = useState<string | null>(null);
const [ocrText, setOcrText] = useState<string>('');
const [ocrUsed, setOcrUsed] = useState<boolean>(false);
const [aiRequestPublicId, setAiRequestPublicId] = useState<string | null>(null);
const [selectedPromptVersion, setSelectedPromptVersion] = useState<number | undefined>(undefined);
const [step, setStep] = useState<'upload' | 'ocr-done' | 'ai-done'>('upload');
Step 1: Run OCR
const handleRunOcr = async () => {
const response = await adminAiService.submitSandboxOcr(file);
setOcrRequestPublicId(response.requestPublicId);
// Poll for result...
};
Step 2: Run AI Extraction
const handleRunAi = async () => {
const response = await adminAiService.submitSandboxAiExtract({
requestPublicId: ocrRequestPublicId,
promptVersion: selectedPromptVersion,
});
setAiRequestPublicId(response.requestPublicId);
// Poll for result...
};
ADR Impact
- ADR-029: เพิ่ม job types ใหม่ แต่ไม่เปลี่ยน architecture หลักของ
ai_promptstable - ADR-030: ไม่กระทบ context resolution logic ยังใช้
resolveContext()เหมือนเดิม - ADR-023A: ไม่กระทบ AI boundary ยังใช้ Ollama ผ่าน BullMQ เหมือนเดิม
Migration Plan
Phase 1: Backend
- เพิ่ม job types ใหม่ใน
AiBatchJobType - Implement
processSandboxOcrOnly()ในAiBatchProcessor - Implement
processSandboxAiExtract()ในAiBatchProcessor - เพิ่ม endpoint
POST /ai/admin/sandbox/ocrในAiController - เพิ่ม endpoint
POST /ai/admin/sandbox/ai-extractในAiController - เพิ่ม method
findByVersion()ในAiPromptsService(ถ้ายังไม่มี)
Phase 2: Frontend
- เพิ่ม methods ใหม่ใน
adminAiService:submitSandboxOcr(file)submitSandboxAiExtract({ requestPublicId, promptVersion })
- Refactor
OcrSandboxPromptManager.tsx:- เพิ่ม state สำหรับ step management
- เพิ่ม UI Step 1 + Step 2 แยกกัน
- เพิ่ม dropdown prompt version ใน Step 2
- Update polling logic ให้รองรับ 2 requestPublicId แยกกัน
Phase 3: Testing
- Unit tests สำหรับ
processSandboxOcrOnly()และprocessSandboxAiExtract() - Integration tests สำหรับ OCR cache invalidation
- E2E tests สำหรับ 2-step flow
Rollback Plan
ถ้า feature นี้มีปัญหา:
- สามารถ rollback โดยใช้ legacy endpoint
POST /ai/admin/sandbox/extract(sandbox-extract) ที่ยังคงอยู่ - หรือ comment out new endpoints และ UI changes