# FR-045 CMMC 2.0 v2.13 (2024) PDF Parser 相容性修正 — Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** 讓合規框架 PDF 匯入的 CMMC parser（jedi-oscal-v2 `CMMC2Level2Adapter.pdf_parser`）同時吃得下 2021 舊版與 2024 v2.13 新版的 Level 1 / Level 2 Assessment Guide，共四份 PDF 都解析正確。

**Architecture:** 單一 adapter 內建雙格式相容（不拆版本 parser、不加 `ParserAdapterType`、FE 零改動）。兩個獨立修正：① practice 編號 regex 改雙格式 alternation（2021 NIST 式 `AC.L1-3.1.1` + 2024 FAR 式 `AC.L1-b.1.i`）＋標題剝 `[FCI/CUI Data]` 尾綴；② 行抽取改「字型分流」— 粗體（標題）與一般（內文）words 分兩條 stream 各自組行再按 y 合併，解掉 v2.13 PDF 「章節標題與內文同 y 座標疊字」造成的區塊切分失效。

**Tech Stack:** Python / pdfplumber / pytest。改動全在 jedi-oscal-v2 套件（`~/Projects/Jedicogy/module/jedi-python-package/jedi-oscal-v2/`），主專案（BE）零程式碼改動。

---

## 背景（2026-07-03 分析結論，本 plan 的事實基礎）

CMMC final rule（32 CFR §170，2024-12）改版了官方 Assessment Guide（v2.13），造成兩類解析失敗：

### Root cause 1：L1 編號制度整個換掉 → L1 v2 解析 0 條

| | 舊版 (2021) | 新版 (2024 v2.13) |
|---|---|---|
| L1 編號 | `AC.L1-3.1.1`（NIST SP 800-171 式） | `AC.L1-b.1.i`（FAR 52.204-21 條款式，羅馬數字，共 **15** 條 b.1.i ~ b.1.xv） |
| 標題 | `– Authorized Access Control` | `– AUTHORIZED ACCESS CONTROL [FCI DATA]`（全大寫 + 資料類型尾綴） |

parser 三支 regex（`practice_pattern` / `practice_code_pattern` / `NEXT_PRACTICE_ID_PATTERN`）都寫死 `\d+\.\d+\.\d+` → 全滅。

### Root cause 2：v2.13 PDF 排版「粗體標題與內文同 y 座標」→ L2 v2 假成功（資料毀損）

新版 PDF 產生器把章節標題（`ASSESSMENT OBJECTIVES` / `POTENTIAL ASSESSMENT METHODS AND OBJECTS` / `Examine` / `Interview` / `Test`，字型 **Cambria-Bold**）放在跟內文行（**Cambria**）相同的 y 座標。單一 stream 按 y 組行會把標題併進內文行（`extract_text` 視角甚至逐字元交錯：`[PbO] TaE uNseTrI...`），後果：

- **AO 遺失 68 條**（320 → 252）：每條 practice 固定丟「最後一條 AO」（被 `POTENTIAL...` 標題行吃掉），8 條 practice AO 歸零
- **methods 分桶全毀（110/110）**：`Interview` / `Test` 整行等值判斷（`lower == "interview"`）因疊字永不成立 → interview/test 項目幾乎全被塞進 examine（分桶 979/362/217 → 1518/10/10）；`AC.L2-3.1.1` 一條 methods 全空（20 項遺失）
- 已抽驗 PDF 原文確認**官方內容沒刪**（`AC.L2-3.1.11` 的 `[b]` 還在），純 parser 毀損

### 官方真實變更（不是 bug，驗收時要知道）

- 新版 L2 guide 把原掛 `.L1-` 的 17 條 practice 全部改編號為 `.L2-`（如 `AC.L1-3.1.1` → `AC.L2-3.1.1`）→ 新版 L2 的 110 條全部是 `.L2-` 前綴
- 新版 L1 guide 只有 **15** 條（舊版 17 條）— FAR 條款對應
- `CA.L2-3.12.2` 標題官方微調（"Plan of Action" → "Operational Plan of Action"）

### 實測基準（修正前，驗收對照用）

| 檔案 | practices | AO 總數 | methods 總數 | methods 分桶 (examine/interview/test) |
|---|---|---|---|---|
| L1 舊版 (2021) | 17 | — | — | 正常 |
| L1 v2 (2024) | **0** | 0 | 0 | — |
| L2 舊版 (2021) | 110 | 320 | 1,558 | 979 / 362 / 217 |
| L2 v2 (2024) | 110 | **252** | 1,538 | **1518 / 10 / 10** |

