SOY NV AI commited on
Commit
a6d68d5
·
1 Parent(s): 8f929e1

기본 AI 모델 선택 기능 추가 및 DB 연결 풀 설정 개선

Browse files

- 관리 페이지에 기본 AI 모델 선택 기능 추가 (질문 분석용/답변 생성용)
- 사용자 화면에서 기본 AI 모델 자동 선택 기능 구현
- SQLAlchemy 연결 풀에 pool_pre_ping과 pool_recycle 옵션 추가 (클라우드 환경 대응)
- 시스템 토큰 사용량 추적 기능 개선
- ChromaDB 텔레메트리 오류 억제 설정 추가

.github/workflows/README.md CHANGED
@@ -86,3 +86,5 @@ GitHub 저장소의 Settings > Secrets and variables > Actions에서 다음 secr
86
 
87
 
88
 
 
 
 
86
 
87
 
88
 
89
+
90
+
.github/workflows/deploy-to-hf.yml CHANGED
@@ -74,3 +74,5 @@ jobs:
74
 
75
 
76
 
 
 
 
74
 
75
 
76
 
77
+
78
+
.github/workflows/test.yml CHANGED
@@ -62,3 +62,5 @@ jobs:
62
 
63
 
64
 
 
 
 
62
 
63
 
64
 
65
+
66
+
DB_CONNECTION_CHECK.md CHANGED
@@ -266,3 +266,5 @@ if db_status.get('error'):
266
 
267
 
268
 
 
 
 
266
 
267
 
268
 
