SOY NV AI commited on
Commit
4a8654b
·
1 Parent(s): d234e06

요약 보기 목차 기능 추가 및 메뉴 이름 변경

Browse files

- 요약 보기 모달에 목차 기능 추가 (Parent Chunk, 회차별 분석 섹션)
- 내용 보기 목차 폭 축소 (250px -> 125px)
- '메인으로' 메뉴를 '창작 어시스턴트'로 변경
- 웹소설 보기 아이콘 버튼을 '원작 정보'로 변경

app/database.py CHANGED
@@ -224,4 +224,26 @@ class SystemConfig(db.Model):
224
  traceback.print_exc()
225
  raise
226
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
227
 
 
224
  traceback.print_exc()
225
  raise
226
 
227
+ # 회차별 분석 모델
228
+ class EpisodeAnalysis(db.Model):
229
+ id = db.Column(db.Integer, primary_key=True)
230
+ file_id = db.Column(db.Integer, db.ForeignKey('uploaded_file.id'), nullable=False)
231
+ episode_title = db.Column(db.String(100), nullable=False) # 회차 제목 (예: '1화', '2화')
232
+ analysis_content = db.Column(db.Text, nullable=False) # 분석 결과 (하나의 텍스트로 이어서 저장)
233
+ created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
234
+ updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
235
+
236
+ # 관계
237
+ file = db.relationship('UploadedFile', backref='episode_analyses')
238
+
239
+ def to_dict(self):
240
+ return {
241
+ 'id': self.id,
242
+ 'file_id': self.file_id,
243
+ 'episode_title': self.episode_title,
244
+ 'analysis_content': self.analysis_content,
245
+ 'created_at': self.created_at.isoformat() if self.created_at else None,
246
+ 'updated_at': self.updated_at.isoformat() if self.updated_at else None
247
+ }
248
+
249
 
app/routes.py CHANGED
@@ -1,7 +1,7 @@
1
  from flask import Blueprint, render_template, request, jsonify, send_from_directory, redirect, url_for, flash
2
  from flask_login import login_user, logout_user, login_required, current_user
3
  from werkzeug.utils import secure_filename
4
- from app.database import db, UploadedFile, User, ChatSession, ChatMessage, DocumentChunk, ParentChunk, SystemConfig
5
  from app.vector_db import get_vector_db
6
  from app.gemini_client import get_gemini_client
7
  import requests