測試檔案位置（user 桌面，執行者可直接讀）：

```
/Users/chouraymond/Desktop/00_Compliance Manager/稽核法規文件/CMMC 2.0/CMMC 2.0 Level 1 Assessment Guide.pdf      (舊 L1)
/Users/chouraymond/Desktop/00_Compliance Manager/稽核法規文件/CMMC 2.0/CMMC 2.0 Level 1 Assessment Guide v2.pdf   (新 L1)
/Users/chouraymond/Desktop/00_Compliance Manager/稽核法規文件/CMMC 2.0/CMMC 2.0 Level 2 Assessment Guide.pdf      (舊 L2)
/Users/chouraymond/Desktop/00_Compliance Manager/稽核法規文件/CMMC 2.0/CMMC 2.0 Level 2 Assessment Guide v2.pdf   (新 L2)
```

---

## ⚠️ 跨 session 協作注意（動工前必讀）

1. **另一個 session（FR-044）正在同一套 jedi-oscal-v2 repo 作業**（branch `feature/FR-044`，最近 commit `7c65e59 feat(FR-044): FindingState 加 NOT_APPLICABLE...`）。本案只碰 `jedi_oscal_v2/infra/adapter/cmmc/cmmc2_lv2_parser_adapter.py` + 新增一個測試檔，跟 FR-044 的 FindingState / matrix 檔案不重疊，但仍要：
   - 動工前 `git -C ~/Projects/Jedicogy/module/jedi-python-package/jedi-oscal-v2 status --short` 確認 working tree 乾淨（或髒檔與本案無關）
   - commit 一律**顯式 `git add <檔名>`，禁用 `-am`**，只 add 本案的兩個檔
   - **絕不切 branch**（在當下 branch 直接做；若發現 branch 狀態怪異，停下問 user）
2. **主專案 `pyproject.toml` 的 jedi-oscal-v2 path override 已由 FR-044 session 開啟**（develop mode）→ 本案改套件源碼後 BE 重啟即生效，**不需要也不可以再動 `pyproject.toml`**（那行是 FR-044 的 dev-only 變更，收尾還原歸他管）。
3. **禁止 bump 套件版本 / 推 Nexus** — 發版等 user 明示。
4. 主專案（BE repo）本案只有 docs（本資料夾 + README 登記列），與 FR-044 的 BE 檔案無交集；BE commit 同樣顯式 add。
5. 測試跑法：套件測試從 BE repo 用其 venv 跑（pdfplumber / pytest 都在）：
   ```bash
   cd /Users/chouraymond/Projects/Billows/Audit-Manager/compliance-manager-be
   poetry run pytest /Users/chouraymond/Projects/Jedicogy/module/jedi-python-package/jedi-oscal-v2/tests/catalog/ -v
   ```

---

## File Structure

| 檔案 | 動作 | 責任 |
|---|---|---|
| `jedi_oscal_v2/infra/adapter/cmmc/cmmc2_lv2_parser_adapter.py` | Modify | ① 三支 practice regex 提升為模組級常數 + 雙格式 alternation；② `_normalize_practice_id` / `_TITLE_DATA_SUFFIX` 新 helper；③ 行抽取抽成可測的 `_merge_font_streams` static method（字型分流） |
| `tests/catalog/test_cmmc_pdf_parser_v213_compat.py` | Create | 純 unit 測試（合成資料，不依賴 PDF 檔）：regex 雙格式、ID 正規化、標題尾綴剝除、字型分流組行 |

（真實四份 PDF 的 E2E 驗證用 Task 5 的 throwaway script 做，不進 repo — PDF 檔在 user 桌面、不入版控。）

---

## Task 0: Pre-flight（基準快照 + 環境確認）

**Files:** 無修改，只讀。

- [ ] **Step 1: 確認套件 repo 狀態**

```bash
git -C /Users/chouraymond/Projects/Jedicogy/module/jedi-python-package/jedi-oscal-v2 status --short
git -C /Users/chouraymond/Projects/Jedicogy/module/jedi-python-package/jedi-oscal-v2 branch --show-current
```

Expected: branch `feature/FR-044`（或 user 已切的其他 branch — 記下即可，不要自己切）；working tree 乾淨或髒檔與 `cmmc2_lv2_parser_adapter.py` 無關。**若該檔本身是髒的 → 停下問 user**（可能 FR-044 session 也在動它）。

