# Quy Trình Tổng Hợp — OCR Pipeline: Trích Xuất → Hiệu Đính → Highlight

> **Dành cho Architect** — toàn bộ pipeline từ PDF scan đến file đã highlight
> Tạo: 2026-06-25 | Dự án: **002-cung-cach-sa-di** | Sách: **ရုပ်ပုံ ရှင်ကျင့်ဝတ်** (264 trang)

---

## 🗺️ Tổng Quan Pipeline

```
┌─────────────────────────────────────────────────────────────────────┐
│                    OCR PIPELINE TOÀN TRÌNH                           │
│                                                                      │
│  PHASE 1: TRÍCH XUẤT          PHASE 2: HIỆU ĐÍNH     PHASE 3: QA   │
│  ┌──────────────────┐    ┌──────────────────────┐   ┌────────────┐  │
│  │ PDF scan          │    │ Editor AI            │   │ Architect  │  │
│  │  ↓ (preprocess)   │    │ (cron, 2 LLM calls)  │   │ QA         │  │
│  │ Cloud Vision OCR  │ →  │  ↓                   │ → │ ↓          │  │
│  │  ↓                │    │ edited/gemini-flash/  │   │ apply-marks│  │
│  │ JSON raw           │    │ edited-notes/        │   │ .py        │  │
│  │  ↓ V9 script      │    │ (bảng sửa lỗi)       │   │ ↓          │  │
│  │ extracted/ (.md)  │    └──────────────────────┘   │ <mark>     │  │
│  └──────────────────┘                                └────────────┘  │
└─────────────────────────────────────────────────────────────────────┘
```

| Phase | Ai làm | Công cụ | Input | Output |
|-------|--------|---------|-------|--------|
| **1. Trích xuất** | Architect (thủ công) | Cloud Vision API + V9 script | PDF scan | `extracted/*.md` (raw text, giữ nguyên thứ tự paragraph) |
| **2. Hiệu đính** | Editor Agent (cron tự động) | Gemini Flash, 2 LLM calls | `extracted/*.md` | `edited/*.md` + `edited-notes/*-notes.md` |
| **3. Highlight** | Architect (1 lệnh) | `apply-marks.py` | `edited/` + `edited-notes/` | `edited/*.md` (có `<mark>`) |

---

## 📊 Thông số dự án

| Thuộc tính | Giá trị |
|-----------|--------|
| **Dự án** | `002-cung-cach-sa-di` |
| **Tên sách** | ရုပ်ပုံ ရှင်ကျင့်�ဝတ် (Chuẩn Mực Sa-di) |
| **Tác giả** | အရှင် ဇနကာဘိဝံသ |
| **Số trang** | 264 |
| **Batch size** | 3 trang/file |
| **Tổng batches** | 88 |
| **Ngôn ngữ** | Myanmar (có xen Pāli) |
| **Đặc điểm** | Sách có nhiều hình minh họa |
| **Progress file** | `progress-ocr.json` |

---

# PHASE 1: TRÍCH XUẤT OCR (PDF → Markdown sạch)

## Bước 0: Preprocess Ảnh Scan (tùy chọn)

> **Đánh giá:** PDF scan chất lượng khá tốt, chữ rõ, ít nhiễu.
> **Khuyến nghị:** KHÔNG cần preprocess cho dự án này.
> Nếu phát hiện trang bị mờ/thấu quang → chạy preprocess cho trang cụ thể.

### Script (nếu cần)

```bash
# Trích xuất PDF → PNG
pdftoppm -r 300 -png input.pdf raw_pages/page

# Xử lý adaptive threshold + morphology
python3 preprocess_all.py

# Gộp lại thành PDF
img2pdf cleaned/page-*.png -o output-clean.pdf
```

---

## Bước 1: OCR với Cloud Vision API

### Thiết lập

