SOY NV AI
commited on
Commit
·
b834258
1
Parent(s):
665bcdc
feat: 메타데이터 추가 옵션, Parent Chunk 수동 생성, 각주 표시 기능 추가
Browse files- 웹소설 업로드 시 메타데이터 추가 옵션 체크박스 추가
- 관리페이지에서 Parent Chunk 수동 생성 기능 추가
- 관리페이지에서 모든 AI 모델 목록 표시
- AI 답변에 [근거: ] 형식을 각주로 변환하여 표시
- 각주 번호에 마우스 오버 시 툴팁으로 내용 표시
- DocumentChunk 모델에 chunk_metadata 필드 추가
- 청크 생성 시 챕터 번호, 화자, 등장인물, 시간적 배경 메타데이터 추출
- EXAONE_설치_가이드.md +4 -0
- EXAONE_추가_안내.md +4 -0
- GIT_SETUP.md +4 -0
- README_SERVER.md +4 -0
- add_exaone_model.py +4 -0
- app/__init__.py +13 -0
- app/database.py +10 -0
- app/routes.py +398 -17
- download_exaone_model.py +4 -0
- install_exaone_direct.py +4 -0
- install_exaone_simple.py +4 -0
- setup_remote.ps1 +4 -0
- templates/admin_webnovels.html +122 -4
- templates/index.html +141 -17
- templates/login.html +4 -0
EXAONE_설치_가이드.md
CHANGED
|
@@ -147,3 +147,7 @@ tokenizer = AutoTokenizer.from_pretrained("LGAI-EXAONE/EXAONE-3.0-7.8B-Instruct"
|
|
| 147 |
|
| 148 |
하지만 이 경우 Ollama API와 통합하려면 추가 작업이 필요합니다.
|
| 149 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 147 |
|
| 148 |
하지만 이 경우 Ollama API와 통합하려면 추가 작업이 필요합니다.
|
| 149 |
|
| 150 |
+
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
|
EXAONE_추가_안내.md
CHANGED
|
@@ -64,3 +64,7 @@ Ollama를 거치지 않고 Python에서 직접 Hugging Face 모델을 사용할
|
|
| 64 |
- [EXAONE 모델 페이지](https://huggingface.co/LGAI-EXAONE/EXAONE-3.0-7.8B-Instruct)
|
| 65 |
- [Ollama 공식 문서](https://github.com/ollama/ollama)
|
| 66 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 64 |
- [EXAONE 모델 페이지](https://huggingface.co/LGAI-EXAONE/EXAONE-3.0-7.8B-Instruct)
|
| 65 |
- [Ollama 공식 문서](https://github.com/ollama/ollama)
|
| 66 |
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
|
GIT_SETUP.md
CHANGED
|
@@ -67,3 +67,7 @@ gh repo create soy-nv-ai --public --source=. --remote=origin --push
|
|
| 67 |
3. README.md 업데이트 (프로젝트 설명 추가)
|
| 68 |
|
| 69 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 67 |
3. README.md 업데이트 (프로젝트 설명 추가)
|
| 68 |
|
| 69 |
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
|
README_SERVER.md
CHANGED
|
@@ -67,3 +67,7 @@ Start-Process powershell -ArgumentList "-File", "start_server_background.ps1" -W
|
|
| 67 |
2. 또는 작업 관리자에서 Python 프로세스 종료
|
| 68 |
|
| 69 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 67 |
2. 또는 작업 관리자에서 Python 프로세스 종료
|
| 68 |
|
| 69 |
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
|
add_exaone_model.py
CHANGED
|
@@ -142,3 +142,7 @@ if __name__ == "__main__":
|
|
| 142 |
print("3. 모델이 목록에 나타나면 웹 애플리케이션에서 사용할 수 있습니다.")
|
| 143 |
print("=" * 60 + "\n")
|
| 144 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 142 |
print("3. 모델이 목록에 나타나면 웹 애플리케이션에서 사용할 수 있습니다.")
|
| 143 |
print("=" * 60 + "\n")
|
| 144 |
|
| 145 |
+
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
|
app/__init__.py
CHANGED
|
@@ -86,6 +86,19 @@ def migrate_database(app):
|
|
| 86 |
conn.commit()
|
| 87 |
print("[마이그레이션] uploaded_file.parent_file_id 컬럼 추가 완료")
|
| 88 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 89 |
conn.close()
|
| 90 |
print("[마이그레이션] 데이터베이스 마이그레이션 완료")
|
| 91 |
except Exception as e:
|
|
|
|
| 86 |
conn.commit()
|
| 87 |
print("[마이그레이션] uploaded_file.parent_file_id 컬럼 추가 완료")
|
| 88 |
|
| 89 |
+
# document_chunk 테이블이 존재하는지 확인
|
| 90 |
+
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='document_chunk'")
|
| 91 |
+
if cursor.fetchone():
|
| 92 |
+
# document_chunk 테이블에 chunk_metadata 컬럼이 있는지 확인
|
| 93 |
+
cursor.execute("PRAGMA table_info(document_chunk)")
|
| 94 |
+
document_chunk_columns = [column[1] for column in cursor.fetchall()]
|
| 95 |
+
|
| 96 |
+
if 'chunk_metadata' not in document_chunk_columns:
|
| 97 |
+
print("[마이그레이션] document_chunk 테이블에 chunk_metadata 컬럼 추가 중...")
|
| 98 |
+
cursor.execute("ALTER TABLE document_chunk ADD COLUMN chunk_metadata TEXT")
|
| 99 |
+
conn.commit()
|
| 100 |
+
print("[마이그레이션] document_chunk.chunk_metadata 컬럼 추가 완료")
|
| 101 |
+
|
| 102 |
conn.close()
|
| 103 |
print("[마이그레이션] 데이터베이스 마이그레이션 완료")
|
| 104 |
except Exception as e:
|
app/database.py
CHANGED
|
@@ -113,17 +113,27 @@ class DocumentChunk(db.Model):
|
|
| 113 |
chunk_index = db.Column(db.Integer, nullable=False) # 청크 순서
|
| 114 |
content = db.Column(db.Text, nullable=False) # 청크 내용
|
| 115 |
embedding = db.Column(db.Text, nullable=True) # 임베딩 벡터 (JSON 문자열로 저장)
|
|
|
|
| 116 |
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
|
| 117 |
|
| 118 |
# 관계
|
| 119 |
file = db.relationship('UploadedFile', backref='chunks')
|
| 120 |
|
| 121 |
def to_dict(self):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 122 |
return {
|
| 123 |
'id': self.id,
|
| 124 |
'file_id': self.file_id,
|
| 125 |
'chunk_index': self.chunk_index,
|
| 126 |
'content': self.content,
|
|
|
|
| 127 |
'created_at': self.created_at.isoformat() if self.created_at else None
|
| 128 |
}
|
| 129 |
|
|
|
|
| 113 |
chunk_index = db.Column(db.Integer, nullable=False) # 청크 순서
|
| 114 |
content = db.Column(db.Text, nullable=False) # 청크 내용
|
| 115 |
embedding = db.Column(db.Text, nullable=True) # 임베딩 벡터 (JSON 문자열로 저장)
|
| 116 |
+
chunk_metadata = db.Column(db.Text, nullable=True) # 메타데이터 (JSON 문자열로 저장: chapter, pov, characters, time_background)
|
| 117 |
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
|
| 118 |
|
| 119 |
# 관계
|
| 120 |
file = db.relationship('UploadedFile', backref='chunks')
|
| 121 |
|
| 122 |
def to_dict(self):
|
| 123 |
+
import json
|
| 124 |
+
metadata_dict = None
|
| 125 |
+
if self.chunk_metadata:
|
| 126 |
+
try:
|
| 127 |
+
metadata_dict = json.loads(self.chunk_metadata)
|
| 128 |
+
except:
|
| 129 |
+
metadata_dict = None
|
| 130 |
+
|
| 131 |
return {
|
| 132 |
'id': self.id,
|
| 133 |
'file_id': self.file_id,
|
| 134 |
'chunk_index': self.chunk_index,
|
| 135 |
'content': self.content,
|
| 136 |
+
'metadata': metadata_dict,
|
| 137 |
'created_at': self.created_at.isoformat() if self.created_at else None
|
| 138 |
}
|
| 139 |
|
app/routes.py
CHANGED
|
@@ -181,11 +181,196 @@ def split_text_into_chunks(text, min_chunk_size=200, max_chunk_size=1000, overla
|
|
| 181 |
|
| 182 |
return final_chunks if final_chunks else [text] if text.strip() else []
|
| 183 |
|
| 184 |
-
def
|
| 185 |
-
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 186 |
try:
|
| 187 |
print(f"[청크 생성] 파일 ID {file_id}에 대한 청크 생성 시작")
|
| 188 |
print(f"[청크 생성] 원본 텍스트 길이: {len(content)}자")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 189 |
|
| 190 |
# 벡터 DB 매니저 가져오기
|
| 191 |
vector_db = get_vector_db()
|
|
@@ -212,13 +397,40 @@ def create_chunks_for_file(file_id, content):
|
|
| 212 |
# 각 청크를 데이터베이스와 벡터 DB에 저장
|
| 213 |
saved_count = 0
|
| 214 |
vector_saved_count = 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 215 |
for idx, chunk_content in enumerate(chunks):
|
| 216 |
try:
|
| 217 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 218 |
chunk = DocumentChunk(
|
| 219 |
file_id=file_id,
|
| 220 |
chunk_index=idx,
|
| 221 |
-
content=chunk_content
|
|
|
|
| 222 |
)
|
| 223 |
db.session.add(chunk)
|
| 224 |
db.session.flush() # ID 생성
|
|
@@ -236,13 +448,15 @@ def create_chunks_for_file(file_id, content):
|
|
| 236 |
|
| 237 |
# 진행 상황 출력 (10개마다)
|
| 238 |
if (idx + 1) % 10 == 0:
|
| 239 |
-
print(f"[청크 생성] 진행 중: {idx + 1}/{len(chunks)}개 청크 저장 중... (DB: {saved_count}, 벡터 DB: {vector_saved_count})")
|
| 240 |
except Exception as e:
|
| 241 |
print(f"[청크 생성] 경고: 청크 {idx} 저장 중 오류: {str(e)}")
|
|
|
|
|
|
|
| 242 |
continue
|
| 243 |
|
| 244 |
db.session.commit()
|
| 245 |
-
print(f"[청크 생성] 완료: {saved_count}개 청크가 데이터베이스에 저장되었습니다. (벡터 DB: {vector_saved_count}개)")
|
| 246 |
|
| 247 |
# 저장 확인
|
| 248 |
verified_count = DocumentChunk.query.filter_by(file_id=file_id).count()
|
|
@@ -986,29 +1200,61 @@ def set_gemini_api_key():
|
|
| 986 |
@main_bp.route('/api/ollama/models', methods=['GET'])
|
| 987 |
@login_required
|
| 988 |
def get_ollama_models():
|
| 989 |
-
"""Ollama 및 Gemini에서 사용 가능한 모델 목록 가져오기"""
|
| 990 |
try:
|
| 991 |
all_models = []
|
| 992 |
|
| 993 |
-
# 1. Ollama 모델 목록 가져오기
|
| 994 |
try:
|
| 995 |
response = requests.get(f'{OLLAMA_BASE_URL}/api/tags', timeout=5)
|
| 996 |
if response.status_code == 200:
|
| 997 |
data = response.json()
|
| 998 |
-
|
| 999 |
-
|
| 1000 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1001 |
except Exception as e:
|
| 1002 |
print(f"[모델 목록] Ollama 모델 목록 조회 실패: {e}")
|
| 1003 |
|
| 1004 |
-
# 2. Gemini 모델 목록 가져오기
|
| 1005 |
try:
|
| 1006 |
gemini_client = get_gemini_client()
|
| 1007 |
if gemini_client.is_configured():
|
| 1008 |
gemini_models = gemini_client.get_available_models()
|
| 1009 |
-
|
| 1010 |
-
|
| 1011 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1012 |
else:
|
| 1013 |
print(f"[모델 목록] Gemini API 키가 설정되지 않아 Gemini 모델을 불러올 수 없습니다.")
|
| 1014 |
except Exception as e:
|
|
@@ -1022,6 +1268,75 @@ def get_ollama_models():
|
|
| 1022 |
except Exception as e:
|
| 1023 |
return jsonify({'error': f'모델 목록을 가져오는 중 오류가 발생했습니다: {str(e)}', 'models': []}), 500
|
| 1024 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1025 |
@main_bp.route('/api/chat', methods=['POST'])
|
| 1026 |
@login_required
|
| 1027 |
def chat():
|
|
@@ -1435,10 +1750,12 @@ def upload_file():
|
|
| 1435 |
file = request.files['file']
|
| 1436 |
model_name = request.form.get('model_name', '').strip()
|
| 1437 |
parent_file_id = request.form.get('parent_file_id', None) # 이어서 업로드할 경우 원본 파일 ID
|
|
|
|
| 1438 |
|
| 1439 |
log_print(f"[2/8] 파일 수신: {file.filename if file else 'None'}")
|
| 1440 |
log_print(f"[2/8] 모델명: {model_name if model_name else 'None (비어있음)'}")
|
| 1441 |
log_print(f"[2/8] 이어서 업로드: {parent_file_id if parent_file_id else '아니오'}")
|
|
|
|
| 1442 |
|
| 1443 |
if file.filename == '':
|
| 1444 |
error_msg = '파일명이 없습니다.'
|
|
@@ -1600,8 +1917,8 @@ def upload_file():
|
|
| 1600 |
log_print(f"[7/8] CP949 인코딩으로 파일 읽기 성공: {len(content)}자")
|
| 1601 |
|
| 1602 |
# 청크 생성 및 저장
|
| 1603 |
-
log_print(f"[7/8] 청크 생성 함수 호출 중...")
|
| 1604 |
-
chunk_count = create_chunks_for_file(uploaded_file.id, content)
|
| 1605 |
|
| 1606 |
if chunk_count > 0:
|
| 1607 |
log_print(f"[7/8] ✅ 성공: 파일 {original_filename}을 {chunk_count}개의 청크로 분할했습니다.")
|
|
@@ -1707,6 +2024,10 @@ def get_files():
|
|
| 1707 |
chunk_count = DocumentChunk.query.filter_by(file_id=file.id).count()
|
| 1708 |
file_dict['chunk_count'] = chunk_count
|
| 1709 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1710 |
# 이어서 업로드된 파일들도 조회
|
| 1711 |
child_files = UploadedFile.query.filter_by(parent_file_id=file.id).order_by(UploadedFile.uploaded_at.asc()).all()
|
| 1712 |
child_files_dict = []
|
|
@@ -1714,6 +2035,9 @@ def get_files():
|
|
| 1714 |
child_dict = child.to_dict()
|
| 1715 |
child_chunk_count = DocumentChunk.query.filter_by(file_id=child.id).count()
|
| 1716 |
child_dict['chunk_count'] = child_chunk_count
|
|
|
|
|
|
|
|
|
|
| 1717 |
child_files_dict.append(child_dict)
|
| 1718 |
file_dict['child_files'] = child_files_dict
|
| 1719 |
files_with_children.append(file_dict)
|
|
@@ -1811,6 +2135,63 @@ def get_file_parent_chunk(file_id):
|
|
| 1811 |
except Exception as e:
|
| 1812 |
return jsonify({'error': f'Parent Chunk 조회 중 오류가 발생했습니다: {str(e)}'}), 500
|
| 1813 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1814 |
@main_bp.route('/api/files/<int:file_id>', methods=['DELETE'])
|
| 1815 |
@login_required
|
| 1816 |
def delete_file(file_id):
|
|
|
|
| 181 |
|
| 182 |
return final_chunks if final_chunks else [text] if text.strip() else []
|
| 183 |
|
| 184 |
+
def extract_chapter_number(text):
|
| 185 |
+
"""텍스트에서 챕터 번호 추출"""
|
| 186 |
+
# 다양한 챕터 패턴 매칭
|
| 187 |
+
patterns = [
|
| 188 |
+
r'제\s*(\d+)\s*장', # 제1장, 제 1 장
|
| 189 |
+
r'제\s*(\d+)\s*화', # 제1화
|
| 190 |
+
r'Chapter\s*(\d+)', # Chapter 1
|
| 191 |
+
r'CHAPTER\s*(\d+)', # CHAPTER 1
|
| 192 |
+
r'Ch\.\s*(\d+)', # Ch. 1
|
| 193 |
+
r'(\d+)\s*장', # 1장
|
| 194 |
+
r'(\d+)\s*화', # 1화
|
| 195 |
+
r'CHAPTER\s*(\d+)', # CHAPTER 1
|
| 196 |
+
r'chap\.\s*(\d+)', # chap. 1
|
| 197 |
+
r'ch\s*(\d+)', # ch 1
|
| 198 |
+
r'(\d+)\s*章', # 1章
|
| 199 |
+
]
|
| 200 |
+
|
| 201 |
+
# 텍스트의 처음 500자만 검사 (챕터 정보는 보통 앞부분에 있음)
|
| 202 |
+
search_text = text[:500]
|
| 203 |
+
|
| 204 |
+
for pattern in patterns:
|
| 205 |
+
match = re.search(pattern, search_text, re.IGNORECASE)
|
| 206 |
+
if match:
|
| 207 |
+
try:
|
| 208 |
+
chapter_num = int(match.group(1))
|
| 209 |
+
return chapter_num
|
| 210 |
+
except:
|
| 211 |
+
continue
|
| 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 우선 시도)
|
| 234 |
+
if not model_name:
|
| 235 |
+
# Gemini 시도
|
| 236 |
+
try:
|
| 237 |
+
gemini_client = get_gemini_client()
|
| 238 |
+
if gemini_client.is_configured():
|
| 239 |
+
result = gemini_client.generate_response(
|
| 240 |
+
prompt=prompt,
|
| 241 |
+
model_name="gemini-1.5-flash",
|
| 242 |
+
temperature=0.3,
|
| 243 |
+
max_output_tokens=500
|
| 244 |
+
)
|
| 245 |
+
if not result['error'] and result.get('response'):
|
| 246 |
+
response_text = result['response'].strip()
|
| 247 |
+
# JSON 추출
|
| 248 |
+
json_match = re.search(r'\{.*\}', response_text, re.DOTALL)
|
| 249 |
+
if json_match:
|
| 250 |
+
metadata = json.loads(json_match.group(0))
|
| 251 |
+
return metadata
|
| 252 |
+
except:
|
| 253 |
+
pass
|
| 254 |
+
|
| 255 |
+
# 모델명이 있거나 Gemini 실패 시 해당 모델 사용
|
| 256 |
+
if model_name:
|
| 257 |
+
model_name_lower = model_name.lower().strip()
|
| 258 |
+
is_gemini = model_name_lower.startswith('gemini:') or model_name_lower.startswith('gemini-')
|
| 259 |
+
|
| 260 |
+
if is_gemini:
|
| 261 |
+
gemini_model_name = model_name.strip()
|
| 262 |
+
if gemini_model_name.lower().startswith('gemini:'):
|
| 263 |
+
gemini_model_name = gemini_model_name.split(':', 1)[1].strip()
|
| 264 |
+
|
| 265 |
+
gemini_client = get_gemini_client()
|
| 266 |
+
if gemini_client.is_configured():
|
| 267 |
+
result = gemini_client.generate_response(
|
| 268 |
+
prompt=prompt,
|
| 269 |
+
model_name=gemini_model_name,
|
| 270 |
+
temperature=0.3,
|
| 271 |
+
max_output_tokens=500
|
| 272 |
+
)
|
| 273 |
+
if not result['error'] and result.get('response'):
|
| 274 |
+
response_text = result['response'].strip()
|
| 275 |
+
json_match = re.search(r'\{.*\}', response_text, re.DOTALL)
|
| 276 |
+
if json_match:
|
| 277 |
+
metadata = json.loads(json_match.group(0))
|
| 278 |
+
return metadata
|
| 279 |
+
else:
|
| 280 |
+
# Ollama API 호출
|
| 281 |
+
try:
|
| 282 |
+
ollama_response = requests.post(
|
| 283 |
+
f'{OLLAMA_BASE_URL}/api/generate',
|
| 284 |
+
json={
|
| 285 |
+
'model': model_name,
|
| 286 |
+
'prompt': prompt,
|
| 287 |
+
'stream': False,
|
| 288 |
+
'options': {
|
| 289 |
+
'temperature': 0.3,
|
| 290 |
+
'num_predict': 500
|
| 291 |
+
}
|
| 292 |
+
},
|
| 293 |
+
timeout=30
|
| 294 |
+
)
|
| 295 |
+
if ollama_response.status_code == 200:
|
| 296 |
+
response_data = ollama_response.json()
|
| 297 |
+
response_text = response_data.get('response', '').strip()
|
| 298 |
+
json_match = re.search(r'\{.*\}', response_text, re.DOTALL)
|
| 299 |
+
if json_match:
|
| 300 |
+
metadata = json.loads(json_match.group(0))
|
| 301 |
+
return metadata
|
| 302 |
+
except:
|
| 303 |
+
pass
|
| 304 |
+
|
| 305 |
+
# AI 추출 실패 시 기본값 반환
|
| 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. 챕터 번호 추출 (정규식 기반)
|
| 329 |
+
chapter_num = extract_chapter_number(chunk_content)
|
| 330 |
+
if chapter_num:
|
| 331 |
+
metadata["chapter"] = chapter_num
|
| 332 |
+
elif full_content and chunk_index is not None:
|
| 333 |
+
# 청크 앞부분 컨텍스트에서 챕터 찾기
|
| 334 |
+
context_start = max(0, sum(len(c) for c in chunk_content.split('\n')[:10]))
|
| 335 |
+
if full_content and len(full_content) > context_start:
|
| 336 |
+
context = full_content[:context_start + 1000]
|
| 337 |
+
chapter_num = extract_chapter_number(context)
|
| 338 |
+
if chapter_num:
|
| 339 |
+
metadata["chapter"] = chapter_num
|
| 340 |
+
|
| 341 |
+
# 2. AI를 사용한 메타데이터 추출 (화자, 등장인물, 시간적 배경)
|
| 342 |
+
# Parent Chunk가 있으면 참조
|
| 343 |
+
parent_chunk = None
|
| 344 |
+
if file_id:
|
| 345 |
+
try:
|
| 346 |
+
parent_chunk = ParentChunk.query.filter_by(file_id=file_id).first()
|
| 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"[청크 생성] 메타데이터 추출: {'예' if extract_metadata else '아니오'}")
|
| 370 |
+
|
| 371 |
+
# 파일 정보 가져오기 (모델명 등)
|
| 372 |
+
uploaded_file = UploadedFile.query.get(file_id)
|
| 373 |
+
model_name = uploaded_file.model_name if uploaded_file else None
|
| 374 |
|
| 375 |
# 벡터 DB 매니저 가져오기
|
| 376 |
vector_db = get_vector_db()
|
|
|
|
| 397 |
# 각 청크를 데이터베이스와 벡터 DB에 저장
|
| 398 |
saved_count = 0
|
| 399 |
vector_saved_count = 0
|
| 400 |
+
metadata_extracted_count = 0
|
| 401 |
+
|
| 402 |
+
if extract_metadata:
|
| 403 |
+
print(f"[청크 생성] 메타데이터 추출 시작 (AI 사용: {model_name is not None})...")
|
| 404 |
+
else:
|
| 405 |
+
print(f"[청크 생성] 메타데이터 추출 건너뜀 (사용자 선택)")
|
| 406 |
+
|
| 407 |
for idx, chunk_content in enumerate(chunks):
|
| 408 |
try:
|
| 409 |
+
# 메타데이터 추출 (옵션이 활성화된 경우에만)
|
| 410 |
+
metadata = None
|
| 411 |
+
metadata_json = None
|
| 412 |
+
|
| 413 |
+
if extract_metadata:
|
| 414 |
+
metadata = extract_chunk_metadata(
|
| 415 |
+
chunk_content=chunk_content,
|
| 416 |
+
full_content=content,
|
| 417 |
+
chunk_index=idx,
|
| 418 |
+
file_id=file_id,
|
| 419 |
+
model_name=model_name
|
| 420 |
+
)
|
| 421 |
+
|
| 422 |
+
# 메타데이터를 JSON 문자열로 변환
|
| 423 |
+
metadata_json = json.dumps(metadata, ensure_ascii=False) if metadata else None
|
| 424 |
+
|
| 425 |
+
if metadata and (metadata.get("chapter") or metadata.get("pov") or metadata.get("characters") or metadata.get("time_background")):
|
| 426 |
+
metadata_extracted_count += 1
|
| 427 |
+
|
| 428 |
+
# DB에 청크 저장 (메타데이터 포함)
|
| 429 |
chunk = DocumentChunk(
|
| 430 |
file_id=file_id,
|
| 431 |
chunk_index=idx,
|
| 432 |
+
content=chunk_content,
|
| 433 |
+
chunk_metadata=metadata_json
|
| 434 |
)
|
| 435 |
db.session.add(chunk)
|
| 436 |
db.session.flush() # ID 생성
|
|
|
|
| 448 |
|
| 449 |
# 진행 상황 출력 (10개마다)
|
| 450 |
if (idx + 1) % 10 == 0:
|
| 451 |
+
print(f"[청크 생성] 진행 중: {idx + 1}/{len(chunks)}개 청크 저장 중... (DB: {saved_count}, 벡터 DB: {vector_saved_count}, 메타데이터: {metadata_extracted_count})")
|
| 452 |
except Exception as e:
|
| 453 |
print(f"[청크 생성] 경고: 청크 {idx} 저장 중 오류: {str(e)}")
|
| 454 |
+
import traceback
|
| 455 |
+
traceback.print_exc()
|
| 456 |
continue
|
| 457 |
|
| 458 |
db.session.commit()
|
| 459 |
+
print(f"[청크 생성] 완료: {saved_count}개 청크가 데이터베이스에 저장되었습니다. (벡터 DB: {vector_saved_count}개, 메타데이터 추출: {metadata_extracted_count}개)")
|
| 460 |
|
| 461 |
# 저장 확인
|
| 462 |
verified_count = DocumentChunk.query.filter_by(file_id=file_id).count()
|
|
|
|
| 1200 |
@main_bp.route('/api/ollama/models', methods=['GET'])
|
| 1201 |
@login_required
|
| 1202 |
def get_ollama_models():
|
| 1203 |
+
"""Ollama 및 Gemini에서 사용 가능한 모델 목록 가져오기 (로컬 AI 모델은 학습된 웹소설이 있는 모델만 표시)"""
|
| 1204 |
try:
|
| 1205 |
all_models = []
|
| 1206 |
|
| 1207 |
+
# 1. Ollama 모델 목록 가져오기 (학습된 웹소설이 있는 모델만 필터링)
|
| 1208 |
try:
|
| 1209 |
response = requests.get(f'{OLLAMA_BASE_URL}/api/tags', timeout=5)
|
| 1210 |
if response.status_code == 200:
|
| 1211 |
data = response.json()
|
| 1212 |
+
ollama_models_raw = [model['name'] for model in data.get('models', [])]
|
| 1213 |
+
|
| 1214 |
+
# 각 Ollama 모델에 대해 학습된 웹소설이 있는지 확인
|
| 1215 |
+
filtered_ollama_models = []
|
| 1216 |
+
for model_name in ollama_models_raw:
|
| 1217 |
+
# 해당 모델로 학습된 원본 파일이 있는지 확인 (parent_file_id가 None인 파일만)
|
| 1218 |
+
file_count = UploadedFile.query.filter_by(
|
| 1219 |
+
model_name=model_name,
|
| 1220 |
+
parent_file_id=None
|
| 1221 |
+
).count()
|
| 1222 |
+
|
| 1223 |
+
if file_count > 0:
|
| 1224 |
+
filtered_ollama_models.append({'name': model_name, 'type': 'ollama'})
|
| 1225 |
+
print(f"[모델 목록] Ollama 모델 '{model_name}' - 학습된 웹소설 {file_count}개")
|
| 1226 |
+
else:
|
| 1227 |
+
print(f"[모델 목록] Ollama 모델 '{model_name}' - 학습된 웹소설 없음, 목록에서 제외")
|
| 1228 |
+
|
| 1229 |
+
all_models.extend(filtered_ollama_models)
|
| 1230 |
+
print(f"[모델 목록] Ollama 모델 {len(filtered_ollama_models)}개 추가 (전체 {len(ollama_models_raw)}개 중 {len(filtered_ollama_models)}개 필터링됨)")
|
| 1231 |
except Exception as e:
|
| 1232 |
print(f"[모델 목록] Ollama 모델 목록 조회 실패: {e}")
|
| 1233 |
|
| 1234 |
+
# 2. Gemini 모델 목록 가져오기 (학습된 웹소설이 있는 모델만 필터링)
|
| 1235 |
try:
|
| 1236 |
gemini_client = get_gemini_client()
|
| 1237 |
if gemini_client.is_configured():
|
| 1238 |
gemini_models = gemini_client.get_available_models()
|
| 1239 |
+
|
| 1240 |
+
# 각 Gemini 모델에 대해 학습된 웹소설이 있는지 확인
|
| 1241 |
+
filtered_gemini_models = []
|
| 1242 |
+
for model_name in gemini_models:
|
| 1243 |
+
full_model_name = f'gemini:{model_name}'
|
| 1244 |
+
# 해당 모델로 학습된 원본 파일이 있는지 확인 (parent_file_id가 None인 파일만)
|
| 1245 |
+
file_count = UploadedFile.query.filter_by(
|
| 1246 |
+
model_name=full_model_name,
|
| 1247 |
+
parent_file_id=None
|
| 1248 |
+
).count()
|
| 1249 |
+
|
| 1250 |
+
if file_count > 0:
|
| 1251 |
+
filtered_gemini_models.append({'name': full_model_name, 'type': 'gemini'})
|
| 1252 |
+
print(f"[모델 목록] Gemini 모델 '{full_model_name}' - 학습된 웹소설 {file_count}개")
|
| 1253 |
+
else:
|
| 1254 |
+
print(f"[모델 목록] Gemini 모델 '{full_model_name}' - 학습된 웹소설 없음, 목록에서 제외")
|
| 1255 |
+
|
| 1256 |
+
all_models.extend(filtered_gemini_models)
|
| 1257 |
+
print(f"[모델 목록] Gemini 모델 {len(filtered_gemini_models)}개 추가 (전체 {len(gemini_models)}개 중 {len(filtered_gemini_models)}개 필터링됨)")
|
| 1258 |
else:
|
| 1259 |
print(f"[모델 목록] Gemini API 키가 설정되지 않아 Gemini 모델을 불러올 수 없습니다.")
|
| 1260 |
except Exception as e:
|
|
|
|
| 1268 |
except Exception as e:
|
| 1269 |
return jsonify({'error': f'모델 목록을 가져오는 중 오류가 발생했습니다: {str(e)}', 'models': []}), 500
|
| 1270 |
|
| 1271 |
+
@main_bp.route('/api/admin/ollama/models', methods=['GET'])
|
| 1272 |
+
@admin_required
|
| 1273 |
+
def get_all_ollama_models():
|
| 1274 |
+
"""관리자용: Ollama 및 Gemini에서 사용 가능한 모든 모델 목록 가져오기 (필터링 없이 전체 목록)"""
|
| 1275 |
+
try:
|
| 1276 |
+
all_models = []
|
| 1277 |
+
|
| 1278 |
+
# 1. Ollama 모델 목록 가져오기 (필터링 없이 전체 목록)
|
| 1279 |
+
try:
|
| 1280 |
+
response = requests.get(f'{OLLAMA_BASE_URL}/api/tags', timeout=5)
|
| 1281 |
+
if response.status_code == 200:
|
| 1282 |
+
data = response.json()
|
| 1283 |
+
ollama_models_raw = [model['name'] for model in data.get('models', [])]
|
| 1284 |
+
|
| 1285 |
+
# 필터링 없이 모든 Ollama 모델 추가
|
| 1286 |
+
for model_name in ollama_models_raw:
|
| 1287 |
+
# 각 모델의 학습된 웹소설 개수 확인 (정보 제공용)
|
| 1288 |
+
file_count = UploadedFile.query.filter_by(
|
| 1289 |
+
model_name=model_name,
|
| 1290 |
+
parent_file_id=None
|
| 1291 |
+
).count()
|
| 1292 |
+
|
| 1293 |
+
all_models.append({
|
| 1294 |
+
'name': model_name,
|
| 1295 |
+
'type': 'ollama',
|
| 1296 |
+
'file_count': file_count # 정보 제공용
|
| 1297 |
+
})
|
| 1298 |
+
print(f"[관리자 모델 목록] Ollama 모델 '{model_name}' - 학습된 웹소설 {file_count}개")
|
| 1299 |
+
|
| 1300 |
+
print(f"[관리자 모델 목록] Ollama 모델 {len(ollama_models_raw)}개 추가")
|
| 1301 |
+
except Exception as e:
|
| 1302 |
+
print(f"[관리자 모델 목록] Ollama 모델 목록 조회 실패: {e}")
|
| 1303 |
+
|
| 1304 |
+
# 2. Gemini 모델 목록 가져오기 (필터링 없이 전체 목록)
|
| 1305 |
+
try:
|
| 1306 |
+
gemini_client = get_gemini_client()
|
| 1307 |
+
if gemini_client.is_configured():
|
| 1308 |
+
gemini_models = gemini_client.get_available_models()
|
| 1309 |
+
|
| 1310 |
+
# 필터링 없이 모든 Gemini 모델 추가
|
| 1311 |
+
for model_name in gemini_models:
|
| 1312 |
+
full_model_name = f'gemini:{model_name}'
|
| 1313 |
+
# 각 모델의 학습된 웹소설 개수 확인 (정보 제공용)
|
| 1314 |
+
file_count = UploadedFile.query.filter_by(
|
| 1315 |
+
model_name=full_model_name,
|
| 1316 |
+
parent_file_id=None
|
| 1317 |
+
).count()
|
| 1318 |
+
|
| 1319 |
+
all_models.append({
|
| 1320 |
+
'name': full_model_name,
|
| 1321 |
+
'type': 'gemini',
|
| 1322 |
+
'file_count': file_count # 정보 제공용
|
| 1323 |
+
})
|
| 1324 |
+
print(f"[관리자 모델 목록] Gemini 모델 '{full_model_name}' - 학습된 웹소설 {file_count}개")
|
| 1325 |
+
|
| 1326 |
+
print(f"[관리자 모델 목록] Gemini 모델 {len(gemini_models)}개 추가")
|
| 1327 |
+
else:
|
| 1328 |
+
print(f"[관리자 모델 목록] Gemini API 키가 설정되지 않아 Gemini 모델을 불러올 수 없습니다.")
|
| 1329 |
+
except Exception as e:
|
| 1330 |
+
print(f"[관리자 모델 목록] Gemini 모델 목록 조회 실패: {e}")
|
| 1331 |
+
|
| 1332 |
+
if all_models:
|
| 1333 |
+
return jsonify({'models': all_models})
|
| 1334 |
+
else:
|
| 1335 |
+
return jsonify({'error': '사용 가능한 모델이 없습니다. Ollama가 실행 중인지, 또는 Gemini API 키가 설정되었는지 확인하세요.', 'models': []}), 500
|
| 1336 |
+
|
| 1337 |
+
except Exception as e:
|
| 1338 |
+
return jsonify({'error': f'모델 목록을 가져오는 중 오류가 발생했습니다: {str(e)}', 'models': []}), 500
|
| 1339 |
+
|
| 1340 |
@main_bp.route('/api/chat', methods=['POST'])
|
| 1341 |
@login_required
|
| 1342 |
def chat():
|
|
|
|
| 1750 |
file = request.files['file']
|
| 1751 |
model_name = request.form.get('model_name', '').strip()
|
| 1752 |
parent_file_id = request.form.get('parent_file_id', None) # 이어서 업로드할 경우 원본 파일 ID
|
| 1753 |
+
extract_metadata = request.form.get('extract_metadata', 'true').lower() == 'true' # 메타데이터 추출 여부 (기본값: true)
|
| 1754 |
|
| 1755 |
log_print(f"[2/8] 파일 수신: {file.filename if file else 'None'}")
|
| 1756 |
log_print(f"[2/8] 모델명: {model_name if model_name else 'None (비어있음)'}")
|
| 1757 |
log_print(f"[2/8] 이어서 업로드: {parent_file_id if parent_file_id else '아니오'}")
|
| 1758 |
+
log_print(f"[2/8] 메타데이터 추출: {'예' if extract_metadata else '아니오'}")
|
| 1759 |
|
| 1760 |
if file.filename == '':
|
| 1761 |
error_msg = '파일명이 없습니다.'
|
|
|
|
| 1917 |
log_print(f"[7/8] CP949 인코딩으로 파일 읽기 성공: {len(content)}자")
|
| 1918 |
|
| 1919 |
# 청크 생성 및 저장
|
| 1920 |
+
log_print(f"[7/8] 청크 생성 함수 호출 중... (메타데이터 추출: {'예' if extract_metadata else '아니오'})")
|
| 1921 |
+
chunk_count = create_chunks_for_file(uploaded_file.id, content, extract_metadata=extract_metadata)
|
| 1922 |
|
| 1923 |
if chunk_count > 0:
|
| 1924 |
log_print(f"[7/8] ✅ 성공: 파일 {original_filename}을 {chunk_count}개의 청크로 분할했습니다.")
|
|
|
|
| 2024 |
chunk_count = DocumentChunk.query.filter_by(file_id=file.id).count()
|
| 2025 |
file_dict['chunk_count'] = chunk_count
|
| 2026 |
|
| 2027 |
+
# Parent Chunk 존재 여부 확인
|
| 2028 |
+
has_parent_chunk = ParentChunk.query.filter_by(file_id=file.id).first() is not None
|
| 2029 |
+
file_dict['has_parent_chunk'] = has_parent_chunk
|
| 2030 |
+
|
| 2031 |
# 이어서 업로드된 파일들도 조회
|
| 2032 |
child_files = UploadedFile.query.filter_by(parent_file_id=file.id).order_by(UploadedFile.uploaded_at.asc()).all()
|
| 2033 |
child_files_dict = []
|
|
|
|
| 2035 |
child_dict = child.to_dict()
|
| 2036 |
child_chunk_count = DocumentChunk.query.filter_by(file_id=child.id).count()
|
| 2037 |
child_dict['chunk_count'] = child_chunk_count
|
| 2038 |
+
# Child 파일도 Parent Chunk 확인
|
| 2039 |
+
child_has_parent_chunk = ParentChunk.query.filter_by(file_id=child.id).first() is not None
|
| 2040 |
+
child_dict['has_parent_chunk'] = child_has_parent_chunk
|
| 2041 |
child_files_dict.append(child_dict)
|
| 2042 |
file_dict['child_files'] = child_files_dict
|
| 2043 |
files_with_children.append(file_dict)
|
|
|
|
| 2135 |
except Exception as e:
|
| 2136 |
return jsonify({'error': f'Parent Chunk 조회 중 오류가 발생했습니다: {str(e)}'}), 500
|
| 2137 |
|
| 2138 |
+
@main_bp.route('/api/files/<int:file_id>/parent-chunk', methods=['POST'])
|
| 2139 |
+
@login_required
|
| 2140 |
+
def create_file_parent_chunk(file_id):
|
| 2141 |
+
"""파일의 Parent Chunk 수동 생성 (재생성)"""
|
| 2142 |
+
try:
|
| 2143 |
+
file = UploadedFile.query.filter_by(id=file_id, uploaded_by=current_user.id).first()
|
| 2144 |
+
if not file:
|
| 2145 |
+
return jsonify({'error': '파일을 찾을 수 없습니다.'}), 404
|
| 2146 |
+
|
| 2147 |
+
# 모델명 확인
|
| 2148 |
+
if not file.model_name:
|
| 2149 |
+
return jsonify({'error': '파일에 연결된 AI 모델이 없습니다. Parent Chunk를 생성할 수 없습니다.'}), 400
|
| 2150 |
+
|
| 2151 |
+
# 파일이 텍스트 파일인지 확인
|
| 2152 |
+
if not file.original_filename.lower().endswith(('.txt', '.md')):
|
| 2153 |
+
return jsonify({'error': 'Parent Chunk는 텍스트 파일(.txt, .md)에만 생성할 수 있습니다.'}), 400
|
| 2154 |
+
|
| 2155 |
+
# 파일 내용 읽기
|
| 2156 |
+
try:
|
| 2157 |
+
encoding = 'utf-8'
|
| 2158 |
+
try:
|
| 2159 |
+
with open(file.file_path, 'r', encoding=encoding) as f:
|
| 2160 |
+
content = f.read()
|
| 2161 |
+
except UnicodeDecodeError:
|
| 2162 |
+
with open(file.file_path, 'r', encoding='cp949') as f:
|
| 2163 |
+
content = f.read()
|
| 2164 |
+
except Exception as e:
|
| 2165 |
+
return jsonify({'error': f'파일을 읽을 수 없습니다: {str(e)}'}), 500
|
| 2166 |
+
|
| 2167 |
+
if not content or len(content.strip()) == 0:
|
| 2168 |
+
return jsonify({'error': '파일 내용이 비어있습니다.'}), 400
|
| 2169 |
+
|
| 2170 |
+
# Parent Chunk 생성
|
| 2171 |
+
print(f"[Parent Chunk 수동 생성] 파일 ID {file_id}에 대한 Parent Chunk 생성 시작")
|
| 2172 |
+
print(f"[Parent Chunk 수동 생성] 모델명: {file.model_name}")
|
| 2173 |
+
print(f"[Parent Chunk 수동 생성] 파일명: {file.original_filename}")
|
| 2174 |
+
|
| 2175 |
+
parent_chunk = create_parent_chunk_with_ai(file_id, content, file.model_name)
|
| 2176 |
+
|
| 2177 |
+
if parent_chunk:
|
| 2178 |
+
return jsonify({
|
| 2179 |
+
'file_id': file_id,
|
| 2180 |
+
'filename': file.original_filename,
|
| 2181 |
+
'has_parent_chunk': True,
|
| 2182 |
+
'parent_chunk': parent_chunk.to_dict(),
|
| 2183 |
+
'message': 'Parent Chunk가 ��공적으로 생성되었습니다.'
|
| 2184 |
+
}), 200
|
| 2185 |
+
else:
|
| 2186 |
+
return jsonify({
|
| 2187 |
+
'error': 'Parent Chunk 생성에 실패했습니다. 서버 로그를 확인하세요.',
|
| 2188 |
+
'file_id': file_id,
|
| 2189 |
+
'filename': file.original_filename
|
| 2190 |
+
}), 500
|
| 2191 |
+
|
| 2192 |
+
except Exception as e:
|
| 2193 |
+
return jsonify({'error': f'Parent Chunk 생성 중 오류가 발생했습니다: {str(e)}'}), 500
|
| 2194 |
+
|
| 2195 |
@main_bp.route('/api/files/<int:file_id>', methods=['DELETE'])
|
| 2196 |
@login_required
|
| 2197 |
def delete_file(file_id):
|
download_exaone_model.py
CHANGED
|
@@ -70,3 +70,7 @@ if __name__ == "__main__":
|
|
| 70 |
else:
|
| 71 |
print("\n다운로드에 실패했습니다.")
|
| 72 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 70 |
else:
|
| 71 |
print("\n다운로드에 실패했습니다.")
|
| 72 |
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
|
install_exaone_direct.py
CHANGED
|
@@ -75,3 +75,7 @@ def main():
|
|
| 75 |
if __name__ == "__main__":
|
| 76 |
main()
|
| 77 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 75 |
if __name__ == "__main__":
|
| 76 |
main()
|
| 77 |
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
|
install_exaone_simple.py
CHANGED
|
@@ -52,3 +52,7 @@ def main():
|
|
| 52 |
if __name__ == "__main__":
|
| 53 |
main()
|
| 54 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 52 |
if __name__ == "__main__":
|
| 53 |
main()
|
| 54 |
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
|
setup_remote.ps1
CHANGED
|
@@ -60,3 +60,7 @@ Write-Host ""
|
|
| 60 |
Write-Host "=== 완료 ===" -ForegroundColor Green
|
| 61 |
|
| 62 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 60 |
Write-Host "=== 완료 ===" -ForegroundColor Green
|
| 61 |
|
| 62 |
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
|
templates/admin_webnovels.html
CHANGED
|
@@ -471,6 +471,18 @@
|
|
| 471 |
</select>
|
| 472 |
</div>
|
| 473 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 474 |
<!-- 파일 업로드 -->
|
| 475 |
<div class="file-upload-input-wrapper" id="fileUploadWrapper">
|
| 476 |
<input type="file" id="fileInput" accept=".txt,.md,.pdf,.docx,.epub" multiple>
|
|
@@ -571,10 +583,10 @@
|
|
| 571 |
const fileModelSelect = document.getElementById('fileModelSelect');
|
| 572 |
const filesTableBody = document.getElementById('filesTableBody');
|
| 573 |
|
| 574 |
-
// 모델 목록 로드
|
| 575 |
async function loadModelsForFiles() {
|
| 576 |
try {
|
| 577 |
-
const response = await fetch('/api/ollama/models');
|
| 578 |
const data = await response.json();
|
| 579 |
|
| 580 |
fileModelSelect.innerHTML = '<option value="">모델을 선택하세요...</option>';
|
|
@@ -583,12 +595,21 @@
|
|
| 583 |
data.models.forEach(model => {
|
| 584 |
const option = document.createElement('option');
|
| 585 |
option.value = model.name;
|
| 586 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 587 |
fileModelSelect.appendChild(option);
|
| 588 |
});
|
|
|
|
|
|
|
|
|
|
| 589 |
}
|
| 590 |
} catch (error) {
|
| 591 |
console.error('모델 로드 오류:', error);
|
|
|
|
| 592 |
}
|
| 593 |
}
|
| 594 |
|
|
@@ -647,7 +668,10 @@
|
|
| 647 |
<td>${uploadDate}</td>
|
| 648 |
<td>
|
| 649 |
<div class="file-actions">
|
| 650 |
-
|
|
|
|
|
|
|
|
|
|
| 651 |
<button class="btn btn-primary" onclick="continueUpload(${file.id})" style="padding: 4px 8px; font-size: 12px; margin-right: 4px;">이어서 업로드</button>
|
| 652 |
<button class="btn btn-secondary" onclick="deleteFile(${file.id})" style="padding: 4px 8px; font-size: 12px;">삭제</button>
|
| 653 |
</div>
|
|
@@ -752,6 +776,9 @@
|
|
| 752 |
const formData = new FormData();
|
| 753 |
formData.append('file', file);
|
| 754 |
formData.append('model_name', modelName);
|
|
|
|
|
|
|
|
|
|
| 755 |
|
| 756 |
// 이어서 업로드인 경우 parent_file_id 추가
|
| 757 |
if (continueUploadFileId) {
|
|
@@ -1004,6 +1031,97 @@
|
|
| 1004 |
}
|
| 1005 |
}
|
| 1006 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1007 |
// Parent Chunk 모달 닫기
|
| 1008 |
function closeParentChunkModal() {
|
| 1009 |
const modal = document.getElementById('parentChunkModal');
|
|
|
|
| 471 |
</select>
|
| 472 |
</div>
|
| 473 |
|
| 474 |
+
<!-- 메타데이터 추가 옵션 -->
|
| 475 |
+
<div style="margin-bottom: 16px; padding: 12px; background: #f8f9fa; border-radius: 6px; border: 1px solid #dadce0;">
|
| 476 |
+
<label style="display: flex; align-items: center; cursor: pointer; font-size: 14px;">
|
| 477 |
+
<input type="checkbox" id="extractMetadataCheckbox" checked style="margin-right: 8px; width: 18px; height: 18px; cursor: pointer;">
|
| 478 |
+
<span style="font-weight: 500;">청크에 메타데이터 추가</span>
|
| 479 |
+
</label>
|
| 480 |
+
<div style="margin-top: 8px; margin-left: 26px; font-size: 12px; color: #5f6368; line-height: 1.5;">
|
| 481 |
+
체크 시 각 청크에 챕터 번호, 화자(POV), 등장인물, 시간적 배경 정보가 자동으로 추출되어 추가됩니다.<br>
|
| 482 |
+
<span style="color: #c5221f;">⚠️ 메타데이터 추출은 AI를 사용하므로 시간이 오래 걸릴 수 있습니다.</span>
|
| 483 |
+
</div>
|
| 484 |
+
</div>
|
| 485 |
+
|
| 486 |
<!-- 파일 업로드 -->
|
| 487 |
<div class="file-upload-input-wrapper" id="fileUploadWrapper">
|
| 488 |
<input type="file" id="fileInput" accept=".txt,.md,.pdf,.docx,.epub" multiple>
|
|
|
|
| 583 |
const fileModelSelect = document.getElementById('fileModelSelect');
|
| 584 |
const filesTableBody = document.getElementById('filesTableBody');
|
| 585 |
|
| 586 |
+
// 모델 목록 로드 (관리자용: 모든 모델 표시)
|
| 587 |
async function loadModelsForFiles() {
|
| 588 |
try {
|
| 589 |
+
const response = await fetch('/api/admin/ollama/models');
|
| 590 |
const data = await response.json();
|
| 591 |
|
| 592 |
fileModelSelect.innerHTML = '<option value="">모델을 선택하세요...</option>';
|
|
|
|
| 595 |
data.models.forEach(model => {
|
| 596 |
const option = document.createElement('option');
|
| 597 |
option.value = model.name;
|
| 598 |
+
// 학습된 웹소설 개수가 있으면 표시
|
| 599 |
+
const fileCount = model.file_count || 0;
|
| 600 |
+
const displayText = fileCount > 0
|
| 601 |
+
? `${model.name} (${fileCount}개 학습됨)`
|
| 602 |
+
: model.name;
|
| 603 |
+
option.textContent = displayText;
|
| 604 |
fileModelSelect.appendChild(option);
|
| 605 |
});
|
| 606 |
+
} else if (data.error) {
|
| 607 |
+
console.error('모델 로드 오류:', data.error);
|
| 608 |
+
fileModelSelect.innerHTML = '<option value="">모델을 불러올 수 없습니다</option>';
|
| 609 |
}
|
| 610 |
} catch (error) {
|
| 611 |
console.error('모델 로드 오류:', error);
|
| 612 |
+
fileModelSelect.innerHTML = '<option value="">모델 로드 실패</option>';
|
| 613 |
}
|
| 614 |
}
|
| 615 |
|
|
|
|
| 668 |
<td>${uploadDate}</td>
|
| 669 |
<td>
|
| 670 |
<div class="file-actions">
|
| 671 |
+
${(file.has_parent_chunk !== undefined && file.has_parent_chunk) ?
|
| 672 |
+
`<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>` :
|
| 673 |
+
`<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>`
|
| 674 |
+
}
|
| 675 |
<button class="btn btn-primary" onclick="continueUpload(${file.id})" style="padding: 4px 8px; font-size: 12px; margin-right: 4px;">이어서 업로드</button>
|
| 676 |
<button class="btn btn-secondary" onclick="deleteFile(${file.id})" style="padding: 4px 8px; font-size: 12px;">삭제</button>
|
| 677 |
</div>
|
|
|
|
| 776 |
const formData = new FormData();
|
| 777 |
formData.append('file', file);
|
| 778 |
formData.append('model_name', modelName);
|
| 779 |
+
// 메타데이터 추출 옵션 추가
|
| 780 |
+
const extractMetadata = document.getElementById('extractMetadataCheckbox').checked;
|
| 781 |
+
formData.append('extract_metadata', extractMetadata ? 'true' : 'false');
|
| 782 |
|
| 783 |
// 이어서 업로드인 경우 parent_file_id 추가
|
| 784 |
if (continueUploadFileId) {
|
|
|
|
| 1031 |
}
|
| 1032 |
}
|
| 1033 |
|
| 1034 |
+
// Parent Chunk 수동 생성
|
| 1035 |
+
async function createParentChunk(fileId, fileName) {
|
| 1036 |
+
if (!confirm(`"${fileName}" 파일에 대해 Parent Chunk를 생성하시겠습니까?\n\n이 작업은 몇 분이 걸릴 수 있으며, AI 모델을 사용합니다.`)) {
|
| 1037 |
+
return;
|
| 1038 |
+
}
|
| 1039 |
+
|
| 1040 |
+
const modal = document.getElementById('parentChunkModal');
|
| 1041 |
+
const modalTitle = document.getElementById('parentChunkModalTitle');
|
| 1042 |
+
const modalBody = document.getElementById('parentChunkModalBody');
|
| 1043 |
+
|
| 1044 |
+
modalTitle.textContent = `Parent Chunk 생성 중 - ${fileName}`;
|
| 1045 |
+
modalBody.innerHTML = '<div class="parent-chunk-loading">Parent Chunk를 생성하고 있습니다. 이 작업은 몇 분이 걸릴 수 있습니다. 잠시만 기다려주세요...</div>';
|
| 1046 |
+
modal.classList.add('active');
|
| 1047 |
+
|
| 1048 |
+
try {
|
| 1049 |
+
const response = await fetch(`/api/files/${fileId}/parent-chunk`, {
|
| 1050 |
+
method: 'POST',
|
| 1051 |
+
headers: {
|
| 1052 |
+
'Content-Type': 'application/json'
|
| 1053 |
+
}
|
| 1054 |
+
});
|
| 1055 |
+
const data = await response.json();
|
| 1056 |
+
|
| 1057 |
+
if (response.ok) {
|
| 1058 |
+
if (data.has_parent_chunk && data.parent_chunk) {
|
| 1059 |
+
// 생성 성공 - Parent Chunk 내용 표시
|
| 1060 |
+
const chunk = data.parent_chunk;
|
| 1061 |
+
let html = '<div style="color: #137333; margin-bottom: 16px; padding: 12px; background: #e8f5e9; border-radius: 6px;">✅ Parent Chunk가 성공적으로 생성되었습니다.</div>';
|
| 1062 |
+
|
| 1063 |
+
if (chunk.world_view) {
|
| 1064 |
+
html += `
|
| 1065 |
+
<div class="parent-chunk-section">
|
| 1066 |
+
<div class="parent-chunk-section-title">🌍 세계관 설명</div>
|
| 1067 |
+
<div class="parent-chunk-section-content">${escapeHtml(chunk.world_view)}</div>
|
| 1068 |
+
</div>
|
| 1069 |
+
`;
|
| 1070 |
+
}
|
| 1071 |
+
|
| 1072 |
+
if (chunk.characters) {
|
| 1073 |
+
html += `
|
| 1074 |
+
<div class="parent-chunk-section">
|
| 1075 |
+
<div class="parent-chunk-section-title">👥 주요 캐릭터 분석</div>
|
| 1076 |
+
<div class="parent-chunk-section-content">${escapeHtml(chunk.characters)}</div>
|
| 1077 |
+
</div>
|
| 1078 |
+
`;
|
| 1079 |
+
}
|
| 1080 |
+
|
| 1081 |
+
if (chunk.story) {
|
| 1082 |
+
html += `
|
| 1083 |
+
<div class="parent-chunk-section">
|
| 1084 |
+
<div class="parent-chunk-section-title">📖 주요 스토리 분석</div>
|
| 1085 |
+
<div class="parent-chunk-section-content">${escapeHtml(chunk.story)}</div>
|
| 1086 |
+
</div>
|
| 1087 |
+
`;
|
| 1088 |
+
}
|
| 1089 |
+
|
| 1090 |
+
if (chunk.episodes) {
|
| 1091 |
+
html += `
|
| 1092 |
+
<div class="parent-chunk-section">
|
| 1093 |
+
<div class="parent-chunk-section-title">📚 주요 에피소드 분석</div>
|
| 1094 |
+
<div class="parent-chunk-section-content">${escapeHtml(chunk.episodes)}</div>
|
| 1095 |
+
</div>
|
| 1096 |
+
`;
|
| 1097 |
+
}
|
| 1098 |
+
|
| 1099 |
+
if (chunk.others) {
|
| 1100 |
+
html += `
|
| 1101 |
+
<div class="parent-chunk-section">
|
| 1102 |
+
<div class="parent-chunk-section-title">📝 기타</div>
|
| 1103 |
+
<div class="parent-chunk-section-content">${escapeHtml(chunk.others)}</div>
|
| 1104 |
+
</div>
|
| 1105 |
+
`;
|
| 1106 |
+
}
|
| 1107 |
+
|
| 1108 |
+
modalTitle.textContent = `Parent Chunk 확인 - ${fileName}`;
|
| 1109 |
+
modalBody.innerHTML = html;
|
| 1110 |
+
|
| 1111 |
+
// 파일 목록 새로고침
|
| 1112 |
+
await loadFiles();
|
| 1113 |
+
} else {
|
| 1114 |
+
modalBody.innerHTML = '<div class="parent-chunk-empty" style="color: #c5221f;">Parent Chunk 생성에 실패했습니다. 서버 로그를 확인하세요.</div>';
|
| 1115 |
+
}
|
| 1116 |
+
} else {
|
| 1117 |
+
modalBody.innerHTML = `<div class="parent-chunk-empty" style="color: #c5221f;">오류: ${data.error || 'Parent Chunk 생성 중 오류가 발생했습니다.'}</div>`;
|
| 1118 |
+
}
|
| 1119 |
+
} catch (error) {
|
| 1120 |
+
modalBody.innerHTML = `<div class="parent-chunk-empty" style="color: #c5221f;">오류: ${error.message}</div>`;
|
| 1121 |
+
console.error('Parent Chunk 생성 오류:', error);
|
| 1122 |
+
}
|
| 1123 |
+
}
|
| 1124 |
+
|
| 1125 |
// Parent Chunk 모달 닫기
|
| 1126 |
function closeParentChunkModal() {
|
| 1127 |
const modal = document.getElementById('parentChunkModal');
|
templates/index.html
CHANGED
|
@@ -553,8 +553,14 @@
|
|
| 553 |
white-space: pre-wrap;
|
| 554 |
}
|
| 555 |
|
| 556 |
-
.message-bubble
|
| 557 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 558 |
}
|
| 559 |
|
| 560 |
.message.user .message-bubble {
|
|
@@ -568,6 +574,93 @@
|
|
| 568 |
color: var(--text-primary);
|
| 569 |
border-bottom-left-radius: 4px;
|
| 570 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 571 |
|
| 572 |
.message-time {
|
| 573 |
font-size: 12px;
|
|
@@ -1219,26 +1312,36 @@
|
|
| 1219 |
}
|
| 1220 |
});
|
| 1221 |
|
| 1222 |
-
//
|
| 1223 |
-
function
|
| 1224 |
-
if (!
|
| 1225 |
|
| 1226 |
-
// HTML
|
| 1227 |
-
let
|
| 1228 |
-
.replace(/&/g, '&')
|
| 1229 |
-
.replace(/</g, '<')
|
| 1230 |
-
.replace(/>/g, '>');
|
| 1231 |
|
| 1232 |
-
//
|
| 1233 |
-
|
| 1234 |
-
|
|
|
|
| 1235 |
|
| 1236 |
-
//
|
| 1237 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1238 |
|
| 1239 |
-
return
|
| 1240 |
}
|
| 1241 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1242 |
// 메시지 추가
|
| 1243 |
function addMessage(role, content, save = true) {
|
| 1244 |
// 빈 상태 숨기기
|
|
@@ -1258,7 +1361,28 @@
|
|
| 1258 |
|
| 1259 |
const bubble = document.createElement('div');
|
| 1260 |
bubble.className = 'message-bubble';
|
| 1261 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1262 |
|
| 1263 |
const time = document.createElement('div');
|
| 1264 |
time.className = 'message-time';
|
|
|
|
| 553 |
white-space: pre-wrap;
|
| 554 |
}
|
| 555 |
|
| 556 |
+
.message.ai .message-bubble {
|
| 557 |
+
white-space: normal;
|
| 558 |
+
}
|
| 559 |
+
|
| 560 |
+
.message.ai .message-bubble br {
|
| 561 |
+
display: block;
|
| 562 |
+
content: "";
|
| 563 |
+
margin: 0;
|
| 564 |
}
|
| 565 |
|
| 566 |
.message.user .message-bubble {
|
|
|
|
| 574 |
color: var(--text-primary);
|
| 575 |
border-bottom-left-radius: 4px;
|
| 576 |
}
|
| 577 |
+
|
| 578 |
+
/* 각주 스타일 */
|
| 579 |
+
.footnote-ref {
|
| 580 |
+
font-size: 0.75em;
|
| 581 |
+
vertical-align: super;
|
| 582 |
+
color: var(--accent);
|
| 583 |
+
cursor: help;
|
| 584 |
+
text-decoration: underline;
|
| 585 |
+
text-decoration-style: dotted;
|
| 586 |
+
position: relative;
|
| 587 |
+
font-weight: 500;
|
| 588 |
+
display: inline-block;
|
| 589 |
+
}
|
| 590 |
+
|
| 591 |
+
.footnote-ref:hover {
|
| 592 |
+
color: var(--accent-hover);
|
| 593 |
+
}
|
| 594 |
+
|
| 595 |
+
/* 각주 툴팁 */
|
| 596 |
+
.footnote-tooltip {
|
| 597 |
+
position: absolute;
|
| 598 |
+
bottom: calc(100% + 8px);
|
| 599 |
+
left: 50%;
|
| 600 |
+
transform: translateX(-50%);
|
| 601 |
+
background: rgba(0, 0, 0, 0.95);
|
| 602 |
+
color: white;
|
| 603 |
+
padding: 8px 12px;
|
| 604 |
+
border-radius: 8px;
|
| 605 |
+
font-size: 0.75em;
|
| 606 |
+
max-width: 350px;
|
| 607 |
+
min-width: 200px;
|
| 608 |
+
word-wrap: break-word;
|
| 609 |
+
white-space: normal;
|
| 610 |
+
opacity: 0;
|
| 611 |
+
pointer-events: none;
|
| 612 |
+
transition: opacity 0.2s ease-in-out;
|
| 613 |
+
z-index: 10000;
|
| 614 |
+
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
|
| 615 |
+
line-height: 1.4;
|
| 616 |
+
text-align: left;
|
| 617 |
+
}
|
| 618 |
+
|
| 619 |
+
.footnote-tooltip::after {
|
| 620 |
+
content: '';
|
| 621 |
+
position: absolute;
|
| 622 |
+
top: 100%;
|
| 623 |
+
left: 50%;
|
| 624 |
+
transform: translateX(-50%);
|
| 625 |
+
border: 6px solid transparent;
|
| 626 |
+
border-top-color: rgba(0, 0, 0, 0.95);
|
| 627 |
+
}
|
| 628 |
+
|
| 629 |
+
.footnote-ref:hover .footnote-tooltip {
|
| 630 |
+
opacity: 1;
|
| 631 |
+
}
|
| 632 |
+
|
| 633 |
+
/* 각주 번호가 화면 위쪽에 있으면 툴팁을 아래쪽에 표시 */
|
| 634 |
+
.message-bubble {
|
| 635 |
+
position: relative;
|
| 636 |
+
}
|
| 637 |
+
|
| 638 |
+
/* 각주 목록 컨테이너 */
|
| 639 |
+
.footnotes-container {
|
| 640 |
+
margin-top: 12px;
|
| 641 |
+
padding-top: 12px;
|
| 642 |
+
border-top: 1px solid var(--border);
|
| 643 |
+
font-size: 0.85em;
|
| 644 |
+
color: var(--text-secondary);
|
| 645 |
+
}
|
| 646 |
+
|
| 647 |
+
.footnote-item {
|
| 648 |
+
margin-bottom: 6px;
|
| 649 |
+
line-height: 1.4;
|
| 650 |
+
display: flex;
|
| 651 |
+
align-items: flex-start;
|
| 652 |
+
gap: 6px;
|
| 653 |
+
}
|
| 654 |
+
|
| 655 |
+
.footnote-item sup {
|
| 656 |
+
font-size: 0.9em;
|
| 657 |
+
color: var(--accent);
|
| 658 |
+
font-weight: 500;
|
| 659 |
+
}
|
| 660 |
+
|
| 661 |
+
.footnote-item span {
|
| 662 |
+
flex: 1;
|
| 663 |
+
}
|
| 664 |
|
| 665 |
.message-time {
|
| 666 |
font-size: 12px;
|
|
|
|
| 1312 |
}
|
| 1313 |
});
|
| 1314 |
|
| 1315 |
+
// [근거: ] 형식을 각주로 변환하는 함수
|
| 1316 |
+
function formatContentWithFootnotes(content) {
|
| 1317 |
+
if (!content) return { formattedContent: '', footnotes: [] };
|
| 1318 |
|
| 1319 |
+
// 일반 텍스트를 HTML로 변환 (줄바꿈 처리)
|
| 1320 |
+
let htmlContent = escapeHtml(content).replace(/\n/g, '<br>');
|
|
|
|
|
|
|
|
|
|
| 1321 |
|
| 1322 |
+
// [근거: 내용] 패턴 찾기
|
| 1323 |
+
const footnotePattern = /\[근거:\s*([^\]]+)\]/g;
|
| 1324 |
+
const footnotes = [];
|
| 1325 |
+
let footnoteIndex = 0;
|
| 1326 |
|
| 1327 |
+
// 각 [근거: ] 부분을 찾아서 각주 번호로 치환
|
| 1328 |
+
let formattedContent = htmlContent.replace(footnotePattern, (match, footnoteText) => {
|
| 1329 |
+
footnoteIndex++;
|
| 1330 |
+
const cleanText = footnoteText.trim();
|
| 1331 |
+
footnotes.push(cleanText);
|
| 1332 |
+
return `<sup class="footnote-ref" data-footnote="${footnoteIndex}">[${footnoteIndex}]<span class="footnote-tooltip">${escapeHtml(cleanText)}</span></sup>`;
|
| 1333 |
+
});
|
| 1334 |
|
| 1335 |
+
return { formattedContent, footnotes };
|
| 1336 |
}
|
| 1337 |
|
| 1338 |
+
// HTML 이스케이프 헬퍼
|
| 1339 |
+
function escapeHtml(text) {
|
| 1340 |
+
const div = document.createElement('div');
|
| 1341 |
+
div.textContent = text;
|
| 1342 |
+
return div.innerHTML;
|
| 1343 |
+
}
|
| 1344 |
+
|
| 1345 |
// 메시지 추가
|
| 1346 |
function addMessage(role, content, save = true) {
|
| 1347 |
// 빈 상태 숨기기
|
|
|
|
| 1361 |
|
| 1362 |
const bubble = document.createElement('div');
|
| 1363 |
bubble.className = 'message-bubble';
|
| 1364 |
+
|
| 1365 |
+
// AI 응답인 경우 [근거: ] 형식을 각주로 변환
|
| 1366 |
+
if (role === 'ai') {
|
| 1367 |
+
const { formattedContent, footnotes } = formatContentWithFootnotes(content);
|
| 1368 |
+
bubble.innerHTML = formattedContent;
|
| 1369 |
+
|
| 1370 |
+
// 각주가 있으면 각주 목록 추가
|
| 1371 |
+
if (footnotes.length > 0) {
|
| 1372 |
+
const footnoteContainer = document.createElement('div');
|
| 1373 |
+
footnoteContainer.className = 'footnotes-container';
|
| 1374 |
+
footnotes.forEach((note, index) => {
|
| 1375 |
+
const footnoteDiv = document.createElement('div');
|
| 1376 |
+
footnoteDiv.className = 'footnote-item';
|
| 1377 |
+
footnoteDiv.innerHTML = `<sup>[${index + 1}]</sup> <span>${escapeHtml(note)}</span>`;
|
| 1378 |
+
footnoteContainer.appendChild(footnoteDiv);
|
| 1379 |
+
});
|
| 1380 |
+
bubble.appendChild(footnoteContainer);
|
| 1381 |
+
}
|
| 1382 |
+
} else {
|
| 1383 |
+
// 사용자 메시지는 일반 텍스트
|
| 1384 |
+
bubble.textContent = content;
|
| 1385 |
+
}
|
| 1386 |
|
| 1387 |
const time = document.createElement('div');
|
| 1388 |
time.className = 'message-time';
|
templates/login.html
CHANGED
|
@@ -160,3 +160,7 @@
|
|
| 160 |
</html>
|
| 161 |
|
| 162 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 160 |
</html>
|
| 161 |
|
| 162 |
|
| 163 |
+
|
| 164 |
+
|
| 165 |
+
|
| 166 |
+
|