package kr.co.ragone.service; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import lombok.extern.slf4j.Slf4j; import org.apache.pdfbox.pdmodel.PDDocument; import org.apache.pdfbox.rendering.ImageType; import org.apache.pdfbox.rendering.PDFRenderer; import org.springframework.beans.factory.annotation.Value; import org.springframework.http.*; import org.springframework.http.client.SimpleClientHttpRequestFactory; import org.springframework.stereotype.Service; import org.springframework.web.client.RestTemplate; import javax.imageio.ImageIO; import java.awt.image.BufferedImage; import java.io.ByteArrayOutputStream; import java.io.File; import java.util.*; @Slf4j @Service public class VisionService { private final ObjectMapper objectMapper; private final RestTemplate restTemplate; // RestTemplate with 10 minute timeout public VisionService(ObjectMapper objectMapper) { this.objectMapper = objectMapper; SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory(); factory.setConnectTimeout(600000); // 10분 factory.setReadTimeout(600000); // 10분 this.restTemplate = new RestTemplate(factory); } @Value("${openai.api-key}") private String apiKey; @Value("${rag.vision.enabled:false}") private boolean visionEnabled; @Value("${rag.vision.model:gpt-4o-mini}") private String visionModel; private static final String OPENAI_VISION_URL = "https://api.openai.com/v1/chat/completions"; /** * Vision 처리 활성화 여부 */ public boolean isEnabled() { return visionEnabled; } /** * 진행률 업데이트 콜백 인터페이스 */ @FunctionalInterface public interface ProgressCallback { void update(Long docId, int progress, String message, String status, String errorMsg); } /** * PDF 파일을 Vision 모델로 분석 (진행률 콜백 포함) */ public String processPdfWithVisionAndProgress(String pdfPath, Long docId, ProgressCallback callback) { if (!visionEnabled) { log.info("[Vision] 비활성화 상태"); return null; } StringBuilder allDescriptions = new StringBuilder(); try (PDDocument document = PDDocument.load(new File(pdfPath))) { PDFRenderer renderer = new PDFRenderer(document); int pageCount = document.getNumberOfPages(); log.info("[Vision] PDF 분석 시작: {} 페이지", pageCount); for (int i = 0; i < pageCount; i++) { try { // 진행률 계산: 10% ~ 40% 구간에서 페이지 수에 따라 분배 int progress = 10 + (int) ((i + 1) * 30.0 / pageCount); String message = "PDF 분석중... (" + (i + 1) + "/" + pageCount + " 페이지)"; callback.update(docId, progress, message, null, null); log.info("[Vision] 페이지 {}/{} 분석 중...", i + 1, pageCount); BufferedImage image = renderer.renderImageWithDPI(i, 150, ImageType.RGB); String base64Image = encodeImageToBase64(image); String description = callVisionApi(base64Image, i + 1, pageCount); if (description != null && !description.isEmpty()) { allDescriptions.append("\n\n=== 페이지 ").append(i + 1).append(" ===\n"); allDescriptions.append(description); } Thread.sleep(1000); } catch (Exception e) { log.warn("[Vision] 페이지 {} 분석 실패: {}", i + 1, e.getMessage()); } } log.info("[Vision] PDF 분석 완료"); } catch (Exception e) { log.error("[Vision] PDF 처리 실패: {}", e.getMessage()); return null; } return allDescriptions.toString(); } /** * PDF 파일을 Vision 모델로 분석하여 텍스트 추출 (기존 메서드 - 호환성 유지) */ public String processPdfWithVision(String pdfPath) { if (!visionEnabled) { log.info("[Vision] 비활성화 상태"); return null; } StringBuilder allDescriptions = new StringBuilder(); try (PDDocument document = PDDocument.load(new File(pdfPath))) { PDFRenderer renderer = new PDFRenderer(document); int pageCount = document.getNumberOfPages(); log.info("[Vision] PDF 분석 시작: {} 페이지", pageCount); for (int i = 0; i < pageCount; i++) { try { log.info("[Vision] 페이지 {}/{} 분석 중...", i + 1, pageCount); // 페이지를 이미지로 변환 (150 DPI) BufferedImage image = renderer.renderImageWithDPI(i, 150, ImageType.RGB); // 이미지를 Base64로 인코딩 String base64Image = encodeImageToBase64(image); // Vision API 호출 String description = callVisionApi(base64Image, i + 1, pageCount); if (description != null && !description.isEmpty()) { allDescriptions.append("\n\n=== 페이지 ").append(i + 1).append(" ===\n"); allDescriptions.append(description); } // API 속도 제한 방지 (1초 대기) Thread.sleep(1000); } catch (Exception e) { log.warn("[Vision] 페이지 {} 분석 실패: {}", i + 1, e.getMessage()); } } log.info("[Vision] PDF 분석 완료"); } catch (Exception e) { log.error("[Vision] PDF 처리 실패: {}", e.getMessage()); return null; } return allDescriptions.toString(); } /** * OpenAI Vision API 직접 호출 */ private String callVisionApi(String base64Image, int pageNum, int totalPages) { try { // 요청 헤더 HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_JSON); headers.setBearerAuth(apiKey); // 프롬프트 구성 String prompt = """ 이 문서 페이지(페이지 %d/%d)의 내용을 분석해주세요. 【분석 지침】 1. 모든 텍스트 내용을 정확하게 추출하세요 2. 표(테이블)가 있으면 마크다운 표 형식으로 변환하세요 3. 요구사항 ID, 제목, 내용이 있으면 구조화해서 정리하세요 4. 다이어그램/차트가 있으면 설명을 추가하세요 5. 중요한 항목은 놓치지 마세요 【중요: 제외할 내용】 - 목차 페이지는 간단히 "목차 페이지입니다"라고만 응답하세요 - 페이지 번호와 점선(...)만 있는 내용은 제외하세요 - 표지, 개정이력 등 의미없는 페이지는 간단히 "표지/개정이력 페이지입니다"라고만 응답 【출력 형식】 - 한국어로 응답 - 원본 구조를 최대한 유지 - 표는 마크다운 형식 사용 """.formatted(pageNum, totalPages); // 요청 바디 구성 Map requestBody = new HashMap<>(); requestBody.put("model", visionModel); // 모델에 따라 파라미터 다르게 설정 if (visionModel.startsWith("gpt-5") || visionModel.startsWith("o3") || visionModel.startsWith("o4")) { requestBody.put("max_completion_tokens", 4096); } else { requestBody.put("max_tokens", 4096); } // 메시지 구성 (Vision API 형식) List> messages = new ArrayList<>(); Map userMessage = new HashMap<>(); userMessage.put("role", "user"); // content 배열 (텍스트 + 이미지) List> content = new ArrayList<>(); // 텍스트 부분 Map textContent = new HashMap<>(); textContent.put("type", "text"); textContent.put("text", prompt); content.add(textContent); // 이미지 부분 Map imageContent = new HashMap<>(); imageContent.put("type", "image_url"); Map imageUrl = new HashMap<>(); imageUrl.put("url", "data:image/png;base64," + base64Image); imageUrl.put("detail", "high"); // high quality 분석 imageContent.put("image_url", imageUrl); content.add(imageContent); userMessage.put("content", content); messages.add(userMessage); requestBody.put("messages", messages); // API 호출 HttpEntity> entity = new HttpEntity<>(requestBody, headers); ResponseEntity response = restTemplate.exchange( OPENAI_VISION_URL, HttpMethod.POST, entity, String.class ); // 응답 파싱 if (response.getStatusCode() == HttpStatus.OK) { JsonNode root = objectMapper.readTree(response.getBody()); String result = root.path("choices").get(0).path("message").path("content").asText(); log.debug("[Vision] 페이지 {} 분석 결과: {} 글자", pageNum, result.length()); return result; } } catch (Exception e) { log.error("[Vision] API 호출 실패: {}", e.getMessage()); } return null; } /** * 이미지를 Base64로 인코딩 */ private String encodeImageToBase64(BufferedImage image) throws Exception { ByteArrayOutputStream baos = new ByteArrayOutputStream(); ImageIO.write(image, "png", baos); byte[] imageBytes = baos.toByteArray(); return Base64.getEncoder().encodeToString(imageBytes); } }