GitHub Actions commited on
Commit
a587664
ยท
1 Parent(s): 9851df5

Auto-deploy from GitHub Actions - 2025-12-12 09:03:43

Browse files
app/__init__.py CHANGED
@@ -234,7 +234,7 @@ def migrate_database(app: Flask) -> None:
234
  # Boolean ํƒ€์ž…์€ DB์— ๋”ฐ๋ผ ๋‹ค๋ฅผ ์ˆ˜ ์žˆ์œผ๋ฏ€๋กœ ์ฃผ์˜ (PostgreSQL/SQLite ๋ชจ๋‘ BOOLEAN ์ง€์›)
235
  conn.execute(text("ALTER TABLE \"user\" ADD COLUMN must_change_password BOOLEAN DEFAULT FALSE NOT NULL"))
236
 
237
- # 2. uploaded_file ํ…Œ์ด๋ธ” - uploaded_by, parent_file_id ์ปฌ๋Ÿผ
238
  if inspector.has_table('uploaded_file'):
239
  columns = [c['name'] for c in inspector.get_columns('uploaded_file')]
240
  with db.engine.begin() as conn:
@@ -245,6 +245,10 @@ def migrate_database(app: Flask) -> None:
245
  if 'parent_file_id' not in columns:
246
  logger.info("uploaded_file ํ…Œ์ด๋ธ”์— parent_file_id ์ปฌ๋Ÿผ ์ถ”๊ฐ€ ์ค‘...")
247
  conn.execute(text("ALTER TABLE uploaded_file ADD COLUMN parent_file_id INTEGER"))
 
 
 
 
248
 
249
  # 3. document_chunk ํ…Œ์ด๋ธ” - chunk_metadata ์ปฌ๋Ÿผ
250
  if inspector.has_table('document_chunk'):
 
234
  # Boolean ํƒ€์ž…์€ DB์— ๋”ฐ๋ผ ๋‹ค๋ฅผ ์ˆ˜ ์žˆ์œผ๋ฏ€๋กœ ์ฃผ์˜ (PostgreSQL/SQLite ๋ชจ๋‘ BOOLEAN ์ง€์›)
235
  conn.execute(text("ALTER TABLE \"user\" ADD COLUMN must_change_password BOOLEAN DEFAULT FALSE NOT NULL"))
236
 
237
+ # 2. uploaded_file ํ…Œ์ด๋ธ” - uploaded_by, parent_file_id, tags ์ปฌ๋Ÿผ
238
  if inspector.has_table('uploaded_file'):
239
  columns = [c['name'] for c in inspector.get_columns('uploaded_file')]
240
  with db.engine.begin() as conn:
 
245
  if 'parent_file_id' not in columns:
246
  logger.info("uploaded_file ํ…Œ์ด๋ธ”์— parent_file_id ์ปฌ๋Ÿผ ์ถ”๊ฐ€ ์ค‘...")
247
  conn.execute(text("ALTER TABLE uploaded_file ADD COLUMN parent_file_id INTEGER"))
248
+
249
+ if 'tags' not in columns:
250
+ logger.info("uploaded_file ํ…Œ์ด๋ธ”์— tags ์ปฌ๋Ÿผ ์ถ”๊ฐ€ ์ค‘...")
251
+ conn.execute(text("ALTER TABLE uploaded_file ADD COLUMN tags TEXT"))
252
 
253
  # 3. document_chunk ํ…Œ์ด๋ธ” - chunk_metadata ์ปฌ๋Ÿผ
254
  if inspector.has_table('document_chunk'):
app/database.py CHANGED
@@ -47,6 +47,7 @@ class UploadedFile(db.Model):
47
  uploaded_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
48
  uploaded_by = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=True)
49
  parent_file_id = db.Column(db.Integer, db.ForeignKey('uploaded_file.id'), nullable=True) # ์ด์–ด์„œ ์—…๋กœ๋“œํ•œ ๊ฒฝ์šฐ ์›๋ณธ ํŒŒ์ผ ID
 
50
 
51
  # ๊ด€๊ณ„
52
  parent_file = db.relationship('UploadedFile', remote_side=[id], backref='child_files')
@@ -65,6 +66,7 @@ class UploadedFile(db.Model):
65
  'uploaded_at': self.uploaded_at.isoformat() if self.uploaded_at else None,
66
  'uploaded_by': self.uploaded_by,
