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

Phase:A3(Track A:Excel 匯入第三階段) 級別:重型(brainstorm → design → plan → 開工) 狀態:design draft(brainstorm 收斂完成 2026-05-20) 前置:A1(樣板下載)+ A2(Parser + 解析 API)皆 BE shipped 依賴本 phase:A4(devices / info_systems / leveraged / controls / AOs 5 種新 matcher)建在本 phase BaseReconciliationService 抽象基類上


1. 為什麼要做 A3

1.1 既有現況

A2 ship 後,主專案有兩條 SSP 匯入入口都需要做「ParsedParty → 系統 user / org_unit」的鉤稽:

入口 既有 reconciliation 行為
Docx importSspDocxImportAppService — parse-time line 258-264 + confirm-time line 489-490 共 2 處) 直接呼叫 PartyReconciliationService.reconcile():person email exact / organization name exact 兩態
Excel importSspExcelImportAppService DI 已注入 party_reconciliation_service(line 50/59),但 _confirm_superset_flow (line 251) + _confirm_update_flow (line 325) 沒有呼叫 reconcile — A2 phase 跳過此串接

A4 後續還要加 5 種新 matcher(devices / info_systems / leveraged / catalog controls / AOs)— 若 A3 不抽共用層,A4 會踩「複製 5 份 reconcile batch 邏輯」的反 pattern。

1.2 A3 三個目標

  1. 抽共用 base:把 PartyReconciliationService 內部邏輯抽成 BaseReconciliationService[TParsed, TEntity] 抽象基類;A4 直接擴 hook
  2. 引入 fuzzy 中間狀態:原 matched / unmatched 二態升級 matched / fuzzy / unmatched 三態,cover「VLOOKUP 未重算」/「email 帶 +alias」/「組織名帶尾綴」常見 case
  3. 串接 A2 path:補上 A2 _confirm_*_flow 對 reconcile 的呼叫,讓 docx + A2 兩條 import flow 行為一致

1.3 與 issue 修補的關係

對應 issue:docs/issues/pending/2026-05-19-person-role-cross-domain-inconsistency.md

A3 範圍不整併修補 B(docx parser 加 role normalize map)— role 是 enum 規範問題、不是 matcher 問題;matcher 處理「字串 → 系統實體」,role enum 屬於另一域。修補 B 列 follow-up(F1)。


2. 範圍 / 不在範圍

In Scope

  • 新建 domain/oscal/service/reconciliation/ sub-folder + 4 個檔(base / match_method / person_reconciler / organization_reconciler)
  • 既有 PartyReconciliationService 改為門面(facade),dispatch by party_type 到兩個 reconciler;公開 method reconcile(parties, tenant_id) 簽章 100% 不變
  • ParsedParty dataclass 加 2 個 optional 欄位(match_method + match_confidence),既有 caller / test fixture 不破壞
  • DI container 新增兩個 reconciler Factory;門面改注入兩 reconciler
  • A2 _confirm_superset_flow + _confirm_update_flow 補 reconcile() 呼叫
  • Fuzzy 演算法:
    • Person:normalize 後 email exact / +alias 移除後 exact / email domain + nickname 對齊
    • Organization:normalize 後 name exact / 全形半形 / 剔除尾綴後 exact
  • Candidate cache:每次 reconcile() call 在 reconciler instance 內 cache 全 tenant user / org_unit list(client-side filter)
  • Cucumber regression features/regression/module-frame/05-ssp-docx-import-party-match.feature 含 5 scenarios(4 exact/normalized + 1 fuzzy)
  • Changelog docs/changelog/YYYY-MM-DD-feat-party-matcher-shared.md(type: feat、modules: oscal)

Out of Scope(列 follow-up)

# 不在 A3 的事 處理時機
F1 Issue 修補 B(docx parser 加 role normalize map) A3 後獨立 commit / 或 issue resolution 階段
F2 A5 預覽 UI 對 A2 fuzzy 的 user 拍板路徑(A3 期間 A2 fuzzy 自動寫入) A5 phase
F3 A4 加 5 個新 reconciler(device / info_system / leveraged / catalog control / AO) A4 phase(直接擴 BaseReconciliationService)
F4 jedi-auth Query Entity 加 _in_email_domain / _in_name_prefix 補 fuzzy DB-side filter Prod 大 tenant feedback 後評估
F5 OSCAL party ↔︎ project_participant 雙寫設計(issue 修補 D) 未來 OSCAL 匯出規劃時
F6 Dev DB oscal_responsible_parties.role_id 髒資料 audit / 清理(issue 修補 C) A3 ship 後執行 audit SQL
F7 docx parser 拿到 fuzzy 標記後預覽 UI 互動(A5-similar UI) A5 phase(既有 docx preview 已 partial cover)

