SOY NV AI commited on
Commit
479b257
·
1 Parent(s): c4ab5fa

feat: 프롬프트 관리 기능 추가 및 메타데이터 생성 방식 개선

Browse files

- 프롬프트 관리 페이지 추가 (/admin/prompts)
- 질문 시 자동 프롬프트 적용 (사용자에게는 보이지 않음)
- 프롬프트 API 엔드포인트 추가 (GET, POST)
- 채팅 API에서 시스템 프롬프트 자동 적용
- 메타데이터 생성 로직 개선:
- 파일 업로드 시 메타데이터 자동 생성 제거
- 메타데이터는 '메타데이터 생성' 버튼으로 수동 생성
- 인물 관계(character_relationships) 필드 추가
- 원본 웹소설 전체 내용 참조하여 인물 관계 파악
- 관리 페이지에 프롬프트 관리 링크 추가

app/routes.py CHANGED
@@ -212,22 +212,49 @@ def extract_chapter_number(text):
212
 
213
  return None
214
 
215
- def extract_metadata_with_ai(chunk_content, parent_chunk=None, model_name=None):
216
- """AI를 사용하여 청크의 메타데이터 추출 (화자, 등장인물, 시간적 배경)"""
 
 
 
 
 
 
 
217
  try:
218
- # 간단한 추출을 위해 짧은 프롬프트 사용
219
- prompt = f"""다음 웹소설 텍스트를 분석하여 아래 정보를 JSON 형식으로만 응답하세요:
 
 
 
 
 
 
 
 
 
 
 
 
220
 
221
- 텍스트:
222
  {chunk_content[:2000]}
223
 
224
  다음 형식으로만 응답하세요 (JSON 형식):
225
  {{
226
  "pov": "화자/시점을 설명하세요 (예: 1인칭 주인공, 3인칭 전지적 작가 등)",
227
  "characters": ["등장인물1", "등장인물2"],
228
- "time_background": "시간적 배경 설명 (예: 과거 회상, 현재 시점, 미래 등)"
 
 
 
 
 
 
 
229
  }}
230
 
 
231
  응답은 오직 JSON 형식만 사용하고, 다른 설명은 포함하지 마세요."""
232
 
233
  # 모델명이 없으면 기본값 사용 (Gemini 우선 시도)
@@ -306,23 +333,34 @@ def extract_metadata_with_ai(chunk_content, parent_chunk=None, model_name=None):
306
  return {
307
  "pov": None,
308
  "characters": [],
309
- "time_background": None
 
310
  }
311
  except Exception as e:
312
  print(f"[메타데이터 추출] 오류: {str(e)}")
313
  return {
314
  "pov": None,
315
  "characters": [],
316
- "time_background": None
 
317
  }
318
 
319
  def extract_chunk_metadata(chunk_content, full_content=None, chunk_index=None, file_id=None, model_name=None):
320
- """청크의 메타데이터 추출 (챕터, 화자, 등장인물, 시간적 배경)"""
 
 
 
 
 
 
 
 
321
  metadata = {
322
  "chapter": None,
323
  "pov": None,
324
  "characters": [],
325
- "time_background": None
 
326
  }
327
 
328
  # 1. 챕터 번호 추출 (정규식 기반)
