KwanHak commited on
Commit
82c1146
·
1 Parent(s): be63ac6

sync: Smart_Demo 브랜치의 Backend 코드 병합 & 이미지 로드를 위한 MultiFileLoader 컴포넌트 구현

Browse files

- 동료가 개발한 최신 Backend 반영
- 기존 Backend는 Backend_backup_before_sync에 백업
- 이미지 로드를 위한 MultiFileLoader 컴포넌트 구현

.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
- from fastapi import FastAPI, Depends, HTTPException, status
 
 
 
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 os
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 텍스트 추출**: PaddleOCR 기반 텍스트 인식
42
  * ✏️ **텍스트 편집 및 버전 관리**: TinyMCE 편집기 지원
43
- * 🖼️ **AI 설명 생성**: GPT-4o-mini 기반 figure/table 설명
44
  * 📊 **문제 기반 정렬**: Worksheet 전용 문제 번호 기반 정렬
45
  * 📐 **좌표 기반 정렬**: Document 전용 좌표 기반 정렬
46
- * 📥 **통합 문서 다운로드**: DOCX/PDF/TXT 형식 지원
47
 
48
  ### 기술 스택
49
  * **Backend**: FastAPI + SQLAlchemy
50
  * **Database**: MySQL 8.0
51
- * **AI Models**: DocLayout-YOLO, PaddleOCR, GPT-4o-mini
52
  * **Document**: python-docx
53
  """,
54
- version="1.0.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.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 연결 확인 (SQLAlchemy 2.0 호환)
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
- # 라우터 등록 (Phase 2에서 추가 예정)
186
- # ============================================================================
187
- # from .routers import users, projects, pages, layout_elements
188
- # app.include_router(users.router, prefix="/api/v1/users", tags=["Users"])
189
- # app.include_router(projects.router, prefix="/api/v1/projects", tags=["Projects"])
190
- # app.include_router(pages.router, prefix="/api/v1/pages", tags=["Pages"])
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
- Phase 2에서 개별 라우터 모듈이 추가될 예정:
7
- - users.py: 사용자 관리 API
8
- - projects.py: 프로젝트 관리 API
9
- - pages.py: 페이지 관리 API
10
- - layout_elements.py: 레이아웃 요소 API
11
- - text_contents.py: 텍스트 내용 API
12
- - ai_descriptions.py: AI 설명 API
13
- - formatting_rules.py: 서식 규칙 API
14
- - combined_results.py: 통합 결과 API
15
  """
16
 
17
- __version__ = "1.0.0"
 
 
 
 
 
 
 
 
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 OCR
29
- ultralytics==8.1.0
 
 
 
 
 
 
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, '일반문서', 'general', 'reading_order', '테스트용 기본 문서 타입', NOW(), NOW()),
26
- (2, '수학문제', 'math', 'question_based', '수학 문제가 포함된 문서', NOW(), NOW()),
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