3. Architecture

3.1 File 結構

domain/oscal/service/
├── reconciliation/                              # 新 sub-folder
│   ├── __init__.py
│   ├── base.py                                  # BaseReconciliationService[TParsed, TEntity]
│   ├── match_method.py                          # MatchMethod StrEnum
│   ├── _normalizers.py                          # 共用 helpers(_strip_plus_alias / _normalize_name / _strip_org_suffix)
│   ├── person_reconciler.py                     # PersonReconciler
│   └── organization_reconciler.py               # OrganizationReconciler
└── party_reconciliation_service.py              # 既有,改門面(dispatch by party_type)

底線命名 _normalizers.py:標示為 sub-folder 內部 helper module,外部不該 import。base / reconciler 之間共用 normalize 規則。

為何開 sub-folder:A4 後總共 ~10 個檔(base + enum + 7 reconciler + 門面),全攤平在 service/ 下會跟既有 30+ 個 service file 混在一起、稀釋 grep 效率。domain/oscal/parser/ 已有 sub-folder precedent。

3.2 BaseReconciliationService 抽象基類

# domain/oscal/service/reconciliation/base.py
from abc import ABC, abstractmethod
from typing import Generic, List, Optional, TypeVar

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 4 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]:
        """Batch reconcile; mutates each TParsed in place + returns same list."""
        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:
        """Three-stage fallback: exact → normalized → fuzzy → UNMATCHED."""
        # Stage 1: exact
        candidate = self._try_exact_match(parsed, tenant_id)
        if candidate is not None:
            self._apply_match(parsed, candidate, MatchMethod.EXACT, 1.0)
            return
        # Stage 2: normalized exact
        candidate = self._try_normalized_match(parsed, tenant_id)
        if candidate is not None:
            self._apply_match(parsed, candidate, MatchMethod.NORMALIZED, 1.0)
            return
        # Stage 3: fuzzy (uses candidate cache)
        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
        # No match
        self._apply_unmatched(parsed)

    # --- Subclass hooks(必須實作)---

    @abstractmethod
    def _try_exact_match(
        self, parsed: TParsed, tenant_id: Optional[int]
    ) -> Optional[TEntity]:
        """Use exact lookup key (e.g. email lower+trim, name trim) via domain service."""

    @abstractmethod
    def _try_normalized_match(
        self, parsed: TParsed, tenant_id: Optional[int]
    ) -> Optional[TEntity]:
        """Apply normalization (+alias removal, 全形半形 conversion) then exact lookup."""

    @abstractmethod
    def _try_fuzzy_match(
        self, parsed: TParsed, tenant_id: Optional[int]
    ) -> tuple[Optional[TEntity], MatchMethod]:
        """Client-side fuzzy filter against candidate cache; returns (entity, method)."""

    @abstractmethod
    def _apply_match(
        self,
        parsed: TParsed,
        entity: TEntity,
        method: MatchMethod,
        confidence: float,
    ) -> None:
        """Mutate parsed in place to record the match.

        Subclass owns which attrs to mutate — base class does NOT assume any
        field names. Conventional mutations:
          - PersonReconciler:        parsed.matched_user_id = entity.id
          - OrganizationReconciler:  parsed.matched_org_unit_id = entity.id
        Always mutate parsed.match_method = method + parsed.match_confidence = confidence
        (the two A3 new fields on ParsedParty).
        """

    @abstractmethod
    def _apply_unmatched(self, parsed: TParsed) -> None:
        """Mutate parsed to UNMATCHED state.

        Subclass owns matched_*_id reset (typically leaves at None).
        Always sets parsed.match_method = MatchMethod.UNMATCHED + parsed.match_confidence = 0.0.
        """

    # --- Cache helpers(subclass 可呼叫)---

    def _get_or_load_candidates(self, loader) -> List[TEntity]:
        """Lazy-load candidate list once per reconciler instance lifetime.

        Exception 行為:與既有 PartyReconciliationService (line 71-72 / 92-93)
        對齊 — exception 後不快取空 list、不快取 None;當次 reconcile 視為「拉不到候選」
        直接 → UNMATCHED;下次 reconcile call(DI 重 build instance)會 retry。
        """
        if self._candidate_cache is not None:
            return self._candidate_cache
        try:
            result = loader() or []
        except Exception:
            return []  # ← 不寫入 cache,下次 retry(跟既有行為一致)
        self._candidate_cache = result
        return result