269
+
270
+
EXAONE_설치_가이드.md CHANGED
@@ -170,6 +170,10 @@ tokenizer = AutoTokenizer.from_pretrained("LGAI-EXAONE/EXAONE-3.0-7.8B-Instruct"
170
 
171
 
172
 
 
 
 
 
173
 
174
 
175
 
 
170
 
171
 
172
 
173
+
174
+
175
+
176
+
177
 
178
 
179
 
HF_UPLOAD_GUIDE.md CHANGED
@@ -147,3 +147,5 @@ Write-Host "파일 복사 완료!"
147
 
148
 
149
 
 
 
 
147
 
148
 
149
 
150
+
151
+
POSTGRESQL_SETUP_GUIDE.md CHANGED
@@ -171,3 +171,5 @@ PostgreSQL 전환 후 다음을 확인하세요:
171
 
172
 
173
 
 
 
 
171
 
172
 
173
 
174
+
175
+
README_HF.md CHANGED
@@ -59,3 +59,5 @@ MIT License
59
 
60
 
61
 
 
 
 
59
 
60
 
61
 
62
+
63
+
add_exaone_model.py CHANGED
@@ -165,6 +165,10 @@ if __name__ == "__main__":
165
 
166
 
167
 
 
 
 
 
168
 
169
 
170
 
 
165
 
166
 
167
 
168
+
169
+
170
+
171
+
172
 
173
 
174
 
app.py CHANGED
@@ -75,3 +75,5 @@ if __name__ == '__main__':
75
 
76
 
77
 
 
 
 
75
 
76
 
77
 
78
+
79
+
app/__init__.py CHANGED
@@ -73,6 +73,14 @@ def create_app() -> Flask:
73
  app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = config.SQLALCHEMY_TRACK_MODIFICATIONS
74
  app.config['MAX_CONTENT_LENGTH'] = config.MAX_CONTENT_LENGTH
75
 
 
 
 
 
 
 
 
 
76
  # 세션 쿠키 설정 (브라우저 호환성 개선)
77
  app.config['SESSION_COOKIE_SECURE'] = True # HTTPS에서만 전송
78
  app.config['SESSION_COOKIE_HTTPONLY'] = True # JavaScript 접근 방지
@@ -163,7 +171,11 @@ def create_app() -> Flask:
163
  logger.info(f"[데이터베이스] PostgreSQL 연결 시도: {masked_uri}")
164
  # 연결 테스트
165
  try:
166
- engine = create_engine(db_uri)
 
 
 
 
167
  with engine.connect() as conn:
168
  result = conn.execute(text("SELECT version()"))
169
  version = result.fetchone()[0]
 
73
  app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = config.SQLALCHEMY_TRACK_MODIFICATIONS
74
  app.config['MAX_CONTENT_LENGTH'] = config.MAX_CONTENT_LENGTH
75
 
76
+ # SQLAlchemy 연결 풀 설정 (클라우드 환경에서 유휴 연결 끊김 방지)
77
+ # pool_pre_ping: 연결 사용 전에 살아있는지 확인 (ping)
78
+ # pool_recycle: 연결을 재사용하기 전 최대 시간(초) - 300초(5분)
79
+ app.config['SQLALCHEMY_ENGINE_OPTIONS'] = {
80
+ 'pool_pre_ping': True,
81
+ 'pool_recycle': 300
82
+ }
83
+
84
  # 세션 쿠키 설정 (브라우저 호환성 개선)
85
  app.config['SESSION_COOKIE_SECURE'] = True # HTTPS에서만 전송
86
  app.config['SESSION_COOKIE_HTTPONLY'] = True # JavaScript 접근 방지
 
171
  logger.info(f"[데이터베이스] PostgreSQL 연결 시도: {masked_uri}")
172
  # 연결 테스트
173
  try:
174
+ engine = create_engine(
175
+ db_uri,
176
+ pool_pre_ping=True,
177
+ pool_recycle=300
178
+ )
179
  with engine.connect() as conn:
180
  result = conn.execute(text("SELECT version()"))
181
  version = result.fetchone()[0]
app/database.py CHANGED
@@ -98,10 +98,15 @@ class ChatSession(db.Model):
98
  # 대화 메시지 모델
99
  class ChatMessage(db.Model):
100
  id = db.Column(db.Integer, primary_key=True)
101
- session_id = db.Column(db.Integer, db.ForeignKey('chat_session.id'), nullable=False)
102
  role = db.Column(db.String(20), nullable=False) # 'user' or 'ai'
103
  content = db.Column(db.Text, nullable=False)
104
  created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
 
 
 
 
 
105
 
106
  def to_dict(self):
107
  return {
@@ -109,7 +114,11 @@ class ChatMessage(db.Model):
109
  'session_id': self.session_id,
110
  'role': self.role,
111
  'content': self.content,
112
- 'created_at': self.created_at.isoformat() if self.created_at else None
 
 
 
 
113
  }
114
 
115
  # 문서 청크 모델 (RAG용)
 
98
  # 대화 메시지 모델
99
  class ChatMessage(db.Model):
100
  id = db.Column(db.Integer, primary_key=True)
101
+ session_id = db.Column(db.Integer, db.ForeignKey('chat_session.id'), nullable=True) # 시스템 사용은 NULL 허용
102
  role = db.Column(db.String(20), nullable=False) # 'user' or 'ai'
103
  content = db.Column(db.Text, nullable=False)
104
  created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
105
+ # 토큰 사용량 정보 (AI 응답 메시지에만 저장)
106
+ input_tokens = db.Column(db.Integer, nullable=True) # 입력 토큰 수
107
+ output_tokens = db.Column(db.Integer, nullable=True) # 출력 토큰 수
108
+ model_name = db.Column(db.String(100), nullable=True) # 사용된 AI 모델명
109
+ usage_type = db.Column(db.String(20), nullable=True, default='user') # 'user' or 'system' (시스템 사용 구분)
110
 
111
  def to_dict(self):
112
  return {
 
114
  'session_id': self.session_id,
115
  'role': self.role,
116
  'content': self.content,
117
+ 'created_at': self.created_at.isoformat() if self.created_at else None,
118
+ 'input_tokens': self.input_tokens,
119
+ 'output_tokens': self.output_tokens,
120
+ 'model_name': self.model_name,
121
+ 'usage_type': self.usage_type
122
  }
123
 
124
  # 문서 청크 모델 (RAG용)
app/gemini_client.py CHANGED
@@ -486,6 +486,16 @@ class GeminiClient:
486
  # REST API 응답 파싱
487
  response_data = rest_response.json()
488
 
 
 
 
 
 
 
 
 
 
 
489
  # 응답에서 텍스트 추출
490
  if 'candidates' in response_data and len(response_data['candidates']) > 0:
491
  candidate = response_data['candidates'][0]
@@ -497,10 +507,12 @@ class GeminiClient:
497
 
498
  # genai 라이브러리 형식으로 변환 (호환성을 위해)
499
  class MockResponse:
500
- def __init__(self, text):
501
  self.text = text
 
 
502
 
503
- response = MockResponse(response_text)
504
  break
505
  else:
506
  raise Exception("REST API 응답에 텍스트가 없습니다.")
@@ -559,10 +571,16 @@ class GeminiClient:
559
  # 응답 텍스트 추출
560
  response_text = response.text if hasattr(response, 'text') else str(response)
561
 
562
- print(f"[Gemini] 응답 생성 완료: {len(response_text)}자")
 
 
 
 
563
  return {
564
  'response': response_text,
565
- 'error': None
 
 
566
  }
567
 
568
  except Exception as e:
 
486
  # REST API 응답 파싱
487
  response_data = rest_response.json()
488
 
489
+ # 토큰 사용량 정보 추출
490
+ input_tokens = None
491
+ output_tokens = None
492
+ if 'usageMetadata' in response_data:
493
+ usage = response_data['usageMetadata']
494
+ input_tokens = usage.get('promptTokenCount')
495
+ output_tokens = usage.get('candidatesTokenCount')
496
+ total_tokens = usage.get('totalTokenCount')
497
+ print(f"[Gemini] 토큰 사용량: 입력={input_tokens}, 출력={output_tokens}, 총={total_tokens}")
498
+
499
  # 응답에서 텍스트 추출
500
  if 'candidates' in response_data and len(response_data['candidates']) > 0:
501
  candidate = response_data['candidates'][0]
 
507
 
508
  # genai 라이브러리 형식으로 변환 (호환성을 위해)
509
  class MockResponse:
510
+ def __init__(self, text, input_tokens=None, output_tokens=None):
511
  self.text = text
512
+ self.input_tokens = input_tokens
513
+ self.output_tokens = output_tokens
514
 
515
+ response = MockResponse(response_text, input_tokens, output_tokens)
516
  break
517
  else:
518
  raise Exception("REST API 응답에 텍스트가 없습니다.")
 
571
  # 응답 텍스트 추출
572
  response_text = response.text if hasattr(response, 'text') else str(response)
573
 
574
+ # 토큰 정보 추출
575
+ input_tokens = getattr(response, 'input_tokens', None)
576
+ output_tokens = getattr(response, 'output_tokens', None)
577
+
578
+ print(f"[Gemini] 응답 생성 완료: {len(response_text)}자, 입력 토큰: {input_tokens}, 출력 토큰: {output_tokens}")
579
  return {
580
  'response': response_text,
581
+ 'error': None,
582
+ 'input_tokens': input_tokens,
583
+ 'output_tokens': output_tokens
584
  }
585
 
586
  except Exception as e:
app/huggingface_client.py CHANGED
@@ -48,3 +48,5 @@ def reset_huggingface_token():
48
 
49
 
50
 
 
 
 
48
 
49
 
50
 
51
+
52
+
app/routes.py CHANGED
@@ -112,6 +112,37 @@ def get_model_token_limit_by_type(model_name, default_tokens=2000, token_type='o
112
 
113
  # 업로드 설정
114
  UPLOAD_FOLDER = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'uploads')
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
115
  ALLOWED_EXTENSIONS = {'txt', 'md', 'pdf', 'docx', 'epub'}
116
 
117
  # 업로드 폴더 경로 출력 (디버깅용)
@@ -635,6 +666,14 @@ def analyze_episode(episode_content, episode_title, full_content=None, parent_ch
635
  max_output_tokens=get_model_token_limit("gemini-1.5-flash", 3000) # 저장된 토큰 수 사용
636
  )
637
  if not result['error'] and result.get('response'):
 
 
 
 
 
 
 
 
638
  return result['response'].strip()
639
  except Exception as e:
640
  print(f"[회차 분석] Gemini 기본 모델 오류: {str(e)}")
@@ -658,6 +697,14 @@ def analyze_episode(episode_content, episode_title, full_content=None, parent_ch
658
  max_output_tokens=get_model_token_limit(model_name, 3000) # 저장된 토큰 수 사용
659
  )
660
  if not result['error'] and result.get('response'):
 
 
 
 
 
 
 
 
661
  return result['response'].strip()
662
  else:
663
  # Ollama API 호출
@@ -680,6 +727,16 @@ def analyze_episode(episode_content, episode_title, full_content=None, parent_ch
680
  )
681
  if ollama_response.status_code == 200:
682
  response_data = ollama_response.json()
 
 
 
 
 
 
 
 
 
 
683
  return response_data.get('response', '').strip()
684
  except requests.exceptions.Timeout:
685
  print(f"[회차 분석] Ollama 타임아웃: 요청 시간이 초과되었습니다. (5분)")
@@ -750,6 +807,14 @@ def extract_graph_from_episode(episode_content, episode_title, file_id, full_con
750
  )
751
  if not result['error'] and result.get('response'):
752
  response_text = result['response'].strip()
 
 
 
 
 
 
 
 
753
  except Exception as e:
754
  print(f"[Graph Extraction] Gemini 기본 모델 오류: {str(e)}")
755
 
@@ -773,6 +838,14 @@ def extract_graph_from_episode(episode_content, episode_title, file_id, full_con
773
  )
774
  if not result['error'] and result.get('response'):
775
  response_text = result['response'].strip()
 
 
 
 
 
 
 
 
776
  else:
777
  # Ollama API 호출
778
  try:
@@ -795,6 +868,16 @@ def extract_graph_from_episode(episode_content, episode_title, file_id, full_con
795
  if ollama_response.status_code == 200:
796
  response_data = ollama_response.json()
797
  response_text = response_data.get('response', '').strip()
 
 
 
 
 
 
 
 
 
 
798
  except requests.exceptions.Timeout:
799
  print(f"[Graph Extraction] Ollama 타임아웃: 요청 시간이 초과되었습니다. (5분)")
800
  except requests.exceptions.ConnectionError:
@@ -1217,6 +1300,17 @@ def create_parent_chunk_with_ai(file_id, content, model_name):
1217
 
1218
  analysis_result = result['response']
1219
  print(f"[Parent Chunk 생성] Gemini API 응답 수신 성공: {len(analysis_result)}자")
 
 
 
 
 
 
 
 
 
 
 
1220
  else:
1221
  # Ollama API 호출
1222
  print(f"[Parent Chunk 생성] Ollama API에 분석 요청 전송 중... (모델: {model_name})")
@@ -1256,6 +1350,17 @@ def create_parent_chunk_with_ai(file_id, content, model_name):
1256
  response_data = ollama_response.json()
1257
  analysis_result = response_data.get('message', {}).get('content', '')
1258
  print(f"[Parent Chunk 생성] Ollama API 응답 수신 성공: {len(analysis_result)}자")
 
 
 
 
 
 
 
 
 
 
 
1259
  except requests.exceptions.Timeout:
1260
  print(f"[Parent Chunk 생성] ❌ Ollama 타임아웃: 요청 시간이 초과되었습니다. (5분)")
1261
  print(f"[Parent Chunk 생성] 파일이 너무 크거나 모델 응답이 느릴 수 있습니다.")
@@ -1797,6 +1902,12 @@ def admin_utils():
1797
  """유틸리티 관리 페이지"""
1798
  return render_template('admin_utils.html')
1799
 
 
 
 
 
 
 
1800
  def convert_episode_format(content):
1801
  """다양한 회차 구분 방식을 #n화 형식으로 변환
1802
 
@@ -1805,6 +1916,7 @@ def convert_episode_format(content):
1805
  - @ 1, @ 2 -> #1화, #2화
1806
  - @1화, @2화 -> #1화, #2화
1807
  - @ 1화, @ 2화 -> #1화, #2화
 
1808
  - #01화, #010화 -> #1화, #10화 (앞의 0 제거)
1809
  - 기타 유사한 패턴들
1810
 
@@ -1821,7 +1933,7 @@ def convert_episode_format(content):
1821
  converted_lines = []
1822
 
1823
  # 다양한 회차 패턴을 #n화 형식으로 변환
1824
- # 패턴: @숫자, @ 숫자, @숫자화, @ 숫자화 등
1825
  # 줄 시작 부분에서만 매칭하도록 ^ 사용
1826
  for line in lines:
1827
  converted_line = line
@@ -1834,6 +1946,11 @@ def convert_episode_format(content):
1834
  if not re.search(r'^#\d+화', converted_line):
1835
  converted_line = re.sub(r'^@\s*(\d+)(?!화)\s*', r'#\1화', converted_line)
1836
 
 
 
 
 
 
1837
  # #0n화 패턴에서 앞의 0 제거 (#01화 -> #1화, #010화 -> #10화)
1838
  # 단, #0화는 그대로 유지
1839
  converted_line = re.sub(r'^#0+(\d+)화', r'#\1화', converted_line)
@@ -1842,6 +1959,65 @@ def convert_episode_format(content):
1842
 
1843
  return '\n'.join(converted_lines)
1844
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1845
  @main_bp.route('/api/admin/utils/convert-episode-format', methods=['POST'])
1846
  @admin_required
1847
  def convert_episode_format_api():
@@ -1849,6 +2025,7 @@ def convert_episode_format_api():
1849
  try:
1850
  content = None
1851
  original_filename = 'converted_file.txt'
 
1852
 
1853
  # 파일 업로드 확인
1854
  if 'file' in request.files:
@@ -1856,20 +2033,30 @@ def convert_episode_format_api():
1856
  if file and file.filename and file.filename != '':
1857
  # 파일 읽기
1858
  try:
1859
- encoding = 'utf-8'
 
 
1860
  file_content = file.read()
1861
  file.seek(0) # 파일 포인터 리셋 (다시 읽을 수 있도록)
1862
 
 
1863
  try:
1864
- content = file_content.decode(encoding)
1865
- except UnicodeDecodeError:
1866
- try:
1867
- content = file_content.decode('cp949')
1868
- except:
 
 
1869
  try:
1870
- content = file_content.decode('latin-1')
1871
- except Exception as decode_error:
1872
- return jsonify({'error': f'파일 인코딩 오류: 지원하지 않는 인코딩입니다. (UTF-8, CP949, Latin-1 시도 실패)'}), 500
 
 
 
 
 
1873
 
1874
  original_filename = file.filename
1875
  except Exception as e:
@@ -1981,6 +2168,178 @@ def download_converted_file():
1981
  except Exception as e:
1982
  return jsonify({'error': f'파일 다운로드 중 오류가 발생했습니다: {str(e)}'}), 500
1983
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1984
  @main_bp.route('/api/admin/users', methods=['GET'])
1985
  @admin_required
1986
  def get_users():
@@ -2077,10 +2436,14 @@ def get_all_messages():
2077
  page = request.args.get('page', 1, type=int)
2078
  per_page = request.args.get('per_page', 50, type=int)
2079
 
2080
- query = ChatMessage.query.join(ChatSession)
2081
-
2082
  if user_id:
2083
- query = query.filter(ChatSession.user_id == user_id)
 
 
 
 
 
2084
  if session_id:
2085
  query = query.filter(ChatMessage.session_id == session_id)
2086
  if message_id:
@@ -2097,6 +2460,10 @@ def get_all_messages():
2097
  }), 200
2098
 
2099
  except Exception as e:
 
 
 
 
2100
  return jsonify({'error': f'메시지 조회 중 오류가 발생했습니다: {str(e)}'}), 500
2101
 
2102
  @main_bp.route('/api/admin/sessions', methods=['GET'])
@@ -2118,10 +2485,33 @@ def get_all_sessions():
2118
 
2119
  sessions_data = []
2120
  for session in sessions.items:
2121
- session_dict = session.to_dict()
2122
- session_dict['username'] = session.user.username if session.user else 'Unknown'
2123
- session_dict['nickname'] = session.user.nickname if session.user else None
2124
- sessions_data.append(session_dict)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2125
 
2126
  return jsonify({
2127
  'sessions': sessions_data,
@@ -2131,6 +2521,10 @@ def get_all_sessions():
2131
  }), 200
2132
 
2133
  except Exception as e:
 
 
 
 
2134
  return jsonify({'error': f'대화 세션 조회 중 오류가 발생했습니다: {str(e)}'}), 500
2135
 
2136
  @main_bp.route('/api/admin/users/<int:user_id>', methods=['DELETE'])
@@ -2655,7 +3049,11 @@ def get_database_status():
2655
  try:
2656
  if is_postgresql:
2657
  # PostgreSQL 연결 테스트
2658
- engine = create_engine(db_uri)
 
 
 
 
2659
  with engine.connect() as conn:
2660
  # 버전 확인
2661
  result = conn.execute(text("SELECT version()"))
@@ -2793,6 +3191,76 @@ def get_all_ollama_models():
2793
  except Exception as e:
2794
  return jsonify({'error': f'모델 목록을 가져오는 중 오류가 발생했습니다: {str(e)}', 'models': []}), 500
2795
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2796
  @main_bp.route('/api/chat', methods=['POST'])
2797
  @login_required
2798
  def chat():
@@ -3222,6 +3690,14 @@ def chat():
3222
  # 모델 타입 확인 (Gemini 또는 Ollama)
3223
  is_gemini = answer_model.startswith('gemini:')
3224
 
 
 
 
 
 
 
 
 
3225
  print(f"[최종 답변 생성] 답변 모델: {answer_model}, 프롬프트 길이: {len(full_prompt)}자")
3226
 
3227
  if is_gemini:
@@ -3247,6 +3723,11 @@ def chat():
3247
  if not response_text:
3248
  print(f"[채팅] Gemini 응답이 비어있습니다. result: {result}")
3249
  response_text = '응답을 생성할 수 없었습니다. 다시 시도해주세요.'
 
 
 
 
 
3250
  else:
3251
  # Ollama API 호출
3252
  # Ollama 서버 연결 확인
@@ -3295,6 +3776,18 @@ def chat():
3295
  if not response_text:
3296
  print(f"[채팅] Ollama 응답이 비어있습니다. ollama_data: {ollama_data}")
3297
  response_text = '응답을 생성할 수 없었습니다. 다시 시도해주세요.'
 
 
 
 
 
 
 
 
 
 
 
 
3298
 
3299
  # 대화 세션에 메시지 저장 (Gemini와 Ollama 공통)
3300
  session_id = data.get('session_id')
@@ -3350,14 +3843,25 @@ def chat():
3350
  else:
3351
  print(f"[메시지 저장] 중복 메시지로 인해 저장을 건너뜁니다.")
3352
 
3353
- # AI 응답 저장
 
 
 
 
 
3354
  ai_msg = ChatMessage(
3355
  session_id=session_id,
3356
  role='ai',
3357
- content=response_text
 
 
 
3358
  )
3359
  db.session.add(ai_msg)
3360
 
 
 
 
3361
  # 세션 모델 정보 업데이트 (첫 메시지인 경우 또는 변경된 경우)
3362
  if not session.analysis_model or session.analysis_model != analysis_model:
3363
  session.analysis_model = analysis_model
 
112
 
113
  # 업로드 설정
114
  UPLOAD_FOLDER = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'uploads')
115
+
116
+ def save_system_token_usage(model_name, input_tokens=None, output_tokens=None, task_type='file_processing'):
117
+ """시스템 사용 토큰 저장 (파일 업로드, 분석 등)
118
+
119
+ Args:
120
+ model_name: 사용된 AI 모델명
121
+ input_tokens: 입력 토큰 수
122
+ output_tokens: 출력 토큰 수
123
+ task_type: 작업 유형 ('parent_chunk', 'episode_analysis', 'graph_extraction', 'metadata_extraction', 'file_processing')
124
+ """
125
+ try:
126
+ # 토큰이 모두 None이거나 0이어도 저장 (통계 목적)
127
+ print(f"[시스템 토큰 저장] 호출됨 - 작업: {task_type}, 모델: {model_name}, 입력: {input_tokens}, 출력: {output_tokens}")
128
+
129
+ system_message = ChatMessage(
130
+ session_id=None, # 시스템 사용은 세션이 없음
131
+ role='ai',
132
+ content=f'시스템 작업: {task_type}',
133
+ input_tokens=input_tokens,
134
+ output_tokens=output_tokens,
135
+ model_name=model_name,
136
+ usage_type='system'
137
+ )
138
+ db.session.add(system_message)
139
+ db.session.commit()
140
+ print(f"[시스템 토큰 저장] 성공 - {task_type} - 모델: {model_name}, 입력: {input_tokens}, 출력: {output_tokens}, 메시지 ID: {system_message.id}")
141
+ except Exception as e:
142
+ print(f"[시스템 토큰 저장] 오류: {str(e)}")
143
+ import traceback
144
+ traceback.print_exc()
145
+ db.session.rollback()
146
  ALLOWED_EXTENSIONS = {'txt', 'md', 'pdf', 'docx', 'epub'}
147
 
148
  # 업로드 폴더 경로 출력 (디버깅용)
 
666
  max_output_tokens=get_model_token_limit("gemini-1.5-flash", 3000) # 저장된 토큰 수 사용
667
  )
668
  if not result['error'] and result.get('response'):
669
+ # 시스템 사용 토큰 저장 (토큰이 None이어도 저장)
670
+ print(f"[회차 분석] 토큰 정보 확인 - 입력: {result.get('input_tokens')}, 출력: {result.get('output_tokens')}")
671
+ save_system_token_usage(
672
+ model_name="gemini-1.5-flash",
673
+ input_tokens=result.get('input_tokens'),
674
+ output_tokens=result.get('output_tokens'),
675
+ task_type='episode_analysis'
676
+ )
677
  return result['response'].strip()
678
  except Exception as e:
679
  print(f"[회차 분석] Gemini 기본 모델 오류: {str(e)}")
 
697
  max_output_tokens=get_model_token_limit(model_name, 3000) # 저장된 토큰 수 사용
698
  )
699
  if not result['error'] and result.get('response'):
700
+ # 시스템 사용 토큰 저장 (토큰이 None이어도 저장)
701
+ print(f"[회차 분석] 토큰 정보 확인 - 입력: {result.get('input_tokens')}, 출력: {result.get('output_tokens')}")
702
+ save_system_token_usage(
703
+ model_name=gemini_model_name,
704
+ input_tokens=result.get('input_tokens'),
705
+ output_tokens=result.get('output_tokens'),
706
+ task_type='episode_analysis'
707
+ )
708
  return result['response'].strip()
709
  else:
710
  # Ollama API 호출
 
727
  )
728
  if ollama_response.status_code == 200:
729
  response_data = ollama_response.json()
730
+ # 시스템 사용 토큰 저장 (토큰이 None이어도 저장)
731
+ ollama_input_tokens = response_data.get('prompt_eval_count')
732
+ ollama_output_tokens = response_data.get('eval_count')
733
+ print(f"[회차 분석] 토큰 정보 확인 - 입력: {ollama_input_tokens}, 출력: {ollama_output_tokens}")
734
+ save_system_token_usage(
735
+ model_name=model_name,
736
+ input_tokens=ollama_input_tokens,
737
+ output_tokens=ollama_output_tokens,
738
+ task_type='episode_analysis'
739
+ )
740
  return response_data.get('response', '').strip()
741
  except requests.exceptions.Timeout:
742
  print(f"[회차 분석] Ollama 타임아웃: 요청 시간이 초과되었습니다. (5분)")
 
807
  )
808
  if not result['error'] and result.get('response'):
809
  response_text = result['response'].strip()
810
+ # 시스템 사용 토큰 저장 (토큰이 None이어도 저장)
811
+ print(f"[Graph Extraction] 토큰 정보 확인 - 입력: {result.get('input_tokens')}, 출력: {result.get('output_tokens')}")
812
+ save_system_token_usage(
813
+ model_name="gemini-1.5-flash",
814
+ input_tokens=result.get('input_tokens'),
815
+ output_tokens=result.get('output_tokens'),
816
+ task_type='graph_extraction'
817
+ )
818
  except Exception as e:
819
  print(f"[Graph Extraction] Gemini 기본 모델 오류: {str(e)}")
820
 
 
838
  )
839
  if not result['error'] and result.get('response'):
840
  response_text = result['response'].strip()
841
+ # 시스템 사용 토큰 저장 (토큰이 None이어도 저장)
842
+ print(f"[Graph Extraction] 토큰 정보 확인 - 입력: {result.get('input_tokens')}, 출력: {result.get('output_tokens')}")
843
+ save_system_token_usage(
844
+ model_name=gemini_model_name,
845
+ input_tokens=result.get('input_tokens'),
846
+ output_tokens=result.get('output_tokens'),
847
+ task_type='graph_extraction'
848
+ )
849
  else:
850
  # Ollama API 호출
851
  try:
 
868
  if ollama_response.status_code == 200:
869
  response_data = ollama_response.json()
870
  response_text = response_data.get('response', '').strip()
871
+ # 시스템 사용 토큰 저장 (토큰이 None이어도 저장)
872
+ ollama_input_tokens = response_data.get('prompt_eval_count')
873
+ ollama_output_tokens = response_data.get('eval_count')
874
+ print(f"[Graph Extraction] 토큰 정보 확인 - 입력: {ollama_input_tokens}, 출력: {ollama_output_tokens}")
875
+ save_system_token_usage(
876
+ model_name=model_name,
877
+ input_tokens=ollama_input_tokens,
878
+ output_tokens=ollama_output_tokens,
879
+ task_type='graph_extraction'
880
+ )
881
  except requests.exceptions.Timeout:
882
  print(f"[Graph Extraction] Ollama 타임아웃: 요청 시간이 초과되었습니다. (5분)")
883
  except requests.exceptions.ConnectionError:
 
1300
 
1301
  analysis_result = result['response']
1302
  print(f"[Parent Chunk 생성] Gemini API 응답 수신 성공: {len(analysis_result)}자")
1303
+
1304
+ # 시스템 사용 토큰 저장 (토큰이 None이어도 저장)
1305
+ gemini_input_tokens = result.get('input_tokens')
1306
+ gemini_output_tokens = result.get('output_tokens')
1307
+ print(f"[Parent Chunk 생성] 토큰 정보 확인 - 입력: {gemini_input_tokens}, 출력: {gemini_output_tokens}")
1308
+ save_system_token_usage(
1309
+ model_name=gemini_model_name,
1310
+ input_tokens=gemini_input_tokens,
1311
+ output_tokens=gemini_output_tokens,
1312
+ task_type='parent_chunk'
1313
+ )
1314
  else:
1315
  # Ollama API 호출
1316
  print(f"[Parent Chunk 생성] Ollama API에 분석 요청 전송 중... (모델: {model_name})")
 
1350
  response_data = ollama_response.json()
1351
  analysis_result = response_data.get('message', {}).get('content', '')
1352
  print(f"[Parent Chunk 생성] Ollama API 응답 수신 성공: {len(analysis_result)}자")
1353
+
1354
+ # 시스템 사용 토큰 저장 (토큰이 None이어도 저장)
1355
+ ollama_input_tokens = response_data.get('prompt_eval_count')
1356
+ ollama_output_tokens = response_data.get('eval_count')
1357
+ print(f"[Parent Chunk 생성] 토큰 정보 확인 - 입력: {ollama_input_tokens}, 출력: {ollama_output_tokens}")
1358
+ save_system_token_usage(
1359
+ model_name=model_name,
1360
+ input_tokens=ollama_input_tokens,
1361
+ output_tokens=ollama_output_tokens,
1362
+ task_type='parent_chunk'
1363
+ )
1364
  except requests.exceptions.Timeout:
1365
  print(f"[Parent Chunk 생성] ❌ Ollama 타임아웃: 요청 시간이 초과되었습니다. (5분)")
1366
  print(f"[Parent Chunk 생성] 파일이 너무 크거나 모델 응답이 느릴 수 있습니다.")
 
1902
  """유틸리티 관리 페이지"""
1903
  return render_template('admin_utils.html')
1904
 
1905
+ @main_bp.route('/admin/tokens')
1906
+ @admin_required
1907
+ def admin_tokens():
1908
+ """토큰 사용량 통계 페이지"""
1909
+ return render_template('admin_tokens.html')
1910
+
1911
  def convert_episode_format(content):
1912
  """다양한 회차 구분 방식을 #n화 형식으로 변환
1913
 
 
1916
  - @ 1, @ 2 -> #1화, #2화
1917
  - @1화, @2화 -> #1화, #2화
1918
  - @ 1화, @ 2화 -> #1화, #2화
1919
+ - 1화, 2화, 10화 -> #1화, #2화, #10화 (앞에 기호 없이)
1920
  - #01화, #010화 -> #1화, #10화 (앞의 0 제거)
1921
  - 기타 유사한 패턴들
1922
 
 
1933
  converted_lines = []
1934
 
1935
  # 다양한 회차 패턴을 #n화 형식으로 변환
1936
+ # 패턴: @숫자, @ 숫자, @숫자화, @ 숫자화, 숫자화 등
1937
  # 줄 시작 부분에서만 매칭하도록 ^ 사용
1938
  for line in lines:
1939
  converted_line = line
 
1946
  if not re.search(r'^#\d+화', converted_line):
1947
  converted_line = re.sub(r'^@\s*(\d+)(?!화)\s*', r'#\1화', converted_line)
1948
 
1949
+ # 숫자화 패턴 (앞에 기호 없이) - 이미 #이 있는 경우는 제외
1950
+ # 줄 시작에서 숫자로 시작하고 바로 "화"가 오는 경우
1951
+ if not re.search(r'^#\d+화', converted_line):
1952
+ converted_line = re.sub(r'^(\d+)\s*화\s*', r'#\1화', converted_line)
1953
+
1954
  # #0n화 패턴에서 앞의 0 제거 (#01화 -> #1화, #010화 -> #10화)
1955
  # 단, #0화는 그대로 유지
1956
  converted_line = re.sub(r'^#0+(\d+)화', r'#\1화', converted_line)
 
1959
 
1960
  return '\n'.join(converted_lines)
1961
 
1962
+ @main_bp.route('/api/admin/utils/detect-encoding', methods=['POST'])
1963
+ @admin_required
1964
+ def detect_file_encoding():
1965
+ """파일 인코딩 감지 API"""
1966
+ try:
1967
+ if 'file' not in request.files:
1968
+ return jsonify({'error': '파일이 필요합니다.'}), 400
1969
+
1970
+ file = request.files['file']
1971
+ if not file or not file.filename:
1972
+ return jsonify({'error': '파일이 필요합니다.'}), 400
1973
+
1974
+ file_content = file.read()
1975
+ file.seek(0) # 파일 포인터 리셋
1976
+
1977
+ # 여러 인코딩 시도하여 감지
1978
+ encodings_to_try = ['utf-8', 'cp949', 'euc-kr', 'latin-1', 'utf-16', 'utf-16-le', 'utf-16-be']
1979
+ detected_encoding = None
1980
+ detected_content = None
1981
+
1982
+ for encoding in encodings_to_try:
1983
+ try:
1984
+ content = file_content.decode(encoding)
1985
+ detected_encoding = encoding
1986
+ detected_content = content
1987
+ break
1988
+ except (UnicodeDecodeError, UnicodeError):
1989
+ continue
1990
+
1991
+ if not detected_encoding:
1992
+ return jsonify({
1993
+ 'error': '파일 인코딩을 감지할 수 없습니다.',
1994
+ 'detected_encoding': None,
1995
+ 'confidence': 0
1996
+ }), 400
1997
+
1998
+ # 간단한 신뢰도 계산 (UTF-8 BOM 확인, 일반적인 문자 범위 확인 등)
1999
+ confidence = 0.8
2000
+ if detected_encoding == 'utf-8':
2001
+ # UTF-8 BOM 확인
2002
+ if file_content.startswith(b'\xef\xbb\xbf'):
2003
+ confidence = 0.95
2004
+ # 한글 문자 포함 여부 확인
2005
+ if any(ord(char) >= 0xAC00 and ord(char) <= 0xD7A3 for char in detected_content[:1000]):
2006
+ confidence = 0.9
2007
+
2008
+ return jsonify({
2009
+ 'success': True,
2010
+ 'detected_encoding': detected_encoding,
2011
+ 'confidence': confidence,
2012
+ 'preview': detected_content[:200] if detected_content else ''
2013
+ }), 200
2014
+
2015
+ except Exception as e:
2016
+ import traceback
2017
+ print(f"[인코딩 감지] 오류: {str(e)}")
2018
+ print(traceback.format_exc())
2019
+ return jsonify({'error': f'인코딩 감지 중 오류가 발생했습니다: {str(e)}'}), 500
2020
+
2021
  @main_bp.route('/api/admin/utils/convert-episode-format', methods=['POST'])
2022
  @admin_required
2023
  def convert_episode_format_api():
 
2025
  try:
2026
  content = None
2027
  original_filename = 'converted_file.txt'
2028
+ specified_encoding = None
2029
 
2030
  # 파일 업로드 확인
2031
  if 'file' in request.files:
 
2033
  if file and file.filename and file.filename != '':
2034
  # 파일 읽기
2035
  try:
2036
+ # 요청에서 인코딩 정보 가져오기
2037
+ specified_encoding = request.form.get('encoding', 'utf-8')
2038
+
2039
  file_content = file.read()
2040
  file.seek(0) # 파일 포인터 리셋 (다시 읽을 수 있도록)
2041
 
2042
+ # 지정된 인코딩으로 시도
2043
  try:
2044
+ content = file_content.decode(specified_encoding)
2045
+ except (UnicodeDecodeError, UnicodeError):
2046
+ # 지정된 인코딩 실패 시 자동 감지 시도
2047
+ encodings_to_try = ['utf-8', 'cp949', 'euc-kr', 'latin-1']
2048
+ for encoding in encodings_to_try:
2049
+ if encoding == specified_encoding:
2050
+ continue
2051
  try:
2052
+ content = file_content.decode(encoding)
2053
+ specified_encoding = encoding # 성공한 인코딩으로 업데이트
2054
+ break
2055
+ except (UnicodeDecodeError, UnicodeError):
2056
+ continue
2057
+
2058
+ if not content:
2059
+ return jsonify({'error': f'파일 인코딩 오류: {specified_encoding} 및 자동 감지 실패'}), 500
2060
 
2061
  original_filename = file.filename
2062
  except Exception as e:
 
2168
  except Exception as e:
2169
  return jsonify({'error': f'파일 다운로드 중 오류가 발생했습니다: {str(e)}'}), 500
2170
 
2171
+ @main_bp.route('/api/admin/token-usage', methods=['GET'])
2172
+ @admin_required
2173
+ def get_token_usage():
2174
+ """토큰 사용량 통계 API (날짜 범위, 모델별 필터링 지원)"""
2175
+ try:
2176
+ from datetime import datetime, timedelta
2177
+ from sqlalchemy import func, and_
2178
+
2179
+ # 필터 파라미터 가져오기
2180
+ start_date = request.args.get('start_date')
2181
+ end_date = request.args.get('end_date')
2182
+ model_name = request.args.get('model_name') # 특정 모델 필터링
2183
+ group_by = request.args.get('group_by', 'day') # 'day', 'week', 'month', 'model'
2184
+
2185
+ # 기본 날짜 범위 설정 (최근 30일)
2186
+ if not end_date:
2187
+ end_date = datetime.utcnow()
2188
+ else:
2189
+ try:
2190
+ # ISO 형식 또는 YYYY-MM-DD 형식 파싱
2191
+ if 'T' in end_date:
2192
+ end_date = datetime.fromisoformat(end_date.replace('Z', '+00:00'))
2193
+ else:
2194
+ # YYYY-MM-DD 형식인 경우 시간을 23:59:59로 설정
2195
+ end_date = datetime.strptime(end_date, '%Y-%m-%d')
2196
+ end_date = end_date.replace(hour=23, minute=59, second=59)
2197
+ except Exception as e:
2198
+ print(f"[토큰 통계] 종료 날짜 파싱 오류: {end_date}, {str(e)}")
2199
+ end_date = datetime.utcnow()
2200
+
2201
+ if not start_date:
2202
+ start_date = end_date - timedelta(days=30)
2203
+ else:
2204
+ try:
2205
+ # ISO 형식 또는 YYYY-MM-DD 형식 파싱
2206
+ if 'T' in start_date:
2207
+ start_date = datetime.fromisoformat(start_date.replace('Z', '+00:00'))
2208
+ else:
2209
+ # YYYY-MM-DD 형식인 경우 시간을 00:00:00으로 설정
2210
+ start_date = datetime.strptime(start_date, '%Y-%m-%d')
2211
+ start_date = start_date.replace(hour=0, minute=0, second=0)
2212
+ except Exception as e:
2213
+ print(f"[토큰 통계] 시작 날짜 파싱 오류: {start_date}, {str(e)}")
2214
+ start_date = end_date - timedelta(days=30)
2215
+
2216
+ # 쿼리 구성
2217
+ query = ChatMessage.query.filter(
2218
+ ChatMessage.role == 'ai',
2219
+ ChatMessage.created_at >= start_date,
2220
+ ChatMessage.created_at <= end_date
2221
+ )
2222
+
2223
+ # 모델 필터링
2224
+ if model_name:
2225
+ query = query.filter(ChatMessage.model_name == model_name)
2226
+
2227
+ # 토큰 정보가 있는 메시지만 조회 (선택적 필터)
2228
+ # 토큰 정보가 없는 메시지도 포함하려면 이 필터를 제거할 수 있음
2229
+ # 하지��� 통계 목적상 토큰 정보가 있는 메시지만 조회하는 것이 맞음
2230
+ query = query.filter(
2231
+ db.or_(
2232
+ ChatMessage.input_tokens.isnot(None),
2233
+ ChatMessage.output_tokens.isnot(None)
2234
+ )
2235
+ )
2236
+
2237
+ messages = query.all()
2238
+ print(f"[토큰 통계] 조회된 메시지 수: {len(messages)} (날짜 범위: {start_date} ~ {end_date})")
2239
+
2240
+ # 사용자 사용과 시스템 사용 구분
2241
+ user_messages = [msg for msg in messages if not msg.usage_type or msg.usage_type == 'user']
2242
+ system_messages = [msg for msg in messages if msg.usage_type == 'system']
2243
+ print(f"[토큰 통계] 사용자 사용: {len(user_messages)}개, 시스템 사용: {len(system_messages)}개")
2244
+
2245
+ # 그룹별 집계
2246
+ if group_by == 'day':
2247
+ # 일별 집계
2248
+ daily_stats = {}
2249
+ for msg in messages:
2250
+ date_key = msg.created_at.date().isoformat()
2251
+ if date_key not in daily_stats:
2252
+ daily_stats[date_key] = {
2253
+ 'date': date_key,
2254
+ 'input_tokens': 0,
2255
+ 'output_tokens': 0,
2256
+ 'total_tokens': 0,
2257
+ 'count': 0
2258
+ }
2259
+ daily_stats[date_key]['input_tokens'] += msg.input_tokens or 0
2260
+ daily_stats[date_key]['output_tokens'] += msg.output_tokens or 0
2261
+ daily_stats[date_key]['total_tokens'] += (msg.input_tokens or 0) + (msg.output_tokens or 0)
2262
+ daily_stats[date_key]['count'] += 1
2263
+
2264
+ stats = sorted(daily_stats.values(), key=lambda x: x['date'])
2265
+ elif group_by == 'model':
2266
+ # 모델별 집계 (시스템 사용 포함)
2267
+ model_stats = {}
2268
+ for msg in messages:
2269
+ # 시스템 사용은 '시스템 사용'으로 표시
2270
+ if msg.usage_type == 'system':
2271
+ model_key = '시스템 사용'
2272
+ else:
2273
+ model_key = msg.model_name or 'Unknown'
2274
+
2275
+ if model_key not in model_stats:
2276
+ model_stats[model_key] = {
2277
+ 'model': model_key,
2278
+ 'input_tokens': 0,
2279
+ 'output_tokens': 0,
2280
+ 'total_tokens': 0,
2281
+ 'count': 0
2282
+ }
2283
+ model_stats[model_key]['input_tokens'] += msg.input_tokens or 0
2284
+ model_stats[model_key]['output_tokens'] += msg.output_tokens or 0
2285
+ model_stats[model_key]['total_tokens'] += (msg.input_tokens or 0) + (msg.output_tokens or 0)
2286
+ model_stats[model_key]['count'] += 1
2287
+
2288
+ stats = list(model_stats.values())
2289
+ else:
2290
+ # 전체 집계
2291
+ total_input = sum(msg.input_tokens or 0 for msg in messages)
2292
+ total_output = sum(msg.output_tokens or 0 for msg in messages)
2293
+ stats = [{
2294
+ 'input_tokens': total_input,
2295
+ 'output_tokens': total_output,
2296
+ 'total_tokens': total_input + total_output,
2297
+ 'count': len(messages)
2298
+ }]
2299
+
2300
+ # 사용 가능한 모델 목록
2301
+ available_models = db.session.query(
2302
+ ChatMessage.model_name
2303
+ ).filter(
2304
+ ChatMessage.role == 'ai',
2305
+ ChatMessage.model_name.isnot(None)
2306
+ ).distinct().all()
2307
+
2308
+ models = [m[0] for m in available_models if m[0]]
2309
+
2310
+ # 사용자 사용과 시스템 사용 통계
2311
+ user_total_input = sum(msg.input_tokens or 0 for msg in user_messages)
2312
+ user_total_output = sum(msg.output_tokens or 0 for msg in user_messages)
2313
+ system_total_input = sum(msg.input_tokens or 0 for msg in system_messages)
2314
+ system_total_output = sum(msg.output_tokens or 0 for msg in system_messages)
2315
+
2316
+ return jsonify({
2317
+ 'success': True,
2318
+ 'stats': stats,
2319
+ 'models': models,
2320
+ 'start_date': start_date.isoformat(),
2321
+ 'end_date': end_date.isoformat(),
2322
+ 'total_messages': len(messages),
2323
+ 'user_usage': {
2324
+ 'input_tokens': user_total_input,
2325
+ 'output_tokens': user_total_output,
2326
+ 'total_tokens': user_total_input + user_total_output,
2327
+ 'count': len(user_messages)
2328
+ },
2329
+ 'system_usage': {
2330
+ 'input_tokens': system_total_input,
2331
+ 'output_tokens': system_total_output,
2332
+ 'total_tokens': system_total_input + system_total_output,
2333
+ 'count': len(system_messages)
2334
+ }
2335
+ }), 200
2336
+
2337
+ except Exception as e:
2338
+ import traceback
2339
+ print(f"[토큰 통계] 오류: {str(e)}")
2340
+ print(traceback.format_exc())
2341
+ return jsonify({'error': f'토큰 통��� 조회 중 오류가 발생했습니다: {str(e)}'}), 500
2342
+
2343
  @main_bp.route('/api/admin/users', methods=['GET'])
2344
  @admin_required
2345
  def get_users():
 
2436
  page = request.args.get('page', 1, type=int)
2437
  per_page = request.args.get('per_page', 50, type=int)
2438
 
2439
+ # user_id가 있는 경우에만 join 사용, 그 외에는 직접 조회
 
2440
  if user_id:
2441
+ # user_id 필터링할 때는 join 필요
2442
+ query = ChatMessage.query.join(ChatSession).filter(ChatSession.user_id == user_id)
2443
+ else:
2444
+ # user_id가 없으면 join 없이 직접 조회
2445
+ query = ChatMessage.query
2446
+
2447
  if session_id:
2448
  query = query.filter(ChatMessage.session_id == session_id)
2449
  if message_id:
 
2460
  }), 200
