SOY NV AI commited on
Commit
b834258
·
1 Parent(s): 665bcdc

feat: 메타데이터 추가 옵션, Parent Chunk 수동 생성, 각주 표시 기능 추가

Browse files

- 웹소설 업로드 시 메타데이터 추가 옵션 체크박스 추가
- 관리페이지에서 Parent Chunk 수동 생성 기능 추가
- 관리페이지에서 모든 AI 모델 목록 표시
- AI 답변에 [근거: ] 형식을 각주로 변환하여 표시
- 각주 번호에 마우스 오버 시 툴팁으로 내용 표시
- DocumentChunk 모델에 chunk_metadata 필드 추가
- 청크 생성 시 챕터 번호, 화자, 등장인물, 시간적 배경 메타데이터 추출

EXAONE_설치_가이드.md CHANGED
@@ -147,3 +147,7 @@ tokenizer = AutoTokenizer.from_pretrained("LGAI-EXAONE/EXAONE-3.0-7.8B-Instruct"
147
 
148
  하지만 이 경우 Ollama API와 통합하려면 추가 작업이 필요합니다.
149
 
 
 
 
 
 
147
 
148
  하지만 이 경우 Ollama API와 통합하려면 추가 작업이 필요합니다.
149
 
150
+
151
+
152
+
153
+
EXAONE_추가_안내.md CHANGED
@@ -64,3 +64,7 @@ Ollama를 거치지 않고 Python에서 직접 Hugging Face 모델을 사용할
64
  - [EXAONE 모델 페이지](https://huggingface.co/LGAI-EXAONE/EXAONE-3.0-7.8B-Instruct)
65
  - [Ollama 공식 문서](https://github.com/ollama/ollama)
66
 
 
 
 
 
 
64
  - [EXAONE 모델 페이지](https://huggingface.co/LGAI-EXAONE/EXAONE-3.0-7.8B-Instruct)
65
  - [Ollama 공식 문서](https://github.com/ollama/ollama)
66
 
67
+
68
+
69
+
70
+
GIT_SETUP.md CHANGED
@@ -67,3 +67,7 @@ gh repo create soy-nv-ai --public --source=. --remote=origin --push
67
  3. README.md 업데이트 (프로젝트 설명 추가)
68
 
69
 
 
 
 
 
 
67
  3. README.md 업데이트 (프로젝트 설명 추가)
68
 
69
 
70
+
71
+
72
+
73
+
README_SERVER.md CHANGED
@@ -67,3 +67,7 @@ Start-Process powershell -ArgumentList "-File", "start_server_background.ps1" -W
67
  2. 또는 작업 관리자에서 Python 프로세스 종료
68
 
69
 
 
 
 
 
 
67
  2. 또는 작업 관리자에서 Python 프로세스 종료
68
 
69
 
70
+
71
+
72
+
73
+
add_exaone_model.py CHANGED
@@ -142,3 +142,7 @@ if __name__ == "__main__":
142
  print("3. 모델이 목록에 나타나면 웹 애플리케이션에서 사용할 수 있습니다.")
143
  print("=" * 60 + "\n")
144
 
 
 
 
 
 
142
  print("3. 모델이 목록에 나타나면 웹 애플리케이션에서 사용할 수 있습니다.")
143
  print("=" * 60 + "\n")
144
 
145
+
146
+
147
+
148
+
app/__init__.py CHANGED
@@ -86,6 +86,19 @@ def migrate_database(app):
86
  conn.commit()
87
  print("[마이그레이션] uploaded_file.parent_file_id 컬럼 추가 완료")
88
 
 
 
 
 
 
 
 
 
 
 
 
 
 
89
  conn.close()
90
  print("[마이그레이션] 데이터베이스 마이그레이션 완료")
91
  except Exception as e:
 
86
  conn.commit()
87
  print("[마이그레이션] uploaded_file.parent_file_id 컬럼 추가 완료")
88
 
89
+ # document_chunk 테이블이 존재하는지 확인
90
+ cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='document_chunk'")
91
+ if cursor.fetchone():
92
+ # document_chunk 테이블에 chunk_metadata 컬럼이 있는지 확인
93
+ cursor.execute("PRAGMA table_info(document_chunk)")
94
+ document_chunk_columns = [column[1] for column in cursor.fetchall()]
95
+
96
+ if 'chunk_metadata' not in document_chunk_columns:
97
+ print("[마이그레이션] document_chunk 테이블에 chunk_metadata 컬럼 추가 중...")
98
+ cursor.execute("ALTER TABLE document_chunk ADD COLUMN chunk_metadata TEXT")
99
+ conn.commit()
100
+ print("[마이그레이션] document_chunk.chunk_metadata 컬럼 추가 완료")
101
+
102
  conn.close()
103
  print("[마이그레이션] 데이터베이스 마이그레이션 완료")
104
  except Exception as e:
app/database.py CHANGED
@@ -113,17 +113,27 @@ class DocumentChunk(db.Model):
113
  chunk_index = db.Column(db.Integer, nullable=False) # 청크 순서
114
  content = db.Column(db.Text, nullable=False) # 청크 내용
115
  embedding = db.Column(db.Text, nullable=True) # 임베딩 벡터 (JSON 문자열로 저장)
 
116
  created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
117
 
118
  # 관계
119
  file = db.relationship('UploadedFile', backref='chunks')
120
 
121
  def to_dict(self):
 
 
 
 
 
 
 
 
122
  return {
123
  'id': self.id,
124
  'file_id': self.file_id,
125
  'chunk_index': self.chunk_index,
126
  'content': self.content,
 
127
  'created_at': self.created_at.isoformat() if self.created_at else None
128
  }
129
 
 
113
  chunk_index = db.Column(db.Integer, nullable=False) # 청크 순서
114
  content = db.Column(db.Text, nullable=False) # 청크 내용
115
  embedding = db.Column(db.Text, nullable=True) # 임베딩 벡터 (JSON 문자열로 저장)
116
+ chunk_metadata = db.Column(db.Text, nullable=True) # 메타데이터 (JSON 문자열로 저장: chapter, pov, characters, time_background)
117
  created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
118
 
119
  # 관계
120
  file = db.relationship('UploadedFile', backref='chunks')
121
 
122
  def to_dict(self):
123
+ import json
124
+ metadata_dict = None
125
+ if self.chunk_metadata:
126
+ try:
127
+ metadata_dict = json.loads(self.chunk_metadata)
128
+ except:
129
+ metadata_dict = None
130
+
131
  return {
132
  'id': self.id,
133
  'file_id': self.file_id,
134
  'chunk_index': self.chunk_index,
135
  'content': self.content,
136
+ 'metadata': metadata_dict,
137
  'created_at': self.created_at.isoformat() if self.created_at else None
138
  }
139
 
app/routes.py CHANGED
@@ -181,11 +181,196 @@ def split_text_into_chunks(text, min_chunk_size=200, max_chunk_size=1000, overla
181
 
182
  return final_chunks if final_chunks else [text] if text.strip() else []
183
 
184
- def create_chunks_for_file(file_id, content):
185
- """파일 내용을 의미 기반 청크로 분할하여 저장 (벡터 DB 포함)"""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
186
  try:
187
  print(f"[청크 생성] 파일 ID {file_id}에 대한 청크 생성 시작")
188
  print(f"[청크 생성] 원본 텍스트 길이: {len(content)}자")
 
 
 
 
 
189
 
190
  # 벡터 DB 매니저 가져오기
191
  vector_db = get_vector_db()
@@ -212,13 +397,40 @@ def create_chunks_for_file(file_id, content):
212
  # 각 청크를 데이터베이스와 벡터 DB에 저장
213
  saved_count = 0
214
  vector_saved_count = 0
 
 
 
 
 
 
 
215
  for idx, chunk_content in enumerate(chunks):
216
  try:
217
- # DB에 청크 저장
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
218
  chunk = DocumentChunk(
219
  file_id=file_id,
220
  chunk_index=idx,
221
- content=chunk_content
 
222
  )
223
  db.session.add(chunk)
224
  db.session.flush() # ID 생성
@@ -236,13 +448,15 @@ def create_chunks_for_file(file_id, content):
236
 
237
  # 진행 상황 출력 (10개마다)
238
  if (idx + 1) % 10 == 0:
239
- print(f"[청크 생성] 진행 중: {idx + 1}/{len(chunks)}개 청크 저장 중... (DB: {saved_count}, 벡터 DB: {vector_saved_count})")
240
  except Exception as e:
241
  print(f"[청크 생성] 경고: 청크 {idx} 저장 중 오류: {str(e)}")
 
 
242
  continue
243
 
244
  db.session.commit()
245
- print(f"[청크 생성] 완료: {saved_count}개 청크가 데이터베이스에 저장되었습니다. (벡터 DB: {vector_saved_count}개)")
246
 
247
  # 저장 확인
248
  verified_count = DocumentChunk.query.filter_by(file_id=file_id).count()
@@ -986,29 +1200,61 @@ def set_gemini_api_key():
986
  @main_bp.route('/api/ollama/models', methods=['GET'])
987
  @login_required
988
  def get_ollama_models():
989
- """Ollama 및 Gemini에서 사용 가능한 모델 목록 가져오기"""
990
  try:
991
  all_models = []
992
 
993
- # 1. Ollama 모델 목록 가져오기
994
  try:
995
  response = requests.get(f'{OLLAMA_BASE_URL}/api/tags', timeout=5)
996
  if response.status_code == 200:
997
  data = response.json()
998
- ollama_models = [{'name': model['name'], 'type': 'ollama'} for model in data.get('models', [])]
999
- all_models.extend(ollama_models)
1000
- print(f"[모델 목록] Ollama 모델 {len(ollama_models)}개 추가")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1001
  except Exception as e:
1002
  print(f"[모델 목록] Ollama 모델 목록 조회 실패: {e}")
1003
 
1004
- # 2. Gemini 모델 목록 가져오기
1005
  try:
1006
  gemini_client = get_gemini_client()
1007
  if gemini_client.is_configured():
1008
  gemini_models = gemini_client.get_available_models()
1009
- gemini_model_list = [{'name': f'gemini:{model_name}', 'type': 'gemini'} for model_name in gemini_models]
1010
- all_models.extend(gemini_model_list)
1011
- print(f"[모델 목록] Gemini 모델 {len(gemini_model_list)}개 추가")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1012
  else:
1013
  print(f"[모델 목록] Gemini API 키가 설정되지 않아 Gemini 모델을 불러올 수 없습니다.")
1014
  except Exception as e:
@@ -1022,6 +1268,75 @@ def get_ollama_models():
1022
  except Exception as e:
1023
  return jsonify({'error': f'모델 목록을 가져오는 중 오류가 발생했습니다: {str(e)}', 'models': []}), 500
1024
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1025
  @main_bp.route('/api/chat', methods=['POST'])
1026
  @login_required
1027
  def chat():
@@ -1435,10 +1750,12 @@ def upload_file():
1435
  file = request.files['file']
1436
  model_name = request.form.get('model_name', '').strip()
1437
  parent_file_id = request.form.get('parent_file_id', None) # 이어서 업로드할 경우 원본 파일 ID
 
1438
 
1439
  log_print(f"[2/8] 파일 수신: {file.filename if file else 'None'}")
1440
  log_print(f"[2/8] 모델명: {model_name if model_name else 'None (비어있음)'}")
1441
  log_print(f"[2/8] 이어서 업로드: {parent_file_id if parent_file_id else '아니오'}")
 
1442
 
1443
  if file.filename == '':
1444
  error_msg = '파일명이 없습니다.'
@@ -1600,8 +1917,8 @@ def upload_file():
1600
  log_print(f"[7/8] CP949 인코딩으로 파일 읽기 성공: {len(content)}자")
1601
 
1602
  # 청크 생성 및 저장
1603
- log_print(f"[7/8] 청크 생성 함수 호출 중...")
1604
- chunk_count = create_chunks_for_file(uploaded_file.id, content)
1605
 
1606
  if chunk_count > 0:
1607
  log_print(f"[7/8] ✅ 성공: 파일 {original_filename}을 {chunk_count}개의 청크로 분할했습니다.")
@@ -1707,6 +2024,10 @@ def get_files():
1707
  chunk_count = DocumentChunk.query.filter_by(file_id=file.id).count()
1708
  file_dict['chunk_count'] = chunk_count
1709
 
 
 
 
 
1710
  # 이어서 업로드된 파일들도 조회
1711
  child_files = UploadedFile.query.filter_by(parent_file_id=file.id).order_by(UploadedFile.uploaded_at.asc()).all()
1712
  child_files_dict = []
@@ -1714,6 +2035,9 @@ def get_files():
1714
  child_dict = child.to_dict()
1715
  child_chunk_count = DocumentChunk.query.filter_by(file_id=child.id).count()
1716
  child_dict['chunk_count'] = child_chunk_count
 
 
 
1717
  child_files_dict.append(child_dict)
1718
  file_dict['child_files'] = child_files_dict
1719
  files_with_children.append(file_dict)
@@ -1811,6 +2135,63 @@ def get_file_parent_chunk(file_id):
1811
  except Exception as e:
1812
  return jsonify({'error': f'Parent Chunk 조회 중 오류가 발생했습니다: {str(e)}'}), 500
1813
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1814
  @main_bp.route('/api/files/<int:file_id>', methods=['DELETE'])
1815
  @login_required
1816
  def delete_file(file_id):
 
181
 
182
  return final_chunks if final_chunks else [text] if text.strip() else []
183
 
184
+ def extract_chapter_number(text):
185
+ """텍스트에서 챕터 번호 추출"""
186
+ # 다양한 챕터 패턴 매칭
187
+ patterns = [
188
+ r'제\s*(\d+)\s*장', # 제1장, 제 1 장
189
+ r'제\s*(\d+)\s*화', # 제1화
190
+ r'Chapter\s*(\d+)', # Chapter 1
191
+ r'CHAPTER\s*(\d+)', # CHAPTER 1
192
+ r'Ch\.\s*(\d+)', # Ch. 1
193
+ r'(\d+)\s*장', # 1장
194
+ r'(\d+)\s*화', # 1화
195
+ r'CHAPTER\s*(\d+)', # CHAPTER 1
196
+ r'chap\.\s*(\d+)', # chap. 1
197
+ r'ch\s*(\d+)', # ch 1
198
+ r'(\d+)\s*章', # 1章
199
+ ]
200
+
201
+ # 텍스트의 처음 500자만 검사 (챕터 정보는 보통 앞부분에 있음)
202
+ search_text = text[:500]
203
+
204
+ for pattern in patterns:
205
+ match = re.search(pattern, search_text, re.IGNORECASE)
206
+ if match:
207
+ try:
208
+ chapter_num = int(match.group(1))
209
+ return chapter_num
210
+ except:
211
+ continue
212
+
213
+ return None
214
+
215
+ def extract_metadata_with_ai(chunk_content, parent_chunk=None, model_name=None):
216
+ """AI를 사용하여 청크의 메타데이터 추출 (화자, 등장인물, 시간적 배경)"""
217
+ try:
218
+ # 간단한 추출을 위해 짧은 프롬프트 사용
219
+ prompt = f"""다음 웹소설 텍스트를 분석하여 아래 정보를 JSON 형식으로만 응답하세요:
220
+
221
+ 텍스트:
222
+ {chunk_content[:2000]}
223
+
224
+ 다음 형식으로만 응답하세요 (JSON 형식):
225
+ {{
226
+ "pov": "화자/시점을 설명하세요 (예: 1인칭 주인공, 3인칭 전지적 작가 등)",
227
+ "characters": ["등장인물1", "등장인물2"],
228
+ "time_background": "시간적 배경 설명 (예: 과거 회상, 현재 시점, 미래 등)"
229
+ }}
230
+
231
+ 응답은 오직 JSON 형식만 사용하고, 다른 설명은 포함하지 마세요."""
232
+
233
+ # 모델명이 없으면 기본값 사용 (Gemini 우선 시도)
234
+ if not model_name:
235
+ # Gemini 시도
236
+ try:
237
+ gemini_client = get_gemini_client()
238
+ if gemini_client.is_configured():
239
+ result = gemini_client.generate_response(
240
+ prompt=prompt,
241
+ model_name="gemini-1.5-flash",
242
+ temperature=0.3,
243
+ max_output_tokens=500
244
+ )
245
+ if not result['error'] and result.get('response'):
246
+ response_text = result['response'].strip()
247
+ # JSON 추출
248
+ json_match = re.search(r'\{.*\}', response_text, re.DOTALL)
249
+ if json_match:
250
+ metadata = json.loads(json_match.group(0))
251
+ return metadata
252
+ except:
253
+ pass
254
+
255
+ # 모델명이 있거나 Gemini 실패 시 해당 모델 사용
256
+ if model_name:
257
+ model_name_lower = model_name.lower().strip()
258
+ is_gemini = model_name_lower.startswith('gemini:') or model_name_lower.startswith('gemini-')
259
+
260
+ if is_gemini:
261
+ gemini_model_name = model_name.strip()
262
+ if gemini_model_name.lower().startswith('gemini:'):
263
+ gemini_model_name = gemini_model_name.split(':', 1)[1].strip()
264
+
265
+ gemini_client = get_gemini_client()
266
+ if gemini_client.is_configured():
267
+ result = gemini_client.generate_response(
268
+ prompt=prompt,
269
+ model_name=gemini_model_name,
270
+ temperature=0.3,
271
+ max_output_tokens=500
272
+ )
273
+ if not result['error'] and result.get('response'):
274
+ response_text = result['response'].strip()
275
+ json_match = re.search(r'\{.*\}', response_text, re.DOTALL)
276
+ if json_match:
277
+ metadata = json.loads(json_match.group(0))
278
+ return metadata
279
+ else:
280
+ # Ollama API 호출
281
+ try:
282
+ ollama_response = requests.post(
283
+ f'{OLLAMA_BASE_URL}/api/generate',
284
+ json={
285
+ 'model': model_name,
286
+ 'prompt': prompt,
287
+ 'stream': False,
288
+ 'options': {
289
+ 'temperature': 0.3,
290
+ 'num_predict': 500
291
+ }
292
+ },
293
+ timeout=30
294
+ )
295
+ if ollama_response.status_code == 200:
296
+ response_data = ollama_response.json()
297
+ response_text = response_data.get('response', '').strip()
298
+ json_match = re.search(r'\{.*\}', response_text, re.DOTALL)
299
+ if json_match:
300
+ metadata = json.loads(json_match.group(0))
301
+ return metadata
302
+ except:
303
+ pass
304
+
305
+ # AI 추출 실패 시 기본값 반환
306
+ return {
307
+ "pov": None,
308
+ "characters": [],
309
+ "time_background": None
310
+ }
311
+ except Exception as e:
312
+ print(f"[메타데이터 추출] 오류: {str(e)}")
313
+ return {
314
+ "pov": None,
315
+ "characters": [],
316
+ "time_background": None
317
+ }
318
+
319
+ def extract_chunk_metadata(chunk_content, full_content=None, chunk_index=None, file_id=None, model_name=None):
320
+ """청크의 메타데이터 추출 (챕터, 화자, 등장인물, 시간적 배경)"""
321
+ metadata = {
322
+ "chapter": None,
323
+ "pov": None,
324
+ "characters": [],
325
+ "time_background": None
326
+ }
327
+
328
+ # 1. 챕터 번호 추출 (정규식 기반)
329
+ chapter_num = extract_chapter_number(chunk_content)
330
+ if chapter_num:
331
+ metadata["chapter"] = chapter_num
332
+ elif full_content and chunk_index is not None:
333
+ # 청크 앞부분 컨텍스트에서 챕터 찾기
334
+ context_start = max(0, sum(len(c) for c in chunk_content.split('\n')[:10]))
335
+ if full_content and len(full_content) > context_start:
336
+ context = full_content[:context_start + 1000]
337
+ chapter_num = extract_chapter_number(context)
338
+ if chapter_num:
339
+ metadata["chapter"] = chapter_num
340
+
341
+ # 2. AI를 사용한 메타데이터 추출 (화자, 등장인물, 시간적 배경)
342
+ # Parent Chunk가 있으면 참조
343
+ parent_chunk = None
344
+ if file_id:
345
+ try:
346
+ parent_chunk = ParentChunk.query.filter_by(file_id=file_id).first()
347
+ except:
348
+ pass
349
+
350
+ ai_metadata = extract_metadata_with_ai(chunk_content, parent_chunk, model_name)
351
+ if ai_metadata:
352
+ metadata["pov"] = ai_metadata.get("pov")
353
+ metadata["characters"] = ai_metadata.get("characters", [])
354
+ metadata["time_background"] = ai_metadata.get("time_background")
355
+
356
+ return metadata
357
+
358
+ def create_chunks_for_file(file_id, content, extract_metadata=True):
359
+ """파일 내용을 의미 기반 청크로 분할하여 저장 (벡터 DB 포함 + 메타데이터 태그)
360
+
361
+ Args:
362
+ file_id: 파일 ID
363
+ content: 파일 내용
364
+ extract_metadata: 메타데이터 추출 여부 (기본값: True)
365
+ """
366
  try:
367
  print(f"[청크 생성] 파일 ID {file_id}에 대한 청크 생성 시작")
368
  print(f"[청크 생성] 원본 텍스트 길이: {len(content)}자")
369
+ print(f"[청크 생성] 메타데이터 추출: {'예' if extract_metadata else '아니오'}")
370
+
371
+ # 파일 정보 가져오기 (모델명 등)
372
+ uploaded_file = UploadedFile.query.get(file_id)
373
+ model_name = uploaded_file.model_name if uploaded_file else None
374
 
375
  # 벡터 DB 매니저 가져오기
376
  vector_db = get_vector_db()
 
397
  # 각 청크를 데이터베이스와 벡터 DB에 저장
398
  saved_count = 0
399
  vector_saved_count = 0
400
+ metadata_extracted_count = 0
401
+
402
+ if extract_metadata:
403
+ print(f"[청크 생성] 메타데이터 추출 시작 (AI 사용: {model_name is not None})...")
404
+ else:
405
+ print(f"[청크 생성] 메타데이터 추출 건너뜀 (사용자 선택)")
406
+
407
  for idx, chunk_content in enumerate(chunks):
408
  try:
409
+ # 메타데이터 추출 (옵션이 활성화된 경우에만)
410
+ metadata = None
411
+ metadata_json = None
412
+
413
+ if extract_metadata:
414
+ metadata = extract_chunk_metadata(
415
+ chunk_content=chunk_content,
416
+ full_content=content,
417
+ chunk_index=idx,
418
+ file_id=file_id,
419
+ model_name=model_name
420
+ )
421
+
422
+ # 메타데이터를 JSON 문자열로 변환
423
+ metadata_json = json.dumps(metadata, ensure_ascii=False) if metadata else None
424
+
425
+ if metadata and (metadata.get("chapter") or metadata.get("pov") or metadata.get("characters") or metadata.get("time_background")):
426
+ metadata_extracted_count += 1
427
+
428
+ # DB에 청크 저장 (메타데이터 포함)
429
  chunk = DocumentChunk(
430
  file_id=file_id,
431
  chunk_index=idx,
432
+ content=chunk_content,
433
+ chunk_metadata=metadata_json
434
  )
435
  db.session.add(chunk)
436
  db.session.flush() # ID 생성
 
448
 
449
  # 진행 상황 출력 (10개마다)
450
  if (idx + 1) % 10 == 0:
451
+ print(f"[청크 생성] 진행 중: {idx + 1}/{len(chunks)}개 청크 저장 중... (DB: {saved_count}, 벡터 DB: {vector_saved_count}, 메타데이터: {metadata_extracted_count})")
452
  except Exception as e:
453
  print(f"[청크 생성] 경고: 청크 {idx} 저장 중 오류: {str(e)}")
454
+ import traceback
455
+ traceback.print_exc()
456
  continue
457
 
458
  db.session.commit()
459
+ print(f"[청크 생성] 완료: {saved_count}개 청크가 데이터베이스에 저장되었습니다. (벡터 DB: {vector_saved_count}개, 메타데이터 추출: {metadata_extracted_count}개)")
460
 
461
  # 저장 확인
462
  verified_count = DocumentChunk.query.filter_by(file_id=file_id).count()
 
1200
  @main_bp.route('/api/ollama/models', methods=['GET'])
1201
  @login_required
1202
  def get_ollama_models():
1203
+ """Ollama 및 Gemini에서 사용 가능한 모델 목록 가져오기 (로컬 AI 모델은 학습된 웹소설이 있는 모델만 표시)"""
1204
  try:
1205
  all_models = []
1206
 
1207
+ # 1. Ollama 모델 목록 가져오기 (학습된 웹소설이 있는 모델만 필터링)
1208
  try:
1209
  response = requests.get(f'{OLLAMA_BASE_URL}/api/tags', timeout=5)
1210
  if response.status_code == 200:
1211
  data = response.json()
1212
+ ollama_models_raw = [model['name'] for model in data.get('models', [])]
1213
+
1214
+ # Ollama 모델에 대해 학습된 웹소설이 있는지 확인
1215
+ filtered_ollama_models = []
1216
+ for model_name in ollama_models_raw:
1217
+ # 해당 모델로 학습된 원본 파일이 있는지 확인 (parent_file_id가 None인 파일만)
1218
+ file_count = UploadedFile.query.filter_by(
1219
+ model_name=model_name,
1220
+ parent_file_id=None
1221
+ ).count()
1222
+
1223
+ if file_count > 0:
1224
+ filtered_ollama_models.append({'name': model_name, 'type': 'ollama'})
1225
+ print(f"[모델 목록] Ollama 모델 '{model_name}' - 학습된 웹소설 {file_count}개")
1226
+ else:
1227
+ print(f"[모델 목록] Ollama 모델 '{model_name}' - 학습된 웹소설 없음, 목록에서 제외")
1228
+
1229
+ all_models.extend(filtered_ollama_models)
1230
+ print(f"[모델 목록] Ollama 모델 {len(filtered_ollama_models)}개 추가 (전체 {len(ollama_models_raw)}개 중 {len(filtered_ollama_models)}개 필터링됨)")
1231
  except Exception as e:
1232
  print(f"[모델 목록] Ollama 모델 목록 조회 실패: {e}")
1233
 
1234
+ # 2. Gemini 모델 목록 가져오기 (학습된 웹소설이 있는 모델만 필터링)
1235
  try:
1236
  gemini_client = get_gemini_client()
1237
  if gemini_client.is_configured():
1238
  gemini_models = gemini_client.get_available_models()
1239
+
1240
+ # 각 Gemini 모델에 대해 학습된 웹소설이 있는지 확인
1241
+ filtered_gemini_models = []
1242
+ for model_name in gemini_models:
1243
+ full_model_name = f'gemini:{model_name}'
1244
+ # 해당 모델로 학습된 원본 파일이 있는지 확인 (parent_file_id가 None인 파일만)
1245
+ file_count = UploadedFile.query.filter_by(
1246
+ model_name=full_model_name,
1247
+ parent_file_id=None
1248
+ ).count()
1249
+
1250
+ if file_count > 0:
1251
+ filtered_gemini_models.append({'name': full_model_name, 'type': 'gemini'})
1252
+ print(f"[모델 목록] Gemini 모델 '{full_model_name}' - 학습된 웹소설 {file_count}개")
1253
+ else:
1254
+ print(f"[모델 목록] Gemini 모델 '{full_model_name}' - 학습된 웹소설 없음, 목록에서 제외")
1255
+
1256
+ all_models.extend(filtered_gemini_models)
1257
+ print(f"[모델 목록] Gemini 모델 {len(filtered_gemini_models)}개 추가 (전체 {len(gemini_models)}개 중 {len(filtered_gemini_models)}개 필터링됨)")
1258
  else:
1259
  print(f"[모델 목록] Gemini API 키가 설정되지 않아 Gemini 모델을 불러올 수 없습니다.")
1260
  except Exception as e:
 
1268
  except Exception as e:
1269
  return jsonify({'error': f'모델 목록을 가져오는 중 오류가 발생했습니다: {str(e)}', 'models': []}), 500
1270
 
1271
+ @main_bp.route('/api/admin/ollama/models', methods=['GET'])
1272
+ @admin_required
1273
+ def get_all_ollama_models():
1274
+ """관리자용: Ollama 및 Gemini에서 사용 가능한 모든 모델 목록 가져오기 (필터링 없이 전체 목록)"""
1275
+ try:
1276
+ all_models = []
1277
+
1278
+ # 1. Ollama 모델 목록 가져오기 (필터링 없이 전체 목록)
1279
+ try:
1280
+ response = requests.get(f'{OLLAMA_BASE_URL}/api/tags', timeout=5)
1281
+ if response.status_code == 200:
1282
+ data = response.json()
1283
+ ollama_models_raw = [model['name'] for model in data.get('models', [])]
1284
+
1285
+ # 필터링 없이 모든 Ollama 모델 추가
1286
+ for model_name in ollama_models_raw:
1287
+ # 각 모델의 학습된 웹소설 개수 확인 (정보 제공용)
1288
+ file_count = UploadedFile.query.filter_by(
1289
+ model_name=model_name,
1290
+ parent_file_id=None
1291
+ ).count()
1292
+
1293
+ all_models.append({
1294
+ 'name': model_name,
1295
+ 'type': 'ollama',
1296
+ 'file_count': file_count # 정보 제공용
1297
+ })
1298
+ print(f"[관리자 모델 목록] Ollama 모델 '{model_name}' - 학습된 웹소설 {file_count}개")
1299
+
1300
+ print(f"[관리자 모델 목록] Ollama 모델 {len(ollama_models_raw)}개 추가")
1301
+ except Exception as e:
1302
+ print(f"[관리자 모델 목록] Ollama 모델 목록 조회 실패: {e}")
1303
+
1304
+ # 2. Gemini 모델 목록 가져오기 (필터링 없이 전체 목록)
1305
+ try:
1306
+ gemini_client = get_gemini_client()
1307
+ if gemini_client.is_configured():
1308
+ gemini_models = gemini_client.get_available_models()
1309
+
1310
+ # 필터링 없이 모든 Gemini 모델 추가
1311
+ for model_name in gemini_models:
1312
+ full_model_name = f'gemini:{model_name}'
1313
+ # 각 모델의 학습된 웹소설 개수 확인 (정보 제공용)
1314
+ file_count = UploadedFile.query.filter_by(
1315
+ model_name=full_model_name,
1316
+ parent_file_id=None
1317
+ ).count()
1318
+
1319
+ all_models.append({
1320
+ 'name': full_model_name,
1321
+ 'type': 'gemini',
1322
+ 'file_count': file_count # 정보 제공용
1323
+ })
1324
+ print(f"[관리자 모델 목록] Gemini 모델 '{full_model_name}' - 학습된 웹소설 {file_count}개")
1325
+
1326
+ print(f"[관리자 모델 목록] Gemini 모델 {len(gemini_models)}개 추가")
1327
+ else:
1328
+ print(f"[관리자 모델 목록] Gemini API 키가 설정되지 않아 Gemini 모델을 불러올 수 없습니다.")
1329
+ except Exception as e:
1330
+ print(f"[관리자 모델 목록] Gemini 모델 목록 조회 실패: {e}")
1331
+
1332
+ if all_models:
1333
+ return jsonify({'models': all_models})
1334
+ else:
1335
+ return jsonify({'error': '사용 가능한 모델이 없습니다. Ollama가 실행 중인지, 또는 Gemini API 키가 설정되었는지 확인하세요.', 'models': []}), 500
1336
+
1337
+ except Exception as e:
1338
+ return jsonify({'error': f'모델 목록을 가져오는 중 오류가 발생했습니다: {str(e)}', 'models': []}), 500
1339
+
1340
  @main_bp.route('/api/chat', methods=['POST'])
1341
  @login_required
1342
  def chat():
 
1750
  file = request.files['file']
1751
  model_name = request.form.get('model_name', '').strip()
1752
  parent_file_id = request.form.get('parent_file_id', None) # 이어서 업로드할 경우 원본 파일 ID
1753
+ extract_metadata = request.form.get('extract_metadata', 'true').lower() == 'true' # 메타데이터 추출 여부 (기본값: true)
1754
 
1755
  log_print(f"[2/8] 파일 수신: {file.filename if file else 'None'}")
1756
  log_print(f"[2/8] 모델명: {model_name if model_name else 'None (비어있음)'}")
1757
  log_print(f"[2/8] 이어서 업로드: {parent_file_id if parent_file_id else '아니오'}")
1758
+ log_print(f"[2/8] 메타데이터 추출: {'예' if extract_metadata else '아니오'}")
1759
 
1760
  if file.filename == '':
1761
  error_msg = '파일명이 없습니다.'
 
1917
  log_print(f"[7/8] CP949 인코딩으로 파일 읽기 성공: {len(content)}자")
1918
 
1919
  # 청크 생성 및 저장
1920
+ log_print(f"[7/8] 청크 생성 함수 호출 중... (메타데이터 추출: {'예' if extract_metadata else '아니오'})")
1921
+ chunk_count = create_chunks_for_file(uploaded_file.id, content, extract_metadata=extract_metadata)
1922
 
1923
  if chunk_count > 0:
1924
  log_print(f"[7/8] ✅ 성공: 파일 {original_filename}을 {chunk_count}개의 청크로 분할했습니다.")
 
2024
  chunk_count = DocumentChunk.query.filter_by(file_id=file.id).count()
2025
  file_dict['chunk_count'] = chunk_count
2026
 
2027
+ # Parent Chunk 존재 여부 확인
2028
+ has_parent_chunk = ParentChunk.query.filter_by(file_id=file.id).first() is not None
2029
+ file_dict['has_parent_chunk'] = has_parent_chunk
2030
+
2031
  # 이어서 업로드된 파일들도 조회
2032
  child_files = UploadedFile.query.filter_by(parent_file_id=file.id).order_by(UploadedFile.uploaded_at.asc()).all()
2033
  child_files_dict = []
 
2035
  child_dict = child.to_dict()
2036
  child_chunk_count = DocumentChunk.query.filter_by(file_id=child.id).count()
2037
  child_dict['chunk_count'] = child_chunk_count
2038
+ # Child 파일도 Parent Chunk 확인
2039
+ child_has_parent_chunk = ParentChunk.query.filter_by(file_id=child.id).first() is not None
2040
+ child_dict['has_parent_chunk'] = child_has_parent_chunk
2041
  child_files_dict.append(child_dict)
2042
  file_dict['child_files'] = child_files_dict
2043
  files_with_children.append(file_dict)
 
2135
  except Exception as e:
2136
  return jsonify({'error': f'Parent Chunk 조회 중 오류가 발생했습니다: {str(e)}'}), 500
2137
 
2138
+ @main_bp.route('/api/files/<int:file_id>/parent-chunk', methods=['POST'])
2139
+ @login_required
2140
+ def create_file_parent_chunk(file_id):
2141
+ """파일의 Parent Chunk 수동 생성 (재생성)"""
2142
+ try:
2143
+ file = UploadedFile.query.filter_by(id=file_id, uploaded_by=current_user.id).first()
2144
+ if not file:
2145
+ return jsonify({'error': '파일을 찾을 수 없습니다.'}), 404
2146
+
2147
+ # 모델명 확인
2148
+ if not file.model_name:
2149
+ return jsonify({'error': '파일에 연결된 AI 모델이 없습니다. Parent Chunk를 생성할 수 없습니다.'}), 400
2150
+
2151
+ # 파일이 텍스트 파일인지 확인
2152
+ if not file.original_filename.lower().endswith(('.txt', '.md')):
2153
+ return jsonify({'error': 'Parent Chunk는 텍스트 파일(.txt, .md)에만 생성할 수 있습니다.'}), 400
2154
+
2155
+ # 파일 내용 읽기
2156
+ try:
2157
+ encoding = 'utf-8'
2158
+ try:
2159
+ with open(file.file_path, 'r', encoding=encoding) as f:
2160
+ content = f.read()
2161
+ except UnicodeDecodeError:
2162
+ with open(file.file_path, 'r', encoding='cp949') as f:
2163
+ content = f.read()
2164
+ except Exception as e:
2165
+ return jsonify({'error': f'파일을 읽을 수 없습니다: {str(e)}'}), 500
2166
+
2167
+ if not content or len(content.strip()) == 0:
2168
+ return jsonify({'error': '파일 내용이 비어있습니다.'}), 400
2169
+
2170
+ # Parent Chunk 생성
2171
+ print(f"[Parent Chunk 수동 생성] 파일 ID {file_id}에 대한 Parent Chunk 생성 시작")
2172
+ print(f"[Parent Chunk 수동 생성] 모델명: {file.model_name}")
2173
+ print(f"[Parent Chunk 수동 생성] 파일명: {file.original_filename}")
2174
+
2175
+ parent_chunk = create_parent_chunk_with_ai(file_id, content, file.model_name)
2176
+
2177
+ if parent_chunk:
2178
+ return jsonify({
2179
+ 'file_id': file_id,
2180
+ 'filename': file.original_filename,
2181
+ 'has_parent_chunk': True,
2182
+ 'parent_chunk': parent_chunk.to_dict(),
2183
+ 'message': 'Parent Chunk가 ��공적으로 생성되었습니다.'
2184
+ }), 200
2185
+ else:
2186
+ return jsonify({
2187
+ 'error': 'Parent Chunk 생성에 실패했습니다. 서버 로그를 확인하세요.',
2188
+ 'file_id': file_id,
2189
+ 'filename': file.original_filename
2190
+ }), 500
2191
+
2192
+ except Exception as e:
2193
+ return jsonify({'error': f'Parent Chunk 생성 중 오류가 발생했습니다: {str(e)}'}), 500
2194
+
2195
  @main_bp.route('/api/files/<int:file_id>', methods=['DELETE'])
2196
  @login_required
2197
  def delete_file(file_id):
download_exaone_model.py CHANGED
@@ -70,3 +70,7 @@ if __name__ == "__main__":
70
  else:
71
  print("\n다운로드에 실패했습니다.")
72
 
 
 
 
 
 
70
  else:
71
  print("\n다운로드에 실패했습니다.")
72
 
73
+
74
+
75
+
76
+
install_exaone_direct.py CHANGED
@@ -75,3 +75,7 @@ def main():
75
  if __name__ == "__main__":
76
  main()
77
 
 
 
 
 
 
75
  if __name__ == "__main__":
76
  main()
77
 
78
+
79
+
80
+
81
+
install_exaone_simple.py CHANGED
@@ -52,3 +52,7 @@ def main():
52
  if __name__ == "__main__":
53
  main()
54
 
 
 
 
 
 
52
  if __name__ == "__main__":
53
  main()
54
 
55
+
56
+
57
+
58
+
setup_remote.ps1 CHANGED
@@ -60,3 +60,7 @@ Write-Host ""
60
  Write-Host "=== 완료 ===" -ForegroundColor Green
61
 
62
 
 
 
 
 
 
60
  Write-Host "=== 완료 ===" -ForegroundColor Green
61
 
62
 
63
+
64
+
65
+
66
+
templates/admin_webnovels.html CHANGED
@@ -471,6 +471,18 @@
471
  </select>
472
  </div>
473
 
 
 
 
 
 
 
 
 
 
 
 
 
474
  <!-- 파일 업로드 -->
475
  <div class="file-upload-input-wrapper" id="fileUploadWrapper">
476
  <input type="file" id="fileInput" accept=".txt,.md,.pdf,.docx,.epub" multiple>
@@ -571,10 +583,10 @@
571
  const fileModelSelect = document.getElementById('fileModelSelect');
572
  const filesTableBody = document.getElementById('filesTableBody');
573
 
574
- // 모델 목록 로드
575
  async function loadModelsForFiles() {
576
  try {
577
- const response = await fetch('/api/ollama/models');
578
  const data = await response.json();
579
 
580
  fileModelSelect.innerHTML = '<option value="">모델을 선택하세요...</option>';
@@ -583,12 +595,21 @@
583
  data.models.forEach(model => {
584
  const option = document.createElement('option');
585
  option.value = model.name;
586
- option.textContent = model.name;
 
 
 
 
 
587
  fileModelSelect.appendChild(option);
588
  });
 
 
 
589
  }
590
  } catch (error) {
591
  console.error('모델 로드 오류:', error);
 
592
  }
593
  }
594
 
@@ -647,7 +668,10 @@
647
  <td>${uploadDate}</td>
648
  <td>
649
  <div class="file-actions">
650
- <button class="btn btn-primary" onclick="viewParentChunk(${file.id}, '${escapeHtml(file.original_filename)}')" style="padding: 4px 8px; font-size: 12px; margin-right: 4px;">Parent Chunk</button>
 
 
 
651
  <button class="btn btn-primary" onclick="continueUpload(${file.id})" style="padding: 4px 8px; font-size: 12px; margin-right: 4px;">이어서 업로드</button>
652
  <button class="btn btn-secondary" onclick="deleteFile(${file.id})" style="padding: 4px 8px; font-size: 12px;">삭제</button>
653
  </div>
@@ -752,6 +776,9 @@
752
  const formData = new FormData();
753
  formData.append('file', file);
754
  formData.append('model_name', modelName);
 
 
 
755
 
756
  // 이어서 업로드인 경우 parent_file_id 추가
757
  if (continueUploadFileId) {
@@ -1004,6 +1031,97 @@
1004
  }
1005
  }
1006
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1007
  // Parent Chunk 모달 닫기
1008
  function closeParentChunkModal() {
1009
  const modal = document.getElementById('parentChunkModal');
 
471
  </select>
472
  </div>
473
 
474
+ <!-- 메타데이터 추가 옵션 -->
475
+ <div style="margin-bottom: 16px; padding: 12px; background: #f8f9fa; border-radius: 6px; border: 1px solid #dadce0;">
476
+ <label style="display: flex; align-items: center; cursor: pointer; font-size: 14px;">
477
+ <input type="checkbox" id="extractMetadataCheckbox" checked style="margin-right: 8px; width: 18px; height: 18px; cursor: pointer;">
478
+ <span style="font-weight: 500;">청크에 메타데이터 추가</span>
479
+ </label>
480
+ <div style="margin-top: 8px; margin-left: 26px; font-size: 12px; color: #5f6368; line-height: 1.5;">
481
+ 체크 시 각 청크에 챕터 번호, 화자(POV), 등장인물, 시간적 배경 정보가 자동으로 추출되어 추가됩니다.<br>
482
+ <span style="color: #c5221f;">⚠️ 메타데이터 추출은 AI를 사용하므로 시간이 오래 걸릴 수 있습니다.</span>
483
+ </div>
484
+ </div>
485
+
486
  <!-- 파일 업로드 -->
487
  <div class="file-upload-input-wrapper" id="fileUploadWrapper">
488
  <input type="file" id="fileInput" accept=".txt,.md,.pdf,.docx,.epub" multiple>
 
583
  const fileModelSelect = document.getElementById('fileModelSelect');
584
  const filesTableBody = document.getElementById('filesTableBody');
585
 
586
+ // 모델 목록 로드 (관리자용: 모든 모델 표시)
587
  async function loadModelsForFiles() {
588
  try {
589
+ const response = await fetch('/api/admin/ollama/models');
590
  const data = await response.json();
591
 
592
  fileModelSelect.innerHTML = '<option value="">모델을 선택하세요...</option>';
 
595
  data.models.forEach(model => {
596
  const option = document.createElement('option');
597
  option.value = model.name;
598
+ // 학습된 웹소설 개수가 있으면 표시
599
+ const fileCount = model.file_count || 0;
600
+ const displayText = fileCount > 0
601
+ ? `${model.name} (${fileCount}개 학습됨)`
602
+ : model.name;
603
+ option.textContent = displayText;
604
  fileModelSelect.appendChild(option);
605
  });
606
+ } else if (data.error) {
607
+ console.error('모델 로드 오류:', data.error);
608
+ fileModelSelect.innerHTML = '<option value="">모델을 불러올 수 없습니다</option>';
609
  }
610
  } catch (error) {
611
  console.error('모델 로드 오류:', error);
612
+ fileModelSelect.innerHTML = '<option value="">모델 로드 실패</option>';
613
  }
614
  }
615
 
 
668
  <td>${uploadDate}</td>
669
  <td>
670
  <div class="file-actions">
671
+ ${(file.has_parent_chunk !== undefined && file.has_parent_chunk) ?
672
+ `<button class="btn btn-primary" onclick="viewParentChunk(${file.id}, '${escapeHtml(file.original_filename)}')" style="padding: 4px 8px; font-size: 12px; margin-right: 4px;">Parent Chunk</button>` :
673
+ `<button class="btn btn-secondary" onclick="createParentChunk(${file.id}, '${escapeHtml(file.original_filename)}')" style="padding: 4px 8px; font-size: 12px; margin-right: 4px;">Parent Chunk 생성</button>`
674
+ }
675
  <button class="btn btn-primary" onclick="continueUpload(${file.id})" style="padding: 4px 8px; font-size: 12px; margin-right: 4px;">이어서 업로드</button>
676
  <button class="btn btn-secondary" onclick="deleteFile(${file.id})" style="padding: 4px 8px; font-size: 12px;">삭제</button>
677
  </div>
 
776
  const formData = new FormData();
777
  formData.append('file', file);
778
  formData.append('model_name', modelName);
779
+ // 메타데이터 추출 옵션 추가
780
+ const extractMetadata = document.getElementById('extractMetadataCheckbox').checked;
781
+ formData.append('extract_metadata', extractMetadata ? 'true' : 'false');
782
 
783
  // 이어서 업로드인 경우 parent_file_id 추가
784
  if (continueUploadFileId) {
 
1031
  }
1032
  }
1033
 
1034
+ // Parent Chunk 수동 생성
1035
+ async function createParentChunk(fileId, fileName) {
1036
+ if (!confirm(`"${fileName}" 파일에 대해 Parent Chunk를 생성하시겠습니까?\n\n이 작업은 몇 분이 걸릴 수 있으며, AI 모델을 사용합니다.`)) {
1037
+ return;
1038
+ }
1039
+
1040
+ const modal = document.getElementById('parentChunkModal');
1041
+ const modalTitle = document.getElementById('parentChunkModalTitle');
1042
+ const modalBody = document.getElementById('parentChunkModalBody');
1043
+
1044
+ modalTitle.textContent = `Parent Chunk 생성 중 - ${fileName}`;
1045
+ modalBody.innerHTML = '<div class="parent-chunk-loading">Parent Chunk를 생성하고 있습니다. 이 작업은 몇 분이 걸릴 수 있습니다. 잠시만 기다려주세요...</div>';
1046
+ modal.classList.add('active');
1047
+
1048
+ try {
1049
+ const response = await fetch(`/api/files/${fileId}/parent-chunk`, {
1050
+ method: 'POST',
1051
+ headers: {
1052
+ 'Content-Type': 'application/json'
1053
+ }
1054
+ });
1055
+ const data = await response.json();
1056
+
1057
+ if (response.ok) {
1058
+ if (data.has_parent_chunk && data.parent_chunk) {
1059
+ // 생성 성공 - Parent Chunk 내용 표시
1060
+ const chunk = data.parent_chunk;
1061
+ let html = '<div style="color: #137333; margin-bottom: 16px; padding: 12px; background: #e8f5e9; border-radius: 6px;">✅ Parent Chunk가 성공적으로 생성되었습니다.</div>';
1062
+
1063
+ if (chunk.world_view) {
1064
+ html += `
1065
+ <div class="parent-chunk-section">
1066
+ <div class="parent-chunk-section-title">🌍 세계관 설명</div>
1067
+ <div class="parent-chunk-section-content">${escapeHtml(chunk.world_view)}</div>
1068
+ </div>
1069
+ `;
1070
+ }
1071
+
1072
+ if (chunk.characters) {
1073
+ html += `
1074
+ <div class="parent-chunk-section">
1075
+ <div class="parent-chunk-section-title">👥 주요 캐릭터 분석</div>
1076
+ <div class="parent-chunk-section-content">${escapeHtml(chunk.characters)}</div>
1077
+ </div>
1078
+ `;
1079
+ }
1080
+
1081
+ if (chunk.story) {
1082
+ html += `
1083
+ <div class="parent-chunk-section">
1084
+ <div class="parent-chunk-section-title">📖 주요 스토리 분석</div>
1085
+ <div class="parent-chunk-section-content">${escapeHtml(chunk.story)}</div>
1086
+ </div>
1087
+ `;
1088
+ }
1089
+
1090
+ if (chunk.episodes) {
1091
+ html += `
1092
+ <div class="parent-chunk-section">
1093
+ <div class="parent-chunk-section-title">📚 주요 에피소드 분석</div>
1094
+ <div class="parent-chunk-section-content">${escapeHtml(chunk.episodes)}</div>
1095
+ </div>
1096
+ `;
1097
+ }
1098
+
1099
+ if (chunk.others) {
1100
+ html += `
1101
+ <div class="parent-chunk-section">
1102
+ <div class="parent-chunk-section-title">📝 기타</div>
1103
+ <div class="parent-chunk-section-content">${escapeHtml(chunk.others)}</div>
1104
+ </div>
1105
+ `;
1106
+ }
1107
+
1108
+ modalTitle.textContent = `Parent Chunk 확인 - ${fileName}`;
1109
+ modalBody.innerHTML = html;
1110
+
1111
+ // 파일 목록 새로고침
1112
+ await loadFiles();
1113
+ } else {
1114
+ modalBody.innerHTML = '<div class="parent-chunk-empty" style="color: #c5221f;">Parent Chunk 생성에 실패했습니다. 서버 로그를 확인하세요.</div>';
1115
+ }
1116
+ } else {
1117
+ modalBody.innerHTML = `<div class="parent-chunk-empty" style="color: #c5221f;">오류: ${data.error || 'Parent Chunk 생성 중 오류가 발생했습니다.'}</div>`;
1118
+ }
1119
+ } catch (error) {
1120
+ modalBody.innerHTML = `<div class="parent-chunk-empty" style="color: #c5221f;">오류: ${error.message}</div>`;
1121
+ console.error('Parent Chunk 생성 오류:', error);
1122
+ }
1123
+ }
1124
+
1125
  // Parent Chunk 모달 닫기
1126
  function closeParentChunkModal() {
1127
  const modal = document.getElementById('parentChunkModal');
templates/index.html CHANGED
@@ -553,8 +553,14 @@
553
  white-space: pre-wrap;
554
  }
555
 
556
- .message-bubble strong {
557
- font-weight: 700;
 
 
 
 
 
 
558
  }
559
 
560
  .message.user .message-bubble {
@@ -568,6 +574,93 @@
568
  color: var(--text-primary);
569
  border-bottom-left-radius: 4px;
570
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
571
 
572
  .message-time {
573
  font-size: 12px;
@@ -1219,26 +1312,36 @@
1219
  }
1220
  });
1221
 
1222
- // Markdown 스타일 강조 표시를 HTML로 변환
1223
- function formatMessageText(text) {
1224
- if (!text) return '';
1225
 
1226
- // HTML 특수문자 이스케이프
1227
- let html = text
1228
- .replace(/&/g, '&amp;')
1229
- .replace(/</g, '&lt;')
1230
- .replace(/>/g, '&gt;');
1231
 
1232
- // **텍스트** 패턴을 <strong>텍스트</strong>로 변환
1233
- // 단, ** 사이에 내용이 있어야 함
1234
- html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
 
1235
 
1236
- // 줄바꿈 처리
1237
- html = html.replace(/\n/g, '<br>');
 
 
 
 
 
1238
 
1239
- return html;
1240
  }
1241
 
 
 
 
 
 
 
 
1242
  // 메시지 추가
1243
  function addMessage(role, content, save = true) {
1244
  // 빈 상태 숨기기
@@ -1258,7 +1361,28 @@
1258
 
1259
  const bubble = document.createElement('div');
1260
  bubble.className = 'message-bubble';
1261
- bubble.innerHTML = formatMessageText(content);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1262
 
1263
  const time = document.createElement('div');
1264
  time.className = 'message-time';
 
553
  white-space: pre-wrap;
554
  }
555
 
556
+ .message.ai .message-bubble {
557
+ white-space: normal;
558
+ }
559
+
560
+ .message.ai .message-bubble br {
561
+ display: block;
562
+ content: "";
563
+ margin: 0;
564
  }
565
 
566
  .message.user .message-bubble {
 
574
  color: var(--text-primary);
575
  border-bottom-left-radius: 4px;
576
  }
577
+
578
+ /* 각주 스타일 */
579
+ .footnote-ref {
580
+ font-size: 0.75em;
581
+ vertical-align: super;
582
+ color: var(--accent);
583
+ cursor: help;
584
+ text-decoration: underline;
585
+ text-decoration-style: dotted;
586
+ position: relative;
587
+ font-weight: 500;
588
+ display: inline-block;
589
+ }
590
+
591
+ .footnote-ref:hover {
592
+ color: var(--accent-hover);
593
+ }
594
+
595
+ /* 각주 툴팁 */
596
+ .footnote-tooltip {
597
+ position: absolute;
598
+ bottom: calc(100% + 8px);
599
+ left: 50%;
600
+ transform: translateX(-50%);
601
+ background: rgba(0, 0, 0, 0.95);
602
+ color: white;
603
+ padding: 8px 12px;
604
+ border-radius: 8px;
605
+ font-size: 0.75em;
606
+ max-width: 350px;
607
+ min-width: 200px;
608
+ word-wrap: break-word;
609
+ white-space: normal;
610
+ opacity: 0;
611
+ pointer-events: none;
612
+ transition: opacity 0.2s ease-in-out;
613
+ z-index: 10000;
614
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
615
+ line-height: 1.4;
616
+ text-align: left;
617
+ }
618
+
619
+ .footnote-tooltip::after {
620
+ content: '';
621
+ position: absolute;
622
+ top: 100%;
623
+ left: 50%;
624
+ transform: translateX(-50%);
625
+ border: 6px solid transparent;
626
+ border-top-color: rgba(0, 0, 0, 0.95);
627
+ }
628
+
629
+ .footnote-ref:hover .footnote-tooltip {
630
+ opacity: 1;
631
+ }
632
+
633
+ /* 각주 번호가 화면 위쪽에 있으면 툴팁을 아래쪽에 표시 */
634
+ .message-bubble {
635
+ position: relative;
636
+ }
637
+
638
+ /* 각주 목록 컨테이너 */
639
+ .footnotes-container {
640
+ margin-top: 12px;
641
+ padding-top: 12px;
642
+ border-top: 1px solid var(--border);
643
+ font-size: 0.85em;
644
+ color: var(--text-secondary);
645
+ }
646
+
647
+ .footnote-item {
648
+ margin-bottom: 6px;
649
+ line-height: 1.4;
650
+ display: flex;
651
+ align-items: flex-start;
652
+ gap: 6px;
653
+ }
654
+
655
+ .footnote-item sup {
656
+ font-size: 0.9em;
657
+ color: var(--accent);
658
+ font-weight: 500;
659
+ }
660
+
661
+ .footnote-item span {
662
+ flex: 1;
663
+ }
664
 
665
  .message-time {
666
  font-size: 12px;
 
1312
  }
1313
  });
