Skip to content
This repository was archived by the owner on Feb 11, 2025. It is now read-only.

Commit 0037938

Browse files
ai-for-javaLangChain4j
andauthored
Support recursion in JSON schema (#44)
Co-authored-by: LangChain4j <info@langchain4j.dev>
1 parent f30ce9a commit 0037938

File tree

5 files changed

+199
-2
lines changed

5 files changed

+199
-2
lines changed

pom.xml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
<groupId>dev.ai4j</groupId>
66
<artifactId>openai4j</artifactId>
7-
<version>0.22.0</version>
7+
<version>0.23.0</version>
88

99
<name>Java Client for OpenAI (ChatGPT)</name>
1010
<description>Java Client for OpenAI (ChatGPT)</description>

src/main/java/dev/ai4j/openai4j/chat/JsonObjectSchema.java

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,13 +23,16 @@ public class JsonObjectSchema extends JsonSchemaElement {
2323
private final List<String> required;
2424
@JsonProperty("additionalProperties")
2525
private final Boolean additionalProperties;
26+
@JsonProperty("$defs")
27+
private final Map<String, JsonSchemaElement> definitions;
2628

2729
public JsonObjectSchema(Builder builder) {
2830
super("object");
2931
this.description = builder.description;
3032
this.properties = new LinkedHashMap<>(builder.properties);
3133
this.required = new ArrayList<>(builder.required);
3234
this.additionalProperties = builder.additionalProperties;
35+
this.definitions = builder.definitions == null ? null : new LinkedHashMap<>(builder.definitions);
3336
}
3437

3538
@Override
@@ -43,7 +46,8 @@ private boolean equalTo(JsonObjectSchema another) {
4346
return Objects.equals(description, another.description)
4447
&& Objects.equals(properties, another.properties)
4548
&& Objects.equals(required, another.required)
46-
&& Objects.equals(additionalProperties, another.additionalProperties);
49+
&& Objects.equals(additionalProperties, another.additionalProperties)
50+
&& Objects.equals(definitions, another.definitions);
4751
}
4852

4953
@Override
@@ -53,6 +57,7 @@ public int hashCode() {
5357
h += (h << 5) + Objects.hashCode(properties);
5458
h += (h << 5) + Objects.hashCode(required);
5559
h += (h << 5) + Objects.hashCode(additionalProperties);
60+
h += (h << 5) + Objects.hashCode(definitions);
5661
return h;
5762
}
5863

@@ -63,6 +68,7 @@ public String toString() {
6368
", properties=" + properties +
6469
", required=" + required +
6570
", additionalProperties=" + additionalProperties +
71+
", definitions=" + definitions +
6672
"}";
6773
}
6874

@@ -79,6 +85,7 @@ public static class Builder {
7985
private Map<String, JsonSchemaElement> properties = new LinkedHashMap<>();
8086
private List<String> required = new ArrayList<>();
8187
private Boolean additionalProperties;
88+
private Map<String, JsonSchemaElement> definitions;
8289

8390
public Builder description(String description) {
8491
this.description = description;
@@ -100,6 +107,11 @@ public Builder additionalProperties(Boolean additionalProperties) {
100107
return this;
101108
}
102109

110+
public Builder definitions(Map<String, JsonSchemaElement> definitions) {
111+
this.definitions = definitions;
112+
return this;
113+
}
114+
103115
public JsonObjectSchema build() {
104116
return new JsonObjectSchema(this);
105117
}
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
package dev.ai4j.openai4j.chat;
2+
3+
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
4+
import com.fasterxml.jackson.annotation.JsonInclude;
5+
import com.fasterxml.jackson.annotation.JsonProperty;
6+
import com.fasterxml.jackson.databind.PropertyNamingStrategies;
7+
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
8+
import com.fasterxml.jackson.databind.annotation.JsonNaming;
9+
import com.fasterxml.jackson.databind.annotation.JsonPOJOBuilder;
10+
11+
import java.util.Objects;
12+
13+
@JsonDeserialize(builder = JsonReferenceSchema.Builder.class)
14+
@JsonInclude(JsonInclude.Include.NON_NULL)
15+
@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
16+
public class JsonReferenceSchema extends JsonSchemaElement {
17+
18+
@JsonProperty("$ref")
19+
private final String reference;
20+
21+
public JsonReferenceSchema(Builder builder) {
22+
super(null);
23+
this.reference = builder.reference;
24+
}
25+
26+
@Override
27+
public boolean equals(Object another) {
28+
if (this == another) return true;
29+
return another instanceof JsonReferenceSchema
30+
&& equalTo((JsonReferenceSchema) another);
31+
}
32+
33+
private boolean equalTo(JsonReferenceSchema another) {
34+
return Objects.equals(reference, another.reference);
35+
}
36+
37+
@Override
38+
public int hashCode() {
39+
int h = 5381;
40+
h += (h << 5) + Objects.hashCode(reference);
41+
return h;
42+
}
43+
44+
@Override
45+
public String toString() {
46+
return "JsonReferenceSchema{" +
47+
"reference=" + reference +
48+
"}";
49+
}
50+
51+
public static Builder builder() {
52+
return new Builder();
53+
}
54+
55+
@JsonPOJOBuilder(withPrefix = "")
56+
@JsonIgnoreProperties(ignoreUnknown = true)
57+
@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
58+
public static class Builder {
59+
60+
private String reference;
61+
62+
public Builder reference(String reference) {
63+
this.reference = reference;
64+
return this;
65+
}
66+
67+
public JsonReferenceSchema build() {
68+
return new JsonReferenceSchema(this);
69+
}
70+
}
71+
}

src/main/resources/META-INF/native-image/dev.ai4j/openai4j/reflect-config.json

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,15 @@
161161
"allDeclaredFields": true,
162162
"allPublicFields": true
163163
},
164+
{
165+
"name": "dev.ai4j.openai4j.chat.JsonReferenceSchema",
166+
"allDeclaredConstructors": true,
167+
"allPublicConstructors": true,
168+
"allDeclaredMethods": true,
169+
"allPublicMethods": true,
170+
"allDeclaredFields": true,
171+
"allPublicFields": true
172+
},
164173
{
165174
"name": "dev.ai4j.openai4j.chat.JsonSchema",
166175
"allDeclaredConstructors": true,

src/test/java/dev/ai4j/openai4j/chat/ChatCompletionTest.java

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -607,6 +607,111 @@ void testStrictJsonResponseFormat(ChatCompletionModel model) {
607607
"{\"name\":{\"first_name\":\"Klaus\",\"last_name\":\"Heisler\"},\"age\":37}");
608608
}
609609

610+
611+
@Test
612+
void testJsonResponseFormatWithExplicitRecursion() {
613+
614+
// given
615+
boolean strict = true;
616+
617+
JsonSchema jsonSchema = JsonSchema.builder()
618+
.name("person")
619+
.schema(JsonObjectSchema.builder()
620+
.properties(new LinkedHashMap<String, JsonSchemaElement>() {{
621+
put("name", JsonStringSchema.builder().build());
622+
put("children", JsonArraySchema.builder()
623+
.items(JsonReferenceSchema.builder()
624+
.reference("#/$defs/person") // explicit recursion
625+
.build())
626+
.build());
627+
}})
628+
.required(asList("name", "children"))
629+
.additionalProperties(false)
630+
.definitions(new LinkedHashMap<String, JsonSchemaElement>() {{
631+
put("person", JsonObjectSchema.builder()
632+
.properties(new LinkedHashMap<String, JsonSchemaElement>() {{
633+
put("name", JsonStringSchema.builder().build());
634+
put("children", JsonArraySchema.builder()
635+
.items(JsonReferenceSchema.builder()
636+
.reference("#/$defs/person") // explicit recursion
637+
.build())
638+
.build());
639+
}})
640+
.required(asList("name", "children"))
641+
.additionalProperties(false)
642+
.build());
643+
}})
644+
.build())
645+
.strict(strict)
646+
.build();
647+
648+
ResponseFormat responseFormat = ResponseFormat.builder()
649+
.type(JSON_SCHEMA)
650+
.jsonSchema(jsonSchema)
651+
.build();
652+
653+
ChatCompletionRequest request = ChatCompletionRequest.builder()
654+
.model(GPT_4O_MINI)
655+
.addUserMessage("Extract information from the following text: Anna has 2 children: David and Kate")
656+
.responseFormat(responseFormat)
657+
.build();
658+
659+
// when
660+
ChatCompletionResponse response = client.chatCompletion(request).execute();
661+
662+
// then
663+
assertThat(response.content()).isEqualToIgnoringWhitespace(
664+
"{\"name\":\"Anna\",\"children\":[" +
665+
"{\"name\":\"David\",\"children\":[]}," +
666+
"{\"name\":\"Kate\",\"children\":[]}" +
667+
"]}");
668+
}
669+
670+
@Test
671+
void testJsonResponseFormatWithRootRecursion() {
672+
673+
// given
674+
boolean strict = true;
675+
676+
JsonSchema jsonSchema = JsonSchema.builder()
677+
.name("person")
678+
.schema(JsonObjectSchema.builder()
679+
.properties(new LinkedHashMap<String, JsonSchemaElement>() {{
680+
put("name", JsonStringSchema.builder().build());
681+
put("children", JsonArraySchema.builder()
682+
.items(JsonReferenceSchema.builder()
683+
.reference("#") // root recursion
684+
.build())
685+
.build());
686+
}})
687+
.required(asList("name", "children"))
688+
.additionalProperties(false)
689+
.build())
690+
.strict(strict)
691+
.build();
692+
693+
ResponseFormat responseFormat = ResponseFormat.builder()
694+
.type(JSON_SCHEMA)
695+
.jsonSchema(jsonSchema)
696+
.build();
697+
698+
ChatCompletionRequest request = ChatCompletionRequest.builder()
699+
.model(GPT_4O_MINI)
700+
.addUserMessage("Extract information from the following text: Anna has 2 children: David and Kate")
701+
.responseFormat(responseFormat)
702+
.build();
703+
704+
// when
705+
ChatCompletionResponse response = client.chatCompletion(request).execute();
706+
707+
// then
708+
assertThat(response.content()).isEqualToIgnoringWhitespace(
709+
"{\"name\":\"Anna\",\"children\":[" +
710+
"{\"name\":\"David\",\"children\":[]}," +
711+
"{\"name\":\"Kate\",\"children\":[]}" +
712+
"]}");
713+
}
714+
610715
@Test
611716
void testGpt4Vision() {
612717

0 commit comments

Comments
 (0)