Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,19 +1,22 @@
package org.togetherjava.tjbot.features.chatgpt;

import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.openai.client.OpenAIClient;
import com.openai.client.okhttp.OpenAIOkHttpClient;
import com.openai.models.responses.Response;
import com.openai.models.responses.ResponseCreateParams;
import com.openai.models.responses.ResponseOutputText;
import com.openai.core.JsonValue;
import com.openai.models.responses.*;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import org.togetherjava.tjbot.config.Config;
import org.togetherjava.tjbot.features.analytics.Metrics;
import org.togetherjava.tjbot.features.chatgpt.schema.ResponseSchema;

import javax.annotation.Nullable;

import java.time.Duration;
import java.util.Map;
import java.util.Optional;
import java.util.stream.Collectors;

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

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

return sendPrompt(inputPrompt, chatModel);
return sendPrompt(inputPrompt, chatModel, null);
}

/**
Expand All @@ -90,17 +95,35 @@ public Optional<String> ask(String question, @Nullable String context, ChatGptMo
* Tokens</a>.
*/
public Optional<String> askRaw(String inputPrompt, ChatGptModel chatModel) {
return sendPrompt(inputPrompt, chatModel);
return sendPrompt(inputPrompt, chatModel, null);
}

/**
* Prompt ChatGPT with a raw prompt and receive a JSON response conforming to the given schema.
* <p>
* Uses OpenAI's structured outputs feature so the model is constrained to return JSON matching
* the supplied schema.
*
* @param inputPrompt The raw prompt to send to ChatGPT. Max is {@value MAX_TOKENS} tokens.
* @param chatModel The AI model to use for this request.
* @param schema The JSON schema the response must conform to.
* @return response from ChatGPT as a JSON string conforming to {@code schema}.
*/
public Optional<String> askRaw(String inputPrompt, ChatGptModel chatModel,
ResponseSchema schema) {
return sendPrompt(inputPrompt, chatModel, schema);
}

