"""입출력 가드레일 — PII 마스킹 · 위기분류 · 출력 안전레일. MASTERPLAN §2.2 / §3.3 / R5 / R7 / F-03, MEMORY_DESIGN §B: [입력] Presidio PII 마스킹(미설치 시 정규식 폴백) + 위기분류(실제위기 vs 페르소나 연기) [출력] 자살수단/방법 정보 차단, ideation_stage 상한(<=3) 강제. 설계 원칙: - 모듈 경계 명확: 입력 가드레일(mask_pii / classify_crisis)과 출력 가드레일(sanitize_client_reply)을 순수함수에 가깝게 분리. IO·LLM·DB 의존 없음(테스트·재사용 용이). - 외부 LLM 경로 진입 전 *하드 게이트*: 마스킹 안 된 원문은 게이트웨이로 절대 안 나간다(F-03). - Presidio 와 한국어 NER adapter 는 선택 의존(미설치 환경에서도 import 가능해야 함) → 지연 로드/명시 등록 + 정규식 폴백. TODO(Phase 2): 한국어 NER adapter 실제 모델/provider 선정 + 한국어 자살콘텐츠 분류기 (JMIR few-shot 5단계, R8) 로 정밀화. 현재 정규식/키워드 폴백은 1차 안전망(재현율 우선). """ from __future__ import annotations import re from dataclasses import dataclass, field from difflib import SequenceMatcher from enum import Enum from typing import Iterable, Protocol from .state_machine import IDEATION_STAGE_CAP, clamp_ideation_stage # ── 출력 가드레일 상한 (R5) ────────────────────────────── CRISIS_HOTLINE_NUMBER = "109" CRISIS_HOTLINE_LABEL = "자살예방상담전화 109" CRISIS_RESOURCE_MESSAGE = ( "지금은 연습을 멈추고 실제 안전 확인이 먼저입니다. 즉시 위험하면 119 또는 가까운 " "응급실에 연락하고, 자살예방상담전화 109로 도움을 요청하세요." ) # ════════════════════════════════════════════════════════════════════════════ # 1. PII 마스킹 (입력 — 저장·외부전송 전 하드 게이트, F-03) # ════════════════════════════════════════════════════════════════════════════ # 정규식 폴백 패턴 (Presidio 미설치 시). 한국 맥락 우선. # TODO: 실제 한국어 NER adapter 로 정밀화(이름/주소/기관 NER). _KOREAN_SURNAME_CHARS = ( "김이박최정강조윤장임한오서신권황안송전홍유고문양손배백허남심노하" "곽성차주우구민류나진지엄채원천방공현함변염여추도소석선설마길연위표" "명기반왕금옥육인맹제모탁국어은편용예봉경" ) _KOREAN_FULL_NAME = rf"[{_KOREAN_SURNAME_CHARS}][가-힣]{{1,3}}" _KOREAN_FULL_NAME_BEFORE_SUFFIX = rf"[{_KOREAN_SURNAME_CHARS}][가-힣]{{1,3}}?" _KOREAN_CONTEXTLESS_NAME = rf"[{_KOREAN_SURNAME_CHARS}][가-힣]{{2,3}}" # 인명 마지막 글자로 사실상 쓰이지 않는 용언 활용/명사화 꼬리 글자. 문맥 단서가 # 없는 인명 패턴이 "유의점은·방치되는·마무리했을·연결감과·표현함을·어지러움은" # 같은 일반 단어를 이름으로 오탐해 평가 문장을 훼손하는 것을 막는다(정밀도 가드). _KOREAN_NAME_TAIL_GUARD = "(?[가-힣A-Za-z0-9·&().-]{2,30}?" r"(?:대학교|대학원|고등학교|중학교|초등학교|병원|의원|클리닉|상담센터|센터|복지관|교육청|보건소|연구소|재단|협회|학과|학부))" r"(?P\s*(?:입니다|이에요|예요|이고|이고요|에서|에|의|은|는|이|가|을|를)?)" r"(?=$|[\s,.;!?。])" ), ), # 한국어 이름: 이름/성명/실명 라벨 뒤 값. ( "NAME", re.compile( r"(?P(?:이름|성명|실명|본명)\s*[::]\s*)" r"(?P[가-힣]{2,4})" r"(?=$|[\s,.;!?。])" ), ), # 한국어 이름: "제 이름은 김서연입니다", "보호자 이름은 박민수입니다" 같은 자연 발화형 라벨. ( "NAME", re.compile( r"(?P(?:(?:제|저의|내|나의|보호자|학생|내담자|상담자|친구|엄마|아빠|어머니|아버지)\s+)?" r"(?:이름|성명|실명|본명)\s*(?:은|는|이|가)?\s*)" rf"(?P{_KOREAN_FULL_NAME_BEFORE_SUFFIX})" r"(?P\s*(?:입니다|이에요|예요|이고|이고요|이라고|라고)?)" r"(?=$|[\s,.;!?。])" ), ), # 한국어 이름: "저는 김서연입니다", "제가 박민수예요", "김서연입니다" 같은 자기소개형 문장. ( "NAME", re.compile( r"(?P(?:(?:저는|나는|제가|내가)\s*)?)" rf"(?P{_KOREAN_FULL_NAME_BEFORE_SUFFIX}){_KOREAN_NAME_TAIL_GUARD}" r"(?P\s*(?:입니다|이에요|예요|이고|이고요))" r"(?=$|[\s,.;!?。])" ), ), # 한국어 이름: 역할/관계 명사 뒤에 붙은 인명 + 조사/호칭. ( "NAME", re.compile( r"(?P(?:내담자|상담자|학생|보호자|담임|교수|선생님|친구|엄마|아빠|어머니|아버지|동생|언니|오빠|형|누나)\s+)" rf"(?P{_KOREAN_FULL_NAME_BEFORE_SUFFIX})" r"(?P\s*(?:님|씨|학생|상담자|내담자)?" r"(?:은|는|이|가|을|를|와|과|에게|한테|라고|이라는|입니다|이에요|예요|이고|이고요))" ), ), # 한국어 이름: 성씨 기반 full-name + 조사. 문맥 없는 순수 2~4글자 마스킹은 오탐이 커서 피한다. ( "NAME", re.compile( rf"(?{_KOREAN_CONTEXTLESS_NAME}){_KOREAN_NAME_TAIL_GUARD}" r"(?P(?:은|는|이|가|을|를|와|과|에게|한테|라고|이라는))" ), ), # 한국어 이름: "김서연 씨", "박민수님" 같은 명시 호칭. ( "NAME", re.compile( rf"(?{_KOREAN_FULL_NAME_BEFORE_SUFFIX})" r"(?P\s?(?:씨|님)(?:은|는|이|가|을|를|와|과|에게|한테|고|이고|인데)?)" r"(?=$|[\s,.;!?。])" ), ), # 주민등록번호 (6자리-7자리) ("RRN", re.compile(r"(? Iterable[PiiEntitySpan]: ... _KO_PII_RECOGNIZER: KoPiiRecognizer | None = None def set_ko_pii_recognizer(recognizer: KoPiiRecognizer | None) -> None: """Register an optional Korean PII recognizer. None keeps regex-only behavior.""" global _KO_PII_RECOGNIZER _KO_PII_RECOGNIZER = recognizer def _try_load_presidio(): """Presidio (analyzer, anonymizer) 지연 로드. 미설치면 (None, None).""" global _PRESIDIO_ANALYZER, _PRESIDIO_ANONYMIZER if _PRESIDIO_ANALYZER != -1: return _PRESIDIO_ANALYZER, _PRESIDIO_ANONYMIZER try: from presidio_analyzer import AnalyzerEngine # type: ignore from presidio_anonymizer import AnonymizerEngine # type: ignore _PRESIDIO_ANALYZER = AnalyzerEngine() _PRESIDIO_ANONYMIZER = AnonymizerEngine() except Exception: _PRESIDIO_ANALYZER = None _PRESIDIO_ANONYMIZER = None return _PRESIDIO_ANALYZER, _PRESIDIO_ANONYMIZER @dataclass(slots=True) class MaskResult: text_masked: str entities: list[str] = field(default_factory=list) # 탐지된 엔티티 타입들 used_presidio: bool = False used_ko_recognizer: bool = False def _mask_regex_pii( text: str, patterns: Iterable[tuple[str, re.Pattern[str]]] = _PII_PATTERNS, ) -> tuple[str, list[str]]: masked = text found: list[str] = [] def replace_match(label: str): def _replace(match: re.Match[str]) -> str: group = match.groupdict().get("value") if group is None: found.append(label) return f"[{label}]" if label == "NAME" and group in _KOREAN_NAME_STOPWORDS: return match.group(0) value_start = match.start("value") - match.start(0) value_end = match.end("value") - match.start(0) found.append(label) return f"{match.group(0)[:value_start]}[{label}]{match.group(0)[value_end:]}" return _replace for label, pat in patterns: masked = pat.sub(replace_match(label), masked) return masked, sorted(set(found)) def _mask_span_pii(text: str, spans: Iterable[PiiEntitySpan]) -> tuple[str, list[str]]: valid: list[PiiEntitySpan] = [] last_end = -1 for span in sorted(spans, key=lambda item: (item.start, item.end)): label = span.entity_type.strip().upper() if not label or span.start < 0 or span.end <= span.start or span.end > len(text): continue if span.start < last_end: continue last_end = span.end valid.append(PiiEntitySpan(label, span.start, span.end)) if not valid: return text, [] masked = text for span in sorted(valid, key=lambda item: item.start, reverse=True): masked = f"{masked[:span.start]}[{span.entity_type}]{masked[span.end:]}" return masked, sorted({span.entity_type for span in valid}) def _mask_ko_recognizer_pii(text: str) -> tuple[str, list[str], bool]: recognizer = _KO_PII_RECOGNIZER if recognizer is None: return text, [], False try: masked, entities = _mask_span_pii(text, recognizer.analyze(text)) return masked, entities, bool(entities) except Exception: return text, [], False def mask_pii(text: str) -> MaskResult: """PII 마스킹. Presidio 가용 시 우선, 아니면 정규식 폴백. 반환 text_masked 만 저장(turns.text_masked)·외부 LLM 전송에 사용한다(F-03). """ if not text: return MaskResult(text_masked=text, entities=[], used_presidio=False) analyzer, anonymizer = _try_load_presidio() if analyzer is not None and anonymizer is not None: try: results = analyzer.analyze(text=text, language="en") # TODO: ko 모델 등록 시 language="ko" ents = sorted({r.entity_type for r in results}) anonymized = anonymizer.anonymize(text=text, analyzer_results=results) ko_masked, ko_ents, used_ko = _mask_ko_recognizer_pii(anonymized.text) masked, regex_ents = _mask_regex_pii(ko_masked) return MaskResult( text_masked=masked, entities=sorted(set(ents + ko_ents + regex_ents)), used_presidio=True, used_ko_recognizer=used_ko, ) except Exception: pass # 폴백으로 # 정규식 폴백 ko_masked, ko_ents, used_ko = _mask_ko_recognizer_pii(text) masked, found = _mask_regex_pii(ko_masked) return MaskResult( text_masked=masked, entities=sorted(set(ko_ents + found)), used_presidio=False, used_ko_recognizer=used_ko, ) def mask_synthetic_generated_pii(text: str) -> MaskResult: """PII gate for model-generated text from validated synthetic sources. Explicit name labels, self-introductions, relationship/name contexts, honorifics, optional recognizers, Presidio and every non-name PII pattern stay enabled. Only the ambiguous contextless Korean surname heuristic is omitted. """ if not text: return MaskResult(text_masked=text, entities=[], used_presidio=False) analyzer, anonymizer = _try_load_presidio() if analyzer is not None and anonymizer is not None: try: results = analyzer.analyze(text=text, language="en") ents = sorted({r.entity_type for r in results}) anonymized = anonymizer.anonymize(text=text, analyzer_results=results) ko_masked, ko_ents, used_ko = _mask_ko_recognizer_pii(anonymized.text) masked, regex_ents = _mask_regex_pii( ko_masked, _SYNTHETIC_GENERATED_PII_PATTERNS, ) return MaskResult( text_masked=masked, entities=sorted(set(ents + ko_ents + regex_ents)), used_presidio=True, used_ko_recognizer=used_ko, ) except Exception: pass ko_masked, ko_ents, used_ko = _mask_ko_recognizer_pii(text) masked, found = _mask_regex_pii( ko_masked, _SYNTHETIC_GENERATED_PII_PATTERNS, ) return MaskResult( text_masked=masked, entities=sorted(set(ko_ents + found)), used_presidio=False, used_ko_recognizer=used_ko, ) _IDENTITY_SEGMENT_RE = re.compile(r"\s*[·|,/]\s*", re.UNICODE) _IDENTITY_UUID_RE = re.compile( r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", re.IGNORECASE, ) _IDENTITY_KO_PARTICLE_LOOKAHEAD = ( r"(?:은|는|이|가|을|를|와|과|의|도|에게|께|랑|하고|님|씨)" ) def _known_identity_variants(identity: str | None) -> list[str]: """Return conservative display-name variants that are safe to role-tokenize.""" raw = str(identity or "").strip() if not raw or "@" in raw or _IDENTITY_UUID_RE.fullmatch(raw): return [] first_segment = _IDENTITY_SEGMENT_RE.split(raw, maxsplit=1)[0].strip() first_segment = re.sub(r"\s*\(가명\)\s*$", "", first_segment).strip() variants: list[str] = [] for candidate in (raw, first_segment): if candidate in variants: continue letters = re.sub(r"[^A-Za-z가-힣]", "", candidate) if len(letters) < 2: continue variants.append(candidate) return sorted(variants, key=len, reverse=True) def mask_role_identities( text: str, *, counselor_identity: str | None = None, client_identity: str | None = None, synthetic_generated: bool = False, ) -> MaskResult: """Mask known session identities with role tokens, then apply the normal PII gate. Only identities already owned by the authenticated session are role-tokenized. Unknown third-party names keep the generic ``[NAME]`` token so the UI cannot incorrectly present every person as the client. """ role_values = ( ("ROLE_COUNSELOR", "[COUNSELOR]", counselor_identity), ("ROLE_CLIENT", "[CLIENT]", client_identity), ) redacted = text role_entities: list[str] = [] claimed_variants: set[str] = set() for entity, placeholder, identity in role_values: for variant in _known_identity_variants(identity): normalized = variant.casefold() if normalized in claimed_variants: continue pattern = ( rf"(? bool: """양성 구문과 실제로 겹치는 부정·인용·타인·질문 문맥만 무효화한다.""" for pattern in _INVALID_REAL_CRISIS_CONTEXTS: for context in pattern.finditer(text): if match.start() < context.end() and context.start() < match.end(): return True return False def _current_personal_danger_help(text: str) -> bool: """현재의 개인적 위험과 실제 도움 필요를 함께 밝힌 짧은 진술인지 확인한다.""" if len(text) > 240: return False danger_matches = [ match for pattern in (*_PERSONAL_DANGER_PATTERNS, *_PERSONAL_IMMEDIATE_HARM_PATTERNS) for match in pattern.finditer(text) if not _match_has_invalid_crisis_context(text, match) ] help_matches = [ match for match in _REAL_HELP_CUE.finditer(text) if not _match_has_invalid_crisis_context(text, match) ] return any( abs(danger.start() - help_match.start()) <= 160 for danger in danger_matches for help_match in help_matches ) def _first_person_current_crisis(text: str) -> bool: if len(text) > 240: return False if any( not _match_has_invalid_crisis_context(text, match) for pattern in ( *_FIRST_PERSON_CURRENT_CRISIS_PATTERNS, *_FIRST_PERSON_EXPLICIT_CRISIS_PATTERNS, ) for match in pattern.finditer(text) ): return True direct_matches = [ match for match in re.finditer(_CURRENT_SUICIDE_OR_SELF_HARM, text) if not _match_has_invalid_crisis_context(text, match) ] help_matches = [ match for match in _REAL_HELP_CUE.finditer(text) if not _match_has_invalid_crisis_context(text, match) ] return any( abs(direct.start() - help_match.start()) <= 160 for direct in direct_matches for help_match in help_matches ) def _crisis_signal_matches_clause(text: str) -> list[str]: """한 절 안에서만 부정·인용·질문의 범위를 적용해 위기 단서를 찾는다.""" matched: list[str] = [] for term in _CRISIS_TERMS: for term_match in re.finditer(re.escape(term), text): if not _match_has_invalid_crisis_context(text, term_match): matched.append(term) break if any( not _match_has_invalid_crisis_context(text, match) for match in _INDIRECT_SELF_ERASURE_CUE.finditer(text) ): matched.append(_INDIRECT_SELF_ERASURE) if _current_personal_danger_help(text): matched.append(_CURRENT_PERSONAL_DANGER_HELP) if _first_person_current_crisis(text): matched.append(_FIRST_PERSON_CURRENT_CRISIS) if any( not _match_has_invalid_crisis_context(text, match) for match in _EXPLICIT_HIGH_RISK_CUE.finditer(text) ): matched.append(_EXPLICIT_HIGH_RISK) return matched def crisis_signal_matches(text: str) -> list[str]: """화자 판정 전의 위기 내용 단서를 반환한다. 가상내담자 출력처럼 1인칭 표현이 정상인 경로에서는 이 함수로 내용 존재만 확인하고, 실제 수련생 위기 여부는 :func:`classify_crisis`가 별도로 판정한다. 대조 접속이나 문장 경계를 넘어서 부정·인용 문맥을 공유하지 않도록 절별로 판정하고, 같은 단서는 최초 한 번만 반환한다. """ if not text: return [] matched: list[str] = [] clauses = (part.strip() for part in _CRISIS_CLAUSE_SPLITTER.split(text)) for clause in clauses: if not clause: continue for signal in _crisis_signal_matches_clause(clause): if signal not in matched: matched.append(signal) # 실제 도움 요청은 짧은 두 문장으로 끊어 말할 수 있으므로 위험 진술과 도움 # 요청의 인접성만큼은 원문 전체에서 한 번 더 결합한다. if _current_personal_danger_help(text) and _CURRENT_PERSONAL_DANGER_HELP not in matched: matched.append(_CURRENT_PERSONAL_DANGER_HELP) if _first_person_current_crisis(text) and _FIRST_PERSON_CURRENT_CRISIS not in matched: matched.append(_FIRST_PERSON_CURRENT_CRISIS) return matched def classify_crisis(text: str, *, speaker_is_persona_context: bool = True) -> CrisisResult: """위기 분류. Args: speaker_is_persona_context: True 면 상담 시뮬레이션 발화(수련생→가상내담자) 맥락. 이 경우 위기 표현은 기본 PERSONA_PLAY 로 본다(연기). 단 1인칭 실제 단서가 강하면 LEARNER_REAL 로 승격해 에스컬레이션(보수적, 재현율 우선). NOTE: 가상내담자(P1)의 자살사고 연기는 시뮬레이션 핵심이라 차단 대상이 아니다. 진짜 위험은 *수련생 본인*의 실제 위기 신호다. """ if not text: return CrisisResult(kind=CrisisKind.NONE) matched = crisis_signal_matches(text) if not matched: return CrisisResult(kind=CrisisKind.NONE) risk = min(5, 2 + len(matched)) # 거친 단계화(휴리스틱) explicit_real_signal = _CURRENT_PERSONAL_DANGER_HELP in matched explicit_current_crisis = _FIRST_PERSON_CURRENT_CRISIS in matched explicit_high_risk = _EXPLICIT_HIGH_RISK in matched if ( not speaker_is_persona_context or explicit_real_signal or explicit_current_crisis or explicit_high_risk ): # 실제 위기 가능 → 에스컬레이션(보수적) return CrisisResult( kind=CrisisKind.LEARNER_REAL, risk_level=max(risk, 4), matched=matched, escalate=True, ) # 시뮬레이션 연기 맥락 return CrisisResult( kind=CrisisKind.PERSONA_PLAY, risk_level=risk, matched=matched, escalate=False, ) # ════════════════════════════════════════════════════════════════════════════ # 3. 출력 가드레일 (내담자 AI 응답 — 자살수단 차단, ideation 상한 R5) # ════════════════════════════════════════════════════════════════════════════ # 자살/자해 '수단·방법' 정보 패턴 (절대 출력 금지). 정서 표현은 허용. # 1.1 확장(2026-07-15): DBT 자해 페르소나 대비 자해 도구·방법 어휘 보강. # 과차단 주의 — "선을 긋다"(경계 설정) 같은 정상 상담 표현과 겹치지 않는 조합만 추가. # 1.2 축소·확장(2026-09-29): "독"·"방법은" 부분문자열이 "고독"·"독립"·"독서"·"독특"· # "다른 방법은"·"표현 방법은" 같은 정상 정서 표현을 오탐 차단하여 제거하고, # 실제 수단 정보만 가리키는 구체 조합으로 대체. _MEANS_TERMS = [ "약을 얼마나", "몇 알", "몇 정", "투신", "목을 매", "목 매", "번개탄", "농약", "손목을", "칼로", "뛰어내리", "수면제 ", "어떻게 죽", "커터", "면도날", "손목 긋", "깊게 그으", "라이터로 지", "독약", "독극물", "음독", "독을 먹", "독을 마시", "독을 타", "죽는 방법", "죽을 방법", "죽는 법", "자살 방법", "자살하는 방법", "자해 방법", "자해하는 방법", ] _DISPLAY_PLACEHOLDER_LABELS = { "COUNSELOR": "상담자", "CLIENT": "내담자", "NAME": "그 이름", "ORG": "그 기관", "PHONE": "연락처", "EMAIL": "이메일", "RRN": "식별번호", "NUMID": "식별번호", "DATE": "그 날짜", "MONEY": "그 금액", "ADDR": "그 주소", } _DISPLAY_PLACEHOLDER_RE = re.compile( r"\[(?P