@@ -338,7 +376,7 @@ def extract_chunk_metadata(chunk_content, full_content=None, chunk_index=None, f
338
  if chapter_num:
339
  metadata["chapter"] = chapter_num
340
 
341
- # 2. AI를 사용한 메타데이터 추출 (화자, 등장인물, 시간적 배경)
342
  # Parent Chunk가 있으면 참조
343
  parent_chunk = None
344
  if file_id:
@@ -347,27 +385,26 @@ def extract_chunk_metadata(chunk_content, full_content=None, chunk_index=None, f
347
  except:
348
  pass
349
 
350
- ai_metadata = extract_metadata_with_ai(chunk_content, parent_chunk, model_name)
 
351
  if ai_metadata:
352
  metadata["pov"] = ai_metadata.get("pov")
353
  metadata["characters"] = ai_metadata.get("characters", [])
354
  metadata["time_background"] = ai_metadata.get("time_background")
 
355
 
356
  return metadata
357
 
358
- def create_chunks_for_file(file_id, content, extract_metadata=True):
359
- """파일 내용을 의미 기반 청크로 분할하여 저장 (벡터 DB 포함 + 메타데이터 태그)
360
 
361
  Args:
362
  file_id: 파일 ID
363
  content: 파일 내용
364
- extract_metadata: 메타데이터 추출 여부 (기본값: True)
365
  """
366
  try:
367
  print(f"[청크 생성] 파일 ID {file_id}에 대한 청크 생성 시작")
368
  print(f"[청크 생성] 원본 텍스트 길이: {len(content)}자")
369
- print(f"[청크 생성] 메타데이터 추출: 비활성화됨 (주석 처리)")
370
- # print(f"[청크 생성] 메타데이터 추출: {'예' if extract_metadata else '아니오'}") # 주석 처리됨
371
 
372
  # 파일 정보 가져오기 (모델명 등)
373
  uploaded_file = UploadedFile.query.get(file_id)
@@ -398,41 +435,15 @@ def create_chunks_for_file(file_id, content, extract_metadata=True):
398
  # 각 청크를 데이터베이스와 벡터 DB에 저장
399
  saved_count = 0
400
  vector_saved_count = 0
401
- # metadata_extracted_count = 0 # 메타데이터 추출 비활성화로 주석 처리
402
-
403
- # 메타데이터 추출 기능 주석 처리됨
404
- # if extract_metadata:
405
- # print(f"[청크 생성] 메타데이터 추출 시작 (AI 사용: {model_name is not None})...")
406
- # else:
407
- # print(f"[청크 생성] 메타데이터 추출 건너뜀 (사용자 선택)")
408
 
409
  for idx, chunk_content in enumerate(chunks):
410
  try:
411
- # 메타데이터 추출 (옵션이 활성화된 경우에만) - 주석 처리됨
412
- metadata = None
413
- metadata_json = None
414
-
415
- # if extract_metadata:
416
- # metadata = extract_chunk_metadata(
417
- # chunk_content=chunk_content,
418
- # full_content=content,
419
- # chunk_index=idx,
420
- # file_id=file_id,
421
- # model_name=model_name
422
- # )
423
- #
424
- # # 메타데이터를 JSON 문자열로 변환
425
- # metadata_json = json.dumps(metadata, ensure_ascii=False) if metadata else None
426
- #
427
- # if metadata and (metadata.get("chapter") or metadata.get("pov") or metadata.get("characters") or metadata.get("time_background")):
428
- # metadata_extracted_count += 1
429
-
430
- # DB에 청크 저장 (메타데이터 없이)
431
  chunk = DocumentChunk(
432
  file_id=file_id,
433
  chunk_index=idx,
434
  content=chunk_content,
435
- chunk_metadata=None # 메타데이터 추출 비활성화
436
  )
437
  db.session.add(chunk)
438
  db.session.flush() # ID 생성
@@ -965,6 +976,12 @@ def admin_webnovels():
965
  """웹소설 관리 페이지"""
966
  return render_template('admin_webnovels.html')
967
 
 
 
 
 
 
 
968
  @main_bp.route('/api/admin/users', methods=['GET'])
969
  @admin_required
970
  def get_users():
@@ -1270,6 +1287,38 @@ def get_ollama_models():
1270
  except Exception as e:
1271
  return jsonify({'error': f'모델 목록을 가져오는 중 오류가 발생했습니다: {str(e)}', 'models': []}), 500
1272
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1273
  @main_bp.route('/api/admin/ollama/models', methods=['GET'])
1274
  @admin_required
1275
  def get_all_ollama_models():
@@ -1564,8 +1613,24 @@ def chat():
1564
  질문:
1565
  """
1566
 
1567
- # 프롬프트 구성
1568
- full_prompt = context + message if context else message
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1569
 
1570
  # 모델 타입 확인 (Gemini 또는 Ollama)
1571
  is_gemini = model.startswith('gemini:')
@@ -1758,13 +1823,10 @@ def upload_file():
1758
  file = request.files['file']
1759
  model_name = request.form.get('model_name', '').strip()
1760
  parent_file_id = request.form.get('parent_file_id', None) # 이어서 업로드할 경우 원본 파일 ID
1761
- extract_metadata = request.form.get('extract_metadata', 'true').lower() == 'true' # 메타데이터 추출 여부 (기본값: true)
1762
 
1763
  log_print(f"[2/8] 파일 수신: {file.filename if file else 'None'}")
1764
  log_print(f"[2/8] 모델명: {model_name if model_name else 'None (비어있음)'}")
1765
  log_print(f"[2/8] 이어서 업로드: {parent_file_id if parent_file_id else '아니오'}")
1766
- log_print(f"[2/8] 메타데이터 추출: 비활성화됨 (주석 처리)")
1767
- # log_print(f"[2/8] 메타데이터 추출: {'예' if extract_metadata else '아니오'}") # 주석 처리됨
1768
 
1769
  if file.filename == '':
1770
  error_msg = '파일명이 없습니다.'
@@ -1926,9 +1988,8 @@ def upload_file():
1926
  log_print(f"[7/8] CP949 인코딩으로 파일 읽기 성공: {len(content)}자")
1927
 
1928
  # 청크 생성 및 저장
1929
- log_print(f"[7/8] 청크 생성 함수 호출 중... (메타데이터 추출: 비활성화됨)")
1930
- # log_print(f"[7/8] 청크 생성 함수 호출 중... (메타데이터 추출: {'예' if extract_metadata else '아니오'})") # 주석 처리됨
1931
- chunk_count = create_chunks_for_file(uploaded_file.id, content, extract_metadata=extract_metadata)
1932
 
1933
  if chunk_count > 0:
1934
  log_print(f"[7/8] ✅ 성공: 파일 {original_filename}을 {chunk_count}개의 청크로 분할했습니다.")
@@ -2212,6 +2273,97 @@ def create_file_parent_chunk(file_id):
2212
  except Exception as e:
2213
  return jsonify({'error': f'Parent Chunk 생성 중 오류가 발생했습니다: {str(e)}'}), 500
2214
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2215
  @main_bp.route('/api/files/<int:file_id>', methods=['DELETE'])
2216
  @login_required
2217
  def delete_file(file_id):
 
212
 
213
  return None
214
 
215
+ def extract_metadata_with_ai(chunk_content, full_content=None, parent_chunk=None, model_name=None):
216
+ """AI를 사용하여 청크의 메타데이터 추출 (화자, 등장인물, 시간적 배경, 인물 관계)
217
+
218
+ Args:
219
+ chunk_content: 분석할 청크 내용
220
+ full_content: 원본 웹소설 전체 내용 (인물 관계 파악용)
221
+ parent_chunk: Parent Chunk 객체 (선택사항)
222
+ model_name: 사용할 AI 모델명
223
+ """
224
  try:
225
+ # 원본 웹소설 전체 내용을 참조하여 인물 관계 파악
226
+ full_content_preview = ""
227
+ if full_content:
228
+ # 전체 내용이 너무 길면 앞부분과 뒷부분 일부만 사용 (최대 20000자)
229
+ if len(full_content) > 20000:
230
+ full_content_preview = full_content[:10000] + "\n... (중간 생략) ...\n" + full_content[-10000:]
231
+ else:
232
+ full_content_preview = full_content
233
+
234
+ # 프롬프트 생성
235
+ prompt = f"""다음 웹소설 텍스트를 분석하여 아래 정보를 JSON 형식으로만 응답하세요.
236
+
237
+ 원본 웹소설 전체 내용 (참고용):
238
+ {full_content_preview[:50000] if full_content_preview else "없음"}
239
 
240
+ 분석할 청크 텍스트:
241
  {chunk_content[:2000]}
242
 
243
  다음 형식으로만 응답하세요 (JSON 형식):
244
  {{
245
  "pov": "화자/시점을 설명하세요 (예: 1인칭 주인공, 3인칭 전지적 작가 등)",
246
  "characters": ["등장인물1", "등장인물2"],
247
+ "time_background": "시간적 배경 설명 (예: 과거 회상, 현재 시점, 미래 등)",
248
+ "character_relationships": [
249
+ {{
250
+ "character1": "인물1",
251
+ "character2": "인물2",
252
+ "relationship": "현재 시점에서의 관계 설명 (예: 연인, 적, 친구, 가족 등)"
253
+ }}
254
+ ]
255
  }}
256
 
257
+ character_relationships는 이 청크에 등장하는 인물들 간의 현재 관계를 원본 웹소설 전체 내용을 참고하여 파악한 것입니다.
258
  응답은 오직 JSON 형식만 사용하고, 다른 설명은 포함하지 마세요."""
259
 
260
  # 모델명이 없으면 기본값 사용 (Gemini 우선 시도)
 
333
  return {
334
  "pov": None,
335
  "characters": [],
336
+ "time_background": None,
337
+ "character_relationships": []
338
  }
339
  except Exception as e:
340
  print(f"[메타데이터 추출] 오류: {str(e)}")
341
  return {
342
  "pov": None,
343
  "characters": [],
344
+ "time_background": None,
345
+ "character_relationships": []
346
  }
347
 
348
  def extract_chunk_metadata(chunk_content, full_content=None, chunk_index=None, file_id=None, model_name=None):
349
+ """청크의 메타데이터 추출 (챕터, 화자, 등장인물, 시간적 배경, 인물 관계)
350
+
351
+ Args:
352
+ chunk_content: 분석할 청크 내용
353
+ full_content: 원본 웹소설 전체 내용 (인물 관계 파악용)
354
+ chunk_index: 청크 인덱스
355
+ file_id: 파일 ID
356
+ model_name: 사용할 AI 모델명
357
+ """
358
  metadata = {
359
  "chapter": None,
360
  "pov": None,
361
  "characters": [],
362
+ "time_background": None,
363
+ "character_relationships": []
364
  }
365
 
366
  # 1. 챕터 번호 추출 (정규식 기반)
 
376
  if chapter_num:
377
  metadata["chapter"] = chapter_num
378
 
379
+ # 2. AI를 사용한 메타데이터 추출 (화자, 등장인물, 시간적 배경, 인물 관계)
380
  # Parent Chunk가 있으면 참조
381
  parent_chunk = None
382
  if file_id:
 
385
  except:
386
  pass
387
 
388
+ # 원본 웹소설 전체 내용을 참조하여 메타데이터 추출
389
+ ai_metadata = extract_metadata_with_ai(chunk_content, full_content, parent_chunk, model_name)
390
  if ai_metadata:
391
  metadata["pov"] = ai_metadata.get("pov")
392
  metadata["characters"] = ai_metadata.get("characters", [])
393
  metadata["time_background"] = ai_metadata.get("time_background")
394
+ metadata["character_relationships"] = ai_metadata.get("character_relationships", [])
395
 
396
  return metadata
397
 
398
+ def create_chunks_for_file(file_id, content):
399
+ """파일 내용을 의미 기반 청크로 분할하여 저장 (벡터 DB 포함)
400
 
401
  Args:
402
  file_id: 파일 ID
403
  content: 파일 내용
 
404
  """
405
  try:
406
  print(f"[청크 생성] 파일 ID {file_id}에 대한 청크 생성 시작")
407
  print(f"[청크 생성] 원본 텍스트 길이: {len(content)}자")
 
 
408
 
409
  # 파일 정보 가져오기 (모델명 등)
410
  uploaded_file = UploadedFile.query.get(file_id)
 
435
  # 각 청크를 데이터베이스와 벡터 DB에 저장
436
  saved_count = 0
437
  vector_saved_count = 0
 
 
 
 
 
 
 
438
 
439
  for idx, chunk_content in enumerate(chunks):
440
  try:
441
+ # DB에 청크 저장 (메타데이터는 나중에 별도로 생성)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
442
  chunk = DocumentChunk(
443
  file_id=file_id,
444
  chunk_index=idx,
445
  content=chunk_content,
446
+ chunk_metadata=None # 메타데이터는 별도 API로 생성
447
  )
448
  db.session.add(chunk)
449
  db.session.flush() # ID 생성
 
976
  """웹소설 관리 페이지"""
977
  return render_template('admin_webnovels.html')
978
 
979
+ @main_bp.route('/admin/prompts')
980
+ @admin_required
981
+ def admin_prompts():
982
+ """프롬프트 관리 페이지"""
983
+ return render_template('admin_prompts.html')
984
+
985
  @main_bp.route('/api/admin/users', methods=['GET'])
986
  @admin_required
987
  def get_users():
 
1287
  except Exception as e:
1288
  return jsonify({'error': f'모델 목록을 가져오는 중 오류가 발생했습니다: {str(e)}', 'models': []}), 500
1289
 
1290
+ @main_bp.route('/api/admin/prompts', methods=['GET'])
1291
+ @admin_required
1292
+ def get_system_prompt():
1293
+ """시스템 프롬프트 가져오기"""
1294
+ try:
1295
+ prompt = SystemConfig.get_config('system_prompt', '')
1296
+ return jsonify({'prompt': prompt}), 200
1297
+ except Exception as e:
1298
+ return jsonify({'error': f'프롬프트를 가져오는 중 오류가 발생했습니다: {str(e)}'}), 500
1299
+
1300
+ @main_bp.route('/api/admin/prompts', methods=['POST'])
1301
+ @admin_required
1302
+ def save_system_prompt():
1303
+ """시스템 프롬프트 저장"""
1304
+ try:
1305
+ data = request.json
1306
+ prompt = data.get('prompt', '').strip()
1307
+
1308
+ SystemConfig.set_config(
1309
+ key='system_prompt',
1310
+ value=prompt,
1311
+ description='질문할 때 자동으로 붙이는 시스템 프롬프트'
1312
+ )
1313
+
1314
+ return jsonify({
1315
+ 'message': '프롬프트가 성공적으로 저장되었습��다.',
1316
+ 'prompt': prompt
1317
+ }), 200
1318
+ except Exception as e:
1319
+ db.session.rollback()
1320
+ return jsonify({'error': f'프롬프트 저장 중 오류가 발생했습니다: {str(e)}'}), 500
1321
+
1322
  @main_bp.route('/api/admin/ollama/models', methods=['GET'])
1323
  @admin_required
1324
  def get_all_ollama_models():
 
1613
  질문:
1614
  """
1615
 
1616
+ # 시스템 프롬프트 가져오기
1617
+ system_prompt = SystemConfig.get_config('system_prompt', '').strip()
1618
+
1619
+ # 프롬프트 구성 (시스템 프롬프트 + 컨텍스트 + 사용자 메시지)
1620
+ prompt_parts = []
1621
+
1622
+ if system_prompt:
1623
+ prompt_parts.append(system_prompt)
1624
+
1625
+ if context:
1626
+ prompt_parts.append(context)
1627
+
1628
+ prompt_parts.append(message)
1629
+
1630
+ full_prompt = "\n\n".join(prompt_parts)
1631
+
1632
+ if system_prompt:
1633
+ print(f"[프롬프트] 시스템 프롬프트 적용: {len(system_prompt)}자")
1634
 
1635
  # 모델 타입 확인 (Gemini 또는 Ollama)
1636
  is_gemini = model.startswith('gemini:')
 
1823
  file = request.files['file']
1824
  model_name = request.form.get('model_name', '').strip()
1825
  parent_file_id = request.form.get('parent_file_id', None) # 이어서 업로드할 경우 원본 파일 ID
 
1826
 
1827
  log_print(f"[2/8] 파일 수신: {file.filename if file else 'None'}")
1828
  log_print(f"[2/8] 모델명: {model_name if model_name else 'None (비어있음)'}")
1829
  log_print(f"[2/8] 이어서 업로드: {parent_file_id if parent_file_id else '아니오'}")
 
 
1830
 
1831
  if file.filename == '':
1832
  error_msg = '파일명이 없습니다.'
 
1988
  log_print(f"[7/8] CP949 인코딩으로 파일 읽기 성공: {len(content)}자")
1989
 
1990
  # 청크 생성 및 저장
1991
+ log_print(f"[7/8] 청크 생성 함수 호출 중...")
1992
+ chunk_count = create_chunks_for_file(uploaded_file.id, content)
 
1993
 
1994
  if chunk_count > 0:
1995
  log_print(f"[7/8] ✅ 성공: 파일 {original_filename}을 {chunk_count}개의 청크로 분할했습니다.")
 
2273
  except Exception as e:
2274
  return jsonify({'error': f'Parent Chunk 생성 중 오류가 발생했습니다: {str(e)}'}), 500
2275
 
2276
+ @main_bp.route('/api/files/<int:file_id>/metadata', methods=['POST'])
2277
+ @login_required
2278
+ def create_file_metadata(file_id):
2279
+ """파일의 모든 청크에 메타데이터 생성 (수동 생성)"""
2280
+ try:
2281
+ file = UploadedFile.query.get_or_404(file_id)
2282
+
2283
+ # 권한 확인
2284
+ if not current_user.is_admin and file.uploaded_by != current_user.id:
2285
+ return jsonify({'error': '권한이 없습니다.'}), 403
2286
+
2287
+ # 모델명 확인
2288
+ if not file.model_name:
2289
+ return jsonify({'error': '파일에 연결된 AI 모델이 없습니다. 메타데이터를 생성할 수 없습니다.'}), 400
2290
+
2291
+ # 텍스트 파일만 가능
2292
+ if not file.original_filename.lower().endswith(('.txt', '.md')):
2293
+ return jsonify({'error': '메타데이터는 텍스트 파일(.txt, .md)에만 생성할 수 있습니다.'}), 400
2294
+
2295
+ # 파일 내용 읽기
2296
+ encoding = 'utf-8'
2297
+ try:
2298
+ with open(file.file_path, 'r', encoding=encoding) as f:
2299
+ content = f.read()
2300
+ except UnicodeDecodeError:
2301
+ with open(file.file_path, 'r', encoding='cp949') as f:
2302
+ content = f.read()
2303
+
2304
+ # 모든 청크 가져오기
2305
+ chunks = DocumentChunk.query.filter_by(file_id=file_id).order_by(DocumentChunk.chunk_index).all()
2306
+
2307
+ if not chunks:
2308
+ return jsonify({'error': '청크가 없습니다. 먼저 파일을 업로드하세요.'}), 400
2309
+
2310
+ print(f"[메타데이터 생성] 파일 ID {file_id}에 대한 메타데이터 생성 시작")
2311
+ print(f"[메타데이터 생성] 모델명: {file.model_name}")
2312
+ print(f"[메타데이터 생성] 파일명: {file.original_filename}")
2313
+ print(f"[메타데이터 생성] 청크 개수: {len(chunks)}개")
2314
+
2315
+ # 각 청크에 메타데이터 생성
2316
+ success_count = 0
2317
+ fail_count = 0
2318
+
2319
+ for chunk in chunks:
2320
+ try:
2321
+ # 메타데이터 추출
2322
+ metadata = extract_chunk_metadata(
2323
+ chunk_content=chunk.content,
2324
+ full_content=content, # 원본 웹소설 전체 내용 참조
2325
+ chunk_index=chunk.chunk_index,
2326
+ file_id=file_id,
2327
+ model_name=file.model_name
2328
+ )
2329
+
2330
+ # 메타데이터를 JSON 문자열로 변환
2331
+ metadata_json = json.dumps(metadata, ensure_ascii=False) if metadata else None
2332
+
2333
+ # 청크에 메타데이터 저장
2334
+ chunk.chunk_metadata = metadata_json
2335
+ success_count += 1
2336
+
2337
+ # 진행 상황 출력 (10개마다)
2338
+ if (success_count + fail_count) % 10 == 0:
2339
+ print(f"[메타데이터 생성] 진행 중: {success_count + fail_count}/{len(chunks)}개 청크 처리 중...")
2340
+
2341
+ except Exception as e:
2342
+ print(f"[메타데이터 생성] 경고: 청크 {chunk.chunk_index} 메타데이터 생성 실패: {str(e)}")
2343
+ fail_count += 1
2344
+ continue
2345
+
2346
+ # 데이터베이스 커밋
2347
+ db.session.commit()
2348
+
2349
+ print(f"[메타데이터 생성] 완료: {success_count}개 성공, {fail_count}개 실패")
2350
+
2351
+ return jsonify({
2352
+ 'file_id': file_id,
2353
+ 'filename': file.original_filename,
2354
+ 'total_chunks': len(chunks),
2355
+ 'success_count': success_count,
2356
+ 'fail_count': fail_count,
2357
+ 'message': f'메타데이터 생성이 완료되었습니다. (성공: {success_count}개, 실패: {fail_count}개)'
2358
+ }), 200
2359
+
2360
+ except Exception as e:
2361
+ db.session.rollback()
2362
+ print(f"[메타데이터 생성] 오류: {str(e)}")
2363
+ import traceback
2364
+ traceback.print_exc()
2365
+ return jsonify({'error': f'메타데이터 생성 중 오류가 발생했습니다: {str(e)}'}), 500
2366
+
2367
  @main_bp.route('/api/files/<int:file_id>', methods=['DELETE'])
2368
  @login_required
2369
  def delete_file(file_id):
templates/admin.html CHANGED
@@ -447,6 +447,7 @@
447
  <span style="margin-right: 12px; color: #5f6368;">{{ current_user.nickname or current_user.username }}</span>
448
  <a href="{{ url_for('main.admin_webnovels') }}" class="btn btn-secondary">웹소설 관리</a>
449
  <a href="{{ url_for('main.admin_messages') }}" class="btn btn-secondary">메시지 확인</a>
 
450
  <a href="{{ url_for('main.index') }}" class="btn btn-secondary">메인으로</a>
451
  <a href="{{ url_for('main.logout') }}" class="btn btn-secondary">로그아웃</a>
452
  </div>
 
447
  <span style="margin-right: 12px; color: #5f6368;">{{ current_user.nickname or current_user.username }}</span>
448
  <a href="{{ url_for('main.admin_webnovels') }}" class="btn btn-secondary">웹소설 관리</a>
449
  <a href="{{ url_for('main.admin_messages') }}" class="btn btn-secondary">메시지 확인</a>
450
+ <a href="{{ url_for('main.admin_prompts') }}" class="btn btn-secondary">프롬프트 관리</a>
451
  <a href="{{ url_for('main.index') }}" class="btn btn-secondary">메인으로</a>
452
  <a href="{{ url_for('main.logout') }}" class="btn btn-secondary">로그아웃</a>
453
  </div>
templates/admin_messages.html CHANGED
@@ -350,6 +350,7 @@
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.index') }}" class="btn btn-secondary">메인으로</a>
354
  <a href="{{ url_for('main.logout') }}" class="btn btn-secondary">로그아웃</a>
355
  </div>
 
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_prompts') }}" class="btn btn-secondary">프롬프트 관리</a>
354
  <a href="{{ url_for('main.index') }}" class="btn btn-secondary">메인으로</a>
355
  <a href="{{ url_for('main.logout') }}" class="btn btn-secondary">로그아웃</a>
356
  </div>
templates/admin_prompts.html ADDED
@@ -0,0 +1,319 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="ko">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>프롬프트 관리 - SOY NV AI</title>
7
+ <style>
8
+ * {
9
+ margin: 0;
10
+ padding: 0;
11
+ box-sizing: border-box;
12
+ }
13
+
14
+ body {
15
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
16
+ background: #f5f5f5;
17
+ color: #202124;
18
+ line-height: 1.6;
19
+ }
20
+
21
+ .header {
22
+ background: #ffffff;
23
+ border-bottom: 1px solid #dadce0;
24
+ padding: 16px 24px;
25
+ display: flex;
26
+ justify-content: space-between;
27
+ align-items: center;
28
+ position: sticky;
29
+ top: 0;
30
+ z-index: 100;
31
+ box-shadow: 0 1px 2px rgba(0,0,0,0.1);
32
+ }
33
+
34
+ .header-title {
35
+ display: flex;
36
+ align-items: center;
37
+ gap: 12px;
38
+ font-size: 18px;
39
+ font-weight: 500;
40
+ }
41
+
42
+ .header-actions {
43
+ display: flex;
44
+ align-items: center;
45
+ gap: 8px;
46
+ }
47
+
48
+ .btn {
49
+ padding: 8px 16px;
50
+ border: none;
51
+ border-radius: 4px;
52
+ font-size: 14px;
53
+ cursor: pointer;
54
+ text-decoration: none;
55
+ display: inline-block;
56
+ transition: background-color 0.2s;
57
+ }
58
+
59
+ .btn-primary {
60
+ background: #1a73e8;
61
+ color: white;
62
+ }
63
+
64
+ .btn-primary:hover {
65
+ background: #1557b0;
66
+ }
67
+
68
+ .btn-secondary {
69
+ background: #f1f3f4;
70
+ color: #202124;
71
+ }
72
+
73
+ .btn-secondary:hover {
74
+ background: #e8eaed;
75
+ }
76
+
77
+ .btn-success {
78
+ background: #34a853;
79
+ color: white;
80
+ }
81
+
82
+ .btn-success:hover {
83
+ background: #2d8e47;
84
+ }
85
+
86
+ .container {
87
+ max-width: 1200px;
88
+ margin: 0 auto;
89
+ padding: 24px;
90
+ }
91
+
92
+ .page-header {
93
+ margin-bottom: 24px;
94
+ }
95
+
96
+ .page-header h1 {
97
+ font-size: 24px;
98
+ margin-bottom: 8px;
99
+ }
100
+
101
+ .page-header p {
102
+ color: #5f6368;
103
+ }
104
+
105
+ .prompt-editor {
106
+ background: white;
107
+ border-radius: 8px;
108
+ padding: 24px;
109
+ box-shadow: 0 1px 2px rgba(0,0,0,0.1);
110
+ margin-bottom: 24px;
111
+ }
112
+
113
+ .form-group {
114
+ margin-bottom: 20px;
115
+ }
116
+
117
+ .form-group label {
118
+ display: block;
119
+ margin-bottom: 8px;
120
+ font-weight: 500;
121
+ color: #202124;
122
+ }
123
+
124
+ .form-group textarea {
125
+ width: 100%;
126
+ min-height: 300px;
127
+ padding: 12px;
128
+ border: 1px solid #dadce0;
129
+ border-radius: 4px;
130
+ font-size: 14px;
131
+ font-family: 'Consolas', 'Monaco', monospace;
132
+ resize: vertical;
133
+ line-height: 1.5;
134
+ }
135
+
136
+ .form-group textarea:focus {
137
+ outline: none;
138
+ border-color: #1a73e8;
139
+ box-shadow: 0 0 0 2px rgba(26, 115, 232, 0.1);
140
+ }
141
+
142
+ .form-help {
143
+ margin-top: 8px;
144
+ font-size: 12px;
145
+ color: #5f6368;
146
+ }
147
+
148
+ .alert {
149
+ padding: 12px 16px;
150
+ border-radius: 4px;
151
+ margin-bottom: 16px;
152
+ display: none;
153
+ }
154
+
155
+ .alert.success {
156
+ background: #e8f5e9;
157
+ color: #137333;
158
+ border: 1px solid #34a853;
159
+ }
160
+
161
+ .alert.error {
162
+ background: #fce8e6;
163
+ color: #c5221f;
164
+ border: 1px solid #ea4335;
165
+ }
166
+
167
+ .alert.show {
168
+ display: block;
169
+ }
170
+
171
+ .button-group {
172
+ display: flex;
173
+ gap: 12px;
174
+ margin-top: 24px;
175
+ }
176
+
177
+ .prompt-info {
178
+ background: #f8f9fa;
179
+ border-left: 4px solid #1a73e8;
180
+ padding: 16px;
181
+ border-radius: 4px;
182
+ margin-bottom: 24px;
183
+ }
184
+
185
+ .prompt-info h3 {
186
+ margin-bottom: 8px;
187
+ font-size: 16px;
188
+ }
189
+
190
+ .prompt-info p {
191
+ margin-bottom: 4px;
192
+ font-size: 14px;
193
+ color: #5f6368;
194
+ }
195
+ </style>
196
+ </head>
197
+ <body>
198
+ <div class="header">
199
+ <div class="header-title">
200
+ <span>📝</span>
201
+ <span>프롬프트 관리</span>
202
+ </div>
203
+ <div class="header-actions">
204
+ <span style="margin-right: 12px; color: #5f6368;">{{ current_user.nickname or current_user.username }}</span>
205
+ <a href="{{ url_for('main.admin') }}" class="btn btn-secondary">사용자 관리</a>
206
+ <a href="{{ url_for('main.admin_webnovels') }}" class="btn btn-secondary">웹소설 관리</a>
207
+ <a href="{{ url_for('main.admin_messages') }}" class="btn btn-secondary">메시지 확인</a>
208
+ <a href="{{ url_for('main.index') }}" class="btn btn-secondary">메인으로</a>
209
+ <a href="{{ url_for('main.logout') }}" class="btn btn-secondary">로그아웃</a>
210
+ </div>
211
+ </div>
212
+
213
+ <div class="container">
214
+ <div class="page-header">
215
+ <h1>시스템 프롬프트 관리</h1>
216
+ <p>질문할 때 자동으로 붙이는 프롬프트를 설정할 수 있습니다. 이 프롬프트는 대화 메시지에는 표시되지 않습니다.</p>
217
+ </div>
218
+
219
+ <div class="alert" id="alert"></div>
220
+
221
+ <div class="prompt-info">
222
+ <h3>💡 프롬프트 사용 방법</h3>
223
+ <p>• 설정한 프롬프트는 모든 질문 앞에 자동으로 붙어서 AI에게 전달됩니다.</p>
224
+ <p>• 프롬프트는 사용자에게는 보이지 않으며, AI 응답 품질 향상을 위해 사용됩니다.</p>
225
+ <p>• 예: "항상 정중하게 답변하세요." 또는 "웹소설의 맥락을 고려하여 답변하세요."</p>
226
+ </div>
227
+
228
+ <div class="prompt-editor">
229
+ <div class="form-group">
230
+ <label for="promptContent">시스템 프롬프트</label>
231
+ <textarea id="promptContent" placeholder="예: 항상 정중하고 상세하게 답변하세요. 웹소설의 맥락을 고려하여 일관성 있는 답변을 제공하세요."></textarea>
232
+ <div class="form-help">
233
+ 질문 앞에 자동으로 추가될 프롬프트를 입력하세요. 비워두면 프롬프트를 사용하지 않습니다.
234
+ </div>
235
+ </div>
236
+
237
+ <div class="button-group">
238
+ <button class="btn btn-primary" onclick="savePrompt()">저장</button>
239
+ <button class="btn btn-secondary" onclick="loadPrompt()">새로고침</button>
240
+ <button class="btn btn-secondary" onclick="clearPrompt()">초기화</button>
241
+ </div>
242
+ </div>
243
+ </div>
244
+
245
+ <script>
246
+ // 페이지 로드 시 프롬프트 불러오기
247
+ window.addEventListener('DOMContentLoaded', () => {
248
+ loadPrompt();
249
+ });
250
+
251
+ // 프롬프트 불러오기
252
+ async function loadPrompt() {
253
+ try {
254
+ const response = await fetch('/api/admin/prompts', {
255
+ credentials: 'include'
256
+ });
257
+ const data = await response.json();
258
+
259
+ if (response.ok) {
260
+ document.getElementById('promptContent').value = data.prompt || '';
261
+ } else {
262
+ showAlert('프롬프트를 불러오는 중 오류가 발생했습니다: ' + (data.error || '알 수 없는 오류'), 'error');
263
+ }
264
+ } catch (error) {
265
+ showAlert('프롬프트를 불러오는 중 오류가 발생했습니다: ' + error.message, 'error');
266
+ console.error('프롬프트 불러오기 오류:', error);
267
+ }
268
+ }
269
+
270
+ // 프롬프트 저장
271
+ async function savePrompt() {
272
+ const promptContent = document.getElementById('promptContent').value.trim();
273
+
274
+ try {
275
+ const response = await fetch('/api/admin/prompts', {
276
+ method: 'POST',
277
+ headers: {
278
+ 'Content-Type': 'application/json'
279
+ },
280
+ credentials: 'include',
281
+ body: JSON.stringify({
282
+ prompt: promptContent
283
+ })
284
+ });
285
+
286
+ const data = await response.json();
287
+
288
+ if (response.ok) {
289
+ showAlert('프롬프트가 성공적으로 저장되었습니다.', 'success');
290
+ } else {
291
+ showAlert('프롬프트 저장 중 오류가 발생했습니다: ' + (data.error || '알 수 없는 오류'), 'error');
292
+ }
293
+ } catch (error) {
294
+ showAlert('프롬프트 저장 중 오류가 발생했습니다: ' + error.message, 'error');
295
+ console.error('프롬프트 저장 오류:', error);
296
+ }
297
+ }
298
+
299
+ // 프롬프트 초기화
300
+ function clearPrompt() {
301
+ if (confirm('프롬프트를 초기화하시겠습니까?')) {
302
+ document.getElementById('promptContent').value = '';
303
+ }
304
+ }
305
+
306
+ // 알림 표시
307
+ function showAlert(message, type) {
308
+ const alert = document.getElementById('alert');
309
+ alert.textContent = message;
310
+ alert.className = `alert ${type} show`;
311
+
312
+ setTimeout(() => {
313
+ alert.classList.remove('show');
314
+ }, 5000);
315
+ }
316
+ </script>
317
+ </body>
318
+ </html>
319
+
templates/admin_webnovels.html CHANGED
@@ -445,6 +445,7 @@
445
  <span style="margin-right: 12px; color: #5f6368;">{{ current_user.nickname or current_user.username }}</span>
446
  <a href="{{ url_for('main.admin') }}" class="btn btn-secondary">사용자 관리</a>
447
  <a href="{{ url_for('main.admin_messages') }}" class="btn btn-secondary">메시지 확인</a>
 
448
  <a href="{{ url_for('main.index') }}" class="btn btn-secondary">메인으로</a>
449
  <a href="{{ url_for('main.logout') }}" class="btn btn-secondary">로그아웃</a>
450
  </div>
@@ -682,6 +683,7 @@
682
  `<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>` :
683
  `<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>`
684
  }
 
685
  <button class="btn btn-primary" onclick="continueUpload(${file.id})" style="padding: 4px 8px; font-size: 12px; margin-right: 4px;">이어서 업로드</button>
686
  <button class="btn btn-secondary" onclick="deleteFile(${file.id})" style="padding: 4px 8px; font-size: 12px;">삭제</button>
687
  </div>
@@ -1319,6 +1321,42 @@
1319
  modal.classList.remove('active');
1320
  }