1314
 
1315
+ // [근거: ] 형식을 각주로 변환하는 함수
1316
+ function formatContentWithFootnotes(content) {
1317
+ if (!content) return { formattedContent: '', footnotes: [] };
1318
 
1319
+ // 일반 텍스트를 HTML 변환 (줄바꿈 처리)
1320
+ let htmlContent = escapeHtml(content).replace(/\n/g, '<br>');
 
 
 
1321
 
1322
+ // [근거: 내용] 패턴 찾기
1323
+ const footnotePattern = /\[근거:\s*([^\]]+)\]/g;
1324
+ const footnotes = [];
1325
+ let footnoteIndex = 0;
1326
 
1327
+ // [근거: ] 부분을 찾아서 각주 번호로 치환
1328
+ let formattedContent = htmlContent.replace(footnotePattern, (match, footnoteText) => {
1329
+ footnoteIndex++;
1330
+ const cleanText = footnoteText.trim();
1331
+ footnotes.push(cleanText);
1332
+ return `<sup class="footnote-ref" data-footnote="${footnoteIndex}">[${footnoteIndex}]<span class="footnote-tooltip">${escapeHtml(cleanText)}</span></sup>`;
1333
+ });
1334
 
1335
+ return { formattedContent, footnotes };
1336
  }
