33import com .fasterxml .jackson .databind .JsonNode ;
44import com .fasterxml .jackson .databind .ObjectMapper ;
55import com .fasterxml .jackson .databind .node .ObjectNode ;
6+ import com .github .victools .jsonschema .generator .SchemaGenerator ;
67import com .nova .nova_server .domain .ai .exception .AiException ;
78import com .nova .nova_server .global .config .OpenAIConfig ;
89import com .openai .client .OpenAIClient ;
2728import java .nio .charset .StandardCharsets ;
2829import java .util .HashMap ;
2930import java .util .Map ;
31+ import java .util .Optional ;
3032
3133import 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가 누락되었습니다." );
0 commit comments