Skip to content

[camel-main] Fixes #9280. Add filter.* and modality=media to langchain4j-ingest - #9290

Closed
JiriOndrusek wants to merge 3 commits into
apache:camel-mainfrom
jiriOndrusek-agent:fix/9280
Closed

JiriOndrusek wants to merge 3 commits into
apache:camel-mainfrom
jiriOndrusek-agent:fix/9280

Conversation

@JiriOndrusek

Copy link
Copy Markdown
Contributor

Fixes #9280

Builds on #9282: only the last commit is new.

  • filter.* options go to the sink Kamelet, which enforces them. With a parser, the id patterns are also checked before the parse.
  • modality=media (optional content-type) embeds each document whole, as one vector; the builder gets modality() and contentType(). Conflicting settings fail the build.
  • Unlike the issue's first plan, the raw-size guard before the parse stays in the extension: it reads at most max + 1 bytes, while the cap of Fix #3086: Parser actions: add maxDocumentSize, checked before the parse camel-kamelets#3087 reads the whole stream. That cap is pinned off.

Not mergeable yet: needs #9282 and the camel-kamelets 4.23.0 release with apache/camel-kamelets#3085.

Tested locally: deployment tests, JVM and native integration tests, with camel-kamelets built from apache/camel-kamelets#3085 and apache/camel-kamelets#3087.

Claude Code on behalf of @JiriOndrusek. AI-generated; please verify before merging.

🤖 Generated with Claude Code

@jamesnetherton jamesnetherton left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed locally (latest commit only; the first commit is from #9282). A few comments inline. The most important is the first: the raw-size guard is skipped for media pipelines.

*/
private static ProcessorDefinition<?> parseSteps(ProcessorDefinition<?> route, String name, String parser,
int maxDocumentSize, boolean directory, IdempotentRepository register, String includeId, String excludeId) {
if (parser == null) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With modality=media there is no parser, so parseSteps returns here and the bounded raw-size read (max + 1 bytes, not trusting CamelFileLength) never runs for media pipelines, including consumer-fed ones (source.uri / Source.endpoint).

The component's own check only trusts the CamelFileLength header before calling getMandatoryBody(byte[]). So a large body sent with a forged CamelFileLength gets past max-document-size and is loaded into the heap in full. Should the size guard also apply when media is true?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Partly true: an oversized media payload is still refused, because MediaIngestService re-checks bytes.length. But that happens only after the whole body is read, and a forged CamelFileLength passes the up-front check.

maxDocumentSize is the component's option, so the fix belongs there rather than in a media copy of the guard here: CAMEL-25463. With it, a body not yet in memory will be read only one byte (media) or one character (text) past the cap, then refused. CamelFileLength will still reject early, but it will no longer decide how much is read. That will cover every user of the component, not only Camel Quarkus, and keep another copy of the size logic out of the extension.

I'll add a test here to verify it, so this PR will wait for the fix. That's no extra delay: the fix targets Camel 4.23.0, which this PR already waits for.

Why that will be enough:

  • A directory pipeline is already safe: the file consumer's CamelFileLength is checked before the file is read.
  • On a consumer-fed pipeline, a guard in the extension could not do more. Stream caching (on by default, in memory) reads a stream whole before any of our steps run. That needs a limit at the consumer (e.g. quarkus.http.limits.max-body-size) or spooling, as the component docs in the fix will say.

The extension keeps its own guard only for parser pipelines, since the parse runs before the sink.

}

/** Comma-separated id patterns, parsed as the component parses them; null when none remain. */
private static String[] patterns(String value) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

patterns() and the AntPathMatcher check before parsing re-implement the component's private parsePatterns/idAccepted. If the component's matching changes (case, a leading /, splitting, where the id comes from), the advisory filter and the authoritative one could disagree. Worth a comment linking the two, or a test that checks they agree?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

True. I added a comment pointing to LangChain4jIngestProducer.parsePatterns/idAccepted ("keep them in step"). The component stays authoritative; the copy only spares the parse. Exposing them from the component would remove the copy, and I can raise that in Camel.

}