- **Key file:** `old/google_service_account.json`
- **Project ID:** `zen-490314`
- **Service Account:** `openclaw-service@zen-490314.iam.gserviceaccount.com`
- **Quyền:** `roles/storage.objectAdmin` + `roles/visionai.user`

### Chạy OCR

```bash
cd /home/tuan-nguyen/.openclaw/workspace
export GOOGLE_APPLICATION_CREDENTIALS="old/google_service_account.json"

python3 scripts/ocr_pdf_to_raw.py \
  "002-cung-cach-sa-di/pdf/Chuan Muc Sadi.pdf" \
  "zen-ocr-pdf" \
  "002-cung-cach-sa-di/ocr/raw/"
```

> Output: JSON files trong `ocr/raw/` — 3 trang/file (~88 files)

---

## Bước 2: JSON → Markdown Raw (V9)

> **Extract nguyên vẹn text từ Cloud Vision** — paragraph-level, giữ đúng thứ tự.
> **KHÔNG clean header/footer** ở bước này — sẽ clean riêng ở Bước 3 (script) và Phase 2 (editor).

### Cách chạy

```bash
cd /home/tuan-nguyen/.openclaw/workspace

# Chạy bình thường
python3 obsidian/scripts/json_to_markdown_v9.py \
  "002-cung-cach-sa-di/ocr/raw/" \
  "002-cung-cach-sa-di/extracted/"

# Debug (hiện paragraph count mỗi trang)
python3 obsidian/scripts/json_to_markdown_v9.py \
  "002-cung-cach-sa-di/ocr/raw/" \
  "002-cung-cach-sa-di/extracted/" \
  --debug

# Dry-run
python3 obsidian/scripts/json_to_markdown_v9.py \
  "002-cung-cach-sa-di/ocr/raw/" \
  "002-cung-cach-sa-di/extracted/" \
  --dry-run
```

### Output

- Mỗi file JSON → 1 file `.md` (VD: `output-1-to-3.md`, `output-4-to-6.md`, ...)
- Mỗi trang bắt đầu bằng `## PAGE X`
- Mỗi paragraph từ Cloud Vision → 1 paragraph trong markdown, cách nhau bằng dòng trống
- ⚠️ **Header/footer/URL vẫn còn trong output** — sẽ clean ở bước sau

---

## Bước 3: Gộp Dòng Bị Ngắt (Line Merging) ⭐

> **Mục đích:** Giảm số dòng ~36%, tiết kiệm input token cho Phase 2 (Editor AI).
> OCR ngắt dòng theo layout PDF — nhiều câu bị cắt ngang giữa chừng.

### Quy tắc

- Dòng **KHÔNG** kết thúc bằng `။` hoặc `၊` → **nối với dòng sau** (cách 1 space)
- Dòng kết thúc bằng `။` hoặc `၊` → giữ nguyên, xuống dòng
- Dòng trống + `## PAGE X` → giữ nguyên

### Chạy

```bash
cd 002-cung-cach-sa-di && python3 merge_lines.py
```

> Script `merge_lines.py` nằm trong thư mục dự án. Có thể copy sang dự án khác.

### Kết quả thực tế

| Trước | Sau | Giảm |
|-------|-----|------|
| 9,607 dòng | 6,108 dòng | 36% (3,499 dòng đã gộp) |

---

## Bước 4: Clean Noise (URL + Footer Artifact)

> **Sau khi đã gộp dòng**, dùng script Python để xóa các artifact lặp lại.

### 4a. Xóa URL

```bash
cd 002-cung-cach-sa-di/extracted && python3 -c "
import re, glob
url_pattern = re.compile(r'https?://\S+', re.IGNORECASE)
for fpath in sorted(glob.glob('*.md')):
    with open(fpath) as f: lines = f.readlines()
    new = [l for l in lines if not url_pattern.search(l) or l.startswith('## PAGE')]
    if len(new) != len(lines):
        with open(fpath, 'w') as f: f.writelines(new)
"
```

