# SSP 文件解析器 設計說明書

> **文件版本**：1.0
> **撰寫日期**：2026-04-30
> **文件性質**：SD — System Design（架構與實作策略）
> **上游 SA**：[`api-spec.md`](./api-spec.md)
> **狀態**：草稿，等使用者 review 後才進 frontend-spec.md
>
> **設計守則**：
> 1. 不破壞既有 `SspControlImplImportService`（Excel 匯入）/ `ModuleFrameTemplateImportService`
> 2. 不修改 jedi-oscal 套件（v1 範圍內），所有新邏輯在主專案
> 3. 沿用既有 3 步驟 wizard / `validate_import` / `confirm_import` 切點 pattern
> 4. 嚴守 CLAUDE.md DDD 層級規範（Route 不查 DB / App Service 用 `@transaction` / Repo session lazy）

---

## 1. DDD 層級結構

```
api/oscal/routes/ssp/                     ← Flask route (HTTP boundary)
└── ssp_docx_import_route.py
    ├── SspDocxImportParseRoute       (POST /ssp-docx-imports/parse)
    ├── SspDocxImportRoute            (GET / DELETE /ssp-docx-import/<uid>)
    └── SspDocxImportConfirmRoute     (POST /ssp-docx-import/<uid>/confirm)

app/oscal/service/                        ← Application service (orchestration + @transaction)
└── ssp_docx_import_app_service.py
    └── SspDocxImportAppService
        ├── upload_and_parse(file, framework, source_type, source_uid, mode, user)
        ├── get_parse_result(parse_uid, user)
        ├── confirm_import(parse_uid, decisions, manual_assignments, user)
        └── discard_parse(parse_uid, user)

domain/oscal/                             ← Domain service (pure business logic)
├── ssp_docx_parse_job_domain_service.py  (CRUD on ssp_docx_parse_jobs)
└── (reuse jedi-oscal: ControlImplementationDomainService, ControlImplementationObjectiveDomainService)

domain/oscal/parser/                      ← Pure parser (NEW)
├── docx_parser_core.py
│   └── DocxParserCore
│       ├── parse(file_path, framework, candidates) -> ParsedDocx
│       ├── _extract_structure(doc) -> DocxStructure
│       ├── _match_controls(structure, candidates) -> list[MatchedControl]
│       └── _score(paragraph, candidate) -> float
├── docx_intermediate.py                  (純 dataclasses: ParsedDocx, MatchedControl, UnmatchedParagraph)
└── framework_patterns.py                 (per-framework regex patterns)

domain/oscal/strategy/                    ← Write strategy (NEW, Strategy pattern)
├── i_ssp_docx_write_strategy.py          (interface)
├── module_frame_write_strategy.py        (writes to module_frame_*_defaults + profile.include_controls)
└── ssp_write_strategy.py                 (writes to ssp_control_implementation + objective)

infra/oscal/model/                        ← ORM model (NEW)
└── ssp_docx_parse_job.py
    └── SspDocxParseJob (table: ssp_docx_parse_jobs)

infra/oscal/repository/                   ← Repository impl (NEW)
└── ssp_docx_parse_job_repo_impl.py
    └── SspDocxParseJobRepoImpl (extends BaseRepositoryImpl)

infra/oscal/mapper/                       ← Entity ↔ Model mapper (NEW)
└── ssp_docx_parse_job_mapper.py

di_containers/oscal/                      ← DI container (NEW sub-container)
└── ssp_docx_import_container.py
    └── SspDocxImportContainer
        (將 wire 進現有 OscalContainer)

common/util/                              ← Shared utility (NEW)
└── control_id_matcher.py
    ├── parse_control_id(text, framework) -> Optional[str]
    ├── fuzzy_match_control(text, candidates) -> tuple[Optional[str], float]
    └── detect_dominant_framework(structure) -> Optional[str]

common/code/                              ← Error code (UPDATE existing)
└── grc_error_code.py
    (新增 17 條 GRC_DOCX_* error code，見 SA §5)
```

### 1.1 Route 層職責

只做：
1. JWT decoded ✅（既有 middleware）
2. multipart/form-data 解析（file 取出、其他欄位 marshmallow load）
3. 呼叫 app service
4. 序列化 response

**禁止**：DB 查詢、權限細節判斷、業務邏輯。

### 1.2 App Service 層職責

每個 public method 必須：
- `@transaction` 裝飾
- 第一行：權限檢查（呼叫 domain service 取得 source 資源 → 驗證使用者角色）
- 業務流程編排：呼叫 parser core / domain services / write strategy
- 例外轉換為 `jedi_common.handler.exception` 標準 error class

### 1.3 Domain Service / Domain 層職責

