SOY NV AI commited on
Commit
04600a5
·
1 Parent(s): 9f9c12f

feat: 단계별 타임아웃 분리 및 토큰 관리 기능 개선 - 파일 업로드를 단계별로 분리 - 각 단계별 독립적인 타임아웃 설정 - 입력/출력/Parent Chunk 토큰 수 별도 관리 - admin/settings 페이지 개선 - 파일 업로드 진행 상황 메시지 개선 - 가로 스크롤 문제 해결

Browse files
EXAONE_설치_가이드.md CHANGED
@@ -154,4 +154,16 @@ tokenizer = AutoTokenizer.from_pretrained("LGAI-EXAONE/EXAONE-3.0-7.8B-Instruct"
154
 
155
 
156
 
 
 
 
 
 
 
 
 
 
 
 
 
157
 
 
154
 
155
 
156
 
157
+
158
+
159
+
160
+
161
+
162
+
163
+
164
+
165
+
166
+
167
+
168
+
169
 
EXAONE_추가_안내.md CHANGED
@@ -71,4 +71,16 @@ Ollama를 거치지 않고 Python에서 직접 Hugging Face 모델을 사용할
71
 
72
 
73
 
 
 
 
 
 
 
 
 
 
 
 
 
74
 
 
71
 
72
 
73
 
74
+
75
+
76
+
77
+
78
+
79
+
80
+
81
+
82
+
83
+
84
+
85
+
86
 
add_exaone_model.py CHANGED
@@ -149,4 +149,16 @@ if __name__ == "__main__":
149
 
150
 
151
 
 
 
 
 
 
 
 
 
 
 
 
 
152
 
 
149
 
150
 
151
 
152
+
153
+
154
+
155
+
156
+
157
+
158
+
159
+
160
+
161
+
162
+
163
+
164
 
app/__init__.py CHANGED
@@ -150,6 +150,24 @@ def migrate_database(app: Flask) -> None:
150
  conn.commit()
151
  logger.info("document_chunk.chunk_metadata 컬럼 추가 완료")
152
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
153
  conn.close()
154
  logger.info("데이터베이스 마이그레이션 완료")
155
 
 
150
  conn.commit()
151
  logger.info("document_chunk.chunk_metadata 컬럼 추가 완료")
152
 
153
+ # chat_session 테이블이 존재하는지 확인
154
+ cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='chat_session'")
155
+ if cursor.fetchone():
156
+ cursor.execute("PRAGMA table_info(chat_session)")
157
+ chat_session_columns = [column[1] for column in cursor.fetchall()]
158
+
159
+ if 'analysis_model' not in chat_session_columns:
160
+ logger.info("chat_session 테이블에 analysis_model 컬럼 추가 중...")
161
+ cursor.execute("ALTER TABLE chat_session ADD COLUMN analysis_model VARCHAR(100)")
162
+ conn.commit()
163
+ logger.info("chat_session.analysis_model 컬럼 추가 완료")
164
+
165
+ if 'answer_model' not in chat_session_columns:
166
+ logger.info("chat_session 테이블에 answer_model 컬럼 추가 중...")
167
+ cursor.execute("ALTER TABLE chat_session ADD COLUMN answer_model VARCHAR(100)")
168
+ conn.commit()
169
+ logger.info("chat_session.answer_model 컬럼 추가 완료")
170
+
171
  conn.close()
172
  logger.info("데이터베이스 마이그레이션 완료")
173
 
app/database.py CHANGED
@@ -70,7 +70,9 @@ class ChatSession(db.Model):
70
  id = db.Column(db.Integer, primary_key=True)
71
  user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
72
  title = db.Column(db.String(255), nullable=True)
73
- model_name = db.Column(db.String(100), nullable=True)
 
 
74
  created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
75
  updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
76
 
@@ -83,7 +85,9 @@ class ChatSession(db.Model):
83
  'id': self.id,
84
  'user_id': self.user_id,
85
  'title': self.title,
86
- 'model_name': self.model_name,
 
 
87
  'created_at': self.created_at.isoformat() if self.created_at else None,
88
  'updated_at': self.updated_at.isoformat() if self.updated_at else None,
89
  'message_count': len(self.messages)
 
70
  id = db.Column(db.Integer, primary_key=True)
71
  user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
72
  title = db.Column(db.String(255), nullable=True)
73
+ model_name = db.Column(db.String(100), nullable=True) # 하위 호환성을 위해 유지
74
+ analysis_model = db.Column(db.String(100), nullable=True) # 질문 분석용 모델
75
+ answer_model = db.Column(db.String(100), nullable=True) # 최종 답변용 모델
76
  created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
77
  updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
78
 
 
85
  'id': self.id,
86
  'user_id': self.user_id,
87
  'title': self.title,
88
+ 'model_name': self.model_name, # 하위 호환성
89
+ 'analysis_model': self.analysis_model,
90
+ 'answer_model': self.answer_model,
91
  'created_at': self.created_at.isoformat() if self.created_at else None,
92
  'updated_at': self.updated_at.isoformat() if self.updated_at else None,
93
  'message_count': len(self.messages)
app/routes.py CHANGED
@@ -31,6 +31,85 @@ def admin_required(f):
31
  # Ollama 기본 URL (환경 변수로 설정 가능)
32
  OLLAMA_BASE_URL = os.getenv('OLLAMA_BASE_URL', 'http://localhost:11434')
33
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
34
  # 업로드 설정
35
  UPLOAD_FOLDER = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'uploads')
36
  ALLOWED_EXTENSIONS = {'txt', 'md', 'pdf', 'docx', 'epub'}
@@ -357,7 +436,7 @@ character_relationships는 이 청크에 등장하는 인물들 간의 현재
357
  prompt=prompt,
358
  model_name="gemini-1.5-flash",
359
  temperature=0.3,
360
- max_output_tokens=500
361
  )
362
  if not result['error'] and result.get('response'):
363
  response_text = result['response'].strip()
@@ -385,7 +464,7 @@ character_relationships는 이 청크에 등장하는 인물들 간의 현재
385
  prompt=prompt,
386
  model_name=gemini_model_name,
387
  temperature=0.3,
388
- max_output_tokens=500
389
  )
390
  if not result['error'] and result.get('response'):
391
  response_text = result['response'].strip()
@@ -404,7 +483,7 @@ character_relationships는 이 청크에 등장하는 인물들 간의 현재
404
  'stream': False,
405
  'options': {
406
  'temperature': 0.3,
407
- 'num_predict': 500
408
  }
409
  },
410
  timeout=120 # 2분 타임아웃
@@ -526,6 +605,15 @@ def analyze_episode(episode_content, episode_title, full_content=None, parent_ch
526
  ## 인물과 인물간의 관계 변화
527
  [이 회차에서 인물들 간의 관계가 어떻게 변화했는지, 새로운 관계가 형성되었는지 등을 분석해주세요]
528
 
 
 
 
 
 
 
 
 
 
529
  ## 기타
530
  [이 회차의 특별한 점, 중요 사건, 떡밥, 복선 등 기타 중요한 내용을 분석해주세요]
531
 
@@ -541,7 +629,7 @@ def analyze_episode(episode_content, episode_title, full_content=None, parent_ch
541
  prompt=prompt,
542
  model_name="gemini-1.5-flash",
543
  temperature=0.5,
544
- max_output_tokens=2000
545
  )
546
  if not result['error'] and result.get('response'):
547
  return result['response'].strip()
@@ -564,7 +652,7 @@ def analyze_episode(episode_content, episode_title, full_content=None, parent_ch
564
  prompt=prompt,
565
  model_name=gemini_model_name,
566
  temperature=0.5,
567
- max_output_tokens=2000
568
  )
569
  if not result['error'] and result.get('response'):
570
  return result['response'].strip()
@@ -579,7 +667,7 @@ def analyze_episode(episode_content, episode_title, full_content=None, parent_ch
579
  'stream': False,
580
  'options': {
581
  'temperature': 0.5,
582
- 'num_predict': 2000
583
  }
584
  },
585
  timeout=300 # 5분 타임아웃 (회차 분석은 시간이 오래 걸릴 수 있음)
@@ -813,7 +901,7 @@ def extract_graph_from_episode(episode_content, episode_title, file_id, full_con
813
  db.session.rollback()
814
  return False
815
 
816
- def create_chunks_for_file(file_id, content):
817
  """파일 내용을 섹션별로 분할하여 의미 기반 청크로 저장 (벡터 DB 포함)
818
 
819
  섹션 분할 규칙:
@@ -823,6 +911,8 @@ def create_chunks_for_file(file_id, content):
823
  Args:
824
  file_id: 파일 ID
825
  content: 파일 내용
 
 
826
  """
827
  try:
828
  print(f"[청크 생성] 파일 ID {file_id}에 대한 청크 생성 시작")
@@ -865,7 +955,7 @@ def create_chunks_for_file(file_id, content):
865
 
866
  # '#작품설명'을 제외한 각 회차 분석
867
  episode_sections = [s for s in sections if s[0] != '작품설명'] # section_type이 '작품설명'이 아닌 것만
868
- if episode_sections and model_name:
869
  print(f"[회차 분석] {len(episode_sections)}개 회차 분석 시작...")
870
 
871
  # Parent Chunk 가져오기
@@ -915,31 +1005,32 @@ def create_chunks_for_file(file_id, content):
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:
@@ -1094,7 +1185,7 @@ def create_parent_chunk_with_ai(file_id, content, model_name):
1094
  prompt=analysis_prompt,
1095
  model_name=gemini_model_name,
1096
  temperature=0.7,
1097
- max_output_tokens=8192
1098
  )
1099
 
1100
  if result['error']:
@@ -1666,6 +1757,12 @@ def admin_prompts():
1666
  """프롬프트 관리 페이지"""
1667
  return render_template('admin_prompts.html')
1668
 
 
 
 
 
 
 
1669
  @main_bp.route('/admin/files')
1670
  @admin_required
1671
  def admin_files():
@@ -1764,6 +1861,7 @@ def get_all_messages():
1764
  try:
1765
  user_id = request.args.get('user_id', type=int)
1766
  session_id = request.args.get('session_id', type=int)
 
1767
  page = request.args.get('page', 1, type=int)
1768
  per_page = request.args.get('per_page', 50, type=int)
1769
 
@@ -1773,6 +1871,8 @@ def get_all_messages():
1773
  query = query.filter(ChatSession.user_id == user_id)
1774
  if session_id:
1775
  query = query.filter(ChatMessage.session_id == session_id)
 
 
1776
 
1777
  messages = query.order_by(ChatMessage.created_at.desc())\
1778
  .paginate(page=page, per_page=per_page, error_out=False)
@@ -1861,6 +1961,201 @@ def get_gemini_api_key():
1861
  traceback.print_exc()
1862
  return jsonify({'error': f'API 키 조회 중 오류가 발생했습니다: {str(e)}'}), 500
1863
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1864
  @main_bp.route('/api/admin/gemini-api-key', methods=['POST'])
1865
  @admin_required
1866
  def set_gemini_api_key():
@@ -1911,59 +2206,74 @@ def set_gemini_api_key():
1911
  def get_ollama_models():
1912
  """Ollama 및 Gemini에서 사용 가능한 모델 목록 가져오기 (로컬 AI 모델은 학습된 웹소설이 있는 모델만 표시)"""
1913
  try:
 
 
 
1914
  all_models = []
1915
 
1916
- # 1. Ollama 모델 목록 가져오기 (학습된 웹소설이 있는 모델만 필터링)
1917
  try:
1918
  response = requests.get(f'{OLLAMA_BASE_URL}/api/tags', timeout=5)
1919
  if response.status_code == 200:
1920
  data = response.json()
1921
  ollama_models_raw = [model['name'] for model in data.get('models', [])]
1922
 
1923
- # 각 Ollama 모델에 대해 학습된 웹소설이 있는지 확인
1924
- filtered_ollama_models = []
1925
- for model_name in ollama_models_raw:
1926
- # 해당 모델로 학습된 원본 파일이 있는지 확인 (parent_file_id가 None인 파일만)
1927
- file_count = UploadedFile.query.filter_by(
1928
- model_name=model_name,
1929
- parent_file_id=None
1930
- ).count()
 
 
 
 
 
 
 
 
 
 
 
 
1931
 
1932
- if file_count > 0:
1933
- filtered_ollama_models.append({'name': model_name, 'type': 'ollama'})
1934
- print(f"[모델 목록] Ollama 모델 '{model_name}' - 학습된 웹소설 {file_count}개")
1935
- else:
1936
- print(f"[모델 목록] Ollama 모델 '{model_name}' - 학습된 웹소설 없음, 목록에서 제외")
1937
-
1938
- all_models.extend(filtered_ollama_models)
1939
- print(f"[모델 목록] Ollama 모델 {len(filtered_ollama_models)}개 추가 (전체 {len(ollama_models_raw)}개 중 {len(filtered_ollama_models)}개 필터링됨)")
1940
  except Exception as e:
1941
  print(f"[모델 목록] Ollama 모델 목록 조회 실패: {e}")
1942
 
1943
- # 2. Gemini 모델 목록 가져오기 (학습된 웹소설이 있는 모델만 필터링)
1944
  try:
1945
  gemini_client = get_gemini_client()
1946
  if gemini_client.is_configured():
1947
  gemini_models = gemini_client.get_available_models()
1948
 
1949
- # 각 Gemini 모델에 대해 학습된 웹소설이 있는지 확인
1950
- filtered_gemini_models = []
1951
- for model_name in gemini_models:
1952
- full_model_name = f'gemini:{model_name}'
1953
- # 해당 모델로 학습된 원본 파일이 있는지 확인 (parent_file_id가 None인 파일만)
1954
- file_count = UploadedFile.query.filter_by(
1955
- model_name=full_model_name,
1956
- parent_file_id=None
1957
- ).count()
 
 
 
 
 
 
 
 
 
 
 
 
1958
 
1959
- if file_count > 0:
1960
- filtered_gemini_models.append({'name': full_model_name, 'type': 'gemini'})
1961
- print(f"[모델 목록] Gemini 모델 '{full_model_name}' - 학습된 웹소설 {file_count}개")
1962
- else:
1963
- print(f"[모델 목록] Gemini 모델 '{full_model_name}' - 학습된 웹소설 없음, 목록에서 제외")
1964
-
1965
- all_models.extend(filtered_gemini_models)
1966
- print(f"[모델 목록] Gemini 모델 {len(filtered_gemini_models)}개 추가 (전체 {len(gemini_models)}개 중 {len(filtered_gemini_models)}개 필터링됨)")
1967
  else:
1968
  print(f"[모델 목록] Gemini API 키가 설정되지 않아 Gemini 모델을 불러올 수 없습니다.")
1969
  except Exception as e:
@@ -2085,22 +2395,32 @@ def chat():
2085
  try:
2086
  data = request.json
2087
  message = data.get('message', '')
2088
- model = data.get('model', '')
 
 
2089
  file_ids = [int(fid) for fid in data.get('file_ids', []) if fid] # 선택한 웹소설 파일 ID 목록
2090
  session_id = data.get('session_id', None) # 대화 세션 ID (정수로 변환)
2091
 
2092
  if not message:
2093
  return jsonify({'error': '메시지가 필요합니다.'}), 400
2094
 
2095
- # 모델이 선택된 경우 Ollama 사용
2096
- if model:
 
 
 
 
 
 
 
 
2097
  try:
2098
  # RAG: 질문과 관련된 청크 검색
2099
  context = ""
2100
  use_rag = True # RAG 사용 여부
2101
 
2102
  if use_rag:
2103
- print(f"\n[RAG 검색] 모델: {model}, 질문: {message[:50]}...")
2104
  print(f"[RAG 검색] 선택된 파일 ID: {file_ids if file_ids else '없음 (모든 파일 검색)'}")
2105
 
2106
  # 1단계: 회차별 분석(EpisodeAnalysis) 조회 (회차별 요약 참조용)
@@ -2120,12 +2440,12 @@ def chat():
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,
2128
- model_name=model,
2129
  top_k=5, # 리랭킹 후 상위 5개만 선택
2130
  min_score=0.5 # 최소 점수 임계값
2131
  )
@@ -2421,13 +2741,13 @@ def chat():
2421
 
2422
  uploaded_files = UploadedFile.query.filter(
2423
  UploadedFile.id.in_(expanded_file_ids),
2424
- UploadedFile.model_name == model
2425
  ).all()
2426
  print(f"[파일 사용] 선택된 파일 ID로 조회 (이어서 업로드 포함): {len(uploaded_files)}개 파일")
2427
  else:
2428
  # 파일 ID가 없으면 해당 모델의 모든 파일 사용 (원본 및 이어서 업로드 포함)
2429
- uploaded_files = UploadedFile.query.filter_by(model_name=model).all()
2430
- print(f"[파일 사용] 모델 '{model}'의 모든 파일 사용: {len(uploaded_files)}개 파일")
2431
 
2432
  if uploaded_files:
2433
  print(f"[파일 사용] 사용되는 파일 목록:")
@@ -2490,12 +2810,18 @@ def chat():
2490
  if system_prompt:
2491
  print(f"[프롬프트] 시스템 프롬프트 적용: {len(system_prompt)}자")
2492
 
 
 
 
 
2493
  # 모델 타입 확인 (Gemini 또는 Ollama)
2494
- is_gemini = model.startswith('gemini:')
 
 
2495
 
2496
  if is_gemini:
2497
  # Gemini API 호출
2498
- gemini_model_name = model.replace('gemini:', '')
2499
  print(f"[Gemini] 모델: {gemini_model_name}, 질문: {message[:50]}...")
2500
 
2501
  gemini_client = get_gemini_client()
@@ -2506,19 +2832,22 @@ def chat():
2506
  prompt=full_prompt,
2507
  model_name=gemini_model_name,
2508
  temperature=0.7,
2509
- max_output_tokens=8192
2510
  )
2511
 
2512
  if result['error']:
2513
  return jsonify({'error': result['error']}), 500
2514
 
2515
- response_text = result['response']
 
 
 
2516
  else:
2517
  # Ollama API 호출
2518
  ollama_response = requests.post(
2519
  f'{OLLAMA_BASE_URL}/api/generate',
2520
  json={
2521
- 'model': model,
2522
  'prompt': full_prompt,
2523
  'stream': False
2524
  },
@@ -2533,13 +2862,16 @@ def chat():
2533
  error_detail = ollama_response.text[:200] if ollama_response.text else '상세 정보 없음'
2534
 
2535
  if ollama_response.status_code == 404:
2536
- error_msg = f'모델 "{model}"을(를) 찾을 수 없습니다. 모델이 Ollama에 설치되어 있는지 확인하세요. (오류: {error_detail})'
2537
  else:
2538
  error_msg = f'Ollama 서버 오류: {ollama_response.status_code} (오류: {error_detail})'
2539
  return jsonify({'error': error_msg}), ollama_response.status_code
2540
 
2541
  ollama_data = ollama_response.json()
2542
- response_text = ollama_data.get('response', '응답을 생성할 수 없습니다.')
 
 
 
2543
 
2544
  # 대화 세션에 메시지 저장 (Gemini와 Ollama 공통)
2545
  session_id = data.get('session_id')
@@ -2603,6 +2935,15 @@ def chat():
2603
  )
2604
  db.session.add(ai_msg)
2605
 
 
 
 
 
 
 
 
 
 
2606
  session.updated_at = datetime.utcnow()
2607
  db.session.commit()
2608
 
@@ -2613,6 +2954,13 @@ def chat():
2613
  db.session.rollback()
2614
  session_dict = None
2615
 
 
 
 
 
 
 
 
2616
  response_data = {'response': response_text, 'session_id': session_id}
2617
  if session_dict:
2618
  response_data['session'] = session_dict
@@ -2827,88 +3175,45 @@ def upload_file():
2827
  db.session.flush() # ID를 얻기 위해 flush
2828
  log_print(f"[7/8] 데이터베이스 flush 완료, 파일 ID: {uploaded_file.id}")
2829
 
2830
- # 텍스트 파일인 경우 청크로 분할하여 저장 (RAG용)
 
 
 
 
 
 
 
 
 
 
 
 
2831
  if original_filename.lower().endswith(('.txt', '.md')):
2832
  try:
2833
- log_print(f"[7/8] 청크 생성 시작: {original_filename}")
2834
- log_print(f"[7/8] 파일 ID: {uploaded_file.id}")
2835
-
2836
- # 파일 내용 읽기
2837
  encoding = 'utf-8'
2838
  try:
2839
  with open(file_path, 'r', encoding=encoding) as f:
2840
  content = f.read()
2841
- log_print(f"[7/8] UTF-8 인코딩으로 파일 읽기 성공: {len(content)}자")
2842
  except UnicodeDecodeError:
2843
- log_print(f"[7/8] UTF-8 인코딩 실패, CP949 시도: {original_filename}")
2844
  with open(file_path, 'r', encoding='cp949') as f:
2845
  content = f.read()
2846
- log_print(f"[7/8] CP949 인코딩으로 파일 읽기 성공: {len(content)}자")
2847
-
2848
- # 1. Parent Chunk 생성 (AI 분석) - 먼저 생성
2849
- try:
2850
- log_print(f"[7/8] Parent Chunk 생성 시작 (AI 분석)...")
2851
- parent_chunk = create_parent_chunk_with_ai(uploaded_file.id, content, model_name)
2852
- if parent_chunk:
2853
- log_print(f"[7/8] ✅ Parent Chunk 생성 완료: {original_filename}")
2854
- print(f"Parent Chunk가 생성되었습니다: {original_filename}")
2855
- else:
2856
- log_print(f"[7/8] ⚠️ 경고: Parent Chunk 생성 실패: {original_filename}")
2857
- print(f"경고: Parent Chunk 생성에 실패했습니다: {original_filename}")
2858
- except Exception as parent_chunk_error:
2859
- # Parent Chunk 생성 실패해도 업로드는 계속 진행
2860
- log_print(f"[7/8] ⚠️ 경고: Parent Chunk 생성 중 예외 발생: {str(parent_chunk_error)}")
2861
- print(f"경고: Parent Chunk 생성 중 오류가 발생했습니다: {original_filename}")
2862
- import traceback
2863
- traceback.print_exc()
2864
-
2865
- # 2. Child Chunk 생성 및 저장 (섹션별 분할)
2866
- log_print(f"[8/8] Child Chunk 생성 함수 호출 중...")
2867
- chunk_count = create_chunks_for_file(uploaded_file.id, content)
2868
-
2869
- if chunk_count > 0:
2870
- log_print(f"[8/8] ✅ 성공: 파일 {original_filename}을 {chunk_count}개의 청크로 분할했습니다.")
2871
- print(f"파일 {original_filename}을 {chunk_count}개의 청크로 분할했습니다.")
2872
- else:
2873
- log_print(f"[8/8] ⚠️ 경고: 청크가 생성되지 않았습니다. (파일이 너무 짧거나 비어있을 수 있습니다.)")
2874
- print(f"경고: 파일 {original_filename}에 대한 청크가 생성되지 않았습니다.")
2875
 
 
 
 
 
 
2876
  except Exception as e:
2877
- error_msg = f"청크 생성 오류: {str(e)}"
2878
- log_print(f"[7/8] 오류: {error_msg}")
2879
- print(error_msg)
2880
- import traceback
2881
- traceback.print_exc()
2882
- # 청크 생성 실패해도 파일 업로드는 계속 진행 (경고만 표시)
2883
- log_print(f"[7/8] ⚠️ 경고: 청크 생성 실패했지만 파일 업로드는 계속 진행합니다.")
2884
-
2885
- # 최종 청크 개수 확인 및 저장
2886
- chunk_count = 0
2887
- if original_filename.lower().endswith(('.txt', '.md')):
2888
- chunk_count = DocumentChunk.query.filter_by(file_id=uploaded_file.id).count()
2889
- log_print(f"[8/8] 최종 청크 개수 확인: {chunk_count}개")
2890
-
2891
- db.session.commit()
2892
- log_print(f"[8/8] 데이터베이스 커밋 완료: {original_filename}")
2893
- log_print(f"[8/8] 연결된 모델: {model_name}")
2894
- log_print(f"[8/8] 생성된 청크 수: {chunk_count}")
2895
-
2896
- # 학습 상태 요약
2897
- if chunk_count > 0:
2898
- log_print(f"[8/8] ✅ AI 학습 준비 완료: {chunk_count}개 청크가 저장되어 RAG 검색에 사용 가능합니다.")
2899
- else:
2900
- log_print(f"[8/8] ⚠️ 경고: 청크가 생성되지 않아 RAG 검색이 불가능합니다.")
2901
- log_print(f"{'='*60}")
2902
- log_print(f"=== 파일 업로드 성공 ===")
2903
- log_print(f"{'='*60}\n")
2904
-
2905
- log_print(f"[8/8] 업로드 완료 - 파일: {original_filename}, 모델: {model_name}, 크기: {saved_file_size} bytes")
2906
 
