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: 把 docx + Excel 兩條 SSP 匯入路徑共用的 ParsedParty → User / OrgUnit reconciliation 邏輯抽成 BaseReconciliationService + Person / Organization Reconciler;引入 fuzzy 中間狀態(matched / fuzzy / unmatched),補上 A2 confirm flow 對 reconcile 的呼叫,並落實 cucumber regression 守 docx path 行為 100% 不變。
Architecture: Strategy Pattern + Generic Base — BaseReconciliationService[TParsed, TEntity] 在 domain/oscal/service/reconciliation/ 提供 5 個 abstract hook(exact / normalized / fuzzy / apply_match / apply_unmatched)+ 共用 batch loop 與 candidate cache;既有 PartyReconciliationService 改門面 dispatch by party_type(公開 method 簽章 0 行變動),DI Factory 加兩個 reconciler;A2 _confirm_*_flow 補 reconcile() 呼叫;ParsedParty 加 match_method + match_confidence 兩個 optional 欄位(default UNMATCHED / 0.0)。
Tech Stack: Python 3.11 / SQLAlchemy / dependency-injector / pytest / openpyxl / cucumber.js(test repo)
Phase:A3(Track A 第三階段) 級別:重型(brainstorm → design → plan → 開工) 依賴:A2 BE 已 shipped(9 commits + 205 test 全綠)+ design-A3.md spec review approved 預計工時:BE ~2.75d + cucumber 0.5d ≈ 3.25 working day(不含 plan/design 寫作) 對應 design:
docs/features/FR-011.2-2605-ssp-import-export-phase2/design-A3.md(13 章) 執行階段切分(5 session):Session A (design / done) → Session B (plan / self) → Session C (T0+T1+T2) → Session D (T3+T4) → Session E (T5+T6+T7) — 對齊 design-A3 §12
| Session | Task | 主題 | repo | 預估 | 依賴 | Commit checkpoint |
|---|---|---|---|---|---|---|
| C | T0 | Implementer pre-flight verification(補 Session A 未驗的 3 項) | BE | 0.1d | — | 併入 T1 commit (feat(oscal): A3 T1 base + reconcilers skeleton) |
| C | T1 | 抽 BaseReconciliationService + MatchMethod enum + _normalizers + PersonReconciler skeleton + OrganizationReconciler skeleton + ParsedParty 加欄位 |
BE | 0.6d | T0 | feat(oscal): A3 T1 base + reconcilers skeleton |
| C | T2 | PartyReconciliationService 改門面 + DI wiring + 既有 8 unit test 全綠保住 |
BE | 0.4d | T1 | feat(oscal): A3 T2 facade + DI wiring |
| D | T3 | A2 _confirm_superset_flow + _confirm_update_flow 補 reconcile() 呼叫 |
BE | 0.25d | T2 | feat(oscal): A3 T3 wire A2 confirm flows to reconciliation |
| D | T4 | Fuzzy 演算法完整落地(PersonReconciler 三階段 + OrganizationReconciler 三階段 + candidate cache) | BE | 0.5d | T1 | feat(oscal): A3 T4 fuzzy match algorithm |
| E | T5 | Unit test 全套(13 normalizers + 7 base + 12 person + 10 org + 3 facade + 4 A2 integration + 4 ParsedParty + ~2 matrix gap = ~51 case)+ A2 串接 integration | BE | 0.75d | T2, T3, T4 | test(oscal): A3 T5 unit + integration tests |
| E | T6 | Cucumber regression 05-ssp-docx-import-party-match.feature 5 scenarios |
test | 0.25d | T5 | test(compliance-manager-test): A3 T6 cucumber party match regression |
| E | T7 | Changelog + tracker + design §11 reconciliation + final SUMMARY | BE | 0.4d | T6 | docs(ssp-import-export-phase2): A3 收尾 + SUMMARY |
並行最佳化:
- T1 是 T2/T4 共同基礎,必須先做。
- T2 結束(門面化 + DI)之後 T3、T4 可平行;T4 不依賴 T3(T3 是 caller-side 串接,T4 是演算法)。
- T5 必須等 T2 / T3 / T4 都完成(test 對齊最終實作)。
- Session 切分(不可平行):Session B → C → D → E 線性推進;每 Session 結束產 handoff 給下一 Session。
domain/oscal/service/reconciliation/ # 新 sub-folder
├── __init__.py # 對外 export PersonReconciler / OrganizationReconciler / MatchMethod
├── base.py # BaseReconciliationService[TParsed, TEntity] (~80 lines)
├── match_method.py # MatchMethod StrEnum (~10 lines)
├── _normalizers.py # _strip_plus_alias / _normalize_name / _strip_org_suffix (~40 lines)
├── person_reconciler.py # PersonReconciler (~70 lines)
└── organization_reconciler.py # OrganizationReconciler (~60 lines)
tests/test_a3_reconciliation_base.py # base class hook contract + cache 行為
tests/test_a3_reconciliation_person.py # PersonReconciler 三階段
tests/test_a3_reconciliation_organization.py # OrganizationReconciler 三階段
tests/test_a3_reconciliation_facade.py # 門面 dispatch 行為
tests/test_a3_reconciliation_normalizers.py # _normalizers 純函式邊界 case
tests/test_a3_reconciliation_a2_integration.py # A2 _confirm_*_flow 串接 reconcile
docs/changelog/YYYY-MM-DD-feat-party-matcher-shared.md # type: feat、modules: oscal
domain/oscal/service/party_reconciliation_service.py # 改門面(行數從 97 → ~30,公開 method 簽章不變)
domain/oscal/parser/ssp_intermediate.py # ParsedParty 加 match_method + match_confidence
app/oscal/service/ssp_excel_import_app_service.py # _confirm_superset_flow:251 + _confirm_update_flow:325 補 reconcile()
di_containers/oscal/oscal_containers.py # 加 person_reconciler / organization_reconciler Factory;門面改注入兩 reconciler
docs/features/FR-011.2-2605-ssp-import-export-phase2/README.md # tracker A3 row pending → shipped
docs/features/FR-011.2-2605-ssp-import-export-phase2/design-A3.md # §11 Implementation Reality / Reconciliation 段補實作偏差
compliance-manager-test/
└── features/regression/module-frame/05-ssp-docx-import-party-match.feature # 5 scenarios
+ steps/module-frame/ssp-docx-import-party-match.steps.js # 對應 step
+ pages/module-frame/ssp-docx-import-page.js # page object 補 selector(若必要)
Session A 已驗的 5 項(design-A3 §7)implementer 不重做:
- PartyReconciliationService caller 盤點 ✅
- A2 confirm flow 沒實際串 reconcile ✅
- jedi-auth Query Entity 既有欄位(無 email_domain / name prefix)✅
- cucumber 既有 docx import 無 matched/unmatched 守門 ✅
- test repo 結構 ✅
T0 補 implementer 開工瞬間才會碰到的 3 項(plan 寫好到 Session C 動工可能跨 days/weeks,期間 method 改名 / entity shape 變)。
PartyReconciliationService 既有實作行為未被人改動grep -n "def reconcile\|def __init__\|def _reconcile_person\|def _reconcile_organization" \
domain/oscal/service/party_reconciliation_service.py期待結果:仍是 design-A3 §1.1 / §3.2 描述的兩 method(person email exact / organization name exact)+ __init__(user_domain_service, org_unit_domain_service);row count 仍 ~97。若被 patch 過,要回頭看 design-A3 §11 是否需 reconcile。
_confirm_superset_flow + _confirm_update_flow 仍未呼叫 reconcilegrep -nE "reconcile|reconciliation" app/oscal/service/ssp_excel_import_app_service.py | head -20期待結果:只看到 __init__ 內 party_reconciliation_service 注入(line 50/59),_confirm_*_flow body 內沒有任何 .reconcile( 呼叫。若已被別人補上,T3 task 可標 N/A 直接合進 changelog。
ParsedParty dataclass 既有欄位grep -nA 20 "^class ParsedParty" domain/oscal/parser/ssp_intermediate.py期待結果:dataclass 含 name / party_type / role / email_address / matched_user_id / matched_org_unit_id / target_party_uid(design-A3 §4.2 假設)。T1 加新欄位前若已被別人補過 match_method / match_confidence,停下對齊命名後續執行。
何時 commit:T0 結果可併入 T1 commit;無 standalone commit 必要。
目標:把 design-A3 §3.2 / §3.3 / §4 落地成可被 import 的 5 個 Python module。 TDD 邊界:base class hook contract + reconciler skeleton 都先寫 test 後寫 code;fuzzy 演算法本身放 T4 落地(此 task 三 abstract method 只需 minimal skeleton 讓 import 不抛)。
MatchMethod enumFiles:
domain/oscal/service/reconciliation/__init__.pydomain/oscal/service/reconciliation/match_method.pytests/test_a3_reconciliation_normalizers.py(後續 T1.4 一起寫)mkdir -p domain/oscal/service/reconciliation
touch domain/oscal/service/reconciliation/__init__.py# domain/oscal/service/reconciliation/match_method.py
from enum import StrEnum
class MatchMethod(StrEnum):
EXACT = "exact"
NORMALIZED = "normalized"
FUZZY_EMAIL_DOMAIN = "fuzzy_email_domain"
FUZZY_NAME_PREFIX = "fuzzy_name_prefix"
UNMATCHED = "unmatched"poetry run python3 -c "from domain.oscal.service.reconciliation.match_method import MatchMethod; print(MatchMethod.EXACT)"Expected: MatchMethod.EXACT
_normalizers.py(純函式 + 內聯 unit test 先驅動)Files:
domain/oscal/service/reconciliation/_normalizers.pytests/test_a3_reconciliation_normalizers.py# tests/test_a3_reconciliation_normalizers.py
import pytest
from domain.oscal.service.reconciliation._normalizers import (
_strip_plus_alias, _normalize_name, _strip_org_suffix
)
class TestStripPlusAlias:
def test_simple_plus_alias(self):
assert _strip_plus_alias("alice+work@acme.com") == "alice@acme.com"
def test_no_plus(self):
assert _strip_plus_alias("alice@acme.com") == "alice@acme.com"
def test_no_at(self):
assert _strip_plus_alias("invalid") == "invalid"
def test_plus_in_domain_not_local(self):
# +在 domain side 不該 strip
assert _strip_plus_alias("alice@a+b.com") == "alice@a+b.com"
def test_multiple_at(self):
# split('@', 1) 取第一個 @
assert _strip_plus_alias("alice+work@b@c.com") == "alice@b@c.com"
class TestNormalizeName:
def test_full_width_space(self):
assert _normalize_name("陳 小明") == "陳 小明"
def test_collapse_spaces(self):
assert _normalize_name("陳 小明") == "陳 小明"
def test_strip_outer(self):
assert _normalize_name(" Alice ") == "Alice"
def test_none(self):
assert _normalize_name(None) == ""
def test_empty(self):
assert _normalize_name("") == ""
class TestStripOrgSuffix:
def test_股份有限公司(self):
assert _strip_org_suffix("ACME 股份有限公司") == "ACME"
def test_有限公司_not_eaten_first(self):
# 順序檢查:「股份有限公司」優先於「有限公司」
assert _strip_org_suffix("台積電股份有限公司") == "台積電"
def test_inc(self):
assert _strip_org_suffix("Foo Inc.") == "Foo"
def test_no_suffix(self):
assert _strip_org_suffix("ACME") == "ACME"
def test_empty(self):
assert _strip_org_suffix("") == ""pytest tests/test_a3_reconciliation_normalizers.py -vExpected: ModuleNotFoundError: No module named 'domain.oscal.service.reconciliation._normalizers'
# domain/oscal/service/reconciliation/_normalizers.py
from typing import Optional
def _strip_plus_alias(email: str) -> str:
if '@' not in email:
return email
local, domain = email.split('@', 1)
if '+' not in local:
return email
return f"{local.split('+', 1)[0]}@{domain}"
def _normalize_name(name: Optional[str]) -> str:
if not name:
return ""
s = name.replace(' ', ' ')
s = ' '.join(s.split())
return s.strip()
ORG_SUFFIXES = [
"股份有限公司",
"有限公司",
"公司",
"Co., Ltd.", "Co., Ltd",
"Corp.", "Corp",
"L.L.C.", "LLC",
"Ltd.", "Ltd",
"Inc.", "Inc",
]
def _strip_org_suffix(name: str) -> str:
if not name:
return name
for suffix in ORG_SUFFIXES:
if name.endswith(suffix):
return name[:-len(suffix)].strip()
return namepytest tests/test_a3_reconciliation_normalizers.py -vExpected: 13 passed
BaseReconciliationService 抽象基類Files:
domain/oscal/service/reconciliation/base.pytests/test_a3_reconciliation_base.py# tests/test_a3_reconciliation_base.py
import pytest
from unittest.mock import MagicMock
from domain.oscal.service.reconciliation.base import BaseReconciliationService
from domain.oscal.service.reconciliation.match_method import MatchMethod
class _DummyReconciler(BaseReconciliationService):
"""測試用 reconciler — 5 hook 都 MagicMock-style 受控"""
def __init__(self):
super().__init__()
self.exact_returns = None
self.normalized_returns = None
self.fuzzy_returns = (None, MatchMethod.UNMATCHED)
self.applied_matches = []
self.applied_unmatched = []
def _try_exact_match(self, parsed, tenant_id):
return self.exact_returns
def _try_normalized_match(self, parsed, tenant_id):
return self.normalized_returns
def _try_fuzzy_match(self, parsed, tenant_id):
return self.fuzzy_returns
def _apply_match(self, parsed, entity, method, confidence):
self.applied_matches.append((parsed, entity, method, confidence))
def _apply_unmatched(self, parsed):
self.applied_unmatched.append(parsed)
def test_exact_hit_short_circuits():
r = _DummyReconciler()
entity = MagicMock(name="user_42")
r.exact_returns = entity
parsed = MagicMock(name="parsed_alice")
r.reconcile([parsed], tenant_id=102)
assert r.applied_matches == [(parsed, entity, MatchMethod.EXACT, 1.0)]
assert r.applied_unmatched == []
def test_normalized_hit_when_exact_miss():
r = _DummyReconciler()
entity = MagicMock()
r.normalized_returns = entity
parsed = MagicMock()
r.reconcile([parsed], tenant_id=102)
assert r.applied_matches == [(parsed, entity, MatchMethod.NORMALIZED, 1.0)]
def test_fuzzy_hit_uses_returned_method_and_low_confidence():
r = _DummyReconciler()
entity = MagicMock()
r.fuzzy_returns = (entity, MatchMethod.FUZZY_EMAIL_DOMAIN)
parsed = MagicMock()
r.reconcile([parsed], tenant_id=102)
assert r.applied_matches == [(parsed, entity, MatchMethod.FUZZY_EMAIL_DOMAIN, 0.7)]
def test_unmatched_when_all_stages_miss():
r = _DummyReconciler()
parsed = MagicMock()
r.reconcile([parsed], tenant_id=102)
assert r.applied_unmatched == [parsed]
assert r.applied_matches == []
def test_batch_iterates_all_records():
r = _DummyReconciler()
parsed_a, parsed_b = MagicMock(), MagicMock()
result = r.reconcile([parsed_a, parsed_b], tenant_id=102)
assert result == [parsed_a, parsed_b]
assert len(r.applied_unmatched) == 2
def test_cache_lazy_loads_once():
r = _DummyReconciler()
loader = MagicMock(return_value=[1, 2, 3])
assert r._get_or_load_candidates(loader) == [1, 2, 3]
assert r._get_or_load_candidates(loader) == [1, 2, 3]
assert loader.call_count == 1
def test_cache_exception_returns_empty_list_no_persist():
r = _DummyReconciler()
loader = MagicMock(side_effect=RuntimeError("DB down"))
result = r._get_or_load_candidates(loader)
assert result == []
assert r._candidate_cache is None # 不持久化 exception 結果
# 下次 call 仍 retry
loader_ok = MagicMock(return_value=[42])
assert r._get_or_load_candidates(loader_ok) == [42]
assert loader_ok.call_count == 1pytest tests/test_a3_reconciliation_base.py -vExpected: ModuleNotFoundError
# domain/oscal/service/reconciliation/base.py
from abc import ABC, abstractmethod
from typing import Generic, List, Optional, Tuple, TypeVar
from domain.oscal.service.reconciliation.match_method import MatchMethod
TParsed = TypeVar("TParsed")
TEntity = TypeVar("TEntity")
class BaseReconciliationService(ABC, Generic[TParsed, TEntity]):
"""Domain-level base class for parsed-record ↔ system-entity reconciliation.
Each subclass owns one domain pair (e.g. ParsedParty(person) ↔ User);
subclass implements 5 hooks. The base class owns the shared batch loop,
candidate cache, and three-state fallback (exact / normalized / fuzzy).
Caller wraps the call in @transaction; this class never opens a session.
"""
def __init__(self):
self._candidate_cache: Optional[List[TEntity]] = None
def reconcile(
self,
parsed_list: List[TParsed],
tenant_id: Optional[int] = None,
) -> List[TParsed]:
for parsed in parsed_list:
self._reconcile_one(parsed, tenant_id)
return parsed_list
def _reconcile_one(self, parsed: TParsed, tenant_id: Optional[int]) -> None:
candidate = self._try_exact_match(parsed, tenant_id)
if candidate is not None:
self._apply_match(parsed, candidate, MatchMethod.EXACT, 1.0)
return
candidate = self._try_normalized_match(parsed, tenant_id)
if candidate is not None:
self._apply_match(parsed, candidate, MatchMethod.NORMALIZED, 1.0)
return
candidate, fuzzy_method = self._try_fuzzy_match(parsed, tenant_id)
if candidate is not None:
self._apply_match(parsed, candidate, fuzzy_method, 0.7)
return
self._apply_unmatched(parsed)
@abstractmethod
def _try_exact_match(
self, parsed: TParsed, tenant_id: Optional[int]
) -> Optional[TEntity]: ...
@abstractmethod
def _try_normalized_match(
self, parsed: TParsed, tenant_id: Optional[int]
) -> Optional[TEntity]: ...
@abstractmethod
def _try_fuzzy_match(
self, parsed: TParsed, tenant_id: Optional[int]
) -> Tuple[Optional[TEntity], MatchMethod]: ...
@abstractmethod
def _apply_match(
self,
parsed: TParsed,
entity: TEntity,
method: MatchMethod,
confidence: float,
) -> None: ...
@abstractmethod
def _apply_unmatched(self, parsed: TParsed) -> None: ...
def _get_or_load_candidates(self, loader) -> List[TEntity]:
if self._candidate_cache is not None:
return self._candidate_cache
try:
result = loader() or []
except Exception:
return []
self._candidate_cache = result
return resultpytest tests/test_a3_reconciliation_base.py -vExpected: 7 passed
match_method + match_confidence 欄位Files:
domain/oscal/parser/ssp_intermediate.pytests/test_party_reconciliation_service.py + 任何用到 ParsedParty(...) 的 fixture 必須仍綠grep -rn "ParsedParty(" tests/ app/ domain/ | head -20# domain/oscal/parser/ssp_intermediate.py
# 既有 import
from domain.oscal.service.reconciliation.match_method import MatchMethod
@dataclass
class ParsedParty:
name: str
party_type: Literal["person", "organization"]
role: Optional[str]
email_address: Optional[str]
# ... 其他既有欄位
matched_user_id: Optional[int] = None
matched_org_unit_id: Optional[int] = None
target_party_uid: Optional[str] = None
# A3 新增
match_method: MatchMethod = MatchMethod.UNMATCHED
match_confidence: float = 0.0pytest tests/test_party_reconciliation_service.py tests/test_ssp_docx_*.py tests/test_ssp_excel_*.py -v 2>&1 | tail -30Expected: 既有測試(A1 106 + A2 99 + party 8 = 213)全綠
Files:
domain/oscal/service/reconciliation/person_reconciler.pytests/test_a3_reconciliation_person.pyT1.5 只做 skeleton + exact match stage;normalized + fuzzy 階段在 T4 完整落地。先 skeleton 是為了讓 T2 門面化能 import / DI / 跑通既有 8 exact test。
# tests/test_a3_reconciliation_person.py
import pytest
from unittest.mock import MagicMock
from domain.oscal.parser.ssp_intermediate import ParsedParty
from domain.oscal.service.reconciliation.person_reconciler import PersonReconciler
from domain.oscal.service.reconciliation.match_method import MatchMethod
@pytest.fixture
def user_domain():
m = MagicMock()
m.get_users.return_value = []
return m
@pytest.fixture
def reconciler(user_domain):
return PersonReconciler(user_domain_service=user_domain)
def _parsed(name="Alice", email="alice@acme.com"):
return ParsedParty(
name=name, party_type="person", role=None, email_address=email,
)
class TestExactStage:
def test_email_lowercase_trim_exact_hit(self, reconciler, user_domain):
user = MagicMock(id=42, is_active=True, email="alice@acme.com")
user_domain.get_users.return_value = [user]
p = _parsed(email=" Alice@Acme.com ")
reconciler.reconcile([p], tenant_id=102)
assert p.matched_user_id == 42
assert p.match_method == MatchMethod.EXACT
assert p.match_confidence == 1.0
def test_inactive_user_skipped(self, reconciler, user_domain):
user = MagicMock(id=42, is_active=False, email="alice@acme.com")
user_domain.get_users.return_value = [user]
p = _parsed()
reconciler.reconcile([p], tenant_id=102)
assert p.matched_user_id is None
assert p.match_method == MatchMethod.UNMATCHED
assert p.match_confidence == 0.0
def test_empty_email_skipped(self, reconciler):
p = _parsed(email=None)
reconciler.reconcile([p], tenant_id=102)
assert p.match_method == MatchMethod.UNMATCHED
def test_exception_from_domain_swallowed_to_unmatched(self, reconciler, user_domain):
user_domain.get_users.side_effect = RuntimeError("DB down")
p = _parsed()
reconciler.reconcile([p], tenant_id=102)
assert p.match_method == MatchMethod.UNMATCHED# domain/oscal/service/reconciliation/person_reconciler.py
from typing import List, Optional, Tuple
from domain.oscal.parser.ssp_intermediate import ParsedParty
from domain.oscal.service.reconciliation.base import BaseReconciliationService
from domain.oscal.service.reconciliation.match_method import MatchMethod
class PersonReconciler(BaseReconciliationService[ParsedParty, object]):
def __init__(self, user_domain_service):
super().__init__()
self._user = user_domain_service
def _try_exact_match(self, parsed, tenant_id):
email = (parsed.email_address or "").strip().lower()
if not email:
return None
try:
# 對齊 既有 PartyReconciliationService Query Entity 簽章
from jedi_auth.domain.entity.user_query_entity import UserQueryEntity
users = self._user.get_users(UserQueryEntity(email=email))
except Exception:
return None
for u in users or []:
if getattr(u, "is_active", True):
return u
return None
def _try_normalized_match(self, parsed, tenant_id):
return None # T4 落地
def _try_fuzzy_match(self, parsed, tenant_id):
return (None, MatchMethod.UNMATCHED) # T4 落地
def _apply_match(self, parsed, entity, method, confidence):
parsed.matched_user_id = entity.id
parsed.match_method = method
parsed.match_confidence = confidence
def _apply_unmatched(self, parsed):
parsed.match_method = MatchMethod.UNMATCHED
parsed.match_confidence = 0.0
# matched_user_id 維持 NoneFiles:
domain/oscal/service/reconciliation/organization_reconciler.pytests/test_a3_reconciliation_organization.py# tests/test_a3_reconciliation_organization.py
import pytest
from unittest.mock import MagicMock
from domain.oscal.parser.ssp_intermediate import ParsedParty
from domain.oscal.service.reconciliation.organization_reconciler import OrganizationReconciler
from domain.oscal.service.reconciliation.match_method import MatchMethod
@pytest.fixture
def org_unit_domain():
m = MagicMock()
m.get_org_units.return_value = []
return m
@pytest.fixture
def reconciler(org_unit_domain):
return OrganizationReconciler(org_unit_domain_service=org_unit_domain)
def _parsed(name="ACME"):
return ParsedParty(
name=name, party_type="organization", role=None, email_address=None,
)
class TestExactStage:
def test_name_trim_exact_hit(self, reconciler, org_unit_domain):
org = MagicMock(id=7)
org_unit_domain.get_org_units.return_value = [org]
p = _parsed(name=" ACME ")
reconciler.reconcile([p], tenant_id=102)
assert p.matched_org_unit_id == 7
assert p.match_method == MatchMethod.EXACT
assert p.match_confidence == 1.0
def test_empty_name(self, reconciler):
p = _parsed(name="")
reconciler.reconcile([p], tenant_id=102)
assert p.match_method == MatchMethod.UNMATCHED
def test_exception_swallowed(self, reconciler, org_unit_domain):
org_unit_domain.get_org_units.side_effect = RuntimeError("DB")
p = _parsed()
reconciler.reconcile([p], tenant_id=102)
assert p.match_method == MatchMethod.UNMATCHED
def test_no_hits(self, reconciler):
p = _parsed()
reconciler.reconcile([p], tenant_id=102)
assert p.match_method == MatchMethod.UNMATCHED
assert p.matched_org_unit_id is None
def test_tenant_id_passed_when_provided(self, reconciler, org_unit_domain):
p = _parsed()
reconciler.reconcile([p], tenant_id=102)
# 確認 query 帶 tenant_id(沿用 既有 PartyReconciliationService 行為)
call_args = org_unit_domain.get_org_units.call_args
assert call_args is not None
# 看 Query Entity 內 tenant_id 是否被設(依 既有 Query Entity 介面)# domain/oscal/service/reconciliation/organization_reconciler.py
from typing import Optional, Tuple
from domain.oscal.parser.ssp_intermediate import ParsedParty
from domain.oscal.service.reconciliation.base import BaseReconciliationService
from domain.oscal.service.reconciliation.match_method import MatchMethod
class OrganizationReconciler(BaseReconciliationService[ParsedParty, object]):
def __init__(self, org_unit_domain_service):
super().__init__()
self._org = org_unit_domain_service
def _try_exact_match(self, parsed, tenant_id):
name = (parsed.name or "").strip()
if not name:
return None
try:
from jedi_auth.domain.entity.org_unit_query_entity import OrgUnitQueryEntity
q = OrgUnitQueryEntity(name=name)
if tenant_id is not None:
q.tenant_id = tenant_id
orgs = self._org.get_org_units(q)
except Exception:
return None
return orgs[0] if orgs else None
def _try_normalized_match(self, parsed, tenant_id):
return None # T4 落地
def _try_fuzzy_match(self, parsed, tenant_id):
return (None, MatchMethod.UNMATCHED) # T4 落地
def _apply_match(self, parsed, entity, method, confidence):
parsed.matched_org_unit_id = entity.id
parsed.match_method = method
parsed.match_confidence = confidence
def _apply_unmatched(self, parsed):
parsed.match_method = MatchMethod.UNMATCHED
parsed.match_confidence = 0.0__init__.py export# domain/oscal/service/reconciliation/__init__.py
from domain.oscal.service.reconciliation.base import BaseReconciliationService
from domain.oscal.service.reconciliation.match_method import MatchMethod
from domain.oscal.service.reconciliation.person_reconciler import PersonReconciler
from domain.oscal.service.reconciliation.organization_reconciler import OrganizationReconciler
__all__ = [
"BaseReconciliationService",
"MatchMethod",
"PersonReconciler",
"OrganizationReconciler",
]git status --short預期看到:
git add domain/oscal/service/reconciliation/__init__.py \
domain/oscal/service/reconciliation/base.py \
domain/oscal/service/reconciliation/match_method.py \
domain/oscal/service/reconciliation/_normalizers.py \
domain/oscal/service/reconciliation/person_reconciler.py \
domain/oscal/service/reconciliation/organization_reconciler.py \
domain/oscal/parser/ssp_intermediate.py \
tests/test_a3_reconciliation_normalizers.py \
tests/test_a3_reconciliation_base.py \
tests/test_a3_reconciliation_person.py \
tests/test_a3_reconciliation_organization.pygit commit -m "$(cat <<'EOF'
feat(oscal): A3 T1 base + reconcilers skeleton
抽 BaseReconciliationService[TParsed, TEntity] 抽象基類 + Person /
Organization Reconciler skeleton(exact stage 完整、normalized/fuzzy 留 T4);
ParsedParty 加 match_method + match_confidence optional 欄位;
13 + 7 + 4 + 5 = 29 個 unit test 全綠。
對齊 design-A3.md §3.2 / §4 / §5.3。
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"Files:
domain/oscal/service/party_reconciliation_service.py(97 → ~30 行)# domain/oscal/service/party_reconciliation_service.py
from typing import List, Optional
from domain.oscal.parser.ssp_intermediate import ParsedParty
class PartyReconciliationService:
"""Facade dispatching ParsedParty list by party_type.
Mixed-list dispatch is domain knowledge — callers (docx / A2 confirm flows)
should not need to know how to split persons / organizations.
"""
def __init__(self, person_reconciler, organization_reconciler):
self._person = person_reconciler
self._org = organization_reconciler
def reconcile(
self,
parties: List[ParsedParty],
tenant_id: Optional[int] = None,
) -> List[ParsedParty]:
persons = [p for p in parties if p.party_type == "person"]
orgs = [p for p in parties if p.party_type == "organization"]
self._person.reconcile(persons, tenant_id)
self._org.reconcile(orgs, tenant_id)
return partiespytest tests/test_party_reconciliation_service.py -vExpected:8 個 case 全綠(0 行測試 code 改動 — 因為公開 method 簽章 100% 不變、行為 100% 不變 via PersonReconciler/OrganizationReconciler 的 exact stage)。
若有 fail:要先檢查既有 test 是否用了 service._user_domain_service 直接存取或 _reconcile_person 私有 method(如果是,更新 test 改用 mock injection)。
Files:
di_containers/oscal/oscal_containers.pygrep -nA 5 "party_reconciliation_service\|person_reconciler\|organization_reconciler" \
di_containers/oscal/oscal_containers.py | head -30grep -nE "^from|^import" di_containers/oscal/oscal_containers.py | grep -i auth期待結果:看到 from di_containers.auth.auth_containers import AuthContainer(或 sub-container reference 樣式)+ 既有 auth_container.user_domain_service / auth_container.org_unit_domain_service 6 處引用都正常解析。若沒看到 import,先補上再進 Step 2。
# di_containers/oscal/oscal_containers.py
# 在 既有 party_reconciliation_service provider 前加:
from domain.oscal.service.reconciliation.person_reconciler import PersonReconciler
from domain.oscal.service.reconciliation.organization_reconciler import OrganizationReconciler
person_reconciler = providers.Factory(
PersonReconciler,
user_domain_service=auth_container.user_domain_service,
)
organization_reconciler = providers.Factory(
OrganizationReconciler,
org_unit_domain_service=auth_container.org_unit_domain_service,
)
# 改既有 line 488 provider:
party_reconciliation_service = providers.Factory(
PartyReconciliationService,
person_reconciler=person_reconciler,
organization_reconciler=organization_reconciler,
)lsof -ti:8000 | xargs kill -9 2>/dev/null
set -a; source .env; set +a
nohup poetry run python main_app.py > /tmp/a3-be-boot.log 2>&1 &
sleep 5
tail -50 /tmp/a3-be-boot.log
grep -E "ERROR|Traceback|Failed" /tmp/a3-be-boot.log | head -20Expected:BE 起動成功,無 DI wire error。 若撞 jedi-issue GitLab env 問題(A0.1 follow-up #7):partial smoke 接受 — 確認 DI container build 不 wire error 即可。
Files:
tests/test_a3_reconciliation_facade.py# tests/test_a3_reconciliation_facade.py
import pytest
from unittest.mock import MagicMock
from domain.oscal.parser.ssp_intermediate import ParsedParty
from domain.oscal.service.party_reconciliation_service import PartyReconciliationService
@pytest.fixture
def person_rec():
m = MagicMock()
m.reconcile.side_effect = lambda lst, tenant_id=None: lst
return m
@pytest.fixture
def org_rec():
m = MagicMock()
m.reconcile.side_effect = lambda lst, tenant_id=None: lst
return m
@pytest.fixture
def facade(person_rec, org_rec):
return PartyReconciliationService(
person_reconciler=person_rec,
organization_reconciler=org_rec,
)
def test_dispatch_by_party_type(facade, person_rec, org_rec):
persons = [ParsedParty(name="A", party_type="person", role=None, email_address="a@x.com")]
orgs = [ParsedParty(name="X", party_type="organization", role=None, email_address=None)]
facade.reconcile(persons + orgs, tenant_id=102)
person_rec.reconcile.assert_called_once_with(persons, 102)
org_rec.reconcile.assert_called_once_with(orgs, 102)
def test_empty_list(facade, person_rec, org_rec):
result = facade.reconcile([], tenant_id=102)
assert result == []
person_rec.reconcile.assert_called_once_with([], 102)
org_rec.reconcile.assert_called_once_with([], 102)
def test_returns_same_list_reference(facade):
parties = [ParsedParty(name="A", party_type="person", role=None, email_address="a@x.com")]
result = facade.reconcile(parties, tenant_id=102)
assert result is partiesgit add domain/oscal/service/party_reconciliation_service.py \
di_containers/oscal/oscal_containers.py \
tests/test_a3_reconciliation_facade.py
git commit -m "$(cat <<'EOF'
feat(oscal): A3 T2 facade + DI wiring
PartyReconciliationService 改門面 dispatch by party_type;新增 DI Factory
person_reconciler + organization_reconciler;門面改注入兩 reconciler。
既有 8 unit test 0 行改動全綠;門面新增 3 個 dispatch test 全綠。
BE 起動 smoke 通過 — DI container build 無 wire error。
對齊 design-A3.md §3.3 / §6.4。
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"目標:design-A3 §6.1 / §6.2 描述的 A2 path 串接 —
_confirm_superset_flow+_confirm_update_flow在write_parties前呼叫 reconcile。 A3 期間 fuzzy 行為:A5 預覽 UI 還沒做 — A3 期間 fuzzy 自動寫入(行為等同 matched);A5 上線後改為 user 拍板(follow-up F2 已標 design §11)。
grep -n "_confirm_superset_flow\|_confirm_update_flow\|write_parties" \
app/oscal/service/ssp_excel_import_app_service.py對齊 plan-A2 / design-A2 §6 描述的 line 251 / 325 / write_parties context_type='module_frame'。
_confirm_superset_flowFiles:
app/oscal/service/ssp_excel_import_app_service.py(line ~251)def _confirm_superset_flow(self, job, parsed_result, decisions, overrides, user_context):
# ... 既有 step 1~3(取 framework_version + catalog + 建 profile + 建 MF)
# A3 新增:reconcile parties → 標 matched_user_id / matched_org_unit_id / match_method
parties_combined = list(parsed_result.parties_org) + list(parsed_result.parties_person)
self._reconciliation.reconcile(parties_combined, tenant_id=user_context.tenant_id)
# 既有 _write_parties 呼叫不動:
self._write_parties(parties_combined, context_type='module_frame', context_id=new_mf.id, user_context=user_context)
# ... 既有 _write_items / _write_control_defaults / _write_ref_docs注意:實際 code 結構若已是「先呼叫 self._write_parties 把 dict list 餵進去」而不是接 ParsedParty dataclass list,要 verify 兩邊的 type — 如果
_write_parties接 dict,reconcile() 必須在 dict 化之前對 ParsedParty list 跑。看 T0.2 verify 結果決定確切插入點。
_confirm_update_flowFiles:
app/oscal/service/ssp_excel_import_app_service.py(line ~325)def _confirm_update_flow(self, job, parsed_result, decisions, overrides, user_context):
# ... 既有取 MF
parties_combined = list(parsed_result.parties_org) + list(parsed_result.parties_person)
self._reconciliation.reconcile(parties_combined, tenant_id=user_context.tenant_id)
self._write_parties(parties_combined, context_type='module_frame', context_id=existing_mf.id, user_context=user_context)
# ... 既有 upsertFiles:
tests/test_a3_reconciliation_a2_integration.py# tests/test_a3_reconciliation_a2_integration.py
import pytest
from unittest.mock import MagicMock, patch
from contextlib import contextmanager
from app.oscal.service.ssp_excel_import_app_service import SspExcelImportAppService
@pytest.fixture
def patch_session_scope():
"""對齊 conftest.py 已有的 helper;若不存在則 inline 定義"""
@contextmanager
def _noop():
yield
with patch("jedi_common.session.database.db.session_scope", _noop):
yield
@pytest.fixture
def mock_reconciliation():
m = MagicMock()
m.reconcile.side_effect = lambda lst, tenant_id=None: lst
return m
@pytest.fixture
def mock_user_context():
ctx = MagicMock()
ctx.tenant_id = 102
ctx.user_id = 7
return ctx
@pytest.fixture
def app_service(mock_reconciliation):
"""Mirror A2 既有 fixture:所有 domain service 都 mock,只關心 reconciliation 被呼叫"""
return SspExcelImportAppService(
parse_job_domain_service=MagicMock(),
parser=MagicMock(),
file_upload_service=MagicMock(),
module_frame_domain_service=MagicMock(),
profile_service=MagicMock(),
module_frame_service=MagicMock(),
party_domain_service=MagicMock(),
responsible_party_domain_service=MagicMock(),
oscal_framework_version_domain_service=MagicMock(),
catalog_control_domain_service=MagicMock(),
party_reconciliation_service=mock_reconciliation,
# ... 其他 dep 依 A2 既有 signature 補
)def test_superset_flow_calls_reconcile_once(app_service, mock_reconciliation, mock_user_context, patch_session_scope):
"""_confirm_superset_flow 必須在 write_parties 前 call reconcile(...) 一次"""
job = MagicMock(uid="parse-uid", source_type="framework_version")
parsed = MagicMock(parties_org=[MagicMock(party_type="organization")],
parties_person=[MagicMock(party_type="person")])
app_service._confirm_superset_flow(job, parsed, decisions=[], overrides={}, user_context=mock_user_context)
assert mock_reconciliation.reconcile.call_count == 1
def test_update_flow_calls_reconcile_once(app_service, mock_reconciliation, mock_user_context, patch_session_scope):
"""同上 — update flow"""
job = MagicMock(uid="parse-uid", source_type="module_frame")
parsed = MagicMock(parties_org=[], parties_person=[MagicMock(party_type="person")])
app_service._confirm_update_flow(job, parsed, decisions=[], overrides={}, user_context=mock_user_context)
assert mock_reconciliation.reconcile.call_count == 1
def test_reconcile_gets_mixed_list(app_service, mock_reconciliation, mock_user_context, patch_session_scope):
"""reconcile 拿到 (parties_org + parties_person) 合併 list"""
org_party = MagicMock(party_type="organization", name="ACME")
person_party = MagicMock(party_type="person", email_address="a@x.com")
job = MagicMock(source_type="framework_version")
parsed = MagicMock(parties_org=[org_party], parties_person=[person_party])
app_service._confirm_superset_flow(job, parsed, decisions=[], overrides={}, user_context=mock_user_context)
args, _ = mock_reconciliation.reconcile.call_args
assert org_party in args[0]
assert person_party in args[0]
def test_tenant_id_propagated_from_user_context(app_service, mock_reconciliation, mock_user_context, patch_session_scope):
"""tenant_id 從 user_context 傳到 reconcile"""
job = MagicMock(source_type="framework_version")
parsed = MagicMock(parties_org=[], parties_person=[])
app_service._confirm_superset_flow(job, parsed, decisions=[], overrides={}, user_context=mock_user_context)
_, kwargs = mock_reconciliation.reconcile.call_args
assert kwargs.get("tenant_id") == 102若 既有 conftest.py 已有
patch_session_scopehelper:直接 import 用,刪除上方 fixture 重複定義。grepgrep -n "patch_session_scope\|def session_scope" tests/conftest.py確認。
git add app/oscal/service/ssp_excel_import_app_service.py \
tests/test_a3_reconciliation_a2_integration.py
git commit -m "$(cat <<'EOF'
feat(oscal): A3 T3 wire A2 confirm flows to reconciliation
_confirm_superset_flow + _confirm_update_flow 在 write_parties 前補
reconcile() 呼叫;A2 path 終於跟 docx path 行為一致。
A3 期間 fuzzy 在 A2 path **自動寫入**(行為等同 matched);A5 phase
補 user 拍板路徑(design-A3.md §11 follow-up F2)。
對齊 design-A3.md §6.1 / §6.2。
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"目標:把 T1.5 / T1.6 留下的
_try_normalized_match+_try_fuzzy_matchstub 用 design-A3 §5.1 / §5.2 描述的演算法填滿。 TDD:每個 stage 先寫 test 確認 stub 路徑會 fail(exact 沒中時走進來),再實作。
Files:
domain/oscal/service/reconciliation/person_reconciler.pytests/test_a3_reconciliation_person.py(補 normalized + fuzzy 兩段 ~8 case)class TestNormalizedStage:
def test_plus_alias_normalize_hit(self, reconciler, user_domain):
# input email: alice+work@acme.com
# system: alice@acme.com
def get_users(q):
if getattr(q, "email", None) == "alice+work@acme.com":
return []
if getattr(q, "email", None) == "alice@acme.com":
return [MagicMock(id=42, is_active=True, email="alice@acme.com")]
return []
user_domain.get_users.side_effect = get_users
p = _parsed(email="alice+work@acme.com")
reconciler.reconcile([p], tenant_id=102)
assert p.match_method == MatchMethod.NORMALIZED
assert p.matched_user_id == 42
def test_no_plus_skips_normalized(self, reconciler, user_domain):
# normalized = original → 跳過 normalized stage 直入 fuzzy
...class TestFuzzyStage:
def test_fuzzy_email_domain_with_nickname_match(self, reconciler, user_domain):
# input: name='陳小明', email='unknown@acme.com'(exact / normalized 都不中)
# candidate: User(email='alice@acme.com', nickname='陳小明', login_name='alice')
def get_users(q):
if getattr(q, "email", None):
return []
# 第三 stage 拉全 tenant list
return [
MagicMock(id=99, is_active=True, email="alice@acme.com",
nickname="陳小明", login_name="alice"),
]
user_domain.get_users.side_effect = get_users
p = _parsed(name="陳小明", email="unknown@acme.com")
reconciler.reconcile([p], tenant_id=102)
assert p.match_method == MatchMethod.FUZZY_EMAIL_DOMAIN
assert p.match_confidence == 0.7
assert p.matched_user_id == 99
def test_fuzzy_no_email_skips(self, reconciler):
p = _parsed(name="陳小明", email=None)
reconciler.reconcile([p], tenant_id=102)
assert p.match_method == MatchMethod.UNMATCHED
def test_fuzzy_no_at_skips(self, reconciler):
p = _parsed(name="陳小明", email="invalid")
reconciler.reconcile([p], tenant_id=102)
assert p.match_method == MatchMethod.UNMATCHED
def test_fuzzy_no_candidates_in_domain(self, reconciler, user_domain):
user_domain.get_users.return_value = []
p = _parsed()
reconciler.reconcile([p], tenant_id=102)
assert p.match_method == MatchMethod.UNMATCHED# domain/oscal/service/reconciliation/person_reconciler.py
from domain.oscal.service.reconciliation._normalizers import _strip_plus_alias, _normalize_name
class PersonReconciler(BaseReconciliationService[ParsedParty, object]):
# ... __init__ + _try_exact_match 沿用 T1.5
def _try_normalized_match(self, parsed, tenant_id):
email = (parsed.email_address or "").strip().lower()
if not email or '@' not in email:
return None
normalized = _strip_plus_alias(email)
if normalized == email:
return None
try:
from jedi_auth.domain.entity.user_query_entity import UserQueryEntity
users = self._user.get_users(UserQueryEntity(email=normalized))
except Exception:
return None
for u in users or []:
if getattr(u, "is_active", True):
return u
return None
def _try_fuzzy_match(self, parsed, tenant_id):
email = (parsed.email_address or "").strip().lower()
if not email or '@' not in email:
return (None, MatchMethod.UNMATCHED)
domain = email.split('@', 1)[1]
if not domain:
return (None, MatchMethod.UNMATCHED)
def _loader():
from jedi_auth.domain.entity.user_query_entity import UserQueryEntity
return self._user.get_users(UserQueryEntity())
candidates = self._get_or_load_candidates(_loader)
parsed_name = _normalize_name(parsed.name)
for u in candidates:
if not getattr(u, "is_active", True):
continue
u_email = getattr(u, "email", None)
if not u_email or '@' not in u_email:
continue
if u_email.split('@', 1)[1].lower() != domain:
continue
u_nickname = _normalize_name(getattr(u, "nickname", None))
u_login = _normalize_name(getattr(u, "login_name", None))
if parsed_name and (u_nickname == parsed_name or u_login == parsed_name):
return (u, MatchMethod.FUZZY_EMAIL_DOMAIN)
return (None, MatchMethod.UNMATCHED)Files:
domain/oscal/service/reconciliation/organization_reconciler.pytests/test_a3_reconciliation_organization.py(補 ~5 case)class TestNormalizedStage:
def test_full_width_space_normalize(self, reconciler, org_unit_domain):
# input: "ACME Inc"(全形);system: "ACME Inc"(半形)
...
class TestFuzzyStage:
def test_strip_股份有限公司_match(self, reconciler, org_unit_domain):
# input: "ACME 股份有限公司";system: "ACME"
def get_org_units(q):
if getattr(q, "name", None):
return []
return [MagicMock(id=7, name="ACME")]
org_unit_domain.get_org_units.side_effect = get_org_units
p = _parsed(name="ACME 股份有限公司")
reconciler.reconcile([p], tenant_id=102)
assert p.match_method == MatchMethod.FUZZY_NAME_PREFIX
assert p.matched_org_unit_id == 7
def test_no_suffix_skips_fuzzy(self, reconciler):
p = _parsed(name="ACME")
# normalized == name,不會 strip 出新 stripped → fuzzy 直接 UNMATCHED
reconciler.reconcile([p], tenant_id=102)
assert p.match_method == MatchMethod.UNMATCHEDfrom domain.oscal.service.reconciliation._normalizers import _normalize_name, _strip_org_suffix
class OrganizationReconciler(...):
# ... __init__ + _try_exact_match 沿用 T1.6
def _try_normalized_match(self, parsed, tenant_id):
name = (parsed.name or "").strip()
if not name:
return None
normalized = _normalize_name(name)
if normalized == name:
return None
try:
from jedi_auth.domain.entity.org_unit_query_entity import OrgUnitQueryEntity
q = OrgUnitQueryEntity(name=normalized)
if tenant_id is not None:
q.tenant_id = tenant_id
orgs = self._org.get_org_units(q)
except Exception:
return None
return orgs[0] if orgs else None
def _try_fuzzy_match(self, parsed, tenant_id):
name = (parsed.name or "").strip()
if not name:
return (None, MatchMethod.UNMATCHED)
normalized = _normalize_name(name)
stripped = _strip_org_suffix(normalized)
if stripped == normalized:
return (None, MatchMethod.UNMATCHED)
def _loader():
from jedi_auth.domain.entity.org_unit_query_entity import OrgUnitQueryEntity
q = OrgUnitQueryEntity()
if tenant_id is not None:
q.tenant_id = tenant_id
return self._org.get_org_units(q)
candidates = self._get_or_load_candidates(_loader)
for o in candidates:
o_name = _normalize_name(getattr(o, "name", None))
if _strip_org_suffix(o_name) == stripped:
return (o, MatchMethod.FUZZY_NAME_PREFIX)
return (None, MatchMethod.UNMATCHED)git add domain/oscal/service/reconciliation/person_reconciler.py \
domain/oscal/service/reconciliation/organization_reconciler.py \
tests/test_a3_reconciliation_person.py \
tests/test_a3_reconciliation_organization.py
git commit -m "$(cat <<'EOF'
feat(oscal): A3 T4 fuzzy match algorithm
PersonReconciler 補 normalized stage(+alias 移除)+ fuzzy stage
(email domain + nickname/login_name 對齊);
OrganizationReconciler 補 normalized stage(全形半形 / collapse)+
fuzzy stage(剔除尾綴)。
Client-side filter — 拉全 tenant list cache 在 reconciler instance
lifetime;exception 不寫入 cache(next call retry)。
ParsedParty 帶 match_method + match_confidence。
對齊 design-A3.md §5.1 / §5.2 / §5.4。
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"目標:累計到 ~51 個 A3 新 test 全綠(design-A3 §8 原寫 ~38 漏算 13 normalizers helper test;以 ~51 為準)+ 既有 213 test 不破壞 → 累計 ~264 BE test 全綠。 本 task 是補洞:把 T1~T4 散佈的 test 補齊 corner case 並加 matrix gap test。
Files:
tests/test_a3_reconciliation_person.pytests/test_a3_reconciliation_organization.py| Case | 預期 |
|---|---|
| exact / normalized / fuzzy 三階段都拉到 active=False → UNMATCHED | UNMATCHED |
| fuzzy stage candidate cache exception → 整批 UNMATCHED 不抛 | UNMATCHED |
| email 「a@」域名空 → 直入 UNMATCHED 不抛 | UNMATCHED |
| email 「@b.com」local 空 + normalized 跳過 + fuzzy 域名比對到 | NORMALIZED 或 UNMATCHED(看 _strip_plus_alias 行為) |
| Case | 預期 |
|---|---|
| name 「公司」單獨 → stripped = "" → UNMATCHED | UNMATCHED |
| 多層 suffix(「ACME 股份有限公司 Inc.」)只 strip 第一個 trailing | strip 「Inc.」一次 |
| 全空白 name → UNMATCHED | UNMATCHED |
Files:
tests/test_a3_parsed_party_fields.pyfrom dataclasses import asdict
import json
from domain.oscal.parser.ssp_intermediate import ParsedParty
from domain.oscal.service.reconciliation.match_method import MatchMethod
def test_default_match_method_unmatched():
p = ParsedParty(name="A", party_type="person", role=None, email_address=None)
assert p.match_method == MatchMethod.UNMATCHED
assert p.match_confidence == 0.0
def test_set_match_method_explicit():
p = ParsedParty(name="A", party_type="person", role=None, email_address=None,
match_method=MatchMethod.FUZZY_EMAIL_DOMAIN, match_confidence=0.7)
assert p.match_method == "fuzzy_email_domain" # StrEnum value 比較
def test_asdict_serializes_match_method_as_string_value():
"""StrEnum 在 dataclasses.asdict() 出來值是 enum instance;JSON dump 後是字串值。
A5 預覽 UI 反序列化拿到的必須是 "unmatched" 不是 "MatchMethod.UNMATCHED"。
"""
p = ParsedParty(name="A", party_type="person", role=None, email_address=None)
d = asdict(p)
# asdict 行為:StrEnum 仍是 enum instance(不是字串)
assert d["match_method"] == MatchMethod.UNMATCHED
# JSON dump 過後變字串 — JSONB 寫入 → A5 UI 反序列化拿到的是這個
json_str = json.dumps(d, default=str)
parsed_back = json.loads(json_str)
assert parsed_back["match_method"] == "unmatched"
assert parsed_back["match_confidence"] == 0.0
def test_fuzzy_method_jsonb_value():
"""同上 — FUZZY_EMAIL_DOMAIN 序列化後是 "fuzzy_email_domain" 字串"""
p = ParsedParty(name="A", party_type="person", role=None, email_address=None,
match_method=MatchMethod.FUZZY_EMAIL_DOMAIN, match_confidence=0.7)
d = asdict(p)
json_str = json.dumps(d, default=str)
parsed_back = json.loads(json_str)
assert parsed_back["match_method"] == "fuzzy_email_domain"
assert parsed_back["match_confidence"] == 0.7若 parse_jobs 寫入路徑用的不是
asdict + json.dumps而是自訂 serializer:T3 開工瞬間 grepparsed_result\s*=在app/oscal/service/ssp_excel_import_app_service.py找實際序列化點,補對應 assert(保住 A5 UI 反序列化拿到 string value 不是"MatchMethod.UNMATCHED")。
pytest tests/ -v --ignore=tests/legacy 2>&1 | tail -50Expected:~264 test passed(A1 106 + A2 99 + party 8 + A3 新 ~51)。
A3 新 ~51 case 拆解:
_strip_plus_alias / _normalize_name / _strip_org_suffix)與 design-A3 §8 acceptance criterion 第 7 條對齊:design 原文寫「~38」漏算了 13 normalizers helper test;T7.3 補 design §11 reconciliation 一條:「A3 ship 後實際 ~51 test,design §8 ~38 數字僅算 reconciliation 主數,未含 normalizers helper」。
若 pre-existing test fail(A0.1 已知
test_write_parties_refreshes_existing_party_in_place):標 follow-up 不擋 ship。
git add tests/test_a3_reconciliation_person.py \
tests/test_a3_reconciliation_organization.py \
tests/test_a3_parsed_party_fields.py
git commit -m "$(cat <<'EOF'
test(oscal): A3 T5 unit + integration tests
補齊 PersonReconciler / OrganizationReconciler 邊界 case;
新增 ParsedParty 加欄位 + asdict JSONB serialize 的 4 個 fixture test;
累計 A3 新增 ~51 unit + integration test 全綠(13 normalizers + 7 base
+ 12 person + 10 org + 3 facade + 4 A2 integration + 4 ParsedParty
+ ~2 matrix gap);
既有 213 test 不破壞 → 累計 ~264 BE test 全綠。
對齊 design-A3.md §8 acceptance criteria(原寫 ~38 漏算 normalizers helper
test,§11 reconciliation 補一條說明)。
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"目標:design-A3 §6.5 描述的 5 scenarios — 4 exact/normalized + 1 fuzzy。 跨 repo 注意事項:本 task 在
~/Projects/Billows/Audit-Manager/compliance-manager-test/進行,branch / commit 跟 BE 主 repo 分開;BE feature branch 與 test repo branch 不耦合。 Partial ship 允許:若 user 端 GitLab env 未配齊(A0.1 follow-up #7)→ scenarios 撰寫完成可接受,跑不過列 follow-up T8;不擋 A3 ship。
cd ~/Projects/Billows/Audit-Manager/compliance-manager-be
git status --short必須是空 output 或只有 pre-existing pyproject.toml dev-path 改動。若有 unstaged 改動 → 先完成 BE 端 T0~T5 任一 commit 再切。
cd ~/Projects/Billows/Audit-Manager/compliance-manager-test
git status
git checkout -b feature/a3-ssp-party-match-regression # 或沿用既有 phase 2 branchFiles:
features/regression/module-frame/05-ssp-docx-import-party-match.feature# language: zh-TW
功能: SSP Docx 匯入 — Party Match Regression(A3)
作為合規顧問
我希望 docx 匯入時 party 自動跟系統 user / org_unit 鉤稽
以便建檔時不用手動對齊每個 OSCAL party
背景:
假設 我以「合規管理員」身份登入
並且 我已開啟一個既有的合規資源庫
場景: docx 含 person email 完全相符 system user
假設 docx 內含 person email "alice@acme.com" 且系統 user 已有此 email
當 我上傳 docx 並等解析完成
那麼 API response parties[].matched_user_id 應該填入該 user id
並且 parties[].match_method 應為 "exact"
場景: docx 含 organization name 完全相符 system org
假設 docx 內含 organization "ACME" 且系統 org_unit 已有此名稱
當 我上傳 docx 並等解析完成
那麼 API response parties[].matched_org_unit_id 應該填入該 org id
並且 parties[].match_method 應為 "exact"
場景: docx 含 person email 完全不相符
假設 docx 內含 person email "unknown@unknown.com"
當 我上傳 docx 並等解析完成
那麼 API response parties[].matched_user_id 應為 null
並且 parties[].match_method 應為 "unmatched"
場景: docx 含 person email +alias normalize 後相符(NORMALIZED)
假設 docx 內含 person email "alice+work@acme.com" 且系統 user email 為 "alice@acme.com"
當 我上傳 docx 並等解析完成
那麼 API response parties[].matched_user_id 應該填入該 user id
並且 parties[].match_method 應為 "normalized"
場景: docx 含 organization name 帶尾綴 fuzzy 相符(FUZZY_NAME_PREFIX)
假設 docx 內含 organization "ACME 股份有限公司" 且系統 org_unit 名稱為 "ACME"
當 我上傳 docx 並等解析完成
那麼 API response parties[].matched_org_unit_id 應該填入該 org id
並且 parties[].match_method 應為 "fuzzy_name_prefix"
Files:
features/regression/module-frame/steps/ssp-docx-import-party-match.steps.js(或合進既有 steps)features/regression/module-frame/pages/ssp-docx-import-page.js(加 selector / API response assertion helper)參考既有 docx import scenarios — 對齊既有 step 體例;assertion 用 API response 而非 UI dom(regression 偏 BE 行為)。
cd ~/Projects/Billows/Audit-Manager/compliance-manager-test
npm run cucumber -- features/regression/module-frame/05-ssp-docx-import-party-match.featureExpected:5 scenarios 全綠。
在 test repo commit:
cd ~/Projects/Billows/Audit-Manager/compliance-manager-test
git add features/regression/module-frame/05-ssp-docx-import-party-match.feature \
features/regression/module-frame/steps/ssp-docx-import-party-match.steps.js
git commit -m "$(cat <<'EOF'
test(compliance-manager-test): A3 T6 cucumber party match regression
新增 5 scenarios cover docx import party match(4 exact/normalized + 1 fuzzy);
A3 ship 後 docx import party match regression baseline = 這 5 個 scenarios。
對齊 compliance-manager-be design-A3.md §6.5。
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"Files:
docs/changelog/2026-05-XX-feat-party-matcher-shared.md(XX = ship 當日)---
type: feat
breaking: false
modules: [oscal, ssp-import]
commit: <T1 + T2 + T3 + T4 commit hash chain>
---
# feat: 共用 matcher 抽取 — parties / org-units (A3)
## 需求說明
A2 ship 後,docx + Excel 兩條 SSP 匯入 path 都需要 ParsedParty → 系統
user / org_unit 鉤稽;A2 phase 跳過串接、行為跟 docx 不一致。A3 抽出共用
`BaseReconciliationService` 並補上 A2 串接、引入 fuzzy 中間狀態。
## 變更範圍
### 新建檔案
- domain/oscal/service/reconciliation/ sub-folder(5 個檔 + 1 個 helper)
- tests/test_a3_reconciliation_*.py + tests/test_a3_parsed_party_fields.py(7 個檔,共 ~51 個 case)
### 改動既有檔案
- domain/oscal/service/party_reconciliation_service.py — 改門面(公開 method 簽章不變)
- domain/oscal/parser/ssp_intermediate.py — ParsedParty 加 match_method + match_confidence
- app/oscal/service/ssp_excel_import_app_service.py — _confirm_*_flow 補 reconcile() 呼叫
- di_containers/oscal/oscal_containers.py — 加 person_reconciler / organization_reconciler Factory
### 跨 repo
- compliance-manager-test — 新增 cucumber regression `05-ssp-docx-import-party-match.feature` 含 5 scenarios
## 行為差異
| Caller | A3 前 | A3 後 |
|--------|-------|-------|
| docx flow parse-time | exact match only | exact + normalized + fuzzy 三階段 |
| docx flow confirm-time | 同上 | 同上 |
| A2 superset flow | **不呼叫** reconcile | 呼叫 reconcile,fuzzy 自動寫入 |
| A2 update flow | **不呼叫** reconcile | 呼叫 reconcile,fuzzy 自動寫入 |
## 測試結果
- A3 新增 ~51 unit + integration test 全綠(13 normalizers + 7 base + 12 person + 10 org + 3 facade + 4 A2 integration + 4 ParsedParty + ~2 matrix gap)
- 既有 213 BE test(A1 106 + A2 99 + party 8)全綠
- 累計 ~264 BE test passed
- Cucumber regression:5 scenarios 撰寫完成(**partial ship**:env 配齊後才能跑)
## A3 ship 後 regression baseline
任何後續 phase 動到 PartyReconciliationService / PersonReconciler /
OrganizationReconciler / fuzzy 演算法時,cucumber 5 scenarios 必過 + 既有 8
party unit test 必過。
## Follow-up
- F1:docx parser role normalize map(issue 修補 B)— A3 後獨立 commit
- F2:A5 phase 補 A2 fuzzy 的 user 拍板路徑
- F4:jedi-auth Query Entity 加 _in_email_domain(prod 大 tenant 才考慮)
- T8(cucumber partial ship):env 配齊後跑 5 scenarios
## 參考
- docs/features/FR-011.2-2605-ssp-import-export-phase2/design-A3.md(13 章)
- docs/features/FR-011.2-2605-ssp-import-export-phase2/implementation-plan-A3.mdFiles:
docs/features/FR-011.2-2605-ssp-import-export-phase2/README.mdFiles:
docs/features/FR-011.2-2605-ssp-import-export-phase2/design-A3.md(§11 段)必出條目(無論實作有無偏差都要寫):
- Test count:「A3 ship 後實際 ~51 test(含 13 normalizers helper),design §8 第 7 條原寫 ~38 漏算 helper test;以 ~51 為準」
- base.py exception swallow 差異:「
_get_or_load_candidatesexception 路徑走return []完全靜默(no log),與 docx parse-time line 264logger.warningswallow 行為不一致;base 內部 cache exception 不污染 caller log,屬可接受差異」可選條目(逐條列出實作偏差跟原因 — 沿用 SSP Update Diff §11 reconciliation pattern 16 條 deviations 樣板)。 例:
- 「§5.1 fuzzy 演算法原 design 用
for u in candidates: continue結構;實作改成if u.email is None: continue提前過濾,行為相同但 readability 較好」- 「§4.2 ParsedParty.to_dict 原假設已存在;實作時發現需另用
asdict()fromdataclasses,補 ParsedParty fixture test」- 「§6.2 A2 confirm flow 串接點原假設是 dict list;實作 verify 後是 ParsedParty list,串接點不變但 reconcile() 拿到 dataclass list 直接 mutate」
- ... (視實際偏差填)
若實作完全對齊 design 無其他偏差:在「必出條目」之後寫一條「除上述兩條外,A3 實作完全對齊 design-A3,無其他偏差條目」即可結束 §11。不能空段不寫。
Files:
docs/features/FR-011.2-2605-ssp-import-export-phase2/handoff/2026-05-XX-a3-SUMMARY.md不是只給簡短摘要 — 按 CLAUDE.md「做 summary」段,完整收尾報告,含:
- A3 task arc commit 清單(BE + test repo)
- 改動範圍(檔案清單)
- 行為差異(A3 前 vs A3 後)
- 規範文件齊全度(changelog / analysis / issue 各檔指標)
- 已知 follow-up(F1-F7 + T8)
- 部署 handover(jedi-* 仍 path-dep / pyproject.toml dev-path 不 commit / 須提醒 user BE 重啟)
git add docs/changelog/2026-05-XX-feat-party-matcher-shared.md \
docs/features/FR-011.2-2605-ssp-import-export-phase2/README.md \
docs/features/FR-011.2-2605-ssp-import-export-phase2/design-A3.md \
docs/features/FR-011.2-2605-ssp-import-export-phase2/handoff/2026-05-XX-a3-SUMMARY.md \
docs/conversation-history/2026-05-XX/ssp-import-export-phase2/
git commit -m "$(cat <<'EOF'
docs(ssp-import-export-phase2): A3 收尾 + SUMMARY
- changelog feat-party-matcher-shared.md
- tracker README A3 row → BE shipped + commit chain
- design-A3.md §11 implementation reality reconciliation
- handoff A3 SUMMARY(task arc 收口)
- conversation-history dump(A3 task arc 全對話原文)
A3 phase 完整收口;A4 phase 可在 BaseReconciliationService 上擴
device / info_system / leveraged / catalog control / AO 5 種新 reconciler。
對齊 design-A3.md §8 + §12 收口規範 / CLAUDE.md 做 summary 段。
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"BaseReconciliationService 4 hook + _get_or_load_candidates exception 行為兩處屬「non-obvious why」,允許寫 docstring| Task | 檢核點 | 處理 |
|---|---|---|
| T0 | T0.2 verify 發現 A2 confirm flow 已被別人補上 reconcile | T3 task 改標 N/A,跳過直入 T4 |
| T0 | T0.3 verify 發現 ParsedParty 已被加 match_method 但命名不同 | 停下對齊命名 + 主動修 design §11 |
| T1 | base.py 7 個 test 跑不過:抽象 method 漏實作 / Generic 命名衝突 | check from abc import ABC, abstractmethod import + Generic[TParsed, TEntity] 順序 |
| T2 | 既有 8 party test fail:mock 注入結構改變 | 看是否用了 service 內部 private method;如果有就把 mock 注入點改成兩 reconciler |
| T2 | DI wire error:auth_container.user_domain_service 跨 container 引用失敗 | 對齊 既有 line 490/491 pattern;確認 sub-container reference 拿到 |
| T3 | A2 confirm flow _write_parties 接受 dict list 不是 ParsedParty list |
reconcile() 在轉 dict 之前對 ParsedParty list 跑(design §11 reconciliation 補一條) |
| T3 | A2 既有 99 unit test fail:mock 注入沒包 reconciliation | 看是否在既有 fixture 中 mock 注入 party_reconciliation_service 沒提供 .reconcile method |
| T4 | candidate cache 在 jedi-auth UserQueryEntity() 空 query 拉不到全 list(RLS) |
verify UserQueryEntity() 不帶條件時 RLS 自動隔離 + 拿到全 tenant active list |
| T4 | Fuzzy name match false positive 過多 | unit test 加更多邊界 case;prod feedback 後 follow-up F5 擴 ORG_SUFFIXES |
| T5 | 累計 test count < ~51:corner case 漏 | 看 design-A3 §8 acceptance criteria 第 7 條 + plan T5.3 的 51 拆解,逐項對齊 |
| T5 | asdict() 序列化 MatchMethod StrEnum 給 A5 UI 拿到 "MatchMethod.UNMATCHED" 而非 "unmatched" |
T5.2 test 已固化 json.dumps(d, default=str) 行為 — implementer 必須 verify parse_jobs 寫入路徑也走 json.dumps + default=str 或自訂 serializer 對齊 StrEnum value 行為 |
| T6 | env 未配齊跑不過 | 列 follow-up T8,scenarios 撰寫完成 partial ship |
| T7 | §11 reconciliation 段沒實作偏差可寫 → 段落空 | 標「§11 無偏差」即可,沿用 SSP Update Diff §11 16 條樣板的「無偏差」標示 |
| Repo | 工作 | Branch | Commit 預估 |
|---|---|---|---|
| compliance-manager-be(主) | T0 + T1 + T2 + T3 + T4 + T5 + T7 | feature/ssp-import-export-phase2 |
6 commits |
| compliance-manager-test | T6 cucumber regression | feature/a3-ssp-party-match-regression(或既有 phase 2 branch) |
1 commit |
| compliance-manager-fe | 不動 | — | 0 commit |
| jedi-* 套件 | 不動 | — | 0 commit |
_in_email_domain / _in_name_prefix — follow-up F4oscal_responsible_parties.role_id 髒資料 audit / 清理(issue 修補 C)— follow-up F6按 CLAUDE.md「做 summary 觸發完整收尾」段:
docs/features/FR-011.2-2605-ssp-import-export-phase2/handoff/2026-05-XX-a3-<session 字母>-to-<下一 session>.mddocs/conversation-history/<date>/ssp-import-export-phase2/(不做格式化)| 換 Session 時點 | 必產出 |
|---|---|
| C → D | handoff prompt(T1 + T2 已 ship,T3 + T4 開工指引) |
| D → E | handoff prompt(T3 + T4 已 ship,T5 + T6 + T7 開工指引) |
| E(收口) | final SUMMARY + design §11 reconciliation + conversation-history dump |
docs/conversation-history/2026-05-06-ssp-update-diff/SUMMARY.md 的 6 段體例下一步:本 plan 經 plan-document-reviewer subagent review approved + user review GO 後 → Session B 結束,commit plan-A3.md + 產 Session B → C handoff prompt → 換 Session C 開 T1。