This is an automated email from the ASF dual-hosted git repository. davsclaus pushed a commit to branch CAMEL-26512-spring-ai-embeddings-text-body in repository https://gitbox.apache.org/repos/asf/camel.git
commit 238ebe6782a88f4f3513def4991060ed7708644f Author: Claus Ibsen <[email protected]> AuthorDate: Tue Nov 3 15:42:56 2026 +0100 CAMEL-26512: camel-spring-ai-embeddings - fail a Map or POJO body, or List element, instead of embedding its toString() A body, and each element of a List body, was converted to a String with the type converter, so a Map, a POJO or a number went through the ToString fallback converter and was embedded as its toString(), such as {a=1, b=2}. A Spring AI Document was embedded as its toString() too. Now text, bytes, a stream, a Reader or a file is read as text, a Document is embedded as its text, and any other value fails with an InvalidPayloadException that says to convert the body to text first (for a List element, the message names its index and type), as in the langchain4j embeddings components. The body types no longer list any body, and the upgrade guide notes the change. Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: Claus Ibsen <[email protected]> --- .../catalog/components/spring-ai-embeddings.json | 2 +- .../docs/spring-ai-embeddings-component.adoc | 5 +- .../springai/embeddings/spring-ai-embeddings.json | 2 +- .../main/docs/spring-ai-embeddings-component.adoc | 5 +- .../embeddings/SpringAiEmbeddingsEndpoint.java | 9 +- .../embeddings/SpringAiEmbeddingsProducer.java | 73 +++++++++++--- .../SpringAiEmbeddingsComponentTest.java | 111 ++++++++++++++++++++- .../ROOT/pages/camel-4x-upgrade-guide-4_24.adoc | 13 ++- 8 files changed, 188 insertions(+), 32 deletions(-) diff --git a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/components/spring-ai-embeddings.json b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/components/spring-ai-embeddings.json index e182c253f1ef..fe27c5cab2a7 100644 --- a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/components/spring-ai-embeddings.json +++ b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/components/spring-ai-embeddings.json @@ -38,7 +38,7 @@ "CamelSpringAiEmbeddingInputTexts": { "index": 5, "kind": "header", "displayName": "", "group": "producer", "label": "", "required": false, "javaType": "java.util.List<String>", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "description": "List of input texts that were embedded", "constantName": "org.apache.camel.component.springai.embeddings.SpringAiEmbeddingsHeaders#INPUT_TEXTS" } }, "bodyTypes": { - "producerIn": { "carriers": [ "any" ], "description": "The text to embed, or a List of texts for a batch. Another body, and each element of a List, is converted to text (a Map or a POJO as its toString()); no body, or a null element in a List, fails" }, + "producerIn": { "carriers": [ "text", "bytes", "stream", "file", "list" ], "description": "The text to embed: a String, bytes, a stream or a file read as text, or a Spring AI Document as its text; or a List of them for a batch. Another body or List element, such as a Map or a POJO, fails, as do no body and a null element: convert it to text first" }, "producerOut": { "carriers": [ "list" ], "description": "The vector as a float[] for one text, and a List of float[] for a List of texts, also for a List with one text or none. The Embedding and the input text are in headers, which spring-ai-vector-store reads" } }, "properties": { diff --git a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/spring-ai-embeddings-component.adoc b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/spring-ai-embeddings-component.adoc index 99ab8537b98c..ffb8a829229c 100644 --- a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/spring-ai-embeddings-component.adoc +++ b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/spring-ai-embeddings-component.adoc @@ -50,7 +50,10 @@ The component accepts two types of input: * **Single text**: A String containing the text to embed * **Batch processing**: A List<String> containing multiple texts to embed at once -Another body, and each element of a List, is converted to a String. A `null` element in a List fails the exchange +Bytes, a stream, a `Reader` or a file is read as text, and a Spring AI `Document` is embedded as its text, as a body +and as an element of a List. Any other body or List element, such as a `Map`, a POJO or a number, fails the exchange +with an `InvalidPayloadException` instead of being embedded as its `toString()`: turn it into text first, with +`convertBodyTo(String.class)` or a marshal to JSON, say. No body, or a `null` element in a List, fails the exchange with an `IllegalArgumentException`. The embedding vectors are returned in the message body, with additional metadata available in headers. diff --git a/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-embeddings/src/generated/resources/META-INF/org/apache/camel/component/springai/embeddings/spring-ai-embeddings.json b/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-embeddings/src/generated/resources/META-INF/org/apache/camel/component/springai/embeddings/spring-ai-embeddings.json index e182c253f1ef..fe27c5cab2a7 100644 --- a/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-embeddings/src/generated/resources/META-INF/org/apache/camel/component/springai/embeddings/spring-ai-embeddings.json +++ b/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-embeddings/src/generated/resources/META-INF/org/apache/camel/component/springai/embeddings/spring-ai-embeddings.json @@ -38,7 +38,7 @@ "CamelSpringAiEmbeddingInputTexts": { "index": 5, "kind": "header", "displayName": "", "group": "producer", "label": "", "required": false, "javaType": "java.util.List<String>", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "description": "List of input texts that were embedded", "constantName": "org.apache.camel.component.springai.embeddings.SpringAiEmbeddingsHeaders#INPUT_TEXTS" } }, "bodyTypes": { - "producerIn": { "carriers": [ "any" ], "description": "The text to embed, or a List of texts for a batch. Another body, and each element of a List, is converted to text (a Map or a POJO as its toString()); no body, or a null element in a List, fails" }, + "producerIn": { "carriers": [ "text", "bytes", "stream", "file", "list" ], "description": "The text to embed: a String, bytes, a stream or a file read as text, or a Spring AI Document as its text; or a List of them for a batch. Another body or List element, such as a Map or a POJO, fails, as do no body and a null element: convert it to text first" }, "producerOut": { "carriers": [ "list" ], "description": "The vector as a float[] for one text, and a List of float[] for a List of texts, also for a List with one text or none. The Embedding and the input text are in headers, which spring-ai-vector-store reads" } }, "properties": { diff --git a/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-embeddings/src/main/docs/spring-ai-embeddings-component.adoc b/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-embeddings/src/main/docs/spring-ai-embeddings-component.adoc index 99ab8537b98c..ffb8a829229c 100644 --- a/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-embeddings/src/main/docs/spring-ai-embeddings-component.adoc +++ b/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-embeddings/src/main/docs/spring-ai-embeddings-component.adoc @@ -50,7 +50,10 @@ The component accepts two types of input: * **Single text**: A String containing the text to embed * **Batch processing**: A List<String> containing multiple texts to embed at once -Another body, and each element of a List, is converted to a String. A `null` element in a List fails the exchange +Bytes, a stream, a `Reader` or a file is read as text, and a Spring AI `Document` is embedded as its text, as a body +and as an element of a List. Any other body or List element, such as a `Map`, a POJO or a number, fails the exchange +with an `InvalidPayloadException` instead of being embedded as its `toString()`: turn it into text first, with +`convertBodyTo(String.class)` or a marshal to JSON, say. No body, or a `null` element in a List, fails the exchange with an `IllegalArgumentException`. The embedding vectors are returned in the message body, with additional metadata available in headers. diff --git a/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-embeddings/src/main/java/org/apache/camel/component/springai/embeddings/SpringAiEmbeddingsEndpoint.java b/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-embeddings/src/main/java/org/apache/camel/component/springai/embeddings/SpringAiEmbeddingsEndpoint.java index e82a65c38365..e6884420db36 100644 --- a/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-embeddings/src/main/java/org/apache/camel/component/springai/embeddings/SpringAiEmbeddingsEndpoint.java +++ b/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-embeddings/src/main/java/org/apache/camel/component/springai/embeddings/SpringAiEmbeddingsEndpoint.java @@ -37,10 +37,11 @@ import org.apache.camel.support.DefaultEndpoint; syntax = "spring-ai-embeddings:embeddingId", producerOnly = true, category = { Category.AI }, headersClass = SpringAiEmbeddingsHeaders.class) -@BodyTypes(producerIn = @BodyType(value = Carrier.ANY, - description = "The text to embed, or a List of texts for a batch. Another body, and" - + " each element of a List, is converted to text (a Map or a POJO as its" - + " toString()); no body, or a null element in a List, fails"), +@BodyTypes(producerIn = @BodyType(value = { Carrier.TEXT, Carrier.BYTES, Carrier.STREAM, Carrier.FILE, Carrier.LIST }, + description = "The text to embed: a String, bytes, a stream or a file read as text, or a" + + " Spring AI Document as its text; or a List of them for a batch. Another" + + " body or List element, such as a Map or a POJO, fails, as do no body and" + + " a null element: convert it to text first"), producerOut = @BodyType(value = Carrier.LIST, description = "The vector as a float[] for one text, and a List of float[] for a List" + " of texts, also for a List with one text or none. The Embedding and" diff --git a/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-embeddings/src/main/java/org/apache/camel/component/springai/embeddings/SpringAiEmbeddingsProducer.java b/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-embeddings/src/main/java/org/apache/camel/component/springai/embeddings/SpringAiEmbeddingsProducer.java index a3a64b8710ea..c5cc48693336 100644 --- a/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-embeddings/src/main/java/org/apache/camel/component/springai/embeddings/SpringAiEmbeddingsProducer.java +++ b/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-embeddings/src/main/java/org/apache/camel/component/springai/embeddings/SpringAiEmbeddingsProducer.java @@ -16,12 +16,23 @@ */ package org.apache.camel.component.springai.embeddings; +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.InputStream; +import java.io.Reader; +import java.nio.ByteBuffer; +import java.nio.file.Path; import java.util.ArrayList; import java.util.List; import org.apache.camel.Exchange; +import org.apache.camel.InvalidPayloadException; import org.apache.camel.Message; +import org.apache.camel.NoTypeConversionAvailableException; +import org.apache.camel.StreamCache; +import org.apache.camel.WrappedFile; import org.apache.camel.support.DefaultProducer; +import org.springframework.ai.document.Document; import org.springframework.ai.embedding.Embedding; import org.springframework.ai.embedding.EmbeddingModel; import org.springframework.ai.embedding.EmbeddingOptions; @@ -43,7 +54,7 @@ public class SpringAiEmbeddingsProducer extends DefaultProducer { final Message message = exchange.getMessage(); final EmbeddingModel model = getEndpoint().getConfiguration().getEmbeddingModel(); - // Support both single String and List<String> as input + // Support a single text and a List of texts as input List<String> inputTexts; Object body = message.getBody(); boolean batch = body instanceof List; @@ -52,14 +63,10 @@ public class SpringAiEmbeddingsProducer extends DefaultProducer { inputTexts = new ArrayList<>(list.size()); for (int i = 0; i < list.size(); i++) { Object element = list.get(i); - String text = element != null - ? exchange.getContext().getTypeConverter().convertTo(String.class, exchange, element) : null; - if (text == null) { - throw new IllegalArgumentException( - "Element " + i + " of the List body must be text, but was: " - + (element != null ? element.getClass().getName() : "null")); + if (element == null) { + throw new IllegalArgumentException("Element " + i + " of the List body must be text, but was: null"); } - inputTexts.add(text); + inputTexts.add(toText(exchange, element, i)); } if (inputTexts.isEmpty()) { // nothing to embed, so do not call the model @@ -69,14 +76,10 @@ public class SpringAiEmbeddingsProducer extends DefaultProducer { return; } } else { - String str = message.getBody(String.class); - if (str != null) { - inputTexts = List.of(str); - } else { - throw new IllegalArgumentException( - "Message body must be a String or List<String>, but was: " - + (body != null ? body.getClass().getName() : "null")); + if (body == null) { + throw new IllegalArgumentException("Message body must be a String or List<String>, but was: null"); } + inputTexts = List.of(toText(exchange, body, -1)); } final EmbeddingResponse response = model.call(new EmbeddingRequest( @@ -109,4 +112,44 @@ public class SpringAiEmbeddingsProducer extends DefaultProducer { message.setHeader(SpringAiEmbeddingsHeaders.EMBEDDING_METADATA, response.getMetadata()); } } + + /** + * The text to embed of a body, or of an element of a List body: a Spring AI Document as its text, and text, bytes, + * a stream, a Reader or a file read as text. Any other value, such as a Map or a POJO, fails as an + * InvalidPayloadException instead of being embedded as its toString(). + * + * @param index the index of the element in a List body, or -1 for the body itself + */ + private static String toText(Exchange exchange, Object value, int index) + throws InvalidPayloadException, NoTypeConversionAvailableException { + String what = index < 0 ? "" : "the element at index " + index + " of the List body is " + typeOf(value) + "; "; + if (value instanceof Document document) { + if (document.getText() == null) { + throw new InvalidPayloadException( + exchange, String.class, exchange.getMessage(), + what + "spring-ai-embeddings embeds the text of a Document, but the Document has no text"); + } + return document.getText(); + } + if (!isReadableAsText(value)) { + throw new InvalidPayloadException( + exchange, String.class, exchange.getMessage(), + what + "spring-ai-embeddings embeds text, bytes, a stream, a Reader, a file or a Spring AI Document;" + + " convert the body to text before the endpoint, such" + + " as with convertBodyTo(String) or a marshal to JSON"); + } + return exchange.getContext().getTypeConverter().mandatoryConvertTo(String.class, exchange, value); + } + + /** A value Camel converts to its text, rather than to the toString() of an object. */ + private static boolean isReadableAsText(Object value) { + return value instanceof CharSequence || value instanceof byte[] || value instanceof ByteBuffer + || value instanceof InputStream || value instanceof Reader || value instanceof StreamCache + || value instanceof ByteArrayOutputStream + || value instanceof File || value instanceof Path || value instanceof WrappedFile; + } + + private static String typeOf(Object value) { + return value == null ? "null" : value.getClass().getName(); + } } diff --git a/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-embeddings/src/test/java/org/apache/camel/component/springai/embeddings/SpringAiEmbeddingsComponentTest.java b/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-embeddings/src/test/java/org/apache/camel/component/springai/embeddings/SpringAiEmbeddingsComponentTest.java index 61472572c4a4..fb2ffc0995b6 100644 --- a/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-embeddings/src/test/java/org/apache/camel/component/springai/embeddings/SpringAiEmbeddingsComponentTest.java +++ b/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-embeddings/src/test/java/org/apache/camel/component/springai/embeddings/SpringAiEmbeddingsComponentTest.java @@ -16,22 +16,29 @@ */ package org.apache.camel.component.springai.embeddings; +import java.io.ByteArrayInputStream; +import java.net.URI; +import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.Arrays; import java.util.List; +import java.util.Map; import java.util.concurrent.CopyOnWriteArrayList; import org.apache.camel.CamelContext; import org.apache.camel.Exchange; +import org.apache.camel.InvalidPayloadException; import org.apache.camel.Message; import org.apache.camel.test.junit6.CamelTestSupport; import org.junit.jupiter.api.Test; +import org.springframework.ai.content.Media; import org.springframework.ai.document.Document; import org.springframework.ai.embedding.Embedding; import org.springframework.ai.embedding.EmbeddingModel; import org.springframework.ai.embedding.EmbeddingRequest; import org.springframework.ai.embedding.EmbeddingResponse; import org.springframework.ai.embedding.EmbeddingResponseMetadata; +import org.springframework.util.MimeTypeUtils; import static org.assertj.core.api.Assertions.assertThat; @@ -157,15 +164,96 @@ public class SpringAiEmbeddingsComponentTest extends CamelTestSupport { } @Test - public void testListOfNonStringsIsConvertedToText() { + public void testListOfTextCarriersIsConvertedToText() { Message result = fluentTemplate.to("spring-ai-embeddings:convert") - .withBody(List.of(42, new StringBuilder("hello"))) + .withBody(List.of(new StringBuilder("hello"), "bytes".getBytes(StandardCharsets.UTF_8), + new Document("document text"))) .request(Message.class); - assertThat(requests).containsExactly(List.of("42", "hello")); + assertThat(requests).containsExactly(List.of("hello", "bytes", "document text")); List<float[]> vectors = result.getBody(List.class); - assertThat(vectors).hasSize(2); - assertThat(result.getHeader(SpringAiEmbeddingsHeaders.INPUT_TEXTS, List.class)).containsExactly("42", "hello"); + assertThat(vectors).hasSize(3); + assertThat(result.getHeader(SpringAiEmbeddingsHeaders.INPUT_TEXTS, List.class)) + .containsExactly("hello", "bytes", "document text"); + } + + @Test + public void testDocumentBodyEmbedsItsText() { + Message result = fluentTemplate.to("spring-ai-embeddings:document") + .withBody(new Document("document text", Map.of("source", "test"))) + .request(Message.class); + + assertThat(requests).containsExactly(List.of("document text")); + assertThat(result.getBody(float[].class)).containsExactly(0.1f, 0.2f, 0.3f, 0.4f, 0.5f); + assertThat(result.getHeader(SpringAiEmbeddingsHeaders.INPUT_TEXT)).isEqualTo("document text"); + } + + @Test + public void testDocumentWithoutTextFails() { + Document media = new Document( + new Media(MimeTypeUtils.IMAGE_PNG, URI.create("https://example.com/image.png")), + Map.of()); + Exchange exchange = template.send("spring-ai-embeddings:media", e -> e.getMessage().setBody(media)); + + assertThat(exchange.getException()) + .isInstanceOf(InvalidPayloadException.class) + .hasMessageContaining("the Document has no text"); + assertThat(requests).isEmpty(); + } + + @Test + public void testStreamBodyIsReadAsText() { + Message result = fluentTemplate.to("spring-ai-embeddings:stream") + .withBody(new ByteArrayInputStream("streamed text".getBytes(StandardCharsets.UTF_8))) + .request(Message.class); + + assertThat(requests).containsExactly(List.of("streamed text")); + assertThat(result.getHeader(SpringAiEmbeddingsHeaders.INPUT_TEXT)).isEqualTo("streamed text"); + } + + @Test + public void testMapBodyFails() { + Exchange exchange = template.send("spring-ai-embeddings:map", + e -> e.getMessage().setBody(Map.of("a", 1))); + + assertThat(exchange.getException()) + .isInstanceOf(InvalidPayloadException.class) + .hasMessageContaining("convert the body to text before the endpoint"); + assertThat(requests).isEmpty(); + } + + @Test + public void testPojoBodyFails() { + Exchange exchange = template.send("spring-ai-embeddings:pojo", + e -> e.getMessage().setBody(new Pojo("hello"))); + + assertThat(exchange.getException()) + .isInstanceOf(InvalidPayloadException.class) + .hasMessageContaining("convert the body to text before the endpoint"); + assertThat(requests).isEmpty(); + } + + @Test + public void testListWithMapElementFails() { + Exchange exchange = template.send("spring-ai-embeddings:listmap", + e -> e.getMessage().setBody(List.of("hello", Map.of("a", 1)))); + + assertThat(exchange.getException()) + .isInstanceOf(InvalidPayloadException.class) + .hasMessageContaining("the element at index 1 of the List body is java.util.") + .hasMessageContaining("convert the body to text before the endpoint"); + assertThat(requests).isEmpty(); + } + + @Test + public void testListWithNumberElementFails() { + Exchange exchange = template.send("spring-ai-embeddings:listnumber", + e -> e.getMessage().setBody(List.of("hello", 42))); + + assertThat(exchange.getException()) + .isInstanceOf(InvalidPayloadException.class) + .hasMessageContaining("the element at index 1 of the List body is java.lang.Integer"); + assertThat(requests).isEmpty(); } @Test @@ -191,4 +279,17 @@ public class SpringAiEmbeddingsComponentTest extends CamelTestSupport { assertThat(result.getHeader(SpringAiEmbeddingsHeaders.INPUT_TEXTS, List.class)).isEmpty(); assertThat(requests).isEmpty(); } + + private static final class Pojo { + private final String text; + + Pojo(String text) { + this.text = text; + } + + @Override + public String toString() { + return "Pojo[text=" + text + "]"; + } + } } diff --git a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc index 7c82ebd18930..df3f57e15866 100644 --- a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc +++ b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc @@ -975,16 +975,21 @@ stream failed after part of the request had already been sent. The JSON of the event is now written before the request is sent, with a `Content-Length` instead of a chunked request body. A body or a header that cannot be written as JSON fails the exchange without sending anything to Splunk. -=== camel-spring-ai-embeddings - a List body always gives a List of vectors +=== camel-spring-ai-embeddings - a List body always gives a List of vectors, and a Map or POJO body fails A `List` body with one text now gives a `List<float[]>` with one vector, as a `List` of more texts does, and sets the `CamelSpringAiEmbeddings` and `CamelSpringAiEmbeddingInputTexts` headers. Before, it gave a single `float[]` and set the `CamelSpringAiEmbedding`, `CamelSpringAiEmbeddingIndex` and `CamelSpringAiEmbeddingInputText` headers, as for a `String` body. A `String` body still gives a single `float[]`. -Each element of a `List` body is now converted to a `String`, and a `null` element fails with an -`IllegalArgumentException`. Before, a `List` of other objects failed with a `ClassCastException` in the embedding -model. An empty `List` now gives an empty `List` without calling the embedding model. +Each element of a `List` body is now read as text, and a `null` element fails with an `IllegalArgumentException`. +Before, a `List` of other objects failed with a `ClassCastException` in the embedding model. An empty `List` now gives +an empty `List` without calling the embedding model. + +The body, and each element of a `List` body, must now be text, bytes, a stream, a `Reader`, a file or a Spring AI +`Document` (its text is embedded). Any other value, such as a `Map`, a POJO or a number, fails the exchange with an +`InvalidPayloadException`. Before, such a body was embedded as its `toString()`, for example `{a=1, b=2}`, and a +`Document` too. Convert the body to text before the endpoint, with `convertBodyTo(String.class)` or a marshal to JSON. === camel-spring-ai-vector-store - DELETE takes the ids from the body first, and the embeddings headers go with a vector body