2461
 
2462
  except Exception as e:
2463
+ import traceback
2464
+ error_trace = traceback.format_exc()
2465
+ print(f"[메시지 조회] 오류: {str(e)}")
2466
+ print(error_trace)
2467
  return jsonify({'error': f'메시지 조회 중 오류가 발생했습니다: {str(e)}'}), 500
2468
 
2469
  @main_bp.route('/api/admin/sessions', methods=['GET'])
 
2485
 
2486
  sessions_data = []
2487
  for session in sessions.items:
2488
+ try:
2489
+ session_dict = session.to_dict()
2490
+ # 사용자 정보 안전하게 가져오기
2491
+ try:
2492
+ if session.user:
2493
+ session_dict['username'] = session.user.username if hasattr(session.user, 'username') else 'Unknown'
2494
+ session_dict['nickname'] = session.user.nickname if hasattr(session.user, 'nickname') else None
2495
+ else:
2496
+ # user_id로 직접 조회 시도
2497
+ user = User.query.get(session.user_id) if session.user_id else None
2498
+ if user:
2499
+ session_dict['username'] = user.username
2500
+ session_dict['nickname'] = user.nickname
2501
+ else:
2502
+ session_dict['username'] = 'Unknown'
2503
+ session_dict['nickname'] = None
2504
+ except Exception as user_error:
2505
+ print(f"[세션 조회] 사용자 정보 조회 오류 (세션 ID: {session.id}): {str(user_error)}")
2506
+ session_dict['username'] = 'Unknown'
2507
+ session_dict['nickname'] = None
2508
+
2509
+ sessions_data.append(session_dict)
2510
+ except Exception as session_error:
2511
+ print(f"[세션 조회] 세션 처리 오류 (세션 ID: {session.id if hasattr(session, 'id') else 'Unknown'}): {str(session_error)}")
2512
+ import traceback
2513
+ traceback.print_exc()
2514
+ continue # 문제가 있는 세션은 건너뛰고 계속 진행
2515
 
2516
  return jsonify({
2517
  'sessions': sessions_data,
 
2521
  }), 200
2522
 
2523
  except Exception as e:
2524
+ import traceback
2525
+ error_trace = traceback.format_exc()
2526
+ print(f"[세션 조회] 전체 오류: {str(e)}")
2527
+ print(error_trace)
2528
  return jsonify({'error': f'대화 세션 조회 중 오류가 발생했습니다: {str(e)}'}), 500
2529
 
2530
  @main_bp.route('/api/admin/users/<int:user_id>', methods=['DELETE'])
 
3049
  try:
3050
  if is_postgresql:
3051
  # PostgreSQL 연결 테스트