- `SspDocxParseJobDomainService`：純 ORM CRUD，不知 HTTP / 不知檔案上傳
- `DocxParserCore`：純函式，輸入檔案路徑 + framework + 候選清單，輸出中介結構（不接 DB、不接 HTTP）
- Strategy classes：純寫入邏輯，輸入中介結構 + 使用者決策，輸出 `ImportResult`

### 1.4 Infra 層職責

- `SspDocxParseJob` model：SQLAlchemy declarative，定 column type / RLS
- `SspDocxParseJobRepoImpl`：繼承 `BaseRepositoryImpl[Entity, Query, Model, Mapper]`，session 自動 lazy
- Mapper：entity ↔ ORM model 雙向轉換

---

## 2. 核心邏輯

### 2.1 DocxParserCore 演算法

```
INPUT:
  - file_path: str
  - framework: str（cmmc-l1 / nist-800-171 / iso27001 / ...）
  - candidates: list[CandidateControl]
      (control_id, control_name, objective_keys, current_implementation_description, current_objectives_descriptions)

ALGO:

  1. doc = python-docx.Document(file_path)

  2. 抽取結構 _extract_structure(doc):
     - 走訪 doc.paragraphs，記錄每個 paragraph：
       - text (純文字，rich text 已被 .text 取為 plain)
       - style.name（"Heading 1" / "Heading 2" / "Heading 3" / "Normal" / ...）
       - paragraph_idx
       - context: { preceding_h1, preceding_h2, preceding_h3 } ← running stack
     - 走訪 doc.tables，記錄每個 table：
       - rows[][] (cell text)
       - 緊鄰前面的 paragraph_idx
     - 輸出 DocxStructure { paragraphs, tables }

  3. 偵測 framework 一致性 detect_dominant_framework(structure):
     - 對所有 H3 paragraph.text 套 control_id_pattern (per framework)
     - 計算命中比例
     - 若 < 60% 命中、且有其他 framework pattern 命中 > 60% → return mismatch_to_<framework>
     - 若 < 30% 命中任何 framework → return None (FATAL: no_control_id_found)

  4. matched_controls, unmatched_paragraphs = _match_controls(structure, candidates):

     For each H3 paragraph:
       a. control_id = parse_control_id(text, framework)
       b. 若命中 → score = 0.95（H3 + ID 直接命中），matched_controls.add({control_id, content_paragraphs, score})
          - content_paragraphs = 接下來到下一個 H3 / H1 / H2 之前的所有 normal paragraphs
          - tables_in_section = 緊鄰的 tables，第二欄當 objective description
       c. 若未命中 → 該段視為 unmatched_paragraph

     For each Normal paragraph 不在已命中 H3 範圍內：
       a. 嘗試在段落文字內 parse_control_id → 若有 → matched (score = 0.6)
       b. 否則 fuzzy_match_control(text, candidates) → (best_id, score)
       c. 若 score < 0.4 → unmatched_paragraphs.add(paragraph)

  5. 找出 missing_baseline_controls:
     baseline_ids = {c.control_id for c in candidates}
     matched_ids = {m.control_id for m in matched_controls}
     missing = baseline_ids - matched_ids

  6. 計算 warning_level:
     match_rate = len(matched_controls) / len(structure.controls_seen)  ← (僅統計 docx 裡看到的)
     missing_rate = len(missing) / len(candidates)
     if match_rate < 0.5 or missing_rate > 0.3:
        warning_level = 'partial'

  7. 組裝 ParsedDocx:
     ParsedDocx(
       summary,
       matched_controls (含 score + parsed_implementation_description + objectives),
       unmatched_paragraphs,
       missing_baseline_controls,
       predicted_module_frame_controls (僅 mode=full)
     )

  RETURN ParsedDocx
```

### 2.2 信心分數計算（_score）

| 信號 | 分數 |
|------|------|
| H3 標題完全包含 control_id（regex 命中） | 0.95 |
| H3 標題包含 control 名稱（不含 ID） | 0.65 |
| Normal paragraph 內含 control_id | 0.60 |
| Normal paragraph 文字與 control_name 相似度 > 0.7（rapidfuzz token_set_ratio） | 0.40-0.55（線性映射） |
| 上方 heading proximity（前 3 段內有 H3 命中相同 control） | + 0.20 |
| 表格 row first cell 含 (a)(b)(c) 模式 + 上方 H3 命中 | 0.85（歸到 objective） |

最終 score 取最大信號，**不疊加**（避免破表）。  

實作位置：`common/util/control_id_matcher.py:fuzzy_match_control()`

### 2.3 Per-framework regex pattern

`domain/oscal/parser/framework_patterns.py`：

