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() (已確認存在)
| 檔案 | 變更類型 | 說明 |
|---|---|---|
app/oscal/service/export/ssp_export_model.py |
Modify | 新增 role_id 欄位到 SspPartyExportItem;新增 control_requirement 到 SspControlImplExportItem |
app/oscal/service/export/ssp_docx_generator.py |
Major rewrite | _build_context 改為 per-role party dict;新增 _append_control_sections / _append_reference_docs;generate() 改用 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_parties 填 role_id;_build_control_implementations 填 control_requirement |
app/oscal/service/export/ssp_version_content_loader.py |
Modify | _build_parties 填 role_id;_build_control_implementations 填 control_requirement |
Files:
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
Files:
scripts/generate_ssp_docx_template.py新 template 結構(靜態部分):
{{ org.name }} 等){{ network_architecture }}){{ data_flow }})替換目前純文字封面為:
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
_build_context——per-role party dictFiles:
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 emptydef _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 複製。
Files:
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),功能等價。
Files:
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,
))Files:
app/oscal/service/export/ssp_version_content_loader.pygrep -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,
))Files:
scripts/generate_ssp_docx_template.pyapp/oscal/templates/ssp/ssp_cmmc_template.docxpython 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 路徑未修改,不受本次改動影響)
建立 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)"| 項目 | 現況 | 後續改進 |
|---|---|---|
| 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 |