This is an automated email from the ASF dual-hosted git repository.
jamesnetherton pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/camel-quarkus.git
The following commit(s) were added to refs/heads/main by this push:
new ae4ef2f4de Fixes #9280. Add filter.* and modality=media to
langchain4j-ingest
ae4ef2f4de is described below
commit ae4ef2f4dec34977d7f46f1b4abe130b59878070
Author: jiriOndrusek-agent <[email protected]>
AuthorDate: Tue Oct 27 12:28:37 2026 +0100
Fixes #9280. Add filter.* and modality=media to langchain4j-ingest
filter.include-id, filter.exclude-id, filter.min-document-size and
filter.document-filter go to the sink Kamelet and are enforced by the
component (CAMEL-24753), for configured and @Ingest pipelines alike.
With a parser, the id patterns are also checked before the parse, so
an excluded document is never parsed. A missing document-filter bean
fails the start naming it. On a directory pipeline, a filtered file's
key is held in memory rather than in a named, possibly persistent
register, so a changed filter applies to the file after a restart.
modality=media, with an optional content-type, embeds each document
whole through the sink's media path (CAMEL-25027); IngestPipeline gets
modality() and contentType(). A configured pipeline's modality and
content-type are checked at build time, while the splitter sizes and
batch size, which do not apply, are not; the builder setters reject
bad values.
modality, contentType and the filter options replace the neutral
values #9152 pins on the sink, so app-wide camel.kamelet.<id>.*
properties still cannot change them. The file source's charset has no
neutral value, so parser and media pipelines drop an app-wide one
before the raw bytes are read.
Co-authored-by: Claude Fable 5.1 <[email protected]>
---
.../reference/extensions/langchain4j-ingest.adoc | 105 +++++++++-
.../deployment/Langchain4jIngestProcessor.java | 37 +++-
.../deployment/IngestBuilderFilterTypoTest.java | 57 ++++++
.../deployment/IngestMediaParserConflictTest.java | 71 +++++++
.../IngestMediaSplitterSettingsTest.java | 90 +++++++++
.../IngestMissingDocumentFilterTest.java | 44 +++++
.../deployment/IngestModalityConfigTest.java | 50 +++++
.../IngestPipelineModalityValidationTest.java | 56 ++++++
.../runtime/src/main/doc/usage.adoc | 39 +++-
.../langchain4j/ingest/IngestBuildTimeConfig.java | 33 +++-
.../langchain4j/ingest/IngestPipeline.java | 82 +++++++-
.../component/langchain4j/ingest/IngestRoutes.java | 219 ++++++++++++++++++---
.../langchain4j/ingest/IngestRunTimeConfig.java | 41 ++++
.../it/DeterministicAudioEmbeddingModel.java | 72 +++++++
.../langchain4j/ingest/it/IngestItProducers.java | 38 ++++
.../langchain4j/ingest/it/IngestResource.java | 59 ++++++
.../langchain4j/ingest/it/ItBuilderPipelines.java | 14 ++
.../src/main/resources/application.properties | 44 ++++-
.../ingest/it/Langchain4jIngestAudioIT.java | 23 +++
.../ingest/it/Langchain4jIngestAudioTest.java | 126 ++++++++++++
.../ingest/it/Langchain4jIngestFilterIT.java | 23 +++
.../ingest/it/Langchain4jIngestFilterTest.java | 148 ++++++++++++++
.../ingest/it/Langchain4jIngestTikaTest.java | 10 +-
23 files changed, 1433 insertions(+), 48 deletions(-)
diff --git
a/docs/modules/ROOT/pages/reference/extensions/langchain4j-ingest.adoc
b/docs/modules/ROOT/pages/reference/extensions/langchain4j-ingest.adoc
index 201059975b..697fc8e707 100644
--- a/docs/modules/ROOT/pages/reference/extensions/langchain4j-ingest.adoc
+++ b/docs/modules/ROOT/pages/reference/extensions/langchain4j-ingest.adoc
@@ -62,7 +62,7 @@
quarkus.camel.langchain4j.ingest.products.embedding-model=my-model
Each file is read as UTF-8 text — unless a `parser` turns binary formats into
text first, see below — split into overlapping segments (`max-segment-size`,
`max-overlap-size`; or a `DocumentSplitter` CDI bean named by
`document-splitter`, which then replaces the sizes), embedded in batches
(`embedding-batch-size`, 32 by default) and written to the store; a document is
held in memory whole while it is split, so `max-document-size` can cap it in
characters — 0, the default, means no limit, [...]
`embedding-store` and `embedding-model` name CDI beans and may be omitted when
the application has exactly one of each.
Every segment carries `camel_ingest_pipeline` and `camel_ingest_document_id`
metadata, so retrieval can cite which document an answer came from (up to
3.39.x the keys were `camel_quarkus_pipeline` and `camel_quarkus_document_id` —
see the migration guide).
-Apart from `enabled` and the `source.*` settings shown here, properties are
fixed at build time.
+Apart from `enabled`, `filter.++*++` and the `source.++*++` settings shown
here, properties are fixed at build time.
A pipeline declared through runtime properties alone — nothing but a
`source.directory`, say — is invisible to build-time validation; its checks,
including the clash with an equally named `@Ingest` pipeline, report at startup
instead.
Each pipeline is composed from the catalog's `langchain4j-ingest-file-source`,
parser action and `langchain4j-ingest-sink` Kamelets: its route
`langchain4j-ingest-<name>` plus one route per Kamelet it uses,
`langchain4j-ingest-<name>-source` (directory pipelines), `-parser` (with a
`parser`) and `-sink`.
The pipelines always load these Kamelets from the classpath, whatever
`camel.component.kamelet.location` says; shipping an equally named Kamelet
replaces internal steps — including the identity capture — and is advanced
surgery.
@@ -77,6 +77,24 @@ Replace and delete arrive with the synchronising engine in a
later release.
What is remembered is which documents were already ingested — the idempotent
repository below.
====
+[id="extensions-langchain4j-ingest-usage-filtering-deliveries"]
+=== Filtering deliveries
+
+Per pipeline, `filter.include-id` and `filter.exclude-id` take comma-separated
Ant-style patterns matched against the document id — exclusion wins, and a
rejected delivery never takes the sink's dedup claim nor reads the body.
+Matching is case-sensitive, and an id starting with `/` (an absolute path,
say) only matches a pattern that starts with `/` too.
+With a `parser` they are checked before the parse, so an excluded document is
never parsed nor sent to Docling Serve.
+`filter.min-document-size` rejects documents too short to carry retrievable
content, and `filter.document-filter` names a Camel `Predicate` bean evaluated
with the body available.
+With a `parser` these two see the parsed text, so a document they reject has
been parsed first — with `docling`, by the remote Docling Serve.
+A rejected delivery is answered with a `filtered` outcome; on a directory
pipeline it is logged at DEBUG and the file is not read again until it changes,
its key held in memory even with a persistent register: after a restart, a
changed `filter.++*++` applies to it.
+An `@Ingest` pipeline is filtered the same way: the builder has no filter
methods, and the `filter.*` properties apply to it too.
+
+[source,properties]
+----
+quarkus.camel.langchain4j.ingest.products.filter.include-id=**/*.pdf,**/*.md
+quarkus.camel.langchain4j.ingest.products.filter.exclude-id=**/draft-*
+quarkus.camel.langchain4j.ingest.products.filter.min-document-size=100
+----
+
[id="extensions-langchain4j-ingest-usage-the-idempotent-repository"]
=== The idempotent repository
@@ -171,6 +189,27 @@ A missing parser extension fails the build naming the
artifact, the same way a m
The Java twin is `IngestPipeline.parser("tika")`, and a consumer-fed pipeline
parses the same way — the payload the consumer delivers goes to the parser
first.
With a parser, `max-document-size` also rejects a raw payload larger than the
cap (in bytes) before it reaches the parser, on top of capping the extracted
text (in characters).
+[id="extensions-langchain4j-ingest-usage-ingesting-media-images-audio-video-and-pdf"]
+=== Ingesting media: images, audio, video and PDF
+
+`modality=media` embeds each document whole, as one vector, instead of reading
it as text: the bytes go to a multimodal embedding model as audio, an image,
video or a PDF, told apart by the MIME type, and the vector is stored with a
placeholder segment whose text is the document id, stamped with the usual
identity metadata, so retrieval cites it like any other.
+The model must declare the matching content type in its
`supportedContentTypes()`: the pipeline fails to start with a text-only model,
and a document whose medium the model does not declare fails before its bytes
are read.
+`parser` and `document-splitter` must not be set, the splitter sizes and
`embedding-batch-size` do not apply, and `max-document-size` and
`filter.min-document-size` count bytes.
+
+[source,properties]
+----
+quarkus.camel.langchain4j.ingest.photos.source.directory=/var/data/photos
+quarkus.camel.langchain4j.ingest.photos.modality=media
+quarkus.camel.langchain4j.ingest.photos.embedding-model=my-multimodal-model
+quarkus.camel.langchain4j.ingest.photos.embedding-store=photos
+quarkus.camel.langchain4j.ingest.photos.filter.include-id=**/*.png,**/*.jpg
+----
+
+The MIME type handed to the model comes from `content-type` or, when unset,
from the file extension through Camel's MIME table (wav, mp3, flac, ogg, opus,
m4a, aac and aiff for audio; png, jpg, gif and webp for images; mp4, mov and
webm for video; pdf); a file whose type cannot be determined fails the
exchange, and `content-type` without `modality=media` is rejected like the
parser and splitter conflicts.
+On a directory pipeline such a file — `notes.txt`, `Thumbs.db` — fails again
on every poll, so restrict the ids with `filter.include-id`, as above: it is
checked before the media type.
+Images are embedded today by several LangChain4j providers; no provider embeds
audio out of the box yet, so an audio model is a CDI bean of your own that
declares `AUDIO` — typically a client of an audio embedding service — and
either way the same bean embeds the query image or clip at retrieval time.
+The Java twin is `IngestPipeline.modality("media")`, and a consumer-fed
pipeline embeds the delivered payload the same way.
+
[id="extensions-langchain4j-ingest-usage-other-sources-declared-in-java"]
=== Other sources, declared in Java
@@ -282,6 +321,28 @@ value needs its extension on the classpath:
`camel-quarkus-tika` or
| `string`
|
+a|icon:lock[title=Fixed at build time]
[[quarkus-camel-langchain4j-ingest-pipeline-name-modality]]`link:#quarkus-camel-langchain4j-ingest-pipeline-name-modality[quarkus.camel.langchain4j.ingest."pipeline-name".modality]`
+
+What the consumed payload is. `text`, the default, is read as a String, split
into
+segments and embedded segment by segment. `media` is read as bytes and
embedded whole,
+as one vector: audio, an image, video or a PDF, told apart by the MIME type,
each
+needing an embedding model that declares the matching content type — the
pipeline
+fails to start with a text-only model. In media mode `parser` and
`document-splitter`
+must not be set, the splitter sizes and `embedding-batch-size` do not apply,
and
+`max-document-size` and `filter.min-document-size` count bytes.
+| `string`
+| `text`
+
+a|icon:lock[title=Fixed at build time]
[[quarkus-camel-langchain4j-ingest-pipeline-name-content-type]]`link:#quarkus-camel-langchain4j-ingest-pipeline-name-content-type[quarkus.camel.langchain4j.ingest."pipeline-name".content-type]`
+
+MIME type of a media payload, such as `audio/wav` or `image/png`, handed to the
+embedding model. When not set, it is derived from the document id's file
extension
+through 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`: set without it, the pipeline is rejected.
+| `string`
+|
+
a|icon:lock[title=Fixed at build time]
[[quarkus-camel-langchain4j-ingest-pipeline-name-embedding-store]]`link:#quarkus-camel-langchain4j-ingest-pipeline-name-embedding-store[quarkus.camel.langchain4j.ingest."pipeline-name".embedding-store]`
Name of the `EmbeddingStore` bean to write to. When not set, the only one
present is
@@ -320,10 +381,11 @@ together against the provider's token limits.
a|icon:lock[title=Fixed at build time]
[[quarkus-camel-langchain4j-ingest-pipeline-name-max-document-size]]`link:#quarkus-camel-langchain4j-ingest-pipeline-name-max-document-size[quarkus.camel.langchain4j.ingest."pipeline-name".max-document-size]`
-Maximum size of one document in characters, applied to the text about to be
split;
-0, the default, means no limit. The pipeline holds a document in memory whole,
so the
-cap is the protection against oversized — on a consumer-fed pipeline,
attacker-sized —
-payloads. An oversized document fails the exchange cleanly.
+Maximum size of one document in characters, applied to the text about to be
split, or
+in bytes with `modality=media`; 0, the default, means no limit. The pipeline
holds a
+document in memory whole, so the cap is the protection against oversized — on a
+consumer-fed pipeline, attacker-sized — payloads. An oversized document fails
the
+exchange cleanly.
| `int`
| `0`
@@ -386,6 +448,39 @@ uses the file name, and one consuming from a component
uses the
`CamelIngestDocumentId` is still read as a fallback).
| `string`
|
+
+a|
[[quarkus-camel-langchain4j-ingest-pipeline-name-filter-include-id]]`link:#quarkus-camel-langchain4j-ingest-pipeline-name-filter-include-id[quarkus.camel.langchain4j.ingest."pipeline-name".filter.include-id]`
+
+Comma-separated Ant-style patterns the document id must match to be ingested,
+for example `++**++/++*++.pdf,++**++/++*++.md` (`++*++.pdf` alone misses files
in
+subdirectories). Matching is case-sensitive, and an id starting with `/` needs
a
+pattern starting with `/`. A non-matching delivery is rejected before the
sink's dedup
+claim, without reading the body and, with a `parser`, before the parse. When
not set,
+every id is accepted.
+| `string`
+|
+
+a|
[[quarkus-camel-langchain4j-ingest-pipeline-name-filter-exclude-id]]`link:#quarkus-camel-langchain4j-ingest-pipeline-name-filter-exclude-id[quarkus.camel.langchain4j.ingest."pipeline-name".filter.exclude-id]`
+
+Comma-separated Ant-style patterns for document ids to skip, for example
+`++**++/draft-++*++`. Exclusion wins over `include-id`.
+| `string`
+|
+
+a|
[[quarkus-camel-langchain4j-ingest-pipeline-name-filter-min-document-size]]`link:#quarkus-camel-langchain4j-ingest-pipeline-name-filter-min-document-size[quarkus.camel.langchain4j.ingest."pipeline-name".filter.min-document-size]`
+
+Minimum size of one document in characters, in bytes with `modality=media`; 0,
the
+default, means no minimum. A shorter document is answered `filtered` instead
of being
+written.
+| `int`
+| `0`
+
+a|
[[quarkus-camel-langchain4j-ingest-pipeline-name-filter-document-filter]]`link:#quarkus-camel-langchain4j-ingest-pipeline-name-filter-document-filter[quarkus.camel.langchain4j.ingest."pipeline-name".filter.document-filter]`
+
+Name of a Camel `Predicate` bean deciding whether a delivery is ingested,
+evaluated with the body available. Looked up by name only.
+| `string`
+|
|===
[.configuration-legend]
diff --git
a/extensions/langchain4j-ingest/deployment/src/main/java/org/apache/camel/quarkus/component/langchain4j/ingest/deployment/Langchain4jIngestProcessor.java
b/extensions/langchain4j-ingest/deployment/src/main/java/org/apache/camel/quarkus/component/langchain4j/ingest/deployment/Langchain4jIngestProcessor.java
index 7455e47c44..d90226088f 100644
---
a/extensions/langchain4j-ingest/deployment/src/main/java/org/apache/camel/quarkus/component/langchain4j/ingest/deployment/Langchain4jIngestProcessor.java
+++
b/extensions/langchain4j-ingest/deployment/src/main/java/org/apache/camel/quarkus/component/langchain4j/ingest/deployment/Langchain4jIngestProcessor.java
@@ -290,16 +290,18 @@ class Langchain4jIngestProcessor {
void validatePipelines(IngestBuildTimeConfig config,
BuildProducer<ValidationErrorBuildItem> validationErrors) {
for (Map.Entry<String, IngestBuildTimeConfig.PipelineBuildTimeConfig>
entry : config.pipelines().entrySet()) {
IngestBuildTimeConfig.PipelineBuildTimeConfig pipeline =
entry.getValue();
+ // as in the component, the splitter sizes and the batch size are
not validated where they do not apply
+ boolean media = "media".equalsIgnoreCase(pipeline.modality());
- if (pipeline.maxSegmentSize() <= 0 || pipeline.maxOverlapSize() < 0
- || pipeline.maxOverlapSize() >= pipeline.maxSegmentSize())
{
+ if (!media && (pipeline.maxSegmentSize() <= 0 ||
pipeline.maxOverlapSize() < 0
+ || pipeline.maxOverlapSize() >=
pipeline.maxSegmentSize())) {
validationErrors.produce(new ValidationErrorBuildItem(new
ConfigurationException(
"Ingestion pipeline '" + entry.getKey() + "':
max-segment-size must be positive and "
+ "max-overlap-size must be smaller than it
(got " + pipeline.maxSegmentSize()
+ " / " + pipeline.maxOverlapSize() + ")")));
}
- if (pipeline.embeddingBatchSize() < 1) {
+ if (!media && pipeline.embeddingBatchSize() < 1) {
validationErrors.produce(new ValidationErrorBuildItem(new
ConfigurationException(
"Ingestion pipeline '" + entry.getKey() + "':
embedding-batch-size must be positive (got "
+ pipeline.embeddingBatchSize() + ")")));
@@ -310,6 +312,35 @@ class Langchain4jIngestProcessor {
"Ingestion pipeline '" + entry.getKey() + "':
max-document-size must not be negative, 0 "
+ "meaning no limit (got " +
pipeline.maxDocumentSize() + ")")));
}
+
+ // the component and IngestRoutes check these at startup; a
configured pipeline fails the build instead
+ if (!media && !"text".equalsIgnoreCase(pipeline.modality())) {
+ validationErrors.produce(new ValidationErrorBuildItem(new
ConfigurationException(
+ "Ingestion pipeline '" + entry.getKey() + "': modality
must be 'text' or 'media' (got '"
+ + pipeline.modality() + "')")));
+ }
+ if (media && pipeline.parser().isPresent()) {
+ validationErrors.produce(new ValidationErrorBuildItem(new
ConfigurationException(
+ "Ingestion pipeline '" + entry.getKey() + "' sets
modality 'media' together with a parser. A "
+ + "media document is embedded whole and never
parsed; remove one of them.")));
+ }
+ if (media && pipeline.documentSplitter().isPresent()) {
+ validationErrors.produce(new ValidationErrorBuildItem(new
ConfigurationException(
+ "Ingestion pipeline '" + entry.getKey() + "' sets
modality 'media' together with a "
+ + "document-splitter. A media document is
embedded whole and never split; remove one "
+ + "of them.")));
+ }
+ if (!media && pipeline.contentType().isPresent()) {
+ validationErrors.produce(new ValidationErrorBuildItem(new
ConfigurationException(
+ "Ingestion pipeline '" + entry.getKey() + "':
content-type only applies to modality 'media' "
+ + "(got '" + pipeline.contentType().get() +
"'). Set modality=media, or remove it.")));
+ }
+ // the component drops the parameters after ';', so a value with
nothing before them would silently be unset
+ if (pipeline.contentType().isPresent() &&
pipeline.contentType().get().split(";", 2)[0].isBlank()) {
+ validationErrors.produce(new ValidationErrorBuildItem(new
ConfigurationException(
+ "Ingestion pipeline '" + entry.getKey() + "':
content-type must not be blank or only parameters "
+ + "(got '" + pipeline.contentType().get() +
"')")));
+ }
}
}
}
diff --git
a/extensions/langchain4j-ingest/deployment/src/test/java/org/apache/camel/quarkus/component/langchain4j/ingest/deployment/IngestBuilderFilterTypoTest.java
b/extensions/langchain4j-ingest/deployment/src/test/java/org/apache/camel/quarkus/component/langchain4j/ingest/deployment/IngestBuilderFilterTypoTest.java
new file mode 100644
index 0000000000..3733962a24
--- /dev/null
+++
b/extensions/langchain4j-ingest/deployment/src/test/java/org/apache/camel/quarkus/component/langchain4j/ingest/deployment/IngestBuilderFilterTypoTest.java
@@ -0,0 +1,57 @@
+/*
+ * 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.quarkus.component.langchain4j.ingest.deployment;
+
+import io.quarkus.test.QuarkusExtensionTest;
+import jakarta.enterprise.context.ApplicationScoped;
+import org.apache.camel.quarkus.component.langchain4j.ingest.Ingest;
+import org.apache.camel.quarkus.component.langchain4j.ingest.IngestPipeline;
+import org.apache.camel.quarkus.component.langchain4j.ingest.Source;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.RegisterExtension;
+
+/**
+ * filter.* meant for an {@code @Ingest} pipeline under a misspelled name
declares a pipeline of its own; the start
+ * fails naming the Java-declared pipelines, not just the missing directory.
+ */
+class IngestBuilderFilterTypoTest {
+
+ @RegisterExtension
+ static final QuarkusExtensionTest CONFIG = new QuarkusExtensionTest()
+ .withApplicationRoot(jar -> jar.addClasses(Pipelines.class,
TestEmbeddingBeans.class))
+
.overrideConfigKey("quarkus.camel.langchain4j.ingest.datasheet.filter.exclude-id",
"secret-*")
+ .assertException(t -> ValidationTestSupport.assertFailure(t,
+ "Ingestion pipeline 'datasheet' has no source.directory,
and no @Ingest method declares it",
+ "(declared in Java: [datasheets])"));
+
+ @Test
+ void startMustFail() {
+ Assertions.fail("The application start was expected to fail");
+ }
+
+ @ApplicationScoped
+ public static class Pipelines {
+
+ @Ingest("datasheets")
+ IngestPipeline datasheets() {
+ return IngestPipeline.from(Source.file("target/filter-typo-docs"))
+ .embeddingStore("store")
+ .embeddingModel("model");
+ }
+ }
+}
diff --git
a/extensions/langchain4j-ingest/deployment/src/test/java/org/apache/camel/quarkus/component/langchain4j/ingest/deployment/IngestMediaParserConflictTest.java
b/extensions/langchain4j-ingest/deployment/src/test/java/org/apache/camel/quarkus/component/langchain4j/ingest/deployment/IngestMediaParserConflictTest.java
new file mode 100644
index 0000000000..bdd55c54f2
--- /dev/null
+++
b/extensions/langchain4j-ingest/deployment/src/test/java/org/apache/camel/quarkus/component/langchain4j/ingest/deployment/IngestMediaParserConflictTest.java
@@ -0,0 +1,71 @@
+/*
+ * 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.quarkus.component.langchain4j.ingest.deployment;
+
+import io.quarkus.test.QuarkusExtensionTest;
+import jakarta.enterprise.context.ApplicationScoped;
+import jakarta.enterprise.inject.Produces;
+import jakarta.inject.Named;
+import jakarta.inject.Singleton;
+import org.apache.camel.Component;
+import org.apache.camel.component.file.FileComponent;
+import org.apache.camel.quarkus.component.langchain4j.ingest.Ingest;
+import org.apache.camel.quarkus.component.langchain4j.ingest.IngestPipeline;
+import org.apache.camel.quarkus.component.langchain4j.ingest.Source;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.RegisterExtension;
+
+/**
+ * A media document is embedded whole and never parsed, so modality media
together with a parser
+ * fails the start.
+ */
+class IngestMediaParserConflictTest {
+
+ @RegisterExtension
+ static final QuarkusExtensionTest CONFIG = new QuarkusExtensionTest()
+ .withApplicationRoot(jar -> jar.addClasses(Pipelines.class,
TestEmbeddingBeans.class))
+ .assertException(t -> ValidationTestSupport.assertFailure(t,
+ "Ingestion pipeline 'docs' sets modality 'media' together
with a parser"));
+
+ @Test
+ void startMustFail() {
+ Assertions.fail("The application start was expected to fail");
+ }
+
+ @ApplicationScoped
+ public static class Pipelines {
+
+ // stands in for camel-quarkus-tika, absent here, so the start gets
past the parser
+ // presence check to the conflict
+ @Produces
+ @Singleton
+ @Named("tika")
+ Component tika() {
+ return new FileComponent();
+ }
+
+ @Ingest("docs")
+ IngestPipeline docs() {
+ return IngestPipeline.from(Source.file("target/media-parser-docs"))
+ .parser("tika")
+ .modality("media")
+ .embeddingStore("store")
+ .embeddingModel("model");
+ }
+ }
+}
diff --git
a/extensions/langchain4j-ingest/deployment/src/test/java/org/apache/camel/quarkus/component/langchain4j/ingest/deployment/IngestMediaSplitterSettingsTest.java
b/extensions/langchain4j-ingest/deployment/src/test/java/org/apache/camel/quarkus/component/langchain4j/ingest/deployment/IngestMediaSplitterSettingsTest.java
new file mode 100644
index 0000000000..b93604b295
--- /dev/null
+++
b/extensions/langchain4j-ingest/deployment/src/test/java/org/apache/camel/quarkus/component/langchain4j/ingest/deployment/IngestMediaSplitterSettingsTest.java
@@ -0,0 +1,90 @@
+/*
+ * 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.quarkus.component.langchain4j.ingest.deployment;
+
+import java.util.List;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+import dev.langchain4j.data.embedding.Embedding;
+import dev.langchain4j.data.message.ContentType;
+import dev.langchain4j.data.segment.TextSegment;
+import dev.langchain4j.model.embedding.EmbeddingModel;
+import dev.langchain4j.model.output.Response;
+import dev.langchain4j.store.embedding.EmbeddingStore;
+import dev.langchain4j.store.embedding.inmemory.InMemoryEmbeddingStore;
+import io.quarkus.test.QuarkusExtensionTest;
+import jakarta.enterprise.context.ApplicationScoped;
+import jakarta.enterprise.inject.Produces;
+import jakarta.inject.Inject;
+import jakarta.inject.Singleton;
+import org.apache.camel.CamelContext;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.RegisterExtension;
+
+/**
+ * A media document is embedded whole, so the splitter sizes and the batch
size do not apply: settings copied from a
+ * text pipeline are not validated, neither at build time nor by the
component, and the pipeline starts.
+ */
+class IngestMediaSplitterSettingsTest {
+
+ @RegisterExtension
+ static final QuarkusExtensionTest CONFIG = new QuarkusExtensionTest()
+ .withApplicationRoot(jar -> jar.addClasses(MediaBeans.class))
+
.overrideConfigKey("quarkus.camel.langchain4j.ingest.clips.source.directory",
"target/media-clips")
+
.overrideConfigKey("quarkus.camel.langchain4j.ingest.clips.modality", "media")
+ // both invalid for text: an overlap above the default segment
size of 500, and no batch
+
.overrideConfigKey("quarkus.camel.langchain4j.ingest.clips.max-overlap-size",
"600")
+
.overrideConfigKey("quarkus.camel.langchain4j.ingest.clips.embedding-batch-size",
"0");
+
+ @Inject
+ CamelContext context;
+
+ @Test
+ void mediaPipelineStarts() {
+ Assertions.assertNotNull(context.getRoute("langchain4j-ingest-clips"));
+ }
+
+ @ApplicationScoped
+ public static class MediaBeans {
+
+ @Produces
+ @Singleton
+ EmbeddingStore<TextSegment> store() {
+ return new InMemoryEmbeddingStore<>();
+ }
+
+ @Produces
+ @Singleton
+ EmbeddingModel model() {
+ return new EmbeddingModel() {
+ @Override
+ public Response<List<Embedding>> embedAll(List<TextSegment>
segments) {
+ return Response.from(segments.stream()
+ .map(segment -> Embedding.from(new float[] { 1f }))
+ .collect(Collectors.toList()));
+ }
+
+ @Override
+ public Set<ContentType> supportedContentTypes() {
+ return Set.of(ContentType.AUDIO);
+ }
+ };
+ }
+ }
+}
diff --git
a/extensions/langchain4j-ingest/deployment/src/test/java/org/apache/camel/quarkus/component/langchain4j/ingest/deployment/IngestMissingDocumentFilterTest.java
b/extensions/langchain4j-ingest/deployment/src/test/java/org/apache/camel/quarkus/component/langchain4j/ingest/deployment/IngestMissingDocumentFilterTest.java
new file mode 100644
index 0000000000..a966d830c0
--- /dev/null
+++
b/extensions/langchain4j-ingest/deployment/src/test/java/org/apache/camel/quarkus/component/langchain4j/ingest/deployment/IngestMissingDocumentFilterTest.java
@@ -0,0 +1,44 @@
+/*
+ * 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.quarkus.component.langchain4j.ingest.deployment;
+
+import io.quarkus.test.QuarkusExtensionTest;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.RegisterExtension;
+
+/**
+ * The document filter is resolved by name when the route starts, like the
idempotent repository, and a missing bean
+ * fails the start naming the pipeline and the bean.
+ */
+class IngestMissingDocumentFilterTest {
+
+ @RegisterExtension
+ static final QuarkusExtensionTest CONFIG = new QuarkusExtensionTest()
+ .withApplicationRoot(jar ->
jar.addClasses(TestEmbeddingBeans.class))
+
.overrideConfigKey("quarkus.camel.langchain4j.ingest.docs.embedding-store",
"store")
+
.overrideConfigKey("quarkus.camel.langchain4j.ingest.docs.embedding-model",
"model")
+
.overrideConfigKey("quarkus.camel.langchain4j.ingest.docs.source.directory",
"target/filter-docs")
+
.overrideConfigKey("quarkus.camel.langchain4j.ingest.docs.filter.document-filter",
"no-such-filter")
+ .assertException(t -> ValidationTestSupport.assertFailure(t,
+ "Ingestion pipeline 'docs' references document filter
'no-such-filter'", "no such bean exists"));
+
+ @Test
+ void startMustFail() {
+ Assertions.fail("The application start was expected to fail");
+ }
+}
diff --git
a/extensions/langchain4j-ingest/deployment/src/test/java/org/apache/camel/quarkus/component/langchain4j/ingest/deployment/IngestModalityConfigTest.java
b/extensions/langchain4j-ingest/deployment/src/test/java/org/apache/camel/quarkus/component/langchain4j/ingest/deployment/IngestModalityConfigTest.java
new file mode 100644
index 0000000000..69c84b6769
--- /dev/null
+++
b/extensions/langchain4j-ingest/deployment/src/test/java/org/apache/camel/quarkus/component/langchain4j/ingest/deployment/IngestModalityConfigTest.java
@@ -0,0 +1,50 @@
+/*
+ * 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.quarkus.component.langchain4j.ingest.deployment;
+
+import io.quarkus.test.QuarkusExtensionTest;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.RegisterExtension;
+
+/** The modality and content-type rules are validated at build time on the
configuration path. */
+class IngestModalityConfigTest {
+
+ @RegisterExtension
+ static final QuarkusExtensionTest CONFIG = new QuarkusExtensionTest()
+ .withApplicationRoot(jar -> {
+ })
+
.overrideConfigKey("quarkus.camel.langchain4j.ingest.unknown.modality", "sound")
+
.overrideConfigKey("quarkus.camel.langchain4j.ingest.parsed.modality", "media")
+
.overrideConfigKey("quarkus.camel.langchain4j.ingest.parsed.parser", "tika")
+
.overrideConfigKey("quarkus.camel.langchain4j.ingest.split.modality", "MEDIA")
+
.overrideConfigKey("quarkus.camel.langchain4j.ingest.split.document-splitter",
"splitter")
+
.overrideConfigKey("quarkus.camel.langchain4j.ingest.typed.content-type",
"audio/wav")
+
.overrideConfigKey("quarkus.camel.langchain4j.ingest.params.modality", "media")
+
.overrideConfigKey("quarkus.camel.langchain4j.ingest.params.content-type", ";
codecs=opus")
+ .assertException(t -> ValidationTestSupport.assertFailure(t,
+ "'unknown': modality must be 'text' or 'media' (got
'sound')",
+ "'parsed' sets modality 'media' together with a parser",
+ "'split' sets modality 'media' together with a
document-splitter",
+ "'typed': content-type only applies to modality 'media'
(got 'audio/wav')",
+ "'params': content-type must not be blank or only
parameters (got '; codecs=opus')"));
+
+ @Test
+ void buildMustFail() {
+ Assertions.fail("The build was expected to fail");
+ }
+}
diff --git
a/extensions/langchain4j-ingest/deployment/src/test/java/org/apache/camel/quarkus/component/langchain4j/ingest/deployment/IngestPipelineModalityValidationTest.java
b/extensions/langchain4j-ingest/deployment/src/test/java/org/apache/camel/quarkus/component/langchain4j/ingest/deployment/IngestPipelineModalityValidationTest.java
new file mode 100644
index 0000000000..f0d706ef79
--- /dev/null
+++
b/extensions/langchain4j-ingest/deployment/src/test/java/org/apache/camel/quarkus/component/langchain4j/ingest/deployment/IngestPipelineModalityValidationTest.java
@@ -0,0 +1,56 @@
+/*
+ * 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.quarkus.component.langchain4j.ingest.deployment;
+
+import org.apache.camel.quarkus.component.langchain4j.ingest.IngestPipeline;
+import org.apache.camel.quarkus.component.langchain4j.ingest.Source;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/** The builder rejects a bad modality or a blank content type eagerly, like
the configuration path. */
+class IngestPipelineModalityValidationTest {
+
+ @Test
+ void unknownOrMissingModalityRejected() {
+ for (String modality : new String[] { "sound", null }) {
+ IllegalArgumentException e =
assertThrows(IllegalArgumentException.class,
+ () ->
IngestPipeline.from(Source.file("target/x")).modality(modality));
+ assertTrue(e.getMessage().contains("modality must be 'text' or
'media'"), e.getMessage());
+ }
+ }
+
+ @Test
+ void blankContentTypeRejected() {
+ // parameters alone would be dropped by the component, leaving the
content type unset
+ for (String contentType : new String[] { " ", null, "; codecs=opus" })
{
+ IllegalArgumentException e =
assertThrows(IllegalArgumentException.class,
+ () ->
IngestPipeline.from(Source.file("target/x")).contentType(contentType));
+ assertTrue(e.getMessage().contains("content-type must not be blank
or only parameters"), e.getMessage());
+ }
+ }
+
+ @Test
+ void validValuesAccepted() {
+ assertDoesNotThrow(() -> IngestPipeline.from(Source.file("target/x"))
+ .modality("text")
+ .modality("Media")
+ .contentType("audio/wav"));
+ }
+}
diff --git a/extensions/langchain4j-ingest/runtime/src/main/doc/usage.adoc
b/extensions/langchain4j-ingest/runtime/src/main/doc/usage.adoc
index 532a4eb694..ef5383976e 100644
--- a/extensions/langchain4j-ingest/runtime/src/main/doc/usage.adoc
+++ b/extensions/langchain4j-ingest/runtime/src/main/doc/usage.adoc
@@ -13,7 +13,7 @@
quarkus.camel.langchain4j.ingest.products.embedding-model=my-model
Each file is read as UTF-8 text — unless a `parser` turns binary formats into
text first, see below — split into overlapping segments (`max-segment-size`,
`max-overlap-size`; or a `DocumentSplitter` CDI bean named by
`document-splitter`, which then replaces the sizes), embedded in batches
(`embedding-batch-size`, 32 by default) and written to the store; a document is
held in memory whole while it is split, so `max-document-size` can cap it in
characters — 0, the default, means no limit, [...]
`embedding-store` and `embedding-model` name CDI beans and may be omitted when
the application has exactly one of each.
Every segment carries `camel_ingest_pipeline` and `camel_ingest_document_id`
metadata, so retrieval can cite which document an answer came from (up to
3.39.x the keys were `camel_quarkus_pipeline` and `camel_quarkus_document_id` —
see the migration guide).
-Apart from `enabled` and the `source.*` settings shown here, properties are
fixed at build time.
+Apart from `enabled`, `filter.++*++` and the `source.++*++` settings shown
here, properties are fixed at build time.
A pipeline declared through runtime properties alone — nothing but a
`source.directory`, say — is invisible to build-time validation; its checks,
including the clash with an equally named `@Ingest` pipeline, report at startup
instead.
Each pipeline is composed from the catalog's `langchain4j-ingest-file-source`,
parser action and `langchain4j-ingest-sink` Kamelets: its route
`langchain4j-ingest-<name>` plus one route per Kamelet it uses,
`langchain4j-ingest-<name>-source` (directory pipelines), `-parser` (with a
`parser`) and `-sink`.
The pipelines always load these Kamelets from the classpath, whatever
`camel.component.kamelet.location` says; shipping an equally named Kamelet
replaces internal steps — including the identity capture — and is advanced
surgery.
@@ -28,6 +28,23 @@ Replace and delete arrive with the synchronising engine in a
later release.
What is remembered is which documents were already ingested — the idempotent
repository below.
====
+=== Filtering deliveries
+
+Per pipeline, `filter.include-id` and `filter.exclude-id` take comma-separated
Ant-style patterns matched against the document id — exclusion wins, and a
rejected delivery never takes the sink's dedup claim nor reads the body.
+Matching is case-sensitive, and an id starting with `/` (an absolute path,
say) only matches a pattern that starts with `/` too.
+With a `parser` they are checked before the parse, so an excluded document is
never parsed nor sent to Docling Serve.
+`filter.min-document-size` rejects documents too short to carry retrievable
content, and `filter.document-filter` names a Camel `Predicate` bean evaluated
with the body available.
+With a `parser` these two see the parsed text, so a document they reject has
been parsed first — with `docling`, by the remote Docling Serve.
+A rejected delivery is answered with a `filtered` outcome; on a directory
pipeline it is logged at DEBUG and the file is not read again until it changes,
its key held in memory even with a persistent register: after a restart, a
changed `filter.++*++` applies to it.
+An `@Ingest` pipeline is filtered the same way: the builder has no filter
methods, and the `filter.*` properties apply to it too.
+
+[source,properties]
+----
+quarkus.camel.langchain4j.ingest.products.filter.include-id=**/*.pdf,**/*.md
+quarkus.camel.langchain4j.ingest.products.filter.exclude-id=**/draft-*
+quarkus.camel.langchain4j.ingest.products.filter.min-document-size=100
+----
+
=== The idempotent repository
A directory pipeline registers what it ingested, keyed on the file's path,
modification time and size: unchanged files are skipped, edited files re-ingest
(their previous segments remain, see the note above).
@@ -120,6 +137,26 @@ A missing parser extension fails the build naming the
artifact, the same way a m
The Java twin is `IngestPipeline.parser("tika")`, and a consumer-fed pipeline
parses the same way — the payload the consumer delivers goes to the parser
first.
With a parser, `max-document-size` also rejects a raw payload larger than the
cap (in bytes) before it reaches the parser, on top of capping the extracted
text (in characters).
+=== Ingesting media: images, audio, video and PDF
+
+`modality=media` embeds each document whole, as one vector, instead of reading
it as text: the bytes go to a multimodal embedding model as audio, an image,
video or a PDF, told apart by the MIME type, and the vector is stored with a
placeholder segment whose text is the document id, stamped with the usual
identity metadata, so retrieval cites it like any other.
+The model must declare the matching content type in its
`supportedContentTypes()`: the pipeline fails to start with a text-only model,
and a document whose medium the model does not declare fails before its bytes
are read.
+`parser` and `document-splitter` must not be set, the splitter sizes and
`embedding-batch-size` do not apply, and `max-document-size` and
`filter.min-document-size` count bytes.
+
+[source,properties]
+----
+quarkus.camel.langchain4j.ingest.photos.source.directory=/var/data/photos
+quarkus.camel.langchain4j.ingest.photos.modality=media
+quarkus.camel.langchain4j.ingest.photos.embedding-model=my-multimodal-model
+quarkus.camel.langchain4j.ingest.photos.embedding-store=photos
+quarkus.camel.langchain4j.ingest.photos.filter.include-id=**/*.png,**/*.jpg
+----
+
+The MIME type handed to the model comes from `content-type` or, when unset,
from the file extension through Camel's MIME table (wav, mp3, flac, ogg, opus,
m4a, aac and aiff for audio; png, jpg, gif and webp for images; mp4, mov and
webm for video; pdf); a file whose type cannot be determined fails the
exchange, and `content-type` without `modality=media` is rejected like the
parser and splitter conflicts.
+On a directory pipeline such a file — `notes.txt`, `Thumbs.db` — fails again
on every poll, so restrict the ids with `filter.include-id`, as above: it is
checked before the media type.
+Images are embedded today by several LangChain4j providers; no provider embeds
audio out of the box yet, so an audio model is a CDI bean of your own that
declares `AUDIO` — typically a client of an audio embedding service — and
either way the same bean embeds the query image or clip at retrieval time.
+The Java twin is `IngestPipeline.modality("media")`, and a consumer-fed
pipeline embeds the delivered payload the same way.
+
=== Other sources, declared in Java
Any Camel consumer can feed a pipeline — the roughly 300 components, each with
its own options and its own documentation.
diff --git
a/extensions/langchain4j-ingest/runtime/src/main/java/org/apache/camel/quarkus/component/langchain4j/ingest/IngestBuildTimeConfig.java
b/extensions/langchain4j-ingest/runtime/src/main/java/org/apache/camel/quarkus/component/langchain4j/ingest/IngestBuildTimeConfig.java
index 98742f8d64..985c966bf6 100644
---
a/extensions/langchain4j-ingest/runtime/src/main/java/org/apache/camel/quarkus/component/langchain4j/ingest/IngestBuildTimeConfig.java
+++
b/extensions/langchain4j-ingest/runtime/src/main/java/org/apache/camel/quarkus/component/langchain4j/ingest/IngestBuildTimeConfig.java
@@ -46,6 +46,9 @@ public interface IngestBuildTimeConfig {
/** Mirrors the {@code @WithDefault} below, which can only carry a
literal; 0 means no limit. */
int DEFAULT_MAX_DOCUMENT_SIZE = 0;
+ /** Mirrors the {@code @WithDefault} below, which can only carry a
literal. */
+ String DEFAULT_MODALITY = "text";
+
/**
* Ingestion pipelines by name.
*/
@@ -70,6 +73,27 @@ public interface IngestBuildTimeConfig {
*/
Optional<String> parser();
+ /**
+ * What the consumed payload is. `text`, the default, is read as a
String, split into
+ * segments and embedded segment by segment. `media` is read as bytes
and embedded whole,
+ * as one vector: audio, an image, video or a PDF, told apart by the
MIME type, each
+ * needing an embedding model that declares the matching content type
— the pipeline
+ * fails to start with a text-only model. In media mode `parser` and
`document-splitter`
+ * must not be set, the splitter sizes and `embedding-batch-size` do
not apply, and
+ * `max-document-size` and `filter.min-document-size` count bytes.
+ */
+ @WithDefault("text")
+ String modality();
+
+ /**
+ * MIME type of a media payload, such as `audio/wav` or `image/png`,
handed to the
+ * embedding model. When not set, it is derived from the document id's
file extension
+ * through 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`: set without it, the pipeline is rejected.
+ */
+ Optional<String> contentType();
+
/**
* Name of the `EmbeddingStore` bean to write to. When not set, the
only one present is
* used.
@@ -105,10 +129,11 @@ public interface IngestBuildTimeConfig {
int embeddingBatchSize();
/**
- * Maximum size of one document in characters, applied to the text
about to be split;
- * 0, the default, means no limit. The pipeline holds a document in
memory whole, so the
- * cap is the protection against oversized — on a consumer-fed
pipeline, attacker-sized —
- * payloads. An oversized document fails the exchange cleanly.
+ * Maximum size of one document in characters, applied to the text
about to be split, or
+ * in bytes with `modality=media`; 0, the default, means no limit. The
pipeline holds a
+ * document in memory whole, so the cap is the protection against
oversized — on a
+ * consumer-fed pipeline, attacker-sized — payloads. An oversized
document fails the
+ * exchange cleanly.
*/
@WithDefault("0")
int maxDocumentSize();
diff --git
a/extensions/langchain4j-ingest/runtime/src/main/java/org/apache/camel/quarkus/component/langchain4j/ingest/IngestPipeline.java
b/extensions/langchain4j-ingest/runtime/src/main/java/org/apache/camel/quarkus/component/langchain4j/ingest/IngestPipeline.java
index 42492f393e..4f0b847736 100644
---
a/extensions/langchain4j-ingest/runtime/src/main/java/org/apache/camel/quarkus/component/langchain4j/ingest/IngestPipeline.java
+++
b/extensions/langchain4j-ingest/runtime/src/main/java/org/apache/camel/quarkus/component/langchain4j/ingest/IngestPipeline.java
@@ -19,6 +19,8 @@ package org.apache.camel.quarkus.component.langchain4j.ingest;
import java.util.Optional;
import java.util.Set;
+import
org.apache.camel.quarkus.component.langchain4j.ingest.IngestRunTimeConfig.PipelineRunTimeConfig.FilterRunTimeConfig;
+
/**
* A pipeline declared in Java rather than in configuration, returned from an
{@link Ingest}
* method. Every property has a configuration twin, and both paths share the
same runtime.
@@ -28,10 +30,36 @@ public final class IngestPipeline {
/** The values {@link #parser(String)} and the {@code parser}
configuration property accept. */
public static final Set<String> SUPPORTED_PARSERS = Parser.NAMES;
+ /** The filters of a pipeline configuration says nothing about: every
delivery is accepted. */
+ static final FilterRunTimeConfig NO_FILTER = new FilterRunTimeConfig() {
+
+ @Override
+ public Optional<String> includeId() {
+ return Optional.empty();
+ }
+
+ @Override
+ public Optional<String> excludeId() {
+ return Optional.empty();
+ }
+
+ @Override
+ public int minDocumentSize() {
+ return 0;
+ }
+
+ @Override
+ public Optional<String> documentFilter() {
+ return Optional.empty();
+ }
+ };
+
private final Source source;
private String embeddingStoreName;
private String embeddingModelName;
private String parser;
+ private String modality = IngestBuildTimeConfig.DEFAULT_MODALITY;
+ private String contentType;
private int maxSegmentSize =
IngestBuildTimeConfig.DEFAULT_MAX_SEGMENT_SIZE;
private int maxOverlapSize =
IngestBuildTimeConfig.DEFAULT_MAX_OVERLAP_SIZE;
private int embeddingBatchSize =
IngestBuildTimeConfig.DEFAULT_EMBEDDING_BATCH_SIZE;
@@ -100,8 +128,8 @@ public final class IngestPipeline {
}
/**
- * Maximum size of one document in characters; 0, the default, means no
limit. The twin of
- * the {@code max-document-size} configuration property.
+ * Maximum size of one document in characters, in bytes with {@code
modality("media")}; 0, the
+ * default, means no limit. The twin of the {@code max-document-size}
configuration property.
*/
public IngestPipeline maxDocumentSize(int maxDocumentSize) {
// the same rule the configuration path is held to at build time
@@ -123,6 +151,36 @@ public final class IngestPipeline {
return this;
}
+ /**
+ * What the consumed payload is: {@code text}, the default, is split and
embedded segment by
+ * segment; {@code media} (audio, an image, video or a PDF, told apart by
the MIME type) is
+ * embedded whole, as one vector, by a model that declares the matching
content type. The
+ * twin of the {@code modality} configuration property.
+ */
+ public IngestPipeline modality(String modality) {
+ // the same rule the configuration path is held to at build time
+ if (!"text".equalsIgnoreCase(modality) &&
!"media".equalsIgnoreCase(modality)) {
+ throw new IllegalArgumentException("modality must be 'text' or
'media' (got '" + modality + "')");
+ }
+ this.modality = modality;
+ return this;
+ }
+
+ /**
+ * MIME type of a media payload, such as {@code audio/wav}; unset, it is
derived from the
+ * document id's file extension. The twin of the {@code content-type}
configuration property.
+ */
+ public IngestPipeline contentType(String contentType) {
+ // the same rule the configuration path is held to at build time: the
component drops the
+ // parameters after ';', so a value with nothing before them would
silently be unset
+ if (contentType == null || contentType.split(";", 2)[0].isBlank()) {
+ throw new IllegalArgumentException(
+ "content-type must not be blank or only parameters (got '"
+ contentType + "')");
+ }
+ this.contentType = contentType;
+ return this;
+ }
+
String sourceType() {
return source.type();
}
@@ -143,6 +201,14 @@ public final class IngestPipeline {
return Optional.ofNullable(parser);
}
+ String modality() {
+ return modality;
+ }
+
+ Optional<String> contentType() {
+ return Optional.ofNullable(contentType);
+ }
+
int maxSegmentSize() {
return maxSegmentSize;
}
@@ -163,8 +229,11 @@ public final class IngestPipeline {
return Optional.ofNullable(documentSplitterName);
}
- /** The configuration view, so a builder pipeline reuses every
configuration path verbatim. */
- IngestRunTimeConfig.PipelineRunTimeConfig asRunTimeConfig() {
+ /**
+ * The configuration view, so a builder pipeline reuses the configuration
paths verbatim. Its
+ * filter is the configured one, or none: the builder has no filter API.
+ */
+ IngestRunTimeConfig.PipelineRunTimeConfig
asRunTimeConfig(IngestRunTimeConfig.PipelineRunTimeConfig configured) {
IngestRunTimeConfig.PipelineRunTimeConfig.SourceRunTimeConfig
sourceConfig = source.asRunTimeConfig();
return new IngestRunTimeConfig.PipelineRunTimeConfig() {
@@ -177,6 +246,11 @@ public final class IngestPipeline {
public SourceRunTimeConfig source() {
return sourceConfig;
}
+
+ @Override
+ public FilterRunTimeConfig filter() {
+ return configured == null ? NO_FILTER : configured.filter();
+ }
};
}
}
diff --git
a/extensions/langchain4j-ingest/runtime/src/main/java/org/apache/camel/quarkus/component/langchain4j/ingest/IngestRoutes.java
b/extensions/langchain4j-ingest/runtime/src/main/java/org/apache/camel/quarkus/component/langchain4j/ingest/IngestRoutes.java
index 0c895937b2..46e8699a09 100644
---
a/extensions/langchain4j-ingest/runtime/src/main/java/org/apache/camel/quarkus/component/langchain4j/ingest/IngestRoutes.java
+++
b/extensions/langchain4j-ingest/runtime/src/main/java/org/apache/camel/quarkus/component/langchain4j/ingest/IngestRoutes.java
@@ -39,9 +39,12 @@ import jakarta.enterprise.inject.literal.NamedLiteral;
import jakarta.inject.Inject;
import org.apache.camel.CamelContextAware;
import org.apache.camel.Exchange;
+import org.apache.camel.ExchangePropertyKey;
import org.apache.camel.Expression;
+import org.apache.camel.Predicate;
import org.apache.camel.Processor;
import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.component.file.GenericFile;
import org.apache.camel.component.langchain4j.ingest.IngestResult;
import org.apache.camel.component.langchain4j.ingest.LangChain4jIngest;
import org.apache.camel.component.langchain4j.ingest.LangChain4jIngestHeaders;
@@ -50,6 +53,9 @@ import org.apache.camel.spi.IdempotentRepository;
import org.apache.camel.support.builder.ExpressionBuilder;
import org.apache.camel.support.builder.PredicateBuilder;
import
org.apache.camel.support.processor.idempotent.MemoryIdempotentRepository;
+import org.apache.camel.support.service.ServiceHelper;
+import org.apache.camel.support.service.ServiceSupport;
+import org.apache.camel.util.AntPathMatcher;
import org.apache.camel.util.URISupport;
import org.jboss.logging.Logger;
@@ -123,6 +129,13 @@ public class IngestRoutes extends RouteBuilder {
LOG.infof("Ingestion pipeline '%s' is disabled", name);
continue;
}
+ // runtime keys alone name it and none is its source: most likely
filter.* meant for an
+ // @Ingest pipeline under a misspelled name
+ if (pipeline == null && !builderDeclared.isEmpty() &&
runtime.source().directory().isEmpty()) {
+ throw new IllegalStateException("Ingestion pipeline '" + name
+ "' has no source.directory, and no"
+ + " @Ingest method declares it (declared in Java: " +
new TreeSet<>(builderDeclared)
+ + "). Fix the name, or set
quarkus.camel.langchain4j.ingest." + name + ".source.directory");
+ }
// a consumer URI says "consume from this"; its absence says "read
that directory"
String uri = pipeline == null ? null :
pipeline.source().uri().orElse(null);
@@ -140,7 +153,9 @@ public class IngestRoutes extends RouteBuilder {
pipeline == null ?
IngestBuildTimeConfig.DEFAULT_EMBEDDING_BATCH_SIZE :
pipeline.embeddingBatchSize(),
pipeline == null ?
IngestBuildTimeConfig.DEFAULT_MAX_DOCUMENT_SIZE : pipeline.maxDocumentSize(),
pipeline == null ? null :
pipeline.documentSplitter().orElse(null),
- pipeline == null ? null : pipeline.parser().orElse(null));
+ pipeline == null ? null : pipeline.parser().orElse(null),
+ pipeline == null ? IngestBuildTimeConfig.DEFAULT_MODALITY
: pipeline.modality(),
+ pipeline == null ? null :
pipeline.contentType().orElse(null));
}
for (IngestBuilderPipelines.Entry entry : builderPipelines.entries()) {
@@ -182,7 +197,7 @@ public class IngestRoutes extends RouteBuilder {
LOG.infof("Ingestion pipeline '%s' (builder) is disabled", name);
return;
}
- // enabled is the one thing configuration may say about a builder
pipeline; anything about
+ // enabled and filter.* are what configuration may say about a builder
pipeline; anything about
// its source would be quietly overruled by the @Ingest method, so it
is an error instead
// (source.recursive cannot be told apart from its default, so it
alone goes undetected -
// Source.recursive() is its builder twin)
@@ -197,7 +212,7 @@ public class IngestRoutes extends RouteBuilder {
IngestPipeline definition = builderPipelines.definition(entry);
String uri = "file".equals(definition.sourceType()) ? null :
definition.sourceUri();
- compositionRoute(name, uri, definition.asRunTimeConfig(),
+ compositionRoute(name, uri, definition.asRunTimeConfig(external),
resolveStore(name,
definition.embeddingStoreName().orElse(null)),
resolveModel(name,
definition.embeddingModelName().orElse(null)),
definition.maxSegmentSize(),
@@ -205,7 +220,9 @@ public class IngestRoutes extends RouteBuilder {
definition.embeddingBatchSize(),
definition.maxDocumentSize(),
definition.documentSplitterName().orElse(null),
- definition.parser().orElse(null));
+ definition.parser().orElse(null),
+ definition.modality(),
+ definition.contentType().orElse(null));
}
/**
@@ -216,7 +233,7 @@ public class IngestRoutes extends RouteBuilder {
IngestRunTimeConfig.PipelineRunTimeConfig runtime,
EmbeddingStore<TextSegment> store, EmbeddingModel model,
int maxSegmentSize, int maxOverlapSize, int embeddingBatchSize,
int maxDocumentSize,
- String documentSplitterName, String parser) {
+ String documentSplitterName, String parser, String modality,
String contentType) {
// the name is substituted into Kamelet URIs and registry references,
so both declaration
// styles are held to one charset; @Ingest names are already checked
at build time
@@ -224,6 +241,15 @@ public class IngestRoutes extends RouteBuilder {
throw new IllegalStateException(
"Ingestion pipeline name '" + name + "' may only contain
letters, digits, '.', '_' and '-'");
}
+ // the component validates modality and content type; that a media
document is never
+ // parsed is a rule of this composition, where the parser action sits
before the sink.
+ // A configured pipeline fails the build on both already
+ boolean media = "media".equalsIgnoreCase(modality);
+ if (media && parser != null) {
+ throw new IllegalStateException("Ingestion pipeline '" + name
+ + "' sets modality 'media' together with a parser. A media
document is embedded whole and"
+ + " never parsed; remove one of them.");
+ }
String storeRef = bindInstance(name, "store", store);
String modelRef = bindInstance(name, "model", model);
String documentId = runtime == null ? null :
runtime.source().documentId().orElse(null);
@@ -241,17 +267,35 @@ public class IngestRoutes extends RouteBuilder {
sink.put("maxSegmentSize", String.valueOf(maxSegmentSize));
sink.put("maxOverlapSize", String.valueOf(maxOverlapSize));
sink.put("embeddingBatchSize", String.valueOf(embeddingBatchSize));
- sink.put("modality", "text");
- sink.put("contentType", "");
+ // lower-cased, as the Kamelet declares it, rather than relying on a
case-insensitive enum conversion
+ sink.put("modality", modality.toLowerCase(Locale.ROOT));
+ sink.put("contentType", contentType == null ? "" : contentType);
// 0, no limit, is the endpoint's default too
sink.put("maxDocumentSize", String.valueOf(maxDocumentSize));
- sink.put("minDocumentSize", "0");
if (documentSplitterName != null) {
sink.put("documentSplitter", "#bean:" + documentSplitterName);
}
- sink.put("includeId", "");
- sink.put("excludeId", "");
- sink.put("documentFilter", "#bean:" + bindInstance(name,
"document-filter", PredicateBuilder.constant(true)));
+ // filter.* comes from configuration for both declaration styles - a
builder pipeline's view
+ // delegates to it, the builder having no filter API - and the
component enforces it
+ IngestRunTimeConfig.PipelineRunTimeConfig.FilterRunTimeConfig filter =
runtime == null
+ ? IngestPipeline.NO_FILTER
+ : runtime.filter();
+ String includeId = filter.includeId().orElse("");
+ String excludeId = filter.excludeId().orElse("");
+ sink.put("includeId", includeId);
+ sink.put("excludeId", excludeId);
+ sink.put("minDocumentSize", String.valueOf(filter.minDocumentSize()));
+ String documentFilter = filter.documentFilter().orElse(null);
+ // checked up front, like the idempotent repository: the Kamelet's
property binding would
+ // fail naming neither the pipeline nor the property
+ if (documentFilter != null
+ &&
getContext().getRegistry().lookupByNameAndType(documentFilter, Predicate.class)
== null) {
+ throw new IllegalStateException("Ingestion pipeline '" + name + "'
references document filter '"
+ + documentFilter + "' but no such bean exists");
+ }
+ sink.put("documentFilter", "#bean:" + (documentFilter != null
+ ? documentFilter
+ : bindInstance(name, "document-filter",
PredicateBuilder.constant(true))));
sink.put("embeddingStore", "#bean:" + storeRef);
sink.put("embeddingModel", "#bean:" + modelRef);
@@ -269,28 +313,38 @@ public class IngestRoutes extends RouteBuilder {
source.put("recursive",
String.valueOf(runtime.source().recursive()));
// the file consumer's own default poll delay
source.put("delay", "500");
- if (parser == null) {
- // text is read as UTF-8; a parser receives the raw bytes
instead - the format is
- // its business, and a charset conversion would corrupt a
binary document
+ if (parser == null && !media) {
+ // text is read as UTF-8; a parser or a media model receives
the raw bytes instead -
+ // the format is its business, and a charset conversion would
corrupt a binary document
source.put("charset", "UTF-8");
}
source.put("idempotentRepository", "#bean:" + repositoryRef);
ProcessorDefinition<?> route = from(kameletUri(name,
FILE_SOURCE_KAMELET, "source", source))
.routeId(id(name));
+ if (parser != null || media) {
+ // the file endpoint has no neutral charset to pin, so an
application-wide
+ // camel.kamelet.langchain4j-ingest-file-source.charset still
reaches it; dropped
+ // before any step reads the file, it can neither fail nor
silently corrupt the bytes
+ route = route.process(IngestRoutes::dropCharset);
+ }
if (documentId != null) {
// override the source's file-name default; captured before
any further step
route = route.setHeader(LangChain4jIngestHeaders.DOCUMENT_ID,
documentIdExpression(documentId));
}
// no duplicate pre-check: the source register already filtered
duplicates out
- route = parseSteps(route, name, parser, maxDocumentSize, true,
null);
+ route = parseSteps(route, name, parser, maxDocumentSize, true,
null, includeId, excludeId);
// the file consumer discards the reply and the source register
already keeps the
// same file version from being ingested twice, so no repository
goes to the sink.
// The discarded reply would hide an empty outcome, so it is
logged: warned with a
// parser, since a parse to nothing typically means a missing Tika
parser module or an
- // image-only document, and the file's register key is committed
+ // image-only document, and the file's register key is committed.
A filtered one is
+ // logged at DEBUG, its key kept in memory only
route.to(kameletUri(name, SINK_KAMELET, "sink",
sink)).process(exchange -> {
IngestResult result =
exchange.getMessage().getBody(IngestResult.class);
+ if (result != null && result.outcome() ==
IngestResult.Outcome.FILTERED) {
+ fileFiltered(exchange, name, result.documentId());
+ }
if (result == null || result.outcome() !=
IngestResult.Outcome.EMPTY) {
return;
}
@@ -332,7 +386,7 @@ public class IngestRoutes extends RouteBuilder {
IdempotentRepository register = repositoryRef == null
? null
:
getContext().getRegistry().lookupByNameAndType(repositoryRef,
IdempotentRepository.class);
- route = parseSteps(route, name, parser, maxDocumentSize, false,
register);
+ route = parseSteps(route, name, parser, maxDocumentSize, false,
register, includeId, excludeId);
if (repositoryRef != null) {
// deduplication by document id happens inside the sink's
producer: a duplicate
// is answered SKIPPED, a blank delivery releases its claim
@@ -344,13 +398,13 @@ public class IngestRoutes extends RouteBuilder {
}
/**
- * The optional parse stage: the id guard, the advisory duplicate check
and the raw-size
- * guard, then the parser action Kamelet, which captures the document id
from the canonical
- * header into the exchange property before the parse. The route is
returned unchanged when
- * the pipeline has no parser.
+ * The optional parse stage: the id guard, the advisory id-pattern and
duplicate checks and the
+ * raw-size guard, then the parser action Kamelet, which captures the
document id from the
+ * canonical header into the exchange property before the parse. The route
is returned
+ * unchanged when the pipeline has no parser.
*/
private static ProcessorDefinition<?> parseSteps(ProcessorDefinition<?>
route, String name, String parser,
- int maxDocumentSize, boolean directory, IdempotentRepository
register) {
+ int maxDocumentSize, boolean directory, IdempotentRepository
register, String includeId, String excludeId) {
if (parser == null) {
return route;
}
@@ -366,6 +420,29 @@ public class IngestRoutes extends RouteBuilder {
+ " the parse - a parsed document must not supply its
own identity");
}
});
+ String[] include = patterns(includeId);
+ String[] exclude = patterns(excludeId);
+ if (include != null || exclude != null) {
+ // advisory too: the sink's id patterns, applied before the parse
so an excluded
+ // document is never parsed, nor fails the parse or the size guard
on every poll.
+ // patterns() and this match mirror the private parsePatterns and
idAccepted of the
+ // component's LangChain4jIngestProducer, which stay authoritative
- keep them in step
+ route = route.choice()
+ .when(exchange -> {
+ String id =
exchange.getMessage().getHeader(documentIdHeader, String.class);
+ return AntPathMatcher.INSTANCE.anyMatch(exclude, id)
+ || (include != null &&
!AntPathMatcher.INSTANCE.anyMatch(include, id));
+ })
+ .process(exchange -> {
+ String id =
exchange.getMessage().getHeader(documentIdHeader, String.class);
+ if (directory) {
+ fileFiltered(exchange, name, id);
+ }
+ exchange.getMessage().setBody(new IngestResult(name,
id, 0, IngestResult.Outcome.FILTERED));
+ })
+ .stop()
+ .end();
+ }
if (register != null) {
// advisory: a known duplicate is answered SKIPPED before the
(possibly remote) parse
// is paid for; the sink producer's eager claim stays
authoritative, so a duplicate
@@ -434,6 +511,37 @@ public class IngestRoutes extends RouteBuilder {
};
}
+ /** Comma-separated id patterns, parsed as the component parses them; null
when none remain. */
+ private static String[] patterns(String value) {
+ String[] patterns = Arrays.stream(value.split(","))
+ .map(String::trim)
+ .filter(pattern -> !pattern.isEmpty())
+ .toArray(String[]::new);
+ return patterns.length == 0 ? null : patterns;
+ }
+
+ /**
+ * A directory pipeline discards the reply, so a filtered document is
logged instead; a named register does not
+ * keep its file's key, see {@link SourceRegister}.
+ */
+ private static void fileFiltered(Exchange exchange, String name, String
documentId) {
+ LOG.debugf("Ingestion pipeline '%s': document '%s' was filtered out;
it is not read again until the file"
+ + " changes or the application restarts", name, documentId);
+ String key = exchange.getProperty(Exchange.FILE_IDEMPOTENT_KEY,
String.class);
+ if (key != null &&
exchange.getContext().getRegistry().lookupByNameAndType(id(name, "register"),
+ IdempotentRepository.class) instanceof SourceRegister
register) {
+ register.filtered(key);
+ }
+ }
+
+ /** As if the file consumer had no charset: a {@code GenericFile} body
then converts to the file's own bytes. */
+ private static void dropCharset(Exchange exchange) {
+ exchange.removeProperty(ExchangePropertyKey.CHARSET_NAME);
+ if (exchange.getMessage().getBody() instanceof GenericFile<?> file) {
+ file.setCharset(null);
+ }
+ }
+
/**
* Built through {@code createQueryString} rather than concatenated, so no
Kamelet property can be injected.
* Every value travels as a {@code RAW(...)} token: {@code
createQueryString} leaves those unencoded and the
@@ -481,7 +589,7 @@ public class IngestRoutes extends RouteBuilder {
* checked up front, with the configuration-level message), an
auto-created in-memory register
* bound under the configured name, or - for a directory pipeline naming
none - a generated
* built-in one, sized above the file endpoint's default so eviction does
not re-ingest large
- * directories.
+ * directories. A directory pipeline's named register is wrapped in a
{@link SourceRegister}.
*/
private String repositoryRef(String name,
IngestRunTimeConfig.PipelineRunTimeConfig runtime, boolean directory) {
String repositoryName = runtime == null ? null :
runtime.source().idempotentRepository().orElse(null);
@@ -499,14 +607,14 @@ public class IngestRoutes extends RouteBuilder {
throw new IllegalStateException("Ingestion pipeline '" +
name
+ "' references idempotent repository '" +
repositoryName + "' but no such bean exists");
}
- getContext().getRegistry().bind(repositoryName,
-
MemoryIdempotentRepository.memoryIdempotentRepository(DEFAULT_REGISTER_CAPACITY));
+ repository =
MemoryIdempotentRepository.memoryIdempotentRepository(DEFAULT_REGISTER_CAPACITY);
+ getContext().getRegistry().bind(repositoryName, repository);
} else {
// a CDI-produced repository does not pass through the
registry's bind hook, so a
// CamelContextAware implementation would otherwise run
contextless
CamelContextAware.trySetCamelContext(repository, getContext());
}
- return repositoryName;
+ return directory ? bindInstance(name, "register", new
SourceRegister(repository)) : repositoryName;
}
if (directory) {
String ref = id(name, "register");
@@ -618,4 +726,63 @@ public class IngestRoutes extends RouteBuilder {
}
return handles.get(0).get();
}
+
+ /**
+ * A directory pipeline's named register, which may be persistent. The key
of a file the pipeline answers
+ * filtered is held in memory instead: the file is skipped while the
application runs and checked again after a
+ * restart, so a changed {@code filter.*} applies to it. Releasing the key
would read the file on every poll.
+ */
+ private static final class SourceRegister extends ServiceSupport
implements IdempotentRepository {
+
+ private final IdempotentRepository register;
+ private final IdempotentRepository filtered =
MemoryIdempotentRepository
+ .memoryIdempotentRepository(DEFAULT_REGISTER_CAPACITY);
+
+ SourceRegister(IdempotentRepository register) {
+ this.register = register;
+ }
+
+ /** Moves a key the file consumer added when it picked the file up;
its commit then finds it here. */
+ void filtered(String key) {
+ filtered.add(key);
+ register.remove(key);
+ }
+
+ @Override
+ public boolean add(String key) {
+ return !filtered.contains(key) && register.add(key);
+ }
+
+ @Override
+ public boolean contains(String key) {
+ return filtered.contains(key) || register.contains(key);
+ }
+
+ @Override
+ public boolean remove(String key) {
+ // from both, never short-circuited
+ return filtered.remove(key) | register.remove(key);
+ }
+
+ @Override
+ public boolean confirm(String key) {
+ return filtered.contains(key) || register.confirm(key);
+ }
+
+ @Override
+ public void clear() {
+ filtered.clear();
+ register.clear();
+ }
+
+ @Override
+ protected void doStart() {
+ ServiceHelper.startService(register);
+ }
+
+ @Override
+ protected void doStop() {
+ ServiceHelper.stopService(register);
+ }
+ }
}
diff --git
a/extensions/langchain4j-ingest/runtime/src/main/java/org/apache/camel/quarkus/component/langchain4j/ingest/IngestRunTimeConfig.java
b/extensions/langchain4j-ingest/runtime/src/main/java/org/apache/camel/quarkus/component/langchain4j/ingest/IngestRunTimeConfig.java
index f6320321d5..9fbbabd448 100644
---
a/extensions/langchain4j-ingest/runtime/src/main/java/org/apache/camel/quarkus/component/langchain4j/ingest/IngestRunTimeConfig.java
+++
b/extensions/langchain4j-ingest/runtime/src/main/java/org/apache/camel/quarkus/component/langchain4j/ingest/IngestRunTimeConfig.java
@@ -54,6 +54,47 @@ public interface IngestRunTimeConfig {
*/
SourceRunTimeConfig source();
+ /**
+ * Filters deciding which deliveries are ingested. A rejected delivery
is answered with
+ * a `filtered` outcome and never keeps the sink's dedup claim; a
directory pipeline does not
+ * read the file again until it changes, its key held in memory even
with a persistent
+ * register, so after a restart a changed filter applies to it.
+ */
+ FilterRunTimeConfig filter();
+
+ interface FilterRunTimeConfig {
+
+ /**
+ * Comma-separated Ant-style patterns the document id must match
to be ingested,
+ * for example `++**++/++*++.pdf,++**++/++*++.md` (`++*++.pdf`
alone misses files in
+ * subdirectories). Matching is case-sensitive, and an id starting
with `/` needs a
+ * pattern starting with `/`. A non-matching delivery is rejected
before the sink's dedup
+ * claim, without reading the body and, with a `parser`, before
the parse. When not set,
+ * every id is accepted.
+ */
+ Optional<String> includeId();
+
+ /**
+ * Comma-separated Ant-style patterns for document ids to skip,
for example
+ * `++**++/draft-++*++`. Exclusion wins over `include-id`.
+ */
+ Optional<String> excludeId();
+
+ /**
+ * Minimum size of one document in characters, in bytes with
`modality=media`; 0, the
+ * default, means no minimum. A shorter document is answered
`filtered` instead of being
+ * written.
+ */
+ @WithDefault("0")
+ int minDocumentSize();
+
+ /**
+ * Name of a Camel `Predicate` bean deciding whether a delivery is
ingested,
+ * evaluated with the body available. Looked up by name only.
+ */
+ Optional<String> documentFilter();
+ }
+
interface SourceRunTimeConfig {
/**
diff --git
a/integration-tests/langchain4j-ingest/src/main/java/org/apache/camel/quarkus/component/langchain4j/ingest/it/DeterministicAudioEmbeddingModel.java
b/integration-tests/langchain4j-ingest/src/main/java/org/apache/camel/quarkus/component/langchain4j/ingest/it/DeterministicAudioEmbeddingModel.java
new file mode 100644
index 0000000000..d70cbf5019
--- /dev/null
+++
b/integration-tests/langchain4j-ingest/src/main/java/org/apache/camel/quarkus/component/langchain4j/ingest/it/DeterministicAudioEmbeddingModel.java
@@ -0,0 +1,72 @@
+/*
+ * 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.quarkus.component.langchain4j.ingest.it;
+
+import java.util.ArrayList;
+import java.util.Base64;
+import java.util.List;
+import java.util.Set;
+
+import dev.langchain4j.data.embedding.Embedding;
+import dev.langchain4j.data.message.AudioContent;
+import dev.langchain4j.data.message.Content;
+import dev.langchain4j.data.message.ContentType;
+import dev.langchain4j.model.embedding.request.EmbeddingInput;
+import dev.langchain4j.model.embedding.request.EmbeddingRequest;
+import dev.langchain4j.model.embedding.response.EmbeddingResponse;
+
+/**
+ * Deterministic fake that also accepts audio: same bytes, same vector — no
network, no model download, native-friendly.
+ */
+public class DeterministicAudioEmbeddingModel extends
DeterministicEmbeddingModel {
+
+ public DeterministicAudioEmbeddingModel(int dimension) {
+ super(dimension);
+ }
+
+ @Override
+ public Set<ContentType> supportedContentTypes() {
+ return Set.of(ContentType.TEXT, ContentType.AUDIO);
+ }
+
+ @Override
+ public EmbeddingResponse doEmbed(EmbeddingRequest request) {
+ List<Embedding> embeddings = new ArrayList<>(request.inputs().size());
+ for (EmbeddingInput input : request.inputs()) {
+ AudioContent audio = audioOf(input);
+ embeddings.add(embeddingFor(audio == null ? input.text() :
audio.audio().base64Data()));
+ }
+ return EmbeddingResponse.builder().embeddings(embeddings).build();
+ }
+
+ /** The vector this fake produces for the given bytes: what a query by the
same clip embeds to. */
+ public Embedding embeddingOf(byte[] audio) {
+ return embeddingFor(Base64.getEncoder().encodeToString(audio));
+ }
+
+ private static AudioContent audioOf(EmbeddingInput input) {
+ if (input.contents() == null) {
+ return null;
+ }
+ for (Content content : input.contents()) {
+ if (content instanceof AudioContent audio) {
+ return audio;
+ }
+ }
+ return null;
+ }
+}
diff --git
a/integration-tests/langchain4j-ingest/src/main/java/org/apache/camel/quarkus/component/langchain4j/ingest/it/IngestItProducers.java
b/integration-tests/langchain4j-ingest/src/main/java/org/apache/camel/quarkus/component/langchain4j/ingest/it/IngestItProducers.java
index d407e2b599..6909881956 100644
---
a/integration-tests/langchain4j-ingest/src/main/java/org/apache/camel/quarkus/component/langchain4j/ingest/it/IngestItProducers.java
+++
b/integration-tests/langchain4j-ingest/src/main/java/org/apache/camel/quarkus/component/langchain4j/ingest/it/IngestItProducers.java
@@ -111,6 +111,29 @@ public class IngestItProducers {
return new InMemoryEmbeddingStore<>();
}
+ @Produces
+ @Singleton
+ @Named("filtered-store")
+ EmbeddingStore<TextSegment> filteredStore() {
+ return new InMemoryEmbeddingStore<>();
+ }
+
+ /** The documentFilter predicate of the filtered pipeline: content marked
confidential stays out. */
+ @Produces
+ @Singleton
+ @Named("confidentialFilter")
+ org.apache.camel.Predicate confidentialFilter() {
+ return exchange ->
!exchange.getMessage().getBody(String.class).contains("CONFIDENTIAL");
+ }
+
+ /** Named only by the application-wide documentFilter property the
pipelines must not pick up. */
+ @Produces
+ @Singleton
+ @Named("rejectAll")
+ org.apache.camel.Predicate rejectAll() {
+ return exchange -> false;
+ }
+
@Produces
@Singleton
@Named("test-model")
@@ -118,6 +141,21 @@ public class IngestItProducers {
return new DeterministicEmbeddingModel(64);
}
+ @Produces
+ @Singleton
+ @Named("audio-store")
+ EmbeddingStore<TextSegment> audioStore() {
+ return new InMemoryEmbeddingStore<>();
+ }
+
+ // the concrete type, so the REST resource can embed a query clip with it
+ @Produces
+ @Singleton
+ @Named("audio-model")
+ DeterministicAudioEmbeddingModel audioModel() {
+ return new DeterministicAudioEmbeddingModel(64);
+ }
+
// the custom pipeline's register; auto-create is also set, so this
existing bean must win
@Produces
@Singleton
diff --git
a/integration-tests/langchain4j-ingest/src/main/java/org/apache/camel/quarkus/component/langchain4j/ingest/it/IngestResource.java
b/integration-tests/langchain4j-ingest/src/main/java/org/apache/camel/quarkus/component/langchain4j/ingest/it/IngestResource.java
index 5db526584b..528f0a3d0a 100644
---
a/integration-tests/langchain4j-ingest/src/main/java/org/apache/camel/quarkus/component/langchain4j/ingest/it/IngestResource.java
+++
b/integration-tests/langchain4j-ingest/src/main/java/org/apache/camel/quarkus/component/langchain4j/ingest/it/IngestResource.java
@@ -16,6 +16,7 @@
*/
package org.apache.camel.quarkus.component.langchain4j.ingest.it;
+import java.io.ByteArrayInputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
@@ -94,6 +95,18 @@ public class IngestResource {
@Named("capped-store")
EmbeddingStore<TextSegment> cappedStore;
+ @Inject
+ @Named("filtered-store")
+ EmbeddingStore<TextSegment> filteredStore;
+
+ @Inject
+ @Named("audio-store")
+ EmbeddingStore<TextSegment> audioStore;
+
+ @Inject
+ @Named("audio-model")
+ DeterministicAudioEmbeddingModel audioModel;
+
@Inject
ProducerTemplate producerTemplate;
@@ -112,6 +125,9 @@ public class IngestResource {
@ConfigProperty(name = "ingest.capped.directory")
String cappedDirectory;
+ @ConfigProperty(name = "ingest.audio.directory")
+ String audioDirectory;
+
/** Asserts a key was committed; registry lookup by name, the same way the
pipelines resolve. */
@GET
@jakarta.ws.rs.Path("/register-contains")
@@ -141,6 +157,7 @@ public class IngestResource {
Path dir = Path.of(switch (pipeline) {
case "scans" -> scansDirectory;
case "capped" -> cappedDirectory;
+ case "audio" -> audioDirectory;
default -> reportsDirectory;
});
Files.createDirectories(dir);
@@ -161,6 +178,7 @@ public class IngestResource {
case "jdbc" -> jdbcStore;
case "reports" -> reportsStore;
case "capped" -> cappedStore;
+ case "filtered" -> filteredStore;
case "scans" -> scansStore;
default -> productsStore;
};
@@ -180,6 +198,25 @@ public class IngestResource {
.toList();
}
+ /** Query by audio: the clip is embedded with the audio model and the
audio store searched for exact hits. */
+ @POST
+ @jakarta.ws.rs.Path("/search/audio")
+ @Consumes(MediaType.APPLICATION_OCTET_STREAM)
+ @Produces(MediaType.APPLICATION_JSON)
+ public List<SearchHit> searchAudio(byte[] clip) {
+ var result = audioStore.search(EmbeddingSearchRequest.builder()
+ .queryEmbedding(audioModel.embeddingOf(clip))
+ .maxResults(10)
+ .minScore(0.99)
+ .build());
+ return result.matches().stream()
+ .map(match -> new SearchHit(
+ match.embedded().text(),
+
match.embedded().metadata().getString(LangChain4jIngest.METADATA_PIPELINE),
+
match.embedded().metadata().getString(LangChain4jIngest.METADATA_DOCUMENT_ID)))
+ .toList();
+ }
+
/** One stored segment with the metadata the pipeline stamped on it. */
public record SearchHit(String text, String pipeline, String documentId) {
}
@@ -201,6 +238,28 @@ public class IngestResource {
return result.outcome().label();
}
+ /**
+ * Feeds a media pipeline the raw bytes as a stream, as a streaming
consumer would, optionally announcing a (forged)
+ * CamelFileLength; answers the outcome or the failure message.
+ */
+ @POST
+ @jakarta.ws.rs.Path("/feed-binary/{pipeline}/{documentId:.+}")
+ @Consumes(MediaType.APPLICATION_OCTET_STREAM)
+ @Produces(MediaType.TEXT_PLAIN)
+ public String feedBinary(@PathParam("pipeline") String pipeline,
@PathParam("documentId") String documentId,
+ @QueryParam("fileLength") Long fileLength, byte[] content) {
+ Exchange exchange = producerTemplate.request("direct:" + pipeline +
"-feed", e -> {
+ e.getMessage().setHeader(IngestHeaders.DOCUMENT_ID, documentId);
+ if (fileLength != null) {
+ e.getMessage().setHeader(Exchange.FILE_LENGTH, fileLength);
+ }
+ e.getMessage().setBody(new ByteArrayInputStream(content));
+ });
+ return exchange.getException() != null
+ ? exchange.getException().getMessage()
+ :
exchange.getMessage().getBody(IngestResult.class).outcome().label();
+ }
+
/** Feeds a pipeline carrying different ids in the current and the
deprecated header, so tests can assert precedence. */
@POST
@jakarta.ws.rs.Path("/feed-both/{pipeline}/{documentId}/{legacyDocumentId}")
diff --git
a/integration-tests/langchain4j-ingest/src/main/java/org/apache/camel/quarkus/component/langchain4j/ingest/it/ItBuilderPipelines.java
b/integration-tests/langchain4j-ingest/src/main/java/org/apache/camel/quarkus/component/langchain4j/ingest/it/ItBuilderPipelines.java
index 512853f563..fb47fbe527 100644
---
a/integration-tests/langchain4j-ingest/src/main/java/org/apache/camel/quarkus/component/langchain4j/ingest/it/ItBuilderPipelines.java
+++
b/integration-tests/langchain4j-ingest/src/main/java/org/apache/camel/quarkus/component/langchain4j/ingest/it/ItBuilderPipelines.java
@@ -57,6 +57,20 @@ public class ItBuilderPipelines {
.splitter(120, 20);
}
+ /**
+ * Media with a fixed content type: the ids it is fed carry no extension
to derive one from. The component reads
+ * a stream only one byte past the byte cap.
+ */
+ @Ingest("clips")
+ IngestPipeline clips() {
+ return IngestPipeline.from(Source.endpoint(direct("clips-feed")))
+ .modality("media")
+ .contentType("audio/wav")
+ .maxDocumentSize(20_000)
+ .embeddingStore("audio-store")
+ .embeddingModel("audio-model");
+ }
+
/**
* The lambda form of the Endpoint DSL — nothing imported, the IDE lists
every component off
* {@code dsl.}. The object key identifies the document, and the store
addresses buckets by
diff --git
a/integration-tests/langchain4j-ingest/src/main/resources/application.properties
b/integration-tests/langchain4j-ingest/src/main/resources/application.properties
index 2440083c87..1a407c9f8d 100644
---
a/integration-tests/langchain4j-ingest/src/main/resources/application.properties
+++
b/integration-tests/langchain4j-ingest/src/main/resources/application.properties
@@ -21,10 +21,17 @@ ingest.test.directory=target/ingest-docs
# per test class, and fail the whole module on platforms without a container
environment
quarkus.kafka.devservices.enabled=false
-# meant for an application's own use of these Kamelets: the pipelines pin the
cap, else every
-# parse would exceed it
+# meant for an application's own use of these Kamelets: the pipelines pin
these options, else the
+# text pipelines would fail the start, every parse would exceed the cap, every
document be filtered
+# and media and parsed files be decoded as text
+camel.kamelet.langchain4j-ingest-sink.modality=media
+camel.kamelet.langchain4j-ingest-sink.contentType=audio/wav
+camel.kamelet.langchain4j-ingest-sink.includeId=no-such-id
+camel.kamelet.langchain4j-ingest-sink.excludeId=**
+camel.kamelet.langchain4j-ingest-sink.documentFilter=#bean:rejectAll
camel.kamelet.tika-extract-text-action.maxDocumentSize=1
camel.kamelet.docling-convert-action.maxDocumentSize=1
+camel.kamelet.langchain4j-ingest-file-source.charset=UTF-16
quarkus.camel.langchain4j.ingest.products.source.directory=${ingest.test.directory}
quarkus.camel.langchain4j.ingest.products.embedding-store=products-store
@@ -36,6 +43,8 @@ quarkus.camel.langchain4j.ingest.products.max-overlap-size=20
camel.beans.propertiesRegister=\#class\:org.apache.camel.support.processor.idempotent.MemoryIdempotentRepository
camel.beans.propertiesRegister.cacheSize=5000
quarkus.camel.langchain4j.ingest.products.source.idempotent-repository=propertiesRegister
+# a filtered file's key must stay out of the named register, which could be
persistent
+quarkus.camel.langchain4j.ingest.products.filter.exclude-id=**/draft-*
# parser pipelines: reports extracts PDF text in-process (tika); scans
converts through a
# Docling Serve instance (docling), which the tests stub with WireMock - see
@@ -48,11 +57,13 @@
quarkus.camel.langchain4j.ingest.reports.embedding-model=test-model
quarkus.camel.langchain4j.ingest.reports.max-segment-size=120
quarkus.camel.langchain4j.ingest.reports.max-overlap-size=20
-# parser pipeline with a tight raw-byte cap: exercises the pre-parse size guard
+# parser pipeline with a tight raw-byte cap: exercises the pre-parse size
guard, and with the
+# exclusion, the id patterns applied before it on a directory pipeline
ingest.capped.directory=target/ingest-capped
quarkus.camel.langchain4j.ingest.capped.source.directory=${ingest.capped.directory}
quarkus.camel.langchain4j.ingest.capped.parser=tika
quarkus.camel.langchain4j.ingest.capped.max-document-size=200
+quarkus.camel.langchain4j.ingest.capped.filter.exclude-id=**/*.mp4
quarkus.camel.langchain4j.ingest.capped.embedding-store=capped-store
quarkus.camel.langchain4j.ingest.capped.embedding-model=test-model
quarkus.camel.langchain4j.ingest.capped.max-segment-size=120
@@ -71,9 +82,12 @@ camel.component.docling.use-docling-serve=true
camel.component.docling.docling-serve-url=${docling.serve.url:http://localhost:5001}
# consumer-fed pipeline with a parser: the endpoint+parser combination, and
the pre-parse
-# document-id capture (a parsed document cannot forge its own id through Tika
metadata headers)
+# document-id capture (a parsed document cannot forge its own id through Tika
metadata headers).
+# The cap and the exclusion show that the id patterns apply before the size
guard and the parse
quarkus.camel.langchain4j.ingest.htmlfeed.source.uri=direct:htmlfeed-feed
quarkus.camel.langchain4j.ingest.htmlfeed.parser=tika
+quarkus.camel.langchain4j.ingest.htmlfeed.max-document-size=4096
+quarkus.camel.langchain4j.ingest.htmlfeed.filter.exclude-id=**/*.mp4
quarkus.camel.langchain4j.ingest.htmlfeed.embedding-store=htmlfeed-store
quarkus.camel.langchain4j.ingest.htmlfeed.embedding-model=test-model
quarkus.camel.langchain4j.ingest.htmlfeed.max-segment-size=120
@@ -110,6 +124,28 @@
quarkus.camel.langchain4j.ingest.jdbcdocs.embedding-model=test-model
quarkus.camel.langchain4j.ingest.jdbcdocs.max-segment-size=120
quarkus.camel.langchain4j.ingest.jdbcdocs.max-overlap-size=20
+# the filter options, forwarded to the sink Kamelet and enforced by the
component
+quarkus.camel.langchain4j.ingest.filtered.source.uri=direct:filtered-feed
+quarkus.camel.langchain4j.ingest.filtered.embedding-store=filtered-store
+quarkus.camel.langchain4j.ingest.filtered.embedding-model=test-model
+quarkus.camel.langchain4j.ingest.filtered.max-segment-size=120
+quarkus.camel.langchain4j.ingest.filtered.max-overlap-size=20
+quarkus.camel.langchain4j.ingest.filtered.filter.include-id=**/*.md,*.md
+quarkus.camel.langchain4j.ingest.filtered.filter.exclude-id=**/draft-*
+quarkus.camel.langchain4j.ingest.filtered.filter.min-document-size=25
+quarkus.camel.langchain4j.ingest.filtered.filter.document-filter=confidentialFilter
+
+# filters on a builder (@Ingest) pipeline: the datasheets pipeline is declared
in Java, the
+# exclusion comes from configuration
+quarkus.camel.langchain4j.ingest.datasheets.filter.exclude-id=secret-*
+
+# audio pipeline: each file is embedded whole, as one vector, by the
audio-capable test model
+ingest.audio.directory=target/ingest-audio
+quarkus.camel.langchain4j.ingest.audio.source.directory=${ingest.audio.directory}
+quarkus.camel.langchain4j.ingest.audio.modality=media
+quarkus.camel.langchain4j.ingest.audio.embedding-store=audio-store
+quarkus.camel.langchain4j.ingest.audio.embedding-model=audio-model
+
# s3docs and events are declared in Java (ItBuilderPipelines) through the
Endpoint DSL; they stay
# off until S3TestResource / IngestKafkaTestResource provide a store and a
broker
quarkus.camel.langchain4j.ingest.s3docs.enabled=false
diff --git
a/integration-tests/langchain4j-ingest/src/test/java/org/apache/camel/quarkus/component/langchain4j/ingest/it/Langchain4jIngestAudioIT.java
b/integration-tests/langchain4j-ingest/src/test/java/org/apache/camel/quarkus/component/langchain4j/ingest/it/Langchain4jIngestAudioIT.java
new file mode 100644
index 0000000000..aa88cc51e6
--- /dev/null
+++
b/integration-tests/langchain4j-ingest/src/test/java/org/apache/camel/quarkus/component/langchain4j/ingest/it/Langchain4jIngestAudioIT.java
@@ -0,0 +1,23 @@
+/*
+ * 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.quarkus.component.langchain4j.ingest.it;
+
+import io.quarkus.test.junit.QuarkusIntegrationTest;
+
+@QuarkusIntegrationTest
+class Langchain4jIngestAudioIT extends Langchain4jIngestAudioTest {
+}
diff --git
a/integration-tests/langchain4j-ingest/src/test/java/org/apache/camel/quarkus/component/langchain4j/ingest/it/Langchain4jIngestAudioTest.java
b/integration-tests/langchain4j-ingest/src/test/java/org/apache/camel/quarkus/component/langchain4j/ingest/it/Langchain4jIngestAudioTest.java
new file mode 100644
index 0000000000..5dfe9f4f63
--- /dev/null
+++
b/integration-tests/langchain4j-ingest/src/test/java/org/apache/camel/quarkus/component/langchain4j/ingest/it/Langchain4jIngestAudioTest.java
@@ -0,0 +1,126 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.quarkus.component.langchain4j.ingest.it;
+
+import java.nio.ByteBuffer;
+import java.nio.ByteOrder;
+import java.nio.charset.StandardCharsets;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.TimeUnit;
+
+import io.quarkus.test.junit.QuarkusTest;
+import io.restassured.RestAssured;
+import io.restassured.http.ContentType;
+import org.awaitility.Awaitility;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * The {@code media} modality with audio: a WAV dropped into the watched
directory is embedded whole, as one
+ * vector, by the audio-capable test model, the file name as the document id.
The fixture is
+ * generated, so no binary is checked in.
+ */
+@QuarkusTest
+class Langchain4jIngestAudioTest {
+
+ @Test
+ void audioFileIsEmbeddedWholeAndFoundByTheSameClip() throws Exception {
+ byte[] tone = wav(200);
+
+ RestAssured.given().contentType(ContentType.BINARY)
+ .body(tone)
+ .post("/langchain4j-ingest/binary/audio/tone.wav")
+ .then().statusCode(204);
+
+ Awaitility.await().atMost(30, TimeUnit.SECONDS).pollInterval(500,
TimeUnit.MILLISECONDS)
+ .untilAsserted(() -> {
+ List<Map<String, String>> hits = searchByClip(tone);
+ assertEquals(1, hits.size(), "one vector per audio file");
+ assertEquals("audio", hits.get(0).get("pipeline"));
+ assertEquals("tone.wav", hits.get(0).get("documentId"));
+ // the placeholder segment carries the id as its text
+ assertEquals("tone.wav", hits.get(0).get("text"));
+ });
+
+ // a different clip embeds to a different vector, so it is no exact hit
+ assertTrue(searchByClip(wav(120)).isEmpty(), "another clip must not
match the stored one");
+ }
+
+ /** The {@code clips} builder pipeline's content type stands in for the
extension its ids lack. */
+ @Test
+ void contentTypeAppliesToIdsWithoutExtension() {
+ byte[] clip = wav(300);
+
+ String outcome = RestAssured.given().contentType(ContentType.BINARY)
+ .body(clip)
+ .post("/langchain4j-ingest/feed-binary/clips/clip-7")
+ .then().statusCode(200).extract().asString();
+ assertEquals("ingested", outcome);
+
+ List<Map<String, String>> hits = searchByClip(clip);
+ assertEquals(1, hits.size());
+ assertEquals("clips", hits.get(0).get("pipeline"));
+ assertEquals("clip-7", hits.get(0).get("documentId"));
+ }
+
+ /**
+ * A consumer-fed media stream over the cap is refused after max + 1
bytes, whatever CamelFileLength it announces:
+ * the component reads it no further (CAMEL-25463), read whole it would
report all 32044 bytes.
+ */
+ @Test
+ void oversizedClipIsRefusedBeforeItIsReadWhole() {
+ byte[] clip = wav(1000);
+
+ String outcome = RestAssured.given().contentType(ContentType.BINARY)
+ .queryParam("fileLength", 10)
+ .body(clip)
+ .post("/langchain4j-ingest/feed-binary/clips/big-clip")
+ .then().statusCode(200).extract().asString();
+ assertTrue(outcome.contains("exceeds maxDocumentSize (20001 > 20000
bytes)"), outcome);
+ assertTrue(searchByClip(clip).isEmpty(), "the oversized clip must not
be stored");
+ }
+
+ /** Query by audio: the clip is embedded with the same model and the store
searched for exact hits. */
+ private static List<Map<String, String>> searchByClip(byte[] clip) {
+ return RestAssured.given().contentType(ContentType.BINARY)
+ .body(clip)
+ .post("/langchain4j-ingest/search/audio")
+ .then()
+ .statusCode(200)
+ .extract().jsonPath().getList("");
+ }
+
+ /** A mono 16 kHz PCM WAV of the given length holding a 440 Hz tone, the
RIFF header written by hand. */
+ static byte[] wav(int millis) {
+ int rate = 16_000;
+ int frames = rate * millis / 1000;
+ ByteBuffer wav = ByteBuffer.allocate(44 + frames *
2).order(ByteOrder.LITTLE_ENDIAN);
+ wav.put("RIFF".getBytes(StandardCharsets.US_ASCII)).putInt(36 + frames
* 2)
+ .put("WAVE".getBytes(StandardCharsets.US_ASCII))
+ .put("fmt ".getBytes(StandardCharsets.US_ASCII)).putInt(16)
+ .putShort((short) 1).putShort((short)
1).putInt(rate).putInt(rate * 2).putShort((short) 2)
+ .putShort((short) 16)
+ .put("data".getBytes(StandardCharsets.US_ASCII)).putInt(frames
* 2);
+ for (int i = 0; i < frames; i++) {
+ wav.putShort((short) (Math.sin(2 * Math.PI * 440 * i / rate) *
Short.MAX_VALUE / 4));
+ }
+ return wav.array();
+ }
+}
diff --git
a/integration-tests/langchain4j-ingest/src/test/java/org/apache/camel/quarkus/component/langchain4j/ingest/it/Langchain4jIngestFilterIT.java
b/integration-tests/langchain4j-ingest/src/test/java/org/apache/camel/quarkus/component/langchain4j/ingest/it/Langchain4jIngestFilterIT.java
new file mode 100644
index 0000000000..5f8078d5b0
--- /dev/null
+++
b/integration-tests/langchain4j-ingest/src/test/java/org/apache/camel/quarkus/component/langchain4j/ingest/it/Langchain4jIngestFilterIT.java
@@ -0,0 +1,23 @@
+/*
+ * 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.quarkus.component.langchain4j.ingest.it;
+
+import io.quarkus.test.junit.QuarkusIntegrationTest;
+
+@QuarkusIntegrationTest
+class Langchain4jIngestFilterIT extends Langchain4jIngestFilterTest {
+}
diff --git
a/integration-tests/langchain4j-ingest/src/test/java/org/apache/camel/quarkus/component/langchain4j/ingest/it/Langchain4jIngestFilterTest.java
b/integration-tests/langchain4j-ingest/src/test/java/org/apache/camel/quarkus/component/langchain4j/ingest/it/Langchain4jIngestFilterTest.java
new file mode 100644
index 0000000000..16e9145f20
--- /dev/null
+++
b/integration-tests/langchain4j-ingest/src/test/java/org/apache/camel/quarkus/component/langchain4j/ingest/it/Langchain4jIngestFilterTest.java
@@ -0,0 +1,148 @@
+/*
+ * 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.quarkus.component.langchain4j.ingest.it;
+
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.concurrent.TimeUnit;
+
+import io.quarkus.test.junit.QuarkusTest;
+import io.restassured.RestAssured;
+import io.restassured.http.ContentType;
+import org.awaitility.Awaitility;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * The filter configuration, forwarded through the sink Kamelet to the
component: id patterns,
+ * the size floor and the documentFilter predicate each answer {@code
filtered}.
+ */
+@QuarkusTest
+class Langchain4jIngestFilterTest {
+
+ @Test
+ void filtersGovernIngestion() {
+ assertEquals("filtered", feed("notes.txt", "A text file the id
patterns must reject."));
+ assertEquals("filtered", feed("draft-plan.md", "Excluded although the
include patterns match."));
+ assertEquals("filtered", feed("stub.md", "too short"));
+ assertEquals("filtered", feed("brief.md", "The CONFIDENTIAL relay
specification stays out."));
+
+ assertEquals("ingested", feed("camels.md", "Camels are resilient
desert animals with two rows of eyelashes."));
+
+ // only the accepted document reached the store
+ assertNotNull(Langchain4jIngestTest.hit("What is resilient?",
"filtered", "Camels"));
+ assertTrue(Langchain4jIngestTest.hits("What is resilient?",
"filtered").stream()
+ .allMatch(hit -> hit.get("documentId").equals("camels.md")),
+ "no filtered delivery may have been written");
+ }
+
+ /** A builder ({@code @Ingest}) pipeline is filtered through configuration
the same way. */
+ @Test
+ void configurationFiltersApplyToBuilderPipelines() {
+ String excluded = RestAssured.given().contentType(ContentType.TEXT)
+ .body("The OMEGA-3 valve datasheet is not for the knowledge
base.")
+ .post("/langchain4j-ingest/feed/datasheets/secret-omega.txt")
+ .then().statusCode(200).extract().asString();
+ assertEquals("filtered", excluded);
+
+ String accepted = RestAssured.given().contentType(ContentType.TEXT)
+ .body("The OMEGA-3 valve datasheet lists coolant tolerances.")
+ .post("/langchain4j-ingest/feed/datasheets/sheet-omega.txt")
+ .then().statusCode(200).extract().asString();
+ assertEquals("ingested", accepted);
+ }
+
+ /**
+ * With a parser, the id patterns are checked before the size guard and
the parse: on the
+ * {@code htmlfeed} pipeline an excluded document over the cap is answered
filtered, while an
+ * included one still meets the guard.
+ */
+ @Test
+ void excludedDocumentIsFilteredBeforeTheParse() {
+ String oversized = "<html><body><p>" + "x".repeat(5000) +
"</p></body></html>";
+ assertEquals("filtered", feedProperty("htmlfeed", "clips/clip.mp4",
oversized));
+ assertTrue(feedProperty("htmlfeed", "big.html",
oversized).contains("exceeds maxDocumentSize"));
+ }
+
+ /**
+ * The same on a directory pipeline: on {@code capped} an excluded file
over the cap is filtered,
+ * so the file register records it as done instead of the guard failing it
on every poll.
+ */
+ @Test
+ void excludedFileIsFilteredBeforeTheParse() throws Exception {
+ RestAssured.given().contentType(ContentType.BINARY)
+ .body(new byte[1000])
+ .post("/langchain4j-ingest/binary/capped/clip.mp4")
+ .then().statusCode(204);
+
+ // the file source's register key: absolute path, modification time
and size. The register
+ // adds it when a poll picks the file up and drops it when the
exchange fails, so only a key
+ // that stays across poll cycles shows the file is done with
+ Path file = Path.of("target/ingest-capped/clip.mp4").toAbsolutePath();
+ String key = file + ":" + Files.getLastModifiedTime(file).toMillis() +
":" + Files.size(file);
+ Awaitility.await().atMost(30, TimeUnit.SECONDS).during(3,
TimeUnit.SECONDS)
+ .pollInterval(100, TimeUnit.MILLISECONDS)
+ .until(() -> Boolean.parseBoolean(RestAssured.given()
+ .queryParam("repo",
"langchain4j-ingest-capped-register")
+ .queryParam("key", key)
+ .get("/langchain4j-ingest/register-contains")
+ .then().statusCode(200).extract().asString()));
+ }
+
+ /**
+ * A named register may be persistent, so a filtered file's key stays out
of it: on {@code products} the excluded
+ * file is still skipped across polls, its key held in memory, and a
filter changed for the next start applies to it.
+ */
+ @Test
+ void filteredFileStaysOutOfTheNamedRegister() throws Exception {
+ Langchain4jIngestTest.write("draft-pumps.txt", "The ZETA-9 pump draft
is not final.");
+
+ Path file =
Path.of("target/ingest-docs/draft-pumps.txt").toAbsolutePath();
+ String key = file + ":" + Files.getLastModifiedTime(file).toMillis() +
":" + Files.size(file);
+ Awaitility.await().atMost(30, TimeUnit.SECONDS).during(3,
TimeUnit.SECONDS)
+ .pollInterval(100, TimeUnit.MILLISECONDS)
+ .until(() ->
registerContains("langchain4j-ingest-products-register", key));
+ assertFalse(registerContains("propertiesRegister", key), "the named
register must not hold the key");
+ }
+
+ private static boolean registerContains(String repo, String key) {
+ return Boolean.parseBoolean(RestAssured.given()
+ .queryParam("repo", repo)
+ .queryParam("key", key)
+ .get("/langchain4j-ingest/register-contains")
+ .then().statusCode(200).extract().asString());
+ }
+
+ /** Answers the outcome, or the failure message. */
+ private static String feedProperty(String pipeline, String documentId,
String body) {
+ return RestAssured.given().contentType(ContentType.TEXT)
+ .body(body)
+ .post("/langchain4j-ingest/feed-property/" + pipeline + "/" +
documentId)
+ .then().statusCode(200).extract().asString();
+ }
+
+ private static String feed(String documentId, String body) {
+ return RestAssured.given().contentType(ContentType.TEXT)
+ .body(body)
+ .post("/langchain4j-ingest/feed/filtered/" + documentId)
+ .then().statusCode(200).extract().asString();
+ }
+}
diff --git
a/integration-tests/langchain4j-ingest/src/test/java/org/apache/camel/quarkus/component/langchain4j/ingest/it/Langchain4jIngestTikaTest.java
b/integration-tests/langchain4j-ingest/src/test/java/org/apache/camel/quarkus/component/langchain4j/ingest/it/Langchain4jIngestTikaTest.java
index 5e06dc33c0..1f01c7c566 100644
---
a/integration-tests/langchain4j-ingest/src/test/java/org/apache/camel/quarkus/component/langchain4j/ingest/it/Langchain4jIngestTikaTest.java
+++
b/integration-tests/langchain4j-ingest/src/test/java/org/apache/camel/quarkus/component/langchain4j/ingest/it/Langchain4jIngestTikaTest.java
@@ -138,10 +138,18 @@ class Langchain4jIngestTikaTest {
.post("/langchain4j-ingest/binary/capped/pump.pdf")
.then().statusCode(204);
+ // a comment carries the bytes, not the text: parsed, it fits the cap,
so only the raw-byte
+ // cap keeps it out
+ RestAssured.given().contentType(ContentType.BINARY)
+ .body(("<html><body><!-- " + "x".repeat(300) + "
--><p>LAMBDA-7 hides in markup.</p></body></html>")
+ .getBytes(java.nio.charset.StandardCharsets.UTF_8))
+ .post("/langchain4j-ingest/binary/capped/markup.html")
+ .then().statusCode(204);
+
// the absence must hold across several poll cycles, not just at one
instant
Awaitility.await().during(3, TimeUnit.SECONDS).atMost(10,
TimeUnit.SECONDS)
.until(() -> Langchain4jIngestTest.hits("What does the pump
tolerate?", "capped").stream()
- .noneMatch(hit ->
hit.get("text").contains("DELTA-5")));
+ .noneMatch(hit -> hit.get("text").contains("DELTA-5")
|| hit.get("text").contains("LAMBDA-7")));
}
/**