Phase A3 Implementation Plan — 共用 matcher 抽取(parties / org-units)

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_*_flowreconcile() 呼叫;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 寫作) 對應 designdocs/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


§1

Task 切分概覽

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_flowreconcile() 呼叫 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。

§2

File Structure

新建檔案(A3)

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

改動既有檔案(A3)

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 段補實作偏差

跨 repo 改動

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(若必要)

不動

  • jedi-* 套件(reconciliation 維持在主專案;F4 follow-up 才可能動 jedi-auth)
  • FE(match_method JSONB schema 已 free shape,A5 phase 才補預覽 UI 互動)

§3

Task 0 — Implementer Pre-flight Verification

Session A 已驗的 5 項(design-A3 §7)implementer 不重做

  1. PartyReconciliationService caller 盤點 ✅
  2. A2 confirm flow 沒實際串 reconcile ✅
  3. jedi-auth Query Entity 既有欄位(無 email_domain / name prefix)✅
  4. cucumber 既有 docx import 無 matched/unmatched 守門 ✅
  5. test repo 結構 ✅

T0 補 implementer 開工瞬間才會碰到的 3 項(plan 寫好到 Session C 動工可能跨 days/weeks,期間 method 改名 / entity shape 變)。

0.1 verify 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。

0.2 verify A2 _confirm_superset_flow + _confirm_update_flow 仍未呼叫 reconcile

grep -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。

0.3 verify 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,停下對齊命名後續執行。

0.4 Acceptance

何時 commit:T0 結果可併入 T1 commit;無 standalone commit 必要。


§4

Task 1 — BaseReconciliationService + Reconcilers Skeleton + ParsedParty 加欄位

目標:把 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 不抛)。

1.1 新建 MatchMethod enum

Files:

  • Create: domain/oscal/service/reconciliation/__init__.py
  • Create: domain/oscal/service/reconciliation/match_method.py
  • Test: tests/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

1.2 寫 _normalizers.py(純函式 + 內聯 unit test 先驅動)

Files:

  • Create: domain/oscal/service/reconciliation/_normalizers.py
  • Test: tests/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 -v

Expected: 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 name
pytest tests/test_a3_reconciliation_normalizers.py -v

Expected: 13 passed

1.3 寫 BaseReconciliationService 抽象基類

Files:

  • Create: domain/oscal/service/reconciliation/base.py
  • Test: tests/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 == 1
pytest tests/test_a3_reconciliation_base.py -v

Expected: 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 result
pytest tests/test_a3_reconciliation_base.py -v

Expected: 7 passed

1.4 ParsedParty 加 match_method + match_confidence 欄位

Files:

  • Modify: domain/oscal/parser/ssp_intermediate.py
  • Test: 既有 tests/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.0
pytest tests/test_party_reconciliation_service.py tests/test_ssp_docx_*.py tests/test_ssp_excel_*.py -v 2>&1 | tail -30

Expected: 既有測試(A1 106 + A2 99 + party 8 = 213)全綠

1.5 寫 PersonReconciler skeleton

Files:

  • Create: domain/oscal/service/reconciliation/person_reconciler.py
  • Test: tests/test_a3_reconciliation_person.py

T1.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 維持 None

1.6 寫 OrganizationReconciler skeleton(同 1.5 pattern,exact stage only)

Files:

  • Create: domain/oscal/service/reconciliation/organization_reconciler.py
  • Test: tests/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

1.7 補 __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",
]

1.8 Commit T1

git status --short

預期看到:

  • ?? 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
  • ?? tests/test_a3_reconciliation_normalizers.py
  • ?? tests/test_a3_reconciliation_base.py
  • ?? tests/test_a3_reconciliation_person.py
  • ?? tests/test_a3_reconciliation_organization.py
  • M domain/oscal/parser/ssp_intermediate.py
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.py
git 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
)"

Task 1 Acceptance


§5

Task 2 — PartyReconciliationService 改門面 + DI Wiring

2.1 改門面實作

Files:

  • Modify: 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 parties
pytest tests/test_party_reconciliation_service.py -v

Expected:8 個 case 全綠(0 行測試 code 改動 — 因為公開 method 簽章 100% 不變、行為 100% 不變 via PersonReconciler/OrganizationReconciler 的 exact stage)。

若有 fail:要先檢查既有 test 是否用了 service._user_domain_service 直接存取或 _reconcile_person 私有 method(如果是,更新 test 改用 mock injection)。

2.2 DI Wiring

Files:

  • Modify: di_containers/oscal/oscal_containers.py
