diff --git "a/app.py" "b/app.py" --- "a/app.py" +++ "b/app.py" @@ -1,602 +1,118 @@ """ -HWP AI 어시스턴트 - Gradio 웹 앱 -AI가 HWP 파일을 읽고, 보고, 말하며, 생각하고 기억합니다. -- Tab 1: LLM 채팅 (스트리밍, 파일 첨부 지원) -- Tab 2: HWP 변환기 +AI 글 판별기 v4.0 — AI 탐지 + 품질 + AI→인간 변환 + 표절 검사 + 문서 분석 +═══════════════════════════════════════════════════════════════════════════ +5축 AI 탐지 | 6항목 품질 | LLM 교차검증 (GPT-OSS-120B · Qwen3-32B · Kimi-K2) +★ AI→인간: Adversarial Humanizer v2 (반복 자기대전 루프) +★ 표절: Brave Search 병렬(최대20) + KCI/RISS/ARXIV + Gemini + CopyKiller 보고서 +★ 문서: PDF·DOCX·HWP·HWPX·TXT 업로드 → 섹션별 히트맵 + PDF 보고서 """ import gradio as gr -import tempfile -import os -import subprocess -import shutil -import sys -import re -import json -import uuid -import sqlite3 -import base64 -import requests -import zlib -import zipfile -from pathlib import Path +import math, re, os, json, random, time, hashlib, zlib, zipfile, tempfile +from collections import Counter from datetime import datetime -from typing import Generator, List, Dict, Optional +from pathlib import Path +from concurrent.futures import ThreadPoolExecutor, as_completed from xml.etree import ElementTree as ET +from kiwipiepy import Kiwi -# Groq 라이브러리 임포트 +KIWI = Kiwi() try: - from groq import Groq - GROQ_AVAILABLE = True - print("✅ Groq library loaded") + import httpx; HAS_HTTPX = True except ImportError: - GROQ_AVAILABLE = False - print("❌ Groq library not available - pip install groq") - -# ============== Comic Style CSS ============== -COMIC_CSS = """ -@import url('https://fonts.googleapis.com/css2?family=Bangers&family=Comic+Neue:wght@400;700&display=swap'); - -.gradio-container { - background-color: #FEF9C3 !important; - background-image: radial-gradient(#1F2937 1px, transparent 1px) !important; - background-size: 20px 20px !important; - min-height: 100vh !important; - font-family: 'Comic Neue', cursive, sans-serif !important; -} - -footer, .footer, .gradio-container footer, .built-with, [class*="footer"], .gradio-footer, a[href*="gradio.app"] { - display: none !important; - visibility: hidden !important; - height: 0 !important; -} - - -/* HOME Button Style */ -.home-button-container { - display: flex; - justify-content: center; - align-items: center; - gap: 15px; - margin-bottom: 15px; - padding: 12px 20px; - background: linear-gradient(135deg, #10B981 0%, #059669 100%); - border: 4px solid #1F2937; - border-radius: 12px; - box-shadow: 6px 6px 0 #1F2937; -} - -.home-button { - display: inline-flex; - align-items: center; - gap: 8px; - padding: 10px 25px; - background: linear-gradient(135deg, #FACC15 0%, #F59E0B 100%); - color: #1F2937; - font-family: 'Bangers', cursive; - font-size: 1.4rem; - letter-spacing: 2px; - text-decoration: none; - border: 3px solid #1F2937; - border-radius: 8px; - box-shadow: 4px 4px 0 #1F2937; - transition: all 0.2s ease; -} - -.home-button:hover { - background: linear-gradient(135deg, #FDE047 0%, #FACC15 100%); - transform: translate(-2px, -2px); - box-shadow: 6px 6px 0 #1F2937; -} - -.home-button:active { - transform: translate(2px, 2px); - box-shadow: 2px 2px 0 #1F2937; -} - -.url-display { - font-family: 'Comic Neue', cursive; - font-size: 1.1rem; - font-weight: 700; - color: #FFF; - background: rgba(0,0,0,0.3); - padding: 8px 16px; - border-radius: 6px; - border: 2px solid rgba(255,255,255,0.3); -} - -.header-container { - text-align: center; - padding: 25px 20px; - background: linear-gradient(135deg, #3B82F6 0%, #8B5CF6 100%); - border: 4px solid #1F2937; - border-radius: 12px; - margin-bottom: 20px; - box-shadow: 8px 8px 0 #1F2937; - position: relative; -} - -.header-title { - font-family: 'Bangers', cursive !important; - color: #FFF !important; - font-size: 2.8rem !important; - text-shadow: 3px 3px 0 #1F2937 !important; - letter-spacing: 3px !important; - margin: 0 !important; -} - -.header-subtitle { - font-family: 'Comic Neue', cursive !important; - font-size: 1.1rem !important; - color: #FEF9C3 !important; - margin-top: 8px !important; - font-weight: 700 !important; -} - -.stats-badge { - display: inline-block; - background: #FACC15; - color: #1F2937; - padding: 6px 14px; - border-radius: 20px; - font-size: 0.9rem; - margin: 3px; - font-weight: 700; - border: 2px solid #1F2937; - box-shadow: 2px 2px 0 #1F2937; -} - -/* 무료 서비스 안내 박스 */ -.free-service-notice { - text-align: center; - padding: 10px 15px; - background: linear-gradient(135deg, #FEE2E2 0%, #FECACA 100%); - border: 3px solid #1F2937; - border-radius: 8px; - margin: 10px 0; - box-shadow: 4px 4px 0 #1F2937; - font-family: 'Comic Neue', cursive; - font-weight: 700; - color: #991B1B; -} - -.free-service-notice a { - color: #1D4ED8; - text-decoration: none; - font-weight: 700; -} - -.free-service-notice a:hover { - text-decoration: underline; -} - -.gr-panel, .gr-box, .gr-form, .block, .gr-group { - background: #FFF !important; - border: 3px solid #1F2937 !important; - border-radius: 8px !important; - box-shadow: 5px 5px 0 #1F2937 !important; -} - -.gr-button-primary, button.primary, .gr-button.primary { - background: linear-gradient(135deg, #EF4444 0%, #F97316 100%) !important; - border: 3px solid #1F2937 !important; - border-radius: 8px !important; - color: #FFF !important; - font-family: 'Bangers', cursive !important; - font-size: 1.3rem !important; - letter-spacing: 2px !important; - padding: 12px 24px !important; - box-shadow: 4px 4px 0 #1F2937 !important; - text-shadow: 1px 1px 0 #1F2937 !important; - transition: all 0.2s ease !important; -} - -.gr-button-primary:hover, button.primary:hover { - background: linear-gradient(135deg, #DC2626 0%, #EA580C 100%) !important; - transform: translate(-2px, -2px) !important; - box-shadow: 6px 6px 0 #1F2937 !important; -} - -.gr-button-primary:active, button.primary:active { - transform: translate(2px, 2px) !important; - box-shadow: 2px 2px 0 #1F2937 !important; -} - -textarea, input[type="text"], input[type="number"] { - background: #FFF !important; - border: 3px solid #1F2937 !important; - border-radius: 8px !important; - color: #1F2937 !important; - font-family: 'Comic Neue', cursive !important; - font-weight: 700 !important; -} - -textarea:focus, input[type="text"]:focus { - border-color: #3B82F6 !important; - box-shadow: 3px 3px 0 #3B82F6 !important; -} - -.info-box { - background: linear-gradient(135deg, #FACC15 0%, #FDE047 100%) !important; - border: 3px solid #1F2937 !important; - border-radius: 8px !important; - padding: 12px 15px !important; - margin: 10px 0 !important; - box-shadow: 4px 4px 0 #1F2937 !important; - font-family: 'Comic Neue', cursive !important; - font-weight: 700 !important; - color: #1F2937 !important; -} - -.feature-box { - background: linear-gradient(135deg, #E0F2FE 0%, #BAE6FD 100%) !important; - border: 3px solid #1F2937 !important; - border-radius: 12px !important; - padding: 20px !important; - margin: 15px 0 !important; - box-shadow: 5px 5px 0 #1F2937 !important; -} - -.feature-title { - font-family: 'Bangers', cursive !important; - font-size: 1.5rem !important; - color: #1F2937 !important; - margin-bottom: 10px !important; - text-shadow: 1px 1px 0 #FFF !important; -} - -.feature-item { - display: flex; - align-items: center; - gap: 10px; - padding: 8px 0; - font-family: 'Comic Neue', cursive !important; - font-weight: 700 !important; - font-size: 1rem !important; - color: #1F2937 !important; -} - -.feature-icon { - font-size: 1.5rem; -} - -/* Markdown 강조 박스 */ -.markdown-highlight-box { - background: linear-gradient(135deg, #EC4899 0%, #F472B6 100%) !important; - border: 4px solid #1F2937 !important; - border-radius: 12px !important; - padding: 20px !important; - margin: 15px 0 !important; - box-shadow: 6px 6px 0 #1F2937 !important; - animation: pulse-glow 2s ease-in-out infinite; -} - -@keyframes pulse-glow { - 0%, 100% { box-shadow: 6px 6px 0 #1F2937; } - 50% { box-shadow: 8px 8px 0 #1F2937, 0 0 20px rgba(236, 72, 153, 0.5); } -} - -.markdown-title { - font-family: 'Bangers', cursive !important; - font-size: 2rem !important; - color: #FFF !important; - text-shadow: 3px 3px 0 #1F2937 !important; - letter-spacing: 2px !important; - margin-bottom: 15px !important; - text-align: center !important; -} - -.markdown-benefits { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); - gap: 12px; - margin-top: 10px; -} - -.markdown-benefit-item { - background: rgba(255,255,255,0.95) !important; - border: 3px solid #1F2937 !important; - border-radius: 8px !important; - padding: 12px !important; - box-shadow: 3px 3px 0 #1F2937 !important; - font-family: 'Comic Neue', cursive !important; - font-weight: 700 !important; - font-size: 0.95rem !important; - color: #1F2937 !important; - text-align: center !important; -} - -.markdown-benefit-icon { - font-size: 1.8rem !important; - display: block !important; - margin-bottom: 5px !important; -} - -label, .gr-input-label, .gr-block-label { - color: #1F2937 !important; - font-family: 'Comic Neue', cursive !important; - font-weight: 700 !important; -} - -.gr-accordion { - background: #E0F2FE !important; - border: 3px solid #1F2937 !important; - border-radius: 8px !important; - box-shadow: 4px 4px 0 #1F2937 !important; -} - -.footer-comic { - text-align: center; - padding: 20px; - background: linear-gradient(135deg, #3B82F6 0%, #8B5CF6 100%); - border: 4px solid #1F2937; - border-radius: 12px; - margin-top: 20px; - box-shadow: 6px 6px 0 #1F2937; -} - -.footer-comic p { - font-family: 'Comic Neue', cursive !important; - color: #FFF !important; - margin: 5px 0 !important; - font-weight: 700 !important; -} - -::-webkit-scrollbar { - width: 12px; - height: 12px; -} - -::-webkit-scrollbar-track { - background: #FEF9C3; - border: 2px solid #1F2937; -} - -::-webkit-scrollbar-thumb { - background: #3B82F6; - border: 2px solid #1F2937; - border-radius: 6px; -} - -::-webkit-scrollbar-thumb:hover { - background: #EF4444; -} - -::selection { - background: #FACC15; - color: #1F2937; -} - -/* Chatbot Styling */ -.gr-chatbot { - border: 3px solid #1F2937 !important; - border-radius: 12px !important; - box-shadow: 5px 5px 0 #1F2937 !important; -} - -/* Tab Styling */ -.gr-tab-nav { - background: linear-gradient(135deg, #F59E0B 0%, #FACC15 100%) !important; - border: 3px solid #1F2937 !important; - border-radius: 8px 8px 0 0 !important; -} - -.gr-tab-nav button { - font-family: 'Bangers', cursive !important; - font-size: 1.2rem !important; - letter-spacing: 1px !important; - color: #1F2937 !important; -} - -.gr-tab-nav button.selected { - background: #FFF !important; - border-bottom: 3px solid #FFF !important; -} - -/* File Upload Box */ -.upload-box { - border: 3px dashed #3B82F6 !important; - border-radius: 12px !important; - background: linear-gradient(135deg, #EFF6FF 0%, #DBEAFE 100%) !important; - box-shadow: 4px 4px 0 #1F2937 !important; -} - -.download-box { - border: 3px solid #10B981 !important; - border-radius: 12px !important; - background: linear-gradient(135deg, #ECFDF5 0%, #D1FAE5 100%) !important; - box-shadow: 4px 4px 0 #1F2937 !important; -} -""" - -# ============== 환경 설정 ============== -SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) -PYHWP_PATH = os.path.join(SCRIPT_DIR, 'pyhwp') -DB_PATH = os.path.join(SCRIPT_DIR, 'chat_history.db') - -if os.path.exists(PYHWP_PATH): - sys.path.insert(0, PYHWP_PATH) - -# ============== 모듈 임포트 ============== + HAS_HTTPX = False try: - import olefile - OLEFILE_AVAILABLE = True - print("✅ olefile loaded") + from google import genai + from google.genai import types as gtypes + HAS_GENAI = True except ImportError: - OLEFILE_AVAILABLE = False + HAS_GENAI = False +# ── 문서 추출 라이브러리 ── try: - from markdownify import markdownify as md - MARKDOWNIFY_AVAILABLE = True - print("✅ markdownify loaded") + import olefile; HAS_OLEFILE = True except ImportError: - MARKDOWNIFY_AVAILABLE = False - + HAS_OLEFILE = False try: - import html2text - HTML2TEXT_AVAILABLE = True - print("✅ html2text loaded") + import pdfplumber; HAS_PDFPLUMBER = True except ImportError: - HTML2TEXT_AVAILABLE = False - + HAS_PDFPLUMBER = False try: - from bs4 import BeautifulSoup - BS4_AVAILABLE = True + import PyPDF2; HAS_PYPDF2 = True except ImportError: - BS4_AVAILABLE = False - + HAS_PYPDF2 = False try: - import PyPDF2 - PYPDF2_AVAILABLE = True - print("✅ PyPDF2 loaded") + from docx import Document as DocxDocument; HAS_DOCX = True except ImportError: - PYPDF2_AVAILABLE = False + HAS_DOCX = False -try: - import pdfplumber - PDFPLUMBER_AVAILABLE = True - print("✅ pdfplumber loaded") -except ImportError: - PDFPLUMBER_AVAILABLE = False - -# ============== API 키 설정 ============== -GROQ_API_KEY = os.environ.get("GROQ_API_KEY", "") -FIREWORKS_API_KEY = os.environ.get("FIREWORKS_API_KEY", "") - -# ============== SQLite 데이터베이스 ============== -def init_database(): - conn = sqlite3.connect(DB_PATH) - cursor = conn.cursor() - cursor.execute(''' - CREATE TABLE IF NOT EXISTS sessions ( - session_id TEXT PRIMARY KEY, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - title TEXT - ) - ''') - cursor.execute(''' - CREATE TABLE IF NOT EXISTS messages ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - session_id TEXT, - role TEXT, - content TEXT, - file_info TEXT, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (session_id) REFERENCES sessions(session_id) - ) - ''') - conn.commit() - conn.close() - -def create_session() -> str: - session_id = str(uuid.uuid4()) - conn = sqlite3.connect(DB_PATH) - cursor = conn.cursor() - cursor.execute("INSERT INTO sessions (session_id, title) VALUES (?, ?)", - (session_id, f"대화 {datetime.now().strftime('%Y-%m-%d %H:%M')}")) - conn.commit() - conn.close() - return session_id - -def save_message(session_id: str, role: str, content: str, file_info: str = None): - conn = sqlite3.connect(DB_PATH) - cursor = conn.cursor() - cursor.execute("INSERT INTO messages (session_id, role, content, file_info) VALUES (?, ?, ?, ?)", - (session_id, role, content, file_info)) - cursor.execute("UPDATE sessions SET updated_at = CURRENT_TIMESTAMP WHERE session_id = ?", (session_id,)) - conn.commit() - conn.close() - -def get_session_messages(session_id: str, limit: int = 20) -> List[Dict]: - conn = sqlite3.connect(DB_PATH) - cursor = conn.cursor() - cursor.execute("SELECT role, content, file_info, created_at FROM messages WHERE session_id = ? ORDER BY created_at DESC LIMIT ?", - (session_id, limit)) - rows = cursor.fetchall() - conn.close() - return [{"role": r[0], "content": r[1], "file_info": r[2], "created_at": r[3]} for r in reversed(rows)] - -def get_all_sessions() -> List[Dict]: - conn = sqlite3.connect(DB_PATH) - cursor = conn.cursor() - cursor.execute("SELECT session_id, title, created_at, updated_at FROM sessions ORDER BY updated_at DESC LIMIT 50") - rows = cursor.fetchall() - conn.close() - return [{"session_id": r[0], "title": r[1], "created_at": r[2], "updated_at": r[3]} for r in rows] - -def update_session_title(session_id: str, title: str): - conn = sqlite3.connect(DB_PATH) - cursor = conn.cursor() - cursor.execute("UPDATE sessions SET title = ? WHERE session_id = ?", (title, session_id)) - conn.commit() - conn.close() - -init_database() - -# ============== 파일 유틸리티 ============== -def extract_text_from_pdf(file_path: str) -> str: - text_parts = [] - if PDFPLUMBER_AVAILABLE: +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 page in pdf.pages: - text = page.extract_text() - if text: - text_parts.append(text) - if text_parts: - return "\n\n".join(text_parts) + 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 error: {e}") - - if PYPDF2_AVAILABLE: + print(f"pdfplumber: {e}") + if HAS_PYPDF2: try: with open(file_path, 'rb') as f: reader = PyPDF2.PdfReader(f) - for page in reader.pages: - text = page.extract_text() - if text: - text_parts.append(text) - if text_parts: - return "\n\n".join(text_parts) + 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 error: {e}") - return None - -def extract_text_from_txt(file_path: str) -> str: - for encoding in ['utf-8', 'euc-kr', 'cp949', 'utf-16', 'latin-1']: - try: - with open(file_path, 'r', encoding=encoding) as f: - return f.read() - except: - continue - return None - -def image_to_base64(file_path: str) -> str: - with open(file_path, 'rb') as f: - return base64.b64encode(f.read()).decode('utf-8') - -def get_image_mime_type(file_path: str) -> str: - ext = Path(file_path).suffix.lower() - return {'.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png', - '.gif': 'image/gif', '.webp': 'image/webp', '.bmp': 'image/bmp'}.get(ext, 'image/jpeg') - -def is_image_file(fp: str) -> bool: - return Path(fp).suffix.lower() in ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp'] + print(f"PyPDF2: {e}") + return None, "PDF 추출 실패 (pdfplumber, PyPDF2 없음)" -def is_hwp_file(fp: str) -> bool: - return Path(fp).suffix.lower() == '.hwp' - -def is_hwpx_file(fp: str) -> bool: - return Path(fp).suffix.lower() == '.hwpx' - -def is_pdf_file(fp: str) -> bool: - return Path(fp).suffix.lower() == '.pdf' - -def is_text_file(fp: str) -> bool: - return Path(fp).suffix.lower() in ['.txt', '.md', '.json', '.csv', '.xml', '.html', '.css', '.js', '.py'] +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}" -# ============== HWPX 텍스트 추출 ============== -def extract_text_from_hwpx(file_path: str) -> tuple: +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: @@ -604,134 +120,60 @@ def extract_text_from_hwpx(file_path: str) -> tuple: 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 section_file in section_files: + for sf_name in section_files: try: - with zf.open(section_file) as sf: - content = sf.read() - content_str = content.decode('utf-8') - content_str = re.sub(r'\sxmlns[^"]*"[^"]*"', '', content_str) - content_str = re.sub(r'<[a-zA-Z]+:', '<', content_str) - content_str = re.sub(r'([^<]+)<', content.decode('utf-8', errors='ignore')) - clean_texts = [t.strip() for t in text_matches if t.strip() and len(t.strip()) > 1] - if clean_texts: - text_parts.append(' '.join(clean_texts)) - except: - continue - + 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: - result = '\n\n'.join(text_parts) - result = re.sub(r'\s+', ' ', result) - result = re.sub(r'\n{3,}', '\n\n', result) - return result.strip(), None - return None, "HWPX에서 텍스트를 찾을 수 없습니다" + return text_parts, None + return None, "HWPX 텍스트 없음" except zipfile.BadZipFile: - return None, "유효하지 않은 HWPX 파일" + return None, "유효하지 않은 HWPX" except Exception as e: - return None, f"HWPX 처리 오류: {str(e)}" + return None, f"HWPX 오류: {e}" -# ============== HWP 텍스트 추출 ============== -def extract_text_with_hwp5txt(file_path: str) -> tuple: - try: - result = subprocess.run(['hwp5txt', file_path], capture_output=True, timeout=60) - if result.returncode == 0 and result.stdout: - for enc in ['utf-8', 'cp949', 'euc-kr']: - try: - text = result.stdout.decode(enc) - if text.strip() and len(text.strip()) > 10: - return text.strip(), None - except: - continue - except FileNotFoundError: - pass - except Exception as e: - print(f"hwp5txt error: {e}") - - try: - code = f''' -import sys -sys.path.insert(0, "{PYHWP_PATH}") -from hwp5.filestructure import Hwp5File -from hwp5.hwp5txt import extract_text -hwp = Hwp5File("{file_path}") -for idx in hwp.bodytext.sections(): - section = hwp.bodytext.section(idx) - for para in extract_text(section): - if para.strip(): - print(para.strip()) -hwp.close() -''' - result = subprocess.run([sys.executable, '-c', code], capture_output=True, timeout=60) - if result.returncode == 0 and result.stdout: - for enc in ['utf-8', 'cp949', 'euc-kr']: - try: - text = result.stdout.decode(enc) - if text.strip() and len(text.strip()) > 10: - return text.strip(), None - except: - continue - except Exception as e: - print(f"hwp5txt subprocess error: {e}") - - return None, "hwp5txt 실패" - -def extract_text_with_olefile(file_path: str) -> tuple: - if not OLEFILE_AVAILABLE: - 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_data = ole.openstream(entry).read() - if is_compressed: - try: - stream_data = zlib.decompress(stream_data, -15) - except: - try: - stream_data = zlib.decompress(stream_data) - except: - pass - - section_text = extract_hwp_section_text(stream_data) - if section_text: - all_texts.append(section_text) - except: - continue - - ole.close() - if all_texts: - return '\n\n'.join(all_texts).strip(), None - return None, "텍스트를 찾을 수 없습니다" - except Exception as e: - return None, f"olefile 오류: {str(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_text(data: bytes) -> str: +def _extract_hwp_section(data): + """HWP 섹션 바이너리 → 텍스트""" texts = [] pos = 0 while pos < len(data) - 4: @@ -741,836 +183,1660 @@ def extract_hwp_section_text(data: bytes) -> str: size = (header >> 20) & 0xFFF pos += 4 if size == 0xFFF: - if pos + 4 > len(data): - break + if pos + 4 > len(data): break size = int.from_bytes(data[pos:pos+4], 'little') pos += 4 - if pos + size > len(data): - break + if pos + size > len(data): break record_data = data[pos:pos+size] pos += size if tag_id == 67 and size > 0: - text = decode_para_text(record_data) - if text: - texts.append(text) + t = _decode_hwp_para(record_data) + if t: texts.append(t) except: pos += 1 - continue return '\n'.join(texts) if texts else None -def decode_para_text(data: bytes) -> str: - result = [] - i = 0 - while i < len(data) - 1: - code = int.from_bytes(data[i:i+2], 'little') - if code == 0: - pass - elif code == 1: - i += 14 - elif code == 2: - i += 14 - elif code == 3: - i += 14 - elif code == 4: - pass - elif code == 9: - result.append('\t') - elif code == 10: - result.append('\n') - elif code == 13: - result.append('\n') - elif code == 24: - result.append('-') - elif code == 30 or code == 31: - result.append(' ') - elif code < 32: - pass - else: - try: - char = chr(code) - if char.isprintable() or char in '\n\t ': - result.append(char) - 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_text_from_hwp(file_path: str) -> tuple: - print(f"\n📖 [HWP 읽기] {os.path.basename(file_path)}") - text, error = extract_text_with_hwp5txt(file_path) - if text and len(text.strip()) > 20: - print(f" ✅ 성공: {len(text)} 글자") - return text, None - text, error = extract_text_with_olefile(file_path) - if text and len(text.strip()) > 20: - print(f" ✅ 성공: {len(text)} 글자") - return text, None - print(f" ❌ 실패: {error}") - return None, "모든 추출 방법 실패" - -def extract_text_from_hwp_or_hwpx(file_path: str) -> tuple: - if is_hwpx_file(file_path): - print(f"\n📖 [HWPX 읽기] {os.path.basename(file_path)}") - return extract_text_from_hwpx(file_path) - else: - return extract_text_from_hwp(file_path) - -# ============== HWP 변환 함수들 ============== -def check_hwp_version(file_path): +def extract_text_from_hwp(file_path): + """HWP (OLE 기반) → 텍스트""" + if not HAS_OLEFILE: return None, "olefile 없음" try: - with open(file_path, 'rb') as f: - header = f.read(32) - if b'HWP Document File' in header: - return "HWP v5", True - elif header[:4] == b'\xd0\xcf\x11\xe0': - return "HWP v5 (OLE)", True - elif header[:4] == b'PK\x03\x04': - return "HWPX", True - else: - return "Unknown", False + 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 f"Error: {e}", False + 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}" -def convert_to_html_subprocess(input_path, output_dir): - output_path = os.path.join(output_dir, "output.html") - try: - for cmd in [['hwp5html', '--output', output_path, input_path]]: - try: - result = subprocess.run(cmd, capture_output=True, timeout=120) - if result.returncode == 0: - if os.path.exists(output_path): - return output_path, None - for item in os.listdir(output_dir): - item_path = os.path.join(output_dir, item) - if item.lower().endswith(('.html', '.htm')): - return item_path, None - if os.path.isdir(item_path): - return item_path, None - except: - continue - except Exception as e: - print(f"HTML 변환 오류: {e}") - return None, "HTML 변환 실패" + if sections: + full = '\n\n'.join(sections) + return sections, full, None + return None, None, error or "텍스트 추출 실패" -def html_to_markdown(html_content): - if MARKDOWNIFY_AVAILABLE: - try: - return md(html_content, heading_style="ATX", bullets="-"), None - except: - pass - if HTML2TEXT_AVAILABLE: - try: - h = html2text.HTML2Text() - h.body_width = 0 - return h.handle(html_content), None - except: - pass - if BS4_AVAILABLE: - try: - soup = BeautifulSoup(html_content, 'html.parser') - return soup.get_text(separator='\n'), None - except: - pass - return None, "Markdown 변환 실패" +# ═══════════════════════════════════════════════ +# 유틸리티 +# ═══════════════════════════════════════════════ +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 convert_hwp_to_markdown(input_path: str) -> tuple: - text, error = extract_text_from_hwp_or_hwpx(input_path) - if text: - return text, None - return None, error +def split_words(text): + return [w for w in re.findall(r'[가-힣a-zA-Z0-9]+', text) if w] -def convert_to_odt_subprocess(input_path, output_dir): - output_path = os.path.join(output_dir, "output.odt") +def get_morphemes(text): try: - result = subprocess.run(['hwp5odt', '--output', output_path, input_path], capture_output=True, timeout=120) - if result.returncode == 0 and os.path.exists(output_path): - return output_path, None - except: - pass - return None, "ODT 변환 실패" + r = KIWI.analyze(text) + if r and r[0]: return [(m.form, m.tag) for m in r[0][0]] + except: pass + return [] -def convert_to_xml_subprocess(input_path, output_dir): - output_path = os.path.join(output_dir, "output.xml") +def http_get(url, headers=None, timeout=15): try: - result = subprocess.run(['hwp5xml', input_path], capture_output=True, timeout=120) - if result.returncode == 0 and result.stdout: - with open(output_path, 'wb') as f: - f.write(result.stdout) - return output_path, None - except: - pass - return None, "XML 변환 실패" - -# ============== LLM API (Groq 라이브러리 사용) ============== -def call_groq_api_stream(messages: List[Dict]) -> Generator[str, None, None]: - """Groq API 스트리밍 호출""" - if not GROQ_AVAILABLE: - yield "❌ Groq 라이브러리가 설치되지 않았습니다. pip install groq" - return - - if not GROQ_API_KEY: - yield "❌ GROQ_API_KEY 환경변수가 설정되지 않았습니다." - return - + 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: - client = Groq(api_key=GROQ_API_KEY) - - completion = client.chat.completions.create( - model="openai/gpt-oss-120b", - messages=messages, - temperature=1, - max_completion_tokens=8192, - top_p=1, - reasoning_effort="medium", - stream=True, - stop=None - ) - - for chunk in completion: - if chunk.choices[0].delta.content: - yield chunk.choices[0].delta.content - - except Exception as e: - error_msg = str(e) - print(f"❌ Groq API 오류: {error_msg}") - yield f"❌ API 오류: {error_msg}" - -def call_fireworks_api_stream(messages: List[Dict], image_base64: str, mime_type: str) -> Generator[str, None, None]: - """Fireworks API 스트리밍 호출 (이미지 분석용)""" - if not FIREWORKS_API_KEY: - yield "❌ FIREWORKS_API_KEY 환경변수가 설정되지 않았습니다." - return - + 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: - formatted_messages = [{"role": m["role"], "content": m["content"]} for m in messages[:-1]] - formatted_messages.append({ - "role": messages[-1]["role"], - "content": [ - {"type": "image_url", "image_url": {"url": f"data:{mime_type};base64,{image_base64}"}}, - {"type": "text", "text": messages[-1]["content"]} - ] - }) - - response = requests.post( - "https://api.fireworks.ai/inference/v1/chat/completions", - headers={"Authorization": f"Bearer {FIREWORKS_API_KEY}", "Content-Type": "application/json"}, - json={ - "model": "accounts/fireworks/models/qwen3-vl-235b-a22b-thinking", - "max_tokens": 4096, - "temperature": 0.6, - "messages": formatted_messages, - "stream": True - }, - stream=True - ) - - if response.status_code != 200: - yield f"❌ Fireworks API 오류: {response.status_code}" - return - - for line in response.iter_lines(): - if line: - line = line.decode('utf-8') - if line.startswith('data: ') and line[6:] != '[DONE]': - try: - data = json.loads(line[6:]) - content = data.get('choices', [{}])[0].get('delta', {}).get('content', '') - if content: - yield content - except: - continue - except Exception as e: - yield f"❌ API 오류: {str(e)}" - -# ============== 채팅 처리 ============== -def process_file(file_path: str) -> tuple: - if not file_path: - return None, None, None - filename = os.path.basename(file_path) - - if is_image_file(file_path): - return "image", image_to_base64(file_path), get_image_mime_type(file_path) - - if is_hwp_file(file_path) or is_hwpx_file(file_path): - text, error = extract_text_from_hwp_or_hwpx(file_path) - if text and len(text.strip()) > 20: - print(f"📄 [문서 내용 추출 완료] {len(text)} 글자") - print(f"📄 [문서 미리보기] {text[:500]}...") - return "text", text, None - return "error", f"한글 문서 추출 실패: {error}", None - - if is_pdf_file(file_path): - text = extract_text_from_pdf(file_path) - if text: - print(f"📄 [PDF 내용 추출 완료] {len(text)} 글자") - return "text", text, None - return "error", "PDF 추출 실패", None - - if is_text_file(file_path): - text = extract_text_from_txt(file_path) - if text: - return "text", text, None - return "error", "텍스트 읽기 실패", None - - return "unsupported", f"지원하지 않는 형식: {filename}", None - -def chat_response(message: str, history: List[Dict], file: Optional[str], - session_id: str) -> Generator[tuple, None, None]: - if history is None: - history = [] - if not message.strip() and not file: - yield history, session_id - return - if not session_id: - session_id = create_session() - - file_type, file_content, file_mime = None, None, None - file_info = None - filename = None - - if file: - filename = os.path.basename(file) - file_type, file_content, file_mime = process_file(file) - file_info = json.dumps({"type": file_type, "filename": filename}) - - if file_type == "error": - history = history + [ - {"role": "user", "content": message or "파일 업로드"}, - {"role": "assistant", "content": f"❌ {file_content}"} - ] - yield history, session_id - return - elif file_type == "unsupported": - history = history + [ - {"role": "user", "content": message or "파일 업로드"}, - {"role": "assistant", "content": f"⚠️ {file_content}"} - ] - yield history, session_id - return - - # 사용자 메시지 표시 - user_msg = message - if file: - user_msg = f"📎 {filename}\n\n{message}" if message else f"📎 {filename}" - - history = history + [{"role": "user", "content": user_msg}, {"role": "assistant", "content": ""}] - yield history, session_id - - # 이전 대화 불러오기 - db_messages = get_session_messages(session_id, limit=10) - - # 시스템 프롬프트 - 문서 분석 강화 - system_prompt = """당신은 문서 분석 전문 AI 어시스턴트입니다. - -## 핵심 역할 -- 사용자가 업로드한 문서의 내용을 **정확하게 분석**하고 **구체적으로 답변**합니다. -- 문서에 있는 **실제 내용**을 기반으로만 답변합니다. -- 문서에 없는 내용은 추측하지 않습니다. - -## 문서 분석 방법 -1. **문서가 제공되면**: 문서 전체 내용을 꼼꼼히 읽고 핵심 정보를 파악합니다. -2. **요약 요청 시**: 문서의 주제, 목적, 핵심 내용, 주요 항목을 구조화하여 요약합니다. -3. **질문 응답 시**: 문서에서 관련 내용을 찾아 **직접 인용하거나 구체적으로 설명**합니다. - -## 답변 형식 -- 한국어로 자연스럽고 명확하게 답변합니다. -- 문서 내용을 인용할 때는 구체적으로 언급합니다. -- 긴 문서는 섹션별로 나누어 정리합니다. - -## 주의사항 -- 문서에 **실제로 있는 내용만** 답변에 포함합니다. -- 불확실한 내용은 "문서에서 확인되지 않습니다"라고 명시합니다.""" - - api_messages = [{"role": "system", "content": system_prompt}] - - # 이전 대화 추가 - for m in db_messages: - api_messages.append({"role": m["role"], "content": m["content"]}) - - # 현재 메시지 구성 - 문서 내용을 명확하게 구분 - if file_type == "text" and file_content: - if message: - current_content = f"""## 📄 업로드된 문서 내용 ({filename}) - -다음은 사용자가 업로드한 문서의 전체 내용입니다: - ---- -{file_content} ---- - -## 💬 사용자 질문 -{message} - -위 문서 내용을 바탕으로 사용자의 질문에 **구체적이고 정확하게** 답변해주세요.""" + 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: - current_content = f"""## 📄 업로드된 문서 내용 ({filename}) + 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] + +# ═══════════════════════════════════════════════ +# ★ 통합 문장 점수 (탭1 + 탭2 공유) +# ═══════════════════════════════════════════════ +AI_ENDINGS = ['합니다','입니다','됩니다','습니다','있습니다','했습니다','겠습니다'] +AI_CONNS = ['또한','따라서','그러므로','이에 따라','한편','더불어','아울러','뿐만 아니라','이를 통해','이에','결과적으로','궁극적으로','특히','나아가','이러한'] +AI_FILLER = ['것으로 보','것으로 나타','것으로 예상','할 수 있','볼 수 있','주목할 만','중요한 역할','중요한 의미','긍정적인 영향','부정적인 영향','필요합니다','필요하다','중요합니다','중요하다','역할을 하','영향을 미','기대된다','예상됩니다','부각되고','대두되고','다양한 분야','다양한 산업','눈부신 성과','획기적인 변화','혁신적인','점에서','측면에서','관점에서'] +HUMAN_MARKERS = { + 'ㅋㅎㅠ': re.compile(r'([ㅋㅎㅠㅜㄷㄱ])\1{2,}'), + '이모티콘': re.compile(r'[;:]-?[)(DPp]|\^[_\-]?\^|ㅡㅡ|;;'), + '줄임': re.compile(r'ㄹㅇ|ㅇㅇ|ㄴㄴ|ㅇㅋ'), + '느낌표': re.compile(r'[!?]{2,}'), + '비격식': re.compile(r'(거든|잖아|인데|인걸|같음|느낌|아님|대박|미쳤)'), +} +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 공유.""" + sc = 0; reasons = [] + # 격식 종결어미 + for e in AI_ENDINGS: + if sent.rstrip('.').endswith(e): sc += 25; reasons.append(f"격식어미(-{e})"); break + # 문두 접속사 + for c in AI_CONNS: + if sent.strip().startswith(c): sc += 20; reasons.append(f"AI접속사({c})"); break + # 상투적 표현 + filler_found = 0 + for f in AI_FILLER: + if f in sent: filler_found += 1 + if filler_found >= 2: sc += 25; reasons.append(f"상투표현×{filler_found}") + elif filler_found == 1: sc += 15; reasons.append("상투표현×1") + # 모델 지문 + for mn, fp in FP.items(): + for m in fp["m"]: + if m in sent: sc += 10; reasons.append(f"{mn}지문({m})"); break + # 인간 마커 (감점) + for n, p in HUMAN_MARKERS.items(): + if p.search(sent): sc -= 30; 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 + burst = 90 if cv<0.25 else 70 if cv<0.35 else 50 if cv<0.50 else 30 if cv<0.65 else 15 + wf = Counter(words); t = len(words) + ne = 0 + if t > 0: + ent = -sum((c/t)*math.log2(c/t) for c in wf.values() if c>0) + mx = math.log2(len(wf)) if len(wf)>1 else 1 + ne = ent/mx if mx>0 else 0 + es = 75 if ne>0.92 else 55 if ne>0.85 else 30 + ttr = len(wf)/t if t>0 else 0 + vs = 70 if ttr<0.45 else 50 if ttr<0.55 else 25 + se = [] + for s in sentences: + sw = split_words(s) + if len(sw)<3: continue + sf = Counter(sw); st = len(sw) + se.append(-sum((c/st)*math.log2(c/st) for c in sf.values() if c>0)) + ps = 50 + if len(se)>=2: + sa = sum(se)/len(se); scv = math.sqrt(sum((e-sa)**2 for e in se)/len(se))/(sa if sa else 1) + ps = 80 if scv<0.15 else 55 if scv<0.25 else 25 + return {"score":int(burst*0.35+es*0.2+vs*0.2+ps*0.25),"cv":round(cv,3),"ttr":round(ttr,3)} + +# ═══════════════════════════════════════════════ +# 축② 문체 +# ═══════════════════════════════════════════════ +def analyze_korean_style(text, sentences, morphemes): + fc = sum(1 for s in sentences if any(s.rstrip('.').endswith(e) for e in AI_ENDINGS)) + fr = fc/len(sentences) if sentences else 0 + es = 80 if fr>0.8 else 60 if fr>0.6 else 40 if fr>0.4 else 20 + cc = sum(1 for c in AI_CONNS if c in text); cd = cc/len(sentences) if sentences else 0 + cs = 85 if cd>0.5 else 65 if cd>0.3 else 40 if cd>0.15 else 15 + flc = sum(1 for f in AI_FILLER if f in text) + fs = 90 if flc>=5 else 70 if flc>=3 else 45 if flc>=1 else 10 + hs = sum(len(p.findall(text)) for p in HUMAN_MARKERS.values()) + hp = min(30, hs*10) + ps = 50 + if morphemes: + pc = Counter(t for _,t in morphemes); tm = sum(pc.values()) + nr = sum(pc.get(t,0) for t in ['NNG','NNP','NNB','NR','NP'])/tm if tm else 0 + ps = 70 if nr>0.45 else 55 if nr>0.40 else 30 + return {"score":max(5,int(es*0.25+cs*0.25+fs*0.25+ps*0.25)-hp),"formal":f"{fr:.0%}","conn":f"{cd:.2f}","filler":flc,"human":hs} + +# ═══════════════════════════════════════════════ +# 축③ 반복 +# ═══════════════════════════════════════════════ +def analyze_repetition(text, sentences, words): + tr = 0 + if len(words)>=3: + 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 + ns = 75 if tr>0.15 else 55 if tr>0.08 else 25 + fws = 50 + if len(sentences)>=3: + fw = [split_words(s)[0] for s in sentences if split_words(s)] + if fw: r = Counter(fw).most_common(1)[0][1]/len(fw); fws = 70 if r>0.4 else 50 if r>0.25 else 20 + csl = AI_CONNS + ['그러나','하지만','그래서','그런데','물론'] + cr = sum(1 for s in sentences if any(s.strip().startswith(c) for c in csl)) + crr = cr/len(sentences) if sentences else 0 + css = 85 if crr>0.4 else 60 if crr>0.25 else 35 if crr>0.1 else 15 + return {"score":int(ns*0.3+fws*0.3+css*0.4)} + +# ════════════���══════════════════════════════════ +# 축④ 구조 +# ═══════════════════════════════════════════════ +def analyze_structure(text, sentences): + paras = [p.strip() for p in text.split('\n\n') if p.strip()] + psc = 40 + if len(paras)>1: + pl = [len(split_sentences(p)) for p in paras]; ap = sum(pl)/len(pl) + sp = math.sqrt(sum((l-ap)**2 for l in pl)/len(pl)) if len(pl)>1 else 0 + cv = sp/ap if ap>0 else 0 + psc = 75 if cv<0.2 and len(paras)>=3 else 50 if cv<0.4 else 25 + 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)) + lsc = 85 if lt>=5 else 60 if lt>=2 else 15 + return {"score":int(psc*0.4+lsc*0.6)} + +# ═══════════════════════════════════════════════ +# 축⑤ 지문 +# ═══════════════════════════════════════════════ +def analyze_model_fingerprint(text, sentences): + ms = {} + 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) + mx = max(ms.values()) if ms else 0 + return {"score":85 if mx>=50 else 65 if mx>=30 else 40 if mx>=15 else 15,"model_scores":ms} + +# ═══════════════════════════════════════════════ +# 품질 +# ═══════════════════════════════════════════════ +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 교차검증 +# ═══════════════════════════════════════════════ +LLM_JUDGES = [("openai/gpt-oss-120b","GPT-OSS 120B"),("qwen/qwen3-32b","Qwen3 32B"),("moonshotai/kimi-k2-instruct-0905","Kimi-K2")] + +def llm_cross_check(text): + if not GROQ_KEY: return {"score":-1,"detail":{}} + prompt = f"AI 텍스트 탐지 전문가로서 분석. 1) AI vs 사람+근거3 2) 마지막줄: \"AI확률: XX%\"\n\n[텍스트]\n{text[:2000]}" + votes=[]; rpt={} + for mid,mn in LLM_JUDGES: + resp,err = call_groq(mid,prompt) + if resp: + pm = re.search(r'AI\s*확률[:\s]*(\d+)',resp) + if pm: p=int(pm.group(1)); votes.append(p); rpt[mn]=f"{p}%" + else: rpt[mn]="파싱실패" + else: rpt[mn]=f"ERR" + if votes: return {"score":int(sum(votes)/len(votes)),"detail":rpt} + return {"score":-1,"detail":rpt} + +# ═══════════════════════════════════════════════ +# 종합 판정 (일관된 기준) +# ═══════════════════════════════════════════════ +def compute_verdict(scores, llm_score=-1): + w={"통계":.25,"문체":.30,"반복성":.15,"구조":.15,"지문":.15} + ws=sum(scores[k]*w[k] for k in w) + hi=sum(1 for v in scores.values() if v>=50) + if hi>=4: ws+=12 + elif hi>=3: ws+=8 + elif hi>=2: ws+=4 + if sum(1 for v in scores.values() if v<25)>=3: ws-=8 + 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"]} + fs,v,lv=compute_verdict(sc); return fs,v,lv,sc + +# ═══════════════════════════════════════════════ +# ★ AI→인간 변환 (대폭 강화) +# ═══════════════════════════════════════════════ +CONN_MAP = {'또한':['그리고','이 밖에도'],'따라서':['그래서','이런 이유로'],'이에 따라':['그래서'], + '한편':['반면'],'더불어':['함께'],'결과적으로':['결국'],'궁극적으로':['결국'], + '나아가':['더 나아가면'],'이러한':['이런'],'특히':['그중에서도','무엇보다'], + '뿐만 아니라':['거기에다'],'이를 통해':['덕분에'],'이에':['그래서'], + '아울러':['그리고'],'그러므로':['그래서']} +FILL_MAP = { + '중요한 역할을 하고':'큰 역할을 하고','중요한 의미를 가지':'큰 의미를 가지', + '긍정적인 영향을 미치고':'좋은 영향을 주고','부정적인 영향을':'나쁜 영향을', + '눈부신 성과를 거두':'대단한 성과를 내','괄목할 만한':'눈에 띄는', + '획기적인 변화':'큰 변화','혁신적인':'새로운', + '다양한 분야':'여러 분야','다양한 산업 분야':'여러 산업','다양한 산업':'여러 산업', + '다양한 창작':'여러 창작','다양한 측면':'여러 면', + '부각되고 있습니다':'두드러지고 있다','부각되고':'두드러지고', + '대두되고':'떠오르고','활용할 수 있게':'쓸 수 있게', + '활발히 진행되고 있습니다':'활발하게 이뤄지고 있다', + '것으로 예상됩니다':'것 같다','것으로 보입니다':'것 같다', + '것으로 판단됩니다':'것으로 보인다','것으로 분석됩니다':'것으로 보인다', +} +INLINE_CONN = {'이를 통해 ':'이걸로 ','이에 대한 ':'이 문제에 대한 ','따라서 ':'그래서 ','결과적으로 ':'결국 '} +END_RULES = [ + ('활발히 진행되고 있습니다','활발하게 이뤄지고 있다'), + ('거두고 있습니다','거두고 있다'),('변화하고 있습니다','바뀌고 있다'), + ('있게 되었습니다','있게 됐다'),('하고 있습니다','하고 있다'), + ('되고 있습니다','되고 있다'),('할 수 있습니다','할 수 있다'), + ('미치고 있으며','주고 있고'),('가능해졌으며','가능해졌고'), + ('필요합니다','필요하다'),('중요합니다','중요하다'), + ('있습니다','있다'),('됩니다','된다'),('했습니다','했다'), + ('겠습니다','것이다'),('입니다','이다'), + ('가지며','가지고'),('이루는 것이','이루는 게'), +] +# 문장 재구성용 패턴 +RESTRUCTURE = [ + (r'(\S+)은 (\S+)에서 (.+)', lambda m: f"{m.group(2)}에서 {m.group(1)}은 {m.group(3)}" if random.random()<0.3 else m.group()), + (r'(.+)하고 있다\.', lambda m: f"{m.group(1)}하는 중이다." if random.random()<0.3 else m.group()), +] + +def rule_humanize(text): + r=text; ch=[] + # 1. 문두 접속사 + for ac,alts in CONN_MAP.items(): + pat=re.compile(r'(?:^|\n)(\s*)('+re.escape(ac)+r')(\s)',re.M) + for m in reversed(list(pat.finditer(r))): + if random.random()<0.5: + alt=random.choice(alts); r=r[:m.start(2)]+alt+r[m.end(2):]; ch.append(f"접속사 '{ac}'→'{alt}'") + else: r=r[:m.start(2)]+r[m.end(2):]; ch.append(f"접속사제거 '{ac}'") + # 2. 문장 내 접속사 + for ai,hu in INLINE_CONN.items(): + if ai in r: r=r.replace(ai,hu,1); ch.append(f"내부접속 '{ai.strip()}'") + # 3. 상투 + for ai in sorted(FILL_MAP.keys(), key=len, reverse=True): + hu = FILL_MAP[ai] + if ai in r: r=r.replace(ai,hu,1); ch.append(f"상투 '{ai}'") + # 4. 종결어미 전면 변환 + for ai,hu in END_RULES: + cnt=r.count(ai) + if cnt>0: r=r.replace(ai,hu); ch.append(f"종결 '{ai}'→'{hu}' ×{cnt}") + # 5. 마크다운 제거 + r=re.sub(r'^\d+\.\s+','',r,flags=re.M) + r=re.sub(r'^[-•*]\s+','',r,flags=re.M) + r=re.sub(r'\*\*([^*]+)\*\*',r'\1',r) + r=re.sub(r'^#+\s+','',r,flags=re.M) + # 6. 문장 재구성 (일부) + sents = split_sentences(r) + rebuilt = [] + for i, s in enumerate(sents): + ns = s + for pat, repl in RESTRUCTURE: + ns = re.sub(pat, repl, ns) + rebuilt.append(ns) + # 7. 균일도 개선: 짧은 감상 삽입 + if len(rebuilt) >= 4: + sl = [len(s) for s in rebuilt] + avg = sum(sl)/len(sl) + cv = math.sqrt(sum((l-avg)**2 for l in sl)/len(sl))/(avg if avg>0 else 1) + if cv < 0.30: + inserts = ["사실 이건 꽤 중요한 변화다.","솔직히 놀라운 부분이다.","이 부분이 핵심이다.","생각보다 파급력이 크다."] + mid = len(rebuilt)//2 + rebuilt.insert(mid, random.choice(inserts)) + ch.append("감상 삽입 (균일도 개선)") + # 8. 가끔 문장 합치기 + final = [] + i = 0 + while i < len(rebuilt): + if i < len(rebuilt)-1 and len(rebuilt[i]) < 25 and len(rebuilt[i+1]) < 40 and random.random() < 0.3: + merged = rebuilt[i].rstrip('.') + ', ' + rebuilt[i+1][0].lower() + rebuilt[i+1][1:] if len(rebuilt[i+1]) > 1 else rebuilt[i] + final.append(merged); ch.append("문장 합병"); i += 2 + else: + final.append(rebuilt[i]); i += 1 + r = ' '.join(final) + return r.strip(), ch + +def llm_humanize(text): + if not GROQ_KEY: return text, ["LLM: API키 없음"] + prompt = f"""당신은 AI 텍스트 탐지 우회 전문 리라이터입니다. 아래 텍스트를 자연스러운 사람이 쓴 글로 완전히 재작성하세요. + +[필수 규칙 - 모두 지킬 것] +1. 격식체(-합니다)와 비격식체(-다/-이야)를 60:40 비율로 섞어라 +2. AI접속사(또한/따라서/나아가/이러한/이에) 전부 제거하거나 구어체로 대체 +3. 상투표현(중요한 역할/긍정적인 영향/다양한 분야/획기적인) 전부 일상어로 +4. 문장 길이를 확실히 불규칙하게: 짧은 문장(10자)과 긴 문장(70자) 반드시 혼합 +5. 반드시 2-3개 개인 의견/감상 삽입 ("솔직히", "사실", "근데 생각해보면", "좀 놀라운 건") +6. 원래 의미·정보 100% 보존 +7. 마크다운/리스트 전부 제거, 완전한 산문 +8. 문장 순서를 일부 변경해도 됨 +9. 능동태 위주로, 피동 표현 줄여라 +10. "~것으로 보인다/예상된다" 같은 회피적 표현 → "~일 것이다/~할 것 같다" + +[원문] +{text[:2500]} + +[변환 결과만 출력 - 설명 없이]""" + resp, err = call_groq("qwen/qwen3-32b", prompt, max_tokens=2000, temperature=0.8) + if resp: + cleaned = re.sub(r'.*?', '', resp, flags=re.S).strip() + if len(cleaned) > 50: return cleaned, [f"LLM 리라이팅 ({len(cleaned)}자)"] + return text, [f"LLM 실패: {err}"] + +def run_humanizer(text, progress=gr.Progress()): + if not text or len(text.strip())<50: return "","","","" + text=text.strip() + progress(0.05,"원본 분석...") + b_score,b_verdict,_,b_axes=quick_score(text) + bq=analyze_quality(text,split_sentences(text),split_words(text),get_morphemes(text)) + if b_score<25: return text,"이미 인간적인 텍스트 (AI점수 25미만).","","" + + # ═══ Adversarial Humanizer v2: 반복 자기대전 ═══ + MAX_ROUNDS = 3 + TARGET_SCORE = 25 # 이 점수 이하면 통과 + best_text = text + best_score = b_score + best_method = "원본" + all_ch = [] + round_log = [] + + for rnd in range(1, MAX_ROUNDS + 1): + if best_score <= TARGET_SCORE: + round_log.append(f"🏆 Round {rnd} 스킵 — 이미 목표 달성 (점수 {best_score})") + break + + pct = 0.05 + (rnd / MAX_ROUNDS) * 0.65 + progress(pct, f"⚔️ Round {rnd}/{MAX_ROUNDS} — 현재 점수 {best_score}...") + + # 1단계: 규칙 변환 + rule_text, rule_ch = rule_humanize(best_text) + r_score, _, _, _ = quick_score(rule_text) + candidates = [(rule_text, r_score, f"R{rnd}-규칙", rule_ch)] + + # 2단계: LLM 변환 (있으면) + if GROQ_KEY: + llm_text, llm_ch = llm_humanize(best_text) + l_score, _, _, _ = quick_score(llm_text) + candidates.append((llm_text, l_score, f"R{rnd}-LLM", llm_ch)) + + # 3단계: LLM → 규칙 하이브리드 + llm_rule, lr_ch = rule_humanize(llm_text) + lr_score, _, _, _ = quick_score(llm_rule) + candidates.append((llm_rule, lr_score, f"R{rnd}-LLM+규칙", llm_ch + lr_ch)) + + # 최적 선택 + winner = min(candidates, key=lambda x: x[1]) + w_text, w_score, w_method, w_changes = winner + score_strs = ', '.join(f"{c[2]}:{c[1]}" for c in candidates) + round_log.append(f"Round {rnd}: {score_strs} → {w_method} 채택 (Δ{best_score - w_score})") + + if w_score < best_score: + best_text, best_score, best_method = w_text, w_score, w_method + all_ch.extend(w_changes) + else: + round_log.append(f" ↳ 개선 없음, 이전 결과 유지") + break # 더 이상 개선 불가 ---- -{file_content} ---- + final_text = best_text + method = best_method -## 📋 요청사항 -위 문서의 내용을 다음 형식으로 **상세하게 요약**해주세요: + progress(0.75, "최종 검증...") + a_score, a_verdict, a_level, a_axes = quick_score(final_text) + aq = analyze_quality(final_text, split_sentences(final_text), split_words(final_text), get_morphemes(final_text)) -1. **문서 제목/주제**: 문서가 다루는 주요 주제 -2. **문서 목적**: 이 문서의 작성 목적 -3. **핵심 내용**: 가장 중요한 내용 3-5가지 -4. **세부 항목**: 문서에 포함된 주요 섹션이나 항목 -5. **결론/요약**: 문서의 핵심 메시지""" + progress(0.85, "LLM 교차검증...") + llm_v = llm_cross_check(final_text) + if llm_v["score"] >= 0: + a_score_f, a_verdict_f, _ = compute_verdict(a_axes, llm_v["score"]) else: - current_content = message or "" - - api_messages.append({"role": "user", "content": current_content}) - - # 디버그 로그 - print(f"\n🤖 [API 요청]") - print(f" - 메시지 수: {len(api_messages)}") - print(f" - 파일 타입: {file_type}") - print(f" - 문서 길이: {len(file_content) if file_content else 0} 글자") - - # 응답 생성 - full_response = "" - if file_type == "image": - for chunk in call_fireworks_api_stream(api_messages, file_content, file_mime): - full_response += chunk - history[-1] = {"role": "assistant", "content": full_response} - yield history, session_id - else: - for chunk in call_groq_api_stream(api_messages): - full_response += chunk - history[-1] = {"role": "assistant", "content": full_response} - yield history, session_id - - # DB 저장 - save_message(session_id, "user", current_content, file_info) - save_message(session_id, "assistant", full_response) - - if len(db_messages) == 0 and message: - update_session_title(session_id, message[:50]) - -def new_chat(): - return [], create_session(), None - -def load_session(session_id: str) -> tuple: - if not session_id: - return [], "" - messages = get_session_messages(session_id, limit=50) - return [{"role": m["role"], "content": m["content"]} for m in messages], session_id - -# ============== HWP 변환기 (수정됨) ============== -def convert_hwp(file, output_format, progress=gr.Progress()): - """HWP/HWPX 파일을 다양한 형식으로 변환 (다운로드 문제 수정)""" - if file is None: - return None, "❌ 파일을 업로드해주세요.", "" - - # Gradio 버전에 따른 파일 경로 처리 - if isinstance(file, str): - input_file = file - elif hasattr(file, 'name'): - input_file = file.name - else: - input_file = str(file) - - print(f"\n🔄 [변환 시작] 입력 파일: {input_file}") - - # 파일 존재 확인 - if not os.path.exists(input_file): - return None, f"❌ 파일을 찾을 수 없습니다: {input_file}", "" - - ext_lower = Path(input_file).suffix.lower() - - if ext_lower not in ['.hwp', '.hwpx']: - return None, "❌ HWP 또는 HWPX 파일만 지원됩니다.", "" - - progress(0.1, desc="📖 파일 읽는 중...") - version, is_valid = check_hwp_version(input_file) - print(f" 파일 버전: {version}, 유효: {is_valid}") - - if not is_valid: - return None, f"❌ 지원하지 않는 파일: {version}", "" - - # 임시 디렉토리 생성 - tmp_dir = tempfile.mkdtemp(prefix="hwp_convert_") - print(f" 임시 디렉토리: {tmp_dir}") - + a_score_f, a_verdict_f = a_score, a_verdict + + delta = b_score - a_score_f + passed = a_score_f < 30 + change_log = f"⚔️ Adversarial v2 ({len(round_log)}라운드)\n" + change_log += '\n'.join(f" {r}" for r in round_log) + change_log += f"\n\n총 {len(all_ch)}건 변환:\n" + '\n'.join(f" • {c}" for c in all_ch[:20]) + if len(all_ch) > 20: change_log += f"\n ... +{len(all_ch)-20}건" + + # 비교 HTML + def cbar(lbl,bv,av): + bc="#FF4444" if bv>=50 else "#DDAA00" if bv>=35 else "#22AA44" + ac="#FF4444" if av>=50 else "#DDAA00" if av>=35 else "#22AA44" + d=bv-av; ds=f"↓{d}" if d>0 else f"↑{abs(d)}" if d<0 else "=" + return f"
{lbl}
{ds}
" + + bfg="#FF4444" if b_score>=60 else "#FF8800" if b_score>=45 else "#DDAA00" if b_score>=30 else "#22AA44" + bbg="#FFE0E0" if b_score>=60 else "#FFF0DD" if b_score>=45 else "#FFFBE0" if b_score>=30 else "#E0FFE8" + afg="#FF4444" if a_score_f>=60 else "#FF8800" if a_score_f>=45 else "#DDAA00" if a_score_f>=30 else "#22AA44" + abg="#FFE0E0" if a_score_f>=60 else "#FFF0DD" if a_score_f>=45 else "#FFFBE0" if a_score_f>=30 else "#E0FFE8" + dc="#22AA44" if delta>0 else "#FF4444" + badge='✅ 검증 통과' if passed else '⚠️ 추가 수정 권장 (AI점수 {})'.format(a_score_f)+'' + + html=f"""
+
{badge}
+
+
+
BEFORE
+
{b_score}
+
{b_verdict}
+
+
+
{"↓" if delta>0 else "↑"}{abs(delta)}
+
+
+
AFTER ({method})
+
{a_score_f}
+
{a_verdict_f}
+
+
+
+
📊 축별 비교
+ {cbar("통계",b_axes["통계"],a_axes["통계"])}{cbar("문체",b_axes["문체"],a_axes["문체"])}{cbar("반복",b_axes["반복성"],a_axes["반복성"])}{cbar("구조",b_axes["구조"],a_axes["구조"])}{cbar("지문",b_axes["지문"],a_axes["지문"])} +
+
+ 품질: {bq['grade']}({bq['score']}) → {aq['grade']}({aq['score']}) +
""" + return final_text, change_log, html, "" + +# ═══════════════════════════════════════════════ +# ★ 표절 검사 (Brave Search 병렬 + KCI/RISS/ARXIV + Gemini) +# ═══════════════════════════════════════════════ +def brave_search(query, count=5): + """Brave Search API — 단일 쿼리""" + if not BRAVE_KEY: return [] + url = f"https://api.search.brave.com/res/v1/web/search?q={query}&count={count}" + try: + if HAS_HTTPX: + r = httpx.get(url, headers={"X-Subscription-Token": BRAVE_KEY, "Accept": "application/json"}, timeout=10) + if r.status_code == 200: + data = r.json() + results = [] + for item in data.get("web", {}).get("results", []): + results.append({"title": item.get("title",""), "url": item.get("url",""), "snippet": item.get("description",""), "source": "Brave"}) + return results + except: pass + return [] + +def search_kci(query): + """KCI(한국학술지인용색인) 검색""" + try: + url = f"https://open.kci.go.kr/po/openapi/openApiSearch.kci?apiCode=articleSearch&title={query}&displayCount=3" + resp = http_get(url, timeout=8) + if resp: + results = [] + for m in re.finditer(r'.*?', resp, re.S): + results.append({"title": m.group(1), "url": m.group(2), "snippet": "", "source": "KCI"}) + return results[:3] + except: pass + return [] + +def search_riss(query): + """RISS(학술연구정보서비스) — 간접 검색""" + results = [] + try: + url = f"http://www.riss.kr/search/Search.do?isDetailSearch=N&searchGubun=true&viewYn=OP&queryText=&strQuery={query}&iStartCount=0&iGroupView=5&icate=all" + resp = http_get(url, timeout=8) + if resp: + for m in re.finditer(r'class="title"[^>]*>.*?]*href="([^"]+)"[^>]*>(.*?)', resp, re.S): + title = re.sub(r'<[^>]+>', '', m.group(2)).strip() + if title: + results.append({"title": title, "url": "https://www.riss.kr" + m.group(1), "snippet": "", "source": "RISS"}) + except: pass + return results[:3] + +def search_arxiv(query): + """arXiv API 검색""" + results = [] + try: + import urllib.parse + q = urllib.parse.quote(query) + url = f"https://export.arxiv.org/api/query?search_query=all:{q}&start=0&max_results=3&sortBy=relevance" + resp = http_get(url, timeout=12) + if resp: + for m in re.finditer(r'.*?(.*?).*?(.*?).*?(.*?)', resp, re.S): + title = re.sub(r'\s+', ' ', m.group(1)).strip() + results.append({"title": title, "url": m.group(2).strip(), "snippet": re.sub(r'\s+', ' ', m.group(3)).strip()[:150], "source": "arXiv"}) + except Exception as e: + pass + return results[:3] + +def gemini_plagiarism_check(text_chunk): + """Gemini + Google Search Grounding으로 표절 검사""" + if not HAS_GENAI or not GEMINI_KEY: return None + try: + client = genai.Client(api_key=GEMINI_KEY) + tool = gtypes.Tool(google_search=gtypes.GoogleSearch()) + prompt = f"""다음 텍스트가 인터넷에 존재하는지 Google Search로 확인하세요. +유사한 문장이 발견되면 출처 URL과 유사도(%)를 보고하세요. +마지막 줄에 "유사도: XX%" 형식으로 작성. + +[텍스트] +{text_chunk[:1000]}""" + resp = client.models.generate_content( + model="gemini-2.0-flash-lite", + contents=prompt, + config=gtypes.GenerateContentConfig(tools=[tool], temperature=0.1, max_output_tokens=600) + ) + text_resp = resp.text if resp.text else "" + sources = [] + if hasattr(resp, 'candidates') and resp.candidates: + gc = resp.candidates[0].grounding_metadata + if gc and hasattr(gc, 'grounding_chunks'): + for chunk in gc.grounding_chunks: + if hasattr(chunk, 'web') and chunk.web: + sources.append({"title": chunk.web.title or "", "url": chunk.web.uri or "", "source": "Google"}) + pm = re.search(r'유사도[:\s]*(\d+)', text_resp) + pct = int(pm.group(1)) if pm else 0 + return {"pct": pct, "response": text_resp, "sources": sources} + except Exception as e: + return {"pct": 0, "response": str(e)[:100], "sources": []} + +def parallel_brave_search(queries, max_workers=10): + """Brave Search 병렬 실행 (최대 20개)""" + all_results = {} + with ThreadPoolExecutor(max_workers=min(max_workers, 20)) as executor: + futures = {executor.submit(brave_search, q, 3): q for q in queries} + for future in as_completed(futures): + q = futures[future] + try: + results = future.result() + all_results[q] = results + except: all_results[q] = [] + return all_results + +def duckduckgo_search(query, max_results=5): + """DuckDuckGo HTML 스크래핑 — API 키 불필요 폴백""" + results = [] try: - input_filename = os.path.basename(input_file) - input_path = os.path.join(tmp_dir, input_filename) - shutil.copy2(input_file, input_path) - - progress(0.3, desc=f"🔄 {output_format}로 변환 중...") - - output_path = None - error = None - ext = "" - - if output_format == "HTML": - if ext_lower == '.hwpx': - return None, "❌ HWPX는 HTML 변환을 지원하지 않습니다.", "" - output_path, error = convert_to_html_subprocess(input_path, tmp_dir) - ext = ".html" - if output_path and os.path.isdir(output_path): - zip_path = shutil.make_archive(os.path.join(tmp_dir, "html"), 'zip', output_path) - output_path, ext = zip_path, ".zip" - - elif output_format == "ODT (OpenDocument)": - if ext_lower == '.hwpx': - return None, "❌ HWPX는 ODT 변환을 지원하지 않습니다.", "" - output_path, error = convert_to_odt_subprocess(input_path, tmp_dir) - ext = ".odt" - - elif output_format == "TXT (텍스트)": - text, error = extract_text_from_hwp_or_hwpx(input_path) - if text and text.strip(): - output_path = os.path.join(tmp_dir, "output.txt") - with open(output_path, 'w', encoding='utf-8') as f: - f.write(text) - ext = ".txt" - print(f" TXT 생성 완료: {len(text)} 글자") - - elif output_format == "⭐ MARKDOWN (추천)": - text, error = convert_hwp_to_markdown(input_path) - if text and text.strip(): - output_path = os.path.join(tmp_dir, "output.md") - with open(output_path, 'w', encoding='utf-8') as f: - f.write(text) - ext = ".md" - print(f" MD 생성 완료: {len(text)} 글자") - - elif output_format == "XML": - if ext_lower == '.hwpx': + import urllib.parse + q = urllib.parse.quote(query) + url = f"https://html.duckduckgo.com/html/?q={q}" + headers = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"} + resp = http_get(url, headers=headers, timeout=10) + if resp: + for m in re.finditer(r']+class="result__a"[^>]+href="([^"]+)"[^>]*>(.*?).*?]+class="result__snippet"[^>]*>(.*?)', resp, re.S): + href = m.group(1) + title = re.sub(r'<[^>]+>', '', m.group(2)).strip() + snippet = re.sub(r'<[^>]+>', '', m.group(3)).strip() + # DuckDuckGo redirect URL 파싱 + real_url = href + if 'uddg=' in href: + um = re.search(r'uddg=([^&]+)', href) + if um: real_url = urllib.parse.unquote(um.group(1)) + if title: + results.append({"title": title, "url": real_url, "snippet": snippet, "source": "Web"}) + if len(results) >= max_results: break + except: pass + return results + +def self_crawl_search(query, max_results=3): + """httpx 기반 자체 크롤링 (DuckDuckGo + 학술 사이트)""" + all_results = [] + # DuckDuckGo + all_results.extend(duckduckgo_search(query, max_results)) + # 학술 키워드 추가 검색 + if '논문' not in query and 'paper' not in query.lower(): + all_results.extend(duckduckgo_search(f"{query} 논문 학술", 2)) + return all_results + +def run_plagiarism(text, progress=gr.Progress()): + if not text or len(text.strip())<50: + return "
⚠️ 최소 50자 이상
", "" + text = text.strip() + sents = split_sentences(text) + now = datetime.now().strftime("%Y-%m-%d %H:%M") + + has_brave = bool(BRAVE_KEY) + has_gemini = bool(HAS_GENAI and GEMINI_KEY) + + progress(0.05, "문장 분리...") + # 문장을 3~5문장 단위로 블록화 + blocks = [] + for i in range(0, len(sents), 4): + block = ' '.join(sents[i:i+4]) + if len(block) > 20: + blocks.append({"text": block, "sent_indices": list(range(i, min(i+4, len(sents))))}) + + all_sources = [] + sent_matches = {i: [] for i in range(len(sents))} # 문장별 매칭 정보 + block_results = [] + log_lines = [] + + # Phase 1: 웹 검색 (Brave Search 병렬 or 자체 크롤링) + if has_brave: + progress(0.15, f"Brave Search 병렬 검색 ({len(blocks)}블록)...") + queries = [] + for b in blocks: + key_phrase = b["text"][:60].strip() + queries.append(f'"{key_phrase}"') + brave_results = parallel_brave_search(queries[:20]) + for q, results in brave_results.items(): + for r in results: + all_sources.append(r) + for b in blocks: + if q.strip('"') in b["text"][:60]: + for si in b["sent_indices"]: + sent_matches[si].append({"source": r["title"], "url": r["url"], "type": "Brave"}) + log_lines.append(f"Brave Search: {len(queries)}쿼리 → {sum(len(v) for v in brave_results.values())}건") + else: + # 자체 크롤링 폴백 (DuckDuckGo + 병렬) + progress(0.15, f"자체 웹 검색 ({len(blocks)}블록)...") + crawl_queries = [] + for b in blocks[:10]: # 최대 10블록 + key_phrase = b["text"][:50].strip() + crawl_queries.append((key_phrase, b)) + with ThreadPoolExecutor(max_workers=5) as executor: + futures = {executor.submit(self_crawl_search, q, 3): (q, b) for q, b in crawl_queries} + for future in as_completed(futures): + q, b = futures[future] try: - with zipfile.ZipFile(input_path, 'r') as zf: - xml_contents = [] - for name in zf.namelist(): - if name.endswith('.xml'): - with zf.open(name) as f: - xml_contents.append(f"\n{f.read().decode('utf-8', errors='ignore')}") - if xml_contents: - output_path = os.path.join(tmp_dir, "output.xml") - with open(output_path, 'w', encoding='utf-8') as f: - f.write('\n\n'.join(xml_contents)) - ext = ".xml" - except Exception as e: - error = f"HWPX XML 추출 실패: {e}" - else: - output_path, error = convert_to_xml_subprocess(input_path, tmp_dir) - ext = ".xml" - - # 변환 결과 확인 - if not output_path: - print(f" ❌ 변환 실패: {error}") - return None, f"❌ {error or '변환 실패'}", "" - - if not os.path.exists(output_path): - print(f" ❌ 출력 파일 없음: {output_path}") - return None, "❌ 변환된 파일을 찾을 수 없습니다.", "" - - progress(0.8, desc="✅ 완료 중...") - - # 최종 파일명 생성 - base_name = Path(input_filename).stem - final_filename = f"{base_name}{ext}" - final_output = os.path.join(tmp_dir, final_filename) - - # 파일명이 다르면 복사 - if output_path != final_output: - shutil.copy2(output_path, final_output) - - # 파일 검증 - if not os.path.exists(final_output): - return None, "❌ 최종 파일 생성 실패", "" - - file_size = os.path.getsize(final_output) - if file_size == 0: - return None, "❌ 변환된 파일이 비어있습니다.", "" - - size_str = f"{file_size/1024:.1f} KB" if file_size > 1024 else f"{file_size} bytes" - - # 미리보기 생성 - preview = "" - if ext in ['.txt', '.md', '.xml']: + results = future.result() + for r in results: + all_sources.append(r) + for si in b["sent_indices"]: + sent_matches[si].append({"source": r["title"], "url": r["url"], "type": r.get("source","Web")}) + except: pass + log_lines.append(f"자체 웹검색: {len(crawl_queries)}쿼리 (DuckDuckGo)") + + # Phase 2: 학술 DB (KCI, RISS, arXiv) — 키워드 추출 후 검색 + progress(0.40, "학술 DB 검색 (KCI/RISS/arXiv)...") + # 핵심 키워드 추출 + words = split_words(text) + wf = Counter(words) + keywords = [w for w, c in wf.most_common(20) if len(w) >= 2 and c >= 2][:5] + kw_query = ' '.join(keywords[:3]) + + academic_results = [] + with ThreadPoolExecutor(max_workers=3) as executor: + futures = [ + executor.submit(search_kci, kw_query), + executor.submit(search_riss, kw_query), + executor.submit(search_arxiv, kw_query), + ] + for future in as_completed(futures): try: - with open(final_output, 'r', encoding='utf-8', errors='ignore') as f: - preview = f.read(5000) - if len(preview) >= 5000: - preview += "\n\n... (생략)" - except Exception as e: - preview = f"미리보기 로드 실패: {e}" - elif ext == '.zip': - preview = "📦 HTML이 ZIP으로 압축되었습니다." - elif ext == '.html': - preview = "🌐 HTML 파일이 생성되었습니다." - elif ext == '.odt': - preview = "📄 ODT 파일이 생성되었습니다." - - progress(1.0, desc="🎉 완료!") - - print(f" ✅ 변환 완료: {final_output}") - print(f" 크기: {size_str}") - print(f" 존재: {os.path.exists(final_output)}") - - # 파일 경로 반환 - return final_output, f"✅ 변환 완료: {final_filename} ({size_str})", preview - - except Exception as e: - import traceback - traceback.print_exc() - return None, f"❌ 오류: {str(e)}", "" - - -# ============== Gradio UI ============== -# Gradio 6.0+ 호환: css, head는 launch()에서 처리 -CUSTOM_HEAD = """ - - -""" + results = future.result() + academic_results.extend(results) + all_sources.extend(results) + except: pass + log_lines.append(f"학술DB: KCI/RISS/arXiv → {len(academic_results)}건") + + # Phase 3: Gemini Google Search Grounding + gemini_results = [] + if has_gemini: + progress(0.60, "Gemini + Google Search...") + for i, b in enumerate(blocks[:5]): # 최대 5블록 + gr_result = gemini_plagiarism_check(b["text"]) + if gr_result: + gemini_results.append(gr_result) + for src in gr_result.get("sources", []): + all_sources.append(src) + for si in b["sent_indices"]: + sent_matches[si].append({"source": src.get("title",""), "url": src.get("url",""), "type": "Google"}) + log_lines.append(f"Gemini: {len(blocks[:5])}블록 → {sum(len(r.get('sources',[])) for r in gemini_results)}출처") + + progress(0.80, "보고서 생성...") + + # 유사도 계산 + matched_sents = sum(1 for si, matches in sent_matches.items() if matches) + total_sents = len(sents) + plag_pct = int(matched_sents / total_sents * 100) if total_sents > 0 else 0 + + # Gemini 유사도도 반영 + if gemini_results: + gemini_pcts = [r["pct"] for r in gemini_results if r["pct"] > 0] + if gemini_pcts: + gemini_avg = sum(gemini_pcts) / len(gemini_pcts) + plag_pct = int(plag_pct * 0.5 + gemini_avg * 0.5) + + # 출처 중복 제거 + seen_urls = set() + unique_sources = [] + for s in all_sources: + url = s.get("url", "") + if url and url not in seen_urls: + seen_urls.add(url) + unique_sources.append(s) + + # 등급 + if plag_pct >= 50: grade, grade_color, grade_bg = "표절 의심", "#FF4444", "#FFE0E0" + elif plag_pct >= 30: grade, grade_color, grade_bg = "주의 필요", "#FF8800", "#FFF0DD" + elif plag_pct >= 15: grade, grade_color, grade_bg = "유사 표현 일부", "#DDAA00", "#FFFBE0" + elif plag_pct >= 5: grade, grade_color, grade_bg = "양호", "#4ECDC4", "#E0FFF8" + else: grade, grade_color, grade_bg = "우수 (원본성 높음)", "#22AA44", "#E0FFE8" + + + # ═══ CopyKiller 정밀 재현 보고서 HTML ═══ + sent_analysis = [] + for i, s in enumerate(sents): + matches = sent_matches.get(i, []) + if matches: + best = matches[0] + sent_analysis.append({"idx":i, "text":s, "matched":True, "source":best.get("source","")[:40], "url":best.get("url",""), "type":best.get("type","")}) + else: + sent_analysis.append({"idx":i, "text":s, "matched":False}) + sim_sents = [s for s in sent_analysis if s["matched"]] + + # 출처 그룹핑 + src_groups = {} + for src in unique_sources: + key = src.get("url","")[:80] + if key not in src_groups: + src_groups[key] = {"title":src.get("title",""), "url":src.get("url",""), "source":src.get("source",""), "count":0} + src_groups[key]["count"] += 1 + src_list = sorted(src_groups.values(), key=lambda x: -x["count"]) + + methods_used = [] + if has_brave: methods_used.append("Brave Search(병렬)") + elif all_sources: methods_used.append("DuckDuckGo(자체크롤링)") + methods_used.append("KCI · RISS · arXiv") + if has_gemini: methods_used.append("Gemini+Google Search") + method_str = " + ".join(methods_used) + + gc = grade_color + word_count = len(split_words(text)) + char_count = len(text) + doc_id = hashlib.md5(text[:100].encode()).hexdigest()[:8].upper() + similarity_pct = plag_pct + citation_pct = 0 + + # 문장 카테고리 분류 (CopyKiller 스타일) + cat_suspect = len(sim_sents) # 의심 + cat_cited = 0 # 인용 (형식적 인용 감지) + cat_normal = total_sents - cat_suspect - cat_cited # 일반 + cat_suspect_pct = int(cat_suspect / max(1, total_sents) * 100) + cat_normal_pct = 100 - cat_suspect_pct + + # 출처 유형 아이콘 + def src_icon(s): + src = s.get("source","").lower() + if "kci" in src: return "📚", "KCI" + if "riss" in src: return "📖", "RISS" + if "arxiv" in src: return "📄", "arXiv" + if "google" in src: return "🔍", "Google" + if "brave" in src: return "🌐", "Brave" + return "🌐", "Web" + + # 출처 테이블 행 + src_rows = "" + for i, sg in enumerate(src_list[:15]): + pct = min(100, int(sg["count"] / max(1, total_sents) * 100 * 3)) + ico, stype = src_icon(sg) + title_short = sg["title"][:50] or "(제목 없음)" + url_short = sg["url"][:60] + src_rows += f""" + {i+1} + {ico}
{stype} +
{title_short}
{url_short}
+ {pct}% +
+ """ + + # 의심 문장 대비 행 + suspect_rows = "" + for i, sa in enumerate(sim_sents[:15]): + suspect_rows += f""" + {i+1} + {sa["text"][:90]} + {sa["text"][:70]}... + {sa["source"][:28]}
{sa.get('type','')} + """ + + # 전체 텍스트 하이라이트 (CopyKiller 스타일 - 문장번호 + 색상) + full_hl = "" + for sa in sent_analysis: + sidx = sa["idx"] + 1 + if sa["matched"]: + full_hl += f'{sa["text"]} ' + else: + full_hl += f'{sa["text"]} ' + + # 카테고리 바 너비 + bar_suspect_w = max(2, cat_suspect_pct) if cat_suspect > 0 else 0 + bar_normal_w = 100 - bar_suspect_w + + # CSS 상수 + HDR_BG = '#3B7DD8' + HDR_BG2 = '#4A8DE0' + TH = 'padding:8px 10px;font-size:10px;font-weight:700;color:#fff;background:{};text-align:center;border:1px solid {};'.format(HDR_BG, HDR_BG) + TL = 'padding:7px 10px;font-size:11px;color:#444;font-weight:600;background:#EDF2FA;border:1px solid #D5D5D5;' + TV = 'padding:7px 10px;font-size:12px;color:#333;border:1px solid #D5D5D5;' + SEC = 'font-size:13px;font-weight:800;color:#1A3C6E;margin:0 0 10px 0;padding:8px 12px;background:#EDF2FA;border-left:4px solid {};border-bottom:1px solid #D5D5D5;'.format(HDR_BG) + + html = f"""
+ + +
+ + + +
+
AI TEXT DETECTOR · PLAGIARISM REPORT
+
표절 검사 결과 확인서
+
+
문서번호 {doc_id}
+
{now}
+
+
-with gr.Blocks( - title="HWPower AI 어시스턴트" -) as demo: - - # HOME Button - gr.HTML(""" -
- - 🏠 HOME - - 🌐 www.humangen.ai + +
+
📋 검사 정보
+ + + + + + + + + + + + + + + + + + + +
검사 일시{now}문서번호{doc_id}
검사 방법{method_str}
전체 분량글자수 {char_count:,} · 어절수 {word_count:,} · 문장수 {total_sents}
검색 범위인터넷(웹), 학술논문(KCI·RISS), 해외논문(arXiv), Google Scholar
- """) - - # Header - gr.HTML(""" -
-
📄 HWPower AI 어시스턴트 🤖
-
AI가 HWP 파일을 읽고, 보고, 말하며, 생각하고 기억합니다!
-
- 📖 읽기 READ - 👁️ 보기 SEE - 💬 말하기 SPEAK - 🧠 생각 THINK - 💾 기억 MEMORY -
+ + +
+
📊 검사 결과
+ + + + + + + + +
+
+ + + + +
+
표절률
+
{plag_pct}%
+
+
+
+ {grade} +
+
+ +
+
+
+
+
+
+ 의심 {cat_suspect} + 출처표시 0 + 인용 {cat_cited} + 일반 {cat_normal} +
+
+ + +
+
+
■ 표절률{plag_pct}%
+
+
+
+
■ 유사율{similarity_pct}%
+
+
+
+
■ 인용률{citation_pct}%
+
+
+
+ + +
+ + + + + + + + + +
의심문장{cat_suspect}건일반문장{cat_normal}건전체{total_sents}건
+
+
+
+ + +
+
📝 전체 텍스트 분석
+
+ 표절 의심 + 출처표시 + 인용 + 자기표절 + 일반 +
+
{full_hl}
+
+ + +
+
🔗 표절 의심 출처 ({len(src_list)}건)
+ + + + + + + + + {src_rows if src_rows else ''} +
No유형출처명 / URL유사율분포
발견된 유사 출처가 없습니다.
+
+ + +
+
⚠️ 의심 문장 비교 ({len(sim_sents)}건)
+ + + + + + + + {suspect_rows if suspect_rows else ''} +
No검사 문장 (원문)비교 문장 (출처)출처
유사 의심 문장이 발견되지 않았습니다.
- """) - - # 무료 서비스 안내 - gr.HTML(""" -
- 🆓 본 서비스는 무료 버전으로 일부 기능에 제약이 있습니다.
- 📧 문의: arxivgpt@gmail.com + + +
+ 📌 검사 안내
+ · 본 보고서는 {method_str} 기반 자동 표절 검사 결과입니다.
+ · 검색 범위: 인터넷 웹페이지, 학술논문(KCI, RISS), 해외논문(arXiv)
+ · 유사도는 문장 단위 매칭 기반이며, 최종 판정은 교수자/검토자의 확인이 필요합니다.
+ · 인용 표기(따옴표, 각주 등)가 포함된 문장은 인용으로 분류될 수 있습니다. +
+ + +
+
+ AI Detector + Plagiarism Checker v3.5 +
+
+
Powered by Brave · KCI · RISS · arXiv · Gemini
+
{now} · ID: {doc_id} · All Rights Reserved.
+
- """) - - session_state = gr.State("") - - with gr.Tabs(): - # Tab 1: AI 채팅 - with gr.Tab("💬 AI 채팅"): - with gr.Row(): - with gr.Column(scale=1): - gr.HTML(""" -
- 📁 지원 파일 형식

