const DISPLAY_PLACEHOLDERS: Record = { "[NAME]": "익명 내담자", "[ORG]": "소속 기관", "[PHONE]": "연락처", "[EMAIL]": "이메일", "[RRN]": "주민등록번호", "[NUMID]": "식별번호", "[DATE]": "날짜", "[MONEY]": "금액", "[ADDR]": "주소", "[ADDRESS]": "주소", }; function hasHangulBatchim(value: string) { for (let index = value.length - 1; index >= 0; index -= 1) { const code = value.charCodeAt(index); if (code >= 0xac00 && code <= 0xd7a3) return (code - 0xac00) % 28 !== 0; } return false; } function replacePlaceholderWithNaturalParticle( text: string, placeholder: string, label: string, ) { const batchim = hasHangulBatchim(label); // `이고`를 단일 조사 `이`보다 먼저 처리하지 않으면 // `[NAME]이고`가 `익명 내담자가고`로 깨진다. let next = text.split(`${placeholder}이고`).join(`${label}이고`); for (const [variants, particle] of [ [["으로", "로"], batchim ? "으로" : "로"], [["은", "는"], batchim ? "은" : "는"], [["이", "가"], batchim ? "이" : "가"], [["을", "를"], batchim ? "을" : "를"], [["과", "와"], batchim ? "과" : "와"], ] as const) { for (const variant of variants) { next = next.split(`${placeholder}${variant}`).join(`${label}${particle}`); } } return next.split(placeholder).join(label); } /** * 저장/API의 privacy-proof 토큰은 유지하고, 사람이 읽는 일반 대화 표면에서만 * 토큰을 안전한 설명으로 낮춘다. 근거 인용·내보내기에는 사용하지 않는다. */ export function displayPiiSafeText(text: string) { // 2026-08-09 이전 저장본에서 광범위한 성씨 휴리스틱이 평범한 용언을 // NAME으로 오탐한 두 실관측 문형. 원문은 복원할 수 없으므로 표시층에서만 // 결정적으로 복구하고 저장/API privacy proof는 그대로 둔다. let next = text.replace( /\[NAME\]는 건지\s+잘 \[NAME\]는데요/g, "되는 건지 잘 모르겠는데요", ); // 평가 문장에 이미 역할 명사가 있으면 이름 토큰을 다시 "익명 내담자"로 // 풀지 않는다. 특히 "내담자 [NAME]을 받을 여백"은 저장 마스킹 뒤 // 사람에게 "내담자 반응을 받을 여백"으로 보여야 한다. next = next .replace(/내담자\s+\[NAME\](?:을|를)/g, "내담자 반응을") .replace(/내담자\s+\[NAME\](?:이|가)/g, "내담자가") .replace(/내담자\s+\[NAME\](?:은|는)/g, "내담자는") .replace(/내담자\s+\[NAME\]의/g, "내담자의") .replace(/내담자\s+\[NAME\]/g, "내담자"); for (const [placeholder, label] of Object.entries(DISPLAY_PLACEHOLDERS)) { next = replacePlaceholderWithNaturalParticle(next, placeholder, label); } return next; }