2907
  return jsonify({
2908
  'message': f'파일이 성공적으로 업로드되었습니다. (모델: {model_name})',
2909
  'file': uploaded_file.to_dict(),
2910
  'model_name': model_name,
2911
- 'chunk_count': chunk_count
 
 
2912
  }), 200
2913
 
2914
  except Exception as e:
@@ -3303,6 +3608,227 @@ def create_file_parent_chunk(file_id):
3303
  except Exception as e:
3304
  return jsonify({'error': f'Parent Chunk 생성 중 오류가 발생했습니다: {str(e)}'}), 500
3305
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3306
  @main_bp.route('/api/files/<int:file_id>/metadata', methods=['POST'])
3307
  @login_required
3308
  def create_file_metadata(file_id):
@@ -3574,12 +4100,16 @@ def create_chat_session():
3574
  try:
3575
  data = request.json
3576
  title = data.get('title', '새 대화')
3577
- model_name = data.get('model_name', None)
 
 
3578
 
3579
  session = ChatSession(
3580
  user_id=current_user.id,
3581
  title=title,
3582
- model_name=model_name
 
 
3583
  )
3584
  db.session.add(session)
3585
  db.session.commit()
 
31
  # Ollama 기본 URL (환경 변수로 설정 가능)
32
  OLLAMA_BASE_URL = os.getenv('OLLAMA_BASE_URL', 'http://localhost:11434')
33
 
34
+ def get_model_token_limit(model_name, default_tokens=2000, token_type='output'):
35
+ """모델별 토큰 수 제한 가져오기 (하위 호환성을 위해 기본값은 출력 토큰)
36
+
37
+ Args:
38
+ model_name: AI 모델명 (예: "gemini-2.0-flash-exp", "gemini:gemini-2.0-flash-exp", "gemma2:9b")
39
+ default_tokens: 기본 토큰 수 (설정이 없을 때 사용)
40
+ token_type: 'input' 또는 'output' (기본값: 'output')
41
+
42
+ Returns:
43
+ 토큰 수 (정수)
44
+ """
45
+ return get_model_token_limit_by_type(model_name, default_tokens, token_type)
46
+
47
+ def get_model_token_limit_by_type(model_name, default_tokens=2000, token_type='output'):
48
+ """모델별 토큰 수 제한 가져오기 (입력/출력/Parent Chunk 구분)
49
+
50
+ Args:
51
+ model_name: AI 모델명 (예: "gemini-2.0-flash-exp", "gemini:gemini-2.0-flash-exp", "gemma2:9b")
52
+ default_tokens: 기본 토큰 수 (설정이 없을 때 사용)
53
+ token_type: 'input', 'output', 또는 'parent_chunk'
54
+
55
+ Returns:
56
+ 토큰 수 (정수)
57
+ """
58
+ if not model_name:
59
+ return default_tokens
60
+
61
+ try:
62
+ from app.database import SystemConfig
63
+
64
+ # 여러 형식의 모델명을 시도
65
+ # 1. 원본 모델명 그대로
66
+ # 2. Gemini 모델의 경우 "gemini:" 접두사 추가/제거 버전
67
+ # 3. Ollama 모델의 경우 그대로
68
+
69
+ model_name_clean = model_name.strip()
70
+ possible_keys = [model_name_clean]
71
+
72
+ # Gemini 모델 처리
73
+ if model_name_clean.startswith('gemini:'):
74
+ # "gemini:gemini-2.0-flash-exp" -> "gemini:gemini-2.0-flash-exp" (그대로)
75
+ # 또는 "gemini-2.0-flash-exp" (접두사 제거)
76
+ possible_keys.append(model_name_clean.replace('gemini:', '', 1))
77
+ elif model_name_clean.startswith('gemini-'):
78
+ # "gemini-2.0-flash-exp" -> "gemini:gemini-2.0-flash-exp" (접두사 추가)
79
+ possible_keys.append(f'gemini:{model_name_clean}')
80
+
81
+ # 각 가능한 키를 시도
82
+ for key in possible_keys:
83
+ # 새로운 형식: model_token_input_{model_name}, model_token_output_{model_name}, model_token_parent_chunk_{model_name}
84
+ config_key = f"model_token_{token_type}_{key}"
85
+ token_value = SystemConfig.get_config(config_key)
86
+ if token_value:
87
+ try:
88
+ token_int = int(token_value)
89
+ print(f"[get_model_token_limit_by_type] 모델 '{model_name}'의 {token_type} 토큰 수 {token_int} 사용 (키: {config_key})")
90
+ return token_int
91
+ except (ValueError, TypeError):
92
+ continue
93
+
94
+ # 하위 호환성: 기존 형식 model_token_{model_name}도 확인 (출력 토큰으로 간주)
95
+ if token_type == 'output':
96
+ old_config_key = f"model_token_{key}"
97
+ token_value = SystemConfig.get_config(old_config_key)
98
+ if token_value:
99
+ try:
100
+ token_int = int(token_value)
101
+ print(f"[get_model_token_limit_by_type] 모델 '{model_name}'의 출력 토큰 수 {token_int} 사용 (기존 키: {old_config_key})")
102
+ return token_int
103
+ except (ValueError, TypeError):
104
+ continue
105
+
106
+ # 설정이 없으면 기본값 사용
107
+ print(f"[get_model_token_limit_by_type] 모델 '{model_name}'의 {token_type} 토큰 수 설정이 없어 기본값 {default_tokens} 사용")
108
+ except Exception as e:
109
+ print(f"[get_model_token_limit_by_type] 오류: {e}")
110
+
111
+ return default_tokens
112
+
113
  # 업로드 설정
114
  UPLOAD_FOLDER = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'uploads')
115
  ALLOWED_EXTENSIONS = {'txt', 'md', 'pdf', 'docx', 'epub'}
 
436
  prompt=prompt,
437
  model_name="gemini-1.5-flash",
438
  temperature=0.3,
439
+ max_output_tokens=get_model_token_limit(model_name or "gemini-1.5-flash", 500) # 저장된 토큰 수 사용
440
  )
441
  if not result['error'] and result.get('response'):
442
  response_text = result['response'].strip()
 
464
  prompt=prompt,
465
  model_name=gemini_model_name,
466
  temperature=0.3,
467
+ max_output_tokens=get_model_token_limit(model_name or "gemini-1.5-flash", 500) # 저장된 토큰 수 사용
468
  )
469
  if not result['error'] and result.get('response'):
470
  response_text = result['response'].strip()
 
483
  'stream': False,
484
  'options': {
485
  'temperature': 0.3,
486
+ 'num_predict': get_model_token_limit(model_name, 500) # 저장된 토큰 수 사용
487
  }
488
  },
489
  timeout=120 # 2분 타임아웃
 
605
  ## 인물과 인물간의 관계 변화
606
  [이 회차에서 인물들 간의 관계가 어떻게 변화했는지, 새로운 관계가 형성되었는지 등을 분석해주세요]
607
 
608
+ ## {episode_title} 인물 외모 분석
609
+ [이 회차에 등장한 인물들의 외모, 체형, 얼굴 특징, 신체적 특징 등을 상세히 분석해주세요. 특히 새로 등장한 인물이나 외모가 변경된 인물에 대해 자세히 설명해주세요]
610
+
611
+ ## {episode_title} 인물 의복 분석
612
+ [이 회차에 등장한 인물들이 착용한 의복, 복장, 액세서리 등을 상세히 분석해주세요. 의복의 스타일, 색상, 특징, 상황에 맞는 복장인지 등을 분석해주세요]
613
+
614
+ ## {episode_title} 배경 분석
615
+ [이 회차의 배경이 되는 장소, 환경, 시간대, 분위기 등을 상세히 분석해주세요. 장소의 특징, 분위기, 시간적 배경, 날씨, 계절 등을 포함하여 분석해주세요]
616
+
617
  ## 기타
618
  [이 회차의 특별한 점, 중요 사건, 떡밥, 복선 등 기타 중요한 내용을 분석해주세요]
619
 
 
629
  prompt=prompt,
630
  model_name="gemini-1.5-flash",
631
  temperature=0.5,
632
+ max_output_tokens=get_model_token_limit("gemini-1.5-flash", 3000) # 저장된 토큰 수 사용
633
  )
634
  if not result['error'] and result.get('response'):
635
  return result['response'].strip()
 
652
  prompt=prompt,
653
  model_name=gemini_model_name,
654
  temperature=0.5,
655
+ max_output_tokens=get_model_token_limit(model_name, 3000) # 저장된 토큰 수 사용
656
  )
657
  if not result['error'] and result.get('response'):
658
  return result['response'].strip()
 
667
  'stream': False,
668
  'options': {
669
  'temperature': 0.5,
670
+ 'num_predict': get_model_token_limit(model_name, 3000) # 저장된 토큰 수 사용
671
  }
672
  },
673
  timeout=300 # 5분 타임아웃 (회차 분석은 시간이 오래 걸릴 수 있음)
 
901
  db.session.rollback()
902
  return False
903
 
904
+ def create_chunks_for_file(file_id, content, skip_episode_analysis=False, skip_graph_extraction=False):
905
  """파일 내용을 섹션별로 분할하여 의미 기반 청크로 저장 (벡터 DB 포함)
906
 
907
  섹션 분할 규칙:
 
911
  Args:
912
  file_id: 파일 ID
913
  content: 파일 내용
914
+ skip_episode_analysis: 회차 분석 건너뛰기 (기본값: False)
915
+ skip_graph_extraction: Graph Extraction 건너뛰기 (기본값: False)
916
  """
917
  try:
918
  print(f"[청크 생성] 파일 ID {file_id}에 대한 청크 생성 시작")
 
955
 
956
  # '#작품설명'을 제외한 각 회차 분석
957
  episode_sections = [s for s in sections if s[0] != '작품설명'] # section_type이 '작품설명'이 아닌 것만
958
+ if episode_sections and model_name and not skip_episode_analysis:
959
  print(f"[회차 분석] {len(episode_sections)}개 회차 분석 시작...")
960
 
961
  # Parent Chunk 가져오기
 
1005
  print(f"[회차 분석] 완료: {len(episode_sections)}개 회차 분석 ���과를 하나의 텍스트로 저장")
1006
 
1007
  # 회차별 Graph Extraction 실행
1008
+ if not skip_graph_extraction:
1009
+ print(f"[Graph Extraction] 회차별 Graph Extraction 시작...")
1010
+ graph_extraction_success_count = 0
1011
+ for section_type, section_title, section_content, section_metadata in episode_sections:
1012
+ try:
1013
+ print(f"[Graph Extraction] '{section_title}' Graph Extraction 중...")
1014
+ success = extract_graph_from_episode(
1015
+ episode_content=section_content,
1016
+ episode_title=section_title,
1017
+ file_id=file_id,
1018
+ full_content=content,
1019
+ parent_chunk=parent_chunk,
1020
+ model_name=model_name
1021
+ )
1022
+ if success:
1023
+ graph_extraction_success_count += 1
1024
+ print(f"[Graph Extraction] '{section_title}' Graph Extraction 완료")
1025
+ else:
1026
+ print(f"[Graph Extraction] '{section_title}' Graph Extraction 실패")
1027
+ except Exception as e:
1028
+ print(f"[Graph Extraction] '{section_title}' Graph Extraction 중 오류: {str(e)}")
1029
+ import traceback
1030
+ traceback.print_exc()
1031
+ continue
1032
+
1033
+ print(f"[Graph Extraction] 완료: {graph_extraction_success_count}/{len(episode_sections)}개 회차 Graph Extraction 성공")
1034
  else:
1035
  print(f"[회차 분석] 경고: 분석 결과가 없습니다.")
1036
  else:
 
1185
  prompt=analysis_prompt,
1186
  model_name=gemini_model_name,
1187
  temperature=0.7,
1188
+ max_output_tokens=get_model_token_limit_by_type(model_name or "gemini-1.5-flash", 8192, 'parent_chunk') # Parent Chunk 전용 토큰 수 사용
1189
  )
1190
 
1191
  if result['error']:
 
1757
  """프롬프트 관리 페이지"""
1758
  return render_template('admin_prompts.html')
1759
 
1760
+ @main_bp.route('/admin/settings')
1761
+ @admin_required
1762
+ def admin_settings():
1763
+ """AI 설정 관리 페이지 (API 키, 토큰 수)"""
1764
+ return render_template('admin_settings.html')
1765
+
1766
  @main_bp.route('/admin/files')
1767
  @admin_required
1768
  def admin_files():
 
1861
  try:
1862
  user_id = request.args.get('user_id', type=int)
1863
  session_id = request.args.get('session_id', type=int)
1864
+ message_id = request.args.get('message_id', type=int)
1865
  page = request.args.get('page', 1, type=int)
1866
  per_page = request.args.get('per_page', 50, type=int)
1867
 
 
1871
  query = query.filter(ChatSession.user_id == user_id)
1872
  if session_id:
1873
  query = query.filter(ChatMessage.session_id == session_id)
1874
+ if message_id:
1875
+ query = query.filter(ChatMessage.id == message_id)
1876
 
1877
  messages = query.order_by(ChatMessage.created_at.desc())\
1878
  .paginate(page=page, per_page=per_page, error_out=False)
 
1961
  traceback.print_exc()
1962
  return jsonify({'error': f'API 키 조회 중 오류가 발생했습니다: {str(e)}'}), 500
1963
 
1964
+ @main_bp.route('/api/admin/model-tokens', methods=['GET'])
1965
+ @admin_required
1966
+ def get_model_tokens():
1967
+ """모든 모델의 토큰 수 설정 조회 (입력/출력 분리)"""
1968
+ try:
1969
+ # Ollama 모델 목록 가져오기
1970
+ ollama_models = []
1971
+ try:
1972
+ response = requests.get(f'{OLLAMA_BASE_URL}/api/tags', timeout=5)
1973
+ if response.status_code == 200:
1974
+ data = response.json()
1975
+ ollama_models = [model['name'] for model in data.get('models', [])]
1976
+ except:
1977
+ pass
1978
+
1979
+ # Gemini 모델 목록 가져오기
1980
+ gemini_models = []
1981
+ try:
1982
+ gemini_client = get_gemini_client()
1983
+ if gemini_client.is_configured():
1984
+ gemini_models = gemini_client.get_available_models()
1985
+ gemini_models = [f'gemini:{m}' for m in gemini_models]
1986
+ except:
1987
+ pass
1988
+
1989
+ # 모든 모델 목록
1990
+ all_models = ollama_models + gemini_models
1991
+
1992
+ # 각 모델의 토큰 수 설정 가져오기 (입력/출력/Parent Chunk 분리)
1993
+ model_input_tokens = {}
1994
+ model_output_tokens = {}
1995
+ model_parent_chunk_tokens = {}
1996
+ default_input_tokens = {}
1997
+ default_output_tokens = {}
1998
+ default_parent_chunk_tokens = {}
1999
+
2000
+ # 모델별 기본값 결정
2001
+ def get_default_token_for_model(model_name, token_type='output'):
2002
+ """모델별 기본 토큰 수 결정"""
2003
+ if not model_name:
2004
+ if token_type == 'parent_chunk':
2005
+ return 8192
2006
+ return 2000 if token_type == 'output' else 100000
2007
+
2008
+ # Gemini 모델의 경우
2009
+ if model_name.startswith('gemini:'):
2010
+ if token_type == 'parent_chunk':
2011
+ return 8192 # Parent Chunk 기본값
2012
+ return 2000 if token_type == 'output' else 100000 # Gemini 입력 기본값은 더 큼
2013
+
2014
+ # Ollama 모델의 경우
2015
+ if token_type == 'parent_chunk':
2016
+ return 8192 # Parent Chunk 기본값
2017
+ return 2000 if token_type == 'output' else 100000 # Ollama 입력 기본값도 더 큼
2018
+
2019
+ for model_name in all_models:
2020
+ # 입력 토큰 설정 가져오기
2021
+ input_config_key = f"model_token_input_{model_name}"
2022
+ input_token_value = SystemConfig.get_config(input_config_key)
2023
+ default_input_token = get_default_token_for_model(model_name, 'input')
2024
+ default_input_tokens[model_name] = default_input_token
2025
+
2026
+ if input_token_value:
2027
+ try:
2028
+ model_input_tokens[model_name] = int(input_token_value)
2029
+ except (ValueError, TypeError):
2030
+ model_input_tokens[model_name] = None
2031
+ else:
2032
+ model_input_tokens[model_name] = None
2033
+
2034
+ # 출력 토큰 설정 가져오기
2035
+ output_config_key = f"model_token_output_{model_name}"
2036
+ output_token_value = SystemConfig.get_config(output_config_key)
2037
+ # 하위 호환성: 기존 형식도 확인
2038
+ if not output_token_value:
2039
+ old_config_key = f"model_token_{model_name}"
2040
+ output_token_value = SystemConfig.get_config(old_config_key)
2041
+
2042
+ default_output_token = get_default_token_for_model(model_name, 'output')
2043
+ default_output_tokens[model_name] = default_output_token
2044
+
2045
+ if output_token_value:
2046
+ try:
2047
+ model_output_tokens[model_name] = int(output_token_value)
2048
+ except (ValueError, TypeError):
2049
+ model_output_tokens[model_name] = None
2050
+ else:
2051
+ model_output_tokens[model_name] = None
2052
+
2053
+ # Parent Chunk 토큰 설정 가져오기
2054
+ parent_chunk_config_key = f"model_token_parent_chunk_{model_name}"
2055
+ parent_chunk_token_value = SystemConfig.get_config(parent_chunk_config_key)
2056
+ default_parent_chunk_token = get_default_token_for_model(model_name, 'parent_chunk')
2057
+ default_parent_chunk_tokens[model_name] = default_parent_chunk_token
2058
+
2059
+ if parent_chunk_token_value:
2060
+ try:
2061
+ model_parent_chunk_tokens[model_name] = int(parent_chunk_token_value)
2062
+ except (ValueError, TypeError):
2063
+ model_parent_chunk_tokens[model_name] = None
2064
+ else:
2065
+ model_parent_chunk_tokens[model_name] = None
2066
+
2067
+ return jsonify({
2068
+ 'models': all_models,
2069
+ 'input_tokens': model_input_tokens,
2070
+ 'output_tokens': model_output_tokens,
2071
+ 'parent_chunk_tokens': model_parent_chunk_tokens,
2072
+ 'default_input_tokens': default_input_tokens,
2073
+ 'default_output_tokens': default_output_tokens,
2074
+ 'default_parent_chunk_tokens': default_parent_chunk_tokens
2075
+ }), 200
2076
+
2077
+ except Exception as e:
2078
+ return jsonify({'error': f'토큰 수 설정 조회 중 오류가 발생했습니다: {str(e)}'}), 500
2079
+
2080
+ @main_bp.route('/api/admin/model-tokens', methods=['POST'])
2081
+ @admin_required
2082
+ def save_model_tokens():
2083
+ """모델별 토큰 수 설정 저장 (입력/출력 분리, 또는 삭제)"""
2084
+ try:
2085
+ data = request.json
2086
+ model_name = data.get('model_name', '').strip()
2087
+ token_type = data.get('token_type', 'output').strip() # 'input' 또는 'output'
2088
+ tokens = data.get('tokens', None)
2089
+
2090
+ if not model_name:
2091
+ return jsonify({'error': '모델명을 입력해주세요.'}), 400
2092
+
2093
+ if token_type not in ['input', 'output', 'parent_chunk']:
2094
+ return jsonify({'error': '토큰 타입은 "input", "output", 또는 "parent_chunk"이어야 합니다.'}), 400
2095
+
2096
+ # tokens가 None이면 설정 삭제 (기본값 사용)
2097
+ if tokens is None:
2098
+ try:
2099
+ config_key = f"model_token_{token_type}_{model_name}"
2100
+ config = SystemConfig.query.filter_by(key=config_key).first()
2101
+ if config:
2102
+ db.session.delete(config)
2103
+ db.session.commit()
2104
+ return jsonify({
2105
+ 'message': f'{model_name} 모델의 {token_type} 토큰 수 설정이 삭제되었습니다. 기본값을 사용합니다.',
2106
+ 'model_name': model_name,
2107
+ 'token_type': token_type,
2108
+ 'tokens': None
2109
+ }), 200
2110
+ else:
2111
+ # 하위 호환성: 기존 형식도 삭제 시도 (출력 토큰인 경우)
2112
+ if token_type == 'output':
2113
+ old_config_key = f"model_token_{model_name}"
2114
+ old_config = SystemConfig.query.filter_by(key=old_config_key).first()
2115
+ if old_config:
2116
+ db.session.delete(old_config)
2117
+ db.session.commit()
2118
+ return jsonify({
2119
+ 'message': f'{model_name} 모델의 출력 토큰 수 설정이 삭제되었습니다. 기본값을 사용합니다.',
2120
+ 'model_name': model_name,
2121
+ 'token_type': token_type,
2122
+ 'tokens': None
2123
+ }), 200
2124
+ return jsonify({
2125
+ 'message': f'{model_name} 모델은 이미 기본값을 사용하고 있습니다.',
2126
+ 'model_name': model_name,
2127
+ 'token_type': token_type,
2128
+ 'tokens': None
2129
+ }), 200
2130
+ except Exception as e:
2131
+ db.session.rollback()
2132
+ return jsonify({'error': f'설정 삭제 중 오류가 발생했습니다: {str(e)}'}), 500
2133
+
2134
+ try:
2135
+ tokens = int(tokens)
2136
+ if tokens < 1:
2137
+ return jsonify({'error': '토큰 수는 1 이상이어야 합니다.'}), 400
2138
+ except (ValueError, TypeError):
2139
+ return jsonify({'error': '토큰 수는 정수여야 합니다.'}), 400
2140
+
2141
+ # SystemConfig에 저장
2142
+ config_key = f"model_token_{token_type}_{model_name}"
2143
+ SystemConfig.set_config(config_key, str(tokens), f'{model_name} 모델 {token_type} 토큰 수 제한')
2144
+
2145
+ return jsonify({
2146
+ 'message': f'{model_name} 모델의 {token_type} 토큰 수가 {tokens}로 설정되었습니다.',
2147
+ 'model_name': model_name,
2148
+ 'token_type': token_type,
2149
+ 'tokens': tokens
2150
+ }), 200
2151
+
2152
+ except Exception as e:
2153
+ db.session.rollback()
2154
+ print(f"[토큰 수 저장] 오류: {e}")
2155
+ import traceback
2156
+ traceback.print_exc()
2157
+ return jsonify({'error': f'토큰 수 저장 중 오류가 발생했습니다: {str(e)}'}), 500
2158
+
2159
  @main_bp.route('/api/admin/gemini-api-key', methods=['POST'])
2160
  @admin_required
2161
  def set_gemini_api_key():
 
2206
  def get_ollama_models():
2207
  """Ollama 및 Gemini에서 사용 가능한 모델 목록 가져오기 (로컬 AI 모델은 학습된 웹소설이 있는 모델만 표시)"""
2208
  try:
2209
+ # 쿼리 파라미터로 all=true가 전달되면 모든 모델 반환
2210
+ show_all = request.args.get('all', 'false').lower() == 'true'
2211
+
2212
  all_models = []
2213
 
2214
+ # 1. Ollama 모델 목록 가져오기
2215
  try:
2216
  response = requests.get(f'{OLLAMA_BASE_URL}/api/tags', timeout=5)
2217
  if response.status_code == 200:
2218
  data = response.json()
2219
  ollama_models_raw = [model['name'] for model in data.get('models', [])]
2220
 
2221
+ if show_all:
2222
+ # 모든 Ollama 모델 반환
2223
+ ollama_models = [{'name': model_name, 'type': 'ollama'} for model_name in ollama_models_raw]
2224
+ all_models.extend(ollama_models)
2225
+ print(f"[모델 목록] Ollama 모델 {len(ollama_models)}개 추가 (전체 목록)")
2226
+ else:
2227
+ # 학습된 웹소설이 있는 모델만 필터링
2228
+ filtered_ollama_models = []
2229
+ for model_name in ollama_models_raw:
2230
+ # 해당 모델로 학습된 원본 파일이 있는지 확인 (parent_file_id가 None인 파일만)
2231
+ file_count = UploadedFile.query.filter_by(
2232
+ model_name=model_name,
2233
+ parent_file_id=None
2234
+ ).count()
2235
+
2236
+ if file_count > 0:
2237
+ filtered_ollama_models.append({'name': model_name, 'type': 'ollama'})
2238
+ print(f"[모델 목록] Ollama 모델 '{model_name}' - 학습된 웹소설 {file_count}개")
2239
+ else:
2240
+ print(f"[모델 목록] Ollama 모델 '{model_name}' - 학습된 웹소설 없음, 목록에서 제외")
2241
 
2242
+ all_models.extend(filtered_ollama_models)
2243
+ print(f"[모델 목록] Ollama 모델 {len(filtered_ollama_models)}개 추가 (전체 {len(ollama_models_raw)}개 중 {len(filtered_ollama_models)}개 필터링됨)")
 
 
 
 
 
 
2244
  except Exception as e:
