SOY NV AI commited on
Commit
bb30dc7
·
1 Parent(s): 0276388

feat: GraphRAG 기능 추가 및 그래프 시각화 구현

Browse files

- GraphRAG 데이터 모델 추가 (GraphEntity, GraphRelationship, GraphEvent)
- 회차별 Graph Extraction 기능 구현
- GraphRAG 데이터 조회 API 엔드포인트 추가
- AI 채팅에서 GraphRAG 데이터 참조 기능 추가
- 관리자 페이지 및 웹소설 페이지에 GraphRAG 보기 기능 추가
- GraphRAG 그래프 시각화 기능 추가 (vis-network 사용)
- 에피소드(사건) 시각화 기능 추가
- AI 답변에 마크다운 렌더링 적용

app/database.py CHANGED
@@ -246,4 +246,97 @@ class EpisodeAnalysis(db.Model):
246
  'updated_at': self.updated_at.isoformat() if self.updated_at else None
247
  }
248
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
249
 
 
246
  'updated_at': self.updated_at.isoformat() if self.updated_at else None
247
  }
248
 
249
+ # Graph Extraction 모델 (GraphRAG용)
250
+ class GraphEntity(db.Model):
251
+ """Graph Extraction에서 추출된 엔티티 (인물/장소)"""
252
+ id = db.Column(db.Integer, primary_key=True)
253
+ file_id = db.Column(db.Integer, db.ForeignKey('uploaded_file.id'), nullable=False)
254
+ episode_title = db.Column(db.String(100), nullable=False) # 회차 제목
255
+ entity_name = db.Column(db.String(200), nullable=False) # 엔티티 이름
256
+ entity_type = db.Column(db.String(50), nullable=False) # 'character' 또는 'location'
257
+ description = db.Column(db.Text, nullable=True) # 엔티티 설명
258
+ role = db.Column(db.String(200), nullable=True) # 인물의 경우 역할
259
+ category = db.Column(db.String(200), nullable=True) # 장소의 경우 유형
260
+ created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
261
+
262
+ # 관계
263
+ file = db.relationship('UploadedFile', backref='graph_entities')
264
+
265
+ def to_dict(self):
266
+ return {
267
+ 'id': self.id,
268
+ 'file_id': self.file_id,
269
+ 'episode_title': self.episode_title,
270
+ 'entity_name': self.entity_name,
271
+ 'entity_type': self.entity_type,
272
+ 'description': self.description,
273
+ 'role': self.role,
274
+ 'category': self.category,
275
+ 'created_at': self.created_at.isoformat() if self.created_at else None
276
+ }
277
+
278
+ class GraphRelationship(db.Model):
279
+ """Graph Extraction에서 추출된 관계"""
280
+ id = db.Column(db.Integer, primary_key=True)
281
+ file_id = db.Column(db.Integer, db.ForeignKey('uploaded_file.id'), nullable=False)
282
+ episode_title = db.Column(db.String(100), nullable=False) # 회차 제목
283
+ source = db.Column(db.String(200), nullable=False) # 관계의 주체
284
+ target = db.Column(db.String(200), nullable=False) # 관계의 대상
285
+ relationship_type = db.Column(db.String(200), nullable=False) # 관계 유형
286
+ description = db.Column(db.Text, nullable=True) # 관계 설명
287
+ event = db.Column(db.String(500), nullable=True) # 관계를 형성/변화시킨 사건
288
+ created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
289
+
290
+ # 관계
291
+ file = db.relationship('UploadedFile', backref='graph_relationships')
292
+
293
+ def to_dict(self):
294
+ return {
295
+ 'id': self.id,
296
+ 'file_id': self.file_id,
297
+ 'episode_title': self.episode_title,
298
+ 'source': self.source,
299
+ 'target': self.target,
300
+ 'relationship_type': self.relationship_type,
301
+ 'description': self.description,
302
+ 'event': self.event,
303
+ 'created_at': self.created_at.isoformat() if self.created_at else None
304
+ }
305
+
306
+ class GraphEvent(db.Model):
307
+ """Graph Extraction에서 추출된 사건"""
308
+ id = db.Column(db.Integer, primary_key=True)
309
+ file_id = db.Column(db.Integer, db.ForeignKey('uploaded_file.id'), nullable=False)
310
+ episode_title = db.Column(db.String(100), nullable=False) # 회차 제목
311
+ event_name = db.Column(db.String(500), nullable=False) # 사건 이름
312
+ description = db.Column(db.Text, nullable=False) # 사건 설명
313
+ participants = db.Column(db.Text, nullable=True) # 관련 인물들 (JSON 문자열로 저장)
314
+ location = db.Column(db.String(500), nullable=True) # 사건 발생 장소
315
+ significance = db.Column(db.String(200), nullable=True) # 사건의 중요도
316
+ created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
317
+
318
+ # 관계
319
+ file = db.relationship('UploadedFile', backref='graph_events')
320
+
321
+ def to_dict(self):
322
+ import json
323
+ participants_list = []
324
+ if self.participants:
325
+ try:
326
+ participants_list = json.loads(self.participants)
327
+ except:
328
+ participants_list = []
329
+
330
+ return {
331
+ 'id': self.id,
332
+ 'file_id': self.file_id,
333
+ 'episode_title': self.episode_title,
334
+ 'event_name': self.event_name,
335
+ 'description': self.description,
336
+ 'participants': participants_list,
337
+ 'location': self.location,
338
+ 'significance': self.significance,
339
+ 'created_at': self.created_at.isoformat() if self.created_at else None
340
+ }
341
+
342
 
app/prompts/__init__.py CHANGED
@@ -5,10 +5,12 @@ LLM 프롬프트 템플릿을 관리합니다.
5
 
6
  from app.prompts.metadata import get_metadata_extraction_prompt
7
  from app.prompts.parent_chunk import get_parent_chunk_analysis_prompt
 
8
 
9
  __all__ = [
10
  'get_metadata_extraction_prompt',
11
  'get_parent_chunk_analysis_prompt',
 
12
  ]
13
 
14
 
 
5
 
6
  from app.prompts.metadata import get_metadata_extraction_prompt
7
  from app.prompts.parent_chunk import get_parent_chunk_analysis_prompt
8
+ from app.prompts.graph_extraction import get_graph_extraction_prompt
9
 
10
  __all__ = [
11
  'get_metadata_extraction_prompt',
12
  'get_parent_chunk_analysis_prompt',
13
+ 'get_graph_extraction_prompt',
14
  ]
15
 
16
 
app/prompts/graph_extraction.py ADDED
@@ -0,0 +1,105 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Graph Extraction 프롬프트
3
+ 엔티티(인물/장소)와 관계(사건)를 추출하는 GraphRAG 기반 프롬프트
4
+ """
5
+
6
+ from typing import Optional
7
+
8
+
9
+ def get_graph_extraction_prompt(
10
+ episode_content: str,
11
+ episode_title: str,
12
+ full_content: Optional[str] = None,
13
+ parent_chunk_info: Optional[str] = None,
14
+ max_length: int = 10000
15
+ ) -> str:
16
+ """
17
+ Graph Extraction을 위한 프롬프트 생성
18
+
19
+ Args:
20
+ episode_content: 분석할 회차 내용
21
+ episode_title: 회차 제목 (예: '1화', '2화')
22
+ full_content: 원본 웹소설 전체 내용 (참고용)
23
+ parent_chunk_info: Parent Chunk 정보 (선택사항)
24
+ max_length: 프롬프트에 포함할 최대 텍스트 길이
25
+
26
+ Returns:
27
+ 프롬프트 문자열
28
+ """
29
+ # 회차 내용 길이 제한
30
+ content_preview = episode_content[:max_length]
31
+ is_truncated = len(episode_content) > max_length
32
+
33
+ truncation_note = "\n(참고: 회차 내용이 길어 일부만 사용되었습니다.)" if is_truncated else ""
34
+
35
+ # 전체 내용 참고용 (선택사항)
36
+ full_content_preview = ""
37
+ if full_content:
38
+ # 전체 내용이 너무 길면 앞부분과 뒷부분 일부만 사용
39
+ if len(full_content) > 30000:
40
+ full_content_preview = full_content[:15000] + "\n... (중간 생략) ...\n" + full_content[-15000:]
41
+ else:
42
+ full_content_preview = full_content
43
+
44
+ prompt = f"""다음 웹소설의 {episode_title} 회차에서 엔티티(인물/장소)와 관계(사건)를 추출해주세요.
45
+
46
+ {parent_chunk_info if parent_chunk_info else ""}
47
+
48
+ 원본 웹소설 전체 내용 (참고용):
49
+ {full_content_preview[:50000] if full_content_preview else "없음"}
50
+
51
+ 분석할 회차 내용 ({episode_title}):
52
+ {content_preview}{truncation_note}
53
+
54
+ 다음 형식으로 JSON 형식으로만 응답하세요:
55
+
56
+ {{
57
+ "entities": {{
58
+ "characters": [
59
+ {{
60
+ "name": "인물 이름",
61
+ "type": "인물",
62
+ "description": "인물에 대한 간단한 설명",
63
+ "role": "이 회차에서의 역할 (예: 주인공, 조연, 악역 등)"
64
+ }}
65
+ ],
66
+ "locations": [
67
+ {{
68
+ "name": "장소 이름",
69
+ "type": "장소",
70
+ "description": "장소에 대한 간단한 설명",
71
+ "category": "장소 유형 (예: 도시, 건물, 차원 등)"
72
+ }}
73
+ ]
74
+ }},
75
+ "relationships": [
76
+ {{
77
+ "source": "관계의 주체 (인물 이름)",
78
+ "target": "관계의 대상 (인물 이름 또는 장소 이름)",
79
+ "type": "관계 유형 (예: 친구, 적, 연인, 거주지, 방문지 등)",
80
+ "description": "관계에 대한 상세 설명",
81
+ "event": "이 관계를 형성하거나 변화시킨 사건 (있는 경우)"
82
+ }}
83
+ ],
84
+ "events": [
85
+ {{
86
+ "name": "사건 이름",
87
+ "description": "사건에 대한 상세 설명",
88
+ "participants": ["관련 인물1", "관련 인물2"],
89
+ "location": "사건이 발생한 장소",
90
+ "significance": "사건의 중요도 (예: 주요 사건, 부수 사건 등)"
91
+ }}
92
+ ]
93
+ }}
94
+
95
+ 중요 사항:
96
+ 1. 엔티티는 이 회차에서 실제로 등장하거나 언급된 인물과 장소만 추출하세요.
97
+ 2. 관계는 이 회차에서 새로 형성되거나 변화한 관계를 중심으로 추출하세요.
98
+ 3. 사건은 이 회차에서 일어난 구체적인 사건들을 추출하세요.
99
+ 4. 응답은 오직 JSON 형식만 사용하고, 다른 설명이나 마크다운은 포함하지 마세요.
100
+ 5. JSON 형식이 올바른지 반드시 확인하세요 (따옴표 이스케이프 등).
101
+ 6. 배열이 비어있을 경우 빈 배열 []로 표시하세요.
102
+ 7. 필드 값이 없는 경우 null 대신 빈 문자열 "" 또는 빈 배열 []을 사용하세요."""
103
+
104
+ return prompt
105
+
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, EpisodeAnalysis
5
  from app.vector_db import get_vector_db
6
  from app.gemini_client import get_gemini_client
7
  import requests
@@ -603,6 +603,216 @@ def analyze_episode(episode_content, episode_title, full_content=None, parent_ch
603
  traceback.print_exc()
604
  return f"## {episode_title} 분석\n분석 중 오류가 발생했습니다: {str(e)}"
605
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
606
  def create_chunks_for_file(file_id, content):
607
  """파일 내용을 섹션별로 분할하여 의미 기반 ��크로 저장 (벡터 DB 포함)
608
 
@@ -703,6 +913,33 @@ def create_chunks_for_file(file_id, content):
703
  db.session.add(episode_analysis)
704
  db.session.commit()
705
  print(f"[회차 분석] 완료: {len(episode_sections)}개 회차 분석 결과를 하나의 텍스트로 저장")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
706
  else:
707
  print(f"[회차 분석] 경고: 분석 결과가 없습니다.")
708
  else:
@@ -1074,6 +1311,129 @@ def get_episode_analyses_for_files(file_ids):
1074
  print(f"[회차별 분석 조회] 오류: {str(e)}")
1075
  return []
1076
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1077
  def search_relevant_chunks(query, file_ids=None, model_name=None, top_k=5, min_score=1):
1078
  """
1079
  질문과 관련된 청크 검색 (벡터 검색 + Re-ranking)
@@ -1750,8 +2110,18 @@ def chat():
1750
  episode_analyses = get_episode_analyses_for_files(file_ids)
1751
  print(f"[RAG 검색 1단계] 회차별 분석 조회 완료: {len(episode_analyses)}개 파일")
1752
 
1753
- # 2단계: 벡터 검색 + 리랭킹으로 Child Chunk 정밀 검색
1754
- print(f"[RAG 검색 2단계] 벡터 검색 + 리랭킹 시작...")
 
 
 
 
 
 
 
 
 
 
1755
  relevant_chunks = search_relevant_chunks(
1756
  query=message,
1757
  file_ids=file_ids if file_ids else None,
@@ -1759,11 +2129,113 @@ def chat():
1759
  top_k=5, # 리랭킹 후 상위 5개만 선택
1760
  min_score=0.5 # 최소 점수 임계값
1761
  )
1762
- print(f"[RAG 검색 2단계] 벡터 검색 + 리랭킹 완료: {len(relevant_chunks)}개 청크 (상위 5개)")
1763
 
1764
  # 컨텍스트 구성
1765
  context_parts = []
1766
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1767
  # 회차별 분석 정보 추가 (회차별 요약 참조용)
1768
  if episode_analyses:
1769
  episode_context_sections = []
@@ -1816,8 +2288,27 @@ def chat():
1816
  if context_parts:
1817
  full_context = "\n\n" + "\n\n---\n\n".join(context_parts) + "\n\n"
1818
 
1819
- # 회차별 분석과 Child Chunk 모두 있는 경우
1820
- if episode_analyses and relevant_chunks:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1821
  context = f"""다음은 질문에 답하기 위한 웹소설 정보입니다:
1822
 
1823
  {full_context}
@@ -1831,6 +2322,38 @@ def chat():
1831
  답변의 각 문장 끝에는 참고한 본문의 문장을 [근거: "문장 내용..."] 형식으로 반드시 붙이세요.
1832
  근거를 찾을 수 없다면 "내용을 찾을 수 없습니다"라고 답하고 지어내지 마세요.
1833
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1834
  질문:
1835
  """
1836
  elif episode_analyses:
@@ -1845,6 +2368,22 @@ def chat():
1845
  답변의 각 문장 끝에는 참고한 본문의 문장을 [근거: "문장 내용..."] 형식으로 반드시 붙이세요.
1846
  근거를 찾을 수 없다면 "내용을 찾을 수 없습니다"라고 답하고 지어내지 마세요.
1847
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1848
  질문:
1849
  """
1850
  else:
