jamesnetherton commented on code in PR #9018:
URL: https://github.com/apache/camel-quarkus/pull/9018#discussion_r3805508069


##########
extensions/langchain4j-ingest/runtime/src/main/java/org/apache/camel/quarkus/component/langchain4j/ingest/IngestRoutes.java:
##########
@@ -0,0 +1,276 @@
+/*
+ * 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;
+
+import java.nio.charset.StandardCharsets;
+import java.util.List;
+import java.util.Set;
+import java.util.TreeSet;
+import java.util.stream.Collectors;
+import java.util.stream.StreamSupport;
+
+import dev.langchain4j.data.segment.TextSegment;
+import dev.langchain4j.model.embedding.EmbeddingModel;
+import dev.langchain4j.store.embedding.EmbeddingStore;
+import jakarta.enterprise.context.ApplicationScoped;
+import jakarta.enterprise.inject.Any;
+import jakarta.enterprise.inject.Instance;
+import jakarta.inject.Inject;
+import org.apache.camel.Exchange;
+import org.apache.camel.Expression;
+import org.apache.camel.builder.RouteBuilder;
+import 
org.apache.camel.quarkus.component.langchain4j.ingest.core.IngestService;
+import org.apache.camel.support.builder.ExpressionBuilder;
+import 
org.apache.camel.support.processor.idempotent.MemoryIdempotentRepository;
+import org.apache.camel.util.URISupport;
+import org.jboss.logging.Logger;
+
+import static org.apache.camel.builder.endpoint.StaticEndpointBuilders.file;
+
+/**
+ * Generates one Camel route per configured ingestion pipeline. Users never 
see these routes —
+ * they are the implementation of the configuration.
+ */
+@ApplicationScoped
+public class IngestRoutes extends RouteBuilder {
+
+    private static final Logger LOG = Logger.getLogger(IngestRoutes.class);
+
+    @Inject
+    IngestBuildTimeConfig buildTimeConfig;
+
+    @Inject
+    IngestRunTimeConfig runTimeConfig;
+
+    @Inject
+    IngestBuilderPipelines builderPipelines;
+
+    @Inject
+    @Any
+    Instance<EmbeddingStore<TextSegment>> storeCandidates;
+
+    @Inject
+    @Any
+    Instance<EmbeddingModel> modelCandidates;
+
+    @Override
+    public void configure() {
+        // a pipeline may be declared entirely through runtime properties - 
the documented
+        // minimum is a directory and nothing else - so the two config roots 
are unioned. Keying
+        // off the build-time map alone would make that configuration a silent 
no-op, since
+        // SmallRye only materialises a map key for the mapping whose 
structure a property matches
+        Set<String> builderDeclared = builderPipelines.entries().stream()
+                .map(IngestBuilderPipelines.Entry::name)
+                .collect(Collectors.toSet());
+        Set<String> names = new 
TreeSet<>(buildTimeConfig.pipelines().keySet());
+        names.addAll(runTimeConfig.pipelines().keySet());
+        names.removeAll(builderDeclared);
+
+        for (String name : names) {
+            IngestBuildTimeConfig.PipelineBuildTimeConfig pipeline = 
buildTimeConfig.pipelines().get(name);
+            IngestRunTimeConfig.PipelineRunTimeConfig runtime = 
runTimeConfig.pipelines().get(name);
+
+            if (runtime != null && !runtime.enabled()) {
+                LOG.infof("Ingestion pipeline '%s' is disabled", name);
+                continue;
+            }
+
+            IngestService service = new IngestService(
+                    name,
+                    resolveStore(name, pipeline == null ? null : 
pipeline.embeddingStore().orElse(null)),
+                    resolveModel(name, pipeline == null ? null : 
pipeline.embeddingModel().orElse(null)),
+                    pipeline == null ? 
IngestBuildTimeConfig.DEFAULT_MAX_SEGMENT_SIZE : pipeline.maxSegmentSize(),
+                    pipeline == null ? 
IngestBuildTimeConfig.DEFAULT_MAX_OVERLAP_SIZE : pipeline.maxOverlapSize());
+
+            // a consumer URI says "consume from this"; its absence says "read 
that directory"
+            String uri = pipeline == null ? null : 
pipeline.source().uri().orElse(null);
+            if (uri != null && runtime != null && 
runtime.source().directory().isPresent()) {
+                throw new IllegalStateException("Ingestion pipeline '" + name 
+ "' sets both source.uri ('" + uri
+                        + "') and source.directory ('" + 
runtime.source().directory().get() + "'). A pipeline "
+                        + "reads one source: keep the URI, or drop it to read 
the directory.");
+            }
+            if (uri == null) {
+                configureFileSource(name, runtime, service);
+                LOG.infof("Ingestion pipeline '%s': source=file", name);
+            } else {
+                configureEndpointSource(name, uri, runtime, service);
+                LOG.infof("Ingestion pipeline '%s': source=%s", name, 
URISupport.sanitizeUri(uri));
+            }
+        }
+
+        for (IngestBuilderPipelines.Entry entry : builderPipelines.entries()) {
+            configureBuilderPipeline(entry);
+        }
+    }
+
+    /** An {@code @Ingest}-declared pipeline: the builder twin of the 
configuration path. */
+    private void configureBuilderPipeline(IngestBuilderPipelines.Entry entry) {
+        String name = entry.name();
+        // configuration can still switch a builder-declared pipeline off, and 
the check precedes
+        // the invocation so a disabled pipeline's method never runs
+        IngestRunTimeConfig.PipelineRunTimeConfig external = 
runTimeConfig.pipelines().get(name);
+        if (external != null && !external.enabled()) {
+            LOG.infof("Ingestion pipeline '%s' (builder) is disabled", name);
+            return;
+        }
+        // enabled is the one thing 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)
+        if (external != null && (external.source().directory().isPresent()
+                || external.source().documentId().isPresent())) {
+            throw new IllegalStateException("Ingestion pipeline '" + name + "' 
is declared in Java, so its source "
+                    + "comes from the @Ingest method. Remove 
quarkus.camel.ai.ingest." + name + ".source.* , or "
+                    + "declare the pipeline in configuration instead.");
+        }
+
+        IngestPipeline definition = builderPipelines.definition(entry);
+        IngestRunTimeConfig.PipelineRunTimeConfig runtime = 
definition.asRunTimeConfig();
+
+        IngestService service = new IngestService(
+                name,
+                resolveStore(name, 
definition.embeddingStoreName().orElse(null)),
+                resolveModel(name, 
definition.embeddingModelName().orElse(null)),
+                definition.maxSegmentSize(),
+                definition.maxOverlapSize());
+
+        switch (definition.sourceType()) {
+        case "file" -> configureFileSource(name, runtime, service);
+        case "endpoint" -> configureEndpointSource(name, 
definition.sourceUri(), runtime, service);
+        default -> throw new IllegalStateException("Unknown source type " + 
definition.sourceType());
+        }
+
+        LOG.infof("Ingestion pipeline '%s' (builder): source=%s", name,
+                URISupport.sanitizeUri(definition.sourceUri() == null ? 
definition.sourceType() : definition.sourceUri()));
+    }
+
+    private void configureFileSource(String name, 
IngestRunTimeConfig.PipelineRunTimeConfig runtime,
+            IngestService service) {
+        String directory = required(name, runtime == null ? null : 
runtime.source().directory().orElse(null),
+                "source.directory");
+        // built with the Endpoint DSL rather than concatenated: a directory 
containing ? # & or a
+        // space would otherwise mis-parse, and a crafted one could inject 
options - delete=true
+        // is honoured ahead of noop and would delete the user's documents 
after reading them.
+        // noop leaves the documents where they are (a knowledge base reads 
its source, it does
+        // not consume it), idempotent keeps the same file from being ingested 
twice, and the
+        // changed read lock waits for a file still being copied in rather 
than embedding half of
+        // it - the truncation would be permanent, since idempotent keys on 
the path. The register
+        // is sized explicitly: Camel's default caps at 1000 entries, and 
beyond that eviction
+        // would re-ingest a large directory steadily during normal operation, 
not just on restart
+        Expression documentId = documentIdExpression(runtime, 
Exchange.FILE_NAME);
+        from(file(directory)
+                .noop(true)
+                .idempotent(true)
+                
.idempotentRepository(MemoryIdempotentRepository.memoryIdempotentRepository(100_000))
+                .recursive(runtime.source().recursive())
+                .readLock("changed")
+                .charset(StandardCharsets.UTF_8.name()))
+                .routeId(routeId(name))
+                .process(exchange -> 
service.ingest(documentId.evaluate(exchange, String.class),
+                        exchange.getIn().getBody(String.class)));
+    }
+
+    /**
+     * The escape hatch: any Camel consumer feeds the pipeline. Which part of 
the exchange
+     * identifies the document is the consumer's business, so {@code 
source.document-id} says it —
+     * {@code ${header.CamelAwsS3Key}} for an S3 consumer, the message header 
otherwise.
+     */
+    private void configureEndpointSource(String name, String uri,
+            IngestRunTimeConfig.PipelineRunTimeConfig runtime, IngestService 
service) {
+        Expression documentId = documentIdExpression(runtime, 
IngestHeaders.DOCUMENT_ID);
+        from(uri)
+                .routeId(routeId(name))
+                .process(exchange -> {
+                    String id = documentId.evaluate(exchange, String.class);
+                    if (id == null) {
+                        throw new IllegalArgumentException("Ingestion pipeline 
'" + name + "': no document id. "
+                                + "Set the " + IngestHeaders.DOCUMENT_ID + " 
header, or point "
+                                + "quarkus.camel.ai.ingest." + name + 
".source.document-id at where the "
+                                + "consumer puts it.");
+                    }
+                    exchange.getIn().setBody(service.ingest(id, 
exchange.getIn().getBody(String.class)));
+                });
+    }
+
+    private Expression 
documentIdExpression(IngestRunTimeConfig.PipelineRunTimeConfig runtime,
+            String defaultHeader) {
+        String configured = runtime == null ? null : 
runtime.source().documentId().orElse(null);
+        if (configured == null) {
+            return ExpressionBuilder.headerExpression(defaultHeader);
+        }
+        // a bare header name is read as a header directly rather than parsed: 
a dotted name such
+        // as a dotted header name sends the simple parser into OGNL, and 
${...} in a properties file is
+        // MicroProfile Config expansion, which would consume an expression 
before Camel saw it
+        return configured.contains("${") ? simple(configured) : 
ExpressionBuilder.headerExpression(configured);
+    }
+
+    private static String routeId(String name) {
+        return "camel-quarkus-ai-ingest-" + name;
+    }
+
+    private static String required(String name, String value, String property) 
{
+        if (value == null) {
+            throw new IllegalStateException("Ingestion pipeline '" + name + "' 
has no " + property
+                    + ". Set quarkus.camel.ai.ingest." + name + "." + 
property);
+        }
+        return value;
+    }
+
+    private EmbeddingStore<TextSegment> resolveStore(String name, String 
configured) {

Review Comment:
   Reopening this one — the change works for the *named* path but breaks the 
unnamed one.
   
   `getRegistry().findByType(EmbeddingStore.class)` reaches 
`RuntimeBeanRepository.findByType`, which is `Arc.container().listAll(type)`. 
That is a raw required type, and CDI treats a parameterized bean type as 
assignable to a raw one only when its type parameters are unbounded variables 
or `Object` — so a bean typed `EmbeddingStore<TextSegment>` never matches. On 
this branch:
   
   ```
   registry.findByType(EmbeddingStore)     = 0
   registry.findByTypeWithName(EmbeddingStore) = []
   Arc.listAll(EmbeddingStore)             = 0
   Arc.listAll(EmbeddingModel)             = 1     <- not generic, so it does 
match
   ```
   
   with `@Named("store")` set and `quarkus.arc.remove-unused-beans=false`, so 
neither naming nor bean removal is the variable.
   
   Separately, the `@Inject @Any Instance<...>` fields that went away were the 
injection points keeping unnamed store and model beans unremovable. Even for 
the non-generic `EmbeddingModel`, an unnamed producer is now removed as unused; 
it resolves again with `quarkus.arc.remove-unused-beans=false`.
   
   Net effect: a configuration-declared pipeline with exactly one unnamed store 
and model — the arrangement `usage.adoc` documents — starts at `dd193707` and 
fails at `2541fc3a` with `needs an embedding store, but no bean of that type 
exists`.
   
   Going the other way would fix both at once: keep the `Instance` fields and 
resolve the named case through them, 
`candidates.select(NamedLiteral.of(configured))`. If the registry is the 
mechanism you would rather keep, it needs an `UnremovableBeanBuildItem` for 
both types and a `TypeLiteral<EmbeddingStore<TextSegment>>` lookup for the 
store.
   
   Worth a test either way — `IngestNoStoreBeanTest` asserts the "no bean of 
that type" message, so it currently passes whether or not resolution works.
   



##########
extensions/langchain4j-ingest/runtime/src/main/java/org/apache/camel/quarkus/component/langchain4j/ingest/IngestRunTimeConfig.java:
##########
@@ -0,0 +1,85 @@
+/*
+ * 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;
+
+import java.util.Map;
+import java.util.Optional;
+
+import io.quarkus.runtime.annotations.ConfigDocMapKey;
+import io.quarkus.runtime.annotations.ConfigPhase;
+import io.quarkus.runtime.annotations.ConfigRoot;
+import io.smallrye.config.ConfigMapping;
+import io.smallrye.config.WithDefault;
+import io.smallrye.config.WithParentName;
+
+/**
+ * Runtime configuration of ingestion pipelines: concrete locations and 
switches that may differ
+ * per deployment. The pipeline topology is build-time, see {@link 
IngestBuildTimeConfig}.
+ */
+@ConfigMapping(prefix = "quarkus.camel.ai.ingest")
+@ConfigRoot(phase = ConfigPhase.RUN_TIME)
+public interface IngestRunTimeConfig {
+
+    /**
+     * Ingestion pipelines by name.
+     */
+    @WithParentName
+    @ConfigDocMapKey("pipeline-name")
+    Map<String, PipelineRunTimeConfig> pipelines();
+
+    interface PipelineRunTimeConfig {
+
+        /**
+         * Whether this pipeline starts. Useful to switch ingestion off in dev 
mode.
+         */
+        @WithDefault("true")
+        boolean enabled();
+
+        /**
+         * The document source.
+         */
+        SourceRunTimeConfig source();
+
+        interface SourceRunTimeConfig {
+
+            /**
+             * The directory to ingest documents from, for a pipeline that has 
no `source.uri`. A
+             * path is a deployment concern, so unlike the URI it stays 
runtime configuration.
+             * Setting both is an error.
+             */
+            Optional<String> directory();
+
+            /**
+             * Whether subdirectories are ingested too, when reading a 
directory.
+             */
+            @WithDefault("true")
+            boolean recursive();
+
+            /**
+             * Where the document id lives in the exchange the consumer 
delivers: normally the
+             * name of a header, such as `CamelAwsS3Key` for an S3 consumer or 
`CamelKafkaKey` for
+             * a Kafka one. A value containing a dollar-brace placeholder is 
taken as a

Review Comment:
   `$simple{...}` is the right call, and the end-to-end coverage on the 
`custom` pipeline is good.
   
   One narrowing though: the check is `configured.startsWith("$simple{")`, 
while Camel's own `LanguageSupport.hasSimpleFunction` uses `contains` for both 
tokens (`SIMPLE_FUNCTION_START = {"${", "$simple{"}`). So 
`id-$simple{header.Foo}` matches neither branch here and is silently taken as a 
header name, even though Camel would happily parse it. The `${` branch already 
uses `contains`, so the two halves disagree with each other too — 
`contains("$simple{")` lines both up with Camel.
   



##########
extensions/langchain4j-ingest/deployment/src/main/java/org/apache/camel/quarkus/component/langchain4j/ingest/deployment/Langchain4jIngestProcessor.java:
##########
@@ -0,0 +1,224 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.quarkus.component.langchain4j.ingest.deployment;
+
+import java.lang.reflect.Modifier;
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+import io.quarkus.arc.deployment.AdditionalBeanBuildItem;
+import io.quarkus.arc.deployment.SyntheticBeanBuildItem;
+import io.quarkus.arc.deployment.SyntheticBeansRuntimeInitBuildItem;
+import 
io.quarkus.arc.deployment.ValidationPhaseBuildItem.ValidationErrorBuildItem;
+import io.quarkus.deployment.annotations.BuildProducer;
+import io.quarkus.deployment.annotations.BuildStep;
+import io.quarkus.deployment.annotations.Consume;
+import io.quarkus.deployment.annotations.ExecutionTime;
+import io.quarkus.deployment.annotations.Record;
+import io.quarkus.deployment.builditem.ApplicationArchivesBuildItem;
+import io.quarkus.deployment.builditem.CombinedIndexBuildItem;
+import io.quarkus.deployment.builditem.FeatureBuildItem;
+import io.quarkus.deployment.builditem.nativeimage.ReflectiveClassBuildItem;
+import io.quarkus.runtime.configuration.ConfigurationException;
+import jakarta.inject.Singleton;
+import org.apache.camel.quarkus.component.langchain4j.ingest.Ingest;
+import 
org.apache.camel.quarkus.component.langchain4j.ingest.IngestBuildTimeConfig;
+import 
org.apache.camel.quarkus.component.langchain4j.ingest.IngestBuilderPipelines;
+import org.apache.camel.quarkus.component.langchain4j.ingest.IngestPipeline;
+import org.apache.camel.quarkus.component.langchain4j.ingest.IngestRoutes;
+import 
org.apache.camel.quarkus.component.langchain4j.ingest.Langchain4jIngestRecorder;
+import org.apache.camel.quarkus.core.deployment.spi.CamelContextBuildItem;
+import org.apache.camel.quarkus.core.deployment.spi.CamelRuntimeTaskBuildItem;
+import org.apache.camel.quarkus.core.deployment.spi.CamelServiceBuildItem;
+import org.apache.camel.quarkus.core.deployment.util.CamelSupport;
+import org.apache.camel.quarkus.core.deployment.util.PathFilter;
+import org.apache.camel.util.URISupport;
+import org.jboss.jandex.AnnotationInstance;
+import org.jboss.jandex.AnnotationTarget;
+import org.jboss.jandex.DotName;
+import org.jboss.jandex.MethodInfo;
+
+class Langchain4jIngestProcessor {
+
+    private static final String FEATURE = "camel-langchain4j-ingest";
+
+    @BuildStep
+    FeatureBuildItem feature() {
+        return new FeatureBuildItem(FEATURE);
+    }
+
+    @BuildStep
+    AdditionalBeanBuildItem beans() {
+        return AdditionalBeanBuildItem.builder()
+                .addBeanClasses(IngestRoutes.class)
+                .setUnremovable()
+                .build();
+    }
+
+    /**
+     * Discovers {@code @Ingest} builder methods: validated here (return type, 
no parameters,
+     * unique names, no collision with configuration-declared pipelines), 
invoked reflectively once
+     * at startup.
+     */
+    @BuildStep
+    @Record(ExecutionTime.STATIC_INIT)
+    void discoverBuilderPipelines(
+            CombinedIndexBuildItem combinedIndex,
+            IngestBuildTimeConfig config,
+            Langchain4jIngestRecorder recorder,
+            BuildProducer<AdditionalBeanBuildItem> beans,
+            BuildProducer<ReflectiveClassBuildItem> reflectiveClasses,
+            BuildProducer<SyntheticBeanBuildItem> syntheticBeans) {
+
+        DotName ingestAnnotation = 
DotName.createSimple(Ingest.class.getName());
+        DotName pipelineType = 
DotName.createSimple(IngestPipeline.class.getName());
+
+        List<String> flatEntries = new ArrayList<>();
+        Set<String> names = new HashSet<>();
+        Set<String> beanClasses = new HashSet<>();
+
+        for (AnnotationInstance annotation : 
combinedIndex.getIndex().getAnnotations(ingestAnnotation)) {
+            if (annotation.target().kind() != AnnotationTarget.Kind.METHOD) {
+                continue;
+            }
+            MethodInfo method = annotation.target().asMethod();
+            String name = annotation.value().asString();
+            String location = method.declaringClass().name() + "#" + 
method.name();
+
+            if (name.isBlank()) {
+                throw new ConfigurationException("@Ingest on " + location + " 
has a blank pipeline name");
+            }
+            if (!method.returnType().name().equals(pipelineType)) {
+                throw new ConfigurationException("@Ingest method " + location 
+ " must return "
+                        + IngestPipeline.class.getSimpleName());
+            }
+            if (!method.parameters().isEmpty()) {
+                throw new ConfigurationException("@Ingest method " + location 
+ " must take no parameters");
+            }
+            // the method is invoked on a CDI bean instance, which a static 
method would bypass
+            // and a private one would run against the client proxy, seeing 
null injected fields
+            if (Modifier.isPrivate(method.flags()) || 
Modifier.isStatic(method.flags())) {
+                throw new ConfigurationException("@Ingest method " + location 
+ " must not be private or static");

Review Comment:
   Thanks — the check and the test do what I was after.
   
   One small over-reach: it rejects `final` on pseudo-scoped beans too. 
`@Singleton` and `@Dependent` get no client proxy, so a final method there is 
harmless. Narrowing the check to normal-scoped declaring beans would keep the 
message for the case that actually breaks. Not a blocker, and erring strict is 
defensible — just noting the false positive.
   



##########
extensions/langchain4j-ingest/deployment/src/main/java/org/apache/camel/quarkus/component/langchain4j/ingest/deployment/Langchain4jIngestProcessor.java:
##########
@@ -0,0 +1,224 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.quarkus.component.langchain4j.ingest.deployment;
+
+import java.lang.reflect.Modifier;
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+import io.quarkus.arc.deployment.AdditionalBeanBuildItem;
+import io.quarkus.arc.deployment.SyntheticBeanBuildItem;
+import io.quarkus.arc.deployment.SyntheticBeansRuntimeInitBuildItem;
+import 
io.quarkus.arc.deployment.ValidationPhaseBuildItem.ValidationErrorBuildItem;
+import io.quarkus.deployment.annotations.BuildProducer;
+import io.quarkus.deployment.annotations.BuildStep;
+import io.quarkus.deployment.annotations.Consume;
+import io.quarkus.deployment.annotations.ExecutionTime;
+import io.quarkus.deployment.annotations.Record;
+import io.quarkus.deployment.builditem.ApplicationArchivesBuildItem;
+import io.quarkus.deployment.builditem.CombinedIndexBuildItem;
+import io.quarkus.deployment.builditem.FeatureBuildItem;
+import io.quarkus.deployment.builditem.nativeimage.ReflectiveClassBuildItem;
+import io.quarkus.runtime.configuration.ConfigurationException;
+import jakarta.inject.Singleton;
+import org.apache.camel.quarkus.component.langchain4j.ingest.Ingest;
+import 
org.apache.camel.quarkus.component.langchain4j.ingest.IngestBuildTimeConfig;
+import 
org.apache.camel.quarkus.component.langchain4j.ingest.IngestBuilderPipelines;
+import org.apache.camel.quarkus.component.langchain4j.ingest.IngestPipeline;
+import org.apache.camel.quarkus.component.langchain4j.ingest.IngestRoutes;
+import 
org.apache.camel.quarkus.component.langchain4j.ingest.Langchain4jIngestRecorder;
+import org.apache.camel.quarkus.core.deployment.spi.CamelContextBuildItem;
+import org.apache.camel.quarkus.core.deployment.spi.CamelRuntimeTaskBuildItem;
+import org.apache.camel.quarkus.core.deployment.spi.CamelServiceBuildItem;
+import org.apache.camel.quarkus.core.deployment.util.CamelSupport;
+import org.apache.camel.quarkus.core.deployment.util.PathFilter;
+import org.apache.camel.util.URISupport;
+import org.jboss.jandex.AnnotationInstance;
+import org.jboss.jandex.AnnotationTarget;
+import org.jboss.jandex.DotName;
+import org.jboss.jandex.MethodInfo;
+
+class Langchain4jIngestProcessor {
+
+    private static final String FEATURE = "camel-langchain4j-ingest";
+
+    @BuildStep
+    FeatureBuildItem feature() {
+        return new FeatureBuildItem(FEATURE);
+    }
+
+    @BuildStep
+    AdditionalBeanBuildItem beans() {
+        return AdditionalBeanBuildItem.builder()
+                .addBeanClasses(IngestRoutes.class)
+                .setUnremovable()
+                .build();
+    }
+
+    /**
+     * Discovers {@code @Ingest} builder methods: validated here (return type, 
no parameters,
+     * unique names, no collision with configuration-declared pipelines), 
invoked reflectively once
+     * at startup.
+     */
+    @BuildStep
+    @Record(ExecutionTime.STATIC_INIT)
+    void discoverBuilderPipelines(
+            CombinedIndexBuildItem combinedIndex,
+            IngestBuildTimeConfig config,
+            Langchain4jIngestRecorder recorder,
+            BuildProducer<AdditionalBeanBuildItem> beans,
+            BuildProducer<ReflectiveClassBuildItem> reflectiveClasses,
+            BuildProducer<SyntheticBeanBuildItem> syntheticBeans) {
+
+        DotName ingestAnnotation = 
DotName.createSimple(Ingest.class.getName());
+        DotName pipelineType = 
DotName.createSimple(IngestPipeline.class.getName());
+
+        List<String> flatEntries = new ArrayList<>();
+        Set<String> names = new HashSet<>();
+        Set<String> beanClasses = new HashSet<>();
+
+        for (AnnotationInstance annotation : 
combinedIndex.getIndex().getAnnotations(ingestAnnotation)) {
+            if (annotation.target().kind() != AnnotationTarget.Kind.METHOD) {
+                continue;
+            }
+            MethodInfo method = annotation.target().asMethod();
+            String name = annotation.value().asString();
+            String location = method.declaringClass().name() + "#" + 
method.name();
+
+            if (name.isBlank()) {
+                throw new ConfigurationException("@Ingest on " + location + " 
has a blank pipeline name");
+            }
+            if (!method.returnType().name().equals(pipelineType)) {
+                throw new ConfigurationException("@Ingest method " + location 
+ " must return "
+                        + IngestPipeline.class.getSimpleName());
+            }
+            if (!method.parameters().isEmpty()) {
+                throw new ConfigurationException("@Ingest method " + location 
+ " must take no parameters");
+            }
+            // the method is invoked on a CDI bean instance, which a static 
method would bypass
+            // and a private one would run against the client proxy, seeing 
null injected fields
+            if (Modifier.isPrivate(method.flags()) || 
Modifier.isStatic(method.flags())) {
+                throw new ConfigurationException("@Ingest method " + location 
+ " must not be private or static");
+            }
+            if (!names.add(name) || config.pipelines().containsKey(name)) {
+                throw new ConfigurationException("Ingestion pipeline '" + name 
+ "' is declared more than once "
+                        + "(builder and/or configuration). Pipeline names must 
be unique.");
+            }
+
+            flatEntries.add(name);
+            flatEntries.add(method.declaringClass().name().toString());
+            flatEntries.add(method.name());
+            beanClasses.add(method.declaringClass().name().toString());
+        }
+
+        if (!beanClasses.isEmpty()) {
+            beans.produce(AdditionalBeanBuildItem.builder()
+                    .addBeanClasses(beanClasses.toArray(new String[0]))
+                    .setUnremovable()
+                    .build());
+            
reflectiveClasses.produce(ReflectiveClassBuildItem.builder(beanClasses.toArray(new
 String[0]))
+                    .methods()
+                    .build());
+        }
+
+        
syntheticBeans.produce(SyntheticBeanBuildItem.configure(IngestBuilderPipelines.class)
+                .scope(Singleton.class)
+                .unremovable()
+                .runtimeValue(recorder.createBuilderPipelines(flatEntries))
+                .done());
+    }
+
+    /**
+     * The pre-start half of the component-presence check: recorded as a Camel 
runtime task, it
+     * runs after ArC is fully initialised but before the Camel runtime is 
assembled — and thus
+     * before Camel Main binds {@code camel.component.*} properties, whose 
failure for a missing
+     * component would otherwise preempt the friendlier add-extension hint.
+     */
+    @BuildStep
+    @Record(ExecutionTime.RUNTIME_INIT)
+    @Consume(SyntheticBeansRuntimeInitBuildItem.class)
+    CamelRuntimeTaskBuildItem checkComponentsPresent(Langchain4jIngestRecorder 
recorder,
+            CamelContextBuildItem camelContext) {
+        recorder.checkComponentsPresent(camelContext.getCamelContext());
+        return new CamelRuntimeTaskBuildItem("langchain4j-ingest-components");
+    }
+
+    /**
+     * A pipeline whose consumer URI names a component that is not on the 
classpath stops the
+     * build, with the command that fixes it rather than a startup failure. 
Only configured URIs
+     * can be checked: a builder-declared pipeline composes its URI at 
startup, where the pre-start
+     * task above applies the same hint. Component services are 
REGISTRY-destination, so they are
+     * read from the application archives directly — they never appear among 
the DISCOVERY
+     * {@code CamelServiceBuildItem}s.
+     */
+    @BuildStep
+    void validateConnectorsPresent(IngestBuildTimeConfig config, 
ApplicationArchivesBuildItem applicationArchives,
+            BuildProducer<ValidationErrorBuildItem> validationErrors) {
+        PathFilter pathFilter = new PathFilter.Builder()
+                .include("META-INF/services/org/apache/camel/component/*")
+                .build();
+        Set<String> components = CamelSupport.services(applicationArchives, 
pathFilter)
+                .map(CamelServiceBuildItem::getName)
+                .collect(Collectors.toSet());
+
+        for (Map.Entry<String, IngestBuildTimeConfig.PipelineBuildTimeConfig> 
entry : config.pipelines().entrySet()) {
+            String uri = entry.getValue().source().uri().orElse(null);
+            if (uri == null) {
+                continue;
+            }
+            int colon = uri.indexOf(':');
+            String scheme = colon < 1 ? uri : uri.substring(0, colon);
+            // a placeholder resolves at startup, so its scheme cannot be 
known here
+            if (scheme.contains("{{") || scheme.contains("$")) {
+                continue;
+            }
+            if (!components.contains(scheme)) {
+                // the URI is sanitized: a consumer URI may legitimately carry 
credentials, and a
+                // build log is no place for them. The artifact hint is a 
heuristic - multi-scheme
+                // components (smtp -> camel-quarkus-mail) name their 
extension differently
+                validationErrors.produce(new ValidationErrorBuildItem(new 
ConfigurationException(
+                        "Ingestion pipeline '" + entry.getKey() + "' consumes 
from '"
+                                + URISupport.sanitizeUri(uri) + "', but the 
Camel component '" + scheme
+                                + "' is not on the classpath.\nAdd the 
extension that provides it, usually:"
+                                + "  ./mvnw quarkus:add-extension 
-Dextensions=camel-quarkus-" + scheme)));

Review Comment:
   The message reads well now, in both checks.
   
   The class javadoc on `IngestComponentPresence` still describes the old form 
though — "Fails a pipeline whose consumer component is not on the classpath 
with the same add-extension hint the build gives for configured URIs".
   



##########
integration-tests/langchain4j-ingest/pom.xml:
##########
@@ -0,0 +1,183 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+
+    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.
+
+-->
+<project xmlns="http://maven.apache.org/POM/4.0.0"; 
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"; 
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 
http://maven.apache.org/xsd/maven-4.0.0.xsd";>
+    <modelVersion>4.0.0</modelVersion>
+    <parent>
+        <groupId>org.apache.camel.quarkus</groupId>
+        <artifactId>camel-quarkus-build-parent-it</artifactId>
+        <version>3.39.0-SNAPSHOT</version>
+        <relativePath>../../poms/build-parent-it/pom.xml</relativePath>
+    </parent>
+
+    <artifactId>camel-quarkus-integration-test-langchain4j-ingest</artifactId>
+    <name>Camel Quarkus :: Integration Tests :: LangChain4j Ingest</name>
+    <description>Integration tests for the declarative AI document ingestion 
extension</description>
+
+    <dependencies>
+        <dependency>
+            <groupId>org.apache.camel.quarkus</groupId>
+            <artifactId>camel-quarkus-langchain4j-ingest</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>dev.langchain4j</groupId>
+            <artifactId>langchain4j</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>io.quarkus</groupId>
+            <artifactId>quarkus-rest</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>io.quarkus</groupId>
+            <artifactId>quarkus-rest-jackson</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>org.apache.camel.quarkus</groupId>
+            <artifactId>camel-quarkus-direct</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>org.apache.camel.quarkus</groupId>
+            <artifactId>camel-quarkus-aws2-s3</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>org.apache.camel.quarkus</groupId>
+            <artifactId>camel-quarkus-kafka</artifactId>
+        </dependency>
+
+        <!-- test dependencies -->
+        <dependency>
+            <groupId>io.quarkus</groupId>
+            <artifactId>quarkus-junit</artifactId>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>io.rest-assured</groupId>
+            <artifactId>rest-assured</artifactId>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.awaitility</groupId>
+            <artifactId>awaitility</artifactId>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.apache.camel.quarkus</groupId>
+            
<artifactId>camel-quarkus-integration-tests-support-kafka</artifactId>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.testcontainers</groupId>
+            <artifactId>testcontainers</artifactId>
+            <scope>test</scope>
+        </dependency>
+    </dependencies>
+
+    <profiles>
+        <profile>
+            <id>native</id>
+            <activation>
+                <property>
+                    <name>native</name>
+                </property>
+            </activation>
+            <properties>
+                <quarkus.native.enabled>true</quarkus.native.enabled>
+            </properties>
+            <build>
+                <plugins>
+                    <plugin>
+                        <groupId>org.apache.maven.plugins</groupId>
+                        <artifactId>maven-failsafe-plugin</artifactId>
+                        <executions>
+                            <execution>
+                                <goals>
+                                    <goal>integration-test</goal>
+                                    <goal>verify</goal>
+                                </goals>
+                            </execution>
+                        </executions>
+                    </plugin>
+                </plugins>
+            </build>
+        </profile>
+        <profile>
+            <id>virtualDependencies</id>
+            <activation>
+                <property>
+                    <name>!noVirtualDependencies</name>
+                </property>
+            </activation>
+            <dependencies>
+                <!-- The following dependencies guarantee that this module is 
built after them. You can update them by running `mvn process-resources 
-Pformat -N` from the source tree root directory -->
+                <dependency>
+                    <groupId>org.apache.camel.quarkus</groupId>
+                    <artifactId>camel-quarkus-aws2-s3-deployment</artifactId>
+                    <version>${project.version}</version>
+                    <type>pom</type>
+                    <scope>test</scope>
+                    <exclusions>
+                        <exclusion>
+                            <groupId>*</groupId>
+                            <artifactId>*</artifactId>
+                        </exclusion>
+                    </exclusions>
+                </dependency>
+                <dependency>
+                    <groupId>org.apache.camel.quarkus</groupId>
+                    <artifactId>camel-quarkus-direct-deployment</artifactId>
+                    <version>${project.version}</version>
+                    <type>pom</type>
+                    <scope>test</scope>
+                    <exclusions>
+                        <exclusion>
+                            <groupId>*</groupId>
+                            <artifactId>*</artifactId>
+                        </exclusion>
+                    </exclusions>
+                </dependency>
+                <dependency>
+                    <groupId>org.apache.camel.quarkus</groupId>
+                    <artifactId>camel-quarkus-kafka-deployment</artifactId>
+                    <version>${project.version}</version>
+                    <type>pom</type>
+                    <scope>test</scope>
+                    <exclusions>
+                        <exclusion>
+                            <groupId>*</groupId>
+                            <artifactId>*</artifactId>
+                        </exclusion>
+                    </exclusions>
+                </dependency>
+                <dependency>
+                    <groupId>org.apache.camel.quarkus</groupId>
+                    
<artifactId>camel-quarkus-langchain4j-ingest-deployment</artifactId>
+                    <version>${project.version}</version>
+                    <type>pom</type>
+                    <scope>test</scope>
+                    <exclusions>
+                        <exclusion>
+                            <groupId>*</groupId>
+                            <artifactId>*</artifactId>
+                        </exclusion>
+                    </exclusions>
+                </dependency>
+            </dependencies>
+        </profile>
+    </profiles>

Review Comment:
   Confirmed locally: `mvn verify -Dskip-testcontainers-tests` on this module 
runs the four non-container tests, starts no container, and passes.
   
   The surefire-only exclusion is fine as things stand — failsafe is bound only 
in the `native` profile, and no workflow combines `-Dnative` with 
`-Dskip-testcontainers-tests`, so the `*IT` classes never run on those jobs. 
Worth keeping in mind if one ever does, since the 74 modules using 
`<skipTests>true</skipTests>` would be covered there and this one would not.
   



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to