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 +205 -53
- templates/admin.html +1 -0
- templates/admin_messages.html +1 -0
- templates/admin_prompts.html +319 -0
- templates/admin_webnovels.html +38 -0
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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
| 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
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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');
|