3052
+ engine = create_engine(
3053
+ db_uri,
3054
+ pool_pre_ping=True,
3055
+ pool_recycle=300
3056
+ )
3057
  with engine.connect() as conn:
3058
  # 버전 확인
3059
  result = conn.execute(text("SELECT version()"))
 
3191
  except Exception as e:
3192
  return jsonify({'error': f'모델 목록을 가져오는 중 오류가 발생했습니다: {str(e)}', 'models': []}), 500
3193
 
3194
+ @main_bp.route('/api/admin/default-models', methods=['GET'])
3195
+ @admin_required
3196
+ def get_default_models():
3197
+ """기본 AI 모델 설정 조회"""
3198
+ try:
3199
+ default_analysis_model = SystemConfig.get_config('default_analysis_model', '')
3200
+ default_answer_model = SystemConfig.get_config('default_answer_model', '')
3201
+
3202
+ return jsonify({
3203
+ 'success': True,
3204
+ 'default_analysis_model': default_analysis_model,
3205
+ 'default_answer_model': default_answer_model
3206
+ }), 200
3207
+ except Exception as e:
3208
+ return jsonify({'error': f'기본 모델 설정 조회 중 오류가 발생했습니다: {str(e)}'}), 500
3209
+
3210
+ @main_bp.route('/api/admin/default-models', methods=['POST'])
3211
+ @admin_required
3212
+ def set_default_models():
3213
+ """기본 AI 모델 설정 저장"""
3214
+ try:
3215
+ data = request.json
3216
+ default_analysis_model = data.get('default_analysis_model', '').strip()
3217
+ default_answer_model = data.get('default_answer_model', '').strip()
3218
+
3219
+ # 빈 문자열이면 설정 삭제
3220
+ if default_analysis_model:
3221
+ SystemConfig.set_config('default_analysis_model', default_analysis_model, '기본 질문 분석용 AI 모델')
3222
+ else:
3223
+ # 설정 삭제
3224
+ config = SystemConfig.query.filter_by(key='default_analysis_model').first()
3225
+ if config:
3226
+ db.session.delete(config)
3227
+ db.session.commit()
3228
+
3229
+ if default_answer_model:
3230
+ SystemConfig.set_config('default_answer_model', default_answer_model, '기본 답변 생성용 AI 모델')
3231
+ else:
3232
+ # 설정 삭제
3233
+ config = SystemConfig.query.filter_by(key='default_answer_model').first()
3234
+ if config:
3235
+ db.session.delete(config)
3236
+ db.session.commit()
3237
+
3238
+ return jsonify({
3239
+ 'success': True,
3240
+ 'message': '기본 AI 모델이 설정되었습니다.',
3241
+ 'default_analysis_model': default_analysis_model or None,
3242
+ 'default_answer_model': default_answer_model or None
3243
+ }), 200
3244
+ except Exception as e:
3245
+ db.session.rollback()
3246
+ return jsonify({'error': f'기본 모델 설정 저장 중 오류가 발생했습니다: {str(e)}'}), 500
3247
+
3248
+ @main_bp.route('/api/default-models', methods=['GET'])
3249
+ @login_required
3250
+ def get_user_default_models():
3251
+ """사용자용: 기본 AI 모델 설정 조회 (공개 API)"""
3252
+ try:
3253
+ default_analysis_model = SystemConfig.get_config('default_analysis_model', '')
3254
+ default_answer_model = SystemConfig.get_config('default_answer_model', '')
3255
+
3256
+ return jsonify({
3257
+ 'success': True,
3258
+ 'default_analysis_model': default_analysis_model,
3259
+ 'default_answer_model': default_answer_model
3260
+ }), 200
3261
+ except Exception as e:
3262
+ return jsonify({'error': f'기본 모델 설정 조회 중 오류가 발생했습니다: {str(e)}'}), 500
3263
+
3264
  @main_bp.route('/api/chat', methods=['POST'])
3265
  @login_required
3266
  def chat():
 
3690
  # 모델 타입 확인 (Gemini 또는 Ollama)
3691
  is_gemini = answer_model.startswith('gemini:')
3692
 
3693
+ # 토큰 정보 변수 초기화
3694
+ gemini_input_tokens = None
3695
+ gemini_output_tokens = None
3696
+ gemini_model_used = None
3697
+ ollama_input_tokens = None
3698
+ ollama_output_tokens = None
3699
+ ollama_model_used = None
3700
+
3701
  print(f"[최종 답변 생성] 답변 모델: {answer_model}, 프롬프트 길이: {len(full_prompt)}자")
3702
 
3703
  if is_gemini:
 
3723
  if not response_text:
3724
  print(f"[채팅] Gemini 응답이 비어있습니다. result: {result}")
3725
  response_text = '응답을 생성할 수 없었습니다. 다시 시도해주세요.'
3726
+
3727
+ # 토큰 정보 추출
3728
+ gemini_input_tokens = result.get('input_tokens')
3729
+ gemini_output_tokens = result.get('output_tokens')
3730
+ gemini_model_used = gemini_model_name
3731
  else:
3732
  # Ollama API 호출
3733
  # Ollama 서버 연결 확인
 
3776
  if not response_text:
3777
  print(f"[채팅] Ollama 응답이 비어있습니다. ollama_data: {ollama_data}")
3778
  response_text = '응답을 생성할 수 없었습니다. 다시 시도해주세요.'
3779
+
3780
+ # Ollama 토큰 정보 추출
3781
+ ollama_input_tokens = None
3782
+ ollama_output_tokens = None
3783
+ if 'prompt_eval_count' in ollama_data:
3784
+ ollama_input_tokens = ollama_data.get('prompt_eval_count')
3785
+ if 'eval_count' in ollama_data:
3786
+ ollama_output_tokens = ollama_data.get('eval_count')
3787
+ ollama_model_used = answer_model
3788
+
3789
+ if ollama_input_tokens or ollama_output_tokens:
3790
+ print(f"[Ollama] 토큰 사용량: 입력={ollama_input_tokens}, 출력={ollama_output_tokens}")
3791
 
3792
  # 대화 세션에 메시지 저장 (Gemini와 Ollama 공통)
3793
  session_id = data.get('session_id')
 
3843
  else:
3844
  print(f"[메시지 저장] 중복 메시지로 인해 저장을 건너뜁니다.")
3845
 
3846
+ # AI 응답 저장 (토큰 정보 포함)
3847
+ # 토큰 정보 설정 (Gemini 또는 Ollama)
3848
+ input_tokens = gemini_input_tokens if is_gemini else ollama_input_tokens
3849
+ output_tokens = gemini_output_tokens if is_gemini else ollama_output_tokens
3850
+ model_used = gemini_model_used if is_gemini else ollama_model_used
3851
+
3852
  ai_msg = ChatMessage(
3853
  session_id=session_id,
3854
  role='ai',
3855
+ content=response_text,
3856
+ input_tokens=input_tokens,
3857
+ output_tokens=output_tokens,
3858
+ model_name=model_used
3859
  )
3860
  db.session.add(ai_msg)
3861
 
3862
+ if input_tokens or output_tokens:
3863
+ print(f"[메시지 저장] AI 메시지 저장 (모델: {model_used}, 입력 토큰: {input_tokens}, 출력 토큰: {output_tokens})")
3864
+
3865
  # 세션 모델 정보 업데이트 (첫 메시지인 경우 또는 변경된 경우)
3866
  if not session.analysis_model or session.analysis_model != analysis_model:
3867
  session.analysis_model = analysis_model
app/vector_db.py CHANGED
@@ -5,12 +5,19 @@ Chroma DB를 사용한 벡터 검색 및 Re-ranking 시스템
5
 
6
  import os
7
  import json
 
8
  import chromadb
9
  from chromadb.config import Settings
10
  from sentence_transformers import SentenceTransformer, CrossEncoder
11
  from pathlib import Path
12
  import numpy as np
13
 
 
 
 
 
 
 
14
  # 벡터 DB 경로
15
  VECTOR_DB_PATH = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'vector_db')
16
 
 
5
 
6
  import os
7
  import json
8
+ import logging
9
  import chromadb
10
  from chromadb.config import Settings
11
  from sentence_transformers import SentenceTransformer, CrossEncoder
12
  from pathlib import Path
13
  import numpy as np
14
 
15
+ # ChromaDB 텔레메트리 오류 억제
16
+ logging.getLogger('chromadb.telemetry.product.posthog').setLevel(logging.CRITICAL)
17
+
18
+ # 환경 변수로 텔레메트리 비활성화 (ChromaDB가 지원하는 경우)
19
+ os.environ.setdefault('CHROMA_TELEMETRY_DISABLED', '1')
20
+
21
  # 벡터 DB 경로
22
  VECTOR_DB_PATH = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'vector_db')
23
 
migrate_add_is_public.py CHANGED
@@ -64,3 +64,5 @@ if __name__ == '__main__':
64
 
65
 
66
 
 
 
 
64
 
65
 
66
 
67
+
68
+
migrate_add_token_fields.py ADDED
@@ -0,0 +1,77 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import sqlite3
2
+ import os
3
+ from pathlib import Path
4
+
5
+ # 데이터베이스 경로 (app/core/config.py와 동일하게 설정)
6
+ PROJECT_ROOT = Path(__file__).parent
7
+ db_path = PROJECT_ROOT / 'instance' / 'finance_analysis.db'
8
+
9
+ def migrate_database():
10
+ if not db_path.exists():
11
+ print(f"데이터베이스 파일이 없습니다: {db_path}")
12
+ print("앱을 실행하면 자동으로 생성됩니다.")
13
+ return
14
+
15
+ conn = None
16
+ try:
17
+ conn = sqlite3.connect(str(db_path))
18
+ cursor = conn.cursor()
19
+
20
+ # chat_message 테이블에 토큰 정보 컬럼이 있는지 확인
21
+ cursor.execute("PRAGMA table_info(chat_message)")
22
+ columns = [column[1] for column in cursor.fetchall()]
23
+
24
+ changes_made = False
25
+
26
+ # input_tokens 컬럼 추가
27
+ if 'input_tokens' not in columns:
28
+ print("input_tokens 컬럼을 추가하는 중...")
29
+ cursor.execute("ALTER TABLE chat_message ADD COLUMN input_tokens INTEGER")
30
+ changes_made = True
31
+ else:
32
+ print("input_tokens 컬럼이 이미 존재합니다.")
33
+
34
+ # output_tokens 컬럼 추가
35
+ if 'output_tokens' not in columns:
36
+ print("output_tokens 컬럼을 추가하는 중...")
37
+ cursor.execute("ALTER TABLE chat_message ADD COLUMN output_tokens INTEGER")
38
+ changes_made = True
39
+ else:
40
+ print("output_tokens 컬럼이 이미 존재합니다.")
41
+
42
+ # model_name 컬럼 추가
43
+ if 'model_name' not in columns:
44
+ print("model_name 컬럼을 추가하는 중...")
45
+ cursor.execute("ALTER TABLE chat_message ADD COLUMN model_name VARCHAR(100)")
46
+ changes_made = True
47
+ else:
48
+ print("model_name 컬럼이 이미 존재합니다.")
49
+
50
+ if changes_made:
51
+ conn.commit()
52
+ print("토큰 정보 컬럼이 성공적으로 추가되었습니다.")
53
+ else:
54
+ print("모든 컬럼이 이미 존재합니다. 마이그레이션이 필요하지 않습니다.")
55
+
56
+ conn.close()
57
+
58
+ except sqlite3.OperationalError as e:
59
+ print(f"오류 발생: {e}")
60
+ if "duplicate column name" in str(e).lower():
61
+ print("일부 컬럼이 이미 존재합니다.")
62
+ else:
63
+ raise
64
+ except Exception as e:
65
+ print(f"예상치 못한 오류: {e}")
66
+ raise
67
+ finally:
68
+ if conn:
69
+ conn.close()
70
+
71
+ if __name__ == '__main__':
72
+ print("=" * 60)
73
+ print("데이터베이스 마이그레이션: 토큰 정보 컬럼 추가")
74
+ print("=" * 60)
75
+ migrate_database()
76
+ print("마이그레이션이 완료되었습니다.")
77
+
migrate_add_usage_type.py ADDED
@@ -0,0 +1,65 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import sqlite3
2
+ import os
3
+ from pathlib import Path
4
+
5
+ # 데이터베이스 경로 (app/core/config.py와 동일하게 설정)
6
+ PROJECT_ROOT = Path(__file__).parent
7
+ db_path = PROJECT_ROOT / 'instance' / 'finance_analysis.db'
8
+
9
+ def migrate_database():
10
+ if not db_path.exists():
11
+ print(f"데이터베이스 파일이 없습니다: {db_path}")
12
+ print("앱을 실행하면 자동으로 생성됩니다.")
13
+ return
14
+
15
+ conn = None
16
+ try:
17
+ conn = sqlite3.connect(str(db_path))
18
+ cursor = conn.cursor()
19
+
20
+ # chat_message 테이블에 usage_type 컬럼이 있는지 확인
21
+ cursor.execute("PRAGMA table_info(chat_message)")
22
+ columns = [column[1] for column in cursor.fetchall()]
23
+
24
+ changes_made = False
25
+
26
+ # usage_type 컬럼 추가
27
+ if 'usage_type' not in columns:
28
+ print("usage_type 컬럼을 추가하는 중...")
29
+ cursor.execute("ALTER TABLE chat_message ADD COLUMN usage_type VARCHAR(20) DEFAULT 'user'")
30
+ changes_made = True
31
+ else:
32
+ print("usage_type 컬럼이 이미 존재합니다.")
33
+
34
+ # session_id를 nullable로 변경 (시스템 사용은 세션이 없을 수 있음)
35
+ # SQLite는 ALTER COLUMN을 직접 지원하지 않으므로, 테이블 재생성이 필요할 수 있음
36
+ # 하지만 기존 데이터가 있으면 복잡하므로, 일단 nullable 체크는 애플리케이션 레벨에서 처리
37
+
38
+ if changes_made:
39
+ conn.commit()
40
+ print("usage_type 컬럼이 성공적으로 추가되었습니다.")
41
+ else:
42
+ print("모든 컬럼이 이미 존재합니다. 마이그레이션이 필요하지 않습니다.")
43
+
44
+ conn.close()
45
+
46
+ except sqlite3.OperationalError as e:
47
+ print(f"오류 발생: {e}")
48
+ if "duplicate column name" in str(e).lower():
49
+ print("일부 컬럼이 이미 존재합니다.")
50
+ else:
51
+ raise
52
+ except Exception as e:
53
+ print(f"예상치 못한 오류: {e}")
54
+ raise
55
+ finally:
56
+ if conn:
57
+ conn.close()
58
+
59
+ if __name__ == '__main__':
60
+ print("=" * 60)
61
+ print("데이터베이스 마이그레이션: usage_type 컬럼 추가")
62
+ print("=" * 60)
63
+ migrate_database()
64
+ print("마이그레이션이 완료되었습니다.")
65
+
migrate_session_id_nullable.py ADDED
@@ -0,0 +1,115 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import sqlite3
2
+ import os
3
+ from pathlib import Path
4
+
5
+ # 데이터베이스 경로
6
+ PROJECT_ROOT = Path(__file__).parent
7
+ db_path = PROJECT_ROOT / 'instance' / 'finance_analysis.db'
8
+
9
+ def migrate_database():
10
+ if not db_path.exists():
11
+ print(f"데이터베이스 파일이 없습니다: {db_path}")
12
+ print("앱을 실행하면 자동으로 생성됩니다.")
13
+ return
14
+
15
+ conn = None
16
+ try:
17
+ conn = sqlite3.connect(str(db_path))
18
+ cursor = conn.cursor()
19
+
20
+ # SQLite는 ALTER COLUMN을 직접 지원하지 않으므로 테이블 재생성이 필요
21
+ # 하지만 기존 데이터를 보존해야 하므로 다음 방법을 사용:
22
+ # 1. 새 테이블 생성 (nullable=True)
23
+ # 2. 기존 데이터 복사
24
+ # 3. 기존 테이블 삭제
25
+ # 4. 새 테이블 이름 변경
26
+
27
+ print("chat_message 테이블의 session_id 컬럼을 nullable로 변경하는 중...")
28
+
29
+ # 기존 테이블 구조 확인
30
+ cursor.execute("PRAGMA table_info(chat_message)")
31
+ columns = cursor.fetchall()
32
+
33
+ # session_id 컬럼 정보 확인
34
+ session_id_col = None
35
+ for col in columns:
36
+ if col[1] == 'session_id':
37
+ session_id_col = col
38
+ break
39
+
40
+ if not session_id_col:
41
+ print("session_id 컬럼을 찾을 수 없습니다.")
42
+ return
43
+
44
+ # 이미 nullable인지 확인 (SQLite에서는 NOT NULL이 없으면 nullable)
45
+ # 하지만 실제로는 제약조건을 확인하기 어려우므로, 테이블 재생성으로 해결
46
+
47
+ # 1. 새 테이블 생성 (nullable=True)
48
+ print("새 테이블 생성 중...")
49
+ cursor.execute("""
50
+ CREATE TABLE chat_message_new (
51
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
52
+ session_id INTEGER,
53
+ role VARCHAR(20) NOT NULL,
54
+ content TEXT NOT NULL,
55
+ created_at DATETIME NOT NULL,
56
+ input_tokens INTEGER,
57
+ output_tokens INTEGER,
58
+ model_name VARCHAR(100),
59
+ usage_type VARCHAR(20) DEFAULT 'user',
60
+ FOREIGN KEY (session_id) REFERENCES chat_session(id)
61
+ )
62
+ """)
63
+
64
+ # 2. 기존 데이터 복사
65
+ print("기존 데이터 복사 중...")
66
+ cursor.execute("""
67
+ INSERT INTO chat_message_new
68
+ (id, session_id, role, content, created_at, input_tokens, output_tokens, model_name, usage_type)
69
+ SELECT
70
+ id, session_id, role, content, created_at, input_tokens, output_tokens, model_name, usage_type
71
+ FROM chat_message
72
+ """)
73
+
74
+ # 3. 기존 테이블 삭제
75
+ print("기존 테이블 삭제 중...")
76
+ cursor.execute("DROP TABLE chat_message")
77
+
78
+ # 4. 새 테이블 이름 변경
79
+ print("새 테이블 이름 변경 중...")
80
+ cursor.execute("ALTER TABLE chat_message_new RENAME TO chat_message")
81
+
82
+ # 인덱스 재생성 (필요한 경우)
83
+ try:
84
+ cursor.execute("CREATE INDEX IF NOT EXISTS idx_chat_message_session_id ON chat_message(session_id)")
85
+ cursor.execute("CREATE INDEX IF NOT EXISTS idx_chat_message_created_at ON chat_message(created_at)")
86
+ cursor.execute("CREATE INDEX IF NOT EXISTS idx_chat_message_usage_type ON chat_message(usage_type)")
87
+ except:
88
+ pass
89
+
90
+ conn.commit()
91
+ print("session_id 컬럼이 성공적으로 nullable로 변경되었습니다.")
92
+
93
+ conn.close()
94
+
95
+ except sqlite3.OperationalError as e:
96
+ print(f"오류 발생: {e}")
97
+ if conn:
98
+ conn.rollback()
99
+ raise
100
+ except Exception as e:
101
+ print(f"예상치 못한 오류: {e}")
102
+ if conn:
103
+ conn.rollback()
104
+ raise
105
+ finally:
106
+ if conn:
107
+ conn.close()
108
+
109
+ if __name__ == '__main__':
110
+ print("=" * 60)
111
+ print("데이터베이스 마이그레이션: session_id nullable 변경")
112
+ print("=" * 60)
113
+ migrate_database()
114
+ print("마이그레이션이 완료되었습니다.")
115
+
run.py CHANGED
@@ -51,6 +51,10 @@ werkzeug_handler.setFormatter(logging.Formatter(
51
  ))
