"""판단 근거 설명 생성기 (Rule-explainer-v1) ================================================= 분류기가 돌려준 구조화된 reasons 를 **자연어 설명**으로 변환한다. SPEC §1 기능 4 — *"분류 결과 설명"* 의 강화 버전. LLM 을 호출하지 않고 템플릿/문장 조립만 사용 — 결정적이고 빠르며 비용 0. 다만 입력이 그대로 자연어 문장으로 매핑되도록 충분히 풍부한 분기를 갖는다. """ from __future__ import annotations EXPLAINER_VERSION = "rule-explainer-v1" # entity_type → 한국어 설명 ENTITY_DESCRIPTIONS = { "KR_RRN": "주민등록번호", "KR_PASSPORT": "여권번호", "KR_BIZ_NO": "사업자등록번호", "KR_PHONE": "한국 전화번호", "KR_ADDRESS": "한국 주소", "PHONE_NUMBER": "전화번호", "CREDIT_CARD": "신용카드번호", "US_SSN": "미국 SSN", "IBAN_CODE": "IBAN 계좌번호", "AWS_ACCESS_KEY": "AWS 액세스 키", "GENERIC_API_KEY": "API 키 추정 토큰", "VIP_NAMES": "VIP 명단 이름", "INTERNAL_PROJECTS": "내부 프로젝트명", "EMAIL_ADDRESS": "이메일 주소", "IP_ADDRESS": "IP 주소", "URL": "URL", "PERSON": "인명", "LOCATION": "지명/장소", "ORGANIZATION": "조직명", "DATE_TIME": "날짜/시간", } def _grade_label(g: str) -> str: return {"C": "**위험 (Critical)**", "S": "**민감 (Sensitive)**", "O": "**공개 (Open)**"}.get(g, g) def _signal_phrase(reason: dict) -> str: label = reason.get("label", "?") cnt = reason.get("count", 1) contrib = reason.get("contribution", 0) if reason.get("kind") == "keyword": return f"등급 라벨 '{label}' {cnt}회 ({contrib:+.2f}점)" desc = ENTITY_DESCRIPTIONS.get(label, label) if cnt > 1: return f"{desc} {cnt}건 ({contrib:+.2f}점)" return f"{desc} ({contrib:+.2f}점)" def explain(classification: dict, findings: list[dict] | None = None) -> dict: """classification + findings → {summary, narrative, bullets, version}. Returns: summary: 1줄 요약 (등급 + 점수) narrative: 2~5문장 자연어 설명 (markdown bold 포함) bullets: 사용자가 빠르게 훑을 수 있는 키 포인트 리스트 version: "rule-explainer-v1" """ g = classification.get("grade", "O") score = classification.get("score", 0.0) conf = classification.get("confidence", 0.5) th = classification.get("thresholds", {"C": 5.0, "S": 2.0}) reasons = classification.get("reasons") or [] entity_reasons = [r for r in reasons if r.get("kind") == "entity"] kw_reasons = [r for r in reasons if r.get("kind") == "keyword"] top = reasons[:3] # ---- summary (한 줄) ---- summary = f"{_grade_label(g)} — score {score} (신뢰도 {conf*100:.0f}%)" # ---- narrative (문단) ---- parts: list[str] = [] # 1) 등급 결정 이유 + 마진 if g == "C": margin = score - th["C"] parts.append( f"이 문서는 {_grade_label(g)} 등급으로 분류됩니다 — " f"누적 점수 {score} 가 C 임계값 {th['C']} 를 {margin:.2f}점 초과했습니다." ) elif g == "S": parts.append( f"이 문서는 {_grade_label(g)} 등급으로 분류됩니다 — " f"점수 {score} 가 S 임계값 {th['S']} 와 C 임계값 {th['C']} 사이에 위치합니다." ) else: parts.append( f"이 문서는 {_grade_label(g)} 등급으로 분류됩니다 — " f"점수 {score} 가 S 임계값 {th['S']} 미만으로, 등급을 올릴 만한 신호가 부족합니다." ) # 2) 결정적 신호 if top: phrases = [_signal_phrase(r) for r in top] if len(phrases) == 1: parts.append(f"결정적 신호는 {phrases[0]} 단 한 개였습니다.") else: parts.append("결정적이었던 신호: " + ", ".join(phrases) + ".") else: parts.append("매칭된 신호가 없어 점수가 0에 가깝습니다.") # 3) 신호 구성 분석 if kw_reasons and entity_reasons: parts.append( f"등급 라벨 키워드 {len(kw_reasons)}종과 식별자 {len(entity_reasons)}종이 함께 매칭되어 " f"등급이 더 안정적으로 결정되었습니다." ) elif kw_reasons and not entity_reasons: parts.append( "본문에 명시된 등급 라벨(예: 대외비/기밀) 만으로 결정되었습니다 — " "실제 식별자가 없을 수도 있으니 사용자 검토를 권장합니다." ) elif entity_reasons and not kw_reasons: if g == "C": parts.append("등급 라벨 키워드 없이 식별자 검출만으로 위험 등급이 확정되었습니다.") elif g == "S": parts.append("개인정보/계정 식별자 검출로 민감 등급이 부여되었습니다.") # 4) 핵심 PII 요약 (있을 때만) pii_high = [r for r in entity_reasons if r.get("contribution", 0) >= 2.0] if pii_high: names = ", ".join(ENTITY_DESCRIPTIONS.get(r["label"], r["label"]) for r in pii_high) parts.append(f"고위험 식별자: {names}.") # 5) 신뢰도 코멘트 if conf < 0.62: parts.append( f"⚠ 신뢰도 {conf*100:.0f}% — 임계값 경계에 가까워 사용자 최종 확인을 권장합니다." ) elif conf > 0.85: parts.append(f"신뢰도 {conf*100:.0f}% — 등급 경계에서 충분히 떨어진 명확한 매칭.") narrative = " ".join(parts) # ---- bullets (대시보드용) ---- bullets: list[str] = [] if top: for r in top: bullets.append(_signal_phrase(r)) bullets.append(f"점수 {score} (S≥{th['S']} · C≥{th['C']})") bullets.append(f"신뢰도 {conf*100:.0f}%") if not entity_reasons and not kw_reasons: bullets.append("매칭된 신호 없음 — 기본값(O)") return { "summary": summary, "narrative": narrative, "bullets": bullets, "version": EXPLAINER_VERSION, }