pdfbook / app.py
seawolf2357's picture
Update app.py
ce62b8e 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 ์„ค์ • (prompt.pdf๋งŒ ์œ ์ง€)
PDF_FILES = {
"prompt": {
"path": BASE / "prompt.pdf",
"id": "prompt_pdf_main",
"name": "์ƒ˜ํ”Œ-ํ”„๋กฌํ”„ํŠธ๋ถ"
}
}
# ํ˜„์žฌ ์„ ํƒ๋œ PDF (๊ธฐ๋ณธ๊ฐ’: 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():
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.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(pdf_id))
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);
}
/* 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;
}
#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>
<!-- 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;
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("ํ”Œ๋ฆฝ๋ถ์„ ์ƒ์„ฑํ•˜๋Š” ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค.");
}
}
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;
}
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);
}
});
loadPDF();
});
</script>
</body>
</html>
"""
if __name__ == "__main__":
uvicorn.run("app:app", host="0.0.0.0", port=int(os.getenv("PORT", 7860)))