1321
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1322
  // 모달 외부 클릭 시 닫기
1323
  window.addEventListener('click', (event) => {
1324
  const modal = document.getElementById('parentChunkModal');
 
445
  <span style="margin-right: 12px; color: #5f6368;">{{ current_user.nickname or current_user.username }}</span>
446
  <a href="{{ url_for('main.admin') }}" class="btn btn-secondary">사용자 관리</a>
447
  <a href="{{ url_for('main.admin_messages') }}" class="btn btn-secondary">메시지 확인</a>
448
+ <a href="{{ url_for('main.admin_prompts') }}" class="btn btn-secondary">프롬프트 관리</a>
449
  <a href="{{ url_for('main.index') }}" class="btn btn-secondary">메인으로</a>
450
  <a href="{{ url_for('main.logout') }}" class="btn btn-secondary">로그아웃</a>
451
  </div>
 
683
  `<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>` :
684
  `<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>`
685
  }
686
+ <button class="btn btn-info" onclick="createMetadata(${file.id}, '${escapeHtml(file.original_filename)}')" style="padding: 4px 8px; font-size: 12px; margin-right: 4px;">메타데이터 생성</button>
687
  <button class="btn btn-primary" onclick="continueUpload(${file.id})" style="padding: 4px 8px; font-size: 12px; margin-right: 4px;">이어서 업로드</button>
688
  <button class="btn btn-secondary" onclick="deleteFile(${file.id})" style="padding: 4px 8px; font-size: 12px;">삭제</button>
689
  </div>
 
1321
  modal.classList.remove('active');
1322
  }
