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

feat: 파일 업로드 기능 개선 및 Parent Chunk 생성 활성화

Browse files

- 파일 업로드 시 Parent Chunk 자동 생성 기능 활성화
- 파일 업로드 진행 상황 단계별 표시 기능 추가
- fetch 요청에 credentials: 'include' 추가 (세션 인증)
- 인증 오류 및 리다이렉트 감지 기능 추가
- 요청 타임아웃 5분 설정
- 서버 로그 강화 (타임스탬프, 헤더 등)
- 프로젝트 리팩토링: core, models, prompts, utils 모듈 분리
- .cursorrules 추가 및 코드 구조 개선
- vector_db 바이너리 파일 gitignore 추가

.gitignore CHANGED
@@ -35,4 +35,7 @@ Thumbs.db
35
  uploads/*
36
  !uploads/.gitkeep
37
 
 
 
 
38
 
 
35
  uploads/*
36
  !uploads/.gitkeep
37
 
38
+ # Vector DB
39
+ vector_db/
40
+
41
 
app/.cursorrules CHANGED
@@ -0,0 +1,69 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Role & Perspective
2
+ You are an expert Python AI Engineer specializing in NLP and LLM integration.
3
+ You are building an "SOY NV AI" that helps users generate plots, characters, and story chapters.
4
+ Your goals are:
5
+ 1. Write clean, modular, and asynchronous Python code.
6
+ 2. Manage LLM context and tokens efficiently.
7
+ 3. Maintain structured data for story elements (Characters, World-building).
8
+
9
+ # General Guidelines
10
+ - **Python Version**: Use **Python 3.10+** syntax features (e.g., structural pattern matching `match/case`).
11
+ - **Style**: Follow **PEP 8** style guidelines strictly.
12
+ - **Prefer Explicit**: Code should be explicit. Avoid "magic" implicit behaviors.
13
+ - **Modularity**: Break down complex logic into small, pure helper functions.
14
+ - **Language**: All code logic in English, but **Docstrings and Comments must be in Korean**.
15
+
16
+ # Type Hinting & Safety
17
+ - **Strict Type Hints**: Mandatory for all function arguments, return values, and class attributes.
18
+ - **No `Any`**: Avoid using `Any` unless absolutely necessary.
19
+ - **Data Validation**: Use `pydantic` models for all complex data structures.
20
+
21
+ # Error Handling
22
+ - Use specific exceptions (e.g., `ValueError`, `KeyError`) instead of bare `except:`.
23
+ - Implement robust error logging using the `logging` module (not `print`).
24
+ - Handle API failures gracefully (e.g., use `tenacity` for retries).
25
+
26
+ # Libraries & Paths
27
+ - **Path Handling**: ALWAYS use `pathlib` instead of `os.path`.
28
+ - **Environment**: Use `pydantic-settings` or `python-dotenv` to manage secrets. NEVER hardcode API keys.
29
+
30
+ # Testing (Cost Saving)
31
+ - **Framework**: Use `pytest`.
32
+ - **Mocking**: NEVER call real LLM APIs in unit tests. Use `unittest.mock` or `pytest-mock` to mock responses and save costs.
33
+ - **Fixtures**: Use fixtures for setup/teardown.
34
+
35
+ # AI & LLM Specific Guidelines
36
+ - **Structured Output**: Always use **Pydantic models** to define schemas for LLM responses.
37
+ - **Prompt Management**: Keep prompt templates separate in `src/prompts/`. Do not embed long strings in code.
38
+ - **Async**: Use `asyncio` for all LLM API calls to prevent blocking the event loop.
39
+
40
+ # Project Structure (Src Layout)
41
+ Reference this structure. Do not modify existing RAG implementations unless requested.
42
+
43
+ .
44
+ ├── src/
45
+ │ └── novel_assistant/ # Main Package
46
+ │ ├── __init__.py
47
+ │ ├── main.py # Entry point
48
+ │ ├── core/ # Config, Logger
49
+ │ │ └── config.py
50
+ │ ├── models/ # Pydantic Schemas
51
+ │ │ ├── character.py
52
+ │ │ └── story.py
53
+ │ ├── prompts/ # System prompts & Jinja2 templates
54
+ │ │ ├── templates/
55
+ │ │ └── manager.py
56
+ │ ├── services/ # Business logic
57
+ │ │ ├── generation.py # Text generation logic
58
+ │ │ └── memory.py # Context management (Interfaces with existing RAG)
59
+ │ └── utils/ # Helpers
60
+ ├── tests/ # Mirror of src structure
61
+ ├── data/ # Local storage
62
+ ├── .env # API Keys
63
+ └── pyproject.toml
64
+
65
+ # Implementation Process (Chain of Thought)
66
+ 1. **Define Model**: Start by defining the Pydantic model for the data.
67
+ 2. **Draft Prompt**: Check/Create the prompt template.
68
+ 3. **Implement**: Write the async service function.
69
+ 4. **Mock Test**: Write a test case mocking the API call.
app/__init__.py CHANGED
@@ -1,122 +1,180 @@
 
 
 
 
1
  from flask import Flask
2
  from flask_login import LoginManager
3
- from app.database import db, User
4
- import os
5
  import sqlite3
6
  from pathlib import Path
 
 
 
 
 
 
 
7
 
8
  login_manager = LoginManager()
9
  login_manager.login_view = 'main.login'
10
  login_manager.login_message = '로그인이 필요합니다.'
11
  login_manager.login_message_category = 'info'
12
 
 
13
  @login_manager.user_loader
14
- def load_user(user_id):
15
- return User.query.get(int(user_id))
 
 
 
 
 
 
 
 
 
 
 
 
 
16
 
17
- def create_app():
18
- # 템플릿 폴더 경로 명시적 설정
19
- template_folder = os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', 'templates')
 
 
 
 
 
 
 
 
 
 
 
 
20
  app = Flask(__name__, template_folder=template_folder)
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)
28
 
 
29
  from app.routes import main_bp
30
  app.register_blueprint(main_bp)
31
 
 
32
  with app.app_context():
33
  db.create_all()
34
- # 데이터베이스 마이그레이션 (nickname 컬럼 추가)
35
  migrate_database(app)
36
- # 초기 관리자 계정 생성
37
  create_admin_user()
38
 
 
 
39
  return app
40
 
41
- def migrate_database(app):
42
- """데이터베이스 마이그레이션"""
 
 
 
 
 
 
43
  try:
44
- # 데이터베이스 URI에서 경로 추출
45
  db_uri = app.config['SQLALCHEMY_DATABASE_URI']
46
- if db_uri.startswith('sqlite:///'):
47
- db_path = db_uri.replace('sqlite:///', '')
48
- # 상대 경로인 경우 instance 폴더 기준으로 처리
49
- if not os.path.isabs(db_path):
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)
57
- cursor = conn.cursor()
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
- # document_chunk 테이블이 존재하는지 확인
90
- cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='document_chunk'")
91
- if cursor.fetchone():
92
- # document_chunk 테이블에 chunk_metadata 컬럼이 있는지 확인
93
- cursor.execute("PRAGMA table_info(document_chunk)")
94
- document_chunk_columns = [column[1] for column in cursor.fetchall()]
95
-
96
- if 'chunk_metadata' not in document_chunk_columns:
97
- print("[마이그레이션] document_chunk 테이블에 chunk_metadata 컬럼 추가 중...")
98
- cursor.execute("ALTER TABLE document_chunk ADD COLUMN chunk_metadata TEXT")
99
- conn.commit()
100
- print("[마이그레이션] document_chunk.chunk_metadata 컬럼 추가 완료")
101
 
102
- conn.close()
103
- print("[마이그레이션] 데이터베이스 마이그레이션 완료")
 
 
 
 
 
 
 
 
 
104
  except Exception as e:
105
- print(f"[마이그레이션] 오류 발생: {e}")
106
- import traceback
107
- traceback.print_exc()
108
 
109
- def create_admin_user():
110
- """초기 관리자 계정 생성"""
 
 
 
111
  admin_username = 'soymedia'
112
  admin_password = 's0ymedi@1@34'
113
 
114
- admin = User.query.filter_by(username=admin_username).first()
115
- if not admin:
116
- admin = User(username=admin_username, is_admin=True, is_active=True)
117
- admin.set_password(admin_password)
118
- db.session.add(admin)
119
- db.session.commit()
120
- print(f'관리자 계정이 생성되었습니다: {admin_username}')
121
-
122
-
 
 
 
 
 
1
+ """
2
+ Flask 애플리케이션 초기화
3
+ """
4
+
5
  from flask import Flask
6
  from flask_login import LoginManager
 
 
7
  import sqlite3
8
  from pathlib import Path
9
+ from typing import Optional
10
+
11
+ from app.database import db, User
12
+ from app.core.config import Config, get_config
13
+ from app.core.logger import get_logger
14
+
15
+ logger = get_logger(__name__)
16
 
17
  login_manager = LoginManager()
18
  login_manager.login_view = 'main.login'
19
  login_manager.login_message = '로그인이 필요합니다.'
20
  login_manager.login_message_category = 'info'
21
 
22
+
23
  @login_manager.user_loader
24
+ def load_user(user_id: str) -> Optional[User]:
25
+ """
26
+ 사용자 로드 함수 (Flask-Login용)
27
+
28
+ Args:
29
+ user_id: 사용자 ID (문자열)
30
+
31
+ Returns:
32
+ User 객체 또는 None
33
+ """
34
+ try:
35
+ return User.query.get(int(user_id))
36
+ except (ValueError, TypeError):
37
+ return None
38
+
39
 
40
+ def create_app() -> Flask:
41
+ """
42
+ Flask 애플리케이션 팩토리 함수
43
+
44
+ Returns:
45
+ 설정된 Flask 애플리케이션 인스턴스
46
+ """
47
+ config = get_config()
48
+
49
+ # 필수 디렉토리 생성
50
+ config.ensure_directories()
51
+
52
+ # 템플릿 폴더 경로 설정
53
+ template_folder = str(config.TEMPLATES_FOLDER)
54
+
55
  app = Flask(__name__, template_folder=template_folder)
 
 
 
 
56
 
57
+ # Flask 설정 적용
58
+ app.config['SECRET_KEY'] = config.SECRET_KEY
59
+ app.config['SQLALCHEMY_DATABASE_URI'] = config.SQLALCHEMY_DATABASE_URI
60
+ app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = config.SQLALCHEMY_TRACK_MODIFICATIONS
61
+ app.config['MAX_CONTENT_LENGTH'] = config.MAX_CONTENT_LENGTH
62
+
63
+ # 확장 초기화
64
  db.init_app(app)
65
  login_manager.init_app(app)
66
 
67
+ # Blueprint 등록
68
  from app.routes import main_bp
69
  app.register_blueprint(main_bp)
70
 
71
+ # 데이터베이스 초기화 및 마이그레이션
72
  with app.app_context():
73
  db.create_all()
 
74
  migrate_database(app)
 
75
  create_admin_user()
76
 
77
+ logger.info("Flask 애플리케이션이 초기화되었습니다.")
78
+
79
  return app
80
 
81
+
82
+ def migrate_database(app: Flask) -> None:
83
+ """
84
+ 데이터베이스 마이그레이션 실행
85
+
86
+ Args:
87
+ app: Flask 애플리케이션 인스턴스
88
+ """
89
  try:
 
90
  db_uri = app.config['SQLALCHEMY_DATABASE_URI']
91
+
92
+ if not db_uri.startswith('sqlite:///'):
93
+ logger.warning(f"SQLite가 아닌 데이터베이스는 자동 마이그레이션이 지원되지 않습니다: {db_uri}")
94
+ return
95
+
96
+ db_path_str = db_uri.replace('sqlite:///', '')
97
+ db_path = Path(db_path_str)
98
+
99
+ # 상대 경로인 경우 instance 폴더 기준으로 처리
100
+ if not db_path.is_absolute():
101
+ db_path = Path(app.instance_path) / db_path
102
+
103
+ if not db_path.exists():
104
+ logger.info(f"데이터베이스 파일이 없습니다 (새로 생성됨): {db_path}")
105
+ return
106
+
107
+ logger.info(f"데이터베이스 마이그레이션 시작: {db_path}")
108
+
109
+ conn = sqlite3.connect(str(db_path))
110
+ cursor = conn.cursor()
111
+
112
+ # user 테이블에 nickname 컬럼이 있는지 확인
113
+ cursor.execute("PRAGMA table_info(user)")
114
+ user_columns = [column[1] for column in cursor.fetchall()]
115
+
116
+ if 'nickname' not in user_columns:
117
+ logger.info("user 테이블에 nickname 컬럼 추가 중...")
118
+ cursor.execute("ALTER TABLE user ADD COLUMN nickname VARCHAR(80)")
119
+ conn.commit()
120
+ logger.info("user.nickname 컬럼 추가 완료")
121
+
122
+ # uploaded_file 테이블이 존재하는지 확인
123
+ cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='uploaded_file'")
124
+ if cursor.fetchone():
125
+ cursor.execute("PRAGMA table_info(uploaded_file)")
126
+ uploaded_file_columns = [column[1] for column in cursor.fetchall()]
127
 
128
+ if 'uploaded_by' not in uploaded_file_columns:
129
+ logger.info("uploaded_file 테이블에 uploaded_by 컬럼 추가 중...")
130
+ cursor.execute("ALTER TABLE uploaded_file ADD COLUMN uploaded_by INTEGER")
 
 
 
 
 
 
 
131
  conn.commit()
132
+ logger.info("uploaded_file.uploaded_by 컬럼 추가 완료")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
133
 
134
+ if 'parent_file_id' not in uploaded_file_columns:
135
+ logger.info("uploaded_file 테이블에 parent_file_id 컬럼 추가 중...")
136
+ cursor.execute("ALTER TABLE uploaded_file ADD COLUMN parent_file_id INTEGER")
137
+ conn.commit()
138
+ logger.info("uploaded_file.parent_file_id 컬럼 추가 완료")
139
+
140
+ # document_chunk 테이블이 존재하는지 확인
141
+ cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='document_chunk'")
142
+ if cursor.fetchone():
143
+ cursor.execute("PRAGMA table_info(document_chunk)")
144
+ document_chunk_columns = [column[1] for column in cursor.fetchall()]
 
145
 
146
+ if 'chunk_metadata' not in document_chunk_columns:
147
+ logger.info("document_chunk 테이블에 chunk_metadata 컬럼 추가 중...")
148
+ cursor.execute("ALTER TABLE document_chunk ADD COLUMN chunk_metadata TEXT")
149
+ conn.commit()
150
+ logger.info("document_chunk.chunk_metadata 컬럼 추가 완료")
151
+
152
+ conn.close()
153
+ logger.info("데이터베이스 마이그레이션 완료")
154
+
155
+ except sqlite3.Error as e:
156
+ logger.error(f"데이터베이스 마이그레이션 중 SQLite 오류 발생: {e}", exc_info=True)
157
  except Exception as e:
158
+ logger.error(f"데이터베이스 마이그레이션 오류 발생: {e}", exc_info=True)
 
 
159
 
160
+
161
+ def create_admin_user() -> None:
162
+ """
163
+ 초기 관리자 계정 생성
164
+ """
165
  admin_username = 'soymedia'
166
  admin_password = 's0ymedi@1@34'
167
 
168
+ try:
169
+ admin = User.query.filter_by(username=admin_username).first()
170
+ if not admin:
171
+ admin = User(username=admin_username, is_admin=True, is_active=True)
172
+ admin.set_password(admin_password)
173
+ db.session.add(admin)
174
+ db.session.commit()
175
+ logger.info(f'관리자 계정이 생성되었습니다: {admin_username}')
176
+ else:
177
+ logger.debug(f'관리자 계정이 이미 존재합니다: {admin_username}')
178
+ except Exception as e:
179
+ logger.error(f'관리자 계정 생성 중 오류 발생: {e}', exc_info=True)
180
+ db.session.rollback()
app/core/__init__.py ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Core 모듈: 설정 및 로거
3
+ """
4
+
5
+ from app.core.config import get_config
6
+ from app.core.logger import get_logger
7
+
8
+ __all__ = ['get_config', 'get_logger']
9
+
app/core/config.py ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ 설정 관리 모듈
3
+ 환경 변수 및 애플리케이션 설정을 관리합니다.
4
+ """
5
+
6
+ import os
7
+ from pathlib import Path
8
+ from typing import Optional
9
+ from dotenv import load_dotenv
10
+
11
+ # .env 파일 로드
12
+ load_dotenv()
13
+
14
+ # 프로젝트 루트 디렉토리
15
+ PROJECT_ROOT = Path(__file__).parent.parent.parent
16
+
17
+ class Config:
18
+ """애플리케이션 설정 클래스"""
19
+
20
+ # Flask 설정
21
+ SECRET_KEY: str = os.getenv('SECRET_KEY', 'dev-secret-key-change-in-production')
22
+ SQLALCHEMY_DATABASE_URI: str = os.getenv(
23
+ 'DATABASE_URL',
24
+ f'sqlite:///{PROJECT_ROOT / "instance" / "finance_analysis.db"}'
25
+ )
26
+ SQLALCHEMY_TRACK_MODIFICATIONS: bool = False
27
+ MAX_CONTENT_LENGTH: int = 100 * 1024 * 1024 # 100MB
28
+
29
+ # Ollama 설정
30
+ OLLAMA_BASE_URL: str = os.getenv('OLLAMA_BASE_URL', 'http://localhost:11434')
31
+
32
+ # 경로 설정
33
+ UPLOAD_FOLDER: Path = PROJECT_ROOT / 'uploads'
34
+ VECTOR_DB_PATH: Path = PROJECT_ROOT / 'vector_db'
35
+ KNOWLEDGE_GRAPH_PATH: Path = PROJECT_ROOT / 'knowledge_graphs'
36
+ TEMPLATES_FOLDER: Path = PROJECT_ROOT / 'templates'
37
+ INSTANCE_FOLDER: Path = PROJECT_ROOT / 'instance'
38
+
39
+ # 파일 확장자 설정
40
+ ALLOWED_EXTENSIONS: set[str] = {'txt', 'md', 'pdf', 'docx', 'epub'}
41
+
42
+ # 임베딩 모델 설정
43
+ EMBEDDING_MODEL_NAME: str = os.getenv('EMBEDDING_MODEL_NAME', 'sentence-transformers/all-MiniLM-L6-v2')
44
+ RERANKER_MODEL_NAME: str = os.getenv('RERANKER_MODEL_NAME', 'BAAI/bge-reranker-base')
45
+
46
+ # Gemini API 설정
47
+ GEMINI_API_KEY: Optional[str] = os.getenv('GEMINI_API_KEY', None)
48
+
49
+ @classmethod
50
+ def ensure_directories(cls) -> None:
51
+ """필수 디렉토리 생성"""
52
+ cls.UPLOAD_FOLDER.mkdir(parents=True, exist_ok=True)
53
+ cls.VECTOR_DB_PATH.mkdir(parents=True, exist_ok=True)
54
+ cls.KNOWLEDGE_GRAPH_PATH.mkdir(parents=True, exist_ok=True)
55
+ cls.INSTANCE_FOLDER.mkdir(parents=True, exist_ok=True)
56
+
57
+
58
+ def get_config() -> Config:
59
+ """설정 인스턴스 반환"""
60
+ return Config
61
+
app/core/logger.py ADDED
@@ -0,0 +1,65 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ 로깅 설정 모듈
3
+ """
4
+
5
+ import logging
6
+ import sys
7
+ from pathlib import Path
8
+ from typing import Optional
9
+
10
+ # 로그 디렉토리
11
+ LOG_DIR = Path(__file__).parent.parent.parent / 'logs'
12
+ LOG_DIR.mkdir(exist_ok=True)
13
+
14
+ # 로그 포맷
15
+ LOG_FORMAT = '%(asctime)s - %(name)s - %(levelname)s - %(message)s'
16
+ DATE_FORMAT = '%Y-%m-%d %H:%M:%S'
17
+
18
+
19
+ def get_logger(name: str, level: int = logging.INFO) -> logging.Logger:
20
+ """
21
+ 로거 인스턴스 생성 및 반환
22
+
23
+ Args:
24
+ name: 로거 이름 (일반적으로 __name__ 사용)
25
+ level: 로그 레벨 (기본값: INFO)
26
+
27
+ Returns:
28
+ 설정된 로거 인스턴스
29
+ """
30
+ logger = logging.getLogger(name)
31
+
32
+ # 이미 핸들러가 설정되어 있으면 기존 로거 반환
33
+ if logger.handlers:
34
+ return logger
35
+
36
+ logger.setLevel(level)
37
+
38
+ # 콘솔 핸들러
39
+ console_handler = logging.StreamHandler(sys.stdout)
40
+ console_handler.setLevel(level)
41
+ console_formatter = logging.Formatter(LOG_FORMAT, datefmt=DATE_FORMAT)
42
+ console_handler.setFormatter(console_formatter)
43
+ logger.addHandler(console_handler)
44
+
45
+ # 파일 핸들러 (애플리케이션 로그)
46
+ file_handler = logging.FileHandler(
47
+ LOG_DIR / 'app.log',
48
+ encoding='utf-8'
49
+ )
50
+ file_handler.setLevel(logging.DEBUG)
51
+ file_formatter = logging.Formatter(LOG_FORMAT, datefmt=DATE_FORMAT)
52
+ file_handler.setFormatter(file_formatter)
53
+ logger.addHandler(file_handler)
54
+
55
+ # 에러 전용 파일 핸들러
56
+ error_handler = logging.FileHandler(
57
+ LOG_DIR / 'error.log',
58
+ encoding='utf-8'
59
+ )
60
+ error_handler.setLevel(logging.ERROR)
61
+ error_handler.setFormatter(file_formatter)
62
+ logger.addHandler(error_handler)
63
+
64
+ return logger
65
+
app/models/__init__.py ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Pydantic 모델 정의
3
+ 데이터 검증 및 직렬화를 위한 모델들을 정의합니다.
4
+ """
5
+
6
+ from app.models.chunk import (
7
+ ChunkMetadata,
8
+ ChunkCreate,
9
+ ChunkResponse,
10
+ )
11
+ from app.models.file import (
12
+ FileUpload,
13
+ FileResponse,
14
+ )
15
+
16
+ __all__ = [
17
+ 'ChunkMetadata',
18
+ 'ChunkCreate',
19
+ 'ChunkResponse',
20
+ 'FileUpload',
21
+ 'FileResponse',
22
+ ]
23
+
app/models/chunk.py ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ 청크 관련 Pydantic 모델
3
+ """
4
+
5
+ from typing import Optional, List
6
+ from pydantic import BaseModel, Field
7
+
8
+
9
+ class ChunkMetadata(BaseModel):
10
+ """청크 메타데이터 모델"""
11
+
12
+ pov: Optional[str] = Field(None, description="화자/시점")
13
+ characters: Optional[List[str]] = Field(default_factory=list, description="등장인물 목록")
14
+ time_background: Optional[str] = Field(None, description="시간적 배경")
15
+ chapter: Optional[int] = Field(None, description="챕터 번호")
16
+
17
+ class Config:
18
+ """Pydantic 설정"""
19
+ json_schema_extra = {
20
+ "example": {
21
+ "pov": "1인칭 주인공",
22
+ "characters": ["홍길동", "김철수"],
23
+ "time_background": "현재 시점",
24
+ "chapter": 1
25
+ }
26
+ }
27
+
28
+
29
+ class ChunkCreate(BaseModel):
30
+ """청크 생성 요청 모델"""
31
+
32
+ file_id: int = Field(..., description="파일 ID")
33
+ chunk_index: int = Field(..., description="청크 인덱스")
34
+ content: str = Field(..., min_length=1, description="청크 내용")
35
+ metadata: Optional[ChunkMetadata] = Field(None, description="청크 메타데이터")
36
+
37
+
38
+ class ChunkResponse(BaseModel):
39
+ """청크 응답 모델"""
40
+
41
+ id: int
42
+ file_id: int
43
+ chunk_index: int
44
+ content: str
45
+ metadata: Optional[ChunkMetadata] = None
46
+
47
+ class Config:
48
+ """Pydantic 설정"""
49
+ from_attributes = True
50
+
app/models/file.py ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ 파일 관련 Pydantic 모델
3
+ """
4
+
5
+ from typing import Optional
6
+ from pydantic import BaseModel, Field
7
+ from datetime import datetime
8
+
9
+
10
+ class FileUpload(BaseModel):
11
+ """파일 업로드 요청 모델"""
12
+
13
+ filename: str = Field(..., description="파일명")
14
+ file_size: int = Field(..., ge=0, description="파일 크기")
15
+ model_name: Optional[str] = Field(None, description="연결된 모델 이름")
16
+ parent_file_id: Optional[int] = Field(None, description="부모 파일 ID")
17
+
18
+
19
+ class FileResponse(BaseModel):
20
+ """파일 응답 모델"""
21
+
22
+ id: int
23
+ filename: str
24
+ original_filename: str
25
+ file_size: int
26
+ model_name: Optional[str] = None
27
+ uploaded_at: datetime
28
+ uploaded_by: Optional[int] = None
29
+ parent_file_id: Optional[int] = None
30
+ chunk_count: int = 0
31
+ child_count: int = 0
32
+
33
+ class Config:
34
+ """Pydantic 설정"""
35
+ from_attributes = True
36
+ json_encoders = {
37
+ datetime: lambda v: v.isoformat()
38
+ }
39
+
app/prompts/__init__.py ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ 프롬프트 관리 모듈
3
+ LLM 프롬프트 템플릿을 관리합니다.
4
+ """
5
+
6
+ from app.prompts.metadata import get_metadata_extraction_prompt
7
+ from app.prompts.parent_chunk import get_parent_chunk_analysis_prompt
8
+
9
+ __all__ = [
10
+ 'get_metadata_extraction_prompt',
11
+ 'get_parent_chunk_analysis_prompt',
12
+ ]
13
+
app/prompts/metadata.py ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ 메타데이터 추출 프롬프트
3
+ """
4
+
5
+ from typing import Optional
6
+
7
+
8
+ def get_metadata_extraction_prompt(
9
+ chunk_content: str,
10
+ max_length: int = 2000
11
+ ) -> str:
12
+ """
13
+ 청크 메타데이터 추출을 위한 프롬프트 생성
14
+
15
+ Args:
16
+ chunk_content: 분석할 청크 내용
17
+ max_length: 프롬프트에 포함할 최대 텍스트 길이
18
+
19
+ Returns:
20
+ 프롬프트 문자열
21
+ """
22
+ content_preview = chunk_content[:max_length]
23
+
24
+ prompt = f"""다음 웹소설 텍스트를 분석하여 아래 정보를 JSON 형식으로만 응답하세요:
25
+
26
+ 텍스트:
27
+ {content_preview}
28
+
29
+ 다음 형식으로만 응답하세요 (JSON 형식):
30
+ {{
31
+ "pov": "화자/시점을 설명하세요 (예: 1인칭 주인공, 3인칭 전지적 작가 등)",
32
+ "characters": ["등장인물1", "등장인물2"],
33
+ "time_background": "시간적 배경 설명 (예: 과거 회상, 현재 시점, 미래 등)"
34
+ }}
35
+
36
+ 응답은 오직 JSON 형식만 사용하고, 다른 설명은 포함하지 마세요."""
37
+
38
+ return prompt
39
+
app/prompts/parent_chunk.py ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Parent Chunk 분석 프롬프트
3
+ """
4
+
5
+ from typing import Optional
6
+
7
+
8
+ def get_parent_chunk_analysis_prompt(
9
+ content: str,
10
+ max_length: int = 8000
11
+ ) -> str:
12
+ """
13
+ Parent Chunk 분석을 위한 프롬프트 생성
14
+
15
+ Args:
16
+ content: 분석할 전체 텍스트
17
+ max_length: 프롬프트에 포함할 최대 텍스트 길이
18
+
19
+ Returns:
20
+ 프롬프트 문자열
21
+ """
22
+ content_preview = content[:max_length]
23
+ is_truncated = len(content) > max_length
24
+
25
+ truncation_note = "\n(참고: 텍스트가 길어 일부만 사용되었습니다.)" if is_truncated else ""
26
+
27
+ prompt = f"""다음 웹소설 텍스트를 분석하여 세계관, 캐릭터, 스토리, 에피소드, 기타 정보를 추출하세요.
28
+
29
+ 텍스트:
30
+ {content_preview}{truncation_note}
31
+
32
+ 다음 형식으로 응답하세요:
33
+
34
+ ## 세계관
35
+ [세계관에 대한 상세 설명]
36
+
37
+ ## 캐릭터
38
+ [주요 캐릭터들의 특징과 배경]
39
+
40
+ ## 스토리
41
+ [주요 스토리 라인과 전개]
42
+
43
+ ## 에피소드
44
+ [주요 에피소드와 사건들]
45
+
46
+ ## 기타
47
+ [기타 중요한 정보]
48
+
49
+ 각 섹션은 상세하고 구조화된 형태로 작성해주세요."""
50
+
51
+ return prompt
52
+
app/routes.py CHANGED
@@ -366,7 +366,8 @@ def create_chunks_for_file(file_id, content, extract_metadata=True):
366
  try:
367
  print(f"[청크 생성] 파일 ID {file_id}에 대한 청크 생성 시작")
368
  print(f"[청크 생성] 원본 텍스트 길이: {len(content)}자")
369
- print(f"[청크 생성] 메타데이터 추출: {'예' if extract_metadata else '아니오'}")
 
370
 
371
  # 파일 정보 가져오기 (모델명 등)
372
  uploaded_file = UploadedFile.query.get(file_id)
@@ -397,40 +398,41 @@ def create_chunks_for_file(file_id, content, extract_metadata=True):
397
  # 각 청크를 데이터베이스와 벡터 DB에 저장
398
  saved_count = 0
399
  vector_saved_count = 0
400
- metadata_extracted_count = 0
401
 
402
- if extract_metadata:
403
- print(f"[청크 생성] 메타데이터 추출 시작 (AI 사용: {model_name is not None})...")
404
- else:
405
- print(f"[청크 생성] 메타데이터 추출 건너뜀 (사용자 선택)")
 
406
 
407
  for idx, chunk_content in enumerate(chunks):
408
  try:
409
- # 메타데이터 추출 (옵션이 활성화된 경우에만)
410
  metadata = None
411
  metadata_json = None
412
 
413
- if extract_metadata:
414
- metadata = extract_chunk_metadata(
415
- chunk_content=chunk_content,
416
- full_content=content,
417
- chunk_index=idx,
418
- file_id=file_id,
419
- model_name=model_name
420
- )
421
-
422
- # 메타데이터를 JSON 문자열로 변환
423
- metadata_json = json.dumps(metadata, ensure_ascii=False) if metadata else None
424
-
425
- if metadata and (metadata.get("chapter") or metadata.get("pov") or metadata.get("characters") or metadata.get("time_background")):
426
- metadata_extracted_count += 1
427
 
428
- # DB에 청크 저장 (메타데이터 포함)
429
  chunk = DocumentChunk(
430
  file_id=file_id,
431
  chunk_index=idx,
432
  content=chunk_content,
433
- chunk_metadata=metadata_json
434
  )
435
  db.session.add(chunk)
436
  db.session.flush() # ID 생성
@@ -448,7 +450,7 @@ def create_chunks_for_file(file_id, content, extract_metadata=True):
448
 
449
  # 진행 상황 출력 (10개마다)
450
  if (idx + 1) % 10 == 0:
451
- print(f"[청크 생성] 진행 중: {idx + 1}/{len(chunks)}개 청크 저장 중... (DB: {saved_count}, 벡터 DB: {vector_saved_count}, 메타데이터: {metadata_extracted_count})")
452
  except Exception as e:
453
  print(f"[청크 생성] 경고: 청크 {idx} 저장 중 오류: {str(e)}")
454
  import traceback
@@ -456,7 +458,7 @@ def create_chunks_for_file(file_id, content, extract_metadata=True):
456
  continue
457
 
458
  db.session.commit()
459
- print(f"[청크 생성] 완료: {saved_count}개 청크가 데이터베이스에 저장되었습니다. (벡터 DB: {vector_saved_count}개, 메타데���터 추출: {metadata_extracted_count}개)")
460
 
461
  # 저장 확인
462
  verified_count = DocumentChunk.query.filter_by(file_id=file_id).count()
@@ -1717,18 +1719,24 @@ def upload_file():
1717
 
1718
  # 모든 출력을 즉시 플러시하여 로그가 바로 보이도록
1719
  def log_print(*args, **kwargs):
1720
- print(*args, **kwargs)
 
 
1721
  sys.stdout.flush()
1722
 
1723
  try:
1724
  log_print(f"\n{'='*60}")
1725
  log_print(f"=== 파일 업로드 요청 시작 ===")
 
1726
  log_print(f"요청 메서드: {request.method}")
1727
  log_print(f"Content-Type: {request.content_type}")
1728
  log_print(f"Content-Length: {request.content_length}")
 
 
1729
  log_print(f"Form 데이터 키: {list(request.form.keys())}")
1730
  log_print(f"Files 키: {list(request.files.keys())}")
1731
- log_print(f"사용자: {current_user.username if current_user else 'None'}")
 
1732
  log_print(f"{'='*60}\n")
1733
 
1734
  # 업로드 폴더 확인 및 생성
@@ -1755,7 +1763,8 @@ def upload_file():
1755
  log_print(f"[2/8] 파일 수신: {file.filename if file else 'None'}")
1756
  log_print(f"[2/8] 모델명: {model_name if model_name else 'None (비어있음)'}")
1757
  log_print(f"[2/8] 이어서 업로드: {parent_file_id if parent_file_id else '아니오'}")
1758
- log_print(f"[2/8] 메타데이터 추출: {'예' if extract_metadata else '아니오'}")
 
1759
 
1760
  if file.filename == '':
1761
  error_msg = '파일명이 없습니다.'
@@ -1917,7 +1926,8 @@ def upload_file():
1917
  log_print(f"[7/8] CP949 인코딩으로 파일 읽기 성공: {len(content)}자")
1918
 
1919
  # 청크 생성 및 저장
1920
- log_print(f"[7/8] 청크 생성 함수 호출 중... (메타데이터 추출: {'예' if extract_metadata else '아니오'})")
 
1921
  chunk_count = create_chunks_for_file(uploaded_file.id, content, extract_metadata=extract_metadata)
1922
 
1923
  if chunk_count > 0:
@@ -1928,14 +1938,21 @@ def upload_file():
1928
  print(f"경고: 파일 {original_filename}에 대한 청크가 생성되지 않았습니다.")
1929
 
1930
  # Parent Chunk 생성 (AI 분석)
1931
- log_print(f"[7/9] Parent Chunk 생성 시작 (AI 분석)...")
1932
- parent_chunk = create_parent_chunk_with_ai(uploaded_file.id, content, model_name)
1933
- if parent_chunk:
1934
- log_print(f"[7/9] ✅ Parent Chunk 생성 완료: {original_filename}")
1935
- print(f"Parent Chunk 생성되었습니다: {original_filename}")
1936
- else:
1937
- log_print(f"[7/9] ⚠️ 경고: Parent Chunk 생성 실패: {original_filename}")
1938
- print(f"경고: Parent Chunk 생성에 실패했습니다: {original_filename}")
 
 
 
 
 
 
 
1939
 
1940
  except Exception as e:
1941
  error_msg = f"청크 생성 중 오류: {str(e)}"
@@ -1943,6 +1960,8 @@ def upload_file():
1943
  print(error_msg)
1944
  import traceback
1945
  traceback.print_exc()
 
 
1946
 
1947
  # 최종 청크 개수 확인 및 저장
1948
  chunk_count = 0
@@ -1963,13 +1982,23 @@ def upload_file():
1963
  log_print(f"{'='*60}")
1964
  log_print(f"=== 파일 업로드 성공 ===")
1965
  log_print(f"{'='*60}\n")
 
 
 
 
 
 
 
 
 
 
1966
  except Exception as e:
1967
  db.session.rollback()
1968
  error_msg = f'데이터베이스 저장 중 오류가 발생했습니다: {str(e)}'
1969
  log_print(f"[ERROR] 데이터베이스 저장 오류: {error_msg}")
1970
  traceback.print_exc()
1971
  # 데이터베이스 저장 실패 시 파일도 삭제
1972
- if os.path.exists(file_path):
1973
  try:
1974
  os.remove(file_path)
1975
  log_print(f"오류로 인한 파일 삭제: {file_path}")
@@ -1977,15 +2006,6 @@ def upload_file():
1977
  log_print(f"파일 삭제 실패: {str(del_e)}")
1978
  return jsonify({'error': error_msg, 'step': 'database_save'}), 500
1979
 
1980
- log_print(f"[8/8] 업로드 완료 - 파일: {original_filename}, 모델: {model_name}, 크기: {saved_file_size} bytes")
1981
-
1982
- return jsonify({
1983
- 'message': f'파일이 성공적으로 업로드되었습니다. (모델: {model_name})',
1984
- 'file': uploaded_file.to_dict(),
1985
- 'model_name': model_name,
1986
- 'chunk_count': chunk_count if 'chunk_count' in locals() else 0
1987
- }), 200
1988
-
1989
  except Exception as e:
1990
  db.session.rollback()
1991
  error_msg = str(e)
 
366
  try:
367
  print(f"[청크 생성] 파일 ID {file_id}에 대한 청크 생성 시작")
368
  print(f"[청크 생성] 원본 텍스트 길이: {len(content)}자")
369
+ print(f"[청크 생성] 메타데이터 추출: 비활성화됨 (주석 처리)")
370
+ # print(f"[청크 생성] 메타데이터 추출: {'예' if extract_metadata else '아니오'}") # 주석 처리됨
371
 
372
  # 파일 정보 가져오기 (모델명 등)
373
  uploaded_file = UploadedFile.query.get(file_id)
 
398
  # 각 청크를 데이터베이스와 벡터 DB에 저장
399
  saved_count = 0
400
  vector_saved_count = 0
401
+ # metadata_extracted_count = 0 # 메타데이터 추출 비활성화로 주석 처리
402
 
403
+ # 메타데이터 추출 기능 주석 처리됨
404
+ # if extract_metadata:
405
+ # print(f"[청크 생성] 메타데이터 추출 시작 (AI 사용: {model_name is not None})...")
406
+ # else:
407
+ # print(f"[청크 생성] 메타데이터 추출 건너뜀 (사용자 선택)")
408
 
409
  for idx, chunk_content in enumerate(chunks):
410
  try:
411
+ # 메타데이터 추출 (옵션이 활성화된 경우에만) - 주석 처리됨
412
  metadata = None
413
  metadata_json = None
414
 
415
+ # if extract_metadata:
416
+ # metadata = extract_chunk_metadata(
417
+ # chunk_content=chunk_content,
418
+ # full_content=content,
419
+ # chunk_index=idx,
420
+ # file_id=file_id,
421
+ # model_name=model_name
422
+ # )
423
+ #
424
+ # # 메타데이터를 JSON 문자열로 변환
425
+ # metadata_json = json.dumps(metadata, ensure_ascii=False) if metadata else None
426
+ #
427
+ # if metadata and (metadata.get("chapter") or metadata.get("pov") or metadata.get("characters") or metadata.get("time_background")):
428
+ # metadata_extracted_count += 1
429
 
430
+ # DB에 청크 저장 (메타데이터 없이)
431
  chunk = DocumentChunk(
432
  file_id=file_id,
433
  chunk_index=idx,
434
  content=chunk_content,
435
+ chunk_metadata=None # 메타데이터 추출 비활성화
436
  )
437
  db.session.add(chunk)
438
  db.session.flush() # ID 생성
 
450
 
451
  # 진행 상황 출력 (10개마다)
452
  if (idx + 1) % 10 == 0:
453
+ print(f"[청크 생성] 진행 중: {idx + 1}/{len(chunks)}개 청크 저장 중... (DB: {saved_count}, 벡터 DB: {vector_saved_count})")
454
  except Exception as e:
455
  print(f"[청크 생성] 경고: 청크 {idx} 저장 중 오류: {str(e)}")
456
  import traceback
 
458
  continue
459
 
460
  db.session.commit()
461
+ print(f"[청크 생성] 완료: {saved_count}개 청크가 데이터베이스에 저장되었습니다. (벡터 DB: {vector_saved_count}개)")
462
 
463
  # 저장 확인
464
  verified_count = DocumentChunk.query.filter_by(file_id=file_id).count()
 
1719
 
1720
  # 모든 출력을 즉시 플러시하여 로그가 바로 보이도록
1721
  def log_print(*args, **kwargs):
1722
+ from datetime import datetime
1723
+ timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S.%f')[:-3]
1724
+ print(f"[{timestamp}]", *args, **kwargs)
1725
  sys.stdout.flush()
1726
 
1727
  try:
1728
  log_print(f"\n{'='*60}")
1729
  log_print(f"=== 파일 업로드 요청 시작 ===")
1730
+ log_print(f"요청 URL: {request.url}")
1731
  log_print(f"요청 메서드: {request.method}")
1732
  log_print(f"Content-Type: {request.content_type}")
1733
  log_print(f"Content-Length: {request.content_length}")
1734
+ log_print(f"Remote Address: {request.remote_addr}")
1735
+ log_print(f"Headers: {dict(request.headers)}")
1736
  log_print(f"Form 데이터 키: {list(request.form.keys())}")
1737
  log_print(f"Files 키: {list(request.files.keys())}")
1738
+ log_print(f"사용자: {current_user.username if current_user and current_user.is_authenticated else 'None'}")
1739
+ log_print(f"사용자 인증 상태: {current_user.is_authenticated if current_user else False}")
1740
  log_print(f"{'='*60}\n")
1741
 
1742
  # 업로드 폴더 확인 및 생성
 
1763
  log_print(f"[2/8] 파일 수신: {file.filename if file else 'None'}")
1764
  log_print(f"[2/8] 모델명: {model_name if model_name else 'None (비어있음)'}")
1765
  log_print(f"[2/8] 이어서 업로드: {parent_file_id if parent_file_id else '아니오'}")
1766
+ log_print(f"[2/8] 메타데이터 추출: 비활성화됨 (주석 처리)")
1767
+ # log_print(f"[2/8] 메타데이터 추출: {'예' if extract_metadata else '아니오'}") # 주석 처리됨
1768
 
1769
  if file.filename == '':
1770
  error_msg = '파일명이 없습니다.'
 
1926
  log_print(f"[7/8] CP949 인코딩으로 파일 읽기 성공: {len(content)}자")
1927
 
1928
  # 청크 생성 및 저장
1929
+ log_print(f"[7/8] 청크 생성 함수 호출 중... (메타데이터 추출: 비활성화됨)")
1930
+ # log_print(f"[7/8] 청크 생성 함수 호출 중... (메타데이터 추출: {'예' if extract_metadata else '아니오'})") # 주석 처리됨
1931
  chunk_count = create_chunks_for_file(uploaded_file.id, content, extract_metadata=extract_metadata)
1932
 
1933
  if chunk_count > 0:
 
1938
  print(f"경고: 파일 {original_filename}에 대한 청크가 생성되지 않았습니다.")
1939
 
1940
  # Parent Chunk 생성 (AI 분석)
1941
+ try:
1942
+ log_print(f"[8/8] Parent Chunk 생성 시작 (AI 분석)...")
1943
+ parent_chunk = create_parent_chunk_with_ai(uploaded_file.id, content, model_name)
1944
+ if parent_chunk:
1945
+ log_print(f"[8/8] ✅ Parent Chunk 생성 완료: {original_filename}")
1946
+ print(f"Parent Chunk가 생성되었습니다: {original_filename}")
1947
+ else:
1948
+ log_print(f"[8/8] ⚠️ 경고: Parent Chunk 생성 실패: {original_filename}")
1949
+ print(f"경고: Parent Chunk 생성에 실패했습니다: {original_filename}")
1950
+ except Exception as parent_chunk_error:
1951
+ # Parent Chunk 생성 실패해도 업로드는 계속 진행
1952
+ log_print(f"[8/8] ⚠️ 경고: Parent Chunk 생성 중 예외 발생: {str(parent_chunk_error)}")
1953
+ print(f"경고: Parent Chunk 생성 중 오류가 발생했습니다: {original_filename}")
1954
+ import traceback
1955
+ traceback.print_exc()
1956
 
1957
  except Exception as e:
1958
  error_msg = f"청크 생성 중 오류: {str(e)}"
 
1960
  print(error_msg)
1961
  import traceback
1962
  traceback.print_exc()
1963
+ # 청크 생성 실패해도 파일 업로드는 계속 진행 (경고만 표시)
1964
+ log_print(f"[7/8] ⚠️ 경고: 청크 생성 실패했지만 파일 업로드는 계속 진행합니다.")
1965
 
1966
  # 최종 청크 개수 확인 및 저장
1967
  chunk_count = 0
 
1982
  log_print(f"{'='*60}")
1983
  log_print(f"=== 파일 업로드 성공 ===")
1984
  log_print(f"{'='*60}\n")
1985
+
1986
+ log_print(f"[8/8] 업로드 완료 - 파일: {original_filename}, 모델: {model_name}, 크기: {saved_file_size} bytes")
1987
+
1988
+ return jsonify({
1989
+ 'message': f'파일이 성공적으로 업로드되었습니다. (모델: {model_name})',
1990
+ 'file': uploaded_file.to_dict(),
1991
+ 'model_name': model_name,
1992
+ 'chunk_count': chunk_count
1993
+ }), 200
1994
+
1995
  except Exception as e:
1996
  db.session.rollback()
1997
  error_msg = f'데이터베이스 저장 중 오류가 발생했습니다: {str(e)}'
1998
  log_print(f"[ERROR] 데이터베이스 저장 오류: {error_msg}")
1999
  traceback.print_exc()
2000
  # 데이터베이스 저장 실패 시 파일도 삭제
2001
+ if 'file_path' in locals() and os.path.exists(file_path):
2002
  try:
2003
  os.remove(file_path)
2004
  log_print(f"오류로 인한 파일 삭제: {file_path}")
 
2006
  log_print(f"파일 삭제 실패: {str(del_e)}")
2007
  return jsonify({'error': error_msg, 'step': 'database_save'}), 500
2008
 
 
 
 
 
 
 
 
 
 
2009
  except Exception as e:
2010
  db.session.rollback()
2011
  error_msg = str(e)
app/utils/__init__.py ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ 유틸리티 모듈
3
+ 공통 유틸리티 함수들을 제공합니다.
4
+ """
5
+
6
+ from app.utils.file_utils import (
7
+ allowed_file,
8
+ ensure_upload_folder,
9
+ get_file_extension,
10
+ )
11
+ from app.utils.text_utils import (
12
+ split_text_into_chunks,
13
+ extract_chapter_number,
14
+ clean_text,
15
+ )
16
+
17
+ __all__ = [
18
+ 'allowed_file',
19
+ 'ensure_upload_folder',
20
+ 'get_file_extension',
21
+ 'split_text_into_chunks',
22
+ 'extract_chapter_number',
23
+ 'clean_text',
24
+ ]
25
+
app/utils/file_utils.py ADDED
@@ -0,0 +1,79 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ 파일 관련 유틸리티 함수
3
+ """
4
+
5
+ from pathlib import Path
6
+ from typing import Optional
7
+ from werkzeug.utils import secure_filename
8
+
9
+ from app.core.config import Config
10
+ from app.core.logger import get_logger
11
+
12
+ logger = get_logger(__name__)
13
+
14
+
15
+ def allowed_file(filename: str) -> bool:
16
+ """
17
+ 파일 확장자가 허용된 확장자인지 확인
18
+
19
+ Args:
20
+ filename: 파일명
21
+
22
+ Returns:
23
+ 허용된 확장자면 True, 아니면 False
24
+ """
25
+ if '.' not in filename:
26
+ return False
27
+ extension = filename.rsplit('.', 1)[1].lower()
28
+ return extension in Config.ALLOWED_EXTENSIONS
29
+
30
+
31
+ def get_file_extension(filename: str) -> Optional[str]:
32
+ """
33
+ 파일 확장자 추출
34
+
35
+ Args:
36
+ filename: 파일명
37
+
38
+ Returns:
39
+ 확장자 (점 제외), 없으면 None
40
+ """
41
+ if '.' not in filename:
42
+ return None
43
+ return filename.rsplit('.', 1)[1].lower()
44
+
45
+
46
+ def ensure_upload_folder() -> Path:
47
+ """
48
+ 업로드 폴더가 존재하는지 확인하고 없으면 생성
49
+
50
+ Returns:
51
+ 업로드 폴더 경로
52
+
53
+ Raises:
54
+ OSError: 폴더 생성 또는 쓰기 권한 오류
55
+ """
56
+ upload_folder = Config.UPLOAD_FOLDER
57
+
58
+ try:
59
+ # 폴더 생성
60
+ upload_folder.mkdir(parents=True, exist_ok=True)
61
+ logger.debug(f"업로드 폴더 확인 완료: {upload_folder}")
62
+
63
+ # 쓰기 권한 테스트
64
+ test_file = upload_folder / '.write_test'
65
+ try:
66
+ test_file.write_text('test')
67
+ test_file.unlink()
68
+ logger.debug(f"업로드 폴더 쓰기 권한 확인 완료: {upload_folder}")
69
+ except PermissionError as e:
70
+ raise OSError(f'업로드 폴더에 쓰기 권한이 없습니다: {upload_folder}') from e
71
+ except Exception as e:
72
+ raise OSError(f'업로드 폴더 쓰기 테스트 실패: {upload_folder}') from e
73
+
74
+ return upload_folder
75
+
76
+ except Exception as e:
77
+ logger.error(f"업로드 폴더 생성 오류: {e}", exc_info=True)
78
+ raise
79
+
app/utils/text_utils.py ADDED
@@ -0,0 +1,195 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ 텍스트 처리 유틸리티 함수
3
+ """
4
+
5
+ import re
6
+ from typing import List, Optional
7
+
8
+ from app.core.logger import get_logger
9
+
10
+ logger = get_logger(__name__)
11
+
12
+
13
+ def clean_text(text: str) -> str:
14
+ """
15
+ 텍스트 정리 (공백 정규화 등)
16
+
17
+ Args:
18
+ text: 정리할 텍스트
19
+
20
+ Returns:
21
+ 정리된 텍스트
22
+ """
23
+ if not text:
24
+ return ''
25
+
26
+ # 연속된 공백 제거
27
+ text = re.sub(r'\s+', ' ', text)
28
+ # 앞뒤 공백 제거
29
+ text = text.strip()
30
+
31
+ return text
32
+
33
+
34
+ def split_text_into_chunks(
35
+ text: str,
36
+ min_chunk_size: int = 200,
37
+ max_chunk_size: int = 1000,
38
+ overlap: int = 150
39
+ ) -> List[str]:
40
+ """
41
+ 의미 기반 텍스트 청킹 (문장과 문단 경계를 고려하여 분할)
42
+
43
+ Args:
44
+ text: 분할할 텍스트
45
+ min_chunk_size: 최소 청크 크기
46
+ max_chunk_size: 최대 청크 크기
47
+ overlap: 오버랩 크기
48
+
49
+ Returns:
50
+ 분할된 청크 리스트
51
+ """
52
+ if not text or len(text.strip()) == 0:
53
+ return []
54
+
55
+ # 1단계: 문단 단위로 분할 (빈 줄 기준)
56
+ paragraphs = re.split(r'\n\s*\n', text.strip())
57
+ paragraphs = [p.strip() for p in paragraphs if p.strip()]
58
+
59
+ if not paragraphs:
60
+ return []
61
+
62
+ # 2단계: 각 문단을 문장 단위로 분할
63
+ sentence_pattern = r'([.!?]+)(?=\s+|$)'
64
+
65
+ all_sentences: List[str] = []
66
+ for para in paragraphs:
67
+ parts = re.split(sentence_pattern, para)
68
+ combined_sentences: List[str] = []
69
+ current_sentence = ""
70
+
71
+ for part in parts:
72
+ if not part.strip():
73
+ continue
74
+ if re.match(r'^[.!?]+$', part):
75
+ # 구두점인 경우 현재 문장에 추가하고 문장 완성
76
+ current_sentence += part
77
+ if current_sentence.strip():
78
+ combined_sentences.append(current_sentence.strip())
79
+ current_sentence = ""
80
+ else:
81
+ # 텍스트인 경우 현재 문장에 추가
82
+ current_sentence += part
83
+
84
+ # 마지막 문장 처리
85
+ if current_sentence.strip():
86
+ combined_sentences.append(current_sentence.strip())
87
+
88
+ # 문장이 하나도 없는 경우
89
+ if not combined_sentences and para.strip():
90
+ combined_sentences.append(para.strip())
91
+
92
+ all_sentences.extend(combined_sentences)
93
+
94
+ if not all_sentences:
95
+ return [text] if text.strip() else []
96
+
97
+ # 3단계: 문장들을 모아서 의미 있는 청크 생성
98
+ chunks: List[str] = []
99
+ current_chunk: List[str] = []
100
+ current_size = 0
101
+
102
+ for sentence in all_sentences:
103
+ sentence_size = len(sentence)
104
+
105
+ # 현재 청크에 문장 추가 시 최대 크기를 초과하는 경우
106
+ if current_size + sentence_size > max_chunk_size and current_chunk:
107
+ # 현재 청크 저장
108
+ chunk_text = '\n'.join(current_chunk)
109
+ if len(chunk_text.strip()) >= min_chunk_size:
110
+ chunks.append(chunk_text)
111
+ else:
112
+ # 최소 크기 미만이면 다음 청크와 병합
113
+ if chunks:
114
+ chunks[-1] = chunks[-1] + '\n' + chunk_text
115
+ else:
116
+ chunks.append(chunk_text)
117
+
118
+ # 오버랩을 위한 문장 유지
119
+ overlap_sentences: List[str] = []
120
+ overlap_size = 0
121
+ for s in reversed(current_chunk):
122
+ if overlap_size + len(s) <= overlap:
123
+ overlap_sentences.insert(0, s)
124
+ overlap_size += len(s) + 1
125
+ else:
126
+ break
127
+
128
+ current_chunk = overlap_sentences + [sentence]
129
+ current_size = overlap_size + sentence_size
130
+ else:
131
+ # 현재 청크에 문장 추가
132
+ current_chunk.append(sentence)
133
+ current_size += sentence_size + 1
134
+
135
+ # 마지막 청크 추가
136
+ if current_chunk:
137
+ chunk_text = '\n'.join(current_chunk)
138
+ if chunks and len(chunk_text.strip()) < min_chunk_size:
139
+ chunks[-1] = chunks[-1] + '\n' + chunk_text
140
+ else:
141
+ chunks.append(chunk_text)
142
+
143
+ # 빈 청크 제거 및 최소 크기 미만 청크 처리
144
+ final_chunks: List[str] = []
145
+ for chunk in chunks:
146
+ chunk = chunk.strip()
147
+ if chunk and len(chunk) >= min_chunk_size:
148
+ final_chunks.append(chunk)
149
+ elif chunk:
150
+ if final_chunks:
151
+ final_chunks[-1] = final_chunks[-1] + '\n' + chunk
152
+ else:
153
+ final_chunks.append(chunk)
154
+
155
+ return final_chunks if final_chunks else [text] if text.strip() else []
156
+
157
+
158
+ def extract_chapter_number(text: str) -> Optional[int]:
159
+ """
160
+ 텍스트에서 챕터 번호 추출
161
+
162
+ Args:
163
+ text: 챕터 번호를 추출할 텍스트
164
+
165
+ Returns:
166
+ 챕터 번호, 없으면 None
167
+ """
168
+ # 다양한 챕터 패턴 매칭
169
+ patterns = [
170
+ r'제\s*(\d+)\s*장', # 제1장, 제 1 장
171
+ r'제\s*(\d+)\s*화', # 제1화
172
+ r'Chapter\s*(\d+)', # Chapter 1
173
+ r'CHAPTER\s*(\d+)', # CHAPTER 1
174
+ r'Ch\.\s*(\d+)', # Ch. 1
175
+ r'(\d+)\s*장', # 1장
176
+ r'(\d+)\s*화', # 1화
177
+ r'chap\.\s*(\d+)', # chap. 1
178
+ r'ch\s*(\d+)', # ch 1
179
+ r'(\d+)\s*章', # 1章
180
+ ]
181
+
182
+ # 텍스트의 처음 500자만 검사
183
+ search_text = text[:500]
184
+
185
+ for pattern in patterns:
186
+ match = re.search(pattern, search_text, re.IGNORECASE)
187
+ if match:
188
+ try:
189
+ chapter_num = int(match.group(1))
190
+ return chapter_num
191
+ except (ValueError, AttributeError):
192
+ continue
193
+
194
+ return None
195
+
requirements.txt CHANGED
@@ -8,5 +8,7 @@ chromadb==0.4.22
8
  sentence-transformers==2.3.1
9
  numpy==1.24.3
10
  google-generativeai==0.3.2
 
 
11
 
12
 
 
8
  sentence-transformers==2.3.1
9
  numpy==1.24.3
10
  google-generativeai==0.3.2
11
+ pydantic==2.5.0
12
+ pydantic-settings==2.1.0
13
 
14
 
templates/admin_webnovels.html CHANGED
@@ -472,6 +472,7 @@
472
  </div>
473
 
474
  <!-- 메타데이터 추가 옵션 -->
 
475
  <div style="margin-bottom: 16px; padding: 12px; background: #f8f9fa; border-radius: 6px; border: 1px solid #dadce0;">
476
  <label style="display: flex; align-items: center; cursor: pointer; font-size: 14px;">
477
  <input type="checkbox" id="extractMetadataCheckbox" checked style="margin-right: 8px; width: 18px; height: 18px; cursor: pointer;">
@@ -482,7 +483,7 @@
482
  <span style="color: #c5221f;">⚠️ 메타데이터 추출은 AI를 사용하므로 시간이 오래 걸릴 수 있습니다.</span>
483
  </div>
484
  </div>
485
-
486
  <!-- 파일 업로드 -->
487
  <div class="file-upload-input-wrapper" id="fileUploadWrapper">
488
  <input type="file" id="fileInput" accept=".txt,.md,.pdf,.docx,.epub" multiple>
@@ -582,6 +583,15 @@
582
  const fileUploadStatus = document.getElementById('fileUploadStatus');
583
  const fileModelSelect = document.getElementById('fileModelSelect');
584
  const filesTableBody = document.getElementById('filesTableBody');
 
 
 
 
 
 
 
 
 
585
 
586
  // 모델 목록 로드 (관리자용: 모든 모델 표시)
587
  async function loadModelsForFiles() {
@@ -728,24 +738,57 @@
728
 
729
  // 파일 업로드 처리
730
  async function handleFileUpload(files) {
731
- if (!files || files.length === 0) return;
 
 
 
 
 
732
 
733
- const modelName = fileModelSelect.value;
734
- if (!modelName) {
735
- showAlert('먼저 AI 모델을 선택해주세요.', 'error');
 
 
 
 
 
 
 
 
 
 
736
  return;
737
  }
738
 
739
- // 업로드 중 UI 비활성화
740
- fileUploadWrapper.classList.add('disabled');
741
- fileModelSelect.disabled = true;
742
- fileInput.disabled = true;
 
 
 
 
 
 
743
 
744
- // 진행 상태 초기화
745
- const progressContainer = document.getElementById('fileUploadProgress');
746
- const progressItems = document.getElementById('progressItems');
747
- progressContainer.classList.add('active');
748
- progressItems.innerHTML = '';
 
 
 
 
 
 
 
 
 
 
 
 
749
 
750
  // 각 파일에 대한 진행 항목 생성
751
  const progressMap = new Map();
@@ -756,48 +799,131 @@
756
  item.innerHTML = `
757
  <span class="progress-item-name">${escapeHtml(file.name)}</span>
758
  <span class="progress-item-status uploading" id="progress-status-${index}">
759
- <span class="spinner"></span>업로드 중...
760
  </span>
761
  `;
762
  progressItems.appendChild(item);
763
- progressMap.set(index, { file, item, status: 'uploading' });
764
  });
765
 
766
- fileUploadStatus.textContent = `총 ${files.length}개 파일 업로드 중...`;
767
- fileUploadStatus.className = 'file-upload-status progress';
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
768
 
769
  let successCount = 0;
770
  let failCount = 0;
771
  const errors = [];
772
 
 
 
773
  // 파일을 순차적으로 업로드
774
- for (let i = 0; i < files.length; i++) {
775
- const file = files[i];
776
- const formData = new FormData();
777
- formData.append('file', file);
778
- formData.append('model_name', modelName);
779
- // 메타데이터 추출 옵션 추가
780
- const extractMetadata = document.getElementById('extractMetadataCheckbox').checked;
781
- formData.append('extract_metadata', extractMetadata ? 'true' : 'false');
782
-
783
- // 이어서 업로드인 경우 parent_file_id 추가
784
- if (continueUploadFileId) {
785
- formData.append('parent_file_id', continueUploadFileId);
786
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
787
 
788
- const statusElement = document.getElementById(`progress-status-${i}`);
789
- const itemElement = document.getElementById(`progress-item-${i}`);
790
 
791
- try {
792
- console.log(`[업로드 시작] 파일: ${file.name}, 크기: ${file.size} bytes, 모델: ${modelName}`);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
793
 
794
- const response = await fetch('/api/upload', {
795
- method: 'POST',
796
- body: formData
797
- });
798
 
799
  console.log(`[응답 수신] 상태: ${response.status} ${response.statusText}, Content-Type: ${response.headers.get('Content-Type')}`);
800
 
 
 
 
 
801
  let data;
802
  let responseText = '';
803
  try {
@@ -810,10 +936,27 @@
810
  throw new Error(`서버 응답 오류 (${response.status}): ${responseText.substring(0, 200)}`);
811
  }
812
 
 
 
 
 
813
  if (response.ok) {
 
 
 
 
 
 
 
 
814
  successCount++;
815
  const modelName = data.model_name || '알 수 없음';
816
  const chunkCount = data.chunk_count || 0;
 
 
 
 
 
817
  statusElement.className = 'progress-item-status success';
818
  statusElement.innerHTML = '✓ 완료';
819
  statusElement.title = `모델: ${modelName}${chunkCount > 0 ? `, 청크: ${chunkCount}개` : ''}`;
@@ -838,38 +981,68 @@
838
  } catch (error) {
839
  failCount++;
840
  const errorMsg = error.message || '네트워크 오류';
841
- statusElement.className = 'progress-item-status error';
842
- statusElement.innerHTML = ' 실패';
843
- statusElement.title = errorMsg; // 툴팁으로 상세 에러 표시
844
- statusElement.style.cursor = 'help'; // 툴팁 표시를 위한 커서 변경
 
 
845
  errors.push(`${file.name}: ${errorMsg}`);
846
  console.error(`[업로드 예외] 파일: ${file.name}`, error);
 
 
 
 
 
 
 
 
 
 
847
  console.error(`[스택 트레이스]`, error.stack);
848
  }
849
 
850
- // 진행 상태 업데이트
851
- fileUploadStatus.textContent = `업로드 중... (${i + 1}/${files.length})`;
 
 
 
 
 
 
 
 
 
 
852
  }
853
 
854
  // 업로드 완료 처리
855
- fileUploadStatus.className = 'file-upload-status';
856
- if (successCount > 0) {
857
- fileUploadStatus.textContent = `${successCount}개 파일 업로드 완료${failCount > 0 ? ` (${failCount}개 실패)` : ''}`;
858
- fileUploadStatus.className = 'file-upload-status success';
859
- showAlert(`${successCount}개 파일이 성공적으로 업로드되었습니다.${failCount > 0 ? ` (${failCount}개 실패)` : ''}`, 'success');
860
- loadFiles();
861
- } else {
862
- fileUploadStatus.textContent = '모든 파일 업로드 실패';
863
- fileUploadStatus.className = 'file-upload-status error';
864
- const errorDetails = errors.length > 0 ? '\n' + errors.slice(0, 3).join('\n') + (errors.length > 3 ? `\n... 외 ${errors.length - 3}개 오류` : '') : '';
865
- showAlert(`파일 업로드에 실패했습니다.${errorDetails}`, 'error');
866
- }
 
 
 
867
 
868
- // UI 활성화
869
- fileUploadWrapper.classList.remove('disabled');
870
- fileModelSelect.disabled = false;
871
- fileInput.disabled = false;
872
- fileInput.value = '';
 
 
 
 
 
873
 
874
  // 3초 후 진행 상태 숨기기
875
  setTimeout(() => {
@@ -922,33 +1095,51 @@
922
  }
923
 
924
  // 파일 입력 이벤트
925
- fileInput.addEventListener('change', function(e) {
926
- if (e.target.files.length > 0) {
927
- handleFileUpload(Array.from(e.target.files));
928
- }
929
- // 이어서 업로드 모드 초기화
930
- continueUploadFileId = null;
931
- });
 
 
 
 
 
 
 
 
 
932
 
933
  // 드래그 앤 드롭
934
- fileUploadWrapper.addEventListener('dragover', (e) => {
935
- e.preventDefault();
936
- fileUploadWrapper.classList.add('dragover');
937
- });
 
938
 
939
- fileUploadWrapper.addEventListener('dragleave', () => {
940
- fileUploadWrapper.classList.remove('dragover');
941
- });
942
 
943
- fileUploadWrapper.addEventListener('drop', (e) => {
944
- e.preventDefault();
945
- fileUploadWrapper.classList.remove('dragover');
946
-
947
- const files = Array.from(e.dataTransfer.files);
948
- if (files.length > 0) {
949
- handleFileUpload(files);
950
- }
951
- });
 
 
 
 
 
 
 
 
952
 
953
  // Parent Chunk 확인
954
  async function viewParentChunk(fileId, fileName) {
 
472
  </div>
473
 
474
  <!-- 메타데이터 추가 옵션 -->
475
+ <!--
476
  <div style="margin-bottom: 16px; padding: 12px; background: #f8f9fa; border-radius: 6px; border: 1px solid #dadce0;">
477
  <label style="display: flex; align-items: center; cursor: pointer; font-size: 14px;">
478
  <input type="checkbox" id="extractMetadataCheckbox" checked style="margin-right: 8px; width: 18px; height: 18px; cursor: pointer;">
 
483
  <span style="color: #c5221f;">⚠️ 메타데이터 추출은 AI를 사용하므로 시간이 오래 걸릴 수 있습니다.</span>
484
  </div>
485
  </div>
486
+ -->
487
  <!-- 파일 업로드 -->
488
  <div class="file-upload-input-wrapper" id="fileUploadWrapper">
489
  <input type="file" id="fileInput" accept=".txt,.md,.pdf,.docx,.epub" multiple>
 
583
  const fileUploadStatus = document.getElementById('fileUploadStatus');
584
  const fileModelSelect = document.getElementById('fileModelSelect');
585
  const filesTableBody = document.getElementById('filesTableBody');
586
+
587
+ // 디버깅: 요소 존재 확인
588
+ console.log('[초기화] 파일 업로드 요소 확인:', {
589
+ fileInput: !!fileInput,
590
+ fileUploadWrapper: !!fileUploadWrapper,
591
+ fileUploadStatus: !!fileUploadStatus,
592
+ fileModelSelect: !!fileModelSelect,
593
+ filesTableBody: !!filesTableBody
594
+ });
595
 
596
  // 모델 목록 로드 (관리자용: 모든 모델 표시)
597
  async function loadModelsForFiles() {
 
738
 
739
  // 파일 업로드 처리
740
  async function handleFileUpload(files) {
741
+ console.log('[handleFileUpload] 함수 호출됨', { filesCount: files ? files.length : 0 });
742
+
743
+ if (!files || files.length === 0) {
744
+ console.warn('[handleFileUpload] 파일이 없습니다');
745
+ return;
746
+ }
747
 
748
+ let modelName;
749
+ try {
750
+ modelName = fileModelSelect.value;
751
+ console.log('[handleFileUpload] 모델명:', modelName);
752
+
753
+ if (!modelName) {
754
+ console.error('[handleFileUpload] 모델이 선택되지 않음');
755
+ showAlert('먼저 AI 모델을 선택해주세요.', 'error');
756
+ return;
757
+ }
758
+ } catch (error) {
759
+ console.error('[handleFileUpload] 초기 검증 오류:', error);
760
+ showAlert(`오류: ${error.message}`, 'error');
761
  return;
762
  }
763
 
764
+ try {
765
+ // 업로드 중 UI 비활성화
766
+ console.log('[handleFileUpload] UI 비활성화 시작');
767
+ if (!fileUploadWrapper || !fileModelSelect || !fileInput) {
768
+ throw new Error('필수 UI 요소를 찾을 수 없습니다');
769
+ }
770
+
771
+ fileUploadWrapper.classList.add('disabled');
772
+ fileModelSelect.disabled = true;
773
+ fileInput.disabled = true;
774
 
775
+ // 진행 상태 초기화
776
+ const progressContainer = document.getElementById('fileUploadProgress');
777
+ const progressItems = document.getElementById('progressItems');
778
+
779
+ if (!progressContainer || !progressItems) {
780
+ console.error('[handleFileUpload] 진행 상태 컨테이너를 찾을 수 없습니다');
781
+ throw new Error('진행 상태 컨테이너를 찾을 수 없습니다');
782
+ }
783
+
784
+ progressContainer.classList.add('active');
785
+ progressItems.innerHTML = '';
786
+ console.log('[handleFileUpload] UI 비활성화 완료');
787
+ } catch (uiError) {
788
+ console.error('[handleFileUpload] UI 설정 오류:', uiError);
789
+ showAlert(`UI 설정 오류: ${uiError.message}`, 'error');
790
+ return;
791
+ }
792
 
793
  // 각 파일에 대한 진행 항목 생성
794
  const progressMap = new Map();
 
799
  item.innerHTML = `
800
  <span class="progress-item-name">${escapeHtml(file.name)}</span>
801
  <span class="progress-item-status uploading" id="progress-status-${index}">
802
+ <span class="spinner"></span>대기 중...
803
  </span>
804
  `;
805
  progressItems.appendChild(item);
806
+ progressMap.set(index, { file, item, status: 'waiting', step: 0 });
807
  });
808
 
809
+ // 업로드 단계 정의
810
+ const uploadSteps = [
811
+ { name: '업로드 폴더 확인', step: 1 },
812
+ { name: '파일 수신', step: 2 },
813
+ { name: '파일 검증', step: 3 },
814
+ { name: '파일 저장', step: 4 },
815
+ { name: '데이터베이스 저장', step: 5 },
816
+ { name: '청크 생성', step: 6 },
817
+ { name: '완료', step: 7 }
818
+ ];
819
+
820
+ function updateProgressStatus(fileIndex, stepIndex, stepName) {
821
+ const statusElement = document.getElementById(`progress-status-${fileIndex}`);
822
+ if (statusElement) {
823
+ const step = uploadSteps[stepIndex] || { name: stepName || '처리 중', step: stepIndex + 1 };
824
+ statusElement.innerHTML = `<span class="spinner"></span>${step.name} (${step.step}/7)`;
825
+ }
826
+ }
827
+
828
+ function updateOverallStatus(currentFile, totalFiles, stepIndex) {
829
+ const step = uploadSteps[stepIndex] || { name: '처리 중', step: stepIndex + 1 };
830
+ fileUploadStatus.textContent = `[${currentFile}/${totalFiles}] ${step.name} 중... (${step.step}/7)`;
831
+ }
832
+
833
+ if (fileUploadStatus) {
834
+ fileUploadStatus.textContent = `[0/${files.length}] 업로드 준비 중...`;
835
+ fileUploadStatus.className = 'file-upload-status progress';
836
+ }
837
 
838
  let successCount = 0;
839
  let failCount = 0;
840
  const errors = [];
841
 
842
+ console.log(`[handleFileUpload] 업로드 루프 시작 준비, 파일 개수: ${files.length}`);
843
+
844
  // 파일을 순차적으로 업로드
845
+ try {
846
+ console.log(`[handleFileUpload] for 루프 시작 전, files.length: ${files.length}`);
847
+ for (let i = 0; i < files.length; i++) {
848
+ console.log(`[handleFileUpload] for 루프 ${i}번째 반복 시작`);
849
+ const file = files[i];
850
+ console.log(`[업로드 루프 시작] 파일 ${i + 1}/${files.length}: ${file.name}`);
851
+
852
+ const formData = new FormData();
853
+ formData.append('file', file);
854
+ formData.append('model_name', modelName);
855
+
856
+ // 메타데이터 추출 옵션 추가 (체크박스가 주석 처리되어 있으므로 기본값 false 사용)
857
+ try {
858
+ const extractMetadataCheckbox = document.getElementById('extractMetadataCheckbox');
859
+ const extractMetadata = extractMetadataCheckbox ? extractMetadataCheckbox.checked : false;
860
+ formData.append('extract_metadata', extractMetadata ? 'true' : 'false');
861
+ console.log(`[메타데이터 설정] extract_metadata: ${extractMetadata}`);
862
+ } catch (metadataError) {
863
+ console.warn(`[메타데이터 설정 오류] 기본값 false 사용:`, metadataError);
864
+ formData.append('extract_metadata', 'false');
865
+ }
866
+
867
+ // 이어서 업로드인 경우 parent_file_id 추가
868
+ if (continueUploadFileId) {
869
+ formData.append('parent_file_id', continueUploadFileId);
870
+ }
871
 
872
+ const statusElement = document.getElementById(`progress-status-${i}`);
873
+ const itemElement = document.getElementById(`progress-item-${i}`);
874
 
875
+ try {
876
+ console.log(`[업로드 시작] 파일: ${file.name}, 크기: ${file.size} bytes, 모델: ${modelName}`);
877
+
878
+ // 단계 1: 업로드 폴더 확인
879
+ console.log(`[단계 1] 업로드 폴더 확인 시작`);
880
+ updateProgressStatus(i, 0, '업로드 폴더 확인');
881
+ updateOverallStatus(i + 1, files.length, 0);
882
+
883
+ console.log(`[단계 1] fetch 호출 시작: /api/upload`);
884
+ console.log(`[단계 1] FormData 항목:`, Array.from(formData.entries()).map(([k, v]) => [k, v instanceof File ? v.name : v]));
885
+
886
+ // 타임아웃이 있는 fetch 래퍼
887
+ const fetchWithTimeout = (url, options, timeout = 300000) => { // 5분 타임아웃
888
+ return Promise.race([
889
+ fetch(url, options),
890
+ new Promise((_, reject) =>
891
+ setTimeout(() => reject(new Error(`요청 타임아웃: ${timeout/1000}초 내에 응답이 없습니다.`)), timeout)
892
+ )
893
+ ]);
894
+ };
895
+
896
+ const response = await fetchWithTimeout('/api/upload', {
897
+ method: 'POST',
898
+ body: formData,
899
+ credentials: 'include' // 쿠키 포함 (세션 인증)
900
+ }, 300000); // 5분 타임아웃
901
+
902
+ console.log(`[단계 1] fetch 응답 수신: ${response.status} ${response.statusText}`);
903
+
904
+ // 인증 오류 체크
905
+ if (response.status === 401 || response.status === 403) {
906
+ const errorText = await response.text();
907
+ console.error(`[인증 오류] ${response.status}: ${errorText}`);
908
+ throw new Error(`인증 오류: 서버에서 로그인이 필요하다고 응답했습니다. (${response.status})`);
909
+ }
910
+
911
+ // 리다이렉트 체크
912
+ if (response.redirected) {
913
+ console.warn(`[리다이렉트] 요청이 리다이렉트되었습니다: ${response.url}`);
914
+ throw new Error('서버에서 리다이렉트가 발생했습니다. 로그인 상태를 확인해주세요.');
915
+ }
916
 
917
+ // 단계 2: 파일 수신
918
+ updateProgressStatus(i, 1, '파일 수신');
919
+ updateOverallStatus(i + 1, files.length, 1);
 
920
 
921
  console.log(`[응답 수신] 상태: ${response.status} ${response.statusText}, Content-Type: ${response.headers.get('Content-Type')}`);
922
 
923
+ // 단계 3: 파일 검증
924
+ updateProgressStatus(i, 2, '파일 검증');
925
+ updateOverallStatus(i + 1, files.length, 2);
926
+
927
  let data;
928
  let responseText = '';
929
  try {
 
936
  throw new Error(`서버 응답 오류 (${response.status}): ${responseText.substring(0, 200)}`);
937
  }
938
 
939
+ // 단계 4: 파일 저장
940
+ updateProgressStatus(i, 3, '파일 저장');
941
+ updateOverallStatus(i + 1, files.length, 3);
942
+
943
  if (response.ok) {
944
+ // 단계 5: 데이터베이스 저장
945
+ updateProgressStatus(i, 4, '데이터베이스 저장');
946
+ updateOverallStatus(i + 1, files.length, 4);
947
+
948
+ // 단계 6: 청크 생성
949
+ updateProgressStatus(i, 5, '청크 생성');
950
+ updateOverallStatus(i + 1, files.length, 5);
951
+
952
  successCount++;
953
  const modelName = data.model_name || '알 수 없음';
954
  const chunkCount = data.chunk_count || 0;
955
+
956
+ // 단계 7: 완료
957
+ updateProgressStatus(i, 6, '완료');
958
+ updateOverallStatus(i + 1, files.length, 6);
959
+
960
  statusElement.className = 'progress-item-status success';
961
  statusElement.innerHTML = '✓ 완료';
962
  statusElement.title = `모델: ${modelName}${chunkCount > 0 ? `, 청크: ${chunkCount}개` : ''}`;
 
981
  } catch (error) {
982
  failCount++;
983
  const errorMsg = error.message || '네트워크 오류';
984
+ if (statusElement) {
985
+ statusElement.className = 'progress-item-status error';
986
+ statusElement.innerHTML = '✗ 실패';
987
+ statusElement.title = errorMsg; // 툴팁으로 상세 에러 표시
988
+ statusElement.style.cursor = 'help'; // 툴팁 표시를 위한 커서 변경
989
+ }
990
  errors.push(`${file.name}: ${errorMsg}`);
991
  console.error(`[업로드 예외] 파일: ${file.name}`, error);
992
+ console.error(`[업로드 예외 스택]`, error.stack);
993
+
994
+ // 네트워크 오류나 타임아웃인 경우 사용자에게 명확한 메시지 표시
995
+ if (error.name === 'TypeError' && error.message.includes('fetch')) {
996
+ console.error(`[네트워크 오류] 서버와의 연결이 끊어졌습니다.`);
997
+ if (fileUploadStatus) {
998
+ fileUploadStatus.textContent = `[${i + 1}/${files.length}] 네트워크 오류: 서버 연결 실패`;
999
+ fileUploadStatus.className = 'file-upload-status error';
1000
+ }
1001
+ }
1002
  console.error(`[스택 트레이스]`, error.stack);
1003
  }
1004
 
1005
+ // 진행 상태 업데이트 (다음 파일로 넘어가기 전)
1006
+ if (i < files.length - 1) {
1007
+ fileUploadStatus.textContent = `[${i + 1}/${files.length}] 완료, 다음 파일 처리 중...`;
1008
+ } else {
1009
+ fileUploadStatus.textContent = `[${i + 1}/${files.length}] 모든 파일 처리 완료`;
1010
+ }
1011
+ }
1012
+ } catch (uploadLoopError) {
1013
+ console.error('[업로드 루프 오류]', uploadLoopError);
1014
+ fileUploadStatus.textContent = `업로드 처리 중 오류 발생: ${uploadLoopError.message}`;
1015
+ fileUploadStatus.className = 'file-upload-status error';
1016
+ showAlert(`업로드 처리 중 오류가 발생했습니다: ${uploadLoopError.message}`, 'error');
1017
  }
1018
 
1019
  // 업로드 완료 처리
1020
+ try {
1021
+ if (fileUploadStatus) {
1022
+ fileUploadStatus.className = 'file-upload-status';
1023
+ if (successCount > 0) {
1024
+ fileUploadStatus.textContent = `${successCount}개 파일 업로드 완료${failCount > 0 ? ` (${failCount}개 실패)` : ''}`;
1025
+ fileUploadStatus.className = 'file-upload-status success';
1026
+ showAlert(`${successCount} 파일이 성공적으로 업로드되었습니다.${failCount > 0 ? ` (${failCount}개 실패)` : ''}`, 'success');
1027
+ loadFiles();
1028
+ } else {
1029
+ fileUploadStatus.textContent = '모든 파일 업로드 실패';
1030
+ fileUploadStatus.className = 'file-upload-status error';
1031
+ const errorDetails = errors.length > 0 ? '\n' + errors.slice(0, 3).join('\n') + (errors.length > 3 ? `\n... 외 ${errors.length - 3}개 오류` : '') : '';
1032
+ showAlert(`파일 업로드에 실패했습니다.${errorDetails}`, 'error');
1033
+ }
1034
+ }
1035
 
1036
+ // UI 활성화
1037
+ if (fileUploadWrapper) fileUploadWrapper.classList.remove('disabled');
1038
+ if (fileModelSelect) fileModelSelect.disabled = false;
1039
+ if (fileInput) {
1040
+ fileInput.disabled = false;
1041
+ fileInput.value = '';
1042
+ }
1043
+ } catch (finalError) {
1044
+ console.error('[handleFileUpload] 완료 처리 오류:', finalError);
1045
+ }
1046
 
1047
  // 3초 후 진행 상태 숨기기
1048
  setTimeout(() => {
 
1095
  }
1096
 
1097
  // 파일 입력 이벤트
1098
+ if (fileInput) {
1099
+ fileInput.addEventListener('change', function(e) {
1100
+ console.log('[파일 입력 이벤트] 파일 선택됨', { filesCount: e.target.files.length });
1101
+ if (e.target.files.length > 0) {
1102
+ console.log('[파일 입력 이벤트] handleFileUpload 호출');
1103
+ handleFileUpload(Array.from(e.target.files)).catch(error => {
1104
+ console.error('[파일 입력 이벤트] handleFileUpload 오류:', error);
1105
+ showAlert(`업로드 오류: ${error.message}`, 'error');
1106
+ });
1107
+ }
1108
+ // 이어서 업로드 모드 초기화
1109
+ continueUploadFileId = null;
1110
+ });
1111
+ } else {
1112
+ console.error('[초기화 오류] fileInput 요소를 찾을 수 없습니다');
1113
+ }
1114
 
1115
  // 드래그 앤 드롭
1116
+ if (fileUploadWrapper) {
1117
+ fileUploadWrapper.addEventListener('dragover', (e) => {
1118
+ e.preventDefault();
1119
+ fileUploadWrapper.classList.add('dragover');
1120
+ });
1121
 
1122
+ fileUploadWrapper.addEventListener('dragleave', () => {
1123
+ fileUploadWrapper.classList.remove('dragover');
1124
+ });
1125
 
1126
+ fileUploadWrapper.addEventListener('drop', (e) => {
1127
+ e.preventDefault();
1128
+ fileUploadWrapper.classList.remove('dragover');
1129
+
1130
+ const files = Array.from(e.dataTransfer.files);
1131
+ console.log('[드래그 앤 드롭] 파일 드롭됨', { filesCount: files.length });
1132
+ if (files.length > 0) {
1133
+ console.log('[드래그 앤 드롭] handleFileUpload 호출');
1134
+ handleFileUpload(files).catch(error => {
1135
+ console.error('[드래그 앤 드롭] handleFileUpload 오류:', error);
1136
+ showAlert(`업로드 오류: ${error.message}`, 'error');
1137
+ });
1138
+ }
1139
+ });
1140
+ } else {
1141
+ console.error('[초기화 오류] fileUploadWrapper 요소를 찾을 수 없습니다');
1142
+ }
1143
 
1144
  // Parent Chunk 확인
1145
  async function viewParentChunk(fileId, fileName) {