ginipick commited on
Commit
d657236
Β·
1 Parent(s): 45ef16b

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +822 -12
app.py CHANGED
@@ -7,6 +7,8 @@ import asyncio
7
  import logging
8
  import threading
9
  import concurrent.futures
 
 
10
 
11
  # λ‘œκΉ… μ„€μ •
12
  logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
@@ -37,15 +39,38 @@ if not METADATA_DIR.exists():
37
  METADATA_DIR.mkdir(parents=True)
38
  PDF_METADATA_FILE = METADATA_DIR / "pdf_metadata.json"
39
 
 
 
 
 
 
40
  # κ΄€λ¦¬μž λΉ„λ°€λ²ˆν˜Έ
41
  ADMIN_PASSWORD = os.getenv("PASSWORD", "admin") # ν™˜κ²½ λ³€μˆ˜μ—μ„œ κ°€μ Έμ˜€κΈ°, 기본값은 ν…ŒμŠ€νŠΈμš©
42
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
43
  # μ „μ—­ μΊμ‹œ 객체
44
  pdf_cache: Dict[str, Dict[str, Any]] = {}
45
  # 캐싱 락
46
  cache_locks = {}
47
  # PDF 메타데이터 (ID to 경둜 λ§€ν•‘)
48
  pdf_metadata: Dict[str, str] = {}
 
 
49
 
50
  # PDF 메타데이터 λ‘œλ“œ
51
  def load_pdf_metadata():
@@ -69,7 +94,6 @@ def save_pdf_metadata():
69
  except Exception as e:
70
  logger.error(f"메타데이터 μ €μž₯ 였λ₯˜: {e}")
71
 
72
- # PDF ID 생성 (파일λͺ… + νƒ€μž„μŠ€νƒ¬ν”„ 기반)
73
  # PDF ID 생성 (파일λͺ… + νƒ€μž„μŠ€νƒ¬ν”„ 기반) - 더 λ‹¨μˆœν•˜κ³  μ•ˆμ „ν•œ λ°©μ‹μœΌλ‘œ λ³€κ²½
74
  def generate_pdf_id(filename: str) -> str:
75
  # 파일λͺ…μ—μ„œ ν™•μž₯자 제거
@@ -83,8 +107,6 @@ def generate_pdf_id(filename: str) -> str:
83
  random_suffix = uuid.uuid4().hex[:6]
84
  return f"{safe_name}_{timestamp}_{random_suffix}"
85
 
86
-
87
-
88
  # PDF 파일 λͺ©λ‘ κ°€μ Έμ˜€κΈ° (메인 λ””λ ‰ν† λ¦¬μš©)
89
  def get_pdf_files():
90
  pdf_files = []
@@ -146,6 +168,225 @@ def generate_pdf_projects():
146
  def get_cache_path(pdf_name: str):
147
  return CACHE_DIR / f"{pdf_name}_cache.json"
148
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
149
  # μ΅œμ ν™”λœ PDF νŽ˜μ΄μ§€ 캐싱 ν•¨μˆ˜
150
  async def cache_pdf(pdf_path: str):
151
  try:
@@ -300,7 +541,6 @@ async def cache_pdf(pdf_path: str):
300
  pdf_cache[pdf_name]["status"] = "error"
301
  pdf_cache[pdf_name]["error"] = str(e)
302
 
303
- # PDF ID둜 PDF 경둜 찾기
304
  # PDF ID둜 PDF 경둜 μ°ΎκΈ° (κ°œμ„ λœ 검색 둜직)
305
  def get_pdf_path_by_id(pdf_id: str) -> str:
306
  logger.info(f"PDF ID둜 파일 쑰회: {pdf_id}")
@@ -534,6 +774,50 @@ async def get_cache_status(path: str = None):
534
  return {name: {"status": info["status"], "progress": info.get("progress", 0)}
535
  for name, info in pdf_cache.items()}
536
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
537
  # API μ—”λ“œν¬μΈνŠΈ: μΊμ‹œλœ PDF μ½˜ν…μΈ  제곡 (점진적 λ‘œλ”© 지원)
538
  @app.get("/api/cached-pdf")
539
  async def get_cached_pdf(path: str, background_tasks: BackgroundTasks):
@@ -818,7 +1102,7 @@ async def root(request: Request, pdf_id: Optional[str] = Query(None)):
818
  return RedirectResponse(url=f"/view/{pdf_id}")
819
  return get_html_content()
820
 
821
- # HTML λ¬Έμžμ—΄ (UI μˆ˜μ • 버전)
822
  HTML = """
823
  <!doctype html>
824
  <html lang="ko">
@@ -844,6 +1128,8 @@ HTML = """
844
  --secondary-color: #ffd6e0; /* νŒŒμŠ€ν…” 핑크 */
845
  --tertiary-color: #c3fae8; /* νŒŒμŠ€ν…” 민트 */
846
  --accent-color: #d0bfff; /* νŒŒμŠ€ν…” νΌν”Œ */
 
 
847
  --bg-color: #f8f9fa; /* 밝은 λ°°κ²½ */
848
  --text-color: #495057; /* λΆ€λ“œλŸ¬μš΄ μ–΄λ‘μš΄ 색 */
849
  --card-bg: #ffffff; /* μΉ΄λ“œ 배경색 */
@@ -873,7 +1159,7 @@ HTML = """
873
  }
874
 
875
  /* 헀더 제λͺ© 제거 및 Home λ²„νŠΌ λ ˆμ΄μ–΄ 처리 */
876
- .floating-home {
877
  position: fixed;
878
  top: 20px;
879
  left: 20px;
@@ -892,12 +1178,17 @@ HTML = """
892
  overflow: hidden;
893
  }
894
 
895
- .floating-home:hover {
 
 
 
 
 
896
  transform: scale(1.05);
897
  box-shadow: var(--shadow-lg);
898
  }
899
 
900
- .floating-home .icon {
901
  display: flex;
902
  justify-content: center;
903
  align-items: center;
@@ -908,11 +1199,19 @@ HTML = """
908
  transition: var(--transition);
909
  }
910
 
 
 
 
 
911
  .floating-home:hover .icon {
912
  color: #8bc5f8;
913
  }
914
 
915
- .floating-home .title {
 
 
 
 
916
  position: absolute;
917
  left: 70px;
918
  background: rgba(255, 255, 255, 0.95);
@@ -928,7 +1227,7 @@ HTML = """
928
  transition: all 0.3s ease;
929
  }
930
 
931
- .floating-home:hover .title {
932
  opacity: 1;
933
  transform: translateX(0);
934
  }
@@ -1472,6 +1771,257 @@ HTML = """
1472
  grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
1473
  }
1474
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1475
  /* λ°˜μ‘ν˜• λ””μžμΈ */
1476
  @media (max-width: 768px) {
1477
  .grid {
@@ -1488,12 +2038,12 @@ HTML = """
1488
  padding: 10px 20px;
1489
  }
1490
 
1491
- .floating-home {
1492
  width: 50px;
1493
  height: 50px;
1494
  }
1495
 
1496
- .floating-home .icon {
1497
  font-size: 18px;
1498
  }
1499
 
@@ -1501,6 +2051,10 @@ HTML = """
1501
  padding: 6px 15px;
1502
  font-size: 12px;
1503
  }
 
 
 
 
1504
  }
1505
  </style>
1506
  </head>
@@ -1511,6 +2065,25 @@ HTML = """
1511
  <div class="title">ν™ˆμœΌλ‘œ λŒμ•„κ°€κΈ°</div>
1512
  </div>
1513
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1514
  <!-- κ΄€λ¦¬μž λ²„νŠΌ -->
1515
  <div id="adminButton">
1516
  <i class="fas fa-cog"></i> Admin
@@ -1588,6 +2161,11 @@ HTML = """
1588
  let audioInitialized = false;
1589
  let audioContext = null;
1590
 
 
 
 
 
 
1591
  // μ˜€λ””μ˜€ μ΄ˆκΈ°ν™” ν•¨μˆ˜
1592
  function initializeAudio() {
1593
  if (audioInitialized) return Promise.resolve();
@@ -1685,6 +2263,183 @@ HTML = """
1685
  /* ── μœ ν‹Έ ── */
1686
  function $id(id){return document.getElementById(id)}
1687
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1688
  // DOM이 λ‘œλ“œλ˜λ©΄ μ‹€ν–‰