```python
FRAMEWORK_PATTERNS = {
    'cmmc-l1': re.compile(r'\b([A-Z]{2})\.L1-b\.\d+\.[ivxlcdm]+\b', re.I),
    'cmmc-l2': re.compile(r'\b([A-Z]{2})\.L2-\d+\.\d+\.\d+\b', re.I),
    'nist-800-171': re.compile(r'\b3\.\d+\.\d+\b'),
    'iso27001': re.compile(r'\bA\.\d+(\.\d+)*\b'),
}
```

- group(1) 取得 family code（AC / AU / IR ...），用於進階比對

### 2.4 Lazy Expiration（取代 cron）

> **設計守則**：v1A 不引入 background job，所有過期判斷在 read path 完成。

```python
# In SspDocxImportAppService.get_parse_result(parse_uid, user):
job = self._job_domain.get_one(uid=parse_uid)
if job.status == 'awaiting_review':
    expires_at = job.created_at + timedelta(hours=1)
    if datetime.utcnow() > expires_at:
        self._job_domain.update_status(parse_uid, 'expired')
        # MinIO 檔案不立即刪除（避免 race condition），由日後背景清理 job 處理（v2）
        raise PreconditionFailedError(GrcErrorCode.GRC_DOCX_PARSE_JOB_EXPIRED)

# Same check in confirm_import().
```

**取捨**：
- ✅ 無 cron 依賴
- ✅ 過期判斷與 status 改寫透過 @transaction 同步完成
- ⚠️ 無人讀取的過期 job 永久保留（DB 行 + MinIO 檔），需 v2 加清理 job

### 2.5 Re-parse Diff 預設 Action

| 現有值 | docx 值 | default_action |
|--------|---------|---------------|
| 空 | 有 | `use_docx` |
| 有 | 空 | `keep_current` |
| 相同 | 相同 | 不顯示（client 收到的 list 不含此項，不算 conflict） |
| 不同 | 不同 | `keep_current`（保守） |

實作：在 ParsedDocx 組裝時，已將 `current_*` 欄位 fetch 並計算好 `default_action`，FE 直接顯示。

### 2.6 拖拉指派處理（manual_assignments）

接收 confirm 階段的 payload：

```python
manual_assignments = [
  {
    paragraph_idx: 142,
    targets: [
      { control_id: 'IR.L2-x', level: 'implementation', merge_action: 'append' },
      { control_id: 'AC.L1-b.1.i', level: 'objective', objective_key: 'c', merge_action: 'replace' }
    ]
  }
]
```

處理邏輯（在 WriteStrategy 內）：
1. 對每個 target：
   - 從 ParsedDocx.unmatched_paragraphs 找到該段文字
   - 依 level：
     - `implementation`：找到目標 control_id 的 implementation_description，依 merge_action 處理
     - `objective`：找到 (control_id, objective_key) 對應 row，同上
2. **objective_key 驗證**：必須在 candidates 對應的 objective_keys 內，否則回 400
3. `merge_action`：
   - `append`：`current_value + '\n\n' + paragraph.text`
   - `replace`：`paragraph.text`

### 2.7 mode = full 時的控制項勾選邏輯

僅入口 A1（合規資源庫 Step 2）：

1. parse 階段：DocxParserCore 偵測 docx 內所有 H3 control IDs → `predicted_module_frame_controls`
2. preview 階段：FE 將 predicted clauses 預勾在 Step 2 控制項清單，使用者可微調（D-Q5 β 合併模式）
3. confirm 階段：使用者最終勾選的清單 → `predicted_controls_user_selection`
4. 寫入：
   - 更新 `oscal_profiles.include_controls = predicted_controls_user_selection`
   - 對每個未在原 profile 但被新增的 control，建立對應的 `module_frame_control_defaults` row
   - 對每個從原 profile 移除的 control，**v1 範圍不刪 row**（保留現有 module_frame_control_defaults，避免破壞），只更新 profile

### 2.8 framework mismatch 偵測（解 SA OPEN-3）

決議：

```
if dominant_pattern == None:
    raise FATAL("GRC_DOCX_NO_CONTROL_ID_FOUND")
elif dominant_pattern != user_selected_framework:
    if dominant_match_rate >= 0.6 AND user_match_rate < 0.3:
        raise FATAL("GRC_DOCX_FRAMEWORK_MISMATCH")
    else:
        # 兩者比例接近，視為 partial warning
        warning_level = 'partial'
        warnings.append("docx 主要 framework 不明，建議確認選擇")
```

threshold 在 `framework_patterns.py` 設常數，未來可調整。

---

## 3. Strategy Pattern（IWriteStrategy）

### 3.1 Interface