- [ ] **Step 2: 確認 path override 生效（develop mode）**

```bash
cd /Users/chouraymond/Projects/Billows/Audit-Manager/compliance-manager-be
poetry run python -c "import jedi_oscal_v2, os; print(os.path.realpath(jedi_oscal_v2.__file__))"
```

Expected: 路徑指向 `~/Projects/Jedicogy/module/jedi-python-package/jedi-oscal-v2/...`（不是 site-packages 的 pin 版）。若指向 site-packages → 停下問 user（表示 path override 沒生效，可能需要 `poetry update jedi-oscal-v2`，讓 user 決定）。

- [ ] **Step 3: 產出修正前基準 JSON（舊版兩檔，之後 diff 用）**

把下面存成 throwaway script（放 scratchpad，勿入 repo）跑一次：

```python
# baseline_dump.py — 修正前基準
import json
from jedi_oscal_v2.infra.adapter.cmmc.cmmc2_lv2_parser_adapter import CMMC2Level2Adapter

FILES = {
    "old_l1": "/Users/chouraymond/Desktop/00_Compliance Manager/稽核法規文件/CMMC 2.0/CMMC 2.0 Level 1 Assessment Guide.pdf",
    "old_l2": "/Users/chouraymond/Desktop/00_Compliance Manager/稽核法規文件/CMMC 2.0/CMMC 2.0 Level 2 Assessment Guide.pdf",
}
a = CMMC2Level2Adapter()
for tag, f in FILES.items():
    with open(f, "rb") as fh:
        recs = a.pdf_parser(fh)
    with open(f"/tmp/cmmc_baseline_{tag}.json", "w") as out:
        json.dump(recs, out, ensure_ascii=False, indent=1, sort_keys=True)
    print(tag, len(recs))
```

Run: `poetry run python baseline_dump.py`（在 BE repo 下跑）
Expected: `old_l1 17` / `old_l2 110`，產出 `/tmp/cmmc_baseline_old_l1.json`、`/tmp/cmmc_baseline_old_l2.json`。

- [ ] **Step 4: 確認 L2 兩版 PDF 字型慣例同為 Cambria / Cambria-Bold**

（L1 兩版已在分析階段驗過；L2 補驗，保證字型分流的前提成立。）

```python
# font_check.py — throwaway
import pdfplumber, re
for f in [
    "/Users/chouraymond/Desktop/00_Compliance Manager/稽核法規文件/CMMC 2.0/CMMC 2.0 Level 2 Assessment Guide.pdf",
    "/Users/chouraymond/Desktop/00_Compliance Manager/稽核法規文件/CMMC 2.0/CMMC 2.0 Level 2 Assessment Guide v2.pdf",
]:
    with pdfplumber.open(f) as pdf:
        for p in pdf.pages[40:46]:
            fonts = {w["fontname"].split("+")[-1] for w in p.extract_words(extra_attrs=["fontname"])}
            print(f.split("/")[-1], sorted(fonts))
            break
```

Expected: 兩檔都出現 `Cambria` 與 `Cambria-Bold`（可能另有 Italic — 無妨，Italic 歸 body stream）。若出現「標題字型不含 Bold 字樣」的異常 → 停下回報，字型分流前提不成立。

---

## Task 1: Practice 編號 regex 雙格式 + ID 正規化 + 標題尾綴（TDD）

**Files:**
- Modify: `jedi_oscal_v2/infra/adapter/cmmc/cmmc2_lv2_parser_adapter.py`
- Create: `tests/catalog/test_cmmc_pdf_parser_v213_compat.py`

- [ ] **Step 1: 寫失敗測試**

新建 `tests/catalog/test_cmmc_pdf_parser_v213_compat.py`：

