GitHub Actions commited on
Commit
8d65b61
ยท
1 Parent(s): e59e120

Auto-deploy from GitHub Actions - 2025-12-12 13:10:39

Browse files
app/routes.py CHANGED
@@ -1991,6 +1991,12 @@ def admin_files():
1991
  """ํŒŒ์ผ ๋ชฉ๋ก ๊ด€๋ฆฌ ํŽ˜์ด์ง€"""
1992
  return render_template('admin_files.html')
1993
 
 
 
 
 
 
 
1994
  @main_bp.route('/admin/utils')
1995
  @admin_required
1996
  def admin_utils():
@@ -4943,10 +4949,10 @@ def process_graph(file_id):
4943
  except Exception as e:
4944
  return jsonify({'error': f'Graph Extraction ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค: {str(e)}', 'step': 'graph'}), 500
4945
 
4946
- @main_bp.route('/api/files/<int:file_id>/process/tags', methods=['POST'])
4947
  @login_required
4948
- def generate_tags(file_id):
4949
- """ํƒœ๊ทธ ์ƒ์„ฑ (Parent Chunk, ํšŒ์ฐจ ๋ถ„์„, GraphRAG ํ™œ์šฉ) - ๊ณ„์ธต์  ๊ตฌ์กฐ"""
4950
  try:
4951
  file = UploadedFile.query.get_or_404(file_id)
4952
 
@@ -4973,7 +4979,7 @@ def generate_tags(file_id):
4973
  """
4974
 
4975
  # 2. ํšŒ์ฐจ ๋ถ„์„ (์š”์•ฝ)
4976
- analyses = EpisodeAnalysis.query.filter_by(file_id=file_id).order_by(EpisodeAnalysis.id).limit(20).all()
4977
  analysis_text = ""
4978
  if analyses:
4979
  analysis_text = "[ํšŒ์ฐจ๋ณ„ ๋ถ„์„ (์š”์•ฝ)]\n"
@@ -4982,40 +4988,107 @@ def generate_tags(file_id):
4982
  content_preview = analysis.analysis_content[:300] + "..." if len(analysis.analysis_content) > 300 else analysis.analysis_content
4983
  analysis_text += f"- {analysis.episode_title}: {content_preview}\n"
4984
 
4985
- # 3. GraphRAG (์ฃผ์š” ์ธ๋ฌผ, ๊ด€๊ณ„, ์‚ฌ๊ฑด)
4986
- entities = GraphEntity.query.filter_by(file_id=file_id).limit(30).all()
4987
- relationships = GraphRelationship.query.filter_by(file_id=file_id).limit(30).all()
4988
- events = GraphEvent.query.filter_by(file_id=file_id).limit(30).all()
4989
-
4990
- graph_text = ""
4991
- if entities or relationships or events:
4992
- graph_text = "[GraphRAG ๋ฐ์ดํ„ฐ]\n"
4993
- if entities:
4994
- entity_names = [f"{e.entity_name}({e.entity_type})" for e in entities]
4995
- graph_text += f"์—”ํ‹ฐํ‹ฐ: {', '.join(entity_names)}\n"
4996
- if relationships:
4997
- rel_strs = [f"{r.source}->{r.target}({r.relationship_type})" for r in relationships]
4998
- graph_text += f"๊ด€๊ณ„: {', '.join(rel_strs)}\n"
4999
- if events:
5000
- event_names = [e.event_name for e in events]
5001
- graph_text += f"์‚ฌ๊ฑด: {', '.join(event_names)}\n"
5002
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5003
  # ๋ฐ์ดํ„ฐ๊ฐ€ ๋„ˆ๋ฌด ์—†์œผ๋ฉด ํƒœ๊ทธ ์ƒ์„ฑ ๋ถˆ๊ฐ€
5004
- if not parent_chunk_text and not analysis_text and not graph_text:
5005
  return jsonify({'error': 'ํƒœ๊ทธ๋ฅผ ์ƒ์„ฑํ•  ์ถฉ๋ถ„ํ•œ ๋ฐ์ดํ„ฐ(Parent Chunk, ํšŒ์ฐจ ๋ถ„์„ ๋“ฑ)๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค.'}), 400
5006
 
5007
  # ํ”„๋กฌํ”„ํŠธ ๊ตฌ์„ฑ
5008
  prompt = f"""
5009
- ๋‹ค์Œ์€ ์›น์†Œ์„ค์˜ ๋ถ„์„ ๋ฐ์ดํ„ฐ์ž…๋‹ˆ๋‹ค. ์ด ๋ฐ์ดํ„ฐ๋ฅผ ๋ฐ”ํƒ•์œผ๋กœ ๊ฐ ์„น์…˜๋ณ„๋กœ ํ•ต์‹ฌ ํƒœ๊ทธ๋ฅผ ์ถ”์ถœํ•ด์ฃผ์„ธ์š”.
5010
 
5011
  {parent_chunk_text}
5012
 
5013
  {analysis_text}
5014
 
5015
- {graph_text}
 
 
 
 
 
 
5016
 
5017
  [์š”๊ตฌ์‚ฌํ•ญ]
5018
- ๋‹ค์Œ JSON ํ˜•์‹์œผ๋กœ ํƒœ๊ทธ๋ฅผ ์ƒ์„ฑํ•˜์„ธ์š”. ๊ฐ ํ•ญ๋ชฉ๋‹น 5~10๊ฐœ์˜ ํ•ต์‹ฌ ํ‚ค์›Œ๋“œ๋ฅผ ์ถ”์ถœํ•˜์„ธ์š”. ์—†๋Š” ํ•ญ๋ชฉ์€ ๋นˆ ๋ฐฐ์—ด๋กœ ๋‘์„ธ์š”.
 
 
 
 
5019
 
5020
  {{
5021
  "parent_chunk": {{
@@ -5025,14 +5098,42 @@ def generate_tags(file_id):
5025
  "others": ["ํƒœ๊ทธ1", "ํƒœ๊ทธ2"]
5026
  }},
5027
  "episodes": {{
5028
- "world_view": ["ํƒœ๊ทธ1", "ํƒœ๊ทธ2"],
 
5029
  "characters": ["ํƒœ๊ทธ1", "ํƒœ๊ทธ2"],
5030
- "items": ["ํƒœ๊ทธ1", "ํƒœ๊ทธ2"]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5031
  }},
5032
- "graph_rag": {{
5033
  "characters": ["ํƒœ๊ทธ1", "ํƒœ๊ทธ2"],
 
5034
  "relationships": ["ํƒœ๊ทธ1", "ํƒœ๊ทธ2"],
5035
  "events": ["ํƒœ๊ทธ1", "ํƒœ๊ทธ2"]
 
 
 
 
5036
  }}
5037
  }}
5038
 
@@ -5040,7 +5141,7 @@ def generate_tags(file_id):
5040
  """
5041
 
5042
  # AI ํ˜ธ์ถœ
5043
- print(f"[ํƒœ๊ทธ ์ƒ์„ฑ] '{file.original_filename}' ํƒœ๊ทธ ์ƒ์„ฑ ์š”์ฒญ (๋ชจ๋ธ: {file.model_name})")
5044
 
5045
  response_text = None
5046
 
@@ -5056,8 +5157,8 @@ def generate_tags(file_id):
5056
  result = gemini_client.generate_response(
5057
  prompt=prompt,
5058
  model_name=gemini_model_name,
5059
- temperature=0.5,
5060
- max_output_tokens=2048
5061
  )
5062
  if not result['error'] and result.get('response'):
5063
  response_text = result['response'].strip()
@@ -5072,23 +5173,40 @@ def generate_tags(file_id):
5072
  from app.core.config import get_config
5073
  config = get_config()
5074
 
 
 
 
5075
  ollama_response = requests.post(
5076
  f"{config.OLLAMA_BASE_URL}/api/generate",
5077
  json={
5078
  'model': file.model_name,
5079
  'prompt': prompt,
5080
  'stream': False,
5081
- 'options': {'temperature': 0.5},
5082
- 'format': 'json' # Ollama JSON ๋ชจ๋“œ ํ™œ์„ฑํ™”
5083
  },
5084
- timeout=180
5085
  )
5086
 
 
 
5087
  if ollama_response.status_code == 200:
5088
  response_data = ollama_response.json()
5089
  response_text = response_data.get('response', '').strip()
 
 
 
 
 
 
 
 
 
 
5090
  except Exception as e:
5091
- print(f"[ํƒœ๊ทธ ์ƒ์„ฑ] Ollama ์˜ค๋ฅ˜: {str(e)}")
 
 
5092
 
5093
  if not response_text:
5094
  return jsonify({'error': 'AI ์‘๋‹ต์„ ๋ฐ›์ง€ ๋ชปํ–ˆ์Šต๋‹ˆ๋‹ค.'}), 500
@@ -5099,32 +5217,365 @@ def generate_tags(file_id):
5099
 
5100
  tags_data = {}
5101
  try:
 
5102
  # JSON ์ถ”์ถœ ์‹œ๋„
5103
- json_match = re.search(r'\{.*\}', response_text, re.DOTALL)
 
 
 
 
 
5104
  if json_match:
5105
  json_str = json_match.group(0)
5106
- tags_data = json.loads(json_str)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5107
  else:
5108
  # JSON ํŒŒ์‹ฑ ์‹คํŒจ ์‹œ
5109
- print(f"[ํƒœ๊ทธ ์ƒ์„ฑ] JSON ํ˜•์‹์ด ์•„๋‹˜: {response_text[:100]}...")
5110
- return jsonify({'error': 'AI ์‘๋‹ต์ด ์˜ฌ๋ฐ”๋ฅธ JSON ํ˜•์‹์ด ์•„๋‹™๋‹ˆ๋‹ค.'}), 500
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5111
  except Exception as e:
5112
- print(f"[ํƒœ๊ทธ ์ƒ์„ฑ] ํŒŒ์‹ฑ ์˜ค๋ฅ˜: {str(e)}")
5113
  return jsonify({'error': f'ํƒœ๊ทธ ํŒŒ์‹ฑ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค: {str(e)}'}), 500
5114
 
5115
- # DB ์ €์žฅ (JSON ๋ฌธ์ž์—ด๋กœ ์ €์žฅ)
5116
- file.tags = json.dumps(tags_data, ensure_ascii=False)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5117
  db.session.commit()
5118
 
 
 
 
 
 
 
 
5119
  return jsonify({
5120
- 'message': 'ํƒœ๊ทธ๊ฐ€ ์ƒ์„ฑ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.',
5121
  'tags': tags_data,
5122
- 'file_id': file.id
 
5123
  }), 200
5124
 
5125
  except Exception as e:
5126
  db.session.rollback()
5127
- return jsonify({'error': f'ํƒœ๊ทธ ์ƒ์„ฑ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค: {str(e)}'}), 500
 
 
 
 
5128
 
5129
  @main_bp.route('/api/files/<int:file_id>/metadata', methods=['POST'])
5130
  @login_required
 
1991
  """ํŒŒ์ผ ๋ชฉ๋ก ๊ด€๋ฆฌ ํŽ˜์ด์ง€"""
1992
  return render_template('admin_files.html')
1993
 
1994
+ @main_bp.route('/admin/tags')
1995
+ @admin_required
1996
+ def admin_tags():
1997
+ """ํƒœ๊ทธ ๋ณด๊ธฐ ํŽ˜์ด์ง€"""
1998
+ return render_template('admin_tags.html')
1999
+
2000
  @main_bp.route('/admin/utils')
2001
  @admin_required
2002
  def admin_utils():
 
4949
  except Exception as e:
4950
  return jsonify({'error': f'Graph Extraction ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค: {str(e)}', 'step': 'graph'}), 500
4951
 
4952
+ @main_bp.route('/api/files/<int:file_id>/process/tags/detailed', methods=['POST'])
4953
  @login_required
4954
+ def generate_detailed_tags(file_id):
4955
+ """์ƒ์„ธ ํƒœ๊ทธ ์ƒ์„ฑ (Parent Chunk, ํšŒ์ฐจ ๋ถ„์„, GraphRAG ํ™œ์šฉ) - ๊ณ„์ธต์  ๊ตฌ์กฐ"""
4956
  try:
4957
  file = UploadedFile.query.get_or_404(file_id)
4958
 
 
4979
  """
4980
 
4981
  # 2. ํšŒ์ฐจ ๋ถ„์„ (์š”์•ฝ)
4982
+ analyses = EpisodeAnalysis.query.filter_by(file_id=file_id).order_by(EpisodeAnalysis.id).limit(50).all()
4983
  analysis_text = ""
4984
  if analyses:
4985
  analysis_text = "[ํšŒ์ฐจ๋ณ„ ๋ถ„์„ (์š”์•ฝ)]\n"
 
4988
  content_preview = analysis.analysis_content[:300] + "..." if len(analysis.analysis_content) > 300 else analysis.analysis_content
4989
  analysis_text += f"- {analysis.episode_title}: {content_preview}\n"
4990
 
4991
+ print(f"[์ƒ์„ธ ํƒœ๊ทธ ์ƒ์„ฑ] ํšŒ์ฐจ ๋ถ„์„ ๋ฐ์ดํ„ฐ ๊ธธ์ด: {len(analysis_text)} (ํ•ญ๋ชฉ ์ˆ˜: {len(analyses)})")
4992
+
4993
+ # 3. GraphRAG (์ „์ฒด, ํšŒ์ฐจ๋ณ„, ์ธ๋ฌผ๋ณ„ ๋ถ„์„์„ ์œ„ํ•œ ๋ฐ์ดํ„ฐ ์ˆ˜์ง‘)
4994
+ all_entities = GraphEntity.query.filter_by(file_id=file_id).all()
4995
+ all_relationships = GraphRelationship.query.filter_by(file_id=file_id).all()
4996
+ all_events = GraphEvent.query.filter_by(file_id=file_id).all()
4997
+
4998
+ # 3-1. ์ „์ฒด ์š”์•ฝ
4999
+ total_graph_summary = ""
5000
+ if all_entities or all_relationships or all_events:
5001
+ total_graph_summary = "[GraphRAG ๋ฐ์ดํ„ฐ (์ „์ฒด)]\n"
5002
+ if all_entities:
5003
+ unique_entities = sorted(list(set([f"{e.entity_name}({e.entity_type})" for e in all_entities])))
5004
+ # ๋„ˆ๋ฌด ๋งŽ์œผ๋ฉด ์ผ๋ถ€๋งŒ
5005
+ total_graph_summary += f"์ฃผ์š” ์—”ํ‹ฐํ‹ฐ({len(unique_entities)}๊ฐœ): {', '.join(unique_entities[:50])}\n"
5006
+ if all_relationships:
5007
+ unique_rels = sorted(list(set([f"{r.source}->{r.target}({r.relationship_type})" for r in all_relationships])))
5008
+ total_graph_summary += f"์ฃผ์š” ๊ด€๊ณ„({len(unique_rels)}๊ฐœ): {', '.join(unique_rels[:50])}\n"
5009
+ if all_events:
5010
+ unique_events = sorted(list(set([e.event_name for e in all_events])))
5011
+ total_graph_summary += f"์ฃผ์š” ์‚ฌ๊ฑด({len(unique_events)}๊ฐœ): {', '.join(unique_events[:30])}\n"
5012
+
5013
+ # 3-2. ํšŒ์ฐจ๋ณ„ ์š”์•ฝ (์•ž๋ถ€๋ถ„ 5๊ฐœ ํšŒ์ฐจ๋งŒ ์ƒ˜ํ”Œ๋ง)
5014
+ episode_graph_summary = ""
5015
+ episode_titles = sorted(list(set([e.episode_title for e in all_events] + [e.episode_title for e in all_entities])))
5016
+ if episode_titles:
5017
+ episode_graph_summary = "[GraphRAG ๋ฐ์ดํ„ฐ (ํšŒ์ฐจ๋ณ„ ์ƒ˜ํ”Œ)]\n"
5018
+ for title in episode_titles[:5]:
5019
+ ep_entities = [e.entity_name for e in all_entities if e.episode_title == title]
5020
+ ep_events = [e.event_name for e in all_events if e.episode_title == title]
5021
+ episode_graph_summary += f"- {title}: ์ฃผ์š” ์—”ํ‹ฐํ‹ฐ({', '.join(ep_entities[:5])}), ์ฃผ์š” ์‚ฌ๊ฑด({', '.join(ep_events[:3])})\n"
5022
+
5023
+ # 3-3. ์ธ๋ฌผ๋ณ„ ์š”์•ฝ (์ฃผ์š” ์ธ๋ฌผ 5๋ช…)
5024
+ character_graph_summary = ""
5025
+ from collections import Counter
5026
+ # entity_type์ด 'character' ๋˜๋Š” '์ธ๋ฌผ'์ธ ๊ฒƒ๋งŒ ์นด์šดํŠธ
5027
+ char_counts = Counter([e.entity_name for e in all_entities if e.entity_type in ['character', '์ธ๋ฌผ']])
5028
+ top_characters = [char for char, count in char_counts.most_common(5)]
5029
+
5030
+ if top_characters:
5031
+ character_graph_summary = "[GraphRAG ๋ฐ์ดํ„ฐ (์ฃผ์š” ์ธ๋ฌผ๋ณ„)]\n"
5032
+ for char in top_characters:
5033
+ # ํ•ด๋‹น ์ธ๋ฌผ์ด ํฌํ•จ๋œ ๊ด€๊ณ„๋‚˜ ์‚ฌ๊ฑด
5034
+ char_rels = [f"{r.target}({r.relationship_type})" for r in all_relationships if r.source == char]
5035
+ char_events = [e.event_name for e in all_events if char in (e.participants or "")]
5036
+ character_graph_summary += f"- {char}: ์ฃผ์š” ๊ด€๊ณ„({', '.join(char_rels[:5])}), ๊ด€๋ จ ์‚ฌ๊ฑด({', '.join(char_events[:3])})\n"
5037
+
5038
+ # 3-4. ์ƒ์„ธ ๊ด€๊ณ„/์‚ฌ๊ฑด ์š”์•ฝ
5039
+ detail_graph_summary = ""
5040
+ # ์ธ๋ฌผ-์ธ๋ฌผ ๊ด€๊ณ„ (์‹œ๊ฐ„์ˆœ/ID์ˆœ)
5041
+ # ID๊ฐ€ ์ƒ์„ฑ ์ˆœ์„œ์ด๋ฏ€๋กœ ์‹œ๊ฐ„ ํ๋ฆ„์„ ์–ด๋А ์ •๋„ ๋ฐ˜์˜ํ•จ
5042
+ sorted_relationships = sorted(all_relationships, key=lambda r: r.id)
5043
+ if sorted_relationships:
5044
+ detail_graph_summary += "[GraphRAG (์ธ๋ฌผ-์ธ๋ฌผ ๊ด€๊ณ„ ์‹œ๊ฐ„์ˆœ - ์ „์ฒด)]\n"
5045
+ # ๋ชจ๋“  ๊ด€๊ณ„ ํฌํ•จ (ํ† ํฐ ์ œํ•œ ๋‚ด์—์„œ)
5046
+ for rel in sorted_relationships:
5047
+ detail_graph_summary += f"- {rel.source} -> {rel.target}: {rel.relationship_type}\n"
5048
+
5049
+ # ์‚ฌ๊ฑด-์ธ๋ฌผ๋“ค ๊ด€๊ณ„
5050
+ if all_events:
5051
+ detail_graph_summary += "\n[GraphRAG (์‚ฌ๊ฑด-์ธ๋ฌผ๋“ค ๊ด€๊ณ„ - ์ „์ฒด)]\n"
5052
+ # ๋ชจ๋“  ์‚ฌ๊ฑด ํฌํ•จ
5053
+ for evt in all_events:
5054
+ participants_str = evt.participants if evt.participants else "์ •๋ณด ์—†์Œ"
5055
+ # JSON ๋ฌธ์ž์—ด์ธ ๊ฒฝ์šฐ ํŒŒ์‹ฑ ์‹œ๋„
5056
+ if participants_str.startswith('['):
5057
+ try:
5058
+ import json
5059
+ p_list = json.loads(participants_str)
5060
+ if isinstance(p_list, list):
5061
+ participants_str = ", ".join(p_list)
5062
+ except:
5063
+ pass
5064
+ detail_graph_summary += f"- ์‚ฌ๊ฑด '{evt.event_name}': ์ฐธ์—ฌ์ž [{participants_str}]\n"
5065
+
5066
  # ๋ฐ์ดํ„ฐ๊ฐ€ ๋„ˆ๋ฌด ์—†์œผ๋ฉด ํƒœ๊ทธ ์ƒ์„ฑ ๋ถˆ๊ฐ€
5067
+ if not parent_chunk_text and not analysis_text and not total_graph_summary:
5068
  return jsonify({'error': 'ํƒœ๊ทธ๋ฅผ ์ƒ์„ฑํ•  ์ถฉ๋ถ„ํ•œ ๋ฐ์ดํ„ฐ(Parent Chunk, ํšŒ์ฐจ ๋ถ„์„ ๋“ฑ)๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค.'}), 400
5069
 
5070
  # ํ”„๋กฌํ”„ํŠธ ๊ตฌ์„ฑ
5071
  prompt = f"""
5072
+ ๋‹ค์Œ์€ ์›น์†Œ์„ค์˜ ๋ถ„์„ ๋ฐ์ดํ„ฐ์ž…๋‹ˆ๋‹ค. ์ด ๋ฐ์ดํ„ฐ๋ฅผ ๋ฐ”ํƒ•์œผ๋กœ ๊ฐ ์„น์…˜๋ณ„๋กœ ๋งค์šฐ ์ƒ์„ธํ•˜๊ณ  ๊ตฌ์ฒด์ ์ธ ํƒœ๊ทธ๋ฅผ ์ถ”์ถœํ•ด์ฃผ์„ธ์š”.
5073
 
5074
  {parent_chunk_text}
5075
 
5076
  {analysis_text}
5077
 
5078
+ {total_graph_summary}
5079
+
5080
+ {episode_graph_summary}
5081
+
5082
+ {character_graph_summary}
5083
+
5084
+ {detail_graph_summary}
5085
 
5086
  [์š”๊ตฌ์‚ฌํ•ญ]
5087
+ ๋‹ค์Œ JSON ๊ตฌ์กฐ์— ๋งž์ถฐ ํƒœ๊ทธ๋ฅผ ์ƒ์„ฑํ•˜์„ธ์š”.
5088
+ ๊ฐ ํ•ญ๋ชฉ๋‹น 10~20๊ฐœ์˜ ํƒœ๊ทธ๋ฅผ ์ƒ์„ฑํ•˜๋ฉฐ, ๋‹จ์ˆœํ•œ ๋‹จ์–ด๋ณด๋‹ค๋Š” ๊ตฌ์ฒด์ ์ธ ๋‚ด์šฉ์„ ๋‹ด์€ '๊ตฌ' ํ˜•ํƒœ์˜ ํƒœ๊ทธ๋ฅผ ์„ ํ˜ธํ•ฉ๋‹ˆ๋‹ค.
5089
+ (์˜ˆ: "๋ณต์ˆ˜" -> "๊ฐ€๋ฌธ ๋ฉธ๋ง์— ๋Œ€ํ•œ ์ฒ˜์ ˆํ•œ ๋ณต์ˆ˜", "์„ฑ์žฅ" -> "๋งˆ๋ฒ•์  ์žฌ๋Šฅ์˜ ๊ธ‰๊ฒฉํ•œ ๊ฐœํ™”", "๊ฐˆ๋“ฑ" -> "ํ™ฉ์œ„ ๊ณ„์Šน๊ถŒ์„ ๋‘˜๋Ÿฌ์‹ผ ํ˜•์ œ๊ฐ„์˜ ์•”ํˆฌ")
5090
+
5091
+ ํŠนํžˆ 'graph_rag_detail' ํ•ญ๋ชฉ์€ ์ž…๋ ฅ๋œ ๋ชจ๋“  ๊ด€๊ณ„์™€ ์‚ฌ๊ฑด ๋ฐ์ดํ„ฐ๋ฅผ ๋น ์ง์—†์ด ๋ถ„์„ํ•˜์—ฌ, ๊ฐ€๋Šฅํ•œ ํ•œ ๋ชจ๋“  ์ธ๋ฌผ ๊ด€๊ณ„ ๋ณ€ํ™”์™€ ์‚ฌ๊ฑด ์† ์ธ๋ฌผ๋“ค์˜ ์—ญํ• ์„ ์ƒ์„ธํžˆ ๋ฌ˜์‚ฌํ•˜๋Š” ํƒœ๊ทธ๋ฅผ ์ƒ์„ฑํ•˜์„ธ์š”. ํƒœ๊ทธ ๊ฐœ์ˆ˜ ์ œํ•œ์„ ๋‘์ง€ ๋ง๊ณ  ํ’๋ถ€ํ•˜๊ฒŒ ์ƒ์„ฑํ•˜์„ธ์š”.
5092
 
5093
  {{
5094
  "parent_chunk": {{
 
5098
  "others": ["ํƒœ๊ทธ1", "ํƒœ๊ทธ2"]
5099
  }},
5100
  "episodes": {{
5101
+ "story": ["ํƒœ๊ทธ1", "ํƒœ๊ทธ2"],
5102
+ "events": ["ํƒœ๊ทธ1", "ํƒœ๊ทธ2"],
5103
  "characters": ["ํƒœ๊ทธ1", "ํƒœ๊ทธ2"],
5104
+ "relationships_change": ["ํƒœ๊ทธ1", "ํƒœ๊ทธ2"],
5105
+ "appearance": ["ํƒœ๊ทธ1", "ํƒœ๊ทธ2"],
5106
+ "clothing": ["ํƒœ๊ทธ1", "ํƒœ๊ทธ2"],
5107
+ "items": ["ํƒœ๊ทธ1", "ํƒœ๊ทธ2"],
5108
+ "others": ["ํƒœ๊ทธ1", "ํƒœ๊ทธ2"]
5109
+ }},
5110
+ "graph_rag_total": {{
5111
+ "characters": ["ํƒœ๊ทธ1", "ํƒœ๊ทธ2"],
5112
+ "locations": ["ํƒœ๊ทธ1", "ํƒœ๊ทธ2"],
5113
+ "relationships": ["ํƒœ๊ทธ1", "ํƒœ๊ทธ2"],
5114
+ "events": ["ํƒœ๊ทธ1", "ํƒœ๊ทธ2"]
5115
+ }},
5116
+ "graph_rag_by_episode": {{
5117
+ "characters": ["ํƒœ๊ทธ1", "ํƒœ๊ทธ2"],
5118
+ "locations": ["ํƒœ๊ทธ1", "ํƒœ๊ทธ2"],
5119
+ "relationships": ["ํƒœ๊ทธ1", "ํƒœ๊ทธ2"],
5120
+ "events": ["ํƒœ๊ทธ1", "ํƒœ๊ทธ2"]
5121
+ }},
5122
+ "graph_rag_by_character": {{
5123
+ "characters": ["ํƒœ๊ทธ1", "ํƒœ๊ทธ2"],
5124
+ "locations": ["ํƒœ๊ทธ1", "ํƒœ๊ทธ2"],
5125
+ "relationships": ["ํƒœ๊ทธ1", "ํƒœ๊ทธ2"],
5126
+ "events": ["ํƒœ๊ทธ1", "ํƒœ๊ทธ2"]
5127
  }},
5128
+ "graph_rag_by_event": {{
5129
  "characters": ["ํƒœ๊ทธ1", "ํƒœ๊ทธ2"],
5130
+ "locations": ["ํƒœ๊ทธ1", "ํƒœ๊ทธ2"],
5131
  "relationships": ["ํƒœ๊ทธ1", "ํƒœ๊ทธ2"],
5132
  "events": ["ํƒœ๊ทธ1", "ํƒœ๊ทธ2"]
5133
+ }},
5134
+ "graph_rag_detail": {{
5135
+ "person_person_relationships_chronological": ["๋ชจ๋“  ์ฃผ์š” ์ธ๋ฌผ ๊ด€๊ณ„์˜ ์‹œ๊ฐ„์  ๋ณ€ํ™”๋ฅผ ์ƒ์„ธํžˆ ๋ฌ˜์‚ฌ"],
5136
+ "event_person_relationships": ["๋ชจ๋“  ์‚ฌ๊ฑด๋ณ„ ์ฃผ์š” ์ธ๋ฌผ์˜ ์—ญํ• ๊ณผ ๊ด€๊ณ„๋ฅผ ์ƒ์„ธํžˆ ๋ฌ˜์‚ฌ"]
5137
  }}
5138
  }}
5139
 
 
5141
  """
5142
 
5143
  # AI ํ˜ธ์ถœ
5144
+ print(f"[์ƒ์„ธ ํƒœ๊ทธ ์ƒ์„ฑ] '{file.original_filename}' ์ƒ์„ธ ํƒœ๊ทธ ์ƒ์„ฑ ์š”์ฒญ (๋ชจ๋ธ: {file.model_name})")
5145
 
5146
  response_text = None
5147
 
 
5157
  result = gemini_client.generate_response(
5158
  prompt=prompt,
5159
  model_name=gemini_model_name,
5160
+ temperature=0.6,
5161
+ max_output_tokens=4096
5162
  )
5163
  if not result['error'] and result.get('response'):
5164
  response_text = result['response'].strip()
 
5173
  from app.core.config import get_config
5174
  config = get_config()
5175
 
5176
+ print(f"[์ƒ์„ธ ํƒœ๊ทธ ์ƒ์„ฑ] Ollama API ํ˜ธ์ถœ ์‹œ์ž‘: {config.OLLAMA_BASE_URL}, ๋ชจ๋ธ: {file.model_name}")
5177
+
5178
+ # JSON ํ˜•์‹ ์ง€์› ์—ฌ๋ถ€์™€ ๊ด€๊ณ„์—†์ด ๋จผ์ € ์‹œ๋„ (์ผ๋ถ€ ๋ชจ๋ธ์€ JSON ํ˜•์‹์„ ์ง€์›ํ•˜์ง€ ์•Š์Œ)
5179
  ollama_response = requests.post(
5180
  f"{config.OLLAMA_BASE_URL}/api/generate",
5181
  json={
5182
  'model': file.model_name,
5183
  'prompt': prompt,
5184
  'stream': False,
5185
+ 'options': {'temperature': 0.5, 'num_predict': 4096}
5186
+ # 'format': 'json' ์ œ๊ฑฐ - ๋ชจ๋“  ๋ชจ๋ธ์ด ์ง€์›ํ•˜์ง€ ์•Š์„ ์ˆ˜ ์žˆ์Œ
5187
  },
5188
+ timeout=300 # ํƒ€์ž„์•„์›ƒ์„ 5๋ถ„์œผ๋กœ ์ฆ๊ฐ€
5189
  )
5190
 
5191
+ print(f"[์ƒ์„ธ ํƒœ๊ทธ ์ƒ์„ฑ] Ollama ์‘๋‹ต ์ƒํƒœ: {ollama_response.status_code}")
5192
+
5193
  if ollama_response.status_code == 200:
5194
  response_data = ollama_response.json()
5195
  response_text = response_data.get('response', '').strip()
