Spaces:
Sleeping
Sleeping
| """ | |
| Microsoft Presidio 기반 PII / 민감정보 감지 로컬 웹 서버. | |
| 실행: | |
| python app.py | |
| 브라우저에서 http://127.0.0.1:5000 접속 후 파일을 드래그-앤-드롭하면 | |
| 감지된 엔티티 목록과 위치, 신뢰도, 매치 텍스트를 확인할 수 있습니다. | |
| custom_patterns.yaml 을 수정하여 사용자 정의 패턴/룰을 자유롭게 추가할 수 있습니다. | |
| """ | |
| from __future__ import annotations | |
| import io | |
| import logging | |
| import os | |
| from datetime import datetime, timezone | |
| from pathlib import Path | |
| from typing import List | |
| import yaml | |
| from flask import Flask, jsonify, render_template, request | |
| import hashlib | |
| import time | |
| import anonymization | |
| import ko_ner | |
| import logging_setup | |
| import ner_filter | |
| import perf | |
| import settings | |
| import shap_explain | |
| import drift_detection | |
| import he_fl | |
| import platt_calibration | |
| import rule_mining | |
| import storage | |
| import train as trainmod | |
| import neural | |
| from classifier import CLASSIFIER_VERSION, active_version, classify | |
| from explainer import explain | |
| logging_setup.setup(level=settings.get("log_level") or "INFO") | |
| KO_MODEL_LBL = neural.KO_MODEL_ID | |
| EN_MODEL_LBL = neural.EN_MODEL_ID | |
| from presidio_analyzer import ( | |
| AnalyzerEngine, | |
| Pattern, | |
| PatternRecognizer, | |
| RecognizerRegistry, | |
| ) | |
| from presidio_analyzer.nlp_engine import NlpEngineProvider | |
| logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s") | |
| log = logging.getLogger("presidio-app") | |
| BASE_DIR = Path(__file__).resolve().parent | |
| PATTERN_FILE = BASE_DIR / "custom_patterns.yaml" | |
| # --------------------------------------------------------------------------- | |
| # Presidio Analyzer 초기화 (en + ko 지원) | |
| # --------------------------------------------------------------------------- | |
| # 사용 가능한 spaCy 모델을 자동 감지해 NLP 엔진을 구성합니다. | |
| # - 영문: en_core_web_sm (또는 _md/_lg) | |
| # - 한국어: ko_core_news_sm (또는 _md/_lg) | |
| # 한국어 모델 설치: python -m spacy download ko_core_news_sm | |
| # 영문 모델 설치 : python -m spacy download en_core_web_sm | |
| _LANG_MODEL_CANDIDATES = [ | |
| ("en", ["en_core_web_lg", "en_core_web_md", "en_core_web_sm"]), | |
| ("ko", ["ko_core_news_lg", "ko_core_news_md", "ko_core_news_sm"]), | |
| ("ja", ["ja_core_news_lg", "ja_core_news_md", "ja_core_news_sm"]), | |
| ] | |
| def _detect_models() -> dict: | |
| """설치된 spaCy 모델만 lang_code 별로 1개씩 골라 반환.""" | |
| import spacy | |
| chosen: dict = {} | |
| for lang, candidates in _LANG_MODEL_CANDIDATES: | |
| for model in candidates: | |
| try: | |
| spacy.load(model) | |
| chosen[lang] = model | |
| log.info("spaCy: %s = %s", lang, model) | |
| break | |
| except OSError: | |
| continue | |
| if lang not in chosen: | |
| log.warning("spaCy: %s 모델 미설치 → %s 비활성화", lang, lang) | |
| if not chosen: | |
| raise RuntimeError( | |
| "사용 가능한 spaCy 모델이 없습니다. " | |
| "python -m spacy download en_core_web_sm 또는 ko_core_news_sm 으로 설치하세요." | |
| ) | |
| return chosen | |
| _MODELS = _detect_models() | |
| SUPPORTED_LANGUAGES = list(_MODELS.keys()) # 예: ["en", "ko"] | |
| NLP_CONFIG = { | |
| "nlp_engine_name": "spacy", | |
| "models": [{"lang_code": k, "model_name": v} for k, v in _MODELS.items()], | |
| } | |
| def _normalize_languages(spec) -> List[str]: | |
| """YAML 의 supported_language → 실제 적용 언어 리스트. | |
| 'any' / None → 설치된 모든 언어. 문자열/리스트는 그대로 (단 미지원 언어는 제거).""" | |
| if spec is None or spec == "any": | |
| return list(SUPPORTED_LANGUAGES) | |
| if isinstance(spec, str): | |
| spec = [spec] | |
| return [s for s in spec if s in SUPPORTED_LANGUAGES] or [SUPPORTED_LANGUAGES[0]] | |
| def load_custom_recognizers(path: Path) -> List[PatternRecognizer]: | |
| """custom_patterns.yaml 을 읽어 PatternRecognizer 목록을 만든다. | |
| supported_language 가 'any' 또는 리스트면 언어별로 복제해 등록한다.""" | |
| if not path.exists(): | |
| log.warning("custom_patterns.yaml not found: %s", path) | |
| return [] | |
| with path.open("r", encoding="utf-8") as f: | |
| cfg = yaml.safe_load(f) or {} | |
| recognizers: List[PatternRecognizer] = [] | |
| for item in cfg.get("pattern_recognizers", []) or []: | |
| patterns = [ | |
| Pattern(name=p["name"], regex=p["regex"], score=float(p["score"])) | |
| for p in item.get("patterns", []) | |
| ] | |
| for lang in _normalize_languages(item.get("supported_language")): | |
| rec = PatternRecognizer( | |
| supported_entity=item["supported_entity"], | |
| name=f"{item['name']}_{lang}" if len(SUPPORTED_LANGUAGES) > 1 else item["name"], | |
| supported_language=lang, | |
| patterns=patterns, | |
| context=item.get("context"), | |
| ) | |
| recognizers.append(rec) | |
| log.info("Loaded pattern recognizer: %s (%d patterns, langs=%s)", | |
| item["name"], len(patterns), _normalize_languages(item.get("supported_language"))) | |
| for item in cfg.get("deny_list_recognizers", []) or []: | |
| for lang in _normalize_languages(item.get("supported_language")): | |
| rec = PatternRecognizer( | |
| supported_entity=item["supported_entity"], | |
| name=f"{item['name']}_{lang}" if len(SUPPORTED_LANGUAGES) > 1 else item["name"], | |
| supported_language=lang, | |
| deny_list=item.get("deny_list", []), | |
| deny_list_score=float(item.get("score", 1.0)), | |
| ) | |
| recognizers.append(rec) | |
| log.info( | |
| "Loaded deny-list recognizer: %s (%d terms, langs=%s)", | |
| item["name"], len(item.get("deny_list", [])), | |
| _normalize_languages(item.get("supported_language")), | |
| ) | |
| return recognizers | |
| def build_analyzer() -> AnalyzerEngine: | |
| """기본 Presidio 인식기 + custom_patterns.yaml 의 사용자 정의 인식기를 합쳐 엔진 생성.""" | |
| provider = NlpEngineProvider(nlp_configuration=NLP_CONFIG) | |
| nlp_engine = provider.create_engine() | |
| registry = RecognizerRegistry(supported_languages=SUPPORTED_LANGUAGES) | |
| # Presidio 빌트인 인식기는 영문 한정 — 한국어 NLP 엔진에서는 일부만 호환 | |
| registry.load_predefined_recognizers(nlp_engine=nlp_engine, languages=SUPPORTED_LANGUAGES) | |
| for rec in load_custom_recognizers(PATTERN_FILE): | |
| registry.add_recognizer(rec) | |
| return AnalyzerEngine( | |
| registry=registry, | |
| nlp_engine=nlp_engine, | |
| supported_languages=SUPPORTED_LANGUAGES, | |
| ) | |
| analyzer: AnalyzerEngine = build_analyzer() | |
| # 자동 모델 로드 (설정에 따라) | |
| if settings.get("neural_auto_load") and neural.is_installed(): | |
| try: | |
| neural.load_models() | |
| except Exception as e: | |
| log.warning("auto-load neural failed: %s", e) | |
| if settings.get("ko_ner_auto_load") and ko_ner.is_installed(): | |
| try: | |
| ko_ner.load() | |
| except Exception as e: | |
| log.warning("auto-load ko_ner failed: %s", e) | |
| # --------------------------------------------------------------------------- | |
| # 한국형 PII 정규식 템플릿 + 등급 키워드 빠른 추가 후보 (UI 의 룰 페이지용) | |
| # --------------------------------------------------------------------------- | |
| _KR_PII_TEMPLATES = { | |
| "patterns": [ | |
| { | |
| "id": "kr_arc", | |
| "name": "KR_ARC", | |
| "label": "외국인등록번호", | |
| "supported_entity": "KR_ARC", | |
| "regex": r"\b\d{6}-?[5-8]\d{6}\b", | |
| "score": 0.95, | |
| "context": ["외국인등록번호", "외국인", "ARC", "Alien"], | |
| "note": "주민등록번호와 같은 13자리, 뒤 7자리 첫번째 = 5/6/7/8.", | |
| }, | |
| { | |
| "id": "kr_drivers", | |
| "name": "KR_DRIVERS_LICENSE", | |
| "label": "운전면허번호", | |
| "supported_entity": "KR_DRIVERS_LICENSE", | |
| "regex": r"\b\d{2}-\d{2}-\d{6}-\d{2}\b", | |
| "score": 0.9, | |
| "context": ["운전면허", "면허번호", "License"], | |
| "note": "12자리, 형식 'XX-XX-XXXXXX-XX'.", | |
| }, | |
| { | |
| "id": "kr_health", | |
| "name": "KR_HEALTH_INSURANCE", | |
| "label": "건강보험증 번호", | |
| "supported_entity": "KR_HEALTH_INSURANCE", | |
| "regex": r"\b[1-9]-\d{10}\b", | |
| "score": 0.85, | |
| "context": ["건강보험", "보험증"], | |
| "note": "1~9 + - + 10자리.", | |
| }, | |
| { | |
| "id": "kr_carplate", | |
| "name": "KR_CAR_PLATE", | |
| "label": "차량 번호판", | |
| "supported_entity": "KR_CAR_PLATE", | |
| "regex": r"\b(?:\d{2,3}\s*[가-힣]\s*\d{4})\b", | |
| "score": 0.85, | |
| "context": ["차량번호", "차번호", "번호판"], | |
| "note": "2~3자리 + 한글1 + 4자리 (예: 12가3456 / 123가4567).", | |
| }, | |
| { | |
| "id": "kr_employee_id", | |
| "name": "KR_EMPLOYEE_ID", | |
| "label": "사번 (일반)", | |
| "supported_entity": "KR_EMPLOYEE_ID", | |
| "regex": r"\b(?:사번|EMP|EID)[-\s:]?\d{4,8}\b", | |
| "score": 0.7, | |
| "context": ["사번", "직원번호"], | |
| "note": "예: 사번 12345 / EMP-0042 — 조직별 차이가 있어 score 낮음.", | |
| }, | |
| { | |
| "id": "kr_student_id", | |
| "name": "KR_STUDENT_ID", | |
| "label": "학번 (일반)", | |
| "supported_entity": "KR_STUDENT_ID", | |
| "regex": r"\b(?:학번|STD|STU)[-\s:]?\d{6,10}\b", | |
| "score": 0.7, | |
| "context": ["학번"], | |
| "note": "예: 학번 20231234.", | |
| }, | |
| { | |
| "id": "kr_account", | |
| "name": "KR_BANK_ACCOUNT", | |
| "label": "계좌번호 (일반)", | |
| "supported_entity": "KR_BANK_ACCOUNT", | |
| "regex": r"\b\d{2,6}-\d{2,6}-\d{2,8}\b", | |
| "score": 0.6, | |
| "context": ["계좌", "예금주", "이체"], | |
| "note": "은행마다 형식이 달라 score 낮음. 정밀도 위해선 은행별 분리 권장.", | |
| }, | |
| { | |
| "id": "kr_corp_seal", | |
| "name": "KR_CORP_REG_NUMBER", | |
| "label": "법인등록번호", | |
| "supported_entity": "KR_CORP_REG_NUMBER", | |
| "regex": r"\b\d{6}-\d{7}\b", | |
| "score": 0.85, | |
| "context": ["법인등록번호", "법인번호"], | |
| "note": "13자리, 형식 'XXXXXX-XXXXXXX'. (사업자등록번호 10자리와 다름)", | |
| }, | |
| ], | |
| "deny_lists": [ | |
| { | |
| "id": "secret_projects", | |
| "name": "SECRET_PROJECTS", | |
| "label": "보안 프로젝트명 예시", | |
| "supported_entity": "SECRET_PROJECT", | |
| "score": 0.95, | |
| "deny_list": ["프로젝트X", "ProjectStealth", "Operation_Quiet"], | |
| }, | |
| { | |
| "id": "exec_names", | |
| "name": "EXEC_NAMES", | |
| "label": "임원 명단 예시", | |
| "supported_entity": "EXEC_NAMES", | |
| "score": 0.9, | |
| "deny_list": ["김대표", "이부사장", "박전무"], | |
| }, | |
| ], | |
| "grade_keywords": [ | |
| {"keyword": "극비", "weight": 4.0, "label": "극비", | |
| "note": "단독으로도 C 임계값 근접"}, | |
| {"keyword": "top secret", "weight": 4.0, "label": "Top Secret"}, | |
| {"keyword": "대외비", "weight": 3.0, "label": "대외비"}, | |
| {"keyword": "기밀", "weight": 3.0, "label": "기밀"}, | |
| {"keyword": "보안1등급", "weight": 4.0, "label": "보안1등급", | |
| "note": "사내 보안 분류 라벨"}, | |
| {"keyword": "보안2등급", "weight": 2.0, "label": "보안2등급"}, | |
| {"keyword": "보안3등급", "weight": 1.0, "label": "보안3등급"}, | |
| {"keyword": "개인정보보호법", "weight": 1.5, "label": "개인정보보호법"}, | |
| {"keyword": "정보통신망법", "weight": 1.5, "label": "정보통신망법"}, | |
| {"keyword": "신용정보법", "weight": 1.5, "label": "신용정보법"}, | |
| {"keyword": "do not distribute", "weight": 2.5, "label": "Do Not Distribute"}, | |
| {"keyword": "n.d.a", "weight": 2.0, "label": "NDA"}, | |
| {"keyword": "non-disclosure", "weight": 2.0, "label": "Non-Disclosure"}, | |
| {"keyword": "외부유출금지", "weight": 3.0, "label": "외부유출금지"}, | |
| {"keyword": "company confidential", "weight": 2.5, "label": "Company Confidential"}, | |
| ], | |
| } | |
| storage.init_db() | |
| trainmod.bootstrap_initial_version() | |
| # 부팅 시 active 버전이 DB에 있으면 in-memory 버전 라벨 동기화 (가중치는 차후 영속 적재) | |
| _active = storage.active_model_version() | |
| if _active: | |
| from classifier import set_active_version | |
| set_active_version(_active["version"]) | |
| # --------------------------------------------------------------------------- | |
| # 파일 → 텍스트 추출 | |
| # --------------------------------------------------------------------------- | |
| def _smart_decode(raw: bytes) -> str: | |
| """BOM 우선 → utf-8 strict → cp949 / euc-kr → utf-8 replace.""" | |
| if raw.startswith(b"\xef\xbb\xbf"): | |
| return raw[3:].decode("utf-8", errors="replace") | |
| if raw.startswith(b"\xff\xfe"): | |
| return raw[2:].decode("utf-16-le", errors="replace") | |
| if raw.startswith(b"\xfe\xff"): | |
| return raw[2:].decode("utf-16-be", errors="replace") | |
| for enc in ("utf-8", "cp949", "euc-kr"): | |
| try: | |
| return raw.decode(enc) | |
| except UnicodeDecodeError: | |
| continue | |
| return raw.decode("utf-8", errors="replace") | |
| def extract_text(filename: str, raw: bytes) -> str: | |
| ext = Path(filename).suffix.lower() | |
| if ext in {".txt", ".log", ".csv", ".json", ".md", ".yaml", ".yml", ".xml", ".html"}: | |
| return _smart_decode(raw) | |
| if ext == ".pdf": | |
| from pypdf import PdfReader # lazy import | |
| reader = PdfReader(io.BytesIO(raw)) | |
| return "\n".join((p.extract_text() or "") for p in reader.pages) | |
| if ext == ".docx": | |
| from docx import Document # lazy import | |
| doc = Document(io.BytesIO(raw)) | |
| parts = [p.text for p in doc.paragraphs] | |
| for tbl in doc.tables: | |
| for row in tbl.rows: | |
| for cell in row.cells: | |
| parts.append(cell.text) | |
| return "\n".join(parts) | |
| if ext == ".hwp": | |
| from hwp_extract import extract_hwp_text # lazy import | |
| return extract_hwp_text(raw) | |
| return _smart_decode(raw) | |
| # --------------------------------------------------------------------------- | |
| # Flask | |
| # --------------------------------------------------------------------------- | |
| app = Flask(__name__) | |
| app.config["MAX_CONTENT_LENGTH"] = 32 * 1024 * 1024 # 32MB | |
| from flask import g | |
| def _perf_before(): | |
| g._perf_t0 = time.monotonic_ns() | |
| g._perf_ev = {"path": request.path, "method": request.method, "ts_unix": time.time()} | |
| def _perf_after(resp): | |
| if not settings.get("perf_collect"): | |
| return resp | |
| t0 = getattr(g, "_perf_t0", None) | |
| ev = getattr(g, "_perf_ev", None) | |
| if t0 and ev is not None: | |
| ev["total"] = round((time.monotonic_ns() - t0) / 1e6, 3) | |
| ev["status"] = resp.status_code | |
| # /api/logs 같은 잡음 경로는 기록하지 않음 (대시보드 폴링 자기참조 방지) | |
| if not ev["path"].startswith(("/api/logs", "/api/perf", "/api/neural/status")): | |
| perf.record(ev) | |
| return resp | |
| def index(): | |
| return render_template("index.html") | |
| def docs(): | |
| return render_template("docs.html") | |
| def list_recognizers(): | |
| """현재 활성화된 인식기 목록과 지원 엔티티를 반환.""" | |
| recs = analyzer.registry.recognizers | |
| out = [] | |
| for r in recs: | |
| out.append( | |
| { | |
| "name": r.name, | |
| "supported_entities": list(r.supported_entities), | |
| "supported_language": r.supported_language, | |
| } | |
| ) | |
| return jsonify( | |
| { | |
| "recognizer_count": len(out), | |
| "supported_entities": sorted( | |
| {e for r in recs for e in r.supported_entities} | |
| ), | |
| "recognizers": out, | |
| } | |
| ) | |
| def reload_patterns(): | |
| """custom_patterns.yaml 을 다시 읽어 분석기를 재구성한다.""" | |
| global analyzer | |
| try: | |
| analyzer = build_analyzer() | |
| return jsonify({"ok": True, "message": "Patterns reloaded."}) | |
| except Exception as e: # noqa: BLE001 | |
| log.exception("reload failed") | |
| return jsonify({"ok": False, "message": str(e)}), 500 | |
| def _run_analysis(text: str, score_threshold: float, language: str): | |
| """auto / ko / en 분석 → (findings, target_langs).""" | |
| if language == "auto" or language not in SUPPORTED_LANGUAGES: | |
| target_langs = list(SUPPORTED_LANGUAGES) | |
| else: | |
| target_langs = [language] | |
| raw_results = [] | |
| for lang in target_langs: | |
| try: | |
| raw_results.extend(analyzer.analyze( | |
| text=text, language=lang, score_threshold=score_threshold, | |
| )) | |
| except Exception as e: # noqa: BLE001 | |
| log.warning("(%s) failed: %s", lang, e) | |
| dedup: dict = {} | |
| for r in raw_results: | |
| k = (r.start, r.end, r.entity_type) | |
| if k not in dedup or dedup[k].score < r.score: | |
| dedup[k] = r | |
| results = list(dedup.values()) | |
| findings = [] | |
| for r in results: | |
| snippet = text[r.start : r.end] | |
| # ── 노이즈 후처리 — 명백한 오탐 차단 ───────────────────── | |
| if not _accept_finding(r.entity_type, snippet): | |
| continue | |
| # ─────────────────────────────────────────────────────── | |
| findings.append({ | |
| "entity_type": r.entity_type, | |
| "start": r.start, | |
| "end": r.end, | |
| "score": round(float(r.score), 3), | |
| "text": snippet, | |
| "recognizer": (r.recognition_metadata.get("recognizer_name") | |
| if r.recognition_metadata else None), | |
| }) | |
| findings.sort(key=lambda x: (x["start"], -x["score"])) | |
| return findings, target_langs | |
| import re as _re | |
| # entity 별 정규식 패턴 필터 (Presidio 기본 인식기의 과적합 차단) | |
| _ENTITY_MIN_LEN = { | |
| "US_DRIVER_LICENSE": 5, # s3, y3, X0 같은 2~3글자 차단 | |
| "US_SSN": 9, | |
| "US_ITIN": 9, | |
| "US_PASSPORT": 6, | |
| "IN_PAN": 10, | |
| "PERSON": 2, | |
| "LOCATION": 2, | |
| "ORGANIZATION": 2, | |
| "GENERIC_API_KEY": 24, # 보수적 | |
| } | |
| _VERSION_NUM = _re.compile(r"^\s*\d+(\.\d+){1,3}\s*$") # 1.9.16 등 | |
| _SINGLE_NUM = _re.compile(r"^\s*\d{1,4}\s*$") # 단순 숫자 | |
| def _accept_finding(entity_type: str, snippet: str) -> bool: | |
| """Presidio 기본 정규식이 만든 명백한 오탐 차단. | |
| True = keep, False = drop. | |
| """ | |
| s = (snippet or "").strip() | |
| if not s: | |
| return False | |
| # 길이 필터 | |
| min_len = _ENTITY_MIN_LEN.get(entity_type) | |
| if min_len and len(s) < min_len: | |
| return False | |
| # DATE_TIME — 버전 번호 / 단순 숫자 (월·일 의미 X) 거부 | |
| if entity_type == "DATE_TIME": | |
| if _VERSION_NUM.match(s) or _SINGLE_NUM.match(s): | |
| return False | |
| # 매우 짧은 토큰 (1글자) 거부 | |
| if len(s) < 3: | |
| return False | |
| return True | |
| def analyze(): | |
| if "file" not in request.files: | |
| return jsonify({"ok": False, "message": "file field is required"}), 400 | |
| f = request.files["file"] | |
| raw = f.read() | |
| score_threshold = float(request.form.get("score_threshold", 0.3)) | |
| language = (request.form.get("language") or "auto").strip().lower() | |
| ev = getattr(g, "_perf_ev", {}) or {} | |
| try: | |
| with perf.Timer(ev, "extract"): | |
| text = extract_text(f.filename or "uploaded", raw) | |
| except Exception as e: # noqa: BLE001 | |
| log.exception("extract_text failed") | |
| return jsonify({"ok": False, "message": f"failed to read file: {e}"}), 400 | |
| with perf.Timer(ev, "pii_analyze"): | |
| findings, target_langs = _run_analysis(text, score_threshold, language) | |
| # ───────────────────────────────────────────────────────────── | |
| # 한국어 NER 보강 — spaCy 오탐 제거 + KoELECTRA-NER (있으면) 추가 | |
| # ───────────────────────────────────────────────────────────── | |
| has_korean = any("가" <= c <= "" for c in (text[:2000] or "")) | |
| ner_diag = { | |
| "spacy_ner_dropped": 0, | |
| "ko_ner_used": False, | |
| "ko_ner_added": 0, | |
| "heuristic_dropped": 0, | |
| "heuristic_dropped_samples": [], | |
| } | |
| use_ko_ner = ( | |
| has_korean and settings.get("ko_ner_enabled") | |
| and ko_ner.is_installed() and ko_ner.is_loaded() | |
| ) | |
| drop_spacy_ko = use_ko_ner and settings.get("drop_spacy_korean_ner") | |
| if drop_spacy_ko: | |
| before = len(findings) | |
| findings = [ | |
| f for f in findings | |
| if not ( | |
| (f.get("recognizer") or "").startswith("Spacy") | |
| and f.get("entity_type") in ("PERSON", "LOCATION", "ORGANIZATION", "ORG", "DATE_TIME") | |
| ) | |
| ] | |
| ner_diag["spacy_ner_dropped"] = before - len(findings) | |
| if use_ko_ner: | |
| try: | |
| with perf.Timer(ev, "ko_ner"): | |
| ko_findings = ko_ner.extract(text, score_threshold=score_threshold) | |
| findings.extend(ko_findings) | |
| ner_diag["ko_ner_used"] = True | |
| ner_diag["ko_ner_added"] = len(ko_findings) | |
| except Exception as e: # noqa: BLE001 | |
| log.warning("ko_ner extract failed: %s", e) | |
| if settings.get("heuristic_filter"): | |
| with perf.Timer(ev, "ner_filter"): | |
| kept, dropped = ner_filter.filter_findings(findings) | |
| findings = kept | |
| ner_diag["heuristic_dropped"] = len(dropped) | |
| ner_diag["heuristic_dropped_samples"] = [ | |
| {"text": d.get("text"), "entity_type": d.get("entity_type"), | |
| "reason": d.get("filter_reason")} | |
| for d in dropped[:10] | |
| ] | |
| findings.sort(key=lambda x: (x["start"], -x["score"])) | |
| summary: dict[str, int] = {} | |
| for f_ in findings: | |
| summary[f_["entity_type"]] = summary.get(f_["entity_type"], 0) + 1 | |
| with perf.Timer(ev, "classify"): | |
| classification = classify(findings, text) | |
| classification["version"] = active_version() | |
| with perf.Timer(ev, "explain"): | |
| explanation = explain(classification, findings) | |
| # Neural 모델 — 설정에서 비활성이면 skip | |
| neural_pred = None | |
| if (neural.is_installed() and neural._state.get("loaded") | |
| and not settings.get("neural_disable")): | |
| try: | |
| with perf.Timer(ev, "neural"): | |
| neural_pred = neural.predict(text) | |
| alpha_default = settings.get("ensemble_alpha") or 0.5 | |
| classification = neural.ensemble_with_rule( | |
| classification, neural_pred, | |
| alpha=float(request.form.get("ensemble_alpha", alpha_default)), | |
| ) | |
| except Exception as e: # noqa: BLE001 | |
| log.warning("neural predict failed: %s", e) | |
| return jsonify( | |
| { | |
| "ok": True, | |
| "filename": f.filename, | |
| "char_count": len(text), | |
| "score_threshold": score_threshold, | |
| "language": language if language in ("auto",) + tuple(SUPPORTED_LANGUAGES) else "auto", | |
| "languages_used": target_langs, | |
| "summary": summary, | |
| "findings": findings, | |
| "ner_diag": ner_diag, | |
| "classification": classification, | |
| "explanation": explanation, | |
| "neural": neural_pred, | |
| "model_version": active_version(), | |
| "text": text, | |
| } | |
| ) | |
| def pseudonymize(): | |
| """가명화/익명화 PoC — Presidio 검출 결과에 ISO 20889 기법 적용.""" | |
| import pseudo_framework as pf | |
| if "file" in request.files: | |
| f = request.files["file"] | |
| raw = f.read() | |
| try: | |
| text = extract_text(f.filename or "uploaded", raw) | |
| except Exception as e: # noqa: BLE001 | |
| return jsonify({"ok": False, "message": f"파일 읽기 실패: {e}"}), 400 | |
| filename = f.filename | |
| else: | |
| text = request.form.get("text") or "" | |
| filename = "<inline>" | |
| if not text: | |
| return jsonify({"ok": False, "message": "text 가 비어있습니다"}), 400 | |
| score_threshold = float(request.form.get("score_threshold", 0.3)) | |
| language = (request.form.get("language") or "auto").strip().lower() | |
| jurisdictions = [j.strip().upper() for j in (request.form.get("jurisdictions") or "KR,JP,US,EU").split(",") if j.strip()] | |
| treatment_level = (request.form.get("treatment_level") or "pseudonymization").strip().lower() | |
| findings, target_langs = _run_analysis(text, score_threshold, language) | |
| result = pf.run(text, findings, jurisdictions, treatment_level) | |
| result.update({ | |
| "ok": True, | |
| "filename": filename, | |
| "language": language, | |
| "languages_used": target_langs, | |
| "score_threshold": score_threshold, | |
| "char_count": len(text), | |
| "findings_raw_count": len(findings), | |
| }) | |
| return jsonify(result) | |
| def api_decision(): | |
| """SPEC §1 기능 5 — 사용자 최종 분류 기록. | |
| Body (JSON): { | |
| file_name?, text?, char_count?, | |
| ai_grade, ai_score?, ai_confidence?, ai_version?, | |
| user_grade, memo?, findings?, reasons? | |
| } | |
| """ | |
| p = request.get_json(silent=True) or {} | |
| if "ai_grade" not in p or "user_grade" not in p: | |
| return jsonify({"ok": False, "message": "ai_grade and user_grade required"}), 400 | |
| if p["ai_grade"] not in "CSO" or p["user_grade"] not in "CSO": | |
| return jsonify({"ok": False, "message": "grade must be one of C/S/O"}), 400 | |
| text = p.get("text") or "" | |
| text_hash = hashlib.sha256(text.encode("utf-8", errors="ignore")).hexdigest() if text else None | |
| from classifier import gap as _gap # 순환 import 방지를 위해 지연 | |
| g = _gap(p["ai_grade"], p["user_grade"]) | |
| rec = { | |
| "file_name": p.get("file_name"), | |
| "text_hash": text_hash, | |
| "text_excerpt": text[:200], | |
| "char_count": int(p.get("char_count") or len(text)), | |
| "ai_grade": p["ai_grade"], | |
| "ai_score": p.get("ai_score"), | |
| "ai_confidence": p.get("ai_confidence"), | |
| "ai_version": p.get("ai_version") or CLASSIFIER_VERSION, | |
| "user_grade": p["user_grade"], | |
| "gap": g, | |
| "memo": p.get("memo"), | |
| "findings": p.get("findings"), | |
| "reasons": p.get("reasons"), | |
| } | |
| new_id = storage.insert_decision(rec) | |
| return jsonify({"ok": True, "id": new_id, "gap": g}) | |
| # ───────────────────────────────────────────────────────────── | |
| # PKIZIP iframe 임베드 PoC — 외부 분류 결과 누적 | |
| # ───────────────────────────────────────────────────────────── | |
| import sqlite3 as _sqlite3 | |
| import json as _json | |
| from pathlib import Path as _Path | |
| _PKIZIP_DB = _Path(__file__).resolve().parent / "pkizip_classified.db" | |
| def _pkizip_conn(): | |
| conn = _sqlite3.connect(_PKIZIP_DB) | |
| conn.execute(""" | |
| CREATE TABLE IF NOT EXISTS classified ( | |
| id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| created_at TEXT NOT NULL, | |
| kind TEXT NOT NULL, -- 'classified' | 'sealed-only' | |
| grade TEXT, | |
| score REAL, | |
| rationale TEXT, | |
| findings_json TEXT, | |
| language TEXT, | |
| ocr_applied INTEGER NOT NULL DEFAULT 0, | |
| envelope_name TEXT, | |
| envelope_base64 TEXT, -- 봉투 바이트 (선택) | |
| sealed_meta_json TEXT | |
| ) | |
| """) | |
| conn.commit() | |
| return conn | |
| def api_pkizip_classified_collection(): | |
| conn = _pkizip_conn() | |
| try: | |
| if request.method == "GET": | |
| cur = conn.execute( | |
| "SELECT id, created_at, kind, grade, score, rationale, findings_json, language, " | |
| "ocr_applied, envelope_name, (envelope_base64 IS NOT NULL) AS has_envelope, sealed_meta_json " | |
| "FROM classified ORDER BY id DESC LIMIT 200" | |
| ) | |
| rows = cur.fetchall() | |
| out = [] | |
| for r in rows: | |
| out.append({ | |
| "id": r[0], | |
| "created_at": r[1], | |
| "kind": r[2], | |
| "grade": r[3], | |
| "score": r[4], | |
| "rationale": r[5] or "", | |
| "findings": _json.loads(r[6] or "[]"), | |
| "language": r[7], | |
| "ocr_applied": bool(r[8]), | |
| "envelope_name": r[9], | |
| "envelope_base64": None, # 리스트에서는 안 보냄 — GET /:id 에서만 | |
| "sealed_meta": _json.loads(r[11] or "null"), | |
| }) | |
| return jsonify(out) | |
| elif request.method == "POST": | |
| body = request.get_json(force=True, silent=True) or {} | |
| now = datetime.now(timezone.utc).isoformat() | |
| cur = conn.execute( | |
| "INSERT INTO classified (created_at, kind, grade, score, rationale, findings_json, " | |
| "language, ocr_applied, envelope_name, envelope_base64, sealed_meta_json) " | |
| "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", | |
| ( | |
| now, | |
| body.get("kind", "classified"), | |
| body.get("grade"), | |
| body.get("score"), | |
| body.get("rationale", ""), | |
| _json.dumps(body.get("findings", []), ensure_ascii=False), | |
| body.get("language"), | |
| 1 if body.get("ocr_applied") else 0, | |
| body.get("envelope_name"), | |
| body.get("envelope_base64"), | |
| _json.dumps(body.get("sealed_meta"), ensure_ascii=False) if body.get("sealed_meta") else None, | |
| ), | |
| ) | |
| conn.commit() | |
| return jsonify({"ok": True, "id": cur.lastrowid}) | |
| elif request.method == "DELETE": | |
| conn.execute("DELETE FROM classified") | |
| conn.commit() | |
| return jsonify({"ok": True}) | |
| finally: | |
| conn.close() | |
| def api_pkizip_classified_item(item_id: int): | |
| conn = _pkizip_conn() | |
| try: | |
| if request.method == "GET": | |
| cur = conn.execute( | |
| "SELECT id, created_at, kind, grade, score, rationale, findings_json, language, " | |
| "ocr_applied, envelope_name, envelope_base64, sealed_meta_json " | |
| "FROM classified WHERE id = ?", (item_id,)) | |
| r = cur.fetchone() | |
| if not r: | |
| return jsonify({"error": "not found"}), 404 | |
| return jsonify({ | |
| "id": r[0], | |
| "created_at": r[1], | |
| "kind": r[2], | |
| "grade": r[3], | |
| "score": r[4], | |
| "rationale": r[5] or "", | |
| "findings": _json.loads(r[6] or "[]"), | |
| "language": r[7], | |
| "ocr_applied": bool(r[8]), | |
| "envelope_name": r[9], | |
| "envelope_base64": r[10], | |
| "sealed_meta": _json.loads(r[11] or "null"), | |
| }) | |
| elif request.method == "DELETE": | |
| conn.execute("DELETE FROM classified WHERE id = ?", (item_id,)) | |
| conn.commit() | |
| return jsonify({"ok": True}) | |
| finally: | |
| conn.close() | |
| def api_decisions_list(): | |
| limit = int(request.args.get("limit", 50)) | |
| return jsonify({ | |
| "ok": True, | |
| "decisions": storage.list_decisions(limit=limit), | |
| "grade_counts": storage.grade_counts(), | |
| "gap_counts": storage.gap_counts(), | |
| "confusion": storage.confusion_matrix(), | |
| }) | |
| def api_train_status(): | |
| decisions = storage.fetch_all_for_training() | |
| readiness = trainmod.evaluate_readiness(decisions) | |
| return jsonify({ | |
| "ok": True, | |
| "ready": readiness["ready"], | |
| "checks": readiness["checks"], | |
| "totals": readiness["totals"], | |
| "last_run": storage.last_training_run(), | |
| "training_runs": storage.list_training_runs(), | |
| "thresholds": { | |
| "MIN_LABELED": trainmod.MIN_LABELED, | |
| "MIN_PER_GRADE": trainmod.MIN_PER_GRADE, | |
| "MIN_GAP_DECISIONS": trainmod.MIN_GAP_DECISIONS, | |
| }, | |
| }) | |
| def api_train(): | |
| """SPEC §1 기능 6 — 학습 라운드 실행. 결과 + gradient 반환 (적용은 별도).""" | |
| decisions = storage.fetch_all_for_training() | |
| readiness = trainmod.evaluate_readiness(decisions) | |
| payload = request.get_json(silent=True) or {} | |
| force = bool(payload.get("force", False)) | |
| if not readiness["ready"] and not force: | |
| return jsonify({ | |
| "ok": False, | |
| "ready": False, | |
| "checks": readiness["checks"], | |
| "message": "학습 조건 미충족 — force=true 로 강제 실행 가능", | |
| }), 400 | |
| result = trainmod.train_one_round( | |
| decisions, | |
| learning_rate=float(payload.get("learning_rate", trainmod.LEARNING_RATE)), | |
| max_delta=float(payload.get("max_delta", trainmod.MAX_DELTA)), | |
| ) | |
| run_id = storage.insert_training_run({ | |
| "started_at": result["started_at"], | |
| "finished_at": result["finished_at"], | |
| "decisions_count": result["decisions_count"], | |
| "accuracy_before": result["accuracy_before"], | |
| "accuracy_after": result["accuracy_after"], | |
| "sse_before": result["sse_before"], | |
| "sse_after": result["sse_after"], | |
| "weight_deltas": result["weight_deltas"], | |
| "new_weights": result["new_weights"], | |
| "status": result["status"], | |
| "notes": "linear-regression 1-step gradient (rule-v1 PoC)", | |
| }) | |
| result["run_id"] = run_id | |
| return jsonify({"ok": True, "result": result}) | |
| def api_train_apply(): | |
| """SPEC §1 기능 10 — 마지막 학습 결과를 in-memory 가중치에 핫스왑. | |
| + SPEC §1 기능 새 버전 등록 (storage.model_versions 에 영속). | |
| """ | |
| payload = request.get_json(silent=True) or {} | |
| new_weights = payload.get("new_weights") | |
| last_run = storage.last_training_run() | |
| if not new_weights: | |
| if not last_run: | |
| return jsonify({"ok": False, "message": "no training run yet"}), 400 | |
| import json as _json | |
| new_weights = _json.loads(last_run.get("new_weights_json") or "{}") | |
| if not new_weights: | |
| return jsonify({"ok": False, "message": "new_weights empty"}), 400 | |
| diff = trainmod.apply_new_weights( | |
| new_weights, | |
| training_run_id=(last_run.get("id") if last_run else None), | |
| decisions_count=(last_run.get("decisions_count") if last_run else None), | |
| accuracy=(last_run.get("accuracy_after") if last_run else None), | |
| sse=(last_run.get("sse_after") if last_run else None), | |
| notes=payload.get("notes"), | |
| ) | |
| return jsonify({"ok": True, "applied": diff}) | |
| def api_model_active(): | |
| return jsonify({ | |
| "ok": True, | |
| "version": active_version(), | |
| "record": storage.active_model_version(), | |
| "weights": dict(__import__("classifier").ENTITY_WEIGHTS), | |
| }) | |
| def api_models(): | |
| return jsonify({ | |
| "ok": True, | |
| "active": active_version(), | |
| "models": storage.list_model_versions(limit=int(request.args.get("limit", 50))), | |
| }) | |
| def api_stack(): | |
| """현재 동작 중인 SW 스택 + 미통합(계획) 라이브러리 가용성 보고. | |
| 각 모듈이 어디서 (server / client / storage) 동작하는지, 어떤 역할을 | |
| 맡는지, 활성/계획 상태인지 명시 — 사용자가 KoELECTRA 같은 미통합 | |
| 모델의 위치를 확실히 알 수 있게 한다. | |
| """ | |
| import importlib.metadata as md | |
| import platform | |
| import socket | |
| import sys | |
| def ver(pkg: str) -> str | None: | |
| try: | |
| return md.version(pkg) | |
| except md.PackageNotFoundError: | |
| return None | |
| # spaCy 설치 모델 점검 | |
| spacy_models: list[dict] = [] | |
| try: | |
| import spacy # noqa: F401 | |
| for m in ( | |
| "en_core_web_sm", "en_core_web_md", "en_core_web_lg", | |
| "ko_core_news_sm", "ko_core_news_md", "ko_core_news_lg", | |
| ): | |
| mv = ver(m.replace("_", "-")) | |
| spacy_models.append({"name": m, "version": mv, "installed": bool(mv)}) | |
| except ImportError: | |
| pass | |
| # Neural 스택 — 설치돼 있으면 활성으로, 아니면 계획에 남는다 | |
| transformers_v = ver("transformers") | |
| torch_v = ver("torch") | |
| neural_loaded = neural._state.get("loaded", False) | |
| head = neural._state.get("head") or {} | |
| # 활성 (서버에서 실제 import 되어 동작 중) | |
| active = [ | |
| {"name": "Python", "version": sys.version.split()[0], | |
| "where": "server", "role": "런타임", "category": "runtime"}, | |
| {"name": "Flask", "version": ver("flask"), | |
| "where": "server", "role": "HTTP 서버 / 라우팅", "category": "runtime"}, | |
| {"name": "presidio-analyzer", "version": ver("presidio-analyzer"), | |
| "where": "server", "role": "PII 인식기 (정규식 + NER 결합)", "category": "inference"}, | |
| {"name": "spaCy", "version": ver("spacy"), | |
| "where": "server", "role": "NER 엔진 (en/ko)", "category": "inference"}, | |
| {"name": "classifier.py", "version": active_version(), | |
| "where": "server", "role": "보안등급 분류 (rule-v1 점수 모델)", "category": "model"}, | |
| {"name": "explainer.py", "version": "rule-explainer-v1", | |
| "where": "server", "role": "판단 근거 자연어 설명 합성", "category": "model"}, | |
| {"name": "train.py", "version": "—", | |
| "where": "server", "role": "Gradient 계산 + 핫스왑", "category": "model"}, | |
| {"name": "storage.py + SQLite", "version": sqlite3_version(), | |
| "where": "storage", "role": "decisions / training_runs / model_versions", "category": "storage"}, | |
| {"name": "pypdf", "version": ver("pypdf"), | |
| "where": "server", "role": "PDF 텍스트 추출", "category": "extract"}, | |
| {"name": "python-docx", "version": ver("python-docx"), | |
| "where": "server", "role": "DOCX 텍스트 추출", "category": "extract"}, | |
| {"name": "pyhwp", "version": ver("pyhwp"), | |
| "where": "server", "role": "HWP (한컴오피스) 텍스트 추출", "category": "extract"}, | |
| {"name": "olefile", "version": ver("olefile"), | |
| "where": "server", "role": "HWP fallback 파서 (OLE2)", "category": "extract"}, | |
| {"name": "PyYAML", "version": ver("PyYAML"), | |
| "where": "server", "role": "custom_patterns.yaml 파서", "category": "config"}, | |
| {"name": "node-seal", "version": "5.1.5", | |
| "where": "client", "role": "Microsoft SEAL WASM 바인딩 (BFV/CKKS)", "category": "he"}, | |
| ] | |
| # transformers / torch 설치돼 있으면 신경망 스택을 active 로 추가 | |
| if transformers_v and torch_v: | |
| active.extend([ | |
| {"name": "transformers (HuggingFace)", "version": transformers_v, | |
| "where": "server", "role": "KoELECTRA / mDeBERTa 토크나이저 + 모델 로더", "category": "neural"}, | |
| {"name": "torch (PyTorch)", "version": torch_v, | |
| "where": "server", | |
| "role": f"NN 추론 ({neural._state.get('device') or '미로드'})", | |
| "category": "neural"}, | |
| {"name": "neural.py", "version": head.get("version") or "neural-v1 (zero-shot)", | |
| "where": "server", | |
| "role": ("KoELECTRA + mDeBERTa 앙상블 — " | |
| + ("✓ 모델 메모리 적재됨 + " + ("trained head" if head else "zero-shot 모드") | |
| if neural_loaded else "📦 설치됨, 미로드 (POST /api/neural/load 호출 필요)")), | |
| "category": "neural"}, | |
| {"name": KO_MODEL_LBL, "version": "small-v3" if neural_loaded else "—", | |
| "where": "server", | |
| "role": "한국어 등급 분류 백본 (~50MB CLS 임베딩)", | |
| "category": "neural"}, | |
| {"name": EN_MODEL_LBL, "version": "v3-base" if neural_loaded else "—", | |
| "where": "server", | |
| "role": "다국어 등급 분류 백본 (~280MB CLS 임베딩)", | |
| "category": "neural"}, | |
| {"name": "ko_ner.py", | |
| "version": ko_ner.NER_VERSION + ( | |
| f" ({ko_ner._state.get('model_id')})" if ko_ner.is_loaded() else " (미로드)"), | |
| "where": "server", | |
| "role": ("한국어 NER 보강 (spaCy 오탐 대체) — " + | |
| ("✓ 메모리 적재됨" if ko_ner.is_loaded() else "📦 미로드 (POST /api/koner/load)")), | |
| "category": "neural"}, | |
| {"name": "ner_filter.py", "version": "heuristic-v1", | |
| "where": "server", | |
| "role": "spaCy 한국어 PERSON/LOC 휴리스틱 후처리 (길이/조사/어미)", | |
| "category": "neural"}, | |
| ]) | |
| # 계획 중 (SPEC §3 에 명시됐으나 미통합 — 설치 여부 점검) | |
| base_planned = [ | |
| {"name": "transformers (HuggingFace)", | |
| "role": "KoELECTRA / mDeBERTa 토크나이저 + 로더", | |
| "where": "server (예정)", "needs": "—", "status_check": "transformers", | |
| "skip_if_active": True}, | |
| {"name": "torch (PyTorch)", | |
| "role": "fine-tuning (Apple M1 MPS 백엔드)", | |
| "where": "server (예정)", "needs": "—", "status_check": "torch", | |
| "skip_if_active": True}, | |
| {"name": "monologg/koelectra-small-v3-discriminator", | |
| "role": "한국어 등급 분류 (~50MB, INT8 양자화 시 ~12MB)", | |
| "where": "server (예정)", "needs": "transformers + optimum + onnxruntime", | |
| "status_check": "transformers", "skip_if_active": True}, | |
| {"name": "microsoft/mdeberta-v3-base", | |
| "role": "다국어 등급 분류 (~280MB, INT8 ~70MB)", | |
| "where": "server (예정)", "needs": "transformers + optimum + onnxruntime", | |
| "status_check": "transformers", "skip_if_active": True}, | |
| {"name": "optimum", | |
| "role": "PyTorch → ONNX 변환 + INT8 양자화", | |
| "where": "server (예정)", "needs": "—", "status_check": "optimum"}, | |
| {"name": "onnxruntime", | |
| "role": "ONNX 추론 엔진 (Python 측)", | |
| "where": "server (예정)", "needs": "—", "status_check": "onnxruntime"}, | |
| {"name": "FastAPI (별도 백엔드)", | |
| "role": "학습 서버 분리 (SPEC §6.3 권장)", | |
| "where": "별도 프로세스 (예정)", "needs": "—", "status_check": "fastapi"}, | |
| {"name": "onnxruntime-web (WebGPU)", | |
| "role": "브라우저 측 ONNX 추론 (Metal 가속)", | |
| "where": "client (예정)", "needs": "프론트엔드 빌드체인 도입", "status_check": None}, | |
| ] | |
| planned = [] | |
| for p in base_planned: | |
| chk = p.pop("status_check", None) | |
| skip = p.pop("skip_if_active", False) | |
| installed = bool(ver(chk)) if chk else False | |
| if skip and installed: | |
| continue # active 로 이미 노출됨 | |
| p["installed"] = installed | |
| if installed: | |
| p["installed_version"] = ver(chk) | |
| planned.append(p) | |
| return jsonify({ | |
| "ok": True, | |
| "active": active, | |
| "planned": planned, | |
| "spacy_models": spacy_models, | |
| "runtime": { | |
| "host": socket.gethostname(), | |
| "platform": platform.platform(), | |
| "machine": platform.machine(), | |
| "pid": os.getpid(), | |
| }, | |
| }) | |
| def sqlite3_version() -> str: | |
| import sqlite3 | |
| return sqlite3.sqlite_version | |
| def api_perf(): | |
| """대시보드용 — 요청 timing 집계 + 시스템 메트릭.""" | |
| return jsonify({ | |
| "ok": True, | |
| "stats": perf.stats(), | |
| "system": perf.system_metrics(), | |
| "neural": neural.status(), | |
| "ko_ner": ko_ner.status(), | |
| "shap": shap_explain.status(), | |
| "active_model": active_version(), | |
| }) | |
| def api_koner_status(): | |
| return jsonify({"ok": True, **ko_ner.status()}) | |
| def api_koner_load(): | |
| """후보 모델 우선순위로 로드 시도 (~50MB 다운로드).""" | |
| s = ko_ner.load() | |
| if s.get("ok") is False: | |
| return jsonify({"ok": False, "status": s, "message": s.get("message")}), 400 | |
| return jsonify({"ok": True, "status": s}) | |
| def api_koner_test(): | |
| """디버그 — 임의 텍스트로 KoELECTRA-NER 결과 미리보기.""" | |
| p = request.get_json(silent=True) or {} | |
| text = p.get("text") or "" | |
| if not text: | |
| return jsonify({"ok": False, "message": "text required"}), 400 | |
| if not ko_ner.is_loaded(): | |
| return jsonify({"ok": False, "message": "ko_ner not loaded — POST /api/koner/load 먼저"}), 400 | |
| findings = ko_ner.extract(text, score_threshold=float(p.get("score_threshold", 0.5))) | |
| return jsonify({"ok": True, "findings": findings, "count": len(findings)}) | |
| def api_perf_reset(): | |
| perf.reset() | |
| return jsonify({"ok": True}) | |
| def api_logs(): | |
| level = request.args.get("level") | |
| contains = request.args.get("q") | |
| limit = int(request.args.get("limit", 200)) | |
| return jsonify({ | |
| "ok": True, | |
| "logs": logging_setup.recent(level=level, contains=contains, limit=limit), | |
| "level": logging_setup.get_level(), | |
| "buffer": logging_setup.buffer_size(), | |
| }) | |
| def api_logs_level(): | |
| p = request.get_json(silent=True) or {} | |
| level = (p.get("level") or "INFO").upper() | |
| try: | |
| logging_setup.set_level(level) | |
| settings.save({"log_level": level}) | |
| except ValueError as e: | |
| return jsonify({"ok": False, "message": str(e)}), 400 | |
| return jsonify({"ok": True, "level": level}) | |
| def api_settings(): | |
| if request.method == "POST": | |
| p = request.get_json(silent=True) or {} | |
| cur = settings.save(p) | |
| if "log_level" in p: | |
| try: logging_setup.set_level(cur["log_level"]) | |
| except ValueError: pass | |
| return jsonify({"ok": True, "settings": cur}) | |
| return jsonify({"ok": True, "settings": settings.load(), "defaults": settings.DEFAULTS}) | |
| def api_neural_shap(): | |
| """SHAP 토큰 기여도 분석. 요청별 max_evals 조정 가능. 옵션 비활성 시 거부.""" | |
| p = request.get_json(silent=True) or {} | |
| if not settings.get("shap_enabled") and not p.get("force"): | |
| return jsonify({"ok": False, "message": "SHAP 가 설정에서 비활성화돼 있습니다 (설정 페이지에서 활성화)"}), 400 | |
| text = (p.get("text") or "").strip() | |
| if not text: | |
| return jsonify({"ok": False, "message": "text required"}), 400 | |
| if not shap_explain.is_available(): | |
| return jsonify({"ok": False, "message": "shap 라이브러리 미설치"}), 400 | |
| if not neural._state.get("loaded"): | |
| return jsonify({"ok": False, "message": "신경망 모델 미로드 — POST /api/neural/load 먼저"}), 400 | |
| max_evals = int(p.get("max_evals") or settings.get("shap_max_evals") or 60) | |
| ev = getattr(g, "_perf_ev", {}) or {} | |
| with perf.Timer(ev, "shap"): | |
| result = shap_explain.compute(text, max_evals=max_evals) | |
| if not result.get("ok"): | |
| return jsonify(result), 400 | |
| return jsonify({"ok": True, "result": result}) | |
| def _analyze_text_for_anonymize(text: str) -> list[dict]: | |
| """익명화 사이클에서 재분석용 — auto language, dedup.""" | |
| raw = [] | |
| for lang in SUPPORTED_LANGUAGES: | |
| try: | |
| raw.extend(analyzer.analyze(text=text, language=lang, score_threshold=0.3)) | |
| except Exception: | |
| pass | |
| dedup: dict = {} | |
| for r in raw: | |
| k = (r.start, r.end, r.entity_type) | |
| if k not in dedup or dedup[k].score < r.score: | |
| dedup[k] = r | |
| return [ | |
| {"entity_type": r.entity_type, "start": r.start, "end": r.end, | |
| "score": float(r.score), "text": text[r.start:r.end]} | |
| for r in dedup.values() | |
| ] | |
| def _classify_for_anonymize(text: str) -> dict: | |
| findings = _analyze_text_for_anonymize(text) | |
| c = classify(findings, text) | |
| c["version"] = active_version() | |
| return c | |
| def api_anonymize_policy(): | |
| if request.method == "POST": | |
| p = request.get_json(silent=True) or {} | |
| if "entities" not in p and "target_grade" not in p and "max_iterations" not in p: | |
| return jsonify({"ok": False, "message": "no recognizable policy fields"}), 400 | |
| cur = anonymization.load_policy() | |
| if "entities" in p and isinstance(p["entities"], dict): | |
| cur["entities"].update(p["entities"]) | |
| if "target_grade" in p and p["target_grade"] in ("C", "S", "O"): | |
| cur["target_grade"] = p["target_grade"] | |
| if "max_iterations" in p: | |
| try: cur["max_iterations"] = max(1, min(10, int(p["max_iterations"]))) | |
| except (TypeError, ValueError): pass | |
| anonymization.save_policy(cur) | |
| return jsonify({"ok": True, "policy": cur}) | |
| return jsonify({"ok": True, | |
| "policy": anonymization.load_policy(), | |
| "default": anonymization.DEFAULT_POLICY, | |
| "methods": ["mask", "remove", "replace", "generalize", "shift", "round"]}) | |
| def api_anonymize(): | |
| """text + (optional findings, policy override) → 단일 패스 익명화.""" | |
| p = request.get_json(silent=True) or {} | |
| text = p.get("text") or "" | |
| if not text: | |
| return jsonify({"ok": False, "message": "text required"}), 400 | |
| findings = p.get("findings") | |
| if findings is None: | |
| findings = _analyze_text_for_anonymize(text) | |
| pol = anonymization.load_policy() | |
| if isinstance(p.get("policy"), dict): | |
| pol = {**pol, **p["policy"]} | |
| if "entities" in p["policy"]: | |
| pol["entities"] = {**pol["entities"], **p["policy"]["entities"]} | |
| result = anonymization.apply_policy(text, findings, pol) | |
| re_classification = _classify_for_anonymize(result["anonymized_text"]) | |
| return jsonify({ | |
| "ok": True, | |
| "result": result, | |
| "before_grade": _classify_for_anonymize(text)["grade"], | |
| "after_classification": re_classification, | |
| }) | |
| def api_anonymize_downgrade(): | |
| """Iterative anonymization until target_grade reached.""" | |
| p = request.get_json(silent=True) or {} | |
| text = p.get("text") or "" | |
| if not text: | |
| return jsonify({"ok": False, "message": "text required"}), 400 | |
| findings = p.get("findings") | |
| if findings is None: | |
| findings = _analyze_text_for_anonymize(text) | |
| pol = anonymization.load_policy() | |
| if isinstance(p.get("policy"), dict): | |
| pol = {**pol, **p["policy"]} | |
| out = anonymization.downgrade_to_target( | |
| text, findings, | |
| classify_fn=_classify_for_anonymize, | |
| analyze_fn=_analyze_text_for_anonymize, | |
| policy=pol, | |
| ) | |
| return jsonify({"ok": True, **out}) | |
| def api_neural_status(): | |
| return jsonify({"ok": True, **neural.status()}) | |
| def api_neural_load(): | |
| """KoELECTRA + mDeBERTa 다운로드 + 메모리 적재. 첫 호출만 느림 (~330MB).""" | |
| s = neural.load_models() | |
| if s.get("ok") is False: | |
| return jsonify({"ok": False, "status": s, "message": s.get("message")}), 400 | |
| return jsonify({"ok": True, "status": s}) | |
| def api_neural_predict(): | |
| payload = request.get_json(silent=True) or {} | |
| text = (payload.get("text") or "").strip() | |
| if not text: | |
| return jsonify({"ok": False, "message": "text required"}), 400 | |
| if not neural._state.get("loaded"): | |
| return jsonify({"ok": False, "message": "neural models not loaded — call /api/neural/load first"}), 400 | |
| return jsonify({"ok": True, "prediction": neural.predict(text)}) | |
| def api_neural_train_head(): | |
| """저장된 사용자 결정으로 LR head 적합. 최소 3건 + 2개 등급 필요.""" | |
| if not neural.is_installed(): | |
| return jsonify({"ok": False, "message": "transformers / torch 미설치"}), 400 | |
| decisions = storage.fetch_all_for_training() | |
| if not decisions: | |
| return jsonify({"ok": False, "message": "결정 누적 없음 — 먼저 사용자 라벨을 저장하세요"}), 400 | |
| try: | |
| head = neural.train_head(decisions) | |
| except Exception as e: | |
| return jsonify({"ok": False, "message": str(e)}), 400 | |
| return jsonify({"ok": True, "head": { | |
| "version": head["version"], | |
| "parent_version": head.get("parent_version"), | |
| "decisions_count": head["decisions_count"], | |
| "accuracy_train": head["accuracy_train"], | |
| "loss_first": head["loss_first"], | |
| "loss_last": head["loss_last"], | |
| "feature_dim": head["feature_dim"], | |
| "classes": head["classes"], | |
| }}) | |
| def api_neural_reset(): | |
| return jsonify({"ok": True, **neural.reset_head()}) | |
| def api_rules_yaml(): | |
| """custom_patterns.yaml 의 raw + 파싱본 + 통계.""" | |
| if not PATTERN_FILE.exists(): | |
| return jsonify({"ok": False, "message": "custom_patterns.yaml not found"}), 404 | |
| raw = PATTERN_FILE.read_text(encoding="utf-8") | |
| cfg = yaml.safe_load(raw) or {} | |
| return jsonify({ | |
| "ok": True, | |
| "path": str(PATTERN_FILE), | |
| "raw": raw, | |
| "parsed": cfg, | |
| "counts": { | |
| "pattern_recognizers": len(cfg.get("pattern_recognizers") or []), | |
| "deny_list_recognizers": len(cfg.get("deny_list_recognizers") or []), | |
| }, | |
| }) | |
| def _validate_regex(rgx: str) -> tuple[bool, str | None]: | |
| import re as _re | |
| try: | |
| _re.compile(rgx) | |
| return True, None | |
| except _re.error as e: | |
| return False, str(e) | |
| def _save_yaml(cfg: dict) -> None: | |
| PATTERN_FILE.write_text( | |
| yaml.safe_dump(cfg, allow_unicode=True, sort_keys=False), | |
| encoding="utf-8", | |
| ) | |
| def _rebuild_analyzer(): | |
| global analyzer | |
| analyzer = build_analyzer() | |
| def api_rules_add_pattern(): | |
| """정규식 인식기 추가. body: {name, supported_entity?, supported_language?, regex, score?, pattern_name?, context?}""" | |
| p = request.get_json(silent=True) or {} | |
| name = (p.get("name") or "").strip() | |
| regex = (p.get("regex") or "").strip() | |
| if not name or not regex: | |
| return jsonify({"ok": False, "message": "name and regex required"}), 400 | |
| ok, err = _validate_regex(regex) | |
| if not ok: | |
| return jsonify({"ok": False, "message": f"invalid regex: {err}"}), 400 | |
| entity = (p.get("supported_entity") or name).strip() | |
| language = p.get("supported_language") or "any" | |
| score = float(p.get("score") or 0.85) | |
| patname = (p.get("pattern_name") or name).strip() | |
| context = p.get("context") or [] | |
| if isinstance(context, str): | |
| context = [c.strip() for c in context.split(",") if c.strip()] | |
| cfg = yaml.safe_load(PATTERN_FILE.read_text(encoding="utf-8")) or {} | |
| pats = cfg.setdefault("pattern_recognizers", []) | |
| # 중복 이름 차단 | |
| if any((r.get("name") == name) for r in pats): | |
| return jsonify({"ok": False, "message": f"recognizer '{name}' already exists"}), 409 | |
| pats.append({ | |
| "name": name, | |
| "supported_entity": entity, | |
| "supported_language": language, | |
| "patterns": [{"name": patname, "regex": regex, "score": score}], | |
| "context": list(context) if context else [], | |
| }) | |
| _save_yaml(cfg) | |
| _rebuild_analyzer() | |
| return jsonify({"ok": True, "name": name, "entity": entity}) | |
| def api_rules_del_pattern(name: str): | |
| cfg = yaml.safe_load(PATTERN_FILE.read_text(encoding="utf-8")) or {} | |
| pats = cfg.get("pattern_recognizers") or [] | |
| new_pats = [r for r in pats if r.get("name") != name] | |
| if len(new_pats) == len(pats): | |
| return jsonify({"ok": False, "message": f"'{name}' not found"}), 404 | |
| cfg["pattern_recognizers"] = new_pats | |
| _save_yaml(cfg) | |
| _rebuild_analyzer() | |
| return jsonify({"ok": True, "removed": name}) | |
| def api_rules_add_deny(): | |
| """deny-list 인식기 추가. body: {name, supported_entity?, supported_language?, deny_list[], score?}""" | |
| p = request.get_json(silent=True) or {} | |
| name = (p.get("name") or "").strip() | |
| deny = p.get("deny_list") or [] | |
| if isinstance(deny, str): | |
| deny = [w.strip() for w in deny.replace(",", "\n").splitlines() if w.strip()] | |
| if not name or not deny: | |
| return jsonify({"ok": False, "message": "name and non-empty deny_list required"}), 400 | |
| entity = (p.get("supported_entity") or name).strip() | |
| language = p.get("supported_language") or "any" | |
| score = float(p.get("score") or 0.95) | |
| cfg = yaml.safe_load(PATTERN_FILE.read_text(encoding="utf-8")) or {} | |
| denies = cfg.setdefault("deny_list_recognizers", []) | |
| if any((r.get("name") == name) for r in denies): | |
| return jsonify({"ok": False, "message": f"recognizer '{name}' already exists"}), 409 | |
| denies.append({ | |
| "name": name, | |
| "supported_entity": entity, | |
| "supported_language": language, | |
| "deny_list": list(deny), | |
| "score": score, | |
| }) | |
| _save_yaml(cfg) | |
| _rebuild_analyzer() | |
| return jsonify({"ok": True, "name": name, "terms": len(deny)}) | |
| def api_rules_del_deny(name: str): | |
| cfg = yaml.safe_load(PATTERN_FILE.read_text(encoding="utf-8")) or {} | |
| denies = cfg.get("deny_list_recognizers") or [] | |
| new_d = [r for r in denies if r.get("name") != name] | |
| if len(new_d) == len(denies): | |
| return jsonify({"ok": False, "message": f"'{name}' not found"}), 404 | |
| cfg["deny_list_recognizers"] = new_d | |
| _save_yaml(cfg) | |
| _rebuild_analyzer() | |
| return jsonify({"ok": True, "removed": name}) | |
| def api_rules_add_grade_kw(): | |
| """등급 점수 키워드 추가/갱신. body: {keyword, weight, label?}""" | |
| import classifier as _c | |
| p = request.get_json(silent=True) or {} | |
| kw = (p.get("keyword") or "").strip() | |
| if not kw: | |
| return jsonify({"ok": False, "message": "keyword required"}), 400 | |
| try: | |
| w = float(p.get("weight") or 2.0) | |
| except ValueError: | |
| return jsonify({"ok": False, "message": "weight must be a number"}), 400 | |
| label = (p.get("label") or kw).strip() | |
| res = _c.add_extra_grade_keyword(kw, w, label) | |
| return jsonify({"ok": True, "result": res, "active_count": len(_c.GRADE_KEYWORDS)}) | |
| def api_rules_del_grade_kw(keyword: str): | |
| import classifier as _c | |
| res = _c.remove_extra_grade_keyword(keyword) | |
| if not res.get("removed"): | |
| return jsonify({"ok": False, **res}), 404 | |
| return jsonify({"ok": True, "result": res, "active_count": len(_c.GRADE_KEYWORDS)}) | |
| def api_rules_list_grade_kw(): | |
| import classifier as _c | |
| return jsonify({ | |
| "ok": True, | |
| "extras": _c.list_extra_grade_keywords(), | |
| "all": [{"keyword": kw, "weight": w, "label": lbl} for (kw, w, lbl) in _c.GRADE_KEYWORDS], | |
| "builtins": [lbl for (_kw, _w, lbl) in _c._BUILTIN_GRADE_KEYWORDS], | |
| }) | |
| def api_rules_templates(): | |
| """한국형 PII 정규식 템플릿 + 등급 키워드 빠른 추가 후보.""" | |
| return jsonify({"ok": True, **_KR_PII_TEMPLATES}) | |
| def api_classifier_rules(): | |
| """현재 분류기/학습기의 모든 상수를 한 번에 반환 — 스코어링 룰 페이지용.""" | |
| import classifier as _c | |
| return jsonify({ | |
| "ok": True, | |
| "version": active_version(), | |
| "explainer": "rule-explainer-v1", | |
| "entity_weights": dict(_c.ENTITY_WEIGHTS), | |
| "default_entity_weight": _c.DEFAULT_ENTITY_WEIGHT, | |
| "grade_keywords": [ | |
| {"keyword": kw, "weight": w, "label": lbl} | |
| for (kw, w, lbl) in _c.GRADE_KEYWORDS | |
| ], | |
| "kw_count_cap": _c.KW_COUNT_CAP, | |
| "thresholds": {"C": _c.C_THRESHOLD, "S": _c.S_THRESHOLD}, | |
| "training": { | |
| "MIN_LABELED": trainmod.MIN_LABELED, | |
| "MIN_PER_GRADE": trainmod.MIN_PER_GRADE, | |
| "MIN_GAP_DECISIONS": trainmod.MIN_GAP_DECISIONS, | |
| "LEARNING_RATE": trainmod.LEARNING_RATE, | |
| "MAX_DELTA": trainmod.MAX_DELTA, | |
| "TARGET_SCORE": trainmod.TARGET_SCORE, | |
| }, | |
| "grade_definition": { | |
| "C": "위험 (Critical) — 직접식별자 / 강한 등급 라벨", | |
| "S": "민감 (Sensitive) — API 키 · 사업자번호 · 내부 프로젝트", | |
| "O": "공개 (Open)", | |
| }, | |
| }) | |
| def api_classify(): | |
| """본문(text) 또는 (findings + text) 만 가지고 등급 분류. | |
| Body (JSON): | |
| { "text": "...", "findings": [...]?, "language": "auto" } | |
| findings 가 없으면 내부적으로 analyze 한 뒤 classifier 에 넘긴다. | |
| """ | |
| payload = request.get_json(silent=True) or {} | |
| text = (payload.get("text") or "").strip() | |
| findings = payload.get("findings") | |
| language = (payload.get("language") or "auto").strip().lower() | |
| if not text and not findings: | |
| return jsonify({"ok": False, "message": "text or findings required"}), 400 | |
| if findings is None: | |
| target_langs = ( | |
| list(SUPPORTED_LANGUAGES) | |
| if language == "auto" or language not in SUPPORTED_LANGUAGES | |
| else [language] | |
| ) | |
| score_threshold = float(payload.get("score_threshold", 0.3)) | |
| raw_results = [] | |
| for lang in target_langs: | |
| try: | |
| raw_results.extend(analyzer.analyze( | |
| text=text, language=lang, score_threshold=score_threshold, | |
| )) | |
| except Exception as e: # noqa: BLE001 | |
| log.warning("analyze(%s) failed: %s", lang, e) | |
| dedup: dict = {} | |
| for r in raw_results: | |
| k = (r.start, r.end, r.entity_type) | |
| if k not in dedup or dedup[k].score < r.score: | |
| dedup[k] = r | |
| findings = [ | |
| { | |
| "entity_type": r.entity_type, | |
| "start": r.start, | |
| "end": r.end, | |
| "score": round(float(r.score), 3), | |
| "text": text[r.start:r.end], | |
| } | |
| for r in dedup.values() | |
| ] | |
| classification = classify(findings, text) | |
| classification["version"] = active_version() | |
| explanation = explain(classification, findings) | |
| return jsonify({ | |
| "ok": True, | |
| "classifier_version": CLASSIFIER_VERSION, | |
| "model_version": active_version(), | |
| "classification": classification, | |
| "explanation": explanation, | |
| "findings_used": len(findings or []), | |
| }) | |
| # ============================================================ | |
| # 컴플라이언스 — KR (PIPA) / US (HIPAA·CCPA) / JP (APPI) / EU (GDPR) | |
| # ============================================================ | |
| # 4개 관할 모두 동일 스키마: entities + sectoral_guidelines + breach_draft 빌더 | |
| # ============================================================ | |
| # --- KR (PIPA) ----------------------------------------------------------- | |
| _KR_ENTITY_META: dict = { | |
| "KR_RRN": { | |
| "category": "고유식별정보 (§24)", | |
| "law": "개인정보보호법 §24·§24-2", | |
| "law_url": "https://www.law.go.kr/법령/개인정보보호법", | |
| "treatment": "mask + 가명화 시 비가역 토큰", | |
| "rationale": "주민등록번호는 §24-2 법령 근거 없이 처리 금지. 가명정보 변환 시 비가역 보장 필수.", | |
| "implemented": True, | |
| }, | |
| "KR_PASSPORT": { | |
| "category": "고유식별정보 (§24)", | |
| "law": "개인정보보호법 §24", | |
| "law_url": "https://www.law.go.kr/법령/개인정보보호법", | |
| "treatment": "mask / tokenize", | |
| "rationale": "여권번호는 §24 고유식별정보 — 별도 동의 또는 법령 근거 필요.", | |
| "implemented": True, | |
| }, | |
| "KR_PHONE": { | |
| "category": "개인정보 (약식별자)", | |
| "law": "개인정보보호법 §2", | |
| "law_url": "https://www.law.go.kr/법령/개인정보보호법", | |
| "treatment": "mask (뒤 4자리 유지)", | |
| "rationale": "전화번호 단독으로 특정 개인 식별 가능 → 개인정보.", | |
| "implemented": True, | |
| }, | |
| "KR_BIZ_NO": { | |
| "category": "법인정보 + 개인사업자 시 개인정보", | |
| "law": "개인정보보호법 §2 (사업자번호 자체는 비개인정보, 개인사업자 결합 시 개인정보)", | |
| "law_url": "https://www.law.go.kr/법령/개인정보보호법", | |
| "treatment": "mask (선택)", | |
| "rationale": "법인 사업자번호는 일반 공개 정보. 개인사업자번호는 개인정보로 취급.", | |
| "implemented": True, | |
| }, | |
| "KR_ADDRESS": { | |
| "category": "개인정보 (약식별자)", | |
| "law": "개인정보보호법 §2", | |
| "law_url": "https://www.law.go.kr/법령/개인정보보호법", | |
| "treatment": "generalize (시·도 단위까지)", | |
| "rationale": "주소는 단독 또는 결합으로 식별 가능 — 행정구역 단위 일반화 권장.", | |
| "implemented": True, | |
| }, | |
| "EMAIL_ADDRESS": { | |
| "category": "개인정보 (약식별자)", | |
| "law": "개인정보보호법 §2", | |
| "law_url": "https://www.law.go.kr/법령/개인정보보호법", | |
| "treatment": "replace (해시 / pseudonym)", | |
| "rationale": "이메일은 일반적으로 식별 가능 — 가명화 시 local 부분 해시 + 도메인 유지.", | |
| "implemented": True, | |
| }, | |
| "CREDIT_CARD": { | |
| "category": "민감/금융 정보", | |
| "law": "신용정보법 §32 / PCI-DSS Req 3.4", | |
| "law_url": "https://www.law.go.kr/법령/신용정보의이용및보호에관한법률", | |
| "treatment": "mask (앞 6 + 뒤 4 유지)", | |
| "rationale": "신용정보법 별도 적용. PCI-DSS 와의 정합성.", | |
| "implemented": True, | |
| }, | |
| "KR_SENSITIVE": { | |
| "category": "민감정보 (§23) — 사상·신념·노조·정치·건강·성생활·유전·전과", | |
| "law": "개인정보보호법 §23", | |
| "law_url": "https://www.law.go.kr/법령/개인정보보호법", | |
| "treatment": "remove + 별도 동의 검증", | |
| "rationale": "민감정보 처리는 §23 별도 동의 필수. 의료/사상 키워드 검출기 미구현.", | |
| "implemented": False, | |
| }, | |
| } | |
| _KR_SECTORAL: list = [ | |
| {"sector": "금융", "regulator": "FSC + 신용정보원", | |
| "guideline": "신용정보의 이용 및 보호에 관한 법률 + 금융분야 가이드라인", | |
| "url": "https://www.fsc.go.kr/po010101", | |
| "implemented": False, | |
| "note": "CREDIT_CARD 만 부분 반영. 신용정보 통합 평가 미구현."}, | |
| {"sector": "의료", "regulator": "보건복지부 + KISA", | |
| "guideline": "의료기관 개인정보보호 가이드라인", | |
| "url": "https://www.mohw.go.kr/menu.es?mid=a10705020600", | |
| "implemented": False, | |
| "note": "민감정보 §23 (건강·유전 등) 검출 미구현."}, | |
| {"sector": "교육", "regulator": "교육부", | |
| "guideline": "학생 개인정보보호 지침", | |
| "url": "https://www.moe.go.kr/", | |
| "implemented": False, | |
| "note": "학생/미성년자 정보 별도 보호 규정 미구현."}, | |
| {"sector": "정보통신", "regulator": "방송통신위원회 + KISA", | |
| "guideline": "정보통신망법 + 위치정보법", | |
| "url": "https://www.kcc.go.kr/", | |
| "implemented": False, | |
| "note": "쿠키/위치정보 동의 모듈 미구현."}, | |
| ] | |
| # --- US (HIPAA + CCPA) --------------------------------------------------- | |
| _US_ENTITY_META: dict = { | |
| "US_SSN": { | |
| "category": "HIPAA Safe Harbor 항목 #2 / CCPA Sensitive PI", | |
| "law": "HIPAA 45 CFR §164.514(b)(2)(i)(B) / CCPA §1798.140(ae)", | |
| "law_url": "https://www.hhs.gov/hipaa/for-professionals/privacy/special-topics/de-identification/", | |
| "treatment": "tokenize / suppress", | |
| "rationale": "Safe Harbor 18 항목 중 #2. CCPA 에서도 Sensitive Personal Information.", | |
| "implemented": True, | |
| }, | |
| "PHONE_NUMBER": { | |
| "category": "HIPAA Safe Harbor 항목 #5", | |
| "law": "HIPAA 45 CFR §164.514(b)(2)(i)(E)", | |
| "law_url": "https://www.hhs.gov/hipaa/for-professionals/privacy/", | |
| "treatment": "mask", | |
| "rationale": "Safe Harbor 의 모든 telephone number 제거 대상.", | |
| "implemented": True, | |
| }, | |
| "EMAIL_ADDRESS": { | |
| "category": "HIPAA Safe Harbor 항목 #4", | |
| "law": "HIPAA 45 CFR §164.514(b)(2)(i)(D)", | |
| "law_url": "https://www.hhs.gov/hipaa/for-professionals/privacy/", | |
| "treatment": "replace / hash", | |
| "rationale": "Safe Harbor electronic mail addresses — 모두 제거.", | |
| "implemented": True, | |
| }, | |
| "CREDIT_CARD": { | |
| "category": "HIPAA #10 (account numbers) / PCI-DSS / GLBA NPI", | |
| "law": "GLBA §501 / PCI-DSS Req 3.4 / FCRA", | |
| "law_url": "https://www.ftc.gov/legal-library/browse/statutes/gramm-leach-bliley-act", | |
| "treatment": "mask (last 4)", | |
| "rationale": "GLBA 금융정보. PCI-DSS Req 3.4 (PAN 보호) 정합.", | |
| "implemented": True, | |
| }, | |
| "LOCATION": { | |
| "category": "HIPAA Safe Harbor 항목 #1 (geographic)", | |
| "law": "HIPAA 45 CFR §164.514(b)(2)(i)(B)", | |
| "law_url": "https://www.hhs.gov/hipaa/for-professionals/privacy/", | |
| "treatment": "generalize (state level / ZIP3)", | |
| "rationale": "Safe Harbor — 인구 ≥20,000 인 ZIP3 만 허용. 그 외 모두 제거.", | |
| "implemented": True, | |
| }, | |
| "PERSON": { | |
| "category": "HIPAA Safe Harbor 항목 #1 (names)", | |
| "law": "HIPAA 45 CFR §164.514(b)(2)(i)(A)", | |
| "law_url": "https://www.hhs.gov/hipaa/for-professionals/privacy/", | |
| "treatment": "replace ([PERSON_n])", | |
| "rationale": "Safe Harbor — 환자/친지/고용주의 모든 names 제거.", | |
| "implemented": True, | |
| }, | |
| "IP_ADDRESS": { | |
| "category": "HIPAA Safe Harbor 항목 #15", | |
| "law": "HIPAA 45 CFR §164.514(b)(2)(i)(O)", | |
| "law_url": "https://www.hhs.gov/hipaa/for-professionals/privacy/", | |
| "treatment": "ip_truncate (/24)", | |
| "rationale": "Safe Harbor IP address numbers — 제거 또는 마지막 옥텟 절단.", | |
| "implemented": True, | |
| }, | |
| "DATE_TIME": { | |
| "category": "HIPAA Safe Harbor 항목 #3 (dates)", | |
| "law": "HIPAA 45 CFR §164.514(b)(2)(i)(C)", | |
| "law_url": "https://www.hhs.gov/hipaa/for-professionals/privacy/", | |
| "treatment": "shift (year only)", | |
| "rationale": "Safe Harbor — 연도 제외 모든 날짜 제거. 89세 이상 연령은 90+ 로 통합.", | |
| "implemented": True, | |
| }, | |
| "URL": { | |
| "category": "HIPAA Safe Harbor 항목 #14", | |
| "law": "HIPAA 45 CFR §164.514(b)(2)(i)(N)", | |
| "law_url": "https://www.hhs.gov/hipaa/for-professionals/privacy/", | |
| "treatment": "replace ([URL])", | |
| "rationale": "Web URLs — Safe Harbor 항목.", | |
| "implemented": True, | |
| }, | |
| "US_HEALTH_INFO": { | |
| "category": "PHI (Protected Health Information)", | |
| "law": "HIPAA Privacy Rule", | |
| "law_url": "https://www.hhs.gov/hipaa/for-professionals/privacy/", | |
| "treatment": "remove / Limited Data Set", | |
| "rationale": "병력·진단·처방 등 PHI 검출기 미구현. ICD-10 / DRG 키워드 룰 필요.", | |
| "implemented": False, | |
| }, | |
| } | |
| _US_SECTORAL: list = [ | |
| {"sector": "Healthcare (HIPAA)", "regulator": "HHS Office for Civil Rights", | |
| "guideline": "HIPAA Privacy Rule + Security Rule + Breach Notification Rule", | |
| "url": "https://www.hhs.gov/hipaa/", | |
| "implemented": True, | |
| "note": "Safe Harbor 18 식별자 중 대다수 검출 가능. PHI/진료기록 키워드 룰 미구현."}, | |
| {"sector": "Finance (GLBA)", "regulator": "FTC + 연방준비제도", | |
| "guideline": "GLBA Privacy Rule + Safeguards Rule", | |
| "url": "https://www.ftc.gov/legal-library/browse/statutes/gramm-leach-bliley-act", | |
| "implemented": False, | |
| "note": "NPI (Nonpublic Personal Information) 별도 분류 미구현."}, | |
| {"sector": "California (CCPA/CPRA)", "regulator": "California Privacy Protection Agency", | |
| "guideline": "California Consumer Privacy Act + 2023 CPRA 개정", | |
| "url": "https://oag.ca.gov/privacy/ccpa", | |
| "implemented": False, | |
| "note": "Sensitive Personal Information (Cal. Civ. §1798.140(ae)) 별도 카테고리. opt-out 권리 UI 미구현."}, | |
| {"sector": "Children (COPPA)", "regulator": "FTC", | |
| "guideline": "Children's Online Privacy Protection Act — 13세 미만", | |
| "url": "https://www.ftc.gov/legal-library/browse/rules/childrens-online-privacy-protection-rule-coppa", | |
| "implemented": False, | |
| "note": "연령 검출 + parental consent 검증 미구현."}, | |
| {"sector": "Education (FERPA)", "regulator": "U.S. Department of Education", | |
| "guideline": "Family Educational Rights and Privacy Act", | |
| "url": "https://www2.ed.gov/policy/gen/guid/fpco/ferpa/", | |
| "implemented": False, | |
| "note": "학생 educational records 분류 미구현."}, | |
| ] | |
| # --- EU (GDPR) ----------------------------------------------------------- | |
| _EU_ENTITY_META: dict = { | |
| "EMAIL_ADDRESS": { | |
| "category": "Personal Data (Art 4(1))", | |
| "law": "GDPR Art 4(1)·Art 6", | |
| "law_url": "https://gdpr-info.eu/art-4-gdpr/", | |
| "treatment": "pseudonymise (hash local + keep domain)", | |
| "rationale": "Personal data — lawful basis 필요. 가명화 시 Art 4(5) 추가정보 분리 보관.", | |
| "implemented": True, | |
| }, | |
| "PHONE_NUMBER": { | |
| "category": "Personal Data (Art 4(1))", | |
| "law": "GDPR Art 4(1)", | |
| "law_url": "https://gdpr-info.eu/art-4-gdpr/", | |
| "treatment": "mask (preserve last 4)", | |
| "rationale": "전화번호는 single-out 가능 → personal data.", | |
| "implemented": True, | |
| }, | |
| "IP_ADDRESS": { | |
| "category": "Personal Data (Breyer v Germany, C-582/14)", | |
| "law": "GDPR Art 4(1) + CJEU Breyer 판결", | |
| "law_url": "https://curia.europa.eu/juris/document/document.jsf?docid=184668", | |
| "treatment": "ip_truncate (/24)", | |
| "rationale": "EU 법원이 IP 를 personal data 로 판단 (지속적 추적 가능성).", | |
| "implemented": True, | |
| }, | |
| "CREDIT_CARD": { | |
| "category": "Personal Data + 금융정보 (sensitive 아님)", | |
| "law": "GDPR Art 6 + PCI-DSS Req 3.4", | |
| "law_url": "https://gdpr-info.eu/art-6-gdpr/", | |
| "treatment": "mask", | |
| "rationale": "금융정보는 Special Category 아님 (Art 9 미해당). 일반 personal data 처리.", | |
| "implemented": True, | |
| }, | |
| "PERSON": { | |
| "category": "Personal Data (Art 4(1))", | |
| "law": "GDPR Art 4(1)", | |
| "law_url": "https://gdpr-info.eu/art-4-gdpr/", | |
| "treatment": "pseudonymise (consistent token)", | |
| "rationale": "이름 단독으로도 personal data — natural person identifier.", | |
| "implemented": True, | |
| }, | |
| "LOCATION": { | |
| "category": "Personal Data (Art 4(1))", | |
| "law": "GDPR Art 4(1)", | |
| "law_url": "https://gdpr-info.eu/art-4-gdpr/", | |
| "treatment": "generalize", | |
| "rationale": "주소 + 다른 정보 결합 시 식별 가능.", | |
| "implemented": True, | |
| }, | |
| "EU_SPECIAL_CATEGORY": { | |
| "category": "Special Categories of Personal Data (Art 9)", | |
| "law": "GDPR Art 9 — race·political·religious·trade union·genetic·biometric·health·sex life", | |
| "law_url": "https://gdpr-info.eu/art-9-gdpr/", | |
| "treatment": "remove + explicit consent (Art 9(2)(a))", | |
| "rationale": "Special Category — Art 9(1) 처리 금지 원칙, Art 9(2) 예외만 허용. 검출기 미구현.", | |
| "implemented": False, | |
| }, | |
| "EU_CRIMINAL_DATA": { | |
| "category": "Criminal data (Art 10)", | |
| "law": "GDPR Art 10", | |
| "law_url": "https://gdpr-info.eu/art-10-gdpr/", | |
| "treatment": "remove + 별도 법령 근거", | |
| "rationale": "Art 10 — 공식기관 또는 회원국 법령 근거 시에만 처리. 검출기 미구현.", | |
| "implemented": False, | |
| }, | |
| } | |
| _EU_SECTORAL: list = [ | |
| {"sector": "Healthcare", "regulator": "EDPB + 각 회원국 DPA", | |
| "guideline": "GDPR Art 9 (health data) + e-Health Network guidelines", | |
| "url": "https://edpb.europa.eu/our-work-tools/general-guidance/guidelines-recommendations-best-practices_en", | |
| "implemented": False, | |
| "note": "Art 9 health data 검출기 미구현. 의료 키워드 룰 필요."}, | |
| {"sector": "Children", "regulator": "EDPB", | |
| "guideline": "GDPR Art 8 — 16세 미만 정보처리 (회원국별 13~16세)", | |
| "url": "https://gdpr-info.eu/art-8-gdpr/", | |
| "implemented": False, | |
| "note": "연령 검출 + parental consent 미구현."}, | |
| {"sector": "Cross-border", "regulator": "EDPB", | |
| "guideline": "GDPR Chapter V — Adequacy / SCC / BCR", | |
| "url": "https://edpb.europa.eu/our-work-tools/our-documents/category/transfers_en", | |
| "implemented": False, | |
| "note": "Adequacy decision 매트릭스 + SCC 검증 UI 미구현."}, | |
| {"sector": "ePrivacy", "regulator": "EDPB + 각 회원국", | |
| "guideline": "ePrivacy Directive 2002/58/EC (쿠키 + 전자통신)", | |
| "url": "https://edpb.europa.eu/our-work-tools/our-documents/category/eprivacy_en", | |
| "implemented": False, | |
| "note": "쿠키 consent 모듈 미구현."}, | |
| ] | |
| # --- JP (APPI) — 기존 ----------------------------------------------------- | |
| _JP_ENTITY_META: dict = { | |
| "JP_MY_NUMBER": { | |
| "appi_category": "個人識別符号", | |
| "law": "マイナンバー法 §2·§19", | |
| "law_url": "https://elaws.e-gov.go.jp/document?lawid=425AC0000000027", | |
| "treatment": "remove (suppress) — 別途厳格法律で利用目的限定", | |
| "rationale": "12자리 個人番号. APPI 위에 マイナンバー法이 별도 적용 — 利用目的外 보관·제공 모두 처벌 대상.", | |
| "implemented": True, | |
| }, | |
| "JP_PASSPORT": { | |
| "appi_category": "個人識別符号", | |
| "law": "APPI §2 (1)·政令 §1", | |
| "law_url": "https://www.ppc.go.jp/personalinfo/legal/", | |
| "treatment": "mask / tokenize", | |
| "rationale": "旅券番号 (2자리 영문 + 7자리 숫자) = 政令열거 個人識別符号.", | |
| "implemented": True, | |
| }, | |
| "JP_DRIVERS_LICENSE": { | |
| "appi_category": "個人識別符号", | |
| "law": "APPI §2 (1)·政令 §1", | |
| "law_url": "https://www.ppc.go.jp/personalinfo/legal/", | |
| "treatment": "mask / tokenize", | |
| "rationale": "公安委員会 발급 12자리 번호 = 個人識別符号. マイナンバー와 같은 길이 — context 의존도 높음.", | |
| "implemented": True, | |
| }, | |
| "JP_PHONE": { | |
| "appi_category": "個人情報", | |
| "law": "APPI §2 (1)", | |
| "law_url": "https://www.ppc.go.jp/personalinfo/legal/", | |
| "treatment": "mask (末尾 4자리 유지)", | |
| "rationale": "특정 개인 식별 가능 → 個人情報. 携帯·固定電話 모두 동일 정책.", | |
| "implemented": True, | |
| }, | |
| "JP_POSTAL_CODE": { | |
| "appi_category": "個人情報 (단독) / 個人関連情報 (조합)", | |
| "law": "APPI §2·§31", | |
| "law_url": "https://www.ppc.go.jp/personalinfo/legal/", | |
| "treatment": "generalize (앞 3자리만)", | |
| "rationale": "주소와 결합 시 식별 가능. 단독으로는 個人関連情報 — 第三者提供時 동의 확인 의무.", | |
| "implemented": True, | |
| }, | |
| "JP_ADDRESS": { | |
| "appi_category": "個人情報", | |
| "law": "APPI §2 (1)", | |
| "law_url": "https://www.ppc.go.jp/personalinfo/legal/", | |
| "treatment": "generalize (都道府県+市区町村까지)", | |
| "rationale": "행정구역 단위까지 일반화 — HIPAA Safe Harbor 정합.", | |
| "implemented": True, | |
| }, | |
| "JP_CORPORATE_NUMBER": { | |
| "appi_category": "법인정보 (個人情報 아님)", | |
| "law": "法人番号公表サイト (国税庁)", | |
| "law_url": "https://www.houjin-bangou.nta.go.jp/", | |
| "treatment": "identity (보존 가능)", | |
| "rationale": "법인번호는 国税庁이 공개하는 정보 — APPI 적용 대상 아님. 개인사업자번호는 별도 검토.", | |
| "implemented": True, | |
| }, | |
| "JP_BANK_ACCOUNT": { | |
| "appi_category": "個人情報 (금융)", | |
| "law": "APPI §2·금융분야 가이드라인 (FSA)", | |
| "law_url": "https://www.fsa.go.jp/news/r3/sonota/20220331-1.html", | |
| "treatment": "mask (末尾 4자리 유지)", | |
| "rationale": "금융분야 가이드라인 적용 — FSA 별도 안전관리조치 권고.", | |
| "implemented": True, | |
| }, | |
| } | |
| # 분야별 가이드라인 — 현재 PoC 미구현 표시용 | |
| _JP_SECTORAL_GUIDELINES: list = [ | |
| {"sector": "金融", "regulator": "FSA (金融庁)", | |
| "guideline": "金融分野における個人情報保護に関するガイドライン", | |
| "url": "https://www.fsa.go.jp/news/r3/sonota/20220331-1.html", | |
| "implemented": False, | |
| "note": "JP_BANK_ACCOUNT 만 부분 반영. 与信情報·機微情報 별도 검출기 미구현."}, | |
| {"sector": "医療", "regulator": "MHLW (厚生労働省)", | |
| "guideline": "医療·介護関係事業者における個人情報の適切な取扱いのためのガイダンス", | |
| "url": "https://www.mhlw.go.jp/stf/seisakunitsuite/bunya/0000027272.html", | |
| "implemented": False, | |
| "note": "要配慮個人情報 (병력) 검출기 미구현 — 醫療文書 키워드 룰 필요."}, | |
| {"sector": "電気通信", "regulator": "MIC (総務省)", | |
| "guideline": "電気通信事業における個人情報保護に関するガイドライン", | |
| "url": "https://www.soumu.go.jp/main_sosiki/joho_tsusin/d_syohi/telecom_perinfo.html", | |
| "implemented": False, | |
| "note": "2023년 개정 外部送信規制 (쿠키 동의) — 별도 UI 모듈 필요."}, | |
| ] | |
| # 구 JP 전용 라우트는 generic /api/compliance/<jur>/* 로 통합됨 | |
| # ============================================================ | |
| # 4-Jurisdiction unified compliance — KR / US / JP / EU | |
| # ============================================================ | |
| _COMPLIANCE_META: dict = { | |
| "kr": { | |
| "name": "대한민국 (PIPA)", | |
| "regulator": "개인정보보호위원회 (PIPC)", | |
| "regulator_url": "https://www.pipc.go.kr/", | |
| "primary_law": "개인정보보호법 (PIPA, 2011 / 2020 개정)", | |
| "law_url": "https://www.law.go.kr/법령/개인정보보호법", | |
| "entities": _KR_ENTITY_META, | |
| "sectoral": _KR_SECTORAL, | |
| }, | |
| "us": { | |
| "name": "미국 (HIPAA · CCPA · GLBA)", | |
| "regulator": "HHS · FTC · State Agencies", | |
| "regulator_url": "https://www.hhs.gov/hipaa/", | |
| "primary_law": "HIPAA Safe Harbor + CCPA/CPRA + GLBA", | |
| "law_url": "https://www.hhs.gov/hipaa/for-professionals/privacy/", | |
| "entities": _US_ENTITY_META, | |
| "sectoral": _US_SECTORAL, | |
| }, | |
| "jp": { | |
| "name": "日本 (APPI)", | |
| "regulator": "個人情報保護委員会 (PPC)", | |
| "regulator_url": "https://www.ppc.go.jp/", | |
| "primary_law": "個人情報保護法 (APPI, 2003 / 2022 개정) + マイナンバー法", | |
| "law_url": "https://elaws.e-gov.go.jp/document?lawid=415AC0000000057", | |
| "entities": _JP_ENTITY_META, | |
| "sectoral": _JP_SECTORAL_GUIDELINES, | |
| }, | |
| "eu": { | |
| "name": "유럽 (GDPR)", | |
| "regulator": "European Data Protection Board (EDPB) + 각 회원국 DPA", | |
| "regulator_url": "https://edpb.europa.eu/", | |
| "primary_law": "GDPR (Regulation 2016/679) + ePrivacy Directive", | |
| "law_url": "https://gdpr-info.eu/", | |
| "entities": _EU_ENTITY_META, | |
| "sectoral": _EU_SECTORAL, | |
| }, | |
| } | |
| def api_jurisdiction_entities(jur: str): | |
| """관할별 엔티티 메타 + 분야 가이드라인.""" | |
| jur = (jur or "").lower() | |
| meta = _COMPLIANCE_META.get(jur) | |
| if not meta: | |
| return jsonify({"ok": False, "message": f"unknown jurisdiction: {jur}"}), 404 | |
| entities = meta["entities"] | |
| return jsonify({ | |
| "ok": True, | |
| "jurisdiction": jur, | |
| "name": meta["name"], | |
| "regulator": meta["regulator"], | |
| "regulator_url": meta["regulator_url"], | |
| "primary_law": meta["primary_law"], | |
| "law_url": meta["law_url"], | |
| "entities": entities, | |
| "sectoral_guidelines": meta["sectoral"], | |
| "implemented_count": sum(1 for v in entities.values() if v["implemented"]), | |
| "total_count": len(entities), | |
| }) | |
| def _classify_for_jurisdiction(jur: str, findings: list) -> dict: | |
| """관할별 분류 + 카운트 + 샘플.""" | |
| meta = _COMPLIANCE_META.get(jur, {}) | |
| ent_meta = meta.get("entities", {}) | |
| buckets: dict = {} | |
| samples: list = [] | |
| for f in findings: | |
| et = f.get("entity_type", "") | |
| em = ent_meta.get(et, {}) | |
| cat = em.get("category", "기타 / 미분류") | |
| buckets.setdefault(cat, []).append(et) | |
| if len(samples) < 10: | |
| samples.append({"entity_type": et, "snippet": (f.get("text") or "")[:20]}) | |
| return {"buckets": {k: sorted(set(v)) for k, v in buckets.items()}, | |
| "samples": samples, "n_total": len(findings)} | |
| def _draft_kr(filename: str, findings: list, classification: dict, memo: str, now: str) -> dict: | |
| """KR PIPA 유출신고 양식 (§34 시행령 §40) — 1,000명 이상 / 민감정보 / 자격증명 시.""" | |
| info = _classify_for_jurisdiction("kr", findings) | |
| has_rrn = any(f.get("entity_type") == "KR_RRN" for f in findings) | |
| has_unique_id = any(f.get("entity_type") in ("KR_RRN", "KR_PASSPORT") for f in findings) | |
| has_credential = any(f.get("entity_type") in ("AWS_ACCESS_KEY", "GENERIC_API_KEY") for f in findings) | |
| n_total = info["n_total"] | |
| grade = (classification.get("grade") or "").upper() | |
| severity = "심각" if has_rrn else ("높음" if has_unique_id or has_credential else "보통") | |
| return { | |
| "보고서종류": "개인정보 유출 신고서 (개인정보보호법 §34 · 시행령 §40)", | |
| "신고처": "개인정보보호위원회 (PIPC) + 한국인터넷진흥원 (KISA)", | |
| "신고기한": "72시간 이내 — 1,000명 이상 / 민감정보 / 자격증명 유출 시", | |
| "1_사고_개요": f"파일 『{filename}』 에서 개인정보 {n_total}건 검출. 분류 등급: {grade or '미판정'}.", | |
| "2_발견_일시": now, | |
| "3_유출_일시": "(미확인 — 조사 필요)", | |
| "4_유출_내용": { | |
| "건수": n_total, | |
| "카테고리별": info["buckets"], | |
| "샘플": info["samples"], | |
| "주민등록번호_포함": has_rrn, | |
| "고유식별정보_포함": has_unique_id, | |
| "자격증명_포함": has_credential, | |
| }, | |
| "5_유출_원인": "(조사 중 — 예: 오발송 · 부정 접근 · 분실 · 내부자 유출)", | |
| "6_2차_피해_가능성": ( | |
| "주민등록번호 유출 — §24-2 처리제한 위반 가능성. 명의도용·금융사기 위험 매우 높음." | |
| if has_rrn else | |
| "고유식별정보 포함 — 신원 도용 / 금융 사기 가능성." | |
| if has_unique_id else | |
| "자격증명 유출 — 추가 시스템 침해 가능성." if has_credential else | |
| "제한적." | |
| ), | |
| "7_본인_통지": "지체없이 — 통지방법: 서면·전자우편·SMS 등 중 1개. §34 ②", | |
| "8_홈페이지_공표": "30일 이상 게시 — 1,000명 이상일 때 의무.", | |
| "9_재발방지_대책": [ | |
| "기술적 안전조치 — 암호화·접근통제·접근기록 보관 (§29 · 시행령 §30)", | |
| "관리적 안전조치 — 내부관리계획 수립·정기 점검", | |
| "물리적 안전조치 — 출입통제·전산실 보호", | |
| "개인정보 영향평가 (CPIA, 공공기관 의무)", | |
| ], | |
| "severity": severity, | |
| "memo": memo, | |
| "_disclaimer": "본 양식은 PoC 자동 생성 초안 — 실 신고 시 법무·DPO 검토 필수.", | |
| } | |
| def _draft_us(filename: str, findings: list, classification: dict, memo: str, now: str) -> dict: | |
| """US HIPAA Breach Notification Rule (45 CFR §164.400~414).""" | |
| info = _classify_for_jurisdiction("us", findings) | |
| has_phi = any(f.get("entity_type") in ("US_SSN", "PERSON", "DATE_TIME") for f in findings) | |
| has_ssn = any(f.get("entity_type") == "US_SSN" for f in findings) | |
| has_credential = any(f.get("entity_type") in ("AWS_ACCESS_KEY", "GENERIC_API_KEY") for f in findings) | |
| n_total = info["n_total"] | |
| grade = (classification.get("grade") or "").upper() | |
| severity = "Severe" if has_ssn else ("High" if has_phi or has_credential else "Moderate") | |
| return { | |
| "report_type": "HIPAA Breach Notification (45 CFR §164.400-414) + State law disclosures", | |
| "reporting_to": "HHS Office for Civil Rights + Affected Individuals + (≥500) Media", | |
| "deadline": "Individuals: 60 days / HHS: 60 days / Media (≥500): 60 days / State laws vary", | |
| "1_incident_overview": f"File '{filename}' contains {n_total} potential PII items. Classification: {grade or 'N/A'}.", | |
| "2_discovery_date": now, | |
| "3_breach_date": "(under investigation)", | |
| "4_breach_details": { | |
| "count": n_total, | |
| "by_category": info["buckets"], | |
| "samples": info["samples"], | |
| "phi_indicators": has_phi, | |
| "ssn_present": has_ssn, | |
| "credentials_present": has_credential, | |
| "safe_harbor_18_identifiers_affected": [ | |
| k for k in info["buckets"].keys() if "Safe Harbor" in k | |
| ], | |
| }, | |
| "5_root_cause": "(under investigation — e.g., unauthorized access / loss / improper disclosure)", | |
| "6_secondary_harm_risk": ( | |
| "SSN exposed — high risk of identity theft / financial fraud." | |
| if has_ssn else | |
| "PHI exposed — HIPAA breach. Risk of medical identity theft." | |
| if has_phi else | |
| "Credentials exposed — risk of further system compromise." | |
| if has_credential else "Limited." | |
| ), | |
| "7_individual_notification": "First-class mail / email (if pre-authorized) — without unreasonable delay, max 60 days.", | |
| "8_substitute_notice": "If ≥10 individuals' contact info insufficient — post on website ≥90 days + media outlet notice.", | |
| "9_corrective_action_plan": [ | |
| "Risk Analysis (45 CFR §164.308(a)(1)(ii)(A)) refresh", | |
| "Workforce sanctions per §164.530(e)", | |
| "Encryption/Access Control improvements (Safe Harbor for future incidents)", | |
| "Business Associate Agreement (BAA) audit", | |
| "Training and awareness program", | |
| ], | |
| "additional_state_laws": "California (Civ. Code §1798.82), New York SHIELD Act, Texas BC §521 — separate notification thresholds.", | |
| "severity": severity, | |
| "memo": memo, | |
| "_disclaimer": "PoC auto-generated draft — actual breach reports require legal review and outside counsel coordination.", | |
| } | |
| def _draft_eu(filename: str, findings: list, classification: dict, memo: str, now: str) -> dict: | |
| """EU GDPR Art 33 (supervisory authority) + Art 34 (data subjects).""" | |
| info = _classify_for_jurisdiction("eu", findings) | |
| has_special = any(f.get("entity_type") in ("EU_SPECIAL_CATEGORY",) for f in findings) | |
| has_personal = info["n_total"] > 0 | |
| has_credential = any(f.get("entity_type") in ("AWS_ACCESS_KEY", "GENERIC_API_KEY") for f in findings) | |
| n_total = info["n_total"] | |
| grade = (classification.get("grade") or "").upper() | |
| severity = "High (likely high risk)" if has_special or has_credential else ( | |
| "Medium" if has_personal else "Low") | |
| return { | |
| "report_type": "GDPR Article 33 (Supervisory Authority) + Article 34 (Data Subjects)", | |
| "reporting_to": "Lead Supervisory Authority (one-stop-shop) + affected data subjects (if high risk)", | |
| "deadline": "Art 33: 72 hours after becoming aware / Art 34: without undue delay if high risk", | |
| "1_nature_of_breach": ( | |
| f"File '{filename}': {n_total} personal data items detected. " | |
| f"Classification: {grade or 'N/A'}. " | |
| "Confidentiality breach (likely) — unauthorized disclosure pending verification." | |
| ), | |
| "2_awareness_time": now, | |
| "3_occurrence_time": "(under investigation)", | |
| "4_categories_and_approximate_numbers": { | |
| "data_subjects_affected": "TBD", | |
| "records_affected": n_total, | |
| "by_category": info["buckets"], | |
| "samples": info["samples"], | |
| "special_categories_art9_affected": has_special, | |
| "credentials_affected": has_credential, | |
| }, | |
| "5_likely_consequences": ( | |
| "High risk — Art 9 special categories breach. Discrimination, identity theft risk." | |
| if has_special else | |
| "Medium — personal data loss of confidentiality." | |
| if has_personal else "Low." | |
| ), | |
| "6_measures_taken_or_proposed": [ | |
| "Containment — system isolation / credential rotation", | |
| "Forensic investigation — log review (Art 32 evidence)", | |
| "Affected data subjects identification", | |
| "Coordination with DPO + legal counsel", | |
| ], | |
| "7_DPO_contact": "(insert Data Protection Officer details)", | |
| "8_data_subject_communication": ( | |
| "REQUIRED (Art 34) — clear and plain language, including DPO contact, " | |
| "likely consequences, measures taken." | |
| if has_special or has_credential else | |
| "Consider voluntary notification — high transparency expectations." | |
| ), | |
| "9_remedial_actions": [ | |
| "Pseudonymisation / encryption upgrade (Art 32 (1)(a))", | |
| "Resilience and integrity testing (Art 32 (1)(b)(c))", | |
| "Restore availability and access (Art 32 (1)(c))", | |
| "Regular DPIA (Art 35) for high-risk processing", | |
| "Staff training — Art 39 DPO responsibility", | |
| ], | |
| "lawful_basis_review": "Re-evaluate Art 6 basis. If Art 9 data — verify Art 9(2) exception.", | |
| "cross_border_implications": "If transfer outside EEA — review SCC / adequacy decision validity.", | |
| "penalty_exposure": "Up to €20M or 4% of global annual revenue (Art 83(5)).", | |
| "severity": severity, | |
| "memo": memo, | |
| "_disclaimer": "PoC auto-generated draft — actual notifications require DPO review and DPA coordination.", | |
| } | |
| def _draft_jp_legacy(filename: str, findings: list, classification: dict, memo: str, now: str) -> dict: | |
| """JP draft — 기존 api_jp_breach_draft 로직을 함수화.""" | |
| info = _classify_for_jurisdiction("jp", findings) | |
| has_my_number = any(f.get("entity_type") == "JP_MY_NUMBER" for f in findings) | |
| n_total = info["n_total"] | |
| grade = (classification.get("grade") or "").upper() | |
| score = classification.get("score") | |
| has_kojin_shikibetsu = any("識別符号" in k for k in info["buckets"].keys()) | |
| severity = "高 (重大)" if has_my_number else ("中" if has_kojin_shikibetsu else "低") | |
| return { | |
| "報告書類": "個人情報の漏えい等の報告 (個人情報保護法 §26·施行規則 §8)", | |
| "報告先": "個人情報保護委員会 (PPC) + 本人通知", | |
| "提出期限": "速報: 概ね 3~5日 / 確報: 30日 (要配慮·財産的被害 60日)", | |
| "1_概要": f"ファイル『{filename}』内で個人情報 {n_total}件を検出. 分類等級: {grade or '未判定'} (score={score}).", | |
| "2_発覚日時": now, | |
| "3_漏えい等の発生日時": "(未確認 — 事業者調査必要)", | |
| "4_漏えい等の状況": { | |
| "件数": n_total, | |
| "カテゴリ別": info["buckets"], | |
| "サンプル": info["samples"], | |
| "マイナンバー含有": has_my_number, | |
| }, | |
| "5_原因": "(調査中 — 例: 誤送信·不正アクセス·紛失·内部不正)", | |
| "6_二次被害·おそれの有無": ( | |
| "マイナンバー含有 — マイナンバー法 §51 不正取得罪該当の可能性. 重大事案." | |
| if has_my_number else | |
| "個人識別符号含有 — 二次被害 (なりすまし等) 可能性あり." | |
| if has_kojin_shikibetsu else "現時点では限定的." | |
| ), | |
| "7_本人への対応": "本人通知 + 問合せ窓口設置 + 必要に応じて謝罪·補償.", | |
| "8_公表": "ウェブサイト等で公表予定 (要配慮·財産的被害·1,000人超 の場合は義務).", | |
| "9_再発防止策": [ | |
| "技術的安全管理措置 — アクセス制御·暗号化·監査ログ強化 (PPC ガイドライン §10)", | |
| "組織的安全管理措置 — 担当者教育·インシデント対応手順整備", | |
| "人的安全管理措置 — 守秘義務契約·定期研修", | |
| "物理的安全管理措置 — 保管区域·端末紛失防止", | |
| ], | |
| "severity": severity, | |
| "memo": memo, | |
| "_disclaimer": "本書類は PoC 自動生成下書きです — 実際の報告には法務·コンプライアンス部門の確認が必須.", | |
| } | |
| _BREACH_DRAFT_BUILDERS = { | |
| "kr": _draft_kr, | |
| "us": _draft_us, | |
| "jp": _draft_jp_legacy, | |
| "eu": _draft_eu, | |
| } | |
| def api_jurisdiction_breach_draft(jur: str): | |
| """관할별 유출신고 양식 자동 초안 생성. | |
| Body (JSON): | |
| { "filename": "...", "findings": [...], "classification": {...}?, "memo": "..." } | |
| """ | |
| jur = (jur or "").lower() | |
| builder = _BREACH_DRAFT_BUILDERS.get(jur) | |
| if not builder: | |
| return jsonify({"ok": False, "message": f"unknown jurisdiction: {jur}"}), 404 | |
| payload = request.get_json(silent=True) or {} | |
| filename = payload.get("filename") or "(unknown)" | |
| findings = payload.get("findings") or [] | |
| classification = payload.get("classification") or {} | |
| memo = payload.get("memo") or "" | |
| now = datetime.now(timezone.utc).isoformat() | |
| draft = builder(filename, findings, classification, memo, now) | |
| return jsonify({"ok": True, "jurisdiction": jur, "draft": draft, "generated_at": now}) | |
| # ============================================================ | |
| # Rule Mining (SPEC §2.4 (3)) | |
| # ============================================================ | |
| def api_rules_mine(): | |
| try: | |
| min_gap = int(request.args.get("min_gap", 1)) | |
| min_count = int(request.args.get("min_count", 3)) | |
| except (TypeError, ValueError): | |
| return jsonify({"ok": False, "message": "min_gap / min_count 는 정수"}), 400 | |
| candidates = rule_mining.mine_candidates(min_gap=min_gap, min_count=min_count) | |
| return jsonify({"ok": True, "candidates": candidates, | |
| "params": {"min_gap": min_gap, "min_count": min_count}, | |
| "decisions_analyzed": len(storage.list_decisions(limit=500))}) | |
| def api_rules_mine_adopt(): | |
| p = request.get_json(silent=True) or {} | |
| ent = p.get("entity_type") | |
| delta = p.get("delta") | |
| if not ent or delta is None: | |
| return jsonify({"ok": False, "message": "entity_type, delta required"}), 400 | |
| return jsonify(rule_mining.adopt_candidate(ent, delta)) | |
| # ============================================================ | |
| # Platt Calibration (SPEC §2.4 (2)) | |
| # ============================================================ | |
| def api_calibration_status(): | |
| return jsonify(platt_calibration.status()) | |
| def api_calibration_fit(): | |
| return jsonify(platt_calibration.fit_and_save()) | |
| def api_calibration_apply(): | |
| p = request.get_json(silent=True) or {} | |
| try: | |
| score = float(p.get("score")) | |
| except (TypeError, ValueError): | |
| return jsonify({"ok": False, "message": "score required (float)"}), 400 | |
| params = platt_calibration.load_params() | |
| if not params: | |
| return jsonify({"ok": False, "message": "calibration not fit yet — call /api/calibration/fit first"}), 400 | |
| cal = platt_calibration.apply_platt(score, params["A"], params["B"]) | |
| return jsonify({"ok": True, "raw": score, "calibrated": cal, | |
| "A": params["A"], "B": params["B"]}) | |
| # ============================================================ | |
| # Drift Detection (SPEC §2.4 (4)) | |
| # ============================================================ | |
| def api_drift_snapshot(): | |
| try: | |
| window = int(request.args.get("window", 30)) | |
| except (TypeError, ValueError): | |
| return jsonify({"ok": False, "message": "window must be int"}), 400 | |
| thresholds = {} | |
| for k in ("accuracy_drop", "gap_rise", "entity_psi"): | |
| v = request.args.get(k) | |
| if v is not None: | |
| try: thresholds[k] = float(v) | |
| except (TypeError, ValueError): pass | |
| return jsonify(drift_detection.snapshot(window_size=window, thresholds=thresholds)) | |
| def api_drift_timeline(): | |
| try: | |
| bin_size = int(request.args.get("bin", 10)) | |
| except (TypeError, ValueError): | |
| return jsonify({"ok": False, "message": "bin must be int"}), 400 | |
| return jsonify(drift_detection.timeline(bin_size=bin_size)) | |
| # ============================================================ | |
| # HE-FL (Federated Learning + Homomorphic Encryption) Phase 1 | |
| # SPEC §2.6 · docs/he_fl_plan.md | |
| # ============================================================ | |
| def api_he_fl_status(): | |
| return jsonify(he_fl.status()) | |
| def api_he_fl_extract(): | |
| p = request.get_json(silent=True) or {} | |
| try: | |
| n_clients = max(1, int(p.get("n_clients", 1))) | |
| eta = float(p.get("eta", 0.05)) | |
| except (TypeError, ValueError): | |
| return jsonify({"ok": False, "message": "n_clients/eta 필수"}), 400 | |
| return jsonify(he_fl.extract_round_data(n_clients=n_clients, eta=eta)) | |
| def api_he_fl_apply(): | |
| p = request.get_json(silent=True) or {} | |
| grad = p.get("gradient") | |
| if not isinstance(grad, dict): | |
| return jsonify({"ok": False, "message": "gradient (entity→float) required"}), 400 | |
| try: | |
| eta = float(p.get("eta", 0.05)) | |
| except (TypeError, ValueError): | |
| eta = 0.05 | |
| grad_f = {k: float(v) for k, v in grad.items()} | |
| return jsonify(he_fl.apply_aggregated_gradient(grad_f, eta=eta)) | |
| # HE-FL Phase 2 — 라운드 자동 반복 | |
| def api_he_fl_round(): | |
| p = request.get_json(silent=True) or {} | |
| try: | |
| n_clients = max(1, int(p.get("n_clients", 2))) | |
| eta = float(p.get("eta", 0.05)) | |
| except (TypeError, ValueError): | |
| return jsonify({"ok": False, "message": "n_clients/eta 필수"}), 400 | |
| return jsonify(he_fl.run_round(n_clients=n_clients, eta=eta)) | |
| def api_he_fl_auto(): | |
| p = request.get_json(silent=True) or {} | |
| try: | |
| n_rounds = max(1, min(100, int(p.get("n_rounds", 10)))) | |
| n_clients = max(1, int(p.get("n_clients", 2))) | |
| eta = float(p.get("eta", 0.05)) | |
| except (TypeError, ValueError): | |
| return jsonify({"ok": False, "message": "n_rounds/n_clients/eta 필수"}), 400 | |
| return jsonify(he_fl.run_multi_rounds(n_rounds=n_rounds, n_clients=n_clients, eta=eta)) | |
| if __name__ == "__main__": | |
| host = os.environ.get("HOST", "127.0.0.1") | |
| port = int(os.environ.get("PORT", "5000")) | |
| log.info("Starting server on http://%s:%s", host, port) | |
| app.run(host=host, port=port, debug=False) | |