Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions docs/fewshot-privacy-masking.md
Original file line number Diff line number Diff line change
@@ -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 승인과 비식별 검수를 대체하지 않습니다. 새로운 개인정보 유형이 발견되면 원문을
로그에 남기지 않은 채 대표 테스트를 먼저 추가하고 정책 범위를 갱신합니다.
3 changes: 2 additions & 1 deletion docs/fewshot-reviewed-loader.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,8 @@ analysis.few-shot.reviewed-evaluation-csv-path=/absolute/path/fewshot_candidates
- 기존 점수는 예시에 넣지 않았으며 보류 사례·문장도 다시 활성화하지 않았습니다.
- 데이터 및 프롬프트의 이메일·국내 휴대전화·HTTP URL 패턴 검사에서 일치 항목이 없었습니다.
이는 이름·소속·경험 조합을 통한 재식별 위험까지 보장하는 개인정보 정책 검증은 아닙니다.
원본 승인 범위는 유지하며 5번 이슈에서 정책을 별도로 확정해야 합니다.
원본 승인 범위는 유지합니다. 검색 입력 자동 마스킹과 수동 검수 범위는
`docs/fewshot-privacy-masking.md`에 정의했습니다.

### 부분 분석 계약

Expand Down
Original file line number Diff line number Diff line change
@@ -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)(?<![\\p{L}\\p{N}._%+-])[\\p{L}\\p{N}._%+-]+@[\\p{L}\\p{N}.-]+\\.[a-z]{2,}(?![\\p{L}\\p{N}._%+-])"
);
private static final Pattern URL_PATTERN = Pattern.compile(
"(?i)\\b(?:https?://|www\\.)[^\\s<>]+"
);
private static final Pattern KOREAN_PHONE_PATTERN = Pattern.compile(
"(?<!\\d)(?:(?:\\+82[- .]?(?:10|2|[3-6][1-5]|70))|0(?:2|1[016789]|[3-6][1-5]|70))[- .]?\\d{3,4}[- .]?\\d{4}(?!\\d)"
);
private static final Pattern RESIDENT_REGISTRATION_PATTERN = Pattern.compile(
"(?<!\\d)\\d{6}[- ]?[1-4]\\d{6}(?!\\d)"
);
private static final Pattern LABELED_NAME_PATTERN = Pattern.compile(
"(?im)(^|\\R)([ \\t]*(?:이름|성명|name)[ \\t]*[::=][ \\t]*)[^\\r\\n]+"
);
private static final Pattern LABELED_ADDRESS_PATTERN = Pattern.compile(
"(?im)(^|\\R)([ \\t]*(?:주소|address)[ \\t]*[::=][ \\t]*)[^\\r\\n]+"
);
private static final Pattern LABELED_ACCOUNT_PATTERN = Pattern.compile(
"(?im)(^|\\R)([ \\t]*(?:계정|아이디|사용자명|user(?:name)?|account)[ \\t]*[::=][ \\t]*)[^\\s,;]+"
);
private static final Pattern LABELED_INTERNAL_ID_PATTERN = Pattern.compile(
"(?im)(^|\\R)([ \\t]*(?:사번|학번|직원번호|employee[ _-]?id|student[ _-]?id)[ \\t]*[::=][ \\t]*)[^\\s,;]+"
);

public String mask(String value) {
if (!StringUtils.hasText(value)) {
return "";
}
String masked = RESIDENT_REGISTRATION_PATTERN.matcher(value).replaceAll("[RESIDENT_ID]");
masked = EMAIL_PATTERN.matcher(masked).replaceAll("[EMAIL]");
masked = KOREAN_PHONE_PATTERN.matcher(masked).replaceAll("[PHONE]");
masked = URL_PATTERN.matcher(masked).replaceAll("[URL]");
masked = LABELED_NAME_PATTERN.matcher(masked).replaceAll("$1$2[NAME]");
masked = LABELED_ADDRESS_PATTERN.matcher(masked).replaceAll("$1$2[ADDRESS]");
masked = LABELED_ACCOUNT_PATTERN.matcher(masked).replaceAll("$1$2[ACCOUNT]");
return LABELED_INTERNAL_ID_PATTERN.matcher(masked).replaceAll("$1$2[INTERNAL_ID]");
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.jobdri.jobdri_api.domain.analysis.service.ai.fewshot;

import org.springframework.stereotype.Component;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.util.StringUtils;
import org.springframework.web.util.HtmlUtils;

Expand All @@ -27,6 +28,17 @@ public class FewShotSearchTextBuilder {
private static final Pattern HTML_TAG_PATTERN = Pattern.compile("(?is)</?[a-z][a-z0-9:-]*\\b[^>]*>");
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]
Expand Down Expand Up @@ -95,36 +107,36 @@ public String buildCandidateDocument(FewShotCase fewShotCase) {
);
}

private static String lines(List<String> values, int maxLength) {
private String lines(List<String> 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<String> values, int maxLength) {
private String values(List<String> 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 "";
}
Expand All @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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를 개발했고 한국대학교에서 컴퓨터공학을 전공했습니다.");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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");
}
}
Loading