This is an automated email from the ASF dual-hosted git repository.
acosentino 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 27ef5f491e16 CAMEL-22881 - Camel-Jbang: Add camel explain command to
explain Camel routes using AI/LLM (#20950)
27ef5f491e16 is described below
commit 27ef5f491e16ef8818cdc5338e12a353b241fc21
Author: Andrea Cosentino <[email protected]>
AuthorDate: Wed Jan 21 11:39:34 2026 +0100
CAMEL-22881 - Camel-Jbang: Add camel explain command to explain Camel
routes using AI/LLM (#20950)
Signed-off-by: Andrea Cosentino <[email protected]>
---
.../pages/jbang-commands/camel-jbang-commands.adoc | 1 +
.../pages/jbang-commands/camel-jbang-explain.adoc | 40 ++
.../partials/jbang-commands/examples/explain.adoc | 122 ++++
.../META-INF/camel-jbang-commands-metadata.json | 1 +
.../dsl/jbang/core/commands/CamelJBangMain.java | 1 +
.../camel/dsl/jbang/core/commands/Explain.java | 635 +++++++++++++++++++++
6 files changed, 800 insertions(+)
diff --git
a/docs/user-manual/modules/ROOT/pages/jbang-commands/camel-jbang-commands.adoc
b/docs/user-manual/modules/ROOT/pages/jbang-commands/camel-jbang-commands.adoc
index 4d473117e548..d519eb557fe6 100644
---
a/docs/user-manual/modules/ROOT/pages/jbang-commands/camel-jbang-commands.adoc
+++
b/docs/user-manual/modules/ROOT/pages/jbang-commands/camel-jbang-commands.adoc
@@ -20,6 +20,7 @@ TIP: You can also use `camel --help` or `camel <command>
--help` to see availabl
| xref:jbang-commands/camel-jbang-dependency.adoc[camel dependency] | Displays
all Camel dependencies required to run
| xref:jbang-commands/camel-jbang-dirty.adoc[camel dirty] | Check if there are
dirty files from previous Camel runs that did not terminate gracefully
| xref:jbang-commands/camel-jbang-doc.adoc[camel doc] | Shows documentation
for kamelet, component, and other Camel resources
+| xref:jbang-commands/camel-jbang-explain.adoc[camel explain] | Explain what a
Camel route does using AI/LLM
| xref:jbang-commands/camel-jbang-export.adoc[camel export] | Export to other
runtimes (Camel Main, Spring Boot, or Quarkus)
| xref:jbang-commands/camel-jbang-get.adoc[camel get] | Get status of Camel
integrations
| xref:jbang-commands/camel-jbang-hawtio.adoc[camel hawtio] | Launch Hawtio
web console
diff --git
a/docs/user-manual/modules/ROOT/pages/jbang-commands/camel-jbang-explain.adoc
b/docs/user-manual/modules/ROOT/pages/jbang-commands/camel-jbang-explain.adoc
new file mode 100644
index 000000000000..59ce60f22eee
--- /dev/null
+++
b/docs/user-manual/modules/ROOT/pages/jbang-commands/camel-jbang-explain.adoc
@@ -0,0 +1,40 @@
+
+// AUTO-GENERATED by camel-package-maven-plugin - DO NOT EDIT THIS FILE
+= camel explain
+
+Explain what a Camel route does using AI/LLM
+
+
+== Usage
+
+[source,bash]
+----
+camel explain [options]
+----
+
+
+
+== Options
+
+[cols="2,5,1,2",options="header"]
+|===
+| Option | Description | Default | Type
+| `--api-key` | API key for authentication. Also reads OPENAI_API_KEY or
LLM_API_KEY env vars | | String
+| `--api-type` | API type: 'ollama' or 'openai' (OpenAI-compatible) | ollama |
ApiType
+| `--catalog-context` | Include Camel Catalog descriptions in the prompt | |
boolean
+| `--format` | Output format: text, markdown | text | String
+| `--model` | Model to use | DEFAULT_MODEL | String
+| `--show-prompt` | Show the prompt sent to the LLM | | boolean
+| `--stream` | Stream the response as it's generated (shows progress) | true |
boolean
+| `--system-prompt` | Custom system prompt | | String
+| `--temperature` | Temperature for response generation (0.0-2.0) | 0.7 |
double
+| `--timeout` | Timeout in seconds for LLM response | 120 | int
+| `--url` | LLM API endpoint URL. Auto-detected from 'camel infra' for Ollama
if not specified. | | String
+| `--verbose,-v` | Include detailed technical information | | boolean
+| `-h,--help` | Display the help and sub-commands | | boolean
+|===
+
+
+
+include::partial$jbang-commands/examples/explain.adoc[]
+
diff --git
a/docs/user-manual/modules/ROOT/partials/jbang-commands/examples/explain.adoc
b/docs/user-manual/modules/ROOT/partials/jbang-commands/examples/explain.adoc
new file mode 100644
index 000000000000..b5031d920cc8
--- /dev/null
+++
b/docs/user-manual/modules/ROOT/partials/jbang-commands/examples/explain.adoc
@@ -0,0 +1,122 @@
+== Examples
+
+The `camel explain` command uses AI/LLM to explain Camel routes in plain
English.
+It supports multiple LLM providers including Ollama (local), OpenAI, Azure
OpenAI, vLLM, LM Studio, and LocalAI.
+
+=== Prerequisites
+
+Start Ollama locally using Camel infra:
+
+[source,bash]
+----
+camel infra run ollama
+----
+
+=== Basic Usage
+
+Explain a YAML route:
+
+[source,bash]
+----
+camel explain my-route.yaml
+----
+
+Explain a Java route:
+
+[source,bash]
+----
+camel explain OrderRoute.java
+----
+
+Explain multiple route files:
+
+[source,bash]
+----
+camel explain route1.yaml route2.xml MyRoute.java
+----
+
+=== Output Options
+
+Use verbose mode for detailed technical information:
+
+[source,bash]
+----
+camel explain my-route.yaml --verbose
+----
+
+Output as Markdown for documentation:
+
+[source,bash]
+----
+camel explain my-route.yaml --format=markdown
+----
+
+=== Prompt Options
+
+Include Camel Catalog descriptions for more accurate explanations:
+
+[source,bash]
+----
+camel explain my-route.yaml --catalog-context
+----
+
+Show the prompt sent to the LLM (useful for debugging):
+
+[source,bash]
+----
+camel explain my-route.yaml --show-prompt
+----
+
+Use a custom system prompt:
+
+[source,bash]
+----
+camel explain my-route.yaml --system-prompt="Focus on error handling and
security aspects."
+----
+
+=== LLM Configuration
+
+Use OpenAI or compatible services:
+
+[source,bash]
+----
+camel explain my-route.yaml --url=https://api.openai.com --api-type=openai
--api-key=sk-...
+----
+
+Use environment variables for the API key:
+
+[source,bash]
+----
+export OPENAI_API_KEY=sk-...
+camel explain my-route.yaml --url=https://api.openai.com --api-type=openai
+----
+
+Use a specific model:
+
+[source,bash]
+----
+camel explain my-route.yaml --model=llama3.1:70b
+----
+
+=== Advanced Options
+
+Disable streaming (wait for complete response):
+
+[source,bash]
+----
+camel explain my-route.yaml --stream=false
+----
+
+Adjust temperature (0.0 = deterministic, 2.0 = creative):
+
+[source,bash]
+----
+camel explain my-route.yaml --temperature=0.3
+----
+
+Set a custom timeout (in seconds):
+
+[source,bash]
+----
+camel explain my-route.yaml --timeout=300
+----
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/generated/resources/META-INF/camel-jbang-commands-metadata.json
b/dsl/camel-jbang/camel-jbang-core/src/generated/resources/META-INF/camel-jbang-commands-metadata.json
index 4e7c866e22ad..65955e46ff1a 100644
---
a/dsl/camel-jbang/camel-jbang-core/src/generated/resources/META-INF/camel-jbang-commands-metadata.json
+++
b/dsl/camel-jbang/camel-jbang-core/src/generated/resources/META-INF/camel-jbang-commands-metadata.json
@@ -9,6 +9,7 @@
{ "name": "dependency", "fullName": "dependency", "description": "Displays
all Camel dependencies required to run", "sourceClass":
"org.apache.camel.dsl.jbang.core.commands.DependencyCommand", "options": [ {
"names": "-h,--help", "description": "Display the help and sub-commands",
"javaType": "boolean", "type": "boolean" } ], "subcommands": [ { "name":
"copy", "fullName": "dependency copy", "description": "Copies all Camel
dependencies required to run to a specific directory", "sourc [...]
{ "name": "dirty", "fullName": "dirty", "description": "Check if there are
dirty files from previous Camel runs that did not terminate gracefully",
"sourceClass": "org.apache.camel.dsl.jbang.core.commands.process.Dirty",
"options": [ { "names": "--clean", "description": "Clean dirty files which are
no longer in use", "defaultValue": "false", "javaType": "boolean", "type":
"boolean" }, { "names": "-h,--help", "description": "Display the help and
sub-commands", "javaType": "boolean", " [...]
{ "name": "doc", "fullName": "doc", "description": "Shows documentation
for kamelet, component, and other Camel resources", "sourceClass":
"org.apache.camel.dsl.jbang.core.commands.catalog.CatalogDoc", "options": [ {
"names": "--camel-version", "description": "To use a different Camel version
than the default version", "javaType": "java.lang.String", "type": "string" },
{ "names": "--download", "description": "Whether to allow automatic downloading
JAR dependencies (over the internet [...]
+ { "name": "explain", "fullName": "explain", "description": "Explain what a
Camel route does using AI\/LLM", "sourceClass":
"org.apache.camel.dsl.jbang.core.commands.Explain", "options": [ { "names":
"--api-key", "description": "API key for authentication. Also reads
OPENAI_API_KEY or LLM_API_KEY env vars", "javaType": "java.lang.String",
"type": "string" }, { "names": "--api-type", "description": "API type: 'ollama'
or 'openai' (OpenAI-compatible)", "defaultValue": "ollama", "javaTyp [...]
{ "name": "export", "fullName": "export", "description": "Export to other
runtimes (Camel Main, Spring Boot, or Quarkus)", "sourceClass":
"org.apache.camel.dsl.jbang.core.commands.Export", "options": [ { "names":
"--build-property", "description": "Maven\/Gradle build properties, ex.
--build-property=prop1=foo", "javaType": "java.util.List", "type": "array" }, {
"names": "--build-tool", "description": "DEPRECATED: Build tool to use (maven
or gradle) (gradle is deprecated)", "defaultV [...]
{ "name": "get", "fullName": "get", "description": "Get status of Camel
integrations", "sourceClass":
"org.apache.camel.dsl.jbang.core.commands.process.CamelStatus", "options": [ {
"names": "--watch", "description": "Execute periodically and showing output
fullscreen", "javaType": "boolean", "type": "boolean" }, { "names":
"-h,--help", "description": "Display the help and sub-commands", "javaType":
"boolean", "type": "boolean" } ], "subcommands": [ { "name": "bean",
"fullName": "get [...]
{ "name": "hawtio", "fullName": "hawtio", "description": "Launch Hawtio
web console", "sourceClass":
"org.apache.camel.dsl.jbang.core.commands.process.Hawtio", "options": [ {
"names": "--openUrl", "description": "To automatic open Hawtio web console in
the web browser", "defaultValue": "true", "javaType": "boolean", "type":
"boolean" }, { "names": "--port", "description": "Port number to use for Hawtio
web console (port 8888 by default)", "defaultValue": "8888", "javaType": "int",
"t [...]
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/CamelJBangMain.java
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/CamelJBangMain.java
index 0eee0f1fb046..885d8d490dfd 100644
---
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/CamelJBangMain.java
+++
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/CamelJBangMain.java
@@ -126,6 +126,7 @@ public class CamelJBangMain implements Callable<Integer> {
.addSubcommand("update", new CommandLine(new
DependencyUpdate(main))))
.addSubcommand("dirty", new CommandLine(new Dirty(main)))
.addSubcommand("export", new CommandLine(new Export(main)))
+ .addSubcommand("explain", new CommandLine(new Explain(main)))
.addSubcommand("get", new CommandLine(new CamelStatus(main))
.addSubcommand("bean", new CommandLine(new
CamelBeanDump(main)))
.addSubcommand("blocked", new CommandLine(new
ListBlocked(main)))
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/Explain.java
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/Explain.java
new file mode 100644
index 000000000000..f94cad65cb0c
--- /dev/null
+++
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/Explain.java
@@ -0,0 +1,635 @@
+/*
+ * 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;
+
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.time.Duration;
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.function.BiFunction;
+import java.util.stream.Stream;
+
+import org.apache.camel.catalog.CamelCatalog;
+import org.apache.camel.catalog.DefaultCamelCatalog;
+import org.apache.camel.dsl.jbang.core.common.CommandLineHelper;
+import org.apache.camel.tooling.model.ComponentModel;
+import org.apache.camel.tooling.model.EipModel;
+import org.apache.camel.util.FileUtil;
+import org.apache.camel.util.json.JsonArray;
+import org.apache.camel.util.json.JsonObject;
+import org.apache.camel.util.json.Jsoner;
+import picocli.CommandLine.Command;
+import picocli.CommandLine.Option;
+import picocli.CommandLine.Parameters;
+
+/**
+ * Command to explain Camel routes using AI/LLM services.
+ * <p>
+ * Supports multiple LLM providers: Ollama, OpenAI, Azure OpenAI, vLLM, LM
Studio, LocalAI, etc.
+ */
+@Command(name = "explain",
+ description = "Explain what a Camel route does using AI/LLM",
+ sortOptions = false, showDefaultValues = true)
+public class Explain extends CamelCommand {
+
+ private static final String DEFAULT_OLLAMA_URL = "http://localhost:11434";
+ private static final String DEFAULT_MODEL = "llama3.2";
+ private static final int CONNECT_TIMEOUT_SECONDS = 10;
+ private static final int HEALTH_CHECK_TIMEOUT_SECONDS = 5;
+
+ private static final List<String> COMMON_COMPONENTS = Arrays.asList(
+ "kafka", "http", "https", "file", "timer", "direct", "seda",
+ "log", "mock", "rest", "rest-api", "sql", "jms", "activemq",
+ "aws2-s3", "aws2-sqs", "aws2-sns", "aws2-kinesis",
+ "azure-storage-blob", "azure-storage-queue",
+ "google-pubsub", "google-storage",
+ "mongodb", "couchdb", "cassandraql",
+ "elasticsearch", "opensearch",
+ "ftp", "sftp", "ftps",
+ "smtp", "imap", "pop3",
+ "websocket", "netty-http", "vertx-http",
+ "bean", "process", "script");
+
+ private static final List<String> COMMON_EIPS = Arrays.asList(
+ "split", "aggregate", "filter", "choice", "when", "otherwise",
+ "multicast", "recipientList", "routingSlip", "dynamicRouter",
+ "circuitBreaker", "throttle", "delay", "resequence",
+ "idempotentConsumer", "loadBalance", "saga",
+ "onException", "doTry", "doCatch", "doFinally",
+ "transform", "setBody", "setHeader", "removeHeader",
+ "marshal", "unmarshal", "convertBodyTo",
+ "enrich", "pollEnrich", "wireTap", "pipeline");
+
+ enum ApiType {
+ ollama((explain, prompts) -> explain.callOllama(prompts[0],
prompts[1], prompts[2])),
+ openai((explain, prompts) -> explain.callOpenAiCompatible(prompts[0],
prompts[1], prompts[2], prompts[3]));
+
+ private final BiFunction<Explain, String[], String> caller;
+
+ ApiType(BiFunction<Explain, String[], String> caller) {
+ this.caller = caller;
+ }
+
+ String call(Explain explain, String endpoint, String sysPrompt, String
userPrompt, String apiKey) {
+ return caller.apply(explain, new String[] { endpoint, sysPrompt,
userPrompt, apiKey });
+ }
+ }
+
+ @Parameters(description = "Route file(s) to explain", arity = "1..*")
+ List<String> files;
+
+ @Option(names = { "--url" },
+ description = "LLM API endpoint URL. Auto-detected from 'camel
infra' for Ollama if not specified.")
+ String url;
+
+ @Option(names = { "--api-type" },
+ description = "API type: 'ollama' or 'openai' (OpenAI-compatible)",
+ defaultValue = "ollama")
+ ApiType apiType = ApiType.ollama;
+
+ @Option(names = { "--api-key" },
+ description = "API key for authentication. Also reads
OPENAI_API_KEY or LLM_API_KEY env vars")
+ String apiKey;
+
+ @Option(names = { "--model" },
+ description = "Model to use",
+ defaultValue = DEFAULT_MODEL)
+ String model = DEFAULT_MODEL;
+
+ @Option(names = { "--verbose", "-v" },
+ description = "Include detailed technical information")
+ boolean verbose;
+
+ @Option(names = { "--format" },
+ description = "Output format: text, markdown",
+ defaultValue = "text")
+ String format = "text";
+
+ @Option(names = { "--timeout" },
+ description = "Timeout in seconds for LLM response",
+ defaultValue = "120")
+ int timeout = 120;
+
+ @Option(names = { "--catalog-context" },
+ description = "Include Camel Catalog descriptions in the prompt")
+ boolean catalogContext;
+
+ @Option(names = { "--show-prompt" },
+ description = "Show the prompt sent to the LLM")
+ boolean showPrompt;
+
+ @Option(names = { "--temperature" },
+ description = "Temperature for response generation (0.0-2.0)",
+ defaultValue = "0.7")
+ double temperature = 0.7;
+
+ @Option(names = { "--system-prompt" },
+ description = "Custom system prompt")
+ String systemPrompt;
+
+ @Option(names = { "--stream" },
+ description = "Stream the response as it's generated (shows
progress)",
+ defaultValue = "true")
+ boolean stream = true;
+
+ private final HttpClient httpClient = HttpClient.newBuilder()
+ .connectTimeout(Duration.ofSeconds(CONNECT_TIMEOUT_SECONDS))
+ .build();
+
+ public Explain(CamelJBangMain main) {
+ super(main);
+ }
+
+ @Override
+ public Integer doCall() throws Exception {
+ String endpoint = detectEndpoint();
+ if (endpoint == null) {
+ printUsageHelp();
+ return 1;
+ }
+
+ String resolvedApiKey = resolveApiKey();
+ printConfiguration(endpoint, resolvedApiKey);
+
+ for (String file : files) {
+ int result = explainRoute(file, endpoint, resolvedApiKey);
+ if (result != 0) {
+ return result;
+ }
+ }
+ return 0;
+ }
+
+ private void printConfiguration(String endpoint, String resolvedApiKey) {
+ printer().println("LLM Configuration:");
+ printer().println(" URL: " + endpoint);
+ printer().println(" API Type: " + apiType);
+ printer().println(" Model: " + model);
+ printMaskedApiKey(resolvedApiKey);
+ printer().println();
+ }
+
+ private void printMaskedApiKey(String key) {
+ if (key == null || key.isBlank()) {
+ return;
+ }
+ String masked = "****" + key.substring(Math.max(0, key.length() - 4));
+ printer().println(" API Key: " + masked);
+ }
+
+ private void printUsageHelp() {
+ printer().printErr("LLM service is not running or not reachable.");
+ printer().printErr("");
+ printer().printErr("Options:");
+ printer().printErr(" 1. camel infra run ollama");
+ printer().printErr(" 2. camel explain my-route.yaml
--url=http://localhost:11434");
+ printer().printErr(" 3. camel explain my-route.yaml
--url=https://api.openai.com --api-type=openai --api-key=sk-...");
+ }
+
+ private String detectEndpoint() {
+ return tryExplicitUrl()
+ .or(this::tryInfraOllama)
+ .or(this::tryDefaultOllama)
+ .orElse(null);
+ }
+
+ private Optional<String> tryExplicitUrl() {
+ if (url == null || url.isBlank()) {
+ return Optional.empty();
+ }
+ if (isEndpointReachable(url)) {
+ return Optional.of(url);
+ }
+ printer().printErr("Cannot connect to LLM service at: " + url);
+ return Optional.empty();
+ }
+
+ private Optional<String> tryInfraOllama() {
+ try {
+ Map<Long, Path> pids = findOllamaPids();
+ for (Path pidFile : pids.values()) {
+ String baseUrl = readBaseUrlFromPidFile(pidFile);
+ if (baseUrl != null && isEndpointReachable(baseUrl)) {
+ apiType = ApiType.ollama;
+ return Optional.of(baseUrl);
+ }
+ }
+ } catch (Exception e) {
+ // ignore
+ }
+ return Optional.empty();
+ }
+
+ private String readBaseUrlFromPidFile(Path pidFile) throws Exception {
+ String json = Files.readString(pidFile);
+ JsonObject jo = (JsonObject) Jsoner.deserialize(json);
+ return jo.getString("baseUrl");
+ }
+
+ private Optional<String> tryDefaultOllama() {
+ if (isEndpointReachable(DEFAULT_OLLAMA_URL)) {
+ apiType = ApiType.ollama;
+ return Optional.of(DEFAULT_OLLAMA_URL);
+ }
+ return Optional.empty();
+ }
+
+ private String resolveApiKey() {
+ if (apiKey != null && !apiKey.isBlank()) {
+ return apiKey;
+ }
+ return Stream.of("OPENAI_API_KEY", "LLM_API_KEY")
+ .map(System::getenv)
+ .filter(k -> k != null && !k.isBlank())
+ .findFirst()
+ .orElse(null);
+ }
+
+ private Map<Long, Path> findOllamaPids() throws Exception {
+ Map<Long, Path> pids = new HashMap<>();
+ Path camelDir = CommandLineHelper.getCamelDir();
+
+ if (!Files.exists(camelDir)) {
+ return pids;
+ }
+
+ try (Stream<Path> fileStream = Files.list(camelDir)) {
+ fileStream
+ .filter(this::isOllamaPidFile)
+ .forEach(p -> addPidEntry(pids, p));
+ }
+ return pids;
+ }
+
+ private boolean isOllamaPidFile(Path p) {
+ String name = p.getFileName().toString();
+ return name.startsWith("infra-ollama-") && name.endsWith(".json");
+ }
+
+ private void addPidEntry(Map<Long, Path> pids, Path p) {
+ String name = p.getFileName().toString();
+ String pidStr = name.substring(name.lastIndexOf("-") + 1,
name.lastIndexOf('.'));
+ try {
+ pids.put(Long.valueOf(pidStr), p);
+ } catch (NumberFormatException e) {
+ // ignore
+ }
+ }
+
+ private boolean isEndpointReachable(String endpoint) {
+ return tryHealthCheck(endpoint + "/api/tags")
+ || tryHealthCheck(endpoint + "/v1/models")
+ || tryHealthCheck(endpoint);
+ }
+
+ private boolean tryHealthCheck(String healthUrl) {
+ try {
+ HttpRequest request = HttpRequest.newBuilder()
+ .uri(URI.create(healthUrl))
+ .timeout(Duration.ofSeconds(HEALTH_CHECK_TIMEOUT_SECONDS))
+ .GET()
+ .build();
+ HttpResponse<String> response = httpClient.send(request,
HttpResponse.BodyHandlers.ofString());
+ return response.statusCode() == 200;
+ } catch (Exception e) {
+ return false;
+ }
+ }
+
+ private int explainRoute(String file, String endpoint, String
resolvedApiKey) throws Exception {
+ Path path = Path.of(file);
+ if (!Files.exists(path)) {
+ printer().printErr("File not found: " + file);
+ return 1;
+ }
+
+ String routeContent = Files.readString(path);
+ String ext = Optional.ofNullable(FileUtil.onlyExt(file,
false)).orElse("yaml");
+
+ printFileHeader(file);
+
+ String sysPrompt = buildSystemPrompt();
+ String userPrompt = buildUserPrompt(routeContent, ext, file);
+
+ printPromptsIfRequested(sysPrompt, userPrompt);
+
+ String explanation = apiType.call(this, endpoint, sysPrompt,
userPrompt, resolvedApiKey);
+
+ return handleExplanationResult(explanation);
+ }
+
+ private void printFileHeader(String file) {
+ printer().println("=".repeat(70));
+ printer().println("Explaining: " + file);
+ printer().println("=".repeat(70));
+ printer().println();
+ }
+
+ private void printPromptsIfRequested(String sysPrompt, String userPrompt) {
+ if (!showPrompt) {
+ return;
+ }
+ printer().println("--- SYSTEM PROMPT ---");
+ printer().println(sysPrompt);
+ printer().println("--- USER PROMPT ---");
+ printer().println(userPrompt);
+ printer().println("--- END PROMPTS ---");
+ printer().println();
+ }
+
+ private int handleExplanationResult(String explanation) {
+ if (explanation == null) {
+ printer().printErr("Failed to get explanation from LLM");
+ return 1;
+ }
+ // With streaming, response was already printed during generation
+ // Without streaming, we need to print it now
+ if (!stream) {
+ printer().println(explanation);
+ }
+ printer().println();
+ return 0;
+ }
+
+ private String buildSystemPrompt() {
+ if (systemPrompt != null && !systemPrompt.isBlank()) {
+ return systemPrompt;
+ }
+
+ StringBuilder prompt = new StringBuilder();
+ prompt.append("You are an Apache Camel integration expert. ");
+ prompt.append("Your task is to explain Camel routes in plain
English.\n\n");
+ prompt.append("Guidelines:\n");
+ prompt.append("- Start with a one-sentence summary\n");
+ prompt.append("- Describe step by step what the route does\n");
+ prompt.append("- Explain each component and EIP used\n");
+ prompt.append("- Mention error handling if present\n");
+ prompt.append("- Use bullet points for clarity\n");
+
+ if ("markdown".equals(format)) {
+ prompt.append("- Format output as Markdown\n");
+ }
+ if (verbose) {
+ prompt.append("- Include technical details about options and
expressions\n");
+ }
+ return prompt.toString();
+ }
+
+ private String buildUserPrompt(String routeContent, String fileExtension,
String fileName) {
+ StringBuilder prompt = new StringBuilder();
+
+ if (catalogContext) {
+ appendCatalogContext(prompt, routeContent);
+ }
+
+ prompt.append("File: ").append(fileName).append("\n");
+ prompt.append("Format:
").append(fileExtension.toUpperCase()).append("\n\n");
+ prompt.append("Route
definition:\n```").append(fileExtension).append("\n");
+ prompt.append(routeContent).append("\n```\n\n");
+ prompt.append("Please explain this Camel route:");
+
+ return prompt.toString();
+ }
+
+ private void appendCatalogContext(StringBuilder prompt, String
routeContent) {
+ String catalogInfo = buildCatalogContext(routeContent);
+ if (catalogInfo.isEmpty()) {
+ return;
+ }
+ prompt.append("Reference
information:\n").append(catalogInfo).append("\n");
+ }
+
+ private String buildCatalogContext(String routeContent) {
+ StringBuilder context = new StringBuilder();
+ CamelCatalog catalog = new DefaultCamelCatalog();
+ String lowerContent = routeContent.toLowerCase();
+
+ appendComponentDescriptions(context, catalog, lowerContent);
+ appendEipDescriptions(context, catalog, lowerContent);
+
+ return context.toString();
+ }
+
+ private void appendComponentDescriptions(StringBuilder context,
CamelCatalog catalog, String content) {
+ COMMON_COMPONENTS.stream()
+ .filter(comp -> containsComponent(content, comp))
+ .forEach(comp -> appendModelDescription(context,
catalog.componentModel(comp), comp, ""));
+ }
+
+ private void appendEipDescriptions(StringBuilder context, CamelCatalog
catalog, String content) {
+ COMMON_EIPS.stream()
+ .filter(eip -> content.contains(eip.toLowerCase()) ||
content.contains(camelCaseToDash(eip)))
+ .forEach(eip -> appendModelDescription(context,
catalog.eipModel(eip), eip, " (EIP)"));
+ }
+
+ private boolean containsComponent(String content, String comp) {
+ return content.contains(comp + ":")
+ || content.contains("\"" + comp + "\"")
+ || content.contains("'" + comp + "'");
+ }
+
+ private void appendModelDescription(StringBuilder context, Object model,
String name, String suffix) {
+ String description = getModelDescription(model);
+ if (description != null) {
+ context.append("- ").append(name).append(suffix).append(":
").append(description).append("\n");
+ }
+ }
+
+ private String getModelDescription(Object model) {
+ if (model instanceof ComponentModel) {
+ return ((ComponentModel) model).getDescription();
+ }
+ if (model instanceof EipModel) {
+ return ((EipModel) model).getDescription();
+ }
+ return null;
+ }
+
+ private String camelCaseToDash(String text) {
+ StringBuilder sb = new StringBuilder();
+ for (char c : text.toCharArray()) {
+ if (Character.isUpperCase(c) && sb.length() > 0) {
+ sb.append('-');
+ }
+ sb.append(Character.toLowerCase(c));
+ }
+ return sb.toString();
+ }
+
+ String callOllama(String endpoint, String sysPrompt, String userPrompt) {
+ JsonObject request = new JsonObject();
+ request.put("model", model);
+ request.put("prompt", userPrompt);
+ request.put("system", sysPrompt);
+ request.put("stream", stream);
+
+ JsonObject options = new JsonObject();
+ options.put("temperature", temperature);
+ request.put("options", options);
+
+ printer().println("Analyzing route with " + model + " (Ollama)...");
+ printer().println();
+
+ if (stream) {
+ return sendStreamingRequest(endpoint + "/api/generate", request);
+ }
+ JsonObject response = sendRequest(endpoint + "/api/generate", request,
null);
+ return response != null ? response.getString("response") : null;
+ }
+
+ String callOpenAiCompatible(String endpoint, String sysPrompt, String
userPrompt, String resolvedApiKey) {
+ JsonArray messages = new JsonArray();
+ messages.add(createMessage("system", sysPrompt));
+ messages.add(createMessage("user", userPrompt));
+
+ JsonObject request = new JsonObject();
+ request.put("model", model);
+ request.put("messages", messages);
+ request.put("temperature", temperature);
+
+ String apiUrl = normalizeOpenAiUrl(endpoint);
+
+ printer().println("Analyzing route with " + model + "
(OpenAI-compatible)...");
+ printer().println();
+
+ JsonObject response = sendRequest(apiUrl, request, resolvedApiKey);
+ return extractOpenAiContent(response);
+ }
+
+ private JsonObject createMessage(String role, String content) {
+ JsonObject msg = new JsonObject();
+ msg.put("role", role);
+ msg.put("content", content);
+ return msg;
+ }
+
+ private String normalizeOpenAiUrl(String endpoint) {
+ String url = endpoint.endsWith("/") ? endpoint.substring(0,
endpoint.length() - 1) : endpoint;
+ if (!url.endsWith("/v1/chat/completions")) {
+ url = url.endsWith("/v1") ? url : url + "/v1";
+ url = url + "/chat/completions";
+ }
+ return url;
+ }
+
+ private String extractOpenAiContent(JsonObject response) {
+ if (response == null) {
+ return null;
+ }
+ JsonArray choices = (JsonArray) response.get("choices");
+ if (choices == null || choices.isEmpty()) {
+ return null;
+ }
+ JsonObject firstChoice = (JsonObject) choices.get(0);
+ JsonObject message = (JsonObject) firstChoice.get("message");
+ return message != null ? message.getString("content") : null;
+ }
+
+ private String sendStreamingRequest(String url, JsonObject body) {
+ try {
+ HttpRequest request = HttpRequest.newBuilder()
+ .uri(URI.create(url))
+ .timeout(Duration.ofSeconds(timeout))
+ .header("Content-Type", "application/json")
+ .POST(HttpRequest.BodyPublishers.ofString(body.toJson()))
+ .build();
+
+ HttpResponse<Stream<String>> response = httpClient.send(
+ request, HttpResponse.BodyHandlers.ofLines());
+
+ if (response.statusCode() != 200) {
+ handleErrorStatus(response.statusCode(), "Streaming request
failed");
+ return null;
+ }
+
+ StringBuilder fullResponse = new StringBuilder();
+ response.body().forEach(line -> {
+ if (line.isBlank()) {
+ return;
+ }
+ try {
+ JsonObject chunk = (JsonObject) Jsoner.deserialize(line);
+ String text = chunk.getString("response");
+ if (text != null) {
+ printer().print(text);
+ fullResponse.append(text);
+ }
+ } catch (Exception e) {
+ // Skip malformed chunks
+ }
+ });
+
+ printer().println();
+ return fullResponse.toString();
+
+ } catch (java.net.http.HttpTimeoutException e) {
+ printer().printErr("\nRequest timed out after " + timeout + "
seconds.");
+ return null;
+ } catch (Exception e) {
+ printer().printErr("\nError during streaming: " + e.getMessage());
+ return null;
+ }
+ }
+
+ private JsonObject sendRequest(String url, JsonObject body, String
authKey) {
+ try {
+ HttpRequest.Builder builder = HttpRequest.newBuilder()
+ .uri(URI.create(url))
+ .timeout(Duration.ofSeconds(timeout))
+ .header("Content-Type", "application/json")
+ .POST(HttpRequest.BodyPublishers.ofString(body.toJson()));
+
+ if (authKey != null && !authKey.isBlank()) {
+ builder.header("Authorization", "Bearer " + authKey);
+ }
+
+ HttpResponse<String> response = httpClient.send(builder.build(),
HttpResponse.BodyHandlers.ofString());
+
+ if (response.statusCode() == 200) {
+ return (JsonObject) Jsoner.deserialize(response.body());
+ }
+
+ handleErrorStatus(response.statusCode(), response.body());
+ return null;
+
+ } catch (java.net.http.HttpTimeoutException e) {
+ printer().printErr("Request timed out after " + timeout + "
seconds.");
+ return null;
+ } catch (Exception e) {
+ printer().printErr("Error calling LLM: " + e.getMessage());
+ return null;
+ }
+ }
+
+ private void handleErrorStatus(int statusCode, String body) {
+ printer().printErr("LLM returned status: " + statusCode);
+ switch (statusCode) {
+ case 401 -> printer().printErr("Authentication failed. Check your
API key.");
+ case 404 -> printer().printErr("Model '" + model + "' not found.");
+ case 429 -> printer().printErr("Rate limit exceeded.");
+ default -> printer().printErr(body);
+ }
+ }
+}