```python
# -*- coding: utf-8 -*-
"""FR-045 — CMMC v2.13 (2024) guide 相容：regex 雙格式 / ID 正規化 / 標題尾綴 / 字型分流組行。

純 unit（合成資料）；四份真實 PDF 的 E2E 驗證見 implementation-plan Task 5。
"""
import pytest

from jedi_oscal_v2.infra.adapter.cmmc.cmmc2_lv2_parser_adapter import (
    _PRACTICE_PATTERN,
    _NEXT_PRACTICE_ID_PATTERN,
    _TITLE_DATA_SUFFIX,
    CMMC2Level2Adapter,
    _normalize_practice_id,
)


class TestPracticePattern:
    @pytest.mark.parametrize(
        "line,pid,title",
        [
            # 2021 NIST 式（L1 / L2）
            ("AC.L1-3.1.1 – Authorized Access Control", "AC.L1-3.1.1", "Authorized Access Control"),
            ("AC.L2-3.1.20 – External Connections", "AC.L2-3.1.20", "External Connections"),
            # 2024 FAR 式：本文標題全大寫 + 尾綴
            (
                "AC.L1-B.1.I – AUTHORIZED ACCESS CONTROL [FCI DATA]",
                "AC.L1-B.1.I",
                "AUTHORIZED ACCESS CONTROL [FCI DATA]",
            ),
            # 2024 FAR 式：小寫變體（目錄 / 頁眉樣式）
            (
                "SI.L1-b.1.xii – Flaw Remediation [FCI Data]",
                "SI.L1-b.1.xii",
                "Flaw Remediation [FCI Data]",
            ),
        ],
    )
    def test_matches_both_numbering_schemes(self, line, pid, title):
        m = _PRACTICE_PATTERN.match(line)
        assert m, f"should match: {line}"
        assert m.group(1) == pid
        assert m.group(2) == title

    @pytest.mark.parametrize(
        "line",
        [
            "Internet Protocol (IP)",                    # 一般散文
            "see AC.L1-3.1.1 for more information",      # 行中引用（非行首）
            "AC.L1-3.1.1 no separator title",            # 缺 – 分隔
            "Access Control (AC)",                        # domain 標題
        ],
    )
    def test_rejects_non_practice_lines(self, line):
        assert _PRACTICE_PATTERN.match(line) is None

    def test_next_practice_pattern_accepts_far_style(self):
        assert _NEXT_PRACTICE_ID_PATTERN.match("IA.L1-b.1.v additional text")
        assert _NEXT_PRACTICE_ID_PATTERN.match("IA.L1-3.5.1 additional text")


class TestNormalizePracticeId:
    @pytest.mark.parametrize(
        "raw,expected",
        [
            ("AC.L1-B.1.I", "AC.L1-b.1.i"),       # 全大寫本文標題 → 官方小寫制式
            ("AC.L1-b.1.iv", "AC.L1-b.1.iv"),     # 已是小寫 → 不變
            ("AC.L2-3.1.1", "AC.L2-3.1.1"),       # NIST 式 → 不變
            ("SI.L1-B.1.XV", "SI.L1-b.1.xv"),
        ],
    )
    def test_normalize(self, raw, expected):
        assert _normalize_practice_id(raw) == expected


class TestTitleDataSuffix:
    @pytest.mark.parametrize(
        "raw,expected",
        [
            ("AUTHORIZED ACCESS CONTROL [FCI DATA]", "AUTHORIZED ACCESS CONTROL"),
            ("Authorized Access Control [CUI Data]", "Authorized Access Control"),
            ("External Connections", "External Connections"),  # 無尾綴不變
        ],
    )
    def test_strip(self, raw, expected):
        assert _TITLE_DATA_SUFFIX.sub("", raw).strip() == expected
```

- [ ] **Step 2: 跑測試確認失敗**

```bash
cd /Users/chouraymond/Projects/Billows/Audit-Manager/compliance-manager-be
poetry run pytest /Users/chouraymond/Projects/Jedicogy/module/jedi-python-package/jedi-oscal-v2/tests/catalog/test_cmmc_pdf_parser_v213_compat.py -v
```

Expected: FAIL — `ImportError: cannot import name '_PRACTICE_PATTERN'`（常數還沒建立）。

- [ ] **Step 3: 實作 — 模組級 regex 常數 + helpers**

在 `cmmc2_lv2_parser_adapter.py` 的 `_METHOD_PROP = {...}` 之後（class 之前）加入：