5196
+ print(f"[์ƒ์„ธ ํƒœ๊ทธ ์ƒ์„ฑ] Ollama ์‘๋‹ต ์ˆ˜์‹  ์™„๋ฃŒ, ๊ธธ์ด: {len(response_text)}")
5197
+ else:
5198
+ error_text = ollama_response.text[:500] if ollama_response.text else "์‘๋‹ต ์—†์Œ"
5199
+ print(f"[์ƒ์„ธ ํƒœ๊ทธ ์ƒ์„ฑ] Ollama API ์˜ค๋ฅ˜ ({ollama_response.status_code}): {error_text}")
5200
+ except requests.exceptions.Timeout:
5201
+ print(f"[์ƒ์„ธ ํƒœ๊ทธ ์ƒ์„ฑ] Ollama ์š”์ฒญ ํƒ€์ž„์•„์›ƒ (300์ดˆ ์ดˆ๊ณผ)")
5202
+ return jsonify({'error': 'AI ์‘๋‹ต ์ƒ์„ฑ ์‹œ๊ฐ„์ด ์ดˆ๊ณผ๋˜์—ˆ์Šต๋‹ˆ๋‹ค. ๋ชจ๋ธ์ด ๋„ˆ๋ฌด ๋А๋ฆฌ๊ฑฐ๋‚˜ ํ”„๋กฌํ”„ํŠธ๊ฐ€ ๋„ˆ๋ฌด ํฝ๋‹ˆ๋‹ค.'}), 500
5203
+ except requests.exceptions.ConnectionError:
5204
+ print(f"[์ƒ์„ธ ํƒœ๊ทธ ์ƒ์„ฑ] Ollama ์„œ๋ฒ„ ์—ฐ๊ฒฐ ์‹คํŒจ: {config.OLLAMA_BASE_URL}")
5205
+ return jsonify({'error': f'Ollama ์„œ๋ฒ„์— ์—ฐ๊ฒฐํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค. ์„œ๋ฒ„๊ฐ€ ์‹คํ–‰ ์ค‘์ธ์ง€ ํ™•์ธํ•˜์„ธ์š”. ({config.OLLAMA_BASE_URL})'}), 500
5206
  except Exception as e:
5207
+ print(f"[์ƒ์„ธ ํƒœ๊ทธ ์ƒ์„ฑ] Ollama ์˜ค๋ฅ˜: {str(e)}")
5208
+ import traceback
5209
+ traceback.print_exc()
5210
 
5211
  if not response_text:
5212
  return jsonify({'error': 'AI ์‘๋‹ต์„ ๋ฐ›์ง€ ๋ชปํ–ˆ์Šต๋‹ˆ๋‹ค.'}), 500
 
5217
 
5218
  tags_data = {}
5219
  try:
5220
+ print(f"[์ƒ์„ธ ํƒœ๊ทธ ์ƒ์„ฑ] AI ์‘๋‹ต ๊ธธ์ด: {len(response_text)}")
5221
  # JSON ์ถ”์ถœ ์‹œ๋„
5222
+ # Markdown ์ฝ”๋“œ ๋ธ”๋ก ์ œ๊ฑฐ
5223
+ cleaned_text = re.sub(r'```json\s*', '', response_text)
5224
+ cleaned_text = re.sub(r'```\s*', '', cleaned_text)
5225
+ cleaned_text = cleaned_text.strip()
5226
+
5227
+ json_match = re.search(r'\{.*\}', cleaned_text, re.DOTALL)
5228
  if json_match:
5229
  json_str = json_match.group(0)
5230
+ try:
5231
+ tags_data = json.loads(json_str)
5232
+ except json.JSONDecodeError as je:
5233
+ print(f"[์ƒ์„ธ ํƒœ๊ทธ ์ƒ์„ฑ] JSON ํŒŒ์‹ฑ ์‹คํŒจ: {je}")
5234
+ print(f"[์ƒ์„ธ ํƒœ๊ทธ ์ƒ์„ฑ] ํŒŒ์‹ฑ ์‹คํŒจํ•œ ๋ฌธ์ž์—ด ์ผ๋ถ€: {json_str[:200]}...")
5235
+ # ์‰ผํ‘œ ๋ฌธ์ œ ๋“ฑ ๊ฐ„๋‹จํ•œ ๋ณต๊ตฌ ์‹œ๋„
5236
+ try:
5237
+ # Trailing comma ์ œ๊ฑฐ
5238
+ json_str_fixed = re.sub(r',\s*([\]}])', r'\1', json_str)
5239
+ tags_data = json.loads(json_str_fixed)
5240
+ print("[์ƒ์„ธ ํƒœ๊ทธ ์ƒ์„ฑ] JSON ๋ณต๊ตฌ ์„ฑ๊ณต")
5241
+ except:
5242
+ # ๋ณต๊ตฌ ์‹คํŒจ ์‹œ
5243
+ raise je
5244
+
5245
+ print(f"[์ƒ์„ธ ํƒœ๊ทธ ์ƒ์„ฑ] ์ƒ์„ฑ๋œ ํƒœ๊ทธ ๊ตฌ์กฐ: {list(tags_data.keys())}")
5246
+ if 'episodes' in tags_data:
5247
+ print(f"[์ƒ์„ธ ํƒœ๊ทธ ์ƒ์„ฑ] episodes ํƒœ๊ทธ ์ƒ˜ํ”Œ: {str(tags_data['episodes'])[:100]}...")
5248
  else:
5249
  # JSON ํŒŒ์‹ฑ ์‹คํŒจ ์‹œ
5250
+ print(f"[์ƒ์„ธ ํƒœ๊ทธ ์ƒ์„ฑ] JSON ํ˜•์‹์ด ์•„๋‹˜: {response_text[:200]}...")
5251
+ return jsonify({'error': 'AI ์‘๋‹ต์ด ์˜ฌ๋ฐ”๋ฅธ JSON ํ˜•์‹์ด ์•„๋‹™๋‹ˆ๋‹ค. (๋กœ๊ทธ ํ™•์ธ ํ•„์š”)'}), 500
5252
+ except Exception as e:
5253
+ print(f"[์ƒ์„ธ ํƒœ๊ทธ ์ƒ์„ฑ] ํŒŒ์‹ฑ ์˜ค๋ฅ˜: {str(e)}")
5254
+ # ์ตœํ›„์˜ ์ˆ˜๋‹จ: ํ…์ŠคํŠธ ํŒŒ์ผ๋กœ๋ผ๋„ ์ €์žฅํ• ์ง€ ๊ณ ๋ฏผํ•ด๋ด์•ผ ํ•จ.
5255
+ return jsonify({'error': f'ํƒœ๊ทธ ํŒŒ์‹ฑ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค: {str(e)}'}), 500
5256
+
5257
+ # DB ์ €์žฅ (๊ธฐ์กด ํƒœ๊ทธ๊ฐ€ ์žˆ์œผ๋ฉด ๋ณ‘ํ•ฉ, type ํ•„๋“œ ์ถ”๊ฐ€)
5258
+ import json
5259
+ existing_tags = None
5260
+ if file.tags:
5261
+ try:
5262
+ existing_tags = json.loads(file.tags)
5263
+ except:
5264
+ pass
5265
+
5266
+ tags_data_with_type = {
5267
+ 'type': 'detailed',
5268
+ 'tags': tags_data
5269
+ }
5270
+
5271
+ # ๊ธฐ์กด ์ผ๋ฐ˜ ํƒœ๊ทธ๊ฐ€ ์žˆ์œผ๋ฉด ๋ณ‘ํ•ฉ
5272
+ if existing_tags and existing_tags.get('type') == 'simple':
5273
+ tags_data_with_type['simple_tags'] = existing_tags.get('tags', {})
5274
+
5275
+ tags_json_str = json.dumps(tags_data_with_type, ensure_ascii=False)
5276
+ file.tags = tags_json_str
5277
+ db.session.commit()
5278
+
5279
+ # ์ €์žฅ ํ™•์ธ
5280
+ db.session.refresh(file)
5281
+ if file.tags:
5282
+ print(f"[์ƒ์„ธ ํƒœ๊ทธ ์ƒ์„ฑ] DB ์ €์žฅ ์„ฑ๊ณต - ํŒŒ์ผ ID: {file.id}, ํƒœ๊ทธ ๊ธธ์ด: {len(file.tags)}")
5283
+ else:
5284
+ print(f"[์ƒ์„ธ ํƒœ๊ทธ ์ƒ์„ฑ] ๊ฒฝ๊ณ : DB ์ €์žฅ ํ›„ ํƒœ๊ทธ๊ฐ€ None์ž…๋‹ˆ๋‹ค!")
5285
+
5286
+ return jsonify({
5287
+ 'message': '์ƒ์„ธ ํƒœ๊ทธ๊ฐ€ ์ƒ์„ฑ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.',
5288
+ 'tags': tags_data,
5289
+ 'file_id': file.id,
5290
+ 'type': 'detailed'
5291
+ }), 200
5292
+
5293
+ except Exception as e:
5294
+ db.session.rollback()
5295
+ error_msg = str(e)
5296
+ print(f"[์ƒ์„ธ ํƒœ๊ทธ ์ƒ์„ฑ] ์˜ˆ์™ธ ๋ฐœ์ƒ: {error_msg}")
5297
+ import traceback
5298
+ traceback.print_exc()
5299
+ return jsonify({'error': f'์ƒ์„ธ ํƒœ๊ทธ ์ƒ์„ฑ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค: {error_msg}'}), 500
5300
+
5301
+ @main_bp.route('/api/files/<int:file_id>/process/tags/simple', methods=['POST'])
5302
+ @login_required
5303
+ def generate_simple_tags(file_id):
5304
+ """์ผ๋ฐ˜ ํƒœ๊ทธ ์ƒ์„ฑ (๊ฐ„๋‹จํ•œ ํ‚ค์›Œ๋“œ ํ˜•ํƒœ)"""
5305
+ try:
5306
+ file = UploadedFile.query.get_or_404(file_id)
5307
+
5308
+ # ๊ถŒํ•œ ํ™•์ธ
5309
+ if not current_user.is_admin and file.uploaded_by != current_user.id:
5310
+ return jsonify({'error': '๊ถŒํ•œ์ด ์—†์Šต๋‹ˆ๋‹ค.'}), 403
5311
+
5312
+ # ๋ชจ๋ธ ํ™•์ธ
5313
+ if not file.model_name:
5314
+ return jsonify({'error': 'ํŒŒ์ผ์— ์—ฐ๊ฒฐ๋œ AI ๋ชจ๋ธ์ด ์—†์Šต๋‹ˆ๋‹ค.'}), 400
5315
+
5316
+ # ๋ฐ์ดํ„ฐ ์ˆ˜์ง‘ (์ƒ์„ธ ํƒœ๊ทธ์™€ ๋™์ผ)
5317
+ parent_chunk = ParentChunk.query.filter_by(file_id=file_id).first()
5318
+ analyses = EpisodeAnalysis.query.filter_by(file_id=file_id).order_by(EpisodeAnalysis.id).limit(50).all()
5319
+ all_entities = GraphEntity.query.filter_by(file_id=file_id).all()
5320
+ all_relationships = GraphRelationship.query.filter_by(file_id=file_id).all()
5321
+ all_events = GraphEvent.query.filter_by(file_id=file_id).all()
5322
+
5323
+ # ๋ฐ์ดํ„ฐ๊ฐ€ ๋„ˆ๋ฌด ์—†์œผ๋ฉด ํƒœ๊ทธ ์ƒ์„ฑ ๋ถˆ๊ฐ€
5324
+ if not parent_chunk and not analyses and not all_entities:
5325
+ return jsonify({'error': 'ํƒœ๊ทธ๋ฅผ ์ƒ์„ฑํ•  ์ถฉ๋ถ„ํ•œ ๋ฐ์ดํ„ฐ(Parent Chunk, ํšŒ์ฐจ ๋ถ„์„ ๋“ฑ)๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค.'}), 400
5326
+
5327
+ # ํ”„๋กฌํ”„ํŠธ ๊ตฌ์„ฑ (๊ฐ„๋‹จํ•œ ํ‚ค์›Œ๋“œ ํ˜•ํƒœ)
5328
+ prompt_parts = []
5329
+
5330
+ if parent_chunk:
5331
+ prompt_parts.append(f"[Parent Chunk]\n์„ธ๊ณ„๊ด€: {parent_chunk.world_view or '์—†์Œ'}\n์ฃผ์š” ์บ๋ฆญํ„ฐ: {parent_chunk.characters or '์—†์Œ'}\n์ด์•ผ๊ธฐ: {parent_chunk.story or '์—†์Œ'}\n๊ธฐํƒ€: {parent_chunk.others or '์—†์Œ'}")
5332
+
5333
+ if analyses:
5334
+ prompt_parts.append("[ํšŒ์ฐจ๋ณ„ ๋ถ„์„]\n" + "\n".join([f"- {a.episode_title}: {a.analysis_content[:200]}..." for a in analyses[:10]]))
5335
+
5336
+ if all_entities or all_relationships or all_events:
5337
+ graph_parts = []
5338
+ if all_entities:
5339
+ entities_list = sorted(list(set([e.entity_name for e in all_entities])))
5340
+ graph_parts.append(f"์ธ๋ฌผ/์žฅ์†Œ: {', '.join(entities_list[:30])}")
5341
+ if all_relationships:
5342
+ rels_list = [f"{r.source}-{r.target}({r.relationship_type})" for r in all_relationships[:20]]
5343
+ graph_parts.append(f"๊ด€๊ณ„: {', '.join(rels_list)}")
5344
+ if all_events:
5345
+ events_list = [e.event_name for e in all_events[:20]]
5346
+ graph_parts.append(f"์‚ฌ๊ฑด: {', '.join(events_list)}")
5347
+ if graph_parts:
5348
+ prompt_parts.append("[GraphRAG]\n" + "\n".join(graph_parts))
5349
+
5350
+ # GraphRAG ์ƒ์„ธ ๋ฐ์ดํ„ฐ ์ถ”๊ฐ€ (์ผ๋ฐ˜ ํƒœ๊ทธ์—๋„ ํฌํ•จ)
5351
+ detail_graph_summary = ""
5352
+ if all_relationships:
5353
+ sorted_relationships = sorted(all_relationships, key=lambda r: r.id)
5354
+ detail_graph_summary += "[GraphRAG (์ธ๋ฌผ-์ธ๋ฌผ ๊ด€๊ณ„ ์‹œ๊ฐ„์ˆœ)]\n"
5355
+ for rel in sorted_relationships:
5356
+ detail_graph_summary += f"- {rel.source} -> {rel.target}: {rel.relationship_type}\n"
5357
+
5358
+ if all_events:
5359
+ detail_graph_summary += "\n[GraphRAG (์‚ฌ๊ฑด-์ธ๋ฌผ๋“ค ๊ด€๊ณ„)]\n"
5360
+ for evt in all_events:
5361
+ participants_str = evt.participants if evt.participants else "์ •๋ณด ์—†์Œ"
5362
+ if participants_str.startswith('['):
5363
+ try:
5364
+ import json
5365
+ p_list = json.loads(participants_str)
5366
+ if isinstance(p_list, list):
5367
+ participants_str = ", ".join(p_list)
5368
+ except:
5369
+ pass
5370
+ detail_graph_summary += f"- ์‚ฌ๊ฑด '{evt.event_name}': ์ฐธ์—ฌ์ž [{participants_str}]\n"
5371
+
5372
+ if detail_graph_summary:
5373
+ prompt_parts.append(detail_graph_summary)
5374
+
5375
+ prompt = "\n\n".join(prompt_parts) + """
5376
+
5377
+ [์š”๊ตฌ์‚ฌํ•ญ]
5378
+ ์œ„ ๋ฐ์ดํ„ฐ๋ฅผ ๋ฐ”ํƒ•์œผ๋กœ ๊ฐ ์„น์…˜๋ณ„๋กœ ๊ฐ„๋‹จํ•œ ํ‚ค์›Œ๋“œ ํƒœ๊ทธ๋ฅผ ์ƒ์„ฑํ•˜์„ธ์š”. ๊ฐ ํ•ญ๋ชฉ๋‹น 5~10๊ฐœ์˜ ๊ฐ„๋‹จํ•œ ํƒœ๊ทธ๋ฅผ ์ƒ์„ฑํ•˜๋ฉฐ, ๋‹จ์–ด๋‚˜ ์งง์€ ๊ตฌ ํ˜•ํƒœ๋กœ ์ž‘์„ฑํ•˜์„ธ์š”.
5379
+
5380
+ ํŠนํžˆ 'graph_rag_detail' ํ•ญ๋ชฉ์˜ ๊ด€๊ณ„ ํƒœ๊ทธ๋Š” "์ธ๋ฌผA โ†’ ์ธ๋ฌผB: ๊ด€๊ณ„์œ ํ˜•" ํ˜•์‹์œผ๋กœ ์ž‘์„ฑํ•˜๊ฑฐ๋‚˜, "์ธ๋ฌผA์™€ ์ธ๋ฌผB์˜ ๊ด€๊ณ„์œ ํ˜•" ๊ฐ™์€ ์ดํ•ดํ•˜๊ธฐ ์‰ฌ์šด ํ˜•์‹์œผ๋กœ ์ž‘์„ฑํ•˜์„ธ์š”.
5381
+
5382
+ ๋‹ค์Œ JSON ๊ตฌ์กฐ์— ๋งž์ถฐ ํƒœ๊ทธ๋ฅผ ์ƒ์„ฑํ•˜์„ธ์š”:
5383
+
5384
+ {
5385
+ "parent_chunk": {
5386
+ "world_view": ["ํƒœ๊ทธ1", "ํƒœ๊ทธ2"],
5387
+ "characters": ["ํƒœ๊ทธ1", "ํƒœ๊ทธ2"],
5388
+ "story": ["ํƒœ๊ทธ1", "ํƒœ๊ทธ2"],
5389
+ "others": ["ํƒœ๊ทธ1", "ํƒœ๊ทธ2"]
5390
+ },
5391
+ "episodes": {
5392
+ "story": ["ํƒœ๊ทธ1", "ํƒœ๊ทธ2"],
5393
+ "events": ["ํƒœ๊ทธ1", "ํƒœ๊ทธ2"],
5394
+ "characters": ["ํƒœ๊ทธ1", "ํƒœ๊ทธ2"],
5395
+ "relationships_change": ["ํƒœ๊ทธ1", "ํƒœ๊ทธ2"],
5396
+ "appearance": ["ํƒœ๊ทธ1", "ํƒœ๊ทธ2"],
5397
+ "clothing": ["ํƒœ๊ทธ1", "ํƒœ๊ทธ2"],
5398
+ "items": ["ํƒœ๊ทธ1", "ํƒœ๊ทธ2"],
5399
+ "others": ["ํƒœ๊ทธ1", "ํƒœ๊ทธ2"]
5400
+ },
5401
+ "graph_rag_total": {
5402
+ "characters": ["ํƒœ๊ทธ1", "ํƒœ๊ทธ2"],
5403
+ "locations": ["ํƒœ๊ทธ1", "ํƒœ๊ทธ2"],
5404
+ "relationships": ["ํƒœ๊ทธ1", "ํƒœ๊ทธ2"],
5405
+ "events": ["ํƒœ๊ทธ1", "ํƒœ๊ทธ2"]
5406
+ },
5407
+ "graph_rag_by_episode": {
5408
+ "characters": ["ํƒœ๊ทธ1", "ํƒœ๊ทธ2"],
5409
+ "locations": ["ํƒœ๊ทธ1", "ํƒœ๊ทธ2"],
5410
+ "relationships": ["ํƒœ๊ทธ1", "ํƒœ๊ทธ2"],
5411
+ "events": ["ํƒœ๊ทธ1", "ํƒœ๊ทธ2"]
5412
+ },
5413
+ "graph_rag_by_character": {
5414
+ "characters": ["ํƒœ๊ทธ1", "ํƒœ๊ทธ2"],
5415
+ "locations": ["ํƒœ๊ทธ1", "ํƒœ๊ทธ2"],
5416
+ "relationships": ["ํƒœ๊ทธ1", "ํƒœ๊ทธ2"],
5417
+ "events": ["ํƒœ๊ทธ1", "ํƒœ๊ทธ2"]
5418
+ },
5419
+ "graph_rag_by_event": {
5420
+ "characters": ["ํƒœ๊ทธ1", "ํƒœ๊ทธ2"],
5421
+ "locations": ["ํƒœ๊ทธ1", "ํƒœ๊ทธ2"],
5422
+ "relationships": ["ํƒœ๊ทธ1", "ํƒœ๊ทธ2"],
5423
+ "events": ["ํƒœ๊ทธ1", "ํƒœ๊ทธ2"]
5424
+ },
5425
+ "graph_rag_detail": {
5426
+ "person_person_relationships_chronological": ["ํƒœ๊ทธ1", "ํƒœ๊ทธ2"],
5427
+ "event_person_relationships": ["ํƒœ๊ทธ1", "ํƒœ๊ทธ2"]
5428
+ }
5429
+ }
5430
+
5431
+ ๋ฐ˜๋“œ์‹œ JSON ํ˜•์‹์œผ๋กœ๋งŒ ์‘๋‹ตํ•˜์„ธ์š”. ๋‹ค๋ฅธ ์„ค๋ช…์€ ํฌํ•จํ•˜์ง€ ๋งˆ์„ธ์š”.
5432
+ """
5433
+
5434
+ # AI ํ˜ธ์ถœ
5435
+ print(f"[์ผ๋ฐ˜ ํƒœ๊ทธ ์ƒ์„ฑ] '{file.original_filename}' ์ผ๋ฐ˜ ํƒœ๊ทธ ์ƒ์„ฑ ์š”์ฒญ (๋ชจ๋ธ: {file.model_name})")
5436
+
5437
+ response_text = None
5438
+
5439
+ # Gemini ๋ชจ๋ธ์ธ ๊ฒฝ์šฐ
5440
+ if 'gemini' in file.model_name.lower():
5441
+ try:
5442
+ gemini_model_name = file.model_name
5443
+ if gemini_model_name.lower().startswith('gemini:'):
5444
+ gemini_model_name = gemini_model_name.split(':', 1)[1].strip()
5445
+
5446
+ gemini_client = get_gemini_client()
5447
+ if gemini_client.is_configured():
5448
+ result = gemini_client.generate_response(
5449
+ prompt=prompt,
5450
+ model_name=gemini_model_name,
5451
+ temperature=0.4,
5452
+ max_output_tokens=2048
5453
+ )
5454
+ if not result['error'] and result.get('response'):
5455
+ response_text = result['response'].strip()
5456
+ except Exception as e:
5457
+ print(f"[์ผ๋ฐ˜ ํƒœ๊ทธ ์ƒ์„ฑ] Gemini ์˜ค๋ฅ˜: {str(e)}")
5458
+
5459
+ # Ollama ๋ชจ๋ธ์ธ ๊ฒฝ์šฐ (๋˜๋Š” Gemini ์‹คํŒจ ์‹œ)
5460
+ if not response_text:
5461
+ try:
5462
+ import requests
5463
+ from app.core.config import get_config
5464
+ config = get_config()
5465
+
5466
+ print(f"[์ผ๋ฐ˜ ํƒœ๊ทธ ์ƒ์„ฑ] Ollama API ํ˜ธ์ถœ ์‹œ์ž‘: {config.OLLAMA_BASE_URL}, ๋ชจ๋ธ: {file.model_name}")
5467
+
5468
+ ollama_response = requests.post(
5469
+ f"{config.OLLAMA_BASE_URL}/api/generate",
5470
+ json={
5471
+ 'model': file.model_name,
5472
+ 'prompt': prompt,
5473
+ 'stream': False,
5474
+ 'options': {'temperature': 0.4, 'num_predict': 2048}
5475
+ },
5476
+ timeout=180
5477
+ )
5478
+
5479
+ print(f"[์ผ๋ฐ˜ ํƒœ๊ทธ ์ƒ์„ฑ] Ollama ์‘๋‹ต ์ƒํƒœ: {ollama_response.status_code}")
5480
+
5481
+ if ollama_response.status_code == 200:
5482
+ response_data = ollama_response.json()
5483
+ response_text = response_data.get('response', '').strip()
5484
+ print(f"[์ผ๋ฐ˜ ํƒœ๊ทธ ์ƒ์„ฑ] Ollama ์‘๋‹ต ์ˆ˜์‹  ์™„๋ฃŒ, ๊ธธ์ด: {len(response_text)}")
5485
+ else:
5486
+ error_text = ollama_response.text[:500] if ollama_response.text else "์‘๋‹ต ์—†์Œ"
5487
+ print(f"[์ผ๋ฐ˜ ํƒœ๊ทธ ์ƒ์„ฑ] Ollama API ์˜ค๋ฅ˜ ({ollama_response.status_code}): {error_text}")
5488
+ except requests.exceptions.Timeout:
5489
+ print(f"[์ผ๋ฐ˜ ํƒœ๊ทธ ์ƒ์„ฑ] Ollama ์š”์ฒญ ํƒ€์ž„์•„์›ƒ (180์ดˆ ์ดˆ๊ณผ)")
5490
+ return jsonify({'error': 'AI ์‘๋‹ต ์ƒ์„ฑ ์‹œ๊ฐ„์ด ์ดˆ๊ณผ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.'}), 500
5491
+ except requests.exceptions.ConnectionError:
5492
+ print(f"[์ผ๋ฐ˜ ํƒœ๊ทธ ์ƒ์„ฑ] Ollama ์„œ๋ฒ„ ์—ฐ๊ฒฐ ์‹คํŒจ: {config.OLLAMA_BASE_URL}")
5493
+ return jsonify({'error': f'Ollama ์„œ๋ฒ„์— ์—ฐ๊ฒฐํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค. ์„œ๋ฒ„๊ฐ€ ์‹คํ–‰ ์ค‘์ธ์ง€ ํ™•์ธํ•˜์„ธ์š”. ({config.OLLAMA_BASE_URL})'}), 500
5494
+ except Exception as e:
5495
+ print(f"[์ผ๋ฐ˜ ํƒœ๊ทธ ์ƒ์„ฑ] Ollama ์˜ค๋ฅ˜: {str(e)}")
5496
+ import traceback
5497
+ traceback.print_exc()
5498
+
5499
+ if not response_text:
5500
+ return jsonify({'error': 'AI ์‘๋‹ต์„ ๋ฐ›์ง€ ๋ชปํ–ˆ์Šต๋‹ˆ๋‹ค.'}), 500
5501
+
5502
+ # JSON ํŒŒ์‹ฑ
5503
+ import json
5504
+ import re
5505
+
5506
+ tags_data = {}
5507
+ try:
5508
+ print(f"[์ผ๋ฐ˜ ํƒœ๊ทธ ์ƒ์„ฑ] AI ์‘๋‹ต ๊ธธ์ด: {len(response_text)}")
5509
+ cleaned_text = re.sub(r'```json\s*', '', response_text)
5510
+ cleaned_text = re.sub(r'```\s*', '', cleaned_text)
5511
+ cleaned_text = cleaned_text.strip()
5512
+
5513
+ json_match = re.search(r'\{.*\}', cleaned_text, re.DOTALL)
5514
+ if json_match:
5515
+ json_str = json_match.group(0)
5516
+ try:
5517
+ tags_data = json.loads(json_str)
5518
+ except json.JSONDecodeError as je:
5519
+ print(f"[์ผ๋ฐ˜ ํƒœ๊ทธ ์ƒ์„ฑ] JSON ํŒŒ์‹ฑ ์‹คํŒจ: {je}")
5520
+ print(f"[์ผ๋ฐ˜ ํƒœ๊ทธ ์ƒ์„ฑ] ํŒŒ์‹ฑ ์‹คํŒจํ•œ ๋ฌธ์ž์—ด ์ผ๋ถ€: {json_str[:200]}...")
5521
+ try:
5522
+ json_str_fixed = re.sub(r',\s*([\]}])', r'\1', json_str)
5523
+ tags_data = json.loads(json_str_fixed)
5524
+ print("[์ผ๋ฐ˜ ํƒœ๊ทธ ์ƒ์„ฑ] JSON ๋ณต๊ตฌ ์„ฑ๊ณต")
5525
+ except:
5526
+ raise je
5527
+
5528
+ print(f"[์ผ๋ฐ˜ ํƒœ๊ทธ ์ƒ์„ฑ] ์ƒ์„ฑ๋œ ํƒœ๊ทธ ๊ตฌ์กฐ: {list(tags_data.keys())}")
5529
+ else:
5530
+ print(f"[์ผ๋ฐ˜ ํƒœ๊ทธ ์ƒ์„ฑ] JSON ํ˜•์‹์ด ์•„๋‹˜: {response_text[:200]}...")
5531
+ return jsonify({'error': 'AI ์‘๋‹ต์ด ์˜ฌ๋ฐ”๋ฅธ JSON ํ˜•์‹์ด ์•„๋‹™๋‹ˆ๋‹ค. (๋กœ๊ทธ ํ™•์ธ ํ•„์š”)'}), 500
5532
  except Exception as e:
5533
+ print(f"[์ผ๋ฐ˜ ํƒœ๊ทธ ์ƒ์„ฑ] ํŒŒ์‹ฑ ์˜ค๋ฅ˜: {str(e)}")
5534
  return jsonify({'error': f'ํƒœ๊ทธ ํŒŒ์‹ฑ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค: {str(e)}'}), 500
5535
 
5536
+ # DB ์ €์žฅ (๊ธฐ์กด ํƒœ๊ทธ๊ฐ€ ์žˆ์œผ๋ฉด ๋ณ‘ํ•ฉ, type ํ•„๋“œ ์ถ”๊ฐ€)
5537
+ import json
5538
+ existing_tags = None
5539
+ if file.tags:
5540
+ try:
5541
+ existing_tags = json.loads(file.tags)
5542
+ except:
5543
+ pass
5544
+
5545
+ tags_data_with_type = {
5546
+ 'type': 'simple',
5547
+ 'tags': tags_data
5548
+ }
5549
+
5550
+ # ๊ธฐ์กด ์ƒ์„ธ ํƒœ๊ทธ๊ฐ€ ์žˆ์œผ๋ฉด ๋ณ‘ํ•ฉ
5551
+ if existing_tags and existing_tags.get('type') == 'detailed':
5552
+ tags_data_with_type['detailed_tags'] = existing_tags.get('tags', {})
5553
+
5554
+ tags_json_str = json.dumps(tags_data_with_type, ensure_ascii=False)
5555
+ file.tags = tags_json_str
5556
  db.session.commit()
5557
 
5558
+ # ์ €์žฅ ํ™•์ธ
5559
+ db.session.refresh(file)
5560
+ if file.tags:
5561
+ print(f"[์ผ๋ฐ˜ ํƒœ๊ทธ ์ƒ์„ฑ] DB ์ €์žฅ ์„ฑ๊ณต - ํŒŒ์ผ ID: {file.id}, ํƒœ๊ทธ ๊ธธ์ด: {len(file.tags)}")
5562
+ else:
5563
+ print(f"[์ผ๋ฐ˜ ํƒœ๊ทธ ์ƒ์„ฑ] ๊ฒฝ๊ณ : DB ์ €์žฅ ํ›„ ํƒœ๊ทธ๊ฐ€ None์ž…๋‹ˆ๋‹ค!")
5564
+
5565
  return jsonify({
5566
+ 'message': '์ผ๋ฐ˜ ํƒœ๊ทธ๊ฐ€ ์ƒ์„ฑ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.',
5567
  'tags': tags_data,
5568
+ 'file_id': file.id,
5569
+ 'type': 'simple'
5570
  }), 200
5571
 
5572
  except Exception as e:
5573
  db.session.rollback()
5574
+ error_msg = str(e)
5575
+ print(f"[์ผ๋ฐ˜ ํƒœ๊ทธ ์ƒ์„ฑ] ์˜ˆ์™ธ ๋ฐœ์ƒ: {error_msg}")
5576
+ import traceback
5577
+ traceback.print_exc()
5578
+ return jsonify({'error': f'์ผ๋ฐ˜ ํƒœ๊ทธ ์ƒ์„ฑ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค: {error_msg}'}), 500
5579
 
5580
  @main_bp.route('/api/files/<int:file_id>/metadata', methods=['POST'])
5581
  @login_required
templates/admin.html CHANGED
@@ -46,8 +46,103 @@
46
 
47
  .header-actions {
48
  display: flex;
49
- gap: 12px;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
50
  align-items: center;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
51
  }
52
 