```python
# domain/oscal/strategy/i_ssp_docx_write_strategy.py
from abc import ABC, abstractmethod

class IWriteStrategy(ABC):
    @abstractmethod
    def write(
        self,
        parsed: ParsedDocx,
        decisions: ImportDecisions,  # decisions + manual_assignments + skipped_idxs + predicted_user_selection
        source_uid: str,
        user_id: str,
    ) -> ImportResult: ...
```

`ImportDecisions` 是 dataclass，無業務邏輯。

### 3.2 ModuleFrameWriteStrategy

職責：
- 寫到 `module_frame_control_defaults` (lazy-create per control_id) + `module_frame_control_objective_defaults`
- mode=full 時：更新 `oscal_profiles.include_controls`

依賴注入：
- `ModuleFrameDomainService` (jedi-something)
- `ModuleFrameControlDefaultDomainService`
- `ModuleFrameControlObjectiveDefaultDomainService`
- `ProfileDomainService` (jedi-oscal)

關鍵：**lazy-create**。先 query control_default by (module_frame_id, control_id)，找不到就 create，找到就 update。

### 3.3 SspWriteStrategy

職責：
- 寫到 `ssp_control_implementation` (jedi-oscal `ControlImplementationDomainService`)
- 寫到 `ssp_control_implementation_objective` (jedi-oscal `ObjectiveDomainService`)
- 寫到 `information_systems`（後述 §6.1 upsert）

依賴注入：
- `ControlImplementationDomainService` (jedi-oscal)
- `ControlImplementationObjectiveDomainService` (jedi-oscal)
- `InformationSystemService` (jedi_information_system)
- `ProjectInformationSystemRepoImpl`

### 3.4 Strategy 選擇

`SspDocxImportAppService.confirm_import()` 內：

```python
strategy = self._module_frame_strategy if job.source_type == 'module_frame' \
                                       else self._ssp_strategy
result = strategy.write(parsed=job.parsed_result, decisions=decisions, ...)
```

兩個 strategy 在 DI container 同時 wire，runtime 選用。

---

## 4. DB Schema

### 4.1 新表：`ssp_docx_parse_jobs`

```sql
-- Date: 2026-04-30
CREATE TABLE oscal.ssp_docx_parse_jobs (
    id              SERIAL PRIMARY KEY,
    uid             VARCHAR(36) NOT NULL UNIQUE,                  -- UUIDv4
    tenant_id       INTEGER NOT NULL,                             -- RLS
    source_type     VARCHAR(20) NOT NULL,                          -- 'module_frame' | 'project_ssp'
    source_uid      VARCHAR(36) NOT NULL,                          -- module_frame.uid or ssp.uid
    mode            VARCHAR(20) NOT NULL,                          -- 'full' | 'statement_only'
    framework       VARCHAR(40) NOT NULL,                          -- 'cmmc-l1' | ...
    status          VARCHAR(20) NOT NULL DEFAULT 'pending',         -- pending|parsing|awaiting_review|completed|failed|expired|discarded
    file_path       VARCHAR(255),                                   -- MinIO path
    file_name       VARCHAR(255),                                   -- 原始檔名
    file_size       BIGINT,
    parsed_result   JSONB,                                          -- 中介結構
    error_code      VARCHAR(40),                                    -- 失敗時的 GrcErrorCode
    error_message   TEXT,
    import_summary  JSONB,                                          -- confirm 後寫入
    is_active       BOOLEAN NOT NULL DEFAULT TRUE,                  -- 軟刪除（discard / 清理時）
    created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    created_user    VARCHAR(50) NOT NULL,                           -- login_name
    updated_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_user    VARCHAR(50) NOT NULL
);

CREATE INDEX idx_ssp_docx_parse_jobs_source ON oscal.ssp_docx_parse_jobs(source_type, source_uid);
CREATE INDEX idx_ssp_docx_parse_jobs_status ON oscal.ssp_docx_parse_jobs(status, created_at);
CREATE INDEX idx_ssp_docx_parse_jobs_tenant ON oscal.ssp_docx_parse_jobs(tenant_id);

ALTER TABLE oscal.ssp_docx_parse_jobs ENABLE ROW LEVEL SECURITY;
CREATE POLICY rls_ssp_docx_parse_jobs ON oscal.ssp_docx_parse_jobs
    USING (tenant_id::text = current_setting('app.user_id', true)
        OR current_setting('app.allowed_tenant_paths', true) LIKE '%' || tenant_id::text || '%');

GRANT SELECT, INSERT, UPDATE, DELETE ON oscal.ssp_docx_parse_jobs TO cm_app;
GRANT USAGE, SELECT ON SEQUENCE oscal.ssp_docx_parse_jobs_id_seq TO cm_app;
```

**status 狀態機**：
```
pending → parsing → awaiting_review → completed
                 ↘                ↘
                  failed          expired (1hr 未確認)
                                  discarded (使用者主動取消)
```