@Override
public FilterRunTimeConfig filter() {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

filter() returns null, so this PipelineRunTimeConfig is only partly implemented. Any later code that reads runtime.filter() will hit an NPE, and only for @Ingest pipelines. Could it return an empty FilterRunTimeConfig, or delegate to the configured pipeline's filter?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed. The builder view now delegates to the configured filter.*, or returns an empty filter when there is none. IngestRoutes reads runtime.filter() for both declaration styles.

// filter.* comes from configuration for both declaration styles, the builder having no
// filter API, and the component enforces it
IngestRunTimeConfig.PipelineRunTimeConfig configured = runTimeConfig.pipelines().get(name);
String includeId = configured == null ? "" : configured.filter().includeId().orElse("");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

filter.* adds the pipeline name to the runtime map. A filter property for a misspelled @Ingest name (e.g. datasheet.filter.exclude-id when the pipeline is datasheets) creates a phantom configured pipeline. Startup then fails with a missing source.directory error rather than "unknown pipeline", and the real pipeline runs without the filter. Could a name that only has filter.* set be detected and reported?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Partly. The misleading error is real, but the start fails, so the real pipeline doesn't run without the filter.
The error now names the Java-declared pipelines: Ingestion pipeline 'datasheet' has no source.directory, and no @Ingest method declares it (declared in Java: [datasheets]). Fix the name, or set ...source.directory. Test: IngestBuilderFilterTypoTest.

if (configured.filter().minDocumentSize() != 0) {
sink.put("minDocumentSize", String.valueOf(configured.filter().minDocumentSize()));
}
configured.filter().documentFilter().ifPresent(bean -> sink.put("documentFilter", "#bean:" + bean));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When filter.document-filter is unset it isn't passed, so an application-wide camel.kamelet.langchain4j-ingest-sink.documentFilter still applies to every pipeline. The docs do say not to set those properties, but binding a neutral always-true Predicate as the default would pin the option, as the PR intends.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. An unset filter.document-filter now goes as an always-true predicate. The IT poison set gains camel.kamelet.langchain4j-ingest-sink.documentFilter=#bean:rejectAll. documentSplitter and idempotentRepository stay unpinned, since no neutral bean exists for them.

* document id's file extension. The twin of the {@code content-type} configuration property.
*/
public IngestPipeline contentType(String contentType) {
if (contentType == null || contentType.isBlank()) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This only rejects blank values. The component drops everything after ; and treats an empty result as unset, so contentType("; codecs=opus") passes here and then silently falls back to the file extension. Could this apply the same normalization before validating?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed for both paths: a value with nothing before ; is rejected by IngestPipeline.contentType() and, for content-type, at build time. Tests extended.

sink.put("maxSegmentSize", String.valueOf(maxSegmentSize));
sink.put("maxOverlapSize", String.valueOf(maxOverlapSize));
sink.put("embeddingBatchSize", String.valueOf(embeddingBatchSize));
sink.put("modality", modality);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The modality is forwarded exactly as typed ("Media", "MEDIA", both used in the tests), so this depends on the component's IngestModality enum conversion ignoring case. modality.toLowerCase(Locale.ROOT) here would remove that dependency.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done: it is lower-cased before it goes to the Kamelet.

----

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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On a directory pipeline, a file whose media type can't be determined fails on every poll, for ever, unless filter.include-id is set. Since retrying can never succeed, could it be committed in the register as filtered/rejected (like an empty parse is) instead of only being documented?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

True: the rollback releases the file's key. I'd keep it documented for now:

  • The extension can't tell this failure from the others without copying the component's MIME table (MediaTypes is package-private).
  • An oversized file is retried the same way on purpose (Langchain4jIngestTikaTest).
  • With a persistent register, a committed key would keep the file skipped even after a config fix.

Answering filtered for an untypable id fits better in the component. I can open a Camel issue.

@jiriOndrusek-agent

Copy link
Copy Markdown
Contributor

Thanks for the review, @jamesnetherton. The last commit addresses it. I kept it separate for an easier re-review and will squash it before merge.

That test fails until CAMEL-25463 is in the Camel snapshot, so CI will be red on it and the PR stays a draft. It now waits for:

Verified locally against a build of the fix: deployment tests, JVM and native ITs pass.

@JiriOndrusek
JiriOndrusek force-pushed the camel-main branch 2 times, most recently from 21bc352 to 82a5858 Compare October 12, 2026 07:11
jiriOndrusek-agent and others added 3 commits October 13, 2026 10:38
…Kamelets

The extension's own route builder is replaced by a composition of the
catalog Kamelets: langchain4j-ingest-file-source, tika-extract-text-action
or docling-convert-action, and langchain4j-ingest-sink, always loaded
from the classpath. Ingestion results stay the same apart from the edge
cases listed in the 4.0 migration guide. Each Kamelet runs
as its own route, named langchain4j-ingest-<name>-source, -parser or
-sink, and the extension registers the four Kamelets for native itself.
New dependencies: camel-kamelets, camel-quarkus-kamelet and
camel-quarkus-yaml-dsl.

Also: an invalid @ingest pipeline name now fails the build, and with two
store or model beans the error for a Java-declared pipeline names the
IngestPipeline setter. Pipelines with colliding route ids are rejected.

Needs the camel-kamelets 4.23.0 release, the first with these Kamelets;
until then the root pom pins 4.23.0-SNAPSHOT (marked TODO).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Builds on the Kamelet composition of apache#9152.

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.

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; the builder setters reject
bad values.

The raw-size guard before the parse stays in the extension: it reads
CamelFileLength on a directory pipeline and at most max + 1 bytes
otherwise. The parser actions' own maxDocumentSize
(apache/camel-kamelets#3087) is pinned to 0.

modality, contentType, includeId, excludeId and the parsers'
maxDocumentSize are always passed ("" when unset), so app-wide
camel.kamelet.<id>.* properties cannot change them.

Needs the camel-kamelets 4.23.0 release including
apache/camel-kamelets#3085 (modality and contentType on
langchain4j-ingest-sink).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Test that a consumer-fed media stream is read only up to the cap (needs CAMEL-25463)
- Pin an unset documentFilter to an always-true predicate
- The builder view's filter() delegates to configuration instead of null
- Name the @ingest pipelines when filter.* targets an unknown name
- Reject a content-type that is only parameters, in the builder and at build time
- Lower-case modality before it goes to the sink
- Link the advisory id filter to the component's matcher

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@jiriOndrusek-agent

Copy link
Copy Markdown
Contributor

Superseded by #9340 (rebased on main after #9315).

Generated with Claude Code on behalf of @JiriOndrusek

@JiriOndrusek

Copy link
Copy Markdown
Contributor Author

superseded by #9340

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants