SOY NV AI
commited on
Commit
·
9fa456d
1
Parent(s):
9d377df
feat: Add webnovel continue upload feature and improve RAG
Browse files- Add parent_file_id field to UploadedFile model for series management
- Implement continue upload functionality for webnovels
- Auto-delete related episodes when parent file is deleted
- Display hierarchical file structure in admin page
- Include child files automatically in RAG search
- Auto-migrate database schema (add parent_file_id column)
- Set Chunk Overlap to 250
- Improve admin page UI with continue upload button
- Enhance chat session title auto-update
- Prevent duplicate message saving
- GIT_SETUP.md +69 -0
- README_SERVER.md +1 -0
- app/__init__.py +31 -4
- app/database.py +7 -1
- app/routes.py +498 -82
- reset_upload_data.py +133 -0
- run.py +26 -0
- setup_remote.ps1 +62 -0
- start_server.bat +1 -0
- start_server.ps1 +1 -0
- start_server_background.ps1 +1 -0
- templates/admin.html +282 -19
- templates/admin_messages.html +1 -0
- templates/index.html +11 -21
- templates/login.html +1 -0
GIT_SETUP.md
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Git 원격 저장소 설정 가이드
|
| 2 |
+
|
| 3 |
+
## 현재 상태
|
| 4 |
+
- ✅ 로컬 Git 저장소 초기화 완료
|
| 5 |
+
- ✅ 모든 파일 커밋 완료
|
| 6 |
+
- ✅ master 브랜치 준비 완료
|
| 7 |
+
|
| 8 |
+
## 원격 저장소 설정 방법
|
| 9 |
+
|
| 10 |
+
### 1. GitHub에 새 저장소 만들기
|
| 11 |
+
|
| 12 |
+
1. GitHub에 로그인: https://github.com
|
| 13 |
+
2. 우측 상단의 **+** 버튼 클릭 → **New repository** 선택
|
| 14 |
+
3. 저장소 정보 입력:
|
| 15 |
+
- Repository name: `soy-nv-ai` (또는 원하는 이름)
|
| 16 |
+
- Description: `SOY NV AI - Web novel training system with RAG functionality`
|
| 17 |
+
- Public 또는 Private 선택
|
| 18 |
+
- **⚠️ 중요: "Initialize this repository with a README" 체크하지 마세요!**
|
| 19 |
+
4. **Create repository** 클릭
|
| 20 |
+
|
| 21 |
+
### 2. 원격 저장소 연결 및 푸시
|
| 22 |
+
|
| 23 |
+
#### 방법 1: PowerShell 스크립트 사용 (권장)
|
| 24 |
+
```powershell
|
| 25 |
+
.\setup_remote.ps1
|
| 26 |
+
```
|
| 27 |
+
스크립트가 원격 저장소 URL을 입력받고 자동으로 설정합니다.
|
| 28 |
+
|
| 29 |
+
#### 방법 2: 수동으로 설정
|
| 30 |
+
```powershell
|
| 31 |
+
# 원격 저장소 추가 (GitHub 저장소 URL로 변경)
|
| 32 |
+
git remote add origin https://github.com/사용자명/저장소명.git
|
| 33 |
+
|
| 34 |
+
# 원격 저장소 확인
|
| 35 |
+
git remote -v
|
| 36 |
+
|
| 37 |
+
# 마스터 브랜치 푸시
|
| 38 |
+
git push -u origin master
|
| 39 |
+
```
|
| 40 |
+
|
| 41 |
+
### 3. GitHub CLI 사용 (선택사항)
|
| 42 |
+
|
| 43 |
+
GitHub CLI가 설치되어 있다면:
|
| 44 |
+
```powershell
|
| 45 |
+
# 새 저장소 생성 및 푸시
|
| 46 |
+
gh repo create soy-nv-ai --public --source=. --remote=origin --push
|
| 47 |
+
```
|
| 48 |
+
|
| 49 |
+
## 주의사항
|
| 50 |
+
|
| 51 |
+
- `.gitignore` 파일에 다음이 포함되어 있습니다:
|
| 52 |
+
- `venv/` - 가상환경
|
| 53 |
+
- `instance/` - 데이터베이스 파일
|
| 54 |
+
- `uploads/*` - 업로드된 파일
|
| 55 |
+
- `__pycache__/` - Python 캐시
|
| 56 |
+
|
| 57 |
+
- 민감한 정보는 커밋하지 마세요:
|
| 58 |
+
- 비밀번호
|
| 59 |
+
- API 키
|
| 60 |
+
- 데이터베이스 파일
|
| 61 |
+
|
| 62 |
+
## 다음 단계
|
| 63 |
+
|
| 64 |
+
원격 저장소 설정 후:
|
| 65 |
+
1. GitHub에서 저장소 확인
|
| 66 |
+
2. 필요시 `.env` 파일을 추가하여 환경 변수 관리
|
| 67 |
+
3. README.md 업데이트 (프로젝트 설명 추가)
|
| 68 |
+
|
| 69 |
+
|
README_SERVER.md
CHANGED
|
@@ -66,3 +66,4 @@ Start-Process powershell -ArgumentList "-File", "start_server_background.ps1" -W
|
|
| 66 |
1. PowerShell 창에서 `Ctrl+C` 누르기
|
| 67 |
2. 또는 작업 관리자에서 Python 프로세스 종료
|
| 68 |
|
|
|
|
|
|
| 66 |
1. PowerShell 창에서 `Ctrl+C` 누르기
|
| 67 |
2. 또는 작업 관리자에서 Python 프로세스 종료
|
| 68 |
|
| 69 |
+
|
app/__init__.py
CHANGED
|
@@ -21,6 +21,7 @@ def create_app():
|
|
| 21 |
app.config['SECRET_KEY'] = os.getenv('SECRET_KEY', 'dev-secret-key-change-in-production')
|
| 22 |
app.config['SQLALCHEMY_DATABASE_URI'] = os.getenv('DATABASE_URL', 'sqlite:///finance_analysis.db')
|
| 23 |
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
|
|
|
|
| 24 |
|
| 25 |
db.init_app(app)
|
| 26 |
login_manager.init_app(app)
|
|
@@ -38,7 +39,7 @@ def create_app():
|
|
| 38 |
return app
|
| 39 |
|
| 40 |
def migrate_database(app):
|
| 41 |
-
"""데이터베이스 마이그레이션
|
| 42 |
try:
|
| 43 |
# 데이터베이스 URI에서 경로 추출
|
| 44 |
db_uri = app.config['SQLALCHEMY_DATABASE_URI']
|
|
@@ -49,6 +50,7 @@ def migrate_database(app):
|
|
| 49 |
db_path = os.path.join(app.instance_path, db_path)
|
| 50 |
|
| 51 |
if not os.path.exists(db_path):
|
|
|
|
| 52 |
return
|
| 53 |
|
| 54 |
conn = sqlite3.connect(db_path)
|
|
@@ -56,15 +58,40 @@ def migrate_database(app):
|
|
| 56 |
|
| 57 |
# user 테이블에 nickname 컬럼이 있는지 확인
|
| 58 |
cursor.execute("PRAGMA table_info(user)")
|
| 59 |
-
|
| 60 |
|
| 61 |
-
if 'nickname' not in
|
|
|
|
| 62 |
cursor.execute("ALTER TABLE user ADD COLUMN nickname VARCHAR(80)")
|
| 63 |
conn.commit()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 64 |
|
| 65 |
conn.close()
|
|
|
|
| 66 |
except Exception as e:
|
| 67 |
-
print(f"마이그레이션 오류
|
|
|
|
|
|
|
| 68 |
|
| 69 |
def create_admin_user():
|
| 70 |
"""초기 관리자 계정 생성"""
|
|
|
|
| 21 |
app.config['SECRET_KEY'] = os.getenv('SECRET_KEY', 'dev-secret-key-change-in-production')
|
| 22 |
app.config['SQLALCHEMY_DATABASE_URI'] = os.getenv('DATABASE_URL', 'sqlite:///finance_analysis.db')
|
| 23 |
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
|
| 24 |
+
app.config['MAX_CONTENT_LENGTH'] = 100 * 1024 * 1024 # 100MB 파일 크기 제한
|
| 25 |
|
| 26 |
db.init_app(app)
|
| 27 |
login_manager.init_app(app)
|
|
|
|
| 39 |
return app
|
| 40 |
|
| 41 |
def migrate_database(app):
|
| 42 |
+
"""데이터베이스 마이그레이션"""
|
| 43 |
try:
|
| 44 |
# 데이터베이스 URI에서 경로 추출
|
| 45 |
db_uri = app.config['SQLALCHEMY_DATABASE_URI']
|
|
|
|
| 50 |
db_path = os.path.join(app.instance_path, db_path)
|
| 51 |
|
| 52 |
if not os.path.exists(db_path):
|
| 53 |
+
print(f"[마이그레이션] 데이터베이스 파일이 없습니다: {db_path}")
|
| 54 |
return
|
| 55 |
|
| 56 |
conn = sqlite3.connect(db_path)
|
|
|
|
| 58 |
|
| 59 |
# user 테이블에 nickname 컬럼이 있는지 확인
|
| 60 |
cursor.execute("PRAGMA table_info(user)")
|
| 61 |
+
user_columns = [column[1] for column in cursor.fetchall()]
|
| 62 |
|
| 63 |
+
if 'nickname' not in user_columns:
|
| 64 |
+
print("[마이그레이션] user 테이블에 nickname 컬럼 추가 중...")
|
| 65 |
cursor.execute("ALTER TABLE user ADD COLUMN nickname VARCHAR(80)")
|
| 66 |
conn.commit()
|
| 67 |
+
print("[마이그레이션] user.nickname 컬럼 추가 완료")
|
| 68 |
+
|
| 69 |
+
# uploaded_file 테이블이 존재하는지 확인
|
| 70 |
+
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='uploaded_file'")
|
| 71 |
+
if cursor.fetchone():
|
| 72 |
+
# uploaded_file 테이블에 uploaded_by 컬럼이 있는지 확인
|
| 73 |
+
cursor.execute("PRAGMA table_info(uploaded_file)")
|
| 74 |
+
uploaded_file_columns = [column[1] for column in cursor.fetchall()]
|
| 75 |
+
|
| 76 |
+
if 'uploaded_by' not in uploaded_file_columns:
|
| 77 |
+
print("[마이그레이션] uploaded_file 테이블에 uploaded_by 컬럼 추가 중...")
|
| 78 |
+
cursor.execute("ALTER TABLE uploaded_file ADD COLUMN uploaded_by INTEGER")
|
| 79 |
+
conn.commit()
|
| 80 |
+
print("[마이그레이션] uploaded_file.uploaded_by 컬럼 추가 완료")
|
| 81 |
+
|
| 82 |
+
# uploaded_file 테이블에 parent_file_id 컬럼이 있는지 확인
|
| 83 |
+
if 'parent_file_id' not in uploaded_file_columns:
|
| 84 |
+
print("[마이그레이션] uploaded_file 테이블에 parent_file_id 컬럼 추가 중...")
|
| 85 |
+
cursor.execute("ALTER TABLE uploaded_file ADD COLUMN parent_file_id INTEGER")
|
| 86 |
+
conn.commit()
|
| 87 |
+
print("[마이그레이션] uploaded_file.parent_file_id 컬럼 추가 완료")
|
| 88 |
|
| 89 |
conn.close()
|
| 90 |
+
print("[마이그레이션] 데이터베이스 마이그레이션 완료")
|
| 91 |
except Exception as e:
|
| 92 |
+
print(f"[마이그레이션] 오류 발생: {e}")
|
| 93 |
+
import traceback
|
| 94 |
+
traceback.print_exc()
|
| 95 |
|
| 96 |
def create_admin_user():
|
| 97 |
"""초기 관리자 계정 생성"""
|
app/database.py
CHANGED
|
@@ -43,6 +43,10 @@ class UploadedFile(db.Model):
|
|
| 43 |
model_name = db.Column(db.String(100), nullable=True) # 연결된 모델 이름
|
| 44 |
uploaded_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
|
| 45 |
uploaded_by = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=True)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
|
| 47 |
def to_dict(self):
|
| 48 |
return {
|
|
@@ -52,7 +56,9 @@ class UploadedFile(db.Model):
|
|
| 52 |
'file_size': self.file_size,
|
| 53 |
'model_name': self.model_name,
|
| 54 |
'uploaded_at': self.uploaded_at.isoformat() if self.uploaded_at else None,
|
| 55 |
-
'uploaded_by': self.uploaded_by
|
|
|
|
|
|
|
| 56 |
}
|
| 57 |
|
| 58 |
# 대화 세션 모델
|
|
|
|
| 43 |
model_name = db.Column(db.String(100), nullable=True) # 연결된 모델 이름
|
| 44 |
uploaded_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
|
| 45 |
uploaded_by = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=True)
|
| 46 |
+
parent_file_id = db.Column(db.Integer, db.ForeignKey('uploaded_file.id'), nullable=True) # 이어서 업로드한 경우 원본 파일 ID
|
| 47 |
+
|
| 48 |
+
# 관계
|
| 49 |
+
parent_file = db.relationship('UploadedFile', remote_side=[id], backref='child_files')
|
| 50 |
|
| 51 |
def to_dict(self):
|
| 52 |
return {
|
|
|
|
| 56 |
'file_size': self.file_size,
|
| 57 |
'model_name': self.model_name,
|
| 58 |
'uploaded_at': self.uploaded_at.isoformat() if self.uploaded_at else None,
|
| 59 |
+
'uploaded_by': self.uploaded_by,
|
| 60 |
+
'parent_file_id': self.parent_file_id,
|
| 61 |
+
'child_count': len(self.child_files) if self.child_files else 0
|
| 62 |
}
|
| 63 |
|
| 64 |
# 대화 세션 모델
|
app/routes.py
CHANGED
|
@@ -30,16 +30,42 @@ OLLAMA_BASE_URL = os.getenv('OLLAMA_BASE_URL', 'http://localhost:11434')
|
|
| 30 |
UPLOAD_FOLDER = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'uploads')
|
| 31 |
ALLOWED_EXTENSIONS = {'txt', 'md', 'pdf', 'docx', 'epub'}
|
| 32 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
def allowed_file(filename):
|
| 34 |
return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS
|
| 35 |
|
| 36 |
def ensure_upload_folder():
|
| 37 |
"""업로드 폴더가 없으면 생성"""
|
| 38 |
-
|
| 39 |
-
os.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
|
| 41 |
-
def split_text_into_chunks(text, chunk_size=
|
| 42 |
-
"""텍스트를 청크로 분할"""
|
| 43 |
chunks = []
|
| 44 |
start = 0
|
| 45 |
while start < len(text):
|
|
@@ -55,8 +81,8 @@ def create_chunks_for_file(file_id, content):
|
|
| 55 |
# 기존 청크 삭제
|
| 56 |
DocumentChunk.query.filter_by(file_id=file_id).delete()
|
| 57 |
|
| 58 |
-
# 텍스트를 청크로 분할
|
| 59 |
-
chunks = split_text_into_chunks(content, chunk_size=
|
| 60 |
|
| 61 |
# 각 청크를 데이터베이스에 저장
|
| 62 |
for idx, chunk_content in enumerate(chunks):
|
|
@@ -74,17 +100,33 @@ def create_chunks_for_file(file_id, content):
|
|
| 74 |
print(f"청크 생성 오류: {str(e)}")
|
| 75 |
return 0
|
| 76 |
|
| 77 |
-
def search_relevant_chunks(query, file_ids=None, model_name=None, top_k=
|
| 78 |
-
"""질문과 관련된 청크 검색 (키워드 기반)"""
|
| 79 |
try:
|
| 80 |
-
# 검색 쿼리 준비
|
| 81 |
-
query_words = set(re.findall(r'
|
|
|
|
|
|
|
|
|
|
| 82 |
|
| 83 |
# 청크 조회
|
| 84 |
query_obj = DocumentChunk.query.join(UploadedFile)
|
| 85 |
|
| 86 |
if file_ids:
|
| 87 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 88 |
|
| 89 |
if model_name:
|
| 90 |
query_obj = query_obj.filter(UploadedFile.model_name == model_name)
|
|
@@ -94,22 +136,54 @@ def search_relevant_chunks(query, file_ids=None, model_name=None, top_k=5):
|
|
| 94 |
if not all_chunks:
|
| 95 |
return []
|
| 96 |
|
| 97 |
-
# 각 청크의 관련도 점수 계산 (
|
| 98 |
scored_chunks = []
|
| 99 |
for chunk in all_chunks:
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 105 |
|
| 106 |
# 점수 순으로 정렬하고 상위 k개 선택
|
| 107 |
scored_chunks.sort(key=lambda x: x[0], reverse=True)
|
| 108 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 109 |
|
| 110 |
return top_chunks
|
| 111 |
except Exception as e:
|
| 112 |
print(f"청크 검색 오류: {str(e)}")
|
|
|
|
|
|
|
| 113 |
return []
|
| 114 |
|
| 115 |
@main_bp.route('/login', methods=['GET', 'POST'])
|
|
@@ -382,13 +456,20 @@ def chat():
|
|
| 382 |
|
| 383 |
if use_rag:
|
| 384 |
# 관련 청크 검색
|
|
|
|
|
|
|
|
|
|
|
|
|
| 385 |
relevant_chunks = search_relevant_chunks(
|
| 386 |
query=message,
|
| 387 |
file_ids=file_ids if file_ids else None,
|
| 388 |
model_name=model,
|
| 389 |
-
top_k=5
|
|
|
|
| 390 |
)
|
| 391 |
|
|
|
|
|
|
|
| 392 |
if relevant_chunks:
|
| 393 |
context_parts = []
|
| 394 |
seen_files = set()
|
|
@@ -397,29 +478,61 @@ def chat():
|
|
| 397 |
file = chunk.file
|
| 398 |
if file.original_filename not in seen_files:
|
| 399 |
seen_files.add(file.original_filename)
|
|
|
|
| 400 |
|
| 401 |
context_parts.append(f"[{file.original_filename} - 청크 {chunk.chunk_index + 1}]\n{chunk.content}")
|
| 402 |
|
| 403 |
if context_parts:
|
| 404 |
-
|
| 405 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 406 |
else:
|
| 407 |
# RAG 검색 결과가 없으면 기존 방식 사용
|
|
|
|
| 408 |
use_rag = False
|
| 409 |
|
| 410 |
# RAG 검색 결과가 없거나 비활성화된 경우 기존 방식 사용
|
| 411 |
if not context and not use_rag:
|
| 412 |
if file_ids:
|
| 413 |
-
# 선택한 파일 ID
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 414 |
uploaded_files = UploadedFile.query.filter(
|
| 415 |
-
UploadedFile.id.in_(
|
| 416 |
UploadedFile.model_name == model
|
| 417 |
).all()
|
|
|
|
| 418 |
else:
|
| 419 |
-
# 파일 ID가 없으면 해당 모델의 모든 파일 사용 (
|
| 420 |
uploaded_files = UploadedFile.query.filter_by(model_name=model).all()
|
|
|
|
| 421 |
|
| 422 |
if uploaded_files:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 423 |
context_parts = []
|
| 424 |
for file in uploaded_files:
|
| 425 |
try:
|
|
@@ -432,9 +545,9 @@ def chat():
|
|
| 432 |
with open(file.file_path, 'r', encoding='cp949') as f:
|
| 433 |
file_content = f.read()
|
| 434 |
|
| 435 |
-
# 파일 내용이 너무 길면 일부만 사용 (최대
|
| 436 |
-
if len(file_content) >
|
| 437 |
-
file_content = file_content[:
|
| 438 |
|
| 439 |
context_parts.append(f"[{file.original_filename}]\n{file_content}")
|
| 440 |
except Exception as e:
|
|
@@ -473,13 +586,48 @@ def chat():
|
|
| 473 |
).first()
|
| 474 |
|
| 475 |
if session:
|
| 476 |
-
# 사용자
|
| 477 |
-
|
|
|
|
| 478 |
session_id=session_id,
|
| 479 |
-
role='user'
|
| 480 |
-
|
| 481 |
-
|
| 482 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 483 |
|
| 484 |
# AI 응답 저장
|
| 485 |
ai_msg = ChatMessage(
|
|
@@ -491,11 +639,19 @@ def chat():
|
|
| 491 |
|
| 492 |
session.updated_at = datetime.utcnow()
|
| 493 |
db.session.commit()
|
|
|
|
|
|
|
|
|
|
| 494 |
except Exception as e:
|
| 495 |
print(f"메시지 저장 오류: {str(e)}")
|
| 496 |
db.session.rollback()
|
|
|
|
| 497 |
|
| 498 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 499 |
else:
|
| 500 |
error_msg = f'Ollama 서버 오류: {ollama_response.status_code}'
|
| 501 |
return jsonify({'error': error_msg}), ollama_response.status_code
|
|
@@ -518,70 +674,260 @@ def chat():
|
|
| 518 |
@login_required
|
| 519 |
def upload_file():
|
| 520 |
"""웹소설 파일 업로드"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 521 |
try:
|
| 522 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 523 |
|
| 524 |
if 'file' not in request.files:
|
| 525 |
-
|
|
|
|
|
|
|
|
|
|
| 526 |
|
| 527 |
file = request.files['file']
|
| 528 |
-
model_name = request.form.get('model_name', '')
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 529 |
|
| 530 |
if file.filename == '':
|
| 531 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 532 |
|
| 533 |
if not allowed_file(file.filename):
|
| 534 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 535 |
|
| 536 |
# 안전한 파일명 생성
|
| 537 |
original_filename = file.filename
|
| 538 |
filename = secure_filename(original_filename)
|
|
|
|
|
|
|
|
|
|
| 539 |
unique_filename = f"{uuid.uuid4().hex}_{filename}"
|
| 540 |
file_path = os.path.join(UPLOAD_FOLDER, unique_filename)
|
| 541 |
|
| 542 |
# 파일 저장
|
| 543 |
-
|
| 544 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 545 |
|
| 546 |
# 데이터베이스에 저장
|
| 547 |
-
|
| 548 |
-
|
| 549 |
-
|
| 550 |
-
|
| 551 |
-
|
| 552 |
-
|
| 553 |
-
|
| 554 |
-
|
| 555 |
-
|
| 556 |
-
|
| 557 |
-
|
| 558 |
-
|
| 559 |
-
|
| 560 |
-
|
| 561 |
-
|
|
|
|
|
|
|
| 562 |
try:
|
| 563 |
-
|
| 564 |
-
|
| 565 |
-
|
| 566 |
-
|
| 567 |
-
|
| 568 |
-
|
| 569 |
-
|
| 570 |
-
|
| 571 |
-
|
| 572 |
-
|
| 573 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 574 |
|
| 575 |
-
|
| 576 |
|
| 577 |
return jsonify({
|
| 578 |
-
'message': '파일이 성공적으로 업로드되었습니다.',
|
| 579 |
-
'file': uploaded_file.to_dict()
|
|
|
|
|
|
|
| 580 |
}), 200
|
| 581 |
|
| 582 |
except Exception as e:
|
| 583 |
db.session.rollback()
|
| 584 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 585 |
|
| 586 |
@main_bp.route('/api/files', methods=['GET'])
|
| 587 |
@login_required
|
|
@@ -590,14 +936,47 @@ def get_files():
|
|
| 590 |
try:
|
| 591 |
model_name = request.args.get('model_name', None)
|
| 592 |
|
| 593 |
-
|
|
|
|
| 594 |
if model_name:
|
| 595 |
query = query.filter_by(model_name=model_name)
|
|
|
|
| 596 |
|
| 597 |
files = query.order_by(UploadedFile.uploaded_at.desc()).all()
|
| 598 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 599 |
return jsonify({
|
| 600 |
-
'files':
|
|
|
|
|
|
|
| 601 |
}), 200
|
| 602 |
|
| 603 |
except Exception as e:
|
|
@@ -606,22 +985,59 @@ def get_files():
|
|
| 606 |
@main_bp.route('/api/files/<int:file_id>', methods=['DELETE'])
|
| 607 |
@login_required
|
| 608 |
def delete_file(file_id):
|
| 609 |
-
"""업로드된 파일 삭제"""
|
| 610 |
try:
|
| 611 |
file = UploadedFile.query.get_or_404(file_id)
|
| 612 |
|
| 613 |
-
#
|
| 614 |
-
|
| 615 |
-
|
| 616 |
|
| 617 |
-
|
| 618 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 619 |
|
| 620 |
-
# 데이터베이스에서 삭제
|
| 621 |
-
db.session.delete(file)
|
| 622 |
db.session.commit()
|
| 623 |
|
| 624 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 625 |
|
| 626 |
except Exception as e:
|
| 627 |
db.session.rollback()
|
|
|
|
| 30 |
UPLOAD_FOLDER = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'uploads')
|
| 31 |
ALLOWED_EXTENSIONS = {'txt', 'md', 'pdf', 'docx', 'epub'}
|
| 32 |
|
| 33 |
+
# 업로드 폴더 경로 출력 (디버깅용)
|
| 34 |
+
print(f"[업로드 설정] 업로드 폴더 경로: {UPLOAD_FOLDER}")
|
| 35 |
+
print(f"[업로드 설정] 업로드 폴더 존재 여부: {os.path.exists(UPLOAD_FOLDER)}")
|
| 36 |
+
|
| 37 |
def allowed_file(filename):
|
| 38 |
return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS
|
| 39 |
|
| 40 |
def ensure_upload_folder():
|
| 41 |
"""업로드 폴더가 없으면 생성"""
|
| 42 |
+
try:
|
| 43 |
+
if not os.path.exists(UPLOAD_FOLDER):
|
| 44 |
+
print(f"업로드 폴더 생성 중: {UPLOAD_FOLDER}")
|
| 45 |
+
os.makedirs(UPLOAD_FOLDER, exist_ok=True)
|
| 46 |
+
|
| 47 |
+
if not os.path.exists(UPLOAD_FOLDER):
|
| 48 |
+
raise Exception(f'업로드 폴더를 생성할 수 없습니다: {UPLOAD_FOLDER}')
|
| 49 |
+
|
| 50 |
+
# 폴더 쓰기 권한 확인
|
| 51 |
+
test_file = os.path.join(UPLOAD_FOLDER, '.write_test')
|
| 52 |
+
try:
|
| 53 |
+
with open(test_file, 'w') as f:
|
| 54 |
+
f.write('test')
|
| 55 |
+
os.remove(test_file)
|
| 56 |
+
print(f"업로드 폴더 쓰기 권한 확인 완료: {UPLOAD_FOLDER}")
|
| 57 |
+
except PermissionError as e:
|
| 58 |
+
raise Exception(f'업로드 폴더에 쓰기 권한이 없습니다: {UPLOAD_FOLDER} - {str(e)}')
|
| 59 |
+
except Exception as e:
|
| 60 |
+
raise Exception(f'업로드 폴더 쓰기 테스트 실패: {UPLOAD_FOLDER} - {str(e)}')
|
| 61 |
+
except Exception as e:
|
| 62 |
+
print(f"업로드 폴더 생성 오류: {str(e)}")
|
| 63 |
+
import traceback
|
| 64 |
+
traceback.print_exc()
|
| 65 |
+
raise
|
| 66 |
|
| 67 |
+
def split_text_into_chunks(text, chunk_size=1000, overlap=150):
|
| 68 |
+
"""텍스트를 청크로 분할 (더 큰 청크와 오버랩으로 문맥 유지)"""
|
| 69 |
chunks = []
|
| 70 |
start = 0
|
| 71 |
while start < len(text):
|
|
|
|
| 81 |
# 기존 청크 삭제
|
| 82 |
DocumentChunk.query.filter_by(file_id=file_id).delete()
|
| 83 |
|
| 84 |
+
# 텍스트를 청크로 분할 (더 큰 청크로 더 많은 컨텍스트 제공)
|
| 85 |
+
chunks = split_text_into_chunks(content, chunk_size=1000, overlap=150)
|
| 86 |
|
| 87 |
# 각 청크를 데이터베이스에 저장
|
| 88 |
for idx, chunk_content in enumerate(chunks):
|
|
|
|
| 100 |
print(f"청크 생성 오류: {str(e)}")
|
| 101 |
return 0
|
| 102 |
|
| 103 |
+
def search_relevant_chunks(query, file_ids=None, model_name=None, top_k=25, min_score=1):
|
| 104 |
+
"""질문과 관련된 청크 검색 (개선된 키워드 기반 검색)"""
|
| 105 |
try:
|
| 106 |
+
# 검색 쿼리 준비 - 한글과 영문 단어 모두 추출
|
| 107 |
+
query_words = set(re.findall(r'[가-힣]+|\w+', query.lower()))
|
| 108 |
+
|
| 109 |
+
if not query_words:
|
| 110 |
+
return []
|
| 111 |
|
| 112 |
# 청크 조회
|
| 113 |
query_obj = DocumentChunk.query.join(UploadedFile)
|
| 114 |
|
| 115 |
if file_ids:
|
| 116 |
+
# 선택된 파일 ID와 그 파일에 이어서 업로드된 모든 파일 ID 포함
|
| 117 |
+
expanded_file_ids = list(file_ids)
|
| 118 |
+
for file_id in file_ids:
|
| 119 |
+
# 원본 파일인 경우 이어서 업로드된 파일들도 포함
|
| 120 |
+
child_files = UploadedFile.query.filter_by(parent_file_id=file_id).all()
|
| 121 |
+
expanded_file_ids.extend([child.id for child in child_files])
|
| 122 |
+
|
| 123 |
+
# 원본 파일이 선택된 경우, 이어서 업로드된 파일들도 포함
|
| 124 |
+
parent_files = UploadedFile.query.filter(UploadedFile.id.in_(file_ids), UploadedFile.parent_file_id.is_(None)).all()
|
| 125 |
+
for parent_file in parent_files:
|
| 126 |
+
child_files = UploadedFile.query.filter_by(parent_file_id=parent_file.id).all()
|
| 127 |
+
expanded_file_ids.extend([child.id for child in child_files])
|
| 128 |
+
|
| 129 |
+
query_obj = query_obj.filter(UploadedFile.id.in_(expanded_file_ids))
|
| 130 |
|
| 131 |
if model_name:
|
| 132 |
query_obj = query_obj.filter(UploadedFile.model_name == model_name)
|
|
|
|
| 136 |
if not all_chunks:
|
| 137 |
return []
|
| 138 |
|
| 139 |
+
# 각 청크의 관련도 점수 계산 (개선된 알고리즘)
|
| 140 |
scored_chunks = []
|
| 141 |
for chunk in all_chunks:
|
| 142 |
+
chunk_content_lower = chunk.content.lower()
|
| 143 |
+
chunk_words = set(re.findall(r'[가-힣]+|\w+', chunk_content_lower))
|
| 144 |
+
|
| 145 |
+
# 1. 공통 단어 수 (기본 점수)
|
| 146 |
+
common_words = query_words & chunk_words
|
| 147 |
+
base_score = len(common_words)
|
| 148 |
+
|
| 149 |
+
# 2. 쿼리 단어의 빈도 가중치 (중요한 단어가 더 많이 나타날수록 높은 점수)
|
| 150 |
+
frequency_score = 0
|
| 151 |
+
for word in query_words:
|
| 152 |
+
frequency_score += chunk_content_lower.count(word)
|
| 153 |
+
|
| 154 |
+
# 3. 쿼리 단어 비율 (청크에서 쿼리 단어가 차지하는 비율)
|
| 155 |
+
if len(chunk_words) > 0:
|
| 156 |
+
ratio_score = len(common_words) / len(chunk_words) * 10
|
| 157 |
+
else:
|
| 158 |
+
ratio_score = 0
|
| 159 |
+
|
| 160 |
+
# 최종 점수 계산 (가중치 적용)
|
| 161 |
+
final_score = base_score * 2 + frequency_score * 0.5 + ratio_score
|
| 162 |
+
|
| 163 |
+
# 최소 점수 이상인 청크만 포함
|
| 164 |
+
if final_score >= min_score:
|
| 165 |
+
scored_chunks.append((final_score, chunk))
|
| 166 |
|
| 167 |
# 점수 순으로 정렬하고 상위 k개 선택
|
| 168 |
scored_chunks.sort(key=lambda x: x[0], reverse=True)
|
| 169 |
+
|
| 170 |
+
# top_k개 선택하되, 점수가 비슷한 청크도 포함 (점수 차이가 30% 이내면 포함)
|
| 171 |
+
top_chunks = []
|
| 172 |
+
if scored_chunks:
|
| 173 |
+
max_score = scored_chunks[0][0]
|
| 174 |
+
threshold = max_score * 0.7 # 최고 점수의 70% 이상인 청크도 포함
|
| 175 |
+
|
| 176 |
+
for score, chunk in scored_chunks:
|
| 177 |
+
if len(top_chunks) < top_k or score >= threshold:
|
| 178 |
+
top_chunks.append(chunk)
|
| 179 |
+
else:
|
| 180 |
+
break
|
| 181 |
|
| 182 |
return top_chunks
|
| 183 |
except Exception as e:
|
| 184 |
print(f"청크 검색 오류: {str(e)}")
|
| 185 |
+
import traceback
|
| 186 |
+
traceback.print_exc()
|
| 187 |
return []
|
| 188 |
|
| 189 |
@main_bp.route('/login', methods=['GET', 'POST'])
|
|
|
|
| 456 |
|
| 457 |
if use_rag:
|
| 458 |
# 관련 청크 검색
|
| 459 |
+
print(f"\n[RAG 검색] 모델: {model}, 질문: {message[:50]}...")
|
| 460 |
+
print(f"[RAG 검색] 선택된 파일 ID: {file_ids if file_ids else '없음 (모든 파일 검색)'}")
|
| 461 |
+
|
| 462 |
+
# 더 많은 청크를 검색하도록 top_k 증가 (기본 25개)
|
| 463 |
relevant_chunks = search_relevant_chunks(
|
| 464 |
query=message,
|
| 465 |
file_ids=file_ids if file_ids else None,
|
| 466 |
model_name=model,
|
| 467 |
+
top_k=25, # 5개에서 25개로 증가
|
| 468 |
+
min_score=0.5 # 최소 점수 임계값 낮춤 (더 많은 청크 포함)
|
| 469 |
)
|
| 470 |
|
| 471 |
+
print(f"[RAG 검색] 검색된 청크 수: {len(relevant_chunks)}")
|
| 472 |
+
|
| 473 |
if relevant_chunks:
|
| 474 |
context_parts = []
|
| 475 |
seen_files = set()
|
|
|
|
| 478 |
file = chunk.file
|
| 479 |
if file.original_filename not in seen_files:
|
| 480 |
seen_files.add(file.original_filename)
|
| 481 |
+
print(f"[RAG 검색] 사용된 파일: {file.original_filename} (모델: {file.model_name})")
|
| 482 |
|
| 483 |
context_parts.append(f"[{file.original_filename} - 청크 {chunk.chunk_index + 1}]\n{chunk.content}")
|
| 484 |
|
| 485 |
if context_parts:
|
| 486 |
+
# 컨텍스트 길이 확인 및 최적화
|
| 487 |
+
full_context = "\n\n".join(context_parts)
|
| 488 |
+
context_length = len(full_context)
|
| 489 |
+
|
| 490 |
+
# 컨텍스트가 너무 길면 일부만 사용 (최대 15000자)
|
| 491 |
+
if context_length > 15000:
|
| 492 |
+
# 상위 점수 청크 우선 유지하면서 길이 조절
|
| 493 |
+
truncated_parts = []
|
| 494 |
+
current_length = 0
|
| 495 |
+
for part in context_parts:
|
| 496 |
+
if current_length + len(part) > 15000:
|
| 497 |
+
break
|
| 498 |
+
truncated_parts.append(part)
|
| 499 |
+
current_length += len(part)
|
| 500 |
+
full_context = "\n\n".join(truncated_parts)
|
| 501 |
+
print(f"[RAG 검색] 컨텍스트 길이 조절: {context_length}자 → {len(full_context)}자")
|
| 502 |
+
|
| 503 |
+
context = f"다음은 질문과 관련된 웹소설 내용입니다 (RAG 검색 결과, 총 {len(relevant_chunks)}개 청크):\n\n{full_context}\n\n위 내용을 충분히 참고하여 다음 질문에 정확하고 상세하게 답변해주세요. 웹소설의 맥락과 스토리를 고려하여 답변해주세요:\n\n"
|
| 504 |
+
print(f"[RAG 검색] 컨텍스트 생성 완료 ({len(seen_files)}개 파일, {len(relevant_chunks)}개 청크, {len(full_context)}자)")
|
| 505 |
else:
|
| 506 |
# RAG 검색 결과가 없으면 기존 방식 사용
|
| 507 |
+
print(f"[RAG 검색] 관련 청크를 찾지 못했습니다. 전체 파일 내용 사용")
|
| 508 |
use_rag = False
|
| 509 |
|
| 510 |
# RAG 검색 결과가 없거나 비활성화된 경우 기존 방식 사용
|
| 511 |
if not context and not use_rag:
|
| 512 |
if file_ids:
|
| 513 |
+
# 선택한 파일 ID와 이어서 업로드된 파일들도 포함
|
| 514 |
+
expanded_file_ids = list(file_ids)
|
| 515 |
+
for file_id in file_ids:
|
| 516 |
+
# 원본 파일인 경우 이어서 업로드된 파일들도 포함
|
| 517 |
+
child_files = UploadedFile.query.filter_by(parent_file_id=file_id).all()
|
| 518 |
+
expanded_file_ids.extend([child.id for child in child_files])
|
| 519 |
+
|
| 520 |
uploaded_files = UploadedFile.query.filter(
|
| 521 |
+
UploadedFile.id.in_(expanded_file_ids),
|
| 522 |
UploadedFile.model_name == model
|
| 523 |
).all()
|
| 524 |
+
print(f"[파일 사용] 선택된 파일 ID로 조회 (이어서 업로드 포함): {len(uploaded_files)}개 파일")
|
| 525 |
else:
|
| 526 |
+
# 파일 ID가 없으면 해당 모델의 모든 파일 사용 (원본 및 이어서 업로드 포함)
|
| 527 |
uploaded_files = UploadedFile.query.filter_by(model_name=model).all()
|
| 528 |
+
print(f"[파일 사용] 모델 '{model}'의 모든 파일 사용: {len(uploaded_files)}개 파일")
|
| 529 |
|
| 530 |
if uploaded_files:
|
| 531 |
+
print(f"[파일 사용] 사용되는 파일 목록:")
|
| 532 |
+
for f in uploaded_files:
|
| 533 |
+
is_child = f.parent_file_id is not None
|
| 534 |
+
prefix = " └─ " if is_child else " - "
|
| 535 |
+
print(f"{prefix}{f.original_filename} (모델: {f.model_name})")
|
| 536 |
context_parts = []
|
| 537 |
for file in uploaded_files:
|
| 538 |
try:
|
|
|
|
| 545 |
with open(file.file_path, 'r', encoding='cp949') as f:
|
| 546 |
file_content = f.read()
|
| 547 |
|
| 548 |
+
# 파일 내용이 너무 길면 일부만 사용 (최대 20000자로 증가)
|
| 549 |
+
if len(file_content) > 20000:
|
| 550 |
+
file_content = file_content[:20000] + "..."
|
| 551 |
|
| 552 |
context_parts.append(f"[{file.original_filename}]\n{file_content}")
|
| 553 |
except Exception as e:
|
|
|
|
| 586 |
).first()
|
| 587 |
|
| 588 |
if session:
|
| 589 |
+
# 사용자 메시지가 이미 저장되어 있는지 확인 (중복 방지)
|
| 590 |
+
# 가장 최근 메시지를 확인하여 중복 저장 방지
|
| 591 |
+
latest_user_msg = ChatMessage.query.filter_by(
|
| 592 |
session_id=session_id,
|
| 593 |
+
role='user'
|
| 594 |
+
).order_by(ChatMessage.created_at.desc()).first()
|
| 595 |
+
|
| 596 |
+
# 최근 10초 이내에 같은 내용의 메시지가 없으면 저장
|
| 597 |
+
should_save = True
|
| 598 |
+
if latest_user_msg:
|
| 599 |
+
time_diff = (datetime.utcnow() - latest_user_msg.created_at).total_seconds()
|
| 600 |
+
if latest_user_msg.content == message and time_diff < 10:
|
| 601 |
+
should_save = False
|
| 602 |
+
print(f"[중복 방지] 최근 {time_diff:.2f}초 전에 같은 메시지가 저장되어 있습니다. 저장을 건너뜁니다.")
|
| 603 |
+
|
| 604 |
+
if should_save:
|
| 605 |
+
user_msg = ChatMessage(
|
| 606 |
+
session_id=session_id,
|
| 607 |
+
role='user',
|
| 608 |
+
content=message
|
| 609 |
+
)
|
| 610 |
+
db.session.add(user_msg)
|
| 611 |
+
print(f"[메시지 저장] 사용자 메시지 저장: {message[:50]}...")
|
| 612 |
+
|
| 613 |
+
# 세션 제목 업데이트 (첫 사용자 메시지인 경우)
|
| 614 |
+
title_needs_update = (
|
| 615 |
+
not session.title or
|
| 616 |
+
session.title.strip() == '' or
|
| 617 |
+
session.title == '새 대화'
|
| 618 |
+
)
|
| 619 |
+
|
| 620 |
+
if title_needs_update and message.strip():
|
| 621 |
+
# 메시지 내용을 제목으로 사용 (최대 30자)
|
| 622 |
+
title = message.strip()[:30]
|
| 623 |
+
if len(message.strip()) > 30:
|
| 624 |
+
title += '...'
|
| 625 |
+
session.title = title
|
| 626 |
+
print(f"[세션 제목] 업데이트: '{title}' (원본 길이: {len(message.strip())}자)")
|
| 627 |
+
elif title_needs_update:
|
| 628 |
+
print(f"[세션 제목] 메시지가 비어있어 제목을 업데이트하지 않습니다.")
|
| 629 |
+
else:
|
| 630 |
+
print(f"[메시지 저장] 중복 메시지로 인해 저장을 건너뜁니다.")
|
| 631 |
|
| 632 |
# AI 응답 저장
|
| 633 |
ai_msg = ChatMessage(
|
|
|
|
| 639 |
|
| 640 |
session.updated_at = datetime.utcnow()
|
| 641 |
db.session.commit()
|
| 642 |
+
|
| 643 |
+
# 세션 정보를 응답에 포함 (제목 업데이트 반영)
|
| 644 |
+
session_dict = session.to_dict()
|
| 645 |
except Exception as e:
|
| 646 |
print(f"메시지 저장 오류: {str(e)}")
|
| 647 |
db.session.rollback()
|
| 648 |
+
session_dict = None
|
| 649 |
|
| 650 |
+
response_data = {'response': response_text, 'session_id': session_id}
|
| 651 |
+
if session_dict:
|
| 652 |
+
response_data['session'] = session_dict
|
| 653 |
+
|
| 654 |
+
return jsonify(response_data)
|
| 655 |
else:
|
| 656 |
error_msg = f'Ollama 서버 오류: {ollama_response.status_code}'
|
| 657 |
return jsonify({'error': error_msg}), ollama_response.status_code
|
|
|
|
| 674 |
@login_required
|
| 675 |
def upload_file():
|
| 676 |
"""웹소설 파일 업로드"""
|
| 677 |
+
import sys
|
| 678 |
+
import traceback
|
| 679 |
+
|
| 680 |
+
# 모든 출력을 즉시 플러시하여 로그가 바로 보이도록
|
| 681 |
+
def log_print(*args, **kwargs):
|
| 682 |
+
print(*args, **kwargs)
|
| 683 |
+
sys.stdout.flush()
|
| 684 |
+
|
| 685 |
try:
|
| 686 |
+
log_print(f"\n{'='*60}")
|
| 687 |
+
log_print(f"=== 파일 업로드 요청 시작 ===")
|
| 688 |
+
log_print(f"요청 메서드: {request.method}")
|
| 689 |
+
log_print(f"Content-Type: {request.content_type}")
|
| 690 |
+
log_print(f"Content-Length: {request.content_length}")
|
| 691 |
+
log_print(f"Form 데이터 키: {list(request.form.keys())}")
|
| 692 |
+
log_print(f"Files 키: {list(request.files.keys())}")
|
| 693 |
+
log_print(f"사용자: {current_user.username if current_user else 'None'}")
|
| 694 |
+
log_print(f"{'='*60}\n")
|
| 695 |
+
|
| 696 |
+
# 업로드 폴더 확인 및 생성
|
| 697 |
+
try:
|
| 698 |
+
ensure_upload_folder()
|
| 699 |
+
log_print(f"[1/8] 업로드 폴더 확인 완료: {UPLOAD_FOLDER}")
|
| 700 |
+
except Exception as e:
|
| 701 |
+
error_msg = f'업로드 폴더를 준비할 수 없습니다: {str(e)}'
|
| 702 |
+
log_print(f"[ERROR] {error_msg}")
|
| 703 |
+
traceback.print_exc()
|
| 704 |
+
return jsonify({'error': error_msg, 'step': 'folder_check'}), 500
|
| 705 |
|
| 706 |
if 'file' not in request.files:
|
| 707 |
+
error_msg = '파일이 없습니다.'
|
| 708 |
+
log_print(f"[ERROR] {error_msg}")
|
| 709 |
+
log_print(f"사용 가능한 키: {list(request.files.keys())}")
|
| 710 |
+
return jsonify({'error': error_msg, 'step': 'file_check'}), 400
|
| 711 |
|
| 712 |
file = request.files['file']
|
| 713 |
+
model_name = request.form.get('model_name', '').strip()
|
| 714 |
+
parent_file_id = request.form.get('parent_file_id', None) # 이어서 업로드할 경우 원본 파일 ID
|
| 715 |
+
|
| 716 |
+
log_print(f"[2/8] 파일 수신: {file.filename if file else 'None'}")
|
| 717 |
+
log_print(f"[2/8] 모델명: {model_name if model_name else 'None (비어있음)'}")
|
| 718 |
+
log_print(f"[2/8] 이어서 업로드: {parent_file_id if parent_file_id else '아니오'}")
|
| 719 |
|
| 720 |
if file.filename == '':
|
| 721 |
+
error_msg = '파일명이 없습니다.'
|
| 722 |
+
log_print(f"[ERROR] {error_msg}")
|
| 723 |
+
return jsonify({'error': error_msg, 'step': 'filename_check'}), 400
|
| 724 |
+
|
| 725 |
+
# 모델명 검증
|
| 726 |
+
if not model_name:
|
| 727 |
+
error_msg = 'AI 모델을 선택해주세요.'
|
| 728 |
+
log_print(f"[ERROR] {error_msg}")
|
| 729 |
+
return jsonify({'error': error_msg, 'step': 'model_check'}), 400
|
| 730 |
+
|
| 731 |
+
# parent_file_id 검증 (이어서 업로드인 경우)
|
| 732 |
+
parent_file = None
|
| 733 |
+
if parent_file_id:
|
| 734 |
+
try:
|
| 735 |
+
parent_file_id = int(parent_file_id)
|
| 736 |
+
parent_file = UploadedFile.query.filter_by(
|
| 737 |
+
id=parent_file_id,
|
| 738 |
+
uploaded_by=current_user.id
|
| 739 |
+
).first()
|
| 740 |
+
|
| 741 |
+
if not parent_file:
|
| 742 |
+
error_msg = '원본 파일을 찾을 수 없습니다.'
|
| 743 |
+
log_print(f"[ERROR] {error_msg}")
|
| 744 |
+
return jsonify({'error': error_msg, 'step': 'parent_file_check'}), 404
|
| 745 |
+
|
| 746 |
+
# 같은 모델인지 확인
|
| 747 |
+
if parent_file.model_name != model_name:
|
| 748 |
+
error_msg = '같은 모델의 파일에만 이어서 업로드할 수 있습니다.'
|
| 749 |
+
log_print(f"[ERROR] {error_msg}")
|
| 750 |
+
return jsonify({'error': error_msg, 'step': 'model_mismatch'}), 400
|
| 751 |
+
|
| 752 |
+
log_print(f"[이어서 업로드] 원본 파일: {parent_file.original_filename} (ID: {parent_file_id})")
|
| 753 |
+
except (ValueError, TypeError):
|
| 754 |
+
parent_file_id = None
|
| 755 |
+
log_print(f"[경고] 잘못된 parent_file_id: {parent_file_id}")
|
| 756 |
+
|
| 757 |
+
log_print(f"[3/8] 업로드 시도: {file.filename}, 모델: {model_name}")
|
| 758 |
|
| 759 |
if not allowed_file(file.filename):
|
| 760 |
+
error_msg = f'허용되지 않은 파일 형식입니다. 허용 형식: {", ".join(ALLOWED_EXTENSIONS)}'
|
| 761 |
+
log_print(f"[ERROR] {error_msg}")
|
| 762 |
+
return jsonify({'error': error_msg, 'step': 'file_type_check'}), 400
|
| 763 |
+
|
| 764 |
+
log_print(f"[4/8] 파일 형식 확인 완료: {file.filename}")
|
| 765 |
+
|
| 766 |
+
# 파일 크기 확인 (Content-Length 헤더 사용)
|
| 767 |
+
file_size = 0
|
| 768 |
+
try:
|
| 769 |
+
# Content-Length 헤더 확인
|
| 770 |
+
if request.content_length:
|
| 771 |
+
file_size = request.content_length
|
| 772 |
+
print(f"Content-Length로 파일 크기 확인: {file_size} bytes")
|
| 773 |
+
else:
|
| 774 |
+
# Content-Length가 없으면 파일 스트림에서 크기 확인 시도
|
| 775 |
+
try:
|
| 776 |
+
# 파일 스트림의 현재 위치 저장
|
| 777 |
+
current_pos = file.tell()
|
| 778 |
+
# 파일 끝으로 이동
|
| 779 |
+
file.seek(0, os.SEEK_END)
|
| 780 |
+
file_size = file.tell()
|
| 781 |
+
# 원래 위치로 복원
|
| 782 |
+
file.seek(current_pos, os.SEEK_SET)
|
| 783 |
+
print(f"파일 스트림으로 크기 확인: {file_size} bytes")
|
| 784 |
+
except (AttributeError, IOError, OSError) as e:
|
| 785 |
+
print(f"파일 크기 확인 실패 (저장 후 확인): {str(e)}")
|
| 786 |
+
file_size = 0 # 저장 후 확인하도록 0으로 설정
|
| 787 |
+
except Exception as e:
|
| 788 |
+
print(f"파일 크기 확인 오류: {str(e)}")
|
| 789 |
+
file_size = 0 # 저장 후 확인하도록 0으로 설정
|
| 790 |
+
|
| 791 |
+
# 파일 크기 사전 체크 (가능한 경우에만)
|
| 792 |
+
if file_size > 0:
|
| 793 |
+
if file_size > 100 * 1024 * 1024: # 100MB
|
| 794 |
+
print(f"파일 크기 초과: {file_size} bytes")
|
| 795 |
+
return jsonify({'error': '파일 크기가 너무 큽니다. 최대 100MB까지 업로드 가능합니다.'}), 400
|
| 796 |
+
if file_size == 0:
|
| 797 |
+
print("빈 파일 업로드 시도")
|
| 798 |
+
return jsonify({'error': '빈 파일은 업로드할 수 없습니다.'}), 400
|
| 799 |
|
| 800 |
# 안전한 파일명 생성
|
| 801 |
original_filename = file.filename
|
| 802 |
filename = secure_filename(original_filename)
|
| 803 |
+
if not filename:
|
| 804 |
+
return jsonify({'error': '유효하지 않은 파일명입니다.'}), 400
|
| 805 |
+
|
| 806 |
unique_filename = f"{uuid.uuid4().hex}_{filename}"
|
| 807 |
file_path = os.path.join(UPLOAD_FOLDER, unique_filename)
|
| 808 |
|
| 809 |
# 파일 저장
|
| 810 |
+
try:
|
| 811 |
+
log_print(f"[6/8] 파일 저장 시도: {file_path}")
|
| 812 |
+
file.save(file_path)
|
| 813 |
+
log_print(f"[6/8] 파일 저장 완료: {file_path}")
|
| 814 |
+
except IOError as e:
|
| 815 |
+
error_msg = f'파일 저장 중 오류가 발생했습니다: {str(e)}'
|
| 816 |
+
log_print(f"[ERROR] 파일 저장 IOError: {error_msg}")
|
| 817 |
+
traceback.print_exc()
|
| 818 |
+
return jsonify({'error': error_msg, 'step': 'file_save'}), 500
|
| 819 |
+
except PermissionError as e:
|
| 820 |
+
error_msg = f'파일 저장 권한 오류: {str(e)}'
|
| 821 |
+
log_print(f"[ERROR] 파일 저장 PermissionError: {error_msg}")
|
| 822 |
+
traceback.print_exc()
|
| 823 |
+
return jsonify({'error': error_msg, 'step': 'file_save_permission'}), 500
|
| 824 |
+
except Exception as e:
|
| 825 |
+
error_msg = f'파일 저장 실패: {str(e)}'
|
| 826 |
+
log_print(f"[ERROR] 파일 저장 Exception: {error_msg}")
|
| 827 |
+
traceback.print_exc()
|
| 828 |
+
return jsonify({'error': error_msg, 'step': 'file_save'}), 500
|
| 829 |
+
|
| 830 |
+
# 저장된 파일 크기 확인
|
| 831 |
+
if not os.path.exists(file_path):
|
| 832 |
+
error_msg = '파일이 저장되지 않았습니다.'
|
| 833 |
+
print(f"파일 존재 확인 실패: {file_path}")
|
| 834 |
+
return jsonify({'error': error_msg}), 500
|
| 835 |
+
|
| 836 |
+
saved_file_size = os.path.getsize(file_path)
|
| 837 |
+
if saved_file_size == 0:
|
| 838 |
+
os.remove(file_path) # 빈 파일 삭제
|
| 839 |
+
error_msg = '파일이 제대로 저장되지 않았습니다.'
|
| 840 |
+
print(f"빈 파일 삭제: {file_path}")
|
| 841 |
+
return jsonify({'error': error_msg}), 500
|
| 842 |
+
|
| 843 |
+
print(f"저장된 파일 크기: {saved_file_size} bytes")
|
| 844 |
|
| 845 |
# 데이터베이스에 저장
|
| 846 |
+
try:
|
| 847 |
+
log_print(f"[7/8] 데이터베이스 저장 시도: {original_filename}")
|
| 848 |
+
uploaded_file = UploadedFile(
|
| 849 |
+
filename=unique_filename,
|
| 850 |
+
original_filename=original_filename,
|
| 851 |
+
file_path=file_path,
|
| 852 |
+
file_size=saved_file_size,
|
| 853 |
+
model_name=model_name, # 이미 검증됨
|
| 854 |
+
uploaded_by=current_user.id,
|
| 855 |
+
parent_file_id=parent_file_id if parent_file else None # 이어서 업로드인 경우
|
| 856 |
+
)
|
| 857 |
+
db.session.add(uploaded_file)
|
| 858 |
+
db.session.flush() # ID를 얻기 위해 flush
|
| 859 |
+
log_print(f"[7/8] 데이터베이스 flush 완료, 파일 ID: {uploaded_file.id}")
|
| 860 |
+
|
| 861 |
+
# 텍스트 파일인 경우 청크로 분할하여 저장 (RAG용)
|
| 862 |
+
if original_filename.lower().endswith(('.txt', '.md')):
|
| 863 |
try:
|
| 864 |
+
print(f"텍스트 파일 청크 분할 시작: {original_filename}")
|
| 865 |
+
encoding = 'utf-8'
|
| 866 |
+
try:
|
| 867 |
+
with open(file_path, 'r', encoding=encoding) as f:
|
| 868 |
+
content = f.read()
|
| 869 |
+
except UnicodeDecodeError:
|
| 870 |
+
print(f"UTF-8 인코딩 실패, CP949 시도: {original_filename}")
|
| 871 |
+
with open(file_path, 'r', encoding='cp949') as f:
|
| 872 |
+
content = f.read()
|
| 873 |
+
|
| 874 |
+
chunk_count = create_chunks_for_file(uploaded_file.id, content)
|
| 875 |
+
if chunk_count > 0:
|
| 876 |
+
print(f"파일 {original_filename}을 {chunk_count}개의 청크로 분할했습니다.")
|
| 877 |
+
except Exception as e:
|
| 878 |
+
print(f"청크 생성 중 오류 (무시): {str(e)}")
|
| 879 |
+
import traceback
|
| 880 |
+
traceback.print_exc()
|
| 881 |
+
|
| 882 |
+
# 청크 개수 저장
|
| 883 |
+
chunk_count = 0
|
| 884 |
+
if original_filename.lower().endswith(('.txt', '.md')):
|
| 885 |
+
chunk_count = DocumentChunk.query.filter_by(file_id=uploaded_file.id).count()
|
| 886 |
+
|
| 887 |
+
db.session.commit()
|
| 888 |
+
log_print(f"[8/8] 데이터베이스 커밋 완료: {original_filename}")
|
| 889 |
+
log_print(f"[8/8] 연결된 모델: {model_name}")
|
| 890 |
+
log_print(f"[8/8] 생성된 청크 수: {chunk_count}")
|
| 891 |
+
log_print(f"{'='*60}")
|
| 892 |
+
log_print(f"=== 파일 업로드 성공 ===")
|
| 893 |
+
log_print(f"{'='*60}\n")
|
| 894 |
+
except Exception as e:
|
| 895 |
+
db.session.rollback()
|
| 896 |
+
error_msg = f'데이터베이스 저장 중 오류가 발생했습니다: {str(e)}'
|
| 897 |
+
log_print(f"[ERROR] 데이터베이스 저장 오류: {error_msg}")
|
| 898 |
+
traceback.print_exc()
|
| 899 |
+
# 데이터베이스 저장 실패 시 파일도 삭제
|
| 900 |
+
if os.path.exists(file_path):
|
| 901 |
+
try:
|
| 902 |
+
os.remove(file_path)
|
| 903 |
+
log_print(f"오류로 인한 파일 삭제: {file_path}")
|
| 904 |
+
except Exception as del_e:
|
| 905 |
+
log_print(f"파일 삭제 실패: {str(del_e)}")
|
| 906 |
+
return jsonify({'error': error_msg, 'step': 'database_save'}), 500
|
| 907 |
|
| 908 |
+
log_print(f"[8/8] 업로드 완료 - 파일: {original_filename}, 모델: {model_name}, 크기: {saved_file_size} bytes")
|
| 909 |
|
| 910 |
return jsonify({
|
| 911 |
+
'message': f'파일이 성공적으로 업로드되었습니다. (모델: {model_name})',
|
| 912 |
+
'file': uploaded_file.to_dict(),
|
| 913 |
+
'model_name': model_name,
|
| 914 |
+
'chunk_count': chunk_count if 'chunk_count' in locals() else 0
|
| 915 |
}), 200
|
| 916 |
|
| 917 |
except Exception as e:
|
| 918 |
db.session.rollback()
|
| 919 |
+
error_msg = str(e)
|
| 920 |
+
error_type = type(e).__name__
|
| 921 |
+
log_print(f"\n{'='*60}")
|
| 922 |
+
log_print(f"=== 업로드 처리 중 예외 발생 ===")
|
| 923 |
+
log_print(f"예외 타입: {error_type}")
|
| 924 |
+
log_print(f"에러 메시지: {error_msg}")
|
| 925 |
+
traceback.print_exc()
|
| 926 |
+
log_print(f"{'='*60}\n")
|
| 927 |
+
# 파일 크기 초과 오류 처리
|
| 928 |
+
if '413' in error_msg or 'Request Entity Too Large' in error_msg or error_type == 'RequestEntityTooLarge':
|
| 929 |
+
return jsonify({'error': '파일 크기가 너무 큽니다. 최대 100MB까지 업로드 가능합니다.', 'step': 'file_size'}), 413
|
| 930 |
+
return jsonify({'error': f'파일 업로드 중 오류가 발생했습니다: {error_type}: {error_msg}', 'step': 'exception'}), 500
|
| 931 |
|
| 932 |
@main_bp.route('/api/files', methods=['GET'])
|
| 933 |
@login_required
|
|
|
|
| 936 |
try:
|
| 937 |
model_name = request.args.get('model_name', None)
|
| 938 |
|
| 939 |
+
# 원본 파일만 조회 (parent_file_id가 None인 파일)
|
| 940 |
+
query = UploadedFile.query.filter_by(parent_file_id=None)
|
| 941 |
if model_name:
|
| 942 |
query = query.filter_by(model_name=model_name)
|
| 943 |
+
print(f"[파일 조회] 모델 '{model_name}' 필터링")
|
| 944 |
|
| 945 |
files = query.order_by(UploadedFile.uploaded_at.desc()).all()
|
| 946 |
|
| 947 |
+
# 각 원본 파일에 대해 이어서 업로드된 파일도 포함
|
| 948 |
+
files_with_children = []
|
| 949 |
+
for file in files:
|
| 950 |
+
file_dict = file.to_dict()
|
| 951 |
+
# 이어서 업로드된 파일들도 조회
|
| 952 |
+
child_files = UploadedFile.query.filter_by(parent_file_id=file.id).order_by(UploadedFile.uploaded_at.asc()).all()
|
| 953 |
+
file_dict['child_files'] = [child.to_dict() for child in child_files]
|
| 954 |
+
files_with_children.append(file_dict)
|
| 955 |
+
|
| 956 |
+
# 모델별 통계 정보 추가 (원본 파일만 카운트)
|
| 957 |
+
model_stats = {}
|
| 958 |
+
if not model_name:
|
| 959 |
+
# 모든 모델의 통계 (원본 파일만)
|
| 960 |
+
all_files = UploadedFile.query.filter_by(parent_file_id=None).all()
|
| 961 |
+
for file in all_files:
|
| 962 |
+
model = file.model_name or '미지정'
|
| 963 |
+
if model not in model_stats:
|
| 964 |
+
model_stats[model] = {'count': 0, 'total_size': 0}
|
| 965 |
+
model_stats[model]['count'] += 1
|
| 966 |
+
model_stats[model]['total_size'] += file.file_size
|
| 967 |
+
else:
|
| 968 |
+
# 특정 모델의 통계
|
| 969 |
+
model_stats[model_name] = {
|
| 970 |
+
'count': len(files),
|
| 971 |
+
'total_size': sum(f.file_size for f in files)
|
| 972 |
+
}
|
| 973 |
+
|
| 974 |
+
print(f"[파일 조회] 조회된 원본 파일 수: {len(files)}개")
|
| 975 |
+
|
| 976 |
return jsonify({
|
| 977 |
+
'files': files_with_children,
|
| 978 |
+
'model_stats': model_stats,
|
| 979 |
+
'filtered_model': model_name
|
| 980 |
}), 200
|
| 981 |
|
| 982 |
except Exception as e:
|
|
|
|
| 985 |
@main_bp.route('/api/files/<int:file_id>', methods=['DELETE'])
|
| 986 |
@login_required
|
| 987 |
def delete_file(file_id):
|
| 988 |
+
"""업로드된 파일 삭제 (연관된 모든 파일도 함께 삭제)"""
|
| 989 |
try:
|
| 990 |
file = UploadedFile.query.get_or_404(file_id)
|
| 991 |
|
| 992 |
+
# 원본 파일인 경우 (parent_file_id가 None인 경우)
|
| 993 |
+
# 이어서 업로드된 모든 파일도 함께 삭제
|
| 994 |
+
files_to_delete = []
|
| 995 |
|
| 996 |
+
if file.parent_file_id is None:
|
| 997 |
+
# 원본 파일이면, 이어서 업로드된 모든 파일도 찾아서 삭제
|
| 998 |
+
child_files = UploadedFile.query.filter_by(parent_file_id=file_id).all()
|
| 999 |
+
files_to_delete = [file] + child_files
|
| 1000 |
+
print(f"[파일 삭제] 원본 파일 삭제: {file.original_filename}, 연관 파일 {len(child_files)}개도 함께 삭제")
|
| 1001 |
+
else:
|
| 1002 |
+
# 이어서 업로드된 파일이면 원본 파일도 함께 삭제
|
| 1003 |
+
parent_file = UploadedFile.query.get(file.parent_file_id)
|
| 1004 |
+
if parent_file:
|
| 1005 |
+
# 원본 파일과 모든 연관 파일 삭제
|
| 1006 |
+
all_child_files = UploadedFile.query.filter_by(parent_file_id=file.parent_file_id).all()
|
| 1007 |
+
files_to_delete = [parent_file] + all_child_files
|
| 1008 |
+
print(f"[파일 삭제] 이어서 업로드된 파일 삭제: {file.original_filename}, 원본 및 연관 파일 {len(all_child_files)}개도 함께 삭제")
|
| 1009 |
+
else:
|
| 1010 |
+
files_to_delete = [file]
|
| 1011 |
+
|
| 1012 |
+
deleted_count = 0
|
| 1013 |
+
deleted_files = []
|
| 1014 |
+
|
| 1015 |
+
for file_to_delete in files_to_delete:
|
| 1016 |
+
try:
|
| 1017 |
+
# 파일 시스템에서 삭제
|
| 1018 |
+
if os.path.exists(file_to_delete.file_path):
|
| 1019 |
+
os.remove(file_to_delete.file_path)
|
| 1020 |
+
|
| 1021 |
+
# 관련 청크도 삭제
|
| 1022 |
+
DocumentChunk.query.filter_by(file_id=file_to_delete.id).delete()
|
| 1023 |
+
|
| 1024 |
+
deleted_files.append(file_to_delete.original_filename)
|
| 1025 |
+
db.session.delete(file_to_delete)
|
| 1026 |
+
deleted_count += 1
|
| 1027 |
+
except Exception as e:
|
| 1028 |
+
print(f"[파일 삭제 오류] {file_to_delete.original_filename}: {str(e)}")
|
| 1029 |
|
|
|
|
|
|
|
| 1030 |
db.session.commit()
|
| 1031 |
|
| 1032 |
+
message = f'파일이 성공적으로 삭제되었습니다.'
|
| 1033 |
+
if deleted_count > 1:
|
| 1034 |
+
message = f'파일 {deleted_count}개가 성공적으로 삭제되었습니다. (원본 및 연관 파일 포함)'
|
| 1035 |
+
|
| 1036 |
+
return jsonify({
|
| 1037 |
+
'message': message,
|
| 1038 |
+
'deleted_count': deleted_count,
|
| 1039 |
+
'deleted_files': deleted_files
|
| 1040 |
+
}), 200
|
| 1041 |
|
| 1042 |
except Exception as e:
|
| 1043 |
db.session.rollback()
|
reset_upload_data.py
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
업로드된 파일과 데이터베이스 정보를 삭제하는 스크립트
|
| 3 |
+
"""
|
| 4 |
+
import os
|
| 5 |
+
import sys
|
| 6 |
+
import sqlite3
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
|
| 9 |
+
# UTF-8 인코딩 설정
|
| 10 |
+
if sys.platform == 'win32':
|
| 11 |
+
sys.stdout.reconfigure(encoding='utf-8')
|
| 12 |
+
sys.stderr.reconfigure(encoding='utf-8')
|
| 13 |
+
|
| 14 |
+
def reset_upload_data():
|
| 15 |
+
"""업로드된 파일과 DB 데이터 삭제"""
|
| 16 |
+
try:
|
| 17 |
+
# 1. 데이터베이스 경로 확인
|
| 18 |
+
db_path = os.path.join('instance', 'finance_analysis.db')
|
| 19 |
+
if not os.path.exists(db_path):
|
| 20 |
+
# 상대 경로로도 시도
|
| 21 |
+
db_path = 'finance_analysis.db'
|
| 22 |
+
if not os.path.exists(db_path):
|
| 23 |
+
print(f"데이터베이스 파일을 찾을 수 없습니다: {db_path}")
|
| 24 |
+
return False
|
| 25 |
+
|
| 26 |
+
print(f"데이터베이스 경로: {db_path}")
|
| 27 |
+
|
| 28 |
+
# 2. 데이터베이스 연결
|
| 29 |
+
conn = sqlite3.connect(db_path)
|
| 30 |
+
cursor = conn.cursor()
|
| 31 |
+
|
| 32 |
+
# 3. 업로드된 파일 목록 조회 (삭제 전에 파일 경로 저장)
|
| 33 |
+
cursor.execute("SELECT id, file_path FROM uploaded_file")
|
| 34 |
+
files_to_delete = cursor.fetchall()
|
| 35 |
+
|
| 36 |
+
print(f"\n삭제할 파일 수: {len(files_to_delete)}개")
|
| 37 |
+
|
| 38 |
+
# 4. 파일 시스템에서 파일 삭제
|
| 39 |
+
deleted_files = 0
|
| 40 |
+
failed_files = []
|
| 41 |
+
|
| 42 |
+
for file_id, file_path in files_to_delete:
|
| 43 |
+
if file_path and os.path.exists(file_path):
|
| 44 |
+
try:
|
| 45 |
+
os.remove(file_path)
|
| 46 |
+
deleted_files += 1
|
| 47 |
+
print(f" ✓ 파일 삭제: {os.path.basename(file_path)}")
|
| 48 |
+
except Exception as e:
|
| 49 |
+
failed_files.append((file_path, str(e)))
|
| 50 |
+
print(f" ✗ 파일 삭제 실패: {os.path.basename(file_path)} - {str(e)}")
|
| 51 |
+
|
| 52 |
+
# 5. 데이터베이스에서 데이터 삭제
|
| 53 |
+
print(f"\n데이터베이스 데이터 삭제 중...")
|
| 54 |
+
|
| 55 |
+
# document_chunk 테이블 삭제 (RAG용 청크)
|
| 56 |
+
cursor.execute("DELETE FROM document_chunk")
|
| 57 |
+
chunk_count = cursor.rowcount
|
| 58 |
+
print(f" ✓ document_chunk: {chunk_count}개 청크 삭제")
|
| 59 |
+
|
| 60 |
+
# uploaded_file 테이블 삭제
|
| 61 |
+
cursor.execute("DELETE FROM uploaded_file")
|
| 62 |
+
file_count = cursor.rowcount
|
| 63 |
+
print(f" ✓ uploaded_file: {file_count}개 파일 정보 삭제")
|
| 64 |
+
|
| 65 |
+
# chat_message 테이블 삭제 (선택사항 - 채팅 기록도 삭제)
|
| 66 |
+
cursor.execute("DELETE FROM chat_message")
|
| 67 |
+
message_count = cursor.rowcount
|
| 68 |
+
print(f" ✓ chat_message: {message_count}개 메시지 삭제")
|
| 69 |
+
|
| 70 |
+
# chat_session 테이블 삭제 (선택사항 - 채팅 세션도 삭제)
|
| 71 |
+
cursor.execute("DELETE FROM chat_session")
|
| 72 |
+
session_count = cursor.rowcount
|
| 73 |
+
print(f" ✓ chat_session: {session_count}개 세션 삭제")
|
| 74 |
+
|
| 75 |
+
# 변경사항 커밋
|
| 76 |
+
conn.commit()
|
| 77 |
+
conn.close()
|
| 78 |
+
|
| 79 |
+
print(f"\n{'='*60}")
|
| 80 |
+
print(f"삭제 완료!")
|
| 81 |
+
print(f" - 파일 시스템: {deleted_files}개 파일 삭제")
|
| 82 |
+
print(f" - 데이터베이스: {file_count}개 파일 정보, {chunk_count}개 청크 삭제")
|
| 83 |
+
if failed_files:
|
| 84 |
+
print(f"\n삭제 실패한 파일 ({len(failed_files)}개):")
|
| 85 |
+
for file_path, error in failed_files:
|
| 86 |
+
print(f" - {file_path}: {error}")
|
| 87 |
+
print(f"{'='*60}\n")
|
| 88 |
+
|
| 89 |
+
return True
|
| 90 |
+
|
| 91 |
+
except Exception as e:
|
| 92 |
+
print(f"\n오류 발생: {str(e)}")
|
| 93 |
+
import traceback
|
| 94 |
+
traceback.print_exc()
|
| 95 |
+
return False
|
| 96 |
+
|
| 97 |
+
if __name__ == '__main__':
|
| 98 |
+
import sys
|
| 99 |
+
|
| 100 |
+
print("="*60)
|
| 101 |
+
print("업로드 데이터 초기화 스크립트")
|
| 102 |
+
print("="*60)
|
| 103 |
+
print("\n다음 데이터가 삭제됩니다:")
|
| 104 |
+
print(" - 업로드된 모든 파일 (uploads 폴더)")
|
| 105 |
+
print(" - 데이터베이스의 uploaded_file 테이블")
|
| 106 |
+
print(" - 데이터베이스의 document_chunk 테이블 (RAG 청크)")
|
| 107 |
+
print(" - 데이터베이스의 chat_message 테이블 (채팅 기록)")
|
| 108 |
+
print(" - 데이터베이스의 chat_session 테이블 (채팅 세션)")
|
| 109 |
+
print("\n사용자 정보는 유지됩니다.")
|
| 110 |
+
print("="*60)
|
| 111 |
+
|
| 112 |
+
# 명령줄 인자로 --yes가 있으면 확인 없이 실행
|
| 113 |
+
auto_confirm = '--yes' in sys.argv or '-y' in sys.argv
|
| 114 |
+
|
| 115 |
+
if not auto_confirm:
|
| 116 |
+
try:
|
| 117 |
+
confirm = input("\n정말 삭제하시겠습니까? (yes/no): ")
|
| 118 |
+
except (EOFError, KeyboardInterrupt):
|
| 119 |
+
print("\n입력이 취소되었습니다. 자동 실행 모드로 진행합니다.")
|
| 120 |
+
confirm = 'yes'
|
| 121 |
+
else:
|
| 122 |
+
confirm = 'yes'
|
| 123 |
+
print("\n자동 실행 모드: 확인 없이 삭제를 진행합니다.")
|
| 124 |
+
|
| 125 |
+
if confirm.lower() in ['yes', 'y', '예', 'ㅇ']:
|
| 126 |
+
success = reset_upload_data()
|
| 127 |
+
if success:
|
| 128 |
+
print("\n초기��가 완료되었습니다. 이제 새로 파일을 업로드할 수 있습니다.")
|
| 129 |
+
else:
|
| 130 |
+
print("\n초기화 중 오류가 발생했습니다.")
|
| 131 |
+
else:
|
| 132 |
+
print("\n취소되었습니다.")
|
| 133 |
+
|
run.py
CHANGED
|
@@ -1,5 +1,7 @@
|
|
| 1 |
import sys
|
| 2 |
import os
|
|
|
|
|
|
|
| 3 |
|
| 4 |
# UTF-8 인코딩 강제 설정 (Windows cp949 오류 방지)
|
| 5 |
if sys.platform == 'win32':
|
|
@@ -10,9 +12,33 @@ from app import create_app
|
|
| 10 |
|
| 11 |
app = create_app()
|
| 12 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
if __name__ == '__main__':
|
| 14 |
try:
|
| 15 |
print(f"[{__name__}] 서버 시작: http://0.0.0.0:5001")
|
|
|
|
| 16 |
app.run(host='0.0.0.0', port=5001, debug=True, use_reloader=False)
|
| 17 |
except Exception as e:
|
| 18 |
print(f"서버 시작 오류: {e}")
|
|
|
|
| 1 |
import sys
|
| 2 |
import os
|
| 3 |
+
import logging
|
| 4 |
+
from logging.handlers import RotatingFileHandler
|
| 5 |
|
| 6 |
# UTF-8 인코딩 강제 설정 (Windows cp949 오류 방지)
|
| 7 |
if sys.platform == 'win32':
|
|
|
|
| 12 |
|
| 13 |
app = create_app()
|
| 14 |
|
| 15 |
+
# 로깅 설정
|
| 16 |
+
if not app.debug:
|
| 17 |
+
# 프로덕션 환경에서는 파일로 로깅
|
| 18 |
+
if not os.path.exists('logs'):
|
| 19 |
+
os.mkdir('logs')
|
| 20 |
+
file_handler = RotatingFileHandler('logs/server.log', maxBytes=10240000, backupCount=10)
|
| 21 |
+
file_handler.setFormatter(logging.Formatter(
|
| 22 |
+
'%(asctime)s %(levelname)s: %(message)s [in %(pathname)s:%(lineno)d]'
|
| 23 |
+
))
|
| 24 |
+
file_handler.setLevel(logging.INFO)
|
| 25 |
+
app.logger.addHandler(file_handler)
|
| 26 |
+
app.logger.setLevel(logging.INFO)
|
| 27 |
+
app.logger.info('서버 시작')
|
| 28 |
+
|
| 29 |
+
# 디버그 모드에서도 콘솔에 모든 로그 출력
|
| 30 |
+
logging.basicConfig(
|
| 31 |
+
level=logging.DEBUG,
|
| 32 |
+
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
| 33 |
+
handlers=[
|
| 34 |
+
logging.StreamHandler(sys.stdout)
|
| 35 |
+
]
|
| 36 |
+
)
|
| 37 |
+
|
| 38 |
if __name__ == '__main__':
|
| 39 |
try:
|
| 40 |
print(f"[{__name__}] 서버 시작: http://0.0.0.0:5001")
|
| 41 |
+
print(f"[{__name__}] 로그는 콘솔과 logs/server.log 파일에 기록됩니다.")
|
| 42 |
app.run(host='0.0.0.0', port=5001, debug=True, use_reloader=False)
|
| 43 |
except Exception as e:
|
| 44 |
print(f"서버 시작 오류: {e}")
|
setup_remote.ps1
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Git 원격 저장소 설정 스크립트
|
| 2 |
+
# 사용법: .\setup_remote.ps1
|
| 3 |
+
|
| 4 |
+
Write-Host "=== Git 원격 저장소 설정 ===" -ForegroundColor Cyan
|
| 5 |
+
Write-Host ""
|
| 6 |
+
|
| 7 |
+
# 현재 원격 저장소 확인
|
| 8 |
+
$currentRemote = git remote -v
|
| 9 |
+
if ($currentRemote) {
|
| 10 |
+
Write-Host "현재 원격 저장소:" -ForegroundColor Yellow
|
| 11 |
+
Write-Host $currentRemote
|
| 12 |
+
Write-Host ""
|
| 13 |
+
$overwrite = Read-Host "원격 저장소가 이미 있습니다. 덮어쓰시겠습니까? (y/n)"
|
| 14 |
+
if ($overwrite -ne "y") {
|
| 15 |
+
Write-Host "취소되었습니다." -ForegroundColor Red
|
| 16 |
+
exit
|
| 17 |
+
}
|
| 18 |
+
git remote remove origin
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
# 원격 저장소 URL 입력
|
| 22 |
+
Write-Host ""
|
| 23 |
+
Write-Host "원격 저장소 URL을 입력하세요:" -ForegroundColor Green
|
| 24 |
+
Write-Host "예시: https://github.com/username/soy-nv-ai.git" -ForegroundColor Gray
|
| 25 |
+
Write-Host " git@github.com:username/soy-nv-ai.git" -ForegroundColor Gray
|
| 26 |
+
Write-Host ""
|
| 27 |
+
$remoteUrl = Read-Host "원격 저장소 URL"
|
| 28 |
+
|
| 29 |
+
if ([string]::IsNullOrWhiteSpace($remoteUrl)) {
|
| 30 |
+
Write-Host "URL이 입력되지 않았습니다. 취소합니다." -ForegroundColor Red
|
| 31 |
+
exit
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
# 원격 저장소 추가
|
| 35 |
+
Write-Host ""
|
| 36 |
+
Write-Host "원격 저장소를 추가하는 중..." -ForegroundColor Yellow
|
| 37 |
+
git remote add origin $remoteUrl
|
| 38 |
+
|
| 39 |
+
# 원격 저장소 확인
|
| 40 |
+
Write-Host ""
|
| 41 |
+
Write-Host "원격 저장소가 추가되었습니다:" -ForegroundColor Green
|
| 42 |
+
git remote -v
|
| 43 |
+
|
| 44 |
+
# 푸시 여부 확인
|
| 45 |
+
Write-Host ""
|
| 46 |
+
$push = Read-Host "마스터 브랜치를 원격 저장소에 푸시하시겠습니까? (y/n)"
|
| 47 |
+
if ($push -eq "y") {
|
| 48 |
+
Write-Host ""
|
| 49 |
+
Write-Host "푸시하는 중..." -ForegroundColor Yellow
|
| 50 |
+
git push -u origin master
|
| 51 |
+
Write-Host ""
|
| 52 |
+
Write-Host "푸시가 완료되었습니다!" -ForegroundColor Green
|
| 53 |
+
} else {
|
| 54 |
+
Write-Host ""
|
| 55 |
+
Write-Host "푸시를 건너뛰었습니다. 나중에 다음 명령으로 푸시할 수 있습니다:" -ForegroundColor Yellow
|
| 56 |
+
Write-Host " git push -u origin master" -ForegroundColor Cyan
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
Write-Host ""
|
| 60 |
+
Write-Host "=== 완료 ===" -ForegroundColor Green
|
| 61 |
+
|
| 62 |
+
|
start_server.bat
CHANGED
|
@@ -8,3 +8,4 @@ echo [%date% %time%] 서버가 종료되었습니다. 5초 후 재시작합니
|
|
| 8 |
timeout /t 5 /nobreak >nul
|
| 9 |
goto start
|
| 10 |
|
|
|
|
|
|
| 8 |
timeout /t 5 /nobreak >nul
|
| 9 |
goto start
|
| 10 |
|
| 11 |
+
|
start_server.ps1
CHANGED
|
@@ -27,3 +27,4 @@ while ($true) {
|
|
| 27 |
}
|
| 28 |
}
|
| 29 |
|
|
|
|
|
|
| 27 |
}
|
| 28 |
}
|
| 29 |
|
| 30 |
+
|
start_server_background.ps1
CHANGED
|
@@ -85,3 +85,4 @@ while ($true) {
|
|
| 85 |
Start-Sleep -Seconds 5
|
| 86 |
}
|
| 87 |
|
|
|
|
|
|
| 85 |
Start-Sleep -Seconds 5
|
| 86 |
}
|
| 87 |
|
| 88 |
+
|
templates/admin.html
CHANGED
|
@@ -342,6 +342,86 @@
|
|
| 342 |
color: #c5221f;
|
| 343 |
}
|
| 344 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 345 |
.files-table {
|
| 346 |
margin-top: 16px;
|
| 347 |
}
|
|
@@ -438,6 +518,14 @@
|
|
| 438 |
<div class="card-title">웹소설 학습 파일 관리</div>
|
| 439 |
</div>
|
| 440 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 441 |
<!-- 파일 업로드 -->
|
| 442 |
<div class="file-upload-input-wrapper" id="fileUploadWrapper">
|
| 443 |
<input type="file" id="fileInput" accept=".txt,.md,.pdf,.docx,.epub" multiple>
|
|
@@ -450,13 +538,8 @@
|
|
| 450 |
</label>
|
| 451 |
</div>
|
| 452 |
<div class="file-upload-status" id="fileUploadStatus"></div>
|
| 453 |
-
|
| 454 |
-
|
| 455 |
-
<div style="margin-top: 16px;">
|
| 456 |
-
<label style="display: block; font-size: 14px; font-weight: 500; margin-bottom: 8px;">AI 모델 선택</label>
|
| 457 |
-
<select id="fileModelSelect" style="width: 100%; padding: 10px 12px; border: 1px solid #dadce0; border-radius: 6px; font-size: 14px;">
|
| 458 |
-
<option value="">모델을 선택하세요...</option>
|
| 459 |
-
</select>
|
| 460 |
</div>
|
| 461 |
|
| 462 |
<!-- 업로드 규칙 안내 -->
|
|
@@ -486,13 +569,19 @@
|
|
| 486 |
</div>
|
| 487 |
</div>
|
| 488 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 489 |
<!-- 파일 목록 -->
|
| 490 |
<div class="files-table">
|
| 491 |
<table style="margin-top: 16px;">
|
| 492 |
<thead>
|
| 493 |
<tr>
|
| 494 |
<th>파일명</th>
|
| 495 |
-
<th
|
| 496 |
<th>크기</th>
|
| 497 |
<th>업로드일</th>
|
| 498 |
<th>작업</th>
|
|
@@ -698,24 +787,81 @@
|
|
| 698 |
|
| 699 |
filesTableBody.innerHTML = '';
|
| 700 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 701 |
if (data.files && data.files.length > 0) {
|
| 702 |
data.files.forEach(file => {
|
| 703 |
const row = document.createElement('tr');
|
| 704 |
const fileSize = formatFileSize(file.file_size);
|
| 705 |
const uploadDate = new Date(file.uploaded_at).toLocaleString('ko-KR');
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 706 |
|
| 707 |
row.innerHTML = `
|
| 708 |
-
<td>${
|
| 709 |
-
<td>${
|
| 710 |
<td class="file-size">${fileSize}</td>
|
| 711 |
<td>${uploadDate}</td>
|
| 712 |
<td>
|
| 713 |
<div class="file-actions">
|
|
|
|
| 714 |
<button class="btn btn-secondary" onclick="deleteFile(${file.id})" style="padding: 4px 8px; font-size: 12px;">삭제</button>
|
| 715 |
</div>
|
| 716 |
</td>
|
| 717 |
`;
|
| 718 |
filesTableBody.appendChild(row);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 719 |
});
|
| 720 |
} else {
|
| 721 |
filesTableBody.innerHTML = '<tr><td colspan="5" style="text-align: center; padding: 20px; color: #5f6368;">업로드된 파일이 없습니다.</td></tr>';
|
|
@@ -752,52 +898,148 @@
|
|
| 752 |
return;
|
| 753 |
}
|
| 754 |
|
| 755 |
-
|
| 756 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 757 |
|
| 758 |
let successCount = 0;
|
| 759 |
let failCount = 0;
|
|
|
|
| 760 |
|
|
|
|
| 761 |
for (let i = 0; i < files.length; i++) {
|
| 762 |
const file = files[i];
|
| 763 |
const formData = new FormData();
|
| 764 |
formData.append('file', file);
|
| 765 |
formData.append('model_name', modelName);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 766 |
|
| 767 |
try {
|
|
|
|
|
|
|
| 768 |
const response = await fetch('/api/upload', {
|
| 769 |
method: 'POST',
|
| 770 |
body: formData
|
| 771 |
});
|
| 772 |
|
| 773 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 774 |
|
| 775 |
if (response.ok) {
|
| 776 |
successCount++;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 777 |
} else {
|
| 778 |
failCount++;
|
| 779 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 780 |
}
|
| 781 |
} catch (error) {
|
| 782 |
failCount++;
|
| 783 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 784 |
}
|
|
|
|
|
|
|
|
|
|
| 785 |
}
|
| 786 |
|
|
|
|
|
|
|
| 787 |
if (successCount > 0) {
|
|
|
|
|
|
|
| 788 |
showAlert(`${successCount}개 파일이 성공적으로 업로드되었습니다.${failCount > 0 ? ` (${failCount}개 실패)` : ''}`, 'success');
|
| 789 |
loadFiles();
|
| 790 |
} else {
|
| 791 |
-
|
|
|
|
|
|
|
|
|
|
| 792 |
}
|
| 793 |
|
| 794 |
-
|
|
|
|
|
|
|
|
|
|
| 795 |
fileInput.value = '';
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 796 |
}
|
| 797 |
|
| 798 |
// 파일 삭제
|
| 799 |
async function deleteFile(fileId) {
|
| 800 |
-
if (!confirm('이 파일을
|
| 801 |
return;
|
| 802 |
}
|
| 803 |
|
|
@@ -809,7 +1051,11 @@
|
|
| 809 |
const data = await response.json();
|
| 810 |
|
| 811 |
if (response.ok) {
|
| 812 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 813 |
loadFiles();
|
| 814 |
} else {
|
| 815 |
showAlert(data.error || '삭제 중 오류가 발생했습니다.', 'error');
|
|
@@ -819,11 +1065,28 @@
|
|
| 819 |
}
|
| 820 |
}
|
| 821 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 822 |
// 파일 입력 이벤트
|
| 823 |
fileInput.addEventListener('change', function(e) {
|
| 824 |
if (e.target.files.length > 0) {
|
| 825 |
handleFileUpload(Array.from(e.target.files));
|
| 826 |
}
|
|
|
|
|
|
|
| 827 |
});
|
| 828 |
|
| 829 |
// 드래그 앤 드롭
|
|
|
|
| 342 |
color: #c5221f;
|
| 343 |
}
|
| 344 |
|
| 345 |
+
.file-upload-status.progress {
|
| 346 |
+
color: #1a73e8;
|
| 347 |
+
font-weight: 500;
|
| 348 |
+
}
|
| 349 |
+
|
| 350 |
+
.file-upload-progress {
|
| 351 |
+
margin-top: 12px;
|
| 352 |
+
padding: 12px;
|
| 353 |
+
background: #f8f9fa;
|
| 354 |
+
border-radius: 6px;
|
| 355 |
+
display: none;
|
| 356 |
+
}
|
| 357 |
+
|
| 358 |
+
.file-upload-progress.active {
|
| 359 |
+
display: block;
|
| 360 |
+
}
|
| 361 |
+
|
| 362 |
+
.progress-item {
|
| 363 |
+
display: flex;
|
| 364 |
+
align-items: center;
|
| 365 |
+
justify-content: space-between;
|
| 366 |
+
padding: 8px 0;
|
| 367 |
+
border-bottom: 1px solid #e8eaed;
|
| 368 |
+
}
|
| 369 |
+
|
| 370 |
+
.progress-item:last-child {
|
| 371 |
+
border-bottom: none;
|
| 372 |
+
}
|
| 373 |
+
|
| 374 |
+
.progress-item-name {
|
| 375 |
+
flex: 1;
|
| 376 |
+
font-size: 13px;
|
| 377 |
+
color: #202124;
|
| 378 |
+
overflow: hidden;
|
| 379 |
+
text-overflow: ellipsis;
|
| 380 |
+
white-space: nowrap;
|
| 381 |
+
margin-right: 12px;
|
| 382 |
+
}
|
| 383 |
+
|
| 384 |
+
.progress-item-status {
|
| 385 |
+
font-size: 12px;
|
| 386 |
+
font-weight: 500;
|
| 387 |
+
min-width: 80px;
|
| 388 |
+
text-align: right;
|
| 389 |
+
}
|
| 390 |
+
|
| 391 |
+
.progress-item-status.uploading {
|
| 392 |
+
color: #1a73e8;
|
| 393 |
+
}
|
| 394 |
+
|
| 395 |
+
.progress-item-status.success {
|
| 396 |
+
color: #137333;
|
| 397 |
+
}
|
| 398 |
+
|
| 399 |
+
.progress-item-status.error {
|
| 400 |
+
color: #c5221f;
|
| 401 |
+
}
|
| 402 |
+
|
| 403 |
+
.file-upload-input-wrapper.disabled {
|
| 404 |
+
opacity: 0.6;
|
| 405 |
+
pointer-events: none;
|
| 406 |
+
cursor: not-allowed;
|
| 407 |
+
}
|
| 408 |
+
|
| 409 |
+
.spinner {
|
| 410 |
+
display: inline-block;
|
| 411 |
+
width: 12px;
|
| 412 |
+
height: 12px;
|
| 413 |
+
border: 2px solid #e8eaed;
|
| 414 |
+
border-top-color: #1a73e8;
|
| 415 |
+
border-radius: 50%;
|
| 416 |
+
animation: spin 0.8s linear infinite;
|
| 417 |
+
margin-right: 6px;
|
| 418 |
+
vertical-align: middle;
|
| 419 |
+
}
|
| 420 |
+
|
| 421 |
+
@keyframes spin {
|
| 422 |
+
to { transform: rotate(360deg); }
|
| 423 |
+
}
|
| 424 |
+
|
| 425 |
.files-table {
|
| 426 |
margin-top: 16px;
|
| 427 |
}
|
|
|
|
| 518 |
<div class="card-title">웹소설 학습 파일 관리</div>
|
| 519 |
</div>
|
| 520 |
|
| 521 |
+
<!-- 모델 선택 -->
|
| 522 |
+
<div style="margin-bottom: 16px;">
|
| 523 |
+
<label style="display: block; font-size: 14px; font-weight: 500; margin-bottom: 8px;">AI 모델 선택</label>
|
| 524 |
+
<select id="fileModelSelect" style="width: 100%; padding: 10px 12px; border: 1px solid #dadce0; border-radius: 6px; font-size: 14px;">
|
| 525 |
+
<option value="">모델을 선택하세요...</option>
|
| 526 |
+
</select>
|
| 527 |
+
</div>
|
| 528 |
+
|
| 529 |
<!-- 파일 업로드 -->
|
| 530 |
<div class="file-upload-input-wrapper" id="fileUploadWrapper">
|
| 531 |
<input type="file" id="fileInput" accept=".txt,.md,.pdf,.docx,.epub" multiple>
|
|
|
|
| 538 |
</label>
|
| 539 |
</div>
|
| 540 |
<div class="file-upload-status" id="fileUploadStatus"></div>
|
| 541 |
+
<div class="file-upload-progress" id="fileUploadProgress">
|
| 542 |
+
<div id="progressItems"></div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 543 |
</div>
|
| 544 |
|
| 545 |
<!-- 업로드 규칙 안내 -->
|
|
|
|
| 569 |
</div>
|
| 570 |
</div>
|
| 571 |
|
| 572 |
+
<!-- 모델별 통계 -->
|
| 573 |
+
<div id="modelStats" style="margin-top: 20px; padding: 16px; background: #e8f0fe; border-radius: 8px; display: none;">
|
| 574 |
+
<div style="font-size: 14px; font-weight: 500; margin-bottom: 12px; color: #1967d2;">📊 모델별 학습 파일 통계</div>
|
| 575 |
+
<div id="modelStatsContent"></div>
|
| 576 |
+
</div>
|
| 577 |
+
|
| 578 |
<!-- 파일 목록 -->
|
| 579 |
<div class="files-table">
|
| 580 |
<table style="margin-top: 16px;">
|
| 581 |
<thead>
|
| 582 |
<tr>
|
| 583 |
<th>파일명</th>
|
| 584 |
+
<th>연결된 모델</th>
|
| 585 |
<th>크기</th>
|
| 586 |
<th>업로드일</th>
|
| 587 |
<th>작업</th>
|
|
|
|
| 787 |
|
| 788 |
filesTableBody.innerHTML = '';
|
| 789 |
|
| 790 |
+
// 모델별 통계 표시
|
| 791 |
+
if (data.model_stats && Object.keys(data.model_stats).length > 0) {
|
| 792 |
+
const statsContainer = document.getElementById('modelStats');
|
| 793 |
+
const statsContent = document.getElementById('modelStatsContent');
|
| 794 |
+
statsContainer.style.display = 'block';
|
| 795 |
+
|
| 796 |
+
let statsHtml = '<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 12px;">';
|
| 797 |
+
for (const [model, stats] of Object.entries(data.model_stats)) {
|
| 798 |
+
const totalSize = formatFileSize(stats.total_size);
|
| 799 |
+
statsHtml += `
|
| 800 |
+
<div style="background: white; padding: 12px; border-radius: 6px; border-left: 4px solid #1a73e8;">
|
| 801 |
+
<div style="font-weight: 500; margin-bottom: 4px; color: #202124;">${escapeHtml(model || '미지정')}</div>
|
| 802 |
+
<div style="font-size: 12px; color: #5f6368;">
|
| 803 |
+
파일: <strong>${stats.count}개</strong><br>
|
| 804 |
+
총 크기: <strong>${totalSize}</strong>
|
| 805 |
+
</div>
|
| 806 |
+
</div>
|
| 807 |
+
`;
|
| 808 |
+
}
|
| 809 |
+
statsHtml += '</div>';
|
| 810 |
+
statsContent.innerHTML = statsHtml;
|
| 811 |
+
} else {
|
| 812 |
+
document.getElementById('modelStats').style.display = 'none';
|
| 813 |
+
}
|
| 814 |
+
|
| 815 |
if (data.files && data.files.length > 0) {
|
| 816 |
data.files.forEach(file => {
|
| 817 |
const row = document.createElement('tr');
|
| 818 |
const fileSize = formatFileSize(file.file_size);
|
| 819 |
const uploadDate = new Date(file.uploaded_at).toLocaleString('ko-KR');
|
| 820 |
+
const modelName = file.model_name || '<span style="color: #c5221f;">미지정</span>';
|
| 821 |
+
const childFiles = file.child_files || [];
|
| 822 |
+
const childCount = childFiles.length;
|
| 823 |
+
|
| 824 |
+
// 원본 파일 행
|
| 825 |
+
let fileNameDisplay = escapeHtml(file.original_filename);
|
| 826 |
+
if (childCount > 0) {
|
| 827 |
+
fileNameDisplay += ` <span style="color: #1a73e8; font-size: 12px;">(${childCount}개 회차)</span>`;
|
| 828 |
+
}
|
| 829 |
|
| 830 |
row.innerHTML = `
|
| 831 |
+
<td>${fileNameDisplay}</td>
|
| 832 |
+
<td>${modelName}</td>
|
| 833 |
<td class="file-size">${fileSize}</td>
|
| 834 |
<td>${uploadDate}</td>
|
| 835 |
<td>
|
| 836 |
<div class="file-actions">
|
| 837 |
+
<button class="btn btn-primary" onclick="continueUpload(${file.id})" style="padding: 4px 8px; font-size: 12px; margin-right: 4px;">이어서 업로드</button>
|
| 838 |
<button class="btn btn-secondary" onclick="deleteFile(${file.id})" style="padding: 4px 8px; font-size: 12px;">삭제</button>
|
| 839 |
</div>
|
| 840 |
</td>
|
| 841 |
`;
|
| 842 |
filesTableBody.appendChild(row);
|
| 843 |
+
|
| 844 |
+
// 이어서 업로드된 파일들 표시
|
| 845 |
+
childFiles.forEach((childFile, index) => {
|
| 846 |
+
const childRow = document.createElement('tr');
|
| 847 |
+
const childFileSize = formatFileSize(childFile.file_size);
|
| 848 |
+
const childUploadDate = new Date(childFile.uploaded_at).toLocaleString('ko-KR');
|
| 849 |
+
|
| 850 |
+
childRow.innerHTML = `
|
| 851 |
+
<td style="padding-left: 32px; color: #5f6368;">
|
| 852 |
+
<span style="margin-right: 8px;">└─</span>${escapeHtml(childFile.original_filename)}
|
| 853 |
+
</td>
|
| 854 |
+
<td>${modelName}</td>
|
| 855 |
+
<td class="file-size">${childFileSize}</td>
|
| 856 |
+
<td>${childUploadDate}</td>
|
| 857 |
+
<td>
|
| 858 |
+
<div class="file-actions">
|
| 859 |
+
<button class="btn btn-secondary" onclick="deleteFile(${childFile.id})" style="padding: 4px 8px; font-size: 12px;">삭제</button>
|
| 860 |
+
</div>
|
| 861 |
+
</td>
|
| 862 |
+
`;
|
| 863 |
+
filesTableBody.appendChild(childRow);
|
| 864 |
+
});
|
| 865 |
});
|
| 866 |
} else {
|
| 867 |
filesTableBody.innerHTML = '<tr><td colspan="5" style="text-align: center; padding: 20px; color: #5f6368;">업로드된 파일이 없습니다.</td></tr>';
|
|
|
|
| 898 |
return;
|
| 899 |
}
|
| 900 |
|
| 901 |
+
// 업로드 중 UI 비활성화
|
| 902 |
+
fileUploadWrapper.classList.add('disabled');
|
| 903 |
+
fileModelSelect.disabled = true;
|
| 904 |
+
fileInput.disabled = true;
|
| 905 |
+
|
| 906 |
+
// 진행 상태 초기화
|
| 907 |
+
const progressContainer = document.getElementById('fileUploadProgress');
|
| 908 |
+
const progressItems = document.getElementById('progressItems');
|
| 909 |
+
progressContainer.classList.add('active');
|
| 910 |
+
progressItems.innerHTML = '';
|
| 911 |
+
|
| 912 |
+
// 각 파일에 대한 진행 항목 생성
|
| 913 |
+
const progressMap = new Map();
|
| 914 |
+
files.forEach((file, index) => {
|
| 915 |
+
const item = document.createElement('div');
|
| 916 |
+
item.className = 'progress-item';
|
| 917 |
+
item.id = `progress-item-${index}`;
|
| 918 |
+
item.innerHTML = `
|
| 919 |
+
<span class="progress-item-name">${escapeHtml(file.name)}</span>
|
| 920 |
+
<span class="progress-item-status uploading" id="progress-status-${index}">
|
| 921 |
+
<span class="spinner"></span>업로드 중...
|
| 922 |
+
</span>
|
| 923 |
+
`;
|
| 924 |
+
progressItems.appendChild(item);
|
| 925 |
+
progressMap.set(index, { file, item, status: 'uploading' });
|
| 926 |
+
});
|
| 927 |
+
|
| 928 |
+
fileUploadStatus.textContent = `총 ${files.length}개 파일 업로드 중...`;
|
| 929 |
+
fileUploadStatus.className = 'file-upload-status progress';
|
| 930 |
|
| 931 |
let successCount = 0;
|
| 932 |
let failCount = 0;
|
| 933 |
+
const errors = [];
|
| 934 |
|
| 935 |
+
// 파일을 순차적으로 업로드
|
| 936 |
for (let i = 0; i < files.length; i++) {
|
| 937 |
const file = files[i];
|
| 938 |
const formData = new FormData();
|
| 939 |
formData.append('file', file);
|
| 940 |
formData.append('model_name', modelName);
|
| 941 |
+
|
| 942 |
+
// 이어서 업로드인 경우 parent_file_id 추가
|
| 943 |
+
if (continueUploadFileId) {
|
| 944 |
+
formData.append('parent_file_id', continueUploadFileId);
|
| 945 |
+
}
|
| 946 |
+
|
| 947 |
+
const statusElement = document.getElementById(`progress-status-${i}`);
|
| 948 |
+
const itemElement = document.getElementById(`progress-item-${i}`);
|
| 949 |
|
| 950 |
try {
|
| 951 |
+
console.log(`[업로드 시작] 파일: ${file.name}, 크기: ${file.size} bytes, 모델: ${modelName}`);
|
| 952 |
+
|
| 953 |
const response = await fetch('/api/upload', {
|
| 954 |
method: 'POST',
|
| 955 |
body: formData
|
| 956 |
});
|
| 957 |
|
| 958 |
+
console.log(`[응답 수신] 상태: ${response.status} ${response.statusText}, Content-Type: ${response.headers.get('Content-Type')}`);
|
| 959 |
+
|
| 960 |
+
let data;
|
| 961 |
+
let responseText = '';
|
| 962 |
+
try {
|
| 963 |
+
responseText = await response.text();
|
| 964 |
+
console.log(`[응답 본문] ${responseText.substring(0, 500)}`);
|
| 965 |
+
data = JSON.parse(responseText);
|
| 966 |
+
} catch (jsonError) {
|
| 967 |
+
// JSON 파싱 실패 시 응답 텍스트 사용
|
| 968 |
+
console.error(`[JSON 파싱 실패]`, jsonError, `응답 텍스트:`, responseText);
|
| 969 |
+
throw new Error(`서버 응답 오류 (${response.status}): ${responseText.substring(0, 200)}`);
|
| 970 |
+
}
|
| 971 |
|
| 972 |
if (response.ok) {
|
| 973 |
successCount++;
|
| 974 |
+
const modelName = data.model_name || '알 수 없음';
|
| 975 |
+
const chunkCount = data.chunk_count || 0;
|
| 976 |
+
statusElement.className = 'progress-item-status success';
|
| 977 |
+
statusElement.innerHTML = '✓ 완료';
|
| 978 |
+
statusElement.title = `모델: ${modelName}${chunkCount > 0 ? `, 청크: ${chunkCount}개` : ''}`;
|
| 979 |
+
itemElement.style.opacity = '0.7';
|
| 980 |
+
console.log(`[업로드 성공] ${file.name} → 모델: ${modelName}, 청크: ${chunkCount}개`);
|
| 981 |
} else {
|
| 982 |
failCount++;
|
| 983 |
+
const errorMsg = data.error || data.message || `HTTP ${response.status} 오류`;
|
| 984 |
+
statusElement.className = 'progress-item-status error';
|
| 985 |
+
statusElement.innerHTML = '✗ 실패';
|
| 986 |
+
statusElement.title = errorMsg; // 툴팁으로 상세 에러 표시
|
| 987 |
+
statusElement.style.cursor = 'help'; // 툴팁 표시를 위한 커서 변경
|
| 988 |
+
errors.push(`${file.name}: ${errorMsg}`);
|
| 989 |
+
console.error(`[업로드 실패] 파일: ${file.name}`, {
|
| 990 |
+
status: response.status,
|
| 991 |
+
statusText: response.statusText,
|
| 992 |
+
error: errorMsg,
|
| 993 |
+
data: data,
|
| 994 |
+
responseText: responseText
|
| 995 |
+
});
|
| 996 |
}
|
| 997 |
} catch (error) {
|
| 998 |
failCount++;
|
| 999 |
+
const errorMsg = error.message || '네트워크 오류';
|
| 1000 |
+
statusElement.className = 'progress-item-status error';
|
| 1001 |
+
statusElement.innerHTML = '✗ 실패';
|
| 1002 |
+
statusElement.title = errorMsg; // 툴팁으로 상세 에러 표시
|
| 1003 |
+
statusElement.style.cursor = 'help'; // 툴팁 표시를 위한 커서 변경
|
| 1004 |
+
errors.push(`${file.name}: ${errorMsg}`);
|
| 1005 |
+
console.error(`[업로드 예외] 파일: ${file.name}`, error);
|
| 1006 |
+
console.error(`[스택 트레이스]`, error.stack);
|
| 1007 |
}
|
| 1008 |
+
|
| 1009 |
+
// 진행 상태 업데이트
|
| 1010 |
+
fileUploadStatus.textContent = `업로드 중... (${i + 1}/${files.length})`;
|
| 1011 |
}
|
| 1012 |
|
| 1013 |
+
// 업로드 완료 처리
|
| 1014 |
+
fileUploadStatus.className = 'file-upload-status';
|
| 1015 |
if (successCount > 0) {
|
| 1016 |
+
fileUploadStatus.textContent = `${successCount}개 파일 업로드 완료${failCount > 0 ? ` (${failCount}개 실패)` : ''}`;
|
| 1017 |
+
fileUploadStatus.className = 'file-upload-status success';
|
| 1018 |
showAlert(`${successCount}개 파일이 성공적으로 업로드되었습니다.${failCount > 0 ? ` (${failCount}개 실패)` : ''}`, 'success');
|
| 1019 |
loadFiles();
|
| 1020 |
} else {
|
| 1021 |
+
fileUploadStatus.textContent = '모든 파일 업로드 실패';
|
| 1022 |
+
fileUploadStatus.className = 'file-upload-status error';
|
| 1023 |
+
const errorDetails = errors.length > 0 ? '\n' + errors.slice(0, 3).join('\n') + (errors.length > 3 ? `\n... 외 ${errors.length - 3}개 오류` : '') : '';
|
| 1024 |
+
showAlert(`파일 업로드에 실패했습니다.${errorDetails}`, 'error');
|
| 1025 |
}
|
| 1026 |
|
| 1027 |
+
// UI 활성화
|
| 1028 |
+
fileUploadWrapper.classList.remove('disabled');
|
| 1029 |
+
fileModelSelect.disabled = false;
|
| 1030 |
+
fileInput.disabled = false;
|
| 1031 |
fileInput.value = '';
|
| 1032 |
+
|
| 1033 |
+
// 3초 후 진행 상태 숨기기
|
| 1034 |
+
setTimeout(() => {
|
| 1035 |
+
progressContainer.classList.remove('active');
|
| 1036 |
+
fileUploadStatus.textContent = '';
|
| 1037 |
+
}, 3000);
|
| 1038 |
}
|
| 1039 |
|
| 1040 |
// 파일 삭제
|
| 1041 |
async function deleteFile(fileId) {
|
| 1042 |
+
if (!confirm('이 파일을 삭제하시겠습니까?\n원본 파일인 경우 이어서 업로드한 모든 회차도 함께 삭제됩니다.')) {
|
| 1043 |
return;
|
| 1044 |
}
|
| 1045 |
|
|
|
|
| 1051 |
const data = await response.json();
|
| 1052 |
|
| 1053 |
if (response.ok) {
|
| 1054 |
+
if (data.deleted_count > 1) {
|
| 1055 |
+
showAlert(`${data.message} (삭제된 파일: ${data.deleted_files.join(', ')})`, 'success');
|
| 1056 |
+
} else {
|
| 1057 |
+
showAlert(data.message, 'success');
|
| 1058 |
+
}
|
| 1059 |
loadFiles();
|
| 1060 |
} else {
|
| 1061 |
showAlert(data.error || '삭제 중 오류가 발생했습니다.', 'error');
|
|
|
|
| 1065 |
}
|
| 1066 |
}
|
| 1067 |
|
| 1068 |
+
// 이어서 업로드
|
| 1069 |
+
let continueUploadFileId = null;
|
| 1070 |
+
|
| 1071 |
+
function continueUpload(fileId) {
|
| 1072 |
+
continueUploadFileId = fileId;
|
| 1073 |
+
// 모델 선택 확인
|
| 1074 |
+
const modelName = fileModelSelect.value;
|
| 1075 |
+
if (!modelName) {
|
| 1076 |
+
showAlert('먼저 AI 모델을 선택해주세요.', 'error');
|
| 1077 |
+
continueUploadFileId = null;
|
| 1078 |
+
return;
|
| 1079 |
+
}
|
| 1080 |
+
fileInput.click(); // 파일 선택 다이얼로그 열기
|
| 1081 |
+
}
|
| 1082 |
+
|
| 1083 |
// 파일 입력 이벤트
|
| 1084 |
fileInput.addEventListener('change', function(e) {
|
| 1085 |
if (e.target.files.length > 0) {
|
| 1086 |
handleFileUpload(Array.from(e.target.files));
|
| 1087 |
}
|
| 1088 |
+
// 이어서 업로드 모드 초기화
|
| 1089 |
+
continueUploadFileId = null;
|
| 1090 |
});
|
| 1091 |
|
| 1092 |
// 드래그 앤 드롭
|
templates/admin_messages.html
CHANGED
|
@@ -693,3 +693,4 @@
|
|
| 693 |
</body>
|
| 694 |
</html>
|
| 695 |
|
|
|
|
|
|
| 693 |
</body>
|
| 694 |
</html>
|
| 695 |
|
| 696 |
+
|
templates/index.html
CHANGED
|
@@ -1299,27 +1299,9 @@
|
|
| 1299 |
currentSessionId = await createNewSession();
|
| 1300 |
}
|
| 1301 |
|
| 1302 |
-
// 사용자 메시지 표시
|
| 1303 |
addMessage('user', message, false);
|
| 1304 |
|
| 1305 |
-
// DB에 사용자 메시지 저장
|
| 1306 |
-
if (currentSessionId) {
|
| 1307 |
-
try {
|
| 1308 |
-
await fetch(`/api/chat/sessions/${currentSessionId}/messages`, {
|
| 1309 |
-
method: 'POST',
|
| 1310 |
-
headers: {
|
| 1311 |
-
'Content-Type': 'application/json',
|
| 1312 |
-
},
|
| 1313 |
-
body: JSON.stringify({
|
| 1314 |
-
role: 'user',
|
| 1315 |
-
content: message
|
| 1316 |
-
})
|
| 1317 |
-
});
|
| 1318 |
-
} catch (error) {
|
| 1319 |
-
console.error('메시지 저장 오류:', error);
|
| 1320 |
-
}
|
| 1321 |
-
}
|
| 1322 |
-
|
| 1323 |
messageInput.value = '';
|
| 1324 |
messageInput.style.height = 'auto';
|
| 1325 |
|
|
@@ -1353,8 +1335,14 @@
|
|
| 1353 |
addMessage('ai', aiResponse, false);
|
| 1354 |
|
| 1355 |
// DB에 AI 응답 저장 (이미 백엔드에서 저장됨)
|
| 1356 |
-
// 세션 목록 새로고침
|
| 1357 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1358 |
} else {
|
| 1359 |
const error = await response.json().catch(() => ({ error: '서버 오류' }));
|
| 1360 |
addMessage('ai', `오류: ${error.error || '알 수 없는 오류가 발생했습니다.'}`, false);
|
|
@@ -1363,6 +1351,8 @@
|
|
| 1363 |
removeTypingIndicator();
|
| 1364 |
addMessage('ai', `연결 오류: ${error.message}`, false);
|
| 1365 |
} finally {
|
|
|
|
|
|
|
| 1366 |
// 입력 활성화
|
| 1367 |
messageInput.disabled = false;
|
| 1368 |
sendButton.disabled = false;
|
|
|
|
| 1299 |
currentSessionId = await createNewSession();
|
| 1300 |
}
|
| 1301 |
|
| 1302 |
+
// 사용자 메시지 표시 (DB 저장은 /api/chat에서 처리)
|
| 1303 |
addMessage('user', message, false);
|
| 1304 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1305 |
messageInput.value = '';
|
| 1306 |
messageInput.style.height = 'auto';
|
| 1307 |
|
|
|
|
| 1335 |
addMessage('ai', aiResponse, false);
|
| 1336 |
|
| 1337 |
// DB에 AI 응답 저장 (이미 백엔드에서 저장됨)
|
| 1338 |
+
// 세션 목록 새로고침 (제목 업데이트 반영)
|
| 1339 |
+
if (data.session && data.session.title) {
|
| 1340 |
+
// 세션 제목이 업데이트되었으면 목록 새로고침
|
| 1341 |
+
await loadChatHistory();
|
| 1342 |
+
} else {
|
| 1343 |
+
// 세션 정보가 없어도 목록 새로고침
|
| 1344 |
+
await loadChatHistory();
|
| 1345 |
+
}
|
| 1346 |
} else {
|
| 1347 |
const error = await response.json().catch(() => ({ error: '서버 오류' }));
|
| 1348 |
addMessage('ai', `오류: ${error.error || '알 수 없는 오류가 발생했습니다.'}`, false);
|
|
|
|
| 1351 |
removeTypingIndicator();
|
| 1352 |
addMessage('ai', `연결 오류: ${error.message}`, false);
|
| 1353 |
} finally {
|
| 1354 |
+
// 전송 상태 해제
|
| 1355 |
+
isSending = false;
|
| 1356 |
// 입력 활성화
|
| 1357 |
messageInput.disabled = false;
|
| 1358 |
sendButton.disabled = false;
|
templates/login.html
CHANGED
|
@@ -159,3 +159,4 @@
|
|
| 159 |
</body>
|
| 160 |
</html>
|
| 161 |
|
|
|
|
|
|
| 159 |
</body>
|
| 160 |
</html>
|
| 161 |
|
| 162 |
+
|