diff --git a/docs/fewshot-privacy-masking.md b/docs/fewshot-privacy-masking.md new file mode 100644 index 0000000..382ab41 --- /dev/null +++ b/docs/fewshot-privacy-masking.md @@ -0,0 +1,44 @@ +# Few-shot 검색 개인정보 마스킹 정책 + +## 적용 범위 + +마스킹은 Cohere embedding으로 보내는 다음 검색 텍스트에 적용합니다. + +- 실시간 분석 질의의 JD, 문항, 답변 +- 승인 Few-shot 후보의 직무 정보, 문항, `sanitizedAnswer`, 태그 + +HTML 제거 후 마스킹하고, 그다음 공백 정규화와 길이 제한을 적용합니다. 동일한 마스커를 질의와 +후보 양쪽에 사용하므로 대체 토큰도 동일합니다. 원래 분석 프롬프트와 DB 원문을 변경하지 않습니다. + +## 자동 마스킹 + +| 유형 | 대체값 | +| --- | --- | +| 이메일 | `[EMAIL]` | +| 국내 전화번호·휴대전화 | `[PHONE]` | +| HTTP(S)·www URL | `[URL]` | +| 주민등록번호 형식 | `[RESIDENT_ID]` | +| `이름:`, `성명:`, `name:` 라벨 값 | `[NAME]` | +| `주소:`, `address:` 라벨 값 | `[ADDRESS]` | +| 계정·아이디·username 라벨 값 | `[ACCOUNT]` | +| 사번·학번·직원번호 라벨 값 | `[INTERNAL_ID]` | + +검색 및 Cohere 호출 로그에는 원문 검색 텍스트나 embedding 벡터를 기록하지 않습니다. 선택 로그와 +평가 sidecar에는 case ID, 후보 ID, source, 점수와 호출 횟수만 기록합니다. + +## 자동 마스킹하지 않는 항목 + +회사명·학교명은 직무와 경험의 의미를 크게 바꿀 수 있고, 일반 명사와의 구분도 불확실하므로 +일괄 제거하지 않습니다. 라벨 없는 사람 이름과 자유 형식 주소도 정규식만으로 안전하게 식별할 수 +없어 자동 탐지 범위 밖입니다. + +따라서 승인 후보는 PM 검수 단계에서 다음을 계속 확인해야 합니다. + +- 사람 이름과 자유 형식 주소 제거 +- 비공개 회사·학교 내부 프로젝트명 및 조직명 일반화 +- 고객명, 거래처명, 계정명, 사번·학번 등 내부 식별자 제거 +- 개인 포트폴리오·SNS·저장소 URL 제거 +- 여러 비식별 정보의 조합으로 특정인을 재식별할 수 있는지 확인 + +자동 마스킹은 PM 승인과 비식별 검수를 대체하지 않습니다. 새로운 개인정보 유형이 발견되면 원문을 +로그에 남기지 않은 채 대표 테스트를 먼저 추가하고 정책 범위를 갱신합니다. diff --git a/docs/fewshot-reviewed-loader.md b/docs/fewshot-reviewed-loader.md index 55ab265..247f840 100644 --- a/docs/fewshot-reviewed-loader.md +++ b/docs/fewshot-reviewed-loader.md @@ -52,7 +52,8 @@ analysis.few-shot.reviewed-evaluation-csv-path=/absolute/path/fewshot_candidates - 기존 점수는 예시에 넣지 않았으며 보류 사례·문장도 다시 활성화하지 않았습니다. - 데이터 및 프롬프트의 이메일·국내 휴대전화·HTTP URL 패턴 검사에서 일치 항목이 없었습니다. 이는 이름·소속·경험 조합을 통한 재식별 위험까지 보장하는 개인정보 정책 검증은 아닙니다. - 원본 승인 범위는 유지하며 5번 이슈에서 정책을 별도로 확정해야 합니다. + 원본 승인 범위는 유지합니다. 검색 입력 자동 마스킹과 수동 검수 범위는 + `docs/fewshot-privacy-masking.md`에 정의했습니다. ### 부분 분석 계약 diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/ai/fewshot/FewShotPrivacyMasker.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/ai/fewshot/FewShotPrivacyMasker.java new file mode 100644 index 0000000..baedebf --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/service/ai/fewshot/FewShotPrivacyMasker.java @@ -0,0 +1,48 @@ +package com.jobdri.jobdri_api.domain.analysis.service.ai.fewshot; + +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; + +import java.util.regex.Pattern; + +@Component +public class FewShotPrivacyMasker { + private static final Pattern EMAIL_PATTERN = Pattern.compile( + "(?i)(?]+" + ); + private static final Pattern KOREAN_PHONE_PATTERN = Pattern.compile( + "(?]*>"); private static final Pattern WHITESPACE_PATTERN = Pattern.compile("[\\p{Z}\\s]+"); + private final FewShotPrivacyMasker privacyMasker; + + FewShotSearchTextBuilder() { + this(new FewShotPrivacyMasker()); + } + + @Autowired + public FewShotSearchTextBuilder(FewShotPrivacyMasker privacyMasker) { + this.privacyMasker = privacyMasker; + } + public String buildQueryText(FewShotSearchQuery query) { return """ [JOB_CATEGORY] @@ -95,36 +107,36 @@ public String buildCandidateDocument(FewShotCase fewShotCase) { ); } - private static String lines(List values, int maxLength) { + private String lines(List values, int maxLength) { if (values == null || values.isEmpty()) { return ""; } String joined = values.stream() .filter(StringUtils::hasText) - .map(FewShotSearchTextBuilder::normalize) + .map(this::normalize) .filter(StringUtils::hasText) .map(line -> line.startsWith("-") ? line : "- " + line) .collect(Collectors.joining("\n")); return truncate(joined, maxLength); } - private static String values(List values, int maxLength) { + private String values(List values, int maxLength) { if (values == null || values.isEmpty()) { return ""; } String joined = values.stream() .filter(StringUtils::hasText) - .map(FewShotSearchTextBuilder::normalize) + .map(this::normalize) .filter(StringUtils::hasText) .collect(Collectors.joining(", ")); return truncate(joined, maxLength); } - private static String value(String value, int maxLength) { + private String value(String value, int maxLength) { return truncate(normalize(value), maxLength); } - private static String normalize(String value) { + private String normalize(String value) { if (!StringUtils.hasText(value)) { return ""; } @@ -133,7 +145,8 @@ private static String normalize(String value) { String withoutComments = HTML_COMMENT_PATTERN.matcher(withoutExecutableContent).replaceAll(" "); String withBlockSeparators = BLOCK_HTML_TAG_PATTERN.matcher(withoutComments).replaceAll(" "); String withoutTags = HTML_TAG_PATTERN.matcher(withBlockSeparators).replaceAll(""); - return WHITESPACE_PATTERN.matcher(withoutTags).replaceAll(" ").trim(); + String privacyMasked = privacyMasker.mask(withoutTags); + return WHITESPACE_PATTERN.matcher(privacyMasked).replaceAll(" ").trim(); } private static String truncate(String value, int maxLength) { diff --git a/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/ai/fewshot/FewShotPrivacyMaskerTest.java b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/ai/fewshot/FewShotPrivacyMaskerTest.java new file mode 100644 index 0000000..de816f9 --- /dev/null +++ b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/ai/fewshot/FewShotPrivacyMaskerTest.java @@ -0,0 +1,39 @@ +package com.jobdri.jobdri_api.domain.analysis.service.ai.fewshot; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class FewShotPrivacyMaskerTest { + private final FewShotPrivacyMasker masker = new FewShotPrivacyMasker(); + + @Test + @DisplayName("검색 입력의 대표 개인정보 유형을 정해진 토큰으로 마스킹한다") + void masksRepresentativePersonalInformation() { + String masked = masker.mask(""" + 이름: 홍길동 + 주소: 서울시 강남구 테헤란로 1 + 이메일 test.user@example.com, 전화 010-1234-5678 + 해외 표기 +82 10 9876 5432, 인터넷 전화 070-1111-2222 + 주민번호 900101-1234567 + 계정: hong_dev + 사번: EMP-1024 + 포트폴리오 https://example.com/users/hong?q=1 + """); + + assertThat(masked) + .contains("이름: [NAME]", "주소: [ADDRESS]", "[EMAIL]", "[PHONE]", "[RESIDENT_ID]", + "계정: [ACCOUNT]", "사번: [INTERNAL_ID]", "[URL]") + .doesNotContain("홍길동", "테헤란로", "test.user@example.com", "010-1234-5678", + "+82 10 9876 5432", "070-1111-2222", "900101-1234567", "hong_dev", + "EMP-1024", "example.com"); + } + + @Test + @DisplayName("직무 의미가 있는 회사명과 학교명은 라벨 없는 일반 문장에서 유지한다") + void keepsOrganizationsThatCarryExperienceMeaning() { + assertThat(masker.mask("네이버에서 검색 API를 개발했고 한국대학교에서 컴퓨터공학을 전공했습니다.")) + .isEqualTo("네이버에서 검색 API를 개발했고 한국대학교에서 컴퓨터공학을 전공했습니다."); + } +} diff --git a/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/ai/fewshot/FewShotSearchTextBuilderTest.java b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/ai/fewshot/FewShotSearchTextBuilderTest.java index ee03128..43d35c4 100644 --- a/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/ai/fewshot/FewShotSearchTextBuilderTest.java +++ b/src/test/java/com/jobdri/jobdri_api/domain/analysis/service/ai/fewshot/FewShotSearchTextBuilderTest.java @@ -108,4 +108,26 @@ void truncatesAnswerAfterNormalization() { .isPositive() .isLessThanOrEqualTo(FewShotSearchTextBuilder.MAX_ANSWER_LENGTH); } + + @Test + @DisplayName("실시간 질의와 승인 후보 문서를 embedding 전에 동일하게 마스킹한다") + void masksPrivacyInQueryAndCandidateDocument() { + String queryText = builder.buildQueryText(new FewShotSearchQuery( + "EV-01", "개발", "백엔드", List.of("API 개발"), List.of("Java"), "경험", + "이름: 홍길동\n연락처 010-1234-5678, test@example.com" + )); + FewShotCase candidate = new FewShotCase( + "FS-01", FewShotSource.REVIEWED_EVALUATION, FewShotReviewStatus.APPROVED, true, 1, + "개발", "백엔드", List.of("API 개발"), List.of("Java"), "경험", + "주소: 서울시 강남구\nhttps://portfolio.example.com/me", "{}", List.of(), "v1", "prompt" + ); + String candidateText = builder.buildCandidateDocument(candidate); + + assertThat(queryText) + .contains("[NAME]", "[PHONE]", "[EMAIL]") + .doesNotContain("홍길동", "010-1234-5678", "test@example.com"); + assertThat(candidateText) + .contains("[ADDRESS]", "[URL]") + .doesNotContain("강남구", "portfolio.example.com"); + } }