@@ -471,6 +471,133 @@ def extract_chunk_metadata(chunk_content, full_content=None, chunk_index=None, f
471
 
472
  return metadata
473
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
474
  def create_chunks_for_file(file_id, content):
475
  """파일 내용을 섹션별로 분할하여 의미 기반 청크로 저장 (벡터 DB 포함)
476
 
@@ -513,6 +640,72 @@ def create_chunks_for_file(file_id, content):
513
  print(f"[청크 생성] 경고: 섹션이 생성되지 않았습니다.")
514
  return 0
515
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
516
  # 각 섹션별로 청크 생성 및 저장
517
  saved_count = 0
518
  vector_saved_count = 0
@@ -851,6 +1044,23 @@ def get_parent_chunks_for_files(file_ids):
851
  print(f"[Parent Chunk 조회] 오류: {str(e)}")
852
  return []
853
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
854
  def search_relevant_chunks(query, file_ids=None, model_name=None, top_k=5, min_score=1):
855
  """
856
  질문과 관련된 청크 검색 (벡터 검색 + Re-ranking)
@@ -1520,12 +1730,12 @@ def chat():
1520
  print(f"\n[RAG 검색] 모델: {model}, 질문: {message[:50]}...")
1521
  print(f"[RAG 검색] 선택된 파일 ID: {file_ids if file_ids else '없음 (모든 파일 검색)'}")
1522
 
1523
- # 1단계: Parent Chunk로 문맥 파악
1524
- parent_chunks = []
1525
  if file_ids:
1526
- print(f"[RAG 검색 1단계] Parent Chunk 조회 시작...")
1527
- parent_chunks = get_parent_chunks_for_files(file_ids)
1528
- print(f"[RAG 검색 1단계] Parent Chunk 조회 완료: {len(parent_chunks)}개 파일")
1529
 
1530
  # 2단계: 벡터 검색 + 리랭킹으로 Child Chunk 정밀 검색
1531
  print(f"[RAG 검색 2단계] 벡터 검색 + 리랭킹 시작...")
@@ -1541,32 +1751,20 @@ def chat():
1541
  # 컨텍스트 구성
1542
  context_parts = []
1543
 
1544
- # Parent Chunk 정보 추가 (문맥 파악용)
1545
- if parent_chunks:
1546
- parent_context_sections = []
1547
- for parent_chunk in parent_chunks:
1548
- file = parent_chunk.file
1549
- file_info = f"\n=== {file.original_filename} 전체 개요 ===\n"
1550
 
1551
- sections = []
1552
- if parent_chunk.world_view:
1553
- sections.append(f"[세계관]\n{parent_chunk.world_view}")
1554
- if parent_chunk.characters:
1555
- sections.append(f"[주요 캐릭터]\n{parent_chunk.characters}")
1556
- if parent_chunk.story:
1557
- sections.append(f"[주요 스토리]\n{parent_chunk.story}")
1558
- if parent_chunk.episodes:
1559
- sections.append(f"[주요 에피소드]\n{parent_chunk.episodes}")
1560
- if parent_chunk.others:
1561
- sections.append(f"[기타 정보]\n{parent_chunk.others}")
1562
-
1563
- if sections:
1564
- parent_context_sections.append(file_info + "\n\n".join(sections))
1565
 
1566
- if parent_context_sections:
1567
- parent_context = "\n\n".join(parent_context_sections)
1568
- context_parts.append(f"다음은 웹소설의 전체적인 문맥과 개요입니다:\n\n{parent_context}")
1569
- print(f"[RAG 검색] Parent Chunk 컨텍스트 추가: {len(parent_context)}자")
1570
 
1571
  # Child Chunk 정보 추가 (정밀 검색 결과)
1572
  if relevant_chunks:
@@ -1605,14 +1803,14 @@ def chat():
1605
  if context_parts:
1606
  full_context = "\n\n" + "\n\n---\n\n".join(context_parts) + "\n\n"
1607
 
1608
- # Parent Chunk와 Child Chunk 모두 있는 경우
1609
- if parent_chunks and relevant_chunks:
1610
  context = f"""다음은 질문에 답하기 위한 웹소설 정보입니다:
1611
 
1612
  {full_context}
1613
 
1614
  위 정보를 참고하여 답변해주세요:
1615
- - 먼저 전체적인 문맥(Parent Chunk)을 이해하여 웹소설의 배경과 설정을 파악하세요.
1616
  - 그 다음 구체적인 내용(Child Chunk)을 통해 질문에 대한 정확한 답변을 제공하세요.
1617
  - 웹소설의 맥락과 스토리를 고려하여 일관성 있는 답변을 작성하세요.
1618
 
@@ -1622,13 +1820,13 @@ def chat():
1622
 
1623
  질문:
1624
  """
1625
- elif parent_chunks:
1626
- # Parent Chunk만 있는 경우
1627
- context = f"""다음은 웹소설의 전체적인 문맥과 개요입니다:
1628
 
1629
  {full_context}
1630
 
1631
- 위 정보를 참고하여 질문에 답변해주세요. 웹소설의 배경과 설정을 고려하여 답변하세요.
1632
 
1633
  중요: 질문에 답변할 때는 반드시 제공된 [소설 본문] 내의 내용을 근거로 해야 합니다.
1634
  답변의 각 문장 끝에는 참고한 본문의 문장을 [근거: "문장 내용..."] 형식으로 반드시 붙이세요.
@@ -1652,7 +1850,7 @@ def chat():
1652
  """
1653
 
1654
  context += message
1655
- print(f"[RAG 검색] 최종 컨텍스트 생성 완료 (Parent Chunk: {len(parent_chunks)}개, Child Chunk: {len(relevant_chunks)}개, 총 {len(context)}자)")
1656
  else:
1657
  # RAG 검색 결과가 없으면 기존 방식 사용
1658
  print(f"[RAG 검색] 관련 청크를 찾지 못했습니다. 전체 파일 내용 사용")
@@ -2360,6 +2558,38 @@ def get_all_file_chunks(file_id):
2360
  except Exception as e:
2361
  return jsonify({'error': f'청크 목록 조회 중 오류가 발생했습니다: {str(e)}'}), 500
2362
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2363
  @main_bp.route('/api/files/<int:file_id>/parent-chunk', methods=['GET'])
2364
  @login_required
2365
  def get_file_parent_chunk(file_id):
 
1
  from flask import Blueprint, render_template, request, jsonify, send_from_directory, redirect, url_for, flash
2
  from flask_login import login_user, logout_user, login_required, current_user
3
  from werkzeug.utils import secure_filename
4
+ from app.database import db, UploadedFile, User, ChatSession, ChatMessage, DocumentChunk, ParentChunk, SystemConfig, EpisodeAnalysis
5
  from app.vector_db import get_vector_db
6
  from app.gemini_client import get_gemini_client
7
  import requests
 
471
 
472
  return metadata
473
 
474
+ def analyze_episode(episode_content, episode_title, full_content=None, parent_chunk=None, model_name=None):
475
+ """회차별 분석 (주요 스토리, 등장인물, 인물 관계 변화, 기타)
476
+
477
+ Args:
478
+ episode_content: 분석할 회차 내용
479
+ episode_title: 회차 제목 (예: '1화', '2화')
480
+ full_content: 원본 웹소설 전체 내용 (참고용)
481
+ parent_chunk: Parent Chunk 객체 (선택사항)
482
+ model_name: 사용할 AI 모델명
483
+
484
+ Returns:
485
+ 분석 결과 텍스트 (하나의 텍스트로 이어서 저장)
486
+ """
487
+ try:
488
+ # 원본 웹소설 전체 내용을 참조
489
+ full_content_preview = ""
490
+ if full_content:
491
+ # 전체 내용이 너무 길면 앞부분과 뒷부분 일부만 사용 (최대 30000자)
492
+ if len(full_content) > 30000:
493
+ full_content_preview = full_content[:15000] + "\n... (중간 생략) ...\n" + full_content[-15000:]
494
+ else:
495
+ full_content_preview = full_content
496
+
497
+ # Parent Chunk 정보 추가
498
+ parent_info = ""
499
+ if parent_chunk:
500
+ parent_info = f"""
501
+ 작품 전체 정보:
502
+ - 세계관: {parent_chunk.world_view or '없음'}
503
+ - 주요 캐릭터: {parent_chunk.characters or '없음'}
504
+ - 주요 스토리: {parent_chunk.story or '없음'}
505
+ """
506
+
507
+ # 프롬프트 생성
508
+ prompt = f"""다음 웹소설의 {episode_title} 회차를 분석하여 아래 항목들을 하나의 텍스트로 이어서 작성해주세요.
509
+
510
+ {parent_info}
511
+
512
+ 원본 웹소설 전체 내용 (참고용):
513
+ {full_content_preview[:50000] if full_content_preview else "없음"}
514
+
515
+ 분석할 회차 내용 ({episode_title}):
516
+ {episode_content[:10000] if len(episode_content) > 10000 else episode_content}
517
+
518
+ 다음 형식으로 분석 결과를 작성해주세요 (하나의 텍스트로 이어서 작성):
519
+
520
+ ## {episode_title} 주요 스토리 분석
521
+ [이 회차에서 일어난 주요 사건과 스토리 전개를 상세히 분석해주세요]
522
+
523
+ ## {episode_title} 주요 등장 인물 분석
524
+ [이 회차에 등장한 주요 인물들과 그들의 역할, 행동, 특징을 분석해주세요]
525
+
526
+ ## 인물과 인물간의 관계 변화
527
+ [이 회차에서 인물들 간의 관계가 어떻게 변화했는지, 새로운 관계가 형성되었는지 등을 분석해주세요]
528
+
529
+ ## 기타
530
+ [이 회차의 특별한 점, 중요 사건, 떡밥, 복선 등 기타 중요한 내용을 분석해주세요]
531
+
532
+ 응답은 위 형식을 그대로 유지하면서 각 항목에 대한 상세한 분석 내용을 작성해주세요."""
533
+
534
+ # 모델명이 없으면 기본값 사용 (Gemini 우선 시도)
535
+ if not model_name:
536
+ # Gemini 시도
537
+ try:
538
+ gemini_client = get_gemini_client()
539
+ if gemini_client.is_configured():
540
+ result = gemini_client.generate_response(
541
+ prompt=prompt,
542
+ model_name="gemini-1.5-flash",
543
+ temperature=0.5,
544
+ max_output_tokens=2000
545
+ )
546
+ if not result['error'] and result.get('response'):
547
+ return result['response'].strip()
548
+ except Exception as e:
549
+ print(f"[회차 분석] Gemini 기본 모델 오류: {str(e)}")
550
+
551
+ # 모델명이 있거나 Gemini 실패 시 해당 모델 사용
552
+ if model_name:
553
+ model_name_lower = model_name.lower().strip()
554
+ is_gemini = model_name_lower.startswith('gemini:') or model_name_lower.startswith('gemini-')
555
+
556
+ if is_gemini:
557
+ gemini_model_name = model_name.strip()
558
+ if gemini_model_name.lower().startswith('gemini:'):
559
+ gemini_model_name = gemini_model_name.split(':', 1)[1].strip()
560
+
561
+ gemini_client = get_gemini_client()
562
+ if gemini_client.is_configured():
563
+ result = gemini_client.generate_response(
564
+ prompt=prompt,
565
+ model_name=gemini_model_name,
566
+ temperature=0.5,
567
+ max_output_tokens=2000
568
+ )
569
+ if not result['error'] and result.get('response'):
570
+ return result['response'].strip()
571
+ else:
572
+ # Ollama API 호출
573
+ try:
574
+ ollama_response = requests.post(
575
+ f'{OLLAMA_BASE_URL}/api/generate',
576
+ json={
577
+ 'model': model_name,
578
+ 'prompt': prompt,
579
+ 'stream': False,
580
+ 'options': {
581
+ 'temperature': 0.5,
582
+ 'num_predict': 2000
583
+ }
584
+ },
585
+ timeout=60
586
+ )
587
+ if ollama_response.status_code == 200:
588
+ response_data = ollama_response.json()
589
+ return response_data.get('response', '').strip()
590
+ except Exception as e:
591
+ print(f"[회차 분석] Ollama 오류: {str(e)}")
592
+
593
+ # AI 분석 실패 시 기본값 반환
594
+ return f"## {episode_title} 분석\n분석을 완료할 수 없었습니다."
595
+ except Exception as e:
596
+ print(f"[회차 분석] 오류: {str(e)}")
597
+ import traceback
598
+ traceback.print_exc()
599
+ return f"## {episode_title} 분석\n분석 중 오류가 발생했습니다: {str(e)}"
600
+
601
  def create_chunks_for_file(file_id, content):
602
  """파일 내용을 섹션별로 분할하여 의미 기반 청크로 저장 (벡터 DB 포함)
603
 
 
640
  print(f"[청크 생성] 경고: 섹션이 생성되지 않았습니다.")
641
  return 0
642
 
643
+ # 기존 회차 분석 삭제
644
+ existing_analyses = EpisodeAnalysis.query.filter_by(file_id=file_id).all()
645
+ if existing_analyses:
646
+ print(f"[회차 분석] 기존 회차 분석 {len(existing_analyses)}개 삭제 중...")
647
+ for analysis in existing_analyses:
648
+ db.session.delete(analysis)
649
+ db.session.commit()
650
+
651
+ # '#작품설명'을 제외한 각 회차 분석
652
+ episode_sections = [s for s in sections if s[0] != '작품설명'] # section_type이 '작품설명'이 아닌 것만
653
+ if episode_sections and model_name:
654
+ print(f"[회차 분석] {len(episode_sections)}개 회차 분석 시작...")
655
+
656
+ # Parent Chunk 가져오기
657
+ parent_chunk = None
658
+ try:
659
+ parent_chunk = ParentChunk.query.filter_by(file_id=file_id).first()
660
+ except:
661
+ pass
662
+
663
+ # 각 회차 분석 결과를 하나의 텍스트로 이어서 저장
664
+ all_analyses = []
665
+
666
+ for section_type, section_title, section_content, section_metadata in episode_sections:
667
+ try:
668
+ print(f"[회차 분석] '{section_title}' 분석 중...")
669
+ analysis_result = analyze_episode(
670
+ episode_content=section_content,
671
+ episode_title=section_title,
672
+ full_content=content,
673
+ parent_chunk=parent_chunk,
674
+ model_name=model_name
675
+ )
676
+
677
+ if analysis_result:
678
+ all_analyses.append(f"\n\n{analysis_result}")
679
+ print(f"[회차 분석] '{section_title}' 분석 완료")
680
+ else:
681
+ print(f"[회차 분석] '{section_title}' 분석 실패 (결과 없음)")
682
+ except Exception as e:
683
+ print(f"[회차 분석] '{section_title}' 분석 중 오류: {str(e)}")
684
+ import traceback
685
+ traceback.print_exc()
686
+ continue
687
+
688
+ # 모든 회차 분석 결과를 하나의 텍스트로 이어서 저장
689
+ if all_analyses:
690
+ combined_analysis = "\n".join(all_analyses).strip()
691
+
692
+ # 하나의 통합 분석으로 저장 (나눠서 저장하지 않고 하나에 이어서 저장)
693
+ episode_analysis = EpisodeAnalysis(
694
+ file_id=file_id,
695
+ episode_title="전체 회차 통합 분석",
696
+ analysis_content=combined_analysis # 모든 회차 분석을 하나의 텍스트로 저장
697
+ )
698
+ db.session.add(episode_analysis)
699
+ db.session.commit()
700
+ print(f"[회차 분석] 완료: {len(episode_sections)}개 회차 분석 결과를 하나의 텍스트로 저장")
701
+ else:
702
+ print(f"[회차 분석] 경고: 분석 결과가 없습니다.")
703
+ else:
704
+ if not model_name:
705
+ print(f"[회차 분석] 모델명이 없어 회차 분석을 건너뜁니다.")
706
+ elif not episode_sections:
707
+ print(f"[회차 분석] 분석할 회차가 없습니다.")
708
+
709
  # 각 섹션별로 청크 생성 및 저장
710
  saved_count = 0
711
  vector_saved_count = 0
 
1044
  print(f"[Parent Chunk 조회] 오류: {str(e)}")
1045
  return []
1046
 
1047
+ def get_episode_analyses_for_files(file_ids):
1048
+ """파일 ID 목록에 대한 회차별 분석(EpisodeAnalysis) 조회 (회차별 요약 참조용)"""
1049
+ try:
1050
+ if not file_ids:
1051
+ return []
1052
+
1053
+ episode_analyses = []
1054
+ for file_id in file_ids:
1055
+ episode_analysis = EpisodeAnalysis.query.filter_by(file_id=file_id).first()
1056
+ if episode_analysis:
1057
+ episode_analyses.append(episode_analysis)
1058
+
1059
+ return episode_analyses
1060
+ except Exception as e:
1061
+ print(f"[회차별 분석 조회] 오류: {str(e)}")
1062
+ return []
1063
+
1064
  def search_relevant_chunks(query, file_ids=None, model_name=None, top_k=5, min_score=1):
1065
  """
1066
  질문과 관련된 청크 검색 (벡터 검색 + Re-ranking)
 
1730
  print(f"\n[RAG 검색] 모델: {model}, 질문: {message[:50]}...")
1731
  print(f"[RAG 검색] 선택된 파일 ID: {file_ids if file_ids else '없음 (모든 파일 검색)'}")
1732
 
1733
+ # 1단계: 회차별 분석(EpisodeAnalysis) 조회 (회차별 요약 참조용)
1734
+ episode_analyses = []
1735
  if file_ids:
1736
+ print(f"[RAG 검색 1단계] 회차별 분석 조회 시작...")
1737
+ episode_analyses = get_episode_analyses_for_files(file_ids)
1738
+ print(f"[RAG 검색 1단계] 회차별 분석 조회 완료: {len(episode_analyses)}개 파일")
1739
 
1740
  # 2단계: 벡터 검색 + 리랭킹으로 Child Chunk 정밀 검색
1741
  print(f"[RAG 검색 2단계] 벡터 검색 + 리랭킹 시작...")
 
1751
  # 컨텍스트 구성
1752
  context_parts = []
1753
 
1754
+ # 회차별 분석 정보 추가 (회차별 요약 참조용)
1755
+ if episode_analyses:
1756
+ episode_context_sections = []
1757
+ for episode_analysis in episode_analyses:
1758
+ file = episode_analysis.file
1759
+ file_info = f"\n=== {file.original_filename} 회차별 분석 ===\n"
1760
 
1761
+ if episode_analysis.analysis_content:
1762
+ episode_context_sections.append(file_info + episode_analysis.analysis_content)
 
 
 
 
 
 
 
 
 
 
 
 
1763
 
1764
+ if episode_context_sections:
1765
+ episode_context = "\n\n".join(episode_context_sections)
1766
+ context_parts.append(f"다음은 웹소설의 회차별 상세 분석 내용입니다:\n\n{episode_context}")
1767
+ print(f"[RAG 검색] 회차별 분석 컨텍스트 추가: {len(episode_context)}자")
1768
 
1769
  # Child Chunk 정보 추가 (정밀 검색 결과)
1770
  if relevant_chunks:
 
1803
  if context_parts:
1804
  full_context = "\n\n" + "\n\n---\n\n".join(context_parts) + "\n\n"
1805
 
1806
+ # 회차별 분석과 Child Chunk 모두 있는 경우
1807
+ if episode_analyses and relevant_chunks:
1808
  context = f"""다음은 질문에 답하기 위한 웹소설 정보입니다:
1809
 
1810
  {full_context}
1811
 
1812
  위 정보를 참고하여 답변해주세요:
1813
+ - 먼저 회차별 분석 내용을 이해하여 회차의 주요 스토리, 등장 인물, 인물 관계 변화를 파악하세요.
1814
  - 그 다음 구체적인 내용(Child Chunk)을 통해 질문에 대한 정확한 답변을 제공하세요.
1815
  - 웹소설의 맥락과 스토리를 고려하여 일관성 있는 답변을 작성하세요.
1816
 
 
1820
 
1821
  질문:
1822
  """
1823
+ elif episode_analyses:
1824
+ # 회차별 분석만 있는 경우
1825
+ context = f"""다음은 웹소설의 회차별 상세 분석 내용입니다:
1826
 
1827
  {full_context}
1828
 
1829
+ 위 정보를 참고하여 질문에 답변해주세요. 회차의 주요 스토리, 등장 인물, 인물 관계 변화를 고려하여 답변하세요.
1830
 
1831
  중요: 질문에 답변할 때는 반드시 제공된 [소설 본문] 내의 내용을 근거로 해야 합니다.
1832
  답변의 각 문장 끝에는 참고한 본문의 문장을 [근거: "문장 내용..."] 형식으로 반드시 붙이세요.
 
1850
  """
1851
 
1852
  context += message
1853
+ print(f"[RAG 검색] 최종 컨텍스트 생성 완료 (회차별 분석: {len(episode_analyses)}개, Child Chunk: {len(relevant_chunks)}개, 총 {len(context)}자)")
1854
  else:
1855
  # RAG 검색 결과가 없으면 기존 방식 사용
1856
  print(f"[RAG 검색] 관련 청크를 찾지 못했습니다. 전체 파일 내용 사용")
 
2558
  except Exception as e:
2559
  return jsonify({'error': f'청크 목록 조회 중 오류가 발생했습니다: {str(e)}'}), 500
2560
 
2561
+ @main_bp.route('/api/files/<int:file_id>/summary', methods=['GET'])
2562
+ @login_required
2563
+ def get_file_summary(file_id):
2564
+ """파일의 요약 내용 조회 (Parent Chunk + Episode Analysis)"""
2565
+ try:
2566
+ print(f"[요약 조회] 파일 ID {file_id} 요약 내용 조회 요청 (사용자: {current_user.username})")
2567
+
2568
+ # 모든 사용자가 모든 파일 조회 가능 (관리자 페이지와 동일)
2569
+ file = UploadedFile.query.get(file_id)
2570
+
2571
+ if not file:
2572
+ print(f"[요약 조회] 파일을 찾을 수 없음: 파일 ID {file_id}")
2573
+ # 디버깅: 전체 파일 목록 확인
2574
+ all_files = UploadedFile.query.all()
2575
+ print(f"[요약 조회] 데이터베이스에 존재하는 파일 ID 목록: {[f.id for f in all_files]}")
2576
+ return jsonify({'error': f'파일을 찾을 수 없습니다. (파일 ID: {file_id})'}), 404
2577
+
2578
+ parent_chunk = ParentChunk.query.filter_by(file_id=file_id).first()
2579
+ episode_analysis = EpisodeAnalysis.query.filter_by(file_id=file_id).first()
2580
+
2581
+ return jsonify({
2582
+ 'file_id': file_id,
2583
+ 'filename': file.original_filename,
2584
+ 'parent_chunk': parent_chunk.to_dict() if parent_chunk else None,
2585
+ 'episode_analysis': episode_analysis.to_dict() if episode_analysis else None,
2586
+ 'has_parent_chunk': parent_chunk is not None,
2587
+ 'has_episode_analysis': episode_analysis is not None
2588
+ }), 200
2589
+
2590
+ except Exception as e:
2591
+ return jsonify({'error': f'요약 내용 조회 중 오류가 발생했습니다: {str(e)}'}), 500
2592
+
2593
  @main_bp.route('/api/files/<int:file_id>/parent-chunk', methods=['GET'])
2594
  @login_required
2595
  def get_file_parent_chunk(file_id):
templates/admin_files.html CHANGED
@@ -387,6 +387,19 @@
387
  </div>
388
  </div>
389
 
 
 
 
 
 
 
 
 
 
 
 
 
 
390
  <!-- 청크 내용 모달 -->
391
  <div id="chunkContentModal" class="modal">
392
  <div class="modal-content chunk-content-modal">
@@ -501,6 +514,7 @@
501
  <td>${formatDate(file.uploaded_at)}</td>
502
  <td>
503
  <div class="file-actions">
 
504
  <button class="btn btn-info" onclick="viewChunks(${file.id}, '${escapeHtml(file.original_filename)}')" style="padding: 4px 8px; font-size: 12px;">청크 보기</button>
505
  </div>
506
  </td>
@@ -601,6 +615,96 @@
601
  modal.classList.add('active');
602
  }
