For agentic workers: REQUIRED SUB-SKILL: Use
superpowers:subagent-driven-development(recommended) orsuperpowers:executing-plansto implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.
Goal: SSP 重新上傳 docx 時新增 Step 2 Diff 解決頁,逐項顯示「DB 現值 vs docx 新值」並讓 user 決策(保留 / 採用新值 / 跳過),最後 confirm 時只寫入勾選為 use_docx 的項目。
Architecture: 不加新表、既有 endpoint URL 不變,只擴充 parse / confirm 的 schema。BE 計算 smart default + diff_status,FE 用 jsdiff 算 word-level highlight 並渲染 side-by-side 並排卡。Step 2 為新插入步驟,Step 3 既有元件接收 decisions 進入 readonly mode。
Tech Stack: Python 3 + Flask-RESTful + SQLAlchemy + DDD layered (BE) / Vue 3 + PrimeVue + Pinia + jsdiff (FE) / Cucumber + Playwright (E2E)
Spec: design.md
⚠️ Post-Implementation Note(2026-05-07): 此計畫已完成(BE 30 commits + FE 52 commits,branch
feature/ssp-update-diff)。實作中有若干與計畫的偏差,包含:addeditems 改為default_action=null(非use_docx)、BE annotate 保留所有 items 不 filter、gone改為顯示給 user 決策、filter 從 5 選項改為 3、ParsedParty.target_party_uid新增欄位、party enrichment(nickname/login_name)、email field aliasing、party_type-aware diff、DB-only AO 合成、module_frame source type 支援。完整偏差原因請見design.md §11(Implementation Reality / Reconciliation)。
responsible_party / parties infrastructure 已存在# 確認 jedi-oscal 有 party domain service
ls ~/Projects/Jedicogy/module/jedi-python-package/jedi-oscal/jedi_oscal/domain/repository/base/responsible_party.py
# 確認主專案 strategy.write_parties() 已存在
grep -n "def write_parties" /Users/chouraymond/Projects/Billows/Audit-Manager/compliance-manager-be/domain/oscal/strategy/ssp_write_strategy.py
# 預期:line 282預期結果:兩個都存在 → Phase 1 不需新增 domain service / strategy method。
grep -E "GRC_(404|409|400)\d+" /Users/chouraymond/Projects/Billows/Audit-Manager/compliance-manager-be/common/code/grc_error_code.py | tail -10當前最新(plan-stage 確認,截至 2026-05-06,已含 party 相關既有 codes):
GRC_DOCX_PARSE_JOB_NOT_FOUND = GRC_404023(將沿用)GRC_PARTY_NAME_REQUIRED = GRC_400015、GRC_PARTY_ROLE_REQUIRED = GRC_400016 已被佔用(line 155-157)GRC_DRIVE_INTEGRATION_ALREADY_EXISTS = GRC_409020新 error codes 將分配於 GRC_400017 / 400018 / GRC_409021(避開已佔序號)。實作前再 grep 一次確認無新 commit 佔用。
app/oscal/service/ssp_docx_diff_service.py — diff 計算邏輯(smart default / matching / summary)tests/test_ssp_docx_diff_service.py — diff service unit teststests/test_ssp_docx_import_diff_flow.py — confirm endpoint integration test(update mode w/ decisions)tests/test_ssp_docx_import_create_no_diff.py — create mode regression testapp/oscal/service/ssp_docx_import_app_service.py — wire diff service 進 parse + confirmapi/oscal/serializers/ssp/ssp_docx_import.py — 擴充 response schema(has_diff / diff_summary / diff_status / default_action / parties.current_values / parties.parsed_values)api/oscal/routes/ssp/ssp_docx_import_route.py — 擴充 confirm payload schema(parties_decisions)common/code/grc_error_code.py — 新增 3 個 error codesdi_containers/oscal/oscal_containers.py — 若 diff service 需 wiring(domain service 注入)compliance-manager-fe/)src/stores/ssp-docx-import.js — Pinia storesrc/utils/textDiff.js — jsdiff 包裝層src/components/grc/ssp-docx-import-v2/diff/DiffResolutionStep.vuesrc/components/grc/ssp-docx-import-v2/diff/DiffSectionShell.vuesrc/components/grc/ssp-docx-import-v2/diff/ControlDiffSection.vuesrc/components/grc/ssp-docx-import-v2/diff/ControlDiffCard.vuesrc/components/grc/ssp-docx-import-v2/diff/ObjectiveDiffRow.vuesrc/components/grc/ssp-docx-import-v2/diff/PartiesDiffSection.vuesrc/components/grc/ssp-docx-import-v2/diff/PartyDiffCard.vuesrc/components/grc/ssp-docx-import-v2/SspDocxImportPage.vue — 加入 Step 2 stepper progressionsrc/components/grc/ssp-docx-import-v2/ControlImplSection.vue — 接收 decisions、filter 跟 readonlysrc/components/grc/ssp-docx-import-v2/PartiesSection.vue — 同上src/components/grc/ssp-docx-import-v2/ImportOutline.vue — 顯示 diff_summarypackage.json — 加 jsdiff 依賴(diff package)compliance-manager-test/)requirements/ssp-update-diff.mdspecs/ssp-update-diff.featurefeatures/steps/ssp-update-diff.steps.jsfeatures/pages/SspDocxImportDiffPage.js階段目標:完成 diff 計算邏輯 + parse response 擴充。完成後既有 create flow 不變,update mode 開始回傳 diff metadata。Confirm endpoint 暫不改。
Files:
app/oscal/service/ssp_docx_diff_service.pytests/test_ssp_docx_diff_service.pySspDocxDiffService 是 stateless 純邏輯類,不查 DB(DB 查詢由 caller SspDocxImportAppService 負責),輸入 current + parsed,輸出 diff metadata。
# tests/test_ssp_docx_diff_service.py
import pytest
from app.oscal.service.ssp_docx_diff_service import SspDocxDiffService
class TestComputeDefaultActionForText:
def setup_method(self):
self.svc = SspDocxDiffService()
def test_current_empty_parsed_has_value_returns_use_docx(self):
result = self.svc.compute_default_action_for_text(current="", parsed="ABAC 機制")
assert result == ("added", "use_docx")
def test_current_none_parsed_has_value_returns_use_docx(self):
result = self.svc.compute_default_action_for_text(current=None, parsed="ABAC 機制")
assert result == ("added", "use_docx")
def test_identical_after_strip_returns_unchanged(self):
result = self.svc.compute_default_action_for_text(current=" RBAC ", parsed="RBAC")
assert result == ("unchanged", None)
def test_different_returns_keep_current(self):
result = self.svc.compute_default_action_for_text(current="RBAC 機制", parsed="ABAC 機制")
assert result == ("changed", "keep_current")
def test_current_has_value_parsed_empty_returns_gone(self):
result = self.svc.compute_default_action_for_text(current="RBAC 機制", parsed="")
assert result == ("gone", None)cd /Users/chouraymond/Projects/Billows/Audit-Manager/compliance-manager-be
pytest tests/test_ssp_docx_diff_service.py::TestComputeDefaultActionForText -vExpected: 5 fails with ModuleNotFoundError or AttributeError
# app/oscal/service/ssp_docx_diff_service.py
from typing import Optional, Tuple, Literal
DiffStatus = Literal["unchanged", "changed", "added", "gone"]
DefaultAction = Literal["keep_current", "use_docx"]
class SspDocxDiffService:
"""Stateless diff calculator. No DB queries — caller passes current + parsed."""
def compute_default_action_for_text(
self,
current: Optional[str],
parsed: Optional[str],
) -> Tuple[DiffStatus, Optional[DefaultAction]]:
"""Per design §3 smart default table.
Returns (diff_status, default_action). default_action is None for
unchanged / gone (those are filtered out of the response).
"""
cur_norm = (current or "").strip()
par_norm = (parsed or "").strip()
if not cur_norm and par_norm:
return ("added", "use_docx")
if cur_norm and not par_norm:
return ("gone", None)
if cur_norm == par_norm:
return ("unchanged", None)
return ("changed", "keep_current")pytest tests/test_ssp_docx_diff_service.py::TestComputeDefaultActionForText -vExpected: 5 PASS
git add app/oscal/service/ssp_docx_diff_service.py tests/test_ssp_docx_diff_service.py
git commit -m "feat(ssp-update-diff): add SspDocxDiffService.compute_default_action_for_text"match_parties 模糊比對Files:
app/oscal/service/ssp_docx_diff_service.pytests/test_ssp_docx_diff_service.py依 design §6.1 edge case rules:用 (name+email) 模糊比對配對 current ↔︎ parsed parties。
# Append to tests/test_ssp_docx_diff_service.py
class TestMatchParties:
def setup_method(self):
self.svc = SspDocxDiffService()
def test_match_by_name_and_email(self):
current = [{"party_uid": "u1", "name": "王小明", "email": "ming@old.com"}]
parsed = [{"name": "王小明", "email": "ming@new.com"}] # email 改了,name 同
pairs, only_current, only_parsed = self.svc.match_parties(current, parsed)
assert len(pairs) == 1
assert pairs[0][0]["party_uid"] == "u1"
assert pairs[0][1]["email"] == "ming@new.com"
assert only_current == []
assert only_parsed == []
def test_no_match_separates_into_only_lists(self):
current = [{"party_uid": "u1", "name": "張三", "email": "san@example.com"}]
parsed = [{"name": "李四", "email": "si@example.com"}]
pairs, only_current, only_parsed = self.svc.match_parties(current, parsed)
assert pairs == []
assert len(only_current) == 1
assert len(only_parsed) == 1
def test_match_when_email_missing_falls_back_to_name(self):
current = [{"party_uid": "u1", "name": "王小明", "email": ""}]
parsed = [{"name": "王小明", "email": ""}]
pairs, _, _ = self.svc.match_parties(current, parsed)
assert len(pairs) == 1
def test_match_supports_email_address_alternate_spelling(self):
"""current 從 ResponsibleParty 拿可能用 email_address;parsed 用 email — 兩邊都要 work"""
current = [{"party_uid": "u1", "name": "王小明", "email_address": "ming@x.com"}]
parsed = [{"name": "王小明", "email": "ming@x.com"}]
pairs, _, _ = self.svc.match_parties(current, parsed)
assert len(pairs) == 1pytest tests/test_ssp_docx_diff_service.py::TestMatchParties -vExpected: 3 FAIL
# Append to app/oscal/service/ssp_docx_diff_service.py
def _party_key(self, party: dict) -> str:
name = (party.get("name") or "").strip().lower()
email = (party.get("email") or party.get("email_address") or "").strip().lower()
return f"{name}|{email}"
def match_parties(self, current: list[dict], parsed: list[dict]):
"""Match current vs parsed parties by (name+email).
Returns (pairs, only_current, only_parsed):
- pairs: list of (current_party, parsed_party) tuples
- only_current: current parties with no parsed match (will be diff_status="gone")
- only_parsed: parsed parties with no current match (will be diff_status="added")
"""
parsed_by_key = {self._party_key(p): p for p in parsed}
pairs = []
only_current = []
for cur in current:
key = self._party_key(cur)
if key in parsed_by_key:
pairs.append((cur, parsed_by_key.pop(key)))
else:
only_current.append(cur)
only_parsed = list(parsed_by_key.values())
return pairs, only_current, only_parsedpytest tests/test_ssp_docx_diff_service.py::TestMatchParties -vExpected: 3 PASS
git add app/oscal/service/ssp_docx_diff_service.py tests/test_ssp_docx_diff_service.py
git commit -m "feat(ssp-update-diff): add match_parties fuzzy matching"compute_party_diff (per-party 整體 diff)Files:
app/oscal/service/ssp_docx_diff_service.pytests/test_ssp_docx_diff_service.py每個 matched pair 比對 4 個欄位(name / email / role / matched_user_id),任一不同就是 changed。
# Append to tests/test_ssp_docx_diff_service.py
class TestComputePartyDiff:
def setup_method(self):
self.svc = SspDocxDiffService()
def test_identical_parties_returns_unchanged(self):
cur = {"name": "王小明", "email": "m@x.com", "role": "admin", "matched_user_id": 42}
par = {"name": "王小明", "email": "m@x.com", "role": "admin", "matched_user_id": 42}
diff_status, default_action = self.svc.compute_party_diff(cur, par)
assert diff_status == "unchanged"
assert default_action is None
def test_email_change_returns_changed_keep_current(self):
cur = {"name": "王小明", "email": "old@x.com", "role": "admin", "matched_user_id": 42}
par = {"name": "王小明", "email": "new@x.com", "role": "admin", "matched_user_id": 42}
result = self.svc.compute_party_diff(cur, par)
assert result == ("changed", "keep_current")
def test_added_party_returns_use_docx(self):
result = self.svc.compute_party_diff(None, {"name": "新人", "email": "new@x.com"})
assert result == ("added", "use_docx")pytest tests/test_ssp_docx_diff_service.py::TestComputePartyDiff -v# Append
def compute_party_diff(self, current, parsed):
if current is None and parsed is not None:
return ("added", "use_docx")
if current is not None and parsed is None:
return ("gone", None)
fields = ("name", "email", "role", "matched_user_id", "matched_org_unit_id")
for f in fields:
if (current.get(f) or "") != (parsed.get(f) or ""):
return ("changed", "keep_current")
return ("unchanged", None)git commit -am "feat(ssp-update-diff): add compute_party_diff"build_diff_summary 統計Files:
app/oscal/service/ssp_docx_diff_service.pytests/test_ssp_docx_diff_service.py依 design §4.1 response 結構,回傳 controls / objectives / parties 三 sections 的 changed / added / total_with_diff 統計。
class TestBuildDiffSummary:
def setup_method(self):
self.svc = SspDocxDiffService()
def test_counts_changed_and_added_per_section(self):
annotated = {
"matched_controls": [
{"diff_status": "changed", "objectives": [{"diff_status": "added"}]},
{"diff_status": "added", "objectives": []},
],
"parties": [
{"diff_status": "changed"},
{"diff_status": "added"},
{"diff_status": "added"},
],
}
s = self.svc.build_diff_summary(annotated)
assert s["controls"] == {"changed": 1, "added": 1, "total_with_diff": 2}
assert s["objectives"] == {"changed": 0, "added": 1, "total_with_diff": 1}
assert s["parties"] == {"changed": 1, "added": 2, "total_with_diff": 3}
def test_empty_annotated_returns_zeros(self):
s = self.svc.build_diff_summary({"matched_controls": [], "parties": []})
assert s["controls"]["total_with_diff"] == 0
assert s["parties"]["total_with_diff"] == 0def build_diff_summary(self, annotated: dict) -> dict:
def _count(items):
c = sum(1 for i in items if i.get("diff_status") == "changed")
a = sum(1 for i in items if i.get("diff_status") == "added")
return {"changed": c, "added": a, "total_with_diff": c + a}
controls = annotated.get("matched_controls", [])
objectives = [obj for ctrl in controls for obj in (ctrl.get("objectives") or [])]
parties = annotated.get("parties", [])
return {
"controls": _count(controls),
"objectives": _count(objectives),
"parties": _count(parties),
}git commit -am "feat(ssp-update-diff): add build_diff_summary"annotate_parse_result — 整合 entrypointFiles:
app/oscal/service/ssp_docx_diff_service.pytests/test_ssp_docx_diff_service.pyEntrypoint method:吃完整 parse_result + current_state,輸出 annotated parse_result(含 diff_status / default_action,且 filter 掉 unchanged / gone 的項目)。
class TestAnnotateParseResult:
def setup_method(self):
self.svc = SspDocxDiffService()
def test_filters_unchanged_controls(self):
parse_result = {
"matched_controls": [
{
"control_id": "AC-1",
"current_implementation_description": "same text",
"parsed_implementation_description": "same text",
"objectives": [],
},
{
"control_id": "AC-2",
"current_implementation_description": "RBAC",
"parsed_implementation_description": "ABAC",
"objectives": [],
},
],
"parties": [],
}
current_parties = []
result = self.svc.annotate_parse_result(parse_result, current_parties)
assert len(result["matched_controls"]) == 1
assert result["matched_controls"][0]["control_id"] == "AC-2"
assert result["matched_controls"][0]["diff_status"] == "changed"
assert result["matched_controls"][0]["default_action"] == "keep_current"
assert result["has_diff"] is True
def test_no_diff_sets_has_diff_false(self):
parse_result = {
"matched_controls": [
{
"control_id": "AC-1",
"current_implementation_description": "same",
"parsed_implementation_description": "same",
"objectives": [],
}
],
"parties": [],
}
result = self.svc.annotate_parse_result(parse_result, [])
assert result["has_diff"] is False
assert result["matched_controls"] == []
def test_added_party_appears_with_synthesized_uid(self):
parse_result = {
"matched_controls": [],
"parties": [{"name": "新人", "email": "new@x.com", "role": "admin"}],
}
result = self.svc.annotate_parse_result(parse_result, current_parties=[])
assert len(result["parties"]) == 1
p = result["parties"][0]
assert p["diff_status"] == "added"
assert p["default_action"] == "use_docx"
assert p["party_uid"] # synthesized
assert p["current_values"] is Noneimport uuid
def annotate_parse_result(self, parse_result: dict, current_parties: list[dict]) -> dict:
"""Annotate parse_result with diff_status / default_action per item, filter
out unchanged / gone items, and add has_diff + diff_summary.
Returns a NEW dict (does not mutate input).
"""
annotated = dict(parse_result)
# Controls + objectives
annotated_controls = []
for ctrl in parse_result.get("matched_controls", []):
ctrl_status, ctrl_action = self.compute_default_action_for_text(
ctrl.get("current_implementation_description"),
ctrl.get("parsed_implementation_description"),
)
annotated_objectives = []
for obj in (ctrl.get("objectives") or []):
obj_status, obj_action = self.compute_default_action_for_text(
obj.get("current_description"),
obj.get("parsed_description"),
)
if obj_status in ("unchanged", "gone"):
continue
annotated_obj = {**obj, "diff_status": obj_status, "default_action": obj_action}
annotated_objectives.append(annotated_obj)
# Skip control entirely if both impl AND all objectives are unchanged/gone
if ctrl_status in ("unchanged", "gone") and not annotated_objectives:
continue
annotated_ctrl = {
**ctrl,
"diff_status": ctrl_status if ctrl_status != "unchanged" else "unchanged",
"default_action": ctrl_action,
"objectives": annotated_objectives,
}
annotated_controls.append(annotated_ctrl)
annotated["matched_controls"] = annotated_controls
# Parties
pairs, _gone, only_parsed = self.match_parties(
current_parties, parse_result.get("parties", [])
)
annotated_parties = []
for cur, par in pairs:
status, action = self.compute_party_diff(cur, par)
if status == "unchanged":
continue
annotated_parties.append({
"party_uid": cur.get("party_uid"),
"party_type": par.get("party_type") or cur.get("party_type"),
"current_values": cur,
"parsed_values": par,
"diff_status": status,
"default_action": action,
})
for par in only_parsed:
annotated_parties.append({
"party_uid": f"new-{uuid.uuid4()}",
"party_type": par.get("party_type"),
"current_values": None,
"parsed_values": par,
"diff_status": "added",
"default_action": "use_docx",
})
annotated["parties"] = annotated_parties
# Summary + has_diff
annotated["diff_summary"] = self.build_diff_summary(annotated)
annotated["has_diff"] = (
annotated["diff_summary"]["controls"]["total_with_diff"] > 0
or annotated["diff_summary"]["objectives"]["total_with_diff"] > 0
or annotated["diff_summary"]["parties"]["total_with_diff"] > 0
)
return annotatedgit commit -am "feat(ssp-update-diff): add annotate_parse_result entrypoint"Files:
app/oscal/service/ssp_docx_import_app_service.pydi_containers/oscal/oscal_containers.pyapi/oscal/serializers/ssp/ssp_docx_import.py注入 diff service,呼叫 annotate_parse_result(),並在 serializer 加新欄位。
# di_containers/oscal/oscal_containers.py
# 在既有 ssp_docx_import_app_service provider 旁邊
from app.oscal.service.ssp_docx_diff_service import SspDocxDiffService
ssp_docx_diff_service = providers.Singleton(SspDocxDiffService)
# 修改既有 provider:
ssp_docx_import_app_service = providers.Singleton(
SspDocxImportAppService,
# ... existing args
diff_service=ssp_docx_diff_service, # 新增
)# app/oscal/service/ssp_docx_import_app_service.py
# 在 SspDocxImportAppService 內加新方法
def _load_current_parties(self, source_uid: str, source_type: str) -> list[dict]:
"""Load existing parties for SSP. Returns empty list for module_frame
or when SSP has no parties yet."""
if source_type != "project_ssp":
return []
# 從 ssp_domain_service 拿 SSP entity,再透過 responsible_party domain service 查
# 細節依既有 jedi-oscal 提供的 query API。例:
ssp = self._ssp_domain_service.get_by_uid(source_uid)
if ssp is None:
return []
# 用 IBaseRepo 標準 list_by(QueryEntity) pattern,避免猜測自訂 method 名
from jedi_oscal.domain.entity.base.oscal_responsible_party_query_entity import (
ResponsiblePartyQueryEntity,
)
parties = self._responsible_party_domain_service.list_by(
ResponsiblePartyQueryEntity(context_type="ssp", context_id=ssp.id)
)
# Translate to plain dict matching parsed_parties shape
return [
{
"party_uid": p.party_uid,
"party_type": p.party_type,
"name": p.name,
"email": p.email_address,
"role": p.role_id,
"matched_user_id": p.user_id,
"matched_org_unit_id": p.org_unit_id,
}
for p in parties
]注意:上面用
list_by(QueryEntity)是 jedi-oscalIBaseRepo標準 query pattern。實作前 grep 確認IResponsibleParty有繼承 IBaseRepo 並提供list_bymethod(grep -n "def list_by\|class.*IResponsibleParty" jedi-oscal/jedi_oscal/domain/repository/base/responsible_party.py)。若 query entity 欄位名稱不是context_type/context_id,依實際 entity 定義調整。
找到既有 get_parse_result() 方法(grep def get_parse_result),在 return 前插入:
@transaction
def get_parse_result(self, parse_uid: str, user_id: str):
job = self._parse_job_repo.get_by_uid(parse_uid)
if job is None:
raise NotFound(GrcErrorCode.GRC_DOCX_PARSE_JOB_NOT_FOUND)
# 既有:parse_result 從 job.parsed_result 取出
parse_result = self._build_parse_result_view(job)
# 新增:annotate with diff metadata
current_parties = self._load_current_parties(job.source_uid, job.source_type)
annotated = self._diff_service.annotate_parse_result(parse_result, current_parties)
return annotated# api/oscal/serializers/ssp/ssp_docx_import.py
class SspDocxImportPreviewSchema(Schema):
# 既有欄位...
has_diff = fields.Bool() # 新增
diff_summary = fields.Dict() # 新增(含 controls/objectives/parties counts)
matched_controls = fields.List(fields.Nested("MatchedControlSchema"))
parties = fields.List(fields.Nested("PartyDiffSchema")) # 新增 schema
class MatchedControlSchema(Schema):
# 既有
diff_status = fields.Str() # 新增
default_action = fields.Str(allow_none=True) # 新增
objectives = fields.List(fields.Nested("ObjectiveDiffSchema"))
class ObjectiveDiffSchema(Schema):
# 既有
diff_status = fields.Str()
default_action = fields.Str(allow_none=True)
class PartyDiffSchema(Schema):
party_uid = fields.Str()
party_type = fields.Str()
current_values = fields.Dict(allow_none=True)
parsed_values = fields.Dict(allow_none=True)
diff_status = fields.Str()
default_action = fields.Str(allow_none=True)啟動 BE,呼叫 GET /ssp-docx-import/<uid> for an update-mode job,確認 response 含 has_diff / diff_summary / matched_controls[].diff_status 欄位。
python main_app.py &
# 用既有 parse_uid 呼叫
curl -H "Authorization: Bearer $TOKEN" http://localhost:8000/ssp-docx-import/<uid> | jq '.has_diff, .diff_summary'git add app/oscal/service/ssp_docx_import_app_service.py \
di_containers/oscal/oscal_containers.py \
api/oscal/serializers/ssp/ssp_docx_import.py
git commit -m "feat(ssp-update-diff): wire SspDocxDiffService into parse response"Files:
docs/changelog/2026-05-XX-feat-ssp-update-diff-be-phase1.md依 CLAUDE.md「Changelog 分類規範」:
---
type: feat
modules: [oscal, ssp]
issue: docs/issues/pending/ssp-update-diff.md
---
# Phase 1 — BE diff service & parse response 擴充
## 需求
SSP 重新匯入時,user 看不到 docx 新值與 DB 現值的差異,可能無預警覆蓋手動編輯內容。Phase 1 在 BE parse response 加上 diff metadata(diff_status / default_action / current_values for parties),FE 後續用此資訊渲染 Step 2 Diff 解決頁。
## 變更範圍
- 新增 `app/oscal/service/ssp_docx_diff_service.py`(純邏輯類)
- 改 `app/oscal/service/ssp_docx_import_app_service.py`(注入 diff service,annotate parse result)
- 改 `api/oscal/serializers/ssp/ssp_docx_import.py`(擴充 response schema)
- 新增 `tests/test_ssp_docx_diff_service.py`
## API 變更(additive only)
GET `/ssp-docx-import/<uid>` response 新增:
- `has_diff: bool`
- `diff_summary: { controls, objectives, parties }`
- `matched_controls[].diff_status / .default_action`
- `matched_controls[].objectives[].diff_status / .default_action`
- `parties[].current_values / .parsed_values / .diff_status / .default_action`
既有欄位行為不變,FE create flow 完全不受影響(has_diff=false)。
## 測試結果
pytest tests/test_ssp_docx_diff_service.py -v: 10+ tests passgit add docs/changelog/
git commit -m "docs(changelog): Phase 1 BE diff service"階段目標:confirm payload 接收
parties_decisions,在 app service filter 後再呼叫既有write_parties()。
Files:
common/code/grc_error_code.py# 在既有 GrcErrorCode class 適當位置插入
GRC_DOCX_DECISIONS_INVALID_CONTROL_ID = ("Decisions 包含未知 control_id", "GRC_400017")
GRC_DOCX_DECISIONS_INVALID_PARTY_UID = ("Parties decisions 包含未知 party_uid", "GRC_400018")
GRC_DOCX_PARSE_ALREADY_CONFIRMED = ("此 Parse 已 confirm,請重新上傳", "GRC_409021")git commit -am "feat(ssp-update-diff): add 3 GRC error codes for confirm validation"Files:
api/oscal/routes/ssp/ssp_docx_import_route.py找到既有 SspDocxImportConfirmRoute 內的 use_kwargs schema(grep parties_decisions\|class.*ConfirmSchema):
# 在既有 ConfirmRequestSchema (or wherever payload schema is) 加:
parties_decisions = fields.List(
fields.Nested(PartiesDecisionItemSchema),
required=False,
load_default=list,
)
class PartiesDecisionItemSchema(Schema):
party_uid = fields.Str(required=True)
action = fields.Str(
required=True,
validate=validate.OneOf(["use_docx", "keep_current", "skip"]),
)git commit -am "feat(ssp-update-diff): extend confirm payload with parties_decisions"Files:
app/oscal/service/ssp_docx_import_app_service.pytests/test_ssp_docx_import_diff_flow.py# tests/test_ssp_docx_import_diff_flow.py
def test_confirm_with_mixed_party_decisions_only_writes_use_docx(client, headers, fixture_update_mode_parse):
"""Update mode: 3 parties (1 keep, 1 use_docx, 1 skip) → only use_docx written."""
parse_uid = fixture_update_mode_parse["parse_uid"]
payload = {
"decisions": [],
"parties_decisions": [
{"party_uid": fixture_update_mode_parse["party_uids"][0], "action": "keep_current"},
{"party_uid": fixture_update_mode_parse["party_uids"][1], "action": "use_docx"},
{"party_uid": fixture_update_mode_parse["party_uids"][2], "action": "skip"},
],
"content_overrides": {},
}
resp = client.post(
f"/ssp-docx-import/{parse_uid}/confirm",
json=payload,
headers=headers,
)
assert resp.status_code == 200, resp.json
assert resp.json["status"] is True
# 查 DB 驗證:只 use_docx 那筆寫入
# (fixture 提供 ssp_id,可查 responsible_party 表)
written_parties = query_responsible_parties(fixture_update_mode_parse["ssp_id"])
expected_email = fixture_update_mode_parse["use_docx_party_new_email"]
assert any(p.email == expected_email for p in written_parties)Fixture
fixture_update_mode_parse需新建(在conftest.py或本測試檔內),預先建好一個 SSP + 3 parties 並產一個 parse_job,回傳必要 ID。
pytest tests/test_ssp_docx_import_diff_flow.py -v# app/oscal/service/ssp_docx_import_app_service.py
@transaction
def confirm_import(self, parse_uid, decisions, parties_decisions, content_overrides, user_id):
job = self._parse_job_repo.get_by_uid(parse_uid)
if job is None:
raise NotFound(GrcErrorCode.GRC_DOCX_PARSE_JOB_NOT_FOUND)
if job.status == "confirmed":
raise ConflictError(GrcErrorCode.GRC_DOCX_PARSE_ALREADY_CONFIRMED)
parse_result = self._build_parse_result_view(job)
# Validate decisions
self._validate_decisions(parse_result, decisions, parties_decisions)
# 既有:filter controls + write
filtered_controls = self._filter_controls_by_decisions(parse_result, decisions)
strategy.write(filtered_controls, content_overrides, user_id)
# 新增:filter parties + write_parties
filtered_parties = self._filter_parties_by_decisions(parse_result, parties_decisions)
if filtered_parties:
strategy.write_parties(filtered_parties, source_uid=job.source_uid, user_id=user_id)
# Mark confirmed
job.status = "confirmed"
self._parse_job_repo.update(job)
def _filter_parties_by_decisions(self, parse_result, parties_decisions):
"""Reuse既有 `_dict_to_parsed_parties()` (plural, line 412) — 它把整個 parsed_result
內 parties[] 轉一批 ParsedParty。我們先 build 一個 sub-dict 只含 use_docx 項目,
再餵給既有 helper,避免新增單筆 helper / 重複既有邏輯。"""
decisions_map = {d["party_uid"]: d["action"] for d in (parties_decisions or [])}
selected_parsed_dicts = []
for p in parse_result.get("parties", []):
if decisions_map.get(p["party_uid"]) == "use_docx":
# parsed_values 是 docx 解析出來的 party dict(與既有 ParsedDocx.parties 元素同 shape)
selected_parsed_dicts.append(p["parsed_values"])
if not selected_parsed_dicts:
return []
# 用既有 helper 轉換(傳入 parsed_result 形狀的 dict)
return self._dict_to_parsed_parties({"parties": selected_parsed_dicts})
def _validate_decisions(self, parse_result, decisions, parties_decisions):
valid_control_ids = {c["control_id"] for c in parse_result.get("matched_controls", [])}
valid_party_uids = {p["party_uid"] for p in parse_result.get("parties", [])}
for d in decisions or []:
if d["control_id"] not in valid_control_ids:
raise BadRequestError(GrcErrorCode.GRC_DOCX_DECISIONS_INVALID_CONTROL_ID)
for pd in parties_decisions or []:
if pd["party_uid"] not in valid_party_uids:
raise BadRequestError(GrcErrorCode.GRC_DOCX_DECISIONS_INVALID_PARTY_UID)def test_invalid_party_uid_returns_400(client, headers, fixture_update_mode_parse):
payload = {
"decisions": [],
"parties_decisions": [{"party_uid": "non-existent-uid", "action": "use_docx"}],
"content_overrides": {},
}
resp = client.post(
f"/ssp-docx-import/{fixture_update_mode_parse['parse_uid']}/confirm",
json=payload, headers=headers,
)
assert resp.status_code == 400
assert "GRC_400018" in resp.json["msg"] or "party_uid" in resp.json["msg"]git commit -am "feat(ssp-update-diff): wire parties_decisions into confirm flow"Files:
tests/test_ssp_docx_import_create_no_diff.py確保 create mode 完全不被影響。
def test_create_mode_returns_has_diff_false(client, headers, fixture_create_mode_parse):
parse_uid = fixture_create_mode_parse["parse_uid"]
resp = client.get(f"/ssp-docx-import/{parse_uid}", headers=headers)
assert resp.status_code == 200
assert resp.json["has_diff"] is False
assert resp.json["diff_summary"]["controls"]["total_with_diff"] == 0
def test_create_mode_confirm_writes_all_controls(client, headers, fixture_create_mode_parse):
"""Regression: existing create flow unchanged."""
parse_uid = fixture_create_mode_parse["parse_uid"]
decisions = fixture_create_mode_parse["full_use_docx_decisions"]
resp = client.post(
f"/ssp-docx-import/{parse_uid}/confirm",
json={"decisions": decisions, "parties_decisions": [], "content_overrides": {}},
headers=headers,
)
assert resp.status_code == 200
# 驗證所有 controls 都寫入
written = query_ssp_control_implementations(fixture_create_mode_parse["ssp_id"])
assert len(written) == len(decisions)git commit -am "test(ssp-update-diff): add create-mode regression test"Files:
docs/changelog/2026-05-XX-feat-ssp-update-diff-be-phase2.md階段目標:寫好新元件 + store。Step 2 頁面可獨立顯示 + 互動。整合進 stepper 是 Phase 4。
Files:
compliance-manager-fe/package.jsoncompliance-manager-fe/src/utils/textDiff.jscompliance-manager-fe/src/utils/__tests__/textDiff.spec.jscd ~/Projects/Billows/Audit-Manager/compliance-manager-fe
npm install diff// src/utils/__tests__/textDiff.spec.js
import { describe, it, expect } from 'vitest';
import { computeWordDiff } from '../textDiff';
describe('computeWordDiff', () => {
it('returns added/removed tokens for changed text', () => {
const result = computeWordDiff('RBAC 機制', 'ABAC 機制');
expect(result).toEqual([
{ value: 'RBAC', removed: true, added: false },
{ value: 'ABAC', removed: false, added: true },
{ value: ' 機制', removed: false, added: false },
]);
});
it('returns single unchanged token when texts match', () => {
const result = computeWordDiff('same', 'same');
expect(result.every(t => !t.added && !t.removed)).toBe(true);
});
it('falls back to plain blocks when text exceeds 100KB', () => {
const huge = 'a'.repeat(110_000);
const result = computeWordDiff(huge, huge + ' new');
// Fallback shape: just two blocks (full old / full new), no per-word
expect(result.length).toBeLessThanOrEqual(2);
});
});npm run test src/utils/__tests__/textDiff.spec.js// src/utils/textDiff.js
import { diffWordsWithSpace } from 'diff';
const FALLBACK_THRESHOLD = 100_000;
export function computeWordDiff(oldText, newText) {
const a = oldText || '';
const b = newText || '';
if (a.length > FALLBACK_THRESHOLD || b.length > FALLBACK_THRESHOLD) {
console.warn('[textDiff] text exceeds 100KB, falling back to block-level diff');
return [
{ value: a, removed: true, added: false },
{ value: b, removed: false, added: true },
];
}
return diffWordsWithSpace(a, b).map(part => ({
value: part.value,
added: !!part.added,
removed: !!part.removed,
}));
}git add package.json package-lock.json src/utils/textDiff.js src/utils/__tests__/textDiff.spec.js
git commit -m "feat(ssp-update-diff): add textDiff util using jsdiff"Files:
compliance-manager-fe/src/stores/ssp-docx-import.jscompliance-manager-fe/src/stores/__tests__/ssp-docx-import.spec.js// src/stores/__tests__/ssp-docx-import.spec.js
import { setActivePinia, createPinia } from 'pinia';
import { useSspDocxImportStore } from '../ssp-docx-import';
beforeEach(() => setActivePinia(createPinia()));
describe('initDefaultDecisions', () => {
it('seeds decisions from parseResult.default_action', () => {
const store = useSspDocxImportStore();
store.parseResult = {
matched_controls: [
{ control_id: 'AC-2', default_action: 'keep_current', objectives: [
{ objective_key: '(a)', default_action: 'use_docx' }
]}
],
parties: [{ party_uid: 'p1', default_action: 'keep_current' }],
};
store.initDefaultDecisions();
expect(store.decisions.controls['AC-2'].action).toBe('keep_current');
expect(store.decisions.controls['AC-2'].objectives['(a)']).toBe('use_docx');
expect(store.decisions.parties['p1'].action).toBe('keep_current');
});
});
describe('bulkApply', () => {
it('sets all controls to given action', () => {
const store = useSspDocxImportStore();
store.parseResult = {
matched_controls: [
{ control_id: 'AC-1', default_action: 'keep_current', objectives: [] },
{ control_id: 'AC-2', default_action: 'keep_current', objectives: [] },
],
parties: [],
};
store.initDefaultDecisions();
store.bulkApply('controls', 'use_docx');
expect(store.decisions.controls['AC-1'].action).toBe('use_docx');
expect(store.decisions.controls['AC-2'].action).toBe('use_docx');
});
});
describe('buildConfirmPayload', () => {
it('shapes payload as decisions + parties_decisions', () => {
const store = useSspDocxImportStore();
store.parseResult = {
matched_controls: [{ control_id: 'AC-2', default_action: 'use_docx', objectives: [] }],
parties: [{ party_uid: 'p1', default_action: 'keep_current' }],
};
store.initDefaultDecisions();
const payload = store.buildConfirmPayload();
expect(payload).toEqual({
decisions: [{ control_id: 'AC-2', action: 'use_docx', objectives: [] }],
parties_decisions: [{ party_uid: 'p1', action: 'keep_current' }],
content_overrides: {},
});
});
});// src/stores/ssp-docx-import.js
import { defineStore } from 'pinia';
export const useSspDocxImportStore = defineStore('sspDocxImport', {
state: () => ({
parseUid: null,
mode: 'create',
parseResult: null,
decisions: {
controls: {}, // { [control_id]: { action, objectives: { [key]: action } } }
parties: {}, // { [party_uid]: { action } }
},
contentOverrides: {},
filters: {
controls: 'pending',
parties: 'pending',
},
}),
actions: {
setParseResult(result) {
this.parseResult = result;
this.parseUid = result.parse_uid;
this.mode = result.mode;
},
initDefaultDecisions() {
const decisions = { controls: {}, parties: {} };
for (const ctrl of (this.parseResult?.matched_controls || [])) {
const objMap = {};
for (const obj of (ctrl.objectives || [])) {
objMap[obj.objective_key] = obj.default_action || 'keep_current';
}
decisions.controls[ctrl.control_id] = {
action: ctrl.default_action || 'keep_current',
objectives: objMap,
};
}
for (const p of (this.parseResult?.parties || [])) {
decisions.parties[p.party_uid] = { action: p.default_action || 'keep_current' };
}
this.decisions = decisions;
},
setControlDecision(controlId, action) {
if (!this.decisions.controls[controlId]) {
this.decisions.controls[controlId] = { action, objectives: {} };
} else {
this.decisions.controls[controlId].action = action;
}
},
setObjectiveDecision(controlId, objectiveKey, action) {
if (!this.decisions.controls[controlId]) return;
this.decisions.controls[controlId].objectives[objectiveKey] = action;
},
setPartyDecision(partyUid, action) {
this.decisions.parties[partyUid] = { action };
},
bulkApply(section, action) {
if (section === 'controls') {
for (const id of Object.keys(this.decisions.controls)) {
this.decisions.controls[id].action = action;
}
} else if (section === 'parties') {
for (const uid of Object.keys(this.decisions.parties)) {
this.decisions.parties[uid] = { action };
}
}
},
setFilter(section, filter) {
this.filters[section] = filter;
},
buildConfirmPayload() {
const decisions = Object.entries(this.decisions.controls).map(([control_id, d]) => ({
control_id,
action: d.action,
objectives: Object.entries(d.objectives).map(([objective_key, action]) => ({
objective_key, action,
})),
}));
const parties_decisions = Object.entries(this.decisions.parties).map(([party_uid, d]) => ({
party_uid, action: d.action,
}));
return { decisions, parties_decisions, content_overrides: this.contentOverrides };
},
},
});git commit -am "feat(ssp-update-diff): add Pinia store ssp-docx-import"Files:
compliance-manager-fe/src/components/grc/ssp-docx-import-v2/diff/ControlDiffCard.vuecompliance-manager-fe/src/components/grc/ssp-docx-import-v2/diff/ObjectiveDiffRow.vueside-by-side 並排卡 + word-level highlight + 決策 pills。設計參考 brainstorm diff-styles-v2.html Option A。
<template>
<div class="control-diff-card" :class="{ 'is-selected': false }">
<header class="card-toolbar">
<div class="control-info">
<span class="control-id">{{ control.control_id }}</span>
<span class="control-name">{{ control.control_name }}</span>
<Tag :value="diffStatusLabel" :severity="diffStatusSeverity" />
</div>
<SelectButton
:modelValue="action"
:options="actionOptions"
optionLabel="label"
optionValue="value"
@update:modelValue="$emit('update:action', $event)"
/>
</header>
<div class="card-body">
<div class="sbs">
<div class="sbs-pane">
<div class="sbs-head old">現值(DB)</div>
<div class="sbs-body" v-html="renderDiff(currentText, parsedText, 'left')" />
</div>
<div class="sbs-pane">
<div class="sbs-head new">新值(docx)</div>
<div class="sbs-body" v-html="renderDiff(currentText, parsedText, 'right')" />
</div>
</div>
<div v-if="control.objectives?.length" class="objectives">
<ObjectiveDiffRow
v-for="obj in control.objectives"
:key="obj.objective_key"
:objective="obj"
:action="objectiveActions[obj.objective_key]"
@update:action="$emit('update:objective-action', { key: obj.objective_key, action: $event })"
/>
</div>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue';
import { computeWordDiff } from '@/utils/textDiff';
import ObjectiveDiffRow from './ObjectiveDiffRow.vue';
import Tag from 'primevue/tag';
import SelectButton from 'primevue/selectbutton';
const props = defineProps({
control: { type: Object, required: true },
action: { type: String, required: true },
objectiveActions: { type: Object, default: () => ({}) },
});
defineEmits(['update:action', 'update:objective-action']);
const currentText = computed(() => props.control.current_implementation_description || '');
const parsedText = computed(() => props.control.parsed_implementation_description || '');
const diffStatusLabel = computed(() => ({
changed: '已修改', added: '新增',
}[props.control.diff_status] || ''));
const diffStatusSeverity = computed(() => ({
changed: 'warning', added: 'success',
}[props.control.diff_status] || 'info'));
const actionOptions = computed(() => {
const opts = [
{ label: '採用新值', value: 'use_docx' },
{ label: '跳過', value: 'skip' },
];
if (props.control.diff_status !== 'added') {
opts.unshift({ label: '保留現值', value: 'keep_current' });
}
return opts;
});
function renderDiff(oldT, newT, side) {
const tokens = computeWordDiff(oldT, newT);
return tokens.map(t => {
if (side === 'left' && t.added) return ''; // hide additions on left
if (side === 'right' && t.removed) return ''; // hide removals on right
if (t.added) return `<span class="hl-add">${escapeHtml(t.value)}</span>`;
if (t.removed) return `<span class="hl-del">${escapeHtml(t.value)}</span>`;
return escapeHtml(t.value);
}).join('');
}
function escapeHtml(s) {
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
}
</script>
<style scoped>
.sbs { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.sbs-pane { border: 1px solid var(--surface-border); border-radius: 8px; overflow: hidden; }
.sbs-head { padding: 6px 12px; font-size: 11px; font-weight: 600; text-transform: uppercase; }
.sbs-head.old { background: #fef2f2; color: #991b1b; }
.sbs-head.new { background: #ecfdf5; color: #065f46; }
.sbs-body { padding: 12px; line-height: 1.65; }
:deep(.hl-del) { background: #fee2e2; color: #7f1d1d; padding: 0 2px; border-radius: 2px; }
:deep(.hl-add) { background: #d1fae5; color: #064e3b; padding: 0 2px; border-radius: 2px; }
</style>
git commit -am "feat(ssp-update-diff): add ControlDiffCard + ObjectiveDiffRow"Files:
compliance-manager-fe/src/components/grc/ssp-docx-import-v2/diff/PartyDiffCard.vuecompliance-manager-fe/src/components/grc/ssp-docx-import-v2/diff/PartiesDiffSection.vue依設計 §5.1,每人一張卡 + side-by-side(每欄 name/email/role/matched_user 一列)。設計參考 brainstorm parties-diff-layout.html Layout 1。
Files:
.../diff/ControlDiffSection.vue.../diff/DiffResolutionStep.vuegit commit -am "feat(ssp-update-diff): add DiffResolutionStep + ControlDiffSection"階段目標:把 Step 2 接進 SspDocxImportPage 的 stepper,既有 Step 3 元件接收 decisions 進入 readonly。
Files:
compliance-manager-fe/src/components/grc/ssp-docx-import-v2/SspDocxImportPage.vue注意:PrimeVue Stepper 在 mount 時讀取 child slots,
v-if直接套<StepperPanel>會在面板顯示/隱藏時打亂 active step index。改為永遠 render 4 panel,當!has_diff時:(a) Step 2 內 render 一個自動 advance 的 placeholder;或 (b) 在 router/onMount logic 自動跳過 Step 2。
<Stepper :activeStep="currentStep">
<StepperPanel header="上傳"><UploadStep /></StepperPanel>
<StepperPanel header="解決差異">
<template v-if="store.parseResult?.has_diff && store.mode === 'update'">
<DiffResolutionStep />
</template>
<template v-else>
<!-- create mode 或無差異 — 在 watch(parseResult) 內自動把 currentStep 從 1 跳到 2 -->
<div class="diff-skip-placeholder">無差異,跳到下一步…</div>
</template>
</StepperPanel>
<StepperPanel header="編輯內容"><ContentAdjustmentStep /></StepperPanel>
<StepperPanel header="確認"><ConfirmStep /></StepperPanel>
</Stepper>
並在 setup 內加:
watch(() => store.parseResult, (result) => {
if (result && (!result.has_diff || store.mode !== 'update') && currentStep.value === 1) {
currentStep.value = 2; // skip Step 2
}
});git commit -am "feat(ssp-update-diff): integrate DiffResolutionStep into stepper"Files:
.../ControlImplSection.vue<script setup>
import { useSspDocxImportStore } from '@/stores/ssp-docx-import';
const store = useSspDocxImportStore();
const visibleControls = computed(() =>
props.matchedControls.filter(c => {
const action = store.decisions.controls[c.control_id]?.action;
return action !== 'skip';
})
);
function isReadonly(controlId) {
return store.decisions.controls[controlId]?.action === 'keep_current';
}
</script>
git commit -am "feat(ssp-update-diff): ControlImplSection respects diff decisions"Files:
.../PartiesSection.vueFiles:
.../ImportOutline.vue階段目標:在獨立 test repo 寫 4 條 BDD scenario 確認端到端流程。
Files:
compliance-manager-test/requirements/ssp-update-diff.mdcompliance-manager-test/specs/ssp-update-diff.featureFiles:
compliance-manager-test/features/pages/SspDocxImportDiffPage.jsFiles:
compliance-manager-test/features/steps/ssp-update-diff.steps.js依 design §10:
ResponsiblePartyQueryEntity 欄位名稱確認 — Phase 1 Task 1.6 用 list_by(ResponsiblePartyQueryEntity(context_type=..., context_id=...)) 標準 pattern。實作前 grep 確認 entity class 實際支援 context_type / context_id 欄位;若名稱不同(例如 target_type / target_id)依實際定義調整_load_current_parties 回傳結構與既有 parsed_parties 結構對齊 — 確保 match_parties 比對 key 一致_dict_to_parsed_party() 是否可重用 — Phase 2 Task 2.3 filter 完寫入時,把 dict 轉 ParsedParty 用既有 helper(grep _dict_to_parsed_part)