| 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_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 κΈ°λ₯μ΄ μ νλ©λλ€.") |
|
|
| |
| PROMPT_PDF_PATH = BASE / "prompt.pdf" |
| PROMPT_PDF_ID = "prompt_pdf_main" |
|
|
| |
| PDF_FILES = { |
| "prompt": { |
| "path": BASE / "prompt.pdf", |
| "id": "prompt_pdf_main", |
| "name": "μν-ν둬ννΈλΆ" |
| }, |
| "ktx": { |
| "path": BASE / "ktx2512.pdf", |
| "id": "ktx_pdf_main", |
| "name": "μν-μ½λ μΌμ‘μ§" |
| } |
| } |
|
|
| |
| current_pdf_key = "prompt" |
|
|
| pdf_cache: Dict[str, Dict[str, Any]] = {} |
| cache_locks = {} |
| pdf_embeddings: Dict[str, Dict[str, Any]] = {} |
|
|
| |
| 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: |
| |
| doc = fitz.open(pdf_path) |
| total_pages = doc.page_count |
| doc.close() |
| |
| logger.info(f"PDF λΆμ μμ: μ΄ {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"λ°°μΉ λΆμ μ€: {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 λΆμμ μ€ν¨νμ΅λλ€. 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 |
| |
| 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))) |
| |
| 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"])) |
| |
| |
| 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" |
| |
| |
| 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))) |