# 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ổng hợp: 2026-06-13 | Cập nhật: 2026-08-06 (bridge-fix v2, đầy đủ edge case) | Từ 04-OCR-Pipeline + 01A-Dieu-Phoi-Editor-OCR

---

## 🗺️ 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)       │   │ ↓          │  │
│  │  ↓ merge dòng     │    └──────────────────────┘   │ <mark>     │  │
│  │ extracted/ (.md)  │                                │ ↓          │  │
│  └──────────────────┘                                │ bridge-fix │  │
│                                                      │ .py        │  │
│                                                      │ ↓ (nối câu)│  │
│                                                      └────────────┘  │
└─────────────────────────────────────────────────────────────────────┘
```

| Phase | Ai làm | Công cụ | Input | Output |
|-------|--------|---------|-------|--------|
| **1. Trích xuất** | Architect (thủ công) | Cloud Vision API + V9 + merge_lines | PDF scan | `extracted/*.md` (đã gộp dòng, clean noise) |
| **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>`) |

---

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

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

> **Khi nào cần:** Giấy mỏng, thấu quang, chữ mờ, nhiều đốm nhiễu.
> **Không cần nếu:** PDF scan từ sách in chất lượng tốt.

### Script

```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
```

### Tham số (preprocess-config.yaml)

```yaml
preprocessing:
  bilateral_d: 9
  bilateral_sigmaColor: 75
  bilateral_sigmaSpace: 75
  adaptive_threshold:
    method: ADAPTIVE_THRESH_GAUSSIAN_C
    block_size: 21
    C: 25
  morphology:
    kernel: [3, 3]
    mode: MORPH_CLOSE
  output:
    dpi: 300
    format: png
```

| Tham số | Tăng lên | Giảm xuống |
|---------|----------|------------|
| **C** | Chữ mờ → trắng, chữ đậm dễ đứt nét | Giữ nét chữ, chữ mờ còn |
| **block_size** | Giữ nét chữ to, bỏ sót chữ mờ vùng tối | Nhạy chi tiết, dễ sinh nhiễu |
| **morph kernel** | Diệt đốm mạnh, dễ mất dấu câu nhỏ | — |

---

## 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 \
  "010-pali-thaykha/pdf/input.pdf" \
  "zen-ocr-pdf" \
  "010-pali-thaykha/ocr/raw/"
```

> Output: JSON files trong `ocr/raw/` — 3-5 trang/file

---

## 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 4 (script) và Phase 2 (editor).

### So sánh V7 vs V9

| | V7 (cũ) | V9 (mới) |
|---|--------|----------|
| Cơ chế | Fragment-level Y-bucket sort | Paragraph-level, giữ nguyên thứ tự |
| Header/Footer | Clean bằng tọa độ Y | **Không clean** — giữ nguyên |
| Ưu điểm | Layout đẹp, spacing chuẩn | **Text nguyên vẹn**, không xáo trộn |
| Nhược điểm | **Xáo trộn text** ở vùng ranh giới header/body | Header/footer còn trong output |

### Pipeline nội bộ V9

```
JSON → [Duyệt paragraphs theo thứ tự block → paragraph trong Cloud Vision]
     → [Extract text từ symbols]
     → [Bỏ dòng scanner artifact]
     → Markdown raw (giữ nguyên cấu trúc paragraph)
```

### 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 \
  "<PROJECT>/ocr/raw/" \
  "<PROJECT>/extracted/"

# Debug (hiện paragraph count mỗi trang)
python3 obsidian/scripts/json_to_markdown_v9.py \
  "<PROJECT>/ocr/raw/" \
  "<PROJECT>/extracted/" \
  --debug

# Dry-run
python3 obsidian/scripts/json_to_markdown_v9.py \
  "<PROJECT>/ocr/raw/" \
  "<PROJECT>/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 ~35-40%, tiết kiệm input token cho Phase 2 (Editor AI).
> OCR thường ngắt dòng theo layout PDF, không theo câu → nhiều dòng bị cắt giữa chừng.
> Gộp các dòng chưa hoàn chỉnh thành câu liền mạch.

### Quy tắc gộp

- Dòng **KHÔNG** kết thúc bằng `။` hoặc `၊` → câu chưa hoàn chỉnh → **nối với dòng sau** (thêm 1 dấu cách)
- Dòng **KẾT THÚC** bằng `။` hoặc `၊` → câu hoàn chỉnh → **giữ nguyên, xuống dòng**
- Dòng trống (chỉ có newline) → giữ nguyên (ngăn cách đoạn)
- `## PAGE X` markers → giữ nguyên

### Script: `merge_lines.py`

> Script dùng chung, chỉ cần chỉnh `DIR` trong script hoặc copy vào thư mục dự án.

```bash
cd <PROJECT>
python3 merge_lines.py
```

### Hiệu quả điển hình

| Dự án | Dòng trước | Dòng sau | Giảm |
|-------|-----------|----------|------|
| 002-cung-cach-sa-di (264 trang) | 9,607 | 6,108 | 36% |

---

## 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 <PROJECT>/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 <PROJECT>/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: Paragraph-level, noise cleaned)\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.
>
> Xem [[03A-Hieu-Dinh-Myanmar-OCR#i-header--footer--xóa|Rule I — Header & Footer]] để biết 2 pattern header editor cần nhận diện.

---

# 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)               │
│  • Cập nhật SQLite patterns khi Checklist thay đổi    │
│  • 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-v4 \
  --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/010-pali-thaykha"
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/010-pali-thaykha/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/010-pali-thaykha/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/010-pali-thaykha"
     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 \
  --every 30m \
  --session isolated \
  --agent architect \
  --model deepseek/deepseek-v4-flash \
  --light-context true \
  --message '## OCR Monitor

### 1. Check progress
```python
import json, glob
PROJ = "/home/tuan-nguyen/.openclaw/workspace/010-pali-thaykha"
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}")
```

### 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-v4" → update enabled=false
  2. cron: find "ocr-monitor" → update enabled=false
  3. Report "✅ 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

```
010-pali-thaykha/
├── pdf/                    # PDF gốc
├── 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
├── progress-ocr.json       # Tiến độ pipeline
├── preprocess_all.py       # Script preprocess
└── preprocess-config.yaml  # Cấu hình preprocess
```

## SQLite Patterns (OCR Checklist)

**Database:** `/home/tuan-nguyen/.openclaw/workspace/data/ocr-checklist-vec.db`

| Category | Mô tả |
|----------|-------|
| Pali | Thuật ngữ Pāli (~80) |
| CoHoc | Lỗi dấu phụ/spacing/ký tự (~180) |
| SaiNghia | Từ sai nghĩa (~30) |
| Khac | Lỗi khác (~20) |

### Rebuild DB khi cập nhật Checklist

```bash
cd /home/tuan-nguyen/.openclaw/workspace && python3 << 'PYEOF'
import re, sqlite3, os
with open('obsidian/huong-dan/OCR-Checklist.md') as f: text = f.read()
patterns = []
for line in text.split('\n'):
    if not line.startswith('|'): continue
    if re.match(r'^\|[- ]+\|', line): continue
    if 'Lỗi' in line or 'STT' in line: continue
    cells = [c.strip() for c in line.split('|')]
    cells = [c for c in cells if c]
    if len(cells) < 3: continue
    mm = []
    for i, c in enumerate(cells):
        if re.search(r'[\u1000-\u109F]', c) and not re.search(r'[a-zA-Z]', c):
            mm.append(i)
    if len(mm) < 2: continue
    fi = mm[-2] if mm[-1]-mm[-2]==1 else mm[-2]
    ri = mm[-1]
    old_t = cells[fi].replace('`','').strip()
    new_t = cells[ri].replace('`','').strip()
    old_t = re.sub(r'\s*\([^)]*\)\s*$','',old_t).strip()
    if not old_t or not new_t or old_t==new_t or '[xóa]' in new_t: continue
    if len(old_t)<2 or re.match(r'^[\d\s\-~%]+$',old_t): continue
    patterns.append({'find':old_t,'replace':new_t})
DB='data/ocr-checklist-vec.db'
if os.path.exists(DB): os.remove(DB)
conn=sqlite3.connect(DB)
conn.execute('CREATE TABLE patterns(id INTEGER PRIMARY KEY, find_text TEXT, replace_text TEXT, note TEXT, category TEXT, is_mechanical INTEGER)')
safe = [p for p in patterns if len(p['find'])>=3 and '**' not in p['find']]
for i,p in enumerate(patterns):
    conn.execute('INSERT INTO patterns VALUES(?,?,?,?,?,?,?)',(i+1,'',p['find'],p['replace'],'','Khac',1 if p in safe else 0))
conn.commit(); conn.close()
print(f'DB: {len(patterns)} patterns ({len(safe)} mechanical)')
PYEOF
```

---

## Giám Sát Chi Phí (Phase 2)

| Phiên bản | Token/batch | LLM calls/batch | Chi phí/batch | Ghi chú |
|-----------|------------|-----------------|---------------|--------|
| V2.2 (10 steps) | 2.4M | ~20 | ~$0.50 | Tuần tự, nhiều bước |
| V3.0 (pre-process) | 71K | ~10 | ~$0.015 | |
| V3.1 (editor-only) | ~434K | ~11 | ~$0.09 | Tuần tự 11 calls |
| V4.0 (parallel) | ~32K | 2 | ~$0.007 | Có bug text report |
| **V4.2 (guide in exec)** | **~23K** | **2** | **~$0.005** | **Single source of truth** |

> **`thinking: "off"` là bắt buộc** — Google API mặc định bật reasoning, tăng output 2-3× mà không cải thiện chất lượng.

---

# PHASE 3: QA HIGHLIGHT (Edited → Marked)

## Mục đích

Sau khi Editor hiệu đính xong toàn bộ batches, chạy `apply-marks.py` để **bọc `<mark>`** vào tất cả các từ đã được sửa trong file edited, dựa trên bảng notes (`edited-notes/`).

```
edited-notes/*-notes.md          edited/*.md
┌──────────────────┐           ┌──────────────────┐
│ | Lỗi | Đã sửa   │           │ ဗုဒ္ဓေါ ဓမ္မံ     │
│ | ဗုဒ္ဓါ  | ဗုဒ္ဓေါ │  ───→   │ <mark>ဗုဒ္ဓေါ</mark> │
└──────────────────┘           └──────────────────┘
```

## Bước 3A: Highlight (`apply-marks.py`)

> **Dùng `edited-notes/` để đánh dấu các lỗi đã sửa bằng `<mark>`.**

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

### Cách hoạt động

1. **Parse notes** — đọc cột "Đã sửa thành" từ bảng markdown trong `edited-notes/*-notes.md`
2. **Tách tokens** — split theo khoảng trắng để tìm từng từ riêng lẻ
3. **Match + mark** — tìm tất cả occurrences trong file edited, bọc `<mark>...</mark>`
4. **Deduplicate** — xử lý overlap (ưu tiên match dài hơn), tránh double-wrap

### 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

# Single batch
python3 apply-marks.py --batch output-37-to-39 --dry-run
python3 apply-marks.py --batch output-37-to-39
```

### Symlink (quan trọng!)

Script dùng paths relative từ thư mục script:

```
obsidian/scripts/
├── edited/gemini-flash/       → symlink → 010-pali-thaykha/edited/gemini-flash/
├── edited-notes/gemini-flash/ → symlink → 010-pali-thaykha/edited-notes/gemini-flash/
└── _backup/marks/             # Backup tự động trước khi sửa
```

> ⚠️ **Kiểm tra symlink trước khi chạy!** Nếu trỏ nhầm dự án, script sẽ modify sai file.
> ```bash
> ls -la obsidian/scripts/edited/gemini-flash
> ls -la obsidian/scripts/edited-notes/gemini-flash
> ```

### Cập nhật symlink khi đổi dự án

```bash
cd obsidian/scripts
rm edited/gemini-flash
ln -s /home/tuan-nguyen/.openclaw/workspace/<PROJECT>/edited/gemini-flash edited/gemini-flash
rm edited-notes/gemini-flash
ln -s /home/tuan-nguyen/.openclaw/workspace/<PROJECT>/edited-notes/gemini-flash edited-notes/gemini-flash
```

### Output

- Files trong `edited/gemini-flash/` được ghi đè với `<mark>` tags
- Backup tự động lưu vào `_backup/marks/{batch}.md.bak.{timestamp}`
- Console log: số marks, tokens, terms mỗi file

---

## Bước 3B: Nối Câu Đứt Giữa Batch (`bridge-fix.py`) ⭐ Mới

> **Vấn đề:** Editor AI (Phase 2) xử lý từng batch độc lập → câu văn có thể bị cắt ngang ở ranh giới giữa batch N và batch N+1.
> **Giải pháp:** Script tự động phát hiện dòng cuối bị đứt → chuyển sang đầu batch tiếp theo.
> ⚠️ **Chạy SAU apply-marks** — apply-marks cần đối chiếu với `edited-notes/` gốc, không bị ảnh hưởng bởi việc di chuyển text.

### Cách phát hiện — Bộ 3 Rule

#### Rule A: Dòng cuối KHÔNG kết thúc hoàn chỉnh

Dòng cuối cùng có nội dung của batch N bị coi là **đứt** nếu:

| Signal | Ví dụ | Độ tin cậy |
|--------|-------|-----------|
| **`…` ở cuối dòng** | `...သိတတ်သော စိတ်ကို …` | 🔴 100% — OCR marker |
| **Không có `။` sau khi strip bracket** | `လွတ်ကင်းသောကြောင့်` | 🟡 Cao |
| **Kết thúc bằng ký tự Myanmar thường** | `စောင့်စည်းမည့်သူ` | 🟡 Cao |

> ⚠️ **Quan trọng:** Trước khi check `။`, phải **strip trailing brackets** (`]`, `)`, `»`, `"`, `'`, whitespace). Nhiều dòng kết thúc bằng `]` nhưng bên trong có `။` → không phải câu đứt.
>
> ```
> [ **သမတိတ္ထိက** = ...ရှိသည်။ ]  ← có ။ bên trong → proper ending
> ```

#### Rule B: Dòng cuối LÀ proper ending (dù không có `။`)

Các trường hợp kết thúc tự nhiên, **không nối**:

| Pattern | Ví dụ |
|---------|-------|
| **Figure caption** | `[ရုပ်ပုံ]`, `[ရုပ်ပုံ: ...]`, `[ ရုပ်ပုံ ]` |
| **Bold heading caption** | `**သင်းပိုင် ဧကသီကို ညီစွာဝတ်ထားပုံ။**` |
| **Em-dash list intro** | `...ပြစ်မှု ဆယ်ပါးကား—` (hoạt động như dấu hai chấm) |
| **Section end marker** | `**ပစ္စည်း ၄-ပါး ဆင်ခြင်ပုံ ပြီး၏။**` |
| **Latin sentence end** | `...the end.` (period + non-alpha trước) |

#### Rule C: Trang tiếp theo BẮT ĐẦU structural → không nối

Nếu dòng đầu tiên của batch N+1 là **heading, table, list, hoặc section marker** → dòng cuối batch N không bị đứt, dù không có `။`:

| Pattern | Regex / Check |
|---------|--------------|
| **Heading** | `^#{1,4}\s` |
| **HR** | `^---` |
| **Image/figure** | `^\[ရုပ်` |
| **Bold section header** | `^\*\*[^*]+\*\*` + len < 100 |
| **Table** | `^\|` |
| **Numbered list** | `^[၁-၉1-9][\)\.။\s]` |
| **Bracketed list** | `^\([က-အ၁-၉1-9]+\)` |
| **"အမှာ" note** | `^\*\*အမှာ` |
| **Exercise section** | Chứa `လေ့ကျင့်ခန်း` / `လေ့ကျင့်ခဏ်း` / `မေးခွန်း` |
| **Chapter section** | `^#{2,4}\s*\([၁-၉1-9]+\)` |
| **Figure description** | Bắt đầu `ပြခဲ့သော` + chứa `နှင့်အညီ` |
| **နိဂုံး** | `^\*\*နိဂုံး` |
| **Homage** | `^\*\*နမော တဿ` |
| **Standalone bold** | `^\*\*[^*]+\*\*$` |

### ⚠️ Edge Case: Overlap/Deduplication

Khi OCR split một từ giữa 2 trang, từ cuối trang N có thể **bị lặp** ở đầu trang N+1:

```
Trang 72 end:   ...ပစ္စည်းလေးပါး...
Trang 73 start: ပါးအလိုရှိလျှင်...
                              ↑ "ပါး" bị lặp
→ Merge + dedup: ...ပစ္စည်းလေးပါးအလိုရှိလျှင်...
```

**Xử lý:** Tìm **common suffix của clean_last** trùng với **prefix của clean_first** (kiểm tra 1-6 ký tự). Nếu có → cắt overlap khỏi clean_first trước khi nối.

### Thuật toán đầy đủ

```
FOR mỗi ranh giới batch N → N+1:
    last = dòng content cuối của batch N
    first = dòng content đầu của batch N+1

    // Rule B: last là proper ending?
    IF last là figure/bold caption/em-dash/section-end → SKIP

    // Strip bracket rồi check Rule A
    clean = last (đã xóa trailing brackets)
    IF clean kết thúc bằng ။ → SKIP

    // Rule C: first là structural?
    IF first là heading/table/list/section → SKIP

    // → BROKEN — tiến hành merge
    clean_last = xóa `…` và `...` ở cuối last
    clean_first = first

    // Dedup overlap (so sánh 1-6 ký tự)
    overlap = tìm đuôi chung dài nhất giữa clean_last và clean_first
    IF overlap:
        merged = clean_last + clean_first[len(overlap):]
    ELSE:
        merged = clean_last + " " + clean_first

    // Move: xóa last khỏi batch N
    //       ghi merged vào vị trí first của batch N+1
```

### Page Range Filter

Chỉ áp dụng cho **trang nội dung chính** (không bìa, mục lục, chú thích).

Mặc định: `--start-page 16 --end-page 260` (tùy chỉnh theo dự án).

```bash
python3 bridge-fix.py --start-page 20 --end-page 300
```

### Script: `bridge-fix.py`

**Vị trí:** `obsidian/quy-trinh/scripts/bridge-fix.py`

```bash
cd /home/tuan-nguyen/.openclaw/workspace/<PROJECT>/edited

# Dry-run: xem trước, không ghi
python3 bridge-fix.py --dry-run

# Chạy thật: output → <SRC>-bridged/
python3 bridge-fix.py

# Tùy chỉnh path + page range
python3 bridge-fix.py --src gemini-flash --dst gemini-flash-bridged --start-page 16 --end-page 260

# Single file mode (xử lý file đơn đã merge toàn bộ)
python3 bridge-fix.py --single /path/to/full-merged.md --start-page 16 --end-page 260
```

### Cấu trúc input/output

```
edited/
├── gemini-flash/          # Input (đã highlight từ Bước 3A)
│   ├── output-1-to-3.md
│   ├── output-4-to-6.md
│   └── ...
└── gemini-flash-bridged/  # Output (đã nối câu)
    ├── output-1-to-3.md
    ├── _bridge-report.json  # Report chi tiết
    └── ...
```

### Output

- File nào có cầu nối: dòng bị đứt được **xóa khỏi cuối** batch N và **chèn vào đầu** batch N+1 (sau `## PAGE X`)
- File không có cầu nối: copy nguyên vẹn
- `_bridge-report.json`: danh sách tất cả cầu nối + overlap đã dedup

### Ví dụ (đa dạng)

```
Case 1: Câu đứt thường
──────────────────────────────────────
Trang 44 end:   ...လွတ်ကင်းသောကြောင့်
Trang 45 start: စိတ်ကြည်လင်၏၊ ထိုသို့...
→ Merge: ...လွတ်ကင်းသောကြောင့် စိတ်ကြည်လင်၏၊ ထိုသို့...

Case 2: OCR ellipsis
──────────────────────────────────────
Trang 33 end:   ...သိတတ်သော စိတ်ကို …
Trang 34 start: သရဏဂမန (**သရဏဂုံ**) ဟု ခေါ်၏...
→ Merge (xóa …): ...စိတ်ကို သရဏဂမန (**သရဏဂုံ**) ဟု ခေါ်၏...

Case 3: Overlap dedup
──────────────────────────────────────
Trang 72 end:   ...ဆွေမျိုးလည်းမဟုတ်၊ ပစ္စည်းလေးပါး...
Trang 73 start: ပါးအလိုရှိလျှင် တောင်းတော်မူပါ-ဟု...
→ Merge + dedup: ...ပစ္စည်းလေးပါးအလိုရှိလျှင် တောင်းတော်မူပါ-ဟု...

Case 4: Figure caption → SKIP (không nối)
──────────────────────────────────────
Trang 22 end:   [ရုပ်ပုံ]
Trang 23 start: **ခေါင်းရိတ်ပြီးနောက်။** ။တစ်ချို့...
→ Rule B: figure caption → intact, không merge

Case 5: Em-dash list → SKIP (không nối)
──────────────────────────────────────
Trang 52 end:   ...ပြစ်မှု ဆယ်ပါးကား—
Trang 53 start: ၁။ **ဝိကာလဘောဇန သိက္ခာပုဒ်**...
→ Rule B: em-dash list intro → intact
```

### Bài học từ thực tế (2026-08-06)

> Áp dụng pipeline này cho dự án `002-cung-cach-sa-di` (264 trang, 3,704 dòng):
> - **69/246 ranh giới bị đứt** (28%)
> - Rule A (thiếu `။`) bắt được ~55 ca
> - Rule A (`…` ellipsis) bắt được ~5 ca
> - Overlap dedup xử lý ~3 ca
> - Rule B (figure/bold caption) lọc được ~10 false positive
> - Rule C (next-page structural) lọc được ~5 false positive
> - Sau fix: **0 lỗi**, backup tự động

### Sử dụng cho dự án khác

Script dùng chung — chỉ cần chỉnh `SRC_DIR` trong script hoặc truyền `--src`:

```bash
python3 bridge-fix.py --src /path/to/edited/folder --dst /path/to/output
```

---

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

## Khởi động Pipeline

- [ ] **Phase 1:** Chạy OCR → V9 extract → merge dòng → clean noise (URL + footer) → có `extracted/*.md` + `progress-ocr.json`
- [ ] **Phase 2:** Enable `ocr-processor` + `ocr-monitor` 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 3A — Highlight:** Cập nhật symlink → chạy `apply-marks.py --all`
- [ ] **Phase 3B — Nối câu:** Chạy `bridge-fix.py` → output `gemini-flash-bridged/`
- [ ] **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
- [ ] **Nếu cần sửa notes:** Sửa `edited-notes/*-notes.md` → chạy lại `apply-marks.py`

## 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 |
|------|----------|
| [[04-OCR-Pipeline]] | Chi tiết Phase 1 (trích xuất) + cleanup legacy |
| [[01A-Dieu-Phoi-Editor-OCR]] | Chi tiết Phase 2 (cron + cache strategy) |
| [[03A-Hieu-Dinh-Myanmar-OCR]] | Quy tắc hiệu đính OCR (single source of truth) |
| [[OCR-Checklist]] | 270+ lỗi OCR hệ thống đã tích lũy |
| [[Cloud-Vision-OCR]] | Chi tiết script OCR |
| [[Pali-Taykha-Cleanup-Guide]] | Case study cleanup tọa độ Y |
| [[05-Cron-Quan-Ly]] | Quản lý cron job |
| `scripts/apply-marks.py` | Script highlight Phase 3B |
| `scripts/bridge-fix.py` | Script nối câu đứt Phase 3B ⭐ Mới |
| `scripts/json_to_markdown_v9.py` | Script JSON→MD Phase 1 **(V9 — paragraph-level, raw)** |
| `scripts/json_to_markdown_v7.py` | Script cũ (V7 — Y-bucket, đã deprecated) |
| `data/ocr-checklist-vec.db` | SQLite patterns cho Phase 2 |

---

# THIẾT LẬP DỰ ÁN MỚI

> **Dành cho Architect** — quy trình tạo project folder + customize guide cho sách mới.

## A. Chuẩn bị Thư Mục

```bash
mkdir -p {PROJ}/{pdf,ocr/raw,extracted,edited/gemini-flash,edited-notes/gemini-flash,guide,_backup}
```

## B. Copy & Customize Guide 03A

```bash
cp obsidian/quy-trinh/03A-Hieu-Dinh-Myanmar-OCR.md {PROJ}/guide/
```

Cập nhật metadata ở header:
```markdown
> **Dành cho Editor Agent** | Cập nhật: YYYY-MM-DD | Dự án: **{PROJ-NAME}** | Sách: **{TÊN SÁCH}**
```

## C. Xác Định Pattern Header

Quét 3-5 trang raw đầu tiên để xác định:
- **Pattern header** (tên sách, tên chương, số trang)
- **Cấu trúc heading** (cách tổ chức chương/mục)
- **Các dạng nhiễu đặc thù**

Sau đó thay thế phần `### 🚨 Header Pattern Sách Này — LUÔN XÓA` trong guide bằng danh sách cụ thể:

```markdown
### 🚨 Header Pattern Sách Này — LUÔN XÓA

| Trang | Header |
|-------|--------|
| Chẵn | `<tên sách>` |
| Lẻ | `<tên chương hiện tại>` |

**Tên chương đã ghi nhận (danh sách mở, sẽ bổ sung):**
| # | Tên chương |
|---|-----------|
| 1 | `<tên chương 1>` |
| 2 | `<tên chương 2>` |
...

> 🔑 **Pattern nhận diện nhanh:** <mô tả đặc trưng, VD: kết thúc bằng `ခဏ်း`>
```

## D. Cập Nhật Cấu Trúc Sách

Thay thế ví dụ trong Section IV.2 của guide bằng cấu trúc thực tế của sách mới.

## E. Tạo Progress File

```json
{
  "project": "<tên dự án>",
  "batches": [
    {"name": "output-X-to-Y", "status": "pending"}
  ]
}
```

Lưu tại `{PROJ}/progress-ocr.json`.

## F. Checklist Trước Khi Chạy

- [ ] Pattern header đã xác định đúng từ raw file?
- [ ] Cấu trúc heading phù hợp với sách?
- [ ] Guide không chứa tên sách/dự án cũ?
- [ ] `progress-ocr.json` đã liệt kê đủ tất cả batch?
- [ ] Cron job `PROJ` path đã trỏ đúng thư mục dự án?

## G. Tiền Xử Lý Xóa Header (Script)

> **Mục đích:** Xóa header (số trang, tên sách, tên chương) khỏi file extracted **trước khi** đưa cho editor xử lý. Editor không phải đoán header → không xóa nhầm content.

### G1. Scan Pattern Header

Quét **ít nhất 10 batch đầu tiên** (30 trang), kiểm tra 5 dòng non-blank đầu mỗi trang để nắm toàn bộ pattern header có thể xuất hiện:

```bash
for f in output-*.md; do
  grep -A8 "^## PAGE" "$f" | head -20
done
```

Xác định pattern:
- Số trang (Myanmar/Ả-rập đứng riêng 1 dòng)
- Tên sách (lặp lại)
- Tên chương (kết thúc `ခဏ်း`, `ပါဌ်အနက်`, `နိဂုံး`, `လင်္ကာ`)
- Single-char artifact (`င`, `န`, `"`, `°`)

### G2. Script Clean Header

```python
import re

MYANMAR_NUM = re.compile(r'^[၀-၉ဝ\s]+$')  # Myanmar + WA as zero
BOOK_NAME = 'ရုပ်ပုံ ရှင်ကျင့်ဝတ်'          # Đổi theo dự án
CHAPTER_PAT = re.compile(r'(ပါဌ်အနက်|နိဂုံး|ခဏ်း|လင်္ကာ)')
SKIP_PAGES = set()  # Back matter pages to skip

for fname in extracted_files:
    pages = re.split(r'(?=## PAGE \d+)', content.strip())
    for page in pages:
        pn = extract_page_number(page)
        if pn in SKIP_PAGES: continue
        
        # Scan first 3 non-blank lines, delete max 2
        deleted = 0; checked = 0
        for line in lines_after_marker:
            s = line.strip()
            if not s or s.startswith('<!--'): continue
            if checked >= 3 or deleted >= 2: break
            checked += 1
            
            if (MYANMAR_NUM.match(s) or s.isdigit() or 
                s == BOOK_NAME or CHAPTER_PAT.search(s) or len(s) <= 1):
                deleted += 1  # delete this line
                continue
            break  # real content
```

### G3. Sinh Report & Kiểm Tra

Script tạo file `edited-notes/header-cleaning-NNN-NNN.md`:

```
PAGE 214
  - ❌ ဒဏ်ဆယ်ပါး ပါဌ်အနက်
  - ❌ ၁၉၉
  - ✅ (nội dung dòng đầu tiên còn lại)
```

### G4. Đọc Report & Sửa Thủ Công

Architect đọc report, kiểm tra các page:
- **Không có trong report** = không xóa gì → kiểm tra xem có sót header không
- **2 dòng xóa** = pattern chuẩn → OK
- **Ngoại lệ**: sub-heading bị xóa nhầm, artifact làm tràn scan zone → sửa tay

### G5. Quy Trình Đầy Đủ

```
1. Script tự động xóa header 90%+ trang → Report
2. Architect đọc Report → phát hiện ngoại lệ
3. Sửa thủ công các trang ngoại lệ → Update Report
4. Editor nhận source đã sạch → không phải đoán header
```

> ⚠️ **Nguyên tắc:** Script làm phần cứng (deterministic), mắt Architect làm phần mềm (edge case). Không để editor tự quyết định header vs content.