53
  .menu-toggle {
@@ -573,15 +668,53 @@
573
  <button class="menu-toggle" onclick="toggleMobileMenu()" aria-label="๋ฉ”๋‰ด ์—ด๊ธฐ">โ˜ฐ</button>
574
  <div class="header-actions">
575
  <span style="margin-right: 12px; color: #5f6368;">{{ current_user.nickname or current_user.username }}</span>
576
- <a href="{{ url_for('main.admin_webnovels') }}" class="btn btn-secondary">์›น์†Œ์„ค ๊ด€๋ฆฌ</a>
577
- <a href="{{ url_for('main.admin_files') }}" class="btn btn-secondary">ํŒŒ์ผ ๋ชฉ๋ก</a>
578
- <a href="{{ url_for('main.admin_messages') }}" class="btn btn-secondary">๋ฉ”์‹œ์ง€ ํ™•์ธ</a>
579
- <a href="{{ url_for('main.admin_prompts') }}" class="btn btn-secondary">ํ”„๋กฌํ”„ํŠธ ๊ด€๋ฆฌ</a>
580
- <a href="{{ url_for('main.admin_settings') }}" class="btn btn-secondary">AI ์„ค์ •</a>
581
- <a href="{{ url_for('main.admin_utils') }}" class="btn btn-secondary">์œ ํ‹ธ</a>
582
- <a href="{{ url_for('main.admin_tokens') }}" class="btn btn-secondary">ํ† ํฐ ํ†ต๊ณ„</a>
583
- <a href="{{ url_for('main.index') }}" class="btn btn-secondary">๋ฉ”์ธ์œผ๋กœ</a>
584
- <a href="{{ url_for('main.logout') }}" class="btn btn-secondary">๋กœ๊ทธ์•„์›ƒ</a>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
585
  </div>
586
  </div>
587
 
@@ -594,13 +727,25 @@
594
  </div>
595
  <div class="mobile-menu-user">{{ current_user.nickname or current_user.username }}</div>
596
  <div class="mobile-menu-items">
597
- <a href="{{ url_for('main.admin_webnovels') }}" class="mobile-menu-item" onclick="closeMobileMenu()">์›น์†Œ์„ค ๊ด€๋ฆฌ</a>
598
- <a href="{{ url_for('main.admin_files') }}" class="mobile-menu-item" onclick="closeMobileMenu()">ํŒŒ์ผ ๋ชฉ๋ก</a>
 
 
 
 
599
  <a href="{{ url_for('main.admin_messages') }}" class="mobile-menu-item" onclick="closeMobileMenu()">๋ฉ”์‹œ์ง€ ํ™•์ธ</a>
600
- <a href="{{ url_for('main.admin_prompts') }}" class="mobile-menu-item" onclick="closeMobileMenu()">ํ”„๋กฌํ”„ํŠธ ๊ด€๋ฆฌ</a>
 
601
  <a href="{{ url_for('main.admin_settings') }}" class="mobile-menu-item" onclick="closeMobileMenu()">AI ์„ค์ •</a>
 
 
 
 
 
 
602
  <a href="{{ url_for('main.admin_utils') }}" class="mobile-menu-item" onclick="closeMobileMenu()">์œ ํ‹ธ</a>
603
- <a href="{{ url_for('main.admin_tokens') }}" class="mobile-menu-item" onclick="closeMobileMenu()">ํ† ํฐ ํ†ต๊ณ„</a>
 
604
  <a href="{{ url_for('main.index') }}" class="mobile-menu-item" onclick="closeMobileMenu()">๋ฉ”์ธ์œผ๋กœ</a>
605
  <a href="{{ url_for('main.logout') }}" class="mobile-menu-item" onclick="closeMobileMenu()">๋กœ๊ทธ์•„์›ƒ</a>
606
  </div>
 
46
 
47
  .header-actions {
48
  display: flex;
49
+ gap: 8px;
50
+ align-items: center;
51
+ flex-wrap: wrap;
52
+ }
53
+
54
+ /* ๋“œ๋กญ๋‹ค์šด ๋ฉ”๋‰ด ์Šคํƒ€์ผ */
55
+ .dropdown {
56
+ position: relative;
57
+ display: inline-block;
58
+ }
59
+
60
+ /* ๋ฒ„ํŠผ๊ณผ ๋ฉ”๋‰ด ์‚ฌ์ด 'ํ‹ˆ'์—์„œ hover๊ฐ€ ๋Š๊ฒจ ๋ฉ”๋‰ด๊ฐ€ ๋‹ซํžˆ๋Š” ํ˜„์ƒ ๋ฐฉ์ง€ */
61
+ .dropdown::after {
62
+ content: '';
63
+ position: absolute;
64
+ left: 0;
65
+ right: 0;
66
+ top: 100%;
67
+ height: 8px;
68
+ }
69
+
70
+ .dropdown-toggle {
71
+ padding: 8px 16px;
72
+ background: #f1f3f4;
73
+ color: #202124;
74
+ border: none;
75
+ border-radius: 6px;
76
+ font-size: 14px;
77
+ font-weight: 500;
78
+ cursor: pointer;
79
+ transition: all 0.2s;
80
+ display: flex;
81
  align-items: center;
82
+ gap: 6px;
83
+ }
84
+
85
+ .dropdown-toggle:hover {
86
+ background: #e8eaed;
87
+ }
88
+
89
+ .dropdown-toggle::after {
90
+ content: 'โ–ผ';
91
+ font-size: 10px;
92
+ transition: transform 0.2s;
93
+ }
94
+
95
+ .dropdown:hover .dropdown-toggle::after {
96
+ transform: rotate(180deg);
97
+ }
98
+
99
+ .dropdown-menu {
100
+ position: absolute;
101
+ top: calc(100% + 4px);
102
+ left: 0;
103
+ margin-top: 0;
104
+ background: white;
105
+ border: 1px solid #dadce0;
106
+ border-radius: 6px;
107
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
108
+ min-width: 200px;
109
+ opacity: 0;
110
+ visibility: hidden;
111
+ transform: translateY(-8px);
112
+ transition: all 0.2s ease;
113
+ z-index: 10000;
114
+ padding: 4px 0;
115
+ pointer-events: none;
116
+ }
117
+
118
+ .dropdown:hover .dropdown-menu {
119
+ opacity: 1;
120
+ visibility: visible;
121
+ transform: translateY(0);
122
+ pointer-events: auto;
123
+ }
124
+
125
+ .dropdown-item {
126
+ display: block;
127
+ padding: 10px 16px;
128
+ color: #202124;
129
+ text-decoration: none;
130
+ font-size: 14px;
131
+ transition: background 0.2s;
132
+ }
133
+
134
+ .dropdown-item:hover {
135
+ background: #f8f9fa;
136
+ }
137
+
138
+ .dropdown-item:first-child {
139
+ border-top-left-radius: 6px;
140
+ border-top-right-radius: 6px;
141
+ }
142
+
143
+ .dropdown-item:last-child {
144
+ border-bottom-left-radius: 6px;
145
+ border-bottom-right-radius: 6px;
146
  }
147
 
148
  .menu-toggle {
 
668
  <button class="menu-toggle" onclick="toggleMobileMenu()" aria-label="๋ฉ”๋‰ด ์—ด๊ธฐ">โ˜ฐ</button>
669
  <div class="header-actions">
670
  <span style="margin-right: 12px; color: #5f6368;">{{ current_user.nickname or current_user.username }}</span>
671
+
672
+ {# ์‚ฌ์ดํŠธ ๊ด€๋ฆฌ #}
673
+ <div class="dropdown">
674
+ <button type="button" class="dropdown-toggle">์‚ฌ์ดํŠธ ๊ด€๋ฆฌ</button>
675
+ <div class="dropdown-menu">
676
+ <a href="{{ url_for('main.admin') }}" class="dropdown-item">์‚ฌ์šฉ์ž ๊ด€๋ฆฌ</a>
677
+ <a href="{{ url_for('main.admin_tokens') }}" class="dropdown-item">ํ† ํฐ ํ†ต๊ณ„</a>
678
+ </div>
679
+ </div>
680
+
681
+ {# ์›น์†Œ์„ค ๊ด€๋ฆฌ #}
682
+ <div class="dropdown">
683
+ <button type="button" class="dropdown-toggle">์›น์†Œ์„ค ๊ด€๋ฆฌ</button>
684
+ <div class="dropdown-menu">
685
+ <a href="{{ url_for('main.admin_webnovels') }}" class="dropdown-item">์›น์†Œ์„ค ์—…๋กœ๋“œ ๋ฐ ๊ด€๋ฆฌ</a>
686
+ <a href="{{ url_for('main.admin_messages') }}" class="dropdown-item">๋ฉ”์‹œ์ง€ ํ™•์ธ</a>
687
+ </div>
688
+ </div>
689
+
690
+ {# AI ์„ค์ • #}
691
+ <div class="dropdown">
692
+ <button type="button" class="dropdown-toggle">AI ์„ค์ •</button>
693
+ <div class="dropdown-menu">
694
+ <a href="{{ url_for('main.admin_settings') }}" class="dropdown-item">AI ์„ค์ •</a>
695
+ <a href="{{ url_for('main.admin_prompts') }}" class="dropdown-item">ํ”„๋กฌํ”„ํŠธ ๊ด€๋ฆฌ</a>
696
+ </div>
697
+ </div>
698
+
699
+ {# ์ฑ—๋ด‡ #}
700
+ <div class="dropdown">
701
+ <button type="button" class="dropdown-toggle">์ฑ—๋ด‡</button>
702
+ <div class="dropdown-menu">
703
+ <a href="{{ url_for('main.admin_tags') }}" class="dropdown-item">ํƒœ๊ทธ/ํ”„๋กฌํ”„ํŠธ</a>
704
+ </div>
705
+ </div>
706
+
707
+ {# ํŽธ์˜๊ธฐ๋Šฅ #}
708
+ <div class="dropdown">
709
+ <button type="button" class="dropdown-toggle">ํŽธ์˜๊ธฐ๋Šฅ</button>
710
+ <div class="dropdown-menu">
711
+ <a href="{{ url_for('main.admin_utils') }}" class="dropdown-item">์œ ํ‹ธ</a>
712
+ </div>
713
+ </div>
714
+
715
+ {# ๋ฉ”์ธ์œผ๋กœ #}
716
+ <a href="{{ url_for('main.index') }}" class="btn btn-secondary" style="padding: 8px 16px; font-size: 14px; margin-right: 4px;">๋ฉ”์ธ์œผ๋กœ</a>
717
+ <a href="{{ url_for('main.logout') }}" class="btn btn-secondary" style="padding: 8px 16px; font-size: 14px;">๋กœ๊ทธ์•„์›ƒ</a>
718
  </div>
719
  </div>
720
 
 
727
  </div>
728
  <div class="mobile-menu-user">{{ current_user.nickname or current_user.username }}</div>
729
  <div class="mobile-menu-items">
730
+ <div style="padding: 8px 20px; font-size: 11px; font-weight: 600; color: #5f6368; text-transform: uppercase; border-bottom: 1px solid #f1f3f4;">์‚ฌ์ดํŠธ ๊ด€๋ฆฌ</div>
731
+ <a href="{{ url_for('main.admin') }}" class="mobile-menu-item" onclick="closeMobileMenu()">์‚ฌ์šฉ์ž ๊ด€๋ฆฌ</a>
732
+ <a href="{{ url_for('main.admin_tokens') }}" class="mobile-menu-item" onclick="closeMobileMenu()">ํ† ํฐ ํ†ต๊ณ„</a>
733
+
734
+ <div style="padding: 8px 20px; font-size: 11px; font-weight: 600; color: #5f6368; text-transform: uppercase; border-bottom: 1px solid #f1f3f4; margin-top: 8px;">์›น์†Œ์„ค ๊ด€๋ฆฌ</div>
735
+ <a href="{{ url_for('main.admin_webnovels') }}" class="mobile-menu-item" onclick="closeMobileMenu()">์›น์†Œ์„ค ์—…๋กœ๋“œ ๋ฐ ๊ด€๋ฆฌ</a>
736
  <a href="{{ url_for('main.admin_messages') }}" class="mobile-menu-item" onclick="closeMobileMenu()">๋ฉ”์‹œ์ง€ ํ™•์ธ</a>
737
+
738
+ <div style="padding: 8px 20px; font-size: 11px; font-weight: 600; color: #5f6368; text-transform: uppercase; border-bottom: 1px solid #f1f3f4; margin-top: 8px;">AI ์„ค์ •</div>
739
  <a href="{{ url_for('main.admin_settings') }}" class="mobile-menu-item" onclick="closeMobileMenu()">AI ์„ค์ •</a>
740
+ <a href="{{ url_for('main.admin_prompts') }}" class="mobile-menu-item" onclick="closeMobileMenu()">ํ”„๋กฌํ”„ํŠธ ๊ด€๋ฆฌ</a>
741
+
742
+ <div style="padding: 8px 20px; font-size: 11px; font-weight: 600; color: #5f6368; text-transform: uppercase; border-bottom: 1px solid #f1f3f4; margin-top: 8px;">์ฑ—๋ด‡</div>
743
+ <a href="{{ url_for('main.admin_tags') }}" class="mobile-menu-item" onclick="closeMobileMenu()">ํƒœ๊ทธ/ํ”„๋กฌํ”„ํŠธ</a>
744
+
745
+ <div style="padding: 8px 20px; font-size: 11px; font-weight: 600; color: #5f6368; text-transform: uppercase; border-bottom: 1px solid #f1f3f4; margin-top: 8px;">ํŽธ์˜๊ธฐ๋Šฅ</div>
746
  <a href="{{ url_for('main.admin_utils') }}" class="mobile-menu-item" onclick="closeMobileMenu()">์œ ํ‹ธ</a>
747
+
748
+ <div style="padding: 8px 20px; font-size: 11px; font-weight: 600; color: #5f6368; text-transform: uppercase; border-bottom: 1px solid #f1f3f4; margin-top: 8px;">๊ธฐํƒ€</div>
749
  <a href="{{ url_for('main.index') }}" class="mobile-menu-item" onclick="closeMobileMenu()">๋ฉ”์ธ์œผ๋กœ</a>
750
  <a href="{{ url_for('main.logout') }}" class="mobile-menu-item" onclick="closeMobileMenu()">๋กœ๊ทธ์•„์›ƒ</a>
751
  </div>
templates/admin_files.html CHANGED
@@ -44,10 +44,107 @@
44
  gap: 12px;
45
  }
46
 
47
- .header-actions {
48
  display: flex;
49
- gap: 12px;
50
  align-items: center;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
51
  }
52
 
53
  .menu-toggle {
@@ -323,6 +420,21 @@
323
  max-height: 90vh;
324
  overflow-y: auto;
325
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
326
 
327
  .modal-header {
328
  display: flex;
@@ -475,16 +587,53 @@
475
  <button class="menu-toggle" onclick="toggleMobileMenu()" aria-label="๋ฉ”๋‰ด ์—ด๊ธฐ">โ˜ฐ</button>
476
  <div class="header-actions">
477
  <span style="margin-right: 12px; color: #5f6368;">{{ current_user.nickname or current_user.username }}</span>
478
- <a href="{{ url_for('main.admin') }}" class="btn btn-secondary">์‚ฌ์šฉ์ž ๊ด€๋ฆฌ</a>
479
- <a href="{{ url_for('main.admin_webnovels') }}" class="btn btn-secondary">์›น์†Œ์„ค ๊ด€๋ฆฌ</a>
480
- <a href="{{ url_for('main.admin_messages') }}" class="btn btn-secondary">๋ฉ”์‹œ์ง€ ํ™•์ธ</a>
481
- <a href="{{ url_for('main.admin_prompts') }}" class="btn btn-secondary">ํ”„๋กฌํ”„ํŠธ ๊ด€๋ฆฌ</a>
482
- <a href="{{ url_for('main.admin_settings') }}" class="btn btn-secondary">AI ์„ค์ •</a>
483
- <a href="{{ url_for('main.admin_utils') }}" class="btn btn-secondary">์œ ํ‹ธ</a>
484
- <a href="{{ url_for('main.admin_tokens') }}" class="btn btn-secondary">ํ† ํฐ ํ†ต๊ณ„</a>
485
- <a href="{{ url_for('main.admin_utils') }}" class="btn btn-secondary">์œ ํ‹ธ</a>
486
- <a href="{{ url_for('main.index') }}" class="btn btn-secondary">๋ฉ”์ธ์œผ๋กœ</a>
487
- <a href="{{ url_for('main.logout') }}" class="btn btn-secondary">๋กœ๊ทธ์•„์›ƒ</a>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
488
  </div>
489
  </div>
490
 
@@ -497,14 +646,25 @@
497
  </div>
498
  <div class="mobile-menu-user">{{ current_user.nickname or current_user.username }}</div>
499
  <div class="mobile-menu-items">
 
500
  <a href="{{ url_for('main.admin') }}" class="mobile-menu-item" onclick="closeMobileMenu()">์‚ฌ์šฉ์ž ๊ด€๋ฆฌ</a>
501
- <a href="{{ url_for('main.admin_webnovels') }}" class="mobile-menu-item" onclick="closeMobileMenu()">์›น์†Œ์„ค ๊ด€๋ฆฌ</a>
 
 
 
502
  <a href="{{ url_for('main.admin_messages') }}" class="mobile-menu-item" onclick="closeMobileMenu()">๋ฉ”์‹œ์ง€ ํ™•์ธ</a>
503
- <a href="{{ url_for('main.admin_prompts') }}" class="mobile-menu-item" onclick="closeMobileMenu()">ํ”„๋กฌํ”„ํŠธ ๊ด€๋ฆฌ</a>
 
504
  <a href="{{ url_for('main.admin_settings') }}" class="mobile-menu-item" onclick="closeMobileMenu()">AI ์„ค์ •</a>
 
 
 
 
 
 
505
  <a href="{{ url_for('main.admin_utils') }}" class="mobile-menu-item" onclick="closeMobileMenu()">์œ ํ‹ธ</a>
506
- <a href="{{ url_for('main.admin_tokens') }}" class="mobile-menu-item" onclick="closeMobileMenu()">ํ† ํฐ ํ†ต๊ณ„</a>
507
- <a href="{{ url_for('main.admin_utils') }}" class="mobile-menu-item" onclick="closeMobileMenu()">์œ ํ‹ธ</a>
508
  <a href="{{ url_for('main.index') }}" class="mobile-menu-item" onclick="closeMobileMenu()">๋ฉ”์ธ์œผ๋กœ</a>
509
  <a href="{{ url_for('main.logout') }}" class="mobile-menu-item" onclick="closeMobileMenu()">๋กœ๊ทธ์•„์›ƒ</a>
510
  </div>
@@ -674,18 +834,6 @@
674
  </div>
675
  </div>
676
 
677
- <!-- ํƒœ๊ทธ ๋ณด๊ธฐ ๋ชจ๋‹ฌ -->
678
- <div id="tagsModal" class="modal">
679
- <div class="modal-content">
680
- <div class="modal-header">
681
- <div class="modal-title" id="tagsModalTitle">ํƒœ๊ทธ ๋ชฉ๋ก</div>
682
- <button class="modal-close" onclick="closeTagsModal()">&times;</button>
683
- </div>
684
- <div id="tagsContent" class="modal-body">
685
- ํƒœ๊ทธ๋ฅผ ๋ถˆ๋Ÿฌ์˜ค๋Š” ์ค‘...
686
- </div>
687
- </div>
688
- </div>
689
 
690
  <!-- vis-network ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ -->
691
  <script type="text/javascript" src="https://unpkg.com/vis-network@latest/standalone/umd/vis-network.min.js"></script>
@@ -811,7 +959,6 @@
811
  <div class="file-actions">
812
  <button class="btn btn-secondary" onclick="viewSummary(${file.id}, '${escapeHtml(file.original_filename)}')" style="padding: 4px 8px; font-size: 12px; margin-right: 4px;">์š”์•ฝ ๋‚ด์šฉ ๋ณด๊ธฐ</button>
813
  <button class="btn btn-info" onclick="viewChunks(${file.id}, '${escapeHtml(file.original_filename)}')" style="padding: 4px 8px; font-size: 12px; margin-right: 4px;">์ฒญํฌ ๋ณด๊ธฐ</button>
814
- <button class="btn" onclick="viewTags(${file.id}, '${escapeHtml(file.original_filename)}')" style="padding: 4px 8px; font-size: 12px; margin-right: 4px; background: #673ab7; color: white;">ํƒœ๊ทธ ๋ณด๊ธฐ</button>
815
  <button class="btn btn-primary" onclick="viewGraphRAG(${file.id}, '${escapeHtml(file.original_filename)}')" style="padding: 4px 8px; font-size: 12px; margin-right: 4px;">GraphRAG ๋ณด๊ธฐ</button>
816
  <button class="btn btn-success" onclick="viewGraphRAGVisualization(${file.id}, '${escapeHtml(file.original_filename)}')" style="padding: 4px 8px; font-size: 12px;">๊ทธ๋ž˜ํ”„ ์‹œ๊ฐํ™”</button>
817
  </div>
@@ -1849,112 +1996,6 @@
1849
  }
1850
  });
1851
 
1852
- async function viewTags(fileId, fileName) {
1853
- const modal = document.getElementById('tagsModal');
1854
- const title = document.getElementById('tagsModalTitle');
1855
- const content = document.getElementById('tagsContent');
1856
-
1857
- title.textContent = `ํƒœ๊ทธ ๋ชฉ๋ก - ${fileName}`;
1858
- content.innerHTML = '<div style="text-align: center; padding: 24px; color: #5f6368;">ํƒœ๊ทธ๋ฅผ ๋ถˆ๋Ÿฌ์˜ค๋Š” ์ค‘...</div>';
1859
- modal.classList.add('active');
1860
-
1861
- try {
1862
- // ํŒŒ์ผ ์ •๋ณด ๋‹ค์‹œ ์กฐํšŒ (ํƒœ๊ทธ ์ •๋ณด ํฌํ•จ)
1863
- const response = await fetch('/api/files', { credentials: 'include' });
1864
- const data = await response.json();
1865
- const file = data.files.find(f => f.id === fileId);
1866
-
1867
- if (file && file.tags) {
1868
- try {
1869
- const tags = JSON.parse(file.tags);
1870
- let html = '';
1871
-
1872
- // ๊ณ„์ธต์  ๊ตฌ์กฐ ๋ Œ๋”๋ง
1873
-
1874
- // Parent Chunk
1875
- if (tags.parent_chunk) {
1876
- html += '<div style="margin-bottom: 24px;">';
1877
- html += '<h3 style="font-size: 15px; font-weight: 700; color: #1a73e8; margin-bottom: 12px; border-bottom: 2px solid #e8f0fe; padding-bottom: 8px;">Parent Chunk (์ „์ฒด ์š”์•ฝ)</h3>';
1878
- html += renderTagsGroup(tags.parent_chunk);
1879
- html += '</div>';
1880
- }
1881
-
1882
- // Episodes
1883
- if (tags.episodes) {
1884
- html += '<div style="margin-bottom: 24px;">';
1885
- html += '<h3 style="font-size: 15px; font-weight: 700; color: #1a73e8; margin-bottom: 12px; border-bottom: 2px solid #e8f0fe; padding-bottom: 8px;">ํšŒ์ฐจ๋ณ„ ๋ถ„์„</h3>';
1886
- html += renderTagsGroup(tags.episodes);
1887
- html += '</div>';
1888
- }
1889
-
1890
- // GraphRAG
1891
- if (tags.graph_rag) {
1892
- html += '<div style="margin-bottom: 24px;">';
1893
- html += '<h3 style="font-size: 15px; font-weight: 700; color: #1a73e8; margin-bottom: 12px; border-bottom: 2px solid #e8f0fe; padding-bottom: 8px;">GraphRAG (์ง€์‹ ๊ทธ๋ž˜ํ”„)</h3>';
1894
- html += renderTagsGroup(tags.graph_rag);
1895
- html += '</div>';
1896
- }
1897
-
1898
- // ๊ตฌ๋ฒ„์ „ ํƒœ๊ทธ (๋ฐฐ์—ด) ํ˜ธํ™˜์„ฑ - ๊ณ„์ธต์  ๊ตฌ์กฐ๊ฐ€ ์•„๋‹ ๊ฒฝ์šฐ
1899
- if (!tags.parent_chunk && !tags.episodes && !tags.graph_rag && Array.isArray(tags)) {
1900
- html += '<div style="display: flex; flex-wrap: wrap; gap: 8px;">';
1901
- tags.forEach(tag => {
1902
- html += `<span style="background: #e8f0fe; color: #1a73e8; padding: 6px 12px; border-radius: 16px; font-size: 13px; border: 1px solid #d2e3fc;">#${escapeHtml(tag)}</span>`;
1903
- });
1904
- html += '</div>';
1905
- }
1906
-
1907
- if (!html) {
1908
- html = '<div style="text-align: center; padding: 20px; color: #5f6368;">ํƒœ๊ทธ ํ˜•์‹์ด ์˜ฌ๋ฐ”๋ฅด์ง€ ์•Š๊ฑฐ๋‚˜ ๋น„์–ด์žˆ์Šต๋‹ˆ๋‹ค.</div>';
1909
- }
1910
-
1911
- content.innerHTML = html;
1912
- } catch (e) {
1913
- console.error('ํƒœ๊ทธ ํŒŒ์‹ฑ ์˜ค๋ฅ˜:', e);
1914
- content.innerHTML = '<div style="text-align: center; padding: 20px; color: #c5221f;">ํƒœ๊ทธ ๋ฐ์ดํ„ฐ๋ฅผ ํŒŒ์‹ฑํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.</div>';
1915
- }
1916
- } else {
1917
- content.innerHTML = '<div style="text-align: center; padding: 20px; color: #5f6368;">์ƒ์„ฑ๋œ ํƒœ๊ทธ๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค.</div>';
1918
- }
1919
- } catch (error) {
1920
- console.error('ํƒœ๊ทธ ๋กœ๋“œ ์˜ค๋ฅ˜:', error);
1921
- content.innerHTML = '<div style="text-align: center; padding: 20px; color: #c5221f;">ํƒœ๊ทธ ์ •๋ณด๋ฅผ ๋ถˆ๋Ÿฌ์˜ค๋Š” ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค.</div>';
1922
- }
1923
- }
1924
-
1925
- function renderTagsGroup(group) {
1926
- let html = '<div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 16px;">';
1927
-
1928
- // ํ‚ค ์ด๋ฆ„์„ ํ•œ๊ธ€๋กœ ๋งคํ•‘
1929
- const keyMap = {
1930
- 'world_view': '์„ธ๊ณ„๊ด€',
1931
- 'characters': '์ธ๋ฌผ',
1932
- 'story': '์Šคํ† ๋ฆฌ',
1933
- 'others': '๊ธฐํƒ€',
1934
- 'items': '์•„์ดํ…œ/์†Œ์žฌ',
1935
- 'relationships': '๊ด€๊ณ„',
1936
- 'events': '์‚ฌ๊ฑด'
1937
- };
1938
-
1939
- for (const [key, values] of Object.entries(group)) {
1940
- if (values && values.length > 0) {
1941
- const label = keyMap[key] || key;
1942
- html += `<div style="background: #f8f9fa; padding: 12px; border-radius: 8px; border: 1px solid #e8eaed;">`;
1943
- html += `<div style="font-size: 13px; font-weight: 700; color: #5f6368; margin-bottom: 8px;">${escapeHtml(label)}</div>`;
1944
- html += `<div style="display: flex; flex-wrap: wrap; gap: 6px;">`;
1945
- values.forEach(tag => {
1946
- html += `<span style="background: white; border: 1px solid #dadce0; padding: 4px 8px; border-radius: 4px; font-size: 12px; color: #3c4043;">${escapeHtml(tag)}</span>`;
1947
- });
1948
- html += `</div></div>`;
1949
- }
1950
- }
1951
- html += '</div>';
1952
- return html;
1953
- }
1954
-
1955
- function closeTagsModal() {
1956
- document.getElementById('tagsModal').classList.remove('active');
1957
- }
1958
 
1959
  // ํŽ˜์ด์ง€ ๋กœ๋“œ ์‹œ ์ดˆ๊ธฐํ™”
1960
  window.addEventListener('load', () => {
 
44
  gap: 12px;
45
  }
46
 
47
+ .header-actions {
48
  display: flex;
49
+ gap: 8px;
50
  align-items: center;
51
+ flex-wrap: wrap;
52
+ }
53
+
54
+ /* ๋“œ๋กญ๋‹ค์šด ๋ฉ”๋‰ด ์Šคํƒ€์ผ */
55
+ .dropdown {
56
+ position: relative;
57
+ display: inline-block;
58
+ }
59
+
60
+ /* ๋ฒ„ํŠผ๊ณผ ๋ฉ”๋‰ด ์‚ฌ์ด 'ํ‹ˆ'์—์„œ hover๊ฐ€ ๋Š๊ฒจ ๋ฉ”๋‰ด๊ฐ€ ๋‹ซํžˆ๋Š” ํ˜„์ƒ ๋ฐฉ์ง€ */
61
+ .dropdown::after {
62
+ content: '';
63
+ position: absolute;
64
+ left: 0;
65
+ right: 0;
66
+ top: 100%;
67
+ height: 8px;
68
+ }
69
+
70
+ .dropdown-toggle {
71
+ padding: 8px 16px;
72
+ background: #f1f3f4;
73
+ color: #202124;
74
+ border: none;
75
+ border-radius: 6px;
76
+ font-size: 14px;
77
+ font-weight: 500;
78
+ cursor: pointer;
79
+ transition: all 0.2s;
80
+ display: flex;
81
+ align-items: center;
82
+ gap: 6px;
83
+ }
84
+
85
+ .dropdown-toggle:hover {
86
+ background: #e8eaed;
87
+ }
88
+
89
+ .dropdown-toggle::after {
90
+ content: 'โ–ผ';
91
+ font-size: 10px;
92
+ transition: transform 0.2s;
93
+ }
94
+
95
+ .dropdown:hover .dropdown-toggle::after {
96
+ transform: rotate(180deg);
97
+ }
98
+
99
+ .dropdown-menu {
100
+ position: absolute;
101
+ top: calc(100% + 4px);
102
+ left: 0;
103
+ margin-top: 0;
104
+ background: white;
105
+ border: 1px solid #dadce0;
106
+ border-radius: 6px;
107
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
108
+ min-width: 200px;
109
+ opacity: 0;
110
+ visibility: hidden;
111
+ transform: translateY(-8px);
112
+ transition: all 0.2s ease;
113
+ z-index: 10000;
114
+ padding: 4px 0;
115
+ pointer-events: none;
116
+ }
117
+
118
+ .dropdown:hover .dropdown-menu {
119
+ opacity: 1;
120
+ visibility: visible;
121
+ transform: translateY(0);
122
+ pointer-events: auto;
123
+ }
124
+
125
+
126
+
127
+ .dropdown-item {
128
+ display: block;
129
+ padding: 10px 16px;
130
+ color: #202124;
131
+ text-decoration: none;
132
+ font-size: 14px;
133
+ transition: background 0.2s;
134
+ }
135
+
136
+ .dropdown-item:hover {
137
+ background: #f8f9fa;
138
+ }
139
+
140
+ .dropdown-item:first-child {
141
+ border-top-left-radius: 6px;
142
+ border-top-right-radius: 6px;
143
+ }
144
+
145
+ .dropdown-item:last-child {
146
+ border-bottom-left-radius: 6px;
147
+ border-bottom-right-radius: 6px;
148
  }
149
 
150
  .menu-toggle {
 
420
  max-height: 90vh;
421
  overflow-y: auto;
422
  }
423
+
424
+ .modal-body {
425
+ max-height: calc(90vh - 100px);
426
+ overflow-y: auto;
427
+ }
428
+
429
+ #tagsContent {
430
+ max-height: calc(90vh - 120px);
431
+ overflow-y: auto;
432
+ }
433
+
434
+ #simpleTagsContent, #detailedTagsContent {
435
+ max-height: calc(90vh - 180px);
436
+ overflow-y: auto;
437
+ }
438
 
439
  .modal-header {
440
  display: flex;
 
587
  <button class="menu-toggle" onclick="toggleMobileMenu()" aria-label="๋ฉ”๋‰ด ์—ด๊ธฐ">โ˜ฐ</button>
588
  <div class="header-actions">
589
  <span style="margin-right: 12px; color: #5f6368;">{{ current_user.nickname or current_user.username }}</span>
590
+
591
+ {# ์‚ฌ์ดํŠธ ๊ด€๋ฆฌ #}
592
+ <div class="dropdown">
593
+ <button type="button" class="dropdown-toggle">์‚ฌ์ดํŠธ ๊ด€๋ฆฌ</button>
594
+ <div class="dropdown-menu">
595
+ <a href="{{ url_for('main.admin') }}" class="dropdown-item">์‚ฌ์šฉ์ž ๊ด€๋ฆฌ</a>
596
+ <a href="{{ url_for('main.admin_tokens') }}" class="dropdown-item">ํ† ํฐ ํ†ต๊ณ„</a>
597
+ </div>
598
+ </div>
599
+
600
+ {# ์›น์†Œ์„ค ๊ด€๋ฆฌ #}
601
+ <div class="dropdown">
602
+ <button type="button" class="dropdown-toggle">์›น์†Œ์„ค ๊ด€๋ฆฌ</button>
603
+ <div class="dropdown-menu">
604
+ <a href="{{ url_for('main.admin_webnovels') }}" class="dropdown-item">์›น์†Œ์„ค ์—…๋กœ๋“œ ๋ฐ ๊ด€๋ฆฌ</a>
605
+ <a href="{{ url_for('main.admin_messages') }}" class="dropdown-item">๋ฉ”์‹œ์ง€ ํ™•์ธ</a>
606
+ </div>
607
+ </div>
608
+
609
+ {# AI ์„ค์ • #}
610
+ <div class="dropdown">
611
+ <button type="button" class="dropdown-toggle">AI ์„ค์ •</button>
612
+ <div class="dropdown-menu">
613
+ <a href="{{ url_for('main.admin_settings') }}" class="dropdown-item">AI ์„ค์ •</a>
614
+ <a href="{{ url_for('main.admin_prompts') }}" class="dropdown-item">ํ”„๋กฌํ”„ํŠธ ๊ด€๋ฆฌ</a>
615
+ </div>
616
+ </div>
617
+
618
+ {# ์ฑ—๋ด‡ #}
619
+ <div class="dropdown">
620
+ <button type="button" class="dropdown-toggle">์ฑ—๋ด‡</button>
621
+ <div class="dropdown-menu">
622
+ <a href="{{ url_for('main.admin_tags') }}" class="dropdown-item">ํƒœ๊ทธ/ํ”„๋กฌํ”„ํŠธ</a>
623
+ </div>
624
+ </div>
625
+
626
+ {# ํŽธ์˜๊ธฐ๋Šฅ #}
627
+ <div class="dropdown">
628
+ <button type="button" class="dropdown-toggle">ํŽธ์˜๊ธฐ๋Šฅ</button>
629
+ <div class="dropdown-menu">
630
+ <a href="{{ url_for('main.admin_utils') }}" class="dropdown-item">์œ ํ‹ธ</a>
631
+ </div>
632
+ </div>
633
+
634
+ {# ๋ฉ”์ธ์œผ๋กœ #}
635
+ <a href="{{ url_for('main.index') }}" class="btn btn-secondary" style="padding: 8px 16px; font-size: 14px; margin-right: 4px;">๋ฉ”์ธ์œผ๋กœ</a>
636
+ <a href="{{ url_for('main.logout') }}" class="btn btn-secondary" style="padding: 8px 16px; font-size: 14px;">๋กœ๊ทธ์•„์›ƒ</a>
637
  </div>
638
  </div>
639
 
 
646
  </div>
647
  <div class="mobile-menu-user">{{ current_user.nickname or current_user.username }}</div>
648
  <div class="mobile-menu-items">
649
+ <div style="padding: 8px 20px; font-size: 11px; font-weight: 600; color: #5f6368; text-transform: uppercase; border-bottom: 1px solid #f1f3f4;">์‚ฌ์ดํŠธ ๊ด€๋ฆฌ</div>
650
  <a href="{{ url_for('main.admin') }}" class="mobile-menu-item" onclick="closeMobileMenu()">์‚ฌ์šฉ์ž ๊ด€๋ฆฌ</a>
651
+ <a href="{{ url_for('main.admin_tokens') }}" class="mobile-menu-item" onclick="closeMobileMenu()">ํ† ํฐ ํ†ต๊ณ„</a>
652
+
653
+ <div style="padding: 8px 20px; font-size: 11px; font-weight: 600; color: #5f6368; text-transform: uppercase; border-bottom: 1px solid #f1f3f4; margin-top: 8px;">์›น์†Œ์„ค ๊ด€๋ฆฌ</div>
654
+ <a href="{{ url_for('main.admin_webnovels') }}" class="mobile-menu-item" onclick="closeMobileMenu()">์›น์†Œ์„ค ์—…๋กœ๋“œ ๋ฐ ๊ด€๋ฆฌ</a>
655
  <a href="{{ url_for('main.admin_messages') }}" class="mobile-menu-item" onclick="closeMobileMenu()">๋ฉ”์‹œ์ง€ ํ™•์ธ</a>
656
+
657
+ <div style="padding: 8px 20px; font-size: 11px; font-weight: 600; color: #5f6368; text-transform: uppercase; border-bottom: 1px solid #f1f3f4; margin-top: 8px;">AI ์„ค์ •</div>
658
  <a href="{{ url_for('main.admin_settings') }}" class="mobile-menu-item" onclick="closeMobileMenu()">AI ์„ค์ •</a>
659
+ <a href="{{ url_for('main.admin_prompts') }}" class="mobile-menu-item" onclick="closeMobileMenu()">ํ”„๋กฌํ”„ํŠธ ๊ด€๋ฆฌ</a>
660
+
661
+ <div style="padding: 8px 20px; font-size: 11px; font-weight: 600; color: #5f6368; text-transform: uppercase; border-bottom: 1px solid #f1f3f4; margin-top: 8px;">์ฑ—๋ด‡</div>
662
+ <a href="{{ url_for('main.admin_tags') }}" class="mobile-menu-item" onclick="closeMobileMenu()">ํƒœ๊ทธ/ํ”„๋กฌํ”„ํŠธ</a>
663
+
664
+ <div style="padding: 8px 20px; font-size: 11px; font-weight: 600; color: #5f6368; text-transform: uppercase; border-bottom: 1px solid #f1f3f4; margin-top: 8px;">ํŽธ์˜๊ธฐ๋Šฅ</div>
665
  <a href="{{ url_for('main.admin_utils') }}" class="mobile-menu-item" onclick="closeMobileMenu()">์œ ํ‹ธ</a>
666
+
667
+ <div style="padding: 8px 20px; font-size: 11px; font-weight: 600; color: #5f6368; text-transform: uppercase; border-bottom: 1px solid #f1f3f4; margin-top: 8px;">๊ธฐํƒ€</div>
668
  <a href="{{ url_for('main.index') }}" class="mobile-menu-item" onclick="closeMobileMenu()">๋ฉ”์ธ์œผ๋กœ</a>
669
  <a href="{{ url_for('main.logout') }}" class="mobile-menu-item" onclick="closeMobileMenu()">๋กœ๊ทธ์•„์›ƒ</a>
670
  </div>
 
834
  </div>
835
  </div>
836
 
 
 
 
 
 
 
 
 
 
 
 
 
837
 
838
  <!-- vis-network ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ -->
839
  <script type="text/javascript" src="https://unpkg.com/vis-network@latest/standalone/umd/vis-network.min.js"></script>
 
959
  <div class="file-actions">
960
  <button class="btn btn-secondary" onclick="viewSummary(${file.id}, '${escapeHtml(file.original_filename)}')" style="padding: 4px 8px; font-size: 12px; margin-right: 4px;">์š”์•ฝ ๋‚ด์šฉ ๋ณด๊ธฐ</button>
961
  <button class="btn btn-info" onclick="viewChunks(${file.id}, '${escapeHtml(file.original_filename)}')" style="padding: 4px 8px; font-size: 12px; margin-right: 4px;">์ฒญํฌ ๋ณด๊ธฐ</button>
 
962
  <button class="btn btn-primary" onclick="viewGraphRAG(${file.id}, '${escapeHtml(file.original_filename)}')" style="padding: 4px 8px; font-size: 12px; margin-right: 4px;">GraphRAG ๋ณด๊ธฐ</button>
963
  <button class="btn btn-success" onclick="viewGraphRAGVisualization(${file.id}, '${escapeHtml(file.original_filename)}')" style="padding: 4px 8px; font-size: 12px;">๊ทธ๋ž˜ํ”„ ์‹œ๊ฐํ™”</button>
964
  </div>
 
1996
  }
1997
  });
1998
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1999
 
2000
  // ํŽ˜์ด์ง€ ๋กœ๋“œ ์‹œ ์ดˆ๊ธฐํ™”
2001
  window.addEventListener('load', () => {
templates/admin_messages.html CHANGED
@@ -44,10 +44,107 @@
44
  gap: 12px;
45
  }
46
 
47
- .header-actions {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
48
  display: flex;
49
- gap: 12px;
50
  align-items: center;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
51
  }
52
 
53
  .menu-toggle {
@@ -476,16 +573,53 @@
476
  <button class="menu-toggle" onclick="toggleMobileMenu()" aria-label="๋ฉ”๋‰ด ์—ด๊ธฐ">โ˜ฐ</button>
477
  <div class="header-actions">
478
  <span style="margin-right: 12px; color: #5f6368;">{{ current_user.nickname or current_user.username }}</span>
479
- <a href="{{ url_for('main.admin') }}" class="btn btn-secondary">์‚ฌ์šฉ์ž ๊ด€๋ฆฌ</a>
480
- <a href="{{ url_for('main.admin_webnovels') }}" class="btn btn-secondary">์›น์†Œ์„ค ๊ด€๋ฆฌ</a>
481
- <a href="{{ url_for('main.admin_files') }}" class="btn btn-secondary">ํŒŒ์ผ ๋ชฉ๋ก</a>
482
- <a href="{{ url_for('main.admin_prompts') }}" class="btn btn-secondary">ํ”„๋กฌํ”„ํŠธ ๊ด€๋ฆฌ</a>
483
- <a href="{{ url_for('main.admin_settings') }}" class="btn btn-secondary">AI ์„ค์ •</a>
484
- <a href="{{ url_for('main.admin_utils') }}" class="btn btn-secondary">์œ ํ‹ธ</a>
485
- <a href="{{ url_for('main.admin_tokens') }}" class="btn btn-secondary">ํ† ํฐ ํ†ต๊ณ„</a>
486
- <a href="{{ url_for('main.admin_utils') }}" class="btn btn-secondary">์œ ํ‹ธ</a>
487
- <a href="{{ url_for('main.index') }}" class="btn btn-secondary">๋ฉ”์ธ์œผ๋กœ</a>
488
- <a href="{{ url_for('main.logout') }}" class="btn btn-secondary">๋กœ๊ทธ์•„์›ƒ</a>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
489
  </div>
490
  </div>
491
 
@@ -498,14 +632,25 @@
498
  </div>
499
  <div class="mobile-menu-user">{{ current_user.nickname or current_user.username }}</div>
500
  <div class="mobile-menu-items">
 
501
  <a href="{{ url_for('main.admin') }}" class="mobile-menu-item" onclick="closeMobileMenu()">์‚ฌ์šฉ์ž ๊ด€๋ฆฌ</a>
502
- <a href="{{ url_for('main.admin_webnovels') }}" class="mobile-menu-item" onclick="closeMobileMenu()">์›น์†Œ์„ค ๊ด€๋ฆฌ</a>
503
- <a href="{{ url_for('main.admin_files') }}" class="mobile-menu-item" onclick="closeMobileMenu()">ํŒŒ์ผ ๋ชฉ๋ก</a>
504
- <a href="{{ url_for('main.admin_prompts') }}" class="mobile-menu-item" onclick="closeMobileMenu()">ํ”„๋กฌํ”„ํŠธ ๊ด€๋ฆฌ</a>
505
- <a href="{{ url_for('main.admin_settings') }}" class="mobile-menu-item" onclick="closeMobileMenu()">AI ์„ค์ •</a>
506
- <a href="{{ url_for('main.admin_utils') }}" class="mobile-menu-item" onclick="closeMobileMenu()">์œ ํ‹ธ</a>
507
  <a href="{{ url_for('main.admin_tokens') }}" class="mobile-menu-item" onclick="closeMobileMenu()">ํ† ํฐ ํ†ต๊ณ„</a>
 
 
 
 
 
 
 
 
 
 
 
 
 
508
  <a href="{{ url_for('main.admin_utils') }}" class="mobile-menu-item" onclick="closeMobileMenu()">์œ ํ‹ธ</a>
 
 
509
  <a href="{{ url_for('main.index') }}" class="mobile-menu-item" onclick="closeMobileMenu()">๋ฉ”์ธ์œผ๋กœ</a>
510
  <a href="{{ url_for('main.logout') }}" class="mobile-menu-item" onclick="closeMobileMenu()">๋กœ๊ทธ์•„์›ƒ</a>
511
  </div>
 
44
  gap: 12px;
45
  }
46
 
47
+ .header-actions {
48
+ display: flex;
49
+ gap: 8px;
50
+ align-items: center;
51
+ flex-wrap: wrap;
52
+ }
53
+
54
+ /* ๋“œ๋กญ๋‹ค์šด ๋ฉ”๋‰ด ์Šคํƒ€์ผ */
55
+ .dropdown {
56
+ position: relative;
57
+ display: inline-block;
58
+ }
59
+
60
+ /* ๋ฒ„ํŠผ๊ณผ ๋ฉ”๋‰ด ์‚ฌ์ด 'ํ‹ˆ'์—์„œ hover๊ฐ€ ๋Š๊ฒจ ๋ฉ”๋‰ด๊ฐ€ ๋‹ซํžˆ๋Š” ํ˜„์ƒ ๋ฐฉ์ง€ */
61
+ .dropdown::after {
62
+ content: '';
63
+ position: absolute;
64
+ left: 0;
65
+ right: 0;
66
+ top: 100%;
67
+ height: 8px;
68
+ }
69
+
70
+ .dropdown-toggle {
71
+ padding: 8px 16px;
72
+ background: #f1f3f4;
73
+ color: #202124;
74
+ border: none;
75
+ border-radius: 6px;
76
+ font-size: 14px;
77
+ font-weight: 500;
78
+ cursor: pointer;
79
+ transition: all 0.2s;
80
  display: flex;
 
81
  align-items: center;
82
+ gap: 6px;
83
+ }
84
+
85
+ .dropdown-toggle:hover {
86
+ background: #e8eaed;
87
+ }
88
+
89
+ .dropdown-toggle::after {
90
+ content: 'โ–ผ';
91
+ font-size: 10px;
92
+ transition: transform 0.2s;
93
+ }
94
+
95
+ .dropdown:hover .dropdown-toggle::after {
96
+ transform: rotate(180deg);
97
+ }
98
+
99
+ .dropdown-menu {
100
+ position: absolute;
101
+ top: calc(100% + 4px);
102
+ left: 0;
103
+ margin-top: 0;
104
+ background: white;
105
+ border: 1px solid #dadce0;
106
+ border-radius: 6px;
107
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
108
+ min-width: 200px;
109
+ opacity: 0;
110
+ visibility: hidden;
111
+ transform: translateY(-8px);
112
+ transition: all 0.2s ease;
113
+ z-index: 10000;
114
+ padding: 4px 0;
115
+ pointer-events: none;
116
+ }
117
+
118
+ .dropdown:hover .dropdown-menu {
119
+ opacity: 1;
120
+ visibility: visible;
121
+ transform: translateY(0);
122
+ pointer-events: auto;
123
+ }
124
+
125
+
126
+
127
+ .dropdown-item {
128
+ display: block;
129
+ padding: 10px 16px;
130
+ color: #202124;
131
+ text-decoration: none;
132
+ font-size: 14px;
133
+ transition: background 0.2s;
134
+ }
135
+
136
+ .dropdown-item:hover {
137
+ background: #f8f9fa;
138
+ }
139
+
140
+ .dropdown-item:first-child {
141
+ border-top-left-radius: 6px;
142
+ border-top-right-radius: 6px;
143
+ }
144
+
145
+ .dropdown-item:last-child {
146
+ border-bottom-left-radius: 6px;
147
+ border-bottom-right-radius: 6px;
148
  }
149
 
150
  .menu-toggle {
 
573
  <button class="menu-toggle" onclick="toggleMobileMenu()" aria-label="๋ฉ”๋‰ด ์—ด๊ธฐ">โ˜ฐ</button>
574
  <div class="header-actions">
575
  <span style="margin-right: 12px; color: #5f6368;">{{ current_user.nickname or current_user.username }}</span>
576
+
577
+ {# ์‚ฌ์ดํŠธ ๊ด€๋ฆฌ #}
578
+ <div class="dropdown">
579
+ <button type="button" class="dropdown-toggle">์‚ฌ์ดํŠธ ๊ด€๋ฆฌ</button>
580
+ <div class="dropdown-menu">
581
+ <a href="{{ url_for('main.admin') }}" class="dropdown-item">์‚ฌ์šฉ์ž ๊ด€๋ฆฌ</a>
582
+ <a href="{{ url_for('main.admin_tokens') }}" class="dropdown-item">ํ† ํฐ ํ†ต๊ณ„</a>
583
+ </div>
584
+ </div>
585
+
586
+ {# ์›น์†Œ์„ค ๊ด€๋ฆฌ #}
587
+ <div class="dropdown">
588
+ <button type="button" class="dropdown-toggle">์›น์†Œ์„ค ๊ด€๋ฆฌ</button>
589
+ <div class="dropdown-menu">
590
+ <a href="{{ url_for('main.admin_webnovels') }}" class="dropdown-item">์›น์†Œ์„ค ์—…๋กœ๋“œ ๋ฐ ๊ด€๋ฆฌ</a>
591
+ <a href="{{ url_for('main.admin_messages') }}" class="dropdown-item">๋ฉ”์‹œ์ง€ ํ™•์ธ</a>
592
+ </div>
593
+ </div>
594
+
595
+ {# AI ์„ค์ • #}
596
+ <div class="dropdown">
597
+ <button type="button" class="dropdown-toggle">AI ์„ค์ •</button>
598
+ <div class="dropdown-menu">
599
+ <a href="{{ url_for('main.admin_settings') }}" class="dropdown-item">AI ์„ค์ •</a>
600
+ <a href="{{ url_for('main.admin_prompts') }}" class="dropdown-item">ํ”„๋กฌํ”„ํŠธ ๊ด€๋ฆฌ</a>
601
+ </div>
602
+ </div>
603
+
604
+ {# ์ฑ—๋ด‡ #}
605
+ <div class="dropdown">
606
+ <button type="button" class="dropdown-toggle">์ฑ—๋ด‡</button>
607
+ <div class="dropdown-menu">
608
+ <a href="{{ url_for('main.admin_tags') }}" class="dropdown-item">ํƒœ๊ทธ/ํ”„๋กฌํ”„ํŠธ</a>
609
+ </div>
610
+ </div>
611
+
612
+ {# ํŽธ์˜๊ธฐ๋Šฅ #}
613
+ <div class="dropdown">
614
+ <button type="button" class="dropdown-toggle">ํŽธ์˜๊ธฐ๋Šฅ</button>
615
+ <div class="dropdown-menu">
616
+ <a href="{{ url_for('main.admin_utils') }}" class="dropdown-item">์œ ํ‹ธ</a>
617
+ </div>
618
+ </div>
619
+
620
+ {# ๋ฉ”์ธ์œผ๋กœ #}
621
+ <a href="{{ url_for('main.index') }}" class="btn btn-secondary" style="padding: 8px 16px; font-size: 14px; margin-right: 4px;">๋ฉ”์ธ์œผ๋กœ</a>
622
+ <a href="{{ url_for('main.logout') }}" class="btn btn-secondary" style="padding: 8px 16px; font-size: 14px;">๋กœ๊ทธ์•„์›ƒ</a>
623
  </div>
624
  </div>
625
 
 
632
  </div>
633
  <div class="mobile-menu-user">{{ current_user.nickname or current_user.username }}</div>
634
  <div class="mobile-menu-items">
635
+ <div style="padding: 8px 20px; font-size: 11px; font-weight: 600; color: #5f6368; text-transform: uppercase; border-bottom: 1px solid #f1f3f4;">์‚ฌ์ดํŠธ ๊ด€๋ฆฌ</div>
636
  <a href="{{ url_for('main.admin') }}" class="mobile-menu-item" onclick="closeMobileMenu()">์‚ฌ์šฉ์ž ๊ด€๋ฆฌ</a>
 
 
 
 
 
637
  <a href="{{ url_for('main.admin_tokens') }}" class="mobile-menu-item" onclick="closeMobileMenu()">ํ† ํฐ ํ†ต๊ณ„</a>
638
+
639
+ <div style="padding: 8px 20px; font-size: 11px; font-weight: 600; color: #5f6368; text-transform: uppercase; border-bottom: 1px solid #f1f3f4; margin-top: 8px;">์›น์†Œ์„ค ๊ด€๋ฆฌ</div>
640
+ <a href="{{ url_for('main.admin_webnovels') }}" class="mobile-menu-item" onclick="closeMobileMenu()">์›น์†Œ์„ค ์—…๋กœ๋“œ ๋ฐ ๊ด€๋ฆฌ</a>
641
+ <a href="{{ url_for('main.admin_messages') }}" class="mobile-menu-item" onclick="closeMobileMenu()">๋ฉ”์‹œ์ง€ ํ™•์ธ</a>
642
+
643
+ <div style="padding: 8px 20px; font-size: 11px; font-weight: 600; color: #5f6368; text-transform: uppercase; border-bottom: 1px solid #f1f3f4; margin-top: 8px;">AI ์„ค์ •</div>
644
+ <a href="{{ url_for('main.admin_settings') }}" class="mobile-menu-item" onclick="closeMobileMenu()">AI ์„ค์ •</a>
645
+ <a href="{{ url_for('main.admin_prompts') }}" class="mobile-menu-item" onclick="closeMobileMenu()">ํ”„๋กฌํ”„ํŠธ ๊ด€๋ฆฌ</a>
646
+
647
+ <div style="padding: 8px 20px; font-size: 11px; font-weight: 600; color: #5f6368; text-transform: uppercase; border-bottom: 1px solid #f1f3f4; margin-top: 8px;">์ฑ—๋ด‡</div>
648
+ <a href="{{ url_for('main.admin_tags') }}" class="mobile-menu-item" onclick="closeMobileMenu()">ํƒœ๊ทธ/ํ”„๋กฌํ”„ํŠธ</a>
649
+
650
+ <div style="padding: 8px 20px; font-size: 11px; font-weight: 600; color: #5f6368; text-transform: uppercase; border-bottom: 1px solid #f1f3f4; margin-top: 8px;">ํŽธ์˜๊ธฐ๋Šฅ</div>
651
  <a href="{{ url_for('main.admin_utils') }}" class="mobile-menu-item" onclick="closeMobileMenu()">์œ ํ‹ธ</a>
652
+
653
+ <div style="padding: 8px 20px; font-size: 11px; font-weight: 600; color: #5f6368; text-transform: uppercase; border-bottom: 1px solid #f1f3f4; margin-top: 8px;">๊ธฐํƒ€</div>
654
  <a href="{{ url_for('main.index') }}" class="mobile-menu-item" onclick="closeMobileMenu()">๋ฉ”์ธ์œผ๋กœ</a>
655
  <a href="{{ url_for('main.logout') }}" class="mobile-menu-item" onclick="closeMobileMenu()">๋กœ๊ทธ์•„์›ƒ</a>
656
  </div>
templates/admin_prompts.html CHANGED
@@ -46,10 +46,107 @@
46
  font-weight: 500;
47
  }
48
 
49
- .header-actions {
50
  display: flex;
51
- align-items: center;
52
  gap: 8px;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
53
  }
54
 
55
  .menu-toggle {
@@ -330,16 +427,53 @@
330
  <button class="menu-toggle" onclick="toggleMobileMenu()" aria-label="๋ฉ”๋‰ด ์—ด๊ธฐ">โ˜ฐ</button>
331
  <div class="header-actions">
332
  <span style="margin-right: 12px; color: #5f6368;">{{ current_user.nickname or current_user.username }}</span>
333
- <a href="{{ url_for('main.admin') }}" class="btn btn-secondary">์‚ฌ์šฉ์ž ๊ด€๋ฆฌ</a>
334
- <a href="{{ url_for('main.admin_webnovels') }}" class="btn btn-secondary">์›น์†Œ์„ค ๊ด€๋ฆฌ</a>
335
- <a href="{{ url_for('main.admin_files') }}" class="btn btn-secondary">ํŒŒ์ผ ๋ชฉ๋ก</a>
336
- <a href="{{ url_for('main.admin_messages') }}" class="btn btn-secondary">๋ฉ”์‹œ์ง€ ํ™•์ธ</a>
337
- <a href="{{ url_for('main.admin_settings') }}" class="btn btn-secondary">AI ์„ค์ •</a>
338
- <a href="{{ url_for('main.admin_utils') }}" class="btn btn-secondary">์œ ํ‹ธ</a>
339
- <a href="{{ url_for('main.admin_tokens') }}" class="btn btn-secondary">ํ† ํฐ ํ†ต๊ณ„</a>
340
- <a href="{{ url_for('main.admin_utils') }}" class="btn btn-secondary">์œ ํ‹ธ</a>
341
- <a href="{{ url_for('main.index') }}" class="btn btn-secondary">๋ฉ”์ธ์œผ๋กœ</a>
342
- <a href="{{ url_for('main.logout') }}" class="btn btn-secondary">๋กœ๊ทธ์•„์›ƒ</a>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
343
  </div>
344
  </div>
345
 
@@ -352,14 +486,25 @@
352
  </div>
353
  <div class="mobile-menu-user">{{ current_user.nickname or current_user.username }}</div>
354
  <div class="mobile-menu-items">
 
355
  <a href="{{ url_for('main.admin') }}" class="mobile-menu-item" onclick="closeMobileMenu()">์‚ฌ์šฉ์ž ๊ด€๋ฆฌ</a>
356
- <a href="{{ url_for('main.admin_webnovels') }}" class="mobile-menu-item" onclick="closeMobileMenu()">์›น์†Œ์„ค ๊ด€๋ฆฌ</a>
357
- <a href="{{ url_for('main.admin_files') }}" class="mobile-menu-item" onclick="closeMobileMenu()">ํŒŒ์ผ ๋ชฉ๋ก</a>
 
 
358
  <a href="{{ url_for('main.admin_messages') }}" class="mobile-menu-item" onclick="closeMobileMenu()">๋ฉ”์‹œ์ง€ ํ™•์ธ</a>
 
 
359
  <a href="{{ url_for('main.admin_settings') }}" class="mobile-menu-item" onclick="closeMobileMenu()">AI ์„ค์ •</a>
 
 
 
 
 
 
360
  <a href="{{ url_for('main.admin_utils') }}" class="mobile-menu-item" onclick="closeMobileMenu()">์œ ํ‹ธ</a>
361
- <a href="{{ url_for('main.admin_tokens') }}" class="mobile-menu-item" onclick="closeMobileMenu()">ํ† ํฐ ํ†ต๊ณ„</a>
362
- <a href="{{ url_for('main.admin_utils') }}" class="mobile-menu-item" onclick="closeMobileMenu()">์œ ํ‹ธ</a>
363
  <a href="{{ url_for('main.index') }}" class="mobile-menu-item" onclick="closeMobileMenu()">๋ฉ”์ธ์œผ๋กœ</a>
364
  <a href="{{ url_for('main.logout') }}" class="mobile-menu-item" onclick="closeMobileMenu()">๋กœ๊ทธ์•„์›ƒ</a>
365
  </div>
 
46
  font-weight: 500;
47
  }
48
 
49
+ .header-actions {
50
  display: flex;
 
51
  gap: 8px;
52
+ align-items: center;
53
+ flex-wrap: wrap;
54
+ }
55
+
56
+ /* ๋“œ๋กญ๋‹ค์šด ๋ฉ”๋‰ด ์Šคํƒ€์ผ */
57
+ .dropdown {
58
+ position: relative;
59
+ display: inline-block;
60
+ }
61
+
62
+ /* ๋ฒ„ํŠผ๊ณผ ๋ฉ”๋‰ด ์‚ฌ์ด 'ํ‹ˆ'์—์„œ hover๊ฐ€ ๋Š๊ฒจ ๋ฉ”๋‰ด๊ฐ€ ๋‹ซํžˆ๋Š” ํ˜„์ƒ ๋ฐฉ์ง€ */
63
+ .dropdown::after {
64
+ content: '';
65
+ position: absolute;
66
+ left: 0;
67
+ right: 0;
68
+ top: 100%;
69
+ height: 8px;
70
+ }
71
+
72
+ .dropdown-toggle {
73
+ padding: 8px 16px;
74
+ background: #f1f3f4;
75
+ color: #202124;
76
+ border: none;
77
+ border-radius: 6px;
78
+ font-size: 14px;
79
+ font-weight: 500;
80
+ cursor: pointer;
81
+ transition: all 0.2s;
82
+ display: flex;
83
+ align-items: center;
84
+ gap: 6px;
85
+ }
86
+
87
+ .dropdown-toggle:hover {
88
+ background: #e8eaed;
89
+ }
90
+
91
+ .dropdown-toggle::after {
92
+ content: 'โ–ผ';
93
+ font-size: 10px;
94
+ transition: transform 0.2s;
95
+ }
96
+
97
+ .dropdown:hover .dropdown-toggle::after {
98
+ transform: rotate(180deg);
99
+ }
100
+
101
+ .dropdown-menu {
102
+ position: absolute;
103
+ top: calc(100% + 4px);
104
+ left: 0;
105
+ margin-top: 0;
106
+ background: white;
107
+ border: 1px solid #dadce0;
108
+ border-radius: 6px;
109
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
110
+ min-width: 200px;
111
+ opacity: 0;
112
+ visibility: hidden;
113
+ transform: translateY(-8px);
114
+ transition: all 0.2s ease;
115
+ z-index: 10000;
116
+ padding: 4px 0;
117
+ pointer-events: none;
118
+ }
119
+
120
+ .dropdown:hover .dropdown-menu {
121
+ opacity: 1;
122
+ visibility: visible;
123
+ transform: translateY(0);
124
+ pointer-events: auto;
125
+ }
126
+
127
+
128
+
129
+ .dropdown-item {
130
+ display: block;
131
+ padding: 10px 16px;
132
+ color: #202124;
133
+ text-decoration: none;
134
+ font-size: 14px;
135
+ transition: background 0.2s;
136
+ }
137
+
138
+ .dropdown-item:hover {
139
+ background: #f8f9fa;
140
+ }
141
+
142
+ .dropdown-item:first-child {
143
+ border-top-left-radius: 6px;
144
+ border-top-right-radius: 6px;
145
+ }
146
+
147
+ .dropdown-item:last-child {
148
+ border-bottom-left-radius: 6px;
149
+ border-bottom-right-radius: 6px;
150
  }
151
 
152
  .menu-toggle {
 
427
  <button class="menu-toggle" onclick="toggleMobileMenu()" aria-label="๋ฉ”๋‰ด ์—ด๊ธฐ">โ˜ฐ</button>
428
  <div class="header-actions">
429
  <span style="margin-right: 12px; color: #5f6368;">{{ current_user.nickname or current_user.username }}</span>
430
+
431
+ {# ์‚ฌ์ดํŠธ ๊ด€๋ฆฌ #}
432
+ <div class="dropdown">
433
+ <button type="button" class="dropdown-toggle">์‚ฌ์ดํŠธ ๊ด€๋ฆฌ</button>
434
+ <div class="dropdown-menu">
435
+ <a href="{{ url_for('main.admin') }}" class="dropdown-item">์‚ฌ์šฉ์ž ๊ด€๋ฆฌ</a>
436
+ <a href="{{ url_for('main.admin_tokens') }}" class="dropdown-item">ํ† ํฐ ํ†ต๊ณ„</a>
437
+ </div>
438
+ </div>
439
+
440
+ {# ์›น์†Œ์„ค ๊ด€๋ฆฌ #}
441
+ <div class="dropdown">
442
+ <button type="button" class="dropdown-toggle">์›น์†Œ์„ค ๊ด€๋ฆฌ</button>
443
+ <div class="dropdown-menu">
444
+ <a href="{{ url_for('main.admin_webnovels') }}" class="dropdown-item">์›น์†Œ์„ค ์—…๋กœ๋“œ ๋ฐ ๊ด€๋ฆฌ</a>
445
+ <a href="{{ url_for('main.admin_messages') }}" class="dropdown-item">๋ฉ”์‹œ์ง€ ํ™•์ธ</a>
446
+ </div>
447
+ </div>
448
+
449
+ {# AI ์„ค์ • #}
450
+ <div class="dropdown">
451
+ <button type="button" class="dropdown-toggle">AI ์„ค์ •</button>
452
+ <div class="dropdown-menu">
453
+ <a href="{{ url_for('main.admin_settings') }}" class="dropdown-item">AI ์„ค์ •</a>
454
+ <a href="{{ url_for('main.admin_prompts') }}" class="dropdown-item">ํ”„๋กฌํ”„ํŠธ ๊ด€๋ฆฌ</a>
455
+ </div>
456
+ </div>
457
+
458
+ {# ์ฑ—๋ด‡ #}
459
+ <div class="dropdown">
460
+ <button type="button" class="dropdown-toggle">์ฑ—๋ด‡</button>
461
+ <div class="dropdown-menu">
462
+ <a href="{{ url_for('main.admin_tags') }}" class="dropdown-item">ํƒœ๊ทธ/ํ”„๋กฌํ”„ํŠธ</a>
463
+ </div>
464
+ </div>
465
+
466
+ {# ํŽธ์˜๊ธฐ๋Šฅ #}
467
+ <div class="dropdown">
468
+ <button type="button" class="dropdown-toggle">ํŽธ์˜๊ธฐ๋Šฅ</button>
469
+ <div class="dropdown-menu">
470
+ <a href="{{ url_for('main.admin_utils') }}" class="dropdown-item">์œ ํ‹ธ</a>
471
+ </div>
472
+ </div>
473
+
474
+ {# ๋ฉ”์ธ์œผ๋กœ #}
475
+ <a href="{{ url_for('main.index') }}" class="btn btn-secondary" style="padding: 8px 16px; font-size: 14px; margin-right: 4px;">๋ฉ”์ธ์œผ๋กœ</a>
476
+ <a href="{{ url_for('main.logout') }}" class="btn btn-secondary" style="padding: 8px 16px; font-size: 14px;">๋กœ๊ทธ์•„์›ƒ</a>
477
  </div>
478
  </div>
479
 
 
486
  </div>
487
  <div class="mobile-menu-user">{{ current_user.nickname or current_user.username }}</div>
488
  <div class="mobile-menu-items">
489
+ <div style="padding: 8px 20px; font-size: 11px; font-weight: 600; color: #5f6368; text-transform: uppercase; border-bottom: 1px solid #f1f3f4;">์‚ฌ์ดํŠธ ๊ด€๋ฆฌ</div>
490
  <a href="{{ url_for('main.admin') }}" class="mobile-menu-item" onclick="closeMobileMenu()">์‚ฌ์šฉ์ž ๊ด€๋ฆฌ</a>
491
+ <a href="{{ url_for('main.admin_tokens') }}" class="mobile-menu-item" onclick="closeMobileMenu()">ํ† ํฐ ํ†ต๊ณ„</a>
492
+
493
+ <div style="padding: 8px 20px; font-size: 11px; font-weight: 600; color: #5f6368; text-transform: uppercase; border-bottom: 1px solid #f1f3f4; margin-top: 8px;">์›น์†Œ์„ค ๊ด€๋ฆฌ</div>
494
+ <a href="{{ url_for('main.admin_webnovels') }}" class="mobile-menu-item" onclick="closeMobileMenu()">์›น์†Œ์„ค ์—…๋กœ๋“œ ๋ฐ ๊ด€๋ฆฌ</a>
495
  <a href="{{ url_for('main.admin_messages') }}" class="mobile-menu-item" onclick="closeMobileMenu()">๋ฉ”์‹œ์ง€ ํ™•์ธ</a>
496
+
497
+ <div style="padding: 8px 20px; font-size: 11px; font-weight: 600; color: #5f6368; text-transform: uppercase; border-bottom: 1px solid #f1f3f4; margin-top: 8px;">AI ์„ค์ •</div>
498
  <a href="{{ url_for('main.admin_settings') }}" class="mobile-menu-item" onclick="closeMobileMenu()">AI ์„ค์ •</a>
499
+ <a href="{{ url_for('main.admin_prompts') }}" class="mobile-menu-item" onclick="closeMobileMenu()">ํ”„๋กฌํ”„ํŠธ ๊ด€๋ฆฌ</a>
500
+
501
+ <div style="padding: 8px 20px; font-size: 11px; font-weight: 600; color: #5f6368; text-transform: uppercase; border-bottom: 1px solid #f1f3f4; margin-top: 8px;">์ฑ—๋ด‡</div>
502
+ <a href="{{ url_for('main.admin_tags') }}" class="mobile-menu-item" onclick="closeMobileMenu()">ํƒœ๊ทธ/ํ”„๋กฌํ”„ํŠธ</a>
503
+
504
+ <div style="padding: 8px 20px; font-size: 11px; font-weight: 600; color: #5f6368; text-transform: uppercase; border-bottom: 1px solid #f1f3f4; margin-top: 8px;">ํŽธ์˜๊ธฐ๋Šฅ</div>
505
  <a href="{{ url_for('main.admin_utils') }}" class="mobile-menu-item" onclick="closeMobileMenu()">์œ ํ‹ธ</a>
506
+
507
+ <div style="padding: 8px 20px; font-size: 11px; font-weight: 600; color: #5f6368; text-transform: uppercase; border-bottom: 1px solid #f1f3f4; margin-top: 8px;">๊ธฐํƒ€</div>
508
  <a href="{{ url_for('main.index') }}" class="mobile-menu-item" onclick="closeMobileMenu()">๋ฉ”์ธ์œผ๋กœ</a>
509
  <a href="{{ url_for('main.logout') }}" class="mobile-menu-item" onclick="closeMobileMenu()">๋กœ๊ทธ์•„์›ƒ</a>
510
  </div>
templates/admin_settings.html CHANGED
@@ -44,10 +44,107 @@
44
  gap: 12px;
45
  }
46
 
47
- .header-actions {
48
  display: flex;
49
- gap: 12px;
50
  align-items: center;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
51
  }
52
 
53
  .menu-toggle {
@@ -293,17 +390,53 @@
293
  <button class="menu-toggle" onclick="toggleMobileMenu()" aria-label="๋ฉ”๋‰ด ์—ด๊ธฐ">โ˜ฐ</button>
294
  <div class="header-actions">
295
  <span style="margin-right: 12px; color: #5f6368;">{{ current_user.nickname or current_user.username }}</span>
296
- <a href="{{ url_for('main.admin') }}" class="btn btn-secondary">์‚ฌ์šฉ์ž ๊ด€๋ฆฌ</a>
297
- <a href="{{ url_for('main.admin_webnovels') }}" class="btn btn-secondary">์›น์†Œ์„ค ๊ด€๋ฆฌ</a>
298
- <a href="{{ url_for('main.admin_files') }}" class="btn btn-secondary">ํŒŒ์ผ ๋ชฉ๋ก</a>
299
- <a href="{{ url_for('main.admin_messages') }}" class="btn btn-secondary">๋ฉ”์‹œ์ง€ ํ™•์ธ</a>
300
- <a href="{{ url_for('main.admin_prompts') }}" class="btn btn-secondary">ํ”„๋กฌํ”„ํŠธ ๊ด€๋ฆฌ</a>
301
- <a href="{{ url_for('main.admin_settings') }}" class="btn btn-secondary">AI ์„ค์ •</a>
302
- <a href="{{ url_for('main.admin_utils') }}" class="btn btn-secondary">์œ ํ‹ธ</a>
303
- <a href="{{ url_for('main.admin_tokens') }}" class="btn btn-secondary">ํ† ํฐ ํ†ต๊ณ„</a>
304
- <a href="{{ url_for('main.admin_utils') }}" class="btn btn-secondary">์œ ํ‹ธ</a>
305
- <a href="{{ url_for('main.index') }}" class="btn btn-secondary">๋ฉ”์ธ์œผ๋กœ</a>
306
- <a href="{{ url_for('main.logout') }}" class="btn btn-secondary">๋กœ๊ทธ์•„์›ƒ</a>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
307
  </div>
308
  </div>
309
 
@@ -316,15 +449,25 @@
316
  </div>
317
  <div class="mobile-menu-user">{{ current_user.nickname or current_user.username }}</div>
318
  <div class="mobile-menu-items">
 
319
  <a href="{{ url_for('main.admin') }}" class="mobile-menu-item" onclick="closeMobileMenu()">์‚ฌ์šฉ์ž ๊ด€๋ฆฌ</a>
320
- <a href="{{ url_for('main.admin_webnovels') }}" class="mobile-menu-item" onclick="closeMobileMenu()">์›น์†Œ์„ค ๊ด€๋ฆฌ</a>
321
- <a href="{{ url_for('main.admin_files') }}" class="mobile-menu-item" onclick="closeMobileMenu()">ํŒŒ์ผ ๋ชฉ๋ก</a>
 
 
322
  <a href="{{ url_for('main.admin_messages') }}" class="mobile-menu-item" onclick="closeMobileMenu()">๋ฉ”์‹œ์ง€ ํ™•์ธ</a>
323
- <a href="{{ url_for('main.admin_prompts') }}" class="mobile-menu-item" onclick="closeMobileMenu()">ํ”„๋กฌํ”„ํŠธ ๊ด€๋ฆฌ</a>
 
324
  <a href="{{ url_for('main.admin_settings') }}" class="mobile-menu-item" onclick="closeMobileMenu()">AI ์„ค์ •</a>
 
 
 
 
 
 
325
  <a href="{{ url_for('main.admin_utils') }}" class="mobile-menu-item" onclick="closeMobileMenu()">์œ ํ‹ธ</a>
326
- <a href="{{ url_for('main.admin_tokens') }}" class="mobile-menu-item" onclick="closeMobileMenu()">ํ† ํฐ ํ†ต๊ณ„</a>
327
- <a href="{{ url_for('main.admin_utils') }}" class="mobile-menu-item" onclick="closeMobileMenu()">์œ ํ‹ธ</a>
328
  <a href="{{ url_for('main.index') }}" class="mobile-menu-item" onclick="closeMobileMenu()">๋ฉ”์ธ์œผ๋กœ</a>
329
  <a href="{{ url_for('main.logout') }}" class="mobile-menu-item" onclick="closeMobileMenu()">๋กœ๊ทธ์•„์›ƒ</a>
330
  </div>
 
44
  gap: 12px;
45
  }
46
 
47
+ .header-actions {
48
  display: flex;
49
+ gap: 8px;
50
  align-items: center;
51
+ flex-wrap: wrap;
52
+ }
53
+
54
+ /* ๋“œ๋กญ๋‹ค์šด ๋ฉ”๋‰ด ์Šคํƒ€์ผ */
55
+ .dropdown {
56
+ position: relative;
57
+ display: inline-block;
58
+ }
59
+
60
+ /* ๋ฒ„ํŠผ๊ณผ ๋ฉ”๋‰ด ์‚ฌ์ด 'ํ‹ˆ'์—์„œ hover๊ฐ€ ๋Š๊ฒจ ๋ฉ”๋‰ด๊ฐ€ ๋‹ซํžˆ๋Š” ํ˜„์ƒ ๋ฐฉ์ง€ */
61
+ .dropdown::after {
62
+ content: '';
63
+ position: absolute;
64
+ left: 0;
65
+ right: 0;
66
+ top: 100%;
67
+ height: 8px;
68
+ }
69
+
70
+ .dropdown-toggle {
71
+ padding: 8px 16px;
72
+ background: #f1f3f4;
73
+ color: #202124;
74
+ border: none;
75
+ border-radius: 6px;
76
+ font-size: 14px;
77
+ font-weight: 500;
78
+ cursor: pointer;
79
+ transition: all 0.2s;
80
+ display: flex;
81
+ align-items: center;
82
+ gap: 6px;
83
+ }
84
+
85
+ .dropdown-toggle:hover {
86
+ background: #e8eaed;
87
+ }
88
+
89
+ .dropdown-toggle::after {
90
+ content: 'โ–ผ';
91
+ font-size: 10px;
92
+ transition: transform 0.2s;
93
+ }
94
+
95
+ .dropdown:hover .dropdown-toggle::after {
96
+ transform: rotate(180deg);
97
+ }
98
+
99
+ .dropdown-menu {
100
+ position: absolute;
101
+ top: calc(100% + 4px);
102
+ left: 0;
103
+ margin-top: 0;
104
+ background: white;
105
+ border: 1px solid #dadce0;
106
+ border-radius: 6px;
107
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
108
+ min-width: 200px;
109
+ opacity: 0;
110
+ visibility: hidden;
111
+ transform: translateY(-8px);
112
+ transition: all 0.2s ease;
113
+ z-index: 10000;
114
+ padding: 4px 0;
115
+ pointer-events: none;
116
+ }
117
+
118
+ .dropdown:hover .dropdown-menu {
119
+ opacity: 1;
120
+ visibility: visible;
121
+ transform: translateY(0);
122
+ pointer-events: auto;
123
+ }
124
+
125
+
126
+
127
+ .dropdown-item {
128
+ display: block;
129
+ padding: 10px 16px;
130
+ color: #202124;
131
+ text-decoration: none;
132
+ font-size: 14px;
133
+ transition: background 0.2s;
134
+ }
135
+
136
+ .dropdown-item:hover {
137
+ background: #f8f9fa;
138
+ }
139
+
140
+ .dropdown-item:first-child {
141
+ border-top-left-radius: 6px;
142
+ border-top-right-radius: 6px;
143
+ }
144
+
145
+ .dropdown-item:last-child {
146
+ border-bottom-left-radius: 6px;
147
+ border-bottom-right-radius: 6px;
148
  }
149
 
150
  .menu-toggle {
 
390
  <button class="menu-toggle" onclick="toggleMobileMenu()" aria-label="๋ฉ”๋‰ด ์—ด๊ธฐ">โ˜ฐ</button>
391
  <div class="header-actions">
392
  <span style="margin-right: 12px; color: #5f6368;">{{ current_user.nickname or current_user.username }}</span>
393
+
394
+ {# ์‚ฌ์ดํŠธ ๊ด€๋ฆฌ #}
395
+ <div class="dropdown">
396
+ <button type="button" class="dropdown-toggle">์‚ฌ์ดํŠธ ๊ด€๋ฆฌ</button>
397
+ <div class="dropdown-menu">
398
+ <a href="{{ url_for('main.admin') }}" class="dropdown-item">์‚ฌ์šฉ์ž ๊ด€๋ฆฌ</a>
399
+ <a href="{{ url_for('main.admin_tokens') }}" class="dropdown-item">ํ† ํฐ ํ†ต๊ณ„</a>
400
+ </div>
401
+ </div>
402
+
403
+ {# ์›น์†Œ์„ค ๊ด€๋ฆฌ #}
404
+ <div class="dropdown">
405
+ <button type="button" class="dropdown-toggle">์›น์†Œ์„ค ๊ด€๋ฆฌ</button>
406
+ <div class="dropdown-menu">
407
+ <a href="{{ url_for('main.admin_webnovels') }}" class="dropdown-item">์›น์†Œ์„ค ์—…๋กœ๋“œ ๋ฐ ๊ด€๋ฆฌ</a>
408
+ <a href="{{ url_for('main.admin_messages') }}" class="dropdown-item">๋ฉ”์‹œ์ง€ ํ™•์ธ</a>
409
+ </div>
410
+ </div>
411
+
412
+ {# AI ์„ค์ • #}
413
+ <div class="dropdown">
414
+ <button type="button" class="dropdown-toggle">AI ์„ค์ •</button>
415
+ <div class="dropdown-menu">
416
+ <a href="{{ url_for('main.admin_settings') }}" class="dropdown-item">AI ์„ค์ •</a>
417
+ <a href="{{ url_for('main.admin_prompts') }}" class="dropdown-item">ํ”„๋กฌํ”„ํŠธ ๊ด€๋ฆฌ</a>
418
+ </div>
419
+ </div>
420
+
421
+ {# ์ฑ—๋ด‡ #}
422
+ <div class="dropdown">
423
+ <button type="button" class="dropdown-toggle">์ฑ—๋ด‡</button>
424
+ <div class="dropdown-menu">
425
+ <a href="{{ url_for('main.admin_tags') }}" class="dropdown-item">ํƒœ๊ทธ/ํ”„๋กฌํ”„ํŠธ</a>
426
+ </div>
427
+ </div>
428
+
429
+ {# ํŽธ์˜๊ธฐ๋Šฅ #}
430
+ <div class="dropdown">
431
+ <button type="button" class="dropdown-toggle">ํŽธ์˜๊ธฐ๋Šฅ</button>
432
+ <div class="dropdown-menu">
433
+ <a href="{{ url_for('main.admin_utils') }}" class="dropdown-item">์œ ํ‹ธ</a>
434
+ </div>
435
+ </div>
436
+
437
+ {# ๋ฉ”์ธ์œผ๋กœ #}
438
+ <a href="{{ url_for('main.index') }}" class="btn btn-secondary" style="padding: 8px 16px; font-size: 14px; margin-right: 4px;">๋ฉ”์ธ์œผ๋กœ</a>
439
+ <a href="{{ url_for('main.logout') }}" class="btn btn-secondary" style="padding: 8px 16px; font-size: 14px;">๋กœ๊ทธ์•„์›ƒ</a>
440
  </div>
441
  </div>
442
 
 
449
  </div>
450
  <div class="mobile-menu-user">{{ current_user.nickname or current_user.username }}</div>
451
  <div class="mobile-menu-items">
452
+ <div style="padding: 8px 20px; font-size: 11px; font-weight: 600; color: #5f6368; text-transform: uppercase; border-bottom: 1px solid #f1f3f4;">์‚ฌ์ดํŠธ ๊ด€๋ฆฌ</div>
453
  <a href="{{ url_for('main.admin') }}" class="mobile-menu-item" onclick="closeMobileMenu()">์‚ฌ์šฉ์ž ๊ด€๋ฆฌ</a>
454
+ <a href="{{ url_for('main.admin_tokens') }}" class="mobile-menu-item" onclick="closeMobileMenu()">ํ† ํฐ ํ†ต๊ณ„</a>
455
+
456
+ <div style="padding: 8px 20px; font-size: 11px; font-weight: 600; color: #5f6368; text-transform: uppercase; border-bottom: 1px solid #f1f3f4; margin-top: 8px;">์›น์†Œ์„ค ๊ด€๋ฆฌ</div>
457
+ <a href="{{ url_for('main.admin_webnovels') }}" class="mobile-menu-item" onclick="closeMobileMenu()">์›น์†Œ์„ค ์—…๋กœ๋“œ ๋ฐ ๊ด€๋ฆฌ</a>
458
  <a href="{{ url_for('main.admin_messages') }}" class="mobile-menu-item" onclick="closeMobileMenu()">๋ฉ”์‹œ์ง€ ํ™•์ธ</a>
459
+
460
+ <div style="padding: 8px 20px; font-size: 11px; font-weight: 600; color: #5f6368; text-transform: uppercase; border-bottom: 1px solid #f1f3f4; margin-top: 8px;">AI ์„ค์ •</div>
461
  <a href="{{ url_for('main.admin_settings') }}" class="mobile-menu-item" onclick="closeMobileMenu()">AI ์„ค์ •</a>
462
+ <a href="{{ url_for('main.admin_prompts') }}" class="mobile-menu-item" onclick="closeMobileMenu()">ํ”„๋กฌํ”„ํŠธ ๊ด€๋ฆฌ</a>
463
+
464
+ <div style="padding: 8px 20px; font-size: 11px; font-weight: 600; color: #5f6368; text-transform: uppercase; border-bottom: 1px solid #f1f3f4; margin-top: 8px;">์ฑ—๋ด‡</div>
465
+ <a href="{{ url_for('main.admin_tags') }}" class="mobile-menu-item" onclick="closeMobileMenu()">ํƒœ๊ทธ/ํ”„๋กฌํ”„ํŠธ</a>
466
+
467
+ <div style="padding: 8px 20px; font-size: 11px; font-weight: 600; color: #5f6368; text-transform: uppercase; border-bottom: 1px solid #f1f3f4; margin-top: 8px;">ํŽธ์˜๊ธฐ๋Šฅ</div>
468
  <a href="{{ url_for('main.admin_utils') }}" class="mobile-menu-item" onclick="closeMobileMenu()">์œ ํ‹ธ</a>
469
+
470
+ <div style="padding: 8px 20px; font-size: 11px; font-weight: 600; color: #5f6368; text-transform: uppercase; border-bottom: 1px solid #f1f3f4; margin-top: 8px;">๊ธฐํƒ€</div>
471
  <a href="{{ url_for('main.index') }}" class="mobile-menu-item" onclick="closeMobileMenu()">๋ฉ”์ธ์œผ๋กœ</a>
472
  <a href="{{ url_for('main.logout') }}" class="mobile-menu-item" onclick="closeMobileMenu()">๋กœ๊ทธ์•„์›ƒ</a>
473
  </div>
templates/admin_tags.html ADDED
@@ -0,0 +1,820 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="ko">
3
+ <head>
4
+ <script type="text/javascript">
5
+ (function(c,l,a,r,i,t,y){
6
+ c[a]=c[a]||function(){(c[a].q=c[a].q||[]).push(arguments)};
7
+ t=l.createElement(r);t.async=1;t.src="https://www.clarity.ms/tag/"+i;
8
+ y=l.getElementsByTagName(r)[0];y.parentNode.insertBefore(t,y);
9
+ })(window, document, "clarity", "script", "ujskfvh0bu");
10
+ </script>
11
+ <meta charset="UTF-8">
12
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
13
+ <title>ํƒœ๊ทธ ๋ณด๊ธฐ - SOY NV AI</title>
14
+ <link rel="preconnect" href="https://fonts.googleapis.com" crossorigin>
15
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap" rel="stylesheet">
16
+ <style>
17
+ * {
18
+ margin: 0;
19
+ padding: 0;
20
+ box-sizing: border-box;
21
+ }
22
+
23
+ body {
24
+ font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
25
+ background: #f8f9fa;
26
+ color: #202124;
27
+ }
28
+
29
+ .header {
30
+ background: white;
31
+ border-bottom: 1px solid #dadce0;
32
+ padding: 16px 24px;
33
+ display: flex;
34
+ align-items: center;
35
+ justify-content: space-between;
36
+ box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
37
+ }
38
+
39
+ .header-title {
40
+ font-size: 20px;
41
+ font-weight: 500;
42
+ display: flex;
43
+ align-items: center;
44
+ gap: 12px;
45
+ }
46
+
47
+ .header-actions {
48
+ display: flex;
49
+ gap: 8px;
50
+ align-items: center;
51
+ flex-wrap: wrap;
52
+ }
53
+
54
+ /* ๋“œ๋กญ๋‹ค์šด ๋ฉ”๋‰ด ์Šคํƒ€์ผ */
55
+ .dropdown {
56
+ position: relative;
57
+ display: inline-block;
58
+ }
59
+
60
+ /* ๋ฒ„ํŠผ๊ณผ ๋ฉ”๋‰ด ์‚ฌ์ด 'ํ‹ˆ'์—์„œ hover๊ฐ€ ๋Š๊ฒจ ๋ฉ”๋‰ด๊ฐ€ ๋‹ซํžˆ๋Š” ํ˜„์ƒ ๋ฐฉ์ง€ */
61
+ .dropdown::after {
62
+ content: '';
63
+ position: absolute;
64
+ left: 0;
65
+ right: 0;
66
+ top: 100%;
67
+ height: 8px;
68
+ }
69
+
70
+ .dropdown-toggle {
71
+ padding: 8px 16px;
72
+ background: #f1f3f4;
73
+ color: #202124;
74
+ border: none;
75
+ border-radius: 6px;
76
+ font-size: 14px;
77
+ font-weight: 500;
78
+ cursor: pointer;
79
+ transition: all 0.2s;
80
+ display: flex;
81
+ align-items: center;
82
+ gap: 6px;
83
+ }
84
+
85
+ .dropdown-toggle:hover {
86
+ background: #e8eaed;
87
+ }
88
+
89
+ .dropdown-toggle::after {
90
+ content: 'โ–ผ';
91
+ font-size: 10px;
92
+ transition: transform 0.2s;
93
+ }
94
+
95
+ .dropdown:hover .dropdown-toggle::after {
96
+ transform: rotate(180deg);
97
+ }
98
+
99
+ .dropdown-menu {
100
+ position: absolute;
101
+ top: calc(100% + 4px);
102
+ left: 0;
103
+ margin-top: 0;
104
+ background: white;
105
+ border: 1px solid #dadce0;
106
+ border-radius: 6px;
107
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
108
+ min-width: 200px;
109
+ opacity: 0;
110
+ visibility: hidden;
111
+ transform: translateY(-8px);
112
+ transition: all 0.2s ease;
113
+ z-index: 10000;
114
+ padding: 4px 0;
115
+ pointer-events: none;
116
+ }
117
+
118
+ .dropdown:hover .dropdown-menu {
119
+ opacity: 1;
120
+ visibility: visible;
121
+ transform: translateY(0);
122
+ pointer-events: auto;
123
+ }
124
+
125
+
126
+
127
+ .dropdown-item {
128
+ display: block;
129
+ padding: 10px 16px;
130
+ color: #202124;
131
+ text-decoration: none;
132
+ font-size: 14px;
133
+ transition: background 0.2s;
134
+ }
135
+
136
+ .dropdown-item:hover {
137
+ background: #f8f9fa;
138
+ }
139
+
140
+ .dropdown-item:first-child {
141
+ border-top-left-radius: 6px;
142
+ border-top-right-radius: 6px;
143
+ }
144
+
145
+ .dropdown-item:last-child {
146
+ border-bottom-left-radius: 6px;
147
+ border-bottom-right-radius: 6px;
148
+ }
149
+
150
+ .menu-toggle {
151
+ display: none;
152
+ background: none;
153
+ border: none;
154
+ font-size: 24px;
155
+ cursor: pointer;
156
+ padding: 8px;
157
+ color: #202124;
158
+ }
159
+
160
+ .mobile-menu {
161
+ display: none;
162
+ position: fixed;
163
+ top: 0;
164
+ left: 0;
165
+ right: 0;
166
+ bottom: 0;
167
+ background: rgba(0, 0, 0, 0.5);
168
+ z-index: 1000;
169
+ }
170
+
171
+ .mobile-menu.active {
172
+ display: block;
173
+ }
174
+
175
+ .mobile-menu-content {
176
+ position: fixed;
177
+ top: 0;
178
+ right: -100%;
179
+ width: 280px;
180
+ max-width: 80%;
181
+ height: 100%;
182
+ background: white;
183
+ box-shadow: -2px 0 8px rgba(0, 0, 0, 0.1);
184
+ transition: right 0.3s ease;
185
+ overflow-y: auto;
186
+ z-index: 1001;
187
+ }
188
+
189
+ .mobile-menu.active .mobile-menu-content {
190
+ right: 0;
191
+ }
192
+
193
+ .mobile-menu-header {
194
+ padding: 16px 20px;
195
+ border-bottom: 1px solid #dadce0;
196
+ display: flex;
197
+ justify-content: space-between;
198
+ align-items: center;
199
+ background: white;
200
+ position: sticky;
201
+ top: 0;
202
+ z-index: 10;
203
+ }
204
+
205
+ .mobile-menu-title {
206
+ font-size: 18px;
207
+ font-weight: 500;
208
+ }
209
+
210
+ .mobile-menu-close {
211
+ background: none;
212
+ border: none;
213
+ font-size: 24px;
214
+ cursor: pointer;
215
+ color: #202124;
216
+ padding: 0;
217
+ width: 32px;
218
+ height: 32px;
219
+ display: flex;
220
+ align-items: center;
221
+ justify-content: center;
222
+ }
223
+
224
+ .mobile-menu-items {
225
+ padding: 8px 0;
226
+ }
227
+
228
+ .mobile-menu-item {
229
+ display: block;
230
+ padding: 12px 20px;
231
+ color: #202124;
232
+ text-decoration: none;
233
+ border-bottom: 1px solid #f1f3f4;
234
+ transition: background 0.2s;
235
+ }
236
+
237
+ .mobile-menu-item:hover {
238
+ background: #f8f9fa;
239
+ }
240
+
241
+ .mobile-menu-user {
242
+ padding: 16px 20px;
243
+ border-bottom: 1px solid #dadce0;
244
+ color: #5f6368;
245
+ font-size: 14px;
246
+ }
247
+
248
+ .btn {
249
+ padding: 8px 16px;
250
+ border: none;
251
+ border-radius: 6px;
252
+ font-size: 14px;
253
+ font-weight: 500;
254
+ cursor: pointer;
255
+ text-decoration: none;
256
+ display: inline-block;
257
+ transition: all 0.2s;
258
+ }
259
+
260
+ .btn-secondary {
261
+ background: #f1f3f4;
262
+ color: #202124;
263
+ }
264
+
265
+ .btn-secondary:hover {
266
+ background: #e8eaed;
267
+ }
268
+
269
+ .container {
270
+ max-width: 1600px;
271
+ margin: 0 auto;
272
+ padding: 24px;
273
+ }
274
+
275
+ .file-selector {
276
+ background: white;
277
+ border-radius: 8px;
278
+ padding: 20px;
279
+ margin-bottom: 24px;
280
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
281
+ }
282
+
283
+ .file-selector label {
284
+ display: block;
285
+ font-size: 14px;
286
+ font-weight: 500;
287
+ margin-bottom: 12px;
288
+ color: #202124;
289
+ }
290
+
291
+ .file-selector select {
292
+ width: 100%;
293
+ padding: 10px 12px;
294
+ border: 1px solid #dadce0;
295
+ border-radius: 6px;
296
+ font-size: 14px;
297
+ font-family: inherit;
298
+ }
299
+
300
+ .file-selector select:focus {
301
+ outline: none;
302
+ border-color: #1a73e8;
303
+ box-shadow: 0 0 0 3px rgba(26, 115, 232, 0.1);
304
+ }
305
+
306
+ .tags-container {
307
+ background: white;
308
+ border-radius: 8px;
309
+ padding: 24px;
310
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
311
+ display: flex;
312
+ gap: 24px;
313
+ min-height: 400px;
314
+ }
315
+
316
+ .tags-column {
317
+ flex: 1;
318
+ min-width: 0;
319
+ }
320
+
321
+ .tags-column-title {
322
+ font-size: 18px;
323
+ font-weight: 600;
324
+ margin-bottom: 16px;
325
+ padding-bottom: 8px;
326
+ border-bottom: 2px solid;
327
+ }
328
+
329
+ .tags-column-title.simple {
330
+ color: #4caf50;
331
+ border-bottom-color: #c8e6c9;
332
+ }
333
+
334
+ .tags-column-title.detailed {
335
+ color: #673ab7;
336
+ border-bottom-color: #d1c4e9;
337
+ }
338
+
339
+ .divider {
340
+ width: 1px;
341
+ background: #e8eaed;
342
+ flex-shrink: 0;
343
+ }
344
+
345
+ .loading {
346
+ text-align: center;
347
+ padding: 24px;
348
+ color: #5f6368;
349
+ }
350
+
351
+ .empty {
352
+ text-align: center;
353
+ padding: 20px;
354
+ color: #5f6368;
355
+ }
356
+
357
+ .error {
358
+ text-align: center;
359
+ padding: 20px;
360
+ color: #c5221f;
361
+ }
362
+
363
+ @media (max-width: 768px) {
364
+ .header-actions {
365
+ display: none;
366
+ }
367
+
368
+ .menu-toggle {
369
+ display: block;
370
+ }
371
+
372
+ .tags-container {
373
+ flex-direction: column;
374
+ }
375
+
376
+ .divider {
377
+ width: 100%;
378
+ height: 1px;
379
+ }
380
+ }
381
+ </style>
382
+ </head>
383
+ <body>
384
+ <div class="header">
385
+ <div class="header-title">
386
+ <span>๐Ÿท๏ธ</span>
387
+ <span>ํƒœ๊ทธ ๋ณด๊ธฐ</span>
388
+ </div>
389
+ <button class="menu-toggle" onclick="toggleMobileMenu()" aria-label="๋ฉ”๋‰ด ์—ด๊ธฐ">โ˜ฐ</button>
390
+ <div class="header-actions">
391
+ <span style="margin-right: 12px; color: #5f6368;">{{ current_user.nickname or current_user.username }}</span>
392
+
393
+ {# ์‚ฌ์ดํŠธ ๊ด€๋ฆฌ #}
394
+ <div class="dropdown">
395
+ <button type="button" class="dropdown-toggle">์‚ฌ์ดํŠธ ๊ด€๋ฆฌ</button>
396
+ <div class="dropdown-menu">
397
+ <a href="{{ url_for('main.admin') }}" class="dropdown-item">์‚ฌ์šฉ์ž ๊ด€๋ฆฌ</a>
398
+ <a href="{{ url_for('main.admin_tokens') }}" class="dropdown-item">ํ† ํฐ ํ†ต๊ณ„</a>
399
+ </div>
400
+ </div>
401
+
402
+ {# ์›น์†Œ์„ค ๊ด€๋ฆฌ #}
403
+ <div class="dropdown">
404
+ <button type="button" class="dropdown-toggle">์›น์†Œ์„ค ๊ด€๋ฆฌ</button>
405
+ <div class="dropdown-menu">
406
+ <a href="{{ url_for('main.admin_webnovels') }}" class="dropdown-item">์›น์†Œ์„ค ์—…๋กœ๋“œ ๋ฐ ๊ด€๋ฆฌ</a>
407
+ <a href="{{ url_for('main.admin_messages') }}" class="dropdown-item">๋ฉ”์‹œ์ง€ ํ™•์ธ</a>
408
+ </div>
409
+ </div>
410
+
411
+ {# AI ์„ค์ • #}
412
+ <div class="dropdown">
413
+ <button type="button" class="dropdown-toggle">AI ์„ค์ •</button>
414
+ <div class="dropdown-menu">
415
+ <a href="{{ url_for('main.admin_settings') }}" class="dropdown-item">AI ์„ค์ •</a>
416
+ <a href="{{ url_for('main.admin_prompts') }}" class="dropdown-item">ํ”„๋กฌํ”„ํŠธ ๊ด€๋ฆฌ</a>
417
+ </div>
418
+ </div>
419
+
420
+ {# ์ฑ—๋ด‡ #}
421
+ <div class="dropdown">
422
+ <button type="button" class="dropdown-toggle">์ฑ—๋ด‡</button>
423
+ <div class="dropdown-menu">
424
+ <a href="{{ url_for('main.admin_tags') }}" class="dropdown-item">ํƒœ๊ทธ/ํ”„๋กฌํ”„ํŠธ</a>
425
+ </div>
426
+ </div>
427
+
428
+ {# ํŽธ์˜๊ธฐ๋Šฅ #}
429
+ <div class="dropdown">
430
+ <button type="button" class="dropdown-toggle">ํŽธ์˜๊ธฐ๋Šฅ</button>
431
+ <div class="dropdown-menu">
432
+ <a href="{{ url_for('main.admin_utils') }}" class="dropdown-item">์œ ํ‹ธ</a>
433
+ </div>
434
+ </div>
435
+
436
+ {# ๋ฉ”์ธ์œผ๋กœ #}
437
+ <a href="{{ url_for('main.index') }}" class="btn btn-secondary" style="padding: 8px 16px; font-size: 14px; margin-right: 4px;">๋ฉ”์ธ์œผ๋กœ</a>
438
+ <a href="{{ url_for('main.logout') }}" class="btn btn-secondary" style="padding: 8px 16px; font-size: 14px;">๋กœ๊ทธ์•„์›ƒ</a>
439
+ </div>
440
+ </div>
441
+
442
+ <!-- ๋ชจ๋ฐ”์ผ ๋ฉ”๋‰ด -->
443
+ <div class="mobile-menu" id="mobileMenu" onclick="closeMobileMenuOnBackdrop(event)">
444
+ <div class="mobile-menu-content" onclick="event.stopPropagation()">
445
+ <div class="mobile-menu-header">
446
+ <div class="mobile-menu-title">๋ฉ”๋‰ด</div>
447
+ <button class="mobile-menu-close" onclick="toggleMobileMenu()" aria-label="๋ฉ”๋‰ด ๋‹ซ๊ธฐ">&times;</button>
448
+ </div>
449
+ <div class="mobile-menu-user">{{ current_user.nickname or current_user.username }}</div>
450
+ <div class="mobile-menu-items">
451
+ <div style="padding: 8px 20px; font-size: 11px; font-weight: 600; color: #5f6368; text-transform: uppercase; border-bottom: 1px solid #f1f3f4;">์‚ฌ์ดํŠธ ๊ด€๋ฆฌ</div>
452
+ <a href="{{ url_for('main.admin') }}" class="mobile-menu-item" onclick="closeMobileMenu()">์‚ฌ์šฉ์ž ๊ด€๋ฆฌ</a>
453
+ <a href="{{ url_for('main.admin_tokens') }}" class="mobile-menu-item" onclick="closeMobileMenu()">ํ† ํฐ ํ†ต๊ณ„</a>
454
+
455
+ <div style="padding: 8px 20px; font-size: 11px; font-weight: 600; color: #5f6368; text-transform: uppercase; border-bottom: 1px solid #f1f3f4; margin-top: 8px;">์›น์†Œ์„ค ๊ด€๋ฆฌ</div>
456
+ <a href="{{ url_for('main.admin_webnovels') }}" class="mobile-menu-item" onclick="closeMobileMenu()">์›น์†Œ์„ค ์—…๋กœ๋“œ ๋ฐ ๊ด€๋ฆฌ</a>
457
+ <a href="{{ url_for('main.admin_messages') }}" class="mobile-menu-item" onclick="closeMobileMenu()">๋ฉ”์‹œ์ง€ ํ™•์ธ</a>
458
+
459
+ <div style="padding: 8px 20px; font-size: 11px; font-weight: 600; color: #5f6368; text-transform: uppercase; border-bottom: 1px solid #f1f3f4; margin-top: 8px;">AI ์„ค์ •</div>
460
+ <a href="{{ url_for('main.admin_settings') }}" class="mobile-menu-item" onclick="closeMobileMenu()">AI ์„ค์ •</a>
461
+ <a href="{{ url_for('main.admin_prompts') }}" class="mobile-menu-item" onclick="closeMobileMenu()">ํ”„๋กฌํ”„ํŠธ ๊ด€๋ฆฌ</a>
462
+
463
+ <div style="padding: 8px 20px; font-size: 11px; font-weight: 600; color: #5f6368; text-transform: uppercase; border-bottom: 1px solid #f1f3f4; margin-top: 8px;">์ฑ—๋ด‡</div>
464
+ <a href="{{ url_for('main.admin_tags') }}" class="mobile-menu-item" onclick="closeMobileMenu()">ํƒœ๊ทธ/ํ”„๋กฌํ”„ํŠธ</a>
465
+
466
+ <div style="padding: 8px 20px; font-size: 11px; font-weight: 600; color: #5f6368; text-transform: uppercase; border-bottom: 1px solid #f1f3f4; margin-top: 8px;">ํŽธ์˜๊ธฐ๋Šฅ</div>
467
+ <a href="{{ url_for('main.admin_utils') }}" class="mobile-menu-item" onclick="closeMobileMenu()">์œ ํ‹ธ</a>
468
+
469
+ <div style="padding: 8px 20px; font-size: 11px; font-weight: 600; color: #5f6368; text-transform: uppercase; border-bottom: 1px solid #f1f3f4; margin-top: 8px;">๊ธฐํƒ€</div>
470
+ <a href="{{ url_for('main.index') }}" class="mobile-menu-item" onclick="closeMobileMenu()">๋ฉ”์ธ์œผ๋กœ</a>
471
+ <a href="{{ url_for('main.logout') }}" class="mobile-menu-item" onclick="closeMobileMenu()">๋กœ๊ทธ์•„์›ƒ</a>
472
+ </div>
473
+ </div>
474
+ </div>
475
+
476
+ <div class="container">
477
+ <div class="file-selector">
478
+ <label for="fileSelect">ํŒŒ์ผ ์„ ํƒ:</label>
479
+ <select id="fileSelect" onchange="loadTags()">
480
+ <option value="">ํŒŒ์ผ์„ ์„ ํƒํ•˜์„ธ์š”...</option>
481
+ </select>
482
+ </div>
483
+
484
+ <div class="tags-container">
485
+ <!-- ์ขŒ์ธก: ์ผ๋ฐ˜ ํƒœ๊ทธ -->
486
+ <div class="tags-column" id="simpleTagsColumn">
487
+ <div class="tags-column-title simple">์ผ๋ฐ˜ ํƒœ๊ทธ</div>
488
+ <div id="simpleTagsContent" class="loading">ํŒŒ์ผ์„ ์„ ํƒํ•˜๋ฉด ํƒœ๊ทธ๊ฐ€ ํ‘œ์‹œ๋ฉ๋‹ˆ๋‹ค.</div>
489
+ </div>
490
+ <!-- ๊ตฌ๋ถ„์„  -->
491
+ <div class="divider"></div>
492
+ <!-- ์šฐ์ธก: ์ƒ์„ธ ํƒœ๊ทธ -->
493
+ <div class="tags-column" id="detailedTagsColumn">
494
+ <div class="tags-column-title detailed">์ƒ์„ธ ํƒœ๊ทธ</div>
495
+ <div id="detailedTagsContent" class="loading">ํŒŒ์ผ์„ ์„ ํƒํ•˜๋ฉด ํƒœ๊ทธ๊ฐ€ ํ‘œ์‹œ๋ฉ๋‹ˆ๋‹ค.</div>
496
+ </div>
497
+ </div>
498
+ </div>
499
+
500
+ <script>
501
+ function toggleMobileMenu() {
502
+ const menu = document.getElementById('mobileMenu');
503
+ menu.classList.toggle('active');
504
+ document.body.style.overflow = menu.classList.contains('active') ? 'hidden' : '';
505
+ }
506
+
507
+ function closeMobileMenu() {
508
+ const menu = document.getElementById('mobileMenu');
509
+ menu.classList.remove('active');
510
+ document.body.style.overflow = '';
511
+ }
512
+
513
+ function closeMobileMenuOnBackdrop(event) {
514
+ if (event.target.id === 'mobileMenu') {
515
+ closeMobileMenu();
516
+ }
517
+ }
518
+
519
+ function escapeHtml(text) {
520
+ const div = document.createElement('div');
521
+ div.textContent = text;
522
+ return div.innerHTML;
523
+ }
524
+
525
+ async function loadFiles() {
526
+ const select = document.getElementById('fileSelect');
527
+ try {
528
+ const response = await fetch('/api/files', { credentials: 'include' });
529
+ if (!response.ok) throw new Error('ํŒŒ์ผ ๋ชฉ๋ก์„ ๋ถˆ๋Ÿฌ์˜ฌ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.');
530
+
531
+ const data = await response.json();
532
+ const files = data.files || [];
533
+
534
+ // ๊ธฐ์กด ์˜ต์…˜ ์ œ๊ฑฐ (์ฒซ ๋ฒˆ์งธ "ํŒŒ์ผ์„ ์„ ํƒํ•˜์„ธ์š”..." ์ œ์™ธ)
535
+ while (select.options.length > 1) {
536
+ select.remove(1);
537
+ }
538
+
539
+ files.forEach(file => {
540
+ const option = document.createElement('option');
541
+ option.value = file.id;
542
+ option.textContent = file.original_filename;
543
+ select.appendChild(option);
544
+ });
545
+ } catch (error) {
546
+ console.error('ํŒŒ์ผ ๋ชฉ๋ก ๋กœ๋“œ ์˜ค๋ฅ˜:', error);
547
+ }
548
+ }
549
+
550
+ async function loadTags() {
551
+ const select = document.getElementById('fileSelect');
552
+ const fileId = select.value;
553
+ const simpleContent = document.getElementById('simpleTagsContent');
554
+ const detailedContent = document.getElementById('detailedTagsContent');
555
+
556
+ if (!fileId) {
557
+ simpleContent.className = 'loading';
558
+ simpleContent.textContent = 'ํŒŒ์ผ์„ ์„ ํƒํ•˜๋ฉด ํƒœ๊ทธ๊ฐ€ ํ‘œ์‹œ๋ฉ๋‹ˆ๋‹ค.';
559
+ detailedContent.className = 'loading';
560
+ detailedContent.textContent = 'ํŒŒ์ผ์„ ์„ ํƒํ•˜๋ฉด ํƒœ๊ทธ๊ฐ€ ํ‘œ์‹œ๋ฉ๋‹ˆ๋‹ค.';
561
+ return;
562
+ }
563
+
564
+ simpleContent.className = 'loading';
565
+ simpleContent.textContent = 'ํƒœ๊ทธ๋ฅผ ๋ถˆ๋Ÿฌ์˜ค๋Š” ์ค‘...';
566
+ detailedContent.className = 'loading';
567
+ detailedContent.textContent = 'ํƒœ๊ทธ๋ฅผ ๋ถˆ๋Ÿฌ์˜ค๋Š” ์ค‘...';
568
+
569
+ try {
570
+ const response = await fetch('/api/files', { credentials: 'include' });
571
+ if (!response.ok) throw new Error('ํŒŒ์ผ ์ •๋ณด๋ฅผ ๋ถˆ๋Ÿฌ์˜ฌ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.');
572
+
573
+ const data = await response.json();
574
+ const file = data.files.find(f => f.id == fileId);
575
+
576
+ if (file && file.tags) {
577
+ try {
578
+ const tagsData = JSON.parse(file.tags);
579
+ const tagType = tagsData.type || 'legacy';
580
+
581
+ // ์ผ๋ฐ˜ ํƒœ๊ทธ์™€ ์ƒ์„ธ ํƒœ๊ทธ ๋ถ„๋ฆฌ
582
+ let simpleTags = null;
583
+ let detailedTags = null;
584
+
585
+ if (tagType === 'simple') {
586
+ simpleTags = tagsData.tags || tagsData;
587
+ detailedTags = tagsData.detailed_tags || null;
588
+ } else if (tagType === 'detailed') {
589
+ detailedTags = tagsData.tags || tagsData;
590
+ simpleTags = tagsData.simple_tags || null;
591
+ } else {
592
+ detailedTags = tagsData;
593
+ simpleTags = null;
594
+ }
595
+
596
+ // ์ผ๋ฐ˜ ํƒœ๊ทธ ๋ Œ๋”๋ง
597
+ let simpleHtml = '';
598
+ if (simpleTags) {
599
+ simpleHtml = renderAllTagSections(simpleTags, 'simple');
600
+ } else {
601
+ simpleHtml = '<div class="empty">์ผ๋ฐ˜ ํƒœ๊ทธ๊ฐ€ ์ƒ์„ฑ๋˜์ง€ ์•Š์•˜์Šต๋‹ˆ๋‹ค.</div>';
602
+ }
603
+ simpleContent.className = '';
604
+ simpleContent.innerHTML = simpleHtml;
605
+
606
+ // ์ƒ์„ธ ํƒœ๊ทธ ๋ Œ๋”๋ง
607
+ let detailedHtml = '';
608
+ if (detailedTags) {
609
+ detailedHtml = renderAllTagSections(detailedTags, 'detailed');
610
+ } else {
611
+ detailedHtml = '<div class="empty">์ƒ์„ธ ํƒœ๊ทธ๊ฐ€ ์ƒ์„ฑ๋˜์ง€ ์•Š์•˜์Šต๋‹ˆ๋‹ค.</div>';
612
+ }
613
+ detailedContent.className = '';
614
+ detailedContent.innerHTML = detailedHtml;
615
+
616
+ } catch (e) {
617
+ console.error('ํƒœ๊ทธ ํŒŒ์‹ฑ ์˜ค๋ฅ˜:', e);
618
+ simpleContent.className = 'error';
619
+ simpleContent.textContent = 'ํƒœ๊ทธ ๋ฐ์ดํ„ฐ๋ฅผ ํŒŒ์‹ฑํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.';
620
+ detailedContent.className = 'error';
621
+ detailedContent.textContent = 'ํƒœ๊ทธ ๋ฐ์ดํ„ฐ๋ฅผ ํŒŒ์‹ฑํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.';
622
+ }
623
+ } else {
624
+ simpleContent.className = 'empty';
625
+ simpleContent.textContent = '์ƒ์„ฑ๋œ ํƒœ๊ทธ๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค.';
626
+ detailedContent.className = 'empty';
627
+ detailedContent.textContent = '์ƒ์„ฑ๋œ ํƒœ๊ทธ๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค.';
628
+ }
629
+ } catch (error) {
630
+ console.error('ํƒœ๊ทธ ๋กœ๋“œ ์˜ค๋ฅ˜:', error);
631
+ simpleContent.className = 'error';
632
+ simpleContent.textContent = 'ํƒœ๊ทธ ์ •๋ณด๋ฅผ ๋ถˆ๋Ÿฌ์˜ค๋Š” ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค.';
633
+ detailedContent.className = 'error';
634
+ detailedContent.textContent = 'ํƒœ๊ทธ ์ •๋ณด๋ฅผ ๋ถˆ๋Ÿฌ์˜ค๋Š” ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค.';
635
+ }
636
+ }
637
+
638
+ // ๋ชจ๋“  ํƒœ๊ทธ ์„น์…˜ ๋ Œ๋”๋ง ํ•จ์ˆ˜
639
+ function renderAllTagSections(tags, tagType) {
640
+ let html = '';
641
+ const tagColor = tagType === 'simple' ? '#4caf50' : '#673ab7';
642
+ const tagBgColor = tagType === 'simple' ? '#e8f5e9' : '#ede7f6';
643
+ const tagBorderColor = tagType === 'simple' ? '#c8e6c9' : '#d1c4e9';
644
+
645
+ // 1. Parent Chunk
646
+ if (tags.parent_chunk) {
647
+ html += createTagSection('Parent Chunk', tags.parent_chunk, {
648
+ 'world_view': '์„ธ๊ณ„๊ด€',
649
+ 'characters': '์ธ๋ฌผ',
650
+ 'story': '์ด์•ผ๊ธฐ',
651
+ 'others': '๊ธฐํƒ€'
652
+ }, tagColor, tagBgColor, tagBorderColor);
653
+ }
654
+
655
+ // 2. Episodes (ํšŒ์ฐจ๋ณ„)
656
+ if (tags.episodes) {
657
+ html += createTagSection('ํšŒ์ฐจ๋ณ„', tags.episodes, {
658
+ 'story': '์ด์•ผ๊ธฐ',
659
+ 'events': '์‚ฌ๊ฑด',
660
+ 'characters': '์ธ๋ฌผ',
661
+ 'relationships_change': '์ธ๋ฌผ๊ฐ„ ๋ณ€ํ™”',
662
+ 'appearance': '์™ธ๋ชจ',
663
+ 'clothing': '์˜๋ณต',
664
+ 'items': '์•„์ดํ…œ/์†Œ์žฌ',
665
+ 'others': '๊ธฐํƒ€'
666
+ }, tagColor, tagBgColor, tagBorderColor);
667
+ }
668
+
669
+ // 3. GraphRAG Total
670
+ if (tags.graph_rag_total) {
671
+ html += createTagSection('GraphRAG (์ „์ฒด)', tags.graph_rag_total, {
672
+ 'characters': '์ธ๋ฌผ',
673
+ 'locations': '์žฅ์†Œ',
674
+ 'relationships': '๊ด€๊ณ„',
675
+ 'events': '์‚ฌ๏ฟฝ๏ฟฝ'
676
+ }, tagColor, tagBgColor, tagBorderColor);
677
+ }
678
+
679
+ // 4. GraphRAG by Episode
680
+ if (tags.graph_rag_by_episode) {
681
+ html += createTagSection('GraphRAG (ํšŒ์ฐจ๋ณ„)', tags.graph_rag_by_episode, {
682
+ 'characters': '์ธ๋ฌผ',
683
+ 'locations': '์žฅ์†Œ',
684
+ 'relationships': '๊ด€๊ณ„',
685
+ 'events': '์‚ฌ๊ฑด'
686
+ }, tagColor, tagBgColor, tagBorderColor);
687
+ }
688
+
689
+ // 5. GraphRAG by Character
690
+ if (tags.graph_rag_by_character) {
691
+ html += createTagSection('GraphRAG (์ธ๋ฌผ๋ณ„)', tags.graph_rag_by_character, {
692
+ 'characters': '์ธ๋ฌผ',
693
+ 'locations': '์žฅ์†Œ',
694
+ 'relationships': '๊ด€๊ณ„',
695
+ 'events': '์‚ฌ๊ฑด'
696
+ }, tagColor, tagBgColor, tagBorderColor);
697
+ }
698
+
699
+ // 6. GraphRAG by Event
700
+ if (tags.graph_rag_by_event) {
701
+ html += createTagSection('GraphRAG (์‚ฌ๊ฑด๋ณ„)', tags.graph_rag_by_event, {
702
+ 'characters': '์ธ๋ฌผ',
703
+ 'locations': '์žฅ์†Œ',
704
+ 'relationships': '๊ด€๊ณ„',
705
+ 'events': '์‚ฌ๊ฑด'
706
+ }, tagColor, tagBgColor, tagBorderColor);
707
+ }
708
+
709
+ // 7. GraphRAG (์ƒ์„ธ)
710
+ if (tags.graph_rag_detail) {
711
+ html += createTagSection('GraphRAG (์ƒ์„ธ)', tags.graph_rag_detail, {
712
+ 'person_person_relationships_chronological': '์ธ๋ฌผ-์ธ๋ฌผ ๊ด€๊ณ„ (์‹œ๊ฐ„ ์ˆœ์œผ๋กœ ๋‚˜์—ด)',
713
+ 'event_person_relationships': '์‚ฌ๊ฑด-์ธ๋ฌผ๋“ค ๊ด€๊ณ„'
714
+ }, tagColor, tagBgColor, tagBorderColor);
715
+ }
716
+
717
+ // ๊ตฌ๋ฒ„์ „ ํƒœ๊ทธ ํ˜ธํ™˜์„ฑ
718
+ if (tags.graph_rag && !tags.graph_rag_total) {
719
+ html += createTagSection('GraphRAG (๊ตฌ๋ฒ„์ „)', tags.graph_rag, {
720
+ 'characters': '์ธ๋ฌผ',
721
+ 'relationships': '๊ด€๊ณ„',
722
+ 'events': '์‚ฌ๊ฑด'
723
+ }, tagColor, tagBgColor, tagBorderColor);
724
+ }
725
+
726
+ // ๋ฐฐ์—ด ํ˜ธํ™˜์„ฑ (์™„์ „ ๊ตฌ๋ฒ„์ „)
727
+ if (Array.isArray(tags)) {
728
+ html += '<div style="display: flex; flex-wrap: wrap; gap: 8px;">';
729
+ tags.forEach(tag => {
730
+ html += `<span style="background: ${tagBgColor}; color: ${tagColor}; padding: 6px 12px; border-radius: 16px; font-size: 13px; border: 1px solid ${tagBorderColor};">#${escapeHtml(tag)}</span>`;
731
+ });
732
+ html += '</div>';
733
+ }
734
+
735
+ if (!html) {
736
+ html = '<div class="empty">ํƒœ๊ทธ ํ˜•์‹์ด ์˜ฌ๋ฐ”๋ฅด์ง€ ์•Š๊ฑฐ๋‚˜ ๋น„์–ด์žˆ์Šต๋‹ˆ๋‹ค.</div>';
737
+ }
738
+
739
+ return html;
740
+ }
741
+
742
+ // ํƒœ๊ทธ ์„น์…˜ ์ƒ์„ฑ ํ—ฌํผ ํ•จ์ˆ˜
743
+ function createTagSection(title, groupData, keyMap, titleColor = '#1a73e8', bgColor = '#e8f0fe', borderColor = '#d2e3fc') {
744
+ // ๋นˆ ์„น์…˜ ์ฒดํฌ
745
+ let hasContent = false;
746
+ for (const key of Object.keys(groupData)) {
747
+ if (groupData[key] && groupData[key].length > 0) {
748
+ hasContent = true;
749
+ break;
750
+ }
751
+ }
752
+ if (!hasContent) return '';
753
+
754
+ let html = `<div style="margin-bottom: 32px;">`;
755
+ html += `<h3 style="font-size: 16px; font-weight: 700; color: ${titleColor}; margin-bottom: 16px; border-bottom: 2px solid ${borderColor}; padding-bottom: 8px; display: flex; align-items: center;">`;
756
+ html += `<span style="margin-right: 8px;">๐Ÿ“Œ</span> ${escapeHtml(title)}`;
757
+ html += `</h3>`;
758
+
759
+ html += `<div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 16px;">`;
760
+
761
+ // ์ •์˜๋œ ์ˆœ์„œ๋Œ€๋กœ ํ‘œ์‹œํ•˜๊ธฐ ์œ„ํ•ด keyMap์˜ ํ‚ค ์ˆœ์„œ๋ฅผ ๋”ฐ๋ฆ„ (์กด์žฌํ•˜๋Š” ๊ฒฝ์šฐ)
762
+ // keyMap์— ์—†๋Š” ํ‚ค๋Š” ๋งˆ์ง€๋ง‰์— ์ถ”๊ฐ€
763
+ const orderedKeys = Object.keys(keyMap);
764
+ const extraKeys = Object.keys(groupData).filter(k => !orderedKeys.includes(k));
765
+ const allKeys = [...orderedKeys, ...extraKeys];
766
+
767
+ for (const key of allKeys) {
768
+ const values = groupData[key];
769
+ if (values && values.length > 0) {
770
+ const label = keyMap[key] || key;
771
+ html += `<div style="background: #f8f9fa; padding: 12px; border-radius: 8px; border: 1px solid #e8eaed;">`;
772
+ html += `<div style="font-size: 13px; font-weight: 700; color: #5f6368; margin-bottom: 8px;">${escapeHtml(label)}</div>`;
773
+ html += `<div style="display: flex; flex-wrap: wrap; gap: 6px;">`;
774
+ values.forEach(tag => {
775
+ // ๊ด€๊ณ„ ํƒœ๊ทธ์ธ ๊ฒฝ์šฐ ํ™”์‚ดํ‘œ ํ˜•์‹์œผ๋กœ ํ‘œ์‹œ
776
+ let displayTag = tag;
777
+ const isRelationshipKey = key === 'relationships' ||
778
+ key === 'person_person_relationships_chronological' ||
779
+ key === 'event_person_relationships' ||
780
+ key === 'relationships_change';
781
+
782
+ if (isRelationshipKey) {
783
+ // "์ธ๋ฌผA โ†’ ์ธ๋ฌผB: ๊ด€๊ณ„์œ ํ˜•" ํ˜•์‹์ด ์ด๋ฏธ ํฌํ•จ๋˜์–ด ์žˆ์œผ๋ฉด ๊ทธ๋Œ€๋กœ ์‚ฌ์šฉ
784
+ // "์ธ๋ฌผA-์ธ๋ฌผB(๊ด€๊ณ„์œ ํ˜•)" ํ˜•์‹์„ "์ธ๋ฌผA โ†’ ์ธ๋ฌผB: ๊ด€๊ณ„์œ ํ˜•"์œผ๋กœ ๋ณ€ํ™˜
785
+ if (tag.includes('-') && tag.includes('(') && tag.includes(')')) {
786
+ const match = tag.match(/^(.+?)-(.+?)\((.+?)\)/);
787
+ if (match) {
788
+ displayTag = `${match[1]} โ†’ ${match[2]}: ${match[3]}`;
789
+ }
790
+ }
791
+ // ๊ด€๊ณ„ ํƒœ๊ทธ๋Š” ํŠน๋ณ„ํ•œ ์Šคํƒ€์ผ ์ ์šฉ
792
+ html += `<span style="background: #fff3cd; border: 1px solid #ffc107; padding: 4px 8px; border-radius: 4px; font-size: 12px; color: #856404; font-weight: 500;">${escapeHtml(displayTag)}</span>`;
793
+ } else {
794
+ html += `<span style="background: white; border: 1px solid #dadce0; padding: 4px 8px; border-radius: 4px; font-size: 12px; color: #3c4043;">${escapeHtml(displayTag)}</span>`;
795
+ }
796
+ });
797
+ html += `</div></div>`;
798
+ }
799
+ }
800
+ html += '</div></div>';
801
+ return html;
802
+ }
803
+
804
+ // ํŽ˜์ด์ง€ ๋กœ๋“œ ์‹œ ์ดˆ๊ธฐํ™”
805
+ window.addEventListener('load', () => {
806
+ loadFiles().then(() => {
807
+ // URL ํŒŒ๋ผ๋ฏธํ„ฐ์—์„œ file_id ๊ฐ€์ ธ์˜ค๊ธฐ
808
+ const urlParams = new URLSearchParams(window.location.search);
809
+ const fileId = urlParams.get('file_id');
810
+ if (fileId) {
811
+ const select = document.getElementById('fileSelect');
812
+ select.value = fileId;
813
+ loadTags();
814
+ }
815
+ });
816
+ });
817
+ </script>
818
+ </body>
819
+ </html>
820
+
templates/admin_tokens.html CHANGED
@@ -35,6 +35,9 @@
35
  align-items: center;
36
  justify-content: space-between;
37
  box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
 
 
 
38
  }
39
 
40
  .header-title {
@@ -47,8 +50,109 @@
47
 
48
  .header-actions {
49
  display: flex;
50
- gap: 12px;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
51
  align-items: center;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
52
  }
53
 
54
  .menu-toggle {
@@ -348,16 +452,53 @@
348
  <button class="menu-toggle" onclick="toggleMobileMenu()" aria-label="๋ฉ”๋‰ด ์—ด๊ธฐ">โ˜ฐ</button>
349
  <div class="header-actions">
350
  <span style="margin-right: 12px; color: #5f6368;">{{ current_user.nickname or current_user.username }}</span>
351
- <a href="{{ url_for('main.admin') }}" class="btn btn-secondary">์‚ฌ์šฉ์ž ๊ด€๋ฆฌ</a>
352
- <a href="{{ url_for('main.admin_webnovels') }}" class="btn btn-secondary">์›น์†Œ์„ค ๊ด€๋ฆฌ</a>
353
- <a href="{{ url_for('main.admin_files') }}" class="btn btn-secondary">ํŒŒ์ผ ๋ชฉ๋ก</a>
354
- <a href="{{ url_for('main.admin_messages') }}" class="btn btn-secondary">๋ฉ”์‹œ์ง€ ํ™•์ธ</a>
355
- <a href="{{ url_for('main.admin_prompts') }}" class="btn btn-secondary">ํ”„๋กฌํ”„ํŠธ ๊ด€๋ฆฌ</a>
356
- <a href="{{ url_for('main.admin_settings') }}" class="btn btn-secondary">AI ์„ค์ •</a>
357
- <a href="{{ url_for('main.admin_utils') }}" class="btn btn-secondary">์œ ํ‹ธ</a>
358
- <a href="{{ url_for('main.admin_tokens') }}" class="btn btn-secondary">ํ† ํฐ ํ†ต๊ณ„</a>
359
- <a href="{{ url_for('main.index') }}" class="btn btn-secondary">๋ฉ”์ธ์œผ๋กœ</a>
360
- <a href="{{ url_for('main.logout') }}" class="btn btn-secondary">๋กœ๊ทธ์•„์›ƒ</a>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
361
  </div>
362
  </div>
363
 
@@ -370,14 +511,25 @@
370
  </div>
371
  <div class="mobile-menu-user">{{ current_user.nickname or current_user.username }}</div>
372
  <div class="mobile-menu-items">
 
373
  <a href="{{ url_for('main.admin') }}" class="mobile-menu-item" onclick="closeMobileMenu()">์‚ฌ์šฉ์ž ๊ด€๋ฆฌ</a>
374
- <a href="{{ url_for('main.admin_webnovels') }}" class="mobile-menu-item" onclick="closeMobileMenu()">์›น์†Œ์„ค ๊ด€๋ฆฌ</a>
375
- <a href="{{ url_for('main.admin_files') }}" class="mobile-menu-item" onclick="closeMobileMenu()">ํŒŒ์ผ ๋ชฉ๋ก</a>
 
 
376
  <a href="{{ url_for('main.admin_messages') }}" class="mobile-menu-item" onclick="closeMobileMenu()">๋ฉ”์‹œ์ง€ ํ™•์ธ</a>
377
- <a href="{{ url_for('main.admin_prompts') }}" class="mobile-menu-item" onclick="closeMobileMenu()">ํ”„๋กฌํ”„ํŠธ ๊ด€๋ฆฌ</a>
 
378
  <a href="{{ url_for('main.admin_settings') }}" class="mobile-menu-item" onclick="closeMobileMenu()">AI ์„ค์ •</a>
 
 
 
 
 
 
379
  <a href="{{ url_for('main.admin_utils') }}" class="mobile-menu-item" onclick="closeMobileMenu()">์œ ํ‹ธ</a>
380
- <a href="{{ url_for('main.admin_tokens') }}" class="mobile-menu-item" onclick="closeMobileMenu()">ํ† ํฐ ํ†ต๊ณ„</a>
 
381
  <a href="{{ url_for('main.index') }}" class="mobile-menu-item" onclick="closeMobileMenu()">๋ฉ”์ธ์œผ๋กœ</a>
382
  <a href="{{ url_for('main.logout') }}" class="mobile-menu-item" onclick="closeMobileMenu()">๋กœ๊ทธ์•„์›ƒ</a>
383
  </div>
 
35
  align-items: center;
36
  justify-content: space-between;
37
  box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
38
+ overflow: visible;
39
+ position: relative;
40
+ z-index: 100;
41
  }
42
 
43
  .header-title {
 
50
 
51
  .header-actions {
52
  display: flex;
53
+ gap: 8px;
54
+ align-items: center;
55
+ flex-wrap: wrap;
56
+ position: relative;
57
+ z-index: 101;
58
+ }
59
+
60
+ /* ๋“œ๋กญ๋‹ค์šด ๋ฉ”๋‰ด ์Šคํƒ€์ผ */
61
+ .dropdown {
62
+ position: relative;
63
+ display: inline-block;
64
+ z-index: 10001;
65
+ }
66
+
67
+ /* ๋ฒ„ํŠผ๊ณผ ๋ฉ”๋‰ด ์‚ฌ์ด 'ํ‹ˆ'์—์„œ hover๊ฐ€ ๋Š๊ฒจ ๋ฉ”๋‰ด๊ฐ€ ๋‹ซํžˆ๋Š” ํ˜„์ƒ ๋ฐฉ์ง€ */
68
+ .dropdown::after {
69
+ content: '';
70
+ position: absolute;
71
+ left: 0;
72
+ right: 0;
73
+ top: 100%;
74
+ height: 8px;
75
+ }
76
+
77
+ .dropdown-toggle {
78
+ padding: 8px 16px;
79
+ background: #f1f3f4;
80
+ color: #202124;
81
+ border: none;
82
+ border-radius: 6px;
83
+ font-size: 14px;
84
+ font-weight: 500;
85
+ cursor: pointer;
86
+ transition: all 0.2s;
87
+ display: flex;
88
  align-items: center;
89
+ gap: 6px;
90
+ }
91
+
92
+ .dropdown-toggle:hover {
93
+ background: #e8eaed;
94
+ }
95
+
96
+ .dropdown-toggle::after {
97
+ content: 'โ–ผ';
98
+ font-size: 10px;
99
+ transition: transform 0.2s;
100
+ }
101
+
102
+ .dropdown:hover .dropdown-toggle::after {
103
+ transform: rotate(180deg);
104
+ }
105
+
106
+ .dropdown-menu {
107
+ position: absolute;
108
+ top: calc(100% + 4px);
109
+ left: 0;
110
+ margin-top: 0;
111
+ background: white;
112
+ border: 1px solid #dadce0;
113
+ border-radius: 6px;
114
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
115
+ min-width: 200px;
116
+ opacity: 0;
117
+ visibility: hidden;
118
+ transform: translateY(-8px);
119
+ transition: all 0.2s ease;
120
+ z-index: 10002;
121
+ padding: 4px 0;
122
+ pointer-events: none;
123
+ white-space: nowrap;
124
+ }
125
+
126
+ .dropdown:hover .dropdown-menu {
127
+ opacity: 1;
128
+ visibility: visible;
129
+ transform: translateY(0);
130
+ pointer-events: auto;
131
+ }
132
+
133
+
134
+
135
+ .dropdown-item {
136
+ display: block;
137
+ padding: 10px 16px;
138
+ color: #202124;
139
+ text-decoration: none;
140
+ font-size: 14px;
141
+ transition: background 0.2s;
142
+ }
143
+
144
+ .dropdown-item:hover {
145
+ background: #f8f9fa;
146
+ }
147
+
148
+ .dropdown-item:first-child {
149
+ border-top-left-radius: 6px;
150
+ border-top-right-radius: 6px;
151
+ }
152
+
153
+ .dropdown-item:last-child {
154
+ border-bottom-left-radius: 6px;
155
+ border-bottom-right-radius: 6px;
156
  }
157
 
158
  .menu-toggle {
 
452
  <button class="menu-toggle" onclick="toggleMobileMenu()" aria-label="๋ฉ”๋‰ด ์—ด๊ธฐ">โ˜ฐ</button>
453
  <div class="header-actions">
454
  <span style="margin-right: 12px; color: #5f6368;">{{ current_user.nickname or current_user.username }}</span>
455
+
456
+ {# ์‚ฌ์ดํŠธ ๊ด€๋ฆฌ #}
457
+ <div class="dropdown">
458
+ <button type="button" class="dropdown-toggle">์‚ฌ์ดํŠธ ๊ด€๋ฆฌ</button>
459
+ <div class="dropdown-menu">
460
+ <a href="{{ url_for('main.admin') }}" class="dropdown-item">์‚ฌ์šฉ์ž ๊ด€๋ฆฌ</a>
461
+ <a href="{{ url_for('main.admin_tokens') }}" class="dropdown-item">ํ† ํฐ ํ†ต๊ณ„</a>
462
+ </div>
463
+ </div>
464
+
465
+ {# ์›น์†Œ์„ค ๊ด€๋ฆฌ #}
466
+ <div class="dropdown">
467
+ <button type="button" class="dropdown-toggle">์›น์†Œ์„ค ๊ด€๋ฆฌ</button>
468
+ <div class="dropdown-menu">
469
+ <a href="{{ url_for('main.admin_webnovels') }}" class="dropdown-item">์›น์†Œ์„ค ์—…๋กœ๋“œ ๋ฐ ๊ด€๋ฆฌ</a>
470
+ <a href="{{ url_for('main.admin_messages') }}" class="dropdown-item">๋ฉ”์‹œ์ง€ ํ™•์ธ</a>
471
+ </div>
472
+ </div>
473
+
474
+ {# AI ์„ค์ • #}
475
+ <div class="dropdown">
476
+ <button type="button" class="dropdown-toggle">AI ์„ค์ •</button>
477
+ <div class="dropdown-menu">
478
+ <a href="{{ url_for('main.admin_settings') }}" class="dropdown-item">AI ์„ค์ •</a>
479
+ <a href="{{ url_for('main.admin_prompts') }}" class="dropdown-item">ํ”„๋กฌํ”„ํŠธ ๊ด€๋ฆฌ</a>
480
+ </div>
481
+ </div>
482
+
483
+ {# ์ฑ—๋ด‡ #}
484
+ <div class="dropdown">
485
+ <button type="button" class="dropdown-toggle">์ฑ—๋ด‡</button>
486
+ <div class="dropdown-menu">
487
+ <a href="{{ url_for('main.admin_tags') }}" class="dropdown-item">ํƒœ๊ทธ/ํ”„๋กฌํ”„ํŠธ</a>
488
+ </div>
489
+ </div>
490
+
491
+ {# ํŽธ์˜๊ธฐ๋Šฅ #}
492
+ <div class="dropdown">
493
+ <button type="button" class="dropdown-toggle">ํŽธ์˜๊ธฐ๋Šฅ</button>
494
+ <div class="dropdown-menu">
495
+ <a href="{{ url_for('main.admin_utils') }}" class="dropdown-item">์œ ํ‹ธ</a>
496
+ </div>
497
+ </div>
498
+
499
+ {# ๋ฉ”์ธ์œผ๋กœ #}
500
+ <a href="{{ url_for('main.index') }}" class="btn btn-secondary" style="padding: 8px 16px; font-size: 14px; margin-right: 4px;">๋ฉ”์ธ์œผ๋กœ</a>
501
+ <a href="{{ url_for('main.logout') }}" class="btn btn-secondary" style="padding: 8px 16px; font-size: 14px;">๋กœ๊ทธ์•„์›ƒ</a>
502
  </div>
503
  </div>
504
 
 
511
  </div>
512
  <div class="mobile-menu-user">{{ current_user.nickname or current_user.username }}</div>
513
  <div class="mobile-menu-items">
514
+ <div style="padding: 8px 20px; font-size: 11px; font-weight: 600; color: #5f6368; text-transform: uppercase; border-bottom: 1px solid #f1f3f4;">์‚ฌ์ดํŠธ ๊ด€๋ฆฌ</div>
515
  <a href="{{ url_for('main.admin') }}" class="mobile-menu-item" onclick="closeMobileMenu()">์‚ฌ์šฉ์ž ๊ด€๋ฆฌ</a>
516
+ <a href="{{ url_for('main.admin_tokens') }}" class="mobile-menu-item" onclick="closeMobileMenu()">ํ† ํฐ ํ†ต๊ณ„</a>
517
+
518
+ <div style="padding: 8px 20px; font-size: 11px; font-weight: 600; color: #5f6368; text-transform: uppercase; border-bottom: 1px solid #f1f3f4; margin-top: 8px;">์›น์†Œ์„ค ๊ด€๋ฆฌ</div>
519
+ <a href="{{ url_for('main.admin_webnovels') }}" class="mobile-menu-item" onclick="closeMobileMenu()">์›น์†Œ์„ค ์—…๋กœ๋“œ ๋ฐ ๊ด€๋ฆฌ</a>
520
  <a href="{{ url_for('main.admin_messages') }}" class="mobile-menu-item" onclick="closeMobileMenu()">๋ฉ”์‹œ์ง€ ํ™•์ธ</a>
521
+
522
+ <div style="padding: 8px 20px; font-size: 11px; font-weight: 600; color: #5f6368; text-transform: uppercase; border-bottom: 1px solid #f1f3f4; margin-top: 8px;">AI ์„ค์ •</div>
523
  <a href="{{ url_for('main.admin_settings') }}" class="mobile-menu-item" onclick="closeMobileMenu()">AI ์„ค์ •</a>
524
+ <a href="{{ url_for('main.admin_prompts') }}" class="mobile-menu-item" onclick="closeMobileMenu()">ํ”„๋กฌํ”„ํŠธ ๊ด€๋ฆฌ</a>
525
+
526
+ <div style="padding: 8px 20px; font-size: 11px; font-weight: 600; color: #5f6368; text-transform: uppercase; border-bottom: 1px solid #f1f3f4; margin-top: 8px;">์ฑ—๋ด‡</div>
527
+ <a href="{{ url_for('main.admin_tags') }}" class="mobile-menu-item" onclick="closeMobileMenu()">ํƒœ๊ทธ/ํ”„๋กฌํ”„ํŠธ</a>
528
+
529
+ <div style="padding: 8px 20px; font-size: 11px; font-weight: 600; color: #5f6368; text-transform: uppercase; border-bottom: 1px solid #f1f3f4; margin-top: 8px;">ํŽธ์˜๊ธฐ๋Šฅ</div>
530
  <a href="{{ url_for('main.admin_utils') }}" class="mobile-menu-item" onclick="closeMobileMenu()">์œ ํ‹ธ</a>
531
+
532
+ <div style="padding: 8px 20px; font-size: 11px; font-weight: 600; color: #5f6368; text-transform: uppercase; border-bottom: 1px solid #f1f3f4; margin-top: 8px;">๊ธฐํƒ€</div>
533
  <a href="{{ url_for('main.index') }}" class="mobile-menu-item" onclick="closeMobileMenu()">๋ฉ”์ธ์œผ๋กœ</a>
534
  <a href="{{ url_for('main.logout') }}" class="mobile-menu-item" onclick="closeMobileMenu()">๋กœ๊ทธ์•„์›ƒ</a>
535
  </div>
templates/admin_utils.html CHANGED
@@ -44,10 +44,107 @@
44
  gap: 12px;
45
  }
46
 
47
- .header-actions {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
48
  display: flex;
49
- gap: 12px;
50
  align-items: center;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
51
  }
52
 
53
  .menu-toggle {
@@ -376,16 +473,53 @@
376
  <button class="menu-toggle" onclick="toggleMobileMenu()" aria-label="๋ฉ”๋‰ด ์—ด๊ธฐ">โ˜ฐ</button>
377
  <div class="header-actions">
378
  <span style="margin-right: 12px; color: #5f6368;">{{ current_user.nickname or current_user.username }}</span>
379
- <a href="{{ url_for('main.admin') }}" class="btn btn-secondary">์‚ฌ์šฉ์ž ๊ด€๋ฆฌ</a>
380
- <a href="{{ url_for('main.admin_webnovels') }}" class="btn btn-secondary">์›น์†Œ์„ค ๊ด€๋ฆฌ</a>
381
- <a href="{{ url_for('main.admin_files') }}" class="btn btn-secondary">ํŒŒ์ผ ๋ชฉ๋ก</a>
382
- <a href="{{ url_for('main.admin_messages') }}" class="btn btn-secondary">๋ฉ”์‹œ์ง€ ํ™•์ธ</a>
383
- <a href="{{ url_for('main.admin_prompts') }}" class="btn btn-secondary">ํ”„๋กฌํ”„ํŠธ ๊ด€๋ฆฌ</a>
384
- <a href="{{ url_for('main.admin_settings') }}" class="btn btn-secondary">AI ์„ค์ •</a>
385
- <a href="{{ url_for('main.admin_utils') }}" class="btn btn-secondary">์œ ํ‹ธ</a>
386
- <a href="{{ url_for('main.admin_tokens') }}" class="btn btn-secondary">ํ† ํฐ ํ†ต๊ณ„</a>
387
- <a href="{{ url_for('main.index') }}" class="btn btn-secondary">๋ฉ”์ธ์œผ๋กœ</a>
388
- <a href="{{ url_for('main.logout') }}" class="btn btn-secondary">๋กœ๊ทธ์•„์›ƒ</a>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
389
  </div>
390
  </div>
391
 
@@ -398,14 +532,25 @@
398
  </div>
399
  <div class="mobile-menu-user">{{ current_user.nickname or current_user.username }}</div>
400
  <div class="mobile-menu-items">
 
401
  <a href="{{ url_for('main.admin') }}" class="mobile-menu-item" onclick="closeMobileMenu()">์‚ฌ์šฉ์ž ๊ด€๋ฆฌ</a>
402
- <a href="{{ url_for('main.admin_webnovels') }}" class="mobile-menu-item" onclick="closeMobileMenu()">์›น์†Œ์„ค ๊ด€๋ฆฌ</a>
403
- <a href="{{ url_for('main.admin_files') }}" class="mobile-menu-item" onclick="closeMobileMenu()">ํŒŒ์ผ ๋ชฉ๋ก</a>
 
 
404
  <a href="{{ url_for('main.admin_messages') }}" class="mobile-menu-item" onclick="closeMobileMenu()">๋ฉ”์‹œ์ง€ ํ™•์ธ</a>
405
- <a href="{{ url_for('main.admin_prompts') }}" class="mobile-menu-item" onclick="closeMobileMenu()">ํ”„๋กฌํ”„ํŠธ ๊ด€๋ฆฌ</a>
 
406
  <a href="{{ url_for('main.admin_settings') }}" class="mobile-menu-item" onclick="closeMobileMenu()">AI ์„ค์ •</a>
 
 
 
 
 
 
407
  <a href="{{ url_for('main.admin_utils') }}" class="mobile-menu-item" onclick="closeMobileMenu()">์œ ํ‹ธ</a>
408
- <a href="{{ url_for('main.admin_tokens') }}" class="mobile-menu-item" onclick="closeMobileMenu()">ํ† ํฐ ํ†ต๊ณ„</a>
 
409
  <a href="{{ url_for('main.index') }}" class="mobile-menu-item" onclick="closeMobileMenu()">๋ฉ”์ธ์œผ๋กœ</a>
410
  <a href="{{ url_for('main.logout') }}" class="mobile-menu-item" onclick="closeMobileMenu()">๋กœ๊ทธ์•„์›ƒ</a>
411
  </div>
 
44
  gap: 12px;
45
  }
46
 
47
+ .header-actions {
48
+ display: flex;
49
+ gap: 8px;
50
+ align-items: center;
51
+ flex-wrap: wrap;
52
+ }
53
+
54
+ /* ๋“œ๋กญ๋‹ค์šด ๋ฉ”๋‰ด ์Šคํƒ€์ผ */
55
+ .dropdown {
56
+ position: relative;
57
+ display: inline-block;
58
+ }
59
+
60
+ /* ๋ฒ„ํŠผ๊ณผ ๋ฉ”๋‰ด ์‚ฌ์ด 'ํ‹ˆ'์—์„œ hover๊ฐ€ ๋Š๊ฒจ ๋ฉ”๋‰ด๊ฐ€ ๋‹ซํžˆ๋Š” ํ˜„์ƒ ๋ฐฉ์ง€ */
61
+ .dropdown::after {
62
+ content: '';
63
+ position: absolute;
64
+ left: 0;
65
+ right: 0;
66
+ top: 100%;
67
+ height: 8px;
68
+ }
69
+
70
+ .dropdown-toggle {
71
+ padding: 8px 16px;
72
+ background: #f1f3f4;
73
+ color: #202124;
74
+ border: none;
75
+ border-radius: 6px;
76
+ font-size: 14px;
77
+ font-weight: 500;
78
+ cursor: pointer;
79
+ transition: all 0.2s;
80
  display: flex;
 
81
  align-items: center;
82
+ gap: 6px;
83
+ }
84
+
85
+ .dropdown-toggle:hover {
86
+ background: #e8eaed;
87
+ }
88
+
89
+ .dropdown-toggle::after {
90
+ content: 'โ–ผ';
91
+ font-size: 10px;
92
+ transition: transform 0.2s;
93
+ }
94
+
95
+ .dropdown:hover .dropdown-toggle::after {
96
+ transform: rotate(180deg);
97
+ }
98
+
99
+ .dropdown-menu {
100
+ position: absolute;
101
+ top: calc(100% + 4px);
102
+ left: 0;
103
+ margin-top: 0;
104
+ background: white;
105
+ border: 1px solid #dadce0;
106
+ border-radius: 6px;
107
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
108
+ min-width: 200px;
109
+ opacity: 0;
110
+ visibility: hidden;
111
+ transform: translateY(-8px);
112
+ transition: all 0.2s ease;
113
+ z-index: 10000;
114
+ padding: 4px 0;
115
+ pointer-events: none;
116
+ }
117
+
118
+ .dropdown:hover .dropdown-menu {
119
+ opacity: 1;
120
+ visibility: visible;
121
+ transform: translateY(0);
122
+ pointer-events: auto;
123
+ }
124
+
125
+
126
+
127
+ .dropdown-item {
128
+ display: block;
129
+ padding: 10px 16px;
130
+ color: #202124;
131
+ text-decoration: none;
132
+ font-size: 14px;
133
+ transition: background 0.2s;
134
+ }
135
+
136
+ .dropdown-item:hover {
137
+ background: #f8f9fa;
138
+ }
139
+
140
+ .dropdown-item:first-child {
141
+ border-top-left-radius: 6px;
142
+ border-top-right-radius: 6px;
143
+ }
144
+
145
+ .dropdown-item:last-child {
146
+ border-bottom-left-radius: 6px;
147
+ border-bottom-right-radius: 6px;
148
  }
149
 
150
  .menu-toggle {
 
473
  <button class="menu-toggle" onclick="toggleMobileMenu()" aria-label="๋ฉ”๋‰ด ์—ด๊ธฐ">โ˜ฐ</button>
474
  <div class="header-actions">
475
  <span style="margin-right: 12px; color: #5f6368;">{{ current_user.nickname or current_user.username }}</span>
476
+
477
+ {# ์‚ฌ์ดํŠธ ๊ด€๋ฆฌ #}
478
+ <div class="dropdown">
479
+ <button type="button" class="dropdown-toggle">์‚ฌ์ดํŠธ ๊ด€๋ฆฌ</button>
480
+ <div class="dropdown-menu">
481
+ <a href="{{ url_for('main.admin') }}" class="dropdown-item">์‚ฌ์šฉ์ž ๊ด€๋ฆฌ</a>
482
+ <a href="{{ url_for('main.admin_tokens') }}" class="dropdown-item">ํ† ํฐ ํ†ต๊ณ„</a>
483
+ </div>
484
+ </div>
485
+
486
+ {# ์›น์†Œ์„ค ๊ด€๋ฆฌ #}
487
+ <div class="dropdown">
488
+ <button type="button" class="dropdown-toggle">์›น์†Œ์„ค ๊ด€๋ฆฌ</button>
489
+ <div class="dropdown-menu">
490
+ <a href="{{ url_for('main.admin_webnovels') }}" class="dropdown-item">์›น์†Œ์„ค ์—…๋กœ๋“œ ๋ฐ ๊ด€๋ฆฌ</a>
491
+ <a href="{{ url_for('main.admin_messages') }}" class="dropdown-item">๋ฉ”์‹œ์ง€ ํ™•์ธ</a>
492
+ </div>
493
+ </div>
494
+
495
+ {# AI ์„ค์ • #}
496
+ <div class="dropdown">
497
+ <button type="button" class="dropdown-toggle">AI ์„ค์ •</button>
498
+ <div class="dropdown-menu">
499
+ <a href="{{ url_for('main.admin_settings') }}" class="dropdown-item">AI ์„ค์ •</a>
500
+ <a href="{{ url_for('main.admin_prompts') }}" class="dropdown-item">ํ”„๋กฌํ”„ํŠธ ๊ด€๋ฆฌ</a>
501
+ </div>
502
+ </div>
503
+
504
+ {# ์ฑ—๋ด‡ #}
505
+ <div class="dropdown">
506
+ <button type="button" class="dropdown-toggle">์ฑ—๋ด‡</button>
507
+ <div class="dropdown-menu">
508
+ <a href="{{ url_for('main.admin_tags') }}" class="dropdown-item">ํƒœ๊ทธ/ํ”„๋กฌํ”„ํŠธ</a>
509
+ </div>
510
+ </div>
511
+
512
+ {# ํŽธ์˜๊ธฐ๋Šฅ #}
513
+ <div class="dropdown">
514
+ <button type="button" class="dropdown-toggle">ํŽธ์˜๊ธฐ๋Šฅ</button>
515
+ <div class="dropdown-menu">
516
+ <a href="{{ url_for('main.admin_utils') }}" class="dropdown-item">์œ ํ‹ธ</a>
517
+ </div>
518
+ </div>
519
+
520
+ {# ๋ฉ”์ธ์œผ๋กœ #}
521
+ <a href="{{ url_for('main.index') }}" class="btn btn-secondary" style="padding: 8px 16px; font-size: 14px; margin-right: 4px;">๋ฉ”์ธ์œผ๋กœ</a>
522
+ <a href="{{ url_for('main.logout') }}" class="btn btn-secondary" style="padding: 8px 16px; font-size: 14px;">๋กœ๊ทธ์•„์›ƒ</a>
523
  </div>
524
  </div>
525
 
 
532
  </div>
533
  <div class="mobile-menu-user">{{ current_user.nickname or current_user.username }}</div>
534
  <div class="mobile-menu-items">
535
+ <div style="padding: 8px 20px; font-size: 11px; font-weight: 600; color: #5f6368; text-transform: uppercase; border-bottom: 1px solid #f1f3f4;">์‚ฌ์ดํŠธ ๊ด€๋ฆฌ</div>
536
  <a href="{{ url_for('main.admin') }}" class="mobile-menu-item" onclick="closeMobileMenu()">์‚ฌ์šฉ์ž ๊ด€๋ฆฌ</a>
537
+ <a href="{{ url_for('main.admin_tokens') }}" class="mobile-menu-item" onclick="closeMobileMenu()">ํ† ํฐ ํ†ต๊ณ„</a>
538
+
539
+ <div style="padding: 8px 20px; font-size: 11px; font-weight: 600; color: #5f6368; text-transform: uppercase; border-bottom: 1px solid #f1f3f4; margin-top: 8px;">์›น์†Œ์„ค ๊ด€๋ฆฌ</div>
540
+ <a href="{{ url_for('main.admin_webnovels') }}" class="mobile-menu-item" onclick="closeMobileMenu()">์›น์†Œ์„ค ์—…๋กœ๋“œ ๋ฐ ๊ด€๋ฆฌ</a>
541
  <a href="{{ url_for('main.admin_messages') }}" class="mobile-menu-item" onclick="closeMobileMenu()">๋ฉ”์‹œ์ง€ ํ™•์ธ</a>
542
+
543
+ <div style="padding: 8px 20px; font-size: 11px; font-weight: 600; color: #5f6368; text-transform: uppercase; border-bottom: 1px solid #f1f3f4; margin-top: 8px;">AI ์„ค์ •</div>
544
  <a href="{{ url_for('main.admin_settings') }}" class="mobile-menu-item" onclick="closeMobileMenu()">AI ์„ค์ •</a>
545
+ <a href="{{ url_for('main.admin_prompts') }}" class="mobile-menu-item" onclick="closeMobileMenu()">ํ”„๋กฌํ”„ํŠธ ๊ด€๋ฆฌ</a>
546
+
547
+ <div style="padding: 8px 20px; font-size: 11px; font-weight: 600; color: #5f6368; text-transform: uppercase; border-bottom: 1px solid #f1f3f4; margin-top: 8px;">์ฑ—๋ด‡</div>
548
+ <a href="{{ url_for('main.admin_tags') }}" class="mobile-menu-item" onclick="closeMobileMenu()">ํƒœ๊ทธ/ํ”„๋กฌํ”„ํŠธ</a>
549
+
550
+ <div style="padding: 8px 20px; font-size: 11px; font-weight: 600; color: #5f6368; text-transform: uppercase; border-bottom: 1px solid #f1f3f4; margin-top: 8px;">ํŽธ์˜๊ธฐ๋Šฅ</div>
551
  <a href="{{ url_for('main.admin_utils') }}" class="mobile-menu-item" onclick="closeMobileMenu()">์œ ํ‹ธ</a>
552
+
553
+ <div style="padding: 8px 20px; font-size: 11px; font-weight: 600; color: #5f6368; text-transform: uppercase; border-bottom: 1px solid #f1f3f4; margin-top: 8px;">๊ธฐํƒ€</div>
554
  <a href="{{ url_for('main.index') }}" class="mobile-menu-item" onclick="closeMobileMenu()">๋ฉ”์ธ์œผ๋กœ</a>
555
  <a href="{{ url_for('main.logout') }}" class="mobile-menu-item" onclick="closeMobileMenu()">๋กœ๊ทธ์•„์›ƒ</a>
556
  </div>
templates/admin_webnovels.html CHANGED
@@ -35,7 +35,9 @@
35
  align-items: center;
36
  justify-content: space-between;
37
  box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
38
- overflow-x: hidden;
 
 
39
  }
40
 
41
  .header-title {
@@ -48,9 +50,109 @@
48
 
49
  .header-actions {
50
  display: flex;
51
- gap: 12px;
52
  align-items: center;
53
  flex-wrap: wrap;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
54
  }
55
 
56
  .menu-toggle {
@@ -619,16 +721,53 @@
619
  <button class="menu-toggle" onclick="toggleMobileMenu()" aria-label="๋ฉ”๋‰ด ์—ด๊ธฐ">โ˜ฐ</button>
620
  <div class="header-actions">
621
  <span style="margin-right: 12px; color: #5f6368;">{{ current_user.nickname or current_user.username }}</span>
622
- <a href="{{ url_for('main.admin') }}" class="btn btn-secondary">์‚ฌ์šฉ์ž ๊ด€๋ฆฌ</a>
623
- <a href="{{ url_for('main.admin_files') }}" class="btn btn-secondary">ํŒŒ์ผ ๋ชฉ๋ก</a>
624
- <a href="{{ url_for('main.admin_messages') }}" class="btn btn-secondary">๋ฉ”์‹œ์ง€ ํ™•์ธ</a>
625
- <a href="{{ url_for('main.admin_prompts') }}" class="btn btn-secondary">ํ”„๋กฌํ”„ํŠธ ๊ด€๋ฆฌ</a>
626
- <a href="{{ url_for('main.admin_settings') }}" class="btn btn-secondary">AI ์„ค์ •</a>
627
- <a href="{{ url_for('main.admin_utils') }}" class="btn btn-secondary">์œ ํ‹ธ</a>
628
- <a href="{{ url_for('main.admin_tokens') }}" class="btn btn-secondary">ํ† ํฐ ํ†ต๊ณ„</a>
629
- <a href="{{ url_for('main.admin_utils') }}" class="btn btn-secondary">์œ ํ‹ธ</a>
630
- <a href="{{ url_for('main.index') }}" class="btn btn-secondary">๋ฉ”์ธ์œผ๋กœ</a>
631
- <a href="{{ url_for('main.logout') }}" class="btn btn-secondary">๋กœ๊ทธ์•„์›ƒ</a>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
632
  </div>
633
  </div>
634
 
@@ -641,14 +780,25 @@
641
  </div>
642
  <div class="mobile-menu-user">{{ current_user.nickname or current_user.username }}</div>
643
  <div class="mobile-menu-items">
 
644
  <a href="{{ url_for('main.admin') }}" class="mobile-menu-item" onclick="closeMobileMenu()">์‚ฌ์šฉ์ž ๊ด€๋ฆฌ</a>
645
- <a href="{{ url_for('main.admin_files') }}" class="mobile-menu-item" onclick="closeMobileMenu()">ํŒŒ์ผ ๋ชฉ๋ก</a>
 
 
 
646
  <a href="{{ url_for('main.admin_messages') }}" class="mobile-menu-item" onclick="closeMobileMenu()">๋ฉ”์‹œ์ง€ ํ™•์ธ</a>
647
- <a href="{{ url_for('main.admin_prompts') }}" class="mobile-menu-item" onclick="closeMobileMenu()">ํ”„๋กฌํ”„ํŠธ ๊ด€๋ฆฌ</a>
 
648
  <a href="{{ url_for('main.admin_settings') }}" class="mobile-menu-item" onclick="closeMobileMenu()">AI ์„ค์ •</a>
 
 
 
 
 
 
649
  <a href="{{ url_for('main.admin_utils') }}" class="mobile-menu-item" onclick="closeMobileMenu()">์œ ํ‹ธ</a>
650
- <a href="{{ url_for('main.admin_tokens') }}" class="mobile-menu-item" onclick="closeMobileMenu()">ํ† ํฐ ํ†ต๊ณ„</a>
651
- <a href="{{ url_for('main.admin_utils') }}" class="mobile-menu-item" onclick="closeMobileMenu()">์œ ํ‹ธ</a>
652
  <a href="{{ url_for('main.index') }}" class="mobile-menu-item" onclick="closeMobileMenu()">๋ฉ”์ธ์œผ๋กœ</a>
653
  <a href="{{ url_for('main.logout') }}" class="mobile-menu-item" onclick="closeMobileMenu()">๋กœ๊ทธ์•„์›ƒ</a>
654
  </div>
@@ -992,44 +1142,87 @@
992
  const toggleButtonText = isPublic ? '๋น„๊ณต๊ฐœ๋กœ' : '๊ณต๊ฐœ๋กœ';
993
  const toggleButtonClass = isPublic ? 'btn-warning' : 'btn-success';
994
 
995
- // ํƒœ๊ทธ ํ‘œ์‹œ
996
  let tagsHtml = '';
997
  if (file.tags) {
998
  try {
999
- const tags = JSON.parse(file.tags);
1000
- let displayTags = [];
 
1001
 
1002
- if (Array.isArray(tags)) {
1003
- displayTags = tags;
1004
- } else {
1005
- // ๊ณ„์ธต์  ๊ตฌ์กฐ์ธ ๊ฒฝ์šฐ ํ‰ํƒ„ํ™”ํ•˜์—ฌ ์ผ๋ถ€๋งŒ ์ถ”์ถœ
1006
- if (tags.parent_chunk) {
1007
- Object.values(tags.parent_chunk).forEach(arr => {
1008
- if (Array.isArray(arr)) displayTags.push(...arr);
1009
- });
1010
- }
1011
- if (tags.episodes) {
1012
- Object.values(tags.episodes).forEach(arr => {
1013
- if (Array.isArray(arr)) displayTags.push(...arr);
1014
- });
1015
- }
1016
- if (tags.graph_rag) {
1017
- Object.values(tags.graph_rag).forEach(arr => {
1018
- if (Array.isArray(arr)) displayTags.push(...arr);
 
 
 
 
 
 
 
 
 
 
1019
  });
1020
  }
1021
  }
1022
 
1023
- // ์ตœ๋Œ€ 5๊ฐœ๋งŒ ํ‘œ์‹œํ•˜๊ณ  ๋‚˜๋จธ์ง€๋Š” ์ƒ๋žต
1024
- const visibleTags = displayTags.slice(0, 5);
1025
- if (visibleTags.length > 0) {
1026
- tagsHtml = '<div style="margin-top: 6px; display: flex; flex-wrap: wrap; gap: 4px;">';
1027
- visibleTags.forEach(tag => {
1028
- tagsHtml += `<span style="background: #e8f0fe; color: #1a73e8; padding: 2px 6px; border-radius: 4px; font-size: 11px; border: 1px solid #d2e3fc;">#${escapeHtml(tag)}</span>`;
 
 
 
1029
  });
1030
- if (displayTags.length > 5) {
1031
- tagsHtml += `<span style="color: #5f6368; font-size: 11px; padding: 2px;">+${displayTags.length - 5}</span>`;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1032
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1033
  tagsHtml += '</div>';
1034
  }
1035
  } catch (e) {
@@ -1058,7 +1251,8 @@
1058
  `<button class="btn btn-secondary" onclick="createParentChunk(${file.id}, '${escapeHtml(file.original_filename)}')" style="padding: 4px 8px; font-size: 12px; margin-right: 4px;">Parent Chunk ์ƒ์„ฑ</button>`
1059
  }
1060
  <button class="btn btn-info" onclick="createMetadata(${file.id}, '${escapeHtml(file.original_filename)}')" style="padding: 4px 8px; font-size: 12px; margin-right: 4px;">๋ฉ”ํƒ€๋ฐ์ดํ„ฐ ์ƒ์„ฑ</button>
1061
- <button class="btn" style="background: #673ab7; color: white; padding: 4px 8px; font-size: 12px; margin-right: 4px;" onclick="createTags(${file.id}, '${escapeHtml(file.original_filename)}')">ํƒœ๊ทธ ์ƒ์„ฑ</button>
 
1062
  <button class="btn btn-primary" onclick="continueUpload(${file.id})" style="padding: 4px 8px; font-size: 12px; margin-right: 4px;">์ด์–ด์„œ ์—…๋กœ๋“œ</button>
1063
  <button class="btn btn-secondary" onclick="deleteFile(${file.id})" style="padding: 4px 8px; font-size: 12px;">์‚ญ์ œ</button>
1064
  </div>
@@ -1945,9 +2139,9 @@
1945
  }
1946
  }
1947
 
1948
- // ํƒœ๊ทธ ์ƒ์„ฑ
1949
- async function createTags(fileId, fileName) {
1950
- if (!confirm(`"${fileName}" ํŒŒ์ผ์˜ ํƒœ๊ทธ๋ฅผ ์ƒ์„ฑํ•˜์‹œ๊ฒ ์Šต๋‹ˆ๊นŒ?\n\n์ด ์ž‘์—…์€ AI ๋ชจ๋ธ์„ ์‚ฌ์šฉํ•˜์—ฌ Parent Chunk์™€ ํšŒ์ฐจ๋ณ„ ๋ถ„์„ ๋ฐ์ดํ„ฐ๋ฅผ ์ข…ํ•ฉํ•˜์—ฌ ๋ถ„์„ํ•ฉ๋‹ˆ๋‹ค.`)) {
1951
  return;
1952
  }
1953
 
@@ -1957,25 +2151,58 @@
1957
  button.textContent = '์ƒ์„ฑ ์ค‘...';
1958
 
1959
  try {
1960
- const response = await fetch(`/api/files/${fileId}/process/tags`, {
1961
  method: 'POST',
1962
  credentials: 'include'
1963
  });
1964
  const data = await response.json();
1965
 
1966
  if (response.ok) {
1967
- let message = 'ํƒœ๊ทธ ์ƒ์„ฑ์ด ์™„๋ฃŒ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.';
1968
- if (data.tags && data.tags.length > 0) {
1969
- message += `\n์ƒ์„ฑ๋œ ํƒœ๊ทธ: ${data.tags.join(', ')}`;
1970
- }
1971
- showAlert(message, 'success');
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1972
  loadFiles(); // ํŒŒ์ผ ๋ชฉ๋ก ์ƒˆ๋กœ๊ณ ์นจ
1973
  } else {
1974
- showAlert(`ํƒœ๊ทธ ์ƒ์„ฑ ์‹คํŒจ: ${data.error || '์•Œ ์ˆ˜ ์—†๋Š” ์˜ค๋ฅ˜'}`, 'error');
 
1975
  }
1976
  } catch (error) {
1977
- showAlert(`ํƒœ๊ทธ ์ƒ์„ฑ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค: ${error.message}`, 'error');
1978
- console.error('ํƒœ๊ทธ ์ƒ์„ฑ ์˜ค๋ฅ˜:', error);
1979
  } finally {
1980
  button.disabled = false;
1981
  button.textContent = originalText;
 
35
  align-items: center;
36
  justify-content: space-between;
37
  box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
38
+ overflow: visible;
39
+ position: relative;
40
+ z-index: 100;
41
  }
42
 
43
  .header-title {
 
50
 
51
  .header-actions {
52
  display: flex;
53
+ gap: 8px;
54
  align-items: center;
55
  flex-wrap: wrap;
56
+ position: relative;
57
+ z-index: 101;
58
+ }
59
+
60
+ /* ๋“œ๋กญ๋‹ค์šด ๋ฉ”๋‰ด ์Šคํƒ€์ผ */
61
+ .dropdown {
62
+ position: relative;
63
+ display: inline-block;
64
+ z-index: 10001;
65
+ }
66
+
67
+ /* ๋ฒ„ํŠผ๊ณผ ๋ฉ”๋‰ด ์‚ฌ์ด 'ํ‹ˆ'์—์„œ hover๊ฐ€ ๋Š๊ฒจ ๋ฉ”๋‰ด๊ฐ€ ๋‹ซํžˆ๋Š” ํ˜„์ƒ ๋ฐฉ์ง€ */
68
+ .dropdown::after {
69
+ content: '';
70
+ position: absolute;
71
+ left: 0;
72
+ right: 0;
73
+ top: 100%;
74
+ height: 8px;
75
+ }
76
+
77
+ .dropdown-toggle {
78
+ padding: 8px 16px;
79
+ background: #f1f3f4;
80
+ color: #202124;
81
+ border: none;
82
+ border-radius: 6px;
83
+ font-size: 14px;
84
+ font-weight: 500;
85
+ cursor: pointer;
86
+ transition: all 0.2s;
87
+ display: flex;
88
+ align-items: center;
89
+ gap: 6px;
90
+ }
91
+
92
+ .dropdown-toggle:hover {
93
+ background: #e8eaed;
94
+ }
95
+
96
+ .dropdown-toggle::after {
97
+ content: 'โ–ผ';
98
+ font-size: 10px;
99
+ transition: transform 0.2s;
100
+ }
101
+
102
+ .dropdown:hover .dropdown-toggle::after {
103
+ transform: rotate(180deg);
104
+ }
105
+
106
+ .dropdown-menu {
107
+ position: absolute;
108
+ top: calc(100% + 4px);
109
+ left: 0;
110
+ margin-top: 0;
111
+ background: white;
112
+ border: 1px solid #dadce0;
113
+ border-radius: 6px;
114
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
115
+ min-width: 200px;
116
+ opacity: 0;
117
+ visibility: hidden;
118
+ transform: translateY(-8px);
119
+ transition: all 0.2s ease;
120
+ z-index: 10002;
121
+ padding: 4px 0;
122
+ pointer-events: none;
123
+ white-space: nowrap;
124
+ }
125
+
126
+ .dropdown:hover .dropdown-menu {
127
+ opacity: 1;
128
+ visibility: visible;
129
+ transform: translateY(0);
130
+ pointer-events: auto;
131
+ }
132
+
133
+
134
+
135
+ .dropdown-item {
136
+ display: block;
137
+ padding: 10px 16px;
138
+ color: #202124;
139
+ text-decoration: none;
140
+ font-size: 14px;
141
+ transition: background 0.2s;
142
+ }
143
+
144
+ .dropdown-item:hover {
145
+ background: #f8f9fa;
146
+ }
147
+
148
+ .dropdown-item:first-child {
149
+ border-top-left-radius: 6px;
150
+ border-top-right-radius: 6px;
151
+ }
152
+
153
+ .dropdown-item:last-child {
154
+ border-bottom-left-radius: 6px;
155
+ border-bottom-right-radius: 6px;
156
  }
157
 
158
  .menu-toggle {
 
721
  <button class="menu-toggle" onclick="toggleMobileMenu()" aria-label="๋ฉ”๋‰ด ์—ด๊ธฐ">โ˜ฐ</button>
722
  <div class="header-actions">
723
  <span style="margin-right: 12px; color: #5f6368;">{{ current_user.nickname or current_user.username }}</span>
724
+
725
+ {# ์‚ฌ์ดํŠธ ๊ด€๋ฆฌ #}
726
+ <div class="dropdown">
727
+ <button type="button" class="dropdown-toggle">์‚ฌ์ดํŠธ ๊ด€๋ฆฌ</button>
728
+ <div class="dropdown-menu">
729
+ <a href="{{ url_for('main.admin') }}" class="dropdown-item">์‚ฌ์šฉ์ž ๊ด€๋ฆฌ</a>
730
+ <a href="{{ url_for('main.admin_tokens') }}" class="dropdown-item">ํ† ํฐ ํ†ต๊ณ„</a>
731
+ </div>
732
+ </div>
733
+
734
+ {# ์›น์†Œ์„ค ๊ด€๋ฆฌ #}
735
+ <div class="dropdown">
736
+ <button type="button" class="dropdown-toggle">์›น์†Œ์„ค ๊ด€๋ฆฌ</button>
737
+ <div class="dropdown-menu">
738
+ <a href="{{ url_for('main.admin_webnovels') }}" class="dropdown-item">์›น์†Œ์„ค ์—…๋กœ๋“œ ๋ฐ ๊ด€๋ฆฌ</a>
739
+ <a href="{{ url_for('main.admin_messages') }}" class="dropdown-item">๋ฉ”์‹œ์ง€ ํ™•์ธ</a>
740
+ </div>
741
+ </div>
742
+
743
+ {# AI ์„ค์ • #}
744
+ <div class="dropdown">
745
+ <button type="button" class="dropdown-toggle">AI ์„ค์ •</button>
746
+ <div class="dropdown-menu">
747
+ <a href="{{ url_for('main.admin_settings') }}" class="dropdown-item">AI ์„ค์ •</a>
748
+ <a href="{{ url_for('main.admin_prompts') }}" class="dropdown-item">ํ”„๋กฌํ”„ํŠธ ๊ด€๋ฆฌ</a>
749
+ </div>
750
+ </div>
751
+
752
+ {# ์ฑ—๋ด‡ #}
753
+ <div class="dropdown">
754
+ <button type="button" class="dropdown-toggle">์ฑ—๋ด‡</button>
755
+ <div class="dropdown-menu">
756
+ <a href="{{ url_for('main.admin_tags') }}" class="dropdown-item">ํƒœ๊ทธ/ํ”„๋กฌํ”„ํŠธ</a>
757
+ </div>
758
+ </div>
759
+
760
+ {# ํŽธ์˜๊ธฐ๋Šฅ #}
761
+ <div class="dropdown">
762
+ <button type="button" class="dropdown-toggle">ํŽธ์˜๊ธฐ๋Šฅ</button>
763
+ <div class="dropdown-menu">
764
+ <a href="{{ url_for('main.admin_utils') }}" class="dropdown-item">์œ ํ‹ธ</a>
765
+ </div>
766
+ </div>
767
+
768
+ {# ๋ฉ”์ธ์œผ๋กœ #}
769
+ <a href="{{ url_for('main.index') }}" class="btn btn-secondary" style="padding: 8px 16px; font-size: 14px; margin-right: 4px;">๋ฉ”์ธ์œผ๋กœ</a>
770
+ <a href="{{ url_for('main.logout') }}" class="btn btn-secondary" style="padding: 8px 16px; font-size: 14px;">๋กœ๊ทธ์•„์›ƒ</a>
771
  </div>
772
  </div>
773
 
 
780
  </div>
781
  <div class="mobile-menu-user">{{ current_user.nickname or current_user.username }}</div>
782
  <div class="mobile-menu-items">
783
+ <div style="padding: 8px 20px; font-size: 11px; font-weight: 600; color: #5f6368; text-transform: uppercase; border-bottom: 1px solid #f1f3f4;">์‚ฌ์ดํŠธ ๊ด€๋ฆฌ</div>
784
  <a href="{{ url_for('main.admin') }}" class="mobile-menu-item" onclick="closeMobileMenu()">์‚ฌ์šฉ์ž ๊ด€๋ฆฌ</a>
785
+ <a href="{{ url_for('main.admin_tokens') }}" class="mobile-menu-item" onclick="closeMobileMenu()">ํ† ํฐ ํ†ต๊ณ„</a>
786
+
787
+ <div style="padding: 8px 20px; font-size: 11px; font-weight: 600; color: #5f6368; text-transform: uppercase; border-bottom: 1px solid #f1f3f4; margin-top: 8px;">์›น์†Œ์„ค ๊ด€๋ฆฌ</div>
788
+ <a href="{{ url_for('main.admin_webnovels') }}" class="mobile-menu-item" onclick="closeMobileMenu()">์›น์†Œ์„ค ์—…๋กœ๋“œ ๋ฐ ๊ด€๋ฆฌ</a>
789
  <a href="{{ url_for('main.admin_messages') }}" class="mobile-menu-item" onclick="closeMobileMenu()">๋ฉ”์‹œ์ง€ ํ™•์ธ</a>
790
+
791
+ <div style="padding: 8px 20px; font-size: 11px; font-weight: 600; color: #5f6368; text-transform: uppercase; border-bottom: 1px solid #f1f3f4; margin-top: 8px;">AI ์„ค์ •</div>
792
  <a href="{{ url_for('main.admin_settings') }}" class="mobile-menu-item" onclick="closeMobileMenu()">AI ์„ค์ •</a>
793
+ <a href="{{ url_for('main.admin_prompts') }}" class="mobile-menu-item" onclick="closeMobileMenu()">ํ”„๋กฌํ”„ํŠธ ๊ด€๋ฆฌ</a>
794
+
795
+ <div style="padding: 8px 20px; font-size: 11px; font-weight: 600; color: #5f6368; text-transform: uppercase; border-bottom: 1px solid #f1f3f4; margin-top: 8px;">์ฑ—๋ด‡</div>
796
+ <a href="{{ url_for('main.admin_tags') }}" class="mobile-menu-item" onclick="closeMobileMenu()">ํƒœ๊ทธ/ํ”„๋กฌํ”„ํŠธ</a>
797
+
798
+ <div style="padding: 8px 20px; font-size: 11px; font-weight: 600; color: #5f6368; text-transform: uppercase; border-bottom: 1px solid #f1f3f4; margin-top: 8px;">ํŽธ์˜๊ธฐ๋Šฅ</div>
799
  <a href="{{ url_for('main.admin_utils') }}" class="mobile-menu-item" onclick="closeMobileMenu()">์œ ํ‹ธ</a>
800
+
801
+ <div style="padding: 8px 20px; font-size: 11px; font-weight: 600; color: #5f6368; text-transform: uppercase; border-bottom: 1px solid #f1f3f4; margin-top: 8px;">๊ธฐํƒ€</div>
802
  <a href="{{ url_for('main.index') }}" class="mobile-menu-item" onclick="closeMobileMenu()">๋ฉ”์ธ์œผ๋กœ</a>
803
  <a href="{{ url_for('main.logout') }}" class="mobile-menu-item" onclick="closeMobileMenu()">๋กœ๊ทธ์•„์›ƒ</a>
804
  </div>
 
1142
  const toggleButtonText = isPublic ? '๋น„๊ณต๊ฐœ๋กœ' : '๊ณต๊ฐœ๋กœ';
1143
  const toggleButtonClass = isPublic ? 'btn-warning' : 'btn-success';
1144
 
1145
+ // ํƒœ๊ทธ ํ‘œ์‹œ (์ผ๋ฐ˜ ํƒœ๊ทธ์™€ ์ƒ์„ธ ํƒœ๊ทธ ๊ตฌ๋ถ„)
1146
  let tagsHtml = '';
1147
  if (file.tags) {
1148
  try {
1149
+ const tagsData = JSON.parse(file.tags);
1150
+ const tagType = tagsData.type || 'legacy'; // legacy๋Š” ๊ธฐ์กด ํ˜•์‹
1151
+ const tags = tagsData.tags || tagsData; // tags ํ•„๋“œ๊ฐ€ ์žˆ์œผ๋ฉด ์‚ฌ์šฉ, ์—†์œผ๋ฉด ์ „์ฒด
1152
 
1153
+ let simpleTags = [];
1154
+ let detailedTags = [];
1155
+
1156
+ // ์ผ๋ฐ˜ ํƒœ๊ทธ ์ถ”์ถœ
1157
+ if (tagType === 'simple' || tagsData.simple_tags) {
1158
+ const simpleData = tagsData.simple_tags || tags;
1159
+ const topLevelKeys = ['parent_chunk', 'episodes', 'graph_rag_total', 'graph_rag_by_episode', 'graph_rag_by_character', 'graph_rag_by_event', 'graph_rag_detail'];
1160
+ topLevelKeys.forEach(key => {
1161
+ if (simpleData[key]) {
1162
+ Object.values(simpleData[key]).forEach(arr => {
1163
+ if (Array.isArray(arr)) simpleTags.push(...arr);
1164
+ });
1165
+ }
1166
+ });
1167
+ }
1168
+
1169
+ // ์ƒ์„ธ ํƒœ๊ทธ ์ถ”์ถœ
1170
+ if (tagType === 'detailed' || tagsData.detailed_tags) {
1171
+ const detailedData = tagsData.detailed_tags || (tagType === 'detailed' ? tags : null);
1172
+ if (detailedData) {
1173
+ const topLevelKeys = ['parent_chunk', 'episodes', 'graph_rag_total', 'graph_rag_by_episode', 'graph_rag_by_character', 'graph_rag_by_event', 'graph_rag_detail'];
1174
+ topLevelKeys.forEach(key => {
1175
+ if (detailedData[key]) {
1176
+ Object.values(detailedData[key]).forEach(arr => {
1177
+ if (Array.isArray(arr)) detailedTags.push(...arr);
1178
+ });
1179
+ }
1180
  });
1181
  }
1182
  }
1183
 
1184
+ // ๊ธฐ์กด ํ˜•์‹ (legacy) ๏ฟฝ๏ฟฝ๏ฟฝ๋ฆฌ
1185
+ if (tagType === 'legacy' && !tagsData.tags) {
1186
+ const topLevelKeys = ['parent_chunk', 'episodes', 'graph_rag_total', 'graph_rag_by_episode', 'graph_rag_by_character', 'graph_rag_by_event', 'graph_rag_detail'];
1187
+ topLevelKeys.forEach(key => {
1188
+ if (tags[key]) {
1189
+ Object.values(tags[key]).forEach(arr => {
1190
+ if (Array.isArray(arr)) detailedTags.push(...arr);
1191
+ });
1192
+ }
1193
  });
1194
+ }
1195
+
1196
+ // ํƒœ๊ทธ ํ‘œ์‹œ HTML ์ƒ์„ฑ
1197
+ if (simpleTags.length > 0 || detailedTags.length > 0) {
1198
+ tagsHtml = '<div style="margin-top: 6px;">';
1199
+
1200
+ // ์ผ๋ฐ˜ ํƒœ๊ทธ ํ‘œ์‹œ
1201
+ if (simpleTags.length > 0) {
1202
+ const visibleSimple = simpleTags.slice(0, 3);
1203
+ tagsHtml += '<div style="margin-bottom: 4px;"><span style="color: #4caf50; font-size: 10px; font-weight: 500; margin-right: 4px;">[์ผ๋ฐ˜ ํƒœ๊ทธ]</span>';
1204
+ visibleSimple.forEach(tag => {
1205
+ tagsHtml += `<span style="background: #e8f5e9; color: #2e7d32; padding: 2px 6px; border-radius: 4px; font-size: 11px; border: 1px solid #c8e6c9; margin-right: 4px;">#${escapeHtml(tag)}</span>`;
1206
+ });
1207
+ if (simpleTags.length > 3) {
1208
+ tagsHtml += `<span style="color: #5f6368; font-size: 10px;">+${simpleTags.length - 3}</span>`;
1209
+ }
1210
+ tagsHtml += '</div>';
1211
  }
1212
+
1213
+ // ์ƒ์„ธ ํƒœ๊ทธ ํ‘œ์‹œ
1214
+ if (detailedTags.length > 0) {
1215
+ const visibleDetailed = detailedTags.slice(0, 3);
1216
+ tagsHtml += '<div><span style="color: #673ab7; font-size: 10px; font-weight: 500; margin-right: 4px;">[์ƒ์„ธ ํƒœ๊ทธ]</span>';
1217
+ visibleDetailed.forEach(tag => {
1218
+ tagsHtml += `<span style="background: #ede7f6; color: #512da8; padding: 2px 6px; border-radius: 4px; font-size: 11px; border: 1px solid #d1c4e9; margin-right: 4px;">#${escapeHtml(tag)}</span>`;
1219
+ });
1220
+ if (detailedTags.length > 3) {
1221
+ tagsHtml += `<span style="color: #5f6368; font-size: 10px;">+${detailedTags.length - 3}</span>`;
1222
+ }
1223
+ tagsHtml += '</div>';
1224
+ }
1225
+
1226
  tagsHtml += '</div>';
1227
  }
1228
  } catch (e) {
 
1251
  `<button class="btn btn-secondary" onclick="createParentChunk(${file.id}, '${escapeHtml(file.original_filename)}')" style="padding: 4px 8px; font-size: 12px; margin-right: 4px;">Parent Chunk ์ƒ์„ฑ</button>`
1252
  }
1253
  <button class="btn btn-info" onclick="createMetadata(${file.id}, '${escapeHtml(file.original_filename)}')" style="padding: 4px 8px; font-size: 12px; margin-right: 4px;">๋ฉ”ํƒ€๋ฐ์ดํ„ฐ ์ƒ์„ฑ</button>
1254
+ <button class="btn" style="background: #4caf50; color: white; padding: 4px 8px; font-size: 12px; margin-right: 4px;" onclick="createSimpleTags(${file.id}, '${escapeHtml(file.original_filename)}')">์ผ๋ฐ˜ ํƒœ๊ทธ ์ƒ์„ฑ</button>
1255
+ <button class="btn" style="background: #673ab7; color: white; padding: 4px 8px; font-size: 12px; margin-right: 4px;" onclick="createDetailedTags(${file.id}, '${escapeHtml(file.original_filename)}')">์ƒ์„ธ ํƒœ๊ทธ ์ƒ์„ฑ</button>
1256
  <button class="btn btn-primary" onclick="continueUpload(${file.id})" style="padding: 4px 8px; font-size: 12px; margin-right: 4px;">์ด์–ด์„œ ์—…๋กœ๋“œ</button>
1257
  <button class="btn btn-secondary" onclick="deleteFile(${file.id})" style="padding: 4px 8px; font-size: 12px;">์‚ญ์ œ</button>
1258
  </div>
 
2139
  }
2140
  }
2141
 
2142
+ // ์ผ๋ฐ˜ ํƒœ๊ทธ ์ƒ์„ฑ
2143
+ async function createSimpleTags(fileId, fileName) {
2144
+ if (!confirm(`"${fileName}" ํŒŒ์ผ์˜ ์ผ๋ฐ˜ ํƒœ๊ทธ๋ฅผ ์ƒ์„ฑํ•˜์‹œ๊ฒ ์Šต๋‹ˆ๊นŒ?\n\n๊ฐ„๋‹จํ•œ ํ‚ค์›Œ๋“œ ํ˜•ํƒœ์˜ ํƒœ๊ทธ๊ฐ€ ์ƒ์„ฑ๋ฉ๋‹ˆ๋‹ค.`)) {
2145
  return;
2146
  }
2147
 
 
2151
  button.textContent = '์ƒ์„ฑ ์ค‘...';
2152
 
2153
  try {
2154
+ const response = await fetch(`/api/files/${fileId}/process/tags/simple`, {
2155
  method: 'POST',
2156
  credentials: 'include'
2157
  });
2158
  const data = await response.json();
2159
 
2160
  if (response.ok) {
2161
+ console.log('[์ผ๋ฐ˜ ํƒœ๊ทธ ์ƒ์„ฑ] ์„ฑ๊ณต ์‘๋‹ต:', data);
2162
+ showAlert('์ผ๋ฐ˜ ํƒœ๊ทธ ์ƒ์„ฑ์ด ์™„๋ฃŒ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.', 'success');
2163
+ loadFiles(); // ํŒŒ์ผ ๋ชฉ๋ก ์ƒˆ๋กœ๊ณ ์นจ
2164
+ } else {
2165
+ console.error('[์ผ๋ฐ˜ ํƒœ๊ทธ ์ƒ์„ฑ] ์‹คํŒจ ์‘๋‹ต:', data);
2166
+ showAlert(`์ผ๋ฐ˜ ํƒœ๊ทธ ์ƒ์„ฑ ์‹คํŒจ: ${data.error || '์•Œ ์ˆ˜ ์—†๋Š” ์˜ค๋ฅ˜'}`, 'error');
2167
+ }
2168
+ } catch (error) {
2169
+ showAlert(`์ผ๋ฐ˜ ํƒœ๊ทธ ์ƒ์„ฑ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค: ${error.message}`, 'error');
2170
+ console.error('์ผ๋ฐ˜ ํƒœ๊ทธ ์ƒ์„ฑ ์˜ค๋ฅ˜:', error);
2171
+ } finally {
2172
+ button.disabled = false;
2173
+ button.textContent = originalText;
2174
+ }
2175
+ }
2176
+
2177
+ // ์ƒ์„ธ ํƒœ๊ทธ ์ƒ์„ฑ
2178
+ async function createDetailedTags(fileId, fileName) {
2179
+ if (!confirm(`"${fileName}" ํŒŒ์ผ์˜ ์ƒ์„ธ ํƒœ๊ทธ๋ฅผ ์ƒ์„ฑํ•˜์‹œ๊ฒ ์Šต๋‹ˆ๊นŒ?\n\n์ด ์ž‘์—…์€ AI ๋ชจ๋ธ์„ ์‚ฌ์šฉํ•˜์—ฌ Parent Chunk์™€ ํšŒ์ฐจ๋ณ„ ๋ถ„์„ ๋ฐ์ดํ„ฐ๋ฅผ ์ข…ํ•ฉํ•˜์—ฌ ์ƒ์„ธํ•œ ํƒœ๊ทธ๋ฅผ ์ƒ์„ฑํ•ฉ๋‹ˆ๋‹ค.\n์‹œ๊ฐ„์ด ์˜ค๋ž˜ ๊ฑธ๋ฆด ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.`)) {
2180
+ return;
2181
+ }
2182
+
2183
+ const button = event.target;
2184
+ const originalText = button.textContent;
2185
+ button.disabled = true;
2186
+ button.textContent = '์ƒ์„ฑ ์ค‘...';
2187
+
2188
+ try {
2189
+ const response = await fetch(`/api/files/${fileId}/process/tags/detailed`, {
2190
+ method: 'POST',
2191
+ credentials: 'include'
2192
+ });
2193
+ const data = await response.json();
2194
+
2195
+ if (response.ok) {
2196
+ console.log('[์ƒ์„ธ ํƒœ๊ทธ ์ƒ์„ฑ] ์„ฑ๊ณต ์‘๋‹ต:', data);
2197
+ showAlert('์ƒ์„ธ ํƒœ๊ทธ ์ƒ์„ฑ์ด ์™„๋ฃŒ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.', 'success');
2198
  loadFiles(); // ํŒŒ์ผ ๋ชฉ๋ก ์ƒˆ๋กœ๊ณ ์นจ
2199
  } else {
2200
+ console.error('[์ƒ์„ธ ํƒœ๊ทธ ์ƒ์„ฑ] ์‹คํŒจ ์‘๋‹ต:', data);
2201
+ showAlert(`์ƒ์„ธ ํƒœ๊ทธ ์ƒ์„ฑ ์‹คํŒจ: ${data.error || '์•Œ ์ˆ˜ ์—†๋Š” ์˜ค๋ฅ˜'}`, 'error');
2202
  }
2203
  } catch (error) {
2204
+ showAlert(`์ƒ์„ธ ํƒœ๊ทธ ์ƒ์„ฑ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค: ${error.message}`, 'error');
2205
+ console.error('์ƒ์„ธ ํƒœ๊ทธ ์ƒ์„ฑ ์˜ค๋ฅ˜:', error);
2206
  } finally {
2207
  button.disabled = false;
2208
  button.textContent = originalText;