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-kamelets.git
The following commit(s) were added to refs/heads/main by this push:
new 9692c9448 CAMEL-25027: langchain4j-ingest-sink: add modality and
contentType (#3085)
9692c9448 is described below
commit 9692c9448dafbbc05e538021505d411132e51937
Author: jiriOndrusek-agent <[email protected]>
AuthorDate: Sun Oct 11 16:30:51 2026 +0200
CAMEL-25027: langchain4j-ingest-sink: add modality and contentType (#3085)
* CAMEL-25027: langchain4j-ingest-sink: add modality and contentType
modality=media hands the payload to the component's media path, which
embeds audio, an image, video or a PDF whole through an embedding model
that declares the matching content type; contentType overrides the MIME
type derived from the document id. Both are forwarded only when set, so
a sink without them keeps working on a Camel without the options. One
Citrus test runs the file source -> sink composition in media mode over
the PNG signature as fixture under an id without extension, the stub
model failing unless the bytes arrive unchanged. Related: CAMEL-25027.
Co-Authored-By: Claude Fable 5.1 <[email protected]>
* CAMEL-25027: langchain4j-ingest-sink: anchor the contentType pattern
JSON Schema patterns match partially, so the unanchored pattern accepted
any value that contained a MIME type, such as one with stray spaces or
surrounding text. Anchored, like the catalog's other patterns. Review
feedback.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---------
Co-authored-by: Jiří Ondrušek <[email protected]>
Co-authored-by: Claude Fable 5.1 <[email protected]>
---
.../langchain4j-ingest-file-source.kamelet.yaml | 5 +-
kamelets/langchain4j-ingest-sink.kamelet.yaml | 41 +++++++++++--
.../langchain4j-ingest-file-source.kamelet.yaml | 5 +-
.../kamelets/langchain4j-ingest-sink.kamelet.yaml | 41 +++++++++++--
...gchain4j-ingest-file-media-route.citrus.it.yaml | 68 ++++++++++++++++++++++
.../langchain4j-ingest-file-media-route.yaml | 68 ++++++++++++++++++++++
6 files changed, 212 insertions(+), 16 deletions(-)
diff --git a/kamelets/langchain4j-ingest-file-source.kamelet.yaml
b/kamelets/langchain4j-ingest-file-source.kamelet.yaml
index 0fdc1434a..c133d9a56 100644
--- a/kamelets/langchain4j-ingest-file-source.kamelet.yaml
+++ b/kamelets/langchain4j-ingest-file-source.kamelet.yaml
@@ -76,8 +76,9 @@ spec:
title: Charset
description: Character set for reading text documents, for example
UTF-8. Leave
unset when the source feeds a parser action
(tika-extract-text-action or
- docling-convert-action), which must receive the raw bytes - a charset
- conversion would corrupt a binary document such as a PDF.
+ docling-convert-action) or the langchain4j-ingest-sink in media
mode, which must
+ receive the raw bytes - a charset conversion would corrupt a binary
document
+ such as a PDF.
type: string
idempotentRepository:
title: Idempotent Repository
diff --git a/kamelets/langchain4j-ingest-sink.kamelet.yaml
b/kamelets/langchain4j-ingest-sink.kamelet.yaml
index 1e43e351a..66b9c6bce 100644
--- a/kamelets/langchain4j-ingest-sink.kamelet.yaml
+++ b/kamelets/langchain4j-ingest-sink.kamelet.yaml
@@ -46,6 +46,11 @@ spec:
version-aware id. The filter options (`includeId`, `excludeId`,
`minDocumentSize`,
`documentFilter`) answer rejected deliveries with a filtered outcome
that never keeps
a dedup claim.
+
+ With `modality` set to `media` the payload (audio, an image, video or a
PDF) is embedded
+ whole, as one vector; the source must deliver raw bytes. A file source
re-polls any
+ document that fails the exchange on every run, so restrict the ids with
`includeId` or
+ the source's `include`.
type: object
properties:
pipelineName:
@@ -61,6 +66,25 @@ spec:
CamelLangChain4jIngestDocumentId exchange property, when set, takes
precedence.
type: string
default: CamelLangChain4jIngestDocumentId
+ modality:
+ title: Modality
+ description: What the payload is; text when unset. `text` is split
into segments and
+ embedded segment by segment; `media` (audio, an image, video or a
PDF) is embedded
+ whole, as one vector, by a model that declares the matching content
type; a text-only
+ model fails the start, a medium the model lacks fails the exchange.
In media mode the
+ splitter options and embeddingBatchSize do not apply,
documentSplitter must not be
+ set, and maxDocumentSize and minDocumentSize count bytes.
+ type: string
+ enum: ["text", "media"]
+ contentType:
+ title: Content Type
+ description: MIME type of a media payload, such as audio/wav or
image/png, handed to
+ the embedding model. When unset, it is derived from the document
id's file extension
+ (Camel's MIME table); a document whose type cannot be determined, or
whose medium the
+ model does not declare, fails the exchange. Only valid with modality
media, else the
+ sink fails to start.
+ type: string
+ pattern: "^[^/ ;]+/[^/ ;]+(;.*)?$"
maxSegmentSize:
title: Max Segment Size
description: Maximum size of one segment, in characters.
@@ -78,9 +102,10 @@ spec:
default: 32
maxDocumentSize:
title: Max Document Size
- description: Maximum size of one document in characters; unset means
no limit. The
- pipeline holds a document in memory whole, so set the cap when the
source can
- deliver oversized payloads. An oversized document fails the exchange
cleanly.
+ description: Maximum size of one document in characters, bytes with
modality media;
+ unset means no limit. The pipeline holds a document in memory whole,
so set the cap
+ when the source can deliver oversized payloads. An oversized
document fails the
+ exchange cleanly.
type: integer
includeId:
title: Include Id
@@ -95,8 +120,9 @@ spec:
type: string
minDocumentSize:
title: Min Document Size
- description: Minimum size of one document in characters; unset means
no minimum. A
- shorter document is answered filtered and releases its dedup claim.
+ description: Minimum size of one document in characters, bytes with
modality media;
+ unset means no minimum. A shorter document is answered filtered and
releases its
+ dedup claim.
type: integer
documentFilter:
title: Document Filter
@@ -107,7 +133,8 @@ spec:
documentSplitter:
title: Document Splitter
description: A DocumentSplitter bean replacing the default recursive
splitting, as a
- `#bean:name` reference; maxSegmentSize and maxOverlapSize are then
ignored.
+ `#bean:name` reference; maxSegmentSize and maxOverlapSize are then
ignored. With
+ modality media the sink fails to start.
type: string
embeddingStore:
title: Embedding Store
@@ -136,6 +163,7 @@ spec:
uri: langchain4j-ingest
parameters:
pipelineName: "{{pipelineName}}"
+ contentType: "{{?contentType}}"
documentFilter: "{{?documentFilter}}"
documentIdHeader: "{{documentIdHeader}}"
documentSplitter: "{{?documentSplitter}}"
@@ -149,3 +177,4 @@ spec:
maxOverlapSize: "{{maxOverlapSize}}"
maxSegmentSize: "{{maxSegmentSize}}"
minDocumentSize: "{{?minDocumentSize}}"
+ modality: "{{?modality}}"
diff --git
a/library/camel-kamelets/src/main/resources/kamelets/langchain4j-ingest-file-source.kamelet.yaml
b/library/camel-kamelets/src/main/resources/kamelets/langchain4j-ingest-file-source.kamelet.yaml
index 0fdc1434a..c133d9a56 100644
---
a/library/camel-kamelets/src/main/resources/kamelets/langchain4j-ingest-file-source.kamelet.yaml
+++
b/library/camel-kamelets/src/main/resources/kamelets/langchain4j-ingest-file-source.kamelet.yaml
@@ -76,8 +76,9 @@ spec:
title: Charset
description: Character set for reading text documents, for example
UTF-8. Leave
unset when the source feeds a parser action
(tika-extract-text-action or
- docling-convert-action), which must receive the raw bytes - a charset
- conversion would corrupt a binary document such as a PDF.
+ docling-convert-action) or the langchain4j-ingest-sink in media
mode, which must
+ receive the raw bytes - a charset conversion would corrupt a binary
document
+ such as a PDF.
type: string
idempotentRepository:
title: Idempotent Repository
diff --git
a/library/camel-kamelets/src/main/resources/kamelets/langchain4j-ingest-sink.kamelet.yaml
b/library/camel-kamelets/src/main/resources/kamelets/langchain4j-ingest-sink.kamelet.yaml
index 1e43e351a..66b9c6bce 100644
---
a/library/camel-kamelets/src/main/resources/kamelets/langchain4j-ingest-sink.kamelet.yaml
+++
b/library/camel-kamelets/src/main/resources/kamelets/langchain4j-ingest-sink.kamelet.yaml
@@ -46,6 +46,11 @@ spec:
version-aware id. The filter options (`includeId`, `excludeId`,
`minDocumentSize`,
`documentFilter`) answer rejected deliveries with a filtered outcome
that never keeps
a dedup claim.
+
+ With `modality` set to `media` the payload (audio, an image, video or a
PDF) is embedded
+ whole, as one vector; the source must deliver raw bytes. A file source
re-polls any
+ document that fails the exchange on every run, so restrict the ids with
`includeId` or
+ the source's `include`.
type: object
properties:
pipelineName:
@@ -61,6 +66,25 @@ spec:
CamelLangChain4jIngestDocumentId exchange property, when set, takes
precedence.
type: string
default: CamelLangChain4jIngestDocumentId
+ modality:
+ title: Modality
+ description: What the payload is; text when unset. `text` is split
into segments and
+ embedded segment by segment; `media` (audio, an image, video or a
PDF) is embedded
+ whole, as one vector, by a model that declares the matching content
type; a text-only
+ model fails the start, a medium the model lacks fails the exchange.
In media mode the
+ splitter options and embeddingBatchSize do not apply,
documentSplitter must not be
+ set, and maxDocumentSize and minDocumentSize count bytes.
+ type: string
+ enum: ["text", "media"]
+ contentType:
+ title: Content Type
+ description: MIME type of a media payload, such as audio/wav or
image/png, handed to
+ the embedding model. When unset, it is derived from the document
id's file extension
+ (Camel's MIME table); a document whose type cannot be determined, or
whose medium the
+ model does not declare, fails the exchange. Only valid with modality
media, else the
+ sink fails to start.
+ type: string
+ pattern: "^[^/ ;]+/[^/ ;]+(;.*)?$"
maxSegmentSize:
title: Max Segment Size
description: Maximum size of one segment, in characters.
@@ -78,9 +102,10 @@ spec:
default: 32
maxDocumentSize:
title: Max Document Size
- description: Maximum size of one document in characters; unset means
no limit. The
- pipeline holds a document in memory whole, so set the cap when the
source can
- deliver oversized payloads. An oversized document fails the exchange
cleanly.
+ description: Maximum size of one document in characters, bytes with
modality media;
+ unset means no limit. The pipeline holds a document in memory whole,
so set the cap
+ when the source can deliver oversized payloads. An oversized
document fails the
+ exchange cleanly.
type: integer
includeId:
title: Include Id
@@ -95,8 +120,9 @@ spec:
type: string
minDocumentSize:
title: Min Document Size
- description: Minimum size of one document in characters; unset means
no minimum. A
- shorter document is answered filtered and releases its dedup claim.
+ description: Minimum size of one document in characters, bytes with
modality media;
+ unset means no minimum. A shorter document is answered filtered and
releases its
+ dedup claim.
type: integer
documentFilter:
title: Document Filter
@@ -107,7 +133,8 @@ spec:
documentSplitter:
title: Document Splitter
description: A DocumentSplitter bean replacing the default recursive
splitting, as a
- `#bean:name` reference; maxSegmentSize and maxOverlapSize are then
ignored.
+ `#bean:name` reference; maxSegmentSize and maxOverlapSize are then
ignored. With
+ modality media the sink fails to start.
type: string
embeddingStore:
title: Embedding Store
@@ -136,6 +163,7 @@ spec:
uri: langchain4j-ingest
parameters:
pipelineName: "{{pipelineName}}"
+ contentType: "{{?contentType}}"
documentFilter: "{{?documentFilter}}"
documentIdHeader: "{{documentIdHeader}}"
documentSplitter: "{{?documentSplitter}}"
@@ -149,3 +177,4 @@ spec:
maxOverlapSize: "{{maxOverlapSize}}"
maxSegmentSize: "{{maxSegmentSize}}"
minDocumentSize: "{{?minDocumentSize}}"
+ modality: "{{?modality}}"
diff --git
a/tests/camel-kamelets-itest/src/test/resources/langchain4j-ingest/langchain4j-ingest-file-media-route.citrus.it.yaml
b/tests/camel-kamelets-itest/src/test/resources/langchain4j-ingest/langchain4j-ingest-file-media-route.citrus.it.yaml
new file mode 100644
index 000000000..907df21db
--- /dev/null
+++
b/tests/camel-kamelets-itest/src/test/resources/langchain4j-ingest/langchain4j-ingest-file-media-route.citrus.it.yaml
@@ -0,0 +1,68 @@
+# ---------------------------------------------------------------------------
+# 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.
+# ---------------------------------------------------------------------------
+
+name: langchain4j-ingest-file-media-route-test
+actions:
+ # drain any request a previous test may have left on the shared server
+ - purge:
+ endpoints:
+ - name: "httpServer"
+ timeout: 100
+ - createVariables:
+ variables:
+ - name: "report.url"
+ value: "http://localhost:${http.server.port}/result/file-media"
+ # a fresh directory per test run, see the file-source test
+ - name: "ingest.dir"
+ value: "ingest-media-citrus:randomNumber(5)"
+
+ # Run the file-source -> sink composition in media mode over a byte fixture
under an id
+ # without extension; the sink's IngestResult is reported back to the test
+ - camel:
+ cli:
+ run:
+ waitForRunningState: false
+ args:
+ - "--dep=camel:groovy,camel:langchain4j-ingest"
+ - "--max-seconds=60"
+ integration:
+ file: "langchain4j-ingest/langchain4j-ingest-file-media-route.yaml"
+ systemProperties:
+ properties:
+ - name: "report.url"
+ value: "${report.url}"
+ - name: "ingest.dir"
+ value: "${ingest.dir}"
+
+ # one vector for the whole file, under the file name as the id; the stub
model has
+ # already failed the exchange if the bytes did not arrive unchanged
+ - http:
+ server: "httpServer"
+ receiveRequest:
+ POST:
+ path: "/result/file-media"
+ type: "plaintext"
+ body:
+ data: "@contains('documentId=img-42, segmentsWritten=1,
outcome=INGESTED')@"
+
+ - http:
+ server: "httpServer"
+ sendResponse:
+ response:
+ status: 200
+ reasonPhrase: "OK"
+ version: "HTTP/1.1"
diff --git
a/tests/camel-kamelets-itest/src/test/resources/langchain4j-ingest/langchain4j-ingest-file-media-route.yaml
b/tests/camel-kamelets-itest/src/test/resources/langchain4j-ingest/langchain4j-ingest-file-media-route.yaml
new file mode 100644
index 000000000..c9244ef04
--- /dev/null
+++
b/tests/camel-kamelets-itest/src/test/resources/langchain4j-ingest/langchain4j-ingest-file-media-route.yaml
@@ -0,0 +1,68 @@
+# ---------------------------------------------------------------------------
+# 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.
+# ---------------------------------------------------------------------------
+
+# The media composition: the file source feeds the sink with modality=media.
The seed
+# route writes the eight-byte PNG signature, whose first byte is invalid
UTF-8, under a
+# name without extension; the source delivers it raw (charset unset, its
default), only
+# the forwarded contentType can type it, and the stub model fails unless the
bytes arrive
+# unchanged - a charset conversion anywhere on the way fails the test instead
of passing.
+- beans:
+ - name: store
+ type: dev.langchain4j.store.embedding.inmemory.InMemoryEmbeddingStore
+ # an in-process stub accepting images only, so the media path is the only
one that can succeed;
+ # it keys doEmbed, the extension point the default embed(request)
delegates to
+ - name: model
+ type: dev.langchain4j.model.embedding.EmbeddingModel
+ scriptLanguage: groovy
+ script: |
+ def expected = "iVBORw0KGgo=" // base64 of the PNG signature 89 50 4E
47 0D 0A 1A 0A
+ return [
+ supportedContentTypes: {
+ [dev.langchain4j.data.message.ContentType.IMAGE] as Set
+ },
+ doEmbed: { request ->
+ def image = request.inputs()[0].contents()
+ .find { it instanceof
dev.langchain4j.data.message.ImageContent }
+ def received = image?.image()?.base64Data()
+ if (received != expected) {
+ throw new IllegalStateException("model received " +
received + ", expected " + expected)
+ }
+
dev.langchain4j.model.embedding.response.EmbeddingResponse.builder()
+
.embeddings([dev.langchain4j.data.embedding.Embedding.from([1f, 0f, 0f, 1f] as
float[])])
+ .build()
+ }
+ ] as dev.langchain4j.model.embedding.EmbeddingModel
+- route:
+ id: seed-media
+ from:
+ uri: "timer:seed"
+ parameters:
+ repeatCount: 1
+ steps:
+ - setBody:
+ groovy: "[0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A] as
byte[]"
+ - to: "file:{{ingest.dir}}?fileName=img-42"
+- route:
+ id: langchain4j-ingest-file-media-route
+ from:
+ uri: "kamelet:langchain4j-ingest-file-source?directory={{ingest.dir}}"
+ steps:
+ - to:
"kamelet:langchain4j-ingest-sink?modality=media&contentType=image/png&embeddingStore=#bean:store&embeddingModel=#bean:model"
+ - log: "INGEST ${body}"
+ - convertBodyTo:
+ type: "java.lang.String"
+ - to: "{{report.url}}"