### 4.2 `parsed_result` JSONB 結構

```json
{
  "summary": { ... },
  "matched_controls": [ {control_id, score, parsed_implementation_description, objectives:[...], current_*} ],
  "unmatched_paragraphs": [ {paragraph_idx, text, context, rule_score, rule_guess_control_id} ],
  "missing_baseline_controls": [...],
  "predicted_module_frame_controls": [...],
  "skipped_paragraphs": [85, 142, 200],
  "warnings": [...]
}
```

JSONB 欄位**不需 schema migration**，未來擴充欄位向下相容。

### 4.3 既有表不變動

- `ssp_control_implementation` / `ssp_control_implementation_objective` (jedi-oscal): 只用既有 `add` / `update` API，不加欄位
- `module_frame_control_defaults` / `module_frame_control_objective_defaults`: 只用既有 service
- `information_systems`: 只用 service，**新增一個 `upsert_by_name` helper method**（見 §6.1）
- `oscal_profiles.include_controls`: 用既有 `ProfileDomainService.update_include_controls()`

---

## 5. DI Container

### 5.1 新增 Sub-Container

```python
# di_containers/oscal/ssp_docx_import_container.py
from dependency_injector import containers, providers

class SspDocxImportContainer(containers.DeclarativeContainer):
    config = providers.Configuration()

    # Repos / Mappers
    parse_job_mapper = providers.Singleton(SspDocxParseJobMapper)
    parse_job_repo = providers.Singleton(SspDocxParseJobRepoImpl, mapper=parse_job_mapper)

    # Domain services
    parse_job_domain_service = providers.Factory(
        SspDocxParseJobDomainService, repo=parse_job_repo
    )

    # Parser (純函式 class，singleton)
    docx_parser_core = providers.Singleton(DocxParserCore)
    control_id_matcher = providers.Singleton(ControlIdMatcher)

    # Strategies (注入既有 OscalContainer + jedi packages 的 service)
    module_frame_write_strategy = providers.Factory(
        ModuleFrameWriteStrategy,
        module_frame_control_default_service=Provide['module_frame_container.control_default_service'],
        module_frame_control_objective_default_service=Provide['module_frame_container.control_objective_default_service'],
        profile_domain_service=Provide['oscal_container.profile_domain_service'],
    )
    ssp_write_strategy = providers.Factory(
        SspWriteStrategy,
        control_impl_service=Provide['oscal_container.control_implementation_domain_service'],
        objective_service=Provide['oscal_container.control_implementation_objective_domain_service'],
        information_system_service=Provide['information_system_container.information_system_service'],
        project_info_system_repo=Provide['information_system_container.project_information_system_repo'],
    )

    # App service
    ssp_docx_import_app_service = providers.Factory(
        SspDocxImportAppService,
        parse_job_domain=parse_job_domain_service,
        parser=docx_parser_core,
        matcher=control_id_matcher,
        module_frame_write_strategy=module_frame_write_strategy,
        ssp_write_strategy=ssp_write_strategy,
        # 用於拿候選控制項
        profile_domain_service=Provide['oscal_container.profile_domain_service'],
        catalog_domain_service=Provide['oscal_container.catalog_domain_service'],
        # 權限驗證用
        project_participant_domain_service=Provide['participant_container.project_participant_domain_service'],
        module_frame_domain_service=Provide['module_frame_container.module_frame_domain_service'],
    )
```

### 5.2 註冊到 OscalContainer

`di_containers/oscal/oscal_containers.py` 新增：

```python
ssp_docx_import = providers.Container(SspDocxImportContainer)
```

### 5.3 di_modules 自動掃描

依 CLAUDE.md，新增的 `_router.py` / `_route.py` / `_handler.py` 會被 `config/di_modules.py` 自動掃進來。`ssp_docx_import_route.py` 會被 wire。Route 需要 `@inject` 拿 `SspDocxImportAppService`。

---

## 6. 與既有功能的相容性

### 6.1 InformationSystemService 新增 `upsert_by_name`

> 不修改既有 `add` / `update` 簽名，只**加新 method**。

```python
# jedi_information_system/app/service/information_system_service.py
class InformationSystemService:
    @transaction
    def upsert_by_name(
        self,
        name: str,
        tenant_id: int,
        fields: dict,
        user_id: str,
    ) -> InformationSystemEntity:
        existing = self._repo.get_one(
            InformationSystemQueryEntity(name=name, tenant_id=tenant_id)
        )
        if existing:
            return self._repo.update(existing.id, fields, user_id)
        else:
            return self._repo.add(InformationSystemEntity(name=name, tenant_id=tenant_id, **fields))
```