67
  'parent_file_id': self.parent_file_id,
 
68
  'chunk_count': chunk_count,
69
  'child_count': len(self.child_files) if self.child_files else 0
70
  }
 
47
  uploaded_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
48
  uploaded_by = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=True)
49
  parent_file_id = db.Column(db.Integer, db.ForeignKey('uploaded_file.id'), nullable=True) # ์ด์–ด์„œ ์—…๋กœ๋“œํ•œ ๊ฒฝ์šฐ ์›๋ณธ ํŒŒ์ผ ID
50
+ tags = db.Column(db.Text, nullable=True) # AI๊ฐ€ ์ƒ์„ฑํ•œ ํƒœ๊ทธ (JSON ๋ฌธ์ž์—ด ๋˜๋Š” ์‰ผํ‘œ ๊ตฌ๋ถ„)
51
 
52
  # ๊ด€๊ณ„
53
  parent_file = db.relationship('UploadedFile', remote_side=[id], backref='child_files')
 
66
  'uploaded_at': self.uploaded_at.isoformat() if self.uploaded_at else None,
67
  'uploaded_by': self.uploaded_by,
68
  'parent_file_id': self.parent_file_id,
69
+ 'tags': self.tags,
70
  'chunk_count': chunk_count,
71
  'child_count': len(self.child_files) if self.child_files else 0
72
  }
app/routes.py CHANGED
@@ -4943,6 +4943,169 @@ 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>/metadata', methods=['POST'])
4947
  @login_required