### 4b. Xóa Footer (dòng chứa `အကြိမ်` ở cuối trang)

```bash
cd 002-cung-cach-sa-di/extracted && python3 -c "
import re, glob
footer_re = re.compile(r'^.{0,40}အကြိမ်.{0,10}$')
for fpath in sorted(glob.glob('*.md')):
    with open(fpath) as f: lines = f.readlines()
    new = []
    for i, line in enumerate(lines):
        ls = line.strip()
        if footer_re.search(ls) and len(ls) < 40:
            prev_ok = (i==0 or lines[i-1].strip()=='' or lines[i-1].strip().startswith('## PAGE'))
            next_ok = (i+1>=len(lines) or lines[i+1].strip()=='' or lines[i+1].strip().startswith('## PAGE'))
            if prev_ok and next_ok: continue
        new.append(line)
    if len(new) != len(lines):
        with open(fpath, 'w') as f: f.writelines(new)
# Regenerate merged
all_md = []
for fpath in sorted(glob.glob('output-*.md')):
    with open(fpath) as f: all_md.append(f.read())
with open('full-merged.md', 'w') as f:
    f.write('# ရုပ်ပုံ ရှင်ကျင့်ဝတ် — OCR Raw Extraction (V9)\n\n')
    f.write('\n'.join(all_md))
"
```

> ⚠️ **Lưu ý:** Header (tên chương + số trang + tên tác giả ở đầu mỗi page) **không clean ở bước này** — editor sẽ xóa trong Phase 2 dựa trên Rule I trong guide.

---

# PHASE 2: HIỆU ĐÍNH OCR (Markdown sạch → Edited)

## Kiến trúc

```
┌──────────────────────────────────────────────────────┐
│                 ARCHITECT (giám sát)                  │
│  • 1 cron job ocr-processor (every 15min)             │
│  • 1 cron job ocr-monitor (every 30min)               │
│  • QA hậu kiểm (Phase 3)                             │
└──────────────────────────────────────────────────────┘

┌──────────────────────────────────────────────────────┐
│         EDITOR AI (Gemini Flash, 2 LLM CALLS)         │
│  ┌──────────────────────────────────────────┐        │
│  │ PHASE 1: exec python                      │        │
│  │   • Chọn batch pending                    │        │
│  │   • Đọc source từ extracted/              │        │
│  │   • Đọc GUIDE (03A) + SQLite patterns     │        │
│  │   → In tất cả vào exec output             │        │
│  └──────────────────────────────────────────┘        │
│  ┌──────────────────────────────────────────┐        │
│  │ PHASE 2: ALL TOOLS IN 1 RESPONSE          │        │
│  │   • write → edited/gemini-flash/{B}.md    │        │
│  │   • write → edited-notes/{B}-notes.md     │        │
│  │   • exec → update progress-ocr.json       │        │
│  │   ⚠️ KHÔNG text report riêng!             │        │
│  └──────────────────────────────────────────┘        │
│  ⚡ ~23K token, ~$0.008/batch                        │
└──────────────────────────────────────────────────────┘
```

## Thiết lập Cron Jobs

### Cron 1: `ocr-processor` (Editor — mỗi 15 phút)

```bash
openclaw cron add \
  --name ocr-processor-sadi \
  --every 15m \
  --session isolated \
  --agent editor \
  --model openrouter/google/gemini-3-flash-preview \
  --light-context true \
  --thinking off \
  --message 'Bạn là Editor Agent hiệu đính OCR Myanmar — TỐI ƯU 2 LLM CALLS.

═══════════════════
QUY TẮC — XEM GUIDE (in từ Phase 1)
═══════════════════

→ Toàn bộ quy tắc hiệu đính nằm trong file 03A-Hieu-Dinh-Myanmar-OCR.md
→ Phase 1 sẽ in nội dung guide ra cùng với source + patterns
→ Editor đọc guide từ Phase 1 output, không cần rules inline trong prompt này

═══════════════════
PHASE 1: CHẠY SCRIPT NÀY (1 LLM call)
═══════════════════

```python
import json, os, sqlite3, glob
from datetime import datetime, timezone, timedelta
PROJ = "/home/tuan-nguyen/.openclaw/workspace/002-cung-cach-sa-di"
tz = timezone(timedelta(hours=7))