Hook contract:subclass 必須實作 5 個 abstractmethod。reconcile() + _reconcile_one() + _get_or_load_candidates() 在 base 內共用,不允許 override(convention only,Python 不強制)。

3.3 PartyReconciliationService 門面

# 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

為何保留門面(不砍直接讓 caller 用 PersonReconciler / OrganizationReconciler)

  • reconcile(parties) 處理的是 mixed list — 由門面負責 party_type dispatch(domain knowledge)
  • 兩個 caller(docx + A2)公開介面 0 行變動,既有 8 unit test 全綠保住
  • A4 加新 dataclass(ParsedDevice / ParsedInfoSystem 等)不會走此門面(不屬 party domain),命名語意維持準確

4. Data Structures

4.1 MatchMethod enum(新建 domain/oscal/service/reconciliation/match_method.py

from enum import StrEnum


class MatchMethod(StrEnum):
    EXACT = "exact"                              # email/name 完全相符(既有行為)
    NORMALIZED = "normalized"                    # +alias / 全形半形 normalize 後 exact
    FUZZY_EMAIL_DOMAIN = "fuzzy_email_domain"    # person 限定:email domain + nickname 對齊
    FUZZY_NAME_PREFIX = "fuzzy_name_prefix"      # organization 限定:剔除尾綴後 exact
    UNMATCHED = "unmatched"                      # 都不中

StrEnum:JSON 序列化直接是字串值,跟既有 GRC 模組慣例一致。

4.2 ParsedParty 加欄位(改 domain/oscal/parser/ssp_intermediate.py

@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  # 預設 UNMATCHED
    match_confidence: float = 0.0                      # 0.0 ~ 1.0

回溯相容性:兩個新欄位都有 default,既有 caller / unit test fixture / write_strategy.write_parties 完全不受影響。

4.3 Confidence 規則(A3 寫死)

match_method confidence matched_*_id 行為 A5 UI 預期
EXACT 1.0 填值 不需 user 確認,預設 confirmed
NORMALIZED 1.0 填值(系統判定為「事實相符」) 同 EXACT — 不需 user 確認,但 UI 可顯示「自動修正」icon 提示 user 系統做了 normalize
FUZZY_EMAIL_DOMAIN 0.7 填值 UI 預選 + 提示「請確認此 user 是否為對應人員」,user 可推翻
FUZZY_NAME_PREFIX 0.7 填值 UI 預選 + 提示「請確認此組織是否為對應單位」,user 可推翻
UNMATCHED 0.0 維持 None UI 提示「無對應系統紀錄,請手動選現有 / 純文字保留 / inline 新建」

A3 不做動態 confidence 打分;future-proof 欄位留給 A4 device matcher 如有需要可細分梯度。

A5 預覽 UI contract:A4/A5 phase 寫 preview UI 時,EXACT 跟 NORMALIZED 共用 path(一律不擋 confirm);FUZZY_* 共用 path(一律擋 confirm 等 user 拍板);UNMATCHED 走 inline 新建 / 選現有 / 純文字三選一 path(沿用 §1.3 raw-requirement §3.2 既有規範)。所以雖然 EXACT / NORMALIZED 都 confidence=1.0,但 match_method 仍寫入 JSONB 是為了 A5 UI 顯示「自動修正」提示(透明性給 user)。

4.4 match_method 落地策略

不寫入 oscal_parties table(OSCAL 規範鏡像不汙染運行時 metadata);只活在:

  1. Parse 階段:寫入 parse_jobs.parsed_result JSONB(A5 預覽 UI 反序列化讀)
  2. Confirm 階段:用完即丟(OSCAL DB 不留痕跡)

JSONB schema 是 free shape,A5 預覽 UI 反序列化新欄位 — 不需 migration


5. Fuzzy Match 演算法

5.1 PersonReconciler 三階段降級

Input: ParsedParty(party_type='person', name='陳小明', email_address='alice+work@acme.com')
Tenant: 102

Stage 1 — Exact match(既有行為)
  email = parsed.email_address.strip().lower()
  if not email: → UNMATCHED 結束
  candidates = user_domain.get_users(UserQueryEntity(email=email))
  if 找到 is_active: → EXACT, confidence=1.0, matched_user_id 填

Stage 2 — Normalized match(新增)
  normalized = _strip_plus_alias(email)
    # alice+work@acme.com → alice@acme.com
  if normalized != email:
      candidates = user_domain.get_users(UserQueryEntity(email=normalized))
      if 找到 is_active: → NORMALIZED, confidence=1.0, matched_user_id 填

Stage 3 — Fuzzy email domain(新增,client-side filter)
  domain = email.split('@', 1)[1] if '@' in email else None
  if not domain: → UNMATCHED 結束

  # candidate cache(每次 reconcile() call lifetime 一次)
  candidates = self._get_or_load_candidates(
      lambda: user_domain.get_users(UserQueryEntity())  # 全 tenant active,RLS 隔離
  )

  parsed_name = parsed.name.strip()
  for u in candidates:
      if not u.is_active or not u.email: continue
      if u.email.split('@', 1)[1].lower() != domain: continue
      # nickname 或 name 完全相符(normalized)
      if (_normalize_name(u.nickname) == _normalize_name(parsed_name)
              or _normalize_name(u.login_name) == _normalize_name(parsed_name)):
          → FUZZY_EMAIL_DOMAIN, confidence=0.7, matched_user_id 填
          break

→ 都不中 → UNMATCHED

5.2 OrganizationReconciler 三階段降級

Input: ParsedParty(party_type='organization', name='ACME 股份有限公司')
Tenant: 102

Stage 1 — Exact match(既有行為)
  trimmed = parsed.name.strip()
  if not trimmed: → UNMATCHED 結束
  query = OrgUnitQueryEntity(name=trimmed)
  if tenant_id: query.tenant_id = tenant_id
  candidates = org_unit_domain.get_org_units(query)
  if 找到: → EXACT, confidence=1.0, matched_org_unit_id 填

Stage 2 — Normalized match(新增)
  normalized = _normalize_name(trimmed)  # 全形 → 半形、collapse 連續空格
  if normalized != trimmed:
      candidates = org_unit_domain.get_org_units(OrgUnitQueryEntity(name=normalized, tenant_id=tenant_id))
      if 找到: → NORMALIZED, confidence=1.0, matched_org_unit_id 填

Stage 3 — Fuzzy name prefix(新增,client-side filter)
  stripped = _strip_org_suffix(normalized)
  if stripped == normalized: → UNMATCHED(無尾綴可剔除,fuzzy 沒意義)

  candidates = self._get_or_load_candidates(
      lambda: org_unit_domain.get_org_units(OrgUnitQueryEntity(tenant_id=tenant_id))
  )

  for o in candidates:
      o_stripped = _strip_org_suffix(_normalize_name(o.name))
      if o_stripped == stripped:
          → FUZZY_NAME_PREFIX, confidence=0.7, matched_org_unit_id 填
          break

→ 都不中 → UNMATCHED

5.3 共用 normalize 函式

# domain/oscal/service/reconciliation/_normalizers.py(內部 helper)

def _strip_plus_alias(email: str) -> str:
    """alice+work@acme.com → alice@acme.com"""
    if '@' not in email or '+' not in email.split('@', 1)[0]:
        return email
    local, domain = email.split('@', 1)
    return f"{local.split('+', 1)[0]}@{domain}"


def _normalize_name(name: Optional[str]) -> str:
    """全形空格 → 半形、連續空格 collapse、strip。
    不做大小寫轉換 — 中文不影響;英文「Alice Wang」vs「alice wang」算不同人,保守處理。
    """
    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:
    """Trailing match only — 只剔結尾。"""
    if not name:
        return name
    for suffix in ORG_SUFFIXES:
        if name.endswith(suffix):
            return name[:-len(suffix)].strip()
    return name

5.4 Candidate Cache 行為

  • 範圍:單一 reconcile() call lifetime
  • 觸發:第一個 fuzzy 階段才拉(exact / normalized 都中的 case 不浪費 query)
  • 位置:reconciler instance attribute(base class 的 self._candidate_cache
  • 生命週期:DI Factory provider,每次 build 新 instance → 等於每次 reconcile call 一個全新 cache,不跨 request

容量考量:dev tenant 102 目前 user < 100、org < 50;prod 預估 < 1000 user / < 200 org per tenant;一次 query 拉全 list 在 Python 端比對 < 50ms 可接受。若 prod tenant user > 10000 → follow-up F4(jedi-auth Query Entity 加 _in_email_domain DB-side filter)。

5.5 Edge case 行為

Case 行為
email_address 為 None / 空 UNMATCHED
name 為 None / 空 UNMATCHED
domain query exception 既有 try/except swallow → UNMATCHED(沿用 PartyReconciliationService 既有 line 71-72 + 92-93 pattern)
email 多 @(「a@b@c.com」) split('@', 1)[1] 取第一個 @ 後段,照原樣處理
email 沒 @(user 亂填) Stage 3 跳過,UNMATCHED
organization name 無尾綴 + 不中 UNMATCHED(Stage 3 跳過)

6. Caller 切換 + DI Wiring

6.1 三個 caller 切換策略

Caller A3 前 A3 後 變動範圍
docx flow — parse-time(SspDocxImportAppService.upload_and_parse:258-264 直接 self._reconciliation.reconcile(parties) 0 行變動(仍呼叫門面,門面內部委派) 0
docx flow — confirm-time(SspDocxImportAppService:489-490,write_parties 前) 同上 reconcile 既有呼叫 0 行變動(同上) 0
A2 superset flow(SspExcelImportAppService._confirm_superset_flow:251 inject 但沒呼叫reconcile 寫入 oscal_parties 前加 self._reconciliation.reconcile(parsed.parties_org + parsed.parties_person, tenant_id=...) +1~3 行
A2 update flow(SspExcelImportAppService._confirm_update_flow:325 同上 同上 +1~3 行

6.2 A2 寫入順序(superset / update flow 都套)

_confirm_*_flow(既有 @transaction scope)
  ↓
1. self._reconciliation.reconcile(...)   ← A3 新增
   → ParsedParty 帶上 matched_user_id / matched_org_unit_id / match_method / match_confidence
  ↓
2. ModuleFrameWriteStrategy.write_parties(parties=..., context_type='module_frame', context_id=mf_id)
   ← 既有寫入路徑不動;write_parties 內部只看 matched_*_id 是否 None
  ↓
3. parse_job.parsed_result 更新 = 加 match_method / match_confidence 標記
   ← 給 A5 預覽 UI 讀;不入 oscal_parties table

6.3 A3 期間 fuzzy 行為差異(已知妥協)

Caller A3 期間 fuzzy 處理
docx flow 既有 docx 是 parse + confirm 分階段(design-A1 §15.3 既有預覽 contract 已有 UI)— match_method=FUZZY_* 在預覽頁可 user 拍板
A2 path A5 預覽 UI 還沒做 — A3 期間 fuzzy 自動寫入(行為等同 matched);A5 上線後改為 user 拍板

列 design.md §11 follow-up F2:A5 phase 必補 A2 fuzzy 的 user 拍板路徑。

6.4 DI Container 變動

di_containers/oscal/oscal_containers.py

# 新增(user / org_unit domain service 跨 container 引用 auth_container,沿用既有 line 490/491 pattern)
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)
party_reconciliation_service = providers.Factory(
    PartyReconciliationService,
    person_reconciler=person_reconciler,
    organization_reconciler=organization_reconciler,
)

# 既有 caller wiring(line 550 + 574)完全不動
# - ssp_docx_import_app_service 仍 inject party_reconciliation_service
# - ssp_excel_import_app_service 仍 inject party_reconciliation_service

6.5 Cucumber Regression(compliance-manager-test repo)

新建 features/regression/module-frame/05-ssp-docx-import-party-match.feature,5 scenarios:

# Scenario 預期
1 docx 含 person email 完全相符 system user API response matched_user_id 填值,match_method='exact'
2 docx 含 organization name 完全相符 system org matched_org_unit_id 填值,match_method='exact'
3 docx 含 person email 完全不相符 matched_user_id is null,match_method='unmatched'
4 docx 含 person email +alias(alice+work@acme.com vs system alice@acme.com matched_user_id 填值,match_method='normalized'
5 docx 含 organization name 帶尾綴(「ACME 股份有限公司」vs system「ACME」) matched_org_unit_id 填值,match_method='fuzzy_name_prefix'fuzzy scenario

跑法:user 端 GitLab env 配齊後跑 — A3 ship 條件之一。Plan T6 標:若 env 未配齊,scenarios 撰寫完成可接受 partial ship(列 follow-up F-T8)

Ship 後 regression baseline:A3 shipped 後,docx import 的 party match regression baseline = 這 5 個 scenarios。任何後續 phase 動到 PartyReconciliationService / PersonReconciler / OrganizationReconciler / fuzzy 演算法時,這 5 個 scenarios 必過。Changelog 內標明此 baseline,給 A4 / A5 implementer 開工前 reference。


7. Pre-flight Verification 結果(已驗)

# 假設 結果
1 PartyReconciliationService caller 盤點 ✅ 兩個 caller:SspDocxImportAppService:58 + SspExcelImportAppService:50。DI 已 wire 兩端(oscal_containers.py:550, 574)。既有 8 unit test 在 tests/test_party_reconciliation_service.py
2 A2 confirm flow 沒實際串 reconcile handoff 假設屬實_confirm_superset_flow:251 + _confirm_update_flow:325 內無任何 reconcile() 呼叫
3 jedi-auth Query Entity 既有欄位 ⚠️ 暴露限制UserQueryEntity 只接 email / nickname / login_name exact,無 email_domain / 模糊查詢欄位OrgUnitQueryEntity 不接 name prefix / like。Q4 fuzzy 改用 client-side filter(拉全 tenant list)
4 cucumber 既有 docx import scenario 守 matched/unmatched ⚠️ 沒守 — 既有只有 features/showcase/training/01-pm-02c-resource-library-docx-import.feature(教學錄影級),無 regression scenario assert matched_user_id。A3 ship 前必補
5 test repo 結構 features/regression/module-frame/ + steps/module-frame/ + pages/module-frame/。A3 新 scenarios 放 features/regression/module-frame/05-*.feature

8. Acceptance Criteria


9. 風險與緩解

# 風險 影響 緩解
R1 A2 fuzzy 自動寫入跟 docx 行為短暫不一致(A3~A5 期間) user 在 A2 path 看不到 fuzzy 提示 接受妥協;plan T3 註解 + §11 follow-up F2 標 A5 必補
R2 candidate cache 在 prod 大 tenant(user > 10000)拉全 list 慢 reconcile 超時 A3 不處理;follow-up F4 — jedi-auth Query Entity 加 _in_email_domain
R3 docx parser 既有抽 email 帶空格 / 特殊字元邊界 case fuzzy 比對誤判 _normalize_name 規則寫嚴(不做 case-insensitive 英文比對);unit test cover 邊界
R4 Cucumber GitLab env 未配齊 → A3 regression 跑不過 ship 卡關 允許 partial ship(A1 / A2 已有先例);scenarios 撰寫完成等 env
R5 _strip_org_suffix 列表寫死無法 cover 中文公司類別變體(如「行」「商行」「企業」) fuzzy 漏配 A3 第一版列 5 種常見(股份有限公司 / 有限公司 / 公司 / Inc / Ltd / Corp / LLC);prod feedback 後擴充
R6 Generic 基類 + abstractmethod 對團隊新成員心智負擔 onboarding 慢 base.py 寫 docstring 解釋 4 個 hook 意圖(屬「non-obvious why」例外,允許寫 docstring)
R7 A2 _confirm_*_flow 串接後 transaction scope 跨多 domain service 寫入失敗 partial write 留垃圾 既有 @transaction scope 已 cover;reconcile + write_parties 同 scope 內,任一失敗整批 rollback

10. 跨 repo 工作

Repo 工作
compliance-manager-be(主) Architecture 抽出 + ParsedParty 欄位 + DI wiring + A2 串接 + fuzzy 演算法 + unit test + integration test
compliance-manager-fe A3 phase 不動 FE(match_method 標記已有 schema 接,預覽 UI fuzzy 互動在 A5 補)
jedi-* 不動(reconciliation 維持在主專案)
compliance-manager-test Cucumber regression:5 scenarios at features/regression/module-frame/05-ssp-docx-import-party-match.feature + page object 加新 step
Changelog docs/changelog/YYYY-MM-DD-feat-party-matcher-shared.md(type: feat、modules: oscal)

11. Implementation Reality / Reconciliation

(實作過程中對原始 design 的偏離 / 擴增紀錄;A3 Session E 收口時統一補。)

11.1 必出條目

  1. Test count §8 vs 實作:design §8 acceptance criterion 第 7 條原寫「新增 unit test ~38 個」漏算 13 個 _normalizers.py helper test_strip_plus_alias / _normalize_name / _strip_org_suffix)。實際 A3 ship 後 A3 + 既有 party reconciliation 套件 75 passed(13 normalizers + 7 base + 3 facade + 16 person + 16 org + 6 A2 integration + 4 ParsedParty fixture + 8 既有 party),其中 A3 新增 67 個。以 75 為實際 ship baseline。

  2. base.py _get_or_load_candidates exception 路徑:實作走「return [] 完全靜默 + 不寫入 cache」(subsequent call 仍重試 loader),與既有 docx parse-time app/oscal/service/ssp_docx_import_app_service.py:258-264try/except logger.warning swallow 行為不一致 — 後者會記錄 warning。

    • 判斷:base 內部 cache exception 屬「effort 性 retry」,污染 caller log 反而干擾 — 維持 silent return;caller side 的 _write_all_data 串接點仍走 logger.warning 樣式(沿用 docx pattern)。屬可接受差異。

11.2 Session C 累積偏差(T1+T2)

  1. Plan T1.7 — __init__.py export 範圍縮減:原 plan 要求 reconciliation/__init__.py export 4 個(BaseReconciliationService / MatchMethod / PersonReconciler / OrganizationReconciler)。實作時 export PersonReconciler / OrganizationReconciler 會 trigger 對 domain/oscal/parser/ssp_intermediate.py 的 circular import(reconciler 內部 import ParsedPartyParsedParty 內部 import MatchMethod),縮減為只 export 2 個(BaseReconciliationService + MatchMethod)。Reconciler 由 caller / DI 直接從 sub-module import — 不影響使用體驗。

  2. Plan T2.1 — 既有 8 個 test_party_reconciliation_service.py fixture 必須改注入:原 plan 寫「既有 8 test 0 行改動全綠」。實作上 facade __init__ signature 從「直接接 jedi_auth domain service」改為「接兩個 reconciler」後,fixture 必須改用 _make_facade() helper inject — assertion 仍 0 行改動。屬 plan 假設過於樂觀,不影響行為。

  3. Plan import path 細節:plan code snippet 寫 from jedi_auth.domain.entity.user_query_entity import UserQueryEntity(單數 entity),但既有 code + jedi-auth 套件實際路徑是 jedi_auth.domain.entities.user_query_entity(複數 entities)。實作對齊複數既有路徑 — minor 但留紀錄避免下次 implementer 重撞。

11.3 Session D 累積偏差(T3+T4)

  1. Plan T3.2/T3.3 — reconcile 插入點 vs 實際 DRY 受惠:原 plan 寫「_confirm_superset_flow + _confirm_update_flow 兩處各插一次 reconcile」。實作 verify 時發現兩 flow 都呼叫 _write_all_data — 插一處(_write_all_data 開頭,在 write_parties 前)即可 DRY 受惠,call chain 自動受惠

    • test 仍透過 confirm_import("u", {}, user_context)_confirm_*_flow 進入,reconcile.call_count == 1 assertion 成立。
  2. Plan T3.4 test fixture 簽章 vs 實際:原 plan 寫 decisions=[], overrides={} 參數,實際 _confirm_superset_flow(job, parsed_result, payload, user_context) 簽章不同。test 改用 confirm_import("u", {}, user_context) 走完整 path 比較自然 — 而非直接 call private method。

  3. 既有 29 個 test_ssp_excel_import_app_service.py fixture backward-compat 路徑:A3 改動 _write_all_data 後既有 29 test 不需改 fixture — party_reconciliation_service__init__ 是 optional default None_write_all_dataif self._reconciliation is not None 守門保住舊行為。新 A2 integration test 自己 inject mock_reconciliation。Backward-compat 路徑刻意保留,避免動既有 fixture noise。

11.4 行為差異 / A5 follow-up

  1. A3 期間 A2 fuzzy 自動寫入:design §1.2 + §6 寫 fuzzy 是「中間狀態」,但 A3 不引入預覽 UI(在 A5)。A3 期間 docx flow + A2 flow 兩條 path 對 fuzzy 結果都自動寫入(confidence=0.7 但 caller 看不到差別),與 unfortunate edge case 一致;A5 phase 才補預覽 UI 拍板路徑(已列 follow-up F2)。

11.5 已驗證對齊 design 無偏差條目

除上述 9 條外,A3 實作的下列項目完全對齊 design-A3:

  • §3.2 BaseReconciliationService 5 hook + reconcile() / _reconcile_one() / _get_or_load_candidates() shape
  • §5.1 Person 三階段 fallback algorithm(exact email → strip+alias normalized → domain+nickname/login_name fuzzy)
  • §5.2 Organization 三階段 fallback algorithm(exact name → normalize 全形半形 → strip suffix fuzzy)
  • §5.4 candidate cache lifecycle(per reconciler instance;exception 不污染 cache)
  • §6.1 DI wiring(person_reconciler / organization_reconciler Factory;門面 party_reconciliation_service 改注入兩 reconciler)
  • §10 跨 repo 工作劃分(BE 主 + compliance-manager-test cucumber,FE / jedi-* 不動)

12. 執行階段切分(5 session)

Session 範圍 Task 預估 Commit checkpoint 換 session 觸發
A(當前) Design 落地 brainstorm 收斂 + 寫 design-A3.md + spec review loop + user review 0.5d docs(ssp-import-export-phase2): A3 design.md design.md user 同意 + commit
B Plan 落地 invoke writing-plans → 寫 implementation-plan-A3.md + plan review + user review 0.25d docs(ssp-import-export-phase2): A3 implementation-plan.md plan.md user 同意 + commit
C 架構抽出 + docx 切換 T1 + T2(BaseReconciliationService + reconcilers + ParsedParty + 門面化 + DI) ~1d feat(oscal): A3 T1 base + reconcilers / feat(oscal): A3 T2 docx path facade + DI docx flow 100% 不變 + 既有 8 + 新增 ~30 unit test 全綠
D A2 串接 + fuzzy 演算法 T3(A2 confirm*_flow 補 reconcile)+ T4(fuzzy 演算法) ~0.75d feat(oscal): A3 T3 wire A2 confirm flows / feat(oscal): A3 T4 fuzzy match algorithm A2 path reconcile 通過 + fuzzy unit test 全綠
E Test 補齊 + cucumber + 收尾 T5(integration test)+ T6(cucumber regression 跨 test repo)+ T7(changelog + tracker + §11 reconciliation + final SUMMARY) ~1.25d test(oscal): A3 T5 integration tests / test(compliance-manager-test): A3 T6 cucumber regression / docs(ssp-import-export-phase2): A3 收尾 + SUMMARY A3 整段 shipped(partial ship 允許)

總計:5 session、~3.75d(含 plan/design 寫作 + 5 implementation sessions)

換 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/YYYY-MM-DD-a3-<session 字母 + 階段>-next.md
  4. Session E 是 task arc 收口 → 對話歷史按日期 dump 到 docs/conversation-history/<date>/ssp-import-export-phase2/

13. 設計決策溯源(brainstorm 拍板)

# 議題 拍板 理由摘要
Q1 Matcher 擺哪層 domain/oscal/service/ 既有位置 + 純 domain logic + 不跨層
Q2 Match 狀態 contract 三態 matched / fuzzy / unmatched,fuzzy 必進 user 確認 A1 VLOOKUP 未重算 / docx email 帶空格等常見 case 有救,同時避免 false positive 自動接受
Q3 docx 路徑切換策略 直接切換(無 feature flag) 內部 refactor 不該點火條雙路徑;cucumber regression 守
Q4 Fuzzy 演算法精細度 Normalize + email domain(person)/ name prefix(org) 規則明確、false positive 低;不走 Levenshtein(threshold 難拍板)
Q4' jedi-auth Query Entity 不支援 fuzzy filter Client-side filter(拉全 tenant list) A3 不動 jedi-* 套件;prod 大 tenant 才考慮 DB-side filter
Q5 跟 issue 修補 B 整併 不整併 A3 scope 純 user/org matcher;role normalize 屬 enum 規範問題、不同域
Q6 A4 擴展契約 Strategy Pattern + Generic Base BaseReconciliationService[TParsed, TEntity] 抽 hook contract 對 A4 5 個新 matcher 直接擴;剃除重複 batch 邏輯
Q3' A2 串接 reconcile 範圍 A3 順手串 A2 path design-A2 §3.2 本來就標 A3 要補;一次到位避免 A4/A5 dispatch 狀態混亂

完整 brainstorm 對話紀錄將於 Session E(task arc 收口)dump 到 docs/conversation-history/<YYYY-MM-DD>/ssp-import-export-phase2/


下一步:spec review loop → user review → 換 Session B invoke writing-plans 寫 implementation-plan-A3.md