StealthMark / wm_constants.py
openfree's picture
Update wm_constants.py
c6d4c6c verified
# 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 = (
"<style>"
"@import url('https://cdn.jsdelivr.net/gh/orioncactus/pretendard/dist/web/static/pretendard.min.css');"
"header,.built-with{display:none!important}"
"body,.gradio-container{background:#f4f7ff!important;font-family:'Pretendard',-apple-system,sans-serif!important;}"
"div[role=\"tablist\"]>button,.tab-nav>button,.tabs .tab-nav button"
"{color:#475569!important;font-size:14px!important;font-weight:700!important;"
"background:#ffffff!important;border:1px solid #e2e8f0!important;"
"border-radius:10px!important;padding:11px 18px!important;opacity:1!important;}"
"div[role=\"tablist\"]>button:hover,.tab-nav>button:hover"
"{color:#1e293b!important;background:rgba(0,184,148,.06)!important;"
"border-color:rgba(0,184,148,.3)!important;}"
"div[role=\"tablist\"]>button[aria-selected=\"true\"],.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 10px rgba(0,184,148,.12)!important;}"
"textarea,input[type=text],input[type=password],input[type=number]"
"{color:#1e293b!important;background:#ffffff!important;font-size:15px!important;border-color:#1e293b!important;}"
"textarea::placeholder,input::placeholder{color:#64748b!important;}"
"label,span[data-testid=\"block-label\"],.label-wrap span{color:#1e293b!important;font-weight:700!important;}"
"div[role=\"radiogroup\"] label,div[role=\"radiogroup\"] div,div[class*=\"radio\"] label,"
"fieldset label,.radio-group label{color:#475569!important;background:#ffffff!important;"
"border:1px solid #e2e8f0!important;}"
"input[type=\"radio\"]:checked+label,input[type=\"radio\"]:checked~label,"
"label:has(input[type=\"radio\"]:checked),div[class*=\"radio\"] label.selected,"
"div[role=\"radiogroup\"] label[data-checked=\"true\"]"
"{color:#00b894!important;background:rgba(0,184,148,.08)!important;"
"border-color:rgba(0,184,148,.35)!important;}"
"</style>"
)
# ─── 탭·섹션 HTML 헬퍼 ───────────────────────────────────────────────────────
def _tab_hdr(icon: str, color: str, title: str, desc: str) -> str:
"""최상위 탭 헤더 HTML 생성 (color = 'r,g,b' 형식)"""
return (
f'<div style="background:linear-gradient(135deg,rgba({color},.07),rgba(124,58,237,.03));'
f'border:1px solid rgba({color},.2);border-radius:14px;padding:14px 20px;margin-bottom:12px;'
f'box-shadow:0 2px 8px rgba(99,102,241,.06);">'
f'<div style="display:flex;align-items:center;gap:12px;">'
f'<span style="font-size:28px;">{icon}</span>'
f'<div><div style="color:rgb({color});font-weight:800;font-size:16px;">{title}</div>'
f'<div style="color:#94a3b8;font-size:12px;padding:4px 0;">{desc}</div>'
f'</div></div></div>'
)
def _sub_hdr(icon: str, clr: str, title: str, desc: str) -> str:
"""서브탭 섹션 헤더 HTML 생성 (clr = 'r,g,b' 형식)"""
return (
f'<div style="background:linear-gradient(135deg,#ffffff,#f0f4ff);'
f'border:1px solid rgba({clr},.18);border-radius:16px;padding:20px 24px;'
f'margin-bottom:16px;position:relative;overflow:hidden;'
f'box-shadow:0 2px 12px rgba(99,102,241,.06);">'
f'<div style="position:absolute;top:-20px;right:-20px;width:120px;height:120px;'
f'border-radius:50%;background:radial-gradient(circle,rgba({clr},.08),transparent);'
f'pointer-events:none;"></div>'
f'<div style="display:flex;align-items:center;gap:14px;">'
f'<div style="width:44px;height:44px;border-radius:12px;'
f'background:linear-gradient(135deg,rgba({clr},.12),rgba(124,58,237,.06));'
f'display:flex;align-items:center;justify-content:center;font-size:22px;'
f'border:1px solid rgba({clr},.2);">{icon}</div>'
f'<div><div style="color:#1e293b;font-weight:800;font-size:17px;">{title}</div>'
f'<div style="color:#475569;font-size:13px;">{desc}</div>'
f'</div></div></div>'
)
# ─── 텍스트 유틸리티 함수 (표절·유사도) ──────────────────────────────────────
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 '<div style="display:grid;grid-template-columns:repeat(4,1fr);gap:12px;margin-bottom:20px;">' + ''.join(f'<div style="{_CARD}"><div style="font-size:24px;font-weight:900;color:{c};">{v}%</div><div style="font-size:11px;color:#64748b;margin-top:4px;">{n}</div></div>' for n,v,c in metrics) + '</div>'
def _sim_html(total,verdict,vc,vi,metrics,extra=""):
return f'''<div style="background:linear-gradient(135deg,#ffffff,#f0f4ff);border-radius:20px;padding:28px;border:1px solid {vc}33;"><div style="text-align:center;margin-bottom:24px;"><div style="font-size:56px;font-weight:900;color:{vc};">{total}%</div><div style="font-size:18px;font-weight:800;color:{vc};margin:4px 0;">{verdict}</div><div style="font-size:13px;color:#64748b;">{vi}</div></div>{_metric_grid(metrics)}{extra}</div>'''
# ─── 워터마크 시각화 렌더 ─────────────────────────────────────────────────────
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'''<div style="display:grid;grid-template-columns:repeat(4,1fr);gap:8px;margin-bottom:14px;">
<div style="text-align:center;padding:10px;border-radius:10px;background:#f8fafc;border:1px solid #00b89433;">
<div style="font-size:20px;font-weight:900;color:#00b894;">{zw_cnt}</div>
<div style="font-size:10px;color:#64748b;margin-top:2px;">L1 ZW 마커</div></div>
<div style="text-align:center;padding:10px;border-radius:10px;background:#f8fafc;border:1px solid #7c3aed33;">
<div style="font-size:20px;font-weight:900;color:#7c3aed;">{vs_cnt}</div>
<div style="font-size:10px;color:#64748b;margin-top:2px;">L4 VS 마커</div></div>
<div style="text-align:center;padding:10px;border-radius:10px;background:#f8fafc;border:1px solid #dd6b2033;">
<div style="font-size:20px;font-weight:900;color:#f97316;">{cgj_cnt}</div>
<div style="font-size:10px;color:#64748b;margin-top:2px;">L4 CGJ 마커</div></div>
<div style="text-align:center;padding:10px;border-radius:10px;background:#f8fafc;border:1px solid #3182ce33;">
<div style="font-size:20px;font-weight:900;color:#3182ce;">{density}%</div>
<div style="font-size:10px;color:#64748b;margin-top:2px;">워터마크 밀도</div></div>
</div>'''
legend = '''<div style="margin-bottom:10px;display:flex;flex-wrap:wrap;gap:6px;font-size:10px;">
<span style="background:#fee2e2;color:#b91c1c;padding:2px 8px;border-radius:10px;border:1px solid #fca5a5;">🔴 ZWSP (L1=0)</span>
<span style="background:#d1fae5;color:#065f46;padding:2px 8px;border-radius:10px;border:1px solid #6ee7b7;">🟢 ZWNJ (L1=1)</span>
<span style="background:#fef9c3;color:#854d0e;padding:2px 8px;border-radius:10px;border:1px solid #fde047;">🟡 BOM (L1 구분자)</span>
<span style="background:rgba(109,40,217,.1);color:#6d28d9;padding:2px 8px;border-radius:10px;border:1px solid rgba(109,40,217,.25);">🟣 VS (L4 구두점)</span>
<span style="background:#fff7ed;color:#c2410c;padding:2px 8px;border-radius:10px;border:1px solid #fed7aa;">🟠 CGJ (L4 한글)</span>
</div>'''
parts = [f'<div style="{BOX}"><div style="font-weight:700;font-size:13px;color:#00b894;margin-bottom:12px;">📊 워터마크 삽입 현황</div>', stats, legend,
'<div style="background:#f8fafc;border-radius:10px;padding:14px;"><div style="background:linear-gradient(90deg,rgba(0,184,148,.06),rgba(0,184,148,.02));border-radius:8px 8px 0 0;padding:8px 12px;margin:-14px -14px 12px;border-bottom:1px solid #e2e8f0;font-size:11px;color:#94a3b8;">워터마크 삽입 텍스트 미리보기 (숨겨진 마커 포함)</div>',
f'<div style="line-height:2.4;font-size:14px;">']
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('<span style="color:#64748b;"> ...</span>'); break
if ch in ALL_ZW:
s,c = ZW_NAMES.get(ch,('?','#475569'))
# 배경: 해당 색의 10% 투명도, 텍스트: 진한 원색
parts.append(f'<span style="background:{c}18;color:{c};font-size:8px;padding:0 4px;border-radius:3px;vertical-align:middle;border:1px solid {c}44;font-weight:700;">{s}</span>')
elif ch in VS_CHARS:
parts.append(f'<span style="background:#6d28d9;color:#ffffff;font-size:7px;padding:0 3px;border-radius:3px;vertical-align:middle;">VS</span>')
elif ch == CGJ:
parts.append(f'<span style="background:#f97316;color:#000;font-size:7px;padding:0 3px;border-radius:3px;vertical-align:middle;">CGJ</span>')
else:
parts.append(html_lib.escape(ch)); count += 1
parts.append('</div></div></div>')
return ''.join(parts)
def render_morph_html(text, boundaries):
parts = [f'<div style="{BOX}"><div style="font-weight:700;font-size:13px;color:#00b894;margin-bottom:12px;">🧬 형태소 경계 & 삽입 위치</div><div style="line-height:2.8;font-size:14px;">']
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'<span style="border-bottom:2px solid {color};margin:0 1px;position:relative;" title="{desc_escaped}"><span style="position:absolute;top:-16px;left:50%;transform:translateX(-50%);font-size:7px;color:{color};white-space:nowrap;">▼</span></span>')
prev = pos
parts.append(html_lib.escape(text[prev:])); parts.append('</div></div>')
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'<div style="{BOX}"><div style="font-weight:700;font-size:13px;color:#00b894;margin-bottom:12px;">🔄 원본 vs 워터마크 변형 비교</div><div style="line-height:2.2;font-size:14px;">']
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'<span style="background:rgba(229,62,62,.1);color:#e53e3e;text-decoration:line-through;padding:1px 3px;border-radius:3px;">{html_lib.escape(" ".join(orig_words[i1:i2]))}</span> ')
parts.append(f'<span style="background:rgba(0,184,148,.1);color:#00b894;padding:1px 3px;border-radius:3px;">{html_lib.escape(" ".join(mod_words[j1:j2]))}</span> ')
elif tag == 'delete':
parts.append(f'<span style="background:rgba(229,62,62,.1);color:#e53e3e;text-decoration:line-through;padding:1px 3px;border-radius:3px;">{html_lib.escape(" ".join(orig_words[i1:i2]))}</span> ')
elif tag == 'insert':
parts.append(f'<span style="background:rgba(0,184,148,.1);color:#00b894;padding:1px 3px;border-radius:3px;">{html_lib.escape(" ".join(mod_words[j1:j2]))}</span> ')
parts.append('</div></div>')
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'<tr style="background:#f0fdf4;">' +
f'<td colspan="9" style="padding:10px 12px;font-weight:800;font-size:12px;' +
f'border-bottom:2px solid #e2e8f0;border-top:1px solid #d1fae5;color:#1e293b;">' +
f'{group_name} ' +
f'<span style="margin-left:6px;padding:2px 8px;border-radius:10px;' +
f'background:rgba(0,184,148,.1);color:{det_color};font-size:10px;font-weight:700;">' +
f'탐지 {group_det}/{len(attacks)}</span></td></tr>'
)
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'<div style="display:flex;align-items:center;gap:5px;">' +
f'<div style="width:40px;height:6px;background:#e2e8f0;border-radius:3px;overflow:hidden;">' +
f'<div style="width:{min(trace,100)}%;height:100%;background:{tr_clr};border-radius:3px;"></div>' +
f'</div>' +
f'<span style="font-size:10px;font-weight:700;color:{tr_clr};">{trace}</span>' +
f'</div>'
)
tr_ev = ('; '.join(trace_reasons[:2])) if trace_reasons else ''
row_bg = "#f0fdf4" if is_det else "#ffffff"
x_span = '<span style="color:#cbd5e1;font-size:13px;">✕</span>'
all_rows += (
f'<tr style="border-bottom:1px solid #e2e8f0;background:{row_bg};">' +
f'<td style="padding:7px 10px;color:#1e293b;font-size:12px;font-weight:700;">{html_lib.escape(name)}</td>' +
f'<td style="padding:7px 10px;color:#475569;font-size:11px;max-width:180px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">{html_lib.escape(desc)}</td>' +
f'<td style="padding:7px 10px;text-align:center;">' +
f'<span style="background:{rbg};color:{rtc};padding:2px 9px;border-radius:8px;font-size:10px;font-weight:700;border:1px solid {rtc}33;">{rl}</span>' +
f'</td>' +
f'<td style="padding:7px 10px;text-align:center;font-size:15px;">{"✅" if l1 else x_span}</td>' +
f'<td style="padding:7px 10px;text-align:center;font-size:15px;">{"✅" if l2 else x_span}</td>' +
f'<td style="padding:7px 10px;text-align:center;font-size:15px;">{"✅" if l4 else x_span}</td>' +
f'<td style="padding:7px 10px;">{tr_bar}</td>' +
f'<td style="padding:7px 10px;text-align:center;">' +
f'<span style="background:{vbg};color:{vlc};padding:3px 9px;border-radius:10px;font-size:10px;font-weight:800;white-space:nowrap;border:1px solid {vlc}33;">{label}</span>' +
f'</td>' +
f'<td style="padding:7px 10px;color:#64748b;font-size:10px;max-width:160px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">{html_lib.escape(tr_ev)}</td>' +
f'</tr>'
)
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'<svg width="100" height="100" viewBox="0 0 100 100">' +
f'<circle cx="{cx}" cy="{cy}" r="{r}" fill="none" stroke="#e2e8f0" stroke-width="{sw}"/>' +
f'<circle cx="{cx}" cy="{cy}" r="{r}" fill="none" stroke="#93c5fd" stroke-width="{sw}"' +
f' stroke-dasharray="{det_dash} {circ}" stroke-dashoffset="0"' +
f' transform="rotate(-90 {cx} {cy})" stroke-linecap="round"/>' +
f'<circle cx="{cx}" cy="{cy}" r="{r}" fill="none" stroke="#166534" stroke-width="{sw}"' +
f' stroke-dasharray="{str_dash} {circ}" stroke-dashoffset="0"' +
f' transform="rotate(-90 {cx} {cy})" stroke-linecap="round"/>' +
f'<text x="{cx}" y="{cy-3}" text-anchor="middle" fill="{pct_fill}" font-size="17" font-weight="900">{detect_pct:.0f}%</text>' +
f'<text x="{cx}" y="{cy+10}" text-anchor="middle" fill="#64748b" font-size="7">커버리지</text>' +
f'</svg>'
)
_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'<div style="display:flex;flex-direction:column;align-items:center;gap:4px;flex:1;">' +
f'<div style="width:100%;height:{gh}px;background:{gc};border-radius:4px 4px 0 0;min-height:4px;"></div>' +
f'<div style="font-size:8px;color:#475569;text-align:center;writing-mode:vertical-rl;height:55px;overflow:hidden;">{short}</div>' +
f'</div>'
)
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'<div style="display:flex;gap:16px;margin-bottom:20px;align-items:stretch;flex-wrap:wrap;">' +
f'<div style="display:flex;align-items:center;gap:16px;padding:16px 20px;' +
f'background:linear-gradient(135deg,#f0fdf4,#eff6ff);' +
f'border:1px solid #bbf7d0;border-radius:14px;min-width:280px;">' +
f'{donut_svg}' +
f'<div>' +
f'<div style="display:flex;gap:16px;margin-bottom:10px;">' +
f'<div><div style="color:#64748b;font-size:10px;font-weight:600;">공격 수</div>' +
f'<div style="color:#1e293b;font-size:22px;font-weight:900;">{total_attacks}</div></div>' +
f'<div><div style="color:#64748b;font-size:10px;font-weight:600;">탐지+흔적</div>' +
f'<div style="color:#1d4ed8;font-size:22px;font-weight:900;">{total_detected}</div></div>' +
f'<div><div style="color:#64748b;font-size:10px;font-weight:600;">강력 탐지</div>' +
f'<div style="color:#166534;font-size:22px;font-weight:900;">{total_strong}</div></div>' +
f'</div>' +
f'<div style="display:flex;gap:6px;">' +
f'<span style="padding:3px 8px;border-radius:6px;background:#dcfce7;color:#166534;font-size:9px;font-weight:700;border:1px solid #bbf7d0;">● 강력 탐지</span>' +
f'<span style="padding:3px 8px;border-radius:6px;background:#dbeafe;color:#1d4ed8;font-size:9px;font-weight:700;border:1px solid #bfdbfe;">● 탐지+흔적</span>' +
f'</div></div></div>' +
f'<div style="flex:1;display:flex;gap:4px;padding:12px 16px;background:#ffffff;' +
f'border:1px solid #e2e8f0;border-radius:14px;align-items:flex-end;min-width:300px;">{group_bars}</div>' +
f'</div>'
)
thead = (
f'<thead><tr style="background:#f8fafc;border-bottom:2px solid #e2e8f0;">' +
f'<th style="padding:9px 10px;text-align:left;color:#1e293b;font-size:11px;font-weight:700;border-bottom:2px solid #00b894;">공격명</th>' +
f'<th style="padding:9px 10px;text-align:left;color:#1e293b;font-size:11px;font-weight:700;border-bottom:2px solid #00b894;">설명</th>' +
f'<th style="padding:9px 10px;text-align:center;color:#1e293b;font-size:11px;font-weight:700;border-bottom:2px solid #e2e8f0;">위험도</th>' +
f'<th style="padding:9px 10px;text-align:center;color:#0d9488;font-size:11px;font-weight:700;border-bottom:2px solid #0d9488;">L1<br><span style="font-size:9px;font-weight:400;color:#64748b;">ZW</span></th>' +
f'<th style="padding:9px 10px;text-align:center;color:#1d4ed8;font-size:11px;font-weight:700;border-bottom:2px solid #1d4ed8;">L2<br><span style="font-size:9px;font-weight:400;color:#64748b;">문체</span></th>' +
f'<th style="padding:9px 10px;text-align:center;color:#6d28d9;font-size:11px;font-weight:700;border-bottom:2px solid #6d28d9;">L4<br><span style="font-size:9px;font-weight:400;color:#64748b;">Mn</span></th>' +
f'<th style="padding:9px 10px;text-align:center;color:#6d28d9;font-size:11px;font-weight:700;border-bottom:2px solid #6d28d9;">흔적</th>' +
f'<th style="padding:9px 10px;text-align:center;color:#1e293b;font-size:11px;font-weight:700;border-bottom:2px solid #e2e8f0;">판정</th>' +
f'<th style="padding:9px 10px;text-align:left;color:#64748b;font-size:10px;font-weight:600;border-bottom:2px solid #e2e8f0;">흔적 증거</th>' +
f'</tr></thead>'
)
return (
f'<div style="{BOX}">' +
f'<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px;">' +
f'<div>' +
f'<div style="color:#1e293b;font-weight:800;font-size:18px;">🔥 34종 공격 대시보드</div>' +
f'<div style="color:#475569;font-size:12px;margin-top:2px;">' +
f'이중축: <span style="color:#166534;font-weight:700;">신호(Signal)</span> + ' +
f'<span style="color:#5b21b6;font-weight:700;">흔적(Trace)</span> = 워터마크 파괴 후에도 저작권 증거 유지' +
f'</div></div>' +
f'<div style="background:{summary_pct_bg};color:{summary_pct_tc};padding:8px 18px;' +
f'border-radius:14px;font-weight:800;font-size:14px;' +
f'border:1px solid {summary_pct_tc}33;">{detect_pct:.0f}% 커버리지</div>' +
f'</div>' +
f'{cards}' +
f'<div style="overflow-x:auto;">' +
f'<table style="width:100%;border-collapse:collapse;min-width:700px;">' +
f'{thead}<tbody>{all_rows}</tbody></table></div></div>'
)
# ─── 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'<div style="display:flex;align-items:center;gap:6px;">' +
f'<div style="width:48px;height:6px;background:#e2e8f0;border-radius:3px;overflow:hidden;">' +
f'<div style="width:{bar_w}%;height:100%;background:{bar_c};border-radius:3px;"></div></div>' +
f'<span style="font-size:10px;color:{bar_c};font-weight:700;">{score}/3</span></div>'
)
rows += (
f'<tr style="border-bottom:1px solid #e2e8f0;">' +
f'<td style="padding:10px;color:#1e293b;font-weight:700;font-size:13px;">{html_lib.escape(display)}</td>' +
f'<td style="padding:10px;color:#64748b;font-size:11px;">{html_lib.escape(mid[:30])}</td>' +
f'<td style="padding:10px;text-align:center;font-size:16px;">{l1i}</td>' +
f'<td style="padding:10px;text-align:center;font-size:16px;">{l2i}</td>' +
f'<td style="padding:10px;text-align:center;font-size:16px;">{l4i}</td>' +
f'<td style="padding:10px;">{mini_bar}</td>' +
f'<td style="padding:10px;text-align:center;">' +
f'<span style="background:{bg};color:{lc};padding:4px 12px;border-radius:14px;font-size:11px;font-weight:800;border:1px solid {lc}33;">{label}</span>' +
f'</td></tr>'
)
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'<svg width="100" height="100" viewBox="0 0 90 90">' +
f'<circle cx="{cx}" cy="{cy}" r="{r2}" fill="none" stroke="#e2e8f0" stroke-width="{sw}"/>' +
f'<circle cx="{cx}" cy="{cy}" r="{r2}" fill="none" stroke="#00b894" stroke-width="{sw}" ' +
f'stroke-dasharray="{det_d} {circ}" stroke-dashoffset="0" transform="rotate(-90 {cx} {cy})" stroke-linecap="round"/>' +
f'<text x="{cx}" y="{cy-2}" text-anchor="middle" fill="#166534" font-size="16" font-weight="900">{det}</text>' +
f'<text x="{cx}" y="{cy+10}" text-anchor="middle" fill="#64748b" font-size="7">/{total}</text>' +
f'</svg>'
)
summary = (
f'<div style="display:flex;gap:16px;margin-bottom:20px;align-items:center;flex-wrap:wrap;">' +
f'<div style="display:flex;align-items:center;gap:12px;padding:14px 20px;' +
f'background:#ffffff;border:1px solid rgba(0,184,148,.2);border-radius:14px;">' +
f'{donut}' +
f'<div><div style="color:#1e293b;font-size:14px;font-weight:800;">LLM 탐지 결과</div>' +
f'<div style="color:#475569;font-size:12px;margin-top:2px;">{total}개 모델 테스트 완료</div></div></div>' +
f'<div style="display:flex;gap:10px;flex:1;min-width:200px;">' +
f'<div style="flex:1;text-align:center;padding:14px;background:#dcfce7;border-radius:12px;border:1px solid #bbf7d0;">' +
f'<div style="color:#166534;font-size:26px;font-weight:900;">{strong_n}</div>' +
f'<div style="color:#166534;font-size:11px;">강력 탐지</div></div>' +
f'<div style="flex:1;text-align:center;padding:14px;background:#fef9c3;border-radius:12px;border:1px solid #fde68a;">' +
f'<div style="color:#854d0e;font-size:26px;font-weight:900;">{det - strong_n}</div>' +
f'<div style="color:#854d0e;font-size:11px;">부분 탐지</div></div>' +
f'<div style="flex:1;text-align:center;padding:14px;background:#fee2e2;border-radius:12px;border:1px solid #fecaca;">' +
f'<div style="color:#991b1b;font-size:26px;font-weight:900;">{total - det}</div>' +
f'<div style="color:#991b1b;font-size:11px;">미탐지</div></div>' +
f'</div></div>'
)
return (
f'<div style="{BOX}">' +
f'<div style="color:#1e293b;font-weight:800;font-size:18px;margin-bottom:16px;">🤖 LLM 배치 탐지 결과</div>' +
f'{summary}' +
f'<div style="overflow-x:auto;">' +
f'<table style="width:100%;border-collapse:collapse;">' +
f'<thead><tr style="background:#f8fafc;border-bottom:2px solid #00b894;">' +
f'<th style="padding:10px;text-align:left;color:#1e293b;font-size:12px;font-weight:700;">모델명</th>' +
f'<th style="padding:10px;text-align:left;color:#64748b;font-size:11px;font-weight:600;">모델 ID</th>' +
f'<th style="padding:10px;text-align:center;color:#0d9488;font-size:11px;font-weight:700;">L1</th>' +
f'<th style="padding:10px;text-align:center;color:#1d4ed8;font-size:11px;font-weight:700;">L2</th>' +
f'<th style="padding:10px;text-align:center;color:#6d28d9;font-size:11px;font-weight:700;">L4</th>' +
f'<th style="padding:10px;text-align:center;color:#475569;font-size:12px;font-weight:600;">계층</th>' +
f'<th style="padding:10px;text-align:center;color:#1e293b;font-size:11px;font-weight:700;">판정</th>' +
f'</tr></thead><tbody>{rows}</tbody></table></div></div>'
)
# ─── 파이프라인 렌더 ─────────────────────────────────────────────────────────
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'<div style="background:#e2e8f0;border-radius:4px;height:5px;overflow:hidden;margin:6px 0;">' +
f'<div style="width:{min(zw_pct,100):.0f}%;height:100%;background:#0d9488;border-radius:4px;"></div></div>'
)
note = ""
if i > 0 and zw_a == 0 and stages[i-1][3] > 0:
note = f'<div style="font-size:10px;color:#c2410c;margin-top:4px;">⚠ 이 단계에서 L1(ZW) 소실 — L2 문체+L4 Mn 방어 유지</div>'
cards += (
f'<div style="background:{health_bg};border:1px solid {health_border}33;' +
f'border-radius:14px;padding:14px;position:relative;">' +
f'<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:4px;">' +
f'<div style="color:#1e293b;font-weight:700;font-size:12px;">{i+1}. {name}</div>' +
f'<span style="background:rgba(0,0,0,.06);color:{health_border};padding:2px 7px;border-radius:8px;font-size:10px;font-weight:700;">{health_icon} {health_label}</span>' +
f'</div>' +
f'<div style="color:#64748b;font-size:10px;margin-bottom:4px;">{desc}</div>' +
f'{zw_bar}' +
f'<div style="display:flex;justify-content:space-between;font-size:10px;color:#64748b;">' +
f'<span>ZW 잔존 {zw_a}/{stages[0][3]}</span>' +
f'<span>L2 {"✅" if l2 else "✕"} L4 {"✅" if l4 else "✕"}</span>' +
f'</div>{note}</div>'
)
total_stages = len(stages); final_alive = sum([stages[-1][3] > 0, stages[-1][4], stages[-1][5]])
summary = (
f'<div style="display:flex;gap:10px;margin-bottom:16px;flex-wrap:wrap;">' +
f'<div style="padding:10px 16px;background:#ffffff;border-radius:10px;border:1px solid #e2e8f0;text-align:center;">' +
f'<div style="color:#64748b;font-size:10px;">단계</div>' +
f'<div style="color:#1e293b;font-size:20px;font-weight:900;">{total_stages}</div></div>' +
f'<div style="padding:10px 16px;background:#ffffff;border-radius:10px;border:1px solid #e2e8f0;text-align:center;">' +
f'<div style="color:#64748b;font-size:10px;">최종 생존</div>' +
f'<div style="color:{"#166534" if final_alive>=2 else "#b91c1c"};font-size:20px;font-weight:900;">{final_alive}/3</div></div>' +
f'<div style="padding:10px 16px;background:#ffffff;border-radius:10px;border:1px solid #e2e8f0;text-align:center;">' +
f'<div style="color:#64748b;font-size:10px;">L1 ZW 잔존</div>' +
f'<div style="color:#0d9488;font-size:20px;font-weight:900;">{stages[-1][3]}</div></div>' +
f'</div>'
)
return (
f'<div style="{BOX}">' +
f'<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px;">' +
f'<div>' +
f'<div style="color:#1e293b;font-weight:800;font-size:16px;">⚙️ AI 파이프라인 — 계층 생존 추적</div>' +
f'<div style="color:#475569;font-size:12px;margin-top:2px;">10단계 전처리 → 3계층 생존 추적</div>' +
f'</div></div>' +
f'{summary}' +
f'<div style="color:#475569;font-size:10px;margin-bottom:12px;padding:8px 12px;background:#f0f4ff;border-radius:8px;">' +
f'💡 L1(ZW)이 제거되어도 L2(문체)와 L4(VS/CGJ)가 생존하면 워터마크 탐지 가능 — ' +
f'이것이 <span style="color:#166534;font-weight:700;">다계층 방어</span>의 핵심입니다.</div>' +
f'<div style="display:grid;grid-template-columns:repeat(2,1fr);gap:10px;">{cards}</div></div>'
)
# ─── 표절 보고서 렌더 ─────────────────────────────────────────────────────────
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'''<svg width="120" height="75" viewBox="0 0 110 70"><path d="M 10 58 A {r_g} {r_g} 0 0 1 100 58" fill="none" stroke="#e2e8f0" stroke-width="8" stroke-linecap="round"/><path d="M 10 58 A {r_g} {r_g} 0 0 1 100 58" fill="none" stroke="{gc}" stroke-width="8" stroke-linecap="round"stroke-dasharray="{fill_g} {circ_g}" opacity=".85"/><text x="55" y="46" text-anchor="middle" fill="{gc}" font-size="22" font-weight="900">{score}</text><text x="55" y="60" text-anchor="middle" fill="#64748b" font-size="8">/100</text></svg>'''
cards = f"""<div style="display:flex;gap:14px;margin-bottom:20px;flex-wrap:wrap;align-items:stretch;"><div style="display:flex;align-items:center;gap:12px;padding:16px 20px;background:#ffffff;border:1px solid {gc}22;border-radius:14px;min-width:240px;">{gauge_svg}<div><div style="background:{gbg};color:{gc};padding:6px 16px;border-radius:12px;font-weight:900;font-size:14px;text-align:center;margin-bottom:6px;">{picon} {grade}</div><div style="color:#64748b;font-size:11px;text-align:center;">{ptype}</div></div></div><div style="flex:1;display:grid;grid-template-columns:repeat(3,1fr);gap:8px;min-width:250px;"><div style="text-align:center;padding:14px;background:#ffffff;border-radius:12px;border:1px solid #e2e8f0;"><div style="color:#d69e2e;font-size:24px;font-weight:900;">{coverage:.0%}</div><div style="color:#64748b;font-size:11px;">문장 커버리지</div></div><div style="text-align:center;padding:14px;background:#ffffff;border-radius:12px;border:1px solid #e2e8f0;"><div style="color:#7c3aed;font-size:24px;font-weight:900;">{sent_avg:.0%}</div><div style="color:#64748b;font-size:11px;">문장 유사도</div></div><div style="text-align:center;padding:14px;background:#ffffff;border-radius:12px;border:1px solid #e2e8f0;"><div style="color:#3182ce;font-size:24px;font-weight:900;">{ng_avg:.0%}</div><div style="color:#64748b;font-size:11px;">N-gram 중복도</div></div></div></div>"""
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"""<div style="display:flex;align-items:center;gap:8px;margin-bottom:4px;"><span style="color:#64748b;font-size:11px;width:52px;text-align:right;">{n}-gram</span><div style="flex:1;height:10px;background:#e2e8f0;border-radius:5px;overflow:hidden;"><div style="height:100%;width:{min(s*100,100)}%;background:linear-gradient(90deg,{bc}88,{bc});border-radius:5px;"></div></div><span style="color:{bc};font-size:12px;font-weight:700;width:40px;text-align:right;">{s:.0%}</span></div>"""
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"""<div style="background:{bg2};border-left:3px solid {lc2};padding:8px 12px;margin-bottom:4px;border-radius:0 8px 8px 0;"><div style="display:flex;justify-content:space-between;align-items:center;"><span style="color:#1e293b;font-size:12px;">{html_lib.escape(ss[:80])}</span><span style="background:{lc2}22;color:{lc2};padding:2px 8px;border-radius:10px;font-size:10px;font-weight:700;white-space:nowrap;margin-left:8px;">{lbl2} {sc_v:.0%}</span></div><div style="color:#64748b;font-size:10px;margin-top:4px;">↔ 원본[{best[0]+1}]: {html_lib.escape(best[2][:60])}</div></div>"""
else: heatmap_rows += f"""<div style="padding:6px 12px;margin-bottom:4px;border-left:3px solid #e2e8f0;border-radius:0 8px 8px 0;"><span style="color:#94a3b8;font-size:12px;padding:4px 0;">{html_lib.escape(ss[:80])}</span></div>"""
axes_html = '<div style="display:grid;grid-template-columns:repeat(4,1fr);gap:8px;margin-top:16px;">' + ''.join(f"""<div style="text-align:center;padding:12px;background:#ffffff;border-radius:10px;border:1px solid rgba({color},.12);"><div style="color:{color};font-size:20px;font-weight:800;">{val:.0%}</div><div style="color:#64748b;font-size:10px;margin-top:2px;">{label}</div></div>""" for label, val, color in [("N-gram 중복도",ng_avg,"#1d4ed8"),("문장 유사도",sent_avg,"#6d28d9"),("커버리지",coverage,"#b45309"),("구조 유지",order_ratio,"#0d9488")]) + '</div>'
return f"""<div style="{BOX}"><div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px;"><div><div style="color:#1e293b;font-weight:800;font-size:18px;">📋 표절 분석 보고서</div><div style="color:#475569;font-size:12px;margin-top:2px;">원본 {len(orig_sents)}문장 vs 의심 {len(susp_sents)}문장</div></div><div style="background:{gbg};color:{gc};padding:8px 20px;border-radius:16px;font-weight:800;font-size:15px;">{picon} {grade}</div></div>{cards}<div style="color:#1e293b;font-weight:700;font-size:14px;margin:20px 0 10px;">📊 N-gram 분석</div>{ng_bars}{axes_html}<div style="color:#1e293b;font-weight:700;font-size:14px;margin:24px 0 10px;">🔍 문장 히트맵</div><div style="color:#64748b;font-size:11px;margin-bottom:8px;"><span style="color:#e53e3e;">■</span> 높음(70%이상)<span style="color:#dd6b20;margin-left:8px;">■</span> 중간(50%이상)<span style="color:#d69e2e;margin-left:8px;">■</span> 낮음(40%이상)<span style="color:#1e293b;margin-left:8px;">■</span> 미탐지</div>{heatmap_rows}</div>"""
# ─── 복사 버튼 포함 워터마크 삽입 결과 박스 ────────────────────────────────────
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'''<div style="background:linear-gradient(135deg,#f0fdf4,#f4f7ff);border:1px solid #bbf7d0;border-radius:16px;padding:20px;margin:12px 0;box-shadow:0 2px 12px rgba(0,184,148,.08);">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px;flex-wrap:wrap;gap:8px;">
<div>
<div style="color:#166534;font-weight:800;font-size:15px;">📋 워터마크 삽입 완료 텍스트</div>
<div style="color:#475569;font-size:11px;margin-top:3px;">
Content ID: <span style="color:#1d4ed8;font-weight:700;">{html_lib.escape(cid)}</span>
&nbsp;·&nbsp; {char_count}
&nbsp;·&nbsp; ZW <span style="color:#0d9488;font-weight:700;">{zw_n}</span>
&nbsp;·&nbsp; Mn <span style="color:#6d28d9;font-weight:700;">{mn_n}</span>
</div>
</div>
<button onclick="(function(){{
var t={js_text};
if(navigator.clipboard){{
navigator.clipboard.writeText(t).then(function(){{
var b=document.getElementById('copy_btn_sm');
var orig=b.innerHTML;
b.innerHTML='✅ 복사됨!';
b.style.background='#dcfce7';
b.style.borderColor='#86efac';
b.style.color='#166534';
setTimeout(function(){{b.innerHTML=orig;b.style.background='';b.style.borderColor='';b.style.color=''}},2000);
}});
}}else{{
var ta=document.createElement('textarea');
ta.value=t;document.body.appendChild(ta);ta.select();document.execCommand('copy');document.body.removeChild(ta);
}}
}})()" id="copy_btn_sm"
style="background:#ffffff;border:1px solid #86efac;color:#166534;padding:8px 20px;border-radius:10px;font-size:13px;font-weight:700;cursor:pointer;white-space:nowrap;transition:all .2s;box-shadow:0 1px 4px rgba(0,184,148,.12);">
📋 전체 복사
</button>
</div>
<div style="background:#ffffff;border:1px solid #e2e8f0;border-radius:10px;padding:14px;max-height:200px;overflow-y:auto;font-size:13px;line-height:1.7;color:#1e293b;word-break:break-all;white-space:pre-wrap;font-family:inherit;">{safe}</div>
<div style="margin-top:8px;color:#64748b;font-size:10px;display:flex;align-items:center;gap:4px;">
<span style="color:#b45309;">⚠️</span> 이 텍스트에는 눈에 보이지 않는 워터마크 문자가 포함되어 있습니다. 그대로 복사·배포하세요.
</div>
</div>'''