From e3e0de66e91092b33f6fd2abf81ca9530a18a2c9 Mon Sep 17 00:00:00 2001 From: admin Date: Sat, 6 Jun 2026 13:54:36 +0700 Subject: [PATCH] 690606:1354 ADR-035-135 #03.1 [skip CI] --- backend/src/modules/ai/ai.controller.ts | 2 + .../ai/processors/ai-batch.processor.ts | 24 ++- .../04-00-docker-compose/Desk-5439/test.md | 62 +++++++ .../Desk-5439/test_llm.ps1 | 157 ++++++++++++++++ .../Desk-5439/test_llm.py | 173 ++++++++++++++++++ specs/88-logs/_backend_logs.md | Bin 179495 -> 23691 bytes 6 files changed, 416 insertions(+), 2 deletions(-) create mode 100644 specs/04-Infrastructure-OPS/04-00-docker-compose/Desk-5439/test.md create mode 100644 specs/04-Infrastructure-OPS/04-00-docker-compose/Desk-5439/test_llm.ps1 create mode 100644 specs/04-Infrastructure-OPS/04-00-docker-compose/Desk-5439/test_llm.py diff --git a/backend/src/modules/ai/ai.controller.ts b/backend/src/modules/ai/ai.controller.ts index c41d7c7d..14cb3e36 100644 --- a/backend/src/modules/ai/ai.controller.ts +++ b/backend/src/modules/ai/ai.controller.ts @@ -12,6 +12,7 @@ // - 2026-05-30: เพิ่ม endpoints GET/POST/PATCH models และ GET vram/status สำหรับ dynamic AI model management และ VRAM monitoring (T031-T034, US2) // - 2026-06-01: [BUGFIX] submitSandboxOcr: เพิ่ม @ApiBearerAuth(), @HttpCode(ACCEPTED), Body({ engineType }) และส่ง engineType ไปยัง enqueueSandboxJob // - 2026-06-02: เพิ่ม REST endpoints GET /ai/ocr-engines และ POST /ai/ocr-engines/:engineId/select (T003, T004, ADR-033) และนำเข้า SystemException เพื่อป้องกันความเสียหายในการคอมไพล์ +// - 2026-06-06: [BUGFIX] เพิ่ม @Throttle({ default: { limit: 300, ttl: 60000 } }) บน GET admin/sandbox/job/:id เพื่อแก้ ThrottlerException spam จาก frontend polling // Controller สำหรับ AI Gateway Endpoints (ADR-023) import { @@ -452,6 +453,7 @@ export class AiController { @UseGuards(JwtAuthGuard, RbacGuard) @ApiBearerAuth() @RequirePermission('system.manage_all') + @Throttle({ default: { limit: 300, ttl: 60000 } }) // 300 req/min — รองรับ admin polling ทุก 200ms @ApiOperation({ summary: 'AI Admin Sandbox Job Status — ตรวจสอบสถานะ RAG sandbox job (T036)', diff --git a/backend/src/modules/ai/processors/ai-batch.processor.ts b/backend/src/modules/ai/processors/ai-batch.processor.ts index 5cd15667..7d8ac636 100644 --- a/backend/src/modules/ai/processors/ai-batch.processor.ts +++ b/backend/src/modules/ai/processors/ai-batch.processor.ts @@ -11,6 +11,7 @@ // - 2026-05-28: EC-001 ใช้ findOrSuggestTags เพื่อตรวจจับ Tag ใหม่และบันทึก aiIssues; EC-002 ตรวจสอบ UUID ของผู้ส่ง/ผู้รับ และ Flag เมื่อหาไม่พบ // - 2026-06-03: ADR-034 — เพิ่ม 'ocr-extract' job type + OCR_JOB_TYPES constant + processOcrExtract() ที่มี model switching logic (unload main → load OCR → generate → reload main) // - 2026-06-06: แก้ไข bug LLM JSON parse failure — เพิ่ม retry logic (2 attempts), debug log raw response, และปรับปรุง error message ให้แสดงทั้ง raw และ cleaned response +// - 2026-06-06: เพิ่ม OCR text truncation (MAX_OCR_TEXT_CHARS=15000) เพื่อป้องกัน context overflow เมื่อเอกสารยาวมากชน num_ctx 8192 import { Processor, WorkerHost } from '@nestjs/bullmq'; import { Logger } from '@nestjs/common'; @@ -75,6 +76,9 @@ export interface AiBatchJobData { idempotencyKey: string; } +/** OCR text สูงสุดที่ส่งเข้า LLM prompt — ป้องกัน context overflow (num_ctx 8192, Thai ~3 chars/token) */ +const MAX_OCR_TEXT_CHARS = 15000; + const readString = (value: unknown): string | undefined => typeof value === 'string' && value.trim().length > 0 ? value : undefined; @@ -421,8 +425,16 @@ export class AiBatchProcessor extends WorkerHost { overrideProjPublicId === 'default' ? undefined : overrideProjPublicId ); + const ocrTextSafe = + ocrResult.text.length > MAX_OCR_TEXT_CHARS + ? (this.logger.warn( + `OCR text truncated: ${ocrResult.text.length} chars > ${MAX_OCR_TEXT_CHARS} limit (context overflow protection)` + ), + ocrResult.text.substring(0, MAX_OCR_TEXT_CHARS)) + : ocrResult.text; + const resolvedPrompt = activePrompt.template - .replace('{{ocr_text}}', ocrResult.text) + .replace('{{ocr_text}}', ocrTextSafe) .replace( '{{master_data_context}}', JSON.stringify(masterDataContext, null, 2) @@ -636,8 +648,16 @@ export class AiBatchProcessor extends WorkerHost { projectPublicId === 'default' ? undefined : projectPublicId ); + const ocrTextSafe = + ocrText.length > MAX_OCR_TEXT_CHARS + ? (this.logger.warn( + `OCR text truncated: ${ocrText.length} chars > ${MAX_OCR_TEXT_CHARS} limit (context overflow protection)` + ), + ocrText.substring(0, MAX_OCR_TEXT_CHARS)) + : ocrText; + const resolvedPrompt = targetPrompt.template - .replace('{{ocr_text}}', ocrText) + .replace('{{ocr_text}}', ocrTextSafe) .replace( '{{master_data_context}}', JSON.stringify(masterDataContext, null, 2) diff --git a/specs/04-Infrastructure-OPS/04-00-docker-compose/Desk-5439/test.md b/specs/04-Infrastructure-OPS/04-00-docker-compose/Desk-5439/test.md new file mode 100644 index 00000000..29b35e1d --- /dev/null +++ b/specs/04-Infrastructure-OPS/04-00-docker-compose/Desk-5439/test.md @@ -0,0 +1,62 @@ +HEC CHEC(THAI) CO.,LTD บริษัท ซีเอชบี (ไทย) จำกัด +Bangkok Liaison Office: 87/2 CRC Tower, 20 FL Wireless Rd, Lumphini, Pathumwan, Bangkok 10330 +สำนักงานใหญ่ : 87/2 อาคารเซียาร์ซี พาวเวอร์ ชั้น 20 ถนนวิทยุ แขวงลุมพินี เขตปทุมวัน กรุงเทพมหานคร 10330 Tel.02-002-8683 Fax. 02-002-8685 +Project Site Office: Thung Sukhla,Si Racha District Chon Buri 20230 สำนักงานโครงการ : ตำบลธงสุขลา สันทวีศรีราชา จังหวัดชลบุรี 20230 +ต้นฉบับ +Project of Laem Chabang Port Phase 3, Infrastructure Works (Contract 2) +สัญญาจ้างเหมาก่อสร้างโครงการพัฒนาท่าเรือแหลมฉบังระยะที่ 3 ( ส่วนที่ 2 ) งานก่อสร้างอาคาร ท่าเทียบเรือ ระบบถนน และสาธารณูปโภค +PSLCP3 +ได้รับเอกสารฉบับนี้แล้ว +เลขที่รับเอกสาร.................... +วันที่ 19 ก.พ. 2568 +ผู้รับ ลายเซ็น +10 กุมภาพันธ์ 2568 +เรื่อง ส่งมอบพื้นที่ถมทะเลพื้นที่ 1 และ 2 +โครงการพัฒนาท่าเรือแหลมฉบังระยะที่ 3 (ส่วนที่ 2) งานก่อสร้างอาคาร ท่าเทียบเรือ ระบบถนน และสาธารณูปโภค +เรียน คุณสุวัฒน์ พิพัฒนปัญญกูล +ผู้จัดการโครงการงานควบคุมงานก่อสร้างโครงการพัฒนาท่าเรือแหลมฉบัง ระยะที่ 3 (ส่วนที่ 1-4) +อ้างถึง 1) สัญญาจ้างเหมาก่อสร้างโครงการพัฒนาท่าเรือแหลมฉบังระยะที่ 3 (ส่วนที่2) งานก่อสร้างอาคาร ท่าเทียบเรือ ระบบถนนและสาธารณูปโภค สัญญาเลขที่ ทลฉ.จ.19/2567 ลงวันที่ 31 กรกฎาคม 2567 +2) หนังสือการท่าเรือแหลมฉบัง เลขที่ สคฉ.03/0036 ลงวันที่ 4 กุมภาพันธ์ 2568 เรื่อง ขอส่งมอบ พื้นที่ โครงการพัฒนาท่าเรือแหลมฉบังระยะที่3 (ส่วนที่2) งานก่อสร้างอาคาร ท่าเทียบเรือ ระบบ ถนนและสาธารณูปโภค +3) หนังสือขอข้อคิดเห็นและข้อมูลเพิ่มเติม เลขที่ LCBP3-C2-RFI-BUD-0001-A ลงวันที่ 4 มกราคม 2568 เรื่อง ขอข้อคิดเห็นงานก่อสร้างอาคารบริเวณพื้นที่ 1 (Area 1) เมื่อเทียบกับระดับดินเดิม ณ ปัจจุบัน จากการสำรวจลักษณะภูมิประเทศ (Topographic Survey) +4) หนังสือขออนุมัติเอกสาร เลขที่ LCBP3-C2-RFA-ROW-RPT-0007-A ลงวันที่ 22 มกราคม 2568 เรื่อง ขออนุมัติรายงานการสำรวจเพื่อการก่อสร้างอาคารประตูตรวจสอบ 5 และถนน RN-2 +สิ่งที่ส่งมาด้วย +1) รูปแสดง การร่วมตรวจสอบพื้นที่ระหว่างผู้ควบคุมงาน/คคง. และผู้รับจ้าง/ผรม.2 และ รูปแสดงอุปสรรคในพื้นที่ก่อสร้างที่ได้รับมอบพื้นที่ถมทะเลพื้นที่ 1 และ 2 +ตามสัญญาที่อ้างถึง +1) สัญญาจ้างเหมาก่อสร้างโครงการพัฒนาท่าเรือแหลมฉบังระยะที่ 3 (ส่วนที่2) งานก่อสร้างอาคาร ท่าเทียบเรือ ระบบถนนและสาธารณูปโภค สัญญาเลขที่ ทลฉ.จ.19/2567 ลงวันที่ 31 กรกฎาคม 2567 หนังสือการท่าเรือแหลมฉบัง ที่อ้างถึง +2) เรื่องขอส่งมอบพื้นที่ โครงการพัฒนาท่าเรือแหลม ฉบังระยะที่3 (ส่วนที่2) งานก่อสร้างอาคาร ท่าเทียบเรือ ระบบถนนและระบบสาธารณูปโภค +ผู้รับจ้าง/ผรม.2 รับทราบการส่งมอบพื้นที่ถมทะเลพื้นที่ 1 และ 2 และขอแจ้งยืนยันวันเริ่ม Keydate ตามรายละเอียดดังนี้ +เริ่มใช้เมื่อวันที่ 20 กันยายน 2567 +3 diff --git a/specs/04-Infrastructure-OPS/04-00-docker-compose/Desk-5439/test_llm.ps1 b/specs/04-Infrastructure-OPS/04-00-docker-compose/Desk-5439/test_llm.ps1 new file mode 100644 index 00000000..a1053420 --- /dev/null +++ b/specs/04-Infrastructure-OPS/04-00-docker-compose/Desk-5439/test_llm.ps1 @@ -0,0 +1,157 @@ +# ทดสอบ typhoon2.5-np-dms โดยใช้ prompt template จริง + OCR text จาก test.md + master data context จาก DB +# รันด้วย: powershell -ExecutionPolicy Bypass -File test_llm.ps1 + +$ErrorActionPreference = "Stop" + +$OLLAMA_URL = "http://192.168.10.100:11434" +$MODEL = "typhoon2.5-np-dms:latest" +$OCR_TEXT_FILE = Join-Path $PSScriptRoot "test.md" + +# --- Prompt Template (ดึงจาก DB: ai_prompts version=2, prompt_type=ocr_extraction) --- +$TEMPLATE = @" +คุณคือเอนจิ้นสกัดข้อมูลอัจฉริยะ (Document Intelligence Engine) +วิเคราะห์ข้อความ OCR ที่ได้รับจากเอกสารของโครงการ Laem Chabang Port Phase 3 และสกัดข้อมูลเมตาดาต้าให้ออกมาเป็น JSON object ที่ถูกต้องตามโครงสร้างที่กำหนด + +ข้อความ OCR ที่สกัดได้: +{{ocr_text}} + +ข้อมูลอ้างอิงของระบบ (Master Data Context): +{{master_data_context}} + +กฎการสกัดข้อมูล: +1. วิเคราะห์และจับคู่ข้อมูลจากข้อความ OCR กับข้อมูลอ้างอิงที่ระบุใน Master Data Context เสมอ +2. สำหรับโครงการ (project) ให้ค้นหาและสกัดส่งกลับเป็น UUID ของโครงการ (projectPublicId) +3. สำหรับประเภทเอกสารโต้ตอบ (correspondence type) ให้สกัดรหัสส่งกลับมา (correspondenceTypeCode) เช่น RFA, Transmittal +4. สำหรับสาขางาน (discipline) ให้ส่งคืนรหัสส่งกลับมา (disciplineCode) เช่น GEN, STR +5. สำหรับหน่วยงานผู้ส่ง (originator) ค้นหาจาก availableOrganizations และส่งกลับมาเป็น UUID (originatorOrganizationPublicId) +6. สำหรับหน่วยงานผู้รับ (recipients) ให้ส่งกลับมาเป็นรายการ Array ของออบเจกต์ ซึ่งมี UUID ขององค์กร (organizationPublicId) และประเภทผู้รับ (recipientType: "TO" หรือ "CC") เสมอ +7. สำหรับหัวข้อเอกสาร (subject) ให้สกัดหัวข้อหรือชื่อเรื่องของเอกสารภาษาไทยหรือภาษาอังกฤษ +8. วันที่ของเอกสาร (documentDate) ให้ส่งคืนในรูปแบบ YYYY-MM-DD +9. รายการแท็ก (tags) สกัดคำสำคัญหรือคำแนะนำ Tags (สอดคล้องกับ availableTags หากมี) +10. สรุปความเนื้อหา (summary) เขียนสรุปรายละเอียดเอกสารสั้นกระชับ 4-5 ประโยคเป็นภาษาไทยอย่างสละสลวย +11. confidence: ค่าความมั่นใจในการสกัดข้อมูลนี้ (ทศนิยมระหว่าง 0.0 ถึง 1.0) + +ส่งคืนคำตอบเฉพาะ JSON Object ที่ถูกต้องเท่านั้น ห้ามใส่บล็อกโค้ด markdown หรือคำอธิบายเพิ่มเติมใดๆ +โครงสร้าง JSON ผลลัพธ์: +{ + "projectPublicId": "string หรือ null", + "correspondenceTypeCode": "string หรือ null", + "disciplineCode": "string หรือ null", + "originatorOrganizationPublicId": "string หรือ null", + "recipients": [ + { + "organizationPublicId": "string", + "recipientType": "TO หรือ CC" + } + ], + "subject": "string หรือ null", + "documentDate": "string:YYYY-MM-DD หรือ null", + "tags": ["string"], + "summary": "string หรือ null", + "confidence": 0.95 +} +"@ + +# --- Master Data Context (ดึงจาก DB จริง) --- +$MASTER_DATA = @{ + availableProjects = @( + @{code = "LCBP3"; uuid = "c957f1e3-538b-11f1-8c7d-0242ac1d0007"; name = "โครงการพัฒนาท่าเรือแหลมฉบัง ระยะที่ 3 (ส่วนที่ 1-4)"} + @{code = "LCBP3-C1"; uuid = "c957f44b-538b-11f1-8c7d-0242ac1d0007"; name = "โครงการพัฒนาท่าเรือแหลมฉบัง ระยะที่ 3 (ส่วนที่ 1) งานก่อสร้างงานทางทะเล"} + @{code = "LCBP3-C2"; uuid = "c957f523-538b-11f1-8c7d-0242ac1d0007"; name = "โครงการพัฒนาท่าเรือแหลมฉบัง ระยะที่ 3 (ส่วนที่ 2) งานก่อสร้างอาคาร ท่าเทียบเรือ ระบบถนน และระบบสาธารณูปโภค"} + @{code = "LCBP3-C3"; uuid = "c957f57c-538b-11f1-8c7d-0242ac1d0007"; name = "โครงการพัฒนาท่าเรือแหลมฉบัง ระยะที่ 3 (ส่วนที่ 3) งานก่อสร้าง"} + @{code = "LCBP3-C4"; uuid = "c957f5cc-538b-11f1-8c7d-0242ac1d0007"; name = "โครงการพัฒนาท่าเรือแหลมฉบัง ระยะที่ 3 (ส่วนที่ 4) งานก่อสร้าง"} + ) + availableOrganizations = @( + @{code = "กทท."; uuid = "c94cb0b4-538b-11f1-8c7d-0242ac1d0007"; name = "การท่าเรือแห่งประเทศไทย"} + @{code = "สคฉ.3"; uuid = "c94cb3f6-538b-11f1-8c7d-0242ac1d0007"; name = "โครงการพัฒนาท่าเรือแหลมฉบัง ระยะที่ 3"} + @{code = "สคฉ.3-01"; uuid = "c94cb532-538b-11f1-8c7d-0242ac1d0007"; name = "ตรวจรับพัสดุ ที่ปรึกษาควบคุมงาน"} + @{code = "สคฉ.3-02"; uuid = "c94cb5ab-538b-11f1-8c7d-0242ac1d0007"; name = "ตรวจรับพัสดุ งานทางทะเล"} + @{code = "สคฉ.3-03"; uuid = "c94cb616-538b-11f1-8c7d-0242ac1d0007"; name = "ตรวจรับพัสดุ อาคารและระบบสาธารณูปโภค"} + @{code = "คคง."; uuid = "c94cb8ac-538b-11f1-8c7d-0242ac1d0007"; name = "Construction Supervision Ltd."} + @{code = "ผรม.1"; uuid = "c94cb907-538b-11f1-8c7d-0242ac1d0007"; name = "Contractor งานทางทะเล"} + @{code = "ผรม.2"; uuid = "c94cb95e-538b-11f1-8c7d-0242ac1d0007"; name = "Contractor งานก่อสร้าง"} + @{code = "ผรม.3"; uuid = "c94cb9b3-538b-11f1-8c7d-0242ac1d0007"; name = "Contractor งานก่อสร้าง ส่วนที่ 3"} + @{code = "ผรม.4"; uuid = "c94cba0c-538b-11f1-8c7d-0242ac1d0007"; name = "Contractor งานก่อสร้าง ส่วนที่ 4"} + ) + availableDisciplines = @( + @{code = "GEN"; name = "งานบริหารโครงการ"} + @{code = "COD"; name = "สัญญาและข้อโต้แย้ง"} + @{code = "QSB"; name = "สำรวจปริมาณและควบคุมงบประมาณ"} + @{code = "PPG"; name = "บริหารแผนและความก้าวหน้า"} + @{code = "BST"; name = "งานโครงสร้างอาคาร"} + @{code = "UTL"; name = "งานระบบสาธารณูปโภค"} + @{code = "EPW"; name = "งานระบบไฟฟ้า"} + @{code = "SRV"; name = "งานสำรวจ"} + @{code = "ODC"; name = "สำนักงาน-ควบคุมเอกสาร"} + ) + availableCorrespondenceTypes = @( + @{code = "RFA"; name = "Request for Approval"} + @{code = "RFI"; name = "Request for Information"} + @{code = "TRANSMITTAL"; name = "Transmittal"} + @{code = "LETTER"; name = "Letter"} + @{code = "MEMO"; name = "Memorandum"} + @{code = "MOM"; name = "Minutes of Meeting"} + @{code = "NOTICE"; name = "Notice"} + @{code = "OTHER"; name = "Other"} + ) + availableTags = @() +} + +function Check-Models { + try { + $resp = Invoke-RestMethod -Uri "$OLLAMA_URL/api/ps" -Method Get -TimeoutSec 5 + if ($resp.models) { + Write-Host " Models in VRAM: $($resp.models.name -join ', ')" + } else { + Write-Host " VRAM: ไม่มี model โหลดอยู่ (ว่าง)" + } + } catch { + Write-Host " Error checking models: $_" + } +} + +Write-Host "=" * 60 +Write-Host "🔍 ตรวจสอบ VRAM ก่อนรัน..." +Check-Models + +$ocrText = Get-Content $OCR_TEXT_FILE -Raw -Encoding UTF8 +Write-Host "`n📄 OCR text: $($ocrText.Length) chars" + +$masterDataJson = $MASTER_DATA | ConvertTo-Json -Depth 10 -Compress:$false +$prompt = $TEMPLATE -replace "{{ocr_text}}", $ocrText -replace "{{master_data_context}}", $masterDataJson +Write-Host "📝 Total prompt: $($prompt.Length) chars" +Write-Host "=" * 60 +Write-Host "⏳ กำลังส่งไปยัง Ollama..." + +$body = @{ + model = $MODEL + prompt = $prompt + stream = $false +} | ConvertTo-Json -Depth 10 + +$start = Get-Date +try { + $resp = Invoke-RestMethod -Uri "$OLLAMA_URL/api/generate" -Method Post -Body $body -ContentType "application/json" -TimeoutSec 180 + $elapsed = (Get-Date - $start).TotalSeconds + $rawResponse = $resp.response + + Write-Host "`n✅ Response ใน $([math]::Round($elapsed, 1))s" + Write-Host "-" * 60 + Write-Host $rawResponse + Write-Host "-" * 60 + + $cleaned = $rawResponse -replace "```json", "" -replace "```", "" -replace "^\s+", "" -replace "\s+$", "" + try { + $parsed = $cleaned | ConvertFrom-Json + Write-Host "`n✅ JSON parse สำเร็จ!" + $parsed | ConvertTo-Json -Depth 10 + } catch { + Write-Host "`n❌ JSON parse ล้มเหลว: $_" + Write-Host " Raw (200 chars): $($rawResponse.Substring(0, [Math]::Min(200, $rawResponse.Length)))" + } +} catch { + Write-Host "`n❌ Error: $_" +} + +Write-Host "`n🔍 ตรวจสอบ VRAM หลังรัน..." +Check-Models diff --git a/specs/04-Infrastructure-OPS/04-00-docker-compose/Desk-5439/test_llm.py b/specs/04-Infrastructure-OPS/04-00-docker-compose/Desk-5439/test_llm.py new file mode 100644 index 00000000..8e89d9a8 --- /dev/null +++ b/specs/04-Infrastructure-OPS/04-00-docker-compose/Desk-5439/test_llm.py @@ -0,0 +1,173 @@ +""" +ทดสอบ typhoon2.5-np-dms โดยใช้ prompt template จริง + OCR text จาก test.md + master data context จาก DB +รันด้วย: python test_llm.py +""" + +import json +import time +import urllib.request +import urllib.error +from pathlib import Path + +OLLAMA_URL = "http://192.168.10.100:11434" +MODEL = "typhoon2.5-np-dms:latest" +OCR_TEXT_FILE = Path(__file__).parent / "test.md" + +# --- Prompt Template (ดึงจาก DB: ai_prompts version=2, prompt_type=ocr_extraction) --- +TEMPLATE = """คุณคือเอนจิ้นสกัดข้อมูลอัจฉริยะ (Document Intelligence Engine) +วิเคราะห์ข้อความ OCR ที่ได้รับจากเอกสารของโครงการ Laem Chabang Port Phase 3 และสกัดข้อมูลเมตาดาต้าให้ออกมาเป็น JSON object ที่ถูกต้องตามโครงสร้างที่กำหนด + +ข้อความ OCR ที่สกัดได้: +{{ocr_text}} + +ข้อมูลอ้างอิงของระบบ (Master Data Context): +{{master_data_context}} + +กฎการสกัดข้อมูล: +1. วิเคราะห์และจับคู่ข้อมูลจากข้อความ OCR กับข้อมูลอ้างอิงที่ระบุใน Master Data Context เสมอ +2. สำหรับโครงการ (project) ให้ค้นหาและสกัดส่งกลับเป็น UUID ของโครงการ (projectPublicId) +3. สำหรับประเภทเอกสารโต้ตอบ (correspondence type) ให้สกัดรหัสส่งกลับมา (correspondenceTypeCode) เช่น RFA, Transmittal +4. สำหรับสาขางาน (discipline) ให้ส่งคืนรหัสส่งกลับมา (disciplineCode) เช่น GEN, STR +5. สำหรับหน่วยงานผู้ส่ง (originator) ค้นหาจาก availableOrganizations และส่งกลับมาเป็น UUID (originatorOrganizationPublicId) +6. สำหรับหน่วยงานผู้รับ (recipients) ให้ส่งกลับมาเป็นรายการ Array ของออบเจกต์ ซึ่งมี UUID ขององค์กร (organizationPublicId) และประเภทผู้รับ (recipientType: "TO" หรือ "CC") เสมอ +7. สำหรับหัวข้อเอกสาร (subject) ให้สกัดหัวข้อหรือชื่อเรื่องของเอกสารภาษาไทยหรือภาษาอังกฤษ +8. วันที่ของเอกสาร (documentDate) ให้ส่งคืนในรูปแบบ YYYY-MM-DD +9. รายการแท็ก (tags) สกัดคำสำคัญหรือคำแนะนำ Tags (สอดคล้องกับ availableTags หากมี) +10. สรุปความเนื้อหา (summary) เขียนสรุปรายละเอียดเอกสารสั้นกระชับ 4-5 ประโยคเป็นภาษาไทยอย่างสละสลวย +11. confidence: ค่าความมั่นใจในการสกัดข้อมูลนี้ (ทศนิยมระหว่าง 0.0 ถึง 1.0) + +ส่งคืนคำตอบเฉพาะ JSON Object ที่ถูกต้องเท่านั้น ห้ามใส่บล็อกโค้ด markdown หรือคำอธิบายเพิ่มเติมใดๆ +โครงสร้าง JSON ผลลัพธ์: +{ + "projectPublicId": "string หรือ null", + "correspondenceTypeCode": "string หรือ null", + "disciplineCode": "string หรือ null", + "originatorOrganizationPublicId": "string หรือ null", + "recipients": [ + { + "organizationPublicId": "string", + "recipientType": "TO หรือ CC" + } + ], + "subject": "string หรือ null", + "documentDate": "string:YYYY-MM-DD หรือ null", + "tags": ["string"], + "summary": "string หรือ null", + "confidence": 0.95 +}""" + +# --- Master Data Context (ดึงจาก DB จริง) --- +MASTER_DATA = { + "availableProjects": [ + {"code": "LCBP3", "uuid": "c957f1e3-538b-11f1-8c7d-0242ac1d0007", "name": "โครงการพัฒนาท่าเรือแหลมฉบัง ระยะที่ 3 (ส่วนที่ 1-4)"}, + {"code": "LCBP3-C1", "uuid": "c957f44b-538b-11f1-8c7d-0242ac1d0007", "name": "โครงการพัฒนาท่าเรือแหลมฉบัง ระยะที่ 3 (ส่วนที่ 1) งานก่อสร้างงานทางทะเล"}, + {"code": "LCBP3-C2", "uuid": "c957f523-538b-11f1-8c7d-0242ac1d0007", "name": "โครงการพัฒนาท่าเรือแหลมฉบัง ระยะที่ 3 (ส่วนที่ 2) งานก่อสร้างอาคาร ท่าเทียบเรือ ระบบถนน และระบบสาธารณูปโภค"}, + {"code": "LCBP3-C3", "uuid": "c957f57c-538b-11f1-8c7d-0242ac1d0007", "name": "โครงการพัฒนาท่าเรือแหลมฉบัง ระยะที่ 3 (ส่วนที่ 3) งานก่อสร้าง"}, + {"code": "LCBP3-C4", "uuid": "c957f5cc-538b-11f1-8c7d-0242ac1d0007", "name": "โครงการพัฒนาท่าเรือแหลมฉบัง ระยะที่ 3 (ส่วนที่ 4) งานก่อสร้าง"}, + ], + "availableOrganizations": [ + {"code": "กทท.", "uuid": "c94cb0b4-538b-11f1-8c7d-0242ac1d0007", "name": "การท่าเรือแห่งประเทศไทย"}, + {"code": "สคฉ.3", "uuid": "c94cb3f6-538b-11f1-8c7d-0242ac1d0007", "name": "โครงการพัฒนาท่าเรือแหลมฉบัง ระยะที่ 3"}, + {"code": "สคฉ.3-01", "uuid": "c94cb532-538b-11f1-8c7d-0242ac1d0007", "name": "ตรวจรับพัสดุ ที่ปรึกษาควบคุมงาน"}, + {"code": "สคฉ.3-02", "uuid": "c94cb5ab-538b-11f1-8c7d-0242ac1d0007", "name": "ตรวจรับพัสดุ งานทางทะเล"}, + {"code": "สคฉ.3-03", "uuid": "c94cb616-538b-11f1-8c7d-0242ac1d0007", "name": "ตรวจรับพัสดุ อาคารและระบบสาธารณูปโภค"}, + {"code": "คคง.", "uuid": "c94cb8ac-538b-11f1-8c7d-0242ac1d0007", "name": "Construction Supervision Ltd."}, + {"code": "ผรม.1", "uuid": "c94cb907-538b-11f1-8c7d-0242ac1d0007", "name": "Contractor งานทางทะเล"}, + {"code": "ผรม.2", "uuid": "c94cb95e-538b-11f1-8c7d-0242ac1d0007", "name": "Contractor งานก่อสร้าง"}, + {"code": "ผรม.3", "uuid": "c94cb9b3-538b-11f1-8c7d-0242ac1d0007", "name": "Contractor งานก่อสร้าง ส่วนที่ 3"}, + {"code": "ผรม.4", "uuid": "c94cba0c-538b-11f1-8c7d-0242ac1d0007", "name": "Contractor งานก่อสร้าง ส่วนที่ 4"}, + ], + "availableDisciplines": [ + {"code": "GEN", "name": "งานบริหารโครงการ"}, + {"code": "COD", "name": "สัญญาและข้อโต้แย้ง"}, + {"code": "QSB", "name": "สำรวจปริมาณและควบคุมงบประมาณ"}, + {"code": "PPG", "name": "บริหารแผนและความก้าวหน้า"}, + {"code": "BST", "name": "งานโครงสร้างอาคาร"}, + {"code": "UTL", "name": "งานระบบสาธารณูปโภค"}, + {"code": "EPW", "name": "งานระบบไฟฟ้า"}, + {"code": "SRV", "name": "งานสำรวจ"}, + {"code": "ODC", "name": "สำนักงาน-ควบคุมเอกสาร"}, + ], + "availableCorrespondenceTypes": [ + {"code": "RFA", "name": "Request for Approval"}, + {"code": "RFI", "name": "Request for Information"}, + {"code": "TRANSMITTAL", "name": "Transmittal"}, + {"code": "LETTER", "name": "Letter"}, + {"code": "MEMO", "name": "Memorandum"}, + {"code": "MOM", "name": "Minutes of Meeting"}, + {"code": "NOTICE", "name": "Notice"}, + {"code": "OTHER", "name": "Other"}, + ], + "availableTags": [], +} + + +def check_models(): + req = urllib.request.Request(f"{OLLAMA_URL}/api/ps") + with urllib.request.urlopen(req, timeout=5) as resp: + data = json.loads(resp.read()) + models = data.get("models", []) + if models: + print(f" Models in VRAM: {[m['name'] for m in models]}") + else: + print(" VRAM: ไม่มี model โหลดอยู่ (ว่าง)") + + +def main(): + print("=" * 60) + print("🔍 ตรวจสอบ VRAM ก่อนรัน...") + check_models() + + ocr_text = OCR_TEXT_FILE.read_text(encoding="utf-8") + print(f"\n📄 OCR text: {len(ocr_text)} chars") + + prompt = TEMPLATE.replace("{{ocr_text}}", ocr_text).replace( + "{{master_data_context}}", json.dumps(MASTER_DATA, ensure_ascii=False, indent=2) + ) + print(f"📝 Total prompt: {len(prompt)} chars") + print("=" * 60) + print("⏳ กำลังส่งไปยัง Ollama...") + + body = json.dumps({ + "model": MODEL, + "prompt": prompt, + "stream": False, + }).encode("utf-8") + + start = time.time() + req = urllib.request.Request( + f"{OLLAMA_URL}/api/generate", + data=body, + headers={"Content-Type": "application/json"}, + method="POST", + ) + try: + with urllib.request.urlopen(req, timeout=180) as resp: + result = json.loads(resp.read()) + elapsed = time.time() - start + raw_response = result.get("response", "") + + print(f"\n✅ Response ใน {elapsed:.1f}s") + print("-" * 60) + print(raw_response) + print("-" * 60) + + # พยายาม parse JSON + cleaned = raw_response.replace("```json", "").replace("```", "").strip() + try: + parsed = json.loads(cleaned) + print("\n✅ JSON parse สำเร็จ!") + print(json.dumps(parsed, ensure_ascii=False, indent=2)) + except json.JSONDecodeError as e: + print(f"\n❌ JSON parse ล้มเหลว: {e}") + print(f" Raw (200 chars): {raw_response[:200]!r}") + + except urllib.error.URLError as e: + print(f"\n❌ Connection error: {e}") + + print("\n🔍 ตรวจสอบ VRAM หลังรัน...") + check_models() + + +if __name__ == "__main__": + main() diff --git a/specs/88-logs/_backend_logs.md b/specs/88-logs/_backend_logs.md index 440f14ea4cb24afc3752605af2cf47d0629715e4..49c8af6fd77d7c559c9b49e81840e2d440a825c6 100644 GIT binary patch literal 23691 zcmeHP+fL(379Gta68})jQ=`V0@-_BRBxnxJ>1k*fpw$mO2$fwGVsLDqDhJ54jxc8umg$xNCt0=juG?OVvcG;}B0obIAqDA<2LBD+a5fS4b=x;Q zbaa4
    V4^z}HM|p3q)eh2%RE!8;M+?nmM<(J#}0M-+*0 z#Kr`@!Mb4~8jZyU`u$)3MdB*TGRO#m_X0*i`-NP03K&RzMy7E}D2fuwZka&Cl%qM; z&ugdXp6x7)qp&1-hKdUadbzpM%62?OA}73H@z!&e_U=OV-=%3Ta!#@{)M^k#0`WKH!f*>2x}Yp&-Slp3x@!r>6!MMB;3%z!bizFpsaem2(y?DaA_7-pD9j1#EPqXmf|^-D7N9~1c$ce+4y;E z>b4I!6oUvr(ERN|GsbER;0lJ(SVm z5y?k`9t^omt2$3;MNet2X;@}p>Pldmo?;QpRy^W)3e)>RVCYU@8oCOf@1Yv_Fe|3L z7<}s$pJ$xF2w*)Q2K8nyPqU-?N6s>s*+(oPaX#(&SjE3dO2T7-{{HX(lsPGSNJNx{ zgu_^zO+U|2vC~&Ekkxg+?>e>y`BAEbNTOFv8kiTbmK}a9I|f_2t(zDH)4W*NcsfQe z=?6(PPpZ8Q<>@R@#gML(dv_3_^mY2(^6mNU?7_i*QuilkB7*_Qn*3IjKMO|hAN
    C=Fj$>#^L>^KT+4PJ&7|$?{kg^P;U!}GgVM~$*IOiPA3HUlFWbkT(AT{<4M@Cu z8?v%vVAw%P1LXzhuyv+kIDrkFuCHaqyf5n-5^02vqeK(c(xw4fty+{HU%X&*emTaN z<)f4ippudTCz(=|4X|sdh6kIofme~CjDTfewEA%&X%oTt_62PDgM;*+Q5+M^R(15| z3zT%}jXB0UcilZxr_MjR_ zH_*-qR!yd1t6!E+I_%Cv!lULRyY!;;{dbzU2jd?}A-bUin-SWZfV5U~`0!1A4T`lHOZ28JQInLi^KnWiF%z1a zCD~ZpN`TX)cw2n3jBLwMEmc2nu7~M(oF-csDoqu8=OM^!p+J?K2+)#a#8ryLR8Bwyg(d$jpA=4fSD2v^g$yYub#3MnOb>|5d#x!@Cqd zwp<_E8?yw|+CiKp{MV!L8|wKs^1}H8QtCVm7#;?$9S&XWkdXEJAmv%#91>Hfx?|lxg$fp0IbB5kO#wHo z3J2zoVfffxjJnmo?~~;{yh4GN?jhHzZwh!^;iS;fMaYFFQHvGBw0-l=2w%1y{<&hb z4By1FHL+U1{eb z-$(s@30W8tRb%Zi5D}>4L58q9BBNmL>@tvVo-=l96!rK+|JFNIcCvrr1(r2GnY zdkDz3UEeg8&AGMSEUrq07!P=?Z!fze_p15O4;i_(e;XcrG=z*meIT`)mcWovy5KNg6k}!%6zS=-Oe)VEThsw?<}L|{Dt6! z4(Gh__e{&^Df(@h25gQ#b{eqLfU9u)V?`@s8Fjx5jFxtg0Ve~aMQNe)WT4ByXfc!l zCj+BJX(4Hy2JAFocWj_Ip7BQUX`PAH znOL2P)tOkYd>2)B?+e^JR~_HzOsvkts&0Y&KW}w>-GdBE3@EcPpk82NnYHs$KgDVB z^#1pUw){a)WH4)0D$&GhAYa?WGVPTyv6fO<2@`86%g)5=Osq$q3kN1vGlc57-95nN zrO%~#Se?qyzA`Xc3}wK{z-UogQAsZ&N*NffOv!+gfzhJ0(0MY@Wni=z%7BxB(W11F zw9drpOsvkt>P)Q8#Ofa4vg}DU^Y?Ld4{#};>h+^Nz~xaB%YkQlJ>eL_`r7{hU3!Y$ literal 179495 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 PGM?XZHn1JQJ$&&0C*Rxv