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.py 가 501 에서 실제 구현으로 바뀌었다.
①이 ②보다 앞이다
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-ner 의 LOC 태그만으로는 부족해
주소 키워드 우측 확장 규칙이 함께 필요하다(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건을 잡았다
부정 케이스를 같이 쓴 덕에 구현 중 실제로 걸렸다. 둘 다 일상어를 개인정보로 오인한 것이다.
"이름을 바꾸고 싶어요"→"이름을 **고 싶어요". 목적격 조사(을·를)를 문맥에서 뺐다 —"이름을 X"는 이름이 무엇인지 밝히는 말이 아니라 이름을 대상으로 하는 말이다. 홑글자 조사"고"도 벗기기 목록에서 뺐다("바꾸고"→"바꾸"가 이름이 되어버린다)"전국 어디로 보내도 되나요"→ 주소로 오인.도를 접미 규칙에서 빼고 광역단체 17개를 닫힌 목록으로 바꿨다."보내도"·"되나요"류 어미가 전부 행정구역으로 잡히고 있었다
골든셋 회귀 테스트를 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 구간이 오프셋까지 정확히 나오는 것을 확인했다.