呼叫者：`SspWriteStrategy.write()` 處理 SSP metadata。

**SA OPEN-1 解答**：v1 用「name + tenant_id 唯一鍵 → 找到就 update、找不到就 create」，不額外讓使用者選 merge / 新建。同名衝突視為「同一個 information_system 的不同 docx 版本」。

### 6.2 多人 race condition（解 SA OPEN-2）

v1 簡化策略：

- **不加 lock**，但用 DB 層的 unique constraint：
  - `ssp_control_implementation` (system_security_plan_id, control_identifier) 應已 unique（jedi-oscal entity）
  - upsert 用 `INSERT ... ON CONFLICT DO UPDATE` 或 `get → update / add` 兩 step
- Race condition 結果：兩個 confirm 並發時**後者勝**，前者結果被覆蓋
- v1 接受此行為，v2 若需要可加：
  - `version` column + optimistic lock
  - 或建立 advisory lock per source_uid

UI 層提示：「有他人正在編輯此 SSP，您的修改可能被覆蓋」（可選，留 frontend-spec 決定）。

### 6.3 既有 SspControlImplImportService（Excel）相容

完全獨立，**無共用 service / repo**。差別：

| 項目 | Excel Import | Docx Import (新) |
|------|-------------|-----------------|
| 入口 Route | `ssp_control_impl_import_route.py` | `ssp_docx_import_route.py` |
| App Service | `SspControlImplImportService` | `SspDocxImportAppService` |
| 解析 | openpyxl 直讀 | python-docx + DocxParserCore |
| 步驟拆分 | validate / confirm 2 step | parse / preview / confirm 3 step（多了預覽編輯） |
| Job 表 | 無（純 stateless） | `ssp_docx_parse_jobs`（短期 stateful） |

兩個服務同時存在，使用者選 Excel 或 docx 由 FE wizard 決定。

---

## 7. python-docx 處理細節

### 7.1 Heading 偵測

```python
def is_heading(p: docx.text.Paragraph) -> Optional[int]:
    """回 1/2/3 表示 H1/H2/H3，None 表示非 heading"""
    style_name = p.style.name  # e.g., "Heading 1"
    match = re.match(r'^Heading (\d)$', style_name)
    if match:
        level = int(match.group(1))
        return level if 1 <= level <= 6 else None
    if style_name == 'Title':
        return 1
    return None
```

### 7.2 Cell 抽取（評估目標 table）

```python
def extract_objective_rows(table: docx.table.Table) -> list[tuple[str, str]]:
    """回 [(objective_key_text, description), ...]"""
    out = []
    for row in table.rows:
        if len(row.cells) < 2:
            continue
        first = row.cells[0].text.strip()
        second = row.cells[1].text.strip()
        # objective key 通常以 (a) (b) (c) 開頭
        m = re.match(r'^\s*\(([a-z])\)', first)
        if m:
            out.append((m.group(1), second))
    return out
```

### 7.3 Rich text → plain（AC-19）

`paragraph.text` 已是 plain text（python-docx 會展平 runs），但 list / 表格需要手動處理：
- 同一段內的多 `run` 用 `''.join` 串接（已是 default 行為）
- 跨段的 list/bullet 在 `style_name` 偵測為 `List Bullet` / `List Number` 時，可選擇加 `- ` 前綴（v1 不做，純取 text）

### 7.4 Table 在 control 區段的歸屬

python-docx 不直接告訴你「這個 table 在哪個 paragraph 後面」。解法：

```python
def iter_block_items(parent):
    """yield Paragraph or Table in document order"""
    parent_elm = parent.element.body
    for child in parent_elm.iterchildren():
        if isinstance(child, CT_P):
            yield Paragraph(child, parent)
        elif isinstance(child, CT_Tbl):
            yield Table(child, parent)
```

走訪時維持 `current_h3_control_id`，碰到 table 時歸屬到該 control。

### 7.5 空 paragraph / 空 cell 處理

- text == "" 或全空白 → 略過（不進 unmatched_paragraphs）
- table cell 第二欄為空 → objective description 設為 `null`（不寫入 DB，等於 keep_current）

---

## 8. 錯誤處理 / Edge Cases

### 8.1 失敗分級實作

```python
class ParseException(Exception):
    """FATAL — 整份匯入終止"""
    pass

def parse_or_raise(...):
    try:
        doc = Document(file_path)
    except Exception as e:
        raise ParseException(GrcErrorCode.GRC_DOCX_PARSE_FAILED, e)

    structure = _extract_structure(doc)
    if not structure.has_any_heading():
        raise ParseException(GrcErrorCode.GRC_DOCX_NO_CONTROL_ID_FOUND)

    dominant_framework = detect_dominant_framework(structure)
    if dominant_framework is None:
        raise ParseException(GrcErrorCode.GRC_DOCX_NO_CONTROL_ID_FOUND)
    if dominant_framework != user_framework and dominant_match_rate > 0.6:
        raise ParseException(GrcErrorCode.GRC_DOCX_FRAMEWORK_MISMATCH)
    ...
```