1689
  document.addEventListener('DOMContentLoaded', function() {
1690
  console.log("DOM λ‘œλ“œ μ™„λ£Œ, 이벀트 μ„€μ • μ‹œμž‘");
@@ -1746,6 +2501,40 @@ HTML = """
1746
  $id('loadingPages').style.display = 'none';
1747
  currentLoadingPdfPath = null;
1748
  currentPdfId = null;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1749
  });
1750
  }
1751
  });
@@ -2015,6 +2804,8 @@ HTML = """
2015
  createFlipBook(cachedData.pages);
2016
  // ν˜„μž¬ μ—΄λ¦° PDF의 ID μ €μž₯
2017
  currentPdfId = pdfId;
 
 
2018
  return;
2019
  }
2020
  } catch (error) {
@@ -2054,6 +2845,8 @@ HTML = """
2054
 
2055
  // ν˜„μž¬ μ—΄λ¦° PDF의 ID μ €μž₯
2056
  currentPdfId = pdfId;
 
 
2057
  }
2058
  } catch (error) {
2059
  console.error("PDF ID둜 μ—΄κΈ° μ‹€νŒ¨:", error);
@@ -2092,10 +2885,19 @@ HTML = """
2092
  const card = document.querySelectorAll('.card')[i];
2093
  if (card && card.dataset.pdfId) {
2094
  currentPdfId = card.dataset.pdfId;
 
 
2095
  } else {
2096
  currentPdfId = null;
 
 
2097
  }
2098
 
 
 
 
 
 
2099
  // κΈ°μ‘΄ FlipBook 정리
2100
  if(fb) {
2101
  fb.destroy();
@@ -2430,6 +3232,14 @@ HTML = """
2430
  $id('homeButton').style.display=showHome?'none':'block';
2431
  $id('adminPage').style.display='none';
2432
 
 
 
 
 
 
 
 
 
2433
  // λ·°μ–΄ λͺ¨λ“œμΌ λ•Œ μŠ€νƒ€μΌ λ³€κ²½
2434
  if(!showHome) {
2435
  document.body.classList.add('viewer-mode');
 
7
  import logging
8
  import threading
9
  import concurrent.futures
10
+ from openai import OpenAI
11
+ import fitz # PyMuPDF
12
 
13
  # λ‘œκΉ… μ„€μ •
14
  logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
 
39
  METADATA_DIR.mkdir(parents=True)
40
  PDF_METADATA_FILE = METADATA_DIR / "pdf_metadata.json"
41
 
42
+ # μž„λ² λ”© μΊμ‹œ 디렉토리 μ„€μ •
43
+ EMBEDDING_DIR = pathlib.Path("/data/embeddings") if os.path.exists("/data") else BASE / "embeddings"
44
+ if not EMBEDDING_DIR.exists():
45
+ EMBEDDING_DIR.mkdir(parents=True)
46
+
47
  # κ΄€λ¦¬μž λΉ„λ°€λ²ˆν˜Έ
48
  ADMIN_PASSWORD = os.getenv("PASSWORD", "admin") # ν™˜κ²½ λ³€μˆ˜μ—μ„œ κ°€μ Έμ˜€κΈ°, 기본값은 ν…ŒμŠ€νŠΈμš©
49
 
50
+ # OpenAI API ν‚€ μ„€μ •
51
+ OPENAI_API_KEY = os.getenv("LLM_API", "")
52
+ # API ν‚€κ°€ μ—†κ±°λ‚˜ λΉ„μ–΄μžˆμ„ λ•Œ ν”Œλž˜κ·Έ μ„€μ •
53
+ HAS_VALID_API_KEY = bool(OPENAI_API_KEY and OPENAI_API_KEY.strip())
54
+
55
+ if HAS_VALID_API_KEY:
56
+ try:
57
+ openai_client = OpenAI(api_key=OPENAI_API_KEY, timeout=30.0)
58
+ logger.info("OpenAI ν΄λΌμ΄μ–ΈνŠΈ μ΄ˆκΈ°ν™” 성곡")
59
+ except Exception as e:
60
+ logger.error(f"OpenAI ν΄λΌμ΄μ–ΈνŠΈ μ΄ˆκΈ°ν™” μ‹€νŒ¨: {e}")
61
+ HAS_VALID_API_KEY = False
62
+ else:
63
+ logger.warning("μœ νš¨ν•œ OpenAI API ν‚€κ°€ μ—†μŠ΅λ‹ˆλ‹€. AI κΈ°λŠ₯이 μ œν•œλ©λ‹ˆλ‹€.")
64
+ openai_client = None
65
+
66
  # μ „μ—­ μΊμ‹œ 객체
67
  pdf_cache: Dict[str, Dict[str, Any]] = {}
68
  # 캐싱 락
69
  cache_locks = {}
70
  # PDF 메타데이터 (ID to 경둜 λ§€ν•‘)
71
  pdf_metadata: Dict[str, str] = {}
72
+ # PDF μž„λ² λ”© μΊμ‹œ
73
+ pdf_embeddings: Dict[str, Dict[str, Any]] = {}
74
 
75
  # PDF 메타데이터 λ‘œλ“œ
76
  def load_pdf_metadata():
 
94
  except Exception as e:
95
  logger.error(f"메타데이터 μ €μž₯ 였λ₯˜: {e}")
96
 
 
97
  # PDF ID 생성 (파일λͺ… + νƒ€μž„μŠ€νƒ¬ν”„ 기반) - 더 λ‹¨μˆœν•˜κ³  μ•ˆμ „ν•œ λ°©μ‹μœΌλ‘œ λ³€κ²½
98
  def generate_pdf_id(filename: str) -> str:
99
  # 파일λͺ…μ—μ„œ ν™•μž₯자 제거
 
107
  random_suffix = uuid.uuid4().hex[:6]
108
  return f"{safe_name}_{timestamp}_{random_suffix}"
109
 
 
 
110
  # PDF 파일 λͺ©λ‘ κ°€μ Έμ˜€κΈ° (메인 λ””λ ‰ν† λ¦¬μš©)
111
  def get_pdf_files():
112
  pdf_files = []
 
168
  def get_cache_path(pdf_name: str):
169
  return CACHE_DIR / f"{pdf_name}_cache.json"
170
 
171
+ # μž„λ² λ”© μΊμ‹œ 파일 경둜 생성
172
+ def get_embedding_path(pdf_id: str):
173
+ return EMBEDDING_DIR / f"{pdf_id}_embedding.json"
174
+
175
+ # PDF ν…μŠ€νŠΈ μΆ”μΆœ ν•¨μˆ˜
176
+ def extract_pdf_text(pdf_path: str) -> List[Dict[str, Any]]:
177
+ try:
178
+ doc = fitz.open(pdf_path)
179
+ chunks = []
180
+
181
+ for page_num in range(len(doc)):
182
+ page = doc[page_num]
183
+ text = page.get_text()
184
+
185
+ # νŽ˜μ΄μ§€ ν…μŠ€νŠΈκ°€ μžˆλŠ” 경우만 μΆ”κ°€
186
+ if text.strip():
187
+ chunks.append({
188
+ "page": page_num + 1,
189
+ "text": text,
190
+ "chunk_id": f"page_{page_num + 1}"
191
+ })
192
+
193
+ return chunks
194
+ except Exception as e:
195
+ logger.error(f"PDF ν…μŠ€νŠΈ μΆ”μΆœ 였λ₯˜: {e}")
196
+ return []
197
+
198
+ # PDF ID둜 μž„λ² λ”© 생성 λ˜λŠ” κ°€μ Έμ˜€κΈ°
199
+ async def get_pdf_embedding(pdf_id: str) -> Dict[str, Any]:
200
+ try:
201
+ # μž„λ² λ”© μΊμ‹œ 확인
202
+ embedding_path = get_embedding_path(pdf_id)
203
+ if embedding_path.exists():
204
+ try:
205
+ with open(embedding_path, "r", encoding="utf-8") as f:
206
+ return json.load(f)
207
+ except Exception as e:
208
+ logger.error(f"μž„λ² λ”© μΊμ‹œ λ‘œλ“œ 였λ₯˜: {e}")
209
+
210
+ # PDF 경둜 찾기
211
+ pdf_path = get_pdf_path_by_id(pdf_id)
212
+ if not pdf_path:
213
+ raise ValueError(f"PDF ID {pdf_id}에 ν•΄λ‹Ήν•˜λŠ” νŒŒμΌμ„ 찾을 수 μ—†μŠ΅λ‹ˆλ‹€")
214
+
215
+ # ν…μŠ€νŠΈ μΆ”μΆœ
216
+ chunks = extract_pdf_text(pdf_path)
217
+ if not chunks:
218
+ raise ValueError(f"PDFμ—μ„œ ν…μŠ€νŠΈλ₯Ό μΆ”μΆœν•  수 μ—†μŠ΅λ‹ˆλ‹€: {pdf_path}")
219
+
220
+ # μž„λ² λ”© μ €μž₯ 및 λ°˜ν™˜
221
+ embedding_data = {
222
+ "pdf_id": pdf_id,
223
+ "pdf_path": pdf_path,
224
+ "chunks": chunks,
225
+ "created_at": time.time()
226
+ }
227
+
228
+ # μž„λ² λ”© μΊμ‹œ μ €μž₯
229
+ with open(embedding_path, "w", encoding="utf-8") as f:
230
+ json.dump(embedding_data, f, ensure_ascii=False)
231
+
232
+ return embedding_data
233
+
234
+ except Exception as e:
235
+ logger.error(f"PDF μž„λ² λ”© 생성 였λ₯˜: {e}")
236
+ return {"error": str(e), "pdf_id": pdf_id}
237
+
238
+ # PDF λ‚΄μš© 기반 μ§ˆμ˜μ‘λ‹΅
239
+ # PDF λ‚΄μš© 기반 μ§ˆμ˜μ‘λ‹΅ ν•¨μˆ˜ κ°œμ„ 
240
+ async def query_pdf(pdf_id: str, query: str) -> Dict[str, Any]:
241
+ try:
242
+ # API ν‚€κ°€ μ—†κ±°λ‚˜ μœ νš¨ν•˜μ§€ μ•Šμ€ 경우
243
+ if not HAS_VALID_API_KEY or not openai_client:
244
+ return {
245
+ "error": "OpenAI API ν‚€κ°€ μ„€μ •λ˜μ§€ μ•Šμ•˜μŠ΅λ‹ˆλ‹€.",
246
+ "answer": "μ£„μ†‘ν•©λ‹ˆλ‹€. ν˜„μž¬ AI κΈ°λŠ₯이 λΉ„ν™œμ„±ν™”λ˜μ–΄ μžˆμ–΄ μ§ˆλ¬Έμ— λ‹΅λ³€ν•  수 μ—†μŠ΅λ‹ˆλ‹€. μ‹œμŠ€ν…œ κ΄€λ¦¬μžμ—κ²Œ λ¬Έμ˜ν•˜μ„Έμš”."
247
+ }
248
+
249
+ # μž„λ² λ”© 데이터 κ°€μ Έμ˜€κΈ°
250
+ embedding_data = await get_pdf_embedding(pdf_id)
251
+ if "error" in embedding_data:
252
+ return {"error": embedding_data["error"]}
253
+
254
+ # 청크 ν…μŠ€νŠΈ λͺ¨μœΌκΈ° (μž„μ‹œλ‘œ κ°„λ‹¨ν•˜κ²Œ 전체 ν…μŠ€νŠΈ μ‚¬μš©)
255
+ all_text = "\n\n".join([f"Page {chunk['page']}: {chunk['text']}" for chunk in embedding_data["chunks"]])
256
+
257
+ # μ»¨ν…μŠ€νŠΈ 크기λ₯Ό κ³ λ €ν•˜μ—¬ ν…μŠ€νŠΈκ°€ λ„ˆλ¬΄ κΈΈλ©΄ μ•žλΆ€λΆ„λ§Œ μ‚¬μš©
258
+ max_context_length = 60000 # 토큰 μˆ˜κ°€ μ•„λ‹Œ 문자 수 κΈ°μ€€ (λŒ€λž΅μ μΈ μ œν•œ)
259
+ if len(all_text) > max_context_length:
260
+ all_text = all_text[:max_context_length] + "...(μ΄ν•˜ μƒλž΅)"
261
+
262
+ # μ‹œμŠ€ν…œ ν”„λ‘¬ν”„νŠΈ μ€€λΉ„
263
+ system_prompt = """
264
+ 당신은 PDF λ‚΄μš©μ„ 기반으둜 μ§ˆλ¬Έμ— λ‹΅λ³€ν•˜λŠ” λ„μš°λ―Έμž…λ‹ˆλ‹€. 제곡된 PDF μ»¨ν…μŠ€νŠΈ μ •λ³΄λ§Œμ„ μ‚¬μš©ν•˜μ—¬ λ‹΅λ³€ν•˜μ„Έμš”.
265
+ μ»¨ν…μŠ€νŠΈμ— κ΄€λ ¨ 정보가 μ—†λŠ” 경우, '제곡된 PDFμ—μ„œ ν•΄λ‹Ή 정보λ₯Ό 찾을 수 μ—†μŠ΅λ‹ˆλ‹€'라고 μ†”μ§νžˆ λ‹΅ν•˜μ„Έμš”.
266
+ 닡변은 λͺ…ν™•ν•˜κ³  κ°„κ²°ν•˜κ²Œ μž‘μ„±ν•˜κ³ , κ΄€λ ¨ νŽ˜μ΄μ§€ 번호λ₯Ό μΈμš©ν•˜μ„Έμš”.
267
+ """
268
+
269
+ # gpt-4.1-mini λͺ¨λΈ μ‚¬μš©
270
+ try:
271
+ # νƒ€μž„μ•„μ›ƒ 및 μž¬μ‹œλ„ μ„€μ • κ°œμ„ 
272
+ for attempt in range(3): # μ΅œλŒ€ 3번 μž¬μ‹œλ„
273
+ try:
274
+ response = openai_client.chat.completions.create(
275
+ model="gpt-4.1-mini",
276
+ messages=[
277
+ {"role": "system", "content": system_prompt},
278
+ {"role": "user", "content": f"λ‹€μŒ PDF λ‚΄μš©μ„ μ°Έκ³ ν•˜μ—¬ μ§ˆλ¬Έμ— λ‹΅λ³€ν•΄μ£Όμ„Έμš”.\n\nPDF λ‚΄μš©:\n{all_text}\n\n질문: {query}"}
279
+ ],
280
+ temperature=0.7,
281
+ max_tokens=2048,
282
+ timeout=30.0 # 30초 νƒ€μž„μ•„μ›ƒ
283
+ )
284
+
285
+ answer = response.choices[0].message.content
286
+ return {
287
+ "answer": answer,
288
+ "pdf_id": pdf_id,
289
+ "query": query
290
+ }
291
+ except Exception as api_error:
292
+ logger.error(f"OpenAI API 호좜 였λ₯˜ (μ‹œλ„ {attempt+1}/3): {api_error}")
293
+ if attempt == 2: # λ§ˆμ§€λ§‰ μ‹œλ„μ—μ„œλ„ μ‹€νŒ¨
294
+ raise api_error
295
+ await asyncio.sleep(1 * (attempt + 1)) # μž¬μ‹œλ„ κ°„ μ§€μ—° μ‹œκ°„ 증가
296
+
297
+ # μ—¬κΈ°κΉŒμ§€ λ„λ‹¬ν•˜μ§€ μ•Šμ•„μ•Ό 함
298
+ raise Exception("API 호좜 μž¬μ‹œλ„ λͺ¨λ‘ μ‹€νŒ¨")
299
+ except Exception as api_error:
300
+ logger.error(f"OpenAI API 호좜 μ΅œμ’… 였λ₯˜: {api_error}")
301
+ # 였λ₯˜ μœ ν˜•μ— λ”°λ₯Έ 더 λͺ…ν™•ν•œ λ©”μ‹œμ§€ 제곡
302
+ error_message = str(api_error)
303
+ if "Connection" in error_message:
304
+ return {"error": "OpenAI μ„œλ²„μ™€ μ—°κ²°ν•  수 μ—†μŠ΅λ‹ˆλ‹€. 인터넷 연결을 ν™•μΈν•˜μ„Έμš”."}
305
+ elif "Unauthorized" in error_message or "Authentication" in error_message:
306
+ return {"error": "API ν‚€κ°€ μœ νš¨ν•˜μ§€ μ•ŠμŠ΅λ‹ˆλ‹€."}
307
+ elif "Rate limit" in error_message:
308
+ return {"error": "API 호좜 ν•œλ„λ₯Ό μ΄ˆκ³Όν–ˆμŠ΅λ‹ˆλ‹€. μž μ‹œ ν›„ λ‹€μ‹œ μ‹œλ„ν•˜μ„Έμš”."}
309
+ else:
310
+ return {"error": f"AI 응닡 생성 쀑 였λ₯˜κ°€ λ°œμƒν–ˆμŠ΅λ‹ˆλ‹€: {error_message}"}
311
+
312
+ except Exception as e:
313
+ logger.error(f"μ§ˆμ˜μ‘λ‹΅ 처리 였λ₯˜: {e}")
314
+ return {"error": str(e)}
315
+
316
+ # PDF μš”μ•½ 생성
317
+ # PDF μš”μ•½ 생성 ν•¨μˆ˜ κ°œμ„ 
318
+ async def summarize_pdf(pdf_id: str) -> Dict[str, Any]:
319
+ try:
320
+ # API ν‚€κ°€ μ—†κ±°λ‚˜ μœ νš¨ν•˜μ§€ μ•Šμ€ 경우
321
+ if not HAS_VALID_API_KEY or not openai_client:
322
+ return {
323
+ "error": "OpenAI API ν‚€κ°€ μ„€μ •λ˜μ§€ μ•Šμ•˜μŠ΅λ‹ˆλ‹€. 'LLM_API' ν™˜κ²½ λ³€μˆ˜λ₯Ό ν™•μΈν•˜μ„Έμš”.",
324
+ "summary": "API ν‚€κ°€ μ—†μ–΄ μš”μ•½μ„ 생성할 수 μ—†μŠ΅λ‹ˆλ‹€. μ‹œμŠ€ν…œ κ΄€λ¦¬μžμ—κ²Œ λ¬Έμ˜ν•˜μ„Έμš”."
325
+ }
326
+
327
+ # μž„λ² λ”© 데이터 κ°€μ Έμ˜€κΈ°
328
+ embedding_data = await get_pdf_embedding(pdf_id)
329
+ if "error" in embedding_data:
330
+ return {"error": embedding_data["error"], "summary": "PDFμ—μ„œ ν…μŠ€νŠΈλ₯Ό μΆ”μΆœν•  수 μ—†μŠ΅λ‹ˆλ‹€."}
331
+
332
+ # 청크 ν…μŠ€νŠΈ λͺ¨μœΌκΈ° (μ œν•œλœ 길이)
333
+ all_text = "\n\n".join([f"Page {chunk['page']}: {chunk['text']}" for chunk in embedding_data["chunks"]])
334
+
335
+ # μ»¨ν…μŠ€νŠΈ 크기λ₯Ό κ³ λ €ν•˜μ—¬ ν…μŠ€νŠΈκ°€ λ„ˆλ¬΄ κΈΈλ©΄ μ•žλΆ€λΆ„λ§Œ 사���
336
+ max_context_length = 60000 # 토큰 μˆ˜κ°€ μ•„λ‹Œ 문자 수 κΈ°μ€€ (λŒ€λž΅μ μΈ μ œν•œ)
337
+ if len(all_text) > max_context_length:
338
+ all_text = all_text[:max_context_length] + "...(μ΄ν•˜ μƒλž΅)"
339
+
340
+ # OpenAI API 호좜
341
+ try:
342
+ # νƒ€μž„μ•„μ›ƒ 및 μž¬μ‹œλ„ μ„€μ • κ°œμ„ 
343
+ for attempt in range(3): # μ΅œλŒ€ 3번 μž¬μ‹œλ„
344
+ try:
345
+ response = openai_client.chat.completions.create(
346
+ model="gpt-4.1-mini",
347
+ messages=[
348
+ {"role": "system", "content": "λ‹€μŒ PDF λ‚΄μš©μ„ κ°„κ²°ν•˜κ²Œ μš”μ•½ν•΄μ£Όμ„Έμš”. 핡심 μ£Όμ œμ™€ μ£Όμš” 포인트λ₯Ό ν¬ν•¨ν•œ μš”μ•½μ„ 500자 μ΄λ‚΄λ‘œ μž‘μ„±ν•΄μ£Όμ„Έμš”."},
349
+ {"role": "user", "content": f"PDF λ‚΄μš©:\n{all_text}"}
350
+ ],
351
+ temperature=0.7,
352
+ max_tokens=1024,
353
+ timeout=30.0 # 30초 νƒ€μž„μ•„μ›ƒ
354
+ )
355
+
356
+ summary = response.choices[0].message.content
357
+ return {
358
+ "summary": summary,
359
+ "pdf_id": pdf_id
360
+ }
361
+ except Exception as api_error:
362
+ logger.error(f"OpenAI API 호좜 였λ₯˜ (μ‹œλ„ {attempt+1}/3): {api_error}")
363
+ if attempt == 2: # λ§ˆμ§€λ§‰ μ‹œλ„μ—μ„œλ„ μ‹€νŒ¨
364
+ raise api_error
365
+ await asyncio.sleep(1 * (attempt + 1)) # μž¬μ‹œλ„ κ°„ μ§€μ—° μ‹œκ°„ 증가
366
+
367
+ # μ—¬κΈ°κΉŒμ§€ λ„λ‹¬ν•˜μ§€ μ•Šμ•„μ•Ό 함
368
+ raise Exception("API 호좜 μž¬μ‹œλ„ λͺ¨λ‘ μ‹€νŒ¨")
369
+ except Exception as api_error:
370
+ logger.error(f"OpenAI API 호좜 μ΅œμ’… 였λ₯˜: {api_error}")
371
+ # 였λ₯˜ μœ ν˜•μ— λ”°λ₯Έ 더 λͺ…ν™•ν•œ λ©”μ‹œμ§€ 제곡
372
+ error_message = str(api_error)
373
+ if "Connection" in error_message:
374
+ return {"error": "OpenAI μ„œλ²„μ™€ μ—°κ²°ν•  수 μ—†μŠ΅λ‹ˆλ‹€. 인터넷 연결을 ν™•μΈν•˜μ„Έμš”.", "pdf_id": pdf_id}
375
+ elif "Unauthorized" in error_message or "Authentication" in error_message:
376
+ return {"error": "API ν‚€κ°€ μœ νš¨ν•˜μ§€ μ•ŠμŠ΅λ‹ˆλ‹€.", "pdf_id": pdf_id}
377
+ elif "Rate limit" in error_message:
378
+ return {"error": "API 호좜 ν•œλ„λ₯Ό μ΄ˆκ³Όν–ˆμŠ΅λ‹ˆλ‹€. μž μ‹œ ν›„ λ‹€μ‹œ μ‹œλ„ν•˜μ„Έμš”.", "pdf_id": pdf_id}
379
+ else:
380
+ return {"error": f"AI μš”μ•½ 생성 쀑 였λ₯˜κ°€ λ°œμƒν–ˆμŠ΅λ‹ˆλ‹€: {error_message}", "pdf_id": pdf_id}
381
+
382
+ except Exception as e:
383
+ logger.error(f"PDF μš”μ•½ 생성 였λ₯˜: {e}")
384
+ return {
385
+ "error": str(e),
386
+ "summary": "PDF μš”μ•½ 쀑 였λ₯˜κ°€ λ°œμƒν–ˆμŠ΅λ‹ˆλ‹€. PDF νŽ˜μ΄μ§€ μˆ˜κ°€ λ„ˆλ¬΄ λ§Žκ±°λ‚˜ ν˜•μ‹μ΄ μ§€μ›λ˜μ§€ μ•Šμ„ 수 μžˆμŠ΅λ‹ˆλ‹€."
387
+ }
388
+
389
+
390
  # μ΅œμ ν™”λœ PDF νŽ˜μ΄μ§€ 캐싱 ν•¨μˆ˜
391
  async def cache_pdf(pdf_path: str):
392
  try:
 
541
  pdf_cache[pdf_name]["status"] = "error"
542
  pdf_cache[pdf_name]["error"] = str(e)
543
 
 
544
  # PDF ID둜 PDF 경둜 μ°ΎκΈ° (κ°œμ„ λœ 검색 둜직)
545
  def get_pdf_path_by_id(pdf_id: str) -> str:
546
  logger.info(f"PDF ID둜 파일 쑰회: {pdf_id}")
 
774
  return {name: {"status": info["status"], "progress": info.get("progress", 0)}
775
  for name, info in pdf_cache.items()}
776
 
777
+ # API μ—”λ“œν¬μΈνŠΈ: PDF에 λŒ€ν•œ μ§ˆμ˜μ‘λ‹΅
778
+ @app.post("/api/ai/query-pdf/{pdf_id}")
779
+ async def api_query_pdf(pdf_id: str, query: Dict[str, str]):
780
+ try:
781
+ user_query = query.get("query", "")
782
+ if not user_query:
783
+ return JSONResponse(content={"error": "질문이 μ œκ³΅λ˜μ§€ μ•Šμ•˜μŠ΅λ‹ˆλ‹€"}, status_code=400)
784
+
785
+ # PDF 경둜 확인
786
+ pdf_path = get_pdf_path_by_id(pdf_id)
787
+ if not pdf_path:
788
+ return JSONResponse(content={"error": f"PDF ID {pdf_id}에 ν•΄λ‹Ήν•˜λŠ” νŒŒμΌμ„ 찾을 수 μ—†μŠ΅λ‹ˆλ‹€"}, status_code=404)
789
+
790
+ # μ§ˆμ˜μ‘λ‹΅ 처리
791
+ result = await query_pdf(pdf_id, user_query)
792
+
793
+ if "error" in result:
794
+ return JSONResponse(content={"error": result["error"]}, status_code=500)
795
+
796
+ return result
797
+ except Exception as e:
798
+ logger.error(f"μ§ˆμ˜μ‘λ‹΅ API 였λ₯˜: {e}")
799
+ return JSONResponse(content={"error": str(e)}, status_code=500)
800
+
801
+ # API μ—”λ“œν¬μΈνŠΈ: PDF μš”μ•½
802
+ @app.get("/api/ai/summarize-pdf/{pdf_id}")
803
+ async def api_summarize_pdf(pdf_id: str):
804
+ try:
805
+ # PDF 경둜 확인
806
+ pdf_path = get_pdf_path_by_id(pdf_id)
807
+ if not pdf_path:
808
+ return JSONResponse(content={"error": f"PDF ID {pdf_id}에 ν•΄λ‹Ήν•˜λŠ” νŒŒμΌμ„ 찾을 수 μ—†μŠ΅λ‹ˆλ‹€"}, status_code=404)
809
+
810
+ # μš”μ•½ 처리
811
+ result = await summarize_pdf(pdf_id)
812
+
813
+ if "error" in result:
814
+ return JSONResponse(content={"error": result["error"]}, status_code=500)
815
+
816
+ return result
817
+ except Exception as e:
818
+ logger.error(f"PDF μš”μ•½ API 였λ₯˜: {e}")
819
+ return JSONResponse(content={"error": str(e)}, status_code=500)
820
+
821
  # API μ—”λ“œν¬μΈνŠΈ: μΊμ‹œλœ PDF μ½˜ν…μΈ  제곡 (점진적 λ‘œλ”© 지원)
822
  @app.get("/api/cached-pdf")
823
  async def get_cached_pdf(path: str, background_tasks: BackgroundTasks):
 
1102
  return RedirectResponse(url=f"/view/{pdf_id}")
1103
  return get_html_content()
1104
 
1105
+ # HTML λ¬Έμžμ—΄ (AI λ²„νŠΌ 및 챗봇 UI μΆ”κ°€)
1106
  HTML = """
1107
  <!doctype html>
1108
  <html lang="ko">
 
1128
  --secondary-color: #ffd6e0; /* νŒŒμŠ€ν…” 핑크 */
1129
  --tertiary-color: #c3fae8; /* νŒŒμŠ€ν…” 민트 */
1130
  --accent-color: #d0bfff; /* νŒŒμŠ€ν…” νΌν”Œ */
1131
+ --ai-color: #86e8ab; /* AI λ²„νŠΌ 색상 */
1132
+ --ai-hover: #65d68a; /* AI ν˜Έλ²„ 색상 */
1133
  --bg-color: #f8f9fa; /* 밝은 λ°°κ²½ */
1134
  --text-color: #495057; /* λΆ€λ“œλŸ¬μš΄ μ–΄λ‘μš΄ 색 */
1135
  --card-bg: #ffffff; /* μΉ΄λ“œ 배경색 */
 
1159
  }
1160
 
1161
  /* 헀더 제λͺ© 제거 및 Home λ²„νŠΌ λ ˆμ΄μ–΄ 처리 */
1162
+ .floating-home, .floating-ai {
1163
  position: fixed;
1164
  top: 20px;
1165
  left: 20px;
 
1178
  overflow: hidden;
1179
  }
1180
 
1181
+ .floating-ai {
1182
+ top: 90px; /* Home λ²„νŠΌ μ•„λž˜μ— μœ„μΉ˜ */
1183
+ background: rgba(134, 232, 171, 0.9); /* AI λ²„νŠΌ 색상 */
1184
+ }
1185
+
1186
+ .floating-home:hover, .floating-ai:hover {
1187
  transform: scale(1.05);
1188
  box-shadow: var(--shadow-lg);
1189
  }
1190
 
1191
+ .floating-home .icon, .floating-ai .icon {
1192
  display: flex;
1193
  justify-content: center;
1194
  align-items: center;
 
1199
  transition: var(--transition);
1200
  }
1201
 
1202
+ .floating-ai .icon {
1203
+ color: white;
1204
+ }
1205
+
1206
  .floating-home:hover .icon {
1207
  color: #8bc5f8;
1208
  }
1209
 
1210
+ .floating-ai:hover .icon {
1211
+ color: #ffffff;
1212
+ }
1213
+
1214
+ .floating-home .title, .floating-ai .title {
1215
  position: absolute;
1216
  left: 70px;
1217
  background: rgba(255, 255, 255, 0.95);
 
1227
  transition: all 0.3s ease;
1228
  }
1229
 
1230
+ .floating-home:hover .title, .floating-ai:hover .title {
1231
  opacity: 1;
1232
  transform: translateX(0);
1233
  }
 
1771
  grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
1772
  }
