Skip to content

Commit 90799e8

Browse files
authored
feat: add support for specifying response schema to AI response (#1485)
* feat: add support for specifying response schema * feat: remove toString and use record for ResponseSchema * feat: skip the modal and transfer question directly
1 parent 29bc88a commit 90799e8

6 files changed

Lines changed: 320 additions & 113 deletions

File tree

application/src/main/java/org/togetherjava/tjbot/features/chatgpt/ChatGptService.java

Lines changed: 56 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,22 @@
11
package org.togetherjava.tjbot.features.chatgpt;
22

3+
import com.fasterxml.jackson.core.type.TypeReference;
4+
import com.fasterxml.jackson.databind.ObjectMapper;
35
import com.openai.client.OpenAIClient;
46
import com.openai.client.okhttp.OpenAIOkHttpClient;
5-
import com.openai.models.responses.Response;
6-
import com.openai.models.responses.ResponseCreateParams;
7-
import com.openai.models.responses.ResponseOutputText;
7+
import com.openai.core.JsonValue;
8+
import com.openai.models.responses.*;
89
import org.slf4j.Logger;
910
import org.slf4j.LoggerFactory;
1011

1112
import org.togetherjava.tjbot.config.Config;
1213
import org.togetherjava.tjbot.features.analytics.Metrics;
14+
import org.togetherjava.tjbot.features.chatgpt.schema.ResponseSchema;
1315

1416
import javax.annotation.Nullable;
1517

1618
import java.time.Duration;
19+
import java.util.Map;
1720
import java.util.Optional;
1821
import java.util.stream.Collectors;
1922

@@ -23,6 +26,8 @@
2326
public class ChatGptService {
2427
private static final Logger logger = LoggerFactory.getLogger(ChatGptService.class);
2528
private static final Duration TIMEOUT = Duration.ofSeconds(90);
29+
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
30+
private static final String DEFAULT_SCHEMA_NAME = "response";
2631

2732
/** The maximum number of tokens allowed for the generated answer. */
2833
private static final int MAX_TOKENS = 1000;
@@ -74,7 +79,7 @@ public Optional<String> ask(String question, @Nullable String context, ChatGptMo
7479
Question: %s
7580
""".formatted(contextText, question);
7681

77-
return sendPrompt(inputPrompt, chatModel);
82+
return sendPrompt(inputPrompt, chatModel, null);
7883
}
7984

8085
/**
@@ -90,17 +95,35 @@ public Optional<String> ask(String question, @Nullable String context, ChatGptMo
9095
* Tokens</a>.
9196
*/
9297
public Optional<String> askRaw(String inputPrompt, ChatGptModel chatModel) {
93-
return sendPrompt(inputPrompt, chatModel);
98+
return sendPrompt(inputPrompt, chatModel, null);
99+
}
100+
101+
/**
102+
* Prompt ChatGPT with a raw prompt and receive a JSON response conforming to the given schema.
103+
* <p>
104+
* Uses OpenAI's structured outputs feature so the model is constrained to return JSON matching
105+
* the supplied schema.
106+
*
107+
* @param inputPrompt The raw prompt to send to ChatGPT. Max is {@value MAX_TOKENS} tokens.
108+
* @param chatModel The AI model to use for this request.
109+
* @param schema The JSON schema the response must conform to.
110+
* @return response from ChatGPT as a JSON string conforming to {@code schema}.
111+
*/
112+
public Optional<String> askRaw(String inputPrompt, ChatGptModel chatModel,
113+
ResponseSchema schema) {
114+
return sendPrompt(inputPrompt, chatModel, schema);
94115
}
95116

96117
/**
97118
* Sends a prompt to the ChatGPT API and returns the response.
98119
*
99120
* @param prompt The prompt to send to ChatGPT.
100121
* @param chatModel The AI model to use for this request.
122+
* @param schema Optional JSON schema constraining the model output; {@code null} for free-form.
101123
* @return response from ChatGPT as a String.
102124
*/
103-
private Optional<String> sendPrompt(String prompt, ChatGptModel chatModel) {
125+
private Optional<String> sendPrompt(String prompt, ChatGptModel chatModel,
126+
@Nullable ResponseSchema schema) {
104127
if (isDisabled) {
105128
logger.warn("ChatGPT request attempted but service is disabled");
106129
return Optional.empty();
@@ -109,11 +132,16 @@ private Optional<String> sendPrompt(String prompt, ChatGptModel chatModel) {
109132
logger.debug("ChatGpt request: {}", prompt);
110133

111134
try {
112-
ResponseCreateParams params = ResponseCreateParams.builder()
135+
ResponseCreateParams.Builder paramsBuilder = ResponseCreateParams.builder()
113136
.model(chatModel.toChatModel())
114137
.input(prompt)
115-
.maxOutputTokens(MAX_TOKENS)
116-
.build();
138+
.maxOutputTokens(MAX_TOKENS);
139+
140+
if (schema != null) {
141+
paramsBuilder.text(buildTextConfig(schema));
142+
}
143+
144+
ResponseCreateParams params = paramsBuilder.build();
117145

118146
Response chatGptResponse = openAIClient.responses().create(params);
119147
metrics.count("chatgpt-prompted");
@@ -142,4 +170,23 @@ private Optional<String> sendPrompt(String prompt, ChatGptModel chatModel) {
142170
return Optional.empty();
143171
}
144172
}
173+
174+
private static ResponseTextConfig buildTextConfig(ResponseSchema schema) {
175+
Map<String, Object> schemaMap =
176+
OBJECT_MAPPER.convertValue(schema, new TypeReference<>() {});
177+
178+
ResponseFormatTextJsonSchemaConfig.Schema.Builder schemaBuilder =
179+
ResponseFormatTextJsonSchemaConfig.Schema.builder();
180+
schemaMap.forEach(
181+
(key, value) -> schemaBuilder.putAdditionalProperty(key, JsonValue.from(value)));
182+
183+
ResponseFormatTextJsonSchemaConfig jsonSchemaConfig =
184+
ResponseFormatTextJsonSchemaConfig.builder()
185+
.name(DEFAULT_SCHEMA_NAME)
186+
.strict(true)
187+
.schema(schemaBuilder.build())
188+
.build();
189+
190+
return ResponseTextConfig.builder().format(jsonSchemaConfig).build();
191+
}
145192
}
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
package org.togetherjava.tjbot.features.chatgpt.schema;
2+
3+
import java.util.List;
4+
import java.util.Map;
5+
6+
/**
7+
* Represents a single property in an OpenAI JSON schema, used to describe the shape of one field
8+
* inside a {@link ResponseSchema}.
9+
* <p>
10+
* The hierarchy is sealed and mirrors the JSON Schema specification supported by OpenAI's
11+
* structured outputs:
12+
* <ul>
13+
* <li>{@link Primitive} – scalar values ({@code string}, {@code number}, {@code integer},
14+
* {@code boolean}, {@code null}).</li>
15+
* <li>{@link ArrayProperty} - an array whose elements conform to a nested {@link Property}.</li>
16+
* <li>{@link ObjectProperty} - a nested object with its own {@code properties} and {@code required}
17+
* list.</li>
18+
* </ul>
19+
* Prefer the static factory methods ({@link #of}, {@link #array}, {@link #object}) for readable
20+
* construction.
21+
*
22+
* @see ResponseSchema
23+
* @see <a href="https://platform.openai.com/docs/guides/structured-outputs">OpenAI Structured
24+
* Outputs</a>
25+
*/
26+
public sealed interface Property
27+
permits Property.Primitive, Property.ArrayProperty, Property.ObjectProperty {
28+
29+
/**
30+
* The JSON schema {@link Type} of this property.
31+
*
32+
* @return the type this property declares
33+
*/
34+
Type type();
35+
36+
/**
37+
* Creates a primitive property of the given scalar type.
38+
*
39+
* @param type a scalar type such as {@link Type#STRING} or {@link Type#INTEGER}
40+
* @return a new {@link Primitive} property
41+
*/
42+
static Primitive of(Type type) {
43+
return new Primitive(type);
44+
}
45+
46+
/**
47+
* Creates an array property whose elements conform to the given item schema.
48+
*
49+
* @param items the schema each element must satisfy
50+
* @return a new {@link ArrayProperty} of type {@link Type#ARRAY}
51+
*/
52+
static ArrayProperty array(Property items) {
53+
return new ArrayProperty(items);
54+
}
55+
56+
/**
57+
* Creates a nested object property with {@code additionalProperties} disabled, matching
58+
* OpenAI's strict-mode requirement.
59+
*
60+
* @param properties the fields of the nested object, keyed by field name
61+
* @param required the names of fields that must be present
62+
* @return a new {@link ObjectProperty} of type {@link Type#OBJECT}
63+
*/
64+
static ObjectProperty object(Map<String, Property> properties, List<String> required) {
65+
return new ObjectProperty(properties, required, false);
66+
}
67+
68+
/**
69+
* A scalar property - anything that isn't an object or array.
70+
*
71+
* @param type the scalar JSON type
72+
*/
73+
record Primitive(Type type) implements Property {
74+
}
75+
76+
/**
77+
* An array property describing a list of elements that all match {@link #items}.
78+
*
79+
* @param type always {@link Type#ARRAY}
80+
* @param items the schema each element must satisfy
81+
*/
82+
record ArrayProperty(Type type, Property items) implements Property {
83+
/**
84+
* Convenience constructor that fixes {@link #type} to {@link Type#ARRAY}.
85+
*
86+
* @param items the schema each element must satisfy
87+
*/
88+
public ArrayProperty(Property items) {
89+
this(Type.ARRAY, items);
90+
}
91+
}
92+
93+
/**
94+
* A nested object property with its own field definitions. Mirrors the top-level
95+
* {@link ResponseSchema} structure, allowing arbitrarily deep nesting.
96+
*
97+
* @param type always {@link Type#OBJECT}
98+
* @param properties the fields of the nested object, keyed by field name
99+
* @param required the names of fields that must be present
100+
* @param additionalProperties whether fields beyond those declared in {@code properties} are
101+
* allowed; OpenAI's strict mode requires {@code false}
102+
*/
103+
record ObjectProperty(Type type, Map<String, Property> properties, List<String> required,
104+
boolean additionalProperties) implements Property {
105+
/**
106+
* Convenience constructor that fixes {@link #type} to {@link Type#OBJECT}.
107+
*
108+
* @param properties the fields of the nested object
109+
* @param required the names of fields that must be present
110+
* @param additionalProperties whether undeclared fields are allowed
111+
*/
112+
public ObjectProperty(Map<String, Property> properties, List<String> required,
113+
boolean additionalProperties) {
114+
this(Type.OBJECT, properties, required, additionalProperties);
115+
}
116+
}
117+
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
package org.togetherjava.tjbot.features.chatgpt.schema;
2+
3+
import java.util.List;
4+
import java.util.Map;
5+
6+
/**
7+
* Top-level JSON schema describing the shape of a structured response from the OpenAI API.
8+
* <p>
9+
* Mirrors the {@code json_schema.schema} object that OpenAI's structured-outputs feature expects:
10+
* an object schema with declared {@code properties}, a {@code required} list, and the
11+
* {@code additionalProperties} flag (which must be {@code false} in strict mode).
12+
* <p>
13+
* Use {@link Property} (and its static factories) to build the {@code properties} map. Example:
14+
*
15+
* <pre>{@code
16+
* ResponseSchema schema = new ResponseSchema(Map.of("answer", Property.of(Type.STRING), "tags",
17+
* Property.array(Property.of(Type.STRING))), List.of("answer", "tags"));
18+
* }</pre>
19+
*
20+
* @param type the JSON type — must be {@link Type#OBJECT} for a top-level schema
21+
* @param properties the fields of the response object, keyed by field name
22+
* @param required the names of fields the model must always include
23+
* @param additionalProperties whether undeclared fields are allowed; strict mode requires
24+
* {@code false}
25+
*/
26+
public record ResponseSchema(Type type, Map<String, Property> properties, List<String> required,
27+
boolean additionalProperties) {
28+
29+
/**
30+
* Creates a strict-mode object schema: {@code type=object}, {@code additionalProperties=false}.
31+
*
32+
* @param properties the fields of the response object, keyed by field name
33+
* @param required the names of fields the model must always include
34+
*/
35+
public ResponseSchema(Map<String, Property> properties, List<String> required) {
36+
this(Type.OBJECT, properties, required, false);
37+
}
38+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
package org.togetherjava.tjbot.features.chatgpt.schema;
2+
3+
import com.fasterxml.jackson.annotation.JsonValue;
4+
5+
/**
6+
* The JSON Schema primitive types supported by OpenAI's structured outputs. Each constant
7+
* serializes to its lowercase form (e.g. {@code STRING} → {@code "string"}) via {@link #jsonValue},
8+
* matching the JSON Schema specification.
9+
*/
10+
public enum Type {
11+
STRING,
12+
NUMBER,
13+
INTEGER,
14+
BOOLEAN,
15+
OBJECT,
16+
ARRAY,
17+
NULL;
18+
19+
/**
20+
* Returns the lowercase JSON representation of this type. Used by Jackson via
21+
* {@link JsonValue}, so the enum serializes to {@code "string"} rather than {@code "STRING"}.
22+
*
23+
* @return the JSON Schema type name in lowercase
24+
*/
25+
@JsonValue
26+
public String jsonValue() {
27+
return name().toLowerCase();
28+
}
29+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
@MethodsReturnNonnullByDefault
2+
@ParametersAreNonnullByDefault
3+
package org.togetherjava.tjbot.features.chatgpt.schema;
4+
5+
import org.togetherjava.tjbot.annotations.MethodsReturnNonnullByDefault;
6+
7+
import javax.annotation.ParametersAreNonnullByDefault;

0 commit comments

Comments
 (0)