2245
  print(f"[모델 목록] Ollama 모델 목록 조회 실패: {e}")
2246
 
2247
+ # 2. Gemini 모델 목록 가져오기
2248
  try:
2249
  gemini_client = get_gemini_client()
2250
  if gemini_client.is_configured():
2251
  gemini_models = gemini_client.get_available_models()
2252
 
2253
+ if show_all:
2254
+ # 모든 Gemini 모델 반환
2255
+ gemini_models_list = [{'name': f'gemini:{model_name}', 'type': 'gemini'} for model_name in gemini_models]
2256
+ all_models.extend(gemini_models_list)
2257
+ print(f"[모델 목록] Gemini 모델 {len(gemini_models_list)}개 추가 (전체 목록)")
2258
+ else:
2259
+ # 학습된 웹소설이 있는 모델만 필터링
2260
+ filtered_gemini_models = []
2261
+ for model_name in gemini_models:
2262
+ full_model_name = f'gemini:{model_name}'
2263
+ # 해당 모델로 학습된 원본 파일이 있는지 확인 (parent_file_id가 None인 파일만)
2264
+ file_count = UploadedFile.query.filter_by(
2265
+ model_name=full_model_name,
2266
+ parent_file_id=None
2267
+ ).count()
2268
+
2269
+ if file_count > 0:
2270
+ filtered_gemini_models.append({'name': full_model_name, 'type': 'gemini'})
2271
+ print(f"[모델 목록] Gemini 모델 '{full_model_name}' - 학습된 웹소설 {file_count}개")
2272
+ else:
2273
+ print(f"[모델 목록] Gemini 모델 '{full_model_name}' - 학습된 웹소설 없음, 목록에서 제외")
2274
 
2275
+ all_models.extend(filtered_gemini_models)
2276
+ print(f"[모델 목록] Gemini 모델 {len(filtered_gemini_models)}개 추가 (전체 {len(gemini_models)}개 중 {len(filtered_gemini_models)}개 필터링됨)")
 
 
 
 
 
 
2277
  else:
2278
  print(f"[모델 목록] Gemini API 키가 설정되지 않아 Gemini 모델을 불러올 수 없습니다.")
2279
  except Exception as e:
 
2395
  try:
2396
  data = request.json
2397
  message = data.get('message', '')
2398
+ # 하위 호환성을 위해 model 확인 (기존 코드)
2399
+ analysis_model = data.get('analysis_model', data.get('model', '')) # 질문 분석용 모델
2400
+ answer_model = data.get('answer_model', '') # 최종 답변용 모델
2401
  file_ids = [int(fid) for fid in data.get('file_ids', []) if fid] # 선택한 웹소설 파일 ID 목록
2402
  session_id = data.get('session_id', None) # 대화 세션 ID (정수로 변환)
2403
 
2404
  if not message:
2405
  return jsonify({'error': '메시지가 필요합니다.'}), 400
2406
 
2407
+ # 답변용 모델이 없으면 분석용 모델 사용 (하위 호환성)
2408
+ if not answer_model:
2409
+ answer_model = analysis_model
2410
+
2411
+ # 답변용 모델이 여전히 없으면 에러 반환
2412
+ if not answer_model:
2413
+ return jsonify({'error': '답변을 생성할 AI 모델이 선택되지 않았습니다. "사용 가능한 AI 목록"에서 답변을 생성할 AI 모델을 선택해주세요.'}), 400
2414
+
2415
+ # 분석용 모델이 선택된 경우 RAG 검색 진행
2416
+ if analysis_model:
2417
  try:
2418
  # RAG: 질문과 관련된 청크 검색
2419
  context = ""
2420
  use_rag = True # RAG 사용 여부
2421
 
2422
  if use_rag:
2423
+ print(f"\n[RAG 검색] 분석 모델: {analysis_model}, 답변 모델: {answer_model}, 질문: {message[:50]}...")
2424
  print(f"[RAG 검색] 선택된 파일 ID: {file_ids if file_ids else '없음 (모든 파일 검색)'}")
2425
 
2426
  # 1단계: 회차별 분석(EpisodeAnalysis) 조회 (회차별 요약 참조용)
 
2440
  )
2441
  print(f"[RAG 검색 2단계] GraphRAG 데이터 조회 완료: 엔티티 {len(graph_data['entities'])}개, 관계 {len(graph_data['relationships'])}개, 사건 {len(graph_data['events'])}개")
2442
 
2443
+ # 3단계: 벡터 검색 + 리랭킹으로 Child Chunk 정밀 검색 (분석 모델 사용)
2444
+ print(f"[RAG 검색 3단계] 벡터 검색 + 리랭킹 시작 (분석 모델: {analysis_model})...")
2445
  relevant_chunks = search_relevant_chunks(
2446
  query=message,
2447
  file_ids=file_ids if file_ids else None,
2448
+ model_name=analysis_model, # 질문 분석은 analysis_model 사용
2449
  top_k=5, # 리랭킹 후 상위 5개만 선택
2450
  min_score=0.5 # 최소 점수 임계값
2451
  )
 
2741
 
2742
  uploaded_files = UploadedFile.query.filter(
2743
  UploadedFile.id.in_(expanded_file_ids),
2744
+ UploadedFile.model_name == analysis_model
2745
  ).all()
2746
  print(f"[파일 사용] 선택된 파일 ID로 조회 (이어서 업로드 포함): {len(uploaded_files)}개 파일")
2747
  else:
2748
  # 파일 ID가 없으면 해당 모델의 모든 파일 사용 (원본 및 이어서 업로드 포함)
2749
+ uploaded_files = UploadedFile.query.filter_by(model_name=analysis_model).all()
2750
+ print(f"[파일 사용] 모델 '{analysis_model}'의 모든 파일 사용: {len(uploaded_files)}개 파일")
2751
 
2752
  if uploaded_files:
2753
  print(f"[파일 사용] 사용되는 파일 목록:")
 
2810
  if system_prompt:
2811
  print(f"[프롬프트] 시스템 프롬프트 적용: {len(system_prompt)}자")
2812
 
2813
+ # 최종 답변 생성은 answer_model 사용
2814
+ if not answer_model:
2815
+ return jsonify({'error': '답변용 모델이 선택되지 않았습니다.'}), 400
2816
+
2817
  # 모델 타입 확인 (Gemini 또는 Ollama)
2818
+ is_gemini = answer_model.startswith('gemini:')
2819
+
2820
+ print(f"[최종 답변 생성] 답변 모델: {answer_model}, 프롬프트 길이: {len(full_prompt)}자")
2821
 
2822
  if is_gemini:
2823
  # Gemini API 호출
2824
+ gemini_model_name = answer_model.replace('gemini:', '')
2825
  print(f"[Gemini] 모델: {gemini_model_name}, 질문: {message[:50]}...")
2826
 
2827
  gemini_client = get_gemini_client()
 
2832
  prompt=full_prompt,
2833
  model_name=gemini_model_name,
2834
  temperature=0.7,
2835
+ max_output_tokens=get_model_token_limit(model_name or "gemini-1.5-flash", 8192) # 저장된 토큰 수 사용
2836
  )
2837
 
2838
  if result['error']:
2839
  return jsonify({'error': result['error']}), 500
2840
 
2841
+ response_text = result.get('response', '').strip()
2842
+ if not response_text:
2843
+ print(f"[채팅] Gemini 응답이 비어있습니다. result: {result}")
2844
+ response_text = '응답을 생성할 수 없었습니다. 다시 시도해주세요.'
2845
  else:
2846
  # Ollama API 호출
2847
  ollama_response = requests.post(
2848
  f'{OLLAMA_BASE_URL}/api/generate',
2849
  json={
2850
+ 'model': answer_model, # 답변 모델 사용
2851
  'prompt': full_prompt,
2852
  'stream': False
2853
  },
 
2862
  error_detail = ollama_response.text[:200] if ollama_response.text else '상세 정보 없음'
2863
 
2864
  if ollama_response.status_code == 404:
2865
+ error_msg = f'모델 "{answer_model}"을(를) 찾을 수 없습니다. 모델이 Ollama에 설치되어 있는지 확인하세요. (오류: {error_detail})'
2866
  else:
2867
  error_msg = f'Ollama 서버 오류: {ollama_response.status_code} (오류: {error_detail})'
2868
  return jsonify({'error': error_msg}), ollama_response.status_code
2869
 
2870
  ollama_data = ollama_response.json()
2871
+ response_text = ollama_data.get('response', '').strip()
2872
+ if not response_text:
2873
+ print(f"[채팅] Ollama 응답이 비어있습니다. ollama_data: {ollama_data}")
2874
+ response_text = '응답을 생성할 수 없었습니다. 다시 시도해주세요.'
2875
 
2876
  # 대화 세션에 메시지 저장 (Gemini와 Ollama 공통)
2877
  session_id = data.get('session_id')
 
2935
  )
2936
  db.session.add(ai_msg)
2937
 
2938
+ # 세션 모델 정보 업데이트 (첫 메시지인 경우 또는 변경된 경우)
2939
+ if not session.analysis_model or session.analysis_model != analysis_model:
2940
+ session.analysis_model = analysis_model
2941
+ if not session.answer_model or session.answer_model != answer_model:
2942
+ session.answer_model = answer_model
2943
+ # 하위 호환성을 위해 model_name도 업데이트
2944
+ if not session.model_name:
2945
+ session.model_name = answer_model or analysis_model
2946
+
2947
  session.updated_at = datetime.utcnow()
2948
  db.session.commit()
2949
 
 
2954
  db.session.rollback()
2955
  session_dict = None
2956
 
2957
+ # 응답이 비어있으면 기본 메시지 사용
2958
+ if not response_text or not response_text.strip():
2959
+ print(f"[채팅] 최종 응답이 비어있습니다. 기본 메시지를 사용합니다.")
2960
+ response_text = '응답을 생성할 수 없었습니다. 다시 시도해주세요.'
2961
+
2962
+ print(f"[채팅] 최종 응답 길이: {len(response_text)}자, 미리보기: {response_text[:100]}...")
2963
+
2964
  response_data = {'response': response_text, 'session_id': session_id}
2965
  if session_dict:
2966
  response_data['session'] = session_dict
 
3175
  db.session.flush() # ID를 얻기 위해 flush
3176
  log_print(f"[7/8] 데이터베이스 flush 완료, 파일 ID: {uploaded_file.id}")
3177
 
3178
+ # 파일 저장만 완료 (청크 생성은 별도 API로 처리)
3179
+ db.session.commit()
3180
+ log_print(f"[8/8] 데이터베이스 커밋 완료: {original_filename}")
3181
+ log_print(f"[8/8] 연결된 모델: {model_name}")
3182
+ log_print(f"{'='*60}")
3183
+ log_print(f"=== 파일 업로드 완료 (처리 대기 중) ===")
3184
+ log_print(f"{'='*60}\n")
3185
+
3186
+ log_print(f"[8/8] 업로드 완료 - 파일: {original_filename}, 모델: {model_name}, 크기: {saved_file_size} bytes")
3187
+ log_print(f"[8/8] 다음 단계: Parent Chunk 생성, Chunk 생성, 회차 분석, Graph Extraction을 별도로 진행합니다.")
3188
+
3189
+ # 회차 수 계산 (섹션 분할 결과 기반) - 파일 읽기 필요
3190
+ episode_count = 0
3191
  if original_filename.lower().endswith(('.txt', '.md')):
3192
  try:
 
 
 
 
3193
  encoding = 'utf-8'
3194
  try:
3195
  with open(file_path, 'r', encoding=encoding) as f:
3196
  content = f.read()
 
3197
  except UnicodeDecodeError:
 
3198
  with open(file_path, 'r', encoding='cp949') as f:
3199
  content = f.read()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3200
 
3201
+ sections = split_content_by_episodes(content)
3202
+ # '#작품설명'을 제외한 회차 수
3203
+ episode_sections = [s for s in sections if s[0] != '작품설명']
3204
+ episode_count = len(episode_sections)
3205
+ log_print(f"[8/8] 회차 수 계산: {episode_count}개 회차")
3206
  except Exception as e:
3207
+ log_print(f"[8/8] 회차 계산 오류: {str(e)}")
3208
+ episode_count = 0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3209
 
3210
  return jsonify({
3211
  'message': f'파일이 성공적으로 업로드되었습니다. (모델: {model_name})',
3212
  'file': uploaded_file.to_dict(),
3213
  'model_name': model_name,
3214
+ 'file_id': uploaded_file.id,
3215
+ 'episode_count': episode_count, # 회차 수 추가
3216
+ 'needs_processing': original_filename.lower().endswith(('.txt', '.md')) # 처리 필요 여부
3217
  }), 200
3218
 
3219
  except Exception as e:
 
3608
  except Exception as e:
3609
  return jsonify({'error': f'Parent Chunk 생성 중 오류가 발생했습니다: {str(e)}'}), 500
3610
 
3611
+ @main_bp.route('/api/files/<int:file_id>/process/parent-chunk', methods=['POST'])
3612
+ @login_required
3613
+ def process_parent_chunk(file_id):
3614
+ """단계 1: Parent Chunk 생성"""
3615
+ return create_file_parent_chunk(file_id)
3616
+
3617
+ @main_bp.route('/api/files/<int:file_id>/process/chunks', methods=['POST'])
3618
+ @login_required
3619
+ def process_chunks(file_id):
3620
+ """단계 2: Chunk 생성 (회차 분석, Graph Extraction 제외)"""
3621
+ try:
3622
+ file = UploadedFile.query.filter_by(id=file_id, uploaded_by=current_user.id).first()
3623
+ if not file:
3624
+ return jsonify({'error': '파일을 찾을 수 없습니다.'}), 404
3625
+
3626
+ if not file.original_filename.lower().endswith(('.txt', '.md')):
3627
+ return jsonify({'error': 'Chunk는 텍스트 파일(.txt, .md)에만 생성할 수 있습니다.'}), 400
3628
+
3629
+ # 파일 내용 읽기
3630
+ try:
3631
+ encoding = 'utf-8'
3632
+ try:
3633
+ with open(file.file_path, 'r', encoding=encoding) as f:
3634
+ content = f.read()
3635
+ except UnicodeDecodeError:
3636
+ with open(file.file_path, 'r', encoding='cp949') as f:
3637
+ content = f.read()
3638
+ except Exception as e:
3639
+ return jsonify({'error': f'파일을 읽을 수 없습니다: {str(e)}'}), 500
3640
+
3641
+ print(f"[단계 2: Chunk 생성] 파일 ID {file_id}에 대한 Chunk 생성 시작")
3642
+ chunk_count = create_chunks_for_file(file_id, content, skip_episode_analysis=True, skip_graph_extraction=True)
3643
+
3644
+ return jsonify({
3645
+ 'file_id': file_id,
3646
+ 'filename': file.original_filename,
3647
+ 'chunk_count': chunk_count,
3648
+ 'message': f'Chunk {chunk_count}개가 성공적으로 생성되었습니다.',
3649
+ 'step': 'chunks',
3650
+ 'completed': True
3651
+ }), 200
3652
+
3653
+ except Exception as e:
3654
+ return jsonify({'error': f'Chunk 생성 중 오류가 발생했습니다: {str(e)}', 'step': 'chunks'}), 500
3655
+
3656
+ @main_bp.route('/api/files/<int:file_id>/process/episode-analysis', methods=['POST'])
3657
+ @login_required
3658
+ def process_episode_analysis(file_id):
3659
+ """단계 3: 회차 분석"""
3660
+ try:
3661
+ file = UploadedFile.query.filter_by(id=file_id, uploaded_by=current_user.id).first()
3662
+ if not file:
3663
+ return jsonify({'error': '파일을 찾을 수 없습니다.'}), 404
3664
+
3665
+ if not file.model_name:
3666
+ return jsonify({'error': '파일에 연결된 AI 모델이 없습니다.'}), 400
3667
+
3668
+ if not file.original_filename.lower().endswith(('.txt', '.md')):
3669
+ return jsonify({'error': '회차 분석은 텍스트 파일(.txt, .md)에만 가능합니다.'}), 400
3670
+
3671
+ # 파일 내용 읽기
3672
+ try:
3673
+ encoding = 'utf-8'
3674
+ try:
3675
+ with open(file.file_path, 'r', encoding=encoding) as f:
3676
+ content = f.read()
3677
+ except UnicodeDecodeError:
3678
+ with open(file.file_path, 'r', encoding='cp949') as f:
3679
+ content = f.read()
3680
+ except Exception as e:
3681
+ return jsonify({'error': f'파일을 읽을 수 없습니다: {str(e)}'}), 500
3682
+
3683
+ # 섹션 분할
3684
+ sections = split_content_by_episodes(content)
3685
+ episode_sections = [s for s in sections if s[0] != '작품설명']
3686
+
3687
+ if not episode_sections:
3688
+ return jsonify({'error': '분석할 회차가 없습니다.'}), 400
3689
+
3690
+ # Parent Chunk 가져오기
3691
+ parent_chunk = None
3692
+ try:
3693
+ parent_chunk = ParentChunk.query.filter_by(file_id=file_id).first()
3694
+ except:
3695
+ pass
3696
+
3697
+ # 기존 회차 분석 삭제
3698
+ existing_analyses = EpisodeAnalysis.query.filter_by(file_id=file_id).all()
3699
+ if existing_analyses:
3700
+ for analysis in existing_analyses:
3701
+ db.session.delete(analysis)
3702
+ db.session.commit()
3703
+
3704
+ print(f"[단계 3: 회차 분석] 파일 ID {file_id}에 대한 회차 분석 시작 ({len(episode_sections)}개 회차)")
3705
+
3706
+ # 각 회차 분석
3707
+ all_analyses = []
3708
+ for section_type, section_title, section_content, section_metadata in episode_sections:
3709
+ try:
3710
+ print(f"[단계 3: 회차 분석] '{section_title}' 분석 중...")
3711
+ analysis_result = analyze_episode(
3712
+ episode_content=section_content,
3713
+ episode_title=section_title,
3714
+ full_content=content,
3715
+ parent_chunk=parent_chunk,
3716
+ model_name=file.model_name
3717
+ )
3718
+
3719
+ if analysis_result:
3720
+ all_analyses.append(f"\n\n{analysis_result}")
3721
+ print(f"[단계 3: 회차 분석] '{section_title}' 분석 완료")
3722
+ except Exception as e:
3723
+ print(f"[단계 3: 회차 분석] '{section_title}' 분석 중 오류: {str(e)}")
3724
+ continue
3725
+
3726
+ # 모든 회차 분석 결과를 하나의 텍스트로 저장
3727
+ if all_analyses:
3728
+ combined_analysis = "\n".join(all_analyses).strip()
3729
+ episode_analysis = EpisodeAnalysis(
3730
+ file_id=file_id,
3731
+ episode_title="전체 회차 통합 분석",
3732
+ analysis_content=combined_analysis
3733
+ )
3734
+ db.session.add(episode_analysis)
3735
+ db.session.commit()
3736
+
3737
+ return jsonify({
3738
+ 'file_id': file_id,
3739
+ 'filename': file.original_filename,
3740
+ 'episode_count': len(episode_sections),
3741
+ 'message': f'{len(episode_sections)}개 회차 분석이 완료되었습니다.',
3742
+ 'step': 'episode-analysis',
3743
+ 'completed': True
3744
+ }), 200
3745
+ else:
3746
+ return jsonify({
3747
+ 'error': '회차 분석 결과가 없습니다.',
3748
+ 'step': 'episode-analysis',
3749
+ 'completed': False
3750
+ }), 500
3751
+
3752
+ except Exception as e:
3753
+ db.session.rollback()
3754
+ return jsonify({'error': f'회차 분석 ��� 오류가 발생했습니다: {str(e)}', 'step': 'episode-analysis'}), 500
3755
+
3756
+ @main_bp.route('/api/files/<int:file_id>/process/graph', methods=['POST'])
3757
+ @login_required
3758
+ def process_graph(file_id):
3759
+ """단계 4: Graph Extraction"""
3760
+ try:
3761
+ file = UploadedFile.query.filter_by(id=file_id, uploaded_by=current_user.id).first()
3762
+ if not file:
3763
+ return jsonify({'error': '파일을 찾을 수 없습니다.'}), 404
3764
+
3765
+ if not file.model_name:
3766
+ return jsonify({'error': '파일에 연결된 AI 모델이 없습니다.'}), 400
3767
+
3768
+ if not file.original_filename.lower().endswith(('.txt', '.md')):
3769
+ return jsonify({'error': 'Graph Extraction은 텍스트 파일(.txt, .md)에만 가능합니다.'}), 400
3770
+
3771
+ # 파일 내용 읽기
3772
+ try:
3773
+ encoding = 'utf-8'
3774
+ try:
3775
+ with open(file.file_path, 'r', encoding=encoding) as f:
3776
+ content = f.read()
3777
+ except UnicodeDecodeError:
3778
+ with open(file.file_path, 'r', encoding='cp949') as f:
3779
+ content = f.read()
3780
+ except Exception as e:
3781
+ return jsonify({'error': f'파일을 읽을 수 없습니다: {str(e)}'}), 500
3782
+
3783
+ # 섹션 분할
3784
+ sections = split_content_by_episodes(content)
3785
+ episode_sections = [s for s in sections if s[0] != '작품설명']
3786
+
3787
+ if not episode_sections:
3788
+ return jsonify({'error': 'Graph Extraction할 회차가 없습니다.'}), 400
3789
+
3790
+ # Parent Chunk 가져오기
3791
+ parent_chunk = None
3792
+ try:
3793
+ parent_chunk = ParentChunk.query.filter_by(file_id=file_id).first()
3794
+ except:
3795
+ pass
3796
+
3797
+ print(f"[단계 4: Graph Extraction] 파일 ID {file_id}에 대한 Graph Extraction 시작 ({len(episode_sections)}개 회차)")
3798
+
3799
+ # 각 회차 Graph Extraction
3800
+ success_count = 0
3801
+ for section_type, section_title, section_content, section_metadata in episode_sections:
3802
+ try:
3803
+ print(f"[단계 4: Graph Extraction] '{section_title}' Graph Extraction 중...")
3804
+ success = extract_graph_from_episode(
3805
+ episode_content=section_content,
3806
+ episode_title=section_title,
3807
+ file_id=file_id,
3808
+ full_content=content,
3809
+ parent_chunk=parent_chunk,
3810
+ model_name=file.model_name
3811
+ )
3812
+ if success:
3813
+ success_count += 1
3814
+ print(f"[단계 4: Graph Extraction] '{section_title}' Graph Extraction 완료")
3815
+ except Exception as e:
3816
+ print(f"[단계 4: Graph Extraction] '{section_title}' Graph Extraction 중 오류: {str(e)}")
3817
+ continue
3818
+
3819
+ return jsonify({
3820
+ 'file_id': file_id,
3821
+ 'filename': file.original_filename,
3822
+ 'episode_count': len(episode_sections),
3823
+ 'success_count': success_count,
3824
+ 'message': f'{success_count}/{len(episode_sections)}개 회차 Graph Extraction이 완료되었습니다.',
3825
+ 'step': 'graph',
3826
+ 'completed': True
3827
+ }), 200
3828
+
3829
+ except Exception as e:
3830
+ return jsonify({'error': f'Graph Extraction 중 오류가 발생했습니다: {str(e)}', 'step': 'graph'}), 500
3831
+
3832
  @main_bp.route('/api/files/<int:file_id>/metadata', methods=['POST'])
3833
  @login_required
3834
  def create_file_metadata(file_id):
 
4100
  try:
4101
  data = request.json
4102
  title = data.get('title', '새 대화')
4103
+ model_name = data.get('model_name', None) # 하위 호환성
4104
+ analysis_model = data.get('analysis_model', None)
4105
+ answer_model = data.get('answer_model', None)
4106
 
4107
  session = ChatSession(
4108
  user_id=current_user.id,
4109
  title=title,
4110
+ model_name=model_name, # 하위 호환성
4111
+ analysis_model=analysis_model,
4112
+ answer_model=answer_model
4113
  )
4114
  db.session.add(session)
4115
  db.session.commit()
download_exaone_model.py CHANGED
@@ -77,4 +77,16 @@ if __name__ == "__main__":
77
 
78
 
79
 
 
 
 
 
 
 
 
 
 
 
 
 
80
 
 
77
 
78
 
79
 
80
+
81
+
82
+
83
+
84
+
85
+
86
+
87
+
88
+
89
+
90
+
91
+
92
 
install_exaone_direct.py CHANGED
@@ -82,4 +82,16 @@ if __name__ == "__main__":
82
 
83
 
84
 
 
 
 
 
 
 
 
 
 
 
 
 
85
 
 
82
 
83
 
84
 
85
+
86
+
87
+
88
+
89
+
90
+
91
+
92
+
93
+
94
+
95
+
96
+
97
 
install_exaone_simple.py CHANGED
@@ -59,4 +59,16 @@ if __name__ == "__main__":
59
 
60
 
61
 
 
 
 
 
 
 
 
 
 
 
 
 
