# wm_constants.py — StealthMark 상수·사전·스타일·HTML 헬퍼 모음 # app.py에서 `from wm_constants import *` 로 임포트 import html as html_lib, re, unicodedata from difflib import SequenceMatcher # ─── Unicode 워터마크 문자 집합 ─────────────────────────────────────────────── ZW_CHARS = {'0': '\u200B', '1': '\u200C'} ZW_START = '\uFEFF' ZW_END = '\u200D' ALL_ZW = {'\u200B', '\u200C', '\u200D', '\u2060', '\uFEFF'} ZW_NAMES = { '\u200B': ('ZWSP', '#b91c1c'), # 빨강 — ZWSP (L1=0) '\u200C': ('ZWNJ', '#065f46'), # 초록 — ZWNJ (L1=1) '\u200D': ('ZWJ', '#1d4ed8'), # 파랑 — ZWJ '\u2060': ('WJ', '#0d9488'), # 틸 — Word Joiner '\uFEFF': ('BOM', '#854d0e'), # 황갈 — BOM } VS_CHARS = [chr(0xFE00 + i) for i in range(16)] CGJ = '\u034F' ALL_MICRO = set(VS_CHARS) | {CGJ} PUNCT_TARGETS = set('.,!?;:·') # ─── 샘플 텍스트 ────────────────────────────────────────────────────────────── SAMPLE_TEXT_KO = ( "이라크 쿠르드군 수천 명이 이란에 진입해 지상전을 벌이고 있다는 보도가 나왔다. " "미국 폭스뉴스는 4일(현지시간) 미국 정부 관계자를 인용해 이라크에 거주해온 이란 쿠르드족이 " "이번 공격 작전의 일환으로 이란 북서부로 향하고 있다고 보도했다. " "이들은 이란 정권에 맞서는 이란계 쿠르드 민병대인 것으로 전해졌다. " "이날 이스라엘 매체 타임스오브이스라엘도 이라크에 주둔 중이던 쿠르드 민병대가 이란 국경을 넘어 공격을 개시했다고 보도했다. " "이스라엘 정부 당국자는 '우리(이스라엘)는 서부 이란에서 활동하는 쿠르드 민병대를 지원하고 있다'며 " "이들이 이란 정권에 도전하도록 해 더 큰 봉기를 유도하는 게 작전의 목표라고 밝혔다. " "이라크 쿠르드족 반군은 반(反)이란 세력 중 가장 조직력이 큰 곳으로 평가된다. " "다만 해당 내용과 상반된 정보도 나오고 있다. " "캐럴라인 레빗 백악관 대변인은 이날 열린 브리핑에서 '트럼프 대통령의 쿠르드족 세력 접촉이 " "이란의 체제 전복을 위해 무장세력을 지원하기 위한 것'이라는 언론 보도에 대해서 " "'대통령이 그런 계획에 동의했다는 것은 전혀 사실이 아니다'고 부인했다. " "앞서 미 월스트리트저널(WSJ)은 전날 트럼프 대통령이 1일 쿠르드족 지도자들과 접촉했으며, " "이들 무장세력에 무기 및 군사훈련 지원과 정보 지원을 할지와 관련해 최종 결정을 내리지는 않았다고 보도했다. " "이란 반관영 타스님통신도 이라크 쿠르드군이 무장한 채 이란으로 진입, " "지상 공격을 시작했다는 보도에 대해 '사실이 아니다'라고 전했다." ) SAMPLE_TEXT_EN = ( "The technology sector witnessed significant developments as major artificial intelligence companies " "announced groundbreaking partnerships. Industry leaders emphasized the importance of responsible AI " "development during the annual summit held in San Francisco. Several prominent researchers highlighted " "that current AI systems still face substantial challenges in areas such as reliability, safety, and " "ethical deployment. The conference attracted participants from over 40 countries, marking record " "attendance. Leading companies presented their latest innovations in content protection, watermarking " "technology, and digital rights management. Experts warned that without proper safeguards, AI-generated " "content could undermine trust in digital media. The summit concluded with a joint declaration calling " "for international cooperation on AI governance standards." ) SAMPLE_TEXT = SAMPLE_TEXT_KO # ─── 한국어 언어 사전 ───────────────────────────────────────────────────────── KO_SYNONYM = { '발': ['구축', '제작', '설계', '구현'], '기술': ['테크놀로지', '방법론', '솔루션', '기법'], '보호': ['방어', '수호', '보전', '보장'], '사용': ['활용', '이용', '적용', '운용'], '콘텐츠': ['컨텐츠', '자료', '내용물'], '데이터': ['자료', '정보', '데이타'], '플랫폼': ['시스템', '서비스', '환경'], '침해': ['위반', '훼손', '침범'], '입증': ['증명', '검증', '확인', '규명'], '대응': ['대처', '조치', '방안'], '확보': ['마련', '구축', '수립'], '실증': ['검증', '실험', '시범'], '성과': ['결과', '실적', '성취'], '의미': ['의의', '중요성', '가치'], } KO_ENDING = { '한다': ['하고 있다', '하게 된다'], '했다': ['하였다', '한 바 있다'], '된다': ['이뤄진다', '이루어진다'], '이다': ['에 해당한다'], '졌다': ['되었다'], '않다': ['아니하다'], } KO_CONNECTIVE = { '하지만': ['그러나', '다만', '그렇지만'], '또한': ['아울러', '더불어'], '따라서': ['이에 따라', '그러므로'], '때문에': ['탓에', '까닭에'], '위해': ['위하여', '목적으로'], '통해': ['통하여', '거쳐'], } KO_PARTICLE = { '에서는': ['에서', '에선'], '으로는': ['으로', '으론'], '이라고': ['라고', '이라'], } # ─── 영어 언어 사전 ─────────────────────────────────────────────────────────── EN_SYNONYM = { 'development': ['construction', 'creation', 'design', 'implementation'], 'technology': ['methodology', 'solution', 'technique', 'approach'], 'protection': ['defense', 'safeguard', 'preservation', 'security'], 'important': ['significant', 'crucial', 'essential', 'vital'], 'content': ['material', 'data', 'information'], 'analysis': ['examination', 'assessment', 'evaluation'], 'detect': ['identify', 'discover', 'find', 'recognize'], 'system': ['platform', 'framework', 'infrastructure'], 'evidence': ['proof', 'documentation', 'verification'], 'attack': ['threat', 'intrusion', 'compromise'], 'improve': ['enhance', 'optimize', 'strengthen', 'refine'], 'create': ['generate', 'produce', 'develop', 'establish'], 'reduce': ['decrease', 'minimize', 'lower', 'diminish'], 'increase': ['boost', 'expand', 'elevate', 'amplify'], 'provide': ['supply', 'deliver', 'offer', 'furnish'], 'maintain': ['sustain', 'preserve', 'uphold', 'retain'], 'demonstrate': ['illustrate', 'showcase', 'exhibit', 'display'], 'implement': ['execute', 'deploy', 'carry out', 'apply'], 'require': ['demand', 'necessitate', 'need', 'call for'], 'significant': ['substantial', 'considerable', 'notable', 'meaningful'], } EN_CONNECTIVE = { 'however': ['nevertheless', 'nonetheless', 'yet'], 'also': ['additionally', 'furthermore', 'moreover'], 'therefore': ['consequently', 'thus', 'hence'], 'because': ['since', 'as', 'due to'], 'although': ['though', 'even though', 'while'], 'meanwhile': ['in the meantime', 'at the same time', 'concurrently'], 'specifically': ['in particular', 'notably', 'especially'], 'instead': ['alternatively', 'rather', 'in place of'], 'similarly': ['likewise', 'in the same way', 'equally'], 'ultimately': ['in the end', 'eventually', 'finally'], } EN_ENDING = { 'is important': ['matters greatly', 'is significant', 'is crucial'], 'was announced': ['has been revealed', 'was disclosed', 'was reported'], 'will be': ['is expected to be', 'shall be'], 'have been': ['were', 'had been'], 'is used': ['is employed', 'is utilized', 'is applied'], 'can be': ['may be', 'is able to be', 'could be'], 'should be': ['ought to be', 'needs to be', 'must be'], 'has shown': ['has demonstrated', 'has revealed', 'has indicated'], 'is known': ['is recognized', 'is acknowledged', 'is understood'], 'was developed': ['was created', 'was designed', 'was built'], } # ─── 영어 전치사·접속사·관계사 집합 ────────────────────────────────────────── EN_PREP = { 'in', 'on', 'at', 'by', 'for', 'with', 'from', 'to', 'of', 'about', 'into', 'through', 'during', 'before', 'after', 'between', 'under', 'above', 'below', 'against', 'among', 'within', 'without', 'toward', 'upon', 'across', 'along', 'behind', 'beyond', 'except', 'since', 'until', 'around', 'beside', 'beneath', 'despite', 'regarding', 'concerning', 'throughout', } EN_CONJ = { 'and', 'but', 'or', 'nor', 'yet', 'so', 'because', 'although', 'while', 'whereas', 'unless', 'since', 'however', 'therefore', 'moreover', 'furthermore', 'nevertheless', 'consequently', 'meanwhile', 'otherwise', 'hence', 'thus', 'accordingly', 'besides', 'nonetheless', 'alternatively', 'specifically', 'additionally', 'subsequently', } EN_REL = { 'that', 'which', 'who', 'whom', 'whose', 'where', 'when', 'while', 'if', 'whether', 'although', 'though', 'because', 'since', 'as', 'until', 'before', 'after', 'once', 'unless', 'whereas', 'whenever', 'wherever', } # ─── LLM 모델 목록 & 태스크 ─────────────────────────────────────────────────── GROQ_MODELS = [ ("openai/gpt-oss-120b", "GPT-OSS 120B"), ("openai/gpt-oss-20b", "GPT-OSS 20B"), ("qwen/qwen3-32b", "Qwen3 32B"), ("llama-3.3-70b-versatile", "LLaMA 3.3 70B"), ("meta-llama/llama-4-scout-17b-16e-instruct","LLaMA 4 Scout"), ("moonshotai/kimi-k2-instruct-0905", "Kimi K2"), ("llama-3.1-8b-instant", "LLaMA 3.1 8B"), ("gemma2-9b-it", "Gemma2 9B"), ("mixtral-8x7b-32768", "Mixtral 8x7B"), ("gpt-5.2", "GPT-5.2 (OpenAI)"), ] LLM_TASKS = { "📝 요약": "다음 글을 핵심 내용 위주로 3문장 이내로 요약해 주세요:\n\n", "🔄 패러프레이징": "다음 텍스트를 같은 의미를 유지하면서 다른 표현으로 재작성해 주세요:\n\n", "📖 확장": "다음 텍스트를 2배 분량으로 배경 설명을 추가하여 확장해 주세요:\n\n", "🌐 역번역": "다음 텍스트를 영어로 번역한 뒤 다시 한국어로 번역해 주세요:\n\n", "✂️ 핵심 발췌": "다음 텍스트에서 가장 중요한 내용만 추출해 주세요:\n\n", "🎭 문체 변환": "다음 글을 편안한 구어체 스타일로 재작성해 주세요:\n\n", "📋 글머리 정리": "다음 텍스트를 핵심 글머리 목록으로 정리해 주세요:\n\n", "🔀 콜라주 재작성": "다음 텍스트의 문장 순서를 바꾸고 일부를 합치거나 나누어 재구성해 주세요:\n\n", } # ─── 분석 모드 목록 ─────────────────────────────────────────────────────────── PLAG_MODES = [ "🔍 종합 분석 — 전체 표절 검사 (Comprehensive)", "📊 N-gram 정밀 — 어구 패턴 비교", "📝 Sentence 유사성 — 문장 1:1 비교", "🔀 Structure 분석 — 순서·배치 보존", "🧠 AI 재작성 탐지 — 패러프레이징 (AI Rewrite)", "✂️ Mosaic 표절 — 짜깁기 탐지", "📖 Excerpt 발췌 — 부분 인용 탐지", ] IMG_MODES = [ "🔍 종합 유사성 분석", "🔢 Hash 지각 해시 비교", "📐 SSIM 구조적 유사도", "🎨 색상 분포 히스토그램 비교", "🧩 특징점 엣지 매칭", ] VIDEO_SIM_MODES = [ "🔍 종합 유사성 분석", "🕐 DTW 시간축 동적 매칭", "🔢 Hash 키프레임 해시 비교", "📐 SSIM 구조적 유사도", ] # ─── 기본 인라인 스타일 상수 ────────────────────────────────────────────────── BOX = "font-family:'Pretendard','Inter','Noto Sans KR',sans-serif;background:#ffffff;color:#1e293b;padding:20px;border-radius:12px;border:1px solid #e2e8f0;box-shadow:0 2px 12px rgba(99,102,241,.06);" BOX_IN = "background:#f0f4ff;color:#1e293b;border-radius:8px;padding:12px;margin-bottom:8px;border:1px solid #e2e8f0;" _CARD = "text-align:center;padding:14px;background:#f4f7ff;border-radius:12px;border:1px solid #e2e8f0;" # ─── Gradio 라이트 테마 CSS (index.html 색상 기반) ────────────────────────── DARK_CSS = ( # ── 폰트 임포트 "@import url('https://cdn.jsdelivr.net/gh/orioncactus/pretendard/dist/web/static/pretendard.min.css');" # ── 전체 배경·폰트 "body,.gradio-container,.main,.app,.contain,div[class*='wrap'],div.app" "{background:#f4f7ff!important;font-family:'Pretendard',-apple-system,sans-serif!important;max-width:100%!important;}" # ── 스크롤바 "*{scrollbar-width:thin;scrollbar-color:#c7d2e0 #f4f7ff;}" "::-webkit-scrollbar{width:6px;height:6px;}" "::-webkit-scrollbar-track{background:#f4f7ff;}" "::-webkit-scrollbar-thumb{background:#c7d2e0;border-radius:3px;}" # ── 푸터 숨기기 "footer,.footer,footer.svelte-1ax1toq{display:none!important;}" # ── 탭 네비게이션 ".tab-nav,.tabs>.tab-nav,div[class*='tab-nav'],div[role='tablist'],.tabitem>.tab-nav," "div.tab-nav{background:linear-gradient(180deg,#ffffff,#f0f4ff)!important;" "border-bottom:2px solid #e2e8f0!important;border-radius:16px 16px 0 0!important;" "padding:8px 10px!important;gap:6px!important;display:flex!important;flex-wrap:wrap!important;" "box-shadow:0 1px 8px rgba(99,102,241,.06)!important;}" # ── 탭 버튼 기본 ".tab-nav button,.tab-nav>button,div[class*='tab-nav'] button,div[role='tablist'] button," "div[role='tablist']>button,button[role='tab'],.tab-nav button:not(.selected)" "{color:#475569!important;font-weight:700!important;font-size:14px!important;" "border:1px solid #e2e8f0!important;border-radius:10px!important;" "padding:11px 18px!important;background:#ffffff!important;" "transition:all .25s ease!important;opacity:1!important;" "letter-spacing:-.2px!important;}" # ── 탭 버튼 hover ".tab-nav button:hover,div[role='tablist'] button:hover,button[role='tab']:hover" "{color:#1e293b!important;background:rgba(0,184,148,.06)!important;" "border-color:rgba(0,184,148,.3)!important;}" # ── 탭 버튼 선택 ".tab-nav button.selected,div[role='tablist'] button[aria-selected='true']," "button[role='tab'][aria-selected='true'],.tab-nav button.selected:hover," "div[class*='tab-nav'] button.selected" "{color:#00b894!important;font-weight:900!important;" "background:linear-gradient(135deg,rgba(0,184,148,.1),rgba(124,58,237,.06))!important;" "border-color:rgba(0,184,148,.35)!important;" "box-shadow:0 2px 12px rgba(0,184,148,.12)!important;}" # ── 탭 패널 ".tabitem,div[class*='tabitem'],div[role='tabpanel']{background:#f4f7ff!important;" "border:1px solid #e2e8f0!important;border-top:none!important;" "border-radius:0 0 14px 14px!important;padding:24px!important;}" # ── 블록 ".block,div.block{background:#ffffff!important;border:1px solid #e2e8f0!important;" "border-radius:12px!important;transition:border-color .3s,box-shadow .3s!important;" "box-shadow:0 2px 8px rgba(99,102,241,.04)!important;}" ".block:focus-within{border-color:rgba(0,184,148,.4)!important;" "box-shadow:0 0 12px rgba(0,184,148,.08)!important;}" # ── 레이블 "label,span.label-wrap,.label-wrap>span,label>span,.label-wrap,.input-label," "span[data-testid='block-label']{color:#1e293b!important;font-weight:700!important;font-size:14px!important;}" # ── textarea·input "textarea,input[type=text],input[type=password],input[type=number],textarea.scroll-hide," ".textbox textarea,.textbox input,input.border-none,div[data-testid='textbox'] textarea," "div[data-testid='password'] input{background:#ffffff!important;color:#1e293b!important;" "border-color:#1e293b!important;border-radius:10px!important;font-size:15px!important;" "caret-color:#00b894!important;line-height:1.6!important;}" "textarea::placeholder,input::placeholder,.textbox textarea::placeholder," ".textbox input::placeholder{color:#64748b!important;font-size:14px!important;}" "textarea:focus,input:focus,input[type=password]:focus{border-color:rgba(0,184,148,.5)!important;" "box-shadow:0 0 10px rgba(0,184,148,.08)!important;color:#1e293b!important;}" # ── mono textarea (로그) ".mono textarea{font-family:'D2Coding','Consolas','Courier New',monospace!important;" "font-size:13px!important;color:#1e293b!important;background:#f8fafc!important;line-height:1.7!important;" "border:1px solid #e2e8f0!important;}" # ── 드롭다운 ".dropdown,.dropdown-container,select,div[class*='dropdown'],div[data-testid='dropdown']," ".dropdown input,button[class*='dropdown']{background:#ffffff!important;color:#1e293b!important;" "border-color:#1e293b!important;border-radius:10px!important;font-size:14px!important;font-weight:600!important;}" "ul[role='listbox']{background:#ffffff!important;border-color:#1e293b!important;border-radius:10px!important;box-shadow:0 4px 16px rgba(99,102,241,.1)!important;}" "ul[role='listbox'] li{color:#1e293b!important;font-size:14px!important;}" "ul[role='listbox'] li:hover{background:rgba(0,184,148,.06)!important;}" # ── Primary 버튼 (민트 그라디언트) ".primary,.btn-primary,button.primary,button.lg.primary,.gradio-button.primary" "{background:linear-gradient(135deg,#00b894,#7c3aed)!important;color:#ffffff!important;" "font-weight:900!important;border:none!important;border-radius:12px!important;" "box-shadow:0 4px 16px rgba(0,184,148,.25)!important;font-size:15px!important;" "letter-spacing:-.3px!important;transition:all .3s!important;}" ".primary:hover,button.primary:hover{box-shadow:0 6px 24px rgba(0,184,148,.4)!important;" "transform:translateY(-1px)!important;}" # ── Secondary 버튼 ".secondary,.btn-secondary,button.secondary,.gradio-button.secondary" "{background:#ffffff!important;color:#475569!important;" "font-weight:700!important;border:1px solid #e2e8f0!important;border-radius:12px!important;" "box-shadow:0 1px 4px rgba(99,102,241,.06)!important;}" ".secondary:hover,button.secondary:hover{background:#f0f4ff!important;color:#1e293b!important;border-color:rgba(0,184,148,.3)!important;}" # ── 아코디언 ".accordion,div[class*='accordion']{background:#ffffff!important;border-color:#1e293b!important;border-radius:12px!important;}" ".accordion .label-wrap{color:#1e293b!important;font-weight:700!important;}" # ── 헤딩·텍스트 "h1,h2,h3,.markdown h1,.markdown h2,.markdown h3{color:#1e293b!important;font-weight:800!important;}" ".markdown,.prose,p,.block p,.block span{color:#475569!important;}" ".block .wrap,.info,.hint,div[class*='description'],.block .info{color:#64748b!important;font-size:13px!important;}" # ── 슬라이더 "input[type=range]{accent-color:#00b894!important;}" ".slider{--slider-color:#00b894!important;}" ".range-slider input{accent-color:#00b894!important;}" # ── 프로그레스바 ".progress-bar,.progress-bar>div{background:linear-gradient(90deg,#00b894,#7c3aed)!important;border-radius:4px!important;}" ".progress-text{color:#1e293b!important;}" # ── 라디오 그룹 ".radio-group,.wrap[data-testid='radio-group'],div[class*='radio'],fieldset[class*='radio']," "div.radio-group{background:#f4f7ff!important;border-radius:12px!important;border:none!important;}" ".radio-group label,.wrap[data-testid='radio-group'] label,div[class*='radio'] label," "div[class*='radio'] label span,fieldset label,input[type='radio']+label," "input[type='radio']~label,input[type='radio']~span,.gradio-radio label," "label[data-testid='radio-label'],div[role='radiogroup'] label,div[role='radiogroup'] div" "{color:#475569!important;font-size:14px!important;font-weight:600!important;" "background:#ffffff!important;border:1px solid #e2e8f0!important;border-radius:8px!important;" "padding:10px 14px!important;transition:all .2s!important;cursor:pointer!important;}" ".radio-group label:hover,div[class*='radio'] label:hover,div[role='radiogroup'] label:hover," "div[role='radiogroup'] div:hover{background:#f0f4ff!important;color:#1e293b!important;" "border-color:rgba(0,184,148,.3)!important;}" "input[type='radio']:checked+label,input[type='radio']:checked~label," "input[type='radio']:checked~span,.radio-group label.selected," "div[class*='radio'] label.selected,div[role='radiogroup'] label[data-checked='true']," "div[role='radiogroup'] div[data-checked],label:has(input[type='radio']:checked)," "div[class*='radio'] label:has(input:checked)" "{background:rgba(0,184,148,.08)!important;color:#00b894!important;font-weight:700!important;" "border-color:rgba(0,184,148,.35)!important;box-shadow:0 0 10px rgba(0,184,148,.08)!important;}" "input[type='radio']{accent-color:#00b894!important;}" ".info,span[class*='info'],.block .info,.gradio-container .info{color:#64748b!important;font-size:12px!important;}" "@media(max-width:768px){.tab-nav button,div[role='tablist'] button{padding:8px 10px!important;font-size:11px!important;}}" ) # ─── Gradio head 인젝션 (HF 헤더 숨기기 + 라이트 탭 강제 스타일) ────────────── HEAD_INJECT = ( "" ) # ─── 탭·섹션 HTML 헬퍼 ─────────────────────────────────────────────────────── def _tab_hdr(icon: str, color: str, title: str, desc: str) -> str: """최상위 탭 헤더 HTML 생성 (color = 'r,g,b' 형식)""" return ( f'
' f'
' f'{icon}' f'
{title}
' f'
{desc}
' f'
' ) def _sub_hdr(icon: str, clr: str, title: str, desc: str) -> str: """서브탭 섹션 헤더 HTML 생성 (clr = 'r,g,b' 형식)""" return ( f'
' f'
' f'
' f'
{icon}
' f'
{title}
' f'
{desc}
' f'
' ) # ─── 텍스트 유틸리티 함수 (표절·유사도) ────────────────────────────────────── def _clean_text(t): return re.sub(r'\s+', ' ', unicodedata.normalize('NFC', ''.join(c for c in t if c not in ALL_ZW and c not in ALL_MICRO))).strip() def _split_sentences(t): return [s.strip() for s in re.split(r'(?<=[.!?。])\s*', t) if s.strip() and len(s.strip()) > 3] def _ngrams(text, n): words = text.split(); return set(tuple(words[i:i+n]) for i in range(len(words)-n+1)) if len(words) >= n else set() def _jaccard(sa, sb): return len(sa & sb) / max(len(sa | sb), 1) if sa or sb else 0.0 def _sentence_similarity(s1, s2): return SequenceMatcher(None, s1, s2).ratio() * 0.6 + _jaccard(_ngrams(s1, 2), _ngrams(s2, 2)) * 0.4 # ─── 이미지·영상 임계값 ──────────────────────────────────────────────────────── _IMG_THRESHOLDS = [(90,"🔴 확실한 도용/복제","#b91c1c","직접 복사 또는 최소 편집"),(70,"🟠 높은 유사도 — 도용 의심","#c2410c","편집된 복사본 가능성"),(50,"🟡 보통 유사도 — 주의 필요","#b45309","AI 재생성 또는 영감 기반"),(30,"🟢 낮은 유사도 — 참고 수준","#0d9488","우연한 유사 가능성"),(0,"⚪ 관련 없음","#64748b","서로 다른 이미지")] _VID_THRESHOLDS = [(85,"🔴 확실한 도용/복제","#b91c1c","동일 영상 또는 최소 편집"),(65,"🟠 높은 유사도 — 도용 의심","#c2410c","편집·크롭·속도 변경 복사본"),(45,"🟡 보통 유사도 — 주의","#b45309","부분 복사 또는 유사 촬영"),(25,"🟢 낮은 유사도","#0d9488","참고 수준"),(0,"⚪ 관련 없음","#64748b","서로 다른 영상")] # ─── HTML 렌더 유틸리티 (이미지·영상) ───────────────────────────────────────── def _sim_verdict(total, thresholds): for thresh,v,c,d in thresholds: if total >= thresh: return v,c,d return thresholds[-1][1],thresholds[-1][2],thresholds[-1][3] def _metric_grid(metrics): return '
' + ''.join(f'
{v}%
{n}
' for n,v,c in metrics) + '
' def _sim_html(total,verdict,vc,vi,metrics,extra=""): return f'''
{total}%
{verdict}
{vi}
{_metric_grid(metrics)}{extra}
''' # ─── 워터마크 시각화 렌더 ───────────────────────────────────────────────────── def render_wm_html(wm_text, max_chars=500): zw_cnt = sum(1 for c in wm_text if c in ALL_ZW) vs_cnt = sum(1 for c in wm_text if c in VS_CHARS) cgj_cnt = sum(1 for c in wm_text if c == CGJ) vis_cnt = sum(1 for c in wm_text if c not in ALL_ZW and c not in ALL_MICRO) density = round((zw_cnt + vs_cnt + cgj_cnt) / max(vis_cnt, 1) * 100, 1) stats = f'''
{zw_cnt}
L1 ZW 마커
{vs_cnt}
L4 VS 마커
{cgj_cnt}
L4 CGJ 마커
{density}%
워터마크 밀도
''' legend = '''
🔴 ZWSP (L1=0) 🟢 ZWNJ (L1=1) 🟡 BOM (L1 구분자) 🟣 VS (L4 구두점) 🟠 CGJ (L4 한글)
''' parts = [f'
📊 워터마크 삽입 현황
', stats, legend, '
워터마크 삽입 텍스트 미리보기 (숨겨진 마커 포함)
', f'
'] count = 0 for ch in wm_text: if count >= max_chars and ch not in ALL_ZW and ch not in ALL_MICRO: parts.append(' ...'); break if ch in ALL_ZW: s,c = ZW_NAMES.get(ch,('?','#475569')) # 배경: 해당 색의 10% 투명도, 텍스트: 진한 원색 parts.append(f'{s}') elif ch in VS_CHARS: parts.append(f'VS') elif ch == CGJ: parts.append(f'CGJ') else: parts.append(html_lib.escape(ch)); count += 1 parts.append('
') return ''.join(parts) def render_morph_html(text, boundaries): parts = [f'
🧬 형태소 경계 & 삽입 위치
'] prev = 0 for b in sorted(boundaries, key=lambda x: x['pos']): pos = b['pos']; seg = text[prev:pos] if seg: parts.append(html_lib.escape(seg)) color = '#00b894' if b.get('type') == 'morpheme' else '#60a5fa' desc_escaped = html_lib.escape(b.get('desc', '')) parts.append(f'') prev = pos parts.append(html_lib.escape(text[prev:])); parts.append('
') return ''.join(parts) def render_diff_html(orig, mod): orig_words = orig.split(); mod_words = mod.split(); sm = SequenceMatcher(None, orig_words, mod_words) parts = [f'
🔄 원본 vs 워터마크 변형 비교
'] for tag, i1, i2, j1, j2 in sm.get_opcodes(): if tag == 'equal': parts.append(html_lib.escape(' '.join(orig_words[i1:i2])) + ' ') elif tag == 'replace': parts.append(f'{html_lib.escape(" ".join(orig_words[i1:i2]))} ') parts.append(f'{html_lib.escape(" ".join(mod_words[j1:j2]))} ') elif tag == 'delete': parts.append(f'{html_lib.escape(" ".join(orig_words[i1:i2]))} ') elif tag == 'insert': parts.append(f'{html_lib.escape(" ".join(mod_words[j1:j2]))} ') parts.append('
') return ''.join(parts) # ─── 34종 공격 대시보드 렌더 ────────────────────────────────────────────────── def render_30atk_dashboard(group_results): # 위험도 뱃지: (연한 배경, 진한 텍스트) — 흰 배경에서 확실히 구분 _ATK_BADGE = { "Low": ("rgba(13,148,136,.12)", "#0d9488"), # teal "Med": ("rgba(180,83,9,.12)", "#b45309"), # amber "High": ("rgba(194,65,12,.12)", "#c2410c"), # orange "Max": ("rgba(185,28,28,.12)", "#b91c1c"), # red "Tier1": ("rgba(109,40,217,.12)", "#6d28d9"), # violet } _ATK_LABELS = {"Low":"낮음","Med":"중간","High":"높음","Max":"최대","Tier1":"Tier1"} all_rows = ""; total_attacks = total_detected = total_strong = 0 for group_name, attacks in group_results: group_det = sum(1 for _,_,_,l1,l2,l4,tr,_,lbl,_,_ in attacks if l1 or l2 or l4 or tr >= 25) det_color = "#166534" if group_det == len(attacks) else "#1d4ed8" if group_det > 0 else "#6b7280" all_rows += ( f'' + f'' + f'{group_name} ' + f'' + f'탐지 {group_det}/{len(attacks)}' ) for name, desc, risk, l1, l2, l4, trace, trace_reasons, label, vbg, vlc in attacks: total_attacks += 1; is_det = l1 or l2 or l4 or trace >= 25 if is_det: total_detected += 1 if "강력" in label: total_strong += 1 rbg, rtc = _ATK_BADGE.get(risk, ("rgba(100,116,139,.1)", "#475569")) rl = _ATK_LABELS.get(risk, risk) # 흔적 바 — 충분히 진한 색 사용 tr_clr = "#6d28d9" if trace>=50 else "#c2410c" if trace>=25 else "#94a3b8" tr_bar = ( f'
' + f'
' + f'
' + f'
' + f'{trace}' + f'
' ) tr_ev = ('; '.join(trace_reasons[:2])) if trace_reasons else '' row_bg = "#f0fdf4" if is_det else "#ffffff" x_span = '' all_rows += ( f'' + f'{html_lib.escape(name)}' + f'{html_lib.escape(desc)}' + f'' + f'{rl}' + f'' + f'{"✅" if l1 else x_span}' + f'{"✅" if l2 else x_span}' + f'{"✅" if l4 else x_span}' + f'{tr_bar}' + f'' + f'{label}' + f'' + f'{html_lib.escape(tr_ev)}' + f'' ) detect_pct = total_detected / max(total_attacks, 1) * 100 cx,cy,r,sw = 50,50,40,9; circ = 2*3.14159*r det_dash = circ*detect_pct/100; str_dash = circ*total_strong/max(total_attacks,1)*100/100 pct_fill = "#166534" if detect_pct>=80 else "#854d0e" if detect_pct>=50 else "#b91c1c" donut_svg = ( f'' + f'' + f'' + f'' + f'{detect_pct:.0f}%' + f'커버리지' + f'' ) _ATK_GROUPS_KO = ["유니코드 정규화","제로폭 제거","조합 마크","공백·구두점","토큰화 교란","새니타이저","의미 보존 재작성"] group_bars = "" for gi, (group_name, attacks) in enumerate(group_results): gdet = sum(1 for _,_,_,l1,l2,l4,tr,_,_,_,_ in attacks if l1 or l2 or l4 or tr >= 25) gh = max(gdet / max(len(attacks),1) * 60, 4) gc = "#166534" if gdet==len(attacks) else "#1d4ed8" if gdet>0 else "#cbd5e1" short = _ATK_GROUPS_KO[gi] if gi < len(_ATK_GROUPS_KO) else f"그룹{gi+1}" group_bars += ( f'
' + f'
' + f'
{short}
' + f'
' ) summary_pct_bg = "#dcfce7" if detect_pct>=80 else "#fef9c3" if detect_pct>=50 else "#fee2e2" summary_pct_tc = "#166534" if detect_pct>=80 else "#854d0e" if detect_pct>=50 else "#991b1b" cards = ( f'
' + f'
' + f'{donut_svg}' + f'
' + f'
' + f'
공격 수
' + f'
{total_attacks}
' + f'
탐지+흔적
' + f'
{total_detected}
' + f'
강력 탐지
' + f'
{total_strong}
' + f'
' + f'
' + f'● 강력 탐지' + f'● 탐지+흔적' + f'
' + f'
{group_bars}
' + f'
' ) thead = ( f'' + f'공격명' + f'설명' + f'위험도' + f'L1
ZW' + f'L2
문체' + f'L4
Mn' + f'흔적' + f'판정' + f'흔적 증거' + f'' ) return ( f'
' + f'
' + f'
' + f'
🔥 34종 공격 대시보드
' + f'
' + f'이중축: 신호(Signal) + ' + f'흔적(Trace) = 워터마크 파괴 후에도 저작권 증거 유지' + f'
' + f'
{detect_pct:.0f}% 커버리지
' + f'
' + f'{cards}' + f'
' + f'' + f'{thead}{all_rows}
' ) # ─── LLM 대시보드 렌더 ────────────────────────────────────────────────────── def render_llm_dashboard(results): rows = "" for mid, display, status, l1, l2, l4, preview in results: l1i = "✅" if l1 else "✕"; l2i = "✅" if l2 else "✕"; l4i = "✅" if l4 else "✕" strong = (l1 and l2) or (l1 and l4) or (l2 and l4) any_d = l1 or l2 or l4; score = sum([l1, l2, l4]) if strong: bg, lc, label = "#dcfce7", "#166534", "강력 탐지" elif any_d: bg, lc, label = "#fef9c3", "#854d0e", "부분 탐지" else: bg, lc, label = "#fee2e2", "#991b1b", "미탐지" bar_w = score / 3 * 100 bar_c = "#166534" if score >= 2 else "#b45309" if score >= 1 else "#e2e8f0" mini_bar = ( f'
' + f'
' + f'
' + f'{score}/3
' ) rows += ( f'' + f'{html_lib.escape(display)}' + f'{html_lib.escape(mid[:30])}' + f'{l1i}' + f'{l2i}' + f'{l4i}' + f'{mini_bar}' + f'' + f'{label}' + f'' ) total = len(results) det = sum(1 for r in results if r[3] or r[4] or r[5]) strong_n = sum(1 for r in results if (r[3] and r[4]) or (r[3] and r[5]) or (r[4] and r[5])) det_pct = det / max(total, 1) * 100 r2, cx, cy, sw = 36, 45, 45, 7; circ = 2 * 3.14159 * r2; det_d = circ * det_pct / 100 donut = ( f'' + f'' + f'' + f'{det}' + f'/{total}' + f'' ) summary = ( f'
' + f'
' + f'{donut}' + f'
LLM 탐지 결과
' + f'
{total}개 모델 테스트 완료
' + f'
' + f'
' + f'
{strong_n}
' + f'
강력 탐지
' + f'
' + f'
{det - strong_n}
' + f'
부분 탐지
' + f'
' + f'
{total - det}
' + f'
미탐지
' + f'
' ) return ( f'
' + f'
🤖 LLM 배치 탐지 결과
' + f'{summary}' + f'
' + f'' + f'' + f'' + f'' + f'' + f'' + f'' + f'' + f'' + f'{rows}
모델명모델 IDL1L2L4계층판정
' ) # ─── 파이프라인 렌더 ───────────────────────────────────────────────────────── def render_pipeline_html(stages): cards = ""; first_zw = max(stages[0][3], 1) for i, (name, desc, zw0, zw_a, l2, l4, vis) in enumerate(stages): zw_pct = zw_a / first_zw * 100; alive = sum([zw_a > 0, l2, l4]) if alive == 3: health_bg, health_border, health_label, health_icon = "linear-gradient(135deg,#f0fdf4,#dcfce7)", "#166534", "3/3 생존", "🟢" elif alive == 2: health_bg, health_border, health_label, health_icon = "linear-gradient(135deg,#fef9c3,#fef08a)", "#b45309", "2/3 생존", "🟡" elif alive == 1: health_bg, health_border, health_label, health_icon = "linear-gradient(135deg,#ffedd5,#fed7aa)", "#c2410c", "1/3 생존", "🟠" else: health_bg, health_border, health_label, health_icon = "linear-gradient(135deg,#fee2e2,#fecaca)", "#b91c1c", "전체 소실", "🔴" zw_bar = ( f'
' + f'
' ) note = "" if i > 0 and zw_a == 0 and stages[i-1][3] > 0: note = f'
⚠ 이 단계에서 L1(ZW) 소실 — L2 문체+L4 Mn 방어 유지
' cards += ( f'
' + f'
' + f'
{i+1}. {name}
' + f'{health_icon} {health_label}' + f'
' + f'
{desc}
' + f'{zw_bar}' + f'
' + f'ZW 잔존 {zw_a}/{stages[0][3]}' + f'L2 {"✅" if l2 else "✕"} L4 {"✅" if l4 else "✕"}' + f'
{note}
' ) total_stages = len(stages); final_alive = sum([stages[-1][3] > 0, stages[-1][4], stages[-1][5]]) summary = ( f'
' + f'
' + f'
단계
' + f'
{total_stages}
' + f'
' + f'
최종 생존
' + f'
=2 else "#b91c1c"};font-size:20px;font-weight:900;">{final_alive}/3
' + f'
' + f'
L1 ZW 잔존
' + f'
{stages[-1][3]}
' + f'
' ) return ( f'
' + f'
' + f'
' + f'
⚙️ AI 파이프라인 — 계층 생존 추적
' + f'
10단계 전처리 → 3계층 생존 추적
' + f'
' + f'{summary}' + f'
' + f'💡 L1(ZW)이 제거되어도 L2(문체)와 L4(VS/CGJ)가 생존하면 워터마크 탐지 가능 — ' + f'이것이 다계층 방어의 핵심입니다.
' + f'
{cards}
' ) # ─── 표절 보고서 렌더 ───────────────────────────────────────────────────────── def _render_plagiarism_html(score, grade, gc, gbg, ptype, picon, ng_scores, orig_sents, susp_sents, matrix, coverage, order_ratio, sent_avg, ng_avg): r_g, cx_g, cy_g = 42, 55, 55; circ_g = 3.14159 * r_g; fill_g = circ_g * score / 100 gauge_svg = f'''{score}/100''' cards = f"""
{gauge_svg}
{picon} {grade}
{ptype}
{coverage:.0%}
문장 커버리지
{sent_avg:.0%}
문장 유사도
{ng_avg:.0%}
N-gram 중복도
""" ng_bars = "" for n, s in ng_scores.items(): bc = "#0d9488" if s>=0.5 else "#b45309" if s>=0.25 else "#b91c1c" if s>=0.1 else "#cbd5e1" ng_bars += f"""
{n}-gram
{s:.0%}
""" heatmap_rows = "" for si, ss in enumerate(susp_sents): best = next(((o_i, sc, ot) for s_i, o_i, sc, st, ot in matrix if s_i==si), None) if best and best[1] >= 0.40: sc_v = best[1]; bg2,lc2,lbl2 = ("rgba(229,62,62,.08)","#c53030","높음") if sc_v>=0.70 else ("rgba(221,107,32,.08)","#c05621","중간") if sc_v>=0.50 else ("rgba(214,158,46,.08)","#b7791f","낮음") heatmap_rows += f"""
{html_lib.escape(ss[:80])}{lbl2} {sc_v:.0%}
↔ 원본[{best[0]+1}]: {html_lib.escape(best[2][:60])}
""" else: heatmap_rows += f"""
{html_lib.escape(ss[:80])}
""" axes_html = '
' + ''.join(f"""
{val:.0%}
{label}
""" for label, val, color in [("N-gram 중복도",ng_avg,"#1d4ed8"),("문장 유사도",sent_avg,"#6d28d9"),("커버리지",coverage,"#b45309"),("구조 유지",order_ratio,"#0d9488")]) + '
' return f"""
📋 표절 분석 보고서
원본 {len(orig_sents)}문장 vs 의심 {len(susp_sents)}문장
{picon} {grade}
{cards}
📊 N-gram 분석
{ng_bars}{axes_html}
🔍 문장 히트맵
높음(70%이상) 중간(50%이상) 낮음(40%이상) 미탐지
{heatmap_rows}
""" # ─── 복사 버튼 포함 워터마크 삽입 결과 박스 ──────────────────────────────────── def render_copy_box(wm_text: str, cid: str) -> str: """워터마크 삽입된 텍스트를 복사 버튼과 함께 표시""" import json safe = html_lib.escape(wm_text) # JSON으로 인코딩해서 JS에서 안전하게 사용 js_text = json.dumps(wm_text) char_count = len(wm_text) zw_n = sum(1 for c in wm_text if c in ALL_ZW) mn_n = sum(1 for c in wm_text if c in ALL_MICRO) return f'''
📋 워터마크 삽입 완료 텍스트
Content ID: {html_lib.escape(cid)}  ·  {char_count}자  ·  ZW {zw_n}  ·  Mn {mn_n}
{safe}
⚠️ 이 텍스트에는 눈에 보이지 않는 워터마크 문자가 포함되어 있습니다. 그대로 복사·배포하세요.
'''