52
  werkzeug_logger.addHandler(werkzeug_handler)
53
 
 
 
 
 
54
  app.logger.info('서버 시작')
55
 
56
  if __name__ == '__main__':
 
51
  ))
52
  werkzeug_logger.addHandler(werkzeug_handler)
53
 
54
+ # ChromaDB 텔레메트리 오류 억제
55
+ chromadb_telemetry_logger = logging.getLogger('chromadb.telemetry.product.posthog')
56
+ chromadb_telemetry_logger.setLevel(logging.CRITICAL)
57
+
58
  app.logger.info('서버 시작')
59
 
60
  if __name__ == '__main__':
templates/admin.html CHANGED
@@ -572,6 +572,7 @@
572
  <a href="{{ url_for('main.admin_prompts') }}" class="btn btn-secondary">프롬프트 관리</a>
573
  <a href="{{ url_for('main.admin_settings') }}" class="btn btn-secondary">AI 설정</a>
574
  <a href="{{ url_for('main.admin_utils') }}" class="btn btn-secondary">유틸</a>
 
575
  <a href="{{ url_for('main.index') }}" class="btn btn-secondary">메인으로</a>
576
  <a href="{{ url_for('main.logout') }}" class="btn btn-secondary">로그아웃</a>
577
  </div>
@@ -592,6 +593,7 @@
592
  <a href="{{ url_for('main.admin_prompts') }}" class="mobile-menu-item" onclick="closeMobileMenu()">프롬프트 관리</a>
593
  <a href="{{ url_for('main.admin_settings') }}" class="mobile-menu-item" onclick="closeMobileMenu()">AI 설정</a>
594
  <a href="{{ url_for('main.admin_utils') }}" class="mobile-menu-item" onclick="closeMobileMenu()">유틸</a>
 
595
  <a href="{{ url_for('main.index') }}" class="mobile-menu-item" onclick="closeMobileMenu()">메인으로</a>
596
  <a href="{{ url_for('main.logout') }}" class="mobile-menu-item" onclick="closeMobileMenu()">로그아웃</a>
597
  </div>
 
572
  <a href="{{ url_for('main.admin_prompts') }}" class="btn btn-secondary">프롬프트 관리</a>
573
  <a href="{{ url_for('main.admin_settings') }}" class="btn btn-secondary">AI 설정</a>
574
  <a href="{{ url_for('main.admin_utils') }}" class="btn btn-secondary">유틸</a>
575
+ <a href="{{ url_for('main.admin_tokens') }}" class="btn btn-secondary">토큰 통계</a>
576
  <a href="{{ url_for('main.index') }}" class="btn btn-secondary">메인으로</a>
577
  <a href="{{ url_for('main.logout') }}" class="btn btn-secondary">로그아웃</a>
578
  </div>
 
593
  <a href="{{ url_for('main.admin_prompts') }}" class="mobile-menu-item" onclick="closeMobileMenu()">프롬프트 관리</a>
594
  <a href="{{ url_for('main.admin_settings') }}" class="mobile-menu-item" onclick="closeMobileMenu()">AI 설정</a>
595
  <a href="{{ url_for('main.admin_utils') }}" class="mobile-menu-item" onclick="closeMobileMenu()">유틸</a>
596
+ <a href="{{ url_for('main.admin_tokens') }}" class="mobile-menu-item" onclick="closeMobileMenu()">토큰 통계</a>
597
  <a href="{{ url_for('main.index') }}" class="mobile-menu-item" onclick="closeMobileMenu()">메인으로</a>
598
  <a href="{{ url_for('main.logout') }}" class="mobile-menu-item" onclick="closeMobileMenu()">로그아웃</a>
599
  </div>
templates/admin_messages.html CHANGED
@@ -578,13 +578,16 @@
578
  <th>세션 ID</th>
579
  <th>역할</th>
580
  <th>내용</th>
 
 
 
581
  <th>시간</th>
582
  <th>작업</th>
583
  </tr>
584
  </thead>
585
  <tbody id="messagesTableBody">
586
  <tr>
587
- <td colspan="6" style="text-align: center; padding: 20px; color: #5f6368;">세션을 선택하거나 필터를 사용하세요</td>
588
  </tr>
589
  </tbody>
590
  </table>
@@ -720,7 +723,7 @@
720
  // 메시지 목록 로드
721
  async function loadMessages(page = 1) {
722
  const tbody = document.getElementById('messagesTableBody');
723
- tbody.innerHTML = '<tr><td colspan="6" style="text-align: center; padding: 20px; color: #5f6368;">로딩 중...</td></tr>';
724
 
725
  try {
726
  const sessionId = document.getElementById('sessionIdFilter').value;
@@ -751,12 +754,18 @@
751
  const row = document.createElement('tr');
752
  const date = new Date(msg.created_at).toLocaleString('ko-KR');
753
  const contentPreview = msg.content && msg.content.length > 100 ? msg.content.substring(0, 100) + '...' : (msg.content || '');
 
 
 
754
 
755
  row.innerHTML = `
756
  <td>${msg.id}</td>
757
  <td>${msg.session_id}</td>
758
  <td><span class="role-badge role-${msg.role}">${msg.role === 'user' ? '사용자' : 'AI'}</span></td>
759
  <td class="message-content">${escapeHtml(contentPreview)}</td>
 
 
 
760
  <td>${date}</td>
761
  <td>
762
  <button class="btn btn-secondary" onclick="viewMessageById(${msg.id})" style="padding: 4px 8px; font-size: 12px;">상세 보기</button>
@@ -766,11 +775,14 @@
766
  row.setAttribute('data-message-id', msg.id);
767
  row.setAttribute('data-message-role', msg.role);
768
  row.setAttribute('data-message-content', escapeHtml(msg.content || ''));
 
 
 
769
  tbody.appendChild(row);
770
  });
771
  } else {
772
  console.log('[메시지 목록] 메시지가 없습니다');
773
- tbody.innerHTML = '<tr><td colspan="6" style="text-align: center; padding: 20px; color: #5f6368;">메시지가 없습니다.</td></tr>';
774
  }
775
 
776
  // 페이지네이션
@@ -785,7 +797,7 @@
785
  } catch (error) {
786
  console.error('[메시지 목록] 조회 오류:', error);
787
  showAlert(`메시지 조회 오류: ${error.message}`, 'error');
788
- tbody.innerHTML = `<tr><td colspan="6" style="text-align: center; padding: 20px; color: #ea4335;">메시지 조회 중 오류가 발생했습니다.<br><small>${error.message || '알 수 없는 오류'}</small></td></tr>`;
789
  }
790
  }
791
 
@@ -804,7 +816,51 @@
804
 
805
  if (data.messages && data.messages.length > 0) {
806
  const msg = data.messages[0];
807
- viewMessage(msg.id, msg.role, msg.content);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
808
  } else {
809
  // 데이터 속성에서 가져오기 (fallback)
810
  const row = document.querySelector(`tr[data-message-id="${messageId}"]`);
@@ -835,6 +891,35 @@
835
  const modal = document.getElementById('messageModal');
836
  const modalContent = document.getElementById('messageModalContent');
837
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
838
  modalContent.innerHTML = `
839
  <div class="message-view">
840
  <div class="message-item ${role}">
@@ -843,6 +928,7 @@
843
  <span class="message-item-time">메시지 ID: ${messageId}</span>
844
  </div>
845
  <div class="message-item-content">${content}</div>
 
846
  </div>
847
  </div>
848
  `;
@@ -914,6 +1000,8 @@
914
  window.addEventListener('load', () => {
915
  loadUsers();
916
  loadSessions();
 
 
917
  });
918
  </script>
919
  </body>
 
578
  <th>세션 ID</th>
579
  <th>역할</th>
580
  <th>내용</th>
581
+ <th>모델</th>
582
+ <th>입력 토큰</th>
583
+ <th>출력 토큰</th>
584
  <th>시간</th>
585
  <th>작업</th>
586
  </tr>
587
  </thead>
588
  <tbody id="messagesTableBody">
589
  <tr>
590
+ <td colspan="9" style="text-align: center; padding: 20px; color: #5f6368;">로딩 중...</td>
591
  </tr>
592
  </tbody>
593
  </table>
 
723
  // 메시지 목록 로드
724
  async function loadMessages(page = 1) {
725
  const tbody = document.getElementById('messagesTableBody');
726
+ tbody.innerHTML = '<tr><td colspan="9" style="text-align: center; padding: 20px; color: #5f6368;">로딩 중...</td></tr>';
727
 
728
  try {
729
  const sessionId = document.getElementById('sessionIdFilter').value;
 
754
  const row = document.createElement('tr');
755
  const date = new Date(msg.created_at).toLocaleString('ko-KR');
756
  const contentPreview = msg.content && msg.content.length > 100 ? msg.content.substring(0, 100) + '...' : (msg.content || '');
757
+ const modelName = msg.model_name || '-';
758
+ const inputTokens = msg.input_tokens !== null && msg.input_tokens !== undefined ? msg.input_tokens.toLocaleString() : '-';
759
+ const outputTokens = msg.output_tokens !== null && msg.output_tokens !== undefined ? msg.output_tokens.toLocaleString() : '-';
760
 
761
  row.innerHTML = `
762
  <td>${msg.id}</td>
763
  <td>${msg.session_id}</td>
764
  <td><span class="role-badge role-${msg.role}">${msg.role === 'user' ? '사용자' : 'AI'}</span></td>
765
  <td class="message-content">${escapeHtml(contentPreview)}</td>
766
+ <td>${modelName}</td>
767
+ <td style="text-align: right;">${inputTokens}</td>
768
+ <td style="text-align: right;">${outputTokens}</td>
769
  <td>${date}</td>
770
  <td>
771
  <button class="btn btn-secondary" onclick="viewMessageById(${msg.id})" style="padding: 4px 8px; font-size: 12px;">상세 보기</button>
 
775
  row.setAttribute('data-message-id', msg.id);
776
  row.setAttribute('data-message-role', msg.role);
777
  row.setAttribute('data-message-content', escapeHtml(msg.content || ''));
778
+ row.setAttribute('data-message-model', modelName);
779
+ row.setAttribute('data-message-input-tokens', msg.input_tokens || '');
780
+ row.setAttribute('data-message-output-tokens', msg.output_tokens || '');
781
  tbody.appendChild(row);
782
  });
783
  } else {
784
  console.log('[메시지 목록] 메시지가 없습니다');
785
+ tbody.innerHTML = '<tr><td colspan="9" style="text-align: center; padding: 20px; color: #5f6368;">메시지가 없습니다.</td></tr>';
786
  }
787
 
788
  // 페이지네이션
 
797
  } catch (error) {
798
  console.error('[메시지 목록] 조회 오류:', error);
799
  showAlert(`메시지 조회 오류: ${error.message}`, 'error');
800
+ tbody.innerHTML = `<tr><td colspan="9" style="text-align: center; padding: 20px; color: #ea4335;">메시지 조회 중 오류가 발생했습니다.<br><small>${error.message || '알 수 없는 오류'}</small></td></tr>`;
801
  }
802
  }
803
 
 
816
 
817
  if (data.messages && data.messages.length > 0) {
818
  const msg = data.messages[0];
819
+ const modelName = msg.model_name || '-';
820
+ const inputTokens = msg.input_tokens;
821
+ const outputTokens = msg.output_tokens;
822
+
823
+ // 메시지 상세 보기 (토큰 정보 포함)
824
+ const modal = document.getElementById('messageModal');
825
+ const modalContent = document.getElementById('messageModalContent');
826
+
827
+ let tokenInfo = '';
828
+ if (msg.role === 'ai' && (inputTokens || outputTokens)) {
829
+ tokenInfo = `
830
+ <div style="margin-top: 16px; padding: 12px; background: #f8f9fa; border-radius: 6px;">
831
+ <div style="font-size: 14px; font-weight: 500; margin-bottom: 8px; color: #5f6368;">토큰 사용량</div>
832
+ <div style="display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px;">
833
+ <div>
834
+ <div style="font-size: 12px; color: #5f6368; margin-bottom: 4px;">모델</div>
835
+ <div style="font-size: 16px; font-weight: 500;">${modelName}</div>
836
+ </div>
837
+ <div>
838
+ <div style="font-size: 12px; color: #5f6368; margin-bottom: 4px;">입력 토큰</div>
839
+ <div style="font-size: 16px; font-weight: 500;">${inputTokens ? parseInt(inputTokens).toLocaleString() : '-'}</div>
840
+ </div>
841
+ <div>
842
+ <div style="font-size: 12px; color: #5f6368; margin-bottom: 4px;">출력 토큰</div>
843
+ <div style="font-size: 16px; font-weight: 500;">${outputTokens ? parseInt(outputTokens).toLocaleString() : '-'}</div>
844
+ </div>
845
+ </div>
846
+ </div>
847
+ `;
848
+ }
849
+
850
+ modalContent.innerHTML = `
851
+ <div class="message-view">
852
+ <div class="message-item ${msg.role}">
853
+ <div class="message-item-header">
854
+ <span class="message-item-role role-badge role-${msg.role}">${msg.role === 'user' ? '사용자' : 'AI'}</span>
855
+ <span class="message-item-time">메시지 ID: ${msg.id}</span>
856
+ </div>
857
+ <div class="message-item-content">${escapeHtml(msg.content || '')}</div>
858
+ ${tokenInfo}
859
+ </div>
860
+ </div>
861
+ `;
862
+
863
+ modal.classList.add('active');
864
  } else {
865
  // 데이터 속성에서 가져오기 (fallback)
866
  const row = document.querySelector(`tr[data-message-id="${messageId}"]`);
 
891
  const modal = document.getElementById('messageModal');
892
  const modalContent = document.getElementById('messageModalContent');
893
 
894
+ // 데이터 속성에서 토큰 정보 가져오기
895
+ const row = document.querySelector(`tr[data-message-id="${messageId}"]`);
896
+ const modelName = row ? row.getAttribute('data-message-model') : '-';
897
+ const inputTokens = row ? row.getAttribute('data-message-input-tokens') : '';
898
+ const outputTokens = row ? row.getAttribute('data-message-output-tokens') : '';
899
+
900
+ let tokenInfo = '';
901
+ if (role === 'ai' && (inputTokens || outputTokens)) {
902
+ tokenInfo = `
903
+ <div style="margin-top: 16px; padding: 12px; background: #f8f9fa; border-radius: 6px;">
904
+ <div style="font-size: 14px; font-weight: 500; margin-bottom: 8px; color: #5f6368;">토큰 사용량</div>
905
+ <div style="display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px;">
906
+ <div>
907
+ <div style="font-size: 12px; color: #5f6368; margin-bottom: 4px;">모델</div>
908
+ <div style="font-size: 16px; font-weight: 500;">${modelName}</div>
909
+ </div>
910
+ <div>
911
+ <div style="font-size: 12px; color: #5f6368; margin-bottom: 4px;">입력 토큰</div>
912
+ <div style="font-size: 16px; font-weight: 500;">${inputTokens ? parseInt(inputTokens).toLocaleString() : '-'}</div>
913
+ </div>
914
+ <div>
915
+ <div style="font-size: 12px; color: #5f6368; margin-bottom: 4px;">출력 토큰</div>
916
+ <div style="font-size: 16px; font-weight: 500;">${outputTokens ? parseInt(outputTokens).toLocaleString() : '-'}</div>
917
+ </div>
918
+ </div>
919
+ </div>
920
+ `;
921
+ }
922
+
923
  modalContent.innerHTML = `
924
  <div class="message-view">
925
  <div class="message-item ${role}">
 
928
  <span class="message-item-time">메시지 ID: ${messageId}</span>
929
  </div>
930
  <div class="message-item-content">${content}</div>
931
+ ${tokenInfo}
932
  </div>
933
  </div>
934
  `;
 
1000
  window.addEventListener('load', () => {
1001
  loadUsers();
1002
  loadSessions();
1003
+ // 세션 ID가 없어도 전체 메시지 로드
1004
+ loadMessages(1);
1005
  });
1006
  </script>
1007
  </body>
templates/admin_settings.html CHANGED
@@ -370,6 +370,43 @@
370
  </div>
371
  </div>
372
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
373
  <!-- AI 모델별 토큰 수 관리 섹션 -->
374
  <div class="card">
375
  <div class="card-header">
@@ -849,11 +886,158 @@
849
  return id;
850
  }