with open(f"{PROJ}/guide/03A-Hieu-Dinh-Myanmar-OCR.md") as f: guide = f.read()

with open(f"{PROJ}/progress-ocr.json") as f: data = json.load(f)
edited = {os.path.basename(x).replace(".md","") for x in glob.glob(f"{PROJ}/edited/gemini-flash/*.md")}
notes = {os.path.basename(x).replace("-notes.md","") for x in glob.glob(f"{PROJ}/edited-notes/gemini-flash/*.md")}
for b in data["batches"]:
    if b["status"] == "in_progress":
        bn = b["name"]
        if bn in edited and bn in notes:
            b["status"] = "done"; b["completed_at"] = datetime.now(tz).isoformat()
            print("RECOVER:" + bn)
        else:
            started = datetime.fromisoformat(b.get("started_at","2000-01-01T00:00:00+07"))
            if (datetime.now(tz)-started).total_seconds() > 900:
                b["status"] = "pending"; print("STUCK:" + bn)
            else: print("SKIP:" + bn); exit()
        with open(f"{PROJ}/progress-ocr.json","w") as f: json.dump(data,f,indent=2,ensure_ascii=False)
target = None
for b in data["batches"]:
    bn = b["name"]
    if b["status"] == "pending" or (bn not in edited or bn not in notes):
        target = b; break
if target is None:
    print("ALL_DONE")
    exit()
target["status"] = "in_progress"; target["started_at"] = datetime.now(tz).isoformat()
BATCH = target["name"]; print("BATCH:" + BATCH)
with open(f"{PROJ}/progress-ocr.json","w") as f: json.dump(data,f,indent=2,ensure_ascii=False)

# Đọc source TRỰC TIẾP từ extracted
with open(f"{PROJ}/extracted/{BATCH}.md") as f: source = f.read()

conn = sqlite3.connect("/home/tuan-nguyen/.openclaw/workspace/data/ocr-checklist-vec.db")
cur = conn.cursor()
cur.execute("SELECT find_text, replace_text, note FROM patterns WHERE is_mechanical=1 ORDER BY id LIMIT 30")
mech = cur.fetchall()
cur.execute("SELECT find_text, replace_text, note, category FROM patterns WHERE is_mechanical=0 ORDER BY id LIMIT 60")
sem = cur.fetchall()
conn.close()
print(f"GUIDE:{len(guide)} chars")
print(f"PATTERNS:{len(mech)} mech + {len(sem)} semantic")
print("---GUIDE_START---")
print(guide)
print("---GUIDE_END---")
print("---SOURCE_START---")
print(source)
print("---SOURCE_END---")
print("---PATTERNS_START---")
for f_text, r_text, note, cat in sem:
    print(f"  [{cat}] {f_text} -> {r_text} | {note}")
