GitHub Actions commited on
Commit
c7e9f96
ยท
1 Parent(s): 26c053b

Auto-deploy from GitHub Actions - 2025-12-11 09:26:27

Browse files
Dockerfile CHANGED
@@ -1,34 +1,34 @@
1
- FROM python:3.11-slim
2
-
3
- WORKDIR /app
4
-
5
- # 1. ์‹œ์Šคํ…œ ํŒจํ‚ค์ง€ ์„ค์น˜
6
- RUN apt-get update && apt-get install -y \
7
- build-essential \
8
- git \
9
- curl \
10
- && rm -rf /var/lib/apt/lists/*
11
-
12
- # 2. Python ์˜์กด์„ฑ ์„ค์น˜
13
- COPY requirements.txt .
14
-
15
- # ๐Ÿ”ฅ [์šฉ๋Ÿ‰ ์ตœ์ ํ™”] ๋ฌด๊ฑฐ์šด PyTorch๋ฅผ CPU ์ „์šฉ์œผ๋กœ ๋จผ์ € ์„ค์น˜ํ•ฉ๋‹ˆ๋‹ค.
16
- RUN pip install --no-cache-dir torch --index-url https://download.pytorch.org/whl/cpu
17
-
18
- # ๋‚˜๋จธ์ง€ ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ ์„ค์น˜ (--no-cache-dir ์œ ์ง€)
19
- RUN pip install --no-cache-dir -r requirements.txt
20
-
21
- # 3. ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜ ์ฝ”๋“œ ๋ณต์‚ฌ
22
- COPY . .
23
-
24
- # ํ•„์š”ํ•œ ๋””๋ ‰ํ† ๋ฆฌ ์ƒ์„ฑ
25
- RUN mkdir -p instance uploads vector_db knowledge_graphs logs static templates
26
-
27
- # 4. ํฌํŠธ ์„ค์ • ๋ฐ ํ™˜๊ฒฝ ๋ณ€์ˆ˜
28
- EXPOSE 7860
29
- ENV PORT=7860
30
- ENV HOST=0.0.0.0
31
-
32
- # 5. ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜ ์‹คํ–‰ (app.py ์ง์ ‘ ์‹คํ–‰)
33
- CMD ["python", "app.py"]
34
-
 
1
+ FROM python:3.11-slim
2
+
3
+ WORKDIR /app
4
+
5
+ # 1. ์‹œ์Šคํ…œ ํŒจํ‚ค์ง€ ์„ค์น˜
6
+ RUN apt-get update && apt-get install -y \
7
+ build-essential \
8
+ git \
9
+ curl \
10
+ && rm -rf /var/lib/apt/lists/*
11
+
12
+ # 2. Python ์˜์กด์„ฑ ์„ค์น˜
13
+ COPY requirements.txt .
14
+
15
+ # ๐Ÿ”ฅ [์šฉ๋Ÿ‰ ์ตœ์ ํ™”] ๋ฌด๊ฑฐ์šด PyTorch๋ฅผ CPU ์ „์šฉ์œผ๋กœ ๋จผ์ € ์„ค์น˜ํ•ฉ๋‹ˆ๋‹ค.
16
+ RUN pip install --no-cache-dir torch --index-url https://download.pytorch.org/whl/cpu
17
+
18
+ # ๋‚˜๋จธ์ง€ ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ ์„ค์น˜ (--no-cache-dir ์œ ์ง€)
19
+ RUN pip install --no-cache-dir -r requirements.txt
20
+
21
+ # 3. ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜ ์ฝ”๋“œ ๋ณต์‚ฌ
22
+ COPY . .
23
+
24
+ # ํ•„์š”ํ•œ ๋””๋ ‰ํ† ๋ฆฌ ์ƒ์„ฑ
25
+ RUN mkdir -p instance uploads vector_db knowledge_graphs logs static templates
26
+
27
+ # 4. ํฌํŠธ ์„ค์ • ๋ฐ ํ™˜๊ฒฝ ๋ณ€์ˆ˜
28
+ EXPOSE 7860
29
+ ENV PORT=7860
30
+ ENV HOST=0.0.0.0
31
+
32
+ # 5. ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜ ์‹คํ–‰ (app.py ์ง์ ‘ ์‹คํ–‰)
33
+ CMD ["python", "app.py"]
34
+
README.md CHANGED
Binary files a/README.md and b/README.md differ
 
app.py CHANGED
@@ -1,79 +1,79 @@
1
- """
2
- Hugging Face Spaces์šฉ Flask ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜ ์ง„์ž…์ 
3
- """
4
- import sys
5
- import os
6
- import logging
7
- from logging.handlers import RotatingFileHandler
8
-
9
- # UTF-8 ์ธ์ฝ”๋”ฉ ๊ฐ•์ œ ์„ค์ •
10
- if sys.platform == 'win32':
11
- sys.stdout.reconfigure(encoding='utf-8')
12
- sys.stderr.reconfigure(encoding='utf-8')
13
-
14
- from app import create_app
15
-
16
- app = create_app()
17
-
18
- # Hugging Face Spaces ํ™˜๊ฒฝ ๋ณ€์ˆ˜ ์„ค์ •
19
- # Spaces๋Š” ์ž๋™์œผ๋กœ ํฌํŠธ๋ฅผ ํ• ๋‹นํ•˜๋ฏ€๋กœ ํ™˜๊ฒฝ ๋ณ€์ˆ˜์—์„œ ๊ฐ€์ ธ์˜ด
20
- port = int(os.environ.get('PORT', 7860))
21
- host = os.environ.get('HOST', '0.0.0.0')
22
-
23
- # ๋กœ๊น… ์„ค์ •
24
- if not os.path.exists('logs'):
25
- os.mkdir('logs')
26
-
27
- # ํŒŒ์ผ ํ•ธ๋“ค๋Ÿฌ ์„ค์ •
28
- file_handler = RotatingFileHandler('logs/server.log', maxBytes=10240000, backupCount=10)
29
- file_handler.setFormatter(logging.Formatter(
30
- '%(asctime)s %(levelname)s: %(message)s [in %(pathname)s:%(lineno)d]'
31
- ))
32
- file_handler.setLevel(logging.INFO)
33
-
34
- # ์ฝ˜์†” ํ•ธ๋“ค๋Ÿฌ ์„ค์ •
35
- console_handler = logging.StreamHandler(sys.stdout)
36
- console_handler.setFormatter(logging.Formatter(
37
- '%(asctime)s - %(name)s - %(levelname)s - %(message)s'
38
- ))
39
- console_handler.setLevel(logging.INFO)
40
-
41
- # Flask ์•ฑ ๋กœ๊ฑฐ ์„ค์ •
42
- app.logger.setLevel(logging.INFO)
43
- app.logger.addHandler(file_handler)
44
- app.logger.addHandler(console_handler)
45
-
46
- # ๋ฃจํŠธ ๋กœ๊ฑฐ ์„ค์ •
47
- root_logger = logging.getLogger()
48
- root_logger.setLevel(logging.INFO)
49
- root_logger.addHandler(console_handler)
50
-
51
- # Werkzeug ๋กœ๊ฑฐ ์„ค์ •
52
- werkzeug_logger = logging.getLogger('werkzeug')
53
- werkzeug_logger.setLevel(logging.INFO)
54
- werkzeug_logger.handlers.clear()
55
- werkzeug_handler = logging.StreamHandler(sys.stdout)
56
- werkzeug_handler.setFormatter(logging.Formatter(
57
- '%(asctime)s - %(levelname)s - %(message)s'
58
- ))
59
- werkzeug_logger.addHandler(werkzeug_handler)
60
-
61
- app.logger.info(f'์„œ๋ฒ„ ์‹œ์ž‘ - Host: {host}, Port: {port}')
62
-
63
- if __name__ == '__main__':
64
- try:
65
- print(f"[{__name__}] ์„œ๋ฒ„ ์‹œ์ž‘: http://{host}:{port}")
66
- print(f"[{__name__}] ๋กœ๊ทธ๋Š” ์ฝ˜์†”๊ณผ logs/server.log ํŒŒ์ผ์— ๊ธฐ๋ก๋ฉ๋‹ˆ๋‹ค.")
67
- app.run(host=host, port=port, debug=False, use_reloader=False)
68
- except Exception as e:
69
- print(f"์„œ๋ฒ„ ์‹œ์ž‘ ์˜ค๋ฅ˜: {e}")
70
- import traceback
71
- traceback.print_exc()
72
-
73
-
74
-
75
-
76
-
77
-
78
-
79
-
 
1
+ """
2
+ Hugging Face Spaces์šฉ Flask ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜ ์ง„์ž…์ 
3
+ """
4
+ import sys
5
+ import os
6
+ import logging
7
+ from logging.handlers import RotatingFileHandler
8
+
9
+ # UTF-8 ์ธ์ฝ”๋”ฉ ๊ฐ•์ œ ์„ค์ •
10
+ if sys.platform == 'win32':
11
+ sys.stdout.reconfigure(encoding='utf-8')
12
+ sys.stderr.reconfigure(encoding='utf-8')
13
+
14
+ from app import create_app
15
+
16
+ app = create_app()
17
+
18
+ # Hugging Face Spaces ํ™˜๊ฒฝ ๋ณ€์ˆ˜ ์„ค์ •
19
+ # Spaces๋Š” ์ž๋™์œผ๋กœ ํฌํŠธ๋ฅผ ํ• ๋‹นํ•˜๋ฏ€๋กœ ํ™˜๊ฒฝ ๋ณ€์ˆ˜์—์„œ ๊ฐ€์ ธ์˜ด
20
+ port = int(os.environ.get('PORT', 7860))
21
+ host = os.environ.get('HOST', '0.0.0.0')
22
+
23
+ # ๋กœ๊น… ์„ค์ •
24
+ if not os.path.exists('logs'):
25
+ os.mkdir('logs')
26
+
27
+ # ํŒŒ์ผ ํ•ธ๋“ค๋Ÿฌ ์„ค์ •
28
+ file_handler = RotatingFileHandler('logs/server.log', maxBytes=10240000, backupCount=10)
29
+ file_handler.setFormatter(logging.Formatter(
30
+ '%(asctime)s %(levelname)s: %(message)s [in %(pathname)s:%(lineno)d]'
31
+ ))
32
+ file_handler.setLevel(logging.INFO)
33
+
34
+ # ์ฝ˜์†” ํ•ธ๋“ค๋Ÿฌ ์„ค์ •
35
+ console_handler = logging.StreamHandler(sys.stdout)
36
+ console_handler.setFormatter(logging.Formatter(
37
+ '%(asctime)s - %(name)s - %(levelname)s - %(message)s'
38
+ ))
39
+ console_handler.setLevel(logging.INFO)
40
+
41
+ # Flask ์•ฑ ๋กœ๊ฑฐ ์„ค์ •
42
+ app.logger.setLevel(logging.INFO)
43
+ app.logger.addHandler(file_handler)
44
+ app.logger.addHandler(console_handler)
45
+
46
+ # ๋ฃจํŠธ ๋กœ๊ฑฐ ์„ค์ •
47
+ root_logger = logging.getLogger()
48
+ root_logger.setLevel(logging.INFO)
49
+ root_logger.addHandler(console_handler)
50
+
51
+ # Werkzeug ๋กœ๊ฑฐ ์„ค์ •
52
+ werkzeug_logger = logging.getLogger('werkzeug')
53
+ werkzeug_logger.setLevel(logging.INFO)
54
+ werkzeug_logger.handlers.clear()
55
+ werkzeug_handler = logging.StreamHandler(sys.stdout)
56
+ werkzeug_handler.setFormatter(logging.Formatter(
57
+ '%(asctime)s - %(levelname)s - %(message)s'
58
+ ))
59
+ werkzeug_logger.addHandler(werkzeug_handler)
60
+
61
+ app.logger.info(f'์„œ๋ฒ„ ์‹œ์ž‘ - Host: {host}, Port: {port}')
62
+
63
+ if __name__ == '__main__':
64
+ try:
65
+ print(f"[{__name__}] ์„œ๋ฒ„ ์‹œ์ž‘: http://{host}:{port}")
66
+ print(f"[{__name__}] ๋กœ๊ทธ๋Š” ์ฝ˜์†”๊ณผ logs/server.log ํŒŒ์ผ์— ๊ธฐ๋ก๋ฉ๋‹ˆ๋‹ค.")
67
+ app.run(host=host, port=port, debug=False, use_reloader=False)
68
+ except Exception as e:
69
+ print(f"์„œ๋ฒ„ ์‹œ์ž‘ ์˜ค๋ฅ˜: {e}")
70
+ import traceback
71
+ traceback.print_exc()
72
+
73
+
74
+
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
@@ -46,3 +46,7 @@ def reset_huggingface_token():
46
 
47
 
48
 
 
 
 
 
 
46
 
47
 
48
 
49
+
50
+
51
+
52
+
app/routes.py CHANGED
@@ -1,4 +1,4 @@
1
- from flask import Blueprint, render_template, request, jsonify, send_from_directory, redirect, url_for, flash
2
  from flask_login import login_user, logout_user, login_required, current_user
3
  from werkzeug.utils import secure_filename
4
  from app.database import db, UploadedFile, User, ChatSession, ChatMessage, DocumentChunk, ParentChunk, SystemConfig, EpisodeAnalysis, GraphEntity, GraphRelationship, GraphEvent
@@ -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 ์ƒ์„ฑ] ํŒŒ์ผ์ด ๋„ˆ๋ฌด ํฌ๊ฑฐ๋‚˜ ๋ชจ๋ธ ์‘๋‹ต์ด ๋А๋ฆด ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.")
@@ -1791,6 +1896,457 @@ def admin_files():
1791
  """ํŒŒ์ผ ๋ชฉ๋ก ๊ด€๋ฆฌ ํŽ˜์ด์ง€"""
1792
  return render_template('admin_files.html')
1793
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1794
  @main_bp.route('/api/admin/users', methods=['GET'])
1795
  @admin_required
1796
  def get_users():
@@ -1887,10 +2443,14 @@ def get_all_messages():
1887
  page = request.args.get('page', 1, type=int)
1888
  per_page = request.args.get('per_page', 50, type=int)
1889
 
1890
- query = ChatMessage.query.join(ChatSession)
1891
-
1892
  if user_id:
1893
- query = query.filter(ChatSession.user_id == user_id)
 
 
 
 
 
1894
  if session_id:
1895
  query = query.filter(ChatMessage.session_id == session_id)
1896
  if message_id:
@@ -1907,6 +2467,10 @@ def get_all_messages():
1907
  }), 200
1908
 
1909
  except Exception as e:
 
 
 
 
1910
  return jsonify({'error': f'๋ฉ”์‹œ์ง€ ์กฐํšŒ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค: {str(e)}'}), 500
1911
 
1912
  @main_bp.route('/api/admin/sessions', methods=['GET'])
@@ -1928,10 +2492,33 @@ def get_all_sessions():
1928
 
1929
  sessions_data = []
1930
  for session in sessions.items:
1931
- session_dict = session.to_dict()
1932
- session_dict['username'] = session.user.username if session.user else 'Unknown'
1933
- session_dict['nickname'] = session.user.nickname if session.user else None
1934
- sessions_data.append(session_dict)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1935
 
1936
  return jsonify({
1937
  'sessions': sessions_data,
@@ -1941,6 +2528,10 @@ def get_all_sessions():
1941
  }), 200
1942
 
1943
  except Exception as e:
 
 
 
 
1944
  return jsonify({'error': f'๋Œ€ํ™” ์„ธ์…˜ ์กฐํšŒ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค: {str(e)}'}), 500
1945
 
1946
  @main_bp.route('/api/admin/users/<int:user_id>', methods=['DELETE'])
@@ -2465,7 +3056,11 @@ def get_database_status():
2465
  try:
2466
  if is_postgresql:
2467
  # PostgreSQL ์—ฐ๊ฒฐ ํ…Œ์ŠคํŠธ
2468
- engine = create_engine(db_uri)
 
 
 
 
2469
  with engine.connect() as conn:
2470
  # ๋ฒ„์ „ ํ™•์ธ
2471
  result = conn.execute(text("SELECT version()"))
@@ -2603,6 +3198,76 @@ def get_all_ollama_models():
2603
  except Exception as e:
2604
  return jsonify({'error': f'๋ชจ๋ธ ๋ชฉ๋ก์„ ๊ฐ€์ ธ์˜ค๋Š” ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค: {str(e)}', 'models': []}), 500
2605
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2606
  @main_bp.route('/api/chat', methods=['POST'])
2607
  @login_required
2608
  def chat():
@@ -3032,6 +3697,14 @@ def chat():
3032
  # ๋ชจ๋ธ ํƒ€์ž… ํ™•์ธ (Gemini ๋˜๋Š” Ollama)
3033
  is_gemini = answer_model.startswith('gemini:')
3034
 
 
 
 
 
 
 
 
 
3035
  print(f"[์ตœ์ข… ๋‹ต๋ณ€ ์ƒ์„ฑ] ๋‹ต๋ณ€ ๋ชจ๋ธ: {answer_model}, ํ”„๋กฌํ”„ํŠธ ๊ธธ์ด: {len(full_prompt)}์ž")
3036
 
3037
  if is_gemini:
@@ -3057,6 +3730,11 @@ def chat():
3057
  if not response_text:
3058
  print(f"[์ฑ„ํŒ…] Gemini ์‘๋‹ต์ด ๋น„์–ด์žˆ์Šต๋‹ˆ๋‹ค. result: {result}")
3059
  response_text = '์‘๋‹ต์„ ์ƒ์„ฑํ•  ์ˆ˜ ์—†์—ˆ์Šต๋‹ˆ๋‹ค. ๋‹ค์‹œ ์‹œ๋„ํ•ด์ฃผ์„ธ์š”.'
 
 
 
 
 
3060
  else:
3061
  # Ollama API ํ˜ธ์ถœ
3062
  # Ollama ์„œ๋ฒ„ ์—ฐ๊ฒฐ ํ™•์ธ
@@ -3105,6 +3783,18 @@ def chat():
3105
  if not response_text:
3106
  print(f"[์ฑ„ํŒ…] Ollama ์‘๋‹ต์ด ๋น„์–ด์žˆ์Šต๋‹ˆ๋‹ค. ollama_data: {ollama_data}")
3107
  response_text = '์‘๋‹ต์„ ์ƒ์„ฑํ•  ์ˆ˜ ์—†์—ˆ์Šต๋‹ˆ๋‹ค. ๋‹ค์‹œ ์‹œ๋„ํ•ด์ฃผ์„ธ์š”.'
 
 
 
 
 
 
 
 
 
 
 
 
3108
 
3109
  # ๋Œ€ํ™” ์„ธ์…˜์— ๋ฉ”์‹œ์ง€ ์ €์žฅ (Gemini์™€ Ollama ๊ณตํ†ต)
3110
  session_id = data.get('session_id')
@@ -3160,14 +3850,25 @@ def chat():
3160
  else:
3161
  print(f"[๋ฉ”์‹œ์ง€ ์ €์žฅ] ์ค‘๋ณต ๋ฉ”์‹œ์ง€๋กœ ์ธํ•ด ์ €์žฅ์„ ๊ฑด๋„ˆ๋œ๋‹ˆ๋‹ค.")
3162
 
3163
- # AI ์‘๋‹ต ์ €์žฅ
 
 
 
 
 
3164
  ai_msg = ChatMessage(
3165
  session_id=session_id,
3166
  role='ai',
3167
- content=response_text
 
 
 
3168
  )
3169
  db.session.add(ai_msg)
3170
 
 
 
 
3171
  # ์„ธ์…˜ ๋ชจ๋ธ ์ •๋ณด ์—…๋ฐ์ดํŠธ (์ฒซ ๋ฉ”์‹œ์ง€์ธ ๊ฒฝ์šฐ ๋˜๋Š” ๋ณ€๊ฒฝ๋œ ๊ฒฝ์šฐ)
3172
  if not session.analysis_model or session.analysis_model != analysis_model:
3173
  session.analysis_model = analysis_model
 
1
+ from flask import Blueprint, render_template, request, jsonify, send_from_directory, redirect, url_for, flash, send_file
2
  from flask_login import login_user, logout_user, login_required, current_user
3
  from werkzeug.utils import secure_filename
4
  from app.database import db, UploadedFile, User, ChatSession, ChatMessage, DocumentChunk, ParentChunk, SystemConfig, EpisodeAnalysis, GraphEntity, GraphRelationship, GraphEvent
 
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 ์ƒ์„ฑ] ํŒŒ์ผ์ด ๋„ˆ๋ฌด ํฌ๊ฑฐ๋‚˜ ๋ชจ๋ธ ์‘๋‹ต์ด ๋А๋ฆด ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.")
 
1896
  """ํŒŒ์ผ ๋ชฉ๋ก ๊ด€๋ฆฌ ํŽ˜์ด์ง€"""
1897
  return render_template('admin_files.html')
1898
 
1899
+ @main_bp.route('/admin/utils')
1900
+ @admin_required
1901
+ def admin_utils():
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
+
1914
+ ์ง€์›ํ•˜๋Š” ํŒจํ„ด:
1915
+ - @1, @2, @10 -> #1ํ™”, #2ํ™”, #10ํ™”
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
+
1923
+ Args:
1924
+ content: ๋ณ€ํ™˜ํ•  ์›น์†Œ์„ค ๋‚ด์šฉ
1925
+
1926
+ Returns:
1927
+ ๋ณ€ํ™˜๋œ ๋‚ด์šฉ
1928
+ """
1929
+ if not content:
1930
+ return content
1931
+
1932
+ lines = content.split('\n')
1933
+ converted_lines = []
1934
+
1935
+ # ๋‹ค์–‘ํ•œ ํšŒ์ฐจ ํŒจํ„ด์„ #nํ™” ํ˜•์‹์œผ๋กœ ๋ณ€ํ™˜
1936
+ # ํŒจํ„ด: @์ˆซ์ž, @ ์ˆซ์ž, @์ˆซ์žํ™”, @ ์ˆซ์žํ™”, ์ˆซ์žํ™” ๋“ฑ
1937
+ # ์ค„ ์‹œ์ž‘ ๋ถ€๋ถ„์—์„œ๋งŒ ๋งค์นญํ•˜๋„๋ก ^ ์‚ฌ์šฉ
1938
+ for line in lines:
1939
+ converted_line = line
1940
+
1941
+ # ํ”„๋กค๋กœ๊ทธ ์ฒ˜๋ฆฌ (@ํ”„๋กค๋กœ๊ทธ, #ํ”„๋กค๋กœ๊ทธ -> #0ํ™” ํ”„๋กค๋กœ๊ทธ)
1942
+ converted_line = re.sub(r'^[@#]\s*ํ”„๋กค๋กœ๊ทธ\s*', '#0ํ™” ํ”„๋กค๋กœ๊ทธ', converted_line)
1943
+
1944
+ # @์ˆซ์žํ™” ํŒจํ„ด (๊ณต๋ฐฑ ํฌํ•จ/๋ฏธํฌํ•จ)
1945
+ converted_line = re.sub(r'^@\s*(\d+)\s*ํ™”\s*', r'#\1ํ™”', converted_line)
1946
+
1947
+ # @์ˆซ์ž ํŒจํ„ด (๊ณต๋ฐฑ ํฌํ•จ/๋ฏธํฌํ•จ) - ํ™”๊ฐ€ ์—†๋Š” ๊ฒฝ์šฐ๋งŒ
1948
+ # ์ด๋ฏธ ํ™”๊ฐ€ ์žˆ๋Š” ๊ฒฝ์šฐ๋Š” ์œ„์—์„œ ์ฒ˜๋ฆฌ๋˜์—ˆ์œผ๋ฏ€๋กœ, ํ™”๊ฐ€ ์—†๋Š” ๊ฒฝ์šฐ๋งŒ ์ฒ˜๋ฆฌ
1949
+ if not re.search(r'^#\d+ํ™”', converted_line):
1950
+ converted_line = re.sub(r'^@\s*(\d+)(?!ํ™”)\s*', r'#\1ํ™”', converted_line)
1951
+
1952
+ # ์ˆซ์žํ™” ํŒจํ„ด (์•ž์— ๊ธฐํ˜ธ ์—†์ด) - ์ด๋ฏธ #์ด ์žˆ๋Š” ๊ฒฝ์šฐ๋Š” ์ œ์™ธ
1953
+ # ์ค„ ์‹œ์ž‘์—์„œ ์ˆซ์ž๋กœ ์‹œ์ž‘ํ•˜๊ณ  ๋ฐ”๋กœ "ํ™”"๊ฐ€ ์˜ค๋Š” ๊ฒฝ์šฐ
1954
+ if not re.search(r'^#\d+ํ™”', converted_line):
1955
+ converted_line = re.sub(r'^(\d+)\s*ํ™”\s*', r'#\1ํ™”', converted_line)
1956
+
1957
+ # #์ˆซ์ž ํŒจํ„ด (ํ™”๊ฐ€ ์—†๋Š” ๊ฒฝ์šฐ) - #n -> #nํ™”
1958
+ if not re.search(r'^#\d+ํ™”', converted_line):
1959
+ converted_line = re.sub(r'^#\s*(\d+)(?!ํ™”)\s*', r'#\1ํ™”', converted_line)
1960
+
1961
+ # #0nํ™” ํŒจํ„ด์—์„œ ์•ž์˜ 0 ์ œ๊ฑฐ (#01ํ™” -> #1ํ™”, #010ํ™” -> #10ํ™”)
1962
+ # ๋‹จ, #0ํ™”๋Š” ๊ทธ๋Œ€๋กœ ์œ ์ง€
1963
+ converted_line = re.sub(r'^#0+(\d+)ํ™”', r'#\1ํ™”', converted_line)
1964
+
1965
+ converted_lines.append(converted_line)
1966
+
1967
+ return '\n'.join(converted_lines)
1968
+
1969
+ @main_bp.route('/api/admin/utils/detect-encoding', methods=['POST'])
1970
+ @admin_required
1971
+ def detect_file_encoding():
1972
+ """ํŒŒ์ผ ์ธ์ฝ”๋”ฉ ๊ฐ์ง€ API"""
1973
+ try:
1974
+ if 'file' not in request.files:
1975
+ return jsonify({'error': 'ํŒŒ์ผ์ด ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค.'}), 400
1976
+
1977
+ file = request.files['file']
1978
+ if not file or not file.filename:
1979
+ return jsonify({'error': 'ํŒŒ์ผ์ด ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค.'}), 400
1980
+
1981
+ file_content = file.read()
1982
+ file.seek(0) # ํŒŒ์ผ ํฌ์ธํ„ฐ ๋ฆฌ์…‹
1983
+
1984
+ # ์—ฌ๋Ÿฌ ์ธ์ฝ”๋”ฉ ์‹œ๋„ํ•˜์—ฌ ๊ฐ์ง€
1985
+ encodings_to_try = ['utf-8', 'cp949', 'euc-kr', 'latin-1', 'utf-16', 'utf-16-le', 'utf-16-be']
1986
+ detected_encoding = None
1987
+ detected_content = None
1988
+
1989
+ for encoding in encodings_to_try:
1990
+ try:
1991
+ content = file_content.decode(encoding)
1992
+ detected_encoding = encoding
1993
+ detected_content = content
1994
+ break
1995
+ except (UnicodeDecodeError, UnicodeError):
1996
+ continue
1997
+
1998
+ if not detected_encoding:
1999
+ return jsonify({
2000
+ 'error': 'ํŒŒ์ผ ์ธ์ฝ”๋”ฉ์„ ๊ฐ์ง€ํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.',
2001
+ 'detected_encoding': None,
2002
+ 'confidence': 0
2003
+ }), 400
2004
+
2005
+ # ๏ฟฝ๏ฟฝ๋‹จํ•œ ์‹ ๋ขฐ๋„ ๊ณ„์‚ฐ (UTF-8 BOM ํ™•์ธ, ์ผ๋ฐ˜์ ์ธ ๋ฌธ์ž ๋ฒ”์œ„ ํ™•์ธ ๋“ฑ)
2006
+ confidence = 0.8
2007
+ if detected_encoding == 'utf-8':
2008
+ # UTF-8 BOM ํ™•์ธ
2009
+ if file_content.startswith(b'\xef\xbb\xbf'):
2010
+ confidence = 0.95
2011
+ # ํ•œ๊ธ€ ๋ฌธ์ž ํฌํ•จ ์—ฌ๋ถ€ ํ™•์ธ
2012
+ if any(ord(char) >= 0xAC00 and ord(char) <= 0xD7A3 for char in detected_content[:1000]):
2013
+ confidence = 0.9
2014
+
2015
+ return jsonify({
2016
+ 'success': True,
2017
+ 'detected_encoding': detected_encoding,
2018
+ 'confidence': confidence,
2019
+ 'preview': detected_content[:200] if detected_content else ''
2020
+ }), 200
2021
+
2022
+ except Exception as e:
2023
+ import traceback
2024
+ print(f"[์ธ์ฝ”๋”ฉ ๊ฐ์ง€] ์˜ค๋ฅ˜: {str(e)}")
2025
+ print(traceback.format_exc())
2026
+ return jsonify({'error': f'์ธ์ฝ”๋”ฉ ๊ฐ์ง€ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค: {str(e)}'}), 500
2027
+
2028
+ @main_bp.route('/api/admin/utils/convert-episode-format', methods=['POST'])
2029
+ @admin_required
2030
+ def convert_episode_format_api():
2031
+ """ํšŒ์ฐจ ๊ตฌ๋ถ„ ๋ฐฉ์‹ ๋ณ€ํ™˜ API (ํŒŒ์ผ ์—…๋กœ๋“œ ๋˜๋Š” ํ…์ŠคํŠธ ์ž…๋ ฅ)"""
2032
+ try:
2033
+ content = None
2034
+ original_filename = 'converted_file.txt'
2035
+ specified_encoding = None
2036
+
2037
+ # ํŒŒ์ผ ์—…๋กœ๋“œ ํ™•์ธ
2038
+ if 'file' in request.files:
2039
+ file = request.files['file']
2040
+ if file and file.filename and file.filename != '':
2041
+ # ํŒŒ์ผ ์ฝ๊ธฐ
2042
+ try:
2043
+ # ์š”์ฒญ์—์„œ ์ธ์ฝ”๋”ฉ ์ •๋ณด ๊ฐ€์ ธ์˜ค๊ธฐ
2044
+ specified_encoding = request.form.get('encoding', 'utf-8')
2045
+
2046
+ file_content = file.read()
2047
+ file.seek(0) # ํŒŒ์ผ ํฌ์ธํ„ฐ ๋ฆฌ์…‹ (๋‹ค์‹œ ์ฝ์„ ์ˆ˜ ์žˆ๋„๋ก)
2048
+
2049
+ # ์ง€์ •๋œ ์ธ์ฝ”๋”ฉ์œผ๋กœ ์‹œ๋„
2050
+ try:
2051
+ content = file_content.decode(specified_encoding)
2052
+ except (UnicodeDecodeError, UnicodeError):
2053
+ # ์ง€์ •๋œ ์ธ์ฝ”๋”ฉ ์‹คํŒจ ์‹œ ์ž๋™ ๊ฐ์ง€ ์‹œ๋„
2054
+ encodings_to_try = ['utf-8', 'cp949', 'euc-kr', 'latin-1']
2055
+ for encoding in encodings_to_try:
2056
+ if encoding == specified_encoding:
2057
+ continue
2058
+ try:
2059
+ content = file_content.decode(encoding)
2060
+ specified_encoding = encoding # ์„ฑ๊ณตํ•œ ์ธ์ฝ”๋”ฉ์œผ๋กœ ์—…๋ฐ์ดํŠธ
2061
+ break
2062
+ except (UnicodeDecodeError, UnicodeError):
2063
+ continue
2064
+
2065
+ if not content:
2066
+ return jsonify({'error': f'ํŒŒ์ผ ์ธ์ฝ”๋”ฉ ์˜ค๋ฅ˜: {specified_encoding} ๋ฐ ์ž๋™ ๊ฐ์ง€ ์‹คํŒจ'}), 500
2067
+
2068
+ original_filename = file.filename
2069
+ except Exception as e:
2070
+ import traceback
2071
+ print(f"[ํšŒ์ฐจ ๋ณ€ํ™˜] ํŒŒ์ผ ์ฝ๊ธฐ ์˜ค๋ฅ˜: {str(e)}")
2072
+ print(traceback.format_exc())
2073
+ return jsonify({'error': f'ํŒŒ์ผ ์ฝ๊ธฐ ์˜ค๋ฅ˜: {str(e)}'}), 500
2074
+
2075
+ # JSON ๋ฐ์ดํ„ฐ์—์„œ ๋‚ด์šฉ ๊ฐ€์ ธ์˜ค๊ธฐ (ํŒŒ์ผ์ด ์—†๋Š” ๊ฒฝ์šฐ)
2076
+ if not content:
2077
+ try:
2078
+ data = request.get_json(silent=True) or {}
2079
+ content = data.get('content', '')
2080
+ original_filename = data.get('filename', 'converted_file.txt')
2081
+ except Exception as e:
2082
+ print(f"[ํšŒ์ฐจ ๋ณ€ํ™˜] JSON ํŒŒ์‹ฑ ์˜ค๋ฅ˜: {str(e)}")
2083
+ return jsonify({'error': f'์š”์ฒญ ๋ฐ์ดํ„ฐ ํŒŒ์‹ฑ ์˜ค๋ฅ˜: {str(e)}'}), 400
2084
+
2085
+ if not content or not content.strip():
2086
+ return jsonify({'error': 'ํŒŒ์ผ์„ ์—…๋กœ๋“œํ•˜๊ฑฐ๋‚˜ ๋‚ด์šฉ์„ ์ž…๋ ฅํ•ด์ฃผ์„ธ์š”.'}), 400
2087
+
2088
+ # ๋ณ€ํ™˜ ์‹คํ–‰
2089
+ try:
2090
+ converted_content = convert_episode_format(content)
2091
+ except Exception as e:
2092
+ import traceback
2093
+ print(f"[ํšŒ์ฐจ ๋ณ€ํ™˜] ๋ณ€ํ™˜ ํ•จ์ˆ˜ ์˜ค๋ฅ˜: {str(e)}")
2094
+ print(traceback.format_exc())
2095
+ return jsonify({'error': f'๋ณ€ํ™˜ ์‹คํ–‰ ์˜ค๋ฅ˜: {str(e)}'}), 500
2096
+
2097
+ # ์ž„์‹œ ํŒŒ์ผ๋กœ ์ €์žฅ
2098
+ try:
2099
+ temp_dir = os.path.join(os.getcwd(), 'uploads', 'temp')
2100
+ os.makedirs(temp_dir, exist_ok=True)
2101
+
2102
+ # ์›๋ณธ ํŒŒ์ผ๋ช…์—์„œ ํ™•์žฅ์ž ์ถ”์ถœ
2103
+ if '.' in original_filename:
2104
+ name, ext = os.path.splitext(original_filename)
2105
+ converted_filename = f"{name}_converted{ext}"
2106
+ else:
2107
+ converted_filename = f"{original_filename}_converted.txt"
2108
+
2109
+ # ์•ˆ์ „ํ•œ ํŒŒ์ผ๋ช… ์ƒ์„ฑ
2110
+ safe_filename = secure_filename(converted_filename)
2111
+ if not safe_filename:
2112
+ safe_filename = 'converted_file.txt'
2113
+
2114
+ temp_file_path = os.path.join(temp_dir, f"{uuid.uuid4().hex}_{safe_filename}")
2115
+
2116
+ # ๋ณ€ํ™˜๋œ ๋‚ด์šฉ์„ ์ž„์‹œ ํŒŒ์ผ์— ์ €์žฅ
2117
+ with open(temp_file_path, 'w', encoding='utf-8') as f:
2118
+ f.write(converted_content)
2119
+
2120
+ # ์ƒ๋Œ€ ๊ฒฝ๋กœ ๋ฐ˜ํ™˜ (๋‹ค์šด๋กœ๋“œ์šฉ)
2121
+ relative_path = os.path.relpath(temp_file_path, os.getcwd())
2122
+ # Windows ๊ฒฝ๋กœ๋ฅผ URL ์ธ์ฝ”๋”ฉ
2123
+ relative_path = relative_path.replace('\\', '/')
2124
+
2125
+ return jsonify({
2126
+ 'success': True,
2127
+ 'message': 'ํšŒ์ฐจ ๊ตฌ๋ถ„ ๋ฐฉ์‹์ด ๋ณ€ํ™˜๋˜์—ˆ์Šต๋‹ˆ๋‹ค.',
2128
+ 'converted_content': converted_content[:500] + '...' if len(converted_content) > 500 else converted_content,
2129
+ 'download_url': f'/api/admin/utils/download-converted-file?path={relative_path}',
2130
+ 'filename': safe_filename
2131
+ }), 200
2132
+ except Exception as e:
2133
+ import traceback
2134
+ print(f"[ํšŒ์ฐจ ๋ณ€ํ™˜] ํŒŒ์ผ ์ €์žฅ ์˜ค๋ฅ˜: {str(e)}")
2135
+ print(traceback.format_exc())
2136
+ return jsonify({'error': f'ํŒŒ์ผ ์ €์žฅ ์˜ค๋ฅ˜: {str(e)}'}), 500
2137
+
2138
+ except Exception as e:
2139
+ import traceback
2140
+ print(f"[ํšŒ์ฐจ ๋ณ€ํ™˜] ์ „์ฒด ์˜ค๋ฅ˜: {str(e)}")
2141
+ print(traceback.format_exc())
2142
+ return jsonify({'error': f'๋ณ€ํ™˜ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค: {str(e)}'}), 500
2143
+
2144
+ @main_bp.route('/api/admin/utils/download-converted-file', methods=['GET'])
2145
+ @admin_required
2146
+ def download_converted_file():
2147
+ """๋ณ€ํ™˜๋œ ํŒŒ์ผ ๋‹ค์šด๋กœ๋“œ"""
2148
+ try:
2149
+ file_path = request.args.get('path')
2150
+ if not file_path:
2151
+ return jsonify({'error': 'ํŒŒ์ผ ๊ฒฝ๋กœ๊ฐ€ ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค.'}), 400
2152
+
2153
+ # ๋ณด์•ˆ: ์ƒ๋Œ€ ๊ฒฝ๋กœ๋งŒ ํ—ˆ์šฉ
2154
+ if os.path.isabs(file_path) or '..' in file_path:
2155
+ return jsonify({'error': '์ž˜๋ชป๋œ ํŒŒ์ผ ๊ฒฝ๋กœ์ž…๋‹ˆ๋‹ค.'}), 400
2156
+
2157
+ full_path = os.path.join(os.getcwd(), file_path)
2158
+
2159
+ # ํŒŒ์ผ ์กด์žฌ ํ™•์ธ
2160
+ if not os.path.exists(full_path):
2161
+ return jsonify({'error': 'ํŒŒ์ผ์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.'}), 404
2162
+
2163
+ # ํŒŒ์ผ๋ช… ์ถ”์ถœ
2164
+ filename = os.path.basename(full_path)
2165
+ # UUID ์ ‘๋‘์‚ฌ ์ œ๊ฑฐ
2166
+ if '_' in filename:
2167
+ filename = '_'.join(filename.split('_')[1:])
2168
+
2169
+ return send_file(
2170
+ full_path,
2171
+ as_attachment=True,
2172
+ download_name=filename,
2173
+ mimetype='text/plain; charset=utf-8'
2174
+ )
2175
+ except Exception as e:
2176
+ return jsonify({'error': f'ํŒŒ์ผ ๋‹ค์šด๋กœ๋“œ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค: {str(e)}'}), 500
2177
+
2178
+ @main_bp.route('/api/admin/token-usage', methods=['GET'])
2179
+ @admin_required
2180
+ def get_token_usage():
2181
+ """ํ† ํฐ ์‚ฌ์šฉ๋Ÿ‰ ํ†ต๊ณ„ API (๋‚ ์งœ ๋ฒ”์œ„, ๋ชจ๋ธ๋ณ„ ํ•„ํ„ฐ๋ง ์ง€์›)"""
2182
+ try:
2183
+ from datetime import datetime, timedelta
2184
+ from sqlalchemy import func, and_
2185
+
2186
+ # ํ•„ํ„ฐ ํŒŒ๋ผ๋ฏธํ„ฐ ๊ฐ€์ ธ์˜ค๊ธฐ
2187
+ start_date = request.args.get('start_date')
2188
+ end_date = request.args.get('end_date')
2189
+ model_name = request.args.get('model_name') # ํŠน์ • ๋ชจ๋ธ ํ•„ํ„ฐ๋ง
2190
+ group_by = request.args.get('group_by', 'day') # 'day', 'week', 'month', 'model'
2191
+
2192
+ # ๊ธฐ๋ณธ ๋‚ ์งœ ๋ฒ”์œ„ ์„ค์ • (์ตœ๊ทผ 30์ผ)
2193
+ if not end_date:
2194
+ end_date = datetime.utcnow()
2195
+ else:
2196
+ try:
2197
+ # ISO ํ˜•์‹ ๋˜๋Š” YYYY-MM-DD ํ˜•์‹ ํŒŒ์‹ฑ
2198
+ if 'T' in end_date:
2199
+ end_date = datetime.fromisoformat(end_date.replace('Z', '+00:00'))
2200
+ else:
2201
+ # YYYY-MM-DD ํ˜•์‹์ธ ๊ฒฝ์šฐ ์‹œ๊ฐ„์„ 23:59:59๋กœ ์„ค์ •
2202
+ end_date = datetime.strptime(end_date, '%Y-%m-%d')
2203
+ end_date = end_date.replace(hour=23, minute=59, second=59)
2204
+ except Exception as e:
2205
+ print(f"[ํ† ํฐ ํ†ต๊ณ„] ์ข…๋ฃŒ ๋‚ ์งœ ํŒŒ์‹ฑ ์˜ค๋ฅ˜: {end_date}, {str(e)}")
2206
+ end_date = datetime.utcnow()
2207
+
2208
+ if not start_date:
2209
+ start_date = end_date - timedelta(days=30)
2210
+ else:
2211
+ try:
2212
+ # ISO ํ˜•์‹ ๋˜๋Š” YYYY-MM-DD ํ˜•์‹ ํŒŒ์‹ฑ
2213
+ if 'T' in start_date:
2214
+ start_date = datetime.fromisoformat(start_date.replace('Z', '+00:00'))
2215
+ else:
2216
+ # YYYY-MM-DD ํ˜•์‹์ธ ๊ฒฝ์šฐ ์‹œ๊ฐ„์„ 00:00:00์œผ๋กœ ์„ค์ •
2217
+ start_date = datetime.strptime(start_date, '%Y-%m-%d')
2218
+ start_date = start_date.replace(hour=0, minute=0, second=0)
2219
+ except Exception as e:
2220
+ print(f"[ํ† ํฐ ํ†ต๊ณ„] ์‹œ์ž‘ ๋‚ ์งœ ํŒŒ์‹ฑ ์˜ค๋ฅ˜: {start_date}, {str(e)}")
2221
+ start_date = end_date - timedelta(days=30)
2222
+
2223
+ # ์ฟผ๋ฆฌ ๊ตฌ์„ฑ
2224
+ query = ChatMessage.query.filter(
2225
+ ChatMessage.role == 'ai',
2226
+ ChatMessage.created_at >= start_date,
2227
+ ChatMessage.created_at <= end_date
2228
+ )
2229
+
2230
+ # ๋ชจ๋ธ ํ•„ํ„ฐ๋ง
2231
+ if model_name:
2232
+ query = query.filter(ChatMessage.model_name == model_name)
2233
+
2234
+ # ํ† ํฐ ์ •๋ณด๊ฐ€ ์žˆ๋Š” ๋ฉ”์‹œ์ง€๋งŒ ์กฐํšŒ (์„ ํƒ์  ํ•„ํ„ฐ)
2235
+ # ํ† ํฐ ์ •๋ณด๊ฐ€ ์—†๋Š” ๋ฉ”์‹œ์ง€๋„ ํฌํ•จํ•˜๋ ค๋ฉด ์ด ํ•„ํ„ฐ๋ฅผ ์ œ๊ฑฐํ•  ์ˆ˜ ์žˆ์Œ
2236
+ # ํ•˜์ง€๋งŒ ํ†ต๊ณ„ ๋ชฉ์ ์ƒ ํ† ํฐ ์ •๋ณด๊ฐ€ ์žˆ๋Š” ๋ฉ”์‹œ์ง€๋งŒ ์กฐํšŒํ•˜๋Š” ๊ฒƒ์ด ๋งž์Œ
2237
+ query = query.filter(
2238
+ db.or_(
2239
+ ChatMessage.input_tokens.isnot(None),
2240
+ ChatMessage.output_tokens.isnot(None)
2241
+ )
2242
+ )
2243
+
2244
+ messages = query.all()
2245
+ print(f"[ํ† ํฐ ํ†ต๊ณ„] ์กฐํšŒ๋œ ๋ฉ”์‹œ์ง€ ์ˆ˜: {len(messages)} (๋‚ ์งœ ๋ฒ”์œ„: {start_date} ~ {end_date})")
2246
+
2247
+ # ์‚ฌ์šฉ์ž ์‚ฌ์šฉ๊ณผ ์‹œ์Šคํ…œ ์‚ฌ์šฉ ๊ตฌ๋ถ„
2248
+ user_messages = [msg for msg in messages if not msg.usage_type or msg.usage_type == 'user']
2249
+ system_messages = [msg for msg in messages if msg.usage_type == 'system']
2250
+ print(f"[ํ† ํฐ ํ†ต๊ณ„] ์‚ฌ์šฉ์ž ์‚ฌ์šฉ: {len(user_messages)}๊ฐœ, ์‹œ์Šคํ…œ ์‚ฌ์šฉ: {len(system_messages)}๊ฐœ")
2251
+
2252
+ # ๊ทธ๋ฃน๋ณ„ ์ง‘๊ณ„
2253
+ if group_by == 'day':
2254
+ # ์ผ๋ณ„ ์ง‘๊ณ„
2255
+ daily_stats = {}
2256
+ for msg in messages:
2257
+ date_key = msg.created_at.date().isoformat()
2258
+ if date_key not in daily_stats:
2259
+ daily_stats[date_key] = {
2260
+ 'date': date_key,
2261
+ 'input_tokens': 0,
2262
+ 'output_tokens': 0,
2263
+ 'total_tokens': 0,
2264
+ 'count': 0
2265
+ }
2266
+ daily_stats[date_key]['input_tokens'] += msg.input_tokens or 0
2267
+ daily_stats[date_key]['output_tokens'] += msg.output_tokens or 0
2268
+ daily_stats[date_key]['total_tokens'] += (msg.input_tokens or 0) + (msg.output_tokens or 0)
2269
+ daily_stats[date_key]['count'] += 1
2270
+
2271
+ stats = sorted(daily_stats.values(), key=lambda x: x['date'])
2272
+ elif group_by == 'model':
2273
+ # ๋ชจ๋ธ๋ณ„ ์ง‘๊ณ„ (์‹œ์Šคํ…œ ์‚ฌ์šฉ ํฌํ•จ)
2274
+ model_stats = {}
2275
+ for msg in messages:
2276
+ # ์‹œ์Šคํ…œ ์‚ฌ์šฉ์€ '์‹œ์Šคํ…œ ์‚ฌ์šฉ'์œผ๋กœ ํ‘œ์‹œ
2277
+ if msg.usage_type == 'system':
2278
+ model_key = '์‹œ์Šคํ…œ ์‚ฌ์šฉ'
2279
+ else:
2280
+ model_key = msg.model_name or 'Unknown'
2281
+
2282
+ if model_key not in model_stats:
2283
+ model_stats[model_key] = {
2284
+ 'model': model_key,
2285
+ 'input_tokens': 0,
2286
+ 'output_tokens': 0,
2287
+ 'total_tokens': 0,
2288
+ 'count': 0
2289
+ }
2290
+ model_stats[model_key]['input_tokens'] += msg.input_tokens or 0
2291
+ model_stats[model_key]['output_tokens'] += msg.output_tokens or 0
2292
+ model_stats[model_key]['total_tokens'] += (msg.input_tokens or 0) + (msg.output_tokens or 0)
2293
+ model_stats[model_key]['count'] += 1
2294
+
2295
+ stats = list(model_stats.values())
2296
+ else:
2297
+ # ์ „์ฒด ์ง‘๊ณ„
2298
+ total_input = sum(msg.input_tokens or 0 for msg in messages)
2299
+ total_output = sum(msg.output_tokens or 0 for msg in messages)
2300
+ stats = [{
2301
+ 'input_tokens': total_input,
2302
+ 'output_tokens': total_output,
2303
+ 'total_tokens': total_input + total_output,
2304
+ 'count': len(messages)
2305
+ }]
2306
+
2307
+ # ์‚ฌ์šฉ ๊ฐ€๋Šฅํ•œ ๋ชจ๋ธ ๋ชฉ๋ก
2308
+ available_models = db.session.query(
2309
+ ChatMessage.model_name
2310
+ ).filter(
2311
+ ChatMessage.role == 'ai',
2312
+ ChatMessage.model_name.isnot(None)
2313
+ ).distinct().all()
2314
+
2315
+ models = [m[0] for m in available_models if m[0]]
2316
+
2317
+ # ์‚ฌ์šฉ์ž ์‚ฌ์šฉ๊ณผ ์‹œ์Šคํ…œ ์‚ฌ์šฉ ํ†ต๊ณ„
2318
+ user_total_input = sum(msg.input_tokens or 0 for msg in user_messages)
2319
+ user_total_output = sum(msg.output_tokens or 0 for msg in user_messages)
2320
+ system_total_input = sum(msg.input_tokens or 0 for msg in system_messages)
2321
+ system_total_output = sum(msg.output_tokens or 0 for msg in system_messages)
2322
+
2323
+ return jsonify({
2324
+ 'success': True,
2325
+ 'stats': stats,
2326
+ 'models': models,
2327
+ 'start_date': start_date.isoformat(),
2328
+ 'end_date': end_date.isoformat(),
2329
+ 'total_messages': len(messages),
2330
+ 'user_usage': {
2331
+ 'input_tokens': user_total_input,
2332
+ 'output_tokens': user_total_output,
2333
+ 'total_tokens': user_total_input + user_total_output,
2334
+ 'count': len(user_messages)
2335
+ },
2336
+ 'system_usage': {
2337
+ 'input_tokens': system_total_input,
2338
+ 'output_tokens': system_total_output,
2339
+ 'total_tokens': system_total_input + system_total_output,
2340
+ 'count': len(system_messages)
2341
+ }
2342
+ }), 200
2343
+
2344
+ except Exception as e:
2345
+ import traceback
2346
+ print(f"[ํ† ํฐ ํ†ต๊ณ„] ์˜ค๋ฅ˜: {str(e)}")
2347
+ print(traceback.format_exc())
2348
+ return jsonify({'error': f'ํ† ํฐ ํ†ต๊ณ„ ์กฐํšŒ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค: {str(e)}'}), 500
2349
+
2350
  @main_bp.route('/api/admin/users', methods=['GET'])
2351
  @admin_required
2352
  def get_users():
 
2443
  page = request.args.get('page', 1, type=int)
2444
  per_page = request.args.get('per_page', 50, type=int)
2445
 
2446
+ # user_id๊ฐ€ ์žˆ๋Š” ๊ฒฝ์šฐ์—๋งŒ join ์‚ฌ์šฉ, ๊ทธ ์™ธ์—๋Š” ์ง์ ‘ ์กฐํšŒ
 
2447
  if user_id:
2448
+ # user_id๋กœ ํ•„ํ„ฐ๋งํ•  ๋•Œ๋Š” join ํ•„์š”
2449
+ query = ChatMessage.query.join(ChatSession).filter(ChatSession.user_id == user_id)
2450
+ else:
2451
+ # user_id๊ฐ€ ์—†์œผ๋ฉด join ์—†์ด ์ง์ ‘ ์กฐํšŒ
2452
+ query = ChatMessage.query
2453
+
2454
  if session_id:
2455
  query = query.filter(ChatMessage.session_id == session_id)
2456
  if message_id:
 
2467
  }), 200
2468
 
2469
  except Exception as e:
2470
+ import traceback
2471
+ error_trace = traceback.format_exc()
2472
+ print(f"[๋ฉ”์‹œ์ง€ ์กฐํšŒ] ์˜ค๋ฅ˜: {str(e)}")
2473
+ print(error_trace)
2474
  return jsonify({'error': f'๋ฉ”์‹œ์ง€ ์กฐํšŒ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค: {str(e)}'}), 500
2475
 
2476
  @main_bp.route('/api/admin/sessions', methods=['GET'])
 
2492
 
2493
  sessions_data = []
2494
  for session in sessions.items:
2495
+ try:
2496
+ session_dict = session.to_dict()
2497
+ # ์‚ฌ์šฉ์ž ์ •๋ณด ์•ˆ์ „ํ•˜๊ฒŒ ๊ฐ€์ ธ์˜ค๊ธฐ
2498
+ try:
2499
+ if session.user:
2500
+ session_dict['username'] = session.user.username if hasattr(session.user, 'username') else 'Unknown'
2501
+ session_dict['nickname'] = session.user.nickname if hasattr(session.user, 'nickname') else None
2502
+ else:
2503
+ # user_id๋กœ ์ง์ ‘ ์กฐํšŒ ์‹œ๋„
2504
+ user = User.query.get(session.user_id) if session.user_id else None
2505
+ if user:
2506
+ session_dict['username'] = user.username
2507
+ session_dict['nickname'] = user.nickname
2508
+ else:
2509
+ session_dict['username'] = 'Unknown'
2510
+ session_dict['nickname'] = None
2511
+ except Exception as user_error:
2512
+ print(f"[์„ธ์…˜ ์กฐํšŒ] ์‚ฌ์šฉ์ž ์ •๋ณด ์กฐํšŒ ์˜ค๋ฅ˜ (์„ธ์…˜ ID: {session.id}): {str(user_error)}")
2513
+ session_dict['username'] = 'Unknown'
2514
+ session_dict['nickname'] = None
2515
+
2516
+ sessions_data.append(session_dict)
2517
+ except Exception as session_error:
2518
+ print(f"[์„ธ์…˜ ์กฐํšŒ] ์„ธ์…˜ ์ฒ˜๋ฆฌ ์˜ค๋ฅ˜ (์„ธ์…˜ ID: {session.id if hasattr(session, 'id') else 'Unknown'}): {str(session_error)}")
2519
+ import traceback
2520
+ traceback.print_exc()
2521
+ continue # ๋ฌธ์ œ๊ฐ€ ์žˆ๋Š” ์„ธ์…˜์€ ๊ฑด๋„ˆ๋›ฐ๊ณ  ๊ณ„์† ์ง„ํ–‰
2522
 
2523
  return jsonify({
2524
  'sessions': sessions_data,
 
2528
  }), 200
2529
 
2530
  except Exception as e:
2531
+ import traceback
2532
+ error_trace = traceback.format_exc()
2533
+ print(f"[์„ธ์…˜ ์กฐํšŒ] ์ „์ฒด ์˜ค๋ฅ˜: {str(e)}")
2534
+ print(error_trace)
2535
  return jsonify({'error': f'๋Œ€ํ™” ์„ธ์…˜ ์กฐํšŒ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค: {str(e)}'}), 500
2536
 
2537
  @main_bp.route('/api/admin/users/<int:user_id>', methods=['DELETE'])
 
3056
  try:
3057
  if is_postgresql:
3058
  # PostgreSQL ์—ฐ๊ฒฐ ํ…Œ์ŠคํŠธ
3059
+ engine = create_engine(
3060
+ db_uri,
3061
+ pool_pre_ping=True,
3062
+ pool_recycle=300
3063
+ )
3064
  with engine.connect() as conn:
3065
  # ๋ฒ„์ „ ํ™•์ธ
3066
  result = conn.execute(text("SELECT version()"))
 
3198
  except Exception as e:
3199
  return jsonify({'error': f'๋ชจ๋ธ ๋ชฉ๋ก์„ ๊ฐ€์ ธ์˜ค๋Š” ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค: {str(e)}', 'models': []}), 500
3200
 
3201
+ @main_bp.route('/api/admin/default-models', methods=['GET'])
3202
+ @admin_required
3203
+ def get_default_models():
3204
+ """๊ธฐ๋ณธ AI ๋ชจ๋ธ ์„ค์ • ์กฐํšŒ"""
3205
+ try:
3206
+ default_analysis_model = SystemConfig.get_config('default_analysis_model', '')
3207
+ default_answer_model = SystemConfig.get_config('default_answer_model', '')
3208
+
3209
+ return jsonify({
3210
+ 'success': True,
3211
+ 'default_analysis_model': default_analysis_model,
3212
+ 'default_answer_model': default_answer_model
3213
+ }), 200
3214
+ except Exception as e:
3215
+ return jsonify({'error': f'๊ธฐ๋ณธ ๋ชจ๋ธ ์„ค์ • ์กฐํšŒ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค: {str(e)}'}), 500
3216
+
3217
+ @main_bp.route('/api/admin/default-models', methods=['POST'])
3218
+ @admin_required
3219
+ def set_default_models():
3220
+ """๊ธฐ๋ณธ AI ๋ชจ๋ธ ์„ค์ • ์ €์žฅ"""
3221
+ try:
3222
+ data = request.json
3223
+ default_analysis_model = data.get('default_analysis_model', '').strip()
3224
+ default_answer_model = data.get('default_answer_model', '').strip()
3225
+
3226
+ # ๋นˆ ๋ฌธ์ž์—ด์ด๋ฉด ์„ค์ • ์‚ญ์ œ
3227
+ if default_analysis_model:
3228
+ SystemConfig.set_config('default_analysis_model', default_analysis_model, '๊ธฐ๋ณธ ์งˆ๋ฌธ ๋ถ„์„์šฉ AI ๋ชจ๋ธ')
3229
+ else:
3230
+ # ์„ค์ • ์‚ญ์ œ
3231
+ config = SystemConfig.query.filter_by(key='default_analysis_model').first()
3232
+ if config:
3233
+ db.session.delete(config)
3234
+ db.session.commit()
3235
+
3236
+ if default_answer_model:
3237
+ SystemConfig.set_config('default_answer_model', default_answer_model, '๊ธฐ๋ณธ ๋‹ต๋ณ€ ์ƒ์„ฑ์šฉ AI ๋ชจ๋ธ')
3238
+ else:
3239
+ # ์„ค์ • ์‚ญ์ œ
3240
+ config = SystemConfig.query.filter_by(key='default_answer_model').first()
3241
+ if config:
3242
+ db.session.delete(config)
3243
+ db.session.commit()
3244
+
3245
+ return jsonify({
3246
+ 'success': True,
3247
+ 'message': '๊ธฐ๋ณธ AI ๋ชจ๋ธ์ด ์„ค์ •๋˜์—ˆ์Šต๋‹ˆ๋‹ค.',
3248
+ 'default_analysis_model': default_analysis_model or None,
3249
+ 'default_answer_model': default_answer_model or None
3250
+ }), 200
3251
+ except Exception as e:
3252
+ db.session.rollback()
3253
+ return jsonify({'error': f'๊ธฐ๋ณธ ๋ชจ๋ธ ์„ค์ • ์ €์žฅ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค: {str(e)}'}), 500
3254
+
3255
+ @main_bp.route('/api/default-models', methods=['GET'])
3256
+ @login_required
3257
+ def get_user_default_models():
3258
+ """์‚ฌ์šฉ์ž์šฉ: ๊ธฐ๋ณธ AI ๋ชจ๋ธ ์„ค์ • ์กฐํšŒ (๊ณต๊ฐœ API)"""
3259
+ try:
3260
+ default_analysis_model = SystemConfig.get_config('default_analysis_model', '')
3261
+ default_answer_model = SystemConfig.get_config('default_answer_model', '')
3262
+
3263
+ return jsonify({
3264
+ 'success': True,
3265
+ 'default_analysis_model': default_analysis_model,
3266
+ 'default_answer_model': default_answer_model
3267
+ }), 200
3268
+ except Exception as e:
3269
+ return jsonify({'error': f'๊ธฐ๋ณธ ๋ชจ๋ธ ์„ค์ • ์กฐํšŒ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค: {str(e)}'}), 500
3270
+
3271
  @main_bp.route('/api/chat', methods=['POST'])
3272
  @login_required
3273
  def chat():
 
3697
  # ๋ชจ๋ธ ํƒ€์ž… ํ™•์ธ (Gemini ๋˜๋Š” Ollama)
3698
  is_gemini = answer_model.startswith('gemini:')
3699
 
3700
+ # ํ† ํฐ ์ •๋ณด ๋ณ€์ˆ˜ ์ดˆ๊ธฐํ™”
3701
+ gemini_input_tokens = None
3702
+ gemini_output_tokens = None
3703
+ gemini_model_used = None
3704
+ ollama_input_tokens = None
3705
+ ollama_output_tokens = None
3706
+ ollama_model_used = None
3707
+
3708
  print(f"[์ตœ์ข… ๋‹ต๋ณ€ ์ƒ์„ฑ] ๋‹ต๋ณ€ ๋ชจ๋ธ: {answer_model}, ํ”„๋กฌํ”„ํŠธ ๊ธธ์ด: {len(full_prompt)}์ž")
3709
 
3710
  if is_gemini:
 
3730
  if not response_text:
3731
  print(f"[์ฑ„ํŒ…] Gemini ์‘๋‹ต์ด ๋น„์–ด์žˆ์Šต๋‹ˆ๋‹ค. result: {result}")
3732
  response_text = '์‘๋‹ต์„ ์ƒ์„ฑํ•  ์ˆ˜ ์—†์—ˆ์Šต๋‹ˆ๋‹ค. ๋‹ค์‹œ ์‹œ๋„ํ•ด์ฃผ์„ธ์š”.'
3733
+
3734
+ # ํ† ํฐ ์ •๋ณด ์ถ”์ถœ
3735
+ gemini_input_tokens = result.get('input_tokens')
3736
+ gemini_output_tokens = result.get('output_tokens')
3737
+ gemini_model_used = gemini_model_name
3738
  else:
3739
  # Ollama API ํ˜ธ์ถœ
3740
  # Ollama ์„œ๋ฒ„ ์—ฐ๊ฒฐ ํ™•์ธ
 
3783
  if not response_text:
3784
  print(f"[์ฑ„ํŒ…] Ollama ์‘๋‹ต์ด ๋น„์–ด์žˆ์Šต๋‹ˆ๋‹ค. ollama_data: {ollama_data}")
3785
  response_text = '์‘๋‹ต์„ ์ƒ์„ฑํ•  ์ˆ˜ ์—†์—ˆ์Šต๋‹ˆ๋‹ค. ๋‹ค์‹œ ์‹œ๋„ํ•ด์ฃผ์„ธ์š”.'
3786
+
3787
+ # Ollama ํ† ํฐ ์ •๋ณด ์ถ”์ถœ
3788
+ ollama_input_tokens = None
3789
+ ollama_output_tokens = None
3790
+ if 'prompt_eval_count' in ollama_data:
3791
+ ollama_input_tokens = ollama_data.get('prompt_eval_count')
3792
+ if 'eval_count' in ollama_data:
3793
+ ollama_output_tokens = ollama_data.get('eval_count')
3794
+ ollama_model_used = answer_model
3795
+
3796
+ if ollama_input_tokens or ollama_output_tokens:
3797
+ print(f"[Ollama] ํ† ํฐ ์‚ฌ์šฉ๋Ÿ‰: ์ž…๋ ฅ={ollama_input_tokens}, ์ถœ๋ ฅ={ollama_output_tokens}")
3798
 
3799
  # ๋Œ€ํ™” ์„ธ์…˜์— ๋ฉ”์‹œ์ง€ ์ €์žฅ (Gemini์™€ Ollama ๊ณตํ†ต)
3800
  session_id = data.get('session_id')
 
3850
  else:
3851
  print(f"[๋ฉ”์‹œ์ง€ ์ €์žฅ] ์ค‘๋ณต ๋ฉ”์‹œ์ง€๋กœ ์ธํ•ด ์ €์žฅ์„ ๊ฑด๋„ˆ๋œ๋‹ˆ๋‹ค.")
3852
 
3853
+ # AI ์‘๋‹ต ์ €์žฅ (ํ† ํฐ ์ •๋ณด ํฌํ•จ)
3854
+ # ํ† ํฐ ์ •๋ณด ์„ค์ • (Gemini ๋˜๋Š” Ollama)
3855
+ input_tokens = gemini_input_tokens if is_gemini else ollama_input_tokens
3856
+ output_tokens = gemini_output_tokens if is_gemini else ollama_output_tokens
3857
+ model_used = gemini_model_used if is_gemini else ollama_model_used
3858
+
3859
  ai_msg = ChatMessage(
3860
  session_id=session_id,
3861
  role='ai',
3862
+ content=response_text,
3863
+ input_tokens=input_tokens,
3864
+ output_tokens=output_tokens,
3865
+ model_name=model_used
3866
  )
3867
  db.session.add(ai_msg)
3868
 
3869
+ if input_tokens or output_tokens:
3870
+ print(f"[๋ฉ”์‹œ์ง€ ์ €์žฅ] AI ๋ฉ”์‹œ์ง€ ์ €์žฅ (๋ชจ๋ธ: {model_used}, ์ž…๋ ฅ ํ† ํฐ: {input_tokens}, ์ถœ๋ ฅ ํ† ํฐ: {output_tokens})")
3871
+
3872
  # ์„ธ์…˜ ๋ชจ๋ธ ์ •๋ณด ์—…๋ฐ์ดํŠธ (์ฒซ ๋ฉ”์‹œ์ง€์ธ ๊ฒฝ์šฐ ๋˜๋Š” ๋ณ€๊ฒฝ๋œ ๊ฒฝ์šฐ)
3873
  if not session.analysis_model or session.analysis_model != analysis_model:
3874
  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
 
requirements.txt CHANGED
@@ -1,17 +1,17 @@
1
- Flask==3.0.0
2
- flask-sqlalchemy==3.1.1
3
- flask-login==0.6.3
4
- python-dotenv==1.0.0
5
- requests==2.31.0
6
- werkzeug==3.0.1
7
- chromadb==0.4.22
8
- sentence-transformers==2.3.1
9
- numpy==1.24.3
10
- google-generativeai==0.3.2
11
- pydantic==2.5.0
12
- pydantic-settings==2.1.0
13
- # Database drivers
14
- psycopg2-binary # PostgreSQL support for external database
15
- # Ollama์™€ ํŒŒ์ด์ฌ์„ ์—ฐ๊ฒฐํ•˜๋ ค๋ฉด ์•„๋ž˜ ํŒจํ‚ค์ง€๊ฐ€ ๋ณดํ†ต ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค.
16
- ollama
17
-
 
1
+ Flask==3.0.0
2
+ flask-sqlalchemy==3.1.1
3
+ flask-login==0.6.3
4
+ python-dotenv==1.0.0
5
+ requests==2.31.0
6
+ werkzeug==3.0.1
7
+ chromadb==0.4.22
8
+ sentence-transformers==2.3.1
9
+ numpy==1.24.3
10
+ google-generativeai==0.3.2
11
+ pydantic==2.5.0
12
+ pydantic-settings==2.1.0
13
+ # Database drivers
14
+ psycopg2-binary # PostgreSQL support for external database
15
+ # Ollama์™€ ํŒŒ์ด์ฌ์„ ์—ฐ๊ฒฐํ•˜๋ ค๋ฉด ์•„๋ž˜ ํŒจํ‚ค์ง€๊ฐ€ ๋ณดํ†ต ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค.
16
+ ollama
17
+
templates/admin.html CHANGED
@@ -571,6 +571,8 @@
571
  <a href="{{ url_for('main.admin_messages') }}" class="btn btn-secondary">๋ฉ”์‹œ์ง€ ํ™•์ธ</a>
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.index') }}" class="btn btn-secondary">๋ฉ”์ธ์œผ๋กœ</a>
575
  <a href="{{ url_for('main.logout') }}" class="btn btn-secondary">๋กœ๊ทธ์•„์›ƒ</a>
576
  </div>
@@ -590,6 +592,8 @@
590
  <a href="{{ url_for('main.admin_messages') }}" class="mobile-menu-item" onclick="closeMobileMenu()">๋ฉ”์‹œ์ง€ ํ™•์ธ</a>
591
  <a href="{{ url_for('main.admin_prompts') }}" class="mobile-menu-item" onclick="closeMobileMenu()">ํ”„๋กฌํ”„ํŠธ ๊ด€๋ฆฌ</a>
592
  <a href="{{ url_for('main.admin_settings') }}" class="mobile-menu-item" onclick="closeMobileMenu()">AI ์„ค์ •</a>
 
 
593
  <a href="{{ url_for('main.index') }}" class="mobile-menu-item" onclick="closeMobileMenu()">๋ฉ”์ธ์œผ๋กœ</a>
594
  <a href="{{ url_for('main.logout') }}" class="mobile-menu-item" onclick="closeMobileMenu()">๋กœ๊ทธ์•„์›ƒ</a>
595
  </div>
 
571
  <a href="{{ url_for('main.admin_messages') }}" class="btn btn-secondary">๋ฉ”์‹œ์ง€ ํ™•์ธ</a>
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>
 
592
  <a href="{{ url_for('main.admin_messages') }}" class="mobile-menu-item" onclick="closeMobileMenu()">๋ฉ”์‹œ์ง€ ํ™•์ธ</a>
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_files.html CHANGED
@@ -473,6 +473,9 @@
473
  <a href="{{ url_for('main.admin_messages') }}" class="btn btn-secondary">๋ฉ”์‹œ์ง€ ํ™•์ธ</a>
474
  <a href="{{ url_for('main.admin_prompts') }}" class="btn btn-secondary">ํ”„๋กฌํ”„ํŠธ ๊ด€๋ฆฌ</a>
475
  <a href="{{ url_for('main.admin_settings') }}" class="btn btn-secondary">AI ์„ค์ •</a>
 
 
 
476
  <a href="{{ url_for('main.index') }}" class="btn btn-secondary">๋ฉ”์ธ์œผ๋กœ</a>
477
  <a href="{{ url_for('main.logout') }}" class="btn btn-secondary">๋กœ๊ทธ์•„์›ƒ</a>
478
  </div>
@@ -492,6 +495,9 @@
492
  <a href="{{ url_for('main.admin_messages') }}" class="mobile-menu-item" onclick="closeMobileMenu()">๋ฉ”์‹œ์ง€ ํ™•์ธ</a>
493
  <a href="{{ url_for('main.admin_prompts') }}" class="mobile-menu-item" onclick="closeMobileMenu()">ํ”„๋กฌํ”„ํŠธ ๊ด€๋ฆฌ</a>
494
  <a href="{{ url_for('main.admin_settings') }}" class="mobile-menu-item" onclick="closeMobileMenu()">AI ์„ค์ •</a>
 
 
 
495
  <a href="{{ url_for('main.index') }}" class="mobile-menu-item" onclick="closeMobileMenu()">๋ฉ”์ธ์œผ๋กœ</a>
496
  <a href="{{ url_for('main.logout') }}" class="mobile-menu-item" onclick="closeMobileMenu()">๋กœ๊ทธ์•„์›ƒ</a>
497
  </div>
 
473
  <a href="{{ url_for('main.admin_messages') }}" class="btn btn-secondary">๋ฉ”์‹œ์ง€ ํ™•์ธ</a>
474
  <a href="{{ url_for('main.admin_prompts') }}" class="btn btn-secondary">ํ”„๋กฌํ”„ํŠธ ๊ด€๋ฆฌ</a>
475
  <a href="{{ url_for('main.admin_settings') }}" class="btn btn-secondary">AI ์„ค์ •</a>
476
+ <a href="{{ url_for('main.admin_utils') }}" class="btn btn-secondary">์œ ํ‹ธ</a>
477
+ <a href="{{ url_for('main.admin_tokens') }}" class="btn btn-secondary">ํ† ํฐ ํ†ต๊ณ„</a>
478
+ <a href="{{ url_for('main.admin_utils') }}" class="btn btn-secondary">์œ ํ‹ธ</a>
479
  <a href="{{ url_for('main.index') }}" class="btn btn-secondary">๋ฉ”์ธ์œผ๋กœ</a>
480
  <a href="{{ url_for('main.logout') }}" class="btn btn-secondary">๋กœ๊ทธ์•„์›ƒ</a>
481
  </div>
 
495
  <a href="{{ url_for('main.admin_messages') }}" class="mobile-menu-item" onclick="closeMobileMenu()">๋ฉ”์‹œ์ง€ ํ™•์ธ</a>
496
  <a href="{{ url_for('main.admin_prompts') }}" class="mobile-menu-item" onclick="closeMobileMenu()">ํ”„๋กฌํ”„ํŠธ ๊ด€๋ฆฌ</a>
497
  <a href="{{ url_for('main.admin_settings') }}" class="mobile-menu-item" onclick="closeMobileMenu()">AI ์„ค์ •</a>
498
+ <a href="{{ url_for('main.admin_utils') }}" class="mobile-menu-item" onclick="closeMobileMenu()">์œ ํ‹ธ</a>
499
+ <a href="{{ url_for('main.admin_tokens') }}" class="mobile-menu-item" onclick="closeMobileMenu()">ํ† ํฐ ํ†ต๊ณ„</a>
500
+ <a href="{{ url_for('main.admin_utils') }}" class="mobile-menu-item" onclick="closeMobileMenu()">์œ ํ‹ธ</a>
501
  <a href="{{ url_for('main.index') }}" class="mobile-menu-item" onclick="closeMobileMenu()">๋ฉ”์ธ์œผ๋กœ</a>
502
  <a href="{{ url_for('main.logout') }}" class="mobile-menu-item" onclick="closeMobileMenu()">๋กœ๊ทธ์•„์›ƒ</a>
503
  </div>
templates/admin_messages.html CHANGED
@@ -474,6 +474,9 @@
474
  <a href="{{ url_for('main.admin_files') }}" class="btn btn-secondary">ํŒŒ์ผ ๋ชฉ๋ก</a>
475
  <a href="{{ url_for('main.admin_prompts') }}" class="btn btn-secondary">ํ”„๋กฌํ”„ํŠธ ๊ด€๋ฆฌ</a>
476
  <a href="{{ url_for('main.admin_settings') }}" class="btn btn-secondary">AI ์„ค์ •</a>
 
 
 
477
  <a href="{{ url_for('main.index') }}" class="btn btn-secondary">๋ฉ”์ธ์œผ๋กœ</a>
478
  <a href="{{ url_for('main.logout') }}" class="btn btn-secondary">๋กœ๊ทธ์•„์›ƒ</a>
479
  </div>
@@ -493,6 +496,9 @@
493
  <a href="{{ url_for('main.admin_files') }}" class="mobile-menu-item" onclick="closeMobileMenu()">ํŒŒ์ผ ๋ชฉ๋ก</a>
494
  <a href="{{ url_for('main.admin_prompts') }}" class="mobile-menu-item" onclick="closeMobileMenu()">ํ”„๋กฌํ”„ํŠธ ๊ด€๋ฆฌ</a>
495
  <a href="{{ url_for('main.admin_settings') }}" class="mobile-menu-item" onclick="closeMobileMenu()">AI ์„ค์ •</a>
 
 
 
496
  <a href="{{ url_for('main.index') }}" class="mobile-menu-item" onclick="closeMobileMenu()">๋ฉ”์ธ์œผ๋กœ</a>
497
  <a href="{{ url_for('main.logout') }}" class="mobile-menu-item" onclick="closeMobileMenu()">๋กœ๊ทธ์•„์›ƒ</a>
498
  </div>
@@ -576,13 +582,16 @@
576
  <th>์„ธ์…˜ ID</th>
577
  <th>์—ญํ• </th>
578
  <th>๋‚ด์šฉ</th>
 
 
 
579
  <th>์‹œ๊ฐ„</th>
580
  <th>์ž‘์—…</th>
581
  </tr>
582
  </thead>
583
  <tbody id="messagesTableBody">
584
  <tr>
585
- <td colspan="6" style="text-align: center; padding: 20px; color: #5f6368;">์„ธ์…˜์„ ์„ ํƒํ•˜๊ฑฐ๋‚˜ ํ•„ํ„ฐ๋ฅผ ์‚ฌ์šฉํ•˜์„ธ์š”</td>
586
  </tr>
587
  </tbody>
588
  </table>
@@ -718,7 +727,7 @@
718
  // ๋ฉ”์‹œ์ง€ ๋ชฉ๋ก ๋กœ๋“œ
719
  async function loadMessages(page = 1) {
720
  const tbody = document.getElementById('messagesTableBody');
721
- tbody.innerHTML = '<tr><td colspan="6" style="text-align: center; padding: 20px; color: #5f6368;">๋กœ๋”ฉ ์ค‘...</td></tr>';
722
 
723
  try {
724
  const sessionId = document.getElementById('sessionIdFilter').value;
@@ -749,12 +758,18 @@
749
  const row = document.createElement('tr');
750
  const date = new Date(msg.created_at).toLocaleString('ko-KR');
751
  const contentPreview = msg.content && msg.content.length > 100 ? msg.content.substring(0, 100) + '...' : (msg.content || '');
 
 
 
752
 
753
  row.innerHTML = `
754
  <td>${msg.id}</td>
755
  <td>${msg.session_id}</td>
756
  <td><span class="role-badge role-${msg.role}">${msg.role === 'user' ? '์‚ฌ์šฉ์ž' : 'AI'}</span></td>
757
  <td class="message-content">${escapeHtml(contentPreview)}</td>
 
 
 
758
  <td>${date}</td>
759
  <td>
760
  <button class="btn btn-secondary" onclick="viewMessageById(${msg.id})" style="padding: 4px 8px; font-size: 12px;">์ƒ์„ธ ๋ณด๊ธฐ</button>
@@ -764,11 +779,14 @@
764
  row.setAttribute('data-message-id', msg.id);
765
  row.setAttribute('data-message-role', msg.role);
766
  row.setAttribute('data-message-content', escapeHtml(msg.content || ''));
 
 
 
767
  tbody.appendChild(row);
768
  });
769
  } else {
770
  console.log('[๋ฉ”์‹œ์ง€ ๋ชฉ๋ก] ๋ฉ”์‹œ์ง€๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค');
771
- tbody.innerHTML = '<tr><td colspan="6" style="text-align: center; padding: 20px; color: #5f6368;">๋ฉ”์‹œ์ง€๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค.</td></tr>';
772
  }
773
 
774
  // ํŽ˜์ด์ง€๋„ค์ด์…˜
@@ -783,7 +801,7 @@
783
  } catch (error) {
784
  console.error('[๋ฉ”์‹œ์ง€ ๋ชฉ๋ก] ์กฐํšŒ ์˜ค๋ฅ˜:', error);
785
  showAlert(`๋ฉ”์‹œ์ง€ ์กฐํšŒ ์˜ค๋ฅ˜: ${error.message}`, 'error');
786
- tbody.innerHTML = `<tr><td colspan="6" style="text-align: center; padding: 20px; color: #ea4335;">๋ฉ”์‹œ์ง€ ์กฐํšŒ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค.<br><small>${error.message || '์•Œ ์ˆ˜ ์—†๋Š” ์˜ค๋ฅ˜'}</small></td></tr>`;
787
  }
788
  }
789
 
@@ -802,7 +820,51 @@
802
 
803
  if (data.messages && data.messages.length > 0) {
804
  const msg = data.messages[0];
805
- viewMessage(msg.id, msg.role, msg.content);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
806
  } else {
807
  // ๋ฐ์ดํ„ฐ ์†์„ฑ์—์„œ ๊ฐ€์ ธ์˜ค๊ธฐ (fallback)
808
  const row = document.querySelector(`tr[data-message-id="${messageId}"]`);
@@ -833,6 +895,35 @@
833
  const modal = document.getElementById('messageModal');
834
  const modalContent = document.getElementById('messageModalContent');
835
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
836
  modalContent.innerHTML = `
837
  <div class="message-view">
838
  <div class="message-item ${role}">
@@ -841,6 +932,7 @@
841
  <span class="message-item-time">๋ฉ”์‹œ์ง€ ID: ${messageId}</span>
842
  </div>
843
  <div class="message-item-content">${content}</div>
 
844
  </div>
845
  </div>
846
  `;
@@ -912,6 +1004,8 @@
912
  window.addEventListener('load', () => {
913
  loadUsers();
914
  loadSessions();
 
 
915
  });
916
  </script>
917
  </body>
 
474
  <a href="{{ url_for('main.admin_files') }}" class="btn btn-secondary">ํŒŒ์ผ ๋ชฉ๋ก</a>
475
  <a href="{{ url_for('main.admin_prompts') }}" class="btn btn-secondary">ํ”„๋กฌํ”„ํŠธ ๊ด€๋ฆฌ</a>
476
  <a href="{{ url_for('main.admin_settings') }}" class="btn btn-secondary">AI ์„ค์ •</a>
477
+ <a href="{{ url_for('main.admin_utils') }}" class="btn btn-secondary">์œ ํ‹ธ</a>
478
+ <a href="{{ url_for('main.admin_tokens') }}" class="btn btn-secondary">ํ† ํฐ ํ†ต๊ณ„</a>
479
+ <a href="{{ url_for('main.admin_utils') }}" class="btn btn-secondary">์œ ํ‹ธ</a>
480
  <a href="{{ url_for('main.index') }}" class="btn btn-secondary">๋ฉ”์ธ์œผ๋กœ</a>
481
  <a href="{{ url_for('main.logout') }}" class="btn btn-secondary">๋กœ๊ทธ์•„์›ƒ</a>
482
  </div>
 
496
  <a href="{{ url_for('main.admin_files') }}" class="mobile-menu-item" onclick="closeMobileMenu()">ํŒŒ์ผ ๋ชฉ๋ก</a>
497
  <a href="{{ url_for('main.admin_prompts') }}" class="mobile-menu-item" onclick="closeMobileMenu()">ํ”„๋กฌํ”„ํŠธ ๊ด€๋ฆฌ</a>
498
  <a href="{{ url_for('main.admin_settings') }}" class="mobile-menu-item" onclick="closeMobileMenu()">AI ์„ค์ •</a>
499
+ <a href="{{ url_for('main.admin_utils') }}" class="mobile-menu-item" onclick="closeMobileMenu()">์œ ํ‹ธ</a>
500
+ <a href="{{ url_for('main.admin_tokens') }}" class="mobile-menu-item" onclick="closeMobileMenu()">ํ† ํฐ ํ†ต๊ณ„</a>
501
+ <a href="{{ url_for('main.admin_utils') }}" class="mobile-menu-item" onclick="closeMobileMenu()">์œ ํ‹ธ</a>
502
  <a href="{{ url_for('main.index') }}" class="mobile-menu-item" onclick="closeMobileMenu()">๋ฉ”์ธ์œผ๋กœ</a>
503
  <a href="{{ url_for('main.logout') }}" class="mobile-menu-item" onclick="closeMobileMenu()">๋กœ๊ทธ์•„์›ƒ</a>
504
  </div>
 
582
  <th>์„ธ์…˜ ID</th>
583
  <th>์—ญํ• </th>
584
  <th>๋‚ด์šฉ</th>
585
+ <th>๋ชจ๋ธ</th>
586
+ <th>์ž…๋ ฅ ํ† ํฐ</th>
587
+ <th>์ถœ๋ ฅ ํ† ํฐ</th>
588
  <th>์‹œ๊ฐ„</th>
589
  <th>์ž‘์—…</th>
590
  </tr>
591
  </thead>
592
  <tbody id="messagesTableBody">
593
  <tr>
594
+ <td colspan="9" style="text-align: center; padding: 20px; color: #5f6368;">๋กœ๋”ฉ ์ค‘...</td>
595
  </tr>
596
  </tbody>
597
  </table>
 
727
  // ๋ฉ”์‹œ์ง€ ๋ชฉ๋ก ๋กœ๋“œ
728
  async function loadMessages(page = 1) {
729
  const tbody = document.getElementById('messagesTableBody');
730
+ tbody.innerHTML = '<tr><td colspan="9" style="text-align: center; padding: 20px; color: #5f6368;">๋กœ๋”ฉ ์ค‘...</td></tr>';
731
 
732
  try {
733
  const sessionId = document.getElementById('sessionIdFilter').value;
 
758
  const row = document.createElement('tr');
759
  const date = new Date(msg.created_at).toLocaleString('ko-KR');
760
  const contentPreview = msg.content && msg.content.length > 100 ? msg.content.substring(0, 100) + '...' : (msg.content || '');
761
+ const modelName = msg.model_name || '-';
762
+ const inputTokens = msg.input_tokens !== null && msg.input_tokens !== undefined ? msg.input_tokens.toLocaleString() : '-';
763
+ const outputTokens = msg.output_tokens !== null && msg.output_tokens !== undefined ? msg.output_tokens.toLocaleString() : '-';
764
 
765
  row.innerHTML = `
766
  <td>${msg.id}</td>
767
  <td>${msg.session_id}</td>
768
  <td><span class="role-badge role-${msg.role}">${msg.role === 'user' ? '์‚ฌ์šฉ์ž' : 'AI'}</span></td>
769
  <td class="message-content">${escapeHtml(contentPreview)}</td>
770
+ <td>${modelName}</td>
771
+ <td style="text-align: right;">${inputTokens}</td>
772
+ <td style="text-align: right;">${outputTokens}</td>
773
  <td>${date}</td>
774
  <td>
775
  <button class="btn btn-secondary" onclick="viewMessageById(${msg.id})" style="padding: 4px 8px; font-size: 12px;">์ƒ์„ธ ๋ณด๊ธฐ</button>
 
779
  row.setAttribute('data-message-id', msg.id);
780
  row.setAttribute('data-message-role', msg.role);
781
  row.setAttribute('data-message-content', escapeHtml(msg.content || ''));
782
+ row.setAttribute('data-message-model', modelName);
783
+ row.setAttribute('data-message-input-tokens', msg.input_tokens || '');
784
+ row.setAttribute('data-message-output-tokens', msg.output_tokens || '');
785
  tbody.appendChild(row);
786
  });
787
  } else {
788
  console.log('[๋ฉ”์‹œ์ง€ ๋ชฉ๋ก] ๋ฉ”์‹œ์ง€๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค');
789
+ tbody.innerHTML = '<tr><td colspan="9" style="text-align: center; padding: 20px; color: #5f6368;">๋ฉ”์‹œ์ง€๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค.</td></tr>';
790
  }
791
 
792
  // ํŽ˜์ด์ง€๋„ค์ด์…˜
 
801
  } catch (error) {
802
  console.error('[๋ฉ”์‹œ์ง€ ๋ชฉ๋ก] ์กฐํšŒ ์˜ค๋ฅ˜:', error);
803
  showAlert(`๋ฉ”์‹œ์ง€ ์กฐํšŒ ์˜ค๋ฅ˜: ${error.message}`, 'error');
804
+ tbody.innerHTML = `<tr><td colspan="9" style="text-align: center; padding: 20px; color: #ea4335;">๋ฉ”์‹œ์ง€ ์กฐํšŒ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค.<br><small>${error.message || '์•Œ ์ˆ˜ ์—†๋Š” ์˜ค๋ฅ˜'}</small></td></tr>`;
805
  }
806
  }
807
 
 
820
 
821
  if (data.messages && data.messages.length > 0) {
822
  const msg = data.messages[0];
823
+ const modelName = msg.model_name || '-';
824
+ const inputTokens = msg.input_tokens;
825
+ const outputTokens = msg.output_tokens;
826
+
827
+ // ๋ฉ”์‹œ์ง€ ์ƒ์„ธ ๋ณด๊ธฐ (ํ† ํฐ ์ •๋ณด ํฌํ•จ)
828
+ const modal = document.getElementById('messageModal');
829
+ const modalContent = document.getElementById('messageModalContent');
830
+
831
+ let tokenInfo = '';
832
+ if (msg.role === 'ai' && (inputTokens || outputTokens)) {
833
+ tokenInfo = `
834
+ <div style="margin-top: 16px; padding: 12px; background: #f8f9fa; border-radius: 6px;">
835
+ <div style="font-size: 14px; font-weight: 500; margin-bottom: 8px; color: #5f6368;">ํ† ํฐ ์‚ฌ์šฉ๋Ÿ‰</div>
836
+ <div style="display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px;">
837
+ <div>
838
+ <div style="font-size: 12px; color: #5f6368; margin-bottom: 4px;">๋ชจ๋ธ</div>
839
+ <div style="font-size: 16px; font-weight: 500;">${modelName}</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;">${inputTokens ? parseInt(inputTokens).toLocaleString() : '-'}</div>
844
+ </div>
845
+ <div>
846
+ <div style="font-size: 12px; color: #5f6368; margin-bottom: 4px;">์ถœ๋ ฅ ํ† ํฐ</div>
847
+ <div style="font-size: 16px; font-weight: 500;">${outputTokens ? parseInt(outputTokens).toLocaleString() : '-'}</div>
848
+ </div>
849
+ </div>
850
+ </div>
851
+ `;
852
+ }
853
+
854
+ modalContent.innerHTML = `
855
+ <div class="message-view">
856
+ <div class="message-item ${msg.role}">
857
+ <div class="message-item-header">
858
+ <span class="message-item-role role-badge role-${msg.role}">${msg.role === 'user' ? '์‚ฌ์šฉ์ž' : 'AI'}</span>
859
+ <span class="message-item-time">๋ฉ”์‹œ์ง€ ID: ${msg.id}</span>
860
+ </div>
861
+ <div class="message-item-content">${escapeHtml(msg.content || '')}</div>
862
+ ${tokenInfo}
863
+ </div>
864
+ </div>
865
+ `;
866
+
867
+ modal.classList.add('active');
868
  } else {
869
  // ๋ฐ์ดํ„ฐ ์†์„ฑ์—์„œ ๊ฐ€์ ธ์˜ค๊ธฐ (fallback)
870
  const row = document.querySelector(`tr[data-message-id="${messageId}"]`);
 
895
  const modal = document.getElementById('messageModal');
896
  const modalContent = document.getElementById('messageModalContent');
897
 
898
+ // ๋ฐ์ดํ„ฐ ์†์„ฑ์—์„œ ํ† ํฐ ์ •๋ณด ๊ฐ€์ ธ์˜ค๊ธฐ
899
+ const row = document.querySelector(`tr[data-message-id="${messageId}"]`);
900
+ const modelName = row ? row.getAttribute('data-message-model') : '-';
901
+ const inputTokens = row ? row.getAttribute('data-message-input-tokens') : '';
902
+ const outputTokens = row ? row.getAttribute('data-message-output-tokens') : '';
903
+
904
+ let tokenInfo = '';
905
+ if (role === 'ai' && (inputTokens || outputTokens)) {
906
+ tokenInfo = `
907
+ <div style="margin-top: 16px; padding: 12px; background: #f8f9fa; border-radius: 6px;">
908
+ <div style="font-size: 14px; font-weight: 500; margin-bottom: 8px; color: #5f6368;">ํ† ํฐ ์‚ฌ์šฉ๋Ÿ‰</div>
909
+ <div style="display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px;">
910
+ <div>
911
+ <div style="font-size: 12px; color: #5f6368; margin-bottom: 4px;">๋ชจ๋ธ</div>
912
+ <div style="font-size: 16px; font-weight: 500;">${modelName}</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;">${inputTokens ? parseInt(inputTokens).toLocaleString() : '-'}</div>
917
+ </div>
918
+ <div>
919
+ <div style="font-size: 12px; color: #5f6368; margin-bottom: 4px;">์ถœ๋ ฅ ํ† ํฐ</div>
920
+ <div style="font-size: 16px; font-weight: 500;">${outputTokens ? parseInt(outputTokens).toLocaleString() : '-'}</div>
921
+ </div>
922
+ </div>
923
+ </div>
924
+ `;
925
+ }
926
+
927
  modalContent.innerHTML = `
928
  <div class="message-view">
929
  <div class="message-item ${role}">
 
932
  <span class="message-item-time">๋ฉ”์‹œ์ง€ ID: ${messageId}</span>
933
  </div>
934
  <div class="message-item-content">${content}</div>
935
+ ${tokenInfo}
936
  </div>
937
  </div>
938
  `;
 
1004
  window.addEventListener('load', () => {
1005
  loadUsers();
1006
  loadSessions();
1007
+ // ์„ธ์…˜ ID๊ฐ€ ์—†์–ด๋„ ์ „์ฒด ๋ฉ”์‹œ์ง€ ๋กœ๋“œ
1008
+ loadMessages(1);
1009
  });
1010
  </script>
1011
  </body>
templates/admin_prompts.html CHANGED
@@ -328,6 +328,9 @@
328
  <a href="{{ url_for('main.admin_files') }}" class="btn btn-secondary">ํŒŒ์ผ ๋ชฉ๋ก</a>
329
  <a href="{{ url_for('main.admin_messages') }}" class="btn btn-secondary">๋ฉ”์‹œ์ง€ ํ™•์ธ</a>
330
  <a href="{{ url_for('main.admin_settings') }}" class="btn btn-secondary">AI ์„ค์ •</a>
 
 
 
331
  <a href="{{ url_for('main.index') }}" class="btn btn-secondary">๋ฉ”์ธ์œผ๋กœ</a>
332
  <a href="{{ url_for('main.logout') }}" class="btn btn-secondary">๋กœ๊ทธ์•„์›ƒ</a>
333
  </div>
@@ -347,6 +350,9 @@
347
  <a href="{{ url_for('main.admin_files') }}" class="mobile-menu-item" onclick="closeMobileMenu()">ํŒŒ์ผ ๋ชฉ๋ก</a>
348
  <a href="{{ url_for('main.admin_messages') }}" class="mobile-menu-item" onclick="closeMobileMenu()">๋ฉ”์‹œ์ง€ ํ™•์ธ</a>
349
  <a href="{{ url_for('main.admin_settings') }}" class="mobile-menu-item" onclick="closeMobileMenu()">AI ์„ค์ •</a>
 
 
 
350
  <a href="{{ url_for('main.index') }}" class="mobile-menu-item" onclick="closeMobileMenu()">๋ฉ”์ธ์œผ๋กœ</a>
351
  <a href="{{ url_for('main.logout') }}" class="mobile-menu-item" onclick="closeMobileMenu()">๋กœ๊ทธ์•„์›ƒ</a>
352
  </div>
 
328
  <a href="{{ url_for('main.admin_files') }}" class="btn btn-secondary">ํŒŒ์ผ ๋ชฉ๋ก</a>
329
  <a href="{{ url_for('main.admin_messages') }}" class="btn btn-secondary">๋ฉ”์‹œ์ง€ ํ™•์ธ</a>
330
  <a href="{{ url_for('main.admin_settings') }}" class="btn btn-secondary">AI ์„ค์ •</a>
331
+ <a href="{{ url_for('main.admin_utils') }}" class="btn btn-secondary">์œ ํ‹ธ</a>
332
+ <a href="{{ url_for('main.admin_tokens') }}" class="btn btn-secondary">ํ† ํฐ ํ†ต๊ณ„</a>
333
+ <a href="{{ url_for('main.admin_utils') }}" class="btn btn-secondary">์œ ํ‹ธ</a>
334
  <a href="{{ url_for('main.index') }}" class="btn btn-secondary">๋ฉ”์ธ์œผ๋กœ</a>
335
  <a href="{{ url_for('main.logout') }}" class="btn btn-secondary">๋กœ๊ทธ์•„์›ƒ</a>
336
  </div>
 
350
  <a href="{{ url_for('main.admin_files') }}" class="mobile-menu-item" onclick="closeMobileMenu()">ํŒŒ์ผ ๋ชฉ๋ก</a>
351
  <a href="{{ url_for('main.admin_messages') }}" class="mobile-menu-item" onclick="closeMobileMenu()">๋ฉ”์‹œ์ง€ ํ™•์ธ</a>
352
  <a href="{{ url_for('main.admin_settings') }}" class="mobile-menu-item" onclick="closeMobileMenu()">AI ์„ค์ •</a>
353
+ <a href="{{ url_for('main.admin_utils') }}" class="mobile-menu-item" onclick="closeMobileMenu()">์œ ํ‹ธ</a>
354
+ <a href="{{ url_for('main.admin_tokens') }}" class="mobile-menu-item" onclick="closeMobileMenu()">ํ† ํฐ ํ†ต๊ณ„</a>
355
+ <a href="{{ url_for('main.admin_utils') }}" class="mobile-menu-item" onclick="closeMobileMenu()">์œ ํ‹ธ</a>
356
  <a href="{{ url_for('main.index') }}" class="mobile-menu-item" onclick="closeMobileMenu()">๋ฉ”์ธ์œผ๋กœ</a>
357
  <a href="{{ url_for('main.logout') }}" class="mobile-menu-item" onclick="closeMobileMenu()">๋กœ๊ทธ์•„์›ƒ</a>
358
  </div>
templates/admin_settings.html CHANGED
@@ -291,6 +291,10 @@
291
  <a href="{{ url_for('main.admin_files') }}" class="btn btn-secondary">ํŒŒ์ผ ๋ชฉ๋ก</a>
292
  <a href="{{ url_for('main.admin_messages') }}" class="btn btn-secondary">๋ฉ”์‹œ์ง€ ํ™•์ธ</a>
293
  <a href="{{ url_for('main.admin_prompts') }}" class="btn btn-secondary">ํ”„๋กฌํ”„ํŠธ ๊ด€๋ฆฌ</a>
 
 
 
 
294
  <a href="{{ url_for('main.index') }}" class="btn btn-secondary">๋ฉ”์ธ์œผ๋กœ</a>
295
  <a href="{{ url_for('main.logout') }}" class="btn btn-secondary">๋กœ๊ทธ์•„์›ƒ</a>
296
  </div>
@@ -310,6 +314,10 @@
310
  <a href="{{ url_for('main.admin_files') }}" class="mobile-menu-item" onclick="closeMobileMenu()">ํŒŒ์ผ ๋ชฉ๋ก</a>
311
  <a href="{{ url_for('main.admin_messages') }}" class="mobile-menu-item" onclick="closeMobileMenu()">๋ฉ”์‹œ์ง€ ํ™•์ธ</a>
312
  <a href="{{ url_for('main.admin_prompts') }}" class="mobile-menu-item" onclick="closeMobileMenu()">ํ”„๋กฌํ”„ํŠธ ๊ด€๋ฆฌ</a>
 
 
 
 
313
  <a href="{{ url_for('main.index') }}" class="mobile-menu-item" onclick="closeMobileMenu()">๋ฉ”์ธ์œผ๋กœ</a>
314
  <a href="{{ url_for('main.logout') }}" class="mobile-menu-item" onclick="closeMobileMenu()">๋กœ๊ทธ์•„์›ƒ</a>
315
  </div>
@@ -366,6 +374,43 @@
366
  </div>
367
  </div>
368
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
369
  <!-- AI ๋ชจ๋ธ๋ณ„ ํ† ํฐ ์ˆ˜ ๊ด€๋ฆฌ ์„น์…˜ -->
370
  <div class="card">
371
  <div class="card-header">
@@ -845,11 +890,158 @@
845
  return id;
846
  }
847
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
848
  // ํŽ˜์ด์ง€ ๋กœ๋“œ ์‹œ API ํ‚ค ์ƒํƒœ ํ™•์ธ ๋ฐ ํ† ํฐ ์ˆ˜ ์„ค์ • ๋กœ๋“œ
849
  window.addEventListener('load', () => {
850
  loadGeminiApiKey();
851
  loadHuggingFaceToken();
852
  loadModelTokens();
 
853
  });
854
  </script>
855
  </body>
 
291
  <a href="{{ url_for('main.admin_files') }}" class="btn btn-secondary">ํŒŒ์ผ ๋ชฉ๋ก</a>
292
  <a href="{{ url_for('main.admin_messages') }}" class="btn btn-secondary">๋ฉ”์‹œ์ง€ ํ™•์ธ</a>
293
  <a href="{{ url_for('main.admin_prompts') }}" class="btn btn-secondary">ํ”„๋กฌํ”„ํŠธ ๊ด€๋ฆฌ</a>
294
+ <a href="{{ url_for('main.admin_settings') }}" class="btn btn-secondary">AI ์„ค์ •</a>
295
+ <a href="{{ url_for('main.admin_utils') }}" class="btn btn-secondary">์œ ํ‹ธ</a>
296
+ <a href="{{ url_for('main.admin_tokens') }}" class="btn btn-secondary">ํ† ํฐ ํ†ต๊ณ„</a>
297
+ <a href="{{ url_for('main.admin_utils') }}" class="btn btn-secondary">์œ ํ‹ธ</a>
298
  <a href="{{ url_for('main.index') }}" class="btn btn-secondary">๋ฉ”์ธ์œผ๋กœ</a>
299
  <a href="{{ url_for('main.logout') }}" class="btn btn-secondary">๋กœ๊ทธ์•„์›ƒ</a>
300
  </div>
 
314
  <a href="{{ url_for('main.admin_files') }}" class="mobile-menu-item" onclick="closeMobileMenu()">ํŒŒ์ผ ๋ชฉ๋ก</a>
315
  <a href="{{ url_for('main.admin_messages') }}" class="mobile-menu-item" onclick="closeMobileMenu()">๋ฉ”์‹œ์ง€ ํ™•์ธ</a>
316
  <a href="{{ url_for('main.admin_prompts') }}" class="mobile-menu-item" onclick="closeMobileMenu()">ํ”„๋กฌํ”„ํŠธ ๊ด€๋ฆฌ</a>
317
+ <a href="{{ url_for('main.admin_settings') }}" class="mobile-menu-item" onclick="closeMobileMenu()">AI ์„ค์ •</a>
318
+ <a href="{{ url_for('main.admin_utils') }}" class="mobile-menu-item" onclick="closeMobileMenu()">์œ ํ‹ธ</a>
319
+ <a href="{{ url_for('main.admin_tokens') }}" class="mobile-menu-item" onclick="closeMobileMenu()">ํ† ํฐ ํ†ต๊ณ„</a>
320
+ <a href="{{ url_for('main.admin_utils') }}" class="mobile-menu-item" onclick="closeMobileMenu()">์œ ํ‹ธ</a>
321
  <a href="{{ url_for('main.index') }}" class="mobile-menu-item" onclick="closeMobileMenu()">๋ฉ”์ธ์œผ๋กœ</a>
322
  <a href="{{ url_for('main.logout') }}" class="mobile-menu-item" onclick="closeMobileMenu()">๋กœ๊ทธ์•„์›ƒ</a>
323
  </div>
 
374
  </div>
375
  </div>
376
 
377
+ <!-- ๊ธฐ๋ณธ AI ๋ชจ๋ธ ์„ค์ • ์„น์…˜ -->
378
+ <div class="card">
379
+ <div class="card-header">
380
+ <div class="card-title">๊ธฐ๋ณธ AI ๋ชจ๋ธ ์„ค์ •</div>
381
+ <button class="btn btn-secondary" onclick="loadDefaultModels()">์ƒˆ๋กœ๊ณ ์นจ</button>
382
+ </div>
383
+ <div style="padding: 16px 0;">
384
+ <div id="defaultModelsStatus" style="margin-bottom: 16px; font-size: 13px;"></div>
385
+ <div style="display: grid; gap: 16px;">
386
+ <div class="form-group">
387
+ <label for="defaultAnalysisModel">๊ธฐ๋ณธ ์งˆ๋ฌธ ๋ถ„์„์šฉ AI ๋ชจ๋ธ (AI ๋ชจ๋ธ ์„ ํƒ)</label>
388
+ <div style="display: flex; gap: 8px;">
389
+ <select id="defaultAnalysisModel" style="flex: 1; padding: 8px 12px; border: 1px solid #dadce0; border-radius: 4px; font-size: 14px;">
390
+ <option value="">๊ธฐ๋ณธ๊ฐ’ ์—†์Œ</option>
391
+ </select>
392
+ <button class="btn btn-primary" onclick="saveDefaultModels()">์ €์žฅ</button>
393
+ </div>
394
+ <small style="color: #5f6368; font-size: 12px; display: block; margin-top: 4px;">
395
+ ์‚ฌ์šฉ์ž ํ™”๋ฉด์˜ "AI ๋ชจ๋ธ ์„ ํƒ" ๋“œ๋กญ๋‹ค์šด์—์„œ ๊ธฐ๋ณธ์œผ๋กœ ์„ ํƒ๋  ๋ชจ๋ธ์ž…๋‹ˆ๋‹ค.
396
+ </small>
397
+ </div>
398
+ <div class="form-group">
399
+ <label for="defaultAnswerModel">๊ธฐ๋ณธ ๋‹ต๋ณ€ ์ƒ์„ฑ์šฉ AI ๋ชจ๋ธ (์‚ฌ์šฉ ๊ฐ€๋Šฅํ•œ AI ๋ชฉ๋ก)</label>
400
+ <div style="display: flex; gap: 8px;">
401
+ <select id="defaultAnswerModel" style="flex: 1; padding: 8px 12px; border: 1px solid #dadce0; border-radius: 4px; font-size: 14px;">
402
+ <option value="">๊ธฐ๋ณธ๊ฐ’ ์—†์Œ</option>
403
+ </select>
404
+ <button class="btn btn-primary" onclick="saveDefaultModels()">์ €์žฅ</button>
405
+ </div>
406
+ <small style="color: #5f6368; font-size: 12px; display: block; margin-top: 4px;">
407
+ ์‚ฌ์šฉ์ž ํ™”๋ฉด์˜ "์‚ฌ์šฉ ๊ฐ€๋Šฅํ•œ AI ๋ชฉ๋ก" ๋“œ๋กญ๋‹ค์šด์—์„œ ๊ธฐ๋ณธ์œผ๋กœ ์„ ํƒ๋  ๋ชจ๋ธ์ž…๋‹ˆ๋‹ค.
408
+ </small>
409
+ </div>
410
+ </div>
411
+ </div>
412
+ </div>
413
+
414
  <!-- AI ๋ชจ๋ธ๋ณ„ ํ† ํฐ ์ˆ˜ ๊ด€๋ฆฌ ์„น์…˜ -->
415
  <div class="card">
416
  <div class="card-header">
 
890
  return id;
891
  }
892
 
893
+ // ๊ธฐ๋ณธ AI ๋ชจ๋ธ ์„ค์ • ๊ด€๋ จ ํ•จ์ˆ˜
894
+ async function loadDefaultModels() {
895
+ const statusDiv = document.getElementById('defaultModelsStatus');
896
+ const analysisSelect = document.getElementById('defaultAnalysisModel');
897
+ const answerSelect = document.getElementById('defaultAnswerModel');
898
+
899
+ try {
900
+ statusDiv.innerHTML = '<span style="color: #1a73e8;">๊ธฐ๋ณธ ๋ชจ๋ธ ์„ค์ •์„ ๋ถˆ๋Ÿฌ์˜ค๋Š” ์ค‘...</span>';
901
+
902
+ // ๋ชจ๋ธ ๋ชฉ๋ก ๊ฐ€์ ธ์˜ค๊ธฐ
903
+ const modelsResponse = await fetch('/api/admin/ollama/models', {
904
+ credentials: 'include'
905
+ });
906
+
907
+ if (!modelsResponse.ok) {
908
+ throw new Error('๋ชจ๋ธ ๋ชฉ๋ก์„ ๋ถˆ๋Ÿฌ์˜ฌ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.');
909
+ }
910
+
911
+ const modelsData = await modelsResponse.json();
912
+
913
+ // ๋“œ๋กญ๋‹ค์šด ์ดˆ๊ธฐํ™”
914
+ analysisSelect.innerHTML = '<option value="">๊ธฐ๋ณธ๊ฐ’ ์—†์Œ</option>';
915
+ answerSelect.innerHTML = '<option value="">๊ธฐ๋ณธ๊ฐ’ ์—†์Œ</option>';
916
+
917
+ // ๋ชจ๋ธ์„ ํƒ€์ž…๋ณ„๋กœ ๊ทธ๋ฃนํ™”
918
+ const ollamaModels = [];
919
+ const geminiModels = [];
920
+
921
+ if (modelsData.models && modelsData.models.length > 0) {
922
+ modelsData.models.forEach(model => {
923
+ if (model.type === 'ollama') {
924
+ ollamaModels.push(model);
925
+ } else if (model.type === 'gemini') {
926
+ geminiModels.push(model);
927
+ }
928
+ });
929
+ }
930
+
931
+ // Ollama ๋ชจ๋ธ ์ถ”๊ฐ€
932
+ if (ollamaModels.length > 0) {
933
+ const optgroup1 = document.createElement('optgroup');
934
+ optgroup1.label = 'Ollama ๋ชจ๋ธ';
935
+ ollamaModels.forEach(model => {
936
+ const option = document.createElement('option');
937
+ option.value = model.name;
938
+ option.textContent = model.name;
939
+ optgroup1.appendChild(option);
940
+ });
941
+ analysisSelect.appendChild(optgroup1);
942
+
943
+ const optgroup2 = document.createElement('optgroup');
944
+ optgroup2.label = 'Ollama ๋ชจ๋ธ';
945
+ ollamaModels.forEach(model => {
946
+ const option = document.createElement('option');
947
+ option.value = model.name;
948
+ option.textContent = model.name;
949
+ optgroup2.appendChild(option);
950
+ });
951
+ answerSelect.appendChild(optgroup2);
952
+ }
953
+
954
+ // Gemini ๋ชจ๋ธ ์ถ”๊ฐ€
955
+ if (geminiModels.length > 0) {
956
+ const optgroup1 = document.createElement('optgroup');
957
+ optgroup1.label = 'Gemini ๋ชจ๋ธ';
958
+ geminiModels.forEach(model => {
959
+ const option = document.createElement('option');
960
+ option.value = model.name;
961
+ option.textContent = model.name.replace('gemini:', '');
962
+ optgroup1.appendChild(option);
963
+ });
964
+ analysisSelect.appendChild(optgroup1);
965
+
966
+ const optgroup2 = document.createElement('optgroup');
967
+ optgroup2.label = 'Gemini ๋ชจ๋ธ';
968
+ geminiModels.forEach(model => {
969
+ const option = document.createElement('option');
970
+ option.value = model.name;
971
+ option.textContent = model.name.replace('gemini:', '');
972
+ optgroup2.appendChild(option);
973
+ });
974
+ answerSelect.appendChild(optgroup2);
975
+ }
976
+
977
+ // ํ˜„์žฌ ์„ค์ •๋œ ๊ธฐ๋ณธ ๋ชจ๋ธ ๊ฐ€์ ธ์˜ค๊ธฐ
978
+ const defaultResponse = await fetch('/api/admin/default-models', {
979
+ credentials: 'include'
980
+ });
981
+
982
+ if (defaultResponse.ok) {
983
+ const defaultData = await defaultResponse.json();
984
+ if (defaultData.default_analysis_model) {
985
+ analysisSelect.value = defaultData.default_analysis_model;
986
+ }
987
+ if (defaultData.default_answer_model) {
988
+ answerSelect.value = defaultData.default_answer_model;
989
+ }
990
+ statusDiv.innerHTML = '<span style="color: #137333;">โœ“ ๊ธฐ๋ณธ ๋ชจ๋ธ ์„ค์ •์„ ๋ถˆ๋Ÿฌ์™”์Šต๋‹ˆ๋‹ค.</span>';
991
+ } else {
992
+ statusDiv.innerHTML = '<span style="color: #ea4335;">๊ธฐ๋ณธ ๋ชจ๋ธ ์„ค์ •์„ ๋ถˆ๋Ÿฌ์˜ฌ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.</span>';
993
+ }
994
+ } catch (error) {
995
+ console.error('๊ธฐ๋ณธ ๋ชจ๋ธ ์„ค์ • ๋กœ๋“œ ์˜ค๋ฅ˜:', error);
996
+ statusDiv.innerHTML = `<span style="color: #ea4335;">์˜ค๋ฅ˜: ${error.message}</span>`;
997
+ }
998
+ }
999
+
1000
+ async function saveDefaultModels() {
1001
+ const analysisSelect = document.getElementById('defaultAnalysisModel');
1002
+ const answerSelect = document.getElementById('defaultAnswerModel');
1003
+ const statusDiv = document.getElementById('defaultModelsStatus');
1004
+
1005
+ const defaultAnalysisModel = analysisSelect.value;
1006
+ const defaultAnswerModel = answerSelect.value;
1007
+
1008
+ try {
1009
+ statusDiv.innerHTML = '<span style="color: #1a73e8;">์ €์žฅ ์ค‘...</span>';
1010
+
1011
+ const response = await fetch('/api/admin/default-models', {
1012
+ method: 'POST',
1013
+ headers: {
1014
+ 'Content-Type': 'application/json',
1015
+ },
1016
+ credentials: 'include',
1017
+ body: JSON.stringify({
1018
+ default_analysis_model: defaultAnalysisModel,
1019
+ default_answer_model: defaultAnswerModel
1020
+ })
1021
+ });
1022
+
1023
+ const data = await response.json();
1024
+
1025
+ if (response.ok) {
1026
+ showAlert(data.message || '๊ธฐ๋ณธ AI ๋ชจ๋ธ์ด ์ €์žฅ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.', 'success');
1027
+ statusDiv.innerHTML = '<span style="color: #137333;">โœ“ ๊ธฐ๋ณธ ๋ชจ๋ธ ์„ค์ •์ด ์ €์žฅ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.</span>';
1028
+ } else {
1029
+ showAlert(data.error || '๊ธฐ๋ณธ ๋ชจ๋ธ ์„ค์ • ์ €์žฅ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค.', 'error');
1030
+ statusDiv.innerHTML = `<span style="color: #ea4335;">์˜ค๋ฅ˜: ${data.error || '์•Œ ์ˆ˜ ์—†๋Š” ์˜ค๋ฅ˜'}</span>`;
1031
+ }
1032
+ } catch (error) {
1033
+ console.error('๊ธฐ๋ณธ ๋ชจ๋ธ ์„ค์ • ์ €์žฅ ์˜ค๋ฅ˜:', error);
1034
+ showAlert(`์˜ค๋ฅ˜: ${error.message}`, 'error');
1035
+ statusDiv.innerHTML = `<span style="color: #ea4335;">์˜ค๋ฅ˜: ${error.message}</span>`;
1036
+ }
1037
+ }
1038
+
1039
  // ํŽ˜์ด์ง€ ๋กœ๋“œ ์‹œ API ํ‚ค ์ƒํƒœ ํ™•์ธ ๋ฐ ํ† ํฐ ์ˆ˜ ์„ค์ • ๋กœ๋“œ
1040
  window.addEventListener('load', () => {
1041
  loadGeminiApiKey();
1042
  loadHuggingFaceToken();
1043
  loadModelTokens();
1044
+ loadDefaultModels();
1045
  });
1046
  </script>
1047
  </body>
templates/admin_tokens.html ADDED
@@ -0,0 +1,711 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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.admin_tokens') }}" class="btn btn-secondary">ํ† ํฐ ํ†ต๊ณ„</a>
352
+ <a href="{{ url_for('main.index') }}" class="btn btn-secondary">๋ฉ”์ธ์œผ๋กœ</a>
353
+ <a href="{{ url_for('main.logout') }}" class="btn btn-secondary">๋กœ๊ทธ์•„์›ƒ</a>
354
+ </div>
355
+ </div>
356
+
357
+ <!-- ๋ชจ๋ฐ”์ผ ๋ฉ”๋‰ด -->
358
+ <div class="mobile-menu" id="mobileMenu" onclick="closeMobileMenuOnBackdrop(event)">
359
+ <div class="mobile-menu-content" onclick="event.stopPropagation()">
360
+ <div class="mobile-menu-header">
361
+ <div class="mobile-menu-title">๋ฉ”๋‰ด</div>
362
+ <button class="mobile-menu-close" onclick="toggleMobileMenu()" aria-label="๋ฉ”๋‰ด ๋‹ซ๊ธฐ">&times;</button>
363
+ </div>
364
+ <div class="mobile-menu-user">{{ current_user.nickname or current_user.username }}</div>
365
+ <div class="mobile-menu-items">
366
+ <a href="{{ url_for('main.admin') }}" class="mobile-menu-item" onclick="closeMobileMenu()">์‚ฌ์šฉ์ž ๊ด€๋ฆฌ</a>
367
+ <a href="{{ url_for('main.admin_webnovels') }}" class="mobile-menu-item" onclick="closeMobileMenu()">์›น์†Œ์„ค ๊ด€๋ฆฌ</a>
368
+ <a href="{{ url_for('main.admin_files') }}" class="mobile-menu-item" onclick="closeMobileMenu()">ํŒŒ์ผ ๋ชฉ๋ก</a>
369
+ <a href="{{ url_for('main.admin_messages') }}" class="mobile-menu-item" onclick="closeMobileMenu()">๋ฉ”์‹œ์ง€ ํ™•์ธ</a>
370
+ <a href="{{ url_for('main.admin_prompts') }}" class="mobile-menu-item" onclick="closeMobileMenu()">ํ”„๋กฌํ”„ํŠธ ๊ด€๋ฆฌ</a>
371
+ <a href="{{ url_for('main.admin_settings') }}" class="mobile-menu-item" onclick="closeMobileMenu()">AI ์„ค์ •</a>
372
+ <a href="{{ url_for('main.admin_utils') }}" class="mobile-menu-item" onclick="closeMobileMenu()">์œ ํ‹ธ</a>
373
+ <a href="{{ url_for('main.admin_tokens') }}" class="mobile-menu-item" onclick="closeMobileMenu()">ํ† ํฐ ํ†ต๊ณ„</a>
374
+ <a href="{{ url_for('main.index') }}" class="mobile-menu-item" onclick="closeMobileMenu()">๋ฉ”์ธ์œผ๋กœ</a>
375
+ <a href="{{ url_for('main.logout') }}" class="mobile-menu-item" onclick="closeMobileMenu()">๋กœ๊ทธ์•„์›ƒ</a>
376
+ </div>
377
+ </div>
378
+ </div>
379
+
380
+ <div class="container">
381
+ <div class="page-header">
382
+ <h1>ํ† ํฐ ์‚ฌ์šฉ๋Ÿ‰ ํ†ต๊ณ„</h1>
383
+ <p>AI ๋ชจ๋ธ๋ณ„ ํ† ํฐ ์‚ฌ์šฉ๋Ÿ‰์„ ์‹œ๊ฐํ™”ํ•˜์—ฌ ํ™•์ธํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.</p>
384
+ </div>
385
+
386
+ <div id="alertContainer"></div>
387
+
388
+ <!-- ํ•„ํ„ฐ -->
389
+ <div class="card">
390
+ <div class="card-header">
391
+ <div class="card-title">ํ•„ํ„ฐ</div>
392
+ </div>
393
+ <div class="filters">
394
+ <div class="filter-group">
395
+ <label for="startDate">์‹œ์ž‘ ๋‚ ์งœ</label>
396
+ <input type="date" id="startDate">
397
+ </div>
398
+ <div class="filter-group">
399
+ <label for="endDate">์ข…๋ฃŒ ๋‚ ์งœ</label>
400
+ <input type="date" id="endDate">
401
+ </div>
402
+ <div class="filter-group">
403
+ <label for="modelFilter">AI ๋ชจ๋ธ</label>
404
+ <select id="modelFilter">
405
+ <option value="">์ „์ฒด</option>
406
+ </select>
407
+ </div>
408
+ <div class="filter-group">
409
+ <label for="groupBy">๊ทธ๋ฃนํ™”</label>
410
+ <select id="groupBy">
411
+ <option value="day">์ผ๋ณ„</option>
412
+ <option value="model">๋ชจ๋ธ๋ณ„</option>
413
+ </select>
414
+ </div>
415
+ <div class="filter-group" style="justify-content: flex-end;">
416
+ <label>&nbsp;</label>
417
+ <button class="btn btn-primary" onclick="loadTokenUsage()">์กฐํšŒ</button>
418
+ </div>
419
+ </div>
420
+ </div>
421
+
422
+ <!-- ํ†ต๊ณ„ ์š”์•ฝ -->
423
+ <div class="stats-grid" id="statsGrid">
424
+ <!-- ๋™์ ์œผ๋กœ ์ƒ์„ฑ๋จ -->
425
+ </div>
426
+
427
+ <!-- ๊ทธ๋ž˜ํ”„ -->
428
+ <div class="card">
429
+ <div class="card-header">
430
+ <div class="card-title">ํ† ํฐ ์‚ฌ์šฉ๋Ÿ‰ ๊ทธ๋ž˜ํ”„</div>
431
+ </div>
432
+ <div class="chart-container">
433
+ <canvas id="tokenChart"></canvas>
434
+ </div>
435
+ </div>
436
+ </div>
437
+
438
+ <script>
439
+ let tokenChart = null;
440
+
441
+ function toggleMobileMenu() {
442
+ const menu = document.getElementById('mobileMenu');
443
+ menu.classList.toggle('active');
444
+ document.body.style.overflow = menu.classList.contains('active') ? 'hidden' : '';
445
+ }
446
+
447
+ function closeMobileMenu() {
448
+ const menu = document.getElementById('mobileMenu');
449
+ menu.classList.remove('active');
450
+ document.body.style.overflow = '';
451
+ }
452
+
453
+ function closeMobileMenuOnBackdrop(event) {
454
+ if (event.target.id === 'mobileMenu') {
455
+ closeMobileMenu();
456
+ }
457
+ }
458
+
459
+ function showAlert(message, type = 'success') {
460
+ const container = document.getElementById('alertContainer');
461
+ container.innerHTML = `<div class="alert ${type}">${message}</div>`;
462
+ setTimeout(() => {
463
+ container.innerHTML = '';
464
+ }, 5000);
465
+ }
466
+
467
+ // ๋‚ ์งœ ๊ธฐ๋ณธ๊ฐ’ ์„ค์ • (์ตœ๊ทผ 30์ผ)
468
+ function setDefaultDates() {
469
+ const endDate = new Date();
470
+ const startDate = new Date();
471
+ startDate.setDate(startDate.getDate() - 30);
472
+
473
+ document.getElementById('endDate').value = endDate.toISOString().split('T')[0];
474
+ document.getElementById('startDate').value = startDate.toISOString().split('T')[0];
475
+ }
476
+
477
+ // ํ† ํฐ ์‚ฌ์šฉ๋Ÿ‰ ์กฐํšŒ
478
+ async function loadTokenUsage() {
479
+ try {
480
+ const startDate = document.getElementById('startDate').value;
481
+ const endDate = document.getElementById('endDate').value;
482
+ const modelName = document.getElementById('modelFilter').value;
483
+ const groupBy = document.getElementById('groupBy').value;
484
+
485
+ if (!startDate || !endDate) {
486
+ showAlert('์‹œ์ž‘ ๋‚ ์งœ์™€ ์ข…๋ฃŒ ๋‚ ์งœ๋ฅผ ๋ชจ๋‘ ์„ ํƒํ•ด์ฃผ์„ธ์š”.', 'error');
487
+ return;
488
+ }
489
+
490
+ const params = new URLSearchParams({
491
+ start_date: startDate,
492
+ end_date: endDate,
493
+ group_by: groupBy
494
+ });
495
+
496
+ if (modelName) {
497
+ params.append('model_name', modelName);
498
+ }
499
+
500
+ console.log('[ํ† ํฐ ํ†ต๊ณ„] ์š”์ฒญ URL:', `/api/admin/token-usage?${params}`);
501
+ const response = await fetch(`/api/admin/token-usage?${params}`, {
502
+ method: 'GET',
503
+ credentials: 'include'
504
+ });
505
+
506
+ const data = await response.json();
507
+ console.log('[ํ† ํฐ ํ†ต๊ณ„] ์‘๋‹ต ๋ฐ์ดํ„ฐ:', data);
508
+
509
+ if (response.ok && data.success) {
510
+ if (data.stats && data.stats.length > 0) {
511
+ updateStats(data.stats, data.total_messages, data.user_usage, data.system_usage);
512
+ updateChart(data.stats, groupBy);
513
+ } else {
514
+ showAlert('์„ ํƒํ•œ ๊ธฐ๊ฐ„์— ํ† ํฐ ์ •๋ณด๊ฐ€ ์žˆ๋Š” ๋ฉ”์‹œ์ง€๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค.', 'error');
515
+ // ๋นˆ ํ†ต๊ณ„ ํ‘œ์‹œ
516
+ updateStats([], 0, null, null);
517
+ updateChart([], groupBy);
518
+ }
519
+ } else {
520
+ console.error('[ํ† ํฐ ํ†ต๊ณ„] API ์˜ค๋ฅ˜:', data);
521
+ showAlert(data.error || 'ํ† ํฐ ์‚ฌ์šฉ๋Ÿ‰ ์กฐํšŒ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค.', 'error');
522
+ }
523
+ } catch (error) {
524
+ console.error('ํ† ํฐ ์‚ฌ์šฉ๋Ÿ‰ ์กฐํšŒ ์˜ค๋ฅ˜:', error);
525
+ showAlert(`์˜ค๋ฅ˜: ${error.message}`, 'error');
526
+ }
527
+ }
528
+
529
+ // ํ†ต๊ณ„ ์š”์•ฝ ์—…๋ฐ์ดํŠธ
530
+ function updateStats(stats, totalMessages, userUsage, systemUsage) {
531
+ const statsGrid = document.getElementById('statsGrid');
532
+
533
+ let totalInput = 0;
534
+ let totalOutput = 0;
535
+ let totalTokens = 0;
536
+
537
+ if (stats && stats.length > 0) {
538
+ stats.forEach(stat => {
539
+ totalInput += stat.input_tokens || 0;
540
+ totalOutput += stat.output_tokens || 0;
541
+ totalTokens += stat.total_tokens || 0;
542
+ });
543
+ }
544
+
545
+ // ์‹œ์Šคํ…œ ์‚ฌ์šฉ ํ†ต๊ณ„
546
+ let systemInput = 0;
547
+ let systemOutput = 0;
548
+ let systemTotal = 0;
549
+ if (systemUsage) {
550
+ systemInput = systemUsage.input_tokens || 0;
551
+ systemOutput = systemUsage.output_tokens || 0;
552
+ systemTotal = systemUsage.total_tokens || 0;
553
+ }
554
+
555
+ statsGrid.innerHTML = `
556
+ <div class="stat-card">
557
+ <div class="stat-label">์ด ์ž…๋ ฅ ํ† ํฐ</div>
558
+ <div class="stat-value">${totalInput.toLocaleString()}</div>
559
+ </div>
560
+ <div class="stat-card">
561
+ <div class="stat-label">์ด ์ถœ๋ ฅ ํ† ํฐ</div>
562
+ <div class="stat-value">${totalOutput.toLocaleString()}</div>
563
+ </div>
564
+ <div class="stat-card">
565
+ <div class="stat-label">์ด ํ† ํฐ</div>
566
+ <div class="stat-value">${totalTokens.toLocaleString()}</div>
567
+ </div>
568
+ <div class="stat-card">
569
+ <div class="stat-label">์ด ๋ฉ”์‹œ์ง€ ์ˆ˜</div>
570
+ <div class="stat-value">${totalMessages.toLocaleString()}</div>
571
+ </div>
572
+ <div class="stat-card" style="border-left: 4px solid #34a853;">
573
+ <div class="stat-label">์‹œ์Šคํ…œ ์‚ฌ์šฉ (์ž…๋ ฅ)</div>
574
+ <div class="stat-value">${systemInput.toLocaleString()}</div>
575
+ </div>
576
+ <div class="stat-card" style="border-left: 4px solid #34a853;">
577
+ <div class="stat-label">์‹œ์Šคํ…œ ์‚ฌ์šฉ (์ถœ๋ ฅ)</div>
578
+ <div class="stat-value">${systemOutput.toLocaleString()}</div>
579
+ </div>
580
+ <div class="stat-card" style="border-left: 4px solid #34a853;">
581
+ <div class="stat-label">์‹œ์Šคํ…œ ์‚ฌ์šฉ (์ด)</div>
582
+ <div class="stat-value">${systemTotal.toLocaleString()}</div>
583
+ </div>
584
+ `;
585
+ }
586
+
587
+ // ๊ทธ๋ž˜ํ”„ ์—…๋ฐ์ดํŠธ
588
+ function updateChart(stats, groupBy) {
589
+ const ctx = document.getElementById('tokenChart').getContext('2d');
590
+
591
+ if (tokenChart) {
592
+ tokenChart.destroy();
593
+ }
594
+
595
+ let labels, inputData, outputData;
596
+
597
+ if (!stats || stats.length === 0) {
598
+ // ๋ฐ์ดํ„ฐ๊ฐ€ ์—†์„ ๋•Œ ๋นˆ ๊ทธ๋ž˜ํ”„ ํ‘œ์‹œ
599
+ labels = ['๋ฐ์ดํ„ฐ ์—†์Œ'];
600
+ inputData = [0];
601
+ outputData = [0];
602
+ } else if (groupBy === 'day') {
603
+ labels = stats.map(s => s.date);
604
+ inputData = stats.map(s => s.input_tokens || 0);
605
+ outputData = stats.map(s => s.output_tokens || 0);
606
+ } else if (groupBy === 'model') {
607
+ labels = stats.map(s => s.model || 'Unknown');
608
+ inputData = stats.map(s => s.input_tokens || 0);
609
+ outputData = stats.map(s => s.output_tokens || 0);
610
+ } else {
611
+ labels = ['์ „์ฒด'];
612
+ inputData = [stats[0]?.input_tokens || 0];
613
+ outputData = [stats[0]?.output_tokens || 0];
614
+ }
615
+
616
+ tokenChart = new Chart(ctx, {
617
+ type: 'line',
618
+ data: {
619
+ labels: labels,
620
+ datasets: [
621
+ {
622
+ label: '์ž…๋ ฅ ํ† ํฐ',
623
+ data: inputData,
624
+ borderColor: 'rgb(26, 115, 232)',
625
+ backgroundColor: 'rgba(26, 115, 232, 0.1)',
626
+ tension: 0.4
627
+ },
628
+ {
629
+ label: '์ถœ๋ ฅ ํ† ํฐ',
630
+ data: outputData,
631
+ borderColor: 'rgb(234, 67, 53)',
632
+ backgroundColor: 'rgba(234, 67, 53, 0.1)',
633
+ tension: 0.4
634
+ }
635
+ ]
636
+ },
637
+ options: {
638
+ responsive: true,
639
+ maintainAspectRatio: false,
640
+ plugins: {
641
+ legend: {
642
+ position: 'top',
643
+ },
644
+ title: {
645
+ display: true,
646
+ text: 'ํ† ํฐ ์‚ฌ์šฉ๋Ÿ‰ ์ถ”์ด'
647
+ }
648
+ },
649
+ scales: {
650
+ y: {
651
+ beginAtZero: true,
652
+ ticks: {
653
+ callback: function(value) {
654
+ return value.toLocaleString();
655
+ }
656
+ }
657
+ }
658
+ }
659
+ }
660
+ });
661
+ }
662
+
663
+ // ๋ชจ๋ธ ๋ชฉ๋ก ๋กœ๋“œ
664
+ async function loadModels() {
665
+ try {
666
+ // ๋‚ ์งœ ๋ฒ”์œ„๋ฅผ ๋„“๊ฒŒ ์„ค์ •ํ•˜์—ฌ ๋ชจ๋“  ๋ชจ๋ธ ์กฐํšŒ
667
+ const endDate = new Date();
668
+ const startDate = new Date();
669
+ startDate.setFullYear(startDate.getFullYear() - 1); // 1๋…„ ์ „๋ถ€ํ„ฐ
670
+
671
+ const params = new URLSearchParams({
672
+ start_date: startDate.toISOString().split('T')[0],
673
+ end_date: endDate.toISOString().split('T')[0],
674
+ group_by: 'model'
675
+ });
676
+
677
+ const response = await fetch(`/api/admin/token-usage?${params}`, {
678
+ method: 'GET',
679
+ credentials: 'include'
680
+ });
681
+
682
+ const data = await response.json();
683
+
684
+ if (response.ok && data.success && data.models) {
685
+ const modelFilter = document.getElementById('modelFilter');
686
+ // ๊ธฐ์กด ์˜ต์…˜ ์œ ์ง€ (์ „์ฒด ์˜ต์…˜)
687
+ const existingOptions = Array.from(modelFilter.options).map(opt => opt.value);
688
+ data.models.forEach(model => {
689
+ if (!existingOptions.includes(model)) {
690
+ const option = document.createElement('option');
691
+ option.value = model;
692
+ option.textContent = model;
693
+ modelFilter.appendChild(option);
694
+ }
695
+ });
696
+ }
697
+ } catch (error) {
698
+ console.error('๋ชจ๋ธ ๋ชฉ๋ก ๋กœ๋“œ ์˜ค๋ฅ˜:', error);
699
+ }
700
+ }
701
+
702
+ // ํŽ˜์ด์ง€ ๋กœ๋“œ ์‹œ ์ดˆ๊ธฐํ™”
703
+ document.addEventListener('DOMContentLoaded', function() {
704
+ setDefaultDates();
705
+ loadModels();
706
+ loadTokenUsage();
707
+ });
708
+ </script>
709
+ </body>
710
+ </html>
711
+
templates/admin_utils.html ADDED
@@ -0,0 +1,770 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ <style>
10
+ * {
11
+ margin: 0;
12
+ padding: 0;
13
+ box-sizing: border-box;
14
+ }
15
+
16
+ body {
17
+ font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
18
+ background: #f8f9fa;
19
+ color: #202124;
20
+ }
21
+
22
+ .header {
23
+ background: white;
24
+ border-bottom: 1px solid #dadce0;
25
+ padding: 16px 24px;
26
+ display: flex;
27
+ align-items: center;
28
+ justify-content: space-between;
29
+ box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
30
+ }
31
+
32
+ .header-title {
33
+ font-size: 20px;
34
+ font-weight: 500;
35
+ display: flex;
36
+ align-items: center;
37
+ gap: 12px;
38
+ }
39
+
40
+ .header-actions {
41
+ display: flex;
42
+ gap: 12px;
43
+ align-items: center;
44
+ }
45
+
46
+ .menu-toggle {
47
+ display: none;
48
+ background: none;
49
+ border: none;
50
+ font-size: 24px;
51
+ cursor: pointer;
52
+ padding: 8px;
53
+ color: #202124;
54
+ }
55
+
56
+ .mobile-menu {
57
+ display: none;
58
+ position: fixed;
59
+ top: 0;
60
+ left: 0;
61
+ right: 0;
62
+ bottom: 0;
63
+ background: rgba(0, 0, 0, 0.5);
64
+ z-index: 1000;
65
+ }
66
+
67
+ .mobile-menu.active {
68
+ display: block;
69
+ }
70
+
71
+ .mobile-menu-content {
72
+ position: fixed;
73
+ top: 0;
74
+ right: -100%;
75
+ width: 280px;
76
+ max-width: 80%;
77
+ height: 100%;
78
+ background: white;
79
+ box-shadow: -2px 0 8px rgba(0, 0, 0, 0.1);
80
+ transition: right 0.3s ease;
81
+ overflow-y: auto;
82
+ z-index: 1001;
83
+ }
84
+
85
+ .mobile-menu.active .mobile-menu-content {
86
+ right: 0;
87
+ }
88
+
89
+ .mobile-menu-header {
90
+ padding: 16px 20px;
91
+ border-bottom: 1px solid #dadce0;
92
+ display: flex;
93
+ justify-content: space-between;
94
+ align-items: center;
95
+ background: white;
96
+ position: sticky;
97
+ top: 0;
98
+ z-index: 10;
99
+ }
100
+
101
+ .mobile-menu-title {
102
+ font-size: 18px;
103
+ font-weight: 500;
104
+ }
105
+
106
+ .mobile-menu-close {
107
+ background: none;
108
+ border: none;
109
+ font-size: 28px;
110
+ cursor: pointer;
111
+ color: #202124;
112
+ padding: 0;
113
+ width: 32px;
114
+ height: 32px;
115
+ display: flex;
116
+ align-items: center;
117
+ justify-content: center;
118
+ }
119
+
120
+ .mobile-menu-user {
121
+ padding: 12px 20px;
122
+ background: #f8f9fa;
123
+ border-bottom: 1px solid #dadce0;
124
+ font-size: 14px;
125
+ color: #5f6368;
126
+ }
127
+
128
+ .mobile-menu-items {
129
+ padding: 8px 0;
130
+ }
131
+
132
+ .mobile-menu-item {
133
+ display: block;
134
+ padding: 12px 20px;
135
+ color: #202124;
136
+ text-decoration: none;
137
+ font-size: 14px;
138
+ transition: background 0.2s;
139
+ }
140
+
141
+ .mobile-menu-item:hover {
142
+ background: #f8f9fa;
143
+ }
144
+
145
+ .btn {
146
+ padding: 8px 16px;
147
+ border: none;
148
+ border-radius: 6px;
149
+ font-size: 14px;
150
+ font-weight: 500;
151
+ cursor: pointer;
152
+ text-decoration: none;
153
+ display: inline-block;
154
+ transition: all 0.2s;
155
+ }
156
+
157
+ .btn-primary {
158
+ background: #1a73e8;
159
+ color: white;
160
+ }
161
+
162
+ .btn-primary:hover {
163
+ background: #1557b0;
164
+ }
165
+
166
+ .btn-secondary {
167
+ background: #f1f3f4;
168
+ color: #202124;
169
+ }
170
+
171
+ .btn-secondary:hover {
172
+ background: #e8eaed;
173
+ }
174
+
175
+ .container {
176
+ max-width: 1200px;
177
+ margin: 0 auto;
178
+ padding: 24px;
179
+ }
180
+
181
+ .page-header {
182
+ margin-bottom: 24px;
183
+ }
184
+
185
+ .page-header h1 {
186
+ font-size: 28px;
187
+ font-weight: 600;
188
+ margin-bottom: 8px;
189
+ }
190
+
191
+ .page-header p {
192
+ color: #5f6368;
193
+ }
194
+
195
+ .card {
196
+ background: white;
197
+ border-radius: 8px;
198
+ box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
199
+ padding: 24px;
200
+ margin-bottom: 24px;
201
+ }
202
+
203
+ .card-header {
204
+ display: flex;
205
+ justify-content: space-between;
206
+ align-items: center;
207
+ margin-bottom: 20px;
208
+ }
209
+
210
+ .card-title {
211
+ font-size: 18px;
212
+ font-weight: 500;
213
+ }
214
+
215
+ .alert {
216
+ padding: 12px 16px;
217
+ border-radius: 6px;
218
+ margin-bottom: 16px;
219
+ font-size: 14px;
220
+ }
221
+
222
+ .alert.error {
223
+ background: #fce8e6;
224
+ color: #c5221f;
225
+ }
226
+
227
+ .alert.success {
228
+ background: #e8f5e9;
229
+ color: #137333;
230
+ }
231
+
232
+ .utils-grid {
233
+ display: grid;
234
+ grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
235
+ gap: 20px;
236
+ margin-top: 20px;
237
+ }
238
+
239
+ .util-card {
240
+ background: white;
241
+ border-radius: 8px;
242
+ padding: 20px;
243
+ box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
244
+ transition: box-shadow 0.2s;
245
+ }
246
+
247
+ .util-card:hover {
248
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
249
+ }
250
+
251
+ .util-card-title {
252
+ font-size: 16px;
253
+ font-weight: 500;
254
+ margin-bottom: 8px;
255
+ }
256
+
257
+ .util-card-description {
258
+ font-size: 14px;
259
+ color: #5f6368;
260
+ margin-bottom: 12px;
261
+ }
262
+
263
+ .form-group {
264
+ margin-bottom: 16px;
265
+ }
266
+
267
+ .form-group label {
268
+ display: block;
269
+ font-size: 14px;
270
+ font-weight: 500;
271
+ margin-bottom: 8px;
272
+ }
273
+
274
+ .form-group select,
275
+ .form-group textarea {
276
+ width: 100%;
277
+ padding: 10px 12px;
278
+ border: 1px solid #dadce0;
279
+ border-radius: 6px;
280
+ font-size: 14px;
281
+ font-family: inherit;
282
+ }
283
+
284
+ .form-group textarea {
285
+ min-height: 200px;
286
+ resize: vertical;
287
+ }
288
+
289
+ .form-group select:focus,
290
+ .form-group textarea:focus {
291
+ outline: none;
292
+ border-color: #1a73e8;
293
+ box-shadow: 0 0 0 3px rgba(26, 115, 232, 0.1);
294
+ }
295
+
296
+ .form-group-checkbox {
297
+ display: flex;
298
+ align-items: center;
299
+ gap: 8px;
300
+ margin-top: 12px;
301
+ }
302
+
303
+ .form-group-checkbox input {
304
+ width: auto;
305
+ }
306
+
307
+ .preview-box {
308
+ background: #f8f9fa;
309
+ border: 1px solid #dadce0;
310
+ border-radius: 6px;
311
+ padding: 12px;
312
+ margin-top: 12px;
313
+ max-height: 300px;
314
+ overflow-y: auto;
315
+ font-size: 13px;
316
+ font-family: 'Courier New', monospace;
317
+ white-space: pre-wrap;
318
+ word-wrap: break-word;
319
+ }
320
+
321
+ .preview-box.empty {
322
+ color: #5f6368;
323
+ font-style: italic;
324
+ }
325
+
326
+ input[type="file"] {
327
+ cursor: pointer;
328
+ }
329
+
330
+ input[type="file"]:focus {
331
+ outline: none;
332
+ border-color: #1a73e8;
333
+ box-shadow: 0 0 0 3px rgba(26, 115, 232, 0.1);
334
+ }
335
+
336
+ @media (max-width: 768px) {
337
+ .header {
338
+ padding: 12px 16px;
339
+ }
340
+
341
+ .header-title {
342
+ font-size: 18px;
343
+ }
344
+
345
+ .menu-toggle {
346
+ display: block;
347
+ }
348
+
349
+ .header-actions {
350
+ display: none;
351
+ }
352
+
353
+ .container {
354
+ padding: 16px;
355
+ }
356
+
357
+ .utils-grid {
358
+ grid-template-columns: 1fr;
359
+ }
360
+ }
361
+ </style>
362
+ </head>
363
+ <body>
364
+ <div class="header">
365
+ <div class="header-title">
366
+ <span>๐Ÿ”ง</span>
367
+ <span>์œ ํ‹ธ๋ฆฌํ‹ฐ</span>
368
+ </div>
369
+ <button class="menu-toggle" onclick="toggleMobileMenu()" aria-label="๋ฉ”๋‰ด ์—ด๊ธฐ">โ˜ฐ</button>
370
+ <div class="header-actions">
371
+ <span style="margin-right: 12px; color: #5f6368;">{{ current_user.nickname or current_user.username }}</span>
372
+ <a href="{{ url_for('main.admin') }}" class="btn btn-secondary">์‚ฌ์šฉ์ž ๊ด€๋ฆฌ</a>
373
+ <a href="{{ url_for('main.admin_webnovels') }}" class="btn btn-secondary">์›น์†Œ์„ค ๊ด€๋ฆฌ</a>
374
+ <a href="{{ url_for('main.admin_files') }}" class="btn btn-secondary">ํŒŒ์ผ ๋ชฉ๋ก</a>
375
+ <a href="{{ url_for('main.admin_messages') }}" class="btn btn-secondary">๋ฉ”์‹œ์ง€ ํ™•์ธ</a>
376
+ <a href="{{ url_for('main.admin_prompts') }}" class="btn btn-secondary">ํ”„๋กฌํ”„ํŠธ ๊ด€๋ฆฌ</a>
377
+ <a href="{{ url_for('main.admin_settings') }}" class="btn btn-secondary">AI ์„ค์ •</a>
378
+ <a href="{{ url_for('main.admin_utils') }}" class="btn btn-secondary">์œ ํ‹ธ</a>
379
+ <a href="{{ url_for('main.admin_tokens') }}" class="btn btn-secondary">ํ† ํฐ ํ†ต๊ณ„</a>
380
+ <a href="{{ url_for('main.index') }}" class="btn btn-secondary">๋ฉ”์ธ์œผ๋กœ</a>
381
+ <a href="{{ url_for('main.logout') }}" class="btn btn-secondary">๋กœ๊ทธ์•„์›ƒ</a>
382
+ </div>
383
+ </div>
384
+
385
+ <!-- ๋ชจ๋ฐ”์ผ ๋ฉ”๋‰ด -->
386
+ <div class="mobile-menu" id="mobileMenu" onclick="closeMobileMenuOnBackdrop(event)">
387
+ <div class="mobile-menu-content" onclick="event.stopPropagation()">
388
+ <div class="mobile-menu-header">
389
+ <div class="mobile-menu-title">๋ฉ”๋‰ด</div>
390
+ <button class="mobile-menu-close" onclick="toggleMobileMenu()" aria-label="๋ฉ”๋‰ด ๋‹ซ๊ธฐ">&times;</button>
391
+ </div>
392
+ <div class="mobile-menu-user">{{ current_user.nickname or current_user.username }}</div>
393
+ <div class="mobile-menu-items">
394
+ <a href="{{ url_for('main.admin') }}" class="mobile-menu-item" onclick="closeMobileMenu()">์‚ฌ์šฉ์ž ๊ด€๋ฆฌ</a>
395
+ <a href="{{ url_for('main.admin_webnovels') }}" class="mobile-menu-item" onclick="closeMobileMenu()">์›น์†Œ์„ค ๊ด€๋ฆฌ</a>
396
+ <a href="{{ url_for('main.admin_files') }}" class="mobile-menu-item" onclick="closeMobileMenu()">ํŒŒ์ผ ๋ชฉ๋ก</a>
397
+ <a href="{{ url_for('main.admin_messages') }}" class="mobile-menu-item" onclick="closeMobileMenu()">๋ฉ”์‹œ์ง€ ํ™•์ธ</a>
398
+ <a href="{{ url_for('main.admin_prompts') }}" class="mobile-menu-item" onclick="closeMobileMenu()">ํ”„๋กฌํ”„ํŠธ ๊ด€๋ฆฌ</a>
399
+ <a href="{{ url_for('main.admin_settings') }}" class="mobile-menu-item" onclick="closeMobileMenu()">AI ์„ค์ •</a>
400
+ <a href="{{ url_for('main.admin_utils') }}" class="mobile-menu-item" onclick="closeMobileMenu()">์œ ํ‹ธ</a>
401
+ <a href="{{ url_for('main.admin_tokens') }}" class="mobile-menu-item" onclick="closeMobileMenu()">ํ† ํฐ ํ†ต๊ณ„</a>
402
+ <a href="{{ url_for('main.index') }}" class="mobile-menu-item" onclick="closeMobileMenu()">๋ฉ”์ธ์œผ๋กœ</a>
403
+ <a href="{{ url_for('main.logout') }}" class="mobile-menu-item" onclick="closeMobileMenu()">๋กœ๊ทธ์•„์›ƒ</a>
404
+ </div>
405
+ </div>
406
+ </div>
407
+
408
+ <div class="container">
409
+ <div class="page-header">
410
+ <h1>์œ ํ‹ธ๋ฆฌํ‹ฐ</h1>
411
+ <p>๋‹ค์–‘ํ•œ ๋ถ€์ˆ˜์ ์ธ ๊ธฐ๋Šฅ๋“ค์„ ๊ด€๋ฆฌํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.</p>
412
+ </div>
413
+
414
+ <div id="alertContainer"></div>
415
+
416
+ <!-- ํšŒ์ฐจ ๊ตฌ๋ถ„ ๋ฐฉ์‹ ๋ณ€ํ™˜ -->
417
+ <div class="card">
418
+ <div class="card-header">
419
+ <div class="card-title">ํšŒ์ฐจ ๊ตฌ๋ถ„ ๋ฐฉ์‹ ๋ณ€ํ™˜</div>
420
+ </div>
421
+ <div style="padding: 16px 0;">
422
+ <p style="margin-bottom: 16px; color: #5f6368; font-size: 14px;">
423
+ ๋‹ค์–‘ํ•œ ํšŒ์ฐจ ๊ตฌ๋ถ„ ๋ฐฉ์‹(@n, #n, @ํ”„๋กค๋กœ๊ทธ ๋“ฑ)์„ #nํ™” ํ˜•์‹์œผ๋กœ ๋ณ€ํ™˜ํ•ฉ๋‹ˆ๋‹ค. ํŒŒ์ผ์„ ์—…๋กœ๋“œํ•˜๊ฑฐ๋‚˜ ์ง์ ‘ ๋‚ด์šฉ์„ ์ž…๋ ฅํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.
424
+ </p>
425
+
426
+ <div class="form-group">
427
+ <label for="episodeFileUpload">ํŒŒ์ผ ์—…๋กœ๋“œ</label>
428
+ <input type="file" id="episodeFileUpload" accept=".txt,.md" onchange="handleFileUpload(event)" style="width: 100%; padding: 8px; border: 1px solid #dadce0; border-radius: 6px; font-size: 14px;">
429
+ <small style="color: #5f6368; font-size: 12px; display: block; margin-top: 4px;">
430
+ ํ…์ŠคํŠธ ํŒŒ์ผ(.txt, .md)์„ ์—…๋กœ๋“œํ•˜์„ธ์š”
431
+ </small>
432
+ </div>
433
+
434
+ <div class="form-group" id="encodingGroup" style="display: none;">
435
+ <label for="fileEncoding">ํŒŒ์ผ ์ธ์ฝ”๋”ฉ</label>
436
+ <div style="display: flex; gap: 8px; align-items: center;">
437
+ <select id="fileEncoding" style="flex: 1; padding: 8px 12px; border: 1px solid #dadce0; border-radius: 6px; font-size: 14px;">
438
+ <option value="utf-8">UTF-8</option>
439
+ <option value="cp949">CP949 (EUC-KR)</option>
440
+ <option value="euc-kr">EUC-KR</option>
441
+ <option value="latin-1">Latin-1 (ISO-8859-1)</option>
442
+ <option value="utf-16">UTF-16</option>
443
+ <option value="utf-16-le">UTF-16 LE</option>
444
+ <option value="utf-16-be">UTF-16 BE</option>
445
+ </select>
446
+ <button class="btn btn-secondary" onclick="reloadFileWithEncoding()" style="padding: 8px 16px;">์ธ์ฝ”๋”ฉ ์ ์šฉ</button>
447
+ </div>
448
+ <div id="encodingInfo" style="margin-top: 8px; padding: 8px; background: #f8f9fa; border-radius: 4px; font-size: 12px; color: #5f6368; display: none;">
449
+ <span id="encodingStatus"></span>
450
+ </div>
451
+ </div>
452
+
453
+ <div class="form-group">
454
+ <label for="episodeContentInput">๋˜๋Š” ์ง์ ‘ ๋‚ด์šฉ ์ž…๋ ฅ</label>
455
+ <textarea id="episodeContentInput" placeholder="@1&#10;@2ํ™”&#10;3ํ™”&#10;... ํ˜•์‹์˜ ๋‚ด์šฉ์„ ์ž…๏ฟฝ๏ฟฝํ•˜์„ธ์š”"></textarea>
456
+ </div>
457
+
458
+ <div style="display: flex; gap: 8px; margin-top: 16px;">
459
+ <button class="btn btn-primary" onclick="convertEpisodeFormat()">๋ณ€ํ™˜ ์‹คํ–‰</button>
460
+ <button class="btn btn-secondary" onclick="previewConversion()">๋ฏธ๋ฆฌ๋ณด๊ธฐ</button>
461
+ <button class="btn btn-secondary" onclick="clearEpisodeForm()">์ดˆ๊ธฐํ™”</button>
462
+ <button class="btn btn-secondary" id="downloadBtn" onclick="downloadConvertedFile()" style="display: none;">๋‹ค์šด๋กœ๋“œ</button>
463
+ </div>
464
+
465
+ <div id="episodePreview" class="preview-box empty" style="display: none;">
466
+ ๋ณ€ํ™˜ ๊ฒฐ๊ณผ๊ฐ€ ์—ฌ๊ธฐ์— ํ‘œ์‹œ๋ฉ๋‹ˆ๋‹ค.
467
+ </div>
468
+
469
+ <div id="conversionInfo" style="margin-top: 12px; padding: 12px; background: #e8f0fe; border-radius: 6px; display: none;">
470
+ <div style="font-size: 14px; color: #1967d2;">
471
+ <strong>๋ณ€ํ™˜ ์™„๋ฃŒ!</strong> ์œ„์˜ "๋‹ค์šด๋กœ๋“œ" ๋ฒ„ํŠผ์„ ํด๋ฆญํ•˜์—ฌ ๋ณ€ํ™˜๋œ ํŒŒ์ผ์„ ๋‹ค์šด๋กœ๋“œํ•˜์„ธ์š”.
472
+ </div>
473
+ </div>
474
+ </div>
475
+ </div>
476
+ </div>
477
+
478
+ <script>
479
+ function toggleMobileMenu() {
480
+ const menu = document.getElementById('mobileMenu');
481
+ menu.classList.toggle('active');
482
+ document.body.style.overflow = menu.classList.contains('active') ? 'hidden' : '';
483
+ }
484
+
485
+ function closeMobileMenu() {
486
+ const menu = document.getElementById('mobileMenu');
487
+ menu.classList.remove('active');
488
+ document.body.style.overflow = '';
489
+ }
490
+
491
+ function closeMobileMenuOnBackdrop(event) {
492
+ if (event.target.id === 'mobileMenu') {
493
+ closeMobileMenu();
494
+ }
495
+ }
496
+
497
+ function showAlert(message, type = 'success') {
498
+ const container = document.getElementById('alertContainer');
499
+ container.innerHTML = `<div class="alert ${type}">${message}</div>`;
500
+ setTimeout(() => {
501
+ container.innerHTML = '';
502
+ }, 5000);
503
+ }
504
+
505
+ // ํ˜„์žฌ ์„ ํƒ๋œ ํŒŒ์ผ ์ €์žฅ
506
+ let currentFile = null;
507
+
508
+ // ํŒŒ์ผ ์—…๋กœ๋“œ ์ฒ˜๋ฆฌ
509
+ async function handleFileUpload(event) {
510
+ const file = event.target.files[0];
511
+ if (!file) {
512
+ currentFile = null;
513
+ document.getElementById('encodingGroup').style.display = 'none';
514
+ return;
515
+ }
516
+
517
+ currentFile = file;
518
+
519
+ // ์ธ์ฝ”๋”ฉ ๊ฐ์ง€ API ํ˜ธ์ถœ
520
+ try {
521
+ const formData = new FormData();
522
+ formData.append('file', file);
523
+
524
+ const response = await fetch('/api/admin/utils/detect-encoding', {
525
+ method: 'POST',
526
+ credentials: 'include',
527
+ body: formData
528
+ });
529
+
530
+ const data = await response.json();
531
+
532
+ if (response.ok && data.detected_encoding) {
533
+ // ์ธ์ฝ”๋”ฉ ์ •๋ณด ํ‘œ์‹œ
534
+ document.getElementById('fileEncoding').value = data.detected_encoding;
535
+ const encodingInfo = document.getElementById('encodingInfo');
536
+ const encodingStatus = document.getElementById('encodingStatus');
537
+ encodingStatus.innerHTML = `๊ฐ์ง€๋œ ์ธ์ฝ”๋”ฉ: <strong>${data.detected_encoding}</strong> (์‹ ๋ขฐ๋„: ${Math.round(data.confidence * 100)}%)`;
538
+ encodingInfo.style.display = 'block';
539
+ document.getElementById('encodingGroup').style.display = 'block';
540
+
541
+ // ๊ฐ์ง€๋œ ์ธ์ฝ”๋”ฉ์œผ๋กœ ํŒŒ์ผ ์ฝ๊ธฐ
542
+ await loadFileWithEncoding(data.detected_encoding);
543
+ } else {
544
+ // ์ธ์ฝ”๋”ฉ ๊ฐ์ง€ ์‹คํŒจ ์‹œ ๊ธฐ๋ณธ๊ฐ’(UTF-8)์œผ๋กœ ์‹œ๋„
545
+ document.getElementById('fileEncoding').value = 'utf-8';
546
+ document.getElementById('encodingInfo').style.display = 'none';
547
+ document.getElementById('encodingGroup').style.display = 'block';
548
+ await loadFileWithEncoding('utf-8');
549
+ }
550
+ } catch (error) {
551
+ console.error('์ธ์ฝ”๋”ฉ ๊ฐ์ง€ ์˜ค๋ฅ˜:', error);
552
+ // ์˜ค๋ฅ˜ ๋ฐœ์ƒ ์‹œ ๊ธฐ๋ณธ๊ฐ’์œผ๋กœ ์‹œ๋„
553
+ document.getElementById('fileEncoding').value = 'utf-8';
554
+ document.getElementById('encodingGroup').style.display = 'block';
555
+ await loadFileWithEncoding('utf-8');
556
+ }
557
+ }
558
+
559
+ // ์ง€์ •๋œ ์ธ์ฝ”๋”ฉ์œผ๋กœ ํŒŒ์ผ ์ฝ๊ธฐ
560
+ async function loadFileWithEncoding(encoding) {
561
+ if (!currentFile) {
562
+ return;
563
+ }
564
+
565
+ try {
566
+ const reader = new FileReader();
567
+ reader.onload = function(e) {
568
+ document.getElementById('episodeContentInput').value = e.target.result;
569
+ showAlert(`ํŒŒ์ผ์ด ${encoding} ์ธ์ฝ”๋”ฉ์œผ๋กœ ๋กœ๋“œ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.`, 'success');
570
+ };
571
+ reader.onerror = function() {
572
+ showAlert('ํŒŒ์ผ ์ฝ๊ธฐ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค.', 'error');
573
+ };
574
+ reader.readAsText(currentFile, encoding);
575
+ } catch (error) {
576
+ console.error('ํŒŒ์ผ ์ฝ๊ธฐ ์˜ค๋ฅ˜:', error);
577
+ showAlert(`ํŒŒ์ผ ์ฝ๊ธฐ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค: ${error.message}`, 'error');
578
+ }
579
+ }
580
+
581
+ // ์ธ์ฝ”๋”ฉ ๋ณ€๊ฒฝ ํ›„ ํŒŒ์ผ ๋‹ค์‹œ ์ฝ๊ธฐ
582
+ function reloadFileWithEncoding() {
583
+ const encoding = document.getElementById('fileEncoding').value;
584
+ if (!currentFile) {
585
+ showAlert('ํŒŒ์ผ์„ ๋จผ์ € ์—…๋กœ๋“œํ•ด์ฃผ์„ธ์š”.', 'error');
586
+ return;
587
+ }
588
+ loadFileWithEncoding(encoding);
589
+ }
590
+
591
+ // ๋‹ค์šด๋กœ๋“œ URL ์ €์žฅ
592
+ let downloadUrl = null;
593
+ let downloadFilename = null;
594
+
595
+ // ํšŒ์ฐจ ํ˜•์‹ ๋ณ€ํ™˜
596
+ async function convertEpisodeFormat() {
597
+ const fileInput = document.getElementById('episodeFileUpload');
598
+ const content = document.getElementById('episodeContentInput').value.trim();
599
+ const file = fileInput.files[0];
600
+
601
+ if (!file && !content) {
602
+ showAlert('ํŒŒ์ผ์„ ์—…๋กœ๋“œํ•˜๊ฑฐ๋‚˜ ๋‚ด์šฉ์„ ์ž…๋ ฅํ•ด์ฃผ์„ธ์š”.', 'error');
603
+ return;
604
+ }
605
+
606
+ try {
607
+ const formData = new FormData();
608
+
609
+ if (file) {
610
+ formData.append('file', file);
611
+ // ์„ ํƒ๋œ ์ธ์ฝ”๋”ฉ ์ •๋ณด ์ถ”๊ฐ€
612
+ const encoding = document.getElementById('fileEncoding').value || 'utf-8';
613
+ formData.append('encoding', encoding);
614
+ } else if (content) {
615
+ // JSON์œผ๋กœ ์ „์†ก
616
+ const response = await fetch('/api/admin/utils/convert-episode-format', {
617
+ method: 'POST',
618
+ headers: {
619
+ 'Content-Type': 'application/json',
620
+ },
621
+ credentials: 'include',
622
+ body: JSON.stringify({
623
+ content: content,
624
+ filename: file ? file.name : 'converted_file.txt'
625
+ })
626
+ });
627
+
628
+ const data = await response.json();
629
+
630
+ if (response.ok) {
631
+ showAlert(data.message, 'success');
632
+ if (data.converted_content) {
633
+ document.getElementById('episodePreview').textContent = data.converted_content;
634
+ document.getElementById('episodePreview').style.display = 'block';
635
+ document.getElementById('episodePreview').classList.remove('empty');
636
+ }
637
+ if (data.download_url) {
638
+ downloadUrl = data.download_url;
639
+ downloadFilename = data.filename;
640
+ document.getElementById('downloadBtn').style.display = 'inline-block';
641
+ document.getElementById('conversionInfo').style.display = 'block';
642
+ }
643
+ } else {
644
+ showAlert(data.error || '๋ณ€ํ™˜ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค.', 'error');
645
+ }
646
+ return;
647
+ }
648
+
649
+ // ํŒŒ์ผ ์—…๋กœ๋“œ์ธ ๊ฒฝ์šฐ
650
+ const response = await fetch('/api/admin/utils/convert-episode-format', {
651
+ method: 'POST',
652
+ credentials: 'include',
653
+ body: formData
654
+ });
655
+
656
+ const data = await response.json();
657
+
658
+ if (response.ok) {
659
+ showAlert(data.message, 'success');
660
+ if (data.converted_content) {
661
+ document.getElementById('episodePreview').textContent = data.converted_content;
662
+ document.getElementById('episodePreview').style.display = 'block';
663
+ document.getElementById('episodePreview').classList.remove('empty');
664
+ }
665
+ if (data.download_url) {
666
+ downloadUrl = data.download_url;
667
+ downloadFilename = data.filename;
668
+ document.getElementById('downloadBtn').style.display = 'inline-block';
669
+ document.getElementById('conversionInfo').style.display = 'block';
670
+ }
671
+ } else {
672
+ showAlert(data.error || '๋ณ€ํ™˜ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค.', 'error');
673
+ }
674
+ } catch (error) {
675
+ console.error('๋ณ€ํ™˜ ์˜ค๋ฅ˜:', error);
676
+ showAlert(`์˜ค๋ฅ˜: ${error.message}`, 'error');
677
+ }
678
+ }
679
+
680
+ // ๋ณ€ํ™˜๋œ ํŒŒ์ผ ๋‹ค์šด๋กœ๋“œ
681
+ function downloadConvertedFile() {
682
+ if (!downloadUrl) {
683
+ showAlert('๋‹ค์šด๋กœ๋“œํ•  ํŒŒ์ผ์ด ์—†์Šต๋‹ˆ๋‹ค.', 'error');
684
+ return;
685
+ }
686
+
687
+ // ์ƒˆ ์ฐฝ์—์„œ ๋‹ค์šด๋กœ๋“œ ๋งํฌ ์—ด๊ธฐ
688
+ window.location.href = downloadUrl;
689
+ }
690
+
691
+ // ๋ฏธ๋ฆฌ๋ณด๊ธฐ
692
+ async function previewConversion() {
693
+ const fileInput = document.getElementById('episodeFileUpload');
694
+ const content = document.getElementById('episodeContentInput').value.trim();
695
+ const file = fileInput.files[0];
696
+
697
+ if (!file && !content) {
698
+ showAlert('ํŒŒ์ผ์„ ์—…๋กœ๋“œํ•˜๊ฑฐ๋‚˜ ๋‚ด์šฉ์„ ์ž…๋ ฅํ•ด์ฃผ์„ธ์š”.', 'error');
699
+ return;
700
+ }
701
+
702
+ try {
703
+ let response;
704
+
705
+ if (file) {
706
+ const formData = new FormData();
707
+ formData.append('file', file);
708
+ // ์„ ํƒ๋œ ์ธ์ฝ”๋”ฉ ์ •๋ณด ์ถ”๊ฐ€
709
+ const encoding = document.getElementById('fileEncoding').value || 'utf-8';
710
+ formData.append('encoding', encoding);
711
+ response = await fetch('/api/admin/utils/convert-episode-format', {
712
+ method: 'POST',
713
+ credentials: 'include',
714
+ body: formData
715
+ });
716
+ } else {
717
+ response = await fetch('/api/admin/utils/convert-episode-format', {
718
+ method: 'POST',
719
+ headers: {
720
+ 'Content-Type': 'application/json',
721
+ },
722
+ credentials: 'include',
723
+ body: JSON.stringify({
724
+ content: content,
725
+ filename: 'preview.txt'
726
+ })
727
+ });
728
+ }
729
+
730
+ const data = await response.json();
731
+
732
+ if (response.ok && data.converted_content) {
733
+ const previewBox = document.getElementById('episodePreview');
734
+ previewBox.textContent = data.converted_content.substring(0, 2000) + (data.converted_content.length > 2000 ? '\n... (๋” ๋งŽ์€ ๋‚ด์šฉ์ด ์žˆ์Šต๋‹ˆ๋‹ค)' : '');
735
+ previewBox.style.display = 'block';
736
+ previewBox.classList.remove('empty');
737
+ } else {
738
+ showAlert(data.error || '๋ฏธ๋ฆฌ๋ณด๊ธฐ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค.', 'error');
739
+ }
740
+ } catch (error) {
741
+ console.error('๋ฏธ๋ฆฌ๋ณด๊ธฐ ์˜ค๋ฅ˜:', error);
742
+ showAlert(`์˜ค๋ฅ˜: ${error.message}`, 'error');
743
+ }
744
+ }
745
+
746
+ // ํผ ์ดˆ๊ธฐํ™”
747
+ function clearEpisodeForm() {
748
+ document.getElementById('episodeFileUpload').value = '';
749
+ document.getElementById('episodeContentInput').value = '';
750
+ document.getElementById('episodePreview').style.display = 'none';
751
+ document.getElementById('episodePreview').textContent = '๋ณ€ํ™˜ ๊ฒฐ๊ณผ๊ฐ€ ์—ฌ๊ธฐ์— ํ‘œ์‹œ๋ฉ๋‹ˆ๋‹ค.';
752
+ document.getElementById('episodePreview').classList.add('empty');
753
+ document.getElementById('downloadBtn').style.display = 'none';
754
+ document.getElementById('conversionInfo').style.display = 'none';
755
+ document.getElementById('encodingGroup').style.display = 'none';
756
+ document.getElementById('encodingInfo').style.display = 'none';
757
+ document.getElementById('fileEncoding').value = 'utf-8';
758
+ currentFile = null;
759
+ downloadUrl = null;
760
+ downloadFilename = null;
761
+ }
762
+
763
+ // ํŽ˜์ด์ง€ ๋กœ๋“œ ์‹œ ์ดˆ๊ธฐํ™”
764
+ document.addEventListener('DOMContentLoaded', function() {
765
+ console.log('์œ ํ‹ธ๋ฆฌํ‹ฐ ํŽ˜์ด์ง€ ๋กœ๋“œ ์™„๋ฃŒ');
766
+ });
767
+ </script>
768
+ </body>
769
+ </html>
770
+
templates/admin_webnovels.html CHANGED
@@ -617,6 +617,9 @@
617
  <a href="{{ url_for('main.admin_messages') }}" class="btn btn-secondary">๋ฉ”์‹œ์ง€ ํ™•์ธ</a>
618
  <a href="{{ url_for('main.admin_prompts') }}" class="btn btn-secondary">ํ”„๋กฌํ”„ํŠธ ๊ด€๋ฆฌ</a>
619
  <a href="{{ url_for('main.admin_settings') }}" class="btn btn-secondary">AI ์„ค์ •</a>
 
 
 
620
  <a href="{{ url_for('main.index') }}" class="btn btn-secondary">๋ฉ”์ธ์œผ๋กœ</a>
621
  <a href="{{ url_for('main.logout') }}" class="btn btn-secondary">๋กœ๊ทธ์•„์›ƒ</a>
622
  </div>
@@ -636,6 +639,9 @@
636
  <a href="{{ url_for('main.admin_messages') }}" class="mobile-menu-item" onclick="closeMobileMenu()">๋ฉ”์‹œ์ง€ ํ™•์ธ</a>
637
  <a href="{{ url_for('main.admin_prompts') }}" class="mobile-menu-item" onclick="closeMobileMenu()">ํ”„๋กฌํ”„ํŠธ ๊ด€๋ฆฌ</a>
638
  <a href="{{ url_for('main.admin_settings') }}" class="mobile-menu-item" onclick="closeMobileMenu()">AI ์„ค์ •</a>
 
 
 
639
  <a href="{{ url_for('main.index') }}" class="mobile-menu-item" onclick="closeMobileMenu()">๋ฉ”์ธ์œผ๋กœ</a>
640
  <a href="{{ url_for('main.logout') }}" class="mobile-menu-item" onclick="closeMobileMenu()">๋กœ๊ทธ์•„์›ƒ</a>
641
  </div>
 
617
  <a href="{{ url_for('main.admin_messages') }}" class="btn btn-secondary">๋ฉ”์‹œ์ง€ ํ™•์ธ</a>
618
  <a href="{{ url_for('main.admin_prompts') }}" class="btn btn-secondary">ํ”„๋กฌํ”„ํŠธ ๊ด€๋ฆฌ</a>
619
  <a href="{{ url_for('main.admin_settings') }}" class="btn btn-secondary">AI ์„ค์ •</a>
620
+ <a href="{{ url_for('main.admin_utils') }}" class="btn btn-secondary">์œ ํ‹ธ</a>
621
+ <a href="{{ url_for('main.admin_tokens') }}" class="btn btn-secondary">ํ† ํฐ ํ†ต๊ณ„</a>
622
+ <a href="{{ url_for('main.admin_utils') }}" class="btn btn-secondary">์œ ํ‹ธ</a>
623
  <a href="{{ url_for('main.index') }}" class="btn btn-secondary">๋ฉ”์ธ์œผ๋กœ</a>
624
  <a href="{{ url_for('main.logout') }}" class="btn btn-secondary">๋กœ๊ทธ์•„์›ƒ</a>
625
  </div>
 
639
  <a href="{{ url_for('main.admin_messages') }}" class="mobile-menu-item" onclick="closeMobileMenu()">๋ฉ”์‹œ์ง€ ํ™•์ธ</a>
640
  <a href="{{ url_for('main.admin_prompts') }}" class="mobile-menu-item" onclick="closeMobileMenu()">ํ”„๋กฌํ”„ํŠธ ๊ด€๋ฆฌ</a>
641
  <a href="{{ url_for('main.admin_settings') }}" class="mobile-menu-item" onclick="closeMobileMenu()">AI ์„ค์ •</a>
642
+ <a href="{{ url_for('main.admin_utils') }}" class="mobile-menu-item" onclick="closeMobileMenu()">์œ ํ‹ธ</a>
643
+ <a href="{{ url_for('main.admin_tokens') }}" class="mobile-menu-item" onclick="closeMobileMenu()">ํ† ํฐ ํ†ต๊ณ„</a>
644
+ <a href="{{ url_for('main.admin_utils') }}" class="mobile-menu-item" onclick="closeMobileMenu()">์œ ํ‹ธ</a>
645
  <a href="{{ url_for('main.index') }}" class="mobile-menu-item" onclick="closeMobileMenu()">๋ฉ”์ธ์œผ๋กœ</a>
646
  <a href="{{ url_for('main.logout') }}" class="mobile-menu-item" onclick="closeMobileMenu()">๋กœ๊ทธ์•„์›ƒ</a>
647
  </div>
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', '์‚ฌ์šฉ ๊ฐ€๋Šฅํ•œ ๋ชจ๋ธ์ด ์—†์Šต๋‹ˆ๋‹ค');