TeXray-backup / app.py
openfree's picture
Update app.py
34656b2 verified
import gradio as gr
import math, re, os, json, random, time, hashlib, zlib, zipfile, tempfile
from collections import Counter
from datetime import datetime
from pathlib import Path
from concurrent.futures import ThreadPoolExecutor, as_completed
from xml.etree import ElementTree as ET
from kiwipiepy import Kiwi
KIWI = Kiwi()
try:
import httpx; HAS_HTTPX = True
except ImportError:
HAS_HTTPX = False
try:
from google import genai
from google.genai import types as gtypes
HAS_GENAI = True
except ImportError:
HAS_GENAI = False
try:
import olefile; HAS_OLEFILE = True
except ImportError:
HAS_OLEFILE = False
try:
import pdfplumber; HAS_PDFPLUMBER = True
except ImportError:
HAS_PDFPLUMBER = False
try:
import PyPDF2; HAS_PYPDF2 = True
except ImportError:
HAS_PYPDF2 = False
try:
from docx import Document as DocxDocument; HAS_DOCX = True
except ImportError:
HAS_DOCX = False
GROQ_KEY = os.getenv("GROQ_API_KEY", "")
GEMINI_KEY = os.getenv("GEMINI_API_KEY", "")
BRAVE_KEY = os.getenv("BRAVE_API_KEY", "")
def extract_text_from_pdf(file_path):
"""PDF โ†’ ํ…์ŠคํŠธ (ํŽ˜์ด์ง€๋ณ„ ๋ถ„๋ฆฌ)"""
pages = []
if HAS_PDFPLUMBER:
try:
with pdfplumber.open(file_path) as pdf:
for p in pdf.pages:
t = p.extract_text()
if t: pages.append(t)
if pages: return pages, None
except Exception as e:
print(f"pdfplumber: {e}")
if HAS_PYPDF2:
try:
with open(file_path, 'rb') as f:
reader = PyPDF2.PdfReader(f)
for p in reader.pages:
t = p.extract_text()
if t: pages.append(t)
if pages: return pages, None
except Exception as e:
print(f"PyPDF2: {e}")
return None, "PDF ์ถ”์ถœ ์‹คํŒจ (pdfplumber, PyPDF2 ์—†์Œ)"
def extract_text_from_docx(file_path):
"""DOCX โ†’ ํ…์ŠคํŠธ (๋ฌธ๋‹จ๋ณ„ ๋ถ„๋ฆฌ)"""
if not HAS_DOCX: return None, "python-docx ์—†์Œ"
try:
doc = DocxDocument(file_path)
sections = []
current = []
for para in doc.paragraphs:
txt = para.text.strip()
if not txt:
if current:
sections.append('\n'.join(current))
current = []
else:
current.append(txt)
if current: sections.append('\n'.join(current))
if sections: return sections, None
return None, "DOCX ํ…์ŠคํŠธ ์—†์Œ"
except Exception as e:
return None, f"DOCX ์˜ค๋ฅ˜: {e}"
def extract_text_from_txt(file_path):
"""TXT/MD/CSV ๋“ฑ โ†’ ํ…์ŠคํŠธ"""
for enc in ['utf-8', 'euc-kr', 'cp949', 'utf-16', 'latin-1']:
try:
with open(file_path, 'r', encoding=enc) as f:
text = f.read()
if text.strip():
sections = [s.strip() for s in re.split(r'\n{2,}', text) if s.strip()]
return sections if sections else [text], None
except: continue
return None, "ํ…์ŠคํŠธ ์ธ์ฝ”๋”ฉ ๊ฐ์ง€ ์‹คํŒจ"
def extract_text_from_hwpx(file_path):
"""HWPX (ZIP ๊ธฐ๋ฐ˜) โ†’ ํ…์ŠคํŠธ"""
try:
text_parts = []
with zipfile.ZipFile(file_path, 'r') as zf:
file_list = zf.namelist()
section_files = sorted([f for f in file_list if f.startswith('Contents/section') and f.endswith('.xml')])
if not section_files:
section_files = sorted([f for f in file_list if 'section' in f.lower() and f.endswith('.xml')])
for sf_name in section_files:
try:
with zf.open(sf_name) as sf:
content = sf.read().decode('utf-8', errors='ignore')
content = re.sub(r'\sxmlns[^"]*"[^"]*"', '', content)
content = re.sub(r'<[a-zA-Z]+:', '<', content)
content = re.sub(r'</[a-zA-Z]+:', '</', content)
try:
root = ET.fromstring(content)
texts = []
for elem in root.iter():
if elem.tag.endswith('t') or elem.tag == 't':
if elem.text: texts.append(elem.text)
elif elem.text and elem.text.strip():
if any(x in elem.tag.lower() for x in ['text', 'run', 'para', 'char']):
texts.append(elem.text.strip())
if texts: text_parts.append(' '.join(texts))
except ET.ParseError:
matches = re.findall(r'>([^<]+)<', content)
clean = [t.strip() for t in matches if t.strip() and len(t.strip()) > 1]
if clean: text_parts.append(' '.join(clean))
except: continue
if text_parts:
return text_parts, None
return None, "HWPX ํ…์ŠคํŠธ ์—†์Œ"
except zipfile.BadZipFile:
return None, "์œ ํšจํ•˜์ง€ ์•Š์€ HWPX"
except Exception as e:
return None, f"HWPX ์˜ค๋ฅ˜: {e}"
def _decode_hwp_para(data):
"""HWP ๋ฐ”์ด๋„ˆ๋ฆฌ โ†’ ๋ฌธ๋‹จ ํ…์ŠคํŠธ"""
result = []
i = 0
while i < len(data) - 1:
code = int.from_bytes(data[i:i+2], 'little')
if code in (1,2,3): i += 14
elif code == 9: result.append('\t')
elif code in (10,13): result.append('\n')
elif code == 24: result.append('-')
elif code in (30,31): result.append(' ')
elif code >= 32:
try:
ch = chr(code)
if ch.isprintable() or ch in '\n\t ': result.append(ch)
except: pass
i += 2
text = ''.join(result).strip()
text = re.sub(r'[ \t]+', ' ', text)
text = re.sub(r'\n{3,}', '\n\n', text)
return text if len(text) > 2 else None
def _extract_hwp_section(data):
"""HWP ์„น์…˜ ๋ฐ”์ด๋„ˆ๋ฆฌ โ†’ ํ…์ŠคํŠธ"""
texts = []
pos = 0
while pos < len(data) - 4:
try:
header = int.from_bytes(data[pos:pos+4], 'little')
tag_id = header & 0x3FF
size = (header >> 20) & 0xFFF
pos += 4
if size == 0xFFF:
if pos + 4 > len(data): break
size = int.from_bytes(data[pos:pos+4], 'little')
pos += 4
if pos + size > len(data): break
record_data = data[pos:pos+size]
pos += size
if tag_id == 67 and size > 0:
t = _decode_hwp_para(record_data)
if t: texts.append(t)
except:
pos += 1
return '\n'.join(texts) if texts else None
def extract_text_from_hwp(file_path):
"""HWP (OLE ๊ธฐ๋ฐ˜) โ†’ ํ…์ŠคํŠธ"""
if not HAS_OLEFILE: return None, "olefile ์—†์Œ"
try:
ole = olefile.OleFileIO(file_path)
if not ole.exists('FileHeader'):
ole.close(); return None, "HWP ํ—ค๋” ์—†์Œ"
header_data = ole.openstream('FileHeader').read()
is_compressed = (header_data[36] & 1) == 1 if len(header_data) > 36 else True
all_texts = []
for entry in ole.listdir():
entry_path = '/'.join(entry)
if entry_path.startswith('BodyText/Section'):
try:
stream = ole.openstream(entry).read()
if is_compressed:
try: stream = zlib.decompress(stream, -15)
except:
try: stream = zlib.decompress(stream)
except: pass
section_text = _extract_hwp_section(stream)
if section_text: all_texts.append(section_text)
except: continue
ole.close()
if all_texts: return all_texts, None
return None, "HWP ํ…์ŠคํŠธ ์—†์Œ"
except Exception as e:
return None, f"HWP ์˜ค๋ฅ˜: {e}"
def extract_text_from_file(file_path):
"""
๋งŒ๋Šฅ ๋ฌธ์„œ ์ถ”์ถœ: PDF/DOCX/HWP/HWPX/TXT โ†’ (sections_list, full_text, error)
sections_list: ํŽ˜์ด์ง€/์„น์…˜๋ณ„ ํ…์ŠคํŠธ ๋ฆฌ์ŠคํŠธ
full_text: ์ „์ฒด ํ•ฉ์นœ ํ…์ŠคํŠธ
"""
if not file_path or not os.path.exists(file_path):
return None, None, "ํŒŒ์ผ ์—†์Œ"
ext = Path(file_path).suffix.lower()
sections, error = None, None
if ext == '.pdf':
sections, error = extract_text_from_pdf(file_path)
elif ext == '.docx':
sections, error = extract_text_from_docx(file_path)
elif ext == '.hwpx':
sections, error = extract_text_from_hwpx(file_path)
elif ext == '.hwp':
sections, error = extract_text_from_hwp(file_path)
elif ext in ('.txt', '.md', '.csv', '.json', '.xml', '.html'):
sections, error = extract_text_from_txt(file_path)
else:
return None, None, f"์ง€์›ํ•˜์ง€ ์•Š๋Š” ํ˜•์‹: {ext}"
if sections:
full = '\n\n'.join(sections)
return sections, full, None
return None, None, error or "ํ…์ŠคํŠธ ์ถ”์ถœ ์‹คํŒจ"
def split_sentences(text):
try:
s = [x.text.strip() for x in KIWI.split_into_sents(text) if x.text.strip()]
if s: return s
except: pass
return [x.strip() for x in re.split(r'(?<=[.!?ใ€‚])\s+', text) if x.strip()]
def split_words(text):
return [w for w in re.findall(r'[๊ฐ€-ํžฃa-zA-Z0-9]+', text) if w]
def get_morphemes(text):
try:
r = KIWI.analyze(text)
if r and r[0]: return [(m.form, m.tag) for m in r[0][0]]
except: pass
return []
def http_get(url, headers=None, timeout=15):
try:
if HAS_HTTPX:
r = httpx.get(url, headers=headers or {}, timeout=timeout, follow_redirects=True)
return r.text if r.status_code == 200 else None
else:
import urllib.request
req = urllib.request.Request(url, headers=headers or {})
with urllib.request.urlopen(req, timeout=timeout) as resp:
return resp.read().decode('utf-8', errors='replace')
except: return None
def http_post_json(url, body, headers=None, timeout=30):
try:
h = headers or {}
h["Content-Type"] = "application/json"
if HAS_HTTPX:
r = httpx.post(url, json=body, headers=h, timeout=timeout)
if r.status_code == 200: return r.json()
return None
else:
import urllib.request, ssl
req = urllib.request.Request(url, json.dumps(body).encode(), h)
with urllib.request.urlopen(req, timeout=timeout, context=ssl.create_default_context()) as resp:
return json.loads(resp.read())
except: return None
def call_groq(model, prompt, max_tokens=800, temperature=0.1):
if not GROQ_KEY: return None, "NO_KEY"
url = "https://api.groq.com/openai/v1/chat/completions"
h = {"Authorization": f"Bearer {GROQ_KEY}", "Content-Type": "application/json"}
b = {"model": model, "messages": [{"role":"user","content":prompt}], "max_tokens": max_tokens, "temperature": temperature}
try:
if HAS_HTTPX:
r = httpx.post(url, json=b, headers=h, timeout=45)
if r.status_code == 200: return r.json()["choices"][0]["message"]["content"], None
return None, f"HTTP {r.status_code}"
else:
import urllib.request, ssl
req = urllib.request.Request(url, json.dumps(b).encode(), h)
with urllib.request.urlopen(req, timeout=45, context=ssl.create_default_context()) as resp:
return json.loads(resp.read())["choices"][0]["message"]["content"], None
except Exception as e: return None, str(e)[:150]
AI_ENDINGS = ['ํ•ฉ๋‹ˆ๋‹ค','์ž…๋‹ˆ๋‹ค','๋ฉ๋‹ˆ๋‹ค','์Šต๋‹ˆ๋‹ค','์žˆ์Šต๋‹ˆ๋‹ค','ํ–ˆ์Šต๋‹ˆ๋‹ค','๊ฒ ์Šต๋‹ˆ๋‹ค']
AI_CASUAL_ENDINGS = ['๋ผ๊ณ  ํ•  ์ˆ˜ ์žˆ๋‹ค','๋ผ๊ณ  ๋ณผ ์ˆ˜ ์žˆ๋‹ค','๋‹ค๊ณ  ์ƒ๊ฐํ•œ๋‹ค','๋‹ค๊ณ  ํŒ๋‹จ๋œ๋‹ค',
'์ธ ์…ˆ์ด๋‹ค','์ธ ๊ฒƒ์ด๋‹ค','๋Š” ๊ฒƒ์ด๋‹ค','๋Š” ์…ˆ์ด๋‹ค','ใ„น ๊ฒƒ์ด๋‹ค','์„ ๊ฒƒ์ด๋‹ค',
'๋ผ ํ•  ์ˆ˜ ์žˆ๋‹ค','๋กœ ๋ณด์ธ๋‹ค','๋กœ ํŒ๋‹จ๋œ๋‹ค','๊ณ  ์žˆ๋‹ค','๋Š” ์ถ”์„ธ๋‹ค','๋Š” ์ƒํ™ฉ์ด๋‹ค',
'์ง€ ์•Š์„ ์ˆ˜ ์—†๋‹ค','๋ผ ํ•˜๊ฒ ๋‹ค','์Œ์„ ์•Œ ์ˆ˜ ์žˆ๋‹ค','ํ•  ํ•„์š”๊ฐ€ ์žˆ๋‹ค']
AI_CONNS = ['๋˜ํ•œ','๋”ฐ๋ผ์„œ','๊ทธ๋Ÿฌ๋ฏ€๋กœ','์ด์— ๋”ฐ๋ผ','ํ•œํŽธ','๋”๋ถˆ์–ด','์•„์šธ๋Ÿฌ','๋ฟ๋งŒ ์•„๋‹ˆ๋ผ',
'์ด๋ฅผ ํ†ตํ•ด','์ด์—','๊ฒฐ๊ณผ์ ์œผ๋กœ','๊ถ๊ทน์ ์œผ๋กœ','ํŠนํžˆ','๋‚˜์•„๊ฐ€','์ด๋Ÿฌํ•œ']
AI_SOFT_CONNS = ['๋ฌผ๋ก ','๊ทธ๋Ÿฌ๋‚˜','ํ•˜์ง€๋งŒ','์ด์ฒ˜๋Ÿผ','์ด์™€ ๊ฐ™์ด','์ด๋ฅผ ๋ฐ”ํƒ•์œผ๋กœ']
AI_FILLER = ['๊ฒƒ์œผ๋กœ ๋ณด','๊ฒƒ์œผ๋กœ ๋‚˜ํƒ€','๊ฒƒ์œผ๋กœ ์˜ˆ์ƒ','ํ•  ์ˆ˜ ์žˆ','๋ณผ ์ˆ˜ ์žˆ','์ฃผ๋ชฉํ•  ๋งŒ',
'์ค‘์š”ํ•œ ์—ญํ• ','์ค‘์š”ํ•œ ์˜๋ฏธ','๊ธ์ •์ ์ธ ์˜ํ–ฅ','๋ถ€์ •์ ์ธ ์˜ํ–ฅ','ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค','ํ•„์š”ํ•˜๋‹ค',
'์ค‘์š”ํ•ฉ๋‹ˆ๋‹ค','์ค‘์š”ํ•˜๋‹ค','์—ญํ• ์„ ํ•˜','์˜ํ–ฅ์„ ๋ฏธ','๊ธฐ๋Œ€๋œ๋‹ค','์˜ˆ์ƒ๋ฉ๋‹ˆ๋‹ค','๋ถ€๊ฐ๋˜๊ณ ',
'๋Œ€๋‘๋˜๊ณ ','๋‹ค์–‘ํ•œ ๋ถ„์•ผ','๋‹ค์–‘ํ•œ ์‚ฐ์—…','๋ˆˆ๋ถ€์‹  ์„ฑ๊ณผ','ํš๊ธฐ์ ์ธ ๋ณ€ํ™”','ํ˜์‹ ์ ์ธ',
'์ ์—์„œ','์ธก๋ฉด์—์„œ','๊ด€์ ์—์„œ']
AI_CASUAL_FILLER = ['๋ฌด๊ถ๋ฌด์ง„ํ•˜๋‹ค','๋ฌด๊ถ๋ฌด์ง„ํ•œ','๊ณผ์–ธ์ด ์•„๋‹ˆ','๋ŒํŒŒ๊ตฌ๊ฐ€ ๋ ','์ „ํ™˜์ ์ด ๋ ',
'๊ธฐ๋ฐ˜์œผ๋กœ','๋ฐœํŒ์œผ๋กœ','์›๋™๋ ฅ์ด','์ดˆ์„์ด ๋ ','๊ฐ€์†ํ™”๋˜','๊ธ‰๋ถ€์ƒ','ํŒจ๋Ÿฌ๋‹ค์ž„',
'์ง€ํ‰์„ ์—ด','์ƒˆ๋กœ์šด ์žฅ์„','๋Œ€์ „ํ™˜','๋ณธ๊ฒฉํ™”๋˜','๊ณ ๋„ํ™”','์ด์ •ํ‘œ']
AI_CONCESSION = re.compile(r'๋ฌผ๋ก .{2,20}(ํ•˜์ง€๋งŒ|๊ทธ๋Ÿฌ๋‚˜|๊ทธ๋ ‡์ง€๋งŒ|๋‹ค๋งŒ)|.{2,15}(์ด๊ธด ํ•˜์ง€๋งŒ|๊ธฐ๋Š” ํ•˜์ง€๋งŒ|์ˆ˜ ์žˆ์ง€๋งŒ|์ˆ˜๋Š” ์žˆ์ง€๋งŒ)')
EN_AI_MARKERS = ['furthermore','additionally','moreover','it is worth noting','in conclusion',
'it is important to','plays a crucial role','significant impact','various aspects',
'in this regard','consequently','nevertheless','integral part of','led to remarkable',
'fundamentally transformed','has become increasingly','it should be noted',
'in the context of','paradigm shift','landscape of','methodologies',
'transformative impact','unprecedented','in various domains']
HUMAN_MARKERS = {
'ใ…‹ใ…Žใ… ': re.compile(r'([ใ…‹ใ…Žใ… ใ…œใ„ทใ„ฑ])\1{1,}'),
'์ด๋ชจํ‹ฐ์ฝ˜': re.compile(r'[;:]-?[)(DPp]|\^[_\-]?\^|ใ…กใ…ก|;;'),
'์ค„์ž„': re.compile(r'ใ„นใ…‡|ใ…‡ใ…‡|ใ„ดใ„ด|ใ…‡ใ…‹|ใ„ทใ„ท|ใ…‚ใ…‚'),
'๋А๋‚Œํ‘œ': re.compile(r'[!?]{2,}'),
'๋น„๊ฒฉ์‹์ข…๊ฒฐ': re.compile(r'(๊ฑฐ๋“ |์ž–์•„|์ธ๋ฐ|์ธ๊ฑธ|๊ฐ™์Œ|๋А๋‚Œ|์•„๋‹˜|๋Œ€๋ฐ•|๋ฏธ์ณค|ํ—|ใ…‹$|ใ…Ž$|์ž„$|์Œ$|๋“ฏ$)'),
'๊ตฌ์–ด์ถ•์•ฝ': re.compile(r'(๊ฑ|์ข€|๋ง‰|์™„์ „|์ง„์งœ|๋ ˆ์•Œ|์กด๋‚˜|๊ฐœ|์กธ๋ผ|์กด๋ง›|๊ฒ๋‚˜)'),
'๋งž์ถค๋ฒ•์˜ค๋ฅ˜': re.compile(r'๋ฌ|๋ช‡์ผ|๊ธˆ์ƒˆ|ํ• ์ˆ˜์žˆ|๊ฒƒ๊ฐ™[์€๋‹ค]|๋˜๊ฐ€|๋˜์„œ|์•ˆ๋€|ํ–‡๋‹ค'),
'๋ง์ค„์ž„ํ‘œ': re.compile(r'\.{3,}|โ€ฆ'),
}
FP = {
"GPT": {"m":['๋ฌผ๋ก ์ด์ฃ ','๋„์›€์ด ๋˜์…จ๊ธฐ๋ฅผ','์„ค๋ช…ํ•ด ๋“œ๋ฆฌ๊ฒ ์Šต๋‹ˆ๋‹ค','์ถ”๊ฐ€ ์งˆ๋ฌธ','๋„์›€์ด ํ•„์š”ํ•˜์‹œ๋ฉด',
'์š”์•ฝํ•˜์ž๋ฉด','๊ฐ„๋žตํžˆ ์ •๋ฆฌํ•˜๋ฉด','ํ•ต์‹ฌ์€'],"e":['์Šต๋‹ˆ๋‹ค','๋“œ๋ฆฌ๊ฒ ์Šต๋‹ˆ๋‹ค'],"lp":re.compile(r'^\d+\.\s|^[-โ€ข]\s',re.M)},
"Claude": {"m":['๋ง์”€ํ•˜์‹ ','์‚ดํŽด๋ณด๊ฒ ์Šต๋‹ˆ๋‹ค','๊ท ํ˜• ์žกํžŒ','๋งฅ๋ฝ์—์„œ','ํ•œ ๊ฐ€์ง€ ์ฃผ์˜ํ• ','๋‰˜์•™์Šค',
'ํฅ๋ฏธ๋กœ์šด ์งˆ๋ฌธ','๋ณต์žกํ•œ ์ฃผ์ œ'],"e":['๋„ค์š”','๊ฑฐ์˜ˆ์š”'],"lp":re.compile(r'^\*\*.*\*\*|^#+\s',re.M)},
"Gemini": {"m":['๋‹ค์Œ๊ณผ ๊ฐ™์Šต๋‹ˆ๋‹ค','์ •๋ฆฌํ•ด ๋“œ๋ฆฌ๊ฒ ์Šต๋‹ˆ๋‹ค','ํ•ต์‹ฌ ๋‚ด์šฉ์„','๋” ์•Œ๊ณ  ์‹ถ์œผ์‹œ๋ฉด',
'์•Œ์•„๋ณด๊ฒ ์Šต๋‹ˆ๋‹ค'],"e":['๊ฒ ์Šต๋‹ˆ๋‹ค','๋ณด์„ธ์š”'],"lp":re.compile(r'^\*\s|^-\s\*\*',re.M)},
"Perplexity": {"m":['๊ฒ€์ƒ‰ ๊ฒฐ๊ณผ์— ๋”ฐ๋ฅด๋ฉด','๋ณด๋„์— ๋”ฐ๋ฅด๋ฉด','์—ฐ๊ตฌ์— ๋”ฐ๋ฅด๋ฉด','๋ฐํ˜”๋‹ค','์ „ํ–ˆ๋‹ค',
'๊ฒƒ์œผ๋กœ ๋‚˜ํƒ€๋‚ฌ๋‹ค','๊ฒƒ์œผ๋กœ ์กฐ์‚ฌ๋๋‹ค','๊ฒƒ์œผ๋กœ ์ง‘๊ณ„๋๋‹ค','๋ฐœํ‘œํ–ˆ๋‹ค'],"e":['๋ฐํ˜”๋‹ค','๋‚˜ํƒ€๋‚ฌ๋‹ค','์ „ํ–ˆ๋‹ค'],"lp":re.compile(r'\[\d+\]',re.M)},
}
def score_sentence(sent):
"""๋‹จ์ผ ๋ฌธ์žฅ AI ์ ์ˆ˜ (0~100). ํƒญ1ยทํƒญ2 ๊ณต์œ . v5.0 ๋Œ€ํญ ๊ฐ•ํ™”."""
sc = 0; reasons = []
sl = sent.lower().strip()
sr = sent.rstrip('.!?ใ€‚')
for e in AI_ENDINGS:
if sr.endswith(e): sc += 22; reasons.append(f"๊ฒฉ์‹์–ด๋ฏธ(-{e})"); break
if sc == 0: # ๊ฒฉ์‹์ด ์•„๋‹Œ ๊ฒฝ์šฐ๋งŒ
for e in AI_CASUAL_ENDINGS:
if sr.endswith(e): sc += 15; reasons.append(f"๋น„๊ฒฉ์‹AI(-{e})"); break
stripped = sent.strip()
for c in AI_CONNS:
if stripped.startswith(c):
sc += 18; reasons.append(f"AI์ ‘์†์‚ฌ({c})"); break
else:
for c in AI_SOFT_CONNS:
if stripped.startswith(c): sc += 8; reasons.append(f"์•ฝํ•œ์ ‘์†์‚ฌ({c})"); break
filler_found = sum(1 for f in AI_FILLER if f in sent)
casual_filler = sum(1 for f in AI_CASUAL_FILLER if f in sent)
total_filler = filler_found + casual_filler
if total_filler >= 3: sc += 25; reasons.append(f"์ƒํˆฌํ‘œํ˜„ร—{total_filler}")
elif total_filler == 2: sc += 18; reasons.append(f"์ƒํˆฌํ‘œํ˜„ร—2")
elif total_filler == 1: sc += 10; reasons.append(f"์ƒํˆฌํ‘œํ˜„ร—1")
if AI_CONCESSION.search(sent): sc += 10; reasons.append("์–‘๋ณดํŒจํ„ด")
for mn, fp in FP.items():
for m in fp["m"]:
if m in sent: sc += 8; reasons.append(f"{mn}์ง€๋ฌธ"); break
en_count = sum(1 for em in EN_AI_MARKERS if em in sl)
if en_count >= 3: sc += 25; reasons.append(f"์˜์–ดAIร—{en_count}")
elif en_count >= 2: sc += 18; reasons.append(f"์˜์–ดAIร—{en_count}")
elif en_count >= 1: sc += 12; reasons.append(f"์˜์–ดAIร—1")
has_formal = any(sr.endswith(e) for e in AI_ENDINGS)
has_conn = any(stripped.startswith(c) for c in AI_CONNS)
if has_formal and total_filler >= 1 and has_conn: sc += 8; reasons.append("๋ณตํ•ฉAI")
elif has_formal and total_filler >= 2: sc += 5; reasons.append("๊ฒฉ์‹+์ƒํˆฌ")
for n, p in HUMAN_MARKERS.items():
matches = p.findall(sent)
if matches:
if n in ('ใ…‹ใ…Žใ… ','์ด๋ชจํ‹ฐ์ฝ˜','์ค„์ž„'): sc -= 25; reasons.append(f"์ธ๊ฐ„({n})")
elif n in ('๋น„๊ฒฉ์‹์ข…๊ฒฐ','๊ตฌ์–ด์ถ•์•ฝ'): sc -= 18; reasons.append(f"๊ตฌ์–ด์ฒด({n})")
elif n == '๋งž์ถค๋ฒ•์˜ค๋ฅ˜': sc -= 12; reasons.append("๋งž์ถค๋ฒ•์˜ค๋ฅ˜")
elif n in ('๋А๋‚Œํ‘œ','๋ง์ค„์ž„ํ‘œ'): sc -= 10; reasons.append(f"์ธ๊ฐ„({n})")
return max(0, min(100, sc)), reasons
def analyze_statistics(text, sentences, words):
sl = [len(s) for s in sentences]
if len(sl) < 2: return {"score":50}
avg = sum(sl)/len(sl); std = math.sqrt(sum((l-avg)**2 for l in sl)/len(sl))
cv = std/avg if avg > 0 else 0
cv_score = 85 if cv<0.20 else 70 if cv<0.30 else 50 if cv<0.45 else 30 if cv<0.60 else 15
diffs = [abs(sl[i]-sl[i-1]) for i in range(1,len(sl))]
burst_score = 50
if diffs:
avg_d = sum(diffs)/len(diffs)
max_d = max(diffs)
burst_ratio = max_d / (avg_d + 1)
burst_score = 85 if burst_ratio < 1.8 else 65 if burst_ratio < 2.5 else 40 if burst_ratio < 3.5 else 20
standard_ratio = sum(1 for l in sl if 20 <= l <= 60) / len(sl)
std_score = 80 if standard_ratio > 0.8 else 60 if standard_ratio > 0.6 else 40 if standard_ratio > 0.4 else 20
extreme = sum(1 for l in sl if l < 10 or l > 80)
if extreme >= 2: std_score = max(10, std_score - 20)
elif extreme >= 1: std_score = max(15, std_score - 10)
wf = Counter(words); t = len(words)
ttr = len(wf)/t if t>0 else 0
vocab_score = 70 if ttr<0.45 else 55 if ttr<0.55 else 35 if ttr<0.65 else 20
wpc = [len(split_words(s)) for s in sentences]
complex_score = 50
if len(wpc) >= 3:
wpc_avg = sum(wpc)/len(wpc)
wpc_std = math.sqrt(sum((w-wpc_avg)**2 for w in wpc)/len(wpc))
wpc_cv = wpc_std/wpc_avg if wpc_avg > 0 else 0
complex_score = 80 if wpc_cv < 0.20 else 60 if wpc_cv < 0.35 else 35 if wpc_cv < 0.50 else 15
final = int(cv_score*0.20 + burst_score*0.20 + std_score*0.25 + vocab_score*0.15 + complex_score*0.20)
return {"score":final,"cv":round(cv,3),"ttr":round(ttr,3)}
def analyze_korean_style(text, sentences, morphemes):
if not sentences: return {"score":50}
formal_cnt = sum(1 for s in sentences if any(s.rstrip('.!?').endswith(e) for e in AI_ENDINGS))
casual_ai = sum(1 for s in sentences if any(s.rstrip('.!?').endswith(e) for e in AI_CASUAL_ENDINGS))
fr = formal_cnt/len(sentences)
car = casual_ai/len(sentences)
ending_score = 85 if fr>0.7 else 65 if fr>0.5 else 45 if fr>0.3 else 25 if fr>0.1 else 10
ending_score = min(90, ending_score + int(car * 25)) # ๋น„๊ฒฉ์‹AI ๋ณด๋„ˆ์Šค
conn_positions = []
for i, s in enumerate(sentences):
for c in AI_CONNS:
if s.strip().startswith(c): conn_positions.append(i); break
conn_density = len(conn_positions)/len(sentences) if sentences else 0
conn_score = 85 if conn_density>0.4 else 65 if conn_density>0.25 else 40 if conn_density>0.1 else 15
if len(conn_positions) >= 2:
gaps = [conn_positions[i]-conn_positions[i-1] for i in range(1,len(conn_positions))]
gap_cv = (math.sqrt(sum((g-sum(gaps)/len(gaps))**2 for g in gaps)/len(gaps))/(sum(gaps)/len(gaps)+0.01))
if gap_cv < 0.5: conn_score = min(90, conn_score + 10) # ๋งค์šฐ ๊ทœ์น™์  โ†’ AI ๋ณด๋„ˆ์Šค
filler_cnt = sum(1 for f in AI_FILLER if f in text) + sum(1 for f in AI_CASUAL_FILLER if f in text)
filler_score = 90 if filler_cnt>=6 else 75 if filler_cnt>=4 else 55 if filler_cnt>=2 else 30 if filler_cnt>=1 else 10
concession_cnt = len(AI_CONCESSION.findall(text))
conc_score = 80 if concession_cnt >= 2 else 55 if concession_cnt >= 1 else 20
human_count = sum(len(p.findall(text)) for p in HUMAN_MARKERS.values())
human_penalty = min(35, human_count * 8)
pos_score = 45
if morphemes:
pc = Counter(t for _,t in morphemes); tm = sum(pc.values())
noun_r = sum(pc.get(t,0) for t in ['NNG','NNP','NNB','NR'])/tm if tm else 0
pos_score = 70 if noun_r>0.42 else 55 if noun_r>0.38 else 35 if noun_r>0.32 else 20
final = max(5, int(ending_score*0.25 + conn_score*0.20 + filler_score*0.20 +
conc_score*0.10 + pos_score*0.15 + 10*0.10) - human_penalty)
return {"score":final,"formal":f"{fr:.0%}","conn":f"{conn_density:.2f}","filler":filler_cnt,"human":human_count}
def analyze_repetition(text, sentences, words):
if not sentences or len(sentences) < 2: return {"score":35}
tr = 0
if len(words)>=5:
tg = Counter(tuple(words[i:i+3]) for i in range(len(words)-2))
tr = sum(1 for c in tg.values() if c>1)/len(tg) if tg else 0
ngram_score = 80 if tr>0.15 else 60 if tr>0.08 else 35 if tr>0.03 else 15
openers_2 = []
openers_3 = []
for s in sentences:
ws = split_words(s)
if len(ws) >= 2: openers_2.append(tuple(ws[:2]))
if len(ws) >= 3: openers_3.append(tuple(ws[:3]))
opener2_score = 50
if openers_2:
unique2 = len(set(openers_2))/len(openers_2)
opener2_score = 80 if unique2 < 0.5 else 60 if unique2 < 0.7 else 35 if unique2 < 0.85 else 15
ai_only_conns = ['๋˜ํ•œ','๋”ฐ๋ผ์„œ','๊ทธ๋Ÿฌ๋ฏ€๋กœ','์ด์— ๋”ฐ๋ผ','๋”๋ถˆ์–ด','์•„์šธ๋Ÿฌ','๋ฟ๋งŒ ์•„๋‹ˆ๋ผ',
'์ด๋ฅผ ํ†ตํ•ด','์ด์—','๊ฒฐ๊ณผ์ ์œผ๋กœ','๊ถ๊ทน์ ์œผ๋กœ','๋‚˜์•„๊ฐ€','์ด๋Ÿฌํ•œ']
cr = sum(1 for s in sentences if any(s.strip().startswith(c) for c in ai_only_conns))
crr = cr/len(sentences) if sentences else 0
ai_conn_score = 85 if crr>0.35 else 65 if crr>0.2 else 40 if crr>0.08 else 15
templates = []
for s in sentences:
ws = split_words(s)
if len(ws) >= 4:
templates.append((ws[0], ws[-1]))
template_rep = 0
if templates:
tc = Counter(templates)
template_rep = sum(1 for c in tc.values() if c > 1) / len(tc) if tc else 0
template_score = 80 if template_rep > 0.3 else 55 if template_rep > 0.1 else 25
endings = []
for s in sentences:
sr = s.rstrip('.!?ใ€‚')
for e in AI_ENDINGS + ['์žˆ๋‹ค','ํ–ˆ๋‹ค','๋œ๋‹ค','ํ•œ๋‹ค','์ด๋‹ค','๋Š”๋‹ค']:
if sr.endswith(e): endings.append(e); break
ending_div = 50
if endings:
unique_e = len(set(endings))/len(endings)
ending_div = 80 if unique_e < 0.3 else 60 if unique_e < 0.5 else 35 if unique_e < 0.7 else 15
final = int(ngram_score*0.15 + opener2_score*0.20 + ai_conn_score*0.25 +
template_score*0.15 + ending_div*0.25)
return {"score":final}
AI_VAGUE = re.compile(r'๋‹ค์–‘ํ•œ|์ค‘์š”ํ•œ|๊ธ์ •์ ์ธ|๋ถ€์ •์ ์ธ|ํš๊ธฐ์ ์ธ|ํ˜์‹ ์ ์ธ|ํšจ์œจ์ ์ธ|์ฒด๊ณ„์ ์ธ|์ข…ํ•ฉ์ ์ธ|์ „๋ฐ˜์ ์ธ|์ง€์†์ ์ธ|์ ๊ทน์ ์ธ|์ƒ๋‹นํ•œ|์ฃผ์š”ํ•œ')
CONCRETE_PROPER = re.compile(r'์‚ผ์„ฑ|LG|ํ˜„๋Œ€|SK|์นด์นด์˜ค|๋„ค์ด๋ฒ„|๋„ทํ”Œ๋ฆญ์Šค|๊ตฌ๊ธ€|์• ํ”Œ|ํ…Œ์Šฌ๋ผ|์•„๋งˆ์กด|๋งˆ์ดํฌ๋กœ์†Œํ”„ํŠธ|[๊ฐ€-ํžฃ]{2,}๋Œ€ํ•™|[๊ฐ€-ํžฃ]{2,}๋ณ‘์›|[๊ฐ€-ํžฃ]{1,3}์‹œ[๋Š”์„์ด๊ฐ€]|[๊ฐ€-ํžฃ]{1,3}๊ตฌ[๋Š”์„์ด๊ฐ€]|[๊ฐ€-ํžฃ]{2,}๋™[์—์„œ]')
CONCRETE_NUMBER = re.compile(r'\d{2,}[๋งŒ์–ต์กฐ์›๋‹ฌ๋Ÿฌ%๊ฐœ๋…„์›”์ผ์œ„๋“ฑํ˜ธ]|\d+\.\d+%|\d{4}๋…„|\d{1,2}์›”')
CONCRETE_QUOTE = re.compile(r'์— ๋”ฐ๋ฅด๋ฉด|๋ฐœํ‘œํ–ˆ|๋ฐํ˜”๋‹ค|๋ณด๋„ํ–ˆ|์ „ํ–ˆ๋‹ค|๋ผ๊ณ  ๋ง|์ธํ„ฐ๋ทฐ|์„ค๋ฌธ|์กฐ์‚ฌ|ํ†ต๊ณ„์ฒญ|๋ณด๊ณ ์„œ')
def analyze_structure(text, sentences):
if not sentences: return {"score":35}
lt = (len(re.findall(r'^\d+[.)]\s',text,re.M)) + len(re.findall(r'^[-โ€ข*]\s',text,re.M)) +
len(re.findall(r'^#+\s',text,re.M)) + len(re.findall(r'\*\*[^*]+\*\*',text)))
list_score = 90 if lt>=5 else 70 if lt>=3 else 45 if lt>=1 else 10
vague_cnt = len(AI_VAGUE.findall(text))
proper_cnt = len(CONCRETE_PROPER.findall(text))
number_cnt = len(CONCRETE_NUMBER.findall(text))
quote_cnt = len(CONCRETE_QUOTE.findall(text))
concrete_total = proper_cnt + number_cnt + quote_cnt
if vague_cnt >= 3 and concrete_total == 0: abstract_score = 90
elif vague_cnt >= 2 and concrete_total <= 1: abstract_score = 70
elif vague_cnt >= 1 and concrete_total == 0: abstract_score = 55
elif concrete_total >= 3: abstract_score = 10
elif concrete_total >= 2: abstract_score = 20
elif concrete_total >= 1: abstract_score = 30
else: abstract_score = 45
has_question = any(s.strip().endswith('?') for s in sentences)
has_exclaim = any(s.strip().endswith('!') for s in sentences)
has_ellipsis = any('...' in s or 'โ€ฆ' in s for s in sentences)
variety = sum([has_question, has_exclaim, has_ellipsis])
type_score = 15 if variety >= 2 else 40 if variety >= 1 else 65
puncts = re.findall(r'[!?,;:โ€ฆโ€”\-~]', text)
unique_punct = len(set(puncts))
punct_score = 65 if unique_punct <= 1 else 45 if unique_punct <= 3 else 20
paras = [p.strip() for p in text.split('\n\n') if p.strip()]
para_score = 35
if len(paras) >= 2:
pl = [len(split_sentences(p)) for p in paras]
avg_p = sum(pl)/len(pl)
if avg_p > 0:
pcv = math.sqrt(sum((l-avg_p)**2 for l in pl)/len(pl))/avg_p
para_score = 75 if pcv < 0.2 else 55 if pcv < 0.35 else 30
if len(paras) >= 3 and pl[0] < avg_p and pl[-1] < avg_p:
para_score = min(85, para_score + 10)
final = int(list_score*0.10 + abstract_score*0.40 + type_score*0.20 + punct_score*0.10 + para_score*0.20)
return {"score":final}
def analyze_model_fingerprint(text, sentences):
ms = {}
sl = text.lower()
for mn, fp in FP.items():
sc = sum(min(15,text.count(m)*5) for m in fp["m"] if text.count(m)>0)
lm = fp["lp"].findall(text)
if lm: sc += min(20,len(lm)*3)
em = sum(1 for s in sentences if any(s.rstrip('.!?').endswith(e) for e in fp.get("e",[])))
if sentences: sc += int((em/len(sentences))*20)
ms[mn] = min(100,sc)
general_ai = 0
general_ai += sum(5 for f in AI_CASUAL_FILLER if f in text)
casual_end_cnt = sum(1 for s in sentences if any(s.rstrip('.!?').endswith(e) for e in AI_CASUAL_ENDINGS))
general_ai += casual_end_cnt * 5
general_ai += len(AI_CONCESSION.findall(text)) * 8
ms["๋น„๊ฒฉ์‹AI"] = min(100, general_ai)
en_score = sum(5 for em in EN_AI_MARKERS if em in sl)
ms["์˜์–ดAI"] = min(100, en_score)
mx = max(ms.values()) if ms else 0
multi = sum(1 for v in ms.values() if v >= 10)
multi_bonus = 10 if multi >= 3 else 5 if multi >= 2 else 0
base = 85 if mx>=50 else 65 if mx>=35 else 45 if mx>=20 else 25 if mx>=10 else 10
return {"score":min(95, base + multi_bonus),"model_scores":{k:v for k,v in ms.items() if k not in ("๋น„๊ฒฉ์‹AI","์˜์–ดAI") or v > 0}}
def analyze_perplexity(text, sentences, morphemes):
"""ํ•œ๊ตญ์–ด ํŠนํ™” Perplexity + Burstiness โ€” ๋ฌธ์ž ์—”ํŠธ๋กœํ”ผ ๋ณด์ •"""
if len(sentences) < 2: return {"score": 40, "entropy": 0, "variance": 0, "order": 0, "zipf": 0}
chars = [c for c in text if c.strip()]
char_score = 45
if len(chars) >= 30:
cbigrams = [(chars[i], chars[i+1]) for i in range(len(chars)-1)]
cb_freq = Counter(cbigrams)
total_cb = len(cbigrams)
char_entropy = -sum((cnt/total_cb)*math.log2(cnt/total_cb) for cnt in cb_freq.values())
if char_entropy < 7.5: char_score = 78
elif char_entropy < 8.5: char_score = 62
elif char_entropy < 9.5: char_score = 42
elif char_entropy < 10.5: char_score = 25
else: char_score = 12
sl = [len(s) for s in sentences]
burst_score = 45
if len(sl) >= 3:
avg = sum(sl)/len(sl)
std = math.sqrt(sum((l-avg)**2 for l in sl)/len(sl))
cv = std/(avg+1e-10)
if cv < 0.15: burst_score = 82
elif cv < 0.25: burst_score = 62
elif cv < 0.40: burst_score = 38
elif cv < 0.60: burst_score = 20
else: burst_score = 8
sent_ttr = []
for s in sentences:
sw = split_words(s)
if len(sw) >= 3:
sent_ttr.append(len(set(sw))/len(sw))
ttr_score = 42
if len(sent_ttr) >= 3:
avg_ttr = sum(sent_ttr)/len(sent_ttr)
std_ttr = math.sqrt(sum((t-avg_ttr)**2 for t in sent_ttr)/len(sent_ttr))
if std_ttr < 0.04: ttr_score = 75
elif std_ttr < 0.08: ttr_score = 55
elif std_ttr < 0.15: ttr_score = 35
else: ttr_score = 15
endings = [s.rstrip('.!?\u2026')[-3:] for s in sentences if len(s) >= 5]
end_score = 40
if len(endings) >= 3:
ef = Counter(endings)
end_ent = -sum((c/len(endings))*math.log2(c/len(endings)) for c in ef.values())
max_ent = math.log2(len(ef)) if len(ef) > 1 else 1
norm_ent = end_ent / (max_ent + 1e-10)
if norm_ent < 0.5: end_score = 72
elif norm_ent < 0.7: end_score = 50
elif norm_ent < 0.85: end_score = 32
else: end_score = 15
final = int(char_score * 0.30 + burst_score * 0.30 + ttr_score * 0.20 + end_score * 0.20)
return {"score": final, "entropy": char_score, "variance": burst_score, "order": ttr_score, "zipf": end_score}
HUMANIZER_OVERSUBST = re.compile(r'ํ™œ์šฉํ•˜๋‹ค|์ด์šฉํ•˜๋‹ค|์‚ฌ์šฉํ•˜๋‹ค|์ ์šฉํ•˜๋‹ค|๋„์ž…ํ•˜๋‹ค|์ฑ„ํƒํ•˜๋‹ค|์ˆ˜ํ–‰ํ•˜๋‹ค|์ง„ํ–‰ํ•˜๋‹ค|์‹ค์‹œํ•˜๋‹ค|์‹คํ–‰ํ•˜๋‹ค')
HUMANIZER_AWKWARD = re.compile(r'๊ทธ๊ฒƒ์€|์ด๊ฒƒ์€|์ €๊ฒƒ์€|ํ•ด๋‹น ์‚ฌํ•ญ|์•ž์„œ ์–ธ๊ธ‰ํ•œ|์ „์ˆ ํ•œ|์ƒ๊ธฐํ•œ|๊ธฐ์ˆ ๋œ')
HUMANIZER_PASSIVE = re.compile(r'๋˜์–ด์ง€[๊ณ ๋Š”๋ฉฐ]|ํ•˜๊ฒŒ ๋˜์—ˆ[๋‹ค์Šต]|์ˆ˜ํ–‰๋˜[์—ˆ์–ด]|์ง„ํ–‰๋˜[์—ˆ์–ด]|์‹ค์‹œ๋˜[์—ˆ์–ด]|ํ™œ์šฉ๋˜[์—ˆ์–ด]')
def analyze_humanizer(text, sentences, words, morphemes):
"""Humanizer/Bypasser ํƒ์ง€ โ€” AI ์›๋ฌธ ํŒจ๋Ÿฌํ”„๋ ˆ์ด์ฆˆ ํ”์  ๋ถ„์„"""
if len(sentences) < 2: return {"score": 20, "signals": []}
signals = []
content_words = [f for f, t in morphemes if t in ('NNG', 'NNP', 'VV', 'VA')]
if len(content_words) >= 10:
cw_freq = Counter(content_words)
hapax = sum(1 for c in cw_freq.values() if c == 1)
hapax_ratio = hapax / len(cw_freq) if cw_freq else 0
if hapax_ratio > 0.95 and len(content_words) >= 30:
signals.append(("๋™์˜์–ด๊ณผ๋‹ค์น˜ํ™˜", 20, "ํ•ต์‹ฌ ์–ดํœ˜๊ฐ€ ๊ณผ๋„ํ•˜๊ฒŒ ๋ถ„์‚ฐ"))
elif hapax_ratio > 0.90 and len(content_words) >= 25:
signals.append(("๋™์˜์–ด์น˜ํ™˜์˜์‹ฌ", 12, "์–ดํœ˜ ๋ฐ˜๋ณต ํšŒํ”ผ ํŒจํ„ด"))
sl = [len(s) for s in sentences]
if len(sl) >= 4:
avg = sum(sl) / len(sl)
cv = math.sqrt(sum((l - avg)**2 for l in sl) / len(sl)) / (avg + 1e-10)
unique_ratio = len(set(words)) / len(words) if words else 0
if cv < 0.20 and unique_ratio > 0.80 and len(sentences) >= 5:
signals.append(("๊ตฌ์กฐ๋ณด์กด์–ดํœ˜๋ณ€๊ฒฝ", 18, "๋ฌธ์žฅ ๊ตฌ์กฐ ๊ท ์ผ + ๋น„์ •์ƒ์  ์–ดํœ˜ ๋‹ค์–‘์„ฑ"))
residual = 0
conn_positions = []
for i, s in enumerate(sentences):
stripped = s.strip()
for c in ['๋˜ํ•œ','ํŠนํžˆ','ํ•œํŽธ','๋”๋ถˆ์–ด','์•„์šธ๋Ÿฌ','๋‚˜์•„๊ฐ€','์ด์—','๊ฒŒ๋‹ค๊ฐ€','๋ฐ˜๋ฉด','๊ฒฐ๊ตญ']:
if stripped.startswith(c):
conn_positions.append(i)
break
if len(conn_positions) >= 2:
gaps = [conn_positions[i] - conn_positions[i-1] for i in range(1, len(conn_positions))]
if gaps and max(gaps) - min(gaps) <= 1: # ๊ฑฐ์˜ ๋“ฑ๊ฐ„๊ฒฉ
signals.append(("์ ‘์†์‚ฌ๋“ฑ๊ฐ„๊ฒฉ์ž”์กด", 15, "์ ‘์†์‚ฌ ๋ฐฐ์น˜๊ฐ€ ๊ทœ์น™์  (AI ์›๋ฌธ ๊ตฌ์กฐ ์ž”์กด)"))
residual += 15
oversubst = len(HUMANIZER_OVERSUBST.findall(text))
awkward = len(HUMANIZER_AWKWARD.findall(text))
passive = len(HUMANIZER_PASSIVE.findall(text))
if oversubst >= 3:
signals.append(("์œ ์‚ฌ๋™์‚ฌ๋‚œ๋ฌด", 12, f"ํ™œ์šฉ/์ด์šฉ/์‚ฌ์šฉ/์ ์šฉ ๋“ฑ {oversubst}๊ฐœ"))
if awkward >= 2:
signals.append(("์–ด์ƒ‰ํ•œ์ง€์‹œ์–ด", 10, f"ํ•ด๋‹น/์ „์ˆ /์ƒ๊ธฐ ๋“ฑ {awkward}๊ฐœ"))
if passive >= 3:
signals.append(("์ด์ค‘ํ”ผ๋™๊ณผ๋‹ค", 15, f"๋˜์–ด์ง€/์ˆ˜ํ–‰๋˜ ๋“ฑ {passive}๊ฐœ"))
endings = [s.rstrip('.!?')[-2:] for s in sentences if len(s) >= 4]
end_types = len(set(endings)) / len(endings) if endings else 0
has_question = any(s.strip().endswith('?') for s in sentences)
has_exclaim = any(s.strip().endswith('!') for s in sentences)
sent_type_variety = sum([has_question, has_exclaim])
if sent_type_variety == 0 and end_types > 0.85 and len(sentences) >= 6:
signals.append(("์œ ํ˜•๋‹จ์กฐ์–ด๋ฏธ๋‹ค์–‘", 12, "์„œ์ˆ ๋ฌธ๋งŒ + ์ข…๊ฒฐ์–ด๋ฏธ ๊ณผ๋‹ค ๋‹ค์–‘ = Humanizer ํŒจํ„ด"))
starters = [s.strip()[:3] for s in sentences if len(s) >= 6]
starter_unique = len(set(starters)) / len(starters) if starters else 0
if starter_unique >= 0.98 and len(sentences) >= 7:
signals.append(("๋ฌธ๋‘๊ณผ๋‹ค๋‹ค์–‘", 8, "๋ชจ๋“  ๋ฌธ์žฅ ์‹œ์ž‘์ด ๋‹ค๋ฆ„ (์ž์—ฐ์Šค๋Ÿฝ์ง€ ์•Š์€ ๋‹ค์–‘์„ฑ)"))
total = sum(s[1] for s in signals)
if total >= 45: score = 85
elif total >= 30: score = 68
elif total >= 20: score = 52
elif total >= 10: score = 35
else: score = 15
return {"score": score, "signals": signals, "total_evidence": total}
MODEL_PROFILES = {
"GPT": {
"style": ["๊ฒฉ์‹์ฒด ~์Šต๋‹ˆ๋‹ค", "๋˜ํ•œ/ํŠนํžˆ ์ ‘์†์‚ฌ", "~์— ๋Œ€ํ•ด", "~๊ฒƒ์œผ๋กœ ์˜ˆ์ƒ๋ฉ๋‹ˆ๋‹ค"],
"markers": ["๋‹ค์–‘ํ•œ", "์ค‘์š”ํ•œ ์—ญํ• ", "๊ธ์ •์ ์ธ", "๋ˆˆ๋ถ€์‹ ", "์ฃผ๋ชฉํ•  ๋งŒํ•œ", "์‚ดํŽด๋ณด๊ฒ ์Šต๋‹ˆ๋‹ค"],
"structure": "๊ท ์ผํ•œ ๋ฌธ๋‹จ, ์„œ๋ก -๋ณธ๋ก -๊ฒฐ๋ก  ๊ตฌ์กฐ, ๋งˆํฌ๋‹ค์šด ์„ ํ˜ธ",
"endings": ["์Šต๋‹ˆ๋‹ค", "์žˆ์Šต๋‹ˆ๋‹ค", "๋ฉ๋‹ˆ๋‹ค", "์ž…๋‹ˆ๋‹ค"],
"connectors": ["๋˜ํ•œ", "ํŠนํžˆ", "ํ•œํŽธ", "์ด์ฒ˜๋Ÿผ"],
},
"Claude": {
"style": ["๋งฅ๋ฝ ์ œ์‹œ", "๊ท ํ˜• ์žกํžŒ", "์‚ฌ๋ ค ๊นŠ์€ ์–ด์กฐ", "์–‘๋ณด ํ›„ ์ฃผ์žฅ"],
"markers": ["ํฅ๋ฏธ๋กœ์šด ์งˆ๋ฌธ", "๋ณต์žกํ•œ ์ฃผ์ œ", "๋งฅ๋ฝ์—์„œ", "๊ท ํ˜• ์žกํžŒ", "์‚ดํŽด๋ณด๊ฒ ์Šต๋‹ˆ๋‹ค", "ํ•œ ๊ฐ€์ง€ ์ฃผ์˜ํ• "],
"structure": "์ž์—ฐ์Šค๋Ÿฌ์šด ํ๋ฆ„, ์–‘๋ณด-์ฃผ์žฅ ๊ตฌ๋ฌธ ์„ ํ˜ธ, ๋ถ€๋“œ๋Ÿฌ์šด ์ „ํ™˜",
"endings": ["๋„ค์š”", "์ž…๋‹ˆ๋‹ค", "์žˆ์Šต๋‹ˆ๋‹ค", "์Šต๋‹ˆ๋‹ค"],
"connectors": ["ํ•œํŽธ", "๋ฌผ๋ก ", "๋‹ค๋งŒ", "์ด์™€ ๊ด€๋ จํ•ด"],
},
"Gemini": {
"style": ["์ •๋ณด ๋‚˜์—ดํ˜•", "~์— ๋Œ€ํ•ด ์•Œ์•„๋ณด๊ฒ ์Šต๋‹ˆ๋‹ค", "๊ฒ€์ƒ‰ ๊ฒฐ๊ณผ ๊ธฐ๋ฐ˜"],
"markers": ["์•Œ์•„๋ณด๊ฒ ์Šต๋‹ˆ๋‹ค", "๋„์›€์ด ๋˜์…จ์œผ๋ฉด", "์ถ”๊ฐ€ ์งˆ๋ฌธ", "์ฐธ๊ณ ๋กœ"],
"structure": "๋ฆฌ์ŠคํŠธ/๋ฒˆํ˜ธ ๋งค๊ธฐ๊ธฐ ์„ ํ˜ธ, ํ—ค๋” ํ™œ์šฉ, ์ •๋ณด ๋ฐ€๋„ ๋†’์Œ",
"endings": ["์Šต๋‹ˆ๋‹ค", "์žˆ์Šต๋‹ˆ๋‹ค", "์„ธ์š”"],
"connectors": ["๋˜ํ•œ", "๊ทธ๋ฆฌ๊ณ ", "์ฐธ๊ณ ๋กœ"],
},
"Perplexity": {
"style": ["์ถœ์ฒ˜ ์ธ์šฉํ˜•", "~์— ๋”ฐ๋ฅด๋ฉด", "์ˆ˜์น˜ ์ œ์‹œ", "๊ฒƒ์œผ๋กœ ๋‚˜ํƒ€๋‚ฌ๋‹ค"],
"markers": ["์— ๋”ฐ๋ฅด๋ฉด", "๊ฒƒ์œผ๋กœ ๋‚˜ํƒ€๋‚ฌ", "๊ฒƒ์œผ๋กœ ์กฐ์‚ฌ๋", "๊ฒƒ์œผ๋กœ ์ง‘๊ณ„๋", "๋ฐœํ‘œํ–ˆ", "๋ณด๋„์— ๋”ฐ๋ฅด๋ฉด"],
"structure": "ํŒฉํŠธ ์ค‘์‹ฌ, ์ˆ˜์น˜ ์ธ์šฉ ๋‹ค์ˆ˜, ์ถœ์ฒ˜ ๋ช…์‹œ ์Šคํƒ€์ผ",
"endings": ["์Šต๋‹ˆ๋‹ค", "๋‚˜ํƒ€๋‚ฌ๋‹ค", "๋ฐํ˜”๋‹ค", "์ „ํ–ˆ๋‹ค"],
"connectors": ["ํ•œํŽธ", "๋˜ํ•œ", "์ด์—"],
},
}
def estimate_model(text, sentences, morphemes, model_scores):
"""AI ๋ชจ๋ธ ์ถ”์ • โ€” ๋ณตํ•ฉ ์ฆ๊ฑฐ ๊ธฐ๋ฐ˜"""
evidence = {m: {"score": 0, "reasons": []} for m in MODEL_PROFILES}
sl = text.lower()
for model, profile in MODEL_PROFILES.items():
fp_score = model_scores.get(model, 0)
evidence[model]["score"] += fp_score * 0.4
if fp_score >= 20:
evidence[model]["reasons"].append(f"์ง€๋ฌธ ๋งค์นญ {fp_score}์ ")
marker_cnt = sum(1 for m in profile["markers"] if m in text)
if marker_cnt >= 2:
evidence[model]["score"] += marker_cnt * 8
evidence[model]["reasons"].append(f"ํŠน์œ  ํ‘œํ˜„ {marker_cnt}๊ฐœ")
end_match = 0
for s in sentences:
for e in profile["endings"]:
if s.rstrip('.!?').endswith(e):
end_match += 1; break
if sentences:
end_ratio = end_match / len(sentences)
if end_ratio > 0.7:
evidence[model]["score"] += 12
evidence[model]["reasons"].append(f"์ข…๊ฒฐ์–ด๋ฏธ {end_ratio:.0%} ์ผ์น˜")
conn_match = sum(1 for s in sentences if any(s.strip().startswith(c) for c in profile["connectors"]))
if conn_match >= 2:
evidence[model]["score"] += conn_match * 4
evidence[model]["reasons"].append(f"์ ‘์†์‚ฌ ํŒจํ„ด {conn_match}ํšŒ")
number_citations = len(re.findall(r'\d+[%๋งŒ์–ต์กฐ]|์— ๋”ฐ๋ฅด๋ฉด|๊ฒƒ์œผ๋กœ ๋‚˜ํƒ€๋‚ฌ|๋ฐœํ‘œํ–ˆ', text))
if number_citations >= 3:
evidence["Perplexity"]["score"] += number_citations * 5
evidence["Perplexity"]["reasons"].append(f"์ˆ˜์น˜/์ธ์šฉ {number_citations}ํšŒ")
concession_cnt = len(AI_CONCESSION.findall(text))
if concession_cnt >= 1:
evidence["Claude"]["score"] += concession_cnt * 10
evidence["Claude"]["reasons"].append(f"์–‘๋ณด-์ฃผ์žฅ ๊ตฌ๋ฌธ {concession_cnt}ํšŒ")
ranked = sorted(evidence.items(), key=lambda x: x[1]["score"], reverse=True)
top = ranked[0]
second = ranked[1] if len(ranked) > 1 else None
if top[1]["score"] < 10:
return {"model": "ํŠน์ • ๋ถˆ๊ฐ€", "confidence": "๋‚ฎ์Œ", "detail": evidence, "ranked": ranked}
gap = top[1]["score"] - (second[1]["score"] if second else 0)
if gap >= 20 and top[1]["score"] >= 30:
conf = "๋†’์Œ"
elif gap >= 10 and top[1]["score"] >= 20:
conf = "์ค‘๊ฐ„"
else:
conf = "๋‚ฎ์Œ"
return {
"model": top[0],
"confidence": conf,
"score": top[1]["score"],
"reasons": top[1]["reasons"],
"detail": evidence,
"ranked": ranked
}
def analyze_quality(text, sentences, words, morphemes):
qs = {}; sl = [len(s) for s in sentences]; tw = len(words)
ideal = sum(1 for l in sl if 15<=l<=70)/len(sentences) if sentences else 0
qs["๊ฐ€๋…์„ฑ"] = min(100,int(ideal*70+(1-sum(1 for l in sl if l>100)/max(1,len(sentences)))*30))
wf = Counter(words); uw = len(wf)
mattr = (sum(len(set(words[i:i+50]))/50 for i in range(max(1,tw-50)))/max(1,tw-50)) if tw>=100 else (uw/tw if tw>0 else 0.5)
hr = sum(1 for c in wf.values() if c==1)/tw if tw>0 else 0
qs["์–ดํœ˜ํ’๋ถ€๋„"] = min(100,int(mattr*80+hr*40))
lc = {'์ˆœ์ ‘':['๊ทธ๋ž˜์„œ','๋”ฐ๋ผ์„œ'],'์—ญ์ ‘':['๊ทธ๋Ÿฌ๋‚˜','ํ•˜์ง€๋งŒ','๋‹ค๋งŒ'],'์ฒจ๊ฐ€':['๋˜ํ•œ','๊ทธ๋ฆฌ๊ณ ','๊ฒŒ๋‹ค๊ฐ€'],'์ „ํ™˜':['ํ•œํŽธ'],'์˜ˆ์‹œ':['์˜ˆ๋ฅผ ๋“ค์–ด'],'์š”์•ฝ':['๊ฒฐ๊ตญ','๊ฒฐ๋ก ์ ์œผ๋กœ']}
ut = sum(1 for cw in lc.values() if any(w in text for w in cw))
qs["๋…ผ๋ฆฌ๊ตฌ์กฐ"] = min(100,int(ut/len(lc)*60+min(40,ut*10)))
si = sum(1 for p in [re.compile(r'๋ฌ'),re.compile(r'๋ช‡์ผ'),re.compile(r'๊ธˆ์ƒˆ')] if p.search(text))
spi = sum(1 for p in [re.compile(r'ํ• ์ˆ˜์žˆ'),re.compile(r'๊ฒƒ๊ฐ™')] if p.search(text))
qs["์ •ํ™•์„ฑ"] = max(0,100-(si+spi)*15)
ar=0;vv=0
if morphemes:
pc = Counter(t for _,t in morphemes); tm = sum(pc.values())
ar = sum(pc.get(t,0) for t in ['VA','MAG','MAJ'])/tm if tm else 0
vv = len(set(f for f,t in morphemes if t in ['VV','VA']))/max(1,sum(1 for _,t in morphemes if t in ['VV','VA']))
qs["ํ‘œํ˜„ํ’๋ถ€์„ฑ"] = min(100,int(ar*200+vv*30))
cr = 0.5
if morphemes:
ct={'NNG','NNP','VV','VA','MAG'}; ft={'JKS','JKC','JKG','JKO','JX','JC','EP','EF','EC','ETN','ETM'}
cc=sum(1 for _,t in morphemes if t in ct); fc=sum(1 for _,t in morphemes if t in ft)
cr = cc/(cc+fc) if (cc+fc)>0 else 0.5
qs["์ •๋ณด๋ฐ€๋„"] = min(100,int(cr*80))
wq = {"๊ฐ€๋…์„ฑ":.20,"์–ดํœ˜ํ’๋ถ€๋„":.18,"๋…ผ๋ฆฌ๊ตฌ์กฐ":.18,"์ •ํ™•์„ฑ":.18,"ํ‘œํ˜„ํ’๋ถ€์„ฑ":.13,"์ •๋ณด๋ฐ€๋„":.13}
total = int(sum(qs[k]*wq[k] for k in wq))
grade = "S" if total>=85 else "A" if total>=72 else "B" if total>=58 else "C" if total>=42 else "D" if total>=28 else "F"
return {"score":total,"grade":grade,"sub_scores":qs}
LLM_JUDGES = [("openai/gpt-oss-120b","GPT-OSS 120B"),("qwen/qwen3-32b","Qwen3 32B"),("moonshotai/kimi-k2-instruct-0905","Kimi-K2")]
def _parse_ai_probability(raw_resp):
"""LLM ์‘๋‹ต์—์„œ AI ํ™•๋ฅ (0~100)์„ ์ถ”์ถœ. ํ•œ๊ตญ์–ด/์˜์–ด ๋‹ค์–‘ํ•œ ํ˜•์‹ ๋Œ€์‘."""
if not raw_resp: return -1
think_content = ''
think_m = re.search(r'<think>(.*?)</think>', raw_resp, flags=re.S)
if think_m: think_content = think_m.group(1)
resp = re.sub(r'<think>.*?</think>', '', raw_resp, flags=re.S).strip()
if not resp or len(resp) < 5:
resp = raw_resp # think๋งŒ ์žˆ์œผ๋ฉด ์›๋ณธ ํฌํ•จ
specific_patterns = [
r'AI\s*ํ™•๋ฅ \s*[:๏ผš]\s*(?:์•ฝ\s*)?(\d+)\s*%?',
r'AI\s*[Pp]robability\s*[:๏ผš]\s*(?:about|approximately?\s*)?(\d+)\s*%?',
r'[Pp]robability\s*(?:of\s*)?(?:being\s*)?AI\s*[:๏ผš\-]\s*(?:about|approximately?\s*)?(\d+)\s*%?',
r'AI\s*(?:์ƒ์„ฑ|์ž‘์„ฑ|ํŒ์ •)?\s*ํ™•๋ฅ \s*[:๏ผš]?\s*(?:์•ฝ\s*)?(\d+)',
r'(?:Score|Rating|Confidence)\s*[:๏ผš]\s*(\d+)',
r'(\d+)\s*%\s*(?:์˜\s*)?(?:ํ™•๋ฅ |๊ฐ€๋Šฅ์„ฑ|probability|likely|chance|likelihood)',
r'(?:์‹ ๋ขฐ๋„|ํ™•์‹ ๋„)\s*[:๏ผš]?\s*(?:์•ฝ\s*)?(\d+)\s*(?:%|ํผ์„ผํŠธ)',
r'(?:์•ฝ\s*)?(\d+)\s*(?:%|ํผ์„ผํŠธ)\s*(?:์ •๋„|์ˆ˜์ค€)',
]
for pat in specific_patterns:
m = re.search(pat, resp, re.I)
if m:
v = int(m.group(1))
if 0 <= v <= 100: return v
lines = [l.strip() for l in resp.strip().split('\n') if l.strip()]
for line in reversed(lines[-5:]):
if re.search(r'AI|ํ™•๋ฅ |[Pp]robab|์‹ ๋ขฐ|ํŒ[์ •๋‹จ]', line):
nums = re.findall(r'(\d+)\s*%', line)
if nums:
v = int(nums[-1])
if 0 <= v <= 100: return v
nums = re.findall(r'(\d+)\s*ํผ์„ผํŠธ', line)
if nums:
v = int(nums[-1])
if 0 <= v <= 100: return v
all_pcts = list(re.finditer(r'(\d+)\s*(?:%|ํผ์„ผํŠธ|percent)', resp, re.I))
for m in reversed(all_pcts):
v = int(m.group(1))
ctx_start = max(0, m.start()-50)
ctx = resp[ctx_start:m.end()+20]
if re.search(r'AI|ํ™•๋ฅ |[Pp]robab|์‹ ๋ขฐ|ํŒ[์ •๋‹จ]|๊ฐ€๋Šฅ์„ฑ|likelihood', ctx, re.I):
if 0 <= v <= 100: return v
if all_pcts:
v = int(all_pcts[-1].group(1))
if 5 <= v <= 99: return v # 100% ์ œ์™ธ (ํ†ต๊ณ„ ์ˆ˜์น˜ ์˜คํƒ ๋ฐฉ์ง€)
if think_content:
for pat in specific_patterns:
m = re.search(pat, think_content, re.I)
if m:
v = int(m.group(1))
if 0 <= v <= 100: return v
think_pcts = re.findall(r'(\d+)\s*%', think_content)
if think_pcts:
v = int(think_pcts[-1])
if 5 <= v <= 99: return v
return -1
def llm_cross_check(text):
if not GROQ_KEY: return {"score":-1,"detail":{}}
prompt = f"""Analyze whether this text was written by AI.
[Instructions]
1. Determine AI vs Human with 3 brief reasons
2. IMPORTANT - Your LAST line MUST be exactly this format:
AIํ™•๋ฅ : XX%
(Replace XX with your estimated probability 0-100)
Example of correct last line:
AIํ™•๋ฅ : 75%
[Text to analyze]
{text[:2000]}"""
votes=[]; rpt={}
for mid,mn in LLM_JUDGES:
resp,err = call_groq(mid,prompt)
if resp:
p = _parse_ai_probability(resp)
if p >= 0:
votes.append(p); rpt[mn]=f"{p}%"
else:
cleaned = re.sub(r'<think>.*?</think>', '', resp, flags=re.S).strip()
tail = cleaned[-60:].replace('\n',' ') if len(cleaned) > 60 else cleaned.replace('\n',' ')
rpt[mn]=f"ํŒŒ์‹ฑ์‹คํŒจ({tail[:40]})"
else: rpt[mn]=f"ERR:{err[:30] if err else '?'}"
if votes: return {"score":int(sum(votes)/len(votes)),"detail":rpt}
return {"score":-1,"detail":rpt}
def compute_verdict(scores, llm_score=-1, sent_avg=-1, ppx_score=-1, hum_score=-1):
w={"ํ†ต๊ณ„":.06,"๋ฌธ์ฒด":.25,"๋ฐ˜๋ณต์„ฑ":.10,"๊ตฌ์กฐ":.12,"์ง€๋ฌธ":.30}
ws=sum(scores[k]*w[k] for k in w)
if ppx_score >= 0: ws += ppx_score * 0.17
style=scores["๋ฌธ์ฒด"]; fp=scores["์ง€๋ฌธ"]; rep=scores["๋ฐ˜๋ณต์„ฑ"]; struct=scores["๊ตฌ์กฐ"]
if style>=35 and fp>=35: ws+=8
elif style>=30 and fp>=25: ws+=4
if style>=30 and rep>=25 and fp>=20: ws+=4
if fp>=45: ws+=3
if struct>=50 and style>=30: ws+=3
if ppx_score>=55 and fp>=35: ws+=5
if ppx_score>=65 and style>=35: ws+=3
if hum_score>=50:
ws=max(ws, 45) # Humanizer ํ™•์ธ โ†’ ์ตœ์†Œ AI ์˜์‹ฌ ์ค‘๊ฐ„
ws += (hum_score-50)*0.15
if sent_avg>=0 and sent_avg>ws: ws=ws*0.80+sent_avg*0.20
hi=sum(1 for v in scores.values() if v>=50)
if hi>=4: ws+=8
elif hi>=3: ws+=5
elif hi>=2: ws+=2
if style<40 and fp<=20 and rep<22 and struct<35 and (ppx_score<0 or ppx_score<40):
ws-=5
lo=sum(1 for v in scores.values() if v<20)
if lo>=3: ws-=8
elif lo>=2: ws-=3
if llm_score>=0: ws=ws*0.70+llm_score*0.30
fs=max(0,min(100,int(ws)))
if fs>=75: return fs,"AI ์ž‘์„ฑ ํ™•์‹ ","ai_high"
if fs>=60: return fs,"AI ์˜์‹ฌ ๋†’์Œ","ai_medium"
if fs>=45: return fs,"AI ์˜์‹ฌ ์ค‘๊ฐ„","ai_low"
if fs>=30: return fs,"ํŒ๋‹จ ์œ ๋ณด","uncertain"
return fs,"์ธ๊ฐ„ ์ž‘์„ฑ ์ถ”์ •","human"
def quick_score(text):
sents=split_sentences(text); words=split_words(text); morphs=get_morphemes(text)
sc={"ํ†ต๊ณ„":analyze_statistics(text,sents,words)["score"],"๋ฌธ์ฒด":analyze_korean_style(text,sents,morphs)["score"],
"๋ฐ˜๋ณต์„ฑ":analyze_repetition(text,sents,words)["score"],"๊ตฌ์กฐ":analyze_structure(text,sents)["score"],
"์ง€๋ฌธ":analyze_model_fingerprint(text,sents)["score"]}
sent_scores=[score_sentence(s)[0] for s in sents]
sent_avg=sum(sent_scores)/len(sent_scores) if sent_scores else -1
ppx=analyze_perplexity(text,sents,morphs)
hum=analyze_humanizer(text,sents,words,morphs)
fs,v,lv=compute_verdict(sc, sent_avg=sent_avg, ppx_score=ppx["score"], hum_score=hum["score"])
return fs,v,lv,sc,ppx,hum
from plagiarism_check import run_plagiarism
def run_detection(text, progress=gr.Progress()):
if not text or len(text.strip())<50: return "<div style='padding:20px;text-align:center;color:#888;'>โš ๏ธ ์ตœ์†Œ 50์ž</div>",""
text=text.strip()
progress(0.05); sents=split_sentences(text); words=split_words(text); morphs=get_morphemes(text)
progress(0.12); s1=analyze_statistics(text,sents,words)
progress(0.22); s2=analyze_korean_style(text,sents,morphs)
progress(0.30); s3=analyze_repetition(text,sents,words)
progress(0.38); s4=analyze_structure(text,sents)
progress(0.45); s5=analyze_model_fingerprint(text,sents)
progress(0.52); ppx=analyze_perplexity(text,sents,morphs)
progress(0.58); hum=analyze_humanizer(text,sents,words,morphs)
progress(0.65); qr=analyze_quality(text,sents,words,morphs)
progress(0.75); lr=llm_cross_check(text)
sc={"ํ†ต๊ณ„":s1["score"],"๋ฌธ์ฒด":s2["score"],"๋ฐ˜๋ณต์„ฑ":s3["score"],"๊ตฌ์กฐ":s4["score"],"์ง€๋ฌธ":s5["score"]}
sent_scores=[score_sentence(s)[0] for s in sents]
sent_avg=sum(sent_scores)/len(sent_scores) if sent_scores else -1
ms_raw=s5.get("model_scores",{})
model_est=estimate_model(text,sents,morphs,ms_raw)
fs,verdict,level=compute_verdict(sc,lr["score"],sent_avg=sent_avg,ppx_score=ppx["score"],hum_score=hum["score"])
progress(0.95)
cm={"ai_high":("#FF4444","#FFE0E0","๋†’์Œ"),"ai_medium":("#FF8800","#FFF0DD","์ค‘๊ฐ„~๋†’์Œ"),"ai_low":("#DDAA00","#FFFBE0","์ค‘๊ฐ„"),"uncertain":("#888","#F0F0F0","๋‚ฎ์Œ"),"human":("#22AA44","#E0FFE8","๋งค์šฐ ๋‚ฎ์Œ")}
fg,bg,conf=cm.get(level,("#888","#F0F0F0","?"))
est_model=model_est.get("model","ํŠน์ • ๋ถˆ๊ฐ€")
est_conf=model_est.get("confidence","๋‚ฎ์Œ")
est_reasons=model_est.get("reasons",[])
if est_model!="ํŠน์ • ๋ถˆ๊ฐ€" and est_conf!="๋‚ฎ์Œ":
mt=f"{est_model} (์‹ ๋ขฐ: {est_conf})"
elif est_model!="ํŠน์ • ๋ถˆ๊ฐ€":
mt=f"{est_model} (์ฐธ๊ณ )"
else:
mt="ํŠน์ • ๋ถˆ๊ฐ€"
ai_sents=sum(1 for s in sent_scores if s>=40)
human_sents=sum(1 for s in sent_scores if s<20)
def gb(l,s,w="",desc=""):
c="#FF4444" if s>=70 else "#FF8800" if s>=50 else "#DDAA00" if s>=35 else "#22AA44"
wt=f"<span style='color:#999;font-size:9px;'> ร—{w}</span>" if w else ""
dt=f"<div style='font-size:9px;color:#888;margin-top:1px;'>{desc}</div>" if desc else ""
return f"<div style='margin:4px 0;'><div style='display:flex;justify-content:space-between;'><span style='font-size:11px;font-weight:600;'>{l}{wt}</span><span style='font-size:11px;font-weight:700;color:{c};'>{s}</span></div><div style='background:#E8E8E8;border-radius:4px;height:7px;'><div style='background:{c};height:100%;width:{s}%;border-radius:4px;'></div></div>{dt}</div>"
mb=""
for mn in ["GPT","Claude","Gemini","Perplexity"]:
s=ms_raw.get(mn,0); mc="#FF4444" if s>=40 else "#FF8800" if s>=20 else "#CCC"
tag=""
if mn==est_model and est_conf!="๋‚ฎ์Œ":
tag=f" <span style='background:#FF4444;color:white;font-size:7px;padding:0 3px;border-radius:3px;'>์ถ”์ •</span>"
mb+=f"<div style='display:flex;align-items:center;gap:4px;margin:2px 0;'><span style='width:66px;font-size:10px;font-weight:600;'>{mn}{tag}</span><div style='flex:1;background:#E8E8E8;border-radius:3px;height:5px;'><div style='background:{mc};height:100%;width:{s}%;'></div></div><span style='font-size:9px;width:18px;text-align:right;color:{mc};'>{s}</span></div>"
ls=""
if lr["score"]>=0:
lsc=lr["score"]
lr_rows="".join(f"<div style='font-size:9px;color:#555;'>{mn}: {lr['detail'].get(mn,'โ€”')}</div>" for _,mn in LLM_JUDGES)
ls=f"<div style='margin-top:8px;padding:8px;background:#F8F8FF;border-radius:6px;border:1px solid #E0E0FF;'><div style='font-size:10px;font-weight:700;margin-bottom:3px;'>๐Ÿค– LLM ๊ต์ฐจ๊ฒ€์ฆ (ํ‰๊ท  {lsc}%)</div>{lr_rows}</div>"
else: ls="<div style='margin-top:6px;padding:4px 8px;background:#F5F5F5;border-radius:4px;color:#999;font-size:9px;'>๐Ÿค– GROQ_API_KEY ๋ฏธ์„ค์ •</div>"
qs=qr["sub_scores"]; gc={"S":"#FF6B6B","A":"#4ECDC4","B":"#45B7D1","C":"#DDAA00","D":"#FF8800","F":"#FF4444"}.get(qr["grade"],"#888")
def qgb(l,s):
c="#22AA44" if s>=70 else "#4ECDC4" if s>=55 else "#DDAA00" if s>=40 else "#FF8800"
return f"<div style='margin:2px 0;display:flex;align-items:center;gap:4px;'><span style='width:50px;font-size:10px;'>{l}</span><div style='flex:1;background:#E8E8E8;border-radius:3px;height:5px;'><div style='background:{c};height:100%;width:{s}%;'></div></div><span style='font-size:9px;color:{c};width:18px;text-align:right;'>{s}</span></div>"
reasons=[]
if sc["๋ฌธ์ฒด"]>=70: reasons.append("๊ฒฉ์‹์ฒด ์ข…๊ฒฐ์–ด๋ฏธ๊ฐ€ ๋Œ€๋ถ€๋ถ„, AIํ˜• ์ ‘์†์‚ฌยท์ƒํˆฌํ‘œํ˜„ ๋‹ค์ˆ˜ ๊ฐ์ง€")
elif sc["๋ฌธ์ฒด"]>=50: reasons.append("๊ฒฉ์‹์ฒด์™€ AIํ˜• ํ‘œํ˜„์ด ํ˜ผ์žฌ")
if ppx["score"]>=65: reasons.append(f"ํ…์ŠคํŠธ ์˜ˆ์ธก ๊ฐ€๋Šฅ๋„๊ฐ€ ๋งค์šฐ ๋†’์Œ (Perplexity {ppx['score']}์ )")
elif ppx["score"]>=50: reasons.append(f"ํ…์ŠคํŠธ ์˜ˆ์ธก ๊ฐ€๋Šฅ๋„๊ฐ€ ๋†’์Œ (Perplexity {ppx['score']}์ )")
if hum["score"]>=50:
hum_sigs=", ".join(s[0] for s in hum["signals"][:3])
reasons.append(f"โš ๏ธ Humanizer/ํŒจ๋Ÿฌํ”„๋ ˆ์ด์ฆˆ ํ”์  ๊ฐ์ง€ ({hum_sigs})")
if sc["ํ†ต๊ณ„"]>=60: reasons.append("๋ฌธ์žฅ ๊ธธ์ด๊ฐ€ ๋งค์šฐ ๊ท ์ผํ•˜์—ฌ ๊ธฐ๊ณ„์  ํŒจํ„ด")
if sc["๋ฐ˜๋ณต์„ฑ"]>=50: reasons.append("๋ฌธ๋‘ ์ ‘์†์‚ฌ ๋ฐ˜๋ณต, n-gram ํŒจํ„ด ๊ฐ์ง€")
if sc["๊ตฌ์กฐ"]>=50: reasons.append("์ถ”์ƒ์  ์ˆ˜์‹์–ด ๋‹ค์ˆ˜, ๊ตฌ์ฒด์  ์‚ฌ์‹ค ๋ถ€์กฑ")
if est_model!="ํŠน์ • ๋ถˆ๊ฐ€" and est_conf!="๋‚ฎ์Œ":
est_why=", ".join(est_reasons[:2]) if est_reasons else ""
reasons.append(f"๐Ÿ” ์ถ”์ • ๋ชจ๋ธ: <b>{est_model}</b> ({est_why})")
if not reasons: reasons.append("์ธ๊ฐ„์  ํ‘œํ˜„์ด ์šฐ์„ธํ•˜๋ฉฐ AI ํŒจํ„ด์ด ์•ฝํ•จ")
reason_html='<br>'.join(f"โ€ข {r}" for r in reasons)
ppx_c="#FF4444" if ppx["score"]>=65 else "#FF8800" if ppx["score"]>=50 else "#DDAA00" if ppx["score"]>=35 else "#22AA44"
ppx_html=f"""<div style='margin-top:8px;padding:8px;background:linear-gradient(135deg,#FFF8F0,#FFF0FF);border-radius:6px;border:1px solid #E8D0FF;'>
<div style='font-size:10px;font-weight:700;margin-bottom:4px;'>๐Ÿง  Perplexity ๋ถ„์„ <span style='color:{ppx_c};font-size:12px;font-weight:900;'>{ppx["score"]}์ </span></div>
<div style='display:grid;grid-template-columns:1fr 1fr;gap:2px;'>
<span style='font-size:9px;color:#777;'>์—”ํŠธ๋กœํ”ผ: {ppx.get("entropy",0)}</span>
<span style='font-size:9px;color:#777;'>๋ถ„์‚ฐ๊ท ์ผ: {ppx.get("variance",0)}</span>
<span style='font-size:9px;color:#777;'>์–ด์ˆœ์˜ˆ์ธก: {ppx.get("order",0)}</span>
<span style='font-size:9px;color:#777;'>Zipf์ ํ•ฉ: {ppx.get("zipf",0)}</span>
</div>
</div>"""
hum_html=""
if hum["score"]>=30:
hc="#FF4444" if hum["score"]>=65 else "#FF8800" if hum["score"]>=50 else "#DDAA00"
sig_rows="".join(f"<div style='font-size:9px;color:#555;'>๐Ÿ”ธ {s[0]}: {s[2]}</div>" for s in hum["signals"][:4])
hum_html=f"""<div style='margin-top:8px;padding:8px;background:linear-gradient(135deg,#FFF0F0,#FFF5F0);border-radius:6px;border:1px solid #FFD0D0;'>
<div style='font-size:10px;font-weight:700;margin-bottom:3px;'>๐Ÿ›ก๏ธ Humanizer ํƒ์ง€ <span style='color:{hc};font-size:12px;font-weight:900;'>{hum["score"]}์ </span></div>
{sig_rows}
</div>"""
est_html=""
if est_model!="ํŠน์ • ๋ถˆ๊ฐ€":
ec="#FF4444" if est_conf=="๋†’์Œ" else "#FF8800" if est_conf=="์ค‘๊ฐ„" else "#DDAA00"
ranked_html=""
for m, ev in model_est.get("ranked",[])[:4]:
ms_c="#FF4444" if ev["score"]>=30 else "#FF8800" if ev["score"]>=15 else "#CCC"
bar_w=min(100,int(ev["score"]*1.5))
ranked_html+=f"<div style='display:flex;align-items:center;gap:4px;margin:1px 0;'><span style='width:55px;font-size:9px;font-weight:600;'>{m}</span><div style='flex:1;background:#E8E8E8;border-radius:3px;height:4px;'><div style='background:{ms_c};height:100%;width:{bar_w}%;border-radius:3px;'></div></div><span style='font-size:8px;color:{ms_c};'>{ev['score']:.0f}</span></div>"
est_html=f"""<div style='margin-top:8px;padding:8px;background:linear-gradient(135deg,#F0F8FF,#F0FFF0);border-radius:6px;border:1px solid #D0E8FF;'>
<div style='font-size:10px;font-weight:700;margin-bottom:3px;'>๐ŸŽฏ AI ๋ชจ๋ธ ์ถ”์ •: <span style='color:{ec};font-size:12px;font-weight:900;'>{est_model}</span> <span style='font-size:9px;color:#888;'>(์‹ ๋ขฐ: {est_conf})</span></div>
{ranked_html}
<div style='font-size:8px;color:#999;margin-top:2px;'>๊ทผ๊ฑฐ: {", ".join(est_reasons[:3]) if est_reasons else "๋ณตํ•ฉ ์ง€ํ‘œ"}</div>
</div>"""
html=f"""<div style="font-family:'Pretendard','Noto Sans KR',sans-serif;max-width:720px;margin:0 auto;">
<div style="background:{bg};border:2px solid {fg};border-radius:14px;padding:20px;margin-bottom:12px;">
<div style="display:flex;align-items:center;gap:16px;">
<div style="text-align:center;min-width:100px;">
<div style="font-size:48px;font-weight:900;color:{fg};line-height:1;">{fs}</div>
<div style="font-size:11px;color:{fg};font-weight:600;">/ 100์ </div>
</div>
<div style="flex:1;">
<div style="font-size:18px;font-weight:800;color:{fg};margin-bottom:4px;">{"๐Ÿ”ด" if level=="ai_high" else "๐ŸŸ " if level=="ai_medium" else "๐ŸŸก" if level in ["ai_low","uncertain"] else "๐ŸŸข"} {verdict}</div>
<div style="font-size:11px;color:#666;line-height:1.6;">
AI ์ž‘์„ฑ ๊ฐ€๋Šฅ์„ฑ: <b style="color:{fg};">{conf}</b> |
์ถ”์ • ๋ชจ๋ธ: <b>{mt}</b><br>
{len(sents)}๋ฌธ์žฅ ์ค‘ AI์˜์‹ฌ <b>{ai_sents}๋ฌธ์žฅ</b>, ์ธ๊ฐ„์ถ”์ • <b>{human_sents}๋ฌธ์žฅ</b>
</div>
</div>
</div>
<div style="margin-top:12px;padding:10px;background:rgba(255,255,255,0.6);border-radius:8px;">
<div style="font-size:11px;font-weight:700;margin-bottom:4px;">๐Ÿ“‹ ํŒ์ • ๊ทผ๊ฑฐ</div>
<div style="font-size:11px;color:#555;line-height:1.7;">{reason_html}</div>
</div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;">
<div style="background:#FAFAFA;border-radius:8px;padding:10px;">
<div style="font-size:10px;font-weight:700;margin-bottom:4px;">๐Ÿ“Š AI ํƒ์ง€ 5์ถ• + Perplexity</div>
{gb('โ‘  ํ†ต๊ณ„',sc['ํ†ต๊ณ„'],'.06','Burstinessยท๋ณต์žก๋„ ๊ท ์ผ์„ฑ')}
{gb('โ‘ก ๋ฌธ์ฒด',sc['๋ฌธ์ฒด'],'.25','๊ฒฉ์‹ยท์ ‘์†์‚ฌยท์–‘๋ณด๊ตฌ๋ฌธ')}
{gb('โ‘ข ๋ฐ˜๋ณต',sc['๋ฐ˜๋ณต์„ฑ'],'.10','n-gramยท๋ฌธ๋‘ยท์ข…๊ฒฐ๋‹ค์–‘์„ฑ')}
{gb('โ‘ฃ ๊ตฌ์กฐ',sc['๊ตฌ์กฐ'],'.12','์ถ”์ƒ์„ฑ/๊ตฌ์ฒด์„ฑ')}
{gb('โ‘ค ์ง€๋ฌธ',sc['์ง€๋ฌธ'],'.30','GPT/Claude/Gemini/PPX')}
{gb('โ‘ฅ PPX',ppx['score'],'.17','์˜ˆ์ธก๊ฐ€๋Šฅ๋„ยท์—”ํŠธ๋กœํ”ผ')}
</div>
<div style="background:#FAFAFA;border-radius:8px;padding:10px;">
<div style="font-size:10px;font-weight:700;margin-bottom:4px;">๐Ÿ” ๋ชจ๋ธ ์ง€๋ฌธ</div>
{mb}
<div style="margin-top:8px;padding:6px;background:linear-gradient(135deg,#FAFAFA,#F0F4FF);border-radius:6px;">
<div style="display:flex;justify-content:space-between;align-items:center;">
<span style="font-size:10px;font-weight:700;">๐Ÿ“ ํ’ˆ์งˆ</span>
<span style="font-size:18px;font-weight:900;color:{gc};">{qr['grade']}<span style="font-size:10px;color:#666;"> {qr['score']}์ </span></span>
</div>
{qgb('๊ฐ€๋…์„ฑ',qs['๊ฐ€๋…์„ฑ'])}{qgb('์–ดํœ˜',qs['์–ดํœ˜ํ’๋ถ€๋„'])}{qgb('๋…ผ๋ฆฌ',qs['๋…ผ๋ฆฌ๊ตฌ์กฐ'])}{qgb('์ •ํ™•',qs['์ •ํ™•์„ฑ'])}
</div>
</div>
</div>
{ppx_html}{hum_html}{est_html}{ls}
</div>"""
log=f"AI:{fs}์  [{verdict}] ์‹ ๋ขฐ:{conf} | ๋ชจ๋ธ:{mt} | PPX:{ppx['score']} HUM:{hum['score']} | ํ’ˆ์งˆ:{qr['grade']}({qr['score']})\n์ถ•: ํ†ต๊ณ„{sc['ํ†ต๊ณ„']} ๋ฌธ์ฒด{sc['๋ฌธ์ฒด']} ๋ฐ˜๋ณต{sc['๋ฐ˜๋ณต์„ฑ']} ๊ตฌ์กฐ{sc['๊ตฌ์กฐ']} ์ง€๋ฌธ{sc['์ง€๋ฌธ']} PPX{ppx['score']} HUM{hum['score']}"
return html, log
def run_highlight(text):
if not text or len(text.strip())<30: return "<div style='color:#888;'>ํ…์ŠคํŠธ ํ•„์š”</div>"
sents=split_sentences(text)
hl=[]
for s in sents:
sc, reasons = score_sentence(s)
if sc >= 60: bg="rgba(220,38,38,0.35)"; level="AIํ™•์‹ "
elif sc >= 40: bg="rgba(249,115,22,0.30)"; level="AI์˜์‹ฌ"
elif sc >= 25: bg="rgba(234,179,8,0.25)"; level="์ฃผ์˜"
elif sc >= 10: bg="rgba(132,204,22,0.15)"; level="์ธ๊ฐ„์ถ”์ •"
else: bg="rgba(34,197,94,0.20)"; level="์ธ๊ฐ„"
detail_parts = []
for r in reasons:
if '๊ฒฉ์‹' in r or '๋น„๊ฒฉ์‹AI' in r: detail_parts.append(f"๐Ÿ”ค {r}")
elif '์ ‘์†์‚ฌ' in r: detail_parts.append(f"๐Ÿ”— {r}")
elif '์ƒํˆฌ' in r: detail_parts.append(f"๐Ÿ“‹ {r}")
elif '์ง€๋ฌธ' in r: detail_parts.append(f"๐Ÿ” {r}")
elif '์ธ๊ฐ„' in r or '๊ตฌ์–ด' in r or '๋งž์ถค๋ฒ•' in r: detail_parts.append(f"โœ… {r}")
else: detail_parts.append(r)
tt = ' | '.join(detail_parts) if detail_parts else 'ํŠน์ด ํŒจํ„ด ์—†์Œ'
hl.append(f'<span style="background:{bg};padding:2px 4px;border-radius:4px;display:inline;line-height:2.2;border-bottom:2px solid {"#DC2626" if sc>=60 else "#F97316" if sc>=40 else "#EAB308" if sc>=25 else "#84CC16" if sc>=10 else "#22C55E"};" title="[{level}] {tt} ({sc}์ )">{s}</span>')
total_scores = [score_sentence(s)[0] for s in sents]
avg_sc = sum(total_scores)/len(total_scores) if total_scores else 0
ai_high = sum(1 for s in total_scores if s >= 60)
ai_mid = sum(1 for s in total_scores if 40 <= s < 60)
human_cnt = sum(1 for s in total_scores if s < 25)
return f"""<div style='font-family:Pretendard,sans-serif;'>
<div style='margin-bottom:10px;padding:10px;background:#F8F8FF;border-radius:8px;'>
<div style='display:flex;gap:8px;align-items:center;font-size:11px;margin-bottom:6px;flex-wrap:wrap;'>
<span style='background:rgba(220,38,38,0.35);padding:2px 8px;border-radius:3px;'>๐Ÿ”ด AIํ™•์‹  {ai_high}</span>
<span style='background:rgba(249,115,22,0.30);padding:2px 8px;border-radius:3px;'>๐ŸŸ  AI์˜์‹ฌ {ai_mid}</span>
<span style='background:rgba(234,179,8,0.25);padding:2px 8px;border-radius:3px;'>๐ŸŸก ์ฃผ์˜</span>
<span style='background:rgba(132,204,22,0.15);padding:2px 8px;border-radius:3px;'>๐ŸŸข ์ธ๊ฐ„ {human_cnt}</span>
<span style='color:#888;'>ํ‰๊ท  {avg_sc:.0f}์  | ๋งˆ์šฐ์Šค ์˜ค๋ฒ„โ†’์ƒ์„ธ ๊ทผ๊ฑฐ</span>
</div>
<div style='font-size:10px;color:#888;'>๐Ÿ’ก ๊ฒฉ์‹์–ด๋ฏธ(22) + AI์ ‘์†์‚ฌ(18) + ์ƒํˆฌํ‘œํ˜„(10~25) + ์–‘๋ณดํŒจํ„ด(10) + ๋ชจ๋ธ์ง€๋ฌธ(8) + ๋ณตํ•ฉ๋ณด๋„ˆ์Šค(8) โˆ’ ์ธ๊ฐ„๋งˆ์ปค(~25)</div>
</div>
<div style='line-height:2.4;font-size:14px;'>{' '.join(hl)}</div>
</div>"""
SAMPLE_AI = """์ธ๊ณต์ง€๋Šฅ ๊ธฐ์ˆ ์€ ํ˜„๋Œ€ ์‚ฌํšŒ์—์„œ ๋งค์šฐ ์ค‘์š”ํ•œ ์—ญํ• ์„ ํ•˜๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค. ํŠนํžˆ ์ž์—ฐ์–ด ์ฒ˜๋ฆฌ ๋ถ„์•ผ์—์„œ์˜ ๋ฐœ์ „์€ ๋ˆˆ๋ถ€์‹  ์„ฑ๊ณผ๋ฅผ ๊ฑฐ๋‘๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค. ์ด๋Ÿฌํ•œ ๊ธฐ์ˆ ์˜ ๋ฐœ์ „์€ ๋‹ค์–‘ํ•œ ์‚ฐ์—… ๋ถ„์•ผ์— ๊ธ์ •์ ์ธ ์˜ํ–ฅ์„ ๋ฏธ์น˜๊ณ  ์žˆ์œผ๋ฉฐ, ํ–ฅํ›„ ๋”์šฑ ๋ฐœ์ „ํ•  ๊ฒƒ์œผ๋กœ ์˜ˆ์ƒ๋ฉ๋‹ˆ๋‹ค.
๋˜ํ•œ ์ƒ์„ฑํ˜• AI์˜ ๋“ฑ์žฅ์œผ๋กœ ์ฝ˜ํ…์ธ  ์ œ์ž‘ ๋ฐฉ์‹์ด ํฌ๊ฒŒ ๋ณ€ํ™”ํ•˜๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค. ์ด๋ฅผ ํ†ตํ•ด ๊ธฐ์—…๋“ค์€ ํšจ์œจ์ ์ธ ์ฝ˜ํ…์ธ  ์ƒ์‚ฐ์ด ๊ฐ€๋Šฅํ•ด์กŒ์œผ๋ฉฐ, ๊ฐœ์ธ ์‚ฌ์šฉ์ž๋“ค๋„ ๋‹ค์–‘ํ•œ ์ฐฝ์ž‘ ํ™œ๋™์— AI๋ฅผ ํ™œ์šฉํ•  ์ˆ˜ ์žˆ๊ฒŒ ๋˜์—ˆ์Šต๋‹ˆ๋‹ค. ๋”ฐ๋ผ์„œ AI ๋ฆฌํ„ฐ๋Ÿฌ์‹œ์˜ ์ค‘์š”์„ฑ์ด ๋”์šฑ ๋ถ€๊ฐ๋˜๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค.
๋‚˜์•„๊ฐ€ AI ์œค๋ฆฌ์™€ ๊ทœ์ œ์— ๋Œ€ํ•œ ๋…ผ์˜๋„ ํ™œ๋ฐœํžˆ ์ง„ํ–‰๋˜๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค. ํŠนํžˆ AI๊ฐ€ ์ƒ์„ฑํ•œ ์ฝ˜ํ…์ธ ์˜ ์ €์ž‘๊ถŒ ๋ฌธ์ œ๋Š” ์ค‘์š”ํ•œ ์˜๋ฏธ๋ฅผ ๊ฐ€์ง€๋ฉฐ, ์ด์— ๋Œ€ํ•œ ๋ฒ•์  ํ”„๋ ˆ์ž„์›Œํฌ ๊ตฌ์ถ•์ด ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค. ๊ฒฐ๊ณผ์ ์œผ๋กœ ๊ธฐ์ˆ  ๋ฐœ์ „๊ณผ ํ•จ๊ป˜ ์‚ฌํšŒ์  ํ•ฉ์˜๋ฅผ ์ด๋ฃจ๋Š” ๊ฒƒ์ด ์ค‘์š”ํ•ฉ๋‹ˆ๋‹ค."""
SAMPLE_HUMAN = """์•„ ์ง„์งœ ์š”์ฆ˜ AI ๋•Œ๋ฌธ์— ๋จธ๋ฆฌ ์•„ํ”„๋‹คใ…‹ใ…‹ใ…‹ ์–ด์ œ chatgptํ•œํ…Œ ๋ ˆํฌํŠธ ์จ๋‹ฌ๋ผ๊ณ  ํ–ˆ๋Š”๋ฐ ์™„์ „ ๊ต๊ณผ์„œ ๊ฐ™์€ ๊ธ€๋งŒ ์จ์ค˜์„œ ๊ทธ๋ƒฅ ๋‚ด๊ฐ€ ๋‹ค์‹œ ์ผ์Œ;;
๊ทผ๋ฐ ์ƒ๊ฐํ•ด๋ณด๋ฉด AI๊ฐ€ ์“ด ๊ธ€์ด๋ž‘ ์‚ฌ๋žŒ์ด ์“ด ๊ธ€์ด ํ™•์‹คํžˆ ๋‹ค๋ฅด๊ธด ํ•ด. ๋ญ”๊ฐ€... ๋„ˆ๋ฌด ๊น”๋”ํ•˜๋‹ฌ๊นŒ? ์‚ฌ๋žŒ์€ ์ด๋ ‡๊ฒŒ ํšก์„ค์ˆ˜์„ค๋„ ํ•˜๊ณ  ๋งž์ถค๋ฒ•๋„ ํ‹€๋ฆฌ๊ณ  ๊ทธ๋Ÿฌ์ž–์•„.
๊ต์ˆ˜๋‹˜์ด AI ํƒ์ง€๊ธฐ ๋Œ๋ฆฐ๋‹ค๊ณ  ํ•ด์„œ ์ข€ ๋ฌด์„œ์šด๋ฐ ใ… ใ…  ๋‚˜๋Š” ์ง„์งœ ์ง์ ‘ ์“ด ๊ฑด๋ฐ ํ˜น์‹œ ์˜คํƒ ๋‚˜๋ฉด ์–ด์ฉŒ์ง€... ๊ฑฑ์ •๋œ๋‹ค ์ง„์‹ฌ์œผ๋กœ."""
def run_document_analysis(file, progress=gr.Progress()):
"""๋ฌธ์„œ ํŒŒ์ผ ์—…๋กœ๋“œ โ†’ ์„น์…˜๋ณ„ AI ํƒ์ง€ ํžˆํŠธ๋งต + PDF ๋ณด๊ณ ์„œ ์ƒ์„ฑ"""
if file is None:
return "<div style='padding:20px;text-align:center;color:#888;'>๐Ÿ“„ ํŒŒ์ผ์„ ์—…๋กœ๋“œํ•˜์„ธ์š” (PDF, DOCX, HWP, HWPX, TXT)</div>", "", None
if hasattr(file, 'path'):
file_path = file.path
elif hasattr(file, 'name'):
file_path = file.name
else:
file_path = str(file)
fname = os.path.basename(file_path)
progress(0.05, f"๐Ÿ“„ {fname} ์ฝ๋Š” ์ค‘...")
sections, full_text, error = extract_text_from_file(file_path)
if error:
return f"<div style='padding:20px;text-align:center;color:#E44;'>โš ๏ธ {error}</div>", "", None
if not sections or not full_text or len(full_text.strip()) < 50:
return "<div style='padding:20px;text-align:center;color:#E44;'>โš ๏ธ ํ…์ŠคํŠธ๊ฐ€ ์ถฉ๋ถ„ํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค (50์ž ๋ฏธ๋งŒ)</div>", "", None
progress(0.15, "์ „์ฒด ํ…์ŠคํŠธ ๋ถ„์„...")
sents_all = split_sentences(full_text)
words_all = split_words(full_text)
morphs_all = get_morphemes(full_text)
total_score, total_verdict, total_level, total_axes, total_ppx, total_hum = quick_score(full_text)
quality = analyze_quality(full_text, sents_all, words_all, morphs_all)
progress(0.30, "LLM ๊ต์ฐจ๊ฒ€์ฆ...")
llm_result = llm_cross_check(full_text[:3000])
if llm_result["score"] >= 0:
_sent_scores = [score_sentence(s)[0] for s in sents_all]
_sent_avg = sum(_sent_scores)/len(_sent_scores) if _sent_scores else -1
total_score, total_verdict, total_level = compute_verdict(total_axes, llm_result["score"], sent_avg=_sent_avg, ppx_score=total_ppx["score"], hum_score=total_hum["score"])
progress(0.45, f"{len(sections)}๊ฐœ ์„น์…˜ ๋ถ„์„...")
section_results = []
for i, sec in enumerate(sections):
if len(sec.strip()) < 20:
section_results.append({"idx": i+1, "text": sec, "score": -1, "verdict": "๋„ˆ๋ฌด ์งง์Œ", "skipped": True})
continue
s_score, s_verdict, s_level, s_axes, _, _ = quick_score(sec)
sec_sents = split_sentences(sec)
sent_scores = []
for sent in sec_sents:
ss = score_sentence(sent)
sent_scores.append({"text": sent, "score": ss})
section_results.append({
"idx": i+1, "text": sec[:200], "score": s_score,
"verdict": s_verdict, "level": s_level, "axes": s_axes,
"sent_scores": sent_scores, "skipped": False
})
pct = 0.45 + (i / max(len(sections), 1)) * 0.30
progress(pct, f"์„น์…˜ {i+1}/{len(sections)}")
now = datetime.now().strftime("%Y-%m-%d %H:%M")
ext = Path(file_path).suffix.upper()
cm_map = {"ai_high": ("#FF4444", "#FFE0E0"), "ai_medium": ("#FF8800", "#FFF0DD"),
"ai_low": ("#DDAA00", "#FFFBE0"), "uncertain": ("#888", "#F5F5F5"), "human": ("#22AA44", "#E0FFE8")}
tc, tbg = cm_map.get(total_level, ("#888", "#F5F5F5"))
heatmap_cells = []
for sr in section_results:
sidx = sr["idx"]
if sr["skipped"]:
heatmap_cells.append(f"<div style='flex:1;min-width:18px;height:28px;background:#E0E0E0;border-radius:3px;' title='์„น์…˜{sidx}: ์งง์Œ'></div>")
else:
sc, sbg = cm_map.get(sr.get("level", "uncertain"), ("#888", "#F5F5F5"))
ssc = sr["score"]; svd = sr["verdict"]
heatmap_cells.append(f"<div style='flex:1;min-width:18px;height:28px;background:{sc};border-radius:3px;opacity:0.8;cursor:pointer;' title='์„น์…˜{sidx}: AI {ssc}์  ({svd})'></div>")
heatmap_bar = f"<div style='display:flex;gap:3px;margin:12px 0;'>" + ''.join(heatmap_cells) + "</div>"
section_cards = []
for sr in section_results:
if sr["skipped"]: continue
sc, sbg = cm_map.get(sr.get("level", "uncertain"), ("#888", "#F5F5F5"))
sent_html = ""
for ss in sr.get("sent_scores", []):
s = ss["score"]
if s >= 60: sclr = "background:rgba(255,68,68,0.15);border-bottom:2px solid #FF4444;"
elif s >= 40: sclr = "background:rgba(255,136,0,0.1);border-bottom:2px solid #FF8800;"
elif s >= 25: sclr = "background:rgba(221,170,0,0.08);border-bottom:1px solid #DDAA00;"
else: sclr = ""
sent_html += f"<span style='{sclr}padding:1px 2px;border-radius:2px;' title='AI {s}์ '>{ss['text']}</span> "
axes_html = ""
if "axes" in sr:
ax = sr["axes"]
for k, v in ax.items():
axc = "#FF4444" if v >= 50 else "#FF8800" if v >= 30 else "#22AA44"
axes_html += f"<span style='display:inline-block;margin:1px 3px;padding:2px 8px;background:{axc}22;border:1px solid {axc}44;border-radius:10px;font-size:9px;color:{axc};'>{k} {v}</span>"
section_cards.append(f"""
<div style="border:1px solid #E0E0E0;border-left:4px solid {sc};border-radius:8px;padding:14px;margin-bottom:10px;background:white;">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px;">
<span style="font-weight:700;font-size:13px;">๐Ÿ“‘ ์„น์…˜ {sr['idx']}</span>
<span style="background:{sbg};color:{sc};padding:4px 12px;border-radius:20px;font-weight:700;font-size:12px;">AI {sr['score']}์  ยท {sr['verdict']}</span>
</div>
<div style="margin-bottom:6px;">{axes_html}</div>
<div style="font-size:12px;line-height:1.8;color:#333;">{sent_html}</div>
</div>""")
ai_high = sum(1 for s in section_results if not s["skipped"] and s["score"] >= 60)
ai_med = sum(1 for s in section_results if not s["skipped"] and 35 <= s["score"] < 60)
ai_low = sum(1 for s in section_results if not s["skipped"] and s["score"] < 35)
valid_sections = [s for s in section_results if not s["skipped"]]
llm_info = ""
if llm_result["score"] >= 0:
llm_rows = ''.join(f"<span style='margin-right:8px;font-size:10px;color:#555;'>{mn}: {llm_result['detail'].get(mn,'โ€”')}</span>" for _, mn in LLM_JUDGES)
llm_info = f"<div style='margin-top:8px;padding:8px 12px;background:#F8F8FF;border-radius:6px;border:1px solid #E0E0FF;font-size:10px;'><b>๐Ÿค– LLM ๊ต์ฐจ๊ฒ€์ฆ:</b> ํ‰๊ท  {llm_result['score']}% | {llm_rows}</div>"
html = f"""<div style="font-family:'Pretendard','Noto Sans KR',sans-serif;max-width:800px;margin:0 auto;background:#FAFBFC;border-radius:12px;border:1px solid #E0E0E0;overflow:hidden;">
<!-- ํ—ค๋” -->
<div style="background:linear-gradient(135deg,#1A1F36,#2D3561);padding:20px 24px;color:white;">
<div style="display:flex;justify-content:space-between;align-items:center;">
<div>
<div style="font-size:18px;font-weight:800;">๐Ÿ“„ ๋ฌธ์„œ AI ๋ถ„์„ ๋ณด๊ณ ์„œ</div>
<div style="font-size:11px;opacity:0.7;margin-top:4px;">{fname} ยท {ext} ยท {len(sections)}๊ฐœ ์„น์…˜ ยท {len(full_text)}์ž</div>
</div>
<div style="text-align:right;">
<div style="font-size:36px;font-weight:900;color:{tc};">{total_score}</div>
<div style="font-size:11px;color:{tc};">{total_verdict}</div>
</div>
</div>
</div>
<!-- ์š”์•ฝ -->
<div style="padding:16px 24px;">
<div style="display:grid;grid-template-columns:repeat(4,1fr);gap:10px;margin-bottom:12px;">
<div style="background:white;border:1px solid #E0E0E0;border-radius:8px;padding:12px;text-align:center;">
<div style="font-size:22px;font-weight:800;color:#FF4444;">{ai_high}</div>
<div style="font-size:10px;color:#888;">AI ์˜์‹ฌ ๋†’์Œ</div>
</div>
<div style="background:white;border:1px solid #E0E0E0;border-radius:8px;padding:12px;text-align:center;">
<div style="font-size:22px;font-weight:800;color:#FF8800;">{ai_med}</div>
<div style="font-size:10px;color:#888;">AI ์˜์‹ฌ ์ค‘๊ฐ„</div>
</div>
<div style="background:white;border:1px solid #E0E0E0;border-radius:8px;padding:12px;text-align:center;">
<div style="font-size:22px;font-weight:800;color:#22AA44;">{ai_low}</div>
<div style="font-size:10px;color:#888;">์ธ๊ฐ„ ํŒ์ •</div>
</div>
<div style="background:white;border:1px solid #E0E0E0;border-radius:8px;padding:12px;text-align:center;">
<div style="font-size:22px;font-weight:800;color:#555;">{quality['grade']}</div>
<div style="font-size:10px;color:#888;">ํ’ˆ์งˆ ๋“ฑ๊ธ‰</div>
</div>
</div>
<!-- ํžˆํŠธ๋งต ๋ฐ” -->
<div style="background:white;border:1px solid #E0E0E0;border-radius:8px;padding:12px;">
<div style="font-size:11px;font-weight:700;margin-bottom:6px;">๐Ÿ—บ๏ธ ์„น์…˜๋ณ„ AI ํžˆํŠธ๋งต (๋นจ๊ฐ•=AI์˜์‹ฌ, ์ดˆ๋ก=์ธ๊ฐ„)</div>
{heatmap_bar}
<div style="display:flex;gap:12px;justify-content:center;margin-top:6px;">
<span style="font-size:9px;"><span style="display:inline-block;width:10px;height:10px;background:#FF4444;border-radius:2px;"></span> AI ๋†’์Œ</span>
<span style="font-size:9px;"><span style="display:inline-block;width:10px;height:10px;background:#FF8800;border-radius:2px;"></span> AI ์ค‘๊ฐ„</span>
<span style="font-size:9px;"><span style="display:inline-block;width:10px;height:10px;background:#DDAA00;border-radius:2px;"></span> ๋ถˆํ™•์‹ค</span>
<span style="font-size:9px;"><span style="display:inline-block;width:10px;height:10px;background:#22AA44;border-radius:2px;"></span> ์ธ๊ฐ„</span>
</div>
</div>
{llm_info}
</div>
<!-- ์„น์…˜ ์ƒ์„ธ -->
<div style="padding:0 24px 20px;">
<div style="font-size:13px;font-weight:700;margin-bottom:10px;">๐Ÿ“Š ์„น์…˜๋ณ„ ์ƒ์„ธ ๋ถ„์„ ({len(valid_sections)}๊ฐœ)</div>
{''.join(section_cards)}
</div>
<!-- ํ‘ธํ„ฐ -->
<div style="padding:10px 24px;background:#F0F3F8;border-top:1px solid #E0E0E0;display:flex;justify-content:space-between;">
<span style="font-size:11px;font-weight:800;color:#1A1F36;">AI Detector v4.0</span>
<span style="font-size:9px;color:#AAA;">{now} ยท 5์ถ• ์•™์ƒ๋ธ” + LLM ๊ต์ฐจ๊ฒ€์ฆ</span>
</div>
</div>"""
progress(0.90, "PDF ๋ณด๊ณ ์„œ ์ƒ์„ฑ...")
pdf_path = _generate_pdf_report(fname, total_score, total_verdict, total_level,
total_axes, quality, section_results, llm_result, now)
log = f"ํŒŒ์ผ: {fname} ({ext})\n"
log += f"์„น์…˜: {len(sections)}๊ฐœ | ์ „์ฒด: {len(full_text)}์ž\n"
log += f"์ด์ : {total_score} ({total_verdict})\n"
log += f"AI ์˜์‹ฌ ๋†’์Œ: {ai_high} | ์ค‘๊ฐ„: {ai_med} | ์ธ๊ฐ„: {ai_low}\n"
log += f"ํ’ˆ์งˆ: {quality['grade']} ({quality['score']}์ )\n"
if llm_result["score"] >= 0:
log += f"LLM ๊ต์ฐจ๊ฒ€์ฆ: {llm_result['score']}%\n"
return html, log, pdf_path
def _generate_pdf_report(fname, score, verdict, level, axes, quality, sections, llm_result, now):
"""HTML โ†’ PDF ๋ณ€ํ™˜์œผ๋กœ ๋ณด๊ณ ์„œ ์ƒ์„ฑ"""
try:
cm = {"ai_high":"#FF4444","ai_medium":"#FF8800","ai_low":"#DDAA00","uncertain":"#888","human":"#22AA44"}
tc = cm.get(level, "#888")
sec_rows = ""
for sr in sections:
if sr["skipped"]: continue
sc = cm.get(sr.get("level","uncertain"),"#888")
sec_rows += f"<tr><td style='padding:6px 10px;border:1px solid #DDD;text-align:center;'>{sr['idx']}</td><td style='padding:6px 10px;border:1px solid #DDD;'>{sr['text'][:80]}...</td><td style='padding:6px 10px;border:1px solid #DDD;text-align:center;color:{sc};font-weight:700;'>{sr['score']}</td><td style='padding:6px 10px;border:1px solid #DDD;text-align:center;'>{sr['verdict']}</td></tr>"
ax_rows = ''.join(f"<tr><td style='padding:4px 10px;border:1px solid #DDD;'>{k}</td><td style='padding:4px 10px;border:1px solid #DDD;text-align:center;font-weight:700;'>{v}/100</td></tr>" for k, v in axes.items())
html_content = f"""<!DOCTYPE html><html><head><meta charset="UTF-8">
<style>
body{{font-family:sans-serif;margin:30px;font-size:12px;color:#333;}}
h1{{color:#1A1F36;font-size:20px;border-bottom:3px solid #1A1F36;padding-bottom:8px;}}
h2{{color:#333;font-size:14px;margin-top:20px;border-bottom:1px solid #DDD;padding-bottom:4px;}}
.score-box{{display:inline-block;padding:12px 24px;background:{tc};color:white;border-radius:8px;font-size:28px;font-weight:900;margin:10px 0;}}
table{{border-collapse:collapse;width:100%;margin:8px 0;}}
th{{background:#F0F3F8;padding:6px 10px;border:1px solid #DDD;font-size:11px;text-align:left;}}
.footer{{margin-top:30px;padding-top:10px;border-top:2px solid #1A1F36;font-size:9px;color:#888;}}
</style></head><body>
<h1>๐Ÿ“„ AI ๊ธ€ ํŒ๋ณ„ ๋ณด๊ณ ์„œ</h1>
<p><b>ํŒŒ์ผ:</b> {fname} | <b>์ƒ์„ฑ:</b> {now} | <b>์—”์ง„:</b> AI Detector v4.0</p>
<h2>์ข…ํ•ฉ ๊ฒฐ๊ณผ</h2>
<div class="score-box">{score}์ </div>
<span style="margin-left:12px;font-size:16px;font-weight:700;">{verdict}</span>
<p><b>ํ’ˆ์งˆ:</b> {quality['grade']} ({quality['score']}์ )</p>
<h2>5์ถ• ๋ถ„์„</h2>
<table><tr><th>์ถ•</th><th>์ ์ˆ˜</th></tr>{ax_rows}</table>
<h2>์„น์…˜๋ณ„ ๋ถ„์„ ({len([s for s in sections if not s['skipped']])}๊ฐœ)</h2>
<table>
<tr><th>No</th><th>๋‚ด์šฉ (๋ฐœ์ทŒ)</th><th>AI ์ ์ˆ˜</th><th>ํŒ์ •</th></tr>
{sec_rows}
</table>
<div class="footer">
AI Detector v4.0 โ€” 5์ถ• ์•™์ƒ๋ธ” + LLM ๊ต์ฐจ๊ฒ€์ฆ | {now}<br>
๋ณธ ๋ณด๊ณ ์„œ๋Š” AI ํŒ๋ณ„ ์ฐธ๊ณ  ์ž๋ฃŒ์ด๋ฉฐ, ์ตœ์ข… ํŒ๋‹จ์€ ๊ฒ€ํ† ์ž์˜ ํ™•์ธ์ด ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค.
</div>
</body></html>"""
report_dir = tempfile.mkdtemp()
html_path = os.path.join(report_dir, f"AI_Report_{fname}.html")
with open(html_path, 'w', encoding='utf-8') as f:
f.write(html_content)
return html_path
except Exception as e:
print(f"PDF ๋ณด๊ณ ์„œ ์ƒ์„ฑ ์˜ค๋ฅ˜: {e}")
return None
def extract_file_text_api(file):
"""ํŒŒ์ผ ์—…๋กœ๋“œ โ†’ ํ…์ŠคํŠธ ์ถ”์ถœ API (ํƒญ1~4์šฉ)"""
if file is None:
return "โš ๏ธ ํŒŒ์ผ์ด ์—†์Šต๋‹ˆ๋‹ค."
if hasattr(file, 'path'):
file_path = file.path
elif hasattr(file, 'name'):
file_path = file.name
else:
file_path = str(file)
if not os.path.exists(file_path):
return f"โš ๏ธ ํŒŒ์ผ์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค: {os.path.basename(file_path)}"
fname = os.path.basename(file_path)
sections, full_text, error = extract_text_from_file(file_path)
if error:
return f"โš ๏ธ {fname}: {error}"
if not full_text or len(full_text.strip()) < 10:
return f"โš ๏ธ {fname}: ํ…์ŠคํŠธ ์ถ”์ถœ ๊ฒฐ๊ณผ ์—†์Œ"
return full_text.strip()
with gr.Blocks(title="AI ๊ธ€ ํŒ๋ณ„๊ธฐ v5.1") as demo:
gr.Markdown("# ๐Ÿ”Ž AI ๊ธ€ ํŒ๋ณ„๊ธฐ v5.1\n**5์ถ•+Perplexity+Humanizerํƒ์ง€+๋ชจ๋ธ์ถ”์ • ยท ํ’ˆ์งˆ ์ธก์ • ยท LLM ๊ต์ฐจ๊ฒ€์ฆ ยท ํ‘œ์ ˆ ๊ฒ€์‚ฌ**")
with gr.Tab("๐Ÿ† ๊ฒฝ์Ÿ๋ ฅ ๋น„๊ต"):
gr.HTML("""
<style>
@keyframes fadeInUp { from { opacity:0; transform:translateY(30px); } to { opacity:1; transform:translateY(0); } }
@keyframes slideInLeft { from { opacity:0; transform:translateX(-40px); } to { opacity:1; transform:translateX(0); } }
@keyframes slideInRight { from { opacity:0; transform:translateX(40px); } to { opacity:1; transform:translateX(0); } }
@keyframes pulse { 0%,100% { transform:scale(1); } 50% { transform:scale(1.05); } }
@keyframes shimmer { 0% { background-position:-200% center; } 100% { background-position:200% center; } }
@keyframes barGrow { from { width:0%; } }
@keyframes countUp { from { opacity:0; transform:scale(0.5); } to { opacity:1; transform:scale(1); } }
@keyframes glow { 0%,100% { box-shadow:0 0 8px rgba(99,102,241,0.3); } 50% { box-shadow:0 0 20px rgba(99,102,241,0.6); } }
@keyframes borderPulse { 0%,100% { border-color:rgba(99,102,241,0.3); } 50% { border-color:rgba(99,102,241,0.8); } }
@keyframes checkPop { from { transform:scale(0) rotate(-180deg); opacity:0; } to { transform:scale(1) rotate(0deg); opacity:1; } }
.comp-wrap { font-family:'Pretendard','Noto Sans KR',system-ui,sans-serif; max-width:860px; margin:0 auto; padding:8px; }
/* ํžˆ์–ด๋กœ ๋ฐฐ๋„ˆ */
.hero-banner {
background: linear-gradient(135deg, #1e1b4b 0%, #312e81 30%, #4338ca 60%, #6366f1 100%);
border-radius:20px; padding:32px 28px; margin-bottom:20px; position:relative; overflow:hidden;
animation: fadeInUp 0.8s ease-out;
}
.hero-banner::before {
content:''; position:absolute; top:-50%; left:-50%; width:200%; height:200%;
background:radial-gradient(circle, rgba(255,255,255,0.06) 0%, transparent 60%);
animation: shimmer 6s linear infinite;
}
.hero-title { font-size:28px; font-weight:900; color:white; margin-bottom:6px; position:relative; }
.hero-sub { font-size:13px; color:rgba(255,255,255,0.75); line-height:1.6; position:relative; }
.hero-badge {
display:inline-block; background:linear-gradient(135deg,#f59e0b,#ef4444); color:white;
font-size:11px; font-weight:800; padding:4px 12px; border-radius:20px; margin-top:10px;
animation: pulse 2s ease-in-out infinite; position:relative;
}
/* ํ•ต์‹ฌ ์ˆ˜์น˜ */
.stats-row { display:grid; grid-template-columns:repeat(4,1fr); gap:10px; margin-bottom:18px; animation:fadeInUp 1s ease-out 0.2s both; }
.stat-card {
background:white; border-radius:14px; padding:16px 10px; text-align:center;
border:2px solid #e0e7ff; transition:all 0.3s; cursor:default;
}
.stat-card:hover { transform:translateY(-4px); border-color:#6366f1; box-shadow:0 8px 25px rgba(99,102,241,0.15); }
.stat-num { font-size:32px; font-weight:900; background:linear-gradient(135deg,#6366f1,#8b5cf6); -webkit-background-clip:text; -webkit-text-fill-color:transparent; animation:countUp 0.6s ease-out; }
.stat-label { font-size:10px; color:#64748b; margin-top:2px; font-weight:600; }
/* ๋น„๊ต ํ…Œ์ด๋ธ” */
.comp-table-wrap { animation:fadeInUp 1.1s ease-out 0.4s both; margin-bottom:18px; }
.comp-table {
width:100%; border-collapse:separate; border-spacing:0;
border-radius:14px; overflow:hidden; box-shadow:0 4px 20px rgba(0,0,0,0.06);
}
.comp-table thead th {
background:linear-gradient(135deg,#1e1b4b,#3730a3); color:white;
padding:12px 8px; font-size:11px; font-weight:700; text-align:center; white-space:nowrap;
}
.comp-table thead th:first-child { text-align:left; padding-left:14px; min-width:130px; }
.comp-table thead th.ours { background:linear-gradient(135deg,#dc2626,#ef4444); font-size:12px; }
.comp-table tbody td {
padding:10px 8px; font-size:11px; text-align:center; border-bottom:1px solid #f1f5f9;
transition:background 0.2s;
}
.comp-table tbody tr:hover td { background:#f8fafc; }
.comp-table tbody td:first-child { text-align:left; padding-left:14px; font-weight:600; color:#1e293b; }
.comp-table tbody td:nth-child(2) { background:rgba(239,68,68,0.04); font-weight:700; }
.comp-table tbody tr:last-child td { border-bottom:none; }
/* ์ฒดํฌ/์—‘์Šค ์•„์ด์ฝ˜ */
.ck { color:#22c55e; font-weight:800; font-size:15px; animation:checkPop 0.4s ease-out; }
.cx { color:#ef4444; font-weight:700; font-size:13px; }
.cp { color:#f59e0b; font-weight:700; font-size:12px; }
.cstar { color:#6366f1; font-weight:900; font-size:14px; }
/* ์ฐจ๋ณ„ํ™” ์นด๋“œ */
.diff-grid { display:grid; grid-template-columns:1fr 1fr; gap:10px; margin-bottom:18px; }
.diff-card {
background:white; border-radius:14px; padding:16px; border:1px solid #e2e8f0;
transition:all 0.3s; position:relative; overflow:hidden;
}
.diff-card:hover { transform:translateY(-3px); box-shadow:0 8px 25px rgba(0,0,0,0.08); }
.diff-card::after {
content:''; position:absolute; top:0; left:0; right:0; height:3px;
background:linear-gradient(90deg,#6366f1,#8b5cf6,#a78bfa);
}
.diff-card:nth-child(2)::after { background:linear-gradient(90deg,#ef4444,#f97316); }
.diff-card:nth-child(3)::after { background:linear-gradient(90deg,#22c55e,#10b981); }
.diff-card:nth-child(4)::after { background:linear-gradient(90deg,#3b82f6,#06b6d4); }
.diff-icon { font-size:24px; margin-bottom:6px; }
.diff-title { font-size:13px; font-weight:800; color:#1e293b; margin-bottom:4px; }
.diff-desc { font-size:10px; color:#64748b; line-height:1.5; }
.diff-vs { display:inline-block; background:#fef2f2; color:#dc2626; font-size:9px; font-weight:700; padding:2px 6px; border-radius:4px; margin-top:4px; }
/* ๊ฒฝ์Ÿ์‚ฌ ๊ณต๋žต ๋ฐ” */
.attack-section { animation:fadeInUp 1.3s ease-out 0.8s both; margin-bottom:14px; }
.attack-title { font-size:13px; font-weight:800; color:#1e293b; margin-bottom:10px; }
.attack-item { margin-bottom:8px; }
.attack-label { display:flex; justify-content:space-between; align-items:center; margin-bottom:3px; }
.attack-name { font-size:11px; font-weight:700; color:#334155; }
.attack-weak { font-size:9px; color:#ef4444; font-weight:600; }
.attack-bar { height:8px; background:#f1f5f9; border-radius:4px; overflow:hidden; }
.attack-fill { height:100%; border-radius:4px; animation:barGrow 1.5s ease-out; }
/* ์Šฌ๋กœ๊ฑด */
.slogan-box {
background:linear-gradient(135deg,#faf5ff,#ede9fe,#e0e7ff); border-radius:16px;
padding:20px; text-align:center; border:2px solid #c7d2fe;
animation:glow 3s ease-in-out infinite;
}
.slogan-main { font-size:16px; font-weight:900; color:#3730a3; margin-bottom:6px; }
.slogan-sub { font-size:11px; color:#6366f1; line-height:1.7; }
</style>
<div class="comp-wrap">
<!-- ํžˆ์–ด๋กœ ๋ฐฐ๋„ˆ -->
<div class="hero-banner">
<div class="hero-title">๐Ÿ‡ฐ๐Ÿ‡ท ํ•œ๊ตญ์–ด AI ํŒ๋ณ„ No.1</div>
<div class="hero-sub">
์นดํ”ผํ‚ฌ๋Ÿฌ ยท GPTZero ยท Turnitin ยท JustDone ยท Originality.ai ๋“ฑ<br>
๊ธ€๋กœ๋ฒŒ 8๊ฐœ ๊ฒฝ์Ÿ ์„œ๋น„์Šค ์ •๋ฐ€ ๋ถ„์„ ํ›„ ์„ค๊ณ„๋œ ์ฐจ์„ธ๋Œ€ ํŒ๋ณ„๊ธฐ
</div>
<div class="hero-badge">๐Ÿ”ฌ v5.1 โ€” 3๋Œ€ ํ‚ฌ๋Ÿฌ ๊ธฐ๋Šฅ ํƒ‘์žฌ</div>
</div>
<!-- ํ•ต์‹ฌ ์ˆ˜์น˜ -->
<div class="stats-row">
<div class="stat-card"><div class="stat-num">8์ถ•</div><div class="stat-label">ํƒ์ง€ ์ฐจ์›<br>(์—…๊ณ„ ์ตœ๋‹ค)</div></div>
<div class="stat-card"><div class="stat-num">100%</div><div class="stat-label">ํ…Œ์ŠคํŠธ ์ •ํ™•๋„<br>(11/11 ์ƒ˜ํ”Œ)</div></div>
<div class="stat-card"><div class="stat-num">๋ฌด๋ฃŒ</div><div class="stat-label">์™„์ „ ๋ฌด๋ฃŒ<br>(ํšŸ์ˆ˜ ๋ฌด์ œํ•œ)</div></div>
<div class="stat-card"><div class="stat-num">4์ข…</div><div class="stat-label">๋ชจ๋ธ ์ถ”์ •<br>(GPT/Claude/Gemini/PPX)</div></div>
</div>
<!-- ๋น„๊ต ํ…Œ์ด๋ธ” -->
<div class="comp-table-wrap">
<table class="comp-table">
<thead>
<tr>
<th>๊ธฐ๋Šฅ</th>
<th class="ours">๐Ÿ”Ž AI ํŒ๋ณ„๊ธฐ<br>v5.1</th>
<th>๐Ÿ“‹ ์นดํ”ผํ‚ฌ๋Ÿฌ<br>GPTํ‚ฌ๋Ÿฌ</th>
<th>๐Ÿ…น JustDone<br>.com</th>
<th>๐Ÿ” GPTZero</th>
<th>๐Ÿ…พ Originality<br>.ai</th>
<th>๐Ÿ…ƒ Turnitin</th>
</tr>
</thead>
<tbody>
<tr>
<td>๐ŸŽฏ ํ•œ๊ตญ์–ด ํ˜•ํƒœ์†Œ ๋ถ„์„</td>
<td><span class="cstar">โ˜… ์ „์šฉ</span></td>
<td><span class="ck">โœ“</span></td>
<td><span class="cx">โœ—</span></td>
<td><span class="cx">โœ—</span></td>
<td><span class="cx">โœ—</span></td>
<td><span class="cp">โ–ณ</span></td>
</tr>
<tr>
<td>๐Ÿ“Š ํƒ์ง€ ์ถ• ์ˆ˜</td>
<td><span class="cstar">8์ถ•</span></td>
<td>1~2์ถ•</td>
<td>1์ถ•</td>
<td>2์ถ•</td>
<td>1์ถ•</td>
<td>1์ถ•</td>
</tr>
<tr>
<td>๐Ÿง  Perplexity ํ™•๋ฅ ๋ถ„์„</td>
<td><span class="ck">โœ“</span> 4์ค‘</td>
<td><span class="cx">โœ—</span></td>
<td><span class="cx">โœ—</span></td>
<td><span class="ck">โœ“</span></td>
<td><span class="cx">โœ—</span></td>
<td><span class="cx">โœ—</span></td>
</tr>
<tr>
<td>๐Ÿ›ก๏ธ Humanizer/Bypasser ํƒ์ง€</td>
<td><span class="ck">โœ“</span> 6์‹œ๊ทธ๋„</td>
<td><span class="cx">โœ—</span></td>
<td><span class="cx">โœ—</span></td>
<td><span class="cx">โœ—</span></td>
<td><span class="ck">โœ“</span></td>
<td><span class="ck">โœ“</span></td>
</tr>
<tr>
<td>๐ŸŽฏ AI ๋ชจ๋ธ ์ถ”์ •</td>
<td><span class="cstar">โ˜… 4๋ชจ๋ธ</span></td>
<td><span class="cx">โœ—</span></td>
<td><span class="cx">โœ—</span></td>
<td><span class="cp">โ–ณ</span></td>
<td><span class="cx">โœ—</span></td>
<td><span class="cx">โœ—</span></td>
</tr>
<tr>
<td>๐Ÿค– LLM ๊ต์ฐจ๊ฒ€์ฆ</td>
<td><span class="cstar">โ˜… 3๋ชจ๋ธ</span></td>
<td><span class="cx">โœ—</span></td>
<td><span class="cx">โœ—</span></td>
<td><span class="cx">โœ—</span></td>
<td><span class="cx">โœ—</span></td>
<td><span class="cx">โœ—</span></td>
</tr>
<tr>
<td>๐Ÿ“ ํŒ์ • ๊ทผ๊ฑฐ ํˆฌ๋ช… ๊ณต๊ฐœ</td>
<td><span class="cstar">โ˜… 8์ถ•๋ณ„</span></td>
<td><span class="cx">โœ—</span></td>
<td><span class="cx">โœ—</span></td>
<td><span class="cp">โ–ณ</span></td>
<td><span class="cp">โ–ณ</span></td>
<td><span class="cx">โœ—</span></td>
</tr>
<tr>
<td>๐ŸŽจ ๋ฌธ์žฅ๋ณ„ ํ•˜์ด๋ผ์ดํŠธ</td>
<td><span class="ck">โœ“</span> 5๋‹จ๊ณ„</td>
<td><span class="cx">โœ—</span></td>
<td><span class="cx">โœ—</span></td>
<td><span class="ck">โœ“</span></td>
<td><span class="ck">โœ“</span></td>
<td><span class="ck">โœ“</span></td>
</tr>
<tr>
<td>๐Ÿ“„ HWP/HWPX ๋ฌธ์„œ</td>
<td><span class="ck">โœ“</span></td>
<td><span class="ck">โœ“</span></td>
<td><span class="cx">โœ—</span></td>
<td><span class="cx">โœ—</span></td>
<td><span class="cx">โœ—</span></td>
<td><span class="cx">โœ—</span></td>
</tr>
<tr>
<td>๐Ÿ“ ๊ธ€ ํ’ˆ์งˆ ์ธก์ •</td>
<td><span class="ck">โœ“</span> 6ํ•ญ๋ชฉ</td>
<td><span class="cx">โœ—</span></td>
<td><span class="cp">โ–ณ</span></td>
<td><span class="cx">โœ—</span></td>
<td><span class="ck">โœ“</span></td>
<td><span class="cx">โœ—</span></td>
</tr>
<tr>
<td>๐Ÿ” ํ‘œ์ ˆ ๊ฒ€์‚ฌ</td>
<td><span class="ck">โœ“</span> 5์†Œ์Šค</td>
<td><span class="ck">โœ“</span> DB</td>
<td><span class="ck">โœ“</span></td>
<td><span class="cx">โœ—</span></td>
<td><span class="cx">โœ—</span></td>
<td><span class="ck">โœ“</span> DB</td>
</tr>
<tr>
<td>๐Ÿ’ฐ ๊ฐ€๊ฒฉ</td>
<td><b style="color:#22c55e;">์™„์ „ ๋ฌด๋ฃŒ</b></td>
<td>9,900์›/๊ฑด</td>
<td>$7.99~/์›”</td>
<td>$8.33~/์›”</td>
<td>$14.95/์›”</td>
<td>๊ธฐ๊ด€ ์ „์šฉ</td>
</tr>
<tr>
<td>๐ŸŽฏ ๋Œ€์ƒ ๋ชจ๋ธ</td>
<td><b style="color:#6366f1;">์ „ AI ๋ชจ๋ธ</b></td>
<td>GPT๋งŒ</td>
<td>๋ฒ”์šฉ</td>
<td>๋ฒ”์šฉ</td>
<td>๋ฒ”์šฉ</td>
<td>๋ฒ”์šฉ</td>
</tr>
<tr>
<td>โš ๏ธ ํ•ต์‹ฌ ์•ฝ์ </td>
<td style="font-size:9px;color:#6366f1;">๊ทœ์น™ ๊ธฐ๋ฐ˜<br>(ML ๋ฏธ์ ์šฉ)</td>
<td style="font-size:9px;color:#dc2626;">GPT๋งŒยท์˜คํƒ๋ฅ <br>89% ๋ถˆ๋งŒ</td>
<td style="font-size:9px;color:#dc2626;">์ ์ˆ˜ 30%+ ํŽธ์ฐจ<br>"Hello"โ†’92%AI</td>
<td style="font-size:9px;color:#dc2626;">ํ˜ผํ•ฉํ…์ŠคํŠธ 41%<br>ํŽธ์ง‘AI 35% ์˜ค๋ฅ˜</td>
<td style="font-size:9px;color:#dc2626;">$14.95/์›”<br>๋น„์˜์–ด ์•ฝํ•จ</td>
<td style="font-size:9px;color:#dc2626;">๊ธฐ๊ด€๋งŒ ์‚ฌ์šฉ<br>ํ•™์ƒ ์ ‘๊ทผ ๋ถˆ๊ฐ€</td>
</tr>
</tbody>
</table>
</div>
<!-- ์ฐจ๋ณ„ํ™” 4๋Œ€ ์นด๋“œ -->
<div class="diff-grid" style="animation:fadeInUp 1.2s ease-out 0.6s both;">
<div class="diff-card">
<div class="diff-icon">๐Ÿง </div>
<div class="diff-title">Perplexity ํ™•๋ฅ  ๋ถ„์„</div>
<div class="diff-desc">GPTZero์˜ ํ•ต์‹ฌ ๊ธฐ์ˆ ์„ ํ•œ๊ตญ์–ด์— ํŠนํ™”.<br>๋ฌธ์ž ์—”ํŠธ๋กœํ”ผ + Burstiness + TTRํŽธ์ฐจ + ์ข…๊ฒฐ์—”ํŠธ๋กœํ”ผ 4์ค‘ ๋ถ„์„์œผ๋กœ AI ํ…์ŠคํŠธ์˜ ์˜ˆ์ธก ๊ฐ€๋Šฅ๋„๋ฅผ ์ •๋ฐ€ ์ธก์ •</div>
<div class="diff-vs">vs GPTZero: ํ•œ๊ตญ์–ด ํ˜•ํƒœ์†Œ ๊ธฐ๋ฐ˜ โ†’ ๋” ์ •ํ™•</div>
</div>
<div class="diff-card">
<div class="diff-icon">๐Ÿ›ก๏ธ</div>
<div class="diff-title">Humanizer/Bypasser ํƒ์ง€</div>
<div class="diff-desc">QuillBot ๋“ฑ ํŒจ๋Ÿฌํ”„๋ ˆ์ด์ฆˆ ๋„๊ตฌ๋กœ ์ˆ˜์ •๋œ AI ๊ธ€์˜ ์ž”์กด ํ”์  6๊ฐ€์ง€ ์‹œ๊ทธ๋„๋กœ ํƒ์ง€.<br>๋™์˜์–ด ๊ณผ๋‹ค์น˜ํ™˜, ๊ตฌ์กฐ๋ณด์กด+์–ดํœ˜๋ณ€๊ฒฝ, ์ ‘์†์‚ฌ ๋“ฑ๊ฐ„๊ฒฉ ์ž”์กด ํŒจํ„ด</div>
<div class="diff-vs">vs Turnitin ์ „์šฉ ๋ชจ๋“ˆ โ†’ ํ•œ๊ตญ์–ด ์ตœ์ดˆ ๊ตฌํ˜„</div>
</div>
<div class="diff-card">
<div class="diff-icon">๐ŸŽฏ</div>
<div class="diff-title">AI ๋ชจ๋ธ ์ถ”์ • (์—…๊ณ„ ์œ ์ผ)</div>
<div class="diff-desc">GPT ยท Claude ยท Gemini ยท Perplexity 4๊ฐœ ๋ชจ๋ธ์˜ ๊ณ ์œ  ์ง€๋ฌธ์„ ๋ณตํ•ฉ ์ฆ๊ฑฐ๋กœ ๋ถ„์„.<br>"์–ด๋–ค AI๊ฐ€ ์ผ๋Š”์ง€"๊นŒ์ง€ ์ถ”์ •ํ•˜๋Š” ์œ ์ผํ•œ ํ•œ๊ตญ์–ด ์„œ๋น„์Šค</div>
<div class="diff-vs">vs ์ „์ฒด ๊ฒฝ์Ÿ์‚ฌ: ๋ชจ๋ธ ์ถ”์ • ๊ธฐ๋Šฅ ์—†์Œ</div>
</div>
<div class="diff-card">
<div class="diff-icon">๐Ÿค–</div>
<div class="diff-title">LLM 3์ค‘ ๊ต์ฐจ๊ฒ€์ฆ (์—…๊ณ„ ์œ ์ผ)</div>
<div class="diff-desc">GPT-OSS-120B ยท Qwen3-32B ยท Kimi-K2<br>3๊ฐœ ๋…๋ฆฝ AI๊ฐ€ ์ง์ ‘ ํŒ๋… โ†’ ๋‹ค์ˆ˜๊ฒฐ ํˆฌํ‘œ๋กœ ๊ทœ์น™ ๊ธฐ๋ฐ˜์˜ ํ•œ๊ณ„ ๋ณด์™„</div>
<div class="diff-vs">vs ์ „์ฒด ๊ฒฝ์Ÿ์‚ฌ: LLM ๊ฒ€์ฆ ๋ฏธ๋ณด์œ </div>
</div>
</div>
<!-- ๊ฒฝ์Ÿ์‚ฌ ์ทจ์•ฝ์  ๊ณต๋žต -->
<div class="attack-section">
<div class="attack-title">โš”๏ธ ๊ฒฝ์Ÿ์‚ฌ ์ทจ์•ฝ์  vs ์šฐ๋ฆฌ์˜ ๊ฐ•์ </div>
<div class="attack-item">
<div class="attack-label"><span class="attack-name">๐Ÿ“‹ ์นดํ”ผํ‚ฌ๋Ÿฌ โ€” GPT๋งŒ ํƒ์ง€, 89% ๋ถˆ๋งŒ์œจ</span><span class="attack-weak">โ†’ ์ „ AI ๋ชจ๋ธ + 8์ถ• ํˆฌ๋ช… ๊ทผ๊ฑฐ</span></div>
<div class="attack-bar"><div class="attack-fill" style="width:92%;background:linear-gradient(90deg,#6366f1,#8b5cf6);"></div></div>
</div>
<div class="attack-item">
<div class="attack-label"><span class="attack-name">๐Ÿ…น JustDone โ€” ๋™์ผ ํ…์ŠคํŠธ 30%+ ์ ์ˆ˜ ํŽธ์ฐจ</span><span class="attack-weak">โ†’ ๊ฒฐ์ •๋ก ์  ์ ์ˆ˜ ๋ณด์žฅ</span></div>
<div class="attack-bar"><div class="attack-fill" style="width:95%;background:linear-gradient(90deg,#ef4444,#f97316);"></div></div>
</div>
<div class="attack-item">
<div class="attack-label"><span class="attack-name">๐Ÿ” GPTZero โ€” ํ˜ผํ•ฉํ…์ŠคํŠธ 41%, ํŽธ์ง‘AI 35% ์˜ค๋ฅ˜</span><span class="attack-weak">โ†’ Humanizer ํƒ์ง€ + ์„น์…˜ ๋ถ„๋ฆฌ</span></div>
<div class="attack-bar"><div class="attack-fill" style="width:88%;background:linear-gradient(90deg,#22c55e,#10b981);"></div></div>
</div>
<div class="attack-item">
<div class="attack-label"><span class="attack-name">๐Ÿ…ƒ Turnitin โ€” ๊ธฐ๊ด€ ์ „์šฉ, ํ•™์ƒ ์‚ฌ์ „๊ฒ€์‚ฌ ๋ถˆ๊ฐ€</span><span class="attack-weak">โ†’ ๋ˆ„๊ตฌ๋‚˜ ๋ฌด๋ฃŒ ์ฆ‰์‹œ ์‚ฌ์šฉ</span></div>
<div class="attack-bar"><div class="attack-fill" style="width:90%;background:linear-gradient(90deg,#3b82f6,#06b6d4);"></div></div>
</div>
<div class="attack-item">
<div class="attack-label"><span class="attack-name">๐Ÿ…พ Originality โ€” $14.95/์›”, ๋น„์˜์–ด ์ทจ์•ฝ</span><span class="attack-weak">โ†’ ์™„์ „ ๋ฌด๋ฃŒ + ํ•œ๊ตญ์–ด ์ „๋ฌธ</span></div>
<div class="attack-bar"><div class="attack-fill" style="width:94%;background:linear-gradient(90deg,#a855f7,#ec4899);"></div></div>
</div>
</div>
<!-- ์Šฌ๋กœ๊ฑด -->
<div class="slogan-box">
<div class="slogan-main">๐Ÿ‡ฐ๐Ÿ‡ท ํ•œ๊ตญ์–ด AI ๊ธ€์„ ๊ฐ€์žฅ ์ •ํ™•ํ•˜๊ฒŒ, ๊ฐ€์žฅ ํˆฌ๋ช…ํ•˜๊ฒŒ, ์™„์ „ ๋ฌด๋ฃŒ๋กœ ํŒ๋ณ„ํ•ฉ๋‹ˆ๋‹ค</div>
<div class="slogan-sub">
vs ์นดํ”ผํ‚ฌ๋Ÿฌ: "GPT๋งŒ์ด ์•„๋‹Œ, <b>๋ชจ๋“  AI</b>๋ฅผ ํƒ์ง€ํ•ฉ๋‹ˆ๋‹ค"<br>
vs GPTZero: "ํ•œ๊ตญ์–ด์— ํŠนํ™”๋œ <b>ํ˜•ํƒœ์†Œ ๋ถ„์„</b>์œผ๋กœ ๋” ์ •ํ™•ํ•ฉ๋‹ˆ๋‹ค"<br>
vs Turnitin: "๋ˆ„๊ตฌ๋‚˜, <b>๋ฌด๋ฃŒ</b>๋กœ, ์ง€๊ธˆ ๋ฐ”๋กœ ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค"<br>
vs JustDone: "๋™์ผํ•œ ํ…์ŠคํŠธ์— <b>ํ•ญ์ƒ ๋™์ผํ•œ ๊ฒฐ๊ณผ</b>๋ฅผ ๋ณด์žฅํ•ฉ๋‹ˆ๋‹ค"
</div>
</div>
</div>
""")
with gr.Tab("๐Ÿ” ๋ถ„์„"):
gr.Markdown("ํ…์ŠคํŠธ๊ฐ€ AI์— ์˜ํ•ด ์ž‘์„ฑ๋˜์—ˆ๋Š”์ง€ 5๊ฐœ ์ถ•์œผ๋กœ ๋ถ„์„ํ•ฉ๋‹ˆ๋‹ค. 0~100์  (๋†’์„์ˆ˜๋ก AI ๊ฐ€๋Šฅ์„ฑ ๋†’์Œ)")
inp=gr.Textbox(label="๋ถ„์„ํ•  ํ…์ŠคํŠธ",placeholder="์ตœ์†Œ 50์ž ์ด์ƒ...",lines=10)
with gr.Row():
btn_a=gr.Button("๐Ÿš€ AI ํŒ๋ณ„ + ํ’ˆ์งˆ ๋ถ„์„",variant="primary",size="lg")
btn_sa=gr.Button("๐Ÿ“ AI ์˜ˆ์‹œ",size="sm"); btn_sh=gr.Button("โœ๏ธ ์ธ๊ฐ„ ์˜ˆ์‹œ",size="sm")
rh=gr.HTML(); rl=gr.Textbox(label="์ƒ์„ธ ๋กœ๊ทธ",lines=3,elem_classes=["mono"])
btn_a.click(run_detection,[inp],[rh,rl],api_name="run_detection")
btn_sa.click(lambda:SAMPLE_AI,outputs=[inp]); btn_sh.click(lambda:SAMPLE_HUMAN,outputs=[inp])
with gr.Tab("๐ŸŽจ ํ•˜์ด๋ผ์ดํŠธ"):
gr.Markdown("๋ฌธ์žฅ๋ณ„๋กœ AI ํ™•๋ฅ ์„ ์ƒ‰์ƒ ํ‘œ์‹œํ•ฉ๋‹ˆ๋‹ค. **ํƒญ1๊ณผ ๋™์ผํ•œ ๊ธฐ์ค€**์œผ๋กœ ํŒ์ •ํ•ฉ๋‹ˆ๋‹ค. ๋งˆ์šฐ์Šค ์˜ค๋ฒ„ ์‹œ ๊ทผ๊ฑฐ ํ™•์ธ.")
ih=gr.Textbox(label="ํ…์ŠคํŠธ",lines=8); bh=gr.Button("๐ŸŽจ ํ•˜์ด๋ผ์ดํŠธ ๋ถ„์„",variant="primary"); hr=gr.HTML()
bh.click(run_highlight,[ih],[hr],api_name="run_highlight")
with gr.Tab("๐Ÿ” ํ‘œ์ ˆ ๊ฒ€์‚ฌ"):
gr.Markdown("**Brave Search ๋ณ‘๋ ฌ(์ตœ๋Œ€20) + KCI ยท RISS ยท arXiv + Gemini Google Search** ๊ธฐ๋ฐ˜ ํ‘œ์ ˆ ๊ฒ€์‚ฌ. CopyKiller ์Šคํƒ€์ผ ๋ณด๊ณ ์„œ.")
inp_plag=gr.Textbox(label="๊ฒ€์‚ฌํ•  ํ…์ŠคํŠธ",placeholder="ํ‘œ์ ˆ ๊ฒ€์‚ฌํ•  ํ…์ŠคํŠธ (์ตœ์†Œ 50์ž)...",lines=10)
with gr.Row():
btn_plag=gr.Button("๐Ÿ” ํ‘œ์ ˆ ๊ฒ€์‚ฌ ์‹œ์ž‘",variant="primary",size="lg")
btn_ps=gr.Button("๐Ÿ“ AI ์˜ˆ์‹œ",size="sm")
plag_html=gr.HTML(); plag_log=gr.Textbox(label="๊ฒ€์‚ฌ ๋กœ๊ทธ",lines=4,elem_classes=["mono"])
btn_plag.click(run_plagiarism,[inp_plag],[plag_html,plag_log],api_name="run_plagiarism")
btn_ps.click(lambda:SAMPLE_AI,outputs=[inp_plag])
with gr.Tab("๐Ÿ“– ์„ค๋ช…"):
gr.Markdown("""
- **ํƒ์ง€ 5์ถ•:** ํ†ต๊ณ„(6%)ยท๋ฌธ์ฒด(25%)ยท๋ฐ˜๋ณต(10%)ยท๊ตฌ์กฐ(12%)ยท์ง€๋ฌธ(30%)
- **โ˜… Perplexity ํ™•๋ฅ ๋ถ„์„(17%):** ๋ฌธ์ž ์—”ํŠธ๋กœํ”ผยทBurstinessยทTTRํŽธ์ฐจยท์ข…๊ฒฐ์—”ํŠธ๋กœํ”ผ
- **โ˜… Humanizer ํƒ์ง€:** ๋™์˜์–ด๊ณผ๋‹ค์น˜ํ™˜ยท๊ตฌ์กฐ๋ณด์กดยท์ ‘์†์‚ฌ์ž”์กดยท์ด์ค‘ํ”ผ๋™ ๋“ฑ 6์‹œ๊ทธ๋„
- **โ˜… ๋ชจ๋ธ ์ถ”์ •:** GPTยทClaudeยทGeminiยทPerplexity 4๋ชจ๋ธ ์ฆ๊ฑฐ๊ธฐ๋ฐ˜ ์ถ”์ •
- **ํ’ˆ์งˆ 6ํ•ญ๋ชฉ:** ๊ฐ€๋…์„ฑยท์–ดํœ˜ยท๋…ผ๋ฆฌยท์ •ํ™•์„ฑยทํ‘œํ˜„ยท์ •๋ณด๋ฐ€๋„
- **LLM ๊ต์ฐจ๊ฒ€์ฆ:** GPT-OSS-120BยทQwen3-32BยทKimi-K2 (GROQ)
- **Brave Search**: ๋ณ‘๋ ฌ 20๊ฐœ ๋™์‹œ ์›น๊ฒ€์ƒ‰
- **ํ•™์ˆ  DB**: KCI(ํ•œ๊ตญํ•™์ˆ ์ง€์ธ์šฉ์ƒ‰์ธ), RISS(ํ•™์ˆ ์—ฐ๊ตฌ์ •๋ณด), arXiv
- **Gemini**: Google Search Grounding
- **๋ณด๊ณ ์„œ**: CopyKiller ์Šคํƒ€์ผ โ€” ์œ ์‚ฌ๋„%, ์ถœ์ฒ˜ํ‘œ, ๋ฌธ์žฅ๋ณ„ ํ•˜์ด๋ผ์ดํŠธ
- `GROQ_API_KEY` โ€” LLM ๊ต์ฐจ๊ฒ€์ฆ
- `GEMINI_API_KEY` โ€” ํ‘œ์ ˆ ๊ฒ€์‚ฌ (Google Search Grounding)
- `BRAVE_API_KEY` โ€” ํ‘œ์ ˆ ๊ฒ€์‚ฌ (Brave Search ๋ณ‘๋ ฌ)
""")
with gr.Row(visible=False):
_file_in = gr.File(label="hidden_file")
_text_out = gr.Textbox(label="hidden_text")
_file_in.change(extract_file_text_api, [_file_in], [_text_out], api_name="extract_file_text")
import shutil, pathlib
static_dir = pathlib.Path("static")
static_dir.mkdir(exist_ok=True)
if pathlib.Path("index.html").exists():
shutil.copy("index.html", static_dir / "index.html")
from fastapi import FastAPI
from fastapi.responses import HTMLResponse, FileResponse
from fastapi.staticfiles import StaticFiles
server = FastAPI()
@server.get("/", response_class=HTMLResponse)
async def serve_root():
"""๋ฃจํŠธ URL์—์„œ ํ”„๋ฆฌ๋ฏธ์—„ index.html ์„œ๋น™"""
fp = pathlib.Path("static/index.html")
if fp.exists():
return HTMLResponse(fp.read_text(encoding="utf-8"))
from fastapi.responses import RedirectResponse
return RedirectResponse("/gradio/")
app = gr.mount_gradio_app(server, demo, path="/gradio", allowed_paths=["static"])
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=7860)