vignette/apps/api/app/services/guardrail.py
2026-08-28 20:20:15 +09:00

1096 lines
45 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""입출력 가드레일 — 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 = "(?<![했됐되된될겠것듯점음움됨함감])"
_KOREAN_NAME_STOPWORDS = {
"연락",
"연락처",
"이메일",
"주민번호",
"번호",
"이름",
"이야기",
"생각",
"마음",
"기분",
"상담",
"기록",
"진료",
"학교",
"엄마",
"아빠",
"어머니",
"아버지",
"친구",
"내담자",
"상담자",
"선생님",
"소속",
"안내",
}
_PII_PATTERNS: list[tuple[str, re.Pattern[str]]] = [
# 한국어 기관/소속명: 학교·병원·센터·학과 등 명시 suffix가 있는 경우만 보수적으로 마스킹.
(
"ORG",
re.compile(
r"(?<![가-힣A-Za-z0-9])"
r"(?P<value>[가-힣A-Za-z0-9·&().-]{2,30}?"
r"(?:대학교|대학원|고등학교|중학교|초등학교|병원|의원|클리닉|상담센터|센터|복지관|교육청|보건소|연구소|재단|협회|학과|학부))"
r"(?P<suffix>\s*(?:입니다|이에요|예요|이고|이고요|에서|에|의|은|는|이|가|을|를)?)"
r"(?=$|[\s,.;!?。])"
),
),
# 한국어 이름: 이름/성명/실명 라벨 뒤 값.
(
"NAME",
re.compile(
r"(?P<prefix>(?:이름|성명|실명|본명)\s*[:]\s*)"
r"(?P<value>[가-힣]{2,4})"
r"(?=$|[\s,.;!?。])"
),
),
# 한국어 이름: "제 이름은 김서연입니다", "보호자 이름은 박민수입니다" 같은 자연 발화형 라벨.
(
"NAME",
re.compile(
r"(?P<prefix>(?:(?:제|저의|내|나의|보호자|학생|내담자|상담자|친구|엄마|아빠|어머니|아버지)\s+)?"
r"(?:이름|성명|실명|본명)\s*(?:은|는|이|가)?\s*)"
rf"(?P<value>{_KOREAN_FULL_NAME_BEFORE_SUFFIX})"
r"(?P<suffix>\s*(?:입니다|이에요|예요|이고|이고요|이라고|라고)?)"
r"(?=$|[\s,.;!?。])"
),
),
# 한국어 이름: "저는 김서연입니다", "제가 박민수예요", "김서연입니다" 같은 자기소개형 문장.
(
"NAME",
re.compile(
r"(?P<prefix>(?:(?:저는|나는|제가|내가)\s*)?)"
rf"(?P<value>{_KOREAN_FULL_NAME_BEFORE_SUFFIX}){_KOREAN_NAME_TAIL_GUARD}"
r"(?P<suffix>\s*(?:입니다|이에요|예요|이고|이고요))"
r"(?=$|[\s,.;!?。])"
),
),
# 한국어 이름: 역할/관계 명사 뒤에 붙은 인명 + 조사/호칭.
(
"NAME",
re.compile(
r"(?P<prefix>(?:내담자|상담자|학생|보호자|담임|교수|선생님|친구|엄마|아빠|어머니|아버지|동생|언니|오빠|형|누나)\s+)"
rf"(?P<value>{_KOREAN_FULL_NAME_BEFORE_SUFFIX})"
r"(?P<suffix>\s*(?:님|씨|학생|상담자|내담자)?"
r"(?:은|는|이|가|을|를|와|과|에게|한테|라고|이라는|입니다|이에요|예요|이고|이고요))"
),
),
# 한국어 이름: 성씨 기반 full-name + 조사. 문맥 없는 순수 2~4글자 마스킹은 오탐이 커서 피한다.
(
"NAME",
re.compile(
rf"(?<![가-힣])(?P<value>{_KOREAN_CONTEXTLESS_NAME}){_KOREAN_NAME_TAIL_GUARD}"
r"(?P<suffix>(?:은|는|이|가|을|를|와|과|에게|한테|라고|이라는))"
),
),
# 한국어 이름: "김서연 씨", "박민수님" 같은 명시 호칭.
(
"NAME",
re.compile(
rf"(?<![가-힣])(?P<value>{_KOREAN_FULL_NAME_BEFORE_SUFFIX})"
r"(?P<suffix>\s?(?:씨|님)(?:은|는|이|가|을|를|와|과|에게|한테|고|이고|인데)?)"
r"(?=$|[\s,.;!?。])"
),
),
# 주민등록번호 (6자리-7자리)
("RRN", re.compile(r"(?<!\d)\d{6}[-\s]?\d{7}(?!\d)")),
# 휴대폰 (010-1234-5678 등)
("PHONE", re.compile(r"(?<!\d)01[016789][-\s]?\d{3,4}[-\s]?\d{4}(?!\d)")),
# 일반 전화
("PHONE", re.compile(r"(?<!\d)0\d{1,2}[-\s]?\d{3,4}[-\s]?\d{4}(?!\d)")),
# 이메일
("EMAIL", re.compile(r"\b[\w.+-]+@[\w-]+\.[\w.-]+\b")),
# 카드/계좌 유사 긴 숫자열 (12자리 이상)
("NUMID", re.compile(r"(?<!\d)\d{12,}(?!\d)")),
# 구체적 날짜(생년월일 등): 2001.4.18 / 2001-04-18 / 2001년 4월 18일
("DATE", re.compile(r"(?:19|20)\d{2}\s?[.\-/년]\s?\d{1,2}\s?[.\-/월]\s?\d{1,2}\s?일?")),
# 금액(원): 1,200원 / 1200원 (3자리+ 또는 콤마구분) — 식별 맥락 보호
("MONEY", re.compile(r"\d{1,3}(?:,\d{3})+\s?원|\d{3,}\s?원")),
# 한국 주소 단편: ○○시/도 ○○시/군/구 ○○동/읍/면/로/길 (행정구역 연쇄)
("ADDR", re.compile(r"[가-힣]{2,}(?:시|도)\s?[가-힣]{1,4}(?:시|군|구)\s?[가-힣0-9]{1,}(?:동|읍|면|로|길)")),
]
# Source/input text keeps the broad contextless-name heuristic above for recall.
# Generated synthetic content has no user-originated text after source validation;
# applying that heuristic there misclassifies ordinary words such as "서운함을".
# Keep every high-confidence label/context/honorific pattern and all non-name PII.
_SYNTHETIC_GENERATED_PII_PATTERNS = (
*_PII_PATTERNS[:5],
*_PII_PATTERNS[6:],
)
# Presidio 지연 로드 캐시 (-1=미시도, None=미설치, 객체=설치됨)
_PRESIDIO_ANALYZER: object = -1
_PRESIDIO_ANONYMIZER: object = -1
@dataclass(frozen=True, slots=True)
class PiiEntitySpan:
entity_type: str
start: int
end: int
class KoPiiRecognizer(Protocol):
"""Optional Korean PII recognizer. Implementations must be local and side-effect free."""
def analyze(self, text: str) -> 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"(?<![A-Za-z가-힣]){re.escape(variant)}"
rf"(?=$|[^A-Za-z가-힣]|{_IDENTITY_KO_PARTICLE_LOOKAHEAD})"
)
replaced, count = re.subn(
pattern,
placeholder,
redacted,
flags=re.IGNORECASE,
)
if count:
redacted = replaced
role_entities.append(entity)
claimed_variants.add(normalized)
masked = (
mask_synthetic_generated_pii(redacted)
if synthetic_generated
else mask_pii(redacted)
)
return MaskResult(
text_masked=masked.text_masked,
entities=sorted(set(masked.entities + role_entities)),
used_presidio=masked.used_presidio,
used_ko_recognizer=masked.used_ko_recognizer,
)
# ════════════════════════════════════════════════════════════════════════════
# 2. 위기 분류 (입력 — 실제위기 vs 페르소나 연기 구분, R8)
# ════════════════════════════════════════════════════════════════════════════
class CrisisKind(str, Enum):
NONE = "none"
PERSONA_PLAY = "persona_play" # 페르소나 연기 맥락의 위기 표현(시뮬레이션 정상)
LEARNER_REAL = "learner_real" # 수련생 본인의 실제 위기 신호(에스컬레이션 대상)
@dataclass(slots=True)
class CrisisResult:
kind: CrisisKind
risk_level: int = 0 # 0~5 (한국어 자살콘텐츠 5단계 자리; 현재 휴리스틱)
matched: list[str] = field(default_factory=list)
escalate: bool = False # safety_events 적재 + 교수자 알림 트리거 여부
# 위기 표현 키워드(한국어 우선). TODO: JMIR 한국어 벤치 few-shot 분류기로 교체(R8).
_CRISIS_TERMS = [
"죽고 싶", "죽고싶", "자살 생각", "자살하고 싶", "자살을 생각", "자살 충동",
"자살 시도", "목숨을 끊", "목숨을 버리", "사라지고 싶", "없어지고 싶",
"자해하고 싶", "자해한 적", "자해를 했", "자해 충동", "자해 시도",
"자살 계획", "자해 계획", "자살하려고", "자살을 하려고", "자해하려고",
"자해를 하려고", "죽으려고", "자살하려", "죽으려", "자살할 계획",
"자해할 계획", "죽을 계획", "자살할 의도", "죽을 준비",
"제 자신을 해치려고", "삶을 끝내려고", "목숨을 끊을 계획",
"스스로를 다치게", "스스로를 해칠 계획", "자살할지도", "끝내고 싶",
"살기 싫", "살아서 뭐", "죽어야", "안전을 스스로 지키기 어렵",
"안전을 유지할 수 없", "안전을 보장할 수 없",
]
# 자살·자해 단어를 직접 쓰지 않더라도 본인의 현재 위험과 실제 도움 필요를 함께
# 밝히는 짧은 진술은 엔진 호출 전에 막아야 한다. 단일 '위험'/'도움' 키워드만으로는
# 상담자의 안전 질문까지 오탐할 수 있어 네 단서가 모두 함께 있을 때만 승격한다.
_SELF_SUBJECT = (
r"(?<![가-힣A-Za-z0-9])"
r"(?:저\s*(?:자신|스스로)|나\s*(?:자신|스스로)|제\s*자신|내\s*자신|"
r"저한테|나한테|제가|저는|저|내가|나는|나)"
)
_CURRENT = r"(?:지금|현재|오늘|내일)(?:은|도)?"
_CURRENT_QUALIFIER = r"(?:(?:정말(?:로)?|진짜(?:로)?|실제로|곧|당장)\s*)*"
_CURRENT_DAY_PART = r"(?:(?:밤|저녁|새벽)\s*)?"
_DANGER = (
r"위험(?:한\s*(?:것\s*같|상태)|할\s*것\s*같|하다고\s*(?:느끼|느껴)|"
r"하(?:고|며)|해(?:요|서)?)"
)
_PERSONAL_DANGER_PATTERNS = (
re.compile(
rf"{_SELF_SUBJECT}(?:이|은|도)?\s*{_CURRENT}\s*"
rf"(?:정말\s*|실제로\s*|너무\s*)?{_DANGER}"
),
re.compile(
rf"{_CURRENT}\s*{_SELF_SUBJECT}(?:이|은|도)?\s*"
rf"(?:정말\s*|실제로\s*|너무\s*)?{_DANGER}"
),
re.compile(
rf"(?:제|내)\s*(?:안전|신변|생명|목숨)(?:을|이|은)?\s*{_CURRENT}\s*"
rf"(?:스스로\s*)?(?:지키기\s*어렵|{_DANGER})"
),
re.compile(
rf"{_CURRENT}\s*(?:제|내)\s*(?:안전|신변|생명|목숨)(?:을|이|은)?\s*"
rf"(?:스스로\s*)?(?:지키기\s*어렵|{_DANGER})"
),
)
_PERSONAL_IMMEDIATE_HARM = (
r"(?:안전하지\s*않|안전을?\s*(?:지킬|유지할|보장할)\s*수\s*없|"
r"(?:제|내)\s*안전을?\s*지킬\s*(?:자신이|수)?\s*없|"
r"혼자\s*있으면\s*위험|"
r"(?:스스로를|저를|나를|제\s*자신을|내\s*자신을)\s*"
r"(?:해칠까\s*봐|해칠\s*것\s*같|해칠지도\s*몰라|"
r"다치게\s*할까\s*봐|다치게\s*할\s*것\s*같))"
)
_PERSONAL_IMMEDIATE_HARM_PATTERNS = (
re.compile(
rf"{_SELF_SUBJECT}(?:이|은|도)?\s*{_CURRENT}\s*(?:당장\s*)?"
rf"{_PERSONAL_IMMEDIATE_HARM}"
),
re.compile(
rf"{_CURRENT}\s*{_SELF_SUBJECT}(?:이|은|도)?\s*(?:당장\s*)?"
rf"{_PERSONAL_IMMEDIATE_HARM}"
),
re.compile(
rf"{_SELF_SUBJECT}(?:이|은|도)?\s*{_CURRENT}.{{0,45}}"
rf"(?:스스로를|저를|나를|제\s*자신을|내\s*자신을)\s*"
rf"(?:해칠\s*것\s*같|해칠지도\s*몰라|"
rf"다치게\s*할까\s*봐|다치게\s*할\s*것\s*같)"
),
)
_HIGH_RISK_ASSERTION = (
r"(?:"
r"(?:자살|자해)(?:을|를)?\s*시도(?:를)?\s*"
r"(?:(?:하)?(?:려고|려)\s*(?:해요|합니다)|할지도\s*(?:모르|몰라))|"
r"(?:자살|자해)(?:을|를)?\s*(?:하)?(?:려고|려)\s*(?:해요|합니다)|"
r"(?:자살|자해)할\s*(?:것\s*같|지도\s*(?:모르|몰라)|거(?:예요|에요|야|다)|래요)|"
r"(?:자살|자해)(?:을|를)?\s*"
r"(?:결심(?:했|한)|준비(?:를\s*)?(?:하고|했|하는)|실행(?:을\s*)?(?:하려고|할))|"
r"(?:자살|자해)하기로\s*(?:했|결심)|"
r"(?:자살|자해)\s*생각(?:이|은)?\s*(?:계속\s*)?반복|"
r"(?:자살|자해)\s*(?:계획|충동|생각)(?:이|가|은|는)?\s*(?:있|세웠)|"
r"(?:자살할|자해할|죽을|(?:제|내)\s*목숨을\s*끊을)\s*"
r"(?:구체적인\s*)?(?:계획|의도)(?:이|가)?\s*(?:있|이에요|입니다)|"
r"(?:자살|자해)\s*계획(?:을|은|는)?\s*세웠|"
r"죽(?:으려고|으려)\s*(?:해요|합니다)|죽기로\s*(?:했|결심)|죽을래요|"
r"죽을\s*준비(?:를|가|는)?\s*(?:하)?고\s*있|"
r"(?:제|내)\s*목숨을\s*"
r"(?:끊고\s*싶|끊으려고\s*(?:해요|합니다)|끊을지도\s*(?:모르|몰라)|"
r"끊을\s*거(?:예요|에요|야|다)|끊을\s*계획(?:을|은|는)?\s*(?:세웠|있))|"
r"(?:스스로를|저를|나를|제\s*자신을|내\s*자신을)\s*"
r"(?:(?:다치게\s*하고|해치고)\s*싶|해치려고\s*(?:해요|합니다)|"
r"해칠\s*계획(?:이|가|은|는)?\s*(?:있|세웠))|"
r"삶을\s*끝내려고\s*(?:해요|합니다)|"
r"(?:(?:제|내)\s*)?안전을?\s*(?:지킬|유지할|보장할|지키기|유지하기|보장하기)\s*"
r"(?:수\s*없|어렵|어려)"
r")"
)
_CURRENT_SUICIDE_OR_SELF_HARM = (
rf"(?:{_HIGH_RISK_ASSERTION}|"
r"자살(?:하고)?\s*싶|죽고\s*싶|죽어버리고\s*싶|"
r"살기\s*싫|살고\s*싶지\s*않|자해(?:하고)?\s*싶|"
r"(?:삶을\s*)?끝내고\s*싶|(?:사라지고|없어지고)\s*싶|"
r"죽어야\s*할\s*것\s*같|살아갈\s*이유가\s*없)"
)
_FIRST_PERSON_CURRENT_CRISIS_PATTERNS = (
re.compile(
rf"{_SELF_SUBJECT}(?:이|은|도)?\s*{_CURRENT}\s*"
rf"{_CURRENT_DAY_PART}{_CURRENT_QUALIFIER}{_CURRENT_SUICIDE_OR_SELF_HARM}"
),
re.compile(
rf"{_CURRENT}\s*{_SELF_SUBJECT}(?:이|은|도)?\s*"
rf"{_CURRENT_DAY_PART}{_CURRENT_QUALIFIER}{_CURRENT_SUICIDE_OR_SELF_HARM}"
),
re.compile(
rf"{_SELF_SUBJECT}(?:이|은|도)?\s*{_CURRENT_QUALIFIER}"
rf"{_CURRENT_SUICIDE_OR_SELF_HARM}"
),
re.compile(
rf"{_CURRENT}\s*{_CURRENT_DAY_PART}{_CURRENT_QUALIFIER}"
rf"{_HIGH_RISK_ASSERTION}"
),
)
_FIRST_PERSON_EXPLICIT_CRISIS_PATTERNS = (
re.compile(
rf"{_SELF_SUBJECT}(?:이|은|도)?\s*(?:당장\s*)?"
rf"{_CURRENT_SUICIDE_OR_SELF_HARM}"
),
re.compile(
rf"당장\s*{_SELF_SUBJECT}(?:이|은|도)?\s*"
rf"{_CURRENT_SUICIDE_OR_SELF_HARM}"
),
re.compile(
r"(?<![가-힣A-Za-z0-9])(?:제|내)\s*목숨을\s*"
r"(?:끊고\s*싶|끊으려고\s*(?:해요|합니다))"
),
)
_REAL_HELP_CUE = re.compile(
r"(?:(?:실제로\s*)?도움(?:이|을)?\s*필요|도와(?:주|줄|주세요))"
)
_CURRENT_PERSONAL_DANGER_HELP = "first_person_current_danger_help"
_FIRST_PERSON_CURRENT_CRISIS = "first_person_current_suicide_or_self_harm"
_EXPLICIT_HIGH_RISK = "explicit_suicide_or_self_harm_risk"
_EXPLICIT_HIGH_RISK_CUE = re.compile(
rf"(?<![가-힣A-Za-z0-9]){_HIGH_RISK_ASSERTION}"
)
_INDIRECT_SELF_ERASURE = "indirect_self_erasure"
_INDIRECT_SELF_ERASURE_CUE = re.compile(
r"(?<![가-힣A-Za-z0-9])(?:차라리\s*(?:없어졌으면|사라졌으면)|"
r"(?:내가|제가|저\s*자신이|나\s*자신이)\s*(?:없어졌으면|사라졌으면))"
)
_OTHER_PERSON_SUBJECT = (
r"(?:내담자|상대|학생|친구|배우자|가족|동생|아버지|아빠|어머니|엄마|"
r"부모|동료|그\s*사람)"
)
_REPORTED_OR_QUOTED_QUESTION = re.compile(
rf"{_OTHER_PERSON_SUBJECT}(?:이|가|은|는|에게)?.{{0,180}}"
r"(?:라고|다고|냐고|는지).{0,40}"
r"(?:말(?:하|해|했)|묻|물어|확인|진술|표현)|"
r"[\"'“‘].{1,180}[\"'”’]\s*(?:라고|다고|냐고|는지)?.{0,60}"
r"(?:예시|읽|말하|가정|연습|문장|번역|질문|확인|묻|물어)"
)
_REPORTED_CRISIS_HELPING_OTHER = re.compile(
r"(?:자살(?:하고)?\s*싶|죽고\s*싶|자해(?:하고)?\s*싶)"
r".{0,35}(?:다고|라고)\s*(?:말한|한)\s*"
r"(?:친구|내담자|학생|사람|가족|동생).{0,45}(?:돕|도와|지원)"
)
_CRISIS_META_QUESTION = re.compile(
r"(?:죽고\s*싶(?:은\s*생각이\s*있는|으신|은)?지|"
r"자살(?:하고)?\s*싶(?:으신|은)?지|자해(?:하고)?\s*싶(?:으신|은)?지)"
r".{0,50}(?:묻|물어|여쭙|여쭤|질문|확인|연습)|"
r"(?:자살|자해)\s*(?:생각|계획|충동)(?:이|가)?\s*있는지"
r".{0,50}(?:묻|물어|여쭙|여쭤|질문|확인|연습)|"
r"(?:자살|자해)\s*(?:생각|계획|충동)(?:이|가)?\s*"
r"있(?:나요|습니까|으신가요)"
)
_CRISIS_META_QUESTION_PREFIX = re.compile(
r"(?:묻|물어|여쭙|여쭤|질문|확인).{0,55}"
r"(?:자살\s*생각|죽고\s*싶(?:은)?\s*생각|자해\s*충동)"
r".{0,30}(?:있는지|있나|유무)"
)
_OTHER_PERSON_CRISIS = re.compile(
r"(?:자살(?:하고)?|죽고|자해(?:하고)?)\s*싶(?:은|어\s*하는|다는)\s*"
rf"{_OTHER_PERSON_SUBJECT}"
)
_CRISIS_META_OR_ROLEPLAY = re.compile(
r"(?:자살(?:하고)?\s*싶|죽고\s*싶|자해(?:하고)?\s*싶)"
r"(?:(?:다고|냐고|은지|는지|다는).{0,60}"
r"(?:가정|역할극|연습|예시|읽|번역|문장|말하|묻|물어|질문|확인)|"
r"으면.{0,60}(?:설명|안내|교육|전화))|"
r"(?:자살|자해|죽|목숨|안전).{0,75}"
r"(?:있는지|없는지|할지|인지).{0,45}"
r"(?:확인|묻|물어|여쭙|여쭤|질문|연습)|"
r"(?:자살|자해|죽|목숨|안전).{0,75}"
r"(?:문장|번역|설문|설명|역할극|연습|예시)|"
r"(?:역할극|연습|예시).{0,55}(?:자살|자해|죽|목숨|안전).{0,55}"
r"(?:라고|다고|라는|다는|대사|문장|말하|연기)"
)
_NEGATED_CRISIS = re.compile(
r"(?:자살(?:하고)?|죽고|자해(?:하고)?|끝내고)\s*"
r"싶(?:지(?:는)?\s*않|진\s*않|다는\s*뜻은\s*아니|"
r"\s*(?:건|것은)?\s*아니|은\s*마음은\s*없)|"
r"(?:자살(?:하고)?|죽고|자해(?:하고)?)\s*싶은\s*(?:생각|마음)"
r"(?:이|은)?\s*(?:전혀\s*)?(?:없|들지\s*않)|"
r"(?:자살\s*생각|자해\s*충동)(?:이|은)?\s*(?:전혀\s*)?(?:없|들지\s*않)|"
r"(?:자살|자해)할\s*것\s*같(?:지|지는|진)\s*않|"
r"살기\s*싫(?:지|지는)\s*않|(?:없어지고|사라지고)\s*싶(?:지|지는|진)\s*않|"
r"죽어야\s*할\s*것\s*같(?:지|지는|진)\s*않|"
r"살아갈\s*이유가\s*없(?:지|지는)\s*않|"
r"살아갈\s*이유가\s*없는\s*(?:건|것은)\s*아니|"
r"(?:자살|자해)\s*충동이\s*있(?:지|지는)\s*않|"
r"(?:자살|자해)\s*(?:계획|생각)(?:이|가)?\s*있는\s*"
r"(?:게|건|것은)\s*아니|"
r"(?:자살|자해)\s*(?:계획|충동|생각|시도)(?:이|가|은|는)?\s*"
r"(?:전혀\s*)?(?:없|있지는\s*않|하지\s*않)|"
r"살고\s*싶지\s*않은\s*(?:게|건|것은)\s*아니|"
r"(?:(?:제|내)\s*)?안전을?\s*(?:지킬|유지할|보장할)\s*수\s*없는\s*"
r"(?:게|건|것은)\s*아니|"
r"(?:자살|자해)(?:을|를)?\s*시도(?:를)?\s*(?:하)?려는\s*"
r"(?:게|건|것은)\s*아니|"
r"(?:자살|자해)(?:을|를)?\s*(?:하)?려고\s*하(?:지|지는|진)\s*않|"
r"죽으려고\s*하(?:지|지는|진)\s*않|"
r"(?:자살할|자해할|죽을|(?:제|내)\s*목숨을\s*끊을)\s*"
r"(?:구체적인\s*)?(?:계획|의도)(?:이|가|은|는)?\s*없|"
r"죽을\s*준비(?:를|가|는)?\s*(?:하)?지\s*않|"
r"위험(?:한|할)\s*것\s*같지\s*않|도움(?:이|을)?\s*필요하지\s*않"
)
_RECOVERED_PAST_CRISIS = re.compile(
r"(?:예전|과거|아까)(?:에는?|는)?\s*"
r"(?:자살(?:하고)?\s*싶|죽고\s*싶|자해(?:하고)?\s*싶)"
r"(?:었지만|었으나|었는데).{0,45}(?:지금|현재)(?:은|는)?\s*"
r"(?:괜찮|안전|그렇지\s*않|생각이\s*없)"
r"|(?:예전|과거|아까)(?:에는?|는)?.{0,35}"
r"(?:자살|자해)\s*(?:계획|충동|생각)(?:이|가)?\s*있었지만"
r".{0,35}(?:지금|현재)(?:은|는)?\s*없"
)
_INVALID_REAL_CRISIS_CONTEXTS = (
_REPORTED_OR_QUOTED_QUESTION,
_REPORTED_CRISIS_HELPING_OTHER,
_CRISIS_META_QUESTION,
_CRISIS_META_QUESTION_PREFIX,
_OTHER_PERSON_CRISIS,
_CRISIS_META_OR_ROLEPLAY,
_NEGATED_CRISIS,
_RECOVERED_PAST_CRISIS,
)
_CRISIS_CLAUSE_SPLITTER = re.compile(
r"(?<=[.!?。!?])\s+|"
r"(?:(?<=지만)|(?<=으나)|(?<=는데))\s+"
r"(?=(?:저|제|나|내))"
)
def _match_has_invalid_crisis_context(text: str, match: re.Match[str]) -> 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 자해 페르소나 대비 자해 도구·방법 어휘 보강.
# 과차단 주의 — "선을 긋다"(경계 설정) 같은 정상 상담 표현과 겹치지 않는 조합만 추가.
_MEANS_TERMS = [
"약을 얼마나", "몇 알", "몇 정", "투신", "목을 매", "목 매", "번개탄", "농약",
"손목을", "칼로", "뛰어내리", "수면제 ", "", "방법은", "어떻게 죽",
"커터", "면도날", "손목 긋", "깊게 그으", "라이터로 지",
]
_DISPLAY_PLACEHOLDER_LABELS = {
"COUNSELOR": "상담자",
"CLIENT": "내담자",
"NAME": "그 이름",
"ORG": "그 기관",
"PHONE": "연락처",
"EMAIL": "이메일",
"RRN": "식별번호",
"NUMID": "식별번호",
"DATE": "그 날짜",
"MONEY": "그 금액",
"ADDR": "그 주소",
}
_DISPLAY_PLACEHOLDER_RE = re.compile(
r"\[(?P<label>COUNSELOR|CLIENT|NAME|ORG|PHONE|EMAIL|RRN|NUMID|DATE|MONEY|ADDR)\]"
r"(?P<particle>[은는이가을를와과])?"
)
_DISPLAY_PLACEHOLDER_STREAM_TAIL = 16
def _adjust_particle(value: str, particle: str | None) -> str:
if particle is None:
return value
last = value[-1] if value else ""
codepoint = ord(last) - 0xAC00 if "" <= last <= "" else -1
has_batchim = codepoint >= 0 and codepoint % 28 != 0
if particle in ("", ""):
return value + ("" if has_batchim else "")
if particle in ("", ""):
return value + ("" if has_batchim else "")
if particle in ("", ""):
return value + ("" if has_batchim else "")
if particle in ("", ""):
return value + ("" if has_batchim else "")
return value + particle
def humanize_pii_placeholders(text: str) -> str:
"""사용자에게 보이는 내담자 응답에서 PII placeholder 토큰을 자연어로 낮춘다."""
if not text:
return text
def _replace(match: re.Match[str]) -> str:
label = match.group("label")
replacement = _DISPLAY_PLACEHOLDER_LABELS.get(label, "그 정보")
return _adjust_particle(replacement, match.group("particle"))
return _DISPLAY_PLACEHOLDER_RE.sub(_replace, text)
class PiiPlaceholderStreamSanitizer:
"""SSE 토큰 경계를 가로질러 나온 PII placeholder를 사용자 표시 전에 치환한다."""
def __init__(self) -> None:
self._tail = ""
def feed(self, chunk: str) -> str:
if not chunk:
return ""
self._tail += chunk
if len(self._tail) <= _DISPLAY_PLACEHOLDER_STREAM_TAIL:
return ""
ready = self._tail[:-_DISPLAY_PLACEHOLDER_STREAM_TAIL]
self._tail = self._tail[-_DISPLAY_PLACEHOLDER_STREAM_TAIL:]
return humanize_pii_placeholders(ready)
def flush(self) -> str:
tail = self._tail
self._tail = ""
return humanize_pii_placeholders(tail)
@dataclass(slots=True)
class OutputGuardResult:
text: str # 정제된(또는 원본) 응답
blocked: bool = False # 차단/치환 발생 여부
needs_regeneration: bool = False # 재생성 필요(수단정보 누출 시)
reasons: list[str] = field(default_factory=list)
_ROLE_META_PATTERNS = [
re.compile(r"(?:내담자|상담자)\s*역할\s*로?\s*응답"),
re.compile(r"AI\s*로서"),
re.compile(r"상담자\s*입장\s*에서"),
re.compile(r"\s*(?:핵심신념|자동적\s*사고|인지왜곡)\s*은"),
]
_OPENING_GREETING_RE = re.compile(r"^\s*(?:안녕하세요|처음\s*뵙겠습니다|반갑습니다)(?:[\s,.!?。!?]|$)")
_DUPLICATE_PUNCT_RE = re.compile(r"[\s\.,!?。!?…\"'“”‘’()\[\]{}:;·~\-]+")
def _normalize_duplicate_text(value: str) -> str:
return _DUPLICATE_PUNCT_RE.sub("", value.casefold())
def _duplicate_tokens(value: str) -> list[str]:
cleaned = _DUPLICATE_PUNCT_RE.sub(" ", value.casefold())
return [token for token in cleaned.split() if len(token) >= 2]
def _is_near_duplicate_client_reply(text: str, previous: str) -> bool:
current_norm = _normalize_duplicate_text(text)
previous_norm = _normalize_duplicate_text(previous)
if len(current_norm) < 12 or len(previous_norm) < 12:
return False
if current_norm == previous_norm or current_norm in previous_norm or previous_norm in current_norm:
return True
if SequenceMatcher(None, current_norm, previous_norm).ratio() >= 0.88:
return True
current_tokens = set(_duplicate_tokens(text))
previous_tokens = set(_duplicate_tokens(previous))
if min(len(current_tokens), len(previous_tokens)) < 4:
return False
overlap = len(current_tokens & previous_tokens) / min(len(current_tokens), len(previous_tokens))
return overlap >= 0.82
def sanitize_client_reply(
text: str,
*,
ideation_stage: int,
turn_seq: int | None = None,
previous_client_reply: str | None = None,
) -> OutputGuardResult:
"""내담자 AI 응답 출력 가드레일.
1. 자살/자해 수단·방법 정보가 있으면 → needs_regeneration=True(차단, 재생성 신호).
2. ideation_stage > IDEATION_STAGE_CAP(3) 이면 상태머신 계약 위반으로 차단하고
needs_regeneration=True를 반환한다(영속 상태 상한에 대한 이중방어).
"""
reasons: list[str] = []
blocked = False
needs_regen = False
if text:
for term in _MEANS_TERMS:
if term in text:
reasons.append(f"means_info:{term}")
blocked = True
needs_regen = True
break
if any(pattern.search(text) for pattern in _ROLE_META_PATTERNS):
reasons.append("role_meta")
blocked = True
needs_regen = True
if turn_seq is not None and turn_seq > 1 and _OPENING_GREETING_RE.search(text):
reasons.append("repeat_greeting_after_opening")
blocked = True
needs_regen = True
if previous_client_reply and _is_near_duplicate_client_reply(text, previous_client_reply):
reasons.append("duplicate_client_reply")
blocked = True
needs_regen = True
if ideation_stage > IDEATION_STAGE_CAP:
reasons.append(f"ideation_over_cap:{ideation_stage}>{IDEATION_STAGE_CAP}")
blocked = True
needs_regen = True
return OutputGuardResult(
text=humanize_pii_placeholders(text),
blocked=blocked,
needs_regeneration=needs_regen,
reasons=reasons,
)
def clamp_ideation(stage: int) -> int:
"""ideation_stage 를 안전 상한(3)으로 클램프 (R5)."""
return clamp_ideation_stage(stage)
def crisis_resource() -> dict[str, str]:
"""LLM 밖 위기 안내 리소스. UI/API 응답에 그대로 실어 보낸다."""
return {
"title": CRISIS_HOTLINE_LABEL,
"number": CRISIS_HOTLINE_NUMBER,
"message": CRISIS_RESOURCE_MESSAGE,
}
__all__ = [
"IDEATION_STAGE_CAP",
"CRISIS_HOTLINE_NUMBER",
"CRISIS_HOTLINE_LABEL",
"CRISIS_RESOURCE_MESSAGE",
"MaskResult",
"PiiEntitySpan",
"set_ko_pii_recognizer",
"mask_pii",
"mask_role_identities",
"mask_synthetic_generated_pii",
"CrisisKind",
"CrisisResult",
"classify_crisis",
"OutputGuardResult",
"PiiPlaceholderStreamSanitizer",
"humanize_pii_placeholders",
"sanitize_client_reply",
"clamp_ideation",
"crisis_resource",
]