gnodet-bot commented on code in PR #27494: URL: https://github.com/apache/camel/pull/27494#discussion_r4208105281
########## components/camel-ai/camel-semantic/src/main/java/org/apache/camel/semantic/SemanticEvaluations.java: ########## @@ -0,0 +1,183 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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.apache.camel.semantic; + +import java.util.Collections; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.WeakHashMap; +import java.util.function.Consumer; + +import org.apache.camel.CamelContext; +import org.apache.camel.language.semantic.SemanticLanguage; +import org.apache.camel.spi.Resource; + +/** Context-local named evaluations, replaced atomically per source when a route resource is reloaded. */ +public final class SemanticEvaluations { + private static final Object CREATION_LOCK = new Object(); Review Comment: 💡 **Static `CREATION_LOCK` serializes all CamelContexts in the JVM** `CREATION_LOCK` is a `static final Object` shared across all `CamelContext` instances. In a test suite or multi-tenant deployment that starts several contexts concurrently, they all serialize on this one lock during `SemanticEvaluations.get()`. The critical section is trivially short, so the window is small — but the contention is unnecessary. Consider locking on the context itself (double-checked) or documenting the trade-off: ```suggestion public static SemanticEvaluations get(CamelContext context) { SemanticEvaluations answer = context.getCamelContextExtension().getContextPlugin(SemanticEvaluations.class); if (answer == null) { synchronized (context) { answer = context.getCamelContextExtension().getContextPlugin(SemanticEvaluations.class); if (answer == null) { answer = new SemanticEvaluations(context); context.getCamelContextExtension().addContextPlugin(SemanticEvaluations.class, answer); } } } return answer; } ``` ########## components/camel-ai/camel-semantic/src/main/java/org/apache/camel/semantic/SemanticCapabilities.java: ########## @@ -138,221 +86,309 @@ public String getArtifactId() { return artifactId; } - public Set<InputType> getInputTypes() { - return inputTypes; - } - - public Set<ResultType> getResultTypes() { - return resultTypes; + public Map<String, Operation> getOperations() { + return operations; } - public Instructions getInstructions() { - return instructions; + public Operation operation(String name) { + Operation result = operations.get(name); + if (result == null) { + throw new IllegalArgumentException( + "Unknown operation '" + name + "'; supported operations: " + operations.keySet()); + } + return result; } - public boolean isCallerDefinedCriteria() { - return callerDefinedCriteria; + public void validate(SemanticEvaluation evaluation) { + operation(evaluation.getOperation()).validate(evaluation.getParameters()); } - public boolean isBooleanProbability() { - return booleanProbability; + private static String required(String value, String field) { + if (value == null || value.isBlank()) { + throw new IllegalArgumentException(field + " is required"); + } + return value; } - public boolean isChoiceProbabilities() { - return choiceProbabilities; + private static boolean withinBounds(Number number, double minimum, double maximum) { + if (minimum == Double.POSITIVE_INFINITY || maximum == Double.NEGATIVE_INFINITY) { + return false; + } + try { + // Preserve decimal precision, including values just outside a declared boundary. + BigDecimal value = new BigDecimal(number.toString()); + return (minimum == Double.NEGATIVE_INFINITY || value.compareTo(BigDecimal.valueOf(minimum)) >= 0) + && (maximum == Double.POSITIVE_INFINITY || value.compareTo(BigDecimal.valueOf(maximum)) <= 0); + } catch (NumberFormatException invalid) { + return false; + } } - public Set<ResultType> getConfidenceTypes() { - return confidenceTypes; - } + /** Operation metadata and common validation, independent of any expert instance. */ + public static final class Operation { + private final SemanticOperation declaration; + private final Set<InputType> inputTypes; + private final Set<String> labels; + private final Map<String, Parameter> parameters; + + private Operation(SemanticOperation declaration) { + this.declaration = declaration; + required(declaration.name(), "Operation name"); + required(declaration.description(), "Operation description"); + required(declaration.inputRequirements(), "Operation input requirements"); + required(declaration.resultMeaning(), "Operation result meaning"); + inputTypes = Set.copyOf(List.of(declaration.inputTypes())); + labels = Collections.unmodifiableSet(new LinkedHashSet<>(List.of(declaration.labels()))); + if (inputTypes.isEmpty() || Double.isNaN(declaration.minimum()) || Double.isNaN(declaration.maximum()) + || declaration.minimum() > declaration.maximum() || labels.stream().anyMatch(String::isBlank)) { + throw new IllegalArgumentException("Invalid operation contract: " + declaration.name()); + } + if (declaration.probability() || declaration.probabilities()) { + required(declaration.probabilityMeaning(), "Probability meaning"); + } + if (declaration.confidence()) { + required(declaration.confidenceMeaning(), "Confidence meaning"); + } + Map<String, Parameter> values = new LinkedHashMap<>(); + for (SemanticParameter parameter : declaration.parameters()) { + Parameter contract = new Parameter(parameter); + if (values.putIfAbsent(contract.getName(), contract) != null) { + throw new IllegalArgumentException("Duplicate parameter: " + contract.getName()); + } + } + parameters = Collections.unmodifiableMap(values); + } - public String getProbabilityMeaning() { - return probabilityMeaning; - } + public String getName() { + return declaration.name(); + } - public String getConfidenceMeaning() { - return confidenceMeaning; - } + public String getDescription() { + return declaration.description(); + } - public String getTrueMeaning() { - return trueMeaning; - } + public Set<InputType> getInputTypes() { + return inputTypes; + } - public int getMaxChoices() { - return maxChoices; - } + public String getInputRequirements() { + return declaration.inputRequirements(); + } - public int getMaxScoreLevels() { - return maxScoreLevels; - } + public Map<String, Parameter> getParameters() { + return parameters; + } - /** Validate the common contract before the provider checks any model-specific restrictions. */ - public void validate(SemanticQuestion question) { - if ((!known || instructions == Instructions.REQUIRED) && question.getInstructions() == null) { - throw new IllegalArgumentException("Question instructions are required"); + public ResultType getResultType() { + return declaration.resultType(); } - if (!known) { - return; + + public String getResultMeaning() { + return declaration.resultMeaning(); } - if (!resultTypes.contains(ResultType.valueOf(question.getType().name()))) { - throw new IllegalArgumentException( - "Requires " + question.getType() + "; supports " + resultTypes + ": " + description); + + public Set<String> getLabels() { + return labels; } - if (instructions == Instructions.UNSUPPORTED && question.getInstructions() != null) { - throw new IllegalArgumentException("Instructions are unsupported by this fixed expert"); + + public double getMinimum() { + return declaration.minimum(); } - if (!callerDefinedCriteria && (!question.getCriteria().isEmpty() || !question.getLevels().isEmpty())) { - throw new IllegalArgumentException("Caller-defined criteria and score levels are unsupported by this fixed expert"); + + public double getMaximum() { + return declaration.maximum(); } - if (question.getType() == SemanticQuestion.Type.CHOICE && maxChoices > 0 - && question.getCriteria().size() > maxChoices) { - throw new IllegalArgumentException("Supports at most " + maxChoices + " choice criteria"); + + public boolean isProbability() { + return declaration.probability(); } - if (question.getType() == SemanticQuestion.Type.SCORE && maxScoreLevels > 0 - && question.getLevels().size() > maxScoreLevels) { - throw new IllegalArgumentException("Supports at most " + maxScoreLevels + " score levels"); + + public boolean isProbabilities() { + return declaration.probabilities(); } - if (question.getType() == SemanticQuestion.Type.BOOLEAN && !booleanProbability - && (question.getThreshold() != 0.5 || question.getUncertainty() != 0 - || question.getUncertaintyPolicy() != SemanticQuestion.UncertaintyPolicy.FAIL)) { - throw new IllegalArgumentException("Boolean decision policy requires positive-class probability support"); + + public String getProbabilityMeaning() { + return declaration.probabilityMeaning(); } - } - /** Input shape can only be checked once the state selector has been evaluated. */ - public void validateInput(Object state) { - if (known && !(state instanceof String && inputTypes.contains(InputType.TEXT) - || (state instanceof Map<?, ?> || state instanceof List<?>) && inputTypes.contains(InputType.STRUCTURED))) { - throw new IllegalArgumentException("Unsupported selected state; accepts " + inputTypes + " input"); + public boolean isConfidence() { + return declaration.confidence(); } - } - /** Named construction of an immutable, explicitly supported contract. */ - public static final class Builder { - private String name; - private String description; - private String provider; - private String artifactId; - private Set<InputType> inputTypes = Set.of(); - private Set<ResultType> resultTypes = Set.of(); - private Instructions instructions = Instructions.REQUIRED; - private boolean callerDefinedCriteria; - private boolean booleanProbability; - private boolean choiceProbabilities; - private Set<ResultType> confidenceTypes = Set.of(); - private String probabilityMeaning; - private String confidenceMeaning; - private String trueMeaning; - private int maxChoices; - private int maxScoreLevels; + public String getConfidenceMeaning() { + return declaration.confidenceMeaning(); + } - private Builder() { + public void validate(Map<String, Object> values) { + for (String name : values.keySet()) { + if (!parameters.containsKey(name)) { + throw new IllegalArgumentException("Unknown parameter '" + name + "' for operation '" + getName() + "'"); + } + } + parameters.forEach((name, parameter) -> parameter.validate(values.get(name), values.containsKey(name))); Review Comment: ⚠️ **Missing blank-key validation for nested Map parameters** `Operation.validate()` calls `parameter.validate(values.get(name), ...)` which in turn calls `map.values().forEach(this::validateItem)` — but **map keys are never checked for blankness**. A declaration like: ```yaml parameters: criteria: "": "empty key" ``` passes SPI validation and reaches the adapter. `SemanticEvaluation.immutableValue` checks that nested map keys are `instanceof String` but not that they're non-blank. TypeSafe AI catches this in its own `validate()`, but custom experts that rely on the generic SPI contract will silently accept blank keys and produce confusing behaviour at inference time. Consider adding a blank-key check in `validateItem` (or in `immutableValue` — see the `SemanticEvaluation` comment below for a combined fix). ########## components/camel-ai/camel-semantic/src/main/java/org/apache/camel/semantic/SemanticEvaluation.java: ########## @@ -0,0 +1,104 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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.apache.camel.semantic; + +import java.math.BigDecimal; +import java.math.BigInteger; +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** Immutable evaluation declaration. The expert defines the operation, parameter vocabulary and result semantics. */ +public final class SemanticEvaluation { + private static final Set<Class<?>> NUMBER_TYPES = Set.of(Byte.class, Short.class, Integer.class, Long.class, + Float.class, Double.class, BigInteger.class, BigDecimal.class); + + private final String operation; + private final String expert; + private final String state; + private final Map<String, Object> parameters; + + public SemanticEvaluation(String operation, String expert, String state, Map<String, ?> parameters) { + if (operation == null || operation.isBlank()) { + throw new IllegalArgumentException("Evaluation operation is required"); + } + if (expert != null && expert.isBlank()) { + throw new IllegalArgumentException("Evaluation expert must not be blank"); + } + if (state != null && state.isBlank()) { + throw new IllegalArgumentException("Evaluation state selector must not be blank"); + } + this.operation = operation; + this.expert = expert; + this.state = state; + this.parameters = immutableMap(parameters == null ? Map.of() : parameters); + } + + static Map<String, Object> immutableMap(Map<String, ?> values) { + Map<String, Object> copy = new LinkedHashMap<>(); + values.forEach((name, value) -> { + if (name == null || name.isBlank()) { + throw new IllegalArgumentException("Parameter names must not be blank"); + } + copy.put(name, immutableValue(value)); + }); + return Collections.unmodifiableMap(copy); + } + + private static Object immutableValue(Object value) { + if (value instanceof Map<?, ?> map) { + Map<String, Object> copy = new LinkedHashMap<>(); + map.forEach((key, entry) -> { + if (!(key instanceof String name)) { + throw new IllegalArgumentException("Parameter maps require string keys"); + } + copy.put(name, entry); + }); + return immutableMap(copy); Review Comment: 💡 **`immutableValue` makes two passes over nested Map entries** For a `Map` parameter, `immutableValue` builds `copy` with raw `entry` values (line 72), then calls `immutableMap(copy)` (line 74), which calls `immutableValue` on each entry again (line 60). The intermediate map is immediately discarded. Calling `immutableValue(entry)` inline during the first pass avoids the redundant traversal and also catches the missing blank-key check for nested map keys: ```suggestion private static Object immutableValue(Object value) { if (value instanceof Map<?, ?> map) { Map<String, Object> copy = new LinkedHashMap<>(); map.forEach((key, entry) -> { if (!(key instanceof String name)) { throw new IllegalArgumentException("Parameter maps require string keys"); } if (name.isBlank()) { throw new IllegalArgumentException("Parameter map keys must not be blank"); } copy.put(name, immutableValue(entry)); }); return Collections.unmodifiableMap(copy); } ``` ########## components/camel-ai/camel-semantic/src/main/java/org/apache/camel/semantic/yaml/SemanticDefinitionDeserializer.java: ########## @@ -69,127 +79,150 @@ public void preParse(YamlDeserializationContext dc, Node root) { if (!(root instanceof SequenceNode sequence)) { return; } - Map<String, SemanticQuestion> definitions = new LinkedHashMap<>(); + Map<String, SemanticEvaluation> definitions = new LinkedHashMap<>(); for (Node node : sequence.getValue()) { if (!(node instanceof MappingNode mapping)) { - // Leave malformed entries to the route loader without replacing the resource's questions. + // Leave malformed entries to the route loader without replacing the resource's evaluations. return; } for (NodeTuple tuple : mapping.getValue()) { if ("semantic".equals(asText(tuple.getKeyNode()))) { - read(dc.getCamelContext(), tuple.getValueNode()).forEach((name, question) -> { - if (definitions.putIfAbsent(name, question) != null) { + read(dc.getCamelContext(), tuple.getValueNode()).forEach((name, evaluation) -> { + if (definitions.putIfAbsent(name, evaluation) != null) { throw new YamlDeserializationException( - tuple.getValueNode(), "Duplicate semantic question: " + name); + tuple.getValueNode(), "Duplicate semantic evaluation: " + name); } }); } } } CamelContext context = dc.getCamelContext(); - SemanticQuestions questions = definitions.isEmpty() - ? context.getCamelContextExtension().getContextPlugin(SemanticQuestions.class) - : SemanticQuestions.get(context); - if (questions != null) { + SemanticEvaluations evaluations = definitions.isEmpty() + ? context.getCamelContextExtension().getContextPlugin(SemanticEvaluations.class) + : SemanticEvaluations.get(context); + if (evaluations != null) { try { - questions.replace(dc.getResource(), definitions); + evaluations.replace(dc.getResource(), definitions); } catch (IllegalArgumentException e) { throw new YamlDeserializationException(root, e.getMessage(), e); } } } - private static Map<String, SemanticQuestion> read(CamelContext context, Node node) { + private static Map<String, SemanticEvaluation> read(CamelContext context, Node node) { Map<String, Node> semantic = fields(node, "semantic declaration"); - if (!semantic.keySet().equals(Set.of("question"))) { - throw new YamlDeserializationException(node, "Semantic declaration requires only question"); + for (String field : semantic.keySet()) { + if (!Set.of("evaluation", "expert", "state").contains(field)) { + throw new YamlDeserializationException( + semantic.get(field), "Unknown property '" + field + "' in semantic declaration"); + } } - Map<String, SemanticQuestion> result = new LinkedHashMap<>(); - fields(semantic.get("question"), "semantic questions").forEach((name, definition) -> { + if (!semantic.containsKey("evaluation")) { + throw new YamlDeserializationException(node, "Semantic declaration requires evaluation"); + } + Map<String, SemanticEvaluation> result = new LinkedHashMap<>(); + fields(semantic.get("evaluation"), "semantic evaluations").forEach((name, definition) -> { if (name.isBlank()) { - throw new YamlDeserializationException(definition, "Semantic question requires a nonblank name"); + throw new YamlDeserializationException(definition, "Semantic evaluation requires a nonblank name"); + } + Map<String, Node> values = fields(definition, "semantic evaluation '" + name + "'"); + for (String common : List.of("expert", "state")) { + if (!values.containsKey(common) && semantic.containsKey(common)) { + values.put(common, semantic.get(common)); + } } - Map<String, Node> values = fields(definition, "semantic question '" + name + "'"); - String expert = values.containsKey("expert") ? asText(values.get("expert")) : "default/automatic"; + String expert = "default/automatic"; try { - result.put(name, readQuestion(context, name, expert, definition, values)); + if (values.containsKey("expert")) { + expert = "invalid expert reference"; + expert = asText(values.get("expert")); + } + result.put(name, readEvaluation(context, name, expert, definition, values)); } catch (IllegalArgumentException | InvalidNodeTypeException e) { throw new YamlDeserializationException( - definition, "Invalid semantic question '" + name + "': " + e.getMessage() + definition, "Invalid semantic evaluation '" + name + "': " + e.getMessage() + " (expert '" + expert + "')", e); } }); return result; } - private static SemanticQuestion readQuestion( + private static SemanticEvaluation readEvaluation( CamelContext context, String name, String expert, Node definition, Map<String, Node> values) { - String description = "semantic question '" + name + "'"; + String description = "semantic evaluation '" + name + "'"; String expertContext = " (expert '" + expert + "')"; values.forEach((field, value) -> { if (!FIELDS.contains(field)) { throw new YamlDeserializationException( value, "Unknown property '" + field + "' in " + description + expertContext); } }); - if (!values.containsKey("type")) { - throw new YamlDeserializationException(definition, "Semantic question type is required: " + name + expertContext); + if (values.containsKey("type") == values.containsKey("operation")) { + throw new YamlDeserializationException( + definition, "Specify exactly one operation or type: " + name + expertContext); } - SemanticQuestion.Type type = enumeration(values.get("type"), name, expert, "type", SemanticQuestion.Type.class); - Map<String, String> criteria = new LinkedHashMap<>(); - List<String> levels = List.of(); - if (values.containsKey("criteria")) { - if (type == SemanticQuestion.Type.SCORE) { - levels = asSequenceNode(values.get("criteria")).getValue().stream().map(YamlDeserializerSupport::asText) - .toList(); - } else { - fields(values.get("criteria"), "criteria for " + description + expertContext) - .forEach((key, value) -> criteria.put(key, asText(value))); - } + String operation = values.containsKey("operation") + ? asText(values.get("operation")) + : asText(values.get("type")).toLowerCase(Locale.ROOT); + SemanticEvaluationBuilder builder = new SemanticEvaluationBuilder().operation(operation) + .expert(asText(values.get("expert"))).state(asText(values.get("state"))); + if (values.containsKey("parameters")) { + fields(values.get("parameters"), "evaluation parameters") + .forEach((key, value) -> builder.parameter(key, value(context, value))); } - if (type != SemanticQuestion.Type.BOOLEAN && (values.containsKey("threshold") || values.containsKey("uncertainty") - || values.containsKey("uncertaintyPolicy"))) { - throw new YamlDeserializationException( - definition, "Threshold and uncertainty policy require a boolean question: " + name + expertContext); + for (String field : List.of("instructions", "criteria", "threshold", "uncertainty", "uncertaintyPolicy")) { + Node node = values.get(field); + if (node == null) { + continue; + } + switch (field) { + case "instructions" -> builder.instructions(asText(node)); + case "threshold", "uncertainty" -> { + try { + builder.parameter(field, Double.valueOf(context.resolvePropertyPlaceholders(asText(node)))); + } catch (NumberFormatException invalid) { + throw new YamlDeserializationException( + node, + "Invalid numeric value for '" + field + "' in " + description + expertContext); + } + } + case "uncertaintyPolicy" -> builder.uncertaintyPolicy(asText(node)); + case "criteria" -> { + if (node instanceof MappingNode) { + Map<String, String> criteria = new LinkedHashMap<>(); + fields(node, "criteria").forEach((key, child) -> criteria.put(key, asText(child))); + builder.parameter(field, criteria); + } else if (node instanceof SequenceNode sequence) { + builder.parameter(field, + sequence.getValue().stream().map(SemanticDefinitionDeserializer::asText).toList()); + } else { + builder.parameter(field, value(context, node)); + } + } + default -> throw new IllegalStateException(field); + } } - SemanticQuestion.UncertaintyPolicy policy = values.containsKey("uncertaintyPolicy") - ? enumeration(values.get("uncertaintyPolicy"), name, expert, "uncertaintyPolicy", - SemanticQuestion.UncertaintyPolicy.class) - : SemanticQuestion.UncertaintyPolicy.FAIL; - return new SemanticQuestion( - type, asText(values.get("instructions")), asText(values.get("state")), - criteria, levels, number(context, values, name, expert, "threshold", 0.5), - number(context, values, name, expert, "uncertainty", 0), policy, asText(values.get("expert"))); + return builder.build(context); } - private static <T extends Enum<T>> T enumeration(Node node, String question, String expert, String field, Class<T> type) { - try { - return asEnum(node, type); - } catch (InvalidEnumException e) { - throw new YamlDeserializationException( - node, "Invalid value for '" + field + "' in semantic question '" + question + "': " + asText(node) - + " (expert '" + expert + "')", - e); + private static Object value(CamelContext context, Node node) { + if (node instanceof MappingNode) { + Map<String, Object> result = new LinkedHashMap<>(); + fields(node, "parameter map").forEach((key, child) -> result.put(key, value(context, child))); + return result; } - } - - private static double number( - CamelContext context, Map<String, Node> values, String question, String expert, String name, double fallback) { - if (!values.containsKey(name)) { - return fallback; + if (node instanceof SequenceNode sequence) { + return sequence.getValue().stream().map(child -> value(context, child)).toList(); } - Node node = values.get(name); - String raw = asText(node); - try { - return Double.parseDouble(context.resolvePropertyPlaceholders(raw)); - } catch (NumberFormatException e) { - throw new YamlDeserializationException( - node, - "Invalid numeric value for '" + name + "' in semantic question '" + question + "': " + raw - + " (expert '" + expert + "')", - e); + if (Tag.FLOAT.equals(node.getTag()) || NUMBER.equals(node.getTag())) { + try { + return new BigDecimal(context.resolvePropertyPlaceholders(asText(node))); + } catch (NumberFormatException invalid) { + throw new YamlDeserializationException(node, "Invalid numeric parameter"); + } } + return new StandardConstructor(LoadSettings.builder().build()).constructSingleDocument(Optional.of(node)); Review Comment: 💡 **`StandardConstructor` allocated per scalar value** A `new StandardConstructor(LoadSettings.builder().build())` is created for every scalar parameter value parsed in a YAML evaluation declaration. For declarations with many parameters this creates unnecessary garbage per parse. `StandardConstructor` with default `LoadSettings` is stateless across `constructSingleDocument` calls in SnakeYAML Engine v2. Consider caching it as a static constant: ```suggestion return YAML_CONSTRUCTOR.constructSingleDocument(Optional.of(node)); ``` And add at class level: ```java private static final StandardConstructor YAML_CONSTRUCTOR = new StandardConstructor(LoadSettings.builder().build()); ``` ########## components/camel-ai/camel-semantic/src/main/java/org/apache/camel/semantic/SemanticEvaluationBuilder.java: ########## @@ -0,0 +1,184 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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.apache.camel.semantic; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +import org.apache.camel.CamelContext; +import org.apache.camel.util.StringHelper; + +/** Fluent definition of an expert-owned evaluation. All DSLs build the same immutable declaration. */ +public final class SemanticEvaluationBuilder { + private final SemanticEvaluationsBuilder parent; + private final Map<String, Object> parameters = new LinkedHashMap<>(); + private final Map<String, String> criteria = new LinkedHashMap<>(); + private final List<String> levels = new ArrayList<>(); + private String threshold; + private String uncertainty; + private boolean normalizeUncertaintyPolicy; + private String operation; + private String expert; + private String state; + + /** Create a standalone declaration, completed with {@link #build(CamelContext)}. */ + public SemanticEvaluationBuilder() { + this(null); + } + + SemanticEvaluationBuilder(SemanticEvaluationsBuilder parent) { + this.parent = parent; + } + + public SemanticEvaluationBuilder operation(String operation) { + this.operation = operation; + return this; + } + + /** Select an instruction-driven operation by type, such as boolean, choice or score. */ + public SemanticEvaluationBuilder type(String type) { + return operation(type.toLowerCase(Locale.ROOT)); + } + + public SemanticEvaluationBuilder expert(String expert) { + this.expert = expert; + return this; + } + + String getExpert() { + return expert != null ? expert : parent != null ? parent.getExpert() : null; + } + + public SemanticEvaluationBuilder state(String state) { + this.state = state; + return this; + } + + public SemanticEvaluationBuilder parameter(String name, Object value) { + if (parameters.containsKey(name)) { + throw new IllegalArgumentException("Duplicate semantic parameter: " + name); + } + parameters.put(name, value); + return this; + } + + public SemanticEvaluationBuilder parameters(Map<String, ?> values) { + values.forEach(this::parameter); + return this; + } + + public SemanticEvaluationBuilder instructions(String instructions) { + return parameter("instructions", instructions); + } + + public SemanticEvaluationBuilder criterion(String name, String description) { + if (criteria.containsKey(name)) { + throw new IllegalArgumentException("Duplicate semantic criterion: " + name); + } + criteria.put(name, description); + return this; + } + + public SemanticEvaluationBuilder level(String level) { + levels.add(level); + return this; + } + + public SemanticEvaluationBuilder threshold(double threshold) { + return parameter("threshold", threshold); + } + + public SemanticEvaluationBuilder threshold(String threshold) { + this.threshold = threshold; + return this; + } + + public SemanticEvaluationBuilder uncertainty(double uncertainty) { + return parameter("uncertainty", uncertainty); + } + + public SemanticEvaluationBuilder uncertainty(String uncertainty) { + this.uncertainty = uncertainty; + return this; + } + + public SemanticEvaluationBuilder uncertaintyPolicy(String policy) { + parameter("uncertaintyPolicy", policy); + normalizeUncertaintyPolicy = true; + return this; + } + + public SemanticEvaluationsBuilder end() { + if (parent == null) { + throw new IllegalStateException("Complete a standalone declaration with build(context)"); + } + return parent; + } + + public void register() { + end().register(); + } + + /** Build an immutable declaration, resolving placeholders in parameter values while retaining their types. */ + public SemanticEvaluation build(CamelContext context) { + Map<String, Object> values = new LinkedHashMap<>(parameters); + if (!criteria.isEmpty() || !levels.isEmpty()) { + if (values.containsKey("criteria") || !criteria.isEmpty() && !levels.isEmpty()) { + throw new IllegalArgumentException("Duplicate parameter 'criteria'"); + } + values.put("criteria", !criteria.isEmpty() ? criteria : levels); + } + for (String numeric : List.of("threshold", "uncertainty")) { + String text = numeric.equals("threshold") ? threshold : uncertainty; + if (text != null) { + if (values.containsKey(numeric)) { + throw new IllegalArgumentException("Duplicate parameter '" + numeric + "'"); + } + try { + values.put(numeric, Double.valueOf(context.resolvePropertyPlaceholders(text))); + } catch (NumberFormatException invalid) { + throw new IllegalArgumentException("Parameter '" + numeric + "' must be a valid number"); + } + } + } + values.replaceAll((name, value) -> resolve(context, value)); + if (normalizeUncertaintyPolicy && values.get("uncertaintyPolicy") instanceof String policy) { + values.put("uncertaintyPolicy", + StringHelper.asEnumConstantValue(policy).toLowerCase(Locale.ROOT).replace('_', '-')); + } + return new SemanticEvaluation( + operation, getExpert(), state != null ? state : parent != null ? parent.getState() : null, values); + } + + private static Object resolve(CamelContext context, Object value) { + if (value instanceof String text) { + return context.resolvePropertyPlaceholders(text); + } + if (value instanceof Map<?, ?> map) { + Map<Object, Object> resolved = new LinkedHashMap<>(); Review Comment: 💡 **`resolve()` loses the `String` key type through `Map<Object, Object>`** The resolved nested map is declared `Map<Object, Object>` even though map parameter keys must be `String` (enforced downstream by `SemanticEvaluation.immutableValue`). Declaring it `Map<String, Object>` makes the constraint explicit at the builder level: ```suggestion Map<String, Object> resolved = new LinkedHashMap<>(); map.forEach((key, item) -> resolved.put((String) key, resolve(context, item))); ``` The cast is safe for YAML-originated maps (YAML keys are always strings) and the same constraint is enforced at runtime by `immutableValue`. -- This is an automated message from the Apache Git Service. To respond to the message, please log on to GitHub and use the URL above to go to the specific comment. To unsubscribe, e-mail: [email protected] For queries about this service, please contact Infrastructure at: [email protected]
