Spaces:
Sleeping
Sleeping
feat(backend): 분석 및 다운로드 API 개선
Browse files- models.py: 분석 모델 스키마 최적화
- routers/analysis.py: 배치 분석 엔드포인트 개선
- routers/downloads.py: 다운로드 API 응답 포맷 개선
- services/pdf_processor.py: PDF 처리 파이프라인 최적화
- app/models.py +1 -1
- app/routers/analysis.py +32 -8
- app/routers/downloads.py +5 -1
- app/services/pdf_processor.py +155 -1
app/models.py
CHANGED
|
@@ -454,7 +454,7 @@ class CombinedResult(Base):
|
|
| 454 |
|
| 455 |
combined_id = Column(Integer, primary_key=True, autoincrement=True, comment="통합 결과 고유 ID")
|
| 456 |
project_id = Column(Integer, ForeignKey("projects.project_id", ondelete="CASCADE"), unique=True, nullable=False, comment="프로젝트 ID (1:1 매핑)")
|
| 457 |
-
combined_text = Column(Text, nullable=False, comment="통합된 전체 텍스트 (페이지별 결과 합침)")
|
| 458 |
combined_stats = Column(JSON, nullable=True, comment="통계 정보 (JSON 형식: 페이지수, 단어수, 문제수 등)")
|
| 459 |
generated_at = Column(DateTime, default=func.now(), comment="최초 생성일")
|
| 460 |
updated_at = Column(DateTime, default=func.now(), onupdate=func.now(), comment="마지막 업데이트일")
|
|
|
|
| 454 |
|
| 455 |
combined_id = Column(Integer, primary_key=True, autoincrement=True, comment="통합 결과 고유 ID")
|
| 456 |
project_id = Column(Integer, ForeignKey("projects.project_id", ondelete="CASCADE"), unique=True, nullable=False, comment="프로젝트 ID (1:1 매핑)")
|
| 457 |
+
combined_text = Column(Text(16777215), nullable=False, comment="통합된 전체 텍스트 (페이지별 결과 합침) - MEDIUMTEXT")
|
| 458 |
combined_stats = Column(JSON, nullable=True, comment="통계 정보 (JSON 형식: 페이지수, 단어수, 문제수 등)")
|
| 459 |
generated_at = Column(DateTime, default=func.now(), comment="최초 생성일")
|
| 460 |
updated_at = Column(DateTime, default=func.now(), onupdate=func.now(), comment="마지막 업데이트일")
|
app/routers/analysis.py
CHANGED
|
@@ -12,6 +12,7 @@ from ..database import get_db, SessionLocal
|
|
| 12 |
from ..models import Page, Project
|
| 13 |
from ..services.batch_analysis import (
|
| 14 |
analyze_project_batch_async,
|
|
|
|
| 15 |
_get_analysis_service,
|
| 16 |
_process_single_page_async,
|
| 17 |
)
|
|
@@ -34,6 +35,8 @@ async_jobs: Dict[str, Dict[str, Any]] = {}
|
|
| 34 |
class ProjectAnalysisRequest(BaseModel):
|
| 35 |
use_ai_descriptions: bool = True
|
| 36 |
api_key: Optional[str] = None
|
|
|
|
|
|
|
| 37 |
|
| 38 |
|
| 39 |
class PageAnalysisRequest(BaseModel):
|
|
@@ -52,22 +55,43 @@ async def analyze_project(
|
|
| 52 |
db: Session = Depends(get_db),
|
| 53 |
):
|
| 54 |
"""
|
| 55 |
-
프로젝트 전체 배치 분석 (
|
| 56 |
|
| 57 |
-
- 프로젝트 내 모든 pending 상태 페이지를
|
| 58 |
- 레이아웃 분석 → OCR → 정렬 → 포맷팅까지 전체 파이프라인 수행
|
| 59 |
- AI 설명 생성 시 비동기 OpenAI 호출을 활용
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 60 |
"""
|
| 61 |
project_exists = db.query(Project.project_id).filter(Project.project_id == project_id).scalar()
|
| 62 |
if not project_exists:
|
| 63 |
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="프로젝트를 찾을 수 없습니다.")
|
| 64 |
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 71 |
return analysis_result
|
| 72 |
|
| 73 |
|
|
|
|
| 12 |
from ..models import Page, Project
|
| 13 |
from ..services.batch_analysis import (
|
| 14 |
analyze_project_batch_async,
|
| 15 |
+
analyze_project_batch_async_parallel,
|
| 16 |
_get_analysis_service,
|
| 17 |
_process_single_page_async,
|
| 18 |
)
|
|
|
|
| 35 |
class ProjectAnalysisRequest(BaseModel):
|
| 36 |
use_ai_descriptions: bool = True
|
| 37 |
api_key: Optional[str] = None
|
| 38 |
+
use_parallel: bool = False
|
| 39 |
+
max_concurrent_pages: int = 4
|
| 40 |
|
| 41 |
|
| 42 |
class PageAnalysisRequest(BaseModel):
|
|
|
|
| 55 |
db: Session = Depends(get_db),
|
| 56 |
):
|
| 57 |
"""
|
| 58 |
+
프로젝트 전체 배치 분석 (비동기/병렬 선택 가능)
|
| 59 |
|
| 60 |
+
- 프로젝트 내 모든 pending 상태 페이지를 분석
|
| 61 |
- 레이아웃 분석 → OCR → 정렬 → 포맷팅까지 전체 파이프라인 수행
|
| 62 |
- AI 설명 생성 시 비동기 OpenAI 호출을 활용
|
| 63 |
+
|
| 64 |
+
파라미터:
|
| 65 |
+
- use_parallel: True이면 여러 페이지를 병렬로 동시 처리 (기본값: False)
|
| 66 |
+
- max_concurrent_pages: 병렬 처리 시 최대 동시 실행 페이지 수 (기본값: 4)
|
| 67 |
+
|
| 68 |
+
병렬 처리 사용 시:
|
| 69 |
+
- 속도: 3-4배 향상
|
| 70 |
+
- 리소스: 더 많은 메모리/GPU 사용
|
| 71 |
+
- 권장: 중대형 시스템 (8GB+ RAM)
|
| 72 |
"""
|
| 73 |
project_exists = db.query(Project.project_id).filter(Project.project_id == project_id).scalar()
|
| 74 |
if not project_exists:
|
| 75 |
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="프로젝트를 찾을 수 없습니다.")
|
| 76 |
|
| 77 |
+
if payload.use_parallel:
|
| 78 |
+
logger.info(f"병렬 분석 시작: project_id={project_id}, max_concurrent={payload.max_concurrent_pages}")
|
| 79 |
+
analysis_result = await analyze_project_batch_async_parallel(
|
| 80 |
+
db=db,
|
| 81 |
+
project_id=project_id,
|
| 82 |
+
use_ai_descriptions=payload.use_ai_descriptions,
|
| 83 |
+
api_key=payload.api_key,
|
| 84 |
+
max_concurrent_pages=payload.max_concurrent_pages,
|
| 85 |
+
)
|
| 86 |
+
else:
|
| 87 |
+
logger.info(f"순차 분석 시작: project_id={project_id}")
|
| 88 |
+
analysis_result = await analyze_project_batch_async(
|
| 89 |
+
db=db,
|
| 90 |
+
project_id=project_id,
|
| 91 |
+
use_ai_descriptions=payload.use_ai_descriptions,
|
| 92 |
+
api_key=payload.api_key,
|
| 93 |
+
)
|
| 94 |
+
|
| 95 |
return analysis_result
|
| 96 |
|
| 97 |
|
app/routers/downloads.py
CHANGED
|
@@ -45,9 +45,13 @@ def get_combined_text(
|
|
| 45 |
combined_data = generate_combined_text(db, project_id, use_cache=True)
|
| 46 |
return schemas.CombinedTextResponse.model_validate(combined_data)
|
| 47 |
except ValueError as value_error:
|
|
|
|
| 48 |
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(value_error)) from value_error
|
| 49 |
except Exception as error: # pylint: disable=broad-except
|
| 50 |
-
logger.error("통합 텍스트 생성 실패: project_id
|
|
|
|
|
|
|
|
|
|
| 51 |
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="통합 텍스트 생성 중 오류가 발생했습니다.") from error
|
| 52 |
|
| 53 |
|
|
|
|
| 45 |
combined_data = generate_combined_text(db, project_id, use_cache=True)
|
| 46 |
return schemas.CombinedTextResponse.model_validate(combined_data)
|
| 47 |
except ValueError as value_error:
|
| 48 |
+
logger.error(f"통합 텍스트 생성 실패 (ValueError): project_id={project_id} / error={str(value_error)}", exc_info=True)
|
| 49 |
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(value_error)) from value_error
|
| 50 |
except Exception as error: # pylint: disable=broad-except
|
| 51 |
+
logger.error(f"통합 텍스트 생성 실패: project_id={project_id} / error={str(error)}", exc_info=True)
|
| 52 |
+
combined_data_value = locals().get('combined_data', 'N/A')
|
| 53 |
+
logger.error(f"combined_data 내용: {str(combined_data_value)}")
|
| 54 |
+
logger.error(f"combined_data 타입: {str(type(combined_data_value))}")
|
| 55 |
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="통합 텍스트 생성 중 오류가 발생했습니다.") from error
|
| 56 |
|
| 57 |
|
app/services/pdf_processor.py
CHANGED
|
@@ -7,13 +7,14 @@ PDF 파일을 페이지별 이미지로 변환하는 기능을 제공합니다.
|
|
| 7 |
PyMuPDF (fitz)를 사용하여 고품질 이미지 변환을 수행합니다.
|
| 8 |
"""
|
| 9 |
|
| 10 |
-
from typing import List, Dict, Optional
|
| 11 |
from loguru import logger
|
| 12 |
import os
|
| 13 |
import fitz # PyMuPDF
|
| 14 |
from PIL import Image
|
| 15 |
import io
|
| 16 |
from pathlib import Path
|
|
|
|
| 17 |
|
| 18 |
DEFAULT_PDF_DPI = 300
|
| 19 |
|
|
@@ -184,6 +185,159 @@ class PDFProcessor:
|
|
| 184 |
if pdf_document:
|
| 185 |
pdf_document.close()
|
| 186 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 187 |
def _rollback_conversion(self, converted_pages: List[Dict[str, any]]) -> None:
|
| 188 |
"""
|
| 189 |
변환 실패 시 생성된 이미지 파일 롤백
|
|
|
|
| 7 |
PyMuPDF (fitz)를 사용하여 고품질 이미지 변환을 수행합니다.
|
| 8 |
"""
|
| 9 |
|
| 10 |
+
from typing import List, Dict, Optional, Tuple
|
| 11 |
from loguru import logger
|
| 12 |
import os
|
| 13 |
import fitz # PyMuPDF
|
| 14 |
from PIL import Image
|
| 15 |
import io
|
| 16 |
from pathlib import Path
|
| 17 |
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
| 18 |
|
| 19 |
DEFAULT_PDF_DPI = 300
|
| 20 |
|
|
|
|
| 185 |
if pdf_document:
|
| 186 |
pdf_document.close()
|
| 187 |
|
| 188 |
+
def convert_pdf_to_images_parallel(
|
| 189 |
+
self,
|
| 190 |
+
pdf_bytes: bytes,
|
| 191 |
+
project_id: int,
|
| 192 |
+
start_page_number: int,
|
| 193 |
+
max_workers: Optional[int] = None
|
| 194 |
+
) -> List[Dict[str, any]]:
|
| 195 |
+
"""
|
| 196 |
+
PDF 바이트 데이터를 페이지별 이미지로 병렬 변환하고 저장
|
| 197 |
+
|
| 198 |
+
Args:
|
| 199 |
+
pdf_bytes: PDF 파일의 바이트 데이터
|
| 200 |
+
project_id: 프로젝트 ID (폴더 경로용)
|
| 201 |
+
start_page_number: 시작 페이지 번호
|
| 202 |
+
max_workers: 최대 워커 스레드 수 (None이면 CPU 코어 수, 최대 4개)
|
| 203 |
+
|
| 204 |
+
Returns:
|
| 205 |
+
변환된 이미지 정보 리스트
|
| 206 |
+
|
| 207 |
+
Note:
|
| 208 |
+
ThreadPoolExecutor를 사용하여 여러 페이지를 동시에 변환합니다.
|
| 209 |
+
대용량 PDF의 경우 변환 속도가 2-3배 향상됩니다.
|
| 210 |
+
max_workers를 너무 크게 설정하면 메모리 사용량이 증가할 수 있으므로 주의하세요.
|
| 211 |
+
"""
|
| 212 |
+
logger.info(
|
| 213 |
+
f"PDF 병렬 변환 시작 - ProjectID: {project_id}, 시작 페이지: {start_page_number}"
|
| 214 |
+
)
|
| 215 |
+
|
| 216 |
+
# 프로젝트별 저장 디렉토리 생성
|
| 217 |
+
project_dir = self.upload_directory / str(project_id)
|
| 218 |
+
project_dir.mkdir(parents=True, exist_ok=True)
|
| 219 |
+
|
| 220 |
+
pdf_document = None
|
| 221 |
+
converted_pages = []
|
| 222 |
+
|
| 223 |
+
try:
|
| 224 |
+
# PDF 문서 열기
|
| 225 |
+
pdf_document = fitz.open(stream=pdf_bytes, filetype="pdf")
|
| 226 |
+
total_pages = len(pdf_document)
|
| 227 |
+
logger.info(f"PDF 페이지 수: {total_pages}")
|
| 228 |
+
|
| 229 |
+
if total_pages == 0:
|
| 230 |
+
raise ValueError("PDF 파일에 페이지가 없습니다.")
|
| 231 |
+
|
| 232 |
+
# PDF 원본 파일 저장
|
| 233 |
+
original_pdf_path = project_dir / "original.pdf"
|
| 234 |
+
with open(original_pdf_path, "wb") as f:
|
| 235 |
+
f.write(pdf_bytes)
|
| 236 |
+
logger.info(f"PDF 원본 저장 완료: {original_pdf_path}")
|
| 237 |
+
|
| 238 |
+
# 워커 수 결정 (기본: CPU 코어 수, 최대 4개)
|
| 239 |
+
if max_workers is None:
|
| 240 |
+
max_workers = min(os.cpu_count() or 4, 4)
|
| 241 |
+
|
| 242 |
+
logger.info(f"병렬 변환 시작: {max_workers}개 워커 사용")
|
| 243 |
+
|
| 244 |
+
def convert_single_page(page_index: int) -> Dict[str, any]:
|
| 245 |
+
"""
|
| 246 |
+
단일 페이지 변환 (완전 독립 실행)
|
| 247 |
+
|
| 248 |
+
각 스레드가 독립적인 PDF 문서 인스턴스를 생성하여
|
| 249 |
+
진정한 병렬 처리를 수행합니다.
|
| 250 |
+
"""
|
| 251 |
+
page_number = start_page_number + page_index
|
| 252 |
+
|
| 253 |
+
try:
|
| 254 |
+
# 각 스레드가 독립적인 PDF 문서 인스턴스 생성
|
| 255 |
+
# PyMuPDF는 각 Document 객체가 독립적이면 스레드 안전함
|
| 256 |
+
temp_doc = fitz.open(stream=pdf_bytes, filetype="pdf")
|
| 257 |
+
page = temp_doc[page_index]
|
| 258 |
+
|
| 259 |
+
# DPI 기반 확대 비율 계산
|
| 260 |
+
zoom = self.dpi / 72
|
| 261 |
+
mat = fitz.Matrix(zoom, zoom)
|
| 262 |
+
pix = page.get_pixmap(matrix=mat, alpha=False)
|
| 263 |
+
|
| 264 |
+
# PIL Image로 변환
|
| 265 |
+
img_data = pix.tobytes("jpeg")
|
| 266 |
+
temp_doc.close()
|
| 267 |
+
|
| 268 |
+
img = Image.open(io.BytesIO(img_data))
|
| 269 |
+
width, height = img.size
|
| 270 |
+
|
| 271 |
+
# 파일명 및 경로 생성
|
| 272 |
+
filename = f"page_{page_number}.jpg"
|
| 273 |
+
full_path = project_dir / filename
|
| 274 |
+
public_path = Path("uploads") / str(project_id) / filename
|
| 275 |
+
|
| 276 |
+
# 이미지 저장
|
| 277 |
+
img.save(str(full_path), "JPEG", quality=self.jpeg_quality, optimize=True)
|
| 278 |
+
|
| 279 |
+
logger.debug(
|
| 280 |
+
f"페이지 {page_index + 1}/{total_pages} 변환 완료 - "
|
| 281 |
+
f"페이지 번호: {page_number}, 크기: {width}x{height}"
|
| 282 |
+
)
|
| 283 |
+
|
| 284 |
+
return {
|
| 285 |
+
'page_number': page_number,
|
| 286 |
+
'image_path': str(public_path).replace("\\", "/"),
|
| 287 |
+
'full_path': str(full_path),
|
| 288 |
+
'width': width,
|
| 289 |
+
'height': height,
|
| 290 |
+
'dpi': self.dpi,
|
| 291 |
+
}
|
| 292 |
+
|
| 293 |
+
except Exception as e:
|
| 294 |
+
logger.error(f"페이지 {page_index + 1} 병렬 변환 실패: {str(e)}")
|
| 295 |
+
raise ValueError(f"PDF 페이지 {page_index + 1} 변환 실패: {str(e)}")
|
| 296 |
+
|
| 297 |
+
# ThreadPoolExecutor로 병렬 처리
|
| 298 |
+
with ThreadPoolExecutor(max_workers=max_workers) as executor:
|
| 299 |
+
# 모든 페이지에 대한 Future 생성
|
| 300 |
+
future_to_page = {
|
| 301 |
+
executor.submit(convert_single_page, i): i
|
| 302 |
+
for i in range(total_pages)
|
| 303 |
+
}
|
| 304 |
+
|
| 305 |
+
# 완료된 순서대로 결과 수집
|
| 306 |
+
for future in as_completed(future_to_page):
|
| 307 |
+
page_index = future_to_page[future]
|
| 308 |
+
try:
|
| 309 |
+
page_info = future.result()
|
| 310 |
+
converted_pages.append(page_info)
|
| 311 |
+
except Exception as e:
|
| 312 |
+
logger.error(f"페이지 {page_index + 1} 처리 실패: {str(e)}")
|
| 313 |
+
# 실패 시 롤백
|
| 314 |
+
self._rollback_conversion(converted_pages)
|
| 315 |
+
raise
|
| 316 |
+
|
| 317 |
+
# 페이지 번호 순으로 정렬
|
| 318 |
+
converted_pages.sort(key=lambda x: x['page_number'])
|
| 319 |
+
|
| 320 |
+
logger.info(
|
| 321 |
+
f"PDF 병렬 변환 완료 - ProjectID: {project_id}, "
|
| 322 |
+
f"총 {len(converted_pages)}개 페이지 변환"
|
| 323 |
+
)
|
| 324 |
+
return converted_pages
|
| 325 |
+
|
| 326 |
+
except fitz.fitz.FileDataError as e:
|
| 327 |
+
logger.error(f"PDF 파일 오류: {str(e)}")
|
| 328 |
+
raise ValueError(f"PDF 파일이 손상되었거나 읽을 수 없습니다: {str(e)}")
|
| 329 |
+
|
| 330 |
+
except Exception as e:
|
| 331 |
+
logger.error(f"PDF 병렬 변환 중 예상치 못한 오류: {str(e)}")
|
| 332 |
+
if converted_pages:
|
| 333 |
+
self._rollback_conversion(converted_pages)
|
| 334 |
+
raise
|
| 335 |
+
|
| 336 |
+
finally:
|
| 337 |
+
# PDF 문서 닫기
|
| 338 |
+
if pdf_document:
|
| 339 |
+
pdf_document.close()
|
| 340 |
+
|
| 341 |
def _rollback_conversion(self, converted_pages: List[Dict[str, any]]) -> None:
|
| 342 |
"""
|
| 343 |
변환 실패 시 생성된 이미지 파일 롤백
|