```python
# Practice-ID 雙格式（FR-045）：
#   2021 guides（NIST SP 800-171 式）:      AC.L1-3.1.1 / AC.L2-3.1.1
#   2024 v2.13 guides（FAR 52.204-21 式）:  AC.L1-b.1.i（羅馬數字；本文標題為全大寫 AC.L1-B.1.I）
_PRACTICE_ID_CORE = r"[A-Z]{2}\.L\d-(?:\d+\.\d+\.\d+|[A-Za-z]\.\d+\.[IVXivx]+)"
_PRACTICE_PATTERN = re.compile(rf"^({_PRACTICE_ID_CORE})\s*[-–—]\s*(.+)$")
_PRACTICE_CODE_PATTERN = re.compile(r"^([A-Z]{2})\.L\d-")
_NEXT_PRACTICE_ID_PATTERN = re.compile(rf"^({_PRACTICE_ID_CORE})\s*")
# 2024 版標題尾綴 [FCI Data] / [CUI DATA]（大小寫不拘）
_TITLE_DATA_SUFFIX = re.compile(r"\s*\[\s*(?:FCI|CUI)\s+DATA\s*\]\s*$", re.IGNORECASE)
_FAR_ID_NORMALIZE = re.compile(r"^([A-Z]{2}\.L\d)-([A-Za-z])\.(\d+)\.([IVXivx]+)$")


def _normalize_practice_id(pid: str) -> str:
    """FAR 式 ID 正規化成官方小寫制式（AC.L1-B.1.I → AC.L1-b.1.i）；NIST 式原樣返回。"""
    m = _FAR_ID_NORMALIZE.match(pid)
    if not m:
        return pid
    return f"{m.group(1)}-{m.group(2).lower()}.{m.group(3)}.{m.group(4).lower()}"
```

- [ ] **Step 4: 把 `pdf_parser` 內的三支區域 regex 換成模組常數**

`pdf_parser` 開頭的 regex 區改為（**刪除**原本的 `practice_pattern` / `practice_code_pattern` / `NEXT_PRACTICE_ID_PATTERN` 三行區域定義，改引用模組常數；`domain_pattern` / `ao_start_pattern` / `toc_pattern` / `dots_pattern` 維持不動）：

```python
        # -------- Regex 定義 --------
        domain_pattern = re.compile(r"^(.+)\s+\(([A-Z]{2})\)$")
        practice_pattern = _PRACTICE_PATTERN
        practice_code_pattern = _PRACTICE_CODE_PATTERN
        ao_start_pattern = re.compile(r"^\[([a-z])\]\s*(.*)$")
        toc_pattern = re.compile(r".{5,}\s\d{1,3}$")
        dots_pattern = re.compile(r"\.{5,}")
```

以及 `NEXT_PRACTICE_ID_PATTERN = re.compile(...)` 那行改為 `NEXT_PRACTICE_ID_PATTERN = _NEXT_PRACTICE_ID_PATTERN`。

- [ ] **Step 5: practice 命中處套用正規化 + 尾綴剝除**

`pdf_parser` 的 practice 命中區塊（原 `pid = m.group(1)` / `pname = m.group(2).strip()` 兩行）改為：

```python
                        pid = _normalize_practice_id(m.group(1))
                        pname = _TITLE_DATA_SUFFIX.sub("", m.group(2)).strip()
```

（後續 `practice_name": pname.title()` 不動 — 全大寫標題經 `.title()` 自動轉為 Title Case，與舊資料慣例一致。）

- [ ] **Step 6: 跑測試確認通過**

```bash
poetry run pytest /Users/chouraymond/Projects/Jedicogy/module/jedi-python-package/jedi-oscal-v2/tests/catalog/test_cmmc_pdf_parser_v213_compat.py -v
```

Expected: 全 PASS。

- [ ] **Step 7: 跑套件既有 catalog 測試確認零回歸**

```bash
poetry run pytest /Users/chouraymond/Projects/Jedicogy/module/jedi-python-package/jedi-oscal-v2/tests/catalog/ -v
```

Expected: 全 PASS。

- [ ] **Step 8: Commit（套件 repo）**

```bash
cd /Users/chouraymond/Projects/Jedicogy/module/jedi-python-package/jedi-oscal-v2
git add jedi_oscal_v2/infra/adapter/cmmc/cmmc2_lv2_parser_adapter.py tests/catalog/test_cmmc_pdf_parser_v213_compat.py
git commit -m "feat(FR-045): CMMC practice regex 雙格式 — 2024 v2.13 FAR 式編號 (AC.L1-b.1.i) + [FCI/CUI Data] 標題尾綴剝除"
```

---

## Task 2: 字型分流行抽取（解疊字）（TDD）

**Files:**
- Modify: `jedi_oscal_v2/infra/adapter/cmmc/cmmc2_lv2_parser_adapter.py`
- Modify: `tests/catalog/test_cmmc_pdf_parser_v213_compat.py`（追加測試）

- [ ] **Step 1: 寫失敗測試（追加到同一測試檔）**