- 🖼️ 이미지: JPG, PNG, GIF, WebP
- 📑 문서: PDF, TXT, MD
- 📄 한글: HWP, HWPX ✨ +
""" + + log = '\n'.join(log_lines) + f"\n\n종합: {plag_pct}% {grade} | 출처 {len(unique_sources)}건 | 유사문장 {matched_sents}/{total_sents}" + return html, log + +# ═══════════════════════════════════════════════ +# 탭1: 분석 (명확한 출력) +# ═══════════════════════════════════════════════ +def run_detection(text, progress=gr.Progress()): + if not text or len(text.strip())<50: return "
⚠️ 최소 50자
","" + text=text.strip() + progress(0.05); sents=split_sentences(text); words=split_words(text); morphs=get_morphemes(text) + progress(0.15); s1=analyze_statistics(text,sents,words) + progress(0.28); s2=analyze_korean_style(text,sents,morphs) + progress(0.38); s3=analyze_repetition(text,sents,words) + progress(0.48); s4=analyze_structure(text,sents) + progress(0.55); s5=analyze_model_fingerprint(text,sents) + progress(0.62); 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"]} + fs,verdict,level=compute_verdict(sc,lr["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","?")) + ms=s5.get("model_scores",{}); tm=max(ms,key=ms.get) if ms else "N/A"; tms=ms.get(tm,0) + mt=f"{tm} ({tms}점)" if tms>=15 else "특정 불가" + + # 문장별 점수 (탭2와 동일 기준) + sent_scores = [score_sentence(s)[0] for s in sents] + 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" ×{w}" if w else "" + dt=f"
{desc}
" if desc else "" + return f"
{l}{wt}{s}
{dt}
" + + mb="" + for mn in ["GPT","Claude","Gemini","Perplexity"]: + s=ms.get(mn,0); mc="#FF4444" if s>=40 else "#FF8800" if s>=20 else "#CCC" + mb+=f"
{mn}
{s}
" + + # LLM 섹션 + ls="" + if lr["score"]>=0: + lsc=lr["score"]; lc="#FF4444" if lsc>=70 else "#FF8800" if lsc>=50 else "#22AA44" + lr_rows="".join(f"
{mn}: {lr['detail'].get(mn,'—')}
" for _,mn in LLM_JUDGES) + ls=f"
🤖 LLM 교차검증 (평균 {lsc}%)
{lr_rows}
" + else: ls="
🤖 GROQ_API_KEY 미설정
" + + # 품질 + 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"
{l}
{s}
" + + # 판정 이유 설명 + reasons = [] + if sc["문체"] >= 70: reasons.append("격식체 종결어미가 대부분, AI형 접속사·상투표현 다수 감지") + elif sc["문체"] >= 50: reasons.append("격식체와 AI형 표현이 혼재") + if sc["통계"] >= 70: reasons.append("문장 길이가 매우 균일하여 기계적 패턴") + elif sc["통계"] >= 50: reasons.append("문장 길이 변동성이 낮음") + if sc["반복성"] >= 50: reasons.append("문두 접속사 반복, n-gram 패턴 감지") + if sc["구조"] >= 50: reasons.append("리스트/마크다운 등 구조적 서식 사용") + if tms >= 20: reasons.append(f"{tm} 모델의 특징적 표현 감지") + if not reasons: reasons.append("인간적 표현이 우세하며 AI 패턴이 약함") + reason_html = '
'.join(f"• {r}" for r in reasons) + + html=f"""
+ +
+
+
+
{fs}
+
/ 100점
+
+
+
{"🔴" if level=="ai_high" else "🟠" if level=="ai_medium" else "🟡" if level in ["ai_low","uncertain"] else "🟢"} {verdict}
+
+ AI 작성 가능성: {conf} | + 추정 모델: {mt}
+ {len(sents)}문장 중 AI의심 {ai_sents}문장, 인간추정 {human_sents}문장
- """) - - new_btn = gr.Button("🆕 새 대화 시작", variant="primary") - - with gr.Accordion("📜 대화 기록 (Memory)", open=False): - session_list = gr.Dataframe(headers=["ID", "제목", "시간"], interactive=False) - refresh_btn = gr.Button("🔄 새로고침", size="sm") - - with gr.Column(scale=3): - chatbot = gr.Chatbot(label="💬 AI 대화", height=500) - - with gr.Row(): - file_upload = gr.File( - label="📎 파일 첨부 (HWP/HWPX/PDF/이미지)", - file_types=[".jpg", ".jpeg", ".png", ".gif", ".webp", ".pdf", ".txt", ".md", ".hwp", ".hwpx"], - scale=1, - elem_classes=["upload-box"] - ) - msg_input = gr.Textbox( - placeholder="💭 메시지를 입력하세요... (파일을 업로드하면 AI가 내용을 읽고 분석합니다)", - lines=2, - show_label=False, - scale=4 - ) - - with gr.Row(): - submit_btn = gr.Button("🚀 전송", variant="primary", scale=3) - clear_btn = gr.Button("🗑️ 지우기", scale=1) - - # Tab 2: HWP 변환기 - with gr.Tab("📄 HWP 변환기"): - gr.HTML(""" -
-
🔄 HWP/HWPX 파일 변환기
-

