SSP DOCX Template Rebuild 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: 將 SSP DOCX 匯出格式從「flat table 全控制項一覽」升級為對齊 CMMC 標準 SSP 文件的正式格式:每個控制項獨立章節(Heading + AO 表格 + 實作說明),封面改為正式表格,各角色人員各一張表。

Architecture: 採 hybrid 架構——docxtpl 負責 Cover / Revision / Introduction / System Environment(靜態結構,template 預先建好),generator 在 tpl.render() 後呼叫 tpl.get_docx() 取得 python-docx Document,再動態 append 控制項章節與 Reference Documents。Template 不再包含控制項與 Reference Docs loop。

Tech Stack: python-docx 1.x, docxtpl 0.17.x, docxtpl.DocxTemplate.get_docx() (已確認存在)


§1

變更範圍一覽

檔案 變更類型 說明
app/oscal/service/export/ssp_export_model.py Modify 新增 role_id 欄位到 SspPartyExportItem;新增 control_requirementSspControlImplExportItem
app/oscal/service/export/ssp_docx_generator.py Major rewrite _build_context 改為 per-role party dict;新增 _append_control_sections / _append_reference_docsgenerate() 改用 get_docx()
scripts/generate_ssp_docx_template.py Major rewrite 新封面、per-role party 靜態表格;移除控制項 loop 與 reference docs
app/oscal/templates/ssp/ssp_cmmc_template.docx Regenerate 重跑 template 腳本產生
app/oscal/service/export/ssp_mf_content_loader.py Modify _build_partiesrole_id_build_control_implementationscontrol_requirement
app/oscal/service/export/ssp_version_content_loader.py Modify _build_partiesrole_id_build_control_implementationscontrol_requirement

§2

Task 1:更新 export model

Files:

  • Modify: app/oscal/service/export/ssp_export_model.py
@dataclass
class SspPartyExportItem:
    name: str
    party_type: str                  # "person" | "organization"
    email: Optional[str] = None
    title: Optional[str] = None
    phone: Optional[str] = None
    address: Optional[str] = None
    org_name: Optional[str] = None
    role: Optional[str] = None
    role_id: Optional[str] = None    # OSCAL role ID,用於 per-role 表格配對
@dataclass
class SspControlImplExportItem:
    control_id: str
    control_name: Optional[str] = None
    control_requirement: Optional[str] = None  # catalog 的 requirement 說明文字
    description: Optional[str] = None
    status: Optional[str] = None
    control_origination: Optional[str] = None
    remarks: Optional[str] = None
    objectives: list[SspAoExportItem] = field(default_factory=list)
cd /path/to/compliance-manager-be
python -c "from app.oscal.service.export.ssp_export_model import SspPartyExportItem, SspControlImplExportItem; print('OK')"

預期輸出:OK


§3

Task 2:重建 template 腳本——封面 + per-role party 表格

Files:

  • Modify: scripts/generate_ssp_docx_template.py

設計說明