62
 
 
59
 
60
 
61
 
62
+
63
+
64
+
65
+
66
+
67
+
68
+
69
+
70
+
71
+
72
+
73
+
74
 
templates/admin.html CHANGED
@@ -449,6 +449,7 @@
449
  <a href="{{ url_for('main.admin_files') }}" class="btn btn-secondary">파일 목록</a>
450
  <a href="{{ url_for('main.admin_messages') }}" class="btn btn-secondary">메시지 확인</a>
451
  <a href="{{ url_for('main.admin_prompts') }}" class="btn btn-secondary">프롬프트 관리</a>
 
452
  <a href="{{ url_for('main.index') }}" class="btn btn-secondary">메인으로</a>
453
  <a href="{{ url_for('main.logout') }}" class="btn btn-secondary">로그아웃</a>
454
  </div>
@@ -462,27 +463,6 @@
462
 
463
  <div id="alertContainer"></div>
464
 
465
- <!-- Gemini API 키 설정 섹션 -->
466
- <div class="card">
467
- <div class="card-header">
468
- <div class="card-title">Gemini API 키 설정</div>
469
- </div>
470
- <div style="padding: 16px 0;">
471
- <div class="form-group">
472
- <label for="geminiApiKey">Gemini API 키</label>
473
- <div style="display: flex; gap: 8px;">
474
- <input type="password" id="geminiApiKey" placeholder="Gemini API 키를 입력하세요" style="flex: 1; padding: 8px 12px; border: 1px solid #dadce0; border-radius: 4px; font-size: 14px;">
475
- <button class="btn btn-primary" onclick="saveGeminiApiKey()">저장</button>
476
- <button class="btn btn-secondary" onclick="loadGeminiApiKey()">현재 상태 확인</button>
477
- </div>
478
- <small style="color: #5f6368; font-size: 12px; display: block; margin-top: 4px;">
479
- Google AI Studio(<a href="https://aistudio.google.com/app/apikey" target="_blank">https://aistudio.google.com/app/apikey</a>)에서 API 키를 발급받을 수 있습니다.
480
- </small>
481
- <div id="geminiApiKeyStatus" style="margin-top: 8px; font-size: 13px;"></div>
482
- </div>
483
- </div>
484
- </div>
485
-
486
  <div class="card">
487
  <div class="card-header">
488
  <div class="card-title">사용자 목록</div>
@@ -691,87 +671,6 @@
691
  }
692
  });
693
 
694
- // Gemini API 키 관련 함수
695
- async function loadGeminiApiKey() {
696
- try {
697
- const response = await fetch('/api/admin/gemini-api-key');
698
-
699
- // 응답이 JSON인지 확인
700
- const contentType = response.headers.get('content-type');
701
- if (!contentType || !contentType.includes('application/json')) {
702
- const text = await response.text();
703
- console.error('Non-JSON response:', text.substring(0, 200));
704
- const statusDiv = document.getElementById('geminiApiKeyStatus');
705
- statusDiv.innerHTML = `<span style="color: #ea4335;">서버 오류: 응답 형식이 올바르지 않습니다.</span>`;
706
- return;
707
- }
708
-
709
- const data = await response.json();
710
-
711
- const statusDiv = document.getElementById('geminiApiKeyStatus');
712
- if (!response.ok) {
713
- statusDiv.innerHTML = `<span style="color: #ea4335;">오류: ${data.error || '알 수 없는 오류'}</span>`;
714
- return;
715
- }
716
-
717
- if (data.has_api_key) {
718
- statusDiv.innerHTML = `<span style="color: #137333;">✓ API 키가 설정되어 있습니다 (${data.masked_key})</span>`;
719
- } else {
720
- statusDiv.innerHTML = `<span style="color: #ea4335;">⚠ API 키가 설정되지 않았습니다</span>`;
721
- }
722
- } catch (error) {
723
- console.error('Gemini API 키 로드 오류:', error);
724
- const statusDiv = document.getElementById('geminiApiKeyStatus');
725
- statusDiv.innerHTML = `<span style="color: #ea4335;">오류: ${error.message}</span>`;
726
- }
727
- }
728
-
729
- async function saveGeminiApiKey() {
730
- const apiKeyInput = document.getElementById('geminiApiKey');
731
- const apiKey = apiKeyInput.value.trim();
732
-
733
- if (!apiKey) {
734
- showAlert('API 키를 입력해주세요.', 'error');
735
- return;
736
- }
737
-
738
- try {
739
- const response = await fetch('/api/admin/gemini-api-key', {
740
- method: 'POST',
741
- headers: {
742
- 'Content-Type': 'application/json',
743
- },
744
- body: JSON.stringify({ api_key: apiKey })
745
- });
746
-
747
- // 응답이 JSON인지 확인
748
- const contentType = response.headers.get('content-type');
749
- if (!contentType || !contentType.includes('application/json')) {
750
- const text = await response.text();
751
- console.error('Non-JSON response:', text.substring(0, 200));
752
- showAlert('서버 오류: 응답 형식이 올바르지 않습니다.', 'error');
753
- return;
754
- }
755
-
756
- const data = await response.json();
757
-
758
- if (response.ok) {
759
- showAlert(data.message, 'success');
760
- apiKeyInput.value = ''; // 보안을 위해 입력 필드 초기화
761
- loadGeminiApiKey(); // 상태 업데이트
762
- } else {
763
- showAlert(data.error || 'API 키 저장 중 오류가 발생했습니다.', 'error');
764
- }
765
- } catch (error) {
766
- console.error('Gemini API 키 저장 오류:', error);
767
- showAlert(`오류: ${error.message}`, 'error');
768
- }
769
- }
770
-
771
- // 페이지 로드 시 API 키 상태 확인
772
- window.addEventListener('load', () => {
773
- loadGeminiApiKey();
774
- });
775
 
776
  </script>
777
  </body>
 
449
  <a href="{{ url_for('main.admin_files') }}" class="btn btn-secondary">파일 목록</a>
450
  <a href="{{ url_for('main.admin_messages') }}" class="btn btn-secondary">메시지 확인</a>
451
  <a href="{{ url_for('main.admin_prompts') }}" class="btn btn-secondary">프롬프트 관리</a>
452
+ <a href="{{ url_for('main.admin_settings') }}" class="btn btn-secondary">AI 설정</a>
453
  <a href="{{ url_for('main.index') }}" class="btn btn-secondary">메인으로</a>
454
  <a href="{{ url_for('main.logout') }}" class="btn btn-secondary">로그아웃</a>
455
  </div>
 
463
 
464
  <div id="alertContainer"></div>
465
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
466
  <div class="card">
467
  <div class="card-header">
468
  <div class="card-title">사용자 목록</div>
 
671
  }
672
  });
673
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
674
 
675
  </script>
676
  </body>
templates/admin_messages.html CHANGED
@@ -392,7 +392,8 @@
392
  <th>ID</th>
393
  <th>사용자</th>
394
  <th>제목</th>
395
- <th>모델</th>
 
396
  <th>메시지 수</th>
397
  <th>생성일</th>
398
  <th>수정일</th>
@@ -521,11 +522,15 @@
521
  return;
522
  }
523
 
 
 
 
524
  row.innerHTML = `
525
  <td>${session.id}</td>
526
  <td>${session.nickname || session.username || 'Unknown'}</td>
527
  <td>${session.title || '-'}</td>
528
- <td>${session.model_name || '-'}</td>
 
529
  <td>${session.message_count || 0}</td>
530
  <td>${createdDate}</td>
531
  <td>${updatedDate}</td>
@@ -536,7 +541,7 @@
536
  tbody.appendChild(row);
537
  });
538
  } else {
539
- tbody.innerHTML = '<tr><td colspan="8" style="text-align: center; padding: 20px; color: #5f6368;">대화 세션이 없습니다.</td></tr>';
540
  }
541
 
542
  // 페이지네이션
@@ -551,6 +556,9 @@
551
 
552
  // 메시지 목록 로드
553
  async function loadMessages(page = 1) {
 
 
 
554
  try {
555
  const sessionId = document.getElementById('sessionIdFilter').value;
556
 
@@ -561,17 +569,25 @@
561
  url += `&session_id=${selectedSessionId}`;
562
  }
563
 
 
564
  const response = await fetch(url);
 
 
 
 
 
 
565
  const data = await response.json();
 
566
 
567
- const tbody = document.getElementById('messagesTableBody');
568
  tbody.innerHTML = '';
569
 
570
  if (data.messages && data.messages.length > 0) {
 
571
  data.messages.forEach(msg => {
572
  const row = document.createElement('tr');
573
  const date = new Date(msg.created_at).toLocaleString('ko-KR');
574
- const contentPreview = msg.content.length > 100 ? msg.content.substring(0, 100) + '...' : msg.content;
575
 
576
  row.innerHTML = `
577
  <td>${msg.id}</td>
@@ -580,22 +596,33 @@
580
  <td class="message-content">${escapeHtml(contentPreview)}</td>
581
  <td>${date}</td>
582
  <td>
583
- <button class="btn btn-secondary" onclick="viewMessage(${msg.id}, '${msg.role}', ${JSON.stringify(msg.content).replace(/"/g, '&quot;')})" style="padding: 4px 8px; font-size: 12px;">상세 보기</button>
584
  </td>
585
  `;
 
 
 
 
586
  tbody.appendChild(row);
587
  });
588
  } else {
 
589
  tbody.innerHTML = '<tr><td colspan="6" style="text-align: center; padding: 20px; color: #5f6368;">메시지가 없습니다.</td></tr>';
590
  }
591
 
592
  // 페이지네이션
593
- updateMessagesPagination(data.current_page, data.pages);
594
- currentMessagesPage = data.current_page;
 
 
 
 
 
595
 
596
  } catch (error) {
 
597
  showAlert(`메시지 조회 오류: ${error.message}`, 'error');
598
- console.error('메시지 조회 오류:', error);
599
  }
600
  }
601
 
@@ -606,6 +633,40 @@
606
  loadMessages(1);
607
  }
608
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
609
  // 메시지 상세 보기
610
  function viewMessage(messageId, role, content) {
611
  const modal = document.getElementById('messageModal');
@@ -618,7 +679,7 @@
618
  <span class="message-item-role role-badge role-${role}">${role === 'user' ? '사용자' : 'AI'}</span>
619
  <span class="message-item-time">메시지 ID: ${messageId}</span>
620
  </div>
621
- <div class="message-item-content">${escapeHtml(content)}</div>
622
  </div>
623
  </div>
624
  `;
 
392
  <th>ID</th>
393
  <th>사용자</th>
394
  <th>제목</th>
395
+ <th>학습 모델</th>
396
+ <th>답변 모델</th>
397
  <th>메시지 수</th>
398
  <th>생성일</th>
399
  <th>수정일</th>
 
522
  return;
523
  }
524
 
525
+ const analysisModel = session.analysis_model || session.model_name || '-';
526
+ const answerModel = session.answer_model || session.model_name || '-';
527
+
528
  row.innerHTML = `
529
  <td>${session.id}</td>
530
  <td>${session.nickname || session.username || 'Unknown'}</td>
531
  <td>${session.title || '-'}</td>
532
+ <td>${analysisModel}</td>
533
+ <td>${answerModel}</td>
534
  <td>${session.message_count || 0}</td>
535
  <td>${createdDate}</td>
536
  <td>${updatedDate}</td>
 
541
  tbody.appendChild(row);
542
  });
543
  } else {
544
+ tbody.innerHTML = '<tr><td colspan="9" style="text-align: center; padding: 20px; color: #5f6368;">대화 세션이 없습니다.</td></tr>';
545
  }
546
 
547
  // 페이지네이션
 
556
 
557
  // 메시지 목록 로드
558
  async function loadMessages(page = 1) {
559
+ const tbody = document.getElementById('messagesTableBody');
560
+ tbody.innerHTML = '<tr><td colspan="6" style="text-align: center; padding: 20px; color: #5f6368;">로딩 중...</td></tr>';
561
+
562
  try {
563
  const sessionId = document.getElementById('sessionIdFilter').value;
564
 
 
569
  url += `&session_id=${selectedSessionId}`;
570
  }
571
 
572
+ console.log('[메시지 목록] 요청 URL:', url);
573
  const response = await fetch(url);
574
+
575
+ if (!response.ok) {
576
+ const errorData = await response.json().catch(() => ({ error: `HTTP ${response.status}` }));
577
+ throw new Error(errorData.error || `HTTP ${response.status}`);
578
+ }
579
+
580
  const data = await response.json();
581
+ console.log('[메시지 목록] 응답 데이터:', data);
582
 
 
583
  tbody.innerHTML = '';
584
 
585
  if (data.messages && data.messages.length > 0) {
586
+ console.log('[메시지 목록] 메시지 개수:', data.messages.length);
587
  data.messages.forEach(msg => {
588
  const row = document.createElement('tr');
589
  const date = new Date(msg.created_at).toLocaleString('ko-KR');
590
+ const contentPreview = msg.content && msg.content.length > 100 ? msg.content.substring(0, 100) + '...' : (msg.content || '');
591
 
592
  row.innerHTML = `
593
  <td>${msg.id}</td>
 
596
  <td class="message-content">${escapeHtml(contentPreview)}</td>
597
  <td>${date}</td>
598
  <td>
599
+ <button class="btn btn-secondary" onclick="viewMessageById(${msg.id})" style="padding: 4px 8px; font-size: 12px;">상세 보기</button>
600
  </td>
601
  `;
602
+ // 메시지 데이터를 data 속성에 저장
603
+ row.setAttribute('data-message-id', msg.id);
604
+ row.setAttribute('data-message-role', msg.role);
605
+ row.setAttribute('data-message-content', escapeHtml(msg.content || ''));
606
  tbody.appendChild(row);
607
  });
608
  } else {
609
+ console.log('[메시지 목록] 메시지가 없습니다');
610
  tbody.innerHTML = '<tr><td colspan="6" style="text-align: center; padding: 20px; color: #5f6368;">메시지가 없습니다.</td></tr>';
611
  }
612
 
613
  // 페이지네이션
614
+ if (data.current_page !== undefined && data.pages !== undefined) {
615
+ updateMessagesPagination(data.current_page, data.pages);
616
+ currentMessagesPage = data.current_page;
617
+ } else {
618
+ // 페이지네이션 정보가 없으면 숨김
619
+ document.getElementById('messagesPagination').innerHTML = '';
620
+ }
621
 
622
  } catch (error) {
623
+ console.error('[메시지 목록] 조회 오류:', error);
624
  showAlert(`메시지 조회 오류: ${error.message}`, 'error');
625
+ tbody.innerHTML = `<tr><td colspan="6" style="text-align: center; padding: 20px; color: #ea4335;">메시지 조회 중 오류가 발생했습니다.<br><small>${error.message || ' 없는 오류'}</small></td></tr>`;
626
  }
627
  }
628
 
 
633
  loadMessages(1);
634
  }
635
 
636
+ // 메시지 상세 보기 (ID로 조회)
637
+ async function viewMessageById(messageId) {
638
+ try {
639
+ const response = await fetch(`/api/admin/messages?page=1&per_page=1&message_id=${messageId}`);
640
+ const data = await response.json();
641
+
642
+ if (data.messages && data.messages.length > 0) {
643
+ const msg = data.messages[0];
644
+ viewMessage(msg.id, msg.role, msg.content);
645
+ } else {
646
+ // 데이터 속성에서 가져오기 (fallback)
647
+ const row = document.querySelector(`tr[data-message-id="${messageId}"]`);
648
+ if (row) {
649
+ const role = row.getAttribute('data-message-role');
650
+ const content = row.getAttribute('data-message-content');
651
+ viewMessage(messageId, role, content);
652
+ } else {
653
+ showAlert('메시지를 찾을 수 없습니다.', 'error');
654
+ }
655
+ }
656
+ } catch (error) {
657
+ // 데이터 속성에서 가져오기 (fallback)
658
+ const row = document.querySelector(`tr[data-message-id="${messageId}"]`);
659
+ if (row) {
660
+ const role = row.getAttribute('data-message-role');
661
+ const content = row.getAttribute('data-message-content');
662
+ viewMessage(messageId, role, content);
663
+ } else {
664
+ showAlert(`메시지 조회 오류: ${error.message}`, 'error');
665
+ console.error('메시지 조회 오류:', error);
666
+ }
667
+ }
668
+ }
669
+
670
  // 메시지 상세 보기
671
  function viewMessage(messageId, role, content) {
672
  const modal = document.getElementById('messageModal');
 
679
  <span class="message-item-role role-badge role-${role}">${role === 'user' ? '사용자' : 'AI'}</span>
680
  <span class="message-item-time">메시지 ID: ${messageId}</span>
681
  </div>
682
+ <div class="message-item-content">${content}</div>
683
  </div>
684
  </div>
685
  `;
