From b79895e6fb669eee03747e83efd729e136a5aa76 Mon Sep 17 00:00:00 2001 From: admin Date: Thu, 4 Jun 2026 10:08:22 +0700 Subject: [PATCH] 690604:1008 ADR-034-134 #04 --- backend/src/modules/ai/ai.controller.ts | 34 ++++++++++-- .../ai/processors/typhoon-ocr.processor.ts | 16 ++++-- .../src/modules/ai/services/ocr.service.ts | 3 +- .../ai/services/sandbox-ocr-engine.service.ts | 27 +++++++--- .../admin/ai/OcrSandboxPromptManager.tsx | 51 +++++++++++++++++- frontend/lib/services/admin-ai.service.ts | 12 ++++- .../Desk-5439/ocr-sidecar/app.py | 53 +++++++++++-------- 7 files changed, 160 insertions(+), 36 deletions(-) diff --git a/backend/src/modules/ai/ai.controller.ts b/backend/src/modules/ai/ai.controller.ts index cb5ce996..c41d7c7d 100644 --- a/backend/src/modules/ai/ai.controller.ts +++ b/backend/src/modules/ai/ai.controller.ts @@ -545,9 +545,24 @@ export class AiController { }, engineType: { type: 'string', - enum: ['auto', 'tesseract', 'typhoon-ocr-3b', 'typhoon-ocr1.5-3b'], + enum: ['auto', 'tesseract', 'typhoon-np-dms-ocr'], description: 'OCR engine ที่ต้องการใช้ (default: auto)', }, + temperature: { + type: 'number', + description: + 'Typhoon OCR temperature (0.0-1.0) — override Modelfile default (0.1)', + }, + topP: { + type: 'number', + description: + 'Typhoon OCR top_p (0.0-1.0) — override Modelfile default (0.1)', + }, + repeatPenalty: { + type: 'number', + description: + 'Typhoon OCR repeat_penalty — override Modelfile default (1.1)', + }, }, }, }) @@ -562,6 +577,9 @@ export class AiController { ) file: Express.Multer.File, @Body('engineType') engineType: string | undefined, + @Body('temperature') temperature: string | undefined, + @Body('topP') topP: string | undefined, + @Body('repeatPenalty') repeatPenalty: string | undefined, @CurrentUser() user: User ): Promise<{ requestPublicId: string; jobId: string; status: string }> { const attachment = await this.fileStorageService.upload(file, user.user_id); @@ -570,20 +588,30 @@ export class AiController { const validEngineTypes = [ 'auto', 'tesseract', - 'typhoon-ocr-3b', - 'typhoon-ocr1.5-3b', + 'typhoon-np-dms-ocr', ] as const; const resolvedEngineType: SandboxOcrEngineType = validEngineTypes.includes( engineType as SandboxOcrEngineType ) ? (engineType as SandboxOcrEngineType) : 'auto'; + // แปลง string จาก multipart form เป็น number (optional override) + const typhoonOptions = { + ...(temperature !== undefined && { + temperature: parseFloat(temperature), + }), + ...(topP !== undefined && { topP: parseFloat(topP) }), + ...(repeatPenalty !== undefined && { + repeatPenalty: parseFloat(repeatPenalty), + }), + }; const jobId = await this.aiQueueService.enqueueSandboxJob( 'sandbox-ocr-only', { idempotencyKey: requestPublicId, pdfPath: attachment.filePath, engineType: resolvedEngineType, + ...(Object.keys(typhoonOptions).length > 0 && { typhoonOptions }), } ); return { requestPublicId, jobId, status: 'queued' }; diff --git a/backend/src/modules/ai/processors/typhoon-ocr.processor.ts b/backend/src/modules/ai/processors/typhoon-ocr.processor.ts index d18d9fe6..699f2252 100644 --- a/backend/src/modules/ai/processors/typhoon-ocr.processor.ts +++ b/backend/src/modules/ai/processors/typhoon-ocr.processor.ts @@ -17,6 +17,7 @@ import { VramMonitorService } from '../services/vram-monitor.service'; import { SandboxOcrEngineService, SandboxOcrEngineType, + OcrTyphoonOptions, } from '../services/sandbox-ocr-engine.service'; /** ชื่อ queue สำหรับ Typhoon OCR jobs */ @@ -26,12 +27,14 @@ export const QUEUE_TYPHOON_OCR = 'typhoon-ocr'; export interface TyphoonOcrJobData { /** public path ของไฟล์ PDF ที่ต้องการ OCR */ pdfPath: string; - /** engineType: เสมอเป็น 'typhoon-ocr-3b' สำหรับ queue นี้ */ + /** engineType: 'typhoon-np-dms-ocr' สำหรับ queue นี้ */ engineType: SandboxOcrEngineType; /** idempotencyKey สำหรับ Redis result key */ idempotencyKey: string; /** documentPublicId สำหรับ audit log (optional) */ documentPublicId?: string; + /** Typhoon OCR options จาก sandbox UI เพื่อ override Modelfile defaults (optional) */ + typhoonOptions?: OcrTyphoonOptions; } // VRAM ที่ Typhoon OCR-3B ต้องการ (MB) — ตาม ADR-032 @@ -59,7 +62,13 @@ export class TyphoonOcrProcessor extends WorkerHost { /** ประมวลผล Typhoon OCR job ทีละงาน */ async process(job: Job): Promise { - const { pdfPath, engineType, idempotencyKey, documentPublicId } = job.data; + const { + pdfPath, + engineType, + idempotencyKey, + documentPublicId, + typhoonOptions, + } = job.data; const startTime = Date.now(); this.logger.log( `Typhoon OCR job started — idempotencyKey=${idempotencyKey}, engine=${engineType}` @@ -106,7 +115,8 @@ export class TyphoonOcrProcessor extends WorkerHost { try { const result = await this.sandboxOcrEngineService.detectAndExtract( pdfPath, - engineType + engineType, + typhoonOptions ); const processingTimeMs = Date.now() - startTime; // บันทึกผลลัพธ์ใน Redis cache (24h TTL) diff --git a/backend/src/modules/ai/services/ocr.service.ts b/backend/src/modules/ai/services/ocr.service.ts index 6fd812a2..705f5eaf 100644 --- a/backend/src/modules/ai/services/ocr.service.ts +++ b/backend/src/modules/ai/services/ocr.service.ts @@ -10,6 +10,7 @@ // - 2026-06-01: ปรับปรุง remapPath ให้รองรับ Windows absolute และ relative path ได้แม่นยำ 100% // - 2026-06-01: เปลี่ยน processWithTesseract/processWithTyphoon ให้ส่ง file content ผ่าน multipart ไปยัง /ocr-upload แทนการส่ง path // - 2026-06-02: ส่งค่า X-API-Key ใน request headers ไปยัง ocr-sidecar เพื่อความมั่นคงปลอดภัยสูงสุด (ADR-033, Suggestion 2) +// - 2026-06-04: ADR-034 — เปลี่ยน TYPHOON_ENGINE.engineName เป็น typhoon-np-dms-ocr:latest ตรงกับชื่อโมเดลใน Ollama import { Injectable, Logger, NotFoundException } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; @@ -77,7 +78,7 @@ const TESSERACT_ENGINE: OcrEngineConfiguration = { const TYPHOON_ENGINE: OcrEngineConfiguration = { engineId: TYPHOON_ENGINE_ID, - engineName: 'Typhoon OCR-3B', + engineName: 'typhoon-np-dms-ocr:latest', engineType: OcrEngineType.TYPHOON_OCR, isActive: true, vramRequirementMB: TYPHOON_OCR_REQUIRED_VRAM_MB, diff --git a/backend/src/modules/ai/services/sandbox-ocr-engine.service.ts b/backend/src/modules/ai/services/sandbox-ocr-engine.service.ts index 0023abae..ce5b1630 100644 --- a/backend/src/modules/ai/services/sandbox-ocr-engine.service.ts +++ b/backend/src/modules/ai/services/sandbox-ocr-engine.service.ts @@ -3,6 +3,8 @@ // - 2026-05-30: แยก SandboxOcrEngineService ออกจาก OcrService เพื่อรองรับการเลือก Typhoon OCR เฉพาะ sandbox โดยไม่กระทบ core OCR flow // - 2026-06-01: เปลี่ยนจาก remapPath + pdfPath ไปเป็น multipart file upload ไปยัง /ocr-upload (แก้ปัญหา Docker WSL2 mount) // - 2026-06-02: ส่งค่า X-API-Key ใน request headers ไปยัง ocr-sidecar เพื่อความมั่นคงปลอดภัยสูงสุด (ADR-033, Suggestion 2) +// - 2026-06-04: ADR-034 — เพิ่ม 'typhoon-np-dms-ocr' เป็น canonical SandboxOcrEngineType; legacy aliases ยังรองรับ +// - 2026-06-04: เพิ่ม OcrTyphoonOptions interface; รับ temperature/topP/repeatPenalty จาก frontend sandbox เพื่อ override Modelfile defaults import { Injectable, Logger } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; @@ -10,11 +12,14 @@ import axios from 'axios'; import * as fs from 'fs'; import { OcrService } from './ocr.service'; -export type SandboxOcrEngineType = - | 'auto' - | 'tesseract' - | 'typhoon-ocr-3b' - | 'typhoon-ocr1.5-3b'; +export type SandboxOcrEngineType = 'auto' | 'tesseract' | 'typhoon-np-dms-ocr'; + +/** ค่า parameter สำหรับ Typhoon OCR ที่ override Modelfile defaults ได้จาก sandbox UI */ +export interface OcrTyphoonOptions { + temperature?: number; + topP?: number; + repeatPenalty?: number; +} interface SandboxOcrSidecarResponse { text?: string; @@ -52,7 +57,8 @@ export class SandboxOcrEngineService { /** รัน OCR ตาม engine ที่เลือก โดย fallback กลับไป Tesseract baseline เมื่อ Typhoon ล้มเหลว */ async detectAndExtract( pdfPath: string, - engineType: SandboxOcrEngineType = 'auto' + engineType: SandboxOcrEngineType = 'auto', + typhoonOptions?: OcrTyphoonOptions ): Promise { if (engineType === 'auto' || engineType === 'tesseract') { const result = await this.ocrService.detectAndExtract({ pdfPath }); @@ -73,6 +79,15 @@ export class SandboxOcrEngineService { 'upload.pdf' ); form.append('engine', engineType); + if (typhoonOptions?.temperature !== undefined) { + form.append('temperature', String(typhoonOptions.temperature)); + } + if (typhoonOptions?.topP !== undefined) { + form.append('topP', String(typhoonOptions.topP)); + } + if (typhoonOptions?.repeatPenalty !== undefined) { + form.append('repeatPenalty', String(typhoonOptions.repeatPenalty)); + } const response = await axios.post( `${this.ocrApiUrl}/ocr-upload`, form, diff --git a/frontend/components/admin/ai/OcrSandboxPromptManager.tsx b/frontend/components/admin/ai/OcrSandboxPromptManager.tsx index cefc0df5..4b5f05f5 100644 --- a/frontend/components/admin/ai/OcrSandboxPromptManager.tsx +++ b/frontend/components/admin/ai/OcrSandboxPromptManager.tsx @@ -9,6 +9,7 @@ // - 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 ให้แสดงหน่วยความจำ VRAM แม่นยำ (T012, T013, ADR-033) // - 2026-06-04: เปลี่ยน OCR Engine dropdown จาก hardcoded เป็น dynamic โดยดึงจาก getOcrEngines() API และ map engineType → SandboxOcrEngineType +// - 2026-06-04: เพิ่ม UI sliders (temperature/topP/repeatPenalty) สำหรับ typhoon-np-dms-ocr engine; ส่งเป็น optional override ไปยัง sidecar 'use client'; import React, { useState, useEffect, useMemo } from 'react'; @@ -111,6 +112,9 @@ export default function OcrSandboxPromptManager() { // 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(), @@ -225,9 +229,13 @@ export default function OcrSandboxPromptManager() { try { resetSandbox(); setSandboxStep('ocr'); + const typhoonOptions = selectedOcrEngine === 'typhoon-np-dms-ocr' + ? { temperature: typhoonTemperature, topP: typhoonTopP, repeatPenalty: typhoonRepeatPenalty } + : undefined; const { requestPublicId } = await adminAiService.submitSandboxOcr( ocrFile, - selectedOcrEngine + selectedOcrEngine, + typhoonOptions ); toast.success(t('ai.prompt.uploadSuccess')); // Poll สำหรับผลลัพธ์ OCR @@ -306,6 +314,9 @@ export default function OcrSandboxPromptManager() { setOcrResult(null); setSelectedPromptVersion(undefined); setSelectedOcrEngine('auto'); + setTyphoonTemperature(0.1); + setTyphoonTopP(0.1); + setTyphoonRepeatPenalty(1.1); setOcrFile(null); resetSandbox(); }; @@ -419,6 +430,44 @@ export default function OcrSandboxPromptManager() { ))} + {selectedOcrEngine === 'typhoon-np-dms-ocr' && ( +
+