grep -nA 5 "party_reconciliation_service\|person_reconciler\|organization_reconciler" \
  di_containers/oscal/oscal_containers.py | head -30
grep -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 -20

Expected:BE 起動成功,無 DI wire error。 若撞 jedi-issue GitLab env 問題(A0.1 follow-up #7):partial smoke 接受 — 確認 DI container build 不 wire error 即可。

2.3 寫門面 unit test

Files:

  • Create: 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 parties

2.4 Commit T2

git 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
)"

Task 2 Acceptance


§6

Task 3 — A2 Confirm Flow 補 reconcile() 串接

目標:design-A3 §6.1 / §6.2 描述的 A2 path 串接 — _confirm_superset_flow + _confirm_update_flowwrite_parties 前呼叫 reconcile。 A3 期間 fuzzy 行為:A5 預覽 UI 還沒做 — A3 期間 fuzzy 自動寫入(行為等同 matched);A5 上線後改為 user 拍板(follow-up F2 已標 design §11)。

3.1 verify 既有 helper 位置

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'。

3.2 修 _confirm_superset_flow

Files:

  • Modify: 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 結果決定確切插入點。

3.3 修 _confirm_update_flow

Files:

  • Modify: 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)
    # ... 既有 upsert

3.4 寫 A2 integration test

Files:

  • Create: 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_scope helper:直接 import 用,刪除上方 fixture 重複定義。grep grep -n "patch_session_scope\|def session_scope" tests/conftest.py 確認。

3.5 Commit T3

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

Task 3 Acceptance


§7

Task 4 — Fuzzy 演算法完整落地

目標:把 T1.5 / T1.6 留下的 _try_normalized_match + _try_fuzzy_match stub 用 design-A3 §5.1 / §5.2 描述的演算法填滿。 TDD:每個 stage 先寫 test 確認 stub 路徑會 fail(exact 沒中時走進來),再實作。

4.1 PersonReconciler normalized + fuzzy stages

Files:

  • Modify: domain/oscal/service/reconciliation/person_reconciler.py
  • Modify: tests/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)

4.2 OrganizationReconciler normalized + fuzzy stages

Files:

  • Modify: domain/oscal/service/reconciliation/organization_reconciler.py
  • Modify: tests/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.UNMATCHED
from 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)

4.3 Commit T4

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

Task 4 Acceptance


§8

Task 5 — Unit Test + Integration Test 補齊

目標:累計到 ~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。

5.1 補 Person / Organization 額外邊界 case

Files:

  • Modify: tests/test_a3_reconciliation_person.py
  • Modify: tests/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

5.2 ParsedParty fixture test

Files:

  • Create: tests/test_a3_parsed_party_fields.py
from 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 開工瞬間 grep parsed_result\s*=app/oscal/service/ssp_excel_import_app_service.py 找實際序列化點,補對應 assert(保住 A5 UI 反序列化拿到 string value 不是 "MatchMethod.UNMATCHED")。

5.3 既有 213 BE test 跑全綠驗證

pytest tests/ -v --ignore=tests/legacy 2>&1 | tail -50

Expected:~264 test passed(A1 106 + A2 99 + party 8 + A3 新 ~51)。

A3 新 ~51 case 拆解

  • 13 normalizers helper (T1.2 — _strip_plus_alias / _normalize_name / _strip_org_suffix)
  • 7 base 抽象基類 (T1.3 — 三階段 fallback + cache 行為)
  • 12 PersonReconciler (T1.5 exact 4 + T4.1 normalized + fuzzy 8)
  • 10 OrganizationReconciler (T1.6 exact 5 + T4.2 normalized + fuzzy 5)
  • 3 facade dispatch (T2.3)
  • 4 A2 integration (T3.4)
  • 4 ParsedParty fixture (T5.2 — default / 顯式 set / asdict serialize / fuzzy JSONB value)
  • 2 matrix gap (T5.1 — Person + Organization 各 1~2 邊界 case)

與 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。

5.4 Commit T5

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

Task 5 Acceptance


§9

Task 6 — Cucumber Regression(compliance-manager-test repo)

目標: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。

6.1 切到 test repo(先確認 BE working tree 乾淨再切)

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 branch

6.2 寫 feature file

Files:

  • Create: 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"

6.3 寫對應 steps + page object

Files:

  • Create: features/regression/module-frame/steps/ssp-docx-import-party-match.steps.js(或合進既有 steps)
  • Modify: 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 行為)。

6.4 Smoke run(user GitLab env 配齊時才能跑)

cd ~/Projects/Billows/Audit-Manager/compliance-manager-test
npm run cucumber -- features/regression/module-frame/05-ssp-docx-import-party-match.feature

