Spaces:
Sleeping
Sleeping
sync: Smart_Demo 브랜치의 Backend 코드 병합 & 이미지 로드를 위한 MultiFileLoader 컴포넌트 구현
Browse files- 동료가 개발한 최신 Backend 반영
- 기존 Backend는 Backend_backup_before_sync에 백업
- 이미지 로드를 위한 MultiFileLoader 컴포넌트 구현
- .env.docker +11 -0
- app/crud.py +4 -0
- app/main.py +20 -19
- app/routers/__init__.py +13 -10
- app/routers/analysis.py +264 -0
- app/routers/downloads.py +95 -0
- app/routers/pages.py +243 -0
- app/routers/projects.py +113 -0
- app/schemas.py +59 -0
- app/services/__init__.py +72 -0
- app/services/analysis_service.py +1057 -0
- app/services/batch_analysis.py +450 -0
- app/services/download_service.py +257 -0
- app/services/formatter.py +403 -0
- app/services/formatter_rules.py +200 -0
- app/services/formatter_utils.py +306 -0
- app/services/mock_models.py +417 -0
- app/services/pdf_processor.py +250 -0
- app/services/sorter.py +1605 -0
- app/services/sorter_strategies.py +788 -0
- app/services/sorter_구버전.py +1316 -0
- app/services/text_version_service.py +179 -0
- docker-compose.yml +57 -0
- docs/Backend API 문서/00_개요.md +413 -0
- docs/Backend API 문서/01_프로젝트_API.md +608 -0
- docs/Backend API 문서/02_페이지_API.md +772 -0
- docs/Backend API 문서/03_분석_API.md +682 -0
- docs/Backend API 문서/04_다운로드_API.md +477 -0
- docs/Backend API 문서/05_데이터_모델.md +678 -0
- docs/Backend API 문서/06_에러_처리.md +556 -0
- docs/Backend API 문서/07_예제_코드.md +0 -0
- docs/백엔드 환경 설정/백엔드 DB 준비방법.txt +61 -0
- docs/백엔드 환경 설정/백엔드 테스트 준비방법.txt +202 -0
- requirements.txt +29 -3
- scripts/reset_db.sh +54 -0
- scripts/seed_test_data.sql +2 -3
- scripts/test_pdf_upload.py +0 -0
.env.docker
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Docker MySQL 환경 변수
|
| 2 |
+
# docker-compose.yml에서 사용됨
|
| 3 |
+
|
| 4 |
+
# MySQL 설정
|
| 5 |
+
MYSQL_ROOT_PASSWORD=1q2w3e4r
|
| 6 |
+
MYSQL_DATABASE=smarteyessen_db
|
| 7 |
+
MYSQL_PORT=3308
|
| 8 |
+
|
| 9 |
+
# 선택적: 추가 사용자 (보안 강화)
|
| 10 |
+
# MYSQL_USER=smarteye_user
|
| 11 |
+
# MYSQL_PASSWORD=smarteye_secure_password_here
|
app/crud.py
CHANGED
|
@@ -664,6 +664,10 @@ def get_formatting_rule_by_class(db: Session, doc_type_id: int, class_name: str)
|
|
| 664 |
def get_formatting_rules_by_doc_type(db: Session, doc_type_id: int) -> List[models.FormattingRule]:
|
| 665 |
return db.query(models.FormattingRule).filter(models.FormattingRule.doc_type_id == doc_type_id).all()
|
| 666 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 667 |
def create_formatting_rule(db: Session, rule: schemas.FormattingRuleCreate) -> models.FormattingRule:
|
| 668 |
db_rule = models.FormattingRule(**rule.model_dump())
|
| 669 |
db.add(db_rule)
|
|
|
|
| 664 |
def get_formatting_rules_by_doc_type(db: Session, doc_type_id: int) -> List[models.FormattingRule]:
|
| 665 |
return db.query(models.FormattingRule).filter(models.FormattingRule.doc_type_id == doc_type_id).all()
|
| 666 |
|
| 667 |
+
def get_all_formatting_rules(db: Session) -> List[models.FormattingRule]:
|
| 668 |
+
"""모든 포맷팅 규칙 조회"""
|
| 669 |
+
return db.query(models.FormattingRule).all()
|
| 670 |
+
|
| 671 |
def create_formatting_rule(db: Session, rule: schemas.FormattingRuleCreate) -> models.FormattingRule:
|
| 672 |
db_rule = models.FormattingRule(**rule.model_dump())
|
| 673 |
db.add(db_rule)
|
app/main.py
CHANGED
|
@@ -11,16 +11,18 @@ FastAPI 메인 애플리케이션 및 라우터 설정
|
|
| 11 |
- API 문서화
|
| 12 |
"""
|
| 13 |
|
| 14 |
-
|
|
|
|
|
|
|
|
|
|
| 15 |
from fastapi.middleware.cors import CORSMiddleware
|
| 16 |
from fastapi.responses import JSONResponse
|
| 17 |
-
from sqlalchemy.orm import Session
|
| 18 |
from sqlalchemy import text
|
| 19 |
-
import
|
| 20 |
-
from dotenv import load_dotenv
|
| 21 |
|
| 22 |
from .database import engine, get_db, init_db, test_connection
|
| 23 |
from . import models
|
|
|
|
| 24 |
|
| 25 |
# 환경 변수 로드
|
| 26 |
load_dotenv()
|
|
@@ -38,20 +40,20 @@ app = FastAPI(
|
|
| 38 |
### 주요 기능
|
| 39 |
* 📄 **다중 페이지 문서 처리**: Worksheet 및 Document 유형 지원
|
| 40 |
* 🤖 **AI 레이아웃 분석**: DocLayout-YOLO 기반 레이아웃 감지
|
| 41 |
-
* 🔍 **OCR 텍스트 추출**:
|
| 42 |
* ✏️ **텍스트 편집 및 버전 관리**: TinyMCE 편집기 지원
|
| 43 |
-
* 🖼️ **AI 설명 생성**: GPT-
|
| 44 |
* 📊 **문제 기반 정렬**: Worksheet 전용 문제 번호 기반 정렬
|
| 45 |
* 📐 **좌표 기반 정렬**: Document 전용 좌표 기반 정렬
|
| 46 |
-
* 📥 **통합 문서 다운로드**: DOCX
|
| 47 |
|
| 48 |
### 기술 스택
|
| 49 |
* **Backend**: FastAPI + SQLAlchemy
|
| 50 |
* **Database**: MySQL 8.0
|
| 51 |
-
* **AI Models**: DocLayout-YOLO,
|
| 52 |
* **Document**: python-docx
|
| 53 |
""",
|
| 54 |
-
version="1.0.
|
| 55 |
docs_url="/docs",
|
| 56 |
redoc_url="/redoc",
|
| 57 |
openapi_url="/openapi.json",
|
|
@@ -125,7 +127,7 @@ async def root():
|
|
| 125 |
"""
|
| 126 |
return {
|
| 127 |
"message": "Welcome to SmartEyeSsen API",
|
| 128 |
-
"version": "1.0.
|
| 129 |
"status": "running",
|
| 130 |
"docs": "/docs",
|
| 131 |
"redoc": "/redoc"
|
|
@@ -140,7 +142,7 @@ async def health_check(db: Session = Depends(get_db)):
|
|
| 140 |
서버 및 데이터베이스 상태 확인
|
| 141 |
"""
|
| 142 |
try:
|
| 143 |
-
# 간단한 쿼리로 DB 연결 확인
|
| 144 |
db.execute(text("SELECT 1"))
|
| 145 |
db_status = "connected"
|
| 146 |
except Exception as e:
|
|
@@ -181,14 +183,13 @@ async def general_exception_handler(request, exc):
|
|
| 181 |
)
|
| 182 |
|
| 183 |
|
| 184 |
-
#
|
| 185 |
-
# 라우터 등록
|
| 186 |
-
#
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
# app.include_router(layout_elements.router, prefix="/api/v1/elements", tags=["Layout Elements"])
|
| 192 |
|
| 193 |
|
| 194 |
# ============================================================================
|
|
|
|
| 11 |
- API 문서화
|
| 12 |
"""
|
| 13 |
|
| 14 |
+
import os
|
| 15 |
+
from dotenv import load_dotenv
|
| 16 |
+
|
| 17 |
+
from fastapi import Depends, FastAPI, HTTPException, status
|
| 18 |
from fastapi.middleware.cors import CORSMiddleware
|
| 19 |
from fastapi.responses import JSONResponse
|
|
|
|
| 20 |
from sqlalchemy import text
|
| 21 |
+
from sqlalchemy.orm import Session
|
|
|
|
| 22 |
|
| 23 |
from .database import engine, get_db, init_db, test_connection
|
| 24 |
from . import models
|
| 25 |
+
from .routers import analysis, downloads, pages, projects
|
| 26 |
|
| 27 |
# 환경 변수 로드
|
| 28 |
load_dotenv()
|
|
|
|
| 40 |
### 주요 기능
|
| 41 |
* 📄 **다중 페이지 문서 처리**: Worksheet 및 Document 유형 지원
|
| 42 |
* 🤖 **AI 레이아웃 분석**: DocLayout-YOLO 기반 레이아웃 감지
|
| 43 |
+
* 🔍 **OCR 텍스트 추출**: Tesseract OCR 기반 텍스트 인식
|
| 44 |
* ✏️ **텍스트 편집 및 버전 관리**: TinyMCE 편집기 지원
|
| 45 |
+
* 🖼️ **AI 설명 생성**: GPT-4-turbo 기반 figure/table/flowchart 설명
|
| 46 |
* 📊 **문제 기반 정렬**: Worksheet 전용 문제 번호 기반 정렬
|
| 47 |
* 📐 **좌표 기반 정렬**: Document 전용 좌표 기반 정렬
|
| 48 |
+
* 📥 **통합 문서 다운로드**: DOCX 형식 지원
|
| 49 |
|
| 50 |
### 기술 스택
|
| 51 |
* **Backend**: FastAPI + SQLAlchemy
|
| 52 |
* **Database**: MySQL 8.0
|
| 53 |
+
* **AI Models**: DocLayout-YOLO, Tesseract OCR, GPT-4-turbo
|
| 54 |
* **Document**: python-docx
|
| 55 |
""",
|
| 56 |
+
version="1.0.1",
|
| 57 |
docs_url="/docs",
|
| 58 |
redoc_url="/redoc",
|
| 59 |
openapi_url="/openapi.json",
|
|
|
|
| 127 |
"""
|
| 128 |
return {
|
| 129 |
"message": "Welcome to SmartEyeSsen API",
|
| 130 |
+
"version": "1.0.1",
|
| 131 |
"status": "running",
|
| 132 |
"docs": "/docs",
|
| 133 |
"redoc": "/redoc"
|
|
|
|
| 142 |
서버 및 데이터베이스 상태 확인
|
| 143 |
"""
|
| 144 |
try:
|
| 145 |
+
# 간단한 쿼리로 DB 연결 확인
|
| 146 |
db.execute(text("SELECT 1"))
|
| 147 |
db_status = "connected"
|
| 148 |
except Exception as e:
|
|
|
|
| 183 |
)
|
| 184 |
|
| 185 |
|
| 186 |
+
# =========================================================================
|
| 187 |
+
# 라우터 등록
|
| 188 |
+
# =========================================================================
|
| 189 |
+
app.include_router(projects.router)
|
| 190 |
+
app.include_router(pages.router)
|
| 191 |
+
app.include_router(analysis.router)
|
| 192 |
+
app.include_router(downloads.router)
|
|
|
|
| 193 |
|
| 194 |
|
| 195 |
# ============================================================================
|
app/routers/__init__.py
CHANGED
|
@@ -3,15 +3,18 @@ SmartEyeSsen Backend - Routers Package
|
|
| 3 |
=======================================
|
| 4 |
API 라우터 패키지 초기화
|
| 5 |
|
| 6 |
-
|
| 7 |
-
-
|
| 8 |
-
-
|
| 9 |
-
-
|
| 10 |
-
-
|
| 11 |
-
- text_contents.py: 텍스트 내용 API
|
| 12 |
-
- ai_descriptions.py: AI 설명 API
|
| 13 |
-
- formatting_rules.py: 서식 규칙 API
|
| 14 |
-
- combined_results.py: 통합 결과 API
|
| 15 |
"""
|
| 16 |
|
| 17 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
=======================================
|
| 4 |
API 라우터 패키지 초기화
|
| 5 |
|
| 6 |
+
제공 라우터:
|
| 7 |
+
- projects.py: 프로젝트 CRUD
|
| 8 |
+
- pages.py: 페이지 업로드 및 조회
|
| 9 |
+
- analysis.py: 배치 분석 트리거
|
| 10 |
+
- downloads.py: 통합 텍스트/문서 다운로드 API
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
"""
|
| 12 |
|
| 13 |
+
from . import analysis, downloads, pages, projects
|
| 14 |
+
|
| 15 |
+
__all__ = [
|
| 16 |
+
"analysis",
|
| 17 |
+
"downloads",
|
| 18 |
+
"pages",
|
| 19 |
+
"projects",
|
| 20 |
+
]
|
app/routers/analysis.py
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import uuid
|
| 4 |
+
from typing import Any, Dict, Optional
|
| 5 |
+
|
| 6 |
+
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, status
|
| 7 |
+
from loguru import logger
|
| 8 |
+
from pydantic import BaseModel
|
| 9 |
+
from sqlalchemy.orm import Session
|
| 10 |
+
|
| 11 |
+
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 |
+
)
|
| 18 |
+
from ..services.formatter import TextFormatter
|
| 19 |
+
|
| 20 |
+
router = APIRouter(
|
| 21 |
+
prefix="/api",
|
| 22 |
+
tags=["Analysis"],
|
| 23 |
+
)
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
# ============================================================================
|
| 27 |
+
# 비동기 작업 상태 저장소
|
| 28 |
+
# ============================================================================
|
| 29 |
+
# 주의: 프로덕션 환경에서는 Redis나 Celery를 사용하는 것을 권장합니다.
|
| 30 |
+
# 현재 구현은 개발/테스트 환경용 인메모리 저장소입니다.
|
| 31 |
+
async_jobs: Dict[str, Dict[str, Any]] = {}
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
class ProjectAnalysisRequest(BaseModel):
|
| 35 |
+
use_ai_descriptions: bool = True
|
| 36 |
+
api_key: Optional[str] = None
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
class PageAnalysisRequest(BaseModel):
|
| 40 |
+
"""단일 페이지 비동기 분석 요청"""
|
| 41 |
+
use_ai_descriptions: bool = True
|
| 42 |
+
api_key: Optional[str] = None
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
@router.post(
|
| 46 |
+
"/projects/{project_id}/analyze",
|
| 47 |
+
status_code=status.HTTP_202_ACCEPTED,
|
| 48 |
+
)
|
| 49 |
+
async def analyze_project(
|
| 50 |
+
project_id: int,
|
| 51 |
+
payload: ProjectAnalysisRequest,
|
| 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 |
+
analysis_result = await analyze_project_batch_async(
|
| 66 |
+
db=db,
|
| 67 |
+
project_id=project_id,
|
| 68 |
+
use_ai_descriptions=payload.use_ai_descriptions,
|
| 69 |
+
api_key=payload.api_key,
|
| 70 |
+
)
|
| 71 |
+
return analysis_result
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
# ============================================================================
|
| 75 |
+
# 비동기 분석 엔드포인트
|
| 76 |
+
# ============================================================================
|
| 77 |
+
|
| 78 |
+
@router.post(
|
| 79 |
+
"/pages/{page_id}/analyze/async",
|
| 80 |
+
status_code=status.HTTP_202_ACCEPTED,
|
| 81 |
+
)
|
| 82 |
+
def analyze_page_async(
|
| 83 |
+
page_id: int,
|
| 84 |
+
payload: PageAnalysisRequest,
|
| 85 |
+
background_tasks: BackgroundTasks,
|
| 86 |
+
db: Session = Depends(get_db),
|
| 87 |
+
):
|
| 88 |
+
"""
|
| 89 |
+
단일 페이지 비동기 분석 시작
|
| 90 |
+
|
| 91 |
+
- Phase 3.2 배치 분석과 병행 가능한 단일 페이지 비동기 분석
|
| 92 |
+
- 작업 ID를 즉시 반환하고 백그라운드에서 분석 수행
|
| 93 |
+
- 작업 상태는 GET /api/analysis/jobs/{job_id}로 조회 가능
|
| 94 |
+
|
| 95 |
+
Args:
|
| 96 |
+
page_id: 분석할 페이지 ID
|
| 97 |
+
payload: 분석 옵션 (AI 설명 사용 여부, API 키)
|
| 98 |
+
background_tasks: FastAPI 백그라운드 작업 매니저
|
| 99 |
+
db: 데이터베이스 세션
|
| 100 |
+
|
| 101 |
+
Returns:
|
| 102 |
+
작업 ID와 상태 조회 URL
|
| 103 |
+
"""
|
| 104 |
+
# 페이지 존재 확인
|
| 105 |
+
page = db.query(Page).filter(Page.page_id == page_id).first()
|
| 106 |
+
if not page:
|
| 107 |
+
raise HTTPException(
|
| 108 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 109 |
+
detail=f"페이지 ID {page_id}를 찾을 수 없습니다."
|
| 110 |
+
)
|
| 111 |
+
|
| 112 |
+
# 작업 ID 생성
|
| 113 |
+
job_id = str(uuid.uuid4())
|
| 114 |
+
async_jobs[job_id] = {
|
| 115 |
+
"job_id": job_id,
|
| 116 |
+
"status": "pending",
|
| 117 |
+
"page_id": page_id,
|
| 118 |
+
"page_number": page.page_number,
|
| 119 |
+
"project_id": page.project_id,
|
| 120 |
+
"result": None,
|
| 121 |
+
"error": None,
|
| 122 |
+
"progress": "작업 대기 중...",
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
logger.info(f"비동기 페이지 분석 작업 생성: job_id={job_id}, page_id={page_id}")
|
| 126 |
+
|
| 127 |
+
# 백그라운드 작업 등록
|
| 128 |
+
background_tasks.add_task(
|
| 129 |
+
_run_async_page_analysis,
|
| 130 |
+
job_id=job_id,
|
| 131 |
+
page_id=page_id,
|
| 132 |
+
use_ai_descriptions=payload.use_ai_descriptions,
|
| 133 |
+
api_key=payload.api_key,
|
| 134 |
+
)
|
| 135 |
+
|
| 136 |
+
return {
|
| 137 |
+
"job_id": job_id,
|
| 138 |
+
"status": "pending",
|
| 139 |
+
"message": "페이지 분석 작업이 시작되었습니다.",
|
| 140 |
+
"page_id": page_id,
|
| 141 |
+
"status_check_url": f"/api/analysis/jobs/{job_id}",
|
| 142 |
+
}
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
@router.get("/analysis/jobs/{job_id}")
|
| 146 |
+
def get_analysis_job_status(job_id: str):
|
| 147 |
+
"""
|
| 148 |
+
비동기 분석 작업 상태 조회
|
| 149 |
+
|
| 150 |
+
Args:
|
| 151 |
+
job_id: 작업 ID (analyze_page_async 엔드포인트에서 반환된 값)
|
| 152 |
+
|
| 153 |
+
Returns:
|
| 154 |
+
작업 상태 정보 (pending, processing, completed, failed)
|
| 155 |
+
"""
|
| 156 |
+
if job_id not in async_jobs:
|
| 157 |
+
raise HTTPException(
|
| 158 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 159 |
+
detail=f"작업 ID {job_id}를 찾을 수 없습니다."
|
| 160 |
+
)
|
| 161 |
+
|
| 162 |
+
return async_jobs[job_id]
|
| 163 |
+
|
| 164 |
+
|
| 165 |
+
# ============================================================================
|
| 166 |
+
# 백그라운드 작업 실행 함수
|
| 167 |
+
# ============================================================================
|
| 168 |
+
|
| 169 |
+
async def _run_async_page_analysis(
|
| 170 |
+
job_id: str,
|
| 171 |
+
page_id: int,
|
| 172 |
+
use_ai_descriptions: bool,
|
| 173 |
+
api_key: Optional[str],
|
| 174 |
+
) -> None:
|
| 175 |
+
"""
|
| 176 |
+
백그라운드에서 실행되는 단일 페이지 비동기 분석 작업
|
| 177 |
+
|
| 178 |
+
- 새로운 DB 세션을 생성하여 사용 (백그라운드 컨텍스트)
|
| 179 |
+
- 기존 batch_analysis.py의 _process_single_page_async 로직 재사용
|
| 180 |
+
- 작업 상태를 async_jobs 딕셔너리에 기록
|
| 181 |
+
|
| 182 |
+
Args:
|
| 183 |
+
job_id: 작업 ID
|
| 184 |
+
page_id: 분석할 페이지 ID
|
| 185 |
+
use_ai_descriptions: AI 설명 생성 여부
|
| 186 |
+
api_key: OpenAI API 키 (선택)
|
| 187 |
+
"""
|
| 188 |
+
db = SessionLocal()
|
| 189 |
+
try:
|
| 190 |
+
async_jobs[job_id]["status"] = "processing"
|
| 191 |
+
async_jobs[job_id]["progress"] = "페이지 분석 중..."
|
| 192 |
+
|
| 193 |
+
logger.info(f"비동기 페이지 분석 시작: job_id={job_id}, page_id={page_id}")
|
| 194 |
+
|
| 195 |
+
# 페이지 및 프로젝트 정보 조회
|
| 196 |
+
page = db.query(Page).filter(Page.page_id == page_id).first()
|
| 197 |
+
if not page:
|
| 198 |
+
raise ValueError(f"페이지 ID {page_id}를 찾을 수 없습니다.")
|
| 199 |
+
|
| 200 |
+
project = db.query(Project).filter(Project.project_id == page.project_id).first()
|
| 201 |
+
if not project:
|
| 202 |
+
raise ValueError(f"프로젝트 ID {page.project_id}를 찾을 수 없습니다.")
|
| 203 |
+
|
| 204 |
+
# AnalysisService 및 TextFormatter 초기화
|
| 205 |
+
analysis_service = _get_analysis_service()
|
| 206 |
+
formatter = TextFormatter(
|
| 207 |
+
doc_type_id=project.doc_type_id,
|
| 208 |
+
db=db,
|
| 209 |
+
use_db_rules=True,
|
| 210 |
+
)
|
| 211 |
+
|
| 212 |
+
# 기존 batch_analysis의 _process_single_page_async 재사용
|
| 213 |
+
async_jobs[job_id]["progress"] = "레이아웃 분석 및 OCR 수행 중..."
|
| 214 |
+
page_result = await _process_single_page_async(
|
| 215 |
+
db=db,
|
| 216 |
+
project=project,
|
| 217 |
+
page=page,
|
| 218 |
+
formatter=formatter,
|
| 219 |
+
analysis_service=analysis_service,
|
| 220 |
+
use_ai_descriptions=use_ai_descriptions,
|
| 221 |
+
api_key=api_key,
|
| 222 |
+
)
|
| 223 |
+
|
| 224 |
+
# 결과 저장
|
| 225 |
+
if page_result["status"] == "completed":
|
| 226 |
+
async_jobs[job_id]["status"] = "completed"
|
| 227 |
+
async_jobs[job_id]["progress"] = "분석 완료"
|
| 228 |
+
async_jobs[job_id]["result"] = {
|
| 229 |
+
"page_id": page_id,
|
| 230 |
+
"page_number": page_result["page_number"],
|
| 231 |
+
"layout_count": page_result["layout_count"],
|
| 232 |
+
"ocr_count": page_result["ocr_count"],
|
| 233 |
+
"ai_description_count": page_result.get("ai_description_count", 0),
|
| 234 |
+
"processing_time": page_result["processing_time"],
|
| 235 |
+
"message": "페이지 분석이 성공적으로 완료되었습니다.",
|
| 236 |
+
}
|
| 237 |
+
logger.info(
|
| 238 |
+
f"비동기 페이지 분석 완료: job_id={job_id}, page_id={page_id}, "
|
| 239 |
+
f"time={page_result['processing_time']:.2f}s"
|
| 240 |
+
)
|
| 241 |
+
else:
|
| 242 |
+
raise Exception(page_result.get("message", "알 수 없는 오류"))
|
| 243 |
+
|
| 244 |
+
except Exception as error:
|
| 245 |
+
logger.error(
|
| 246 |
+
f"비동기 페이지 분석 실패: job_id={job_id}, page_id={page_id}, error={error}",
|
| 247 |
+
exc_info=True
|
| 248 |
+
)
|
| 249 |
+
async_jobs[job_id]["status"] = "failed"
|
| 250 |
+
async_jobs[job_id]["progress"] = "분석 실패"
|
| 251 |
+
async_jobs[job_id]["error"] = str(error)
|
| 252 |
+
|
| 253 |
+
# 페이지 상태를 error로 업데이트
|
| 254 |
+
try:
|
| 255 |
+
page = db.query(Page).filter(Page.page_id == page_id).first()
|
| 256 |
+
if page:
|
| 257 |
+
page.analysis_status = "error"
|
| 258 |
+
db.commit()
|
| 259 |
+
except Exception as db_error:
|
| 260 |
+
logger.error(f"페이지 상태 업데이트 실패: {db_error}")
|
| 261 |
+
db.rollback()
|
| 262 |
+
|
| 263 |
+
finally:
|
| 264 |
+
db.close()
|
app/routers/downloads.py
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from fastapi import APIRouter, Depends, HTTPException, status
|
| 4 |
+
from fastapi.responses import StreamingResponse
|
| 5 |
+
from loguru import logger
|
| 6 |
+
from sqlalchemy.orm import Session
|
| 7 |
+
|
| 8 |
+
from .. import schemas
|
| 9 |
+
from ..database import get_db
|
| 10 |
+
from ..models import Project
|
| 11 |
+
from ..services.download_service import (
|
| 12 |
+
generate_combined_text,
|
| 13 |
+
generate_word_document,
|
| 14 |
+
)
|
| 15 |
+
|
| 16 |
+
router = APIRouter(
|
| 17 |
+
prefix="/api/projects",
|
| 18 |
+
tags=["Downloads"],
|
| 19 |
+
)
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
@router.get(
|
| 23 |
+
"/{project_id}/combined-text",
|
| 24 |
+
response_model=schemas.CombinedTextResponse,
|
| 25 |
+
status_code=status.HTTP_200_OK,
|
| 26 |
+
summary="프로젝트 통합 텍스트 조회",
|
| 27 |
+
)
|
| 28 |
+
def get_combined_text(
|
| 29 |
+
project_id: int,
|
| 30 |
+
db: Session = Depends(get_db),
|
| 31 |
+
) -> schemas.CombinedTextResponse:
|
| 32 |
+
"""
|
| 33 |
+
프로젝트의 최신 텍스트 버전을 통합하여 반환합니다.
|
| 34 |
+
CombinedResult 캐시가 최신이면 캐시를 사용합니다.
|
| 35 |
+
"""
|
| 36 |
+
project_exists = (
|
| 37 |
+
db.query(Project.project_id)
|
| 38 |
+
.filter(Project.project_id == project_id)
|
| 39 |
+
.scalar()
|
| 40 |
+
)
|
| 41 |
+
if not project_exists:
|
| 42 |
+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="프로젝트를 찾을 수 없습니다.")
|
| 43 |
+
|
| 44 |
+
try:
|
| 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=%s / error=%s", project_id, error, exc_info=True)
|
| 51 |
+
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="통합 텍스트 생성 중 오류가 발생했습니다.") from error
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
@router.post(
|
| 55 |
+
"/{project_id}/download",
|
| 56 |
+
response_class=StreamingResponse,
|
| 57 |
+
status_code=status.HTTP_200_OK,
|
| 58 |
+
summary="프로젝트 Word 문서 다운로드",
|
| 59 |
+
)
|
| 60 |
+
def download_document(
|
| 61 |
+
project_id: int,
|
| 62 |
+
db: Session = Depends(get_db),
|
| 63 |
+
) -> StreamingResponse:
|
| 64 |
+
"""
|
| 65 |
+
프로젝트의 통합 텍스트를 Word(.docx) 문서로 생성하여 스트리밍 응답으로 반환합니다.
|
| 66 |
+
"""
|
| 67 |
+
project_exists = (
|
| 68 |
+
db.query(Project.project_id)
|
| 69 |
+
.filter(Project.project_id == project_id)
|
| 70 |
+
.scalar()
|
| 71 |
+
)
|
| 72 |
+
if not project_exists:
|
| 73 |
+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="프로젝트를 찾을 수 없습니다.")
|
| 74 |
+
|
| 75 |
+
try:
|
| 76 |
+
filename, file_stream = generate_word_document(db, project_id, use_cache=True)
|
| 77 |
+
headers = {"Content-Disposition": f'attachment; filename="{filename}"'}
|
| 78 |
+
return StreamingResponse(
|
| 79 |
+
file_stream,
|
| 80 |
+
media_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
| 81 |
+
headers=headers,
|
| 82 |
+
)
|
| 83 |
+
except ImportError as import_error:
|
| 84 |
+
raise HTTPException(
|
| 85 |
+
status_code=status.HTTP_501_NOT_IMPLEMENTED,
|
| 86 |
+
detail=str(import_error),
|
| 87 |
+
) from import_error
|
| 88 |
+
except ValueError as value_error:
|
| 89 |
+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(value_error)) from value_error
|
| 90 |
+
except Exception as error: # pylint: disable=broad-except
|
| 91 |
+
logger.error("Word 문서 생성 실패: project_id=%s / error=%s", project_id, error, exc_info=True)
|
| 92 |
+
raise HTTPException(
|
| 93 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 94 |
+
detail="Word 문서 생성 중 오류가 발생했습니다.",
|
| 95 |
+
) from error
|
app/routers/pages.py
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import asyncio
|
| 4 |
+
import os
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
from typing import List, Optional, Union
|
| 7 |
+
from uuid import uuid4
|
| 8 |
+
|
| 9 |
+
import cv2
|
| 10 |
+
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status
|
| 11 |
+
from loguru import logger
|
| 12 |
+
from sqlalchemy.orm import Session
|
| 13 |
+
|
| 14 |
+
from .. import crud, schemas
|
| 15 |
+
from ..database import get_db
|
| 16 |
+
from ..models import AnalysisStatusEnum, Page
|
| 17 |
+
from ..services.pdf_processor import pdf_processor
|
| 18 |
+
from ..services.text_version_service import (
|
| 19 |
+
get_current_page_text,
|
| 20 |
+
save_user_edited_version,
|
| 21 |
+
)
|
| 22 |
+
|
| 23 |
+
router = APIRouter(
|
| 24 |
+
prefix="/api/pages",
|
| 25 |
+
tags=["Pages"],
|
| 26 |
+
)
|
| 27 |
+
|
| 28 |
+
UPLOAD_DIR = Path(os.getenv("UPLOAD_DIR", "uploads")).resolve()
|
| 29 |
+
UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def _page_to_response(page: Page) -> schemas.PageResponse:
|
| 33 |
+
return schemas.PageResponse.model_validate(page)
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
@router.post(
|
| 37 |
+
"/upload",
|
| 38 |
+
response_model=Union[schemas.PageResponse, schemas.MultiPageCreateResponse],
|
| 39 |
+
status_code=status.HTTP_201_CREATED,
|
| 40 |
+
)
|
| 41 |
+
async def upload_page(
|
| 42 |
+
project_id: int = Form(..., description="프로젝트 ID"),
|
| 43 |
+
page_number: Optional[int] = Form(None, ge=1, description="페이지 번호 (이미지 업로드 시 필수, PDF는 자동)"),
|
| 44 |
+
file: UploadFile = File(..., description="페이지 이미지 또는 PDF 파일"),
|
| 45 |
+
db: Session = Depends(get_db),
|
| 46 |
+
) -> Union[schemas.PageResponse, schemas.MultiPageCreateResponse]:
|
| 47 |
+
"""
|
| 48 |
+
페이지 업로드 (이미지 또는 PDF)
|
| 49 |
+
|
| 50 |
+
- 이미지 업로드: page_number 필수, 단일 페이지 생성
|
| 51 |
+
- PDF 업로드: page_number 선택적, 다중 페이지 자동 생성
|
| 52 |
+
"""
|
| 53 |
+
|
| 54 |
+
# PDF 업로드 분기 처리
|
| 55 |
+
if file.content_type == "application/pdf":
|
| 56 |
+
logger.info(f"PDF 업로드 시작 - ProjectID: {project_id}")
|
| 57 |
+
|
| 58 |
+
# PDF 바이트 읽기
|
| 59 |
+
pdf_bytes = await file.read()
|
| 60 |
+
|
| 61 |
+
# 시작 페이지 번호 계산
|
| 62 |
+
existing_pages = crud.get_pages_by_project(db, project_id)
|
| 63 |
+
start_page_number = len(existing_pages) + 1
|
| 64 |
+
logger.info(f"기존 페이지 수: {len(existing_pages)}, 시작 페이지: {start_page_number}")
|
| 65 |
+
|
| 66 |
+
try:
|
| 67 |
+
# PDF → 이미지 변환
|
| 68 |
+
converted_pages = pdf_processor.convert_pdf_to_images(
|
| 69 |
+
pdf_bytes=pdf_bytes,
|
| 70 |
+
project_id=project_id,
|
| 71 |
+
start_page_number=start_page_number
|
| 72 |
+
)
|
| 73 |
+
logger.info(f"PDF 변환 완료 - {len(converted_pages)}개 페이지")
|
| 74 |
+
|
| 75 |
+
# 비동기 병렬 페이지 생성 (Semaphore로 동시성 제한)
|
| 76 |
+
semaphore = asyncio.Semaphore(5)
|
| 77 |
+
created_pages = []
|
| 78 |
+
|
| 79 |
+
async def save_page(page_info: dict):
|
| 80 |
+
async with semaphore:
|
| 81 |
+
page_create = schemas.PageCreate(
|
| 82 |
+
project_id=project_id,
|
| 83 |
+
page_number=page_info['page_number'],
|
| 84 |
+
image_path=page_info['image_path'],
|
| 85 |
+
image_width=page_info['width'],
|
| 86 |
+
image_height=page_info['height']
|
| 87 |
+
)
|
| 88 |
+
page = crud.create_page(db, page_create)
|
| 89 |
+
created_pages.append(page)
|
| 90 |
+
logger.debug(f"페이지 {page_info['page_number']} 저장 완료")
|
| 91 |
+
|
| 92 |
+
# 병렬 저장 실행
|
| 93 |
+
await asyncio.gather(*(save_page(info) for info in converted_pages))
|
| 94 |
+
|
| 95 |
+
logger.info(f"PDF 업로드 완료 - ProjectID: {project_id}, {len(created_pages)}개 페이지 생성")
|
| 96 |
+
|
| 97 |
+
return schemas.MultiPageCreateResponse(
|
| 98 |
+
project_id=project_id,
|
| 99 |
+
total_created=len(created_pages),
|
| 100 |
+
source_type="pdf",
|
| 101 |
+
pages=[_page_to_response(p) for p in created_pages],
|
| 102 |
+
)
|
| 103 |
+
|
| 104 |
+
except ValueError as ve:
|
| 105 |
+
logger.error(f"PDF 처리 실패: {str(ve)}")
|
| 106 |
+
raise HTTPException(
|
| 107 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 108 |
+
detail=f"PDF 처리 실패: {str(ve)}"
|
| 109 |
+
) from ve
|
| 110 |
+
except Exception as exc:
|
| 111 |
+
logger.error(f"PDF 업로드 중 오류: {str(exc)}")
|
| 112 |
+
raise HTTPException(
|
| 113 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 114 |
+
detail=f"PDF 업로드 실패: {str(exc)}"
|
| 115 |
+
) from exc
|
| 116 |
+
|
| 117 |
+
# 기존 단일 이미지 업로드 로직
|
| 118 |
+
else:
|
| 119 |
+
if page_number is None:
|
| 120 |
+
raise HTTPException(
|
| 121 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 122 |
+
detail="이미지 업로드 시 page_number는 필수입니다."
|
| 123 |
+
)
|
| 124 |
+
|
| 125 |
+
logger.info(f"이미지 업로드 - ProjectID: {project_id}, PageNumber: {page_number}")
|
| 126 |
+
|
| 127 |
+
suffix = Path(file.filename).suffix or ".png"
|
| 128 |
+
filename = f"project_{project_id}_page_{page_number}_{uuid4().hex}{suffix}"
|
| 129 |
+
file_path = UPLOAD_DIR / filename
|
| 130 |
+
|
| 131 |
+
content = await file.read()
|
| 132 |
+
file_path.write_bytes(content)
|
| 133 |
+
|
| 134 |
+
try:
|
| 135 |
+
image = cv2.imread(str(file_path))
|
| 136 |
+
if image is None:
|
| 137 |
+
raise ValueError("이미지를 읽을 수 없습니다.")
|
| 138 |
+
height, width = image.shape[:2]
|
| 139 |
+
except Exception as exc: # pylint: disable=broad-except
|
| 140 |
+
file_path.unlink(missing_ok=True)
|
| 141 |
+
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"이미지 처리 실패: {exc}") from exc
|
| 142 |
+
|
| 143 |
+
try:
|
| 144 |
+
relative_path = file_path.relative_to(Path.cwd())
|
| 145 |
+
stored_path = str(relative_path)
|
| 146 |
+
except ValueError:
|
| 147 |
+
stored_path = str(file_path)
|
| 148 |
+
|
| 149 |
+
page_create = schemas.PageCreate(
|
| 150 |
+
project_id=project_id,
|
| 151 |
+
page_number=page_number,
|
| 152 |
+
image_path=stored_path,
|
| 153 |
+
image_width=width,
|
| 154 |
+
image_height=height,
|
| 155 |
+
)
|
| 156 |
+
page = crud.create_page(db, page_create)
|
| 157 |
+
|
| 158 |
+
logger.info(f"이미지 업로드 완료 - PageID: {page.page_id}")
|
| 159 |
+
return _page_to_response(page)
|
| 160 |
+
|
| 161 |
+
|
| 162 |
+
@router.get(
|
| 163 |
+
"/{page_id}",
|
| 164 |
+
response_model=schemas.PageResponse,
|
| 165 |
+
)
|
| 166 |
+
def get_page_detail(
|
| 167 |
+
page_id: int,
|
| 168 |
+
db: Session = Depends(get_db),
|
| 169 |
+
) -> schemas.PageResponse:
|
| 170 |
+
page = crud.get_page(db, page_id)
|
| 171 |
+
if not page:
|
| 172 |
+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="페이지를 찾을 수 없습니다.")
|
| 173 |
+
return _page_to_response(page)
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
@router.get(
|
| 177 |
+
"/project/{project_id}",
|
| 178 |
+
response_model=List[schemas.PageResponse],
|
| 179 |
+
)
|
| 180 |
+
def list_project_pages(
|
| 181 |
+
project_id: int,
|
| 182 |
+
db: Session = Depends(get_db),
|
| 183 |
+
include_error: bool = False,
|
| 184 |
+
) -> List[schemas.PageResponse]:
|
| 185 |
+
pages = crud.get_pages_by_project(db, project_id)
|
| 186 |
+
if not include_error:
|
| 187 |
+
pages = [page for page in pages if page.analysis_status != AnalysisStatusEnum.ERROR]
|
| 188 |
+
return [_page_to_response(page) for page in pages]
|
| 189 |
+
|
| 190 |
+
|
| 191 |
+
@router.get(
|
| 192 |
+
"/{page_id}/text",
|
| 193 |
+
response_model=schemas.PageTextResponse,
|
| 194 |
+
summary="현재 페이지 텍스트 조회",
|
| 195 |
+
)
|
| 196 |
+
def get_page_text(
|
| 197 |
+
page_id: int,
|
| 198 |
+
db: Session = Depends(get_db),
|
| 199 |
+
) -> schemas.PageTextResponse:
|
| 200 |
+
"""
|
| 201 |
+
is_current=True인 최신 텍스트 버전을 반환합니다.
|
| 202 |
+
"""
|
| 203 |
+
version_data = get_current_page_text(db, page_id)
|
| 204 |
+
if not version_data:
|
| 205 |
+
raise HTTPException(
|
| 206 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 207 |
+
detail="해당 페이지의 텍스트 버전을 찾을 수 없습니다.",
|
| 208 |
+
)
|
| 209 |
+
return schemas.PageTextResponse.model_validate(version_data)
|
| 210 |
+
|
| 211 |
+
|
| 212 |
+
@router.post(
|
| 213 |
+
"/{page_id}/text",
|
| 214 |
+
response_model=schemas.PageTextResponse,
|
| 215 |
+
summary="사용자 수정 텍스트 저장",
|
| 216 |
+
)
|
| 217 |
+
def save_page_text(
|
| 218 |
+
page_id: int,
|
| 219 |
+
payload: schemas.PageTextUpdate,
|
| 220 |
+
db: Session = Depends(get_db),
|
| 221 |
+
) -> schemas.PageTextResponse:
|
| 222 |
+
"""
|
| 223 |
+
사용자 편집 내용을 새 텍스트 버전으로 저장합니다.
|
| 224 |
+
"""
|
| 225 |
+
try:
|
| 226 |
+
version_data = save_user_edited_version(
|
| 227 |
+
db,
|
| 228 |
+
page_id,
|
| 229 |
+
payload.content,
|
| 230 |
+
user_id=payload.user_id,
|
| 231 |
+
)
|
| 232 |
+
return schemas.PageTextResponse.model_validate(version_data)
|
| 233 |
+
except ValueError as value_error:
|
| 234 |
+
raise HTTPException(
|
| 235 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 236 |
+
detail=str(value_error),
|
| 237 |
+
) from value_error
|
| 238 |
+
except Exception as error: # pylint: disable=broad-except
|
| 239 |
+
logger.error("페이지 텍스트 저장 실패: page_id=%s / error=%s", page_id, error, exc_info=True)
|
| 240 |
+
raise HTTPException(
|
| 241 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 242 |
+
detail="페이지 텍스트 저장 중 오류가 발생했습니다.",
|
| 243 |
+
) from error
|
app/routers/projects.py
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from typing import List, Optional
|
| 4 |
+
|
| 5 |
+
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
|
| 6 |
+
from sqlalchemy.orm import Session
|
| 7 |
+
|
| 8 |
+
from .. import crud, schemas
|
| 9 |
+
from ..database import get_db
|
| 10 |
+
from ..models import Page, Project
|
| 11 |
+
|
| 12 |
+
router = APIRouter(
|
| 13 |
+
prefix="/api/projects",
|
| 14 |
+
tags=["Projects"],
|
| 15 |
+
)
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
class ProjectCreateRequest(schemas.ProjectCreate):
|
| 19 |
+
"""프로젝트 생성 요청 스키마 (user_id 포함)"""
|
| 20 |
+
|
| 21 |
+
user_id: int
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def _project_to_response(project: Project) -> schemas.ProjectResponse:
|
| 25 |
+
return schemas.ProjectResponse.model_validate(project)
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def _page_to_response(page: Page) -> schemas.PageResponse:
|
| 29 |
+
return schemas.PageResponse.model_validate(page)
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
@router.post(
|
| 33 |
+
"",
|
| 34 |
+
response_model=schemas.ProjectResponse,
|
| 35 |
+
status_code=status.HTTP_201_CREATED,
|
| 36 |
+
)
|
| 37 |
+
def create_project_endpoint(
|
| 38 |
+
payload: ProjectCreateRequest,
|
| 39 |
+
db: Session = Depends(get_db),
|
| 40 |
+
) -> schemas.ProjectResponse:
|
| 41 |
+
project = crud.create_project(
|
| 42 |
+
db=db,
|
| 43 |
+
project=schemas.ProjectCreate(
|
| 44 |
+
project_name=payload.project_name,
|
| 45 |
+
doc_type_id=payload.doc_type_id,
|
| 46 |
+
analysis_mode=payload.analysis_mode,
|
| 47 |
+
),
|
| 48 |
+
user_id=payload.user_id,
|
| 49 |
+
)
|
| 50 |
+
return _project_to_response(project)
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
@router.get("", response_model=List[schemas.ProjectResponse])
|
| 54 |
+
def list_projects(
|
| 55 |
+
db: Session = Depends(get_db),
|
| 56 |
+
user_id: Optional[int] = Query(default=None, description="특정 사용자 ID로 필터링"),
|
| 57 |
+
skip: int = Query(default=0, ge=0),
|
| 58 |
+
limit: int = Query(default=100, ge=1, le=1000),
|
| 59 |
+
) -> List[schemas.ProjectResponse]:
|
| 60 |
+
query = db.query(Project).order_by(Project.created_at.desc())
|
| 61 |
+
if user_id is not None:
|
| 62 |
+
query = query.filter(Project.user_id == user_id)
|
| 63 |
+
projects = query.offset(skip).limit(limit).all()
|
| 64 |
+
return [_project_to_response(project) for project in projects]
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
@router.get(
|
| 68 |
+
"/{project_id}",
|
| 69 |
+
response_model=schemas.ProjectWithPagesResponse,
|
| 70 |
+
)
|
| 71 |
+
def get_project_detail(
|
| 72 |
+
project_id: int,
|
| 73 |
+
db: Session = Depends(get_db),
|
| 74 |
+
) -> schemas.ProjectWithPagesResponse:
|
| 75 |
+
project = crud.get_project_with_pages(db, project_id)
|
| 76 |
+
if not project:
|
| 77 |
+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="프로젝트를 찾을 수 없습니다.")
|
| 78 |
+
project_response = _project_to_response(project)
|
| 79 |
+
page_responses = [_page_to_response(page) for page in project.pages]
|
| 80 |
+
return schemas.ProjectWithPagesResponse(
|
| 81 |
+
**project_response.model_dump(),
|
| 82 |
+
pages=page_responses,
|
| 83 |
+
)
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
@router.patch(
|
| 87 |
+
"/{project_id}",
|
| 88 |
+
response_model=schemas.ProjectResponse,
|
| 89 |
+
)
|
| 90 |
+
def update_project_endpoint(
|
| 91 |
+
project_id: int,
|
| 92 |
+
payload: schemas.ProjectUpdate,
|
| 93 |
+
db: Session = Depends(get_db),
|
| 94 |
+
) -> schemas.ProjectResponse:
|
| 95 |
+
project = crud.update_project(db, project_id, payload)
|
| 96 |
+
if not project:
|
| 97 |
+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="프로젝트를 찾을 수 없습니다.")
|
| 98 |
+
return _project_to_response(project)
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
@router.delete(
|
| 102 |
+
"/{project_id}",
|
| 103 |
+
status_code=status.HTTP_204_NO_CONTENT,
|
| 104 |
+
response_class=Response,
|
| 105 |
+
)
|
| 106 |
+
def delete_project_endpoint(
|
| 107 |
+
project_id: int,
|
| 108 |
+
db: Session = Depends(get_db),
|
| 109 |
+
) -> Response:
|
| 110 |
+
success = crud.delete_project(db, project_id)
|
| 111 |
+
if not success:
|
| 112 |
+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="프로젝트를 찾을 수 없습니다.")
|
| 113 |
+
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
app/schemas.py
CHANGED
|
@@ -209,6 +209,37 @@ class PageWithElementsResponse(PageResponse):
|
|
| 209 |
layout_elements: List["LayoutElementResponse"] = []
|
| 210 |
|
| 211 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 212 |
# ============================================================================
|
| 213 |
# 5. LayoutElement Schemas
|
| 214 |
# ============================================================================
|
|
@@ -475,6 +506,34 @@ class CombinedResultResponse(CombinedResultBase):
|
|
| 475 |
model_config = ConfigDict(from_attributes=True)
|
| 476 |
|
| 477 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 478 |
# ============================================================================
|
| 479 |
# 복합 응답 스키마 (관계 포함)
|
| 480 |
# ============================================================================
|
|
|
|
| 209 |
layout_elements: List["LayoutElementResponse"] = []
|
| 210 |
|
| 211 |
|
| 212 |
+
# ============================================================================
|
| 213 |
+
# 페이지 추가 응답/요청 스키마
|
| 214 |
+
# ============================================================================
|
| 215 |
+
class MultiPageCreateResponse(BaseModel):
|
| 216 |
+
"""다중 페이지 생성 응답 (PDF 업로드 시)"""
|
| 217 |
+
project_id: int = Field(..., description="프로젝트 ID")
|
| 218 |
+
total_created: int = Field(..., ge=0, description="생성된 페이지 수")
|
| 219 |
+
source_type: str = Field(..., description="소스 타입 (pdf 또는 image)")
|
| 220 |
+
pages: List[PageResponse] = Field(default=[], description="생성된 페이지 목록")
|
| 221 |
+
|
| 222 |
+
model_config = ConfigDict(from_attributes=True)
|
| 223 |
+
|
| 224 |
+
|
| 225 |
+
class PageTextResponse(BaseModel):
|
| 226 |
+
"""페이지 텍스트 조회 응답"""
|
| 227 |
+
page_id: int
|
| 228 |
+
version_id: int
|
| 229 |
+
version_type: str
|
| 230 |
+
is_current: bool
|
| 231 |
+
content: str
|
| 232 |
+
created_at: datetime
|
| 233 |
+
|
| 234 |
+
model_config = ConfigDict(from_attributes=True)
|
| 235 |
+
|
| 236 |
+
|
| 237 |
+
class PageTextUpdate(BaseModel):
|
| 238 |
+
"""페이지 텍스트 업데이트 요청"""
|
| 239 |
+
content: str = Field(..., description="저장할 전체 텍스트 내용")
|
| 240 |
+
user_id: Optional[int] = Field(None, description="수정한 사용자 ID")
|
| 241 |
+
|
| 242 |
+
|
| 243 |
# ============================================================================
|
| 244 |
# 5. LayoutElement Schemas
|
| 245 |
# ============================================================================
|
|
|
|
| 506 |
model_config = ConfigDict(from_attributes=True)
|
| 507 |
|
| 508 |
|
| 509 |
+
class CombinedTextStats(BaseModel):
|
| 510 |
+
"""통합 텍스트 통계"""
|
| 511 |
+
total_pages: int
|
| 512 |
+
total_words: int
|
| 513 |
+
total_characters: int
|
| 514 |
+
|
| 515 |
+
|
| 516 |
+
class CombinedTextResponse(BaseModel):
|
| 517 |
+
"""통합 텍스트 응답"""
|
| 518 |
+
project_id: int
|
| 519 |
+
project_name: Optional[str] = None
|
| 520 |
+
combined_text: str
|
| 521 |
+
stats: CombinedTextStats
|
| 522 |
+
generated_at: datetime
|
| 523 |
+
|
| 524 |
+
|
| 525 |
+
class DownloadResponse(BaseModel):
|
| 526 |
+
"""문서 다운로드 메타데이터 응답"""
|
| 527 |
+
message: str = Field(
|
| 528 |
+
"Word 문서가 성공적으로 생성되었습니다.", description="응답 메시지"
|
| 529 |
+
)
|
| 530 |
+
project_id: int
|
| 531 |
+
filename: str
|
| 532 |
+
download_url: Optional[str] = Field(
|
| 533 |
+
None, description="다운로드 가능한 URL (선택 사항)"
|
| 534 |
+
)
|
| 535 |
+
|
| 536 |
+
|
| 537 |
# ============================================================================
|
| 538 |
# 복합 응답 스키마 (관계 포함)
|
| 539 |
# ============================================================================
|
app/services/__init__.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
SmartEyeSsen Backend - Services Module
|
| 3 |
+
=======================================
|
| 4 |
+
비즈니스 로직 서비스 모듈
|
| 5 |
+
|
| 6 |
+
주요 서비스:
|
| 7 |
+
- formatter_rules: 포맷팅 규칙 정의 (코드 기반)
|
| 8 |
+
- formatter: 텍스트 포맷팅 처리
|
| 9 |
+
- sorter: 레이아웃 정렬 알고리즘
|
| 10 |
+
- analysis_service: 페이지 분석 파이프라인
|
| 11 |
+
- batch_analysis: 다중 페이지 일괄 분석
|
| 12 |
+
- download_service: 문서 생성 및 다운로드
|
| 13 |
+
"""
|
| 14 |
+
|
| 15 |
+
from .formatter_rules import (
|
| 16 |
+
RuleConfig,
|
| 17 |
+
QUESTION_BASED_RULES,
|
| 18 |
+
READING_ORDER_RULES,
|
| 19 |
+
get_rules_for_document_type,
|
| 20 |
+
fetch_db_rules,
|
| 21 |
+
override_rules_with_db,
|
| 22 |
+
get_rule_for_class
|
| 23 |
+
)
|
| 24 |
+
|
| 25 |
+
from .formatter import (
|
| 26 |
+
TextFormatter
|
| 27 |
+
)
|
| 28 |
+
|
| 29 |
+
from .sorter import (
|
| 30 |
+
sort_layout_elements,
|
| 31 |
+
save_sorting_results_to_db
|
| 32 |
+
)
|
| 33 |
+
|
| 34 |
+
from .analysis_service import analyze_page
|
| 35 |
+
from .batch_analysis import analyze_project_batch, analyze_project_batch_async
|
| 36 |
+
from .text_version_service import (
|
| 37 |
+
create_text_version,
|
| 38 |
+
get_current_page_text,
|
| 39 |
+
save_user_edited_version,
|
| 40 |
+
)
|
| 41 |
+
from .download_service import generate_document
|
| 42 |
+
|
| 43 |
+
__all__ = [
|
| 44 |
+
# Formatter rules
|
| 45 |
+
"RuleConfig",
|
| 46 |
+
"QUESTION_BASED_RULES",
|
| 47 |
+
"READING_ORDER_RULES",
|
| 48 |
+
"get_rules_for_document_type",
|
| 49 |
+
"fetch_db_rules",
|
| 50 |
+
"override_rules_with_db",
|
| 51 |
+
"get_rule_for_class",
|
| 52 |
+
|
| 53 |
+
# Formatter
|
| 54 |
+
"TextFormatter",
|
| 55 |
+
|
| 56 |
+
# Sorter
|
| 57 |
+
"sort_layout_elements",
|
| 58 |
+
"save_sorting_results_to_db",
|
| 59 |
+
|
| 60 |
+
# Analysis
|
| 61 |
+
"analyze_page",
|
| 62 |
+
"analyze_project_batch",
|
| 63 |
+
"analyze_project_batch_async",
|
| 64 |
+
|
| 65 |
+
# Text Versions
|
| 66 |
+
"create_text_version",
|
| 67 |
+
"get_current_page_text",
|
| 68 |
+
"save_user_edited_version",
|
| 69 |
+
|
| 70 |
+
# Download
|
| 71 |
+
"generate_document",
|
| 72 |
+
]
|
app/services/analysis_service.py
ADDED
|
@@ -0,0 +1,1057 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# -*- coding: utf-8 -*-
|
| 2 |
+
"""
|
| 3 |
+
SmartEyeSsen Analysis Service (v1.1 - Duplicate Detection Filter Added)
|
| 4 |
+
========================================================================
|
| 5 |
+
|
| 6 |
+
학습지 분석 서비스 - 레이아웃 분석, OCR, AI 설명 생성을 담당합니다.
|
| 7 |
+
Refactored from api_server.py WorksheetAnalyzer class.
|
| 8 |
+
|
| 9 |
+
주요 변경사항 (DB 통합 버전):
|
| 10 |
+
- analyze_layout: DocLayout-YOLO 결과를 layout_elements 테이블에 저장 후 ORM 객체 반환
|
| 11 |
+
- perform_ocr: text_contents 테이블에 OCR 결과 upsert
|
| 12 |
+
- call_openai_api / call_openai_api_async: ai_descriptions 테이블에 설명 텍스트 upsert
|
| 13 |
+
- 중복 탐지 필터링(IoU 기반) 로직 유지
|
| 14 |
+
"""
|
| 15 |
+
|
| 16 |
+
import asyncio
|
| 17 |
+
import base64
|
| 18 |
+
import colorsys
|
| 19 |
+
import io
|
| 20 |
+
import platform
|
| 21 |
+
import random
|
| 22 |
+
from typing import Dict, List, Optional
|
| 23 |
+
|
| 24 |
+
import cv2
|
| 25 |
+
import numpy as np
|
| 26 |
+
import openai
|
| 27 |
+
import pytesseract
|
| 28 |
+
import torch
|
| 29 |
+
from PIL import Image
|
| 30 |
+
from huggingface_hub import hf_hub_download
|
| 31 |
+
from loguru import logger
|
| 32 |
+
from openai import AsyncOpenAI
|
| 33 |
+
from sqlalchemy.orm import Session
|
| 34 |
+
|
| 35 |
+
from .. import models
|
| 36 |
+
|
| 37 |
+
# --- 신규: 이미지 설명을 위한 프롬프트 템플릿 추가 ---
|
| 38 |
+
figure_prompt = """
|
| 39 |
+
당신은 초등학생을 위한 학습 도우미입니다.
|
| 40 |
+
다음 그림을 초등학생이 쉽게 이해할 수 있도록 설명해주세요.
|
| 41 |
+
|
| 42 |
+
[설명 규칙]
|
| 43 |
+
1. 쉬운 말 사용: 어려운 용어 대신 일상 언어로 설명
|
| 44 |
+
2. 중요한 것부터: 가장 눈에 띄는 것부터 차례대로 설명
|
| 45 |
+
3. 위치 표현: "왼쪽에", "오른쪽에", "가운데" 같은 말로 위치 알려주기
|
| 46 |
+
4. 구체적으로: 크기, 모양, 색깔을 쉽게 표현
|
| 47 |
+
|
| 48 |
+
[출력 형식]
|
| 49 |
+
이것은 무엇인가요: [한 문장으로 쉽게 설명]
|
| 50 |
+
|
| 51 |
+
어떻게 생겼나요:
|
| 52 |
+
- 전체 모습: [그림의 전체 모양]
|
| 53 |
+
- 중요한 부분: [가장 중요한 것들]
|
| 54 |
+
- 세부 내용: [자세한 설명]
|
| 55 |
+
|
| 56 |
+
이 그림이 말하고 싶은 것: [핵심 내용을 한 문장으로]
|
| 57 |
+
|
| 58 |
+
[예시]
|
| 59 |
+
이것은 무엇인가요: 우리나라 인구가 어떻게 늘어났는지 보여주는 선 그래프예요.
|
| 60 |
+
|
| 61 |
+
어떻게 생겼나요:
|
| 62 |
+
- 전체 모습: 아래쪽에는 연도가, 왼쪽에는 인구수가 적혀 있어요.
|
| 63 |
+
- 중요한 부분: 2000년부터 2025년까지 오른쪽 위로 올라가는 선이 그려져 있어요.
|
| 64 |
+
- 세부 내용: 처음에는 빠르게 올라가다가 나중에는 천천히 올라가요.
|
| 65 |
+
|
| 66 |
+
이 그림이 말하고 싶은 것: 우리나라 인구는 계속 늘어났지만, 요즘은 천천히 늘어나고 있어요.
|
| 67 |
+
"""
|
| 68 |
+
|
| 69 |
+
table_prompt = """
|
| 70 |
+
당신은 초등학생을 위한 학습 도우미입니다.
|
| 71 |
+
다음 표를 초등학생이 쉽게 이해할 수 있도록 설명해주세요.
|
| 72 |
+
|
| 73 |
+
[설명 규칙]
|
| 74 |
+
1. 쉬운 말 사용: 어려운 용어 대신 일상 언어로 설명
|
| 75 |
+
2. 표의 모양: 몇 줄, 몇 칸인지 먼저 알려주기
|
| 76 |
+
3. 제목 설명: 각 칸의 제목이 무엇인지 차례대로 말하기
|
| 77 |
+
4. 내용 읽기: 왼쪽에서 오른쪽으로, 위에서 아래로 읽기
|
| 78 |
+
|
| 79 |
+
[출력 형식]
|
| 80 |
+
이것은 무엇인가요: [표의 내용을 한 문장으로]
|
| 81 |
+
|
| 82 |
+
표의 모양:
|
| 83 |
+
- 크기: [몇 줄, 몇 칸]
|
| 84 |
+
- 제목: [각 칸의 제목]
|
| 85 |
+
|
| 86 |
+
표에 적힌 내용:
|
| 87 |
+
첫 번째 줄: [내용]
|
| 88 |
+
두 번째 줄: [내용]
|
| 89 |
+
세 번째 줄: [내용]
|
| 90 |
+
|
| 91 |
+
중요한 내용: [표에서 가장 중요한 것]
|
| 92 |
+
|
| 93 |
+
[예시]
|
| 94 |
+
이것은 무엇인가요: 2024년에 회사가 번 돈을 분기별로 정리한 표예요.
|
| 95 |
+
|
| 96 |
+
표의 모양:
|
| 97 |
+
- 크기: 5줄, 4칸
|
| 98 |
+
- 제목: 구분, 1분기, 2분기, 3분기
|
| 99 |
+
|
| 100 |
+
표에 적힌 내용:
|
| 101 |
+
첫 번째 줄 (매출액): 100억원, 120억원, 150억원
|
| 102 |
+
두 번째 줄 (성장률): 10퍼센트, 20퍼센트, 25퍼센트
|
| 103 |
+
세 번째 줄 (영업이익): 10억원, 15억원, 20억원
|
| 104 |
+
네 번째 줄 (순이익): 8억원, 12억원, 18억원
|
| 105 |
+
|
| 106 |
+
중요한 내용: 회사가 번 돈이 계속 늘어나고 있고, 3분기에 가장 많이 늘었어요.
|
| 107 |
+
"""
|
| 108 |
+
|
| 109 |
+
flowchart_prompt = """
|
| 110 |
+
당신은 초등학생을 위한 학습 도우미입니다.
|
| 111 |
+
다음 순서도를 초등학생이 쉽게 이해할 수 있도록 설명해주세요.
|
| 112 |
+
|
| 113 |
+
[설명 규칙]
|
| 114 |
+
1. 쉬운 말 사용: 어려운 용어 대신 일상 언어로 설명
|
| 115 |
+
2. 단계별로 천천히: "첫 번째로", "그 다음에", "마지막으로" 같은 표현 사용
|
| 116 |
+
3. 선택 상황: "만약 ~라면 어떻게 할까?" 형식으로 질문하듯 설명
|
| 117 |
+
4. 구체적 예시: 가능하면 실생활 예시 추가
|
| 118 |
+
|
| 119 |
+
[출력 형식]
|
| 120 |
+
이것은 무엇인가요: [한 문장으로 쉽게 설명]
|
| 121 |
+
|
| 122 |
+
어떻게 진행되나요:
|
| 123 |
+
1. 처음에는 [시작 단계를 쉽게 설명]
|
| 124 |
+
2. 그 다음에는 [다음 단계를 쉽게 설명]
|
| 125 |
+
3. 여기서 선택해요: [선택 상황이 있다면]
|
| 126 |
+
- [조건]이면 → [결과]
|
| 127 |
+
- [조건]이 아니면 → [다른 결과]
|
| 128 |
+
4. 마지막에는 [마무리 단계]
|
| 129 |
+
|
| 130 |
+
핵심 내용: [전체 흐름을 한 문장으로 요약]
|
| 131 |
+
|
| 132 |
+
[예시]
|
| 133 |
+
이것은 무엇인가요: 우리가 웹사이트에 로그인하는 과정을 보여주는 그림이에요.
|
| 134 |
+
|
| 135 |
+
어떻게 진행되나요:
|
| 136 |
+
1. 처음에는 로그인 화면에서 아이디와 비밀번호를 입력해요.
|
| 137 |
+
2. 그 다음에는 로그인 버튼을 눌러요.
|
| 138 |
+
3. 여기서 선택해요: 입력한 정보가 맞는지 확인해요.
|
| 139 |
+
- 정보가 맞으면 → 메인 페이지로 들어가요. 끝!
|
| 140 |
+
- 정보가 틀리면 → 다시 입력하라는 메시지가 나와요.
|
| 141 |
+
4. 만약 3번 틀리면 잠시 동안 로그인할 수 없어요.
|
| 142 |
+
|
| 143 |
+
핵심 내용: 아이디와 비밀번호가 맞아야 웹사이트에 들어갈 수 있어요.
|
| 144 |
+
"""
|
| 145 |
+
|
| 146 |
+
|
| 147 |
+
# --- 신규: IoU 계산 함수 추가 ---
|
| 148 |
+
def calculate_iou(box1, box2):
|
| 149 |
+
"""두 바운딩 박스 간의 IoU(Intersection over Union) 계산"""
|
| 150 |
+
# box 형식: [x1, y1, x2, y2]
|
| 151 |
+
x1_inter = max(box1[0], box2[0])
|
| 152 |
+
y1_inter = max(box1[1], box2[1])
|
| 153 |
+
x2_inter = min(box1[2], box2[2])
|
| 154 |
+
y2_inter = min(box1[3], box2[3])
|
| 155 |
+
|
| 156 |
+
inter_area = max(0, x2_inter - x1_inter) * max(0, y2_inter - y1_inter)
|
| 157 |
+
|
| 158 |
+
box1_area = (box1[2] - box1[0]) * (box1[3] - box1[1])
|
| 159 |
+
box2_area = (box2[2] - box2[0]) * (box2[3] - box2[1])
|
| 160 |
+
|
| 161 |
+
union_area = box1_area + box2_area - inter_area
|
| 162 |
+
|
| 163 |
+
if union_area == 0:
|
| 164 |
+
return 0.0
|
| 165 |
+
return inter_area / union_area
|
| 166 |
+
|
| 167 |
+
|
| 168 |
+
# --- 신규: 중복 제거 후처리 함수 추가 ---
|
| 169 |
+
def filter_duplicate_detections(boxes, classes, confs, class_names, iou_threshold=0.7):
|
| 170 |
+
"""
|
| 171 |
+
모든 클래스 쌍에 대해 IoU 기반으로 중복 탐지를 필터링. (자동 방식)
|
| 172 |
+
신뢰도가 낮은 쪽을 제거.
|
| 173 |
+
"""
|
| 174 |
+
num_detections = len(boxes)
|
| 175 |
+
suppressed = [False] * num_detections # 제거할 요소 표시
|
| 176 |
+
|
| 177 |
+
indices = list(range(num_detections))
|
| 178 |
+
# 신뢰도 높은 순으로 정렬 (높은 것을 남기기 위함)
|
| 179 |
+
indices.sort(key=lambda i: confs[i], reverse=True)
|
| 180 |
+
|
| 181 |
+
for i in range(num_detections):
|
| 182 |
+
idx1 = indices[i]
|
| 183 |
+
if suppressed[idx1]:
|
| 184 |
+
continue
|
| 185 |
+
|
| 186 |
+
box1 = boxes[idx1]
|
| 187 |
+
# cls_id1 = int(classes[idx1]) # 클래스 정보는 제거 로직에 불필요
|
| 188 |
+
# cls_name1 = class_names.get(cls_id1, f"unknown_{cls_id1}") # 클래스 정보는 제거 로직에 불필요
|
| 189 |
+
|
| 190 |
+
for j in range(i + 1, num_detections):
|
| 191 |
+
idx2 = indices[j]
|
| 192 |
+
if suppressed[idx2]:
|
| 193 |
+
continue
|
| 194 |
+
|
| 195 |
+
box2 = boxes[idx2]
|
| 196 |
+
# cls_id2 = int(classes[idx2]) # 클래스 정보는 제거 로직에 불필요
|
| 197 |
+
# cls_name2 = class_names.get(cls_id2, f"unknown_{cls_id2}") # 클래스 정보는 제거 로직에 불필요
|
| 198 |
+
|
| 199 |
+
# --- 👇 수정된 부분 시작 👇 ---
|
| 200 |
+
# 특정 클래스 쌍 확인 조건 제거: 모든 쌍에 대해 IoU 계산
|
| 201 |
+
# if (cls_name1, cls_name2) in problematic_pairs: # 이 조건 제거
|
| 202 |
+
iou = calculate_iou(box1, box2)
|
| 203 |
+
if iou > iou_threshold:
|
| 204 |
+
# 신뢰도 낮은 쪽(idx2)을 제거 대상으로 표시
|
| 205 |
+
suppressed[idx2] = True
|
| 206 |
+
# 로그 메시지에서 클래스 이름 제거 (선택 사항)
|
| 207 |
+
logger.debug(
|
| 208 |
+
f"중복 탐지 제거: Box {idx2}(conf={confs[idx2]:.2f}) - "
|
| 209 |
+
f"Box {idx1}(conf={confs[idx1]:.2f})와 IoU={iou:.2f} > {iou_threshold}"
|
| 210 |
+
)
|
| 211 |
+
# --- 👆 수정된 부분 끝 👆 ---
|
| 212 |
+
|
| 213 |
+
# 제거되지 않은 요소들의 인덱스 반환
|
| 214 |
+
final_indices = [i for i, s in enumerate(suppressed) if not s]
|
| 215 |
+
logger.info(
|
| 216 |
+
f"자동 중복 탐지 필터링: {num_detections}개 → {len(final_indices)}개 요소 (IoU > {iou_threshold})"
|
| 217 |
+
) # 로그 메시지 수정
|
| 218 |
+
return final_indices
|
| 219 |
+
|
| 220 |
+
|
| 221 |
+
# Windows에서 Tesseract 경로 설정 (기존과 동일)
|
| 222 |
+
if platform.system() == "Windows":
|
| 223 |
+
pytesseract.pytesseract.tesseract_cmd = (
|
| 224 |
+
r"C:\Program Files\Tesseract-OCR\tesseract.exe"
|
| 225 |
+
)
|
| 226 |
+
|
| 227 |
+
# 디바이스 설정 (기존과 동일)
|
| 228 |
+
device = "cuda:0" if torch.cuda.is_available() else "cpu"
|
| 229 |
+
|
| 230 |
+
|
| 231 |
+
class AnalysisService:
|
| 232 |
+
"""학습지 분석 서비스 - 상태 없는 함수형 디자인"""
|
| 233 |
+
|
| 234 |
+
def __init__(self, model_choice: str = "SmartEyeSsen", auto_load: bool = False):
|
| 235 |
+
"""
|
| 236 |
+
분석 서비스 초기화
|
| 237 |
+
|
| 238 |
+
Args:
|
| 239 |
+
model_choice: 사용할 모델 선택 (기본값: "SmartEyeSsen")
|
| 240 |
+
auto_load: True이면 초기화 시 자동으로 모델 로드 (기본값: False, 하위 호환성 유지)
|
| 241 |
+
"""
|
| 242 |
+
self.model = None
|
| 243 |
+
self.device = device
|
| 244 |
+
self.model_choice = model_choice
|
| 245 |
+
self._model_loaded = False
|
| 246 |
+
|
| 247 |
+
# 자동 로드 옵션이 활성화된 경우 즉시 모델 로드
|
| 248 |
+
if auto_load:
|
| 249 |
+
self._ensure_model_loaded()
|
| 250 |
+
|
| 251 |
+
def download_model(self, model_choice="SmartEyeSsen"):
|
| 252 |
+
"""모델 다운로드 (기존과 동일)"""
|
| 253 |
+
models = {
|
| 254 |
+
"doclaynet_docsynth": {
|
| 255 |
+
"repo_id": "juliozhao/DocLayout-YOLO-DocLayNet-Docsynth300K_pretrained",
|
| 256 |
+
"filename": "doclayout_yolo_doclaynet_imgsz1120_docsynth_pretrain.pt",
|
| 257 |
+
},
|
| 258 |
+
"docstructbench": {
|
| 259 |
+
"repo_id": "juliozhao/DocLayout-YOLO-DocStructBench",
|
| 260 |
+
"filename": "doclayout_yolo_docstructbench_imgsz1024.pt",
|
| 261 |
+
},
|
| 262 |
+
"docsynth300k": {
|
| 263 |
+
"repo_id": "juliozhao/DocLayout-YOLO-DocSynth300K-pretrain",
|
| 264 |
+
"filename": "doclayout_yolo_docsynth300k_imgsz1600.pt",
|
| 265 |
+
},
|
| 266 |
+
"SmartEyeSsen": {"repo_id": "AkJeond/SmartEye", "filename": "best.pt"},
|
| 267 |
+
}
|
| 268 |
+
selected_model = models.get(model_choice, models["SmartEyeSsen"])
|
| 269 |
+
try:
|
| 270 |
+
logger.info(f"모델 다운로드 중: {selected_model['repo_id']}")
|
| 271 |
+
filepath = hf_hub_download(
|
| 272 |
+
repo_id=selected_model["repo_id"], filename=selected_model["filename"]
|
| 273 |
+
)
|
| 274 |
+
logger.info(f"모델 다운로드 완료: {filepath}")
|
| 275 |
+
return filepath
|
| 276 |
+
except Exception as e:
|
| 277 |
+
logger.error(f"모델 다운로드 실패: {e}")
|
| 278 |
+
raise
|
| 279 |
+
|
| 280 |
+
def load_model(self, model_path):
|
| 281 |
+
"""모델 로드 (기존과 동일)"""
|
| 282 |
+
try:
|
| 283 |
+
try:
|
| 284 |
+
from doclayout_yolo import YOLOv10
|
| 285 |
+
except ImportError:
|
| 286 |
+
logger.error("DocLayout-YOLO가 설치되지 않았습니다.")
|
| 287 |
+
return False
|
| 288 |
+
logger.info("모델 로드 중...")
|
| 289 |
+
self.model = YOLOv10(model_path, task="predict")
|
| 290 |
+
self.model.to(self.device)
|
| 291 |
+
if hasattr(self.model, "training"):
|
| 292 |
+
self.model.training = False
|
| 293 |
+
logger.info("모델 로드 완료!")
|
| 294 |
+
return True
|
| 295 |
+
except Exception as e:
|
| 296 |
+
logger.error(f"모델 로드 실패: {e}")
|
| 297 |
+
return False
|
| 298 |
+
|
| 299 |
+
def _ensure_model_loaded(self):
|
| 300 |
+
"""
|
| 301 |
+
Lazy Loading: 모델이 로드되지 않았으면 자동으로 로드
|
| 302 |
+
(다중 페이지 처리 시 모델을 한 번만 로드하도록 최적화)
|
| 303 |
+
"""
|
| 304 |
+
if self._model_loaded and self.model is not None:
|
| 305 |
+
return # 이미 로드됨
|
| 306 |
+
|
| 307 |
+
logger.info(f"모델 자동 로드 시작 (선택: {self.model_choice})...")
|
| 308 |
+
model_path = self.download_model(self.model_choice)
|
| 309 |
+
if not self.load_model(model_path):
|
| 310 |
+
raise RuntimeError(f"모델 로드 실패: {self.model_choice}")
|
| 311 |
+
self._model_loaded = True
|
| 312 |
+
logger.info("모델 자동 로드 완료!")
|
| 313 |
+
|
| 314 |
+
def analyze_layout(
|
| 315 |
+
self,
|
| 316 |
+
image: np.ndarray,
|
| 317 |
+
*,
|
| 318 |
+
page_id: int,
|
| 319 |
+
db: Session,
|
| 320 |
+
model_choice: Optional[str] = None,
|
| 321 |
+
) -> List[models.LayoutElement]:
|
| 322 |
+
"""
|
| 323 |
+
레이아웃 분석 + 중복 탐지 필터링 후 결과를 DB에 저장한다.
|
| 324 |
+
|
| 325 |
+
Args:
|
| 326 |
+
image: 분석할 이미지 (numpy array)
|
| 327 |
+
page_id: 결과를 저장할 pages.page_id
|
| 328 |
+
db: SQLAlchemy Session
|
| 329 |
+
model_choice: 사용할 모델 (미지정 시 인스턴스 기본값 사용)
|
| 330 |
+
|
| 331 |
+
Returns:
|
| 332 |
+
DB에 저장된 LayoutElement ORM 객체 리스트
|
| 333 |
+
"""
|
| 334 |
+
active_model = model_choice or self.model_choice
|
| 335 |
+
|
| 336 |
+
try:
|
| 337 |
+
# 모델 선택이 변경되었으면 재로드
|
| 338 |
+
if active_model != self.model_choice:
|
| 339 |
+
logger.warning(f"모델 변경 감지: {self.model_choice} -> {active_model}")
|
| 340 |
+
self.model_choice = active_model
|
| 341 |
+
self._model_loaded = False
|
| 342 |
+
|
| 343 |
+
# Lazy Loading: 모델이 없으면 자동 로드
|
| 344 |
+
self._ensure_model_loaded()
|
| 345 |
+
|
| 346 |
+
logger.info("레이아웃 분석 시작...")
|
| 347 |
+
temp_path = "temp_image.jpg"
|
| 348 |
+
cv2.imwrite(temp_path, image)
|
| 349 |
+
|
| 350 |
+
if active_model == "SmartEyeSsen":
|
| 351 |
+
imgsz, conf = 1024, 0.25
|
| 352 |
+
elif active_model == "docsynth300k":
|
| 353 |
+
imgsz, conf = 1600, 0.15
|
| 354 |
+
else:
|
| 355 |
+
imgsz, conf = 1024, 0.25
|
| 356 |
+
|
| 357 |
+
results = self.model.predict(
|
| 358 |
+
temp_path, imgsz=imgsz, conf=conf, iou=0.45, device=self.device
|
| 359 |
+
)
|
| 360 |
+
|
| 361 |
+
boxes = results[0].boxes.xyxy.cpu().numpy() # [x1, y1, x2, y2]
|
| 362 |
+
classes = results[0].boxes.cls.cpu().numpy()
|
| 363 |
+
confs = results[0].boxes.conf.cpu().numpy()
|
| 364 |
+
class_names = self.model.names # 클래스 ID → 이름
|
| 365 |
+
|
| 366 |
+
detection_records: List[Dict[str, float]] = []
|
| 367 |
+
|
| 368 |
+
if not boxes.size:
|
| 369 |
+
logger.warning("레이아웃 분석 결과, 감지된 요소가 없습니다.")
|
| 370 |
+
return self._create_elements_from_layout(
|
| 371 |
+
detections=detection_records, page_id=page_id, db=db
|
| 372 |
+
)
|
| 373 |
+
|
| 374 |
+
final_indices = filter_duplicate_detections(
|
| 375 |
+
boxes, classes, confs, class_names, iou_threshold=0.7
|
| 376 |
+
)
|
| 377 |
+
|
| 378 |
+
for i in final_indices:
|
| 379 |
+
box = boxes[i]
|
| 380 |
+
cls_id = int(classes[i])
|
| 381 |
+
conf_val = float(confs[i])
|
| 382 |
+
x1, y1, x2, y2 = map(int, box)
|
| 383 |
+
|
| 384 |
+
cls_name = (
|
| 385 |
+
class_names.get(cls_id, f"unknown_{cls_id}")
|
| 386 |
+
if isinstance(class_names, dict)
|
| 387 |
+
else None
|
| 388 |
+
)
|
| 389 |
+
if cls_name is None:
|
| 390 |
+
try:
|
| 391 |
+
cls_name = class_names[cls_id]
|
| 392 |
+
except (IndexError, KeyError):
|
| 393 |
+
cls_name = f"unknown_{cls_id}"
|
| 394 |
+
|
| 395 |
+
width = x2 - x1
|
| 396 |
+
height = y2 - y1
|
| 397 |
+
area = width * height
|
| 398 |
+
if area < 100:
|
| 399 |
+
continue
|
| 400 |
+
|
| 401 |
+
detection_records.append(
|
| 402 |
+
{
|
| 403 |
+
"class_name": cls_name,
|
| 404 |
+
"confidence": conf_val,
|
| 405 |
+
"bbox_x": x1,
|
| 406 |
+
"bbox_y": y1,
|
| 407 |
+
"bbox_width": width,
|
| 408 |
+
"bbox_height": height,
|
| 409 |
+
}
|
| 410 |
+
)
|
| 411 |
+
|
| 412 |
+
elements = self._create_elements_from_layout(
|
| 413 |
+
detections=detection_records, page_id=page_id, db=db
|
| 414 |
+
)
|
| 415 |
+
logger.info(f"레이아웃 분석 완료: 최종 {len(elements)}개 요소 저장")
|
| 416 |
+
return elements
|
| 417 |
+
|
| 418 |
+
except Exception as e:
|
| 419 |
+
logger.error(f"레이아웃 분석 실패: {e}", exc_info=True)
|
| 420 |
+
return []
|
| 421 |
+
|
| 422 |
+
def _create_elements_from_layout(
|
| 423 |
+
self, *, detections: List[Dict[str, float]], page_id: int, db: Session
|
| 424 |
+
) -> List[models.LayoutElement]:
|
| 425 |
+
"""
|
| 426 |
+
감지 결과를 layout_elements 테이블에 저장하고 ORM 객체 리스트를 반환한다.
|
| 427 |
+
"""
|
| 428 |
+
logger.debug(f"페이지 {page_id} 기존 레이아웃 요소 정리")
|
| 429 |
+
existing_elements = (
|
| 430 |
+
db.query(models.LayoutElement)
|
| 431 |
+
.filter(models.LayoutElement.page_id == page_id)
|
| 432 |
+
.all()
|
| 433 |
+
)
|
| 434 |
+
for element in existing_elements:
|
| 435 |
+
db.delete(element)
|
| 436 |
+
db.flush() # CASCADE 관계 정리
|
| 437 |
+
|
| 438 |
+
if not detections:
|
| 439 |
+
db.commit()
|
| 440 |
+
return []
|
| 441 |
+
|
| 442 |
+
created_elements: List[models.LayoutElement] = []
|
| 443 |
+
for record in detections:
|
| 444 |
+
element = models.LayoutElement(
|
| 445 |
+
page_id=page_id,
|
| 446 |
+
class_name=record["class_name"],
|
| 447 |
+
confidence=record["confidence"],
|
| 448 |
+
bbox_x=int(record["bbox_x"]),
|
| 449 |
+
bbox_y=int(record["bbox_y"]),
|
| 450 |
+
bbox_width=int(record["bbox_width"]),
|
| 451 |
+
bbox_height=int(record["bbox_height"]),
|
| 452 |
+
)
|
| 453 |
+
db.add(element)
|
| 454 |
+
created_elements.append(element)
|
| 455 |
+
|
| 456 |
+
db.flush()
|
| 457 |
+
db.commit()
|
| 458 |
+
for element in created_elements:
|
| 459 |
+
db.refresh(element)
|
| 460 |
+
|
| 461 |
+
return created_elements
|
| 462 |
+
|
| 463 |
+
def _upsert_text_content(
|
| 464 |
+
self,
|
| 465 |
+
*,
|
| 466 |
+
db: Session,
|
| 467 |
+
element_id: int,
|
| 468 |
+
ocr_text: str,
|
| 469 |
+
ocr_engine: str,
|
| 470 |
+
language: str,
|
| 471 |
+
ocr_confidence: Optional[float] = None,
|
| 472 |
+
) -> models.TextContent:
|
| 473 |
+
"""
|
| 474 |
+
텍스트 콘텐츠를 생성하거나 업데이트한다.
|
| 475 |
+
"""
|
| 476 |
+
existing = (
|
| 477 |
+
db.query(models.TextContent)
|
| 478 |
+
.filter(models.TextContent.element_id == element_id)
|
| 479 |
+
.one_or_none()
|
| 480 |
+
)
|
| 481 |
+
|
| 482 |
+
if existing:
|
| 483 |
+
existing.ocr_text = ocr_text
|
| 484 |
+
existing.ocr_engine = ocr_engine
|
| 485 |
+
existing.language = language
|
| 486 |
+
existing.ocr_confidence = ocr_confidence
|
| 487 |
+
db.flush()
|
| 488 |
+
return existing
|
| 489 |
+
|
| 490 |
+
content = models.TextContent(
|
| 491 |
+
element_id=element_id,
|
| 492 |
+
ocr_text=ocr_text,
|
| 493 |
+
ocr_engine=ocr_engine,
|
| 494 |
+
ocr_confidence=ocr_confidence,
|
| 495 |
+
language=language,
|
| 496 |
+
)
|
| 497 |
+
db.add(content)
|
| 498 |
+
db.flush()
|
| 499 |
+
return content
|
| 500 |
+
|
| 501 |
+
def _upsert_ai_descriptions(
|
| 502 |
+
self,
|
| 503 |
+
*,
|
| 504 |
+
db: Session,
|
| 505 |
+
descriptions: Dict[int, str],
|
| 506 |
+
model_name: str,
|
| 507 |
+
prompt: Optional[str],
|
| 508 |
+
) -> List[models.AIDescription]:
|
| 509 |
+
"""
|
| 510 |
+
AI 설명을 생성하거나 갱신한다.
|
| 511 |
+
"""
|
| 512 |
+
saved_records: List[models.AIDescription] = []
|
| 513 |
+
for element_id, description in descriptions.items():
|
| 514 |
+
existing = (
|
| 515 |
+
db.query(models.AIDescription)
|
| 516 |
+
.filter(models.AIDescription.element_id == element_id)
|
| 517 |
+
.one_or_none()
|
| 518 |
+
)
|
| 519 |
+
|
| 520 |
+
if existing:
|
| 521 |
+
existing.description = description
|
| 522 |
+
existing.ai_model = model_name
|
| 523 |
+
existing.prompt_used = prompt
|
| 524 |
+
db.flush()
|
| 525 |
+
saved_records.append(existing)
|
| 526 |
+
continue
|
| 527 |
+
|
| 528 |
+
record = models.AIDescription(
|
| 529 |
+
element_id=element_id,
|
| 530 |
+
description=description,
|
| 531 |
+
ai_model=model_name,
|
| 532 |
+
prompt_used=prompt,
|
| 533 |
+
)
|
| 534 |
+
db.add(record)
|
| 535 |
+
saved_records.append(record)
|
| 536 |
+
|
| 537 |
+
db.flush()
|
| 538 |
+
db.commit()
|
| 539 |
+
for record in saved_records:
|
| 540 |
+
db.refresh(record)
|
| 541 |
+
|
| 542 |
+
return saved_records
|
| 543 |
+
|
| 544 |
+
def perform_ocr(
|
| 545 |
+
self,
|
| 546 |
+
image: np.ndarray,
|
| 547 |
+
layout_elements: List[models.LayoutElement],
|
| 548 |
+
*,
|
| 549 |
+
db: Session,
|
| 550 |
+
language: str = "kor",
|
| 551 |
+
) -> List[models.TextContent]:
|
| 552 |
+
"""OCR 처리 (영역별 전처리 추가) 및 text_contents 테이블 저장"""
|
| 553 |
+
target_classes = [
|
| 554 |
+
"plain text",
|
| 555 |
+
"unit",
|
| 556 |
+
"question type",
|
| 557 |
+
"question text",
|
| 558 |
+
"question number",
|
| 559 |
+
"title",
|
| 560 |
+
"figure_caption",
|
| 561 |
+
"table caption",
|
| 562 |
+
"table footnote",
|
| 563 |
+
"isolate_formula",
|
| 564 |
+
"formula_caption",
|
| 565 |
+
"list",
|
| 566 |
+
"choices",
|
| 567 |
+
"page",
|
| 568 |
+
"second_question_number",
|
| 569 |
+
]
|
| 570 |
+
ocr_results: List[models.TextContent] = []
|
| 571 |
+
custom_config = r"--oem 3 --psm 6"
|
| 572 |
+
logger.info(
|
| 573 |
+
f"OCR 처리 시작... 총 {len(layout_elements)}개 레이아웃 요소 중 OCR 대상 필터링"
|
| 574 |
+
)
|
| 575 |
+
logger.info(f"OCR 대상 클래스 목록: {target_classes}")
|
| 576 |
+
detected_classes = {elem.class_name for elem in layout_elements} # Set으로 변경
|
| 577 |
+
logger.info(f"감지된 모든 클래스: {detected_classes}")
|
| 578 |
+
|
| 579 |
+
target_count = 0
|
| 580 |
+
for element in layout_elements:
|
| 581 |
+
cls_name = element.class_name # Pydantic 모델은 이미 lower() 불필요
|
| 582 |
+
logger.debug(
|
| 583 |
+
f"레이아웃 ID {element.element_id}: 클래스 '{cls_name}' 확인 중..."
|
| 584 |
+
) # DEBUG 레벨로 변경
|
| 585 |
+
if cls_name not in target_classes:
|
| 586 |
+
logger.debug(f" → OCR 대상 아님")
|
| 587 |
+
continue
|
| 588 |
+
|
| 589 |
+
target_count += 1
|
| 590 |
+
logger.debug(
|
| 591 |
+
f" → OCR 대상 {target_count}: ID {element.element_id} - 클래스 '{cls_name}'"
|
| 592 |
+
)
|
| 593 |
+
|
| 594 |
+
# 1. 영역 이미지 잘라내기 (기존 코드)
|
| 595 |
+
x1, y1 = element.bbox_x, element.bbox_y
|
| 596 |
+
x2, y2 = x1 + element.bbox_width, y1 + element.bbox_height
|
| 597 |
+
# 이미지 경계 내로 좌표 조정
|
| 598 |
+
x1, y1 = max(0, x1), max(0, y1)
|
| 599 |
+
x2, y2 = min(image.shape[1], x2), min(image.shape[0], y2)
|
| 600 |
+
|
| 601 |
+
if y2 <= y1 or x2 <= x1: # 크기가 0이거나 음수인 경우 건너뛰기
|
| 602 |
+
logger.warning(
|
| 603 |
+
f" → 유효하지 않은 BBox 크기: ID {element.element_id}, 건너뜀"
|
| 604 |
+
)
|
| 605 |
+
continue
|
| 606 |
+
cropped_img = image[y1:y2, x1:x2]
|
| 607 |
+
|
| 608 |
+
try:
|
| 609 |
+
# --- 👇 영역별 전처리 단계 시작 👇 ---
|
| 610 |
+
|
| 611 |
+
# 2. 그레이스케일 변환: 색상 정보 제거
|
| 612 |
+
gray_img = cv2.cvtColor(cropped_img, cv2.COLOR_BGR2GRAY)
|
| 613 |
+
|
| 614 |
+
# 3. 이진화 (Otsu's Binarization): 텍스트/배경 명확화
|
| 615 |
+
# Otsu 방식은 임계값을 자동으로 결정해 줍니다.
|
| 616 |
+
# 필요에 따라 cv2.adaptiveThreshold 등 다른 방식 사용 가능
|
| 617 |
+
_, binary_img = cv2.threshold(
|
| 618 |
+
gray_img, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU
|
| 619 |
+
)
|
| 620 |
+
|
| 621 |
+
# 4. (선택적) 노이즈 제거: Median 필터 적용 (작은 점 제거에 효과적)
|
| 622 |
+
# 커널 크기(예: 3)는 실험을 통해 조정
|
| 623 |
+
denoised_img = cv2.medianBlur(binary_img, 3)
|
| 624 |
+
|
| 625 |
+
# --- 👆 영역별 전처리 단계 끝 👆 ---
|
| 626 |
+
|
| 627 |
+
# 5. 전처리된 이미지로 OCR 수행
|
| 628 |
+
# Pillow 이미지로 변환 (Tesseract는 Pillow 이미지 입력 선호)
|
| 629 |
+
pil_img = Image.fromarray(cropped_img)
|
| 630 |
+
text = pytesseract.image_to_string(
|
| 631 |
+
pil_img, lang="kor", config=custom_config
|
| 632 |
+
).strip()
|
| 633 |
+
|
| 634 |
+
if len(text) > 1: # 빈 문자열이 아닌 경우만
|
| 635 |
+
db_text = self._upsert_text_content(
|
| 636 |
+
db=db,
|
| 637 |
+
element_id=element.element_id,
|
| 638 |
+
ocr_text=text,
|
| 639 |
+
ocr_engine="Tesseract",
|
| 640 |
+
language=language,
|
| 641 |
+
)
|
| 642 |
+
ocr_results.append(db_text)
|
| 643 |
+
logger.info(
|
| 644 |
+
f"✅ OCR 성공: ID {element.element_id} ({cls_name}) - '{text[:50].replace(chr(10), ' ')}...' ({len(text)}자)"
|
| 645 |
+
) # 개행문자 제거
|
| 646 |
+
else:
|
| 647 |
+
logger.warning(
|
| 648 |
+
f"⚠️ OCR 결과 없음: ID {element.element_id} ({cls_name})"
|
| 649 |
+
)
|
| 650 |
+
except Exception as e:
|
| 651 |
+
logger.error(
|
| 652 |
+
f"OCR 실패: ID {element.element_id} - {e}", exc_info=True
|
| 653 |
+
) # 상세 에러
|
| 654 |
+
|
| 655 |
+
db.commit()
|
| 656 |
+
for content in ocr_results:
|
| 657 |
+
db.refresh(content)
|
| 658 |
+
|
| 659 |
+
logger.info(f"OCR 처리 완료: {len(ocr_results)}개 텍스트 블록 저장")
|
| 660 |
+
return ocr_results
|
| 661 |
+
|
| 662 |
+
def call_openai_api(
|
| 663 |
+
self,
|
| 664 |
+
image: np.ndarray,
|
| 665 |
+
layout_elements: List[models.LayoutElement],
|
| 666 |
+
*,
|
| 667 |
+
api_key: Optional[str],
|
| 668 |
+
db: Session,
|
| 669 |
+
model_name: str = "gpt-4-turbo",
|
| 670 |
+
) -> Dict[int, str]:
|
| 671 |
+
"""OpenAI API 호출 및 ai_descriptions 테이블 저장"""
|
| 672 |
+
if not api_key:
|
| 673 |
+
logger.warning("API 키가 없어 AI 설명 생성을 건너뜁니다.")
|
| 674 |
+
return {}
|
| 675 |
+
target_classes = ["figure", "table", "flowchart"]
|
| 676 |
+
ai_descriptions: Dict[int, str] = {}
|
| 677 |
+
|
| 678 |
+
try:
|
| 679 |
+
client = openai.OpenAI(api_key=api_key)
|
| 680 |
+
logger.info("OpenAI API 처리 시작...")
|
| 681 |
+
except Exception as e:
|
| 682 |
+
logger.error(f"OpenAI 클라이언트 초기화 실패: {e}")
|
| 683 |
+
return {}
|
| 684 |
+
|
| 685 |
+
prompts = {
|
| 686 |
+
"figure": figure_prompt,
|
| 687 |
+
"table": table_prompt,
|
| 688 |
+
"flowchart": flowchart_prompt,
|
| 689 |
+
}
|
| 690 |
+
system_prompt = (
|
| 691 |
+
"당신은 시각 장애 아동 학습 AI 비서입니다. "
|
| 692 |
+
"시각 자료 내용을 한국어로 간결하고 명확하게 설명하세요. "
|
| 693 |
+
"음성 변환 시 이해하기 쉽도록 직접적인 문장을 사용하세요."
|
| 694 |
+
)
|
| 695 |
+
|
| 696 |
+
for element in layout_elements:
|
| 697 |
+
cls_name = element.class_name
|
| 698 |
+
if cls_name not in target_classes:
|
| 699 |
+
continue
|
| 700 |
+
|
| 701 |
+
x1, y1 = element.bbox_x, element.bbox_y
|
| 702 |
+
x2, y2 = x1 + element.bbox_width, y1 + element.bbox_height
|
| 703 |
+
if y2 <= y1 or x2 <= x1:
|
| 704 |
+
continue
|
| 705 |
+
|
| 706 |
+
cropped_img = image[y1:y2, x1:x2]
|
| 707 |
+
pil_img = Image.fromarray(cv2.cvtColor(cropped_img, cv2.COLOR_BGR2RGB))
|
| 708 |
+
buffered = io.BytesIO()
|
| 709 |
+
pil_img.save(buffered, format="PNG")
|
| 710 |
+
img_base64 = base64.b64encode(buffered.getvalue()).decode("utf-8")
|
| 711 |
+
prompt = prompts.get(cls_name, f"이 {cls_name} 내용 설명")
|
| 712 |
+
|
| 713 |
+
try:
|
| 714 |
+
response = client.chat.completions.create(
|
| 715 |
+
model=model_name,
|
| 716 |
+
messages=[
|
| 717 |
+
{"role": "system", "content": system_prompt},
|
| 718 |
+
{
|
| 719 |
+
"role": "user",
|
| 720 |
+
"content": [
|
| 721 |
+
{"type": "text", "text": prompt},
|
| 722 |
+
{
|
| 723 |
+
"type": "image_url",
|
| 724 |
+
"image_url": {
|
| 725 |
+
"url": f"data:image/png;base64,{img_base64}"
|
| 726 |
+
},
|
| 727 |
+
},
|
| 728 |
+
],
|
| 729 |
+
},
|
| 730 |
+
],
|
| 731 |
+
temperature=0.2,
|
| 732 |
+
max_tokens=600,
|
| 733 |
+
)
|
| 734 |
+
description = response.choices[0].message.content.strip()
|
| 735 |
+
ai_descriptions[element.element_id] = description
|
| 736 |
+
logger.info(f"API 응답 완료: ID {element.element_id} - {cls_name}")
|
| 737 |
+
except Exception as e:
|
| 738 |
+
logger.error(
|
| 739 |
+
f"API 요청 실패: ID {element.element_id} - {e}", exc_info=True
|
| 740 |
+
)
|
| 741 |
+
|
| 742 |
+
saved = self._upsert_ai_descriptions(
|
| 743 |
+
db=db, descriptions=ai_descriptions, model_name=model_name, prompt=None
|
| 744 |
+
)
|
| 745 |
+
logger.info(f"OpenAI API 처리 완료: {len(saved)}개 설명 생성 및 저장")
|
| 746 |
+
return ai_descriptions
|
| 747 |
+
|
| 748 |
+
async def call_openai_api_async(
|
| 749 |
+
self,
|
| 750 |
+
image: np.ndarray,
|
| 751 |
+
layout_elements: List[models.LayoutElement],
|
| 752 |
+
api_key: str,
|
| 753 |
+
*,
|
| 754 |
+
db: Optional[Session] = None,
|
| 755 |
+
model_name: str = "gpt-4-turbo",
|
| 756 |
+
max_concurrent_requests: int = 5,
|
| 757 |
+
) -> Dict[int, str]:
|
| 758 |
+
"""
|
| 759 |
+
OpenAI API 비동기 병렬 호출 (성능 최적화 버전)
|
| 760 |
+
|
| 761 |
+
Args:
|
| 762 |
+
image: 원본 이미지 (BGR 포맷)
|
| 763 |
+
layout_elements: 레이아웃 요소 리스트
|
| 764 |
+
api_key: OpenAI API 키
|
| 765 |
+
db: SQLAlchemy Session (선택, 제공 시 DB에 설명 저장)
|
| 766 |
+
model_name: 사용할 OpenAI 모델 이름
|
| 767 |
+
max_concurrent_requests: 최대 동시 요청 수 (기본값: 5)
|
| 768 |
+
|
| 769 |
+
Returns:
|
| 770 |
+
Dict[int, str]: {element_id: AI 설명} 딕셔너리
|
| 771 |
+
|
| 772 |
+
주요 개선사항:
|
| 773 |
+
- 비동기 병렬 처리로 처리 시간 70% 단축
|
| 774 |
+
- asyncio.Semaphore로 Rate Limit 대응
|
| 775 |
+
- 지수 백오프 재시도 로직 (exponential backoff)
|
| 776 |
+
"""
|
| 777 |
+
if not api_key:
|
| 778 |
+
logger.warning("API 키가 없어 AI 설명 생성을 건너뜁니다.")
|
| 779 |
+
return {}
|
| 780 |
+
|
| 781 |
+
# 1. 대상 클래스 필터링 (figure, table, flowchart만 처리)
|
| 782 |
+
target_classes = ["figure", "table", "flowchart"]
|
| 783 |
+
target_elements = [
|
| 784 |
+
elem for elem in layout_elements if elem.class_name in target_classes
|
| 785 |
+
]
|
| 786 |
+
|
| 787 |
+
if not target_elements:
|
| 788 |
+
logger.info("AI 설명 대상 요소가 없습니다.")
|
| 789 |
+
return {}
|
| 790 |
+
|
| 791 |
+
logger.info(
|
| 792 |
+
f"OpenAI API 비동기 처리 시작... (총 {len(target_elements)}개 요소)"
|
| 793 |
+
)
|
| 794 |
+
|
| 795 |
+
# 2. AsyncOpenAI 클라이언트 초기화
|
| 796 |
+
try:
|
| 797 |
+
async_client = AsyncOpenAI(api_key=api_key)
|
| 798 |
+
except Exception as e:
|
| 799 |
+
logger.error(f"AsyncOpenAI 클라이언트 초기화 실패: {e}")
|
| 800 |
+
return {}
|
| 801 |
+
|
| 802 |
+
# 3. Semaphore로 동시 요청 수 제한 (Rate Limit 대응)
|
| 803 |
+
semaphore = asyncio.Semaphore(max_concurrent_requests)
|
| 804 |
+
|
| 805 |
+
# 4. 모든 비동기 태스크 생성
|
| 806 |
+
tasks = [
|
| 807 |
+
self._process_single_element_async(
|
| 808 |
+
async_client=async_client,
|
| 809 |
+
image=image,
|
| 810 |
+
element=elem,
|
| 811 |
+
semaphore=semaphore,
|
| 812 |
+
model_name=model_name,
|
| 813 |
+
)
|
| 814 |
+
for elem in target_elements
|
| 815 |
+
]
|
| 816 |
+
|
| 817 |
+
# 5. 병렬 실행 (asyncio.gather)
|
| 818 |
+
results = await asyncio.gather(*tasks, return_exceptions=True)
|
| 819 |
+
|
| 820 |
+
# 6. 결과 매핑 및 예외 처리
|
| 821 |
+
ai_descriptions = {}
|
| 822 |
+
success_count = 0
|
| 823 |
+
error_count = 0
|
| 824 |
+
|
| 825 |
+
for element, result in zip(target_elements, results):
|
| 826 |
+
if isinstance(result, Exception):
|
| 827 |
+
logger.error(f"API 실패: Element {element.element_id} - {result}")
|
| 828 |
+
error_count += 1
|
| 829 |
+
elif result: # 성공 시 (빈 문자열이 아닌 경우)
|
| 830 |
+
ai_descriptions[element.element_id] = result
|
| 831 |
+
success_count += 1
|
| 832 |
+
logger.info(
|
| 833 |
+
f"✅ API 성공: Element {element.element_id} ({element.class_name})"
|
| 834 |
+
)
|
| 835 |
+
|
| 836 |
+
logger.info(
|
| 837 |
+
f"OpenAI API 비동기 처리 완료: "
|
| 838 |
+
f"성공 {success_count}건, 실패 {error_count}건 / 총 {len(target_elements)}건"
|
| 839 |
+
)
|
| 840 |
+
|
| 841 |
+
if db and ai_descriptions:
|
| 842 |
+
saved = self._upsert_ai_descriptions(
|
| 843 |
+
db=db, descriptions=ai_descriptions, model_name=model_name, prompt=None
|
| 844 |
+
)
|
| 845 |
+
logger.info(f"AI 설명 {len(saved)}건 저장 완료 (비동기)")
|
| 846 |
+
|
| 847 |
+
return ai_descriptions
|
| 848 |
+
|
| 849 |
+
async def _process_single_element_async(
|
| 850 |
+
self,
|
| 851 |
+
async_client: AsyncOpenAI,
|
| 852 |
+
image: np.ndarray,
|
| 853 |
+
element: models.LayoutElement,
|
| 854 |
+
semaphore: asyncio.Semaphore,
|
| 855 |
+
model_name: str,
|
| 856 |
+
) -> str:
|
| 857 |
+
"""
|
| 858 |
+
단일 element에 대한 비동기 AI 설명 생성 (지수 백오프 재시도 포함)
|
| 859 |
+
|
| 860 |
+
Args:
|
| 861 |
+
async_client: AsyncOpenAI 클라이언트
|
| 862 |
+
image: 원본 이미지
|
| 863 |
+
element: 처리할 레이아웃 요소
|
| 864 |
+
semaphore: 동시 요청 수 제한용 Semaphore
|
| 865 |
+
model_name: 사용할 OpenAI 모델 이름
|
| 866 |
+
|
| 867 |
+
Returns:
|
| 868 |
+
str: AI 생성 설명 텍스트
|
| 869 |
+
|
| 870 |
+
재시도 로직:
|
| 871 |
+
- 최대 3회 재시도
|
| 872 |
+
- 대기 시간: 1초 → 2초 → 4초 (지수 백오프)
|
| 873 |
+
"""
|
| 874 |
+
# 1. 이미지 크롭 및 검증
|
| 875 |
+
x1, y1 = element.bbox_x, element.bbox_y
|
| 876 |
+
x2, y2 = x1 + element.bbox_width, y1 + element.bbox_height
|
| 877 |
+
|
| 878 |
+
# 크기 검증
|
| 879 |
+
if y2 <= y1 or x2 <= x1:
|
| 880 |
+
logger.warning(f"유효하지 않은 BBox 크기: Element {element.element_id}")
|
| 881 |
+
return ""
|
| 882 |
+
|
| 883 |
+
# 이미지 크롭
|
| 884 |
+
cropped_img = image[y1:y2, x1:x2]
|
| 885 |
+
|
| 886 |
+
# 2. PIL 이미지 변환 및 Base64 인코딩
|
| 887 |
+
pil_img = Image.fromarray(cv2.cvtColor(cropped_img, cv2.COLOR_BGR2RGB))
|
| 888 |
+
buffered = io.BytesIO()
|
| 889 |
+
pil_img.save(buffered, format="PNG")
|
| 890 |
+
img_base64 = base64.b64encode(buffered.getvalue()).decode("utf-8")
|
| 891 |
+
|
| 892 |
+
# 3. 프롬프트 선택
|
| 893 |
+
prompts = {
|
| 894 |
+
"figure": figure_prompt,
|
| 895 |
+
"table": table_prompt,
|
| 896 |
+
"flowchart": flowchart_prompt,
|
| 897 |
+
}
|
| 898 |
+
prompt = prompts.get(element.class_name, f"이 {element.class_name} 내용 설명")
|
| 899 |
+
|
| 900 |
+
system_prompt = (
|
| 901 |
+
"당신은 시각 장애 아동 학습 AI 비서입니다. "
|
| 902 |
+
"시각 자료 내용을 한국어로 간결, 명확하게 설명하세요. "
|
| 903 |
+
"음성 변환 가능하게 직접적이고 이해하기 쉽게 작성하세요."
|
| 904 |
+
)
|
| 905 |
+
|
| 906 |
+
# 4. 지수 백오프 재시도 로직
|
| 907 |
+
max_retries = 3
|
| 908 |
+
base_delay = 1.0 # 초 단위
|
| 909 |
+
|
| 910 |
+
async with semaphore: # Rate Limit 제어
|
| 911 |
+
for attempt in range(max_retries):
|
| 912 |
+
try:
|
| 913 |
+
# API 호출
|
| 914 |
+
response = await async_client.chat.completions.create(
|
| 915 |
+
model=model_name,
|
| 916 |
+
messages=[
|
| 917 |
+
{"role": "system", "content": system_prompt},
|
| 918 |
+
{
|
| 919 |
+
"role": "user",
|
| 920 |
+
"content": [
|
| 921 |
+
{"type": "text", "text": prompt},
|
| 922 |
+
{
|
| 923 |
+
"type": "image_url",
|
| 924 |
+
"image_url": {
|
| 925 |
+
"url": f"data:image/png;base64,{img_base64}"
|
| 926 |
+
},
|
| 927 |
+
},
|
| 928 |
+
],
|
| 929 |
+
},
|
| 930 |
+
],
|
| 931 |
+
temperature=0.2,
|
| 932 |
+
max_tokens=600,
|
| 933 |
+
)
|
| 934 |
+
|
| 935 |
+
# 성공 시 결과 반환
|
| 936 |
+
description = response.choices[0].message.content.strip()
|
| 937 |
+
logger.debug(
|
| 938 |
+
f"API 응답 완료 (시도 {attempt + 1}/{max_retries}): "
|
| 939 |
+
f"Element {element.element_id}"
|
| 940 |
+
)
|
| 941 |
+
return description
|
| 942 |
+
|
| 943 |
+
except openai.RateLimitError as e:
|
| 944 |
+
# Rate Limit 오류: 지수 백오프 대기 후 재시도
|
| 945 |
+
if attempt < max_retries - 1:
|
| 946 |
+
delay = base_delay * (2**attempt) # 1초 → 2초 → 4초
|
| 947 |
+
logger.warning(
|
| 948 |
+
f"⚠️ Rate Limit 오류 (Element {element.element_id}): "
|
| 949 |
+
f"{delay}초 대기 후 재시도 ({attempt + 1}/{max_retries})"
|
| 950 |
+
)
|
| 951 |
+
await asyncio.sleep(delay)
|
| 952 |
+
else:
|
| 953 |
+
logger.error(
|
| 954 |
+
f"❌ Rate Limit 오류 최종 실패 (Element {element.element_id}): {e}"
|
| 955 |
+
)
|
| 956 |
+
raise # 최종 실패 시 예외 전파
|
| 957 |
+
|
| 958 |
+
except openai.APIError as e:
|
| 959 |
+
# API 일반 오류: 지수 백오프 대기 후 재시도
|
| 960 |
+
if attempt < max_retries - 1:
|
| 961 |
+
delay = base_delay * (2**attempt)
|
| 962 |
+
logger.warning(
|
| 963 |
+
f"⚠️ API 오류 (Element {element.element_id}): "
|
| 964 |
+
f"{delay}초 대기 후 재시도 ({attempt + 1}/{max_retries}) - {e}"
|
| 965 |
+
)
|
| 966 |
+
await asyncio.sleep(delay)
|
| 967 |
+
else:
|
| 968 |
+
logger.error(
|
| 969 |
+
f"❌ API 오류 최종 실패 (Element {element.element_id}): {e}"
|
| 970 |
+
)
|
| 971 |
+
raise
|
| 972 |
+
|
| 973 |
+
except Exception as e:
|
| 974 |
+
# 기타 예외: 즉시 실패
|
| 975 |
+
logger.error(
|
| 976 |
+
f"❌ 예상치 못한 오류 (Element {element.element_id}): {e}",
|
| 977 |
+
exc_info=True,
|
| 978 |
+
)
|
| 979 |
+
raise
|
| 980 |
+
|
| 981 |
+
# 모든 재시도 실패 시 빈 문자열 반환 (unreachable, but for type safety)
|
| 982 |
+
return ""
|
| 983 |
+
|
| 984 |
+
def visualize_results(
|
| 985 |
+
self, image: np.ndarray, layout_elements: List[models.LayoutElement]
|
| 986 |
+
) -> np.ndarray:
|
| 987 |
+
"""결과 시각화 (기존과 동일)"""
|
| 988 |
+
img_result = image.copy()
|
| 989 |
+
overlay = image.copy()
|
| 990 |
+
random.seed(42)
|
| 991 |
+
unique_classes = list({elem.class_name for elem in layout_elements})
|
| 992 |
+
class_colors = {}
|
| 993 |
+
for i, cls_name in enumerate(unique_classes):
|
| 994 |
+
h, s, v = i / max(1, len(unique_classes)), 0.8, 0.9
|
| 995 |
+
r, g, b = colorsys.hsv_to_rgb(h, s, v)
|
| 996 |
+
class_colors[cls_name] = (int(b * 255), int(g * 255), int(r * 255))
|
| 997 |
+
for element in layout_elements:
|
| 998 |
+
x1, y1 = element.bbox_x, element.bbox_y
|
| 999 |
+
x2, y2 = x1 + element.bbox_width, y1 + element.bbox_height
|
| 1000 |
+
cls_name, color = element.class_name, class_colors[element.class_name]
|
| 1001 |
+
cv2.rectangle(overlay, (x1, y1), (x2, y2), color, -1)
|
| 1002 |
+
cv2.rectangle(img_result, (x1, y1), (x2, y2), color, 2)
|
| 1003 |
+
label = f"{cls_name} ({element.confidence:.2f})"
|
| 1004 |
+
labelSize, _ = cv2.getTextSize(label, cv2.FONT_HERSHEY_SIMPLEX, 0.5, 1)
|
| 1005 |
+
y1_label = max(y1, labelSize[1] + 10)
|
| 1006 |
+
cv2.rectangle(
|
| 1007 |
+
img_result,
|
| 1008 |
+
(x1, y1_label - labelSize[1] - 10),
|
| 1009 |
+
(x1 + labelSize[0], y1_label),
|
| 1010 |
+
color,
|
| 1011 |
+
-1,
|
| 1012 |
+
)
|
| 1013 |
+
cv2.putText(
|
| 1014 |
+
img_result,
|
| 1015 |
+
label,
|
| 1016 |
+
(x1, y1_label - 5),
|
| 1017 |
+
cv2.FONT_HERSHEY_SIMPLEX,
|
| 1018 |
+
0.5,
|
| 1019 |
+
(255, 255, 255),
|
| 1020 |
+
1,
|
| 1021 |
+
)
|
| 1022 |
+
img_result = cv2.addWeighted(overlay, 0.2, img_result, 0.8, 0)
|
| 1023 |
+
return cv2.cvtColor(img_result, cv2.COLOR_BGR2RGB)
|
| 1024 |
+
|
| 1025 |
+
|
| 1026 |
+
def analyze_page(
|
| 1027 |
+
*,
|
| 1028 |
+
page_id: int,
|
| 1029 |
+
image: np.ndarray,
|
| 1030 |
+
db: Session,
|
| 1031 |
+
api_key: Optional[str] = None,
|
| 1032 |
+
model_choice: Optional[str] = None,
|
| 1033 |
+
) -> Dict[str, object]:
|
| 1034 |
+
"""단일 페이지에 대한 전체 분석 파이프라인을 실행한다."""
|
| 1035 |
+
service = AnalysisService(
|
| 1036 |
+
model_choice=model_choice or "SmartEyeSsen", auto_load=False
|
| 1037 |
+
)
|
| 1038 |
+
|
| 1039 |
+
layout_elements = service.analyze_layout(
|
| 1040 |
+
image=image, page_id=page_id, db=db, model_choice=model_choice
|
| 1041 |
+
)
|
| 1042 |
+
|
| 1043 |
+
text_contents = service.perform_ocr(
|
| 1044 |
+
image=image, layout_elements=layout_elements, db=db
|
| 1045 |
+
)
|
| 1046 |
+
|
| 1047 |
+
ai_descriptions: Dict[int, str] = {}
|
| 1048 |
+
if api_key:
|
| 1049 |
+
ai_descriptions = service.call_openai_api(
|
| 1050 |
+
image=image, layout_elements=layout_elements, api_key=api_key, db=db
|
| 1051 |
+
)
|
| 1052 |
+
|
| 1053 |
+
return {
|
| 1054 |
+
"layout_elements": layout_elements,
|
| 1055 |
+
"text_contents": text_contents,
|
| 1056 |
+
"ai_descriptions": ai_descriptions,
|
| 1057 |
+
}
|
app/services/batch_analysis.py
ADDED
|
@@ -0,0 +1,450 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Project Batch Analysis Service
|
| 3 |
+
=============================
|
| 4 |
+
|
| 5 |
+
실제 데이터베이스(DB) 기반으로 프로젝트 내 페이지들을 순차적으로 분석하고
|
| 6 |
+
정렬(Question Grouping) 및 포맷팅(Text Version 생성)까지 수행합니다.
|
| 7 |
+
|
| 8 |
+
파이프라인 (페이지 단위)
|
| 9 |
+
1. 이미지 로드
|
| 10 |
+
2. AnalysisService로 레이아웃 → OCR → (선택) AI 설명 생성
|
| 11 |
+
3. sorter.py를 이용한 정렬 후 question_groups / question_elements 저장
|
| 12 |
+
4. TextFormatter로 자동 포맷팅 → text_versions에 최신 버전 기록
|
| 13 |
+
|
| 14 |
+
결과는 페이지별 요약 정보와 함께 프로젝트 상태를 갱신합니다.
|
| 15 |
+
"""
|
| 16 |
+
|
| 17 |
+
from __future__ import annotations
|
| 18 |
+
|
| 19 |
+
import asyncio
|
| 20 |
+
import os
|
| 21 |
+
import time
|
| 22 |
+
from datetime import datetime
|
| 23 |
+
from functools import lru_cache
|
| 24 |
+
from pathlib import Path
|
| 25 |
+
from typing import Any, Dict, List, Optional
|
| 26 |
+
|
| 27 |
+
import cv2
|
| 28 |
+
import numpy as np
|
| 29 |
+
from loguru import logger
|
| 30 |
+
from sqlalchemy.orm import Session, selectinload
|
| 31 |
+
|
| 32 |
+
from ..models import LayoutElement, Page, Project
|
| 33 |
+
from .analysis_service import AnalysisService
|
| 34 |
+
from .formatter import TextFormatter
|
| 35 |
+
from .mock_models import MockElement
|
| 36 |
+
from .sorter import save_sorting_results_to_db, sort_layout_elements
|
| 37 |
+
from .text_version_service import create_text_version
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
# -----------------------------------------------------------------------------
|
| 41 |
+
# 내부 상수 & 헬퍼
|
| 42 |
+
# -----------------------------------------------------------------------------
|
| 43 |
+
|
| 44 |
+
UPLOADS_ROOT = (Path(__file__).resolve().parents[2] / "uploads").resolve()
|
| 45 |
+
DEFAULT_AI_CONCURRENCY = int(os.getenv("OPENAI_MAX_CONCURRENCY", "5"))
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
@lru_cache(maxsize=1)
|
| 49 |
+
def _get_analysis_service(model_choice: str = "SmartEyeSsen") -> AnalysisService:
|
| 50 |
+
"""
|
| 51 |
+
모델 로딩 비용을 줄이기 위해 AnalysisService 인스턴스를 캐시합니다.
|
| 52 |
+
"""
|
| 53 |
+
logger.debug("AnalysisService 인스턴스 요청 (model_choice=%s)", model_choice)
|
| 54 |
+
return AnalysisService(model_choice=model_choice, auto_load=False)
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def _resolve_image_path(image_path: str) -> Path:
|
| 58 |
+
"""
|
| 59 |
+
Page.image_path 값을 절대 경로로 변환합니다.
|
| 60 |
+
"""
|
| 61 |
+
raw_path = Path(image_path)
|
| 62 |
+
candidates = []
|
| 63 |
+
|
| 64 |
+
if raw_path.is_absolute():
|
| 65 |
+
candidates.append(raw_path)
|
| 66 |
+
else:
|
| 67 |
+
candidates.append((UPLOADS_ROOT / raw_path).resolve())
|
| 68 |
+
candidates.append((Path.cwd() / "uploads" / raw_path).resolve())
|
| 69 |
+
candidates.append((Path.cwd() / raw_path).resolve())
|
| 70 |
+
|
| 71 |
+
for candidate in candidates:
|
| 72 |
+
if candidate.exists():
|
| 73 |
+
return candidate
|
| 74 |
+
|
| 75 |
+
raise FileNotFoundError(
|
| 76 |
+
"이미지 파일을 찾을 수 없습니다. "
|
| 77 |
+
f"확인된 경로: {[str(path) for path in candidates]}"
|
| 78 |
+
)
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def _load_page_image(page: Page) -> np.ndarray:
|
| 82 |
+
"""
|
| 83 |
+
페이지 객체에서 이미지를 로드하고, 해상도 정보를 갱신합니다.
|
| 84 |
+
"""
|
| 85 |
+
resolved_path = _resolve_image_path(page.image_path)
|
| 86 |
+
image = cv2.imread(str(resolved_path))
|
| 87 |
+
if image is None:
|
| 88 |
+
raise ValueError(f"이미지 파일을 읽을 수 없습니다: {resolved_path}")
|
| 89 |
+
|
| 90 |
+
height, width = image.shape[:2]
|
| 91 |
+
if page.image_width != width or page.image_height != height:
|
| 92 |
+
page.image_width = width
|
| 93 |
+
page.image_height = height
|
| 94 |
+
return image
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
def _layout_to_mock(elements: List[LayoutElement]) -> List[MockElement]:
|
| 98 |
+
"""
|
| 99 |
+
SQLAlchemy LayoutElement 객체를 sorter에서 사용하는 MockElement로 변환합니다.
|
| 100 |
+
"""
|
| 101 |
+
mock_elements: List[MockElement] = []
|
| 102 |
+
for element in elements:
|
| 103 |
+
mock = MockElement(
|
| 104 |
+
element_id=element.element_id,
|
| 105 |
+
class_name=element.class_name,
|
| 106 |
+
confidence=float(element.confidence or 0.0),
|
| 107 |
+
bbox_x=int(element.bbox_x),
|
| 108 |
+
bbox_y=int(element.bbox_y),
|
| 109 |
+
bbox_width=int(element.bbox_width),
|
| 110 |
+
bbox_height=int(element.bbox_height),
|
| 111 |
+
page_id=element.page_id,
|
| 112 |
+
)
|
| 113 |
+
mock_elements.append(mock)
|
| 114 |
+
return mock_elements
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
def _sync_layout_runtime_fields(
|
| 118 |
+
layout_elements: List[LayoutElement],
|
| 119 |
+
mock_elements: List[MockElement],
|
| 120 |
+
) -> List[LayoutElement]:
|
| 121 |
+
"""
|
| 122 |
+
sorter가 계산한 order_in_question, group_id 등을 실제 LayoutElement에 반영합니다.
|
| 123 |
+
"""
|
| 124 |
+
element_map: Dict[int, LayoutElement] = {
|
| 125 |
+
elem.element_id: elem for elem in layout_elements
|
| 126 |
+
}
|
| 127 |
+
synced_elements: List[LayoutElement] = []
|
| 128 |
+
|
| 129 |
+
for mock in mock_elements:
|
| 130 |
+
target = element_map.get(mock.element_id)
|
| 131 |
+
if not target:
|
| 132 |
+
logger.warning(
|
| 133 |
+
"정렬 결과에 존재하지만 DB에 없는 element_id=%s", mock.element_id
|
| 134 |
+
)
|
| 135 |
+
continue
|
| 136 |
+
|
| 137 |
+
setattr(target, "order_in_question", getattr(mock, "order_in_question", None))
|
| 138 |
+
setattr(target, "group_id", getattr(mock, "group_id", None))
|
| 139 |
+
setattr(target, "order_in_group", getattr(mock, "order_in_group", None))
|
| 140 |
+
setattr(target, "y_position", getattr(mock, "y_position", target.bbox_y))
|
| 141 |
+
setattr(target, "x_position", getattr(mock, "x_position", target.bbox_x))
|
| 142 |
+
setattr(
|
| 143 |
+
target,
|
| 144 |
+
"area",
|
| 145 |
+
getattr(mock, "area", target.bbox_width * target.bbox_height),
|
| 146 |
+
)
|
| 147 |
+
synced_elements.append(target)
|
| 148 |
+
|
| 149 |
+
return synced_elements
|
| 150 |
+
|
| 151 |
+
|
| 152 |
+
def _update_page_status(
|
| 153 |
+
page: Page,
|
| 154 |
+
*,
|
| 155 |
+
status: str,
|
| 156 |
+
processing_time: float,
|
| 157 |
+
) -> None:
|
| 158 |
+
"""
|
| 159 |
+
페이지의 상태/처리시간/분석 완료 시간을 갱신합니다.
|
| 160 |
+
"""
|
| 161 |
+
page.analysis_status = status
|
| 162 |
+
page.processing_time = processing_time
|
| 163 |
+
page.analyzed_at = datetime.utcnow()
|
| 164 |
+
|
| 165 |
+
|
| 166 |
+
def _update_project_status(project: Project, status: str) -> None:
|
| 167 |
+
"""
|
| 168 |
+
프로젝트 상태를 갱신합니다.
|
| 169 |
+
"""
|
| 170 |
+
project.status = status
|
| 171 |
+
project.updated_at = datetime.utcnow()
|
| 172 |
+
|
| 173 |
+
|
| 174 |
+
async def _process_single_page_async(
|
| 175 |
+
*,
|
| 176 |
+
db: Session,
|
| 177 |
+
project: Project,
|
| 178 |
+
page: Page,
|
| 179 |
+
formatter: TextFormatter,
|
| 180 |
+
analysis_service: AnalysisService,
|
| 181 |
+
use_ai_descriptions: bool,
|
| 182 |
+
api_key: Optional[str],
|
| 183 |
+
ai_max_concurrency: int = DEFAULT_AI_CONCURRENCY,
|
| 184 |
+
) -> Dict[str, Any]:
|
| 185 |
+
"""
|
| 186 |
+
개별 페이지에 대한 전체 파이프라인을 실행하고 결과 요약을 반환합니다.
|
| 187 |
+
"""
|
| 188 |
+
logger.info(
|
| 189 |
+
"페이지 분석 시작: project_id=%s / page_id=%s", project.project_id, page.page_id
|
| 190 |
+
)
|
| 191 |
+
page_start = time.time()
|
| 192 |
+
|
| 193 |
+
summary: Dict[str, Any] = {
|
| 194 |
+
"page_id": page.page_id,
|
| 195 |
+
"page_number": page.page_number,
|
| 196 |
+
"status": "error",
|
| 197 |
+
"message": "",
|
| 198 |
+
"layout_count": 0,
|
| 199 |
+
"ocr_count": 0,
|
| 200 |
+
"ai_description_count": 0,
|
| 201 |
+
"processing_time": 0.0,
|
| 202 |
+
}
|
| 203 |
+
|
| 204 |
+
try:
|
| 205 |
+
image = _load_page_image(page)
|
| 206 |
+
|
| 207 |
+
layout_elements = analysis_service.analyze_layout(
|
| 208 |
+
image=image,
|
| 209 |
+
page_id=page.page_id,
|
| 210 |
+
db=db,
|
| 211 |
+
model_choice=analysis_service.model_choice,
|
| 212 |
+
)
|
| 213 |
+
if not layout_elements:
|
| 214 |
+
raise ValueError("레이아웃 분석 결과가 비어 있습니다.")
|
| 215 |
+
summary["layout_count"] = len(layout_elements)
|
| 216 |
+
|
| 217 |
+
text_contents = analysis_service.perform_ocr(
|
| 218 |
+
image=image,
|
| 219 |
+
layout_elements=layout_elements,
|
| 220 |
+
db=db,
|
| 221 |
+
)
|
| 222 |
+
summary["ocr_count"] = len(text_contents)
|
| 223 |
+
|
| 224 |
+
ai_descriptions: Dict[int, str] = {}
|
| 225 |
+
if use_ai_descriptions:
|
| 226 |
+
# API 키: 요청 파라미터 우선, 없으면 환경변수에서 로드
|
| 227 |
+
effective_api_key = api_key or os.getenv("OPENAI_API_KEY")
|
| 228 |
+
if effective_api_key:
|
| 229 |
+
logger.info(f"AI 설명 생성 시작: page_id={page.page_id}")
|
| 230 |
+
try:
|
| 231 |
+
ai_descriptions = await analysis_service.call_openai_api_async(
|
| 232 |
+
image=image,
|
| 233 |
+
layout_elements=layout_elements,
|
| 234 |
+
api_key=effective_api_key,
|
| 235 |
+
db=db,
|
| 236 |
+
max_concurrent_requests=ai_max_concurrency,
|
| 237 |
+
)
|
| 238 |
+
summary["ai_description_count"] = len(ai_descriptions)
|
| 239 |
+
logger.info(
|
| 240 |
+
f"AI 설명 생성 완료: {len(ai_descriptions)}개 요소 처리"
|
| 241 |
+
)
|
| 242 |
+
except Exception as ai_error:
|
| 243 |
+
logger.error(
|
| 244 |
+
"AI 설명 생성 비동기 처리 실패: page_id=%s / error=%s",
|
| 245 |
+
page.page_id,
|
| 246 |
+
ai_error,
|
| 247 |
+
)
|
| 248 |
+
else:
|
| 249 |
+
logger.warning(
|
| 250 |
+
f"AI 설명 생성 요청되었으나 API 키가 없습니다 (page_id={page.page_id})"
|
| 251 |
+
)
|
| 252 |
+
|
| 253 |
+
mock_elements = _layout_to_mock(layout_elements)
|
| 254 |
+
sorted_mock = sort_layout_elements(
|
| 255 |
+
mock_elements,
|
| 256 |
+
document_type=formatter.document_type,
|
| 257 |
+
page_width=page.image_width or 0,
|
| 258 |
+
page_height=page.image_height or 0,
|
| 259 |
+
)
|
| 260 |
+
synced_layouts = _sync_layout_runtime_fields(layout_elements, sorted_mock)
|
| 261 |
+
|
| 262 |
+
save_sorting_results_to_db(db, page.page_id, synced_layouts)
|
| 263 |
+
|
| 264 |
+
formatted_text = formatter.format_page(
|
| 265 |
+
synced_layouts,
|
| 266 |
+
text_contents,
|
| 267 |
+
ai_descriptions=ai_descriptions,
|
| 268 |
+
)
|
| 269 |
+
create_text_version(db, page, formatted_text or "")
|
| 270 |
+
|
| 271 |
+
processing_time = time.time() - page_start
|
| 272 |
+
_update_page_status(page, status="completed", processing_time=processing_time)
|
| 273 |
+
summary["status"] = "completed"
|
| 274 |
+
summary["processing_time"] = processing_time
|
| 275 |
+
summary["message"] = "success"
|
| 276 |
+
|
| 277 |
+
db.commit()
|
| 278 |
+
return summary
|
| 279 |
+
|
| 280 |
+
except Exception as error: # pylint: disable=broad-except
|
| 281 |
+
logger.error(f"페이지 분석 실패: page_id={page.page_id} / error={str(error)}")
|
| 282 |
+
logger.exception("상세 스택 트레이스:") # 전체 스택 출력
|
| 283 |
+
db.rollback()
|
| 284 |
+
processing_time = time.time() - page_start
|
| 285 |
+
_update_page_status(page, status="error", processing_time=processing_time)
|
| 286 |
+
summary["processing_time"] = processing_time
|
| 287 |
+
summary["message"] = str(error)
|
| 288 |
+
db.commit()
|
| 289 |
+
return summary
|
| 290 |
+
|
| 291 |
+
|
| 292 |
+
def _process_single_page(
|
| 293 |
+
*,
|
| 294 |
+
db: Session,
|
| 295 |
+
project: Project,
|
| 296 |
+
page: Page,
|
| 297 |
+
formatter: TextFormatter,
|
| 298 |
+
analysis_service: AnalysisService,
|
| 299 |
+
use_ai_descriptions: bool,
|
| 300 |
+
api_key: Optional[str],
|
| 301 |
+
ai_max_concurrency: int = DEFAULT_AI_CONCURRENCY,
|
| 302 |
+
) -> Dict[str, Any]:
|
| 303 |
+
"""
|
| 304 |
+
동기 컨텍스트 호환용 래퍼.
|
| 305 |
+
"""
|
| 306 |
+
return asyncio.run(
|
| 307 |
+
_process_single_page_async(
|
| 308 |
+
db=db,
|
| 309 |
+
project=project,
|
| 310 |
+
page=page,
|
| 311 |
+
formatter=formatter,
|
| 312 |
+
analysis_service=analysis_service,
|
| 313 |
+
use_ai_descriptions=use_ai_descriptions,
|
| 314 |
+
api_key=api_key,
|
| 315 |
+
ai_max_concurrency=ai_max_concurrency,
|
| 316 |
+
)
|
| 317 |
+
)
|
| 318 |
+
|
| 319 |
+
|
| 320 |
+
# -----------------------------------------------------------------------------
|
| 321 |
+
# 공개 API
|
| 322 |
+
# -----------------------------------------------------------------------------
|
| 323 |
+
|
| 324 |
+
|
| 325 |
+
async def analyze_project_batch_async(
|
| 326 |
+
db: Session,
|
| 327 |
+
project_id: int,
|
| 328 |
+
*,
|
| 329 |
+
use_ai_descriptions: bool = True,
|
| 330 |
+
api_key: Optional[str] = None,
|
| 331 |
+
ai_max_concurrency: int = DEFAULT_AI_CONCURRENCY,
|
| 332 |
+
) -> Dict[str, Any]:
|
| 333 |
+
"""
|
| 334 |
+
프로젝트 내 'pending' 상태 페이지를 순차적으로 분석하고 결과 요약을 반환합니다.
|
| 335 |
+
"""
|
| 336 |
+
logger.info("프로젝트 배치 분석 시작: project_id=%s", project_id)
|
| 337 |
+
started_at = time.time()
|
| 338 |
+
|
| 339 |
+
project = (
|
| 340 |
+
db.query(Project)
|
| 341 |
+
.options(selectinload(Project.pages))
|
| 342 |
+
.filter(Project.project_id == project_id)
|
| 343 |
+
.one_or_none()
|
| 344 |
+
)
|
| 345 |
+
if not project:
|
| 346 |
+
raise ValueError(f"프로젝트 ID {project_id}를 찾을 수 없습니다.")
|
| 347 |
+
|
| 348 |
+
pending_pages = [
|
| 349 |
+
page for page in project.pages if page.analysis_status in {"pending", "error"}
|
| 350 |
+
]
|
| 351 |
+
pending_pages.sort(key=lambda p: p.page_number)
|
| 352 |
+
|
| 353 |
+
result_summary: Dict[str, Any] = {
|
| 354 |
+
"project_id": project.project_id,
|
| 355 |
+
"project_status_before": project.status,
|
| 356 |
+
"processed_pages": 0,
|
| 357 |
+
"successful_pages": 0,
|
| 358 |
+
"failed_pages": 0,
|
| 359 |
+
"total_pages": len(pending_pages),
|
| 360 |
+
"status": "completed" if pending_pages else "no_pending_pages",
|
| 361 |
+
"page_results": [],
|
| 362 |
+
"total_time": 0.0,
|
| 363 |
+
}
|
| 364 |
+
|
| 365 |
+
if not pending_pages:
|
| 366 |
+
logger.warning("분석할 페이지가 없습니다. project_id=%s", project.project_id)
|
| 367 |
+
return result_summary
|
| 368 |
+
|
| 369 |
+
_update_project_status(project, "in_progress")
|
| 370 |
+
db.commit()
|
| 371 |
+
|
| 372 |
+
analysis_service = _get_analysis_service()
|
| 373 |
+
formatter = TextFormatter(
|
| 374 |
+
doc_type_id=project.doc_type_id,
|
| 375 |
+
db=db,
|
| 376 |
+
use_db_rules=True,
|
| 377 |
+
)
|
| 378 |
+
|
| 379 |
+
for page in pending_pages:
|
| 380 |
+
page_summary = await _process_single_page_async(
|
| 381 |
+
db=db,
|
| 382 |
+
project=project,
|
| 383 |
+
page=page,
|
| 384 |
+
formatter=formatter,
|
| 385 |
+
analysis_service=analysis_service,
|
| 386 |
+
use_ai_descriptions=use_ai_descriptions,
|
| 387 |
+
api_key=api_key,
|
| 388 |
+
ai_max_concurrency=ai_max_concurrency,
|
| 389 |
+
)
|
| 390 |
+
result_summary["page_results"].append(page_summary)
|
| 391 |
+
result_summary["processed_pages"] += 1
|
| 392 |
+
if page_summary["status"] == "completed":
|
| 393 |
+
result_summary["successful_pages"] += 1
|
| 394 |
+
else:
|
| 395 |
+
result_summary["failed_pages"] += 1
|
| 396 |
+
|
| 397 |
+
if result_summary["failed_pages"] == 0:
|
| 398 |
+
final_status = "completed"
|
| 399 |
+
elif result_summary["successful_pages"] == 0:
|
| 400 |
+
final_status = "error"
|
| 401 |
+
else:
|
| 402 |
+
final_status = "partial"
|
| 403 |
+
|
| 404 |
+
_update_project_status(project, final_status)
|
| 405 |
+
db.commit()
|
| 406 |
+
|
| 407 |
+
result_summary["status"] = final_status
|
| 408 |
+
result_summary["project_status_after"] = project.status
|
| 409 |
+
result_summary["total_time"] = time.time() - started_at
|
| 410 |
+
logger.info(
|
| 411 |
+
"프로젝트 배치 분석 종료: project_id=%s / status=%s / success=%s / fail=%s / %.2fs",
|
| 412 |
+
project.project_id,
|
| 413 |
+
final_status,
|
| 414 |
+
result_summary["successful_pages"],
|
| 415 |
+
result_summary["failed_pages"],
|
| 416 |
+
result_summary["total_time"],
|
| 417 |
+
)
|
| 418 |
+
return result_summary
|
| 419 |
+
|
| 420 |
+
|
| 421 |
+
def analyze_project_batch(
|
| 422 |
+
db: Session,
|
| 423 |
+
project_id: int,
|
| 424 |
+
*,
|
| 425 |
+
use_ai_descriptions: bool = True,
|
| 426 |
+
api_key: Optional[str] = None,
|
| 427 |
+
ai_max_concurrency: int = DEFAULT_AI_CONCURRENCY,
|
| 428 |
+
) -> Dict[str, Any]:
|
| 429 |
+
"""
|
| 430 |
+
동기 컨텍스트 호환용 래퍼.
|
| 431 |
+
"""
|
| 432 |
+
return asyncio.run(
|
| 433 |
+
analyze_project_batch_async(
|
| 434 |
+
db=db,
|
| 435 |
+
project_id=project_id,
|
| 436 |
+
use_ai_descriptions=use_ai_descriptions,
|
| 437 |
+
api_key=api_key,
|
| 438 |
+
ai_max_concurrency=ai_max_concurrency,
|
| 439 |
+
)
|
| 440 |
+
)
|
| 441 |
+
|
| 442 |
+
|
| 443 |
+
__all__ = [
|
| 444 |
+
"analyze_project_batch",
|
| 445 |
+
"analyze_project_batch_async",
|
| 446 |
+
"_get_analysis_service",
|
| 447 |
+
"_process_single_page",
|
| 448 |
+
"_process_single_page_async",
|
| 449 |
+
"DEFAULT_AI_CONCURRENCY",
|
| 450 |
+
]
|
app/services/download_service.py
ADDED
|
@@ -0,0 +1,257 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Project Download Service
|
| 3 |
+
========================
|
| 4 |
+
|
| 5 |
+
데이터베이스에 저장된 분석 결과를 활용하여 프로젝트 단위의 통합 텍스트 및
|
| 6 |
+
Word 문서를 생성합니다. CombinedResult 테이블을 캐시로 사용하며, 각 페이지의
|
| 7 |
+
최신(TextVersion.is_current=True) 자동 포맷팅 텍스트를 모아 제공합니다.
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
import io
|
| 13 |
+
from datetime import datetime
|
| 14 |
+
from typing import Dict, List, Optional, Tuple
|
| 15 |
+
|
| 16 |
+
from loguru import logger
|
| 17 |
+
from sqlalchemy.orm import Session, selectinload
|
| 18 |
+
|
| 19 |
+
from ..models import CombinedResult, Page, Project, TextVersion
|
| 20 |
+
|
| 21 |
+
try:
|
| 22 |
+
from docx import Document
|
| 23 |
+
from docx.enum.text import WD_ALIGN_PARAGRAPH
|
| 24 |
+
except ImportError: # pragma: no cover - optional dependency
|
| 25 |
+
Document = None # type: ignore[assignment]
|
| 26 |
+
WD_ALIGN_PARAGRAPH = None # type: ignore[assignment]
|
| 27 |
+
logger.error("python-docx 라이브러리가 설치되지 않았습니다. pip install python-docx")
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
# -----------------------------------------------------------------------------
|
| 31 |
+
# 헬퍼 함수
|
| 32 |
+
# -----------------------------------------------------------------------------
|
| 33 |
+
|
| 34 |
+
def _fetch_project_with_pages(db: Session, project_id: int) -> Project:
|
| 35 |
+
project = (
|
| 36 |
+
db.query(Project)
|
| 37 |
+
.options(selectinload(Project.pages))
|
| 38 |
+
.filter(Project.project_id == project_id)
|
| 39 |
+
.one_or_none()
|
| 40 |
+
)
|
| 41 |
+
if not project:
|
| 42 |
+
raise ValueError(f"프로젝트 ID {project_id}를 찾을 수 없습니다.")
|
| 43 |
+
return project
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def _fetch_current_text_versions(db: Session, page_ids: List[int]) -> Dict[int, TextVersion]:
|
| 47 |
+
if not page_ids:
|
| 48 |
+
return {}
|
| 49 |
+
versions = (
|
| 50 |
+
db.query(TextVersion)
|
| 51 |
+
.filter(
|
| 52 |
+
TextVersion.page_id.in_(page_ids),
|
| 53 |
+
TextVersion.is_current.is_(True),
|
| 54 |
+
)
|
| 55 |
+
.all()
|
| 56 |
+
)
|
| 57 |
+
return {version.page_id: version for version in versions}
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def _latest_text_timestamp(versions: Dict[int, TextVersion]) -> Optional[datetime]:
|
| 61 |
+
timestamps = [version.created_at for version in versions.values() if version.created_at]
|
| 62 |
+
return max(timestamps) if timestamps else None
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def _combined_result_is_fresh(
|
| 66 |
+
combined_result: CombinedResult,
|
| 67 |
+
latest_text_time: Optional[datetime],
|
| 68 |
+
) -> bool:
|
| 69 |
+
if latest_text_time is None:
|
| 70 |
+
return combined_result.combined_text is not None
|
| 71 |
+
if combined_result.updated_at is None:
|
| 72 |
+
return False
|
| 73 |
+
return combined_result.updated_at >= latest_text_time
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def _format_combined_sections(pages: List[Page], versions: Dict[int, TextVersion]) -> Tuple[str, Dict[str, int]]:
|
| 77 |
+
sections: List[str] = []
|
| 78 |
+
total_words = 0
|
| 79 |
+
total_characters = 0
|
| 80 |
+
|
| 81 |
+
for page in sorted(pages, key=lambda p: p.page_number):
|
| 82 |
+
version = versions.get(page.page_id)
|
| 83 |
+
header = f"─── 페이지 {page.page_number}"
|
| 84 |
+
if version:
|
| 85 |
+
header += f" (Version: {version.version_number} - {version.version_type}) ───"
|
| 86 |
+
content = version.content or ""
|
| 87 |
+
total_words += len(content.split())
|
| 88 |
+
total_characters += len(content)
|
| 89 |
+
else:
|
| 90 |
+
header += " (내용 없음) ───"
|
| 91 |
+
content = ""
|
| 92 |
+
sections.append(f"{header}\n\n{content}".rstrip())
|
| 93 |
+
|
| 94 |
+
combined_text = "\n\n".join(sections)
|
| 95 |
+
stats = {
|
| 96 |
+
"total_pages": len(pages),
|
| 97 |
+
"total_words": total_words,
|
| 98 |
+
"total_characters": total_characters,
|
| 99 |
+
}
|
| 100 |
+
return combined_text, stats
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
def _upsert_combined_result(
|
| 104 |
+
db: Session,
|
| 105 |
+
project_id: int,
|
| 106 |
+
combined_text: str,
|
| 107 |
+
stats: Dict[str, int],
|
| 108 |
+
) -> CombinedResult:
|
| 109 |
+
record = (
|
| 110 |
+
db.query(CombinedResult)
|
| 111 |
+
.filter(CombinedResult.project_id == project_id)
|
| 112 |
+
.one_or_none()
|
| 113 |
+
)
|
| 114 |
+
now = datetime.utcnow()
|
| 115 |
+
if record:
|
| 116 |
+
record.combined_text = combined_text
|
| 117 |
+
record.combined_stats = stats
|
| 118 |
+
record.updated_at = now
|
| 119 |
+
else:
|
| 120 |
+
record = CombinedResult(
|
| 121 |
+
project_id=project_id,
|
| 122 |
+
combined_text=combined_text,
|
| 123 |
+
combined_stats=stats,
|
| 124 |
+
generated_at=now,
|
| 125 |
+
updated_at=now,
|
| 126 |
+
)
|
| 127 |
+
db.add(record)
|
| 128 |
+
db.commit()
|
| 129 |
+
db.refresh(record)
|
| 130 |
+
return record
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
# -----------------------------------------------------------------------------
|
| 134 |
+
# 공개 API
|
| 135 |
+
# -----------------------------------------------------------------------------
|
| 136 |
+
|
| 137 |
+
def generate_combined_text(
|
| 138 |
+
db: Session,
|
| 139 |
+
project_id: int,
|
| 140 |
+
*,
|
| 141 |
+
use_cache: bool = True,
|
| 142 |
+
) -> Dict[str, object]:
|
| 143 |
+
"""
|
| 144 |
+
프로젝트의 최신 텍스트 버전을 통합하여 반환합니다.
|
| 145 |
+
CombinedResult 테이블을 캐시로 사용합니다.
|
| 146 |
+
"""
|
| 147 |
+
project = _fetch_project_with_pages(db, project_id)
|
| 148 |
+
page_ids = [page.page_id for page in project.pages]
|
| 149 |
+
versions = _fetch_current_text_versions(db, page_ids)
|
| 150 |
+
latest_version_time = _latest_text_timestamp(versions)
|
| 151 |
+
|
| 152 |
+
combined_record = (
|
| 153 |
+
db.query(CombinedResult)
|
| 154 |
+
.filter(CombinedResult.project_id == project_id)
|
| 155 |
+
.one_or_none()
|
| 156 |
+
)
|
| 157 |
+
|
| 158 |
+
if use_cache and combined_record and _combined_result_is_fresh(combined_record, latest_version_time):
|
| 159 |
+
logger.info("CombinedResult 캐시 사용: project_id=%s", project_id)
|
| 160 |
+
stats = combined_record.combined_stats or {}
|
| 161 |
+
generated_at = combined_record.updated_at or combined_record.generated_at or datetime.utcnow()
|
| 162 |
+
return {
|
| 163 |
+
"project_id": project_id,
|
| 164 |
+
"project_name": project.project_name,
|
| 165 |
+
"combined_text": combined_record.combined_text or "",
|
| 166 |
+
"stats": stats,
|
| 167 |
+
"generated_at": generated_at,
|
| 168 |
+
}
|
| 169 |
+
|
| 170 |
+
combined_text, stats = _format_combined_sections(project.pages, versions)
|
| 171 |
+
combined_record = _upsert_combined_result(db, project_id, combined_text, stats)
|
| 172 |
+
|
| 173 |
+
return {
|
| 174 |
+
"project_id": project_id,
|
| 175 |
+
"project_name": project.project_name,
|
| 176 |
+
"combined_text": combined_text,
|
| 177 |
+
"stats": stats,
|
| 178 |
+
"generated_at": combined_record.updated_at or combined_record.generated_at or datetime.utcnow(),
|
| 179 |
+
}
|
| 180 |
+
|
| 181 |
+
|
| 182 |
+
def generate_word_document(
|
| 183 |
+
db: Session,
|
| 184 |
+
project_id: int,
|
| 185 |
+
*,
|
| 186 |
+
use_cache: bool = True,
|
| 187 |
+
) -> Tuple[str, io.BytesIO]:
|
| 188 |
+
"""
|
| 189 |
+
통합 텍스트를 기반으로 Word(.docx) 문서를 생성하고 파일 이름과 스트림을 반환합니다.
|
| 190 |
+
"""
|
| 191 |
+
if Document is None or WD_ALIGN_PARAGRAPH is None:
|
| 192 |
+
raise ImportError("python-docx 라이브러리가 필요합니다. pip install python-docx")
|
| 193 |
+
|
| 194 |
+
combined_data = generate_combined_text(db, project_id, use_cache=use_cache)
|
| 195 |
+
project_name = combined_data.get("project_name") or f"프로젝트 {project_id}"
|
| 196 |
+
combined_text = combined_data.get("combined_text", "")
|
| 197 |
+
|
| 198 |
+
document = Document()
|
| 199 |
+
title = document.add_heading(project_name, level=0)
|
| 200 |
+
title.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
| 201 |
+
|
| 202 |
+
meta_paragraph = document.add_paragraph(
|
| 203 |
+
f"생성일: {datetime.now().strftime('%Y-%m-%d %H:%M')}"
|
| 204 |
+
)
|
| 205 |
+
meta_paragraph.alignment = WD_ALIGN_PARAGRAPH.RIGHT
|
| 206 |
+
|
| 207 |
+
stats = combined_data.get("stats", {})
|
| 208 |
+
total_pages = stats.get("total_pages", 0)
|
| 209 |
+
stats_paragraph = document.add_paragraph(f"총 페이지: {total_pages}개")
|
| 210 |
+
stats_paragraph.alignment = WD_ALIGN_PARAGRAPH.RIGHT
|
| 211 |
+
|
| 212 |
+
document.add_paragraph("─" * 60)
|
| 213 |
+
|
| 214 |
+
sections = [segment.strip() for segment in combined_text.split("─── 페이지 ") if segment.strip()]
|
| 215 |
+
for index, section in enumerate(sections):
|
| 216 |
+
lines = section.split("\n")
|
| 217 |
+
header = lines[0]
|
| 218 |
+
content_lines = lines[1:]
|
| 219 |
+
|
| 220 |
+
document.add_heading(f"페이지 {header.split()[0]}", level=2)
|
| 221 |
+
for paragraph in content_lines:
|
| 222 |
+
paragraph = paragraph.strip()
|
| 223 |
+
if paragraph:
|
| 224 |
+
document.add_paragraph(paragraph)
|
| 225 |
+
if index < len(sections) - 1:
|
| 226 |
+
document.add_page_break()
|
| 227 |
+
|
| 228 |
+
file_stream = io.BytesIO()
|
| 229 |
+
document.save(file_stream)
|
| 230 |
+
file_stream.seek(0)
|
| 231 |
+
|
| 232 |
+
filename = f"SmartEyeSsen_{project_id}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.docx"
|
| 233 |
+
return filename, file_stream
|
| 234 |
+
|
| 235 |
+
|
| 236 |
+
def generate_document(
|
| 237 |
+
db: Session,
|
| 238 |
+
project_id: int,
|
| 239 |
+
*,
|
| 240 |
+
output: str = "text",
|
| 241 |
+
use_cache: bool = True,
|
| 242 |
+
):
|
| 243 |
+
"""
|
| 244 |
+
통합 텍스트 또는 Word 문서를 생성합니다.
|
| 245 |
+
output="text" -> dict 반환 (combined_text 등)
|
| 246 |
+
output="docx" -> (filename, BytesIO) 반환
|
| 247 |
+
"""
|
| 248 |
+
if output == "docx":
|
| 249 |
+
return generate_word_document(db, project_id, use_cache=use_cache)
|
| 250 |
+
return generate_combined_text(db, project_id, use_cache=use_cache)
|
| 251 |
+
|
| 252 |
+
|
| 253 |
+
__all__ = [
|
| 254 |
+
"generate_document",
|
| 255 |
+
"generate_combined_text",
|
| 256 |
+
"generate_word_document",
|
| 257 |
+
]
|
app/services/formatter.py
ADDED
|
@@ -0,0 +1,403 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# -*- coding: utf-8 -*-
|
| 2 |
+
"""
|
| 3 |
+
앵커 기반 TextFormatter
|
| 4 |
+
========================
|
| 5 |
+
|
| 6 |
+
정렬기(sorter.py)가 부여한 앵커/그룹 정보를 활용하여
|
| 7 |
+
사람이 읽기 쉬운 텍스트를 생성한다.
|
| 8 |
+
|
| 9 |
+
기본 규칙은 코드 내에 정의되어 있으며, formatting_rules DB 테이블을
|
| 10 |
+
통해 오버라이드할 수 있도록 확장 지점을 남겨둔다.
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
from __future__ import annotations
|
| 14 |
+
|
| 15 |
+
from collections import OrderedDict
|
| 16 |
+
from dataclasses import dataclass
|
| 17 |
+
from typing import Dict, Iterable, List, Optional, Tuple, Union, TYPE_CHECKING
|
| 18 |
+
|
| 19 |
+
from loguru import logger
|
| 20 |
+
|
| 21 |
+
from .formatter_rules import (
|
| 22 |
+
RuleConfig,
|
| 23 |
+
get_rules_for_document_type,
|
| 24 |
+
override_rules_with_db,
|
| 25 |
+
fetch_db_rules,
|
| 26 |
+
)
|
| 27 |
+
from .formatter_utils import (
|
| 28 |
+
RenderContext,
|
| 29 |
+
apply_rule,
|
| 30 |
+
clean_output,
|
| 31 |
+
normalize_ai_descriptions,
|
| 32 |
+
ocr_inputs_to_dict,
|
| 33 |
+
split_first_line,
|
| 34 |
+
)
|
| 35 |
+
|
| 36 |
+
if TYPE_CHECKING:
|
| 37 |
+
from sqlalchemy.orm import Session
|
| 38 |
+
from ..models import LayoutElement, TextContent
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
DOC_TYPE_ID_MAP = {
|
| 42 |
+
1: "question_based", # worksheet (문제지)
|
| 43 |
+
2: "reading_order", # 일반 문서
|
| 44 |
+
3: "reading_order", # 기타 확장용(표/차트 등)
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
ANCHOR_CLASSES = {"question type", "question number", "second_question_number"}
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
@dataclass
|
| 51 |
+
class ElementGroupBundle:
|
| 52 |
+
"""
|
| 53 |
+
앵커와 자식 요소들을 묶은 번들.
|
| 54 |
+
|
| 55 |
+
Note: LayoutElement 타입 대신 Any 사용 (순환 import 방지)
|
| 56 |
+
"""
|
| 57 |
+
|
| 58 |
+
anchor: Optional[any] # LayoutElement
|
| 59 |
+
children: List[any] # List[LayoutElement]
|
| 60 |
+
ordered_elements: List[any] # List[LayoutElement]
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
class TextFormatter:
|
| 64 |
+
"""
|
| 65 |
+
정렬된 LayoutElement + OCR 텍스트를 받아 최종 포맷팅된 문자열을 생성한다.
|
| 66 |
+
"""
|
| 67 |
+
|
| 68 |
+
def __init__(
|
| 69 |
+
self,
|
| 70 |
+
doc_type_id: int,
|
| 71 |
+
*,
|
| 72 |
+
db: Optional["Session"] = None,
|
| 73 |
+
use_db_rules: bool = False,
|
| 74 |
+
db_rule_records: Optional[Dict[str, Dict[str, str]]] = None,
|
| 75 |
+
) -> None:
|
| 76 |
+
"""
|
| 77 |
+
Args:
|
| 78 |
+
doc_type_id: document_types 테이블의 doc_type_id (1=문제지, 2=일반 문서).
|
| 79 |
+
db: SQLAlchemy 세션 (use_db_rules=True일 때 필수).
|
| 80 |
+
use_db_rules: True면 DB에서 덮어쓴 규칙을 반영.
|
| 81 |
+
db_rule_records: 외부에서 주입한 규칙 덮어쓰기 정보 (테스트용).
|
| 82 |
+
"""
|
| 83 |
+
self.doc_type_id = doc_type_id
|
| 84 |
+
self.document_type = DOC_TYPE_ID_MAP.get(doc_type_id, "question_based")
|
| 85 |
+
base_rules = get_rules_for_document_type(self.document_type)
|
| 86 |
+
|
| 87 |
+
# DB 규칙 오버라이드 적용
|
| 88 |
+
if use_db_rules and db:
|
| 89 |
+
db_records = fetch_db_rules(db, doc_type_id)
|
| 90 |
+
self.rules = override_rules_with_db(base_rules, db_records)
|
| 91 |
+
elif db_rule_records:
|
| 92 |
+
# 테스트용: 직접 주입된 규칙 사용
|
| 93 |
+
self.rules = override_rules_with_db(base_rules, db_rule_records)
|
| 94 |
+
else:
|
| 95 |
+
self.rules = base_rules
|
| 96 |
+
|
| 97 |
+
self.use_db_rules = use_db_rules
|
| 98 |
+
self.db = db
|
| 99 |
+
|
| 100 |
+
# ------------------------------------------------------------------
|
| 101 |
+
# 퍼블릭 API
|
| 102 |
+
# ------------------------------------------------------------------
|
| 103 |
+
def format_page(
|
| 104 |
+
self,
|
| 105 |
+
sorted_elements: List["LayoutElement"],
|
| 106 |
+
ocr_texts: Union[Dict[int, str], List["TextContent"]],
|
| 107 |
+
*,
|
| 108 |
+
ai_descriptions: Optional[Dict[int, str]] = None,
|
| 109 |
+
metadata: Optional[Dict[str, Union[str, int]]] = None,
|
| 110 |
+
) -> str:
|
| 111 |
+
"""
|
| 112 |
+
Args:
|
| 113 |
+
sorted_elements: sorter가 반환한 LayoutElement 리스트 (order_in_question 필수).
|
| 114 |
+
ocr_texts: element_id → 텍스트 딕셔너리 또는 TextContent 리스트.
|
| 115 |
+
ai_descriptions: AI가 생성한 시각 자료 설명 (선택).
|
| 116 |
+
metadata: 출력 상단 등에 활용할 추가 정보 (현재는 미사용).
|
| 117 |
+
"""
|
| 118 |
+
logger.info(
|
| 119 |
+
f"[Formatter] format_page 시작: sorted_elements={len(sorted_elements)}, "
|
| 120 |
+
f"ocr_texts_count={len(ocr_texts) if isinstance(ocr_texts, dict) else len(ocr_texts)}, "
|
| 121 |
+
f"ai_descriptions_count={len(ai_descriptions) if ai_descriptions else 0}"
|
| 122 |
+
)
|
| 123 |
+
|
| 124 |
+
if not sorted_elements:
|
| 125 |
+
logger.warning("[Formatter] sorted_elements가 비어있음 - 빈 문자열 반환")
|
| 126 |
+
return ""
|
| 127 |
+
|
| 128 |
+
ocr_dict = ocr_inputs_to_dict(ocr_texts)
|
| 129 |
+
ai_dict = normalize_ai_descriptions(ai_descriptions)
|
| 130 |
+
logger.debug(f"[Formatter] ocr_dict keys: {list(ocr_dict.keys())[:10]}")
|
| 131 |
+
logger.debug(f"[Formatter] ai_dict keys: {list(ai_dict.keys())[:10]}")
|
| 132 |
+
|
| 133 |
+
context = RenderContext(ocr_dict, ai_dict, self.rules)
|
| 134 |
+
|
| 135 |
+
valid_elements = self._filter_elements(sorted_elements)
|
| 136 |
+
logger.info(f"[Formatter] 필터링 후: {len(valid_elements)}개 유효 요소")
|
| 137 |
+
|
| 138 |
+
if not valid_elements:
|
| 139 |
+
logger.warning("[Formatter] 포맷팅 대상 요소가 없습니다 - 빈 문자열 반환")
|
| 140 |
+
return ""
|
| 141 |
+
|
| 142 |
+
if self.document_type == "reading_order":
|
| 143 |
+
rendered = self._render_reading_order(valid_elements, context)
|
| 144 |
+
result = clean_output("".join(rendered))
|
| 145 |
+
logger.info(f"[Formatter] reading_order 포맷팅 완료: {len(result)}자")
|
| 146 |
+
return result
|
| 147 |
+
|
| 148 |
+
group_bundles = self._build_group_bundles(valid_elements)
|
| 149 |
+
logger.info(f"[Formatter] 그룹 번들 생성: {len(group_bundles)}개")
|
| 150 |
+
|
| 151 |
+
rendered_blocks = [
|
| 152 |
+
self._render_group(bundle, context) for bundle in group_bundles
|
| 153 |
+
]
|
| 154 |
+
combined = "".join(rendered_blocks)
|
| 155 |
+
result = clean_output(combined)
|
| 156 |
+
|
| 157 |
+
logger.info(
|
| 158 |
+
f"[Formatter] question_based 포맷팅 완료: {len(result)}자, "
|
| 159 |
+
f"{len(group_bundles)}개 그룹"
|
| 160 |
+
)
|
| 161 |
+
return result
|
| 162 |
+
|
| 163 |
+
# ------------------------------------------------------------------
|
| 164 |
+
# 내부 유틸 (공통)
|
| 165 |
+
# ------------------------------------------------------------------
|
| 166 |
+
@staticmethod
|
| 167 |
+
def _filter_elements(elements: Iterable[MockElement]) -> List[MockElement]:
|
| 168 |
+
"""
|
| 169 |
+
면적 또는 필수 속성이 없는 요소를 제외한다.
|
| 170 |
+
"""
|
| 171 |
+
filtered: List[MockElement] = []
|
| 172 |
+
for element in elements:
|
| 173 |
+
try:
|
| 174 |
+
area = getattr(element, "area")
|
| 175 |
+
except AttributeError:
|
| 176 |
+
area = element.bbox_width * element.bbox_height
|
| 177 |
+
if area <= 0:
|
| 178 |
+
continue
|
| 179 |
+
filtered.append(element)
|
| 180 |
+
return filtered
|
| 181 |
+
|
| 182 |
+
@staticmethod
|
| 183 |
+
def _element_sort_key(element: MockElement) -> Tuple[int, int, int, int]:
|
| 184 |
+
large_offset = 10**7
|
| 185 |
+
order_in_question = getattr(element, "order_in_question", None)
|
| 186 |
+
order_key = (
|
| 187 |
+
order_in_question
|
| 188 |
+
if order_in_question is not None
|
| 189 |
+
else large_offset + int(getattr(element, "y_position", 0))
|
| 190 |
+
)
|
| 191 |
+
return (
|
| 192 |
+
order_key,
|
| 193 |
+
int(getattr(element, "order_in_group", 0)),
|
| 194 |
+
int(getattr(element, "y_position", 0)),
|
| 195 |
+
int(getattr(element, "x_position", 0)),
|
| 196 |
+
)
|
| 197 |
+
|
| 198 |
+
def _build_group_bundles(
|
| 199 |
+
self, elements: List[MockElement]
|
| 200 |
+
) -> List[ElementGroupBundle]:
|
| 201 |
+
"""
|
| 202 |
+
group_id 기준으로 요소를 묶고, 앵커/자식 정보를 포함한 번들을 생성한다.
|
| 203 |
+
"""
|
| 204 |
+
ordered_elements = sorted(elements, key=self._element_sort_key)
|
| 205 |
+
grouped: "OrderedDict[Union[int, str], List[MockElement]]" = OrderedDict()
|
| 206 |
+
|
| 207 |
+
orphan_counter = 0
|
| 208 |
+
for element in ordered_elements:
|
| 209 |
+
group_id = getattr(element, "group_id", None)
|
| 210 |
+
if group_id is None:
|
| 211 |
+
group_id = f"_orphan_{orphan_counter}"
|
| 212 |
+
orphan_counter += 1
|
| 213 |
+
grouped.setdefault(group_id, []).append(element)
|
| 214 |
+
|
| 215 |
+
bundles: List[ElementGroupBundle] = []
|
| 216 |
+
for elems in grouped.values():
|
| 217 |
+
anchor = self._pick_anchor(elems)
|
| 218 |
+
children = self._sorted_children(elems, anchor)
|
| 219 |
+
bundles.append(
|
| 220 |
+
ElementGroupBundle(
|
| 221 |
+
anchor=anchor, children=children, ordered_elements=elems
|
| 222 |
+
)
|
| 223 |
+
)
|
| 224 |
+
return bundles
|
| 225 |
+
|
| 226 |
+
@staticmethod
|
| 227 |
+
def _pick_anchor(elements: Iterable[MockElement]) -> Optional[MockElement]:
|
| 228 |
+
anchors = [e for e in elements if e.class_name in ANCHOR_CLASSES]
|
| 229 |
+
if not anchors:
|
| 230 |
+
return None
|
| 231 |
+
anchors.sort(
|
| 232 |
+
key=lambda e: (
|
| 233 |
+
int(getattr(e, "order_in_group", 0)),
|
| 234 |
+
int(getattr(e, "y_position", 0)),
|
| 235 |
+
int(getattr(e, "x_position", 0)),
|
| 236 |
+
)
|
| 237 |
+
)
|
| 238 |
+
return anchors[0]
|
| 239 |
+
|
| 240 |
+
def _sorted_children(
|
| 241 |
+
self, elements: Iterable[MockElement], anchor: Optional[MockElement]
|
| 242 |
+
) -> List[MockElement]:
|
| 243 |
+
return sorted(
|
| 244 |
+
[e for e in elements if e is not anchor],
|
| 245 |
+
key=self._element_sort_key,
|
| 246 |
+
)
|
| 247 |
+
|
| 248 |
+
# ------------------------------------------------------------------
|
| 249 |
+
# 렌더링 로직
|
| 250 |
+
# ------------------------------------------------------------------
|
| 251 |
+
def _render_group(self, bundle: ElementGroupBundle, context: RenderContext) -> str:
|
| 252 |
+
anchor = bundle.anchor
|
| 253 |
+
if anchor is None:
|
| 254 |
+
return self._render_orphan_block(
|
| 255 |
+
bundle.children or bundle.ordered_elements, context
|
| 256 |
+
)
|
| 257 |
+
|
| 258 |
+
if anchor.class_name == "question type":
|
| 259 |
+
return self._render_section_block(anchor, bundle.children, context)
|
| 260 |
+
if anchor.class_name == "question number":
|
| 261 |
+
return self._render_question_block(anchor, bundle.children, context)
|
| 262 |
+
if anchor.class_name == "second_question_number":
|
| 263 |
+
return self._render_sub_question_block(anchor, bundle.children, context)
|
| 264 |
+
# 예상치 못한 앵커는 고아 블록으로 처리
|
| 265 |
+
logger.debug(
|
| 266 |
+
f"알 수 없는 앵커 클래스 '{anchor.class_name}' → 고아 블록으�� 처리"
|
| 267 |
+
)
|
| 268 |
+
return self._render_orphan_block(bundle.ordered_elements, context)
|
| 269 |
+
|
| 270 |
+
def _render_section_block(
|
| 271 |
+
self,
|
| 272 |
+
anchor: MockElement,
|
| 273 |
+
children: List[MockElement],
|
| 274 |
+
context: RenderContext,
|
| 275 |
+
) -> str:
|
| 276 |
+
output_parts: List[str] = []
|
| 277 |
+
rendered_anchor = context.format_element(anchor)
|
| 278 |
+
if rendered_anchor.strip():
|
| 279 |
+
output_parts.append(rendered_anchor)
|
| 280 |
+
|
| 281 |
+
for child in children:
|
| 282 |
+
rendered_child = context.format_element(child)
|
| 283 |
+
if rendered_child.strip():
|
| 284 |
+
output_parts.append(rendered_child)
|
| 285 |
+
|
| 286 |
+
return "".join(output_parts)
|
| 287 |
+
|
| 288 |
+
def _render_question_block(
|
| 289 |
+
self,
|
| 290 |
+
anchor: MockElement,
|
| 291 |
+
children: List[MockElement],
|
| 292 |
+
context: RenderContext,
|
| 293 |
+
) -> str:
|
| 294 |
+
return self._render_numbered_block(
|
| 295 |
+
anchor, children, context, primary_class="question text"
|
| 296 |
+
)
|
| 297 |
+
|
| 298 |
+
def _render_sub_question_block(
|
| 299 |
+
self,
|
| 300 |
+
anchor: MockElement,
|
| 301 |
+
children: List[MockElement],
|
| 302 |
+
context: RenderContext,
|
| 303 |
+
) -> str:
|
| 304 |
+
return self._render_numbered_block(
|
| 305 |
+
anchor, children, context, primary_class="question text"
|
| 306 |
+
)
|
| 307 |
+
|
| 308 |
+
def _render_numbered_block(
|
| 309 |
+
self,
|
| 310 |
+
anchor: MockElement,
|
| 311 |
+
children: List[MockElement],
|
| 312 |
+
context: RenderContext,
|
| 313 |
+
*,
|
| 314 |
+
primary_class: str,
|
| 315 |
+
) -> str:
|
| 316 |
+
output_parts: List[str] = []
|
| 317 |
+
|
| 318 |
+
primary_element = next(
|
| 319 |
+
(child for child in children if child.class_name == primary_class), None
|
| 320 |
+
)
|
| 321 |
+
# 앵커 텍스트 및 기본 포맷
|
| 322 |
+
anchor_text, _ = context.get_texts(anchor)
|
| 323 |
+
anchor_rule = context.rules.get(anchor.class_name, RuleConfig())
|
| 324 |
+
if anchor_text or anchor_rule.allow_empty:
|
| 325 |
+
anchor_line = apply_rule(anchor_rule, anchor_text).rstrip("\n")
|
| 326 |
+
else:
|
| 327 |
+
anchor_line = anchor_rule.prefix
|
| 328 |
+
|
| 329 |
+
first_line = ""
|
| 330 |
+
remainder = ""
|
| 331 |
+
if primary_element:
|
| 332 |
+
base_text, ai_text = context.get_texts(primary_element)
|
| 333 |
+
working = base_text or ai_text
|
| 334 |
+
working = context.apply_transform(
|
| 335 |
+
primary_element,
|
| 336 |
+
working,
|
| 337 |
+
base_text=base_text,
|
| 338 |
+
ai_text=ai_text,
|
| 339 |
+
)
|
| 340 |
+
if not working and ai_text:
|
| 341 |
+
working = ai_text
|
| 342 |
+
first_line, remainder = split_first_line(working)
|
| 343 |
+
|
| 344 |
+
if anchor_line:
|
| 345 |
+
leading_newlines = len(anchor_line) - len(anchor_line.lstrip("\n"))
|
| 346 |
+
anchor_core = anchor_line.lstrip("\n")
|
| 347 |
+
combined_line = anchor_core + (first_line or "")
|
| 348 |
+
prefix_newlines = "\n" * leading_newlines
|
| 349 |
+
output_parts.append(prefix_newlines + combined_line.rstrip())
|
| 350 |
+
elif first_line:
|
| 351 |
+
output_parts.append(first_line)
|
| 352 |
+
|
| 353 |
+
question_rule = context.rules.get(primary_class)
|
| 354 |
+
if primary_element and remainder:
|
| 355 |
+
if question_rule:
|
| 356 |
+
output_parts.append(apply_rule(question_rule, remainder).rstrip("\n"))
|
| 357 |
+
else:
|
| 358 |
+
output_parts.append(remainder)
|
| 359 |
+
|
| 360 |
+
rendered_children = []
|
| 361 |
+
for child in children:
|
| 362 |
+
if child is primary_element:
|
| 363 |
+
continue
|
| 364 |
+
rendered = context.format_element(child)
|
| 365 |
+
if rendered.strip():
|
| 366 |
+
if output_parts and not output_parts[-1].endswith("\n\n"):
|
| 367 |
+
output_parts[-1] = output_parts[-1].rstrip("\n") + "\n\n"
|
| 368 |
+
rendered_children.append(rendered)
|
| 369 |
+
output_parts.extend(rendered_children)
|
| 370 |
+
|
| 371 |
+
return "".join(output_parts)
|
| 372 |
+
|
| 373 |
+
def _render_orphan_block(
|
| 374 |
+
self,
|
| 375 |
+
elements: List[MockElement],
|
| 376 |
+
context: RenderContext,
|
| 377 |
+
) -> str:
|
| 378 |
+
outputs: List[str] = []
|
| 379 |
+
for element in elements:
|
| 380 |
+
rendered = context.format_element(element)
|
| 381 |
+
if rendered.strip():
|
| 382 |
+
outputs.append(rendered)
|
| 383 |
+
return "".join(outputs)
|
| 384 |
+
|
| 385 |
+
def _render_reading_order(
|
| 386 |
+
self,
|
| 387 |
+
elements: List[MockElement],
|
| 388 |
+
context: RenderContext,
|
| 389 |
+
) -> List[str]:
|
| 390 |
+
sorted_elements = sorted(
|
| 391 |
+
elements,
|
| 392 |
+
key=lambda e: (
|
| 393 |
+
int(getattr(e, "y_position", 0)),
|
| 394 |
+
int(getattr(e, "x_position", 0)),
|
| 395 |
+
int(getattr(e, "element_id", 0)),
|
| 396 |
+
),
|
| 397 |
+
)
|
| 398 |
+
outputs = []
|
| 399 |
+
for element in sorted_elements:
|
| 400 |
+
rendered = context.format_element(element)
|
| 401 |
+
if rendered.strip():
|
| 402 |
+
outputs.append(rendered)
|
| 403 |
+
return outputs
|
app/services/formatter_rules.py
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
앵커 기반 텍스트 포맷터 규칙 정의
|
| 3 |
+
=================================
|
| 4 |
+
|
| 5 |
+
실제 서비스는 formatting_rules DB 테이블을 참고하는 것을 목표로 하지만,
|
| 6 |
+
현재 구현에서는 코드 레벨의 기본 규칙을 제공하고, 향후 DB 오버라이드를
|
| 7 |
+
위해 동일한 구조를 유지한다.
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
from dataclasses import dataclass, replace
|
| 13 |
+
from typing import Dict, Optional, TYPE_CHECKING
|
| 14 |
+
|
| 15 |
+
if TYPE_CHECKING:
|
| 16 |
+
from sqlalchemy.orm import Session
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
@dataclass(frozen=True)
|
| 20 |
+
class RuleConfig:
|
| 21 |
+
"""
|
| 22 |
+
개별 클래스에 대한 포맷팅 규칙.
|
| 23 |
+
|
| 24 |
+
Attributes:
|
| 25 |
+
prefix: 콘텐츠 앞에 붙일 문자열.
|
| 26 |
+
suffix: 콘텐츠 뒤에 붙일 문자열.
|
| 27 |
+
indent: 들여쓰기 공백 수(각 라인에 적용).
|
| 28 |
+
transform: formatter_utils에서 사용할 후처리 함수 이름.
|
| 29 |
+
allow_empty: True면 빈 콘텐츠라도 규칙을 적용.
|
| 30 |
+
keep_suffix_on_empty: 빈 콘텐츠일 때도 suffix를 유지할지 여부.
|
| 31 |
+
"""
|
| 32 |
+
|
| 33 |
+
prefix: str = ""
|
| 34 |
+
suffix: str = "\n"
|
| 35 |
+
indent: int = 0
|
| 36 |
+
transform: Optional[str] = None
|
| 37 |
+
allow_empty: bool = False
|
| 38 |
+
keep_suffix_on_empty: bool = False
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
# ---------------------------------------------------------------------------
|
| 42 |
+
# 기본 규칙: 문제지(question_based) 문서
|
| 43 |
+
# ---------------------------------------------------------------------------
|
| 44 |
+
|
| 45 |
+
QUESTION_BASED_RULES: Dict[str, RuleConfig] = {
|
| 46 |
+
# 앵커
|
| 47 |
+
"question type": RuleConfig(prefix="\n\n[", suffix="]\n", indent=0, transform="normalize_question_type"),
|
| 48 |
+
"question number": RuleConfig(prefix="\n\n", suffix=". ", indent=0, allow_empty=False),
|
| 49 |
+
"second_question_number": RuleConfig(prefix="\n ", suffix="", indent=3, allow_empty=False),
|
| 50 |
+
# 본문
|
| 51 |
+
"question text": RuleConfig(prefix="", suffix="\n", indent=3),
|
| 52 |
+
"plain text": RuleConfig(prefix="", suffix="\n", indent=0),
|
| 53 |
+
"unit": RuleConfig(prefix="", suffix="\n", indent=3),
|
| 54 |
+
"list": RuleConfig(prefix=" - ", suffix="\n", indent=0, transform="normalize_list"),
|
| 55 |
+
"choices": RuleConfig(prefix="", suffix="\n", indent=3, transform="normalize_choices"),
|
| 56 |
+
# 시각 자료
|
| 57 |
+
"figure": RuleConfig(prefix="\n [그림 설명]\n", suffix="\n\n", indent=3, transform="merge_visual_description", allow_empty=True),
|
| 58 |
+
"table": RuleConfig(prefix="\n [표 설명]\n", suffix="\n\n", indent=3, transform="merge_visual_description", allow_empty=True),
|
| 59 |
+
"flowchart": RuleConfig(prefix="\n [순서도 설명]\n", suffix="\n\n", indent=3, transform="merge_visual_description", allow_empty=True),
|
| 60 |
+
# 캡션 및 메타
|
| 61 |
+
"figure_caption": RuleConfig(prefix=" (그림 캡션) ", suffix="\n\n", indent=0),
|
| 62 |
+
"table caption": RuleConfig(prefix=" (표 캡션) ", suffix="\n\n", indent=0),
|
| 63 |
+
"table footnote": RuleConfig(prefix=" * ", suffix="\n", indent=0),
|
| 64 |
+
"formula_caption": RuleConfig(prefix=" (수식 설명) ", suffix="\n", indent=0),
|
| 65 |
+
"isolated_formula": RuleConfig(prefix="\n [수식]\n", suffix="\n", indent=3, transform="isolate_formula")
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
# ---------------------------------------------------------------------------
|
| 70 |
+
# 기본 규칙: 일반 문서(reading_order) 문서
|
| 71 |
+
# ---------------------------------------------------------------------------
|
| 72 |
+
|
| 73 |
+
READING_ORDER_RULES: Dict[str, RuleConfig] = {
|
| 74 |
+
"title": RuleConfig(prefix="", suffix="\n\n", indent=0, transform="uppercase_title"),
|
| 75 |
+
"heading": RuleConfig(prefix="\n", suffix="\n\n", indent=0),
|
| 76 |
+
"plain text": RuleConfig(prefix="", suffix="\n\n", indent=0),
|
| 77 |
+
"list": RuleConfig(prefix="", suffix="\n", indent=0, transform="normalize_reading_list"),
|
| 78 |
+
"figure": RuleConfig(prefix="\n[그림] ", suffix="\n\n", indent=0, transform="merge_visual_description"),
|
| 79 |
+
"table": RuleConfig(prefix="\n[표] ", suffix="\n\n", indent=0, transform="merge_visual_description"),
|
| 80 |
+
"figure_caption": RuleConfig(prefix="(그림 캡션) ", suffix="\n", indent=0),
|
| 81 |
+
"table caption": RuleConfig(prefix="(표 캡션) ", suffix="\n", indent=0),
|
| 82 |
+
"table footnote": RuleConfig(prefix="* ", suffix="\n", indent=0),
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
RULE_MAP_BY_DOC_TYPE: Dict[str, Dict[str, RuleConfig]] = {
|
| 87 |
+
"question_based": QUESTION_BASED_RULES,
|
| 88 |
+
"reading_order": READING_ORDER_RULES,
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
def get_rules_for_document_type(document_type: str) -> Dict[str, RuleConfig]:
|
| 93 |
+
"""
|
| 94 |
+
지정된 문서 타입의 규칙 사전을 복사하여 반환합니다.
|
| 95 |
+
|
| 96 |
+
Args:
|
| 97 |
+
document_type: "question_based" 또는 "reading_order"
|
| 98 |
+
|
| 99 |
+
Returns:
|
| 100 |
+
class_name → RuleConfig 매핑 (복사본)
|
| 101 |
+
"""
|
| 102 |
+
base_rules = RULE_MAP_BY_DOC_TYPE.get(document_type)
|
| 103 |
+
if base_rules is None:
|
| 104 |
+
raise ValueError(f"지원하지 않는 문서 타입입니다: {document_type}")
|
| 105 |
+
return {class_name: replace(rule) for class_name, rule in base_rules.items()}
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
def fetch_db_rules(db: "Session", doc_type_id: int) -> Dict[str, Dict[str, str]]:
|
| 109 |
+
"""
|
| 110 |
+
DB에서 formatting_rules를 조회하여 덮어쓰기 정보를 반환합니다.
|
| 111 |
+
|
| 112 |
+
Args:
|
| 113 |
+
db: SQLAlchemy 세션
|
| 114 |
+
doc_type_id: document_types.doc_type_id (1=문제지, 2=일반문서)
|
| 115 |
+
|
| 116 |
+
Returns:
|
| 117 |
+
class_name → {prefix, suffix, indent} 형태의 덮어쓰기 정보
|
| 118 |
+
"""
|
| 119 |
+
# Import here to avoid circular dependency
|
| 120 |
+
from .. import crud
|
| 121 |
+
|
| 122 |
+
db_rules = crud.get_all_formatting_rules(db)
|
| 123 |
+
if not db_rules:
|
| 124 |
+
return {}
|
| 125 |
+
|
| 126 |
+
override_dict: Dict[str, Dict[str, str]] = {}
|
| 127 |
+
for rule in db_rules:
|
| 128 |
+
# doc_type_id가 일치하거나 NULL(공통 규칙)인 경우만 적용
|
| 129 |
+
if rule.doc_type_id is None or rule.doc_type_id == doc_type_id:
|
| 130 |
+
override_dict[rule.class_name] = {
|
| 131 |
+
"prefix": rule.prefix or "",
|
| 132 |
+
"suffix": rule.suffix or "\n",
|
| 133 |
+
"indent": str(rule.indent_level or 0),
|
| 134 |
+
}
|
| 135 |
+
|
| 136 |
+
return override_dict
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
def override_rules_with_db(
|
| 140 |
+
base_rules: Dict[str, RuleConfig],
|
| 141 |
+
db_records: Optional[Dict[str, Dict[str, str]]] = None
|
| 142 |
+
) -> Dict[str, RuleConfig]:
|
| 143 |
+
"""
|
| 144 |
+
DB 레코드 정보를 사용하여 규칙을 덮어씁니다.
|
| 145 |
+
|
| 146 |
+
Args:
|
| 147 |
+
base_rules: 코드 기본 규칙 사전.
|
| 148 |
+
db_records: class_name → {prefix, suffix, indent} 형태의 덮어쓰기 정보.
|
| 149 |
+
|
| 150 |
+
Returns:
|
| 151 |
+
덮어쓰기 적용된 규칙 사전.
|
| 152 |
+
"""
|
| 153 |
+
if not db_records:
|
| 154 |
+
return base_rules
|
| 155 |
+
|
| 156 |
+
updated_rules = dict(base_rules)
|
| 157 |
+
for class_name, override in db_records.items():
|
| 158 |
+
rule = updated_rules.get(class_name)
|
| 159 |
+
if not rule:
|
| 160 |
+
continue
|
| 161 |
+
updated_rules[class_name] = RuleConfig(
|
| 162 |
+
prefix=override.get("prefix", rule.prefix),
|
| 163 |
+
suffix=override.get("suffix", rule.suffix),
|
| 164 |
+
indent=int(override.get("indent", rule.indent)),
|
| 165 |
+
transform=rule.transform,
|
| 166 |
+
allow_empty=rule.allow_empty,
|
| 167 |
+
keep_suffix_on_empty=rule.keep_suffix_on_empty,
|
| 168 |
+
)
|
| 169 |
+
return updated_rules
|
| 170 |
+
|
| 171 |
+
|
| 172 |
+
def get_rule_for_class(
|
| 173 |
+
class_name: str,
|
| 174 |
+
document_type: str,
|
| 175 |
+
db: Optional["Session"] = None,
|
| 176 |
+
doc_type_id: Optional[int] = None
|
| 177 |
+
) -> RuleConfig:
|
| 178 |
+
"""
|
| 179 |
+
주어진 클래스명에 대한 포맷팅 규칙을 반환합니다.
|
| 180 |
+
DB 세션이 제공되면 DB 오버라이드를 적용합니다.
|
| 181 |
+
|
| 182 |
+
Args:
|
| 183 |
+
class_name: 레이아웃 요소 클래스명
|
| 184 |
+
document_type: "question_based" 또는 "reading_order"
|
| 185 |
+
db: SQLAlchemy 세션 (선택)
|
| 186 |
+
doc_type_id: 문서 타입 ID (선택, db 제공 시 필요)
|
| 187 |
+
|
| 188 |
+
Returns:
|
| 189 |
+
해당 클래스의 RuleConfig
|
| 190 |
+
"""
|
| 191 |
+
base_rules = get_rules_for_document_type(document_type)
|
| 192 |
+
|
| 193 |
+
if db and doc_type_id:
|
| 194 |
+
db_records = fetch_db_rules(db, doc_type_id)
|
| 195 |
+
rules = override_rules_with_db(base_rules, db_records)
|
| 196 |
+
else:
|
| 197 |
+
rules = base_rules
|
| 198 |
+
|
| 199 |
+
# 기본값 반환 (규칙이 없는 경우)
|
| 200 |
+
return rules.get(class_name, RuleConfig())
|
app/services/formatter_utils.py
ADDED
|
@@ -0,0 +1,306 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
포맷터 유틸리티 함수 모음.
|
| 3 |
+
|
| 4 |
+
포맷팅 규칙 적용, 선택지 정규화, 시각 자료 설명 병합 등
|
| 5 |
+
핵심 후처리 로직을 한 곳에 모아둔다.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import re
|
| 11 |
+
import unicodedata
|
| 12 |
+
from dataclasses import dataclass
|
| 13 |
+
from typing import Dict, Iterable, List, Optional, Tuple
|
| 14 |
+
|
| 15 |
+
from .mock_models import MockElement
|
| 16 |
+
from .formatter_rules import RuleConfig
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
AI_PRIORITY_CLASSES = {"figure", "table", "flowchart"}
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
# ---------------------------------------------------------------------------
|
| 23 |
+
# 데이터 변환 헬퍼
|
| 24 |
+
# ---------------------------------------------------------------------------
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def ocr_inputs_to_dict(ocr_texts) -> Dict[int, str]:
|
| 28 |
+
"""
|
| 29 |
+
OCR 입력을 element_id → text 딕셔너리로 변환.
|
| 30 |
+
"""
|
| 31 |
+
if isinstance(ocr_texts, dict):
|
| 32 |
+
return {int(k): (v or "").strip() for k, v in ocr_texts.items()}
|
| 33 |
+
|
| 34 |
+
ocr_dict: Dict[int, str] = {}
|
| 35 |
+
for item in ocr_texts or []:
|
| 36 |
+
try:
|
| 37 |
+
element_id = int(getattr(item, "element_id"))
|
| 38 |
+
text = getattr(item, "ocr_text", "") or ""
|
| 39 |
+
except AttributeError:
|
| 40 |
+
continue
|
| 41 |
+
cleaned = text.strip()
|
| 42 |
+
if cleaned:
|
| 43 |
+
ocr_dict[element_id] = cleaned
|
| 44 |
+
return ocr_dict
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def normalize_ai_descriptions(
|
| 48 |
+
ai_descriptions: Optional[Dict[int, str]],
|
| 49 |
+
) -> Dict[int, str]:
|
| 50 |
+
"""
|
| 51 |
+
AI 설명 딕셔너리를 정리합니다.
|
| 52 |
+
"""
|
| 53 |
+
if not ai_descriptions:
|
| 54 |
+
return {}
|
| 55 |
+
return {
|
| 56 |
+
int(k): (v or "").strip()
|
| 57 |
+
for k, v in ai_descriptions.items()
|
| 58 |
+
if (v or "").strip()
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def split_first_line(text: str) -> Tuple[str, str]:
|
| 63 |
+
"""
|
| 64 |
+
문자열을 첫 줄과 나머지로 분리한다.
|
| 65 |
+
"""
|
| 66 |
+
if not text:
|
| 67 |
+
return "", ""
|
| 68 |
+
lines = text.splitlines()
|
| 69 |
+
first = lines[0]
|
| 70 |
+
remainder = "\n".join(lines[1:]).strip()
|
| 71 |
+
return first, remainder
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
# ---------------------------------------------------------------------------
|
| 75 |
+
# 콘텐츠 후처리
|
| 76 |
+
# ---------------------------------------------------------------------------
|
| 77 |
+
|
| 78 |
+
CHOICE_PATTERN = re.compile(
|
| 79 |
+
r"^(\(?\d{1,2}[\).]|[①-⑳]|[A-Z][\).]|[가-하]\.|[가-하]\))\s*(.+)$"
|
| 80 |
+
)
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def normalize_choices(text: str) -> str:
|
| 84 |
+
"""
|
| 85 |
+
선택지 텍스트를 표준화한다.
|
| 86 |
+
- 패턴이 명확하면 그대로 사용.
|
| 87 |
+
- 그렇지 않으면 '• ' 불릿을 붙인다.
|
| 88 |
+
"""
|
| 89 |
+
lines = [line.strip() for line in text.splitlines() if line.strip()]
|
| 90 |
+
normalized: List[str] = []
|
| 91 |
+
for line in lines:
|
| 92 |
+
match = CHOICE_PATTERN.match(line)
|
| 93 |
+
if match:
|
| 94 |
+
label, body = match.groups()
|
| 95 |
+
normalized.append(f"{label} {body.strip()}")
|
| 96 |
+
else:
|
| 97 |
+
normalized.append(f"• {line}")
|
| 98 |
+
return "\n".join(normalized)
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
LIST_PATTERN = re.compile(r"^([\-•]|\d+\.)\s*(.+)$")
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
def normalize_list(text: str) -> str:
|
| 105 |
+
"""
|
| 106 |
+
일반 리스트 텍스트를 정규화.
|
| 107 |
+
"""
|
| 108 |
+
lines = [line.strip() for line in text.splitlines() if line.strip()]
|
| 109 |
+
normalized: List[str] = []
|
| 110 |
+
for line in lines:
|
| 111 |
+
match = LIST_PATTERN.match(line)
|
| 112 |
+
if match:
|
| 113 |
+
normalized.append(f"- {match.group(2).strip()}")
|
| 114 |
+
else:
|
| 115 |
+
normalized.append(f"- {line}")
|
| 116 |
+
return "\n".join(normalized)
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
def normalize_reading_list(text: str) -> str:
|
| 120 |
+
"""
|
| 121 |
+
일반 문서용 리스트 정규화 (불릿 기호 유지).
|
| 122 |
+
"""
|
| 123 |
+
lines = [line.strip() for line in text.splitlines() if line.strip()]
|
| 124 |
+
normalized: List[str] = []
|
| 125 |
+
for line in lines:
|
| 126 |
+
match = LIST_PATTERN.match(line)
|
| 127 |
+
if match:
|
| 128 |
+
normalized.append(f"• {match.group(2).strip()}")
|
| 129 |
+
else:
|
| 130 |
+
normalized.append(f"• {line}")
|
| 131 |
+
return "\n".join(normalized)
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
def merge_visual_description(text: str, ai_text: Optional[str]) -> str:
|
| 135 |
+
"""
|
| 136 |
+
그림/표/순서도 설명을 결합한다.
|
| 137 |
+
AI 설명이 있으면 우선 사용하고, OCR 텍스트가 있으면 다음 줄에 추가한다.
|
| 138 |
+
"""
|
| 139 |
+
if ai_text and text:
|
| 140 |
+
return f"{ai_text}\n{text}"
|
| 141 |
+
return ai_text or text
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
def isolate_formula(text: str) -> str:
|
| 145 |
+
"""
|
| 146 |
+
수식은 주어진 텍스트를 그대로 사용하되 앞뒤 공백을 정돈한다.
|
| 147 |
+
"""
|
| 148 |
+
return text.strip()
|
| 149 |
+
|
| 150 |
+
|
| 151 |
+
def uppercase_title(text: str) -> str:
|
| 152 |
+
return text.strip()
|
| 153 |
+
|
| 154 |
+
|
| 155 |
+
def normalize_question_type(text: str) -> str:
|
| 156 |
+
"""
|
| 157 |
+
question type OCR 결과의 줄 정렬/노이즈 제거.
|
| 158 |
+
- 줄바꿈을 공백으로 치환하여 한 줄로 정리
|
| 159 |
+
(그 외 문자/공백은 원본을 최대한 유지)
|
| 160 |
+
"""
|
| 161 |
+
normalized = unicodedata.normalize("NFKC", text or "")
|
| 162 |
+
normalized = normalized.replace("\r\n", "\n").replace("\r", "\n")
|
| 163 |
+
return normalized.replace("\n", " ")
|
| 164 |
+
|
| 165 |
+
|
| 166 |
+
TRANSFORM_DISPATCH = {
|
| 167 |
+
"normalize_choices": normalize_choices,
|
| 168 |
+
"normalize_list": normalize_list,
|
| 169 |
+
"normalize_reading_list": normalize_reading_list,
|
| 170 |
+
"merge_visual_description": merge_visual_description,
|
| 171 |
+
"isolate_formula": isolate_formula,
|
| 172 |
+
"uppercase_title": uppercase_title,
|
| 173 |
+
"normalize_question_type": normalize_question_type,
|
| 174 |
+
}
|
| 175 |
+
|
| 176 |
+
|
| 177 |
+
# ---------------------------------------------------------------------------
|
| 178 |
+
# 규칙 적용 및 출력 정리
|
| 179 |
+
# ---------------------------------------------------------------------------
|
| 180 |
+
|
| 181 |
+
|
| 182 |
+
def apply_rule(rule: RuleConfig, content: str) -> str:
|
| 183 |
+
"""
|
| 184 |
+
규칙에 따라 콘텐츠에 접두사, 들여쓰기, 접미사를 적용한다.
|
| 185 |
+
"""
|
| 186 |
+
if not content and not rule.allow_empty:
|
| 187 |
+
return ""
|
| 188 |
+
|
| 189 |
+
working = content
|
| 190 |
+
if rule.indent > 0:
|
| 191 |
+
indent_str = " " * rule.indent
|
| 192 |
+
indented_lines: List[str] = []
|
| 193 |
+
for line in working.splitlines():
|
| 194 |
+
if not line.strip():
|
| 195 |
+
indented_lines.append("")
|
| 196 |
+
else:
|
| 197 |
+
indented_lines.append(f"{indent_str}{line}")
|
| 198 |
+
working = "\n".join(indented_lines)
|
| 199 |
+
|
| 200 |
+
if not working and not rule.keep_suffix_on_empty:
|
| 201 |
+
return rule.prefix if rule.prefix else ""
|
| 202 |
+
|
| 203 |
+
return f"{rule.prefix}{working}{rule.suffix}"
|
| 204 |
+
|
| 205 |
+
|
| 206 |
+
def clean_output(text: str) -> str:
|
| 207 |
+
"""
|
| 208 |
+
최종 출력 문자열에서 연속 빈 줄 및 후행 공백을 정리한다.
|
| 209 |
+
"""
|
| 210 |
+
lines = text.splitlines()
|
| 211 |
+
cleaned: List[str] = []
|
| 212 |
+
empty_streak = 0
|
| 213 |
+
for line in lines:
|
| 214 |
+
stripped = line.rstrip()
|
| 215 |
+
if stripped == "":
|
| 216 |
+
empty_streak += 1
|
| 217 |
+
if empty_streak > 2:
|
| 218 |
+
continue
|
| 219 |
+
else:
|
| 220 |
+
empty_streak = 0
|
| 221 |
+
cleaned.append(stripped)
|
| 222 |
+
result = "\n".join(cleaned).strip()
|
| 223 |
+
return result
|
| 224 |
+
|
| 225 |
+
|
| 226 |
+
# ---------------------------------------------------------------------------
|
| 227 |
+
# 렌더링 컨텍스트
|
| 228 |
+
# ---------------------------------------------------------------------------
|
| 229 |
+
|
| 230 |
+
|
| 231 |
+
@dataclass
|
| 232 |
+
class RenderContext:
|
| 233 |
+
"""
|
| 234 |
+
렌더링 시 필요한 컨텍스트.
|
| 235 |
+
"""
|
| 236 |
+
|
| 237 |
+
ocr_texts: Dict[int, str]
|
| 238 |
+
ai_texts: Dict[int, str]
|
| 239 |
+
rules: Dict[str, RuleConfig]
|
| 240 |
+
|
| 241 |
+
def get_texts(self, element: MockElement) -> Tuple[str, str]:
|
| 242 |
+
element_id = getattr(element, "element_id", None)
|
| 243 |
+
base_text = self.ocr_texts.get(element_id, "").strip()
|
| 244 |
+
ai_text = self.ai_texts.get(element_id, "").strip()
|
| 245 |
+
return base_text, ai_text
|
| 246 |
+
|
| 247 |
+
def apply_transform(
|
| 248 |
+
self,
|
| 249 |
+
element: MockElement,
|
| 250 |
+
text: str,
|
| 251 |
+
*,
|
| 252 |
+
base_text: str,
|
| 253 |
+
ai_text: str,
|
| 254 |
+
) -> str:
|
| 255 |
+
rule = self.rules.get(element.class_name)
|
| 256 |
+
if not rule or not rule.transform:
|
| 257 |
+
return text.strip()
|
| 258 |
+
|
| 259 |
+
transform = TRANSFORM_DISPATCH.get(rule.transform)
|
| 260 |
+
if not transform:
|
| 261 |
+
return text.strip()
|
| 262 |
+
|
| 263 |
+
if rule.transform == "merge_visual_description":
|
| 264 |
+
return transform(base_text.strip(), ai_text.strip())
|
| 265 |
+
|
| 266 |
+
return transform(text.strip())
|
| 267 |
+
|
| 268 |
+
def format_element(
|
| 269 |
+
self, element: MockElement, content_override: Optional[str] = None
|
| 270 |
+
) -> str:
|
| 271 |
+
"""
|
| 272 |
+
개별 요소를 규칙에 따라 문자열로 변환한다.
|
| 273 |
+
"""
|
| 274 |
+
element_id = getattr(element, "element_id", None)
|
| 275 |
+
base_text = (
|
| 276 |
+
content_override
|
| 277 |
+
if content_override is not None
|
| 278 |
+
else self.ocr_texts.get(element_id, "")
|
| 279 |
+
).strip()
|
| 280 |
+
ai_text = self.ai_texts.get(element_id, "").strip()
|
| 281 |
+
|
| 282 |
+
# 그림/표/순서도는 transform 함수에서 병합 처리
|
| 283 |
+
if element.class_name in AI_PRIORITY_CLASSES:
|
| 284 |
+
# merge_visual_description transform에 맡김
|
| 285 |
+
working = base_text
|
| 286 |
+
else:
|
| 287 |
+
working = base_text or ai_text
|
| 288 |
+
|
| 289 |
+
# Transform 적용 (merge_visual_description이 ai_text와 병합)
|
| 290 |
+
working = self.apply_transform(
|
| 291 |
+
element,
|
| 292 |
+
working,
|
| 293 |
+
base_text=base_text,
|
| 294 |
+
ai_text=ai_text,
|
| 295 |
+
)
|
| 296 |
+
|
| 297 |
+
# Transform 후에도 비어있고 AI 설명이 있으면 AI 설명 사용
|
| 298 |
+
if not working and ai_text and element.class_name in AI_PRIORITY_CLASSES:
|
| 299 |
+
working = ai_text
|
| 300 |
+
|
| 301 |
+
# 규칙 적용 (prefix, suffix, indent)
|
| 302 |
+
rule = self.rules.get(element.class_name)
|
| 303 |
+
if rule:
|
| 304 |
+
return apply_rule(rule, working)
|
| 305 |
+
# 규칙이 없으면 기본적으로 한 줄 출력
|
| 306 |
+
return f"{working}\n" if working else ""
|
app/services/mock_models.py
ADDED
|
@@ -0,0 +1,417 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
데이터베이스 독립성을 위한 Mock 모델 (v2.1 스키마 호환)
|
| 3 |
+
======================================================
|
| 4 |
+
|
| 5 |
+
이 모듈은 데이터베이스 의존성을 제거하기 위한 Mock 모델들을 정의하며,
|
| 6 |
+
v2.1 E-R 다이어그램에 맞춰져 있습니다. 테스트 목적으로 layout_elements,
|
| 7 |
+
text_contents, question_groups, question_elements 테이블을 대체합니다.
|
| 8 |
+
|
| 9 |
+
주요 특징:
|
| 10 |
+
- MockElement, MockTextContent, MockQuestionGroup, MockQuestionElement 구현
|
| 11 |
+
- 자동 계산 속성 (y_position, x_position, area)
|
| 12 |
+
- Pydantic (설치된 경우) 또는 순수 Python 클래스 지원
|
| 13 |
+
|
| 14 |
+
v2.1 스키마 변경 사항 반영:
|
| 15 |
+
- MockElement: DB 표현에서는 정렬 필드(order_in_question 등)가 없지만, sorter.py 호환성을 위해 메모리 내 속성으로 유지
|
| 16 |
+
- MockQuestionGroup: question_groups 테이블 표현
|
| 17 |
+
- MockQuestionElement: question_elements 테이블 (N:M 매핑) 표현
|
| 18 |
+
|
| 19 |
+
사용법:
|
| 20 |
+
- analysis_service, formatter에서 MockElement, MockTextContent 임포트
|
| 21 |
+
- db_saver (v2.1)에서 MockQuestionGroup, MockQuestionElement 임포트
|
| 22 |
+
|
| 23 |
+
참조:
|
| 24 |
+
- E-R 다이어그램 v2.1: Project/docs/E-R_다이어그램_v2.1_스키마.md
|
| 25 |
+
"""
|
| 26 |
+
|
| 27 |
+
from datetime import datetime
|
| 28 |
+
from typing import Optional, Dict, Any, List # Pydantic MockElement bbox 호환성을 위해 List 추가
|
| 29 |
+
import json as json_module
|
| 30 |
+
from dataclasses import dataclass
|
| 31 |
+
|
| 32 |
+
# 의존성 체크
|
| 33 |
+
try:
|
| 34 |
+
from pydantic import BaseModel, Field, computed_field
|
| 35 |
+
USE_PYDANTIC = True
|
| 36 |
+
except ImportError:
|
| 37 |
+
USE_PYDANTIC = False
|
| 38 |
+
BaseModel = object # type: ignore
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
# ============================================================================
|
| 42 |
+
# Pydantic 기반 구현 (Pydantic이 설치된 경우)
|
| 43 |
+
# ============================================================================
|
| 44 |
+
|
| 45 |
+
if USE_PYDANTIC:
|
| 46 |
+
|
| 47 |
+
class MockElement(BaseModel): # type: ignore
|
| 48 |
+
"""
|
| 49 |
+
layout_elements 테이블을 대체하는 Mock 모델 (Pydantic 버전).
|
| 50 |
+
레이아웃 분석 결과의 각 요소를 나타냅니다.
|
| 51 |
+
|
| 52 |
+
v2.1 참고: 데이터베이스의 layout_elements 테이블에는 정렬 정보가 저장되지 않습니다.
|
| 53 |
+
하지만 sorter.py는 이 속성들을 메모리 내에서 동적으로 추가합니다.
|
| 54 |
+
따라서 sorter.py 출력과의 호환성을 위해 이 선택적 필드들을 유지합니다.
|
| 55 |
+
"""
|
| 56 |
+
|
| 57 |
+
# 기본 필드
|
| 58 |
+
element_id: int = Field(..., description="요소 고유 식별자", ge=1)
|
| 59 |
+
page_id: Optional[int] = Field(None, description="페이지 식별자", ge=1)
|
| 60 |
+
class_name: str = Field(..., description="요소 클래스명", min_length=1, max_length=100)
|
| 61 |
+
confidence: float = Field(..., description="탐지 신뢰도", ge=0.0, le=1.0)
|
| 62 |
+
|
| 63 |
+
# 바운딩 박스 좌표
|
| 64 |
+
bbox_x: int = Field(..., description="바운딩 박스 좌상단 X", ge=0)
|
| 65 |
+
bbox_y: int = Field(..., description="바운딩 박스 좌상단 Y", ge=0)
|
| 66 |
+
bbox_width: int = Field(..., description="바운딩 박스 너비", ge=1)
|
| 67 |
+
bbox_height: int = Field(..., description="바운딩 박스 높이", ge=1)
|
| 68 |
+
|
| 69 |
+
# 메타데이터
|
| 70 |
+
created_at: Optional[datetime] = Field(default_factory=datetime.now, description="생성 타임스탬프")
|
| 71 |
+
|
| 72 |
+
# ===>>> sorter.py가 동적으로 추가하는 필드 (v2.1 DB 스키마에는 없음) <<<===
|
| 73 |
+
order_in_question: Optional[int] = Field(None, description="전체 정렬 순서 (sorter가 추가)")
|
| 74 |
+
group_id: Optional[int] = Field(None, description="할당된 그룹 ID (sorter가 추가)")
|
| 75 |
+
order_in_group: Optional[int] = Field(None, description="그룹 내 정렬 순서 (sorter가 추가)")
|
| 76 |
+
# ===>>> sorter.py 필드 끝 <<<===
|
| 77 |
+
|
| 78 |
+
# 이전 bbox 형식 호환성 필드 (필요시)
|
| 79 |
+
bbox: Optional[List[int]] = Field(None, description="선택적 bbox 리스트 [x, y, w, h]")
|
| 80 |
+
|
| 81 |
+
# 계산된 속성
|
| 82 |
+
@computed_field # type: ignore[misc]
|
| 83 |
+
@property
|
| 84 |
+
def area(self) -> int:
|
| 85 |
+
"""요소 면적 계산 (너비 * 높이)"""
|
| 86 |
+
return self.bbox_width * self.bbox_height
|
| 87 |
+
|
| 88 |
+
@computed_field # type: ignore[misc]
|
| 89 |
+
@property
|
| 90 |
+
def y_position(self) -> int:
|
| 91 |
+
"""Y 좌표 정렬용 속성 (= bbox_y)"""
|
| 92 |
+
return self.bbox_y
|
| 93 |
+
|
| 94 |
+
@computed_field # type: ignore[misc]
|
| 95 |
+
@property
|
| 96 |
+
def x_position(self) -> int:
|
| 97 |
+
"""X 좌표 정렬용 속성 (= bbox_x)"""
|
| 98 |
+
return self.bbox_x
|
| 99 |
+
|
| 100 |
+
model_config = {
|
| 101 |
+
"json_schema_extra": {
|
| 102 |
+
"example": {
|
| 103 |
+
"element_id": 1, "page_id": 1, "class_name": "question_number",
|
| 104 |
+
"confidence": 0.95, "bbox_x": 100, "bbox_y": 200,
|
| 105 |
+
"bbox_width": 50, "bbox_height": 30,
|
| 106 |
+
}
|
| 107 |
+
}
|
| 108 |
+
}
|
| 109 |
+
|
| 110 |
+
class MockTextContent(BaseModel): # type: ignore
|
| 111 |
+
"""
|
| 112 |
+
text_contents 테이블을 대체하는 Mock 모델 (Pydantic 버전).
|
| 113 |
+
레이아웃 요소의 OCR 결과 텍스트를 저장합니다 (1:1 관계).
|
| 114 |
+
"""
|
| 115 |
+
# 기본 필드
|
| 116 |
+
text_id: int = Field(..., description="텍스트 콘텐츠 고유 식별자", ge=1)
|
| 117 |
+
element_id: int = Field(..., description="연결된 레이아웃 요소 식별자 (UNIQUE)", ge=1)
|
| 118 |
+
|
| 119 |
+
# OCR 콘텐츠
|
| 120 |
+
ocr_text: str = Field(..., description="추출된 OCR 텍스트")
|
| 121 |
+
|
| 122 |
+
# OCR 메타데이터
|
| 123 |
+
ocr_engine: str = Field(default="PaddleOCR", description="사용된 OCR 엔진", max_length=50)
|
| 124 |
+
ocr_confidence: Optional[float] = Field(None, description="OCR 신뢰도", ge=0.0, le=1.0)
|
| 125 |
+
language: str = Field(default="ko", description="텍스트 언어 코드", max_length=10)
|
| 126 |
+
|
| 127 |
+
# 메타데이터
|
| 128 |
+
created_at: Optional[datetime] = Field(default_factory=datetime.now, description="생성 타임스탬프")
|
| 129 |
+
|
| 130 |
+
model_config = {
|
| 131 |
+
"json_schema_extra": {
|
| 132 |
+
"example": {
|
| 133 |
+
"text_id": 1, "element_id": 1, "ocr_text": "1. 다음 중 옳은 것을 고르시오.",
|
| 134 |
+
"ocr_engine": "PaddleOCR", "ocr_confidence": 0.98, "language": "ko",
|
| 135 |
+
}
|
| 136 |
+
}
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
# ===>>> v2.1 스키마 Mock 모델 (마이그레이션 계획에 따라 추가됨) <<<===
|
| 140 |
+
@dataclass # 계획에서 정의된 대로 dataclass 사용
|
| 141 |
+
class MockQuestionGroup:
|
| 142 |
+
"""
|
| 143 |
+
v2.1 question_groups 테이블 Mock
|
| 144 |
+
|
| 145 |
+
E-R 다이어그램 line 199-234 참조
|
| 146 |
+
"""
|
| 147 |
+
question_group_id: int
|
| 148 |
+
page_id: int
|
| 149 |
+
anchor_element_id: Optional[int] # None = 고아 그룹
|
| 150 |
+
group_type: str # 'anchor' | 'orphan'
|
| 151 |
+
start_y: int
|
| 152 |
+
end_y: int
|
| 153 |
+
element_count: int
|
| 154 |
+
created_at: Optional[str] = None # Mock에서는 간단히 str 사용
|
| 155 |
+
updated_at: Optional[str] = None # Mock에서는 간단히 str 사용
|
| 156 |
+
|
| 157 |
+
@dataclass # 계획에서 정의된 대로 dataclass 사용
|
| 158 |
+
class MockQuestionElement:
|
| 159 |
+
"""
|
| 160 |
+
v2.1 question_elements 테이블 Mock (N:M 매핑 테이블)
|
| 161 |
+
|
| 162 |
+
E-R 다이어그램 line 236-261 참조
|
| 163 |
+
"""
|
| 164 |
+
qe_id: int # PK, auto_increment 시뮬레이션
|
| 165 |
+
question_group_id: int # FK -> question_groups
|
| 166 |
+
element_id: int # FK -> layout_elements
|
| 167 |
+
order_in_question: int # 전체 정렬 순서 (sorter.py 결과)
|
| 168 |
+
order_in_group: int # 그룹 내 정렬 순서 (sorter.py 결과)
|
| 169 |
+
created_at: Optional[str] = None # Mock에서는 간단히 str 사용
|
| 170 |
+
# ===>>> v2.1 스키마 Mock 모델 끝 <<<===
|
| 171 |
+
|
| 172 |
+
|
| 173 |
+
# ============================================================================
|
| 174 |
+
# 순수 Python 클래스 구현 (Pydantic이 설치되지 않은 경우)
|
| 175 |
+
# ============================================================================
|
| 176 |
+
|
| 177 |
+
else:
|
| 178 |
+
|
| 179 |
+
@dataclass
|
| 180 |
+
class MockElement:
|
| 181 |
+
"""
|
| 182 |
+
layout_elements 테이블을 대체하는 Mock 모델 (순수 Python 버전).
|
| 183 |
+
|
| 184 |
+
v2.1 참고: Pydantic 버전 주석 참조. 정렬 필드는 sorter.py 호환성용.
|
| 185 |
+
"""
|
| 186 |
+
element_id: int
|
| 187 |
+
class_name: str
|
| 188 |
+
confidence: float
|
| 189 |
+
bbox_x: int
|
| 190 |
+
bbox_y: int
|
| 191 |
+
bbox_width: int
|
| 192 |
+
bbox_height: int
|
| 193 |
+
page_id: Optional[int] = None
|
| 194 |
+
created_at: Optional[datetime] = None
|
| 195 |
+
|
| 196 |
+
# sorter.py가 동적으로 추가하는 필드
|
| 197 |
+
order_in_question: Optional[int] = None
|
| 198 |
+
group_id: Optional[int] = None
|
| 199 |
+
order_in_group: Optional[int] = None
|
| 200 |
+
|
| 201 |
+
def __post_init__(self):
|
| 202 |
+
"""순수 Python 버전 유효성 검사"""
|
| 203 |
+
if self.element_id < 1: raise ValueError("element_id는 1 이상이어야 합니다")
|
| 204 |
+
if not self.class_name or len(self.class_name) > 100: raise ValueError("class_name 길이가 유효하지 않습니다")
|
| 205 |
+
if not (0.0 <= self.confidence <= 1.0): raise ValueError("confidence는 0.0과 1.0 사이여야 합니다")
|
| 206 |
+
if self.bbox_x < 0 or self.bbox_y < 0: raise ValueError("bbox 좌표는 0 이상이어야 합니다")
|
| 207 |
+
if self.bbox_width < 1 or self.bbox_height < 1: raise ValueError("bbox 크기는 1 이상이어야 합니다")
|
| 208 |
+
if self.page_id is not None and self.page_id < 1: raise ValueError("page_id는 1 이상이어야 합니다")
|
| 209 |
+
self.created_at = self.created_at or datetime.now()
|
| 210 |
+
|
| 211 |
+
@property
|
| 212 |
+
def area(self) -> int: return self.bbox_width * self.bbox_height
|
| 213 |
+
@property
|
| 214 |
+
def y_position(self) -> int: return self.bbox_y
|
| 215 |
+
@property
|
| 216 |
+
def x_position(self) -> int: return self.bbox_x
|
| 217 |
+
|
| 218 |
+
def to_dict(self) -> Dict[str, Any]:
|
| 219 |
+
"""인스턴스를 딕셔너리로 변환"""
|
| 220 |
+
data = self.__dict__.copy()
|
| 221 |
+
data["area"] = self.area
|
| 222 |
+
data["y_position"] = self.y_position
|
| 223 |
+
data["x_position"] = self.x_position
|
| 224 |
+
if self.created_at: data["created_at"] = self.created_at.isoformat()
|
| 225 |
+
return data
|
| 226 |
+
|
| 227 |
+
def to_json(self, indent: Optional[int] = None) -> str:
|
| 228 |
+
"""인스턴스를 JSON 문자열로 변환"""
|
| 229 |
+
return json_module.dumps(self.to_dict(), ensure_ascii=False, indent=indent)
|
| 230 |
+
|
| 231 |
+
def __repr__(self) -> str:
|
| 232 |
+
return (f"MockElement(id={self.element_id}, cls='{self.class_name}', "
|
| 233 |
+
f"bbox=({self.bbox_x}, {self.bbox_y}, {self.bbox_width}, {self.bbox_height}))")
|
| 234 |
+
|
| 235 |
+
@dataclass
|
| 236 |
+
class MockTextContent:
|
| 237 |
+
"""
|
| 238 |
+
text_contents 테이블을 대체하는 Mock 모델 (순수 Python 버전).
|
| 239 |
+
"""
|
| 240 |
+
text_id: int
|
| 241 |
+
element_id: int
|
| 242 |
+
ocr_text: str
|
| 243 |
+
ocr_engine: str = "PaddleOCR"
|
| 244 |
+
ocr_confidence: Optional[float] = None
|
| 245 |
+
language: str = "ko"
|
| 246 |
+
created_at: Optional[datetime] = None
|
| 247 |
+
|
| 248 |
+
def __post_init__(self):
|
| 249 |
+
"""순수 Python 버전 유효성 검사"""
|
| 250 |
+
if self.text_id < 1: raise ValueError("text_id는 1 이상이어야 합니다")
|
| 251 |
+
if self.element_id < 1: raise ValueError("element_id는 1 이상이어야 합니다")
|
| 252 |
+
if not self.ocr_text: raise ValueError("ocr_text는 비어 있을 수 없습니다")
|
| 253 |
+
if self.ocr_confidence is not None and not (0.0 <= self.ocr_confidence <= 1.0): raise ValueError("ocr_confidence는 0.0과 1.0 사이여야 합니다")
|
| 254 |
+
if len(self.ocr_engine) > 50: raise ValueError("ocr_engine이 너무 깁니다")
|
| 255 |
+
if len(self.language) > 10: raise ValueError("language가 너무 깁니다")
|
| 256 |
+
self.created_at = self.created_at or datetime.now()
|
| 257 |
+
|
| 258 |
+
def to_dict(self) -> Dict[str, Any]:
|
| 259 |
+
"""인스턴스를 딕셔너리로 변환"""
|
| 260 |
+
data = self.__dict__.copy()
|
| 261 |
+
if self.created_at: data["created_at"] = self.created_at.isoformat()
|
| 262 |
+
return data
|
| 263 |
+
|
| 264 |
+
def to_json(self, indent: Optional[int] = None) -> str:
|
| 265 |
+
"""인스턴스를 JSON 문자열로 변환"""
|
| 266 |
+
return json_module.dumps(self.to_dict(), ensure_ascii=False, indent=indent)
|
| 267 |
+
|
| 268 |
+
def __repr__(self) -> str:
|
| 269 |
+
return (f"MockTextContent(id={self.text_id}, elem_id={self.element_id}, "
|
| 270 |
+
f"text='{self.ocr_text[:30]}...')")
|
| 271 |
+
|
| 272 |
+
# ===>>> v2.1 스키마 Mock 모델 (순수 Python) <<<===
|
| 273 |
+
@dataclass
|
| 274 |
+
class MockQuestionGroup:
|
| 275 |
+
"""v2.1 question_groups 테이블 Mock (순수 Python)"""
|
| 276 |
+
question_group_id: int
|
| 277 |
+
page_id: int
|
| 278 |
+
anchor_element_id: Optional[int]
|
| 279 |
+
group_type: str
|
| 280 |
+
start_y: int
|
| 281 |
+
end_y: int
|
| 282 |
+
element_count: int
|
| 283 |
+
created_at: Optional[str] = None
|
| 284 |
+
updated_at: Optional[str] = None
|
| 285 |
+
|
| 286 |
+
@dataclass
|
| 287 |
+
class MockQuestionElement:
|
| 288 |
+
"""v2.1 question_elements 테이블 Mock (순수 Python)"""
|
| 289 |
+
qe_id: int
|
| 290 |
+
question_group_id: int
|
| 291 |
+
element_id: int
|
| 292 |
+
order_in_question: int
|
| 293 |
+
order_in_group: int
|
| 294 |
+
created_at: Optional[str] = None
|
| 295 |
+
# ===>>> v2.1 스키마 Mock 모델 끝 <<<===
|
| 296 |
+
|
| 297 |
+
|
| 298 |
+
# ============================================================================
|
| 299 |
+
# 유틸리티 함수 (공통)
|
| 300 |
+
# ============================================================================
|
| 301 |
+
|
| 302 |
+
def create_mock_element_from_detection(
|
| 303 |
+
element_id: int,
|
| 304 |
+
detection_result: Dict[str, Any],
|
| 305 |
+
page_id: Optional[int] = None
|
| 306 |
+
) -> MockElement:
|
| 307 |
+
"""
|
| 308 |
+
레이아웃 탐지 결과로부터 MockElement 생성.
|
| 309 |
+
다양한 bbox 형식을 처리합니다.
|
| 310 |
+
"""
|
| 311 |
+
bbox = detection_result['bbox']
|
| 312 |
+
bbox_x, bbox_y, bbox_width, bbox_height = 0, 0, 0, 0 # 초기화
|
| 313 |
+
|
| 314 |
+
if isinstance(bbox, list) and len(bbox) == 4:
|
| 315 |
+
# 형식 [x, y, 너비, 높이] 또는 [x1, y1, x2, y2] 가정
|
| 316 |
+
# 실제 사용된 YOLO 출력 형식에 따라 명확화 필요
|
| 317 |
+
# 현재는 순수 Python init 기반으로 [x, y, 너비, 높이] 가정
|
| 318 |
+
bbox_x, bbox_y, bbox_width, bbox_height = bbox
|
| 319 |
+
# 만약 [x1, y1, x2, y2] 형식이면 아래 주석 해제:
|
| 320 |
+
# bbox_x, bbox_y = bbox[0], bbox[1]
|
| 321 |
+
# bbox_width, bbox_height = bbox[2] - bbox[0], bbox[3] - bbox[1]
|
| 322 |
+
elif isinstance(bbox, dict):
|
| 323 |
+
bbox_x = bbox.get('x', 0)
|
| 324 |
+
bbox_y = bbox.get('y', 0)
|
| 325 |
+
bbox_width = bbox.get('width', 0)
|
| 326 |
+
bbox_height = bbox.get('height', 0)
|
| 327 |
+
|
| 328 |
+
# 유효성 검사를 위해 너비/높이가 양수인지 확인
|
| 329 |
+
bbox_width = max(1, bbox_width)
|
| 330 |
+
bbox_height = max(1, bbox_height)
|
| 331 |
+
|
| 332 |
+
return MockElement(
|
| 333 |
+
element_id=element_id,
|
| 334 |
+
page_id=page_id,
|
| 335 |
+
class_name=str(detection_result['class_name']), # str 확인
|
| 336 |
+
confidence=float(detection_result['confidence']), # float 확인
|
| 337 |
+
bbox_x=int(bbox_x), # int 확인
|
| 338 |
+
bbox_y=int(bbox_y), # int 확인
|
| 339 |
+
bbox_width=int(bbox_width), # int 확인
|
| 340 |
+
bbox_height=int(bbox_height) # int 확인
|
| 341 |
+
)
|
| 342 |
+
|
| 343 |
+
|
| 344 |
+
def create_mock_text_content(
|
| 345 |
+
text_id: int,
|
| 346 |
+
element_id: int,
|
| 347 |
+
ocr_result: str,
|
| 348 |
+
ocr_confidence: Optional[float] = None,
|
| 349 |
+
ocr_engine: str = "PaddleOCR"
|
| 350 |
+
) -> MockTextContent:
|
| 351 |
+
"""
|
| 352 |
+
OCR 결과로부터 MockTextContent 생성.
|
| 353 |
+
"""
|
| 354 |
+
return MockTextContent(
|
| 355 |
+
text_id=text_id,
|
| 356 |
+
element_id=element_id,
|
| 357 |
+
ocr_text=ocr_result,
|
| 358 |
+
ocr_engine=ocr_engine,
|
| 359 |
+
ocr_confidence=ocr_confidence
|
| 360 |
+
)
|
| 361 |
+
|
| 362 |
+
|
| 363 |
+
# ============================================================================
|
| 364 |
+
# 사용 예시 (공통)
|
| 365 |
+
# ============================================================================
|
| 366 |
+
|
| 367 |
+
if __name__ == "__main__":
|
| 368 |
+
print(f"Mock 모델 구현: {'Pydantic' if USE_PYDANTIC else '순수 Python'}")
|
| 369 |
+
|
| 370 |
+
# 예시 1: MockElement
|
| 371 |
+
element = MockElement(
|
| 372 |
+
element_id=1, page_id=1, class_name="question_number", confidence=0.95,
|
| 373 |
+
bbox_x=100, bbox_y=200, bbox_width=50, bbox_height=30
|
| 374 |
+
)
|
| 375 |
+
print("\nMockElement 예시:")
|
| 376 |
+
print(element)
|
| 377 |
+
print(f"면적: {element.area}, Y-위치: {element.y_position}, X-위치: {element.x_position}")
|
| 378 |
+
|
| 379 |
+
# 예시 2: MockTextContent
|
| 380 |
+
text_content = MockTextContent(
|
| 381 |
+
text_id=1, element_id=1, ocr_text="1. 다음 중 옳은 것을 고르시오.", ocr_confidence=0.98
|
| 382 |
+
)
|
| 383 |
+
print("\nMockTextContent 예시:")
|
| 384 |
+
print(text_content)
|
| 385 |
+
|
| 386 |
+
# 예시 3: v2.1 모델
|
| 387 |
+
group = MockQuestionGroup(
|
| 388 |
+
question_group_id=1, page_id=1, anchor_element_id=1, group_type='anchor',
|
| 389 |
+
start_y=100, end_y=450, element_count=3
|
| 390 |
+
)
|
| 391 |
+
qe = MockQuestionElement(
|
| 392 |
+
qe_id=1, question_group_id=1, element_id=2, order_in_question=1, order_in_group=1
|
| 393 |
+
)
|
| 394 |
+
print("\nv2.1 MockQuestionGroup 예시:")
|
| 395 |
+
print(group)
|
| 396 |
+
print("\nv2.1 MockQuestionElement 예시:")
|
| 397 |
+
print(qe)
|
| 398 |
+
|
| 399 |
+
# 예시 4: 유틸리티 함수
|
| 400 |
+
detection = {'class_name': 'figure', 'confidence': 0.88, 'bbox': [50, 300, 200, 150]}
|
| 401 |
+
element_util = create_mock_element_from_detection(2, detection, page_id=1)
|
| 402 |
+
print("\n유틸리티 함수 예시 (Element):")
|
| 403 |
+
print(element_util)
|
| 404 |
+
|
| 405 |
+
text_util = create_mock_text_content(2, 2, "그림 A는 ...", ocr_confidence=0.91)
|
| 406 |
+
print("\n유틸리티 함수 예시 (Text):")
|
| 407 |
+
print(text_util)
|
| 408 |
+
|
| 409 |
+
# 예시 5: JSON 직렬화
|
| 410 |
+
print("\nJSON 직렬화 예시:")
|
| 411 |
+
if USE_PYDANTIC:
|
| 412 |
+
# Pydantic v2는 model_dump_json 사용
|
| 413 |
+
print(element.model_dump_json(indent=2)) # type: ignore[attr-defined]
|
| 414 |
+
print(text_content.model_dump_json(indent=2)) # type: ignore[attr-defined]
|
| 415 |
+
else:
|
| 416 |
+
print(element.to_json(indent=2))
|
| 417 |
+
print(text_content.to_json(indent=2))
|
app/services/pdf_processor.py
ADDED
|
@@ -0,0 +1,250 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# -*- coding: utf-8 -*-
|
| 2 |
+
"""
|
| 3 |
+
SmartEyeSsen PDF 처리 서비스
|
| 4 |
+
============================
|
| 5 |
+
|
| 6 |
+
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 |
+
|
| 20 |
+
|
| 21 |
+
class PDFProcessor:
|
| 22 |
+
"""PDF 파일 처리 클래스"""
|
| 23 |
+
|
| 24 |
+
def __init__(self, upload_directory: str = "uploads", dpi: Optional[int] = None):
|
| 25 |
+
"""
|
| 26 |
+
PDF 처리기 초기화
|
| 27 |
+
|
| 28 |
+
Args:
|
| 29 |
+
upload_directory: 파일 저장 기본 디렉토리
|
| 30 |
+
dpi: 이미지 변환 해상도 (기본값: 300)
|
| 31 |
+
"""
|
| 32 |
+
self.upload_directory = upload_directory
|
| 33 |
+
self.dpi = self._resolve_dpi(dpi)
|
| 34 |
+
self.jpeg_quality = 95
|
| 35 |
+
os.makedirs(upload_directory, exist_ok=True)
|
| 36 |
+
logger.info(
|
| 37 |
+
f"PDFProcessor 초기화 완료 - DPI: {self.dpi}, 저장 경로: {upload_directory}"
|
| 38 |
+
)
|
| 39 |
+
|
| 40 |
+
@staticmethod
|
| 41 |
+
def _resolve_dpi(provided_dpi: Optional[int]) -> int:
|
| 42 |
+
"""환경 변수와 인자 값을 고려해 DPI를 결정"""
|
| 43 |
+
if provided_dpi and provided_dpi > 0:
|
| 44 |
+
return int(provided_dpi)
|
| 45 |
+
|
| 46 |
+
env_value = os.getenv("PDF_PROCESSOR_DPI")
|
| 47 |
+
if env_value:
|
| 48 |
+
try:
|
| 49 |
+
parsed = int(env_value)
|
| 50 |
+
if parsed > 0:
|
| 51 |
+
logger.debug(
|
| 52 |
+
f"환경 변수 PDF_PROCESSOR_DPI 적용: {parsed} (인자 미지정)"
|
| 53 |
+
)
|
| 54 |
+
return parsed
|
| 55 |
+
except ValueError:
|
| 56 |
+
logger.warning(
|
| 57 |
+
f"환경 변수 PDF_PROCESSOR_DPI 값 '{env_value}'을(를) 정수로 변환할 수 없어 기본값 {DEFAULT_PDF_DPI}을 사용합니다."
|
| 58 |
+
)
|
| 59 |
+
return DEFAULT_PDF_DPI
|
| 60 |
+
|
| 61 |
+
def convert_pdf_to_images(
|
| 62 |
+
self,
|
| 63 |
+
pdf_bytes: bytes,
|
| 64 |
+
project_id: int,
|
| 65 |
+
start_page_number: int
|
| 66 |
+
) -> List[Dict[str, any]]:
|
| 67 |
+
"""
|
| 68 |
+
PDF 바이트 데이터를 페이지별 이미지로 변환하고 저장
|
| 69 |
+
|
| 70 |
+
Args:
|
| 71 |
+
pdf_bytes: PDF 파일의 바이트 데이터
|
| 72 |
+
project_id: 프로젝트 ID (폴더 경로용)
|
| 73 |
+
start_page_number: 시작 페이지 번호
|
| 74 |
+
|
| 75 |
+
Returns:
|
| 76 |
+
변환된 이미지 정보 리스트
|
| 77 |
+
[
|
| 78 |
+
{
|
| 79 |
+
'page_number': 1,
|
| 80 |
+
'image_path': '123/page_1.jpg', # DB 저장용 상대 경로
|
| 81 |
+
'full_path': 'uploads/123/page_1.jpg', # 실제 파일 경로
|
| 82 |
+
'width': 2480,
|
| 83 |
+
'height': 3508
|
| 84 |
+
},
|
| 85 |
+
...
|
| 86 |
+
]
|
| 87 |
+
|
| 88 |
+
Raises:
|
| 89 |
+
ValueError: PDF 파일이 손상되었거나 읽을 수 없는 경우
|
| 90 |
+
OSError: 파일 저장 중 디스크 오류 발생 시
|
| 91 |
+
"""
|
| 92 |
+
logger.info(f"PDF 변환 시작 - ProjectID: {project_id}, 시작 페이지: {start_page_number}")
|
| 93 |
+
|
| 94 |
+
# 프로젝트별 저장 디렉토리 생성
|
| 95 |
+
project_dir = os.path.join(self.upload_directory, str(project_id))
|
| 96 |
+
os.makedirs(project_dir, exist_ok=True)
|
| 97 |
+
|
| 98 |
+
converted_pages = []
|
| 99 |
+
pdf_document = None
|
| 100 |
+
|
| 101 |
+
try:
|
| 102 |
+
# PDF 문서 열기
|
| 103 |
+
pdf_document = fitz.open(stream=pdf_bytes, filetype="pdf")
|
| 104 |
+
total_pages = len(pdf_document)
|
| 105 |
+
logger.info(f"PDF 페이지 수: {total_pages}")
|
| 106 |
+
|
| 107 |
+
if total_pages == 0:
|
| 108 |
+
raise ValueError("PDF 파일에 페이지가 없습니다.")
|
| 109 |
+
|
| 110 |
+
# PDF 원본 파일 저장
|
| 111 |
+
original_pdf_path = os.path.join(project_dir, "original.pdf")
|
| 112 |
+
with open(original_pdf_path, "wb") as f:
|
| 113 |
+
f.write(pdf_bytes)
|
| 114 |
+
logger.info(f"PDF 원본 저장 완료: {original_pdf_path}")
|
| 115 |
+
|
| 116 |
+
# 각 페이지를 이미지로 변환
|
| 117 |
+
for page_index in range(total_pages):
|
| 118 |
+
page_number = start_page_number + page_index
|
| 119 |
+
|
| 120 |
+
try:
|
| 121 |
+
# PDF 페이지를 Pixmap으로 렌더링
|
| 122 |
+
page = pdf_document[page_index]
|
| 123 |
+
|
| 124 |
+
# DPI 기반 확대 비율 계산 (72 DPI가 기본)
|
| 125 |
+
zoom = self.dpi / 72
|
| 126 |
+
mat = fitz.Matrix(zoom, zoom)
|
| 127 |
+
pix = page.get_pixmap(matrix=mat, alpha=False)
|
| 128 |
+
|
| 129 |
+
# PIL Image로 변환
|
| 130 |
+
img_data = pix.tobytes("jpeg")
|
| 131 |
+
img = Image.open(io.BytesIO(img_data))
|
| 132 |
+
|
| 133 |
+
# 이미지 크기
|
| 134 |
+
width, height = img.size
|
| 135 |
+
|
| 136 |
+
# 파일명 및 경로 생성
|
| 137 |
+
filename = f"page_{page_number}.jpg"
|
| 138 |
+
full_path = os.path.join(project_dir, filename)
|
| 139 |
+
relative_path = os.path.join(str(project_id), filename)
|
| 140 |
+
|
| 141 |
+
# 이미지 저장 (JPEG 품질 적용)
|
| 142 |
+
img.save(full_path, "JPEG", quality=self.jpeg_quality, optimize=True)
|
| 143 |
+
|
| 144 |
+
# 변환 정보 저장
|
| 145 |
+
page_info = {
|
| 146 |
+
'page_number': page_number,
|
| 147 |
+
'image_path': relative_path,
|
| 148 |
+
'full_path': full_path,
|
| 149 |
+
'width': width,
|
| 150 |
+
'height': height,
|
| 151 |
+
'dpi': self.dpi,
|
| 152 |
+
}
|
| 153 |
+
converted_pages.append(page_info)
|
| 154 |
+
|
| 155 |
+
logger.debug(
|
| 156 |
+
f"페이지 {page_index + 1}/{total_pages} 변환 완료 - "
|
| 157 |
+
f"페이지 번호: {page_number}, 크기: {width}x{height}"
|
| 158 |
+
)
|
| 159 |
+
|
| 160 |
+
except Exception as e:
|
| 161 |
+
logger.error(f"페이지 {page_index + 1} 변환 실패: {str(e)}")
|
| 162 |
+
# 부분 변환 실패 시 롤백
|
| 163 |
+
self._rollback_conversion(converted_pages)
|
| 164 |
+
raise ValueError(f"PDF 페이지 {page_index + 1} 변환 실패: {str(e)}")
|
| 165 |
+
|
| 166 |
+
logger.info(
|
| 167 |
+
f"PDF 변환 완료 - ProjectID: {project_id}, "
|
| 168 |
+
f"총 {len(converted_pages)}개 페이지 변환"
|
| 169 |
+
)
|
| 170 |
+
return converted_pages
|
| 171 |
+
|
| 172 |
+
except fitz.fitz.FileDataError as e:
|
| 173 |
+
logger.error(f"PDF 파일 오류: {str(e)}")
|
| 174 |
+
raise ValueError(f"PDF 파일이 손상되었거나 읽을 수 없습니다: {str(e)}")
|
| 175 |
+
|
| 176 |
+
except Exception as e:
|
| 177 |
+
logger.error(f"PDF 변환 중 예상치 못한 오류: {str(e)}")
|
| 178 |
+
if converted_pages:
|
| 179 |
+
self._rollback_conversion(converted_pages)
|
| 180 |
+
raise
|
| 181 |
+
|
| 182 |
+
finally:
|
| 183 |
+
# PDF 문서 닫기
|
| 184 |
+
if pdf_document:
|
| 185 |
+
pdf_document.close()
|
| 186 |
+
|
| 187 |
+
def _rollback_conversion(self, converted_pages: List[Dict[str, any]]) -> None:
|
| 188 |
+
"""
|
| 189 |
+
변환 실패 시 생성된 이미지 파일 롤백
|
| 190 |
+
|
| 191 |
+
Args:
|
| 192 |
+
converted_pages: 롤백할 페이지 정보 리스트
|
| 193 |
+
"""
|
| 194 |
+
logger.warning(f"변환 롤백 시작 - {len(converted_pages)}개 파일 삭제")
|
| 195 |
+
|
| 196 |
+
for page_info in converted_pages:
|
| 197 |
+
try:
|
| 198 |
+
full_path = page_info.get('full_path')
|
| 199 |
+
if full_path and os.path.exists(full_path):
|
| 200 |
+
os.remove(full_path)
|
| 201 |
+
logger.debug(f"파일 삭제: {full_path}")
|
| 202 |
+
except Exception as e:
|
| 203 |
+
logger.error(f"롤백 중 파일 삭제 실패: {full_path}, 오류: {str(e)}")
|
| 204 |
+
|
| 205 |
+
logger.info("변환 롤백 완료")
|
| 206 |
+
|
| 207 |
+
def get_pdf_info(self, pdf_bytes: bytes) -> Dict[str, any]:
|
| 208 |
+
"""
|
| 209 |
+
PDF 파일의 메타데이터 추출
|
| 210 |
+
|
| 211 |
+
Args:
|
| 212 |
+
pdf_bytes: PDF 파일의 바이트 데이터
|
| 213 |
+
|
| 214 |
+
Returns:
|
| 215 |
+
PDF 정보 딕셔너리
|
| 216 |
+
{
|
| 217 |
+
'total_pages': 10,
|
| 218 |
+
'title': '문서 제목',
|
| 219 |
+
'author': '작성자',
|
| 220 |
+
'subject': '주제',
|
| 221 |
+
'creator': '생성 프로그램',
|
| 222 |
+
'producer': 'PDF 생성기',
|
| 223 |
+
'creation_date': '생성 날짜'
|
| 224 |
+
}
|
| 225 |
+
"""
|
| 226 |
+
try:
|
| 227 |
+
pdf_document = fitz.open(stream=pdf_bytes, filetype="pdf")
|
| 228 |
+
metadata = pdf_document.metadata
|
| 229 |
+
|
| 230 |
+
info = {
|
| 231 |
+
'total_pages': len(pdf_document),
|
| 232 |
+
'title': metadata.get('title', ''),
|
| 233 |
+
'author': metadata.get('author', ''),
|
| 234 |
+
'subject': metadata.get('subject', ''),
|
| 235 |
+
'creator': metadata.get('creator', ''),
|
| 236 |
+
'producer': metadata.get('producer', ''),
|
| 237 |
+
'creation_date': metadata.get('creationDate', '')
|
| 238 |
+
}
|
| 239 |
+
|
| 240 |
+
pdf_document.close()
|
| 241 |
+
logger.debug(f"PDF 메타데이터 추출 완료: {info}")
|
| 242 |
+
return info
|
| 243 |
+
|
| 244 |
+
except Exception as e:
|
| 245 |
+
logger.error(f"PDF 메타데이터 추출 실패: {str(e)}")
|
| 246 |
+
raise ValueError(f"PDF 파일 정보를 읽을 수 없습니다: {str(e)}")
|
| 247 |
+
|
| 248 |
+
|
| 249 |
+
# 전역 인스턴스 생성 (싱글톤 패턴)
|
| 250 |
+
pdf_processor = PDFProcessor(upload_directory="uploads")
|
app/services/sorter.py
ADDED
|
@@ -0,0 +1,1605 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# -*- coding: utf-8 -*-
|
| 2 |
+
"""
|
| 3 |
+
SmartEyeSsen Layout Sorter (v.LayoutDetect.2.4 - Tie-breaker in Post-processing)
|
| 4 |
+
=================================================================================
|
| 5 |
+
|
| 6 |
+
문제 레이아웃 정렬 알고리즘 구현 (Layout Type Detection 기반 Hybrid)
|
| 7 |
+
페이지 전체 레이아웃 유형(1단, 2단, 혼합형 등)을 먼저 판별하고,
|
| 8 |
+
유형에 맞는 분할 전략(수평/수직) 적용.
|
| 9 |
+
분할 실패 시(Base Case), 레이아웃 유형별로 특화된 그룹핑 로직 호출.
|
| 10 |
+
- 표준 1단/2단 컬럼: _base_case_standard_1_column
|
| 11 |
+
- 혼합형: _base_case_mixed_layout
|
| 12 |
+
최종 병합 시 전역 고아 그룹 처리 로직 적용.
|
| 13 |
+
|
| 14 |
+
알고리즘 흐름: (v.LayoutDetect.2.1/2.2/2.3과 동일)
|
| 15 |
+
0. 전처리
|
| 16 |
+
1. 레이아웃 유형 판별
|
| 17 |
+
2. 유형별 재귀 처리
|
| 18 |
+
3. Base Case 처리 (후처리 포함)
|
| 19 |
+
4. 최종 병합 및 순서 부여
|
| 20 |
+
|
| 21 |
+
v.LayoutDetect.2.4:
|
| 22 |
+
- _post_process_table_figure_assignment: 최적 그룹 탐색 시 Y 거리가 동일할 경우 더 뒤쪽 그룹을 우선하는 Tie-breaker 추가.
|
| 23 |
+
- sort_layout_elements: 후처리 호출 전에 임시 그룹 ID 할당하여 로그 가독성 개선.
|
| 24 |
+
- (v2.3 변경 유지) _post_process_table_figure_assignment: 최적 그룹 탐색 로직 (Lookahead).
|
| 25 |
+
- (v2.2 변경 유지) _post_process_table_figure_assignment: 이동 조건은 거리 비교 로직 사용.
|
| 26 |
+
- (v2.1 변경 유지) _post_process_table_figure_assignment: y_diff_threshold 기본값 150.
|
| 27 |
+
- (v2.1 변경 유지) _base_case_standard_1_column: 상단 고아 요소 분리 로직.
|
| 28 |
+
"""
|
| 29 |
+
|
| 30 |
+
# 필요한 라이브러리 임포트
|
| 31 |
+
from typing import List, Dict, Tuple, Optional, Any, Union, TYPE_CHECKING
|
| 32 |
+
from dataclasses import dataclass, field
|
| 33 |
+
import numpy as np
|
| 34 |
+
from sklearn.cluster import KMeans
|
| 35 |
+
from loguru import logger
|
| 36 |
+
import math
|
| 37 |
+
from enum import Enum, auto
|
| 38 |
+
import os
|
| 39 |
+
|
| 40 |
+
# Mock 모델 임포트 (호환성 유지용, 추후 제거 예정)
|
| 41 |
+
from .mock_models import MockElement
|
| 42 |
+
|
| 43 |
+
if TYPE_CHECKING:
|
| 44 |
+
from sqlalchemy.orm import Session
|
| 45 |
+
from ..models import LayoutElement
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
# ============================================================================
|
| 49 |
+
# 데이터 클래스 및 Enum 정의 (기존과 동일)
|
| 50 |
+
# ============================================================================
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
class LayoutType(Enum):
|
| 54 |
+
STANDARD_1_COLUMN = auto()
|
| 55 |
+
STANDARD_2_COLUMN = auto()
|
| 56 |
+
MIXED_TOP1_BOTTOM2 = auto()
|
| 57 |
+
MIXED_TOP2_BOTTOM1 = auto()
|
| 58 |
+
HORIZONTAL_SEP_PRESENT = auto()
|
| 59 |
+
READING_ORDER = auto()
|
| 60 |
+
UNKNOWN = auto()
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
@dataclass
|
| 64 |
+
class Zone:
|
| 65 |
+
x_min: int
|
| 66 |
+
y_min: int
|
| 67 |
+
x_max: int
|
| 68 |
+
y_max: int
|
| 69 |
+
|
| 70 |
+
@property
|
| 71 |
+
def width(self) -> int:
|
| 72 |
+
return max(0, self.x_max - self.x_min)
|
| 73 |
+
|
| 74 |
+
@property
|
| 75 |
+
def height(self) -> int:
|
| 76 |
+
return max(0, self.y_max - self.y_min)
|
| 77 |
+
|
| 78 |
+
def __repr__(self) -> str:
|
| 79 |
+
return f"Zone(x=[{self.x_min}, {self.x_max}), y=[{self.y_min}, {self.y_max}))"
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
@dataclass
|
| 83 |
+
class HorizontalSplit:
|
| 84 |
+
top_zone: Zone
|
| 85 |
+
bottom_zone: Zone
|
| 86 |
+
separator_element: MockElement
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
@dataclass
|
| 90 |
+
class HorizontalSplitYGap:
|
| 91 |
+
top_zone: Zone
|
| 92 |
+
bottom_zone: Zone
|
| 93 |
+
split_y: float
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
@dataclass
|
| 97 |
+
class VerticalSplit:
|
| 98 |
+
left_zone: Zone
|
| 99 |
+
right_zone: Zone
|
| 100 |
+
gutter_x: float
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
@dataclass
|
| 104 |
+
class ElementGroup:
|
| 105 |
+
anchor: Optional[MockElement]
|
| 106 |
+
children: List[MockElement] = field(default_factory=list)
|
| 107 |
+
group_id: int = -1 # flatten 함수에서 최종 할당, 후처리 전 임시 할당
|
| 108 |
+
|
| 109 |
+
def add_child(self, child: MockElement):
|
| 110 |
+
self.children.append(child)
|
| 111 |
+
|
| 112 |
+
def get_all_elements_sorted(self) -> List[MockElement]:
|
| 113 |
+
"""
|
| 114 |
+
그룹 내 요소들을 정렬합니다.
|
| 115 |
+
- 앵커(Anchor)가 항상 가장 먼저 위치합니다.
|
| 116 |
+
- 나머지 자식(Children) 요소들은 (Y, X) 좌표 순으로 정렬됩니다.
|
| 117 |
+
"""
|
| 118 |
+
# 1. 앵커가 존재하면 리스트의 첫 요소로 설정합니다.
|
| 119 |
+
elements = [self.anchor] if self.anchor else []
|
| 120 |
+
|
| 121 |
+
# 2. 자식 요소들을 (Y, X) 좌표 기준으로 정렬합니다.
|
| 122 |
+
sorted_children = sorted(
|
| 123 |
+
self.children, key=lambda e: (e.y_position, e.x_position)
|
| 124 |
+
)
|
| 125 |
+
|
| 126 |
+
# 3. 앵커 요소 뒤에 정렬된 자식 요소들을 추가합니다.
|
| 127 |
+
elements.extend(sorted_children)
|
| 128 |
+
|
| 129 |
+
return elements
|
| 130 |
+
|
| 131 |
+
def is_empty(self) -> bool:
|
| 132 |
+
return self.anchor is None and not self.children
|
| 133 |
+
|
| 134 |
+
def __repr__(self) -> str:
|
| 135 |
+
anchor_id = self.anchor.element_id if self.anchor else "Orphan"
|
| 136 |
+
child_ids = sorted([c.element_id for c in self.children])
|
| 137 |
+
# flatten 전에는 group_id가 임시값일 수 있음
|
| 138 |
+
return f"Group(ID:{self.group_id}, Anchor: {anchor_id}, Children: {child_ids})"
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
# ============================================================================
|
| 142 |
+
# 상수 정의 (기존과 동일)
|
| 143 |
+
# ============================================================================
|
| 144 |
+
|
| 145 |
+
ALLOWED_ANCHORS = ["question type", "question number", "second_question_number"]
|
| 146 |
+
ALLOWED_CHILDREN = ["question text", "list", "choices", "figure", "table", "flowchart"]
|
| 147 |
+
ALLOWED_CLASSES = ALLOWED_ANCHORS + ALLOWED_CHILDREN
|
| 148 |
+
|
| 149 |
+
HORIZONTAL_SEP_WIDTH_THRESHOLD = 0.8
|
| 150 |
+
HORIZONTAL_SEP_Y_POS_THRESHOLD = 0.15
|
| 151 |
+
MIN_ANCHORS_FOR_SPLIT = 2
|
| 152 |
+
VERTICAL_GAP_THRESHOLD_RATIO = 1.5
|
| 153 |
+
VERTICAL_GAP_THRESHOLD_ABS = 100
|
| 154 |
+
KMEANS_N_CLUSTERS = 2
|
| 155 |
+
KMEANS_CLUSTER_SEPARATION_MIN = 50
|
| 156 |
+
LAYOUT_DETECT_Y_SPLIT_POINT = 0.4
|
| 157 |
+
LAYOUT_DETECT_X_STD_THRESHOLD_RATIO = 0.1
|
| 158 |
+
|
| 159 |
+
HORIZONTAL_ADJACENCY_Y_CENTER_RATIO = 0.7
|
| 160 |
+
HORIZONTAL_ADJACENCY_X_PROXIMITY = 50
|
| 161 |
+
|
| 162 |
+
BASE_CASE_TOP_ORPHAN_THRESHOLD_RATIO = 0.15
|
| 163 |
+
POST_PROCESS_CLOSENESS_RATIO = 0.5
|
| 164 |
+
POST_PROCESS_LOOKAHEAD = 2
|
| 165 |
+
|
| 166 |
+
# 2D 거리 기반 그룹핑 관련 상수
|
| 167 |
+
ANCHOR_VERTICAL_PROXIMITY_THRESHOLD = 250 # px - 앵커와 Y 거리 임계값
|
| 168 |
+
ANCHOR_2D_DISTANCE_WEIGHT_X = 0.2 # X 거리 가중치 (낮게 설정)
|
| 169 |
+
ANCHOR_2D_DISTANCE_WEIGHT_Y = 1.0 # Y 거리 가중치
|
| 170 |
+
|
| 171 |
+
# ============================================================================
|
| 172 |
+
# 메인 함수: 레이아웃 유형 판별 후 정렬 (수정됨)
|
| 173 |
+
# ============================================================================
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
def _sort_layout_elements_v24(
|
| 177 |
+
elements: List[MockElement],
|
| 178 |
+
document_type: str = "question_based",
|
| 179 |
+
page_width: Optional[int] = None,
|
| 180 |
+
page_height: Optional[int] = None,
|
| 181 |
+
) -> List[MockElement]:
|
| 182 |
+
"""
|
| 183 |
+
레이아웃 유형 판별 후 맞춤형 정렬 로직 적용 (v.LayoutDetect.2.4)
|
| 184 |
+
"""
|
| 185 |
+
logger.info(
|
| 186 |
+
f"맞춤형 정렬(v.LayoutDetect.2.4) 시작: {len(elements)}개 요소, 타입={document_type}"
|
| 187 |
+
)
|
| 188 |
+
|
| 189 |
+
filtered_elements = preprocess_elements(elements, document_type)
|
| 190 |
+
if not filtered_elements:
|
| 191 |
+
logger.warning("전처리 후 정렬할 요소가 없습니다.")
|
| 192 |
+
return []
|
| 193 |
+
|
| 194 |
+
if page_width is None:
|
| 195 |
+
page_width = calculate_page_width(filtered_elements)
|
| 196 |
+
if page_height is None:
|
| 197 |
+
page_height = calculate_page_height(filtered_elements)
|
| 198 |
+
logger.info(f"페이지 크기: {page_width} x {page_height}")
|
| 199 |
+
|
| 200 |
+
initial_zone = Zone(x_min=0, y_min=0, x_max=page_width, y_max=page_height)
|
| 201 |
+
grouped_results: List[ElementGroup] = []
|
| 202 |
+
|
| 203 |
+
try:
|
| 204 |
+
if document_type == "reading_order":
|
| 205 |
+
layout_type = LayoutType.READING_ORDER
|
| 206 |
+
logger.info(f"판별된 레이아웃 유형: {layout_type.name} (문서 타입 지정)")
|
| 207 |
+
sorted_elements_reading = sorted(
|
| 208 |
+
filtered_elements, key=lambda e: (e.y_position, e.x_position)
|
| 209 |
+
)
|
| 210 |
+
grouped_results = [
|
| 211 |
+
ElementGroup(anchor=None, children=[elem])
|
| 212 |
+
for elem in sorted_elements_reading
|
| 213 |
+
]
|
| 214 |
+
else:
|
| 215 |
+
layout_type = detect_layout_type(filtered_elements, page_width, page_height)
|
| 216 |
+
logger.info(f"판별된 레이아웃 유형: {layout_type.name}")
|
| 217 |
+
|
| 218 |
+
if layout_type == LayoutType.STANDARD_1_COLUMN:
|
| 219 |
+
logger.debug(
|
| 220 |
+
f"{layout_type.name}: 분할 없이 전체 구역 표준 1단 Base Case 실행"
|
| 221 |
+
)
|
| 222 |
+
grouped_results = _base_case_standard_1_column(
|
| 223 |
+
initial_zone, filtered_elements
|
| 224 |
+
)
|
| 225 |
+
elif layout_type == LayoutType.STANDARD_2_COLUMN:
|
| 226 |
+
grouped_results = _sort_standard_2_column(
|
| 227 |
+
initial_zone, filtered_elements
|
| 228 |
+
)
|
| 229 |
+
elif layout_type in [
|
| 230 |
+
LayoutType.HORIZONTAL_SEP_PRESENT,
|
| 231 |
+
LayoutType.MIXED_TOP1_BOTTOM2,
|
| 232 |
+
LayoutType.MIXED_TOP2_BOTTOM1,
|
| 233 |
+
LayoutType.UNKNOWN,
|
| 234 |
+
]:
|
| 235 |
+
grouped_results = _sort_recursive_by_layout(
|
| 236 |
+
initial_zone, filtered_elements, layout_type, depth=0
|
| 237 |
+
)
|
| 238 |
+
else:
|
| 239 |
+
logger.error(
|
| 240 |
+
f"처리할 수 없는 레이아웃 유형: {layout_type.name}. (Y,X) 정렬로 대체합니다."
|
| 241 |
+
)
|
| 242 |
+
sorted_elements_fallback = sorted(
|
| 243 |
+
filtered_elements, key=lambda e: (e.y_position, e.x_position)
|
| 244 |
+
)
|
| 245 |
+
grouped_results = [
|
| 246 |
+
ElementGroup(anchor=None, children=[elem])
|
| 247 |
+
for elem in sorted_elements_fallback
|
| 248 |
+
]
|
| 249 |
+
|
| 250 |
+
# --- 👇 수정: 후처리 전에 임시 그룹 ID 할당 (로깅용) ---
|
| 251 |
+
if grouped_results and document_type == "question_based":
|
| 252 |
+
logger.debug("후처리 전 임시 그룹 ID 할당...")
|
| 253 |
+
temp_groups_with_id = []
|
| 254 |
+
temp_group_id_counter = 0
|
| 255 |
+
temp_orphan_groups = [g for g in grouped_results if g.anchor is None]
|
| 256 |
+
temp_non_orphan_groups = [
|
| 257 |
+
g for g in grouped_results if g.anchor is not None
|
| 258 |
+
]
|
| 259 |
+
|
| 260 |
+
# 고아 그룹 먼저 ID 할당
|
| 261 |
+
if temp_orphan_groups:
|
| 262 |
+
temp_orphan_groups.sort(
|
| 263 |
+
key=lambda g: (
|
| 264 |
+
min(c.y_position for c in g.children)
|
| 265 |
+
if g.children
|
| 266 |
+
else float("inf")
|
| 267 |
+
)
|
| 268 |
+
)
|
| 269 |
+
for group in temp_orphan_groups:
|
| 270 |
+
group.group_id = temp_group_id_counter
|
| 271 |
+
temp_groups_with_id.append(group)
|
| 272 |
+
temp_group_id_counter += 1
|
| 273 |
+
|
| 274 |
+
# 앵커 그룹 ID 할당
|
| 275 |
+
# (주의: _post_process... 함수는 앵커 그룹 리스트만 받도록 수정 필요)
|
| 276 |
+
# 우선 여기서 ID만 할당하고, 후처리는 non_orphan_groups 대상으로 수행
|
| 277 |
+
for group in temp_non_orphan_groups:
|
| 278 |
+
group.group_id = temp_group_id_counter
|
| 279 |
+
# temp_groups_with_id.append(group) # flatten 전 최종 순서는 아직 모름
|
| 280 |
+
temp_group_id_counter += 1
|
| 281 |
+
|
| 282 |
+
# 후처리는 앵커가 있는 그룹들을 대상으로 수행
|
| 283 |
+
logger.debug(
|
| 284 |
+
f"{len(temp_non_orphan_groups)}개 앵커 그룹 대상 후처리 실행..."
|
| 285 |
+
)
|
| 286 |
+
processed_non_orphan_groups = _post_process_table_figure_assignment(
|
| 287 |
+
temp_non_orphan_groups
|
| 288 |
+
)
|
| 289 |
+
|
| 290 |
+
# 최종 그룹 리스트 재구성 (고아 + 후처리된 앵커 그룹)
|
| 291 |
+
grouped_results = temp_orphan_groups + processed_non_orphan_groups
|
| 292 |
+
logger.debug("후처리 및 임시 그룹 ID 할당 완료.")
|
| 293 |
+
# --- 👆 수정 끝 ---
|
| 294 |
+
|
| 295 |
+
except Exception as e:
|
| 296 |
+
logger.error(
|
| 297 |
+
f"맞춤형 정렬 중 심각한 오류 발생: {e}. (Y,X) 좌표 정렬로 대체합니다.",
|
| 298 |
+
exc_info=True,
|
| 299 |
+
)
|
| 300 |
+
sorted_elements_fallback = sorted(
|
| 301 |
+
filtered_elements, key=lambda e: (e.y_position, e.x_position)
|
| 302 |
+
)
|
| 303 |
+
grouped_results = [
|
| 304 |
+
ElementGroup(anchor=None, children=[elem])
|
| 305 |
+
for elem in sorted_elements_fallback
|
| 306 |
+
]
|
| 307 |
+
|
| 308 |
+
if not grouped_results:
|
| 309 |
+
logger.warning("그룹핑 결과가 비어 있습니다.")
|
| 310 |
+
return []
|
| 311 |
+
|
| 312 |
+
# 최종 병합: 고아 그룹과 앵커 그룹 순서 결정 (기존 로직 유지)
|
| 313 |
+
orphan_groups = [g for g in grouped_results if g.anchor is None]
|
| 314 |
+
non_orphan_groups = [
|
| 315 |
+
g for g in grouped_results if g.anchor is not None
|
| 316 |
+
] # 후처리된 리스트 사용
|
| 317 |
+
final_ordered_groups: List[ElementGroup] = []
|
| 318 |
+
if orphan_groups:
|
| 319 |
+
# 고아 그룹은 Y 좌표 기준으로 정렬
|
| 320 |
+
orphan_groups.sort(
|
| 321 |
+
key=lambda g: (
|
| 322 |
+
min(c.y_position for c in g.children) if g.children else float("inf")
|
| 323 |
+
)
|
| 324 |
+
)
|
| 325 |
+
logger.debug(
|
| 326 |
+
f"전역 고아 그룹 {len(orphan_groups)}개 (Y 좌표 정렬됨) 리스트 맨 앞으로 이동"
|
| 327 |
+
)
|
| 328 |
+
final_ordered_groups.extend(orphan_groups)
|
| 329 |
+
else:
|
| 330 |
+
logger.debug("전역 고아 그룹 없음")
|
| 331 |
+
# 앵커 그룹은 Base Case/재귀 호출에서 결정된 순서 유지 (Y좌표 정렬 불필요)
|
| 332 |
+
final_ordered_groups.extend(non_orphan_groups)
|
| 333 |
+
|
| 334 |
+
# 최종 순서 및 ID 부여
|
| 335 |
+
final_sorted_elements, _, _ = flatten_groups_and_assign_order(
|
| 336 |
+
final_ordered_groups, start_global_order=0, start_group_id=0
|
| 337 |
+
)
|
| 338 |
+
|
| 339 |
+
logger.info(f"맞춤형 정렬 완료: {len(final_sorted_elements)}개 요소")
|
| 340 |
+
return final_sorted_elements
|
| 341 |
+
|
| 342 |
+
|
| 343 |
+
def _use_adaptive_strategy() -> bool:
|
| 344 |
+
"""환경 변수 기반 Adaptive 전략 사용 여부 판단"""
|
| 345 |
+
return os.getenv("USE_ADAPTIVE_SORTER", "false").lower() in {"1", "true", "yes"}
|
| 346 |
+
|
| 347 |
+
|
| 348 |
+
def sort_layout_elements(
|
| 349 |
+
elements: List[MockElement],
|
| 350 |
+
document_type: str = "question_based",
|
| 351 |
+
page_width: Optional[int] = None,
|
| 352 |
+
page_height: Optional[int] = None,
|
| 353 |
+
page_dpi: Optional[float] = None,
|
| 354 |
+
) -> List[MockElement]:
|
| 355 |
+
"""
|
| 356 |
+
Adaptive 전략 플래그가 활성화된 경우 sorter_strategies의 Adaptive 엔트리포인트로 위임하고,
|
| 357 |
+
그렇지 않으면 v2.4 코어 구현을 그대로 사용한다.
|
| 358 |
+
"""
|
| 359 |
+
if _use_adaptive_strategy():
|
| 360 |
+
from .sorter_strategies import sort_layout_elements_adaptive
|
| 361 |
+
|
| 362 |
+
return sort_layout_elements_adaptive(
|
| 363 |
+
elements=elements,
|
| 364 |
+
document_type=document_type,
|
| 365 |
+
page_width=page_width,
|
| 366 |
+
page_height=page_height,
|
| 367 |
+
force_strategy=None,
|
| 368 |
+
page_dpi=page_dpi,
|
| 369 |
+
)
|
| 370 |
+
|
| 371 |
+
return _sort_layout_elements_v24(
|
| 372 |
+
elements=elements,
|
| 373 |
+
document_type=document_type,
|
| 374 |
+
page_width=page_width,
|
| 375 |
+
page_height=page_height,
|
| 376 |
+
)
|
| 377 |
+
|
| 378 |
+
|
| 379 |
+
# ============================================================================
|
| 380 |
+
# 레이아웃 유형 판별 함수 (기존과 동일)
|
| 381 |
+
# ============================================================================
|
| 382 |
+
def detect_layout_type(
|
| 383 |
+
elements: List[MockElement], page_width: int, page_height: int
|
| 384 |
+
) -> LayoutType:
|
| 385 |
+
# ... (코드 동일) ...
|
| 386 |
+
"""앵커 요소 분포를 분석하여 페이지 레이아웃 유형 판별"""
|
| 387 |
+
anchors = [e for e in elements if e.class_name in ALLOWED_ANCHORS]
|
| 388 |
+
if len(anchors) < MIN_ANCHORS_FOR_SPLIT:
|
| 389 |
+
logger.debug(
|
| 390 |
+
f"레이아웃 판별: 앵커 수({len(anchors)}) 부족 -> STANDARD_1_COLUMN"
|
| 391 |
+
)
|
| 392 |
+
return LayoutType.STANDARD_1_COLUMN
|
| 393 |
+
|
| 394 |
+
top_zone_height = page_height * HORIZONTAL_SEP_Y_POS_THRESHOLD
|
| 395 |
+
wide_q_type = find_wide_question_type(elements, page_width, top_zone_height)
|
| 396 |
+
if wide_q_type:
|
| 397 |
+
logger.debug(
|
| 398 |
+
f"레이아웃 판별: 넓은 question_type(ID:{wide_q_type.element_id}) 존재 -> HORIZONTAL_SEP_PRESENT"
|
| 399 |
+
)
|
| 400 |
+
return LayoutType.HORIZONTAL_SEP_PRESENT
|
| 401 |
+
|
| 402 |
+
anchor_x_centers = np.array([[a.bbox_x + a.bbox_width / 2] for a in anchors])
|
| 403 |
+
is_clearly_2_column = False
|
| 404 |
+
if len(np.unique(anchor_x_centers)) >= 2:
|
| 405 |
+
try:
|
| 406 |
+
kmeans = KMeans(
|
| 407 |
+
n_clusters=KMEANS_N_CLUSTERS, random_state=42, n_init="auto"
|
| 408 |
+
)
|
| 409 |
+
kmeans.fit(anchor_x_centers)
|
| 410 |
+
centers = sorted(kmeans.cluster_centers_.flatten())
|
| 411 |
+
if (
|
| 412 |
+
len(centers) == 2
|
| 413 |
+
and centers[1] - centers[0] >= KMEANS_CLUSTER_SEPARATION_MIN
|
| 414 |
+
):
|
| 415 |
+
is_clearly_2_column = True
|
| 416 |
+
logger.trace(
|
| 417 |
+
f"레이아웃 판별: 전체 X 분포는 2단 구조 가능성 높음 (Centers: {centers})"
|
| 418 |
+
)
|
| 419 |
+
else:
|
| 420 |
+
logger.trace(f"레이아웃 판별: 전체 X 분포는 1단 구조 또는 불분명")
|
| 421 |
+
except Exception as e:
|
| 422 |
+
logger.warning(f"레이아웃 판별 중 K-Means 오류 발생: {e}")
|
| 423 |
+
|
| 424 |
+
if is_clearly_2_column:
|
| 425 |
+
split_y = page_height * LAYOUT_DETECT_Y_SPLIT_POINT
|
| 426 |
+
top_anchors = [
|
| 427 |
+
a for a in anchors if (a.y_position + a.bbox_height / 2) < split_y
|
| 428 |
+
]
|
| 429 |
+
bottom_anchors = [
|
| 430 |
+
a for a in anchors if (a.y_position + a.bbox_height / 2) >= split_y
|
| 431 |
+
]
|
| 432 |
+
|
| 433 |
+
if not top_anchors or not bottom_anchors:
|
| 434 |
+
logger.debug("레이아웃 판별: 상/하단 앵커 그룹 불완전 -> STANDARD_2_COLUMN")
|
| 435 |
+
return LayoutType.STANDARD_2_COLUMN
|
| 436 |
+
|
| 437 |
+
top_x_centers = (
|
| 438 |
+
np.array([[a.bbox_x + a.bbox_width / 2] for a in top_anchors])
|
| 439 |
+
if top_anchors
|
| 440 |
+
else np.array([])
|
| 441 |
+
)
|
| 442 |
+
bottom_x_centers = (
|
| 443 |
+
np.array([[a.bbox_x + a.bbox_width / 2] for a in bottom_anchors])
|
| 444 |
+
if bottom_anchors
|
| 445 |
+
else np.array([])
|
| 446 |
+
)
|
| 447 |
+
|
| 448 |
+
x_std_threshold = page_width * LAYOUT_DETECT_X_STD_THRESHOLD_RATIO
|
| 449 |
+
top_is_multi_column = (
|
| 450 |
+
top_x_centers.size > 1 and np.std(top_x_centers) > x_std_threshold
|
| 451 |
+
)
|
| 452 |
+
bottom_is_multi_column = (
|
| 453 |
+
bottom_x_centers.size > 1 and np.std(bottom_x_centers) > x_std_threshold
|
| 454 |
+
)
|
| 455 |
+
|
| 456 |
+
if not top_is_multi_column and bottom_is_multi_column:
|
| 457 |
+
logger.debug(
|
| 458 |
+
f"레이아웃 판별: 상단({len(top_anchors)}개) 1단, 하단({len(bottom_anchors)}개) 2단 -> MIXED_TOP1_BOTTOM2"
|
| 459 |
+
)
|
| 460 |
+
return LayoutType.MIXED_TOP1_BOTTOM2
|
| 461 |
+
elif top_is_multi_column and not bottom_is_multi_column:
|
| 462 |
+
logger.debug(
|
| 463 |
+
f"레이아웃 판별: 상단({len(top_anchors)}개) 2단, 하단({len(bottom_anchors)}개) 1단 -> MIXED_TOP2_BOTTOM1"
|
| 464 |
+
)
|
| 465 |
+
return LayoutType.MIXED_TOP2_BOTTOM1
|
| 466 |
+
elif top_is_multi_column and bottom_is_multi_column:
|
| 467 |
+
logger.debug(
|
| 468 |
+
f"레이아웃 판별: 상단({len(top_anchors)}개) 2단, 하단({len(bottom_anchors)}개) 2단 -> STANDARD_2_COLUMN"
|
| 469 |
+
)
|
| 470 |
+
return LayoutType.STANDARD_2_COLUMN
|
| 471 |
+
else:
|
| 472 |
+
logger.warning(
|
| 473 |
+
f"레이아웃 판별: 상/하단 모두 1단으로 보이나 전체는 2단 구조? -> UNKNOWN"
|
| 474 |
+
)
|
| 475 |
+
return LayoutType.UNKNOWN
|
| 476 |
+
else:
|
| 477 |
+
logger.debug("레이아웃 판별: 전체 1단 구조 -> STANDARD_1_COLUMN")
|
| 478 |
+
return LayoutType.STANDARD_1_COLUMN
|
| 479 |
+
|
| 480 |
+
|
| 481 |
+
# ============================================================================
|
| 482 |
+
# 재귀 정렬 함수 (기존과 동일)
|
| 483 |
+
# ============================================================================
|
| 484 |
+
def _sort_recursive_by_layout(
|
| 485 |
+
current_zone: Zone,
|
| 486 |
+
elements_in_zone: List[MockElement],
|
| 487 |
+
layout_type: LayoutType,
|
| 488 |
+
depth: int,
|
| 489 |
+
) -> List[ElementGroup]:
|
| 490 |
+
# ... (코드 동일) ...
|
| 491 |
+
"""레이아웃 유형에 따라 다른 분할 우선순위를 적용하는 재귀 함수"""
|
| 492 |
+
indent = " " * depth
|
| 493 |
+
logger.debug(
|
| 494 |
+
f"{indent}[Depth {depth}, Type: {layout_type.name}] 구역 처리 시작: {current_zone}, 요소 수={len(elements_in_zone)}"
|
| 495 |
+
)
|
| 496 |
+
|
| 497 |
+
if not elements_in_zone:
|
| 498 |
+
logger.trace(f"{indent} -> 빈 구역")
|
| 499 |
+
return []
|
| 500 |
+
if len(elements_in_zone) == 1:
|
| 501 |
+
element = elements_in_zone[0]
|
| 502 |
+
logger.trace(f"{indent} -> 요소 1개")
|
| 503 |
+
return (
|
| 504 |
+
[ElementGroup(anchor=element)]
|
| 505 |
+
if element.class_name in ALLOWED_ANCHORS
|
| 506 |
+
else [ElementGroup(anchor=None, children=[element])]
|
| 507 |
+
)
|
| 508 |
+
|
| 509 |
+
if layout_type == LayoutType.STANDARD_2_COLUMN:
|
| 510 |
+
logger.debug(f"{indent} -> {layout_type.name}: 표준 2단 처리 함수 직접 호출")
|
| 511 |
+
return _sort_standard_2_column(current_zone, elements_in_zone)
|
| 512 |
+
|
| 513 |
+
split_result: Optional[
|
| 514 |
+
Union[HorizontalSplit, HorizontalSplitYGap, VerticalSplit]
|
| 515 |
+
] = None
|
| 516 |
+
split_type = "None"
|
| 517 |
+
|
| 518 |
+
if layout_type == LayoutType.HORIZONTAL_SEP_PRESENT:
|
| 519 |
+
split_result = find_horizontal_split_by_type(current_zone, elements_in_zone)
|
| 520 |
+
if split_result:
|
| 521 |
+
split_type = "H_Type"
|
| 522 |
+
else:
|
| 523 |
+
anchors = [e for e in elements_in_zone if e.class_name in ALLOWED_ANCHORS]
|
| 524 |
+
split_result = find_vertical_split_kmeans(current_zone, anchors)
|
| 525 |
+
if split_result:
|
| 526 |
+
split_type = "Vertical"
|
| 527 |
+
else:
|
| 528 |
+
split_result = find_horizontal_split_by_y_gap(
|
| 529 |
+
current_zone, elements_in_zone
|
| 530 |
+
)
|
| 531 |
+
if split_result:
|
| 532 |
+
split_type = "H_YGap"
|
| 533 |
+
|
| 534 |
+
elif (
|
| 535 |
+
layout_type == LayoutType.MIXED_TOP1_BOTTOM2
|
| 536 |
+
or layout_type == LayoutType.MIXED_TOP2_BOTTOM1
|
| 537 |
+
):
|
| 538 |
+
split_result = find_horizontal_split_by_y_gap(current_zone, elements_in_zone)
|
| 539 |
+
if split_result:
|
| 540 |
+
split_type = "H_YGap"
|
| 541 |
+
else:
|
| 542 |
+
split_result = find_horizontal_split_by_type(current_zone, elements_in_zone)
|
| 543 |
+
if split_result:
|
| 544 |
+
split_type = "H_Type"
|
| 545 |
+
else:
|
| 546 |
+
anchors = [
|
| 547 |
+
e for e in elements_in_zone if e.class_name in ALLOWED_ANCHORS
|
| 548 |
+
]
|
| 549 |
+
split_result = find_vertical_split_kmeans(current_zone, anchors)
|
| 550 |
+
if split_result:
|
| 551 |
+
split_type = "Vertical"
|
| 552 |
+
|
| 553 |
+
elif layout_type == LayoutType.UNKNOWN:
|
| 554 |
+
split_result = find_horizontal_split_by_type(current_zone, elements_in_zone)
|
| 555 |
+
if split_result:
|
| 556 |
+
split_type = "H_Type"
|
| 557 |
+
else:
|
| 558 |
+
anchors = [e for e in elements_in_zone if e.class_name in ALLOWED_ANCHORS]
|
| 559 |
+
split_result = find_vertical_split_kmeans(current_zone, anchors)
|
| 560 |
+
if split_result:
|
| 561 |
+
split_type = "Vertical"
|
| 562 |
+
else:
|
| 563 |
+
split_result = find_horizontal_split_by_y_gap(
|
| 564 |
+
current_zone, elements_in_zone
|
| 565 |
+
)
|
| 566 |
+
if split_result:
|
| 567 |
+
split_type = "H_YGap"
|
| 568 |
+
|
| 569 |
+
if split_result:
|
| 570 |
+
if isinstance(split_result, (HorizontalSplit, HorizontalSplitYGap)):
|
| 571 |
+
split_y = (
|
| 572 |
+
split_result.split_y
|
| 573 |
+
if isinstance(split_result, HorizontalSplitYGap)
|
| 574 |
+
else split_result.separator_element.y_position
|
| 575 |
+
+ split_result.separator_element.bbox_height / 2
|
| 576 |
+
)
|
| 577 |
+
top_elements = [
|
| 578 |
+
e
|
| 579 |
+
for e in elements_in_zone
|
| 580 |
+
if getattr(e, "element_id", -1)
|
| 581 |
+
!= getattr(
|
| 582 |
+
getattr(split_result, "separator_element", None), "element_id", -2
|
| 583 |
+
)
|
| 584 |
+
and (e.bbox_y + e.bbox_height / 2) < split_y
|
| 585 |
+
]
|
| 586 |
+
bottom_elements = [
|
| 587 |
+
e
|
| 588 |
+
for e in elements_in_zone
|
| 589 |
+
if getattr(e, "element_id", -1)
|
| 590 |
+
!= getattr(
|
| 591 |
+
getattr(split_result, "separator_element", None), "element_id", -2
|
| 592 |
+
)
|
| 593 |
+
and (e.bbox_y + e.bbox_height / 2) >= split_y
|
| 594 |
+
]
|
| 595 |
+
logger.debug(
|
| 596 |
+
f"{indent} -> {split_type} 수평 분할 성공! Top:{len(top_elements)}, Bottom:{len(bottom_elements)}"
|
| 597 |
+
)
|
| 598 |
+
top_layout_type = (
|
| 599 |
+
detect_layout_type(
|
| 600 |
+
top_elements,
|
| 601 |
+
split_result.top_zone.width,
|
| 602 |
+
split_result.top_zone.height,
|
| 603 |
+
)
|
| 604 |
+
if top_elements
|
| 605 |
+
else LayoutType.UNKNOWN
|
| 606 |
+
)
|
| 607 |
+
bottom_layout_type = (
|
| 608 |
+
detect_layout_type(
|
| 609 |
+
bottom_elements,
|
| 610 |
+
split_result.bottom_zone.width,
|
| 611 |
+
split_result.bottom_zone.height,
|
| 612 |
+
)
|
| 613 |
+
if bottom_elements
|
| 614 |
+
else LayoutType.UNKNOWN
|
| 615 |
+
)
|
| 616 |
+
sorted_top = _sort_recursive_by_layout(
|
| 617 |
+
split_result.top_zone, top_elements, top_layout_type, depth + 1
|
| 618 |
+
)
|
| 619 |
+
sep_group = (
|
| 620 |
+
[ElementGroup(anchor=split_result.separator_element)]
|
| 621 |
+
if isinstance(split_result, HorizontalSplit)
|
| 622 |
+
else []
|
| 623 |
+
)
|
| 624 |
+
sorted_bottom = _sort_recursive_by_layout(
|
| 625 |
+
split_result.bottom_zone, bottom_elements, bottom_layout_type, depth + 1
|
| 626 |
+
)
|
| 627 |
+
logger.debug(f"{indent} <- {split_type} 수평 분할 결과 병합")
|
| 628 |
+
return sorted_top + sep_group + sorted_bottom
|
| 629 |
+
|
| 630 |
+
elif isinstance(split_result, VerticalSplit):
|
| 631 |
+
left_elements = [
|
| 632 |
+
e
|
| 633 |
+
for e in elements_in_zone
|
| 634 |
+
if (e.bbox_x + e.bbox_width / 2) < split_result.gutter_x
|
| 635 |
+
]
|
| 636 |
+
right_elements = [
|
| 637 |
+
e
|
| 638 |
+
for e in elements_in_zone
|
| 639 |
+
if (e.bbox_x + e.bbox_width / 2) >= split_result.gutter_x
|
| 640 |
+
]
|
| 641 |
+
logger.debug(
|
| 642 |
+
f"{indent} -> Vertical 수직 분할 성공! Left:{len(left_elements)}, Right:{len(right_elements)}"
|
| 643 |
+
)
|
| 644 |
+
left_layout_type = (
|
| 645 |
+
detect_layout_type(
|
| 646 |
+
left_elements,
|
| 647 |
+
split_result.left_zone.width,
|
| 648 |
+
split_result.left_zone.height,
|
| 649 |
+
)
|
| 650 |
+
if left_elements
|
| 651 |
+
else LayoutType.UNKNOWN
|
| 652 |
+
)
|
| 653 |
+
right_layout_type = (
|
| 654 |
+
detect_layout_type(
|
| 655 |
+
right_elements,
|
| 656 |
+
split_result.right_zone.width,
|
| 657 |
+
split_result.right_zone.height,
|
| 658 |
+
)
|
| 659 |
+
if right_elements
|
| 660 |
+
else LayoutType.UNKNOWN
|
| 661 |
+
)
|
| 662 |
+
sorted_left = _sort_recursive_by_layout(
|
| 663 |
+
split_result.left_zone, left_elements, left_layout_type, depth + 1
|
| 664 |
+
)
|
| 665 |
+
sorted_right = _sort_recursive_by_layout(
|
| 666 |
+
split_result.right_zone, right_elements, right_layout_type, depth + 1
|
| 667 |
+
)
|
| 668 |
+
logger.debug(f"{indent} <- Vertical 수직 분할 결과 병합")
|
| 669 |
+
return sorted_left + sorted_right
|
| 670 |
+
else:
|
| 671 |
+
logger.debug(
|
| 672 |
+
f"{indent} -> 모든 분할 실패, 레이아웃 유형({layout_type.name})에 따른 Base Case 실행"
|
| 673 |
+
)
|
| 674 |
+
result_groups: List[ElementGroup] = []
|
| 675 |
+
if layout_type == LayoutType.STANDARD_1_COLUMN:
|
| 676 |
+
result_groups = _base_case_standard_1_column(current_zone, elements_in_zone)
|
| 677 |
+
elif (
|
| 678 |
+
layout_type == LayoutType.MIXED_TOP1_BOTTOM2
|
| 679 |
+
or layout_type == LayoutType.MIXED_TOP2_BOTTOM1
|
| 680 |
+
):
|
| 681 |
+
result_groups = _base_case_mixed_layout(
|
| 682 |
+
current_zone, elements_in_zone, layout_type
|
| 683 |
+
)
|
| 684 |
+
elif (
|
| 685 |
+
layout_type == LayoutType.HORIZONTAL_SEP_PRESENT
|
| 686 |
+
or layout_type == LayoutType.UNKNOWN
|
| 687 |
+
):
|
| 688 |
+
logger.warning(
|
| 689 |
+
f"{indent} -> {layout_type.name} 유형 분할 실패. 1단 Base Case로 처리합니다."
|
| 690 |
+
)
|
| 691 |
+
result_groups = _base_case_standard_1_column(current_zone, elements_in_zone)
|
| 692 |
+
else:
|
| 693 |
+
logger.error(
|
| 694 |
+
f"{indent} -> 처리할 수 없는 Base Case 유형: {layout_type.name}. 1단으로 처리."
|
| 695 |
+
)
|
| 696 |
+
result_groups = _base_case_standard_1_column(current_zone, elements_in_zone)
|
| 697 |
+
|
| 698 |
+
logger.debug(f"{indent} <- Base Case 처리 완료: {len(result_groups)} 그룹 생성")
|
| 699 |
+
return result_groups
|
| 700 |
+
|
| 701 |
+
|
| 702 |
+
# ============================================================================
|
| 703 |
+
# 표준 2단 레이아웃 처리 함수 (기존과 동일)
|
| 704 |
+
# ============================================================================
|
| 705 |
+
def _sort_standard_2_column(
|
| 706 |
+
zone: Zone, elements: List[MockElement]
|
| 707 |
+
) -> List[ElementGroup]:
|
| 708 |
+
# ... (코드 동일) ...
|
| 709 |
+
"""표준 2단 레이아웃 처리: K-Means 분할 후 컬럼별 _base_case_standard_1_column 호출"""
|
| 710 |
+
logger.debug("표준 2단 처리: K-Means 분할 시도")
|
| 711 |
+
anchors = [e for e in elements if e.class_name in ALLOWED_ANCHORS]
|
| 712 |
+
vertical_split = find_vertical_split_kmeans(zone, anchors)
|
| 713 |
+
|
| 714 |
+
if vertical_split:
|
| 715 |
+
logger.debug(f" -> 수직 분할 성공! 분리선 X={vertical_split.gutter_x:.1f}")
|
| 716 |
+
left_elements = [
|
| 717 |
+
e
|
| 718 |
+
for e in elements
|
| 719 |
+
if (e.bbox_x + e.bbox_width / 2) < vertical_split.gutter_x
|
| 720 |
+
]
|
| 721 |
+
right_elements = [
|
| 722 |
+
e
|
| 723 |
+
for e in elements
|
| 724 |
+
if (e.bbox_x + e.bbox_width / 2) >= vertical_split.gutter_x
|
| 725 |
+
]
|
| 726 |
+
logger.debug(
|
| 727 |
+
f" Left 요소 수: {len(left_elements)}, Right 요소 수: {len(right_elements)}"
|
| 728 |
+
)
|
| 729 |
+
groups_left = _base_case_standard_1_column(
|
| 730 |
+
vertical_split.left_zone, left_elements
|
| 731 |
+
)
|
| 732 |
+
groups_right = _base_case_standard_1_column(
|
| 733 |
+
vertical_split.right_zone, right_elements
|
| 734 |
+
)
|
| 735 |
+
logger.debug(
|
| 736 |
+
f" <- 컬럼별 그룹핑 완료 (Left: {len(groups_left)} 그룹, Right: {len(groups_right)} 그룹)"
|
| 737 |
+
)
|
| 738 |
+
return groups_left + groups_right
|
| 739 |
+
else:
|
| 740 |
+
logger.warning(
|
| 741 |
+
"표준 2단 처리 실패: 수직 분할 불가. 전체 구역 표준 1단 Base Case 실행"
|
| 742 |
+
)
|
| 743 |
+
return _base_case_standard_1_column(zone, elements)
|
| 744 |
+
|
| 745 |
+
|
| 746 |
+
# ============================================================================
|
| 747 |
+
# 분할 함수 구현 (기존과 동일)
|
| 748 |
+
# ============================================================================
|
| 749 |
+
def find_wide_question_type(
|
| 750 |
+
elements: List[MockElement], page_width: int, top_y_limit: float
|
| 751 |
+
) -> Optional[MockElement]:
|
| 752 |
+
# ... (코드 동일) ...
|
| 753 |
+
"""페이지 상단 영역에서 넓은 question_type 찾기"""
|
| 754 |
+
wide_types = [
|
| 755 |
+
e
|
| 756 |
+
for e in elements
|
| 757 |
+
if e.class_name == "question_type"
|
| 758 |
+
and e.y_position < top_y_limit
|
| 759 |
+
and (e.bbox_width / page_width if page_width > 0 else 0)
|
| 760 |
+
>= HORIZONTAL_SEP_WIDTH_THRESHOLD
|
| 761 |
+
]
|
| 762 |
+
return min(wide_types, key=lambda e: e.y_position) if wide_types else None
|
| 763 |
+
|
| 764 |
+
|
| 765 |
+
def find_horizontal_split_by_type(
|
| 766 |
+
zone: Zone, elements: List[MockElement]
|
| 767 |
+
) -> Optional[HorizontalSplit]:
|
| 768 |
+
# ... (코드 동일) ...
|
| 769 |
+
"""넓은 question_type으로 수평 분할"""
|
| 770 |
+
potential_separators = []
|
| 771 |
+
for element in elements:
|
| 772 |
+
if element.class_name == "question_type":
|
| 773 |
+
width_ratio = element.bbox_width / zone.width if zone.width > 0 else 0
|
| 774 |
+
if width_ratio >= HORIZONTAL_SEP_WIDTH_THRESHOLD:
|
| 775 |
+
potential_separators.append(element)
|
| 776 |
+
if not potential_separators:
|
| 777 |
+
return None
|
| 778 |
+
separator = min(potential_separators, key=lambda e: e.y_position)
|
| 779 |
+
if not (zone.y_min < separator.y_position < zone.y_max):
|
| 780 |
+
return None
|
| 781 |
+
top_zone = Zone(zone.x_min, zone.y_min, zone.x_max, separator.y_position)
|
| 782 |
+
bottom_zone = Zone(
|
| 783 |
+
zone.x_min, separator.y_position + separator.bbox_height, zone.x_max, zone.y_max
|
| 784 |
+
)
|
| 785 |
+
if top_zone.height <= 0 or bottom_zone.height <= 0:
|
| 786 |
+
return None
|
| 787 |
+
return HorizontalSplit(top_zone, bottom_zone, separator)
|
| 788 |
+
|
| 789 |
+
|
| 790 |
+
def find_horizontal_split_by_y_gap(
|
| 791 |
+
zone: Zone, elements: List[MockElement]
|
| 792 |
+
) -> Optional[HorizontalSplitYGap]:
|
| 793 |
+
# ... (코드 동일) ...
|
| 794 |
+
"""앵커 Y Gap으로 수평 분할"""
|
| 795 |
+
anchors = sorted(
|
| 796 |
+
[e for e in elements if e.class_name in ALLOWED_ANCHORS],
|
| 797 |
+
key=lambda e: e.y_position,
|
| 798 |
+
)
|
| 799 |
+
if len(anchors) < MIN_ANCHORS_FOR_SPLIT:
|
| 800 |
+
return None
|
| 801 |
+
max_gap = -1
|
| 802 |
+
split_index = -1
|
| 803 |
+
avg_anchor_height = (
|
| 804 |
+
np.mean([a.bbox_height for a in anchors if a.bbox_height > 0])
|
| 805 |
+
if any(a.bbox_height > 0 for a in anchors)
|
| 806 |
+
else 30
|
| 807 |
+
)
|
| 808 |
+
for i in range(len(anchors) - 1):
|
| 809 |
+
gap = (anchors[i + 1].y_position + anchors[i + 1].bbox_height / 2) - (
|
| 810 |
+
anchors[i].y_position + anchors[i].bbox_height / 2
|
| 811 |
+
)
|
| 812 |
+
if gap > max_gap:
|
| 813 |
+
max_gap = gap
|
| 814 |
+
split_index = i
|
| 815 |
+
threshold = max(
|
| 816 |
+
avg_anchor_height * VERTICAL_GAP_THRESHOLD_RATIO, VERTICAL_GAP_THRESHOLD_ABS
|
| 817 |
+
)
|
| 818 |
+
if max_gap >= threshold:
|
| 819 |
+
split_y = (
|
| 820 |
+
anchors[split_index].y_position
|
| 821 |
+
+ anchors[split_index].bbox_height
|
| 822 |
+
+ anchors[split_index + 1].y_position
|
| 823 |
+
) / 2
|
| 824 |
+
if zone.y_min < split_y < zone.y_max:
|
| 825 |
+
top_zone = Zone(zone.x_min, zone.y_min, zone.x_max, int(split_y))
|
| 826 |
+
bottom_zone = Zone(zone.x_min, int(split_y), zone.x_max, zone.y_max)
|
| 827 |
+
logger.debug(
|
| 828 |
+
f" Y Gap 분석: 수평 분할 가능 (Max Gap={max_gap:.1f} >= Threshold={threshold:.1f})"
|
| 829 |
+
)
|
| 830 |
+
return HorizontalSplitYGap(top_zone, bottom_zone, split_y)
|
| 831 |
+
else:
|
| 832 |
+
logger.warning(
|
| 833 |
+
f" Y Gap 분석: 분할선({split_y:.1f})이 구역({zone.y_min}-{zone.y_max}) 밖에 위치. 분할 취소."
|
| 834 |
+
)
|
| 835 |
+
return None
|
| 836 |
+
else:
|
| 837 |
+
logger.debug(
|
| 838 |
+
f" Y Gap 분석: 최대 간격({max_gap:.1f}) 임계값({threshold:.1f}) 미만. 수평 분할 불가."
|
| 839 |
+
)
|
| 840 |
+
return None
|
| 841 |
+
|
| 842 |
+
|
| 843 |
+
def find_vertical_split_kmeans(
|
| 844 |
+
zone: Zone, anchors: List[MockElement]
|
| 845 |
+
) -> Optional[VerticalSplit]:
|
| 846 |
+
"""앵커 X 좌표 K-Means로 수직 분할 (개선: 오른쪽 칼럼 시작점 기준 분할)"""
|
| 847 |
+
if len(anchors) < MIN_ANCHORS_FOR_SPLIT:
|
| 848 |
+
return None
|
| 849 |
+
anchor_x_centers = np.array([[a.bbox_x + a.bbox_width / 2] for a in anchors])
|
| 850 |
+
if len(np.unique(anchor_x_centers)) < 2:
|
| 851 |
+
return None
|
| 852 |
+
try:
|
| 853 |
+
kmeans = KMeans(n_clusters=KMEANS_N_CLUSTERS, random_state=42, n_init="auto")
|
| 854 |
+
kmeans.fit(anchor_x_centers)
|
| 855 |
+
centers = sorted(kmeans.cluster_centers_.flatten())
|
| 856 |
+
|
| 857 |
+
if (
|
| 858 |
+
len(centers) == 2
|
| 859 |
+
and centers[1] - centers[0] >= KMEANS_CLUSTER_SEPARATION_MIN
|
| 860 |
+
):
|
| 861 |
+
# 🔥 핵심 변경: 오른쪽 칼럼 앵커의 시작점을 경계로 사용
|
| 862 |
+
# 너무 타이트한 경계가 문제될 경우
|
| 863 |
+
COLUMN_BOUNDARY_MARGIN = 20 # px
|
| 864 |
+
gutter_x = centers[1] - COLUMN_BOUNDARY_MARGIN
|
| 865 |
+
# gutter_x = centers[1] # 기존: (centers[0] + centers[1]) / 2
|
| 866 |
+
|
| 867 |
+
if zone.x_min < gutter_x < zone.x_max:
|
| 868 |
+
left_zone = Zone(zone.x_min, zone.y_min, int(gutter_x), zone.y_max)
|
| 869 |
+
right_zone = Zone(int(gutter_x), zone.y_min, zone.x_max, zone.y_max)
|
| 870 |
+
logger.debug(
|
| 871 |
+
f" 수직 분할 성공: 왼쪽 칼럼 X=[{zone.x_min}, {int(gutter_x)}), "
|
| 872 |
+
f"오른쪽 칼럼 X=[{int(gutter_x)}, {zone.x_max})"
|
| 873 |
+
)
|
| 874 |
+
return VerticalSplit(left_zone, right_zone, gutter_x)
|
| 875 |
+
else:
|
| 876 |
+
logger.warning(
|
| 877 |
+
f" 수직 분할: 경계선({gutter_x:.1f})이 구역 밖. 분할 취소."
|
| 878 |
+
)
|
| 879 |
+
return None
|
| 880 |
+
else:
|
| 881 |
+
logger.debug(f" 수직 분할 실패: 중심간 거리 부족")
|
| 882 |
+
return None
|
| 883 |
+
except Exception as e:
|
| 884 |
+
logger.error(f" 수직 분할 K-Means 오류: {e}")
|
| 885 |
+
return None
|
| 886 |
+
|
| 887 |
+
|
| 888 |
+
# ============================================================================
|
| 889 |
+
# 후처리 함수 (수정됨)
|
| 890 |
+
# ============================================================================
|
| 891 |
+
def _post_process_table_figure_assignment(
|
| 892 |
+
groups: List[ElementGroup], y_diff_threshold: int = 150
|
| 893 |
+
) -> List[ElementGroup]:
|
| 894 |
+
"""
|
| 895 |
+
그룹핑 후처리: 테이블/그림 요소가 현재 앵커보다 다음 앵커(들)에 훨씬 가까우면 이동 시도
|
| 896 |
+
--- 수정: 최적 그룹 탐색 및 Tie-breaker 추가 ---
|
| 897 |
+
"""
|
| 898 |
+
logger.debug(
|
| 899 |
+
f" 테이블/그림 할당 후처리 시작: {len(groups)}개 그룹 (Threshold={y_diff_threshold}px, Closeness Ratio={POST_PROCESS_CLOSENESS_RATIO}, Lookahead={POST_PROCESS_LOOKAHEAD})"
|
| 900 |
+
)
|
| 901 |
+
adjusted_groups = groups # 원본 리스트를 직접 수정
|
| 902 |
+
elements_to_move_dict: Dict[int, Tuple[MockElement, int]] = (
|
| 903 |
+
{}
|
| 904 |
+
) # {element_id: (element, target_group_idx)}
|
| 905 |
+
moved_elements_log = [] # 로깅용
|
| 906 |
+
|
| 907 |
+
for i in range(len(adjusted_groups)):
|
| 908 |
+
current_group = adjusted_groups[i]
|
| 909 |
+
if not current_group.anchor:
|
| 910 |
+
continue
|
| 911 |
+
|
| 912 |
+
current_children_copy = list(
|
| 913 |
+
current_group.children
|
| 914 |
+
) # 순회 중 변경을 위한 복사본
|
| 915 |
+
|
| 916 |
+
for child_idx, child in enumerate(current_children_copy):
|
| 917 |
+
# 이미 이동 대상으로 결정된 요소는 건너뜀
|
| 918 |
+
if child.element_id in elements_to_move_dict:
|
| 919 |
+
continue
|
| 920 |
+
|
| 921 |
+
if child.class_name in ["table", "figure", "flowchart"]:
|
| 922 |
+
y_diff_current = child.y_position - current_group.anchor.y_position
|
| 923 |
+
|
| 924 |
+
best_target_group_idx = -1
|
| 925 |
+
min_y_diff_next = float("inf")
|
| 926 |
+
|
| 927 |
+
# 현재 그룹 이후 몇 개 그룹까지 탐색
|
| 928 |
+
for lookahead_idx in range(1, POST_PROCESS_LOOKAHEAD + 1):
|
| 929 |
+
next_group_idx = i + lookahead_idx
|
| 930 |
+
if next_group_idx >= len(adjusted_groups):
|
| 931 |
+
break
|
| 932 |
+
|
| 933 |
+
next_group = adjusted_groups[next_group_idx]
|
| 934 |
+
if not next_group.anchor:
|
| 935 |
+
continue
|
| 936 |
+
|
| 937 |
+
y_diff_next = abs(child.y_position - next_group.anchor.y_position)
|
| 938 |
+
|
| 939 |
+
# 이동 조건 검사 (v2.2 조건)
|
| 940 |
+
if y_diff_current > (y_diff_threshold / 2) and y_diff_next < (
|
| 941 |
+
y_diff_current * POST_PROCESS_CLOSENESS_RATIO
|
| 942 |
+
):
|
| 943 |
+
# --- 👇 Tie-breaker 수정 👇 ---
|
| 944 |
+
# 더 가까운 그룹을 찾거나, 거리가 같지만 더 뒤의 그룹일 경우 갱신
|
| 945 |
+
if y_diff_next < min_y_diff_next or (
|
| 946 |
+
y_diff_next == min_y_diff_next
|
| 947 |
+
and next_group_idx > best_target_group_idx
|
| 948 |
+
):
|
| 949 |
+
min_y_diff_next = y_diff_next
|
| 950 |
+
best_target_group_idx = next_group_idx
|
| 951 |
+
# --- 👆 Tie-breaker 수정 끝 👆 ---
|
| 952 |
+
|
| 953 |
+
# 최적 그룹을 찾았으면 이동 대상으로 등록
|
| 954 |
+
if best_target_group_idx != -1:
|
| 955 |
+
elements_to_move_dict[child.element_id] = (
|
| 956 |
+
child,
|
| 957 |
+
best_target_group_idx,
|
| 958 |
+
)
|
| 959 |
+
moved_elements_log.append(
|
| 960 |
+
f"Elem {child.element_id} ({child.class_name}) from Grp {current_group.group_id} to Grp {adjusted_groups[best_target_group_idx].group_id}"
|
| 961 |
+
)
|
| 962 |
+
logger.trace(
|
| 963 |
+
f" 이동 후보 확정: Elem {child.element_id} -> Group {adjusted_groups[best_target_group_idx].group_id} (Min Y diff next={min_y_diff_next:.0f})"
|
| 964 |
+
)
|
| 965 |
+
|
| 966 |
+
# --- 실제 요소 이동 (루프 종료 후) ---
|
| 967 |
+
if elements_to_move_dict:
|
| 968 |
+
# 1. 원본 그룹에서 요소 제거
|
| 969 |
+
elements_removed_count = 0
|
| 970 |
+
for group in adjusted_groups:
|
| 971 |
+
original_children_count = len(group.children)
|
| 972 |
+
group.children = [
|
| 973 |
+
child
|
| 974 |
+
for child in group.children
|
| 975 |
+
if child.element_id not in elements_to_move_dict
|
| 976 |
+
]
|
| 977 |
+
elements_removed_count += original_children_count - len(group.children)
|
| 978 |
+
|
| 979 |
+
# 2. 대상 그룹에 요소 추가
|
| 980 |
+
elements_added_count = 0
|
| 981 |
+
for element_id, (element, target_group_idx) in elements_to_move_dict.items():
|
| 982 |
+
if 0 <= target_group_idx < len(adjusted_groups):
|
| 983 |
+
adjusted_groups[target_group_idx].children.insert(
|
| 984 |
+
0, element
|
| 985 |
+
) # 그룹 맨 앞에 추가
|
| 986 |
+
elements_added_count += 1
|
| 987 |
+
else:
|
| 988 |
+
logger.error(
|
| 989 |
+
f"후처리 이동 중 유효하지 않은 대상 그룹 인덱스: {target_group_idx} for Elem {element_id}"
|
| 990 |
+
)
|
| 991 |
+
|
| 992 |
+
logger.debug(
|
| 993 |
+
f" 후처리 요소 이동 완료: {elements_removed_count}개 제거, {elements_added_count}개 추가"
|
| 994 |
+
)
|
| 995 |
+
|
| 996 |
+
if moved_elements_log:
|
| 997 |
+
logger.info(
|
| 998 |
+
f" 테이블/그림 할당 후처리: {len(moved_elements_log)}개 요소 이동됨 - {', '.join(moved_elements_log)}"
|
| 999 |
+
)
|
| 1000 |
+
else:
|
| 1001 |
+
logger.debug(" 테이블/그림 할당 후처리: 이동된 요소 없음")
|
| 1002 |
+
|
| 1003 |
+
return adjusted_groups
|
| 1004 |
+
|
| 1005 |
+
|
| 1006 |
+
# ============================================================================
|
| 1007 |
+
# Base Case 함수들 (기존과 동일 v2.1)
|
| 1008 |
+
# ============================================================================
|
| 1009 |
+
|
| 1010 |
+
|
| 1011 |
+
def _assign_children_to_anchors_with_2d_proximity(
|
| 1012 |
+
anchors: List[MockElement],
|
| 1013 |
+
children: List[MockElement],
|
| 1014 |
+
zone: Zone,
|
| 1015 |
+
preserve_top_orphans: bool = True,
|
| 1016 |
+
) -> Tuple[List[ElementGroup], List[MockElement]]:
|
| 1017 |
+
"""
|
| 1018 |
+
앵커와 자식 요소를 2D 거리 기반으로 그룹핑 (Phase 1: STANDARD_2_COLUMN 적용)
|
| 1019 |
+
|
| 1020 |
+
Args:
|
| 1021 |
+
anchors: 앵커 요소 리스트
|
| 1022 |
+
children: 자식 요소 리스트
|
| 1023 |
+
zone: 현재 처리 중인 구역
|
| 1024 |
+
preserve_top_orphans: True일 경우 상단 영역의 요소는 고아로 유지
|
| 1025 |
+
|
| 1026 |
+
Returns:
|
| 1027 |
+
(그룹 리스트, 고아 요소 리스트)
|
| 1028 |
+
"""
|
| 1029 |
+
groups: List[ElementGroup] = [ElementGroup(anchor=a) for a in anchors]
|
| 1030 |
+
orphans: List[MockElement] = []
|
| 1031 |
+
|
| 1032 |
+
# 상단 고아 임계값 (기존 로직 유지 옵션)
|
| 1033 |
+
top_orphan_threshold_y = (
|
| 1034 |
+
zone.y_min + zone.height * BASE_CASE_TOP_ORPHAN_THRESHOLD_RATIO
|
| 1035 |
+
if preserve_top_orphans
|
| 1036 |
+
else zone.y_min
|
| 1037 |
+
)
|
| 1038 |
+
|
| 1039 |
+
for child in children:
|
| 1040 |
+
child_x_center = child.bbox_x + child.bbox_width / 2
|
| 1041 |
+
child_y_center = child.bbox_y + child.bbox_height / 2
|
| 1042 |
+
|
| 1043 |
+
# 상단 고아 체크 (선택적)
|
| 1044 |
+
if preserve_top_orphans and child.bbox_y < top_orphan_threshold_y:
|
| 1045 |
+
# 첫 번째 앵커보다 훨씬 위쪽인 경우만 고아로 처리
|
| 1046 |
+
if not anchors or child_y_center < (
|
| 1047 |
+
anchors[0].bbox_y - ANCHOR_VERTICAL_PROXIMITY_THRESHOLD / 2
|
| 1048 |
+
):
|
| 1049 |
+
orphans.append(child)
|
| 1050 |
+
logger.trace(
|
| 1051 |
+
f" Elem {child.element_id} 상단 고아 유지 (Y={child.bbox_y})"
|
| 1052 |
+
)
|
| 1053 |
+
continue
|
| 1054 |
+
|
| 1055 |
+
best_anchor_idx = None
|
| 1056 |
+
min_distance = float("inf")
|
| 1057 |
+
|
| 1058 |
+
for idx, anchor in enumerate(anchors):
|
| 1059 |
+
anchor_x_center = anchor.bbox_x + anchor.bbox_width / 2
|
| 1060 |
+
anchor_y_center = anchor.bbox_y + anchor.bbox_height / 2
|
| 1061 |
+
|
| 1062 |
+
# 🔥 핵심 수정: 자식이 앵커보다 위쪽에 있으면 제외
|
| 1063 |
+
# figure/table은 반드시 자신보다 위쪽에 있는 앵커에만 배정되어야 함
|
| 1064 |
+
if child_y_center < anchor_y_center:
|
| 1065 |
+
logger.trace(
|
| 1066 |
+
f" Elem {child.element_id} → Anchor {anchor.element_id} 제외 "
|
| 1067 |
+
f"(자식 Y={child_y_center:.0f} < 앵커 Y={anchor_y_center:.0f})"
|
| 1068 |
+
)
|
| 1069 |
+
continue
|
| 1070 |
+
|
| 1071 |
+
# 가중 2D 거리 계산
|
| 1072 |
+
x_diff = abs(child_x_center - anchor_x_center) * ANCHOR_2D_DISTANCE_WEIGHT_X
|
| 1073 |
+
y_diff = abs(child_y_center - anchor_y_center) * ANCHOR_2D_DISTANCE_WEIGHT_Y
|
| 1074 |
+
distance = (x_diff**2 + y_diff**2) ** 0.5
|
| 1075 |
+
|
| 1076 |
+
if distance < min_distance:
|
| 1077 |
+
min_distance = distance
|
| 1078 |
+
best_anchor_idx = idx
|
| 1079 |
+
|
| 1080 |
+
# 거리 임계값 체크
|
| 1081 |
+
if (
|
| 1082 |
+
best_anchor_idx is not None
|
| 1083 |
+
and min_distance < ANCHOR_VERTICAL_PROXIMITY_THRESHOLD
|
| 1084 |
+
):
|
| 1085 |
+
groups[best_anchor_idx].children.append(child)
|
| 1086 |
+
logger.trace(
|
| 1087 |
+
f" Elem {child.element_id} → Anchor {anchors[best_anchor_idx].element_id} "
|
| 1088 |
+
f"(2D 거리={min_distance:.1f})"
|
| 1089 |
+
)
|
| 1090 |
+
else:
|
| 1091 |
+
orphans.append(child)
|
| 1092 |
+
if best_anchor_idx is None:
|
| 1093 |
+
reason = "위쪽 앵커만 허용 (모든 앵커가 자식보다 아래쪽)"
|
| 1094 |
+
else:
|
| 1095 |
+
reason = f"최소 거리={min_distance:.1f} > {ANCHOR_VERTICAL_PROXIMITY_THRESHOLD}"
|
| 1096 |
+
logger.debug(f" Elem {child.element_id} 고아 ({reason})")
|
| 1097 |
+
|
| 1098 |
+
return groups, orphans
|
| 1099 |
+
|
| 1100 |
+
|
| 1101 |
+
def _base_case_standard_1_column(
|
| 1102 |
+
zone: Zone, elements: List[MockElement]
|
| 1103 |
+
) -> List[ElementGroup]:
|
| 1104 |
+
# ... (v2.1 코드와 동일) ...
|
| 1105 |
+
"""표준 1단 구역 Base Case 처리 (상단 고아 분리)"""
|
| 1106 |
+
logger.debug(
|
| 1107 |
+
f" 표준 1단 Base Case 시작 (순차 처리 + 고아 개선): {len(elements)}개 요소 in {zone}"
|
| 1108 |
+
)
|
| 1109 |
+
anchors = sorted(
|
| 1110 |
+
[e for e in elements if e.class_name in ALLOWED_ANCHORS],
|
| 1111 |
+
key=lambda e: e.y_position,
|
| 1112 |
+
)
|
| 1113 |
+
children = [e for e in elements if e.class_name in ALLOWED_CHILDREN]
|
| 1114 |
+
groups: Dict[int, ElementGroup] = {
|
| 1115 |
+
anchor.element_id: ElementGroup(anchor=anchor) for anchor in anchors
|
| 1116 |
+
}
|
| 1117 |
+
assigned_children_ids = set()
|
| 1118 |
+
logger.trace(" 수평 인접 처리 시작...")
|
| 1119 |
+
|
| 1120 |
+
if anchors and children:
|
| 1121 |
+
for anchor in anchors:
|
| 1122 |
+
anchor_cy = anchor.bbox_y + anchor.bbox_height / 2
|
| 1123 |
+
anchor_right_x = anchor.bbox_x + anchor.bbox_width
|
| 1124 |
+
anchor_left_x = anchor.bbox_x
|
| 1125 |
+
unassigned_children = [
|
| 1126 |
+
c for c in children if c.element_id not in assigned_children_ids
|
| 1127 |
+
]
|
| 1128 |
+
adjacent_child = None
|
| 1129 |
+
min_y_diff = float("inf")
|
| 1130 |
+
for child in unassigned_children:
|
| 1131 |
+
child_cy = child.bbox_y + child.bbox_height / 2
|
| 1132 |
+
child_right_x = child.bbox_x + child.bbox_width
|
| 1133 |
+
child_left_x = child.bbox_x
|
| 1134 |
+
y_diff = abs(anchor_cy - child_cy)
|
| 1135 |
+
y_threshold = (
|
| 1136 |
+
(anchor.bbox_height + child.bbox_height)
|
| 1137 |
+
/ 2
|
| 1138 |
+
* HORIZONTAL_ADJACENCY_Y_CENTER_RATIO
|
| 1139 |
+
if (anchor.bbox_height + child.bbox_height) > 0
|
| 1140 |
+
else 0
|
| 1141 |
+
)
|
| 1142 |
+
if y_diff >= y_threshold:
|
| 1143 |
+
continue
|
| 1144 |
+
gap_right = child_left_x - anchor_right_x
|
| 1145 |
+
gap_left = anchor_left_x - child_right_x
|
| 1146 |
+
is_adjacent = (abs(gap_right) < HORIZONTAL_ADJACENCY_X_PROXIMITY) or (
|
| 1147 |
+
abs(gap_left) < HORIZONTAL_ADJACENCY_X_PROXIMITY
|
| 1148 |
+
)
|
| 1149 |
+
if is_adjacent and y_diff < min_y_diff:
|
| 1150 |
+
min_y_diff = y_diff
|
| 1151 |
+
adjacent_child = child
|
| 1152 |
+
if adjacent_child:
|
| 1153 |
+
logger.trace(
|
| 1154 |
+
f" 수평 인접 배정: 앵커 ID {anchor.element_id} <- 자식 ID {adjacent_child.element_id}"
|
| 1155 |
+
)
|
| 1156 |
+
groups[anchor.element_id].add_child(adjacent_child)
|
| 1157 |
+
assigned_children_ids.add(adjacent_child.element_id)
|
| 1158 |
+
logger.debug(
|
| 1159 |
+
f" 수평 인접 처리 완료: {len(assigned_children_ids)}개 자식 우선 배정됨"
|
| 1160 |
+
)
|
| 1161 |
+
|
| 1162 |
+
remaining_elements = anchors + [
|
| 1163 |
+
c for c in children if c.element_id not in assigned_children_ids
|
| 1164 |
+
]
|
| 1165 |
+
if not remaining_elements:
|
| 1166 |
+
logger.debug(" 모든 요소가 수평 인접으로 배정되어 그룹핑 완료.")
|
| 1167 |
+
# 후처리 호출 전 그룹 ID 임시 할당 (선택적)
|
| 1168 |
+
temp_groups = sorted(
|
| 1169 |
+
list(groups.values()),
|
| 1170 |
+
key=lambda g: g.anchor.y_position if g.anchor else float("inf"),
|
| 1171 |
+
)
|
| 1172 |
+
for idx, group in enumerate(temp_groups):
|
| 1173 |
+
group.group_id = idx
|
| 1174 |
+
return _post_process_table_figure_assignment(temp_groups)
|
| 1175 |
+
|
| 1176 |
+
# 2단계: 나머지 요소를 2D 거리 기반으로 그룹핑 (Phase 1 적용)
|
| 1177 |
+
remaining_children = [
|
| 1178 |
+
c for c in children if c.element_id not in assigned_children_ids
|
| 1179 |
+
]
|
| 1180 |
+
|
| 1181 |
+
if remaining_children and anchors:
|
| 1182 |
+
logger.trace(
|
| 1183 |
+
f" 2단계: 나머지 {len(remaining_children)}개 요소 2D 거리 그룹핑..."
|
| 1184 |
+
)
|
| 1185 |
+
|
| 1186 |
+
# 🔥 2D 거리 기반 그룹핑 (상단 고아 보존 옵션 활성화)
|
| 1187 |
+
proximity_groups, proximity_orphans = (
|
| 1188 |
+
_assign_children_to_anchors_with_2d_proximity(
|
| 1189 |
+
anchors,
|
| 1190 |
+
remaining_children,
|
| 1191 |
+
zone,
|
| 1192 |
+
preserve_top_orphans=True, # 상단 고아 보존
|
| 1193 |
+
)
|
| 1194 |
+
)
|
| 1195 |
+
|
| 1196 |
+
# 2D 거리로 배정된 자식들을 기존 그룹에 병합
|
| 1197 |
+
for idx, proximity_group in enumerate(proximity_groups):
|
| 1198 |
+
anchor_id = anchors[idx].element_id
|
| 1199 |
+
if anchor_id in groups:
|
| 1200 |
+
groups[anchor_id].children.extend(proximity_group.children)
|
| 1201 |
+
|
| 1202 |
+
# 2D 그룹핑 후 여전히 남은 요소들은 순차 처리로 넘김
|
| 1203 |
+
remaining_elements = [
|
| 1204 |
+
a for a in anchors if a.element_id not in assigned_children_ids
|
| 1205 |
+
] + proximity_orphans
|
| 1206 |
+
logger.debug(
|
| 1207 |
+
f" 2단계 완료: {len(remaining_children) - len(proximity_orphans)}개 배정, {len(proximity_orphans)}개 고아로 순차 처리 대기"
|
| 1208 |
+
)
|
| 1209 |
+
else:
|
| 1210 |
+
remaining_elements = anchors + [
|
| 1211 |
+
c for c in children if c.element_id not in assigned_children_ids
|
| 1212 |
+
]
|
| 1213 |
+
|
| 1214 |
+
if not remaining_elements:
|
| 1215 |
+
logger.debug(" 2D 거리 그룹핑 후 나머지 요소 없음. 그룹핑 완료.")
|
| 1216 |
+
temp_groups = sorted(
|
| 1217 |
+
list(groups.values()),
|
| 1218 |
+
key=lambda g: g.anchor.y_position if g.anchor else float("inf"),
|
| 1219 |
+
)
|
| 1220 |
+
for idx, group in enumerate(temp_groups):
|
| 1221 |
+
group.group_id = idx
|
| 1222 |
+
return _post_process_table_figure_assignment(temp_groups)
|
| 1223 |
+
|
| 1224 |
+
logger.trace(
|
| 1225 |
+
f" 3단계: 나머지 요소 {len(remaining_elements)}개 (Y, X) 정렬 및 순차 그룹핑 시작..."
|
| 1226 |
+
)
|
| 1227 |
+
remaining_elements.sort(key=lambda e: (e.y_position, e.x_position))
|
| 1228 |
+
|
| 1229 |
+
final_groups: List[ElementGroup] = []
|
| 1230 |
+
current_group: Optional[ElementGroup] = None
|
| 1231 |
+
initial_top_orphan_children: List[MockElement] = []
|
| 1232 |
+
initial_bottom_orphan_children: List[MockElement] = []
|
| 1233 |
+
first_anchor_found = False
|
| 1234 |
+
|
| 1235 |
+
top_orphan_threshold_y = (
|
| 1236 |
+
zone.y_min + zone.height * BASE_CASE_TOP_ORPHAN_THRESHOLD_RATIO
|
| 1237 |
+
)
|
| 1238 |
+
|
| 1239 |
+
for element in remaining_elements:
|
| 1240 |
+
if element.class_name in ALLOWED_ANCHORS:
|
| 1241 |
+
first_anchor_found = True
|
| 1242 |
+
if initial_top_orphan_children:
|
| 1243 |
+
logger.trace(
|
| 1244 |
+
f" 독립적인 상단 고아 그룹 생성 ({len(initial_top_orphan_children)}개 요소)"
|
| 1245 |
+
)
|
| 1246 |
+
final_groups.append(
|
| 1247 |
+
ElementGroup(anchor=None, children=initial_top_orphan_children)
|
| 1248 |
+
)
|
| 1249 |
+
initial_top_orphan_children = []
|
| 1250 |
+
if (
|
| 1251 |
+
current_group is not None
|
| 1252 |
+
and current_group.anchor is not None
|
| 1253 |
+
and not current_group.is_empty()
|
| 1254 |
+
):
|
| 1255 |
+
final_groups.append(current_group)
|
| 1256 |
+
if element.element_id in groups:
|
| 1257 |
+
current_group = groups[element.element_id]
|
| 1258 |
+
logger.trace(f" 앵커 그룹 재사용 (ID: {element.element_id})")
|
| 1259 |
+
else:
|
| 1260 |
+
current_group = ElementGroup(anchor=element, children=[])
|
| 1261 |
+
logger.trace(f" 새 앵커 그룹 시작 (ID: {element.element_id})")
|
| 1262 |
+
if initial_bottom_orphan_children:
|
| 1263 |
+
logger.trace(
|
| 1264 |
+
f" 첫 앵커(ID: {element.element_id}) 그룹에 하단 고아 자식 {len(initial_bottom_orphan_children)}개 추가"
|
| 1265 |
+
)
|
| 1266 |
+
current_group.children = (
|
| 1267 |
+
initial_bottom_orphan_children + current_group.children
|
| 1268 |
+
)
|
| 1269 |
+
initial_bottom_orphan_children = []
|
| 1270 |
+
else:
|
| 1271 |
+
if first_anchor_found:
|
| 1272 |
+
if current_group is None:
|
| 1273 |
+
logger.warning(
|
| 1274 |
+
f" 앵커 없이 자식 요소(ID: {element.element_id}) 발견됨. 위치({element.y_position:.1f}) 따라 임시 고아 리스트에 추가."
|
| 1275 |
+
)
|
| 1276 |
+
if element.y_position < top_orphan_threshold_y:
|
| 1277 |
+
initial_top_orphan_children.append(element)
|
| 1278 |
+
else:
|
| 1279 |
+
initial_bottom_orphan_children.append(element)
|
| 1280 |
+
else:
|
| 1281 |
+
current_group.add_child(element)
|
| 1282 |
+
logger.trace(
|
| 1283 |
+
f" 현재 그룹(앵커: {current_group.anchor.element_id if current_group.anchor else 'Orphan'})에 자식 추가 (ID: {element.element_id})"
|
| 1284 |
+
)
|
| 1285 |
+
else:
|
| 1286 |
+
if element.y_position < top_orphan_threshold_y:
|
| 1287 |
+
initial_top_orphan_children.append(element)
|
| 1288 |
+
logger.trace(
|
| 1289 |
+
f" 상단 고아 자식 요소(ID: {element.element_id}) 임시 저장 (Y < {top_orphan_threshold_y:.0f})"
|
| 1290 |
+
)
|
| 1291 |
+
else:
|
| 1292 |
+
initial_bottom_orphan_children.append(element)
|
| 1293 |
+
logger.trace(
|
| 1294 |
+
f" 하단 고아 자식 요소(ID: {element.element_id}) 임시 저장 (Y >= {top_orphan_threshold_y:.0f})"
|
| 1295 |
+
)
|
| 1296 |
+
|
| 1297 |
+
if initial_top_orphan_children:
|
| 1298 |
+
logger.trace(
|
| 1299 |
+
f" 마지막 독립 상단 고아 그룹 생성 ({len(initial_top_orphan_children)}개 요소)"
|
| 1300 |
+
)
|
| 1301 |
+
final_groups.append(
|
| 1302 |
+
ElementGroup(anchor=None, children=initial_top_orphan_children)
|
| 1303 |
+
)
|
| 1304 |
+
if current_group is not None and not current_group.is_empty():
|
| 1305 |
+
final_groups.append(current_group)
|
| 1306 |
+
elif initial_bottom_orphan_children:
|
| 1307 |
+
logger.warning(" 모든 요소가 하단 자식 요소임. 단일 고아 그룹 생성.")
|
| 1308 |
+
final_groups.append(
|
| 1309 |
+
ElementGroup(anchor=None, children=initial_bottom_orphan_children)
|
| 1310 |
+
)
|
| 1311 |
+
|
| 1312 |
+
processed_anchor_ids = set(g.anchor.element_id for g in final_groups if g.anchor)
|
| 1313 |
+
for anchor_id, group in groups.items():
|
| 1314 |
+
if anchor_id not in processed_anchor_ids and group.anchor:
|
| 1315 |
+
final_groups.append(group)
|
| 1316 |
+
logger.trace(f" 미포함 앵커 그룹 추가 (수평 인접만): ID {anchor_id}")
|
| 1317 |
+
|
| 1318 |
+
final_groups.sort(
|
| 1319 |
+
key=lambda g: (
|
| 1320 |
+
g.anchor.y_position
|
| 1321 |
+
if g.anchor
|
| 1322 |
+
else (min(c.y_position for c in g.children) if g.children else float("inf"))
|
| 1323 |
+
)
|
| 1324 |
+
)
|
| 1325 |
+
|
| 1326 |
+
# 후처리 호출 전 그룹 ID 임시 할당
|
| 1327 |
+
for idx, group in enumerate(final_groups):
|
| 1328 |
+
group.group_id = idx
|
| 1329 |
+
final_groups = _post_process_table_figure_assignment(final_groups)
|
| 1330 |
+
|
| 1331 |
+
logger.debug(
|
| 1332 |
+
f" 순차 처리 기반 그룹핑 (+후처리) 완료: {len(final_groups)} 그룹 생성"
|
| 1333 |
+
)
|
| 1334 |
+
return final_groups
|
| 1335 |
+
|
| 1336 |
+
|
| 1337 |
+
def _base_case_mixed_layout(
|
| 1338 |
+
zone: Zone, elements: List[MockElement], layout_type: LayoutType
|
| 1339 |
+
) -> List[ElementGroup]:
|
| 1340 |
+
"""혼합형 레이아웃 Base Case 처리 (기존과 동일)"""
|
| 1341 |
+
# ... (v2.1 코드와 동일) ...
|
| 1342 |
+
logger.debug(
|
| 1343 |
+
f" 혼합형 Base Case 시작 ({layout_type.name}): {len(elements)}개 요소 in {zone}"
|
| 1344 |
+
)
|
| 1345 |
+
sorted_elements = sorted(elements, key=lambda e: (e.y_position, e.x_position))
|
| 1346 |
+
final_groups: List[ElementGroup] = []
|
| 1347 |
+
current_group: Optional[ElementGroup] = None
|
| 1348 |
+
initial_top_orphan_children: List[MockElement] = []
|
| 1349 |
+
initial_bottom_orphan_children: List[MockElement] = []
|
| 1350 |
+
first_anchor_found = False
|
| 1351 |
+
split_y = zone.y_min + zone.height * LAYOUT_DETECT_Y_SPLIT_POINT
|
| 1352 |
+
logger.trace(f" 혼합형 Base Case Y 분할점: {split_y:.1f}")
|
| 1353 |
+
|
| 1354 |
+
for element in sorted_elements:
|
| 1355 |
+
element_y_center = element.y_position + element.bbox_height / 2
|
| 1356 |
+
if element.class_name in ALLOWED_ANCHORS:
|
| 1357 |
+
first_anchor_found = True
|
| 1358 |
+
if initial_top_orphan_children:
|
| 1359 |
+
logger.trace(
|
| 1360 |
+
f" 독립적인 상단 고아 그룹 생성 ({len(initial_top_orphan_children)}개 요소)"
|
| 1361 |
+
)
|
| 1362 |
+
final_groups.append(
|
| 1363 |
+
ElementGroup(anchor=None, children=initial_top_orphan_children)
|
| 1364 |
+
)
|
| 1365 |
+
initial_top_orphan_children = []
|
| 1366 |
+
if current_group is not None and not current_group.is_empty():
|
| 1367 |
+
final_groups.append(current_group)
|
| 1368 |
+
current_group = ElementGroup(anchor=element, children=[])
|
| 1369 |
+
logger.trace(f" 새 앵커 그룹 시작 (ID: {element.element_id})")
|
| 1370 |
+
if initial_bottom_orphan_children:
|
| 1371 |
+
logger.trace(
|
| 1372 |
+
f" 첫 앵커(ID: {element.element_id}) 그룹에 하단 고아 자식 {len(initial_bottom_orphan_children)}개 추가"
|
| 1373 |
+
)
|
| 1374 |
+
current_group.children = (
|
| 1375 |
+
initial_bottom_orphan_children + current_group.children
|
| 1376 |
+
)
|
| 1377 |
+
initial_bottom_orphan_children = []
|
| 1378 |
+
else:
|
| 1379 |
+
if first_anchor_found:
|
| 1380 |
+
if current_group is None:
|
| 1381 |
+
logger.warning(
|
| 1382 |
+
f" 앵커 없이 자식 요소(ID: {element.element_id}) 발견됨. 위치({element_y_center:.1f}) 따라 임시 고아 리스트에 추가."
|
| 1383 |
+
)
|
| 1384 |
+
if element_y_center < split_y:
|
| 1385 |
+
initial_top_orphan_children.append(element)
|
| 1386 |
+
else:
|
| 1387 |
+
initial_bottom_orphan_children.append(element)
|
| 1388 |
+
else:
|
| 1389 |
+
current_group.add_child(element)
|
| 1390 |
+
logger.trace(
|
| 1391 |
+
f" 현재 그룹(앵커: {current_group.anchor.element_id if current_group.anchor else 'Orphan'})에 자식 추가 (ID: {element.element_id})"
|
| 1392 |
+
)
|
| 1393 |
+
else:
|
| 1394 |
+
if element_y_center < split_y:
|
| 1395 |
+
initial_top_orphan_children.append(element)
|
| 1396 |
+
logger.trace(
|
| 1397 |
+
f" 상단 고아 자식 요소(ID: {element.element_id}) 임시 저장"
|
| 1398 |
+
)
|
| 1399 |
+
else:
|
| 1400 |
+
initial_bottom_orphan_children.append(element)
|
| 1401 |
+
logger.trace(
|
| 1402 |
+
f" 하단 고아 자식 요소(ID: {element.element_id}) 임시 저장"
|
| 1403 |
+
)
|
| 1404 |
+
|
| 1405 |
+
if initial_top_orphan_children:
|
| 1406 |
+
logger.trace(
|
| 1407 |
+
f" 마지막 독립 상단 고아 그룹 생성 ({len(initial_top_orphan_children)}개 요소)"
|
| 1408 |
+
)
|
| 1409 |
+
final_groups.append(
|
| 1410 |
+
ElementGroup(anchor=None, children=initial_top_orphan_children)
|
| 1411 |
+
)
|
| 1412 |
+
if current_group is not None and not current_group.is_empty():
|
| 1413 |
+
final_groups.append(current_group)
|
| 1414 |
+
elif initial_bottom_orphan_children:
|
| 1415 |
+
logger.warning(" 모든 요소가 하단 자식 요소임. 단일 고아 그룹 생성.")
|
| 1416 |
+
final_groups.append(
|
| 1417 |
+
ElementGroup(anchor=None, children=initial_bottom_orphan_children)
|
| 1418 |
+
)
|
| 1419 |
+
|
| 1420 |
+
# 후처리 호출 전 그룹 ID 임시 할당
|
| 1421 |
+
for idx, group in enumerate(final_groups):
|
| 1422 |
+
group.group_id = idx
|
| 1423 |
+
final_groups = _post_process_table_figure_assignment(final_groups)
|
| 1424 |
+
|
| 1425 |
+
return final_groups
|
| 1426 |
+
|
| 1427 |
+
|
| 1428 |
+
# ============================================================================
|
| 1429 |
+
# 최종 병합 및 순서 부여 함수 (기존과 동일)
|
| 1430 |
+
# ============================================================================
|
| 1431 |
+
def flatten_groups_and_assign_order(
|
| 1432 |
+
groups: List[ElementGroup], start_global_order: int, start_group_id: int
|
| 1433 |
+
) -> Tuple[List[MockElement], int, int]:
|
| 1434 |
+
# ... (코드 동일) ...
|
| 1435 |
+
"""주어진 그룹 리스트를 평탄화하고 전역 순서/그룹 ID 부여"""
|
| 1436 |
+
flattened = []
|
| 1437 |
+
global_order = start_global_order
|
| 1438 |
+
group_id_counter = start_group_id
|
| 1439 |
+
logger.debug(
|
| 1440 |
+
f" 평탄화 시작: {len(groups)}개 그룹 (시작 order={global_order}, group_id={group_id_counter})"
|
| 1441 |
+
)
|
| 1442 |
+
for group in groups: # 최종 정렬된 그룹 순서 사용
|
| 1443 |
+
# 그룹 객체의 ID는 임시 ID일 수 있으므로 여기서 최종 ID 할당
|
| 1444 |
+
final_group_id = group_id_counter
|
| 1445 |
+
group.group_id = final_group_id # 로깅 및 참조용 업데이트
|
| 1446 |
+
|
| 1447 |
+
elements_in_group = group.get_all_elements_sorted()
|
| 1448 |
+
logger.trace(
|
| 1449 |
+
f" 그룹 {final_group_id} 평탄화 (Anchor: {group.anchor.element_id if group.anchor else 'Orphan'}, 요소 수: {len(elements_in_group)})"
|
| 1450 |
+
)
|
| 1451 |
+
for local_order, element in enumerate(elements_in_group):
|
| 1452 |
+
try:
|
| 1453 |
+
setattr(element, "order_in_question", global_order)
|
| 1454 |
+
setattr(element, "group_id", final_group_id) # 최종 그룹 ID 사용
|
| 1455 |
+
setattr(element, "order_in_group", local_order)
|
| 1456 |
+
flattened.append(element)
|
| 1457 |
+
global_order += 1
|
| 1458 |
+
except AttributeError as e:
|
| 1459 |
+
logger.error(
|
| 1460 |
+
f"요소 (ID: {getattr(element, 'element_id', 'N/A')})에 정렬 속성 추가 실패: {e}"
|
| 1461 |
+
)
|
| 1462 |
+
group_id_counter += 1
|
| 1463 |
+
logger.debug(
|
| 1464 |
+
f" 평탄화 완료: {len(flattened)}개 요소 생성 (다음 order={global_order}, group_id={group_id_counter})"
|
| 1465 |
+
)
|
| 1466 |
+
return flattened, global_order, group_id_counter
|
| 1467 |
+
|
| 1468 |
+
|
| 1469 |
+
# ============================================================================
|
| 1470 |
+
# 헬퍼 함수 (기존과 동일)
|
| 1471 |
+
# ============================================================================
|
| 1472 |
+
def preprocess_elements(
|
| 1473 |
+
elements: List[MockElement], document_type: str
|
| 1474 |
+
) -> List[MockElement]:
|
| 1475 |
+
# ... (코드 동일) ...
|
| 1476 |
+
"""0단계 전처리"""
|
| 1477 |
+
original_count = len(elements)
|
| 1478 |
+
if document_type == "question_based":
|
| 1479 |
+
filtered = [e for e in elements if e.class_name in ALLOWED_CLASSES]
|
| 1480 |
+
logger.info(
|
| 1481 |
+
f"전처리 (question_based): {original_count}개 → {len(filtered)}개 (허용 클래스 필터링)"
|
| 1482 |
+
)
|
| 1483 |
+
elif document_type == "reading_order":
|
| 1484 |
+
filtered = elements
|
| 1485 |
+
logger.info(f"전처리 (reading_order): {original_count}개 (모든 클래스 허용)")
|
| 1486 |
+
else:
|
| 1487 |
+
logger.warning(f"알 수 없는 문서 타입 '{document_type}', 모든 요소 반환")
|
| 1488 |
+
filtered = elements
|
| 1489 |
+
valid_elements = [e for e in filtered if hasattr(e, "area") and e.area > 0]
|
| 1490 |
+
if len(valid_elements) < len(filtered):
|
| 1491 |
+
logger.warning(
|
| 1492 |
+
f"전처리: 면적이 0 이하인 요소 {len(filtered) - len(valid_elements)}개 제거"
|
| 1493 |
+
)
|
| 1494 |
+
return valid_elements
|
| 1495 |
+
|
| 1496 |
+
|
| 1497 |
+
def calculate_page_width(elements: List[MockElement]) -> int:
|
| 1498 |
+
# ... (코드 동일) ...
|
| 1499 |
+
"""페이지 너비 추정"""
|
| 1500 |
+
if not elements:
|
| 1501 |
+
return 0
|
| 1502 |
+
return max(e.bbox_x + e.bbox_width for e in elements) if elements else 0
|
| 1503 |
+
|
| 1504 |
+
|
| 1505 |
+
def calculate_page_height(elements: List[MockElement]) -> int:
|
| 1506 |
+
# ... (코드 동일) ...
|
| 1507 |
+
"""페이지 높이 추정"""
|
| 1508 |
+
if not elements:
|
| 1509 |
+
return 0
|
| 1510 |
+
return max(e.bbox_y + e.bbox_height for e in elements) if elements else 0
|
| 1511 |
+
|
| 1512 |
+
|
| 1513 |
+
# ============================================================================
|
| 1514 |
+
# DB 저장 함수 (ORM 연동)
|
| 1515 |
+
# ============================================================================
|
| 1516 |
+
|
| 1517 |
+
|
| 1518 |
+
def save_sorting_results_to_db(
|
| 1519 |
+
db: "Session", page_id: int, sorted_elements: List["LayoutElement"]
|
| 1520 |
+
) -> Tuple[int, int]:
|
| 1521 |
+
"""
|
| 1522 |
+
정렬된 LayoutElement 리스트를 question_groups와 question_elements 테이블에 저장합니다.
|
| 1523 |
+
|
| 1524 |
+
Args:
|
| 1525 |
+
db: SQLAlchemy 세션
|
| 1526 |
+
page_id: 페이지 ID
|
| 1527 |
+
sorted_elements: sorter.py로 정렬된 LayoutElement 리스트
|
| 1528 |
+
(order_in_question, group_id 속성 필수)
|
| 1529 |
+
|
| 1530 |
+
Returns:
|
| 1531 |
+
(생성된 그룹 수, 생성된 요소 수) 튜플
|
| 1532 |
+
|
| 1533 |
+
Raises:
|
| 1534 |
+
ValueError: sorted_elements에 order_in_question 또는 group_id가 없는 경우
|
| 1535 |
+
"""
|
| 1536 |
+
from .. import crud
|
| 1537 |
+
from ..schemas import QuestionGroupCreate, QuestionElementCreate
|
| 1538 |
+
|
| 1539 |
+
if not sorted_elements:
|
| 1540 |
+
logger.warning(f"page_id={page_id}: 정렬된 요소가 없어 DB 저장을 건너뜁니다.")
|
| 1541 |
+
return 0, 0
|
| 1542 |
+
|
| 1543 |
+
# 1. 요소들을 group_id별로 그룹화
|
| 1544 |
+
groups_dict: Dict[int, List["LayoutElement"]] = {}
|
| 1545 |
+
for elem in sorted_elements:
|
| 1546 |
+
if not hasattr(elem, "order_in_question") or not hasattr(elem, "group_id"):
|
| 1547 |
+
raise ValueError(
|
| 1548 |
+
f"element_id={elem.element_id}: order_in_question 또는 group_id ��성이 없습니다. "
|
| 1549 |
+
"sorter.py의 flatten_groups_and_assign_order() 실행 후 호출하세요."
|
| 1550 |
+
)
|
| 1551 |
+
|
| 1552 |
+
group_id = elem.group_id
|
| 1553 |
+
if group_id not in groups_dict:
|
| 1554 |
+
groups_dict[group_id] = []
|
| 1555 |
+
groups_dict[group_id].append(elem)
|
| 1556 |
+
|
| 1557 |
+
logger.info(
|
| 1558 |
+
f"page_id={page_id}: {len(groups_dict)}개 그룹, {len(sorted_elements)}개 요소를 DB에 저장 시작"
|
| 1559 |
+
)
|
| 1560 |
+
|
| 1561 |
+
# 2. 각 그룹에 대해 QuestionGroup 생성
|
| 1562 |
+
group_count = 0
|
| 1563 |
+
element_count = 0
|
| 1564 |
+
|
| 1565 |
+
for group_id, group_elements in sorted(groups_dict.items()):
|
| 1566 |
+
# 앵커 요소 찾기 (그룹 내 첫 번째 요소가 앵커)
|
| 1567 |
+
anchor_elem = min(group_elements, key=lambda e: e.order_in_question)
|
| 1568 |
+
|
| 1569 |
+
# Y 범위 계산
|
| 1570 |
+
start_y = min(e.y_position for e in group_elements)
|
| 1571 |
+
end_y = max(
|
| 1572 |
+
e.y_position + (e.bbox_height if hasattr(e, "bbox_height") else 0)
|
| 1573 |
+
for e in group_elements
|
| 1574 |
+
)
|
| 1575 |
+
|
| 1576 |
+
# QuestionGroup 생성
|
| 1577 |
+
group_create = QuestionGroupCreate(
|
| 1578 |
+
page_id=page_id,
|
| 1579 |
+
anchor_element_id=anchor_elem.element_id,
|
| 1580 |
+
start_y=start_y,
|
| 1581 |
+
end_y=end_y,
|
| 1582 |
+
element_count=len(group_elements),
|
| 1583 |
+
)
|
| 1584 |
+
|
| 1585 |
+
db_group = crud.create_question_group(db, group_create)
|
| 1586 |
+
group_count += 1
|
| 1587 |
+
logger.debug(
|
| 1588 |
+
f" 그룹 {group_id} → question_group_id={db_group.question_group_id} (앵커: {anchor_elem.element_id}, 요소 수: {len(group_elements)})"
|
| 1589 |
+
)
|
| 1590 |
+
|
| 1591 |
+
# 3. 그룹 내 각 요소에 대해 QuestionElement 생성
|
| 1592 |
+
for elem in group_elements:
|
| 1593 |
+
element_create = QuestionElementCreate(
|
| 1594 |
+
question_group_id=db_group.question_group_id,
|
| 1595 |
+
element_id=elem.element_id,
|
| 1596 |
+
order_in_question=elem.order_in_question + 1,
|
| 1597 |
+
)
|
| 1598 |
+
|
| 1599 |
+
crud.create_question_element(db, element_create)
|
| 1600 |
+
element_count += 1
|
| 1601 |
+
|
| 1602 |
+
logger.info(
|
| 1603 |
+
f"page_id={page_id}: DB 저장 완료 ({group_count}개 그룹, {element_count}개 요소)"
|
| 1604 |
+
)
|
| 1605 |
+
return group_count, element_count
|
app/services/sorter_strategies.py
ADDED
|
@@ -0,0 +1,788 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# -*- coding: utf-8 -*-
|
| 2 |
+
"""
|
| 3 |
+
SmartEyeSsen Sorter - Adaptive Strategy Pattern (완성)
|
| 4 |
+
======================================================
|
| 5 |
+
|
| 6 |
+
학습지 레이아웃의 특성을 자동 분석하여 최적의 정렬 전략을 선택하는 시스템입니다.
|
| 7 |
+
|
| 8 |
+
주요 컴포넌트:
|
| 9 |
+
--------------
|
| 10 |
+
1. **LayoutProfiler**: 레이아웃 특성 분석 및 전략 추천
|
| 11 |
+
- global_consistency_score: 앵커의 전역적 X좌표 일관성 (0~1)
|
| 12 |
+
- horizontal_adjacency_ratio: 앵커-자식 수평 인접 비율 (0~1)
|
| 13 |
+
- layout_type: 페이지 레이아웃 구조 유형
|
| 14 |
+
|
| 15 |
+
2. **정렬 전략 (Sorting Strategies)**:
|
| 16 |
+
- GlobalFirstStrategy: PDF처럼 전역적으로 일관된 레이아웃
|
| 17 |
+
- LocalFirstStrategy: 이미지처럼 불규칙한 레이아웃
|
| 18 |
+
- HybridStrategy: 두 전략을 병렬 실행하여 최적 결과 선택
|
| 19 |
+
|
| 20 |
+
3. **sort_layout_elements_adaptive()**: 메인 진입점 함수
|
| 21 |
+
- force_strategy=None: 자동 전략 선택 (권장)
|
| 22 |
+
- force_strategy="GLOBAL_FIRST"|"LOCAL_FIRST"|"HYBRID": 강제 지정
|
| 23 |
+
|
| 24 |
+
사용 예시:
|
| 25 |
+
---------
|
| 26 |
+
>>> from backend.app.services.sorter_strategies import sort_layout_elements_adaptive
|
| 27 |
+
>>> from backend.app.services.mock_models import MockElement
|
| 28 |
+
>>>
|
| 29 |
+
>>> elements = [
|
| 30 |
+
... MockElement(element_id=1, class_name="question_type",
|
| 31 |
+
... bbox_x=50, bbox_y=100, bbox_width=200, bbox_height=30),
|
| 32 |
+
... MockElement(element_id=2, class_name="question_text",
|
| 33 |
+
... bbox_x=50, bbox_y=140, bbox_width=400, bbox_height=50),
|
| 34 |
+
... ]
|
| 35 |
+
>>>
|
| 36 |
+
>>> # 자동 전략 선택 (권장)
|
| 37 |
+
>>> sorted_elements = sort_layout_elements_adaptive(
|
| 38 |
+
... elements=elements,
|
| 39 |
+
... document_type="question_based",
|
| 40 |
+
... page_width=2480,
|
| 41 |
+
... page_height=3508,
|
| 42 |
+
... force_strategy=None
|
| 43 |
+
... )
|
| 44 |
+
>>>
|
| 45 |
+
>>> # 정렬 결과
|
| 46 |
+
>>> for elem in sorted_elements:
|
| 47 |
+
... print(f"Element {elem.element_id}: group={elem.group_id}, order={elem.order_in_group}")
|
| 48 |
+
|
| 49 |
+
구현 단계:
|
| 50 |
+
---------
|
| 51 |
+
- ✅ Phase 1: GlobalFirstStrategy, LocalFirstStrategy, 강제 전략 선택
|
| 52 |
+
- ✅ Phase 2: LayoutProfiler, 자동 전략 선택
|
| 53 |
+
- ✅ Phase 3: HybridStrategy, 회귀 테스트, 파이프라인 통합
|
| 54 |
+
|
| 55 |
+
자세한 API 문서는 `docs/sorter_adaptive_strategy_api.md`를 참조하세요.
|
| 56 |
+
|
| 57 |
+
작성일: 2025-10-31
|
| 58 |
+
버전: v3.0
|
| 59 |
+
"""
|
| 60 |
+
|
| 61 |
+
from abc import ABC, abstractmethod
|
| 62 |
+
import copy
|
| 63 |
+
import os
|
| 64 |
+
from typing import List, Optional, Dict, Tuple
|
| 65 |
+
from dataclasses import dataclass, field
|
| 66 |
+
from enum import Enum, auto
|
| 67 |
+
import numpy as np
|
| 68 |
+
from sklearn.cluster import KMeans
|
| 69 |
+
from loguru import logger
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def _env_float(name: str, default: float) -> float:
|
| 73 |
+
"""환경 변수 값을 부동소수점으로 파싱하며 실패 시 기본값을 반환"""
|
| 74 |
+
value = os.getenv(name)
|
| 75 |
+
if value is None or value == "":
|
| 76 |
+
return default
|
| 77 |
+
try:
|
| 78 |
+
return float(value)
|
| 79 |
+
except ValueError:
|
| 80 |
+
logger.warning(
|
| 81 |
+
"환경 변수 %s 값 '%s'을(를) float로 변환하지 못했습니다. 기본값 %.2f을 사용합니다.",
|
| 82 |
+
name,
|
| 83 |
+
value,
|
| 84 |
+
default,
|
| 85 |
+
)
|
| 86 |
+
return default
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
DEFAULT_BASE_DPI = _env_float("LAYOUT_ANALYSIS_BASE_DPI", 300.0)
|
| 90 |
+
BASE_PAGE_HEIGHT_INCHES = _env_float("LAYOUT_BASE_PAGE_HEIGHT_INCHES", 11.69)
|
| 91 |
+
BASE_PAGE_WIDTH_INCHES = _env_float("LAYOUT_BASE_PAGE_WIDTH_INCHES", 8.27)
|
| 92 |
+
MIN_EFFECTIVE_DPI = _env_float("LAYOUT_MIN_EFFECTIVE_DPI", 120.0)
|
| 93 |
+
MAX_EFFECTIVE_DPI = _env_float("LAYOUT_MAX_EFFECTIVE_DPI", 600.0)
|
| 94 |
+
GLOBAL_WIDTH_INCH_THRESHOLD = _env_float("LAYOUT_GLOBAL_WIDTH_INCH_THRESHOLD", 9.0)
|
| 95 |
+
GLOBAL_WIDTH_INCH_MARGIN = _env_float("LAYOUT_GLOBAL_WIDTH_INCH_MARGIN", 0.75)
|
| 96 |
+
|
| 97 |
+
# sorter.py의 모든 함수와 클래스 임포트
|
| 98 |
+
from .sorter import (
|
| 99 |
+
_sort_layout_elements_v24 as _sort_layout_elements_new,
|
| 100 |
+
MockElement,
|
| 101 |
+
ElementGroup,
|
| 102 |
+
Zone,
|
| 103 |
+
VerticalSplit,
|
| 104 |
+
LayoutType,
|
| 105 |
+
ALLOWED_ANCHORS,
|
| 106 |
+
ALLOWED_CHILDREN,
|
| 107 |
+
ALLOWED_CLASSES,
|
| 108 |
+
MIN_ANCHORS_FOR_SPLIT,
|
| 109 |
+
KMEANS_N_CLUSTERS,
|
| 110 |
+
KMEANS_CLUSTER_SEPARATION_MIN,
|
| 111 |
+
HORIZONTAL_ADJACENCY_Y_CENTER_RATIO,
|
| 112 |
+
HORIZONTAL_ADJACENCY_X_PROXIMITY,
|
| 113 |
+
BASE_CASE_TOP_ORPHAN_THRESHOLD_RATIO,
|
| 114 |
+
POST_PROCESS_CLOSENESS_RATIO,
|
| 115 |
+
POST_PROCESS_LOOKAHEAD,
|
| 116 |
+
detect_layout_type,
|
| 117 |
+
preprocess_elements,
|
| 118 |
+
calculate_page_width,
|
| 119 |
+
calculate_page_height,
|
| 120 |
+
flatten_groups_and_assign_order,
|
| 121 |
+
_post_process_table_figure_assignment,
|
| 122 |
+
_sort_recursive_by_layout,
|
| 123 |
+
find_wide_question_type,
|
| 124 |
+
find_horizontal_split_by_type,
|
| 125 |
+
find_horizontal_split_by_y_gap,
|
| 126 |
+
_sort_standard_2_column,
|
| 127 |
+
_base_case_mixed_layout,
|
| 128 |
+
)
|
| 129 |
+
from .sorter_구버전 import sort_layout_elements as _sort_layout_elements_legacy
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
# ============================================================================
|
| 133 |
+
# Enum 및 Dataclass 정의
|
| 134 |
+
# ============================================================================
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
class SortingStrategyType(Enum):
|
| 138 |
+
"""정렬 전략 타입"""
|
| 139 |
+
|
| 140 |
+
GLOBAL_FIRST = auto() # 전역 우선 (신규 로직)
|
| 141 |
+
LOCAL_FIRST = auto() # 로컬 우선 (구 로직)
|
| 142 |
+
HYBRID = auto() # 혼합형 (Phase 3)
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
# ============================================================================
|
| 146 |
+
# Strategy 인터페이스
|
| 147 |
+
# ============================================================================
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
class SortingStrategy(ABC):
|
| 151 |
+
"""정렬 전략 추상 인터페이스"""
|
| 152 |
+
|
| 153 |
+
@abstractmethod
|
| 154 |
+
def sort(
|
| 155 |
+
self,
|
| 156 |
+
elements: List[MockElement],
|
| 157 |
+
document_type: str,
|
| 158 |
+
page_width: int,
|
| 159 |
+
page_height: int,
|
| 160 |
+
) -> List[MockElement]:
|
| 161 |
+
"""
|
| 162 |
+
레이아웃 요소 정렬
|
| 163 |
+
|
| 164 |
+
Args:
|
| 165 |
+
elements: 정렬할 요소 리스트
|
| 166 |
+
document_type: 문서 타입 ("question_based" 또는 "reading_order")
|
| 167 |
+
page_width: 페이지 너비
|
| 168 |
+
page_height: 페이지 높이
|
| 169 |
+
|
| 170 |
+
Returns:
|
| 171 |
+
정렬된 요소 리스트 (group_id, order_in_group 할당됨)
|
| 172 |
+
"""
|
| 173 |
+
pass
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
# ============================================================================
|
| 177 |
+
# GlobalFirstStrategy (신규 로직)
|
| 178 |
+
# ============================================================================
|
| 179 |
+
|
| 180 |
+
|
| 181 |
+
class GlobalFirstStrategy(SortingStrategy):
|
| 182 |
+
"""
|
| 183 |
+
전역 우선 전략 (Global-First Strategy)
|
| 184 |
+
|
| 185 |
+
현재 sorter.py의 로직을 그대로 사용:
|
| 186 |
+
- 오른쪽 칼럼 시작점 기준 수직 분할
|
| 187 |
+
- 2D 거리 기반 그룹핑 적용
|
| 188 |
+
|
| 189 |
+
PDF와 같은 전역적으로 일관된 레이아웃에 효과적
|
| 190 |
+
"""
|
| 191 |
+
|
| 192 |
+
def sort(
|
| 193 |
+
self,
|
| 194 |
+
elements: List[MockElement],
|
| 195 |
+
document_type: str,
|
| 196 |
+
page_width: int,
|
| 197 |
+
page_height: int,
|
| 198 |
+
) -> List[MockElement]:
|
| 199 |
+
"""현재 sorter.py (v2.4) 로직 직접 호출"""
|
| 200 |
+
logger.info("[GlobalFirstStrategy] 전역 우선 전략 실행 중 (v2.4 코어 호출)")
|
| 201 |
+
return _sort_layout_elements_new(
|
| 202 |
+
elements=elements,
|
| 203 |
+
document_type=document_type,
|
| 204 |
+
page_width=page_width,
|
| 205 |
+
page_height=page_height,
|
| 206 |
+
)
|
| 207 |
+
|
| 208 |
+
|
| 209 |
+
# ============================================================================
|
| 210 |
+
# LocalFirstStrategy (구 버전 로직)
|
| 211 |
+
# ============================================================================
|
| 212 |
+
|
| 213 |
+
|
| 214 |
+
class LocalFirstStrategy(SortingStrategy):
|
| 215 |
+
"""
|
| 216 |
+
로컬 우선 전략 (Local-First Strategy)
|
| 217 |
+
|
| 218 |
+
구 버전 sorter_구버전.py의 정렬 함수를 그대로 호출한다.
|
| 219 |
+
"""
|
| 220 |
+
|
| 221 |
+
def sort(
|
| 222 |
+
self,
|
| 223 |
+
elements: List[MockElement],
|
| 224 |
+
document_type: str,
|
| 225 |
+
page_width: int,
|
| 226 |
+
page_height: int,
|
| 227 |
+
) -> List[MockElement]:
|
| 228 |
+
logger.info("[LocalFirstStrategy] 로컬 우선 전략 실행 중 (구버전 직접 호출)")
|
| 229 |
+
return _sort_layout_elements_legacy(
|
| 230 |
+
elements=elements,
|
| 231 |
+
document_type=document_type,
|
| 232 |
+
page_width=page_width,
|
| 233 |
+
page_height=page_height,
|
| 234 |
+
)
|
| 235 |
+
|
| 236 |
+
|
| 237 |
+
class HybridStrategy(SortingStrategy):
|
| 238 |
+
"""
|
| 239 |
+
혼합 전략 (Hybrid Strategy)
|
| 240 |
+
|
| 241 |
+
전역/로컬 전략을 모두 실행한 뒤 그룹 품질을 평가하여 더 일관된 결과를 선택한다.
|
| 242 |
+
"""
|
| 243 |
+
|
| 244 |
+
_COLUMN_OFFSET_RATIO = 0.4 # 앵커와 자식 간 허용 가능한 X 거리 비율
|
| 245 |
+
|
| 246 |
+
def __init__(self) -> None:
|
| 247 |
+
self._global_strategy = GlobalFirstStrategy()
|
| 248 |
+
self._local_strategy = LocalFirstStrategy()
|
| 249 |
+
|
| 250 |
+
def sort(
|
| 251 |
+
self,
|
| 252 |
+
elements: List[MockElement],
|
| 253 |
+
document_type: str,
|
| 254 |
+
page_width: int,
|
| 255 |
+
page_height: int,
|
| 256 |
+
) -> List[MockElement]:
|
| 257 |
+
logger.info("[HybridStrategy] 혼합 전략 실행 시작")
|
| 258 |
+
|
| 259 |
+
# 전략별로 깊은 복사하여 독립적으로 실행
|
| 260 |
+
global_input = copy.deepcopy(elements)
|
| 261 |
+
local_input = copy.deepcopy(elements)
|
| 262 |
+
|
| 263 |
+
global_result = self._global_strategy.sort(
|
| 264 |
+
elements=global_input,
|
| 265 |
+
document_type=document_type,
|
| 266 |
+
page_width=page_width,
|
| 267 |
+
page_height=page_height,
|
| 268 |
+
)
|
| 269 |
+
local_result = self._local_strategy.sort(
|
| 270 |
+
elements=local_input,
|
| 271 |
+
document_type=document_type,
|
| 272 |
+
page_width=page_width,
|
| 273 |
+
page_height=page_height,
|
| 274 |
+
)
|
| 275 |
+
|
| 276 |
+
global_penalty = self._score_grouping(global_result, page_width)
|
| 277 |
+
local_penalty = self._score_grouping(local_result, page_width)
|
| 278 |
+
|
| 279 |
+
logger.info(
|
| 280 |
+
"[HybridStrategy] 평가 점수 비교 - Global: %.3f, Local: %.3f",
|
| 281 |
+
global_penalty,
|
| 282 |
+
local_penalty,
|
| 283 |
+
)
|
| 284 |
+
|
| 285 |
+
if global_penalty <= local_penalty:
|
| 286 |
+
logger.info("[HybridStrategy] GlobalFirstStrategy 결과採用")
|
| 287 |
+
return global_result
|
| 288 |
+
|
| 289 |
+
logger.info("[HybridStrategy] LocalFirstStrategy 결과採用")
|
| 290 |
+
return local_result
|
| 291 |
+
|
| 292 |
+
def _score_grouping(self, elements: List[MockElement], page_width: int) -> float:
|
| 293 |
+
"""
|
| 294 |
+
그룹 품질을 평가하여 점수(낮을수록 좋음)를 계산한다.
|
| 295 |
+
- 앵커가 없는 그룹, 자식이 없는 앵커, 고아 자식 등을 패널티로 부여한다.
|
| 296 |
+
"""
|
| 297 |
+
if not elements:
|
| 298 |
+
return float("inf")
|
| 299 |
+
|
| 300 |
+
anchors = [e for e in elements if e.class_name in ALLOWED_ANCHORS]
|
| 301 |
+
children = [e for e in elements if e.class_name in ALLOWED_CHILDREN]
|
| 302 |
+
anchor_ids = {anchor.element_id for anchor in anchors}
|
| 303 |
+
|
| 304 |
+
groups: Dict[int, List[MockElement]] = {}
|
| 305 |
+
for elem in elements:
|
| 306 |
+
group_id = getattr(elem, "group_id", None)
|
| 307 |
+
if group_id is None:
|
| 308 |
+
continue
|
| 309 |
+
groups.setdefault(group_id, []).append(elem)
|
| 310 |
+
|
| 311 |
+
penalty = 0.0
|
| 312 |
+
assigned_anchors = set()
|
| 313 |
+
grouped_child_ids = set()
|
| 314 |
+
x_threshold = page_width * self._COLUMN_OFFSET_RATIO if page_width else None
|
| 315 |
+
|
| 316 |
+
for group_id, group_elems in groups.items():
|
| 317 |
+
anchor = next(
|
| 318 |
+
(e for e in group_elems if e.class_name in ALLOWED_ANCHORS), None
|
| 319 |
+
)
|
| 320 |
+
|
| 321 |
+
if anchor is None:
|
| 322 |
+
# 앵커가 없는 그룹은 큰 패널티
|
| 323 |
+
penalty += 5.0
|
| 324 |
+
penalty += sum(
|
| 325 |
+
1.5 for e in group_elems if e.class_name in ALLOWED_CHILDREN
|
| 326 |
+
)
|
| 327 |
+
continue
|
| 328 |
+
|
| 329 |
+
assigned_anchors.add(anchor.element_id)
|
| 330 |
+
anchor_cx = anchor.bbox_x + anchor.bbox_width / 2
|
| 331 |
+
anchor_cy = anchor.bbox_y + anchor.bbox_height / 2
|
| 332 |
+
|
| 333 |
+
children_in_group = [
|
| 334 |
+
e for e in group_elems if e.class_name in ALLOWED_CHILDREN
|
| 335 |
+
]
|
| 336 |
+
if not children_in_group:
|
| 337 |
+
penalty += 1.0 # 앵커에 자식이 전혀 없는 경우 경미한 패널티
|
| 338 |
+
|
| 339 |
+
for child in children_in_group:
|
| 340 |
+
grouped_child_ids.add(child.element_id)
|
| 341 |
+
|
| 342 |
+
child_cx = child.bbox_x + child.bbox_width / 2
|
| 343 |
+
child_cy = child.bbox_y + child.bbox_height / 2
|
| 344 |
+
|
| 345 |
+
if child_cy < anchor_cy:
|
| 346 |
+
penalty += 1.0 # 자식이 앵커보다 위에 위치한 경우
|
| 347 |
+
|
| 348 |
+
if x_threshold and abs(child_cx - anchor_cx) > x_threshold:
|
| 349 |
+
penalty += 0.5 # 칼럼을 심하게 넘나드는 자식
|
| 350 |
+
|
| 351 |
+
# 그룹에 배정되지 않은 자식 요소
|
| 352 |
+
orphan_children = [
|
| 353 |
+
child for child in children if child.element_id not in grouped_child_ids
|
| 354 |
+
]
|
| 355 |
+
penalty += len(orphan_children) * 2.0
|
| 356 |
+
|
| 357 |
+
# 어떤 그룹에도 속하지 않은 앵커 (고아 앵커)
|
| 358 |
+
unassigned_anchors = anchor_ids - assigned_anchors
|
| 359 |
+
penalty += len(unassigned_anchors) * 1.5
|
| 360 |
+
|
| 361 |
+
return penalty
|
| 362 |
+
|
| 363 |
+
|
| 364 |
+
@dataclass
|
| 365 |
+
class LayoutProfile:
|
| 366 |
+
"""레이아웃 프로파일 (Phase 2 확장 버전)"""
|
| 367 |
+
|
| 368 |
+
global_consistency_score: float = 0.0
|
| 369 |
+
anchor_x_std: float = 0.0
|
| 370 |
+
horizontal_adjacency_ratio: float = 0.0
|
| 371 |
+
anchor_count: int = 0
|
| 372 |
+
layout_type: LayoutType = LayoutType.STANDARD_1_COLUMN
|
| 373 |
+
page_width: int = 0
|
| 374 |
+
page_height: int = 0
|
| 375 |
+
anchor_y_variance: float = 0.0
|
| 376 |
+
recommended_strategy: SortingStrategyType = field(
|
| 377 |
+
default=SortingStrategyType.GLOBAL_FIRST
|
| 378 |
+
)
|
| 379 |
+
effective_dpi: float = 0.0
|
| 380 |
+
width_in_inches: float = 0.0
|
| 381 |
+
height_in_inches: float = 0.0
|
| 382 |
+
|
| 383 |
+
|
| 384 |
+
# ============================================================================
|
| 385 |
+
# LayoutProfiler (Phase 2 구현)
|
| 386 |
+
# ============================================================================
|
| 387 |
+
|
| 388 |
+
|
| 389 |
+
class LayoutProfiler:
|
| 390 |
+
"""
|
| 391 |
+
레이아웃 프로파일 분석기
|
| 392 |
+
|
| 393 |
+
입력 요소들의 전역/로컬 패턴을 분석하여 최적 전략을 추천한다.
|
| 394 |
+
"""
|
| 395 |
+
|
| 396 |
+
@staticmethod
|
| 397 |
+
def analyze(
|
| 398 |
+
elements: List[MockElement],
|
| 399 |
+
page_width: Optional[int],
|
| 400 |
+
page_height: Optional[int],
|
| 401 |
+
page_dpi: Optional[float] = None,
|
| 402 |
+
) -> LayoutProfile:
|
| 403 |
+
"""레이아웃 특성 분석 및 전략 추천"""
|
| 404 |
+
|
| 405 |
+
logger.info("[LayoutProfiler] 레이아웃 분석 시작...")
|
| 406 |
+
|
| 407 |
+
anchors = [e for e in elements if e.class_name in ALLOWED_ANCHORS]
|
| 408 |
+
children = [e for e in elements if e.class_name in ALLOWED_CHILDREN]
|
| 409 |
+
|
| 410 |
+
if not page_width or page_width <= 0:
|
| 411 |
+
page_width = (
|
| 412 |
+
int(max(e.bbox_x + e.bbox_width for e in elements)) if elements else 0
|
| 413 |
+
)
|
| 414 |
+
if not page_height or page_height <= 0:
|
| 415 |
+
page_height = (
|
| 416 |
+
int(max(e.bbox_y + e.bbox_height for e in elements)) if elements else 0
|
| 417 |
+
)
|
| 418 |
+
|
| 419 |
+
effective_dpi = LayoutProfiler._resolve_effective_dpi(
|
| 420 |
+
page_dpi, page_width, page_height
|
| 421 |
+
)
|
| 422 |
+
width_in_inches, height_in_inches = LayoutProfiler._compute_physical_dimensions(
|
| 423 |
+
page_width, page_height, effective_dpi
|
| 424 |
+
)
|
| 425 |
+
|
| 426 |
+
# 1. 앵커 통계
|
| 427 |
+
if len(anchors) >= 2:
|
| 428 |
+
anchor_x_centers = [a.bbox_x + a.bbox_width / 2 for a in anchors]
|
| 429 |
+
anchor_x_std = float(np.std(anchor_x_centers))
|
| 430 |
+
anchor_y_centers = [a.bbox_y + a.bbox_height / 2 for a in anchors]
|
| 431 |
+
anchor_y_variance = float(np.var(anchor_y_centers))
|
| 432 |
+
else:
|
| 433 |
+
anchor_x_std = 0.0
|
| 434 |
+
anchor_y_variance = 0.0
|
| 435 |
+
|
| 436 |
+
anchor_count = len(anchors)
|
| 437 |
+
|
| 438 |
+
# 2. 전역 일관성 점수
|
| 439 |
+
max_x_std = page_width * 0.3 if page_width else 0.0
|
| 440 |
+
global_consistency_score = (
|
| 441 |
+
max(0.0, 1.0 - (anchor_x_std / max_x_std)) if max_x_std > 0 else 0.5
|
| 442 |
+
)
|
| 443 |
+
|
| 444 |
+
# 3. 수평 인접 비율
|
| 445 |
+
horizontal_adjacency_count = 0
|
| 446 |
+
if anchors and children:
|
| 447 |
+
for anchor in anchors:
|
| 448 |
+
anchor_cy = anchor.bbox_y + anchor.bbox_height / 2
|
| 449 |
+
anchor_right_x = anchor.bbox_x + anchor.bbox_width
|
| 450 |
+
for child in children:
|
| 451 |
+
child_cy = child.bbox_y + child.bbox_height / 2
|
| 452 |
+
child_left_x = child.bbox_x
|
| 453 |
+
y_diff = abs(anchor_cy - child_cy)
|
| 454 |
+
y_threshold = (
|
| 455 |
+
(anchor.bbox_height + child.bbox_height)
|
| 456 |
+
/ 2
|
| 457 |
+
* HORIZONTAL_ADJACENCY_Y_CENTER_RATIO
|
| 458 |
+
if (anchor.bbox_height + child.bbox_height) > 0
|
| 459 |
+
else 0
|
| 460 |
+
)
|
| 461 |
+
gap_right = child_left_x - anchor_right_x
|
| 462 |
+
if (
|
| 463 |
+
y_diff < y_threshold
|
| 464 |
+
and abs(gap_right) < HORIZONTAL_ADJACENCY_X_PROXIMITY
|
| 465 |
+
):
|
| 466 |
+
horizontal_adjacency_count += 1
|
| 467 |
+
break
|
| 468 |
+
|
| 469 |
+
horizontal_adjacency_ratio = (
|
| 470 |
+
horizontal_adjacency_count / anchor_count if anchor_count else 0.0
|
| 471 |
+
)
|
| 472 |
+
|
| 473 |
+
# 4. 레이아웃 유형 판별
|
| 474 |
+
layout_type = detect_layout_type(elements, page_width, page_height)
|
| 475 |
+
|
| 476 |
+
# 5. 전략 추천
|
| 477 |
+
recommended_strategy = LayoutProfiler._recommend_strategy(
|
| 478 |
+
consistency=global_consistency_score,
|
| 479 |
+
anchor_x_std=anchor_x_std,
|
| 480 |
+
horizontal_adjacency_ratio=horizontal_adjacency_ratio,
|
| 481 |
+
layout_type=layout_type,
|
| 482 |
+
anchor_count=anchor_count,
|
| 483 |
+
page_width=page_width,
|
| 484 |
+
page_height=page_height,
|
| 485 |
+
anchor_y_variance=anchor_y_variance,
|
| 486 |
+
effective_dpi=effective_dpi,
|
| 487 |
+
width_in_inches=width_in_inches,
|
| 488 |
+
height_in_inches=height_in_inches,
|
| 489 |
+
)
|
| 490 |
+
|
| 491 |
+
logger.info(
|
| 492 |
+
"[LayoutProfiler] 분석 완료: "
|
| 493 |
+
f"consistency={global_consistency_score:.3f}, "
|
| 494 |
+
f"adjacency={horizontal_adjacency_ratio:.3f}, "
|
| 495 |
+
f"anchors={anchor_count}, "
|
| 496 |
+
f"layout={layout_type.name}, "
|
| 497 |
+
f"dpi={effective_dpi:.1f}, "
|
| 498 |
+
f"width_in={width_in_inches:.2f}, "
|
| 499 |
+
f"추천 전략={recommended_strategy.name}"
|
| 500 |
+
)
|
| 501 |
+
|
| 502 |
+
return LayoutProfile(
|
| 503 |
+
global_consistency_score=global_consistency_score,
|
| 504 |
+
anchor_x_std=anchor_x_std,
|
| 505 |
+
horizontal_adjacency_ratio=horizontal_adjacency_ratio,
|
| 506 |
+
anchor_count=anchor_count,
|
| 507 |
+
layout_type=layout_type,
|
| 508 |
+
page_width=page_width,
|
| 509 |
+
page_height=page_height,
|
| 510 |
+
anchor_y_variance=anchor_y_variance,
|
| 511 |
+
recommended_strategy=recommended_strategy,
|
| 512 |
+
effective_dpi=effective_dpi,
|
| 513 |
+
width_in_inches=width_in_inches,
|
| 514 |
+
height_in_inches=height_in_inches,
|
| 515 |
+
)
|
| 516 |
+
|
| 517 |
+
@staticmethod
|
| 518 |
+
def _recommend_strategy(
|
| 519 |
+
consistency: float,
|
| 520 |
+
anchor_x_std: float,
|
| 521 |
+
horizontal_adjacency_ratio: float,
|
| 522 |
+
layout_type: LayoutType,
|
| 523 |
+
anchor_count: int,
|
| 524 |
+
page_width: int,
|
| 525 |
+
page_height: int,
|
| 526 |
+
anchor_y_variance: float,
|
| 527 |
+
effective_dpi: float,
|
| 528 |
+
width_in_inches: float,
|
| 529 |
+
height_in_inches: float,
|
| 530 |
+
) -> SortingStrategyType:
|
| 531 |
+
"""전략 추천 로직 (Phase 2)"""
|
| 532 |
+
|
| 533 |
+
# 명확한 2단 구조
|
| 534 |
+
if layout_type == LayoutType.STANDARD_2_COLUMN:
|
| 535 |
+
if 0.4 <= horizontal_adjacency_ratio < 0.6 and 0.4 <= consistency <= 0.75:
|
| 536 |
+
return SortingStrategyType.HYBRID
|
| 537 |
+
|
| 538 |
+
if horizontal_adjacency_ratio >= 0.6:
|
| 539 |
+
if LayoutProfiler._prefer_global_for_column_layout(
|
| 540 |
+
width_in_inches, effective_dpi
|
| 541 |
+
):
|
| 542 |
+
return SortingStrategyType.GLOBAL_FIRST
|
| 543 |
+
return SortingStrategyType.LOCAL_FIRST
|
| 544 |
+
|
| 545 |
+
if horizontal_adjacency_ratio < 0.4:
|
| 546 |
+
return SortingStrategyType.LOCAL_FIRST
|
| 547 |
+
|
| 548 |
+
if anchor_count < 8:
|
| 549 |
+
return SortingStrategyType.LOCAL_FIRST
|
| 550 |
+
|
| 551 |
+
return (
|
| 552 |
+
SortingStrategyType.GLOBAL_FIRST
|
| 553 |
+
if consistency >= 0.6
|
| 554 |
+
else SortingStrategyType.LOCAL_FIRST
|
| 555 |
+
)
|
| 556 |
+
|
| 557 |
+
if layout_type in (
|
| 558 |
+
LayoutType.MIXED_TOP1_BOTTOM2,
|
| 559 |
+
LayoutType.MIXED_TOP2_BOTTOM1,
|
| 560 |
+
):
|
| 561 |
+
if horizontal_adjacency_ratio >= 0.5:
|
| 562 |
+
return SortingStrategyType.HYBRID
|
| 563 |
+
return SortingStrategyType.LOCAL_FIRST
|
| 564 |
+
|
| 565 |
+
if layout_type == LayoutType.HORIZONTAL_SEP_PRESENT:
|
| 566 |
+
return (
|
| 567 |
+
SortingStrategyType.LOCAL_FIRST
|
| 568 |
+
if horizontal_adjacency_ratio >= 0.4
|
| 569 |
+
else SortingStrategyType.GLOBAL_FIRST
|
| 570 |
+
)
|
| 571 |
+
|
| 572 |
+
if horizontal_adjacency_ratio > 0.5:
|
| 573 |
+
return SortingStrategyType.LOCAL_FIRST
|
| 574 |
+
if consistency > 0.75:
|
| 575 |
+
return SortingStrategyType.GLOBAL_FIRST
|
| 576 |
+
if consistency < 0.4:
|
| 577 |
+
return SortingStrategyType.LOCAL_FIRST
|
| 578 |
+
|
| 579 |
+
if 0.35 <= horizontal_adjacency_ratio <= 0.65:
|
| 580 |
+
return SortingStrategyType.HYBRID
|
| 581 |
+
|
| 582 |
+
return SortingStrategyType.GLOBAL_FIRST
|
| 583 |
+
|
| 584 |
+
@staticmethod
|
| 585 |
+
def _resolve_effective_dpi(
|
| 586 |
+
provided_dpi: Optional[float],
|
| 587 |
+
page_width: int,
|
| 588 |
+
page_height: int,
|
| 589 |
+
) -> float:
|
| 590 |
+
"""
|
| 591 |
+
DPI 정보를 인자로 전달받지 못한 경우 페이지 높이를 기반으로 추정한다.
|
| 592 |
+
기본값과 최소/최대 한계를 두어 극단값을 방지한다.
|
| 593 |
+
"""
|
| 594 |
+
if provided_dpi and provided_dpi > 0:
|
| 595 |
+
return float(provided_dpi)
|
| 596 |
+
|
| 597 |
+
if page_height and page_height > 0:
|
| 598 |
+
estimated = page_height / BASE_PAGE_HEIGHT_INCHES
|
| 599 |
+
return min(max(estimated, MIN_EFFECTIVE_DPI), MAX_EFFECTIVE_DPI)
|
| 600 |
+
|
| 601 |
+
return DEFAULT_BASE_DPI
|
| 602 |
+
|
| 603 |
+
@staticmethod
|
| 604 |
+
def _compute_physical_dimensions(
|
| 605 |
+
page_width: int, page_height: int, effective_dpi: float
|
| 606 |
+
) -> Tuple[float, float]:
|
| 607 |
+
"""픽셀 단위 크기를 인치 단위로 변환한다."""
|
| 608 |
+
dpi = effective_dpi if effective_dpi > 0 else DEFAULT_BASE_DPI
|
| 609 |
+
width_in = page_width / dpi if page_width else 0.0
|
| 610 |
+
height_in = page_height / dpi if page_height else 0.0
|
| 611 |
+
return width_in, height_in
|
| 612 |
+
|
| 613 |
+
@staticmethod
|
| 614 |
+
def _prefer_global_for_column_layout(
|
| 615 |
+
width_in_inches: float, effective_dpi: float
|
| 616 |
+
) -> bool:
|
| 617 |
+
"""
|
| 618 |
+
2단 구조에서 Global 전략을 우선해야 하는지 판단한다.
|
| 619 |
+
페이지 폭이 특정 임계값 이하이거나 DPI가 낮아 스캔본 특성이 강할 때 Global을 선호한다.
|
| 620 |
+
"""
|
| 621 |
+
if width_in_inches <= 0:
|
| 622 |
+
return True
|
| 623 |
+
|
| 624 |
+
if width_in_inches <= GLOBAL_WIDTH_INCH_THRESHOLD:
|
| 625 |
+
return True
|
| 626 |
+
|
| 627 |
+
dpi_ratio = effective_dpi / DEFAULT_BASE_DPI if DEFAULT_BASE_DPI else 1.0
|
| 628 |
+
adjusted_threshold = GLOBAL_WIDTH_INCH_THRESHOLD * (1 + GLOBAL_WIDTH_INCH_MARGIN)
|
| 629 |
+
if dpi_ratio <= 0.75 and width_in_inches <= adjusted_threshold:
|
| 630 |
+
return True
|
| 631 |
+
|
| 632 |
+
return False
|
| 633 |
+
|
| 634 |
+
|
| 635 |
+
# ============================================================================
|
| 636 |
+
# Strategy Factory
|
| 637 |
+
# ============================================================================
|
| 638 |
+
|
| 639 |
+
|
| 640 |
+
class SortingStrategyFactory:
|
| 641 |
+
"""전략 인스턴스 생성 팩토리"""
|
| 642 |
+
|
| 643 |
+
_strategies: Dict[SortingStrategyType, SortingStrategy] = {
|
| 644 |
+
SortingStrategyType.GLOBAL_FIRST: GlobalFirstStrategy(),
|
| 645 |
+
SortingStrategyType.LOCAL_FIRST: LocalFirstStrategy(),
|
| 646 |
+
SortingStrategyType.HYBRID: HybridStrategy(),
|
| 647 |
+
}
|
| 648 |
+
|
| 649 |
+
@classmethod
|
| 650 |
+
def get_strategy(cls, strategy_type: SortingStrategyType) -> SortingStrategy:
|
| 651 |
+
"""전략 타입에 따라 전략 인스턴스 반환"""
|
| 652 |
+
if strategy_type not in cls._strategies:
|
| 653 |
+
raise ValueError(f"지원되지 않는 전략 타입: {strategy_type}")
|
| 654 |
+
return cls._strategies[strategy_type]
|
| 655 |
+
|
| 656 |
+
|
| 657 |
+
# ============================================================================
|
| 658 |
+
# Adaptive 메인 함수 (Phase 1)
|
| 659 |
+
# ============================================================================
|
| 660 |
+
|
| 661 |
+
|
| 662 |
+
def sort_layout_elements_adaptive(
|
| 663 |
+
elements: List[MockElement],
|
| 664 |
+
document_type: str,
|
| 665 |
+
page_width: Optional[int] = None,
|
| 666 |
+
page_height: Optional[int] = None,
|
| 667 |
+
force_strategy: Optional[str] = None,
|
| 668 |
+
page_dpi: Optional[float] = None,
|
| 669 |
+
) -> List[MockElement]:
|
| 670 |
+
"""
|
| 671 |
+
Adaptive 정렬 함수 - 레이아웃 특성을 분석하여 최적 전략을 선택하고 정렬 실행
|
| 672 |
+
|
| 673 |
+
레이아웃 요소들의 구조적 특성(전역 일관성, 수평 인접성, 레이아웃 유형)을 분석하여
|
| 674 |
+
GlobalFirstStrategy, LocalFirstStrategy, HybridStrategy 중 최적의 전략을 선택합니다.
|
| 675 |
+
|
| 676 |
+
Args:
|
| 677 |
+
elements: 정렬할 레이아웃 요소 리스트 (MockElement 객체)
|
| 678 |
+
document_type: 문서 타입
|
| 679 |
+
- "question_based": 학습지 (앵커-자식 그룹핑 적용)
|
| 680 |
+
- "reading_order": 일반 문서 (단순 읽기 순서)
|
| 681 |
+
page_width: 페이지 너비 (픽셀). None이면 요소 bbox에서 자동 계산
|
| 682 |
+
page_height: 페이지 높이 (픽셀). None이면 요소 bbox에서 자동 계산
|
| 683 |
+
page_dpi: 페이지 DPI. 지정하지 않으면 LayoutProfiler가 높이를 기반으로 추정
|
| 684 |
+
force_strategy: 강제 전략 지정 (테스트 또는 디버깅용)
|
| 685 |
+
- None (기본값): LayoutProfiler가 자동으로 전략 선택 (권장)
|
| 686 |
+
- "GLOBAL_FIRST": GlobalFirstStrategy 강제 사용
|
| 687 |
+
- "LOCAL_FIRST": LocalFirstStrategy 강제 사용
|
| 688 |
+
- "HYBRID": HybridStrategy 강제 ���용
|
| 689 |
+
|
| 690 |
+
Returns:
|
| 691 |
+
정렬된 요소 리스트. 각 요소에 다음 속성이 할당됨:
|
| 692 |
+
- group_id (int): 그룹 번호 (0부터 시작)
|
| 693 |
+
- order_in_group (int): 그룹 내 순서 (0부터 시작)
|
| 694 |
+
|
| 695 |
+
Raises:
|
| 696 |
+
ValueError: 유효하지 않은 force_strategy 값 (자동으로 GLOBAL_FIRST로 폴백)
|
| 697 |
+
|
| 698 |
+
Examples:
|
| 699 |
+
>>> # 자동 전략 선택 (권장)
|
| 700 |
+
>>> sorted_elements = sort_layout_elements_adaptive(
|
| 701 |
+
... elements=elements,
|
| 702 |
+
... document_type="question_based",
|
| 703 |
+
... page_width=2480,
|
| 704 |
+
... page_height=3508,
|
| 705 |
+
... force_strategy=None
|
| 706 |
+
... )
|
| 707 |
+
|
| 708 |
+
>>> # 강제 전략 지정 (테스트 또는 디버깅)
|
| 709 |
+
>>> sorted_elements = sort_layout_elements_adaptive(
|
| 710 |
+
... elements=elements,
|
| 711 |
+
... document_type="question_based",
|
| 712 |
+
... page_width=2480,
|
| 713 |
+
... page_height=3508,
|
| 714 |
+
... force_strategy="GLOBAL_FIRST"
|
| 715 |
+
... )
|
| 716 |
+
|
| 717 |
+
Notes:
|
| 718 |
+
- 자동 선택 시 LayoutProfiler가 레이아웃을 분석하여 최적 전략 추천
|
| 719 |
+
- 분석 오버헤드: < 5ms (전체 실행 시간의 < 5%)
|
| 720 |
+
- HybridStrategy는 두 전략을 병렬 실행하므로 실행 시간 약 2배
|
| 721 |
+
- 상세한 로그는 loguru logger를 통해 출력됨
|
| 722 |
+
|
| 723 |
+
See Also:
|
| 724 |
+
- LayoutProfiler.analyze(): 레이아웃 특성 분석
|
| 725 |
+
- GlobalFirstStrategy: PDF 레이아웃 전략
|
| 726 |
+
- LocalFirstStrategy: 이미지 레이아웃 전략
|
| 727 |
+
- HybridStrategy: 혼합 전략
|
| 728 |
+
"""
|
| 729 |
+
logger.info("=" * 80)
|
| 730 |
+
logger.info("[Adaptive Sorter] Phase 1 프로토타입 실행")
|
| 731 |
+
logger.info(f" - 강제 전략: {force_strategy if force_strategy else '자동 선택'}")
|
| 732 |
+
logger.info("=" * 80)
|
| 733 |
+
|
| 734 |
+
filtered_elements = preprocess_elements(elements, document_type)
|
| 735 |
+
if not filtered_elements:
|
| 736 |
+
logger.warning("전처리 후 정렬할 요소가 없습니다.")
|
| 737 |
+
return []
|
| 738 |
+
|
| 739 |
+
if not page_width or page_width <= 0:
|
| 740 |
+
page_width = (
|
| 741 |
+
int(max(e.bbox_x + e.bbox_width for e in filtered_elements))
|
| 742 |
+
if filtered_elements
|
| 743 |
+
else 0
|
| 744 |
+
)
|
| 745 |
+
if not page_height or page_height <= 0:
|
| 746 |
+
page_height = (
|
| 747 |
+
int(max(e.bbox_y + e.bbox_height for e in filtered_elements))
|
| 748 |
+
if filtered_elements
|
| 749 |
+
else 0
|
| 750 |
+
)
|
| 751 |
+
|
| 752 |
+
dpi_log_value = (
|
| 753 |
+
f"{page_dpi:.1f}" if page_dpi and page_dpi > 0 else "auto (height-based)"
|
| 754 |
+
)
|
| 755 |
+
logger.info(
|
| 756 |
+
f"[Adaptive] 페이지 크기 추정: {page_width} x {page_height} / dpi={dpi_log_value}"
|
| 757 |
+
)
|
| 758 |
+
|
| 759 |
+
# Phase 1: 강제 전략 사용
|
| 760 |
+
if force_strategy:
|
| 761 |
+
try:
|
| 762 |
+
strategy_type = SortingStrategyType[force_strategy.upper()]
|
| 763 |
+
logger.info(f"[Adaptive] 강제 전략 사용: {strategy_type.name}")
|
| 764 |
+
except KeyError:
|
| 765 |
+
logger.error(f"유효하지 않은 전략 이름: {force_strategy}")
|
| 766 |
+
logger.info("기본 전략(GLOBAL_FIRST) 사용")
|
| 767 |
+
strategy_type = SortingStrategyType.GLOBAL_FIRST
|
| 768 |
+
else:
|
| 769 |
+
# Phase 2: 자동 선택
|
| 770 |
+
profile = LayoutProfiler.analyze(
|
| 771 |
+
filtered_elements, page_width, page_height, page_dpi
|
| 772 |
+
)
|
| 773 |
+
logger.info(f"[Adaptive] 자동 전략 선택: {profile.recommended_strategy.name}")
|
| 774 |
+
strategy_type = profile.recommended_strategy
|
| 775 |
+
|
| 776 |
+
# 전략 실행
|
| 777 |
+
strategy = SortingStrategyFactory.get_strategy(strategy_type)
|
| 778 |
+
sorted_elements = strategy.sort(
|
| 779 |
+
elements=elements,
|
| 780 |
+
document_type=document_type,
|
| 781 |
+
page_width=page_width,
|
| 782 |
+
page_height=page_height,
|
| 783 |
+
)
|
| 784 |
+
|
| 785 |
+
logger.info(f"[Adaptive Sorter] 완료: {len(sorted_elements)}개 요소 정렬됨")
|
| 786 |
+
logger.info("=" * 80)
|
| 787 |
+
|
| 788 |
+
return sorted_elements
|
app/services/sorter_구버전.py
ADDED
|
@@ -0,0 +1,1316 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# -*- coding: utf-8 -*-
|
| 2 |
+
"""
|
| 3 |
+
SmartEyeSsen Layout Sorter (v.LayoutDetect.2.4 - Tie-breaker in Post-processing)
|
| 4 |
+
=================================================================================
|
| 5 |
+
|
| 6 |
+
문제 레이아웃 정렬 알고리즘 구현 (Layout Type Detection 기반 Hybrid)
|
| 7 |
+
페이지 전체 레이아웃 유형(1단, 2단, 혼합형 등)을 먼저 판별하고,
|
| 8 |
+
유형에 맞는 분할 전략(수평/수직) 적용.
|
| 9 |
+
분할 실패 시(Base Case), 레이아웃 유형별로 특화된 그룹핑 로직 호출.
|
| 10 |
+
- 표준 1단/2단 컬럼: _base_case_standard_1_column
|
| 11 |
+
- 혼합형: _base_case_mixed_layout
|
| 12 |
+
최종 병합 시 전역 고아 그룹 처리 로직 적용.
|
| 13 |
+
|
| 14 |
+
알고리즘 흐름: (v.LayoutDetect.2.1/2.2/2.3과 동일)
|
| 15 |
+
0. 전처리
|
| 16 |
+
1. 레이아웃 유형 판별
|
| 17 |
+
2. 유형별 재귀 처리
|
| 18 |
+
3. Base Case 처리 (후처리 포함)
|
| 19 |
+
4. 최종 병합 및 순서 부여
|
| 20 |
+
|
| 21 |
+
v.LayoutDetect.2.4:
|
| 22 |
+
- _post_process_table_figure_assignment: 최적 그룹 탐색 시 Y 거리가 동일할 경우 더 뒤쪽 그룹을 우선하는 Tie-breaker 추가.
|
| 23 |
+
- sort_layout_elements: 후처리 호출 전에 임시 그룹 ID 할당하여 로그 가독성 개선.
|
| 24 |
+
- (v2.3 변경 유지) _post_process_table_figure_assignment: 최적 그룹 탐색 로직 (Lookahead).
|
| 25 |
+
- (v2.2 변경 유지) _post_process_table_figure_assignment: 이동 조건은 거리 비교 로직 사용.
|
| 26 |
+
- (v2.1 변경 유지) _post_process_table_figure_assignment: y_diff_threshold 기본값 150.
|
| 27 |
+
- (v2.1 변경 유지) _base_case_standard_1_column: 상단 고아 요소 분리 로직.
|
| 28 |
+
"""
|
| 29 |
+
|
| 30 |
+
# 필요한 라이브러리 임포트
|
| 31 |
+
from typing import List, Dict, Tuple, Optional, Any, Union
|
| 32 |
+
from dataclasses import dataclass, field
|
| 33 |
+
import numpy as np
|
| 34 |
+
from sklearn.cluster import KMeans
|
| 35 |
+
from loguru import logger
|
| 36 |
+
import math
|
| 37 |
+
from enum import Enum, auto
|
| 38 |
+
|
| 39 |
+
# Mock 모델 임포트
|
| 40 |
+
from .mock_models import MockElement
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
# ============================================================================
|
| 44 |
+
# 데이터 클래스 및 Enum 정의 (기존과 동일)
|
| 45 |
+
# ============================================================================
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
class LayoutType(Enum):
|
| 49 |
+
STANDARD_1_COLUMN = auto()
|
| 50 |
+
STANDARD_2_COLUMN = auto()
|
| 51 |
+
MIXED_TOP1_BOTTOM2 = auto()
|
| 52 |
+
MIXED_TOP2_BOTTOM1 = auto()
|
| 53 |
+
HORIZONTAL_SEP_PRESENT = auto()
|
| 54 |
+
READING_ORDER = auto()
|
| 55 |
+
UNKNOWN = auto()
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
@dataclass
|
| 59 |
+
class Zone:
|
| 60 |
+
x_min: int
|
| 61 |
+
y_min: int
|
| 62 |
+
x_max: int
|
| 63 |
+
y_max: int
|
| 64 |
+
|
| 65 |
+
@property
|
| 66 |
+
def width(self) -> int:
|
| 67 |
+
return max(0, self.x_max - self.x_min)
|
| 68 |
+
|
| 69 |
+
@property
|
| 70 |
+
def height(self) -> int:
|
| 71 |
+
return max(0, self.y_max - self.y_min)
|
| 72 |
+
|
| 73 |
+
def __repr__(self) -> str:
|
| 74 |
+
return f"Zone(x=[{self.x_min}, {self.x_max}), y=[{self.y_min}, {self.y_max}))"
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
@dataclass
|
| 78 |
+
class HorizontalSplit:
|
| 79 |
+
top_zone: Zone
|
| 80 |
+
bottom_zone: Zone
|
| 81 |
+
separator_element: MockElement
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
@dataclass
|
| 85 |
+
class HorizontalSplitYGap:
|
| 86 |
+
top_zone: Zone
|
| 87 |
+
bottom_zone: Zone
|
| 88 |
+
split_y: float
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
@dataclass
|
| 92 |
+
class VerticalSplit:
|
| 93 |
+
left_zone: Zone
|
| 94 |
+
right_zone: Zone
|
| 95 |
+
gutter_x: float
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
@dataclass
|
| 99 |
+
class ElementGroup:
|
| 100 |
+
anchor: Optional[MockElement]
|
| 101 |
+
children: List[MockElement] = field(default_factory=list)
|
| 102 |
+
group_id: int = -1 # flatten 함수에서 최종 할당, 후처리 전 임시 할당
|
| 103 |
+
|
| 104 |
+
def add_child(self, child: MockElement):
|
| 105 |
+
self.children.append(child)
|
| 106 |
+
|
| 107 |
+
def get_all_elements_sorted(self) -> List[MockElement]:
|
| 108 |
+
"""
|
| 109 |
+
그룹 내 요소들을 정렬합니다.
|
| 110 |
+
- 앵커(Anchor)가 항상 가장 먼저 위치합니다.
|
| 111 |
+
- 나머지 자식(Children) 요소들은 (Y, X) 좌표 순으로 정렬됩니다.
|
| 112 |
+
"""
|
| 113 |
+
# 1. 앵커가 존재하면 리스트의 첫 요소로 설정합니다.
|
| 114 |
+
elements = [self.anchor] if self.anchor else []
|
| 115 |
+
|
| 116 |
+
# 2. 자식 요소들을 (Y, X) 좌표 기준으로 정렬합니다.
|
| 117 |
+
sorted_children = sorted(
|
| 118 |
+
self.children, key=lambda e: (e.y_position, e.x_position)
|
| 119 |
+
)
|
| 120 |
+
|
| 121 |
+
# 3. 앵커 요소 뒤에 정렬된 자식 요소들을 추가합니다.
|
| 122 |
+
elements.extend(sorted_children)
|
| 123 |
+
|
| 124 |
+
return elements
|
| 125 |
+
|
| 126 |
+
def is_empty(self) -> bool:
|
| 127 |
+
return self.anchor is None and not self.children
|
| 128 |
+
|
| 129 |
+
def __repr__(self) -> str:
|
| 130 |
+
anchor_id = self.anchor.element_id if self.anchor else "Orphan"
|
| 131 |
+
child_ids = sorted([c.element_id for c in self.children])
|
| 132 |
+
# flatten 전에는 group_id가 임시값일 수 있음
|
| 133 |
+
return f"Group(ID:{self.group_id}, Anchor: {anchor_id}, Children: {child_ids})"
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
# ============================================================================
|
| 137 |
+
# 상수 정의 (기존과 동일)
|
| 138 |
+
# ============================================================================
|
| 139 |
+
|
| 140 |
+
ALLOWED_ANCHORS = ["question type", "question number", "second_question_number"]
|
| 141 |
+
ALLOWED_CHILDREN = ["question text", "list", "choices", "figure", "table", "flowchart"]
|
| 142 |
+
ALLOWED_CLASSES = ALLOWED_ANCHORS + ALLOWED_CHILDREN
|
| 143 |
+
|
| 144 |
+
HORIZONTAL_SEP_WIDTH_THRESHOLD = 0.8
|
| 145 |
+
HORIZONTAL_SEP_Y_POS_THRESHOLD = 0.15
|
| 146 |
+
MIN_ANCHORS_FOR_SPLIT = 2
|
| 147 |
+
VERTICAL_GAP_THRESHOLD_RATIO = 1.5
|
| 148 |
+
VERTICAL_GAP_THRESHOLD_ABS = 100
|
| 149 |
+
KMEANS_N_CLUSTERS = 2
|
| 150 |
+
KMEANS_CLUSTER_SEPARATION_MIN = 50
|
| 151 |
+
LAYOUT_DETECT_Y_SPLIT_POINT = 0.4
|
| 152 |
+
LAYOUT_DETECT_X_STD_THRESHOLD_RATIO = 0.1
|
| 153 |
+
|
| 154 |
+
HORIZONTAL_ADJACENCY_Y_CENTER_RATIO = 0.7
|
| 155 |
+
HORIZONTAL_ADJACENCY_X_PROXIMITY = 50
|
| 156 |
+
|
| 157 |
+
BASE_CASE_TOP_ORPHAN_THRESHOLD_RATIO = 0.15
|
| 158 |
+
POST_PROCESS_CLOSENESS_RATIO = 0.5
|
| 159 |
+
POST_PROCESS_LOOKAHEAD = 2
|
| 160 |
+
|
| 161 |
+
# ============================================================================
|
| 162 |
+
# 메인 함수: 레이아웃 유형 판별 후 정렬 (수정됨)
|
| 163 |
+
# ============================================================================
|
| 164 |
+
|
| 165 |
+
|
| 166 |
+
def sort_layout_elements(
|
| 167 |
+
elements: List[MockElement],
|
| 168 |
+
document_type: str = "question_based",
|
| 169 |
+
page_width: Optional[int] = None,
|
| 170 |
+
page_height: Optional[int] = None,
|
| 171 |
+
) -> List[MockElement]:
|
| 172 |
+
"""
|
| 173 |
+
레이아웃 유형 판별 후 맞춤형 정렬 로직 적용 (v.LayoutDetect.2.4)
|
| 174 |
+
"""
|
| 175 |
+
logger.info(
|
| 176 |
+
f"맞춤형 정렬(v.LayoutDetect.2.4) 시작: {len(elements)}개 요소, 타입={document_type}"
|
| 177 |
+
)
|
| 178 |
+
|
| 179 |
+
filtered_elements = preprocess_elements(elements, document_type)
|
| 180 |
+
if not filtered_elements:
|
| 181 |
+
logger.warning("전처리 후 정렬할 요소가 없습니다.")
|
| 182 |
+
return []
|
| 183 |
+
|
| 184 |
+
if page_width is None:
|
| 185 |
+
page_width = calculate_page_width(filtered_elements)
|
| 186 |
+
if page_height is None:
|
| 187 |
+
page_height = calculate_page_height(filtered_elements)
|
| 188 |
+
logger.info(f"페이지 크기: {page_width} x {page_height}")
|
| 189 |
+
|
| 190 |
+
initial_zone = Zone(x_min=0, y_min=0, x_max=page_width, y_max=page_height)
|
| 191 |
+
grouped_results: List[ElementGroup] = []
|
| 192 |
+
|
| 193 |
+
try:
|
| 194 |
+
if document_type == "reading_order":
|
| 195 |
+
layout_type = LayoutType.READING_ORDER
|
| 196 |
+
logger.info(f"판별된 레이아웃 유형: {layout_type.name} (문서 타입 지정)")
|
| 197 |
+
sorted_elements_reading = sorted(
|
| 198 |
+
filtered_elements, key=lambda e: (e.y_position, e.x_position)
|
| 199 |
+
)
|
| 200 |
+
grouped_results = [
|
| 201 |
+
ElementGroup(anchor=None, children=[elem])
|
| 202 |
+
for elem in sorted_elements_reading
|
| 203 |
+
]
|
| 204 |
+
else:
|
| 205 |
+
layout_type = detect_layout_type(filtered_elements, page_width, page_height)
|
| 206 |
+
logger.info(f"판별된 레이아웃 유형: {layout_type.name}")
|
| 207 |
+
|
| 208 |
+
if layout_type == LayoutType.STANDARD_1_COLUMN:
|
| 209 |
+
logger.debug(
|
| 210 |
+
f"{layout_type.name}: 분할 없이 전체 구역 표준 1단 Base Case 실행"
|
| 211 |
+
)
|
| 212 |
+
grouped_results = _base_case_standard_1_column(
|
| 213 |
+
initial_zone, filtered_elements
|
| 214 |
+
)
|
| 215 |
+
elif layout_type == LayoutType.STANDARD_2_COLUMN:
|
| 216 |
+
grouped_results = _sort_standard_2_column(
|
| 217 |
+
initial_zone, filtered_elements
|
| 218 |
+
)
|
| 219 |
+
elif layout_type in [
|
| 220 |
+
LayoutType.HORIZONTAL_SEP_PRESENT,
|
| 221 |
+
LayoutType.MIXED_TOP1_BOTTOM2,
|
| 222 |
+
LayoutType.MIXED_TOP2_BOTTOM1,
|
| 223 |
+
LayoutType.UNKNOWN,
|
| 224 |
+
]:
|
| 225 |
+
grouped_results = _sort_recursive_by_layout(
|
| 226 |
+
initial_zone, filtered_elements, layout_type, depth=0
|
| 227 |
+
)
|
| 228 |
+
else:
|
| 229 |
+
logger.error(
|
| 230 |
+
f"처리할 수 없는 레이아웃 유형: {layout_type.name}. (Y,X) 정렬로 대체합니다."
|
| 231 |
+
)
|
| 232 |
+
sorted_elements_fallback = sorted(
|
| 233 |
+
filtered_elements, key=lambda e: (e.y_position, e.x_position)
|
| 234 |
+
)
|
| 235 |
+
grouped_results = [
|
| 236 |
+
ElementGroup(anchor=None, children=[elem])
|
| 237 |
+
for elem in sorted_elements_fallback
|
| 238 |
+
]
|
| 239 |
+
|
| 240 |
+
# --- 👇 수정: 후처리 전에 임시 그룹 ID 할당 (로깅용) ---
|
| 241 |
+
if grouped_results and document_type == "question_based":
|
| 242 |
+
logger.debug("후처리 전 임시 그룹 ID 할당...")
|
| 243 |
+
temp_groups_with_id = []
|
| 244 |
+
temp_group_id_counter = 0
|
| 245 |
+
temp_orphan_groups = [g for g in grouped_results if g.anchor is None]
|
| 246 |
+
temp_non_orphan_groups = [
|
| 247 |
+
g for g in grouped_results if g.anchor is not None
|
| 248 |
+
]
|
| 249 |
+
|
| 250 |
+
# 고아 그룹 먼저 ID 할당
|
| 251 |
+
if temp_orphan_groups:
|
| 252 |
+
temp_orphan_groups.sort(
|
| 253 |
+
key=lambda g: (
|
| 254 |
+
min(c.y_position for c in g.children)
|
| 255 |
+
if g.children
|
| 256 |
+
else float("inf")
|
| 257 |
+
)
|
| 258 |
+
)
|
| 259 |
+
for group in temp_orphan_groups:
|
| 260 |
+
group.group_id = temp_group_id_counter
|
| 261 |
+
temp_groups_with_id.append(group)
|
| 262 |
+
temp_group_id_counter += 1
|
| 263 |
+
|
| 264 |
+
# 앵커 그룹 ID 할당
|
| 265 |
+
# (주의: _post_process... 함���는 앵커 그룹 리스트만 받도록 수정 필요)
|
| 266 |
+
# 우선 여기서 ID만 할당하고, 후처리는 non_orphan_groups 대상으로 수행
|
| 267 |
+
for group in temp_non_orphan_groups:
|
| 268 |
+
group.group_id = temp_group_id_counter
|
| 269 |
+
# temp_groups_with_id.append(group) # flatten 전 최종 순서는 아직 모름
|
| 270 |
+
temp_group_id_counter += 1
|
| 271 |
+
|
| 272 |
+
# 후처리는 앵커가 있는 그룹들을 대상으로 수행
|
| 273 |
+
logger.debug(
|
| 274 |
+
f"{len(temp_non_orphan_groups)}개 앵커 그룹 대상 후처리 실행..."
|
| 275 |
+
)
|
| 276 |
+
processed_non_orphan_groups = _post_process_table_figure_assignment(
|
| 277 |
+
temp_non_orphan_groups
|
| 278 |
+
)
|
| 279 |
+
|
| 280 |
+
# 최종 그룹 리스트 재구성 (고아 + 후처리된 앵커 그룹)
|
| 281 |
+
grouped_results = temp_orphan_groups + processed_non_orphan_groups
|
| 282 |
+
logger.debug("후처리 및 임시 그룹 ID 할당 완료.")
|
| 283 |
+
# --- 👆 수정 끝 ---
|
| 284 |
+
|
| 285 |
+
except Exception as e:
|
| 286 |
+
logger.error(
|
| 287 |
+
f"맞춤형 정렬 중 심각한 오류 발생: {e}. (Y,X) 좌표 정렬로 대체합니다.",
|
| 288 |
+
exc_info=True,
|
| 289 |
+
)
|
| 290 |
+
sorted_elements_fallback = sorted(
|
| 291 |
+
filtered_elements, key=lambda e: (e.y_position, e.x_position)
|
| 292 |
+
)
|
| 293 |
+
grouped_results = [
|
| 294 |
+
ElementGroup(anchor=None, children=[elem])
|
| 295 |
+
for elem in sorted_elements_fallback
|
| 296 |
+
]
|
| 297 |
+
|
| 298 |
+
if not grouped_results:
|
| 299 |
+
logger.warning("그룹핑 결과가 비어 있습니다.")
|
| 300 |
+
return []
|
| 301 |
+
|
| 302 |
+
# 최종 병합: 고아 그룹과 앵커 그룹 순서 결정 (기존 로직 유지)
|
| 303 |
+
orphan_groups = [g for g in grouped_results if g.anchor is None]
|
| 304 |
+
non_orphan_groups = [
|
| 305 |
+
g for g in grouped_results if g.anchor is not None
|
| 306 |
+
] # 후처리된 리스트 사용
|
| 307 |
+
final_ordered_groups: List[ElementGroup] = []
|
| 308 |
+
if orphan_groups:
|
| 309 |
+
# 고아 그룹은 Y 좌표 기준으로 정렬
|
| 310 |
+
orphan_groups.sort(
|
| 311 |
+
key=lambda g: (
|
| 312 |
+
min(c.y_position for c in g.children) if g.children else float("inf")
|
| 313 |
+
)
|
| 314 |
+
)
|
| 315 |
+
logger.debug(
|
| 316 |
+
f"전역 고아 그룹 {len(orphan_groups)}개 (Y 좌표 정렬됨) 리스트 맨 앞으로 이동"
|
| 317 |
+
)
|
| 318 |
+
final_ordered_groups.extend(orphan_groups)
|
| 319 |
+
else:
|
| 320 |
+
logger.debug("전역 고아 그룹 없음")
|
| 321 |
+
# 앵커 그룹은 Base Case/재귀 호출에서 결정된 순서 유지 (Y좌표 정렬 불필요)
|
| 322 |
+
final_ordered_groups.extend(non_orphan_groups)
|
| 323 |
+
|
| 324 |
+
# 최종 순서 및 ID 부여
|
| 325 |
+
final_sorted_elements, _, _ = flatten_groups_and_assign_order(
|
| 326 |
+
final_ordered_groups, start_global_order=0, start_group_id=0
|
| 327 |
+
)
|
| 328 |
+
|
| 329 |
+
logger.info(f"맞춤형 정렬 완료: {len(final_sorted_elements)}개 요소")
|
| 330 |
+
return final_sorted_elements
|
| 331 |
+
|
| 332 |
+
|
| 333 |
+
# ============================================================================
|
| 334 |
+
# 레이아웃 유형 판별 함수 (기존과 동일)
|
| 335 |
+
# ============================================================================
|
| 336 |
+
def detect_layout_type(
|
| 337 |
+
elements: List[MockElement], page_width: int, page_height: int
|
| 338 |
+
) -> LayoutType:
|
| 339 |
+
# ... (코드 동일) ...
|
| 340 |
+
"""앵커 요소 분포를 분석하여 페이지 레이아웃 유형 판별"""
|
| 341 |
+
anchors = [e for e in elements if e.class_name in ALLOWED_ANCHORS]
|
| 342 |
+
if len(anchors) < MIN_ANCHORS_FOR_SPLIT:
|
| 343 |
+
logger.debug(
|
| 344 |
+
f"레이아웃 판별: 앵커 수({len(anchors)}) 부족 -> STANDARD_1_COLUMN"
|
| 345 |
+
)
|
| 346 |
+
return LayoutType.STANDARD_1_COLUMN
|
| 347 |
+
|
| 348 |
+
top_zone_height = page_height * HORIZONTAL_SEP_Y_POS_THRESHOLD
|
| 349 |
+
wide_q_type = find_wide_question_type(elements, page_width, top_zone_height)
|
| 350 |
+
if wide_q_type:
|
| 351 |
+
logger.debug(
|
| 352 |
+
f"레이아웃 판별: 넓은 question_type(ID:{wide_q_type.element_id}) 존재 -> HORIZONTAL_SEP_PRESENT"
|
| 353 |
+
)
|
| 354 |
+
return LayoutType.HORIZONTAL_SEP_PRESENT
|
| 355 |
+
|
| 356 |
+
anchor_x_centers = np.array([[a.bbox_x + a.bbox_width / 2] for a in anchors])
|
| 357 |
+
is_clearly_2_column = False
|
| 358 |
+
if len(np.unique(anchor_x_centers)) >= 2:
|
| 359 |
+
try:
|
| 360 |
+
kmeans = KMeans(
|
| 361 |
+
n_clusters=KMEANS_N_CLUSTERS, random_state=42, n_init="auto"
|
| 362 |
+
)
|
| 363 |
+
kmeans.fit(anchor_x_centers)
|
| 364 |
+
centers = sorted(kmeans.cluster_centers_.flatten())
|
| 365 |
+
if (
|
| 366 |
+
len(centers) == 2
|
| 367 |
+
and centers[1] - centers[0] >= KMEANS_CLUSTER_SEPARATION_MIN
|
| 368 |
+
):
|
| 369 |
+
is_clearly_2_column = True
|
| 370 |
+
logger.trace(
|
| 371 |
+
f"레이아웃 판별: 전체 X 분포는 2단 구조 가능성 높음 (Centers: {centers})"
|
| 372 |
+
)
|
| 373 |
+
else:
|
| 374 |
+
logger.trace(f"레이아웃 판별: 전체 X 분포는 1단 구조 또는 불분명")
|
| 375 |
+
except Exception as e:
|
| 376 |
+
logger.warning(f"레이아웃 판별 중 K-Means 오류 발생: {e}")
|
| 377 |
+
|
| 378 |
+
if is_clearly_2_column:
|
| 379 |
+
split_y = page_height * LAYOUT_DETECT_Y_SPLIT_POINT
|
| 380 |
+
top_anchors = [
|
| 381 |
+
a for a in anchors if (a.y_position + a.bbox_height / 2) < split_y
|
| 382 |
+
]
|
| 383 |
+
bottom_anchors = [
|
| 384 |
+
a for a in anchors if (a.y_position + a.bbox_height / 2) >= split_y
|
| 385 |
+
]
|
| 386 |
+
|
| 387 |
+
if not top_anchors or not bottom_anchors:
|
| 388 |
+
logger.debug("레이아웃 판별: 상/하단 앵커 그룹 불완전 -> STANDARD_2_COLUMN")
|
| 389 |
+
return LayoutType.STANDARD_2_COLUMN
|
| 390 |
+
|
| 391 |
+
top_x_centers = (
|
| 392 |
+
np.array([[a.bbox_x + a.bbox_width / 2] for a in top_anchors])
|
| 393 |
+
if top_anchors
|
| 394 |
+
else np.array([])
|
| 395 |
+
)
|
| 396 |
+
bottom_x_centers = (
|
| 397 |
+
np.array([[a.bbox_x + a.bbox_width / 2] for a in bottom_anchors])
|
| 398 |
+
if bottom_anchors
|
| 399 |
+
else np.array([])
|
| 400 |
+
)
|
| 401 |
+
|
| 402 |
+
x_std_threshold = page_width * LAYOUT_DETECT_X_STD_THRESHOLD_RATIO
|
| 403 |
+
top_is_multi_column = (
|
| 404 |
+
top_x_centers.size > 1 and np.std(top_x_centers) > x_std_threshold
|
| 405 |
+
)
|
| 406 |
+
bottom_is_multi_column = (
|
| 407 |
+
bottom_x_centers.size > 1 and np.std(bottom_x_centers) > x_std_threshold
|
| 408 |
+
)
|
| 409 |
+
|
| 410 |
+
if not top_is_multi_column and bottom_is_multi_column:
|
| 411 |
+
logger.debug(
|
| 412 |
+
f"레이아웃 판별: 상단({len(top_anchors)}개) 1단, 하단({len(bottom_anchors)}개) 2단 -> MIXED_TOP1_BOTTOM2"
|
| 413 |
+
)
|
| 414 |
+
return LayoutType.MIXED_TOP1_BOTTOM2
|
| 415 |
+
elif top_is_multi_column and not bottom_is_multi_column:
|
| 416 |
+
logger.debug(
|
| 417 |
+
f"레이아웃 판별: 상단({len(top_anchors)}개) 2단, 하단({len(bottom_anchors)}개) 1단 -> MIXED_TOP2_BOTTOM1"
|
| 418 |
+
)
|
| 419 |
+
return LayoutType.MIXED_TOP2_BOTTOM1
|
| 420 |
+
elif top_is_multi_column and bottom_is_multi_column:
|
| 421 |
+
logger.debug(
|
| 422 |
+
f"레이아웃 판별: 상단({len(top_anchors)}개) 2단, 하단({len(bottom_anchors)}개) 2단 -> STANDARD_2_COLUMN"
|
| 423 |
+
)
|
| 424 |
+
return LayoutType.STANDARD_2_COLUMN
|
| 425 |
+
else:
|
| 426 |
+
logger.warning(
|
| 427 |
+
f"레이아웃 판별: 상/하단 모두 1단으로 보이나 전체는 2단 구조? -> UNKNOWN"
|
| 428 |
+
)
|
| 429 |
+
return LayoutType.UNKNOWN
|
| 430 |
+
else:
|
| 431 |
+
logger.debug("레이아웃 판별: 전체 1단 구조 -> STANDARD_1_COLUMN")
|
| 432 |
+
return LayoutType.STANDARD_1_COLUMN
|
| 433 |
+
|
| 434 |
+
|
| 435 |
+
# ============================================================================
|
| 436 |
+
# 재귀 정렬 함수 (기존과 동일)
|
| 437 |
+
# ============================================================================
|
| 438 |
+
def _sort_recursive_by_layout(
|
| 439 |
+
current_zone: Zone,
|
| 440 |
+
elements_in_zone: List[MockElement],
|
| 441 |
+
layout_type: LayoutType,
|
| 442 |
+
depth: int,
|
| 443 |
+
) -> List[ElementGroup]:
|
| 444 |
+
# ... (코드 동일) ...
|
| 445 |
+
"""레이아웃 유형에 따라 다른 분할 우선순위를 적용하는 재귀 함수"""
|
| 446 |
+
indent = " " * depth
|
| 447 |
+
logger.debug(
|
| 448 |
+
f"{indent}[Depth {depth}, Type: {layout_type.name}] 구역 처리 시작: {current_zone}, 요소 수={len(elements_in_zone)}"
|
| 449 |
+
)
|
| 450 |
+
|
| 451 |
+
if not elements_in_zone:
|
| 452 |
+
logger.trace(f"{indent} -> 빈 구역")
|
| 453 |
+
return []
|
| 454 |
+
if len(elements_in_zone) == 1:
|
| 455 |
+
element = elements_in_zone[0]
|
| 456 |
+
logger.trace(f"{indent} -> 요소 1개")
|
| 457 |
+
return (
|
| 458 |
+
[ElementGroup(anchor=element)]
|
| 459 |
+
if element.class_name in ALLOWED_ANCHORS
|
| 460 |
+
else [ElementGroup(anchor=None, children=[element])]
|
| 461 |
+
)
|
| 462 |
+
|
| 463 |
+
if layout_type == LayoutType.STANDARD_2_COLUMN:
|
| 464 |
+
logger.debug(f"{indent} -> {layout_type.name}: 표준 2단 처리 함수 직접 호출")
|
| 465 |
+
return _sort_standard_2_column(current_zone, elements_in_zone)
|
| 466 |
+
|
| 467 |
+
split_result: Optional[
|
| 468 |
+
Union[HorizontalSplit, HorizontalSplitYGap, VerticalSplit]
|
| 469 |
+
] = None
|
| 470 |
+
split_type = "None"
|
| 471 |
+
|
| 472 |
+
if layout_type == LayoutType.HORIZONTAL_SEP_PRESENT:
|
| 473 |
+
split_result = find_horizontal_split_by_type(current_zone, elements_in_zone)
|
| 474 |
+
if split_result:
|
| 475 |
+
split_type = "H_Type"
|
| 476 |
+
else:
|
| 477 |
+
anchors = [e for e in elements_in_zone if e.class_name in ALLOWED_ANCHORS]
|
| 478 |
+
split_result = find_vertical_split_kmeans(current_zone, anchors)
|
| 479 |
+
if split_result:
|
| 480 |
+
split_type = "Vertical"
|
| 481 |
+
else:
|
| 482 |
+
split_result = find_horizontal_split_by_y_gap(
|
| 483 |
+
current_zone, elements_in_zone
|
| 484 |
+
)
|
| 485 |
+
if split_result:
|
| 486 |
+
split_type = "H_YGap"
|
| 487 |
+
|
| 488 |
+
elif (
|
| 489 |
+
layout_type == LayoutType.MIXED_TOP1_BOTTOM2
|
| 490 |
+
or layout_type == LayoutType.MIXED_TOP2_BOTTOM1
|
| 491 |
+
):
|
| 492 |
+
split_result = find_horizontal_split_by_y_gap(current_zone, elements_in_zone)
|
| 493 |
+
if split_result:
|
| 494 |
+
split_type = "H_YGap"
|
| 495 |
+
else:
|
| 496 |
+
split_result = find_horizontal_split_by_type(current_zone, elements_in_zone)
|
| 497 |
+
if split_result:
|
| 498 |
+
split_type = "H_Type"
|
| 499 |
+
else:
|
| 500 |
+
anchors = [
|
| 501 |
+
e for e in elements_in_zone if e.class_name in ALLOWED_ANCHORS
|
| 502 |
+
]
|
| 503 |
+
split_result = find_vertical_split_kmeans(current_zone, anchors)
|
| 504 |
+
if split_result:
|
| 505 |
+
split_type = "Vertical"
|
| 506 |
+
|
| 507 |
+
elif layout_type == LayoutType.UNKNOWN:
|
| 508 |
+
split_result = find_horizontal_split_by_type(current_zone, elements_in_zone)
|
| 509 |
+
if split_result:
|
| 510 |
+
split_type = "H_Type"
|
| 511 |
+
else:
|
| 512 |
+
anchors = [e for e in elements_in_zone if e.class_name in ALLOWED_ANCHORS]
|
| 513 |
+
split_result = find_vertical_split_kmeans(current_zone, anchors)
|
| 514 |
+
if split_result:
|
| 515 |
+
split_type = "Vertical"
|
| 516 |
+
else:
|
| 517 |
+
split_result = find_horizontal_split_by_y_gap(
|
| 518 |
+
current_zone, elements_in_zone
|
| 519 |
+
)
|
| 520 |
+
if split_result:
|
| 521 |
+
split_type = "H_YGap"
|
| 522 |
+
|
| 523 |
+
if split_result:
|
| 524 |
+
if isinstance(split_result, (HorizontalSplit, HorizontalSplitYGap)):
|
| 525 |
+
split_y = (
|
| 526 |
+
split_result.split_y
|
| 527 |
+
if isinstance(split_result, HorizontalSplitYGap)
|
| 528 |
+
else split_result.separator_element.y_position
|
| 529 |
+
+ split_result.separator_element.bbox_height / 2
|
| 530 |
+
)
|
| 531 |
+
top_elements = [
|
| 532 |
+
e
|
| 533 |
+
for e in elements_in_zone
|
| 534 |
+
if getattr(e, "element_id", -1)
|
| 535 |
+
!= getattr(
|
| 536 |
+
getattr(split_result, "separator_element", None), "element_id", -2
|
| 537 |
+
)
|
| 538 |
+
and (e.bbox_y + e.bbox_height / 2) < split_y
|
| 539 |
+
]
|
| 540 |
+
bottom_elements = [
|
| 541 |
+
e
|
| 542 |
+
for e in elements_in_zone
|
| 543 |
+
if getattr(e, "element_id", -1)
|
| 544 |
+
!= getattr(
|
| 545 |
+
getattr(split_result, "separator_element", None), "element_id", -2
|
| 546 |
+
)
|
| 547 |
+
and (e.bbox_y + e.bbox_height / 2) >= split_y
|
| 548 |
+
]
|
| 549 |
+
logger.debug(
|
| 550 |
+
f"{indent} -> {split_type} 수평 분할 성공! Top:{len(top_elements)}, Bottom:{len(bottom_elements)}"
|
| 551 |
+
)
|
| 552 |
+
top_layout_type = (
|
| 553 |
+
detect_layout_type(
|
| 554 |
+
top_elements,
|
| 555 |
+
split_result.top_zone.width,
|
| 556 |
+
split_result.top_zone.height,
|
| 557 |
+
)
|
| 558 |
+
if top_elements
|
| 559 |
+
else LayoutType.UNKNOWN
|
| 560 |
+
)
|
| 561 |
+
bottom_layout_type = (
|
| 562 |
+
detect_layout_type(
|
| 563 |
+
bottom_elements,
|
| 564 |
+
split_result.bottom_zone.width,
|
| 565 |
+
split_result.bottom_zone.height,
|
| 566 |
+
)
|
| 567 |
+
if bottom_elements
|
| 568 |
+
else LayoutType.UNKNOWN
|
| 569 |
+
)
|
| 570 |
+
sorted_top = _sort_recursive_by_layout(
|
| 571 |
+
split_result.top_zone, top_elements, top_layout_type, depth + 1
|
| 572 |
+
)
|
| 573 |
+
sep_group = (
|
| 574 |
+
[ElementGroup(anchor=split_result.separator_element)]
|
| 575 |
+
if isinstance(split_result, HorizontalSplit)
|
| 576 |
+
else []
|
| 577 |
+
)
|
| 578 |
+
sorted_bottom = _sort_recursive_by_layout(
|
| 579 |
+
split_result.bottom_zone, bottom_elements, bottom_layout_type, depth + 1
|
| 580 |
+
)
|
| 581 |
+
logger.debug(f"{indent} <- {split_type} 수평 분할 결과 병합")
|
| 582 |
+
return sorted_top + sep_group + sorted_bottom
|
| 583 |
+
|
| 584 |
+
elif isinstance(split_result, VerticalSplit):
|
| 585 |
+
left_elements = [
|
| 586 |
+
e
|
| 587 |
+
for e in elements_in_zone
|
| 588 |
+
if (e.bbox_x + e.bbox_width / 2) < split_result.gutter_x
|
| 589 |
+
]
|
| 590 |
+
right_elements = [
|
| 591 |
+
e
|
| 592 |
+
for e in elements_in_zone
|
| 593 |
+
if (e.bbox_x + e.bbox_width / 2) >= split_result.gutter_x
|
| 594 |
+
]
|
| 595 |
+
logger.debug(
|
| 596 |
+
f"{indent} -> Vertical 수직 분할 성공! Left:{len(left_elements)}, Right:{len(right_elements)}"
|
| 597 |
+
)
|
| 598 |
+
left_layout_type = (
|
| 599 |
+
detect_layout_type(
|
| 600 |
+
left_elements,
|
| 601 |
+
split_result.left_zone.width,
|
| 602 |
+
split_result.left_zone.height,
|
| 603 |
+
)
|
| 604 |
+
if left_elements
|
| 605 |
+
else LayoutType.UNKNOWN
|
| 606 |
+
)
|
| 607 |
+
right_layout_type = (
|
| 608 |
+
detect_layout_type(
|
| 609 |
+
right_elements,
|
| 610 |
+
split_result.right_zone.width,
|
| 611 |
+
split_result.right_zone.height,
|
| 612 |
+
)
|
| 613 |
+
if right_elements
|
| 614 |
+
else LayoutType.UNKNOWN
|
| 615 |
+
)
|
| 616 |
+
sorted_left = _sort_recursive_by_layout(
|
| 617 |
+
split_result.left_zone, left_elements, left_layout_type, depth + 1
|
| 618 |
+
)
|
| 619 |
+
sorted_right = _sort_recursive_by_layout(
|
| 620 |
+
split_result.right_zone, right_elements, right_layout_type, depth + 1
|
| 621 |
+
)
|
| 622 |
+
logger.debug(f"{indent} <- Vertical 수직 분할 결과 병합")
|
| 623 |
+
return sorted_left + sorted_right
|
| 624 |
+
else:
|
| 625 |
+
logger.debug(
|
| 626 |
+
f"{indent} -> 모든 분할 실패, 레이아웃 유형({layout_type.name})에 따른 Base Case 실행"
|
| 627 |
+
)
|
| 628 |
+
result_groups: List[ElementGroup] = []
|
| 629 |
+
if layout_type == LayoutType.STANDARD_1_COLUMN:
|
| 630 |
+
result_groups = _base_case_standard_1_column(current_zone, elements_in_zone)
|
| 631 |
+
elif (
|
| 632 |
+
layout_type == LayoutType.MIXED_TOP1_BOTTOM2
|
| 633 |
+
or layout_type == LayoutType.MIXED_TOP2_BOTTOM1
|
| 634 |
+
):
|
| 635 |
+
result_groups = _base_case_mixed_layout(
|
| 636 |
+
current_zone, elements_in_zone, layout_type
|
| 637 |
+
)
|
| 638 |
+
elif (
|
| 639 |
+
layout_type == LayoutType.HORIZONTAL_SEP_PRESENT
|
| 640 |
+
or layout_type == LayoutType.UNKNOWN
|
| 641 |
+
):
|
| 642 |
+
logger.warning(
|
| 643 |
+
f"{indent} -> {layout_type.name} 유형 분할 실패. 1단 Base Case로 처리합니다."
|
| 644 |
+
)
|
| 645 |
+
result_groups = _base_case_standard_1_column(current_zone, elements_in_zone)
|
| 646 |
+
else:
|
| 647 |
+
logger.error(
|
| 648 |
+
f"{indent} -> 처리할 수 없는 Base Case 유형: {layout_type.name}. 1단으로 처리."
|
| 649 |
+
)
|
| 650 |
+
result_groups = _base_case_standard_1_column(current_zone, elements_in_zone)
|
| 651 |
+
|
| 652 |
+
logger.debug(f"{indent} <- Base Case 처리 완료: {len(result_groups)} 그룹 생성")
|
| 653 |
+
return result_groups
|
| 654 |
+
|
| 655 |
+
|
| 656 |
+
# ============================================================================
|
| 657 |
+
# 표준 2단 레이아웃 처리 함수 (기존과 동일)
|
| 658 |
+
# ============================================================================
|
| 659 |
+
def _sort_standard_2_column(
|
| 660 |
+
zone: Zone, elements: List[MockElement]
|
| 661 |
+
) -> List[ElementGroup]:
|
| 662 |
+
# ... (코드 동일) ...
|
| 663 |
+
"""표준 2단 레이아웃 처리: K-Means 분할 후 컬럼별 _base_case_standard_1_column 호출"""
|
| 664 |
+
logger.debug("표준 2단 처리: K-Means 분할 시도")
|
| 665 |
+
anchors = [e for e in elements if e.class_name in ALLOWED_ANCHORS]
|
| 666 |
+
vertical_split = find_vertical_split_kmeans(zone, anchors)
|
| 667 |
+
|
| 668 |
+
if vertical_split:
|
| 669 |
+
logger.debug(f" -> 수직 분할 성공! 분리선 X={vertical_split.gutter_x:.1f}")
|
| 670 |
+
left_elements = [
|
| 671 |
+
e
|
| 672 |
+
for e in elements
|
| 673 |
+
if (e.bbox_x + e.bbox_width / 2) < vertical_split.gutter_x
|
| 674 |
+
]
|
| 675 |
+
right_elements = [
|
| 676 |
+
e
|
| 677 |
+
for e in elements
|
| 678 |
+
if (e.bbox_x + e.bbox_width / 2) >= vertical_split.gutter_x
|
| 679 |
+
]
|
| 680 |
+
logger.debug(
|
| 681 |
+
f" Left 요소 수: {len(left_elements)}, Right 요소 수: {len(right_elements)}"
|
| 682 |
+
)
|
| 683 |
+
groups_left = _base_case_standard_1_column(
|
| 684 |
+
vertical_split.left_zone, left_elements
|
| 685 |
+
)
|
| 686 |
+
groups_right = _base_case_standard_1_column(
|
| 687 |
+
vertical_split.right_zone, right_elements
|
| 688 |
+
)
|
| 689 |
+
logger.debug(
|
| 690 |
+
f" <- 컬럼별 그룹핑 완료 (Left: {len(groups_left)} 그룹, Right: {len(groups_right)} 그룹)"
|
| 691 |
+
)
|
| 692 |
+
return groups_left + groups_right
|
| 693 |
+
else:
|
| 694 |
+
logger.warning(
|
| 695 |
+
"표준 2단 처리 실패: 수직 분할 불가. 전체 구역 표준 1단 Base Case 실행"
|
| 696 |
+
)
|
| 697 |
+
return _base_case_standard_1_column(zone, elements)
|
| 698 |
+
|
| 699 |
+
|
| 700 |
+
# ============================================================================
|
| 701 |
+
# 분할 함수 구현 (기존과 동일)
|
| 702 |
+
# ============================================================================
|
| 703 |
+
def find_wide_question_type(
|
| 704 |
+
elements: List[MockElement], page_width: int, top_y_limit: float
|
| 705 |
+
) -> Optional[MockElement]:
|
| 706 |
+
# ... (코드 동일) ...
|
| 707 |
+
"""페이지 상단 영역에서 넓은 question_type 찾기"""
|
| 708 |
+
wide_types = [
|
| 709 |
+
e
|
| 710 |
+
for e in elements
|
| 711 |
+
if e.class_name == "question_type"
|
| 712 |
+
and e.y_position < top_y_limit
|
| 713 |
+
and (e.bbox_width / page_width if page_width > 0 else 0)
|
| 714 |
+
>= HORIZONTAL_SEP_WIDTH_THRESHOLD
|
| 715 |
+
]
|
| 716 |
+
return min(wide_types, key=lambda e: e.y_position) if wide_types else None
|
| 717 |
+
|
| 718 |
+
|
| 719 |
+
def find_horizontal_split_by_type(
|
| 720 |
+
zone: Zone, elements: List[MockElement]
|
| 721 |
+
) -> Optional[HorizontalSplit]:
|
| 722 |
+
# ... (코드 동일) ...
|
| 723 |
+
"""넓은 question_type으로 수평 분할"""
|
| 724 |
+
potential_separators = []
|
| 725 |
+
for element in elements:
|
| 726 |
+
if element.class_name == "question_type":
|
| 727 |
+
width_ratio = element.bbox_width / zone.width if zone.width > 0 else 0
|
| 728 |
+
if width_ratio >= HORIZONTAL_SEP_WIDTH_THRESHOLD:
|
| 729 |
+
potential_separators.append(element)
|
| 730 |
+
if not potential_separators:
|
| 731 |
+
return None
|
| 732 |
+
separator = min(potential_separators, key=lambda e: e.y_position)
|
| 733 |
+
if not (zone.y_min < separator.y_position < zone.y_max):
|
| 734 |
+
return None
|
| 735 |
+
top_zone = Zone(zone.x_min, zone.y_min, zone.x_max, separator.y_position)
|
| 736 |
+
bottom_zone = Zone(
|
| 737 |
+
zone.x_min, separator.y_position + separator.bbox_height, zone.x_max, zone.y_max
|
| 738 |
+
)
|
| 739 |
+
if top_zone.height <= 0 or bottom_zone.height <= 0:
|
| 740 |
+
return None
|
| 741 |
+
return HorizontalSplit(top_zone, bottom_zone, separator)
|
| 742 |
+
|
| 743 |
+
|
| 744 |
+
def find_horizontal_split_by_y_gap(
|
| 745 |
+
zone: Zone, elements: List[MockElement]
|
| 746 |
+
) -> Optional[HorizontalSplitYGap]:
|
| 747 |
+
# ... (코드 동일) ...
|
| 748 |
+
"""앵커 Y Gap으로 수평 분할"""
|
| 749 |
+
anchors = sorted(
|
| 750 |
+
[e for e in elements if e.class_name in ALLOWED_ANCHORS],
|
| 751 |
+
key=lambda e: e.y_position,
|
| 752 |
+
)
|
| 753 |
+
if len(anchors) < MIN_ANCHORS_FOR_SPLIT:
|
| 754 |
+
return None
|
| 755 |
+
max_gap = -1
|
| 756 |
+
split_index = -1
|
| 757 |
+
avg_anchor_height = (
|
| 758 |
+
np.mean([a.bbox_height for a in anchors if a.bbox_height > 0])
|
| 759 |
+
if any(a.bbox_height > 0 for a in anchors)
|
| 760 |
+
else 30
|
| 761 |
+
)
|
| 762 |
+
for i in range(len(anchors) - 1):
|
| 763 |
+
gap = (anchors[i + 1].y_position + anchors[i + 1].bbox_height / 2) - (
|
| 764 |
+
anchors[i].y_position + anchors[i].bbox_height / 2
|
| 765 |
+
)
|
| 766 |
+
if gap > max_gap:
|
| 767 |
+
max_gap = gap
|
| 768 |
+
split_index = i
|
| 769 |
+
threshold = max(
|
| 770 |
+
avg_anchor_height * VERTICAL_GAP_THRESHOLD_RATIO, VERTICAL_GAP_THRESHOLD_ABS
|
| 771 |
+
)
|
| 772 |
+
if max_gap >= threshold:
|
| 773 |
+
split_y = (
|
| 774 |
+
anchors[split_index].y_position
|
| 775 |
+
+ anchors[split_index].bbox_height
|
| 776 |
+
+ anchors[split_index + 1].y_position
|
| 777 |
+
) / 2
|
| 778 |
+
if zone.y_min < split_y < zone.y_max:
|
| 779 |
+
top_zone = Zone(zone.x_min, zone.y_min, zone.x_max, int(split_y))
|
| 780 |
+
bottom_zone = Zone(zone.x_min, int(split_y), zone.x_max, zone.y_max)
|
| 781 |
+
logger.debug(
|
| 782 |
+
f" Y Gap 분석: 수평 분할 가능 (Max Gap={max_gap:.1f} >= Threshold={threshold:.1f})"
|
| 783 |
+
)
|
| 784 |
+
return HorizontalSplitYGap(top_zone, bottom_zone, split_y)
|
| 785 |
+
else:
|
| 786 |
+
logger.warning(
|
| 787 |
+
f" Y Gap 분석: 분할선({split_y:.1f})이 구역({zone.y_min}-{zone.y_max}) 밖에 위치. 분할 취소."
|
| 788 |
+
)
|
| 789 |
+
return None
|
| 790 |
+
else:
|
| 791 |
+
logger.debug(
|
| 792 |
+
f" Y Gap 분석: 최대 간격({max_gap:.1f}) 임계값({threshold:.1f}) 미만. 수평 분할 불가."
|
| 793 |
+
)
|
| 794 |
+
return None
|
| 795 |
+
|
| 796 |
+
|
| 797 |
+
def find_vertical_split_kmeans(
|
| 798 |
+
zone: Zone, anchors: List[MockElement]
|
| 799 |
+
) -> Optional[VerticalSplit]:
|
| 800 |
+
# ... (코드 동일) ...
|
| 801 |
+
"""앵커 X 좌표 K-Means로 수직 분할"""
|
| 802 |
+
if len(anchors) < MIN_ANCHORS_FOR_SPLIT:
|
| 803 |
+
return None
|
| 804 |
+
anchor_x_centers = np.array([[a.bbox_x + a.bbox_width / 2] for a in anchors])
|
| 805 |
+
if len(np.unique(anchor_x_centers)) < 2:
|
| 806 |
+
return None
|
| 807 |
+
try:
|
| 808 |
+
kmeans = KMeans(n_clusters=KMEANS_N_CLUSTERS, random_state=42, n_init="auto")
|
| 809 |
+
kmeans.fit(anchor_x_centers)
|
| 810 |
+
centers = sorted(kmeans.cluster_centers_.flatten())
|
| 811 |
+
if (
|
| 812 |
+
len(centers) == 2
|
| 813 |
+
and centers[1] - centers[0] >= KMEANS_CLUSTER_SEPARATION_MIN
|
| 814 |
+
):
|
| 815 |
+
gutter_x = (centers[0] + centers[1]) / 2
|
| 816 |
+
if zone.x_min < gutter_x < zone.x_max:
|
| 817 |
+
left_zone = Zone(zone.x_min, zone.y_min, int(gutter_x), zone.y_max)
|
| 818 |
+
right_zone = Zone(int(gutter_x), zone.y_min, zone.x_max, zone.y_max)
|
| 819 |
+
return VerticalSplit(left_zone, right_zone, gutter_x)
|
| 820 |
+
else:
|
| 821 |
+
logger.warning(
|
| 822 |
+
f" 수직 분할 K-Means: 분할선({gutter_x:.1f})이 구역({zone.x_min}-{zone.x_max}) 밖에 위치. 분할 취소."
|
| 823 |
+
)
|
| 824 |
+
return None
|
| 825 |
+
else:
|
| 826 |
+
logger.debug(
|
| 827 |
+
f" 수직 분할 K-Means 실패: 중심간 거리({(centers[1] - centers[0]) if len(centers)==2 else 0:.1f}px) 임계값 미만"
|
| 828 |
+
)
|
| 829 |
+
return None
|
| 830 |
+
except Exception as e:
|
| 831 |
+
logger.error(f" 수직 분할 K-Means 중 오류: {e}")
|
| 832 |
+
return None
|
| 833 |
+
|
| 834 |
+
|
| 835 |
+
# ============================================================================
|
| 836 |
+
# 후처리 함수 (수정됨)
|
| 837 |
+
# ============================================================================
|
| 838 |
+
def _post_process_table_figure_assignment(
|
| 839 |
+
groups: List[ElementGroup], y_diff_threshold: int = 150
|
| 840 |
+
) -> List[ElementGroup]:
|
| 841 |
+
"""
|
| 842 |
+
그룹핑 후처리: 테이블/그림 요소가 현재 앵커보다 다음 앵커(들)에 훨씬 가까우면 이동 시도
|
| 843 |
+
--- 수정: 최적 그룹 탐색 및 Tie-breaker 추가 ---
|
| 844 |
+
"""
|
| 845 |
+
logger.debug(
|
| 846 |
+
f" 테이블/그림 할당 후처리 시작: {len(groups)}개 그룹 (Threshold={y_diff_threshold}px, Closeness Ratio={POST_PROCESS_CLOSENESS_RATIO}, Lookahead={POST_PROCESS_LOOKAHEAD})"
|
| 847 |
+
)
|
| 848 |
+
adjusted_groups = groups # 원본 리스트를 직접 수정
|
| 849 |
+
elements_to_move_dict: Dict[int, Tuple[MockElement, int]] = (
|
| 850 |
+
{}
|
| 851 |
+
) # {element_id: (element, target_group_idx)}
|
| 852 |
+
moved_elements_log = [] # 로깅용
|
| 853 |
+
|
| 854 |
+
for i in range(len(adjusted_groups)):
|
| 855 |
+
current_group = adjusted_groups[i]
|
| 856 |
+
if not current_group.anchor:
|
| 857 |
+
continue
|
| 858 |
+
|
| 859 |
+
current_children_copy = list(
|
| 860 |
+
current_group.children
|
| 861 |
+
) # 순회 중 변경을 위한 복사본
|
| 862 |
+
|
| 863 |
+
for child_idx, child in enumerate(current_children_copy):
|
| 864 |
+
# 이미 이동 대상으로 결정된 요소는 건너뜀
|
| 865 |
+
if child.element_id in elements_to_move_dict:
|
| 866 |
+
continue
|
| 867 |
+
|
| 868 |
+
if child.class_name in ["table", "figure", "flowchart"]:
|
| 869 |
+
y_diff_current = child.y_position - current_group.anchor.y_position
|
| 870 |
+
|
| 871 |
+
best_target_group_idx = -1
|
| 872 |
+
min_y_diff_next = float("inf")
|
| 873 |
+
|
| 874 |
+
# 현재 그룹 이후 몇 개 그룹까지 탐색
|
| 875 |
+
for lookahead_idx in range(1, POST_PROCESS_LOOKAHEAD + 1):
|
| 876 |
+
next_group_idx = i + lookahead_idx
|
| 877 |
+
if next_group_idx >= len(adjusted_groups):
|
| 878 |
+
break
|
| 879 |
+
|
| 880 |
+
next_group = adjusted_groups[next_group_idx]
|
| 881 |
+
if not next_group.anchor:
|
| 882 |
+
continue
|
| 883 |
+
|
| 884 |
+
y_diff_next = abs(child.y_position - next_group.anchor.y_position)
|
| 885 |
+
|
| 886 |
+
# 이동 조건 검사 (v2.2 조건)
|
| 887 |
+
if y_diff_current > (y_diff_threshold / 2) and y_diff_next < (
|
| 888 |
+
y_diff_current * POST_PROCESS_CLOSENESS_RATIO
|
| 889 |
+
):
|
| 890 |
+
# --- 👇 Tie-breaker 수정 👇 ---
|
| 891 |
+
# 더 가까운 그룹을 찾거나, 거리가 같지만 더 뒤의 그룹일 경우 갱신
|
| 892 |
+
if y_diff_next < min_y_diff_next or (
|
| 893 |
+
y_diff_next == min_y_diff_next
|
| 894 |
+
and next_group_idx > best_target_group_idx
|
| 895 |
+
):
|
| 896 |
+
min_y_diff_next = y_diff_next
|
| 897 |
+
best_target_group_idx = next_group_idx
|
| 898 |
+
# --- 👆 Tie-breaker 수정 끝 👆 ---
|
| 899 |
+
|
| 900 |
+
# 최적 그룹을 찾았으면 이동 대상으로 등록
|
| 901 |
+
if best_target_group_idx != -1:
|
| 902 |
+
elements_to_move_dict[child.element_id] = (
|
| 903 |
+
child,
|
| 904 |
+
best_target_group_idx,
|
| 905 |
+
)
|
| 906 |
+
moved_elements_log.append(
|
| 907 |
+
f"Elem {child.element_id} ({child.class_name}) from Grp {current_group.group_id} to Grp {adjusted_groups[best_target_group_idx].group_id}"
|
| 908 |
+
)
|
| 909 |
+
logger.trace(
|
| 910 |
+
f" 이동 후보 확정: Elem {child.element_id} -> Group {adjusted_groups[best_target_group_idx].group_id} (Min Y diff next={min_y_diff_next:.0f})"
|
| 911 |
+
)
|
| 912 |
+
|
| 913 |
+
# --- 실제 요소 이동 (루프 종료 후) ---
|
| 914 |
+
if elements_to_move_dict:
|
| 915 |
+
# 1. 원본 그룹에서 요소 제거
|
| 916 |
+
elements_removed_count = 0
|
| 917 |
+
for group in adjusted_groups:
|
| 918 |
+
original_children_count = len(group.children)
|
| 919 |
+
group.children = [
|
| 920 |
+
child
|
| 921 |
+
for child in group.children
|
| 922 |
+
if child.element_id not in elements_to_move_dict
|
| 923 |
+
]
|
| 924 |
+
elements_removed_count += original_children_count - len(group.children)
|
| 925 |
+
|
| 926 |
+
# 2. 대상 그룹에 요소 추가
|
| 927 |
+
elements_added_count = 0
|
| 928 |
+
for element_id, (element, target_group_idx) in elements_to_move_dict.items():
|
| 929 |
+
if 0 <= target_group_idx < len(adjusted_groups):
|
| 930 |
+
adjusted_groups[target_group_idx].children.insert(
|
| 931 |
+
0, element
|
| 932 |
+
) # 그룹 맨 앞에 추가
|
| 933 |
+
elements_added_count += 1
|
| 934 |
+
else:
|
| 935 |
+
logger.error(
|
| 936 |
+
f"후처리 이동 중 유효하지 않은 대상 그룹 인덱스: {target_group_idx} for Elem {element_id}"
|
| 937 |
+
)
|
| 938 |
+
|
| 939 |
+
logger.debug(
|
| 940 |
+
f" 후처리 요소 이동 완료: {elements_removed_count}개 제거, {elements_added_count}개 추가"
|
| 941 |
+
)
|
| 942 |
+
|
| 943 |
+
if moved_elements_log:
|
| 944 |
+
logger.info(
|
| 945 |
+
f" 테이블/그림 할당 후처리: {len(moved_elements_log)}개 요소 이동됨 - {', '.join(moved_elements_log)}"
|
| 946 |
+
)
|
| 947 |
+
else:
|
| 948 |
+
logger.debug(" 테이블/그림 할당 후처리: 이동된 요소 없음")
|
| 949 |
+
|
| 950 |
+
return adjusted_groups
|
| 951 |
+
|
| 952 |
+
|
| 953 |
+
# ============================================================================
|
| 954 |
+
# Base Case 함수들 (기존과 동일 v2.1)
|
| 955 |
+
# ============================================================================
|
| 956 |
+
def _base_case_standard_1_column(
|
| 957 |
+
zone: Zone, elements: List[MockElement]
|
| 958 |
+
) -> List[ElementGroup]:
|
| 959 |
+
# ... (v2.1 코드와 동일) ...
|
| 960 |
+
"""표준 1단 구역 Base Case 처리 (상단 고아 분리)"""
|
| 961 |
+
logger.debug(
|
| 962 |
+
f" 표준 1단 Base Case 시작 (순차 처리 + 고아 개선): {len(elements)}개 요소 in {zone}"
|
| 963 |
+
)
|
| 964 |
+
anchors = sorted(
|
| 965 |
+
[e for e in elements if e.class_name in ALLOWED_ANCHORS],
|
| 966 |
+
key=lambda e: e.y_position,
|
| 967 |
+
)
|
| 968 |
+
children = [e for e in elements if e.class_name in ALLOWED_CHILDREN]
|
| 969 |
+
groups: Dict[int, ElementGroup] = {
|
| 970 |
+
anchor.element_id: ElementGroup(anchor=anchor) for anchor in anchors
|
| 971 |
+
}
|
| 972 |
+
assigned_children_ids = set()
|
| 973 |
+
logger.trace(" 수평 인접 처리 시작...")
|
| 974 |
+
|
| 975 |
+
if anchors and children:
|
| 976 |
+
for anchor in anchors:
|
| 977 |
+
anchor_cy = anchor.bbox_y + anchor.bbox_height / 2
|
| 978 |
+
anchor_right_x = anchor.bbox_x + anchor.bbox_width
|
| 979 |
+
anchor_left_x = anchor.bbox_x
|
| 980 |
+
unassigned_children = [
|
| 981 |
+
c for c in children if c.element_id not in assigned_children_ids
|
| 982 |
+
]
|
| 983 |
+
adjacent_child = None
|
| 984 |
+
min_y_diff = float("inf")
|
| 985 |
+
for child in unassigned_children:
|
| 986 |
+
child_cy = child.bbox_y + child.bbox_height / 2
|
| 987 |
+
child_right_x = child.bbox_x + child.bbox_width
|
| 988 |
+
child_left_x = child.bbox_x
|
| 989 |
+
y_diff = abs(anchor_cy - child_cy)
|
| 990 |
+
y_threshold = (
|
| 991 |
+
(anchor.bbox_height + child.bbox_height)
|
| 992 |
+
/ 2
|
| 993 |
+
* HORIZONTAL_ADJACENCY_Y_CENTER_RATIO
|
| 994 |
+
if (anchor.bbox_height + child.bbox_height) > 0
|
| 995 |
+
else 0
|
| 996 |
+
)
|
| 997 |
+
if y_diff >= y_threshold:
|
| 998 |
+
continue
|
| 999 |
+
gap_right = child_left_x - anchor_right_x
|
| 1000 |
+
gap_left = anchor_left_x - child_right_x
|
| 1001 |
+
is_adjacent = (abs(gap_right) < HORIZONTAL_ADJACENCY_X_PROXIMITY) or (
|
| 1002 |
+
abs(gap_left) < HORIZONTAL_ADJACENCY_X_PROXIMITY
|
| 1003 |
+
)
|
| 1004 |
+
if is_adjacent and y_diff < min_y_diff:
|
| 1005 |
+
min_y_diff = y_diff
|
| 1006 |
+
adjacent_child = child
|
| 1007 |
+
if adjacent_child:
|
| 1008 |
+
logger.trace(
|
| 1009 |
+
f" 수평 인접 배정: 앵커 ID {anchor.element_id} <- 자식 ID {adjacent_child.element_id}"
|
| 1010 |
+
)
|
| 1011 |
+
groups[anchor.element_id].add_child(adjacent_child)
|
| 1012 |
+
assigned_children_ids.add(adjacent_child.element_id)
|
| 1013 |
+
logger.debug(
|
| 1014 |
+
f" 수평 인접 처리 완료: {len(assigned_children_ids)}개 자식 우선 배정됨"
|
| 1015 |
+
)
|
| 1016 |
+
|
| 1017 |
+
remaining_elements = anchors + [
|
| 1018 |
+
c for c in children if c.element_id not in assigned_children_ids
|
| 1019 |
+
]
|
| 1020 |
+
if not remaining_elements:
|
| 1021 |
+
logger.debug(" 모든 요소가 수평 인접으로 배정되어 그룹핑 완료.")
|
| 1022 |
+
# 후처리 호출 전 그룹 ID 임시 할당 (선택적)
|
| 1023 |
+
temp_groups = sorted(
|
| 1024 |
+
list(groups.values()),
|
| 1025 |
+
key=lambda g: g.anchor.y_position if g.anchor else float("inf"),
|
| 1026 |
+
)
|
| 1027 |
+
for idx, group in enumerate(temp_groups):
|
| 1028 |
+
group.group_id = idx
|
| 1029 |
+
return _post_process_table_figure_assignment(temp_groups)
|
| 1030 |
+
|
| 1031 |
+
logger.trace(
|
| 1032 |
+
f" 나머지 요소 {len(remaining_elements)}개 (Y, X) 정렬 및 순차 그룹핑 시작..."
|
| 1033 |
+
)
|
| 1034 |
+
remaining_elements.sort(key=lambda e: (e.y_position, e.x_position))
|
| 1035 |
+
|
| 1036 |
+
final_groups: List[ElementGroup] = []
|
| 1037 |
+
current_group: Optional[ElementGroup] = None
|
| 1038 |
+
initial_top_orphan_children: List[MockElement] = []
|
| 1039 |
+
initial_bottom_orphan_children: List[MockElement] = []
|
| 1040 |
+
first_anchor_found = False
|
| 1041 |
+
top_orphan_threshold_y = (
|
| 1042 |
+
zone.y_min + zone.height * BASE_CASE_TOP_ORPHAN_THRESHOLD_RATIO
|
| 1043 |
+
)
|
| 1044 |
+
|
| 1045 |
+
for element in remaining_elements:
|
| 1046 |
+
if element.class_name in ALLOWED_ANCHORS:
|
| 1047 |
+
first_anchor_found = True
|
| 1048 |
+
if initial_top_orphan_children:
|
| 1049 |
+
logger.trace(
|
| 1050 |
+
f" 독립적인 상단 고아 그룹 생성 ({len(initial_top_orphan_children)}개 요소)"
|
| 1051 |
+
)
|
| 1052 |
+
final_groups.append(
|
| 1053 |
+
ElementGroup(anchor=None, children=initial_top_orphan_children)
|
| 1054 |
+
)
|
| 1055 |
+
initial_top_orphan_children = []
|
| 1056 |
+
if (
|
| 1057 |
+
current_group is not None
|
| 1058 |
+
and current_group.anchor is not None
|
| 1059 |
+
and not current_group.is_empty()
|
| 1060 |
+
):
|
| 1061 |
+
final_groups.append(current_group)
|
| 1062 |
+
if element.element_id in groups:
|
| 1063 |
+
current_group = groups[element.element_id]
|
| 1064 |
+
logger.trace(f" 앵커 그룹 재사용 (ID: {element.element_id})")
|
| 1065 |
+
else:
|
| 1066 |
+
current_group = ElementGroup(anchor=element, children=[])
|
| 1067 |
+
logger.trace(f" 새 앵커 그룹 시작 (ID: {element.element_id})")
|
| 1068 |
+
if initial_bottom_orphan_children:
|
| 1069 |
+
logger.trace(
|
| 1070 |
+
f" 첫 앵커(ID: {element.element_id}) 그룹에 하단 고아 자식 {len(initial_bottom_orphan_children)}개 추가"
|
| 1071 |
+
)
|
| 1072 |
+
current_group.children = (
|
| 1073 |
+
initial_bottom_orphan_children + current_group.children
|
| 1074 |
+
)
|
| 1075 |
+
initial_bottom_orphan_children = []
|
| 1076 |
+
else:
|
| 1077 |
+
if first_anchor_found:
|
| 1078 |
+
if current_group is None:
|
| 1079 |
+
logger.warning(
|
| 1080 |
+
f" 앵커 없이 자식 요소(ID: {element.element_id}) 발견됨. 위치({element.y_position:.1f}) ��라 임시 고아 리스트에 추가."
|
| 1081 |
+
)
|
| 1082 |
+
if element.y_position < top_orphan_threshold_y:
|
| 1083 |
+
initial_top_orphan_children.append(element)
|
| 1084 |
+
else:
|
| 1085 |
+
initial_bottom_orphan_children.append(element)
|
| 1086 |
+
else:
|
| 1087 |
+
current_group.add_child(element)
|
| 1088 |
+
logger.trace(
|
| 1089 |
+
f" 현재 그룹(앵커: {current_group.anchor.element_id if current_group.anchor else 'Orphan'})에 자식 추가 (ID: {element.element_id})"
|
| 1090 |
+
)
|
| 1091 |
+
else:
|
| 1092 |
+
if element.y_position < top_orphan_threshold_y:
|
| 1093 |
+
initial_top_orphan_children.append(element)
|
| 1094 |
+
logger.trace(
|
| 1095 |
+
f" 상단 고아 자식 요소(ID: {element.element_id}) 임시 저장 (Y < {top_orphan_threshold_y:.0f})"
|
| 1096 |
+
)
|
| 1097 |
+
else:
|
| 1098 |
+
initial_bottom_orphan_children.append(element)
|
| 1099 |
+
logger.trace(
|
| 1100 |
+
f" 하단 고아 자식 요소(ID: {element.element_id}) 임시 저장 (Y >= {top_orphan_threshold_y:.0f})"
|
| 1101 |
+
)
|
| 1102 |
+
|
| 1103 |
+
if initial_top_orphan_children:
|
| 1104 |
+
logger.trace(
|
| 1105 |
+
f" 마지막 독립 상단 고아 그룹 생성 ({len(initial_top_orphan_children)}개 요소)"
|
| 1106 |
+
)
|
| 1107 |
+
final_groups.append(
|
| 1108 |
+
ElementGroup(anchor=None, children=initial_top_orphan_children)
|
| 1109 |
+
)
|
| 1110 |
+
if current_group is not None and not current_group.is_empty():
|
| 1111 |
+
final_groups.append(current_group)
|
| 1112 |
+
elif initial_bottom_orphan_children:
|
| 1113 |
+
logger.warning(" 모든 요소가 하단 자식 요소임. 단일 고아 그룹 생성.")
|
| 1114 |
+
final_groups.append(
|
| 1115 |
+
ElementGroup(anchor=None, children=initial_bottom_orphan_children)
|
| 1116 |
+
)
|
| 1117 |
+
|
| 1118 |
+
processed_anchor_ids = set(g.anchor.element_id for g in final_groups if g.anchor)
|
| 1119 |
+
for anchor_id, group in groups.items():
|
| 1120 |
+
if anchor_id not in processed_anchor_ids and group.anchor:
|
| 1121 |
+
final_groups.append(group)
|
| 1122 |
+
logger.trace(f" 미포함 앵커 그룹 추가 (수평 인접만): ID {anchor_id}")
|
| 1123 |
+
|
| 1124 |
+
final_groups.sort(
|
| 1125 |
+
key=lambda g: (
|
| 1126 |
+
g.anchor.y_position
|
| 1127 |
+
if g.anchor
|
| 1128 |
+
else (min(c.y_position for c in g.children) if g.children else float("inf"))
|
| 1129 |
+
)
|
| 1130 |
+
)
|
| 1131 |
+
|
| 1132 |
+
# 후처리 호출 전 그룹 ID 임시 할당
|
| 1133 |
+
for idx, group in enumerate(final_groups):
|
| 1134 |
+
group.group_id = idx
|
| 1135 |
+
final_groups = _post_process_table_figure_assignment(final_groups)
|
| 1136 |
+
|
| 1137 |
+
logger.debug(
|
| 1138 |
+
f" 순차 처리 기반 그룹핑 (+후처리) 완료: {len(final_groups)} 그룹 생성"
|
| 1139 |
+
)
|
| 1140 |
+
return final_groups
|
| 1141 |
+
|
| 1142 |
+
|
| 1143 |
+
def _base_case_mixed_layout(
|
| 1144 |
+
zone: Zone, elements: List[MockElement], layout_type: LayoutType
|
| 1145 |
+
) -> List[ElementGroup]:
|
| 1146 |
+
"""혼합형 레이아웃 Base Case 처리 (기존과 동일)"""
|
| 1147 |
+
# ... (v2.1 코드와 동일) ...
|
| 1148 |
+
logger.debug(
|
| 1149 |
+
f" 혼합형 Base Case 시작 ({layout_type.name}): {len(elements)}개 요소 in {zone}"
|
| 1150 |
+
)
|
| 1151 |
+
sorted_elements = sorted(elements, key=lambda e: (e.y_position, e.x_position))
|
| 1152 |
+
final_groups: List[ElementGroup] = []
|
| 1153 |
+
current_group: Optional[ElementGroup] = None
|
| 1154 |
+
initial_top_orphan_children: List[MockElement] = []
|
| 1155 |
+
initial_bottom_orphan_children: List[MockElement] = []
|
| 1156 |
+
first_anchor_found = False
|
| 1157 |
+
split_y = zone.y_min + zone.height * LAYOUT_DETECT_Y_SPLIT_POINT
|
| 1158 |
+
logger.trace(f" 혼합형 Base Case Y 분할점: {split_y:.1f}")
|
| 1159 |
+
|
| 1160 |
+
for element in sorted_elements:
|
| 1161 |
+
element_y_center = element.y_position + element.bbox_height / 2
|
| 1162 |
+
if element.class_name in ALLOWED_ANCHORS:
|
| 1163 |
+
first_anchor_found = True
|
| 1164 |
+
if initial_top_orphan_children:
|
| 1165 |
+
logger.trace(
|
| 1166 |
+
f" 독립적인 상단 고아 그룹 생성 ({len(initial_top_orphan_children)}개 요소)"
|
| 1167 |
+
)
|
| 1168 |
+
final_groups.append(
|
| 1169 |
+
ElementGroup(anchor=None, children=initial_top_orphan_children)
|
| 1170 |
+
)
|
| 1171 |
+
initial_top_orphan_children = []
|
| 1172 |
+
if current_group is not None and not current_group.is_empty():
|
| 1173 |
+
final_groups.append(current_group)
|
| 1174 |
+
current_group = ElementGroup(anchor=element, children=[])
|
| 1175 |
+
logger.trace(f" 새 앵커 그룹 시작 (ID: {element.element_id})")
|
| 1176 |
+
if initial_bottom_orphan_children:
|
| 1177 |
+
logger.trace(
|
| 1178 |
+
f" 첫 앵커(ID: {element.element_id}) 그룹에 하단 고아 자식 {len(initial_bottom_orphan_children)}개 추가"
|
| 1179 |
+
)
|
| 1180 |
+
current_group.children = (
|
| 1181 |
+
initial_bottom_orphan_children + current_group.children
|
| 1182 |
+
)
|
| 1183 |
+
initial_bottom_orphan_children = []
|
| 1184 |
+
else:
|
| 1185 |
+
if first_anchor_found:
|
| 1186 |
+
if current_group is None:
|
| 1187 |
+
logger.warning(
|
| 1188 |
+
f" 앵커 없이 자식 요소(ID: {element.element_id}) 발견됨. 위치({element_y_center:.1f}) 따라 임시 고아 리스트에 추가."
|
| 1189 |
+
)
|
| 1190 |
+
if element_y_center < split_y:
|
| 1191 |
+
initial_top_orphan_children.append(element)
|
| 1192 |
+
else:
|
| 1193 |
+
initial_bottom_orphan_children.append(element)
|
| 1194 |
+
else:
|
| 1195 |
+
current_group.add_child(element)
|
| 1196 |
+
logger.trace(
|
| 1197 |
+
f" 현재 그룹(앵커: {current_group.anchor.element_id if current_group.anchor else 'Orphan'})에 자식 추가 (ID: {element.element_id})"
|
| 1198 |
+
)
|
| 1199 |
+
else:
|
| 1200 |
+
if element_y_center < split_y:
|
| 1201 |
+
initial_top_orphan_children.append(element)
|
| 1202 |
+
logger.trace(
|
| 1203 |
+
f" 상단 고아 자식 요소(ID: {element.element_id}) 임시 저장"
|
| 1204 |
+
)
|
| 1205 |
+
else:
|
| 1206 |
+
initial_bottom_orphan_children.append(element)
|
| 1207 |
+
logger.trace(
|
| 1208 |
+
f" 하단 고아 자식 요소(ID: {element.element_id}) 임시 저장"
|
| 1209 |
+
)
|
| 1210 |
+
|
| 1211 |
+
if initial_top_orphan_children:
|
| 1212 |
+
logger.trace(
|
| 1213 |
+
f" 마지막 독립 상단 고아 그룹 생성 ({len(initial_top_orphan_children)}개 요소)"
|
| 1214 |
+
)
|
| 1215 |
+
final_groups.append(
|
| 1216 |
+
ElementGroup(anchor=None, children=initial_top_orphan_children)
|
| 1217 |
+
)
|
| 1218 |
+
if current_group is not None and not current_group.is_empty():
|
| 1219 |
+
final_groups.append(current_group)
|
| 1220 |
+
elif initial_bottom_orphan_children:
|
| 1221 |
+
logger.warning(" 모든 요소가 하단 자식 요소임. 단일 고아 그룹 생성.")
|
| 1222 |
+
final_groups.append(
|
| 1223 |
+
ElementGroup(anchor=None, children=initial_bottom_orphan_children)
|
| 1224 |
+
)
|
| 1225 |
+
|
| 1226 |
+
# 후처리 호출 전 그룹 ID 임시 할당
|
| 1227 |
+
for idx, group in enumerate(final_groups):
|
| 1228 |
+
group.group_id = idx
|
| 1229 |
+
final_groups = _post_process_table_figure_assignment(final_groups)
|
| 1230 |
+
|
| 1231 |
+
return final_groups
|
| 1232 |
+
|
| 1233 |
+
|
| 1234 |
+
# ============================================================================
|
| 1235 |
+
# 최종 병합 및 순서 부여 함수 (기존과 동일)
|
| 1236 |
+
# ============================================================================
|
| 1237 |
+
def flatten_groups_and_assign_order(
|
| 1238 |
+
groups: List[ElementGroup], start_global_order: int, start_group_id: int
|
| 1239 |
+
) -> Tuple[List[MockElement], int, int]:
|
| 1240 |
+
# ... (코드 동일) ...
|
| 1241 |
+
"""주어진 그룹 리스트를 평탄화하고 전역 순서/그룹 ID 부여"""
|
| 1242 |
+
flattened = []
|
| 1243 |
+
global_order = start_global_order
|
| 1244 |
+
group_id_counter = start_group_id
|
| 1245 |
+
logger.debug(
|
| 1246 |
+
f" 평탄화 시작: {len(groups)}개 그룹 (시작 order={global_order}, group_id={group_id_counter})"
|
| 1247 |
+
)
|
| 1248 |
+
for group in groups: # 최종 정렬된 그룹 순서 사용
|
| 1249 |
+
# 그룹 객체의 ID는 임시 ID일 수 있으므로 여기서 최종 ID 할당
|
| 1250 |
+
final_group_id = group_id_counter
|
| 1251 |
+
group.group_id = final_group_id # 로깅 및 참조용 업데이트
|
| 1252 |
+
|
| 1253 |
+
elements_in_group = group.get_all_elements_sorted()
|
| 1254 |
+
logger.trace(
|
| 1255 |
+
f" 그룹 {final_group_id} 평탄화 (Anchor: {group.anchor.element_id if group.anchor else 'Orphan'}, 요소 수: {len(elements_in_group)})"
|
| 1256 |
+
)
|
| 1257 |
+
for local_order, element in enumerate(elements_in_group):
|
| 1258 |
+
try:
|
| 1259 |
+
setattr(element, "order_in_question", global_order)
|
| 1260 |
+
setattr(element, "group_id", final_group_id) # 최종 그룹 ID 사용
|
| 1261 |
+
setattr(element, "order_in_group", local_order)
|
| 1262 |
+
flattened.append(element)
|
| 1263 |
+
global_order += 1
|
| 1264 |
+
except AttributeError as e:
|
| 1265 |
+
logger.error(
|
| 1266 |
+
f"요소 (ID: {getattr(element, 'element_id', 'N/A')})에 정렬 속성 추가 실패: {e}"
|
| 1267 |
+
)
|
| 1268 |
+
group_id_counter += 1
|
| 1269 |
+
logger.debug(
|
| 1270 |
+
f" 평탄화 완료: {len(flattened)}개 요소 생성 (다음 order={global_order}, group_id={group_id_counter})"
|
| 1271 |
+
)
|
| 1272 |
+
return flattened, global_order, group_id_counter
|
| 1273 |
+
|
| 1274 |
+
|
| 1275 |
+
# ============================================================================
|
| 1276 |
+
# 헬퍼 함수 (기존과 동일)
|
| 1277 |
+
# ============================================================================
|
| 1278 |
+
def preprocess_elements(
|
| 1279 |
+
elements: List[MockElement], document_type: str
|
| 1280 |
+
) -> List[MockElement]:
|
| 1281 |
+
# ... (코드 동일) ...
|
| 1282 |
+
"""0단계 전처리"""
|
| 1283 |
+
original_count = len(elements)
|
| 1284 |
+
if document_type == "question_based":
|
| 1285 |
+
filtered = [e for e in elements if e.class_name in ALLOWED_CLASSES]
|
| 1286 |
+
logger.info(
|
| 1287 |
+
f"전처리 (question_based): {original_count}개 → {len(filtered)}개 (허용 클래스 필터링)"
|
| 1288 |
+
)
|
| 1289 |
+
elif document_type == "reading_order":
|
| 1290 |
+
filtered = elements
|
| 1291 |
+
logger.info(f"전처리 (reading_order): {original_count}개 (모든 클래스 허용)")
|
| 1292 |
+
else:
|
| 1293 |
+
logger.warning(f"알 수 없는 문서 타입 '{document_type}', 모든 요소 반환")
|
| 1294 |
+
filtered = elements
|
| 1295 |
+
valid_elements = [e for e in filtered if hasattr(e, "area") and e.area > 0]
|
| 1296 |
+
if len(valid_elements) < len(filtered):
|
| 1297 |
+
logger.warning(
|
| 1298 |
+
f"전처리: 면적이 0 이하인 요소 {len(filtered) - len(valid_elements)}개 제거"
|
| 1299 |
+
)
|
| 1300 |
+
return valid_elements
|
| 1301 |
+
|
| 1302 |
+
|
| 1303 |
+
def calculate_page_width(elements: List[MockElement]) -> int:
|
| 1304 |
+
# ... (코드 동일) ...
|
| 1305 |
+
"""페이지 너비 추정"""
|
| 1306 |
+
if not elements:
|
| 1307 |
+
return 0
|
| 1308 |
+
return max(e.bbox_x + e.bbox_width for e in elements) if elements else 0
|
| 1309 |
+
|
| 1310 |
+
|
| 1311 |
+
def calculate_page_height(elements: List[MockElement]) -> int:
|
| 1312 |
+
# ... (코드 동일) ...
|
| 1313 |
+
"""페이지 높이 추정"""
|
| 1314 |
+
if not elements:
|
| 1315 |
+
return 0
|
| 1316 |
+
return max(e.bbox_y + e.bbox_height for e in elements) if elements else 0
|
app/services/text_version_service.py
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
텍스트 버전 관리 서비스
|
| 3 |
+
=======================
|
| 4 |
+
|
| 5 |
+
Project/의 Mock 기반 텍스트 버전 로직을 Backend/ ORM 환경에 맞게 재구성한 모듈입니다.
|
| 6 |
+
페이지별 텍스트 버전을 조회·생성·갱신하며, `batch_analysis` 및 FastAPI 라우터에서 재사용합니다.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
from typing import Any, Dict, Optional
|
| 12 |
+
|
| 13 |
+
from loguru import logger
|
| 14 |
+
from sqlalchemy.orm import Session
|
| 15 |
+
|
| 16 |
+
from ..models import CombinedResult, Page, TextVersion
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def _deactivate_existing_versions(db: Session, page_id: int) -> None:
|
| 20 |
+
"""
|
| 21 |
+
지정한 페이지의 기존 text_versions 중 is_current=True 항목을 False로 설정합니다.
|
| 22 |
+
"""
|
| 23 |
+
updated = (
|
| 24 |
+
db.query(TextVersion)
|
| 25 |
+
.filter(
|
| 26 |
+
TextVersion.page_id == page_id,
|
| 27 |
+
TextVersion.is_current.is_(True),
|
| 28 |
+
)
|
| 29 |
+
.update({"is_current": False}, synchronize_session=False)
|
| 30 |
+
)
|
| 31 |
+
if updated:
|
| 32 |
+
logger.debug("페이지 %s의 기존 텍스트 버전 %s건을 비활성화했습니다.", page_id, updated)
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def _get_next_version_number(db: Session, page_id: int) -> int:
|
| 36 |
+
"""
|
| 37 |
+
다음 버전 번호를 계산합니다.
|
| 38 |
+
"""
|
| 39 |
+
latest_number = (
|
| 40 |
+
db.query(TextVersion.version_number)
|
| 41 |
+
.filter(TextVersion.page_id == page_id)
|
| 42 |
+
.order_by(TextVersion.version_number.desc())
|
| 43 |
+
.limit(1)
|
| 44 |
+
.scalar()
|
| 45 |
+
)
|
| 46 |
+
return (latest_number or 0) + 1
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def create_text_version(
|
| 50 |
+
db: Session,
|
| 51 |
+
page: Page,
|
| 52 |
+
content: str,
|
| 53 |
+
*,
|
| 54 |
+
version_type: str = "auto_formatted",
|
| 55 |
+
user_id: Optional[int] = None,
|
| 56 |
+
commit: bool = False,
|
| 57 |
+
) -> TextVersion:
|
| 58 |
+
"""
|
| 59 |
+
텍스트 버전을 생성하고 is_current 플래그를 관리합니다.
|
| 60 |
+
"""
|
| 61 |
+
_deactivate_existing_versions(db, page.page_id)
|
| 62 |
+
next_number = _get_next_version_number(db, page.page_id)
|
| 63 |
+
|
| 64 |
+
version = TextVersion(
|
| 65 |
+
page_id=page.page_id,
|
| 66 |
+
user_id=user_id,
|
| 67 |
+
content=content,
|
| 68 |
+
version_number=next_number,
|
| 69 |
+
version_type=version_type,
|
| 70 |
+
is_current=True,
|
| 71 |
+
)
|
| 72 |
+
db.add(version)
|
| 73 |
+
db.flush()
|
| 74 |
+
db.refresh(version)
|
| 75 |
+
|
| 76 |
+
if commit:
|
| 77 |
+
db.commit()
|
| 78 |
+
|
| 79 |
+
logger.info(
|
| 80 |
+
"텍스트 버전 생성 완료: page_id=%s, version_id=%s, number=%s, type=%s",
|
| 81 |
+
page.page_id,
|
| 82 |
+
version.version_id,
|
| 83 |
+
next_number,
|
| 84 |
+
version_type,
|
| 85 |
+
)
|
| 86 |
+
return version
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
def _serialize_version(version: TextVersion) -> Dict[str, Any]:
|
| 90 |
+
return {
|
| 91 |
+
"page_id": version.page_id,
|
| 92 |
+
"version_id": version.version_id,
|
| 93 |
+
"version_type": version.version_type,
|
| 94 |
+
"is_current": version.is_current,
|
| 95 |
+
"content": version.content,
|
| 96 |
+
"created_at": version.created_at,
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
def get_current_page_text(db: Session, page_id: int) -> Optional[Dict[str, Any]]:
|
| 101 |
+
"""
|
| 102 |
+
현재(is_current=True) 텍스트 버전을 조회합니다.
|
| 103 |
+
"""
|
| 104 |
+
page = (
|
| 105 |
+
db.query(Page)
|
| 106 |
+
.filter(Page.page_id == page_id)
|
| 107 |
+
.first()
|
| 108 |
+
)
|
| 109 |
+
if not page:
|
| 110 |
+
raise ValueError(f"페이지 ID {page_id}를 찾을 수 없습니다.")
|
| 111 |
+
|
| 112 |
+
version = (
|
| 113 |
+
db.query(TextVersion)
|
| 114 |
+
.filter(
|
| 115 |
+
TextVersion.page_id == page_id,
|
| 116 |
+
TextVersion.is_current.is_(True),
|
| 117 |
+
)
|
| 118 |
+
.order_by(TextVersion.version_number.desc())
|
| 119 |
+
.first()
|
| 120 |
+
)
|
| 121 |
+
if not version:
|
| 122 |
+
logger.warning(
|
| 123 |
+
"페이지 %s의 현재 텍스트 버전을 찾을 수 없습니다. status=%s",
|
| 124 |
+
page_id,
|
| 125 |
+
page.analysis_status,
|
| 126 |
+
)
|
| 127 |
+
return None
|
| 128 |
+
return _serialize_version(version)
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
def save_user_edited_version(
|
| 132 |
+
db: Session,
|
| 133 |
+
page_id: int,
|
| 134 |
+
content: str,
|
| 135 |
+
*,
|
| 136 |
+
user_id: Optional[int],
|
| 137 |
+
) -> Dict[str, Any]:
|
| 138 |
+
"""
|
| 139 |
+
사용자 편집 텍스트를 새 버전으로 저장하고 해당 버전을 현재 버전으로 설정합니다.
|
| 140 |
+
"""
|
| 141 |
+
page = (
|
| 142 |
+
db.query(Page)
|
| 143 |
+
.filter(Page.page_id == page_id)
|
| 144 |
+
.first()
|
| 145 |
+
)
|
| 146 |
+
if not page:
|
| 147 |
+
raise ValueError(f"페이지 ID {page_id}를 찾을 수 없습니다.")
|
| 148 |
+
|
| 149 |
+
version = create_text_version(
|
| 150 |
+
db,
|
| 151 |
+
page,
|
| 152 |
+
content,
|
| 153 |
+
version_type="user_edited",
|
| 154 |
+
user_id=user_id,
|
| 155 |
+
commit=False,
|
| 156 |
+
)
|
| 157 |
+
|
| 158 |
+
deleted = (
|
| 159 |
+
db.query(CombinedResult)
|
| 160 |
+
.filter(CombinedResult.project_id == page.project_id)
|
| 161 |
+
.delete(synchronize_session=False)
|
| 162 |
+
)
|
| 163 |
+
if deleted:
|
| 164 |
+
logger.info(
|
| 165 |
+
"CombinedResult 캐시 무효화: project_id=%s, 삭제된 레코드=%s",
|
| 166 |
+
page.project_id,
|
| 167 |
+
deleted,
|
| 168 |
+
)
|
| 169 |
+
|
| 170 |
+
db.commit()
|
| 171 |
+
db.refresh(version)
|
| 172 |
+
return _serialize_version(version)
|
| 173 |
+
|
| 174 |
+
|
| 175 |
+
__all__ = [
|
| 176 |
+
"create_text_version",
|
| 177 |
+
"get_current_page_text",
|
| 178 |
+
"save_user_edited_version",
|
| 179 |
+
]
|
docker-compose.yml
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version: '3.8'
|
| 2 |
+
|
| 3 |
+
services:
|
| 4 |
+
mysql:
|
| 5 |
+
image: mysql:8.0
|
| 6 |
+
container_name: smart_mysql
|
| 7 |
+
restart: unless-stopped
|
| 8 |
+
|
| 9 |
+
# 환경 변수
|
| 10 |
+
environment:
|
| 11 |
+
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:-1q2w3e4r}
|
| 12 |
+
MYSQL_DATABASE: ${MYSQL_DATABASE:-smarteyessen_db}
|
| 13 |
+
# 선택적: 추가 사용자 생성
|
| 14 |
+
# MYSQL_USER: smarteye_user
|
| 15 |
+
# MYSQL_PASSWORD: smarteye_pass
|
| 16 |
+
|
| 17 |
+
# 포트 매핑
|
| 18 |
+
ports:
|
| 19 |
+
- "${MYSQL_PORT:-3308}:3306"
|
| 20 |
+
|
| 21 |
+
# MySQL 서버 설정 (UTF-8 강제)
|
| 22 |
+
command:
|
| 23 |
+
- --character-set-server=utf8mb4
|
| 24 |
+
- --collation-server=utf8mb4_unicode_ci
|
| 25 |
+
- --default-authentication-plugin=mysql_native_password
|
| 26 |
+
- --max-connections=200
|
| 27 |
+
- --sql-mode=STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION
|
| 28 |
+
|
| 29 |
+
# 데이터 지속성 (Named Volume 사용)
|
| 30 |
+
volumes:
|
| 31 |
+
- smart_mysql_data:/var/lib/mysql
|
| 32 |
+
# 초기화 스크립트 (선택사항)
|
| 33 |
+
- ./scripts/init_db.sql:/docker-entrypoint-initdb.d/01_init.sql
|
| 34 |
+
- ./scripts/seed_data.sql:/docker-entrypoint-initdb.d/02_seed.sql
|
| 35 |
+
|
| 36 |
+
# 헬스체크
|
| 37 |
+
healthcheck:
|
| 38 |
+
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p1q2w3e4r"]
|
| 39 |
+
interval: 10s
|
| 40 |
+
timeout: 5s
|
| 41 |
+
retries: 5
|
| 42 |
+
|
| 43 |
+
# 네트워크
|
| 44 |
+
networks:
|
| 45 |
+
- smarteye_network
|
| 46 |
+
|
| 47 |
+
# Named Volume 정의
|
| 48 |
+
volumes:
|
| 49 |
+
smart_mysql_data:
|
| 50 |
+
name: smart_mysql_data
|
| 51 |
+
driver: local
|
| 52 |
+
|
| 53 |
+
# 네트워크 정의
|
| 54 |
+
networks:
|
| 55 |
+
smarteye_network:
|
| 56 |
+
name: smarteye_network
|
| 57 |
+
driver: bridge
|
docs/Backend API 문서/00_개요.md
ADDED
|
@@ -0,0 +1,413 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# SmartEyeSsen API 문서 - 개요
|
| 2 |
+
|
| 3 |
+
## 📖 목차
|
| 4 |
+
- [시스템 소개](#시스템-소개)
|
| 5 |
+
- [기술 스택](#기술-스택)
|
| 6 |
+
- [시작하기](#시작하기)
|
| 7 |
+
- [인증](#인증)
|
| 8 |
+
- [기본 URL 및 환경 설정](#기본-url-및-환경-설정)
|
| 9 |
+
- [API 문서 구성](#api-문서-구성)
|
| 10 |
+
- [빠른 시작 예제](#빠른-시작-예제)
|
| 11 |
+
|
| 12 |
+
---
|
| 13 |
+
|
| 14 |
+
## 시스템 소개
|
| 15 |
+
|
| 16 |
+
**SmartEyeSsen**은 시각장애 학생을 위한 AI 기반 학습 자료 분석 시스템입니다. PDF 또는 이미지 형태의 학습 자료를 업로드하면, AI가 자동으로 레이아웃을 분석하고 텍스트를 추출하며, 도표와 그림에 대한 설명을 생성합니다.
|
| 17 |
+
|
| 18 |
+
### 주요 기능
|
| 19 |
+
|
| 20 |
+
#### 📄 다중 페이지 문서 처리
|
| 21 |
+
- **이미지 업로드**: 개별 페이지를 이미지로 업로드
|
| 22 |
+
- **PDF 업로드**: PDF 파일을 자동으로 페이지별로 분할하여 처리
|
| 23 |
+
|
| 24 |
+
#### 🤖 AI 레이아웃 분석
|
| 25 |
+
- DocLayout-YOLO 모델을 사용한 자동 레이아웃 감지
|
| 26 |
+
- 문제 번호(question_number), 본문(text), 도표(figure), 표(table) 등 자동 인식
|
| 27 |
+
|
| 28 |
+
#### 🔍 OCR 텍스트 추출
|
| 29 |
+
- PaddleOCR 기반 고정밀 텍스트 인식
|
| 30 |
+
- 다국어 지원 (한국어, 영어, 일본어, 중국어)
|
| 31 |
+
|
| 32 |
+
#### ✏️ 텍스트 편집 및 버전 관리
|
| 33 |
+
- TinyMCE 편집기 기반 텍스트 수정
|
| 34 |
+
- 자동 버전 관리 (원본, 자동 포맷팅, 사용자 편집)
|
| 35 |
+
|
| 36 |
+
#### 🖼️ AI 설명 생성
|
| 37 |
+
- GPT-4-turbo를 활용한 도표/표/순서도 설명 자동 생성
|
| 38 |
+
- 시각장애인을 위한 상세한 설명 제공
|
| 39 |
+
|
| 40 |
+
#### 📊 지능형 정렬
|
| 41 |
+
- **문제지(Worksheet)**: 문제 번호 기반 계층적 정렬
|
| 42 |
+
- **일반 문서(Document)**: 좌표 기반 읽기 순서 정렬
|
| 43 |
+
|
| 44 |
+
#### 📥 통합 문서 다운로드
|
| 45 |
+
- DOCX 형식으로 전체 내용 다운로드
|
| 46 |
+
- 페이지별 텍스트 통합 및 포맷팅 적용
|
| 47 |
+
|
| 48 |
+
---
|
| 49 |
+
|
| 50 |
+
## 기술 스택
|
| 51 |
+
|
| 52 |
+
### Backend
|
| 53 |
+
- **Framework**: FastAPI 0.104+
|
| 54 |
+
- **Database**: MySQL 8.0
|
| 55 |
+
- **ORM**: SQLAlchemy 2.0
|
| 56 |
+
- **Validation**: Pydantic v2
|
| 57 |
+
- **문서화**: Swagger/OpenAPI 3.0
|
| 58 |
+
|
| 59 |
+
### AI Models
|
| 60 |
+
- **Layout Detection**: DocLayout-YOLO
|
| 61 |
+
- **OCR**: PaddleOCR (한국어/영어/중국어/일본어)
|
| 62 |
+
- **AI Description**: GPT-4-turbo (OpenAI)
|
| 63 |
+
|
| 64 |
+
### Document Processing
|
| 65 |
+
- **PDF**: PyMuPDF (fitz)
|
| 66 |
+
- **Image**: OpenCV, Pillow
|
| 67 |
+
- **Word**: python-docx
|
| 68 |
+
|
| 69 |
+
---
|
| 70 |
+
|
| 71 |
+
## 시작하기
|
| 72 |
+
|
| 73 |
+
### 1. 백엔드 서버 실행
|
| 74 |
+
|
| 75 |
+
#### 환경 변수 설정
|
| 76 |
+
`.env` 파일을 생성하여 다음 내용을 설정하세요:
|
| 77 |
+
|
| 78 |
+
```env
|
| 79 |
+
# 데이터베이스 설정
|
| 80 |
+
DB_HOST=localhost
|
| 81 |
+
DB_PORT=3306
|
| 82 |
+
DB_USER=your_username
|
| 83 |
+
DB_PASSWORD=your_password
|
| 84 |
+
DB_NAME=smarteyessen_db
|
| 85 |
+
|
| 86 |
+
# API 서버 설정
|
| 87 |
+
API_HOST=0.0.0.0
|
| 88 |
+
API_PORT=8000
|
| 89 |
+
CORS_ORIGINS=http://localhost:3000,http://localhost:8080
|
| 90 |
+
|
| 91 |
+
# OpenAI API (선택 사항)
|
| 92 |
+
OPENAI_API_KEY=sk-...
|
| 93 |
+
|
| 94 |
+
# 환경
|
| 95 |
+
ENVIRONMENT=development
|
| 96 |
+
```
|
| 97 |
+
|
| 98 |
+
#### 서버 시작
|
| 99 |
+
|
| 100 |
+
**Linux/Mac**:
|
| 101 |
+
```bash
|
| 102 |
+
cd Backend
|
| 103 |
+
chmod +x start_server.sh
|
| 104 |
+
./start_server.sh
|
| 105 |
+
```
|
| 106 |
+
|
| 107 |
+
**Windows**:
|
| 108 |
+
```cmd
|
| 109 |
+
cd Backend
|
| 110 |
+
start_server.bat
|
| 111 |
+
```
|
| 112 |
+
|
| 113 |
+
서버가 정상적으로 시작되면 다음과 같은 메시지가 표시됩니다:
|
| 114 |
+
```
|
| 115 |
+
🚀 SmartEyeSsen Backend Starting...
|
| 116 |
+
✅ Database connection successful
|
| 117 |
+
✅ Database tables initialized
|
| 118 |
+
✅ SmartEyeSsen Backend Ready!
|
| 119 |
+
📖 API Docs: http://localhost:8000/docs
|
| 120 |
+
```
|
| 121 |
+
|
| 122 |
+
### 2. API 문서 확인
|
| 123 |
+
|
| 124 |
+
브라우저에서 다음 URL에 접속하여 인터랙티브 API 문서를 확인할 수 있습니다:
|
| 125 |
+
|
| 126 |
+
- **Swagger UI**: http://localhost:8000/docs
|
| 127 |
+
- **ReDoc**: http://localhost:8000/redoc
|
| 128 |
+
- **OpenAPI JSON**: http://localhost:8000/openapi.json
|
| 129 |
+
|
| 130 |
+
### 3. 헬스 체크
|
| 131 |
+
|
| 132 |
+
서버가 정상적으로 작동하는지 확인:
|
| 133 |
+
|
| 134 |
+
```bash
|
| 135 |
+
curl http://localhost:8000/health
|
| 136 |
+
```
|
| 137 |
+
|
| 138 |
+
**응답 예시**:
|
| 139 |
+
```json
|
| 140 |
+
{
|
| 141 |
+
"status": "healthy",
|
| 142 |
+
"database": "connected",
|
| 143 |
+
"api_version": "1.0.0"
|
| 144 |
+
}
|
| 145 |
+
```
|
| 146 |
+
|
| 147 |
+
---
|
| 148 |
+
|
| 149 |
+
## 인증
|
| 150 |
+
|
| 151 |
+
**현재 버전 (v1.0.1)에서는 인증이 필요하지 않습니다.**
|
| 152 |
+
|
| 153 |
+
향후 버전에서는 다음과 같은 인증 방식이 추가될 예정입니다:
|
| 154 |
+
- JWT (JSON Web Token) 기반 인증
|
| 155 |
+
- OAuth 2.0 (Google, Naver 등)
|
| 156 |
+
- API Key 기반 인증
|
| 157 |
+
|
| 158 |
+
---
|
| 159 |
+
|
| 160 |
+
## 기본 URL 및 환경 설정
|
| 161 |
+
|
| 162 |
+
### Base URL
|
| 163 |
+
|
| 164 |
+
**개발 환경**:
|
| 165 |
+
```
|
| 166 |
+
http://localhost:8000
|
| 167 |
+
```
|
| 168 |
+
|
| 169 |
+
**프로덕션 환경** (배포 후):
|
| 170 |
+
```
|
| 171 |
+
https://api.smarteyessen.com
|
| 172 |
+
```
|
| 173 |
+
|
| 174 |
+
### API Endpoint 구조
|
| 175 |
+
|
| 176 |
+
모든 API 엔드포인트는 `/api` 접두사를 사용합니다:
|
| 177 |
+
|
| 178 |
+
```
|
| 179 |
+
/api/projects # 프로젝트 관리
|
| 180 |
+
/api/pages # 페이지 관리
|
| 181 |
+
/api/analysis # 분석 관련
|
| 182 |
+
```
|
| 183 |
+
|
| 184 |
+
### CORS 설정
|
| 185 |
+
|
| 186 |
+
프론트엔드에서 API를 호출하려면 CORS 설정이 필요합니다. `.env` 파일의 `CORS_ORIGINS`에 프론트엔드 URL을 추가하세요:
|
| 187 |
+
|
| 188 |
+
```env
|
| 189 |
+
CORS_ORIGINS=http://localhost:3000,http://localhost:8080,https://yourdomain.com
|
| 190 |
+
```
|
| 191 |
+
|
| 192 |
+
---
|
| 193 |
+
|
| 194 |
+
## API 문서 구성
|
| 195 |
+
|
| 196 |
+
이 API 문서는 다음과 같이 구성되어 있습니다:
|
| 197 |
+
|
| 198 |
+
| 문서 | 내용 |
|
| 199 |
+
|------|------|
|
| 200 |
+
| [00_개요.md](./00_개요.md) | 시스템 소개, 시작하기, 인증 (현재 문서) |
|
| 201 |
+
| [01_프로젝트_API.md](./01_프로젝트_API.md) | 프로젝트 생성, 조회, 수정, 삭제 |
|
| 202 |
+
| [02_페이지_API.md](./02_페이지_API.md) | 이미지/PDF 업로드, 페이지 조회, 텍스트 관리 |
|
| 203 |
+
| [03_분석_API.md](./03_분석_API.md) | 배치 분석, 비동기 분석, 작업 상태 조회 |
|
| 204 |
+
| [04_다운로드_API.md](./04_다운로드_API.md) | 통합 텍스트 조회, Word 문서 다운로드 |
|
| 205 |
+
| [05_데이터_모델.md](./05_데이터_모델.md) | 스키마 및 Enum 정의 |
|
| 206 |
+
| [06_에러_처리.md](./06_에러_처리.md) | HTTP 상태 코드, 에러 응답 형식 |
|
| 207 |
+
|
| 208 |
+
---
|
| 209 |
+
|
| 210 |
+
## 빠른 시작 예제
|
| 211 |
+
|
| 212 |
+
다음은 프론트엔드에서 SmartEyeSsen API를 사용하는 기본적인 흐름입니다.
|
| 213 |
+
|
| 214 |
+
### 1️⃣ 프로젝트 생성
|
| 215 |
+
|
| 216 |
+
```javascript
|
| 217 |
+
const createProject = async () => {
|
| 218 |
+
const response = await fetch('http://localhost:8000/api/projects', {
|
| 219 |
+
method: 'POST',
|
| 220 |
+
headers: {
|
| 221 |
+
'Content-Type': 'application/json',
|
| 222 |
+
},
|
| 223 |
+
body: JSON.stringify({
|
| 224 |
+
project_name: '수학 문제집 1단원',
|
| 225 |
+
doc_type_id: 1, // 1: worksheet, 2: document
|
| 226 |
+
analysis_mode: 'auto',
|
| 227 |
+
user_id: 1
|
| 228 |
+
})
|
| 229 |
+
});
|
| 230 |
+
|
| 231 |
+
const project = await response.json();
|
| 232 |
+
console.log('프로젝트 생성:', project);
|
| 233 |
+
return project;
|
| 234 |
+
};
|
| 235 |
+
```
|
| 236 |
+
|
| 237 |
+
### 2️⃣ PDF 업로드
|
| 238 |
+
|
| 239 |
+
```javascript
|
| 240 |
+
const uploadPDF = async (projectId, pdfFile) => {
|
| 241 |
+
const formData = new FormData();
|
| 242 |
+
formData.append('project_id', projectId);
|
| 243 |
+
formData.append('file', pdfFile);
|
| 244 |
+
|
| 245 |
+
const response = await fetch('http://localhost:8000/api/pages/upload', {
|
| 246 |
+
method: 'POST',
|
| 247 |
+
body: formData
|
| 248 |
+
});
|
| 249 |
+
|
| 250 |
+
const result = await response.json();
|
| 251 |
+
console.log(`${result.total_created}개 페이지 생성됨`);
|
| 252 |
+
return result;
|
| 253 |
+
};
|
| 254 |
+
```
|
| 255 |
+
|
| 256 |
+
### 3️⃣ 프로젝트 분석 (비동기)
|
| 257 |
+
|
| 258 |
+
```javascript
|
| 259 |
+
const analyzeProject = async (projectId) => {
|
| 260 |
+
const response = await fetch(`http://localhost:8000/api/projects/${projectId}/analyze`, {
|
| 261 |
+
method: 'POST',
|
| 262 |
+
headers: {
|
| 263 |
+
'Content-Type': 'application/json',
|
| 264 |
+
},
|
| 265 |
+
body: JSON.stringify({
|
| 266 |
+
use_ai_descriptions: true,
|
| 267 |
+
api_key: 'sk-...' // OpenAI API Key (선택)
|
| 268 |
+
})
|
| 269 |
+
});
|
| 270 |
+
|
| 271 |
+
const result = await response.json();
|
| 272 |
+
console.log('분석 결과:', result);
|
| 273 |
+
return result;
|
| 274 |
+
};
|
| 275 |
+
```
|
| 276 |
+
|
| 277 |
+
### 4️⃣ 페이지 텍스트 조회
|
| 278 |
+
|
| 279 |
+
```javascript
|
| 280 |
+
const getPageText = async (pageId) => {
|
| 281 |
+
const response = await fetch(`http://localhost:8000/api/pages/${pageId}/text`);
|
| 282 |
+
const textData = await response.json();
|
| 283 |
+
console.log('페이지 텍스트:', textData.content);
|
| 284 |
+
return textData;
|
| 285 |
+
};
|
| 286 |
+
```
|
| 287 |
+
|
| 288 |
+
### 5️⃣ 사용자 편집 텍스트 저장
|
| 289 |
+
|
| 290 |
+
```javascript
|
| 291 |
+
const saveEditedText = async (pageId, editedContent, userId) => {
|
| 292 |
+
const response = await fetch(`http://localhost:8000/api/pages/${pageId}/text`, {
|
| 293 |
+
method: 'POST',
|
| 294 |
+
headers: {
|
| 295 |
+
'Content-Type': 'application/json',
|
| 296 |
+
},
|
| 297 |
+
body: JSON.stringify({
|
| 298 |
+
content: editedContent,
|
| 299 |
+
user_id: userId
|
| 300 |
+
})
|
| 301 |
+
});
|
| 302 |
+
|
| 303 |
+
const result = await response.json();
|
| 304 |
+
console.log('텍스트 저장 완료:', result);
|
| 305 |
+
return result;
|
| 306 |
+
};
|
| 307 |
+
```
|
| 308 |
+
|
| 309 |
+
### 6️⃣ Word 문서 다운로드
|
| 310 |
+
|
| 311 |
+
```javascript
|
| 312 |
+
const downloadDocument = async (projectId) => {
|
| 313 |
+
const response = await fetch(`http://localhost:8000/api/projects/${projectId}/download`, {
|
| 314 |
+
method: 'POST'
|
| 315 |
+
});
|
| 316 |
+
|
| 317 |
+
const blob = await response.blob();
|
| 318 |
+
const url = window.URL.createObjectURL(blob);
|
| 319 |
+
const a = document.createElement('a');
|
| 320 |
+
a.href = url;
|
| 321 |
+
a.download = `project_${projectId}.docx`;
|
| 322 |
+
document.body.appendChild(a);
|
| 323 |
+
a.click();
|
| 324 |
+
a.remove();
|
| 325 |
+
window.URL.revokeObjectURL(url);
|
| 326 |
+
};
|
| 327 |
+
```
|
| 328 |
+
|
| 329 |
+
---
|
| 330 |
+
|
| 331 |
+
## React/Axios 예제
|
| 332 |
+
|
| 333 |
+
Axios를 사용하는 경우:
|
| 334 |
+
|
| 335 |
+
```javascript
|
| 336 |
+
import axios from 'axios';
|
| 337 |
+
|
| 338 |
+
// API 클라이언트 생성
|
| 339 |
+
const apiClient = axios.create({
|
| 340 |
+
baseURL: 'http://localhost:8000',
|
| 341 |
+
headers: {
|
| 342 |
+
'Content-Type': 'application/json',
|
| 343 |
+
}
|
| 344 |
+
});
|
| 345 |
+
|
| 346 |
+
// 프로젝트 생성
|
| 347 |
+
const createProject = async (projectData) => {
|
| 348 |
+
try {
|
| 349 |
+
const response = await apiClient.post('/api/projects', projectData);
|
| 350 |
+
return response.data;
|
| 351 |
+
} catch (error) {
|
| 352 |
+
console.error('프로젝트 생성 실패:', error.response?.data);
|
| 353 |
+
throw error;
|
| 354 |
+
}
|
| 355 |
+
};
|
| 356 |
+
|
| 357 |
+
// PDF 업로드
|
| 358 |
+
const uploadPDF = async (projectId, pdfFile) => {
|
| 359 |
+
const formData = new FormData();
|
| 360 |
+
formData.append('project_id', projectId);
|
| 361 |
+
formData.append('file', pdfFile);
|
| 362 |
+
|
| 363 |
+
try {
|
| 364 |
+
const response = await apiClient.post('/api/pages/upload', formData, {
|
| 365 |
+
headers: {
|
| 366 |
+
'Content-Type': 'multipart/form-data',
|
| 367 |
+
}
|
| 368 |
+
});
|
| 369 |
+
return response.data;
|
| 370 |
+
} catch (error) {
|
| 371 |
+
console.error('PDF 업로드 실패:', error.response?.data);
|
| 372 |
+
throw error;
|
| 373 |
+
}
|
| 374 |
+
};
|
| 375 |
+
|
| 376 |
+
// 프로젝트 분석
|
| 377 |
+
const analyzeProject = async (projectId, useAI = true, apiKey = null) => {
|
| 378 |
+
try {
|
| 379 |
+
const response = await apiClient.post(`/api/projects/${projectId}/analyze`, {
|
| 380 |
+
use_ai_descriptions: useAI,
|
| 381 |
+
api_key: apiKey
|
| 382 |
+
});
|
| 383 |
+
return response.data;
|
| 384 |
+
} catch (error) {
|
| 385 |
+
console.error('분석 실패:', error.response?.data);
|
| 386 |
+
throw error;
|
| 387 |
+
}
|
| 388 |
+
};
|
| 389 |
+
|
| 390 |
+
export { createProject, uploadPDF, analyzeProject };
|
| 391 |
+
```
|
| 392 |
+
|
| 393 |
+
---
|
| 394 |
+
|
| 395 |
+
## 다음 단계
|
| 396 |
+
|
| 397 |
+
API 사용 방법을 더 자세히 알아보려면 다음 문서를 참고하세요:
|
| 398 |
+
|
| 399 |
+
1. **[프로젝트 API](./01_프로젝트_API.md)**: 프로젝트 생성, 조회, 수정, 삭제 방법
|
| 400 |
+
2. **[페이지 API](./02_페이지_API.md)**: 이미지/PDF 업로드 및 텍스트 관리
|
| 401 |
+
3. **[분석 API](./03_분석_API.md)**: AI 분석 실행 및 작업 상태 조회
|
| 402 |
+
4. **[다운로드 API](./04_다운로드_API.md)**: 통합 텍스트 및 문서 다운로드
|
| 403 |
+
5. **[데이터 모델](./05_데이터_모델.md)**: 상세한 스키마 및 Enum 정의
|
| 404 |
+
6. **[에러 처리](./06_에러_처리.md)**: 에러 코드 및 처리 방법
|
| 405 |
+
|
| 406 |
+
---
|
| 407 |
+
|
| 408 |
+
## 문의 및 지원
|
| 409 |
+
|
| 410 |
+
- **GitHub Issues**: [SmartEyeSsen Issues](https://github.com/yourorg/smarteyessen/issues)
|
| 411 |
+
- **이메일**: support@smarteyessen.com
|
| 412 |
+
- **문서 버전**: v1.0.1
|
| 413 |
+
- **최종 수정일**: 2025-01-22
|
docs/Backend API 문서/01_프로젝트_API.md
ADDED
|
@@ -0,0 +1,608 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# 프로젝트 API
|
| 2 |
+
|
| 3 |
+
프로젝트는 문서 처리의 최상위 단위입니다. 하나의 프로젝트는 여러 페이지를 포함할 수 있으며, 각 프로젝트는 문서 타입(worksheet 또는 document)을 가집니다.
|
| 4 |
+
|
| 5 |
+
## 📖 목차
|
| 6 |
+
|
| 7 |
+
- [엔드포인트 목록](#엔드포인트-목록)
|
| 8 |
+
- [1. 프로젝트 생성](#1-프로젝트-생성)
|
| 9 |
+
- [2. 프로젝트 목록 조회](#2-프로젝트-목록-조회)
|
| 10 |
+
- [3. 프로젝트 상세 조회](#3-프로젝트-상세-조회)
|
| 11 |
+
- [4. 프로젝트 수정](#4-프로젝트-수정)
|
| 12 |
+
- [5. 프로젝트 삭제](#5-프로젝트-삭제)
|
| 13 |
+
|
| 14 |
+
---
|
| 15 |
+
|
| 16 |
+
## 엔드포인트 목록
|
| 17 |
+
|
| 18 |
+
| Method | Endpoint | 설명 |
|
| 19 |
+
|--------|----------|------|
|
| 20 |
+
| POST | `/api/projects` | 새 프로젝트 생성 |
|
| 21 |
+
| GET | `/api/projects` | 프로젝트 목록 조회 (페이지네이션 지원) |
|
| 22 |
+
| GET | `/api/projects/{project_id}` | 프로젝트 상세 조회 (페이지 포함) |
|
| 23 |
+
| PATCH | `/api/projects/{project_id}` | 프로젝트 정보 수정 |
|
| 24 |
+
| DELETE | `/api/projects/{project_id}` | 프로젝트 삭제 (cascade) |
|
| 25 |
+
|
| 26 |
+
---
|
| 27 |
+
|
| 28 |
+
## 1. 프로젝트 생성
|
| 29 |
+
|
| 30 |
+
새로운 프로젝트를 생성합니다.
|
| 31 |
+
|
| 32 |
+
### Endpoint
|
| 33 |
+
|
| 34 |
+
```
|
| 35 |
+
POST /api/projects
|
| 36 |
+
```
|
| 37 |
+
|
| 38 |
+
### Request Body
|
| 39 |
+
|
| 40 |
+
```json
|
| 41 |
+
{
|
| 42 |
+
"project_name": "수학 문제집 1단원",
|
| 43 |
+
"doc_type_id": 1,
|
| 44 |
+
"analysis_mode": "auto",
|
| 45 |
+
"user_id": 1
|
| 46 |
+
}
|
| 47 |
+
```
|
| 48 |
+
|
| 49 |
+
**필드 설명**:
|
| 50 |
+
|
| 51 |
+
| 필드 | 타입 | 필수 | 설명 |
|
| 52 |
+
|------|------|------|------|
|
| 53 |
+
| `project_name` | string | ✅ | 프로젝트 이름 (1~255자) |
|
| 54 |
+
| `doc_type_id` | integer | ✅ | 문서 타입 ID<br>- `1`: worksheet (문제지)<br>- `2`: document (일반 문서) |
|
| 55 |
+
| `analysis_mode` | string | ❌ | 분석 모드 (기본값: `auto`)<br>- `auto`: 자동 분석<br>- `manual`: 수동 분석<br>- `hybrid`: 하이브리드 |
|
| 56 |
+
| `user_id` | integer | ✅ | 사용자 ID |
|
| 57 |
+
|
| 58 |
+
### Response
|
| 59 |
+
|
| 60 |
+
**HTTP 201 Created**
|
| 61 |
+
|
| 62 |
+
```json
|
| 63 |
+
{
|
| 64 |
+
"project_id": 1,
|
| 65 |
+
"user_id": 1,
|
| 66 |
+
"doc_type_id": 1,
|
| 67 |
+
"project_name": "수학 문제집 1단원",
|
| 68 |
+
"total_pages": 0,
|
| 69 |
+
"analysis_mode": "auto",
|
| 70 |
+
"status": "created",
|
| 71 |
+
"created_at": "2025-01-22T10:30:00",
|
| 72 |
+
"updated_at": "2025-01-22T10:30:00"
|
| 73 |
+
}
|
| 74 |
+
```
|
| 75 |
+
|
| 76 |
+
**응답 필드**:
|
| 77 |
+
|
| 78 |
+
| 필드 | 타입 | 설명 |
|
| 79 |
+
|------|------|------|
|
| 80 |
+
| `project_id` | integer | 생성된 프로젝트 고유 ID |
|
| 81 |
+
| `user_id` | integer | 소유자 사용자 ID |
|
| 82 |
+
| `doc_type_id` | integer | 문서 타입 ID |
|
| 83 |
+
| `project_name` | string | 프로젝트 이름 |
|
| 84 |
+
| `total_pages` | integer | 총 페이지 수 (초기값: 0) |
|
| 85 |
+
| `analysis_mode` | string | 분석 모드 |
|
| 86 |
+
| `status` | string | 프로젝트 상태<br>- `created`: 생성됨<br>- `in_progress`: 진행 중<br>- `completed`: 완료<br>- `error`: 오류 |
|
| 87 |
+
| `created_at` | datetime | 생성일시 |
|
| 88 |
+
| `updated_at` | datetime | 수정일시 |
|
| 89 |
+
|
| 90 |
+
### 예제 코드
|
| 91 |
+
|
| 92 |
+
**JavaScript (fetch)**:
|
| 93 |
+
|
| 94 |
+
```javascript
|
| 95 |
+
const createProject = async (projectData) => {
|
| 96 |
+
const response = await fetch('http://localhost:8000/api/projects', {
|
| 97 |
+
method: 'POST',
|
| 98 |
+
headers: {
|
| 99 |
+
'Content-Type': 'application/json',
|
| 100 |
+
},
|
| 101 |
+
body: JSON.stringify({
|
| 102 |
+
project_name: projectData.name,
|
| 103 |
+
doc_type_id: projectData.docType,
|
| 104 |
+
analysis_mode: 'auto',
|
| 105 |
+
user_id: projectData.userId
|
| 106 |
+
})
|
| 107 |
+
});
|
| 108 |
+
|
| 109 |
+
if (!response.ok) {
|
| 110 |
+
throw new Error(`프로젝트 생성 실패: ${response.status}`);
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
return await response.json();
|
| 114 |
+
};
|
| 115 |
+
|
| 116 |
+
// 사용 예시
|
| 117 |
+
createProject({
|
| 118 |
+
name: '수학 문제집 1단원',
|
| 119 |
+
docType: 1,
|
| 120 |
+
userId: 1
|
| 121 |
+
}).then(project => {
|
| 122 |
+
console.log('프로젝트 생성 완료:', project.project_id);
|
| 123 |
+
});
|
| 124 |
+
```
|
| 125 |
+
|
| 126 |
+
**React + Axios**:
|
| 127 |
+
|
| 128 |
+
```javascript
|
| 129 |
+
import axios from 'axios';
|
| 130 |
+
|
| 131 |
+
const apiClient = axios.create({
|
| 132 |
+
baseURL: 'http://localhost:8000',
|
| 133 |
+
});
|
| 134 |
+
|
| 135 |
+
const createProject = async (name, docTypeId, userId) => {
|
| 136 |
+
try {
|
| 137 |
+
const response = await apiClient.post('/api/projects', {
|
| 138 |
+
project_name: name,
|
| 139 |
+
doc_type_id: docTypeId,
|
| 140 |
+
analysis_mode: 'auto',
|
| 141 |
+
user_id: userId
|
| 142 |
+
});
|
| 143 |
+
return response.data;
|
| 144 |
+
} catch (error) {
|
| 145 |
+
console.error('프로젝트 생성 실패:', error.response?.data);
|
| 146 |
+
throw error;
|
| 147 |
+
}
|
| 148 |
+
};
|
| 149 |
+
|
| 150 |
+
// 사용 예시
|
| 151 |
+
createProject('수학 문제집 1단원', 1, 1)
|
| 152 |
+
.then(project => console.log('생성됨:', project))
|
| 153 |
+
.catch(error => console.error('에러:', error));
|
| 154 |
+
```
|
| 155 |
+
|
| 156 |
+
---
|
| 157 |
+
|
| 158 |
+
## 2. 프로젝트 목록 조회
|
| 159 |
+
|
| 160 |
+
사용자의 프로젝트 목록을 조회합니다. 페이지네이션을 지원합니다.
|
| 161 |
+
|
| 162 |
+
### Endpoint
|
| 163 |
+
|
| 164 |
+
```
|
| 165 |
+
GET /api/projects
|
| 166 |
+
```
|
| 167 |
+
|
| 168 |
+
### Query Parameters
|
| 169 |
+
|
| 170 |
+
| 파라미터 | 타입 | 필수 | 설명 |
|
| 171 |
+
|----------|------|------|------|
|
| 172 |
+
| `user_id` | integer | ❌ | 특정 사용자의 프로젝트만 필터링 |
|
| 173 |
+
| `skip` | integer | ❌ | 건너뛸 개수 (기본값: 0) |
|
| 174 |
+
| `limit` | integer | ❌ | 조회할 개수 (기본값: 100, 최대: 1000) |
|
| 175 |
+
|
| 176 |
+
### Response
|
| 177 |
+
|
| 178 |
+
**HTTP 200 OK**
|
| 179 |
+
|
| 180 |
+
```json
|
| 181 |
+
[
|
| 182 |
+
{
|
| 183 |
+
"project_id": 1,
|
| 184 |
+
"user_id": 1,
|
| 185 |
+
"doc_type_id": 1,
|
| 186 |
+
"project_name": "수학 문제집 1단원",
|
| 187 |
+
"total_pages": 5,
|
| 188 |
+
"analysis_mode": "auto",
|
| 189 |
+
"status": "completed",
|
| 190 |
+
"created_at": "2025-01-22T10:30:00",
|
| 191 |
+
"updated_at": "2025-01-22T10:35:00"
|
| 192 |
+
},
|
| 193 |
+
{
|
| 194 |
+
"project_id": 2,
|
| 195 |
+
"user_id": 1,
|
| 196 |
+
"doc_type_id": 2,
|
| 197 |
+
"project_name": "역사 교과서",
|
| 198 |
+
"total_pages": 10,
|
| 199 |
+
"analysis_mode": "auto",
|
| 200 |
+
"status": "in_progress",
|
| 201 |
+
"created_at": "2025-01-22T11:00:00",
|
| 202 |
+
"updated_at": "2025-01-22T11:05:00"
|
| 203 |
+
}
|
| 204 |
+
]
|
| 205 |
+
```
|
| 206 |
+
|
| 207 |
+
### 예제 코드
|
| 208 |
+
|
| 209 |
+
**JavaScript (fetch)**:
|
| 210 |
+
|
| 211 |
+
```javascript
|
| 212 |
+
const getProjects = async (userId = null, skip = 0, limit = 100) => {
|
| 213 |
+
const params = new URLSearchParams();
|
| 214 |
+
if (userId) params.append('user_id', userId);
|
| 215 |
+
params.append('skip', skip);
|
| 216 |
+
params.append('limit', limit);
|
| 217 |
+
|
| 218 |
+
const response = await fetch(`http://localhost:8000/api/projects?${params}`);
|
| 219 |
+
|
| 220 |
+
if (!response.ok) {
|
| 221 |
+
throw new Error(`프로젝트 조회 실패: ${response.status}`);
|
| 222 |
+
}
|
| 223 |
+
|
| 224 |
+
return await response.json();
|
| 225 |
+
};
|
| 226 |
+
|
| 227 |
+
// 사용 예시
|
| 228 |
+
getProjects(1, 0, 20).then(projects => {
|
| 229 |
+
console.log(`${projects.length}개 프로젝트 조회됨`);
|
| 230 |
+
projects.forEach(p => {
|
| 231 |
+
console.log(`- ${p.project_name} (${p.total_pages}페이지)`);
|
| 232 |
+
});
|
| 233 |
+
});
|
| 234 |
+
```
|
| 235 |
+
|
| 236 |
+
**React Component 예제**:
|
| 237 |
+
|
| 238 |
+
```jsx
|
| 239 |
+
import React, { useState, useEffect } from 'react';
|
| 240 |
+
import axios from 'axios';
|
| 241 |
+
|
| 242 |
+
function ProjectList({ userId }) {
|
| 243 |
+
const [projects, setProjects] = useState([]);
|
| 244 |
+
const [loading, setLoading] = useState(true);
|
| 245 |
+
|
| 246 |
+
useEffect(() => {
|
| 247 |
+
const fetchProjects = async () => {
|
| 248 |
+
try {
|
| 249 |
+
const response = await axios.get('http://localhost:8000/api/projects', {
|
| 250 |
+
params: { user_id: userId, limit: 50 }
|
| 251 |
+
});
|
| 252 |
+
setProjects(response.data);
|
| 253 |
+
} catch (error) {
|
| 254 |
+
console.error('프로젝트 조회 실패:', error);
|
| 255 |
+
} finally {
|
| 256 |
+
setLoading(false);
|
| 257 |
+
}
|
| 258 |
+
};
|
| 259 |
+
|
| 260 |
+
fetchProjects();
|
| 261 |
+
}, [userId]);
|
| 262 |
+
|
| 263 |
+
if (loading) return <div>로딩 중...</div>;
|
| 264 |
+
|
| 265 |
+
return (
|
| 266 |
+
<div>
|
| 267 |
+
<h2>내 프로젝트 ({projects.length}개)</h2>
|
| 268 |
+
<ul>
|
| 269 |
+
{projects.map(project => (
|
| 270 |
+
<li key={project.project_id}>
|
| 271 |
+
{project.project_name} - {project.total_pages}페이지
|
| 272 |
+
<span className={`status-${project.status}`}>
|
| 273 |
+
{project.status}
|
| 274 |
+
</span>
|
| 275 |
+
</li>
|
| 276 |
+
))}
|
| 277 |
+
</ul>
|
| 278 |
+
</div>
|
| 279 |
+
);
|
| 280 |
+
}
|
| 281 |
+
```
|
| 282 |
+
|
| 283 |
+
---
|
| 284 |
+
|
| 285 |
+
## 3. 프로젝트 상세 조회
|
| 286 |
+
|
| 287 |
+
프로젝트의 상세 정보를 페이지 목록과 함께 조회합니다.
|
| 288 |
+
|
| 289 |
+
### Endpoint
|
| 290 |
+
|
| 291 |
+
```
|
| 292 |
+
GET /api/projects/{project_id}
|
| 293 |
+
```
|
| 294 |
+
|
| 295 |
+
### Path Parameters
|
| 296 |
+
|
| 297 |
+
| 파라미터 | 타입 | 설명 |
|
| 298 |
+
|----------|------|------|
|
| 299 |
+
| `project_id` | integer | 조회할 프로젝트 ID |
|
| 300 |
+
|
| 301 |
+
### Response
|
| 302 |
+
|
| 303 |
+
**HTTP 200 OK**
|
| 304 |
+
|
| 305 |
+
```json
|
| 306 |
+
{
|
| 307 |
+
"project_id": 1,
|
| 308 |
+
"user_id": 1,
|
| 309 |
+
"doc_type_id": 1,
|
| 310 |
+
"project_name": "수학 문제집 1단원",
|
| 311 |
+
"total_pages": 3,
|
| 312 |
+
"analysis_mode": "auto",
|
| 313 |
+
"status": "completed",
|
| 314 |
+
"created_at": "2025-01-22T10:30:00",
|
| 315 |
+
"updated_at": "2025-01-22T10:35:00",
|
| 316 |
+
"pages": [
|
| 317 |
+
{
|
| 318 |
+
"page_id": 1,
|
| 319 |
+
"project_id": 1,
|
| 320 |
+
"page_number": 1,
|
| 321 |
+
"image_path": "uploads/project_1_page_1_abc123.png",
|
| 322 |
+
"image_width": 2480,
|
| 323 |
+
"image_height": 3508,
|
| 324 |
+
"analysis_status": "completed",
|
| 325 |
+
"processing_time": 5.23,
|
| 326 |
+
"created_at": "2025-01-22T10:31:00",
|
| 327 |
+
"analyzed_at": "2025-01-22T10:35:00"
|
| 328 |
+
},
|
| 329 |
+
{
|
| 330 |
+
"page_id": 2,
|
| 331 |
+
"project_id": 1,
|
| 332 |
+
"page_number": 2,
|
| 333 |
+
"image_path": "uploads/project_1_page_2_def456.png",
|
| 334 |
+
"image_width": 2480,
|
| 335 |
+
"image_height": 3508,
|
| 336 |
+
"analysis_status": "completed",
|
| 337 |
+
"processing_time": 4.87,
|
| 338 |
+
"created_at": "2025-01-22T10:32:00",
|
| 339 |
+
"analyzed_at": "2025-01-22T10:36:00"
|
| 340 |
+
}
|
| 341 |
+
]
|
| 342 |
+
}
|
| 343 |
+
```
|
| 344 |
+
|
| 345 |
+
### 예제 코드
|
| 346 |
+
|
| 347 |
+
**JavaScript (fetch)**:
|
| 348 |
+
|
| 349 |
+
```javascript
|
| 350 |
+
const getProjectDetail = async (projectId) => {
|
| 351 |
+
const response = await fetch(`http://localhost:8000/api/projects/${projectId}`);
|
| 352 |
+
|
| 353 |
+
if (!response.ok) {
|
| 354 |
+
if (response.status === 404) {
|
| 355 |
+
throw new Error('프로젝트를 찾을 수 없습니다.');
|
| 356 |
+
}
|
| 357 |
+
throw new Error(`프로젝트 조회 실패: ${response.status}`);
|
| 358 |
+
}
|
| 359 |
+
|
| 360 |
+
return await response.json();
|
| 361 |
+
};
|
| 362 |
+
|
| 363 |
+
// 사용 예시
|
| 364 |
+
getProjectDetail(1).then(project => {
|
| 365 |
+
console.log(`프로젝트: ${project.project_name}`);
|
| 366 |
+
console.log(`페이지 수: ${project.pages.length}`);
|
| 367 |
+
project.pages.forEach(page => {
|
| 368 |
+
console.log(`- 페이지 ${page.page_number}: ${page.analysis_status}`);
|
| 369 |
+
});
|
| 370 |
+
});
|
| 371 |
+
```
|
| 372 |
+
|
| 373 |
+
---
|
| 374 |
+
|
| 375 |
+
## 4. 프로젝트 수정
|
| 376 |
+
|
| 377 |
+
프로젝트 정보를 수정합니다.
|
| 378 |
+
|
| 379 |
+
### Endpoint
|
| 380 |
+
|
| 381 |
+
```
|
| 382 |
+
PATCH /api/projects/{project_id}
|
| 383 |
+
```
|
| 384 |
+
|
| 385 |
+
### Path Parameters
|
| 386 |
+
|
| 387 |
+
| 파라미터 | 타입 | 설명 |
|
| 388 |
+
|----------|------|------|
|
| 389 |
+
| `project_id` | integer | 수정할 프로젝트 ID |
|
| 390 |
+
|
| 391 |
+
### Request Body
|
| 392 |
+
|
| 393 |
+
모든 필드는 선택사항(optional)입니다. 수정하려는 필드만 포함하세요.
|
| 394 |
+
|
| 395 |
+
```json
|
| 396 |
+
{
|
| 397 |
+
"project_name": "수학 문제집 1단원 (수정본)",
|
| 398 |
+
"status": "completed"
|
| 399 |
+
}
|
| 400 |
+
```
|
| 401 |
+
|
| 402 |
+
**수정 가능한 필드**:
|
| 403 |
+
|
| 404 |
+
| 필드 | 타입 | 설명 |
|
| 405 |
+
|------|------|------|
|
| 406 |
+
| `project_name` | string | 프로젝트 이름 (1~255자) |
|
| 407 |
+
| `doc_type_id` | integer | 문서 타입 ID |
|
| 408 |
+
| `analysis_mode` | string | 분석 모드 (`auto`, `manual`, `hybrid`) |
|
| 409 |
+
| `status` | string | 프로젝트 상태 (`created`, `in_progress`, `completed`, `error`) |
|
| 410 |
+
|
| 411 |
+
### Response
|
| 412 |
+
|
| 413 |
+
**HTTP 200 OK**
|
| 414 |
+
|
| 415 |
+
```json
|
| 416 |
+
{
|
| 417 |
+
"project_id": 1,
|
| 418 |
+
"user_id": 1,
|
| 419 |
+
"doc_type_id": 1,
|
| 420 |
+
"project_name": "수학 문제집 1단원 (수정본)",
|
| 421 |
+
"total_pages": 3,
|
| 422 |
+
"analysis_mode": "auto",
|
| 423 |
+
"status": "completed",
|
| 424 |
+
"created_at": "2025-01-22T10:30:00",
|
| 425 |
+
"updated_at": "2025-01-22T14:20:00"
|
| 426 |
+
}
|
| 427 |
+
```
|
| 428 |
+
|
| 429 |
+
### 예제 코드
|
| 430 |
+
|
| 431 |
+
**JavaScript (fetch)**:
|
| 432 |
+
|
| 433 |
+
```javascript
|
| 434 |
+
const updateProject = async (projectId, updates) => {
|
| 435 |
+
const response = await fetch(`http://localhost:8000/api/projects/${projectId}`, {
|
| 436 |
+
method: 'PATCH',
|
| 437 |
+
headers: {
|
| 438 |
+
'Content-Type': 'application/json',
|
| 439 |
+
},
|
| 440 |
+
body: JSON.stringify(updates)
|
| 441 |
+
});
|
| 442 |
+
|
| 443 |
+
if (!response.ok) {
|
| 444 |
+
throw new Error(`프로젝트 수정 실패: ${response.status}`);
|
| 445 |
+
}
|
| 446 |
+
|
| 447 |
+
return await response.json();
|
| 448 |
+
};
|
| 449 |
+
|
| 450 |
+
// 사용 예시
|
| 451 |
+
updateProject(1, {
|
| 452 |
+
project_name: '수학 문제집 1단원 (최종)',
|
| 453 |
+
status: 'completed'
|
| 454 |
+
}).then(project => {
|
| 455 |
+
console.log('프로젝트 수정 완료:', project);
|
| 456 |
+
});
|
| 457 |
+
```
|
| 458 |
+
|
| 459 |
+
---
|
| 460 |
+
|
| 461 |
+
## 5. 프로젝트 삭제
|
| 462 |
+
|
| 463 |
+
프로젝트를 삭제합니다. **프로젝트 삭제 시 관련된 모든 페이지, 레이아웃 요소, 텍스트 등이 함께 삭제됩니다 (CASCADE).**
|
| 464 |
+
|
| 465 |
+
### Endpoint
|
| 466 |
+
|
| 467 |
+
```
|
| 468 |
+
DELETE /api/projects/{project_id}
|
| 469 |
+
```
|
| 470 |
+
|
| 471 |
+
### Path Parameters
|
| 472 |
+
|
| 473 |
+
| 파라미터 | 타입 | 설명 |
|
| 474 |
+
|----------|------|------|
|
| 475 |
+
| `project_id` | integer | 삭제할 프로젝트 ID |
|
| 476 |
+
|
| 477 |
+
### Response
|
| 478 |
+
|
| 479 |
+
**HTTP 204 No Content**
|
| 480 |
+
|
| 481 |
+
응답 본문(body)이 없습니다.
|
| 482 |
+
|
| 483 |
+
### 예제 코드
|
| 484 |
+
|
| 485 |
+
**JavaScript (fetch)**:
|
| 486 |
+
|
| 487 |
+
```javascript
|
| 488 |
+
const deleteProject = async (projectId) => {
|
| 489 |
+
const response = await fetch(`http://localhost:8000/api/projects/${projectId}`, {
|
| 490 |
+
method: 'DELETE'
|
| 491 |
+
});
|
| 492 |
+
|
| 493 |
+
if (!response.ok) {
|
| 494 |
+
if (response.status === 404) {
|
| 495 |
+
throw new Error('프로젝트를 찾을 수 없습니다.');
|
| 496 |
+
}
|
| 497 |
+
throw new Error(`프로젝트 삭제 실패: ${response.status}`);
|
| 498 |
+
}
|
| 499 |
+
|
| 500 |
+
return true;
|
| 501 |
+
};
|
| 502 |
+
|
| 503 |
+
// 사용 예시 (확인 다이얼로그 포함)
|
| 504 |
+
const handleDeleteProject = async (projectId, projectName) => {
|
| 505 |
+
const confirmed = confirm(`"${projectName}" 프로젝트를 삭제하시겠습니까?\n모든 페이지와 데이터가 함께 삭제됩니다.`);
|
| 506 |
+
|
| 507 |
+
if (confirmed) {
|
| 508 |
+
try {
|
| 509 |
+
await deleteProject(projectId);
|
| 510 |
+
alert('프로젝트가 삭제되었습니다.');
|
| 511 |
+
// 목록 새로고침 등
|
| 512 |
+
} catch (error) {
|
| 513 |
+
alert('프로젝트 삭제 실패: ' + error.message);
|
| 514 |
+
}
|
| 515 |
+
}
|
| 516 |
+
};
|
| 517 |
+
```
|
| 518 |
+
|
| 519 |
+
**React Component 예제**:
|
| 520 |
+
|
| 521 |
+
```jsx
|
| 522 |
+
import React, { useState } from 'react';
|
| 523 |
+
import axios from 'axios';
|
| 524 |
+
|
| 525 |
+
function ProjectDeleteButton({ projectId, projectName, onDeleted }) {
|
| 526 |
+
const [deleting, setDeleting] = useState(false);
|
| 527 |
+
|
| 528 |
+
const handleDelete = async () => {
|
| 529 |
+
if (!confirm(`"${projectName}"을(를) 삭제하시겠습니까?`)) {
|
| 530 |
+
return;
|
| 531 |
+
}
|
| 532 |
+
|
| 533 |
+
setDeleting(true);
|
| 534 |
+
|
| 535 |
+
try {
|
| 536 |
+
await axios.delete(`http://localhost:8000/api/projects/${projectId}`);
|
| 537 |
+
alert('프로젝트가 삭제되었습니다.');
|
| 538 |
+
if (onDeleted) onDeleted(projectId);
|
| 539 |
+
} catch (error) {
|
| 540 |
+
console.error('삭제 실패:', error);
|
| 541 |
+
alert('프로젝트 삭제 실패: ' + error.message);
|
| 542 |
+
} finally {
|
| 543 |
+
setDeleting(false);
|
| 544 |
+
}
|
| 545 |
+
};
|
| 546 |
+
|
| 547 |
+
return (
|
| 548 |
+
<button
|
| 549 |
+
onClick={handleDelete}
|
| 550 |
+
disabled={deleting}
|
| 551 |
+
className="btn btn-danger"
|
| 552 |
+
>
|
| 553 |
+
{deleting ? '삭제 중...' : '삭제'}
|
| 554 |
+
</button>
|
| 555 |
+
);
|
| 556 |
+
}
|
| 557 |
+
```
|
| 558 |
+
|
| 559 |
+
---
|
| 560 |
+
|
| 561 |
+
## 에러 응답
|
| 562 |
+
|
| 563 |
+
모든 프로젝트 API는 다음과 같은 에러 응답을 반환할 수 있습니다:
|
| 564 |
+
|
| 565 |
+
### 400 Bad Request
|
| 566 |
+
|
| 567 |
+
요청 데이터가 유효하지 않은 경우
|
| 568 |
+
|
| 569 |
+
```json
|
| 570 |
+
{
|
| 571 |
+
"error": "Validation Error",
|
| 572 |
+
"detail": "project_name은 1자 이상이어야 합니다.",
|
| 573 |
+
"status_code": 400
|
| 574 |
+
}
|
| 575 |
+
```
|
| 576 |
+
|
| 577 |
+
### 404 Not Found
|
| 578 |
+
|
| 579 |
+
프로젝트를 찾을 수 없는 경우
|
| 580 |
+
|
| 581 |
+
```json
|
| 582 |
+
{
|
| 583 |
+
"error": "프로젝트를 찾을 수 없습니다.",
|
| 584 |
+
"status_code": 404
|
| 585 |
+
}
|
| 586 |
+
```
|
| 587 |
+
|
| 588 |
+
### 500 Internal Server Error
|
| 589 |
+
|
| 590 |
+
서버 내부 오류
|
| 591 |
+
|
| 592 |
+
```json
|
| 593 |
+
{
|
| 594 |
+
"error": "Internal Server Error",
|
| 595 |
+
"detail": "데이터베이스 연결 실패",
|
| 596 |
+
"status_code": 500
|
| 597 |
+
}
|
| 598 |
+
```
|
| 599 |
+
|
| 600 |
+
자세한 에러 처리 방법은 [에러 처리 문서](./06_에러_처리.md)를 참고하세요.
|
| 601 |
+
|
| 602 |
+
---
|
| 603 |
+
|
| 604 |
+
## 다음 단계
|
| 605 |
+
|
| 606 |
+
- **[페이지 API](./02_페이지_API.md)**: 페이지 업로드 및 관리
|
| 607 |
+
- **[분석 API](./03_분석_API.md)**: 프로젝트 분석 실행
|
| 608 |
+
- **[데이터 모델](./05_데이터_모델.md)**: 프로젝트 스키마 상세 정보
|
docs/Backend API 문서/02_페이지_API.md
ADDED
|
@@ -0,0 +1,772 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# 페이지 API
|
| 2 |
+
|
| 3 |
+
페이지는 프로젝트를 구성하는 개별 문서 페이지입니다. 이미지 또는 PDF 형식으로 업로드할 수 있으며, 각 페이지는 레이아웃 분석 및 OCR 처리 대상이 됩니다.
|
| 4 |
+
|
| 5 |
+
## 📖 목차
|
| 6 |
+
|
| 7 |
+
- [엔드포인트 목록](#엔드포인트-목록)
|
| 8 |
+
- [1. 페이지 업로드 (이미지/PDF)](#1-페이지-업로드-이미지pdf)
|
| 9 |
+
- [2. 페이지 상세 조회](#2-페이지-상세-조회)
|
| 10 |
+
- [3. 프로젝트 페이지 목록 조회](#3-프로젝트-페이지-목록-조회)
|
| 11 |
+
- [4. 페이지 텍스트 조회](#4-페이지-텍스트-조회)
|
| 12 |
+
- [5. 페이지 텍스트 저장 (사용자 편집)](#5-페이지-텍스트-저장-사용자-편집)
|
| 13 |
+
|
| 14 |
+
---
|
| 15 |
+
|
| 16 |
+
## 엔드포인트 목록
|
| 17 |
+
|
| 18 |
+
| Method | Endpoint | 설명 |
|
| 19 |
+
|--------|----------|------|
|
| 20 |
+
| POST | `/api/pages/upload` | 이미지 또는 PDF 업로드 |
|
| 21 |
+
| GET | `/api/pages/{page_id}` | 페이지 상세 조회 |
|
| 22 |
+
| GET | `/api/pages/project/{project_id}` | 프로젝트의 모든 페이지 조회 |
|
| 23 |
+
| GET | `/api/pages/{page_id}/text` | 페이지 텍스트 조회 (최신 버전) |
|
| 24 |
+
| POST | `/api/pages/{page_id}/text` | 사용자 편집 텍스트 저장 |
|
| 25 |
+
|
| 26 |
+
---
|
| 27 |
+
|
| 28 |
+
## 1. 페이지 업로드 (이미지/PDF)
|
| 29 |
+
|
| 30 |
+
이미지 파일 또는 PDF 파일을 업로드하여 페이지를 생성합니다.
|
| 31 |
+
|
| 32 |
+
### Endpoint
|
| 33 |
+
|
| 34 |
+
```http
|
| 35 |
+
POST /api/pages/upload
|
| 36 |
+
```
|
| 37 |
+
|
| 38 |
+
### Request
|
| 39 |
+
|
| 40 |
+
**Content-Type**: `multipart/form-data`
|
| 41 |
+
|
| 42 |
+
#### 이미지 업로드 (단일 페이지)
|
| 43 |
+
|
| 44 |
+
| 필드 | 타입 | 필수 | 설명 |
|
| 45 |
+
|------|------|------|------|
|
| 46 |
+
| `project_id` | integer | ✅ | 프로젝트 ID |
|
| 47 |
+
| `page_number` | integer | ✅ | 페이지 번호 (1부터 시작) |
|
| 48 |
+
| `file` | file | ✅ | 이미지 파일 (PNG, JPG, JPEG) |
|
| 49 |
+
|
| 50 |
+
#### PDF 업로드 (다중 페이지 자동 생성)
|
| 51 |
+
|
| 52 |
+
| 필드 | 타입 | 필수 | 설명 |
|
| 53 |
+
|------|------|------|------|
|
| 54 |
+
| `project_id` | integer | ✅ | 프로젝트 ID |
|
| 55 |
+
| `file` | file | ✅ | PDF 파일 |
|
| 56 |
+
| `page_number` | integer | ❌ | 시작 페이지 번호 (선택, 기본값: 자동 계산) |
|
| 57 |
+
|
| 58 |
+
### Response
|
| 59 |
+
|
| 60 |
+
#### 이미지 업로드 응답
|
| 61 |
+
|
| 62 |
+
**HTTP 201 Created**
|
| 63 |
+
|
| 64 |
+
```json
|
| 65 |
+
{
|
| 66 |
+
"page_id": 1,
|
| 67 |
+
"project_id": 1,
|
| 68 |
+
"page_number": 1,
|
| 69 |
+
"image_path": "uploads/project_1_page_1_abc123.png",
|
| 70 |
+
"image_width": 2480,
|
| 71 |
+
"image_height": 3508,
|
| 72 |
+
"analysis_status": "pending",
|
| 73 |
+
"processing_time": null,
|
| 74 |
+
"created_at": "2025-01-22T10:31:00",
|
| 75 |
+
"analyzed_at": null
|
| 76 |
+
}
|
| 77 |
+
```
|
| 78 |
+
|
| 79 |
+
#### PDF 업로드 응답
|
| 80 |
+
|
| 81 |
+
**HTTP 201 Created**
|
| 82 |
+
|
| 83 |
+
```json
|
| 84 |
+
{
|
| 85 |
+
"project_id": 1,
|
| 86 |
+
"total_created": 5,
|
| 87 |
+
"source_type": "pdf",
|
| 88 |
+
"pages": [
|
| 89 |
+
{
|
| 90 |
+
"page_id": 1,
|
| 91 |
+
"project_id": 1,
|
| 92 |
+
"page_number": 1,
|
| 93 |
+
"image_path": "uploads/3/page_1.png",
|
| 94 |
+
"image_width": 2480,
|
| 95 |
+
"image_height": 3508,
|
| 96 |
+
"analysis_status": "pending",
|
| 97 |
+
"processing_time": null,
|
| 98 |
+
"created_at": "2025-01-22T10:31:00",
|
| 99 |
+
"analyzed_at": null
|
| 100 |
+
},
|
| 101 |
+
{
|
| 102 |
+
"page_id": 2,
|
| 103 |
+
"project_id": 1,
|
| 104 |
+
"page_number": 2,
|
| 105 |
+
"image_path": "uploads/3/page_2.png",
|
| 106 |
+
"image_width": 2480,
|
| 107 |
+
"image_height": 3508,
|
| 108 |
+
"analysis_status": "pending",
|
| 109 |
+
"processing_time": null,
|
| 110 |
+
"created_at": "2025-01-22T10:31:01",
|
| 111 |
+
"analyzed_at": null
|
| 112 |
+
}
|
| 113 |
+
// ... 나머지 페이지들
|
| 114 |
+
]
|
| 115 |
+
}
|
| 116 |
+
```
|
| 117 |
+
|
| 118 |
+
**응답 필드**:
|
| 119 |
+
|
| 120 |
+
| 필드 | 타입 | 설명 |
|
| 121 |
+
|------|------|------|
|
| 122 |
+
| `page_id` | integer | 생성된 페이지 고유 ID |
|
| 123 |
+
| `project_id` | integer | 소속 프로젝트 ID |
|
| 124 |
+
| `page_number` | integer | 페이지 번호 |
|
| 125 |
+
| `image_path` | string | 저장된 이미지 파일 경로 |
|
| 126 |
+
| `image_width` | integer | 이미지 너비 (픽셀) |
|
| 127 |
+
| `image_height` | integer | 이미지 높이 (픽셀) |
|
| 128 |
+
| `analysis_status` | string | 분석 상태 (`pending`, `processing`, `completed`, `error`) |
|
| 129 |
+
| `processing_time` | float | 처리 시간 (초, 분석 완료 후 설정) |
|
| 130 |
+
| `created_at` | datetime | 페이지 생성일시 |
|
| 131 |
+
| `analyzed_at` | datetime | 분석 완료일시 |
|
| 132 |
+
|
| 133 |
+
### 예제 코드
|
| 134 |
+
|
| 135 |
+
#### JavaScript - 이미지 업로드
|
| 136 |
+
|
| 137 |
+
```javascript
|
| 138 |
+
const uploadImage = async (projectId, pageNumber, imageFile) => {
|
| 139 |
+
const formData = new FormData();
|
| 140 |
+
formData.append('project_id', projectId);
|
| 141 |
+
formData.append('page_number', pageNumber);
|
| 142 |
+
formData.append('file', imageFile);
|
| 143 |
+
|
| 144 |
+
const response = await fetch('http://localhost:8000/api/pages/upload', {
|
| 145 |
+
method: 'POST',
|
| 146 |
+
body: formData
|
| 147 |
+
});
|
| 148 |
+
|
| 149 |
+
if (!response.ok) {
|
| 150 |
+
throw new Error(`이미지 업로드 실패: ${response.status}`);
|
| 151 |
+
}
|
| 152 |
+
|
| 153 |
+
return await response.json();
|
| 154 |
+
};
|
| 155 |
+
|
| 156 |
+
// 사용 예시
|
| 157 |
+
const fileInput = document.getElementById('imageFile');
|
| 158 |
+
const imageFile = fileInput.files[0];
|
| 159 |
+
|
| 160 |
+
uploadImage(1, 1, imageFile)
|
| 161 |
+
.then(page => {
|
| 162 |
+
console.log('페이지 생성됨:', page.page_id);
|
| 163 |
+
console.log('이미지 크기:', page.image_width, 'x', page.image_height);
|
| 164 |
+
})
|
| 165 |
+
.catch(error => console.error('업로드 실패:', error));
|
| 166 |
+
```
|
| 167 |
+
|
| 168 |
+
#### JavaScript - PDF 업로드
|
| 169 |
+
|
| 170 |
+
```javascript
|
| 171 |
+
const uploadPDF = async (projectId, pdfFile) => {
|
| 172 |
+
const formData = new FormData();
|
| 173 |
+
formData.append('project_id', projectId);
|
| 174 |
+
formData.append('file', pdfFile);
|
| 175 |
+
|
| 176 |
+
const response = await fetch('http://localhost:8000/api/pages/upload', {
|
| 177 |
+
method: 'POST',
|
| 178 |
+
body: formData
|
| 179 |
+
});
|
| 180 |
+
|
| 181 |
+
if (!response.ok) {
|
| 182 |
+
throw new Error(`PDF 업로드 실패: ${response.status}`);
|
| 183 |
+
}
|
| 184 |
+
|
| 185 |
+
return await response.json();
|
| 186 |
+
};
|
| 187 |
+
|
| 188 |
+
// 사용 예시
|
| 189 |
+
const fileInput = document.getElementById('pdfFile');
|
| 190 |
+
const pdfFile = fileInput.files[0];
|
| 191 |
+
|
| 192 |
+
uploadPDF(1, pdfFile)
|
| 193 |
+
.then(result => {
|
| 194 |
+
console.log(`PDF 업로드 완료: ${result.total_created}개 페이지 생성됨`);
|
| 195 |
+
result.pages.forEach((page, index) => {
|
| 196 |
+
console.log(` 페이지 ${index + 1}: ${page.image_path}`);
|
| 197 |
+
});
|
| 198 |
+
})
|
| 199 |
+
.catch(error => console.error('업로드 실패:', error));
|
| 200 |
+
```
|
| 201 |
+
|
| 202 |
+
#### React Component - 파일 업로드
|
| 203 |
+
|
| 204 |
+
```jsx
|
| 205 |
+
import React, { useState } from 'react';
|
| 206 |
+
import axios from 'axios';
|
| 207 |
+
|
| 208 |
+
function FileUploader({ projectId, onUploadComplete }) {
|
| 209 |
+
const [uploading, setUploading] = useState(false);
|
| 210 |
+
const [progress, setProgress] = useState(0);
|
| 211 |
+
|
| 212 |
+
const handleFileUpload = async (event) => {
|
| 213 |
+
const file = event.target.files[0];
|
| 214 |
+
if (!file) return;
|
| 215 |
+
|
| 216 |
+
const formData = new FormData();
|
| 217 |
+
formData.append('project_id', projectId);
|
| 218 |
+
formData.append('file', file);
|
| 219 |
+
|
| 220 |
+
// 이미지 파일인 경우 page_number 필요
|
| 221 |
+
if (file.type.startsWith('image/')) {
|
| 222 |
+
const pageNumber = prompt('페이지 번호를 입력하세요:');
|
| 223 |
+
if (!pageNumber) return;
|
| 224 |
+
formData.append('page_number', pageNumber);
|
| 225 |
+
}
|
| 226 |
+
|
| 227 |
+
setUploading(true);
|
| 228 |
+
|
| 229 |
+
try {
|
| 230 |
+
const response = await axios.post(
|
| 231 |
+
'http://localhost:8000/api/pages/upload',
|
| 232 |
+
formData,
|
| 233 |
+
{
|
| 234 |
+
headers: { 'Content-Type': 'multipart/form-data' },
|
| 235 |
+
onUploadProgress: (progressEvent) => {
|
| 236 |
+
const percentCompleted = Math.round(
|
| 237 |
+
(progressEvent.loaded * 100) / progressEvent.total
|
| 238 |
+
);
|
| 239 |
+
setProgress(percentCompleted);
|
| 240 |
+
}
|
| 241 |
+
}
|
| 242 |
+
);
|
| 243 |
+
|
| 244 |
+
if (response.data.source_type === 'pdf') {
|
| 245 |
+
alert(`${response.data.total_created}개 페이지가 생성되었습니다.`);
|
| 246 |
+
} else {
|
| 247 |
+
alert('페이지가 생성되었습니다.');
|
| 248 |
+
}
|
| 249 |
+
|
| 250 |
+
if (onUploadComplete) onUploadComplete(response.data);
|
| 251 |
+
|
| 252 |
+
} catch (error) {
|
| 253 |
+
console.error('업로드 실패:', error);
|
| 254 |
+
alert('파일 업로드 실패: ' + error.message);
|
| 255 |
+
} finally {
|
| 256 |
+
setUploading(false);
|
| 257 |
+
setProgress(0);
|
| 258 |
+
}
|
| 259 |
+
};
|
| 260 |
+
|
| 261 |
+
return (
|
| 262 |
+
<div>
|
| 263 |
+
<input
|
| 264 |
+
type="file"
|
| 265 |
+
accept=".pdf,.png,.jpg,.jpeg"
|
| 266 |
+
onChange={handleFileUpload}
|
| 267 |
+
disabled={uploading}
|
| 268 |
+
/>
|
| 269 |
+
{uploading && (
|
| 270 |
+
<div>
|
| 271 |
+
<progress value={progress} max="100" />
|
| 272 |
+
<span>{progress}%</span>
|
| 273 |
+
</div>
|
| 274 |
+
)}
|
| 275 |
+
</div>
|
| 276 |
+
);
|
| 277 |
+
}
|
| 278 |
+
```
|
| 279 |
+
|
| 280 |
+
---
|
| 281 |
+
|
| 282 |
+
## 2. 페이지 상세 조회
|
| 283 |
+
|
| 284 |
+
특정 페이지의 상세 정보를 조회합니다.
|
| 285 |
+
|
| 286 |
+
### Endpoint
|
| 287 |
+
|
| 288 |
+
```http
|
| 289 |
+
GET /api/pages/{page_id}
|
| 290 |
+
```
|
| 291 |
+
|
| 292 |
+
### Path Parameters
|
| 293 |
+
|
| 294 |
+
| 파라미터 | 타입 | 설명 |
|
| 295 |
+
|----------|------|------|
|
| 296 |
+
| `page_id` | integer | 조회할 페이지 ID |
|
| 297 |
+
|
| 298 |
+
### Response
|
| 299 |
+
|
| 300 |
+
**HTTP 200 OK**
|
| 301 |
+
|
| 302 |
+
```json
|
| 303 |
+
{
|
| 304 |
+
"page_id": 1,
|
| 305 |
+
"project_id": 1,
|
| 306 |
+
"page_number": 1,
|
| 307 |
+
"image_path": "uploads/project_1_page_1_abc123.png",
|
| 308 |
+
"image_width": 2480,
|
| 309 |
+
"image_height": 3508,
|
| 310 |
+
"analysis_status": "completed",
|
| 311 |
+
"processing_time": 5.23,
|
| 312 |
+
"created_at": "2025-01-22T10:31:00",
|
| 313 |
+
"analyzed_at": "2025-01-22T10:35:00"
|
| 314 |
+
}
|
| 315 |
+
```
|
| 316 |
+
|
| 317 |
+
### 예제 코드
|
| 318 |
+
|
| 319 |
+
```javascript
|
| 320 |
+
const getPageDetail = async (pageId) => {
|
| 321 |
+
const response = await fetch(`http://localhost:8000/api/pages/${pageId}`);
|
| 322 |
+
|
| 323 |
+
if (!response.ok) {
|
| 324 |
+
if (response.status === 404) {
|
| 325 |
+
throw new Error('페이지를 찾을 수 없습니다.');
|
| 326 |
+
}
|
| 327 |
+
throw new Error(`페이지 조회 실패: ${response.status}`);
|
| 328 |
+
}
|
| 329 |
+
|
| 330 |
+
return await response.json();
|
| 331 |
+
};
|
| 332 |
+
|
| 333 |
+
// 사용 예시
|
| 334 |
+
getPageDetail(1).then(page => {
|
| 335 |
+
console.log('페이지:', page.page_number);
|
| 336 |
+
console.log('분석 상태:', page.analysis_status);
|
| 337 |
+
console.log('처리 시간:', page.processing_time, '초');
|
| 338 |
+
});
|
| 339 |
+
```
|
| 340 |
+
|
| 341 |
+
---
|
| 342 |
+
|
| 343 |
+
## 3. 프로젝트 페이지 목록 조회
|
| 344 |
+
|
| 345 |
+
프로젝트에 속한 모든 페이지를 조회합니다.
|
| 346 |
+
|
| 347 |
+
### Endpoint
|
| 348 |
+
|
| 349 |
+
```http
|
| 350 |
+
GET /api/pages/project/{project_id}
|
| 351 |
+
```
|
| 352 |
+
|
| 353 |
+
### Path Parameters
|
| 354 |
+
|
| 355 |
+
| 파라미터 | 타입 | 설명 |
|
| 356 |
+
|----------|------|------|
|
| 357 |
+
| `project_id` | integer | 프로젝트 ID |
|
| 358 |
+
|
| 359 |
+
### Query Parameters
|
| 360 |
+
|
| 361 |
+
| 파라미터 | 타입 | 필수 | 설명 |
|
| 362 |
+
|----------|------|------|------|
|
| 363 |
+
| `include_error` | boolean | ❌ | 에러 상태 페이지 포함 여부 (기본값: false) |
|
| 364 |
+
|
| 365 |
+
### Response
|
| 366 |
+
|
| 367 |
+
**HTTP 200 OK**
|
| 368 |
+
|
| 369 |
+
```json
|
| 370 |
+
[
|
| 371 |
+
{
|
| 372 |
+
"page_id": 1,
|
| 373 |
+
"project_id": 1,
|
| 374 |
+
"page_number": 1,
|
| 375 |
+
"image_path": "uploads/project_1_page_1_abc123.png",
|
| 376 |
+
"image_width": 2480,
|
| 377 |
+
"image_height": 3508,
|
| 378 |
+
"analysis_status": "completed",
|
| 379 |
+
"processing_time": 5.23,
|
| 380 |
+
"created_at": "2025-01-22T10:31:00",
|
| 381 |
+
"analyzed_at": "2025-01-22T10:35:00"
|
| 382 |
+
},
|
| 383 |
+
{
|
| 384 |
+
"page_id": 2,
|
| 385 |
+
"project_id": 1,
|
| 386 |
+
"page_number": 2,
|
| 387 |
+
"image_path": "uploads/project_1_page_2_def456.png",
|
| 388 |
+
"image_width": 2480,
|
| 389 |
+
"image_height": 3508,
|
| 390 |
+
"analysis_status": "completed",
|
| 391 |
+
"processing_time": 4.87,
|
| 392 |
+
"created_at": "2025-01-22T10:32:00",
|
| 393 |
+
"analyzed_at": "2025-01-22T10:36:00"
|
| 394 |
+
}
|
| 395 |
+
]
|
| 396 |
+
```
|
| 397 |
+
|
| 398 |
+
### 예제 코드
|
| 399 |
+
|
| 400 |
+
```javascript
|
| 401 |
+
const getProjectPages = async (projectId, includeError = false) => {
|
| 402 |
+
const params = new URLSearchParams({ include_error: includeError });
|
| 403 |
+
const response = await fetch(
|
| 404 |
+
`http://localhost:8000/api/pages/project/${projectId}?${params}`
|
| 405 |
+
);
|
| 406 |
+
|
| 407 |
+
if (!response.ok) {
|
| 408 |
+
throw new Error(`페이지 조회 실패: ${response.status}`);
|
| 409 |
+
}
|
| 410 |
+
|
| 411 |
+
return await response.json();
|
| 412 |
+
};
|
| 413 |
+
|
| 414 |
+
// 사용 예시
|
| 415 |
+
getProjectPages(1, false).then(pages => {
|
| 416 |
+
console.log(`총 ${pages.length}개 페이지`);
|
| 417 |
+
|
| 418 |
+
const completedPages = pages.filter(p => p.analysis_status === 'completed');
|
| 419 |
+
const pendingPages = pages.filter(p => p.analysis_status === 'pending');
|
| 420 |
+
|
| 421 |
+
console.log(`완료: ${completedPages.length}, 대기: ${pendingPages.length}`);
|
| 422 |
+
});
|
| 423 |
+
```
|
| 424 |
+
|
| 425 |
+
---
|
| 426 |
+
|
| 427 |
+
## 4. 페이지 텍스트 조회
|
| 428 |
+
|
| 429 |
+
페이지의 최신 텍스트 버전을 조회합니다. `is_current=True`인 버전이 반환됩니다.
|
| 430 |
+
|
| 431 |
+
### Endpoint
|
| 432 |
+
|
| 433 |
+
```http
|
| 434 |
+
GET /api/pages/{page_id}/text
|
| 435 |
+
```
|
| 436 |
+
|
| 437 |
+
### Path Parameters
|
| 438 |
+
|
| 439 |
+
| 파라미터 | 타입 | 설명 |
|
| 440 |
+
|----------|------|------|
|
| 441 |
+
| `page_id` | integer | 페이지 ID |
|
| 442 |
+
|
| 443 |
+
### Response
|
| 444 |
+
|
| 445 |
+
**HTTP 200 OK**
|
| 446 |
+
|
| 447 |
+
```json
|
| 448 |
+
{
|
| 449 |
+
"page_id": 1,
|
| 450 |
+
"version_id": 3,
|
| 451 |
+
"version_type": "user_edited",
|
| 452 |
+
"is_current": true,
|
| 453 |
+
"content": "<h2>1. 다음 식을 계산하시오.</h2>\n<p>(1) 3 + 5 = ?</p>\n<p>답: 8</p>",
|
| 454 |
+
"created_at": "2025-01-22T11:00:00"
|
| 455 |
+
}
|
| 456 |
+
```
|
| 457 |
+
|
| 458 |
+
**응답 필드**:
|
| 459 |
+
|
| 460 |
+
| 필드 | 타입 | 설명 |
|
| 461 |
+
|------|------|------|
|
| 462 |
+
| `page_id` | integer | 페이지 ID |
|
| 463 |
+
| `version_id` | integer | 텍스트 버전 ID |
|
| 464 |
+
| `version_type` | string | 버전 유형<br>- `original`: 원본 OCR 결과<br>- `auto_formatted`: 자동 포맷팅 적용<br>- `user_edited`: 사용자 편집 |
|
| 465 |
+
| `is_current` | boolean | 현재 버전 여부 (항상 true) |
|
| 466 |
+
| `content` | string | HTML 형식의 텍스트 내용 |
|
| 467 |
+
| `created_at` | datetime | 버전 생성일시 |
|
| 468 |
+
|
| 469 |
+
### 예제 코드
|
| 470 |
+
|
| 471 |
+
```javascript
|
| 472 |
+
const getPageText = async (pageId) => {
|
| 473 |
+
const response = await fetch(`http://localhost:8000/api/pages/${pageId}/text`);
|
| 474 |
+
|
| 475 |
+
if (!response.ok) {
|
| 476 |
+
if (response.status === 404) {
|
| 477 |
+
throw new Error('페이지 텍스트를 찾을 수 없습니다.');
|
| 478 |
+
}
|
| 479 |
+
throw new Error(`텍스트 조회 실패: ${response.status}`);
|
| 480 |
+
}
|
| 481 |
+
|
| 482 |
+
return await response.json();
|
| 483 |
+
};
|
| 484 |
+
|
| 485 |
+
// 사용 예시
|
| 486 |
+
getPageText(1).then(textData => {
|
| 487 |
+
console.log('버전:', textData.version_type);
|
| 488 |
+
console.log('내용:', textData.content);
|
| 489 |
+
|
| 490 |
+
// HTML 표시
|
| 491 |
+
document.getElementById('textEditor').innerHTML = textData.content;
|
| 492 |
+
});
|
| 493 |
+
```
|
| 494 |
+
|
| 495 |
+
#### React Component - 텍스트 뷰어
|
| 496 |
+
|
| 497 |
+
```jsx
|
| 498 |
+
import React, { useState, useEffect } from 'react';
|
| 499 |
+
import axios from 'axios';
|
| 500 |
+
|
| 501 |
+
function PageTextViewer({ pageId }) {
|
| 502 |
+
const [textData, setTextData] = useState(null);
|
| 503 |
+
const [loading, setLoading] = useState(true);
|
| 504 |
+
|
| 505 |
+
useEffect(() => {
|
| 506 |
+
const fetchText = async () => {
|
| 507 |
+
try {
|
| 508 |
+
const response = await axios.get(
|
| 509 |
+
`http://localhost:8000/api/pages/${pageId}/text`
|
| 510 |
+
);
|
| 511 |
+
setTextData(response.data);
|
| 512 |
+
} catch (error) {
|
| 513 |
+
console.error('텍스트 조회 실패:', error);
|
| 514 |
+
} finally {
|
| 515 |
+
setLoading(false);
|
| 516 |
+
}
|
| 517 |
+
};
|
| 518 |
+
|
| 519 |
+
fetchText();
|
| 520 |
+
}, [pageId]);
|
| 521 |
+
|
| 522 |
+
if (loading) return <div>로딩 중...</div>;
|
| 523 |
+
if (!textData) return <div>텍스트를 찾을 수 없습니다.</div>;
|
| 524 |
+
|
| 525 |
+
return (
|
| 526 |
+
<div>
|
| 527 |
+
<div className="text-meta">
|
| 528 |
+
<span>버전: {textData.version_type}</span>
|
| 529 |
+
<span>생성: {new Date(textData.created_at).toLocaleString()}</span>
|
| 530 |
+
</div>
|
| 531 |
+
<div
|
| 532 |
+
className="text-content"
|
| 533 |
+
dangerouslySetInnerHTML={{ __html: textData.content }}
|
| 534 |
+
/>
|
| 535 |
+
</div>
|
| 536 |
+
);
|
| 537 |
+
}
|
| 538 |
+
```
|
| 539 |
+
|
| 540 |
+
---
|
| 541 |
+
|
| 542 |
+
## 5. 페이지 텍스트 저장 (사용자 편집)
|
| 543 |
+
|
| 544 |
+
사용자가 편집한 텍스트를 새로운 버전으로 저장합니다.
|
| 545 |
+
|
| 546 |
+
### Endpoint
|
| 547 |
+
|
| 548 |
+
```http
|
| 549 |
+
POST /api/pages/{page_id}/text
|
| 550 |
+
```
|
| 551 |
+
|
| 552 |
+
### Path Parameters
|
| 553 |
+
|
| 554 |
+
| 파라미터 | 타입 | 설명 |
|
| 555 |
+
|----------|------|------|
|
| 556 |
+
| `page_id` | integer | 페이지 ID |
|
| 557 |
+
|
| 558 |
+
### Request Body
|
| 559 |
+
|
| 560 |
+
```json
|
| 561 |
+
{
|
| 562 |
+
"content": "<h2>1. 다음 식을 계산하시오.</h2>\n<p>(1) 3 + 5 = ?</p>\n<p>답: 8</p>",
|
| 563 |
+
"user_id": 1
|
| 564 |
+
}
|
| 565 |
+
```
|
| 566 |
+
|
| 567 |
+
**필드 설명**:
|
| 568 |
+
|
| 569 |
+
| 필드 | 타입 | 필수 | 설명 |
|
| 570 |
+
|------|------|------|------|
|
| 571 |
+
| `content` | string | ✅ | 저장할 텍스트 내용 (HTML 형식) |
|
| 572 |
+
| `user_id` | integer | ❌ | 수정한 사용자 ID (선택) |
|
| 573 |
+
|
| 574 |
+
### Response
|
| 575 |
+
|
| 576 |
+
**HTTP 200 OK**
|
| 577 |
+
|
| 578 |
+
```json
|
| 579 |
+
{
|
| 580 |
+
"page_id": 1,
|
| 581 |
+
"version_id": 4,
|
| 582 |
+
"version_type": "user_edited",
|
| 583 |
+
"is_current": true,
|
| 584 |
+
"content": "<h2>1. 다음 식을 계산하시오.</h2>\n<p>(1) 3 + 5 = ?</p>\n<p>답: 8</p>",
|
| 585 |
+
"created_at": "2025-01-22T11:10:00"
|
| 586 |
+
}
|
| 587 |
+
```
|
| 588 |
+
|
| 589 |
+
### 동작 방식
|
| 590 |
+
|
| 591 |
+
1. 새로운 텍스트 버전을 생성 (`version_type="user_edited"`)
|
| 592 |
+
2. 기존의 `is_current=True` 버전을 `False`로 변경
|
| 593 |
+
3. 새 버전을 `is_current=True`로 설정
|
| 594 |
+
4. 버전 번호 자동 증가
|
| 595 |
+
|
| 596 |
+
### 예제 코드
|
| 597 |
+
|
| 598 |
+
```javascript
|
| 599 |
+
const savePageText = async (pageId, content, userId = null) => {
|
| 600 |
+
const response = await fetch(`http://localhost:8000/api/pages/${pageId}/text`, {
|
| 601 |
+
method: 'POST',
|
| 602 |
+
headers: {
|
| 603 |
+
'Content-Type': 'application/json',
|
| 604 |
+
},
|
| 605 |
+
body: JSON.stringify({
|
| 606 |
+
content: content,
|
| 607 |
+
user_id: userId
|
| 608 |
+
})
|
| 609 |
+
});
|
| 610 |
+
|
| 611 |
+
if (!response.ok) {
|
| 612 |
+
throw new Error(`텍스트 저장 실패: ${response.status}`);
|
| 613 |
+
}
|
| 614 |
+
|
| 615 |
+
return await response.json();
|
| 616 |
+
};
|
| 617 |
+
|
| 618 |
+
// 사용 예시
|
| 619 |
+
const editorContent = document.getElementById('textEditor').innerHTML;
|
| 620 |
+
|
| 621 |
+
savePageText(1, editorContent, 1)
|
| 622 |
+
.then(result => {
|
| 623 |
+
console.log('텍스트 저장 완료');
|
| 624 |
+
console.log('새 버전 ID:', result.version_id);
|
| 625 |
+
alert('저장되었습니다.');
|
| 626 |
+
})
|
| 627 |
+
.catch(error => {
|
| 628 |
+
console.error('저장 실패:', error);
|
| 629 |
+
alert('저장 실패: ' + error.message);
|
| 630 |
+
});
|
| 631 |
+
```
|
| 632 |
+
|
| 633 |
+
#### React Component - TinyMCE 편집기
|
| 634 |
+
|
| 635 |
+
```jsx
|
| 636 |
+
import React, { useState, useEffect } from 'react';
|
| 637 |
+
import { Editor } from '@tinymce/tinymce-react';
|
| 638 |
+
import axios from 'axios';
|
| 639 |
+
|
| 640 |
+
function PageTextEditor({ pageId, userId }) {
|
| 641 |
+
const [content, setContent] = useState('');
|
| 642 |
+
const [saving, setSaving] = useState(false);
|
| 643 |
+
const [versionId, setVersionId] = useState(null);
|
| 644 |
+
|
| 645 |
+
useEffect(() => {
|
| 646 |
+
const fetchText = async () => {
|
| 647 |
+
try {
|
| 648 |
+
const response = await axios.get(
|
| 649 |
+
`http://localhost:8000/api/pages/${pageId}/text`
|
| 650 |
+
);
|
| 651 |
+
setContent(response.data.content);
|
| 652 |
+
setVersionId(response.data.version_id);
|
| 653 |
+
} catch (error) {
|
| 654 |
+
console.error('텍스트 로드 실패:', error);
|
| 655 |
+
}
|
| 656 |
+
};
|
| 657 |
+
|
| 658 |
+
fetchText();
|
| 659 |
+
}, [pageId]);
|
| 660 |
+
|
| 661 |
+
const handleSave = async () => {
|
| 662 |
+
setSaving(true);
|
| 663 |
+
|
| 664 |
+
try {
|
| 665 |
+
const response = await axios.post(
|
| 666 |
+
`http://localhost:8000/api/pages/${pageId}/text`,
|
| 667 |
+
{
|
| 668 |
+
content: content,
|
| 669 |
+
user_id: userId
|
| 670 |
+
}
|
| 671 |
+
);
|
| 672 |
+
|
| 673 |
+
setVersionId(response.data.version_id);
|
| 674 |
+
alert('저장되었습니다.');
|
| 675 |
+
|
| 676 |
+
} catch (error) {
|
| 677 |
+
console.error('저장 실패:', error);
|
| 678 |
+
alert('저장 실패: ' + error.message);
|
| 679 |
+
} finally {
|
| 680 |
+
setSaving(false);
|
| 681 |
+
}
|
| 682 |
+
};
|
| 683 |
+
|
| 684 |
+
return (
|
| 685 |
+
<div>
|
| 686 |
+
<div className="editor-header">
|
| 687 |
+
<span>버전 ID: {versionId}</span>
|
| 688 |
+
<button onClick={handleSave} disabled={saving}>
|
| 689 |
+
{saving ? '저장 중...' : '저장'}
|
| 690 |
+
</button>
|
| 691 |
+
</div>
|
| 692 |
+
|
| 693 |
+
<Editor
|
| 694 |
+
apiKey="your-tinymce-api-key"
|
| 695 |
+
value={content}
|
| 696 |
+
onEditorChange={setContent}
|
| 697 |
+
init={{
|
| 698 |
+
height: 500,
|
| 699 |
+
menubar: false,
|
| 700 |
+
plugins: [
|
| 701 |
+
'advlist', 'autolink', 'lists', 'link', 'image',
|
| 702 |
+
'charmap', 'preview', 'anchor', 'searchreplace',
|
| 703 |
+
'visualblocks', 'code', 'fullscreen',
|
| 704 |
+
'insertdatetime', 'media', 'table', 'help', 'wordcount'
|
| 705 |
+
],
|
| 706 |
+
toolbar: 'undo redo | formatselect | bold italic | ' +
|
| 707 |
+
'alignleft aligncenter alignright | ' +
|
| 708 |
+
'bullist numlist outdent indent | help'
|
| 709 |
+
}}
|
| 710 |
+
/>
|
| 711 |
+
</div>
|
| 712 |
+
);
|
| 713 |
+
}
|
| 714 |
+
```
|
| 715 |
+
|
| 716 |
+
---
|
| 717 |
+
|
| 718 |
+
## 에러 응답
|
| 719 |
+
|
| 720 |
+
### 400 Bad Request
|
| 721 |
+
|
| 722 |
+
요청 데이터가 유효하지 않은 경우
|
| 723 |
+
|
| 724 |
+
```json
|
| 725 |
+
{
|
| 726 |
+
"error": "이미지 업로드 시 page_number는 필수입니다.",
|
| 727 |
+
"status_code": 400
|
| 728 |
+
}
|
| 729 |
+
```
|
| 730 |
+
|
| 731 |
+
### 404 Not Found
|
| 732 |
+
|
| 733 |
+
페이지를 찾을 수 없는 경우
|
| 734 |
+
|
| 735 |
+
```json
|
| 736 |
+
{
|
| 737 |
+
"error": "페이지를 찾을 수 없습니다.",
|
| 738 |
+
"status_code": 404
|
| 739 |
+
}
|
| 740 |
+
```
|
| 741 |
+
|
| 742 |
+
### 413 Payload Too Large
|
| 743 |
+
|
| 744 |
+
파일 크기가 너무 큰 경우 (일반적으로 50MB 제한)
|
| 745 |
+
|
| 746 |
+
```json
|
| 747 |
+
{
|
| 748 |
+
"error": "파일 크기가 너무 큽니다.",
|
| 749 |
+
"detail": "최대 50MB까지 업로드 가능합니다.",
|
| 750 |
+
"status_code": 413
|
| 751 |
+
}
|
| 752 |
+
```
|
| 753 |
+
|
| 754 |
+
### 500 Internal Server Error
|
| 755 |
+
|
| 756 |
+
서버 내부 오류
|
| 757 |
+
|
| 758 |
+
```json
|
| 759 |
+
{
|
| 760 |
+
"error": "Internal Server Error",
|
| 761 |
+
"detail": "이미지 처리 중 오류가 발생했습니다.",
|
| 762 |
+
"status_code": 500
|
| 763 |
+
}
|
| 764 |
+
```
|
| 765 |
+
|
| 766 |
+
---
|
| 767 |
+
|
| 768 |
+
## 다음 단계
|
| 769 |
+
|
| 770 |
+
- **[분석 API](./03_분석_API.md)**: 업로드한 페이지 분석하기
|
| 771 |
+
- **[다운로드 API](./04_다운로드_API.md)**: 전체 문서 다운로드
|
| 772 |
+
- **[데이터 모델](./05_데이터_모델.md)**: 페이지 스키마 상세 정보
|
docs/Backend API 문서/03_분석_API.md
ADDED
|
@@ -0,0 +1,682 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# 분석 API
|
| 2 |
+
|
| 3 |
+
분석 API는 업로드된 페이지에 대해 AI 레이아웃 분석, OCR 텍스트 추출, 정렬, 포맷팅을 수행합니다.
|
| 4 |
+
|
| 5 |
+
## 📖 목차
|
| 6 |
+
|
| 7 |
+
- [엔드포인트 목록](#엔드포인트-목록)
|
| 8 |
+
- [1. 프로젝트 배치 분석 (동기)](#1-프로젝트-배치-분석-동기)
|
| 9 |
+
- [2. 단일 페이지 비동기 분석](#2-단일-페이지-비동기-분석)
|
| 10 |
+
- [3. 분석 작업 상태 조회](#3-분석-작업-상태-조회)
|
| 11 |
+
- [분석 파이프라인 상세](#분석-파이프라인-상세)
|
| 12 |
+
|
| 13 |
+
---
|
| 14 |
+
|
| 15 |
+
## 엔드포인트 목록
|
| 16 |
+
|
| 17 |
+
| Method | Endpoint | 설명 |
|
| 18 |
+
|--------|----------|------|
|
| 19 |
+
| POST | `/api/projects/{project_id}/analyze` | 프로젝트 전체 배치 분석 (동기) |
|
| 20 |
+
| POST | `/api/pages/{page_id}/analyze/async` | 단일 페이지 비동기 분석 |
|
| 21 |
+
| GET | `/api/analysis/jobs/{job_id}` | 비동기 작업 상태 조회 |
|
| 22 |
+
|
| 23 |
+
---
|
| 24 |
+
|
| 25 |
+
## 1. 프로젝트 배치 분석 (동기)
|
| 26 |
+
|
| 27 |
+
프로젝트 내 모든 `pending` 상태 페이지를 순차적으로 분석합니다.
|
| 28 |
+
|
| 29 |
+
### Endpoint
|
| 30 |
+
|
| 31 |
+
```http
|
| 32 |
+
POST /api/projects/{project_id}/analyze
|
| 33 |
+
```
|
| 34 |
+
|
| 35 |
+
### Path Parameters
|
| 36 |
+
|
| 37 |
+
| 파라미터 | 타입 | 설명 |
|
| 38 |
+
|----------|------|------|
|
| 39 |
+
| `project_id` | integer | 분석할 프로젝트 ID |
|
| 40 |
+
|
| 41 |
+
### Request Body
|
| 42 |
+
|
| 43 |
+
```json
|
| 44 |
+
{
|
| 45 |
+
"use_ai_descriptions": true,
|
| 46 |
+
"api_key": "sk-..."
|
| 47 |
+
}
|
| 48 |
+
```
|
| 49 |
+
|
| 50 |
+
**필드 설명**:
|
| 51 |
+
|
| 52 |
+
| 필드 | 타입 | 필수 | 설명 |
|
| 53 |
+
|------|------|------|------|
|
| 54 |
+
| `use_ai_descriptions` | boolean | ❌ | AI 설명 생성 여부 (기본값: true)<br>figure, table, flowchart에 대한 GPT-4 설명 생성 |
|
| 55 |
+
| `api_key` | string | ❌ | OpenAI API 키 (선택)<br>제공하지 않으면 서버 환경 변수 사용 |
|
| 56 |
+
|
| 57 |
+
### Response
|
| 58 |
+
|
| 59 |
+
**HTTP 202 Accepted**
|
| 60 |
+
|
| 61 |
+
```json
|
| 62 |
+
{
|
| 63 |
+
"project_id": 1,
|
| 64 |
+
"status": "completed",
|
| 65 |
+
"total_pages": 3,
|
| 66 |
+
"completed_pages": 3,
|
| 67 |
+
"failed_pages": 0,
|
| 68 |
+
"total_time": 15.67,
|
| 69 |
+
"pages": [
|
| 70 |
+
{
|
| 71 |
+
"page_id": 1,
|
| 72 |
+
"page_number": 1,
|
| 73 |
+
"status": "completed",
|
| 74 |
+
"layout_count": 12,
|
| 75 |
+
"ocr_count": 10,
|
| 76 |
+
"ai_description_count": 2,
|
| 77 |
+
"processing_time": 5.23,
|
| 78 |
+
"message": "페이지 분석 완료"
|
| 79 |
+
},
|
| 80 |
+
{
|
| 81 |
+
"page_id": 2,
|
| 82 |
+
"page_number": 2,
|
| 83 |
+
"status": "completed",
|
| 84 |
+
"layout_count": 15,
|
| 85 |
+
"ocr_count": 13,
|
| 86 |
+
"ai_description_count": 2,
|
| 87 |
+
"processing_time": 5.12,
|
| 88 |
+
"message": "페이지 분석 완료"
|
| 89 |
+
},
|
| 90 |
+
{
|
| 91 |
+
"page_id": 3,
|
| 92 |
+
"page_number": 3,
|
| 93 |
+
"status": "completed",
|
| 94 |
+
"layout_count": 10,
|
| 95 |
+
"ocr_count": 8,
|
| 96 |
+
"ai_description_count": 2,
|
| 97 |
+
"processing_time": 5.32,
|
| 98 |
+
"message": "페이지 분석 완료"
|
| 99 |
+
}
|
| 100 |
+
]
|
| 101 |
+
}
|
| 102 |
+
```
|
| 103 |
+
|
| 104 |
+
**응답 필드**:
|
| 105 |
+
|
| 106 |
+
| 필드 | 타입 | 설명 |
|
| 107 |
+
|------|------|------|
|
| 108 |
+
| `project_id` | integer | 프로젝트 ID |
|
| 109 |
+
| `status` | string | 전체 분석 상태 (`completed`, `partial`, `failed`) |
|
| 110 |
+
| `total_pages` | integer | 분석 대상 페이지 수 |
|
| 111 |
+
| `completed_pages` | integer | 성공한 페이지 수 |
|
| 112 |
+
| `failed_pages` | integer | 실패한 페이지 수 |
|
| 113 |
+
| `total_time` | float | 전체 처리 시간 (초) |
|
| 114 |
+
| `pages` | array | 페이지별 분석 결과 |
|
| 115 |
+
|
| 116 |
+
**페이지별 결과**:
|
| 117 |
+
|
| 118 |
+
| 필드 | 타입 | 설명 |
|
| 119 |
+
|------|------|------|
|
| 120 |
+
| `page_id` | integer | 페이지 ID |
|
| 121 |
+
| `page_number` | integer | 페이지 번호 |
|
| 122 |
+
| `status` | string | 분석 상태 (`completed`, `failed`) |
|
| 123 |
+
| `layout_count` | integer | 감지된 레이아웃 요소 수 |
|
| 124 |
+
| `ocr_count` | integer | OCR 수행된 요소 수 |
|
| 125 |
+
| `ai_description_count` | integer | AI 설명 생성된 요소 수 |
|
| 126 |
+
| `processing_time` | float | 페이지 처리 시간 (초) |
|
| 127 |
+
| `message` | string | 상태 메시지 |
|
| 128 |
+
|
| 129 |
+
### 예제 코드
|
| 130 |
+
|
| 131 |
+
**JavaScript (fetch)**:
|
| 132 |
+
|
| 133 |
+
```javascript
|
| 134 |
+
const analyzeProject = async (projectId, useAI = true, apiKey = null) => {
|
| 135 |
+
const response = await fetch(
|
| 136 |
+
`http://localhost:8000/api/projects/${projectId}/analyze`,
|
| 137 |
+
{
|
| 138 |
+
method: 'POST',
|
| 139 |
+
headers: {
|
| 140 |
+
'Content-Type': 'application/json',
|
| 141 |
+
},
|
| 142 |
+
body: JSON.stringify({
|
| 143 |
+
use_ai_descriptions: useAI,
|
| 144 |
+
api_key: apiKey
|
| 145 |
+
})
|
| 146 |
+
}
|
| 147 |
+
);
|
| 148 |
+
|
| 149 |
+
if (!response.ok) {
|
| 150 |
+
throw new Error(`분석 실패: ${response.status}`);
|
| 151 |
+
}
|
| 152 |
+
|
| 153 |
+
return await response.json();
|
| 154 |
+
};
|
| 155 |
+
|
| 156 |
+
// 사용 예시
|
| 157 |
+
analyzeProject(1, true, 'sk-...')
|
| 158 |
+
.then(result => {
|
| 159 |
+
console.log(`분석 완료: ${result.completed_pages}/${result.total_pages} 페이지`);
|
| 160 |
+
console.log(`총 소요 시간: ${result.total_time.toFixed(2)}초`);
|
| 161 |
+
|
| 162 |
+
result.pages.forEach(page => {
|
| 163 |
+
console.log(`페이지 ${page.page_number}:`);
|
| 164 |
+
console.log(` - 레이아웃: ${page.layout_count}개`);
|
| 165 |
+
console.log(` - OCR: ${page.ocr_count}개`);
|
| 166 |
+
console.log(` - AI 설명: ${page.ai_description_count}개`);
|
| 167 |
+
console.log(` - 시간: ${page.processing_time.toFixed(2)}초`);
|
| 168 |
+
});
|
| 169 |
+
})
|
| 170 |
+
.catch(error => console.error('분석 실패:', error));
|
| 171 |
+
```
|
| 172 |
+
|
| 173 |
+
**React Component - 진행률 표시**:
|
| 174 |
+
|
| 175 |
+
```jsx
|
| 176 |
+
import React, { useState } from 'react';
|
| 177 |
+
import axios from 'axios';
|
| 178 |
+
|
| 179 |
+
function ProjectAnalyzer({ projectId, onComplete }) {
|
| 180 |
+
const [analyzing, setAnalyzing] = useState(false);
|
| 181 |
+
const [result, setResult] = useState(null);
|
| 182 |
+
|
| 183 |
+
const handleAnalyze = async () => {
|
| 184 |
+
setAnalyzing(true);
|
| 185 |
+
|
| 186 |
+
try {
|
| 187 |
+
const response = await axios.post(
|
| 188 |
+
`http://localhost:8000/api/projects/${projectId}/analyze`,
|
| 189 |
+
{
|
| 190 |
+
use_ai_descriptions: true,
|
| 191 |
+
api_key: localStorage.getItem('openai_api_key') // 사용자 API 키 사용
|
| 192 |
+
}
|
| 193 |
+
);
|
| 194 |
+
|
| 195 |
+
setResult(response.data);
|
| 196 |
+
|
| 197 |
+
if (response.data.status === 'completed') {
|
| 198 |
+
alert(`분석 완료: ${response.data.completed_pages}/${response.data.total_pages} 페이지`);
|
| 199 |
+
} else {
|
| 200 |
+
alert(`일부 실패: ${response.data.failed_pages}개 페이지 실패`);
|
| 201 |
+
}
|
| 202 |
+
|
| 203 |
+
if (onComplete) onComplete(response.data);
|
| 204 |
+
|
| 205 |
+
} catch (error) {
|
| 206 |
+
console.error('분석 실패:', error);
|
| 207 |
+
alert('분석 실패: ' + error.message);
|
| 208 |
+
} finally {
|
| 209 |
+
setAnalyzing(false);
|
| 210 |
+
}
|
| 211 |
+
};
|
| 212 |
+
|
| 213 |
+
return (
|
| 214 |
+
<div>
|
| 215 |
+
<button onClick={handleAnalyze} disabled={analyzing}>
|
| 216 |
+
{analyzing ? '분석 중...' : 'AI 분석 시작'}
|
| 217 |
+
</button>
|
| 218 |
+
|
| 219 |
+
{analyzing && (
|
| 220 |
+
<div className="analyzing-indicator">
|
| 221 |
+
<div className="spinner"></div>
|
| 222 |
+
<p>페이지를 분석하고 있습니다. 잠시만 기다려주세요...</p>
|
| 223 |
+
</div>
|
| 224 |
+
)}
|
| 225 |
+
|
| 226 |
+
{result && (
|
| 227 |
+
<div className="analysis-result">
|
| 228 |
+
<h3>분석 결과</h3>
|
| 229 |
+
<p>전체 페이지: {result.total_pages}</p>
|
| 230 |
+
<p>완료: {result.completed_pages}</p>
|
| 231 |
+
<p>실패: {result.failed_pages}</p>
|
| 232 |
+
<p>소요 시간: {result.total_time.toFixed(2)}초</p>
|
| 233 |
+
|
| 234 |
+
<table>
|
| 235 |
+
<thead>
|
| 236 |
+
<tr>
|
| 237 |
+
<th>페이지</th>
|
| 238 |
+
<th>상태</th>
|
| 239 |
+
<th>레이아웃</th>
|
| 240 |
+
<th>OCR</th>
|
| 241 |
+
<th>AI 설명</th>
|
| 242 |
+
<th>시간</th>
|
| 243 |
+
</tr>
|
| 244 |
+
</thead>
|
| 245 |
+
<tbody>
|
| 246 |
+
{result.pages.map(page => (
|
| 247 |
+
<tr key={page.page_id}>
|
| 248 |
+
<td>{page.page_number}</td>
|
| 249 |
+
<td>{page.status}</td>
|
| 250 |
+
<td>{page.layout_count}</td>
|
| 251 |
+
<td>{page.ocr_count}</td>
|
| 252 |
+
<td>{page.ai_description_count}</td>
|
| 253 |
+
<td>{page.processing_time.toFixed(2)}s</td>
|
| 254 |
+
</tr>
|
| 255 |
+
))}
|
| 256 |
+
</tbody>
|
| 257 |
+
</table>
|
| 258 |
+
</div>
|
| 259 |
+
)}
|
| 260 |
+
</div>
|
| 261 |
+
);
|
| 262 |
+
}
|
| 263 |
+
```
|
| 264 |
+
|
| 265 |
+
---
|
| 266 |
+
|
| 267 |
+
## 2. 단일 페이지 비동기 분석
|
| 268 |
+
|
| 269 |
+
단일 페이지를 백그라운드에서 비동기로 분석합니다. 작업 ID를 즉시 반환하고, 작업 상태는 별도로 조회할 수 있습니다.
|
| 270 |
+
|
| 271 |
+
### Endpoint
|
| 272 |
+
|
| 273 |
+
```http
|
| 274 |
+
POST /api/pages/{page_id}/analyze/async
|
| 275 |
+
```
|
| 276 |
+
|
| 277 |
+
### Path Parameters
|
| 278 |
+
|
| 279 |
+
| 파라미터 | 타입 | 설명 |
|
| 280 |
+
|----------|------|------|
|
| 281 |
+
| `page_id` | integer | 분석할 페이지 ID |
|
| 282 |
+
|
| 283 |
+
### Request Body
|
| 284 |
+
|
| 285 |
+
```json
|
| 286 |
+
{
|
| 287 |
+
"use_ai_descriptions": true,
|
| 288 |
+
"api_key": "sk-..."
|
| 289 |
+
}
|
| 290 |
+
```
|
| 291 |
+
|
| 292 |
+
### Response
|
| 293 |
+
|
| 294 |
+
**HTTP 202 Accepted**
|
| 295 |
+
|
| 296 |
+
```json
|
| 297 |
+
{
|
| 298 |
+
"job_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
|
| 299 |
+
"status": "pending",
|
| 300 |
+
"message": "페이지 분석 작업이 시작되었습니다.",
|
| 301 |
+
"page_id": 1,
|
| 302 |
+
"status_check_url": "/api/analysis/jobs/a1b2c3d4-e5f6-7890-abcd-ef1234567890"
|
| 303 |
+
}
|
| 304 |
+
```
|
| 305 |
+
|
| 306 |
+
**응답 필드**:
|
| 307 |
+
|
| 308 |
+
| 필드 | 타입 | 설명 |
|
| 309 |
+
|------|------|------|
|
| 310 |
+
| `job_id` | string | 작업 고유 ID (UUID) |
|
| 311 |
+
| `status` | string | 작업 상태 (`pending`) |
|
| 312 |
+
| `message` | string | 상태 메시지 |
|
| 313 |
+
| `page_id` | integer | 분석 중인 페이지 ID |
|
| 314 |
+
| `status_check_url` | string | 작업 상태 조회 URL |
|
| 315 |
+
|
| 316 |
+
### 예제 코드
|
| 317 |
+
|
| 318 |
+
```javascript
|
| 319 |
+
const analyzePageAsync = async (pageId, useAI = true, apiKey = null) => {
|
| 320 |
+
const response = await fetch(
|
| 321 |
+
`http://localhost:8000/api/pages/${pageId}/analyze/async`,
|
| 322 |
+
{
|
| 323 |
+
method: 'POST',
|
| 324 |
+
headers: {
|
| 325 |
+
'Content-Type': 'application/json',
|
| 326 |
+
},
|
| 327 |
+
body: JSON.stringify({
|
| 328 |
+
use_ai_descriptions: useAI,
|
| 329 |
+
api_key: apiKey
|
| 330 |
+
})
|
| 331 |
+
}
|
| 332 |
+
);
|
| 333 |
+
|
| 334 |
+
if (!response.ok) {
|
| 335 |
+
throw new Error(`비동기 분석 시작 실패: ${response.status}`);
|
| 336 |
+
}
|
| 337 |
+
|
| 338 |
+
return await response.json();
|
| 339 |
+
};
|
| 340 |
+
|
| 341 |
+
// 사용 예시
|
| 342 |
+
analyzePageAsync(1, true)
|
| 343 |
+
.then(job => {
|
| 344 |
+
console.log('작업 시작됨:', job.job_id);
|
| 345 |
+
console.log('상태 조회 URL:', job.status_check_url);
|
| 346 |
+
|
| 347 |
+
// 주기적으로 상태 확인
|
| 348 |
+
checkJobStatus(job.job_id);
|
| 349 |
+
})
|
| 350 |
+
.catch(error => console.error('작업 시작 실패:', error));
|
| 351 |
+
```
|
| 352 |
+
|
| 353 |
+
---
|
| 354 |
+
|
| 355 |
+
## 3. 분석 작업 상태 조회
|
| 356 |
+
|
| 357 |
+
비동기 분석 작업의 현재 상태를 조회합니다.
|
| 358 |
+
|
| 359 |
+
### Endpoint
|
| 360 |
+
|
| 361 |
+
```http
|
| 362 |
+
GET /api/analysis/jobs/{job_id}
|
| 363 |
+
```
|
| 364 |
+
|
| 365 |
+
### Path Parameters
|
| 366 |
+
|
| 367 |
+
| 파라미터 | 타입 | 설명 |
|
| 368 |
+
|----------|------|------|
|
| 369 |
+
| `job_id` | string | 작업 ID (UUID) |
|
| 370 |
+
|
| 371 |
+
### Response
|
| 372 |
+
|
| 373 |
+
#### 작업 대기 중
|
| 374 |
+
|
| 375 |
+
**HTTP 200 OK**
|
| 376 |
+
|
| 377 |
+
```json
|
| 378 |
+
{
|
| 379 |
+
"job_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
|
| 380 |
+
"status": "pending",
|
| 381 |
+
"page_id": 1,
|
| 382 |
+
"page_number": 1,
|
| 383 |
+
"project_id": 1,
|
| 384 |
+
"result": null,
|
| 385 |
+
"error": null,
|
| 386 |
+
"progress": "작업 대기 중..."
|
| 387 |
+
}
|
| 388 |
+
```
|
| 389 |
+
|
| 390 |
+
#### 작업 진행 중
|
| 391 |
+
|
| 392 |
+
```json
|
| 393 |
+
{
|
| 394 |
+
"job_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
|
| 395 |
+
"status": "processing",
|
| 396 |
+
"page_id": 1,
|
| 397 |
+
"page_number": 1,
|
| 398 |
+
"project_id": 1,
|
| 399 |
+
"result": null,
|
| 400 |
+
"error": null,
|
| 401 |
+
"progress": "레이아웃 분석 및 OCR 수행 중..."
|
| 402 |
+
}
|
| 403 |
+
```
|
| 404 |
+
|
| 405 |
+
#### 작업 완료
|
| 406 |
+
|
| 407 |
+
```json
|
| 408 |
+
{
|
| 409 |
+
"job_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
|
| 410 |
+
"status": "completed",
|
| 411 |
+
"page_id": 1,
|
| 412 |
+
"page_number": 1,
|
| 413 |
+
"project_id": 1,
|
| 414 |
+
"result": {
|
| 415 |
+
"page_id": 1,
|
| 416 |
+
"page_number": 1,
|
| 417 |
+
"layout_count": 12,
|
| 418 |
+
"ocr_count": 10,
|
| 419 |
+
"ai_description_count": 2,
|
| 420 |
+
"processing_time": 5.23,
|
| 421 |
+
"message": "페이지 분석이 성공적으로 완료되었습니다."
|
| 422 |
+
},
|
| 423 |
+
"error": null,
|
| 424 |
+
"progress": "분석 완료"
|
| 425 |
+
}
|
| 426 |
+
```
|
| 427 |
+
|
| 428 |
+
#### 작업 실패
|
| 429 |
+
|
| 430 |
+
```json
|
| 431 |
+
{
|
| 432 |
+
"job_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
|
| 433 |
+
"status": "failed",
|
| 434 |
+
"page_id": 1,
|
| 435 |
+
"page_number": 1,
|
| 436 |
+
"project_id": 1,
|
| 437 |
+
"result": null,
|
| 438 |
+
"error": "이미지 파일을 찾을 수 없습니다.",
|
| 439 |
+
"progress": "분석 실패"
|
| 440 |
+
}
|
| 441 |
+
```
|
| 442 |
+
|
| 443 |
+
### 예제 코드
|
| 444 |
+
|
| 445 |
+
**JavaScript - 폴링(Polling)**:
|
| 446 |
+
|
| 447 |
+
```javascript
|
| 448 |
+
const checkJobStatus = async (jobId) => {
|
| 449 |
+
const response = await fetch(
|
| 450 |
+
`http://localhost:8000/api/analysis/jobs/${jobId}`
|
| 451 |
+
);
|
| 452 |
+
|
| 453 |
+
if (!response.ok) {
|
| 454 |
+
throw new Error(`작업 상태 조회 실패: ${response.status}`);
|
| 455 |
+
}
|
| 456 |
+
|
| 457 |
+
return await response.json();
|
| 458 |
+
};
|
| 459 |
+
|
| 460 |
+
// 주기적으로 상태 확인 (폴링)
|
| 461 |
+
const pollJobStatus = async (jobId, interval = 2000, maxAttempts = 60) => {
|
| 462 |
+
let attempts = 0;
|
| 463 |
+
|
| 464 |
+
const poll = async () => {
|
| 465 |
+
if (attempts >= maxAttempts) {
|
| 466 |
+
throw new Error('작업 조회 시간 초과');
|
| 467 |
+
}
|
| 468 |
+
|
| 469 |
+
attempts++;
|
| 470 |
+
const status = await checkJobStatus(jobId);
|
| 471 |
+
|
| 472 |
+
console.log(`[${attempts}] 상태: ${status.status} - ${status.progress}`);
|
| 473 |
+
|
| 474 |
+
if (status.status === 'completed') {
|
| 475 |
+
console.log('작업 완료!', status.result);
|
| 476 |
+
return status.result;
|
| 477 |
+
}
|
| 478 |
+
|
| 479 |
+
if (status.status === 'failed') {
|
| 480 |
+
throw new Error(`작업 실패: ${status.error}`);
|
| 481 |
+
}
|
| 482 |
+
|
| 483 |
+
// 계속 대기 중이면 재시도
|
| 484 |
+
await new Promise(resolve => setTimeout(resolve, interval));
|
| 485 |
+
return poll();
|
| 486 |
+
};
|
| 487 |
+
|
| 488 |
+
return poll();
|
| 489 |
+
};
|
| 490 |
+
|
| 491 |
+
// 사용 예시
|
| 492 |
+
analyzePageAsync(1, true)
|
| 493 |
+
.then(job => {
|
| 494 |
+
console.log('비동기 작업 시작:', job.job_id);
|
| 495 |
+
return pollJobStatus(job.job_id);
|
| 496 |
+
})
|
| 497 |
+
.then(result => {
|
| 498 |
+
console.log('분석 완료:', result);
|
| 499 |
+
alert(`분석 완료: 레이아웃 ${result.layout_count}개, OCR ${result.ocr_count}개`);
|
| 500 |
+
})
|
| 501 |
+
.catch(error => {
|
| 502 |
+
console.error('에러:', error);
|
| 503 |
+
alert('분석 실패: ' + error.message);
|
| 504 |
+
});
|
| 505 |
+
```
|
| 506 |
+
|
| 507 |
+
**React Component - 실시간 상태 표시**:
|
| 508 |
+
|
| 509 |
+
```jsx
|
| 510 |
+
import React, { useState, useEffect } from 'react';
|
| 511 |
+
import axios from 'axios';
|
| 512 |
+
|
| 513 |
+
function AsyncAnalyzer({ pageId, onComplete }) {
|
| 514 |
+
const [jobId, setJobId] = useState(null);
|
| 515 |
+
const [status, setStatus] = useState(null);
|
| 516 |
+
const [analyzing, setAnalyzing] = useState(false);
|
| 517 |
+
|
| 518 |
+
useEffect(() => {
|
| 519 |
+
if (!jobId) return;
|
| 520 |
+
|
| 521 |
+
// 2초마다 상태 확인
|
| 522 |
+
const interval = setInterval(async () => {
|
| 523 |
+
try {
|
| 524 |
+
const response = await axios.get(
|
| 525 |
+
`http://localhost:8000/api/analysis/jobs/${jobId}`
|
| 526 |
+
);
|
| 527 |
+
setStatus(response.data);
|
| 528 |
+
|
| 529 |
+
if (response.data.status === 'completed') {
|
| 530 |
+
clearInterval(interval);
|
| 531 |
+
setAnalyzing(false);
|
| 532 |
+
if (onComplete) onComplete(response.data.result);
|
| 533 |
+
}
|
| 534 |
+
|
| 535 |
+
if (response.data.status === 'failed') {
|
| 536 |
+
clearInterval(interval);
|
| 537 |
+
setAnalyzing(false);
|
| 538 |
+
alert('분석 실패: ' + response.data.error);
|
| 539 |
+
}
|
| 540 |
+
} catch (error) {
|
| 541 |
+
console.error('상태 조회 실패:', error);
|
| 542 |
+
}
|
| 543 |
+
}, 2000);
|
| 544 |
+
|
| 545 |
+
return () => clearInterval(interval);
|
| 546 |
+
}, [jobId, onComplete]);
|
| 547 |
+
|
| 548 |
+
const handleStartAnalysis = async () => {
|
| 549 |
+
setAnalyzing(true);
|
| 550 |
+
|
| 551 |
+
try {
|
| 552 |
+
const response = await axios.post(
|
| 553 |
+
`http://localhost:8000/api/pages/${pageId}/analyze/async`,
|
| 554 |
+
{ use_ai_descriptions: true }
|
| 555 |
+
);
|
| 556 |
+
setJobId(response.data.job_id);
|
| 557 |
+
setStatus(response.data);
|
| 558 |
+
} catch (error) {
|
| 559 |
+
console.error('분석 시작 실패:', error);
|
| 560 |
+
alert('분석 시작 실패: ' + error.message);
|
| 561 |
+
setAnalyzing(false);
|
| 562 |
+
}
|
| 563 |
+
};
|
| 564 |
+
|
| 565 |
+
return (
|
| 566 |
+
<div>
|
| 567 |
+
<button onClick={handleStartAnalysis} disabled={analyzing}>
|
| 568 |
+
{analyzing ? '분석 중...' : '비동기 분석 시작'}
|
| 569 |
+
</button>
|
| 570 |
+
|
| 571 |
+
{status && (
|
| 572 |
+
<div className="status-display">
|
| 573 |
+
<p><strong>작업 ID:</strong> {status.job_id}</p>
|
| 574 |
+
<p><strong>상태:</strong> {status.status}</p>
|
| 575 |
+
<p><strong>진행상황:</strong> {status.progress}</p>
|
| 576 |
+
|
| 577 |
+
{status.status === 'completed' && status.result && (
|
| 578 |
+
<div className="result">
|
| 579 |
+
<h4>분석 결과</h4>
|
| 580 |
+
<p>레이아웃: {status.result.layout_count}개</p>
|
| 581 |
+
<p>OCR: {status.result.ocr_count}개</p>
|
| 582 |
+
<p>AI 설명: {status.result.ai_description_count}개</p>
|
| 583 |
+
<p>처리 시간: {status.result.processing_time.toFixed(2)}초</p>
|
| 584 |
+
</div>
|
| 585 |
+
)}
|
| 586 |
+
|
| 587 |
+
{status.status === 'failed' && (
|
| 588 |
+
<div className="error">
|
| 589 |
+
<p style={{color: 'red'}}>에러: {status.error}</p>
|
| 590 |
+
</div>
|
| 591 |
+
)}
|
| 592 |
+
</div>
|
| 593 |
+
)}
|
| 594 |
+
</div>
|
| 595 |
+
);
|
| 596 |
+
}
|
| 597 |
+
```
|
| 598 |
+
|
| 599 |
+
---
|
| 600 |
+
|
| 601 |
+
## 분석 파이프라인 상세
|
| 602 |
+
|
| 603 |
+
각 페이지 분석은 다음 단계로 진행됩니다:
|
| 604 |
+
|
| 605 |
+
### 1단계: 레이아웃 분석 (Layout Detection)
|
| 606 |
+
|
| 607 |
+
- **모델**: DocLayout-YOLO
|
| 608 |
+
- **감지 클래스**:
|
| 609 |
+
- `question_number`: 문제 번호 (worksheet 전용)
|
| 610 |
+
- `text`: 본문 텍스트
|
| 611 |
+
- `figure`: 그림/도표
|
| 612 |
+
- `table`: 표
|
| 613 |
+
- `flowchart`: 순서도
|
| 614 |
+
- 등
|
| 615 |
+
|
| 616 |
+
### 2단계: OCR 텍스트 추출
|
| 617 |
+
|
| 618 |
+
- **엔진**: PaddleOCR
|
| 619 |
+
- **대상**: `text`, `question_number` 등 텍스트 요소
|
| 620 |
+
- **언어**: 한국어, 영어, 중국어, 일본어 지원
|
| 621 |
+
|
| 622 |
+
### 3단계: AI 설명 생성 (선택)
|
| 623 |
+
|
| 624 |
+
- **모델**: GPT-4-turbo
|
| 625 |
+
- **대상**: `figure`, `table`, `flowchart`
|
| 626 |
+
- **조건**: `use_ai_descriptions=true` 일 때만 수행
|
| 627 |
+
|
| 628 |
+
### 4단계: 정렬 (Sorting)
|
| 629 |
+
|
| 630 |
+
#### Worksheet (문제지)
|
| 631 |
+
- 문제 번호 기반 그룹화
|
| 632 |
+
- 앵커 요소(문제 번호) 중심으로 자식 요소 수집
|
| 633 |
+
- Y좌표 기준 정렬
|
| 634 |
+
|
| 635 |
+
#### Document (일반 문서)
|
| 636 |
+
- 좌표 기반 읽기 순서 정렬
|
| 637 |
+
- Y좌표 우선, X좌표 보조
|
| 638 |
+
|
| 639 |
+
### 5단계: 포맷팅 (Formatting)
|
| 640 |
+
|
| 641 |
+
- 데이터베이스 포맷팅 규칙 적용
|
| 642 |
+
- 클래스별 접두사/접미사, 들여쓰기 적용
|
| 643 |
+
- HTML 형식으로 변환
|
| 644 |
+
|
| 645 |
+
### 6단계: 버전 저장
|
| 646 |
+
|
| 647 |
+
- `version_type="auto_formatted"` 버전 생성
|
| 648 |
+
- `is_current=true` 설정
|
| 649 |
+
|
| 650 |
+
---
|
| 651 |
+
|
| 652 |
+
## 에러 응답
|
| 653 |
+
|
| 654 |
+
### 404 Not Found
|
| 655 |
+
|
| 656 |
+
프로젝트 또는 페이지를 찾을 수 없음
|
| 657 |
+
|
| 658 |
+
```json
|
| 659 |
+
{
|
| 660 |
+
"error": "프로젝트를 찾을 수 없습니다.",
|
| 661 |
+
"status_code": 404
|
| 662 |
+
}
|
| 663 |
+
```
|
| 664 |
+
|
| 665 |
+
### 500 Internal Server Error
|
| 666 |
+
|
| 667 |
+
분석 중 오류 발생
|
| 668 |
+
|
| 669 |
+
```json
|
| 670 |
+
{
|
| 671 |
+
"error": "Internal Server Error",
|
| 672 |
+
"detail": "레이아웃 분석 중 오류가 발생했습니다.",
|
| 673 |
+
"status_code": 500
|
| 674 |
+
}
|
| 675 |
+
```
|
| 676 |
+
|
| 677 |
+
---
|
| 678 |
+
|
| 679 |
+
## 다음 단계
|
| 680 |
+
|
| 681 |
+
- **[다운로드 API](./04_다운로드_API.md)**: 분석 결과를 Word 문서로 다운로드
|
| 682 |
+
- **[데이터 모델](./05_데이터_모델.md)**: 분석 관련 스키마 상세 정보
|
docs/Backend API 문서/04_다운로드_API.md
ADDED
|
@@ -0,0 +1,477 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# 다운로드 API
|
| 2 |
+
|
| 3 |
+
분석이 완료된 프로젝트의 텍스트를 통합하여 조회하거나 Word 문서로 다운로드할 수 있습니다.
|
| 4 |
+
|
| 5 |
+
## 📖 목차
|
| 6 |
+
|
| 7 |
+
- [엔드포인트 목록](#엔드포인트-목록)
|
| 8 |
+
- [1. 통합 텍스트 조회](#1-통합-텍스트-조회)
|
| 9 |
+
- [2. Word 문서 다운로드](#2-word-문서-다운로드)
|
| 10 |
+
|
| 11 |
+
---
|
| 12 |
+
|
| 13 |
+
## 엔드포인트 목록
|
| 14 |
+
|
| 15 |
+
| Method | Endpoint | 설명 |
|
| 16 |
+
|--------|----------|------|
|
| 17 |
+
| GET | `/api/projects/{project_id}/combined-text` | 프로젝트 통합 텍스트 조회 (JSON) |
|
| 18 |
+
| POST | `/api/projects/{project_id}/download` | Word 문서 다운로드 (DOCX) |
|
| 19 |
+
|
| 20 |
+
---
|
| 21 |
+
|
| 22 |
+
## 1. 통합 텍스트 조회
|
| 23 |
+
|
| 24 |
+
프로젝트의 모든 페이지 텍스트를 통합하여 조회합니다. 캐시를 사용하므로 빠르게 응답합니다.
|
| 25 |
+
|
| 26 |
+
### Endpoint
|
| 27 |
+
|
| 28 |
+
```http
|
| 29 |
+
GET /api/projects/{project_id}/combined-text
|
| 30 |
+
```
|
| 31 |
+
|
| 32 |
+
### Path Parameters
|
| 33 |
+
|
| 34 |
+
| 파라미터 | 타입 | 설명 |
|
| 35 |
+
|----------|------|------|
|
| 36 |
+
| `project_id` | integer | 프로젝트 ID |
|
| 37 |
+
|
| 38 |
+
### Response
|
| 39 |
+
|
| 40 |
+
**HTTP 200 OK**
|
| 41 |
+
|
| 42 |
+
```json
|
| 43 |
+
{
|
| 44 |
+
"project_id": 1,
|
| 45 |
+
"project_name": "수학 문제집 1단원",
|
| 46 |
+
"combined_text": "<h2>1. 다음 식을 계산하시오.</h2>\n<p>(1) 3 + 5 = ?</p>\n<p>답: 8</p>\n\n<h2>2. 다음 그림을 보고 답하시오.</h2>\n<p>[그림 설명] 세 개의 사과가 그려져 있는 그림입니다...</p>",
|
| 47 |
+
"stats": {
|
| 48 |
+
"total_pages": 3,
|
| 49 |
+
"total_words": 450,
|
| 50 |
+
"total_characters": 2340
|
| 51 |
+
},
|
| 52 |
+
"generated_at": "2025-01-22T11:00:00"
|
| 53 |
+
}
|
| 54 |
+
```
|
| 55 |
+
|
| 56 |
+
**응답 필드**:
|
| 57 |
+
|
| 58 |
+
| 필드 | 타입 | 설명 |
|
| 59 |
+
|------|------|------|
|
| 60 |
+
| `project_id` | integer | 프로젝트 ID |
|
| 61 |
+
| `project_name` | string | 프로젝트 이름 |
|
| 62 |
+
| `combined_text` | string | 전체 페이지의 텍스트를 통합한 HTML 문자열 |
|
| 63 |
+
| `stats` | object | 통계 정보 |
|
| 64 |
+
| `stats.total_pages` | integer | 총 페이지 수 |
|
| 65 |
+
| `stats.total_words` | integer | 총 단어 수 |
|
| 66 |
+
| `stats.total_characters` | integer | 총 문자 수 |
|
| 67 |
+
| `generated_at` | datetime | 통합 텍스트 생성 일시 |
|
| 68 |
+
|
| 69 |
+
### 캐시 동작
|
| 70 |
+
|
| 71 |
+
- 첫 번째 호출 시 모든 페이지의 최신 텍스트 버전을 수집하여 통합
|
| 72 |
+
- 결과를 `combined_results` 테이블에 캐시
|
| 73 |
+
- 이후 호출 시 캐시된 데이터 반환 (빠른 응답)
|
| 74 |
+
- 페이지 텍스트가 수정되면 자동으로 캐시 갱신
|
| 75 |
+
|
| 76 |
+
### 예제 코드
|
| 77 |
+
|
| 78 |
+
**JavaScript (fetch)**:
|
| 79 |
+
|
| 80 |
+
```javascript
|
| 81 |
+
const getCombinedText = async (projectId) => {
|
| 82 |
+
const response = await fetch(
|
| 83 |
+
`http://localhost:8000/api/projects/${projectId}/combined-text`
|
| 84 |
+
);
|
| 85 |
+
|
| 86 |
+
if (!response.ok) {
|
| 87 |
+
if (response.status === 404) {
|
| 88 |
+
throw new Error('프로젝트를 찾을 수 없습니다.');
|
| 89 |
+
}
|
| 90 |
+
throw new Error(`통합 텍스트 조회 실패: ${response.status}`);
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
return await response.json();
|
| 94 |
+
};
|
| 95 |
+
|
| 96 |
+
// 사용 예시
|
| 97 |
+
getCombinedText(1).then(data => {
|
| 98 |
+
console.log('프로젝트:', data.project_name);
|
| 99 |
+
console.log('페이지 수:', data.stats.total_pages);
|
| 100 |
+
console.log('단어 수:', data.stats.total_words);
|
| 101 |
+
|
| 102 |
+
// HTML 표시
|
| 103 |
+
document.getElementById('combined-content').innerHTML = data.combined_text;
|
| 104 |
+
});
|
| 105 |
+
```
|
| 106 |
+
|
| 107 |
+
**React Component**:
|
| 108 |
+
|
| 109 |
+
```jsx
|
| 110 |
+
import React, { useState, useEffect } from 'react';
|
| 111 |
+
import axios from 'axios';
|
| 112 |
+
|
| 113 |
+
function CombinedTextViewer({ projectId }) {
|
| 114 |
+
const [data, setData] = useState(null);
|
| 115 |
+
const [loading, setLoading] = useState(true);
|
| 116 |
+
|
| 117 |
+
useEffect(() => {
|
| 118 |
+
const fetchCombinedText = async () => {
|
| 119 |
+
try {
|
| 120 |
+
const response = await axios.get(
|
| 121 |
+
`http://localhost:8000/api/projects/${projectId}/combined-text`
|
| 122 |
+
);
|
| 123 |
+
setData(response.data);
|
| 124 |
+
} catch (error) {
|
| 125 |
+
console.error('통합 텍스트 조회 실패:', error);
|
| 126 |
+
} finally {
|
| 127 |
+
setLoading(false);
|
| 128 |
+
}
|
| 129 |
+
};
|
| 130 |
+
|
| 131 |
+
fetchCombinedText();
|
| 132 |
+
}, [projectId]);
|
| 133 |
+
|
| 134 |
+
if (loading) return <div>로딩 중...</div>;
|
| 135 |
+
if (!data) return <div>데이터를 찾을 수 없습니다.</div>;
|
| 136 |
+
|
| 137 |
+
return (
|
| 138 |
+
<div className="combined-text-viewer">
|
| 139 |
+
<header>
|
| 140 |
+
<h1>{data.project_name}</h1>
|
| 141 |
+
<div className="stats">
|
| 142 |
+
<span>페이지: {data.stats.total_pages}</span>
|
| 143 |
+
<span>단어: {data.stats.total_words}</span>
|
| 144 |
+
<span>문자: {data.stats.total_characters}</span>
|
| 145 |
+
</div>
|
| 146 |
+
</header>
|
| 147 |
+
|
| 148 |
+
<div
|
| 149 |
+
className="content"
|
| 150 |
+
dangerouslySetInnerHTML={{ __html: data.combined_text }}
|
| 151 |
+
/>
|
| 152 |
+
|
| 153 |
+
<footer>
|
| 154 |
+
<small>생성 일시: {new Date(data.generated_at).toLocaleString()}</small>
|
| 155 |
+
</footer>
|
| 156 |
+
</div>
|
| 157 |
+
);
|
| 158 |
+
}
|
| 159 |
+
```
|
| 160 |
+
|
| 161 |
+
---
|
| 162 |
+
|
| 163 |
+
## 2. Word 문서 다운로드
|
| 164 |
+
|
| 165 |
+
프로젝트의 통합 텍스트를 Word 문서(DOCX) 형식으로 다운로드합니다.
|
| 166 |
+
|
| 167 |
+
### Endpoint
|
| 168 |
+
|
| 169 |
+
```http
|
| 170 |
+
POST /api/projects/{project_id}/download
|
| 171 |
+
```
|
| 172 |
+
|
| 173 |
+
### Path Parameters
|
| 174 |
+
|
| 175 |
+
| 파라미터 | 타입 | 설명 |
|
| 176 |
+
|----------|------|------|
|
| 177 |
+
| `project_id` | integer | 프로젝트 ID |
|
| 178 |
+
|
| 179 |
+
### Response
|
| 180 |
+
|
| 181 |
+
**HTTP 200 OK**
|
| 182 |
+
|
| 183 |
+
**Content-Type**: `application/vnd.openxmlformats-officedocument.wordprocessingml.document`
|
| 184 |
+
|
| 185 |
+
**Content-Disposition**: `attachment; filename="project_1_수학_문제집_1단원.docx"`
|
| 186 |
+
|
| 187 |
+
응답은 바이너리 스트림으로 Word 문서 파일을 반환합니다.
|
| 188 |
+
|
| 189 |
+
### 문서 구조
|
| 190 |
+
|
| 191 |
+
생성되는 Word 문서는 다음과 같은 구조를 가집니다:
|
| 192 |
+
|
| 193 |
+
1. **제목**: 프로젝트 이름 (Heading 1)
|
| 194 |
+
2. **메타정보**: 총 페이지 수, 생성 일시 등
|
| 195 |
+
3. **본문**: 페이지별로 구분된 내용
|
| 196 |
+
- 페이지 번호 (Heading 2)
|
| 197 |
+
- 페이지 내용 (HTML을 Word 형식으로 변환)
|
| 198 |
+
4. **푸터**: 생성 정보
|
| 199 |
+
|
| 200 |
+
### 예제 코드
|
| 201 |
+
|
| 202 |
+
**JavaScript (fetch)**:
|
| 203 |
+
|
| 204 |
+
```javascript
|
| 205 |
+
const downloadDocument = async (projectId) => {
|
| 206 |
+
const response = await fetch(
|
| 207 |
+
`http://localhost:8000/api/projects/${projectId}/download`,
|
| 208 |
+
{
|
| 209 |
+
method: 'POST'
|
| 210 |
+
}
|
| 211 |
+
);
|
| 212 |
+
|
| 213 |
+
if (!response.ok) {
|
| 214 |
+
throw new Error(`문서 다운로드 실패: ${response.status}`);
|
| 215 |
+
}
|
| 216 |
+
|
| 217 |
+
// Blob으로 변환
|
| 218 |
+
const blob = await response.blob();
|
| 219 |
+
|
| 220 |
+
// 파일명 추출
|
| 221 |
+
const contentDisposition = response.headers.get('Content-Disposition');
|
| 222 |
+
let filename = `project_${projectId}.docx`;
|
| 223 |
+
|
| 224 |
+
if (contentDisposition) {
|
| 225 |
+
const match = contentDisposition.match(/filename="(.+)"/);
|
| 226 |
+
if (match) {
|
| 227 |
+
filename = match[1];
|
| 228 |
+
}
|
| 229 |
+
}
|
| 230 |
+
|
| 231 |
+
// 다운로드 트리거
|
| 232 |
+
const url = window.URL.createObjectURL(blob);
|
| 233 |
+
const a = document.createElement('a');
|
| 234 |
+
a.href = url;
|
| 235 |
+
a.download = filename;
|
| 236 |
+
document.body.appendChild(a);
|
| 237 |
+
a.click();
|
| 238 |
+
a.remove();
|
| 239 |
+
window.URL.revokeObjectURL(url);
|
| 240 |
+
|
| 241 |
+
console.log('다운로드 완료:', filename);
|
| 242 |
+
};
|
| 243 |
+
|
| 244 |
+
// 사용 예시
|
| 245 |
+
document.getElementById('downloadBtn').addEventListener('click', async () => {
|
| 246 |
+
try {
|
| 247 |
+
await downloadDocument(1);
|
| 248 |
+
alert('Word 문서 다운로드가 시작되었습니다.');
|
| 249 |
+
} catch (error) {
|
| 250 |
+
console.error('다운로드 실패:', error);
|
| 251 |
+
alert('다운로드 실패: ' + error.message);
|
| 252 |
+
}
|
| 253 |
+
});
|
| 254 |
+
```
|
| 255 |
+
|
| 256 |
+
**React Component**:
|
| 257 |
+
|
| 258 |
+
```jsx
|
| 259 |
+
import React, { useState } from 'react';
|
| 260 |
+
import axios from 'axios';
|
| 261 |
+
|
| 262 |
+
function DocumentDownloader({ projectId, projectName }) {
|
| 263 |
+
const [downloading, setDownloading] = useState(false);
|
| 264 |
+
|
| 265 |
+
const handleDownload = async () => {
|
| 266 |
+
setDownloading(true);
|
| 267 |
+
|
| 268 |
+
try {
|
| 269 |
+
const response = await axios.post(
|
| 270 |
+
`http://localhost:8000/api/projects/${projectId}/download`,
|
| 271 |
+
{},
|
| 272 |
+
{
|
| 273 |
+
responseType: 'blob' // 중요: blob 타입으로 응답 받기
|
| 274 |
+
}
|
| 275 |
+
);
|
| 276 |
+
|
| 277 |
+
// Blob 생성
|
| 278 |
+
const blob = new Blob([response.data], {
|
| 279 |
+
type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
|
| 280 |
+
});
|
| 281 |
+
|
| 282 |
+
// 파일명 추출
|
| 283 |
+
let filename = `project_${projectId}.docx`;
|
| 284 |
+
const contentDisposition = response.headers['content-disposition'];
|
| 285 |
+
if (contentDisposition) {
|
| 286 |
+
const match = contentDisposition.match(/filename="(.+)"/);
|
| 287 |
+
if (match) {
|
| 288 |
+
filename = decodeURIComponent(match[1]);
|
| 289 |
+
}
|
| 290 |
+
}
|
| 291 |
+
|
| 292 |
+
// 다운로드
|
| 293 |
+
const url = window.URL.createObjectURL(blob);
|
| 294 |
+
const link = document.createElement('a');
|
| 295 |
+
link.href = url;
|
| 296 |
+
link.download = filename;
|
| 297 |
+
document.body.appendChild(link);
|
| 298 |
+
link.click();
|
| 299 |
+
document.body.removeChild(link);
|
| 300 |
+
window.URL.revokeObjectURL(url);
|
| 301 |
+
|
| 302 |
+
alert('다운로드가 완료되었습니다.');
|
| 303 |
+
|
| 304 |
+
} catch (error) {
|
| 305 |
+
console.error('다운로드 실패:', error);
|
| 306 |
+
alert('다운로드 실패: ' + error.message);
|
| 307 |
+
} finally {
|
| 308 |
+
setDownloading(false);
|
| 309 |
+
}
|
| 310 |
+
};
|
| 311 |
+
|
| 312 |
+
return (
|
| 313 |
+
<div>
|
| 314 |
+
<button
|
| 315 |
+
onClick={handleDownload}
|
| 316 |
+
disabled={downloading}
|
| 317 |
+
className="btn btn-primary"
|
| 318 |
+
>
|
| 319 |
+
{downloading ? (
|
| 320 |
+
<>
|
| 321 |
+
<span className="spinner"></span>
|
| 322 |
+
다운로드 중...
|
| 323 |
+
</>
|
| 324 |
+
) : (
|
| 325 |
+
<>
|
| 326 |
+
<i className="icon-download"></i>
|
| 327 |
+
Word 문서 다운로드
|
| 328 |
+
</>
|
| 329 |
+
)}
|
| 330 |
+
</button>
|
| 331 |
+
|
| 332 |
+
{downloading && (
|
| 333 |
+
<p className="help-text">
|
| 334 |
+
문서를 생성하고 있습니다. 잠시만 기다려주세요...
|
| 335 |
+
</p>
|
| 336 |
+
)}
|
| 337 |
+
</div>
|
| 338 |
+
);
|
| 339 |
+
}
|
| 340 |
+
```
|
| 341 |
+
|
| 342 |
+
**Axios 설정 팁**:
|
| 343 |
+
|
| 344 |
+
```javascript
|
| 345 |
+
// Axios 인터셉터를 사용한 다운로드 헬퍼
|
| 346 |
+
import axios from 'axios';
|
| 347 |
+
|
| 348 |
+
const downloadFile = async (url, method = 'GET', data = null) => {
|
| 349 |
+
try {
|
| 350 |
+
const response = await axios({
|
| 351 |
+
url,
|
| 352 |
+
method,
|
| 353 |
+
data,
|
| 354 |
+
responseType: 'blob',
|
| 355 |
+
onDownloadProgress: (progressEvent) => {
|
| 356 |
+
const percentCompleted = Math.round(
|
| 357 |
+
(progressEvent.loaded * 100) / progressEvent.total
|
| 358 |
+
);
|
| 359 |
+
console.log(`다운로드 진행률: ${percentCompleted}%`);
|
| 360 |
+
}
|
| 361 |
+
});
|
| 362 |
+
|
| 363 |
+
// 파일명 추출
|
| 364 |
+
const contentDisposition = response.headers['content-disposition'];
|
| 365 |
+
let filename = 'download';
|
| 366 |
+
if (contentDisposition) {
|
| 367 |
+
const match = contentDisposition.match(/filename="(.+)"/);
|
| 368 |
+
if (match) {
|
| 369 |
+
filename = decodeURIComponent(match[1]);
|
| 370 |
+
}
|
| 371 |
+
}
|
| 372 |
+
|
| 373 |
+
// Blob 생성 및 다운로드
|
| 374 |
+
const blob = new Blob([response.data]);
|
| 375 |
+
const downloadUrl = window.URL.createObjectURL(blob);
|
| 376 |
+
const link = document.createElement('a');
|
| 377 |
+
link.href = downloadUrl;
|
| 378 |
+
link.download = filename;
|
| 379 |
+
document.body.appendChild(link);
|
| 380 |
+
link.click();
|
| 381 |
+
document.body.removeChild(link);
|
| 382 |
+
window.URL.revokeObjectURL(downloadUrl);
|
| 383 |
+
|
| 384 |
+
return filename;
|
| 385 |
+
} catch (error) {
|
| 386 |
+
console.error('다운로드 실패:', error);
|
| 387 |
+
throw error;
|
| 388 |
+
}
|
| 389 |
+
};
|
| 390 |
+
|
| 391 |
+
// 사용 예시
|
| 392 |
+
downloadFile(`http://localhost:8000/api/projects/1/download`, 'POST')
|
| 393 |
+
.then(filename => alert(`${filename} 다운로드 완료`))
|
| 394 |
+
.catch(error => alert('다운로드 실패: ' + error.message));
|
| 395 |
+
```
|
| 396 |
+
|
| 397 |
+
---
|
| 398 |
+
|
| 399 |
+
## 다운로드 플로우 다이어그램
|
| 400 |
+
|
| 401 |
+
```
|
| 402 |
+
사용자 → [다운로드 버튼 클릭]
|
| 403 |
+
↓
|
| 404 |
+
프론트엔드 → POST /api/projects/{id}/download
|
| 405 |
+
↓
|
| 406 |
+
백엔드 → 1. 통합 텍스트 조회 (캐시 우선)
|
| 407 |
+
2. HTML → Word 변환 (python-docx)
|
| 408 |
+
3. 파일 스트림 생성
|
| 409 |
+
↓
|
| 410 |
+
프론트엔드 ← Blob 응답 수신
|
| 411 |
+
↓
|
| 412 |
+
브라우저 → 파일 다운로드 트리거
|
| 413 |
+
↓
|
| 414 |
+
완료!
|
| 415 |
+
```
|
| 416 |
+
|
| 417 |
+
---
|
| 418 |
+
|
| 419 |
+
## 에러 응답
|
| 420 |
+
|
| 421 |
+
### 404 Not Found
|
| 422 |
+
|
| 423 |
+
프로젝트를 찾을 수 없음
|
| 424 |
+
|
| 425 |
+
```json
|
| 426 |
+
{
|
| 427 |
+
"error": "프로젝트를 찾을 수 없습니다.",
|
| 428 |
+
"status_code": 404
|
| 429 |
+
}
|
| 430 |
+
```
|
| 431 |
+
|
| 432 |
+
### 500 Internal Server Error
|
| 433 |
+
|
| 434 |
+
문서 생성 실패
|
| 435 |
+
|
| 436 |
+
```json
|
| 437 |
+
{
|
| 438 |
+
"error": "Internal Server Error",
|
| 439 |
+
"detail": "Word 문서 생성 중 오류가 발생했습니다.",
|
| 440 |
+
"status_code": 500
|
| 441 |
+
}
|
| 442 |
+
```
|
| 443 |
+
|
| 444 |
+
### 501 Not Implemented
|
| 445 |
+
|
| 446 |
+
python-docx 라이브러리 미설치
|
| 447 |
+
|
| 448 |
+
```json
|
| 449 |
+
{
|
| 450 |
+
"error": "python-docx 라이브러리가 설치되지 않았습니다.",
|
| 451 |
+
"status_code": 501
|
| 452 |
+
}
|
| 453 |
+
```
|
| 454 |
+
|
| 455 |
+
---
|
| 456 |
+
|
| 457 |
+
## 주의사항
|
| 458 |
+
|
| 459 |
+
### 파일명 인코딩
|
| 460 |
+
|
| 461 |
+
한글 파일명이 포함된 경우 브라우저마다 처리 방식이 다를 수 있습니다. 백엔드에서는 UTF-8로 인코딩된 파일명을 반환하므로, 필요시 `decodeURIComponent()`를 사용하세요.
|
| 462 |
+
|
| 463 |
+
### 큰 프로젝트 처리
|
| 464 |
+
|
| 465 |
+
페이지 수가 많은 프로젝트는 문서 생성에 시간이 걸릴 수 있습니다. 프론트엔드에서 로딩 인디케이터를 표시하는 것을 권장합니다.
|
| 466 |
+
|
| 467 |
+
### 브라우저 호환성
|
| 468 |
+
|
| 469 |
+
- `Blob` API는 IE10 이상에서 지원됩니다.
|
| 470 |
+
- `download` 속성은 IE에서 지원되지 않으므로, 필요시 polyfill을 사용하세요.
|
| 471 |
+
|
| 472 |
+
---
|
| 473 |
+
|
| 474 |
+
## 다음 단계
|
| 475 |
+
|
| 476 |
+
- **[데이터 모델](./05_데이터_모델.md)**: 통합 결과 스키마 상세 정보
|
| 477 |
+
- **[에러 처리](./06_에러_처리.md)**: 에러 코드 및 처리 방법
|
docs/Backend API 문서/05_데이터_모델.md
ADDED
|
@@ -0,0 +1,678 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# 데이터 모델
|
| 2 |
+
|
| 3 |
+
SmartEyeSsen API에서 사용되는 주요 데이터 구조와 Enum 정의입니다.
|
| 4 |
+
|
| 5 |
+
## 📖 목차
|
| 6 |
+
|
| 7 |
+
- [Enum 정의](#enum-정의)
|
| 8 |
+
- [프로젝트 관련 스키마](#프로젝트-관련-스키마)
|
| 9 |
+
- [페이지 관련 스키마](#페이지-관련-스키마)
|
| 10 |
+
- [레이아웃 및 분석 관련 스키마](#레이아웃-및-분석-관련-스키마)
|
| 11 |
+
- [텍스트 버전 관련 스키마](#텍스트-버전-관련-스키마)
|
| 12 |
+
- [다운로드 관련 스키마](#다운로드-관련-스키마)
|
| 13 |
+
|
| 14 |
+
---
|
| 15 |
+
|
| 16 |
+
## Enum 정의
|
| 17 |
+
|
| 18 |
+
### AnalysisModeEnum
|
| 19 |
+
|
| 20 |
+
분석 모드를 정의합니다.
|
| 21 |
+
|
| 22 |
+
| 값 | 설명 |
|
| 23 |
+
|----|------|
|
| 24 |
+
| `auto` | 자동 분석 (기본값) |
|
| 25 |
+
| `manual` | 수동 분석 |
|
| 26 |
+
| `hybrid` | 하이브리드 (자동 + 수동 조정) |
|
| 27 |
+
|
| 28 |
+
### ProjectStatusEnum
|
| 29 |
+
|
| 30 |
+
프로젝트 상태를 정의합니다.
|
| 31 |
+
|
| 32 |
+
| 값 | 설명 |
|
| 33 |
+
|----|------|
|
| 34 |
+
| `created` | 생성됨 (초기 상태) |
|
| 35 |
+
| `in_progress` | 진행 중 (분석 중) |
|
| 36 |
+
| `completed` | 완료 |
|
| 37 |
+
| `error` | 오류 발생 |
|
| 38 |
+
|
| 39 |
+
### AnalysisStatusEnum
|
| 40 |
+
|
| 41 |
+
페이지 분석 상태를 정의합니다.
|
| 42 |
+
|
| 43 |
+
| 값 | 설명 |
|
| 44 |
+
|----|------|
|
| 45 |
+
| `pending` | 대기 중 (분석 전) |
|
| 46 |
+
| `processing` | 처리 중 |
|
| 47 |
+
| `completed` | 완료 |
|
| 48 |
+
| `error` | 오류 발생 |
|
| 49 |
+
|
| 50 |
+
### VersionTypeEnum
|
| 51 |
+
|
| 52 |
+
텍스트 버전 유형을 정의합니다.
|
| 53 |
+
|
| 54 |
+
| 값 | 설명 |
|
| 55 |
+
|----|------|
|
| 56 |
+
| `original` | 원본 OCR 결과 (정렬만 적용) |
|
| 57 |
+
| `auto_formatted` | 자동 포맷팅 적용 (접두사/접미사/들여쓰기) |
|
| 58 |
+
| `user_edited` | 사용자 편집 |
|
| 59 |
+
|
| 60 |
+
### SortingMethodEnum
|
| 61 |
+
|
| 62 |
+
정렬 방식을 정의합니다.
|
| 63 |
+
|
| 64 |
+
| 값 | 설명 |
|
| 65 |
+
|----|------|
|
| 66 |
+
| `question_based` | 문제 번호 기반 정렬 (worksheet) |
|
| 67 |
+
| `reading_order` | 읽기 순서 기반 정렬 (document) |
|
| 68 |
+
|
| 69 |
+
---
|
| 70 |
+
|
| 71 |
+
## 프로젝트 관련 스키마
|
| 72 |
+
|
| 73 |
+
### ProjectCreate
|
| 74 |
+
|
| 75 |
+
프로젝트 생성 요청 스키마입니다.
|
| 76 |
+
|
| 77 |
+
```typescript
|
| 78 |
+
interface ProjectCreate {
|
| 79 |
+
project_name: string; // 프로젝트 이름 (1~255자)
|
| 80 |
+
doc_type_id: number; // 문서 타입 ID (1: worksheet, 2: document)
|
| 81 |
+
analysis_mode?: string; // 분석 모드 (기본값: "auto")
|
| 82 |
+
user_id: number; // 사용자 ID
|
| 83 |
+
}
|
| 84 |
+
```
|
| 85 |
+
|
| 86 |
+
**예시**:
|
| 87 |
+
|
| 88 |
+
```json
|
| 89 |
+
{
|
| 90 |
+
"project_name": "수학 문제집 1단원",
|
| 91 |
+
"doc_type_id": 1,
|
| 92 |
+
"analysis_mode": "auto",
|
| 93 |
+
"user_id": 1
|
| 94 |
+
}
|
| 95 |
+
```
|
| 96 |
+
|
| 97 |
+
### ProjectResponse
|
| 98 |
+
|
| 99 |
+
프로젝트 응답 스키마입니다.
|
| 100 |
+
|
| 101 |
+
```typescript
|
| 102 |
+
interface ProjectResponse {
|
| 103 |
+
project_id: number; // 프로젝트 고유 ID
|
| 104 |
+
user_id: number; // 소유자 사용자 ID
|
| 105 |
+
doc_type_id: number; // 문서 타입 ID
|
| 106 |
+
project_name: string; // 프로젝트 이름
|
| 107 |
+
total_pages: number; // 총 페이지 수
|
| 108 |
+
analysis_mode: string; // 분석 모드
|
| 109 |
+
status: string; // 프로젝트 상태
|
| 110 |
+
created_at: string; // 생성일시 (ISO 8601)
|
| 111 |
+
updated_at: string; // 수정일시 (ISO 8601)
|
| 112 |
+
}
|
| 113 |
+
```
|
| 114 |
+
|
| 115 |
+
**예시**:
|
| 116 |
+
|
| 117 |
+
```json
|
| 118 |
+
{
|
| 119 |
+
"project_id": 1,
|
| 120 |
+
"user_id": 1,
|
| 121 |
+
"doc_type_id": 1,
|
| 122 |
+
"project_name": "수학 문제집 1단원",
|
| 123 |
+
"total_pages": 5,
|
| 124 |
+
"analysis_mode": "auto",
|
| 125 |
+
"status": "completed",
|
| 126 |
+
"created_at": "2025-01-22T10:30:00",
|
| 127 |
+
"updated_at": "2025-01-22T10:35:00"
|
| 128 |
+
}
|
| 129 |
+
```
|
| 130 |
+
|
| 131 |
+
### ProjectWithPagesResponse
|
| 132 |
+
|
| 133 |
+
페이지 목록을 포함한 프로젝트 응답 스키마입니다.
|
| 134 |
+
|
| 135 |
+
```typescript
|
| 136 |
+
interface ProjectWithPagesResponse extends ProjectResponse {
|
| 137 |
+
pages: PageResponse[]; // 페이지 목록
|
| 138 |
+
}
|
| 139 |
+
```
|
| 140 |
+
|
| 141 |
+
**예시**:
|
| 142 |
+
|
| 143 |
+
```json
|
| 144 |
+
{
|
| 145 |
+
"project_id": 1,
|
| 146 |
+
"user_id": 1,
|
| 147 |
+
"doc_type_id": 1,
|
| 148 |
+
"project_name": "수학 문제집 1단원",
|
| 149 |
+
"total_pages": 2,
|
| 150 |
+
"analysis_mode": "auto",
|
| 151 |
+
"status": "completed",
|
| 152 |
+
"created_at": "2025-01-22T10:30:00",
|
| 153 |
+
"updated_at": "2025-01-22T10:35:00",
|
| 154 |
+
"pages": [
|
| 155 |
+
{
|
| 156 |
+
"page_id": 1,
|
| 157 |
+
"project_id": 1,
|
| 158 |
+
"page_number": 1,
|
| 159 |
+
"image_path": "uploads/project_1_page_1_abc123.png",
|
| 160 |
+
"image_width": 2480,
|
| 161 |
+
"image_height": 3508,
|
| 162 |
+
"analysis_status": "completed",
|
| 163 |
+
"processing_time": 5.23,
|
| 164 |
+
"created_at": "2025-01-22T10:31:00",
|
| 165 |
+
"analyzed_at": "2025-01-22T10:35:00"
|
| 166 |
+
}
|
| 167 |
+
]
|
| 168 |
+
}
|
| 169 |
+
```
|
| 170 |
+
|
| 171 |
+
### ProjectUpdate
|
| 172 |
+
|
| 173 |
+
프로젝트 수정 요청 스키마입니다. 모든 필드는 선택사항입니다.
|
| 174 |
+
|
| 175 |
+
```typescript
|
| 176 |
+
interface ProjectUpdate {
|
| 177 |
+
project_name?: string; // 프로젝트 이름
|
| 178 |
+
doc_type_id?: number; // 문서 타입 ID
|
| 179 |
+
analysis_mode?: string; // 분석 모드
|
| 180 |
+
status?: string; // 프로젝트 상태
|
| 181 |
+
}
|
| 182 |
+
```
|
| 183 |
+
|
| 184 |
+
---
|
| 185 |
+
|
| 186 |
+
## 페이지 관련 스키마
|
| 187 |
+
|
| 188 |
+
### PageCreate
|
| 189 |
+
|
| 190 |
+
페이지 생성 요청 스키마입니다.
|
| 191 |
+
|
| 192 |
+
```typescript
|
| 193 |
+
interface PageCreate {
|
| 194 |
+
project_id: number; // 프로젝트 ID
|
| 195 |
+
page_number: number; // 페이지 번호 (1부터 시작)
|
| 196 |
+
image_path: string; // 이미지 파일 경로
|
| 197 |
+
image_width?: number; // 이미지 너비 (픽셀)
|
| 198 |
+
image_height?: number; // 이미지 높이 (픽셀)
|
| 199 |
+
}
|
| 200 |
+
```
|
| 201 |
+
|
| 202 |
+
### PageResponse
|
| 203 |
+
|
| 204 |
+
페이지 응답 스키마입니다.
|
| 205 |
+
|
| 206 |
+
```typescript
|
| 207 |
+
interface PageResponse {
|
| 208 |
+
page_id: number; // 페이지 고유 ID
|
| 209 |
+
project_id: number; // 소속 프로젝트 ID
|
| 210 |
+
page_number: number; // 페이지 번호
|
| 211 |
+
image_path: string; // 이미지 파일 경로
|
| 212 |
+
image_width: number | null; // 이미지 너비 (픽셀)
|
| 213 |
+
image_height: number | null;// 이미지 높이 (픽셀)
|
| 214 |
+
analysis_status: string; // 분석 상태
|
| 215 |
+
processing_time: number | null; // 처리 시간 (초)
|
| 216 |
+
created_at: string; // 생성일시
|
| 217 |
+
analyzed_at: string | null; // 분석 완료일시
|
| 218 |
+
}
|
| 219 |
+
```
|
| 220 |
+
|
| 221 |
+
**예시**:
|
| 222 |
+
|
| 223 |
+
```json
|
| 224 |
+
{
|
| 225 |
+
"page_id": 1,
|
| 226 |
+
"project_id": 1,
|
| 227 |
+
"page_number": 1,
|
| 228 |
+
"image_path": "uploads/project_1_page_1_abc123.png",
|
| 229 |
+
"image_width": 2480,
|
| 230 |
+
"image_height": 3508,
|
| 231 |
+
"analysis_status": "completed",
|
| 232 |
+
"processing_time": 5.23,
|
| 233 |
+
"created_at": "2025-01-22T10:31:00",
|
| 234 |
+
"analyzed_at": "2025-01-22T10:35:00"
|
| 235 |
+
}
|
| 236 |
+
```
|
| 237 |
+
|
| 238 |
+
### MultiPageCreateResponse
|
| 239 |
+
|
| 240 |
+
PDF 업로드 시 여러 페이지 생성 응답 스키마입니다.
|
| 241 |
+
|
| 242 |
+
```typescript
|
| 243 |
+
interface MultiPageCreateResponse {
|
| 244 |
+
project_id: number; // 프로젝트 ID
|
| 245 |
+
total_created: number; // 생성된 페이지 수
|
| 246 |
+
source_type: string; // 소스 타입 ("pdf" 또는 "image")
|
| 247 |
+
pages: PageResponse[]; // 생성된 페이지 목록
|
| 248 |
+
}
|
| 249 |
+
```
|
| 250 |
+
|
| 251 |
+
**예시**:
|
| 252 |
+
|
| 253 |
+
```json
|
| 254 |
+
{
|
| 255 |
+
"project_id": 1,
|
| 256 |
+
"total_created": 5,
|
| 257 |
+
"source_type": "pdf",
|
| 258 |
+
"pages": [
|
| 259 |
+
{
|
| 260 |
+
"page_id": 1,
|
| 261 |
+
"project_id": 1,
|
| 262 |
+
"page_number": 1,
|
| 263 |
+
"image_path": "uploads/3/page_1.png",
|
| 264 |
+
"image_width": 2480,
|
| 265 |
+
"image_height": 3508,
|
| 266 |
+
"analysis_status": "pending",
|
| 267 |
+
"processing_time": null,
|
| 268 |
+
"created_at": "2025-01-22T10:31:00",
|
| 269 |
+
"analyzed_at": null
|
| 270 |
+
}
|
| 271 |
+
// ... 나머지 페이지
|
| 272 |
+
]
|
| 273 |
+
}
|
| 274 |
+
```
|
| 275 |
+
|
| 276 |
+
---
|
| 277 |
+
|
| 278 |
+
## 레이아웃 및 분석 관련 스키마
|
| 279 |
+
|
| 280 |
+
### LayoutElementResponse
|
| 281 |
+
|
| 282 |
+
레이아웃 요소 응답 스키마입니다.
|
| 283 |
+
|
| 284 |
+
```typescript
|
| 285 |
+
interface LayoutElementResponse {
|
| 286 |
+
element_id: number; // 요소 고유 ID
|
| 287 |
+
page_id: number; // 소속 페이지 ID
|
| 288 |
+
class_name: string; // 클래스명 (question_number, text, figure, table 등)
|
| 289 |
+
confidence: number; // 신뢰도 (0.0~1.0)
|
| 290 |
+
bbox_x: number; // X 좌표 (왼쪽 상단)
|
| 291 |
+
bbox_y: number; // Y 좌표 (왼쪽 상단)
|
| 292 |
+
bbox_width: number; // 너비 (픽셀)
|
| 293 |
+
bbox_height: number; // 높이 (픽셀)
|
| 294 |
+
area: number | null; // 면적 (자동 계산)
|
| 295 |
+
y_position: number | null; // Y 정렬용 좌표
|
| 296 |
+
x_position: number | null; // X 정렬용 좌표
|
| 297 |
+
created_at: string; // 생성일시
|
| 298 |
+
}
|
| 299 |
+
```
|
| 300 |
+
|
| 301 |
+
### TextContentResponse
|
| 302 |
+
|
| 303 |
+
OCR 텍스트 응답 스키마입니다.
|
| 304 |
+
|
| 305 |
+
```typescript
|
| 306 |
+
interface TextContentResponse {
|
| 307 |
+
text_id: number; // OCR 결과 고유 ID
|
| 308 |
+
element_id: number; // 레이아웃 요소 ID
|
| 309 |
+
ocr_text: string; // OCR 추출 텍스트
|
| 310 |
+
ocr_engine: string; // OCR 엔진명 (예: "PaddleOCR")
|
| 311 |
+
ocr_confidence: number | null; // OCR 신뢰도 (0.0~1.0)
|
| 312 |
+
language: string; // 언어 코드 (ko, en, ja, zh)
|
| 313 |
+
created_at: string; // 생성일시
|
| 314 |
+
}
|
| 315 |
+
```
|
| 316 |
+
|
| 317 |
+
### AIDescriptionResponse
|
| 318 |
+
|
| 319 |
+
AI 설명 응답 스키마입니다.
|
| 320 |
+
|
| 321 |
+
```typescript
|
| 322 |
+
interface AIDescriptionResponse {
|
| 323 |
+
ai_desc_id: number; // AI 설명 고유 ID
|
| 324 |
+
element_id: number; // 레이아웃 요소 ID
|
| 325 |
+
description: string; // AI 생성 설명 텍스트
|
| 326 |
+
ai_model: string; // AI 모델명 (예: "gpt-4o-mini")
|
| 327 |
+
prompt_used: string | null; // 사용한 프롬프트 (디버깅용)
|
| 328 |
+
created_at: string; // 생성일시
|
| 329 |
+
}
|
| 330 |
+
```
|
| 331 |
+
|
| 332 |
+
---
|
| 333 |
+
|
| 334 |
+
## 텍스트 버전 관련 스키마
|
| 335 |
+
|
| 336 |
+
### PageTextResponse
|
| 337 |
+
|
| 338 |
+
페이지 텍스트 조회 응답 스키마입니다.
|
| 339 |
+
|
| 340 |
+
```typescript
|
| 341 |
+
interface PageTextResponse {
|
| 342 |
+
page_id: number; // 페이지 ID
|
| 343 |
+
version_id: number; // 텍스트 버전 ID
|
| 344 |
+
version_type: string; // 버전 유형 (original, auto_formatted, user_edited)
|
| 345 |
+
is_current: boolean; // 현재 버전 여부
|
| 346 |
+
content: string; // HTML 형식의 텍스트 내용
|
| 347 |
+
created_at: string; // 버전 생성일시
|
| 348 |
+
}
|
| 349 |
+
```
|
| 350 |
+
|
| 351 |
+
**예시**:
|
| 352 |
+
|
| 353 |
+
```json
|
| 354 |
+
{
|
| 355 |
+
"page_id": 1,
|
| 356 |
+
"version_id": 3,
|
| 357 |
+
"version_type": "user_edited",
|
| 358 |
+
"is_current": true,
|
| 359 |
+
"content": "<h2>1. 다음 식을 계산하시오.</h2>\n<p>(1) 3 + 5 = ?</p>\n<p>답: 8</p>",
|
| 360 |
+
"created_at": "2025-01-22T11:00:00"
|
| 361 |
+
}
|
| 362 |
+
```
|
| 363 |
+
|
| 364 |
+
### PageTextUpdate
|
| 365 |
+
|
| 366 |
+
페이지 텍스트 저장 요청 스키마입니다.
|
| 367 |
+
|
| 368 |
+
```typescript
|
| 369 |
+
interface PageTextUpdate {
|
| 370 |
+
content: string; // 저장할 텍스트 내용 (HTML 형식)
|
| 371 |
+
user_id?: number; // 수정한 사용자 ID (선택)
|
| 372 |
+
}
|
| 373 |
+
```
|
| 374 |
+
|
| 375 |
+
**예시**:
|
| 376 |
+
|
| 377 |
+
```json
|
| 378 |
+
{
|
| 379 |
+
"content": "<h2>1. 다음 식을 계산하시오.</h2>\n<p>(1) 3 + 5 = ?</p>\n<p>답: 8</p>",
|
| 380 |
+
"user_id": 1
|
| 381 |
+
}
|
| 382 |
+
```
|
| 383 |
+
|
| 384 |
+
### TextVersionResponse
|
| 385 |
+
|
| 386 |
+
텍스트 버전 전체 정보 응답 스키마입니다.
|
| 387 |
+
|
| 388 |
+
```typescript
|
| 389 |
+
interface TextVersionResponse {
|
| 390 |
+
version_id: number; // 버전 고유 ID
|
| 391 |
+
page_id: number; // 페이지 ID
|
| 392 |
+
user_id: number | null; // 수정한 사용자 ID
|
| 393 |
+
content: string; // 텍스트 내용
|
| 394 |
+
version_number: number; // 버전 번호 (1, 2, 3, ...)
|
| 395 |
+
version_type: string; // 버전 유형
|
| 396 |
+
is_current: boolean; // 현재 버전 여부
|
| 397 |
+
created_at: string; // 버전 생성일시
|
| 398 |
+
}
|
| 399 |
+
```
|
| 400 |
+
|
| 401 |
+
---
|
| 402 |
+
|
| 403 |
+
## 다운로드 관련 스키마
|
| 404 |
+
|
| 405 |
+
### CombinedTextResponse
|
| 406 |
+
|
| 407 |
+
통합 텍스트 조회 응답 스키마입니다.
|
| 408 |
+
|
| 409 |
+
```typescript
|
| 410 |
+
interface CombinedTextResponse {
|
| 411 |
+
project_id: number; // 프로젝트 ID
|
| 412 |
+
project_name: string | null;// 프로젝트 이름
|
| 413 |
+
combined_text: string; // 통합된 전체 텍스트 (HTML)
|
| 414 |
+
stats: CombinedTextStats; // 통계 정보
|
| 415 |
+
generated_at: string; // 생성일시
|
| 416 |
+
}
|
| 417 |
+
|
| 418 |
+
interface CombinedTextStats {
|
| 419 |
+
total_pages: number; // 총 페이지 수
|
| 420 |
+
total_words: number; // 총 단어 수
|
| 421 |
+
total_characters: number; // 총 문자 수
|
| 422 |
+
}
|
| 423 |
+
```
|
| 424 |
+
|
| 425 |
+
**예시**:
|
| 426 |
+
|
| 427 |
+
```json
|
| 428 |
+
{
|
| 429 |
+
"project_id": 1,
|
| 430 |
+
"project_name": "수학 문제집 1단원",
|
| 431 |
+
"combined_text": "<h2>1. 다음 식을 계산하시오.</h2>\n<p>(1) 3 + 5 = ?</p>\n<p>답: 8</p>",
|
| 432 |
+
"stats": {
|
| 433 |
+
"total_pages": 3,
|
| 434 |
+
"total_words": 450,
|
| 435 |
+
"total_characters": 2340
|
| 436 |
+
},
|
| 437 |
+
"generated_at": "2025-01-22T11:00:00"
|
| 438 |
+
}
|
| 439 |
+
```
|
| 440 |
+
|
| 441 |
+
---
|
| 442 |
+
|
| 443 |
+
## 분석 관련 스키마
|
| 444 |
+
|
| 445 |
+
### ProjectAnalysisRequest
|
| 446 |
+
|
| 447 |
+
프로젝트 분석 요청 스키마입니다.
|
| 448 |
+
|
| 449 |
+
```typescript
|
| 450 |
+
interface ProjectAnalysisRequest {
|
| 451 |
+
use_ai_descriptions?: boolean; // AI 설명 생성 여부 (기본값: true)
|
| 452 |
+
api_key?: string | null; // OpenAI API 키 (선택)
|
| 453 |
+
}
|
| 454 |
+
```
|
| 455 |
+
|
| 456 |
+
### PageAnalysisRequest
|
| 457 |
+
|
| 458 |
+
단일 페이지 비동기 분석 요청 스키마입니다.
|
| 459 |
+
|
| 460 |
+
```typescript
|
| 461 |
+
interface PageAnalysisRequest {
|
| 462 |
+
use_ai_descriptions?: boolean; // AI 설명 생성 여부 (기본값: true)
|
| 463 |
+
api_key?: string | null; // OpenAI API 키 (선택)
|
| 464 |
+
}
|
| 465 |
+
```
|
| 466 |
+
|
| 467 |
+
### AnalysisJobResponse
|
| 468 |
+
|
| 469 |
+
비동기 분석 작업 상태 응답 스키마입니다.
|
| 470 |
+
|
| 471 |
+
```typescript
|
| 472 |
+
interface AnalysisJobResponse {
|
| 473 |
+
job_id: string; // 작업 ID (UUID)
|
| 474 |
+
status: string; // 작업 상태 (pending, processing, completed, failed)
|
| 475 |
+
page_id: number; // 페이지 ID
|
| 476 |
+
page_number: number; // 페이지 번호
|
| 477 |
+
project_id: number; // 프로젝트 ID
|
| 478 |
+
result: AnalysisResult | null; // 분석 결과 (완료 시)
|
| 479 |
+
error: string | null; // 에러 메시지 (실패 시)
|
| 480 |
+
progress: string; // 진행 상황 메시지
|
| 481 |
+
}
|
| 482 |
+
|
| 483 |
+
interface AnalysisResult {
|
| 484 |
+
page_id: number;
|
| 485 |
+
page_number: number;
|
| 486 |
+
layout_count: number; // 감지된 레이아웃 요소 수
|
| 487 |
+
ocr_count: number; // OCR 수행된 요소 수
|
| 488 |
+
ai_description_count: number; // AI 설명 생성된 요소 수
|
| 489 |
+
processing_time: number; // 처리 시간 (초)
|
| 490 |
+
message: string; // 결과 메시지
|
| 491 |
+
}
|
| 492 |
+
```
|
| 493 |
+
|
| 494 |
+
---
|
| 495 |
+
|
| 496 |
+
## 에러 응답 스키마
|
| 497 |
+
|
| 498 |
+
### ErrorResponse
|
| 499 |
+
|
| 500 |
+
에러 응답 스키마입니다.
|
| 501 |
+
|
| 502 |
+
```typescript
|
| 503 |
+
interface ErrorResponse {
|
| 504 |
+
error: string; // 에러 메시지
|
| 505 |
+
detail?: string; // 상세 설명 (선택)
|
| 506 |
+
status_code: number; // HTTP 상태 코드
|
| 507 |
+
}
|
| 508 |
+
```
|
| 509 |
+
|
| 510 |
+
**예시**:
|
| 511 |
+
|
| 512 |
+
```json
|
| 513 |
+
{
|
| 514 |
+
"error": "프로젝트를 찾을 수 없습니다.",
|
| 515 |
+
"status_code": 404
|
| 516 |
+
}
|
| 517 |
+
```
|
| 518 |
+
|
| 519 |
+
```json
|
| 520 |
+
{
|
| 521 |
+
"error": "Validation Error",
|
| 522 |
+
"detail": "project_name은 1자 이상이어야 합니다.",
|
| 523 |
+
"status_code": 400
|
| 524 |
+
}
|
| 525 |
+
```
|
| 526 |
+
|
| 527 |
+
---
|
| 528 |
+
|
| 529 |
+
## TypeScript 타입 정의 파일
|
| 530 |
+
|
| 531 |
+
프론트엔드에서 사용할 수 있는 TypeScript 타입 정의 예시입니다:
|
| 532 |
+
|
| 533 |
+
```typescript
|
| 534 |
+
// types/api.ts
|
| 535 |
+
|
| 536 |
+
// Enums
|
| 537 |
+
export enum AnalysisModeEnum {
|
| 538 |
+
AUTO = 'auto',
|
| 539 |
+
MANUAL = 'manual',
|
| 540 |
+
HYBRID = 'hybrid',
|
| 541 |
+
}
|
| 542 |
+
|
| 543 |
+
export enum ProjectStatusEnum {
|
| 544 |
+
CREATED = 'created',
|
| 545 |
+
IN_PROGRESS = 'in_progress',
|
| 546 |
+
COMPLETED = 'completed',
|
| 547 |
+
ERROR = 'error',
|
| 548 |
+
}
|
| 549 |
+
|
| 550 |
+
export enum AnalysisStatusEnum {
|
| 551 |
+
PENDING = 'pending',
|
| 552 |
+
PROCESSING = 'processing',
|
| 553 |
+
COMPLETED = 'completed',
|
| 554 |
+
ERROR = 'error',
|
| 555 |
+
}
|
| 556 |
+
|
| 557 |
+
export enum VersionTypeEnum {
|
| 558 |
+
ORIGINAL = 'original',
|
| 559 |
+
AUTO_FORMATTED = 'auto_formatted',
|
| 560 |
+
USER_EDITED = 'user_edited',
|
| 561 |
+
}
|
| 562 |
+
|
| 563 |
+
// Project
|
| 564 |
+
export interface ProjectCreate {
|
| 565 |
+
project_name: string;
|
| 566 |
+
doc_type_id: number;
|
| 567 |
+
analysis_mode?: AnalysisModeEnum;
|
| 568 |
+
user_id: number;
|
| 569 |
+
}
|
| 570 |
+
|
| 571 |
+
export interface ProjectResponse {
|
| 572 |
+
project_id: number;
|
| 573 |
+
user_id: number;
|
| 574 |
+
doc_type_id: number;
|
| 575 |
+
project_name: string;
|
| 576 |
+
total_pages: number;
|
| 577 |
+
analysis_mode: AnalysisModeEnum;
|
| 578 |
+
status: ProjectStatusEnum;
|
| 579 |
+
created_at: string;
|
| 580 |
+
updated_at: string;
|
| 581 |
+
}
|
| 582 |
+
|
| 583 |
+
export interface ProjectWithPagesResponse extends ProjectResponse {
|
| 584 |
+
pages: PageResponse[];
|
| 585 |
+
}
|
| 586 |
+
|
| 587 |
+
// Page
|
| 588 |
+
export interface PageResponse {
|
| 589 |
+
page_id: number;
|
| 590 |
+
project_id: number;
|
| 591 |
+
page_number: number;
|
| 592 |
+
image_path: string;
|
| 593 |
+
image_width: number | null;
|
| 594 |
+
image_height: number | null;
|
| 595 |
+
analysis_status: AnalysisStatusEnum;
|
| 596 |
+
processing_time: number | null;
|
| 597 |
+
created_at: string;
|
| 598 |
+
analyzed_at: string | null;
|
| 599 |
+
}
|
| 600 |
+
|
| 601 |
+
export interface MultiPageCreateResponse {
|
| 602 |
+
project_id: number;
|
| 603 |
+
total_created: number;
|
| 604 |
+
source_type: string;
|
| 605 |
+
pages: PageResponse[];
|
| 606 |
+
}
|
| 607 |
+
|
| 608 |
+
// Text
|
| 609 |
+
export interface PageTextResponse {
|
| 610 |
+
page_id: number;
|
| 611 |
+
version_id: number;
|
| 612 |
+
version_type: VersionTypeEnum;
|
| 613 |
+
is_current: boolean;
|
| 614 |
+
content: string;
|
| 615 |
+
created_at: string;
|
| 616 |
+
}
|
| 617 |
+
|
| 618 |
+
export interface PageTextUpdate {
|
| 619 |
+
content: string;
|
| 620 |
+
user_id?: number;
|
| 621 |
+
}
|
| 622 |
+
|
| 623 |
+
// Download
|
| 624 |
+
export interface CombinedTextStats {
|
| 625 |
+
total_pages: number;
|
| 626 |
+
total_words: number;
|
| 627 |
+
total_characters: number;
|
| 628 |
+
}
|
| 629 |
+
|
| 630 |
+
export interface CombinedTextResponse {
|
| 631 |
+
project_id: number;
|
| 632 |
+
project_name: string | null;
|
| 633 |
+
combined_text: string;
|
| 634 |
+
stats: CombinedTextStats;
|
| 635 |
+
generated_at: string;
|
| 636 |
+
}
|
| 637 |
+
|
| 638 |
+
// Analysis
|
| 639 |
+
export interface AnalysisRequest {
|
| 640 |
+
use_ai_descriptions?: boolean;
|
| 641 |
+
api_key?: string | null;
|
| 642 |
+
}
|
| 643 |
+
|
| 644 |
+
export interface AnalysisResult {
|
| 645 |
+
page_id: number;
|
| 646 |
+
page_number: number;
|
| 647 |
+
layout_count: number;
|
| 648 |
+
ocr_count: number;
|
| 649 |
+
ai_description_count: number;
|
| 650 |
+
processing_time: number;
|
| 651 |
+
message: string;
|
| 652 |
+
}
|
| 653 |
+
|
| 654 |
+
export interface AnalysisJobResponse {
|
| 655 |
+
job_id: string;
|
| 656 |
+
status: 'pending' | 'processing' | 'completed' | 'failed';
|
| 657 |
+
page_id: number;
|
| 658 |
+
page_number: number;
|
| 659 |
+
project_id: number;
|
| 660 |
+
result: AnalysisResult | null;
|
| 661 |
+
error: string | null;
|
| 662 |
+
progress: string;
|
| 663 |
+
}
|
| 664 |
+
|
| 665 |
+
// Error
|
| 666 |
+
export interface ErrorResponse {
|
| 667 |
+
error: string;
|
| 668 |
+
detail?: string;
|
| 669 |
+
status_code: number;
|
| 670 |
+
}
|
| 671 |
+
```
|
| 672 |
+
|
| 673 |
+
---
|
| 674 |
+
|
| 675 |
+
## 다음 단계
|
| 676 |
+
|
| 677 |
+
- **[에러 처리](./06_에러_처리.md)**: 에러 코드 및 처리 방법
|
| 678 |
+
- **[개요](./00_개요.md)**: 시스템 소개 및 시작하기
|
docs/Backend API 문서/06_에러_처리.md
ADDED
|
@@ -0,0 +1,556 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# 에러 처리
|
| 2 |
+
|
| 3 |
+
SmartEyeSsen API의 에러 응답 형식과 처리 방법을 설명합니다.
|
| 4 |
+
|
| 5 |
+
## 📖 목차
|
| 6 |
+
|
| 7 |
+
- [에러 응답 형식](#에러-응답-형식)
|
| 8 |
+
- [HTTP 상태 코드](#http-상태-코드)
|
| 9 |
+
- [공통 에러 코드](#공통-에러-코드)
|
| 10 |
+
- [에러 처리 모범 사례](#에러-처리-모범-사례)
|
| 11 |
+
- [프론트엔드 에러 핸들러 예제](#프론트엔드-에러-핸들러-예제)
|
| 12 |
+
|
| 13 |
+
---
|
| 14 |
+
|
| 15 |
+
## 에러 응답 형식
|
| 16 |
+
|
| 17 |
+
모든 API 에러는 일관된 JSON 형식으로 반환됩니다.
|
| 18 |
+
|
| 19 |
+
### 기본 형식
|
| 20 |
+
|
| 21 |
+
```json
|
| 22 |
+
{
|
| 23 |
+
"error": "에러 메시지",
|
| 24 |
+
"detail": "상세 설명 (선택)",
|
| 25 |
+
"status_code": 400
|
| 26 |
+
}
|
| 27 |
+
```
|
| 28 |
+
|
| 29 |
+
### 필드 설명
|
| 30 |
+
|
| 31 |
+
| 필드 | 타입 | 필수 | 설명 |
|
| 32 |
+
|------|------|------|------|
|
| 33 |
+
| `error` | string | ✅ | 간단한 에러 메시지 |
|
| 34 |
+
| `detail` | string | ❌ | 상세한 에러 설명 (선택) |
|
| 35 |
+
| `status_code` | number | ✅ | HTTP 상태 코드 |
|
| 36 |
+
|
| 37 |
+
---
|
| 38 |
+
|
| 39 |
+
## HTTP 상태 코드
|
| 40 |
+
|
| 41 |
+
### 2xx: 성공
|
| 42 |
+
|
| 43 |
+
| 코드 | 설명 | 사용 예시 |
|
| 44 |
+
|------|------|----------|
|
| 45 |
+
| 200 OK | 요청 성공 | GET 요청, PATCH 요청 |
|
| 46 |
+
| 201 Created | 리소스 생성 성공 | POST 요청 (프로젝트 생성, 페이지 업로드) |
|
| 47 |
+
| 202 Accepted | 요청 수락됨 (비동기 처리) | 분석 작업 시작 |
|
| 48 |
+
| 204 No Content | 성공, 응답 본문 없음 | DELETE 요청 |
|
| 49 |
+
|
| 50 |
+
### 4xx: 클라이언트 에러
|
| 51 |
+
|
| 52 |
+
#### 400 Bad Request
|
| 53 |
+
|
| 54 |
+
요청 데이터가 유효하지 않은 경우
|
| 55 |
+
|
| 56 |
+
**예시**:
|
| 57 |
+
|
| 58 |
+
```json
|
| 59 |
+
{
|
| 60 |
+
"error": "Validation Error",
|
| 61 |
+
"detail": "project_name은 1자 이상이어야 합니다.",
|
| 62 |
+
"status_code": 400
|
| 63 |
+
}
|
| 64 |
+
```
|
| 65 |
+
|
| 66 |
+
**원인**:
|
| 67 |
+
- 필수 필드 누락
|
| 68 |
+
- 데이터 타입 불일치
|
| 69 |
+
- 유효성 검증 실패
|
| 70 |
+
- 잘못된 파일 형식
|
| 71 |
+
|
| 72 |
+
**해결 방법**:
|
| 73 |
+
- 요청 데이터 형식 확인
|
| 74 |
+
- API 문서의 필수 필드 확인
|
| 75 |
+
- 데이터 타입 검증
|
| 76 |
+
|
| 77 |
+
#### 404 Not Found
|
| 78 |
+
|
| 79 |
+
요청한 리소스를 찾을 수 없는 경우
|
| 80 |
+
|
| 81 |
+
**예시**:
|
| 82 |
+
|
| 83 |
+
```json
|
| 84 |
+
{
|
| 85 |
+
"error": "프로젝트를 찾을 수 없습니다.",
|
| 86 |
+
"status_code": 404
|
| 87 |
+
}
|
| 88 |
+
```
|
| 89 |
+
|
| 90 |
+
**원인**:
|
| 91 |
+
- 존재하지 않는 ID로 조회
|
| 92 |
+
- 삭제된 리소스 접근
|
| 93 |
+
- 잘못된 URL
|
| 94 |
+
|
| 95 |
+
**해결 방법**:
|
| 96 |
+
- ID 값 확인
|
| 97 |
+
- 리소스 존재 여부 사전 확인
|
| 98 |
+
- 사용자에게 적절한 메시지 표시
|
| 99 |
+
|
| 100 |
+
#### 413 Payload Too Large
|
| 101 |
+
|
| 102 |
+
요청 본문이 너무 큰 경우 (일반적으로 파일 업로드)
|
| 103 |
+
|
| 104 |
+
**예시**:
|
| 105 |
+
|
| 106 |
+
```json
|
| 107 |
+
{
|
| 108 |
+
"error": "파일 크기가 너무 큽니다.",
|
| 109 |
+
"detail": "최대 50MB까지 업로드 가능합니다.",
|
| 110 |
+
"status_code": 413
|
| 111 |
+
}
|
| 112 |
+
```
|
| 113 |
+
|
| 114 |
+
**원인**:
|
| 115 |
+
- 업로드 파일 크기 제한 초과 (일반적으로 50MB)
|
| 116 |
+
|
| 117 |
+
**해결 방법**:
|
| 118 |
+
- 파일 크기 사전 확인
|
| 119 |
+
- 사용자에게 파일 크기 제한 안내
|
| 120 |
+
- 파일 압축 권장
|
| 121 |
+
|
| 122 |
+
#### 422 Unprocessable Entity
|
| 123 |
+
|
| 124 |
+
요청 형식은 올바르나 의미상 처리할 수 없는 경우
|
| 125 |
+
|
| 126 |
+
**예시**:
|
| 127 |
+
|
| 128 |
+
```json
|
| 129 |
+
{
|
| 130 |
+
"error": "Unprocessable Entity",
|
| 131 |
+
"detail": "이미 분석이 완료된 페이지입니다.",
|
| 132 |
+
"status_code": 422
|
| 133 |
+
}
|
| 134 |
+
```
|
| 135 |
+
|
| 136 |
+
**원인**:
|
| 137 |
+
- 비즈니스 로직 위반
|
| 138 |
+
- 상태 충돌
|
| 139 |
+
|
| 140 |
+
**해결 방법**:
|
| 141 |
+
- 리소스 현재 상태 확인
|
| 142 |
+
- 조건부 요청 처리
|
| 143 |
+
|
| 144 |
+
### 5xx: 서버 에러
|
| 145 |
+
|
| 146 |
+
#### 500 Internal Server Error
|
| 147 |
+
|
| 148 |
+
서버 내부 오류
|
| 149 |
+
|
| 150 |
+
**예시**:
|
| 151 |
+
|
| 152 |
+
```json
|
| 153 |
+
{
|
| 154 |
+
"error": "Internal Server Error",
|
| 155 |
+
"detail": "데이터베이스 연결 실패",
|
| 156 |
+
"status_code": 500
|
| 157 |
+
}
|
| 158 |
+
```
|
| 159 |
+
|
| 160 |
+
**원인**:
|
| 161 |
+
- 서버 내부 버그
|
| 162 |
+
- 데이터베이스 연결 오류
|
| 163 |
+
- 예상치 못한 예외
|
| 164 |
+
|
| 165 |
+
**해결 방법**:
|
| 166 |
+
- 잠시 후 재시도
|
| 167 |
+
- 계속 발생 시 관리자에게 문의
|
| 168 |
+
- 에러 로그 수집 및 리포트
|
| 169 |
+
|
| 170 |
+
#### 501 Not Implemented
|
| 171 |
+
|
| 172 |
+
기능이 구현되지 않음
|
| 173 |
+
|
| 174 |
+
**예시**:
|
| 175 |
+
|
| 176 |
+
```json
|
| 177 |
+
{
|
| 178 |
+
"error": "python-docx 라이브러리가 설치되지 않았습니다.",
|
| 179 |
+
"status_code": 501
|
| 180 |
+
}
|
| 181 |
+
```
|
| 182 |
+
|
| 183 |
+
**원인**:
|
| 184 |
+
- 서버 환경 설정 문제
|
| 185 |
+
- 필수 라이브러리 미설치
|
| 186 |
+
|
| 187 |
+
**해결 방법**:
|
| 188 |
+
- 관리자에게 문의
|
| 189 |
+
- 대체 기능 사용
|
| 190 |
+
|
| 191 |
+
#### 503 Service Unavailable
|
| 192 |
+
|
| 193 |
+
서비스 일시적 사용 불가
|
| 194 |
+
|
| 195 |
+
**예시**:
|
| 196 |
+
|
| 197 |
+
```json
|
| 198 |
+
{
|
| 199 |
+
"error": "Service Unavailable",
|
| 200 |
+
"detail": "서버가 과부하 상태입니다. 잠시 후 다시 시도해주세요.",
|
| 201 |
+
"status_code": 503
|
| 202 |
+
}
|
| 203 |
+
```
|
| 204 |
+
|
| 205 |
+
**원인**:
|
| 206 |
+
- 서버 과부하
|
| 207 |
+
- 유지보수 중
|
| 208 |
+
- 일시적 장애
|
| 209 |
+
|
| 210 |
+
**해결 방법**:
|
| 211 |
+
- 지수 백오프(exponential backoff)로 재시도
|
| 212 |
+
- 사용자에게 안내 메시지 표시
|
| 213 |
+
|
| 214 |
+
---
|
| 215 |
+
|
| 216 |
+
## 공통 에러 코드
|
| 217 |
+
|
| 218 |
+
### 프로젝트 관련
|
| 219 |
+
|
| 220 |
+
| HTTP | 에러 메시지 | 원인 | 해결 방법 |
|
| 221 |
+
|------|------------|------|----------|
|
| 222 |
+
| 400 | `project_name은 1자 이상이어야 합니다.` | 프로젝트 이름이 비어있음 | 프로젝트 이름 입력 확인 |
|
| 223 |
+
| 400 | `doc_type_id는 필수입니다.` | 문서 타입 ID 누락 | doc_type_id 제공 (1 또는 2) |
|
| 224 |
+
| 404 | `프로젝트를 찾을 수 없습니다.` | 존재하지 않는 프로젝트 ID | 프로젝트 ID 확인 |
|
| 225 |
+
|
| 226 |
+
### 페이지 관련
|
| 227 |
+
|
| 228 |
+
| HTTP | 에러 메시지 | 원인 | 해결 방법 |
|
| 229 |
+
|------|------------|------|----------|
|
| 230 |
+
| 400 | `이미지 업로드 시 page_number는 필수입니다.` | 이미지 업로드 시 페이지 번호 누락 | page_number 제공 |
|
| 231 |
+
| 400 | `이미지 처리 실패` | 손상된 이미지 파일 | 올바른 이미지 파일 확인 |
|
| 232 |
+
| 400 | `PDF 처리 실패` | 손상된 PDF 파일 | 올바른 PDF 파일 확인 |
|
| 233 |
+
| 404 | `페이지를 찾을 수 없습니다.` | 존재하지 않는 페이지 ID | 페이지 ID 확인 |
|
| 234 |
+
| 413 | `파일 크기가 너무 큽니다.` | 파일 크기 제한 초과 | 파일 크기 확인 (최대 50MB) |
|
| 235 |
+
|
| 236 |
+
### 분석 관련
|
| 237 |
+
|
| 238 |
+
| HTTP | 에러 메시지 | 원인 | 해결 방법 |
|
| 239 |
+
|------|------------|------|----------|
|
| 240 |
+
| 400 | `분석할 페이지가 없습니다.` | pending 상태 페이지 없음 | 페이지 업로드 먼저 수행 |
|
| 241 |
+
| 404 | `작업을 찾을 수 없습니다.` | 존재하지 않는 작업 ID | 작업 ID 확인 |
|
| 242 |
+
| 500 | `레이아웃 분석 중 오류가 발생했습니다.` | AI 모델 오류 | 재시도 또는 관리자 문의 |
|
| 243 |
+
|
| 244 |
+
### 다운로드 관련
|
| 245 |
+
|
| 246 |
+
| HTTP | 에러 메시지 | 원인 | 해결 방법 |
|
| 247 |
+
|------|------------|------|----------|
|
| 248 |
+
| 404 | `통합 텍스트를 찾을 수 없습니다.` | 분석되지 않은 프로젝트 | 분석 먼저 수행 |
|
| 249 |
+
| 500 | `Word 문서 생성 중 오류가 발생했습니다.` | 문서 변환 오류 | 재시도 또는 관리자 문의 |
|
| 250 |
+
| 501 | `python-docx 라이브러리가 설치되지 않았습니다.` | 서버 설정 문제 | 관리자에게 문의 |
|
| 251 |
+
|
| 252 |
+
---
|
| 253 |
+
|
| 254 |
+
## 에러 처리 모범 사례
|
| 255 |
+
|
| 256 |
+
### 1. 일관된 에러 핸들링
|
| 257 |
+
|
| 258 |
+
모든 API 호출에 대해 일관된 에러 처리 로직을 적용하세요.
|
| 259 |
+
|
| 260 |
+
```javascript
|
| 261 |
+
// ❌ 나쁜 예
|
| 262 |
+
fetch('/api/projects')
|
| 263 |
+
.then(res => res.json())
|
| 264 |
+
.then(data => console.log(data));
|
| 265 |
+
|
| 266 |
+
// ✅ 좋은 예
|
| 267 |
+
fetch('/api/projects')
|
| 268 |
+
.then(res => {
|
| 269 |
+
if (!res.ok) {
|
| 270 |
+
throw new Error(`HTTP ${res.status}`);
|
| 271 |
+
}
|
| 272 |
+
return res.json();
|
| 273 |
+
})
|
| 274 |
+
.then(data => console.log(data))
|
| 275 |
+
.catch(error => {
|
| 276 |
+
console.error('에러 발생:', error);
|
| 277 |
+
showErrorMessage(error.message);
|
| 278 |
+
});
|
| 279 |
+
```
|
| 280 |
+
|
| 281 |
+
### 2. 사용자 친화적인 에러 메시지
|
| 282 |
+
|
| 283 |
+
서버 에러 메시지를 그대로 표시하지 말고, 사용자가 이해하기 쉬운 메시지로 변환하세요.
|
| 284 |
+
|
| 285 |
+
```javascript
|
| 286 |
+
function getUserFriendlyMessage(error) {
|
| 287 |
+
const errorMessages = {
|
| 288 |
+
404: '요청한 항목을 찾을 수 없습니다.',
|
| 289 |
+
413: '파일 크기가 너무 큽니다. 50MB 이하의 파일을 선택해주세요.',
|
| 290 |
+
500: '서버 오류가 발생했습니다. 잠시 후 다시 시도해주세요.',
|
| 291 |
+
503: '서비스가 일시적으로 사용 불가능합니다. 잠시 후 다시 시도해주세요.'
|
| 292 |
+
};
|
| 293 |
+
|
| 294 |
+
return errorMessages[error.status_code] || '알 수 없는 오류가 발생했습니다.';
|
| 295 |
+
}
|
| 296 |
+
```
|
| 297 |
+
|
| 298 |
+
### 3. 재시도 로직
|
| 299 |
+
|
| 300 |
+
일시적인 오류(5xx)에 대해서는 재시도 로직을 구현하세요.
|
| 301 |
+
|
| 302 |
+
```javascript
|
| 303 |
+
async function fetchWithRetry(url, options = {}, maxRetries = 3) {
|
| 304 |
+
let lastError;
|
| 305 |
+
|
| 306 |
+
for (let i = 0; i < maxRetries; i++) {
|
| 307 |
+
try {
|
| 308 |
+
const response = await fetch(url, options);
|
| 309 |
+
|
| 310 |
+
if (response.ok || response.status < 500) {
|
| 311 |
+
// 성공하거나 4xx 에러는 재시도하지 않음
|
| 312 |
+
return response;
|
| 313 |
+
}
|
| 314 |
+
|
| 315 |
+
// 5xx 에러는 재시도
|
| 316 |
+
lastError = new Error(`HTTP ${response.status}`);
|
| 317 |
+
|
| 318 |
+
// 지수 백오프
|
| 319 |
+
await new Promise(resolve => setTimeout(resolve, Math.pow(2, i) * 1000));
|
| 320 |
+
|
| 321 |
+
} catch (error) {
|
| 322 |
+
lastError = error;
|
| 323 |
+
await new Promise(resolve => setTimeout(resolve, Math.pow(2, i) * 1000));
|
| 324 |
+
}
|
| 325 |
+
}
|
| 326 |
+
|
| 327 |
+
throw lastError;
|
| 328 |
+
}
|
| 329 |
+
```
|
| 330 |
+
|
| 331 |
+
### 4. 전역 에러 핸들러
|
| 332 |
+
|
| 333 |
+
전역 에러 핸들러를 설정하여 모든 에러를 중앙에서 관리하세요.
|
| 334 |
+
|
| 335 |
+
```javascript
|
| 336 |
+
// Axios 인터셉터 예제
|
| 337 |
+
axios.interceptors.response.use(
|
| 338 |
+
response => response,
|
| 339 |
+
error => {
|
| 340 |
+
if (error.response) {
|
| 341 |
+
// 서버 응답이 있는 경우
|
| 342 |
+
const { status, data } = error.response;
|
| 343 |
+
|
| 344 |
+
if (status === 401) {
|
| 345 |
+
// 인증 오류 - 로그인 페이지로 이동
|
| 346 |
+
window.location.href = '/login';
|
| 347 |
+
} else if (status === 403) {
|
| 348 |
+
// 권한 오류
|
| 349 |
+
showErrorToast('접근 권한이 없습니다.');
|
| 350 |
+
} else if (status === 404) {
|
| 351 |
+
// 리소스 없음
|
| 352 |
+
showErrorToast(data.error || '요청한 항목을 찾을 수 없습니다.');
|
| 353 |
+
} else if (status >= 500) {
|
| 354 |
+
// 서버 오류
|
| 355 |
+
showErrorToast('서버 오류가 발생했습니다. 잠시 후 다시 시도해주세요.');
|
| 356 |
+
// 에러 로깅
|
| 357 |
+
logErrorToServer(error);
|
| 358 |
+
} else {
|
| 359 |
+
// 기타 에러
|
| 360 |
+
showErrorToast(data.error || '오류가 발생했습니다.');
|
| 361 |
+
}
|
| 362 |
+
} else if (error.request) {
|
| 363 |
+
// 요청은 보냈으나 응답이 없는 경우 (네트워크 오류)
|
| 364 |
+
showErrorToast('네트워크 오류가 발생했습니다. 인터넷 연결을 확인해주세요.');
|
| 365 |
+
} else {
|
| 366 |
+
// 요청 설정 중 오류
|
| 367 |
+
showErrorToast('요청 처리 중 오류가 발생했습니다.');
|
| 368 |
+
}
|
| 369 |
+
|
| 370 |
+
return Promise.reject(error);
|
| 371 |
+
}
|
| 372 |
+
);
|
| 373 |
+
```
|
| 374 |
+
|
| 375 |
+
---
|
| 376 |
+
|
| 377 |
+
## 프론트엔드 에러 핸들러 예제
|
| 378 |
+
|
| 379 |
+
### React + Axios
|
| 380 |
+
|
| 381 |
+
```jsx
|
| 382 |
+
import React, { createContext, useContext, useState } from 'react';
|
| 383 |
+
import axios from 'axios';
|
| 384 |
+
|
| 385 |
+
// 에러 컨텍스트
|
| 386 |
+
const ErrorContext = createContext();
|
| 387 |
+
|
| 388 |
+
export const useError = () => useContext(ErrorContext);
|
| 389 |
+
|
| 390 |
+
export const ErrorProvider = ({ children }) => {
|
| 391 |
+
const [error, setError] = useState(null);
|
| 392 |
+
|
| 393 |
+
const showError = (message, detail = null) => {
|
| 394 |
+
setError({ message, detail });
|
| 395 |
+
setTimeout(() => setError(null), 5000); // 5초 후 자동 제거
|
| 396 |
+
};
|
| 397 |
+
|
| 398 |
+
const clearError = () => setError(null);
|
| 399 |
+
|
| 400 |
+
return (
|
| 401 |
+
<ErrorContext.Provider value={{ error, showError, clearError }}>
|
| 402 |
+
{children}
|
| 403 |
+
{error && (
|
| 404 |
+
<div className="error-toast">
|
| 405 |
+
<div className="error-message">{error.message}</div>
|
| 406 |
+
{error.detail && <div className="error-detail">{error.detail}</div>}
|
| 407 |
+
<button onClick={clearError}>×</button>
|
| 408 |
+
</div>
|
| 409 |
+
)}
|
| 410 |
+
</ErrorContext.Provider>
|
| 411 |
+
);
|
| 412 |
+
};
|
| 413 |
+
|
| 414 |
+
// API 클라이언트 설정
|
| 415 |
+
const apiClient = axios.create({
|
| 416 |
+
baseURL: 'http://localhost:8000',
|
| 417 |
+
});
|
| 418 |
+
|
| 419 |
+
// 에러 핸들링 래퍼
|
| 420 |
+
export const withErrorHandling = (apiCall) => async (...args) => {
|
| 421 |
+
const { showError } = useError();
|
| 422 |
+
|
| 423 |
+
try {
|
| 424 |
+
return await apiCall(...args);
|
| 425 |
+
} catch (error) {
|
| 426 |
+
if (error.response) {
|
| 427 |
+
const { status, data } = error.response;
|
| 428 |
+
|
| 429 |
+
if (status === 404) {
|
| 430 |
+
showError('요청한 항목을 찾을 수 없습니다.', data.detail);
|
| 431 |
+
} else if (status === 413) {
|
| 432 |
+
showError('파일 크기가 너무 큽니다.', '50MB 이하의 파일을 선택해주세요.');
|
| 433 |
+
} else if (status >= 500) {
|
| 434 |
+
showError('서버 오류가 발생했습니다.', '잠시 후 다시 시도해주세요.');
|
| 435 |
+
} else {
|
| 436 |
+
showError(data.error || '오류가 발생했습니다.', data.detail);
|
| 437 |
+
}
|
| 438 |
+
} else if (error.request) {
|
| 439 |
+
showError('네트워크 오류', '인터넷 연결을 확인해주세요.');
|
| 440 |
+
} else {
|
| 441 |
+
showError('요청 처리 오류', error.message);
|
| 442 |
+
}
|
| 443 |
+
|
| 444 |
+
throw error;
|
| 445 |
+
}
|
| 446 |
+
};
|
| 447 |
+
|
| 448 |
+
// 사용 예시
|
| 449 |
+
function MyComponent() {
|
| 450 |
+
const { showError } = useError();
|
| 451 |
+
const [projects, setProjects] = useState([]);
|
| 452 |
+
|
| 453 |
+
const fetchProjects = async () => {
|
| 454 |
+
try {
|
| 455 |
+
const response = await apiClient.get('/api/projects');
|
| 456 |
+
setProjects(response.data);
|
| 457 |
+
} catch (error) {
|
| 458 |
+
// 에러는 이미 ErrorProvider에서 처리됨
|
| 459 |
+
}
|
| 460 |
+
};
|
| 461 |
+
|
| 462 |
+
return (
|
| 463 |
+
<div>
|
| 464 |
+
<button onClick={fetchProjects}>프로젝트 불러오기</button>
|
| 465 |
+
<ul>
|
| 466 |
+
{projects.map(p => (
|
| 467 |
+
<li key={p.project_id}>{p.project_name}</li>
|
| 468 |
+
))}
|
| 469 |
+
</ul>
|
| 470 |
+
</div>
|
| 471 |
+
);
|
| 472 |
+
}
|
| 473 |
+
```
|
| 474 |
+
|
| 475 |
+
### Vue.js
|
| 476 |
+
|
| 477 |
+
```javascript
|
| 478 |
+
// plugins/api.js
|
| 479 |
+
import axios from 'axios';
|
| 480 |
+
|
| 481 |
+
export default {
|
| 482 |
+
install: (app) => {
|
| 483 |
+
const apiClient = axios.create({
|
| 484 |
+
baseURL: 'http://localhost:8000',
|
| 485 |
+
});
|
| 486 |
+
|
| 487 |
+
apiClient.interceptors.response.use(
|
| 488 |
+
response => response,
|
| 489 |
+
error => {
|
| 490 |
+
if (error.response) {
|
| 491 |
+
const { status, data } = error.response;
|
| 492 |
+
|
| 493 |
+
let message = '오류가 발생했습니다.';
|
| 494 |
+
|
| 495 |
+
if (status === 404) {
|
| 496 |
+
message = '요청한 항목을 찾을 수 없습니다.';
|
| 497 |
+
} else if (status === 413) {
|
| 498 |
+
message = '파일 크기가 너무 큽니다. 50MB 이하의 파일을 선택해주세요.';
|
| 499 |
+
} else if (status >= 500) {
|
| 500 |
+
message = '서버 오류가 발생했습니다. 잠시 후 다시 시도해주세요.';
|
| 501 |
+
} else if (data.error) {
|
| 502 |
+
message = data.error;
|
| 503 |
+
}
|
| 504 |
+
|
| 505 |
+
// Vue Toast 또는 Notification 라이브러리 사용
|
| 506 |
+
app.config.globalProperties.$toast.error(message);
|
| 507 |
+
} else if (error.request) {
|
| 508 |
+
app.config.globalProperties.$toast.error('네트워크 오류가 발생했습니다.');
|
| 509 |
+
}
|
| 510 |
+
|
| 511 |
+
return Promise.reject(error);
|
| 512 |
+
}
|
| 513 |
+
);
|
| 514 |
+
|
| 515 |
+
app.config.globalProperties.$api = apiClient;
|
| 516 |
+
}
|
| 517 |
+
};
|
| 518 |
+
```
|
| 519 |
+
|
| 520 |
+
---
|
| 521 |
+
|
| 522 |
+
## 에러 로깅
|
| 523 |
+
|
| 524 |
+
프로덕션 환경에서는 에러를 로깅하여 문제를 추적하세요.
|
| 525 |
+
|
| 526 |
+
```javascript
|
| 527 |
+
function logErrorToServer(error) {
|
| 528 |
+
// Sentry, LogRocket, 자체 로깅 서버 등에 에러 전송
|
| 529 |
+
if (window.Sentry) {
|
| 530 |
+
Sentry.captureException(error);
|
| 531 |
+
}
|
| 532 |
+
|
| 533 |
+
// 또는 자체 로깅 API 호출
|
| 534 |
+
fetch('/api/logs/errors', {
|
| 535 |
+
method: 'POST',
|
| 536 |
+
headers: { 'Content-Type': 'application/json' },
|
| 537 |
+
body: JSON.stringify({
|
| 538 |
+
message: error.message,
|
| 539 |
+
stack: error.stack,
|
| 540 |
+
url: window.location.href,
|
| 541 |
+
userAgent: navigator.userAgent,
|
| 542 |
+
timestamp: new Date().toISOString()
|
| 543 |
+
})
|
| 544 |
+
}).catch(() => {
|
| 545 |
+
// 로깅 실패 시 무시 (무한 루프 방지)
|
| 546 |
+
});
|
| 547 |
+
}
|
| 548 |
+
```
|
| 549 |
+
|
| 550 |
+
---
|
| 551 |
+
|
| 552 |
+
## 다음 단계
|
| 553 |
+
|
| 554 |
+
- **[개요](./00_개요.md)**: 시스템 소개 및 시작하기
|
| 555 |
+
- **[프로젝트 API](./01_프로젝트_API.md)**: 프로젝트 관리 API
|
| 556 |
+
- **[데이터 모델](./05_데이터_모델.md)**: 에러 응답 스키마
|
docs/Backend API 문서/07_예제_코드.md
ADDED
|
File without changes
|
docs/백엔드 환경 설정/백엔드 DB 준비방법.txt
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
• DB 준비 절차
|
| 2 |
+
|
| 3 |
+
- Backend/docker-compose.yml:3 의 mysql 서비스는 포트를 ${MYSQL_PORT:-3308}:3306 으로 매핑하므로 Backend/.env.docker 의 기본
|
| 4 |
+
값(3308)을 그대로 쓰면 호스트에서 127.0.0.1:3308 로 접근한다.
|
| 5 |
+
- 컨테이너 실행: docker compose -f Backend/docker-compose.yml --env-file Backend/.env.docker up -d mysql
|
| 6 |
+
(이미 띄워져 있다면 docker ps 로 상태만 확인).
|
| 7 |
+
- 백엔드가 같은 컨테이너를 사용하려면 .env 또는 환경 변수에 DB_HOST=127.0.0.1, DB_PORT=3308, DB_USER=root,
|
| 8 |
+
DB_PASSWORD=1q2w3e4r, DB_NAME=smarteyessen_db 를 명시한다 (Backend/.env.example 참고).
|
| 9 |
+
|
| 10 |
+
스키마 초기화
|
| 11 |
+
|
| 12 |
+
- Project/DB/erd_schema.sql:58 은 DROP DATABASE 후 전체 12개 테이블을 다시 생성하므로 실행 전 기존 데이터 백업을 권장한다.
|
| 13 |
+
- 스키마 적용:
|
| 14 |
+
|
| 15 |
+
docker exec -i smart_mysql \
|
| 16 |
+
mysql -uroot -p'1q2w3e4r' \
|
| 17 |
+
< Project/DB/erd_schema.sql
|
| 18 |
+
(스크립트가 내부에서 USE smarteyessen_db 를 수행).
|
| 19 |
+
- 완료 후 확인:
|
| 20 |
+
|
| 21 |
+
docker exec -it smart_mysql \
|
| 22 |
+
mysql -uroot -p'1q2w3e4r' smarteyessen_db \
|
| 23 |
+
-e "SHOW TABLES;"
|
| 24 |
+
|
| 25 |
+
기본 데이터 시드
|
| 26 |
+
|
| 27 |
+
- 테스트용 사용자/문서 타입만 필요한 경우 Backend/scripts/seed_test_data.sql 을 적용한다.
|
| 28 |
+
|
| 29 |
+
docker exec -i smart_mysql \
|
| 30 |
+
mysql -uroot -p'1q2w3e4r' smarteyessen_db \
|
| 31 |
+
< Backend/scripts/seed_test_data.sql
|
| 32 |
+
- SELECT user_id, email FROM users; 와 SELECT doc_type_id, type_name FROM document_types; 로 삽입 여부를 확인한다.
|
| 33 |
+
|
| 34 |
+
백엔드 연결 검증
|
| 35 |
+
|
| 36 |
+
- 가상환경에서 pip install -r Backend/requirements.txt 후 python -m Backend.app.database 를 실행하면 test_connection() 결과가
|
| 37 |
+
출력된다 (Backend/app/database.py:119).
|
| 38 |
+
- 서버 기동:
|
| 39 |
+
|
| 40 |
+
uvicorn Backend.app.main:app --reload --host 0.0.0.0 --port 8000
|
| 41 |
+
시작 로그에 ✅ Database connection successful 과 ✅ Database tables initialized 가 보이면 연결 성공.
|
| 42 |
+
- 런타임 헬스 체크: curl http://localhost:8000/health → 응답의 "database": "connected" 여부 확인.
|
| 43 |
+
|
| 44 |
+
파이프라인 테스트 방법
|
| 45 |
+
|
| 46 |
+
- 프로젝트 생성 → 단일 이미지 업로드 → 비동기 분석 → 상태 폴링 순으로 REST 엔드포인트를 호출한다 (Backend/app/routers/
|
| 47 |
+
pages.py:49, Backend/app/routers/analysis.py:45).
|
| 48 |
+
- 다중 이미지 또는 PDF(application/pdf) 업로드 후 POST /api/projects/{project_id}/analyze 로 배치 파이프라인을 호출하면
|
| 49 |
+
page_results 에 처리 요약이 담긴다.
|
| 50 |
+
- DB 검증 쿼리 예시:
|
| 51 |
+
|
| 52 |
+
SELECT page_id, analysis_status FROM pages WHERE project_id = ?;
|
| 53 |
+
SELECT COUNT(*) FROM layout_elements WHERE page_id = ?;
|
| 54 |
+
SELECT version_type FROM text_versions WHERE page_id = ? ORDER BY version_number DESC;
|
| 55 |
+
- OpenAI 설명을 테스트할 경우 .env 의 OPENAI_API_KEY 와 요청 본문 use_ai_descriptions=true 를 함께 지정한다.
|
| 56 |
+
|
| 57 |
+
문제 해결 팁
|
| 58 |
+
|
| 59 |
+
- 스키마 재생성 후에도 테이블이 보이지 않으면 볼륨(smart_mysql_data)을 삭제 후 다시 docker compose up 으로 마운트한다.
|
| 60 |
+
- 포트 충돌 시 Backend/.env.docker 의 MYSQL_PORT 값을 바꾼 뒤 컨테이너를 재기동하고 .env 도 동일하게 맞춘다.
|
| 61 |
+
- OCR/모델 로딩 실패는 백엔드 로그(loguru)에 경로·모델 키를 남기므로 tail -f 로 추적하면서 해결한다.
|
docs/백엔드 환경 설정/백엔드 테스트 준비방법.txt
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
• 구성 개요
|
| 2 |
+
|
| 3 |
+
- Backend/app/main.py:31 에서 FastAPI 앱을 초기화하고 CORS·라우터를 등록하며 시작/종료 훅에서 DB 연결과 테이블 생성을 보장
|
| 4 |
+
한다.
|
| 5 |
+
- Backend/app/database.py:27 는 .env 값을 읽어 MySQL용 SQLAlchemy 엔진과 세션 팩토리를 구성하고 health check/테이블 생성 유틸
|
| 6 |
+
을 제공한다.
|
| 7 |
+
- Backend/app/routers/pages.py:49 는 이미지·PDF 업로드 엔드포인트를 정의해 파일을 uploads/에 저장하고 pages 레코드를 생성
|
| 8 |
+
한다.
|
| 9 |
+
- Backend/app/routers/analysis.py:45 는 프로젝트 일괄 분석과 페이지 단일 분석(비동기) 엔드포인트를 노출하며 내부적으로 배치
|
| 10 |
+
파이프라인을 호출한다.
|
| 11 |
+
- Backend/app/services/batch_analysis.py:166 이 레이아웃→OCR→AI 설명→정렬→포맷팅까지 단일 페이지 파이프라인을 묶어 DB에 커밋
|
| 12 |
+
한다.
|
| 13 |
+
- Backend/app/services/text_version_service.py:49 는 자동 포맷팅 결과를 text_versions 테이블에 버전으로 적재하고 현재 버전을
|
| 14 |
+
관리한다.
|
| 15 |
+
|
| 16 |
+
파이프라인 흐름
|
| 17 |
+
|
| 18 |
+
- Backend/app/routers/pages.py:125 에서 멀티파트 업로드를 처리하여 원본 파일을 저장하고 pages 테이블에
|
| 19 |
+
analysis_status="pending" 으로 레코드를 만든다.
|
| 20 |
+
- Backend/app/services/analysis_service.py:308 이 DocLayout-YOLO로 레이아웃을 검출하고 layout_elements 테이블을 최신 상태로
|
| 21 |
+
갱신한다.
|
| 22 |
+
- Backend/app/services/analysis_service.py:533 는 PaddleOCR/Tesseract 기반 OCR 을 수행해 text_contents 테이블에 upsert 한다.
|
| 23 |
+
- Backend/app/services/analysis_service.py:620 이 AI 설명 옵션이 활성화된 figure/table/flowchart 요소에 대해 OpenAI 설명을 생
|
| 24 |
+
성하고 ai_descriptions 테이블을 업데이트한다.
|
| 25 |
+
- Backend/app/services/sorter.py:993 은 정렬 결과를 question_groups·question_elements 로 저장하여 문제 단위 그룹 정보를 유지
|
| 26 |
+
한다.
|
| 27 |
+
- Backend/app/services/batch_analysis.py:250 은 TextFormatter 출력으로 text_versions 를 생성하고 페이지/프로젝트 상태를
|
| 28 |
+
completed|partial|error 로 갱신한다.
|
| 29 |
+
|
| 30 |
+
서버 준비
|
| 31 |
+
|
| 32 |
+
- 환경 변수: cp Backend/.env.example Backend/.env 후 Docker MySQL 호스트/포트(예: 127.0.0.1:3308), 계정, OPENAI_API_KEY 등을
|
| 33 |
+
실제 값으로 지정한다.
|
| 34 |
+
- 의존성: python -m venv venv && source venv/bin/activate && pip install -r Backend/requirements.txt 로 모델·OCR·FastAPI 패키
|
| 35 |
+
지를 설치한다.
|
| 36 |
+
- DB: docker-compose -f Backend/docker-compose.yml up -d mysql 로 컨테이너를 가동하고 필요 시 Backend/scripts/
|
| 37 |
+
seed_test_data.sql 을 로드해 기본 user/doc_type 데이터를 넣는다.
|
| 38 |
+
- 마이그레이션: 개발 모드라면 ENVIRONMENT=development 인 상태에서 첫 서버 기동 시 init_db() 가 테이블을 자동 생성한다.
|
| 39 |
+
- 서버 실행: 리포지토리 루트에서 uvicorn Backend.app.main:app --host 0.0.0.0 --port 8000 --reload 로 백엔드를 기동하고
|
| 40 |
+
http://localhost:8000/docs 로 접근성을 확인한다.
|
| 41 |
+
|
| 42 |
+
# DB 시드 예시
|
| 43 |
+
docker exec -i smart_mysql mysql -uroot -p'1q2w3e4r' smarteyessen_db < Backend/scripts/seed_test_data.sql
|
| 44 |
+
|
| 45 |
+
단일 이미지 검증
|
| 46 |
+
|
| 47 |
+
- 프로젝트 생성: POST /api/projects 로 {"project_name": "...", "type_id": 1, "user_id": 1} 을 보내고 응답의 project_id 를 기
|
| 48 |
+
록한다.
|
| 49 |
+
- 이미지 업로드: POST /api/pages/upload 에 project_id, page_number=1, JPEG/PNG 파일을 멀티파트로 전송해 page_id 를 확보한다
|
| 50 |
+
(예시 파일: Backend/temp_image.jpg).
|
| 51 |
+
- 단일 분석 요청: POST /api/pages/{page_id}/analyze/async 로 AI 설명 사용 여부와 OpenAI 키를 전달해 비동기 작업을 시작한다.
|
| 52 |
+
- 상태 폴링: GET /api/analysis/jobs/{job_id} 를 수초 간격으로 호출하여 status 가 completed 로 변할 때까지 확인한다.
|
| 53 |
+
- 결과 검증: 완료 후 MySQL 에서 pages, layout_elements, text_contents, ai_descriptions, text_versions 를 page_id 기준으로 조
|
| 54 |
+
회해 레코드가 채워졌는지 확인한다.
|
| 55 |
+
|
| 56 |
+
BASE_URL=http://localhost:8000
|
| 57 |
+
|
| 58 |
+
# 1) 프로젝트 생성
|
| 59 |
+
PROJECT_ID=$(curl -s -X POST "$BASE_URL/api/projects" \
|
| 60 |
+
-H 'Content-Type: application/json' \
|
| 61 |
+
-d '{"project_name":"single-demo","type_id":1,"user_id":1}' | jq '.project_id')
|
| 62 |
+
|
| 63 |
+
# 2) 이미지 업로드
|
| 64 |
+
PAGE_RESP=$(curl -s -X POST "$BASE_URL/api/pages/upload" \
|
| 65 |
+
-F project_id="$PROJECT_ID" \
|
| 66 |
+
-F page_number=1 \
|
| 67 |
+
-F file=@Backend/temp_image.jpg)
|
| 68 |
+
PAGE_ID=$(echo "$PAGE_RESP" | jq '.page_id')
|
| 69 |
+
|
| 70 |
+
# 3) 비동기 분석
|
| 71 |
+
JOB_ID=$(curl -s -X POST "$BASE_URL/api/pages/$PAGE_ID/analyze/async" \
|
| 72 |
+
-H 'Content-Type: application/json' \
|
| 73 |
+
-d '{"use_ai_descriptions":false}' | jq -r '.job_id')
|
| 74 |
+
|
| 75 |
+
# 4) 상태 확인
|
| 76 |
+
curl -s "$BASE_URL/api/analysis/jobs/$JOB_ID" | jq
|
| 77 |
+
|
| 78 |
+
다중 이미지 검증
|
| 79 |
+
|
| 80 |
+
- 동일 프로젝트에 대해 page_number 를 증가시키며 ���러 장을 /api/pages/upload 으로 업로드한다.
|
| 81 |
+
- 업로드가 끝나면 POST /api/projects/{project_id}/analyze 를 호출해 pending/error 상태 페이지를 일괄 처리한다.
|
| 82 |
+
- 응답의 page_results 에서 각 페이지의 layout_count·ocr_count·status 를 검토한다.
|
| 83 |
+
- MySQL pages 테이블에서 해당 프로젝트의 analysis_status 가 모두 completed 인지 확인하고, question_groups·question_elements
|
| 84 |
+
에 페이지별 그룹이 생성됐는지 점검한다.
|
| 85 |
+
- 발견된 실패 페이지는 GET /api/pages/{page_id} 로 메타데이터를 확인하고 필요 시 개별 비동기 분석으로 재시도한다.
|
| 86 |
+
|
| 87 |
+
# 추가 이미지 업로드 (예: 두 번째 페이지)
|
| 88 |
+
curl -s -X POST "$BASE_URL/api/pages/upload" \
|
| 89 |
+
-F project_id="$PROJECT_ID" \
|
| 90 |
+
-F page_number=2 \
|
| 91 |
+
-F file=@path/to/second_page.png > /dev/null
|
| 92 |
+
|
| 93 |
+
# 프로젝트 전체 분석
|
| 94 |
+
curl -s -X POST "$BASE_URL/api/projects/$PROJECT_ID/analyze" \
|
| 95 |
+
-H 'Content-Type: application/json' \
|
| 96 |
+
-d '{"use_ai_descriptions":true,"api_key":"'"$OPENAI_API_KEY"'"}' | jq '.page_results'
|
| 97 |
+
|
| 98 |
+
PDF 검증
|
| 99 |
+
|
| 100 |
+
- 샘플 PDF 가 없으면 python Backend/scripts/test_pdf_upload.py 로 3페이지 시험용 문서를 생성한다.
|
| 101 |
+
- POST /api/pages/upload 에 Content-Type: application/pdf 파일을 전송하면 서버가 페이지별 JPEG 로 분할해 여러 page_id 를 반환
|
| 102 |
+
한다.
|
| 103 |
+
- 자동 계산된 시작 페이지 번호가 기존 페이지 수 이후로 이어지는지 응답의 pages[].page_number 로 확인한다.
|
| 104 |
+
- 이후 POST /api/projects/{project_id}/analyze 로 전체 페이지를 처리하고, 변환된 JPEG 파일이 uploads/{project_id}/ 에 저장됐
|
| 105 |
+
는지 확인한다.
|
| 106 |
+
- DB 에서 layout_elements·text_contents 를 페이지별로 조회하여 PDF 변환 후 파이프라인이 동일하게 적용됐는지 검증한다.
|
| 107 |
+
|
| 108 |
+
# PDF 업로드
|
| 109 |
+
curl -s -X POST "$BASE_URL/api/pages/upload" \
|
| 110 |
+
-F project_id="$PROJECT_ID" \
|
| 111 |
+
-F file=@Backend/scripts/test_sample.pdf \
|
| 112 |
+
-H 'Content-Type: application/pdf' | jq '.pages[].page_number'
|
| 113 |
+
|
| 114 |
+
DB 확인
|
| 115 |
+
|
| 116 |
+
- MySQL CLI 에 접속 후 분석 대상 프로젝트/페이지를 확인하여 상태 전이를 검증한다.
|
| 117 |
+
- 레이아웃과 OCR 수를 비교해 누락 요소나 0건 여부를 빠르게 파악한다.
|
| 118 |
+
- AI 설명을 사용했다면 ai_descriptions 에 element_id 와 model_name 이 채워졌는지 확인한다.
|
| 119 |
+
- 텍스트 버전 히스토리를 확인해 자동 포맷팅 내용이 version_type='auto_formatted' 로 저장됐는지 살핀다.
|
| 120 |
+
|
| 121 |
+
USE smarteyessen_db;
|
| 122 |
+
SELECT page_id, analysis_status, processing_time FROM pages WHERE project_id = {PROJECT_ID};
|
| 123 |
+
SELECT COUNT(*) AS layout_cnt FROM layout_elements WHERE page_id = {PAGE_ID};
|
| 124 |
+
SELECT COUNT(*) AS ocr_cnt FROM text_contents WHERE element_id IN (
|
| 125 |
+
SELECT element_id FROM layout_elements WHERE page_id = {PAGE_ID}
|
| 126 |
+
);
|
| 127 |
+
SELECT version_id, version_type, created_at FROM text_versions WHERE page_id = {PAGE_ID} ORDER BY version_number DESC;
|
| 128 |
+
|
| 129 |
+
일괄 테스트 스크립트
|
| 130 |
+
|
| 131 |
+
- 아래 예시는 requests 와 mysql-connector-python 을 활용해 이미지/다중/ PDF 시나리오를 한 번에 실행하고 결과를 요약한다.
|
| 132 |
+
- 환경 변수 API_BASE_URL, OPENAI_API_KEY, MYSQL_HOST, MYSQL_PORT, MYSQL_PASSWORD 등을 미리 설정한다.
|
| 133 |
+
- 프로젝트 생성 이후 단일 이미지→다중 이미지→PDF 업로드 순서로 실행하며, 각 단계가 끝나면 DB 에서 카운트를 조회한다.
|
| 134 |
+
- 실제 파일 경로는 프로젝트에 맞게 교체하고, GPU/모델 로딩 시간을 고려해 time.sleep() 으로 폴링 간격을 조정한다.
|
| 135 |
+
|
| 136 |
+
import os, time, requests, mysql.connector
|
| 137 |
+
|
| 138 |
+
base = os.environ["API_BASE_URL"]
|
| 139 |
+
headers = {"Content-Type": "application/json"}
|
| 140 |
+
|
| 141 |
+
def create_project(name):
|
| 142 |
+
resp = requests.post(f"{base}/api/projects", headers=headers,
|
| 143 |
+
json={"project_name": name, "type_id": 1, "user_id": 1})
|
| 144 |
+
resp.raise_for_status()
|
| 145 |
+
return resp.json()["project_id"]
|
| 146 |
+
|
| 147 |
+
def upload_image(project_id, page_number, path):
|
| 148 |
+
files = {"file": open(path, "rb")}
|
| 149 |
+
data = {"project_id": str(project_id), "page_number": str(page_number)}
|
| 150 |
+
resp = requests.post(f"{base}/api/pages/upload", files=files, data=data)
|
| 151 |
+
resp.raise_for_status()
|
| 152 |
+
return resp.json()["page_id"]
|
| 153 |
+
|
| 154 |
+
def trigger_page(page_id):
|
| 155 |
+
resp = requests.post(f"{base}/api/pages/{page_id}/analyze/async",
|
| 156 |
+
json={"use_ai_descriptions": False})
|
| 157 |
+
resp.raise_for_status()
|
| 158 |
+
job_id = resp.json()["job_id"]
|
| 159 |
+
while True:
|
| 160 |
+
status = requests.get(f"{base}/api/analysis/jobs/{job_id}").json()
|
| 161 |
+
if status["status"] in {"completed", "failed"}:
|
| 162 |
+
return status
|
| 163 |
+
time.sleep(3)
|
| 164 |
+
|
| 165 |
+
def run_project(project_id):
|
| 166 |
+
resp = requests.post(f"{base}/api/projects/{project_id}/analyze",
|
| 167 |
+
json={"use_ai_descriptions": True,
|
| 168 |
+
"api_key": os.environ.get("OPENAI_API_KEY")})
|
| 169 |
+
resp.raise_for_status()
|
| 170 |
+
return resp.json()
|
| 171 |
+
|
| 172 |
+
def fetch_counts(project_id):
|
| 173 |
+
conn = mysql.connector.connect(host=os.environ["MYSQL_HOST"],
|
| 174 |
+
port=os.environ.get("MYSQL_PORT", 3308),
|
| 175 |
+
user="root",
|
| 176 |
+
password=os.environ["MYSQL_PASSWORD"],
|
| 177 |
+
database="smarteyessen_db")
|
| 178 |
+
cur = conn.cursor()
|
| 179 |
+
cur.execute("SELECT page_id, analysis_status FROM pages WHERE project_id=%s", (project_id,))
|
| 180 |
+
pages = cur.fetchall()
|
| 181 |
+
cur.close(); conn.close()
|
| 182 |
+
return pages
|
| 183 |
+
|
| 184 |
+
if __name__ == "__main__":
|
| 185 |
+
project = create_project("pipeline-suite")
|
| 186 |
+
page1 = upload_image(project, 1, "Backend/temp_image.jpg")
|
| 187 |
+
print(trigger_page(page1))
|
| 188 |
+
page2 = upload_image(project, 2, "path/to/second.png")
|
| 189 |
+
page3 = upload_image(project, 3, "path/to/third.png")
|
| 190 |
+
print(run_project(project))
|
| 191 |
+
print(fetch_counts(project))
|
| 192 |
+
|
| 193 |
+
추가 체크포인트
|
| 194 |
+
|
| 195 |
+
- OpenAI 호출을 활성화할 경우 OPENAI_API_KEY 와 요금 한도를 점검하고, 테스트에서는 use_ai_descriptions=false 로 속도를 높일
|
| 196 |
+
수 있다.
|
| 197 |
+
- Torch/Paddle 로 모델을 처음 로드할 때 수십 초가 걸리므로 CI 환경에서는 사전 워밍업이나 모델 경량화를 고려한다.
|
| 198 |
+
- Docker MySQL 과 애플리케이션 간 네트워크 포트를 일치시키고, 대용량 PDF 변환 시 uploads/ 디렉토리 용량을 주기적으로 정리
|
| 199 |
+
한다.
|
| 200 |
+
- 텍스트 버전 자동 생성 후 사용자가 수정하면 save_user_edited_version 로 캐시가 무효화되므로 다운로드 서비스 정확도를 위해 수
|
| 201 |
+
정 이력을 기록한다.
|
| 202 |
+
- 오류 발생 시 pages.analysis_status='error' 로 남으니 재시도 전 로그 (loguru) 를 확인해 모델·경로·권한 문제를 해결한다.
|
requirements.txt
CHANGED
|
@@ -25,10 +25,24 @@ python-dotenv==1.0.0
|
|
| 25 |
# ============================================================================
|
| 26 |
# AI/ML 라이브러리
|
| 27 |
# ============================================================================
|
| 28 |
-
# DocLayout-YOLO
|
| 29 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
paddlepaddle==2.6.0
|
| 31 |
paddleocr==2.7.0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
# OpenAI API (AI 설명 생성)
|
| 33 |
openai==1.10.0
|
| 34 |
|
|
@@ -37,11 +51,23 @@ openai==1.10.0
|
|
| 37 |
# ============================================================================
|
| 38 |
pillow==10.2.0
|
| 39 |
opencv-python==4.9.0.80
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
|
| 41 |
# ============================================================================
|
| 42 |
-
# 문서 생성
|
| 43 |
# ============================================================================
|
| 44 |
python-docx==1.1.0
|
|
|
|
| 45 |
|
| 46 |
# ============================================================================
|
| 47 |
# 유틸리티
|
|
|
|
| 25 |
# ============================================================================
|
| 26 |
# AI/ML 라이브러리
|
| 27 |
# ============================================================================
|
| 28 |
+
# PyTorch (DocLayout-YOLO 호환 버전)
|
| 29 |
+
torch==2.0.1
|
| 30 |
+
torchvision==0.15.2
|
| 31 |
+
|
| 32 |
+
# DocLayout-YOLO (Project 검증된 버전)
|
| 33 |
+
ultralytics==8.0.196
|
| 34 |
+
|
| 35 |
+
# PaddleOCR
|
| 36 |
paddlepaddle==2.6.0
|
| 37 |
paddleocr==2.7.0
|
| 38 |
+
|
| 39 |
+
# Tesseract OCR
|
| 40 |
+
pytesseract==0.3.10
|
| 41 |
+
|
| 42 |
+
# Hugging Face Transformers
|
| 43 |
+
huggingface-hub>=0.17.0
|
| 44 |
+
transformers==4.35.2
|
| 45 |
+
|
| 46 |
# OpenAI API (AI 설명 생성)
|
| 47 |
openai==1.10.0
|
| 48 |
|
|
|
|
| 51 |
# ============================================================================
|
| 52 |
pillow==10.2.0
|
| 53 |
opencv-python==4.9.0.80
|
| 54 |
+
matplotlib>=3.5.0
|
| 55 |
+
scikit-image==0.22.0
|
| 56 |
+
imageio==2.31.6
|
| 57 |
+
|
| 58 |
+
# ============================================================================
|
| 59 |
+
# 데이터 처리 및 분석
|
| 60 |
+
# ============================================================================
|
| 61 |
+
pandas>=1.3.0
|
| 62 |
+
scipy>=1.7.0
|
| 63 |
+
scikit-learn>=1.0.0
|
| 64 |
+
numpy==1.26.4
|
| 65 |
|
| 66 |
# ============================================================================
|
| 67 |
+
# 문서 생성 및 처리
|
| 68 |
# ============================================================================
|
| 69 |
python-docx==1.1.0
|
| 70 |
+
PyMuPDF==1.23.8 # PDF 처리
|
| 71 |
|
| 72 |
# ============================================================================
|
| 73 |
# 유틸리티
|
scripts/reset_db.sh
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env bash
|
| 2 |
+
#
|
| 3 |
+
# 개발/테스트용 SmartEyeSsen MySQL 스키마 초기화 스크립트
|
| 4 |
+
# -------------------------------------------------------
|
| 5 |
+
# - docker mysql 컨테이너에서 사용하는 smarteyessen_db 스키마를 드롭 후 재생성합니다.
|
| 6 |
+
# - 이후 FastAPI ORM 모델 기반으로 테이블을 초기화합니다.
|
| 7 |
+
# - 환경 변수(DB_HOST, DB_PORT, DB_USER, DB_PASSWORD, DB_NAME)가 설정되어 있어야 합니다.
|
| 8 |
+
#
|
| 9 |
+
# 사용 예시:
|
| 10 |
+
# chmod +x Backend/scripts/reset_db.sh
|
| 11 |
+
# Backend/scripts/reset_db.sh
|
| 12 |
+
#
|
| 13 |
+
|
| 14 |
+
set -euo pipefail
|
| 15 |
+
|
| 16 |
+
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
| 17 |
+
export PYTHONPATH="${ROOT_DIR}"
|
| 18 |
+
|
| 19 |
+
DB_HOST="${DB_HOST:-10.255.255.254}"
|
| 20 |
+
DB_PORT="${DB_PORT:-3308}"
|
| 21 |
+
DB_USER="${DB_USER:-root}"
|
| 22 |
+
DB_PASSWORD="${DB_PASSWORD:-1q2w3e4r}"
|
| 23 |
+
DB_NAME="${DB_NAME:-smarteyessen_db}"
|
| 24 |
+
|
| 25 |
+
MYSQL_CMD=(
|
| 26 |
+
mysql
|
| 27 |
+
-h "${DB_HOST}"
|
| 28 |
+
-P "${DB_PORT}"
|
| 29 |
+
-u "${DB_USER}"
|
| 30 |
+
)
|
| 31 |
+
|
| 32 |
+
if [[ -n "${DB_PASSWORD}" ]]; then
|
| 33 |
+
MYSQL_CMD+=(-p"${DB_PASSWORD}")
|
| 34 |
+
fi
|
| 35 |
+
|
| 36 |
+
echo "🔄 Dropping and recreating schema \`${DB_NAME}\` on ${DB_HOST}:${DB_PORT}..."
|
| 37 |
+
"${MYSQL_CMD[@]}" <<SQL
|
| 38 |
+
DROP DATABASE IF EXISTS \`${DB_NAME}\`;
|
| 39 |
+
CREATE DATABASE \`${DB_NAME}\` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
| 40 |
+
SQL
|
| 41 |
+
|
| 42 |
+
echo "✅ Schema recreated."
|
| 43 |
+
|
| 44 |
+
echo "📦 Initializing tables via Backend.app.database.init_db()..."
|
| 45 |
+
python - <<'PYTHON'
|
| 46 |
+
from Backend.app.database import init_db
|
| 47 |
+
|
| 48 |
+
if __name__ == "__main__":
|
| 49 |
+
init_db()
|
| 50 |
+
PYTHON
|
| 51 |
+
|
| 52 |
+
echo "✅ Table initialization complete."
|
| 53 |
+
|
| 54 |
+
echo "🎉 Database reset finished. You can now rerun backend services or seed data as needed."
|
scripts/seed_test_data.sql
CHANGED
|
@@ -22,9 +22,8 @@ ON DUPLICATE KEY UPDATE
|
|
| 22 |
-- ============================================================================
|
| 23 |
INSERT INTO document_types (doc_type_id, type_name, model_name, sorting_method, description, created_at, updated_at)
|
| 24 |
VALUES
|
| 25 |
-
(1, '
|
| 26 |
-
(2, '
|
| 27 |
-
(3, '표/차트', 'table', 'reading_order', '표와 차트가 포함된 문서', NOW(), NOW())
|
| 28 |
ON DUPLICATE KEY UPDATE
|
| 29 |
type_name = VALUES(type_name),
|
| 30 |
model_name = VALUES(model_name),
|
|
|
|
| 22 |
-- ============================================================================
|
| 23 |
INSERT INTO document_types (doc_type_id, type_name, model_name, sorting_method, description, created_at, updated_at)
|
| 24 |
VALUES
|
| 25 |
+
(1, '학습지', 'worksheet', 'question_based', '학습지가 포함된 문서', NOW(), NOW()),
|
| 26 |
+
(2, '일반문서', 'general', 'reading_order', '테스트용 기본 문서 타입', NOW(), NOW())
|
|
|
|
| 27 |
ON DUPLICATE KEY UPDATE
|
| 28 |
type_name = VALUES(type_name),
|
| 29 |
model_name = VALUES(model_name),
|
scripts/test_pdf_upload.py
CHANGED
|
File without changes
|