```python
def _w(text, top, font="XDXQPJ+Cambria"):
    """合成 pdfplumber word dict（只含 parser 用到的 key）。"""
    return {"text": text, "top": top, "fontname": font}


_BOLD = "MFJSRF+Cambria-Bold"


class TestMergeFontStreams:
    def test_plain_lines_unchanged(self):
        """單一字型、逐行遞增 y — 行為與舊版逐 y 組行一致。"""
        words = [
            _w("Limit", 100.0), _w("system", 100.0), _w("access", 100.0),
            _w("Determine", 120.0), _w("if:", 120.0),
        ]
        assert CMMC2Level2Adapter._merge_font_streams(words) == [
            "Limit system access",
            "Determine if:",
        ]

    def test_bold_heading_near_same_y_becomes_own_line(self):
        """v2.13 疊字場景：Bold 標題與內文 y 差 0.2 → 必須拆成獨立兩行，內文在前。"""
        words = [
            _w("or", 193.2), _w("devices", 193.2),
            _w("ASSESSMENT", 193.4, _BOLD), _w("OBJECTIVES", 193.4, _BOLD),
            _w("Determine", 249.2), _w("if:", 249.2),
        ]
        assert CMMC2Level2Adapter._merge_font_streams(words) == [
            "or devices",
            "ASSESSMENT OBJECTIVES",
            "Determine if:",
        ]

    def test_equal_y_body_sorts_before_bold(self):
        """完全同 y（實測 369.6 == 369.6）：內文行必須排在 Bold 標題前，
        否則收 AO 的最後一條（[f]）會被標題先終結而遺失。"""
        words = [
            _w("[f]", 369.6), _w("system", 369.6), _w("access", 369.6),
            _w("POTENTIAL", 369.6, _BOLD), _w("METHODS", 369.6, _BOLD),
        ]
        assert CMMC2Level2Adapter._merge_font_streams(words) == [
            "[f] system access",
            "POTENTIAL METHODS",
        ]

    def test_header_words_above_threshold_dropped(self):
        words = [_w("RunningHeader", 30.0), _w("body", 100.0)]
        assert CMMC2Level2Adapter._merge_font_streams(words) == ["body"]

    def test_bold_lines_grouped_by_y_within_stream(self):
        """Bold stream 自己也要逐 y 組行（Examine 與 Interview 是不同標題行）。"""
        words = [
            _w("Examine", 399.5, _BOLD),
            _w("Interview", 558.2, _BOLD),
        ]
        assert CMMC2Level2Adapter._merge_font_streams(words) == ["Examine", "Interview"]
```

- [ ] **Step 2: 跑測試確認失敗**

```bash
poetry run pytest /Users/chouraymond/Projects/Jedicogy/module/jedi-python-package/jedi-oscal-v2/tests/catalog/test_cmmc_pdf_parser_v213_compat.py -v -k MergeFontStreams
```

Expected: FAIL — `AttributeError: ... has no attribute '_merge_font_streams'`。

- [ ] **Step 3: 實作 `_merge_font_streams`（class 內新增 static method）**

```python
    @staticmethod
    def _merge_font_streams(
        words: list[dict], header_y_threshold: float = 50, line_gap: float = 8
    ) -> list[str]:
        """把 words 依字型分成 Bold（章節標題）/ 一般（內文）兩條 stream，
        各自沿用「y 跳距 > line_gap 斷行」的組行邏輯，最後按 (y, is_bold)
        排序合併回閱讀序。

        動機（FR-045）：2024 v2.13 CMMC guide 的 PDF 把粗體章節標題
        （ASSESSMENT OBJECTIVES / POTENTIAL ASSESSMENT METHODS AND OBJECTS /
        Examine / Interview / Test）排在與內文行相同的 y 座標，單一 stream
        組行會把標題併進內文行，導致區塊切分失效（AO 遺失、methods 全進
        examine 桶）。2021 版標題本來就獨立成行，分流後輸出不變。

        同 y 時內文排在 Bold 前（is_bold False < True）：AO 末條與
        POTENTIAL... 標題同 y 時，必須先收完 AO 再讓標題終結區塊。
        """
        streams: dict[bool, list[dict]] = {False: [], True: []}
        for w in words:
            if float(w["top"]) < header_y_threshold:
                continue
            streams["Bold" in w.get("fontname", "")].append(w)

        lines: list[tuple[float, bool, str]] = []
        for is_bold, stream_words in streams.items():
            current: list[str] = []
            anchor_y = None
            for w in stream_words:
                y = float(w["top"])
                if anchor_y is None:
                    anchor_y = y
                if abs(y - anchor_y) > line_gap:
                    if current:
                        lines.append((anchor_y, is_bold, " ".join(current)))
                        current = []
                    anchor_y = y
                current.append(w["text"])
            if current:
                lines.append((anchor_y, is_bold, " ".join(current)))

        lines.sort(key=lambda t: (t[0], t[1]))
        return [text for _, _, text in lines]
```