templates/admin_settings.html ADDED
@@ -0,0 +1,596 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="ko">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>AI 설정 관리 - SOY NV AI</title>
7
+ <link rel="preconnect" href="https://fonts.googleapis.com" crossorigin>
8
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap" rel="stylesheet">
9
+ <style>
10
+ * {
11
+ margin: 0;
12
+ padding: 0;
13
+ box-sizing: border-box;
14
+ }
15
+
16
+ body {
17
+ font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
18
+ background: #f8f9fa;
19
+ color: #202124;
20
+ }
21
+
22
+ .header {
23
+ background: white;
24
+ border-bottom: 1px solid #dadce0;
25
+ padding: 16px 24px;
26
+ display: flex;
27
+ align-items: center;
28
+ justify-content: space-between;
29
+ box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
30
+ }
31
+
32
+ .header-title {
33
+ font-size: 20px;
34
+ font-weight: 500;
35
+ display: flex;
36
+ align-items: center;
37
+ gap: 12px;
38
+ }
39
+
40
+ .header-actions {
41
+ display: flex;
42
+ gap: 12px;
43
+ align-items: center;
44
+ }
45
+
46
+ .btn {
47
+ padding: 8px 16px;
48
+ border: none;
49
+ border-radius: 6px;
50
+ font-size: 14px;
51
+ font-weight: 500;
52
+ cursor: pointer;
53
+ transition: all 0.2s;
54
+ text-decoration: none;
55
+ display: inline-block;
56
+ }
57
+
58
+ .btn-primary {
59
+ background: #1a73e8;
60
+ color: white;
61
+ }
62
+
63
+ .btn-primary:hover {
64
+ background: #1557b0;
65
+ }
66
+
67
+ .btn-secondary {
68
+ background: #f1f3f4;
69
+ color: #202124;
70
+ }
71
+
72
+ .btn-secondary:hover {
73
+ background: #e8eaed;
74
+ }
75
+
76
+ .container {
77
+ max-width: 1200px;
78
+ margin: 0 auto;
79
+ padding: 24px;
80
+ }
81
+
82
+ .page-header {
83
+ margin-bottom: 24px;
84
+ }
85
+
86
+ .page-header h1 {
87
+ font-size: 28px;
88
+ font-weight: 600;
89
+ margin-bottom: 8px;
90
+ }
91
+
92
+ .page-header p {
93
+ color: #5f6368;
94
+ }
95
+
96
+ .card {
97
+ background: white;
98
+ border-radius: 8px;
99
+ box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
100
+ padding: 24px;
101
+ margin-bottom: 24px;
102
+ }
103
+
104
+ .card-header {
105
+ display: flex;
106
+ justify-content: space-between;
107
+ align-items: center;
108
+ margin-bottom: 20px;
109
+ }
110
+
111
+ .card-title {
112
+ font-size: 18px;
113
+ font-weight: 500;
114
+ }
115
+
116
+ .form-group {
117
+ margin-bottom: 16px;
118
+ }
119
+
120
+ .form-group label {
121
+ display: block;
122
+ font-size: 14px;
123
+ font-weight: 500;
124
+ margin-bottom: 8px;
125
+ }
126
+
127
+ .form-group input {
128
+ width: 100%;
129
+ padding: 10px 12px;
130
+ border: 1px solid #dadce0;
131
+ border-radius: 6px;
132
+ font-size: 14px;
133
+ font-family: inherit;
134
+ }
135
+
136
+ .form-group input:focus {
137
+ outline: none;
138
+ border-color: #1a73e8;
139
+ box-shadow: 0 0 0 3px rgba(26, 115, 232, 0.1);
140
+ }
141
+
142
+ .alert {
143
+ padding: 12px 16px;
144
+ border-radius: 6px;
145
+ margin-bottom: 16px;
146
+ font-size: 14px;
147
+ }
148
+
149
+ .alert.error {
150
+ background: #fce8e6;
151
+ color: #c5221f;
152
+ }
153
+
154
+ .alert.success {
155
+ background: #e8f5e9;
156
+ color: #137333;
157
+ }
158
+ </style>
159
+ </head>
160
+ <body>
161
+ <div class="header">
162
+ <div class="header-title">
163
+ <span>⚙️</span>
164
+ <span>AI 설정 관리</span>
165
+ </div>
166
+ <div class="header-actions">
167
+ <span style="margin-right: 12px; color: #5f6368;">{{ current_user.nickname or current_user.username }}</span>
168
+ <a href="{{ url_for('main.admin') }}" class="btn btn-secondary">사용자 관리</a>
169
+ <a href="{{ url_for('main.admin_webnovels') }}" class="btn btn-secondary">웹소설 관리</a>
170
+ <a href="{{ url_for('main.admin_files') }}" class="btn btn-secondary">파일 목록</a>
171
+ <a href="{{ url_for('main.admin_messages') }}" class="btn btn-secondary">메시지 확인</a>
172
+ <a href="{{ url_for('main.admin_prompts') }}" class="btn btn-secondary">프롬프트 관리</a>
173
+ <a href="{{ url_for('main.index') }}" class="btn btn-secondary">메인으로</a>
174
+ <a href="{{ url_for('main.logout') }}" class="btn btn-secondary">로그아웃</a>
175
+ </div>
176
+ </div>
177
+
178
+ <div class="container">
179
+ <div class="page-header">
180
+ <h1>AI 설정 관리</h1>
181
+ <p>Gemini API 키와 AI 모델별 토큰 수를 관리할 수 있습니다.</p>
182
+ </div>
183
+
184
+ <div id="alertContainer"></div>
185
+
186
+ <!-- Gemini API 키 설정 섹션 -->
187
+ <div class="card">
188
+ <div class="card-header">
189
+ <div class="card-title">Gemini API 키 설정</div>
190
+ </div>
191
+ <div style="padding: 16px 0;">
192
+ <div class="form-group">
193
+ <label for="geminiApiKey">Gemini API 키</label>
194
+ <div style="display: flex; gap: 8px;">
195
+ <input type="password" id="geminiApiKey" placeholder="Gemini API 키를 입력하세요" style="flex: 1; padding: 8px 12px; border: 1px solid #dadce0; border-radius: 4px; font-size: 14px;">
196
+ <button class="btn btn-primary" onclick="saveGeminiApiKey()">저장</button>
197
+ <button class="btn btn-secondary" onclick="loadGeminiApiKey()">현재 상태 확인</button>
198
+ </div>
199
+ <small style="color: #5f6368; font-size: 12px; display: block; margin-top: 4px;">
200
+ Google AI Studio(<a href="https://aistudio.google.com/app/apikey" target="_blank">https://aistudio.google.com/app/apikey</a>)에서 API 키를 발급받을 수 있습니다.
201
+ </small>
202
+ <div id="geminiApiKeyStatus" style="margin-top: 8px; font-size: 13px;"></div>
203
+ </div>
204
+ </div>
205
+ </div>
206
+
207
+ <!-- AI 모델별 토큰 수 관리 섹션 -->
208
+ <div class="card">
209
+ <div class="card-header">
210
+ <div class="card-title">AI 모델별 토큰 수 관리</div>
211
+ <button class="btn btn-secondary" onclick="loadModelTokens()">새로고침</button>
212
+ </div>
213
+ <div style="padding: 16px 0;">
214
+ <div id="modelTokensStatus" style="margin-bottom: 16px; font-size: 13px;"></div>
215
+ <div id="modelTokensList" style="display: grid; gap: 12px;">
216
+ <div style="text-align: center; padding: 20px; color: #5f6368;">로딩 중...</div>
217
+ </div>
218
+ </div>
219
+ </div>
220
+ </div>
221
+
222
+ <script>
223
+ function showAlert(message, type = 'success') {
224
+ const container = document.getElementById('alertContainer');
225
+ container.innerHTML = `<div class="alert ${type}">${message}</div>`;
226
+ setTimeout(() => {
227
+ container.innerHTML = '';
228
+ }, 5000);
229
+ }
230
+
231
+ // Gemini API 키 관련 함수
232
+ async function loadGeminiApiKey() {
233
+ try {
234
+ const response = await fetch('/api/admin/gemini-api-key', {
235
+ credentials: 'include'
236
+ });
237
+
238
+ // 응답이 JSON인지 확인
239
+ const contentType = response.headers.get('content-type');
240
+ if (!contentType || !contentType.includes('application/json')) {
241
+ const text = await response.text();
242
+ console.error('Non-JSON response:', text.substring(0, 200));
243
+ const statusDiv = document.getElementById('geminiApiKeyStatus');
244
+ statusDiv.innerHTML = `<span style="color: #ea4335;">서버 오류: 응답 형식이 올바르지 않습니다.</span>`;
245
+ return;
246
+ }
247
+
248
+ const data = await response.json();
249
+
250
+ const statusDiv = document.getElementById('geminiApiKeyStatus');
251
+ if (!response.ok) {
252
+ statusDiv.innerHTML = `<span style="color: #ea4335;">오류: ${data.error || '알 수 없는 오류'}</span>`;
253
+ return;
254
+ }
255
+
256
+ if (data.has_api_key) {
257
+ statusDiv.innerHTML = `<span style="color: #137333;">✓ API 키가 설정되어 있습니다 (${data.masked_key})</span>`;
258
+ } else {
259
+ statusDiv.innerHTML = `<span style="color: #ea4335;">⚠ API 키가 설정되지 않았습니다</span>`;
260
+ }
261
+ } catch (error) {
262
+ console.error('Gemini API 키 로드 오류:', error);
263
+ const statusDiv = document.getElementById('geminiApiKeyStatus');
264
+ statusDiv.innerHTML = `<span style="color: #ea4335;">오류: ${error.message}</span>`;
265
+ }
266
+ }
267
+
268
+ async function saveGeminiApiKey() {
269
+ const apiKeyInput = document.getElementById('geminiApiKey');
270
+ const apiKey = apiKeyInput.value.trim();
271
+
272
+ if (!apiKey) {
273
+ showAlert('API 키를 입력해주세요.', 'error');
274
+ return;
275
+ }
276
+
277
+ try {
278
+ const response = await fetch('/api/admin/gemini-api-key', {
279
+ method: 'POST',
280
+ headers: {
281
+ 'Content-Type': 'application/json',
282
+ },
283
+ credentials: 'include',
284
+ body: JSON.stringify({ api_key: apiKey })
285
+ });
286
+
287
+ // 응답이 JSON인지 확인
288
+ const contentType = response.headers.get('content-type');
289
+ if (!contentType || !contentType.includes('application/json')) {
290
+ const text = await response.text();
291
+ console.error('Non-JSON response:', text.substring(0, 200));
292
+ showAlert('서버 오류: 응답 형식이 올바르지 않습니다.', 'error');
293
+ return;
294
+ }
295
+
296
+ const data = await response.json();
297
+
298
+ if (response.ok) {
299
+ showAlert(data.message, 'success');
300
+ apiKeyInput.value = ''; // 보안을 위해 입력 필드 초기화
301
+ loadGeminiApiKey(); // 상태 업데이트
302
+ } else {
303
+ showAlert(data.error || 'API 키 저장 중 오류가 발생했습니다.', 'error');
304
+ }
305
+ } catch (error) {
306
+ console.error('Gemini API 키 저장 오류:', error);
307
+ showAlert(`오류: ${error.message}`, 'error');
308
+ }
309
+ }
310
+
311
+ // AI 모델별 토큰 수 관리 (입력/출력 분리)
312
+ async function loadModelTokens() {
313
+ const statusDiv = document.getElementById('modelTokensStatus');
314
+ const listDiv = document.getElementById('modelTokensList');
315
+
316
+ try {
317
+ statusDiv.innerHTML = '<span style="color: #1a73e8;">토큰 수 설정을 불러오는 중...</span>';
318
+ listDiv.innerHTML = '<div style="text-align: center; padding: 20px; color: #5f6368;">로딩 중...</div>';
319
+
320
+ const response = await fetch('/api/admin/model-tokens', {
321
+ credentials: 'include'
322
+ });
323
+
324
+ if (!response.ok) {
325
+ const error = await response.json().catch(() => ({ error: '서버 오류' }));
326
+ statusDiv.innerHTML = `<span style="color: #ea4335;">오류: ${error.error || '토큰 수 설정을 불러올 수 없습니다'}</span>`;
327
+ listDiv.innerHTML = '<div style="text-align: center; padding: 20px; color: #ea4335;">토큰 수 설정을 불러올 수 없습니다.</div>';
328
+ return;
329
+ }
330
+
331
+ const data = await response.json();
332
+
333
+ if (!data.models || data.models.length === 0) {
334
+ statusDiv.innerHTML = '<span style="color: #5f6368;">사용 가능한 AI 모델이 없습니다.</span>';
335
+ listDiv.innerHTML = '<div style="text-align: center; padding: 20px; color: #5f6368;">사용 가능한 AI 모델이 없습니다.</div>';
336
+ return;
337
+ }
338
+
339
+ statusDiv.innerHTML = `<span style="color: #137333;">✓ ${data.models.length}개 모델 발견</span>`;
340
+
341
+ let html = '';
342
+ data.models.forEach(modelName => {
343
+ // 안전하게 토큰 데이터 접근 (undefined 체크)
344
+ const inputTokens = data.input_tokens || {};
345
+ const outputTokens = data.output_tokens || {};
346
+ const parentChunkTokens = data.parent_chunk_tokens || {};
347
+ const defaultInputTokens = data.default_input_tokens || {};
348
+ const defaultOutputTokens = data.default_output_tokens || {};
349
+ const defaultParentChunkTokens = data.default_parent_chunk_tokens || {};
350
+
351
+ const currentInputTokens = inputTokens[modelName] || '';
352
+ const currentOutputTokens = outputTokens[modelName] || '';
353
+ const currentParentChunkTokens = parentChunkTokens[modelName] || '';
354
+ const defaultInputToken = defaultInputTokens[modelName] || 100000;
355
+ const defaultOutputToken = defaultOutputTokens[modelName] || 2000;
356
+ const defaultParentChunkToken = defaultParentChunkTokens[modelName] || 8192;
357
+ const modelType = modelName.startsWith('gemini:') ? 'Gemini' : 'Ollama';
358
+ const displayName = modelName.startsWith('gemini:') ? modelName.replace('gemini:', '') : modelName;
359
+
360
+ // 실제 사용 중인 값 계산
361
+ const actualInputToken = currentInputTokens ? parseInt(currentInputTokens) : defaultInputToken;
362
+ const actualOutputToken = currentOutputTokens ? parseInt(currentOutputTokens) : defaultOutputToken;
363
+ const actualParentChunkToken = currentParentChunkTokens ? parseInt(currentParentChunkTokens) : defaultParentChunkToken;
364
+
365
+ // 사용 중인 값 표시 텍스트 생성
366
+ const getTokenStatusText = (actual, defaultVal, isUsingDefault) => {
367
+ if (isUsingDefault) {
368
+ return `실제 사용: ${actual.toLocaleString()} (기본값)`;
369
+ } else {
370
+ return `실제 사용: ${actual.toLocaleString()} (설정값)`;
371
+ }
372
+ };
373
+
374
+ const inputStatusText = getTokenStatusText(actualInputToken, defaultInputToken, !currentInputTokens);
375
+ const outputStatusText = getTokenStatusText(actualOutputToken, defaultOutputToken, !currentOutputTokens);
376
+ const parentChunkStatusText = getTokenStatusText(actualParentChunkToken, defaultParentChunkToken, !currentParentChunkTokens);
377
+
378
+ // 안전한 ID 생성 (특수문자 처리)
379
+ const safeId = modelNameToId(modelName);
380
+
381
+ html += `
382
+ <div style="padding: 16px; background: #f8f9fa; border-radius: 6px; border: 1px solid #dadce0; margin-bottom: 12px;" data-model-name="${escapeHtml(modelName)}">
383
+ <div style="margin-bottom: 12px;">
384
+ <div style="font-weight: 500; margin-bottom: 4px; font-size: 15px;">${escapeHtml(displayName)}</div>
385
+ <div style="font-size: 12px; color: #5f6368; line-height: 1.6;">
386
+ <div><strong>타입:</strong> ${modelType}</div>
387
+ <div><strong>입력 토큰:</strong> ${inputStatusText}</div>
388
+ <div><strong>출력 토큰:</strong> ${outputStatusText}</div>
389
+ <div><strong>Parent Chunk 토큰:</strong> ${parentChunkStatusText}</div>
390
+ </div>
391
+ </div>
392
+ <div style="display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 12px;">
393
+ <div>
394
+ <label style="display: block; font-size: 13px; font-weight: 500; margin-bottom: 6px; color: #202124;">입력 토큰</label>
395
+ <div style="display: flex; gap: 8px;">
396
+ <input
397
+ type="number"
398
+ id="input_tokens_${safeId}"
399
+ data-model-name="${escapeHtml(modelName)}"
400
+ value="${currentInputTokens || ''}"
401
+ placeholder="기본값: ${defaultInputToken.toLocaleString()}"
402
+ min="1"
403
+ style="flex: 1; padding: 6px 10px; border: 1px solid #dadce0; border-radius: 4px; font-size: 14px;"
404
+ >
405
+ <button
406
+ class="btn btn-primary"
407
+ onclick="saveModelTokens('${escapeHtml(modelName)}', 'input')"
408
+ style="padding: 6px 12px; font-size: 13px; white-space: nowrap;"
409
+ >
410
+ 저장
411
+ </button>
412
+ </div>
413
+ </div>
414
+ <div>
415
+ <label style="display: block; font-size: 13px; font-weight: 500; margin-bottom: 6px; color: #202124;">출력 토큰</label>
416
+ <div style="display: flex; gap: 8px;">
417
+ <input
418
+ type="number"
419
+ id="output_tokens_${safeId}"
420
+ data-model-name="${escapeHtml(modelName)}"
421
+ value="${currentOutputTokens || ''}"
422
+ placeholder="기본값: ${defaultOutputToken.toLocaleString()}"
423
+ min="1"
424
+ style="flex: 1; padding: 6px 10px; border: 1px solid #dadce0; border-radius: 4px; font-size: 14px;"
425
+ >
426
+ <button
427
+ class="btn btn-primary"
428
+ onclick="saveModelTokens('${escapeHtml(modelName)}', 'output')"
429
+ style="padding: 6px 12px; font-size: 13px; white-space: nowrap;"
430
+ >
431
+ 저장
432
+ </button>
433
+ </div>
434
+ </div>
435
+ <div>
436
+ <label style="display: block; font-size: 13px; font-weight: 500; margin-bottom: 6px; color: #202124;">Parent Chunk 토큰</label>
437
+ <div style="display: flex; gap: 8px;">
438
+ <input
439
+ type="number"
440
+ id="parent_chunk_tokens_${safeId}"
441
+ data-model-name="${escapeHtml(modelName)}"
442
+ value="${currentParentChunkTokens || ''}"
443
+ placeholder="기본값: ${defaultParentChunkToken.toLocaleString()}"
444
+ min="1"
445
+ style="flex: 1; padding: 6px 10px; border: 1px solid #dadce0; border-radius: 4px; font-size: 14px;"
446
+ >
447
+ <button
448
+ class="btn btn-primary"
449
+ onclick="saveModelTokens('${escapeHtml(modelName)}', 'parent_chunk')"
450
+ style="padding: 6px 12px; font-size: 13px; white-space: nowrap;"
451
+ >
452
+ 저장
453
+ </button>
454
+ </div>
455
+ </div>
456
+ </div>
457
+ </div>
458
+ `;
459
+ });
460
+
461
+ listDiv.innerHTML = html;
462
+
463
+ } catch (error) {
464
+ console.error('토큰 수 설정 로드 오류:', error);
465
+ statusDiv.innerHTML = `<span style="color: #ea4335;">오류: ${error.message}</span>`;
466
+ listDiv.innerHTML = '<div style="text-align: center; padding: 20px; color: #ea4335;">토큰 수 설정을 불러오는 중 오류가 발생했습니다.</div>';
467
+ }
468
+ }
469
+
470
+ async function saveModelTokens(modelName, tokenType) {
471
+ // tokenType에 따라 input ID 결정 (안전한 ID 사용)
472
+ const safeId = modelNameToId(modelName);
473
+ let inputId;
474
+ if (tokenType === 'input') {
475
+ inputId = `input_tokens_${safeId}`;
476
+ } else if (tokenType === 'output') {
477
+ inputId = `output_tokens_${safeId}`;
478
+ } else if (tokenType === 'parent_chunk') {
479
+ inputId = `parent_chunk_tokens_${safeId}`;
480
+ } else {
481
+ showAlert('잘못된 토큰 타입입니다.', 'error');
482
+ return;
483
+ }
484
+
485
+ const input = document.getElementById(inputId);
486
+ if (!input) {
487
+ console.error(`Input element not found: ${inputId}`);
488
+ showAlert('입력 필드를 찾을 수 없습니다. 페이지를 새로고침해주세요.', 'error');
489
+ return;
490
+ }
491
+
492
+ const tokens = input.value.trim();
493
+ console.log(`[토큰 저장] 모델: ${modelName}, 타입: ${tokenType}, 값: ${tokens}`);
494
+
495
+ // 빈 값이면 기본값으로 되돌리기 (설정 삭제)
496
+ if (!tokens) {
497
+ if (!confirm('입력값이 비어있습니다. 기본값으로 되돌리시겠습니까?')) {
498
+ return;
499
+ }
500
+
501
+ try {
502
+ const response = await fetch('/api/admin/model-tokens', {
503
+ method: 'POST',
504
+ headers: {
505
+ 'Content-Type': 'application/json'
506
+ },
507
+ credentials: 'include',
508
+ body: JSON.stringify({
509
+ model_name: modelName,
510
+ token_type: tokenType,
511
+ tokens: null // null을 보내면 삭제
512
+ })
513
+ });
514
+
515
+ const data = await response.json();
516
+
517
+ if (response.ok) {
518
+ showAlert('기본값으로 되돌렸습니다.', 'success');
519
+ loadModelTokens(); // 목록 새로고침
520
+ } else {
521
+ showAlert(data.error || '설정 삭제 중 오류가 발생했습니다.', 'error');
522
+ }
523
+ } catch (error) {
524
+ console.error('설정 삭제 오류:', error);
525
+ showAlert(`오류: ${error.message}`, 'error');
526
+ }
527
+ return;
528
+ }
529
+
530
+ try {
531
+ const tokensNum = parseInt(tokens);
532
+ if (isNaN(tokensNum) || tokensNum < 1) {
533
+ showAlert('토큰 수는 1 이상의 정수여야 합니다.', 'error');
534
+ return;
535
+ }
536
+
537
+ const response = await fetch('/api/admin/model-tokens', {
538
+ method: 'POST',
539
+ headers: {
540
+ 'Content-Type': 'application/json'
541
+ },
542
+ credentials: 'include',
543
+ body: JSON.stringify({
544
+ model_name: modelName,
545
+ token_type: tokenType,
546
+ tokens: tokensNum
547
+ })
548
+ });
549
+
550
+ const data = await response.json();
551
+ console.log(`[토큰 저장 응답]`, data);
552
+
553
+ if (response.ok) {
554
+ showAlert(data.message || '토큰 수가 저장되었습니다.', 'success');
555
+ // 약간의 지연 후 목록 새로고침 (서버 반영 시간 고려)
556
+ setTimeout(() => {
557
+ loadModelTokens(); // 목록 새로고침
558
+ }, 300);
559
+ } else {
560
+ console.error(`[토큰 저장 실패]`, data);
561
+ showAlert(data.error || '토큰 수 저장 중 오류가 발생했습니다.', 'error');
562
+ }
563
+ } catch (error) {
564
+ console.error('토큰 수 저장 오류:', error);
565
+ showAlert(`오류: ${error.message}`, 'error');
566
+ }
567
+ }
568
+
569
+ function escapeHtml(text) {
570
+ const div = document.createElement('div');
571
+ div.textContent = text;
572
+ return div.innerHTML;
573
+ }
574
+
575
+ // 모델명을 안전한 ID로 변환 (특수문자 처리)
576
+ function modelNameToId(modelName) {
577
+ // 특수문자를 언더스코어로 변환
578
+ return modelName.replace(/[^a-zA-Z0-9]/g, '_');
579
+ }
580
+
581
+ // ID를 모델명으로 역변환 (언더스코어를 원래 특수문자로 복원)
582
+ // 주의: 이 방법은 완벽하지 않으므로, 대신 data 속성을 사용하는 것이 더 안전합니다
583
+ function idToModelName(id) {
584
+ // 이 함수는 사용하지 않음 - data 속성 사용
585
+ return id;
586
+ }
587
+
588
+ // 페이지 로드 시 API 키 상태 확인 및 토큰 수 설정 로드
589
+ window.addEventListener('load', () => {
590
+ loadGeminiApiKey();
591
+ loadModelTokens();
592
+ });
593
+ </script>
594
+ </body>
595
+ </html>
596
+
templates/admin_webnovels.html CHANGED
@@ -17,6 +17,7 @@
17
  font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
18
  background: #f8f9fa;
19
  color: #202124;
 
20
  }
21
 
22
  .header {
@@ -27,6 +28,7 @@
27
  align-items: center;
28
  justify-content: space-between;
29
  box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
 
30
  }
31
 
32
  .header-title {
@@ -41,6 +43,7 @@
41
  display: flex;
42
  gap: 12px;
43
  align-items: center;
 
44
  }
45
 
46
  .btn {
@@ -86,6 +89,7 @@
86
  max-width: 1200px;
87
  margin: 0 auto;
88
  padding: 24px;
 
89
  }
90
 
91
  .page-header {
@@ -108,6 +112,7 @@
108
  box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
109
  padding: 24px;
110
  margin-bottom: 24px;
 
111
  }
112
 
113
  .card-header {
@@ -125,6 +130,7 @@
125
  table {
126
  width: 100%;
127
  border-collapse: collapse;
 
128
  }
129
 
130
  thead {
@@ -135,6 +141,8 @@
135
  padding: 12px;
136
  text-align: left;
137
  border-bottom: 1px solid #e8eaed;
 
 
138
  }
139
 
140
  th {
@@ -217,6 +225,8 @@
217
  font-size: 12px;
218
  margin-top: 8px;
219
  min-height: 16px;
 
 
220
  }
221
 
222
  .file-upload-status.success {
@@ -250,6 +260,7 @@
250
  justify-content: space-between;
251
  padding: 8px 0;
252
  border-bottom: 1px solid #e8eaed;
 
253
  }
254
 
255
  .progress-item:last-child {
@@ -264,6 +275,8 @@
264
  text-overflow: ellipsis;
265
  white-space: nowrap;
266
  margin-right: 12px;
 
 
267
  }
268
 
269
  .progress-item-status {
@@ -597,13 +610,42 @@
597
 
598
  // 모델 목록 로드 (관리자용: 모든 모델 표시)
599
  async function loadModelsForFiles() {
 
 
 
 
 
600
  try {
601
- const response = await fetch('/api/admin/ollama/models');
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
602
  const data = await response.json();
 
603
 
604
  fileModelSelect.innerHTML = '<option value="">모델을 선택하세요...</option>';
605
 
606
  if (data.models && data.models.length > 0) {
 
607
  data.models.forEach(model => {
608
  const option = document.createElement('option');
609
  option.value = model.name;
@@ -614,22 +656,55 @@
614
  : model.name;
615
  option.textContent = displayText;
616
  fileModelSelect.appendChild(option);
 
617
  });
 
618
  } else if (data.error) {
619
- console.error('모델 로드 오류:', data.error);
620
- fileModelSelect.innerHTML = '<option value="">모델을 불러올 수 없습니���</option>';
 
 
 
621
  }
622
  } catch (error) {
623
- console.error('모델 로드 오류:', error);
624
- fileModelSelect.innerHTML = '<option value="">모델 로드 실패</option>';
625
  }
626
  }
627
 
628
  // 파일 목록 로드
629
  async function loadFiles() {
 
 
 
 
 
630
  try {
631
- const response = await fetch('/api/files');
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
632
  const data = await response.json();
 
633
 
634
  filesTableBody.innerHTML = '';
635
 
@@ -715,11 +790,12 @@
715
  });
716
  });
717
  } else {
 
718
  filesTableBody.innerHTML = '<tr><td colspan="5" style="text-align: center; padding: 20px; color: #5f6368;">업로드된 파일이 없습니다.</td></tr>';
719
  }
720
  } catch (error) {
721
- filesTableBody.innerHTML = '<tr><td colspan="5" style="text-align: center; padding: 20px; color: #c5221f;">파일 목록을 불러오는 중 오류가 발생했습니다.</td></tr>';
722
- console.error('파일 목록 로드 오류:', error);
723
  }
724
  }
725
 
@@ -813,32 +889,41 @@
813
  progressMap.set(index, { file, item, status: 'waiting', step: 0 });
814
  });
815
 
816
- // 업로드 단계 정의
817
  const uploadSteps = [
818
  { name: '업로드 폴더 확인', step: 1 },
819
  { name: '파일 수신', step: 2 },
820
  { name: '파일 검증', step: 3 },
821
  { name: '파일 저장', step: 4 },
822
  { name: '데이터베이스 저장', step: 5 },
823
- { name: '청크 생성', step: 6 },
824
- { name: '완료', step: 7 }
 
 
 
825
  ];
826
 
827
- function updateProgressStatus(fileIndex, stepIndex, stepName) {
828
  const statusElement = document.getElementById(`progress-status-${fileIndex}`);
829
  if (statusElement) {
830
  const step = uploadSteps[stepIndex] || { name: stepName || '처리 중', step: stepIndex + 1 };
831
- statusElement.innerHTML = `<span class="spinner"></span>${step.name} (${step.step}/7)`;
 
 
 
832
  }
833
  }
834
 
835
- function updateOverallStatus(currentFile, totalFiles, stepIndex) {
836
  const step = uploadSteps[stepIndex] || { name: '처리 중', step: stepIndex + 1 };
837
- fileUploadStatus.textContent = `[${currentFile}/${totalFiles}] ${step.name} 중... (${step.step}/7)`;
 
 
 
838
  }
839
 
840
  if (fileUploadStatus) {
841
- fileUploadStatus.textContent = `[0/${files.length}] 업로드 준비 중...`;
842
  fileUploadStatus.className = 'file-upload-status progress';
843
  }
844
 
@@ -884,14 +969,14 @@
884
 
885
  // 단계 1: 업로드 폴더 확인
886
  console.log(`[단계 1] 업로드 폴더 확인 시작`);
887
- updateProgressStatus(i, 0, '업로드 폴더 확인');
888
  updateOverallStatus(i + 1, files.length, 0);
889
 
890
  console.log(`[단계 1] fetch 호출 시작: /api/upload`);
891
  console.log(`[단계 1] FormData 항목:`, Array.from(formData.entries()).map(([k, v]) => [k, v instanceof File ? v.name : v]));
892
 
893
- // 타임아웃이 있는 fetch 래퍼 (15분 타임아웃 - 큰 파일 처리 시간 고려)
894
- const fetchWithTimeout = (url, options, timeout = 900000) => { // 15분 타임아웃
895
  return Promise.race([
896
  fetch(url, options),
897
  new Promise((_, reject) =>
@@ -904,7 +989,7 @@
904
  method: 'POST',
905
  body: formData,
906
  credentials: 'include' // 쿠키 포함 (세션 인증)
907
- }, 900000); // 15분 타임아웃
908
 
909
  console.log(`[단계 1] fetch 응답 수신: ${response.status} ${response.statusText}`);
910
 
@@ -922,13 +1007,13 @@
922
  }
923
 
924
  // 단계 2: 파일 수신
925
- updateProgressStatus(i, 1, '파일 수신');
926
  updateOverallStatus(i + 1, files.length, 1);
927
 
928
  console.log(`[응답 수신] 상태: ${response.status} ${response.statusText}, Content-Type: ${response.headers.get('Content-Type')}`);
929
 
930
  // 단계 3: 파일 검증
931
- updateProgressStatus(i, 2, '파일 검증');
932
  updateOverallStatus(i + 1, files.length, 2);
933
 
934
  let data;
@@ -944,36 +1029,94 @@
944
  }
945
 
946
  // 단계 4: 파일 저장
947
- updateProgressStatus(i, 3, '파일 저장');
948
  updateOverallStatus(i + 1, files.length, 3);
949
 
950
  if (response.ok) {
951
- // 단계 5: 데이터베이스 저장
952
- updateProgressStatus(i, 4, '데이터베��스 저장');
953
- updateOverallStatus(i + 1, files.length, 4);
954
-
955
- // 단계 6: 청크 생성
956
- updateProgressStatus(i, 5, '청크 생성');
957
- updateOverallStatus(i + 1, files.length, 5);
958
-
959
- successCount++;
960
- const modelName = data.model_name || '알 수 없음';
961
- const chunkCount = data.chunk_count || 0;
962
 
963
- // 단계 7: 완료
964
- updateProgressStatus(i, 6, '완료');
965
- updateOverallStatus(i + 1, files.length, 6);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
966
 
967
- statusElement.className = 'progress-item-status success';
968
- statusElement.innerHTML = '✓ 완료';
969
- statusElement.title = `모델: ${modelName}${chunkCount > 0 ? `, 청크: ${chunkCount}개` : ''}`;
970
- itemElement.style.opacity = '0.7';
971
- console.log(`[업로드 성공] ${file.name} 모델: ${modelName}, 청크: ${chunkCount}개`);
 
 
 
 
972
  } else {
973
  failCount++;
974
  const errorMsg = data.error || data.message || `HTTP ${response.status} 오류`;
975
  statusElement.className = 'progress-item-status error';
976
- statusElement.innerHTML = '✗ 실패';
977
  statusElement.title = errorMsg; // 툴팁으로 상세 에러 표시
978
  statusElement.style.cursor = 'help'; // 툴팁 표시를 위한 커서 변경
979
  errors.push(`${file.name}: ${errorMsg}`);
@@ -984,26 +1127,41 @@
984
  data: data,
985
  responseText: responseText
986
  });
 
 
 
 
 
 
 
 
 
 
 
 