1773
 
1774
+ /* AI 챗봇 UI μŠ€νƒ€μΌ */
1775
+ #aiChatContainer {
1776
+ display: none;
1777
+ position: fixed;
1778
+ top: 0;
1779
+ right: 0;
1780
+ width: 400px;
1781
+ height: 100%;
1782
+ background: rgba(255, 255, 255, 0.95);
1783
+ backdrop-filter: blur(10px);
1784
+ box-shadow: -5px 0 20px rgba(0, 0, 0, 0.1);
1785
+ z-index: 9999;
1786
+ transition: all 0.3s ease;
1787
+ transform: translateX(100%);
1788
+ padding: 20px;
1789
+ box-sizing: border-box;
1790
+ display: flex;
1791
+ flex-direction: column;
1792
+ }
1793
+
1794
+ #aiChatContainer.active {
1795
+ transform: translateX(0);
1796
+ }
1797
+
1798
+ #aiChatHeader {
1799
+ display: flex;
1800
+ justify-content: space-between;
1801
+ align-items: center;
1802
+ margin-bottom: 15px;
1803
+ padding-bottom: 15px;
1804
+ border-bottom: 1px solid rgba(0, 0, 0, 0.1);
1805
+ }
1806
+
1807
+ #aiChatHeader h3 {
1808
+ margin: 0;
1809
+ color: #333;
1810
+ font-size: 18px;
1811
+ display: flex;
1812
+ align-items: center;
1813
+ }
1814
+
1815
+ #aiChatHeader h3 i {
1816
+ margin-right: 10px;
1817
+ color: var(--ai-color);
1818
+ }
1819
+
1820
+ #aiChatClose {
1821
+ background: none;
1822
+ border: none;
1823
+ cursor: pointer;
1824
+ font-size: 18px;
1825
+ color: #666;
1826
+ transition: var(--transition);
1827
+ }
1828
+
1829
+ #aiChatClose:hover {
1830
+ color: #333;
1831
+ transform: scale(1.1);
1832
+ }
1833
+
1834
+ #aiChatMessages {
1835
+ flex: 1;
1836
+ overflow-y: auto;
1837
+ padding: 10px 0;
1838
+ margin-bottom: 15px;
1839
+ }
1840
+
1841
+ .chat-message {
1842
+ margin-bottom: 15px;
1843
+ display: flex;
1844
+ align-items: flex-start;
1845
+ }
1846
+
1847
+ .chat-message.user {
1848
+ flex-direction: row-reverse;
1849
+ }
1850
+
1851
+ .chat-avatar {
1852
+ width: 35px;
1853
+ height: 35px;
1854
+ border-radius: 50%;
1855
+ display: flex;
1856
+ justify-content: center;
1857
+ align-items: center;
1858
+ margin-right: 10px;
1859
+ flex-shrink: 0;
1860
+ }
1861
+
1862
+ .chat-message.user .chat-avatar {
1863
+ margin-right: 0;
1864
+ margin-left: 10px;
1865
+ background: var(--primary-color);
1866
+ color: white;
1867
+ }
1868
+
1869
+ .chat-message.ai .chat-avatar {
1870
+ background: var(--ai-color);
1871
+ color: white;
1872
+ }
1873
+
1874
+ .chat-content {
1875
+ background: #f1f1f1;
1876
+ padding: 12px 15px;
1877
+ border-radius: 18px;
1878
+ max-width: 75%;
1879
+ word-break: break-word;
1880
+ position: relative;
1881
+ font-size: 14px;
1882
+ line-height: 1.4;
1883
+ }
1884
+
1885
+ .chat-message.user .chat-content {
1886
+ background: var(--primary-color);
1887
+ color: white;
1888
+ border-bottom-right-radius: 4px;
1889
+ }
1890
+
1891
+ .chat-message.ai .chat-content {
1892
+ background: #f1f1f1;
1893
+ color: #333;
1894
+ border-bottom-left-radius: 4px;
1895
+ }
1896
+
1897
+ #aiChatForm {
1898
+ display: flex;
1899
+ border-top: 1px solid rgba(0, 0, 0, 0.1);
1900
+ padding-top: 15px;
1901
+ }
1902
+
1903
+ #aiChatInput {
1904
+ flex: 1;
1905
+ padding: 12px 15px;
1906
+ border: 1px solid #ddd;
1907
+ border-radius: 25px;
1908
+ font-size: 14px;
1909
+ outline: none;
1910
+ transition: var(--transition);
1911
+ }
1912
+
1913
+ #aiChatInput:focus {
1914
+ border-color: var(--ai-color);
1915
+ box-shadow: 0 0 0 2px rgba(134, 232, 171, 0.2);
1916
+ }
1917
+
1918
+ #aiChatSubmit {
1919
+ background: var(--ai-color);
1920
+ border: none;
1921
+ color: white;
1922
+ width: 45px;
1923
+ height: 45px;
1924
+ border-radius: 50%;
1925
+ margin-left: 10px;
1926
+ display: flex;
1927
+ justify-content: center;
1928
+ align-items: center;
1929
+ cursor: pointer;
1930
+ transition: var(--transition);
1931
+ }
1932
+
1933
+ #aiChatSubmit:hover {
1934
+ background: var(--ai-hover);
1935
+ transform: scale(1.05);
1936
+ }
1937
+
1938
+ #aiChatSubmit:disabled {
1939
+ background: #ccc;
1940
+ cursor: not-allowed;
1941
+ }
1942
+
1943
+ .typing-indicator {
1944
+ display: flex;
1945
+ align-items: center;
1946
+ margin-top: 5px;
1947
+ font-size: 12px;
1948
+ color: #666;
1949
+ }
1950
+
1951
+ .typing-indicator span {
1952
+ height: 8px;
1953
+ width: 8px;
1954
+ background: var(--ai-color);
1955
+ border-radius: 50%;
1956
+ display: inline-block;
1957
+ margin-right: 3px;
1958
+ animation: typing 1s infinite;
1959
+ }
1960
+
1961
+ .typing-indicator span:nth-child(2) {
1962
+ animation-delay: 0.2s;
1963
+ }
1964
+
1965
+ .typing-indicator span:nth-child(3) {
1966
+ animation-delay: 0.4s;
1967
+ }
1968
+
1969
+ @keyframes typing {
1970
+ 0% { transform: translateY(0); }
1971
+ 50% { transform: translateY(-5px); }
1972
+ 100% { transform: translateY(0); }
1973
+ }
1974
+
1975
+ .chat-time {
1976
+ font-size: 10px;
1977
+ color: #999;
1978
+ margin-top: 5px;
1979
+ text-align: right;
1980
+ }
1981
+
1982
+ /* μ½”λ“œ 블둝 μŠ€νƒ€μΌ */
1983
+ .chat-content pre {
1984
+ background: rgba(0, 0, 0, 0.05);
1985
+ padding: 10px;
1986
+ border-radius: 5px;
1987
+ overflow-x: auto;
1988
+ font-family: monospace;
1989
+ font-size: 12px;
1990
+ margin: 10px 0;
1991
+ }
1992
+
1993
+ /* λ§ˆν¬λ‹€μš΄ μŠ€νƒ€μΌ */
1994
+ .chat-content strong {
1995
+ font-weight: bold;
1996
+ }
1997
+
1998
+ .chat-content em {
1999
+ font-style: italic;
2000
+ }
2001
+
2002
+ .chat-content ul, .chat-content ol {
2003
+ margin-left: 20px;
2004
+ margin-top: 5px;
2005
+ margin-bottom: 5px;
2006
+ }
2007
+
2008
+ /* 곡유 λ²„νŠΌ */
2009
+ #shareChat {
2010
+ padding: 8px 15px;
2011
+ background: #f1f1f1;
2012
+ border: none;
2013
+ border-radius: 20px;
2014
+ font-size: 12px;
2015
+ color: #666;
2016
+ cursor: pointer;
2017
+ margin-top: 5px;
2018
+ transition: var(--transition);
2019
+ }
2020
+
2021
+ #shareChat:hover {
2022
+ background: #ddd;
2023
+ }
2024
+
2025
  /* λ°˜μ‘ν˜• λ””μžμΈ */
