from __future__ import annotations from functools import lru_cache from pathlib import Path from pydantic_settings import BaseSettings, SettingsConfigDict class Settings(BaseSettings): model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore") # 서버 바인딩 host: str = "0.0.0.0" port: int = 8000 log_level: str = "info" # debug / info / warning / error reload: bool = False # 개발용 자동 재시작 root_path: str = "" # 리버스 프록시 sub-path (예: /plagiarism) # --- 인증 (#1) --- # api_key 만으로는 "빈 값 = 무인증"이 조용히 성립한다. 운영에서는 # require_api_key=true 로 명시적 fail-closed 를 걸어, 키가 없으면 앱이 # 아예 뜨지 않게 한다. api_key: str = "" # 설정 시 X-API-Key 필수 require_api_key: bool = False # true 인데 api_key 가 비면 기동 실패 public_health: bool = True # /v1/health 를 인증 없이 공개할지 public_docs: bool = True # /docs, /openapi.json, /redoc 공개 여부 engine_version: str = "o2o-plagiarism-2.0.0-pdf-v1.2" reference_corpus_dir: str = "./data/reference" taxonomy_dir: str = "./data/taxonomy" autobiography_patterns_path: str = "./data/autobiography/common_patterns.txt" corpus_db_path: str = "./data/runtime/corpus.sqlite3" persistent_index_dir: str = "./data/runtime/index" use_persistent_index: bool = False persistent_similarity_threshold: float = 0.65 # 9어절 = 공백 포함 35자. 어절 3~9 스윕과 문서쌍 전수 대조로 정한 값이다. # 4어절 이하는 임의 조합의 99% 이상이 겹쳐 신호가 되지 못하고, 10~15어절 # 구간부터 겹치는 문서쌍이 고정되어 남는 것이 실제 유사 원고다. persistent_min_exact_span: int = 35 # 그대로 겹친 구간을 침해 판정의 필수 조건으로 둘지. false 면 유사도나 # 커버리지 단독으로도 채택되어 같은 주제의 글에서 오탐이 발생한다. require_exact_span_evidence: bool = True persistent_min_coverage: float = 0.30 # 상위 N개 후보만 SequenceMatcher 정밀 비교 + union coverage 에 참여시킨다. # 이 값이 곧 요청당 O(질의길이 × 세그먼트길이) 연산의 상한이다 (#3/#5). persistent_rerank_top_k: int = 20 precedents_path: str = "./data/precedents/precedents.jsonl" ai_detector_model_path: str = "./data/models/ai_detector.joblib" ai_detector_allow_heuristic: bool = False ai_detector_use_pos: bool = True # 휴리스틱 모드 의심도 구간. scripts/calibrate_ai_detector_cuts.py 가 등록 # 코퍼스(전부 인간 저작)의 점수 분포에서 백분위로 산출한다. 둘 다 설정해야 # 적용되며, 없으면 근거 없는 자리표시자(0.40/0.70)가 쓰인다. ai_detector_low_cut: float | None = None ai_detector_high_cut: float | None = None # PDF VII-4 권장: 정밀도 우선 보수적 임계값 similarity_threshold: float = 0.85 # 임계값이 실데이터로 캘리브레이션되었는지. false 면 API 응답에 # provisional=true 로 노출된다 (#9). 79권 FP 분포 측정 후 true 로 전환. similarity_threshold_calibrated: bool = False # KoSimCSE / KoSBERT (PDF VII-3 권장) - 한국어 오픈소스 임베딩 use_kosimcse: bool = False kosimcse_model: str = "BM-K/KoSimCSE-roberta-multitask" kosimcse_max_length: int = 512 # OpenAI (옵션 - 자체 모델 없을 때 폴백) openai_api_key: str = "" openai_extraction_model: str = "gpt-4o-mini" openai_embedding_model: str = "text-embedding-3-small" openai_judge_model: str = "gpt-4o-mini" use_llm_extractor: bool = False use_embedding_similarity: bool = False use_llm_legal_judge: bool = False llm_judge_timeout_seconds: float = 20.0 llm_judge_max_evidence_chars: int = 4000 # 삼중 유사도 가중치 (실측 기반) weight_text_sim: float = 0.30 weight_lemma_sim: float = 0.45 weight_char_sim: float = 0.15 weight_motif_sim: float = 0.10 # PDF VII-3 캐스케이딩 use_lsh_filter: bool = True lsh_threshold: float = 0.3 # 1차 필터는 느슨하게 (재현율 우선) lsh_top_k: int = 50 # 2단계 고도화: 군집화 기반 부분 표절(요소 교체) 신호 (계획서 p.21) use_clustering: bool = True cluster_link_threshold: float = 0.35 # PDF VII-4 자서전 모드 autobiography_mode: bool = True enable_entity_masking: bool = True @property def corpus_path(self) -> Path: return Path(self.reference_corpus_dir).resolve() @property def taxonomy_path(self) -> Path: return Path(self.taxonomy_dir).resolve() @property def corpus_db(self) -> Path: return Path(self.corpus_db_path).resolve() @property def persistent_index_path(self) -> Path: return Path(self.persistent_index_dir).resolve() @property def precedent_path(self) -> Path: return Path(self.precedents_path).resolve() @property def has_openai(self) -> bool: return bool(self.openai_api_key.strip()) @property def has_llm_legal_judge(self) -> bool: return self.use_llm_legal_judge and self.has_openai @lru_cache def get_settings() -> Settings: return Settings()