1323
 
1324
+ // 메타데이터 생성
1325
+ async function createMetadata(fileId, fileName) {
1326
+ if (!confirm(`"${fileName}" 파일의 모든 청크에 메타데이터를 생성하시겠습니까?\n\n이 작업은 몇 분이 걸릴 수 있으며, AI 모델을 사용합니다.`)) {
1327
+ return;
1328
+ }
1329
+
1330
+ const button = event.target;
1331
+ const originalText = button.textContent;
1332
+ button.disabled = true;
1333
+ button.textContent = '생성 중...';
1334
+
1335
+ try {
1336
+ const response = await fetch(`/api/files/${fileId}/metadata`, {
1337
+ method: 'POST',
1338
+ credentials: 'include'
1339
+ });
1340
+ const data = await response.json();
1341
+
1342
+ if (response.ok) {
1343
+ showAlert(
1344
+ `메타데이터 생성이 완료되었습니다.\n총 ${data.total_chunks}개 청크 중 ${data.success_count}개 성공, ${data.fail_count}개 실패`,
1345
+ 'success'
1346
+ );
1347
+ loadFiles(); // 파일 목록 새로고침
1348
+ } else {
1349
+ showAlert(`메타데이터 생성 실패: ${data.error || '알 수 없는 오류'}`, 'error');
1350
+ }
1351
+ } catch (error) {
1352
+ showAlert(`메타데이터 생성 중 오류가 발생했습니다: ${error.message}`, 'error');
1353
+ console.error('메타데이터 생성 오류:', error);
1354
+ } finally {
1355
+ button.disabled = false;
1356
+ button.textContent = originalText;
1357
+ }
1358
+ }
1359
+
1360
  // 모달 외부 클릭 시 닫기
1361
  window.addEventListener('click', (event) => {
1362
  const modal = document.getElementById('parentChunkModal');