2026
  @media (max-width: 768px) {
2027
  .grid {
 
2038
  padding: 10px 20px;
2039
  }
2040
 
2041
+ .floating-home, .floating-ai {
2042
  width: 50px;
2043
  height: 50px;
2044
  }
2045
 
2046
+ .floating-home .icon, .floating-ai .icon {
2047
  font-size: 18px;
2048
  }
2049
 
 
2051
  padding: 6px 15px;
2052
  font-size: 12px;
2053
  }
2054
+
2055
+ #aiChatContainer {
2056
+ width: 100%;
2057
+ }
2058
  }
2059
  </style>
2060
  </head>
 
2065
  <div class="title">ν™ˆμœΌλ‘œ λŒμ•„κ°€κΈ°</div>
2066
  </div>
2067
 
2068
+ <!-- AI λ²„νŠΌ μΆ”κ°€ -->
2069
+ <div id="aiButton" class="floating-ai" style="display:none;">
2070
+ <div class="icon"><i class="fas fa-robot"></i></div>
2071
+ <div class="title">AI μ–΄μ‹œμŠ€ν„΄νŠΈ</div>
2072
+ </div>
2073
+
2074
+ <!-- AI 챗봇 μ»¨ν…Œμ΄λ„ˆ -->
2075
+ <div id="aiChatContainer">
2076
+ <div id="aiChatHeader">
2077
+ <h3><i class="fas fa-robot"></i> AI μ–΄μ‹œμŠ€ν„΄νŠΈ</h3>
2078
+ <button id="aiChatClose"><i class="fas fa-times"></i></button>
2079
+ </div>
2080
+ <div id="aiChatMessages"></div>
2081
+ <form id="aiChatForm">
2082
+ <input type="text" id="aiChatInput" placeholder="PDF에 λŒ€ν•΄ μ§ˆλ¬Έν•˜μ„Έμš”..." autocomplete="off">
2083
+ <button type="submit" id="aiChatSubmit"><i class="fas fa-paper-plane"></i></button>
2084
+ </form>
2085
+ </div>
2086
+
2087
  <!-- κ΄€λ¦¬μž λ²„νŠΌ -->
