C-5 개인정보 실시간 마스킹 — P1~P5 규칙 구현

코어 사수 항목. 절대 규칙(누락 0건)이 걸려 있고, 이게 없으면 POST /hub/transcripts 가 501 이라 파이프라인 전체가 데이터를 못 받는다.

담당 이관 (2026-08-27): 원래 정성윤 담당이었으나 티켓도 코드도 없는 착수 전 상태였고 부재가 겹쳐 장민석이 넘겨받았다. 근거·되돌리는 법: _project/decisions/019-C-5-담당-이관.md. 정성윤 복귀 시 가장 먼저 공유할 것.

구현

2.4절 탐지 파이프라인 순서를 그대로 따랐다.

server/apps/masking/
├── domain/value_objects/pii_pattern.py     PiiSpan (문자 오프셋) · MASK_CHAR
├── domain/services/number_normalizer.py    ① 구분자 제거  ② 한글 수사 변환
├── domain/services/pii_detector.py         ③ 패턴 매칭 (P1~P5)
├── domain/services/masker.py               ④ 마스킹
└── adapter/outbound/rule_masking_adapter.py  MaskingPort 구현

hub/dependencies/masking_provider.py501 에서 실제 구현으로 바뀌었다.

①이 ②보다 앞이다

V3 실측상 Google STT 한국어는 긴 숫자열을 대체로 아라비아 숫자로 정규화하므로, 한글 수사보다 구분자 부재·띄어쓰기 붕괴가 지배적 실패 모드다. 명세가 정한 순서 그대로다.

"010-1234-5678" · "010 1234 5678" · "01012345678" · "(010)1234-5678" 를 전부 같은 번호로 잡는다.

⚠ 구현 중 실제로 터진 것 — 한글 수사가 일상어를 먹는다

"01012345678이고" 에서 조사 “이”가 숫자 2로 바뀌어 번호 구간이 뒤 글자까지 번졌다. "이사"·"사과"·"구입" 도 같은 문제다.

연속 3자 이상일 때만 낭독형으로 본다. "공일공일이삼사오육칠팔" 은 잡히고 "이고" 는 안 잡힌다. 명세가 ②를 “보조”라고 한 이유가 이것이다.

겹치면 넓은 쪽을 남긴다

카드(14~16)와 계좌(10~14)는 자릿수가 겹친다. 좁은 쪽을 고르면 뒷자리가 노출되므로 넓은 쪽을 남긴다 — 누락 0건 우선(2.4절 지표 우선순위)을 코드로 고정한 것이다.

P5 만 문맥 조건이 있다

4~6자리는 금액·개수·연도와 구분되지 않는다. 문맥 단어(인증·승인·확인번호·코드·OTP)가 같은 발화에 있을 때만 잡는다 — 명세가 정한 조건이다. 문맥 없이 전부 가리면 "3천원"·"2개"·"2026년" 까지 지워져 자막 자체가 못 쓰게 된다.

아직 done 이 아닌 이유 — P6·P7 미구현

인명(P6)·상세주소(P7)는 NER 모델이 필요해 아직 없다. 어댑터가 SUPPORTED_PATTERNS / UNSUPPORTED_PATTERNS 로 그 사실을 드러낸다 — 평가 하네스가 “무엇을 못 잡는지” 알아야 하기 때문이다. 숨기지 않는다.

모델 구성(_project/decisions/010-AI-모델-구성-확정.md)상 P7(상세주소)은 naver-nerLOC 태그만으로는 부족해 주소 키워드 우측 확장 규칙이 함께 필요하다(2026-08-26 조사).

완료 조건

P1~P7 전부에 대해 누락 0건. 현재 P1~P5 까지 도달했고 골든셋 채점은 평가 하네스가 한다.

검증: cd server && pytest 155개 통과(125→155, 마스킹 30건) · 계약 4종 KEPT (masking.domain 이 생기며 도메인 순수성 계약이 처음 활성화됐다).


✅ P6·P7 완료 (2026-08-27, 장민석) — done

골든셋으로 실패를 먼저 재현하고 고쳤다. golden-set/v1-50.json 의 C-5 케이스 10건에는 P6·P7 정답 구간이 이미 붙어 있었고, P1~P5 만으로는 누락 3건이 났다.

(전) 포착  9건 · 누락 3건   ← GS-032 P6 '김민준' · GS-033 P7 '서울시 …456호' · GS-037 P6 '박서연'
(후) 포착 12건 · 누락 0건

과잉 마스킹도 0글자다 — 10건 전부 가린 글자 수 = 정답 구간 길이로 일치한다.

⚠ NER 이 아니다 — 규칙으로 바닥을 깐 것이다

2.4절은 P6·P7 을 NER 로 정했고 그 판단은 유효하다. 다만 server/.importlinter 계약 2 가 server/ 안의 transformers import 를 금지하므로 모델은 ai/이다. 0% 로 두는 것보다 낫고 「누락 0건 > 과잉 마스킹 억제」에도 맞아 규칙을 먼저 넣었다.

  잡는 조건 못 잡는 것
P6 인명 이름을 밝히는 문맥(이름은·성함이·명의자는) 뒤 "그 김민준 씨가" — 문맥 없는 이름
P7 상세주소 주소 어절 2개 이상 + 행정구역 표지 "테헤란로요" — 단독 어절

어댑터가 PARTIAL_PATTERNS = ("P6","P7") 로 이 사실을 드러낸다 — 평가 하네스가 “무엇을 어떤 방식으로 잡았는지” 알아야 수치를 해석할 수 있다. UNSUPPORTED_PATTERNS 는 비었다.

자체 테스트가 과잉 마스킹 2건을 잡았다

부정 케이스를 같이 쓴 덕에 구현 중 실제로 걸렸다. 둘 다 일상어를 개인정보로 오인한 것이다.

골든셋 회귀 테스트를 CI 에 걸었다

test_golden_set_masking.py — 골든셋 C-5 케이스를 그대로 채점한다(규칙 기반, 절대 원칙 1). 6.2절대로 평균이 아니라 건 단위로 실패시키고, 케이스가 0건이면 “빈 채로 초록불”이 되지 않게 별도 테스트가 막는다. 골든셋이 150건으로 커지면 자동으로 늘어난다.

검증: server 200개 통과(155→200), 계약 4종 KEPT, 실제 HTTP 경로 (POST /hub/transcripts)에서 P6·P7 구간이 오프셋까지 정확히 나오는 것을 확인했다.