/**
* Sends a prompt to the ChatGPT API and returns the response.
*
* @param prompt The prompt to send to ChatGPT.
* @param chatModel The AI model to use for this request.
* @param schema Optional JSON schema constraining the model output; {@code null} for free-form.
* @return response from ChatGPT as a String.
*/
private Optional<String> sendPrompt(String prompt, ChatGptModel chatModel) {
private Optional<String> sendPrompt(String prompt, ChatGptModel chatModel,
@Nullable ResponseSchema schema) {
if (isDisabled) {
logger.warn("ChatGPT request attempted but service is disabled");
return Optional.empty();
Expand All @@ -109,11 +132,16 @@ private Optional<String> sendPrompt(String prompt, ChatGptModel chatModel) {
logger.debug("ChatGpt request: {}", prompt);

try {
ResponseCreateParams params = ResponseCreateParams.builder()
ResponseCreateParams.Builder paramsBuilder = ResponseCreateParams.builder()
.model(chatModel.toChatModel())
.input(prompt)
.maxOutputTokens(MAX_TOKENS)
.build();
.maxOutputTokens(MAX_TOKENS);

if (schema != null) {
paramsBuilder.text(buildTextConfig(schema));
}

ResponseCreateParams params = paramsBuilder.build();

Response chatGptResponse = openAIClient.responses().create(params);
metrics.count("chatgpt-prompted");
Expand Down Expand Up @@ -142,4 +170,23 @@ private Optional<String> sendPrompt(String prompt, ChatGptModel chatModel) {
return Optional.empty();
}
}

private static ResponseTextConfig buildTextConfig(ResponseSchema schema) {
Map<String, Object> schemaMap =
OBJECT_MAPPER.convertValue(schema, new TypeReference<>() {});

ResponseFormatTextJsonSchemaConfig.Schema.Builder schemaBuilder =
ResponseFormatTextJsonSchemaConfig.Schema.builder();
schemaMap.forEach(
(key, value) -> schemaBuilder.putAdditionalProperty(key, JsonValue.from(value)));

ResponseFormatTextJsonSchemaConfig jsonSchemaConfig =
ResponseFormatTextJsonSchemaConfig.builder()
.name(DEFAULT_SCHEMA_NAME)
.strict(true)
.schema(schemaBuilder.build())
.build();

return ResponseTextConfig.builder().format(jsonSchemaConfig).build();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
package org.togetherjava.tjbot.features.chatgpt.schema;

import java.util.List;
import java.util.Map;

/**
* Represents a single property in an OpenAI JSON schema, used to describe the shape of one field
* inside a {@link ResponseSchema}.
* <p>
* The hierarchy is sealed and mirrors the JSON Schema specification supported by OpenAI's
* structured outputs:
* <ul>
* <li>{@link Primitive} – scalar values ({@code string}, {@code number}, {@code integer},
* {@code boolean}, {@code null}).</li>
* <li>{@link ArrayProperty} - an array whose elements conform to a nested {@link Property}.</li>
* <li>{@link ObjectProperty} - a nested object with its own {@code properties} and {@code required}
* list.</li>
* </ul>
* Prefer the static factory methods ({@link #of}, {@link #array}, {@link #object}) for readable
* construction.
*
* @see ResponseSchema
* @see <a href="https://platform.openai.com/docs/guides/structured-outputs">OpenAI Structured
* Outputs</a>
*/
public sealed interface Property
permits Property.Primitive, Property.ArrayProperty, Property.ObjectProperty {

/**
* The JSON schema {@link Type} of this property.
*
* @return the type this property declares
*/
Type type();

/**
* Creates a primitive property of the given scalar type.
*
* @param type a scalar type such as {@link Type#STRING} or {@link Type#INTEGER}
* @return a new {@link Primitive} property
*/
static Primitive of(Type type) {
return new Primitive(type);
}

/**
* Creates an array property whose elements conform to the given item schema.
*
* @param items the schema each element must satisfy
* @return a new {@link ArrayProperty} of type {@link Type#ARRAY}
*/
static ArrayProperty array(Property items) {
return new ArrayProperty(items);
}

/**
* Creates a nested object property with {@code additionalProperties} disabled, matching
* OpenAI's strict-mode requirement.
*
* @param properties the fields of the nested object, keyed by field name
* @param required the names of fields that must be present
* @return a new {@link ObjectProperty} of type {@link Type#OBJECT}
*/
static ObjectProperty object(Map<String, Property> properties, List<String> required) {
return new ObjectProperty(properties, required, false);
}

/**
* A scalar property - anything that isn't an object or array.
*
* @param type the scalar JSON type
*/
record Primitive(Type type) implements Property {
}

/**
* An array property describing a list of elements that all match {@link #items}.
*
* @param type always {@link Type#ARRAY}
* @param items the schema each element must satisfy
*/
record ArrayProperty(Type type, Property items) implements Property {
/**
* Convenience constructor that fixes {@link #type} to {@link Type#ARRAY}.
*
* @param items the schema each element must satisfy
*/
public ArrayProperty(Property items) {
this(Type.ARRAY, items);
}
}
Comment thread
surajkumar marked this conversation as resolved.

/**
* A nested object property with its own field definitions. Mirrors the top-level
* {@link ResponseSchema} structure, allowing arbitrarily deep nesting.
*
* @param type always {@link Type#OBJECT}
* @param properties the fields of the nested object, keyed by field name
* @param required the names of fields that must be present
* @param additionalProperties whether fields beyond those declared in {@code properties} are
* allowed; OpenAI's strict mode requires {@code false}
*/
record ObjectProperty(Type type, Map<String, Property> properties, List<String> required,
boolean additionalProperties) implements Property {
/**
* Convenience constructor that fixes {@link #type} to {@link Type#OBJECT}.
*
* @param properties the fields of the nested object
* @param required the names of fields that must be present
* @param additionalProperties whether undeclared fields are allowed
*/
public ObjectProperty(Map<String, Property> properties, List<String> required,
boolean additionalProperties) {
this(Type.OBJECT, properties, required, additionalProperties);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package org.togetherjava.tjbot.features.chatgpt.schema;

import java.util.List;
import java.util.Map;

/**
* Top-level JSON schema describing the shape of a structured response from the OpenAI API.
* <p>
* Mirrors the {@code json_schema.schema} object that OpenAI's structured-outputs feature expects:
* an object schema with declared {@code properties}, a {@code required} list, and the
* {@code additionalProperties} flag (which must be {@code false} in strict mode).
* <p>
* Use {@link Property} (and its static factories) to build the {@code properties} map. Example:
*
* <pre>{@code
* ResponseSchema schema = new ResponseSchema(Map.of("answer", Property.of(Type.STRING), "tags",
* Property.array(Property.of(Type.STRING))), List.of("answer", "tags"));
* }</pre>
*
* @param type the JSON type — must be {@link Type#OBJECT} for a top-level schema
* @param properties the fields of the response object, keyed by field name
* @param required the names of fields the model must always include
* @param additionalProperties whether undeclared fields are allowed; strict mode requires
* {@code false}
*/
public record ResponseSchema(Type type, Map<String, Property> properties, List<String> required,
boolean additionalProperties) {

/**
* Creates a strict-mode object schema: {@code type=object}, {@code additionalProperties=false}.
*
* @param properties the fields of the response object, keyed by field name
* @param required the names of fields the model must always include
*/
public ResponseSchema(Map<String, Property> properties, List<String> required) {
this(Type.OBJECT, properties, required, false);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package org.togetherjava.tjbot.features.chatgpt.schema;

import com.fasterxml.jackson.annotation.JsonValue;

/**
* The JSON Schema primitive types supported by OpenAI's structured outputs. Each constant
* serializes to its lowercase form (e.g. {@code STRING} → {@code "string"}) via {@link #jsonValue},
* matching the JSON Schema specification.
*/
public enum Type {
STRING,
NUMBER,
INTEGER,
BOOLEAN,
OBJECT,
ARRAY,
NULL;
Comment thread
surajkumar marked this conversation as resolved.

/**
* Returns the lowercase JSON representation of this type. Used by Jackson via
* {@link JsonValue}, so the enum serializes to {@code "string"} rather than {@code "STRING"}.
*
* @return the JSON Schema type name in lowercase
*/
@JsonValue
public String jsonValue() {
Comment thread
surajkumar marked this conversation as resolved.
return name().toLowerCase();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
@MethodsReturnNonnullByDefault
@ParametersAreNonnullByDefault
package org.togetherjava.tjbot.features.chatgpt.schema;
Comment thread
surajkumar marked this conversation as resolved.

import org.togetherjava.tjbot.annotations.MethodsReturnNonnullByDefault;

import javax.annotation.ParametersAreNonnullByDefault;
Loading