4948
  def create_file_metadata(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
+
4953
+ # ๊ถŒํ•œ ํ™•์ธ
4954
+ if not current_user.is_admin and file.uploaded_by != current_user.id:
4955
+ return jsonify({'error': '๊ถŒํ•œ์ด ์—†์Šต๋‹ˆ๋‹ค.'}), 403
4956
+
4957
+ # ๋ชจ๋ธ ํ™•์ธ
4958
+ if not file.model_name:
4959
+ return jsonify({'error': 'ํŒŒ์ผ์— ์—ฐ๊ฒฐ๋œ AI ๋ชจ๋ธ์ด ์—†์Šต๋‹ˆ๋‹ค.'}), 400
4960
+
4961
+ # ๋ฐ์ดํ„ฐ ์ˆ˜์ง‘
4962
+ # 1. Parent Chunk
4963
+ parent_chunk = ParentChunk.query.filter_by(file_id=file_id).first()
4964
+ parent_chunk_text = ""
4965
+ if parent_chunk:
4966
+ parent_chunk_text = f"""
4967
+ [์ž‘ํ’ˆ ์ „์ฒด ์ •๋ณด]
4968
+ ์„ธ๊ณ„๊ด€: {parent_chunk.world_view or '์—†์Œ'}
4969
+ ์ฃผ์š” ์บ๋ฆญํ„ฐ: {parent_chunk.characters or '์—†์Œ'}
4970
+ ์ฃผ์š” ์Šคํ† ๋ฆฌ: {parent_chunk.story or '์—†์Œ'}
4971
+ """
4972
+
4973
+ # 2. ํšŒ์ฐจ ๋ถ„์„ (์š”์•ฝ)
4974
+ analyses = EpisodeAnalysis.query.filter_by(file_id=file_id).order_by(EpisodeAnalysis.id).limit(10).all()
4975
+ analysis_text = ""
4976
+ if analyses:
4977
+ analysis_text = "[์ฃผ์š” ํšŒ์ฐจ ๋ถ„์„ ๋‚ด์šฉ]\n"
4978
+ for analysis in analyses:
4979
+ # ๋‚ด์šฉ์ด ๋„ˆ๋ฌด ๊ธธ๋ฉด ์•ž๋ถ€๋ถ„๋งŒ ์‚ฌ์šฉ
4980
+ content_preview = analysis.analysis_content[:500] + "..." if len(analysis.analysis_content) > 500 else analysis.analysis_content
4981
+ analysis_text += f"- {analysis.episode_title}: {content_preview}\n"
4982
+
4983
+ # 3. GraphRAG (์ฃผ์š” ์ธ๋ฌผ, ํ‚ค์›Œ๋“œ)
4984
+ entities = GraphEntity.query.filter_by(file_id=file_id).limit(20).all()
4985
+ entity_text = ""
4986
+ if entities:
4987
+ entity_names = [e.entity_name for e in entities]
4988
+ entity_text = f"[์ฃผ์š” ๋“ฑ์žฅ ์š”์†Œ]\n{', '.join(entity_names)}"
4989
+
4990
+ # ๋ฐ์ดํ„ฐ๊ฐ€ ๋„ˆ๋ฌด ์—†์œผ๋ฉด ํƒœ๊ทธ ์ƒ์„ฑ ๋ถˆ๊ฐ€
4991
+ if not parent_chunk_text and not analysis_text and not entity_text:
4992
+ return jsonify({'error': 'ํƒœ๊ทธ๋ฅผ ์ƒ์„ฑํ•  ์ถฉ๋ถ„ํ•œ ๋ฐ์ดํ„ฐ(Parent Chunk, ํšŒ์ฐจ ๋ถ„์„ ๋“ฑ)๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค.'}), 400
4993
+
4994
+ # ํ”„๋กฌํ”„ํŠธ ๊ตฌ์„ฑ
4995
+ prompt = f"""
4996
+ ๋‹ค์Œ์€ ์›น์†Œ์„ค์˜ ๋ถ„์„ ๋ฐ์ดํ„ฐ์ž…๋‹ˆ๋‹ค. ์ด ๋ฐ์ดํ„ฐ๋ฅผ ๋ฐ”ํƒ•์œผ๋กœ ์ด ์ž‘ํ’ˆ์„ ์ž˜ ์„ค๋ช…ํ•˜๋Š” ํƒœ๊ทธ 10๊ฐœ~20๊ฐœ๋ฅผ ์ƒ์„ฑํ•ด์ฃผ์„ธ์š”.
4997
+
4998
+ {parent_chunk_text}
4999
+
5000
+ {analysis_text}
5001
+
5002
+ {entity_text}
5003
+
5004
+ ์š”๊ตฌ์‚ฌํ•ญ:
5005
+ 1. ์žฅ๋ฅด, ๋ถ„์œ„๊ธฐ, ์ฃผ์š” ์†Œ์žฌ, ์บ๋ฆญํ„ฐ ํŠน์„ฑ ๋“ฑ์„ ํฌํ•จํ•˜๋Š” ํƒœ๊ทธ๋ฅผ ์ƒ์„ฑํ•˜์„ธ์š”.
5006
+ 2. ๋ฐ˜๋“œ์‹œ JSON ๋ฐฐ์—ด ํ˜•์‹์œผ๋กœ๋งŒ ์‘๋‹ตํ•˜์„ธ์š”. (์˜ˆ: ["ํŒํƒ€์ง€", "๋กœ๋งจ์Šค", "์„ฑ์žฅ๋ฌผ", "๋งˆ๋ฒ•"])
5007
+ 3. ๋‹ค๋ฅธ ์„ค๋ช… ์—†์ด JSON ๋ฐฐ์—ด๋งŒ ์ถœ๋ ฅํ•˜์„ธ์š”.
5008
+ """
5009
+
5010
+ # AI ํ˜ธ์ถœ
5011
+ print(f"[ํƒœ๊ทธ ์ƒ์„ฑ] '{file.original_filename}' ํƒœ๊ทธ ์ƒ์„ฑ ์š”์ฒญ (๋ชจ๋ธ: {file.model_name})")
5012
+
5013
+ response_text = None
5014
+
5015
+ # Gemini ๋ชจ๋ธ์ธ ๊ฒฝ์šฐ
5016
+ if 'gemini' in file.model_name.lower():
5017
+ try:
5018
+ gemini_model_name = file.model_name
5019
+ if gemini_model_name.lower().startswith('gemini:'):
5020
+ gemini_model_name = gemini_model_name.split(':', 1)[1].strip()
5021
+
5022
+ gemini_client = get_gemini_client()
5023
+ if gemini_client.is_configured():
5024
+ result = gemini_client.generate_response(
5025
+ prompt=prompt,
5026
+ model_name=gemini_model_name,
5027
+ temperature=0.7,
5028
+ max_output_tokens=1024
5029
+ )
5030
+ if not result['error'] and result.get('response'):
5031
+ response_text = result['response'].strip()
5032
+ except Exception as e:
5033
+ print(f"[ํƒœ๊ทธ ์ƒ์„ฑ] Gemini ์˜ค๋ฅ˜: {str(e)}")
5034
+
5035
+ # Ollama ๋ชจ๋ธ์ธ ๊ฒฝ์šฐ (๋˜๋Š” Gemini ์‹คํŒจ ์‹œ)
5036
+ if not response_text:
5037
+ try:
5038
+ ollama_model_name = file.model_name
5039
+ if ollama_model_name.lower().startswith('gemini:'): # Gemini ๋ชจ๋ธ๋ช…์ด์ง€๋งŒ Ollama๋กœ ์‹œ๋„ํ•˜๋Š” ๊ฒฝ์šฐ (์„ค์ • ์˜ค๋ฅ˜ ๋“ฑ)
5040
+ # ๊ธฐ๋ณธ Ollama ๋ชจ๋ธ ์‚ฌ์šฉํ•˜๊ฑฐ๋‚˜ ์—๋Ÿฌ ์ฒ˜๋ฆฌ
5041
+ pass
5042
+
5043
+ # Ollama ํ˜ธ์ถœ ๋กœ์ง (generate_response_with_ollama ์‚ฌ์šฉ)
5044
+ # ์—ฌ๊ธฐ์„œ๋Š” ์ง์ ‘ ํ˜ธ์ถœ ๋Œ€์‹  ๊ธฐ์กด ํ•จ์ˆ˜ ํ™œ์šฉ
5045
+ # app/routes.py์— ollama ๊ด€๋ จ ํ•จ์ˆ˜๊ฐ€ ์žˆ๋Š”์ง€ ํ™•์ธ ํ•„์š”ํ•˜์ง€๋งŒ,
5046
+ # ๊ฐ„๋‹จํ•˜๊ฒŒ requests๋กœ ํ˜ธ์ถœ ๊ตฌํ˜„
5047
+ import requests
5048
+ from app.core.config import get_config
5049
+ config = get_config()
5050
+
5051
+ ollama_response = requests.post(
5052
+ f"{config.OLLAMA_API_URL}/api/generate",
5053
+ json={
5054
+ 'model': file.model_name,
5055
+ 'prompt': prompt,
5056
+ 'stream': False,
5057
+ 'options': {'temperature': 0.7}
5058
+ },
5059
+ timeout=120
5060
+ )
5061
+
5062
+ if ollama_response.status_code == 200:
5063
+ response_data = ollama_response.json()
5064
+ response_text = response_data.get('response', '').strip()
5065
+ except Exception as e:
5066
+ print(f"[ํƒœ๊ทธ ์ƒ์„ฑ] Ollama ์˜ค๋ฅ˜: {str(e)}")
5067
+
5068
+ if not response_text:
5069
+ return jsonify({'error': 'AI ์‘๋‹ต์„ ๋ฐ›์ง€ ๋ชปํ–ˆ์Šต๋‹ˆ๋‹ค.'}), 500
5070
+
5071
+ # JSON ํŒŒ์‹ฑ
5072
+ import json
5073
+ import re
5074
+
5075
+ tags = []
5076
+ try:
5077
+ # JSON ์ถ”์ถœ ์‹œ๋„
5078
+ json_match = re.search(r'\[.*\]', response_text, re.DOTALL)
5079
+ if json_match:
5080
+ tags = json.loads(json_match.group(0))
5081
+ else:
5082
+ # JSON ํ˜•์‹์ด ์•„๋‹ˆ๋ฉด ์‰ผํ‘œ๋กœ ๊ตฌ๋ถ„ํ•˜์—ฌ ์ฒ˜๋ฆฌ ์‹œ๋„
5083
+ tags = [t.strip() for t in response_text.replace('[', '').replace(']', '').replace('"', '').split(',')]
5084
+ except Exception as e:
5085
+ print(f"[ํƒœ๊ทธ ์ƒ์„ฑ] ํŒŒ์‹ฑ ์˜ค๋ฅ˜: {str(e)}")
5086
+ # ์‰ผํ‘œ๋กœ ๋‹จ์ˆœ ๋ถ„๋ฆฌ ์‹œ๋„
5087
+ tags = [t.strip() for t in response_text.split(',')]
5088
+
5089
+ # ๋นˆ ํƒœ๊ทธ ์ œ๊ฑฐ ๋ฐ ๋ฌธ์ž์—ด๋กœ ๋ณ€ํ™˜ํ•˜์—ฌ ์ €์žฅ
5090
+ tags = [t for t in tags if t]
5091
+
5092
+ if not tags:
5093
+ return jsonify({'error': 'ํƒœ๊ทธ๋ฅผ ์ถ”์ถœํ•˜์ง€ ๋ชปํ–ˆ์Šต๋‹ˆ๋‹ค.'}), 500
5094
+
5095
+ # DB ์ €์žฅ (JSON ๋ฌธ์ž์—ด๋กœ ์ €์žฅ)
5096
+ file.tags = json.dumps(tags, ensure_ascii=False)
5097
+ db.session.commit()
5098
+
5099
+ return jsonify({
5100
+ 'message': 'ํƒœ๊ทธ๊ฐ€ ์ƒ์„ฑ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.',
5101
+ 'tags': tags,
5102
+ 'file_id': file.id
5103
+ }), 200
5104
+
5105
+ except Exception as e:
5106
+ db.session.rollback()
5107
+ return jsonify({'error': f'ํƒœ๊ทธ ์ƒ์„ฑ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค: {str(e)}'}), 500
5108
+
5109
  @main_bp.route('/api/files/<int:file_id>/metadata', methods=['POST'])
5110
  @login_required
5111
  def create_file_metadata(file_id):
templates/admin_webnovels.html CHANGED
@@ -992,8 +992,28 @@
992
  const toggleButtonText = isPublic ? '๋น„๊ณต๊ฐœ๋กœ' : '๊ณต๊ฐœ๋กœ';
993
  const toggleButtonClass = isPublic ? 'btn-warning' : 'btn-success';
994
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
995
  row.innerHTML = `
996
- <td>${fileNameDisplay}</td>
 
 
 
997
  <td>${modelName}</td>
998
  <td class="file-size">${fileSize}</td>
999
  <td>${uploadDate}</td>
@@ -1004,12 +1024,13 @@
1004
  </button>
1005
  </td>
1006
  <td>
1007
- <div class="file-actions">
1008
  ${(file.has_parent_chunk !== undefined && file.has_parent_chunk) ?
1009
  `<button class="btn btn-primary" onclick="viewParentChunk(${file.id}, '${escapeHtml(file.original_filename)}')" style="padding: 4px 8px; font-size: 12px; margin-right: 4px;">Parent Chunk</button>` :
1010
  `<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>`
1011
  }
1012
  <button class="btn btn-info" onclick="createMetadata(${file.id}, '${escapeHtml(file.original_filename)}')" style="padding: 4px 8px; font-size: 12px; margin-right: 4px;">๋ฉ”ํƒ€๋ฐ์ดํ„ฐ ์ƒ์„ฑ</button>
 
1013
  <button class="btn btn-primary" onclick="continueUpload(${file.id})" style="padding: 4px 8px; font-size: 12px; margin-right: 4px;">์ด์–ด์„œ ์—…๋กœ๋“œ</button>
1014
  <button class="btn btn-secondary" onclick="deleteFile(${file.id})" style="padding: 4px 8px; font-size: 12px;">์‚ญ์ œ</button>
1015
  </div>
@@ -1896,6 +1917,43 @@
1896
  }
1897
  }
1898
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1899
  // ๋ชจ๋‹ฌ ์™ธ๋ถ€ ํด๋ฆญ ์‹œ ๋‹ซ๊ธฐ
1900
  window.addEventListener('click', (event) => {
1901
  const modal = document.getElementById('parentChunkModal');
 
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
+ if (tags && tags.length > 0) {
1001
+ tagsHtml = '<div style="margin-top: 6px; display: flex; flex-wrap: wrap; gap: 4px;">';
1002
+ tags.forEach(tag => {
1003
+ tagsHtml += `<span style="background: #e8f0fe; color: #1a73e8; padding: 2px 6px; border-radius: 4px; font-size: 11px; border: 1px solid #d2e3fc;">#${escapeHtml(tag)}</span>`;
1004
+ });
1005
+ tagsHtml += '</div>';
1006
+ }
1007
+ } catch (e) {
1008
+ console.error('ํƒœ๊ทธ ํŒŒ์‹ฑ ์˜ค๋ฅ˜:', e);
1009
+ }
1010
+ }
1011
+
1012
  row.innerHTML = `
1013
+ <td>
1014
+ <div>${fileNameDisplay}</div>
1015
+ ${tagsHtml}
1016
+ </td>
1017
  <td>${modelName}</td>
1018
  <td class="file-size">${fileSize}</td>
1019
  <td>${uploadDate}</td>
 
1024
  </button>
1025
  </td>
1026
  <td>
1027
+ <div class="file-actions" style="flex-wrap: wrap; row-gap: 4px;">
1028
  ${(file.has_parent_chunk !== undefined && file.has_parent_chunk) ?
1029
  `<button class="btn btn-primary" onclick="viewParentChunk(${file.id}, '${escapeHtml(file.original_filename)}')" style="padding: 4px 8px; font-size: 12px; margin-right: 4px;">Parent Chunk</button>` :
1030
  `<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>`
1031
  }
1032
  <button class="btn btn-info" onclick="createMetadata(${file.id}, '${escapeHtml(file.original_filename)}')" style="padding: 4px 8px; font-size: 12px; margin-right: 4px;">๋ฉ”ํƒ€๋ฐ์ดํ„ฐ ์ƒ์„ฑ</button>
1033
+ <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>
1034
  <button class="btn btn-primary" onclick="continueUpload(${file.id})" style="padding: 4px 8px; font-size: 12px; margin-right: 4px;">์ด์–ด์„œ ์—…๋กœ๋“œ</button>
1035
  <button class="btn btn-secondary" onclick="deleteFile(${file.id})" style="padding: 4px 8px; font-size: 12px;">์‚ญ์ œ</button>
1036
  </div>
 
1917
  }
1918
  }
1919
 
1920
+ // ํƒœ๊ทธ ์ƒ์„ฑ
1921
+ async function createTags(fileId, fileName) {
1922
+ if (!confirm(`"${fileName}" ํŒŒ์ผ์˜ ํƒœ๊ทธ๋ฅผ ์ƒ์„ฑํ•˜์‹œ๊ฒ ์Šต๋‹ˆ๊นŒ?\n\n์ด ์ž‘์—…์€ AI ๋ชจ๋ธ์„ ์‚ฌ์šฉํ•˜์—ฌ Parent Chunk์™€ ํšŒ์ฐจ๋ณ„ ๋ถ„์„ ๋ฐ์ดํ„ฐ๋ฅผ ์ข…ํ•ฉํ•˜์—ฌ ๋ถ„์„ํ•ฉ๋‹ˆ๋‹ค.`)) {
1923
+ return;
1924
+ }
1925
+
1926
+ const button = event.target;
1927
+ const originalText = button.textContent;
1928
+ button.disabled = true;
1929
+ button.textContent = '์ƒ์„ฑ ์ค‘...';
1930
+
1931
+ try {
1932
+ const response = await fetch(`/api/files/${fileId}/process/tags`, {
1933
+ method: 'POST',
1934
+ credentials: 'include'
1935
+ });
1936
+ const data = await response.json();
1937
+
1938
+ if (response.ok) {
1939
+ let message = 'ํƒœ๊ทธ ์ƒ์„ฑ์ด ์™„๋ฃŒ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.';
1940
+ if (data.tags && data.tags.length > 0) {
1941
+ message += `\n์ƒ์„ฑ๋œ ํƒœ๊ทธ: ${data.tags.join(', ')}`;
1942
+ }
1943
+ showAlert(message, 'success');
1944
+ loadFiles(); // ํŒŒ์ผ ๋ชฉ๋ก ์ƒˆ๋กœ๊ณ ์นจ
1945
+ } else {
1946
+ showAlert(`ํƒœ๊ทธ ์ƒ์„ฑ ์‹คํŒจ: ${data.error || '์•Œ ์ˆ˜ ์—†๋Š” ์˜ค๋ฅ˜'}`, 'error');
1947
+ }
1948
+ } catch (error) {
1949
+ showAlert(`ํƒœ๊ทธ ์ƒ์„ฑ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค: ${error.message}`, 'error');
1950
+ console.error('ํƒœ๊ทธ ์ƒ์„ฑ ์˜ค๋ฅ˜:', error);
1951
+ } finally {
1952
+ button.disabled = false;
1953
+ button.textContent = originalText;
1954
+ }
1955
+ }
1956
+
1957
  // ๋ชจ๋‹ฌ ์™ธ๋ถ€ ํด๋ฆญ ์‹œ ๋‹ซ๊ธฐ
1958
  window.addEventListener('click', (event) => {
1959
  const modal = document.getElementById('parentChunkModal');