- [ ] **Step 4: `pdf_parser` 的 `_extract_lines_from_page` 改用分流 helper**

把 `pdf_parser` 內的 `_extract_lines_from_page` 整段（原 `words = p.extract_words(use_text_flow=True)` 到 `return lines_local`）替換為：

```python
        def _extract_lines_from_page(p):
            words = p.extract_words(use_text_flow=True, extra_attrs=["fontname"])
            return CMMC2Level2Adapter._merge_font_streams(
                words, header_y_threshold=header_y_threshold
            )
```

- [ ] **Step 5: 跑新測試 + 既有 catalog 測試**

```bash
poetry run pytest /Users/chouraymond/Projects/Jedicogy/module/jedi-python-package/jedi-oscal-v2/tests/catalog/ -v
```

Expected: 全 PASS。

- [ ] **Step 6: Commit（套件 repo）**

```bash
cd /Users/chouraymond/Projects/Jedicogy/module/jedi-python-package/jedi-oscal-v2
git add jedi_oscal_v2/infra/adapter/cmmc/cmmc2_lv2_parser_adapter.py tests/catalog/test_cmmc_pdf_parser_v213_compat.py
git commit -m "fix(FR-045): PDF 行抽取字型分流 — v2.13 guide 粗體標題與內文同 y 疊字導致 AO 遺失 / methods 分桶全毀"
```

---

## Task 3: 四份真實 PDF E2E 驗收

**Files:** 無修改 — throwaway script（scratchpad，勿入 repo）。

- [ ] **Step 1: 跑四檔統計 + 舊版基準 diff**

```python
# verify_all.py — FR-045 E2E 驗收
import json
from jedi_oscal_v2.infra.adapter.cmmc.cmmc2_lv2_parser_adapter import CMMC2Level2Adapter

BASE = "/Users/chouraymond/Desktop/00_Compliance Manager/稽核法規文件/CMMC 2.0"
FILES = {
    "old_l1": f"{BASE}/CMMC 2.0 Level 1 Assessment Guide.pdf",
    "new_l1": f"{BASE}/CMMC 2.0 Level 1 Assessment Guide v2.pdf",
    "old_l2": f"{BASE}/CMMC 2.0 Level 2 Assessment Guide.pdf",
    "new_l2": f"{BASE}/CMMC 2.0 Level 2 Assessment Guide v2.pdf",
}
a = CMMC2Level2Adapter()
parsed = {}
for tag, f in FILES.items():
    with open(f, "rb") as fh:
        recs = a.pdf_parser(fh)
    parsed[tag] = recs
    ao = sum(len(r["assessment_objectives"]) for r in recs)
    buckets = {k: 0 for k in ("examine", "interview", "test")}
    for r in recs:
        for k, v in r["methods"].items():
            buckets[k] += len(v)
    zero_ao = [r["practice_id"] for r in recs if not r["assessment_objectives"]]
    zero_m = [r["practice_id"] for r in recs if not any(r["methods"].values())]
    print(f"{tag}: practices={len(recs)} AOs={ao} buckets={buckets} zero_AO={zero_ao} zero_methods={zero_m}")

# 舊版兩檔與修正前基準逐欄 diff（AO / methods 必須逐條一致；description 允許輕微差異需人工看過）
for tag in ("old_l1", "old_l2"):
    with open(f"/tmp/cmmc_baseline_{tag}.json") as fh:
        base = {r["practice_id"]: r for r in json.load(fh)}
    now = {r["practice_id"]: r for r in parsed[tag]}
    assert set(base) == set(now), f"{tag} practice id set changed!"
    for pid in base:
        assert base[pid]["assessment_objectives"] == now[pid]["assessment_objectives"], f"{tag} {pid} AO diff"
        assert base[pid]["methods"] == now[pid]["methods"], f"{tag} {pid} methods diff"
        if base[pid]["description"] != now[pid]["description"]:
            print(f"[description diff — 人工確認] {tag} {pid}")
    print(f"{tag}: baseline diff OK")

# 新 L1 spot-check：官方 AC.L1-b.1.i 有 [a]~[f] 六條 AO
l1 = {r["practice_id"]: r for r in parsed["new_l1"]}
assert len(l1["AC.L1-b.1.i"]["assessment_objectives"]) == 6, l1["AC.L1-b.1.i"]["assessment_objectives"]
print("new_l1 spot-check OK")

# 新版 description 抽查（reviewer 指出的盲點：字型分流若把內文 inline 粗體字
# 抽成獨立行，description 會被重排 — 數量統計驗不出來，要人工對 PDF 原文眼球比對）
for tag, pids in (("new_l1", ["AC.L1-b.1.i", "SI.L1-b.1.xii"]), ("new_l2", ["AC.L2-3.1.1", "CM.L2-3.4.4", "SC.L2-3.13.11"])):
    d = {r["practice_id"]: r for r in parsed[tag]}
    for pid in pids:
        print(f"[description 抽查 — 對 PDF 原文眼球比對] {tag} {pid}: {d[pid]['description'][:300]}")
```