851
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
852
  // 페이지 로드 시 API 키 상�� 확인 및 토큰 수 설정 로드
853
  window.addEventListener('load', () => {
854
  loadGeminiApiKey();
855
  loadHuggingFaceToken();
856
  loadModelTokens();
 
857
  });
858
  </script>
859
  </body>
 
370
  </div>
371
  </div>
372
 
373
+ <!-- 기본 AI 모델 설정 섹션 -->
374
+ <div class="card">
375
+ <div class="card-header">
376
+ <div class="card-title">기본 AI 모델 설정</div>
377
+ <button class="btn btn-secondary" onclick="loadDefaultModels()">새로고침</button>
378
+ </div>
379
+ <div style="padding: 16px 0;">
380
+ <div id="defaultModelsStatus" style="margin-bottom: 16px; font-size: 13px;"></div>
381
+ <div style="display: grid; gap: 16px;">
382
+ <div class="form-group">
383
+ <label for="defaultAnalysisModel">기본 질문 분석용 AI 모델 (AI 모델 선택)</label>
384
+ <div style="display: flex; gap: 8px;">
385
+ <select id="defaultAnalysisModel" style="flex: 1; padding: 8px 12px; border: 1px solid #dadce0; border-radius: 4px; font-size: 14px;">
386
+ <option value="">기본값 없음</option>
387
+ </select>
388
+ <button class="btn btn-primary" onclick="saveDefaultModels()">저장</button>
389
+ </div>
390
+ <small style="color: #5f6368; font-size: 12px; display: block; margin-top: 4px;">
391
+ 사용자 화면의 "AI 모델 선택" 드롭다운에서 기본으로 선택될 모델입니다.
392
+ </small>
393
+ </div>
394
+ <div class="form-group">
395
+ <label for="defaultAnswerModel">기본 답변 생성용 AI 모델 (사용 가능한 AI 목록)</label>
396
+ <div style="display: flex; gap: 8px;">
397
+ <select id="defaultAnswerModel" style="flex: 1; padding: 8px 12px; border: 1px solid #dadce0; border-radius: 4px; font-size: 14px;">
398
+ <option value="">기본값 없음</option>
399
+ </select>
400
+ <button class="btn btn-primary" onclick="saveDefaultModels()">저장</button>
401
+ </div>
402
+ <small style="color: #5f6368; font-size: 12px; display: block; margin-top: 4px;">
403
+ 사용자 화면의 "사용 가능한 AI 목록" 드롭다운에서 기본으로 선택될 모델입니다.
404
+ </small>
405
+ </div>
406
+ </div>
407
+ </div>
408
+ </div>
409
+
410
  <!-- AI 모델별 토큰 수 관리 섹션 -->
411
  <div class="card">
412
  <div class="card-header">
 
886
  return id;
887
  }
888
 
889
+ // 기본 AI 모델 설정 관련 함수
890
+ async function loadDefaultModels() {
891
+ const statusDiv = document.getElementById('defaultModelsStatus');
892
+ const analysisSelect = document.getElementById('defaultAnalysisModel');
893
+ const answerSelect = document.getElementById('defaultAnswerModel');
894
+
895
+ try {
896
+ statusDiv.innerHTML = '<span style="color: #1a73e8;">기본 모델 설정을 불러오는 중...</span>';
897
+
898
+ // 모델 목록 가져오기
899
+ const modelsResponse = await fetch('/api/admin/ollama/models', {
900
+ credentials: 'include'
901
+ });
902
+
903
+ if (!modelsResponse.ok) {
904
+ throw new Error('모델 목록을 불러올 수 없습니다.');
905
+ }
906
+
907
+ const modelsData = await modelsResponse.json();
908
+
909
+ // 드롭다운 초기화
910
+ analysisSelect.innerHTML = '<option value="">기본값 없음</option>';
911
+ answerSelect.innerHTML = '<option value="">기본값 없음</option>';
912
+
913
+ // 모델을 타입별로 그룹화
914
+ const ollamaModels = [];
915
+ const geminiModels = [];
916
+
917
+ if (modelsData.models && modelsData.models.length > 0) {
918
+ modelsData.models.forEach(model => {
919
+ if (model.type === 'ollama') {
920
+ ollamaModels.push(model);
921
+ } else if (model.type === 'gemini') {
922
+ geminiModels.push(model);
923
+ }
924
+ });
925
+ }
926
+
927
+ // Ollama 모델 추가
928
+ if (ollamaModels.length > 0) {
929
+ const optgroup1 = document.createElement('optgroup');
930
+ optgroup1.label = 'Ollama 모델';
931
+ ollamaModels.forEach(model => {
932
+ const option = document.createElement('option');
933
+ option.value = model.name;
934
+ option.textContent = model.name;
935
+ optgroup1.appendChild(option);
936
+ });
937
+ analysisSelect.appendChild(optgroup1);
938
+
939
+ const optgroup2 = document.createElement('optgroup');
940
+ optgroup2.label = 'Ollama 모델';
941
+ ollamaModels.forEach(model => {
942
+ const option = document.createElement('option');
943
+ option.value = model.name;
944
+ option.textContent = model.name;
945
+ optgroup2.appendChild(option);
946
+ });
947
+ answerSelect.appendChild(optgroup2);
948
+ }
949
+
950
+ // Gemini 모델 추가
951
+ if (geminiModels.length > 0) {
952
+ const optgroup1 = document.createElement('optgroup');
953
+ optgroup1.label = 'Gemini 모델';
954
+ geminiModels.forEach(model => {
955
+ const option = document.createElement('option');
956
+ option.value = model.name;
957
+ option.textContent = model.name.replace('gemini:', '');
958
+ optgroup1.appendChild(option);
959
+ });
960
+ analysisSelect.appendChild(optgroup1);
961
+
962
+ const optgroup2 = document.createElement('optgroup');
963
+ optgroup2.label = 'Gemini 모델';
964
+ geminiModels.forEach(model => {
965
+ const option = document.createElement('option');
966
+ option.value = model.name;
967
+ option.textContent = model.name.replace('gemini:', '');
968
+ optgroup2.appendChild(option);
969
+ });
970
+ answerSelect.appendChild(optgroup2);
971
+ }
972
+
973
+ // 현재 설정된 기본 모델 가져오기
974
+ const defaultResponse = await fetch('/api/admin/default-models', {
975
+ credentials: 'include'
976
+ });
977
+
978
+ if (defaultResponse.ok) {
979
+ const defaultData = await defaultResponse.json();
980
+ if (defaultData.default_analysis_model) {
981
+ analysisSelect.value = defaultData.default_analysis_model;
982
+ }
983
+ if (defaultData.default_answer_model) {
984
+ answerSelect.value = defaultData.default_answer_model;
985
+ }
986
+ statusDiv.innerHTML = '<span style="color: #137333;">✓ 기본 모델 설정을 불러왔습니다.</span>';
987
+ } else {
988
+ statusDiv.innerHTML = '<span style="color: #ea4335;">기본 모델 설정을 불러올 수 없습니다.</span>';
989
+ }
990
+ } catch (error) {
991
+ console.error('기본 모델 설정 로드 오류:', error);
992
+ statusDiv.innerHTML = `<span style="color: #ea4335;">오류: ${error.message}</span>`;
993
+ }
994
+ }
995
+
996
+ async function saveDefaultModels() {
997
+ const analysisSelect = document.getElementById('defaultAnalysisModel');
998
+ const answerSelect = document.getElementById('defaultAnswerModel');
999
+ const statusDiv = document.getElementById('defaultModelsStatus');
1000
+
1001
+ const defaultAnalysisModel = analysisSelect.value;
1002
+ const defaultAnswerModel = answerSelect.value;
1003
+
1004
+ try {
1005
+ statusDiv.innerHTML = '<span style="color: #1a73e8;">저장 중...</span>';
1006
+
1007
+ const response = await fetch('/api/admin/default-models', {
1008
+ method: 'POST',
1009
+ headers: {
1010
+ 'Content-Type': 'application/json',
1011
+ },
1012
+ credentials: 'include',
1013
+ body: JSON.stringify({
1014
+ default_analysis_model: defaultAnalysisModel,
1015
+ default_answer_model: defaultAnswerModel
1016
+ })
1017
+ });
1018
+
1019
+ const data = await response.json();
1020
+
1021
+ if (response.ok) {
1022
+ showAlert(data.message || '기본 AI 모델이 저장되었습니다.', 'success');
1023
+ statusDiv.innerHTML = '<span style="color: #137333;">✓ 기본 모델 설정이 저장되었습니다.</span>';
1024
+ } else {
1025
+ showAlert(data.error || '기본 모델 설정 저장 중 오류가 발생했습니다.', 'error');
1026
+ statusDiv.innerHTML = `<span style="color: #ea4335;">오류: ${data.error || '알 수 없는 오류'}</span>`;
1027
+ }
1028
+ } catch (error) {
1029
+ console.error('기본 모델 설정 저장 오류:', error);
1030
+ showAlert(`오류: ${error.message}`, 'error');
1031
+ statusDiv.innerHTML = `<span style="color: #ea4335;">오류: ${error.message}</span>`;
1032
+ }
1033
+ }
1034
+
1035
  // 페이지 로드 시 API 키 상�� 확인 및 토큰 수 설정 로드
1036
  window.addEventListener('load', () => {
1037
  loadGeminiApiKey();
1038
  loadHuggingFaceToken();
1039
  loadModelTokens();
1040
+ loadDefaultModels();
1041
  });
1042
  </script>
1043
  </body>
