Skip to content

Commit d01df43

Browse files
authored
Merge pull request #114 from NOVA-9th/feat/#113-apply-structured-output-to-ai-batch
[FEAT] AiBatchService에 Structured Output 적용
2 parents cb70526 + 155e656 commit d01df43

9 files changed

Lines changed: 167 additions & 73 deletions

File tree

build.gradle

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,10 @@ dependencies {
4848
// OpenAI Java SDK
4949
implementation("com.openai:openai-java:4.16.1")
5050

51+
// JSON Schema Generator
52+
implementation 'com.github.victools:jsonschema-generator:4.38.0'
53+
implementation 'com.github.victools:jsonschema-module-jackson:4.38.0'
54+
5155
// 모니터링용
5256
implementation 'org.springframework.boot:spring-boot-starter-actuator'
5357
runtimeOnly 'io.micrometer:micrometer-registry-prometheus'
@@ -90,4 +94,3 @@ tasks.withType(JavaCompile).configureEach {
9094
clean.doLast {
9195
file(querydslDir).deleteDir()
9296
}
93-

src/main/java/com/nova/nova_server/domain/ai/service/AiBatchService.java

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,17 @@ public interface AiBatchService {
1313
*/
1414
String createBatch(Map<String, String> prompts);
1515

16+
/**
17+
* 배치 작업을 요청한다.
18+
* DTO 클래스를 지정하여 LLM 응답이 해당 형식을 준수하도록 보장한다.
19+
*
20+
* @param prompts 임의의 ID를 key로, prompt 내용을 value로 갖는 Map<br/>
21+
* Batch API 결과는 순서가 보장되지 않으므로 구분을 위해 prompt마다 고유한 ID를 부여해야 한다.
22+
* @param resultDtoClass 결과 DTO 클래스
23+
* @return 배치 작업 ID
24+
*/
25+
String createBatch(Map<String, String> prompts, Class<?> resultDtoClass);
26+
1627
/**
1728
* 배치 작업 완료 여부를 확인한다.
1829
* 배치 작업에 실패한 경우 AiException 하위 예외를 던진다.
@@ -31,4 +42,13 @@ public interface AiBatchService {
3142
*/
3243
Map<String, String> getResults(String batchId);
3344

45+
/**
46+
* 배치 작업의 응답을 가져와 지정한 DTO로 파싱한다.
47+
*
48+
* @param batchId 배치 작업 ID
49+
* @param resultDtoClass 결과 DTO 클래스
50+
* @return 요청에서 지정된 ID를 key로, 결과 DTO를 value로 갖는 Map
51+
*/
52+
<T> Map<String, T> getResults(String batchId, Class<T> resultDtoClass);
53+
3454
}

src/main/java/com/nova/nova_server/domain/ai/service/OpenAiBatchService.java

Lines changed: 101 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import com.fasterxml.jackson.databind.JsonNode;
44
import com.fasterxml.jackson.databind.ObjectMapper;
55
import com.fasterxml.jackson.databind.node.ObjectNode;
6+
import com.github.victools.jsonschema.generator.SchemaGenerator;
67
import com.nova.nova_server.domain.ai.exception.AiException;
78
import com.nova.nova_server.global.config.OpenAIConfig;
89
import com.openai.client.OpenAIClient;
@@ -27,6 +28,7 @@
2728
import java.nio.charset.StandardCharsets;
2829
import java.util.HashMap;
2930
import java.util.Map;
31+
import java.util.Optional;
3032

3133
import static com.openai.models.batches.Batch.Status.*;
3234

@@ -38,6 +40,7 @@ public class OpenAiBatchService implements AiBatchService {
3840
private final OpenAIClient client;
3941
private final OpenAIConfig config;
4042
private final ObjectMapper objectMapper;
43+
private final SchemaGenerator schemaGenerator;
4144

4245
@Override
4346
public String createBatch(Map<String, String> prompts) {
@@ -53,6 +56,22 @@ public String createBatch(Map<String, String> prompts) {
5356
return requestBatch(inputFileId);
5457
}
5558

59+
@Override
60+
public String createBatch(Map<String, String> prompts, Class<?> resultDtoClass) {
61+
validatePrompts(prompts);
62+
validateResultDtoClass(resultDtoClass);
63+
64+
String batchInput = createBatchInput(
65+
prompts,
66+
config.getModel(),
67+
config.getTemperature(),
68+
resultDtoClass
69+
);
70+
String inputFileId = uploadBatchInput(batchInput);
71+
72+
return requestBatch(inputFileId);
73+
}
74+
5675
@Override
5776
public boolean isCompleted(String batchId) {
5877
validateBatchId(batchId);
@@ -70,16 +89,19 @@ public Map<String, String> getResults(String batchId) {
7089
}
7190

7291
String batchOutput = fetchBatchOutput(batch);
73-
long total = batch.requestCounts()
74-
.orElseThrow(() -> {
75-
log.error("배치 요청 수를 확인할 수 없습니다. batchId={}", batchId);
76-
return new AiException.InvalidBatchOutputException("배치 요청 수를 확인할 수 없습니다.");
77-
})
78-
.total();
7992

8093
return parseBatchOutput(batchOutput);
8194
}
8295

96+
@Override
97+
public <T> Map<String, T> getResults(String batchId, Class<T> resultDtoClass) {
98+
validateResultDtoClass(resultDtoClass);
99+
100+
Map<String, String> rawResults = getResults(batchId);
101+
102+
return parseBatchResults(rawResults, resultDtoClass);
103+
}
104+
83105
/**
84106
* OpenAI 배치 작업에 필요한 jsonl 형식의 입력 문자열을 생성한다.
85107
*
@@ -89,7 +111,21 @@ public Map<String, String> getResults(String batchId) {
89111
* @return 배치 입력 문자열 (jsonl 형식)
90112
*/
91113
private String createBatchInput(Map<String, String> prompts, String model, double temperature) {
114+
return createBatchInput(prompts, model, temperature, null);
115+
}
116+
117+
/**
118+
* OpenAI 배치 작업에 필요한 jsonl 형식의 입력 문자열을 생성한다.
119+
*
120+
* @param prompts prompt map
121+
* @param model OpenAI LLM 모델 이름
122+
* @param temperature temperature 값
123+
* @param resultDtoClass 결과 DTO 클래스 (Structured Outputs 설정에 사용)
124+
* @return 배치 입력 문자열 (jsonl 형식)
125+
*/
126+
private String createBatchInput(Map<String, String> prompts, String model, double temperature, Class<?> resultDtoClass) {
92127
StringBuilder jsonlBuilder = new StringBuilder();
128+
ObjectNode responseFormatNode = Optional.ofNullable(resultDtoClass).map(this::createResponseFormatNode).orElse(null);
93129

94130
for (String key : prompts.keySet()) {
95131
ObjectNode requestNode = objectMapper.createObjectNode();
@@ -100,6 +136,9 @@ private String createBatchInput(Map<String, String> prompts, String model, doubl
100136
ObjectNode bodyNode = objectMapper.createObjectNode();
101137
bodyNode.put("model", model);
102138
bodyNode.put("temperature", temperature);
139+
if (responseFormatNode != null) {
140+
bodyNode.set("response_format", responseFormatNode);
141+
}
103142

104143
ObjectNode messageNode = objectMapper.createObjectNode();
105144
messageNode.put("role", "user");
@@ -114,6 +153,25 @@ private String createBatchInput(Map<String, String> prompts, String model, doubl
114153
return jsonlBuilder.toString();
115154
}
116155

156+
/**
157+
* Structured Outputs 설정을 위한 response_format 노드를 생성한다.
158+
*
159+
* @param resultDtoClass 결과 DTO 클래스
160+
* @return response_format 노드
161+
*/
162+
private ObjectNode createResponseFormatNode(Class<?> resultDtoClass) {
163+
ObjectNode responseFormatNode = objectMapper.createObjectNode();
164+
responseFormatNode.put("type", "json_schema");
165+
166+
ObjectNode jsonSchemaNode = objectMapper.createObjectNode();
167+
jsonSchemaNode.put("name", resultDtoClass.getSimpleName());
168+
jsonSchemaNode.put("strict", true);
169+
jsonSchemaNode.set("schema", schemaGenerator.generateSchema(resultDtoClass));
170+
171+
responseFormatNode.set("json_schema", jsonSchemaNode);
172+
return responseFormatNode;
173+
}
174+
117175
/**
118176
* 배치 입력 파일을 업로드한다.
119177
*
@@ -182,13 +240,12 @@ private boolean isCompleted(Batch batch) {
182240
* @return 배치 작업 결과 문자열 (jsonl 형식)
183241
*/
184242
private String fetchBatchOutput(Batch batch) {
185-
StringBuffer outputBuffer = new StringBuffer();
186-
187-
batch.outputFileId().ifPresent(fileId -> {
188-
outputBuffer.append(fetchBatchOutputFile(fileId));
189-
});
190-
191-
return outputBuffer.toString();
243+
return batch.outputFileId()
244+
.map(this::fetchBatchOutputFile)
245+
.orElseThrow(() -> {
246+
log.error("배치 결과 파일이 존재하지 않습니다. batchId={}", batch.id());
247+
return new AiException.InvalidBatchOutputException("배치 결과 파일이 존재하지 않습니다.");
248+
});
192249
}
193250

194251
/**
@@ -235,13 +292,44 @@ private Map<String, String> parseBatchOutput(String batchOutput) {
235292
return resultMap;
236293
}
237294

295+
/**
296+
* 배치 작업 결과 Map의 value를 지정한 DTO로 파싱한다.
297+
*
298+
* @param rawResults 배치 작업 결과 Map
299+
* @return 요청에서 지정된 ID를 key로, 결과 DTO를 value로 갖는 Map
300+
*/
301+
private <T> Map<String, T> parseBatchResults(Map<String, String> rawResults, Class<T> resultDtoClass) {
302+
Map<String, T> parsedResults = new HashMap<>();
303+
304+
for (String customId : rawResults.keySet()) {
305+
String rawContent = rawResults.get(customId);
306+
if (!StringUtils.hasText(rawContent)) {
307+
continue;
308+
}
309+
310+
try {
311+
T parsed = objectMapper.readValue(rawContent, resultDtoClass);
312+
parsedResults.put(customId, parsed);
313+
} catch (Exception e) {
314+
log.warn("배치 결과 DTO 파싱에 실패했습니다. customId={}, content={}", customId, rawContent);
315+
throw new AiException.InvalidBatchOutputException("배치 결과 DTO 파싱에 실패했습니다.");
316+
}
317+
}
318+
return parsedResults;
319+
}
320+
238321
private void validatePrompts(Map<String, String> prompts) {
239322
if (CollectionUtils.isEmpty(prompts))
240323
throw new AiException.InvalidBatchInputException("배치 입력이 누락되었습니다.");
241324
if (prompts.size() > config.getMaxRequestPerBatch())
242325
throw new AiException.InvalidBatchInputException("배치 당 최대 요청수를 초과했습니다.");
243326
}
244327

328+
private void validateResultDtoClass(Class<?> resultDtoClass) {
329+
if (resultDtoClass == null)
330+
throw new AiException.InvalidBatchInputException("결과 DTO 클래스가 누락되었습니다.");
331+
}
332+
245333
private void validateBatchId(String batchId) {
246334
if (!StringUtils.hasText(batchId))
247335
throw new AiException.InvalidBatchIdException("배치 ID가 누락되었습니다.");

src/main/java/com/nova/nova_server/domain/batch/cardnews/converter/AiResponseConverter.java

Lines changed: 0 additions & 52 deletions
This file was deleted.

src/main/java/com/nova/nova_server/domain/batch/cardnews/service/BatchProcessingService.java

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22

33
import com.nova.nova_server.domain.ai.exception.AiException;
44
import com.nova.nova_server.domain.ai.service.AiBatchService;
5-
import com.nova.nova_server.domain.batch.cardnews.converter.AiResponseConverter;
65
import com.nova.nova_server.domain.batch.common.entity.AiBatchEntity;
76
import com.nova.nova_server.domain.batch.common.entity.AiBatchState;
87
import com.nova.nova_server.domain.batch.common.entity.ArticleEntity;
@@ -46,8 +45,11 @@ public void processBatchResult(AiBatchEntity entity) {
4645
}
4746

4847
private void onBatchSuccess(String batchId) {
49-
Map<String, String> batchResult = aiBatchService.getResults(batchId);
50-
Map<Long, LlmSummaryResult> summaryResult = AiResponseConverter.fromBatchResult(batchResult);
48+
Map<Long, LlmSummaryResult> summaryResult = aiBatchService.getResults(batchId, LlmSummaryResult.class)
49+
.entrySet().stream().collect(Collectors.toMap(
50+
entry -> Long.parseLong(entry.getKey()),
51+
Map.Entry::getValue
52+
));
5153
Map<Long, ArticleEntity> entities = articleEntityRepository.findAllByIdIn(summaryResult.keySet())
5254
.stream()
5355
.collect(Collectors.toMap(
Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package com.nova.nova_server.domain.batch.summary.dto;
22

33
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
4+
import com.fasterxml.jackson.annotation.JsonProperty;
45

56
import java.util.List;
67

@@ -9,7 +10,8 @@
910
*/
1011
@JsonIgnoreProperties(ignoreUnknown = true)
1112
public record LlmSummaryResult(
12-
String summary,
13-
List<String> evidence,
14-
List<String> keywords) {
13+
@JsonProperty(required = true) String summary,
14+
@JsonProperty(required = true) List<String> evidence,
15+
@JsonProperty(required = true) List<String> keywords
16+
) {
1517
}

src/main/java/com/nova/nova_server/domain/batch/summary/service/ArticleSummaryWriter.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ public void write(Chunk<? extends ArticleEntity> chunk) {
3434
log.info("ArticleSummaryWriter: Processing chunk of {} items", chunk.size());
3535

3636
Map<String, String> prompts = PromptConverter.toPromptMap(chunk.getItems());
37-
String batchId = aiBatchService.createBatch(prompts);
37+
String batchId = aiBatchService.createBatch(prompts, LlmSummaryResult.class);
3838
aiBatchRepository.save(AiBatchEntity.fromBatchId(batchId));
3939
log.info("Batch submitted. BatchId: {}, Count: {}", batchId, chunk.size());
4040

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
package com.nova.nova_server.global.config;
2+
3+
import com.fasterxml.jackson.databind.ObjectMapper;
4+
import com.github.victools.jsonschema.generator.*;
5+
import com.github.victools.jsonschema.module.jackson.JacksonModule;
6+
import com.github.victools.jsonschema.module.jackson.JacksonOption;
7+
import org.springframework.context.annotation.Bean;
8+
import org.springframework.context.annotation.Configuration;
9+
10+
@Configuration
11+
public class JsonSchemaConfig {
12+
13+
@Bean
14+
public SchemaGenerator schemaGenerator(ObjectMapper objectMapper) {
15+
SchemaGeneratorConfig config = new SchemaGeneratorConfigBuilder(
16+
objectMapper,
17+
SchemaVersion.DRAFT_2020_12,
18+
OptionPreset.PLAIN_JSON
19+
)
20+
.with(Option.FORBIDDEN_ADDITIONAL_PROPERTIES_BY_DEFAULT)
21+
.with(new JacksonModule(JacksonOption.RESPECT_JSONPROPERTY_REQUIRED))
22+
.build();
23+
24+
return new SchemaGenerator(config);
25+
}
26+
27+
}

src/test/resources/application-test.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,10 @@ oauth:
3232
client-secret: test-secret
3333
redirect-uri: http://localhost:8080/auth/github/callback
3434

35+
batch:
36+
article-ingestion:
37+
cron: "0 0 18 * * ?"
38+
3539
ai:
3640
openai:
3741
key: test-key

0 commit comments

Comments
 (0)