603
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
604
  function closeChunkContentModal() {
605
  document.getElementById('chunkContentModal').classList.remove('active');
606
  }
@@ -612,6 +716,12 @@
612
  }
613
  });
614
 
 
 
 
 
 
 
615
  document.getElementById('chunkContentModal').addEventListener('click', function(e) {
616
  if (e.target === this) {
617
  closeChunkContentModal();
 
387
  </div>
388
  </div>
389
 
390
+ <!-- 요약 내용 모달 -->
391
+ <div id="summaryModal" class="modal">
392
+ <div class="modal-content chunk-content-modal">
393
+ <div class="modal-header">
394
+ <div class="modal-title" id="summaryModalTitle">요약 내용</div>
395
+ <button class="modal-close" onclick="closeSummaryModal()">&times;</button>
396
+ </div>
397
+ <div id="summaryContent" class="chunk-content">
398
+ 내용을 불러오는 중...
399
+ </div>
400
+ </div>
401
+ </div>
402
+
403
  <!-- 청크 내용 모달 -->
404
  <div id="chunkContentModal" class="modal">
405
  <div class="modal-content chunk-content-modal">
 
514
  <td>${formatDate(file.uploaded_at)}</td>
515
  <td>
516
  <div class="file-actions">
517
+ <button class="btn btn-secondary" onclick="viewSummary(${file.id}, '${escapeHtml(file.original_filename)}')" style="padding: 4px 8px; font-size: 12px; margin-right: 4px;">요약 내용 보기</button>
518
  <button class="btn btn-info" onclick="viewChunks(${file.id}, '${escapeHtml(file.original_filename)}')" style="padding: 4px 8px; font-size: 12px;">청크 보기</button>
519
  </div>
520
  </td>
 
615
  modal.classList.add('active');
616
  }
617
 
618
+ async function viewSummary(fileId, fileName) {
619
+ const modal = document.getElementById('summaryModal');
620
+ const title = document.getElementById('summaryModalTitle');
621
+ const content = document.getElementById('summaryContent');
622
+
623
+ title.textContent = `요약 내용 - ${fileName}`;
624
+ content.innerHTML = '<div style="text-align: center; padding: 24px; color: #5f6368;">요약 내용을 불러오는 중...</div>';
625
+ modal.classList.add('active');
626
+
627
+ try {
628
+ const response = await fetch(`/api/files/${fileId}/summary`, {
629
+ credentials: 'include'
630
+ });
631
+ if (!response.ok) throw new Error('요약 내용을 불러올 수 없습니다.');
632
+
633
+ const data = await response.json();
634
+
635
+ let contentHtml = '';
636
+
637
+ // Parent Chunk 내용
638
+ if (data.parent_chunk) {
639
+ contentHtml += '<div style="margin-bottom: 32px;">';
640
+ contentHtml += '<h3 style="font-size: 18px; font-weight: 600; margin-bottom: 16px; color: #1a73e8; border-bottom: 2px solid #1a73e8; padding-bottom: 8px;">Parent Chunk (작품 전체 요약)</h3>';
641
+
642
+ if (data.parent_chunk.world_view) {
643
+ contentHtml += '<div style="margin-bottom: 16px;">';
644
+ contentHtml += '<h4 style="font-size: 16px; font-weight: 600; margin-bottom: 8px; color: #1967d2;">세계관</h4>';
645
+ contentHtml += `<div style="white-space: pre-wrap; line-height: 1.6; padding: 12px; background: #f8f9fa; border-radius: 6px;">${escapeHtml(data.parent_chunk.world_view)}</div>`;
646
+ contentHtml += '</div>';
647
+ }
648
+
649
+ if (data.parent_chunk.characters) {
650
+ contentHtml += '<div style="margin-bottom: 16px;">';
651
+ contentHtml += '<h4 style="font-size: 16px; font-weight: 600; margin-bottom: 8px; color: #1967d2;">주요 캐릭터</h4>';
652
+ contentHtml += `<div style="white-space: pre-wrap; line-height: 1.6; padding: 12px; background: #f8f9fa; border-radius: 6px;">${escapeHtml(data.parent_chunk.characters)}</div>`;
653
+ contentHtml += '</div>';
654
+ }
655
+
656
+ if (data.parent_chunk.story) {
657
+ contentHtml += '<div style="margin-bottom: 16px;">';
658
+ contentHtml += '<h4 style="font-size: 16px; font-weight: 600; margin-bottom: 8px; color: #1967d2;">주요 스토리</h4>';
659
+ contentHtml += `<div style="white-space: pre-wrap; line-height: 1.6; padding: 12px; background: #f8f9fa; border-radius: 6px;">${escapeHtml(data.parent_chunk.story)}</div>`;
660
+ contentHtml += '</div>';
661
+ }
662
+
663
+ if (data.parent_chunk.episodes) {
664
+ contentHtml += '<div style="margin-bottom: 16px;">';
665
+ contentHtml += '<h4 style="font-size: 16px; font-weight: 600; margin-bottom: 8px; color: #1967d2;">주요 에피소드</h4>';
666
+ contentHtml += `<div style="white-space: pre-wrap; line-height: 1.6; padding: 12px; background: #f8f9fa; border-radius: 6px;">${escapeHtml(data.parent_chunk.episodes)}</div>`;
667
+ contentHtml += '</div>';
668
+ }
669
+
670
+ if (data.parent_chunk.others) {
671
+ contentHtml += '<div style="margin-bottom: 16px;">';
672
+ contentHtml += '<h4 style="font-size: 16px; font-weight: 600; margin-bottom: 8px; color: #1967d2;">기타</h4>';
673
+ contentHtml += `<div style="white-space: pre-wrap; line-height: 1.6; padding: 12px; background: #f8f9fa; border-radius: 6px;">${escapeHtml(data.parent_chunk.others)}</div>`;
674
+ contentHtml += '</div>';
675
+ }
676
+
677
+ contentHtml += '</div>';
678
+ } else {
679
+ contentHtml += '<div style="margin-bottom: 32px; padding: 16px; background: #fff3cd; border-radius: 6px; color: #856404;">Parent Chunk가 생성되지 않았습니다.</div>';
680
+ }
681
+
682
+ // Episode Analysis 내용
683
+ if (data.episode_analysis) {
684
+ contentHtml += '<div style="margin-top: 32px; border-top: 2px solid #e8eaed; padding-top: 24px;">';
685
+ contentHtml += '<h3 style="font-size: 18px; font-weight: 600; margin-bottom: 16px; color: #1a73e8; border-bottom: 2px solid #1a73e8; padding-bottom: 8px;">회차별 분석</h3>';
686
+ contentHtml += `<div style="white-space: pre-wrap; line-height: 1.6; padding: 12px; background: #f8f9fa; border-radius: 6px;">${escapeHtml(data.episode_analysis.analysis_content)}</div>`;
687
+ contentHtml += '</div>';
688
+ } else {
689
+ contentHtml += '<div style="margin-top: 32px; padding: 16px; background: #fff3cd; border-radius: 6px; color: #856404;">회차별 분석이 생성되지 않았습니다.</div>';
690
+ }
691
+
692
+ if (!data.parent_chunk && !data.episode_analysis) {
693
+ contentHtml = '<div style="text-align: center; padding: 24px; color: #5f6368;">요약 내용이 없습니다.</div>';
694
+ }
695
+
696
+ content.innerHTML = contentHtml;
697
+ } catch (error) {
698
+ console.error('요약 내용 로드 오류:', error);
699
+ content.innerHTML = '<div style="text-align: center; padding: 24px; color: #c5221f;">요약 내용을 불러오는 중 오류가 발생했습니다.</div>';
700
+ showAlert('요약 내용을 불러오는 중 오류가 발생했습니다.', 'error');
701
+ }
702
+ }
703
+
704
+ function closeSummaryModal() {
705
+ document.getElementById('summaryModal').classList.remove('active');
706
+ }
707
+
708
  function closeChunkContentModal() {
709
  document.getElementById('chunkContentModal').classList.remove('active');
710
  }
 
716
  }
717
  });