templates/admin_tokens.html ADDED
@@ -0,0 +1,710 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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>토큰 사용량 통계 - SOY NV AI</title>
7
+ <link rel="preconnect" href="https://fonts.googleapis.com" crossorigin>
8
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap" rel="stylesheet">
9
+ <script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
10
+ <style>
11
+ * {
12
+ margin: 0;
13
+ padding: 0;
14
+ box-sizing: border-box;
15
+ }
16
+
17
+ body {
18
+ font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
19
+ background: #f8f9fa;
20
+ color: #202124;
21
+ }
22
+
23
+ .header {
24
+ background: white;
25
+ border-bottom: 1px solid #dadce0;
26
+ padding: 16px 24px;
27
+ display: flex;
28
+ align-items: center;
29
+ justify-content: space-between;
30
+ box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
31
+ }
32
+
33
+ .header-title {
34
+ font-size: 20px;
35
+ font-weight: 500;
36
+ display: flex;
37
+ align-items: center;
38
+ gap: 12px;
39
+ }
40
+
41
+ .header-actions {
42
+ display: flex;
43
+ gap: 12px;
44
+ align-items: center;
45
+ }
46
+
47
+ .menu-toggle {
48
+ display: none;
49
+ background: none;
50
+ border: none;
51
+ font-size: 24px;
52
+ cursor: pointer;
53
+ padding: 8px;
54
+ color: #202124;
55
+ }
56
+
57
+ .mobile-menu {
58
+ display: none;
59
+ position: fixed;
60
+ top: 0;
61
+ left: 0;
62
+ right: 0;
63
+ bottom: 0;
64
+ background: rgba(0, 0, 0, 0.5);
65
+ z-index: 1000;
66
+ }
67
+
68
+ .mobile-menu.active {
69
+ display: block;
70
+ }
71
+
72
+ .mobile-menu-content {
73
+ position: fixed;
74
+ top: 0;
75
+ right: -100%;
76
+ width: 280px;
77
+ max-width: 80%;
78
+ height: 100%;
79
+ background: white;
80
+ box-shadow: -2px 0 8px rgba(0, 0, 0, 0.1);
81
+ transition: right 0.3s ease;
82
+ overflow-y: auto;
83
+ z-index: 1001;
84
+ }
85
+
86
+ .mobile-menu.active .mobile-menu-content {
87
+ right: 0;
88
+ }
89
+
90
+ .mobile-menu-header {
91
+ padding: 16px 20px;
92
+ border-bottom: 1px solid #dadce0;
93
+ display: flex;
94
+ justify-content: space-between;
95
+ align-items: center;
96
+ background: white;
97
+ position: sticky;
98
+ top: 0;
99
+ z-index: 10;
100
+ }
101
+
102
+ .mobile-menu-title {
103
+ font-size: 18px;
104
+ font-weight: 500;
105
+ }
106
+
107
+ .mobile-menu-close {
108
+ background: none;
109
+ border: none;
110
+ font-size: 28px;
111
+ cursor: pointer;
112
+ color: #202124;
113
+ padding: 0;
114
+ width: 32px;
115
+ height: 32px;
116
+ display: flex;
117
+ align-items: center;
118
+ justify-content: center;
119
+ }
120
+
121
+ .mobile-menu-user {
122
+ padding: 12px 20px;
123
+ background: #f8f9fa;
124
+ border-bottom: 1px solid #dadce0;
125
+ font-size: 14px;
126
+ color: #5f6368;
127
+ }
128
+
129
+ .mobile-menu-items {
130
+ padding: 8px 0;
131
+ }
132
+
133
+ .mobile-menu-item {
134
+ display: block;
135
+ padding: 12px 20px;
136
+ color: #202124;
137
+ text-decoration: none;
138
+ font-size: 14px;
139
+ transition: background 0.2s;
140
+ }
141
+
142
+ .mobile-menu-item:hover {
143
+ background: #f8f9fa;
144
+ }
145
+
146
+ .btn {
147
+ padding: 8px 16px;
148
+ border: none;
149
+ border-radius: 6px;
150
+ font-size: 14px;
151
+ font-weight: 500;
152
+ cursor: pointer;
153
+ text-decoration: none;
154
+ display: inline-block;
155
+ transition: all 0.2s;
156
+ }
157
+
158
+ .btn-primary {
159
+ background: #1a73e8;
160
+ color: white;
161
+ }
162
+
163
+ .btn-primary:hover {
164
+ background: #1557b0;
165
+ }
166
+
167
+ .btn-secondary {
168
+ background: #f1f3f4;
169
+ color: #202124;
170
+ }
171
+
172
+ .btn-secondary:hover {
173
+ background: #e8eaed;
174
+ }
175
+
176
+ .container {
177
+ max-width: 1400px;
178
+ margin: 0 auto;
179
+ padding: 24px;
180
+ }
181
+
182
+ .page-header {
183
+ margin-bottom: 24px;
184
+ }
185
+
186
+ .page-header h1 {
187
+ font-size: 28px;
188
+ font-weight: 600;
189
+ margin-bottom: 8px;
190
+ }
191
+
192
+ .page-header p {
193
+ color: #5f6368;
194
+ }
195
+
196
+ .card {
197
+ background: white;
198
+ border-radius: 8px;
199
+ box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
200
+ padding: 24px;
201
+ margin-bottom: 24px;
202
+ }
203
+
204
+ .card-header {
205
+ display: flex;
206
+ justify-content: space-between;
207
+ align-items: center;
208
+ margin-bottom: 20px;
209
+ }
210
+
211
+ .card-title {
212
+ font-size: 18px;
213
+ font-weight: 500;
214
+ }
215
+
216
+ .filters {
217
+ display: flex;
218
+ gap: 12px;
219
+ flex-wrap: wrap;
220
+ margin-bottom: 20px;
221
+ }
222
+
223
+ .filter-group {
224
+ display: flex;
225
+ flex-direction: column;
226
+ gap: 4px;
227
+ }
228
+
229
+ .filter-group label {
230
+ font-size: 12px;
231
+ color: #5f6368;
232
+ font-weight: 500;
233
+ }
234
+
235
+ .filter-group input,
236
+ .filter-group select {
237
+ padding: 8px 12px;
238
+ border: 1px solid #dadce0;
239
+ border-radius: 6px;
240
+ font-size: 14px;
241
+ font-family: inherit;
242
+ }
243
+
244
+ .filter-group input:focus,
245
+ .filter-group select:focus {
246
+ outline: none;
247
+ border-color: #1a73e8;
248
+ box-shadow: 0 0 0 3px rgba(26, 115, 232, 0.1);
249
+ }
250
+
251
+ .stats-grid {
252
+ display: grid;
253
+ grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
254
+ gap: 16px;
255
+ margin-bottom: 24px;
256
+ }
257
+
258
+ .stat-card {
259
+ background: white;
260
+ border-radius: 8px;
261
+ padding: 20px;
262
+ box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
263
+ }
264
+
265
+ .stat-label {
266
+ font-size: 14px;
267
+ color: #5f6368;
268
+ margin-bottom: 8px;
269
+ }
270
+
271
+ .stat-value {
272
+ font-size: 24px;
273
+ font-weight: 600;
274
+ color: #202124;
275
+ }
276
+
277
+ .chart-container {
278
+ position: relative;
279
+ height: 400px;
280
+ margin-bottom: 24px;
281
+ }
282
+
283
+ .alert {
284
+ padding: 12px 16px;
285
+ border-radius: 6px;
286
+ margin-bottom: 16px;
287
+ font-size: 14px;
288
+ }
289
+
290
+ .alert.error {
291
+ background: #fce8e6;
292
+ color: #c5221f;
293
+ }
294
+
295
+ .alert.success {
296
+ background: #e8f5e9;
297
+ color: #137333;
298
+ }
299
+
300
+ @media (max-width: 768px) {
301
+ .header {
302
+ padding: 12px 16px;
303
+ }
304
+
305
+ .header-title {
306
+ font-size: 18px;
307
+ }
308
+
309
+ .menu-toggle {
310
+ display: block;
311
+ }
312
+
313
+ .header-actions {
314
+ display: none;
315
+ }
316
+
317
+ .container {
318
+ padding: 16px;
319
+ }
320
+
321
+ .filters {
322
+ flex-direction: column;
323
+ }
324
+
325
+ .filter-group {
326
+ width: 100%;
327
+ }
328
+
329
+ .chart-container {
330
+ height: 300px;
331
+ }
332
+ }
333
+ </style>
334
+ </head>
335
+ <body>
336
+ <div class="header">
337
+ <div class="header-title">
338
+ <span>📊</span>
339
+ <span>토큰 사용량 통계</span>
340
+ </div>
341
+ <button class="menu-toggle" onclick="toggleMobileMenu()" aria-label="메뉴 열기">☰</button>
342
+ <div class="header-actions">
343
+ <span style="margin-right: 12px; color: #5f6368;">{{ current_user.nickname or current_user.username }}</span>
344
+ <a href="{{ url_for('main.admin') }}" class="btn btn-secondary">사용자 관리</a>
345
+ <a href="{{ url_for('main.admin_webnovels') }}" class="btn btn-secondary">웹소설 관리</a>
346
+ <a href="{{ url_for('main.admin_files') }}" class="btn btn-secondary">파일 목록</a>
347
+ <a href="{{ url_for('main.admin_messages') }}" class="btn btn-secondary">메시지 확인</a>
348
+ <a href="{{ url_for('main.admin_prompts') }}" class="btn btn-secondary">프롬프트 관리</a>
349
+ <a href="{{ url_for('main.admin_settings') }}" class="btn btn-secondary">AI 설정</a>
350
+ <a href="{{ url_for('main.admin_utils') }}" class="btn btn-secondary">유틸</a>
351
+ <a href="{{ url_for('main.index') }}" class="btn btn-secondary">메인으로</a>
352
+ <a href="{{ url_for('main.logout') }}" class="btn btn-secondary">로그아웃</a>
353
+ </div>
354
+ </div>
355
+
356
+ <!-- 모바일 메뉴 -->
357
+ <div class="mobile-menu" id="mobileMenu" onclick="closeMobileMenuOnBackdrop(event)">
358
+ <div class="mobile-menu-content" onclick="event.stopPropagation()">
359
+ <div class="mobile-menu-header">
360
+ <div class="mobile-menu-title">메뉴</div>
361
+ <button class="mobile-menu-close" onclick="toggleMobileMenu()" aria-label="메뉴 닫기">&times;</button>
362
+ </div>
363
+ <div class="mobile-menu-user">{{ current_user.nickname or current_user.username }}</div>
364
+ <div class="mobile-menu-items">
365
+ <a href="{{ url_for('main.admin') }}" class="mobile-menu-item" onclick="closeMobileMenu()">사용자 관리</a>
366
+ <a href="{{ url_for('main.admin_webnovels') }}" class="mobile-menu-item" onclick="closeMobileMenu()">웹소설 관리</a>
367
+ <a href="{{ url_for('main.admin_files') }}" class="mobile-menu-item" onclick="closeMobileMenu()">파일 목록</a>
368
+ <a href="{{ url_for('main.admin_messages') }}" class="mobile-menu-item" onclick="closeMobileMenu()">메시지 확인</a>
369
+ <a href="{{ url_for('main.admin_prompts') }}" class="mobile-menu-item" onclick="closeMobileMenu()">프롬프트 관리</a>
370
+ <a href="{{ url_for('main.admin_settings') }}" class="mobile-menu-item" onclick="closeMobileMenu()">AI 설정</a>
371
+ <a href="{{ url_for('main.admin_utils') }}" class="mobile-menu-item" onclick="closeMobileMenu()">유틸</a>
372
+ <a href="{{ url_for('main.admin_tokens') }}" class="mobile-menu-item" onclick="closeMobileMenu()">토큰 통계</a>
373
+ <a href="{{ url_for('main.index') }}" class="mobile-menu-item" onclick="closeMobileMenu()">메인으로</a>
374
+ <a href="{{ url_for('main.logout') }}" class="mobile-menu-item" onclick="closeMobileMenu()">로그아웃</a>
375
+ </div>
376
+ </div>
377
+ </div>
378
+
379
+ <div class="container">
380
+ <div class="page-header">
381
+ <h1>토큰 사용량 통계</h1>
382
+ <p>AI 모델별 토큰 사용량을 시각화하여 확인할 수 있습니다.</p>
383
+ </div>
384
+
385
+ <div id="alertContainer"></div>
386
+
387
+ <!-- 필터 -->
388
+ <div class="card">
389
+ <div class="card-header">
390
+ <div class="card-title">필터</div>
391
+ </div>
392
+ <div class="filters">
393
+ <div class="filter-group">
394
+ <label for="startDate">시작 날짜</label>
395
+ <input type="date" id="startDate">
396
+ </div>
397
+ <div class="filter-group">
398
+ <label for="endDate">종료 날짜</label>
399
+ <input type="date" id="endDate">
400
+ </div>
401
+ <div class="filter-group">
402
+ <label for="modelFilter">AI 모델</label>
403
+ <select id="modelFilter">
404
+ <option value="">전체</option>
405
+ </select>
406
+ </div>
407
+ <div class="filter-group">
408
+ <label for="groupBy">그룹화</label>
409
+ <select id="groupBy">
410
+ <option value="day">일별</option>
411
+ <option value="model">모델별</option>
412
+ </select>
413
+ </div>
414
+ <div class="filter-group" style="justify-content: flex-end;">
415
+ <label>&nbsp;</label>
416
+ <button class="btn btn-primary" onclick="loadTokenUsage()">조회</button>
417
+ </div>
418
+ </div>
419
+ </div>
420
+
421
+ <!-- 통계 요약 -->
422
+ <div class="stats-grid" id="statsGrid">
423
+ <!-- 동적으로 생성됨 -->
424
+ </div>
425
+
426
+ <!-- 그래프 -->
427
+ <div class="card">
428
+ <div class="card-header">
429
+ <div class="card-title">토큰 사용량 그래프</div>
430
+ </div>
431
+ <div class="chart-container">
432
+ <canvas id="tokenChart"></canvas>
433
+ </div>
434
+ </div>
435
+ </div>
436
+
437
+ <script>
438
+ let tokenChart = null;
439
+
440
+ function toggleMobileMenu() {
441
+ const menu = document.getElementById('mobileMenu');
442
+ menu.classList.toggle('active');
443
+ document.body.style.overflow = menu.classList.contains('active') ? 'hidden' : '';
444
+ }
445
+
446
+ function closeMobileMenu() {
447
+ const menu = document.getElementById('mobileMenu');
448
+ menu.classList.remove('active');
449
+ document.body.style.overflow = '';
450
+ }
451
+
452
+ function closeMobileMenuOnBackdrop(event) {
453
+ if (event.target.id === 'mobileMenu') {
454
+ closeMobileMenu();
455
+ }
456
+ }
457
+
458
+ function showAlert(message, type = 'success') {
459
+ const container = document.getElementById('alertContainer');
460
+ container.innerHTML = `<div class="alert ${type}">${message}</div>`;
461
+ setTimeout(() => {
462
+ container.innerHTML = '';
463
+ }, 5000);
464
+ }
465
+
466
+ // 날짜 기본값 설정 (최근 30일)
467
+ function setDefaultDates() {
468
+ const endDate = new Date();
469
+ const startDate = new Date();
470
+ startDate.setDate(startDate.getDate() - 30);
471
+
472
+ document.getElementById('endDate').value = endDate.toISOString().split('T')[0];
473
+ document.getElementById('startDate').value = startDate.toISOString().split('T')[0];
474
+ }
475
+
476
+ // 토큰 사용량 조회
477
+ async function loadTokenUsage() {
478
+ try {
479
+ const startDate = document.getElementById('startDate').value;
480
+ const endDate = document.getElementById('endDate').value;
481
+ const modelName = document.getElementById('modelFilter').value;
482
+ const groupBy = document.getElementById('groupBy').value;
483
+
484
+ if (!startDate || !endDate) {
485
+ showAlert('시작 날짜와 종료 날짜를 모두 선택해주세요.', 'error');
486
+ return;
487
+ }
488
+
489
+ const params = new URLSearchParams({
490
+ start_date: startDate,
491
+ end_date: endDate,
492
+ group_by: groupBy
493
+ });
494
+
495
+ if (modelName) {
496
+ params.append('model_name', modelName);
497
+ }
498
+
499
+ console.log('[토큰 통계] 요청 URL:', `/api/admin/token-usage?${params}`);
500
+ const response = await fetch(`/api/admin/token-usage?${params}`, {
501
+ method: 'GET',
502
+ credentials: 'include'
503
+ });
504
+
505
+ const data = await response.json();
506
+ console.log('[토큰 통계] 응답 데이터:', data);
507
+
508
+ if (response.ok && data.success) {
509
+ if (data.stats && data.stats.length > 0) {
510
+ updateStats(data.stats, data.total_messages, data.user_usage, data.system_usage);
511
+ updateChart(data.stats, groupBy);
512
+ } else {
513
+ showAlert('선택한 기간에 토큰 정보가 있는 메시지가 없습니다.', 'error');
514
+ // 빈 통계 표시
515
+ updateStats([], 0, null, null);
516
+ updateChart([], groupBy);
517
+ }
518
+ } else {
519
+ console.error('[토큰 통계] API 오류:', data);
520
+ showAlert(data.error || '토큰 사용량 조회 중 오류가 발생했습니다.', 'error');
521
+ }
522
+ } catch (error) {
523
+ console.error('토큰 사용량 조회 오류:', error);
524
+ showAlert(`오류: ${error.message}`, 'error');
525
+ }
526
+ }
527
+
528
+ // 통계 요약 업데이트
529
+ function updateStats(stats, totalMessages, userUsage, systemUsage) {
530
+ const statsGrid = document.getElementById('statsGrid');
531
+
532
+ let totalInput = 0;
533
+ let totalOutput = 0;
534
+ let totalTokens = 0;
535
+
536
+ if (stats && stats.length > 0) {
537
+ stats.forEach(stat => {
538
+ totalInput += stat.input_tokens || 0;
539
+ totalOutput += stat.output_tokens || 0;
540
+ totalTokens += stat.total_tokens || 0;
541
+ });
542
+ }
543
+
544
+ // 시스템 사용 통계
545
+ let systemInput = 0;
546
+ let systemOutput = 0;
547
+ let systemTotal = 0;
548
+ if (systemUsage) {
549
+ systemInput = systemUsage.input_tokens || 0;
550
+ systemOutput = systemUsage.output_tokens || 0;
551
+ systemTotal = systemUsage.total_tokens || 0;
552
+ }
553
+
554
+ statsGrid.innerHTML = `
555
+ <div class="stat-card">
556
+ <div class="stat-label">총 입력 토큰</div>
557
+ <div class="stat-value">${totalInput.toLocaleString()}</div>
558
+ </div>
559
+ <div class="stat-card">
560
+ <div class="stat-label">총 출력 토큰</div>
561
+ <div class="stat-value">${totalOutput.toLocaleString()}</div>
562
+ </div>
563
+ <div class="stat-card">
564
+ <div class="stat-label">총 토큰</div>
565
+ <div class="stat-value">${totalTokens.toLocaleString()}</div>
566
+ </div>
567
+ <div class="stat-card">
568
+ <div class="stat-label">총 메시지 수</div>
569
+ <div class="stat-value">${totalMessages.toLocaleString()}</div>
570
+ </div>
571
+ <div class="stat-card" style="border-left: 4px solid #34a853;">
572
+ <div class="stat-label">시스템 사용 (입력)</div>
573
+ <div class="stat-value">${systemInput.toLocaleString()}</div>
574
+ </div>
575
+ <div class="stat-card" style="border-left: 4px solid #34a853;">
576
+ <div class="stat-label">시스템 사용 (출력)</div>
577
+ <div class="stat-value">${systemOutput.toLocaleString()}</div>
578
+ </div>
579
+ <div class="stat-card" style="border-left: 4px solid #34a853;">
580
+ <div class="stat-label">시스템 사용 (총)</div>
581
+ <div class="stat-value">${systemTotal.toLocaleString()}</div>
582
+ </div>
583
+ `;
584
+ }
585
+
586
+ // 그래프 업데이트
587
+ function updateChart(stats, groupBy) {
588
+ const ctx = document.getElementById('tokenChart').getContext('2d');
589
+
590
+ if (tokenChart) {
591
+ tokenChart.destroy();
592
+ }
593
+
594
+ let labels, inputData, outputData;
595
+
596
+ if (!stats || stats.length === 0) {
597
+ // 데이터가 없을 때 빈 그래프 표시
598
+ labels = ['데이터 없음'];
599
+ inputData = [0];
600
+ outputData = [0];
601
+ } else if (groupBy === 'day') {
602
+ labels = stats.map(s => s.date);
603
+ inputData = stats.map(s => s.input_tokens || 0);
604
+ outputData = stats.map(s => s.output_tokens || 0);
605
+ } else if (groupBy === 'model') {
606
+ labels = stats.map(s => s.model || 'Unknown');
607
+ inputData = stats.map(s => s.input_tokens || 0);
608
+ outputData = stats.map(s => s.output_tokens || 0);
609
+ } else {
610
+ labels = ['전체'];
611
+ inputData = [stats[0]?.input_tokens || 0];
612
+ outputData = [stats[0]?.output_tokens || 0];
613
+ }
614
+
615
+ tokenChart = new Chart(ctx, {
616
+ type: 'line',
617
+ data: {
618
+ labels: labels,
619
+ datasets: [
620
+ {
621
+ label: '입력 토큰',
622
+ data: inputData,
623
+ borderColor: 'rgb(26, 115, 232)',
624
+ backgroundColor: 'rgba(26, 115, 232, 0.1)',
625
+ tension: 0.4
626
+ },
627
+ {
628
+ label: '출력 토큰',
629
+ data: outputData,
630
+ borderColor: 'rgb(234, 67, 53)',
631
+ backgroundColor: 'rgba(234, 67, 53, 0.1)',
632
+ tension: 0.4
633
+ }
634
+ ]
635
+ },
636
+ options: {
637
+ responsive: true,
638
+ maintainAspectRatio: false,
639
+ plugins: {
640
+ legend: {
641
+ position: 'top',
642
+ },
643
+ title: {
644
+ display: true,
645
+ text: '토큰 사용량 추이'
646
+ }
647
+ },
648
+ scales: {
649
+ y: {
650
+ beginAtZero: true,
651
+ ticks: {
652
+ callback: function(value) {
653
+ return value.toLocaleString();
654
+ }
655
+ }
656
+ }
657
+ }
658
+ }
659
+ });
660
+ }
661
+
662
+ // 모델 목록 로드
663
+ async function loadModels() {
664
+ try {
665
+ // 날짜 범위를 넓게 설정하여 모든 모델 조회
666
+ const endDate = new Date();
667
+ const startDate = new Date();
668
+ startDate.setFullYear(startDate.getFullYear() - 1); // 1년 전부터
669
+
670
+ const params = new URLSearchParams({
671
+ start_date: startDate.toISOString().split('T')[0],
672
+ end_date: endDate.toISOString().split('T')[0],
673
+ group_by: 'model'
674
+ });
675
+
676
+ const response = await fetch(`/api/admin/token-usage?${params}`, {
677
+ method: 'GET',
678
+ credentials: 'include'
679
+ });
680
+
681
+ const data = await response.json();
682
+
683
+ if (response.ok && data.success && data.models) {
684
+ const modelFilter = document.getElementById('modelFilter');
685
+ // 기존 옵션 유지 (전체 옵션)
686
+ const existingOptions = Array.from(modelFilter.options).map(opt => opt.value);
687
+ data.models.forEach(model => {
688
+ if (!existingOptions.includes(model)) {
689
+ const option = document.createElement('option');
690
+ option.value = model;
691
+ option.textContent = model;
692
+ modelFilter.appendChild(option);
693
+ }
694
+ });
695
+ }
696
+ } catch (error) {
697
+ console.error('모델 목록 로드 오류:', error);
698
+ }
699
+ }
700
+
701
+ // 페이지 로드 시 초기화
702
+ document.addEventListener('DOMContentLoaded', function() {
703
+ setDefaultDates();
704
+ loadModels();
705
+ loadTokenUsage();
706
+ });
707
+ </script>
708
+ </body>
709
+ </html>
710
+
templates/admin_utils.html CHANGED
@@ -417,7 +417,7 @@
417
  </div>