2088
  <div id="adminButton">
2089
  <i class="fas fa-cog"></i> Admin
 
2161
  let audioInitialized = false;
2162
  let audioContext = null;
2163
 
2164
+ // AI 챗봇 κ΄€λ ¨ λ³€μˆ˜
2165
+ let isAiChatActive = false;
2166
+ let isAiProcessing = false;
2167
+ let hasLoadedSummary = false;
2168
+
2169
  // μ˜€λ””μ˜€ μ΄ˆκΈ°ν™” ν•¨μˆ˜
2170
  function initializeAudio() {
2171
  if (audioInitialized) return Promise.resolve();
 
2263
  /* ── μœ ν‹Έ ── */
2264
  function $id(id){return document.getElementById(id)}
2265
 
2266
+ // ν˜„μž¬ μ‹œκ°„μ„ ν¬λ§·νŒ…ν•˜λŠ” ν•¨μˆ˜
2267
+ function formatTime() {
2268
+ const now = new Date();
2269
+ const hours = now.getHours().toString().padStart(2, '0');
2270
+ const minutes = now.getMinutes().toString().padStart(2, '0');
2271
+ return `${hours}:${minutes}`;
2272
+ }
2273
+
2274
+ // AI 챗봇 λ©”μ‹œμ§€ μΆ”κ°€ ν•¨μˆ˜
2275
+ function addChatMessage(content, isUser = false) {
2276
+ const messagesContainer = $id('aiChatMessages');
2277
+ const messageElement = document.createElement('div');
2278
+ messageElement.className = `chat-message ${isUser ? 'user' : 'ai'}`;
2279
+
2280
+ const currentTime = formatTime();
2281
+
2282
+ messageElement.innerHTML = `
2283
+ <div class="chat-avatar">
2284
+ <i class="fas ${isUser ? 'fa-user' : 'fa-robot'}"></i>
2285
+ </div>
2286
+ <div class="chat-bubble">
2287
+ <div class="chat-content">${content}</div>
2288
+ <div class="chat-time">${currentTime}</div>
2289
+ </div>
2290
+ `;
2291
+
2292
+ messagesContainer.appendChild(messageElement);
2293
+ messagesContainer.scrollTop = messagesContainer.scrollHeight;
2294
+ return messageElement;
2295
+ }
2296
+
2297
+ // λ‘œλ”© ν‘œμ‹œκΈ° μΆ”κ°€ ν•¨μˆ˜
2298
+ function addTypingIndicator() {
2299
+ const messagesContainer = $id('aiChatMessages');
2300
+ const indicatorElement = document.createElement('div');
2301
+ indicatorElement.className = 'typing-indicator';
2302
+ indicatorElement.innerHTML = `
2303
+ <div class="chat-avatar">
2304
+ <i class="fas fa-robot"></i>
2305
+ </div>
2306
+ <div>
2307
+ <span></span>
2308
+ <span></span>
2309
+ <span></span>
2310
+ </div>
2311
+ `;
2312
+ messagesContainer.appendChild(indicatorElement);
2313
+ messagesContainer.scrollTop = messagesContainer.scrollHeight;
2314
+ return indicatorElement;
2315
+ }
2316
+
2317
+ // AI 챗봇 ν† κΈ€ ν•¨μˆ˜
2318
+ function toggleAiChat(show = true) {
2319
+ const aiChatContainer = $id('aiChatContainer');
2320
+
2321
+ if (show) {
2322
+ // 챗봇 ν‘œμ‹œ
2323
+ aiChatContainer.style.display = 'flex';
2324
+ setTimeout(() => {
2325
+ aiChatContainer.classList.add('active');
2326
+ }, 10);
2327
+ isAiChatActive = true;
2328
+
2329
+ // 처음 μ—΄ λ•Œ μžλ™ μš”μ•½ λ‘œλ“œ
2330
+ if (!hasLoadedSummary && currentPdfId) {
2331
+ loadPdfSummary();
2332
+ }
2333
+ } else {
2334
+ // 챗봇 숨기기
2335
+ aiChatContainer.classList.remove('active');
2336
+ setTimeout(() => {
2337
+ aiChatContainer.style.display = 'none';
2338
+ }, 300);
2339
+ isAiChatActive = false;
2340
+ }
2341
+ }
2342
+
2343
+ // PDF μš”μ•½ λ‘œλ“œ ν•¨μˆ˜
2344
+ // PDF μš”μ•½ λ‘œλ“œ ν•¨μˆ˜
2345
+ async function loadPdfSummary() {
2346
+ if (!currentPdfId || isAiProcessing || hasLoadedSummary) return;
2347
+
2348
+ try {
2349
+ isAiProcessing = true;
2350
+ const typingIndicator = addTypingIndicator();
2351
+
2352
+ // μ„œλ²„μ— μš”μ•½ μš”μ²­
2353
+ const response = await fetch(`/api/ai/summarize-pdf/${currentPdfId}`);
2354
+ const data = await response.json();
2355
+
2356
+ // λ‘œλ”© ν‘œμ‹œκΈ° 제거
2357
+ typingIndicator.remove();
2358
+
2359
+ if (data.error) {
2360
+ // 였λ₯˜ λ©”μ‹œμ§€ ν‘œμ‹œ
2361
+ addChatMessage(`PDF μš”μ•½μ„ μƒμ„±ν•˜λŠ” 쀑 λ¬Έμ œκ°€ λ°œμƒν–ˆμŠ΅λ‹ˆλ‹€: ${data.error}<br><br>계속 μ§ˆλ¬Έμ„ μž…λ ₯ν•˜μ‹œλ©΄ PDF λ‚΄μš©μ„ 기반으둜 닡변을 μ‹œλ„ν•˜κ² μŠ΅λ‹ˆλ‹€.`);
2362
+
2363
+ // μš”μ•½μ΄ μ‹€νŒ¨ν•΄λ„ νŠΉμ • κ²½μš°μ—λŠ” μ‚¬μš©μžμ—κ²Œ μ•Œλ¦¬κ³  계속 μ‚¬μš© κ°€λŠ₯ν•˜λ„λ‘ μ„€μ •
2364
+ if (data.summary) {
2365
+ addChatMessage(`<strong>PDFμ—μ„œ μΆ”μΆœν•œ 정보:</strong><br>${data.summary}`);
2366
+ hasLoadedSummary = true;
2367
+ }
2368
+ } else {
2369
+ // ν™˜μ˜ λ©”μ‹œμ§€μ™€ μš”μ•½ μΆ”κ°€
2370
+ addChatMessage(`μ•ˆλ…•ν•˜μ„Έμš”! 이 PDF에 λŒ€ν•΄ μ–΄λ–€ 것이든 μ§ˆλ¬Έν•΄μ£Όμ„Έμš”. μ œκ°€ λ„μ™€λ“œλ¦¬κ² μŠ΅λ‹ˆλ‹€.<br><br><strong>PDF μš”μ•½:</strong><br>${data.summary}`);
2371
+ hasLoadedSummary = true;
2372
+ }
2373
+ } catch (error) {
2374
+ console.error("PDF μš”μ•½ λ‘œλ“œ 였λ₯˜:", error);
2375
+ addChatMessage(`PDF μš”μ•½μ„ λ‘œλ“œν•˜λŠ” 쀑 였λ₯˜κ°€ λ°œμƒν–ˆμŠ΅λ‹ˆλ‹€. μ„œλ²„ 연결을 ν™•μΈν•΄μ£Όμ„Έμš”.<br><br>μ–΄λ–€ μ§ˆλ¬Έμ΄λ“  μž…λ ₯ν•˜μ‹œλ©΄ μ΅œμ„ μ„ λ‹€ν•΄ λ‹΅λ³€ν•˜κ² μŠ΅λ‹ˆλ‹€.`);
2376
+ } finally {
2377
+ isAiProcessing = false;
2378
+ }
2379
+ }
2380
+
2381
+ // 질문 제좜 ν•¨μˆ˜
2382
+ async function submitQuestion(question) {
2383
+ if (!currentPdfId || isAiProcessing || !question.trim()) return;
2384
+
2385
+ try {
2386
+ isAiProcessing = true;
2387
+ $id('aiChatSubmit').disabled = true;
2388
+
2389
+ // μ‚¬μš©μž λ©”μ‹œμ§€ μΆ”κ°€
2390
+ addChatMessage(question, true);
2391
+
2392
+ // λ‘œλ”© ν‘œμ‹œκΈ° μΆ”κ°€
2393
+ const typingIndicator = addTypingIndicator();
2394
+
2395
+ // μ„œλ²„μ— 질의 μš”μ²­
2396
+ const response = await fetch(`/api/ai/query-pdf/${currentPdfId}`, {
2397
+ method: 'POST',
2398
+ headers: {
2399
+ 'Content-Type': 'application/json'
2400
+ },
2401
+ body: JSON.stringify({ query: question }),
2402
+ // νƒ€μž„μ•„μ›ƒ μ„€μ • μΆ”κ°€
2403
+ signal: AbortSignal.timeout(60000) // 60초 νƒ€μž„μ•„μ›ƒ
2404
+ });
2405
+
2406
+ const data = await response.json();
2407
+
2408
+ // λ‘œλ”© ν‘œμ‹œκΈ° 제거
2409
+ typingIndicator.remove();
2410
+
2411
+ if (data.error) {
2412
+ // 였λ₯˜ λ©”μ‹œμ§€μ— 따라 λ‹€λ₯Έ μΉœμ ˆν•œ μ•ˆλ‚΄ 제곡
2413
+ if (data.error.includes("API ν‚€")) {
2414
+ addChatMessage("μ£„μ†‘ν•©λ‹ˆλ‹€. ν˜„μž¬ AI μ„œλΉ„μŠ€μ— μ—°κ²°ν•  수 μ—†μŠ΅λ‹ˆλ‹€. μ‹œμŠ€ν…œ κ΄€λ¦¬μžμ—κ²Œ API ν‚€ 섀정을 확인해달라고 μš”μ²­ν•΄μ£Όμ„Έμš”.");
2415
+ } else if (data.error.includes("μ—°κ²°")) {
2416
+ addChatMessage("μ£„μ†‘ν•©λ‹ˆλ‹€. AI μ„œλΉ„μŠ€μ— μ—°κ²°ν•  수 μ—†μŠ΅λ‹ˆλ‹€. 인터넷 연결을 ν™•μΈν•˜κ±°λ‚˜ μž μ‹œ ν›„ λ‹€μ‹œ μ‹œλ„ν•΄μ£Όμ„Έμš”.");
2417
+ } else {
2418
+ addChatMessage(`μ£„μ†‘ν•©λ‹ˆλ‹€. μ§ˆλ¬Έμ— λ‹΅λ³€ν•˜λŠ” 쀑 λ¬Έμ œκ°€ λ°œμƒν–ˆμŠ΅λ‹ˆλ‹€: ${data.error}`);
2419
+ }
2420
+ } else {
2421
+ // AI 응닡 μΆ”κ°€
2422
+ addChatMessage(data.answer);
2423
+ }
2424
+ } catch (error) {
2425
+ console.error("질문 제좜 였λ₯˜:", error);
2426
+ if (error.name === 'AbortError') {
2427
+ addChatMessage("μ£„μ†‘ν•©λ‹ˆλ‹€. 응닡 μ‹œκ°„μ΄ λ„ˆλ¬΄ 였래 κ±Έλ € μš”μ²­μ΄ μ·¨μ†Œλ˜μ—ˆμŠ΅λ‹ˆλ‹€. 인터넷 연결을 ν™•μΈν•˜κ±°λ‚˜ 더 짧은 질문으둜 λ‹€μ‹œ μ‹œλ„ν•΄λ³΄μ„Έμš”.");
2428
+ } else {
2429
+ addChatMessage("μ£„μ†‘ν•©λ‹ˆλ‹€. μ„œλ²„μ™€ 톡신 쀑 였λ₯˜κ°€ λ°œμƒν–ˆμŠ΅λ‹ˆλ‹€. μž μ‹œ ν›„ λ‹€μ‹œ μ‹œλ„ν•΄μ£Όμ„Έμš”.");
2430
+ }
2431
+ } finally {
2432
+ isAiProcessing = false;
2433
+ $id('aiChatSubmit').disabled = false;
2434
+ $id('aiChatInput').value = '';
2435
+ $id('aiChatInput').focus();
2436
+ }
2437
+ }
2438
+
2439
+
2440
+
2441
+
2442
+
2443
  // DOM이 λ‘œλ“œλ˜λ©΄ μ‹€ν–‰
2444
  document.addEventListener('DOMContentLoaded', function() {
2445
  console.log("DOM λ‘œλ“œ μ™„λ£Œ, 이벀트 μ„€μ • μ‹œμž‘");
 
2501
  $id('loadingPages').style.display = 'none';
2502
  currentLoadingPdfPath = null;
2503
  currentPdfId = null;
2504
+
2505
+ // AI 챗봇 λ‹«κΈ°
2506
+ toggleAiChat(false);
2507
+ hasLoadedSummary = false; // μš”μ•½ λ‘œλ“œ μƒνƒœ μ΄ˆκΈ°ν™”
2508
+ });
2509
+ }
2510
+
2511
+ // AI λ²„νŠΌ 이벀트 μ„€μ •
2512
+ const aiButton = document.getElementById('aiButton');
2513
+ if (aiButton) {
2514
+ aiButton.addEventListener('click', function() {
2515
+ toggleAiChat(!isAiChatActive);
2516
+ });
2517
+ }
2518
+
2519
+ // AI 챗봇 λ‹«κΈ° λ²„νŠΌ
2520
+ const aiChatClose = document.getElementById('aiChatClose');
2521
+ if (aiChatClose) {
2522
+ aiChatClose.addEventListener('click', function() {
2523
+ toggleAiChat(false);
2524
+ });
2525
+ }
2526
+
2527
+ // AI 챗봇 폼 제좜
2528
+ const aiChatForm = document.getElementById('aiChatForm');
2529
+ if (aiChatForm) {
2530
+ aiChatForm.addEventListener('submit', function(e) {
2531
+ e.preventDefault();
2532
+ const inputField = document.getElementById('aiChatInput');
2533
+ const question = inputField.value.trim();
2534
+
2535
+ if (question && !isAiProcessing) {
2536
+ submitQuestion(question);
2537
+ }
2538
  });
2539
  }