- 한글 문서를 다양한 형식으로 변환합니다. AI가 문서를 읽고 텍스트를 추출합니다. -

+
- """) - - # Markdown 강조 박스 - gr.HTML(""" -
-
⭐ MARKDOWN 변환 추천! ⭐
-
-
- 🤖 - AI/LLM 최적화
- ChatGPT, Claude 등 AI에 바로 입력 가능 -
-
- 📝 - 범용 포맷
- GitHub, Notion, 블로그 등 어디서나 사용 -
-
- 🔍 - 구조 유지
- 제목, 목록, 표 등 문서 구조 보존 -
-
- - 가볍고 빠름
- 용량이 작고 처리 속도 빠름 -
-
- 🔄 - 변환 용이
- HTML, PDF, Word 등으로 재변환 가능 -
-
- ✏️ - 편집 간편
- 메모장으로도 바로 수정 가능 +
+
📋 판정 근거
+
{reason_html}
+
+
+ +
+
+
📊 AI 탐지 5축
+ {gb('① 통계',sc['통계'],'.25','문장 길이 균일도·엔트로피')} + {gb('② 문체',sc['문체'],'.30','격식체·접속사·상투표현')} + {gb('③ 반복',sc['반복성'],'.15','n-gram·문두 반복')} + {gb('④ 구조',sc['구조'],'.15','문단·리스트·서식')} + {gb('⑤ 지문',sc['지문'],'.15','GPT/Claude/Gemini 특징')} +
+
+
🔍 모델 지문
+ {mb} +
+
+ 📝 품질 + {qr['grade']} {qr['score']}점
+ {qgb('가독성',qs['가독성'])}{qgb('어휘',qs['어휘풍부도'])}{qgb('논리',qs['논리구조'])}{qgb('정확',qs['정확성'])}
- """) - - with gr.Row(): - with gr.Column(): - gr.HTML('
📤 파일 업로드
') - hwp_input = gr.File( - label="HWP/HWPX 파일 선택", - file_types=[".hwp", ".hwpx"], - elem_classes=["upload-box"] - ) - format_select = gr.Radio( - ["⭐ MARKDOWN (추천)", "TXT (텍스트)", "HTML", "ODT (OpenDocument)", "XML"], - value="⭐ MARKDOWN (추천)", - label="📋 변환 형식" - ) - convert_btn = gr.Button("🔄 변환하기", variant="primary", size="lg") - - with gr.Column(): - gr.HTML('
📥 변환 결과
') - status_out = gr.Textbox(label="상태", interactive=False) - file_out = gr.File(label="다운로드", elem_classes=["download-box"]) - - with gr.Accordion("📋 미리보기", open=False): - preview_out = gr.Textbox(lines=15, interactive=False) - - gr.HTML(""" -
- ℹ️ 안내: 변환 서비스는 개인용도로 사용시 어떠한 제약도 없습니다. * Special Thanks: june9713@gmail.com * +
+ {ls} +
""" + log=f"AI:{fs}점 [{verdict}] 신뢰:{conf} | 모델:{mt} | 품질:{qr['grade']}({qr['score']})\n축: 통계{sc['통계']} 문체{sc['문체']} 반복{sc['반복성']} 구조{sc['구조']} 지문{sc['지문']}" + return html, log + +# ═══════════════════════════════════════════════ +# 탭2: 하이라이트 (탭1과 동일 기준) +# ═══════════════════════════════════════════════ +def run_highlight(text): + if not text or len(text.strip())<30: return "
텍스트 필요
" + sents=split_sentences(text) + hl=[] + for s in sents: + sc, reasons = score_sentence(s) # ← 동일 함수 사용 + bg=f"rgba(255,68,68,{sc/150})" if sc>=50 else f"rgba(255,170,0,{sc/150})" if sc>=25 else f"rgba(34,170,68,{max(0.05,(100-sc)/300)})" + tt = ' | '.join(reasons) if reasons else '인간적 표현' + level = "AI" if sc >= 50 else "의심" if sc >= 25 else "인간" + hl.append(f'{s}') + + total_scores = [score_sentence(s)[0] for s in sents] + avg_sc = sum(total_scores)/len(total_scores) if total_scores else 0 + ai_cnt = sum(1 for s in total_scores if s >= 50) + human_cnt = sum(1 for s in total_scores if s < 25) + + return f"""
+
+
+ 🔴 AI ({ai_cnt}문장) + 🟠 의심 + 🟢 인간 ({human_cnt}문장) + 평균 {avg_sc:.0f}점 | 마우스 오버→근거 +
+
💡 점수 기준: 격식어미(25점) + AI접속사(20점) + 상투표현(15~25점) + 모델지문(10점) − 인간마커(30점)
+
+
{' '.join(hl)}
+
""" + +# ═══════════════════════════════════════════════ +# GRADIO UI +# ═══════════════════════════════════════════════ +SAMPLE_AI = """인공지능 기술은 현대 사회에서 매우 중요한 역할을 하고 있습니다. 특히 자연어 처리 분야에서의 발전은 눈부신 성과를 거두고 있습니다. 이러한 기술의 발전은 다양한 산업 분야에 긍정적인 영향을 미치고 있으며, 향후 더욱 발전할 것으로 예상됩니다. + +또한 생성형 AI의 등장으로 콘텐츠 제작 방식이 크게 변화하고 있습니다. 이를 통해 기업들은 효율적인 콘텐츠 생산이 가능해졌으며, 개인 사용자들도 다양한 창작 활동에 AI를 활용할 수 있게 되었습니다. 따라서 AI 리터러시의 중요성이 더욱 부각되고 있습니다. + +나아가 AI 윤리와 규제에 대한 논의도 활발히 진행되고 있습니다. 특히 AI가 생성한 콘텐츠의 저작권 문제는 중요한 의미를 가지며, 이에 대한 법적 프레임워크 구축이 필요합니다. 결과적으로 기술 발전과 함께 사회적 합의를 이루는 것이 중요합니다.""" + +SAMPLE_HUMAN = """아 진짜 요즘 AI 때문에 머리 아프다ㅋㅋㅋ 어제 chatgpt한테 레포트 써달라고 했는데 완전 교과서 같은 글만 써줘서 그냥 내가 다시 썼음;; + +근데 생각해보면 AI가 쓴 글이랑 사람이 쓴 글이 확실히 다르긴 해. 뭔가... 너무 깔끔하달까? 사람은 이렇게 횡설수설도 하고 맞춤법도 틀리고 그러잖아. + +교수님이 AI ��지기 돌린다고 해서 좀 무서운데 ㅠㅠ 나는 진짜 직접 쓴 건데 혹시 오탐 나면 어쩌지... 걱정된다 진심으로.""" + +# ═══════════════════════════════════════════════ +# 탭5: 문서 업로드 → 섹션별 히트맵 분석 + PDF 보고서 +# ═══════════════════════════════════════════════ +def run_document_analysis(file, progress=gr.Progress()): + """문서 파일 업로드 → 섹션별 AI 탐지 히트맵 + PDF 보고서 생성""" + if file is None: + return "
📄 파일을 업로드하세요 (PDF, DOCX, HWP, HWPX, TXT)
", "", None + + file_path = file.name if hasattr(file, 'name') else 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"
⚠️ {error}
", "", None + if not sections or not full_text or len(full_text.strip()) < 50: + return "
⚠️ 텍스트가 충분하지 않습니다 (50자 미만)
", "", 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 = quick_score(full_text) + quality = analyze_quality(full_text, sents_all, words_all, morphs_all) + + # LLM 교차검증 (전체) + progress(0.30, "LLM 교차검증...") + llm_result = llm_cross_check(full_text[:3000]) + if llm_result["score"] >= 0: + total_score, total_verdict, total_level = compute_verdict(total_axes, llm_result["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)}") + + # ═══ HTML 히트맵 보고서 ═══ + 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")) + + # 섹션별 히트맵 바 HTML + heatmap_cells = [] + for sr in section_results: + sidx = sr["idx"] + if sr["skipped"]: + heatmap_cells.append(f"
") + else: + sc, sbg = cm_map.get(sr.get("level", "uncertain"), ("#888", "#F5F5F5")) + ssc = sr["score"]; svd = sr["verdict"] + heatmap_cells.append(f"
") + heatmap_bar = f"
" + ''.join(heatmap_cells) + "
" + + # 섹션 상세 카드 + section_cards = [] + for sr in section_results: + if sr["skipped"]: continue + sc, sbg = cm_map.get(sr.get("level", "uncertain"), ("#888", "#F5F5F5")) + # 문장 하이라이트 (score_sentence 기반) + 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"{ss['text']} " + + 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"{k} {v}" + + section_cards.append(f""" +
+
+ 📑 섹션 {sr['idx']} + AI {sr['score']}점 · {sr['verdict']} +
+
{axes_html}
+
{sent_html}
+
""") + + # AI 비율 분포 + 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 교차검증 정보 + llm_info = "" + if llm_result["score"] >= 0: + llm_rows = ''.join(f"{mn}: {llm_result['detail'].get(mn,'—')}" for _, mn in LLM_JUDGES) + llm_info = f"
🤖 LLM 교차검증: 평균 {llm_result['score']}% | {llm_rows}
" + + html = f"""
+ +
+
+
+
📄 문서 AI 분석 보고서
+
{fname} · {ext} · {len(sections)}개 섹션 · {len(full_text)}자
+
+
+
{total_score}
+
{total_verdict}
+
+
+
+ + +
+
+
+
{ai_high}
+
AI 의심 높음
+
+
+
{ai_med}
+
AI 의심 중간
+
+
+
{ai_low}
+
인간 판정
+
+
+
{quality['grade']}
+
품질 등급
+
+
+ + +
+
🗺️ 섹션별 AI 히트맵 (빨강=AI의심, 초록=인간)
+ {heatmap_bar} +
+ AI 높음 + AI 중간 + 불���실 + 인간
- """) - - # Footer - gr.HTML(""" - + {llm_info} +
+ + +
+
📊 섹션별 상세 분석 ({len(valid_sections)}개)
+ {''.join(section_cards)}
- """) - - # ============== 이벤트 핸들러 ============== - def on_submit(msg, hist, f, sid): - if hist is None: - hist = [] - for r in chat_response(msg, hist, f, sid): - yield r[0], r[1], "", None - - submit_btn.click( - fn=on_submit, - inputs=[msg_input, chatbot, file_upload, session_state], - outputs=[chatbot, session_state, msg_input, file_upload] - ) - msg_input.submit( - fn=on_submit, - inputs=[msg_input, chatbot, file_upload, session_state], - outputs=[chatbot, session_state, msg_input, file_upload] - ) - - new_btn.click( - fn=lambda: ([], create_session(), None, ""), - outputs=[chatbot, session_state, file_upload, msg_input] - ) - clear_btn.click( - fn=lambda: ([], None, ""), - outputs=[chatbot, file_upload, msg_input] - ) - - def refresh(): - sessions = get_all_sessions() - return [[s["session_id"][:8], s["title"] or "제목없음", s["updated_at"][:16] if s["updated_at"] else ""] for s in sessions] - - refresh_btn.click(fn=refresh, outputs=[session_list]) - - def select_session(evt: gr.SelectData, data): - if evt.index[0] < len(data): - for s in get_all_sessions(): - if s["session_id"].startswith(data[evt.index[0]][0]): - return load_session(s["session_id"]) - return [], "" - - session_list.select(fn=select_session, inputs=[session_list], outputs=[chatbot, session_state]) - - # 변환 버튼 이벤트 (수정됨) - convert_btn.click( - fn=convert_hwp, - inputs=[hwp_input, format_select], - outputs=[file_out, status_out, preview_out] - ) - - demo.load(fn=refresh, outputs=[session_list]) + + +
+ AI Detector v4.0 + {now} · 5축 앙상블 + LLM 교차검증 +
+
""" + + # ═══ PDF 보고서 생성 ═══ + 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"{sr['idx']}{sr['text'][:80]}...{sr['score']}{sr['verdict']}" + + ax_rows = ''.join(f"{k}{v}/100" for k, v in axes.items()) + + html_content = f""" + +

