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 8743ef4a41be CAMEL-24805: camel-jbang - the TUI AI panel checks the
simple expressions of an answer before showing it, and gives the model one turn
to fix them
8743ef4a41be is described below
commit 8743ef4a41becd628cbcc0370b1931a8ce313bfa
Author: Claus Ibsen <[email protected]>
AuthorDate: Thu Sep 17 22:47:54 2026 +0200
CAMEL-24805: camel-jbang - the TUI AI panel checks the simple expressions
of an answer before showing it, and gives the model one turn to fix them
What the model writes goes through the validator before camel_write_file
writes it; what it says in the answer did not, and the answer is what the
user copies. AnswerChecks (camel-jbang-core) extracts the ${...}
placeholders
of the text and non-YAML code blocks that start with a simple function from
the catalog and validates them; YAML blocks go through SimpleChecks. The TUI
AI panel sends an invalid expression back to the model once, shows a
corrected answer with "1 correction" in the byline, and an unfixed one with
a "Simple check" note. Documented on the TUI page.
Closes #26567
Co-authored-by: Claude Opus 5 (1M context) <[email protected]>
---
.../modules/ROOT/pages/camel-jbang-tui.adoc | 11 +
.../dsl/jbang/core/commands/ai/AnswerChecks.java | 229 +++++++++++++++++++++
.../jbang/core/commands/ai/AnswerChecksTest.java | 126 ++++++++++++
.../camel/dsl/jbang/core/commands/tui/AiPanel.java | 79 ++++++-
.../dsl/jbang/core/commands/tui/AiPanelTest.java | 101 +++++++++
5 files changed, 537 insertions(+), 9 deletions(-)
diff --git a/docs/user-manual/modules/ROOT/pages/camel-jbang-tui.adoc
b/docs/user-manual/modules/ROOT/pages/camel-jbang-tui.adoc
index 3cc8ee23226b..5ae3ee5b0a8e 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-jbang-tui.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-jbang-tui.adoc
@@ -1133,6 +1133,17 @@ yellow from 10 requests, and the total time yellow from
30 seconds and orange fr
that runs into the limit is usually a tool that makes the model guess, not a
weak model: the AI log
shows which one.
+What the model says is checked like what it writes. A file goes through the
validator before
+`camel_write_file` writes it; the answer shown in the chat is what you copy,
so its simple expressions
+are checked against the catalog too: the `${...}` placeholders in the text and
in Java or XML code
+blocks, and the `simple:` values and log messages of YAML blocks. A small
model tends to put the
+operator inside the placeholder (`${header.user ?: 'Guest'}` instead of
`${header.user} ?: 'Guest'`).
+When the check finds such an expression the panel sends the answer back to the
model once, with what is
+wrong and the rule, and shows the corrected answer with `1 correction` in the
byline. When the model
+does not fix it the answer is shown as it is with a *Simple check* line under
it naming the expression
+and the error, and the AI log has the exchange. Placeholders that are not
simple (a Maven
+`${camel-version}`, a shell variable) are left alone.
+
Press *Ctrl+U* while the AI panel is open to toggle the AI Usage view. It
shows token consumption from
the embedded AI prompt (*TUI ask*) and from LLM calls made by the monitored
integration (*integration*),
the latter taken from OpenTelemetry GenAI spans when observability is enabled
on the integration. The
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/AnswerChecks.java
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/AnswerChecks.java
new file mode 100644
index 000000000000..89b9b6710996
--- /dev/null
+++
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/AnswerChecks.java
@@ -0,0 +1,229 @@
+/*
+ * 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.HashSet;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Set;
+
+import org.apache.camel.catalog.CamelCatalog;
+import org.apache.camel.catalog.DefaultCamelCatalog;
+import org.apache.camel.catalog.LanguageValidationResult;
+import org.apache.camel.tooling.model.LanguageModel;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * The checks of what an AI model says, as opposed to what it writes: a file
goes through the validator before it is
+ * written, an answer shown in a chat does not, and it is what the user
copies. The simple expressions of an answer are
+ * checked against the catalog, the way {@code camel_validate_source} checks a
route: the {@code ${...}} placeholders of
+ * the text and of any code block that is not YAML, and the {@code simple:}
values and log messages of the YAML blocks.
+ * <p>
+ * A placeholder is only checked when it starts with a simple function or
value (body, header, exchangeProperty, random,
+ * date, ...), so a Maven {@code ${camel-version}} or a shell variable in the
same answer is left alone. What the
+ * catalog cannot judge is skipped as in {@link SimpleChecks}: a function of a
language that is not on the classpath, a
+ * property placeholder used as a logical operand.
+ */
+public final class AnswerChecks {
+
+ /** A simple expression of an answer that the catalog rejects: what was
written, and why. */
+ public record Problem(String expression, String error) {
+
+ /** {@code ${header.user ?: 'Guest'}: Unexpected token ?: at location
13}. */
+ public String message() {
+ return expression + ": " + error;
+ }
+ }
+
+ private static final Logger LOG =
LoggerFactory.getLogger(AnswerChecks.class);
+
+ private static volatile CamelCatalog defaultCatalog;
+
+ private AnswerChecks() {
+ }
+
+ /** The problems of the simple expressions of an answer, checked against
the default catalog. */
+ public static List<Problem> checkSimple(String markdown) {
+ return checkSimple(markdown, defaultCatalog());
+ }
+
+ /** The problems of the simple expressions of an answer, checked against
the given catalog. */
+ public static List<Problem> checkSimple(String markdown, CamelCatalog
catalog) {
+ if (markdown == null || markdown.isBlank() || catalog == null) {
+ return List.of();
+ }
+ Map<String, Problem> problems = new LinkedHashMap<>();
+ StringBuilder text = new StringBuilder();
+ StringBuilder block = null;
+ boolean yaml = false;
+ for (String line : markdown.split("\n", -1)) {
+ String trimmed = line.trim();
+ if (trimmed.startsWith("```")) {
+ if (block == null) {
+ String language =
trimmed.substring(3).trim().toLowerCase(Locale.ROOT);
+ yaml = language.equals("yaml") || language.equals("yml");
+ block = new StringBuilder();
+ } else {
+ if (yaml) {
+ for (String error :
SimpleChecks.validateYamlSimple(block.toString(), catalog)) {
+ problems.putIfAbsent(error, new Problem("the YAML
block", error));
+ }
+ } else {
+ text.append(block).append('\n');
+ }
+ block = null;
+ }
+ continue;
+ }
+ if (block != null) {
+ block.append(line).append('\n');
+ } else {
+ text.append(line).append('\n');
+ }
+ }
+ if (block != null) {
+ // an unterminated fence: the model ran out of tokens, judge what
is there as text
+ text.append(block);
+ }
+ Set<String> roots = roots(catalog);
+ for (String placeholder : placeholders(text.toString())) {
+ if (problems.containsKey(placeholder) || !isSimple(placeholder,
roots)
+ ||
SimpleChecks.hasPlaceholderAsLogicalOperand(placeholder)) {
+ continue;
+ }
+ try {
+ LanguageValidationResult result =
catalog.validateLanguageExpression(null, "simple", placeholder);
+ if (!result.isSuccess()) {
+ String error = result.getShortError() != null ?
result.getShortError() : result.getError();
+ if (error != null &&
!SimpleChecks.isMissingDependency(error)) {
+ problems.put(placeholder, new Problem(placeholder,
error));
+ }
+ }
+ } catch (Exception e) {
+ // best effort: what the catalog cannot judge is not reported,
the cause is in the debug log
+ LOG.debug("Cannot validate the simple expression {} of the
answer", placeholder, e);
+ }
+ }
+ return new ArrayList<>(problems.values());
+ }
+
+ /**
+ * The {@code ${...}} placeholders of a text, braces balanced so {@code
${header.${header.key}}} is one placeholder,
+ * in the order they appear.
+ */
+ static List<String> placeholders(String text) {
+ List<String> answer = new ArrayList<>();
+ int i = 0;
+ while (i < text.length() - 1) {
+ if (text.charAt(i) == '$' && text.charAt(i + 1) == '{') {
+ int depth = 0;
+ int end = -1;
+ for (int j = i + 1; j < text.length(); j++) {
+ char ch = text.charAt(j);
+ if (ch == '{') {
+ depth++;
+ } else if (ch == '}') {
+ depth--;
+ if (depth == 0) {
+ end = j;
+ break;
+ }
+ } else if (ch == '\n' && depth == 1) {
+ // a placeholder does not span lines; an unclosed one
is prose
+ break;
+ }
+ }
+ if (end < 0) {
+ i += 2;
+ continue;
+ }
+ answer.add(text.substring(i, end + 1));
+ i = end + 1;
+ } else {
+ i++;
+ }
+ }
+ return answer;
+ }
+
+ /**
+ * Whether the placeholder starts with a simple function or value, one of
the {@link #roots(CamelCatalog) roots}, so
+ * the catalog is the judge of it.
+ */
+ static boolean isSimple(String placeholder, Set<String> roots) {
+ String content = placeholder.substring(2, placeholder.length() -
1).strip();
+ int end = 0;
+ while (end < content.length()) {
+ char ch = content.charAt(end);
+ if (!Character.isLetterOrDigit(ch) && ch != '-' && ch != '_') {
+ break;
+ }
+ end++;
+ }
+ if (end == 0) {
+ return false;
+ }
+ return roots.contains(content.substring(0, end));
+ }
+
+ /**
+ * The first word of every simple function name: header for header.name,
date for date:command:pattern. Computed
+ * once per answer from the catalog given, so a catalog of another Camel
version answers for its own functions.
+ */
+ static Set<String> roots(CamelCatalog catalog) {
+ // the language model behind it is cached by the catalog, so this is a
walk over a list of names
+ Set<String> answer = new HashSet<>();
+ LanguageModel simple = catalog.languageModel("simple");
+ if (simple != null && simple.getFunctions() != null) {
+ for (LanguageModel.LanguageFunctionModel fn :
simple.getFunctions()) {
+ String name = fn.getName();
+ if (name != null) {
+ int end = 0;
+ while (end < name.length()) {
+ char ch = name.charAt(end);
+ if (!Character.isLetterOrDigit(ch) && ch != '-' && ch
!= '_') {
+ break;
+ }
+ end++;
+ }
+ if (end > 0) {
+ answer.add(name.substring(0, end));
+ }
+ }
+ }
+ }
+ return answer;
+ }
+
+ private static CamelCatalog defaultCatalog() {
+ CamelCatalog answer = defaultCatalog;
+ if (answer == null) {
+ synchronized (AnswerChecks.class) {
+ answer = defaultCatalog;
+ if (answer == null) {
+ answer = new DefaultCamelCatalog();
+ defaultCatalog = answer;
+ }
+ }
+ }
+ return answer;
+ }
+}
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/AnswerChecksTest.java
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/AnswerChecksTest.java
new file mode 100644
index 000000000000..0b4093a0726f
--- /dev/null
+++
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/AnswerChecksTest.java
@@ -0,0 +1,126 @@
+/*
+ * 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 java.util.Set;
+
+import org.apache.camel.catalog.CamelCatalog;
+import org.apache.camel.catalog.DefaultCamelCatalog;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * CAMEL-24805: the simple expressions an AI model writes in an answer are
checked the way a file is, since the answer
+ * is what the user copies and where a small model puts the operator inside
the placeholder.
+ */
+class AnswerChecksTest {
+
+ private static final CamelCatalog CATALOG = new DefaultCamelCatalog();
+
+ @Test
+ void theOperatorInsideThePlaceholderIsFoundInProseAndInlineCode() {
+ String answer = "Use `${header.user ?: 'Guest'}` to fall back to a
guest name, and log it with "
+ + "`${header.user} ?: 'Guest'` between placeholders is
what Camel expects.";
+ List<AnswerChecks.Problem> problems = AnswerChecks.checkSimple(answer,
CATALOG);
+ assertThat(problems).hasSize(1);
+ assertThat(problems.get(0).expression()).isEqualTo("${header.user ?:
'Guest'}");
+ assertThat(problems.get(0).error()).isNotBlank();
+ assertThat(problems.get(0).message()).startsWith("${header.user ?:
'Guest'}: ");
+ // the same mistake twice is one problem
+ assertThat(AnswerChecks.checkSimple(answer + "\nAgain: ${header.user
?: 'Guest'}", CATALOG)).hasSize(1);
+ }
+
+ @Test
+ void correctSimpleAndForeignPlaceholdersPass() {
+ String answer = """
+ The body is ${body}, the header ${header.user}, a random
number ${random(1,10)} and today
+ ${date:now:yyyy-MM-dd}; nested works too:
${header.${header.key}}.
+ The Maven property ${camel-version} and the shell variable
${HOME} are not simple.
+ A function of another language, ${jsonpath($.name)}, needs its
dependency at runtime.
+ A property placeholder used as a predicate, {{enabled}} &&
${body} != null, is judged at runtime.
+ """;
+ assertThat(AnswerChecks.checkSimple(answer, CATALOG)).isEmpty();
+ assertThat(AnswerChecks.checkSimple(null, CATALOG)).isEmpty();
+ assertThat(AnswerChecks.checkSimple("no expressions here",
CATALOG)).isEmpty();
+ }
+
+ @Test
+ void yamlBlocksAreCheckedAsRoutesAndOtherBlocksAsText() {
+ String answer = """
+ Here is the route:
+
+ ```yaml
+ - route:
+ from:
+ uri: timer:tick
+ steps:
+ - setBody:
+ expression:
+ simple:
+ expression: "${header.user ?: 'Guest'}"
+ - log:
+ message: "Hello ${body}"
+ ```
+
+ And in Java:
+
+ ```java
+ from("timer:tick").setBody(simple("${header.user ?:
'Guest'}"));
+ ```
+ """;
+ List<AnswerChecks.Problem> problems = AnswerChecks.checkSimple(answer,
CATALOG);
+ assertThat(problems).hasSize(2);
+ assertThat(problems.get(0).expression()).isEqualTo("the YAML block");
+ assertThat(problems.get(0).error()).startsWith("Line 8: Simple syntax
error: ");
+ assertThat(problems.get(1).expression()).isEqualTo("${header.user ?:
'Guest'}");
+
+ String valid = """
+ ```yaml
+ - route:
+ from:
+ uri: timer:tick
+ steps:
+ - setBody:
+ expression:
+ simple:
+ expression: "${header.user} ?: 'Guest'"
+ ```
+ """;
+ assertThat(AnswerChecks.checkSimple(valid, CATALOG)).isEmpty();
+ }
+
+ @Test
+ void placeholdersAreBalancedAndStayOnOneLine() {
+ assertThat(AnswerChecks.placeholders("a ${body} b
${header.${header.key}} c ${x"))
+ .containsExactly("${body}", "${header.${header.key}}");
+ assertThat(AnswerChecks.placeholders("${header.a\n}")).isEmpty();
+ Set<String> roots = AnswerChecks.roots(CATALOG);
+ assertThat(roots).contains("body", "header", "date", "random");
+ assertThat(AnswerChecks.isSimple("${header.user}", roots)).isTrue();
+ assertThat(AnswerChecks.isSimple("${date-with-timezone:now:UTC:yyyy}",
roots)).isTrue();
+ assertThat(AnswerChecks.isSimple("${camel-version}", roots)).isFalse();
+ assertThat(AnswerChecks.isSimple("${}", roots)).isFalse();
+ }
+
+ @Test
+ void theDefaultCatalogIsUsedWhenNoneIsGiven() {
+ assertThat(AnswerChecks.checkSimple("${header.user ?:
'Guest'}")).hasSize(1);
+ assertThat(AnswerChecks.checkSimple("${header.user} ?:
'Guest'")).isEmpty();
+ }
+}
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanel.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanel.java
index 8a07a06a90c6..e105802b3fe9 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanel.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanel.java
@@ -74,6 +74,7 @@ import dev.tamboui.widgets.table.Row;
import dev.tamboui.widgets.table.Table;
import dev.tamboui.widgets.table.TableState;
import org.apache.camel.dsl.jbang.core.commands.LlmClient;
+import org.apache.camel.dsl.jbang.core.commands.ai.AnswerChecks;
import org.apache.camel.dsl.jbang.core.common.ExampleHelper;
import org.apache.camel.dsl.jbang.core.common.Printer;
import org.apache.camel.util.json.JsonObject;
@@ -334,14 +335,20 @@ class AiPanel {
* {@code toolCalls} TUI tool calls the model made.
*/
record ConversationEntry(AiRole role, String text, long elapsedMs, long
aiMs, long toolMs, int toolCalls,
- int totalTokens, int requests, boolean limitReached, int
contextPercent) {
+ int totalTokens, int requests, boolean limitReached, int
contextPercent, int corrections, String note) {
ConversationEntry(AiRole role, String text) {
- this(role, text, -1, 0, 0, 0, 0, 0, false, 0);
+ this(role, text, -1, 0, 0, 0, 0, 0, false, 0, 0, null);
}
ConversationEntry(AiRole role, String text, long elapsedMs, long aiMs,
long toolMs, int toolCalls,
int totalTokens) {
- this(role, text, elapsedMs, aiMs, toolMs, toolCalls, totalTokens,
0, false, 0);
+ this(role, text, elapsedMs, aiMs, toolMs, toolCalls, totalTokens,
0, false, 0, 0, null);
+ }
+
+ ConversationEntry(AiRole role, String text, long elapsedMs, long aiMs,
long toolMs, int toolCalls,
+ int totalTokens, int requests, boolean limitReached,
int contextPercent) {
+ this(role, text, elapsedMs, aiMs, toolMs, toolCalls, totalTokens,
requests, limitReached, contextPercent, 0,
+ null);
}
/** "5.2s" or, when tools were called, "5.2s, ai 4.1s, tools 1.1s/3".
*/
@@ -374,6 +381,9 @@ class AiPanel {
if (requests > 1) {
sb.append(" · ").append(requests).append(" requests");
}
+ if (corrections > 0) {
+ sb.append(" · ").append(corrections).append(corrections == 1 ?
" correction" : " corrections");
+ }
if (totalTokens > 0) {
sb.append(" ·
").append(LlmClient.formatTokens(totalTokens)).append(" tokens");
}
@@ -1495,6 +1505,7 @@ class AiPanel {
long turnToolMs = 0;
int turnToolCalls = 0;
int turnRequests = 0;
+ int turnCorrections = 0;
for (int i = 0; i < MAX_ITERATIONS; i++) {
if (Thread.interrupted()) {
throw new InterruptedException();
@@ -1565,8 +1576,19 @@ class AiPanel {
}
messages.add(LlmClient.Message.toolResults(results));
} else {
+ // what the model says is checked like what it writes: an
invalid simple expression in the answer
+ // gets one correction turn, since the answer is what the user
copies (CAMEL-24805)
+ List<AnswerChecks.Problem> problems =
AnswerChecks.checkSimple(response.text());
+ if (!problems.isEmpty() && turnCorrections == 0 && i <
MAX_ITERATIONS - 1) {
+ turnCorrections++;
+ String request = correctionRequest(problems);
+
messages.add(LlmClient.Message.assistantWithToolCalls(response.text(),
List.of()));
+ messages.add(LlmClient.Message.user(request));
+ log(LogLevel.RESULT, "Answer check: invalid simple
expression, asking for a fix", request);
+ continue;
+ }
completeTurn(response.text(), false, totalUsage, turnAiMs,
turnToolMs, turnToolCalls, turnRequests,
- messages);
+ turnCorrections, problems, messages);
return;
}
}
@@ -1603,8 +1625,9 @@ class AiPanel {
}
}
if (wrapUp != null && wrapUp.text() != null &&
!wrapUp.text().isBlank()) {
+ // no round trip left for a correction: the answer is shown with
what the check found
completeTurn(wrapUp.text(), true, totalUsage, turnAiMs,
turnToolMs, turnToolCalls, turnRequests,
- messages);
+ turnCorrections, AnswerChecks.checkSimple(wrapUp.text()),
messages);
return;
}
@@ -1632,17 +1655,20 @@ class AiPanel {
*/
private void completeTurn(
String text, boolean wrapUp, LlmClient.TokenUsage totalUsage, long
turnAiMs, long turnToolMs,
- int turnToolCalls, int turnRequests, List<LlmClient.Message>
messages) {
+ int turnToolCalls, int turnRequests, int corrections,
List<AnswerChecks.Problem> problems,
+ List<LlmClient.Message> messages) {
sessionTotalTokens += totalUsage.totalTokens();
if (text != null && !text.isBlank()) {
long elapsed = System.currentTimeMillis() - thinkingStartTime;
+ String note = problems.isEmpty() ? null : answerNote(problems);
ConversationEntry entry = new ConversationEntry(
AiRole.ASSISTANT, text, elapsed, turnAiMs, turnToolMs,
turnToolCalls,
- totalUsage.totalTokens(), turnRequests, wrapUp,
contextFillPercent());
+ totalUsage.totalTokens(), turnRequests, wrapUp,
contextFillPercent(), corrections, note);
conversation.add(entry);
turnTimings.add(new long[] { turnAiMs, turnToolMs, turnToolCalls
});
String label = wrapUp ? "Response after reaching the tool call
limit (" : "Response (";
- log(LogLevel.RESPONSE, label + entry.byline() +
describeCacheSignal(totalUsage) + ")", text);
+ log(LogLevel.RESPONSE, label + entry.byline() +
describeCacheSignal(totalUsage) + ")",
+ note == null ? text : text + "\n\n" + note);
} else {
String err = "Empty response from LLM.";
conversation.add(new ConversationEntry(AiRole.ERROR, err));
@@ -1805,6 +1831,36 @@ class AiPanel {
return millis < 1000 ? millis + "ms" : formatSeconds(millis);
}
+ /**
+ * The one correction turn a model gets when its answer has an invalid
simple expression: what is wrong, the rule it
+ * broke, and to send the whole answer again. Worded so the fake clients
of the tests and the log recognise it.
+ */
+ static String correctionRequest(List<AnswerChecks.Problem> problems) {
+ StringBuilder sb = new StringBuilder("Your answer contains ");
+ sb.append(problems.size() == 1 ? "an invalid simple expression" :
"invalid simple expressions").append(":\n");
+ for (AnswerChecks.Problem p : problems) {
+ sb.append("- ").append(p.message()).append('\n');
+ }
+ sb.append("Rule: functions and values go inside ${...}, operators go
between placeholders with spaces:")
+ .append(" ${header.user} ?: 'Guest', ${header.a} == 'b',
${body} != null.")
+ .append(" Send the whole answer again with the expressions
fixed; do not call a tool.");
+ return sb.toString();
+ }
+
+ /** The line under an answer whose simple expressions the model did not
fix, so the user is not misled. */
+ static String answerNote(List<AnswerChecks.Problem> problems) {
+ StringBuilder sb = new StringBuilder("**Simple check:** ");
+ if (problems.size() == 1) {
+ sb.append("`").append(problems.get(0).expression()).append("` is
invalid: ").append(problems.get(0).error());
+ } else {
+ sb.append(problems.size()).append(" expressions are invalid:");
+ for (AnswerChecks.Problem p : problems) {
+ sb.append("\n- `").append(p.expression()).append("`:
").append(p.error());
+ }
+ }
+ return sb.toString();
+ }
+
private static String summarize(String text, int max) {
if (text == null) {
return "";
@@ -2391,7 +2447,12 @@ class AiPanel {
for (ConversationEntry entry : conversation) {
switch (entry.role()) {
case USER -> md.append("> ").append(entry.text().replace("\n",
"\n> ")).append("\n\n");
- case ASSISTANT ->
md.append(toHardBreaks(entry.text())).append("\n\n");
+ case ASSISTANT -> {
+ md.append(toHardBreaks(entry.text())).append("\n\n");
+ if (entry.note() != null) {
+ md.append(entry.note()).append("\n\n");
+ }
+ }
case ERROR -> md.append("**Error:**
").append(entry.text()).append("\n\n");
case SYSTEM ->
md.append(toHardBreaks(entry.text())).append("\n\n");
}
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanelTest.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanelTest.java
index b5764201ec26..4074e7a8b27d 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanelTest.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanelTest.java
@@ -1080,6 +1080,107 @@ class AiPanelTest {
}
}
+ // ---- the simple expressions of an answer are checked (CAMEL-24805) ----
+
+ @Test
+ void anInvalidSimpleExpressionInTheAnswerGetsOneCorrectionTurn() throws
Exception {
+ AiPanel panel = new AiPanel();
+ panel.setToolRegistryForTesting(new TuiToolRegistry(null));
+ CorrectingLlmClient client = new CorrectingLlmClient();
+ panel.setClientForTesting(client);
+ panel.open();
+ type(panel, "how do I fall back to a guest name");
+ panel.handleKeyEvent(KeyEvent.ofKey(KeyCode.ENTER, KeyModifiers.NONE));
+ await().atMost(10, TimeUnit.SECONDS).until(() ->
!panel.isAgentThreadRunningForTesting());
+
+ AiPanel.ConversationEntry last =
panel.conversationForTesting().get(panel.conversationForTesting().size() - 1);
+ assertEquals(AiRole.ASSISTANT, last.role(), last.text());
+ assertTrue(client.sawCorrectionRequest, "the model must be told what
is invalid and asked to fix the answer");
+ assertEquals(2, client.answers, "the first answer and the corrected
one");
+ assertTrue(last.text().contains("${header.user} ?: 'Guest'"),
last.text());
+ assertFalse(last.text().contains("${header.user ?: 'Guest'}"),
last.text());
+ assertNull(last.note(), "a corrected answer is shown as it is");
+ assertEquals(1, last.corrections());
+ assertEquals(2, last.requests());
+ assertTrue(last.byline().contains("2 requests · 1 correction"),
last.byline());
+ assertTrue(panel.conversationForTesting().stream().noneMatch(e ->
e.role() == AiRole.ERROR));
+ }
+
+ @Test
+ void anAnswerThatStaysWrongIsShownWithWhatTheCheckFound() throws Exception
{
+ AiPanel panel = new AiPanel();
+ panel.setToolRegistryForTesting(new TuiToolRegistry(null));
+ CorrectingLlmClient client = new CorrectingLlmClient();
+ client.fixes = false;
+ panel.setClientForTesting(client);
+ panel.open();
+ type(panel, "how do I fall back to a guest name");
+ panel.handleKeyEvent(KeyEvent.ofKey(KeyCode.ENTER, KeyModifiers.NONE));
+ await().atMost(10, TimeUnit.SECONDS).until(() ->
!panel.isAgentThreadRunningForTesting());
+
+ AiPanel.ConversationEntry last =
panel.conversationForTesting().get(panel.conversationForTesting().size() - 1);
+ assertEquals(AiRole.ASSISTANT, last.role(), last.text());
+ assertEquals(2, client.answers, "one correction turn, never a second");
+ assertTrue(last.text().contains("${header.user ?: 'Guest'}"),
last.text());
+ assertNotNull(last.note(), "the user must see that the expression is
invalid");
+ assertTrue(last.note().startsWith("**Simple check:** `${header.user ?:
'Guest'}` is invalid: "), last.note());
+ assertEquals(1, last.corrections());
+ }
+
+ @Test
+ void aCorrectAnswerIsNotSentBack() throws Exception {
+ AiPanel panel = new AiPanel();
+ panel.setToolRegistryForTesting(new TuiToolRegistry(null));
+ CorrectingLlmClient client = new CorrectingLlmClient();
+ client.firstAnswerIsCorrect = true;
+ panel.setClientForTesting(client);
+ panel.open();
+ type(panel, "how do I fall back to a guest name");
+ panel.handleKeyEvent(KeyEvent.ofKey(KeyCode.ENTER, KeyModifiers.NONE));
+ await().atMost(10, TimeUnit.SECONDS).until(() ->
!panel.isAgentThreadRunningForTesting());
+
+ AiPanel.ConversationEntry last =
panel.conversationForTesting().get(panel.conversationForTesting().size() - 1);
+ assertEquals(1, client.answers);
+ assertFalse(client.sawCorrectionRequest);
+ assertEquals(0, last.corrections());
+ assertNull(last.note());
+ assertFalse(last.byline().contains("correction"), last.byline());
+ }
+
+ /** Answers with the operator inside the placeholder, and fixes it when
asked, like a small local model. */
+ private static final class CorrectingLlmClient extends LlmClient {
+
+ volatile boolean sawCorrectionRequest;
+ volatile int answers;
+ volatile boolean fixes = true;
+ volatile boolean firstAnswerIsCorrect;
+
+ CorrectingLlmClient() {
+ withModel("test-model");
+ withApiType(ApiType.openai);
+ }
+
+ @Override
+ public boolean detectEndpoint() {
+ return true;
+ }
+
+ @Override
+ public ChatResponse chatWithTools(String systemPrompt, List<Message>
messages, List<ToolDef> tools) {
+ answers++;
+ Message last = messages.get(messages.size() - 1);
+ boolean correction = last.content() != null &&
last.content().contains("invalid simple expression");
+ if (correction) {
+ sawCorrectionRequest = true;
+ }
+ boolean correct = firstAnswerIsCorrect || (correction && fixes);
+ String text = correct
+ ? "Use `${header.user} ?: 'Guest'` as the expression: the
elvis operator goes between placeholders."
+ : "Use `${header.user ?: 'Guest'}` as the expression to
fall back to Guest.";
+ return new ChatResponse(text, List.of(), "stop", false,
TokenUsage.EMPTY);
+ }
+ }
+
/** Always asks for the same tool call, like a model stuck on a failing
send. */
private static final class LoopingLlmClient extends LlmClient {