1337
 
1338
+ // HTML 이스케이프 헬퍼
1339
+ function escapeHtml(text) {
1340
+ const div = document.createElement('div');
1341
+ div.textContent = text;
1342
+ return div.innerHTML;
1343
+ }
1344
+
1345
  // 메시지 추가
1346
  function addMessage(role, content, save = true) {
1347
  // 빈 상태 숨기기
 
1361
 
1362
  const bubble = document.createElement('div');
1363
  bubble.className = 'message-bubble';
1364
+
1365
+ // AI 응답인 경우 [근거: ] 형식을 각주로 변환
1366
+ if (role === 'ai') {
1367
+ const { formattedContent, footnotes } = formatContentWithFootnotes(content);
1368
+ bubble.innerHTML = formattedContent;
1369
+
1370
+ // 각주가 있으면 각주 목록 추가
1371
+ if (footnotes.length > 0) {
1372
+ const footnoteContainer = document.createElement('div');
1373
+ footnoteContainer.className = 'footnotes-container';
1374
+ footnotes.forEach((note, index) => {
1375
+ const footnoteDiv = document.createElement('div');
1376
+ footnoteDiv.className = 'footnote-item';
1377
+ footnoteDiv.innerHTML = `<sup>[${index + 1}]</sup> <span>${escapeHtml(note)}</span>`;
1378
+ footnoteContainer.appendChild(footnoteDiv);
1379
+ });
1380
+ bubble.appendChild(footnoteContainer);
1381
+ }
1382
+ } else {
1383
+ // 사용자 메시지는 일반 텍스트
1384
+ bubble.textContent = content;
1385
+ }
1386
 
1387
  const time = document.createElement('div');
1388
  time.className = 'message-time';
templates/login.html CHANGED
@@ -160,3 +160,7 @@
160
  </html>
161
 
162
 
 
 
 
 
 
160
  </html>
161
 
162
 
163
+
164
+
165
+
166
+