App service 接 `ParseException`：寫入 `job.status='failed'` + `error_code` + `error_message`，回 422 給 FE。

### 8.2 PARTIAL warning

不 raise，只回 ParsedDocx with `warning_level='partial'`。FE Step 1 上方顯示 banner。

### 8.3 ITEM-LEVEL（個別段落）

不 raise，加進 `unmatched_paragraphs`。FE Step 1 顯示拖拉 UI。

---

## 9. 與 SA Acceptance Criteria 對照

| AC | SD 設計位置 |
|----|-----------|
| AC-1 ~ AC-3（三入口按鈕） | §1 Route + §3 strategy 選擇 + frontend-spec.md |
| AC-4（必選 framework） | Route 層 marshmallow schema 必填 |
| AC-5（解析時間 < 10s） | §2.1 純規則無 LLM，python-docx 5MB 預估 < 5s |
| AC-6（規則命中自動配對） | §2.1 + §2.2 |
| AC-7（拖拉到主述/objective） | §2.6 |
| AC-8（同段拖到多控制項） | §2.6（`targets[]` 是陣列） |
| AC-9（拖到已有內容 conflict） | §2.6 `merge_action` |
| AC-10（diff preview） | §2.5 |
| AC-11（每筆 objective 獨立行） | §3.x ParsedDocx 結構 + §2.5 |
| AC-12（批次操作） | FE only — frontend-spec.md |
| AC-13（baseline 缺漏不阻擋） | §2.1 step 5 + §3.x ImportResult.missing_left_blank |
| AC-14（framework mismatch FATAL） | §2.8 |
| AC-15（無 control_id FATAL） | §8.1 |
| AC-16（PARTIAL warning） | §8.2 |
| AC-17（1hr expire） | §2.4 lazy expiration |
| AC-18（skipped_paragraphs 紀錄） | §4.2 JSONB 欄位 |
| AC-19（rich text → plain） | §7.3 |
| AC-20（圖片忽略） | §2.1 不走訪 inline_shape |
| AC-21（objective 第 2 欄當 description） | §7.2 |
| AC-22（權限） | §1.2 App Service 第一行檢查 |
| AC-23（information_systems 寫入） | §6.1 upsert_by_name |

---

## 10. 測試策略（高層次，細節留 test-plan.md）

### 10.1 單元測試（pytest，BE 主專案 `test/`）

| 測試對象 | 測試類別 |
|---------|---------|
| `DocxParserCore.parse()` | 多份 minimal docx fixture，覆蓋各 happy / FATAL / PARTIAL 情境 |
| `ControlIdMatcher.parse_control_id()` / `fuzzy_match_control()` | 純函式，輸入文字輸出 (id, score) |
| `ModuleFrameWriteStrategy.write()` | mock domain services，驗證 lazy-create 行為 |
| `SspWriteStrategy.write()` | 同上 |
| `SspDocxImportAppService` 各 method | 整合層，含權限檢查 / `@transaction` 測試 |

### 10.2 整合測試

- 上傳真實 fixture → parse → preview → confirm 全流程，DB 驗證 row 寫入
- Re-parse 場景：先寫一筆，再上傳同 docx，驗證 diff preview + decisions 行為

### 10.3 E2E（compliance-manager-test/）

- 三個入口的完整 wizard flow
- 拖拉指派場景
- FATAL / PARTIAL 錯誤呈現
- 權限拒絕場景

### 10.4 docx fixture 管理

- 主 fixture：`raw-requirement/reference/ASIA-CMMC-SSP-DRAFT-202604.docx`（5MB 真實客戶範本）
- 單元測試：自建 minimal docx（python-docx 程式內構造，不存檔）
- 整合測試：可用主 fixture，但要在 setup 時 copy 到 tmp 路徑

---

## 11. 待解決 / 風險

### 11.1 從 SA 帶來的 OPEN（已解 4 個）

| OPEN | 解法 |
|------|------|
| OPEN-1 information_systems 同名碰撞 | §6.1 upsert_by_name |
| OPEN-2 多人 race condition | §6.2 v1 不加 lock，後者勝 |
| OPEN-3 framework mismatch threshold | §2.8 dominant_match_rate 0.6, user_match_rate < 0.3 |
| OPEN-4 mode=full 空清單 | 視為「使用者明確選擇 0 個控制項」，允許但 Step 2 顯示 warning |
| OPEN-5 objective_key 驗證 | §2.6 在 strategy 層驗證 |
| OPEN-6 1hr expire 是否合適 | 採用 lazy + 1hr 預設，可後續從 system_config 調 |

