aibook / app.py
seawolf2357's picture
Update app.py
e16fe27 verified
from fastapi import FastAPI, BackgroundTasks, Request
from fastapi.responses import HTMLResponse, JSONResponse, Response
from fastapi.staticfiles import StaticFiles
import pathlib, os, uvicorn, base64, json, uuid, time
from typing import Dict, List, Any, Optional
import asyncio
import logging
import threading
import concurrent.futures
import requests
import fitz
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
logger = logging.getLogger(__name__)
BASE = pathlib.Path(__file__).parent
app = FastAPI()
app.mount("/static", StaticFiles(directory=BASE), name="static")
CACHE_DIR = BASE / "cache"
if not CACHE_DIR.exists():
CACHE_DIR.mkdir(parents=True)
EMBEDDING_DIR = BASE / "embeddings"
if not EMBEDDING_DIR.exists():
EMBEDDING_DIR.mkdir(parents=True)
# Fireworks AI API μ„€μ • (VLM λͺ¨λΈ)
FIREWORKS_API_KEY = os.getenv("FIREWORKS_API", "").strip() # μ€„λ°”κΏˆ/곡백 제거
FIREWORKS_API_URL = "https://api.fireworks.ai/inference/v1/chat/completions"
FIREWORKS_VLM_MODEL = "accounts/fireworks/models/qwen3-vl-235b-a22b-instruct"
HAS_VALID_API_KEY = bool(FIREWORKS_API_KEY)
if HAS_VALID_API_KEY:
logger.info("Fireworks AI VLM API ν‚€ μ„€μ • μ™„λ£Œ")
else:
logger.warning("μœ νš¨ν•œ Fireworks AI API ν‚€κ°€ μ—†μŠ΅λ‹ˆλ‹€. AI κΈ°λŠ₯이 μ œν•œλ©λ‹ˆλ‹€.")
# κ³ μ • PDF 파일 경둜
PROMPT_PDF_PATH = BASE / "prompt.pdf"
PROMPT_PDF_ID = "prompt_pdf_main"
# 닀쀑 PDF 지원
PDF_FILES = {
"prompt": {
"path": BASE / "prompt.pdf",
"id": "prompt_pdf_main",
"name": "μƒ˜ν”Œ-ν”„λ‘¬ν”„νŠΈλΆ"
},
"ktx": {
"path": BASE / "ktx2512.pdf",
"id": "ktx_pdf_main",
"name": "μƒ˜ν”Œ-μ½”λ ˆμΌμž‘μ§€"
}
}
# ν˜„μž¬ μ„ νƒλœ PDF (κΈ°λ³Έκ°’: prompt) - μ„œλ²„ μ‹œμž‘ μ‹œ 항상 prompt둜 μ΄ˆκΈ°ν™”
current_pdf_key = "prompt"
pdf_cache: Dict[str, Dict[str, Any]] = {}
cache_locks = {}
pdf_embeddings: Dict[str, Dict[str, Any]] = {}
# VLM 뢄석 μƒνƒœ 좔적 (λ©”λͺ¨λ¦¬)
analysis_status: Dict[str, Dict[str, Any]] = {}
def get_cache_path(pdf_name: str):
return CACHE_DIR / f"{pdf_name}_cache.json"
def get_embedding_path(pdf_id: str):
return EMBEDDING_DIR / f"{pdf_id}_embedding.json"
def get_analysis_cache_path(pdf_id: str):
"""VLM 뢄석 κ²°κ³Ό μΊμ‹œ 경둜"""
return EMBEDDING_DIR / f"{pdf_id}_vlm_analysis.json"
def load_analysis_cache(pdf_id: str) -> Optional[Dict[str, Any]]:
"""VLM 뢄석 μΊμ‹œ λ‘œλ“œ"""
cache_path = get_analysis_cache_path(pdf_id)
if cache_path.exists():
try:
with open(cache_path, "r", encoding="utf-8") as f:
data = json.load(f)
logger.info(f"VLM 뢄석 μΊμ‹œ λ‘œλ“œ μ™„λ£Œ: {pdf_id}")
return data
except Exception as e:
logger.error(f"뢄석 μΊμ‹œ λ‘œλ“œ 였λ₯˜: {e}")
return None
def save_analysis_cache(pdf_id: str, analysis_data: Dict[str, Any]):
"""VLM 뢄석 κ²°κ³Ό μΊμ‹œ μ €μž₯"""
cache_path = get_analysis_cache_path(pdf_id)
try:
with open(cache_path, "w", encoding="utf-8") as f:
json.dump(analysis_data, f, ensure_ascii=False, indent=2)
logger.info(f"VLM 뢄석 μΊμ‹œ μ €μž₯ μ™„λ£Œ: {pdf_id}")
except Exception as e:
logger.error(f"뢄석 μΊμ‹œ μ €μž₯ 였λ₯˜: {e}")
def get_pdf_page_as_base64(pdf_path: str, page_num: int, scale: float = 1.0) -> str:
"""PDF νŽ˜μ΄μ§€λ₯Ό base64 μ΄λ―Έμ§€λ‘œ λ³€ν™˜"""
try:
doc = fitz.open(pdf_path)
if page_num >= doc.page_count:
doc.close()
return None
page = doc[page_num]
pix = page.get_pixmap(matrix=fitz.Matrix(scale, scale))
img_data = pix.tobytes("jpeg", 85)
b64_img = base64.b64encode(img_data).decode('utf-8')
doc.close()
return b64_img
except Exception as e:
logger.error(f"PDF νŽ˜μ΄μ§€ 이미지 λ³€ν™˜ 였λ₯˜: {e}")
return None
def get_pdf_pages_as_base64(pdf_path: str, start_page: int = 0, max_pages: int = 10, scale: float = 0.7) -> List[Dict[str, Any]]:
"""PDF μ—¬λŸ¬ νŽ˜μ΄μ§€λ₯Ό base64 이미지 리슀트둜 λ³€ν™˜ (배치 처리용)"""
try:
doc = fitz.open(pdf_path)
total_pages = doc.page_count
end_page = min(start_page + max_pages, total_pages)
images = []
for page_num in range(start_page, end_page):
page = doc[page_num]
pix = page.get_pixmap(matrix=fitz.Matrix(scale, scale))
img_data = pix.tobytes("jpeg", 75)
b64_img = base64.b64encode(img_data).decode('utf-8')
images.append({
"page": page_num + 1,
"image_base64": b64_img
})
doc.close()
logger.info(f"PDF {start_page+1}~{end_page}/{total_pages}νŽ˜μ΄μ§€ 이미지 λ³€ν™˜ μ™„λ£Œ")
return images, total_pages
except Exception as e:
logger.error(f"PDF νŽ˜μ΄μ§€λ“€ 이미지 λ³€ν™˜ 였λ₯˜: {e}")
return [], 0
def call_fireworks_vlm_api(messages: List[Dict], max_tokens: int = 4096, temperature: float = 0.6) -> str:
"""Fireworks AI VLM API 호좜 (이미지 뢄석 지원)"""
if not HAS_VALID_API_KEY:
raise Exception("API ν‚€κ°€ μ„€μ •λ˜μ§€ μ•Šμ•˜μŠ΅λ‹ˆλ‹€.")
payload = {
"model": FIREWORKS_VLM_MODEL,
"max_tokens": max_tokens,
"top_p": 1,
"top_k": 40,
"presence_penalty": 0,
"frequency_penalty": 0,
"temperature": temperature,
"messages": messages
}
headers = {
"Accept": "application/json",
"Content-Type": "application/json",
"Authorization": f"Bearer {FIREWORKS_API_KEY}"
}
response = requests.post(FIREWORKS_API_URL, headers=headers, data=json.dumps(payload), timeout=180)
if response.status_code != 200:
raise Exception(f"API 였λ₯˜: {response.status_code} - {response.text}")
result = response.json()
return result["choices"][0]["message"]["content"]
def analyze_batch_pages_sync(pdf_path: str, start_page: int, batch_size: int = 5) -> str:
"""배치 νŽ˜μ΄μ§€ 뢄석 (동기)"""
page_images, total_pages = get_pdf_pages_as_base64(pdf_path, start_page, batch_size, scale=0.6)
if not page_images:
return ""
content_parts = []
for img_data in page_images:
content_parts.append({
"type": "image_url",
"image_url": {
"url": f"data:image/jpeg;base64,{img_data['image_base64']}"
}
})
page_range = f"{start_page + 1}~{start_page + len(page_images)}"
content_parts.append({
"type": "text",
"text": f"""μœ„ 이미지듀은 PDF λ¬Έμ„œμ˜ {page_range}νŽ˜μ΄μ§€μž…λ‹ˆλ‹€.
각 νŽ˜μ΄μ§€μ˜ λ‚΄μš©μ„ μƒμ„Έν•˜κ²Œ λΆ„μ„ν•˜μ—¬ ν…μŠ€νŠΈλ‘œ μΆ”μΆœν•΄μ£Όμ„Έμš”.
- λͺ¨λ“  ν…μŠ€νŠΈ λ‚΄μš©μ„ 빠짐없이 μΆ”μΆœ
- ν‘œ, 차트, κ·Έλž˜ν”„κ°€ 있으면 λ‚΄μš© μ„€λͺ…
- 이미지가 있으면 μ„€λͺ…
- νŽ˜μ΄μ§€λ³„λ‘œ κ΅¬λΆ„ν•˜μ—¬ μž‘μ„±
ν•œκ΅­μ–΄λ‘œ μž‘μ„±ν•΄μ£Όμ„Έμš”."""
})
messages = [{"role": "user", "content": content_parts}]
return call_fireworks_vlm_api(messages, max_tokens=4096, temperature=0.3)
async def analyze_pdf_with_vlm_batched(pdf_id: str, force_refresh: bool = False) -> Dict[str, Any]:
"""VLM으둜 PDF 배치 뢄석 ν›„ μΊμ‹œμ— μ €μž₯"""
global analysis_status
# 이미 뢄석 쀑인지 확인
if pdf_id in analysis_status and analysis_status[pdf_id].get("status") == "analyzing":
logger.info(f"PDF {pdf_id} 이미 뢄석 쀑...")
return {"status": "analyzing", "progress": analysis_status[pdf_id].get("progress", 0)}
# μΊμ‹œ 확인
if not force_refresh:
cached = load_analysis_cache(pdf_id)
if cached:
analysis_status[pdf_id] = {"status": "completed", "progress": 100}
return cached
pdf_path = str(PROMPT_PDF_PATH)
if not PROMPT_PDF_PATH.exists():
analysis_status[pdf_id] = {"status": "error", "error": "PDF 파일 μ—†μŒ"}
return {"error": "PDF νŒŒμΌμ„ 찾을 수 μ—†μŠ΅λ‹ˆλ‹€."}
if not HAS_VALID_API_KEY:
analysis_status[pdf_id] = {"status": "error", "error": "API ν‚€ μ—†μŒ"}
return {"error": "API ν‚€κ°€ μ„€μ •λ˜μ§€ μ•Šμ•˜μŠ΅λ‹ˆλ‹€."}
# 뢄석 μ‹œμž‘
analysis_status[pdf_id] = {"status": "analyzing", "progress": 0, "started_at": time.time()}
try:
# PDF 총 νŽ˜μ΄μ§€ 수 확인
doc = fitz.open(pdf_path)
total_pages = doc.page_count
doc.close()
logger.info(f"PDF 뢄석 μ‹œμž‘: 총 {total_pages}νŽ˜μ΄μ§€")
# 배치둜 λ‚˜λˆ μ„œ 뢄석 (5νŽ˜μ΄μ§€μ”©)
batch_size = 5
all_analyses = []
for start_page in range(0, min(total_pages, 25), batch_size): # μ΅œλŒ€ 25νŽ˜μ΄μ§€
try:
progress = int((start_page / min(total_pages, 25)) * 100)
analysis_status[pdf_id]["progress"] = progress
logger.info(f"배치 뢄석 쀑: {start_page + 1}νŽ˜μ΄μ§€λΆ€ν„° (μ§„ν–‰λ₯ : {progress}%)")
# 동기 ν•¨μˆ˜λ₯Ό 별도 μŠ€λ ˆλ“œμ—μ„œ μ‹€ν–‰
loop = asyncio.get_event_loop()
batch_result = await loop.run_in_executor(
None,
analyze_batch_pages_sync,
pdf_path,
start_page,
batch_size
)
if batch_result:
all_analyses.append(f"### νŽ˜μ΄μ§€ {start_page + 1}~{min(start_page + batch_size, total_pages)}\n{batch_result}")
# API 레이트 리밋 λ°©μ§€
await asyncio.sleep(2)
except Exception as batch_error:
logger.error(f"배치 {start_page} 뢄석 였λ₯˜: {batch_error}")
all_analyses.append(f"### νŽ˜μ΄μ§€ {start_page + 1}~{min(start_page + batch_size, total_pages)}\n[뢄석 μ‹€νŒ¨: {str(batch_error)}]")
# 전체 뢄석 κ²°κ³Ό ν•©μΉ˜κΈ°
combined_analysis = "\n\n".join(all_analyses)
# μ„±κ³΅ν•œ 뢄석이 μžˆλŠ”μ§€ 확인
successful_analyses = [a for a in all_analyses if "[뢄석 μ‹€νŒ¨:" not in a]
if not successful_analyses:
logger.error("λͺ¨λ“  배치 뢄석 μ‹€νŒ¨")
analysis_status[pdf_id] = {"status": "error", "error": "λͺ¨λ“  νŽ˜μ΄μ§€ 뢄석 μ‹€νŒ¨"}
return {"error": "PDF 뢄석에 μ‹€νŒ¨ν–ˆμŠ΅λ‹ˆλ‹€. API ν‚€λ₯Ό ν™•μΈν•΄μ£Όμ„Έμš”."}
# μš”μ•½ 생성
summary = ""
if combined_analysis:
try:
summary_messages = [
{
"role": "system",
"content": "λ‹€μŒ PDF 뢄석 λ‚΄μš©μ„ 500자 μ΄λ‚΄λ‘œ μš”μ•½ν•΄μ£Όμ„Έμš”. 핡심 λ‚΄μš©κ³Ό μ£Όμš” ν‚€μ›Œλ“œλ₯Ό ν¬ν•¨ν•΄μ£Όμ„Έμš”."
},
{
"role": "user",
"content": combined_analysis[:8000] # 토큰 μ œν•œ
}
]
summary = call_fireworks_vlm_api(summary_messages, max_tokens=1024, temperature=0.5)
except Exception as sum_err:
logger.error(f"μš”μ•½ 생성 였λ₯˜: {sum_err}")
summary = combined_analysis[:500] + "..."
analysis_data = {
"pdf_id": pdf_id,
"total_pages": total_pages,
"analyzed_pages": min(total_pages, 25),
"analysis": combined_analysis,
"summary": summary,
"created_at": time.time()
}
# μΊμ‹œμ— μ €μž₯
save_analysis_cache(pdf_id, analysis_data)
analysis_status[pdf_id] = {"status": "completed", "progress": 100}
logger.info(f"PDF 뢄석 μ™„λ£Œ: {pdf_id}")
return analysis_data
except Exception as e:
logger.error(f"VLM PDF 뢄석 였λ₯˜: {e}")
analysis_status[pdf_id] = {"status": "error", "error": str(e)}
return {"error": str(e)}
async def run_initial_analysis():
"""μ„œλ²„ μ‹œμž‘ μ‹œ 초기 뢄석 μ‹€ν–‰"""
logger.info("초기 PDF 뢄석 μ‹œμž‘...")
try:
result = await analyze_pdf_with_vlm_batched(PROMPT_PDF_ID)
if "error" in result:
logger.error(f"초기 뢄석 μ‹€νŒ¨: {result['error']}")
else:
logger.info("초기 PDF 뢄석 μ™„λ£Œ!")
except Exception as e:
logger.error(f"초기 뢄석 μ˜ˆμ™Έ: {e}")
def extract_pdf_text(pdf_path: str) -> List[Dict[str, Any]]:
try:
doc = fitz.open(pdf_path)
chunks = []
for page_num in range(len(doc)):
page = doc[page_num]
text = page.get_text("text")
if not text.strip():
text = page.get_text("blocks")
if text:
text = "\n".join([block[4] for block in text if len(block) > 4 and isinstance(block[4], str)])
if not text.strip():
text = f"[νŽ˜μ΄μ§€ {page_num + 1} - 이미지 기반 νŽ˜μ΄μ§€]"
chunks.append({
"page": page_num + 1,
"text": text.strip() if text.strip() else f"[νŽ˜μ΄μ§€ {page_num + 1}]",
"chunk_id": f"page_{page_num + 1}"
})
doc.close()
return chunks
except Exception as e:
logger.error(f"PDF ν…μŠ€νŠΈ μΆ”μΆœ 였λ₯˜: {e}")
return []
async def query_pdf(pdf_id: str, query: str) -> Dict[str, Any]:
"""μΊμ‹œλœ VLM 뢄석 κ²°κ³Όλ₯Ό 기반으둜 μ§ˆμ˜μ‘λ‹΅"""
try:
if not HAS_VALID_API_KEY:
return {
"error": "Fireworks AI API ν‚€κ°€ μ„€μ •λ˜μ§€ μ•Šμ•˜μŠ΅λ‹ˆλ‹€.",
"answer": "μ£„μ†‘ν•©λ‹ˆλ‹€. ν˜„μž¬ AI κΈ°λŠ₯이 λΉ„ν™œμ„±ν™”λ˜μ–΄ μžˆμ–΄ μ§ˆλ¬Έμ— λ‹΅λ³€ν•  수 μ—†μŠ΅λ‹ˆλ‹€."
}
# μΊμ‹œλœ 뢄석 κ²°κ³Ό 확인
analysis_data = load_analysis_cache(pdf_id)
if not analysis_data:
# 뢄석 μƒνƒœ 확인
if pdf_id in analysis_status:
status = analysis_status[pdf_id].get("status")
if status == "analyzing":
progress = analysis_status[pdf_id].get("progress", 0)
return {"error": f"뢄석 μ§„ν–‰ 쀑 ({progress}%)", "answer": f"PDF 뢄석이 μ§„ν–‰ μ€‘μž…λ‹ˆλ‹€ ({progress}%). μž μ‹œλ§Œ κΈ°λ‹€λ €μ£Όμ„Έμš”."}
elif status == "error":
return {"error": "뢄석 μ‹€νŒ¨", "answer": f"PDF 뢄석에 μ‹€νŒ¨ν–ˆμŠ΅λ‹ˆλ‹€: {analysis_status[pdf_id].get('error', 'μ•Œ 수 μ—†λŠ” 였λ₯˜')}"}
return {"error": "뢄석 데이터 μ—†μŒ", "answer": "PDFκ°€ 아직 λΆ„μ„λ˜μ§€ μ•Šμ•˜μŠ΅λ‹ˆλ‹€. μž μ‹œ ν›„ λ‹€μ‹œ μ‹œλ„ν•΄μ£Όμ„Έμš”."}
analysis_text = analysis_data.get("analysis", "")
total_pages = analysis_data.get("total_pages", 0)
if not analysis_text:
return {"error": "뢄석 데이터 μ—†μŒ", "answer": "PDF 뢄석 데이터λ₯Ό 찾을 수 μ—†μŠ΅λ‹ˆλ‹€."}
# μΊμ‹œλœ 뢄석 λ‚΄μš©μ„ 기반으둜 μ§ˆλ¬Έμ— λ‹΅λ³€
messages = [
{
"role": "system",
"content": f"""당신은 PDF λ¬Έμ„œ 뢄석 μ „λ¬Έκ°€μž…λ‹ˆλ‹€.
μ•„λž˜λŠ” {total_pages}νŽ˜μ΄μ§€ PDF λ¬Έμ„œλ₯Ό VLM으둜 λΆ„μ„ν•œ κ²°κ³Όμž…λ‹ˆλ‹€.
이 뢄석 λ‚΄μš©μ„ 기반으둜 μ‚¬μš©μžμ˜ μ§ˆλ¬Έμ— μ •ν™•ν•˜κ³  μΉœμ ˆν•˜κ²Œ ν•œκ΅­μ–΄λ‘œ λ‹΅λ³€ν•΄μ£Όμ„Έμš”.
λ‹΅λ³€ν•  λ•Œ κ΄€λ ¨ νŽ˜μ΄μ§€ λ²ˆν˜Έκ°€ 있으면 μ–ΈκΈ‰ν•΄μ£Όμ„Έμš”.
뢄석 λ‚΄μš©μ— μ—†λŠ” μ •λ³΄λŠ” "ν•΄λ‹Ή 정보λ₯Ό 찾을 수 μ—†μŠ΅λ‹ˆλ‹€"라고 μ†”μ§νžˆ λ‹΅ν•΄μ£Όμ„Έμš”.
=== PDF 뢄석 κ²°κ³Ό ===
{analysis_text[:12000]}
=================="""
},
{
"role": "user",
"content": query
}
]
try:
answer = call_fireworks_vlm_api(messages, max_tokens=4096, temperature=0.6)
return {
"answer": answer,
"pdf_id": pdf_id,
"query": query
}
except Exception as api_error:
logger.error(f"Fireworks API 호좜 였λ₯˜: {api_error}")
error_message = str(api_error)
return {"error": f"AI 였λ₯˜: {error_message}", "answer": "처리 쀑 였λ₯˜κ°€ λ°œμƒν–ˆμŠ΅λ‹ˆλ‹€. μž μ‹œ ν›„ λ‹€μ‹œ μ‹œλ„ν•΄μ£Όμ„Έμš”."}
except Exception as e:
logger.error(f"μ§ˆμ˜μ‘λ‹΅ 처리 였λ₯˜: {e}")
return {"error": str(e), "answer": "처리 쀑 였λ₯˜κ°€ λ°œμƒν–ˆμŠ΅λ‹ˆλ‹€."}
async def summarize_pdf(pdf_id: str) -> Dict[str, Any]:
"""μΊμ‹œλœ VLM 뢄석 κ²°κ³Όμ—μ„œ μš”μ•½ μΆ”μΆœ"""
try:
if not HAS_VALID_API_KEY:
return {
"error": "Fireworks AI API ν‚€κ°€ μ„€μ •λ˜μ§€ μ•Šμ•˜μŠ΅λ‹ˆλ‹€.",
"summary": "API ν‚€κ°€ μ—†μ–΄ μš”μ•½μ„ 생성할 수 μ—†μŠ΅λ‹ˆλ‹€."
}
# μΊμ‹œλœ 뢄석 κ²°κ³Ό 확인
analysis_data = load_analysis_cache(pdf_id)
if not analysis_data:
# 뢄석 μƒνƒœ 확인
if pdf_id in analysis_status:
status = analysis_status[pdf_id].get("status")
if status == "analyzing":
progress = analysis_status[pdf_id].get("progress", 0)
return {"error": f"뢄석 μ§„ν–‰ 쀑", "summary": f"PDF 뢄석이 μ§„ν–‰ μ€‘μž…λ‹ˆλ‹€ ({progress}%). μž μ‹œλ§Œ κΈ°λ‹€λ €μ£Όμ„Έμš”."}
elif status == "error":
return {"error": "뢄석 μ‹€νŒ¨", "summary": f"PDF 뢄석에 μ‹€νŒ¨ν–ˆμŠ΅λ‹ˆλ‹€."}
return {"error": "뢄석 데이터 μ—†μŒ", "summary": "PDFκ°€ 아직 λΆ„μ„λ˜μ§€ μ•Šμ•˜μŠ΅λ‹ˆλ‹€."}
summary = analysis_data.get("summary", "")
total_pages = analysis_data.get("total_pages", 0)
analyzed_pages = analysis_data.get("analyzed_pages", total_pages)
if summary:
return {
"summary": summary,
"pdf_id": pdf_id,
"total_pages": total_pages,
"analyzed_pages": analyzed_pages
}
# μš”μ•½μ΄ μ—†μœΌλ©΄ 뢄석 λ‚΄μš©μ—μ„œ μΆ”μΆœ
analysis_text = analysis_data.get("analysis", "")
if analysis_text:
return {
"summary": analysis_text[:500] + "...",
"pdf_id": pdf_id,
"total_pages": total_pages
}
return {"error": "μš”μ•½ μ—†μŒ", "summary": "μš”μ•½μ„ 생성할 수 μ—†μŠ΅λ‹ˆλ‹€."}
except Exception as e:
logger.error(f"PDF μš”μ•½ 생성 였λ₯˜: {e}")
return {
"error": str(e),
"summary": "PDF μš”μ•½ 쀑 였λ₯˜κ°€ λ°œμƒν–ˆμŠ΅λ‹ˆλ‹€."
}
async def cache_pdf(pdf_path: str):
try:
pdf_file = pathlib.Path(pdf_path)
pdf_name = pdf_file.stem
if pdf_name not in cache_locks:
cache_locks[pdf_name] = threading.Lock()
if pdf_name in pdf_cache and pdf_cache[pdf_name].get("status") in ["processing", "completed"]:
logger.info(f"PDF {pdf_name} 이미 캐싱 μ™„λ£Œ λ˜λŠ” μ§„ν–‰ 쀑")
return
with cache_locks[pdf_name]:
if pdf_name in pdf_cache and pdf_cache[pdf_name].get("status") in ["processing", "completed"]:
return
pdf_cache[pdf_name] = {"status": "processing", "progress": 0, "pages": []}
cache_path = get_cache_path(pdf_name)
if cache_path.exists():
try:
with open(cache_path, "r") as cache_file:
cached_data = json.load(cache_file)
if cached_data.get("status") == "completed" and cached_data.get("pages"):
pdf_cache[pdf_name] = cached_data
pdf_cache[pdf_name]["status"] = "completed"
logger.info(f"μΊμ‹œ νŒŒμΌμ—μ„œ {pdf_name} λ‘œλ“œ μ™„λ£Œ")
return
except Exception as e:
logger.error(f"μΊμ‹œ 파일 λ‘œλ“œ μ‹€νŒ¨: {e}")
doc = fitz.open(pdf_path)
total_pages = doc.page_count
if total_pages > 0:
page = doc[0]
pix_thumb = page.get_pixmap(matrix=fitz.Matrix(0.2, 0.2))
thumb_data = pix_thumb.tobytes("png")
b64_thumb = base64.b64encode(thumb_data).decode('utf-8')
thumb_src = f"data:image/png;base64,{b64_thumb}"
pdf_cache[pdf_name]["pages"] = [{"thumb": thumb_src, "src": ""}]
pdf_cache[pdf_name]["progress"] = 1
pdf_cache[pdf_name]["total_pages"] = total_pages
scale_factor = 1.0
jpeg_quality = 80
def process_page(page_num):
try:
page = doc[page_num]
pix = page.get_pixmap(matrix=fitz.Matrix(scale_factor, scale_factor))
img_data = pix.tobytes("jpeg", jpeg_quality)
b64_img = base64.b64encode(img_data).decode('utf-8')
img_src = f"data:image/jpeg;base64,{b64_img}"
thumb_src = "" if page_num > 0 else pdf_cache[pdf_name]["pages"][0]["thumb"]
return {
"page_num": page_num,
"src": img_src,
"thumb": thumb_src
}
except Exception as e:
logger.error(f"νŽ˜μ΄μ§€ {page_num} 처리 였λ₯˜: {e}")
return {
"page_num": page_num,
"src": "",
"thumb": "",
"error": str(e)
}
pages = [None] * total_pages
processed_count = 0
batch_size = 5
for batch_start in range(0, total_pages, batch_size):
batch_end = min(batch_start + batch_size, total_pages)
current_batch = list(range(batch_start, batch_end))
with concurrent.futures.ThreadPoolExecutor(max_workers=min(5, batch_size)) as executor:
batch_results = list(executor.map(process_page, current_batch))
for result in batch_results:
page_num = result["page_num"]
pages[page_num] = {
"src": result["src"],
"thumb": result["thumb"]
}
processed_count += 1
progress = round(processed_count / total_pages * 100)
pdf_cache[pdf_name]["progress"] = progress
pdf_cache[pdf_name]["pages"] = pages
pdf_cache[pdf_name] = {
"status": "completed",
"progress": 100,
"pages": pages,
"total_pages": total_pages
}
try:
with open(cache_path, "w") as cache_file:
json.dump(pdf_cache[pdf_name], cache_file)
logger.info(f"PDF {pdf_name} 캐싱 μ™„λ£Œ, {total_pages}νŽ˜μ΄μ§€")
except Exception as e:
logger.error(f"μ΅œμ’… μΊμ‹œ μ €μž₯ μ‹€νŒ¨: {e}")
except Exception as e:
import traceback
logger.error(f"PDF 캐싱 였λ₯˜: {str(e)}\n{traceback.format_exc()}")
if 'pdf_name' in locals() and pdf_name in pdf_cache:
pdf_cache[pdf_name]["status"] = "error"
pdf_cache[pdf_name]["error"] = str(e)
@app.on_event("startup")
async def startup_event():
global current_pdf_key
# μ„œλ²„ μ‹œμž‘ μ‹œ 항상 prompt둜 μ΄ˆκΈ°ν™”
current_pdf_key = "prompt"
logger.info(f"μ„œλ²„ μ‹œμž‘ - κΈ°λ³Έ PDF: {current_pdf_key}")
if PROMPT_PDF_PATH.exists():
logger.info(f"prompt.pdf 파일 발견: {PROMPT_PDF_PATH}")
# ν”Œλ¦½λΆ 캐싱
asyncio.create_task(cache_pdf(str(PROMPT_PDF_PATH)))
# VLM 뢄석 - μ—λŸ¬ 핸듀링 포함
asyncio.create_task(run_initial_analysis())
else:
logger.warning(f"prompt.pdf νŒŒμΌμ„ 찾을 수 μ—†μŠ΅λ‹ˆλ‹€: {PROMPT_PDF_PATH}")
@app.get("/api/pdf-list")
async def get_pdf_list():
"""μ‚¬μš© κ°€λŠ₯ν•œ PDF λͺ©λ‘ λ°˜ν™˜"""
global current_pdf_key
pdf_list = []
for key, info in PDF_FILES.items():
pdf_list.append({
"key": key,
"name": info["name"],
"exists": info["path"].exists(),
"is_current": key == current_pdf_key
})
return {"pdfs": pdf_list, "current": current_pdf_key}
@app.post("/api/switch-pdf/{pdf_key}")
async def switch_pdf(pdf_key: str, background_tasks: BackgroundTasks):
"""PDF μ „ν™˜"""
global current_pdf_key
if pdf_key not in PDF_FILES:
return JSONResponse(content={"error": "μœ νš¨ν•˜μ§€ μ•Šμ€ PDF ν‚€μž…λ‹ˆλ‹€."}, status_code=400)
pdf_info = PDF_FILES[pdf_key]
if not pdf_info["path"].exists():
return JSONResponse(content={"error": f"PDF νŒŒμΌμ„ 찾을 수 μ—†μŠ΅λ‹ˆλ‹€: {pdf_info['name']}"}, status_code=404)
current_pdf_key = pdf_key
logger.info(f"PDF μ „ν™˜: {pdf_key} - {pdf_info['name']}")
# 캐싱 μ‹œμž‘
pdf_name = pdf_info["path"].stem
if pdf_name not in pdf_cache or pdf_cache[pdf_name].get("status") != "completed":
background_tasks.add_task(cache_pdf, str(pdf_info["path"]))
# VLM 뢄석 μ‹œμž‘ (μΊμ‹œκ°€ μ—†λŠ” 경우)
if not load_analysis_cache(pdf_info["id"]):
asyncio.create_task(analyze_pdf_with_vlm_batched_for_key(pdf_key))
return {
"success": True,
"current": pdf_key,
"name": pdf_info["name"],
"id": pdf_info["id"]
}
async def analyze_pdf_with_vlm_batched_for_key(pdf_key: str, force_refresh: bool = False) -> Dict[str, Any]:
"""νŠΉμ • PDF에 λŒ€ν•œ VLM 뢄석"""
global analysis_status
if pdf_key not in PDF_FILES:
return {"error": "μœ νš¨ν•˜μ§€ μ•Šμ€ PDF ν‚€"}
pdf_info = PDF_FILES[pdf_key]
pdf_id = pdf_info["id"]
pdf_path = str(pdf_info["path"])
# 이미 뢄석 쀑인지 확인
if pdf_id in analysis_status and analysis_status[pdf_id].get("status") == "analyzing":
logger.info(f"PDF {pdf_id} 이미 뢄석 쀑...")
return {"status": "analyzing", "progress": analysis_status[pdf_id].get("progress", 0)}
# μΊμ‹œ 확인
if not force_refresh:
cached = load_analysis_cache(pdf_id)
if cached:
analysis_status[pdf_id] = {"status": "completed", "progress": 100}
return cached
if not pdf_info["path"].exists():
analysis_status[pdf_id] = {"status": "error", "error": "PDF 파일 μ—†μŒ"}
return {"error": "PDF νŒŒμΌμ„ 찾을 수 μ—†μŠ΅λ‹ˆλ‹€."}
if not HAS_VALID_API_KEY:
analysis_status[pdf_id] = {"status": "error", "error": "API ν‚€ μ—†μŒ"}
return {"error": "API ν‚€κ°€ μ„€μ •λ˜μ§€ μ•Šμ•˜μŠ΅λ‹ˆλ‹€."}
# 뢄석 μ‹œμž‘
analysis_status[pdf_id] = {"status": "analyzing", "progress": 0, "started_at": time.time()}
try:
doc = fitz.open(pdf_path)
total_pages = doc.page_count
doc.close()
logger.info(f"PDF 뢄석 μ‹œμž‘ ({pdf_key}): 총 {total_pages}νŽ˜μ΄μ§€")
batch_size = 5
all_analyses = []
for start_page in range(0, min(total_pages, 25), batch_size):
try:
progress = int((start_page / min(total_pages, 25)) * 100)
analysis_status[pdf_id]["progress"] = progress
logger.info(f"배치 뢄석 쀑 ({pdf_key}): {start_page + 1}νŽ˜μ΄μ§€λΆ€ν„° (μ§„ν–‰λ₯ : {progress}%)")
loop = asyncio.get_event_loop()
batch_result = await loop.run_in_executor(
None,
analyze_batch_pages_sync,
pdf_path,
start_page,
batch_size
)
if batch_result:
all_analyses.append(f"### νŽ˜μ΄μ§€ {start_page + 1}~{min(start_page + batch_size, total_pages)}\n{batch_result}")
await asyncio.sleep(2)
except Exception as batch_error:
logger.error(f"배치 {start_page} 뢄석 였λ₯˜: {batch_error}")
all_analyses.append(f"### νŽ˜μ΄μ§€ {start_page + 1}~{min(start_page + batch_size, total_pages)}\n[뢄석 μ‹€νŒ¨: {str(batch_error)}]")
combined_analysis = "\n\n".join(all_analyses)
successful_analyses = [a for a in all_analyses if "[뢄석 μ‹€νŒ¨:" not in a]
if not successful_analyses:
logger.error("λͺ¨λ“  배치 뢄석 μ‹€νŒ¨")
analysis_status[pdf_id] = {"status": "error", "error": "λͺ¨λ“  νŽ˜μ΄μ§€ 뢄석 μ‹€νŒ¨"}
return {"error": "PDF 뢄석에 μ‹€νŒ¨ν–ˆμŠ΅λ‹ˆλ‹€."}
summary = ""
if combined_analysis:
try:
summary_messages = [
{"role": "system", "content": "λ‹€μŒ PDF 뢄석 λ‚΄μš©μ„ 500자 μ΄λ‚΄λ‘œ μš”μ•½ν•΄μ£Όμ„Έμš”. 핡심 λ‚΄μš©κ³Ό μ£Όμš” ν‚€μ›Œλ“œλ₯Ό ν¬ν•¨ν•΄μ£Όμ„Έμš”."},
{"role": "user", "content": combined_analysis[:8000]}
]
summary = call_fireworks_vlm_api(summary_messages, max_tokens=1024, temperature=0.5)
except Exception as sum_err:
logger.error(f"μš”μ•½ 생성 였λ₯˜: {sum_err}")
summary = combined_analysis[:500] + "..."
analysis_data = {
"pdf_id": pdf_id,
"pdf_key": pdf_key,
"total_pages": total_pages,
"analyzed_pages": min(total_pages, 25),
"analysis": combined_analysis,
"summary": summary,
"created_at": time.time()
}
save_analysis_cache(pdf_id, analysis_data)
analysis_status[pdf_id] = {"status": "completed", "progress": 100}
logger.info(f"PDF 뢄석 μ™„λ£Œ: {pdf_id}")
return analysis_data
except Exception as e:
logger.error(f"VLM PDF 뢄석 였λ₯˜: {e}")
analysis_status[pdf_id] = {"status": "error", "error": str(e)}
return {"error": str(e)}
@app.get("/api/pdf-info")
async def get_pdf_info():
global current_pdf_key
pdf_info = PDF_FILES.get(current_pdf_key, PDF_FILES["prompt"])
pdf_path = pdf_info["path"]
pdf_id = pdf_info["id"]
if not pdf_path.exists():
return {"exists": False, "error": "PDF νŒŒμΌμ„ 찾을 수 μ—†μŠ΅λ‹ˆλ‹€"}
pdf_name = pdf_path.stem
is_cached = pdf_name in pdf_cache and pdf_cache[pdf_name].get("status") == "completed"
# VLM 뢄석 μΊμ‹œ 확인
analysis_cached = load_analysis_cache(pdf_id) is not None
return {
"path": str(pdf_path),
"name": pdf_name,
"display_name": pdf_info["name"],
"id": pdf_id,
"key": current_pdf_key,
"exists": True,
"cached": is_cached,
"analysis_cached": analysis_cached
}
@app.get("/api/analysis-status")
async def get_analysis_status():
"""VLM 뢄석 μƒνƒœ 확인"""
global current_pdf_key
pdf_info = PDF_FILES.get(current_pdf_key, PDF_FILES["prompt"])
pdf_id = pdf_info["id"]
# λ¨Όμ € μΊμ‹œ 파일 확인
cached = load_analysis_cache(pdf_id)
if cached:
return {
"status": "completed",
"total_pages": cached.get("total_pages", 0),
"analyzed_pages": cached.get("analyzed_pages", 0),
"created_at": cached.get("created_at", 0)
}
# λ©”λͺ¨λ¦¬ μƒνƒœ 확인
if pdf_id in analysis_status:
status_info = analysis_status[pdf_id]
return {
"status": status_info.get("status", "unknown"),
"progress": status_info.get("progress", 0),
"error": status_info.get("error")
}
return {"status": "not_started"}
@app.post("/api/reanalyze-pdf")
async def reanalyze_pdf():
"""PDF μž¬λΆ„μ„ (μΊμ‹œ λ¬΄μ‹œ)"""
global current_pdf_key
try:
if not HAS_VALID_API_KEY:
return JSONResponse(content={"error": "API ν‚€κ°€ μ„€μ •λ˜μ§€ μ•Šμ•˜μŠ΅λ‹ˆλ‹€."}, status_code=400)
pdf_info = PDF_FILES.get(current_pdf_key, PDF_FILES["prompt"])
pdf_id = pdf_info["id"]
# κΈ°μ‘΄ μΊμ‹œ μ‚­μ œ
cache_path = get_analysis_cache_path(pdf_id)
if cache_path.exists():
cache_path.unlink()
logger.info("κΈ°μ‘΄ VLM 뢄석 μΊμ‹œ μ‚­μ œ")
# μƒνƒœ μ΄ˆκΈ°ν™”
if pdf_id in analysis_status:
del analysis_status[pdf_id]
# λ°±κ·ΈλΌμš΄λ“œμ—μ„œ μž¬λΆ„μ„ μ‹œμž‘
asyncio.create_task(analyze_pdf_with_vlm_batched_for_key(current_pdf_key))
return {"status": "started", "message": "PDF μž¬λΆ„μ„μ„ μ‹œμž‘ν•©λ‹ˆλ‹€."}
except Exception as e:
logger.error(f"μž¬λΆ„μ„ μ‹œμž‘ 였λ₯˜: {e}")
return JSONResponse(content={"error": str(e)}, status_code=500)
@app.get("/api/pdf-thumbnail")
async def get_pdf_thumbnail():
global current_pdf_key
try:
pdf_info = PDF_FILES.get(current_pdf_key, PDF_FILES["prompt"])
pdf_path = pdf_info["path"]
if not pdf_path.exists():
return {"thumbnail": None, "error": "PDF νŒŒμΌμ„ 찾을 수 μ—†μŠ΅λ‹ˆλ‹€"}
pdf_name = pdf_path.stem
if pdf_name in pdf_cache and pdf_cache[pdf_name].get("pages"):
if pdf_cache[pdf_name]["pages"][0].get("thumb"):
return {"thumbnail": pdf_cache[pdf_name]["pages"][0]["thumb"]}
doc = fitz.open(str(pdf_path))
if doc.page_count > 0:
page = doc[0]
pix = page.get_pixmap(matrix=fitz.Matrix(0.2, 0.2))
img_data = pix.tobytes("jpeg", 70)
b64_img = base64.b64encode(img_data).decode('utf-8')
asyncio.create_task(cache_pdf(str(pdf_path)))
return {"thumbnail": f"data:image/jpeg;base64,{b64_img}"}
return {"thumbnail": None}
except Exception as e:
logger.error(f"썸넀일 생성 였λ₯˜: {str(e)}")
return {"error": str(e), "thumbnail": None}
@app.get("/api/cache-status")
async def get_cache_status():
global current_pdf_key
pdf_info = PDF_FILES.get(current_pdf_key, PDF_FILES["prompt"])
pdf_name = pdf_info["path"].stem
if pdf_name in pdf_cache:
return pdf_cache[pdf_name]
return {"status": "not_cached"}
@app.post("/api/ai/query-pdf")
async def api_query_pdf(query: Dict[str, str]):
global current_pdf_key
try:
user_query = query.get("query", "")
if not user_query:
return JSONResponse(content={"error": "질문이 μ œκ³΅λ˜μ§€ μ•Šμ•˜μŠ΅λ‹ˆλ‹€"}, status_code=400)
pdf_info = PDF_FILES.get(current_pdf_key, PDF_FILES["prompt"])
if not pdf_info["path"].exists():
return JSONResponse(content={"error": "PDF νŒŒμΌμ„ 찾을 수 μ—†μŠ΅λ‹ˆλ‹€"}, status_code=404)
result = await query_pdf(pdf_info["id"], user_query)
if "answer" in result:
return result
if "error" in result:
return JSONResponse(content=result, status_code=200)
return result
except Exception as e:
logger.error(f"μ§ˆμ˜μ‘λ‹΅ API 였λ₯˜: {e}")
return JSONResponse(content={"error": str(e), "answer": "μ£„μ†‘ν•©λ‹ˆλ‹€. 처리 쀑 였λ₯˜κ°€ λ°œμƒν–ˆμŠ΅λ‹ˆλ‹€."}, status_code=200)
@app.get("/api/ai/summarize-pdf")
async def api_summarize_pdf():
global current_pdf_key
try:
pdf_info = PDF_FILES.get(current_pdf_key, PDF_FILES["prompt"])
if not pdf_info["path"].exists():
return JSONResponse(content={"error": "PDF νŒŒμΌμ„ 찾을 수 μ—†μŠ΅λ‹ˆλ‹€"}, status_code=404)
result = await summarize_pdf(pdf_info["id"])
if "summary" in result:
return result
if "error" in result:
return JSONResponse(content=result, status_code=200)
return result
except Exception as e:
logger.error(f"PDF μš”μ•½ API 였λ₯˜: {e}")
return JSONResponse(content={"error": str(e), "summary": "μ£„μ†‘ν•©λ‹ˆλ‹€. μš”μ•½ 생성 쀑 였λ₯˜κ°€ λ°œμƒν–ˆμŠ΅λ‹ˆλ‹€."}, status_code=200)
@app.get("/api/cached-pdf")
async def get_cached_pdf(background_tasks: BackgroundTasks):
global current_pdf_key
try:
pdf_info = PDF_FILES.get(current_pdf_key, PDF_FILES["prompt"])
pdf_path = pdf_info["path"]
pdf_name = pdf_path.stem
if pdf_name in pdf_cache:
status = pdf_cache[pdf_name].get("status", "")
if status == "completed":
return pdf_cache[pdf_name]
elif status == "processing":
progress = pdf_cache[pdf_name].get("progress", 0)
pages = pdf_cache[pdf_name].get("pages", [])
total_pages = pdf_cache[pdf_name].get("total_pages", 0)
return {
"status": "processing",
"progress": progress,
"pages": pages,
"total_pages": total_pages,
"available_pages": len([p for p in pages if p and p.get("src")])
}
background_tasks.add_task(cache_pdf, str(pdf_path))
return {"status": "started", "progress": 0}
except Exception as e:
logger.error(f"μΊμ‹œλœ PDF 제곡 였λ₯˜: {str(e)}")
return {"error": str(e), "status": "error"}
@app.get("/", response_class=HTMLResponse)
async def root():
return get_html_content()
def get_html_content():
return HTMLResponse(content=HTML)
HTML = """
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>🎨 AI ν”Œλ¦½λΆ - Comic Style</title>
<link rel="stylesheet" href="/static/flipbook.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<script src="https://cdnjs.cloudflare.com/ajax/libs/marked/9.1.6/marked.min.js"></script>
<link href="https://fonts.googleapis.com/css2?family=Bangers&family=Comic+Neue:wght@400;700&display=swap" rel="stylesheet">
<script src="/static/three.js"></script>
<script src="/static/iscroll.js"></script>
<script src="/static/mark.js"></script>
<script src="/static/mod3d.js"></script>
<script src="/static/pdf.js"></script>
<script src="/static/flipbook.js"></script>
<script src="/static/flipbook.book3.js"></script>
<script src="/static/flipbook.scroll.js"></script>
<script src="/static/flipbook.swipe.js"></script>
<script src="/static/flipbook.webgl.js"></script>
<style>
/* ============================================
Comic Style CSS - Z-Image Style Applied
============================================ */
:root {
--primary-color: #3B82F6;
--secondary-color: #8B5CF6;
--accent-color: #FACC15;
--ai-color: #10B981;
--ai-hover: #059669;
--bg-yellow: #FEF9C3;
--text-dark: #1F2937;
--card-bg: #ffffff;
--shadow-comic: 5px 5px 0 #1F2937;
--shadow-lg: 8px 8px 0 #1F2937;
--border-comic: 3px solid #1F2937;
--radius-sm: 8px;
--radius-md: 12px;
--transition: all 0.2s ease;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: 'Comic Neue', cursive, sans-serif;
color: var(--text-dark);
background-color: var(--bg-yellow);
background-image: radial-gradient(#1F2937 1px, transparent 1px);
background-size: 20px 20px;
background-attachment: fixed;
min-height: 100vh;
}
/* Header Info - Compact Comic Style */
.header-info {
position: fixed;
top: 10px;
left: 50%;
transform: translateX(-50%);
text-align: center;
z-index: 100;
background: linear-gradient(135deg, #3B82F6 0%, #8B5CF6 100%);
border: 3px solid #1F2937;
padding: 8px 25px;
border-radius: 10px;
box-shadow: 4px 4px 0 #1F2937;
}
.header-info .title {
font-family: 'Bangers', cursive;
font-size: 1.3rem;
color: #FFF;
text-shadow: 2px 2px 0 #1F2937;
letter-spacing: 2px;
margin: 0;
}
/* Floating AI Button - Comic Style */
.floating-ai {
position: fixed;
top: 10px;
right: 20px;
width: 50px;
height: 50px;
border-radius: 50%;
background: linear-gradient(135deg, #EF4444 0%, #F97316 100%);
border: 3px solid #1F2937;
box-shadow: 4px 4px 0 #1F2937;
z-index: 9999;
display: flex;
justify-content: center;
align-items: center;
cursor: pointer;
transition: var(--transition);
overflow: hidden;
}
.floating-ai:hover {
transform: translate(-2px, -2px);
box-shadow: 6px 6px 0 #1F2937;
}
.floating-ai:active {
transform: translate(2px, 2px);
box-shadow: 2px 2px 0 #1F2937;
}
.floating-ai .icon {
display: flex;
justify-content: center;
align-items: center;
width: 100%;
height: 100%;
font-size: 20px;
color: white;
text-shadow: 1px 1px 0 #1F2937;
transition: var(--transition);
}
.floating-ai .ai-title {
position: absolute;
right: 58px;
background: #FFF;
padding: 8px 14px;
border-radius: 8px;
border: 2px solid #1F2937;
box-shadow: 3px 3px 0 #1F2937;
font-family: 'Bangers', cursive;
font-size: 0.95rem;
letter-spacing: 1px;
white-space: nowrap;
pointer-events: none;
opacity: 0;
transform: translateX(10px);
transition: all 0.3s ease;
color: var(--text-dark);
}
.floating-ai:hover .ai-title {
opacity: 1;
transform: translateX(0);
}
/* PDF Selection Buttons */
.floating-pdf-btn {
position: fixed;
right: 20px;
width: 50px;
height: 50px;
border-radius: 50%;
background: linear-gradient(135deg, #3B82F6 0%, #8B5CF6 100%);
border: 3px solid #1F2937;
box-shadow: 4px 4px 0 #1F2937;
z-index: 9998;
display: flex;
justify-content: center;
align-items: center;
cursor: pointer;
transition: var(--transition);
overflow: hidden;
}
#pdfPromptBtn {
top: 70px;
}
#pdfKtxBtn {
top: 130px;
}
.floating-pdf-btn:hover {
transform: translate(-2px, -2px);
box-shadow: 6px 6px 0 #1F2937;
}
.floating-pdf-btn:active {
transform: translate(2px, 2px);
box-shadow: 2px 2px 0 #1F2937;
}
.floating-pdf-btn.active {
background: linear-gradient(135deg, #10B981 0%, #059669 100%);
box-shadow: 4px 4px 0 #065F46;
}
.floating-pdf-btn .icon {
display: flex;
justify-content: center;
align-items: center;
width: 100%;
height: 100%;
font-size: 18px;
color: white;
text-shadow: 1px 1px 0 #1F2937;
transition: var(--transition);
}
.floating-pdf-btn .pdf-title {
position: absolute;
right: 58px;
background: #FFF;
padding: 8px 14px;
border-radius: 8px;
border: 2px solid #1F2937;
box-shadow: 3px 3px 0 #1F2937;
font-family: 'Comic Neue', cursive;
font-size: 0.85rem;
font-weight: 700;
letter-spacing: 0.5px;
white-space: nowrap;
pointer-events: none;
opacity: 0;
transform: translateX(10px);
transition: all 0.3s ease;
color: var(--text-dark);
}
.floating-pdf-btn:hover .pdf-title {
opacity: 1;
transform: translateX(0);
}
.floating-pdf-btn.loading {
pointer-events: none;
opacity: 0.7;
}
.floating-pdf-btn.loading .icon {
animation: spin 1s linear infinite;
}
/* Viewer Container - Comic Style */
#viewer {
width: 94%;
height: 90vh;
max-width: 94%;
margin: 0;
background: var(--card-bg);
border: 4px solid #1F2937;
border-radius: var(--radius-md);
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: 1000;
box-shadow: var(--shadow-lg);
overflow: hidden;
}
.flipbook-container .fb3d-menu-bar {
z-index: 2000 !important;
opacity: 1 !important;
bottom: 0 !important;
background: linear-gradient(135deg, #FACC15 0%, #F59E0B 100%) !important;
border-top: 3px solid #1F2937 !important;
border-radius: 0 0 var(--radius-md) var(--radius-md) !important;
padding: 12px 0 !important;
}
.flipbook-container .fb3d-menu-bar > ul > li > img,
.flipbook-container .fb3d-menu-bar > ul > li > div {
opacity: 1 !important;
transform: scale(1.2) !important;
filter: drop-shadow(2px 2px 0 #1F2937) !important;
}
.flipbook-container .fb3d-menu-bar > ul > li {
margin: 0 12px !important;
}
.flipbook-container .fb3d-menu-bar > ul > li > span {
background: #FFF !important;
color: #1F2937 !important;
border: 2px solid #1F2937 !important;
border-radius: var(--radius-sm) !important;
padding: 8px 12px !important;
font-size: 13px !important;
bottom: 55px !important;
font-family: 'Comic Neue', cursive !important;
font-weight: 700 !important;
box-shadow: 3px 3px 0 #1F2937 !important;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
/* Loading Container - Comic Style */
.loading-container {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
text-align: center;
background: #FFF;
border: 4px solid #1F2937;
padding: 40px;
border-radius: var(--radius-md);
box-shadow: var(--shadow-lg);
z-index: 9999;
}
.loading-spinner {
border: 5px solid #FEF9C3;
border-top: 5px solid #3B82F6;
border-radius: 50%;
width: 55px;
height: 55px;
margin: 0 auto;
animation: spin 1s linear infinite;
}
.loading-text {
margin-top: 20px;
font-family: 'Bangers', cursive;
font-size: 1.3rem;
color: var(--text-dark);
letter-spacing: 1px;
}
.progress-bar-container {
width: 220px;
height: 20px;
background: #FEF9C3;
border: 3px solid #1F2937;
border-radius: 10px;
margin-top: 15px;
overflow: hidden;
}
.progress-bar {
height: 100%;
background: linear-gradient(to right, #3B82F6, #8B5CF6);
border-radius: 7px;
transition: width 0.3s ease;
}
.loading-pages {
position: fixed;
bottom: 20px;
left: 50%;
transform: translateX(-50%);
background: #FFF;
border: 3px solid #1F2937;
padding: 12px 25px;
border-radius: 25px;
box-shadow: 4px 4px 0 #1F2937;
font-family: 'Comic Neue', cursive;
font-size: 14px;
font-weight: 700;
color: var(--text-dark);
z-index: 9998;
}
/* AI Chat Container - Comic Style */
#aiChatContainer {
position: fixed;
top: 0;
right: 0;
width: 420px;
height: 100%;
background: #FFF;
border-left: 4px solid #1F2937;
box-shadow: -8px 0 0 #1F2937;
z-index: 10000;
transform: translateX(100%);
transition: transform 0.3s ease;
display: flex;
flex-direction: column;
}
#aiChatContainer.active {
transform: translateX(0);
}
#aiChatHeader {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px;
border-bottom: 4px solid #1F2937;
background: linear-gradient(135deg, #EF4444 0%, #F97316 100%);
}
#aiChatHeader h3 {
margin: 0;
color: white;
font-family: 'Bangers', cursive;
font-size: 1.5rem;
letter-spacing: 2px;
text-shadow: 2px 2px 0 #1F2937;
display: flex;
align-items: center;
}
#aiChatHeader h3 i {
margin-right: 10px;
}
#aiChatClose {
background: #FFF;
border: 3px solid #1F2937;
cursor: pointer;
font-size: 18px;
color: #1F2937;
width: 40px;
height: 40px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 3px 3px 0 #1F2937;
transition: var(--transition);
}
#aiChatClose:hover {
background: #FACC15;
transform: translate(-2px, -2px);
box-shadow: 5px 5px 0 #1F2937;
}
#aiChatMessages {
flex: 1;
overflow-y: auto;
padding: 20px;
background: #FEF9C3;
}
.chat-message {
margin-bottom: 15px;
display: flex;
align-items: flex-start;
}
.chat-message.user {
flex-direction: row-reverse;
}
.chat-avatar {
width: 40px;
height: 40px;
border-radius: 8px;
border: 3px solid #1F2937;
display: flex;
justify-content: center;
align-items: center;
flex-shrink: 0;
box-shadow: 2px 2px 0 #1F2937;
}
.chat-message.user .chat-avatar {
margin-left: 10px;
background: linear-gradient(135deg, #8B5CF6 0%, #A855F7 100%);
color: white;
}
.chat-message.ai .chat-avatar {
margin-right: 10px;
background: linear-gradient(135deg, #10B981 0%, #059669 100%);
color: white;
}
.chat-bubble {
max-width: 75%;
}
.chat-content {
padding: 14px 18px;
border-radius: 12px;
border: 3px solid #1F2937;
word-break: break-word;
font-family: 'Comic Neue', cursive;
font-size: 14px;
font-weight: 700;
line-height: 1.6;
box-shadow: 3px 3px 0 #1F2937;
}
.chat-message.user .chat-content {
background: linear-gradient(135deg, #8B5CF6 0%, #A855F7 100%);
color: white;
border-bottom-right-radius: 4px;
}
.chat-message.ai .chat-content {
background: #FFF;
color: #1F2937;
border-bottom-left-radius: 4px;
}
/* λ§ˆν¬λ‹€μš΄ λ Œλ”λ§ μŠ€νƒ€μΌ */
.chat-content.markdown-body {
font-size: 14px;
line-height: 1.6;
}
.chat-content.markdown-body h1,
.chat-content.markdown-body h2,
.chat-content.markdown-body h3,
.chat-content.markdown-body h4 {
margin: 12px 0 8px 0;
font-family: 'Bangers', cursive;
color: #3B82F6;
border-bottom: 2px solid #E5E7EB;
padding-bottom: 4px;
}
.chat-content.markdown-body h1 { font-size: 1.4em; }
.chat-content.markdown-body h2 { font-size: 1.25em; }
.chat-content.markdown-body h3 { font-size: 1.1em; }
.chat-content.markdown-body h4 { font-size: 1em; }
.chat-content.markdown-body p {
margin: 8px 0;
}
.chat-content.markdown-body strong {
color: #8B5CF6;
font-weight: 700;
}
.chat-content.markdown-body em {
color: #059669;
font-style: italic;
}
.chat-content.markdown-body ul,
.chat-content.markdown-body ol {
margin: 8px 0;
padding-left: 20px;
}
.chat-content.markdown-body li {
margin: 4px 0;
position: relative;
}
.chat-content.markdown-body ul li::marker {
color: #EF4444;
}
.chat-content.markdown-body ol li::marker {
color: #3B82F6;
font-weight: 700;
}
.chat-content.markdown-body blockquote {
margin: 10px 0;
padding: 8px 12px;
border-left: 4px solid #FACC15;
background: #FEF9C3;
border-radius: 0 8px 8px 0;
font-style: italic;
}
.chat-content.markdown-body code {
background: #F3F4F6;
padding: 2px 6px;
border-radius: 4px;
font-family: 'Consolas', monospace;
font-size: 0.9em;
color: #EF4444;
}
.chat-content.markdown-body pre {
background: #1F2937;
color: #F9FAFB;
padding: 12px;
border-radius: 8px;
overflow-x: auto;
margin: 10px 0;
}
.chat-content.markdown-body pre code {
background: transparent;
color: inherit;
padding: 0;
}
.chat-content.markdown-body hr {
border: none;
border-top: 2px dashed #D1D5DB;
margin: 12px 0;
}
.chat-content.markdown-body a {
color: #3B82F6;
text-decoration: underline;
}
.chat-content.markdown-body table {
width: 100%;
border-collapse: collapse;
margin: 10px 0;
}
.chat-content.markdown-body th,
.chat-content.markdown-body td {
border: 2px solid #E5E7EB;
padding: 8px;
text-align: left;
}
.chat-content.markdown-body th {
background: #F3F4F6;
font-weight: 700;
}
.chat-time {
font-size: 11px;
color: #6B7280;
margin-top: 6px;
text-align: right;
font-weight: 700;
}
.chat-message.ai .chat-time {
text-align: left;
}
#aiChatForm {
display: flex;
padding: 15px 20px;
border-top: 4px solid #1F2937;
background: #FFF;
gap: 10px;
}
#aiChatInput {
flex: 1;
padding: 14px 20px;
border: 3px solid #1F2937;
border-radius: 25px;
font-family: 'Comic Neue', cursive;
font-size: 14px;
font-weight: 700;
outline: none;
transition: var(--transition);
background: #FEF9C3;
}
#aiChatInput:focus {
border-color: #3B82F6;
box-shadow: 3px 3px 0 #3B82F6;
}
#aiChatSubmit {
background: linear-gradient(135deg, #EF4444 0%, #F97316 100%);
border: 3px solid #1F2937;
color: white;
width: 50px;
height: 50px;
border-radius: 50%;
display: flex;
justify-content: center;
align-items: center;
cursor: pointer;
box-shadow: 3px 3px 0 #1F2937;
transition: var(--transition);
}
#aiChatSubmit:hover {
transform: translate(-2px, -2px);
box-shadow: 5px 5px 0 #1F2937;
}
#aiChatSubmit:active {
transform: translate(2px, 2px);
box-shadow: 1px 1px 0 #1F2937;
}
#aiChatSubmit:disabled {
background: #9CA3AF;
cursor: not-allowed;
transform: none;
box-shadow: 3px 3px 0 #1F2937;
}
.typing-indicator {
display: flex;
align-items: center;
padding: 10px;
}
.typing-indicator .chat-avatar {
margin-right: 10px;
background: linear-gradient(135deg, #10B981 0%, #059669 100%);
color: white;
}
.typing-indicator span {
height: 10px;
width: 10px;
background: #10B981;
border-radius: 50%;
display: inline-block;
margin-right: 5px;
border: 2px solid #1F2937;
animation: typing 1s infinite;
}
.typing-indicator span:nth-child(2) { animation-delay: 0.2s; }
.typing-indicator span:nth-child(3) { animation-delay: 0.4s; }
@keyframes typing {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-8px); }
}
.error-container {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
text-align: center;
background: #FFF;
border: 4px solid #1F2937;
padding: 40px;
border-radius: var(--radius-md);
box-shadow: var(--shadow-lg);
z-index: 9999;
}
.error-container i {
font-size: 50px;
color: #EF4444;
margin-bottom: 20px;
text-shadow: 3px 3px 0 #1F2937;
}
.error-container p {
font-family: 'Comic Neue', cursive;
font-size: 16px;
font-weight: 700;
color: var(--text-dark);
margin-bottom: 20px;
}
.error-container button {
padding: 12px 30px;
background: linear-gradient(135deg, #3B82F6 0%, #8B5CF6 100%);
color: white;
border: 3px solid #1F2937;
border-radius: 8px;
cursor: pointer;
font-family: 'Bangers', cursive;
font-size: 1.2rem;
letter-spacing: 1px;
box-shadow: 4px 4px 0 #1F2937;
transition: var(--transition);
}
.error-container button:hover {
transform: translate(-2px, -2px);
box-shadow: 6px 6px 0 #1F2937;
}
/* Footer - Comic Style */
.footer-comic {
position: fixed;
bottom: 10px;
right: 10px;
text-align: center;
padding: 10px 20px;
background: linear-gradient(135deg, #3B82F6 0%, #8B5CF6 100%);
border: 3px solid #1F2937;
border-radius: 10px;
box-shadow: 4px 4px 0 #1F2937;
z-index: 100;
}
.footer-comic p {
font-family: 'Comic Neue', cursive;
color: #FFF;
margin: 3px 0;
font-weight: 700;
font-size: 0.85rem;
}
.footer-comic a {
color: #FACC15;
text-decoration: none;
font-weight: 700;
}
.footer-comic a:hover {
text-decoration: underline;
}
/* Scrollbar - Comic Style */
::-webkit-scrollbar {
width: 14px;
height: 14px;
}
::-webkit-scrollbar-track {
background: #FEF9C3;
border: 2px solid #1F2937;
}
::-webkit-scrollbar-thumb {
background: linear-gradient(135deg, #3B82F6, #8B5CF6);
border: 2px solid #1F2937;
border-radius: 7px;
}
::-webkit-scrollbar-thumb:hover {
background: linear-gradient(135deg, #EF4444, #F97316);
}
::selection {
background: #FACC15;
color: #1F2937;
}
@media (max-width: 768px) {
.header-info {
top: 8px;
padding: 6px 15px;
max-width: 60%;
}
.header-info .title {
font-size: 1rem;
}
.floating-ai {
width: 40px;
height: 40px;
top: 8px;
right: 10px;
}
.floating-ai .icon {
font-size: 16px;
}
.floating-pdf-btn {
width: 40px;
height: 40px;
}
#pdfPromptBtn {
top: 55px;
}
#pdfKtxBtn {
top: 102px;
}
.floating-pdf-btn .icon {
font-size: 14px;
}
.floating-pdf-btn .pdf-title {
display: none;
}
#aiChatContainer {
width: 100%;
}
#viewer {
width: 98%;
height: 92vh;
top: 50%;
}
.footer-comic {
display: none;
}
}
</style>
</head>
<body>
<!-- Header Info - Compact Comic Style -->
<div class="header-info">
<div class="title">πŸ“š AI ν”Œλ¦½λΆ</div>
</div>
<!-- Floating AI Button - Comic Style -->
<div id="aiButton" class="floating-ai">
<div class="icon"><i class="fas fa-robot"></i></div>
<div class="ai-title">πŸ€– AI μ–΄μ‹œμŠ€ν„΄νŠΈ</div>
</div>
<!-- PDF Selection Buttons -->
<div id="pdfPromptBtn" class="floating-pdf-btn active" data-pdf="prompt">
<div class="icon"><i class="fas fa-book"></i></div>
<div class="pdf-title">μƒ˜ν”Œ-ν”„λ‘¬ν”„νŠΈλΆ</div>
</div>
<div id="pdfKtxBtn" class="floating-pdf-btn" data-pdf="ktx">
<div class="icon"><i class="fas fa-train"></i></div>
<div class="pdf-title">μƒ˜ν”Œ-μ½”λ ˆμΌμž‘μ§€</div>
</div>
<!-- AI Chat Container -->
<div id="aiChatContainer">
<div id="aiChatHeader">
<h3><i class="fas fa-robot"></i> AI μ–΄μ‹œμŠ€ν„΄νŠΈ</h3>
<button id="aiChatClose"><i class="fas fa-times"></i></button>
</div>
<div id="aiChatMessages"></div>
<form id="aiChatForm">
<input type="text" id="aiChatInput" placeholder="PDF에 λŒ€ν•΄ μ§ˆλ¬Έν•˜μ„Έμš”..." autocomplete="off">
<button type="submit" id="aiChatSubmit"><i class="fas fa-paper-plane"></i></button>
</form>
</div>
<!-- PDF Viewer -->
<div id="viewer"></div>
<div id="loadingPages" class="loading-pages" style="display:none;">νŽ˜μ΄μ§€ λ‘œλ”© 쀑... <span id="loadingPagesCount">0%</span></div>
<!-- Footer - Comic Style -->
<div class="footer-comic">
<p style="font-family:'Bangers',cursive;font-size:1.1rem;letter-spacing:1px">πŸ“š AI FLIPBOOK πŸ“š</p>
<p>Powered by VLM + 3D FlipBook</p>
<p><a href="https://ginigen.ai" target="_blank">🏠 ginigen.ai</a></p>
</div>
<script>
let fb = null;
const viewer = document.getElementById('viewer');
pdfjsLib.GlobalWorkerOptions.workerSrc = '/static/pdf.worker.js';
let pageLoadingInterval = null;
let audioInitialized = false;
let isAiChatActive = false;
let isAiProcessing = false;
let hasLoadedSummary = false;
let analysisCheckInterval = null;
// 기본값을 prompt둜 μ„€μ • (μ„œλ²„μ™€ 동기화됨)
let currentPdfKey = 'prompt';
let isPdfSwitching = false;
function $id(id) { return document.getElementById(id); }
function formatTime() {
const now = new Date();
return now.toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit' });
}
function initializeAudio() {
if (audioInitialized) return Promise.resolve();
return new Promise((resolve) => {
const audio = new Audio('/static/turnPage2.mp3');
audio.volume = 0.01;
audio.play().then(() => {
audio.pause();
audioInitialized = true;
resolve();
}).catch(() => {
const initOnClick = () => {
const tempAudio = new Audio('/static/turnPage2.mp3');
tempAudio.volume = 0.01;
tempAudio.play().then(() => {
tempAudio.pause();
audioInitialized = true;
document.removeEventListener('click', initOnClick);
resolve();
}).catch(() => {});
};
document.addEventListener('click', initOnClick, { once: true });
resolve();
});
});
}
function addChatMessage(content, isUser = false) {
const messagesContainer = $id('aiChatMessages');
const messageElement = document.createElement('div');
messageElement.className = `chat-message ${isUser ? 'user' : 'ai'}`;
// AI λ©”μ‹œμ§€λŠ” λ§ˆν¬λ‹€μš΄μ„ HTML둜 λ³€ν™˜
let displayContent = content;
if (!isUser && typeof marked !== 'undefined') {
try {
marked.setOptions({
breaks: true, // μ€„λ°”κΏˆμ„ <br>둜 λ³€ν™˜
gfm: true // GitHub Flavored Markdown
});
displayContent = marked.parse(content);
} catch (e) {
console.error('λ§ˆν¬λ‹€μš΄ νŒŒμ‹± 였λ₯˜:', e);
displayContent = content;
}
}
messageElement.innerHTML = `
<div class="chat-avatar">
<i class="fas ${isUser ? 'fa-user' : 'fa-robot'}"></i>
</div>
<div class="chat-bubble">
<div class="chat-content markdown-body">${displayContent}</div>
<div class="chat-time">${formatTime()}</div>
</div>
`;
messagesContainer.appendChild(messageElement);
messagesContainer.scrollTop = messagesContainer.scrollHeight;
return messageElement;
}
function addTypingIndicator() {
const messagesContainer = $id('aiChatMessages');
const indicator = document.createElement('div');
indicator.className = 'typing-indicator';
indicator.id = 'typingIndicator';
indicator.innerHTML = `
<div class="chat-avatar"><i class="fas fa-robot"></i></div>
<div><span></span><span></span><span></span></div>
`;
messagesContainer.appendChild(indicator);
messagesContainer.scrollTop = messagesContainer.scrollHeight;
return indicator;
}
function removeTypingIndicator() {
const indicator = $id('typingIndicator');
if (indicator) indicator.remove();
}
function toggleAiChat(show = true) {
const container = $id('aiChatContainer');
if (show) {
container.classList.add('active');
isAiChatActive = true;
if (!hasLoadedSummary) {
loadPdfSummary();
}
$id('aiChatInput').focus();
} else {
container.classList.remove('active');
isAiChatActive = false;
}
}
async function checkAnalysisStatus() {
try {
const response = await fetch('/api/analysis-status');
const data = await response.json();
return data;
} catch (e) {
console.error("뢄석 μƒνƒœ 확인 였λ₯˜:", e);
return { status: "error" };
}
}
async function loadPdfSummary() {
if (isAiProcessing || hasLoadedSummary) return;
try {
isAiProcessing = true;
addTypingIndicator();
// 뢄석 μƒνƒœ 확인
const statusData = await checkAnalysisStatus();
if (statusData.status === 'analyzing') {
removeTypingIndicator();
const progress = statusData.progress || 0;
addChatMessage(`μ•ˆλ…•ν•˜μ„Έμš”! πŸ’₯ ν˜„μž¬ PDFλ₯Ό AIκ°€ λΆ„μ„ν•˜κ³  μžˆμŠ΅λ‹ˆλ‹€. πŸ“Š<br><br>μ§„ν–‰λ₯ : <strong>${progress}%</strong><br><small style="color:#6B7280;">뢄석이 μ™„λ£Œλ˜λ©΄ μžλ™μœΌλ‘œ μ•Œλ €λ“œλ¦¬κ² μŠ΅λ‹ˆλ‹€.</small>`);
hasLoadedSummary = true;
isAiProcessing = false;
// 뢄석 μ™„λ£Œ 폴링
startAnalysisPolling();
return;
}
if (statusData.status === 'error') {
removeTypingIndicator();
addChatMessage(`μ•ˆλ…•ν•˜μ„Έμš”! πŸ’₯ PDF 뢄석 쀑 였λ₯˜κ°€ λ°œμƒν–ˆμŠ΅λ‹ˆλ‹€. ⚠️<br><br><small style="color:#EF4444;">${statusData.error || 'μ•Œ 수 μ—†λŠ” 였λ₯˜'}</small><br><br>νŽ˜μ΄μ§€λ₯Ό μƒˆλ‘œκ³ μΉ¨ν•˜κ±°λ‚˜ μž μ‹œ ν›„ λ‹€μ‹œ μ‹œλ„ν•΄μ£Όμ„Έμš”.`);
hasLoadedSummary = true;
isAiProcessing = false;
return;
}
if (statusData.status === 'not_started') {
removeTypingIndicator();
addChatMessage(`μ•ˆλ…•ν•˜μ„Έμš”! πŸ’₯ PDF 뢄석이 아직 μ‹œμž‘λ˜μ§€ μ•Šμ•˜μŠ΅λ‹ˆλ‹€. πŸ”„<br><br><small style="color:#6B7280;">μž μ‹œλ§Œ κΈ°λ‹€λ €μ£Όμ„Έμš”...</small>`);
hasLoadedSummary = true;
isAiProcessing = false;
startAnalysisPolling();
return;
}
// 뢄석 μ™„λ£Œλ¨ - μš”μ•½ κ°€μ Έμ˜€κΈ°
const response = await fetch('/api/ai/summarize-pdf');
const data = await response.json();
removeTypingIndicator();
if (data.summary) {
const pageInfo = data.analyzed_pages ? ` (${data.analyzed_pages}/${data.total_pages}νŽ˜μ΄μ§€ λΆ„μ„μ™„λ£Œ)` : '';
addChatMessage(`μ•ˆλ…•ν•˜μ„Έμš”! πŸ’₯ 이 PDF에 λŒ€ν•΄ 무엇이든 μ§ˆλ¬Έν•΄μ£Όμ„Έμš”.${pageInfo}<br><br><strong>πŸ“„ PDF μš”μ•½:</strong><br>${data.summary}`);
} else {
addChatMessage("μ•ˆλ…•ν•˜μ„Έμš”! πŸ’₯ PDF에 λŒ€ν•΄ μ§ˆλ¬Έν•΄μ£Όμ„Έμš”. μ΅œμ„ μ„ λ‹€ν•΄ λ‹΅λ³€ν•˜κ² μŠ΅λ‹ˆλ‹€.");
}
hasLoadedSummary = true;
} catch (error) {
console.error("PDF μš”μ•½ λ‘œλ“œ 였λ₯˜:", error);
removeTypingIndicator();
addChatMessage("μ•ˆλ…•ν•˜μ„Έμš”! πŸ’₯ PDF에 λŒ€ν•΄ μ§ˆλ¬Έν•΄μ£Όμ„Έμš”.");
hasLoadedSummary = true;
} finally {
isAiProcessing = false;
}
}
function startAnalysisPolling() {
if (analysisCheckInterval) return;
analysisCheckInterval = setInterval(async () => {
try {
const data = await checkAnalysisStatus();
if (data.status === 'completed') {
clearInterval(analysisCheckInterval);
analysisCheckInterval = null;
addChatMessage(`βœ… PDF 뢄석이 μ™„λ£Œλ˜μ—ˆμŠ΅λ‹ˆλ‹€! (${data.analyzed_pages || data.total_pages}νŽ˜μ΄μ§€)<br>이제 자유둭게 μ§ˆλ¬Έν•΄μ£Όμ„Έμš”.`);
} else if (data.status === 'analyzing') {
// μ§„ν–‰λ₯  μ—…λ°μ΄νŠΈ (선택적)
console.log(`뢄석 μ§„ν–‰ 쀑: ${data.progress}%`);
} else if (data.status === 'error') {
clearInterval(analysisCheckInterval);
analysisCheckInterval = null;
addChatMessage(`⚠️ PDF 뢄석 μ‹€νŒ¨: ${data.error || 'μ•Œ 수 μ—†λŠ” 였λ₯˜'}`);
}
} catch (e) {
console.error("폴링 였λ₯˜:", e);
}
}, 5000); // 5μ΄ˆλ§ˆλ‹€ 확인
// 5λΆ„ ν›„ μžλ™ 쀑지
setTimeout(() => {
if (analysisCheckInterval) {
clearInterval(analysisCheckInterval);
analysisCheckInterval = null;
}
}, 300000);
}
async function submitQuestion(question) {
if (isAiProcessing || !question.trim()) return;
try {
isAiProcessing = true;
$id('aiChatSubmit').disabled = true;
addChatMessage(question, true);
// 뢄석 μƒνƒœ 확인
const statusData = await checkAnalysisStatus();
if (statusData.status !== 'completed') {
if (statusData.status === 'analyzing') {
addChatMessage(`PDF 뢄석이 μ§„ν–‰ μ€‘μž…λ‹ˆλ‹€ (${statusData.progress || 0}%). μ™„λ£Œ ν›„ μ§ˆλ¬Έν•΄μ£Όμ„Έμš”. ⏳`);
} else {
addChatMessage("PDF 뢄석이 아직 μ™„λ£Œλ˜μ§€ μ•Šμ•˜μŠ΅λ‹ˆλ‹€. μž μ‹œλ§Œ κΈ°λ‹€λ €μ£Όμ„Έμš”.");
}
isAiProcessing = false;
$id('aiChatSubmit').disabled = false;
$id('aiChatInput').value = question;
return;
}
addTypingIndicator();
const response = await fetch('/api/ai/query-pdf', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query: question }),
signal: AbortSignal.timeout(120000)
});
const data = await response.json();
removeTypingIndicator();
if (data.answer) {
addChatMessage(data.answer);
} else if (data.error) {
addChatMessage(`μ£„μ†‘ν•©λ‹ˆλ‹€. ${data.error}`);
} else {
addChatMessage("μ£„μ†‘ν•©λ‹ˆλ‹€. 닡변을 μƒμ„±ν•˜μ§€ λͺ»ν–ˆμŠ΅λ‹ˆλ‹€.");
}
} catch (error) {
console.error("질문 제좜 였λ₯˜:", error);
removeTypingIndicator();
if (error.name === 'AbortError') {
addChatMessage("응닡 μ‹œκ°„μ΄ μ΄ˆκ³Όλ˜μ—ˆμŠ΅λ‹ˆλ‹€. μž μ‹œ ν›„ λ‹€μ‹œ μ‹œλ„ν•΄μ£Όμ„Έμš”.");
} else {
addChatMessage("μ„œλ²„μ™€ 톡신 쀑 였λ₯˜κ°€ λ°œμƒν–ˆμŠ΅λ‹ˆλ‹€. μž μ‹œ ν›„ λ‹€μ‹œ μ‹œλ„ν•΄μ£Όμ„Έμš”.");
}
} finally {
isAiProcessing = false;
$id('aiChatSubmit').disabled = false;
$id('aiChatInput').value = '';
$id('aiChatInput').focus();
}
}
function showLoading(message) {
hideLoading();
const container = document.createElement('div');
container.className = 'loading-container';
container.id = 'loadingContainer';
container.innerHTML = `
<div class="loading-spinner"></div>
<p class="loading-text">${message || 'λ‘œλ”© 쀑...'}</p>
<div class="progress-bar-container">
<div id="progressBar" class="progress-bar" style="width: 0%;"></div>
</div>
`;
document.body.appendChild(container);
}
function updateLoading(message, progress) {
const text = document.querySelector('.loading-text');
if (text) text.textContent = message;
const bar = $id('progressBar');
if (bar && progress !== undefined) bar.style.width = `${progress}%`;
}
function hideLoading() {
const container = $id('loadingContainer');
if (container) container.remove();
}
function showError(message) {
hideLoading();
const container = document.createElement('div');
container.className = 'error-container';
container.id = 'errorContainer';
container.innerHTML = `
<i class="fas fa-exclamation-circle"></i>
<p>${message}</p>
<button onclick="location.reload()">λ‹€μ‹œ μ‹œλ„</button>
`;
document.body.appendChild(container);
}
function createFlipBook(pages) {
try {
const windowWidth = window.innerWidth;
const windowHeight = window.innerHeight;
const aspectRatio = windowWidth / windowHeight;
let width, height;
if (aspectRatio > 1) {
// κ°€λ‘œ λͺ¨λ“œ: 높이 κΈ°μ€€μœΌλ‘œ 90% μ‚¬μš©
height = Math.min(windowHeight * 0.88, windowHeight - 60);
width = height * aspectRatio * 0.75;
if (width > windowWidth * 0.94) {
width = windowWidth * 0.94;
height = width / (aspectRatio * 0.75);
}
} else {
// μ„Έλ‘œ λͺ¨λ“œ: λ„ˆλΉ„ κΈ°μ€€μœΌλ‘œ 98% μ‚¬μš©
width = Math.min(windowWidth * 0.98, windowWidth - 10);
height = width / aspectRatio * 0.9;
if (height > windowHeight * 0.9) {
height = windowHeight * 0.9;
width = height * aspectRatio * 0.9;
}
}
viewer.style.width = Math.round(width) + 'px';
viewer.style.height = Math.round(height) + 'px';
const validPages = pages.map(page => {
if (!page || !page.src) {
return {
src: 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTAwIiBoZWlnaHQ9IjEwMCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cmVjdCB3aWR0aD0iMTAwJSIgaGVpZ2h0PSIxMDAlIiBmaWxsPSIjZjVmNWY1Ii8+PC9zdmc+',
thumb: ''
};
}
return page;
});
fb = new FlipBook(viewer, {
pages: validPages,
viewMode: 'webgl',
autoSize: true,
flipDuration: 800,
backgroundColor: '#fff',
sound: true,
assets: { flipMp3: '/static/turnPage2.mp3', hardFlipMp3: '/static/turnPage2.mp3' },
controlsProps: {
enableFullscreen: true,
enableToc: true,
enableDownload: false,
enablePrint: false,
enableZoom: true,
enableShare: false,
enableSearch: true,
enableAutoPlay: true,
enableSound: true,
layout: 10,
skin: 'light',
autoNavigationTime: 3600,
hideControls: false,
paddingTop: 10,
paddingLeft: 10,
paddingRight: 10,
paddingBottom: 10,
pageTextureSize: 1024,
thumbnails: true,
autoHideControls: false,
controlsTimeout: 8000
}
});
window.addEventListener('resize', () => {
if (fb) fb.resize();
});
console.log('FlipBook 생성 μ™„λ£Œ');
} catch (error) {
console.error('FlipBook 생성 였λ₯˜:', error);
showError("ν”Œλ¦½λΆμ„ μƒμ„±ν•˜λŠ” 쀑 였λ₯˜κ°€ λ°œμƒν–ˆμŠ΅λ‹ˆλ‹€.");
}
}
// λ²„νŠΌ active μƒνƒœ μ—…λ°μ΄νŠΈ ν•¨μˆ˜
function updatePdfButtonState(pdfKey) {
document.querySelectorAll('.floating-pdf-btn').forEach(btn => {
btn.classList.remove('active');
btn.classList.remove('loading');
if (btn.dataset.pdf === pdfKey) {
btn.classList.add('active');
}
});
}
async function switchPDF(pdfKey) {
if (isPdfSwitching || pdfKey === currentPdfKey) return;
try {
isPdfSwitching = true;
// λ²„νŠΌ μƒνƒœ μ—…λ°μ΄νŠΈ
document.querySelectorAll('.floating-pdf-btn').forEach(btn => {
btn.classList.remove('active');
if (btn.dataset.pdf === pdfKey) {
btn.classList.add('loading');
}
});
showLoading("PDF μ „ν™˜ 쀑...");
// μ„œλ²„μ— PDF μ „ν™˜ μš”μ²­
const switchResponse = await fetch(`/api/switch-pdf/${pdfKey}`, { method: 'POST' });
const switchResult = await switchResponse.json();
if (!switchResult.success) {
hideLoading();
showError(switchResult.error || "PDF μ „ν™˜μ— μ‹€νŒ¨ν–ˆμŠ΅λ‹ˆλ‹€.");
isPdfSwitching = false;
updatePdfButtonState(currentPdfKey);
return;
}
currentPdfKey = pdfKey;
// 기쑴 FlipBook 제거
if (fb) {
viewer.innerHTML = '';
fb = null;
}
// AI 챗봇 μ΄ˆκΈ°ν™”
hasLoadedSummary = false;
$id('aiChatMessages').innerHTML = '';
if (analysisCheckInterval) {
clearInterval(analysisCheckInterval);
analysisCheckInterval = null;
}
// μƒˆ PDF λ‘œλ“œ
await loadPDF();
// λ²„νŠΌ μƒνƒœ μ΅œμ’… μ—…λ°μ΄νŠΈ
updatePdfButtonState(currentPdfKey);
isPdfSwitching = false;
} catch (error) {
console.error("PDF μ „ν™˜ 였λ₯˜:", error);
hideLoading();
showError("PDF μ „ν™˜ 쀑 였λ₯˜κ°€ λ°œμƒν–ˆμŠ΅λ‹ˆλ‹€.");
isPdfSwitching = false;
updatePdfButtonState(currentPdfKey);
}
}
async function loadPDF() {
try {
showLoading("PDF 정보 확인 쀑...");
const infoResponse = await fetch('/api/pdf-info');
const pdfInfo = await infoResponse.json();
if (!pdfInfo.exists) {
hideLoading();
showError("PDF νŒŒμΌμ„ 찾을 수 μ—†μŠ΅λ‹ˆλ‹€. prompt.pdf νŒŒμΌμ„ ν™•μΈν•΄μ£Όμ„Έμš”.");
return;
}
// β˜…β˜…β˜… 핡심 μˆ˜μ •: μ„œλ²„ μƒνƒœμ™€ ν΄λΌμ΄μ–ΈνŠΈ μƒνƒœ 동기화 β˜…β˜…β˜…
if (pdfInfo.key) {
currentPdfKey = pdfInfo.key;
// λ²„νŠΌ active μƒνƒœ 동기화
updatePdfButtonState(currentPdfKey);
console.log('PDF 동기화 μ™„λ£Œ:', currentPdfKey, pdfInfo.display_name);
}
updateLoading("PDF λ‘œλ”© 쀑...", 10);
if (pdfInfo.cached) {
const cacheResponse = await fetch('/api/cached-pdf');
const cachedData = await cacheResponse.json();
if (cachedData.status === "completed" && cachedData.pages) {
hideLoading();
createFlipBook(cachedData.pages);
return;
}
}
const cacheResponse = await fetch('/api/cached-pdf');
let cachedData = await cacheResponse.json();
if (cachedData.status === "completed" && cachedData.pages) {
hideLoading();
createFlipBook(cachedData.pages);
return;
}
while (cachedData.status === "processing" || cachedData.status === "started") {
await new Promise(resolve => setTimeout(resolve, 1000));
const statusResponse = await fetch('/api/cache-status');
cachedData = await statusResponse.json();
if (cachedData.progress) {
updateLoading(`PDF 처리 쀑... ${cachedData.progress}%`, cachedData.progress);
}
if (cachedData.status === "completed") {
const finalResponse = await fetch('/api/cached-pdf');
cachedData = await finalResponse.json();
break;
}
}
hideLoading();
if (cachedData.pages && cachedData.pages.length > 0) {
createFlipBook(cachedData.pages);
} else {
showError("PDFλ₯Ό μ²˜λ¦¬ν•  수 μ—†μŠ΅λ‹ˆλ‹€.");
}
} catch (error) {
console.error("PDF λ‘œλ“œ 였λ₯˜:", error);
hideLoading();
showError("PDFλ₯Ό λ‘œλ“œν•˜λŠ” 쀑 였λ₯˜κ°€ λ°œμƒν–ˆμŠ΅λ‹ˆλ‹€.");
}
}
document.addEventListener('DOMContentLoaded', function() {
initializeAudio();
$id('aiButton').addEventListener('click', () => toggleAiChat(!isAiChatActive));
$id('aiChatClose').addEventListener('click', () => toggleAiChat(false));
$id('aiChatForm').addEventListener('submit', function(e) {
e.preventDefault();
const question = $id('aiChatInput').value.trim();
if (question && !isAiProcessing) {
submitQuestion(question);
}
});
// PDF 선택 λ²„νŠΌ 이벀트
$id('pdfPromptBtn').addEventListener('click', () => switchPDF('prompt'));
$id('pdfKtxBtn').addEventListener('click', () => switchPDF('ktx'));
// νŽ˜μ΄μ§€ λ‘œλ“œ μ‹œ PDF λ‘œλ“œ (μ„œλ²„μ—μ„œ κΈ°λ³Έ PDF 정보 κ°€μ Έμ˜΄)
loadPDF();
});
</script>
</body>
</html>
"""
if __name__ == "__main__":
uvicorn.run("app:app", host="0.0.0.0", port=int(os.getenv("PORT", 7860)))