14 KiB
14 KiB
ADR-040: OCR Sidecar Refactor — Pure Compute Worker, Preserved GPU Policy, Network-Trust Boundary
Status: Proposed Date: 2026-06-20 Supersedes: ADR-033 §7 (X-API-Key sidecar auth) Amends: ADR-036 §5 (sidecar contract), ADR-034 (model identity unchanged) Related Documents:
- ADR-016: Security & Authentication
- ADR-008: Email Notification Strategy
- ADR-029: Dynamic Prompt Management
- ADR-037: Active Prompt System
- ADR-035: AI Pipeline & OCR Integration
- ADR-041: Server Consolidation
- CONTEXT.md
- OCR Sidecar Refactor Plan - Claude
- OCR Sidecar Refactor Plan - Qwen
Note: ADR numbers 038–039 are intentionally reserved/skipped.
🎯 Context and Problem Statement
Current Architecture
OCR Sidecar บน Desk-5439 (RTX 5060 Ti 16GB) ทำหน้าที่เป็น FastAPI HTTP service สำหรับ:
/ocr- สกัดข้อความจาก PDF ผ่าน Typhoon OCR (np-dms-ocr via Ollama)/embed- สร้าง vector embedding ผ่าน BGE-M3/rerank- จัดลำดับผลลัพธ์ retrieval ผ่าน FlagReranker/normalize- normalize ภาษาไทย (ใช้โดย ThaiPreprocessProcessor)
Problems Identified
จากการทบทวนสองแผน refactor (Claude + Qwen) พบปัญหาดังนี้:
- Security Bug: Hardcoded default API key (
lcbp3-dms-ocr-sidecar-secure-token-2026) ในapp.py— หาก leak จะไม่สามารถ rotate ได้โดยไม่ rebuild container - Synchronous Blocking I/O:
process_ocrใช้httpx.Clientแบบ sync ทำให้ block event loop ของ FastAPI - Deprecated Startup Pattern: ใช้
@app.on_event("startup")แทนlifespancontext manager - Hardcoded keep_alive:
process_ocrบังคับkeep_alive: 0แต่ไม่ได้เรียกcalculate_ocr_residency()จากresidency_policy.py— ทำให้ Adaptive OCR Residency policy ไม่ทำงาน - Hardcoded Runtime Parameters:
temperature,top_p,repeat_penalty,max_tokensถูก hardcode ใน sidecar แทนการดึงจากai_execution_profiles(ADR-036 Profile-Only Parameter Governance) - Path Traversal Vulnerability:
/ocrendpoint เปิดไฟล์ตามreq.pdfPathโดยไม่มี canonicalization/whitelist — เสี่ยง arbitrary file read (ADR-016) - Cross-Host Trust Gap: ปัจจุบัน sidecar อยู่บน Desk-5439 (192.168.10.100) และ backend อยู่บน QNAP (192.168.10.8) — "Docker internal network" เป็นเท็จ ต้องพึ่ง VLAN/firewall ACL
- Mutable Default Argument:
process_with_typhoon_ocr(pdf_path, ..., options_override={})— Python anti-pattern
Conflict with Canonical Specs
การทบทวนทั้งสองแผนพบว่า:
- Claude สมมติ
np-dms-ai = llama3.2 3B (~2–3GB)แต่ ADR-034/CONTEXT ระบุnp-dms-airuntime คือ Typhoon-2.5 (~7–8B) — VRAM budget ผิด - ทั้งสองแผน เสนอลบ
vram_monitor.py/residency_policy.pyและบังคับ BGE+Reranker GPU-resident — ละเมิด LLM-First GPU Ownership + CPU Fallback Retrieval ที่ CONTEXT.md ได้ resolve ไว้แล้ว - ทั้งสองแผน ถือ
keep_aliveเป็น fixed config value — ละเมิด ADR-036 Gap-2 (keep_alive = lazy resource param via residency policy)
⚙️ Decision Drivers
- Preserve Resolved GPU Policy: Adaptive OCR Residency + CPU Fallback Retrieval + LLM-First GPU Ownership (CONTEXT.md)
- Profile-Only Parameter Governance: พารามิเตอร์ AI model (temperature, top_p, keep_alive) ต้องมาจาก
ai_execution_profilesrowocr-extract(ADR-036) - Security (ADR-016): Path traversal hardening, no hardcoded secrets
- Network Trust Boundary: Server consolidation (ADR-041) ทำให้ Docker-internal isolation เป็นไปได้จริง
- No Invented Orchestration: ห้ามสร้าง
VramMutexService,GpuTaskQueue,PromptBuilderServiceใหม่ — ใช้ existing services/Active Prompt ตาม ADR-008, ADR-029/037 - ADR-023A Boundary: AI sidecar ห้ามเข้าถึง DB/storage โดยตรง
🏛️ Decisions
D1: Sidecar as Pure Compute Worker
Sidecar ทำหน้าที่เป็น compute worker เท่านั้น — orchestration, parameter governance, และ business logic อยู่ใน backend (existing services)
- Reject: การสร้าง
PromptBuilderService,OcrNoiseFilterService,OcrOrchestratorServiceใหม่ (Qwen plan) - Fast-path decision (PyMuPDF chars > 100 → fast path): คงไว้ใน sidecar
- Page range calculation: ย้ายไป backend
- Engine selection: ไม่ต้องมีแล้ว — ใช้ np-dms-ocr ตัวเดียว (Typhoon OCR)
- systemPrompt validation (ตรวจสอบ placeholders เช่น
{{ocr_text}}): backend
D2: Remove /normalize Endpoint
- ตัด /normalize endpoint ออกจาก sidecar
- ใช้แค่ np-dms-ocr (OCR) เท่านั้น — sidecar ไม่รองรับ Thai normalization
- ThaiPreprocessProcessor ไม่มีการใช้งาน — ไม่ต้องแก้ไข backend
D3: Async I/O + Lifespan + Shared AsyncClient
process_ocr→async def- ใช้
httpx.AsyncClientshared ผ่าน lifespan context manager - เปลี่ยนจาก
@app.on_event("startup")เป็น@asynccontextmanagerlifespan - Load models ผ่าน
asyncio.to_threadเพื่อไม่ block startup
D4: keep_alive via calculate_ocr_residency() (Lazy, ADR-036 Gap-2)
- Wire
calculate_ocr_residency(active_profile)เข้าprocess_ocr - ไม่ใช้ fixed value (Claude 300, Qwen 0/10m)
- ไม่รับ explicit
options_override["keep_alive"]จาก backend — keep_alive เป็น lazy resource param ที่คำนวณณ process time เท่านั้น (ADR-036 Gap-2) - Reject: การลบ
vram_monitor.py/residency_policy.py
D5: Retain vram_monitor + CPU-Fallback for /embed, /rerank
- Reject: การบังคับ BGE-M3 + Reranker GPU-resident ถาวร
- Keep: Dynamic CPU/GPU selection ผ่าน
.to(device)logic - เป็นการ implement LLM-First GPU Ownership + CPU Fallback Retrieval
D6: Remove Hardcoded Default Key; Auth = Network Isolation (2-Phase)
- Phase 1 (ก่อน consolidation): ลบ hardcoded default
OCR_SIDECAR_API_KEY— fail-fast ถ้า env missing - Phase 2 (หลัง consolidation): Supersedes ADR-033 §7 — ลบ
X-API-Keyvalidation จาก sidecar endpoints และ backend send-side - Network Isolation: ตรวจสอบผ่าน Docker-internal network (post-consolidation) หรือ VLAN/firewall ACL (interim cross-host)
- Sequencing: ลบ
X-API-Keyเฉพาะเมื่อ ADR-041 cutover เสร็จ (single Docker host) - Interim Period: ระหว่าง Phase 1 และ Phase 2, sidecar และ backend ต้อง ยังคง validate และส่ง
X-API-Key - Rotate leaked key ก่อน cutover
D7: Path Canonicalization + Base-Path Whitelist on /ocr
- Canonicalize
pdfPathผ่านos.path.abspath()+os.path.realpath() - Whitelist base path =
OCR_SIDECAR_UPLOAD_BASE(CIFS mount base) - Reject paths ที่ไม่ได้อยู่ภายใต้ base path → 403 Forbidden
D8: Runtime Params from Job Snapshot (ocr-extract row)
- Backend resolve params จาก
ai_execution_profiles(rowocr-extractสำหรับ OCR, profile สำหรับ LLM) - Backend ส่ง params (
temperature,top_p,repeat_penalty,max_tokens) ไปให้ sidecar - Sidecar รับ params จาก backend แล้วส่งต่อไป Ollama (ในทุกครั้งที่ load/generate)
- ห้าม hardcode defaults ใน sidecar
- Modfile ทำหน้าที่เป็น last-resort fallback เท่านั้น
- Align กับ ADR-036 Profile-Only Parameter Governance
D9: DMS Tags + SystemPrompt from Active Prompt
- Backend resolve systemPrompt จาก Active Prompt ใน
ai_prompts(ADR-029/037) - Backend resolve DMS extraction tags (
<document_number>,<document_date>,<received_date>) จาก Active Prompt - Backend ส่งทั้ง systemPrompt และ DMS tags ไปให้ sidecar
- Sidecar รับ systemPrompt และ DMS tags จาก backend แล้วส่งต่อไป Ollama (ในทุกครั้งที่ load/generate)
- Reject: การสร้าง
PromptBuilderServiceใหม่เป็น prompt authority
📋 Implementation Tasks
Phase 1 — ก่อน ADR-041 Consolidation (ยังคง X-API-Key)
| Task ID | Component | Summary | Status |
|---|---|---|---|
| T001 | Sidecar | Remove hardcoded default API key (fail-fast if env missing) | Pending |
| T002 | Sidecar | Fix mutable default arg options_override={} |
Pending |
| T003 | Sidecar | Remove duplicate import tempfile |
Pending |
| T004 | Sidecar | Refactor to async I/O + shared AsyncClient | Pending |
| T005 | Sidecar | Replace @app.on_event("startup") with lifespan |
Pending |
| T006 | Sidecar | Wire calculate_ocr_residency() into process_ocr |
Pending |
| T007 | Sidecar | Path canonicalization + base-path whitelist on /ocr |
Pending |
| T008 | Sidecar | Remove hardcoded runtime params (use from job snapshot) | Pending |
| T009 | Sidecar | Receive systemPrompt + DMS tags from backend, pass to Ollama | Pending |
| T010 | Sidecar | Remove /normalize endpoint (D2) |
Pending |
| T011 | Backend | Send runtime params from ai_execution_profiles snapshot to sidecar |
Pending |
| T012 | Backend | Wire Active Prompt injection for DMS tags + systemPrompt | Pending |
| T013 | Tests | Pytest for path-traversal (403) | Pending |
| T014 | Tests | Unit check for residency wiring | Pending |
Phase 2 — หลัง ADR-041 Consolidation (ลบ X-API-Key)
| Task ID | Component | Summary | Status |
|---|---|---|---|
| T016 | Sidecar | Remove X-API-Key validation from endpoints |
Pending (ADR-041 cutover) |
| T017 | Backend | Remove X-API-Key send-side in OcrService |
Pending (ADR-041 cutover) |
| T018 | Backend | Remove X-API-Key send-side in SandboxOcrEngineService |
Pending (ADR-041 cutover) |
📋 Consequences
Positive
- OOM Safety Retained: รักษา Adaptive OCR Residency + CPU Fallback Retrieval — ป้องกัน VRAM exhaustion
- Spec-Consistent: สอดคล้องกับ ADR-036, ADR-029/037, CONTEXT.md
- Smaller Sidecar Surface: Pure compute worker — ไม่มี business logic หรือ parameter governance
- Security Hardened: Path traversal fix, no hardcoded secrets
- Performance: Async I/O ลด blocking, shared AsyncClient ลด connection overhead
Negative
- Lose Defense-in-Depth Auth: ลบ
X-API-Keyทำให้ขึ้นอยู่กับ network isolation เท่านั้น — mitigated โดย ACL/bridge network - Cross-Host Firewall Rule Mandatory: ใน topology ปัจจุบัน (ก่อน consolidation) ต้องมี VLAN/firewall ACL เป็น interim constraint
- Migration Complexity: Sequencing ของ auth removal ต้อง sync กับ ADR-041 cutover
🚫 Out of Scope (Future ADR)
- 1-page-1-request horizontal scaling rework (Qwen 2.7) — ต้องการ separate spec + load evidence
- OpenTelemetry/Prometheus/Grafana observability (Qwen 4.4–4.5) — separate ticket
- /normalize endpoint — ตัดออกจาก sidecar แล้ว (D2); ThaiPreprocessProcessor ไม่มีการใช้งาน
🔄 Rollback Plan
- Revert
app.pyไปเวอร์ชันก่อน refactor - Restore
X-API-Keysend-side ในOcrServiceและSandboxOcrEngineService - Re-pin
keep_alivedefault เป็น0ในprocess_ocr - Restore hardcoded runtime params (ถ้าต้องการ emergency fallback)
📝 Verification Plan
- Confirm backend send-side
X-API-Keylocations:backend/src/modules/ai/services/ocr.service.tsbackend/src/modules/ai/services/sandbox-ocr-engine.service.ts
- Confirm
calculate_ocr_residencyไม่ถูกเรียกใช้ในapp.py(grep) ก่อน claim gap - ✅ ยืนยันแล้ว: ไม่มี consumer ใดใช้
/normalizeendpoint (grep ไม่พบใน backend) - Pytest สำหรับ path-traversal (expect 403)
- Unit test สำหรับ residency wiring