### 11.2 SD 階段新增的待解 OPEN

| ID | 問題 | 影響 |
|----|------|------|
| **SD-OPEN-1** | mode=full 移除控制項時，舊的 `module_frame_control_defaults` 是否要刪除？目前設計保留（避免誤刪），但會出現「孤兒」defaults | 低；可在 v2 加清理 job |
| **SD-OPEN-2** | `dominant_match_rate` / `user_match_rate` threshold 參數化儲存位置：常數 vs system_config | 中；建議常數，未來真要客製再升級 |
| **SD-OPEN-3** | LLM hook 介面預留位置：parser core 是否預留 `_llm_classify(paragraph) -> control_id` method 簽名？v1 不實作但留 abstract base 讓 v2 加 | 中；建議**不預留**，v2 來時再加（YAGNI） |
| **SD-OPEN-4** | python-docx 對舊版 `.doc` 檔不支援。route 層只擋副檔名 `.docx`，但若使用者傳 `.docx` 但實際是 `.doc` 會失敗 | 低；用既有 mime 偵測抓 |
| **SD-OPEN-5** | parse_job 的 file_path：MinIO 內存還是 local tmp？若同步處理完不再用，是否一律刪除？ | 中；建議 confirm 後刪除 file，但保留 row（審計） |

### 11.3 風險

- **R1**：規則命中率低於預期（< 50%）導致 PARTIAL warning 觸發頻繁 → v1 上線早期需密切監控，必要時調 `_score` 公式
- **R2**：`information_systems.upsert_by_name` 大量被觸發若 docx 標題隨意，會多出大量無意義 row → 加「最少 metadata 才寫入」門檻（如 name 至少 5 字）
- **R3**：JSONB `parsed_result` 在 5MB docx 解析後可能膨脹（包含 273 段 + 25 控制項全文）→ 預估 < 1MB 安全；若實測超過，可改存 MinIO 帶 file_path

---

## 12. 對應的檔案清單（implementation 階段參考）

### BE 新增
- `infra/oscal/model/ssp_docx_parse_job.py`
- `infra/oscal/repository/ssp_docx_parse_job_repo_impl.py`
- `infra/oscal/mapper/ssp_docx_parse_job_mapper.py`
- `domain/oscal/entity/ssp_docx_parse_job_entity.py`
- `domain/oscal/repository/i_ssp_docx_parse_job_repo.py`
- `domain/oscal/services/ssp_docx_parse_job_domain_service.py`
- `domain/oscal/parser/docx_parser_core.py`
- `domain/oscal/parser/docx_intermediate.py`
- `domain/oscal/parser/framework_patterns.py`
- `domain/oscal/strategy/i_ssp_docx_write_strategy.py`
- `domain/oscal/strategy/module_frame_write_strategy.py`
- `domain/oscal/strategy/ssp_write_strategy.py`
- `app/oscal/service/ssp_docx_import_app_service.py`
- `api/oscal/routes/ssp/ssp_docx_import_route.py`
- `api/oscal/serializers/ssp/ssp_docx_import.py` (marshmallow schemas)
- `di_containers/oscal/ssp_docx_import_container.py`
- `common/util/control_id_matcher.py`
- `scripts/sql/ssp_docx_parse_jobs_migration.sql`

### BE 修改（小幅）
- `common/code/grc_error_code.py` (新增 17 個 error code)
- `di_containers/oscal/oscal_containers.py` (註冊新 sub-container)
- `jedi_information_system/app/service/information_system_service.py` (新增 `upsert_by_name`)
- `api/oscal/__init__.py` (註冊新 route)

### FE 新增（細節留 frontend-spec.md）
- `components/grc/SspDocxImportDialog.vue`
- `components/grc/ssp-docx-import/StepUpload.vue`
- `components/grc/ssp-docx-import/StepPreview.vue`
- `components/grc/ssp-docx-import/UnmatchedParagraphList.vue`（含拖拉）
- `components/grc/ssp-docx-import/MatchedControlDiff.vue`
- `components/grc/ssp-docx-import/ConflictModal.vue`
- `service/SspDocxImportService.js`

### FE 修改
- `components/grc/ModuleFrame.vue` (Step 2 / Step 3 加按鈕)
- 既有 SSP 控制項現況頁加「匯入 docx」按鈕

---

**Sign-off 區（Phase 2.2 結束時填寫）**

| 角色 | 名字 | 日期 | 備註 |
|------|------|------|------|
| Tech Lead | | | |

簽完後進 Phase 2.3（frontend-spec.md 撰寫）。