987
  }
988
  } catch (error) {
989
  failCount++;
990
  const errorMsg = error.message || '네트워크 오류';
 
 
991
  if (statusElement) {
992
  statusElement.className = 'progress-item-status error';
993
- statusElement.innerHTML = '✗ 실패';
994
  statusElement.title = errorMsg; // 툴팁으로 상세 에러 표시
995
  statusElement.style.cursor = 'help'; // 툴팁 표시를 위한 커서 변경
996
  }
 
997
  errors.push(`${file.name}: ${errorMsg}`);
998
  console.error(`[업로드 예외] 파일: ${file.name}`, error);
999
  console.error(`[업로드 예외 스택]`, error.stack);
1000
 
1001
  // 타임아웃 오류인 경우 사용자에게 명확한 메시지 표시
1002
  if (error.message && error.message.includes('타임아웃')) {
1003
- const timeoutMsg = `파일 업로드 타임아웃: ${file.name}\n파일이 크��나 처리 시간이 오래 걸려 타임아웃이 발생했습니다.`;
1004
  console.error(`[타임아웃 오류] ${timeoutMsg}`);
1005
  if (fileUploadStatus) {
1006
- fileUploadStatus.textContent = `[${i + 1}/${files.length}] 타임아웃: ${file.name}`;
1007
  fileUploadStatus.className = 'file-upload-status error';
1008
  }
1009
  // 사용자에게 알림 표시
@@ -1023,15 +1181,18 @@
1023
  showAlert(`파일 업로드 오류: ${file.name}\n${errorMsg}`, 'error');
1024
  }
1025
  console.error(`[스택 트레이스]`, error.stack);
1026
- }
1027
-
1028
- // 진행 상태 업데이트 (다음 파일로 넘어가기 )
1029
- if (i < files.length - 1) {
1030
- fileUploadStatus.textContent = `[${i + 1}/${files.length}] 완료, 다음 파일 처리 중...`;
1031
- } else {
1032
- fileUploadStatus.textContent = `[${i + 1}/${files.length}] 모든 파일 처리 완료`;
 
 
1033
  }
1034
  }
 
1035
  } catch (uploadLoopError) {
1036
  console.error('[업로드 루프 오류]', uploadLoopError);
1037
  fileUploadStatus.textContent = `업로드 처리 중 오류 발생: ${uploadLoopError.message}`;
@@ -1044,12 +1205,12 @@
1044
  if (fileUploadStatus) {
1045
  fileUploadStatus.className = 'file-upload-status';
1046
  if (successCount > 0) {
1047
- fileUploadStatus.textContent = `${successCount}개 파일 업로드 완료${failCount > 0 ? ` (${failCount}개 실패)` : ''}`;
1048
  fileUploadStatus.className = 'file-upload-status success';
1049
  showAlert(`${successCount}개 파일이 성공적으로 업로드되었습니다.${failCount > 0 ? ` (${failCount}개 실패)` : ''}`, 'success');
1050
  loadFiles();
1051
  } else {
1052
- fileUploadStatus.textContent = '모든 파일 업로드 실패';
1053
  fileUploadStatus.className = 'file-upload-status error';
1054
  const errorDetails = errors.length > 0 ? '\n\n오류 상세:\n' + errors.slice(0, 5).map((e, idx) => `${idx + 1}. ${e}`).join('\n') + (errors.length > 5 ? `\n... 외 ${errors.length - 5}개 오류` : '') : '';
1055
  showAlert(`파일 업로드에 실패했습니다.${errorDetails}`, 'error');
@@ -1067,15 +1228,9 @@
1067
  console.error('[handleFileUpload] 완료 처리 오류:', finalError);
1068
  }
1069
 
1070
- // 3초 진행 상태 숨기기
1071
- setTimeout(() => {
1072
- if (progressContainer) {
1073
- progressContainer.classList.remove('active');
1074
- }
1075
- if (fileUploadStatus) {
1076
- fileUploadStatus.textContent = '';
1077
- }
1078
- }, 3000);
1079
  }
1080
 
1081
  // 파일 삭제
@@ -1397,11 +1552,49 @@
1397
  }
1398
  });
1399
 
1400
- // 페이지 로드 시 초기화
1401
- window.addEventListener('load', () => {
1402
- loadModelsForFiles();
1403
- loadFiles();
1404
- });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1405
  </script>
1406
  </body>
1407
  </html>
 
17
  font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
18
  background: #f8f9fa;
19
  color: #202124;
20
+ overflow-x: hidden;
21
  }
22
 
23
  .header {
 
28
  align-items: center;
29
  justify-content: space-between;
30
  box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
31
+ overflow-x: hidden;
32
  }
33
 
34
  .header-title {
 
43
  display: flex;
44
  gap: 12px;
45
  align-items: center;
46
+ flex-wrap: wrap;
47
  }
48
 
49
  .btn {
 
89
  max-width: 1200px;
90
  margin: 0 auto;
91
  padding: 24px;
92
+ overflow-x: hidden;
93
  }
94
 
95
  .page-header {
 
112
  box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
113
  padding: 24px;
114
  margin-bottom: 24px;
115
+ overflow-x: hidden;
116
  }
117
 
118
  .card-header {
 
130
  table {
131
  width: 100%;
132
  border-collapse: collapse;
133
+ table-layout: auto;
134
  }
135
 
136
  thead {
 
141
  padding: 12px;
142
  text-align: left;
143
  border-bottom: 1px solid #e8eaed;
144
+ word-break: break-word;
145
+ overflow-wrap: break-word;
146
  }
147
 
148
  th {
 
225
  font-size: 12px;
226
  margin-top: 8px;
227
  min-height: 16px;
228
+ word-break: break-word;
229
+ overflow-wrap: break-word;
230
  }
231
 
232
  .file-upload-status.success {
 
260
  justify-content: space-between;
261
  padding: 8px 0;
262
  border-bottom: 1px solid #e8eaed;
263
+ min-width: 0;
264
  }
265
 
266
  .progress-item:last-child {
 
275
  text-overflow: ellipsis;
276
  white-space: nowrap;
277
  margin-right: 12px;
278
+ min-width: 0;
279
+ max-width: 100%;
280
  }
281
 
282
  .progress-item-status {
 
610
 
611
  // 모델 목록 로드 (관리자용: 모든 모델 표시)
612
  async function loadModelsForFiles() {
613
+ if (!fileModelSelect) {
614
+ console.error('[모델 목록] fileModelSelect 요소를 찾을 수 없습니다.');
615
+ return;
616
+ }
617
+
618
  try {
619
+ console.log('[모델 목록] API 요청 시작: /api/admin/ollama/models');
620
+ const response = await fetch('/api/admin/ollama/models', {
621
+ credentials: 'include', // 쿠키 포함 (세션 인증)
622
+ headers: {
623
+ 'Accept': 'application/json'
624
+ }
625
+ });
626
+
627
+ console.log('[모델 목록] 응답 상태:', response.status, response.statusText);
628
+ console.log('[모델 목록] 응답 헤더:', Object.fromEntries(response.headers.entries()));
629
+
630
+ if (!response.ok) {
631
+ const errorText = await response.text();
632
+ console.error('[모델 목록] 응답 오류:', errorText);
633
+ try {
634
+ const errorData = JSON.parse(errorText);
635
+ fileModelSelect.innerHTML = `<option value="">모델을 불러올 수 없습니다: ${errorData.error || '서버 오류'}</option>`;
636
+ } catch {
637
+ fileModelSelect.innerHTML = `<option value="">모델을 불러올 수 없습니다 (${response.status} ${response.statusText})</option>`;
638
+ }
639
+ return;
640
+ }
641
+
642
  const data = await response.json();
643
+ console.log('[모델 목록] 응답 데이터:', data);
644
 
645
  fileModelSelect.innerHTML = '<option value="">모델을 선택하세요...</option>';
646
 
647
  if (data.models && data.models.length > 0) {
648
+ console.log(`[모델 목록] ${data.models.length}개 모델 발견`);
649
  data.models.forEach(model => {
650
  const option = document.createElement('option');
651
  option.value = model.name;
 
656
  : model.name;
657
  option.textContent = displayText;
658
  fileModelSelect.appendChild(option);
659
+ console.log(`[모델 목록] 모델 추가: ${model.name} (타입: ${model.type || 'unknown'})`);
660
  });
661
+ console.log(`[모델 목록] 총 ${data.models.length}개 모델 로드 완료`);
662
  } else if (data.error) {
663
+ console.error('[모델 목록] 오류:', data.error);
664
+ fileModelSelect.innerHTML = `<option value="">${escapeHtml(data.error) || '모델을 불러올 수 없습니다'}</option>`;
665
+ } else {
666
+ console.warn('[모델 목록] 모델이 없습니다');
667
+ fileModelSelect.innerHTML = '<option value="">사용 가능한 모델이 없습니다</option>';
668
  }
669
  } catch (error) {
670
+ console.error('[모델 목록] 로드 오류:', error);
671
+ fileModelSelect.innerHTML = `<option value="">모델 로드 실패: ${escapeHtml(error.message)}</option>`;
672
  }
673
  }
674
 
675
  // 파일 목록 로드
676
  async function loadFiles() {
677
+ if (!filesTableBody) {
678
+ console.error('[파일 목록] filesTableBody 요소를 찾을 수 없습니다.');
679
+ return;
680
+ }
681
+
682
  try {
683
+ console.log('[파일 목록] API 요청 시작: /api/files');
684
+ const response = await fetch('/api/files', {
685
+ credentials: 'include', // 쿠키 포함 (세션 인증)
686
+ headers: {
687
+ 'Accept': 'application/json'
688
+ }
689
+ });
690
+
691
+ console.log('[파일 목록] 응답 상태:', response.status, response.statusText);
692
+ console.log('[파일 목록] 응답 헤더:', Object.fromEntries(response.headers.entries()));
693
+
694
+ if (!response.ok) {
695
+ const errorText = await response.text();
696
+ console.error('[파일 목록] 응답 오류:', errorText);
697
+ try {
698
+ const errorData = JSON.parse(errorText);
699
+ filesTableBody.innerHTML = `<tr><td colspan="5" style="text-align: center; padding: 20px; color: #c5221f;">파일 목록을 불러올 수 없습니다: ${escapeHtml(errorData.error || '서버 오류')}</td></tr>`;
700
+ } catch {
701
+ filesTableBody.innerHTML = `<tr><td colspan="5" style="text-align: center; padding: 20px; color: #c5221f;">파일 목록을 불러올 수 없습니다 (${response.status} ${response.statusText})</td></tr>`;
702
+ }
703
+ return;
704
+ }
705
+
706
  const data = await response.json();
707
+ console.log('[파일 목록] 응답 데이터:', data);
708
 
709
  filesTableBody.innerHTML = '';
710
 
 
790
  });
791
  });
792
  } else {
793
+ console.log('[파일 목록] 업로드된 파일이 없습니다');
794
  filesTableBody.innerHTML = '<tr><td colspan="5" style="text-align: center; padding: 20px; color: #5f6368;">업로드된 파일이 없습니다.</td></tr>';
795
  }
796
  } catch (error) {
797
+ console.error('[파일 목록] 로드 오류:', error);
798
+ filesTableBody.innerHTML = `<tr><td colspan="5" style="text-align: center; padding: 20px; color: #c5221f;">파일 목록을 불러오는 중 오류가 발생했습니다.<br><small>${escapeHtml(error.message)}</small></td></tr>`;
799
  }
800
  }
801
 
 
889
  progressMap.set(index, { file, item, status: 'waiting', step: 0 });
890
  });
891
 
892
+ // 업로드 단계 정의 (더 세분화)
893
  const uploadSteps = [
894
  { name: '업로드 폴더 확인', step: 1 },
895
  { name: '파일 수신', step: 2 },
896
  { name: '파일 검증', step: 3 },
897
  { name: '파일 저장', step: 4 },
898
  { name: '데이터베이스 저장', step: 5 },
899
+ { name: 'Parent Chunk 생성', step: 6, detail: 'AI 분석 중...' },
900
+ { name: 'Child Chunk 생성', step: 7, detail: '섹션 분할 중...' },
901
+ { name: '회차 분석', step: 8, detail: '회차 분석 준비 중...' },
902
+ { name: 'Graph Extraction', step: 9, detail: 'Graph Extraction 준비 중...' },
903
+ { name: '완료', step: 10 }
904
  ];
905
 
906
+ function updateProgressStatus(fileIndex, stepIndex, stepName, totalFiles, detail = null) {
907
  const statusElement = document.getElementById(`progress-status-${fileIndex}`);
908
  if (statusElement) {
909
  const step = uploadSteps[stepIndex] || { name: stepName || '처리 중', step: stepIndex + 1 };
910
+ const currentFile = fileIndex + 1;
911
+ const detailText = detail || step.detail || '';
912
+ const detailDisplay = detailText ? ` - ${detailText}` : '';
913
+ statusElement.innerHTML = `<span class="spinner"></span>파일 ${currentFile}/${totalFiles}: ${step.name} (${step.step}/10)${detailDisplay}`;
914
  }
915
  }
916
 
917
+ function updateOverallStatus(currentFile, totalFiles, stepIndex, detail = null) {
918
  const step = uploadSteps[stepIndex] || { name: '처리 중', step: stepIndex + 1 };
919
+ const progressPercent = Math.round((currentFile / totalFiles) * 100);
920
+ const detailText = detail || step.detail || '';
921
+ const detailDisplay = detailText ? ` - ${detailText}` : '';
922
+ fileUploadStatus.textContent = `[파일 ${currentFile}/${totalFiles}] ${step.name} 중... (${step.step}/10)${detailDisplay} - 전체 진행률: ${progressPercent}%`;
923
  }
924
 
925
  if (fileUploadStatus) {
926
+ fileUploadStatus.textContent = `[파일 0/${files.length}] 업로드 준비 중... - 전체 진행률: 0%`;
927
  fileUploadStatus.className = 'file-upload-status progress';
928
  }
929
 
 
969
 
970
  // 단계 1: 업로드 폴더 확인
971
  console.log(`[단계 1] 업로드 폴더 확인 시작`);
972
+ updateProgressStatus(i, 0, '업로드 폴더 확인', files.length);
973
  updateOverallStatus(i + 1, files.length, 0);
974
 
975
  console.log(`[단계 1] fetch 호출 시작: /api/upload`);
976
  console.log(`[단계 1] FormData 항목:`, Array.from(formData.entries()).map(([k, v]) => [k, v instanceof File ? v.name : v]));
977
 
978
+ // 타임아웃이 있는 fetch 래퍼 (30분 타임아웃 - 큰 파일 처리 시간 고려)
979
+ const fetchWithTimeout = (url, options, timeout = 1800000) => { // 30분 타임아웃
980
  return Promise.race([
981
  fetch(url, options),
982
  new Promise((_, reject) =>
 
989
  method: 'POST',
990
  body: formData,
991
  credentials: 'include' // 쿠키 포함 (세션 인증)
992
+ }, 1800000); // 30분 타임아웃
993
 
994
  console.log(`[단계 1] fetch 응답 수신: ${response.status} ${response.statusText}`);
995
 
 
1007
  }
1008
 
1009
  // 단계 2: 파일 수신
1010
+ updateProgressStatus(i, 1, '파일 수신', files.length);
1011
  updateOverallStatus(i + 1, files.length, 1);
1012
 
1013
  console.log(`[응답 수신] 상태: ${response.status} ${response.statusText}, Content-Type: ${response.headers.get('Content-Type')}`);
1014
 
1015
  // 단계 3: 파일 검증
1016
+ updateProgressStatus(i, 2, '파일 검증', files.length);
1017
  updateOverallStatus(i + 1, files.length, 2);
1018
 
1019
  let data;
 
1029
  }
1030
 
1031
  // 단계 4: 파일 저장
1032
+ updateProgressStatus(i, 3, '파일 저장', files.length);
1033
  updateOverallStatus(i + 1, files.length, 3);
1034
 
1035
  if (response.ok) {
1036
+ const fileId = data.file_id;
1037
+ const episodeCount = data.episode_count || 0;
1038
+ const needsProcessing = data.needs_processing !== false; // 기본값 true
 
 
 
 
 
 
 
 
1039
 
1040
+ // 텍스트 파일이 아니면 처리 단계 건너뛰기
1041
+ if (!needsProcessing) {
1042
+ successCount++;
1043
+ const modelName = data.model_name || '알 수 없음';
1044
+ updateProgressStatus(i, 9, '완료', files.length);
1045
+ updateOverallStatus(i + 1, files.length, 9);
1046
+ statusElement.className = 'progress-item-status success';
1047
+ statusElement.innerHTML = `✓ 완료 (파일 ${i + 1}/${files.length})`;
1048
+ statusElement.title = `모델: ${modelName}`;
1049
+ itemElement.style.opacity = '0.7';
1050
+ console.log(`[업로드 성공] ${file.name} → 모델: ${modelName}`);
1051
+ } else {
1052
+ // 단계별 처리 함수
1053
+ const processStep = async (stepName, stepNumber, url, timeout, detailText = '') => {
1054
+ updateProgressStatus(i, stepNumber, stepName, files.length, detailText);
1055
+ updateOverallStatus(i + 1, files.length, stepNumber, detailText);
1056
+
1057
+ const stepResponse = await fetchWithTimeout(url, {
1058
+ method: 'POST',
1059
+ credentials: 'include'
1060
+ }, timeout);
1061
+
1062
+ if (!stepResponse.ok) {
1063
+ const stepError = await stepResponse.json().catch(() => ({ error: `HTTP ${stepResponse.status}` }));
1064
+ throw new Error(`${stepName} 실패: ${stepError.error || '알 수 없는 오류'}`);
1065
+ }
1066
+
1067
+ return await stepResponse.json();
1068
+ };
1069
+
1070
+ try {
1071
+ // 단계 1: Parent Chunk 생성 (10분 타임아웃)
1072
+ await processStep('Parent Chunk 생성', 5, `/api/files/${fileId}/process/parent-chunk`, 600000, 'AI 분석 중...');
1073
+
1074
+ // 단계 2: Chunk 생성 (5분 타임아웃)
1075
+ const chunksData = await processStep('Child Chunk 생성', 6, `/api/files/${fileId}/process/chunks`, 300000, '섹션 분할 중...');
1076
+ const chunkCount = chunksData.chunk_count || 0;
1077
+
1078
+ // 단계 3: 회차 분석 (회차당 2분, 최대 30분)
1079
+ const episodeTimeout = episodeCount > 0 ? Math.min(episodeCount * 120000, 1800000) : 600000;
1080
+ const episodeDetail = episodeCount > 0 ? `총 ${episodeCount}회차 중 분석 중...` : '회차 분석 중...';
1081
+ await processStep('회차 분석', 7, `/api/files/${fileId}/process/episode-analysis`, episodeTimeout, episodeDetail);
1082
+
1083
+ // 단계 4: Graph Extraction (회차당 1분, 최대 20분)
1084
+ const graphTimeout = episodeCount > 0 ? Math.min(episodeCount * 60000, 1200000) : 300000;
1085
+ const graphDetail = episodeCount > 0 ? `총 ${episodeCount}회차 중 추출 중...` : 'Graph Extraction 중...';
1086
+ await processStep('Graph Extraction', 8, `/api/files/${fileId}/process/graph`, graphTimeout, graphDetail);
1087
+
1088
+ successCount++;
1089
+ const modelName = data.model_name || '알 수 없음';
1090
+
1091
+ // 단계 10: 완료
1092
+ updateProgressStatus(i, 9, '완료', files.length);
1093
+ updateOverallStatus(i + 1, files.length, 9);
1094
+
1095
+ statusElement.className = 'progress-item-status success';
1096
+ statusElement.innerHTML = `✓ 완료 (파일 ${i + 1}/${files.length})`;
1097
+ statusElement.title = `모델: ${modelName}${chunkCount > 0 ? `, 청크: ${chunkCount}개` : ''}`;
1098
+ itemElement.style.opacity = '0.7';
1099
+ console.log(`[업로드 성공] ${file.name} → 모델: ${modelName}, 청크: ${chunkCount}개`);
1100
+ } catch (stepError) {
1101
+ // 단계별 처리 실패
1102
+ throw stepError;
1103
+ }
1104
+ }
1105
 
1106
+ // 성공한 경우 진행 상태 업데이트
1107
+ const progressPercent = Math.round(((i + 1) / files.length) * 100);
1108
+ if (fileUploadStatus) {
1109
+ if (i < files.length - 1) {
1110
+ fileUploadStatus.textContent = `[파일 ${i + 1}/${files.length}] 완료, 다음 파일 처리 중... - 전체 진행률: ${progressPercent}%`;
1111
+ } else {
1112
+ fileUploadStatus.textContent = `[파일 ${i + 1}/${files.length}] 모든 파일 처리 완료 - 전체 진행률: 100%`;
1113
+ }
1114
+ }
1115
  } else {
1116
  failCount++;
1117
  const errorMsg = data.error || data.message || `HTTP ${response.status} 오류`;
1118
  statusElement.className = 'progress-item-status error';
1119
+ statusElement.innerHTML = `✗ 실패 (파일 ${i + 1}/${files.length})`;
1120
  statusElement.title = errorMsg; // 툴팁으로 상세 에러 표시
1121
  statusElement.style.cursor = 'help'; // 툴팁 표시를 위한 커서 변경
1122
  errors.push(`${file.name}: ${errorMsg}`);
 
1127
  data: data,
1128
  responseText: responseText
1129
  });
1130
+
1131
+ // 실패한 경우 진행 상태 업데이트
1132
+ const progressPercent = Math.round(((i + 1) / files.length) * 100);
1133
+ if (fileUploadStatus) {
1134
+ if (i < files.length - 1) {
1135
+ fileUploadStatus.textContent = `[파일 ${i + 1}/${files.length}] 실패, 다음 파일 처리 중... - 전체 진행률: ${progressPercent}%`;
1136
+ } else {
1137
+ fileUploadStatus.textContent = `[파일 ${i + 1}/${files.length}] 모든 파일 처리 완료 (일부 실패) - 전체 진행률: ${progressPercent}%`;
1138
+ }
1139
+ fileUploadStatus.className = 'file-upload-status error';
1140
+ }
1141
+ showAlert(`파일 업로드 실패: ${file.name}\n${errorMsg}`, 'error');
1142
  }
1143
  } catch (error) {
1144
  failCount++;
1145
  const errorMsg = error.message || '네트워크 오류';
1146
+
1147
+ // statusElement가 있으면 실패 상태로 표시
1148
  if (statusElement) {
1149
  statusElement.className = 'progress-item-status error';
1150
+ statusElement.innerHTML = `✗ 실패 (파일 ${i + 1}/${files.length})`;
1151
  statusElement.title = errorMsg; // 툴팁으로 상세 에러 표시
1152
  statusElement.style.cursor = 'help'; // 툴팁 표시를 위한 커서 변경
1153
  }
1154
+
1155
  errors.push(`${file.name}: ${errorMsg}`);
1156
  console.error(`[업로드 예외] 파일: ${file.name}`, error);
1157
  console.error(`[업로드 예외 스택]`, error.stack);
1158
 
1159
  // 타임아웃 오류인 경우 사용자에게 명확한 메시지 표시
1160
  if (error.message && error.message.includes('타임아웃')) {
1161
+ const timeoutMsg = `파일 업로드 타임아웃: ${file.name}\n\n파일이 크거나 처리 시간이 오래 걸려 타임아웃이 발생했습니다.\n서버에서는 파일 처리가 계속 진행 중일 수 있습니다.\n서버 로그를 확인하시거나 잠시 후 파일 목록을 새로고침해보세요.`;
1162
  console.error(`[타임아웃 오류] ${timeoutMsg}`);
1163
  if (fileUploadStatus) {
1164
+ fileUploadStatus.textContent = `[${i + 1}/${files.length}] 타임아웃: ${file.name} - 서버 로그 확인 필요`;
1165
  fileUploadStatus.className = 'file-upload-status error';
1166
  }
1167
  // 사용자에게 알림 표시
 
1181
  showAlert(`파일 업로드 오류: ${file.name}\n${errorMsg}`, 'error');
1182
  }
1183
  console.error(`[스택 트레이스]`, error.stack);
1184
+
1185
+ // 진행 상태 업데이트 (에러 발생 시에도)
1186
+ const progressPercent = Math.round(((i + 1) / files.length) * 100);
1187
+ if (fileUploadStatus) {
1188
+ if (i < files.length - 1) {
1189
+ fileUploadStatus.textContent = `[파일 ${i + 1}/${files.length}] 오류 발생, 다음 파일 처리 중... - 전체 진행률: ${progressPercent}%`;
1190
+ } else {
1191
+ fileUploadStatus.textContent = `[파일 ${i + 1}/${files.length}] 모든 파일 처리 완료 (일부 실패) - 전체 진행률: ${progressPercent}%`;
1192
+ }
1193
  }
1194
  }
