diff --git a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/components/seda.json b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/components/seda.json index fcafafdfaae03..d2ef0a7fcc499 100644 --- a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/components/seda.json +++ b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/components/seda.json @@ -38,7 +38,7 @@ "properties": { "name": { "index": 0, "kind": "path", "displayName": "Name", "group": "common", "label": "", "required": true, "type": "string", "javaType": "java.lang.String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "description": "Name of queue" }, "size": { "index": 1, "kind": "parameter", "displayName": "Size", "group": "common", "label": "", "required": false, "type": "integer", "javaType": "int", "deprecated": false, "autowired": false, "secret": false, "defaultValue": 1000, "description": "The maximum capacity of the SEDA queue (i.e., the number of messages it can hold). Will by default use the queueSize set on the SEDA component." }, - "concurrentConsumers": { "index": 2, "kind": "parameter", "displayName": "Concurrent Consumers", "group": "consumer", "label": "consumer", "required": false, "type": "integer", "javaType": "int", "deprecated": false, "autowired": false, "secret": false, "defaultValue": 1, "description": "Number of concurrent threads processing exchanges." }, + "concurrentConsumers": { "index": 2, "kind": "parameter", "displayName": "Concurrent Consumers", "group": "consumer", "label": "consumer", "required": false, "type": "integer", "javaType": "int", "deprecated": false, "autowired": false, "secret": false, "defaultValue": 1, "description": "Number of concurrent threads processing exchanges. When virtualThreadPerTask is enabled, this becomes a concurrency limit (0 = unlimited) and defaults to 0 instead of 1." }, "bridgeErrorHandler": { "index": 3, "kind": "parameter", "displayName": "Bridge Error Handler", "group": "consumer (advanced)", "label": "consumer,advanced", "required": false, "type": "boolean", "javaType": "boolean", "deprecated": false, "autowired": false, "secret": false, "defaultValue": false, "description": "Allows for bridging the consumer to the Camel routing Error Handler, which mean any exceptions (if possible) occurred while the Camel consumer is trying to pickup incoming messages, or the likes, will now be processed as a message and handled by the routing Error Handler. Important: This is only possible if the 3rd party component allows Camel to be alerted if an exception was thrown. Some components handle this internally only, and therefore bridgeErrorHandler is not possible. In other situations we may improve the Camel component to hook into the 3rd party component and make this possible for future releases. By default the consumer will use the org.apache.camel.spi.ExceptionHandler to deal with exceptions, that will be logged at WARN or ERROR level and ignored." }, "exceptionHandler": { "index": 4, "kind": "parameter", "displayName": "Exception Handler", "group": "consumer (advanced)", "label": "consumer,advanced", "required": false, "type": "object", "javaType": "org.apache.camel.spi.ExceptionHandler", "optionalPrefix": "consumer.", "deprecated": false, "autowired": false, "secret": false, "description": "To let the consumer use a custom ExceptionHandler. Notice if the option bridgeErrorHandler is enabled then this option is not in use. By default the consumer will deal with exceptions, that will be logged at WARN or ERROR level and ignored." }, "exchangePattern": { "index": 5, "kind": "parameter", "displayName": "Exchange Pattern", "group": "consumer (advanced)", "label": "consumer,advanced", "required": false, "type": "enum", "javaType": "org.apache.camel.ExchangePattern", "enum": [ "InOnly", "InOut" ], "deprecated": false, "autowired": false, "secret": false, "description": "Sets the exchange pattern when the consumer creates an exchange." }, diff --git a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/components/stub.json b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/components/stub.json index 6d33364a3a0c2..286a319f9264b 100644 --- a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/components/stub.json +++ b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/components/stub.json @@ -40,7 +40,7 @@ "properties": { "name": { "index": 0, "kind": "path", "displayName": "Name", "group": "common", "label": "", "required": true, "type": "string", "javaType": "java.lang.String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "description": "Name of queue" }, "size": { "index": 1, "kind": "parameter", "displayName": "Size", "group": "common", "label": "", "required": false, "type": "integer", "javaType": "int", "deprecated": false, "autowired": false, "secret": false, "defaultValue": 1000, "description": "The maximum capacity of the SEDA queue (i.e., the number of messages it can hold). Will by default use the queueSize set on the SEDA component." }, - "concurrentConsumers": { "index": 2, "kind": "parameter", "displayName": "Concurrent Consumers", "group": "consumer", "label": "consumer", "required": false, "type": "integer", "javaType": "int", "deprecated": false, "autowired": false, "secret": false, "defaultValue": 1, "description": "Number of concurrent threads processing exchanges." }, + "concurrentConsumers": { "index": 2, "kind": "parameter", "displayName": "Concurrent Consumers", "group": "consumer", "label": "consumer", "required": false, "type": "integer", "javaType": "int", "deprecated": false, "autowired": false, "secret": false, "defaultValue": 1, "description": "Number of concurrent threads processing exchanges. When virtualThreadPerTask is enabled, this becomes a concurrency limit (0 = unlimited) and defaults to 0 instead of 1." }, "bridgeErrorHandler": { "index": 3, "kind": "parameter", "displayName": "Bridge Error Handler", "group": "consumer (advanced)", "label": "consumer,advanced", "required": false, "type": "boolean", "javaType": "boolean", "deprecated": false, "autowired": false, "secret": false, "defaultValue": false, "description": "Allows for bridging the consumer to the Camel routing Error Handler, which mean any exceptions (if possible) occurred while the Camel consumer is trying to pickup incoming messages, or the likes, will now be processed as a message and handled by the routing Error Handler. Important: This is only possible if the 3rd party component allows Camel to be alerted if an exception was thrown. Some components handle this internally only, and therefore bridgeErrorHandler is not possible. In other situations we may improve the Camel component to hook into the 3rd party component and make this possible for future releases. By default the consumer will use the org.apache.camel.spi.ExceptionHandler to deal with exceptions, that will be logged at WARN or ERROR level and ignored." }, "exceptionHandler": { "index": 4, "kind": "parameter", "displayName": "Exception Handler", "group": "consumer (advanced)", "label": "consumer,advanced", "required": false, "type": "object", "javaType": "org.apache.camel.spi.ExceptionHandler", "optionalPrefix": "consumer.", "deprecated": false, "autowired": false, "secret": false, "description": "To let the consumer use a custom ExceptionHandler. Notice if the option bridgeErrorHandler is enabled then this option is not in use. By default the consumer will deal with exceptions, that will be logged at WARN or ERROR level and ignored." }, "exchangePattern": { "index": 5, "kind": "parameter", "displayName": "Exchange Pattern", "group": "consumer (advanced)", "label": "consumer,advanced", "required": false, "type": "enum", "javaType": "org.apache.camel.ExchangePattern", "enum": [ "InOnly", "InOut" ], "deprecated": false, "autowired": false, "secret": false, "description": "Sets the exchange pattern when the consumer creates an exchange." }, diff --git a/dsl/camel-endpointdsl/src/generated/java/org/apache/camel/builder/endpoint/dsl/SedaEndpointBuilderFactory.java b/dsl/camel-endpointdsl/src/generated/java/org/apache/camel/builder/endpoint/dsl/SedaEndpointBuilderFactory.java index 4a8dabc31c6c0..6c1890a0b3284 100644 --- a/dsl/camel-endpointdsl/src/generated/java/org/apache/camel/builder/endpoint/dsl/SedaEndpointBuilderFactory.java +++ b/dsl/camel-endpointdsl/src/generated/java/org/apache/camel/builder/endpoint/dsl/SedaEndpointBuilderFactory.java @@ -81,12 +81,12 @@ default SedaEndpointConsumerBuilder size(String size) { * Number of concurrent threads processing exchanges. When * virtualThreadPerTask is enabled, this becomes a concurrency limit (0 * = unlimited) and defaults to 0 instead of 1. - * + * * The option is a: int type. - * + * * Default: 1 * Group: consumer - * + * * @param concurrentConsumers the value to set * @return the dsl builder */ @@ -98,12 +98,12 @@ default SedaEndpointConsumerBuilder concurrentConsumers(int concurrentConsumers) * Number of concurrent threads processing exchanges. When * virtualThreadPerTask is enabled, this becomes a concurrency limit (0 * = unlimited) and defaults to 0 instead of 1. - * + * * The option will be converted to a int type. - * + * * Default: 1 * Group: consumer - * + * * @param concurrentConsumers the value to set * @return the dsl builder */ diff --git a/dsl/camel-endpointdsl/src/generated/java/org/apache/camel/builder/endpoint/dsl/StubEndpointBuilderFactory.java b/dsl/camel-endpointdsl/src/generated/java/org/apache/camel/builder/endpoint/dsl/StubEndpointBuilderFactory.java index 5be38587183dd..6fd4b9f479c15 100644 --- a/dsl/camel-endpointdsl/src/generated/java/org/apache/camel/builder/endpoint/dsl/StubEndpointBuilderFactory.java +++ b/dsl/camel-endpointdsl/src/generated/java/org/apache/camel/builder/endpoint/dsl/StubEndpointBuilderFactory.java @@ -81,12 +81,12 @@ default StubEndpointConsumerBuilder size(String size) { * Number of concurrent threads processing exchanges. When * virtualThreadPerTask is enabled, this becomes a concurrency limit (0 * = unlimited) and defaults to 0 instead of 1. - * + * * The option is a: int type. - * + * * Default: 1 * Group: consumer - * + * * @param concurrentConsumers the value to set * @return the dsl builder */ @@ -98,12 +98,12 @@ default StubEndpointConsumerBuilder concurrentConsumers(int concurrentConsumers) * Number of concurrent threads processing exchanges. When * virtualThreadPerTask is enabled, this becomes a concurrency limit (0 * = unlimited) and defaults to 0 instead of 1. - * + * * The option will be converted to a int type. - * + * * Default: 1 * Group: consumer - * + * * @param concurrentConsumers the value to set * @return the dsl builder */ diff --git a/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/PromptDefinitions.java b/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/PromptDefinitions.java new file mode 100644 index 0000000000000..9bd4c345724ef --- /dev/null +++ b/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/PromptDefinitions.java @@ -0,0 +1,224 @@ +/* + * 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.mcp; + +import java.util.List; + +import jakarta.enterprise.context.ApplicationScoped; + +import io.quarkiverse.mcp.server.Prompt; +import io.quarkiverse.mcp.server.PromptArg; +import io.quarkiverse.mcp.server.PromptMessage; + +/** + * MCP Prompt definitions that provide structured multi-step workflows for LLMs. + *

+ * Prompts guide the LLM through orchestrating multiple existing tools in the correct sequence, rather than requiring it + * to discover the workflow on its own. + */ +@ApplicationScoped +public class PromptDefinitions { + + /** + * Guided workflow for building a Camel integration from requirements. + */ + @Prompt(name = "camel_build_integration", + description = "Guided workflow to build a Camel integration: " + + "analyze requirements, discover components and EIPs, " + + "generate a YAML route, validate it, and run a security check.") + public List buildIntegration( + @PromptArg(name = "requirements", + description = "Natural-language description of what the integration should do") String requirements, + @PromptArg(name = "runtime", description = "Target runtime: main, spring-boot, or quarkus (default: main)", + required = false) String runtime) { + + String resolvedRuntime = runtime != null && !runtime.isBlank() ? runtime : "main"; + + String instructions = """ + You are building a Camel integration for the "%s" runtime. + + ## Requirements + %s + + ## Workflow + + Follow these steps in order: + + ### Step 1: Identify components + Analyze the requirements above and identify the Camel components needed. + Call `camel_catalog_components` with a relevant filter and runtime="%s" to find matching components. + + ### Step 2: Identify EIPs + Determine which Enterprise Integration Patterns are needed (e.g., split, aggregate, filter, choice). + Call `camel_catalog_eips` with a relevant filter to find matching patterns. + + ### Step 3: Get component details + For each component you selected, call `camel_catalog_component_doc` with the component name \ + and runtime="%s" to get its endpoint options, required parameters, and URI syntax. + + ### Step 4: Build the route + Using the gathered information, write a complete YAML route definition. \ + Use correct component URI syntax and required options from the documentation. + + ### Step 5: Validate + Call `camel_validate_yaml_dsl` with the generated YAML route to check for syntax errors. + If validation fails, fix the issues and re-validate. + + ### Step 6: Security review + Call `camel_route_harden_context` with the generated route and format="yaml" \ + to identify security concerns. Address any critical or high-severity findings. + + ### Step 7: Present result + Present the final YAML route along with: + - A brief explanation of each component and EIP used + - Any security recommendations from Step 6 + - Instructions for running the route (e.g., with camel-jbang) + """.formatted(resolvedRuntime, requirements, resolvedRuntime, resolvedRuntime); + + return List.of(PromptMessage.withUserRole(instructions)); + } + + /** + * Guided workflow for migrating a Camel project to a new version. + */ + @Prompt(name = "camel_migrate_project", + description = "Guided workflow to migrate a Camel project: " + + "analyze the pom.xml, check compatibility, " + + "get OpenRewrite recipes, search migration guides, " + + "and produce a migration summary.") + public List migrateProject( + @PromptArg(name = "pomContent", description = "The project's pom.xml file content") String pomContent, + @PromptArg(name = "targetVersion", description = "Target Camel version to migrate to (e.g., 4.18.0)", + required = false) String targetVersion) { + + String versionNote = targetVersion != null && !targetVersion.isBlank() + ? "Target version: " + targetVersion + : "Target version: latest stable (determine from camel_version_list)"; + + String instructions = """ + You are migrating a Camel project to a newer version. + + ## %s + + ## Project pom.xml + ```xml + %s + ``` + + ## Workflow + + Follow these steps in order: + + ### Step 1: Analyze the project + Call `camel_migration_analyze` with the pom.xml content above. + This detects the current runtime, Camel version, Java version, and component dependencies. + + ### Step 2: Determine target version + If no target version was specified, call `camel_version_list` with the detected runtime \ + to find the latest stable version. For LTS releases, filter with lts=true. + + ### Step 3: Check compatibility + Based on the detected runtime from Step 1: + - For **wildfly** or **karaf** runtimes: call `camel_migration_wildfly_karaf` with the pom.xml \ + content, target runtime, and target version. + - For **main**, **spring-boot**, or **quarkus** runtimes: call `camel_migration_compatibility` \ + with the detected components, current version, target version, runtime, and Java version. + + Review any blockers (e.g., Java version too old) and warnings. + + ### Step 4: Get migration recipes + Call `camel_migration_recipes` with the runtime, current version, target version, \ + Java version, and dryRun=true to get the OpenRewrite Maven commands. + + ### Step 5: Search for breaking changes + For each component detected in Step 1, call `camel_migration_guide_search` \ + with the component name to find relevant breaking changes and rename mappings. + + ### Step 6: Produce migration summary + Present a structured summary: + - **Current state**: runtime, Camel version, Java version, component count + - **Target state**: target version, required Java version + - **Blockers**: issues that must be resolved before migration + - **Breaking changes**: component renames, API changes found in guides + - **Migration commands**: the OpenRewrite commands from Step 4 + - **Manual steps**: any changes that OpenRewrite cannot automate + """.formatted(versionNote, pomContent); + + return List.of(PromptMessage.withUserRole(instructions)); + } + + /** + * Guided workflow for a security review of a Camel route. + */ + @Prompt(name = "camel_security_review", + description = "Guided workflow to perform a security audit of a Camel route: " + + "analyze security-sensitive components, check for vulnerabilities, " + + "and produce an actionable audit checklist.") + public List securityReview( + @PromptArg(name = "route", description = "The Camel route content to review") String route, + @PromptArg(name = "format", description = "Route format: yaml, xml, or java (default: yaml)", + required = false) String format) { + + String resolvedFormat = format != null && !format.isBlank() ? format : "yaml"; + + String instructions = """ + You are performing a security audit of a Camel route. + + ## Route (format: %s) + ``` + %s + ``` + + ## Workflow + + Follow these steps in order: + + ### Step 1: Analyze security + Call `camel_route_harden_context` with the route above and format="%s". + This returns security-sensitive components, vulnerabilities, and risk levels. + + ### Step 2: Understand route structure + Call `camel_route_context` with the route and format="%s". + This returns the components and EIPs used, helping you understand the full data flow. + + ### Step 3: Produce audit checklist + Using the results from Steps 1 and 2, produce a structured security audit report: + + **Critical Issues** (must fix before production): + - List all critical-severity concerns from the security analysis + - For each: describe the issue, the affected component, and the specific fix + + **Warnings** (should fix): + - List all high and medium-severity concerns + - For each: describe the risk and the recommended mitigation + + **Positive Findings** (already secured): + - List all positive security findings (TLS enabled, property placeholders used, etc.) + + **Recommendations**: + - Provide actionable, prioritized recommendations based on the specific components used + - Reference the relevant security best practices for each component + - Include specific configuration examples where applicable + + **Compliance Notes**: + - Note any components that handle PII or sensitive data + - Flag any components that communicate over the network without encryption + """.formatted(resolvedFormat, route, resolvedFormat, resolvedFormat); + + return List.of(PromptMessage.withUserRole(instructions)); + } +} diff --git a/dsl/camel-jbang/camel-jbang-mcp/src/test/java/org/apache/camel/dsl/jbang/core/commands/mcp/PromptDefinitionsTest.java b/dsl/camel-jbang/camel-jbang-mcp/src/test/java/org/apache/camel/dsl/jbang/core/commands/mcp/PromptDefinitionsTest.java new file mode 100644 index 0000000000000..64fd0da9eea2d --- /dev/null +++ b/dsl/camel-jbang/camel-jbang-mcp/src/test/java/org/apache/camel/dsl/jbang/core/commands/mcp/PromptDefinitionsTest.java @@ -0,0 +1,208 @@ +/* + * 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.mcp; + +import java.util.List; + +import io.quarkiverse.mcp.server.PromptMessage; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class PromptDefinitionsTest { + + private final PromptDefinitions prompts = new PromptDefinitions(); + + // ---- camel_build_integration ---- + + @Test + void buildIntegrationReturnsNonEmptyMessages() { + List result = prompts.buildIntegration("Read from Kafka and write to S3", "spring-boot"); + + assertThat(result).isNotEmpty(); + } + + @Test + void buildIntegrationContainsRequirements() { + List result = prompts.buildIntegration("Read from Kafka and write to S3", null); + + String text = extractText(result); + assertThat(text).contains("Read from Kafka and write to S3"); + } + + @Test + void buildIntegrationReferencesTools() { + List result = prompts.buildIntegration("poll an FTP server", null); + + String text = extractText(result); + assertThat(text).contains("camel_catalog_components"); + assertThat(text).contains("camel_catalog_eips"); + assertThat(text).contains("camel_catalog_component_doc"); + assertThat(text).contains("camel_validate_yaml_dsl"); + assertThat(text).contains("camel_route_harden_context"); + } + + @Test + void buildIntegrationDefaultsRuntimeToMain() { + List result = prompts.buildIntegration("timer to log", null); + + String text = extractText(result); + assertThat(text).contains("\"main\" runtime"); + } + + @Test + void buildIntegrationUsesSpecifiedRuntime() { + List result = prompts.buildIntegration("timer to log", "quarkus"); + + String text = extractText(result); + assertThat(text).contains("\"quarkus\" runtime"); + } + + @Test + void buildIntegrationBlankRuntimeDefaultsToMain() { + List result = prompts.buildIntegration("timer to log", " "); + + String text = extractText(result); + assertThat(text).contains("\"main\" runtime"); + } + + // ---- camel_migrate_project ---- + + @Test + void migrateProjectReturnsNonEmptyMessages() { + List result = prompts.migrateProject("", "4.18.0"); + + assertThat(result).isNotEmpty(); + } + + @Test + void migrateProjectContainsPomContent() { + String pom = "3.20.0"; + List result = prompts.migrateProject(pom, null); + + String text = extractText(result); + assertThat(text).contains(pom); + } + + @Test + void migrateProjectReferencesTools() { + List result = prompts.migrateProject("", "4.18.0"); + + String text = extractText(result); + assertThat(text).contains("camel_migration_analyze"); + assertThat(text).contains("camel_migration_compatibility"); + assertThat(text).contains("camel_migration_wildfly_karaf"); + assertThat(text).contains("camel_migration_recipes"); + assertThat(text).contains("camel_migration_guide_search"); + } + + @Test + void migrateProjectIncludesTargetVersion() { + List result = prompts.migrateProject("", "4.18.0"); + + String text = extractText(result); + assertThat(text).contains("Target version: 4.18.0"); + } + + @Test + void migrateProjectNullVersionSuggestsLatest() { + List result = prompts.migrateProject("", null); + + String text = extractText(result); + assertThat(text).contains("camel_version_list"); + } + + @Test + void migrateProjectBlankVersionSuggestsLatest() { + List result = prompts.migrateProject("", " "); + + String text = extractText(result); + assertThat(text).contains("camel_version_list"); + } + + // ---- camel_security_review ---- + + @Test + void securityReviewReturnsNonEmptyMessages() { + List result = prompts.securityReview("from: timer:tick", "yaml"); + + assertThat(result).isNotEmpty(); + } + + @Test + void securityReviewContainsRoute() { + String route = "from:\n uri: kafka:topic\n steps:\n - to: log:out"; + List result = prompts.securityReview(route, null); + + String text = extractText(result); + assertThat(text).contains(route); + } + + @Test + void securityReviewReferencesTools() { + List result = prompts.securityReview("from: timer:tick", null); + + String text = extractText(result); + assertThat(text).contains("camel_route_harden_context"); + assertThat(text).contains("camel_route_context"); + } + + @Test + void securityReviewDefaultsFormatToYaml() { + List result = prompts.securityReview("from: timer:tick", null); + + String text = extractText(result); + assertThat(text).contains("format: yaml"); + } + + @Test + void securityReviewUsesSpecifiedFormat() { + List result = prompts.securityReview("", "xml"); + + String text = extractText(result); + assertThat(text).contains("format: xml"); + } + + @Test + void securityReviewBlankFormatDefaultsToYaml() { + List result = prompts.securityReview("from: timer:tick", " "); + + String text = extractText(result); + assertThat(text).contains("format: yaml"); + } + + @Test + void securityReviewContainsAuditSections() { + List result = prompts.securityReview("from: timer:tick", null); + + String text = extractText(result); + assertThat(text).contains("Critical Issues"); + assertThat(text).contains("Warnings"); + assertThat(text).contains("Positive Findings"); + assertThat(text).contains("Recommendations"); + } + + // ---- helper ---- + + private String extractText(List messages) { + StringBuilder sb = new StringBuilder(); + for (PromptMessage msg : messages) { + sb.append(msg.content().asText().text()); + } + return sb.toString(); + } +}