Files
ragone-backend/src/main/java/kr/co/ragone/service/VisionService.java
2025-12-12 03:30:20 +09:00

273 lines
10 KiB
Java

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<String, Object> 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<Map<String, Object>> messages = new ArrayList<>();
Map<String, Object> userMessage = new HashMap<>();
userMessage.put("role", "user");
// content 배열 (텍스트 + 이미지)
List<Map<String, Object>> content = new ArrayList<>();
// 텍스트 부분
Map<String, Object> textContent = new HashMap<>();
textContent.put("type", "text");
textContent.put("text", prompt);
content.add(textContent);
// 이미지 부분
Map<String, Object> imageContent = new HashMap<>();
imageContent.put("type", "image_url");
Map<String, String> 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<Map<String, Object>> entity = new HttpEntity<>(requestBody, headers);
ResponseEntity<String> 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);
}
}