1195
+ } // for 루프 닫기
1196
  } catch (uploadLoopError) {
1197
  console.error('[업로드 루프 오류]', uploadLoopError);
1198
  fileUploadStatus.textContent = `업로드 처리 중 오류 발생: ${uploadLoopError.message}`;
 
1205
  if (fileUploadStatus) {
1206
  fileUploadStatus.className = 'file-upload-status';
1207
  if (successCount > 0) {
1208
+ fileUploadStatus.textContent = `✅ ${successCount}개 파일 업로드 완료${failCount > 0 ? ` (${failCount}개 실패)` : ''} - 전체 진행률: 100%`;
1209
  fileUploadStatus.className = 'file-upload-status success';
1210
  showAlert(`${successCount}개 파일이 성공적으로 업로드되었습니다.${failCount > 0 ? ` (${failCount}개 실패)` : ''}`, 'success');
1211
  loadFiles();
1212
  } else {
1213
+ fileUploadStatus.textContent = `❌ 모든 파일 업로드 실패 - 전체 진행률: 100%`;
1214
  fileUploadStatus.className = 'file-upload-status error';
1215
  const errorDetails = errors.length > 0 ? '\n\n오류 상세:\n' + errors.slice(0, 5).map((e, idx) => `${idx + 1}. ${e}`).join('\n') + (errors.length > 5 ? `\n... 외 ${errors.length - 5}개 오류` : '') : '';
1216
  showAlert(`파일 업로드에 실패했습니다.${errorDetails}`, 'error');
 
1228
  console.error('[handleFileUpload] 완료 처리 오류:', finalError);
1229
  }
1230
 
1231
+ // 업로드가 완전히 끝날 때까지 진행 상태 유지 (자동 숨김 제거)
1232
+ // 사용자가 수동으로 닫을 수 있도록 버튼 추가는 선택사항
1233
+ // 진행 상태는 업로드 완료 후에도 계속 표시됨
 
 
 
 
 
 
1234
  }
1235
 
1236
  // 파일 삭제
 
1552
  }
1553
  });
1554
 
1555
+ // 페이지 로드 시 초기화 (한 번만 실행)
1556
+ let initialized = false;
1557
+ function initializePage() {
1558
+ if (initialized) {
1559
+ console.log('[초기화] 이미 초기화되었습니다.');
1560
+ return;
1561
+ }
1562
+
1563
+ // DOM 요소 존재 확인
1564
+ if (!fileModelSelect || !filesTableBody) {
1565
+ console.error('[초기화] 필수 DOM 요소가 없습니다:', {
1566
+ fileModelSelect: !!fileModelSelect,
1567
+ filesTableBody: !!filesTableBody
1568
+ });
1569
+ return;
1570
+ }
1571
+
1572
+ initialized = true;
1573
+ console.log('[초기화] 페이지 초기화 시작');
1574
+
1575
+ // 모델 목록 로드
1576
+ loadModelsForFiles().catch(error => {
1577
+ console.error('[초기화] 모델 목록 로드 실패:', error);
1578
+ initialized = false; // 실패 시 재시도 가능하도록
1579
+ });
1580
+
1581
+ // 파일 목록 로드
1582
+ loadFiles().catch(error => {
1583
+ console.error('[초기화] 파일 목록 로드 실패:', error);
1584
+ initialized = false; // 실패 시 재시도 가능하도록
1585
+ });
1586
+ }
1587
+
1588
+ // DOMContentLoaded 또는 load 이벤트에서 초기화
1589
+ if (document.readyState === 'loading') {
1590
+ document.addEventListener('DOMContentLoaded', initializePage);
1591
+ } else {
1592
+ // 이미 로드된 경우 즉시 실행
1593
+ initializePage();
1594
+ }
1595
+
1596
+ // 추가 안전장치: load 이벤트에서도 시도 (이미 초기화되었으면 무시됨)
1597
+ window.addEventListener('load', initializePage);
1598
  </script>
1599
  </body>
1600
  </html>
templates/index.html CHANGED
@@ -88,6 +88,7 @@
88
  padding: 12px;
89
  }
90
 
 
91
  .sidebar.collapsed .model-selector-label,
92
  .sidebar.collapsed .model-select,
93
  .sidebar.collapsed .model-status,
@@ -258,6 +259,21 @@
258
  }
259
 
260
  /* AI 모델 선택 영역 */
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
261
  .model-selector {
262
  border-top: 1px solid var(--border);
263
  padding: 16px;
@@ -733,6 +749,11 @@
733
  flex-direction: row-reverse;
734
  }
735
 
 
 
 
 
 
736
  .message-avatar {
737
  width: 32px;
738
  height: 32px;
@@ -744,6 +765,13 @@
744
  flex-shrink: 0;
745
  }
746
 
 
 
 
 
 
 
 
747
  .message.user .message-avatar {
748
  background: var(--accent);
749
  color: white;
@@ -771,7 +799,7 @@
771
  }
772
 
773
  .message.ai .message-bubble {
774
- white-space: normal;
775
  }
776
 
777
  .message.ai .message-bubble br {
@@ -1216,13 +1244,26 @@
1216
  <!-- 대화 히스토리 항목들이 여기에 동적으로 추가됩니다 -->
1217
  </div>
1218
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1219
  <!-- AI 모델 선택 영역 -->
1220
  <div class="model-selector">
1221
  <div class="model-selector-label">
1222
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
1223
  <path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"/>
1224
  </svg>
1225
- 로컬 AI 모델
1226
  </div>
1227
  <select class="model-select" id="modelSelect">
1228
  <option value="">모델을 선택하세요...</option>
@@ -1419,20 +1460,32 @@
1419
  let currentChatId = null;
1420
  let currentSessionId = null;
1421
  let chatSessions = [];
1422
- let selectedModel = localStorage.getItem('selectedModel') || '';
 
1423
  let selectedFileIds = JSON.parse(localStorage.getItem('selectedFileIds') || '[]');
1424
 
1425
  const novelList = document.getElementById('novelList');
1426
  const selectedNovelsInfo = document.getElementById('selectedNovelsInfo');
1427
 
1428
- // 모델 선택 이벤트
1429
  modelSelect.addEventListener('change', function() {
1430
  selectedModel = this.value;
1431
  localStorage.setItem('selectedModel', selectedModel);
1432
- updateModelStatus();
1433
  loadNovels(); // 모델 변경 시 웹소설 목록 로드
1434
  });
1435
 
 
 
 
 
 
 
 
 
 
 
 
1436
  // 웹소설 목록 로드
1437
  async function loadNovels() {
1438
  try {
@@ -1545,7 +1598,78 @@
1545
  }
1546
  }
1547
 
1548
- // 모델 목록 로드
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1549
  async function loadModels() {
1550
  refreshModelsBtn.disabled = true;
1551
  refreshModelsBtn.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M23 4v6h-6M1 20v-6h6M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg> 로딩 중...';
@@ -1557,21 +1681,61 @@
1557
  modelSelect.innerHTML = '<option value="">모델을 선택하세요...</option>';
1558
 
1559
  if (data.models && data.models.length > 0) {
 
 
 
 
1560
  data.models.forEach(model => {
1561
- const option = document.createElement('option');
1562
- option.value = model.name;
1563
- option.textContent = model.name;
1564
- if (model.name === selectedModel) {
1565
- option.selected = true;
1566
  }
1567
- modelSelect.appendChild(option);
1568
  });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1569
  updateModelStatus('connected');
1570
  } else {
1571
  updateModelStatus('error', '사용 가능한 모델이 없습니다');
1572
  }
1573
  } catch (error) {
1574
- updateModelStatus('error', 'Ollama 연결 실패');
1575
  console.error('모델 로드 오류:', error);
1576
  } finally {
1577
  refreshModelsBtn.disabled = false;
@@ -1584,12 +1748,18 @@
1584
  modelStatus.className = 'model-status';
1585
  if (status === 'connected') {
1586
  modelStatus.classList.add('connected');
1587
- modelStatus.innerHTML = '<span class="model-status-dot"></span><span>연결됨</span>';
 
 
 
 
 
 
1588
  } else if (status === 'error') {
1589
  modelStatus.classList.add('error');
1590
  modelStatus.innerHTML = `<span class="model-status-dot"></span><span>${message || '오류'}</span>`;
1591
  } else {
1592
- modelStatus.innerHTML = '<span class="model-status-dot"></span><span>연결 안 됨</span>';
1593
  }
1594
  }
1595
 
@@ -1639,8 +1809,18 @@
1639
 
1640
  try {
1641
  const response = await fetch('/api/chat/sessions');
 
 
 
 
 
 
1642
  const data = await response.json();
1643
 
 
 
 
 
1644
  chatHistory.innerHTML = '';
1645
  chatSessions = data.sessions || [];
1646
 
@@ -1666,7 +1846,7 @@
1666
  <svg class="chat-item-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
1667
  <path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
1668
  </svg>
1669
- <div class="chat-item-title">${session.title || '새 대화'}</div>
1670
  <div class="chat-item-time">${formatTime(session.updated_at)}</div>
1671
  `;
1672
 
@@ -1675,7 +1855,7 @@
1675
  });
1676
  } catch (error) {
1677
  console.error('대화 히스토리 로드 오류:', error);
1678
- chatHistory.innerHTML = '<div style="padding: 16px; text-align: center; color: var(--text-secondary); font-size: 14px;">대화 기록을 불러올 수 없습니다</div>';
1679
  }
1680
  }
1681
 
@@ -1699,19 +1879,54 @@
1699
  async function loadChat(sessionId) {
1700
  try {
1701
  const response = await fetch(`/api/chat/sessions/${sessionId}`);
 
 
 
 
 
 
 
 
 
 
 
 
1702
  const data = await response.json();
1703
 
1704
- if (!data.session) return;
 
 
 
 
1705
 
1706
  currentSessionId = sessionId;
1707
  currentChatId = sessionId;
1708
  chatContainer.innerHTML = '';
1709
 
 
 
 
1710
  if (data.session.messages && data.session.messages.length > 0) {
1711
- data.session.messages.forEach(msg => {
1712
- addMessage(msg.role, msg.content, false);
1713
- });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1714
  } else {
 
1715
  if (emptyState) {
1716
  emptyState.style.display = 'flex';
1717
  }
@@ -1723,7 +1938,11 @@
1723
  }
1724
  } catch (error) {
1725
  console.error('대화 로드 오류:', error);
1726
- alert('대화를 불러올 없습니다.');
 
 
 
 
1727
  }
1728
  }
1729
 
@@ -1737,7 +1956,9 @@
1737
  },
1738
  body: JSON.stringify({
1739
  title: '새 대화',
1740
- model_name: selectedModel || null
 
 
1741
  })
1742
  });
1743
 
@@ -1879,8 +2100,27 @@
1879
  function formatContentWithFootnotes(content) {
1880
  if (!content) return { formattedContent: '', footnotes: [] };
1881
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1882
  // 먼저 마크다운을 HTML로 변환
1883
- let htmlContent = markdownToHtml(content);
 
 
 
 
 
1884
 
1885
  // [근거: 내용] 패턴 찾기 (마크다운 변환 후에 처리)
1886
  const footnotePattern = /\[근거:\s*([^\]]+)\]/g;
@@ -1889,8 +2129,12 @@
1889
 
1890
  // HTML에서 [근거: ] 패턴을 찾아서 각주로 변환
1891
  htmlContent = htmlContent.replace(footnotePattern, (match, footnoteText) => {
1892
- footnoteIndex++;
1893
  const cleanText = footnoteText.trim();
 
 
 
 
 
1894
  footnotes.push(cleanText);
1895
  // 직접 HTML로 변환 (플레이스홀더 사용하지 않음)
1896
  return `<sup class="footnote-ref" data-footnote="${footnoteIndex}">[${footnoteIndex}]<span class="footnote-tooltip">${escapeHtml(cleanText)}</span></sup>`;
@@ -1908,6 +2152,18 @@
1908
 
1909
  // 메시지 추가
1910
  function addMessage(role, content, save = true) {
 
 
 
 
 
 
 
 
 
 
 
 
1911
  // 빈 상태 숨기기
1912
  if (emptyState) {
1913
  emptyState.style.display = 'none';
@@ -1923,11 +2179,30 @@
1923
  const contentDiv = document.createElement('div');
1924
  contentDiv.className = 'message-content';
1925
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1926
  const bubble = document.createElement('div');
1927
  bubble.className = 'message-bubble';
1928
 
1929
  // AI 응답인 경우 [근거: ] 형식을 각주로 변환
1930
  if (role === 'ai') {
 
 
 
1931
  const { formattedContent, footnotes } = formatContentWithFootnotes(content);
1932
  bubble.innerHTML = formattedContent;
1933
 
@@ -1965,33 +2240,109 @@
1965
  chatContainer.scrollTop = chatContainer.scrollHeight;
1966
  }
1967
 
1968
- // 타이핑 인디케이터 표시
1969
- function showTypingIndicator() {
1970
- const messageDiv = document.createElement('div');
1971
- messageDiv.className = 'message ai';
1972
- messageDiv.id = 'typingIndicator';
1973
-
1974
- const avatar = document.createElement('div');
1975
- avatar.className = 'message-avatar';
1976
- avatar.textContent = '🤖';
1977
-
1978
- const contentDiv = document.createElement('div');
1979
- contentDiv.className = 'message-content';
1980
-
1981
- const typingDiv = document.createElement('div');
1982
- typingDiv.className = 'typing-indicator';
1983
- for (let i = 0; i < 3; i++) {
1984
- const dot = document.createElement('div');
1985
- dot.className = 'typing-dot';
1986
- typingDiv.appendChild(dot);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1987
  }
1988
-
1989
- contentDiv.appendChild(typingDiv);
1990
- messageDiv.appendChild(avatar);
1991
- messageDiv.appendChild(contentDiv);
1992
- chatContainer.appendChild(messageDiv);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1993
  chatContainer.scrollTop = chatContainer.scrollHeight;
1994
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1995
 
1996
  // 타이핑 인디케이터 제거
1997
  function removeTypingIndicator() {
@@ -2006,6 +2357,12 @@
2006
  const message = messageInput.value.trim();
2007
  if (!message) return;
2008
 
 
 
 
 
 
 
2009
  // 세션이 없으면 새로 생성
2010
  if (!currentSessionId) {
2011
  currentSessionId = await createNewSession();
@@ -2021,11 +2378,42 @@
2021
  messageInput.disabled = true;
2022
  sendButton.disabled = true;
2023
 
2024
- // 타이핑 인디케이터 표시
2025
- showTypingIndicator();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2026
 
2027
  try {
2028
  // API 호출
 
 
 
 
 
 
 
 
2029
  const response = await fetch('/api/chat', {
2030
  method: 'POST',
2031
  headers: {
@@ -2033,18 +2421,38 @@
2033
  },
2034
  body: JSON.stringify({
2035
  message: message,
2036
- model: selectedModel || null,
 
2037
  file_ids: selectedFileIds.length > 0 ? selectedFileIds : [],
2038
  session_id: currentSessionId
2039
  })
2040
  });
 
 
2041
 
2042
- removeTypingIndicator();
 
 
 
 
 
 
 
2043
 
2044
  if (response.ok) {
2045
  const data = await response.json();
2046
- const aiResponse = data.response || '응답을 생성하는 중 오류가 발생했습니다.';
2047
- addMessage('ai', aiResponse, false);
 
 
 
 
 
 
 
 
 
 
2048
 
2049
  // DB에 AI 응답 저장 (이미 백엔드에서 저장됨)
2050
  // 세션 목록 새로고침 (제목 업데이트 반영)
@@ -2057,6 +2465,7 @@
2057
  }
2058
  } else {
2059
  const error = await response.json().catch(() => ({ error: '서버 오류' }));
 
2060
  addMessage('ai', `오류: ${error.error || '알 수 없는 오류가 발생했습니다.'}`, false);
2061
  }
2062
  } catch (error) {
@@ -2391,9 +2800,14 @@
2391
  // 페이지 로드 시 초기화
2392
  window.addEventListener('load', async () => {
2393
  await loadChatHistory();
2394
- await loadModels();
 
2395
  // 모델 선택 여부와 관계없이 웹소설 목록 로드 (모델이 선택되어 있으면 해당 모델의 파일만, 없으면 모든 파일)
2396
  loadNovels();
 
 
 
 
2397
  messageInput.focus();
2398
  });
2399
  </script>
 
88
  padding: 12px;
89
  }
90
 
91
+ .sidebar.collapsed .available-models-list,
92
  .sidebar.collapsed .model-selector-label,
93
  .sidebar.collapsed .model-select,
94
  .sidebar.collapsed .model-status,
 
259
  }
260
 
261
  /* AI 모델 선택 영역 */
262
+ .available-models-list {
263
+ border-top: 1px solid var(--border);
264
+ padding: 16px;
265
+ background: var(--bg-primary);
266
+ }
267
+
268
+ .available-models-list .model-select {
269
+ margin-top: 8px;
270
+ }
271
+
272
+ .available-models-list .model-select:disabled {
273
+ opacity: 0.6;
274
+ cursor: not-allowed;
275
+ }
276
+
277
  .model-selector {
278
  border-top: 1px solid var(--border);
279
  padding: 16px;
 
749
  flex-direction: row-reverse;
750
  }
751
 
752
+ .message.ai {
753
+ flex-direction: column;
754
+ align-items: flex-start;
755
+ }
756
+
757
  .message-avatar {
758
  width: 32px;
759
  height: 32px;
 
765
  flex-shrink: 0;
766
  }
767
 
768
+ .message.ai .message-avatar {
769
+ width: 24px;
770
+ height: 24px;
771
+ font-size: 14px;
772
+ margin-bottom: 8px;
773
+ }
774
+
775
  .message.user .message-avatar {
776
  background: var(--accent);
777
  color: white;
 
799
  }
800
 
801
  .message.ai .message-bubble {
802
+ white-space: pre-wrap; /* 줄바꿈 보존 */
803
  }
804
 
805
  .message.ai .message-bubble br {
 
1244
  <!-- 대화 히스토리 항목들이 여기에 동적으로 추가됩니다 -->
1245
  </div>
1246
 
1247
+ <!-- 사용 가능한 AI 목록 -->
1248
+ <div class="available-models-list">
1249
+ <div class="model-selector-label">
1250
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
1251
+ <path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"/>
1252
+ </svg>
1253
+ 사용 가능한 AI 목록
1254
+ </div>
1255
+ <select class="model-select" id="availableModelsSelect">
1256
+ <option value="">AI 목록을 불러오는 중...</option>
1257
+ </select>
1258
+ </div>
1259
+
1260
  <!-- AI 모델 선택 영역 -->
1261
  <div class="model-selector">
1262
  <div class="model-selector-label">
1263
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
1264
  <path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"/>
1265
  </svg>
1266
+ AI 모델 선택
1267
  </div>
1268
  <select class="model-select" id="modelSelect">
1269
  <option value="">모델을 선택하세요...</option>
 
1460
  let currentChatId = null;
1461
  let currentSessionId = null;
1462
  let chatSessions = [];
1463
+ let selectedModel = localStorage.getItem('selectedModel') || ''; // 질문 분석용 모델
1464
+ let answerModel = localStorage.getItem('answerModel') || ''; // 최종 답변용 모델
1465
  let selectedFileIds = JSON.parse(localStorage.getItem('selectedFileIds') || '[]');
1466
 
1467
  const novelList = document.getElementById('novelList');
1468
  const selectedNovelsInfo = document.getElementById('selectedNovelsInfo');
1469
 
1470
+ // 모델 선택 이벤트 (질문 분석용)
1471
  modelSelect.addEventListener('change', function() {
1472
  selectedModel = this.value;
1473
  localStorage.setItem('selectedModel', selectedModel);
1474
+ updateModelStatus('connected');
1475
  loadNovels(); // 모델 변경 시 웹소설 목록 로드
1476
  });
1477
 
1478
+ // 사용 가능한 AI 목록 선택 이벤트 (최종 답변용)
1479
+ const availableModelsSelect = document.getElementById('availableModelsSelect');
1480
+ availableModelsSelect.addEventListener('change', function() {
1481
+ answerModel = this.value;
1482
+ localStorage.setItem('answerModel', answerModel);
1483
+ // 선택된 모델이 있으면 콘솔에 로그 출력 (디버깅용)
1484
+ if (answerModel) {
1485
+ console.log('[답변 모델 선택]', answerModel);
1486
+ }
1487
+ });
1488
+
1489
  // 웹소설 목록 로드
1490
  async function loadNovels() {
1491
  try {
 
1598
  }
1599
  }
1600
 
1601
+ // 사용 가능한 AI 목록 로드 (모든 모델)
1602
+ async function loadAvailableModels() {
1603
+ const availableModelsSelect = document.getElementById('availableModelsSelect');
1604
+ availableModelsSelect.disabled = true;
1605
+ availableModelsSelect.innerHTML = '<option value="">AI 목록을 불러오는 중...</option>';
1606
+
1607
+ try {
1608
+ const response = await fetch('/api/ollama/models?all=true');
1609
+ const data = await response.json();
1610
+
1611
+ availableModelsSelect.innerHTML = '<option value="">답변 생성할 AI 모델을 선택하세요...</option>';
1612
+
1613
+ if (data.models && data.models.length > 0) {
1614
+ // 모델을 타입별로 그룹화
1615
+ const ollamaModels = [];
1616
+ const geminiModels = [];
1617
+
1618
+ data.models.forEach(model => {
1619
+ if (model.type === 'gemini') {
1620
+ geminiModels.push(model);
1621
+ } else {
1622
+ ollamaModels.push(model);
1623
+ }
1624
+ });
1625
+
1626
+ // Ollama 모델 그룹
1627
+ if (ollamaModels.length > 0) {
1628
+ const optgroup = document.createElement('optgroup');
1629
+ optgroup.label = '🤖 Ollama 모델';
1630
+ ollamaModels.forEach(model => {
1631
+ const option = document.createElement('option');
1632
+ option.value = model.name;
1633
+ option.textContent = model.name;
1634
+ if (model.name === answerModel) {
1635
+ option.selected = true;
1636
+ }
1637
+ optgroup.appendChild(option);
1638
+ });
1639
+ availableModelsSelect.appendChild(optgroup);
1640
+ }
1641
+
1642
+ // Gemini 모델 그룹
1643
+ if (geminiModels.length > 0) {
1644
+ const optgroup = document.createElement('optgroup');
1645
+ optgroup.label = '✨ Gemini 모델';
1646
+ geminiModels.forEach(model => {
1647
+ const option = document.createElement('option');
1648
+ option.value = model.name;
1649
+ // "gemini:" 접두사 제거하여 표시
1650
+ const displayName = model.name.startsWith('gemini:')
1651
+ ? model.name.substring(7)
1652
+ : model.name;
1653
+ option.textContent = displayName;
1654
+ if (model.name === answerModel) {
1655
+ option.selected = true;
1656
+ }
1657
+ optgroup.appendChild(option);
1658
+ });
1659
+ availableModelsSelect.appendChild(optgroup);
1660
+ }
1661
+
1662
+ availableModelsSelect.disabled = false;
1663
+ } else {
1664
+ availableModelsSelect.innerHTML = '<option value="">등록된 AI 모델이 없습니다</option>';
1665
+ }
1666
+ } catch (error) {
1667
+ availableModelsSelect.innerHTML = '<option value="">모델 목록 로드 실패</option>';
1668
+ console.error('사용 가능한 모델 로드 오류:', error);
1669
+ }
1670
+ }
1671
+
1672
+ // 모델 목록 로드 (학습된 웹소설이 있는 모델만)
1673
  async function loadModels() {
1674
  refreshModelsBtn.disabled = true;
1675
  refreshModelsBtn.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M23 4v6h-6M1 20v-6h6M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg> 로딩 중...';
 
1681
  modelSelect.innerHTML = '<option value="">모델을 선택하세요...</option>';
1682
 
1683
  if (data.models && data.models.length > 0) {
1684
+ // 모델을 타입별로 그룹화
1685
+ const ollamaModels = [];
1686
+ const geminiModels = [];
1687
+
1688
  data.models.forEach(model => {
1689
+ if (model.type === 'gemini') {
1690
+ geminiModels.push(model);
1691
+ } else {
1692
+ ollamaModels.push(model);
 
1693
  }
 
1694
  });
1695
+
1696
+ // 드롭다운에 추가
1697
+ // Ollama 모델 그룹
1698
+ if (ollamaModels.length > 0) {
1699
+ const optgroup = document.createElement('optgroup');
1700
+ optgroup.label = '🤖 Ollama 모델';
1701
+ ollamaModels.forEach(model => {
1702
+ const option = document.createElement('option');
1703
+ option.value = model.name;
1704
+ option.textContent = model.name;
1705
+ if (model.name === selectedModel) {
1706
+ option.selected = true;
1707
+ }
1708
+ optgroup.appendChild(option);
1709
+ });
1710
+ modelSelect.appendChild(optgroup);
1711
+ }
1712
+
1713
+ // Gemini 모델 그룹
1714
+ if (geminiModels.length > 0) {
1715
+ const optgroup = document.createElement('optgroup');
1716
+ optgroup.label = '✨ Gemini 모델';
1717
+ geminiModels.forEach(model => {
1718
+ const option = document.createElement('option');
1719
+ option.value = model.name;
1720
+ // "gemini:" 접두사 제거하여 표시
1721
+ const displayName = model.name.startsWith('gemini:')
1722
+ ? model.name.substring(7)
1723
+ : model.name;
1724
+ option.textContent = displayName;
1725
+ if (model.name === selectedModel) {
1726
+ option.selected = true;
1727
+ }
1728
+ optgroup.appendChild(option);
1729
+ });
1730
+ modelSelect.appendChild(optgroup);
1731
+ }
1732
+
1733
  updateModelStatus('connected');
1734
  } else {
1735
  updateModelStatus('error', '사용 가능한 모델이 없습니다');
1736
  }
1737
  } catch (error) {
1738
+ updateModelStatus('error', '모델 목록 로드 실패');
1739
  console.error('모델 로드 오류:', error);
1740
  } finally {
1741
  refreshModelsBtn.disabled = false;
 
1748
  modelStatus.className = 'model-status';
1749
  if (status === 'connected') {
1750
  modelStatus.classList.add('connected');
1751
+ // 선택된 모델 타입에 따라 메시지 변경
1752
+ if (selectedModel) {
1753
+ const modelType = selectedModel.toLowerCase().startsWith('gemini') ? 'Gemini' : 'Ollama';
1754
+ modelStatus.innerHTML = `<span class="model-status-dot"></span><span>${modelType} 모델 선택됨</span>`;
1755
+ } else {
1756
+ modelStatus.innerHTML = '<span class="model-status-dot"></span><span>모델 선택 가능</span>';
1757
+ }
1758
  } else if (status === 'error') {
1759
  modelStatus.classList.add('error');
1760
  modelStatus.innerHTML = `<span class="model-status-dot"></span><span>${message || '오류'}</span>`;
1761
  } else {
1762
+ modelStatus.innerHTML = '<span class="model-status-dot"></span><span>모델을 선택하세요</span>';
1763
  }
1764
  }
1765
 
 
1809
 
1810
  try {
1811
  const response = await fetch('/api/chat/sessions');
1812
+
1813
+ if (!response.ok) {
1814
+ const errorData = await response.json().catch(() => ({ error: '서버 오류' }));
1815
+ throw new Error(errorData.error || `HTTP ${response.status}`);
1816
+ }
1817
+
1818
  const data = await response.json();
1819
 
1820
+ if (data.error) {
1821
+ throw new Error(data.error);
1822
+ }
1823
+
1824
  chatHistory.innerHTML = '';
1825
  chatSessions = data.sessions || [];
1826
 
 
1846
  <svg class="chat-item-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
1847
  <path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
1848
  </svg>
1849
+ <div class="chat-item-title">${escapeHtml(session.title || '새 대화')}</div>
1850
  <div class="chat-item-time">${formatTime(session.updated_at)}</div>
1851
  `;
1852
 
 
1855
  });