Typhoon OCR Options (override Modelfile defaults)

+
+
+ + {typhoonTemperature.toFixed(2)} +
+ setTyphoonTemperature(parseFloat(e.target.value))} + className="w-full h-1.5 accent-amber-500" + /> +
+
+
+ + {typhoonTopP.toFixed(2)} +
+ setTyphoonTopP(parseFloat(e.target.value))} + className="w-full h-1.5 accent-amber-500" + /> +
+
+
+ + {typhoonRepeatPenalty.toFixed(2)} +
+ setTyphoonRepeatPenalty(parseFloat(e.target.value))} + className="w-full h-1.5 accent-amber-500" + /> +
+
+ )}
=> { const formData = new FormData(); formData.append('file', file); formData.append('engineType', engineType); + if (typhoonOptions?.temperature !== undefined) { + formData.append('temperature', String(typhoonOptions.temperature)); + } + if (typhoonOptions?.topP !== undefined) { + formData.append('topP', String(typhoonOptions.topP)); + } + if (typhoonOptions?.repeatPenalty !== undefined) { + formData.append('repeatPenalty', String(typhoonOptions.repeatPenalty)); + } const { data } = await api.post('/ai/admin/sandbox/ocr', formData, { headers: { 'Content-Type': 'multipart/form-data', diff --git a/specs/04-Infrastructure-OPS/04-00-docker-compose/Desk-5439/ocr-sidecar/app.py b/specs/04-Infrastructure-OPS/04-00-docker-compose/Desk-5439/ocr-sidecar/app.py index 9f8e3a13..b23fa561 100644 --- a/specs/04-Infrastructure-OPS/04-00-docker-compose/Desk-5439/ocr-sidecar/app.py +++ b/specs/04-Infrastructure-OPS/04-00-docker-compose/Desk-5439/ocr-sidecar/app.py @@ -9,6 +9,9 @@ # - 2026-06-01: เพิ่ม POST /ocr-upload รับ multipart file โดยตรง ไม่ต้องพึ่ง shared volume mount # - 2026-06-01: เปลี่ยน TYPHOON_OCR_MODEL default เป็น scb10x/typhoon-ocr1.5-3b # - 2026-06-02: เพิ่มตัวเลือกสลับโมเดลใน process_with_typhoon_ocr ตามพารามิเตอร์ engine และตั้ง engineUsed ให้ตรงตามจริง (T015, ADR-033) +# - 2026-06-04: ADR-034 — เพิ่ม typhoon-np-dms-ocr เป็น canonical engine key; default TYPHOON_OCR_MODEL เปวน typhoon-np-dms-ocr:latest; alias โมเดลเก่ายังคงไว้ +# - 2026-06-04: ให้ SYSTEM ใน Modelfile ทำงานแทน — ลบ prompt ซ้าซ้อน; sync options ให้ตรงกับ Modelfile (temperature 0.1, top_p 0.1, repeat_penalty 1.1) +# - 2026-06-04: รับค่า temperature/top_p/repeat_penalty จาก frontend sandbox ได้ (optional override) # - 2026-06-02: เพิ่มการตรวจสอบ API Key (X-API-Key Header) สำหรับ endpoints หลัก เพื่อความมั่นคงปลอดภัยตามข้อเสนอแนะ Code Review import os @@ -51,7 +54,7 @@ OCR_CHAR_THRESHOLD = int(os.getenv("OCR_CHAR_THRESHOLD", "100")) MAX_PAGES = int(os.getenv("OCR_MAX_PAGES", "0")) # 0 = ทุกหน้า OCR_LANG = os.getenv("OCR_LANG", "tha+eng") # Tesseract language code (tha+eng = Thai + English) OLLAMA_API_URL = os.getenv("OLLAMA_API_URL", "http://host.docker.internal:11434") -TYPHOON_OCR_MODEL = os.getenv("TYPHOON_OCR_MODEL", "scb10x/typhoon-ocr1.5-3b") +TYPHOON_OCR_MODEL = os.getenv("TYPHOON_OCR_MODEL", "typhoon-np-dms-ocr:latest") TYPHOON_OCR_TIMEOUT = int(os.getenv("TYPHOON_OCR_TIMEOUT", "120")) # PSM 3 = Fully automatic page segmentation (เหมาะกับเอกสารที่มี layout หลายส่วน เช่น วันที่/เลขที่) # OEM 1 = LSTM only (ดีกว่า legacy engine) @@ -137,7 +140,7 @@ def health(): return {"status": "ok", "engine": "tesseract"} -def _process_pdf_doc(doc: fitz.Document, selected_engine: str, max_pages: int) -> OcrResponse: +def _process_pdf_doc(doc: fitz.Document, selected_engine: str, max_pages: int, typhoon_options: dict = {}) -> OcrResponse: """ประมวลผล fitz.Document ด้วย engine ที่เลือก — shared logic สำหรับ /ocr และ /ocr-upload""" pages_to_process = list(range(min(len(doc), max_pages) if max_pages > 0 else len(doc))) page_count = len(pages_to_process) @@ -160,7 +163,7 @@ def _process_pdf_doc(doc: fitz.Document, selected_engine: str, max_pages: int) - engineUsed="fast-path", ) - if selected_engine in ("typhoon-ocr-3b", "typhoon-ocr1.5-3b"): + if selected_engine == "typhoon-np-dms-ocr": typhoon_text_parts = [] for i in pages_to_process: page = doc[i] @@ -169,7 +172,7 @@ def _process_pdf_doc(doc: fitz.Document, selected_engine: str, max_pages: int) - img = Image.open(io.BytesIO(img_bytes)) cropped_img = crop_header_footer(img, CROP_TOP_RATIO, CROP_BOTTOM_RATIO) processed_img = preprocess_image(cropped_img) - typhoon_text_parts.append(process_with_typhoon_ocr(processed_img, selected_engine)) + typhoon_text_parts.append(process_with_typhoon_ocr(processed_img, typhoon_options)) typhoon_text = filter_ocr_noise("\n".join(typhoon_text_parts).strip()) return OcrResponse( text=typhoon_text, @@ -202,28 +205,25 @@ def _process_pdf_doc(doc: fitz.Document, selected_engine: str, max_pages: int) - ) -def process_with_typhoon_ocr(pil_image: Image.Image, engine_type: str = "typhoon-ocr1.5-3b") -> str: - """เรียก Typhoon OCR ผ่าน Ollama สำหรับ sandbox option โดยเลือก model ตาม engine ที่ระบุ""" - model_name = "scb10x/typhoon-ocr1.5-3b" - if engine_type == "typhoon-ocr-3b": - model_name = "scb10x/typhoon-ocr-3b" - elif engine_type == "typhoon-ocr1.5-3b": - model_name = "scb10x/typhoon-ocr1.5-3b" - else: - model_name = TYPHOON_OCR_MODEL +def process_with_typhoon_ocr(pil_image: Image.Image, options_override: dict = {}) -> str: + """เรียก Typhoon OCR ผ่าน Ollama — ใช้ SYSTEM ใน Modelfile เป็น instruction หลัก; options_override ยัง override ค่า Modelfile ได้""" + model_name = TYPHOON_OCR_MODEL img_buffer = io.BytesIO() pil_image.save(img_buffer, format="PNG") image_base64 = base64.b64encode(img_buffer.getvalue()).decode("utf-8") + # ค่า default ตาม Modelfile; frontend override ได้บางส่วนหรือทั้งหมด + options = { + "temperature": 0.1, + "top_p": 0.1, + "repeat_penalty": 1.1, + **options_override, + } payload = { "model": model_name, - "prompt": "สกัดข้อความภาษาไทยและอังกฤษทั้งหมดจากภาพนี้อย่างถูกต้อง รักษาโครงสร้างบรรทัดและการเว้นวรรคให้ใกล้เคียงต้นฉบับมากที่สุด ห้ามเพิ่มคำอธิบายใดๆ", + "prompt": "", "images": [image_base64], "stream": False, - "options": { - "temperature": 0.0, - "top_p": 0.9, - "repeat_penalty": 1.0, - }, + "options": options, "keep_alive": 0, } with httpx.Client(timeout=TYPHOON_OCR_TIMEOUT) as client: @@ -253,17 +253,28 @@ def ocr_upload( file: UploadFile = File(...), engine: str = Form(default="auto"), maxPages: int = Form(default=0), + temperature: Optional[float] = Form(default=None), + topP: Optional[float] = Form(default=None), + repeatPenalty: Optional[float] = Form(default=None), ): """OCR จาก multipart file upload — ไม่ต้องการ shared volume mount""" selected_engine = engine.strip().lower() max_pages = maxPages or MAX_PAGES + # รวม options override สำหรับ Typhoon OCR (ถ้า frontend ส่งมา) + typhoon_options: dict = {} + if temperature is not None: + typhoon_options["temperature"] = temperature + if topP is not None: + typhoon_options["top_p"] = topP + if repeatPenalty is not None: + typhoon_options["repeat_penalty"] = repeatPenalty pdf_bytes = file.file.read() try: doc = fitz.open(stream=pdf_bytes, filetype="pdf") except Exception as e: raise HTTPException(status_code=422, detail=f"เปิดไฟล์ PDF ล้มเหลว: {e}") - logger.info(f"OCR upload: {file.filename} engine={selected_engine}") - return _process_pdf_doc(doc, selected_engine, max_pages) + logger.info(f"OCR upload: {file.filename} engine={selected_engine} options={typhoon_options or 'modelfile-defaults'}") + return _process_pdf_doc(doc, selected_engine, max_pages, typhoon_options) class NormalizeRequest(BaseModel):