This is an automated email from the ASF dual-hosted git repository.
davsclaus pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/camel.git
The following commit(s) were added to refs/heads/main by this push:
new 3bc999e99029 CAMEL-26825: camel-jbang - AI tools: the refusal of a
constant with ${...} gives the lines to write (#29613)
3bc999e99029 is described below
commit 3bc999e99029efa34a5cd800d7aadc4cbc3008fd
Author: Claus Ibsen <[email protected]>
AuthorDate: Wed Nov 11 09:57:59 2026 +0100
CAMEL-26825: camel-jbang - AI tools: the refusal of a constant with ${...}
gives the lines to write (#29613)
A constant is used as written, so ${header.id} in it is never evaluated. The
refusal said to use simple, but gave no lines to write. The loop breaker of
CAMEL-26758 writes the third identical refused content only when every
problem
gave its lines. So in r31 of the local-model benchmark,
contracts-rest-code-first
step 1 sent the same file 13 times and failed: its jsonpath with [] had a
line
to write, its constant had none.
The message now ends with "Write line 7 as:" or "Write lines 57-58 as:" and
the
lines with simple: in place of constant:.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_016Y4dDi5iQNgtHrDdz2NpwF
---
.../dsl/jbang/core/commands/ai/ConstantLines.java | 139 +++++++++++++++++++++
.../jbang/core/commands/ai/SourceValidator.java | 3 +
.../ai/AuthoringToolsRefusalFixesTest.java | 34 +++++
.../jbang/core/commands/ai/ConstantLinesTest.java | 135 ++++++++++++++++++++
4 files changed, 311 insertions(+)
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/ConstantLines.java
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/ConstantLines.java
new file mode 100644
index 000000000000..a9ca6cac30b4
--- /dev/null
+++
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/ConstantLines.java
@@ -0,0 +1,139 @@
+/*
+ * 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.dsl.jbang.core.commands.ai;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * The lines to write for a constant that holds a simple function, such as
{@code constant: "unknown order
+ * ${header.id}"}: a constant is used as written, and the validator says to
use simple instead. The lines with simple:
+ * in place of constant: end the message, so a model can copy them, and the
write tool makes the fix when the same
+ * content comes a third time ({@link RefusalFixes}, CAMEL-26758). In r31 of
the local-model benchmark,
+ * contracts-rest-code-first step 1 sent the same file 13 times: the refusal
of its jsonpath with [] gave the line to
+ * write, the refusal of its constant with ${header.id} gave none, so the
write tool could not fix the file
+ * (CAMEL-26825).
+ */
+final class ConstantLines {
+
+ /**
+ * The validator's message for it: "Line 58: constant: ${header.id} is not
evaluated, a constant is used as ...".
+ */
+ private static final Pattern CONSTANT_WITH_SIMPLE = Pattern.compile(
+ "^Line (\\d+): (?:\\S*/)?constant: \\$\\{.* is not evaluated, a
constant is used as written");
+ /** The constant key, with its value or without: constant: "x", constant:,
- constant: "x". */
+ private static final Pattern CONSTANT_KEY = Pattern.compile("^(-
+)?constant:(\\s|$)");
+
+ /** How many lines are shown at most; for a longer value only the line of
the key is given. */
+ private static final int MAX_LINES = 12;
+
+ private ConstantLines() {
+ }
+
+ /** The messages, with the lines to write after each message of a constant
that holds a simple function. */
+ static List<String> withLines(List<String> messages, String content) {
+ if (messages.isEmpty() || content == null) {
+ return messages;
+ }
+ String[] lines = content.split("\n", -1);
+ List<String> answer = new ArrayList<>(messages.size());
+ for (String message : messages) {
+ Matcher m = CONSTANT_WITH_SIMPLE.matcher(message);
+ String written = m.find() ? linesToWrite(lines,
Integer.parseInt(m.group(1)) - 1) : null;
+ answer.add(written != null ? message + "." + written : message);
+ }
+ return answer;
+ }
+
+ /**
+ * " Write lines 57-58 as:" and the lines of the constant with simple: in
place of constant:; null when the line is
+ * not of a constant.
+ */
+ static String linesToWrite(String[] lines, int index) {
+ int key = keyLine(lines, index);
+ if (key < 0) {
+ return null;
+ }
+ String line = lines[key];
+ int at = indent(line);
+ Matcher k = CONSTANT_KEY.matcher(line.substring(at));
+ if (!k.find()) {
+ return null;
+ }
+ // the column of the key: past the dash of a list item
+ int column = at + (k.group(1) != null ? k.group(1).length() : 0);
+ int end = blockEnd(lines, key, column);
+ if (end - key > MAX_LINES) {
+ end = key + 1;
+ }
+ StringBuilder sb
+ = new StringBuilder(end == key + 1 ? " Write line " + (key +
1) : " Write lines " + (key + 1) + "-" + end)
+ .append(" as:\n")
+ .append(line, 0,
column).append("simple").append(line.substring(column + "constant".length()));
+ for (int i = key + 1; i < end; i++) {
+ sb.append('\n').append(lines[i]);
+ }
+ return sb.toString();
+ }
+
+ /**
+ * The line of the constant key: the line of the message, or for a
constant whose value is on the lines below it
+ * (constant: and then expression: ...), the key above that the value
belongs to; -1 when there is none.
+ */
+ private static int keyLine(String[] lines, int index) {
+ if (index < 0 || index >= lines.length || lines[index].isBlank()) {
+ return -1;
+ }
+ if (CONSTANT_KEY.matcher(lines[index].stripLeading()).find()) {
+ return index;
+ }
+ int column = indent(lines[index]);
+ for (int i = index - 1; i >= 0; i--) {
+ String l = lines[i];
+ if (!l.isBlank() && indent(l) < column) {
+ String key = l.strip();
+ return CONSTANT_KEY.matcher(key).find() &&
key.endsWith("constant:") ? i : -1;
+ }
+ }
+ return -1;
+ }
+
+ /**
+ * The end (exclusive) of the value of the key at the column: the lines
below it that are more indented, without the
+ * blank lines at its end.
+ */
+ private static int blockEnd(String[] lines, int start, int column) {
+ int end = start + 1;
+ while (end < lines.length && (lines[end].isBlank() ||
indent(lines[end]) > column)) {
+ end++;
+ }
+ while (end > start + 1 && lines[end - 1].isBlank()) {
+ end--;
+ }
+ return end;
+ }
+
+ private static int indent(String line) {
+ int i = 0;
+ while (i < line.length() && line.charAt(i) == ' ') {
+ i++;
+ }
+ return i;
+ }
+}
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/SourceValidator.java
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/SourceValidator.java
index f988ea115e90..77815e948712 100644
---
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/SourceValidator.java
+++
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/SourceValidator.java
@@ -192,6 +192,9 @@ public final class SourceValidator {
// an option at the column of its EIP: the lines to write, which a
model copies (the words it does not,
// CAMEL-25556)
msgs = IndentLines.withLines(msgs, content);
+ // a constant with ${...}: the lines with simple: in its place,
which the write tool can also apply when the
+ // same content comes a third time (CAMEL-26825)
+ msgs = ConstantLines.withLines(msgs, content);
return KameletChecks.withTemplateHints(name, content, msgs);
}
if (name.endsWith(".properties")) {
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/AuthoringToolsRefusalFixesTest.java
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/AuthoringToolsRefusalFixesTest.java
index fb4370cb61fe..307580baada0 100644
---
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/AuthoringToolsRefusalFixesTest.java
+++
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/AuthoringToolsRefusalFixesTest.java
@@ -136,4 +136,38 @@ class AuthoringToolsRefusalFixesTest {
assertThat(third.getString("status")).as(String.valueOf(third)).isEqualTo("created");
assertThat(Files.readString(dir.resolve(FILE))).isEqualTo(content.replace("==
'paid'\n", "== 'paid'\"\n"));
}
+
+ @Test
+ void aConstantWithSimpleIsWrittenAsTheRefusalsSaid(@TempDir Path dir)
throws IOException {
+ // CAMEL-26825, r31n-7 contracts-rest-code-first step 1 (shortened):
sent 13 times with two problems, the []
+ // whose refusal gave the line to write, and a constant with
${header.id} whose refusal gave none
+ String content = BRACKETS.replace(" - log: \"${body}\"\n", """
+ - choice:
+ when:
+ - simple: "${body.size()} == 0"
+ steps:
+ - setBody:
+ expression:
+ constant:
+ expression: '{"error": "unknown
order ${header.id}"}'
+ - log: "${body}"
+ """);
+ String[] lines = content.split("\n");
+ for (int i = 1; i <= 2; i++) {
+ JsonObject refused = write(dir, content);
+
assertThat(refused.getString("status")).as(String.valueOf(refused)).isEqualTo("invalid");
+ assertThat((JsonArray)
refused.get("errors")).as(String.valueOf(refused)).hasSize(2)
+ .anySatisfy(e -> assertThat(String.valueOf(e))
+ .contains("constant: ${header.id} is not
evaluated")
+ .endsWith(". Write lines 17-18 as:\n" +
lines[16].replace("constant:", "simple:") + "\n"
+ + lines[17]));
+ }
+ JsonObject third = write(dir, content);
+
assertThat(third.getString("status")).as(String.valueOf(third)).isEqualTo("created");
+ assertThat((JsonArray) third.get("repaired")).containsExactly(
+ "line 9: written as the refusals said: expression:
\"$[?(@.orderId == '${header.id}')]\"",
+ "lines 17-18: written as the refusals said");
+ assertThat(Files.readString(dir.resolve(FILE)))
+ .isEqualTo(content.replace("')][]\"",
"')]\"").replace("constant:\n", "simple:\n"));
+ }
}
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/ConstantLinesTest.java
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/ConstantLinesTest.java
new file mode 100644
index 000000000000..dac03f2eff6f
--- /dev/null
+++
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/ConstantLinesTest.java
@@ -0,0 +1,135 @@
+/*
+ * 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.dsl.jbang.core.commands.ai;
+
+import java.util.List;
+
+import org.apache.camel.catalog.CamelCatalog;
+import org.apache.camel.catalog.DefaultCamelCatalog;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * CAMEL-26825: the refusal of a constant with ${...} ends with the lines to
write, simple: in place of constant:.
+ */
+class ConstantLinesTest {
+
+ private static CamelCatalog catalog;
+
+ @BeforeAll
+ static void loadCatalog() {
+ catalog = new DefaultCamelCatalog();
+ }
+
+ private static List<String> validate(String yaml) {
+ return SourceValidator.validate("orders.camel.yaml", yaml, catalog,
null);
+ }
+
+ /** The line of the YAML, 1-based, as written. */
+ private static String line(String yaml, int number) {
+ return yaml.split("\n")[number - 1];
+ }
+
+ @Test
+ void aConstantOnItsLineGetsTheLineToWrite() {
+ String yaml = """
+ - route:
+ from:
+ uri: direct:getStock
+ steps:
+ - setBody:
+ expression:
+ constant: '{"error": "unknown sku
${header.sku}"}'
+ """;
+ assertThat(validate(yaml)).singleElement().asString()
+ .startsWith("Line 7: ")
+ .contains("constant: ${header.sku} is not evaluated, a
constant is used as written")
+ .endsWith(". Write line 7 as:\n" + line(yaml,
7).replace("constant:", "simple:"));
+ }
+
+ @Test
+ void aConstantWithItsValueBelowGetsAllItsLines() {
+ // r31n-7 contracts-rest-code-first step 1 (shortened): sent 13 times
as it is
+ String yaml = """
+ - route:
+ from:
+ uri: direct:get-order-by-id
+ steps:
+ - setBody:
+ expression:
+ constant:
+ expression: '{"error": "unknown order
${header.id}"}'
+ - setHeader:
+ name: CamelHttpResponseCode
+ constant: "404"
+ """;
+ assertThat(validate(yaml)).singleElement().asString()
+ .contains("constant: ${header.id} is not evaluated")
+ .endsWith(". Write lines 7-8 as:\n" + line(yaml,
7).replace("constant:", "simple:") + "\n" + line(yaml, 8));
+ }
+
+ @Test
+ void theShortFormOfAHeaderGetsTheLineToWrite() {
+ String yaml = """
+ - route:
+ from:
+ uri: direct:start
+ steps:
+ - setHeader:
+ name: greeting
+ constant: "Hello ${body}"
+ """;
+ assertThat(validate(yaml)).singleElement().asString()
+ .endsWith(". Write line 7 as:\n" + line(yaml,
7).replace("constant:", "simple:"));
+ }
+
+ @Test
+ void aListItemKeepsItsDash() {
+ String[] lines = """
+ when:
+ - constant: "${header.ok}"
+ steps:
+ - to: log:ok
+ """.split("\n");
+ assertThat(ConstantLines.linesToWrite(lines, 1)).isEqualTo(" Write
line 2 as:\n - simple: \"${header.ok}\"");
+ }
+
+ @Test
+ void aLongValueGetsTheLineOfTheKeyOnly() {
+ StringBuilder sb = new StringBuilder("constant: |\n");
+ for (int i = 1; i <= 12; i++) {
+ sb.append(" line ").append(i).append(" of order ${header.id}\n");
+ }
+ String[] lines = sb.toString().split("\n");
+ assertThat(ConstantLines.linesToWrite(lines, 0)).isEqualTo(" Write
line 1 as:\nsimple: |");
+ }
+
+ @Test
+ void otherLinesAndMessagesAreLeftAsTheyAre() {
+ String[] lines = """
+ - setBody:
+ expression:
+ simple:
+ expression: "${header.id}"
+ """.split("\n");
+ assertThat(ConstantLines.linesToWrite(lines, 3)).isNull();
+ assertThat(ConstantLines.withLines(List.of("Line 2: something else"),
String.join("\n", lines)))
+ .containsExactly("Line 2: something else");
+ }
+}