1856
  } catch (error) {
1857
  console.error('대화 히스토리 로드 오류:', error);
1858
+ chatHistory.innerHTML = `<div style="padding: 16px; text-align: center; color: #ea4335; font-size: 14px;">대화 기록을 불러올 수 없습니다<br><small>${error.message || '알 수 없는 오류'}</small></div>`;
1859
  }
1860
  }
1861
 
 
1879
  async function loadChat(sessionId) {
1880
  try {
1881
  const response = await fetch(`/api/chat/sessions/${sessionId}`);
1882
+
1883
+ if (!response.ok) {
1884
+ const errorData = await response.json().catch(() => ({ error: '서버 오류' }));
1885
+ if (response.status === 404) {
1886
+ console.warn('대화 세션을 찾을 수 없습니다:', sessionId);
1887
+ // 세션이 삭제되었을 수 있으므로 히스토리만 새로고침
1888
+ await loadChatHistory();
1889
+ return;
1890
+ }
1891
+ throw new Error(errorData.error || `HTTP ${response.status}`);
1892
+ }
1893
+
1894
  const data = await response.json();
1895
 
1896
+ if (!data.session) {
1897
+ console.warn('세션 데이터가 없습니다:', sessionId);
1898
+ await loadChatHistory();
1899
+ return;
1900
+ }
1901
 
1902
  currentSessionId = sessionId;
1903
  currentChatId = sessionId;
1904
  chatContainer.innerHTML = '';
1905
 
1906
+ console.log('[대화 로드] 세션 ID:', sessionId);
1907
+ console.log('[대화 로드] 메시지 개수:', data.session.messages ? data.session.messages.length : 0);
1908
+
1909
  if (data.session.messages && data.session.messages.length > 0) {
1910
+ console.log('[대화 로드] 메시지 목록:', data.session.messages.map(m => ({ id: m.id, role: m.role, content_preview: m.content ? m.content.substring(0, 50) : '' })));
1911
+
1912
+ // 메시지를 순차적으로 추가
1913
+ for (let index = 0; index < data.session.messages.length; index++) {
1914
+ const msg = data.session.messages[index];
1915
+ console.log(`[대화 로드] 메시지 ${index + 1}/${data.session.messages.length} 추가 중:`, msg.id, msg.role, msg.content ? msg.content.substring(0, 50) : '');
1916
+ if (msg.content && msg.content.trim() !== '') {
1917
+ addMessage(msg.role, msg.content, false);
1918
+ } else {
1919
+ console.warn(`[대화 로드] 메시지 ${msg.id}의 내용이 비어있습니다.`);
1920
+ }
1921
+ }
1922
+
1923
+ // 모든 메시지 추가 후 스크롤을 맨 아래로
1924
+ setTimeout(() => {
1925
+ chatContainer.scrollTop = chatContainer.scrollHeight;
1926
+ console.log('[대화 로드] 모든 메시지 추가 완료, 스크롤 위치:', chatContainer.scrollTop);
1927
+ }, 100);
1928
  } else {
1929
+ console.log('[대화 로드] 메시지가 없습니다.');
1930
  if (emptyState) {
1931
  emptyState.style.display = 'flex';
1932
  }
 
1938
  }
1939
  } catch (error) {
1940
  console.error('대화 로드 오류:', error);
1941
+ // 자세한 에러 메시지 표시
1942
+ const errorMessage = error.message || '알 수 없는 오류';
1943
+ console.error('에러 상세:', errorMessage);
1944
+ // alert 대신 콘솔에만 표시하고 히스토리 새로고침
1945
+ await loadChatHistory();
1946
  }
1947
  }
1948
 
 
1956
  },
1957
  body: JSON.stringify({
1958
  title: '새 대화',
1959
+ model_name: selectedModel || null, // 하위 호환성
1960
+ analysis_model: selectedModel || null, // 질문 분석용 모델
1961
+ answer_model: answerModel || null // 최종 답변용 모델
1962
  })
1963
  });
1964
 
 
2100
  function formatContentWithFootnotes(content) {
2101
  if (!content) return { formattedContent: '', footnotes: [] };
2102
 
2103
+ // "[내용을 찾을 수 없습니다]" 관련 텍스트 제거
2104
+ let cleanedContent = content;
2105
+ // 줄바꿈은 보존하면서 제거 (줄바꿈 앞뒤 공백도 ���께 제거)
2106
+ cleanedContent = cleanedContent.replace(/\s*\[내용을 찾을 수 없습니다\]\s*/g, ' ');
2107
+ cleanedContent = cleanedContent.replace(/\s*내용을 찾을 수 없습니다\s*/g, ' ');
2108
+ cleanedContent = cleanedContent.replace(/\s*\[근거:\s*내용을 찾을 수 없습니다\]\s*/g, ' ');
2109
+ cleanedContent = cleanedContent.replace(/\s*\[근거:\s*"내용을 찾을 수 없습니다"\]\s*/g, ' ');
2110
+ cleanedContent = cleanedContent.replace(/\s*\[근거:\s*'내용을 찾을 수 없습니다'\]\s*/g, ' ');
2111
+ // 연속된 공백만 정리 (줄바꿈은 보존)
2112
+ cleanedContent = cleanedContent.replace(/[ \t]{2,}/g, ' '); // 탭과 공백만 정리
2113
+ // 줄바꿈 앞뒤의 불필요한 공백 제거 (줄바꿈은 유지)
2114
+ cleanedContent = cleanedContent.replace(/[ \t]+\n/g, '\n');
2115
+ cleanedContent = cleanedContent.replace(/\n[ \t]+/g, '\n');
2116
+
2117
  // 먼저 마크다운을 HTML로 변환
2118
+ let htmlContent = markdownToHtml(cleanedContent);
2119
+
2120
+ // HTML에서도 "[내용을 찾을 수 없습니다]" 제거
2121
+ htmlContent = htmlContent.replace(/\[내용을 찾을 수 없습니다\]/g, '');
2122
+ htmlContent = htmlContent.replace(/내용을 찾을 수 없습니다/g, '');
2123
+ htmlContent = htmlContent.replace(/<[^>]*>내용을 찾을 수 없습니다<\/[^>]*>/g, '');
2124
 
2125
  // [근거: 내용] 패턴 찾기 (마크다운 변환 후에 처리)
2126
  const footnotePattern = /\[근거:\s*([^\]]+)\]/g;
 
2129
 
2130
  // HTML에서 [근거: ] 패턴을 찾아서 각주로 변환
2131
  htmlContent = htmlContent.replace(footnotePattern, (match, footnoteText) => {
 
2132
  const cleanText = footnoteText.trim();
2133
+ // "내용을 찾을 수 없습니다"는 각주에 추가하지 않음
2134
+ if (cleanText.includes('내용을 찾을 수 없습니다')) {
2135
+ return '';
2136
+ }
2137
+ footnoteIndex++;
2138
  footnotes.push(cleanText);
2139
  // 직접 HTML로 변환 (플레이스홀더 사용하지 않음)
2140
  return `<sup class="footnote-ref" data-footnote="${footnoteIndex}">[${footnoteIndex}]<span class="footnote-tooltip">${escapeHtml(cleanText)}</span></sup>`;
 
2152
 
2153
  // 메시지 추가
2154
  function addMessage(role, content, save = true) {
2155
+ // 내용이 없으면 추가하지 않음 (단, AI 응답인 경우 기본 메시지 표시)
2156
+ if (!content || (typeof content === 'string' && content.trim() === '')) {
2157
+ if (role === 'ai') {
2158
+ // AI 응답이 비어있으면 기본 메시지 표시
2159
+ content = '응답을 생성할 수 없었습니다. 다시 시도해주세요.';
2160
+ console.warn('[addMessage] AI 응답이 비어있어 기본 메시지를 표시합니다.');
2161
+ } else {
2162
+ console.warn('[addMessage] 내용이 비어있어 메시지를 추가하지 않습니다. role:', role);
2163
+ return;
2164
+ }
2165
+ }
2166
+
2167
  // 빈 상태 숨기기
2168
  if (emptyState) {
2169
  emptyState.style.display = 'none';
 
2179
  const contentDiv = document.createElement('div');
2180
  contentDiv.className = 'message-content';
2181
 
2182
+ // AI 응답인 경우 AI 정보 표시
2183
+ if (role === 'ai') {
2184
+ const aiInfoDiv = document.createElement('div');
2185
+ aiInfoDiv.className = 'ai-info';
2186
+ aiInfoDiv.style.cssText = 'font-size: 12px; color: var(--text-secondary); margin-bottom: 8px; display: flex; align-items: center; gap: 6px;';
2187
+
2188
+ const modelType = answerModel ? (answerModel.startsWith('gemini:') ? 'Gemini' : 'Ollama') : 'AI';
2189
+ const modelName = answerModel ? (answerModel.startsWith('gemini:') ? answerModel.replace('gemini:', '') : answerModel) : '모델 미선택';
2190
+
2191
+ aiInfoDiv.innerHTML = `
2192
+ <span style="font-weight: 500;">${modelType} 모델:</span>
2193
+ <span>${escapeHtml(modelName)}</span>
2194
+ `;
2195
+ contentDiv.appendChild(aiInfoDiv);
2196
+ }
2197
+
2198
  const bubble = document.createElement('div');
2199
  bubble.className = 'message-bubble';
2200
 
2201
  // AI 응답인 경우 [근거: ] 형식을 각주로 변환
2202
  if (role === 'ai') {
2203
+ // "[내용을 찾을 수 없습니다]" 텍스트 제거는 formatContentWithFootnotes에서 처리
2204
+ // 여기서는 원본 내용을 그대로 전달
2205
+
2206
  const { formattedContent, footnotes } = formatContentWithFootnotes(content);
2207
  bubble.innerHTML = formattedContent;
2208
 
 
2240
  chatContainer.scrollTop = chatContainer.scrollHeight;
2241
  }
2242
 
2243
+ // AI 모델 정보 가져오기
2244
+ function getAIModelInfo() {
2245
+ const analysisModelType = selectedModel ? (selectedModel.startsWith('gemini:') ? 'Gemini' : 'Ollama') : null;
2246
+ const analysisModelName = selectedModel ? (selectedModel.startsWith('gemini:') ? selectedModel.replace('gemini:', '') : selectedModel) : null;
2247
+
2248
+ const answerModelType = answerModel ? (answerModel.startsWith('gemini:') ? 'Gemini' : 'Ollama') : null;
2249
+ const answerModelName = answerModel ? (answerModel.startsWith('gemini:') ? answerModel.replace('gemini:', '') : answerModel) : null;
2250
+
2251
+ let aiInfo = '';
2252
+ if (analysisModelName && answerModelName) {
2253
+ if (analysisModelName === answerModelName) {
2254
+ // 같은 모델인 경우
2255
+ aiInfo = `<div style="font-size: 11px; color: var(--text-secondary); margin-bottom: 6px; display: flex; align-items: center; gap: 6px;">
2256
+ <span style="font-weight: 500;">${analysisModelType} 모델:</span>
2257
+ <span>${escapeHtml(analysisModelName)}</span>
2258
+ </div>`;
2259
+ } else {
2260
+ // 다른 모델인 경우
2261
+ aiInfo = `<div style="font-size: 11px; color: var(--text-secondary); margin-bottom: 6px; display: flex; flex-direction: column; gap: 2px;">
2262
+ <div style="display: flex; align-items: center; gap: 6px;">
2263
+ <span style="font-weight: 500;">분석 모델:</span>
2264
+ <span>${escapeHtml(analysisModelName)} (${analysisModelType})</span>
2265
+ </div>
2266
+ <div style="display: flex; align-items: center; gap: 6px;">
2267
+ <span style="font-weight: 500;">답변 모델:</span>
2268
+ <span>${escapeHtml(answerModelName)} (${answerModelType})</span>
2269
+ </div>
2270
+ </div>`;
2271
+ }
2272
+ } else if (answerModelName) {
2273
+ aiInfo = `<div style="font-size: 11px; color: var(--text-secondary); margin-bottom: 6px; display: flex; align-items: center; gap: 6px;">
2274
+ <span style="font-weight: 500;">${answerModelType} 모델:</span>
2275
+ <span>${escapeHtml(answerModelName)}</span>
2276
+ </div>`;
2277
  }
2278
+
2279
+ return aiInfo;
2280
+ }
2281
+
2282
+ // 진행 상황 표시
2283
+ function showTypingIndicator(step = '질문 분석 중...') {
2284
+ let messageDiv = document.getElementById('typingIndicator');
2285
+
2286
+ if (!messageDiv) {
2287
+ messageDiv = document.createElement('div');
2288
+ messageDiv.className = 'message ai';
2289
+ messageDiv.id = 'typingIndicator';
2290
+
2291
+ const avatar = document.createElement('div');
2292
+ avatar.className = 'message-avatar';
2293
+ avatar.textContent = '🤖';
2294
+
2295
+ const contentDiv = document.createElement('div');
2296
+ contentDiv.className = 'message-content';
2297
+ contentDiv.id = 'typingIndicatorContent';
2298
+
2299
+ messageDiv.appendChild(avatar);
2300
+ messageDiv.appendChild(contentDiv);
2301
+ chatContainer.appendChild(messageDiv);
2302
+ chatContainer.scrollTop = chatContainer.scrollHeight;
2303
+ }
2304
+
2305
+ const aiInfo = getAIModelInfo();
2306
+ const contentDiv = document.getElementById('typingIndicatorContent');
2307
+ contentDiv.innerHTML = `
2308
+ ${aiInfo}
2309
+ <div style="display: flex; flex-direction: column; gap: 8px;">
2310
+ <div style="display: flex; align-items: center; gap: 8px;">
2311
+ <div class="typing-indicator" style="display: flex; gap: 4px;">
2312
+ <div class="typing-dot"></div>
2313
+ <div class="typing-dot"></div>
2314
+ <div class="typing-dot"></div>
2315
+ </div>
2316
+ <span style="color: var(--text-secondary); font-size: 13px;">${step}</span>
2317
+ </div>
2318
+ </div>
2319
+ `;
2320
  chatContainer.scrollTop = chatContainer.scrollHeight;
2321
  }
2322
+
2323
+ // 진행 상황 업데이트
2324
+ function updateTypingIndicator(step, details = '') {
2325
+ const contentDiv = document.getElementById('typingIndicatorContent');
2326
+ if (contentDiv) {
2327
+ const aiInfo = getAIModelInfo();
2328
+ const detailsHtml = details ? `<div style="font-size: 12px; color: var(--text-secondary); margin-left: 24px; margin-top: 4px;">${details}</div>` : '';
2329
+ contentDiv.innerHTML = `
2330
+ ${aiInfo}
2331
+ <div style="display: flex; flex-direction: column; gap: 8px;">
2332
+ <div style="display: flex; align-items: center; gap: 8px;">
2333
+ <div class="typing-indicator" style="display: flex; gap: 4px;">
2334
+ <div class="typing-dot"></div>
2335
+ <div class="typing-dot"></div>
2336
+ <div class="typing-dot"></div>
2337
+ </div>
2338
+ <span style="color: var(--text-secondary); font-size: 13px;">${step}</span>
2339
+ </div>
2340
+ ${detailsHtml}
2341
+ </div>
2342
+ `;
2343
+ chatContainer.scrollTop = chatContainer.scrollHeight;
2344
+ }
2345
+ }
2346
 
2347
  // 타이핑 인디케이터 제거
2348
  function removeTypingIndicator() {
 
2357
  const message = messageInput.value.trim();
2358
  if (!message) return;
2359
 
2360
+ // 답변용 모델이 선택되지 않았으면 경고
2361
+ if (!answerModel) {
2362
+ alert('답변을 생성할 AI 모델을 선택해주세요.\n\n"사용 가능한 AI 목록"에서 답변을 생성할 AI 모델을 선택하세요.');
2363
+ return;
2364
+ }
2365
+
2366
  // 세션이 없으면 새로 생성
2367
  if (!currentSessionId) {
2368
  currentSessionId = await createNewSession();
 
2378
  messageInput.disabled = true;
2379
  sendButton.disabled = true;
2380
 
2381
+ // 진행 상황 표시 시작
2382
+ showTypingIndicator('질문 분석 중...');
2383
+
2384
+ // 진행 상황 단계별 표시
2385
+ const progressSteps = [
2386
+ { step: '질문 분석 중...', details: '질문 내용을 분석하고 있습니다' },
2387
+ { step: '관련 정보 검색 중...', details: '웹소설 데이터베이스에서 관련 정보를 찾고 있습니다' },
2388
+ { step: '회차별 분석 조회 중...', details: '회차별 요약 정보를 불러오고 있습니다' },
2389
+ { step: 'GraphRAG 데이터 조회 중...', details: '캐릭터 관계 및 사건 정보를 조회하고 있습니다' },
2390
+ { step: '벡터 검색 및 리랭킹 중...', details: '관련된 구체적인 내용을 검색하고 있습니다' },
2391
+ { step: '컨텍스트 구성 중...', details: '참고할 정보를 정리하고 있습니다' },
2392
+ { step: 'AI 답변 생성 중...', details: '답변을 생성하고 있습니다' }
2393
+ ];
2394
+
2395
+ let currentStepIndex = 0;
2396
+ const startTime = Date.now();
2397
+
2398
+ // 진행 상황 자동 업데이트 (백엔드 응답 대기 중)
2399
+ const progressInterval = setInterval(() => {
2400
+ if (currentStepIndex < progressSteps.length) {
2401
+ const currentStep = progressSteps[currentStepIndex];
2402
+ updateTypingIndicator(currentStep.step, currentStep.details);
2403
+ currentStepIndex++;
2404
+ }
2405
+ }, 1500); // 1.5초마다 다음 단계로
2406
 
2407
  try {
2408
  // API 호출
2409
+ console.log('[sendMessage] 요청 전송:', {
2410
+ message: message.substring(0, 50) + '...',
2411
+ analysis_model: selectedModel,
2412
+ answer_model: answerModel,
2413
+ file_ids: selectedFileIds,
2414
+ session_id: currentSessionId
2415
+ });
2416
+
2417
  const response = await fetch('/api/chat', {
2418
  method: 'POST',
2419
  headers: {
 
2421
  },
2422
  body: JSON.stringify({
2423
  message: message,
2424
+ analysis_model: selectedModel || null, // 질문 분석용 모델
2425
+ answer_model: answerModel || null, // 최종 답변용 모델
2426
  file_ids: selectedFileIds.length > 0 ? selectedFileIds : [],
2427
  session_id: currentSessionId
2428
  })
2429
  });
2430
+
2431
+ console.log('[sendMessage] 응답 상태:', response.status, response.statusText);
2432
 
2433
+ clearInterval(progressInterval);
2434
+ const elapsedTime = ((Date.now() - startTime) / 1000).toFixed(1);
2435
+ updateTypingIndicator('답변 수신 완료', `총 ${elapsedTime}초 소요`);
2436
+
2437
+ // 잠시 후 인디케이터 제거
2438
+ setTimeout(() => {
2439
+ removeTypingIndicator();
2440
+ }, 300);
2441
 
2442
  if (response.ok) {
2443
  const data = await response.json();
2444
+ console.log('[sendMessage] 응답 데이터:', data);
2445
+
2446
+ const aiResponse = data.response || data.message || '응답을 생성하는 중 오류가 발생했습니다.';
2447
+ console.log('[sendMessage] AI 응답:', aiResponse ? aiResponse.substring(0, 100) + '...' : '(비어있음)');
2448
+
2449
+ // 응답이 비어있거나 공백만 있어도 메시지 표시
2450
+ if (!aiResponse || aiResponse.trim() === '') {
2451
+ console.warn('[sendMessage] 응답이 비어있습니다. 기본 메시지를 표시합니다.');
2452
+ addMessage('ai', '응답을 생성할 수 없었습니다. 다시 시도해주세요.', false);
2453
+ } else {
2454
+ addMessage('ai', aiResponse, false);
2455
+ }
2456
 
2457
  // DB에 AI 응답 저장 (이미 백엔드에서 저장됨)
2458
  // 세션 목록 새로고침 (제목 업데이트 반영)
 
2465
  }
2466
  } else {
2467
  const error = await response.json().catch(() => ({ error: '서버 오류' }));
2468
+ console.error('[sendMessage] 서버 오류:', error);
2469
  addMessage('ai', `오류: ${error.error || '알 수 없는 오류가 발생했습니다.'}`, false);
2470
  }
2471
  } catch (error) {
 
2800
  // 페이지 로드 시 초기화
2801
  window.addEventListener('load', async () => {
2802
  await loadChatHistory();
2803
+ await loadAvailableModels(); // 사용 가능한 AI 목록 로드 (모든 모델)
2804
+ await loadModels(); // AI 모델 선택 로드 (학습된 웹소설이 있는 모델만)
2805
  // 모델 선택 여부와 관계없이 웹소설 목록 로드 (모델이 선택되어 있으면 해당 모델의 파일만, 없으면 모든 파일)
2806
  loadNovels();
2807
+ // 초기 모델 상태 업데이트
2808
+ if (selectedModel) {
2809
+ updateModelStatus('connected');
2810
+ }
2811
  messageInput.focus();
2812
  });
2813
  </script>