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 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
- """데이터베이스 마이그레이션 - nickname 컬럼 추가"""
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
- columns = [column[1] for column in cursor.fetchall()]
60
 
61
- if 'nickname' not in columns:
 
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"마이그레이션 오류 (무시 가능): {e}")
 
 
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
- if not os.path.exists(UPLOAD_FOLDER):
39
- os.makedirs(UPLOAD_FOLDER)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
40
 
41
- def split_text_into_chunks(text, chunk_size=500, overlap=50):
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=500, overlap=50)
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=5):
78
- """질문과 관련된 청크 검색 (키워드 기반)"""
79
  try:
80
- # 검색 쿼리 준비
81
- query_words = set(re.findall(r'\w+', query.lower()))
 
 
 
82
 
83
  # 청크 조회
84
  query_obj = DocumentChunk.query.join(UploadedFile)
85
 
86
  if file_ids:
87
- query_obj = query_obj.filter(UploadedFile.id.in_(file_ids))
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- chunk_words = set(re.findall(r'\w+', chunk.content.lower()))
101
- # 공통 단어 수로 점수 계산
102
- score = len(query_words & chunk_words)
103
- if score > 0:
104
- scored_chunks.append((score, chunk))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
105
 
106
  # 점수 순으로 정렬하고 상위 k개 선택
107
  scored_chunks.sort(key=lambda x: x[0], reverse=True)
108
- top_chunks = [chunk for _, chunk in scored_chunks[:top_k]]
 
 
 
 
 
 
 
 
 
 
 
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
- context = "\n\n".join(context_parts)
405
- context = f"다음은 질문과 관련된 웹소설 내용입니다 (RAG 검색 결과):\n\n{context}\n\n위 내용을 참고하여 다음 질문에 정확하게 답변해주세요:\n\n"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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_(file_ids),
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
- # 파일 내용이 너무 길면 일부만 사용 (최대 10000자로 증가)
436
- if len(file_content) > 10000:
437
- file_content = file_content[:10000] + "..."
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
- user_msg = ChatMessage(
 
478
  session_id=session_id,
479
- role='user',
480
- content=message
481
- )
482
- db.session.add(user_msg)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- return jsonify({'response': response_text, 'session_id': session_id})
 
 
 
 
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
- ensure_upload_folder()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
523
 
524
  if 'file' not in request.files:
525
- return jsonify({'error': '파일이 없습니다.'}), 400
 
 
 
526
 
527
  file = request.files['file']
528
- model_name = request.form.get('model_name', '')
 
 
 
 
 
529
 
530
  if file.filename == '':
531
- return jsonify({'error': '파일명이 없습니다.'}), 400
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
532
 
533
  if not allowed_file(file.filename):
534
- return jsonify({'error': f'허용되지 않은 파일 형식입니다. 허용 형식: {", ".join(ALLOWED_EXTENSIONS)}'}), 400
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- file.save(file_path)
544
- file_size = os.path.getsize(file_path)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
545
 
546
  # 데이터베이스에 저장
547
- uploaded_file = UploadedFile(
548
- filename=unique_filename,
549
- original_filename=original_filename,
550
- file_path=file_path,
551
- file_size=file_size,
552
- model_name=model_name if model_name else None,
553
- uploaded_by=current_user.id
554
- )
555
- db.session.add(uploaded_file)
556
- db.session.flush() # ID를 얻기 위해 flush
557
-
558
- # 텍스트 파일인 경우 청크로 분할하여 저장 (RAG용)
559
- if original_filename.lower().endswith(('.txt', '.md')):
560
- try:
561
- encoding = 'utf-8'
 
 
562
  try:
563
- with open(file_path, 'r', encoding=encoding) as f:
564
- content = f.read()
565
- except UnicodeDecodeError:
566
- with open(file_path, 'r', encoding='cp949') as f:
567
- content = f.read()
568
-
569
- chunk_count = create_chunks_for_file(uploaded_file.id, content)
570
- if chunk_count > 0:
571
- print(f"파일 {original_filename}을 {chunk_count}개의 청크로 분할했습니다.")
572
- except Exception as e:
573
- print(f"청크 생성 중 오류 (무시): {str(e)}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
574
 
575
- db.session.commit()
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
- return jsonify({'error': f'파일 업로드 중 오류가 발생했습니다: {str(e)}'}), 500
 
 
 
 
 
 
 
 
 
 
 
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
- query = UploadedFile.query
 
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': [file.to_dict() for file in 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
- if os.path.exists(file.file_path):
615
- os.remove(file.file_path)
616
 
617
- # 관련 청크도 삭제 (CASCADE로 자동 삭제되지만 명시적으로 처리)
618
- DocumentChunk.query.filter_by(file_id=file_id).delete()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
619
 
620
- # 데이터베이스에서 삭제
621
- db.session.delete(file)
622
  db.session.commit()
623
 
624
- return jsonify({'message': '파일이 성공적으로 삭제되었습니다.'}), 200
 
 
 
 
 
 
 
 
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>모델</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>${escapeHtml(file.original_filename)}</td>
709
- <td>${file.model_name || '-'}</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
- fileUploadStatus.textContent = `${files.length}개 파일 업로드 중...`;
756
- fileUploadStatus.className = 'file-upload-status';
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- const data = await response.json();
 
 
 
 
 
 
 
 
 
 
 
 
774
 
775
  if (response.ok) {
776
  successCount++;
 
 
 
 
 
 
 
777
  } else {
778
  failCount++;
779
- console.error(`파일 업로드 실패 (${file.name}):`, data.error);
 
 
 
 
 
 
 
 
 
 
 
 
780
  }
781
  } catch (error) {
782
  failCount++;
783
- console.error(`파일 업로드 오류 (${file.name}):`, error);
 
 
 
 
 
 
 
784
  }
 
 
 
785
  }
786
 
 
 
787
  if (successCount > 0) {
 
 
788
  showAlert(`${successCount}개 파일이 성공적으로 업로드되었습니다.${failCount > 0 ? ` (${failCount}개 실패)` : ''}`, 'success');
789
  loadFiles();
790
  } else {
791
- showAlert('파일 업로드에 실패했습니다.', 'error');
 
 
 
792
  }
793
 
794
- fileUploadStatus.textContent = '';
 
 
 
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
- showAlert(data.message, 'success');
 
 
 
 
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
- await loadChatHistory();
 
 
 
 
 
 
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
+