📄 AI 글 판별 보고서

+

파일: {fname} | 생성: {now} | 엔진: AI Detector v4.0

+ +

종합 결과

+
{score}점
+ {verdict} +

품질: {quality['grade']} ({quality['score']}점)

+ +

5축 분석

+ {ax_rows}
점수
+ +

섹션별 분석 ({len([s for s in sections if not s['skipped']])}개)

+ + + {sec_rows} +
No내용 (발췌)AI 점수판정
+ + + """ + + # 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 + + +with gr.Blocks(title="AI 글 판별기 v4.0") as demo: + gr.Markdown("# 🔎 AI 글 판별기 v4.0\n**5축 AI 탐지 · 품질 측정 · LLM 교차검증 · Adversarial Humanizer v2 · 표절 검사 · 문서 분석**") + 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("🔄 AI→인간 변환"): + gr.Markdown("**Adversarial Humanizer v2** — 탐지기와 변환기의 자기대전 루프. 최대 3라운드 반복하며 AI 점수를 최저로 끌어내립니다.") + ihm=gr.Textbox(label="원본 (AI 텍스트)",lines=8) + with gr.Row(): + bhm=gr.Button("🔄 자동 변환 + 검증",variant="primary",size="lg"); bhs=gr.Button("📝 AI 예시",size="sm") + ohm=gr.Textbox(label="✅ 변환 결과",lines=8) + och=gr.Textbox(label="📋 변환 내역",lines=5,elem_classes=["mono"]) + ocp=gr.HTML(); oex=gr.Textbox(visible=False) + bhm.click(run_humanizer,[ihm],[ohm,och,ocp,oex],api_name="run_humanizer"); bhs.click(lambda:SAMPLE_AI,outputs=[ihm]) + 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("**PDF · DOCX · HWP · HWPX · TXT** 파일을 업로드하면 섹션별 AI 히트맵 + 보고서를 생성합니다.") + doc_file = gr.File(label="📁 문서 업로드", file_types=[".pdf",".docx",".hwp",".hwpx",".txt",".md"]) + btn_doc = gr.Button("📊 문서 AI 분석", variant="primary", size="lg") + doc_html = gr.HTML() + doc_log = gr.Textbox(label="분석 로그", lines=4, elem_classes=["mono"]) + doc_pdf = gr.File(label="📥 보고서 다운로드") + btn_doc.click(run_document_analysis, [doc_file], [doc_html, doc_log, doc_pdf], api_name="run_document") + with gr.Tab("📖 설명"): + gr.Markdown(""" +### 아키텍처 v4.0 +- **탐지 5축:** 통계(25%)·문체(30%)·반복(15%)·구조(15%)·지문(15%) +- **품질 6항목:** 가독성·어휘·논리·정확성·표현·정보밀도 +- **LLM 교차검증:** GPT-OSS-120B·Qwen3-32B·Kimi-K2 (GROQ) + +### 탭1·탭2 일관성 +- `score_sentence()` 통합 함수로 동일 기준 판정 +- 격식어미(25점) + AI접속사(20점) + 상투표현(15~25점) + 모델지문(10점) − 인간마커(30점) + +### AI→인간 변환 (Adversarial v2) +1. **자기대전 루프**: 변환→탐지→재변환 최대 3라운드 +2. **라운드별**: 규칙 / LLM / LLM+규칙 3후보 경쟁 +3. **자동 종료**: 목표 점수(25점) 이하 달성 시 종료 +4. **피드백**: 이전 라운드 최적 결과를 다음 라운드 입력으로 + +### 표절 검사 +- **Brave Search**: 병렬 20개 동시 웹검색 +- **학술 DB**: KCI(한국학술지인용색인), RISS(학술연구정보), arXiv +- **Gemini**: Google Search Grounding +- **보고서**: CopyKiller 스타일 — 유사도%, 출처표, 문장별 하이라이트 + +### 📄 문서 분석 (NEW) +- **지원 형식**: PDF · DOCX · HWP · HWPX · TXT · MD +- **섹션별 히트맵**: 페이지/문단별 AI 확률 색상 시각화 +- **문장별 하이라이트**: 각 문장 AI 확률 score_sentence() 기반 +- **PDF 보고서**: 종합결과 + 5축 분석 + 섹션별 상세 다운로드 + +### 환경변수 +- `GROQ_API_KEY` — LLM 교차검증 + AI→인간 리라이팅 +- `GEMINI_API_KEY` — 표절 검사 (Google Search Grounding) +- `BRAVE_API_KEY` — 표절 검사 (Brave Search 병렬) + """) + +# ═══ 정적 파일 준비 ═══ +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") + +# ═══ FastAPI — 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")) + # index.html 없으면 Gradio UI로 리다이렉트 + from fastapi.responses import RedirectResponse + return RedirectResponse("/gradio/") + +# Gradio를 /gradio 경로에 마운트 — API는 /gradio/gradio_api/call/... 에서 작동 +app = gr.mount_gradio_app(server, demo, path="/gradio", allowed_paths=["static"]) if __name__ == "__main__": - demo.launch(ssr_mode=False, css=COMIC_CSS, head=CUSTOM_HEAD) \ No newline at end of file + import uvicorn + uvicorn.run(app, host="0.0.0.0", port=7860) \ No newline at end of file