print("---PATTERNS_END---")
```

═══════════════════
PHASE 2: 1 RESPONSE — GỌI TẤT CẢ TOOL CÙNG LÚC
═══════════════════

⚠️ PHASE NÀY PHẢI HOÀN THÀNH TRONG ĐÚNG 1 RESPONSE.
   GỌI TẤT CẢ TOOL SONG SONG TRONG CÙNG 1 LƯỢT.

⚠️ Nếu Phase 1 in "ALL_DONE": KHÔNG làm gì hết.

Khi Phase 1 in "BATCH:{tên_batch}":
   → Đọc GUIDE (giữa ---GUIDE_START--- và ---GUIDE_END---)
   → Đọc SOURCE (giữa ---SOURCE_START--- và ---SOURCE_END---)
   → Đọc PATTERNS (giữa ---PATTERNS_START--- và ---PATTERNS_END---)
   → Áp dụng GUIDE + patterns để HIỆU ĐÍNH — KHÔNG DỊCH

   → SAU ĐÓ, GỌI ĐỒNG THỜI TẤT CẢ CÁC TOOL SAU TRONG 1 RESPONSE:

   [TOOL 1] write:
     path: /home/tuan-nguyen/.openclaw/workspace/002-cung-cach-sa-di/edited/gemini-flash/{BATCH}.md
     content: toàn bộ file đã hiệu đính (ngắt đoạn + bold Pāli, GIỮ NGUYÊN TIẾNG MYANMAR)

   [TOOL 2] write:
     path: /home/tuan-nguyen/.openclaw/workspace/002-cung-cach-sa-di/edited-notes/gemini-flash/{BATCH}-notes.md
     content: bảng notes markdown (CHỈ ghi lỗi thay đổi ký tự, không ghi formatting)
     | # | Trang | Lỗi (OCR) | Đã sửa thành | Loại |
     |---|-------|-----------|-------------|------|

   [TOOL 3] exec python:
     import json
     from datetime import datetime, timezone, timedelta
     PROJ = "/home/tuan-nguyen/.openclaw/workspace/002-cung-cach-sa-di"
     BATCH = "{BATCH}"
     with open(f"{PROJ}/progress-ocr.json") as f: data = json.load(f)
     for b in data["batches"]:
         if b["name"] == BATCH: b["status"] = "done"; b["completed_at"] = datetime.now(timezone(timedelta(hours=7))).isoformat(); break
     with open(f"{PROJ}/progress-ocr.json","w") as f: json.dump(data,f,indent=2,ensure_ascii=False)
     print("OK")

   → SAU TOOL, thêm 1 dòng text ngắn xác nhận.' \
  --timeout-seconds 600 \
  --delivery "announce,telegram,412242443"
```

### Cron 2: `ocr-monitor` (Architect — mỗi 30 phút)

```bash
openclaw cron add \
  --name ocr-monitor-sadi \
  --every 30m \
  --session isolated \
  --agent architect \
  --model deepseek/deepseek-v4-flash \
  --light-context true \
  --message '## OCR Monitor — Sadi

### 1. Check progress
```python
import json, glob
PROJ = "/home/tuan-nguyen/.openclaw/workspace/002-cung-cach-sa-di"
with open(f"{PROJ}/progress-ocr.json") as f: data = json.load(f)
from collections import Counter
c = Counter(b["status"] for b in data["batches"])
n = len(glob.glob(f"{PROJ}/edited-notes/gemini-flash/*.md"))
print(f"Done={c.get(\"done\",0)} InProg={c.get(\"in_progress\",0)} Pend={c.get(\"pending\",0)} Notes={n} total={len(data[\"batches\"])}")
```

### 2. Detect issues
- in_progress > 20min + no output → STUCK → reset to pending
- in_progress == 0 + pending > 0 + idle > 20min → PIPELINE DEAD

### 3. Self-disable khi ALL DONE
When done == total_batches:
  1. cron: find "ocr-processor-sadi" → update enabled=false
  2. cron: find "ocr-monitor-sadi" → update enabled=false
  3. Report "✅ Sadi Pipeline complete — all crons disabled."

### 4. Report 1 dòng' \
  --timeout-seconds 120 \
  --delivery "announce,telegram,412242443"
```

> ⚠️ Cron monitor chỉ enable khi pipeline đang chạy. Disable khi idle.

---

## Cấu trúc thư mục dự án

