273 lines
10 KiB
Java
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);
|
|
}
|
|
}
|