SSP 文件解析器 設計說明書

文件版本:1.0 撰寫日期:2026-04-30 文件性質:SD — System Design(架構與實作策略) 上游 SAapi-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

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 完成。

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

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
    • appendcurrent_value + '\n\n' + paragraph.text
    • replaceparagraph.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

# 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() 內:

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

-- 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 結構

{
  "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

# 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 新增:

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 需要 @injectSspDocxImportAppService


6. 與既有功能的相容性

6.1 InformationSystemService 新增 upsert_by_name

不修改既有 add / update 簽名,只加新 method

# 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 UPDATEget → 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 偵測

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)

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 後面」。解法:

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 失敗分級實作

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 公式
  • R2information_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 撰寫)。