@@ -1863,7 +2402,8 @@ def chat():
1863
  """
1864
 
1865
  context += message
1866
- print(f"[RAG 검색] 최종 컨텍스트 생성 완료 (회차별 분석: {len(episode_analyses)}개, Child Chunk: {len(relevant_chunks)}개, {len(context)}자)")
 
1867
  else:
1868
  # RAG 검색 결과가 없으면 기존 방식 사용
1869
  print(f"[RAG 검색] 관련 청크를 찾지 못했습니다. 전체 파일 내용 사용")
@@ -2603,6 +3143,79 @@ def get_file_summary(file_id):
2603
  except Exception as e:
2604
  return jsonify({'error': f'요약 내용 조회 중 오류가 발생했습니다: {str(e)}'}), 500
2605
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2606
  @main_bp.route('/api/files/<int:file_id>/parent-chunk', methods=['GET'])
2607
  @login_required
2608
  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, GraphEntity, GraphRelationship, GraphEvent
5
  from app.vector_db import get_vector_db
6
  from app.gemini_client import get_gemini_client
7
  import requests
 
603
  traceback.print_exc()
604
  return f"## {episode_title} 분석\n분석 중 오류가 발생했습니다: {str(e)}"
605
 
606
+ def extract_graph_from_episode(episode_content, episode_title, file_id, full_content=None, parent_chunk=None, model_name=None):
607
+ """회차별 Graph Extraction (엔티티와 관계 추출)
608
+
609
+ Args:
610
+ episode_content: 분석할 회차 내용
611
+ episode_title: 회차 제목 (예: '1화', '2화')
612
+ file_id: 파일 ID
613
+ full_content: 원본 웹소설 전체 내용 (참고용)
614
+ parent_chunk: Parent Chunk 객체 (선택사항)
615
+ model_name: 사용할 AI 모델명
616
+
617
+ Returns:
618
+ 추출 성공 여부 (bool)
619
+ """
620
+ try:
621
+ print(f"[Graph Extraction] '{episode_title}' Graph Extraction 시작...")
622
+
623
+ # Parent Chunk 정보 추가
624
+ parent_info = ""
625
+ if parent_chunk:
626
+ parent_info = f"""
627
+ 작품 전체 정보:
628
+ - 세계관: {parent_chunk.world_view or '없음'}
629
+ - 주요 캐릭터: {parent_chunk.characters or '없음'}
630
+ - 주요 스토리: {parent_chunk.story or '없음'}
631
+ """
632
+
633
+ # Graph Extraction 프롬프트 생성
634
+ from app.prompts.graph_extraction import get_graph_extraction_prompt
635
+
636
+ prompt = get_graph_extraction_prompt(
637
+ episode_content=episode_content,
638
+ episode_title=episode_title,
639
+ full_content=full_content,
640
+ parent_chunk_info=parent_info,
641
+ max_length=10000
642
+ )
643
+
644
+ # 모델명이 없으면 기본값 사용 (Gemini 우선 시도)
645
+ response_text = None
646
+ if not model_name:
647
+ # Gemini 시도
648
+ try:
649
+ gemini_client = get_gemini_client()
650
+ if gemini_client.is_configured():
651
+ result = gemini_client.generate_response(
652
+ prompt=prompt,
653
+ model_name="gemini-1.5-flash",
654
+ temperature=0.3,
655
+ max_output_tokens=3000
656
+ )
657
+ if not result['error'] and result.get('response'):
658
+ response_text = result['response'].strip()
659
+ except Exception as e:
660
+ print(f"[Graph Extraction] Gemini 기본 모델 오류: {str(e)}")
661
+
662
+ # 모델명이 있거나 Gemini 실패 시 해당 모델 사용
663
+ if not response_text and model_name:
664
+ model_name_lower = model_name.lower().strip()
665
+ is_gemini = model_name_lower.startswith('gemini:') or model_name_lower.startswith('gemini-')
666
+
667
+ if is_gemini:
668
+ gemini_model_name = model_name.strip()
669
+ if gemini_model_name.lower().startswith('gemini:'):
670
+ gemini_model_name = gemini_model_name.split(':', 1)[1].strip()
671
+
672
+ gemini_client = get_gemini_client()
673
+ if gemini_client.is_configured():
674
+ result = gemini_client.generate_response(
675
+ prompt=prompt,
676
+ model_name=gemini_model_name,
677
+ temperature=0.3,
678
+ max_output_tokens=3000
679
+ )
680
+ if not result['error'] and result.get('response'):
681
+ response_text = result['response'].strip()
682
+ else:
683
+ # Ollama API 호출
684
+ try:
685
+ ollama_response = requests.post(
686
+ f'{OLLAMA_BASE_URL}/api/generate',
687
+ json={
688
+ 'model': model_name,
689
+ 'prompt': prompt,
690
+ 'stream': False,
691
+ 'options': {
692
+ 'temperature': 0.3,
693
+ 'num_predict': 3000
694
+ }
695
+ },
696
+ timeout=300 # 5분 타임아웃
697
+ )
698
+ if ollama_response.status_code == 200:
699
+ response_data = ollama_response.json()
700
+ response_text = response_data.get('response', '').strip()
701
+ except requests.exceptions.Timeout:
702
+ print(f"[Graph Extraction] Ollama 타임아웃: 요청 시간이 초과되었습니다. (5분)")
703
+ except requests.exceptions.ConnectionError:
704
+ print(f"[Graph Extraction] Ollama 연결 오류: Ollama 서버에 연결할 수 없습니다.")
705
+ except Exception as e:
706
+ print(f"[Graph Extraction] Ollama 오류: {str(e)}")
707
+
708
+ if not response_text:
709
+ print(f"[Graph Extraction] '{episode_title}' Graph Extraction 실패: 응답 없음")
710
+ return False
711
+
712
+ # JSON 추출
713
+ json_match = re.search(r'\{.*\}', response_text, re.DOTALL)
714
+ if not json_match:
715
+ print(f"[Graph Extraction] '{episode_title}' Graph Extraction 실패: JSON 형식이 아닙니다")
716
+ print(f"[Graph Extraction] 응답 일부: {response_text[:500]}")
717
+ return False
718
+
719
+ try:
720
+ graph_data = json.loads(json_match.group(0))
721
+ except json.JSONDecodeError as e:
722
+ print(f"[Graph Extraction] '{episode_title}' JSON 파싱 오류: {str(e)}")
723
+ print(f"[Graph Extraction] 응답 일부: {response_text[:500]}")
724
+ return False
725
+
726
+ # 기존 Graph 데이터 삭제 (같은 회차의 기존 데이터)
727
+ GraphEntity.query.filter_by(file_id=file_id, episode_title=episode_title).delete()
728
+ GraphRelationship.query.filter_by(file_id=file_id, episode_title=episode_title).delete()
729
+ GraphEvent.query.filter_by(file_id=file_id, episode_title=episode_title).delete()
730
+ db.session.commit()
731
+
732
+ # 데이터베이스에 저장
733
+ saved_count = 0
734
+
735
+ # 엔티티 저장
736
+ entities = graph_data.get('entities', {})
737
+
738
+ # 인물 저장
739
+ characters = entities.get('characters', [])
740
+ for char in characters:
741
+ if char.get('name'):
742
+ entity = GraphEntity(
743
+ file_id=file_id,
744
+ episode_title=episode_title,
745
+ entity_name=char.get('name', ''),
746
+ entity_type='character',
747
+ description=char.get('description'),
748
+ role=char.get('role'),
749
+ category=None
750
+ )
751
+ db.session.add(entity)
752
+ saved_count += 1
753
+
754
+ # 장소 저장
755
+ locations = entities.get('locations', [])
756
+ for loc in locations:
757
+ if loc.get('name'):
758
+ entity = GraphEntity(
759
+ file_id=file_id,
760
+ episode_title=episode_title,
761
+ entity_name=loc.get('name', ''),
762
+ entity_type='location',
763
+ description=loc.get('description'),
764
+ role=None,
765
+ category=loc.get('category')
766
+ )
767
+ db.session.add(entity)
768
+ saved_count += 1
769
+
770
+ # 관계 저장
771
+ relationships = graph_data.get('relationships', [])
772
+ for rel in relationships:
773
+ if rel.get('source') and rel.get('target'):
774
+ relationship = GraphRelationship(
775
+ file_id=file_id,
776
+ episode_title=episode_title,
777
+ source=rel.get('source', ''),
778
+ target=rel.get('target', ''),
779
+ relationship_type=rel.get('type', ''),
780
+ description=rel.get('description'),
781
+ event=rel.get('event')
782
+ )
783
+ db.session.add(relationship)
784
+ saved_count += 1
785
+
786
+ # 사건 저장
787
+ events = graph_data.get('events', [])
788
+ for event in events:
789
+ if event.get('name') or event.get('description'):
790
+ participants = event.get('participants', [])
791
+ participants_json = json.dumps(participants, ensure_ascii=False) if participants else None
792
+
793
+ graph_event = GraphEvent(
794
+ file_id=file_id,
795
+ episode_title=episode_title,
796
+ event_name=event.get('name', ''),
797
+ description=event.get('description', ''),
798
+ participants=participants_json,
799
+ location=event.get('location'),
800
+ significance=event.get('significance')
801
+ )
802
+ db.session.add(graph_event)
803
+ saved_count += 1
804
+
805
+ db.session.commit()
806
+ print(f"[Graph Extraction] '{episode_title}' Graph Extraction 완료: {saved_count}개 항목 저장")
807
+ return True
808
+
809
+ except Exception as e:
810
+ print(f"[Graph Extraction] '{episode_title}' Graph Extraction 오류: {str(e)}")
811
+ import traceback
812
+ traceback.print_exc()
813
+ db.session.rollback()
814
+ return False
815
+
816
  def create_chunks_for_file(file_id, content):
817
  """파일 내용을 섹션별로 분할하여 의미 기반 ��크로 저장 (벡터 DB 포함)
818
 
 
913
  db.session.add(episode_analysis)
914
  db.session.commit()
915
  print(f"[회차 분석] 완료: {len(episode_sections)}개 회차 분석 결과를 하나의 텍스트로 저장")
916
+
917
+ # 회차별 Graph Extraction 실행
918
+ print(f"[Graph Extraction] 회차별 Graph Extraction 시작...")
919
+ graph_extraction_success_count = 0
920
+ for section_type, section_title, section_content, section_metadata in episode_sections:
921
+ try:
922
+ print(f"[Graph Extraction] '{section_title}' Graph Extraction 중...")
923
+ success = extract_graph_from_episode(
924
+ episode_content=section_content,
925
+ episode_title=section_title,
926
+ file_id=file_id,
927
+ full_content=content,
928
+ parent_chunk=parent_chunk,
929
+ model_name=model_name
930
+ )
931
+ if success:
932
+ graph_extraction_success_count += 1
933
+ print(f"[Graph Extraction] '{section_title}' Graph Extraction 완료")
934
+ else:
935
+ print(f"[Graph Extraction] '{section_title}' Graph Extraction 실패")
936
+ except Exception as e:
937
+ print(f"[Graph Extraction] '{section_title}' Graph Extraction 중 오류: {str(e)}")
938
+ import traceback
939
+ traceback.print_exc()
940
+ continue
941
+
942
+ print(f"[Graph Extraction] 완료: {graph_extraction_success_count}/{len(episode_sections)}개 회차 Graph Extraction 성공")
943
  else:
944
  print(f"[회차 분석] 경고: 분석 결과가 없습니다.")
945
  else:
 
1311
  print(f"[회차별 분석 조회] 오류: {str(e)}")
1312
  return []
1313
 
1314
+ def get_relevant_graph_data(query, file_ids=None):
1315
+ """질문과 관련된 GraphRAG 데이터 조회 (엔티티, 관계, 사건)
1316
+
1317
+ Args:
1318
+ query: 사용자 질문
1319
+ file_ids: 파일 ID 목록 (None이면 모든 파일)
1320
+
1321
+ Returns:
1322
+ dict: {
1323
+ 'entities': [...],
1324
+ 'relationships': [...],
1325
+ 'events': [...],
1326
+ 'episodes': [...]
1327
+ }
1328
+ """
1329
+ try:
1330
+ if not file_ids:
1331
+ return {
1332
+ 'entities': [],
1333
+ 'relationships': [],
1334
+ 'events': [],
1335
+ 'episodes': []
1336
+ }
1337
+
1338
+ # 질문에서 키워드 추출 (한글 단어, 영문 단어)
1339
+ query_words = set(re.findall(r'[가-힣]+|\w+', query.lower()))
1340
+
1341
+ # 파일 ID 확장 (이어서 업로드된 파일 포함)
1342
+ expanded_file_ids = list(file_ids)
1343
+ for file_id in file_ids:
1344
+ child_files = UploadedFile.query.filter_by(parent_file_id=file_id).all()
1345
+ expanded_file_ids.extend([child.id for child in child_files])
1346
+
1347
+ # 엔티티 검색 (인물, 장소 이름이 질문에 포함된 경우)
1348
+ entities = []
1349
+ if query_words:
1350
+ # 엔티티 이름에 질문의 키워드가 포함된 경우
1351
+ entity_query = GraphEntity.query.filter(
1352
+ GraphEntity.file_id.in_(expanded_file_ids)
1353
+ )
1354
+
1355
+ # 키워드 매칭 (엔티티 이름이나 설명에 포함)
1356
+ matching_entities = []
1357
+ for entity in entity_query.all():
1358
+ entity_name_lower = entity.entity_name.lower()
1359
+ entity_desc_lower = (entity.description or '').lower()
1360
+
1361
+ # 엔티티 이름이나 설명에 질문 키워드가 포함되어 있는지 확인
1362
+ if any(word in entity_name_lower or word in entity_desc_lower for word in query_words if len(word) > 1):
1363
+ matching_entities.append(entity)
1364
+
1365
+ entities = matching_entities[:20] # 최대 20개
1366
+
1367
+ # 관계 검색 (관계의 주체나 대상이 질문에 포함된 경우)
1368
+ relationships = []
1369
+ if query_words:
1370
+ relationship_query = GraphRelationship.query.filter(
1371
+ GraphRelationship.file_id.in_(expanded_file_ids)
1372
+ )
1373
+
1374
+ matching_relationships = []
1375
+ for rel in relationship_query.all():
1376
+ source_lower = rel.source.lower()
1377
+ target_lower = rel.target.lower()
1378
+ rel_type_lower = rel.relationship_type.lower()
1379
+ rel_desc_lower = (rel.description or '').lower()
1380
+
1381
+ # 관계의 주체, 대상, 유형, 설명에 질문 키워드가 포함되어 있는지 확인
1382
+ if any(word in source_lower or word in target_lower or word in rel_type_lower or word in rel_desc_lower
1383
+ for word in query_words if len(word) > 1):
1384
+ matching_relationships.append(rel)
1385
+
1386
+ relationships = matching_relationships[:20] # 최대 20개
1387
+
1388
+ # 사건 검색 (사건 이름이나 설명에 질문 키워드가 포함된 경우)
1389
+ events = []
1390
+ if query_words:
1391
+ event_query = GraphEvent.query.filter(
1392
+ GraphEvent.file_id.in_(expanded_file_ids)
1393
+ )
1394
+
1395
+ matching_events = []
1396
+ for event in event_query.all():
1397
+ event_name_lower = (event.event_name or '').lower()
1398
+ event_desc_lower = (event.description or '').lower()
1399
+ event_location_lower = (event.location or '').lower()
1400
+
1401
+ # 사건 이름, 설명, 장소에 질문 키워드가 포함되어 있는지 확인
1402
+ if any(word in event_name_lower or word in event_desc_lower or word in event_location_lower
1403
+ for word in query_words if len(word) > 1):
1404
+ matching_events.append(event)
1405
+
1406
+ events = matching_events[:20] # 최대 20개
1407
+
1408
+ # 관련 회차 추출
1409
+ episodes = set()
1410
+ for entity in entities:
1411
+ episodes.add(entity.episode_title)
1412
+ for rel in relationships:
1413
+ episodes.add(rel.episode_title)
1414
+ for event in events:
1415
+ episodes.add(event.episode_title)
1416
+
1417
+ print(f"[GraphRAG 검색] 관련 데이터 발견: 엔티티 {len(entities)}개, 관계 {len(relationships)}개, 사건 {len(events)}개, 회차 {len(episodes)}개")
1418
+
1419
+ return {
1420
+ 'entities': [e.to_dict() for e in entities],
1421
+ 'relationships': [r.to_dict() for r in relationships],
1422
+ 'events': [ev.to_dict() for ev in events],
1423
+ 'episodes': sorted(list(episodes))
1424
+ }
1425
+
1426
+ except Exception as e:
1427
+ print(f"[GraphRAG 검색] 오류: {str(e)}")
1428
+ import traceback
1429
+ traceback.print_exc()
1430
+ return {
1431
+ 'entities': [],
1432
+ 'relationships': [],
1433
+ 'events': [],
1434
+ 'episodes': []
1435
+ }
1436
+
1437
  def search_relevant_chunks(query, file_ids=None, model_name=None, top_k=5, min_score=1):