```
002-cung-cach-sa-di/
├── pdf/                    # PDF gốc: Chuan Muc Sadi.pdf
├── ocr/raw/                # JSON output từ Cloud Vision
├── extracted/              # Markdown raw (V9 output)
├── edited/
│   └── gemini-flash/       # File đã hiệu đính (Editor)
├── edited-notes/
│   └── gemini-flash/       # Bảng notes sửa lỗi (Editor)
├── guide/
│   ├── 03A-Hieu-Dinh-Myanmar-OCR.md   # Quy tắc hiệu đính
│   └── 04-OCR-Pipeline-Full.md        # Quy trình tổng hợp (file này)
├── progress-ocr.json       # Tiến độ pipeline (88 batches)
└── ver-1/                  # Phiên bản cũ (không dùng)
```

---

# PHASE 3: QA HIGHLIGHT (Edited → Marked)

## Script: `apply-marks.py`

**Vị trí:** `obsidian/scripts/apply-marks.py`

Trước khi chạy, cập nhật symlink:

```bash
cd /home/tuan-nguyen/.openclaw/workspace/obsidian/scripts
rm -f edited/gemini-flash edited-notes/gemini-flash
ln -s /home/tuan-nguyen/.openclaw/workspace/002-cung-cach-sa-di/edited/gemini-flash edited/gemini-flash
ln -s /home/tuan-nguyen/.openclaw/workspace/002-cung-cach-sa-di/edited-notes/gemini-flash edited-notes/gemini-flash
```

### Cách chạy

```bash
cd /home/tuan-nguyen/.openclaw/workspace/obsidian/scripts

# Dry-run: preview không sửa file
python3 apply-marks.py --all --dry-run

# Chạy thật
python3 apply-marks.py --all
```

---

# 🎯 QUY TRÌNH VẬN HÀNH (Architect Checklist)

## Khởi động Pipeline

- [ ] **Phase 1:** Chạy OCR `scripts/ocr_pdf_to_raw.py` → JSON files trong `ocr/raw/`
- [ ] **Phase 1:** Chạy V9 `obsidian/scripts/json_to_markdown_v9.py` → `extracted/*.md`
- [ ] **Phase 1:** Clean noise (URL + footer) → regenerate `full-merged.md`
- [ ] **Phase 2:** Enable `ocr-processor-sadi` + `ocr-monitor-sadi` cron jobs
- [ ] **Giám sát:** Theo dõi Telegram notifications từ monitor
- [ ] **Khi ALL DONE:** Monitor tự disable cả 2 cron jobs

## Sau khi Pipeline hoàn tất

- [ ] **Phase 3 — Highlight:** Cập nhật symlink → chạy `apply-marks.py --all`
- [ ] **Kiểm tra:** `grep -c '<mark>' edited/gemini-flash/*.md` để đếm marks
- [ ] **Spot-check:** Mở vài file xem marks có chính xác không

## Xử lý sự cố

| Sự cố | Hành động |
|--------|----------|
| Cron processor stuck | Monitor tự reset → pending sau 15ph |
| Pipeline dead | Architect kiểm tra log → restart cron nếu cần |
| Symlink sai dự án | `ls -la` kiểm tra → sửa symlink → restore backup |
| Marks không khớp | Kiểm tra notes format (cột "Đã sửa thành") → chạy lại |
| Lỡ modify sai file | Copy từ `_backup/marks/` timestamp gần nhất |
| V9 cần extract lại | Chạy V9 → clean noise → reset batch → enable cron |

---

## File Liên Quan

| File | Nội dung |
|------|----------|
| [[03A-Hieu-Dinh-Myanmar-OCR]] | Quy tắc hiệu đính OCR (single source of truth) |
| `obsidian/scripts/json_to_markdown_v9.py` | Script JSON→MD Phase 1 (V9 — paragraph-level) |
| `obsidian/scripts/apply-marks.py` | Script highlight Phase 3 |
| `scripts/ocr_pdf_to_raw.py` | Script gọi Cloud Vision OCR |
| `data/ocr-checklist-vec.db` | SQLite patterns cho Phase 2 |
| `ver-1/` | Phiên bản cũ (OCR từng trang riêng) — tham khảo |