418
  <div style="padding: 16px 0;">
419
  <p style="margin-bottom: 16px; color: #5f6368; font-size: 14px;">
420
- 다양한 회차 구분 방식(@n, @n화 등)을 #n화 형식으로 변환합니다. 파일을 업로드하거나 직접 내용을 입력할 수 있습니다.
421
  </p>
422
 
423
  <div class="form-group">
@@ -428,9 +428,28 @@
428
  </small>
429
  </div>
430
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
431
  <div class="form-group">
432
  <label for="episodeContentInput">또는 직접 내용 입력</label>
433
- <textarea id="episodeContentInput" placeholder="@1&#10;@2&#10;@3&#10;... 형식의 내용을 입력하세요"></textarea>
434
  </div>
435
 
436
  <div style="display: flex; gap: 8px; margin-top: 16px;">
@@ -480,22 +499,90 @@
480
  }, 5000);
481
  }
482
 
 
 
 
483
  // 파일 업로드 처리
484
- function handleFileUpload(event) {
485
  const file = event.target.files[0];
486
  if (!file) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
487
  return;
488
  }
489
 
490
- const reader = new FileReader();
491
- reader.onload = function(e) {
492
- document.getElementById('episodeContentInput').value = e.target.result;
493
- showAlert('파일이 로드되었습니다.', 'success');
494
- };
495
- reader.onerror = function() {
496
- showAlert('파일 읽기 오류가 발생했습니다.', 'error');
497
- };
498
- reader.readAsText(file, 'UTF-8');
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
499
  }
500
 
501
  // 다운로드 URL 저장
@@ -518,6 +605,9 @@
518
 
519
  if (file) {
520
  formData.append('file', file);
 
 
 
521
  } else if (content) {
522
  // JSON으로 전송
523
  const response = await fetch('/api/admin/utils/convert-episode-format', {
@@ -612,6 +702,9 @@
612
  if (file) {
613
  const formData = new FormData();
614
  formData.append('file', file);
 
 
 
615
  response = await fetch('/api/admin/utils/convert-episode-format', {
616
  method: 'POST',
617
  credentials: 'include',
@@ -656,6 +749,10 @@
656
  document.getElementById('episodePreview').classList.add('empty');
657
  document.getElementById('downloadBtn').style.display = 'none';
658
  document.getElementById('conversionInfo').style.display = 'none';
 
 
 
 
659
  downloadUrl = null;
660
  downloadFilename = null;
661
  }
 
417
  </div>
418
  <div style="padding: 16px 0;">
419
  <p style="margin-bottom: 16px; color: #5f6368; font-size: 14px;">
420
+ 다양한 회차 구분 방식(@n, @n화, n화 등)을 #n화 형식으로 변환합니다. 파일을 업로드하거나 직접 내용을 입력할 수 있습니다.
421
  </p>
422
 
423
  <div class="form-group">
 
428
  </small>
429
  </div>
430
 
431
+ <div class="form-group" id="encodingGroup" style="display: none;">
432
+ <label for="fileEncoding">파일 인코딩</label>
433
+ <div style="display: flex; gap: 8px; align-items: center;">
434
+ <select id="fileEncoding" style="flex: 1; padding: 8px 12px; border: 1px solid #dadce0; border-radius: 6px; font-size: 14px;">
435
+ <option value="utf-8">UTF-8</option>
436
+ <option value="cp949">CP949 (EUC-KR)</option>
437
+ <option value="euc-kr">EUC-KR</option>
438
+ <option value="latin-1">Latin-1 (ISO-8859-1)</option>
439
+ <option value="utf-16">UTF-16</option>
440
+ <option value="utf-16-le">UTF-16 LE</option>
441
+ <option value="utf-16-be">UTF-16 BE</option>
442
+ </select>
443
+ <button class="btn btn-secondary" onclick="reloadFileWithEncoding()" style="padding: 8px 16px;">인코딩 적용</button>
444
+ </div>
445
+ <div id="encodingInfo" style="margin-top: 8px; padding: 8px; background: #f8f9fa; border-radius: 4px; font-size: 12px; color: #5f6368; display: none;">
446
+ <span id="encodingStatus"></span>
447
+ </div>
448
+ </div>
449
+
450
  <div class="form-group">
451
  <label for="episodeContentInput">또는 직접 내용 입력</label>
452
+ <textarea id="episodeContentInput" placeholder="@1&#10;@2화&#10;3화&#10;... 형식의 내용을 입력하세요"></textarea>
453
  </div>
454
 
455
  <div style="display: flex; gap: 8px; margin-top: 16px;">
 
499
  }, 5000);
500
  }
501
 
502
+ // 현재 선택된 파일 저장
503
+ let currentFile = null;
504
+
505
  // 파일 업로드 처리
506
+ async function handleFileUpload(event) {
507
  const file = event.target.files[0];
508
  if (!file) {
509
+ currentFile = null;
510
+ document.getElementById('encodingGroup').style.display = 'none';
511
+ return;
512
+ }
513
+
514
+ currentFile = file;
515
+
516
+ // 인코딩 감지 API 호출
517
+ try {
518
+ const formData = new FormData();
519
+ formData.append('file', file);
520
+
521
+ const response = await fetch('/api/admin/utils/detect-encoding', {
522
+ method: 'POST',
523
+ credentials: 'include',
524
+ body: formData
525
+ });
526
+
527
+ const data = await response.json();
528
+
529
+ if (response.ok && data.detected_encoding) {
530
+ // 인코딩 정보 표시
531
+ document.getElementById('fileEncoding').value = data.detected_encoding;
532
+ const encodingInfo = document.getElementById('encodingInfo');
533
+ const encodingStatus = document.getElementById('encodingStatus');
534
+ encodingStatus.innerHTML = `감지된 인코딩: <strong>${data.detected_encoding}</strong> (신뢰도: ${Math.round(data.confidence * 100)}%)`;
535
+ encodingInfo.style.display = 'block';
536
+ document.getElementById('encodingGroup').style.display = 'block';
537
+
538
+ // 감지된 인코딩으로 파일 읽기
539
+ await loadFileWithEncoding(data.detected_encoding);
540
+ } else {
541
+ // 인코딩 감지 실패 시 기본값(UTF-8)으로 시도
542
+ document.getElementById('fileEncoding').value = 'utf-8';
543
+ document.getElementById('encodingInfo').style.display = 'none';
544
+ document.getElementById('encodingGroup').style.display = 'block';
545
+ await loadFileWithEncoding('utf-8');
546
+ }
547
+ } catch (error) {
548
+ console.error('인코딩 감지 오류:', error);
549
+ // 오류 발생 시 기본값으로 시도
550
+ document.getElementById('fileEncoding').value = 'utf-8';
551
+ document.getElementById('encodingGroup').style.display = 'block';
552
+ await loadFileWithEncoding('utf-8');
553
+ }
554
+ }
555
+
556
+ // 지정된 인코딩으로 파일 읽기
557
+ async function loadFileWithEncoding(encoding) {
558
+ if (!currentFile) {
559
  return;
560
  }
561
 
562
+ try {
563
+ const reader = new FileReader();
564
+ reader.onload = function(e) {
565
+ document.getElementById('episodeContentInput').value = e.target.result;
566
+ showAlert(`파일이 ${encoding} 인코딩으로 로드되었습니다.`, 'success');
567
+ };
568
+ reader.onerror = function() {
569
+ showAlert('파일 읽기 중 오류가 발생했습니다.', 'error');
570
+ };
571
+ reader.readAsText(currentFile, encoding);
572
+ } catch (error) {
573
+ console.error('파일 읽기 오류:', error);
574
+ showAlert(`파일 읽기 중 오류가 발생했습니다: ${error.message}`, 'error');
575
+ }
576
+ }
577
+
578
+ // 인코딩 변경 후 파일 다시 읽기
579
+ function reloadFileWithEncoding() {
580
+ const encoding = document.getElementById('fileEncoding').value;
581
+ if (!currentFile) {
582
+ showAlert('파일을 먼저 업로드해주세요.', 'error');
583
+ return;
584
+ }
585
+ loadFileWithEncoding(encoding);
586
  }
587
 
588
  // 다운로드 URL 저장
 
605
 
606
  if (file) {
607
  formData.append('file', file);
608
+ // 선택된 인코딩 정보 추가
609
+ const encoding = document.getElementById('fileEncoding').value || 'utf-8';
610
+ formData.append('encoding', encoding);
611
  } else if (content) {
612
  // JSON으로 전송
613
  const response = await fetch('/api/admin/utils/convert-episode-format', {
 
702
  if (file) {
703
  const formData = new FormData();
704
  formData.append('file', file);
705
+ // 선택된 인코딩 정보 추가
706
+ const encoding = document.getElementById('fileEncoding').value || 'utf-8';
707
+ formData.append('encoding', encoding);
708
  response = await fetch('/api/admin/utils/convert-episode-format', {
709
  method: 'POST',
710
  credentials: 'include',
 
749
  document.getElementById('episodePreview').classList.add('empty');
750
  document.getElementById('downloadBtn').style.display = 'none';
751
  document.getElementById('conversionInfo').style.display = 'none';
752
+ document.getElementById('encodingGroup').style.display = 'none';
753
+ document.getElementById('encodingInfo').style.display = 'none';
754
+ document.getElementById('fileEncoding').value = 'utf-8';
755
+ currentFile = null;
756
  downloadUrl = null;
757
  downloadFilename = null;
758
  }
templates/index.html CHANGED
@@ -1794,6 +1794,24 @@
1794
 
1795
  availableModelsSelect.innerHTML = '<option value="">답변 생성할 AI 모델을 선택하세요...</option>';
1796
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1797
  if (data.models && data.models.length > 0) {
1798
  // 모델을 타입별로 그룹화
1799
  const ollamaModels = [];
@@ -1843,6 +1861,11 @@
1843
  availableModelsSelect.appendChild(optgroup);
1844
  }
1845
 
 
 
 
 
 
1846
  availableModelsSelect.disabled = false;
1847
  } else {
1848
  availableModelsSelect.innerHTML = '<option value="">등록된 AI 모델이 없습니다</option>';
@@ -1864,6 +1887,24 @@
1864
 
1865
  modelSelect.innerHTML = '<option value="">모델을 선택하세요...</option>';
1866
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1867
  if (data.models && data.models.length > 0) {
1868
  // 모델을 타입별로 그룹화
1869
  const ollamaModels = [];
@@ -1914,6 +1955,11 @@
1914
  modelSelect.appendChild(optgroup);
1915
  }
1916
 
 
 
 
 
 
1917
  updateModelStatus('connected');
1918
  } else {
1919
  updateModelStatus('error', '사용 가능한 모델이 없습니다');
 
1794
 
1795
  availableModelsSelect.innerHTML = '<option value="">답변 생성할 AI 모델을 선택하세요...</option>';
1796
 
1797
+ // 기본 모델 설정 가져오기 (localStorage에 선택된 모델이 없을 때만 사용)
1798
+ let defaultAnswerModel = null;
1799
+ if (!answerModel) {
1800
+ try {
1801
+ const defaultResponse = await fetch('/api/default-models');
1802
+ if (defaultResponse.ok) {
1803
+ const defaultData = await defaultResponse.json();
1804
+ defaultAnswerModel = defaultData.default_answer_model || null;
1805
+ if (defaultAnswerModel) {
1806
+ answerModel = defaultAnswerModel;
1807
+ localStorage.setItem('answerModel', answerModel);
1808
+ }
1809
+ }
1810
+ } catch (e) {
1811
+ console.log('기본 모델 설정 로드 실패:', e);
1812
+ }
1813
+ }
1814
+
1815
  if (data.models && data.models.length > 0) {
1816
  // 모델을 타입별로 그룹화
1817
  const ollamaModels = [];
 
1861
  availableModelsSelect.appendChild(optgroup);
1862
  }
1863
 
1864
+ // 기본 모델이 설정되어 있고 선택되었으면 모델 선택 이벤트 트리거
1865
+ if (defaultAnswerModel && availableModelsSelect.value === defaultAnswerModel) {
1866
+ availableModelsSelect.dispatchEvent(new Event('change'));
1867
+ }
1868
+
1869
  availableModelsSelect.disabled = false;
1870
  } else {
1871
  availableModelsSelect.innerHTML = '<option value="">등록된 AI 모델이 없습니다</option>';
 
1887
 
1888
  modelSelect.innerHTML = '<option value="">모델을 선택하세요...</option>';
1889
 
1890
+ // 기본 모델 설정 가져오기 (localStorage에 선택된 모델이 없을 때만 사용)
1891
+ let defaultAnalysisModel = null;
1892
+ if (!selectedModel) {
1893
+ try {
1894
+ const defaultResponse = await fetch('/api/default-models');
1895
+ if (defaultResponse.ok) {
1896
+ const defaultData = await defaultResponse.json();
1897
+ defaultAnalysisModel = defaultData.default_analysis_model || null;
1898
+ if (defaultAnalysisModel) {
1899
+ selectedModel = defaultAnalysisModel;
1900
+ localStorage.setItem('selectedModel', selectedModel);
1901
+ }
1902
+ }
1903
+ } catch (e) {
1904
+ console.log('기본 모델 설정 로드 실패:', e);
1905
+ }
1906
+ }
1907
+
1908
  if (data.models && data.models.length > 0) {
1909
  // 모델을 타입별로 그룹화
1910
  const ollamaModels = [];
 
1955
  modelSelect.appendChild(optgroup);
1956
  }
1957
 
1958
+ // 기본 모델이 설정되어 있고 선택되었으면 모델 선택 이벤트 트리거
1959
+ if (defaultAnalysisModel && modelSelect.value === defaultAnalysisModel) {
1960
+ modelSelect.dispatchEvent(new Event('change'));
1961
+ }
1962
+
1963
  updateModelStatus('connected');
1964
  } else {
1965
  updateModelStatus('error', '사용 가능한 모델이 없습니다');
upload_to_hf.ps1 CHANGED
@@ -117,3 +117,5 @@ try {
117
 
118
 
119
 
 
 
 
117
 
118
 
119
 
120
+
121
+