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)零程式碼改動。


§1

背景(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.1AC.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)

§2

⚠️ 跨 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 都在):
    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

§3

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 桌面、不入版控。)


§4

Task 0: Pre-flight(基準快照 + 環境確認)

Files: 無修改,只讀。

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 也在動它)。

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 決定)。

把下面存成 throwaway script(放 scratchpad,勿入 repo)跑一次:

# 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

(L1 兩版已在分析階段驗過;L2 補驗,保證字型分流的前提成立。)

# 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: 兩檔都出現 CambriaCambria-Bold(可能另有 Italic — 無妨,Italic 歸 body stream)。若出現「標題字型不含 Bold 字樣」的異常 → 停下回報,字型分流前提不成立。


§5

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

新建 tests/catalog/test_cmmc_pdf_parser_v213_compat.py

# -*- 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
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'(常數還沒建立)。

cmmc2_lv2_parser_adapter.py_METHOD_PROP = {...} 之後(class 之前)加入:

# 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()}"

pdf_parser 開頭的 regex 區改為(刪除原本的 practice_pattern / practice_code_pattern / NEXT_PRACTICE_ID_PATTERN 三行區域定義,改引用模組常數;domain_pattern / ao_start_pattern / toc_pattern / dots_pattern 維持不動):

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

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

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

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

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。

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

Expected: 全 PASS。

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] 標題尾綴剝除"

§6

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(追加測試)
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"]
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'

    @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]

pdf_parser 內的 _extract_lines_from_page 整段(原 words = p.extract_words(use_text_flow=True)return lines_local)替換為:

        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
            )
poetry run pytest /Users/chouraymond/Projects/Jedicogy/module/jedi-python-package/jedi-oscal-v2/tests/catalog/ -v

Expected: 全 PASS。

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 分桶全毀"

§7

Task 3: 四份真實 PDF E2E 驗收

Files: 無修改 — throwaway script(scratchpad,勿入 repo)。

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

檔案 必須達成
old_l1 practices=17;AO / methods 與基準 JSON 逐條一致(description 若有差異,人工確認只是空白/斷行級)
old_l2 practices=110、AOs=320、buckets=979/362/217;與基準 JSON 逐條一致
new_l1 practices=15AC.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 還在漏」,證據附進回報。

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


§8

Task 4: 收尾(等 user 明確下令才做)

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


§9

明確不做(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 是否存在此情況)