# RISK #1: Fixing Plan — HybridParser OCR Fallback Logic Tidak Robust

**Risk ID:** RISK-001  
**Severity:** 🟠 HIGH  
**File Target:** `app/services/parser/hybrid_parser.py`  
**Date:** 2026-05-12  
**Status:** PLANNING  

---

## 1. Scope Fix (4 Masalah)

| # | Masalah | Lokasi | Fix |
|---|---------|--------|-----|
| A | Tidak ada timeout pada OCR per-halaman | `_full_ocr()` | Tambah `signal.alarm` atau `threading.Timer` per halaman |
| B | Fallback gagal → `""` silent | `_full_ocr()` | Raise exception jika hasil kosong, bukan return `""` diam-diam |
| C | `RapidOCR` engine tidak thread-safe | `__init__` | Tambah `threading.Lock` untuk serialisasi akses ke `ocr_engine` |
| D | Semua `print()` bukan `logger` | Seluruh file | Ganti semua `print()` → `logger` |

---

## 2. Detail Setiap Fix

### Fix A: Timeout per Halaman di `_full_ocr`

**Before:**
```python
def _full_ocr(self, pdf_path: str) -> str:
    doc = fitz.open(pdf_path)
    for page_num in range(len(doc)):
        pix = page.get_pixmap(dpi=150)
        img_bytes = pix.tobytes("png")
        result, _ = self.ocr_engine(img_bytes)  # ← bisa hang selamanya
```

**After:**
```python
OCR_PAGE_TIMEOUT = 30  # detik per halaman

def _ocr_single_page(self, img_bytes: bytes) -> str:
    """OCR satu halaman dengan timeout protection."""
    result_container = []
    error_container = []

    def _run():
        try:
            result, _ = self.ocr_engine(img_bytes)
            result_container.append(result)
        except Exception as e:
            error_container.append(e)

    t = threading.Thread(target=_run)
    t.start()
    t.join(timeout=OCR_PAGE_TIMEOUT)

    if t.is_alive():
        logger.warning(f"OCR timeout setelah {OCR_PAGE_TIMEOUT}s, halaman dilewati.")
        return ""

    if error_container:
        raise error_container[0]

    result = result_container[0] if result_container else None
    if result:
        return "\n".join([item[1] for item in result])
    return ""

def _full_ocr(self, pdf_path: str) -> str:
    doc = fitz.open(pdf_path)
    full_text = []
    for page_num in range(len(doc)):
        page = doc[page_num]
        pix = page.get_pixmap(dpi=150)
        img_bytes = pix.tobytes("png")
        page_text = self._ocr_single_page(img_bytes)  # ← dengan timeout
        if page_text:
            full_text.append(page_text)
    return "\n\n".join(full_text)
```

**Catatan:** Menggunakan `threading.Thread` + `join(timeout=)` karena:
- Kompatibel lintas OS (Linux & Windows)
- `signal.alarm` hanya bekerja di main thread Linux

---

### Fix B: Raise Exception jika Hasil Kosong

**Before:**
```python
def _full_ocr(self, pdf_path: str) -> str:
    try:
        ...
    except Exception as e:
        print(f"[HybridParser] Error pada _full_ocr: {e}")
        return ""  # ← silent failure
```

**After:**
```python
def _full_ocr(self, pdf_path: str) -> str:
    try:
        ...
        if not full_text:
            raise RuntimeError(f"_full_ocr menghasilkan teks kosong untuk {pdf_path}")
        return "\n\n".join(full_text)
    except Exception as e:
        logger.error(f"[HybridParser] Error pada _full_ocr: {e}", exc_info=True)
        raise  # ← propagate ke caller, bukan diam-diam return ""
```

**Implikasi di `ingestion.py`:** Caller (`_repair_supabase_if_missing` dan pipeline utama) sudah punya `try/except` — exception akan tertangkap dan di-log dengan benar.

---

### Fix C: Thread Lock untuk `ocr_engine`

**Before:**
```python
class HybridParser:
    def __init__(self):
        self.ocr_engine = RapidOCR()  # ← shared, tidak thread-safe
```

**After:**
```python
class HybridParser:
    def __init__(self):
        self.ocr_engine = RapidOCR()
        self._ocr_lock = threading.Lock()  # ← serialisasi akses
```

Semua pemanggilan `self.ocr_engine` dibungkus:
```python
with self._ocr_lock:
    result, _ = self.ocr_engine(img_bytes)
```

**Trade-off:** OCR menjadi serial (tidak paralel), tapi ini sudah sesuai dengan desain awal — `HybridParser` memang dirancang CPU-bound sequential.

---

### Fix D: Ganti `print()` → `logger`

Tambah di atas class:
```python
import logging
logger = logging.getLogger(__name__)
```

Pemetaan severity:

| Kondisi | Before | After |
|---------|--------|-------|
| `is_gibberish` error | `print()` | `logger.error(..., exc_info=True)` |
| OCR timeout per halaman | — (baru) | `logger.warning()` |
| `_full_ocr` error | `print()` | `logger.error(..., exc_info=True)` |
| pymupdf4llm None/kosong | `print()` | `logger.warning()` |
| OCR gambar gagal | `print()` | `logger.warning()` |
| pymupdf4llm error | `print()` | `logger.error(..., exc_info=True)` |
| `parse_pdf` deteksi mode | `print()` | `logger.info()` |

---

## 3. Urutan Implementasi

| # | Langkah | File | Estimasi |
|---|---------|------|----------|
| 1 | Tambah `import threading, logging` + `logger` | `hybrid_parser.py` | 2 menit |
| 2 | Tambah `_ocr_lock` di `__init__` | `hybrid_parser.py` | 1 menit |
| 3 | Buat helper `_ocr_single_page` dengan timeout | `hybrid_parser.py` | 5 menit |
| 4 | Refactor `_full_ocr` pakai `_ocr_single_page` + raise | `hybrid_parser.py` | 5 menit |
| 5 | Ganti semua `print()` → `logger` | `hybrid_parser.py` | 3 menit |
| 6 | Verify syntax `py_compile` | — | 1 menit |
| 7 | Smoke test | — | 5 menit |
| **Total** | | | **~22 menit** |

---

## 4. Yang TIDAK Diubah

- ❌ Tidak mengubah logic `is_gibberish` (threshold, sampling 3 halaman)
- ❌ Tidak mengubah logic `_extract_with_pymupdf4llm` (pymupdf4llm flow)
- ❌ Tidak mengubah interface publik `parse_pdf(pdf_path)` — caller tidak perlu diubah

---

## 5. Acceptance Criteria

- [ ] `_full_ocr` tidak bisa hang — setiap halaman punya timeout 30 detik
- [ ] `_full_ocr` raise exception jika hasil kosong, bukan return `""`
- [ ] Semua akses `self.ocr_engine` dibungkus `self._ocr_lock`
- [ ] Tidak ada `print()` di seluruh file
- [ ] `python3 -m py_compile hybrid_parser.py` → PASS
- [ ] Smoke test instantiasi `HybridParser()` → PASS