1438
  """
1439
  질문과 관련된 청크 검색 (벡터 검색 + Re-ranking)
 
2110
  episode_analyses = get_episode_analyses_for_files(file_ids)
2111
  print(f"[RAG 검색 1단계] 회차별 분석 조회 완료: {len(episode_analyses)}개 파일")
2112
 
2113
+ # 2단계: GraphRAG 데이터 조회 (엔티티, 관계, 사건)
2114
+ graph_data = None
2115
+ if file_ids:
2116
+ print(f"[RAG 검색 2단계] GraphRAG 데이터 조회 시작...")
2117
+ graph_data = get_relevant_graph_data(
2118
+ query=message,
2119
+ file_ids=file_ids
2120
+ )
2121
+ print(f"[RAG 검색 2단계] GraphRAG 데이터 조회 완료: 엔티티 {len(graph_data['entities'])}개, 관계 {len(graph_data['relationships'])}개, 사건 {len(graph_data['events'])}개")
2122
+
2123
+ # 3단계: 벡터 검색 + 리랭킹으로 Child Chunk 정밀 검색
2124
+ print(f"[RAG 검색 3단계] 벡터 검색 + 리랭킹 시작...")
2125
  relevant_chunks = search_relevant_chunks(
2126
  query=message,
2127
  file_ids=file_ids if file_ids else None,
 
2129
  top_k=5, # 리랭킹 후 상위 5개만 선택
2130
  min_score=0.5 # 최소 점수 임계값
2131
  )
2132
+ print(f"[RAG 검색 3단계] 벡터 검색 + 리랭킹 완료: {len(relevant_chunks)}개 청크 (상위 5개)")
2133
 
2134
  # 컨텍스트 구성
2135
  context_parts = []
2136
 
2137
+ # GraphRAG 데이터 추가 (엔티티, 관계, 사건 정보)
2138
+ if graph_data and (graph_data['entities'] or graph_data['relationships'] or graph_data['events']):
2139
+ graph_context_parts = []
2140
+
2141
+ # 엔티티 정보
2142
+ if graph_data['entities']:
2143
+ entity_sections = {}
2144
+ for entity in graph_data['entities']:
2145
+ episode = entity.get('episode_title', '기타')
2146
+ if episode not in entity_sections:
2147
+ entity_sections[episode] = {'characters': [], 'locations': []}
2148
+
2149
+ if entity.get('entity_type') == 'character':
2150
+ entity_sections[episode]['characters'].append(entity)
2151
+ elif entity.get('entity_type') == 'location':
2152
+ entity_sections[episode]['locations'].append(entity)
2153
+
2154
+ entity_text = "다음은 질문과 관련된 등장인물 및 장소 정보입니다:\n\n"
2155
+ for episode, entities in entity_sections.items():
2156
+ entity_text += f"=== {episode} ===\n"
2157
+
2158
+ if entities['characters']:
2159
+ entity_text += "인물:\n"
2160
+ for char in entities['characters']:
2161
+ entity_text += f"- {char.get('entity_name', '')}"
2162
+ if char.get('role'):
2163
+ entity_text += f" (역할: {char.get('role')})"
2164
+ if char.get('description'):
2165
+ entity_text += f": {char.get('description')}"
2166
+ entity_text += "\n"
2167
+
2168
+ if entities['locations']:
2169
+ entity_text += "장소:\n"
2170
+ for loc in entities['locations']:
2171
+ entity_text += f"- {loc.get('entity_name', '')}"
2172
+ if loc.get('category'):
2173
+ entity_text += f" (유형: {loc.get('category')})"
2174
+ if loc.get('description'):
2175
+ entity_text += f": {loc.get('description')}"
2176
+ entity_text += "\n"
2177
+
2178
+ entity_text += "\n"
2179
+
2180
+ graph_context_parts.append(entity_text)
2181
+
2182
+ # 관계 정보
2183
+ if graph_data['relationships']:
2184
+ rel_sections = {}
2185
+ for rel in graph_data['relationships']:
2186
+ episode = rel.get('episode_title', '기타')
2187
+ if episode not in rel_sections:
2188
+ rel_sections[episode] = []
2189
+ rel_sections[episode].append(rel)
2190
+
2191
+ rel_text = "다음은 질문과 관련된 인물/장소 간의 관계 정보입니다:\n\n"
2192
+ for episode, rels in rel_sections.items():
2193
+ rel_text += f"=== {episode} ===\n"
2194
+ for rel in rels:
2195
+ rel_text += f"- {rel.get('source', '')} → {rel.get('target', '')}"
2196
+ if rel.get('relationship_type'):
2197
+ rel_text += f" ({rel.get('relationship_type')})"
2198
+ if rel.get('description'):
2199
+ rel_text += f": {rel.get('description')}"
2200
+ if rel.get('event'):
2201
+ rel_text += f" [관련 사건: {rel.get('event')}]"
2202
+ rel_text += "\n"
2203
+ rel_text += "\n"
2204
+
2205
+ graph_context_parts.append(rel_text)
2206
+
2207
+ # 사건 정보
2208
+ if graph_data['events']:
2209
+ event_sections = {}
2210
+ for event in graph_data['events']:
2211
+ episode = event.get('episode_title', '기타')
2212
+ if episode not in event_sections:
2213
+ event_sections[episode] = []
2214
+ event_sections[episode].append(event)
2215
+
2216
+ event_text = "다음은 질문과 관련된 주요 사건 정보입니다:\n\n"
2217
+ for episode, events in event_sections.items():
2218
+ event_text += f"=== {episode} ===\n"
2219
+ for event in events:
2220
+ if event.get('event_name'):
2221
+ event_text += f"- {event.get('event_name')}\n"
2222
+ if event.get('description'):
2223
+ event_text += f" 설명: {event.get('description')}\n"
2224
+ if event.get('participants') and len(event.get('participants', [])) > 0:
2225
+ event_text += f" 관련 인물: {', '.join(event.get('participants', []))}\n"
2226
+ if event.get('location'):
2227
+ event_text += f" 장소: {event.get('location')}\n"
2228
+ if event.get('significance'):
2229
+ event_text += f" 중요도: {event.get('significance')}\n"
2230
+ event_text += "\n"
2231
+
2232
+ graph_context_parts.append(event_text)
2233
+
2234
+ if graph_context_parts:
2235
+ graph_context = "\n\n".join(graph_context_parts)
2236
+ context_parts.append(f"다음은 질문과 관련된 GraphRAG 데이터입니다 (엔티티, 관계, 사건 정보):\n\n{graph_context}")
2237
+ print(f"[RAG 검색] GraphRAG 컨텍스트 추가: {len(graph_context)}자")
2238
+
2239
  # 회차별 분석 정보 추가 (회차별 요약 참조용)
2240
  if episode_analyses:
2241
  episode_context_sections = []
 
2288
  if context_parts:
2289
  full_context = "\n\n" + "\n\n---\n\n".join(context_parts) + "\n\n"
2290
 
2291
+ # 회차별 분석, GraphRAG, Child Chunk 모두 있는 경우
2292
+ has_graph = graph_data and (graph_data['entities'] or graph_data['relationships'] or graph_data['events'])
2293
+
2294
+ if episode_analyses and has_graph and relevant_chunks:
2295
+ context = f"""다음은 질문에 답하기 위한 웹소설 정보입니다:
2296
+
2297
+ {full_context}
2298
+
2299
+ 위 정보를 참고하여 답변해주세요:
2300
+ - 먼저 GraphRAG 데이터(엔티티, 관계, 사건)를 확인하여 등장인물, 장소, 인물 간의 관계, 주요 사건을 파악하세요.
2301
+ - 그 다음 회차별 분석 내용을 이해하여 각 회차의 주요 스토리, 등장 인물, 인물 관계 변화를 파악하세요.
2302
+ - 마지막으로 구체적인 내용(Child Chunk)을 통해 질문에 대한 정확한 답변을 제공하세요.
2303
+ - 웹소설의 맥락과 스토리를 고려하여 일관성 있는 답변을 작성하세요.
2304
+
2305
+ 중요: 질문에 답변할 때는 반드시 제공된 [소설 본문] 내의 내용을 근거로 해야 합니다.
2306
+ 답변의 각 문장 끝에는 참고한 본문의 문장을 [근거: "문장 내용..."] 형식으로 반드시 붙이세요.
2307
+ 근거를 찾을 수 없다면 "내용을 찾을 수 없습니다"라고 답하고 지어내지 마세요.
2308
+
2309
+ 질문:
2310
+ """
2311
+ elif episode_analyses and relevant_chunks:
2312
  context = f"""다음은 질문에 답하기 위한 웹소설 정보입니다:
2313
 
2314
  {full_context}
 
2322
  답변의 각 문장 끝에는 참고한 본문의 문장을 [근거: "문장 내용..."] 형식으로 반드시 붙이세요.
2323
  근거를 찾을 수 없다면 "내용을 찾을 수 없습니다"라고 답하고 지어내지 마세요.
2324
 
2325
+ 질문:
2326
+ """
2327
+ elif has_graph and relevant_chunks:
2328
+ context = f"""다음은 질문에 답하기 위한 웹소설 정보입니다:
2329
+
2330
+ {full_context}
2331
+
2332
+ 위 정보를 참고하여 답변해주세요:
2333
+ - 먼저 GraphRAG 데이터(엔티티, 관계, 사건)를 확인하여 등장인물, 장소, 인물 간의 관계, 주요 사건을 파악하세요.
2334
+ - 그 다음 구체적인 내용(Child Chunk)을 통해 질문에 대한 정확한 답변을 제공하세요.
2335
+ - 웹소설의 맥락과 스토리를 고려하여 일관성 있는 답변을 작성하세요.
2336
+
2337
+ 중요: 질문에 답변할 때는 반드시 제공된 [소설 본문] 내의 내용을 근거로 해야 합니다.
2338
+ 답변의 각 문장 끝에는 참고한 본문의 문장을 [근거: "문장 내용..."] 형식으로 반드시 붙이세요.
2339
+ 근거를 찾을 수 없다면 "내용을 찾을 수 없습니다"라고 답하고 지어내지 마세요.
2340
+
2341
+ 질문:
2342
+ """
2343
+ elif episode_analyses and has_graph:
2344
+ # 회차별 분석과 GraphRAG만 있는 경우
2345
+ context = f"""다음은 웹소설의 회차별 상세 분석 및 GraphRAG 데이터입니다:
2346
+
2347
+ {full_context}
2348
+
2349
+ 위 정보를 참고하여 질문에 답변해주세요:
2350
+ - GraphRAG 데이터(엔티티, 관계, 사건)를 확인하여 등장인물, 장소, 인물 간의 관계, 주요 사건을 파악하세요.
2351
+ - 회차별 분석 내용을 이해하여 각 회차의 주요 스토리, 등장 인물, 인물 관계 변화를 고려하여 답변하세요.
2352
+
2353
+ 중요: 질문에 답변할 때는 반드시 제공된 [소설 본문] 내의 내용을 근거로 해야 합니다.
2354
+ 답변의 각 문장 끝에는 참고한 본문의 문장을 [근거: "문장 내용..."] 형식으로 반드시 붙이세요.
2355
+ 근거를 찾을 수 없다면 "내용을 찾을 수 없습니다"라고 답하고 지어내지 마세요.
2356
+
2357
  질문:
2358
  """
2359
  elif episode_analyses:
 
2368
  답변의 각 문장 끝에는 참고한 본문의 문장을 [근거: "문장 내용..."] 형식으로 반드시 붙이세요.
2369
  근거를 찾을 수 없다면 "내용을 찾을 수 없습니다"라고 답하고 지어내지 마세요.
2370
 
2371
+ 질문:
2372
+ """
2373
+ elif has_graph:
2374
+ # GraphRAG만 있는 경우
2375
+ context = f"""다음은 질문과 관련된 GraphRAG 데이터입니다 (엔티티, 관계, 사건 정보):
2376
+
2377
+ {full_context}
2378
+
2379
+ 위 정보를 참고하여 질문에 답변해주세요:
2380
+ - GraphRAG 데이터를 확인하여 등장인물, 장소, 인물 간의 관계, 주요 사건을 파악하세요.
2381
+ - 웹소설의 맥락과 스토리를 고려하여 일관성 있는 답변을 작성하세요.
2382
+
2383
+ 중요: 질문에 답변할 때는 반드시 제공된 [소설 본문] 내의 내용을 근거로 해야 합니다.
2384
+ 답변의 각 문장 끝에는 참고한 본문의 문장을 [근거: "문장 내용..."] 형식으로 반드시 붙이세요.
2385
+ 근거를 찾을 수 없다면 "내용을 찾을 수 없습니다"라고 답하고 지어내지 마세요.
2386
+
2387
  질문:
2388
  """
2389
  else:
 