新 template 結構(靜態部分):

  1. 封面表格(4行 x 3欄,模仿 reference DOCX 格式)
  2. Revision History 表格
  3. Introduction
    • System Information kv table
    • Responsible Organization 靜態 2-col 表格({{ org.name }} 等)
    • Information Owner 靜態 2-col 表格
    • Information Provider / Receiver / System Owner / Security Officer(各一張)
  4. System Environment
    • Network Architecture({{ network_architecture }}
    • Data Flow({{ data_flow }}
    • System Components loop table
    • Hardware Inventory loop table
    • Leveraged External Services loop table
  5. (結束,controls 與 reference docs 由 generator 動態 append)

替換目前純文字封面為:

def add_cover(doc):
    tbl = doc.add_table(rows=4, cols=3)
    tbl.style = "Table Grid"
    # Row 0: Ser. NO
    set_cell_text(tbl.rows[0].cells[0], "Ser. NO:", bold=True)
    tbl.rows[0].cells[1].merge(tbl.rows[0].cells[2])
    set_cell_text(tbl.rows[0].cells[1], "{{ system_id }}")
    # Row 1: Version
    set_cell_text(tbl.rows[1].cells[0], "Version:", bold=True)
    tbl.rows[1].cells[1].merge(tbl.rows[1].cells[2])
    set_cell_text(tbl.rows[1].cells[1], "{{ version }}")
    # Row 2: Issue Date
    set_cell_text(tbl.rows[2].cells[0], "Issue Date:", bold=True)
    tbl.rows[2].cells[1].merge(tbl.rows[2].cells[2])
    set_cell_text(tbl.rows[2].cells[1], "{{ export_date }}")
    # Row 3: Title(全欄合併)
    c0, c1, c2 = tbl.rows[3].cells
    c0.merge(c1).merge(c2)
    set_cell_text(
        tbl.rows[3].cells[0],
        "System Security Plan (SSP)\n系統安全計畫",
        bold=True, font_size=16,
    )
    tbl.rows[3].paragraphs[0].alignment = WD_ALIGN_PARAGRAPH.CENTER
    doc.add_page_break()

每個 role 用同一個 helper 產生一張 2-col 靜態表:

def add_party_table(doc, heading: str, role_var: str):
    """role_var e.g. 'info_owner' → template vars {{ info_owner.name }} etc."""
    h2(doc, heading)
    tbl = doc.add_table(rows=5, cols=2)
    tbl.style = "Table Grid"
    rows_spec = [
        ("Name:", f"{{{{ {role_var}.name }}}}"),
        ("Title:", f"{{{{ {role_var}.title }}}}"),
        ("Office Address:", f"{{{{ {role_var}.address }}}}"),
        ("Work Phone:", f"{{{{ {role_var}.phone }}}}"),
        ("e-Mail Address:", f"{{{{ {role_var}.email }}}}"),
    ]
    for i, (label, val) in enumerate(rows_spec):
        set_cell_text(tbl.rows[i].cells[0], label, bold=True)
        set_cell_text(tbl.rows[i].cells[1], val)

組織用另一個 helper(欄位不同):

def add_org_table(doc, heading: str, role_var: str):
    h2(doc, heading)
    tbl = doc.add_table(rows=3, cols=2)
    tbl.style = "Table Grid"
    for i, (label, val) in enumerate([
        ("Name:", f"{{{{ {role_var}.name }}}}"),
        ("Address:", f"{{{{ {role_var}.address }}}}"),
        ("Phone:", f"{{{{ {role_var}.phone }}}}"),
    ]):
        set_cell_text(tbl.rows[i].cells[0], label, bold=True)
        set_cell_text(tbl.rows[i].cells[1], val)
h1(doc, "Introduction 基本資料")

h2(doc, "System Information 系統資訊")
add_kv_table(doc, [
    ("System Name 系統名稱",              "{{ system_name }}"),
    ("Abbreviation 簡稱",                 "{{ system_name_short }}"),
    ("System Categorization 系統類別",    "{{ sensitivity_level }}"),
    ("System Unique Identifier 識別號碼", "{{ system_id }}"),
    ("Deployment Model 部署模型",         "{{ deployment_model }}"),
    ("Authorization Boundary 授權邊界",   "{{ authorization_boundary }}"),
])

h2(doc, "General Description / Purpose of System 系統一般說明 / 用途")
doc.add_paragraph("{{ general_description }}")

add_org_table(doc,
    "Responsible Organization 專案負責單位", "org")
add_party_table(doc,
    "Information Owner 資料所有者", "info_owner")
add_party_table(doc,
    "Information Provider 資料提供者", "info_provider")
add_party_table(doc,
    "Information Receiver 資料接收者", "info_receiver")
add_party_table(doc,
    "System Owner 系統所有者", "system_owner")
add_party_table(doc,
    "System Security Officer 系統安全官", "security_officer")
h1(doc, "SYSTEM ENVIRONMENT 系統環境")

h2(doc, "Network Architecture 網路架構")
doc.add_paragraph("{{ network_architecture }}")

h2(doc, "Data Flow 資料流")
doc.add_paragraph("{{ data_flow }}")

h2(doc, "System Components 系統元件")
add_loop_table(doc,
    headers=["Name 名稱", "Type 類型", "Description 說明", "Status 狀態"],
    loop_var="components",
    cell_vars=["{{ item.name }}", "{{ item.component_type }}", "{{ item.description }}", "{{ item.status }}"],
)

h2(doc, "Hardware Inventory 設備清單")
add_loop_table(doc,
    headers=["Name 名稱", "Type 類型", "IP Address", "OS", "Status 狀態"],
    loop_var="inventory_items",
    cell_vars=["{{ item.name }}", "{{ item.device_type }}", "{{ item.ip_address }}", "{{ item.os }}", "{{ item.status }}"],
)

h2(doc, "Leveraged External Services 外部利用服務")
add_loop_table(doc,
    headers=["Service Title 服務名稱", "Provider 提供方", "Date Authorized 授權日期"],
    loop_var="leveraged_authorizations",
    cell_vars=["{{ item.title }}", "{{ item.provider_name }}", "{{ item.date_authorized }}"],
)
# 注意:控制項與 Reference Docs 由 generator 動態 append,template 到此結束

確認 build_template() 末段不再有 add_loop_table(..., "control_implementations", ...)add_loop_table(..., "all_objectives", ...) 及 Appendix 章節。

python scripts/generate_ssp_docx_template.py

預期:✅ Template saved to: .../ssp_cmmc_template.docx


§4

Task 3:重寫 generator _build_context——per-role party dict

Files:

  • Modify: app/oscal/service/export/ssp_docx_generator.py
@staticmethod
def _party_by_role(parties: list, role_id: str, is_org: bool = False) -> dict:
    """從 parties 清單找第一個符合 role_id 的 party,回傳 template 用 dict。"""
    for p in parties:
        if p.role_id == role_id or p.role == role_id:
            if is_org:
                return {
                    "name": p.name or "",
                    "address": p.address or "",
                    "phone": p.phone or "",
                }
            return {
                "name": p.name or "",
                "title": p.title or "",
                "email": p.email or "",
                "phone": p.phone or "",
                "address": p.address or "",
            }
    empty = {"name": "", "address": "", "phone": ""}
    if not is_org:
        empty.update({"title": "", "email": ""})
    return empty
def _build_context(self, model: SspExportDataModel, author: str) -> dict:
    p = model.parties
    return {
        # metadata
        "title": model.title,
        "system_name": model.system_name,
        "system_name_short": model.system_name_short or "",
        "system_id": model.source_uid or "",
        "version": model.version or "1.0",
        "export_date": date.today().isoformat(),
        "author": author,
        "general_description": model.description or "",
        "sensitivity_level": model.sensitivity_level or "",
        "authorization_boundary": model.authorization_boundary or "",
        "network_architecture": model.network_architecture or "",
        "data_flow": model.data_flow or "",
        "deployment_model": model.deployment_model or "",

        # per-role parties(各對應 template 中的靜態表格)
        "org":              self._party_by_role(p, "responsible-organization", is_org=True),
        "info_owner":       self._party_by_role(p, "information-owner"),
        "info_provider":    self._party_by_role(p, "information-provider"),
        "info_receiver":    self._party_by_role(p, "information-receiver"),
        "system_owner":     self._party_by_role(p, "system-owner"),
        "security_officer": self._party_by_role(p, "security-officer"),

        # system implementation(loop tables 在 template)
        "inventory_items": [...],   # 同舊邏輯
        "components": [...],        # 同舊邏輯
        "leveraged_authorizations": [...],  # 同舊邏輯

        # 控制項與 reference docs 已移至 generator 動態 append,這裡不需要
    }

注意:inventory_items / components / leveraged_authorizations 的組裝邏輯完全不動,直接從舊 _build_context 複製。


§5

Task 4:加入動態控制項章節 builder

Files:

  • Modify: app/oscal/service/export/ssp_docx_generator.py

設計說明

每個控制項產生以下結構:

[Heading 3] {control_id} – {control_name}
[Normal]    {control_requirement}(catalog requirement 說明,可能空白)
[Bold Para] Objective / 評估目標:
[Table 2-col] | statement_id | AO description |
[Bold Para] Implementation Description 實作說明:
[Normal]    {user description}
[空行]
@staticmethod
def _add_bold_paragraph(doc, text: str):
    p = doc.add_paragraph()
    run = p.add_run(text)
    run.bold = True
@staticmethod
def _add_ao_table(doc, objectives: list):
    """2欄 AO 表格:Statement ID | AO Description。"""
    from docx.shared import Pt, Cm
    from docx.oxml.ns import qn
    from docx.oxml import OxmlElement

    if not objectives:
        return

    tbl = doc.add_table(rows=1, cols=2)
    tbl.style = "Table Grid"

    # header row
    hdr = tbl.rows[0]
    hdr.cells[0].text = "Statement"
    hdr.cells[1].text = "Assessment Objective"
    for cell in hdr.cells:
        cell.paragraphs[0].runs[0].bold = True

    # data rows
    for ao in objectives:
        row = tbl.add_row()
        row.cells[0].text = ao.statement_id or ""
        row.cells[1].text = ao.ao_name or ""
def _append_control_sections(self, doc, model: SspExportDataModel):
    """在 docxtpl 渲染後的 doc 末尾 append 控制項章節。"""
    from docx.oxml import OxmlElement
    from docx.enum.text import WD_BREAK

    doc.add_page_break()
    doc.add_heading("REQUIREMENTS 安全要求", level=1)

    for ctrl in model.control_implementations:
        heading_text = ctrl.control_id
        if ctrl.control_name:
            heading_text += f" – {ctrl.control_name}"
        doc.add_heading(heading_text, level=3)

        if ctrl.control_requirement:
            doc.add_paragraph(ctrl.control_requirement)

        if ctrl.objectives:
            self._add_bold_paragraph(doc, "Objective / 評估目標:")
            self._add_ao_table(doc, ctrl.objectives)

        self._add_bold_paragraph(doc, "Implementation Description 實作說明:")
        doc.add_paragraph(ctrl.description or "")
        doc.add_paragraph("")  # 空行間距
def _append_reference_docs(self, doc, model: SspExportDataModel):
    """在控制項章節後 append Reference Documents 附錄。"""
    if not model.reference_documents:
        return

    doc.add_page_break()
    doc.add_heading("Appendix: Reference Documents 附錄:參考程序書", level=1)

    tbl = doc.add_table(rows=1, cols=4)
    tbl.style = "Table Grid"
    for i, h in enumerate(["Document Title 文件名稱", "Doc No. 文件編號", "Version 版本", "Type 類型"]):
        cell = tbl.rows[0].cells[i]
        cell.text = h
        cell.paragraphs[0].runs[0].bold = True

    for ref in model.reference_documents:
        row = tbl.add_row()
        row.cells[0].text = ref.title or ""
        row.cells[1].text = ref.doc_no or ""
        row.cells[2].text = ref.version or ""
        row.cells[3].text = ref.doc_type or ""
def generate(self, model: SspExportDataModel, author: str = "") -> BytesIO:
    tpl = DocxTemplate(self._template_path)
    context = self._build_context(model, author)
    tpl.render(context)

    doc = tpl.get_docx()                        # 取得 python-docx Document
    self._append_control_sections(doc, model)   # 動態 append 控制項
    self._append_reference_docs(doc, model)     # 動態 append 附錄

    buf = BytesIO()
    doc.save(buf)                               # 用 python-docx 存檔
    buf.seek(0)
    return buf

注意:原本 tpl.save(buf) 改成 doc.save(buf),功能等價。


§6

Task 5:更新 MfSspContentLoader — 填 role_id + control_requirement

Files:

  • Modify: app/oscal/service/export/ssp_mf_content_loader.py
# 查 module_frame_party_service.list_parties 的回傳結構
grep -n "def list_parties" ~/Projects/Jedicogy/module/jedi-python-package/jedi-oscal/ -r
# 或查主專案實作
grep -rn "list_parties" app/oscal/ --include="*.py" | head -10

確認 p.get("role") 的實際值(可能是 "system-owner" 或 "System Owner")。

def _build_parties(self, mf_uid: str) -> list[SspPartyExportItem]:
    raw = self._party.list_parties(mf_uid)
    result = []
    for p in raw:
        result.append(SspPartyExportItem(
            name=p.get("name") or "",
            party_type=p.get("party_type") or "person",
            email=p.get("email_address"),
            title=p.get("title"),
            phone=p.get("telephone_number"),
            address=p.get("address"),
            org_name=p.get("matched_org_unit_name"),
            role=p.get("role"),
            role_id=p.get("role"),   # 暫時與 role 同值,Step 5.1 確認後調整
        ))
    return result

注意: 若 step 5.1 發現 p.get("role") 回傳中文顯示名稱(如 "系統所有者"),則 role_id 需要另外填 OSCAL 標準 role ID。若目前沒有 OSCAL role_id 可用,則 Task 7 的 smoke test 時 party 表格會留空(graceful degradation,不影響其他 section)。

@staticmethod
def _build_catalog_maps(mf) -> dict:
    control_title_map: dict[str, str] = {}
    control_req_map: dict[str, str] = {}   # NEW
    ao_desc_map: dict[tuple, str] = {}

    profile = getattr(mf, "oscal_profile", None)
    if profile is None:
        return {"titles": control_title_map, "requirements": control_req_map, "ao_descs": ao_desc_map}

    for pc in (getattr(profile, "profile_controls", None) or []):
        cc = getattr(pc, "catalog_control", None)
        if cc is None:
            continue
        cid = getattr(cc, "control_id", None) or ""
        control_title_map[cid] = getattr(cc, "control_title", "") or ""
        control_req_map[cid] = getattr(cc, "description", "") or ""  # NEW

        for a in (getattr(cc, "assessments", None) or []):
            # ... 同舊邏輯 ...
            pass

    return {"titles": control_title_map, "requirements": control_req_map, "ao_descs": ao_desc_map}
result.append(SspControlImplExportItem(
    control_id=cid,
    control_name=control_title_map.get(cid, ""),
    control_requirement=catalog_maps.get("requirements", {}).get(cid, ""),  # NEW
    description=ctrl.implementation_description,
    status=ctrl.implementation_status,
    control_origination=ctrl.control_origination,
    remarks=ctrl.remarks,
    objectives=objectives,
))

§7

Task 6:更新 SspVersionContentLoader — 填 role_id + control_requirement

Files:

  • Modify: app/oscal/service/export/ssp_version_content_loader.py
grep -rn "responsible_parties" ~/Projects/Jedicogy/module/jedi-python-package/jedi-oscal/ --include="*.py" | head -15

確認 metadata.responsible_parties 是否存在、其結構(role_id + party_uuid list)。

metadata.responsible_parties 可用:

@staticmethod
def _build_parties(metadata) -> list[SspPartyExportItem]:
    if metadata is None:
        return []
    raw_parties = getattr(metadata, "parties", None) or []
    responsible = getattr(metadata, "responsible_parties", None) or []

    # 建立 party_uuid → role_id 對照
    uuid_to_role: dict[str, str] = {}
    for rp in responsible:
        role_id = getattr(rp, "role_id", None) or ""
        for uuid in (getattr(rp, "party_uuids", None) or []):
            uuid_to_role[str(uuid)] = role_id

    result = []
    for p in raw_parties:
        party_uuid = str(getattr(p, "uuid", "") or "")
        result.append(SspPartyExportItem(
            name=getattr(p, "name", "") or "",
            party_type=getattr(p, "party_type", "person") or "person",
            email=getattr(p, "email_address", None),
            title=getattr(p, "title", None),
            phone=getattr(p, "telephone_number", None),
            address=getattr(p, "address", None),
            org_name=None,
            role=uuid_to_role.get(party_uuid),
            role_id=uuid_to_role.get(party_uuid),  # NEW
        ))
    return result

若 Step 6.1 確認 responsible_parties 不存在,則 role_id=None(graceful degradation)。

catalog_control = getattr(ctrl, "catalog_control", None)
control_name = getattr(catalog_control, "control_title", "") if catalog_control else ""
control_requirement = getattr(catalog_control, "description", "") if catalog_control else ""  # NEW

result.append(SspControlImplExportItem(
    control_id=ctrl.control_identifier or "",
    control_name=control_name,
    control_requirement=control_requirement,  # NEW
    description=ctrl.implementation_description,
    status=ctrl.implementation_status,
    objectives=objectives,
))

§8

Task 7:重新產生 template + smoke test

Files:

  • Run: scripts/generate_ssp_docx_template.py
  • Check: app/oscal/templates/ssp/ssp_cmmc_template.docx
python scripts/generate_ssp_docx_template.py

預期:✅ Template saved to: .../ssp_cmmc_template.docx

lsof -ti:8000 || (lsof -ti:8000 | xargs kill -9; python main_socketio.py &)
curl -s -o /tmp/test_ssp.docx \
  -H "Authorization: Bearer <token>" \
  -H "X-Tenant-Id: 102" \
  -H "X-Org-Unit-Id: 98" \
  "http://localhost:8000/api/1.0/module-frame/<mf_uid>/ssp-export?format=docx"

驗證(用 python-docx):

from docx import Document
doc = Document("/tmp/test_ssp.docx")
headings = [p.text for p in doc.paragraphs if "Heading" in p.style.name]
print(headings)
# 預期:['Introduction 基本資料', 'REQUIREMENTS 安全要求', 'AC.1.001 – Control Name', ...]
curl -s -o /tmp/test_ssp_version.docx \
  -H "Authorization: Bearer <token>" \
  -H "X-Tenant-Id: 102" \
  -H "X-Org-Unit-Id: 98" \
  "http://localhost:8000/api/1.0/ssp/<ssp_uid>/export?format=docx"
curl -s "http://localhost:8000/api/1.0/ssp/<ssp_uid>/export?format=json" | python -m json.tool | head -20

預期:JSON 正常回傳(OSCAL 路徑未修改,不受本次改動影響)


§9

Task 8:Changelog + commit

建立 docs/changelog/2026-05-22-feat-ssp-docx-template-rebuild.md

---
type: feat
modules: [oscal, ssp-export]
commit:
---

說明:SSP DOCX 匯出格式升級 — 對齊 CMMC 標準 SSP 文件格式,每個控制項獨立章節(Heading 3 + AO 表格 + 實作說明);封面改為正式四行表格;各角色人員(Information Owner / Provider / Receiver / System Owner / SSO)各一張獨立表格。

git add app/oscal/service/export/ssp_export_model.py \
        app/oscal/service/export/ssp_docx_generator.py \
        app/oscal/service/export/ssp_mf_content_loader.py \
        app/oscal/service/export/ssp_version_content_loader.py \
        scripts/generate_ssp_docx_template.py \
        app/oscal/templates/ssp/ssp_cmmc_template.docx \
        docs/changelog/2026-05-22-feat-ssp-docx-template-rebuild.md
git commit -m "feat(ssp-export): rebuild DOCX template to CMMC SSP format (per-control sections + per-role parties)"

§10

已知限制與後續優化

項目 現況 後續改進
AO ZH 翻譯 AO 表格只有 EN 若 catalog 補 ZH 翻譯欄位,加入右欄
控制項 requirement 說明 catalog_control.description,若空白則不顯示 確認 catalog data 是否有此欄位
Party role_id 配對 依 Step 5.1 / 6.1 實際查到的欄位決定;若查不到則 party 表格留空 長期補充 role_id 對照映射
控制項排序 按 service 回傳順序 可加 sort by control_id