718
 
719
+ document.getElementById('summaryModal').addEventListener('click', function(e) {
720
+ if (e.target === this) {
721
+ closeSummaryModal();
722
+ }
723
+ });
724
+
725
  document.getElementById('chunkContentModal').addEventListener('click', function(e) {
726
  if (e.target === this) {
727
  closeChunkContentModal();
templates/index.html CHANGED
@@ -1140,7 +1140,7 @@
1140
  <span></span>
1141
  </div>
1142
  <div class="header-actions">
1143
- <a href="{{ url_for('main.webnovels') }}" class="btn-icon" title="웹소설 보기" style="text-decoration: none; color: var(--text-secondary);">
1144
  <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
1145
  <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8zM14 2v6h6M16 13H8M16 17H8M10 9H8"/>
1146
  </svg>
 
1140
  <span></span>
1141
  </div>
1142
  <div class="header-actions">
1143
+ <a href="{{ url_for('main.webnovels') }}" class="btn-icon" title="원작 정보" style="text-decoration: none; color: var(--text-secondary);">
1144
  <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
1145
  <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8zM14 2v6h6M16 13H8M16 17H8M10 9H8"/>
1146
  </svg>
templates/webnovels.html CHANGED
@@ -6,6 +6,7 @@
6
  <title>업로드된 웹소설 - SOY NV AI</title>
7
  <link rel="preconnect" href="https://fonts.googleapis.com" crossorigin>
8
  <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap" rel="stylesheet">
 
9
  <style>
10
  * {
11
  margin: 0;
@@ -186,6 +187,8 @@
186
 
187
  .webnovel-item-btn:hover {
188
  background: var(--accent-hover);
 
 
189
  }
190
 
191
  /* 모달 스타일 */
@@ -261,6 +264,184 @@
261
  padding: 2px 0;
262
  border-radius: 2px;
263
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
264
  </style>
265
  </head>
266
  <body>
@@ -271,7 +452,7 @@
271
  </div>
272
  <div class="header-actions">
273
  <span style="margin-right: 12px; color: var(--text-secondary);">{{ current_user.nickname or current_user.username }}</span>
274
- <a href="{{ url_for('main.index') }}" class="btn btn-secondary">메인으로</a>
275
  {% if current_user.is_admin %}
276
  <a href="{{ url_for('main.admin') }}" class="btn btn-secondary">관리자 페이지</a>
277
  {% endif %}
@@ -312,6 +493,34 @@
312
  </div>
313
  </div>
314
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
315
  <!-- 웹소설 내용 보기 모달 -->
316
  <div id="webnovelContentModal" class="modal">
317
  <div class="modal-content">
@@ -354,9 +563,22 @@
354
  다음 →
355
  </button>
356
  </div>
357
- <div class="modal-body" style="height: calc(90vh - 200px); overflow-y: auto; position: relative;" id="webnovelContentContainer">
358
- <div id="webnovelContent" style="white-space: pre-wrap; font-family: inherit; line-height: 1.6; color: var(--text-primary); padding: 16px;">
359
- 내용을 불러오는 중...
 
 
 
 
 
 
 
 
 
 
 
 
 
360
  </div>
361
  </div>
362
  </div>
@@ -369,6 +591,22 @@
369
  return div.innerHTML;
370
  }
371
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
372
  function formatFileSize(bytes) {
373
  if (bytes === 0) return '0 Bytes';
374
  const k = 1024;
@@ -481,7 +719,8 @@
481
  ${file.model_name ? `<span>🤖 ${escapeHtml(file.model_name)}</span>` : ''}
482
  </div>
483
  <div class="webnovel-item-actions">
484
- <button class="webnovel-item-btn" onclick="viewWebnovelContent(${file.id}, '${escapeHtml(file.original_filename)}')">내용 보기</button>
 
485
  </div>
486
  `;
487
  listContainer.appendChild(fileItem);
@@ -491,6 +730,93 @@
491
  let webnovelOriginalContent = '';
492
  let webnovelSearchMatches = [];
493
  let webnovelCurrentMatchIndex = -1;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
494
 
495
  async function viewWebnovelContent(fileId, filename) {
496
  const modal = document.getElementById('webnovelContentModal');
@@ -505,8 +831,13 @@
505
  webnovelOriginalContent = '';
506
  webnovelSearchMatches = [];
507
  webnovelCurrentMatchIndex = -1;
 
508
  updateWebnovelSearchInfo();
509
 
 
 
 
 
510
  try {
511
  const response = await fetch(`/api/files/${fileId}/content`, {
512
  credentials: 'include'
@@ -515,7 +846,15 @@
515
 
516
  const data = await response.json();
517
  webnovelOriginalContent = data.content;
518
- content.textContent = data.content;
 
 
 
 
 
 
 
 
519
 
520
  // 검색 입력 필드 포커스
521
  searchInput.focus();
@@ -536,13 +875,17 @@
536
  }
537
 
538
  if (!webnovelOriginalContent) {
539
- webnovelOriginalContent = content.textContent;
 
 
 
540
  }
541
 
542
- // 검색어로 하이라이트 처리
 
543
  const regex = new RegExp(`(${escapeRegex(searchTerm)})`, 'gi');
544
- const highlightedContent = webnovelOriginalContent.replace(regex, '<mark style="background: #ffeb3b; padding: 2px 0; border-radius: 2px;">$1</mark>');
545
- content.innerHTML = highlightedContent;
546
 
547
  // 검색 결과 위치 찾기
548
  webnovelSearchMatches = [];
@@ -566,7 +909,8 @@
566
 
567
  searchInput.value = '';
568
  if (webnovelOriginalContent) {
569
- content.textContent = webnovelOriginalContent;
 
570
  }
571
  webnovelSearchMatches = [];
572
  webnovelCurrentMatchIndex = -1;
@@ -674,12 +1018,276 @@
674
  return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
675
  }
676
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
677
  function closeWebnovelContentModal() {
678
  document.getElementById('webnovelContentModal').classList.remove('active');
679
  clearWebnovelSearch();
680
  }
681
 
682
  // 모달 외부 클릭 시 닫기
 
 
 
 
 
 
683
  document.getElementById('webnovelContentModal').addEventListener('click', function(e) {
684
  if (e.target === this) {
685
  closeWebnovelContentModal();
 
6
  <title>업로드된 웹소설 - SOY NV AI</title>
7
  <link rel="preconnect" href="https://fonts.googleapis.com" crossorigin>
8
  <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap" rel="stylesheet">
9
+ <script src="https://cdn.jsdelivr.net/npm/marked@11.1.1/marked.min.js"></script>
10
  <style>
11
  * {
12
  margin: 0;
 
187
 
188
  .webnovel-item-btn:hover {
189
  background: var(--accent-hover);
190
+ transform: translateY(-1px);
191
+ box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
192
  }
193
 
194
  /* 모달 스타일 */
 
264
  padding: 2px 0;
265
  border-radius: 2px;
266
  }
267
+
268
+ /* 마크다운 스타일 */
269
+ .markdown-content {
270
+ line-height: 1.6;
271
+ }
272
+
273
+ .markdown-content h1,
274
+ .markdown-content h2,
275
+ .markdown-content h3,
276
+ .markdown-content h4,
277
+ .markdown-content h5,
278
+ .markdown-content h6 {
279
+ margin-top: 24px;
280
+ margin-bottom: 16px;
281
+ font-weight: 600;
282
+ line-height: 1.25;
283
+ }
284
+
285
+ .markdown-content h1 {
286
+ font-size: 2em;
287
+ border-bottom: 1px solid var(--border);
288
+ padding-bottom: 8px;
289
+ }
290
+
291
+ .markdown-content h2 {
292
+ font-size: 1.5em;
293
+ border-bottom: 1px solid var(--border);
294
+ padding-bottom: 8px;
295
+ }
296
+
297
+ .markdown-content h3 {
298
+ font-size: 1.25em;
299
+ }
300
+
301
+ .markdown-content p {
302
+ margin-bottom: 16px;
303
+ }
304
+
305
+ .markdown-content ul,
306
+ .markdown-content ol {
307
+ margin-bottom: 16px;
308
+ padding-left: 30px;
309
+ }
310
+
311
+ .markdown-content li {
312
+ margin-bottom: 8px;
313
+ }
314
+
315
+ .markdown-content code {
316
+ background: var(--bg-tertiary);
317
+ padding: 2px 6px;
318
+ border-radius: 3px;
319
+ font-family: 'Courier New', monospace;
320
+ font-size: 0.9em;
321
+ }
322
+
323
+ .markdown-content pre {
324
+ background: var(--bg-tertiary);
325
+ padding: 16px;
326
+ border-radius: 6px;
327
+ overflow-x: auto;
328
+ margin-bottom: 16px;
329
+ }
330
+
331
+ .markdown-content pre code {
332
+ background: none;
333
+ padding: 0;
334
+ }
335
+
336
+ .markdown-content blockquote {
337
+ border-left: 4px solid var(--accent);
338
+ padding-left: 16px;
339
+ margin-left: 0;
340
+ margin-bottom: 16px;
341
+ color: var(--text-secondary);
342
+ }
343
+
344
+ .markdown-content table {
345
+ border-collapse: collapse;
346
+ width: 100%;
347
+ margin-bottom: 16px;
348
+ }
349
+
350
+ .markdown-content table th,
351
+ .markdown-content table td {
352
+ border: 1px solid var(--border);
353
+ padding: 8px 12px;
354
+ text-align: left;
355
+ }
356
+
357
+ .markdown-content table th {
358
+ background: var(--bg-tertiary);
359
+ font-weight: 600;
360
+ }
361
+
362
+ .markdown-content strong {
363
+ font-weight: 600;
364
+ }
365
+
366
+ .markdown-content em {
367
+ font-style: italic;
368
+ }
369
+
370
+ .markdown-content a {
371
+ color: var(--accent);
372
+ text-decoration: none;
373
+ }
374
+
375
+ .markdown-content a:hover {
376
+ text-decoration: underline;
377
+ }
378
+
379
+ /* 목차 사이드바 스타일 */
380
+ .content-modal-wrapper {
381
+ display: flex;
382
+ height: calc(90vh - 100px);
383
+ overflow: hidden;
384
+ }
385
+
386
+ .content-sidebar {
387
+ width: 125px;
388
+ background: var(--bg-secondary);
389
+ border-right: 1px solid var(--border);
390
+ overflow-y: auto;
391
+ padding: 12px;
392
+ flex-shrink: 0;
393
+ }
394
+
395
+ .content-sidebar-title {
396
+ font-size: 12px;
397
+ font-weight: 600;
398
+ color: var(--text-primary);
399
+ margin-bottom: 8px;
400
+ padding-bottom: 6px;
401
+ border-bottom: 2px solid var(--accent);
402
+ }
403
+
404
+ .content-toc {
405
+ list-style: none;
406
+ padding: 0;
407
+ margin: 0;
408
+ }
409
+
410
+ .content-toc-item {
411
+ margin-bottom: 4px;
412
+ }
413
+
414
+ .content-toc-link {
415
+ display: block;
416
+ padding: 6px 8px;
417
+ color: var(--text-primary);
418
+ text-decoration: none;
419
+ border-radius: 4px;
420
+ font-size: 11px;
421
+ transition: all 0.2s;
422
+ cursor: pointer;
423
+ line-height: 1.4;
424
+ }
425
+
426
+ .content-toc-link:hover {
427
+ background: var(--bg-tertiary);
428
+ color: var(--accent);
429
+ }
430
+
431
+ .content-toc-link.active {
432
+ background: var(--accent);
433
+ color: white;
434
+ }
435
+
436
+ .content-main {
437
+ flex: 1;
438
+ overflow-y: auto;
439
+ padding: 24px;
440
+ }
441
+
442
+ .content-section {
443
+ scroll-margin-top: 20px;
444
+ }
445
  </style>
446
  </head>
447
  <body>
 
452
  </div>
453
  <div class="header-actions">
454
  <span style="margin-right: 12px; color: var(--text-secondary);">{{ current_user.nickname or current_user.username }}</span>
455
+ <a href="{{ url_for('main.index') }}" class="btn btn-secondary">창작 어시스턴트</a>
456
  {% if current_user.is_admin %}
457
  <a href="{{ url_for('main.admin') }}" class="btn btn-secondary">관리자 페이지</a>
458
  {% endif %}
 
493
  </div>
494
  </div>
495
 
496
+ <!-- 웹소설 요약 보기 모달 -->
497
+ <div id="webnovelSummaryModal" class="modal">
498
+ <div class="modal-content">
499
+ <div class="modal-header">
500
+ <h2 class="modal-title" id="webnovelSummaryTitle">요약 내용</h2>
501
+ <button class="modal-close" onclick="closeWebnovelSummaryModal()">&times;</button>
502
+ </div>
503
+ <!-- 목차와 내용 영역 -->
504
+ <div class="content-modal-wrapper">
505
+ <!-- 목차 사이드바 -->
506
+ <div class="content-sidebar">
507
+ <div class="content-sidebar-title">📑 목차</div>
508
+ <ul class="content-toc" id="webnovelSummaryTOC">
509
+ <li class="content-toc-item">
510
+ <div style="padding: 8px 12px; color: var(--text-secondary); font-size: 12px;">목차를 불러오는 중...</div>
511
+ </li>
512
+ </ul>
513
+ </div>
514
+ <!-- 메인 내용 영역 -->
515
+ <div class="content-main" id="webnovelSummaryContentContainer" style="height: calc(90vh - 100px); overflow-y: auto;">
516
+ <div id="webnovelSummaryContent" style="white-space: pre-wrap; font-family: inherit; line-height: 1.6; color: var(--text-primary); padding: 16px;">
517
+ 내용을 불러오는 중...
518
+ </div>
519
+ </div>
520
+ </div>
521
+ </div>
522
+ </div>
523
+
524
  <!-- 웹소설 내용 보기 모달 -->
525
  <div id="webnovelContentModal" class="modal">
526
  <div class="modal-content">
 
563
  다음 →
564
  </button>
565
  </div>
566
+ <!-- 목차와 내용 영역 -->
567
+ <div class="content-modal-wrapper">
568
+ <!-- 목차 사이드바 -->
569
+ <div class="content-sidebar">
570
+ <div class="content-sidebar-title">📑 목차</div>
571
+ <ul class="content-toc" id="webnovelTOC">
572
+ <li class="content-toc-item">
573
+ <div style="padding: 8px 12px; color: var(--text-secondary); font-size: 12px;">목차를 불러오는 중...</div>
574
+ </li>
575
+ </ul>
576
+ </div>
577
+ <!-- 메인 내용 영역 -->
578
+ <div class="content-main" id="webnovelContentContainer">
579
+ <div id="webnovelContent" style="white-space: pre-wrap; font-family: inherit; line-height: 1.6; color: var(--text-primary);">
580
+ 내용을 불러오는 중...
581
+ </div>
582
  </div>
583
  </div>
584
  </div>
 
591
  return div.innerHTML;
592
  }
593
 
594
+ function renderMarkdown(text) {
595
+ if (!text) return '';
596
+ try {
597
+ // marked.js가 로드되었는지 확인
598
+ if (typeof marked !== 'undefined') {
599
+ return marked.parse(text);
600
+ } else {
601
+ // marked.js가 로드되지 않은 경우 기본 텍스트 반환
602
+ return escapeHtml(text).replace(/\n/g, '<br>');
603
+ }
604
+ } catch (e) {
605
+ console.error('마크다운 렌더링 오류:', e);
606
+ return escapeHtml(text).replace(/\n/g, '<br>');
607
+ }
608
+ }
609
+
610
  function formatFileSize(bytes) {
611
  if (bytes === 0) return '0 Bytes';
612
  const k = 1024;
 
719
  ${file.model_name ? `<span>🤖 ${escapeHtml(file.model_name)}</span>` : ''}
720
  </div>
721
  <div class="webnovel-item-actions">
722
+ <button class="webnovel-item-btn" onclick="viewWebnovelSummary(${file.id}, '${escapeHtml(file.original_filename)}')" style="background: #17a2b8; color: white; margin-right: 8px;">📋 요약 보기</button>
723
+ <button class="webnovel-item-btn" onclick="viewWebnovelContent(${file.id}, '${escapeHtml(file.original_filename)}')">📖 내용 보기</button>
724
  </div>
725
  `;
726
  listContainer.appendChild(fileItem);
 
730
  let webnovelOriginalContent = '';
731
  let webnovelSearchMatches = [];
732
  let webnovelCurrentMatchIndex = -1;
733
+ let webnovelTOC = [];
734
+
735
+ function parseTableOfContents(content) {
736
+ const toc = [];
737
+ const lines = content.split('\n');
738
+ const episodePattern = /^#\s*(작품설명|\d+화)/;
739
+
740
+ lines.forEach((line, index) => {
741
+ const match = line.match(episodePattern);
742
+ if (match) {
743
+ const title = match[1];
744
+ const id = `section-${toc.length}`;
745
+ toc.push({
746
+ id: id,
747
+ title: title === '작품설명' ? '작품설명' : title,
748
+ lineIndex: index,
749
+ element: null
750
+ });
751
+ }
752
+ });
753
+
754
+ return toc;
755
+ }
756
+
757
+ function renderContentWithSections(content, toc) {
758
+ const lines = content.split('\n');
759
+ let html = '';
760
+ let currentSectionIndex = 0;
761
+
762
+ lines.forEach((line, index) => {
763
+ if (currentSectionIndex < toc.length && index === toc[currentSectionIndex].lineIndex) {
764
+ // 섹션 시작
765
+ if (currentSectionIndex > 0) {
766
+ html += '</div>'; // 이전 섹션 닫기
767
+ }
768
+ html += `<div id="${toc[currentSectionIndex].id}" class="content-section">`;
769
+ html += `<h2 style="font-size: 20px; font-weight: 600; margin-top: 24px; margin-bottom: 16px; padding-bottom: 8px; border-bottom: 2px solid var(--accent);">${escapeHtml(line)}</h2>`;
770
+ currentSectionIndex++;
771
+ } else {
772
+ html += escapeHtml(line) + '\n';
773
+ }
774
+ });
775
+
776
+ if (currentSectionIndex > 0) {
777
+ html += '</div>'; // 마지막 섹션 닫기
778
+ }
779
+
780
+ return html;
781
+ }
782
+
783
+ function renderTableOfContents(toc) {
784
+ const tocContainer = document.getElementById('webnovelTOC');
785
+ if (toc.length === 0) {
786
+ tocContainer.innerHTML = '<li class="content-toc-item"><div style="padding: 8px 12px; color: var(--text-secondary); font-size: 12px;">목차가 없습니다.</div></li>';
787
+ return;
788
+ }
789
+
790
+ tocContainer.innerHTML = '';
791
+ toc.forEach((item, index) => {
792
+ const li = document.createElement('li');
793
+ li.className = 'content-toc-item';
794
+ const link = document.createElement('a');
795
+ link.className = 'content-toc-link';
796
+ link.href = `#${item.id}`;
797
+ link.textContent = item.title;
798
+ link.onclick = (e) => {
799
+ e.preventDefault();
800
+ scrollToSection(item.id);
801
+ // 활성 상태 업데이트
802
+ document.querySelectorAll('.content-toc-link').forEach(l => l.classList.remove('active'));
803
+ link.classList.add('active');
804
+ };
805
+ li.appendChild(link);
806
+ tocContainer.appendChild(li);
807
+ });
808
+ }
809
+
810
+ function scrollToSection(sectionId) {
811
+ const section = document.getElementById(sectionId);
812
+ if (section) {
813
+ const container = document.getElementById('webnovelContentContainer');
814
+ const containerRect = container.getBoundingClientRect();
815
+ const sectionRect = section.getBoundingClientRect();
816
+ const scrollTop = container.scrollTop + (sectionRect.top - containerRect.top) - 20;
817
+ container.scrollTo({ top: scrollTop, behavior: 'smooth' });
818
+ }
819
+ }
820
 
821
  async function viewWebnovelContent(fileId, filename) {
822
  const modal = document.getElementById('webnovelContentModal');
 
831
  webnovelOriginalContent = '';
832
  webnovelSearchMatches = [];
833
  webnovelCurrentMatchIndex = -1;
834
+ webnovelTOC = [];
835
  updateWebnovelSearchInfo();
836
 
837
+ // 목차 초기화
838
+ const tocContainer = document.getElementById('webnovelTOC');
839
+ tocContainer.innerHTML = '<li class="content-toc-item"><div style="padding: 8px 12px; color: var(--text-secondary); font-size: 12px;">목차를 불러오는 중...</div></li>';
840
+
841
  try {
842
  const response = await fetch(`/api/files/${fileId}/content`, {
843
  credentials: 'include'
 
846
 
847
  const data = await response.json();
848
  webnovelOriginalContent = data.content;
849
+
850
+ // 목차 파싱
851
+ webnovelTOC = parseTableOfContents(webnovelOriginalContent);
852
+
853
+ // 목차 렌더링
854
+ renderTableOfContents(webnovelTOC);
855
+
856
+ // 내용을 섹션으로 나누어 렌더링
857
+ content.innerHTML = renderContentWithSections(webnovelOriginalContent, webnovelTOC);
858
 
859
  // 검색 입력 필드 포커스
860
  searchInput.focus();
 
875
  }
876
 
877
  if (!webnovelOriginalContent) {
878
+ // HTML에서 텍스트 추출
879
+ const tempDiv = document.createElement('div');
880
+ tempDiv.innerHTML = content.innerHTML;
881
+ webnovelOriginalContent = tempDiv.textContent || tempDiv.innerText || '';
882
  }
883
 
884
+ // 섹션으로 나눈 내용을 다시 렌더링한 후 검색어로 하이라이트 처리
885
+ let contentHtml = renderContentWithSections(webnovelOriginalContent, webnovelTOC);
886
  const regex = new RegExp(`(${escapeRegex(searchTerm)})`, 'gi');
887
+ contentHtml = contentHtml.replace(regex, '<mark style="background: #ffeb3b; padding: 2px 0; border-radius: 2px;">$1</mark>');
888
+ content.innerHTML = contentHtml;
889
 
890
  // 검색 결과 위치 찾기
891
  webnovelSearchMatches = [];
 
909
 
910
  searchInput.value = '';
911
  if (webnovelOriginalContent) {
912
+ // 섹션으로 나눈 내용을 다시 렌더링
913
+ content.innerHTML = renderContentWithSections(webnovelOriginalContent, webnovelTOC);
914
  }
915
  webnovelSearchMatches = [];
916
  webnovelCurrentMatchIndex = -1;
 
1018
  return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
1019
  }
1020
 
1021
+ function parseSummaryTableOfContents(data) {
1022
+ const toc = [];
1023
+ let sectionIndex = 0;
1024
+
1025
+ // Parent Chunk 섹션
1026
+ if (data.parent_chunk) {
1027
+ toc.push({
1028
+ id: 'summary-section-parent',
1029
+ title: 'Parent Chunk',
1030
+ level: 1
1031
+ });
1032
+
1033
+ if (data.parent_chunk.world_view) {
1034
+ toc.push({
1035
+ id: 'summary-section-world-view',
1036
+ title: '세계관',
1037
+ level: 2
1038
+ });
1039
+ }
1040
+ if (data.parent_chunk.characters) {
1041
+ toc.push({
1042
+ id: 'summary-section-characters',
1043
+ title: '주요 캐릭터',
1044
+ level: 2
1045
+ });
1046
+ }
1047
+ if (data.parent_chunk.story) {
1048
+ toc.push({
1049
+ id: 'summary-section-story',
1050
+ title: '주요 스토리',
1051
+ level: 2
1052
+ });
1053
+ }
1054
+ if (data.parent_chunk.episodes) {
1055
+ toc.push({
1056
+ id: 'summary-section-episodes',
1057
+ title: '주요 에피소드',
1058
+ level: 2
1059
+ });
1060
+ }
1061
+ if (data.parent_chunk.others) {
1062
+ toc.push({
1063
+ id: 'summary-section-others',
1064
+ title: '기타',
1065
+ level: 2
1066
+ });
1067
+ }
1068
+ }
1069
+
1070
+ // 회차별 분석 섹션
1071
+ if (data.episode_analysis && data.episode_analysis.analysis_content) {
1072
+ toc.push({
1073
+ id: 'summary-section-episode-analysis',
1074
+ title: '회차별 분석',
1075
+ level: 1
1076
+ });
1077
+
1078
+ // 마크다운에서 ## 헤더 찾기 (회차 제목)
1079
+ const lines = data.episode_analysis.analysis_content.split('\n');
1080
+ let episodeIndex = 0;
1081
+ lines.forEach((line) => {
1082
+ const headerMatch = line.match(/^##\s+(.+)$/);
1083
+ if (headerMatch) {
1084
+ const title = headerMatch[1].trim();
1085
+ const sectionId = `summary-episode-${episodeIndex}`;
1086
+ toc.push({
1087
+ id: sectionId,
1088
+ title: title,
1089
+ level: 2
1090
+ });
1091
+ episodeIndex++;
1092
+ }
1093
+ });
1094
+ }
1095
+
1096
+ return toc;
1097
+ }
1098
+
1099
+ function renderSummaryWithSections(data) {
1100
+ let contentHtml = '';
1101
+ let sectionIndex = 0;
1102
+
1103
+ // Parent Chunk 내용
1104
+ if (data.parent_chunk) {
1105
+ contentHtml += '<div id="summary-section-parent" class="content-section" style="margin-bottom: 32px;">';
1106
+ contentHtml += '<h3 style="font-size: 18px; font-weight: 600; margin-bottom: 16px; color: var(--accent); border-bottom: 2px solid var(--accent); padding-bottom: 8px;">Parent Chunk (작품 전체 요약)</h3>';
1107
+
1108
+ if (data.parent_chunk.world_view) {
1109
+ contentHtml += '<div id="summary-section-world-view" class="content-section" style="margin-bottom: 16px;">';
1110
+ contentHtml += '<h4 style="font-size: 16px; font-weight: 600; margin-bottom: 8px; color: #1967d2;">세계관</h4>';
1111
+ contentHtml += `<div class="markdown-content" style="padding: 12px; background: var(--bg-secondary); border-radius: 6px;">${renderMarkdown(data.parent_chunk.world_view)}</div>`;
1112
+ contentHtml += '</div>';
1113
+ }
1114
+
1115
+ if (data.parent_chunk.characters) {
1116
+ contentHtml += '<div id="summary-section-characters" class="content-section" style="margin-bottom: 16px;">';
1117
+ contentHtml += '<h4 style="font-size: 16px; font-weight: 600; margin-bottom: 8px; color: #1967d2;">주요 캐릭터</h4>';
1118
+ contentHtml += `<div class="markdown-content" style="padding: 12px; background: var(--bg-secondary); border-radius: 6px;">${renderMarkdown(data.parent_chunk.characters)}</div>`;
1119
+ contentHtml += '</div>';
1120
+ }
1121
+
1122
+ if (data.parent_chunk.story) {
1123
+ contentHtml += '<div id="summary-section-story" class="content-section" style="margin-bottom: 16px;">';
1124
+ contentHtml += '<h4 style="font-size: 16px; font-weight: 600; margin-bottom: 8px; color: #1967d2;">주요 스토리</h4>';
1125
+ contentHtml += `<div class="markdown-content" style="padding: 12px; background: var(--bg-secondary); border-radius: 6px;">${renderMarkdown(data.parent_chunk.story)}</div>`;
1126
+ contentHtml += '</div>';
1127
+ }
1128
+
1129
+ if (data.parent_chunk.episodes) {
1130
+ contentHtml += '<div id="summary-section-episodes" class="content-section" style="margin-bottom: 16px;">';
1131
+ contentHtml += '<h4 style="font-size: 16px; font-weight: 600; margin-bottom: 8px; color: #1967d2;">주요 에피소드</h4>';
1132
+ contentHtml += `<div class="markdown-content" style="padding: 12px; background: var(--bg-secondary); border-radius: 6px;">${renderMarkdown(data.parent_chunk.episodes)}</div>`;
1133
+ contentHtml += '</div>';
1134
+ }
1135
+
1136
+ if (data.parent_chunk.others) {
1137
+ contentHtml += '<div id="summary-section-others" class="content-section" style="margin-bottom: 16px;">';
1138
+ contentHtml += '<h4 style="font-size: 16px; font-weight: 600; margin-bottom: 8px; color: #1967d2;">기타</h4>';
1139
+ contentHtml += `<div class="markdown-content" style="padding: 12px; background: var(--bg-secondary); border-radius: 6px;">${renderMarkdown(data.parent_chunk.others)}</div>`;
1140
+ contentHtml += '</div>';
1141
+ }
1142
+
1143
+ contentHtml += '</div>';
1144
+ } else {
1145
+ contentHtml += '<div style="margin-bottom: 32px; padding: 16px; background: #fff3cd; border-radius: 6px; color: #856404;">Parent Chunk가 생성되지 않았습니다.</div>';
1146
+ }
1147
+
1148
+ // Episode Analysis 내용
1149
+ if (data.episode_analysis) {
1150
+ contentHtml += '<div id="summary-section-episode-analysis" class="content-section" style="margin-top: 32px; border-top: 2px solid var(--border); padding-top: 24px;">';
1151
+ contentHtml += '<h3 style="font-size: 18px; font-weight: 600; margin-bottom: 16px; color: var(--accent); border-bottom: 2px solid var(--accent); padding-bottom: 8px;">회차별 분석</h3>';
1152
+
1153
+ // 회차별 분석 내용을 마크다운으로 렌더링하고, 각 ## 헤더에 ID 추가
1154
+ let episodeContent = data.episode_analysis.analysis_content || '';
1155
+
1156
+ // 먼저 마크다운을 HTML로 렌더링
1157
+ let episodeHtml = renderMarkdown(episodeContent);
1158
+
1159
+ // 렌더링된 HTML에서 h2 헤더를 찾아서 ID 추가
1160
+ const tempDiv = document.createElement('div');
1161
+ tempDiv.innerHTML = episodeHtml;
1162
+ const headers = tempDiv.querySelectorAll('h2');
1163
+ let headerIndex = 0;
1164
+ headers.forEach((header) => {
1165
+ const sectionId = `summary-episode-${headerIndex}`;
1166
+ header.id = sectionId;
1167
+ header.className = 'content-section';
1168
+ header.style.cssText = 'font-size: 16px; font-weight: 600; margin-top: 24px; margin-bottom: 12px; padding-bottom: 6px; border-bottom: 1px solid var(--border);';
1169
+ headerIndex++;
1170
+ });
1171
+ episodeHtml = tempDiv.innerHTML;
1172
+
1173
+ contentHtml += `<div class="markdown-content" style="padding: 12px; background: var(--bg-secondary); border-radius: 6px;">${episodeHtml}</div>`;
1174
+ contentHtml += '</div>';
1175
+ } else {
1176
+ contentHtml += '<div style="margin-top: 32px; padding: 16px; background: #fff3cd; border-radius: 6px; color: #856404;">회차별 분석이 생성되지 않았습니다.</div>';
1177
+ }
1178
+
1179
+ if (!data.parent_chunk && !data.episode_analysis) {
1180
+ contentHtml = '<div style="text-align: center; padding: 24px; color: var(--text-secondary);">요약 내용이 없습니다.</div>';
1181
+ }
1182
+
1183
+ return contentHtml;
1184
+ }
1185
+
1186
+ function renderSummaryTableOfContents(toc) {
1187
+ const tocContainer = document.getElementById('webnovelSummaryTOC');
1188
+ if (toc.length === 0) {
1189
+ tocContainer.innerHTML = '<li class="content-toc-item"><div style="padding: 8px 12px; color: var(--text-secondary); font-size: 12px;">목차가 없습니다.</div></li>';
1190
+ return;
1191
+ }
1192
+
1193
+ tocContainer.innerHTML = '';
1194
+ toc.forEach((item) => {
1195
+ const li = document.createElement('li');
1196
+ li.className = 'content-toc-item';
1197
+ const link = document.createElement('a');
1198
+ link.className = 'content-toc-link';
1199
+ link.href = `#${item.id}`;
1200
+ link.textContent = item.title;
1201
+ if (item.level === 2) {
1202
+ link.style.paddingLeft = '16px';
1203
+ link.style.fontSize = '10px';
1204
+ }
1205
+ link.onclick = (e) => {
1206
+ e.preventDefault();
1207
+ scrollToSummarySection(item.id);
1208
+ // 활성 상태 업데이트
1209
+ document.querySelectorAll('#webnovelSummaryTOC .content-toc-link').forEach(l => l.classList.remove('active'));
1210
+ link.classList.add('active');
1211
+ };
1212
+ li.appendChild(link);
1213
+ tocContainer.appendChild(li);
1214
+ });
1215
+ }
1216
+
1217
+ function scrollToSummarySection(sectionId) {
1218
+ const section = document.getElementById(sectionId);
1219
+ if (section) {
1220
+ const container = document.getElementById('webnovelSummaryContentContainer');
1221
+ const containerRect = container.getBoundingClientRect();
1222
+ const sectionRect = section.getBoundingClientRect();
1223
+ const scrollTop = container.scrollTop + (sectionRect.top - containerRect.top) - 20;
1224
+ container.scrollTo({ top: scrollTop, behavior: 'smooth' });
1225
+ }
1226
+ }
1227
+
1228
+ async function viewWebnovelSummary(fileId, fileName) {
1229
+ const modal = document.getElementById('webnovelSummaryModal');
1230
+ const title = document.getElementById('webnovelSummaryTitle');
1231
+ const content = document.getElementById('webnovelSummaryContent');
1232
+
1233
+ console.log('[요약 보기] 파일 ID:', fileId, '파일명:', fileName);
1234
+
1235
+ title.textContent = `요약 내용 - ${fileName}`;
1236
+ content.textContent = '요약 내용을 불러오는 중...';
1237
+ modal.classList.add('active');
1238
+
1239
+ // 목차 초기화
1240
+ const tocContainer = document.getElementById('webnovelSummaryTOC');
1241
+ tocContainer.innerHTML = '<li class="content-toc-item"><div style="padding: 8px 12px; color: var(--text-secondary); font-size: 12px;">목차를 불러오는 중...</div></li>';
1242
+
1243
+ try {
1244
+ const url = `/api/files/${fileId}/summary`;
1245
+ console.log('[요약 보기] API 요청 URL:', url);
1246
+ const response = await fetch(url, {
1247
+ credentials: 'include'
1248
+ });
1249
+ console.log('[요약 보기] 응답 상태:', response.status, response.statusText);
1250
+
1251
+ if (!response.ok) {
1252
+ const errorData = await response.json().catch(() => ({ error: '요약 내용을 불러올 수 없습니다.' }));
1253
+ throw new Error(errorData.error || `요약 내용을 불러올 수 없습니다. (${response.status})`);
1254
+ }
1255
+
1256
+ const data = await response.json();
1257
+
1258
+ // 목차 파싱
1259
+ const summaryTOC = parseSummaryTableOfContents(data);
1260
+
1261
+ // 목차 렌더링
1262
+ renderSummaryTableOfContents(summaryTOC);
1263
+
1264
+ // 내용을 섹션으로 나누어 렌더링
1265
+ content.innerHTML = renderSummaryWithSections(data);
1266
+ } catch (error) {
1267
+ console.error('요약 내용 로드 오류:', error);
1268
+ content.innerHTML = `<div style="text-align: center; padding: 24px; color: #ea4335;">
1269
+ <p style="font-weight: 600; margin-bottom: 8px;">요약 내용을 불러오는 중 오류가 발생했습니다.</p>
1270
+ <p style="font-size: 13px; color: var(--text-secondary);">${escapeHtml(error.message)}</p>
1271
+ </div>`;
1272
+ }
1273
+ }
1274
+
1275
+ function closeWebnovelSummaryModal() {
1276
+ document.getElementById('webnovelSummaryModal').classList.remove('active');
1277
+ }
1278
+
1279
  function closeWebnovelContentModal() {
1280
  document.getElementById('webnovelContentModal').classList.remove('active');
1281
  clearWebnovelSearch();
1282
  }
1283
 
1284
  // 모달 외부 클릭 시 닫기
1285
+ document.getElementById('webnovelSummaryModal').addEventListener('click', function(e) {
1286
+ if (e.target === this) {
1287
+ closeWebnovelSummaryModal();
1288
+ }
1289
+ });
1290
+
1291
  document.getElementById('webnovelContentModal').addEventListener('click', function(e) {
1292
  if (e.target === this) {
1293
  closeWebnovelContentModal();