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 +3 -0
- app/.cursorrules +69 -0
- app/__init__.py +142 -84
- app/core/__init__.py +9 -0
- app/core/config.py +61 -0
- app/core/logger.py +65 -0
- app/models/__init__.py +23 -0
- app/models/chunk.py +50 -0
- app/models/file.py +39 -0
- app/prompts/__init__.py +13 -0
- app/prompts/metadata.py +39 -0
- app/prompts/parent_chunk.py +52 -0
- app/routes.py +67 -47
- app/utils/__init__.py +25 -0
- app/utils/file_utils.py +79 -0
- app/utils/text_utils.py +195 -0
- requirements.txt +2 -0
- templates/admin_webnovels.html +276 -85
.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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
|
| 17 |
-
def create_app():
|
| 18 |
-
|
| 19 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 42 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 43 |
try:
|
| 44 |
-
# 데이터베이스 URI에서 경로 추출
|
| 45 |
db_uri = app.config['SQLALCHEMY_DATABASE_URI']
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
|
| 56 |
-
|
| 57 |
-
|
| 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 |
-
|
| 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 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
print("[마이그레이션] document_chunk.chunk_metadata 컬럼 추가 완료")
|
| 101 |
|
| 102 |
-
|
| 103 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 104 |
except Exception as e:
|
| 105 |
-
|
| 106 |
-
import traceback
|
| 107 |
-
traceback.print_exc()
|
| 108 |
|
| 109 |
-
|
| 110 |
-
|
|
|
|
|
|
|
|
|
|
| 111 |
admin_username = 'soymedia'
|
| 112 |
admin_password = 's0ymedi@1@34'
|
| 113 |
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 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"[청크 생성] 메타데이터 추출:
|
|
|
|
| 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 |
-
|
| 403 |
-
|
| 404 |
-
|
| 405 |
-
|
|
|
|
| 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 |
-
|
| 415 |
-
|
| 416 |
-
|
| 417 |
-
|
| 418 |
-
|
| 419 |
-
|
| 420 |
-
|
| 421 |
-
|
| 422 |
-
|
| 423 |
-
|
| 424 |
-
|
| 425 |
-
|
| 426 |
-
|
| 427 |
|
| 428 |
-
# DB에 청크 저장 (메타데이터
|
| 429 |
chunk = DocumentChunk(
|
| 430 |
file_id=file_id,
|
| 431 |
chunk_index=idx,
|
| 432 |
content=chunk_content,
|
| 433 |
-
chunk_metadata=
|
| 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}
|
| 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}
|
| 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 |
-
|
|
|
|
|
|
|
| 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] 메타데이터 추출:
|
|
|
|
| 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] 청크 생성 함수 호출 중... (메타데이터 추출:
|
|
|
|
| 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 |
-
|
| 1932 |
-
|
| 1933 |
-
|
| 1934 |
-
|
| 1935 |
-
|
| 1936 |
-
|
| 1937 |
-
|
| 1938 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 732 |
|
| 733 |
-
|
| 734 |
-
|
| 735 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 736 |
return;
|
| 737 |
}
|
| 738 |
|
| 739 |
-
|
| 740 |
-
|
| 741 |
-
|
| 742 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 743 |
|
| 744 |
-
|
| 745 |
-
|
| 746 |
-
|
| 747 |
-
|
| 748 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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: '
|
| 764 |
});
|
| 765 |
|
| 766 |
-
|
| 767 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 768 |
|
| 769 |
let successCount = 0;
|
| 770 |
let failCount = 0;
|
| 771 |
const errors = [];
|
| 772 |
|
|
|
|
|
|
|
| 773 |
// 파일을 순차적으로 업로드
|
| 774 |
-
|
| 775 |
-
|
| 776 |
-
|
| 777 |
-
|
| 778 |
-
|
| 779 |
-
|
| 780 |
-
|
| 781 |
-
|
| 782 |
-
|
| 783 |
-
|
| 784 |
-
|
| 785 |
-
|
| 786 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 787 |
|
| 788 |
-
|
| 789 |
-
|
| 790 |
|
| 791 |
-
|
| 792 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 793 |
|
| 794 |
-
|
| 795 |
-
|
| 796 |
-
|
| 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
|
| 842 |
-
|
| 843 |
-
|
| 844 |
-
|
|
|
|
|
|
|
| 845 |
errors.push(`${file.name}: ${errorMsg}`);
|
| 846 |
console.error(`[업로드 예외] 파일: ${file.name}`, error);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 847 |
console.error(`[스택 트레이스]`, error.stack);
|
| 848 |
}
|
| 849 |
|
| 850 |
-
|
| 851 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 852 |
}
|
| 853 |
|
| 854 |
// 업로드 완료 처리
|
| 855 |
-
|
| 856 |
-
|
| 857 |
-
|
| 858 |
-
|
| 859 |
-
|
| 860 |
-
|
| 861 |
-
|
| 862 |
-
|
| 863 |
-
|
| 864 |
-
|
| 865 |
-
|
| 866 |
-
|
|
|
|
|
|
|
|
|
|
| 867 |
|
| 868 |
-
|
| 869 |
-
|
| 870 |
-
|
| 871 |
-
|
| 872 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 873 |
|
| 874 |
// 3초 후 진행 상태 숨기기
|
| 875 |
setTimeout(() => {
|
|
@@ -922,33 +1095,51 @@
|
|
| 922 |
}
|
| 923 |
|
| 924 |
// 파일 입력 이벤트
|
| 925 |
-
|
| 926 |
-
|
| 927 |
-
|
| 928 |
-
|
| 929 |
-
|
| 930 |
-
|
| 931 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 932 |
|
| 933 |
// 드래그 앤 드롭
|
| 934 |
-
|
| 935 |
-
|
| 936 |
-
|
| 937 |
-
|
|
|
|
| 938 |
|
| 939 |
-
|
| 940 |
-
|
| 941 |
-
|
| 942 |
|
| 943 |
-
|
| 944 |
-
|
| 945 |
-
|
| 946 |
-
|
| 947 |
-
|
| 948 |
-
|
| 949 |
-
|
| 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) {
|