2402
  """
2403
 
2404
  context += message
2405
+ graph_info = f", GraphRAG: {len(graph_data['entities']) if graph_data else 0}개 엔티티, {len(graph_data['relationships']) if graph_data else 0} 관계, {len(graph_data['events']) if graph_data else 0}개 사건" if graph_data else ""
2406
+ print(f"[RAG 검색] 최종 컨텍스트 생성 완료 (회차별 분석: {len(episode_analyses)}개{graph_info}, Child Chunk: {len(relevant_chunks)}개, 총 {len(context)}자)")
2407
  else:
2408
  # RAG 검색 결과가 없으면 기존 방식 사용
2409
  print(f"[RAG 검색] 관련 청크를 찾지 못했습니다. 전체 파일 내용 사용")
 
3143
  except Exception as e:
3144
  return jsonify({'error': f'요약 내용 조회 중 오류가 발생했습니다: {str(e)}'}), 500
3145
 
3146
+ @main_bp.route('/api/files/<int:file_id>/graph', methods=['GET'])
3147
+ @login_required
3148
+ def get_file_graph(file_id):
3149
+ """파일의 GraphRAG 데이터 조회 (엔티티, 관계, 사건)"""
3150
+ try:
3151
+ print(f"[GraphRAG 조회] 파일 ID {file_id} GraphRAG 데이터 조회 요청 (사용자: {current_user.username})")
3152
+
3153
+ file = UploadedFile.query.get(file_id)
3154
+
3155
+ if not file:
3156
+ print(f"[GraphRAG 조회] 파일을 찾을 수 없음: 파일 ID {file_id}")
3157
+ return jsonify({'error': f'파일을 찾을 수 없습니다. (파일 ID: {file_id})'}), 404
3158
+
3159
+ # 엔티티 조회 (회차별로 그룹화)
3160
+ entities = GraphEntity.query.filter_by(file_id=file_id).all()
3161
+ entities_by_episode = {}
3162
+ for entity in entities:
3163
+ episode = entity.episode_title
3164
+ if episode not in entities_by_episode:
3165
+ entities_by_episode[episode] = {'characters': [], 'locations': []}
3166
+
3167
+ if entity.entity_type == 'character':
3168
+ entities_by_episode[episode]['characters'].append(entity.to_dict())
3169
+ elif entity.entity_type == 'location':
3170
+ entities_by_episode[episode]['locations'].append(entity.to_dict())
3171
+
3172
+ # 관계 조회 (회차별로 그룹화)
3173
+ relationships = GraphRelationship.query.filter_by(file_id=file_id).all()
3174
+ relationships_by_episode = {}
3175
+ for rel in relationships:
3176
+ episode = rel.episode_title
3177
+ if episode not in relationships_by_episode:
3178
+ relationships_by_episode[episode] = []
3179
+ relationships_by_episode[episode].append(rel.to_dict())
3180
+
3181
+ # 사건 조회 (회차별로 그룹화)
3182
+ events = GraphEvent.query.filter_by(file_id=file_id).all()
3183
+ events_by_episode = {}
3184
+ for event in events:
3185
+ episode = event.episode_title
3186
+ if episode not in events_by_episode:
3187
+ events_by_episode[episode] = []
3188
+ events_by_episode[episode].append(event.to_dict())
3189
+
3190
+ # 통계 정보
3191
+ total_entities = len(entities)
3192
+ total_relationships = len(relationships)
3193
+ total_events = len(events)
3194
+ episodes = list(set([e.episode_title for e in entities] +
3195
+ [r.episode_title for r in relationships] +
3196
+ [ev.episode_title for ev in events]))
3197
+
3198
+ return jsonify({
3199
+ 'file_id': file_id,
3200
+ 'filename': file.original_filename,
3201
+ 'statistics': {
3202
+ 'total_entities': total_entities,
3203
+ 'total_relationships': total_relationships,
3204
+ 'total_events': total_events,
3205
+ 'episodes_count': len(episodes)
3206
+ },
3207
+ 'entities_by_episode': entities_by_episode,
3208
+ 'relationships_by_episode': relationships_by_episode,
3209
+ 'events_by_episode': events_by_episode,
3210
+ 'episodes': sorted(episodes)
3211
+ }), 200
3212
+
3213
+ except Exception as e:
3214
+ print(f"[GraphRAG 조회] 오류: {str(e)}")
3215
+ import traceback
3216
+ traceback.print_exc()
3217
+ return jsonify({'error': f'GraphRAG 데이터 조회 중 오류가 발생했습니다: {str(e)}'}), 500
3218
+
3219
  @main_bp.route('/api/files/<int:file_id>/parent-chunk', methods=['GET'])
3220
  @login_required
3221
  def get_file_parent_chunk(file_id):
templates/admin_files.html CHANGED
@@ -413,6 +413,61 @@
413
  </div>
414
  </div>
415
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
416
  <script>
417
  function escapeHtml(text) {
418
  const div = document.createElement('div');
@@ -515,7 +570,9 @@
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>
521
  `;
@@ -709,6 +766,518 @@
709
  document.getElementById('chunkContentModal').classList.remove('active');
710
  }
711
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
712
  // 모달 외부 클릭 시 닫기
713
  document.getElementById('chunksModal').addEventListener('click', function(e) {
714
  if (e.target === this) {
@@ -728,6 +1297,18 @@
728
  }
729
  });
730
 
 
 
 
 
 
 
 
 
 
 
 
 
731
  // 페이지 로드 시 초기화
732
  window.addEventListener('load', () => {
733
  loadModelFilter();
 
413
  </div>
414
  </div>
415
 
416
+ <!-- GraphRAG 모달 -->
417
+ <div id="graphRAGModal" class="modal">
418
+ <div class="modal-content" style="max-width: 1400px;">
419
+ <div class="modal-header">
420
+ <div class="modal-title" id="graphRAGModalTitle">GraphRAG 데이터</div>
421
+ <button class="modal-close" onclick="closeGraphRAGModal()">&times;</button>
422
+ </div>
423
+ <div id="graphRAGContent" style="max-height: 80vh; overflow-y: auto;">
424
+ <div style="text-align: center; padding: 24px; color: #5f6368;">
425
+ GraphRAG 데이터를 불러오는 중...
426
+ </div>
427
+ </div>
428
+ </div>
429
+ </div>
430
+
431
+ <!-- GraphRAG 그래프 시각화 모달 -->
432
+ <div id="graphRAGVisualizationModal" class="modal">
433
+ <div class="modal-content" style="max-width: 1600px; width: 95%; height: 90vh;">
434
+ <div class="modal-header">
435
+ <div class="modal-title" id="graphRAGVisualizationModalTitle">GraphRAG 그래프 시각화</div>
436
+ <button class="modal-close" onclick="closeGraphRAGVisualizationModal()">&times;</button>
437
+ </div>
438
+ <div style="padding: 16px; border-bottom: 1px solid #dadce0; background: #f8f9fa; display: flex; gap: 12px; align-items: center; flex-wrap: wrap;">
439
+ <label style="font-size: 14px; font-weight: 500;">회차 필터:</label>
440
+ <select id="episodeFilter" onchange="updateGraphVisualization()" style="padding: 6px 12px; border: 1px solid #dadce0; border-radius: 6px; font-size: 14px; min-width: 200px;">
441
+ <option value="all">전체 회차</option>
442
+ </select>
443
+ <label style="font-size: 14px; font-weight: 500; margin-left: 16px;">노드 타입:</label>
444
+ <label style="font-size: 13px; margin-left: 8px;">
445
+ <input type="checkbox" id="showCharacters" checked onchange="updateGraphVisualization()" style="margin-right: 4px;">
446
+ 인물
447
+ </label>
448
+ <label style="font-size: 13px; margin-left: 8px;">
449
+ <input type="checkbox" id="showLocations" checked onchange="updateGraphVisualization()" style="margin-right: 4px;">
450
+ 장소
451
+ </label>
452
+ <label style="font-size: 13px; margin-left: 8px;">
453
+ <input type="checkbox" id="showEvents" checked onchange="updateGraphVisualization()" style="margin-right: 4px;">
454
+ 사건
455
+ </label>
456
+ <button onclick="resetGraphView()" style="padding: 6px 16px; background: #1a73e8; color: white; border: none; border-radius: 6px; cursor: pointer; font-size: 13px; margin-left: auto;">
457
+ 뷰 리셋
458
+ </button>
459
+ </div>
460
+ <div id="graphRAGVisualizationContent" style="height: calc(90vh - 120px); position: relative; background: #ffffff;">
461
+ <div style="text-align: center; padding: 24px; color: #5f6368; position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);">
462
+ 그래프를 불러오는 중...
463
+ </div>
464
+ </div>
465
+ </div>
466
+ </div>
467
+
468
+ <!-- vis-network 라이브러리 -->
469
+ <script type="text/javascript" src="https://unpkg.com/vis-network@latest/standalone/umd/vis-network.min.js"></script>
470
+
471
  <script>
472
  function escapeHtml(text) {
473
  const div = document.createElement('div');
 
570
  <td>
571
  <div class="file-actions">
572
  <button class="btn btn-secondary" onclick="viewSummary(${file.id}, '${escapeHtml(file.original_filename)}')" style="padding: 4px 8px; font-size: 12px; margin-right: 4px;">요약 내용 보기</button>
573
+ <button class="btn btn-info" onclick="viewChunks(${file.id}, '${escapeHtml(file.original_filename)}')" style="padding: 4px 8px; font-size: 12px; margin-right: 4px;">청크 보기</button>
574
+ <button class="btn btn-primary" onclick="viewGraphRAG(${file.id}, '${escapeHtml(file.original_filename)}')" style="padding: 4px 8px; font-size: 12px; margin-right: 4px;">GraphRAG 보기</button>
575
+ <button class="btn btn-success" onclick="viewGraphRAGVisualization(${file.id}, '${escapeHtml(file.original_filename)}')" style="padding: 4px 8px; font-size: 12px;">그래프 시각화</button>
576
  </div>
577
  </td>
578
  `;
 
766
  document.getElementById('chunkContentModal').classList.remove('active');
767
  }
768
 
769
+ async function viewGraphRAG(fileId, fileName) {
770
+ const modal = document.getElementById('graphRAGModal');
771
+ const title = document.getElementById('graphRAGModalTitle');
772
+ const content = document.getElementById('graphRAGContent');
773
+
774
+ title.textContent = `GraphRAG 데이터 - ${fileName}`;
775
+ content.innerHTML = '<div style="text-align: center; padding: 24px; color: #5f6368;">GraphRAG 데이터를 불러오는 중...</div>';
776
+ modal.classList.add('active');
777
+
778
+ try {
779
+ const response = await fetch(`/api/files/${fileId}/graph`, {
780
+ credentials: 'include'
781
+ });
782
+ if (!response.ok) throw new Error('GraphRAG 데이터를 불러올 수 없습니다.');
783
+
784
+ const data = await response.json();
785
+
786
+ let contentHtml = '';
787
+
788
+ // 통계 정보
789
+ if (data.statistics) {
790
+ contentHtml += '<div style="margin-bottom: 32px; padding: 16px; background: #e8f0fe; border-radius: 6px;">';
791
+ contentHtml += '<h3 style="font-size: 18px; font-weight: 600; margin-bottom: 16px; color: #1a73e8;">통계 정보</h3>';
792
+ contentHtml += '<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 16px;">';
793
+ contentHtml += `<div style="padding: 12px; background: white; border-radius: 6px;"><strong>엔티티:</strong> ${data.statistics.total_entities}개</div>`;
794
+ contentHtml += `<div style="padding: 12px; background: white; border-radius: 6px;"><strong>관계:</strong> ${data.statistics.total_relationships}개</div>`;
795
+ contentHtml += `<div style="padding: 12px; background: white; border-radius: 6px;"><strong>사건:</strong> ${data.statistics.total_events}개</div>`;
796
+ contentHtml += `<div style="padding: 12px; background: white; border-radius: 6px;"><strong>회차 수:</strong> ${data.statistics.episodes_count}개</div>`;
797
+ contentHtml += '</div>';
798
+ contentHtml += '</div>';
799
+ }
800
+
801
+ // 회차별 데이터 표시
802
+ const episodes = data.episodes || [];
803
+
804
+ if (episodes.length === 0) {
805
+ contentHtml += '<div style="text-align: center; padding: 24px; color: #5f6368;">GraphRAG 데이터가 없습니다.</div>';
806
+ } else {
807
+ episodes.forEach(episode => {
808
+ contentHtml += `<div style="margin-bottom: 32px; padding: 20px; background: #f8f9fa; border-radius: 8px; border-left: 4px solid #1a73e8;">`;
809
+ contentHtml += `<h3 style="font-size: 20px; font-weight: 600; margin-bottom: 20px; color: #1a73e8;">${escapeHtml(episode)}</h3>`;
810
+
811
+ // 엔티티 (인물)
812
+ const characters = data.entities_by_episode[episode]?.characters || [];
813
+ if (characters.length > 0) {
814
+ contentHtml += '<div style="margin-bottom: 20px;">';
815
+ contentHtml += '<h4 style="font-size: 16px; font-weight: 600; margin-bottom: 12px; color: #1967d2;">인물</h4>';
816
+ contentHtml += '<div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 12px;">';
817
+ characters.forEach(char => {
818
+ contentHtml += '<div style="padding: 12px; background: white; border-radius: 6px; border: 1px solid #e8eaed;">';
819
+ contentHtml += `<div style="font-weight: 600; margin-bottom: 8px; color: #1a73e8;">${escapeHtml(char.entity_name)}</div>`;
820
+ if (char.role) {
821
+ contentHtml += `<div style="font-size: 13px; color: #5f6368; margin-bottom: 4px;"><strong>역할:</strong> ${escapeHtml(char.role)}</div>`;
822
+ }
823
+ if (char.description) {
824
+ contentHtml += `<div style="font-size: 13px; color: #5f6368;">${escapeHtml(char.description)}</div>`;
825
+ }
826
+ contentHtml += '</div>';
827
+ });
828
+ contentHtml += '</div>';
829
+ contentHtml += '</div>';
830
+ }
831
+
832
+ // 엔티티 (장소)
833
+ const locations = data.entities_by_episode[episode]?.locations || [];
834
+ if (locations.length > 0) {
835
+ contentHtml += '<div style="margin-bottom: 20px;">';
836
+ contentHtml += '<h4 style="font-size: 16px; font-weight: 600; margin-bottom: 12px; color: #1967d2;">장소</h4>';
837
+ contentHtml += '<div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 12px;">';
838
+ locations.forEach(loc => {
839
+ contentHtml += '<div style="padding: 12px; background: white; border-radius: 6px; border: 1px solid #e8eaed;">';
840
+ contentHtml += `<div style="font-weight: 600; margin-bottom: 8px; color: #1a73e8;">${escapeHtml(loc.entity_name)}</div>`;
841
+ if (loc.category) {
842
+ contentHtml += `<div style="font-size: 13px; color: #5f6368; margin-bottom: 4px;"><strong>유형:</strong> ${escapeHtml(loc.category)}</div>`;
843
+ }
844
+ if (loc.description) {
845
+ contentHtml += `<div style="font-size: 13px; color: #5f6368;">${escapeHtml(loc.description)}</div>`;
846
+ }
847
+ contentHtml += '</div>';
848
+ });
849
+ contentHtml += '</div>';
850
+ contentHtml += '</div>';
851
+ }
852
+
853
+ // 관계
854
+ const relationships = data.relationships_by_episode[episode] || [];
855
+ if (relationships.length > 0) {
856
+ contentHtml += '<div style="margin-bottom: 20px;">';
857
+ contentHtml += '<h4 style="font-size: 16px; font-weight: 600; margin-bottom: 12px; color: #1967d2;">관계</h4>';
858
+ contentHtml += '<div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(400px, 1fr)); gap: 12px;">';
859
+ relationships.forEach(rel => {
860
+ contentHtml += '<div style="padding: 12px; background: white; border-radius: 6px; border: 1px solid #e8eaed;">';
861
+ contentHtml += `<div style="margin-bottom: 8px;">`;
862
+ contentHtml += `<span style="font-weight: 600; color: #1a73e8;">${escapeHtml(rel.source)}</span>`;
863
+ contentHtml += `<span style="margin: 0 8px; color: #5f6368;">→</span>`;
864
+ contentHtml += `<span style="font-weight: 600; color: #1a73e8;">${escapeHtml(rel.target)}</span>`;
865
+ contentHtml += '</div>';
866
+ if (rel.relationship_type) {
867
+ contentHtml += `<div style="font-size: 13px; color: #5f6368; margin-bottom: 4px;"><strong>관계 유형:</strong> ${escapeHtml(rel.relationship_type)}</div>`;
868
+ }
869
+ if (rel.description) {
870
+ contentHtml += `<div style="font-size: 13px; color: #5f6368; margin-bottom: 4px;">${escapeHtml(rel.description)}</div>`;
871
+ }
872
+ if (rel.event) {
873
+ contentHtml += `<div style="font-size: 12px; color: #856404; padding: 8px; background: #fff3cd; border-radius: 4px; margin-top: 8px;"><strong>관련 사건:</strong> ${escapeHtml(rel.event)}</div>`;
874
+ }
875
+ contentHtml += '</div>';
876
+ });
877
+ contentHtml += '</div>';
878
+ contentHtml += '</div>';
879
+ }
880
+
881
+ // 사건
882
+ const events = data.events_by_episode[episode] || [];
883
+ if (events.length > 0) {
884
+ contentHtml += '<div style="margin-bottom: 20px;">';
885
+ contentHtml += '<h4 style="font-size: 16px; font-weight: 600; margin-bottom: 12px; color: #1967d2;">사건</h4>';
886
+ contentHtml += '<div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(400px, 1fr)); gap: 12px;">';
887
+ events.forEach(event => {
888
+ contentHtml += '<div style="padding: 12px; background: white; border-radius: 6px; border: 1px solid #e8eaed;">';
889
+ if (event.event_name) {
890
+ contentHtml += `<div style="font-weight: 600; margin-bottom: 8px; color: #1a73e8;">${escapeHtml(event.event_name)}</div>`;
891
+ }
892
+ if (event.description) {
893
+ contentHtml += `<div style="font-size: 13px; color: #5f6368; margin-bottom: 8px; line-height: 1.6;">${escapeHtml(event.description)}</div>`;
894
+ }
895
+ if (event.participants && event.participants.length > 0) {
896
+ contentHtml += `<div style="font-size: 13px; color: #5f6368; margin-bottom: 4px;"><strong>관련 인물:</strong> ${escapeHtml(event.participants.join(', '))}</div>`;
897
+ }
898
+ if (event.location) {
899
+ contentHtml += `<div style="font-size: 13px; color: #5f6368; margin-bottom: 4px;"><strong>장소:</strong> ${escapeHtml(event.location)}</div>`;
900
+ }
901
+ if (event.significance) {
902
+ contentHtml += `<div style="font-size: 12px; color: #137333; padding: 6px; background: #e8f5e9; border-radius: 4px; margin-top: 8px; display: inline-block;"><strong>중요도:</strong> ${escapeHtml(event.significance)}</div>`;
903
+ }
904
+ contentHtml += '</div>';
905
+ });
906
+ contentHtml += '</div>';
907
+ contentHtml += '</div>';
908
+ }
909
+
910
+ contentHtml += '</div>';
911
+ });
912
+ }
913
+
914
+ content.innerHTML = contentHtml;
915
+ } catch (error) {
916
+ console.error('GraphRAG 데이터 로드 오류:', error);
917
+ content.innerHTML = '<div style="text-align: center; padding: 24px; color: #c5221f;">GraphRAG 데이터를 불러오는 중 오류가 발생했습니다.</div>';
918
+ showAlert('GraphRAG 데이터를 불러오는 중 오류가 발생했습니다.', 'error');
919
+ }
920
+ }
921
+
922
+ function closeGraphRAGModal() {
923
+ document.getElementById('graphRAGModal').classList.remove('active');
924
+ }
925
+
926
+ // GraphRAG 그래프 시각화 관련 변수
927
+ let graphData = null;
928
+ let graphNetwork = null;
929
+ let allGraphData = null;
930
+
931
+ async function viewGraphRAGVisualization(fileId, fileName) {
932
+ const modal = document.getElementById('graphRAGVisualizationModal');
933
+ const title = document.getElementById('graphRAGVisualizationModalTitle');
934
+ const content = document.getElementById('graphRAGVisualizationContent');
935
+
936
+ title.textContent = `GraphRAG 그래프 시각화 - ${fileName}`;
937
+ content.innerHTML = '<div style="text-align: center; padding: 24px; color: #5f6368; position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);">그래프를 불러오는 중...</div>';
938
+ modal.classList.add('active');
939
+
940
+ // 기존 네트워크 제거
941
+ if (graphNetwork) {
942
+ graphNetwork.destroy();
943
+ graphNetwork = null;
944
+ }
945
+
946
+ try {
947
+ const response = await fetch(`/api/files/${fileId}/graph`, {
948
+ credentials: 'include'
949
+ });
950
+ if (!response.ok) throw new Error('GraphRAG 데이터를 불러올 수 없습니다.');
951
+
952
+ const data = await response.json();
953
+ allGraphData = data;
954
+
955
+ // 회차 필터 옵션 생성
956
+ const episodeFilter = document.getElementById('episodeFilter');
957
+ episodeFilter.innerHTML = '<option value="all">전체 회차</option>';
958
+ if (data.episodes && data.episodes.length > 0) {
959
+ data.episodes.forEach(episode => {
960
+ const option = document.createElement('option');
961
+ option.value = episode;
962
+ option.textContent = episode;
963
+ episodeFilter.appendChild(option);
964
+ });
965
+ }
966
+
967
+ // 그래프 생성
968
+ createGraphVisualization(data);
969
+ } catch (error) {
970
+ console.error('GraphRAG 그래프 로드 오류:', error);
971
+ content.innerHTML = '<div style="text-align: center; padding: 24px; color: #c5221f; position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);">그래프를 불러오는 중 오류가 발생했습니다.</div>';
972
+ }
973
+ }
974
+
975
+ function createGraphVisualization(data, episodeFilter = 'all') {
976
+ const content = document.getElementById('graphRAGVisualizationContent');
977
+ content.innerHTML = ''; // 기존 내용 제거
978
+
979
+ // 노드와 엣지 데이터 생성
980
+ const nodes = new vis.DataSet([]);
981
+ const edges = new vis.DataSet([]);
982
+
983
+ const nodeMap = new Map(); // 노드 ID 매핑
984
+ let nodeIdCounter = 1;
985
+
986
+ const showCharacters = document.getElementById('showCharacters').checked;
987
+ const showLocations = document.getElementById('showLocations').checked;
988
+ const showEvents = document.getElementById('showEvents').checked;
989
+
990
+ // 필터링할 회차 목록
991
+ const episodes = episodeFilter === 'all'
992
+ ? (data.episodes || [])
993
+ : [episodeFilter];
994
+
995
+ // 엔티티 추가 (인물, 장소)
996
+ episodes.forEach(episode => {
997
+ const entities = data.entities_by_episode?.[episode] || {};
998
+
999
+ // 인물 추가
1000
+ if (showCharacters && entities.characters) {
1001
+ entities.characters.forEach(char => {
1002
+ const nodeId = `char_${char.entity_name}`;
1003
+ if (!nodeMap.has(nodeId)) {
1004
+ const id = nodeIdCounter++;
1005
+ nodeMap.set(nodeId, id);
1006
+ nodes.add({
1007
+ id: id,
1008
+ label: char.entity_name,
1009
+ title: `인물: ${char.entity_name}\n역할: ${char.role || '없음'}\n설명: ${char.description || '없음'}`,
1010
+ color: {
1011
+ background: '#4285f4',
1012
+ border: '#1967d2',
1013
+ highlight: { background: '#1a73e8', border: '#1557b0' }
1014
+ },
1015
+ shape: 'ellipse',
1016
+ font: { size: 14, face: 'Inter' },
1017
+ size: 20
1018
+ });
1019
+ }
1020
+ });
1021
+ }
1022
+
1023
+ // 장소 추가
1024
+ if (showLocations && entities.locations) {
1025
+ entities.locations.forEach(loc => {
1026
+ const nodeId = `loc_${loc.entity_name}`;
1027
+ if (!nodeMap.has(nodeId)) {
1028
+ const id = nodeIdCounter++;
1029
+ nodeMap.set(nodeId, id);
1030
+ nodes.add({
1031
+ id: id,
1032
+ label: loc.entity_name,
1033
+ title: `장소: ${loc.entity_name}\n유형: ${loc.category || '없음'}\n설명: ${loc.description || '없음'}`,
1034
+ color: {
1035
+ background: '#34a853',
1036
+ border: '#137333',
1037
+ highlight: { background: '#2e7d32', border: '#1b5e20' }
1038
+ },
1039
+ shape: 'box',
1040
+ font: { size: 14, face: 'Inter' },
1041
+ size: 20
1042
+ });
1043
+ }
1044
+ });
1045
+ }
1046
+ });
1047
+
1048
+ // 사건 추가
1049
+ if (showEvents) {
1050
+ episodes.forEach(episode => {
1051
+ const events = data.events_by_episode?.[episode] || [];
1052
+ events.forEach(event => {
1053
+ const eventName = event.event_name || `사건_${episode}_${events.indexOf(event)}`;
1054
+ const nodeId = `event_${eventName}`;
1055
+ if (!nodeMap.has(nodeId)) {
1056
+ const id = nodeIdCounter++;
1057
+ nodeMap.set(nodeId, id);
1058
+ nodes.add({
1059
+ id: id,
1060
+ label: eventName,
1061
+ title: `사건: ${eventName}\n설명: ${event.description || '없음'}\n관련 인물: ${event.participants ? event.participants.join(', ') : '없음'}\n장소: ${event.location || '없음'}\n중요도: ${event.significance || '없음'}`,
1062
+ color: {
1063
+ background: '#ff9800',
1064
+ border: '#f57c00',
1065
+ highlight: { background: '#fb8c00', border: '#e65100' }
1066
+ },
1067
+ shape: 'diamond',
1068
+ font: { size: 13, face: 'Inter' },
1069
+ size: 25
1070
+ });
1071
+
1072
+ // 사건과 관련 인물 연결
1073
+ if (event.participants && Array.isArray(event.participants)) {
1074
+ event.participants.forEach(participant => {
1075
+ const participantNodeId = `char_${participant}`;
1076
+ const participantId = nodeMap.get(participantNodeId);
1077
+ if (participantId) {
1078
+ edges.add({
1079
+ from: participantId,
1080
+ to: id,
1081
+ label: '참여',
1082
+ title: `${participant}이(가) ${eventName}에 참여`,
1083
+ color: {
1084
+ color: '#ff9800',
1085
+ highlight: '#f57c00'
1086
+ },
1087
+ arrows: 'to',
1088
+ font: { size: 11, align: 'middle' },
1089
+ dashes: true,
1090
+ smooth: {
1091
+ type: 'curvedCW',
1092
+ roundness: 0.3
1093
+ }
1094
+ });
1095
+ }
1096
+ });
1097
+ }
1098
+
1099
+ // 사건과 장소 연결
1100
+ if (event.location) {
1101
+ const locationNodeId = `loc_${event.location}`;
1102
+ const locationId = nodeMap.get(locationNodeId);
1103
+ if (locationId) {
1104
+ edges.add({
1105
+ from: locationId,
1106
+ to: id,
1107
+ label: '발생',
1108
+ title: `${eventName}이(가) ${event.location}에서 발생`,
1109
+ color: {
1110
+ color: '#ff9800',
1111
+ highlight: '#f57c00'
1112
+ },
1113
+ arrows: 'to',
1114
+ font: { size: 11, align: 'middle' },
1115
+ dashes: [5, 5],
1116
+ smooth: {
1117
+ type: 'curvedCW',
1118
+ roundness: 0.3
1119
+ }
1120
+ });
1121
+ }
1122
+ }
1123
+ }
1124
+ });
1125
+ });
1126
+ }
1127
+
1128
+ // 관계 추가
1129
+ episodes.forEach(episode => {
1130
+ const relationships = data.relationships_by_episode?.[episode] || [];
1131
+ relationships.forEach(rel => {
1132
+ // 소스와 타겟이 인물인지 장소인지 확인
1133
+ let sourceNodeId = null;
1134
+ let targetNodeId = null;
1135
+
1136
+ // 소스 노드 찾기
1137
+ if (nodeMap.has(`char_${rel.source}`)) {
1138
+ sourceNodeId = nodeMap.get(`char_${rel.source}`);
1139
+ } else if (nodeMap.has(`loc_${rel.source}`)) {
1140
+ sourceNodeId = nodeMap.get(`loc_${rel.source}`);
1141
+ }
1142
+
1143
+ // 타겟 노드 찾기
1144
+ if (nodeMap.has(`char_${rel.target}`)) {
1145
+ targetNodeId = nodeMap.get(`char_${rel.target}`);
1146
+ } else if (nodeMap.has(`loc_${rel.target}`)) {
1147
+ targetNodeId = nodeMap.get(`loc_${rel.target}`);
1148
+ }
1149
+
1150
+ if (sourceNodeId && targetNodeId) {
1151
+ edges.add({
1152
+ from: sourceNodeId,
1153
+ to: targetNodeId,
1154
+ label: rel.relationship_type || '',
1155
+ title: `관계: ${rel.relationship_type || '없음'}\n설명: ${rel.description || '없음'}${rel.event ? `\n관련 사건: ${rel.event}` : ''}`,
1156
+ color: {
1157
+ color: '#ea4335',
1158
+ highlight: '#c5221f'
1159
+ },
1160
+ arrows: 'to',
1161
+ font: { size: 12, align: 'middle' },
1162
+ smooth: {
1163
+ type: 'curvedCW',
1164
+ roundness: 0.2
1165
+ }
1166
+ });
1167
+ }
1168
+ });
1169
+ });
1170
+
1171
+ // 네트워크 생성
1172
+ const container = document.createElement('div');
1173
+ container.id = 'graphNetworkContainer';
1174
+ container.style.width = '100%';
1175
+ container.style.height = '100%';
1176
+ content.appendChild(container);
1177
+
1178
+ const graphData = {
1179
+ nodes: nodes,
1180
+ edges: edges
1181
+ };
1182
+
1183
+ const options = {
1184
+ nodes: {
1185
+ borderWidth: 2,
1186
+ shadow: true,
1187
+ font: {
1188
+ size: 14,
1189
+ face: 'Inter'
1190
+ }
1191
+ },
1192
+ edges: {
1193
+ width: 2,
1194
+ shadow: true,
1195
+ font: {
1196
+ size: 12,
1197
+ align: 'middle'
1198
+ },
1199
+ arrows: {
1200
+ to: {
1201
+ enabled: true,
1202
+ scaleFactor: 0.8
1203
+ }
1204
+ }
1205
+ },
1206
+ physics: {
1207
+ enabled: true,
1208
+ stabilization: {
1209
+ enabled: true,
1210
+ iterations: 200
1211
+ },
1212
+ barnesHut: {
1213
+ gravitationalConstant: -2000,
1214
+ centralGravity: 0.1,
1215
+ springLength: 200,
1216
+ springConstant: 0.04,
1217
+ damping: 0.09
1218
+ }
1219
+ },
1220
+ interaction: {
1221
+ hover: true,
1222
+ tooltipDelay: 200,
1223
+ zoomView: true,
1224
+ dragView: true
1225
+ },
1226
+ layout: {
1227
+ improvedLayout: true
1228
+ }
1229
+ };
1230
+
1231
+ graphNetwork = new vis.Network(container, graphData, options);
1232
+
1233
+ // 네트워크 이벤트 리스너
1234
+ graphNetwork.on('click', function(params) {
1235
+ if (params.nodes.length > 0) {
1236
+ const nodeId = params.nodes[0];
1237
+ const node = nodes.get(nodeId);
1238
+ if (node) {
1239
+ console.log('선택된 노드:', node);
1240
+ }
1241
+ }
1242
+ });
1243
+ }
1244
+
1245
+ function updateGraphVisualization() {
1246
+ if (!allGraphData) return;
1247
+
1248
+ const episodeFilter = document.getElementById('episodeFilter').value;
1249
+
1250
+ // 기존 네트워크 제거
1251
+ if (graphNetwork) {
1252
+ graphNetwork.destroy();
1253
+ graphNetwork = null;
1254
+ }
1255
+
1256
+ // 새 그래프 생성
1257
+ createGraphVisualization(allGraphData, episodeFilter);
1258
+ }
1259
+
1260
+ function resetGraphView() {
1261
+ if (graphNetwork) {
1262
+ graphNetwork.fit({
1263
+ animation: {
1264
+ duration: 1000,
1265
+ easingFunction: 'easeInOutQuad'
1266
+ }
1267
+ });
1268
+ }
1269
+ }
1270
+
1271
+ function closeGraphRAGVisualizationModal() {
1272
+ document.getElementById('graphRAGVisualizationModal').classList.remove('active');
1273
+ if (graphNetwork) {
1274
+ graphNetwork.destroy();
1275
+ graphNetwork = null;
1276
+ }
1277
+ graphData = null;
1278
+ allGraphData = null;
1279
+ }
1280
+
1281
  // 모달 외부 클릭 시 닫기
1282
  document.getElementById('chunksModal').addEventListener('click', function(e) {
1283
  if (e.target === this) {
 
1297
  }
1298
  });
1299
 
1300
+ document.getElementById('graphRAGModal').addEventListener('click', function(e) {
1301
+ if (e.target === this) {
1302
+ closeGraphRAGModal();
1303
+ }
1304
+ });
1305
+
1306
+ document.getElementById('graphRAGVisualizationModal').addEventListener('click', function(e) {
1307
+ if (e.target === this) {
1308
+ closeGraphRAGVisualizationModal();
1309
+ }
1310
+ });
1311
+
1312
  // 페이지 로드 시 초기화
1313
  window.addEventListener('load', () => {
1314
  loadModelFilter();
templates/index.html CHANGED
@@ -774,6 +774,125 @@
774
  border-bottom-left-radius: 4px;
775
  }
776
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
777
  /* 각주 스타일 */
778
  .footnote-ref {
779
  font-size: 0.75em;
@@ -1631,26 +1750,134 @@
1631
  });
1632
 
1633
  // [근거: ] 형식을 각주로 변환하는 함수
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1634
  function formatContentWithFootnotes(content) {
1635
  if (!content) return { formattedContent: '', footnotes: [] };
1636
 
1637
- // 일반 텍스트를 HTML로 변환 (줄바꿈 처리)
1638
- let htmlContent = escapeHtml(content).replace(/\n/g, '<br>');
1639
 
1640
- // [근거: 내용] 패턴 찾기
1641
  const footnotePattern = /\[근거:\s*([^\]]+)\]/g;
1642
  const footnotes = [];
1643
  let footnoteIndex = 0;
1644
 
1645
- // [근거: ] 부분을 찾아서 각주 번호로 치환
1646
- let formattedContent = htmlContent.replace(footnotePattern, (match, footnoteText) => {
1647
  footnoteIndex++;
1648
  const cleanText = footnoteText.trim();
1649
  footnotes.push(cleanText);
 
1650
  return `<sup class="footnote-ref" data-footnote="${footnoteIndex}">[${footnoteIndex}]<span class="footnote-tooltip">${escapeHtml(cleanText)}</span></sup>`;
1651
  });
1652
 
1653
- return { formattedContent, footnotes };
1654
  }
1655
 
1656
  // HTML 이스케이프 헬퍼
 
774
  border-bottom-left-radius: 4px;
775
  }
776
 
777
+ /* 마크다운 스타일 */
778
+ .message.ai .message-bubble h1,
779
+ .message.ai .message-bubble h2,
780
+ .message.ai .message-bubble h3,
781
+ .message.ai .message-bubble h4,
782
+ .message.ai .message-bubble h5,
783
+ .message.ai .message-bubble h6 {
784
+ margin: 12px 0 8px 0;
785
+ font-weight: 600;
786
+ line-height: 1.3;
787
+ }
788
+
789
+ .message.ai .message-bubble h1 {
790
+ font-size: 1.5em;
791
+ border-bottom: 2px solid var(--border);
792
+ padding-bottom: 8px;
793
+ }
794
+
795
+ .message.ai .message-bubble h2 {
796
+ font-size: 1.3em;
797
+ border-bottom: 1px solid var(--border);
798
+ padding-bottom: 6px;
799
+ }
800
+
801
+ .message.ai .message-bubble h3 {
802
+ font-size: 1.1em;
803
+ }
804
+
805
+ .message.ai .message-bubble h4 {
806
+ font-size: 1em;
807
+ }
808
+
809
+ .message.ai .message-bubble h5 {
810
+ font-size: 0.9em;
811
+ }
812
+
813
+ .message.ai .message-bubble h6 {
814
+ font-size: 0.85em;
815
+ }
816
+
817
+ .message.ai .message-bubble p {
818
+ margin: 8px 0;
819
+ line-height: 1.6;
820
+ }
821
+
822
+ .message.ai .message-bubble code {
823
+ background: var(--bg-tertiary);
824
+ padding: 2px 6px;
825
+ border-radius: 4px;
826
+ font-family: 'Courier New', monospace;
827
+ font-size: 0.9em;
828
+ color: var(--accent);
829
+ }
830
+
831
+ .message.ai .message-bubble pre {
832
+ background: var(--bg-tertiary);
833
+ padding: 12px;
834
+ border-radius: 8px;
835
+ overflow-x: auto;
836
+ margin: 12px 0;
837
+ border: 1px solid var(--border);
838
+ }
839
+
840
+ .message.ai .message-bubble pre code {
841
+ background: none;
842
+ padding: 0;
843
+ color: var(--text-primary);
844
+ font-size: 0.85em;
845
+ }
846
+
847
+ .message.ai .message-bubble ul,
848
+ .message.ai .message-bubble ol {
849
+ margin: 8px 0;
850
+ padding-left: 24px;
851
+ }
852
+
853
+ .message.ai .message-bubble li {
854
+ margin: 4px 0;
855
+ line-height: 1.5;
856
+ }
857
+
858
+ .message.ai .message-bubble blockquote {
859
+ border-left: 4px solid var(--accent);
860
+ padding-left: 16px;
861
+ margin: 12px 0;
862
+ color: var(--text-secondary);
863
+ font-style: italic;
864
+ }
865
+
866
+ .message.ai .message-bubble hr {
867
+ border: none;
868
+ border-top: 1px solid var(--border);
869
+ margin: 16px 0;
870
+ }
871
+
872
+ .message.ai .message-bubble a {
873
+ color: var(--accent);
874
+ text-decoration: underline;
875
+ }
876
+
877
+ .message.ai .message-bubble a:hover {
878
+ opacity: 0.8;
879
+ }
880
+
881
+ .message.ai .message-bubble img {
882
+ max-width: 100%;
883
+ height: auto;
884
+ border-radius: 8px;
885
+ margin: 12px 0;
886
+ }
887
+
888
+ .message.ai .message-bubble strong {
889
+ font-weight: 600;
890
+ }
891
+
892
+ .message.ai .message-bubble em {
893
+ font-style: italic;
894
+ }
895
+
896
  /* 각주 스타일 */
897
  .footnote-ref {
898
  font-size: 0.75em;
 
1750
  });
1751
 
1752
  // [근거: ] 형식을 각주로 변환하는 함수
1753
+ // 마크다운을 HTML로 변환하는 함수
1754
+ function markdownToHtml(text) {
1755
+ if (!text) return '';
1756
+
1757
+ let html = text;
1758
+
1759
+ // FOOTNOTE 플레이스홀더 보호 (마크다운 변환 전에 보호)
1760
+ // 이 부분은 formatContentWithFootnotes에서 처리하므로 여기서는 보호하지 않음
1761
+
1762
+ // 코드 블록 처리 (```로 감싸진 부분) - 먼저 처리하여 다른 변환에 영향받지 않도록
1763
+ const codeBlocks = [];
1764
+ html = html.replace(/```([\s\S]*?)```/g, (match, code) => {
1765
+ const placeholder = `__CODE_BLOCK_${codeBlocks.length}__`;
1766
+ codeBlocks.push({
1767
+ placeholder: placeholder,
1768
+ html: `<pre><code>${escapeHtml(code.trim())}</code></pre>`
1769
+ });
1770
+ return placeholder;
1771
+ });
1772
+
1773
+ // 인라인 코드 처리 (`로 감싸진 부분) - 코드 블록이 아닌 부분만
1774
+ const inlineCodes = [];
1775
+ html = html.replace(/`([^`\n]+)`/g, (match, code) => {
1776
+ const placeholder = `__INLINE_CODE_${inlineCodes.length}__`;
1777
+ inlineCodes.push({
1778
+ placeholder: placeholder,
1779
+ html: `<code>${escapeHtml(code)}</code>`
1780
+ });
1781
+ return placeholder;
1782
+ });
1783
+
1784
+ // 헤더 처리 (# ## ### #### ##### ######)
1785
+ html = html.replace(/^###### (.*$)/gim, '<h6>$1</h6>');
1786
+ html = html.replace(/^##### (.*$)/gim, '<h5>$1</h5>');
1787
+ html = html.replace(/^#### (.*$)/gim, '<h4>$1</h4>');
1788
+ html = html.replace(/^### (.*$)/gim, '<h3>$1</h3>');
1789
+ html = html.replace(/^## (.*$)/gim, '<h2>$1</h2>');
1790
+ html = html.replace(/^# (.*$)/gim, '<h1>$1</h1>');
1791
+
1792
+ // 수평선 처리 (--- 또는 ***) - 헤더 다음에 처리
1793
+ html = html.replace(/^---$/gim, '<hr>');
1794
+ html = html.replace(/^\*\*\*$/gim, '<hr>');
1795
+
1796
+ // 인용문 처리 (>)
1797
+ html = html.replace(/^> (.+)$/gim, '<blockquote>$1</blockquote>');
1798
+
1799
+ // 순서 없는 리스트 처리 (- 또는 * 또는 +)
1800
+ html = html.replace(/^[\*\-\+] (.+)$/gim, '<li>$1</li>');
1801
+ // 연속된 <li> 태그를 <ul>로 감싸기
1802
+ html = html.replace(/(<li>.*?<\/li>(?:\s*<li>.*?<\/li>)*)/gs, '<ul>$1</ul>');
1803
+
1804
+ // 순서 있는 리스트 처리 (1. 2. 3.)
1805
+ html = html.replace(/^\d+\. (.+)$/gim, '<li>$1</li>');
1806
+ // 연속된 <li> 태그를 <ol>로 감싸기 (이미 <ul>로 감싸지지 않은 경우만)
1807
+ html = html.replace(/(?<!<ul>)(<li>.*?<\/li>(?:\s*<li>.*?<\/li>)*)(?!<\/ul>)/gs, '<ol>$1</ol>');
1808
+
1809
+ // 볼드 처리 (**text** 또는 __text__) - 코드 블록/인라인 코드가 아닌 부분만
1810
+ html = html.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
1811
+ html = html.replace(/__(.+?)__/g, '<strong>$1</strong>');
1812
+
1813
+ // 이탤릭 처리 (*text* 또는 _text_) - 볼드가 아닌 경우만
1814
+ html = html.replace(/(?<!\*)\*([^*]+)\*(?!\*)/g, '<em>$1</em>');
1815
+ html = html.replace(/(?<!_)_([^_]+)_(?!_)/g, '<em>$1</em>');
1816
+
1817
+ // 링크 처리 ([text](url))
1818
+ html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener noreferrer">$1</a>');
1819
+
1820
+ // 이미지 처리 (![alt](url))
1821
+ html = html.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '<img src="$2" alt="$1">');
1822
+
1823
+ // 인라인 코드 플레이스홀더 복원
1824
+ inlineCodes.forEach(({ placeholder, html: codeHtml }) => {
1825
+ html = html.replace(placeholder, codeHtml);
1826
+ });
1827
+
1828
+ // 코드 블록 플레이스홀더 복원
1829
+ codeBlocks.forEach(({ placeholder, html: codeHtml }) => {
1830
+ html = html.replace(placeholder, codeHtml);
1831
+ });
1832
+
1833
+ // 줄바꿈 처리
1834
+ // 먼저 두 개 이상의 연속된 줄바꿈을 단락 구분으로 처리
1835
+ html = html.split(/\n\n+/).map(para => {
1836
+ if (para.trim() && !para.match(/^<[h|u|o|b|p|d]/)) {
1837
+ // 이미 HTML 태그가 아닌 일반 텍스트만 <p>로 감싸기
1838
+ if (!para.trim().startsWith('<')) {
1839
+ return `<p>${para.trim()}</p>`;
1840
+ }
1841
+ }
1842
+ return para;
1843
+ }).join('\n');
1844
+
1845
+ // 단일 줄바꿈은 <br>로 (이미 태그로 감싸진 부분은 제외)
1846
+ html = html.replace(/(?<!>)\n(?!<)/g, '<br>');
1847
+
1848
+ // 빈 <p> 태그 제거
1849
+ html = html.replace(/<p>\s*<\/p>/g, '');
1850
+ html = html.replace(/<p>(<[^>]+>)/g, '$1');
1851
+ html = html.replace(/(<\/[^>]+>)<\/p>/g, '$1');
1852
+
1853
+ // 나머지 텍스트 이스케이프 (이미 HTML 태그가 아닌 부분만)
1854
+ // 하지만 이미 escapeHtml을 사용한 부분이 있으므로 주의 필요
1855
+ // 기본적으로 마크다운 변환 후 남은 텍스트는 안전하게 처리됨
1856
+
1857
+ return html;
1858
+ }
1859
+
1860
  function formatContentWithFootnotes(content) {
1861
  if (!content) return { formattedContent: '', footnotes: [] };
1862
 
1863
+ // 먼저 마크다운을 HTML로 변환
1864
+ let htmlContent = markdownToHtml(content);
1865
 
1866
+ // [근거: 내용] 패턴 찾기 (마크다운 변환 후에 처리)
1867
  const footnotePattern = /\[근거:\s*([^\]]+)\]/g;
1868
  const footnotes = [];
1869
  let footnoteIndex = 0;
1870
 
1871
+ // HTML에서 [근거: ] 패턴을 찾아서 각주로 변환
1872
+ htmlContent = htmlContent.replace(footnotePattern, (match, footnoteText) => {
1873
  footnoteIndex++;
1874
  const cleanText = footnoteText.trim();
1875
  footnotes.push(cleanText);
1876
+ // 직접 HTML로 변환 (플레이스홀더 사용하지 않음)
1877
  return `<sup class="footnote-ref" data-footnote="${footnoteIndex}">[${footnoteIndex}]<span class="footnote-tooltip">${escapeHtml(cleanText)}</span></sup>`;
1878
  });
1879
 
1880
+ return { formattedContent: htmlContent, footnotes };
1881
  }
1882
 
1883
  // HTML 이스케이프 헬퍼
templates/webnovels.html CHANGED
@@ -521,6 +521,61 @@
521
  </div>
522
  </div>
523
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
524
  <!-- 웹소설 내용 보기 모달 -->
525
  <div id="webnovelContentModal" class="modal">
526
  <div class="modal-content">
@@ -720,7 +775,9 @@
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);
@@ -1294,6 +1351,529 @@
1294
  }
1295
  });
1296
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1297
  // 페이지 로드 시 초기화
1298
  window.addEventListener('load', async () => {
1299
  await loadWebnovelModelFilter();
 
521
  </div>
522
  </div>
523
 
524
+ <!-- GraphRAG 모달 -->
525
+ <div id="graphRAGModal" class="modal">
526
+ <div class="modal-content" style="max-width: 1400px;">
527
+ <div class="modal-header">
528
+ <div class="modal-title" id="graphRAGModalTitle">회차별 캐릭터 관계 분석</div>
529
+ <button class="modal-close" onclick="closeGraphRAGModal()">&times;</button>
530
+ </div>
531
+ <div id="graphRAGContent" style="max-height: 80vh; overflow-y: auto; padding: 24px;">
532
+ <div style="text-align: center; padding: 24px; color: var(--text-secondary);">
533
+ GraphRAG 데이터를 불러오는 중...
534
+ </div>
535
+ </div>
536
+ </div>
537
+ </div>
538
+
539
+ <!-- GraphRAG 그래프 시각화 모달 -->
540
+ <div id="graphRAGVisualizationModal" class="modal">
541
+ <div class="modal-content" style="max-width: 1600px; width: 95%; height: 90vh;">
542
+ <div class="modal-header">
543
+ <div class="modal-title" id="graphRAGVisualizationModalTitle">캐릭터 관계도 시각화</div>
544
+ <button class="modal-close" onclick="closeGraphRAGVisualizationModal()">&times;</button>
545
+ </div>
546
+ <div style="padding: 16px; border-bottom: 1px solid var(--border); background: var(--bg-secondary); display: flex; gap: 12px; align-items: center; flex-wrap: wrap;">
547
+ <label style="font-size: 14px; font-weight: 500;">회차 필터:</label>
548
+ <select id="episodeFilter" onchange="updateGraphVisualization()" style="padding: 6px 12px; border: 1px solid var(--border); border-radius: 6px; font-size: 14px; min-width: 200px; background: var(--bg-primary); color: var(--text-primary);">
549
+ <option value="all">전체 회차</option>
550
+ </select>
551
+ <label style="font-size: 14px; font-weight: 500; margin-left: 16px;">노드 타입:</label>
552
+ <label style="font-size: 13px; margin-left: 8px;">
553
+ <input type="checkbox" id="showCharacters" checked onchange="updateGraphVisualization()" style="margin-right: 4px;">
554
+ 인물
555
+ </label>
556
+ <label style="font-size: 13px; margin-left: 8px;">
557
+ <input type="checkbox" id="showLocations" checked onchange="updateGraphVisualization()" style="margin-right: 4px;">
558
+ 장소
559
+ </label>
560
+ <label style="font-size: 13px; margin-left: 8px;">
561
+ <input type="checkbox" id="showEvents" checked onchange="updateGraphVisualization()" style="margin-right: 4px;">
562
+ 사건
563
+ </label>
564
+ <button onclick="resetGraphView()" style="padding: 6px 16px; background: var(--accent); color: white; border: none; border-radius: 6px; cursor: pointer; font-size: 13px; margin-left: auto;">
565
+ 뷰 리셋
566
+ </button>
567
+ </div>
568
+ <div id="graphRAGVisualizationContent" style="height: calc(90vh - 120px); position: relative; background: var(--bg-primary);">
569
+ <div style="text-align: center; padding: 24px; color: var(--text-secondary); position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);">
570
+ 그래프를 불러오는 중...
571
+ </div>
572
+ </div>
573
+ </div>
574
+ </div>
575
+
576
+ <!-- vis-network 라이브러리 -->
577
+ <script type="text/javascript" src="https://unpkg.com/vis-network@latest/standalone/umd/vis-network.min.js"></script>
578
+
579
  <!-- 웹소설 내용 보기 모달 -->
580
  <div id="webnovelContentModal" class="modal">
581
  <div class="modal-content">
 
775
  </div>
776
  <div class="webnovel-item-actions">
777
  <button class="webnovel-item-btn" onclick="viewWebnovelSummary(${file.id}, '${escapeHtml(file.original_filename)}')" style="background: #17a2b8; color: white; margin-right: 8px;">📋 요약 보기</button>
778
+ <button class="webnovel-item-btn" onclick="viewWebnovelContent(${file.id}, '${escapeHtml(file.original_filename)}')" style="margin-right: 8px;">📖 내용 보기</button>
779
+ <button class="webnovel-item-btn" onclick="viewGraphRAG(${file.id}, '${escapeHtml(file.original_filename)}')" style="background: #9c27b0; color: white; margin-right: 8px;">🔗 회차별 캐릭터 관계 분석</button>
780
+ <button class="webnovel-item-btn" onclick="viewGraphRAGVisualization(${file.id}, '${escapeHtml(file.original_filename)}')" style="background: #28a745; color: white;">📊 캐릭터 관계도 시각화</button>
781
  </div>
782
  `;
783
  listContainer.appendChild(fileItem);
 
1351
  }
1352
  });
1353
 
1354
+ document.getElementById('graphRAGModal').addEventListener('click', function(e) {
1355
+ if (e.target === this) {
1356
+ closeGraphRAGModal();
1357
+ }
1358
+ });
1359
+
1360
+ async function viewGraphRAG(fileId, fileName) {
1361
+ const modal = document.getElementById('graphRAGModal');
1362
+ const title = document.getElementById('graphRAGModalTitle');
1363
+ const content = document.getElementById('graphRAGContent');
1364
+
1365
+ title.textContent = `회차별 캐릭터 관계 분석 - ${fileName}`;
1366
+ content.innerHTML = '<div style="text-align: center; padding: 24px; color: var(--text-secondary);">GraphRAG 데이터를 불러오는 중...</div>';
1367
+ modal.classList.add('active');
1368
+
1369
+ try {
1370
+ const response = await fetch(`/api/files/${fileId}/graph`, {
1371
+ credentials: 'include'
1372
+ });
1373
+ if (!response.ok) throw new Error('GraphRAG 데이터를 불러올 수 없습니다.');
1374
+
1375
+ const data = await response.json();
1376
+
1377
+ let contentHtml = '';
1378
+
1379
+ // 통계 정보
1380
+ if (data.statistics) {
1381
+ contentHtml += '<div style="margin-bottom: 32px; padding: 16px; background: var(--bg-secondary); border-radius: 6px;">';
1382
+ contentHtml += '<h3 style="font-size: 18px; font-weight: 600; margin-bottom: 16px; color: var(--accent);">통계 정보</h3>';
1383
+ contentHtml += '<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 16px;">';
1384
+ contentHtml += `<div style="padding: 12px; background: var(--bg-primary); border-radius: 6px;"><strong>엔티티:</strong> ${data.statistics.total_entities}개</div>`;
1385
+ contentHtml += `<div style="padding: 12px; background: var(--bg-primary); border-radius: 6px;"><strong>관계:</strong> ${data.statistics.total_relationships}개</div>`;
1386
+ contentHtml += `<div style="padding: 12px; background: var(--bg-primary); border-radius: 6px;"><strong>사건:</strong> ${data.statistics.total_events}개</div>`;
1387
+ contentHtml += `<div style="padding: 12px; background: var(--bg-primary); border-radius: 6px;"><strong>회차 수:</strong> ${data.statistics.episodes_count}개</div>`;
1388
+ contentHtml += '</div>';
1389
+ contentHtml += '</div>';
1390
+ }
1391
+
1392
+ // 회차별 데이터 표시
1393
+ const episodes = data.episodes || [];
1394
+
1395
+ if (episodes.length === 0) {
1396
+ contentHtml += '<div style="text-align: center; padding: 24px; color: var(--text-secondary);">GraphRAG 데이터가 없습니다.</div>';
1397
+ } else {
1398
+ episodes.forEach(episode => {
1399
+ contentHtml += `<div style="margin-bottom: 32px; padding: 20px; background: var(--bg-secondary); border-radius: 8px; border-left: 4px solid var(--accent);">`;
1400
+ contentHtml += `<h3 style="font-size: 20px; font-weight: 600; margin-bottom: 20px; color: var(--accent);">${escapeHtml(episode)}</h3>`;
1401
+
1402
+ // 엔티티 (인물)
1403
+ const characters = data.entities_by_episode[episode]?.characters || [];
1404
+ if (characters.length > 0) {
1405
+ contentHtml += '<div style="margin-bottom: 20px;">';
1406
+ contentHtml += '<h4 style="font-size: 16px; font-weight: 600; margin-bottom: 12px; color: #1967d2;">인물</h4>';
1407
+ contentHtml += '<div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 12px;">';
1408
+ characters.forEach(char => {
1409
+ contentHtml += '<div style="padding: 12px; background: var(--bg-primary); border-radius: 6px; border: 1px solid var(--border);">';
1410
+ contentHtml += `<div style="font-weight: 600; margin-bottom: 8px; color: var(--accent);">${escapeHtml(char.entity_name)}</div>`;
1411
+ if (char.role) {
1412
+ contentHtml += `<div style="font-size: 13px; color: var(--text-secondary); margin-bottom: 4px;"><strong>역할:</strong> ${escapeHtml(char.role)}</div>`;
1413
+ }
1414
+ if (char.description) {
1415
+ contentHtml += `<div style="font-size: 13px; color: var(--text-secondary);">${escapeHtml(char.description)}</div>`;
1416
+ }
1417
+ contentHtml += '</div>';
1418
+ });
1419
+ contentHtml += '</div>';
1420
+ contentHtml += '</div>';
1421
+ }
1422
+
1423
+ // 엔티티 (장소)
1424
+ const locations = data.entities_by_episode[episode]?.locations || [];
1425
+ if (locations.length > 0) {
1426
+ contentHtml += '<div style="margin-bottom: 20px;">';
1427
+ contentHtml += '<h4 style="font-size: 16px; font-weight: 600; margin-bottom: 12px; color: #1967d2;">장소</h4>';
1428
+ contentHtml += '<div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 12px;">';
1429
+ locations.forEach(loc => {
1430
+ contentHtml += '<div style="padding: 12px; background: var(--bg-primary); border-radius: 6px; border: 1px solid var(--border);">';
1431
+ contentHtml += `<div style="font-weight: 600; margin-bottom: 8px; color: var(--accent);">${escapeHtml(loc.entity_name)}</div>`;
1432
+ if (loc.category) {
1433
+ contentHtml += `<div style="font-size: 13px; color: var(--text-secondary); margin-bottom: 4px;"><strong>유형:</strong> ${escapeHtml(loc.category)}</div>`;
1434
+ }
1435
+ if (loc.description) {
1436
+ contentHtml += `<div style="font-size: 13px; color: var(--text-secondary);">${escapeHtml(loc.description)}</div>`;
1437
+ }
1438
+ contentHtml += '</div>';
1439
+ });
1440
+ contentHtml += '</div>';
1441
+ contentHtml += '</div>';
1442
+ }
1443
+
1444
+ // 관계
1445
+ const relationships = data.relationships_by_episode[episode] || [];
1446
+ if (relationships.length > 0) {
1447
+ contentHtml += '<div style="margin-bottom: 20px;">';
1448
+ contentHtml += '<h4 style="font-size: 16px; font-weight: 600; margin-bottom: 12px; color: #1967d2;">관계</h4>';
1449
+ contentHtml += '<div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(400px, 1fr)); gap: 12px;">';
1450
+ relationships.forEach(rel => {
1451
+ contentHtml += '<div style="padding: 12px; background: var(--bg-primary); border-radius: 6px; border: 1px solid var(--border);">';
1452
+ contentHtml += `<div style="margin-bottom: 8px;">`;
1453
+ contentHtml += `<span style="font-weight: 600; color: var(--accent);">${escapeHtml(rel.source)}</span>`;
1454
+ contentHtml += `<span style="margin: 0 8px; color: var(--text-secondary);">→</span>`;
1455
+ contentHtml += `<span style="font-weight: 600; color: var(--accent);">${escapeHtml(rel.target)}</span>`;
1456
+ contentHtml += '</div>';
1457
+ if (rel.relationship_type) {
1458
+ contentHtml += `<div style="font-size: 13px; color: var(--text-secondary); margin-bottom: 4px;"><strong>관계 유형:</strong> ${escapeHtml(rel.relationship_type)}</div>`;
1459
+ }
1460
+ if (rel.description) {
1461
+ contentHtml += `<div style="font-size: 13px; color: var(--text-secondary); margin-bottom: 4px;">${escapeHtml(rel.description)}</div>`;
1462
+ }
1463
+ if (rel.event) {
1464
+ contentHtml += `<div style="font-size: 12px; color: #856404; padding: 8px; background: #fff3cd; border-radius: 4px; margin-top: 8px;"><strong>관련 사건:</strong> ${escapeHtml(rel.event)}</div>`;
1465
+ }
1466
+ contentHtml += '</div>';
1467
+ });
1468
+ contentHtml += '</div>';
1469
+ contentHtml += '</div>';
1470
+ }
1471
+
1472
+ // 사건
1473
+ const events = data.events_by_episode[episode] || [];
1474
+ if (events.length > 0) {
1475
+ contentHtml += '<div style="margin-bottom: 20px;">';
1476
+ contentHtml += '<h4 style="font-size: 16px; font-weight: 600; margin-bottom: 12px; color: #1967d2;">사건</h4>';
1477
+ contentHtml += '<div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(400px, 1fr)); gap: 12px;">';
1478
+ events.forEach(event => {
1479
+ contentHtml += '<div style="padding: 12px; background: var(--bg-primary); border-radius: 6px; border: 1px solid var(--border);">';
1480
+ if (event.event_name) {
1481
+ contentHtml += `<div style="font-weight: 600; margin-bottom: 8px; color: var(--accent);">${escapeHtml(event.event_name)}</div>`;
1482
+ }
1483
+ if (event.description) {
1484
+ contentHtml += `<div style="font-size: 13px; color: var(--text-secondary); margin-bottom: 8px; line-height: 1.6;">${escapeHtml(event.description)}</div>`;
1485
+ }
1486
+ if (event.participants && event.participants.length > 0) {
1487
+ contentHtml += `<div style="font-size: 13px; color: var(--text-secondary); margin-bottom: 4px;"><strong>관련 인물:</strong> ${escapeHtml(event.participants.join(', '))}</div>`;
1488
+ }
1489
+ if (event.location) {
1490
+ contentHtml += `<div style="font-size: 13px; color: var(--text-secondary); margin-bottom: 4px;"><strong>장소:</strong> ${escapeHtml(event.location)}</div>`;
1491
+ }
1492
+ if (event.significance) {
1493
+ contentHtml += `<div style="font-size: 12px; color: #137333; padding: 6px; background: #e8f5e9; border-radius: 4px; margin-top: 8px; display: inline-block;"><strong>중요도:</strong> ${escapeHtml(event.significance)}</div>`;
1494
+ }
1495
+ contentHtml += '</div>';
1496
+ });
1497
+ contentHtml += '</div>';
1498
+ contentHtml += '</div>';
1499
+ }
1500
+
1501
+ contentHtml += '</div>';
1502
+ });
1503
+ }
1504
+
1505
+ content.innerHTML = contentHtml;
1506
+ } catch (error) {
1507
+ console.error('GraphRAG 데이터 로드 오류:', error);
1508
+ content.innerHTML = '<div style="text-align: center; padding: 24px; color: #ea4335;">GraphRAG 데이터를 불러오는 중 오류가 발생했습니다.</div>';
1509
+ }
1510
+ }
1511
+
1512
+ function closeGraphRAGModal() {
1513
+ document.getElementById('graphRAGModal').classList.remove('active');
1514
+ }
1515
+
1516
+ // GraphRAG 그래프 시각화 관련 변수
1517
+ let webnovelGraphData = null;
1518
+ let webnovelGraphNetwork = null;
1519
+ let webnovelAllGraphData = null;
1520
+
1521
+ async function viewGraphRAGVisualization(fileId, fileName) {
1522
+ const modal = document.getElementById('graphRAGVisualizationModal');
1523
+ const title = document.getElementById('graphRAGVisualizationModalTitle');
1524
+ const content = document.getElementById('graphRAGVisualizationContent');
1525
+
1526
+ title.textContent = `캐릭터 관계도 시각화 - ${fileName}`;
1527
+ content.innerHTML = '<div style="text-align: center; padding: 24px; color: var(--text-secondary); position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);">그래프를 불러오는 중...</div>';
1528
+ modal.classList.add('active');
1529
+
1530
+ // 기존 네트워크 제거
1531
+ if (webnovelGraphNetwork) {
1532
+ webnovelGraphNetwork.destroy();
1533
+ webnovelGraphNetwork = null;
1534
+ }
1535
+
1536
+ try {
1537
+ const response = await fetch(`/api/files/${fileId}/graph`, {
1538
+ credentials: 'include'
1539
+ });
1540
+ if (!response.ok) throw new Error('GraphRAG 데이터를 불러올 수 없습니다.');
1541
+
1542
+ const data = await response.json();
1543
+ webnovelAllGraphData = data;
1544
+
1545
+ // 회차 필터 옵션 생성
1546
+ const episodeFilter = document.getElementById('episodeFilter');
1547
+ episodeFilter.innerHTML = '<option value="all">전체 회차</option>';
1548
+ if (data.episodes && data.episodes.length > 0) {
1549
+ data.episodes.forEach(episode => {
1550
+ const option = document.createElement('option');
1551
+ option.value = episode;
1552
+ option.textContent = episode;
1553
+ episodeFilter.appendChild(option);
1554
+ });
1555
+ }
1556
+
1557
+ // 그래프 생성
1558
+ createWebnovelGraphVisualization(data);
1559
+ } catch (error) {
1560
+ console.error('GraphRAG 그래프 로드 오류:', error);
1561
+ content.innerHTML = '<div style="text-align: center; padding: 24px; color: #ea4335; position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);">그래프를 불러오는 중 오류가 발생했습니다.</div>';
1562
+ }
1563
+ }
1564
+
1565
+ function createWebnovelGraphVisualization(data, episodeFilter = 'all') {
1566
+ const content = document.getElementById('graphRAGVisualizationContent');
1567
+ content.innerHTML = ''; // 기존 내용 제거
1568
+
1569
+ // 노드와 엣지 데이터 생성
1570
+ const nodes = new vis.DataSet([]);
1571
+ const edges = new vis.DataSet([]);
1572
+
1573
+ const nodeMap = new Map(); // 노드 ID 매핑
1574
+ let nodeIdCounter = 1;
1575
+
1576
+ const showCharacters = document.getElementById('showCharacters').checked;
1577
+ const showLocations = document.getElementById('showLocations').checked;
1578
+ const showEvents = document.getElementById('showEvents').checked;
1579
+
1580
+ // 필터링할 회차 목록
1581
+ const episodes = episodeFilter === 'all'
1582
+ ? (data.episodes || [])
1583
+ : [episodeFilter];
1584
+
1585
+ // 엔티티 추가 (인물, 장소)
1586
+ episodes.forEach(episode => {
1587
+ const entities = data.entities_by_episode?.[episode] || {};
1588
+
1589
+ // 인물 추가
1590
+ if (showCharacters && entities.characters) {
1591
+ entities.characters.forEach(char => {
1592
+ const nodeId = `char_${char.entity_name}`;
1593
+ if (!nodeMap.has(nodeId)) {
1594
+ const id = nodeIdCounter++;
1595
+ nodeMap.set(nodeId, id);
1596
+ nodes.add({
1597
+ id: id,
1598
+ label: char.entity_name,
1599
+ title: `인물: ${char.entity_name}\n역할: ${char.role || '없음'}\n설명: ${char.description || '없음'}`,
1600
+ color: {
1601
+ background: '#4285f4',
1602
+ border: '#1967d2',
1603
+ highlight: { background: '#1a73e8', border: '#1557b0' }
1604
+ },
1605
+ shape: 'ellipse',
1606
+ font: { size: 14, face: 'Inter' },
1607
+ size: 20
1608
+ });
1609
+ }
1610
+ });
1611
+ }
1612
+
1613
+ // 장소 추가
1614
+ if (showLocations && entities.locations) {
1615
+ entities.locations.forEach(loc => {
1616
+ const nodeId = `loc_${loc.entity_name}`;
1617
+ if (!nodeMap.has(nodeId)) {
1618
+ const id = nodeIdCounter++;
1619
+ nodeMap.set(nodeId, id);
1620
+ nodes.add({
1621
+ id: id,
1622
+ label: loc.entity_name,
1623
+ title: `장소: ${loc.entity_name}\n유형: ${loc.category || '없음'}\n설명: ${loc.description || '없음'}`,
1624
+ color: {
1625
+ background: '#34a853',
1626
+ border: '#137333',
1627
+ highlight: { background: '#2e7d32', border: '#1b5e20' }
1628
+ },
1629
+ shape: 'box',
1630
+ font: { size: 14, face: 'Inter' },
1631
+ size: 20
1632
+ });
1633
+ }
1634
+ });
1635
+ }
1636
+ });
1637
+
1638
+ // 사건 추가
1639
+ if (showEvents) {
1640
+ episodes.forEach(episode => {
1641
+ const events = data.events_by_episode?.[episode] || [];
1642
+ events.forEach(event => {
1643
+ const eventName = event.event_name || `사건_${episode}_${events.indexOf(event)}`;
1644
+ const nodeId = `event_${eventName}`;
1645
+ if (!nodeMap.has(nodeId)) {
1646
+ const id = nodeIdCounter++;
1647
+ nodeMap.set(nodeId, id);
1648
+ nodes.add({
1649
+ id: id,
1650
+ label: eventName,
1651
+ title: `사건: ${eventName}\n설명: ${event.description || '없음'}\n관련 인물: ${event.participants ? event.participants.join(', ') : '없음'}\n장소: ${event.location || '없음'}\n중요도: ${event.significance || '없음'}`,
1652
+ color: {
1653
+ background: '#ff9800',
1654
+ border: '#f57c00',
1655
+ highlight: { background: '#fb8c00', border: '#e65100' }
1656
+ },
1657
+ shape: 'diamond',
1658
+ font: { size: 13, face: 'Inter' },
1659
+ size: 25
1660
+ });
1661
+
1662
+ // 사건과 관련 인물 연결
1663
+ if (event.participants && Array.isArray(event.participants)) {
1664
+ event.participants.forEach(participant => {
1665
+ const participantNodeId = `char_${participant}`;
1666
+ const participantId = nodeMap.get(participantNodeId);
1667
+ if (participantId) {
1668
+ edges.add({
1669
+ from: participantId,
1670
+ to: id,
1671
+ label: '참여',
1672
+ title: `${participant}이(가) ${eventName}에 참여`,
1673
+ color: {
1674
+ color: '#ff9800',
1675
+ highlight: '#f57c00'
1676
+ },
1677
+ arrows: 'to',
1678
+ font: { size: 11, align: 'middle' },
1679
+ dashes: true,
1680
+ smooth: {
1681
+ type: 'curvedCW',
1682
+ roundness: 0.3
1683
+ }
1684
+ });
1685
+ }
1686
+ });
1687
+ }
1688
+
1689
+ // 사건과 장소 연결
1690
+ if (event.location) {
1691
+ const locationNodeId = `loc_${event.location}`;
1692
+ const locationId = nodeMap.get(locationNodeId);
1693
+ if (locationId) {
1694
+ edges.add({
1695
+ from: locationId,
1696
+ to: id,
1697
+ label: '발생',
1698
+ title: `${eventName}이(가) ${event.location}에서 발생`,
1699
+ color: {
1700
+ color: '#ff9800',
1701
+ highlight: '#f57c00'
1702
+ },
1703
+ arrows: 'to',
1704
+ font: { size: 11, align: 'middle' },
1705
+ dashes: [5, 5],
1706
+ smooth: {
1707
+ type: 'curvedCW',
1708
+ roundness: 0.3
1709
+ }
1710
+ });
1711
+ }
1712
+ }
1713
+ }
1714
+ });
1715
+ });
1716
+ }
1717
+
1718
+ // 관계 추가
1719
+ episodes.forEach(episode => {
1720
+ const relationships = data.relationships_by_episode?.[episode] || [];
1721
+ relationships.forEach(rel => {
1722
+ // 소스와 타겟이 인물인지 장소인지 확인
1723
+ let sourceNodeId = null;
1724
+ let targetNodeId = null;
1725
+
1726
+ // 소스 노드 찾기
1727
+ if (nodeMap.has(`char_${rel.source}`)) {
1728
+ sourceNodeId = nodeMap.get(`char_${rel.source}`);
1729
+ } else if (nodeMap.has(`loc_${rel.source}`)) {
1730
+ sourceNodeId = nodeMap.get(`loc_${rel.source}`);
1731
+ }
1732
+
1733
+ // 타겟 노드 찾기
1734
+ if (nodeMap.has(`char_${rel.target}`)) {
1735
+ targetNodeId = nodeMap.get(`char_${rel.target}`);
1736
+ } else if (nodeMap.has(`loc_${rel.target}`)) {
1737
+ targetNodeId = nodeMap.get(`loc_${rel.target}`);
1738
+ }
1739
+
1740
+ if (sourceNodeId && targetNodeId) {
1741
+ edges.add({
1742
+ from: sourceNodeId,
1743
+ to: targetNodeId,
1744
+ label: rel.relationship_type || '',
1745
+ title: `관계: ${rel.relationship_type || '없음'}\n설명: ${rel.description || '없음'}${rel.event ? `\n관련 사건: ${rel.event}` : ''}`,
1746
+ color: {
1747
+ color: '#ea4335',
1748
+ highlight: '#c5221f'
1749
+ },
1750
+ arrows: 'to',
1751
+ font: { size: 12, align: 'middle' },
1752
+ smooth: {
1753
+ type: 'curvedCW',
1754
+ roundness: 0.2
1755
+ }
1756
+ });
1757
+ }
1758
+ });
1759
+ });
1760
+
1761
+ // 네트워크 생성
1762
+ const container = document.createElement('div');
1763
+ container.id = 'webnovelGraphNetworkContainer';
1764
+ container.style.width = '100%';
1765
+ container.style.height = '100%';
1766
+ content.appendChild(container);
1767
+
1768
+ const graphData = {
1769
+ nodes: nodes,
1770
+ edges: edges
1771
+ };
1772
+
1773
+ const options = {
1774
+ nodes: {
1775
+ borderWidth: 2,
1776
+ shadow: true,
1777
+ font: {
1778
+ size: 14,
1779
+ face: 'Inter'
1780
+ }
1781
+ },
1782
+ edges: {
1783
+ width: 2,
1784
+ shadow: true,
1785
+ font: {
1786
+ size: 12,
1787
+ align: 'middle'
1788
+ },
1789
+ arrows: {
1790
+ to: {
1791
+ enabled: true,
1792
+ scaleFactor: 0.8
1793
+ }
1794
+ }
1795
+ },
1796
+ physics: {
1797
+ enabled: true,
1798
+ stabilization: {
1799
+ enabled: true,
1800
+ iterations: 200
1801
+ },
1802
+ barnesHut: {
1803
+ gravitationalConstant: -2000,
1804
+ centralGravity: 0.1,
1805
+ springLength: 200,
1806
+ springConstant: 0.04,
1807
+ damping: 0.09
1808
+ }
1809
+ },
1810
+ interaction: {
1811
+ hover: true,
1812
+ tooltipDelay: 200,
1813
+ zoomView: true,
1814
+ dragView: true
1815
+ },
1816
+ layout: {
1817
+ improvedLayout: true
1818
+ }
1819
+ };
1820
+
1821
+ webnovelGraphNetwork = new vis.Network(container, graphData, options);
1822
+
1823
+ // 네트워크 이벤트 리스너
1824
+ webnovelGraphNetwork.on('click', function(params) {
1825
+ if (params.nodes.length > 0) {
1826
+ const nodeId = params.nodes[0];
1827
+ const node = nodes.get(nodeId);
1828
+ if (node) {
1829
+ console.log('선택된 노드:', node);
1830
+ }
1831
+ }
1832
+ });
1833
+ }
1834
+
1835
+ function updateGraphVisualization() {
1836
+ if (!webnovelAllGraphData) return;
1837
+
1838
+ const episodeFilter = document.getElementById('episodeFilter').value;
1839
+
1840
+ // 기존 네트워크 제거
1841
+ if (webnovelGraphNetwork) {
1842
+ webnovelGraphNetwork.destroy();
1843
+ webnovelGraphNetwork = null;
1844
+ }
1845
+
1846
+ // 새 그래프 생성
1847
+ createWebnovelGraphVisualization(webnovelAllGraphData, episodeFilter);
1848
+ }
1849
+
1850
+ function resetGraphView() {
1851
+ if (webnovelGraphNetwork) {
1852
+ webnovelGraphNetwork.fit({
1853
+ animation: {
1854
+ duration: 1000,
1855
+ easingFunction: 'easeInOutQuad'
1856
+ }
1857
+ });
1858
+ }
1859
+ }
1860
+
1861
+ function closeGraphRAGVisualizationModal() {
1862
+ document.getElementById('graphRAGVisualizationModal').classList.remove('active');
1863
+ if (webnovelGraphNetwork) {
1864
+ webnovelGraphNetwork.destroy();
1865
+ webnovelGraphNetwork = null;
1866
+ }
1867
+ webnovelGraphData = null;
1868
+ webnovelAllGraphData = null;
1869
+ }
1870
+
1871
+ document.getElementById('graphRAGVisualizationModal').addEventListener('click', function(e) {
1872
+ if (e.target === this) {
1873
+ closeGraphRAGVisualizationModal();
1874
+ }
1875
+ });
1876
+
1877
  // 페이지 로드 시 초기화
1878
  window.addEventListener('load', async () => {
1879
  await loadWebnovelModelFilter();