2540
  });
 
2804
  createFlipBook(cachedData.pages);
2805
  // ν˜„μž¬ μ—΄λ¦° PDF의 ID μ €μž₯
2806
  currentPdfId = pdfId;
2807
+ // AI λ²„νŠΌ ν‘œμ‹œ
2808
+ $id('aiButton').style.display = 'block';
2809
  return;
2810
  }
2811
  } catch (error) {
 
2845
 
2846
  // ν˜„μž¬ μ—΄λ¦° PDF의 ID μ €μž₯
2847
  currentPdfId = pdfId;
2848
+ // AI λ²„νŠΌ ν‘œμ‹œ
2849
+ $id('aiButton').style.display = 'block';
2850
  }
2851
  } catch (error) {
2852
  console.error("PDF ID둜 μ—΄κΈ° μ‹€νŒ¨:", error);
 
2885
  const card = document.querySelectorAll('.card')[i];
2886
  if (card && card.dataset.pdfId) {
2887
  currentPdfId = card.dataset.pdfId;
2888
+ // AI λ²„νŠΌ ν‘œμ‹œ
2889
+ $id('aiButton').style.display = 'block';
2890
  } else {
2891
  currentPdfId = null;
2892
+ // AI λ²„νŠΌ μˆ¨κΉ€
2893
+ $id('aiButton').style.display = 'none';
2894
  }
2895
 
2896
+ // AI 챗봇 μ΄ˆκΈ°ν™”
2897
+ toggleAiChat(false);
2898
+ hasLoadedSummary = false;
2899
+ $id('aiChatMessages').innerHTML = '';
2900
+
2901
  // κΈ°μ‘΄ FlipBook 정리
2902
  if(fb) {
2903
  fb.destroy();
 
3232
  $id('homeButton').style.display=showHome?'none':'block';
3233
  $id('adminPage').style.display='none';
3234
 
3235
+ // AI λ²„νŠΌ 관리
3236
+ $id('aiButton').style.display = (!showHome && currentPdfId) ? 'block' : 'none';
3237
+
3238
+ // AI 챗봇이 μ—΄λ €μžˆμœΌλ©΄ λ‹«κΈ°
3239
+ if (isAiChatActive) {
3240
+ toggleAiChat(false);
3241
+ }
3242
+
3243
  // λ·°μ–΄ λͺ¨λ“œμΌ λ•Œ μŠ€νƒ€μΌ λ³€κ²½
3244
  if(!showHome) {
3245
  document.body.classList.add('viewer-mode');