Expected:5 scenarios 全綠。

6.5 Commit T6

在 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
)"

Task 6 Acceptance


§10

Task 7 — Changelog + Tracker + Design §11 + Final SUMMARY

7.1 寫 Changelog

Files:

  • Create: 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.md

7.2 更新 tracker

Files:

  • Modify: docs/features/FR-011.2-2605-ssp-import-export-phase2/README.md

7.3 補 design-A3.md §11 Implementation Reality

Files:

  • Modify: 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_candidates exception 路徑走 return [] 完全靜默(no log),與 docx parse-time line 264 logger.warning swallow 行為不一致;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() from dataclasses,補 ParsedParty fixture test」
  • 「§6.2 A2 confirm flow 串接點原假設是 dict list;實作 verify 後是 ParsedParty list,串接點不變但 reconcile() 拿到 dataclass list 直接 mutate」
  • ... (視實際偏差填)

若實作完全對齊 design 無其他偏差:在「必出條目」之後寫一條「除上述兩條外,A3 實作完全對齊 design-A3,無其他偏差條目」即可結束 §11。不能空段不寫

7.4 寫 Final SUMMARY

Files:

  • Create: docs/features/FR-011.2-2605-ssp-import-export-phase2/handoff/2026-05-XX-a3-SUMMARY.md

不是只給簡短摘要 — 按 CLAUDE.md「做 summary」段,完整收尾報告,含:

  1. A3 task arc commit 清單(BE + test repo)
  2. 改動範圍(檔案清單)
  3. 行為差異(A3 前 vs A3 後)
  4. 規範文件齊全度(changelog / analysis / issue 各檔指標)
  5. 已知 follow-up(F1-F7 + T8)
  6. 部署 handover(jedi-* 仍 path-dep / pyproject.toml dev-path 不 commit / 須提醒 user BE 重啟)

7.5 對話歷史 dump(Session E task arc 收口)

7.6 Commit T7(BE main repo)

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

Task 7 Acceptance


§11

規範遵守清單(執行時逐項勾)

    • A3 例外BaseReconciliationService 4 hook + _get_or_load_candidates exception 行為兩處屬「non-obvious why」,允許寫 docstring

§12

風險檢核點

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 條樣板的「無偏差」標示

§13

跨 repo 工作清單

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

§14

不在 A3 範圍(明確排除)

  • ❌ A4 5 種新 reconciler(device / info_system / leveraged / catalog control / AO)— follow-up F3
  • ❌ docx parser role normalize map(issue 修補 B)— follow-up F1
  • ❌ A5 預覽 UI 對 A2 fuzzy 的 user 拍板路徑(A3 期間自動寫入)— follow-up F2
  • ❌ jedi-auth Query Entity 加 _in_email_domain / _in_name_prefix — follow-up F4
  • ❌ OSCAL party ↔︎ project_participant 雙寫設計(issue 修補 D)— follow-up F5
  • ❌ Dev DB oscal_responsible_parties.role_id 髒資料 audit / 清理(issue 修補 C)— follow-up F6
  • ❌ docx parser fuzzy 標記後預覽 UI 互動 — follow-up F7

§15

換 Session 收尾規範(每 Session 結束時)

按 CLAUDE.md「做 summary 觸發完整收尾」段:

  1. 盤點 commits + working tree 乾淨
  2. 規範文件齊全度檢查(changelog / analysis / issue / design §11 reconciliation)
  3. 產 handoff prompt → docs/features/FR-011.2-2605-ssp-import-export-phase2/handoff/2026-05-XX-a3-<session 字母>-to-<下一 session>.md
  4. Session E 是 task arc 收口 → 對話歷史按日期 dump 到 docs/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

§16

工具預期

  • TDD:每個 task 都先寫 failing test 再實作(superpowers:test-driven-development)
  • 子 task 完成階段性 commit(不用停下問 user)
  • T6 跨 repo 切換時切勿汙染 BE working tree
  • T7 SUMMARY 階段:mirror SSP Update Diff docs/conversation-history/2026-05-06-ssp-update-diff/SUMMARY.md 的 6 段體例
  • Subagent dispatch(如需 parallel test 補齊):明寫「git add 顯式檔名 禁用 -am」+「對齊 既有 mock pattern」+「不動 pyproject.toml」三鐵律

下一步:本 plan 經 plan-document-reviewer subagent review approved + user review GO 後 → Session B 結束,commit plan-A3.md + 產 Session B → C handoff prompt → 換 Session C 開 T1。