AI rule node: change structure of response format info in node config

This commit is contained in:
Dmytro Skarzhynets 2025-07-07 20:11:52 +03:00
parent 749b327795
commit d4ec3f8b39
No known key found for this signature in database
GPG Key ID: 2B51652F224037DF
7 changed files with 188 additions and 28 deletions

View File

@ -0,0 +1,39 @@
/**
* Copyright © 2016-2025 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.server.common.data.validation;
import jakarta.validation.Constraint;
import jakarta.validation.Payload;
import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Documented
@Constraint(validatedBy = {})
@Target({ElementType.FIELD, ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
public @interface ValidJsonSchema {
String message() default "must conform to the Draft 2020-12 meta-schema";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}

View File

@ -15,7 +15,7 @@
*/
package org.thingsboard.common.util;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.networknt.schema.JsonSchemaFactory;
import com.networknt.schema.SchemaId;
import com.networknt.schema.SchemaLocation;
@ -26,17 +26,15 @@ import java.util.Set;
public final class JsonSchemaUtils {
private JsonSchemaUtils() {
throw new AssertionError("Can't instantiate utility class");
}
private JsonSchemaUtils() {}
/**
* Validates that the provided JsonNode is a valid JSON Schema (Draft 2020-12).
* Validates that the provided ObjectNode is a valid JSON Schema (Draft 2020-12).
*
* @param schemaNode the JSON Schema document as a JsonNode
* @param schemaNode the JSON Schema document as an ObjectNode
* @return true if the schema is well-formed, false otherwise
*/
public static boolean isValidJsonSchema(JsonNode schemaNode) {
public static boolean isValidJsonSchema(ObjectNode schemaNode) {
Set<ValidationMessage> errors = JsonSchemaFactory
.getInstance(SpecVersion.VersionFlag.V202012)
.getSchema(SchemaLocation.of(SchemaId.V202012))

View File

@ -34,6 +34,7 @@ import org.thingsboard.server.common.data.validation.Length;
import org.thingsboard.server.common.data.validation.NoNullChar;
import org.thingsboard.server.common.data.validation.NoXss;
import org.thingsboard.server.common.data.validation.RateLimit;
import org.thingsboard.server.common.data.validation.ValidJsonSchema;
import org.thingsboard.server.dao.exception.DataValidationException;
import java.util.Collection;
@ -107,6 +108,7 @@ public class ConstraintValidator {
constraintMapping.constraintDefinition(Length.class).validatedBy(StringLengthValidator.class);
constraintMapping.constraintDefinition(RateLimit.class).validatedBy(RateLimitValidator.class);
constraintMapping.constraintDefinition(NoNullChar.class).validatedBy(NoNullCharValidator.class);
constraintMapping.constraintDefinition(ValidJsonSchema.class).validatedBy(JsonSchemaValidator.class);
return constraintMapping;
}

View File

@ -0,0 +1,31 @@
/**
* Copyright © 2016-2025 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.server.dao.service;
import com.fasterxml.jackson.databind.node.ObjectNode;
import jakarta.validation.ConstraintValidator;
import jakarta.validation.ConstraintValidatorContext;
import org.thingsboard.common.util.JsonSchemaUtils;
import org.thingsboard.server.common.data.validation.ValidJsonSchema;
public final class JsonSchemaValidator implements ConstraintValidator<ValidJsonSchema, ObjectNode> {
@Override
public boolean isValid(ObjectNode schema, ConstraintValidatorContext context) {
return schema == null || JsonSchemaUtils.isValidJsonSchema(schema);
}
}

View File

@ -23,7 +23,6 @@ import dev.langchain4j.data.message.SystemMessage;
import dev.langchain4j.data.message.UserMessage;
import dev.langchain4j.model.chat.request.ChatRequest;
import dev.langchain4j.model.chat.request.ResponseFormat;
import dev.langchain4j.model.chat.request.ResponseFormatType;
import dev.langchain4j.model.chat.response.ChatResponse;
import org.checkerframework.checker.nullness.qual.NonNull;
import org.thingsboard.common.util.JacksonUtil;
@ -81,11 +80,8 @@ public final class TbAiNode extends TbAbstractExternalNode implements TbNode {
}
// LC4j AnthropicChatModel rejects requests with non-null ResponseFormat even if ResponseFormatType is TEXT
if (config.getResponseFormatType() == ResponseFormatType.JSON) {
responseFormat = ResponseFormat.builder()
.type(config.getResponseFormatType())
.jsonSchema(config.getJsonSchema() != null ? Langchain4jJsonSchemaAdapter.fromObjectNode(config.getJsonSchema()) : null)
.build();
if (config.getResponseFormat().type() == TbResponseFormat.TbResponseFormatType.JSON) {
responseFormat = config.getResponseFormat().toLangChainResponseFormat();
}
systemPrompt = config.getSystemPrompt();

View File

@ -15,21 +15,19 @@
*/
package org.thingsboard.rule.engine.ai;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.databind.node.ObjectNode;
import dev.langchain4j.model.chat.request.ResponseFormatType;
import jakarta.validation.constraints.AssertTrue;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
import lombok.Data;
import org.thingsboard.common.util.JsonSchemaUtils;
import org.thingsboard.rule.engine.api.NodeConfiguration;
import org.thingsboard.server.common.data.id.AiModelSettingsId;
import org.thingsboard.server.common.data.validation.Length;
import static org.thingsboard.rule.engine.ai.TbResponseFormat.TbJsonResponseFormat;
@Data
public class TbAiNodeConfiguration implements NodeConfiguration<TbAiNodeConfiguration> {
@ -45,26 +43,19 @@ public class TbAiNodeConfiguration implements NodeConfiguration<TbAiNodeConfigur
private String userPrompt;
@NotNull
private ResponseFormatType responseFormatType;
private ObjectNode jsonSchema;
@Valid
private TbResponseFormat responseFormat;
@Min(value = 1, message = "must be at least 1 second")
@Max(value = 600, message = "cannot exceed 600 seconds (10 minutes)")
private int timeoutSeconds;
@JsonIgnore
@AssertTrue(message = "provided JSON Schema must conform to the Draft 2020-12 meta-schema")
public boolean isJsonSchemaValid() {
return jsonSchema == null || JsonSchemaUtils.isValidJsonSchema(jsonSchema);
}
@Override
public TbAiNodeConfiguration defaultConfiguration() {
var configuration = new TbAiNodeConfiguration();
configuration.setSystemPrompt("You are helpful assistant. Your response must be in JSON format.");
configuration.setUserPrompt("Tell me a joke.");
configuration.setResponseFormatType(ResponseFormatType.JSON);
configuration.setResponseFormat(new TbJsonResponseFormat());
configuration.setTimeoutSeconds(60);
return configuration;
}

View File

@ -0,0 +1,103 @@
/**
* Copyright © 2016-2025 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.rule.engine.ai;
import com.fasterxml.jackson.annotation.JsonSubTypes;
import com.fasterxml.jackson.annotation.JsonTypeInfo;
import com.fasterxml.jackson.databind.node.ObjectNode;
import dev.langchain4j.model.chat.request.ResponseFormat;
import dev.langchain4j.model.chat.request.ResponseFormatType;
import jakarta.validation.constraints.NotNull;
import org.thingsboard.server.common.data.validation.ValidJsonSchema;
import static org.thingsboard.rule.engine.ai.TbResponseFormat.TbJsonResponseFormat;
import static org.thingsboard.rule.engine.ai.TbResponseFormat.TbJsonSchemaResponseFormat;
import static org.thingsboard.rule.engine.ai.TbResponseFormat.TbTextResponseFormat;
@JsonTypeInfo(
use = JsonTypeInfo.Id.NAME,
include = JsonTypeInfo.As.PROPERTY,
property = "type"
)
@JsonSubTypes({
@JsonSubTypes.Type(value = TbTextResponseFormat.class, name = "TEXT"),
@JsonSubTypes.Type(value = TbJsonResponseFormat.class, name = "JSON"),
@JsonSubTypes.Type(value = TbJsonSchemaResponseFormat.class, name = "JSON_SCHEMA")
})
public sealed interface TbResponseFormat permits TbTextResponseFormat, TbJsonResponseFormat, TbJsonSchemaResponseFormat {
TbResponseFormatType type();
ResponseFormat toLangChainResponseFormat();
enum TbResponseFormatType {
TEXT,
JSON,
JSON_SCHEMA
}
record TbTextResponseFormat() implements TbResponseFormat {
@Override
public TbResponseFormatType type() {
return TbResponseFormatType.TEXT;
}
@Override
public ResponseFormat toLangChainResponseFormat() {
return ResponseFormat.builder()
.type(ResponseFormatType.TEXT)
.build();
}
}
record TbJsonResponseFormat() implements TbResponseFormat {
@Override
public TbResponseFormatType type() {
return TbResponseFormatType.JSON;
}
@Override
public ResponseFormat toLangChainResponseFormat() {
return ResponseFormat.builder()
.type(ResponseFormatType.JSON)
.build();
}
}
record TbJsonSchemaResponseFormat(@NotNull @ValidJsonSchema ObjectNode schema) implements TbResponseFormat {
@Override
public TbResponseFormatType type() {
return TbResponseFormatType.JSON_SCHEMA;
}
@Override
public ResponseFormat toLangChainResponseFormat() {
return ResponseFormat.builder()
.type(ResponseFormatType.JSON)
.jsonSchema(Langchain4jJsonSchemaAdapter.fromObjectNode(schema))
.build();
}
}
}