Run: `poetry run python verify_all.py`（BE repo 下）

- [ ] **Step 2: 對照驗收基準**

| 檔案 | 必須達成 |
|---|---|
| old_l1 | practices=17；AO / methods 與基準 JSON 逐條一致（description 若有差異，人工確認只是空白/斷行級） |
| old_l2 | practices=110、AOs=320、buckets=979/362/217；與基準 JSON 逐條一致 |
| new_l1 | practices=**15**（`AC.L1-b.1.i` ~ `SI.L1-b.1.xv`，全小寫制式）；`AC.L1-b.1.i` AO=6；zero_AO=[]；interview 桶 > 0 |
| new_l2 | practices=110（全 `.L2-` 前綴）；AOs **≥ 315**（期望 ≈320，允許官方 v2.13 個位數內容微調）；zero_AO=[]；buckets 大致回到 979/362/217 量級（interview ≥ 330、test ≥ 200）；zero_methods=[] |
| new_l1 / new_l2 description 抽查 | script 印出的 5 條 description 對 PDF 原文眼球比對：語句完整、無字序錯亂（字型分流對 inline 粗體字的副作用，數量統計驗不出，必須人工看） |

任何一項不達 → 回頭修，不要下修驗收標準。若 new_l2 的 AO/bucket 與期望差距大於個位數，逐條 dump 差異 practice 的 PDF 原文確認是「官方真改了」還是「parser 還在漏」，證據附進回報。

- [ ] **Step 3: （選配）BE 端手測提醒**

套件是 develop mode，**BE 無 hot reload** — 若 user 要從 UI（`/compliance-framework/import-version`）手測，請 user 重啟 BE 後用四份 PDF 各跑一次 parse 預覽。此步驟只提醒 user，不代跑。

---

## Task 4: 收尾（等 user 明確下令才做）

依 CLAUDE.md「收尾必須等 user 下命令才做」鐵則，Task 3 驗收過後**停下**，給 user 一句話 status + 手測 checklist。以下項目等 user 說「收尾」才執行：

- [ ] changelog（BE repo `docs/changelog/`，type=fix，主題：CMMC v2.13 parser 相容）
- [ ] `docs/features/README.md` 已有 FR-045 列（plan 階段已登記，確認無誤即可）
- [ ] analysis 文件（`docs/analysis/2026-07-03-cmmc-v213-parser-compat.md`：三 root cause、單一 parser vs 拆版本 parser 的取捨、字型分流 vs 已知字串切割的取捨）
- [ ] 套件發版（bump version + 推 Nexus + BE pin 回 Nexus 版）— **一律等 user 明示**，且需與 FR-044 session 的套件變更協調（同一顆套件兩案都有改動，發版時一起帶出去）
- [ ] Notion 任務登記

---

## 明確不做（YAGNI）

- **不拆版本 parser / 不加 `ParserAdapterType.CMMC_2_V213`**：兩版共用面遠大於差異面，拆開養兩份重複碼且 FE 要多選項、user 要自判版本
- **不做 Level 3 guide 支援**：本案 scope 只有 L1/L2 四檔；L3（`*.L3-` / 800-172）另案
- **不動 `convert_to_oscal_catalog_entity` / FrameworkParseJobService / DB 寫入**：分析已證實下游只是忠實搬運，問題全在 `pdf_parser`
- **不清理既有髒資料**：若 DB 已有用 v2 檔匯過的殘缺 catalog，屬另一個資料修復任務（先問 user 是否存在此情況）
