You are viewing a plain text version of this content. The canonical link for it is here.
Posted to issues@solr.apache.org by "nicolo-rinaldi (via GitHub)" <gi...@apache.org> on 2026/04/01 16:11:48 UTC

[PR] SOLR-18187: Document enrichment with LLMs [solr]

nicolo-rinaldi opened a new pull request, #4259:
URL: https://github.com/apache/solr/pull/4259

   https://issues.apache.org/jira/browse/SOLR-18187
   
   <!--
   _(If you are a project committer then you may remove some/all of the following template.)_
   
   Before creating a pull request, please file an issue in the ASF Jira system for Solr:
   
   * https://issues.apache.org/jira/projects/SOLR
   
   For something minor (i.e. that wouldn't be worth putting in release notes), you can skip JIRA.
   To create a Jira issue, you will need to create an account there first.
   
   The title of the PR should reference the Jira issue number in the form:
   
   * SOLR-####: <short description of problem or changes>
   
   SOLR must be fully capitalized. A short description helps people scanning pull requests for items they can work on.
   
   Properly referencing the issue in the title ensures that Jira is correctly updated with code review comments and commits. -->
   
   
   # Description
   
   The goal of this PR is to add a way to integrate LLMs directly into Solr at index time to fill fields that might be useful (e.g., categories, tags, etc.)
   
   # Solution
   
   This PR adds LLM-based document enrichment capabilities to Solr's indexing pipeline via a new DocumentEnrichmentUpdateProcessorFactory in the language-models module. The processor allows users to enrich documents at index time by calling an LLM (via https://github.com/langchain4j/langchain4j) with a configurable prompt built from one or more existing document fields (inputFields), and storing the model's response into an output field. The output field can be of different types (i.e., string, text, int, long, float, double, boolean, and date) and can be single-valued or multi-valued. The structured output has been used to adapt to the output field type.
   
   The implementation has taken inspiration from the text-to-vector feature in the same module. This has been done to keep the implementation consistent with conventions already in the language-models module.
   
   Note: this PR was developed with assistance from Claude Code (Anthropic). 
   
   # Tests
   Tests covering configuration validation (missing required params, conflicting params, invalid field types, placeholder mismatches), and processor initialization. 
   
   Tests covering single-valued and multi-valued output fields of all supported types, multi-input-field prompts, prompt file loading, error handling (model exceptions, ambiguous/malformed JSON responses, unsupported model types), and skipNullOrMissingFieldValues behaviour. All the supported models have been tested.
   
    
   # Checklist
   
   Please review the following and check all that apply:
   
   - [x] I have reviewed the guidelines for [How to Contribute](https://github.com/apache/solr/blob/main/CONTRIBUTING.md) and my code conforms to the standards described there to the best of my ability.
   - [x] I have created a Jira issue and added the issue ID to my pull request title.
   - [ ] I have given Solr maintainers [access](https://help.github.com/en/articles/allowing-changes-to-a-pull-request-branch-created-from-a-fork) to contribute to my PR branch. (optional but recommended, not available for branches on forks living under an organisation)
   - [x] I have developed this patch against the `main` branch.
   - [ ] I have run `./gradlew check`.
   - [x] I have added tests for my changes.
   - [x] I have added documentation for the [Reference Guide](https://github.com/apache/solr/tree/main/solr/solr-ref-guide)
   - [x] I have added a [changelog entry](https://github.com/apache/solr/blob/main/dev-docs/changelog.adoc) for my change
   


-- 
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: issues-unsubscribe@solr.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


---------------------------------------------------------------------
To unsubscribe, e-mail: issues-unsubscribe@solr.apache.org
For additional commands, e-mail: issues-help@solr.apache.org


Re: [PR] SOLR-18187: Document enrichment with LLMs [solr]

Posted by "nicolo-rinaldi (via GitHub)" <gi...@apache.org>.
nicolo-rinaldi commented on code in PR #4259:
URL: https://github.com/apache/solr/pull/4259#discussion_r3225516262


##########
solr/modules/language-models/src/java/org/apache/solr/languagemodels/update/processor/factory/DocumentEnrichmentUpdateProcessorFactory.java:
##########
@@ -0,0 +1,335 @@
+/*
+ * 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.solr.languagemodels.update.processor.factory;
+
+import dev.langchain4j.model.chat.request.ResponseFormat;
+import dev.langchain4j.model.chat.request.ResponseFormatType;
+import dev.langchain4j.model.chat.request.json.JsonArraySchema;
+import dev.langchain4j.model.chat.request.json.JsonBooleanSchema;
+import dev.langchain4j.model.chat.request.json.JsonIntegerSchema;
+import dev.langchain4j.model.chat.request.json.JsonNumberSchema;
+import dev.langchain4j.model.chat.request.json.JsonObjectSchema;
+import dev.langchain4j.model.chat.request.json.JsonSchema;
+import dev.langchain4j.model.chat.request.json.JsonSchemaElement;
+import dev.langchain4j.model.chat.request.json.JsonStringSchema;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import java.util.Collection;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import org.apache.solr.common.SolrException;
+import org.apache.solr.common.params.RequiredSolrParams;
+import org.apache.solr.common.params.SolrParams;
+import org.apache.solr.common.util.NamedList;
+import org.apache.solr.core.SolrCore;
+import org.apache.solr.core.SolrResourceLoader;
+import org.apache.solr.languagemodels.model.SolrLargeLanguageModel;
+import org.apache.solr.languagemodels.store.rest.ManagedLargeLanguageModelStore;
+import org.apache.solr.languagemodels.update.processor.DocumentEnrichmentUpdateProcessor;
+import org.apache.solr.request.SolrQueryRequest;
+import org.apache.solr.response.SolrQueryResponse;
+import org.apache.solr.rest.ManagedResource;
+import org.apache.solr.rest.ManagedResourceObserver;
+import org.apache.solr.schema.BoolField;
+import org.apache.solr.schema.DatePointField;
+import org.apache.solr.schema.DenseVectorField;
+import org.apache.solr.schema.DoublePointField;
+import org.apache.solr.schema.FieldType;
+import org.apache.solr.schema.FloatPointField;
+import org.apache.solr.schema.IndexSchema;
+import org.apache.solr.schema.IntPointField;
+import org.apache.solr.schema.LongPointField;
+import org.apache.solr.schema.NestPathField;
+import org.apache.solr.schema.SchemaField;
+import org.apache.solr.schema.StrField;
+import org.apache.solr.schema.TextField;
+import org.apache.solr.schema.UUIDField;
+import org.apache.solr.update.processor.UpdateRequestProcessor;
+import org.apache.solr.update.processor.UpdateRequestProcessorFactory;
+import org.apache.solr.util.plugin.SolrCoreAware;
+
+/**
+ * Generate the content of {@code outputField} based on other fields specified as {@code
+ * inputField}s.
+ *
+ * <p>The following validation rules are applied:
+ *
+ * <ul>
+ *   <li>At least one {@code inputField} must be declared.
+ *   <li>Exactly one of {@code prompt} or {@code promptFile} must be provided.
+ *   <li>Every declared {@code inputField} must have a corresponding {@code {fieldName}} placeholder
+ *       in the prompt.
+ *   <li>Every {@code {placeholder}} in the prompt must correspond to a declared {@code inputField}.
+ *   <li>One and only one {@code outputField} is allowed.
+ * </ul>
+ *
+ * <pre class="prettyprint" >
+ * &lt;processor class=&quot;solr.llm.documentenrichment.update.processor.DocumentEnrichmentUpdateProcessorFactory&quot;&gt;
+ *   &lt;str name=&quot;inputField&quot;&gt;title_field&lt;/str&gt;
+ *   &lt;str name=&quot;inputField&quot;&gt;body_field&lt;/str&gt;
+ *   &lt;str name=&quot;outputField&quot;&gt;enriched_field&lt;/str&gt;
+ *   &lt;str name=&quot;prompt&quot;&gt;Title: {title_field}. Body: {body_field}.&lt;/str&gt; // or &lt;str name=&quot;promptFile&quot;&gt;prompt.txt&lt;/str&gt;
+ *   &lt;str name=&quot;model&quot;&gt;model-name&lt;/str&gt;
+ * &lt;/processor&gt;
+ * </pre>
+ *
+ * <p>Multiple {@code inputField} values can also be declared as an array using {@code arr}:
+ *
+ * <pre class="prettyprint" >
+ * &lt;arr name=&quot;inputField&quot;&gt;
+ *   &lt;str&gt;title_field&lt;/str&gt;
+ *   &lt;str&gt;body_field&lt;/str&gt;
+ * &lt;/arr&gt;
+ * </pre>
+ */
+public class DocumentEnrichmentUpdateProcessorFactory extends UpdateRequestProcessorFactory
+    implements SolrCoreAware, ManagedResourceObserver {
+  private static final String INPUT_FIELD_PARAM = "inputField";
+  private static final String OUTPUT_FIELD_PARAM = "outputField";
+  private static final String PROMPT = "prompt";
+  private static final String PROMPT_FILE = "promptFile";
+  private static final String MODEL_NAME = "model";
+  private static final String JSON_SCHEMA_NAME = "output";
+  public static final String JSON_FIELD_PROPERTY = "value";
+  private static final Pattern PLACEHOLDER_PATTERN = Pattern.compile("\\{([^}]+)\\}");
+
+  private List<String> inputFields;
+  private String outputField;
+  private String promptText;
+  private String promptFile;
+  private String modelName;
+
+  @Override
+  public void init(final NamedList<?> args) {
+    // removeConfigArgs handles both multiple <str name="inputField"> and <arr name="inputField">
+    // and must be called before toSolrParams() since it mutates args in place
+    Collection<String> fieldNames = args.removeConfigArgs(INPUT_FIELD_PARAM);
+    if (fieldNames.isEmpty()) {
+      throw new SolrException(
+          SolrException.ErrorCode.SERVER_ERROR, "At least one 'inputField' must be provided");
+    }
+    inputFields = List.copyOf(fieldNames);
+
+    Collection<String> outputFields = args.removeConfigArgs(OUTPUT_FIELD_PARAM);
+    if (outputFields.isEmpty()) {
+      throw new SolrException(
+          SolrException.ErrorCode.SERVER_ERROR, "Exactly one 'outputField' must be provided");
+    }
+    if (outputFields.size() > 1) {
+      throw new SolrException(
+          SolrException.ErrorCode.SERVER_ERROR,
+          "Only one 'outputField' can be provided, but found: " + outputFields);
+    }
+    outputField = outputFields.iterator().next();
+
+    SolrParams params = args.toSolrParams();
+    RequiredSolrParams required = params.required();
+    modelName = required.get(MODEL_NAME);
+
+    String inlinePrompt = params.get(PROMPT);
+    String promptFilePath = params.get(PROMPT_FILE);
+
+    if (inlinePrompt == null && promptFilePath == null) {
+      throw new SolrException(
+          SolrException.ErrorCode.SERVER_ERROR, "Either 'prompt' or 'promptFile' must be provided");
+    }
+    if (inlinePrompt != null && promptFilePath != null) {
+      throw new SolrException(
+          SolrException.ErrorCode.SERVER_ERROR,
+          "Only one of 'prompt' or 'promptFile' can be provided, not both");
+    }
+    if (inlinePrompt != null) {
+      validatePromptPlaceholders(inlinePrompt, inputFields);
+      this.promptText = inlinePrompt;
+    }
+    this.promptFile = promptFilePath;
+  }
+
+  @Override
+  public void inform(SolrCore core) {
+    final SolrResourceLoader solrResourceLoader = core.getResourceLoader();
+    ManagedLargeLanguageModelStore.registerManagedLargeLanguageModelStore(solrResourceLoader, this);
+    if (promptFile != null) {
+      try (InputStream is = solrResourceLoader.openResource(promptFile)) {
+        promptText = new String(is.readAllBytes(), StandardCharsets.UTF_8).trim();
+      } catch (IOException e) {
+        throw new SolrException(
+            SolrException.ErrorCode.SERVER_ERROR, "Cannot read prompt file: " + promptFile, e);
+      }
+      validatePromptPlaceholders(promptText, inputFields);
+    }
+  }
+
+  @Override
+  public void onManagedResourceInitialized(NamedList<?> args, ManagedResource res)
+      throws SolrException {
+    if (res instanceof ManagedLargeLanguageModelStore store) {
+      store.loadStoredModels();
+    }
+  }
+
+  @Override
+  public UpdateRequestProcessor getInstance(
+      SolrQueryRequest req, SolrQueryResponse rsp, UpdateRequestProcessor next) {
+    IndexSchema latestSchema = req.getCore().getLatestSchema();
+
+    for (String fieldName : inputFields) {
+      if (!latestSchema.isDynamicField(fieldName) && !latestSchema.hasExplicitField(fieldName)) {
+        throw new SolrException(
+            SolrException.ErrorCode.SERVER_ERROR, "undefined field: \"" + fieldName + "\"");
+      }
+    }
+
+    final SchemaField outputFieldSchema = latestSchema.getField(outputField);
+
+    ResponseFormat responseFormat = getJsonSchema(outputFieldSchema);
+    boolean multiValued = outputFieldSchema.multiValued();
+
+    ManagedLargeLanguageModelStore store =
+        ManagedLargeLanguageModelStore.getManagedModelStore(req.getCore());
+    SolrLargeLanguageModel fieldGenerationModel = store.getModel(modelName);
+    if (fieldGenerationModel == null) {
+      throw new SolrException(
+          SolrException.ErrorCode.SERVER_ERROR,
+          "The model configured in the Update Request Processor '"
+              + modelName
+              + "' can't be found in the store: "
+              + ManagedLargeLanguageModelStore.REST_END_POINT);
+    }
+
+    return new DocumentEnrichmentUpdateProcessor(
+        inputFields,
+        outputField,
+        promptText,
+        fieldGenerationModel,
+        multiValued,
+        responseFormat,
+        req,
+        next);
+  }
+
+  /**
+   * Builds a {@link ResponseFormat} that instructs the model to return a JSON object {@code
+   * {"value": ...}} whose value type matches the Solr field type. For multivalued fields the value
+   * is wrapped in a {@link JsonArraySchema} nested inside the root {@link JsonObjectSchema}.
+   *
+   * <p>Nesting {@link JsonArraySchema} inside a {@link JsonObjectSchema} property is supported by
+   * all langchain4j providers that implement structured outputs with {@link JsonObjectSchema}
+   * (OpenAI, Azure OpenAI, Google AI, Gemini, Mistral, Ollama, Amazon Bedrock, Watsonx).
+   */
+  static ResponseFormat getJsonSchema(SchemaField schemaField) {
+    JsonSchemaElement valueElement = toJsonSchemaElement(schemaField.getType());
+    JsonSchemaElement valueSchema =
+        schemaField.multiValued()
+            ? JsonArraySchema.builder().items(valueElement).build()
+            : valueElement;
+    // estrai costanti output e value

Review Comment:
   done



-- 
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: issues-unsubscribe@solr.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


---------------------------------------------------------------------
To unsubscribe, e-mail: issues-unsubscribe@solr.apache.org
For additional commands, e-mail: issues-help@solr.apache.org


Re: [PR] SOLR-18187: Document enrichment with LLMs [solr]

Posted by "alessandrobenedetti (via GitHub)" <gi...@apache.org>.
alessandrobenedetti commented on code in PR #4259:
URL: https://github.com/apache/solr/pull/4259#discussion_r3122738302


##########
solr/modules/language-models/src/java/org/apache/solr/languagemodels/documentenrichment/update/processor/DocumentEnrichmentUpdateProcessorFactory.java:
##########
@@ -0,0 +1,338 @@
+/*
+ * 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.solr.languagemodels.documentenrichment.update.processor;
+
+import dev.langchain4j.model.chat.request.ResponseFormat;
+import dev.langchain4j.model.chat.request.ResponseFormatType;
+import dev.langchain4j.model.chat.request.json.JsonArraySchema;
+import dev.langchain4j.model.chat.request.json.JsonBooleanSchema;
+import dev.langchain4j.model.chat.request.json.JsonIntegerSchema;
+import dev.langchain4j.model.chat.request.json.JsonNumberSchema;
+import dev.langchain4j.model.chat.request.json.JsonObjectSchema;
+import dev.langchain4j.model.chat.request.json.JsonSchema;
+import dev.langchain4j.model.chat.request.json.JsonSchemaElement;
+import dev.langchain4j.model.chat.request.json.JsonStringSchema;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import java.util.Collection;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import org.apache.solr.common.SolrException;
+import org.apache.solr.common.params.RequiredSolrParams;
+import org.apache.solr.common.params.SolrParams;
+import org.apache.solr.common.util.NamedList;
+import org.apache.solr.core.SolrCore;
+import org.apache.solr.core.SolrResourceLoader;
+import org.apache.solr.languagemodels.documentenrichment.model.SolrChatModel;
+import org.apache.solr.languagemodels.documentenrichment.store.rest.ManagedChatModelStore;
+import org.apache.solr.request.SolrQueryRequest;
+import org.apache.solr.response.SolrQueryResponse;
+import org.apache.solr.rest.ManagedResource;
+import org.apache.solr.rest.ManagedResourceObserver;
+import org.apache.solr.schema.BoolField;
+import org.apache.solr.schema.DatePointField;
+import org.apache.solr.schema.DenseVectorField;
+import org.apache.solr.schema.DoublePointField;
+import org.apache.solr.schema.FieldType;
+import org.apache.solr.schema.FloatPointField;
+import org.apache.solr.schema.IndexSchema;
+import org.apache.solr.schema.IntPointField;
+import org.apache.solr.schema.LongPointField;
+import org.apache.solr.schema.NestPathField;
+import org.apache.solr.schema.SchemaField;
+import org.apache.solr.schema.StrField;
+import org.apache.solr.schema.TextField;
+import org.apache.solr.schema.UUIDField;
+import org.apache.solr.update.processor.UpdateRequestProcessor;
+import org.apache.solr.update.processor.UpdateRequestProcessorFactory;
+import org.apache.solr.util.plugin.SolrCoreAware;
+
+/**
+ * Generate the content of a field based on other fields specified as input.
+ *
+ * <p>One or more {@code inputField} parameters specify the Solr fields to use as input. Each field
+ * name must appear as a {@code {fieldName}} placeholder in the prompt. Exactly one of {@code
+ * prompt} or {@code promptFile} must be provided.
+ *
+ * <pre class="prettyprint" >
+ * &lt;processor class=&quot;solr.llm.documentenrichment.update.processor.DocumentEnrichmentUpdateProcessorFactory&quot;&gt;
+ *   &lt;str name=&quot;inputField&quot;&gt;title_field&lt;/str&gt;
+ *   &lt;str name=&quot;inputField&quot;&gt;body_field&lt;/str&gt;
+ *   &lt;str name=&quot;outputField&quot;&gt;enriched_field&lt;/str&gt;
+ *   &lt;str name=&quot;prompt&quot;&gt;Title: {title_field}. Body: {body_field}.&lt;/str&gt;
+ *   &lt;str name=&quot;model&quot;&gt;ChatModel&lt;/str&gt;
+ * &lt;/processor&gt;
+ * </pre>
+ *
+ * <p>Multiple {@code inputField} values can also be declared as an array using {@code arr}:
+ *
+ * <pre class="prettyprint" >
+ * &lt;processor class=&quot;solr.llm.documentenrichment.update.processor.DocumentEnrichmentUpdateProcessorFactory&quot;&gt;
+ *   &lt;arr name=&quot;inputField&quot;&gt;
+ *     &lt;str&gt;title_field&lt;/str&gt;
+ *     &lt;str&gt;body_field&lt;/str&gt;
+ *   &lt;/arr&gt;
+ *   &lt;str name=&quot;outputField&quot;&gt;enriched_field&lt;/str&gt;
+ *   &lt;str name=&quot;prompt&quot;&gt;Title: {title_field}. Body: {body_field}.&lt;/str&gt;
+ *   &lt;str name=&quot;model&quot;&gt;ChatModel&lt;/str&gt;
+ * &lt;/processor&gt;
+ * </pre>
+ *
+ * <p>Alternatively, the prompt can be loaded from a text file using {@code promptFile}:
+ *
+ * <pre class="prettyprint" >
+ * &lt;processor class=&quot;solr.llm.documentenrichment.update.processor.DocumentEnrichmentUpdateProcessorFactory&quot;&gt;
+ *   &lt;str name=&quot;inputField&quot;&gt;title_field&lt;/str&gt;
+ *   &lt;str name=&quot;outputField&quot;&gt;enriched_field&lt;/str&gt;
+ *   &lt;str name=&quot;promptFile&quot;&gt;prompt.txt&lt;/str&gt;
+ *   &lt;str name=&quot;model&quot;&gt;ChatModel&lt;/str&gt;
+ * &lt;/processor&gt;
+ * </pre>
+ *
+ * <p>Validation rules:
+ *
+ * <ul>
+ *   <li>At least one {@code inputField} must be declared.
+ *   <li>Exactly one of {@code prompt} or {@code promptFile} must be provided.
+ *   <li>Every declared {@code inputField} must have a corresponding {@code {fieldName}} placeholder
+ *       in the prompt.
+ *   <li>Every {@code {placeholder}} in the prompt must correspond to a declared {@code inputField}.
+ * </ul>
+ */

Review Comment:
   The comment is full of encoded symbols, it's basically unreadable, possible built for Javadocs only, but in general if a comment is there it should be readable also simply via code



##########
solr/modules/language-models/src/java/org/apache/solr/languagemodels/documentenrichment/update/processor/DocumentEnrichmentUpdateProcessorFactory.java:
##########
@@ -0,0 +1,338 @@
+/*
+ * 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.solr.languagemodels.documentenrichment.update.processor;
+
+import dev.langchain4j.model.chat.request.ResponseFormat;
+import dev.langchain4j.model.chat.request.ResponseFormatType;
+import dev.langchain4j.model.chat.request.json.JsonArraySchema;
+import dev.langchain4j.model.chat.request.json.JsonBooleanSchema;
+import dev.langchain4j.model.chat.request.json.JsonIntegerSchema;
+import dev.langchain4j.model.chat.request.json.JsonNumberSchema;
+import dev.langchain4j.model.chat.request.json.JsonObjectSchema;
+import dev.langchain4j.model.chat.request.json.JsonSchema;
+import dev.langchain4j.model.chat.request.json.JsonSchemaElement;
+import dev.langchain4j.model.chat.request.json.JsonStringSchema;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import java.util.Collection;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import org.apache.solr.common.SolrException;
+import org.apache.solr.common.params.RequiredSolrParams;
+import org.apache.solr.common.params.SolrParams;
+import org.apache.solr.common.util.NamedList;
+import org.apache.solr.core.SolrCore;
+import org.apache.solr.core.SolrResourceLoader;
+import org.apache.solr.languagemodels.documentenrichment.model.SolrChatModel;
+import org.apache.solr.languagemodels.documentenrichment.store.rest.ManagedChatModelStore;
+import org.apache.solr.request.SolrQueryRequest;
+import org.apache.solr.response.SolrQueryResponse;
+import org.apache.solr.rest.ManagedResource;
+import org.apache.solr.rest.ManagedResourceObserver;
+import org.apache.solr.schema.BoolField;
+import org.apache.solr.schema.DatePointField;
+import org.apache.solr.schema.DenseVectorField;
+import org.apache.solr.schema.DoublePointField;
+import org.apache.solr.schema.FieldType;
+import org.apache.solr.schema.FloatPointField;
+import org.apache.solr.schema.IndexSchema;
+import org.apache.solr.schema.IntPointField;
+import org.apache.solr.schema.LongPointField;
+import org.apache.solr.schema.NestPathField;
+import org.apache.solr.schema.SchemaField;
+import org.apache.solr.schema.StrField;
+import org.apache.solr.schema.TextField;
+import org.apache.solr.schema.UUIDField;
+import org.apache.solr.update.processor.UpdateRequestProcessor;
+import org.apache.solr.update.processor.UpdateRequestProcessorFactory;
+import org.apache.solr.util.plugin.SolrCoreAware;
+
+/**
+ * Generate the content of a field based on other fields specified as input.
+ *
+ * <p>One or more {@code inputField} parameters specify the Solr fields to use as input. Each field
+ * name must appear as a {@code {fieldName}} placeholder in the prompt. Exactly one of {@code
+ * prompt} or {@code promptFile} must be provided.
+ *
+ * <pre class="prettyprint" >
+ * &lt;processor class=&quot;solr.llm.documentenrichment.update.processor.DocumentEnrichmentUpdateProcessorFactory&quot;&gt;
+ *   &lt;str name=&quot;inputField&quot;&gt;title_field&lt;/str&gt;
+ *   &lt;str name=&quot;inputField&quot;&gt;body_field&lt;/str&gt;
+ *   &lt;str name=&quot;outputField&quot;&gt;enriched_field&lt;/str&gt;
+ *   &lt;str name=&quot;prompt&quot;&gt;Title: {title_field}. Body: {body_field}.&lt;/str&gt;
+ *   &lt;str name=&quot;model&quot;&gt;ChatModel&lt;/str&gt;
+ * &lt;/processor&gt;
+ * </pre>
+ *
+ * <p>Multiple {@code inputField} values can also be declared as an array using {@code arr}:
+ *
+ * <pre class="prettyprint" >
+ * &lt;processor class=&quot;solr.llm.documentenrichment.update.processor.DocumentEnrichmentUpdateProcessorFactory&quot;&gt;
+ *   &lt;arr name=&quot;inputField&quot;&gt;
+ *     &lt;str&gt;title_field&lt;/str&gt;
+ *     &lt;str&gt;body_field&lt;/str&gt;
+ *   &lt;/arr&gt;
+ *   &lt;str name=&quot;outputField&quot;&gt;enriched_field&lt;/str&gt;
+ *   &lt;str name=&quot;prompt&quot;&gt;Title: {title_field}. Body: {body_field}.&lt;/str&gt;
+ *   &lt;str name=&quot;model&quot;&gt;ChatModel&lt;/str&gt;
+ * &lt;/processor&gt;
+ * </pre>
+ *
+ * <p>Alternatively, the prompt can be loaded from a text file using {@code promptFile}:
+ *
+ * <pre class="prettyprint" >
+ * &lt;processor class=&quot;solr.llm.documentenrichment.update.processor.DocumentEnrichmentUpdateProcessorFactory&quot;&gt;
+ *   &lt;str name=&quot;inputField&quot;&gt;title_field&lt;/str&gt;
+ *   &lt;str name=&quot;outputField&quot;&gt;enriched_field&lt;/str&gt;
+ *   &lt;str name=&quot;promptFile&quot;&gt;prompt.txt&lt;/str&gt;
+ *   &lt;str name=&quot;model&quot;&gt;ChatModel&lt;/str&gt;
+ * &lt;/processor&gt;
+ * </pre>
+ *
+ * <p>Validation rules:
+ *
+ * <ul>
+ *   <li>At least one {@code inputField} must be declared.
+ *   <li>Exactly one of {@code prompt} or {@code promptFile} must be provided.
+ *   <li>Every declared {@code inputField} must have a corresponding {@code {fieldName}} placeholder
+ *       in the prompt.
+ *   <li>Every {@code {placeholder}} in the prompt must correspond to a declared {@code inputField}.
+ * </ul>
+ */
+public class DocumentEnrichmentUpdateProcessorFactory extends UpdateRequestProcessorFactory
+    implements SolrCoreAware, ManagedResourceObserver {
+  private static final String INPUT_FIELD_PARAM = "inputField";
+  private static final String OUTPUT_FIELD_PARAM = "outputField";
+  private static final String PROMPT = "prompt";
+  private static final String PROMPT_FILE = "promptFile";
+  private static final String MODEL_NAME = "model";
+  private static final Pattern PLACEHOLDER_PATTERN = Pattern.compile("\\{([^}]+)\\}");
+
+  private List<String> inputFields;
+  private String outputField;
+  private String promptText;
+  private String promptFile;
+  private String modelName;
+
+  @Override
+  public void init(final NamedList<?> args) {
+    // removeConfigArgs handles both multiple <str name="inputField"> and <arr name="inputField">
+    // and must be called before toSolrParams() since it mutates args in place
+    Collection<String> fieldNames = args.removeConfigArgs(INPUT_FIELD_PARAM);
+    if (fieldNames.isEmpty()) {
+      throw new SolrException(
+          SolrException.ErrorCode.SERVER_ERROR, "At least one 'inputField' must be provided");
+    }
+    inputFields = List.copyOf(fieldNames);
+
+    Collection<String> outputFields = args.removeConfigArgs(OUTPUT_FIELD_PARAM);
+    if (outputFields.isEmpty()) {
+      throw new SolrException(
+          SolrException.ErrorCode.SERVER_ERROR, "Exactly one 'outputField' must be provided");
+    }
+    if (outputFields.size() > 1) {
+      throw new SolrException(
+          SolrException.ErrorCode.SERVER_ERROR,
+          "Only one 'outputField' can be provided, but found: " + outputFields);
+    }
+    outputField = outputFields.iterator().next();
+
+    SolrParams params = args.toSolrParams();
+    RequiredSolrParams required = params.required();
+    modelName = required.get(MODEL_NAME);
+
+    String inlinePrompt = params.get(PROMPT);
+    String promptFilePath = params.get(PROMPT_FILE);
+
+    if (inlinePrompt == null && promptFilePath == null) {
+      throw new SolrException(
+          SolrException.ErrorCode.SERVER_ERROR, "Either 'prompt' or 'promptFile' must be provided");
+    }
+    if (inlinePrompt != null && promptFilePath != null) {
+      throw new SolrException(
+          SolrException.ErrorCode.SERVER_ERROR,
+          "Only one of 'prompt' or 'promptFile' can be provided, not both");
+    }
+    if (inlinePrompt != null) {
+      validatePromptPlaceholders(inlinePrompt, inputFields);
+      this.promptText = inlinePrompt;
+    }
+    this.promptFile = promptFilePath;
+  }
+
+  @Override
+  public void inform(SolrCore core) {
+    final SolrResourceLoader solrResourceLoader = core.getResourceLoader();
+    ManagedChatModelStore.registerManagedChatModelStore(solrResourceLoader, this);
+    if (promptFile != null) {
+      try (InputStream is = solrResourceLoader.openResource(promptFile)) {
+        promptText = new String(is.readAllBytes(), StandardCharsets.UTF_8).trim();
+      } catch (IOException e) {
+        throw new SolrException(
+            SolrException.ErrorCode.SERVER_ERROR, "Cannot read prompt file: " + promptFile, e);
+      }
+      validatePromptPlaceholders(promptText, inputFields);
+    }
+  }
+
+  @Override
+  public void onManagedResourceInitialized(NamedList<?> args, ManagedResource res)
+      throws SolrException {
+    if (res instanceof ManagedChatModelStore store) {
+      store.loadStoredModels();
+    }
+  }
+
+  @Override
+  public UpdateRequestProcessor getInstance(
+      SolrQueryRequest req, SolrQueryResponse rsp, UpdateRequestProcessor next) {
+    IndexSchema latestSchema = req.getCore().getLatestSchema();
+
+    for (String fieldName : inputFields) {
+      if (!latestSchema.isDynamicField(fieldName) && !latestSchema.hasExplicitField(fieldName)) {
+        throw new SolrException(
+            SolrException.ErrorCode.SERVER_ERROR, "undefined field: \"" + fieldName + "\"");
+      }
+    }
+
+    final SchemaField outputFieldSchema = latestSchema.getField(outputField);
+
+    ResponseFormat responseFormat = buildResponseFormat(outputFieldSchema);
+    boolean multiValued = outputFieldSchema.multiValued();
+
+    ManagedChatModelStore store = ManagedChatModelStore.getManagedModelStore(req.getCore());
+    SolrChatModel chatModel = store.getModel(modelName);
+    if (chatModel == null) {
+      throw new SolrException(
+          SolrException.ErrorCode.SERVER_ERROR,
+          "The model configured in the Update Request Processor '"
+              + modelName
+              + "' can't be found in the store: "
+              + ManagedChatModelStore.REST_END_POINT);
+    }
+
+    return new DocumentEnrichmentUpdateProcessor(
+        inputFields, outputField, promptText, chatModel, multiValued, responseFormat, req, next);
+  }
+
+  /**
+   * Builds a {@link ResponseFormat} that instructs the model to return a JSON object {@code
+   * {"value": ...}} whose value type matches the Solr field type. For multivalued fields the value
+   * is wrapped in a {@link JsonArraySchema} nested inside the root {@link JsonObjectSchema}.
+   *
+   * <p>Nesting {@link JsonArraySchema} inside a {@link JsonObjectSchema} property is supported by
+   * all langchain4j providers that implement structured outputs with {@link JsonObjectSchema}
+   * (OpenAI, Azure OpenAI, Google AI, Gemini, Mistral, Ollama, Amazon Bedrock, Watsonx).
+   */
+  static ResponseFormat buildResponseFormat(SchemaField schemaField) {
+    JsonSchemaElement valueElement = toJsonSchemaElement(schemaField.getType());
+    JsonSchemaElement valueSchema =
+        schemaField.multiValued()
+            ? JsonArraySchema.builder().items(valueElement).build()
+            : valueElement;
+    return ResponseFormat.builder()
+        .type(ResponseFormatType.JSON)
+        .jsonSchema(
+            JsonSchema.builder()
+                .name("output")
+                .rootElement(
+                    JsonObjectSchema.builder()
+                        .addProperty("value", valueSchema)
+                        .required("value")
+                        .build())
+                .build())
+        .build();
+  }

Review Comment:
   will need to clarify this via a call



##########
solr/modules/language-models/src/test-files/modelChatExamples/dummy-chat-model-unsupported.json:
##########
@@ -0,0 +1,8 @@
+{
+  "class": "org.apache.solr.languagemodels.documentenrichment.model.DummyChatModel",
+  "name": "dummy-chat-1",
+  "params": {
+    "response": "enriched content",
+    "unsupported": 10

Review Comment:
   unsupported?



##########
solr/modules/language-models/src/java/org/apache/solr/languagemodels/documentenrichment/update/processor/DocumentEnrichmentUpdateProcessorFactory.java:
##########
@@ -0,0 +1,338 @@
+/*
+ * 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.solr.languagemodels.documentenrichment.update.processor;
+
+import dev.langchain4j.model.chat.request.ResponseFormat;
+import dev.langchain4j.model.chat.request.ResponseFormatType;
+import dev.langchain4j.model.chat.request.json.JsonArraySchema;
+import dev.langchain4j.model.chat.request.json.JsonBooleanSchema;
+import dev.langchain4j.model.chat.request.json.JsonIntegerSchema;
+import dev.langchain4j.model.chat.request.json.JsonNumberSchema;
+import dev.langchain4j.model.chat.request.json.JsonObjectSchema;
+import dev.langchain4j.model.chat.request.json.JsonSchema;
+import dev.langchain4j.model.chat.request.json.JsonSchemaElement;
+import dev.langchain4j.model.chat.request.json.JsonStringSchema;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import java.util.Collection;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import org.apache.solr.common.SolrException;
+import org.apache.solr.common.params.RequiredSolrParams;
+import org.apache.solr.common.params.SolrParams;
+import org.apache.solr.common.util.NamedList;
+import org.apache.solr.core.SolrCore;
+import org.apache.solr.core.SolrResourceLoader;
+import org.apache.solr.languagemodels.documentenrichment.model.SolrChatModel;
+import org.apache.solr.languagemodels.documentenrichment.store.rest.ManagedChatModelStore;
+import org.apache.solr.request.SolrQueryRequest;
+import org.apache.solr.response.SolrQueryResponse;
+import org.apache.solr.rest.ManagedResource;
+import org.apache.solr.rest.ManagedResourceObserver;
+import org.apache.solr.schema.BoolField;
+import org.apache.solr.schema.DatePointField;
+import org.apache.solr.schema.DenseVectorField;
+import org.apache.solr.schema.DoublePointField;
+import org.apache.solr.schema.FieldType;
+import org.apache.solr.schema.FloatPointField;
+import org.apache.solr.schema.IndexSchema;
+import org.apache.solr.schema.IntPointField;
+import org.apache.solr.schema.LongPointField;
+import org.apache.solr.schema.NestPathField;
+import org.apache.solr.schema.SchemaField;
+import org.apache.solr.schema.StrField;
+import org.apache.solr.schema.TextField;
+import org.apache.solr.schema.UUIDField;
+import org.apache.solr.update.processor.UpdateRequestProcessor;
+import org.apache.solr.update.processor.UpdateRequestProcessorFactory;
+import org.apache.solr.util.plugin.SolrCoreAware;
+
+/**
+ * Generate the content of a field based on other fields specified as input.
+ *
+ * <p>One or more {@code inputField} parameters specify the Solr fields to use as input. Each field
+ * name must appear as a {@code {fieldName}} placeholder in the prompt. Exactly one of {@code
+ * prompt} or {@code promptFile} must be provided.
+ *
+ * <pre class="prettyprint" >
+ * &lt;processor class=&quot;solr.llm.documentenrichment.update.processor.DocumentEnrichmentUpdateProcessorFactory&quot;&gt;
+ *   &lt;str name=&quot;inputField&quot;&gt;title_field&lt;/str&gt;
+ *   &lt;str name=&quot;inputField&quot;&gt;body_field&lt;/str&gt;
+ *   &lt;str name=&quot;outputField&quot;&gt;enriched_field&lt;/str&gt;
+ *   &lt;str name=&quot;prompt&quot;&gt;Title: {title_field}. Body: {body_field}.&lt;/str&gt;
+ *   &lt;str name=&quot;model&quot;&gt;ChatModel&lt;/str&gt;
+ * &lt;/processor&gt;
+ * </pre>
+ *
+ * <p>Multiple {@code inputField} values can also be declared as an array using {@code arr}:
+ *
+ * <pre class="prettyprint" >
+ * &lt;processor class=&quot;solr.llm.documentenrichment.update.processor.DocumentEnrichmentUpdateProcessorFactory&quot;&gt;
+ *   &lt;arr name=&quot;inputField&quot;&gt;
+ *     &lt;str&gt;title_field&lt;/str&gt;
+ *     &lt;str&gt;body_field&lt;/str&gt;
+ *   &lt;/arr&gt;
+ *   &lt;str name=&quot;outputField&quot;&gt;enriched_field&lt;/str&gt;
+ *   &lt;str name=&quot;prompt&quot;&gt;Title: {title_field}. Body: {body_field}.&lt;/str&gt;
+ *   &lt;str name=&quot;model&quot;&gt;ChatModel&lt;/str&gt;
+ * &lt;/processor&gt;
+ * </pre>
+ *
+ * <p>Alternatively, the prompt can be loaded from a text file using {@code promptFile}:
+ *
+ * <pre class="prettyprint" >
+ * &lt;processor class=&quot;solr.llm.documentenrichment.update.processor.DocumentEnrichmentUpdateProcessorFactory&quot;&gt;
+ *   &lt;str name=&quot;inputField&quot;&gt;title_field&lt;/str&gt;
+ *   &lt;str name=&quot;outputField&quot;&gt;enriched_field&lt;/str&gt;
+ *   &lt;str name=&quot;promptFile&quot;&gt;prompt.txt&lt;/str&gt;
+ *   &lt;str name=&quot;model&quot;&gt;ChatModel&lt;/str&gt;
+ * &lt;/processor&gt;
+ * </pre>
+ *
+ * <p>Validation rules:
+ *
+ * <ul>
+ *   <li>At least one {@code inputField} must be declared.
+ *   <li>Exactly one of {@code prompt} or {@code promptFile} must be provided.
+ *   <li>Every declared {@code inputField} must have a corresponding {@code {fieldName}} placeholder
+ *       in the prompt.
+ *   <li>Every {@code {placeholder}} in the prompt must correspond to a declared {@code inputField}.
+ * </ul>
+ */
+public class DocumentEnrichmentUpdateProcessorFactory extends UpdateRequestProcessorFactory
+    implements SolrCoreAware, ManagedResourceObserver {
+  private static final String INPUT_FIELD_PARAM = "inputField";
+  private static final String OUTPUT_FIELD_PARAM = "outputField";
+  private static final String PROMPT = "prompt";
+  private static final String PROMPT_FILE = "promptFile";
+  private static final String MODEL_NAME = "model";
+  private static final Pattern PLACEHOLDER_PATTERN = Pattern.compile("\\{([^}]+)\\}");
+
+  private List<String> inputFields;
+  private String outputField;
+  private String promptText;
+  private String promptFile;
+  private String modelName;
+
+  @Override
+  public void init(final NamedList<?> args) {
+    // removeConfigArgs handles both multiple <str name="inputField"> and <arr name="inputField">
+    // and must be called before toSolrParams() since it mutates args in place
+    Collection<String> fieldNames = args.removeConfigArgs(INPUT_FIELD_PARAM);
+    if (fieldNames.isEmpty()) {
+      throw new SolrException(
+          SolrException.ErrorCode.SERVER_ERROR, "At least one 'inputField' must be provided");
+    }
+    inputFields = List.copyOf(fieldNames);
+
+    Collection<String> outputFields = args.removeConfigArgs(OUTPUT_FIELD_PARAM);
+    if (outputFields.isEmpty()) {
+      throw new SolrException(
+          SolrException.ErrorCode.SERVER_ERROR, "Exactly one 'outputField' must be provided");
+    }
+    if (outputFields.size() > 1) {
+      throw new SolrException(
+          SolrException.ErrorCode.SERVER_ERROR,
+          "Only one 'outputField' can be provided, but found: " + outputFields);
+    }
+    outputField = outputFields.iterator().next();
+
+    SolrParams params = args.toSolrParams();
+    RequiredSolrParams required = params.required();
+    modelName = required.get(MODEL_NAME);
+
+    String inlinePrompt = params.get(PROMPT);
+    String promptFilePath = params.get(PROMPT_FILE);
+
+    if (inlinePrompt == null && promptFilePath == null) {
+      throw new SolrException(
+          SolrException.ErrorCode.SERVER_ERROR, "Either 'prompt' or 'promptFile' must be provided");
+    }
+    if (inlinePrompt != null && promptFilePath != null) {
+      throw new SolrException(
+          SolrException.ErrorCode.SERVER_ERROR,
+          "Only one of 'prompt' or 'promptFile' can be provided, not both");
+    }
+    if (inlinePrompt != null) {
+      validatePromptPlaceholders(inlinePrompt, inputFields);
+      this.promptText = inlinePrompt;
+    }
+    this.promptFile = promptFilePath;
+  }
+
+  @Override
+  public void inform(SolrCore core) {
+    final SolrResourceLoader solrResourceLoader = core.getResourceLoader();
+    ManagedChatModelStore.registerManagedChatModelStore(solrResourceLoader, this);
+    if (promptFile != null) {
+      try (InputStream is = solrResourceLoader.openResource(promptFile)) {
+        promptText = new String(is.readAllBytes(), StandardCharsets.UTF_8).trim();
+      } catch (IOException e) {
+        throw new SolrException(
+            SolrException.ErrorCode.SERVER_ERROR, "Cannot read prompt file: " + promptFile, e);
+      }
+      validatePromptPlaceholders(promptText, inputFields);
+    }
+  }
+
+  @Override
+  public void onManagedResourceInitialized(NamedList<?> args, ManagedResource res)
+      throws SolrException {
+    if (res instanceof ManagedChatModelStore store) {
+      store.loadStoredModels();
+    }
+  }
+
+  @Override
+  public UpdateRequestProcessor getInstance(
+      SolrQueryRequest req, SolrQueryResponse rsp, UpdateRequestProcessor next) {
+    IndexSchema latestSchema = req.getCore().getLatestSchema();
+
+    for (String fieldName : inputFields) {
+      if (!latestSchema.isDynamicField(fieldName) && !latestSchema.hasExplicitField(fieldName)) {
+        throw new SolrException(
+            SolrException.ErrorCode.SERVER_ERROR, "undefined field: \"" + fieldName + "\"");
+      }
+    }
+
+    final SchemaField outputFieldSchema = latestSchema.getField(outputField);
+
+    ResponseFormat responseFormat = buildResponseFormat(outputFieldSchema);
+    boolean multiValued = outputFieldSchema.multiValued();
+
+    ManagedChatModelStore store = ManagedChatModelStore.getManagedModelStore(req.getCore());
+    SolrChatModel chatModel = store.getModel(modelName);
+    if (chatModel == null) {
+      throw new SolrException(
+          SolrException.ErrorCode.SERVER_ERROR,
+          "The model configured in the Update Request Processor '"
+              + modelName
+              + "' can't be found in the store: "
+              + ManagedChatModelStore.REST_END_POINT);
+    }
+
+    return new DocumentEnrichmentUpdateProcessor(
+        inputFields, outputField, promptText, chatModel, multiValued, responseFormat, req, next);
+  }
+
+  /**
+   * Builds a {@link ResponseFormat} that instructs the model to return a JSON object {@code
+   * {"value": ...}} whose value type matches the Solr field type. For multivalued fields the value
+   * is wrapped in a {@link JsonArraySchema} nested inside the root {@link JsonObjectSchema}.
+   *
+   * <p>Nesting {@link JsonArraySchema} inside a {@link JsonObjectSchema} property is supported by
+   * all langchain4j providers that implement structured outputs with {@link JsonObjectSchema}
+   * (OpenAI, Azure OpenAI, Google AI, Gemini, Mistral, Ollama, Amazon Bedrock, Watsonx).
+   */
+  static ResponseFormat buildResponseFormat(SchemaField schemaField) {
+    JsonSchemaElement valueElement = toJsonSchemaElement(schemaField.getType());
+    JsonSchemaElement valueSchema =
+        schemaField.multiValued()
+            ? JsonArraySchema.builder().items(valueElement).build()
+            : valueElement;
+    return ResponseFormat.builder()
+        .type(ResponseFormatType.JSON)
+        .jsonSchema(
+            JsonSchema.builder()
+                .name("output")
+                .rootElement(
+                    JsonObjectSchema.builder()
+                        .addProperty("value", valueSchema)
+                        .required("value")
+                        .build())
+                .build())
+        .build();
+  }
+
+  private static JsonSchemaElement toJsonSchemaElement(FieldType fieldType) {
+    // DenseVectorField extends FloatPointField, so it must be rejected before the numeric checks
+    if (fieldType instanceof DenseVectorField
+        || fieldType instanceof UUIDField
+        || fieldType instanceof NestPathField) {
+      throw new SolrException(
+          SolrException.ErrorCode.SERVER_ERROR,
+          "field type is not supported by Document Enrichment: "
+              + fieldType.getClass().getSimpleName());
+    }
+    if (fieldType instanceof StrField
+        || fieldType instanceof TextField
+        || fieldType instanceof DatePointField) {
+      return new JsonStringSchema();
+    } else if (fieldType instanceof IntPointField || fieldType instanceof LongPointField) {
+      return new JsonIntegerSchema();
+    } else if (fieldType instanceof FloatPointField || fieldType instanceof DoublePointField) {
+      return new JsonNumberSchema();
+    } else if (fieldType instanceof BoolField) {
+      return new JsonBooleanSchema();
+    } else {
+      throw new SolrException(
+          SolrException.ErrorCode.SERVER_ERROR,
+          "field type is not supported by Document Enrichment: "
+              + fieldType.getClass().getSimpleName());
+    }
+  }
+
+  private static void validatePromptPlaceholders(String prompt, List<String> fieldNames) {

Review Comment:
   fieldNames -> inputFields



##########
solr/modules/language-models/src/test/org/apache/solr/languagemodels/documentenrichment/model/DummyChatModel.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.solr.languagemodels.documentenrichment.model;
+
+import dev.langchain4j.data.message.AiMessage;
+import dev.langchain4j.data.message.UserMessage;
+import dev.langchain4j.model.chat.ChatModel;
+import dev.langchain4j.model.chat.request.ChatRequest;
+import dev.langchain4j.model.chat.response.ChatResponse;
+
+/**
+ * A deterministic {@link ChatModel} for testing. It returns a fixed response string regardless of
+ * the input, allowing tests to assert exact enriched-field values without real API calls.
+ *
+ * <p>The builder also exposes {@code unsupported} and {@code ambiguous} setter methods to exercise
+ * the reflection-based parameter handling in {@link
+ * org.apache.solr.languagemodels.documentenrichment.model.SolrChatModel#getInstance}.
+ */
+public class DummyChatModel implements ChatModel {
+
+  /** The text of the last prompt received by any instance. Useful for test assertions. */
+  public static String lastReceivedPrompt;
+
+  private final String response;
+
+  public DummyChatModel(String response) {
+    this.response = response;
+  }
+
+  @Override
+  public ChatResponse chat(ChatRequest chatRequest) {
+    lastReceivedPrompt = ((UserMessage) chatRequest.messages().getFirst()).singleText();
+    return ChatResponse.builder().aiMessage(AiMessage.from(response)).build();
+  }
+
+  public static DummyChatModelBuilder builder() {
+    return new DummyChatModelBuilder();
+  }
+
+  public static class DummyChatModelBuilder {
+    private String response = "dummy response";
+    private int intValue;
+
+    public DummyChatModelBuilder() {}
+
+    public DummyChatModelBuilder response(String response) {
+      this.response = response;
+      return this;
+    }
+
+    /** Intentionally has no String overload so the reflection code raises a BAD_REQUEST error. */
+    public DummyChatModelBuilder unsupported(Integer input) {
+      return this;
+    }
+
+    /** Two overloads make this param "ambiguous": the reflection code should default to String. */
+    public DummyChatModelBuilder ambiguous(int input) {
+      this.intValue = input;
+      return this;
+    }
+
+    public DummyChatModelBuilder ambiguous(String input) {
+      this.intValue = Integer.valueOf(input);
+      return this;
+    }

Review Comment:
   will have to elaborate this



##########
solr/modules/language-models/src/test/org/apache/solr/languagemodels/documentenrichment/store/rest/TestChatModelManager.java:
##########


Review Comment:
   not sure we need a separate model manager, but tests are ok and may need to be relocated



##########
solr/solr-ref-guide/modules/configuration-guide/pages/update-request-processors.adoc:
##########
@@ -421,6 +421,10 @@ The {solr-javadocs}/modules/language-models/index.html[`language-models`] module
 It uses external text to vectors language models to perform the vectorisation for each processed document.
 For more information: xref:query-guide:text-to-vector.adoc[Update Request Processor]
 
+{solr-javadocs}/modules/language-models/org/apache/solr/languagemodels/documentenrichment/update/processor/DocumentEnrichmentUpdateProcessorFactory.html[DocumentEnrichmentUpdateProcessorFactory]:: Update processor which, starting from one or more fields in input and a given prompt, adds the output of an LLM as the value of a new field.
+It uses external chat language models to perform the enrichment of each processed document.

Review Comment:
   It uses external Large Language Model services to perform the enrichment of each processed document.



##########
solr/modules/language-models/src/test-files/modelChatExamples/dummy-chat-model-ambiguous.json:
##########
@@ -0,0 +1,8 @@
+{
+  "class": "org.apache.solr.languagemodels.documentenrichment.model.DummyChatModel",
+  "name": "dummy-chat-1",
+  "params": {
+    "response": "enriched content",
+    "ambiguous": 10

Review Comment:
   ambiguous?



##########
solr/modules/language-models/src/test-files/solr/collection1/conf/enumsConfig.xml:
##########


Review Comment:
   What's this file?



##########
solr/modules/language-models/src/test-files/solr/collection1/conf/solrconfig-document-enrichment.xml:
##########
@@ -0,0 +1,258 @@
+<?xml version="1.0" ?>
+<!-- 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. -->
+
+<config>
+    <luceneMatchVersion>${tests.luceneMatchVersion:LATEST}</luceneMatchVersion>
+ <dataDir>${solr.data.dir:}</dataDir>
+ <directoryFactory name="DirectoryFactory"
+                   class="${solr.directoryFactory:solr.MockDirectoryFactory}" />
+ <schemaFactory class="ClassicIndexSchemaFactory" />
+
+ <requestDispatcher>
+   <requestParsers />
+ </requestDispatcher>
+
+ <query>
+  <filterCache class="solr.CaffeineCache" size="4096"
+   initialSize="2048" autowarmCount="0" />
+ </query>
+ <requestHandler name="/select" class="solr.SearchHandler" />
+
+ <updateHandler class="solr.DirectUpdateHandler2">
+  <autoCommit>
+   <maxTime>15000</maxTime>
+   <openSearcher>false</openSearcher>
+  </autoCommit>
+  <autoSoftCommit>
+   <maxTime>1000</maxTime>
+  </autoSoftCommit>
+  <updateLog>
+   <str name="dir">${solr.data.dir:}</str>
+  </updateLog>
+ </updateHandler>
+
+ <requestHandler name="/query" class="solr.SearchHandler">
+  <lst name="defaults">
+   <str name="echoParams">explicit</str>
+   <str name="wt">json</str>
+   <str name="indent">true</str>
+   <str name="df">id</str>
+  </lst>
+ </requestHandler>
+
+ <updateRequestProcessorChain name="documentEnrichment">
+  <processor class="solr.languagemodels.documentenrichment.update.processor.DocumentEnrichmentUpdateProcessorFactory">
+   <str name="inputField">string_field</str>
+   <str name="outputField">enriched_field</str>
+   <str name="prompt">Summarize this content: {string_field}</str>
+   <str name="model">dummy-chat-1</str>
+  </processor>
+  <processor class="solr.RunUpdateProcessorFactory"/>
+ </updateRequestProcessorChain>
+
+  <updateRequestProcessorChain name="documentEnrichmentArrInputField">
+    <processor class="solr.languagemodels.documentenrichment.update.processor.DocumentEnrichmentUpdateProcessorFactory">
+      <arr name="inputField">
+        <str>string_field</str>
+        <str>body_field</str>
+      </arr>
+      <str name="outputField">enriched_field</str>
+      <str name="prompt">Title: {string_field}. Body: {body_field}.</str>

Review Comment:
   I don't understand this prompt, what type of enrichment do we expect?



##########
solr/modules/language-models/src/java/org/apache/solr/languagemodels/documentenrichment/update/processor/DocumentEnrichmentUpdateProcessorFactory.java:
##########
@@ -0,0 +1,338 @@
+/*
+ * 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.solr.languagemodels.documentenrichment.update.processor;
+
+import dev.langchain4j.model.chat.request.ResponseFormat;
+import dev.langchain4j.model.chat.request.ResponseFormatType;
+import dev.langchain4j.model.chat.request.json.JsonArraySchema;
+import dev.langchain4j.model.chat.request.json.JsonBooleanSchema;
+import dev.langchain4j.model.chat.request.json.JsonIntegerSchema;
+import dev.langchain4j.model.chat.request.json.JsonNumberSchema;
+import dev.langchain4j.model.chat.request.json.JsonObjectSchema;
+import dev.langchain4j.model.chat.request.json.JsonSchema;
+import dev.langchain4j.model.chat.request.json.JsonSchemaElement;
+import dev.langchain4j.model.chat.request.json.JsonStringSchema;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import java.util.Collection;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import org.apache.solr.common.SolrException;
+import org.apache.solr.common.params.RequiredSolrParams;
+import org.apache.solr.common.params.SolrParams;
+import org.apache.solr.common.util.NamedList;
+import org.apache.solr.core.SolrCore;
+import org.apache.solr.core.SolrResourceLoader;
+import org.apache.solr.languagemodels.documentenrichment.model.SolrChatModel;
+import org.apache.solr.languagemodels.documentenrichment.store.rest.ManagedChatModelStore;
+import org.apache.solr.request.SolrQueryRequest;
+import org.apache.solr.response.SolrQueryResponse;
+import org.apache.solr.rest.ManagedResource;
+import org.apache.solr.rest.ManagedResourceObserver;
+import org.apache.solr.schema.BoolField;
+import org.apache.solr.schema.DatePointField;
+import org.apache.solr.schema.DenseVectorField;
+import org.apache.solr.schema.DoublePointField;
+import org.apache.solr.schema.FieldType;
+import org.apache.solr.schema.FloatPointField;
+import org.apache.solr.schema.IndexSchema;
+import org.apache.solr.schema.IntPointField;
+import org.apache.solr.schema.LongPointField;
+import org.apache.solr.schema.NestPathField;
+import org.apache.solr.schema.SchemaField;
+import org.apache.solr.schema.StrField;
+import org.apache.solr.schema.TextField;
+import org.apache.solr.schema.UUIDField;
+import org.apache.solr.update.processor.UpdateRequestProcessor;
+import org.apache.solr.update.processor.UpdateRequestProcessorFactory;
+import org.apache.solr.util.plugin.SolrCoreAware;
+
+/**
+ * Generate the content of a field based on other fields specified as input.
+ *
+ * <p>One or more {@code inputField} parameters specify the Solr fields to use as input. Each field
+ * name must appear as a {@code {fieldName}} placeholder in the prompt. Exactly one of {@code
+ * prompt} or {@code promptFile} must be provided.
+ *
+ * <pre class="prettyprint" >
+ * &lt;processor class=&quot;solr.llm.documentenrichment.update.processor.DocumentEnrichmentUpdateProcessorFactory&quot;&gt;
+ *   &lt;str name=&quot;inputField&quot;&gt;title_field&lt;/str&gt;
+ *   &lt;str name=&quot;inputField&quot;&gt;body_field&lt;/str&gt;
+ *   &lt;str name=&quot;outputField&quot;&gt;enriched_field&lt;/str&gt;
+ *   &lt;str name=&quot;prompt&quot;&gt;Title: {title_field}. Body: {body_field}.&lt;/str&gt;
+ *   &lt;str name=&quot;model&quot;&gt;ChatModel&lt;/str&gt;
+ * &lt;/processor&gt;
+ * </pre>
+ *
+ * <p>Multiple {@code inputField} values can also be declared as an array using {@code arr}:
+ *
+ * <pre class="prettyprint" >
+ * &lt;processor class=&quot;solr.llm.documentenrichment.update.processor.DocumentEnrichmentUpdateProcessorFactory&quot;&gt;
+ *   &lt;arr name=&quot;inputField&quot;&gt;
+ *     &lt;str&gt;title_field&lt;/str&gt;
+ *     &lt;str&gt;body_field&lt;/str&gt;
+ *   &lt;/arr&gt;
+ *   &lt;str name=&quot;outputField&quot;&gt;enriched_field&lt;/str&gt;
+ *   &lt;str name=&quot;prompt&quot;&gt;Title: {title_field}. Body: {body_field}.&lt;/str&gt;
+ *   &lt;str name=&quot;model&quot;&gt;ChatModel&lt;/str&gt;
+ * &lt;/processor&gt;
+ * </pre>
+ *
+ * <p>Alternatively, the prompt can be loaded from a text file using {@code promptFile}:
+ *
+ * <pre class="prettyprint" >
+ * &lt;processor class=&quot;solr.llm.documentenrichment.update.processor.DocumentEnrichmentUpdateProcessorFactory&quot;&gt;
+ *   &lt;str name=&quot;inputField&quot;&gt;title_field&lt;/str&gt;
+ *   &lt;str name=&quot;outputField&quot;&gt;enriched_field&lt;/str&gt;
+ *   &lt;str name=&quot;promptFile&quot;&gt;prompt.txt&lt;/str&gt;
+ *   &lt;str name=&quot;model&quot;&gt;ChatModel&lt;/str&gt;
+ * &lt;/processor&gt;
+ * </pre>
+ *
+ * <p>Validation rules:
+ *
+ * <ul>
+ *   <li>At least one {@code inputField} must be declared.
+ *   <li>Exactly one of {@code prompt} or {@code promptFile} must be provided.
+ *   <li>Every declared {@code inputField} must have a corresponding {@code {fieldName}} placeholder
+ *       in the prompt.
+ *   <li>Every {@code {placeholder}} in the prompt must correspond to a declared {@code inputField}.
+ * </ul>
+ */
+public class DocumentEnrichmentUpdateProcessorFactory extends UpdateRequestProcessorFactory
+    implements SolrCoreAware, ManagedResourceObserver {
+  private static final String INPUT_FIELD_PARAM = "inputField";
+  private static final String OUTPUT_FIELD_PARAM = "outputField";
+  private static final String PROMPT = "prompt";
+  private static final String PROMPT_FILE = "promptFile";
+  private static final String MODEL_NAME = "model";
+  private static final Pattern PLACEHOLDER_PATTERN = Pattern.compile("\\{([^}]+)\\}");
+
+  private List<String> inputFields;
+  private String outputField;
+  private String promptText;
+  private String promptFile;
+  private String modelName;
+
+  @Override
+  public void init(final NamedList<?> args) {
+    // removeConfigArgs handles both multiple <str name="inputField"> and <arr name="inputField">
+    // and must be called before toSolrParams() since it mutates args in place
+    Collection<String> fieldNames = args.removeConfigArgs(INPUT_FIELD_PARAM);
+    if (fieldNames.isEmpty()) {
+      throw new SolrException(
+          SolrException.ErrorCode.SERVER_ERROR, "At least one 'inputField' must be provided");
+    }
+    inputFields = List.copyOf(fieldNames);
+
+    Collection<String> outputFields = args.removeConfigArgs(OUTPUT_FIELD_PARAM);
+    if (outputFields.isEmpty()) {
+      throw new SolrException(
+          SolrException.ErrorCode.SERVER_ERROR, "Exactly one 'outputField' must be provided");
+    }
+    if (outputFields.size() > 1) {
+      throw new SolrException(
+          SolrException.ErrorCode.SERVER_ERROR,
+          "Only one 'outputField' can be provided, but found: " + outputFields);
+    }
+    outputField = outputFields.iterator().next();
+
+    SolrParams params = args.toSolrParams();
+    RequiredSolrParams required = params.required();
+    modelName = required.get(MODEL_NAME);
+
+    String inlinePrompt = params.get(PROMPT);
+    String promptFilePath = params.get(PROMPT_FILE);
+
+    if (inlinePrompt == null && promptFilePath == null) {
+      throw new SolrException(
+          SolrException.ErrorCode.SERVER_ERROR, "Either 'prompt' or 'promptFile' must be provided");
+    }
+    if (inlinePrompt != null && promptFilePath != null) {
+      throw new SolrException(
+          SolrException.ErrorCode.SERVER_ERROR,
+          "Only one of 'prompt' or 'promptFile' can be provided, not both");
+    }
+    if (inlinePrompt != null) {
+      validatePromptPlaceholders(inlinePrompt, inputFields);
+      this.promptText = inlinePrompt;
+    }
+    this.promptFile = promptFilePath;
+  }
+
+  @Override
+  public void inform(SolrCore core) {
+    final SolrResourceLoader solrResourceLoader = core.getResourceLoader();
+    ManagedChatModelStore.registerManagedChatModelStore(solrResourceLoader, this);
+    if (promptFile != null) {
+      try (InputStream is = solrResourceLoader.openResource(promptFile)) {
+        promptText = new String(is.readAllBytes(), StandardCharsets.UTF_8).trim();
+      } catch (IOException e) {
+        throw new SolrException(
+            SolrException.ErrorCode.SERVER_ERROR, "Cannot read prompt file: " + promptFile, e);
+      }
+      validatePromptPlaceholders(promptText, inputFields);
+    }
+  }
+
+  @Override
+  public void onManagedResourceInitialized(NamedList<?> args, ManagedResource res)
+      throws SolrException {
+    if (res instanceof ManagedChatModelStore store) {
+      store.loadStoredModels();
+    }
+  }
+
+  @Override
+  public UpdateRequestProcessor getInstance(
+      SolrQueryRequest req, SolrQueryResponse rsp, UpdateRequestProcessor next) {
+    IndexSchema latestSchema = req.getCore().getLatestSchema();
+
+    for (String fieldName : inputFields) {
+      if (!latestSchema.isDynamicField(fieldName) && !latestSchema.hasExplicitField(fieldName)) {
+        throw new SolrException(
+            SolrException.ErrorCode.SERVER_ERROR, "undefined field: \"" + fieldName + "\"");
+      }
+    }
+
+    final SchemaField outputFieldSchema = latestSchema.getField(outputField);
+
+    ResponseFormat responseFormat = buildResponseFormat(outputFieldSchema);
+    boolean multiValued = outputFieldSchema.multiValued();
+
+    ManagedChatModelStore store = ManagedChatModelStore.getManagedModelStore(req.getCore());
+    SolrChatModel chatModel = store.getModel(modelName);
+    if (chatModel == null) {
+      throw new SolrException(
+          SolrException.ErrorCode.SERVER_ERROR,
+          "The model configured in the Update Request Processor '"
+              + modelName
+              + "' can't be found in the store: "
+              + ManagedChatModelStore.REST_END_POINT);
+    }
+
+    return new DocumentEnrichmentUpdateProcessor(
+        inputFields, outputField, promptText, chatModel, multiValued, responseFormat, req, next);
+  }
+
+  /**
+   * Builds a {@link ResponseFormat} that instructs the model to return a JSON object {@code
+   * {"value": ...}} whose value type matches the Solr field type. For multivalued fields the value
+   * is wrapped in a {@link JsonArraySchema} nested inside the root {@link JsonObjectSchema}.
+   *
+   * <p>Nesting {@link JsonArraySchema} inside a {@link JsonObjectSchema} property is supported by
+   * all langchain4j providers that implement structured outputs with {@link JsonObjectSchema}
+   * (OpenAI, Azure OpenAI, Google AI, Gemini, Mistral, Ollama, Amazon Bedrock, Watsonx).
+   */
+  static ResponseFormat buildResponseFormat(SchemaField schemaField) {
+    JsonSchemaElement valueElement = toJsonSchemaElement(schemaField.getType());
+    JsonSchemaElement valueSchema =
+        schemaField.multiValued()
+            ? JsonArraySchema.builder().items(valueElement).build()
+            : valueElement;
+    return ResponseFormat.builder()
+        .type(ResponseFormatType.JSON)
+        .jsonSchema(
+            JsonSchema.builder()
+                .name("output")
+                .rootElement(
+                    JsonObjectSchema.builder()
+                        .addProperty("value", valueSchema)
+                        .required("value")
+                        .build())
+                .build())
+        .build();
+  }
+
+  private static JsonSchemaElement toJsonSchemaElement(FieldType fieldType) {
+    // DenseVectorField extends FloatPointField, so it must be rejected before the numeric checks
+    if (fieldType instanceof DenseVectorField
+        || fieldType instanceof UUIDField
+        || fieldType instanceof NestPathField) {
+      throw new SolrException(
+          SolrException.ErrorCode.SERVER_ERROR,
+          "field type is not supported by Document Enrichment: "
+              + fieldType.getClass().getSimpleName());
+    }
+    if (fieldType instanceof StrField
+        || fieldType instanceof TextField
+        || fieldType instanceof DatePointField) {
+      return new JsonStringSchema();
+    } else if (fieldType instanceof IntPointField || fieldType instanceof LongPointField) {
+      return new JsonIntegerSchema();
+    } else if (fieldType instanceof FloatPointField || fieldType instanceof DoublePointField) {
+      return new JsonNumberSchema();
+    } else if (fieldType instanceof BoolField) {
+      return new JsonBooleanSchema();
+    } else {
+      throw new SolrException(
+          SolrException.ErrorCode.SERVER_ERROR,
+          "field type is not supported by Document Enrichment: "
+              + fieldType.getClass().getSimpleName());
+    }

Review Comment:
   I think with switch-case java construct this part will be more readable



##########
solr/modules/language-models/src/test-files/modelEmbeddingExamples/dummy-model.json:
##########


Review Comment:
   not sure we need this relocation, but in case it is:
   embeddingModelExamples



##########
solr/solr-ref-guide/modules/indexing-guide/pages/document-enrichment-with-llms.adoc:
##########
@@ -0,0 +1,534 @@
+= Document Enrichment with LLMs
+// 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.
+
+This module brings the power of *Large Language Models* to Solr.
+
+More specifically, it enables calling an LLM at indexing time to enrich documents with additional/generated/extracted
+data. Given a prompt and a set of input fields, for each document, the LLM is invoked through
+https://github.com/langchain4j/langchain4j[LangChain4j], and the result is stored in an output field, which can support
+multiple types and may also be multivalued.
+
+_Without_ this module, the LLM calls to enrich documents must be done _outside_ Solr, before indexing.
+
+[IMPORTANT]
+====
+This module sends your documents off to some hosted service on the internet.
+There are cost, privacy, performance, and service availability implications on such a strong dependency that should be
+diligently examined before employing this module in a serious way.
+
+====
+
+At the moment, Solr supports a subset of the LLM providers available in LangChain4j.
+
+*Disclaimer*: Apache Solr is *in no way* affiliated to any of these corporations or services.
+
+If you want to add support for additional services or improve the support for the existing ones, feel free to
+contribute:
+
+* https://github.com/apache/solr/blob/main/CONTRIBUTING.md[Contributing to Solr]
+
+== Module
+
+This is provided via the `language-models` xref:configuration-guide:solr-modules.adoc[Solr Module] that needs to be
+enabled before use.
+
+== Language Model Configuration
+
+Language Models is a module and therefore its plugins must be configured in `solrconfig.xml`.
+
+=== Minimum Requirements
+
+* Enable the `language-models` module to make the Language Models classes available on Solr's classpath.
+See xref:configuration-guide:solr-modules.adoc[Solr Module] for more details.
+
+* An {solr-javadocs}/core/org/apache/solr/update/processor/UpdateRequestProcessorChain.html[UpdateRequestProcessorChain]
+that includes at least one `DocumentEnrichmentUpdateProcessor` update processor.
+
+=== Update Processor Chain Design
+
+To properly design the Update Processor Chain for Document Enrichment, several parameters must be defined:
+
+`inputField`::
++
+[%autowidth,frame=none]
+|===
+s|Required |Default: none
+|===
++
+The field whose content is passed to the LLM to enrich the documents. Every `inputField` declared must be referred to in
+the prompt.
+
++
+Multiple `inputField` are supported and can be defined by using one of the following notations:
+
+* Add more than one `inputField` string element
++
+[source,xml]
+----
+<updateRequestProcessorChain name="documentEnrichment">
+  <processor class="solr.languagemodels.documentenrichment.update.processor.DocumentEnrichmentUpdateProcessorFactory">
+   <str name="inputField">title</str>
+   <str name="inputField">body</str>
+   <str name="outputField">summary</str>
+   <str name="prompt">Summarize with the following information. Title: {title}. Body: {body}.</str>
+   <str name="model">chat-model</str>
+  </processor>
+  <processor class="solr.RunUpdateProcessorFactory"/>
+ </updateRequestProcessorChain>
+----
+
+* Substitute the `inputField` string element with an array of string elements with the same name
++
+[source,xml]
+----
+<arr name="inputField">
+    <str>title</str>
+    <str>body</str>
+</arr>
+----
+
+
+`outputField`::
++
+[%autowidth,frame=none]
+|===
+s|Required |Default: none
+|===
++
+The LLM response is mapped to the specified `outputField`, and only one field is supported as output. Note that this
+module only supports a subset of Solr's available field types, which includes:
+
+* *String/Text*: `StrField`, `TextField`, `SortableTextField`
+* *Date*: `DatePointField` (the LLM must return an ISO-8601 date string; it might be useful to tune your prompt accordingly, to avoid indexing errors)
+* *Numeric*: `IntPointField`, `LongPointField`, `FloatPointField`, `DoublePointField`
+* *Boolean*: `BoolField`
+
+
+These fields _can_ be multivalued. Solr uses structured output from LangChain4j to deal with LLMs' responses.
+
+
+`prompt` or `promptFile`::
++
+[%autowidth,frame=none]
+|===
+s|Exactly one of these parameters is required |Default: none
+|===
++
+Two different ways to define a prompt are available: one directly in the solrconfig and one through a dedicated file.
+Either way, the content of the prompt _must_ contain a special token for each `inputField` declared, that are the
+`fieldName` surrounded by curly brackets (e.g., `{string_field}`, in the example below). Solr will throw an error if
+the parameters are not properly defined.
++
+These parameters can be defined in one of the following ways:
+
+* Update processor definition with the `prompt` parameter
++
+[source,xml]
+----
+<updateRequestProcessorChain name="documentEnrichment">
+  <processor class="solr.languagemodels.documentenrichment.update.processor.DocumentEnrichmentUpdateProcessorFactory">
+   <str name="inputField">string_field</str>
+   <str name="outputField">summary</str>
+   <str name="prompt">Summarize this content: {string_field}</str>
+   <str name="model">model-name</str>
+  </processor>
+  <processor class="solr.RunUpdateProcessorFactory"/>
+ </updateRequestProcessorChain>
+----
+
+* Update processor definition with the parameter `promptFile` parameter: in this case, the file `prompt.txt` must be
+uploaded to Solr inside the config folder of the collection (e.g., similarly to `solrconfig.xml`, `synonyms.txt`, etc.)
++
+[source,xml]
+----
+<updateRequestProcessorChain name="documentEnrichment">
+  <processor class="solr.languagemodels.documentenrichment.update.processor.DocumentEnrichmentUpdateProcessorFactory">
+   <str name="inputField">string_field</str>
+   <str name="outputField">summary</str>
+   <str name="promptFile">prompt.txt</str>
+   <str name="model">model-name</str>
+  </processor>
+  <processor class="solr.RunUpdateProcessorFactory"/>
+ </updateRequestProcessorChain>
+----
+
+`model`::
++
+[%autowidth,frame=none]
+|===
+s|Required |Default: none
+|===
++
+
+The name of the model that will be uploaded via REST. See xref:document-enrichment-with-llms.adoc#chat-model-setup[] for
+more information.
+
+
+For more details on how to work with update request processors in Apache Solr, please refer to the dedicated page:
+xref:configuration-guide:update-request-processors.adoc[Update Request Processor]
+
+[IMPORTANT]
+====
+This update processor sends your document field content off to some hosted service on the internet.
+There are serious performance implications that should be diligently examined before employing this component in production.
+It will slow down substantially your indexing pipeline so make sure to stress test your solution before going live.
+
+====
+
+[NOTE]
+====
+If any `inputField` value is absent or empty for a given document, enrichment is silently skipped for that document:
+the `outputField` is not added and the document is indexed as-is.
+
+If the LLM call fails at runtime (e.g., network error, model timeout), the exception is caught and logged but is
+*non-fatal*: the document is still indexed without the `outputField`.
+Monitor your indexing logs to detect documents that were not enriched as expected.
+====
+
+== Chat Model Setup

Review Comment:
   Chat Model is a LangChain4j naming, please remove it entirely from the doc and Solr where possible.
   Furthermore we don't offer any chat style interaction so it can be misleading.
   
   let's just use 'general purpose LLM' 



##########
solr/solr-ref-guide/modules/configuration-guide/pages/update-request-processors.adoc:
##########
@@ -421,6 +421,10 @@ The {solr-javadocs}/modules/language-models/index.html[`language-models`] module
 It uses external text to vectors language models to perform the vectorisation for each processed document.
 For more information: xref:query-guide:text-to-vector.adoc[Update Request Processor]
 
+{solr-javadocs}/modules/language-models/org/apache/solr/languagemodels/documentenrichment/update/processor/DocumentEnrichmentUpdateProcessorFactory.html[DocumentEnrichmentUpdateProcessorFactory]:: Update processor which, starting from one or more fields in input and a given prompt, adds the output of an LLM as the value of a new field.

Review Comment:
   Update processor that takes one or more fields and a given prompt in input and returns the output of an LLM as the value of a new field.



-- 
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: issues-unsubscribe@solr.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


---------------------------------------------------------------------
To unsubscribe, e-mail: issues-unsubscribe@solr.apache.org
For additional commands, e-mail: issues-help@solr.apache.org


Re: [PR] SOLR-18187: Document enrichment with LLMs [solr]

Posted by "alessandrobenedetti (via GitHub)" <gi...@apache.org>.
alessandrobenedetti commented on code in PR #4259:
URL: https://github.com/apache/solr/pull/4259#discussion_r3101183907


##########
solr/modules/language-models/src/java/org/apache/solr/languagemodels/documentenrichment/store/rest/ManagedChatModelStore.java:
##########
@@ -0,0 +1,192 @@
+/*
+ * 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.solr.languagemodels.documentenrichment.store.rest;
+
+import java.lang.invoke.MethodHandles;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+import net.jcip.annotations.ThreadSafe;
+import org.apache.solr.common.SolrException;
+import org.apache.solr.common.util.NamedList;
+import org.apache.solr.core.SolrCore;
+import org.apache.solr.core.SolrResourceLoader;
+import org.apache.solr.languagemodels.documentenrichment.model.SolrChatModel;
+import org.apache.solr.languagemodels.documentenrichment.store.ChatModelException;
+import org.apache.solr.languagemodels.documentenrichment.store.ChatModelStore;
+import org.apache.solr.response.SolrQueryResponse;
+import org.apache.solr.rest.BaseSolrResource;
+import org.apache.solr.rest.ManagedResource;
+import org.apache.solr.rest.ManagedResourceObserver;
+import org.apache.solr.rest.ManagedResourceStorage;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/** Managed Resource wrapper for the {@link ChatModelStore} to expose it via REST */
+@ThreadSafe
+public class ManagedChatModelStore extends ManagedResource
+    implements ManagedResource.ChildResourceSupport {
+  private static final Logger log = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());
+
+  /** the model store rest endpoint */
+  public static final String REST_END_POINT = "/schema/chat-model-store";
+
+  /** Managed model store: the name of the attribute containing all the models of a model store */
+  private static final String MODELS_JSON_FIELD = "models";
+
+  /** name of the attribute containing a class */
+  static final String CLASS_KEY = "class";
+
+  /** name of the attribute containing a name */
+  static final String NAME_KEY = "name";
+
+  /** name of the attribute containing parameters */
+  static final String PARAMS_KEY = "params";
+
+  public static void registerManagedChatModelStore(
+      SolrResourceLoader solrResourceLoader, ManagedResourceObserver managedResourceObserver) {
+    solrResourceLoader
+        .getManagedResourceRegistry()
+        .registerManagedResource(
+            REST_END_POINT, ManagedChatModelStore.class, managedResourceObserver);
+  }
+
+  public static ManagedChatModelStore getManagedModelStore(SolrCore core) {
+    return (ManagedChatModelStore) core.getRestManager().getManagedResource(REST_END_POINT);
+  }
+
+  /**
+   * Returns the available models as a list of Maps objects. After an update the managed resources
+   * needs to return the resources in this format in order to store in json somewhere (zookeeper,
+   * disk...)
+   *
+   * @return the available models as a list of Maps objects
+   */
+  private static List<Object> modelsAsManagedResources(List<SolrChatModel> models) {
+    return models.stream().map(ManagedChatModelStore::toModelMap).collect(Collectors.toList());
+  }
+
+  @SuppressWarnings("unchecked")
+  public static SolrChatModel fromModelMap(
+      SolrResourceLoader solrResourceLoader, Map<String, Object> chatModel) {
+    return SolrChatModel.getInstance(
+        solrResourceLoader,
+        (String) chatModel.get(CLASS_KEY), // modelClassName
+        (String) chatModel.get(NAME_KEY), // modelName
+        (Map<String, Object>) chatModel.get(PARAMS_KEY));
+  }
+
+  private static LinkedHashMap<String, Object> toModelMap(SolrChatModel model) {
+    final LinkedHashMap<String, Object> modelMap = new LinkedHashMap<>(3, 1.0f);
+    modelMap.put(NAME_KEY, model.getName());
+    modelMap.put(CLASS_KEY, model.getChatModelClassName());
+    modelMap.put(PARAMS_KEY, model.getParams());
+    return modelMap;
+  }
+
+  private final ChatModelStore store;
+  private Object managedData;
+
+  public ManagedChatModelStore(
+      String resourceId, SolrResourceLoader loader, ManagedResourceStorage.StorageIO storageIO)
+      throws SolrException {
+    super(resourceId, loader, storageIO);
+    store = new ChatModelStore();
+  }
+
+  @Override
+  protected void onManagedDataLoadedFromStorage(NamedList<?> managedInitArgs, Object managedData)
+      throws SolrException {
+    store.clear();
+    this.managedData = managedData;
+  }
+
+  public void loadStoredModels() {
+    log.info("------ managed models ~ loading ------");
+
+    if ((managedData != null) && (managedData instanceof List)) {
+      @SuppressWarnings({"unchecked"})
+      final List<Map<String, Object>> chatModels = (List<Map<String, Object>>) managedData;
+      for (final Map<String, Object> chatModel : chatModels) {
+        addModelFromMap(chatModel);
+      }
+    }
+  }
+
+  private void addModelFromMap(Map<String, Object> modelMap) {
+    try {
+      addModel(fromModelMap(solrResourceLoader, modelMap));
+    } catch (final ChatModelException e) {
+      throw new SolrException(SolrException.ErrorCode.BAD_REQUEST, e.getMessage(), e);
+    }
+  }
+
+  public void addModel(SolrChatModel model) throws SolrException {
+    try {
+      if (log.isInfoEnabled()) {
+        log.info("adding model {}", model.getName());
+      }
+      store.addModel(model);
+    } catch (final ChatModelException e) {
+      throw new SolrException(SolrException.ErrorCode.BAD_REQUEST, e);
+    }
+  }
+
+  @SuppressWarnings("unchecked")
+  @Override
+  protected Object applyUpdatesToManagedData(Object updates) {
+    if (updates instanceof List) {
+      final List<Map<String, Object>> chatModels = (List<Map<String, Object>>) updates;
+      for (final Map<String, Object> chatModel : chatModels) {
+        addModelFromMap(chatModel);
+      }
+    }
+
+    if (updates instanceof Map) {
+      final Map<String, Object> map = (Map<String, Object>) updates;
+      addModelFromMap(map);
+    }
+
+    return modelsAsManagedResources(store.getModels());
+  }
+
+  @Override
+  public void doDeleteChild(BaseSolrResource endpoint, String childId) {
+    store.delete(childId);
+    storeManagedData(applyUpdatesToManagedData(null));
+  }
+
+  /**
+   * Called to retrieve a named part (the given childId) of the resource at the given endpoint.
+   * Note: since we have a unique child managed store we ignore the childId.
+   */
+  @Override
+  public void doGet(BaseSolrResource endpoint, String childId) {
+    final SolrQueryResponse response = endpoint.getSolrResponse();
+    response.add(MODELS_JSON_FIELD, modelsAsManagedResources(store.getModels()));
+  }
+
+  public SolrChatModel getModel(String modelName) {
+    return store.getModel(modelName);
+  }
+

Review Comment:
   this entire class feels like exactly the same of the one I implemented for embedding models.
   Can't we use the same class but for multiple storage solutions?
   So you instantiate different endpoints but same class.
   It feels a lot of duplicate code



-- 
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: issues-unsubscribe@solr.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


---------------------------------------------------------------------
To unsubscribe, e-mail: issues-unsubscribe@solr.apache.org
For additional commands, e-mail: issues-help@solr.apache.org


Re: [PR] SOLR-18187: Document enrichment with LLMs [solr]

Posted by "alessandrobenedetti (via GitHub)" <gi...@apache.org>.
alessandrobenedetti commented on code in PR #4259:
URL: https://github.com/apache/solr/pull/4259#discussion_r3219694670


##########
solr/modules/language-models/src/test/org/apache/solr/languagemodels/store/rest/TestTextToVectorModelManager.java:
##########
@@ -27,7 +27,7 @@
 import org.junit.BeforeClass;
 import org.junit.Test;
 
-public class TestModelManager extends TestLanguageModelBase {
+public class TestTextToVectorModelManager extends TestLanguageModelBase {

Review Comment:
   What about this?



##########
solr/modules/language-models/src/test/org/apache/solr/languagemodels/store/rest/TestTextToVectorModelManagerPersistence.java:
##########
@@ -21,7 +21,7 @@
 import org.junit.Before;
 import org.junit.Test;
 

Review Comment:
   another suspicious spurious change



##########
solr/modules/language-models/src/test/org/apache/solr/languagemodels/update/processor/TextToVectorUpdateProcessorTest.java:
##########
@@ -51,7 +51,7 @@ public void afterEachTest() throws Exception {
 
   @Test
   public void processAdd_inputField_shouldVectoriseInputField() throws Exception {
-    loadModel("dummy-model.json"); // preparation
+    loadTextToVectorModel("dummy-model.json"); // preparation

Review Comment:
   another spurious change



##########
solr/modules/language-models/src/test/org/apache/solr/languagemodels/search/TextToVectorQParserTest.java:
##########
@@ -28,7 +28,7 @@ public class TextToVectorQParserTest extends TestLanguageModelBase {
   @BeforeClass
   public static void init() throws Exception {
     setupTest("solrconfig-language-models.xml", "schema-language-models.xml", true, false);
-    loadModel("dummy-model.json");
+    loadTextToVectorModel("dummy-model.json");

Review Comment:
   What about this?



##########
solr/modules/language-models/src/test/org/apache/solr/languagemodels/TestLanguageModelBase.java:
##########
@@ -87,7 +104,7 @@ protected static void afterTest() throws Exception {
     }
   }
 
-  public static void loadModel(String fileName, String status) throws Exception {
+  public static void loadTextToVectorModel(String fileName, String status) throws Exception {

Review Comment:
   spurious change



-- 
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: issues-unsubscribe@solr.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


---------------------------------------------------------------------
To unsubscribe, e-mail: issues-unsubscribe@solr.apache.org
For additional commands, e-mail: issues-help@solr.apache.org


Re: [PR] SOLR-18187: Document enrichment with LLMs [solr]

Posted by "aruggero (via GitHub)" <gi...@apache.org>.
aruggero commented on code in PR #4259:
URL: https://github.com/apache/solr/pull/4259#discussion_r3084761971


##########
solr/solr-ref-guide/modules/indexing-guide/pages/document-enrichment-with-llms.adoc:
##########
@@ -0,0 +1,500 @@
+= Document Enrichment with LLMs
+// 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.
+
+This module brings the power of *Large Language Models* to Solr.
+
+More specifically, it provides the capability, at indexing time, given a prompt and a set of input fields, of calling an

Review Comment:
   More specifically, it enables calling an LLM at indexing time to enrich documents with additional/generated/extracted data. Given a prompt and a set of input fields, for each document, the LLM is invoked through https://github.com/langchain4j/langchain4j[LangChain4j], and the result is stored in an `outputField`, which can support multiple types and may also be multivalued.



##########
solr/solr-ref-guide/modules/indexing-guide/pages/document-enrichment-with-llms.adoc:
##########
@@ -0,0 +1,500 @@
+= Document Enrichment with LLMs
+// 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.
+
+This module brings the power of *Large Language Models* to Solr.
+
+More specifically, it provides the capability, at indexing time, given a prompt and a set of input fields, of calling an
+LLM through https://github.com/langchain4j/langchain4j[LangChain4j] for each document and store the result of the call
+in an `outputField`, that can be of multiple types and even multivalued.
+
+_Without_ this module, the LLM calls must be done _outside_ Solr, before indexing.
+
+[IMPORTANT]
+====
+This module sends your documents off to some hosted service on the internet.
+There are cost, privacy, performance, and service availability implications on such a strong dependency that should be
+diligently examined before employing this module in a serious way.
+
+====
+
+At the moment a subset of LLM providers supported by LangChain4j is supported by Solr.

Review Comment:
   At the moment, Solr supports a subset of the LLM providers available in LangChain4j.



##########
solr/solr-ref-guide/modules/indexing-guide/pages/document-enrichment-with-llms.adoc:
##########
@@ -0,0 +1,500 @@
+= Document Enrichment with LLMs
+// 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.
+
+This module brings the power of *Large Language Models* to Solr.
+
+More specifically, it provides the capability, at indexing time, given a prompt and a set of input fields, of calling an
+LLM through https://github.com/langchain4j/langchain4j[LangChain4j] for each document and store the result of the call
+in an `outputField`, that can be of multiple types and even multivalued.
+
+_Without_ this module, the LLM calls must be done _outside_ Solr, before indexing.

Review Comment:
   _Without_ this module, the LLM calls to enrich documents must be done _outside_ Solr, before indexing.



##########
solr/solr-ref-guide/modules/indexing-guide/pages/document-enrichment-with-llms.adoc:
##########
@@ -0,0 +1,500 @@
+= Document Enrichment with LLMs
+// 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.
+
+This module brings the power of *Large Language Models* to Solr.
+
+More specifically, it provides the capability, at indexing time, given a prompt and a set of input fields, of calling an
+LLM through https://github.com/langchain4j/langchain4j[LangChain4j] for each document and store the result of the call
+in an `outputField`, that can be of multiple types and even multivalued.
+
+_Without_ this module, the LLM calls must be done _outside_ Solr, before indexing.
+
+[IMPORTANT]
+====
+This module sends your documents off to some hosted service on the internet.
+There are cost, privacy, performance, and service availability implications on such a strong dependency that should be
+diligently examined before employing this module in a serious way.
+
+====
+
+At the moment a subset of LLM providers supported by LangChain4j is supported by Solr.
+
+*Disclaimer*: Apache Solr is *in no way* affiliated to any of these corporations or services.
+
+If you want to add support for additional services or improve the support for the existing ones, feel free to
+contribute:
+
+* https://github.com/apache/solr/blob/main/CONTRIBUTING.md[Contributing to Solr]
+
+== Module
+
+This is provided via the `language-models` xref:configuration-guide:solr-modules.adoc[Solr Module] that needs to be
+enabled before use.
+
+== Language Model Configuration
+
+Language Models is a module and therefore its plugins must be configured in `solrconfig.xml`.
+
+=== Minimum Requirements
+
+* Enable the `language-models` module to make the Language Models classes available on Solr's classpath.
+See xref:configuration-guide:solr-modules.adoc[Solr Module] for more details.
+
+* An update processor, similar to the one below, must be declared in `solrconfig.xml`:
++
+[source,xml]
+----
+<updateRequestProcessorChain name="documentEnrichment">
+  <processor class="solr.languagemodels.documentenrichment.update.processor.DocumentEnrichmentUpdateProcessorFactory">
+   <str name="inputField">string_field</str>
+   <str name="outputField">summary</str>
+   <str name="prompt">Summarize this content: {string_field}</str>
+   <str name="model">model-name</str>
+  </processor>
+  <processor class="solr.RunUpdateProcessorFactory"/>
+ </updateRequestProcessorChain>
+----
+[NOTE]
+====
+If no component is configured in `solrconfig.xml`, the `ChatModel` store will not be registered and requests to

Review Comment:
   Remove



##########
solr/solr-ref-guide/modules/indexing-guide/pages/document-enrichment-with-llms.adoc:
##########
@@ -0,0 +1,500 @@
+= Document Enrichment with LLMs
+// 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.
+
+This module brings the power of *Large Language Models* to Solr.
+
+More specifically, it provides the capability, at indexing time, given a prompt and a set of input fields, of calling an
+LLM through https://github.com/langchain4j/langchain4j[LangChain4j] for each document and store the result of the call
+in an `outputField`, that can be of multiple types and even multivalued.
+
+_Without_ this module, the LLM calls must be done _outside_ Solr, before indexing.
+
+[IMPORTANT]
+====
+This module sends your documents off to some hosted service on the internet.
+There are cost, privacy, performance, and service availability implications on such a strong dependency that should be
+diligently examined before employing this module in a serious way.
+
+====
+
+At the moment a subset of LLM providers supported by LangChain4j is supported by Solr.
+
+*Disclaimer*: Apache Solr is *in no way* affiliated to any of these corporations or services.
+
+If you want to add support for additional services or improve the support for the existing ones, feel free to
+contribute:
+
+* https://github.com/apache/solr/blob/main/CONTRIBUTING.md[Contributing to Solr]
+
+== Module
+
+This is provided via the `language-models` xref:configuration-guide:solr-modules.adoc[Solr Module] that needs to be
+enabled before use.
+
+== Language Model Configuration
+
+Language Models is a module and therefore its plugins must be configured in `solrconfig.xml`.
+
+=== Minimum Requirements
+
+* Enable the `language-models` module to make the Language Models classes available on Solr's classpath.
+See xref:configuration-guide:solr-modules.adoc[Solr Module] for more details.
+
+* An update processor, similar to the one below, must be declared in `solrconfig.xml`:
++
+[source,xml]
+----
+<updateRequestProcessorChain name="documentEnrichment">
+  <processor class="solr.languagemodels.documentenrichment.update.processor.DocumentEnrichmentUpdateProcessorFactory">
+   <str name="inputField">string_field</str>
+   <str name="outputField">summary</str>
+   <str name="prompt">Summarize this content: {string_field}</str>
+   <str name="model">model-name</str>
+  </processor>
+  <processor class="solr.RunUpdateProcessorFactory"/>
+ </updateRequestProcessorChain>
+----
+[NOTE]
+====
+If no component is configured in `solrconfig.xml`, the `ChatModel` store will not be registered and requests to
+`/schema/chat-model-store` will return an error.
+====
+
+== Chat Model Configuration

Review Comment:
   Mmmm.. maybe "Chat Model setup?"



##########
solr/solr-ref-guide/modules/indexing-guide/pages/document-enrichment-with-llms.adoc:
##########
@@ -0,0 +1,500 @@
+= Document Enrichment with LLMs
+// 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.
+
+This module brings the power of *Large Language Models* to Solr.
+
+More specifically, it provides the capability, at indexing time, given a prompt and a set of input fields, of calling an
+LLM through https://github.com/langchain4j/langchain4j[LangChain4j] for each document and store the result of the call
+in an `outputField`, that can be of multiple types and even multivalued.
+
+_Without_ this module, the LLM calls must be done _outside_ Solr, before indexing.
+
+[IMPORTANT]
+====
+This module sends your documents off to some hosted service on the internet.
+There are cost, privacy, performance, and service availability implications on such a strong dependency that should be
+diligently examined before employing this module in a serious way.
+
+====
+
+At the moment a subset of LLM providers supported by LangChain4j is supported by Solr.
+
+*Disclaimer*: Apache Solr is *in no way* affiliated to any of these corporations or services.
+
+If you want to add support for additional services or improve the support for the existing ones, feel free to
+contribute:
+
+* https://github.com/apache/solr/blob/main/CONTRIBUTING.md[Contributing to Solr]
+
+== Module
+
+This is provided via the `language-models` xref:configuration-guide:solr-modules.adoc[Solr Module] that needs to be
+enabled before use.
+
+== Language Model Configuration
+
+Language Models is a module and therefore its plugins must be configured in `solrconfig.xml`.
+
+=== Minimum Requirements
+
+* Enable the `language-models` module to make the Language Models classes available on Solr's classpath.
+See xref:configuration-guide:solr-modules.adoc[Solr Module] for more details.
+
+* An update processor, similar to the one below, must be declared in `solrconfig.xml`:
++
+[source,xml]
+----
+<updateRequestProcessorChain name="documentEnrichment">
+  <processor class="solr.languagemodels.documentenrichment.update.processor.DocumentEnrichmentUpdateProcessorFactory">
+   <str name="inputField">string_field</str>
+   <str name="outputField">summary</str>
+   <str name="prompt">Summarize this content: {string_field}</str>
+   <str name="model">model-name</str>
+  </processor>
+  <processor class="solr.RunUpdateProcessorFactory"/>
+ </updateRequestProcessorChain>
+----
+[NOTE]
+====
+If no component is configured in `solrconfig.xml`, the `ChatModel` store will not be registered and requests to
+`/schema/chat-model-store` will return an error.
+====
+
+== Chat Model Configuration
+
+=== Models
+
+* A model in this module is a chat model, that answers with text given a prompt.

Review Comment:
   A model is a chat model that generates a text response given a prompt.



##########
solr/solr-ref-guide/modules/indexing-guide/pages/document-enrichment-with-llms.adoc:
##########
@@ -0,0 +1,500 @@
+= Document Enrichment with LLMs
+// 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.
+
+This module brings the power of *Large Language Models* to Solr.
+
+More specifically, it provides the capability, at indexing time, given a prompt and a set of input fields, of calling an
+LLM through https://github.com/langchain4j/langchain4j[LangChain4j] for each document and store the result of the call
+in an `outputField`, that can be of multiple types and even multivalued.
+
+_Without_ this module, the LLM calls must be done _outside_ Solr, before indexing.
+
+[IMPORTANT]
+====
+This module sends your documents off to some hosted service on the internet.
+There are cost, privacy, performance, and service availability implications on such a strong dependency that should be
+diligently examined before employing this module in a serious way.
+
+====
+
+At the moment a subset of LLM providers supported by LangChain4j is supported by Solr.
+
+*Disclaimer*: Apache Solr is *in no way* affiliated to any of these corporations or services.
+
+If you want to add support for additional services or improve the support for the existing ones, feel free to
+contribute:
+
+* https://github.com/apache/solr/blob/main/CONTRIBUTING.md[Contributing to Solr]
+
+== Module
+
+This is provided via the `language-models` xref:configuration-guide:solr-modules.adoc[Solr Module] that needs to be
+enabled before use.
+
+== Language Model Configuration
+
+Language Models is a module and therefore its plugins must be configured in `solrconfig.xml`.
+
+=== Minimum Requirements
+
+* Enable the `language-models` module to make the Language Models classes available on Solr's classpath.
+See xref:configuration-guide:solr-modules.adoc[Solr Module] for more details.
+
+* An update processor, similar to the one below, must be declared in `solrconfig.xml`:
++
+[source,xml]
+----
+<updateRequestProcessorChain name="documentEnrichment">
+  <processor class="solr.languagemodels.documentenrichment.update.processor.DocumentEnrichmentUpdateProcessorFactory">
+   <str name="inputField">string_field</str>
+   <str name="outputField">summary</str>
+   <str name="prompt">Summarize this content: {string_field}</str>
+   <str name="model">model-name</str>
+  </processor>
+  <processor class="solr.RunUpdateProcessorFactory"/>
+ </updateRequestProcessorChain>
+----
+[NOTE]
+====
+If no component is configured in `solrconfig.xml`, the `ChatModel` store will not be registered and requests to
+`/schema/chat-model-store` will return an error.
+====
+
+== Chat Model Configuration
+
+=== Models
+
+* A model in this module is a chat model, that answers with text given a prompt.
+* A model in this Solr module is a reference to an external API that runs the Large Language Model responsible for chat
+completion.
+
+[IMPORTANT]
+====
+The Solr chat model specifies the parameters to access the APIs, the LLM doesn't run internally in Solr
+
+====
+
+A model is described by these parameters:
+
+
+`class`::
++
+[%autowidth,frame=none]
+|===
+s|Required |Default: none
+|===
++
+The model implementation.
+Accepted values:
+
+* `dev.langchain4j.model.ollama.OllamaChatModel`
+* `dev.langchain4j.model.mistralai.MistralAiChatModel`
+* `dev.langchain4j.model.anthropic.AnthropicChatModel`
+* `dev.langchain4j.model.openai.OpenAiChatModel`
+* `dev.langchain4j.model.googleai.GoogleAiGeminiChatModel`
+
+`name`::
++
+[%autowidth,frame=none]
+|===
+s|Required |Default: none
+|===
++
+The identifier of your model, this is used by any component that intends to use the model (e.g., `DocumentEnrichmentUpdateProcessorFactory` update processor).
+
+`params`::
++
+[%autowidth,frame=none]
+|===
+|Optional |Default: none
+|===
++
+Each model class has potentially different params.
+Many are shared but for the full set of parameters of the model you are interested in please refer to the official documentation of the LangChain4j version included in Solr: https://docs.langchain4j.dev/category/language-models[Chat Models in LangChain4j].
+
+=== Supported Models
+Apache Solr uses https://github.com/langchain4j/langchain4j[LangChain4j] to support document enrichment with LLMs.
+The models currently supported are:
+
+[tabs#supported-chat-models]
+======
+Ollama::
++
+====
+
+[source,json]
+----
+{
+  "class": "dev.langchain4j.model.ollama.OllamaChatModel",
+  "name": "<a-name-for-your-model>",
+  "params": {
+    "baseUrl": "http://localhost:11434",
+    "modelName": "<a-local/hosted-chat-model>",
+    "timeout": 300,
+    "logRequests": true,
+    "logResponses": true,
+    "maxRetries": 5
+  }
+}
+----
+====
+
+MistralAI::
++
+====
+[source,json]
+----
+{
+  "class": "dev.langchain4j.model.mistralai.MistralAiChatModel",
+  "name": "<a-name-for-your-model>",
+  "params": {
+    "baseUrl": "https://api.mistral.ai/v1",
+    "apiKey": "<your-mistralAI-api-key>",
+    "modelName": "<a-mistralAI-chat-model>",
+    "timeout": 60,
+    "logRequests": true,
+    "logResponses": true,
+    "maxRetries": 5
+  }
+}
+----
+====
+OpenAI::
++
+====
+[source,json]
+----
+{
+  "class": "dev.langchain4j.model.openai.OpenAiChatModel",
+  "name": "<a-name-for-your-model>",
+  "params": {
+    "baseUrl": "https://api.openai.com/v1",
+    "apiKey": "<your-openAI-api-key>",
+    "modelName": "<a-openAI-chat-model>",
+    "timeout": 60,
+    "logRequests": true,
+    "logResponses": true,
+    "maxRetries": 5
+  }
+}
+----
+====
+
+Anthropic::
++
+====
+[source,json]
+----
+{
+  "class": "dev.langchain4j.model.anthropic.AnthropicChatModel",
+  "name": "<a-name-for-your-model>",
+  "params": {
+    "baseUrl": "https://api.anthropic.com/v1/",
+    "apiKey": "<your-anthropic-api-key>",
+    "modelName": "<a-anthropic-chat-model>",
+    "timeout": 60,
+    "logRequests": true,
+    "logResponses": true,
+    "maxRetries": 5
+  }
+}
+----
+====
+
+Gemini::
++
+====
+[source,json]
+----
+{
+  "class": "dev.langchain4j.model.googleai.GoogleAiGeminiChatModel",
+  "name": "<a-name-for-your-model>",
+  "params": {
+    "baseUrl": "https://generativelanguage.googleapis.com/v1beta/",
+    "apiKey": "<your-geminiAi-api-key>",
+    "modelName": "<a-geminiAi-chat-model>",
+    "timeout": 60,
+    "logRequests": true,
+    "logResponses": true,
+    "maxRetries": 5
+  }
+}
+----
+====
+======
+
+=== Uploading a Model
+
+To upload the model in a `/path/myModel.json` file, please run:
+
+[source,bash]
+----
+curl -XPUT 'http://localhost:8983/solr/YOUR_COLLECTION/schema/chat-model-store' --data-binary "@/path/myModel.json" -H 'Content-type:application/json'
+----
+
+To delete the `currentModel` model:
+
+[source,bash]
+----
+curl -XDELETE 'http://localhost:8983/solr/YOUR_COLLECTION/schema/chat-model-store/currentModel'
+----
+
+To view all models:
+
+[source,text]
+http://localhost:8983/solr/YOUR_COLLECTION/schema/chat-model-store
+
+
+.Example: /path/myOpenAIModel.json
+[source,json]
+----
+{
+  "class": "dev.langchain4j.model.openai.OpenAiChatModel",
+  "name": "openai-1",
+  "params": {
+    "baseUrl": "https://api.openai.com/v1",
+    "apiKey": "apiKey-openAI",
+    "modelName": "gpt-5.4-nano",
+    "timeout": 60,
+    "logRequests": true,
+    "logResponses": true,
+    "maxRetries": 5
+  }
+}
+----
+
+== How to Trigger Document Enrichment during Indexing
+To create new fields starting from existent ones in your documents at indexing time you need to configure an {solr-javadocs}/core/org/apache/solr/update/processor/UpdateRequestProcessorChain.html[Update Request Processor Chain] that includes at least one `DocumentEnrichmentUpdateProcessor` update request processor in one of the 2 following way:

Review Comment:
   To create new fields from existing document fields at indexing time, configure an {solr-javadocs}/core/org/apache/solr/update/processor/UpdateRequestProcessorChain.html[UpdateRequestProcessorChain] that includes at least one `DocumentEnrichmentUpdateProcessor` update request processor.
   
   This can be done in one of the following two ways:



##########
solr/solr-ref-guide/modules/indexing-guide/pages/document-enrichment-with-llms.adoc:
##########
@@ -0,0 +1,500 @@
+= Document Enrichment with LLMs
+// 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.
+
+This module brings the power of *Large Language Models* to Solr.
+
+More specifically, it provides the capability, at indexing time, given a prompt and a set of input fields, of calling an
+LLM through https://github.com/langchain4j/langchain4j[LangChain4j] for each document and store the result of the call
+in an `outputField`, that can be of multiple types and even multivalued.
+
+_Without_ this module, the LLM calls must be done _outside_ Solr, before indexing.
+
+[IMPORTANT]
+====
+This module sends your documents off to some hosted service on the internet.
+There are cost, privacy, performance, and service availability implications on such a strong dependency that should be
+diligently examined before employing this module in a serious way.
+
+====
+
+At the moment a subset of LLM providers supported by LangChain4j is supported by Solr.
+
+*Disclaimer*: Apache Solr is *in no way* affiliated to any of these corporations or services.
+
+If you want to add support for additional services or improve the support for the existing ones, feel free to
+contribute:
+
+* https://github.com/apache/solr/blob/main/CONTRIBUTING.md[Contributing to Solr]
+
+== Module
+
+This is provided via the `language-models` xref:configuration-guide:solr-modules.adoc[Solr Module] that needs to be
+enabled before use.
+
+== Language Model Configuration
+
+Language Models is a module and therefore its plugins must be configured in `solrconfig.xml`.
+
+=== Minimum Requirements
+
+* Enable the `language-models` module to make the Language Models classes available on Solr's classpath.
+See xref:configuration-guide:solr-modules.adoc[Solr Module] for more details.
+
+* An update processor, similar to the one below, must be declared in `solrconfig.xml`:
++
+[source,xml]
+----
+<updateRequestProcessorChain name="documentEnrichment">
+  <processor class="solr.languagemodels.documentenrichment.update.processor.DocumentEnrichmentUpdateProcessorFactory">
+   <str name="inputField">string_field</str>
+   <str name="outputField">summary</str>
+   <str name="prompt">Summarize this content: {string_field}</str>
+   <str name="model">model-name</str>
+  </processor>
+  <processor class="solr.RunUpdateProcessorFactory"/>
+ </updateRequestProcessorChain>
+----
+[NOTE]
+====
+If no component is configured in `solrconfig.xml`, the `ChatModel` store will not be registered and requests to
+`/schema/chat-model-store` will return an error.
+====
+
+== Chat Model Configuration
+
+=== Models
+
+* A model in this module is a chat model, that answers with text given a prompt.
+* A model in this Solr module is a reference to an external API that runs the Large Language Model responsible for chat
+completion.
+
+[IMPORTANT]
+====
+The Solr chat model specifies the parameters to access the APIs, the LLM doesn't run internally in Solr
+
+====
+
+A model is described by these parameters:
+
+
+`class`::
++
+[%autowidth,frame=none]
+|===
+s|Required |Default: none
+|===
++
+The model implementation.
+Accepted values:
+
+* `dev.langchain4j.model.ollama.OllamaChatModel`
+* `dev.langchain4j.model.mistralai.MistralAiChatModel`
+* `dev.langchain4j.model.anthropic.AnthropicChatModel`
+* `dev.langchain4j.model.openai.OpenAiChatModel`
+* `dev.langchain4j.model.googleai.GoogleAiGeminiChatModel`
+
+`name`::
++
+[%autowidth,frame=none]
+|===
+s|Required |Default: none
+|===
++
+The identifier of your model, this is used by any component that intends to use the model (e.g., `DocumentEnrichmentUpdateProcessorFactory` update processor).
+
+`params`::
++
+[%autowidth,frame=none]
+|===
+|Optional |Default: none
+|===
++
+Each model class has potentially different params.
+Many are shared but for the full set of parameters of the model you are interested in please refer to the official documentation of the LangChain4j version included in Solr: https://docs.langchain4j.dev/category/language-models[Chat Models in LangChain4j].
+
+=== Supported Models
+Apache Solr uses https://github.com/langchain4j/langchain4j[LangChain4j] to support document enrichment with LLMs.
+The models currently supported are:
+
+[tabs#supported-chat-models]
+======
+Ollama::
++
+====
+
+[source,json]
+----
+{
+  "class": "dev.langchain4j.model.ollama.OllamaChatModel",
+  "name": "<a-name-for-your-model>",
+  "params": {
+    "baseUrl": "http://localhost:11434",
+    "modelName": "<a-local/hosted-chat-model>",
+    "timeout": 300,
+    "logRequests": true,
+    "logResponses": true,
+    "maxRetries": 5
+  }
+}
+----
+====
+
+MistralAI::
++
+====
+[source,json]
+----
+{
+  "class": "dev.langchain4j.model.mistralai.MistralAiChatModel",
+  "name": "<a-name-for-your-model>",
+  "params": {
+    "baseUrl": "https://api.mistral.ai/v1",
+    "apiKey": "<your-mistralAI-api-key>",
+    "modelName": "<a-mistralAI-chat-model>",
+    "timeout": 60,
+    "logRequests": true,
+    "logResponses": true,
+    "maxRetries": 5
+  }
+}
+----
+====
+OpenAI::
++
+====
+[source,json]
+----
+{
+  "class": "dev.langchain4j.model.openai.OpenAiChatModel",
+  "name": "<a-name-for-your-model>",
+  "params": {
+    "baseUrl": "https://api.openai.com/v1",
+    "apiKey": "<your-openAI-api-key>",
+    "modelName": "<a-openAI-chat-model>",
+    "timeout": 60,
+    "logRequests": true,
+    "logResponses": true,
+    "maxRetries": 5
+  }
+}
+----
+====
+
+Anthropic::
++
+====
+[source,json]
+----
+{
+  "class": "dev.langchain4j.model.anthropic.AnthropicChatModel",
+  "name": "<a-name-for-your-model>",
+  "params": {
+    "baseUrl": "https://api.anthropic.com/v1/",
+    "apiKey": "<your-anthropic-api-key>",
+    "modelName": "<a-anthropic-chat-model>",
+    "timeout": 60,
+    "logRequests": true,
+    "logResponses": true,
+    "maxRetries": 5
+  }
+}
+----
+====
+
+Gemini::
++
+====
+[source,json]
+----
+{
+  "class": "dev.langchain4j.model.googleai.GoogleAiGeminiChatModel",
+  "name": "<a-name-for-your-model>",
+  "params": {
+    "baseUrl": "https://generativelanguage.googleapis.com/v1beta/",
+    "apiKey": "<your-geminiAi-api-key>",
+    "modelName": "<a-geminiAi-chat-model>",
+    "timeout": 60,
+    "logRequests": true,
+    "logResponses": true,
+    "maxRetries": 5
+  }
+}
+----
+====
+======
+
+=== Uploading a Model
+
+To upload the model in a `/path/myModel.json` file, please run:
+
+[source,bash]
+----
+curl -XPUT 'http://localhost:8983/solr/YOUR_COLLECTION/schema/chat-model-store' --data-binary "@/path/myModel.json" -H 'Content-type:application/json'
+----
+
+To delete the `currentModel` model:
+
+[source,bash]
+----
+curl -XDELETE 'http://localhost:8983/solr/YOUR_COLLECTION/schema/chat-model-store/currentModel'
+----
+
+To view all models:
+
+[source,text]
+http://localhost:8983/solr/YOUR_COLLECTION/schema/chat-model-store
+
+
+.Example: /path/myOpenAIModel.json
+[source,json]
+----
+{
+  "class": "dev.langchain4j.model.openai.OpenAiChatModel",
+  "name": "openai-1",
+  "params": {
+    "baseUrl": "https://api.openai.com/v1",
+    "apiKey": "apiKey-openAI",
+    "modelName": "gpt-5.4-nano",
+    "timeout": 60,
+    "logRequests": true,
+    "logResponses": true,
+    "maxRetries": 5
+  }
+}
+----
+
+== How to Trigger Document Enrichment during Indexing
+To create new fields starting from existent ones in your documents at indexing time you need to configure an {solr-javadocs}/core/org/apache/solr/update/processor/UpdateRequestProcessorChain.html[Update Request Processor Chain] that includes at least one `DocumentEnrichmentUpdateProcessor` update request processor in one of the 2 following way:
+
+* Update processor with parameter `prompt`
++
+[source,xml]
+----
+<updateRequestProcessorChain name="documentEnrichment">
+  <processor class="solr.languagemodels.documentenrichment.update.processor.DocumentEnrichmentUpdateProcessorFactory">
+   <str name="inputField">string_field</str>
+   <str name="outputField">summary</str>
+   <str name="prompt">Summarize this content: {string_field}</str>
+   <str name="model">model-name</str>
+  </processor>
+  <processor class="solr.RunUpdateProcessorFactory"/>
+ </updateRequestProcessorChain>
+----
+
+* Update processor with parameter `promptFile`: in this case, the file `prompt.txt` must be uploaded to Solr similarly to any other configuration file (e.g., `solrconfig.xml`, `synonyms.txt`, etc.)
++
+[source,xml]
+----
+<updateRequestProcessorChain name="documentEnrichment">
+  <processor class="solr.languagemodels.documentenrichment.update.processor.DocumentEnrichmentUpdateProcessorFactory">
+   <str name="inputField">string_field</str>
+   <str name="outputField">summary</str>
+   <str name="promptFile">prompt.txt</str>
+   <str name="model">model-name</str>
+  </processor>
+  <processor class="solr.RunUpdateProcessorFactory"/>
+ </updateRequestProcessorChain>
+----
+
+Exactly one of the following parameters is required: `prompt` or `promptFile`.
+
+Another important feature of this module is that one (or more) `inputField` needs to be injected in the prompt. This is

Review Comment:
   I would structure this part as:
   These are the available parameters: bullet point and then the explanation of each... meaning.. usage... required or not... similar to the ones you put in langchain4j part



##########
solr/solr-ref-guide/modules/indexing-guide/pages/document-enrichment-with-llms.adoc:
##########
@@ -0,0 +1,500 @@
+= Document Enrichment with LLMs
+// 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.
+
+This module brings the power of *Large Language Models* to Solr.
+
+More specifically, it provides the capability, at indexing time, given a prompt and a set of input fields, of calling an
+LLM through https://github.com/langchain4j/langchain4j[LangChain4j] for each document and store the result of the call
+in an `outputField`, that can be of multiple types and even multivalued.
+
+_Without_ this module, the LLM calls must be done _outside_ Solr, before indexing.
+
+[IMPORTANT]
+====
+This module sends your documents off to some hosted service on the internet.
+There are cost, privacy, performance, and service availability implications on such a strong dependency that should be
+diligently examined before employing this module in a serious way.
+
+====
+
+At the moment a subset of LLM providers supported by LangChain4j is supported by Solr.
+
+*Disclaimer*: Apache Solr is *in no way* affiliated to any of these corporations or services.
+
+If you want to add support for additional services or improve the support for the existing ones, feel free to
+contribute:
+
+* https://github.com/apache/solr/blob/main/CONTRIBUTING.md[Contributing to Solr]
+
+== Module
+
+This is provided via the `language-models` xref:configuration-guide:solr-modules.adoc[Solr Module] that needs to be
+enabled before use.
+
+== Language Model Configuration
+
+Language Models is a module and therefore its plugins must be configured in `solrconfig.xml`.
+
+=== Minimum Requirements
+
+* Enable the `language-models` module to make the Language Models classes available on Solr's classpath.
+See xref:configuration-guide:solr-modules.adoc[Solr Module] for more details.
+
+* An update processor, similar to the one below, must be declared in `solrconfig.xml`:
++
+[source,xml]
+----
+<updateRequestProcessorChain name="documentEnrichment">
+  <processor class="solr.languagemodels.documentenrichment.update.processor.DocumentEnrichmentUpdateProcessorFactory">
+   <str name="inputField">string_field</str>
+   <str name="outputField">summary</str>
+   <str name="prompt">Summarize this content: {string_field}</str>
+   <str name="model">model-name</str>
+  </processor>
+  <processor class="solr.RunUpdateProcessorFactory"/>
+ </updateRequestProcessorChain>
+----
+[NOTE]
+====
+If no component is configured in `solrconfig.xml`, the `ChatModel` store will not be registered and requests to
+`/schema/chat-model-store` will return an error.
+====
+
+== Chat Model Configuration
+
+=== Models
+
+* A model in this module is a chat model, that answers with text given a prompt.
+* A model in this Solr module is a reference to an external API that runs the Large Language Model responsible for chat
+completion.
+
+[IMPORTANT]
+====
+The Solr chat model specifies the parameters to access the APIs, the LLM doesn't run internally in Solr
+
+====
+
+A model is described by these parameters:
+
+
+`class`::
++
+[%autowidth,frame=none]
+|===
+s|Required |Default: none
+|===
++
+The model implementation.
+Accepted values:
+
+* `dev.langchain4j.model.ollama.OllamaChatModel`
+* `dev.langchain4j.model.mistralai.MistralAiChatModel`
+* `dev.langchain4j.model.anthropic.AnthropicChatModel`
+* `dev.langchain4j.model.openai.OpenAiChatModel`
+* `dev.langchain4j.model.googleai.GoogleAiGeminiChatModel`
+
+`name`::
++
+[%autowidth,frame=none]
+|===
+s|Required |Default: none
+|===
++
+The identifier of your model, this is used by any component that intends to use the model (e.g., `DocumentEnrichmentUpdateProcessorFactory` update processor).
+
+`params`::
++
+[%autowidth,frame=none]
+|===
+|Optional |Default: none
+|===
++
+Each model class has potentially different params.
+Many are shared but for the full set of parameters of the model you are interested in please refer to the official documentation of the LangChain4j version included in Solr: https://docs.langchain4j.dev/category/language-models[Chat Models in LangChain4j].
+
+=== Supported Models
+Apache Solr uses https://github.com/langchain4j/langchain4j[LangChain4j] to support document enrichment with LLMs.
+The models currently supported are:
+
+[tabs#supported-chat-models]
+======
+Ollama::
++
+====
+
+[source,json]
+----
+{
+  "class": "dev.langchain4j.model.ollama.OllamaChatModel",
+  "name": "<a-name-for-your-model>",
+  "params": {
+    "baseUrl": "http://localhost:11434",
+    "modelName": "<a-local/hosted-chat-model>",
+    "timeout": 300,
+    "logRequests": true,
+    "logResponses": true,
+    "maxRetries": 5
+  }
+}
+----
+====
+
+MistralAI::
++
+====
+[source,json]
+----
+{
+  "class": "dev.langchain4j.model.mistralai.MistralAiChatModel",
+  "name": "<a-name-for-your-model>",
+  "params": {
+    "baseUrl": "https://api.mistral.ai/v1",
+    "apiKey": "<your-mistralAI-api-key>",
+    "modelName": "<a-mistralAI-chat-model>",
+    "timeout": 60,
+    "logRequests": true,
+    "logResponses": true,
+    "maxRetries": 5
+  }
+}
+----
+====
+OpenAI::
++
+====
+[source,json]
+----
+{
+  "class": "dev.langchain4j.model.openai.OpenAiChatModel",
+  "name": "<a-name-for-your-model>",
+  "params": {
+    "baseUrl": "https://api.openai.com/v1",
+    "apiKey": "<your-openAI-api-key>",
+    "modelName": "<a-openAI-chat-model>",
+    "timeout": 60,
+    "logRequests": true,
+    "logResponses": true,
+    "maxRetries": 5
+  }
+}
+----
+====
+
+Anthropic::
++
+====
+[source,json]
+----
+{
+  "class": "dev.langchain4j.model.anthropic.AnthropicChatModel",
+  "name": "<a-name-for-your-model>",
+  "params": {
+    "baseUrl": "https://api.anthropic.com/v1/",
+    "apiKey": "<your-anthropic-api-key>",
+    "modelName": "<a-anthropic-chat-model>",
+    "timeout": 60,
+    "logRequests": true,
+    "logResponses": true,
+    "maxRetries": 5
+  }
+}
+----
+====
+
+Gemini::
++
+====
+[source,json]
+----
+{
+  "class": "dev.langchain4j.model.googleai.GoogleAiGeminiChatModel",
+  "name": "<a-name-for-your-model>",
+  "params": {
+    "baseUrl": "https://generativelanguage.googleapis.com/v1beta/",
+    "apiKey": "<your-geminiAi-api-key>",
+    "modelName": "<a-geminiAi-chat-model>",
+    "timeout": 60,
+    "logRequests": true,
+    "logResponses": true,
+    "maxRetries": 5
+  }
+}
+----
+====
+======
+
+=== Uploading a Model
+
+To upload the model in a `/path/myModel.json` file, please run:
+
+[source,bash]
+----
+curl -XPUT 'http://localhost:8983/solr/YOUR_COLLECTION/schema/chat-model-store' --data-binary "@/path/myModel.json" -H 'Content-type:application/json'
+----
+
+To delete the `currentModel` model:
+
+[source,bash]
+----
+curl -XDELETE 'http://localhost:8983/solr/YOUR_COLLECTION/schema/chat-model-store/currentModel'
+----
+
+To view all models:
+
+[source,text]
+http://localhost:8983/solr/YOUR_COLLECTION/schema/chat-model-store
+
+
+.Example: /path/myOpenAIModel.json
+[source,json]
+----
+{
+  "class": "dev.langchain4j.model.openai.OpenAiChatModel",
+  "name": "openai-1",
+  "params": {
+    "baseUrl": "https://api.openai.com/v1",
+    "apiKey": "apiKey-openAI",
+    "modelName": "gpt-5.4-nano",
+    "timeout": 60,
+    "logRequests": true,
+    "logResponses": true,
+    "maxRetries": 5
+  }
+}
+----
+
+== How to Trigger Document Enrichment during Indexing
+To create new fields starting from existent ones in your documents at indexing time you need to configure an {solr-javadocs}/core/org/apache/solr/update/processor/UpdateRequestProcessorChain.html[Update Request Processor Chain] that includes at least one `DocumentEnrichmentUpdateProcessor` update request processor in one of the 2 following way:
+
+* Update processor with parameter `prompt`
++
+[source,xml]
+----
+<updateRequestProcessorChain name="documentEnrichment">
+  <processor class="solr.languagemodels.documentenrichment.update.processor.DocumentEnrichmentUpdateProcessorFactory">
+   <str name="inputField">string_field</str>
+   <str name="outputField">summary</str>
+   <str name="prompt">Summarize this content: {string_field}</str>
+   <str name="model">model-name</str>
+  </processor>
+  <processor class="solr.RunUpdateProcessorFactory"/>
+ </updateRequestProcessorChain>
+----
+
+* Update processor with parameter `promptFile`: in this case, the file `prompt.txt` must be uploaded to Solr similarly to any other configuration file (e.g., `solrconfig.xml`, `synonyms.txt`, etc.)
++
+[source,xml]
+----
+<updateRequestProcessorChain name="documentEnrichment">
+  <processor class="solr.languagemodels.documentenrichment.update.processor.DocumentEnrichmentUpdateProcessorFactory">
+   <str name="inputField">string_field</str>
+   <str name="outputField">summary</str>
+   <str name="promptFile">prompt.txt</str>
+   <str name="model">model-name</str>
+  </processor>
+  <processor class="solr.RunUpdateProcessorFactory"/>
+ </updateRequestProcessorChain>
+----
+
+Exactly one of the following parameters is required: `prompt` or `promptFile`.
+
+Another important feature of this module is that one (or more) `inputField` needs to be injected in the prompt. This is
+done by some special tokens, that are the `fieldName` surrounded by curly brackets (e.g., `{string_field}`, in the
+example above). These tokens are _mandatory_ for this module to work properly. Solr will throw an error if the
+parameters are not properly defined.
+For example, both the prompt and the content of the file prompt.txt, must contain the text '{string_field}', which
+will be substituted with the content of the `string_field` field for each document. An example of a valid prompt with
+multiple input fields is as follows:
+
+[source,xml]
+----
+<updateRequestProcessorChain name="documentEnrichment">
+  <processor class="solr.languagemodels.documentenrichment.update.processor.DocumentEnrichmentUpdateProcessorFactory">
+   <str name="inputField">title</str>
+   <str name="inputField">body</str>
+   <str name="outputField">summary</str>
+   <str name="prompt">Summarize with the following information. Title: {title}. Body: {body}.</str>
+   <str name="model">chat-model</str>
+  </processor>
+  <processor class="solr.RunUpdateProcessorFactory"/>
+ </updateRequestProcessorChain>
+----
+
+Another way of using more than one `inputField` is by using the following notation, instead of more than one parameter

Review Comment:
   Multiple `inputField` could also be defined by using the following notation:



##########
solr/solr-ref-guide/modules/indexing-guide/pages/document-enrichment-with-llms.adoc:
##########
@@ -0,0 +1,500 @@
+= Document Enrichment with LLMs
+// 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.
+
+This module brings the power of *Large Language Models* to Solr.
+
+More specifically, it provides the capability, at indexing time, given a prompt and a set of input fields, of calling an
+LLM through https://github.com/langchain4j/langchain4j[LangChain4j] for each document and store the result of the call
+in an `outputField`, that can be of multiple types and even multivalued.
+
+_Without_ this module, the LLM calls must be done _outside_ Solr, before indexing.
+
+[IMPORTANT]
+====
+This module sends your documents off to some hosted service on the internet.
+There are cost, privacy, performance, and service availability implications on such a strong dependency that should be
+diligently examined before employing this module in a serious way.
+
+====
+
+At the moment a subset of LLM providers supported by LangChain4j is supported by Solr.
+
+*Disclaimer*: Apache Solr is *in no way* affiliated to any of these corporations or services.
+
+If you want to add support for additional services or improve the support for the existing ones, feel free to
+contribute:
+
+* https://github.com/apache/solr/blob/main/CONTRIBUTING.md[Contributing to Solr]
+
+== Module
+
+This is provided via the `language-models` xref:configuration-guide:solr-modules.adoc[Solr Module] that needs to be
+enabled before use.
+
+== Language Model Configuration
+
+Language Models is a module and therefore its plugins must be configured in `solrconfig.xml`.
+
+=== Minimum Requirements
+
+* Enable the `language-models` module to make the Language Models classes available on Solr's classpath.
+See xref:configuration-guide:solr-modules.adoc[Solr Module] for more details.
+
+* An update processor, similar to the one below, must be declared in `solrconfig.xml`:
++
+[source,xml]
+----
+<updateRequestProcessorChain name="documentEnrichment">
+  <processor class="solr.languagemodels.documentenrichment.update.processor.DocumentEnrichmentUpdateProcessorFactory">
+   <str name="inputField">string_field</str>
+   <str name="outputField">summary</str>
+   <str name="prompt">Summarize this content: {string_field}</str>
+   <str name="model">model-name</str>
+  </processor>
+  <processor class="solr.RunUpdateProcessorFactory"/>
+ </updateRequestProcessorChain>
+----
+[NOTE]
+====
+If no component is configured in `solrconfig.xml`, the `ChatModel` store will not be registered and requests to
+`/schema/chat-model-store` will return an error.
+====
+
+== Chat Model Configuration
+
+=== Models
+
+* A model in this module is a chat model, that answers with text given a prompt.
+* A model in this Solr module is a reference to an external API that runs the Large Language Model responsible for chat
+completion.
+
+[IMPORTANT]
+====
+The Solr chat model specifies the parameters to access the APIs, the LLM doesn't run internally in Solr
+
+====
+
+A model is described by these parameters:
+
+
+`class`::
++
+[%autowidth,frame=none]
+|===
+s|Required |Default: none
+|===
++
+The model implementation.
+Accepted values:
+
+* `dev.langchain4j.model.ollama.OllamaChatModel`
+* `dev.langchain4j.model.mistralai.MistralAiChatModel`
+* `dev.langchain4j.model.anthropic.AnthropicChatModel`
+* `dev.langchain4j.model.openai.OpenAiChatModel`
+* `dev.langchain4j.model.googleai.GoogleAiGeminiChatModel`
+
+`name`::
++
+[%autowidth,frame=none]
+|===
+s|Required |Default: none
+|===
++
+The identifier of your model, this is used by any component that intends to use the model (e.g., `DocumentEnrichmentUpdateProcessorFactory` update processor).
+
+`params`::
++
+[%autowidth,frame=none]
+|===
+|Optional |Default: none
+|===
++
+Each model class has potentially different params.
+Many are shared but for the full set of parameters of the model you are interested in please refer to the official documentation of the LangChain4j version included in Solr: https://docs.langchain4j.dev/category/language-models[Chat Models in LangChain4j].
+
+=== Supported Models
+Apache Solr uses https://github.com/langchain4j/langchain4j[LangChain4j] to support document enrichment with LLMs.
+The models currently supported are:
+
+[tabs#supported-chat-models]
+======
+Ollama::
++
+====
+
+[source,json]
+----
+{
+  "class": "dev.langchain4j.model.ollama.OllamaChatModel",
+  "name": "<a-name-for-your-model>",
+  "params": {
+    "baseUrl": "http://localhost:11434",
+    "modelName": "<a-local/hosted-chat-model>",
+    "timeout": 300,
+    "logRequests": true,
+    "logResponses": true,
+    "maxRetries": 5
+  }
+}
+----
+====
+
+MistralAI::
++
+====
+[source,json]
+----
+{
+  "class": "dev.langchain4j.model.mistralai.MistralAiChatModel",
+  "name": "<a-name-for-your-model>",
+  "params": {
+    "baseUrl": "https://api.mistral.ai/v1",
+    "apiKey": "<your-mistralAI-api-key>",
+    "modelName": "<a-mistralAI-chat-model>",
+    "timeout": 60,
+    "logRequests": true,
+    "logResponses": true,
+    "maxRetries": 5
+  }
+}
+----
+====
+OpenAI::
++
+====
+[source,json]
+----
+{
+  "class": "dev.langchain4j.model.openai.OpenAiChatModel",
+  "name": "<a-name-for-your-model>",
+  "params": {
+    "baseUrl": "https://api.openai.com/v1",
+    "apiKey": "<your-openAI-api-key>",
+    "modelName": "<a-openAI-chat-model>",
+    "timeout": 60,
+    "logRequests": true,
+    "logResponses": true,
+    "maxRetries": 5
+  }
+}
+----
+====
+
+Anthropic::
++
+====
+[source,json]
+----
+{
+  "class": "dev.langchain4j.model.anthropic.AnthropicChatModel",
+  "name": "<a-name-for-your-model>",
+  "params": {
+    "baseUrl": "https://api.anthropic.com/v1/",
+    "apiKey": "<your-anthropic-api-key>",
+    "modelName": "<a-anthropic-chat-model>",
+    "timeout": 60,
+    "logRequests": true,
+    "logResponses": true,
+    "maxRetries": 5
+  }
+}
+----
+====
+
+Gemini::
++
+====
+[source,json]
+----
+{
+  "class": "dev.langchain4j.model.googleai.GoogleAiGeminiChatModel",
+  "name": "<a-name-for-your-model>",
+  "params": {
+    "baseUrl": "https://generativelanguage.googleapis.com/v1beta/",
+    "apiKey": "<your-geminiAi-api-key>",
+    "modelName": "<a-geminiAi-chat-model>",
+    "timeout": 60,
+    "logRequests": true,
+    "logResponses": true,
+    "maxRetries": 5
+  }
+}
+----
+====
+======
+
+=== Uploading a Model
+
+To upload the model in a `/path/myModel.json` file, please run:
+
+[source,bash]
+----
+curl -XPUT 'http://localhost:8983/solr/YOUR_COLLECTION/schema/chat-model-store' --data-binary "@/path/myModel.json" -H 'Content-type:application/json'
+----
+
+To delete the `currentModel` model:
+
+[source,bash]
+----
+curl -XDELETE 'http://localhost:8983/solr/YOUR_COLLECTION/schema/chat-model-store/currentModel'
+----
+
+To view all models:
+
+[source,text]
+http://localhost:8983/solr/YOUR_COLLECTION/schema/chat-model-store
+
+
+.Example: /path/myOpenAIModel.json
+[source,json]
+----
+{
+  "class": "dev.langchain4j.model.openai.OpenAiChatModel",
+  "name": "openai-1",
+  "params": {
+    "baseUrl": "https://api.openai.com/v1",
+    "apiKey": "apiKey-openAI",
+    "modelName": "gpt-5.4-nano",
+    "timeout": 60,
+    "logRequests": true,
+    "logResponses": true,
+    "maxRetries": 5
+  }
+}
+----
+
+== How to Trigger Document Enrichment during Indexing
+To create new fields starting from existent ones in your documents at indexing time you need to configure an {solr-javadocs}/core/org/apache/solr/update/processor/UpdateRequestProcessorChain.html[Update Request Processor Chain] that includes at least one `DocumentEnrichmentUpdateProcessor` update request processor in one of the 2 following way:

Review Comment:
   I would move this part where you talk about configuring the model, since this is an explanation of how to write that part



##########
solr/solr-ref-guide/modules/indexing-guide/pages/document-enrichment-with-llms.adoc:
##########
@@ -0,0 +1,500 @@
+= Document Enrichment with LLMs
+// 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.
+
+This module brings the power of *Large Language Models* to Solr.
+
+More specifically, it provides the capability, at indexing time, given a prompt and a set of input fields, of calling an
+LLM through https://github.com/langchain4j/langchain4j[LangChain4j] for each document and store the result of the call
+in an `outputField`, that can be of multiple types and even multivalued.
+
+_Without_ this module, the LLM calls must be done _outside_ Solr, before indexing.
+
+[IMPORTANT]
+====
+This module sends your documents off to some hosted service on the internet.
+There are cost, privacy, performance, and service availability implications on such a strong dependency that should be
+diligently examined before employing this module in a serious way.
+
+====
+
+At the moment a subset of LLM providers supported by LangChain4j is supported by Solr.
+
+*Disclaimer*: Apache Solr is *in no way* affiliated to any of these corporations or services.
+
+If you want to add support for additional services or improve the support for the existing ones, feel free to
+contribute:
+
+* https://github.com/apache/solr/blob/main/CONTRIBUTING.md[Contributing to Solr]
+
+== Module
+
+This is provided via the `language-models` xref:configuration-guide:solr-modules.adoc[Solr Module] that needs to be
+enabled before use.
+
+== Language Model Configuration
+
+Language Models is a module and therefore its plugins must be configured in `solrconfig.xml`.
+
+=== Minimum Requirements
+
+* Enable the `language-models` module to make the Language Models classes available on Solr's classpath.
+See xref:configuration-guide:solr-modules.adoc[Solr Module] for more details.
+
+* An update processor, similar to the one below, must be declared in `solrconfig.xml`:
++
+[source,xml]
+----
+<updateRequestProcessorChain name="documentEnrichment">
+  <processor class="solr.languagemodels.documentenrichment.update.processor.DocumentEnrichmentUpdateProcessorFactory">
+   <str name="inputField">string_field</str>
+   <str name="outputField">summary</str>
+   <str name="prompt">Summarize this content: {string_field}</str>
+   <str name="model">model-name</str>
+  </processor>
+  <processor class="solr.RunUpdateProcessorFactory"/>
+ </updateRequestProcessorChain>
+----
+[NOTE]
+====
+If no component is configured in `solrconfig.xml`, the `ChatModel` store will not be registered and requests to
+`/schema/chat-model-store` will return an error.
+====
+
+== Chat Model Configuration
+
+=== Models
+
+* A model in this module is a chat model, that answers with text given a prompt.
+* A model in this Solr module is a reference to an external API that runs the Large Language Model responsible for chat
+completion.
+
+[IMPORTANT]
+====
+The Solr chat model specifies the parameters to access the APIs, the LLM doesn't run internally in Solr
+
+====
+
+A model is described by these parameters:
+
+
+`class`::
++
+[%autowidth,frame=none]
+|===
+s|Required |Default: none
+|===
++
+The model implementation.
+Accepted values:
+
+* `dev.langchain4j.model.ollama.OllamaChatModel`
+* `dev.langchain4j.model.mistralai.MistralAiChatModel`
+* `dev.langchain4j.model.anthropic.AnthropicChatModel`
+* `dev.langchain4j.model.openai.OpenAiChatModel`
+* `dev.langchain4j.model.googleai.GoogleAiGeminiChatModel`
+
+`name`::
++
+[%autowidth,frame=none]
+|===
+s|Required |Default: none
+|===
++
+The identifier of your model, this is used by any component that intends to use the model (e.g., `DocumentEnrichmentUpdateProcessorFactory` update processor).
+
+`params`::
++
+[%autowidth,frame=none]
+|===
+|Optional |Default: none
+|===
++
+Each model class has potentially different params.
+Many are shared but for the full set of parameters of the model you are interested in please refer to the official documentation of the LangChain4j version included in Solr: https://docs.langchain4j.dev/category/language-models[Chat Models in LangChain4j].
+
+=== Supported Models
+Apache Solr uses https://github.com/langchain4j/langchain4j[LangChain4j] to support document enrichment with LLMs.
+The models currently supported are:
+
+[tabs#supported-chat-models]
+======
+Ollama::
++
+====
+
+[source,json]
+----
+{
+  "class": "dev.langchain4j.model.ollama.OllamaChatModel",
+  "name": "<a-name-for-your-model>",
+  "params": {
+    "baseUrl": "http://localhost:11434",
+    "modelName": "<a-local/hosted-chat-model>",
+    "timeout": 300,
+    "logRequests": true,
+    "logResponses": true,
+    "maxRetries": 5
+  }
+}
+----
+====
+
+MistralAI::
++
+====
+[source,json]
+----
+{
+  "class": "dev.langchain4j.model.mistralai.MistralAiChatModel",
+  "name": "<a-name-for-your-model>",
+  "params": {
+    "baseUrl": "https://api.mistral.ai/v1",
+    "apiKey": "<your-mistralAI-api-key>",
+    "modelName": "<a-mistralAI-chat-model>",
+    "timeout": 60,
+    "logRequests": true,
+    "logResponses": true,
+    "maxRetries": 5
+  }
+}
+----
+====
+OpenAI::
++
+====
+[source,json]
+----
+{
+  "class": "dev.langchain4j.model.openai.OpenAiChatModel",
+  "name": "<a-name-for-your-model>",
+  "params": {
+    "baseUrl": "https://api.openai.com/v1",
+    "apiKey": "<your-openAI-api-key>",
+    "modelName": "<a-openAI-chat-model>",
+    "timeout": 60,
+    "logRequests": true,
+    "logResponses": true,
+    "maxRetries": 5
+  }
+}
+----
+====
+
+Anthropic::
++
+====
+[source,json]
+----
+{
+  "class": "dev.langchain4j.model.anthropic.AnthropicChatModel",
+  "name": "<a-name-for-your-model>",
+  "params": {
+    "baseUrl": "https://api.anthropic.com/v1/",
+    "apiKey": "<your-anthropic-api-key>",
+    "modelName": "<a-anthropic-chat-model>",
+    "timeout": 60,
+    "logRequests": true,
+    "logResponses": true,
+    "maxRetries": 5
+  }
+}
+----
+====
+
+Gemini::
++
+====
+[source,json]
+----
+{
+  "class": "dev.langchain4j.model.googleai.GoogleAiGeminiChatModel",
+  "name": "<a-name-for-your-model>",
+  "params": {
+    "baseUrl": "https://generativelanguage.googleapis.com/v1beta/",
+    "apiKey": "<your-geminiAi-api-key>",
+    "modelName": "<a-geminiAi-chat-model>",
+    "timeout": 60,
+    "logRequests": true,
+    "logResponses": true,
+    "maxRetries": 5
+  }
+}
+----
+====
+======
+
+=== Uploading a Model
+
+To upload the model in a `/path/myModel.json` file, please run:
+
+[source,bash]
+----
+curl -XPUT 'http://localhost:8983/solr/YOUR_COLLECTION/schema/chat-model-store' --data-binary "@/path/myModel.json" -H 'Content-type:application/json'
+----
+
+To delete the `currentModel` model:
+
+[source,bash]
+----
+curl -XDELETE 'http://localhost:8983/solr/YOUR_COLLECTION/schema/chat-model-store/currentModel'
+----
+
+To view all models:
+
+[source,text]
+http://localhost:8983/solr/YOUR_COLLECTION/schema/chat-model-store
+
+
+.Example: /path/myOpenAIModel.json
+[source,json]
+----
+{
+  "class": "dev.langchain4j.model.openai.OpenAiChatModel",
+  "name": "openai-1",
+  "params": {
+    "baseUrl": "https://api.openai.com/v1",
+    "apiKey": "apiKey-openAI",
+    "modelName": "gpt-5.4-nano",
+    "timeout": 60,
+    "logRequests": true,
+    "logResponses": true,
+    "maxRetries": 5
+  }
+}
+----
+
+== How to Trigger Document Enrichment during Indexing
+To create new fields starting from existent ones in your documents at indexing time you need to configure an {solr-javadocs}/core/org/apache/solr/update/processor/UpdateRequestProcessorChain.html[Update Request Processor Chain] that includes at least one `DocumentEnrichmentUpdateProcessor` update request processor in one of the 2 following way:
+
+* Update processor with parameter `prompt`
++
+[source,xml]
+----
+<updateRequestProcessorChain name="documentEnrichment">
+  <processor class="solr.languagemodels.documentenrichment.update.processor.DocumentEnrichmentUpdateProcessorFactory">
+   <str name="inputField">string_field</str>
+   <str name="outputField">summary</str>
+   <str name="prompt">Summarize this content: {string_field}</str>
+   <str name="model">model-name</str>
+  </processor>
+  <processor class="solr.RunUpdateProcessorFactory"/>
+ </updateRequestProcessorChain>
+----
+
+* Update processor with parameter `promptFile`: in this case, the file `prompt.txt` must be uploaded to Solr similarly to any other configuration file (e.g., `solrconfig.xml`, `synonyms.txt`, etc.)

Review Comment:
   Do you mean that the file needs to be put inside the config folder of the collection?



##########
solr/solr-ref-guide/modules/indexing-guide/pages/document-enrichment-with-llms.adoc:
##########
@@ -0,0 +1,500 @@
+= Document Enrichment with LLMs
+// 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.
+
+This module brings the power of *Large Language Models* to Solr.
+
+More specifically, it provides the capability, at indexing time, given a prompt and a set of input fields, of calling an
+LLM through https://github.com/langchain4j/langchain4j[LangChain4j] for each document and store the result of the call
+in an `outputField`, that can be of multiple types and even multivalued.
+
+_Without_ this module, the LLM calls must be done _outside_ Solr, before indexing.
+
+[IMPORTANT]
+====
+This module sends your documents off to some hosted service on the internet.
+There are cost, privacy, performance, and service availability implications on such a strong dependency that should be
+diligently examined before employing this module in a serious way.
+
+====
+
+At the moment a subset of LLM providers supported by LangChain4j is supported by Solr.
+
+*Disclaimer*: Apache Solr is *in no way* affiliated to any of these corporations or services.
+
+If you want to add support for additional services or improve the support for the existing ones, feel free to
+contribute:
+
+* https://github.com/apache/solr/blob/main/CONTRIBUTING.md[Contributing to Solr]
+
+== Module
+
+This is provided via the `language-models` xref:configuration-guide:solr-modules.adoc[Solr Module] that needs to be
+enabled before use.
+
+== Language Model Configuration
+
+Language Models is a module and therefore its plugins must be configured in `solrconfig.xml`.
+
+=== Minimum Requirements
+
+* Enable the `language-models` module to make the Language Models classes available on Solr's classpath.
+See xref:configuration-guide:solr-modules.adoc[Solr Module] for more details.
+
+* An update processor, similar to the one below, must be declared in `solrconfig.xml`:
++
+[source,xml]
+----
+<updateRequestProcessorChain name="documentEnrichment">
+  <processor class="solr.languagemodels.documentenrichment.update.processor.DocumentEnrichmentUpdateProcessorFactory">
+   <str name="inputField">string_field</str>
+   <str name="outputField">summary</str>
+   <str name="prompt">Summarize this content: {string_field}</str>
+   <str name="model">model-name</str>
+  </processor>
+  <processor class="solr.RunUpdateProcessorFactory"/>
+ </updateRequestProcessorChain>
+----
+[NOTE]
+====
+If no component is configured in `solrconfig.xml`, the `ChatModel` store will not be registered and requests to
+`/schema/chat-model-store` will return an error.
+====
+
+== Chat Model Configuration
+
+=== Models
+
+* A model in this module is a chat model, that answers with text given a prompt.
+* A model in this Solr module is a reference to an external API that runs the Large Language Model responsible for chat
+completion.
+
+[IMPORTANT]
+====
+The Solr chat model specifies the parameters to access the APIs, the LLM doesn't run internally in Solr
+
+====
+
+A model is described by these parameters:
+
+
+`class`::
++
+[%autowidth,frame=none]
+|===
+s|Required |Default: none
+|===
++
+The model implementation.
+Accepted values:
+
+* `dev.langchain4j.model.ollama.OllamaChatModel`
+* `dev.langchain4j.model.mistralai.MistralAiChatModel`
+* `dev.langchain4j.model.anthropic.AnthropicChatModel`
+* `dev.langchain4j.model.openai.OpenAiChatModel`
+* `dev.langchain4j.model.googleai.GoogleAiGeminiChatModel`
+
+`name`::
++
+[%autowidth,frame=none]
+|===
+s|Required |Default: none
+|===
++
+The identifier of your model, this is used by any component that intends to use the model (e.g., `DocumentEnrichmentUpdateProcessorFactory` update processor).
+
+`params`::
++
+[%autowidth,frame=none]
+|===
+|Optional |Default: none
+|===
++
+Each model class has potentially different params.
+Many are shared but for the full set of parameters of the model you are interested in please refer to the official documentation of the LangChain4j version included in Solr: https://docs.langchain4j.dev/category/language-models[Chat Models in LangChain4j].
+
+=== Supported Models
+Apache Solr uses https://github.com/langchain4j/langchain4j[LangChain4j] to support document enrichment with LLMs.
+The models currently supported are:
+
+[tabs#supported-chat-models]
+======
+Ollama::
++
+====
+
+[source,json]
+----
+{
+  "class": "dev.langchain4j.model.ollama.OllamaChatModel",
+  "name": "<a-name-for-your-model>",
+  "params": {
+    "baseUrl": "http://localhost:11434",
+    "modelName": "<a-local/hosted-chat-model>",
+    "timeout": 300,
+    "logRequests": true,
+    "logResponses": true,
+    "maxRetries": 5
+  }
+}
+----
+====
+
+MistralAI::
++
+====
+[source,json]
+----
+{
+  "class": "dev.langchain4j.model.mistralai.MistralAiChatModel",
+  "name": "<a-name-for-your-model>",
+  "params": {
+    "baseUrl": "https://api.mistral.ai/v1",
+    "apiKey": "<your-mistralAI-api-key>",
+    "modelName": "<a-mistralAI-chat-model>",
+    "timeout": 60,
+    "logRequests": true,
+    "logResponses": true,
+    "maxRetries": 5
+  }
+}
+----
+====
+OpenAI::
++
+====
+[source,json]
+----
+{
+  "class": "dev.langchain4j.model.openai.OpenAiChatModel",
+  "name": "<a-name-for-your-model>",
+  "params": {
+    "baseUrl": "https://api.openai.com/v1",
+    "apiKey": "<your-openAI-api-key>",
+    "modelName": "<a-openAI-chat-model>",
+    "timeout": 60,
+    "logRequests": true,
+    "logResponses": true,
+    "maxRetries": 5
+  }
+}
+----
+====
+
+Anthropic::
++
+====
+[source,json]
+----
+{
+  "class": "dev.langchain4j.model.anthropic.AnthropicChatModel",
+  "name": "<a-name-for-your-model>",
+  "params": {
+    "baseUrl": "https://api.anthropic.com/v1/",
+    "apiKey": "<your-anthropic-api-key>",
+    "modelName": "<a-anthropic-chat-model>",
+    "timeout": 60,
+    "logRequests": true,
+    "logResponses": true,
+    "maxRetries": 5
+  }
+}
+----
+====
+
+Gemini::
++
+====
+[source,json]
+----
+{
+  "class": "dev.langchain4j.model.googleai.GoogleAiGeminiChatModel",
+  "name": "<a-name-for-your-model>",
+  "params": {
+    "baseUrl": "https://generativelanguage.googleapis.com/v1beta/",
+    "apiKey": "<your-geminiAi-api-key>",
+    "modelName": "<a-geminiAi-chat-model>",
+    "timeout": 60,
+    "logRequests": true,
+    "logResponses": true,
+    "maxRetries": 5
+  }
+}
+----
+====
+======
+
+=== Uploading a Model
+
+To upload the model in a `/path/myModel.json` file, please run:
+
+[source,bash]
+----
+curl -XPUT 'http://localhost:8983/solr/YOUR_COLLECTION/schema/chat-model-store' --data-binary "@/path/myModel.json" -H 'Content-type:application/json'
+----
+
+To delete the `currentModel` model:
+
+[source,bash]
+----
+curl -XDELETE 'http://localhost:8983/solr/YOUR_COLLECTION/schema/chat-model-store/currentModel'
+----
+
+To view all models:
+
+[source,text]
+http://localhost:8983/solr/YOUR_COLLECTION/schema/chat-model-store
+
+
+.Example: /path/myOpenAIModel.json
+[source,json]
+----
+{
+  "class": "dev.langchain4j.model.openai.OpenAiChatModel",
+  "name": "openai-1",
+  "params": {
+    "baseUrl": "https://api.openai.com/v1",
+    "apiKey": "apiKey-openAI",
+    "modelName": "gpt-5.4-nano",
+    "timeout": 60,
+    "logRequests": true,
+    "logResponses": true,
+    "maxRetries": 5
+  }
+}
+----
+
+== How to Trigger Document Enrichment during Indexing
+To create new fields starting from existent ones in your documents at indexing time you need to configure an {solr-javadocs}/core/org/apache/solr/update/processor/UpdateRequestProcessorChain.html[Update Request Processor Chain] that includes at least one `DocumentEnrichmentUpdateProcessor` update request processor in one of the 2 following way:
+
+* Update processor with parameter `prompt`
++
+[source,xml]
+----
+<updateRequestProcessorChain name="documentEnrichment">
+  <processor class="solr.languagemodels.documentenrichment.update.processor.DocumentEnrichmentUpdateProcessorFactory">
+   <str name="inputField">string_field</str>
+   <str name="outputField">summary</str>
+   <str name="prompt">Summarize this content: {string_field}</str>
+   <str name="model">model-name</str>
+  </processor>
+  <processor class="solr.RunUpdateProcessorFactory"/>
+ </updateRequestProcessorChain>
+----
+
+* Update processor with parameter `promptFile`: in this case, the file `prompt.txt` must be uploaded to Solr similarly to any other configuration file (e.g., `solrconfig.xml`, `synonyms.txt`, etc.)
++
+[source,xml]
+----
+<updateRequestProcessorChain name="documentEnrichment">
+  <processor class="solr.languagemodels.documentenrichment.update.processor.DocumentEnrichmentUpdateProcessorFactory">
+   <str name="inputField">string_field</str>
+   <str name="outputField">summary</str>
+   <str name="promptFile">prompt.txt</str>
+   <str name="model">model-name</str>
+  </processor>
+  <processor class="solr.RunUpdateProcessorFactory"/>
+ </updateRequestProcessorChain>
+----
+
+Exactly one of the following parameters is required: `prompt` or `promptFile`.
+
+Another important feature of this module is that one (or more) `inputField` needs to be injected in the prompt. This is
+done by some special tokens, that are the `fieldName` surrounded by curly brackets (e.g., `{string_field}`, in the
+example above). These tokens are _mandatory_ for this module to work properly. Solr will throw an error if the
+parameters are not properly defined.
+For example, both the prompt and the content of the file prompt.txt, must contain the text '{string_field}', which
+will be substituted with the content of the `string_field` field for each document. An example of a valid prompt with

Review Comment:
   I think the part so far could be explained in a more schematic and better understandable way.



##########
solr/solr-ref-guide/modules/indexing-guide/pages/document-enrichment-with-llms.adoc:
##########
@@ -0,0 +1,500 @@
+= Document Enrichment with LLMs
+// 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.
+
+This module brings the power of *Large Language Models* to Solr.
+
+More specifically, it provides the capability, at indexing time, given a prompt and a set of input fields, of calling an
+LLM through https://github.com/langchain4j/langchain4j[LangChain4j] for each document and store the result of the call
+in an `outputField`, that can be of multiple types and even multivalued.
+
+_Without_ this module, the LLM calls must be done _outside_ Solr, before indexing.
+
+[IMPORTANT]
+====
+This module sends your documents off to some hosted service on the internet.
+There are cost, privacy, performance, and service availability implications on such a strong dependency that should be
+diligently examined before employing this module in a serious way.
+
+====
+
+At the moment a subset of LLM providers supported by LangChain4j is supported by Solr.
+
+*Disclaimer*: Apache Solr is *in no way* affiliated to any of these corporations or services.
+
+If you want to add support for additional services or improve the support for the existing ones, feel free to
+contribute:
+
+* https://github.com/apache/solr/blob/main/CONTRIBUTING.md[Contributing to Solr]
+
+== Module
+
+This is provided via the `language-models` xref:configuration-guide:solr-modules.adoc[Solr Module] that needs to be
+enabled before use.
+
+== Language Model Configuration
+
+Language Models is a module and therefore its plugins must be configured in `solrconfig.xml`.
+
+=== Minimum Requirements
+
+* Enable the `language-models` module to make the Language Models classes available on Solr's classpath.
+See xref:configuration-guide:solr-modules.adoc[Solr Module] for more details.
+
+* An update processor, similar to the one below, must be declared in `solrconfig.xml`:
++
+[source,xml]
+----
+<updateRequestProcessorChain name="documentEnrichment">
+  <processor class="solr.languagemodels.documentenrichment.update.processor.DocumentEnrichmentUpdateProcessorFactory">
+   <str name="inputField">string_field</str>
+   <str name="outputField">summary</str>
+   <str name="prompt">Summarize this content: {string_field}</str>
+   <str name="model">model-name</str>
+  </processor>
+  <processor class="solr.RunUpdateProcessorFactory"/>
+ </updateRequestProcessorChain>
+----
+[NOTE]
+====
+If no component is configured in `solrconfig.xml`, the `ChatModel` store will not be registered and requests to
+`/schema/chat-model-store` will return an error.
+====
+
+== Chat Model Configuration
+
+=== Models
+
+* A model in this module is a chat model, that answers with text given a prompt.
+* A model in this Solr module is a reference to an external API that runs the Large Language Model responsible for chat

Review Comment:
   A model is a reference to an external API that runs the Large Language Model responsible for chat completion.



##########
solr/solr-ref-guide/modules/indexing-guide/pages/document-enrichment-with-llms.adoc:
##########
@@ -0,0 +1,500 @@
+= Document Enrichment with LLMs
+// 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.
+
+This module brings the power of *Large Language Models* to Solr.
+
+More specifically, it provides the capability, at indexing time, given a prompt and a set of input fields, of calling an
+LLM through https://github.com/langchain4j/langchain4j[LangChain4j] for each document and store the result of the call
+in an `outputField`, that can be of multiple types and even multivalued.
+
+_Without_ this module, the LLM calls must be done _outside_ Solr, before indexing.
+
+[IMPORTANT]
+====
+This module sends your documents off to some hosted service on the internet.
+There are cost, privacy, performance, and service availability implications on such a strong dependency that should be
+diligently examined before employing this module in a serious way.
+
+====
+
+At the moment a subset of LLM providers supported by LangChain4j is supported by Solr.
+
+*Disclaimer*: Apache Solr is *in no way* affiliated to any of these corporations or services.
+
+If you want to add support for additional services or improve the support for the existing ones, feel free to
+contribute:
+
+* https://github.com/apache/solr/blob/main/CONTRIBUTING.md[Contributing to Solr]
+
+== Module
+
+This is provided via the `language-models` xref:configuration-guide:solr-modules.adoc[Solr Module] that needs to be
+enabled before use.
+
+== Language Model Configuration
+
+Language Models is a module and therefore its plugins must be configured in `solrconfig.xml`.
+
+=== Minimum Requirements
+
+* Enable the `language-models` module to make the Language Models classes available on Solr's classpath.
+See xref:configuration-guide:solr-modules.adoc[Solr Module] for more details.
+
+* An update processor, similar to the one below, must be declared in `solrconfig.xml`:
++
+[source,xml]
+----
+<updateRequestProcessorChain name="documentEnrichment">
+  <processor class="solr.languagemodels.documentenrichment.update.processor.DocumentEnrichmentUpdateProcessorFactory">
+   <str name="inputField">string_field</str>
+   <str name="outputField">summary</str>
+   <str name="prompt">Summarize this content: {string_field}</str>
+   <str name="model">model-name</str>
+  </processor>
+  <processor class="solr.RunUpdateProcessorFactory"/>
+ </updateRequestProcessorChain>
+----
+[NOTE]
+====
+If no component is configured in `solrconfig.xml`, the `ChatModel` store will not be registered and requests to
+`/schema/chat-model-store` will return an error.
+====
+
+== Chat Model Configuration
+
+=== Models
+
+* A model in this module is a chat model, that answers with text given a prompt.
+* A model in this Solr module is a reference to an external API that runs the Large Language Model responsible for chat
+completion.
+
+[IMPORTANT]
+====
+The Solr chat model specifies the parameters to access the APIs, the LLM doesn't run internally in Solr
+
+====
+
+A model is described by these parameters:
+
+
+`class`::
++
+[%autowidth,frame=none]
+|===
+s|Required |Default: none
+|===
++
+The model implementation.
+Accepted values:
+
+* `dev.langchain4j.model.ollama.OllamaChatModel`
+* `dev.langchain4j.model.mistralai.MistralAiChatModel`
+* `dev.langchain4j.model.anthropic.AnthropicChatModel`
+* `dev.langchain4j.model.openai.OpenAiChatModel`
+* `dev.langchain4j.model.googleai.GoogleAiGeminiChatModel`
+
+`name`::
++
+[%autowidth,frame=none]
+|===
+s|Required |Default: none
+|===
++
+The identifier of your model, this is used by any component that intends to use the model (e.g., `DocumentEnrichmentUpdateProcessorFactory` update processor).
+
+`params`::
++
+[%autowidth,frame=none]
+|===
+|Optional |Default: none
+|===
++
+Each model class has potentially different params.
+Many are shared but for the full set of parameters of the model you are interested in please refer to the official documentation of the LangChain4j version included in Solr: https://docs.langchain4j.dev/category/language-models[Chat Models in LangChain4j].
+
+=== Supported Models
+Apache Solr uses https://github.com/langchain4j/langchain4j[LangChain4j] to support document enrichment with LLMs.
+The models currently supported are:
+
+[tabs#supported-chat-models]
+======
+Ollama::
++
+====
+
+[source,json]
+----
+{
+  "class": "dev.langchain4j.model.ollama.OllamaChatModel",
+  "name": "<a-name-for-your-model>",
+  "params": {
+    "baseUrl": "http://localhost:11434",
+    "modelName": "<a-local/hosted-chat-model>",
+    "timeout": 300,
+    "logRequests": true,
+    "logResponses": true,
+    "maxRetries": 5
+  }
+}
+----
+====
+
+MistralAI::
++
+====
+[source,json]
+----
+{
+  "class": "dev.langchain4j.model.mistralai.MistralAiChatModel",
+  "name": "<a-name-for-your-model>",
+  "params": {
+    "baseUrl": "https://api.mistral.ai/v1",
+    "apiKey": "<your-mistralAI-api-key>",
+    "modelName": "<a-mistralAI-chat-model>",
+    "timeout": 60,
+    "logRequests": true,
+    "logResponses": true,
+    "maxRetries": 5
+  }
+}
+----
+====
+OpenAI::
++
+====
+[source,json]
+----
+{
+  "class": "dev.langchain4j.model.openai.OpenAiChatModel",
+  "name": "<a-name-for-your-model>",
+  "params": {
+    "baseUrl": "https://api.openai.com/v1",
+    "apiKey": "<your-openAI-api-key>",
+    "modelName": "<a-openAI-chat-model>",
+    "timeout": 60,
+    "logRequests": true,
+    "logResponses": true,
+    "maxRetries": 5
+  }
+}
+----
+====
+
+Anthropic::
++
+====
+[source,json]
+----
+{
+  "class": "dev.langchain4j.model.anthropic.AnthropicChatModel",
+  "name": "<a-name-for-your-model>",
+  "params": {
+    "baseUrl": "https://api.anthropic.com/v1/",
+    "apiKey": "<your-anthropic-api-key>",
+    "modelName": "<a-anthropic-chat-model>",
+    "timeout": 60,
+    "logRequests": true,
+    "logResponses": true,
+    "maxRetries": 5
+  }
+}
+----
+====
+
+Gemini::
++
+====
+[source,json]
+----
+{
+  "class": "dev.langchain4j.model.googleai.GoogleAiGeminiChatModel",
+  "name": "<a-name-for-your-model>",
+  "params": {
+    "baseUrl": "https://generativelanguage.googleapis.com/v1beta/",
+    "apiKey": "<your-geminiAi-api-key>",
+    "modelName": "<a-geminiAi-chat-model>",
+    "timeout": 60,
+    "logRequests": true,
+    "logResponses": true,
+    "maxRetries": 5
+  }
+}
+----
+====
+======
+
+=== Uploading a Model
+
+To upload the model in a `/path/myModel.json` file, please run:
+
+[source,bash]
+----
+curl -XPUT 'http://localhost:8983/solr/YOUR_COLLECTION/schema/chat-model-store' --data-binary "@/path/myModel.json" -H 'Content-type:application/json'
+----
+
+To delete the `currentModel` model:
+
+[source,bash]
+----
+curl -XDELETE 'http://localhost:8983/solr/YOUR_COLLECTION/schema/chat-model-store/currentModel'
+----
+
+To view all models:
+
+[source,text]
+http://localhost:8983/solr/YOUR_COLLECTION/schema/chat-model-store
+
+
+.Example: /path/myOpenAIModel.json
+[source,json]
+----
+{
+  "class": "dev.langchain4j.model.openai.OpenAiChatModel",
+  "name": "openai-1",
+  "params": {
+    "baseUrl": "https://api.openai.com/v1",
+    "apiKey": "apiKey-openAI",
+    "modelName": "gpt-5.4-nano",
+    "timeout": 60,
+    "logRequests": true,
+    "logResponses": true,
+    "maxRetries": 5
+  }
+}
+----
+
+== How to Trigger Document Enrichment during Indexing
+To create new fields starting from existent ones in your documents at indexing time you need to configure an {solr-javadocs}/core/org/apache/solr/update/processor/UpdateRequestProcessorChain.html[Update Request Processor Chain] that includes at least one `DocumentEnrichmentUpdateProcessor` update request processor in one of the 2 following way:
+
+* Update processor with parameter `prompt`
++
+[source,xml]
+----
+<updateRequestProcessorChain name="documentEnrichment">
+  <processor class="solr.languagemodels.documentenrichment.update.processor.DocumentEnrichmentUpdateProcessorFactory">
+   <str name="inputField">string_field</str>
+   <str name="outputField">summary</str>
+   <str name="prompt">Summarize this content: {string_field}</str>
+   <str name="model">model-name</str>
+  </processor>
+  <processor class="solr.RunUpdateProcessorFactory"/>
+ </updateRequestProcessorChain>
+----
+
+* Update processor with parameter `promptFile`: in this case, the file `prompt.txt` must be uploaded to Solr similarly to any other configuration file (e.g., `solrconfig.xml`, `synonyms.txt`, etc.)

Review Comment:
   Update processor definition with the `promptFile` parameter



##########
solr/solr-ref-guide/modules/indexing-guide/pages/document-enrichment-with-llms.adoc:
##########
@@ -0,0 +1,500 @@
+= Document Enrichment with LLMs
+// 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.
+
+This module brings the power of *Large Language Models* to Solr.
+
+More specifically, it provides the capability, at indexing time, given a prompt and a set of input fields, of calling an
+LLM through https://github.com/langchain4j/langchain4j[LangChain4j] for each document and store the result of the call
+in an `outputField`, that can be of multiple types and even multivalued.
+
+_Without_ this module, the LLM calls must be done _outside_ Solr, before indexing.
+
+[IMPORTANT]
+====
+This module sends your documents off to some hosted service on the internet.
+There are cost, privacy, performance, and service availability implications on such a strong dependency that should be
+diligently examined before employing this module in a serious way.
+
+====
+
+At the moment a subset of LLM providers supported by LangChain4j is supported by Solr.
+
+*Disclaimer*: Apache Solr is *in no way* affiliated to any of these corporations or services.
+
+If you want to add support for additional services or improve the support for the existing ones, feel free to
+contribute:
+
+* https://github.com/apache/solr/blob/main/CONTRIBUTING.md[Contributing to Solr]
+
+== Module
+
+This is provided via the `language-models` xref:configuration-guide:solr-modules.adoc[Solr Module] that needs to be
+enabled before use.
+
+== Language Model Configuration
+
+Language Models is a module and therefore its plugins must be configured in `solrconfig.xml`.
+
+=== Minimum Requirements
+
+* Enable the `language-models` module to make the Language Models classes available on Solr's classpath.
+See xref:configuration-guide:solr-modules.adoc[Solr Module] for more details.
+
+* An update processor, similar to the one below, must be declared in `solrconfig.xml`:
++
+[source,xml]
+----
+<updateRequestProcessorChain name="documentEnrichment">
+  <processor class="solr.languagemodels.documentenrichment.update.processor.DocumentEnrichmentUpdateProcessorFactory">
+   <str name="inputField">string_field</str>
+   <str name="outputField">summary</str>
+   <str name="prompt">Summarize this content: {string_field}</str>
+   <str name="model">model-name</str>
+  </processor>
+  <processor class="solr.RunUpdateProcessorFactory"/>
+ </updateRequestProcessorChain>
+----
+[NOTE]
+====
+If no component is configured in `solrconfig.xml`, the `ChatModel` store will not be registered and requests to
+`/schema/chat-model-store` will return an error.
+====
+
+== Chat Model Configuration
+
+=== Models
+
+* A model in this module is a chat model, that answers with text given a prompt.
+* A model in this Solr module is a reference to an external API that runs the Large Language Model responsible for chat
+completion.
+
+[IMPORTANT]
+====
+The Solr chat model specifies the parameters to access the APIs, the LLM doesn't run internally in Solr
+
+====
+
+A model is described by these parameters:
+
+
+`class`::
++
+[%autowidth,frame=none]
+|===
+s|Required |Default: none
+|===
++
+The model implementation.
+Accepted values:
+
+* `dev.langchain4j.model.ollama.OllamaChatModel`
+* `dev.langchain4j.model.mistralai.MistralAiChatModel`
+* `dev.langchain4j.model.anthropic.AnthropicChatModel`
+* `dev.langchain4j.model.openai.OpenAiChatModel`
+* `dev.langchain4j.model.googleai.GoogleAiGeminiChatModel`
+
+`name`::
++
+[%autowidth,frame=none]
+|===
+s|Required |Default: none
+|===
++
+The identifier of your model, this is used by any component that intends to use the model (e.g., `DocumentEnrichmentUpdateProcessorFactory` update processor).
+
+`params`::
++
+[%autowidth,frame=none]
+|===
+|Optional |Default: none
+|===
++
+Each model class has potentially different params.
+Many are shared but for the full set of parameters of the model you are interested in please refer to the official documentation of the LangChain4j version included in Solr: https://docs.langchain4j.dev/category/language-models[Chat Models in LangChain4j].
+
+=== Supported Models
+Apache Solr uses https://github.com/langchain4j/langchain4j[LangChain4j] to support document enrichment with LLMs.
+The models currently supported are:
+
+[tabs#supported-chat-models]
+======
+Ollama::
++
+====
+
+[source,json]
+----
+{
+  "class": "dev.langchain4j.model.ollama.OllamaChatModel",
+  "name": "<a-name-for-your-model>",
+  "params": {
+    "baseUrl": "http://localhost:11434",
+    "modelName": "<a-local/hosted-chat-model>",
+    "timeout": 300,
+    "logRequests": true,
+    "logResponses": true,
+    "maxRetries": 5
+  }
+}
+----
+====
+
+MistralAI::
++
+====
+[source,json]
+----
+{
+  "class": "dev.langchain4j.model.mistralai.MistralAiChatModel",
+  "name": "<a-name-for-your-model>",
+  "params": {
+    "baseUrl": "https://api.mistral.ai/v1",
+    "apiKey": "<your-mistralAI-api-key>",
+    "modelName": "<a-mistralAI-chat-model>",
+    "timeout": 60,
+    "logRequests": true,
+    "logResponses": true,
+    "maxRetries": 5
+  }
+}
+----
+====
+OpenAI::
++
+====
+[source,json]
+----
+{
+  "class": "dev.langchain4j.model.openai.OpenAiChatModel",
+  "name": "<a-name-for-your-model>",
+  "params": {
+    "baseUrl": "https://api.openai.com/v1",
+    "apiKey": "<your-openAI-api-key>",
+    "modelName": "<a-openAI-chat-model>",
+    "timeout": 60,
+    "logRequests": true,
+    "logResponses": true,
+    "maxRetries": 5
+  }
+}
+----
+====
+
+Anthropic::
++
+====
+[source,json]
+----
+{
+  "class": "dev.langchain4j.model.anthropic.AnthropicChatModel",
+  "name": "<a-name-for-your-model>",
+  "params": {
+    "baseUrl": "https://api.anthropic.com/v1/",
+    "apiKey": "<your-anthropic-api-key>",
+    "modelName": "<a-anthropic-chat-model>",
+    "timeout": 60,
+    "logRequests": true,
+    "logResponses": true,
+    "maxRetries": 5
+  }
+}
+----
+====
+
+Gemini::
++
+====
+[source,json]
+----
+{
+  "class": "dev.langchain4j.model.googleai.GoogleAiGeminiChatModel",
+  "name": "<a-name-for-your-model>",
+  "params": {
+    "baseUrl": "https://generativelanguage.googleapis.com/v1beta/",
+    "apiKey": "<your-geminiAi-api-key>",
+    "modelName": "<a-geminiAi-chat-model>",
+    "timeout": 60,
+    "logRequests": true,
+    "logResponses": true,
+    "maxRetries": 5
+  }
+}
+----
+====
+======
+
+=== Uploading a Model
+
+To upload the model in a `/path/myModel.json` file, please run:
+
+[source,bash]
+----
+curl -XPUT 'http://localhost:8983/solr/YOUR_COLLECTION/schema/chat-model-store' --data-binary "@/path/myModel.json" -H 'Content-type:application/json'
+----
+
+To delete the `currentModel` model:
+
+[source,bash]
+----
+curl -XDELETE 'http://localhost:8983/solr/YOUR_COLLECTION/schema/chat-model-store/currentModel'
+----
+
+To view all models:
+
+[source,text]
+http://localhost:8983/solr/YOUR_COLLECTION/schema/chat-model-store
+
+
+.Example: /path/myOpenAIModel.json
+[source,json]
+----
+{
+  "class": "dev.langchain4j.model.openai.OpenAiChatModel",
+  "name": "openai-1",
+  "params": {
+    "baseUrl": "https://api.openai.com/v1",
+    "apiKey": "apiKey-openAI",
+    "modelName": "gpt-5.4-nano",
+    "timeout": 60,
+    "logRequests": true,
+    "logResponses": true,
+    "maxRetries": 5
+  }
+}
+----
+
+== How to Trigger Document Enrichment during Indexing
+To create new fields starting from existent ones in your documents at indexing time you need to configure an {solr-javadocs}/core/org/apache/solr/update/processor/UpdateRequestProcessorChain.html[Update Request Processor Chain] that includes at least one `DocumentEnrichmentUpdateProcessor` update request processor in one of the 2 following way:
+
+* Update processor with parameter `prompt`

Review Comment:
   Update processor definition with the `prompt` parameter



##########
solr/solr-ref-guide/modules/indexing-guide/pages/document-enrichment-with-llms.adoc:
##########
@@ -0,0 +1,500 @@
+= Document Enrichment with LLMs
+// 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.
+
+This module brings the power of *Large Language Models* to Solr.
+
+More specifically, it provides the capability, at indexing time, given a prompt and a set of input fields, of calling an
+LLM through https://github.com/langchain4j/langchain4j[LangChain4j] for each document and store the result of the call
+in an `outputField`, that can be of multiple types and even multivalued.
+
+_Without_ this module, the LLM calls must be done _outside_ Solr, before indexing.
+
+[IMPORTANT]
+====
+This module sends your documents off to some hosted service on the internet.
+There are cost, privacy, performance, and service availability implications on such a strong dependency that should be
+diligently examined before employing this module in a serious way.
+
+====
+
+At the moment a subset of LLM providers supported by LangChain4j is supported by Solr.
+
+*Disclaimer*: Apache Solr is *in no way* affiliated to any of these corporations or services.
+
+If you want to add support for additional services or improve the support for the existing ones, feel free to
+contribute:
+
+* https://github.com/apache/solr/blob/main/CONTRIBUTING.md[Contributing to Solr]
+
+== Module
+
+This is provided via the `language-models` xref:configuration-guide:solr-modules.adoc[Solr Module] that needs to be
+enabled before use.
+
+== Language Model Configuration
+
+Language Models is a module and therefore its plugins must be configured in `solrconfig.xml`.
+
+=== Minimum Requirements
+
+* Enable the `language-models` module to make the Language Models classes available on Solr's classpath.
+See xref:configuration-guide:solr-modules.adoc[Solr Module] for more details.
+
+* An update processor, similar to the one below, must be declared in `solrconfig.xml`:
++
+[source,xml]
+----
+<updateRequestProcessorChain name="documentEnrichment">
+  <processor class="solr.languagemodels.documentenrichment.update.processor.DocumentEnrichmentUpdateProcessorFactory">
+   <str name="inputField">string_field</str>
+   <str name="outputField">summary</str>
+   <str name="prompt">Summarize this content: {string_field}</str>
+   <str name="model">model-name</str>
+  </processor>
+  <processor class="solr.RunUpdateProcessorFactory"/>
+ </updateRequestProcessorChain>
+----
+[NOTE]
+====
+If no component is configured in `solrconfig.xml`, the `ChatModel` store will not be registered and requests to
+`/schema/chat-model-store` will return an error.
+====
+
+== Chat Model Configuration
+
+=== Models
+
+* A model in this module is a chat model, that answers with text given a prompt.
+* A model in this Solr module is a reference to an external API that runs the Large Language Model responsible for chat
+completion.
+
+[IMPORTANT]
+====
+The Solr chat model specifies the parameters to access the APIs, the LLM doesn't run internally in Solr
+
+====
+
+A model is described by these parameters:
+
+
+`class`::
++
+[%autowidth,frame=none]
+|===
+s|Required |Default: none
+|===
++
+The model implementation.
+Accepted values:
+
+* `dev.langchain4j.model.ollama.OllamaChatModel`
+* `dev.langchain4j.model.mistralai.MistralAiChatModel`
+* `dev.langchain4j.model.anthropic.AnthropicChatModel`
+* `dev.langchain4j.model.openai.OpenAiChatModel`
+* `dev.langchain4j.model.googleai.GoogleAiGeminiChatModel`
+
+`name`::
++
+[%autowidth,frame=none]
+|===
+s|Required |Default: none
+|===
++
+The identifier of your model, this is used by any component that intends to use the model (e.g., `DocumentEnrichmentUpdateProcessorFactory` update processor).
+
+`params`::
++
+[%autowidth,frame=none]
+|===
+|Optional |Default: none
+|===
++
+Each model class has potentially different params.
+Many are shared but for the full set of parameters of the model you are interested in please refer to the official documentation of the LangChain4j version included in Solr: https://docs.langchain4j.dev/category/language-models[Chat Models in LangChain4j].
+
+=== Supported Models
+Apache Solr uses https://github.com/langchain4j/langchain4j[LangChain4j] to support document enrichment with LLMs.
+The models currently supported are:
+
+[tabs#supported-chat-models]
+======
+Ollama::
++
+====
+
+[source,json]
+----
+{
+  "class": "dev.langchain4j.model.ollama.OllamaChatModel",
+  "name": "<a-name-for-your-model>",
+  "params": {
+    "baseUrl": "http://localhost:11434",
+    "modelName": "<a-local/hosted-chat-model>",
+    "timeout": 300,
+    "logRequests": true,
+    "logResponses": true,
+    "maxRetries": 5
+  }
+}
+----
+====
+
+MistralAI::
++
+====
+[source,json]
+----
+{
+  "class": "dev.langchain4j.model.mistralai.MistralAiChatModel",
+  "name": "<a-name-for-your-model>",
+  "params": {
+    "baseUrl": "https://api.mistral.ai/v1",
+    "apiKey": "<your-mistralAI-api-key>",
+    "modelName": "<a-mistralAI-chat-model>",
+    "timeout": 60,
+    "logRequests": true,
+    "logResponses": true,
+    "maxRetries": 5
+  }
+}
+----
+====
+OpenAI::
++
+====
+[source,json]
+----
+{
+  "class": "dev.langchain4j.model.openai.OpenAiChatModel",
+  "name": "<a-name-for-your-model>",
+  "params": {
+    "baseUrl": "https://api.openai.com/v1",
+    "apiKey": "<your-openAI-api-key>",
+    "modelName": "<a-openAI-chat-model>",
+    "timeout": 60,
+    "logRequests": true,
+    "logResponses": true,
+    "maxRetries": 5
+  }
+}
+----
+====
+
+Anthropic::
++
+====
+[source,json]
+----
+{
+  "class": "dev.langchain4j.model.anthropic.AnthropicChatModel",
+  "name": "<a-name-for-your-model>",
+  "params": {
+    "baseUrl": "https://api.anthropic.com/v1/",
+    "apiKey": "<your-anthropic-api-key>",
+    "modelName": "<a-anthropic-chat-model>",
+    "timeout": 60,
+    "logRequests": true,
+    "logResponses": true,
+    "maxRetries": 5
+  }
+}
+----
+====
+
+Gemini::
++
+====
+[source,json]
+----
+{
+  "class": "dev.langchain4j.model.googleai.GoogleAiGeminiChatModel",
+  "name": "<a-name-for-your-model>",
+  "params": {
+    "baseUrl": "https://generativelanguage.googleapis.com/v1beta/",
+    "apiKey": "<your-geminiAi-api-key>",
+    "modelName": "<a-geminiAi-chat-model>",
+    "timeout": 60,
+    "logRequests": true,
+    "logResponses": true,
+    "maxRetries": 5
+  }
+}
+----
+====
+======
+
+=== Uploading a Model
+
+To upload the model in a `/path/myModel.json` file, please run:
+
+[source,bash]
+----
+curl -XPUT 'http://localhost:8983/solr/YOUR_COLLECTION/schema/chat-model-store' --data-binary "@/path/myModel.json" -H 'Content-type:application/json'
+----
+
+To delete the `currentModel` model:
+
+[source,bash]
+----
+curl -XDELETE 'http://localhost:8983/solr/YOUR_COLLECTION/schema/chat-model-store/currentModel'
+----
+
+To view all models:
+
+[source,text]
+http://localhost:8983/solr/YOUR_COLLECTION/schema/chat-model-store
+
+
+.Example: /path/myOpenAIModel.json
+[source,json]
+----
+{
+  "class": "dev.langchain4j.model.openai.OpenAiChatModel",
+  "name": "openai-1",
+  "params": {
+    "baseUrl": "https://api.openai.com/v1",
+    "apiKey": "apiKey-openAI",
+    "modelName": "gpt-5.4-nano",
+    "timeout": 60,
+    "logRequests": true,
+    "logResponses": true,
+    "maxRetries": 5
+  }
+}
+----
+
+== How to Trigger Document Enrichment during Indexing
+To create new fields starting from existent ones in your documents at indexing time you need to configure an {solr-javadocs}/core/org/apache/solr/update/processor/UpdateRequestProcessorChain.html[Update Request Processor Chain] that includes at least one `DocumentEnrichmentUpdateProcessor` update request processor in one of the 2 following way:
+
+* Update processor with parameter `prompt`
++
+[source,xml]
+----
+<updateRequestProcessorChain name="documentEnrichment">
+  <processor class="solr.languagemodels.documentenrichment.update.processor.DocumentEnrichmentUpdateProcessorFactory">
+   <str name="inputField">string_field</str>
+   <str name="outputField">summary</str>
+   <str name="prompt">Summarize this content: {string_field}</str>
+   <str name="model">model-name</str>
+  </processor>
+  <processor class="solr.RunUpdateProcessorFactory"/>
+ </updateRequestProcessorChain>
+----
+
+* Update processor with parameter `promptFile`: in this case, the file `prompt.txt` must be uploaded to Solr similarly to any other configuration file (e.g., `solrconfig.xml`, `synonyms.txt`, etc.)
++
+[source,xml]
+----
+<updateRequestProcessorChain name="documentEnrichment">
+  <processor class="solr.languagemodels.documentenrichment.update.processor.DocumentEnrichmentUpdateProcessorFactory">
+   <str name="inputField">string_field</str>
+   <str name="outputField">summary</str>
+   <str name="promptFile">prompt.txt</str>
+   <str name="model">model-name</str>
+  </processor>
+  <processor class="solr.RunUpdateProcessorFactory"/>
+ </updateRequestProcessorChain>
+----
+
+Exactly one of the following parameters is required: `prompt` or `promptFile`.
+
+Another important feature of this module is that one (or more) `inputField` needs to be injected in the prompt. This is
+done by some special tokens, that are the `fieldName` surrounded by curly brackets (e.g., `{string_field}`, in the
+example above). These tokens are _mandatory_ for this module to work properly. Solr will throw an error if the

Review Comment:
   above or below?



##########
solr/solr-ref-guide/modules/indexing-guide/pages/document-enrichment-with-llms.adoc:
##########
@@ -0,0 +1,500 @@
+= Document Enrichment with LLMs
+// 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.
+
+This module brings the power of *Large Language Models* to Solr.
+
+More specifically, it provides the capability, at indexing time, given a prompt and a set of input fields, of calling an
+LLM through https://github.com/langchain4j/langchain4j[LangChain4j] for each document and store the result of the call
+in an `outputField`, that can be of multiple types and even multivalued.
+
+_Without_ this module, the LLM calls must be done _outside_ Solr, before indexing.
+
+[IMPORTANT]
+====
+This module sends your documents off to some hosted service on the internet.
+There are cost, privacy, performance, and service availability implications on such a strong dependency that should be
+diligently examined before employing this module in a serious way.
+
+====
+
+At the moment a subset of LLM providers supported by LangChain4j is supported by Solr.
+
+*Disclaimer*: Apache Solr is *in no way* affiliated to any of these corporations or services.
+
+If you want to add support for additional services or improve the support for the existing ones, feel free to
+contribute:
+
+* https://github.com/apache/solr/blob/main/CONTRIBUTING.md[Contributing to Solr]
+
+== Module
+
+This is provided via the `language-models` xref:configuration-guide:solr-modules.adoc[Solr Module] that needs to be
+enabled before use.
+
+== Language Model Configuration
+
+Language Models is a module and therefore its plugins must be configured in `solrconfig.xml`.
+
+=== Minimum Requirements
+
+* Enable the `language-models` module to make the Language Models classes available on Solr's classpath.
+See xref:configuration-guide:solr-modules.adoc[Solr Module] for more details.
+
+* An update processor, similar to the one below, must be declared in `solrconfig.xml`:
++
+[source,xml]
+----
+<updateRequestProcessorChain name="documentEnrichment">
+  <processor class="solr.languagemodels.documentenrichment.update.processor.DocumentEnrichmentUpdateProcessorFactory">
+   <str name="inputField">string_field</str>
+   <str name="outputField">summary</str>
+   <str name="prompt">Summarize this content: {string_field}</str>
+   <str name="model">model-name</str>
+  </processor>
+  <processor class="solr.RunUpdateProcessorFactory"/>
+ </updateRequestProcessorChain>
+----
+[NOTE]
+====
+If no component is configured in `solrconfig.xml`, the `ChatModel` store will not be registered and requests to
+`/schema/chat-model-store` will return an error.
+====
+
+== Chat Model Configuration
+
+=== Models
+
+* A model in this module is a chat model, that answers with text given a prompt.
+* A model in this Solr module is a reference to an external API that runs the Large Language Model responsible for chat
+completion.
+
+[IMPORTANT]
+====
+The Solr chat model specifies the parameters to access the APIs, the LLM doesn't run internally in Solr
+
+====
+
+A model is described by these parameters:
+
+
+`class`::
++
+[%autowidth,frame=none]
+|===
+s|Required |Default: none
+|===
++
+The model implementation.
+Accepted values:
+
+* `dev.langchain4j.model.ollama.OllamaChatModel`
+* `dev.langchain4j.model.mistralai.MistralAiChatModel`
+* `dev.langchain4j.model.anthropic.AnthropicChatModel`
+* `dev.langchain4j.model.openai.OpenAiChatModel`
+* `dev.langchain4j.model.googleai.GoogleAiGeminiChatModel`
+
+`name`::
++
+[%autowidth,frame=none]
+|===
+s|Required |Default: none
+|===
++
+The identifier of your model, this is used by any component that intends to use the model (e.g., `DocumentEnrichmentUpdateProcessorFactory` update processor).
+
+`params`::
++
+[%autowidth,frame=none]
+|===
+|Optional |Default: none
+|===
++
+Each model class has potentially different params.
+Many are shared but for the full set of parameters of the model you are interested in please refer to the official documentation of the LangChain4j version included in Solr: https://docs.langchain4j.dev/category/language-models[Chat Models in LangChain4j].
+
+=== Supported Models
+Apache Solr uses https://github.com/langchain4j/langchain4j[LangChain4j] to support document enrichment with LLMs.
+The models currently supported are:
+
+[tabs#supported-chat-models]
+======
+Ollama::
++
+====
+
+[source,json]
+----
+{
+  "class": "dev.langchain4j.model.ollama.OllamaChatModel",
+  "name": "<a-name-for-your-model>",
+  "params": {
+    "baseUrl": "http://localhost:11434",
+    "modelName": "<a-local/hosted-chat-model>",
+    "timeout": 300,
+    "logRequests": true,
+    "logResponses": true,
+    "maxRetries": 5
+  }
+}
+----
+====
+
+MistralAI::
++
+====
+[source,json]
+----
+{
+  "class": "dev.langchain4j.model.mistralai.MistralAiChatModel",
+  "name": "<a-name-for-your-model>",
+  "params": {
+    "baseUrl": "https://api.mistral.ai/v1",
+    "apiKey": "<your-mistralAI-api-key>",
+    "modelName": "<a-mistralAI-chat-model>",
+    "timeout": 60,
+    "logRequests": true,
+    "logResponses": true,
+    "maxRetries": 5
+  }
+}
+----
+====
+OpenAI::
++
+====
+[source,json]
+----
+{
+  "class": "dev.langchain4j.model.openai.OpenAiChatModel",
+  "name": "<a-name-for-your-model>",
+  "params": {
+    "baseUrl": "https://api.openai.com/v1",
+    "apiKey": "<your-openAI-api-key>",
+    "modelName": "<a-openAI-chat-model>",
+    "timeout": 60,
+    "logRequests": true,
+    "logResponses": true,
+    "maxRetries": 5
+  }
+}
+----
+====
+
+Anthropic::
++
+====
+[source,json]
+----
+{
+  "class": "dev.langchain4j.model.anthropic.AnthropicChatModel",
+  "name": "<a-name-for-your-model>",
+  "params": {
+    "baseUrl": "https://api.anthropic.com/v1/",
+    "apiKey": "<your-anthropic-api-key>",
+    "modelName": "<a-anthropic-chat-model>",
+    "timeout": 60,
+    "logRequests": true,
+    "logResponses": true,
+    "maxRetries": 5
+  }
+}
+----
+====
+
+Gemini::
++
+====
+[source,json]
+----
+{
+  "class": "dev.langchain4j.model.googleai.GoogleAiGeminiChatModel",
+  "name": "<a-name-for-your-model>",
+  "params": {
+    "baseUrl": "https://generativelanguage.googleapis.com/v1beta/",
+    "apiKey": "<your-geminiAi-api-key>",
+    "modelName": "<a-geminiAi-chat-model>",
+    "timeout": 60,
+    "logRequests": true,
+    "logResponses": true,
+    "maxRetries": 5
+  }
+}
+----
+====
+======
+
+=== Uploading a Model
+
+To upload the model in a `/path/myModel.json` file, please run:
+
+[source,bash]
+----
+curl -XPUT 'http://localhost:8983/solr/YOUR_COLLECTION/schema/chat-model-store' --data-binary "@/path/myModel.json" -H 'Content-type:application/json'
+----
+
+To delete the `currentModel` model:
+
+[source,bash]
+----
+curl -XDELETE 'http://localhost:8983/solr/YOUR_COLLECTION/schema/chat-model-store/currentModel'
+----
+
+To view all models:
+
+[source,text]
+http://localhost:8983/solr/YOUR_COLLECTION/schema/chat-model-store
+
+
+.Example: /path/myOpenAIModel.json
+[source,json]
+----
+{
+  "class": "dev.langchain4j.model.openai.OpenAiChatModel",
+  "name": "openai-1",
+  "params": {
+    "baseUrl": "https://api.openai.com/v1",
+    "apiKey": "apiKey-openAI",
+    "modelName": "gpt-5.4-nano",
+    "timeout": 60,
+    "logRequests": true,
+    "logResponses": true,
+    "maxRetries": 5
+  }
+}
+----
+
+== How to Trigger Document Enrichment during Indexing
+To create new fields starting from existent ones in your documents at indexing time you need to configure an {solr-javadocs}/core/org/apache/solr/update/processor/UpdateRequestProcessorChain.html[Update Request Processor Chain] that includes at least one `DocumentEnrichmentUpdateProcessor` update request processor in one of the 2 following way:
+
+* Update processor with parameter `prompt`
++
+[source,xml]
+----
+<updateRequestProcessorChain name="documentEnrichment">
+  <processor class="solr.languagemodels.documentenrichment.update.processor.DocumentEnrichmentUpdateProcessorFactory">
+   <str name="inputField">string_field</str>
+   <str name="outputField">summary</str>
+   <str name="prompt">Summarize this content: {string_field}</str>
+   <str name="model">model-name</str>
+  </processor>
+  <processor class="solr.RunUpdateProcessorFactory"/>
+ </updateRequestProcessorChain>
+----
+
+* Update processor with parameter `promptFile`: in this case, the file `prompt.txt` must be uploaded to Solr similarly to any other configuration file (e.g., `solrconfig.xml`, `synonyms.txt`, etc.)
++
+[source,xml]
+----
+<updateRequestProcessorChain name="documentEnrichment">
+  <processor class="solr.languagemodels.documentenrichment.update.processor.DocumentEnrichmentUpdateProcessorFactory">
+   <str name="inputField">string_field</str>
+   <str name="outputField">summary</str>
+   <str name="promptFile">prompt.txt</str>
+   <str name="model">model-name</str>
+  </processor>
+  <processor class="solr.RunUpdateProcessorFactory"/>
+ </updateRequestProcessorChain>
+----
+
+Exactly one of the following parameters is required: `prompt` or `promptFile`.
+
+Another important feature of this module is that one (or more) `inputField` needs to be injected in the prompt. This is
+done by some special tokens, that are the `fieldName` surrounded by curly brackets (e.g., `{string_field}`, in the
+example above). These tokens are _mandatory_ for this module to work properly. Solr will throw an error if the
+parameters are not properly defined.
+For example, both the prompt and the content of the file prompt.txt, must contain the text '{string_field}', which
+will be substituted with the content of the `string_field` field for each document. An example of a valid prompt with
+multiple input fields is as follows:
+
+[source,xml]
+----
+<updateRequestProcessorChain name="documentEnrichment">
+  <processor class="solr.languagemodels.documentenrichment.update.processor.DocumentEnrichmentUpdateProcessorFactory">
+   <str name="inputField">title</str>
+   <str name="inputField">body</str>
+   <str name="outputField">summary</str>
+   <str name="prompt">Summarize with the following information. Title: {title}. Body: {body}.</str>
+   <str name="model">chat-model</str>
+  </processor>
+  <processor class="solr.RunUpdateProcessorFactory"/>
+ </updateRequestProcessorChain>
+----
+
+Another way of using more than one `inputField` is by using the following notation, instead of more than one parameter
+with the same name:
+[source,xml]
+----
+<arr name="inputField">
+    <str>title</str>
+    <str>body</str>
+</arr>
+----
+
+The LLM response is mapped to the specified `outputField`. Note that this module only supports a subset of Solr's

Review Comment:
   Maybe we can also specify that only one outputField is supported



##########
solr/solr-ref-guide/modules/indexing-guide/pages/document-enrichment-with-llms.adoc:
##########
@@ -0,0 +1,500 @@
+= Document Enrichment with LLMs
+// 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.
+
+This module brings the power of *Large Language Models* to Solr.
+
+More specifically, it provides the capability, at indexing time, given a prompt and a set of input fields, of calling an
+LLM through https://github.com/langchain4j/langchain4j[LangChain4j] for each document and store the result of the call
+in an `outputField`, that can be of multiple types and even multivalued.
+
+_Without_ this module, the LLM calls must be done _outside_ Solr, before indexing.
+
+[IMPORTANT]
+====
+This module sends your documents off to some hosted service on the internet.
+There are cost, privacy, performance, and service availability implications on such a strong dependency that should be
+diligently examined before employing this module in a serious way.
+
+====
+
+At the moment a subset of LLM providers supported by LangChain4j is supported by Solr.
+
+*Disclaimer*: Apache Solr is *in no way* affiliated to any of these corporations or services.
+
+If you want to add support for additional services or improve the support for the existing ones, feel free to
+contribute:
+
+* https://github.com/apache/solr/blob/main/CONTRIBUTING.md[Contributing to Solr]
+
+== Module
+
+This is provided via the `language-models` xref:configuration-guide:solr-modules.adoc[Solr Module] that needs to be
+enabled before use.
+
+== Language Model Configuration
+
+Language Models is a module and therefore its plugins must be configured in `solrconfig.xml`.
+
+=== Minimum Requirements
+
+* Enable the `language-models` module to make the Language Models classes available on Solr's classpath.
+See xref:configuration-guide:solr-modules.adoc[Solr Module] for more details.
+
+* An update processor, similar to the one below, must be declared in `solrconfig.xml`:
++
+[source,xml]
+----
+<updateRequestProcessorChain name="documentEnrichment">
+  <processor class="solr.languagemodels.documentenrichment.update.processor.DocumentEnrichmentUpdateProcessorFactory">
+   <str name="inputField">string_field</str>
+   <str name="outputField">summary</str>
+   <str name="prompt">Summarize this content: {string_field}</str>
+   <str name="model">model-name</str>
+  </processor>
+  <processor class="solr.RunUpdateProcessorFactory"/>
+ </updateRequestProcessorChain>
+----
+[NOTE]
+====
+If no component is configured in `solrconfig.xml`, the `ChatModel` store will not be registered and requests to
+`/schema/chat-model-store` will return an error.
+====
+
+== Chat Model Configuration
+
+=== Models
+
+* A model in this module is a chat model, that answers with text given a prompt.
+* A model in this Solr module is a reference to an external API that runs the Large Language Model responsible for chat
+completion.
+
+[IMPORTANT]
+====
+The Solr chat model specifies the parameters to access the APIs, the LLM doesn't run internally in Solr
+
+====
+
+A model is described by these parameters:
+
+
+`class`::
++
+[%autowidth,frame=none]
+|===
+s|Required |Default: none
+|===
++
+The model implementation.
+Accepted values:
+
+* `dev.langchain4j.model.ollama.OllamaChatModel`
+* `dev.langchain4j.model.mistralai.MistralAiChatModel`
+* `dev.langchain4j.model.anthropic.AnthropicChatModel`
+* `dev.langchain4j.model.openai.OpenAiChatModel`
+* `dev.langchain4j.model.googleai.GoogleAiGeminiChatModel`
+
+`name`::
++
+[%autowidth,frame=none]
+|===
+s|Required |Default: none
+|===
++
+The identifier of your model, this is used by any component that intends to use the model (e.g., `DocumentEnrichmentUpdateProcessorFactory` update processor).
+
+`params`::
++
+[%autowidth,frame=none]
+|===
+|Optional |Default: none
+|===
++
+Each model class has potentially different params.
+Many are shared but for the full set of parameters of the model you are interested in please refer to the official documentation of the LangChain4j version included in Solr: https://docs.langchain4j.dev/category/language-models[Chat Models in LangChain4j].
+
+=== Supported Models
+Apache Solr uses https://github.com/langchain4j/langchain4j[LangChain4j] to support document enrichment with LLMs.
+The models currently supported are:
+
+[tabs#supported-chat-models]
+======
+Ollama::
++
+====
+
+[source,json]
+----
+{
+  "class": "dev.langchain4j.model.ollama.OllamaChatModel",
+  "name": "<a-name-for-your-model>",
+  "params": {
+    "baseUrl": "http://localhost:11434",
+    "modelName": "<a-local/hosted-chat-model>",
+    "timeout": 300,
+    "logRequests": true,
+    "logResponses": true,
+    "maxRetries": 5
+  }
+}
+----
+====
+
+MistralAI::
++
+====
+[source,json]
+----
+{
+  "class": "dev.langchain4j.model.mistralai.MistralAiChatModel",
+  "name": "<a-name-for-your-model>",
+  "params": {
+    "baseUrl": "https://api.mistral.ai/v1",
+    "apiKey": "<your-mistralAI-api-key>",
+    "modelName": "<a-mistralAI-chat-model>",
+    "timeout": 60,
+    "logRequests": true,
+    "logResponses": true,
+    "maxRetries": 5
+  }
+}
+----
+====
+OpenAI::
++
+====
+[source,json]
+----
+{
+  "class": "dev.langchain4j.model.openai.OpenAiChatModel",
+  "name": "<a-name-for-your-model>",
+  "params": {
+    "baseUrl": "https://api.openai.com/v1",
+    "apiKey": "<your-openAI-api-key>",
+    "modelName": "<a-openAI-chat-model>",
+    "timeout": 60,
+    "logRequests": true,
+    "logResponses": true,
+    "maxRetries": 5
+  }
+}
+----
+====
+
+Anthropic::
++
+====
+[source,json]
+----
+{
+  "class": "dev.langchain4j.model.anthropic.AnthropicChatModel",
+  "name": "<a-name-for-your-model>",
+  "params": {
+    "baseUrl": "https://api.anthropic.com/v1/",
+    "apiKey": "<your-anthropic-api-key>",
+    "modelName": "<a-anthropic-chat-model>",
+    "timeout": 60,
+    "logRequests": true,
+    "logResponses": true,
+    "maxRetries": 5
+  }
+}
+----
+====
+
+Gemini::
++
+====
+[source,json]
+----
+{
+  "class": "dev.langchain4j.model.googleai.GoogleAiGeminiChatModel",
+  "name": "<a-name-for-your-model>",
+  "params": {
+    "baseUrl": "https://generativelanguage.googleapis.com/v1beta/",
+    "apiKey": "<your-geminiAi-api-key>",
+    "modelName": "<a-geminiAi-chat-model>",
+    "timeout": 60,
+    "logRequests": true,
+    "logResponses": true,
+    "maxRetries": 5
+  }
+}
+----
+====
+======
+
+=== Uploading a Model
+
+To upload the model in a `/path/myModel.json` file, please run:
+
+[source,bash]
+----
+curl -XPUT 'http://localhost:8983/solr/YOUR_COLLECTION/schema/chat-model-store' --data-binary "@/path/myModel.json" -H 'Content-type:application/json'
+----
+
+To delete the `currentModel` model:
+
+[source,bash]
+----
+curl -XDELETE 'http://localhost:8983/solr/YOUR_COLLECTION/schema/chat-model-store/currentModel'
+----
+
+To view all models:
+
+[source,text]
+http://localhost:8983/solr/YOUR_COLLECTION/schema/chat-model-store
+
+
+.Example: /path/myOpenAIModel.json
+[source,json]
+----
+{
+  "class": "dev.langchain4j.model.openai.OpenAiChatModel",
+  "name": "openai-1",
+  "params": {
+    "baseUrl": "https://api.openai.com/v1",
+    "apiKey": "apiKey-openAI",
+    "modelName": "gpt-5.4-nano",
+    "timeout": 60,
+    "logRequests": true,
+    "logResponses": true,
+    "maxRetries": 5
+  }
+}
+----
+
+== How to Trigger Document Enrichment during Indexing
+To create new fields starting from existent ones in your documents at indexing time you need to configure an {solr-javadocs}/core/org/apache/solr/update/processor/UpdateRequestProcessorChain.html[Update Request Processor Chain] that includes at least one `DocumentEnrichmentUpdateProcessor` update request processor in one of the 2 following way:
+
+* Update processor with parameter `prompt`
++
+[source,xml]
+----
+<updateRequestProcessorChain name="documentEnrichment">
+  <processor class="solr.languagemodels.documentenrichment.update.processor.DocumentEnrichmentUpdateProcessorFactory">
+   <str name="inputField">string_field</str>
+   <str name="outputField">summary</str>
+   <str name="prompt">Summarize this content: {string_field}</str>
+   <str name="model">model-name</str>
+  </processor>
+  <processor class="solr.RunUpdateProcessorFactory"/>
+ </updateRequestProcessorChain>
+----
+
+* Update processor with parameter `promptFile`: in this case, the file `prompt.txt` must be uploaded to Solr similarly to any other configuration file (e.g., `solrconfig.xml`, `synonyms.txt`, etc.)
++
+[source,xml]
+----
+<updateRequestProcessorChain name="documentEnrichment">
+  <processor class="solr.languagemodels.documentenrichment.update.processor.DocumentEnrichmentUpdateProcessorFactory">
+   <str name="inputField">string_field</str>
+   <str name="outputField">summary</str>
+   <str name="promptFile">prompt.txt</str>
+   <str name="model">model-name</str>
+  </processor>
+  <processor class="solr.RunUpdateProcessorFactory"/>
+ </updateRequestProcessorChain>
+----
+
+Exactly one of the following parameters is required: `prompt` or `promptFile`.
+
+Another important feature of this module is that one (or more) `inputField` needs to be injected in the prompt. This is
+done by some special tokens, that are the `fieldName` surrounded by curly brackets (e.g., `{string_field}`, in the
+example above). These tokens are _mandatory_ for this module to work properly. Solr will throw an error if the
+parameters are not properly defined.
+For example, both the prompt and the content of the file prompt.txt, must contain the text '{string_field}', which
+will be substituted with the content of the `string_field` field for each document. An example of a valid prompt with
+multiple input fields is as follows:
+
+[source,xml]
+----
+<updateRequestProcessorChain name="documentEnrichment">
+  <processor class="solr.languagemodels.documentenrichment.update.processor.DocumentEnrichmentUpdateProcessorFactory">
+   <str name="inputField">title</str>
+   <str name="inputField">body</str>
+   <str name="outputField">summary</str>
+   <str name="prompt">Summarize with the following information. Title: {title}. Body: {body}.</str>
+   <str name="model">chat-model</str>
+  </processor>
+  <processor class="solr.RunUpdateProcessorFactory"/>
+ </updateRequestProcessorChain>
+----
+
+Another way of using more than one `inputField` is by using the following notation, instead of more than one parameter
+with the same name:
+[source,xml]
+----
+<arr name="inputField">
+    <str>title</str>
+    <str>body</str>
+</arr>
+----
+
+The LLM response is mapped to the specified `outputField`. Note that this module only supports a subset of Solr's
+available field types, which includes:
+
+* *String/Text*: `StrField`, `TextField`, `SortableTextField`
+* *Date*: `DatePointField` (the LLM must return an ISO-8601 date string; it might be useful to tune your prompt accordingly, to avoid indexing errors)
+* *Numeric*: `IntPointField`, `LongPointField`, `FloatPointField`, `DoublePointField`
+* *Boolean*: `BoolField`
+
+
+These fields _can_ be multivalued. Solr uses structured output from LangChain4j to deal with LLMs' responses.
+
+
+For more details on how to work with update request processors in Apache Solr, please refer to the dedicated page:
+xref:configuration-guide:update-request-processors.adoc[Update Request Processor]
+
+[IMPORTANT]
+====
+This update processor sends your document field content off to some hosted service on the internet.
+There are serious performance implications that should be diligently examined before employing this component in production.
+It will slow down substantially your indexing pipeline so make sure to stress test your solution before going live.
+
+====
+
+[NOTE]
+====
+If any `inputField` value is absent or empty for a given document, enrichment is silently skipped for that document:
+the `outputField` is not added and the document is indexed as-is.
+
+If the LLM call fails at runtime (e.g., network error, model timeout), the exception is caught and logged but is
+*non-fatal*: the document is still indexed without the `outputField`.
+Monitor your indexing logs to detect documents that were not enriched as expected.
+====
+
+=== Index first and enrich your documents on a second pass
+LLM calls are usually quite slow, so, depending on your use case it could be a good idea to index first your documents

Review Comment:
   LLM calls are typically slow, so depending on your use case, it may be preferable to first index your documents and enrich them with LLM-generated fields at a later stage.



-- 
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: issues-unsubscribe@solr.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


---------------------------------------------------------------------
To unsubscribe, e-mail: issues-unsubscribe@solr.apache.org
For additional commands, e-mail: issues-help@solr.apache.org


Re: [PR] SOLR-18187: Document enrichment with LLMs [solr]

Posted by "nicolo-rinaldi (via GitHub)" <gi...@apache.org>.
nicolo-rinaldi commented on code in PR #4259:
URL: https://github.com/apache/solr/pull/4259#discussion_r3044169362


##########
solr/modules/language-models/src/java/org/apache/solr/languagemodels/documentenrichment/store/rest/ManagedChatModelStore.java:
##########
@@ -0,0 +1,200 @@
+/*
+ * 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.solr.languagemodels.documentenrichment.store.rest;
+
+import java.lang.invoke.MethodHandles;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+import net.jcip.annotations.ThreadSafe;
+import org.apache.solr.common.SolrException;
+import org.apache.solr.common.util.NamedList;
+import org.apache.solr.core.SolrCore;
+import org.apache.solr.core.SolrResourceLoader;
+import org.apache.solr.languagemodels.documentenrichment.model.SolrChatModel;
+import org.apache.solr.languagemodels.documentenrichment.store.ChatModelException;
+import org.apache.solr.languagemodels.documentenrichment.store.ChatModelStore;
+import org.apache.solr.response.SolrQueryResponse;
+import org.apache.solr.rest.BaseSolrResource;
+import org.apache.solr.rest.ManagedResource;
+import org.apache.solr.rest.ManagedResourceObserver;
+import org.apache.solr.rest.ManagedResourceStorage;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/** Managed Resource wrapper for the {@link ChatModelStore} to expose it via REST */
+@ThreadSafe
+public class ManagedChatModelStore extends ManagedResource
+    implements ManagedResource.ChildResourceSupport {
+  private static final Logger log = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());
+
+  /** the model store rest endpoint */
+  public static final String REST_END_POINT = "/schema/chat-model-store";
+
+  /** Managed model store: the name of the attribute containing all the models of a model store */
+  private static final String MODELS_JSON_FIELD = "models";
+
+  /** name of the attribute containing a class */
+  static final String CLASS_KEY = "class";
+
+  /** name of the attribute containing a name */
+  static final String NAME_KEY = "name";
+
+  /** name of the attribute containing parameters */
+  static final String PARAMS_KEY = "params";
+
+  public static void registerManagedChatModelStore(
+      SolrResourceLoader solrResourceLoader, ManagedResourceObserver managedResourceObserver) {
+    solrResourceLoader
+        .getManagedResourceRegistry()
+        .registerManagedResource(
+            REST_END_POINT, ManagedChatModelStore.class, managedResourceObserver);
+  }
+
+  public static ManagedChatModelStore getManagedModelStore(SolrCore core) {
+    return (ManagedChatModelStore) core.getRestManager().getManagedResource(REST_END_POINT);
+  }
+
+  /**
+   * Returns the available models as a list of Maps objects. After an update the managed resources
+   * needs to return the resources in this format in order to store in json somewhere (zookeeper,
+   * disk...)
+   *
+   * @return the available models as a list of Maps objects
+   */
+  private static List<Object> modelsAsManagedResources(List<SolrChatModel> models) {
+    return models.stream()
+        .map(ManagedChatModelStore::toModelMap)
+        .collect(Collectors.toList());
+  }
+
+  @SuppressWarnings("unchecked")
+  public static SolrChatModel fromModelMap(
+      SolrResourceLoader solrResourceLoader, Map<String, Object> chatModel) {
+    return SolrChatModel.getInstance(
+        solrResourceLoader,
+        (String) chatModel.get(CLASS_KEY), // modelClassName
+        (String) chatModel.get(NAME_KEY), // modelName
+        (Map<String, Object>) chatModel.get(PARAMS_KEY));
+  }
+
+  private static LinkedHashMap<String, Object> toModelMap(SolrChatModel model) {
+    final LinkedHashMap<String, Object> modelMap = new LinkedHashMap<>(5, 1.0f);

Review Comment:
   This was a copy-paste leftover of textToVector from LTR module at org.apache.solr.ltr.store.rest.ManagedModelStore#toLTRScoringModelMap. I'm changing this to 3. Same for textToVector.



##########
solr/modules/language-models/src/java/org/apache/solr/languagemodels/documentenrichment/model/SolrChatModel.java:
##########
@@ -0,0 +1,215 @@
+/*
+ * 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.solr.languagemodels.documentenrichment.model;
+
+import dev.langchain4j.data.message.UserMessage;
+import dev.langchain4j.model.chat.ChatModel;
+import dev.langchain4j.model.chat.request.ChatRequest;
+import dev.langchain4j.model.chat.request.ResponseFormat;
+import java.lang.invoke.MethodHandles;
+import java.lang.reflect.Method;
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.Map;
+import java.util.Objects;
+import org.apache.lucene.util.Accountable;
+import org.apache.lucene.util.RamUsageEstimator;
+import org.apache.solr.common.SolrException;
+import org.apache.solr.common.util.Utils;
+import org.apache.solr.core.SolrResourceLoader;
+import org.apache.solr.languagemodels.documentenrichment.store.ChatModelException;
+import org.apache.solr.languagemodels.documentenrichment.store.rest.ManagedChatModelStore;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * This object wraps a {@link ChatModel} to produce the content of new fields from another.
+ * It's meant to be used as a managed resource with the {@link
+ * ManagedChatModelStore}
+ */
+public class SolrChatModel implements Accountable {
+  private static final Logger log = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());
+  private static final long BASE_RAM_BYTES =
+      RamUsageEstimator.shallowSizeOfInstance(SolrChatModel.class);
+  // timeout is type Duration
+  private static final String TIMEOUT_PARAM = "timeout";
+
+  // the followings are Integer type
+  private static final String MAX_RETRIES_PARAM = "maxRetries";
+  private static final String THINKING_BUDGET_TOKENS = "thinkingBudgetTokens";
+  private static final String RANDOM_SEED = "randomSeed";
+
+  private final String name;
+  private final Map<String, Object> params;
+  private final ChatModel chatModel;
+  private final int hashCode;
+
+  public static SolrChatModel getInstance(
+      SolrResourceLoader solrResourceLoader,
+      String className,
+      String name,
+      Map<String, Object> params)
+      throws ChatModelException {
+    try {
+      /*
+       * The idea here is to build a {@link dev.langchain4j.model.chat.ChatModel} using inversion
+       * of control.
+       * Each model has its own list of parameters we don't know beforehand, but each {@link dev.langchain4j.model.chat.ChatModel} class
+       * has its own builder that uses setters with the same name of the parameter in input.
+       * */
+      ChatModel textToTextModel;
+      Class<?> modelClass = solrResourceLoader.findClass(className, ChatModel.class);
+      var builder = modelClass.getMethod("builder").invoke(null);
+      if (params != null) {
+        /*
+         * This block of code has the responsibility of instantiate a {@link
+         * dev.langchain4j.model.chat.ChatModel} using the params provided.classes have
+         * params of The specific implementation of {@link
+         * dev.langchain4j.model.chat.ChatModel} is not known beforehand. So we benefit of
+         * the design choice in langchain4j that each subclass implementing {@link
+         * dev.langchain4j.model.chat.ChatModel} uses setters with the same name of the
+         * param.
+         */
+        for (String paramName : params.keySet()) {
+          /*
+           * When a param is not primitive, we need to instantiate the object explicitly and then call the
+           * setter method.
+           * N.B. when adding support to new models, pay attention to all the parameters they
+           * support, some of them may require to be handled in here as separate switch cases
+           */
+          switch (paramName) {
+            case TIMEOUT_PARAM -> builder
+                  .getClass()
+                  .getMethod(paramName, Duration.class)
+                  .invoke(builder, Duration.ofSeconds((Long) params.get(paramName)));
+
+            case MAX_RETRIES_PARAM, THINKING_BUDGET_TOKENS, RANDOM_SEED -> builder
+                .getClass()
+                .getMethod(paramName, Integer.class)
+                .invoke(builder, ((Long) params.get(paramName)).intValue());
+
+              /*
+               * For primitive params if there's only one setter available, we call it.
+               * If there's choice we default to the string one
+               */
+            default -> {
+              ArrayList<Method> paramNameMatches = new ArrayList<>();
+              for (var method : builder.getClass().getMethods()) {
+                if (paramName.equals(method.getName()) && method.getParameterCount() == 1) {
+                  paramNameMatches.add(method);
+                }
+              }
+              if (paramNameMatches.size() == 1) {
+                paramNameMatches.getFirst().invoke(builder, params.get(paramName));
+              } else {
+                try {
+                  builder
+                      .getClass()
+                      .getMethod(paramName, String.class)
+                      .invoke(builder, params.get(paramName).toString());
+                } catch (NoSuchMethodException e) {
+                  log.error("Parameter {} not supported by model {}", paramName, className);
+                  throw new SolrException(SolrException.ErrorCode.BAD_REQUEST, e.getMessage(), e);
+                }
+              }
+            }
+          }
+        }
+      }
+      textToTextModel = (ChatModel) builder.getClass().getMethod("build").invoke(builder);
+      return new SolrChatModel(name, textToTextModel, params);
+    } catch (final Exception e) {
+      throw new ChatModelException("Model loading failed for " + className, e);
+    }
+  }
+
+  public SolrChatModel(
+      String name, ChatModel chatModel, Map<String, Object> params) {
+    this.name = name;
+    this.chatModel = chatModel;
+    this.params = params;
+    this.hashCode = calculateHashCode();
+  }
+
+  /**
+   * Sends a structured chat request and returns the parsed value from the {@code {"value": ...}}
+   * JSON object that the model is instructed to produce via {@code responseFormat}.
+   *
+   * @return the extracted value: a {@link String}, {@link Number}, {@link Boolean}, or {@link
+   *     java.util.List} depending on the Solr output field type
+   */
+  public Object chat(String text, ResponseFormat responseFormat) {
+    ChatRequest chatRequest =
+        ChatRequest.builder()
+            .responseFormat(responseFormat)
+            .messages(UserMessage.from(text))
+            .build();
+    String rawJson = chatModel.chat(chatRequest).aiMessage().text();
+    Object parsed = Utils.fromJSONString(rawJson);
+    if (!(parsed instanceof Map<?, ?> map) || !map.containsKey("value")) {

Review Comment:
   To keep due to Ollama support (for local models). No guardrails in the output in that case



##########
solr/modules/language-models/src/java/org/apache/solr/languagemodels/documentenrichment/model/SolrChatModel.java:
##########
@@ -0,0 +1,215 @@
+/*
+ * 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.solr.languagemodels.documentenrichment.model;
+
+import dev.langchain4j.data.message.UserMessage;
+import dev.langchain4j.model.chat.ChatModel;
+import dev.langchain4j.model.chat.request.ChatRequest;
+import dev.langchain4j.model.chat.request.ResponseFormat;
+import java.lang.invoke.MethodHandles;
+import java.lang.reflect.Method;
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.Map;
+import java.util.Objects;
+import org.apache.lucene.util.Accountable;
+import org.apache.lucene.util.RamUsageEstimator;
+import org.apache.solr.common.SolrException;
+import org.apache.solr.common.util.Utils;
+import org.apache.solr.core.SolrResourceLoader;
+import org.apache.solr.languagemodels.documentenrichment.store.ChatModelException;
+import org.apache.solr.languagemodels.documentenrichment.store.rest.ManagedChatModelStore;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * This object wraps a {@link ChatModel} to produce the content of new fields from another.
+ * It's meant to be used as a managed resource with the {@link
+ * ManagedChatModelStore}
+ */
+public class SolrChatModel implements Accountable {
+  private static final Logger log = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());
+  private static final long BASE_RAM_BYTES =
+      RamUsageEstimator.shallowSizeOfInstance(SolrChatModel.class);
+  // timeout is type Duration
+  private static final String TIMEOUT_PARAM = "timeout";
+
+  // the followings are Integer type
+  private static final String MAX_RETRIES_PARAM = "maxRetries";
+  private static final String THINKING_BUDGET_TOKENS = "thinkingBudgetTokens";
+  private static final String RANDOM_SEED = "randomSeed";
+
+  private final String name;
+  private final Map<String, Object> params;
+  private final ChatModel chatModel;
+  private final int hashCode;
+
+  public static SolrChatModel getInstance(
+      SolrResourceLoader solrResourceLoader,
+      String className,
+      String name,
+      Map<String, Object> params)
+      throws ChatModelException {
+    try {
+      /*
+       * The idea here is to build a {@link dev.langchain4j.model.chat.ChatModel} using inversion
+       * of control.
+       * Each model has its own list of parameters we don't know beforehand, but each {@link dev.langchain4j.model.chat.ChatModel} class
+       * has its own builder that uses setters with the same name of the parameter in input.
+       * */
+      ChatModel textToTextModel;
+      Class<?> modelClass = solrResourceLoader.findClass(className, ChatModel.class);
+      var builder = modelClass.getMethod("builder").invoke(null);
+      if (params != null) {
+        /*
+         * This block of code has the responsibility of instantiate a {@link
+         * dev.langchain4j.model.chat.ChatModel} using the params provided.classes have
+         * params of The specific implementation of {@link
+         * dev.langchain4j.model.chat.ChatModel} is not known beforehand. So we benefit of
+         * the design choice in langchain4j that each subclass implementing {@link
+         * dev.langchain4j.model.chat.ChatModel} uses setters with the same name of the
+         * param.
+         */
+        for (String paramName : params.keySet()) {
+          /*
+           * When a param is not primitive, we need to instantiate the object explicitly and then call the
+           * setter method.
+           * N.B. when adding support to new models, pay attention to all the parameters they
+           * support, some of them may require to be handled in here as separate switch cases
+           */
+          switch (paramName) {
+            case TIMEOUT_PARAM -> builder
+                  .getClass()
+                  .getMethod(paramName, Duration.class)
+                  .invoke(builder, Duration.ofSeconds((Long) params.get(paramName)));
+
+            case MAX_RETRIES_PARAM, THINKING_BUDGET_TOKENS, RANDOM_SEED -> builder
+                .getClass()
+                .getMethod(paramName, Integer.class)
+                .invoke(builder, ((Long) params.get(paramName)).intValue());
+
+              /*
+               * For primitive params if there's only one setter available, we call it.
+               * If there's choice we default to the string one
+               */
+            default -> {
+              ArrayList<Method> paramNameMatches = new ArrayList<>();
+              for (var method : builder.getClass().getMethods()) {
+                if (paramName.equals(method.getName()) && method.getParameterCount() == 1) {
+                  paramNameMatches.add(method);
+                }
+              }
+              if (paramNameMatches.size() == 1) {
+                paramNameMatches.getFirst().invoke(builder, params.get(paramName));
+              } else {
+                try {
+                  builder
+                      .getClass()
+                      .getMethod(paramName, String.class)
+                      .invoke(builder, params.get(paramName).toString());
+                } catch (NoSuchMethodException e) {
+                  log.error("Parameter {} not supported by model {}", paramName, className);
+                  throw new SolrException(SolrException.ErrorCode.BAD_REQUEST, e.getMessage(), e);
+                }
+              }
+            }
+          }
+        }
+      }
+      textToTextModel = (ChatModel) builder.getClass().getMethod("build").invoke(builder);
+      return new SolrChatModel(name, textToTextModel, params);
+    } catch (final Exception e) {
+      throw new ChatModelException("Model loading failed for " + className, e);
+    }
+  }
+
+  public SolrChatModel(
+      String name, ChatModel chatModel, Map<String, Object> params) {
+    this.name = name;
+    this.chatModel = chatModel;
+    this.params = params;
+    this.hashCode = calculateHashCode();
+  }
+
+  /**
+   * Sends a structured chat request and returns the parsed value from the {@code {"value": ...}}
+   * JSON object that the model is instructed to produce via {@code responseFormat}.
+   *
+   * @return the extracted value: a {@link String}, {@link Number}, {@link Boolean}, or {@link

Review Comment:
   Added



##########
solr/modules/language-models/src/java/org/apache/solr/languagemodels/documentenrichment/model/SolrChatModel.java:
##########
@@ -0,0 +1,215 @@
+/*
+ * 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.solr.languagemodels.documentenrichment.model;
+
+import dev.langchain4j.data.message.UserMessage;
+import dev.langchain4j.model.chat.ChatModel;
+import dev.langchain4j.model.chat.request.ChatRequest;
+import dev.langchain4j.model.chat.request.ResponseFormat;
+import java.lang.invoke.MethodHandles;
+import java.lang.reflect.Method;
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.Map;
+import java.util.Objects;
+import org.apache.lucene.util.Accountable;
+import org.apache.lucene.util.RamUsageEstimator;
+import org.apache.solr.common.SolrException;
+import org.apache.solr.common.util.Utils;
+import org.apache.solr.core.SolrResourceLoader;
+import org.apache.solr.languagemodels.documentenrichment.store.ChatModelException;
+import org.apache.solr.languagemodels.documentenrichment.store.rest.ManagedChatModelStore;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * This object wraps a {@link ChatModel} to produce the content of new fields from another.
+ * It's meant to be used as a managed resource with the {@link
+ * ManagedChatModelStore}
+ */
+public class SolrChatModel implements Accountable {
+  private static final Logger log = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());
+  private static final long BASE_RAM_BYTES =
+      RamUsageEstimator.shallowSizeOfInstance(SolrChatModel.class);
+  // timeout is type Duration

Review Comment:
   Removed



##########
solr/modules/language-models/src/java/org/apache/solr/languagemodels/documentenrichment/store/rest/ManagedChatModelStore.java:
##########
@@ -0,0 +1,200 @@
+/*
+ * 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.solr.languagemodels.documentenrichment.store.rest;
+
+import java.lang.invoke.MethodHandles;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+import net.jcip.annotations.ThreadSafe;
+import org.apache.solr.common.SolrException;
+import org.apache.solr.common.util.NamedList;
+import org.apache.solr.core.SolrCore;
+import org.apache.solr.core.SolrResourceLoader;
+import org.apache.solr.languagemodels.documentenrichment.model.SolrChatModel;
+import org.apache.solr.languagemodels.documentenrichment.store.ChatModelException;
+import org.apache.solr.languagemodels.documentenrichment.store.ChatModelStore;
+import org.apache.solr.response.SolrQueryResponse;
+import org.apache.solr.rest.BaseSolrResource;
+import org.apache.solr.rest.ManagedResource;
+import org.apache.solr.rest.ManagedResourceObserver;
+import org.apache.solr.rest.ManagedResourceStorage;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/** Managed Resource wrapper for the {@link ChatModelStore} to expose it via REST */
+@ThreadSafe
+public class ManagedChatModelStore extends ManagedResource
+    implements ManagedResource.ChildResourceSupport {
+  private static final Logger log = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());
+
+  /** the model store rest endpoint */
+  public static final String REST_END_POINT = "/schema/chat-model-store";
+
+  /** Managed model store: the name of the attribute containing all the models of a model store */
+  private static final String MODELS_JSON_FIELD = "models";
+
+  /** name of the attribute containing a class */
+  static final String CLASS_KEY = "class";
+
+  /** name of the attribute containing a name */
+  static final String NAME_KEY = "name";
+
+  /** name of the attribute containing parameters */
+  static final String PARAMS_KEY = "params";
+
+  public static void registerManagedChatModelStore(
+      SolrResourceLoader solrResourceLoader, ManagedResourceObserver managedResourceObserver) {
+    solrResourceLoader
+        .getManagedResourceRegistry()
+        .registerManagedResource(
+            REST_END_POINT, ManagedChatModelStore.class, managedResourceObserver);
+  }
+
+  public static ManagedChatModelStore getManagedModelStore(SolrCore core) {
+    return (ManagedChatModelStore) core.getRestManager().getManagedResource(REST_END_POINT);
+  }
+
+  /**
+   * Returns the available models as a list of Maps objects. After an update the managed resources
+   * needs to return the resources in this format in order to store in json somewhere (zookeeper,
+   * disk...)
+   *
+   * @return the available models as a list of Maps objects
+   */
+  private static List<Object> modelsAsManagedResources(List<SolrChatModel> models) {
+    return models.stream()
+        .map(ManagedChatModelStore::toModelMap)
+        .collect(Collectors.toList());
+  }
+
+  @SuppressWarnings("unchecked")
+  public static SolrChatModel fromModelMap(
+      SolrResourceLoader solrResourceLoader, Map<String, Object> chatModel) {
+    return SolrChatModel.getInstance(
+        solrResourceLoader,
+        (String) chatModel.get(CLASS_KEY), // modelClassName
+        (String) chatModel.get(NAME_KEY), // modelName
+        (Map<String, Object>) chatModel.get(PARAMS_KEY));
+  }
+
+  private static LinkedHashMap<String, Object> toModelMap(SolrChatModel model) {
+    final LinkedHashMap<String, Object> modelMap = new LinkedHashMap<>(5, 1.0f);
+    modelMap.put(NAME_KEY, model.getName());
+    modelMap.put(CLASS_KEY, model.getChatModelClassName());
+    modelMap.put(PARAMS_KEY, model.getParams());
+    return modelMap;
+  }
+
+  private final ChatModelStore store;
+  private Object managedData;
+
+  public ManagedChatModelStore(
+      String resourceId, SolrResourceLoader loader, ManagedResourceStorage.StorageIO storageIO)
+      throws SolrException {
+    super(resourceId, loader, storageIO);
+    store = new ChatModelStore();
+  }
+
+  @Override
+  protected ManagedResourceStorage createStorage(
+      ManagedResourceStorage.StorageIO storageIO, SolrResourceLoader loader) throws SolrException {
+    return new ManagedResourceStorage.JsonStorage(storageIO, loader, -1);

Review Comment:
   This is another copy-paste leftover of textToVector from LTR module at org.apache.solr.ltr.store.rest.ManagedModelStore#createStorage. I removed it to default indentation (since `-1` means "no indentation"), for the document enrichment module. One possible reason why this has been done for LTR is that the store was also used to store features, and the file size can increase. To discuss what to do in textToVector module.



##########
solr/modules/language-models/src/java/org/apache/solr/languagemodels/documentenrichment/model/SolrChatModel.java:
##########
@@ -0,0 +1,215 @@
+/*
+ * 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.solr.languagemodels.documentenrichment.model;
+
+import dev.langchain4j.data.message.UserMessage;
+import dev.langchain4j.model.chat.ChatModel;
+import dev.langchain4j.model.chat.request.ChatRequest;
+import dev.langchain4j.model.chat.request.ResponseFormat;
+import java.lang.invoke.MethodHandles;
+import java.lang.reflect.Method;
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.Map;
+import java.util.Objects;
+import org.apache.lucene.util.Accountable;
+import org.apache.lucene.util.RamUsageEstimator;
+import org.apache.solr.common.SolrException;
+import org.apache.solr.common.util.Utils;
+import org.apache.solr.core.SolrResourceLoader;
+import org.apache.solr.languagemodels.documentenrichment.store.ChatModelException;
+import org.apache.solr.languagemodels.documentenrichment.store.rest.ManagedChatModelStore;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * This object wraps a {@link ChatModel} to produce the content of new fields from another.

Review Comment:
   Changed



##########
solr/modules/language-models/src/java/org/apache/solr/languagemodels/documentenrichment/model/SolrChatModel.java:
##########
@@ -0,0 +1,215 @@
+/*
+ * 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.solr.languagemodels.documentenrichment.model;
+
+import dev.langchain4j.data.message.UserMessage;
+import dev.langchain4j.model.chat.ChatModel;
+import dev.langchain4j.model.chat.request.ChatRequest;
+import dev.langchain4j.model.chat.request.ResponseFormat;
+import java.lang.invoke.MethodHandles;
+import java.lang.reflect.Method;
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.Map;
+import java.util.Objects;
+import org.apache.lucene.util.Accountable;
+import org.apache.lucene.util.RamUsageEstimator;
+import org.apache.solr.common.SolrException;
+import org.apache.solr.common.util.Utils;
+import org.apache.solr.core.SolrResourceLoader;
+import org.apache.solr.languagemodels.documentenrichment.store.ChatModelException;
+import org.apache.solr.languagemodels.documentenrichment.store.rest.ManagedChatModelStore;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * This object wraps a {@link ChatModel} to produce the content of new fields from another.
+ * It's meant to be used as a managed resource with the {@link
+ * ManagedChatModelStore}
+ */
+public class SolrChatModel implements Accountable {
+  private static final Logger log = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());
+  private static final long BASE_RAM_BYTES =
+      RamUsageEstimator.shallowSizeOfInstance(SolrChatModel.class);
+  // timeout is type Duration
+  private static final String TIMEOUT_PARAM = "timeout";
+
+  // the followings are Integer type

Review Comment:
   Removed



##########
solr/modules/language-models/src/java/org/apache/solr/languagemodels/documentenrichment/model/SolrChatModel.java:
##########
@@ -0,0 +1,215 @@
+/*
+ * 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.solr.languagemodels.documentenrichment.model;
+
+import dev.langchain4j.data.message.UserMessage;
+import dev.langchain4j.model.chat.ChatModel;
+import dev.langchain4j.model.chat.request.ChatRequest;
+import dev.langchain4j.model.chat.request.ResponseFormat;
+import java.lang.invoke.MethodHandles;
+import java.lang.reflect.Method;
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.Map;
+import java.util.Objects;
+import org.apache.lucene.util.Accountable;
+import org.apache.lucene.util.RamUsageEstimator;
+import org.apache.solr.common.SolrException;
+import org.apache.solr.common.util.Utils;
+import org.apache.solr.core.SolrResourceLoader;
+import org.apache.solr.languagemodels.documentenrichment.store.ChatModelException;
+import org.apache.solr.languagemodels.documentenrichment.store.rest.ManagedChatModelStore;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * This object wraps a {@link ChatModel} to produce the content of new fields from another.
+ * It's meant to be used as a managed resource with the {@link
+ * ManagedChatModelStore}
+ */
+public class SolrChatModel implements Accountable {
+  private static final Logger log = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());
+  private static final long BASE_RAM_BYTES =
+      RamUsageEstimator.shallowSizeOfInstance(SolrChatModel.class);
+  // timeout is type Duration
+  private static final String TIMEOUT_PARAM = "timeout";
+
+  // the followings are Integer type
+  private static final String MAX_RETRIES_PARAM = "maxRetries";
+  private static final String THINKING_BUDGET_TOKENS = "thinkingBudgetTokens";
+  private static final String RANDOM_SEED = "randomSeed";
+
+  private final String name;
+  private final Map<String, Object> params;
+  private final ChatModel chatModel;
+  private final int hashCode;
+
+  public static SolrChatModel getInstance(
+      SolrResourceLoader solrResourceLoader,
+      String className,
+      String name,
+      Map<String, Object> params)
+      throws ChatModelException {
+    try {
+      /*
+       * The idea here is to build a {@link dev.langchain4j.model.chat.ChatModel} using inversion
+       * of control.
+       * Each model has its own list of parameters we don't know beforehand, but each {@link dev.langchain4j.model.chat.ChatModel} class
+       * has its own builder that uses setters with the same name of the parameter in input.
+       * */
+      ChatModel textToTextModel;
+      Class<?> modelClass = solrResourceLoader.findClass(className, ChatModel.class);
+      var builder = modelClass.getMethod("builder").invoke(null);
+      if (params != null) {
+        /*
+         * This block of code has the responsibility of instantiate a {@link
+         * dev.langchain4j.model.chat.ChatModel} using the params provided.classes have
+         * params of The specific implementation of {@link
+         * dev.langchain4j.model.chat.ChatModel} is not known beforehand. So we benefit of
+         * the design choice in langchain4j that each subclass implementing {@link
+         * dev.langchain4j.model.chat.ChatModel} uses setters with the same name of the
+         * param.
+         */
+        for (String paramName : params.keySet()) {
+          /*
+           * When a param is not primitive, we need to instantiate the object explicitly and then call the
+           * setter method.
+           * N.B. when adding support to new models, pay attention to all the parameters they
+           * support, some of them may require to be handled in here as separate switch cases
+           */
+          switch (paramName) {
+            case TIMEOUT_PARAM -> builder
+                  .getClass()
+                  .getMethod(paramName, Duration.class)
+                  .invoke(builder, Duration.ofSeconds((Long) params.get(paramName)));
+
+            case MAX_RETRIES_PARAM, THINKING_BUDGET_TOKENS, RANDOM_SEED -> builder
+                .getClass()
+                .getMethod(paramName, Integer.class)
+                .invoke(builder, ((Long) params.get(paramName)).intValue());
+
+              /*
+               * For primitive params if there's only one setter available, we call it.
+               * If there's choice we default to the string one
+               */
+            default -> {
+              ArrayList<Method> paramNameMatches = new ArrayList<>();
+              for (var method : builder.getClass().getMethods()) {
+                if (paramName.equals(method.getName()) && method.getParameterCount() == 1) {
+                  paramNameMatches.add(method);
+                }
+              }
+              if (paramNameMatches.size() == 1) {
+                paramNameMatches.getFirst().invoke(builder, params.get(paramName));
+              } else {
+                try {
+                  builder
+                      .getClass()
+                      .getMethod(paramName, String.class)
+                      .invoke(builder, params.get(paramName).toString());
+                } catch (NoSuchMethodException e) {
+                  log.error("Parameter {} not supported by model {}", paramName, className);
+                  throw new SolrException(SolrException.ErrorCode.BAD_REQUEST, e.getMessage(), e);
+                }
+              }
+            }
+          }
+        }
+      }
+      textToTextModel = (ChatModel) builder.getClass().getMethod("build").invoke(builder);
+      return new SolrChatModel(name, textToTextModel, params);
+    } catch (final Exception e) {
+      throw new ChatModelException("Model loading failed for " + className, e);
+    }
+  }
+
+  public SolrChatModel(
+      String name, ChatModel chatModel, Map<String, Object> params) {
+    this.name = name;
+    this.chatModel = chatModel;
+    this.params = params;
+    this.hashCode = calculateHashCode();
+  }
+
+  /**
+   * Sends a structured chat request and returns the parsed value from the {@code {"value": ...}}
+   * JSON object that the model is instructed to produce via {@code responseFormat}.
+   *
+   * @return the extracted value: a {@link String}, {@link Number}, {@link Boolean}, or {@link
+   *     java.util.List} depending on the Solr output field type
+   */
+  public Object chat(String text, ResponseFormat responseFormat) {
+    ChatRequest chatRequest =
+        ChatRequest.builder()
+            .responseFormat(responseFormat)
+            .messages(UserMessage.from(text))
+            .build();
+    String rawJson = chatModel.chat(chatRequest).aiMessage().text();
+    Object parsed = Utils.fromJSONString(rawJson);
+    if (!(parsed instanceof Map<?, ?> map) || !map.containsKey("value")) {
+      throw new SolrException(

Review Comment:
   Changed



##########
solr/modules/language-models/src/java/org/apache/solr/languagemodels/documentenrichment/update/processor/DocumentEnrichmentUpdateProcessorFactory.java:
##########
@@ -0,0 +1,313 @@
+/*
+ * 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.solr.languagemodels.documentenrichment.update.processor;
+
+import dev.langchain4j.model.chat.request.ResponseFormat;
+import dev.langchain4j.model.chat.request.ResponseFormatType;
+import dev.langchain4j.model.chat.request.json.JsonArraySchema;
+import dev.langchain4j.model.chat.request.json.JsonBooleanSchema;
+import dev.langchain4j.model.chat.request.json.JsonIntegerSchema;
+import dev.langchain4j.model.chat.request.json.JsonNumberSchema;
+import dev.langchain4j.model.chat.request.json.JsonObjectSchema;
+import dev.langchain4j.model.chat.request.json.JsonSchema;
+import dev.langchain4j.model.chat.request.json.JsonSchemaElement;
+import dev.langchain4j.model.chat.request.json.JsonStringSchema;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import java.util.Collection;
+import java.util.HashSet;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import org.apache.solr.common.SolrException;
+import org.apache.solr.common.params.RequiredSolrParams;
+import org.apache.solr.common.params.SolrParams;
+import org.apache.solr.common.util.NamedList;
+import org.apache.solr.core.SolrCore;
+import org.apache.solr.core.SolrResourceLoader;
+import org.apache.solr.languagemodels.documentenrichment.model.SolrChatModel;
+import org.apache.solr.languagemodels.documentenrichment.store.rest.ManagedChatModelStore;
+import org.apache.solr.request.SolrQueryRequest;
+import org.apache.solr.response.SolrQueryResponse;
+import org.apache.solr.rest.ManagedResource;
+import org.apache.solr.rest.ManagedResourceObserver;
+import org.apache.solr.schema.BoolField;
+import org.apache.solr.schema.DatePointField;
+import org.apache.solr.schema.DenseVectorField;
+import org.apache.solr.schema.DoublePointField;
+import org.apache.solr.schema.FieldType;
+import org.apache.solr.schema.FloatPointField;
+import org.apache.solr.schema.IndexSchema;
+import org.apache.solr.schema.IntPointField;
+import org.apache.solr.schema.LongPointField;
+import org.apache.solr.schema.SchemaField;
+import org.apache.solr.schema.StrField;
+import org.apache.solr.schema.TextField;
+import org.apache.solr.update.processor.UpdateRequestProcessor;
+import org.apache.solr.update.processor.UpdateRequestProcessorFactory;
+import org.apache.solr.util.plugin.SolrCoreAware;
+
+/**
+ * Insert in an existing field the output of the model coming from one or more textual field values.
+ *
+ * <p>One or more {@code inputField} parameters specify the Solr fields to use as input. Each field
+ * name must appear as a {@code {fieldName}} placeholder in the prompt. Exactly one of {@code
+ * prompt} or {@code promptFile} must be provided.
+ *
+ * <pre class="prettyprint" >
+ * &lt;processor class=&quot;solr.llm.documentenrichment.update.processor.DocumentEnrichmentUpdateProcessorFactory&quot;&gt;
+ *   &lt;str name=&quot;inputField&quot;&gt;title_field&lt;/str&gt;
+ *   &lt;str name=&quot;inputField&quot;&gt;body_field&lt;/str&gt;
+ *   &lt;str name=&quot;outputField&quot;&gt;enriched_field&lt;/str&gt;
+ *   &lt;str name=&quot;prompt&quot;&gt;Title: {title_field}. Body: {body_field}.&lt;/str&gt;
+ *   &lt;str name=&quot;model&quot;&gt;ChatModel&lt;/str&gt;
+ * &lt;/processor&gt;
+ * </pre>
+ *
+ * <p>Alternatively, the prompt can be loaded from a text file using {@code promptFile}:
+ *
+ * <pre class="prettyprint" >
+ * &lt;processor class=&quot;solr.llm.documentenrichment.update.processor.DocumentEnrichmentUpdateProcessorFactory&quot;&gt;
+ *   &lt;str name=&quot;inputField&quot;&gt;title_field&lt;/str&gt;
+ *   &lt;str name=&quot;outputField&quot;&gt;enriched_field&lt;/str&gt;
+ *   &lt;str name=&quot;promptFile&quot;&gt;prompt.txt&lt;/str&gt;
+ *   &lt;str name=&quot;model&quot;&gt;ChatModel&lt;/str&gt;
+ * &lt;/processor&gt;
+ * </pre>
+ *
+ * <p>Validation rules:
+ *
+ * <ul>
+ *   <li>At least one {@code inputField} must be declared.
+ *   <li>Exactly one of {@code prompt} or {@code promptFile} must be provided.
+ *   <li>Every declared {@code inputField} must have a corresponding {@code {fieldName}} placeholder
+ *       in the prompt.
+ *   <li>Every {@code {placeholder}} in the prompt must correspond to a declared {@code inputField}.
+ * </ul>
+ */
+public class DocumentEnrichmentUpdateProcessorFactory extends UpdateRequestProcessorFactory
+    implements SolrCoreAware, ManagedResourceObserver {
+  private static final String INPUT_FIELD_PARAM = "inputField";
+  private static final String OUTPUT_FIELD_PARAM = "outputField";
+  private static final String PROMPT = "prompt";
+  private static final String PROMPT_FILE = "promptFile";
+  private static final String MODEL_NAME = "model";
+  private static final Pattern PLACEHOLDER_PATTERN = Pattern.compile("\\{([^}]+)\\}");
+
+  private List<String> inputFields;
+  private String outputField;
+  private String promptText;
+  private String promptFile;
+  private String modelName;
+
+  @Override
+  public void init(final NamedList<?> args) {
+    // removeConfigArgs handles both multiple <str name="inputField"> and <arr name="inputField">
+    // and must be called before toSolrParams() since it mutates args in place
+    Collection<String> fieldNames = args.removeConfigArgs(INPUT_FIELD_PARAM);

Review Comment:
   I added tests and updated the JavaDoc for this since the code was already working without any changes



##########
solr/modules/language-models/src/test-files/modelChatExamples/dummy-chat-model-ambiguous.json:
##########
@@ -0,0 +1,8 @@
+{

Review Comment:
   I added 2 tests that cover these 12 cases (6 for multivalued and 6 for non-multivalued output fields) to be able to remove most of these configurations.



##########
solr/modules/language-models/src/java/org/apache/solr/languagemodels/documentenrichment/update/processor/DocumentEnrichmentUpdateProcessorFactory.java:
##########
@@ -0,0 +1,313 @@
+/*
+ * 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.solr.languagemodels.documentenrichment.update.processor;
+
+import dev.langchain4j.model.chat.request.ResponseFormat;
+import dev.langchain4j.model.chat.request.ResponseFormatType;
+import dev.langchain4j.model.chat.request.json.JsonArraySchema;
+import dev.langchain4j.model.chat.request.json.JsonBooleanSchema;
+import dev.langchain4j.model.chat.request.json.JsonIntegerSchema;
+import dev.langchain4j.model.chat.request.json.JsonNumberSchema;
+import dev.langchain4j.model.chat.request.json.JsonObjectSchema;
+import dev.langchain4j.model.chat.request.json.JsonSchema;
+import dev.langchain4j.model.chat.request.json.JsonSchemaElement;
+import dev.langchain4j.model.chat.request.json.JsonStringSchema;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import java.util.Collection;
+import java.util.HashSet;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import org.apache.solr.common.SolrException;
+import org.apache.solr.common.params.RequiredSolrParams;
+import org.apache.solr.common.params.SolrParams;
+import org.apache.solr.common.util.NamedList;
+import org.apache.solr.core.SolrCore;
+import org.apache.solr.core.SolrResourceLoader;
+import org.apache.solr.languagemodels.documentenrichment.model.SolrChatModel;
+import org.apache.solr.languagemodels.documentenrichment.store.rest.ManagedChatModelStore;
+import org.apache.solr.request.SolrQueryRequest;
+import org.apache.solr.response.SolrQueryResponse;
+import org.apache.solr.rest.ManagedResource;
+import org.apache.solr.rest.ManagedResourceObserver;
+import org.apache.solr.schema.BoolField;
+import org.apache.solr.schema.DatePointField;
+import org.apache.solr.schema.DenseVectorField;
+import org.apache.solr.schema.DoublePointField;
+import org.apache.solr.schema.FieldType;
+import org.apache.solr.schema.FloatPointField;
+import org.apache.solr.schema.IndexSchema;
+import org.apache.solr.schema.IntPointField;
+import org.apache.solr.schema.LongPointField;
+import org.apache.solr.schema.SchemaField;
+import org.apache.solr.schema.StrField;
+import org.apache.solr.schema.TextField;
+import org.apache.solr.update.processor.UpdateRequestProcessor;
+import org.apache.solr.update.processor.UpdateRequestProcessorFactory;
+import org.apache.solr.util.plugin.SolrCoreAware;
+
+/**
+ * Insert in an existing field the output of the model coming from one or more textual field values.
+ *
+ * <p>One or more {@code inputField} parameters specify the Solr fields to use as input. Each field
+ * name must appear as a {@code {fieldName}} placeholder in the prompt. Exactly one of {@code
+ * prompt} or {@code promptFile} must be provided.
+ *
+ * <pre class="prettyprint" >
+ * &lt;processor class=&quot;solr.llm.documentenrichment.update.processor.DocumentEnrichmentUpdateProcessorFactory&quot;&gt;
+ *   &lt;str name=&quot;inputField&quot;&gt;title_field&lt;/str&gt;
+ *   &lt;str name=&quot;inputField&quot;&gt;body_field&lt;/str&gt;
+ *   &lt;str name=&quot;outputField&quot;&gt;enriched_field&lt;/str&gt;
+ *   &lt;str name=&quot;prompt&quot;&gt;Title: {title_field}. Body: {body_field}.&lt;/str&gt;
+ *   &lt;str name=&quot;model&quot;&gt;ChatModel&lt;/str&gt;
+ * &lt;/processor&gt;
+ * </pre>
+ *
+ * <p>Alternatively, the prompt can be loaded from a text file using {@code promptFile}:
+ *
+ * <pre class="prettyprint" >
+ * &lt;processor class=&quot;solr.llm.documentenrichment.update.processor.DocumentEnrichmentUpdateProcessorFactory&quot;&gt;
+ *   &lt;str name=&quot;inputField&quot;&gt;title_field&lt;/str&gt;
+ *   &lt;str name=&quot;outputField&quot;&gt;enriched_field&lt;/str&gt;
+ *   &lt;str name=&quot;promptFile&quot;&gt;prompt.txt&lt;/str&gt;
+ *   &lt;str name=&quot;model&quot;&gt;ChatModel&lt;/str&gt;
+ * &lt;/processor&gt;
+ * </pre>
+ *
+ * <p>Validation rules:
+ *
+ * <ul>
+ *   <li>At least one {@code inputField} must be declared.
+ *   <li>Exactly one of {@code prompt} or {@code promptFile} must be provided.
+ *   <li>Every declared {@code inputField} must have a corresponding {@code {fieldName}} placeholder
+ *       in the prompt.
+ *   <li>Every {@code {placeholder}} in the prompt must correspond to a declared {@code inputField}.
+ * </ul>
+ */
+public class DocumentEnrichmentUpdateProcessorFactory extends UpdateRequestProcessorFactory
+    implements SolrCoreAware, ManagedResourceObserver {
+  private static final String INPUT_FIELD_PARAM = "inputField";
+  private static final String OUTPUT_FIELD_PARAM = "outputField";
+  private static final String PROMPT = "prompt";
+  private static final String PROMPT_FILE = "promptFile";
+  private static final String MODEL_NAME = "model";
+  private static final Pattern PLACEHOLDER_PATTERN = Pattern.compile("\\{([^}]+)\\}");
+
+  private List<String> inputFields;
+  private String outputField;
+  private String promptText;
+  private String promptFile;
+  private String modelName;
+
+  @Override
+  public void init(final NamedList<?> args) {
+    // removeConfigArgs handles both multiple <str name="inputField"> and <arr name="inputField">
+    // and must be called before toSolrParams() since it mutates args in place
+    Collection<String> fieldNames = args.removeConfigArgs(INPUT_FIELD_PARAM);
+    if (fieldNames.isEmpty()) {
+      throw new SolrException(
+          SolrException.ErrorCode.SERVER_ERROR,
+          "At least one 'inputField' must be provided");
+    }
+    inputFields = List.copyOf(fieldNames);
+
+    SolrParams params = args.toSolrParams();
+    RequiredSolrParams required = params.required();
+    outputField = required.get(OUTPUT_FIELD_PARAM);
+    modelName = required.get(MODEL_NAME);
+
+    String inlinePrompt = params.get(PROMPT);
+    String promptFilePath = params.get(PROMPT_FILE);
+
+    if (inlinePrompt == null && promptFilePath == null) {
+      throw new SolrException(
+          SolrException.ErrorCode.SERVER_ERROR,
+          "Either 'prompt' or 'promptFile' must be provided");
+    }
+    if (inlinePrompt != null && promptFilePath != null) {
+      throw new SolrException(
+          SolrException.ErrorCode.SERVER_ERROR,
+          "Only one of 'prompt' or 'promptFile' can be provided, not both");
+    }
+    if (inlinePrompt != null) {
+      validatePromptPlaceholders(inlinePrompt, inputFields);
+      this.promptText = inlinePrompt;
+    }
+    this.promptFile = promptFilePath;
+  }
+
+  @Override
+  public void inform(SolrCore core) {
+    final SolrResourceLoader solrResourceLoader = core.getResourceLoader();
+    ManagedChatModelStore.registerManagedChatModelStore(solrResourceLoader, this);
+    if (promptFile != null) {
+      try (InputStream is = solrResourceLoader.openResource(promptFile)) {
+        promptText = new String(is.readAllBytes(), StandardCharsets.UTF_8).trim();
+      } catch (IOException e) {
+        throw new SolrException(
+            SolrException.ErrorCode.SERVER_ERROR,
+            "Cannot read prompt file: " + promptFile,
+            e);
+      }
+      validatePromptPlaceholders(promptText, inputFields);
+    }
+  }
+
+  @Override
+  public void onManagedResourceInitialized(NamedList<?> args, ManagedResource res)
+      throws SolrException {
+    if (res instanceof ManagedChatModelStore store) {
+      store.loadStoredModels();
+    }
+  }
+
+  @Override
+  public UpdateRequestProcessor getInstance(
+      SolrQueryRequest req, SolrQueryResponse rsp, UpdateRequestProcessor next) {
+    IndexSchema latestSchema = req.getCore().getLatestSchema();
+
+    for (String fieldName : inputFields) {
+      if (!latestSchema.isDynamicField(fieldName) && !latestSchema.hasExplicitField(fieldName)) {
+        throw new SolrException(
+            SolrException.ErrorCode.SERVER_ERROR, "undefined field: \"" + fieldName + "\"");
+      }
+    }
+
+    final SchemaField outputFieldSchema = latestSchema.getField(outputField);
+
+    ResponseFormat responseFormat = buildResponseFormat(outputFieldSchema);
+    boolean multiValued = outputFieldSchema.multiValued();
+
+    ManagedChatModelStore store = ManagedChatModelStore.getManagedModelStore(req.getCore());
+    SolrChatModel chatModel = store.getModel(modelName);
+    if (chatModel == null) {
+      throw new SolrException(
+          SolrException.ErrorCode.SERVER_ERROR,
+          "The model configured in the Update Request Processor '"
+              + modelName
+              + "' can't be found in the store: "
+              + ManagedChatModelStore.REST_END_POINT);
+    }
+
+    return new DocumentEnrichmentUpdateProcessor(
+        inputFields, outputField, promptText, chatModel, multiValued, responseFormat, req, next);
+  }
+
+  /**
+   * Builds a {@link ResponseFormat} that instructs the model to return a JSON object {@code
+   * {"value": ...}} whose value type matches the Solr field type. For multivalued fields the value
+   * is wrapped in a {@link JsonArraySchema} nested inside the root {@link JsonObjectSchema}.
+   *
+   * <p>Nesting {@link JsonArraySchema} inside a {@link JsonObjectSchema} property is supported by
+   * all langchain4j providers that implement structured outputs with {@link JsonObjectSchema} (OpenAI, Azure OpenAI,
+   * Google AI, Gemini, Mistral, Ollama, Amazon Bedrock, Watsonx).
+   */
+  static ResponseFormat buildResponseFormat(SchemaField schemaField) {
+    JsonSchemaElement valueElement = toJsonSchemaElement(schemaField.getType());
+    JsonSchemaElement valueSchema =
+        schemaField.multiValued()
+            ? JsonArraySchema.builder().items(valueElement).build()
+            : valueElement;
+    return ResponseFormat.builder()
+        .type(ResponseFormatType.JSON)
+        .jsonSchema(
+            JsonSchema.builder()
+                .name("output")
+                .rootElement(
+                    JsonObjectSchema.builder()
+                        .addProperty("value", valueSchema)
+                        .required("value")
+                        .build())
+                .build())
+        .build();
+  }
+
+  private static JsonSchemaElement toJsonSchemaElement(FieldType fieldType) {
+    // DenseVectorField extends FloatPointField, so it must be rejected before the numeric checks
+    if (fieldType instanceof DenseVectorField) {
+      throw new SolrException(
+          SolrException.ErrorCode.SERVER_ERROR,
+          "field type is not supported by Document Enrichment: "
+              + fieldType.getClass().getSimpleName());
+    }
+    if (fieldType instanceof StrField
+        || fieldType instanceof TextField
+        || fieldType instanceof DatePointField) {
+      return new JsonStringSchema();
+    } else if (fieldType instanceof IntPointField || fieldType instanceof LongPointField) {
+      return new JsonIntegerSchema();
+    } else if (fieldType instanceof FloatPointField || fieldType instanceof DoublePointField) {
+      return new JsonNumberSchema();
+    } else if (fieldType instanceof BoolField) {
+      return new JsonBooleanSchema();
+    } else {
+      throw new SolrException(
+          SolrException.ErrorCode.SERVER_ERROR,
+          "field type is not supported by Document Enrichment: "
+              + fieldType.getClass().getSimpleName());
+    }
+  }
+
+  private static void validatePromptPlaceholders(String prompt, List<String> fieldNames) {
+    Set<String> promptPlaceholders = new LinkedHashSet<>();
+    Matcher m = PLACEHOLDER_PATTERN.matcher(prompt);
+    while (m.find()) {
+      promptPlaceholders.add(m.group(1));
+    }
+
+    Set<String> missingInPrompt = new LinkedHashSet<>(fieldNames);

Review Comment:
   Changed into `fieldsWithoutPlaceholder`. The logic is almost the same. I wanted to do a one-liner, but it could have been done only streams, that were way less readable



##########
solr/modules/language-models/src/test/org/apache/solr/languagemodels/documentenrichment/update/processor/DocumentEnrichmentUpdateProcessorTest.java:
##########
@@ -0,0 +1,718 @@
+/*
+ * 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.solr.languagemodels.documentenrichment.update.processor;
+
+import java.io.IOException;
+import java.util.Map;
+import org.apache.solr.client.solrj.RemoteSolrException;
+import org.apache.solr.client.solrj.SolrServerException;
+import org.apache.solr.client.solrj.request.SolrQuery;
+import org.apache.solr.client.solrj.request.UpdateRequest;
+import org.apache.solr.common.SolrInputDocument;
+import org.apache.solr.languagemodels.TestLanguageModelBase;
+import org.apache.solr.languagemodels.documentenrichment.store.rest.ManagedChatModelStore;
+import org.junit.After;
+import org.junit.AfterClass;
+import org.junit.BeforeClass;
+import org.junit.Test;
+
+public class DocumentEnrichmentUpdateProcessorTest extends TestLanguageModelBase {
+
+  @BeforeClass
+  public static void init() throws Exception {
+    setupTest("solrconfig-document-enrichment.xml", "schema-language-models.xml", false, false);
+  }
+
+  @AfterClass
+  public static void cleanup() throws Exception {
+    afterTest();
+  }
+
+  private String loadedModelId;
+
+  @After
+  public void afterEachTest() throws Exception {
+    if (loadedModelId != null) {
+      restTestHarness.delete(ManagedChatModelStore.REST_END_POINT + "/" + loadedModelId);
+      loadedModelId = null;
+    }
+  }
+
+  private void loadTestChatModel(String fileName, String modelId) throws Exception {
+    loadChatModel(fileName);
+    loadedModelId = modelId;
+  }
+
+  @Test
+  public void processAdd_inputField_shouldEnrichInputField() throws Exception {
+    loadTestChatModel("dummy-chat-model.json", "dummy-chat-1");
+
+    addWithChain(sdoc("id", "99", "string_field", "Vegeta is the saiyan prince."), "documentEnrichment");
+    addWithChain(sdoc("id", "98", "string_field", "Kakaroth is a saiyan grown up on planet Earth."), "documentEnrichment");
+    assertU(commit());
+
+    final SolrQuery query = getEnrichmentQuery("enriched_field");
+
+    assertJQ(
+        "/query" + query.toQueryString(),
+        "/response/numFound==2]",
+        "/response/docs/[0]/id=='99'",
+        "/response/docs/[0]/enriched_field=='enriched content'",
+        "/response/docs/[1]/id=='98'",
+        "/response/docs/[1]/enriched_field=='enriched content'");
+  }
+
+  /*
+   This test looks for the 'dummy-chat-1' model, but such model is not loaded —
+   the model store is empty, so the update fails.
+  */
+  @Test
+  public void processAdd_modelNotFound_shouldThrowException() {
+    RuntimeException thrown =
+        assertThrows(
+            "model not found should throw an exception",
+            RemoteSolrException.class,
+            () ->
+                addWithChain(
+                    sdoc("id", "99", "string_field", "Vegeta is the saiyan prince."),
+                    "documentEnrichment"));
+    assertTrue(
+        thrown
+            .getMessage()
+            .contains(
+                "The model configured in the Update Request Processor 'dummy-chat-1' can't be found in the store: /schema/chat-model-store"));
+  }
+
+  @Test
+  public void processAdd_emptyInputField_shouldLogAndIndexWithNoEnrichedField() throws Exception {
+    loadTestChatModel("dummy-chat-model.json", "dummy-chat-1");
+    addWithChain(sdoc("id", "99", "string_field", ""), "documentEnrichment");
+    addWithChain(sdoc("id", "98", "string_field", "Vegeta is the saiyan prince."), "documentEnrichment");
+    assertU(commit());
+
+    final SolrQuery query = getEnrichmentQuery("enriched_field");
+
+    assertJQ(
+        "/query" + query.toQueryString(),
+        "/response/numFound==2]",
+        "/response/docs/[0]/id=='99'",
+        "!/response/docs/[0]/enriched_field==", // no enriched field for doc 99
+        "/response/docs/[1]/id=='98'",
+        "/response/docs/[1]/enriched_field=='enriched content'");
+  }
+
+  @Test
+  public void processAdd_nullInputField_shouldLogAndIndexWithNoEnrichedField() throws Exception {
+    loadTestChatModel("dummy-chat-model.json", "dummy-chat-1");
+    addWithChain(sdoc("id", "99", "string_field", "Vegeta is the saiyan prince."), "documentEnrichment");
+    assertU(adoc("id", "98")); // no string_field
+    assertU(commit());
+
+    final SolrQuery query = getEnrichmentQuery("enriched_field");
+
+    assertJQ(
+        "/query" + query.toQueryString(),
+        "/response/numFound==2]",
+        "/response/docs/[0]/id=='99'",
+        "/response/docs/[0]/enriched_field=='enriched content'",
+        "/response/docs/[1]/id=='98'",
+        "!/response/docs/[1]/enriched_field=="); // no enriched field for doc 98
+  }
+
+  @Test
+  public void processAdd_failingEnrichment_shouldLogAndIndexWithNoEnrichedField() throws Exception {
+    loadTestChatModel("exception-throwing-chat-model.json", "exception-throwing-chat-model");
+    addWithChain(sdoc("id", "99", "string_field", "Vegeta is the saiyan prince."), "failingDocumentEnrichment");
+    addWithChain(sdoc("id", "98", "string_field", "Kakaroth is a saiyan grown up on planet Earth."), "failingDocumentEnrichment");
+    assertU(commit());
+
+    final SolrQuery query = getEnrichmentQuery("enriched_field");
+
+    assertJQ(
+        "/query" + query.toQueryString(),
+        "/response/numFound==2]",
+        "/response/docs/[0]/id=='99'",
+        "!/response/docs/[0]/enriched_field==", // no enriched field for doc 99
+        "/response/docs/[1]/id=='98'",
+        "!/response/docs/[1]/enriched_field=="); // no enriched field for doc 98
+  }
+
+  @Test
+  public void processAtomicUpdate_shouldTriggerEnrichmentAndFetchTheStoredContent()
+      throws Exception {
+    // Verifies that when using a processor chain configured for partial updates
+    // (i.e., DistributedUpdateProcessorFactory before DocumentEnrichmentUpdateProcessorFactory),
+    // the system correctly retrieves the stored value of string_field and generates the
+    // enriched content for the document.
+    loadTestChatModel("dummy-chat-model.json", "dummy-chat-1");
+    assertU(adoc("id", "99", "string_field", "Vegeta is the saiyan prince."));
+    assertU(adoc("id", "98", "string_field", "Kakaroth is a saiyan grown up on planet Earth."));
+    assertU(commit());
+
+    SolrInputDocument atomicDoc = new SolrInputDocument();
+    atomicDoc.setField("id", "99");
+    atomicDoc.setField("enriched", Map.of("set", true));
+    addWithChain(atomicDoc, "documentEnrichmentForPartialUpdates");
+    assertU(commit());
+
+    final SolrQuery query = getEnrichmentQuery("enriched_field");
+
+    assertJQ(
+        "/query" + query.toQueryString(),
+        "/response/numFound==2]",
+        "/response/docs/[0]/id=='99'",
+        "/response/docs/[0]/enriched_field=='enriched content'",
+        "/response/docs/[1]/id=='98'",
+        "!/response/docs/[1]/enriched_field==" // no enriched field for document 98
+        );
+  }
+
+  @Test
+  public void processAtomicUpdate_shouldReplaceExistingEnrichedFieldNotAppend() throws Exception {
+    // Verifies that when a document already contains an enriched_field and string_field is
+    // modified via atomic update, the enriched content is recomputed and replaces the previous
+    // value rather than being appended.
+    loadTestChatModel("dummy-chat-model.json", "dummy-chat-1");
+    addWithChain(sdoc("id", "99", "string_field", "Vegeta is the saiyan prince."), "documentEnrichment");
+    addWithChain(sdoc("id", "98", "string_field", "Kakaroth is a saiyan grown up on planet Earth."), "documentEnrichment");
+    assertU(commit());
+
+    SolrInputDocument atomicDoc = new SolrInputDocument();
+    atomicDoc.setField("id", "99");
+    atomicDoc.setField("string_field", Map.of("set", "Vegeta is the saiyan prince from the Dragon Ball series."));
+    addWithChain(atomicDoc, "documentEnrichmentForPartialUpdates");
+    assertU(commit());
+
+    final SolrQuery query = getEnrichmentQuery("enriched_field");
+
+    assertJQ(
+        "/query" + query.toQueryString(),
+        "/response/numFound==2]",
+        "/response/docs/[0]/id=='99'",
+        "/response/docs/[0]/enriched_field=='enriched content'",
+        "/response/docs/[1]/id=='98'",
+        "/response/docs/[1]/enriched_field=='enriched content'");
+  }
+
+  // --- multi-field tests ---
+
+  @Test
+  public void processAdd_multipleInputFields_allPresent_shouldEnrichDocument() throws Exception {
+    loadTestChatModel("dummy-chat-model.json", "dummy-chat-1");
+
+    addWithChain(
+        sdoc("id", "99", "string_field", "Vegeta is the saiyan prince.", "body_field", "He is very proud."),
+        "documentEnrichmentMultiField");
+    addWithChain(
+        sdoc("id", "98", "string_field", "Kakaroth is a saiyan.", "body_field", "He grew up on Earth."),
+        "documentEnrichmentMultiField");
+    assertU(commit());
+
+    final SolrQuery query = getEnrichmentQuery("enriched_field");
+
+    assertJQ(
+        "/query" + query.toQueryString(),
+        "/response/numFound==2]",
+        "/response/docs/[0]/id=='99'",
+        "/response/docs/[0]/enriched_field=='enriched content'",
+        "/response/docs/[1]/id=='98'",
+        "/response/docs/[1]/enriched_field=='enriched content'");
+  }
+
+  @Test
+  public void processAdd_multipleInputFields_firstFieldNull_shouldSkipEnrichment() throws Exception {
+    loadTestChatModel("dummy-chat-model.json", "dummy-chat-1");
+
+    addWithChain(
+        sdoc("id", "99", "body_field", "He is very proud."), // string_field absent
+        "documentEnrichmentMultiField");
+    addWithChain(
+        sdoc("id", "98", "body_field", "He is very jealous."), // string_field absent
+        "documentEnrichmentMultiField");
+    assertU(commit());
+
+    final SolrQuery query = getEnrichmentQuery("enriched_field");
+
+    assertJQ(
+        "/query" + query.toQueryString(),
+        "/response/numFound==2]",
+        "/response/docs/[0]/id=='99'",
+        "!/response/docs/[0]/enriched_field==",
+        "/response/docs/[1]/id=='98'",
+        "!/response/docs/[1]/enriched_field==");
+  }
+
+  @Test
+  public void processAdd_multipleInputFields_secondFieldEmpty_shouldSkipEnrichment() throws Exception {
+    loadTestChatModel("dummy-chat-model.json", "dummy-chat-1");
+
+    addWithChain(
+        sdoc("id", "99", "string_field", "Vegeta is the saiyan prince.", "body_field", ""),
+        "documentEnrichmentMultiField");
+    addWithChain(
+        sdoc("id", "98", "string_field", "Goku is the best saiyan.", "body_field", ""),
+        "documentEnrichmentMultiField");
+    assertU(commit());
+
+    final SolrQuery query = getEnrichmentQuery("enriched_field");
+
+    assertJQ(
+        "/query" + query.toQueryString(),
+        "/response/numFound==2]",
+        "/response/docs/[0]/id=='99'",
+        "!/response/docs/[0]/enriched_field==",
+        "/response/docs/[1]/id=='98'",
+        "!/response/docs/[1]/enriched_field==");
+  }
+
+  @Test
+  public void processAdd_multipleInputFields_bothFieldsAbsent_shouldSkipEnrichment() throws Exception {
+    loadTestChatModel("dummy-chat-model.json", "dummy-chat-1");
+
+    addWithChain(sdoc("id", "99"), "documentEnrichmentMultiField");
+    addWithChain(sdoc("id", "98"), "documentEnrichmentMultiField");
+    assertU(commit());
+
+    final SolrQuery query = getEnrichmentQuery("enriched_field");
+
+    assertJQ(
+        "/query" + query.toQueryString(),
+        "/response/numFound==2]",
+        "/response/docs/[0]/id=='99'",
+        "!/response/docs/[0]/enriched_field==",
+        "/response/docs/[1]/id=='98'",
+        "!/response/docs/[1]/enriched_field==");
+  }
+
+  @Test
+  public void processAdd_multipleInputFields_failingModel_shouldLogAndSkipEnrichment() throws Exception {
+    loadTestChatModel("exception-throwing-chat-model.json", "exception-throwing-chat-model");
+
+    addWithChain(
+        sdoc("id", "99", "string_field", "Vegeta is the saiyan prince.", "body_field", "He is very proud."),
+        "failingDocumentEnrichmentMultiField");
+    addWithChain(
+        sdoc("id", "98", "string_field", "Kakaroth is a saiyan.", "body_field", "He grew up on Earth."),
+        "failingDocumentEnrichmentMultiField");
+    assertU(commit());
+
+    final SolrQuery query = getEnrichmentQuery("enriched_field");
+
+    assertJQ(
+        "/query" + query.toQueryString(),
+        "/response/numFound==2]",
+        "/response/docs/[0]/id=='99'",
+        "!/response/docs/[0]/enriched_field==",
+        "/response/docs/[1]/id=='98'",
+        "!/response/docs/[1]/enriched_field==");
+  }
+
+  @Test
+  public void processAdd_multivaluedStringOutputField_shouldPopulateAllValues() throws Exception {
+    loadTestChatModel("dummy-chat-model-multivalued-string.json", "dummy-chat-multivalued-1");
+
+    addWithChain(
+        sdoc("id", "99", "string_field", "Vegeta is the saiyan prince."),
+        "documentEnrichmentMultivaluedString");
+    addWithChain(
+        sdoc("id", "98", "string_field", "Kakaroth is a saiyan grown up on planet Earth."),
+        "documentEnrichmentMultivaluedString");
+    assertU(commit());
+
+    final SolrQuery query = getEnrichmentQuery("enriched_field_multi");
+
+    assertJQ(
+        "/query" + query.toQueryString(),
+        "/response/numFound==2]",
+        "/response/docs/[0]/id=='99'",
+        "/response/docs/[0]/enriched_field_multi/[0]=='tag1'",
+        "/response/docs/[0]/enriched_field_multi/[1]=='tag2'",
+        "/response/docs/[1]/id=='98'",
+        "/response/docs/[1]/enriched_field_multi/[0]=='tag1'",
+        "/response/docs/[1]/enriched_field_multi/[1]=='tag2'");
+  }
+
+  @Test
+  public void processAdd_multivaluedStringOutputField_emptyInput_shouldSkipEnrichment()
+      throws Exception {
+    loadTestChatModel("dummy-chat-model-multivalued-string.json", "dummy-chat-multivalued-1");
+
+    addWithChain(sdoc("id", "99", "string_field", ""), "documentEnrichmentMultivaluedString");
+    addWithChain(sdoc("id", "98", "string_field", ""), "documentEnrichmentMultivaluedString");
+    assertU(commit());
+
+    final SolrQuery query = getEnrichmentQuery("enriched_field_multi");
+
+    assertJQ(
+        "/query" + query.toQueryString(),
+        "/response/numFound==2]",
+        "/response/docs/[0]/id=='99'",
+        "!/response/docs/[0]/enriched_field_multi==",
+        "/response/docs/[1]/id=='98'",
+        "!/response/docs/[1]/enriched_field_multi==");
+  }
+
+  // --- typed single-valued output field tests ---
+
+  @Test
+  public void processAdd_singleLongOutputField_shouldPopulateValue() throws Exception {
+    loadTestChatModel("dummy-chat-model-single-long.json", "dummy-long");
+
+    addWithChain(sdoc("id", "99", "string_field", "some content"), "documentEnrichmentSingleLong");
+    addWithChain(sdoc("id", "98", "string_field", "other content"), "documentEnrichmentSingleLong");
+    assertU(commit());
+
+    final SolrQuery query = getEnrichmentQuery("output_long");
+
+    assertJQ(
+        "/query" + query.toQueryString(),
+        "/response/numFound==2]",
+        "/response/docs/[0]/id=='99'",
+        "/response/docs/[0]/output_long==42",
+        "/response/docs/[1]/id=='98'",
+        "/response/docs/[1]/output_long==42");
+  }
+
+  @Test
+  public void processAdd_singleIntOutputField_shouldPopulateValue() throws Exception {
+    loadTestChatModel("dummy-chat-model-single-int.json", "dummy-int");
+
+    addWithChain(sdoc("id", "99", "string_field", "some content"), "documentEnrichmentSingleInt");
+    addWithChain(sdoc("id", "98", "string_field", "other content"), "documentEnrichmentSingleInt");
+    assertU(commit());
+
+    final SolrQuery query = getEnrichmentQuery("output_int");
+
+    assertJQ(
+        "/query" + query.toQueryString(),
+        "/response/numFound==2]",
+        "/response/docs/[0]/id=='99'",
+        "/response/docs/[0]/output_int==7",
+        "/response/docs/[1]/id=='98'",
+        "/response/docs/[1]/output_int==7");
+  }
+
+  @Test
+  public void processAdd_singleFloatOutputField_shouldPopulateValue() throws Exception {
+    loadTestChatModel("dummy-chat-model-single-float.json", "dummy-float");
+
+    addWithChain(sdoc("id", "99", "string_field", "some content"), "documentEnrichmentSingleFloat");
+    addWithChain(sdoc("id", "98", "string_field", "other content"), "documentEnrichmentSingleFloat");
+    assertU(commit());
+
+    final SolrQuery query = getEnrichmentQuery("output_float");
+
+    assertJQ(
+        "/query" + query.toQueryString(),
+        "/response/numFound==2]",
+        "/response/docs/[0]/id=='99'",
+        "/response/docs/[0]/output_float==1.5",
+        "/response/docs/[1]/id=='98'",
+        "/response/docs/[1]/output_float==1.5");
+  }
+
+  @Test
+  public void processAdd_singleDoubleOutputField_shouldPopulateValue() throws Exception {
+    loadTestChatModel("dummy-chat-model-single-double.json", "dummy-double");
+
+    addWithChain(
+        sdoc("id", "99", "string_field", "some content"), "documentEnrichmentSingleDouble");
+    addWithChain(
+        sdoc("id", "98", "string_field", "other content"), "documentEnrichmentSingleDouble");
+    assertU(commit());
+
+    final SolrQuery query = getEnrichmentQuery("output_double");
+
+    assertJQ(
+        "/query" + query.toQueryString(),
+        "/response/numFound==2]",
+        "/response/docs/[0]/id=='99'",
+        "/response/docs/[0]/output_double==2.5",
+        "/response/docs/[1]/id=='98'",
+        "/response/docs/[1]/output_double==2.5");
+  }
+
+  @Test
+  public void processAdd_singleBooleanOutputField_shouldPopulateValue() throws Exception {
+    loadTestChatModel("dummy-chat-model-single-boolean.json", "dummy-boolean");
+
+    addWithChain(
+        sdoc("id", "99", "string_field", "some content"), "documentEnrichmentSingleBoolean");
+    addWithChain(
+        sdoc("id", "98", "string_field", "other content"), "documentEnrichmentSingleBoolean");
+    assertU(commit());
+
+    final SolrQuery query = getEnrichmentQuery("output_boolean");
+
+    assertJQ(
+        "/query" + query.toQueryString(),
+        "/response/numFound==2]",
+        "/response/docs/[0]/id=='99'",
+        "/response/docs/[0]/output_boolean==true",
+        "/response/docs/[1]/id=='98'",
+        "/response/docs/[1]/output_boolean==true");
+  }
+
+  @Test
+  public void processAdd_singleDateOutputField_shouldPopulateValue() throws Exception {
+    loadTestChatModel("dummy-chat-model-single-date.json", "dummy-date");
+
+    addWithChain(sdoc("id", "99", "string_field", "some content"), "documentEnrichmentSingleDate");
+    addWithChain(sdoc("id", "98", "string_field", "other content"), "documentEnrichmentSingleDate");
+    assertU(commit());
+
+    final SolrQuery query = getEnrichmentQuery("output_date");
+
+    assertJQ(
+        "/query" + query.toQueryString(),
+        "/response/numFound==2]",
+        "/response/docs/[0]/id=='99'",
+        "/response/docs/[0]/output_date=='2024-01-15T00:00:00Z'",
+        "/response/docs/[1]/id=='98'",
+        "/response/docs/[1]/output_date=='2024-01-15T00:00:00Z'");
+  }
+
+  // --- typed multivalued output field tests ---
+
+  @Test
+  public void processAdd_multivaluedLongOutputField_shouldPopulateAllValues() throws Exception {
+    loadTestChatModel("dummy-chat-model-multivalued-long.json", "dummy-long-multi");
+
+    addWithChain(
+        sdoc("id", "99", "string_field", "some content"), "documentEnrichmentMultivaluedLong");
+    addWithChain(
+        sdoc("id", "98", "string_field", "other content"), "documentEnrichmentMultivaluedLong");
+    assertU(commit());
+
+    final SolrQuery query = getEnrichmentQuery("output_long_multi");
+
+    assertJQ(
+        "/query" + query.toQueryString(),
+        "/response/numFound==2]",
+        "/response/docs/[0]/id=='99'",
+        "/response/docs/[0]/output_long_multi/[0]==10",
+        "/response/docs/[0]/output_long_multi/[1]==20",
+        "/response/docs/[0]/output_long_multi/[2]==30",
+        "/response/docs/[1]/id=='98'",
+        "/response/docs/[1]/output_long_multi/[0]==10",
+        "/response/docs/[1]/output_long_multi/[1]==20",
+        "/response/docs/[1]/output_long_multi/[2]==30");
+  }
+
+  @Test
+  public void processAdd_multivaluedIntOutputField_shouldPopulateAllValues() throws Exception {
+    loadTestChatModel("dummy-chat-model-multivalued-int.json", "dummy-int-multi");
+
+    addWithChain(
+        sdoc("id", "99", "string_field", "some content"), "documentEnrichmentMultivaluedInt");
+    addWithChain(
+        sdoc("id", "98", "string_field", "other content"), "documentEnrichmentMultivaluedInt");
+    assertU(commit());
+
+    final SolrQuery query = getEnrichmentQuery("output_int_multi");
+
+    assertJQ(
+        "/query" + query.toQueryString(),
+        "/response/numFound==2]",
+        "/response/docs/[0]/id=='99'",
+        "/response/docs/[0]/output_int_multi/[0]==1",
+        "/response/docs/[0]/output_int_multi/[1]==2",
+        "/response/docs/[1]/id=='98'",
+        "/response/docs/[1]/output_int_multi/[0]==1",
+        "/response/docs/[1]/output_int_multi/[1]==2");
+  }
+
+  @Test
+  public void processAdd_multivaluedFloatOutputField_shouldPopulateAllValues() throws Exception {
+    loadTestChatModel("dummy-chat-model-multivalued-float.json", "dummy-float-multi");
+
+    addWithChain(
+        sdoc("id", "99", "string_field", "some content"), "documentEnrichmentMultivaluedFloat");
+    addWithChain(
+        sdoc("id", "98", "string_field", "other content"), "documentEnrichmentMultivaluedFloat");
+    assertU(commit());
+
+    final SolrQuery query = getEnrichmentQuery("output_float_multi");
+
+    assertJQ(
+        "/query" + query.toQueryString(),
+        "/response/numFound==2]",
+        "/response/docs/[0]/id=='99'",
+        "/response/docs/[0]/output_float_multi/[0]==1.5",
+        "/response/docs/[0]/output_float_multi/[1]==2.5",
+        "/response/docs/[1]/id=='98'",
+        "/response/docs/[1]/output_float_multi/[0]==1.5",
+        "/response/docs/[1]/output_float_multi/[1]==2.5");
+  }
+
+  @Test
+  public void processAdd_multivaluedDoubleOutputField_shouldPopulateAllValues() throws Exception {
+    loadTestChatModel("dummy-chat-model-multivalued-double.json", "dummy-double-multi");
+
+    addWithChain(
+        sdoc("id", "99", "string_field", "some content"), "documentEnrichmentMultivaluedDouble");
+    addWithChain(
+        sdoc("id", "98", "string_field", "other content"), "documentEnrichmentMultivaluedDouble");
+    assertU(commit());
+
+    final SolrQuery query = getEnrichmentQuery("output_double_multi");
+
+    assertJQ(
+        "/query" + query.toQueryString(),
+        "/response/numFound==2]",
+        "/response/docs/[0]/id=='99'",
+        "/response/docs/[0]/output_double_multi/[0]==3.14",
+        "/response/docs/[0]/output_double_multi/[1]==2.71",
+        "/response/docs/[1]/id=='98'",
+        "/response/docs/[1]/output_double_multi/[0]==3.14",
+        "/response/docs/[1]/output_double_multi/[1]==2.71");
+  }
+
+  @Test
+  public void processAdd_multivaluedBooleanOutputField_shouldPopulateAllValues() throws Exception {
+    loadTestChatModel("dummy-chat-model-multivalued-boolean.json", "dummy-boolean-multi");
+
+    addWithChain(
+        sdoc("id", "99", "string_field", "some content"), "documentEnrichmentMultivaluedBoolean");
+    addWithChain(
+        sdoc("id", "98", "string_field", "other content"), "documentEnrichmentMultivaluedBoolean");
+    assertU(commit());
+
+    final SolrQuery query = getEnrichmentQuery("output_boolean_multi");
+
+    assertJQ(
+        "/query" + query.toQueryString(),
+        "/response/numFound==2]",
+        "/response/docs/[0]/id=='99'",
+        "/response/docs/[0]/output_boolean_multi/[0]==true",
+        "/response/docs/[0]/output_boolean_multi/[1]==false",
+        "/response/docs/[1]/id=='98'",
+        "/response/docs/[1]/output_boolean_multi/[0]==true",
+        "/response/docs/[1]/output_boolean_multi/[1]==false");
+  }
+
+  @Test
+  public void processAdd_multivaluedDateOutputField_shouldPopulateAllValues() throws Exception {
+    loadTestChatModel("dummy-chat-model-multivalued-date.json", "dummy-date-multi");
+
+    addWithChain(
+        sdoc("id", "99", "string_field", "some content"), "documentEnrichmentMultivaluedDate");
+    addWithChain(
+        sdoc("id", "98", "string_field", "other content"), "documentEnrichmentMultivaluedDate");
+    assertU(commit());
+
+    final SolrQuery query = getEnrichmentQuery("output_date_multi");
+
+    assertJQ(
+        "/query" + query.toQueryString(),
+        "/response/numFound==2]",
+        "/response/docs/[0]/id=='99'",
+        "/response/docs/[0]/output_date_multi/[0]=='2024-01-15T00:00:00Z'",
+        "/response/docs/[0]/output_date_multi/[1]=='2025-06-30T00:00:00Z'",
+        "/response/docs/[1]/id=='98'",
+        "/response/docs/[1]/output_date_multi/[0]=='2024-01-15T00:00:00Z'",
+        "/response/docs/[1]/output_date_multi/[1]=='2025-06-30T00:00:00Z'");
+  }
+
+  // --- LLM response contract violation tests ---
+
+  @Test
+  public void processAdd_llmResponseMissingValueKey_shouldLogAndIndexWithNoEnrichedField()
+      throws Exception {
+    // Model returns valid JSON but without the required "value" key
+    loadTestChatModel("dummy-chat-model-missing-value-key.json", "dummy-chat-1");
+
+    addWithChain(sdoc("id", "99", "string_field", "Vegeta is the saiyan prince."), "documentEnrichment");
+    addWithChain(sdoc("id", "98", "string_field", "Kakaroth is a saiyan grown up on planet Earth."), "documentEnrichment");
+    assertU(commit());
+
+    final SolrQuery query = getEnrichmentQuery("enriched_field");
+
+    assertJQ(
+        "/query" + query.toQueryString(),
+        "/response/numFound==2]",
+        "/response/docs/[0]/id=='99'",
+        "!/response/docs/[0]/enriched_field==",
+        "/response/docs/[1]/id=='98'",
+        "!/response/docs/[1]/enriched_field==");
+  }
+
+  @Test
+  public void processAdd_llmResponseMalformedJson_shouldLogAndIndexWithNoEnrichedField()
+      throws Exception {
+    // Model returns a plain string that cannot be parsed as JSON
+    loadTestChatModel("dummy-chat-model-malformed-json.json", "dummy-chat-1");
+
+    addWithChain(sdoc("id", "99", "string_field", "Vegeta is the saiyan prince."), "documentEnrichment");
+    addWithChain(sdoc("id", "98", "string_field", "Kakaroth is a saiyan grown up on planet Earth."), "documentEnrichment");
+    assertU(commit());
+
+    final SolrQuery query = getEnrichmentQuery("enriched_field");
+
+    assertJQ(
+        "/query" + query.toQueryString(),
+        "/response/numFound==2]",
+        "/response/docs/[0]/id=='99'",
+        "!/response/docs/[0]/enriched_field==",
+        "/response/docs/[1]/id=='98'",
+        "!/response/docs/[1]/enriched_field==");
+  }
+
+  // --- multivalued output field / scalar response test ---
+
+  @Test
+  public void processAdd_multivaluedOutputField_scalarLlmResponse_shouldStoreSingleValue()

Review Comment:
   renamed



##########
solr/modules/language-models/src/test-files/solr/collection1/conf/solrconfig-document-enrichment-update-request-processor-only.xml:
##########
@@ -0,0 +1,62 @@
+<?xml version="1.0" ?>

Review Comment:
   Yes, I was following the other module, but this is basically useless. I thought that the main reason this is here is that if a user wants to see how it works, it can be seen in this very minimal file. I don't see a reason other than that. I removed it for now



##########
solr/modules/language-models/src/test/org/apache/solr/languagemodels/documentenrichment/store/rest/TestChatModelManagerPersistence.java:
##########
@@ -0,0 +1,101 @@
+/*
+ * 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.solr.languagemodels.documentenrichment.store.rest;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import org.apache.solr.common.util.Utils;
+import org.apache.solr.languagemodels.TestLanguageModelBase;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+
+public class TestChatModelManagerPersistence extends TestLanguageModelBase {
+
+  @Before
+  public void init() throws Exception {
+    setupTest("solrconfig-document-enrichment.xml", "schema-language-models.xml", false, true);
+  }
+
+  @After
+  public void cleanup() throws Exception {
+    afterTest();
+  }
+
+  @Test
+  public void testModelAreStoredCompact() throws Exception {
+    loadChatModel("openai-model.json");
+
+    final String JSONOnDisk = Files.readString(chatModelStoreFile, StandardCharsets.UTF_8);
+    Object objectFromDisk = Utils.fromJSONString(JSONOnDisk);
+    assertEquals(new String(Utils.toJSON(objectFromDisk, -1), UTF_8), JSONOnDisk);
+  }
+
+  @Test
+  public void testModelStorePersistence() throws Exception {
+    // check store is empty at start
+    assertJQ(ManagedChatModelStore.REST_END_POINT, "/models/==[]");
+
+    // load a model
+    loadChatModel("openai-model.json");
+
+    final String modelName = "openai-1";
+    assertJQ(ManagedChatModelStore.REST_END_POINT, "/models/[0]/name=='" + modelName + "'");
+    assertJQ(
+        ManagedChatModelStore.REST_END_POINT,
+        "/models/[0]/params/baseUrl=='https://api.openai.com/v1'");
+    assertJQ(
+        ManagedChatModelStore.REST_END_POINT, "/models/[0]/params/apiKey=='apiKey-openAI'");
+    assertJQ(
+        ManagedChatModelStore.REST_END_POINT, "/models/[0]/params/modelName=='gpt-5.4-nano'");
+    assertJQ(ManagedChatModelStore.REST_END_POINT, "/models/[0]/params/timeout==60");
+    assertJQ(ManagedChatModelStore.REST_END_POINT, "/models/[0]/params/logRequests==true");
+    assertJQ(ManagedChatModelStore.REST_END_POINT, "/models/[0]/params/logResponses==true");
+    assertJQ(ManagedChatModelStore.REST_END_POINT, "/models/[0]/params/maxRetries==5");
+
+    // check persistence after reload
+    restTestHarness.reload();
+    assertJQ(ManagedChatModelStore.REST_END_POINT, "/models/[0]/name=='" + modelName + "'");

Review Comment:
   added



##########
solr/modules/language-models/src/java/org/apache/solr/languagemodels/documentenrichment/update/processor/DocumentEnrichmentUpdateProcessorFactory.java:
##########
@@ -0,0 +1,313 @@
+/*
+ * 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.solr.languagemodels.documentenrichment.update.processor;
+
+import dev.langchain4j.model.chat.request.ResponseFormat;
+import dev.langchain4j.model.chat.request.ResponseFormatType;
+import dev.langchain4j.model.chat.request.json.JsonArraySchema;
+import dev.langchain4j.model.chat.request.json.JsonBooleanSchema;
+import dev.langchain4j.model.chat.request.json.JsonIntegerSchema;
+import dev.langchain4j.model.chat.request.json.JsonNumberSchema;
+import dev.langchain4j.model.chat.request.json.JsonObjectSchema;
+import dev.langchain4j.model.chat.request.json.JsonSchema;
+import dev.langchain4j.model.chat.request.json.JsonSchemaElement;
+import dev.langchain4j.model.chat.request.json.JsonStringSchema;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import java.util.Collection;
+import java.util.HashSet;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import org.apache.solr.common.SolrException;
+import org.apache.solr.common.params.RequiredSolrParams;
+import org.apache.solr.common.params.SolrParams;
+import org.apache.solr.common.util.NamedList;
+import org.apache.solr.core.SolrCore;
+import org.apache.solr.core.SolrResourceLoader;
+import org.apache.solr.languagemodels.documentenrichment.model.SolrChatModel;
+import org.apache.solr.languagemodels.documentenrichment.store.rest.ManagedChatModelStore;
+import org.apache.solr.request.SolrQueryRequest;
+import org.apache.solr.response.SolrQueryResponse;
+import org.apache.solr.rest.ManagedResource;
+import org.apache.solr.rest.ManagedResourceObserver;
+import org.apache.solr.schema.BoolField;
+import org.apache.solr.schema.DatePointField;
+import org.apache.solr.schema.DenseVectorField;
+import org.apache.solr.schema.DoublePointField;
+import org.apache.solr.schema.FieldType;
+import org.apache.solr.schema.FloatPointField;
+import org.apache.solr.schema.IndexSchema;
+import org.apache.solr.schema.IntPointField;
+import org.apache.solr.schema.LongPointField;
+import org.apache.solr.schema.SchemaField;
+import org.apache.solr.schema.StrField;
+import org.apache.solr.schema.TextField;
+import org.apache.solr.update.processor.UpdateRequestProcessor;
+import org.apache.solr.update.processor.UpdateRequestProcessorFactory;
+import org.apache.solr.util.plugin.SolrCoreAware;
+
+/**
+ * Insert in an existing field the output of the model coming from one or more textual field values.

Review Comment:
   Changed



##########
solr/modules/language-models/src/java/org/apache/solr/languagemodels/documentenrichment/update/processor/DocumentEnrichmentUpdateProcessorFactory.java:
##########
@@ -0,0 +1,313 @@
+/*
+ * 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.solr.languagemodels.documentenrichment.update.processor;
+
+import dev.langchain4j.model.chat.request.ResponseFormat;
+import dev.langchain4j.model.chat.request.ResponseFormatType;
+import dev.langchain4j.model.chat.request.json.JsonArraySchema;
+import dev.langchain4j.model.chat.request.json.JsonBooleanSchema;
+import dev.langchain4j.model.chat.request.json.JsonIntegerSchema;
+import dev.langchain4j.model.chat.request.json.JsonNumberSchema;
+import dev.langchain4j.model.chat.request.json.JsonObjectSchema;
+import dev.langchain4j.model.chat.request.json.JsonSchema;
+import dev.langchain4j.model.chat.request.json.JsonSchemaElement;
+import dev.langchain4j.model.chat.request.json.JsonStringSchema;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import java.util.Collection;
+import java.util.HashSet;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import org.apache.solr.common.SolrException;
+import org.apache.solr.common.params.RequiredSolrParams;
+import org.apache.solr.common.params.SolrParams;
+import org.apache.solr.common.util.NamedList;
+import org.apache.solr.core.SolrCore;
+import org.apache.solr.core.SolrResourceLoader;
+import org.apache.solr.languagemodels.documentenrichment.model.SolrChatModel;
+import org.apache.solr.languagemodels.documentenrichment.store.rest.ManagedChatModelStore;
+import org.apache.solr.request.SolrQueryRequest;
+import org.apache.solr.response.SolrQueryResponse;
+import org.apache.solr.rest.ManagedResource;
+import org.apache.solr.rest.ManagedResourceObserver;
+import org.apache.solr.schema.BoolField;
+import org.apache.solr.schema.DatePointField;
+import org.apache.solr.schema.DenseVectorField;
+import org.apache.solr.schema.DoublePointField;
+import org.apache.solr.schema.FieldType;
+import org.apache.solr.schema.FloatPointField;
+import org.apache.solr.schema.IndexSchema;
+import org.apache.solr.schema.IntPointField;
+import org.apache.solr.schema.LongPointField;
+import org.apache.solr.schema.SchemaField;
+import org.apache.solr.schema.StrField;
+import org.apache.solr.schema.TextField;
+import org.apache.solr.update.processor.UpdateRequestProcessor;
+import org.apache.solr.update.processor.UpdateRequestProcessorFactory;
+import org.apache.solr.util.plugin.SolrCoreAware;
+
+/**
+ * Insert in an existing field the output of the model coming from one or more textual field values.
+ *
+ * <p>One or more {@code inputField} parameters specify the Solr fields to use as input. Each field
+ * name must appear as a {@code {fieldName}} placeholder in the prompt. Exactly one of {@code
+ * prompt} or {@code promptFile} must be provided.
+ *
+ * <pre class="prettyprint" >
+ * &lt;processor class=&quot;solr.llm.documentenrichment.update.processor.DocumentEnrichmentUpdateProcessorFactory&quot;&gt;
+ *   &lt;str name=&quot;inputField&quot;&gt;title_field&lt;/str&gt;
+ *   &lt;str name=&quot;inputField&quot;&gt;body_field&lt;/str&gt;
+ *   &lt;str name=&quot;outputField&quot;&gt;enriched_field&lt;/str&gt;
+ *   &lt;str name=&quot;prompt&quot;&gt;Title: {title_field}. Body: {body_field}.&lt;/str&gt;
+ *   &lt;str name=&quot;model&quot;&gt;ChatModel&lt;/str&gt;
+ * &lt;/processor&gt;
+ * </pre>
+ *
+ * <p>Alternatively, the prompt can be loaded from a text file using {@code promptFile}:
+ *
+ * <pre class="prettyprint" >
+ * &lt;processor class=&quot;solr.llm.documentenrichment.update.processor.DocumentEnrichmentUpdateProcessorFactory&quot;&gt;
+ *   &lt;str name=&quot;inputField&quot;&gt;title_field&lt;/str&gt;
+ *   &lt;str name=&quot;outputField&quot;&gt;enriched_field&lt;/str&gt;
+ *   &lt;str name=&quot;promptFile&quot;&gt;prompt.txt&lt;/str&gt;
+ *   &lt;str name=&quot;model&quot;&gt;ChatModel&lt;/str&gt;
+ * &lt;/processor&gt;
+ * </pre>
+ *
+ * <p>Validation rules:
+ *
+ * <ul>
+ *   <li>At least one {@code inputField} must be declared.
+ *   <li>Exactly one of {@code prompt} or {@code promptFile} must be provided.
+ *   <li>Every declared {@code inputField} must have a corresponding {@code {fieldName}} placeholder
+ *       in the prompt.
+ *   <li>Every {@code {placeholder}} in the prompt must correspond to a declared {@code inputField}.
+ * </ul>
+ */
+public class DocumentEnrichmentUpdateProcessorFactory extends UpdateRequestProcessorFactory
+    implements SolrCoreAware, ManagedResourceObserver {
+  private static final String INPUT_FIELD_PARAM = "inputField";
+  private static final String OUTPUT_FIELD_PARAM = "outputField";
+  private static final String PROMPT = "prompt";
+  private static final String PROMPT_FILE = "promptFile";
+  private static final String MODEL_NAME = "model";
+  private static final Pattern PLACEHOLDER_PATTERN = Pattern.compile("\\{([^}]+)\\}");
+
+  private List<String> inputFields;
+  private String outputField;
+  private String promptText;
+  private String promptFile;
+  private String modelName;
+
+  @Override
+  public void init(final NamedList<?> args) {
+    // removeConfigArgs handles both multiple <str name="inputField"> and <arr name="inputField">
+    // and must be called before toSolrParams() since it mutates args in place
+    Collection<String> fieldNames = args.removeConfigArgs(INPUT_FIELD_PARAM);
+    if (fieldNames.isEmpty()) {
+      throw new SolrException(
+          SolrException.ErrorCode.SERVER_ERROR,
+          "At least one 'inputField' must be provided");
+    }
+    inputFields = List.copyOf(fieldNames);
+
+    SolrParams params = args.toSolrParams();
+    RequiredSolrParams required = params.required();
+    outputField = required.get(OUTPUT_FIELD_PARAM);
+    modelName = required.get(MODEL_NAME);
+
+    String inlinePrompt = params.get(PROMPT);
+    String promptFilePath = params.get(PROMPT_FILE);
+
+    if (inlinePrompt == null && promptFilePath == null) {
+      throw new SolrException(
+          SolrException.ErrorCode.SERVER_ERROR,
+          "Either 'prompt' or 'promptFile' must be provided");
+    }
+    if (inlinePrompt != null && promptFilePath != null) {
+      throw new SolrException(
+          SolrException.ErrorCode.SERVER_ERROR,
+          "Only one of 'prompt' or 'promptFile' can be provided, not both");
+    }
+    if (inlinePrompt != null) {
+      validatePromptPlaceholders(inlinePrompt, inputFields);
+      this.promptText = inlinePrompt;
+    }
+    this.promptFile = promptFilePath;
+  }
+
+  @Override
+  public void inform(SolrCore core) {
+    final SolrResourceLoader solrResourceLoader = core.getResourceLoader();
+    ManagedChatModelStore.registerManagedChatModelStore(solrResourceLoader, this);
+    if (promptFile != null) {
+      try (InputStream is = solrResourceLoader.openResource(promptFile)) {
+        promptText = new String(is.readAllBytes(), StandardCharsets.UTF_8).trim();
+      } catch (IOException e) {
+        throw new SolrException(
+            SolrException.ErrorCode.SERVER_ERROR,
+            "Cannot read prompt file: " + promptFile,
+            e);
+      }
+      validatePromptPlaceholders(promptText, inputFields);
+    }
+  }
+
+  @Override
+  public void onManagedResourceInitialized(NamedList<?> args, ManagedResource res)
+      throws SolrException {
+    if (res instanceof ManagedChatModelStore store) {
+      store.loadStoredModels();
+    }
+  }
+
+  @Override
+  public UpdateRequestProcessor getInstance(
+      SolrQueryRequest req, SolrQueryResponse rsp, UpdateRequestProcessor next) {
+    IndexSchema latestSchema = req.getCore().getLatestSchema();
+
+    for (String fieldName : inputFields) {
+      if (!latestSchema.isDynamicField(fieldName) && !latestSchema.hasExplicitField(fieldName)) {
+        throw new SolrException(
+            SolrException.ErrorCode.SERVER_ERROR, "undefined field: \"" + fieldName + "\"");
+      }
+    }
+
+    final SchemaField outputFieldSchema = latestSchema.getField(outputField);
+
+    ResponseFormat responseFormat = buildResponseFormat(outputFieldSchema);
+    boolean multiValued = outputFieldSchema.multiValued();
+
+    ManagedChatModelStore store = ManagedChatModelStore.getManagedModelStore(req.getCore());
+    SolrChatModel chatModel = store.getModel(modelName);
+    if (chatModel == null) {
+      throw new SolrException(
+          SolrException.ErrorCode.SERVER_ERROR,
+          "The model configured in the Update Request Processor '"
+              + modelName
+              + "' can't be found in the store: "
+              + ManagedChatModelStore.REST_END_POINT);
+    }
+
+    return new DocumentEnrichmentUpdateProcessor(
+        inputFields, outputField, promptText, chatModel, multiValued, responseFormat, req, next);
+  }
+
+  /**
+   * Builds a {@link ResponseFormat} that instructs the model to return a JSON object {@code
+   * {"value": ...}} whose value type matches the Solr field type. For multivalued fields the value
+   * is wrapped in a {@link JsonArraySchema} nested inside the root {@link JsonObjectSchema}.
+   *
+   * <p>Nesting {@link JsonArraySchema} inside a {@link JsonObjectSchema} property is supported by
+   * all langchain4j providers that implement structured outputs with {@link JsonObjectSchema} (OpenAI, Azure OpenAI,
+   * Google AI, Gemini, Mistral, Ollama, Amazon Bedrock, Watsonx).
+   */
+  static ResponseFormat buildResponseFormat(SchemaField schemaField) {
+    JsonSchemaElement valueElement = toJsonSchemaElement(schemaField.getType());
+    JsonSchemaElement valueSchema =
+        schemaField.multiValued()
+            ? JsonArraySchema.builder().items(valueElement).build()
+            : valueElement;
+    return ResponseFormat.builder()
+        .type(ResponseFormatType.JSON)
+        .jsonSchema(
+            JsonSchema.builder()
+                .name("output")
+                .rootElement(
+                    JsonObjectSchema.builder()
+                        .addProperty("value", valueSchema)
+                        .required("value")
+                        .build())
+                .build())
+        .build();
+  }
+
+  private static JsonSchemaElement toJsonSchemaElement(FieldType fieldType) {
+    // DenseVectorField extends FloatPointField, so it must be rejected before the numeric checks
+    if (fieldType instanceof DenseVectorField) {
+      throw new SolrException(
+          SolrException.ErrorCode.SERVER_ERROR,
+          "field type is not supported by Document Enrichment: "
+              + fieldType.getClass().getSimpleName());
+    }
+    if (fieldType instanceof StrField
+        || fieldType instanceof TextField
+        || fieldType instanceof DatePointField) {
+      return new JsonStringSchema();
+    } else if (fieldType instanceof IntPointField || fieldType instanceof LongPointField) {
+      return new JsonIntegerSchema();
+    } else if (fieldType instanceof FloatPointField || fieldType instanceof DoublePointField) {
+      return new JsonNumberSchema();
+    } else if (fieldType instanceof BoolField) {
+      return new JsonBooleanSchema();
+    } else {
+      throw new SolrException(
+          SolrException.ErrorCode.SERVER_ERROR,
+          "field type is not supported by Document Enrichment: "
+              + fieldType.getClass().getSimpleName());
+    }
+  }
+
+  private static void validatePromptPlaceholders(String prompt, List<String> fieldNames) {
+    Set<String> promptPlaceholders = new LinkedHashSet<>();
+    Matcher m = PLACEHOLDER_PATTERN.matcher(prompt);
+    while (m.find()) {
+      promptPlaceholders.add(m.group(1));
+    }
+
+    Set<String> missingInPrompt = new LinkedHashSet<>(fieldNames);
+    missingInPrompt.removeAll(promptPlaceholders);
+    if (!missingInPrompt.isEmpty()) {
+      throw new SolrException(
+          SolrException.ErrorCode.SERVER_ERROR,
+          "prompt is missing placeholders for inputField(s): " + missingInPrompt);
+    }
+
+    Set<String> unknownInPrompt = new LinkedHashSet<>(promptPlaceholders);
+    unknownInPrompt.removeAll(new HashSet<>(fieldNames));

Review Comment:
   Everything `HashSet` now, since `LinkedHashSet` keeps double-links between elements to keep insertion order. Not needed here.



##########
solr/modules/language-models/src/test/org/apache/solr/languagemodels/documentenrichment/store/rest/TestChatModelManager.java:
##########
@@ -0,0 +1,239 @@
+/*
+ * 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.solr.languagemodels.documentenrichment.store.rest;
+
+import org.apache.solr.common.util.NamedList;
+import org.apache.solr.core.SolrResourceLoader;
+import org.apache.solr.languagemodels.TestLanguageModelBase;
+import org.apache.solr.languagemodels.documentenrichment.update.processor.DocumentEnrichmentUpdateProcessorFactory;
+import org.apache.solr.rest.ManagedResource;
+import org.apache.solr.rest.ManagedResourceStorage;
+import org.apache.solr.rest.RestManager;
+import org.junit.AfterClass;
+import org.junit.BeforeClass;
+import org.junit.Test;
+
+public class TestChatModelManager extends TestLanguageModelBase {
+
+  @BeforeClass
+  public static void init() throws Exception {
+    setupTest("solrconfig-document-enrichment.xml", "schema-language-models.xml", false, false);
+  }
+
+  @AfterClass
+  public static void cleanup() throws Exception {
+    afterTest();
+  }
+
+  @Test
+  public void test() throws Exception {
+    final SolrResourceLoader loader = new SolrResourceLoader(tmpSolrHome);
+
+    final RestManager.Registry registry = loader.getManagedResourceRegistry();
+    assertNotNull(
+        "Expected a non-null RestManager.Registry from the SolrResourceLoader!", registry);
+
+    final String resourceId = "/schema/mstore1";
+    registry.registerManagedResource(
+        resourceId, ManagedChatModelStore.class, new DocumentEnrichmentUpdateProcessorFactory());
+
+    final NamedList<String> initArgs = new NamedList<>();
+
+    final RestManager restManager = new RestManager();
+    restManager.init(loader, initArgs, new ManagedResourceStorage.InMemoryStorageIO());
+
+    final ManagedResource res = restManager.getManagedResource(resourceId);
+    assertTrue(res instanceof ManagedChatModelStore);
+    assertEquals(res.getResourceId(), resourceId);
+  }
+
+  @Test
+  public void testRestManagerEndpoints() throws Exception {
+    assertJQ("/schema/managed", "/responseHeader/status==0");
+
+    final String openAiClassName = "dev.langchain4j.model.openai.OpenAiChatModel";

Review Comment:
   I removed this intentionally, since LangChain4j deals differently with chat models and embedding models. `ChatModel` doesn't raise an error if `params` is empty, but `EmbeddingModel` does



##########
solr/modules/language-models/src/test/org/apache/solr/languagemodels/documentenrichment/update/processor/DocumentEnrichmentUpdateProcessorTest.java:
##########
@@ -0,0 +1,718 @@
+/*
+ * 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.solr.languagemodels.documentenrichment.update.processor;
+
+import java.io.IOException;
+import java.util.Map;
+import org.apache.solr.client.solrj.RemoteSolrException;
+import org.apache.solr.client.solrj.SolrServerException;
+import org.apache.solr.client.solrj.request.SolrQuery;
+import org.apache.solr.client.solrj.request.UpdateRequest;
+import org.apache.solr.common.SolrInputDocument;
+import org.apache.solr.languagemodels.TestLanguageModelBase;
+import org.apache.solr.languagemodels.documentenrichment.store.rest.ManagedChatModelStore;
+import org.junit.After;
+import org.junit.AfterClass;
+import org.junit.BeforeClass;
+import org.junit.Test;
+
+public class DocumentEnrichmentUpdateProcessorTest extends TestLanguageModelBase {
+
+  @BeforeClass
+  public static void init() throws Exception {
+    setupTest("solrconfig-document-enrichment.xml", "schema-language-models.xml", false, false);
+  }
+
+  @AfterClass
+  public static void cleanup() throws Exception {
+    afterTest();
+  }
+
+  private String loadedModelId;
+
+  @After
+  public void afterEachTest() throws Exception {
+    if (loadedModelId != null) {
+      restTestHarness.delete(ManagedChatModelStore.REST_END_POINT + "/" + loadedModelId);
+      loadedModelId = null;
+    }
+  }
+
+  private void loadTestChatModel(String fileName, String modelId) throws Exception {
+    loadChatModel(fileName);
+    loadedModelId = modelId;
+  }
+
+  @Test
+  public void processAdd_inputField_shouldEnrichInputField() throws Exception {
+    loadTestChatModel("dummy-chat-model.json", "dummy-chat-1");
+
+    addWithChain(sdoc("id", "99", "string_field", "Vegeta is the saiyan prince."), "documentEnrichment");
+    addWithChain(sdoc("id", "98", "string_field", "Kakaroth is a saiyan grown up on planet Earth."), "documentEnrichment");
+    assertU(commit());
+
+    final SolrQuery query = getEnrichmentQuery("enriched_field");
+
+    assertJQ(
+        "/query" + query.toQueryString(),
+        "/response/numFound==2]",
+        "/response/docs/[0]/id=='99'",
+        "/response/docs/[0]/enriched_field=='enriched content'",
+        "/response/docs/[1]/id=='98'",
+        "/response/docs/[1]/enriched_field=='enriched content'");
+  }
+
+  /*
+   This test looks for the 'dummy-chat-1' model, but such model is not loaded —
+   the model store is empty, so the update fails.
+  */
+  @Test
+  public void processAdd_modelNotFound_shouldThrowException() {
+    RuntimeException thrown =
+        assertThrows(
+            "model not found should throw an exception",
+            RemoteSolrException.class,
+            () ->
+                addWithChain(
+                    sdoc("id", "99", "string_field", "Vegeta is the saiyan prince."),
+                    "documentEnrichment"));
+    assertTrue(
+        thrown
+            .getMessage()
+            .contains(
+                "The model configured in the Update Request Processor 'dummy-chat-1' can't be found in the store: /schema/chat-model-store"));
+  }
+
+  @Test
+  public void processAdd_emptyInputField_shouldLogAndIndexWithNoEnrichedField() throws Exception {
+    loadTestChatModel("dummy-chat-model.json", "dummy-chat-1");
+    addWithChain(sdoc("id", "99", "string_field", ""), "documentEnrichment");
+    addWithChain(sdoc("id", "98", "string_field", "Vegeta is the saiyan prince."), "documentEnrichment");
+    assertU(commit());
+
+    final SolrQuery query = getEnrichmentQuery("enriched_field");
+
+    assertJQ(
+        "/query" + query.toQueryString(),
+        "/response/numFound==2]",
+        "/response/docs/[0]/id=='99'",
+        "!/response/docs/[0]/enriched_field==", // no enriched field for doc 99
+        "/response/docs/[1]/id=='98'",
+        "/response/docs/[1]/enriched_field=='enriched content'");
+  }
+
+  @Test
+  public void processAdd_nullInputField_shouldLogAndIndexWithNoEnrichedField() throws Exception {
+    loadTestChatModel("dummy-chat-model.json", "dummy-chat-1");
+    addWithChain(sdoc("id", "99", "string_field", "Vegeta is the saiyan prince."), "documentEnrichment");
+    assertU(adoc("id", "98")); // no string_field
+    assertU(commit());
+
+    final SolrQuery query = getEnrichmentQuery("enriched_field");
+
+    assertJQ(
+        "/query" + query.toQueryString(),
+        "/response/numFound==2]",
+        "/response/docs/[0]/id=='99'",
+        "/response/docs/[0]/enriched_field=='enriched content'",
+        "/response/docs/[1]/id=='98'",
+        "!/response/docs/[1]/enriched_field=="); // no enriched field for doc 98
+  }
+
+  @Test
+  public void processAdd_failingEnrichment_shouldLogAndIndexWithNoEnrichedField() throws Exception {
+    loadTestChatModel("exception-throwing-chat-model.json", "exception-throwing-chat-model");
+    addWithChain(sdoc("id", "99", "string_field", "Vegeta is the saiyan prince."), "failingDocumentEnrichment");
+    addWithChain(sdoc("id", "98", "string_field", "Kakaroth is a saiyan grown up on planet Earth."), "failingDocumentEnrichment");
+    assertU(commit());
+
+    final SolrQuery query = getEnrichmentQuery("enriched_field");
+
+    assertJQ(
+        "/query" + query.toQueryString(),
+        "/response/numFound==2]",
+        "/response/docs/[0]/id=='99'",
+        "!/response/docs/[0]/enriched_field==", // no enriched field for doc 99
+        "/response/docs/[1]/id=='98'",
+        "!/response/docs/[1]/enriched_field=="); // no enriched field for doc 98
+  }
+
+  @Test
+  public void processAtomicUpdate_shouldTriggerEnrichmentAndFetchTheStoredContent()
+      throws Exception {
+    // Verifies that when using a processor chain configured for partial updates
+    // (i.e., DistributedUpdateProcessorFactory before DocumentEnrichmentUpdateProcessorFactory),
+    // the system correctly retrieves the stored value of string_field and generates the
+    // enriched content for the document.
+    loadTestChatModel("dummy-chat-model.json", "dummy-chat-1");
+    assertU(adoc("id", "99", "string_field", "Vegeta is the saiyan prince."));
+    assertU(adoc("id", "98", "string_field", "Kakaroth is a saiyan grown up on planet Earth."));
+    assertU(commit());
+
+    SolrInputDocument atomicDoc = new SolrInputDocument();
+    atomicDoc.setField("id", "99");
+    atomicDoc.setField("enriched", Map.of("set", true));
+    addWithChain(atomicDoc, "documentEnrichmentForPartialUpdates");
+    assertU(commit());
+
+    final SolrQuery query = getEnrichmentQuery("enriched_field");
+
+    assertJQ(
+        "/query" + query.toQueryString(),
+        "/response/numFound==2]",
+        "/response/docs/[0]/id=='99'",
+        "/response/docs/[0]/enriched_field=='enriched content'",
+        "/response/docs/[1]/id=='98'",
+        "!/response/docs/[1]/enriched_field==" // no enriched field for document 98
+        );
+  }
+
+  @Test
+  public void processAtomicUpdate_shouldReplaceExistingEnrichedFieldNotAppend() throws Exception {
+    // Verifies that when a document already contains an enriched_field and string_field is
+    // modified via atomic update, the enriched content is recomputed and replaces the previous
+    // value rather than being appended.
+    loadTestChatModel("dummy-chat-model.json", "dummy-chat-1");
+    addWithChain(sdoc("id", "99", "string_field", "Vegeta is the saiyan prince."), "documentEnrichment");
+    addWithChain(sdoc("id", "98", "string_field", "Kakaroth is a saiyan grown up on planet Earth."), "documentEnrichment");
+    assertU(commit());
+
+    SolrInputDocument atomicDoc = new SolrInputDocument();
+    atomicDoc.setField("id", "99");
+    atomicDoc.setField("string_field", Map.of("set", "Vegeta is the saiyan prince from the Dragon Ball series."));
+    addWithChain(atomicDoc, "documentEnrichmentForPartialUpdates");
+    assertU(commit());
+
+    final SolrQuery query = getEnrichmentQuery("enriched_field");
+
+    assertJQ(
+        "/query" + query.toQueryString(),
+        "/response/numFound==2]",
+        "/response/docs/[0]/id=='99'",
+        "/response/docs/[0]/enriched_field=='enriched content'",
+        "/response/docs/[1]/id=='98'",
+        "/response/docs/[1]/enriched_field=='enriched content'");
+  }
+
+  // --- multi-field tests ---
+
+  @Test
+  public void processAdd_multipleInputFields_allPresent_shouldEnrichDocument() throws Exception {
+    loadTestChatModel("dummy-chat-model.json", "dummy-chat-1");
+
+    addWithChain(
+        sdoc("id", "99", "string_field", "Vegeta is the saiyan prince.", "body_field", "He is very proud."),
+        "documentEnrichmentMultiField");
+    addWithChain(
+        sdoc("id", "98", "string_field", "Kakaroth is a saiyan.", "body_field", "He grew up on Earth."),
+        "documentEnrichmentMultiField");
+    assertU(commit());
+
+    final SolrQuery query = getEnrichmentQuery("enriched_field");
+
+    assertJQ(
+        "/query" + query.toQueryString(),
+        "/response/numFound==2]",
+        "/response/docs/[0]/id=='99'",
+        "/response/docs/[0]/enriched_field=='enriched content'",
+        "/response/docs/[1]/id=='98'",
+        "/response/docs/[1]/enriched_field=='enriched content'");
+  }
+
+  @Test
+  public void processAdd_multipleInputFields_firstFieldNull_shouldSkipEnrichment() throws Exception {
+    loadTestChatModel("dummy-chat-model.json", "dummy-chat-1");
+
+    addWithChain(
+        sdoc("id", "99", "body_field", "He is very proud."), // string_field absent
+        "documentEnrichmentMultiField");
+    addWithChain(
+        sdoc("id", "98", "body_field", "He is very jealous."), // string_field absent
+        "documentEnrichmentMultiField");
+    assertU(commit());
+
+    final SolrQuery query = getEnrichmentQuery("enriched_field");
+
+    assertJQ(
+        "/query" + query.toQueryString(),
+        "/response/numFound==2]",
+        "/response/docs/[0]/id=='99'",
+        "!/response/docs/[0]/enriched_field==",
+        "/response/docs/[1]/id=='98'",
+        "!/response/docs/[1]/enriched_field==");
+  }
+
+  @Test
+  public void processAdd_multipleInputFields_secondFieldEmpty_shouldSkipEnrichment() throws Exception {
+    loadTestChatModel("dummy-chat-model.json", "dummy-chat-1");
+
+    addWithChain(
+        sdoc("id", "99", "string_field", "Vegeta is the saiyan prince.", "body_field", ""),
+        "documentEnrichmentMultiField");
+    addWithChain(
+        sdoc("id", "98", "string_field", "Goku is the best saiyan.", "body_field", ""),
+        "documentEnrichmentMultiField");
+    assertU(commit());
+
+    final SolrQuery query = getEnrichmentQuery("enriched_field");
+
+    assertJQ(
+        "/query" + query.toQueryString(),
+        "/response/numFound==2]",
+        "/response/docs/[0]/id=='99'",
+        "!/response/docs/[0]/enriched_field==",
+        "/response/docs/[1]/id=='98'",
+        "!/response/docs/[1]/enriched_field==");
+  }
+
+  @Test
+  public void processAdd_multipleInputFields_bothFieldsAbsent_shouldSkipEnrichment() throws Exception {
+    loadTestChatModel("dummy-chat-model.json", "dummy-chat-1");
+
+    addWithChain(sdoc("id", "99"), "documentEnrichmentMultiField");
+    addWithChain(sdoc("id", "98"), "documentEnrichmentMultiField");
+    assertU(commit());
+
+    final SolrQuery query = getEnrichmentQuery("enriched_field");
+
+    assertJQ(
+        "/query" + query.toQueryString(),
+        "/response/numFound==2]",
+        "/response/docs/[0]/id=='99'",
+        "!/response/docs/[0]/enriched_field==",
+        "/response/docs/[1]/id=='98'",
+        "!/response/docs/[1]/enriched_field==");
+  }
+
+  @Test
+  public void processAdd_multipleInputFields_failingModel_shouldLogAndSkipEnrichment() throws Exception {
+    loadTestChatModel("exception-throwing-chat-model.json", "exception-throwing-chat-model");
+
+    addWithChain(
+        sdoc("id", "99", "string_field", "Vegeta is the saiyan prince.", "body_field", "He is very proud."),
+        "failingDocumentEnrichmentMultiField");
+    addWithChain(
+        sdoc("id", "98", "string_field", "Kakaroth is a saiyan.", "body_field", "He grew up on Earth."),
+        "failingDocumentEnrichmentMultiField");
+    assertU(commit());
+
+    final SolrQuery query = getEnrichmentQuery("enriched_field");
+
+    assertJQ(
+        "/query" + query.toQueryString(),
+        "/response/numFound==2]",
+        "/response/docs/[0]/id=='99'",
+        "!/response/docs/[0]/enriched_field==",
+        "/response/docs/[1]/id=='98'",
+        "!/response/docs/[1]/enriched_field==");
+  }
+
+  @Test
+  public void processAdd_multivaluedStringOutputField_shouldPopulateAllValues() throws Exception {
+    loadTestChatModel("dummy-chat-model-multivalued-string.json", "dummy-chat-multivalued-1");
+
+    addWithChain(
+        sdoc("id", "99", "string_field", "Vegeta is the saiyan prince."),
+        "documentEnrichmentMultivaluedString");
+    addWithChain(
+        sdoc("id", "98", "string_field", "Kakaroth is a saiyan grown up on planet Earth."),
+        "documentEnrichmentMultivaluedString");
+    assertU(commit());
+
+    final SolrQuery query = getEnrichmentQuery("enriched_field_multi");
+
+    assertJQ(
+        "/query" + query.toQueryString(),
+        "/response/numFound==2]",
+        "/response/docs/[0]/id=='99'",
+        "/response/docs/[0]/enriched_field_multi/[0]=='tag1'",
+        "/response/docs/[0]/enriched_field_multi/[1]=='tag2'",
+        "/response/docs/[1]/id=='98'",
+        "/response/docs/[1]/enriched_field_multi/[0]=='tag1'",
+        "/response/docs/[1]/enriched_field_multi/[1]=='tag2'");
+  }
+
+  @Test
+  public void processAdd_multivaluedStringOutputField_emptyInput_shouldSkipEnrichment()
+      throws Exception {
+    loadTestChatModel("dummy-chat-model-multivalued-string.json", "dummy-chat-multivalued-1");
+
+    addWithChain(sdoc("id", "99", "string_field", ""), "documentEnrichmentMultivaluedString");
+    addWithChain(sdoc("id", "98", "string_field", ""), "documentEnrichmentMultivaluedString");
+    assertU(commit());
+
+    final SolrQuery query = getEnrichmentQuery("enriched_field_multi");
+
+    assertJQ(
+        "/query" + query.toQueryString(),
+        "/response/numFound==2]",
+        "/response/docs/[0]/id=='99'",
+        "!/response/docs/[0]/enriched_field_multi==",
+        "/response/docs/[1]/id=='98'",
+        "!/response/docs/[1]/enriched_field_multi==");
+  }
+
+  // --- typed single-valued output field tests ---
+
+  @Test
+  public void processAdd_singleLongOutputField_shouldPopulateValue() throws Exception {

Review Comment:
   The output type should be correct since the field is declared with the correct type. If the field is populated with the expected output, then it should be correct. Nevertheless, I changed the values to edge cases (i.e., now the doubles throw an exception if indexed as float, same for longs)



##########
solr/modules/language-models/src/test/org/apache/solr/languagemodels/documentenrichment/update/processor/DocumentEnrichmentUpdateProcessorTest.java:
##########
@@ -0,0 +1,718 @@
+/*
+ * 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.solr.languagemodels.documentenrichment.update.processor;
+
+import java.io.IOException;
+import java.util.Map;
+import org.apache.solr.client.solrj.RemoteSolrException;
+import org.apache.solr.client.solrj.SolrServerException;
+import org.apache.solr.client.solrj.request.SolrQuery;
+import org.apache.solr.client.solrj.request.UpdateRequest;
+import org.apache.solr.common.SolrInputDocument;
+import org.apache.solr.languagemodels.TestLanguageModelBase;
+import org.apache.solr.languagemodels.documentenrichment.store.rest.ManagedChatModelStore;
+import org.junit.After;
+import org.junit.AfterClass;
+import org.junit.BeforeClass;
+import org.junit.Test;
+
+public class DocumentEnrichmentUpdateProcessorTest extends TestLanguageModelBase {
+
+  @BeforeClass
+  public static void init() throws Exception {
+    setupTest("solrconfig-document-enrichment.xml", "schema-language-models.xml", false, false);
+  }
+
+  @AfterClass
+  public static void cleanup() throws Exception {
+    afterTest();
+  }
+
+  private String loadedModelId;
+
+  @After
+  public void afterEachTest() throws Exception {
+    if (loadedModelId != null) {
+      restTestHarness.delete(ManagedChatModelStore.REST_END_POINT + "/" + loadedModelId);
+      loadedModelId = null;
+    }
+  }
+
+  private void loadTestChatModel(String fileName, String modelId) throws Exception {
+    loadChatModel(fileName);
+    loadedModelId = modelId;
+  }
+
+  @Test
+  public void processAdd_inputField_shouldEnrichInputField() throws Exception {
+    loadTestChatModel("dummy-chat-model.json", "dummy-chat-1");
+
+    addWithChain(sdoc("id", "99", "string_field", "Vegeta is the saiyan prince."), "documentEnrichment");
+    addWithChain(sdoc("id", "98", "string_field", "Kakaroth is a saiyan grown up on planet Earth."), "documentEnrichment");
+    assertU(commit());
+
+    final SolrQuery query = getEnrichmentQuery("enriched_field");
+
+    assertJQ(
+        "/query" + query.toQueryString(),
+        "/response/numFound==2]",
+        "/response/docs/[0]/id=='99'",
+        "/response/docs/[0]/enriched_field=='enriched content'",
+        "/response/docs/[1]/id=='98'",
+        "/response/docs/[1]/enriched_field=='enriched content'");
+  }
+
+  /*
+   This test looks for the 'dummy-chat-1' model, but such model is not loaded —
+   the model store is empty, so the update fails.
+  */
+  @Test
+  public void processAdd_modelNotFound_shouldThrowException() {
+    RuntimeException thrown =
+        assertThrows(
+            "model not found should throw an exception",
+            RemoteSolrException.class,
+            () ->
+                addWithChain(
+                    sdoc("id", "99", "string_field", "Vegeta is the saiyan prince."),
+                    "documentEnrichment"));
+    assertTrue(
+        thrown
+            .getMessage()
+            .contains(
+                "The model configured in the Update Request Processor 'dummy-chat-1' can't be found in the store: /schema/chat-model-store"));
+  }
+
+  @Test
+  public void processAdd_emptyInputField_shouldLogAndIndexWithNoEnrichedField() throws Exception {
+    loadTestChatModel("dummy-chat-model.json", "dummy-chat-1");
+    addWithChain(sdoc("id", "99", "string_field", ""), "documentEnrichment");
+    addWithChain(sdoc("id", "98", "string_field", "Vegeta is the saiyan prince."), "documentEnrichment");
+    assertU(commit());
+
+    final SolrQuery query = getEnrichmentQuery("enriched_field");
+
+    assertJQ(
+        "/query" + query.toQueryString(),
+        "/response/numFound==2]",
+        "/response/docs/[0]/id=='99'",
+        "!/response/docs/[0]/enriched_field==", // no enriched field for doc 99
+        "/response/docs/[1]/id=='98'",
+        "/response/docs/[1]/enriched_field=='enriched content'");
+  }
+
+  @Test
+  public void processAdd_nullInputField_shouldLogAndIndexWithNoEnrichedField() throws Exception {
+    loadTestChatModel("dummy-chat-model.json", "dummy-chat-1");
+    addWithChain(sdoc("id", "99", "string_field", "Vegeta is the saiyan prince."), "documentEnrichment");
+    assertU(adoc("id", "98")); // no string_field
+    assertU(commit());
+
+    final SolrQuery query = getEnrichmentQuery("enriched_field");
+
+    assertJQ(
+        "/query" + query.toQueryString(),
+        "/response/numFound==2]",
+        "/response/docs/[0]/id=='99'",
+        "/response/docs/[0]/enriched_field=='enriched content'",
+        "/response/docs/[1]/id=='98'",
+        "!/response/docs/[1]/enriched_field=="); // no enriched field for doc 98
+  }
+
+  @Test
+  public void processAdd_failingEnrichment_shouldLogAndIndexWithNoEnrichedField() throws Exception {
+    loadTestChatModel("exception-throwing-chat-model.json", "exception-throwing-chat-model");
+    addWithChain(sdoc("id", "99", "string_field", "Vegeta is the saiyan prince."), "failingDocumentEnrichment");
+    addWithChain(sdoc("id", "98", "string_field", "Kakaroth is a saiyan grown up on planet Earth."), "failingDocumentEnrichment");
+    assertU(commit());
+
+    final SolrQuery query = getEnrichmentQuery("enriched_field");
+
+    assertJQ(
+        "/query" + query.toQueryString(),
+        "/response/numFound==2]",
+        "/response/docs/[0]/id=='99'",
+        "!/response/docs/[0]/enriched_field==", // no enriched field for doc 99
+        "/response/docs/[1]/id=='98'",
+        "!/response/docs/[1]/enriched_field=="); // no enriched field for doc 98
+  }
+
+  @Test
+  public void processAtomicUpdate_shouldTriggerEnrichmentAndFetchTheStoredContent()
+      throws Exception {
+    // Verifies that when using a processor chain configured for partial updates
+    // (i.e., DistributedUpdateProcessorFactory before DocumentEnrichmentUpdateProcessorFactory),
+    // the system correctly retrieves the stored value of string_field and generates the
+    // enriched content for the document.
+    loadTestChatModel("dummy-chat-model.json", "dummy-chat-1");
+    assertU(adoc("id", "99", "string_field", "Vegeta is the saiyan prince."));
+    assertU(adoc("id", "98", "string_field", "Kakaroth is a saiyan grown up on planet Earth."));
+    assertU(commit());
+
+    SolrInputDocument atomicDoc = new SolrInputDocument();
+    atomicDoc.setField("id", "99");
+    atomicDoc.setField("enriched", Map.of("set", true));
+    addWithChain(atomicDoc, "documentEnrichmentForPartialUpdates");
+    assertU(commit());
+
+    final SolrQuery query = getEnrichmentQuery("enriched_field");
+
+    assertJQ(
+        "/query" + query.toQueryString(),
+        "/response/numFound==2]",
+        "/response/docs/[0]/id=='99'",
+        "/response/docs/[0]/enriched_field=='enriched content'",
+        "/response/docs/[1]/id=='98'",
+        "!/response/docs/[1]/enriched_field==" // no enriched field for document 98
+        );
+  }
+
+  @Test
+  public void processAtomicUpdate_shouldReplaceExistingEnrichedFieldNotAppend() throws Exception {
+    // Verifies that when a document already contains an enriched_field and string_field is
+    // modified via atomic update, the enriched content is recomputed and replaces the previous
+    // value rather than being appended.
+    loadTestChatModel("dummy-chat-model.json", "dummy-chat-1");
+    addWithChain(sdoc("id", "99", "string_field", "Vegeta is the saiyan prince."), "documentEnrichment");
+    addWithChain(sdoc("id", "98", "string_field", "Kakaroth is a saiyan grown up on planet Earth."), "documentEnrichment");
+    assertU(commit());
+
+    SolrInputDocument atomicDoc = new SolrInputDocument();
+    atomicDoc.setField("id", "99");
+    atomicDoc.setField("string_field", Map.of("set", "Vegeta is the saiyan prince from the Dragon Ball series."));
+    addWithChain(atomicDoc, "documentEnrichmentForPartialUpdates");
+    assertU(commit());
+
+    final SolrQuery query = getEnrichmentQuery("enriched_field");
+
+    assertJQ(
+        "/query" + query.toQueryString(),
+        "/response/numFound==2]",
+        "/response/docs/[0]/id=='99'",
+        "/response/docs/[0]/enriched_field=='enriched content'",
+        "/response/docs/[1]/id=='98'",
+        "/response/docs/[1]/enriched_field=='enriched content'");
+  }
+
+  // --- multi-field tests ---
+
+  @Test
+  public void processAdd_multipleInputFields_allPresent_shouldEnrichDocument() throws Exception {
+    loadTestChatModel("dummy-chat-model.json", "dummy-chat-1");
+
+    addWithChain(
+        sdoc("id", "99", "string_field", "Vegeta is the saiyan prince.", "body_field", "He is very proud."),
+        "documentEnrichmentMultiField");
+    addWithChain(
+        sdoc("id", "98", "string_field", "Kakaroth is a saiyan.", "body_field", "He grew up on Earth."),
+        "documentEnrichmentMultiField");
+    assertU(commit());
+
+    final SolrQuery query = getEnrichmentQuery("enriched_field");
+
+    assertJQ(
+        "/query" + query.toQueryString(),
+        "/response/numFound==2]",
+        "/response/docs/[0]/id=='99'",
+        "/response/docs/[0]/enriched_field=='enriched content'",
+        "/response/docs/[1]/id=='98'",
+        "/response/docs/[1]/enriched_field=='enriched content'");
+  }
+
+  @Test
+  public void processAdd_multipleInputFields_firstFieldNull_shouldSkipEnrichment() throws Exception {
+    loadTestChatModel("dummy-chat-model.json", "dummy-chat-1");
+
+    addWithChain(
+        sdoc("id", "99", "body_field", "He is very proud."), // string_field absent
+        "documentEnrichmentMultiField");
+    addWithChain(
+        sdoc("id", "98", "body_field", "He is very jealous."), // string_field absent
+        "documentEnrichmentMultiField");
+    assertU(commit());
+
+    final SolrQuery query = getEnrichmentQuery("enriched_field");
+
+    assertJQ(
+        "/query" + query.toQueryString(),
+        "/response/numFound==2]",
+        "/response/docs/[0]/id=='99'",
+        "!/response/docs/[0]/enriched_field==",
+        "/response/docs/[1]/id=='98'",
+        "!/response/docs/[1]/enriched_field==");
+  }
+
+  @Test
+  public void processAdd_multipleInputFields_secondFieldEmpty_shouldSkipEnrichment() throws Exception {
+    loadTestChatModel("dummy-chat-model.json", "dummy-chat-1");
+
+    addWithChain(
+        sdoc("id", "99", "string_field", "Vegeta is the saiyan prince.", "body_field", ""),
+        "documentEnrichmentMultiField");
+    addWithChain(
+        sdoc("id", "98", "string_field", "Goku is the best saiyan.", "body_field", ""),
+        "documentEnrichmentMultiField");
+    assertU(commit());
+
+    final SolrQuery query = getEnrichmentQuery("enriched_field");
+
+    assertJQ(
+        "/query" + query.toQueryString(),
+        "/response/numFound==2]",
+        "/response/docs/[0]/id=='99'",
+        "!/response/docs/[0]/enriched_field==",
+        "/response/docs/[1]/id=='98'",
+        "!/response/docs/[1]/enriched_field==");
+  }
+
+  @Test
+  public void processAdd_multipleInputFields_bothFieldsAbsent_shouldSkipEnrichment() throws Exception {
+    loadTestChatModel("dummy-chat-model.json", "dummy-chat-1");
+
+    addWithChain(sdoc("id", "99"), "documentEnrichmentMultiField");
+    addWithChain(sdoc("id", "98"), "documentEnrichmentMultiField");
+    assertU(commit());
+
+    final SolrQuery query = getEnrichmentQuery("enriched_field");
+
+    assertJQ(
+        "/query" + query.toQueryString(),
+        "/response/numFound==2]",
+        "/response/docs/[0]/id=='99'",
+        "!/response/docs/[0]/enriched_field==",
+        "/response/docs/[1]/id=='98'",
+        "!/response/docs/[1]/enriched_field==");
+  }
+
+  @Test
+  public void processAdd_multipleInputFields_failingModel_shouldLogAndSkipEnrichment() throws Exception {
+    loadTestChatModel("exception-throwing-chat-model.json", "exception-throwing-chat-model");
+
+    addWithChain(
+        sdoc("id", "99", "string_field", "Vegeta is the saiyan prince.", "body_field", "He is very proud."),
+        "failingDocumentEnrichmentMultiField");
+    addWithChain(
+        sdoc("id", "98", "string_field", "Kakaroth is a saiyan.", "body_field", "He grew up on Earth."),
+        "failingDocumentEnrichmentMultiField");
+    assertU(commit());
+
+    final SolrQuery query = getEnrichmentQuery("enriched_field");
+
+    assertJQ(
+        "/query" + query.toQueryString(),
+        "/response/numFound==2]",
+        "/response/docs/[0]/id=='99'",
+        "!/response/docs/[0]/enriched_field==",
+        "/response/docs/[1]/id=='98'",
+        "!/response/docs/[1]/enriched_field==");
+  }
+
+  @Test
+  public void processAdd_multivaluedStringOutputField_shouldPopulateAllValues() throws Exception {
+    loadTestChatModel("dummy-chat-model-multivalued-string.json", "dummy-chat-multivalued-1");
+
+    addWithChain(
+        sdoc("id", "99", "string_field", "Vegeta is the saiyan prince."),
+        "documentEnrichmentMultivaluedString");
+    addWithChain(
+        sdoc("id", "98", "string_field", "Kakaroth is a saiyan grown up on planet Earth."),
+        "documentEnrichmentMultivaluedString");
+    assertU(commit());
+
+    final SolrQuery query = getEnrichmentQuery("enriched_field_multi");
+
+    assertJQ(
+        "/query" + query.toQueryString(),
+        "/response/numFound==2]",
+        "/response/docs/[0]/id=='99'",
+        "/response/docs/[0]/enriched_field_multi/[0]=='tag1'",
+        "/response/docs/[0]/enriched_field_multi/[1]=='tag2'",
+        "/response/docs/[1]/id=='98'",
+        "/response/docs/[1]/enriched_field_multi/[0]=='tag1'",
+        "/response/docs/[1]/enriched_field_multi/[1]=='tag2'");
+  }
+
+  @Test
+  public void processAdd_multivaluedStringOutputField_emptyInput_shouldSkipEnrichment()
+      throws Exception {
+    loadTestChatModel("dummy-chat-model-multivalued-string.json", "dummy-chat-multivalued-1");
+
+    addWithChain(sdoc("id", "99", "string_field", ""), "documentEnrichmentMultivaluedString");
+    addWithChain(sdoc("id", "98", "string_field", ""), "documentEnrichmentMultivaluedString");
+    assertU(commit());
+
+    final SolrQuery query = getEnrichmentQuery("enriched_field_multi");
+
+    assertJQ(
+        "/query" + query.toQueryString(),
+        "/response/numFound==2]",
+        "/response/docs/[0]/id=='99'",
+        "!/response/docs/[0]/enriched_field_multi==",
+        "/response/docs/[1]/id=='98'",
+        "!/response/docs/[1]/enriched_field_multi==");
+  }
+
+  // --- typed single-valued output field tests ---
+
+  @Test
+  public void processAdd_singleLongOutputField_shouldPopulateValue() throws Exception {
+    loadTestChatModel("dummy-chat-model-single-long.json", "dummy-long");
+
+    addWithChain(sdoc("id", "99", "string_field", "some content"), "documentEnrichmentSingleLong");
+    addWithChain(sdoc("id", "98", "string_field", "other content"), "documentEnrichmentSingleLong");
+    assertU(commit());
+
+    final SolrQuery query = getEnrichmentQuery("output_long");
+
+    assertJQ(
+        "/query" + query.toQueryString(),
+        "/response/numFound==2]",
+        "/response/docs/[0]/id=='99'",
+        "/response/docs/[0]/output_long==42",
+        "/response/docs/[1]/id=='98'",
+        "/response/docs/[1]/output_long==42");
+  }
+
+  @Test
+  public void processAdd_singleIntOutputField_shouldPopulateValue() throws Exception {
+    loadTestChatModel("dummy-chat-model-single-int.json", "dummy-int");
+
+    addWithChain(sdoc("id", "99", "string_field", "some content"), "documentEnrichmentSingleInt");
+    addWithChain(sdoc("id", "98", "string_field", "other content"), "documentEnrichmentSingleInt");
+    assertU(commit());
+
+    final SolrQuery query = getEnrichmentQuery("output_int");
+
+    assertJQ(
+        "/query" + query.toQueryString(),
+        "/response/numFound==2]",
+        "/response/docs/[0]/id=='99'",
+        "/response/docs/[0]/output_int==7",
+        "/response/docs/[1]/id=='98'",
+        "/response/docs/[1]/output_int==7");
+  }
+
+  @Test
+  public void processAdd_singleFloatOutputField_shouldPopulateValue() throws Exception {
+    loadTestChatModel("dummy-chat-model-single-float.json", "dummy-float");
+
+    addWithChain(sdoc("id", "99", "string_field", "some content"), "documentEnrichmentSingleFloat");
+    addWithChain(sdoc("id", "98", "string_field", "other content"), "documentEnrichmentSingleFloat");
+    assertU(commit());
+
+    final SolrQuery query = getEnrichmentQuery("output_float");
+
+    assertJQ(
+        "/query" + query.toQueryString(),
+        "/response/numFound==2]",
+        "/response/docs/[0]/id=='99'",
+        "/response/docs/[0]/output_float==1.5",
+        "/response/docs/[1]/id=='98'",
+        "/response/docs/[1]/output_float==1.5");
+  }
+
+  @Test
+  public void processAdd_singleDoubleOutputField_shouldPopulateValue() throws Exception {
+    loadTestChatModel("dummy-chat-model-single-double.json", "dummy-double");
+
+    addWithChain(
+        sdoc("id", "99", "string_field", "some content"), "documentEnrichmentSingleDouble");
+    addWithChain(
+        sdoc("id", "98", "string_field", "other content"), "documentEnrichmentSingleDouble");
+    assertU(commit());
+
+    final SolrQuery query = getEnrichmentQuery("output_double");
+
+    assertJQ(
+        "/query" + query.toQueryString(),
+        "/response/numFound==2]",
+        "/response/docs/[0]/id=='99'",
+        "/response/docs/[0]/output_double==2.5",
+        "/response/docs/[1]/id=='98'",
+        "/response/docs/[1]/output_double==2.5");
+  }
+
+  @Test
+  public void processAdd_singleBooleanOutputField_shouldPopulateValue() throws Exception {
+    loadTestChatModel("dummy-chat-model-single-boolean.json", "dummy-boolean");
+
+    addWithChain(
+        sdoc("id", "99", "string_field", "some content"), "documentEnrichmentSingleBoolean");
+    addWithChain(
+        sdoc("id", "98", "string_field", "other content"), "documentEnrichmentSingleBoolean");
+    assertU(commit());
+
+    final SolrQuery query = getEnrichmentQuery("output_boolean");
+
+    assertJQ(
+        "/query" + query.toQueryString(),
+        "/response/numFound==2]",
+        "/response/docs/[0]/id=='99'",
+        "/response/docs/[0]/output_boolean==true",
+        "/response/docs/[1]/id=='98'",
+        "/response/docs/[1]/output_boolean==true");
+  }
+
+  @Test
+  public void processAdd_singleDateOutputField_shouldPopulateValue() throws Exception {
+    loadTestChatModel("dummy-chat-model-single-date.json", "dummy-date");
+
+    addWithChain(sdoc("id", "99", "string_field", "some content"), "documentEnrichmentSingleDate");
+    addWithChain(sdoc("id", "98", "string_field", "other content"), "documentEnrichmentSingleDate");
+    assertU(commit());
+
+    final SolrQuery query = getEnrichmentQuery("output_date");
+
+    assertJQ(
+        "/query" + query.toQueryString(),
+        "/response/numFound==2]",
+        "/response/docs/[0]/id=='99'",
+        "/response/docs/[0]/output_date=='2024-01-15T00:00:00Z'",
+        "/response/docs/[1]/id=='98'",
+        "/response/docs/[1]/output_date=='2024-01-15T00:00:00Z'");
+  }
+
+  // --- typed multivalued output field tests ---
+
+  @Test
+  public void processAdd_multivaluedLongOutputField_shouldPopulateAllValues() throws Exception {
+    loadTestChatModel("dummy-chat-model-multivalued-long.json", "dummy-long-multi");
+
+    addWithChain(
+        sdoc("id", "99", "string_field", "some content"), "documentEnrichmentMultivaluedLong");
+    addWithChain(
+        sdoc("id", "98", "string_field", "other content"), "documentEnrichmentMultivaluedLong");
+    assertU(commit());
+
+    final SolrQuery query = getEnrichmentQuery("output_long_multi");
+
+    assertJQ(
+        "/query" + query.toQueryString(),
+        "/response/numFound==2]",
+        "/response/docs/[0]/id=='99'",
+        "/response/docs/[0]/output_long_multi/[0]==10",
+        "/response/docs/[0]/output_long_multi/[1]==20",
+        "/response/docs/[0]/output_long_multi/[2]==30",
+        "/response/docs/[1]/id=='98'",
+        "/response/docs/[1]/output_long_multi/[0]==10",
+        "/response/docs/[1]/output_long_multi/[1]==20",
+        "/response/docs/[1]/output_long_multi/[2]==30");
+  }
+
+  @Test
+  public void processAdd_multivaluedIntOutputField_shouldPopulateAllValues() throws Exception {
+    loadTestChatModel("dummy-chat-model-multivalued-int.json", "dummy-int-multi");
+
+    addWithChain(
+        sdoc("id", "99", "string_field", "some content"), "documentEnrichmentMultivaluedInt");
+    addWithChain(
+        sdoc("id", "98", "string_field", "other content"), "documentEnrichmentMultivaluedInt");
+    assertU(commit());
+
+    final SolrQuery query = getEnrichmentQuery("output_int_multi");
+
+    assertJQ(
+        "/query" + query.toQueryString(),
+        "/response/numFound==2]",
+        "/response/docs/[0]/id=='99'",
+        "/response/docs/[0]/output_int_multi/[0]==1",
+        "/response/docs/[0]/output_int_multi/[1]==2",
+        "/response/docs/[1]/id=='98'",
+        "/response/docs/[1]/output_int_multi/[0]==1",
+        "/response/docs/[1]/output_int_multi/[1]==2");
+  }
+
+  @Test
+  public void processAdd_multivaluedFloatOutputField_shouldPopulateAllValues() throws Exception {
+    loadTestChatModel("dummy-chat-model-multivalued-float.json", "dummy-float-multi");
+
+    addWithChain(
+        sdoc("id", "99", "string_field", "some content"), "documentEnrichmentMultivaluedFloat");
+    addWithChain(
+        sdoc("id", "98", "string_field", "other content"), "documentEnrichmentMultivaluedFloat");
+    assertU(commit());
+
+    final SolrQuery query = getEnrichmentQuery("output_float_multi");
+
+    assertJQ(
+        "/query" + query.toQueryString(),
+        "/response/numFound==2]",
+        "/response/docs/[0]/id=='99'",
+        "/response/docs/[0]/output_float_multi/[0]==1.5",
+        "/response/docs/[0]/output_float_multi/[1]==2.5",
+        "/response/docs/[1]/id=='98'",
+        "/response/docs/[1]/output_float_multi/[0]==1.5",
+        "/response/docs/[1]/output_float_multi/[1]==2.5");
+  }
+
+  @Test
+  public void processAdd_multivaluedDoubleOutputField_shouldPopulateAllValues() throws Exception {
+    loadTestChatModel("dummy-chat-model-multivalued-double.json", "dummy-double-multi");
+
+    addWithChain(
+        sdoc("id", "99", "string_field", "some content"), "documentEnrichmentMultivaluedDouble");
+    addWithChain(
+        sdoc("id", "98", "string_field", "other content"), "documentEnrichmentMultivaluedDouble");
+    assertU(commit());
+
+    final SolrQuery query = getEnrichmentQuery("output_double_multi");
+
+    assertJQ(
+        "/query" + query.toQueryString(),
+        "/response/numFound==2]",
+        "/response/docs/[0]/id=='99'",
+        "/response/docs/[0]/output_double_multi/[0]==3.14",
+        "/response/docs/[0]/output_double_multi/[1]==2.71",
+        "/response/docs/[1]/id=='98'",
+        "/response/docs/[1]/output_double_multi/[0]==3.14",
+        "/response/docs/[1]/output_double_multi/[1]==2.71");
+  }
+
+  @Test
+  public void processAdd_multivaluedBooleanOutputField_shouldPopulateAllValues() throws Exception {
+    loadTestChatModel("dummy-chat-model-multivalued-boolean.json", "dummy-boolean-multi");
+
+    addWithChain(
+        sdoc("id", "99", "string_field", "some content"), "documentEnrichmentMultivaluedBoolean");
+    addWithChain(
+        sdoc("id", "98", "string_field", "other content"), "documentEnrichmentMultivaluedBoolean");
+    assertU(commit());
+
+    final SolrQuery query = getEnrichmentQuery("output_boolean_multi");
+
+    assertJQ(
+        "/query" + query.toQueryString(),
+        "/response/numFound==2]",
+        "/response/docs/[0]/id=='99'",
+        "/response/docs/[0]/output_boolean_multi/[0]==true",
+        "/response/docs/[0]/output_boolean_multi/[1]==false",
+        "/response/docs/[1]/id=='98'",
+        "/response/docs/[1]/output_boolean_multi/[0]==true",
+        "/response/docs/[1]/output_boolean_multi/[1]==false");
+  }
+
+  @Test
+  public void processAdd_multivaluedDateOutputField_shouldPopulateAllValues() throws Exception {
+    loadTestChatModel("dummy-chat-model-multivalued-date.json", "dummy-date-multi");
+
+    addWithChain(
+        sdoc("id", "99", "string_field", "some content"), "documentEnrichmentMultivaluedDate");
+    addWithChain(
+        sdoc("id", "98", "string_field", "other content"), "documentEnrichmentMultivaluedDate");
+    assertU(commit());
+
+    final SolrQuery query = getEnrichmentQuery("output_date_multi");
+
+    assertJQ(
+        "/query" + query.toQueryString(),
+        "/response/numFound==2]",
+        "/response/docs/[0]/id=='99'",
+        "/response/docs/[0]/output_date_multi/[0]=='2024-01-15T00:00:00Z'",
+        "/response/docs/[0]/output_date_multi/[1]=='2025-06-30T00:00:00Z'",
+        "/response/docs/[1]/id=='98'",
+        "/response/docs/[1]/output_date_multi/[0]=='2024-01-15T00:00:00Z'",
+        "/response/docs/[1]/output_date_multi/[1]=='2025-06-30T00:00:00Z'");
+  }
+
+  // --- LLM response contract violation tests ---
+
+  @Test
+  public void processAdd_llmResponseMissingValueKey_shouldLogAndIndexWithNoEnrichedField()
+      throws Exception {
+    // Model returns valid JSON but without the required "value" key
+    loadTestChatModel("dummy-chat-model-missing-value-key.json", "dummy-chat-1");
+
+    addWithChain(sdoc("id", "99", "string_field", "Vegeta is the saiyan prince."), "documentEnrichment");
+    addWithChain(sdoc("id", "98", "string_field", "Kakaroth is a saiyan grown up on planet Earth."), "documentEnrichment");
+    assertU(commit());
+
+    final SolrQuery query = getEnrichmentQuery("enriched_field");
+
+    assertJQ(
+        "/query" + query.toQueryString(),
+        "/response/numFound==2]",
+        "/response/docs/[0]/id=='99'",
+        "!/response/docs/[0]/enriched_field==",
+        "/response/docs/[1]/id=='98'",
+        "!/response/docs/[1]/enriched_field==");
+  }
+
+  @Test
+  public void processAdd_llmResponseMalformedJson_shouldLogAndIndexWithNoEnrichedField()
+      throws Exception {
+    // Model returns a plain string that cannot be parsed as JSON
+    loadTestChatModel("dummy-chat-model-malformed-json.json", "dummy-chat-1");
+
+    addWithChain(sdoc("id", "99", "string_field", "Vegeta is the saiyan prince."), "documentEnrichment");
+    addWithChain(sdoc("id", "98", "string_field", "Kakaroth is a saiyan grown up on planet Earth."), "documentEnrichment");
+    assertU(commit());
+
+    final SolrQuery query = getEnrichmentQuery("enriched_field");
+
+    assertJQ(
+        "/query" + query.toQueryString(),
+        "/response/numFound==2]",
+        "/response/docs/[0]/id=='99'",
+        "!/response/docs/[0]/enriched_field==",
+        "/response/docs/[1]/id=='98'",
+        "!/response/docs/[1]/enriched_field==");
+  }
+
+  // --- multivalued output field / scalar response test ---
+
+  @Test
+  public void processAdd_multivaluedOutputField_scalarLlmResponse_shouldStoreSingleValue()
+      throws Exception {
+    // Model returns {"value": "a single string"} for a multivalued output field.
+    // The scalar falls through the List<?> check and is stored as a single-element value.

Review Comment:
   Rephrased



##########
solr/modules/language-models/src/java/org/apache/solr/languagemodels/documentenrichment/model/SolrChatModel.java:
##########
@@ -0,0 +1,215 @@
+/*
+ * 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.solr.languagemodels.documentenrichment.model;
+
+import dev.langchain4j.data.message.UserMessage;
+import dev.langchain4j.model.chat.ChatModel;
+import dev.langchain4j.model.chat.request.ChatRequest;
+import dev.langchain4j.model.chat.request.ResponseFormat;
+import java.lang.invoke.MethodHandles;
+import java.lang.reflect.Method;
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.Map;
+import java.util.Objects;
+import org.apache.lucene.util.Accountable;
+import org.apache.lucene.util.RamUsageEstimator;
+import org.apache.solr.common.SolrException;
+import org.apache.solr.common.util.Utils;
+import org.apache.solr.core.SolrResourceLoader;
+import org.apache.solr.languagemodels.documentenrichment.store.ChatModelException;
+import org.apache.solr.languagemodels.documentenrichment.store.rest.ManagedChatModelStore;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * This object wraps a {@link ChatModel} to produce the content of new fields from another.
+ * It's meant to be used as a managed resource with the {@link
+ * ManagedChatModelStore}
+ */
+public class SolrChatModel implements Accountable {
+  private static final Logger log = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());
+  private static final long BASE_RAM_BYTES =
+      RamUsageEstimator.shallowSizeOfInstance(SolrChatModel.class);
+  // timeout is type Duration
+  private static final String TIMEOUT_PARAM = "timeout";
+
+  // the followings are Integer type
+  private static final String MAX_RETRIES_PARAM = "maxRetries";
+  private static final String THINKING_BUDGET_TOKENS = "thinkingBudgetTokens";
+  private static final String RANDOM_SEED = "randomSeed";
+
+  private final String name;
+  private final Map<String, Object> params;
+  private final ChatModel chatModel;
+  private final int hashCode;
+
+  public static SolrChatModel getInstance(
+      SolrResourceLoader solrResourceLoader,
+      String className,
+      String name,
+      Map<String, Object> params)
+      throws ChatModelException {
+    try {
+      /*
+       * The idea here is to build a {@link dev.langchain4j.model.chat.ChatModel} using inversion
+       * of control.
+       * Each model has its own list of parameters we don't know beforehand, but each {@link dev.langchain4j.model.chat.ChatModel} class
+       * has its own builder that uses setters with the same name of the parameter in input.
+       * */
+      ChatModel textToTextModel;
+      Class<?> modelClass = solrResourceLoader.findClass(className, ChatModel.class);
+      var builder = modelClass.getMethod("builder").invoke(null);
+      if (params != null) {
+        /*
+         * This block of code has the responsibility of instantiate a {@link
+         * dev.langchain4j.model.chat.ChatModel} using the params provided.classes have
+         * params of The specific implementation of {@link
+         * dev.langchain4j.model.chat.ChatModel} is not known beforehand. So we benefit of
+         * the design choice in langchain4j that each subclass implementing {@link
+         * dev.langchain4j.model.chat.ChatModel} uses setters with the same name of the
+         * param.
+         */
+        for (String paramName : params.keySet()) {
+          /*
+           * When a param is not primitive, we need to instantiate the object explicitly and then call the
+           * setter method.
+           * N.B. when adding support to new models, pay attention to all the parameters they
+           * support, some of them may require to be handled in here as separate switch cases
+           */
+          switch (paramName) {
+            case TIMEOUT_PARAM -> builder
+                  .getClass()
+                  .getMethod(paramName, Duration.class)
+                  .invoke(builder, Duration.ofSeconds((Long) params.get(paramName)));
+
+            case MAX_RETRIES_PARAM, THINKING_BUDGET_TOKENS, RANDOM_SEED -> builder
+                .getClass()
+                .getMethod(paramName, Integer.class)
+                .invoke(builder, ((Long) params.get(paramName)).intValue());
+
+              /*
+               * For primitive params if there's only one setter available, we call it.
+               * If there's choice we default to the string one
+               */
+            default -> {
+              ArrayList<Method> paramNameMatches = new ArrayList<>();
+              for (var method : builder.getClass().getMethods()) {
+                if (paramName.equals(method.getName()) && method.getParameterCount() == 1) {
+                  paramNameMatches.add(method);
+                }
+              }
+              if (paramNameMatches.size() == 1) {
+                paramNameMatches.getFirst().invoke(builder, params.get(paramName));
+              } else {
+                try {
+                  builder
+                      .getClass()
+                      .getMethod(paramName, String.class)
+                      .invoke(builder, params.get(paramName).toString());
+                } catch (NoSuchMethodException e) {
+                  log.error("Parameter {} not supported by model {}", paramName, className);
+                  throw new SolrException(SolrException.ErrorCode.BAD_REQUEST, e.getMessage(), e);
+                }
+              }
+            }
+          }
+        }
+      }
+      textToTextModel = (ChatModel) builder.getClass().getMethod("build").invoke(builder);

Review Comment:
   Renamed



##########
solr/modules/language-models/src/java/org/apache/solr/languagemodels/documentenrichment/update/processor/DocumentEnrichmentUpdateProcessor.java:
##########
@@ -0,0 +1,115 @@
+/*
+ * 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.solr.languagemodels.documentenrichment.update.processor;
+
+import dev.langchain4j.model.chat.request.ResponseFormat;
+import java.io.IOException;
+import java.lang.invoke.MethodHandles;
+import java.util.List;
+import org.apache.solr.common.SolrInputDocument;
+import org.apache.solr.common.SolrInputField;
+import org.apache.solr.languagemodels.documentenrichment.model.SolrChatModel;
+import org.apache.solr.request.SolrQueryRequest;
+import org.apache.solr.schema.IndexSchema;
+import org.apache.solr.schema.SchemaField;
+import org.apache.solr.update.AddUpdateCommand;
+import org.apache.solr.update.processor.UpdateRequestProcessor;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+class DocumentEnrichmentUpdateProcessor extends UpdateRequestProcessor {
+  private static final Logger log = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());
+
+  private final IndexSchema schema;
+  private final List<String> inputFields;
+  private final String outputField;
+  private final String prompt;
+  private final SolrChatModel chatModel;
+  private final boolean multiValued;
+  private final ResponseFormat responseFormat;
+
+  public DocumentEnrichmentUpdateProcessor(
+      List<String> inputFields,
+      String outputField,
+      String prompt,
+      SolrChatModel chatModel,
+      boolean multiValued,
+      ResponseFormat responseFormat,
+      SolrQueryRequest req,
+      UpdateRequestProcessor next) {
+    super(next);
+    this.schema = req.getSchema();
+    this.inputFields = inputFields;
+    this.outputField = outputField;
+    this.prompt = prompt;
+    this.chatModel = chatModel;
+    this.multiValued = multiValued;
+    this.responseFormat = responseFormat;
+  }
+
+  /**
+   * @param cmd the update command in input containing the Document to process
+   * @throws IOException If there is a low-level I/O error
+   */
+  @Override
+  public void processAdd(AddUpdateCommand cmd) throws IOException {
+    SolrInputDocument doc = cmd.getSolrInputDocument();
+
+    // Collect all field values; skip enrichment if any declared field is null or empty
+    String injectedPrompt = prompt;
+    for (String fieldName : inputFields) {
+      SolrInputField field = doc.get(fieldName);
+      if (isNullOrEmpty(field)) {
+        super.processAdd(cmd);
+        return;
+      }
+      injectedPrompt = injectedPrompt.replace("{" + fieldName + "}", field.getValue().toString());
+    }
+
+    try {
+      // as for now, only a plain text as prompt is sent to the model (no support for tools/skills/agents)
+      // chatModel.chat returns the parsed value from the structured JSON response
+      Object value = chatModel.chat(injectedPrompt, responseFormat);
+      if (multiValued && value instanceof List<?> list) {

Review Comment:
   Similarly to what I said in the comment in SolrChatModel: in some models (specifically, local ones), the output structure is not guaranteed 100%. I'd rather keep this as guardrails



##########
solr/modules/language-models/src/test/org/apache/solr/languagemodels/documentenrichment/update/processor/DocumentEnrichmentUpdateProcessorTest.java:
##########
@@ -0,0 +1,718 @@
+/*
+ * 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.solr.languagemodels.documentenrichment.update.processor;
+
+import java.io.IOException;
+import java.util.Map;
+import org.apache.solr.client.solrj.RemoteSolrException;
+import org.apache.solr.client.solrj.SolrServerException;
+import org.apache.solr.client.solrj.request.SolrQuery;
+import org.apache.solr.client.solrj.request.UpdateRequest;
+import org.apache.solr.common.SolrInputDocument;
+import org.apache.solr.languagemodels.TestLanguageModelBase;
+import org.apache.solr.languagemodels.documentenrichment.store.rest.ManagedChatModelStore;
+import org.junit.After;
+import org.junit.AfterClass;
+import org.junit.BeforeClass;
+import org.junit.Test;
+
+public class DocumentEnrichmentUpdateProcessorTest extends TestLanguageModelBase {
+
+  @BeforeClass
+  public static void init() throws Exception {
+    setupTest("solrconfig-document-enrichment.xml", "schema-language-models.xml", false, false);
+  }
+
+  @AfterClass
+  public static void cleanup() throws Exception {
+    afterTest();
+  }
+
+  private String loadedModelId;
+
+  @After
+  public void afterEachTest() throws Exception {
+    if (loadedModelId != null) {
+      restTestHarness.delete(ManagedChatModelStore.REST_END_POINT + "/" + loadedModelId);
+      loadedModelId = null;
+    }
+  }
+
+  private void loadTestChatModel(String fileName, String modelId) throws Exception {
+    loadChatModel(fileName);
+    loadedModelId = modelId;
+  }
+
+  @Test
+  public void processAdd_inputField_shouldEnrichInputField() throws Exception {
+    loadTestChatModel("dummy-chat-model.json", "dummy-chat-1");
+
+    addWithChain(sdoc("id", "99", "string_field", "Vegeta is the saiyan prince."), "documentEnrichment");
+    addWithChain(sdoc("id", "98", "string_field", "Kakaroth is a saiyan grown up on planet Earth."), "documentEnrichment");
+    assertU(commit());
+
+    final SolrQuery query = getEnrichmentQuery("enriched_field");
+
+    assertJQ(
+        "/query" + query.toQueryString(),
+        "/response/numFound==2]",
+        "/response/docs/[0]/id=='99'",
+        "/response/docs/[0]/enriched_field=='enriched content'",
+        "/response/docs/[1]/id=='98'",
+        "/response/docs/[1]/enriched_field=='enriched content'");
+  }
+
+  /*
+   This test looks for the 'dummy-chat-1' model, but such model is not loaded —
+   the model store is empty, so the update fails.
+  */
+  @Test
+  public void processAdd_modelNotFound_shouldThrowException() {
+    RuntimeException thrown =
+        assertThrows(
+            "model not found should throw an exception",
+            RemoteSolrException.class,
+            () ->
+                addWithChain(
+                    sdoc("id", "99", "string_field", "Vegeta is the saiyan prince."),
+                    "documentEnrichment"));
+    assertTrue(
+        thrown
+            .getMessage()
+            .contains(
+                "The model configured in the Update Request Processor 'dummy-chat-1' can't be found in the store: /schema/chat-model-store"));
+  }
+
+  @Test
+  public void processAdd_emptyInputField_shouldLogAndIndexWithNoEnrichedField() throws Exception {
+    loadTestChatModel("dummy-chat-model.json", "dummy-chat-1");
+    addWithChain(sdoc("id", "99", "string_field", ""), "documentEnrichment");
+    addWithChain(sdoc("id", "98", "string_field", "Vegeta is the saiyan prince."), "documentEnrichment");
+    assertU(commit());
+
+    final SolrQuery query = getEnrichmentQuery("enriched_field");
+
+    assertJQ(
+        "/query" + query.toQueryString(),
+        "/response/numFound==2]",
+        "/response/docs/[0]/id=='99'",
+        "!/response/docs/[0]/enriched_field==", // no enriched field for doc 99
+        "/response/docs/[1]/id=='98'",
+        "/response/docs/[1]/enriched_field=='enriched content'");
+  }
+
+  @Test
+  public void processAdd_nullInputField_shouldLogAndIndexWithNoEnrichedField() throws Exception {
+    loadTestChatModel("dummy-chat-model.json", "dummy-chat-1");
+    addWithChain(sdoc("id", "99", "string_field", "Vegeta is the saiyan prince."), "documentEnrichment");
+    assertU(adoc("id", "98")); // no string_field
+    assertU(commit());
+
+    final SolrQuery query = getEnrichmentQuery("enriched_field");
+
+    assertJQ(
+        "/query" + query.toQueryString(),
+        "/response/numFound==2]",
+        "/response/docs/[0]/id=='99'",
+        "/response/docs/[0]/enriched_field=='enriched content'",
+        "/response/docs/[1]/id=='98'",
+        "!/response/docs/[1]/enriched_field=="); // no enriched field for doc 98
+  }
+
+  @Test
+  public void processAdd_failingEnrichment_shouldLogAndIndexWithNoEnrichedField() throws Exception {
+    loadTestChatModel("exception-throwing-chat-model.json", "exception-throwing-chat-model");
+    addWithChain(sdoc("id", "99", "string_field", "Vegeta is the saiyan prince."), "failingDocumentEnrichment");
+    addWithChain(sdoc("id", "98", "string_field", "Kakaroth is a saiyan grown up on planet Earth."), "failingDocumentEnrichment");
+    assertU(commit());
+
+    final SolrQuery query = getEnrichmentQuery("enriched_field");
+
+    assertJQ(
+        "/query" + query.toQueryString(),
+        "/response/numFound==2]",
+        "/response/docs/[0]/id=='99'",
+        "!/response/docs/[0]/enriched_field==", // no enriched field for doc 99
+        "/response/docs/[1]/id=='98'",
+        "!/response/docs/[1]/enriched_field=="); // no enriched field for doc 98
+  }
+
+  @Test
+  public void processAtomicUpdate_shouldTriggerEnrichmentAndFetchTheStoredContent()
+      throws Exception {
+    // Verifies that when using a processor chain configured for partial updates
+    // (i.e., DistributedUpdateProcessorFactory before DocumentEnrichmentUpdateProcessorFactory),
+    // the system correctly retrieves the stored value of string_field and generates the
+    // enriched content for the document.
+    loadTestChatModel("dummy-chat-model.json", "dummy-chat-1");
+    assertU(adoc("id", "99", "string_field", "Vegeta is the saiyan prince."));
+    assertU(adoc("id", "98", "string_field", "Kakaroth is a saiyan grown up on planet Earth."));
+    assertU(commit());
+
+    SolrInputDocument atomicDoc = new SolrInputDocument();
+    atomicDoc.setField("id", "99");
+    atomicDoc.setField("enriched", Map.of("set", true));
+    addWithChain(atomicDoc, "documentEnrichmentForPartialUpdates");
+    assertU(commit());
+
+    final SolrQuery query = getEnrichmentQuery("enriched_field");
+
+    assertJQ(
+        "/query" + query.toQueryString(),
+        "/response/numFound==2]",
+        "/response/docs/[0]/id=='99'",
+        "/response/docs/[0]/enriched_field=='enriched content'",
+        "/response/docs/[1]/id=='98'",
+        "!/response/docs/[1]/enriched_field==" // no enriched field for document 98
+        );
+  }
+
+  @Test
+  public void processAtomicUpdate_shouldReplaceExistingEnrichedFieldNotAppend() throws Exception {

Review Comment:
   Changed



##########
solr/modules/language-models/src/test/org/apache/solr/languagemodels/documentenrichment/update/processor/DocumentEnrichmentUpdateProcessorFactoryTest.java:
##########
@@ -0,0 +1,422 @@
+/*
+ * 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.solr.languagemodels.documentenrichment.update.processor;
+
+import java.util.List;
+import org.apache.solr.common.SolrException;
+import org.apache.solr.common.params.ModifiableSolrParams;
+import org.apache.solr.common.util.NamedList;
+import org.apache.solr.core.SolrCore;
+import org.apache.solr.languagemodels.TestLanguageModelBase;
+import org.apache.solr.languagemodels.documentenrichment.model.SolrChatModel;
+import org.apache.solr.languagemodels.documentenrichment.store.rest.ManagedChatModelStore;
+import org.apache.solr.request.SolrQueryRequestBase;
+import org.apache.solr.update.processor.UpdateRequestProcessor;
+import org.junit.After;
+import org.junit.AfterClass;
+import org.junit.Before;
+import org.junit.BeforeClass;
+import org.junit.Test;
+
+public class DocumentEnrichmentUpdateProcessorFactoryTest extends TestLanguageModelBase {
+
+  @BeforeClass
+  public static void init() throws Exception {
+    setupTest("solrconfig-document-enrichment.xml", "schema-language-models.xml", false, false);
+  }
+
+  @AfterClass
+  public static void cleanup() throws Exception {
+    afterTest();
+  }
+
+  SolrCore collection1;
+
+  @Before
+  public void setup() {
+    collection1 = solrTestRule.getCoreContainer().getCore("collection1");
+  }
+
+  @After
+  public void after() {
+    collection1.close();
+  }
+
+  @Test
+  public void init_fullArgs_shouldInitAllParams() {
+    NamedList<String> args = new NamedList<>();
+    args.add("inputField", "string_field");
+    args.add("outputField", "enriched_field");
+    args.add("prompt", "Summarize: {string_field}");
+    args.add("model", "model1");
+
+    DocumentEnrichmentUpdateProcessorFactory factory = new DocumentEnrichmentUpdateProcessorFactory();
+    factory.init(args);
+
+    assertEquals(List.of("string_field"), factory.getInputFields());
+    assertEquals("enriched_field", factory.getOutputField());
+    assertEquals("Summarize: {string_field}", factory.getPrompt());
+    assertEquals("model1", factory.getModelName());
+  }
+
+  @Test
+  public void init_multipleInputFields_shouldInitAllFields() {
+    NamedList<String> args = new NamedList<>();
+    args.add("inputField", "string_field");
+    args.add("inputField", "body_field");
+    args.add("outputField", "enriched_field");
+    args.add("prompt", "Title: {string_field}. Body: {body_field}.");
+    args.add("model", "model1");
+
+    DocumentEnrichmentUpdateProcessorFactory factory = new DocumentEnrichmentUpdateProcessorFactory();
+    factory.init(args);
+
+    assertEquals(List.of("string_field", "body_field"), factory.getInputFields());
+  }
+
+  @Test
+  public void init_noInputField_shouldThrowExceptionWithDetailedMessage() {
+    NamedList<String> args = new NamedList<>();
+    args.add("outputField", "enriched_field");
+    args.add("prompt", "Summarize: {string_field}");
+    args.add("model", "model1");
+
+    DocumentEnrichmentUpdateProcessorFactory factory = new DocumentEnrichmentUpdateProcessorFactory();
+
+    SolrException e = assertThrows(SolrException.class, () -> factory.init(args));
+    assertEquals("At least one 'inputField' must be provided", e.getMessage());
+  }
+
+  @Test
+  public void init_nullOutputField_shouldThrowExceptionWithDetailedMessage() {
+    NamedList<String> args = new NamedList<>();
+    args.add("inputField", "string_field");
+    args.add("prompt", "Summarize: {string_field}");
+    args.add("model", "model1");
+
+    DocumentEnrichmentUpdateProcessorFactory factory = new DocumentEnrichmentUpdateProcessorFactory();
+
+    SolrException e = assertThrows(SolrException.class, () -> factory.init(args));
+    assertEquals("Missing required parameter: outputField", e.getMessage());
+  }
+
+  @Test
+  public void init_neitherPromptNorPromptFile_shouldThrowExceptionWithDetailedMessage() {
+    NamedList<String> args = new NamedList<>();
+    args.add("inputField", "string_field");
+    args.add("outputField", "enriched_field");
+    args.add("model", "model1");
+
+    DocumentEnrichmentUpdateProcessorFactory factory = new DocumentEnrichmentUpdateProcessorFactory();
+
+    SolrException e = assertThrows(SolrException.class, () -> factory.init(args));
+    assertEquals("Either 'prompt' or 'promptFile' must be provided", e.getMessage());
+  }
+
+  @Test
+  public void init_bothPromptAndPromptFile_shouldThrowExceptionWithDetailedMessage() {
+    NamedList<String> args = new NamedList<>();
+    args.add("inputField", "string_field");
+    args.add("outputField", "enriched_field");
+    args.add("prompt", "Summarize: {string_field}");
+    args.add("promptFile", "prompt.txt");
+    args.add("model", "model1");
+
+    DocumentEnrichmentUpdateProcessorFactory factory = new DocumentEnrichmentUpdateProcessorFactory();
+
+    SolrException e = assertThrows(SolrException.class, () -> factory.init(args));
+    assertEquals("Only one of 'prompt' or 'promptFile' can be provided, not both", e.getMessage());
+  }
+
+  @Test
+  public void init_promptMissingPlaceholderForDeclaredField_shouldThrowExceptionWithDetailedMessage() {
+    NamedList<String> args = new NamedList<>();
+    args.add("inputField", "string_field");
+    args.add("outputField", "enriched_field");
+    args.add("prompt", "Summarize:");
+    args.add("model", "model1");
+
+    DocumentEnrichmentUpdateProcessorFactory factory = new DocumentEnrichmentUpdateProcessorFactory();
+
+    SolrException e = assertThrows(SolrException.class, () -> factory.init(args));
+    assertEquals("prompt is missing placeholders for inputField(s): [string_field]", e.getMessage());
+  }
+
+  @Test
+  public void init_promptMissingOnePlaceholderOfMultipleFields_shouldThrowExceptionWithDetailedMessage() {
+    NamedList<String> args = new NamedList<>();
+    args.add("inputField", "string_field");
+    args.add("inputField", "body_field");
+    args.add("outputField", "enriched_field");
+    args.add("prompt", "Title: {string_field}.");
+    args.add("model", "model1");
+
+    DocumentEnrichmentUpdateProcessorFactory factory = new DocumentEnrichmentUpdateProcessorFactory();
+
+    SolrException e = assertThrows(SolrException.class, () -> factory.init(args));
+    assertEquals("prompt is missing placeholders for inputField(s): [body_field]", e.getMessage());
+  }
+
+  @Test
+  public void init_promptHasExtraPlaceholderNotDeclaredAsInputField_shouldThrowExceptionWithDetailedMessage() {
+    NamedList<String> args = new NamedList<>();
+    args.add("inputField", "string_field");
+    args.add("outputField", "enriched_field");
+    args.add("prompt", "Title: {string_field}. Extra: {unknown_field}.");
+    args.add("model", "model1");
+
+    DocumentEnrichmentUpdateProcessorFactory factory = new DocumentEnrichmentUpdateProcessorFactory();
+
+    SolrException e = assertThrows(SolrException.class, () -> factory.init(args));
+    assertEquals(
+        "prompt contains placeholders not declared as inputField(s): [unknown_field]",
+        e.getMessage());
+  }
+
+  @Test
+  public void init_nullModel_shouldThrowExceptionWithDetailedMessage() {
+    NamedList<String> args = new NamedList<>();
+    args.add("inputField", "string_field");
+    args.add("outputField", "enriched_field");
+    args.add("prompt", "Summarize: {string_field}");
+
+    DocumentEnrichmentUpdateProcessorFactory factory = new DocumentEnrichmentUpdateProcessorFactory();
+
+    SolrException e = assertThrows(SolrException.class, () -> factory.init(args));
+    assertEquals("Missing required parameter: model", e.getMessage());
+  }
+
+  @Test
+  public void init_promptFile_shouldLoadPromptFromFile() {
+    NamedList<String> args = new NamedList<>();
+    args.add("inputField", "string_field");
+    args.add("outputField", "enriched_field");
+    args.add("promptFile", "prompt.txt");
+    args.add("model", "model1");
+
+    DocumentEnrichmentUpdateProcessorFactory factory = new DocumentEnrichmentUpdateProcessorFactory();
+    factory.init(args);
+    factory.inform(collection1);
+
+    assertEquals("prompt.txt", factory.getPromptFile());
+    assertNotNull(factory.getPrompt());
+    assertTrue(factory.getPrompt().contains("{string_field}"));
+  }
+
+  @Test
+  public void init_promptFileMultiField_shouldLoadAndValidateBothPlaceholders() {
+    NamedList<String> args = new NamedList<>();
+    args.add("inputField", "string_field");
+    args.add("inputField", "body_field");
+    args.add("outputField", "enriched_field");
+    args.add("promptFile", "prompt-multi-field.txt");
+    args.add("model", "model1");
+
+    DocumentEnrichmentUpdateProcessorFactory factory = new DocumentEnrichmentUpdateProcessorFactory();
+    factory.init(args);
+    factory.inform(collection1);
+
+    assertNotNull(factory.getPrompt());
+    assertTrue(factory.getPrompt().contains("{string_field}"));
+    assertTrue(factory.getPrompt().contains("{body_field}"));
+  }
+
+  @Test
+  public void init_promptFileWithMissingPlaceholder_shouldThrowExceptionInInform() {
+    NamedList<String> args = new NamedList<>();
+    args.add("inputField", "string_field");
+    args.add("outputField", "enriched_field");
+    args.add("promptFile", "prompt-no-placeholder.txt");
+    args.add("model", "model1");
+
+    DocumentEnrichmentUpdateProcessorFactory factory = new DocumentEnrichmentUpdateProcessorFactory();
+    factory.init(args);
+
+    SolrException e = assertThrows(SolrException.class, () -> factory.inform(collection1));
+    assertEquals(
+        "prompt is missing placeholders for inputField(s): [string_field]", e.getMessage());
+  }
+
+  /* Following tests depend on a real solr schema and depend on BeforeClass-AfterClass methods */
+
+  @Test
+  public void init_notExistentOutputField_shouldThrowExceptionWithDetailedMessage() {
+    NamedList<String> args = new NamedList<>();
+    args.add("inputField", "string_field");
+    args.add("outputField", "notExistentOutput");
+    args.add("prompt", "Summarize: {string_field}");
+    args.add("model", "model1");
+
+    DocumentEnrichmentUpdateProcessorFactory factory = new DocumentEnrichmentUpdateProcessorFactory();
+    ModifiableSolrParams params = new ModifiableSolrParams();
+    SolrQueryRequestBase req = new SolrQueryRequestBase(collection1, params) {};
+    factory.init(args);
+
+    SolrException e = assertThrows(SolrException.class, () -> factory.getInstance(req, null, null));
+    assertEquals("undefined field: \"notExistentOutput\"", e.getMessage());
+  }
+
+  @Test
+  public void init_notTextualOutputField_shouldThrowExceptionWithDetailedMessage() {
+    // vector is a DenseVectorField — not a textual field
+    NamedList<String> args = new NamedList<>();
+    args.add("inputField", "string_field");
+    args.add("outputField", "vector");
+    args.add("prompt", "Summarize: {string_field}");
+    args.add("model", "model1");
+
+    DocumentEnrichmentUpdateProcessorFactory factory = new DocumentEnrichmentUpdateProcessorFactory();
+    ModifiableSolrParams params = new ModifiableSolrParams();
+    SolrQueryRequestBase req = new SolrQueryRequestBase(collection1, params) {};
+    factory.init(args);
+
+    SolrException e = assertThrows(SolrException.class, () -> factory.getInstance(req, null, null));
+    assertEquals(
+        "field type is not supported by Document Enrichment: DenseVectorField", e.getMessage());
+  }
+
+  @Test
+  public void init_notExistentInputField_shouldThrowExceptionWithDetailedMessage() {
+    NamedList<String> args = new NamedList<>();
+    args.add("inputField", "notExistentInput");
+    args.add("outputField", "enriched_field");
+    args.add("prompt", "Summarize: {notExistentInput}");
+    args.add("model", "model1");
+
+    DocumentEnrichmentUpdateProcessorFactory factory = new DocumentEnrichmentUpdateProcessorFactory();
+    ModifiableSolrParams params = new ModifiableSolrParams();
+    SolrQueryRequestBase req = new SolrQueryRequestBase(collection1, params) {};
+    factory.init(args);
+
+    SolrException e = assertThrows(SolrException.class, () -> factory.getInstance(req, null, null));
+    assertEquals("undefined field: \"notExistentInput\"", e.getMessage());
+  }
+
+  @Test
+  public void init_multipleInputFields_oneNotExistent_shouldThrowExceptionWithDetailedMessage() {
+    NamedList<String> args = new NamedList<>();
+    args.add("inputField", "string_field");
+    args.add("inputField", "notExistentInput");
+    args.add("outputField", "enriched_field");
+    args.add("prompt", "Title: {string_field}. Body: {notExistentInput}.");
+    args.add("model", "model1");
+
+    DocumentEnrichmentUpdateProcessorFactory factory = new DocumentEnrichmentUpdateProcessorFactory();
+    ModifiableSolrParams params = new ModifiableSolrParams();
+    SolrQueryRequestBase req = new SolrQueryRequestBase(collection1, params) {};
+    factory.init(args);
+
+    SolrException e = assertThrows(SolrException.class, () -> factory.getInstance(req, null, null));
+    assertEquals("undefined field: \"notExistentInput\"", e.getMessage());
+  }
+
+  @Test
+  public void init_multivaluedStringOutputField_shouldNotThrowException() throws Exception {
+    UpdateRequestProcessor instance =
+        createUpdateProcessor("string_field", "enriched_field_multi", collection1, "model-mv");
+    assertNotNull(instance);
+    restTestHarness.delete(ManagedChatModelStore.REST_END_POINT + "/model-mv");
+  }
+
+  @Test
+  public void init_multivaluedStringOutputField_buildResponseFormat_shouldProduceArraySchema() throws Exception {
+    NamedList<String> args = new NamedList<>();
+    ManagedChatModelStore.getManagedModelStore(collection1)
+        .addModel(new SolrChatModel("model-rf", null, null));
+    args.add("inputField", "string_field");
+    args.add("outputField", "enriched_field_multi");
+    args.add("prompt", "Summarize: {string_field}");
+    args.add("model", "model-rf");
+
+    DocumentEnrichmentUpdateProcessorFactory factory = new DocumentEnrichmentUpdateProcessorFactory();
+    factory.init(args);
+    ModifiableSolrParams params = new ModifiableSolrParams();
+    SolrQueryRequestBase req = new SolrQueryRequestBase(collection1, params) {};
+    assertNotNull(factory.getInstance(req, null, null));
+
+    // verify the ResponseFormat is constructed correctly for the multivalued field
+    var schema = collection1.getLatestSchema();
+    var schemaField = schema.getField("enriched_field_multi");
+    assertTrue(schemaField.multiValued());
+    var responseFormat = DocumentEnrichmentUpdateProcessorFactory.buildResponseFormat(schemaField);
+    assertNotNull(responseFormat);
+    assertEquals(
+        dev.langchain4j.model.chat.request.ResponseFormatType.JSON, responseFormat.type());
+    assertNotNull(responseFormat.jsonSchema());
+    restTestHarness.delete(ManagedChatModelStore.REST_END_POINT + "/model-rf");
+  }
+
+  @Test
+  public void init_singleValuedStringOutputField_buildResponseFormat_shouldProduceStringSchema() {
+    var schema = collection1.getLatestSchema();
+    var schemaField = schema.getField("enriched_field");
+    assertFalse(schemaField.multiValued());
+    var responseFormat = DocumentEnrichmentUpdateProcessorFactory.buildResponseFormat(schemaField);
+    assertNotNull(responseFormat);
+    assertEquals(
+        dev.langchain4j.model.chat.request.ResponseFormatType.JSON, responseFormat.type());
+    assertNotNull(responseFormat.jsonSchema());
+  }
+
+  @Test
+  public void init_dynamicInputField_shouldNotThrowException() throws Exception{
+    UpdateRequestProcessor instance =
+        createUpdateProcessor("text_s", "enriched_field", collection1, "model2");
+    assertNotNull(instance);
+    restTestHarness.delete(ManagedChatModelStore.REST_END_POINT + "/model2");
+  }
+
+  @Test
+  public void init_multipleDynamicInputFields_shouldNotThrowException() throws Exception{
+    NamedList<String> args = new NamedList<>();
+    ManagedChatModelStore.getManagedModelStore(collection1)
+        .addModel(new SolrChatModel("model1", null, null));
+    args.add("inputField", "text_s");
+    args.add("inputField", "body_field");
+    args.add("outputField", "enriched_field");
+    args.add("prompt", "Title: {text_s}. Body: {body_field}.");
+    args.add("model", "model1");
+
+    DocumentEnrichmentUpdateProcessorFactory factory = new DocumentEnrichmentUpdateProcessorFactory();
+    ModifiableSolrParams params = new ModifiableSolrParams();
+    factory.init(args);
+
+    SolrQueryRequestBase req = new SolrQueryRequestBase(collection1, params) {};
+    assertNotNull(factory.getInstance(req, null, null));
+    restTestHarness.delete(ManagedChatModelStore.REST_END_POINT + "/model1");
+  }
+
+  private UpdateRequestProcessor createUpdateProcessor(

Review Comment:
   Done



##########
solr/modules/language-models/src/test-files/solr/collection1/conf/schema-language-models.xml:
##########
@@ -36,11 +40,31 @@
   <field name="2048_byte_vector" type="high_dimensional_byte_knn_vector" indexed="true" stored="true" />
   <field name="2048_float_vector" type="high_dimensional_float_knn_vector" indexed="true" stored="true" />
   <field name="string_field" type="string" indexed="true" stored="true" multiValued="false" required="false"/>
+  <field name="body_field" type="string" indexed="true" stored="true" multiValued="false" required="false"/>
+  <field name="enriched_field" type="string" indexed="true" stored="true" multiValued="false" required="false"/>
+  <field name="enriched_field_multi" type="string" indexed="true" stored="true" multiValued="true" required="false"/>
+
+  <!-- single-valued output fields for each supported type -->
+  <field name="output_long"    type="plong"   indexed="true" stored="true" multiValued="false"/>
+  <field name="output_int"     type="pint"    indexed="true" stored="true" multiValued="false"/>
+  <field name="output_float"   type="pfloat"  indexed="true" stored="true" multiValued="false"/>
+  <field name="output_double"  type="pdouble" indexed="true" stored="true" multiValued="false"/>
+  <field name="output_boolean" type="boolean" indexed="true" stored="true" multiValued="false"/>
+  <field name="output_date"    type="pdate"   indexed="true" stored="true" multiValued="false"/>
+
+  <!-- multivalued output fields for each supported type -->
+  <field name="output_long_multi"    type="plong"   indexed="true" stored="true" multiValued="true"/>
+  <field name="output_int_multi"     type="pint"    indexed="true" stored="true" multiValued="true"/>
+  <field name="output_float_multi"   type="pfloat"  indexed="true" stored="true" multiValued="true"/>
+  <field name="output_double_multi"  type="pdouble" indexed="true" stored="true" multiValued="true"/>
+  <field name="output_boolean_multi" type="boolean" indexed="true" stored="true" multiValued="true"/>
+  <field name="output_date_multi"    type="pdate"   indexed="true" stored="true" multiValued="true"/>
 
   <field name="_version_" type="plong" indexed="true" stored="true" multiValued="false" />
   <field name="_text_" type="text_general" indexed="true" stored="false" multiValued="true"/>
   <copyField source="*" dest="_text_"/>
   <field name="vectorised" type="boolean" indexed="true" stored="false" docValues="true" default="false"/>
+  <field name="enriched" type="boolean" indexed="true" stored="false" docValues="true" default="false"/>

Review Comment:
   To test partial updates



##########
solr/modules/language-models/src/java/org/apache/solr/languagemodels/documentenrichment/model/SolrChatModel.java:
##########
@@ -0,0 +1,215 @@
+/*
+ * 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.solr.languagemodels.documentenrichment.model;
+
+import dev.langchain4j.data.message.UserMessage;
+import dev.langchain4j.model.chat.ChatModel;
+import dev.langchain4j.model.chat.request.ChatRequest;
+import dev.langchain4j.model.chat.request.ResponseFormat;
+import java.lang.invoke.MethodHandles;
+import java.lang.reflect.Method;
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.Map;
+import java.util.Objects;
+import org.apache.lucene.util.Accountable;
+import org.apache.lucene.util.RamUsageEstimator;
+import org.apache.solr.common.SolrException;
+import org.apache.solr.common.util.Utils;
+import org.apache.solr.core.SolrResourceLoader;
+import org.apache.solr.languagemodels.documentenrichment.store.ChatModelException;
+import org.apache.solr.languagemodels.documentenrichment.store.rest.ManagedChatModelStore;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * This object wraps a {@link ChatModel} to produce the content of new fields from another.
+ * It's meant to be used as a managed resource with the {@link
+ * ManagedChatModelStore}
+ */
+public class SolrChatModel implements Accountable {
+  private static final Logger log = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());
+  private static final long BASE_RAM_BYTES =
+      RamUsageEstimator.shallowSizeOfInstance(SolrChatModel.class);
+  // timeout is type Duration
+  private static final String TIMEOUT_PARAM = "timeout";
+
+  // the followings are Integer type
+  private static final String MAX_RETRIES_PARAM = "maxRetries";
+  private static final String THINKING_BUDGET_TOKENS = "thinkingBudgetTokens";
+  private static final String RANDOM_SEED = "randomSeed";
+
+  private final String name;
+  private final Map<String, Object> params;
+  private final ChatModel chatModel;
+  private final int hashCode;
+
+  public static SolrChatModel getInstance(
+      SolrResourceLoader solrResourceLoader,
+      String className,
+      String name,
+      Map<String, Object> params)
+      throws ChatModelException {
+    try {
+      /*
+       * The idea here is to build a {@link dev.langchain4j.model.chat.ChatModel} using inversion
+       * of control.
+       * Each model has its own list of parameters we don't know beforehand, but each {@link dev.langchain4j.model.chat.ChatModel} class
+       * has its own builder that uses setters with the same name of the parameter in input.
+       * */
+      ChatModel textToTextModel;
+      Class<?> modelClass = solrResourceLoader.findClass(className, ChatModel.class);
+      var builder = modelClass.getMethod("builder").invoke(null);
+      if (params != null) {
+        /*
+         * This block of code has the responsibility of instantiate a {@link
+         * dev.langchain4j.model.chat.ChatModel} using the params provided.classes have
+         * params of The specific implementation of {@link
+         * dev.langchain4j.model.chat.ChatModel} is not known beforehand. So we benefit of
+         * the design choice in langchain4j that each subclass implementing {@link
+         * dev.langchain4j.model.chat.ChatModel} uses setters with the same name of the
+         * param.
+         */
+        for (String paramName : params.keySet()) {
+          /*
+           * When a param is not primitive, we need to instantiate the object explicitly and then call the
+           * setter method.
+           * N.B. when adding support to new models, pay attention to all the parameters they
+           * support, some of them may require to be handled in here as separate switch cases
+           */
+          switch (paramName) {
+            case TIMEOUT_PARAM -> builder
+                  .getClass()
+                  .getMethod(paramName, Duration.class)
+                  .invoke(builder, Duration.ofSeconds((Long) params.get(paramName)));
+
+            case MAX_RETRIES_PARAM, THINKING_BUDGET_TOKENS, RANDOM_SEED -> builder
+                .getClass()
+                .getMethod(paramName, Integer.class)
+                .invoke(builder, ((Long) params.get(paramName)).intValue());
+
+              /*
+               * For primitive params if there's only one setter available, we call it.
+               * If there's choice we default to the string one
+               */
+            default -> {
+              ArrayList<Method> paramNameMatches = new ArrayList<>();
+              for (var method : builder.getClass().getMethods()) {
+                if (paramName.equals(method.getName()) && method.getParameterCount() == 1) {
+                  paramNameMatches.add(method);
+                }
+              }
+              if (paramNameMatches.size() == 1) {
+                paramNameMatches.getFirst().invoke(builder, params.get(paramName));
+              } else {
+                try {
+                  builder
+                      .getClass()
+                      .getMethod(paramName, String.class)
+                      .invoke(builder, params.get(paramName).toString());
+                } catch (NoSuchMethodException e) {
+                  log.error("Parameter {} not supported by model {}", paramName, className);
+                  throw new SolrException(SolrException.ErrorCode.BAD_REQUEST, e.getMessage(), e);
+                }
+              }
+            }
+          }
+        }
+      }
+      textToTextModel = (ChatModel) builder.getClass().getMethod("build").invoke(builder);
+      return new SolrChatModel(name, textToTextModel, params);
+    } catch (final Exception e) {
+      throw new ChatModelException("Model loading failed for " + className, e);
+    }
+  }
+
+  public SolrChatModel(
+      String name, ChatModel chatModel, Map<String, Object> params) {
+    this.name = name;
+    this.chatModel = chatModel;
+    this.params = params;
+    this.hashCode = calculateHashCode();
+  }
+
+  /**
+   * Sends a structured chat request and returns the parsed value from the {@code {"value": ...}}
+   * JSON object that the model is instructed to produce via {@code responseFormat}.
+   *
+   * @return the extracted value: a {@link String}, {@link Number}, {@link Boolean}, or {@link
+   *     java.util.List} depending on the Solr output field type
+   */
+  public Object chat(String text, ResponseFormat responseFormat) {

Review Comment:
   Renamed



##########
solr/modules/language-models/src/java/org/apache/solr/languagemodels/documentenrichment/update/processor/DocumentEnrichmentUpdateProcessorFactory.java:
##########
@@ -0,0 +1,313 @@
+/*
+ * 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.solr.languagemodels.documentenrichment.update.processor;
+
+import dev.langchain4j.model.chat.request.ResponseFormat;
+import dev.langchain4j.model.chat.request.ResponseFormatType;
+import dev.langchain4j.model.chat.request.json.JsonArraySchema;
+import dev.langchain4j.model.chat.request.json.JsonBooleanSchema;
+import dev.langchain4j.model.chat.request.json.JsonIntegerSchema;
+import dev.langchain4j.model.chat.request.json.JsonNumberSchema;
+import dev.langchain4j.model.chat.request.json.JsonObjectSchema;
+import dev.langchain4j.model.chat.request.json.JsonSchema;
+import dev.langchain4j.model.chat.request.json.JsonSchemaElement;
+import dev.langchain4j.model.chat.request.json.JsonStringSchema;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import java.util.Collection;
+import java.util.HashSet;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import org.apache.solr.common.SolrException;
+import org.apache.solr.common.params.RequiredSolrParams;
+import org.apache.solr.common.params.SolrParams;
+import org.apache.solr.common.util.NamedList;
+import org.apache.solr.core.SolrCore;
+import org.apache.solr.core.SolrResourceLoader;
+import org.apache.solr.languagemodels.documentenrichment.model.SolrChatModel;
+import org.apache.solr.languagemodels.documentenrichment.store.rest.ManagedChatModelStore;
+import org.apache.solr.request.SolrQueryRequest;
+import org.apache.solr.response.SolrQueryResponse;
+import org.apache.solr.rest.ManagedResource;
+import org.apache.solr.rest.ManagedResourceObserver;
+import org.apache.solr.schema.BoolField;
+import org.apache.solr.schema.DatePointField;
+import org.apache.solr.schema.DenseVectorField;
+import org.apache.solr.schema.DoublePointField;
+import org.apache.solr.schema.FieldType;
+import org.apache.solr.schema.FloatPointField;
+import org.apache.solr.schema.IndexSchema;
+import org.apache.solr.schema.IntPointField;
+import org.apache.solr.schema.LongPointField;
+import org.apache.solr.schema.SchemaField;
+import org.apache.solr.schema.StrField;
+import org.apache.solr.schema.TextField;
+import org.apache.solr.update.processor.UpdateRequestProcessor;
+import org.apache.solr.update.processor.UpdateRequestProcessorFactory;
+import org.apache.solr.util.plugin.SolrCoreAware;
+
+/**
+ * Insert in an existing field the output of the model coming from one or more textual field values.
+ *
+ * <p>One or more {@code inputField} parameters specify the Solr fields to use as input. Each field
+ * name must appear as a {@code {fieldName}} placeholder in the prompt. Exactly one of {@code
+ * prompt} or {@code promptFile} must be provided.
+ *
+ * <pre class="prettyprint" >
+ * &lt;processor class=&quot;solr.llm.documentenrichment.update.processor.DocumentEnrichmentUpdateProcessorFactory&quot;&gt;
+ *   &lt;str name=&quot;inputField&quot;&gt;title_field&lt;/str&gt;
+ *   &lt;str name=&quot;inputField&quot;&gt;body_field&lt;/str&gt;
+ *   &lt;str name=&quot;outputField&quot;&gt;enriched_field&lt;/str&gt;
+ *   &lt;str name=&quot;prompt&quot;&gt;Title: {title_field}. Body: {body_field}.&lt;/str&gt;
+ *   &lt;str name=&quot;model&quot;&gt;ChatModel&lt;/str&gt;
+ * &lt;/processor&gt;
+ * </pre>
+ *
+ * <p>Alternatively, the prompt can be loaded from a text file using {@code promptFile}:
+ *
+ * <pre class="prettyprint" >
+ * &lt;processor class=&quot;solr.llm.documentenrichment.update.processor.DocumentEnrichmentUpdateProcessorFactory&quot;&gt;
+ *   &lt;str name=&quot;inputField&quot;&gt;title_field&lt;/str&gt;
+ *   &lt;str name=&quot;outputField&quot;&gt;enriched_field&lt;/str&gt;
+ *   &lt;str name=&quot;promptFile&quot;&gt;prompt.txt&lt;/str&gt;
+ *   &lt;str name=&quot;model&quot;&gt;ChatModel&lt;/str&gt;
+ * &lt;/processor&gt;
+ * </pre>
+ *
+ * <p>Validation rules:
+ *
+ * <ul>
+ *   <li>At least one {@code inputField} must be declared.
+ *   <li>Exactly one of {@code prompt} or {@code promptFile} must be provided.
+ *   <li>Every declared {@code inputField} must have a corresponding {@code {fieldName}} placeholder
+ *       in the prompt.
+ *   <li>Every {@code {placeholder}} in the prompt must correspond to a declared {@code inputField}.
+ * </ul>
+ */
+public class DocumentEnrichmentUpdateProcessorFactory extends UpdateRequestProcessorFactory
+    implements SolrCoreAware, ManagedResourceObserver {
+  private static final String INPUT_FIELD_PARAM = "inputField";
+  private static final String OUTPUT_FIELD_PARAM = "outputField";
+  private static final String PROMPT = "prompt";
+  private static final String PROMPT_FILE = "promptFile";
+  private static final String MODEL_NAME = "model";
+  private static final Pattern PLACEHOLDER_PATTERN = Pattern.compile("\\{([^}]+)\\}");
+
+  private List<String> inputFields;
+  private String outputField;
+  private String promptText;
+  private String promptFile;
+  private String modelName;
+
+  @Override
+  public void init(final NamedList<?> args) {
+    // removeConfigArgs handles both multiple <str name="inputField"> and <arr name="inputField">
+    // and must be called before toSolrParams() since it mutates args in place
+    Collection<String> fieldNames = args.removeConfigArgs(INPUT_FIELD_PARAM);
+    if (fieldNames.isEmpty()) {
+      throw new SolrException(
+          SolrException.ErrorCode.SERVER_ERROR,
+          "At least one 'inputField' must be provided");
+    }
+    inputFields = List.copyOf(fieldNames);
+
+    SolrParams params = args.toSolrParams();
+    RequiredSolrParams required = params.required();
+    outputField = required.get(OUTPUT_FIELD_PARAM);
+    modelName = required.get(MODEL_NAME);
+
+    String inlinePrompt = params.get(PROMPT);
+    String promptFilePath = params.get(PROMPT_FILE);
+
+    if (inlinePrompt == null && promptFilePath == null) {
+      throw new SolrException(
+          SolrException.ErrorCode.SERVER_ERROR,
+          "Either 'prompt' or 'promptFile' must be provided");
+    }
+    if (inlinePrompt != null && promptFilePath != null) {
+      throw new SolrException(
+          SolrException.ErrorCode.SERVER_ERROR,
+          "Only one of 'prompt' or 'promptFile' can be provided, not both");
+    }
+    if (inlinePrompt != null) {
+      validatePromptPlaceholders(inlinePrompt, inputFields);
+      this.promptText = inlinePrompt;
+    }
+    this.promptFile = promptFilePath;
+  }
+
+  @Override
+  public void inform(SolrCore core) {
+    final SolrResourceLoader solrResourceLoader = core.getResourceLoader();
+    ManagedChatModelStore.registerManagedChatModelStore(solrResourceLoader, this);
+    if (promptFile != null) {
+      try (InputStream is = solrResourceLoader.openResource(promptFile)) {
+        promptText = new String(is.readAllBytes(), StandardCharsets.UTF_8).trim();
+      } catch (IOException e) {
+        throw new SolrException(
+            SolrException.ErrorCode.SERVER_ERROR,
+            "Cannot read prompt file: " + promptFile,
+            e);
+      }
+      validatePromptPlaceholders(promptText, inputFields);
+    }
+  }
+
+  @Override
+  public void onManagedResourceInitialized(NamedList<?> args, ManagedResource res)
+      throws SolrException {
+    if (res instanceof ManagedChatModelStore store) {
+      store.loadStoredModels();
+    }
+  }
+
+  @Override
+  public UpdateRequestProcessor getInstance(
+      SolrQueryRequest req, SolrQueryResponse rsp, UpdateRequestProcessor next) {
+    IndexSchema latestSchema = req.getCore().getLatestSchema();
+
+    for (String fieldName : inputFields) {
+      if (!latestSchema.isDynamicField(fieldName) && !latestSchema.hasExplicitField(fieldName)) {
+        throw new SolrException(
+            SolrException.ErrorCode.SERVER_ERROR, "undefined field: \"" + fieldName + "\"");
+      }
+    }
+
+    final SchemaField outputFieldSchema = latestSchema.getField(outputField);
+
+    ResponseFormat responseFormat = buildResponseFormat(outputFieldSchema);
+    boolean multiValued = outputFieldSchema.multiValued();
+
+    ManagedChatModelStore store = ManagedChatModelStore.getManagedModelStore(req.getCore());
+    SolrChatModel chatModel = store.getModel(modelName);
+    if (chatModel == null) {
+      throw new SolrException(
+          SolrException.ErrorCode.SERVER_ERROR,
+          "The model configured in the Update Request Processor '"
+              + modelName
+              + "' can't be found in the store: "
+              + ManagedChatModelStore.REST_END_POINT);
+    }
+
+    return new DocumentEnrichmentUpdateProcessor(
+        inputFields, outputField, promptText, chatModel, multiValued, responseFormat, req, next);
+  }
+
+  /**
+   * Builds a {@link ResponseFormat} that instructs the model to return a JSON object {@code
+   * {"value": ...}} whose value type matches the Solr field type. For multivalued fields the value
+   * is wrapped in a {@link JsonArraySchema} nested inside the root {@link JsonObjectSchema}.
+   *
+   * <p>Nesting {@link JsonArraySchema} inside a {@link JsonObjectSchema} property is supported by
+   * all langchain4j providers that implement structured outputs with {@link JsonObjectSchema} (OpenAI, Azure OpenAI,
+   * Google AI, Gemini, Mistral, Ollama, Amazon Bedrock, Watsonx).
+   */
+  static ResponseFormat buildResponseFormat(SchemaField schemaField) {
+    JsonSchemaElement valueElement = toJsonSchemaElement(schemaField.getType());
+    JsonSchemaElement valueSchema =
+        schemaField.multiValued()
+            ? JsonArraySchema.builder().items(valueElement).build()
+            : valueElement;
+    return ResponseFormat.builder()
+        .type(ResponseFormatType.JSON)
+        .jsonSchema(
+            JsonSchema.builder()
+                .name("output")
+                .rootElement(
+                    JsonObjectSchema.builder()
+                        .addProperty("value", valueSchema)
+                        .required("value")
+                        .build())
+                .build())
+        .build();
+  }
+
+  private static JsonSchemaElement toJsonSchemaElement(FieldType fieldType) {
+    // DenseVectorField extends FloatPointField, so it must be rejected before the numeric checks
+    if (fieldType instanceof DenseVectorField) {
+      throw new SolrException(
+          SolrException.ErrorCode.SERVER_ERROR,
+          "field type is not supported by Document Enrichment: "
+              + fieldType.getClass().getSimpleName());
+    }
+    if (fieldType instanceof StrField
+        || fieldType instanceof TextField
+        || fieldType instanceof DatePointField) {
+      return new JsonStringSchema();
+    } else if (fieldType instanceof IntPointField || fieldType instanceof LongPointField) {
+      return new JsonIntegerSchema();
+    } else if (fieldType instanceof FloatPointField || fieldType instanceof DoublePointField) {
+      return new JsonNumberSchema();
+    } else if (fieldType instanceof BoolField) {
+      return new JsonBooleanSchema();
+    } else {
+      throw new SolrException(
+          SolrException.ErrorCode.SERVER_ERROR,
+          "field type is not supported by Document Enrichment: "
+              + fieldType.getClass().getSimpleName());
+    }
+  }
+
+  private static void validatePromptPlaceholders(String prompt, List<String> fieldNames) {
+    Set<String> promptPlaceholders = new LinkedHashSet<>();
+    Matcher m = PLACEHOLDER_PATTERN.matcher(prompt);
+    while (m.find()) {
+      promptPlaceholders.add(m.group(1));
+    }
+
+    Set<String> missingInPrompt = new LinkedHashSet<>(fieldNames);
+    missingInPrompt.removeAll(promptPlaceholders);
+    if (!missingInPrompt.isEmpty()) {
+      throw new SolrException(
+          SolrException.ErrorCode.SERVER_ERROR,
+          "prompt is missing placeholders for inputField(s): " + missingInPrompt);
+    }
+
+    Set<String> unknownInPrompt = new LinkedHashSet<>(promptPlaceholders);

Review Comment:
   Similar to above: Changed into `placeholdersWithoutField`.



##########
solr/modules/language-models/src/test/org/apache/solr/languagemodels/documentenrichment/update/processor/DocumentEnrichmentUpdateProcessorTest.java:
##########
@@ -0,0 +1,718 @@
+/*
+ * 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.solr.languagemodels.documentenrichment.update.processor;
+
+import java.io.IOException;
+import java.util.Map;
+import org.apache.solr.client.solrj.RemoteSolrException;
+import org.apache.solr.client.solrj.SolrServerException;
+import org.apache.solr.client.solrj.request.SolrQuery;
+import org.apache.solr.client.solrj.request.UpdateRequest;
+import org.apache.solr.common.SolrInputDocument;
+import org.apache.solr.languagemodels.TestLanguageModelBase;
+import org.apache.solr.languagemodels.documentenrichment.store.rest.ManagedChatModelStore;
+import org.junit.After;
+import org.junit.AfterClass;
+import org.junit.BeforeClass;
+import org.junit.Test;
+
+public class DocumentEnrichmentUpdateProcessorTest extends TestLanguageModelBase {
+
+  @BeforeClass
+  public static void init() throws Exception {
+    setupTest("solrconfig-document-enrichment.xml", "schema-language-models.xml", false, false);
+  }
+
+  @AfterClass
+  public static void cleanup() throws Exception {
+    afterTest();
+  }
+
+  private String loadedModelId;
+
+  @After
+  public void afterEachTest() throws Exception {
+    if (loadedModelId != null) {
+      restTestHarness.delete(ManagedChatModelStore.REST_END_POINT + "/" + loadedModelId);
+      loadedModelId = null;
+    }
+  }
+
+  private void loadTestChatModel(String fileName, String modelId) throws Exception {
+    loadChatModel(fileName);
+    loadedModelId = modelId;
+  }
+
+  @Test
+  public void processAdd_inputField_shouldEnrichInputField() throws Exception {
+    loadTestChatModel("dummy-chat-model.json", "dummy-chat-1");
+
+    addWithChain(sdoc("id", "99", "string_field", "Vegeta is the saiyan prince."), "documentEnrichment");
+    addWithChain(sdoc("id", "98", "string_field", "Kakaroth is a saiyan grown up on planet Earth."), "documentEnrichment");
+    assertU(commit());
+
+    final SolrQuery query = getEnrichmentQuery("enriched_field");
+
+    assertJQ(
+        "/query" + query.toQueryString(),
+        "/response/numFound==2]",
+        "/response/docs/[0]/id=='99'",
+        "/response/docs/[0]/enriched_field=='enriched content'",
+        "/response/docs/[1]/id=='98'",
+        "/response/docs/[1]/enriched_field=='enriched content'");
+  }
+
+  /*
+   This test looks for the 'dummy-chat-1' model, but such model is not loaded —
+   the model store is empty, so the update fails.
+  */
+  @Test
+  public void processAdd_modelNotFound_shouldThrowException() {
+    RuntimeException thrown =
+        assertThrows(
+            "model not found should throw an exception",
+            RemoteSolrException.class,
+            () ->
+                addWithChain(
+                    sdoc("id", "99", "string_field", "Vegeta is the saiyan prince."),
+                    "documentEnrichment"));
+    assertTrue(
+        thrown
+            .getMessage()
+            .contains(
+                "The model configured in the Update Request Processor 'dummy-chat-1' can't be found in the store: /schema/chat-model-store"));
+  }
+
+  @Test
+  public void processAdd_emptyInputField_shouldLogAndIndexWithNoEnrichedField() throws Exception {
+    loadTestChatModel("dummy-chat-model.json", "dummy-chat-1");
+    addWithChain(sdoc("id", "99", "string_field", ""), "documentEnrichment");
+    addWithChain(sdoc("id", "98", "string_field", "Vegeta is the saiyan prince."), "documentEnrichment");
+    assertU(commit());
+
+    final SolrQuery query = getEnrichmentQuery("enriched_field");
+
+    assertJQ(
+        "/query" + query.toQueryString(),
+        "/response/numFound==2]",
+        "/response/docs/[0]/id=='99'",
+        "!/response/docs/[0]/enriched_field==", // no enriched field for doc 99
+        "/response/docs/[1]/id=='98'",
+        "/response/docs/[1]/enriched_field=='enriched content'");
+  }
+
+  @Test
+  public void processAdd_nullInputField_shouldLogAndIndexWithNoEnrichedField() throws Exception {
+    loadTestChatModel("dummy-chat-model.json", "dummy-chat-1");
+    addWithChain(sdoc("id", "99", "string_field", "Vegeta is the saiyan prince."), "documentEnrichment");
+    assertU(adoc("id", "98")); // no string_field
+    assertU(commit());
+
+    final SolrQuery query = getEnrichmentQuery("enriched_field");
+
+    assertJQ(
+        "/query" + query.toQueryString(),
+        "/response/numFound==2]",
+        "/response/docs/[0]/id=='99'",
+        "/response/docs/[0]/enriched_field=='enriched content'",
+        "/response/docs/[1]/id=='98'",
+        "!/response/docs/[1]/enriched_field=="); // no enriched field for doc 98
+  }
+
+  @Test
+  public void processAdd_failingEnrichment_shouldLogAndIndexWithNoEnrichedField() throws Exception {
+    loadTestChatModel("exception-throwing-chat-model.json", "exception-throwing-chat-model");
+    addWithChain(sdoc("id", "99", "string_field", "Vegeta is the saiyan prince."), "failingDocumentEnrichment");
+    addWithChain(sdoc("id", "98", "string_field", "Kakaroth is a saiyan grown up on planet Earth."), "failingDocumentEnrichment");
+    assertU(commit());
+
+    final SolrQuery query = getEnrichmentQuery("enriched_field");
+
+    assertJQ(
+        "/query" + query.toQueryString(),
+        "/response/numFound==2]",
+        "/response/docs/[0]/id=='99'",
+        "!/response/docs/[0]/enriched_field==", // no enriched field for doc 99
+        "/response/docs/[1]/id=='98'",
+        "!/response/docs/[1]/enriched_field=="); // no enriched field for doc 98
+  }
+
+  @Test
+  public void processAtomicUpdate_shouldTriggerEnrichmentAndFetchTheStoredContent()
+      throws Exception {
+    // Verifies that when using a processor chain configured for partial updates
+    // (i.e., DistributedUpdateProcessorFactory before DocumentEnrichmentUpdateProcessorFactory),
+    // the system correctly retrieves the stored value of string_field and generates the
+    // enriched content for the document.
+    loadTestChatModel("dummy-chat-model.json", "dummy-chat-1");
+    assertU(adoc("id", "99", "string_field", "Vegeta is the saiyan prince."));
+    assertU(adoc("id", "98", "string_field", "Kakaroth is a saiyan grown up on planet Earth."));
+    assertU(commit());
+
+    SolrInputDocument atomicDoc = new SolrInputDocument();
+    atomicDoc.setField("id", "99");
+    atomicDoc.setField("enriched", Map.of("set", true));
+    addWithChain(atomicDoc, "documentEnrichmentForPartialUpdates");
+    assertU(commit());
+
+    final SolrQuery query = getEnrichmentQuery("enriched_field");
+
+    assertJQ(
+        "/query" + query.toQueryString(),
+        "/response/numFound==2]",
+        "/response/docs/[0]/id=='99'",
+        "/response/docs/[0]/enriched_field=='enriched content'",
+        "/response/docs/[1]/id=='98'",
+        "!/response/docs/[1]/enriched_field==" // no enriched field for document 98
+        );
+  }
+
+  @Test
+  public void processAtomicUpdate_shouldReplaceExistingEnrichedFieldNotAppend() throws Exception {
+    // Verifies that when a document already contains an enriched_field and string_field is
+    // modified via atomic update, the enriched content is recomputed and replaces the previous
+    // value rather than being appended.
+    loadTestChatModel("dummy-chat-model.json", "dummy-chat-1");
+    addWithChain(sdoc("id", "99", "string_field", "Vegeta is the saiyan prince."), "documentEnrichment");
+    addWithChain(sdoc("id", "98", "string_field", "Kakaroth is a saiyan grown up on planet Earth."), "documentEnrichment");
+    assertU(commit());
+
+    SolrInputDocument atomicDoc = new SolrInputDocument();
+    atomicDoc.setField("id", "99");
+    atomicDoc.setField("string_field", Map.of("set", "Vegeta is the saiyan prince from the Dragon Ball series."));
+    addWithChain(atomicDoc, "documentEnrichmentForPartialUpdates");
+    assertU(commit());
+
+    final SolrQuery query = getEnrichmentQuery("enriched_field");
+
+    assertJQ(
+        "/query" + query.toQueryString(),
+        "/response/numFound==2]",
+        "/response/docs/[0]/id=='99'",
+        "/response/docs/[0]/enriched_field=='enriched content'",
+        "/response/docs/[1]/id=='98'",
+        "/response/docs/[1]/enriched_field=='enriched content'");
+  }
+
+  // --- multi-field tests ---
+
+  @Test
+  public void processAdd_multipleInputFields_allPresent_shouldEnrichDocument() throws Exception {

Review Comment:
   Added the param `lastReceivedPrompt` to the DummyChatModel to be able to extract the prompt from the dummy call of the model



##########
solr/modules/language-models/src/test-files/solr/collection1/conf/solrconfig-document-enrichment.xml:
##########
@@ -0,0 +1,235 @@
+<?xml version="1.0" ?>
+<!-- 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. -->
+
+<config>
+    <luceneMatchVersion>${tests.luceneMatchVersion:LATEST}</luceneMatchVersion>
+ <dataDir>${solr.data.dir:}</dataDir>
+ <directoryFactory name="DirectoryFactory"
+                   class="${solr.directoryFactory:solr.MockDirectoryFactory}" />
+ <schemaFactory class="ClassicIndexSchemaFactory" />
+
+ <requestDispatcher>
+   <requestParsers />
+ </requestDispatcher>
+
+ <query>
+  <filterCache class="solr.CaffeineCache" size="4096"
+   initialSize="2048" autowarmCount="0" />
+ </query>
+ <requestHandler name="/select" class="solr.SearchHandler" />
+
+ <updateHandler class="solr.DirectUpdateHandler2">
+  <autoCommit>
+   <maxTime>15000</maxTime>
+   <openSearcher>false</openSearcher>
+  </autoCommit>
+  <autoSoftCommit>
+   <maxTime>1000</maxTime>
+  </autoSoftCommit>
+  <updateLog>
+   <str name="dir">${solr.data.dir:}</str>
+  </updateLog>
+ </updateHandler>
+
+ <requestHandler name="/query" class="solr.SearchHandler">
+  <lst name="defaults">
+   <str name="echoParams">explicit</str>
+   <str name="wt">json</str>
+   <str name="indent">true</str>
+   <str name="df">id</str>
+  </lst>
+ </requestHandler>
+
+ <updateRequestProcessorChain name="documentEnrichment">
+  <processor class="solr.languagemodels.documentenrichment.update.processor.DocumentEnrichmentUpdateProcessorFactory">
+   <str name="inputField">string_field</str>
+   <str name="outputField">enriched_field</str>
+   <str name="prompt">Summarize this content: {string_field}</str>
+   <str name="model">dummy-chat-1</str>
+  </processor>
+  <processor class="solr.RunUpdateProcessorFactory"/>
+ </updateRequestProcessorChain>
+
+ <updateRequestProcessorChain name="failingDocumentEnrichment">
+  <processor class="solr.languagemodels.documentenrichment.update.processor.DocumentEnrichmentUpdateProcessorFactory">
+   <str name="inputField">string_field</str>
+   <str name="outputField">enriched_field</str>
+   <str name="prompt">Summarize this content: {string_field}</str>
+   <str name="model">exception-throwing-chat-model</str>
+  </processor>
+  <processor class="solr.RunUpdateProcessorFactory"/>
+ </updateRequestProcessorChain>
+
+ <updateRequestProcessorChain name="documentEnrichmentForPartialUpdates">
+  <processor class="solr.DistributedUpdateProcessorFactory"/>
+  <processor class="solr.languagemodels.documentenrichment.update.processor.DocumentEnrichmentUpdateProcessorFactory">
+   <str name="inputField">string_field</str>
+   <str name="outputField">enriched_field</str>
+   <str name="prompt">Summarize this content: {string_field}</str>
+   <str name="model">dummy-chat-1</str>
+  </processor>
+  <processor class="solr.RunUpdateProcessorFactory"/>
+ </updateRequestProcessorChain>
+
+ <updateRequestProcessorChain name="documentEnrichmentMultiField">
+  <processor class="solr.languagemodels.documentenrichment.update.processor.DocumentEnrichmentUpdateProcessorFactory">
+   <str name="inputField">string_field</str>
+   <str name="inputField">body_field</str>
+   <str name="outputField">enriched_field</str>
+   <str name="prompt">Title: {string_field}. Body: {body_field}.</str>
+   <str name="model">dummy-chat-1</str>
+  </processor>
+  <processor class="solr.RunUpdateProcessorFactory"/>
+ </updateRequestProcessorChain>
+
+ <updateRequestProcessorChain name="documentEnrichmentMultivaluedString">
+  <processor class="solr.languagemodels.documentenrichment.update.processor.DocumentEnrichmentUpdateProcessorFactory">
+   <str name="inputField">string_field</str>
+   <str name="outputField">enriched_field_multi</str>
+   <str name="prompt">Extract tags from: {string_field}</str>
+   <str name="model">dummy-chat-multivalued-1</str>
+  </processor>
+  <processor class="solr.RunUpdateProcessorFactory"/>
+ </updateRequestProcessorChain>
+
+ <updateRequestProcessorChain name="failingDocumentEnrichmentMultiField">

Review Comment:
   More than one input field is declared. The result is the same since the model is giving an error at run time when the code calls the method `.chat()`. Can also be removed, but here I was testing the component in both cases, with one or more than one input fields



##########
solr/modules/language-models/src/test/org/apache/solr/languagemodels/documentenrichment/update/processor/DocumentEnrichmentUpdateProcessorFactoryTest.java:
##########
@@ -0,0 +1,422 @@
+/*
+ * 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.solr.languagemodels.documentenrichment.update.processor;
+
+import java.util.List;
+import org.apache.solr.common.SolrException;
+import org.apache.solr.common.params.ModifiableSolrParams;
+import org.apache.solr.common.util.NamedList;
+import org.apache.solr.core.SolrCore;
+import org.apache.solr.languagemodels.TestLanguageModelBase;
+import org.apache.solr.languagemodels.documentenrichment.model.SolrChatModel;
+import org.apache.solr.languagemodels.documentenrichment.store.rest.ManagedChatModelStore;
+import org.apache.solr.request.SolrQueryRequestBase;
+import org.apache.solr.update.processor.UpdateRequestProcessor;
+import org.junit.After;
+import org.junit.AfterClass;
+import org.junit.Before;
+import org.junit.BeforeClass;
+import org.junit.Test;
+
+public class DocumentEnrichmentUpdateProcessorFactoryTest extends TestLanguageModelBase {

Review Comment:
   Added



-- 
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: issues-unsubscribe@solr.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


---------------------------------------------------------------------
To unsubscribe, e-mail: issues-unsubscribe@solr.apache.org
For additional commands, e-mail: issues-help@solr.apache.org


Re: [PR] SOLR-18187: Document enrichment with LLMs [solr]

Posted by "alessandrobenedetti (via GitHub)" <gi...@apache.org>.
alessandrobenedetti merged PR #4259:
URL: https://github.com/apache/solr/pull/4259


-- 
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: issues-unsubscribe@solr.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


---------------------------------------------------------------------
To unsubscribe, e-mail: issues-unsubscribe@solr.apache.org
For additional commands, e-mail: issues-help@solr.apache.org


Re: [PR] SOLR-18187: Document enrichment with LLMs [solr]

Posted by "aruggero (via GitHub)" <gi...@apache.org>.
aruggero commented on code in PR #4259:
URL: https://github.com/apache/solr/pull/4259#discussion_r3031955934


##########
solr/modules/language-models/src/java/org/apache/solr/languagemodels/documentenrichment/model/SolrChatModel.java:
##########
@@ -0,0 +1,215 @@
+/*
+ * 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.solr.languagemodels.documentenrichment.model;
+
+import dev.langchain4j.data.message.UserMessage;
+import dev.langchain4j.model.chat.ChatModel;
+import dev.langchain4j.model.chat.request.ChatRequest;
+import dev.langchain4j.model.chat.request.ResponseFormat;
+import java.lang.invoke.MethodHandles;
+import java.lang.reflect.Method;
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.Map;
+import java.util.Objects;
+import org.apache.lucene.util.Accountable;
+import org.apache.lucene.util.RamUsageEstimator;
+import org.apache.solr.common.SolrException;
+import org.apache.solr.common.util.Utils;
+import org.apache.solr.core.SolrResourceLoader;
+import org.apache.solr.languagemodels.documentenrichment.store.ChatModelException;
+import org.apache.solr.languagemodels.documentenrichment.store.rest.ManagedChatModelStore;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * This object wraps a {@link ChatModel} to produce the content of new fields from another.
+ * It's meant to be used as a managed resource with the {@link
+ * ManagedChatModelStore}
+ */
+public class SolrChatModel implements Accountable {
+  private static final Logger log = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());
+  private static final long BASE_RAM_BYTES =
+      RamUsageEstimator.shallowSizeOfInstance(SolrChatModel.class);
+  // timeout is type Duration

Review Comment:
   not here



##########
solr/modules/language-models/src/java/org/apache/solr/languagemodels/documentenrichment/model/SolrChatModel.java:
##########
@@ -0,0 +1,215 @@
+/*
+ * 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.solr.languagemodels.documentenrichment.model;
+
+import dev.langchain4j.data.message.UserMessage;
+import dev.langchain4j.model.chat.ChatModel;
+import dev.langchain4j.model.chat.request.ChatRequest;
+import dev.langchain4j.model.chat.request.ResponseFormat;
+import java.lang.invoke.MethodHandles;
+import java.lang.reflect.Method;
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.Map;
+import java.util.Objects;
+import org.apache.lucene.util.Accountable;
+import org.apache.lucene.util.RamUsageEstimator;
+import org.apache.solr.common.SolrException;
+import org.apache.solr.common.util.Utils;
+import org.apache.solr.core.SolrResourceLoader;
+import org.apache.solr.languagemodels.documentenrichment.store.ChatModelException;
+import org.apache.solr.languagemodels.documentenrichment.store.rest.ManagedChatModelStore;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * This object wraps a {@link ChatModel} to produce the content of new fields from another.
+ * It's meant to be used as a managed resource with the {@link
+ * ManagedChatModelStore}
+ */
+public class SolrChatModel implements Accountable {
+  private static final Logger log = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());
+  private static final long BASE_RAM_BYTES =
+      RamUsageEstimator.shallowSizeOfInstance(SolrChatModel.class);
+  // timeout is type Duration
+  private static final String TIMEOUT_PARAM = "timeout";
+
+  // the followings are Integer type
+  private static final String MAX_RETRIES_PARAM = "maxRetries";
+  private static final String THINKING_BUDGET_TOKENS = "thinkingBudgetTokens";
+  private static final String RANDOM_SEED = "randomSeed";
+
+  private final String name;
+  private final Map<String, Object> params;
+  private final ChatModel chatModel;
+  private final int hashCode;
+
+  public static SolrChatModel getInstance(
+      SolrResourceLoader solrResourceLoader,
+      String className,
+      String name,
+      Map<String, Object> params)
+      throws ChatModelException {
+    try {
+      /*
+       * The idea here is to build a {@link dev.langchain4j.model.chat.ChatModel} using inversion
+       * of control.
+       * Each model has its own list of parameters we don't know beforehand, but each {@link dev.langchain4j.model.chat.ChatModel} class
+       * has its own builder that uses setters with the same name of the parameter in input.
+       * */
+      ChatModel textToTextModel;
+      Class<?> modelClass = solrResourceLoader.findClass(className, ChatModel.class);
+      var builder = modelClass.getMethod("builder").invoke(null);
+      if (params != null) {
+        /*
+         * This block of code has the responsibility of instantiate a {@link

Review Comment:
           /*
            * This block of code has the responsibility of instantiate a {@link
            * dev.langchain4j.model.chat.ChatModel} using the params provided. Classes have
            * params of the specific implementation of {@link
            * dev.langchain4j.model.chat.ChatModel}, which is not known beforehand. So we benefit of
            * the design choice in langchain4j that each subclass implementing {@link
            * dev.langchain4j.model.chat.ChatModel} uses setters with the same name of the
            * param.
            */



##########
solr/modules/language-models/src/java/org/apache/solr/languagemodels/documentenrichment/model/SolrChatModel.java:
##########
@@ -0,0 +1,215 @@
+/*
+ * 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.solr.languagemodels.documentenrichment.model;
+
+import dev.langchain4j.data.message.UserMessage;
+import dev.langchain4j.model.chat.ChatModel;
+import dev.langchain4j.model.chat.request.ChatRequest;
+import dev.langchain4j.model.chat.request.ResponseFormat;
+import java.lang.invoke.MethodHandles;
+import java.lang.reflect.Method;
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.Map;
+import java.util.Objects;
+import org.apache.lucene.util.Accountable;
+import org.apache.lucene.util.RamUsageEstimator;
+import org.apache.solr.common.SolrException;
+import org.apache.solr.common.util.Utils;
+import org.apache.solr.core.SolrResourceLoader;
+import org.apache.solr.languagemodels.documentenrichment.store.ChatModelException;
+import org.apache.solr.languagemodels.documentenrichment.store.rest.ManagedChatModelStore;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * This object wraps a {@link ChatModel} to produce the content of new fields from another.
+ * It's meant to be used as a managed resource with the {@link
+ * ManagedChatModelStore}
+ */
+public class SolrChatModel implements Accountable {
+  private static final Logger log = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());
+  private static final long BASE_RAM_BYTES =
+      RamUsageEstimator.shallowSizeOfInstance(SolrChatModel.class);
+  // timeout is type Duration
+  private static final String TIMEOUT_PARAM = "timeout";
+
+  // the followings are Integer type
+  private static final String MAX_RETRIES_PARAM = "maxRetries";
+  private static final String THINKING_BUDGET_TOKENS = "thinkingBudgetTokens";
+  private static final String RANDOM_SEED = "randomSeed";
+
+  private final String name;
+  private final Map<String, Object> params;
+  private final ChatModel chatModel;
+  private final int hashCode;
+
+  public static SolrChatModel getInstance(
+      SolrResourceLoader solrResourceLoader,
+      String className,
+      String name,
+      Map<String, Object> params)
+      throws ChatModelException {
+    try {
+      /*
+       * The idea here is to build a {@link dev.langchain4j.model.chat.ChatModel} using inversion
+       * of control.
+       * Each model has its own list of parameters we don't know beforehand, but each {@link dev.langchain4j.model.chat.ChatModel} class
+       * has its own builder that uses setters with the same name of the parameter in input.
+       * */
+      ChatModel textToTextModel;
+      Class<?> modelClass = solrResourceLoader.findClass(className, ChatModel.class);
+      var builder = modelClass.getMethod("builder").invoke(null);
+      if (params != null) {
+        /*
+         * This block of code has the responsibility of instantiate a {@link
+         * dev.langchain4j.model.chat.ChatModel} using the params provided.classes have
+         * params of The specific implementation of {@link
+         * dev.langchain4j.model.chat.ChatModel} is not known beforehand. So we benefit of
+         * the design choice in langchain4j that each subclass implementing {@link
+         * dev.langchain4j.model.chat.ChatModel} uses setters with the same name of the
+         * param.
+         */
+        for (String paramName : params.keySet()) {
+          /*
+           * When a param is not primitive, we need to instantiate the object explicitly and then call the
+           * setter method.
+           * N.B. when adding support to new models, pay attention to all the parameters they
+           * support, some of them may require to be handled in here as separate switch cases
+           */
+          switch (paramName) {
+            case TIMEOUT_PARAM -> builder
+                  .getClass()
+                  .getMethod(paramName, Duration.class)
+                  .invoke(builder, Duration.ofSeconds((Long) params.get(paramName)));
+
+            case MAX_RETRIES_PARAM, THINKING_BUDGET_TOKENS, RANDOM_SEED -> builder
+                .getClass()
+                .getMethod(paramName, Integer.class)
+                .invoke(builder, ((Long) params.get(paramName)).intValue());
+
+              /*
+               * For primitive params if there's only one setter available, we call it.
+               * If there's choice we default to the string one
+               */
+            default -> {
+              ArrayList<Method> paramNameMatches = new ArrayList<>();
+              for (var method : builder.getClass().getMethods()) {
+                if (paramName.equals(method.getName()) && method.getParameterCount() == 1) {
+                  paramNameMatches.add(method);
+                }
+              }
+              if (paramNameMatches.size() == 1) {
+                paramNameMatches.getFirst().invoke(builder, params.get(paramName));
+              } else {
+                try {
+                  builder
+                      .getClass()
+                      .getMethod(paramName, String.class)
+                      .invoke(builder, params.get(paramName).toString());
+                } catch (NoSuchMethodException e) {
+                  log.error("Parameter {} not supported by model {}", paramName, className);
+                  throw new SolrException(SolrException.ErrorCode.BAD_REQUEST, e.getMessage(), e);
+                }
+              }
+            }
+          }
+        }
+      }
+      textToTextModel = (ChatModel) builder.getClass().getMethod("build").invoke(builder);
+      return new SolrChatModel(name, textToTextModel, params);
+    } catch (final Exception e) {
+      throw new ChatModelException("Model loading failed for " + className, e);
+    }
+  }
+
+  public SolrChatModel(
+      String name, ChatModel chatModel, Map<String, Object> params) {
+    this.name = name;
+    this.chatModel = chatModel;
+    this.params = params;
+    this.hashCode = calculateHashCode();
+  }
+
+  /**
+   * Sends a structured chat request and returns the parsed value from the {@code {"value": ...}}
+   * JSON object that the model is instructed to produce via {@code responseFormat}.
+   *
+   * @return the extracted value: a {@link String}, {@link Number}, {@link Boolean}, or {@link
+   *     java.util.List} depending on the Solr output field type
+   */
+  public Object chat(String text, ResponseFormat responseFormat) {
+    ChatRequest chatRequest =
+        ChatRequest.builder()
+            .responseFormat(responseFormat)
+            .messages(UserMessage.from(text))
+            .build();
+    String rawJson = chatModel.chat(chatRequest).aiMessage().text();
+    Object parsed = Utils.fromJSONString(rawJson);
+    if (!(parsed instanceof Map<?, ?> map) || !map.containsKey("value")) {

Review Comment:
   Verify if this part is necessary.



##########
solr/modules/language-models/src/java/org/apache/solr/languagemodels/documentenrichment/model/SolrChatModel.java:
##########
@@ -0,0 +1,215 @@
+/*
+ * 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.solr.languagemodels.documentenrichment.model;
+
+import dev.langchain4j.data.message.UserMessage;
+import dev.langchain4j.model.chat.ChatModel;
+import dev.langchain4j.model.chat.request.ChatRequest;
+import dev.langchain4j.model.chat.request.ResponseFormat;
+import java.lang.invoke.MethodHandles;
+import java.lang.reflect.Method;
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.Map;
+import java.util.Objects;
+import org.apache.lucene.util.Accountable;
+import org.apache.lucene.util.RamUsageEstimator;
+import org.apache.solr.common.SolrException;
+import org.apache.solr.common.util.Utils;
+import org.apache.solr.core.SolrResourceLoader;
+import org.apache.solr.languagemodels.documentenrichment.store.ChatModelException;
+import org.apache.solr.languagemodels.documentenrichment.store.rest.ManagedChatModelStore;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * This object wraps a {@link ChatModel} to produce the content of new fields from another.
+ * It's meant to be used as a managed resource with the {@link
+ * ManagedChatModelStore}
+ */
+public class SolrChatModel implements Accountable {
+  private static final Logger log = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());
+  private static final long BASE_RAM_BYTES =
+      RamUsageEstimator.shallowSizeOfInstance(SolrChatModel.class);
+  // timeout is type Duration
+  private static final String TIMEOUT_PARAM = "timeout";
+
+  // the followings are Integer type
+  private static final String MAX_RETRIES_PARAM = "maxRetries";
+  private static final String THINKING_BUDGET_TOKENS = "thinkingBudgetTokens";
+  private static final String RANDOM_SEED = "randomSeed";
+
+  private final String name;
+  private final Map<String, Object> params;
+  private final ChatModel chatModel;
+  private final int hashCode;
+
+  public static SolrChatModel getInstance(
+      SolrResourceLoader solrResourceLoader,
+      String className,
+      String name,
+      Map<String, Object> params)
+      throws ChatModelException {
+    try {
+      /*
+       * The idea here is to build a {@link dev.langchain4j.model.chat.ChatModel} using inversion
+       * of control.
+       * Each model has its own list of parameters we don't know beforehand, but each {@link dev.langchain4j.model.chat.ChatModel} class
+       * has its own builder that uses setters with the same name of the parameter in input.
+       * */
+      ChatModel textToTextModel;
+      Class<?> modelClass = solrResourceLoader.findClass(className, ChatModel.class);
+      var builder = modelClass.getMethod("builder").invoke(null);
+      if (params != null) {
+        /*
+         * This block of code has the responsibility of instantiate a {@link
+         * dev.langchain4j.model.chat.ChatModel} using the params provided.classes have
+         * params of The specific implementation of {@link
+         * dev.langchain4j.model.chat.ChatModel} is not known beforehand. So we benefit of
+         * the design choice in langchain4j that each subclass implementing {@link
+         * dev.langchain4j.model.chat.ChatModel} uses setters with the same name of the
+         * param.
+         */
+        for (String paramName : params.keySet()) {
+          /*
+           * When a param is not primitive, we need to instantiate the object explicitly and then call the
+           * setter method.
+           * N.B. when adding support to new models, pay attention to all the parameters they
+           * support, some of them may require to be handled in here as separate switch cases
+           */
+          switch (paramName) {
+            case TIMEOUT_PARAM -> builder
+                  .getClass()
+                  .getMethod(paramName, Duration.class)
+                  .invoke(builder, Duration.ofSeconds((Long) params.get(paramName)));
+
+            case MAX_RETRIES_PARAM, THINKING_BUDGET_TOKENS, RANDOM_SEED -> builder
+                .getClass()
+                .getMethod(paramName, Integer.class)
+                .invoke(builder, ((Long) params.get(paramName)).intValue());
+
+              /*
+               * For primitive params if there's only one setter available, we call it.
+               * If there's choice we default to the string one
+               */
+            default -> {
+              ArrayList<Method> paramNameMatches = new ArrayList<>();
+              for (var method : builder.getClass().getMethods()) {
+                if (paramName.equals(method.getName()) && method.getParameterCount() == 1) {
+                  paramNameMatches.add(method);
+                }
+              }
+              if (paramNameMatches.size() == 1) {
+                paramNameMatches.getFirst().invoke(builder, params.get(paramName));
+              } else {
+                try {
+                  builder
+                      .getClass()
+                      .getMethod(paramName, String.class)
+                      .invoke(builder, params.get(paramName).toString());
+                } catch (NoSuchMethodException e) {
+                  log.error("Parameter {} not supported by model {}", paramName, className);
+                  throw new SolrException(SolrException.ErrorCode.BAD_REQUEST, e.getMessage(), e);
+                }
+              }
+            }
+          }
+        }
+      }
+      textToTextModel = (ChatModel) builder.getClass().getMethod("build").invoke(builder);
+      return new SolrChatModel(name, textToTextModel, params);
+    } catch (final Exception e) {
+      throw new ChatModelException("Model loading failed for " + className, e);
+    }
+  }
+
+  public SolrChatModel(
+      String name, ChatModel chatModel, Map<String, Object> params) {
+    this.name = name;
+    this.chatModel = chatModel;
+    this.params = params;
+    this.hashCode = calculateHashCode();
+  }
+
+  /**
+   * Sends a structured chat request and returns the parsed value from the {@code {"value": ...}}
+   * JSON object that the model is instructed to produce via {@code responseFormat}.
+   *
+   * @return the extracted value: a {@link String}, {@link Number}, {@link Boolean}, or {@link

Review Comment:
   From what we discussed, is this related to the response of toJsonSchemaElement?
   If it is, don't we also have an integer that could be returned?



##########
solr/modules/language-models/src/java/org/apache/solr/languagemodels/documentenrichment/model/SolrChatModel.java:
##########
@@ -0,0 +1,215 @@
+/*
+ * 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.solr.languagemodels.documentenrichment.model;
+
+import dev.langchain4j.data.message.UserMessage;
+import dev.langchain4j.model.chat.ChatModel;
+import dev.langchain4j.model.chat.request.ChatRequest;
+import dev.langchain4j.model.chat.request.ResponseFormat;
+import java.lang.invoke.MethodHandles;
+import java.lang.reflect.Method;
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.Map;
+import java.util.Objects;
+import org.apache.lucene.util.Accountable;
+import org.apache.lucene.util.RamUsageEstimator;
+import org.apache.solr.common.SolrException;
+import org.apache.solr.common.util.Utils;
+import org.apache.solr.core.SolrResourceLoader;
+import org.apache.solr.languagemodels.documentenrichment.store.ChatModelException;
+import org.apache.solr.languagemodels.documentenrichment.store.rest.ManagedChatModelStore;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * This object wraps a {@link ChatModel} to produce the content of new fields from another.
+ * It's meant to be used as a managed resource with the {@link
+ * ManagedChatModelStore}
+ */
+public class SolrChatModel implements Accountable {
+  private static final Logger log = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());
+  private static final long BASE_RAM_BYTES =
+      RamUsageEstimator.shallowSizeOfInstance(SolrChatModel.class);
+  // timeout is type Duration
+  private static final String TIMEOUT_PARAM = "timeout";
+
+  // the followings are Integer type
+  private static final String MAX_RETRIES_PARAM = "maxRetries";
+  private static final String THINKING_BUDGET_TOKENS = "thinkingBudgetTokens";
+  private static final String RANDOM_SEED = "randomSeed";
+
+  private final String name;
+  private final Map<String, Object> params;
+  private final ChatModel chatModel;
+  private final int hashCode;
+
+  public static SolrChatModel getInstance(
+      SolrResourceLoader solrResourceLoader,
+      String className,
+      String name,
+      Map<String, Object> params)
+      throws ChatModelException {
+    try {
+      /*
+       * The idea here is to build a {@link dev.langchain4j.model.chat.ChatModel} using inversion
+       * of control.
+       * Each model has its own list of parameters we don't know beforehand, but each {@link dev.langchain4j.model.chat.ChatModel} class
+       * has its own builder that uses setters with the same name of the parameter in input.
+       * */
+      ChatModel textToTextModel;
+      Class<?> modelClass = solrResourceLoader.findClass(className, ChatModel.class);
+      var builder = modelClass.getMethod("builder").invoke(null);
+      if (params != null) {
+        /*
+         * This block of code has the responsibility of instantiate a {@link
+         * dev.langchain4j.model.chat.ChatModel} using the params provided.classes have
+         * params of The specific implementation of {@link
+         * dev.langchain4j.model.chat.ChatModel} is not known beforehand. So we benefit of
+         * the design choice in langchain4j that each subclass implementing {@link
+         * dev.langchain4j.model.chat.ChatModel} uses setters with the same name of the
+         * param.
+         */
+        for (String paramName : params.keySet()) {
+          /*
+           * When a param is not primitive, we need to instantiate the object explicitly and then call the
+           * setter method.
+           * N.B. when adding support to new models, pay attention to all the parameters they
+           * support, some of them may require to be handled in here as separate switch cases
+           */
+          switch (paramName) {
+            case TIMEOUT_PARAM -> builder
+                  .getClass()
+                  .getMethod(paramName, Duration.class)
+                  .invoke(builder, Duration.ofSeconds((Long) params.get(paramName)));
+
+            case MAX_RETRIES_PARAM, THINKING_BUDGET_TOKENS, RANDOM_SEED -> builder
+                .getClass()
+                .getMethod(paramName, Integer.class)
+                .invoke(builder, ((Long) params.get(paramName)).intValue());
+
+              /*
+               * For primitive params if there's only one setter available, we call it.
+               * If there's choice we default to the string one
+               */
+            default -> {
+              ArrayList<Method> paramNameMatches = new ArrayList<>();
+              for (var method : builder.getClass().getMethods()) {
+                if (paramName.equals(method.getName()) && method.getParameterCount() == 1) {
+                  paramNameMatches.add(method);
+                }
+              }
+              if (paramNameMatches.size() == 1) {
+                paramNameMatches.getFirst().invoke(builder, params.get(paramName));
+              } else {
+                try {
+                  builder
+                      .getClass()
+                      .getMethod(paramName, String.class)
+                      .invoke(builder, params.get(paramName).toString());
+                } catch (NoSuchMethodException e) {
+                  log.error("Parameter {} not supported by model {}", paramName, className);
+                  throw new SolrException(SolrException.ErrorCode.BAD_REQUEST, e.getMessage(), e);
+                }
+              }
+            }
+          }
+        }
+      }
+      textToTextModel = (ChatModel) builder.getClass().getMethod("build").invoke(builder);
+      return new SolrChatModel(name, textToTextModel, params);
+    } catch (final Exception e) {
+      throw new ChatModelException("Model loading failed for " + className, e);
+    }
+  }
+
+  public SolrChatModel(
+      String name, ChatModel chatModel, Map<String, Object> params) {
+    this.name = name;
+    this.chatModel = chatModel;
+    this.params = params;
+    this.hashCode = calculateHashCode();
+  }
+
+  /**
+   * Sends a structured chat request and returns the parsed value from the {@code {"value": ...}}
+   * JSON object that the model is instructed to produce via {@code responseFormat}.
+   *
+   * @return the extracted value: a {@link String}, {@link Number}, {@link Boolean}, or {@link
+   *     java.util.List} depending on the Solr output field type
+   */
+  public Object chat(String text, ResponseFormat responseFormat) {
+    ChatRequest chatRequest =
+        ChatRequest.builder()
+            .responseFormat(responseFormat)
+            .messages(UserMessage.from(text))
+            .build();
+    String rawJson = chatModel.chat(chatRequest).aiMessage().text();
+    Object parsed = Utils.fromJSONString(rawJson);
+    if (!(parsed instanceof Map<?, ?> map) || !map.containsKey("value")) {
+      throw new SolrException(

Review Comment:
   I would raise a different message. The final user using Solr would not understand what "value" is.
   I would say something like the model is not correctly formatting the response.. or similar..
   



##########
solr/modules/language-models/src/java/org/apache/solr/languagemodels/documentenrichment/update/processor/DocumentEnrichmentUpdateProcessorFactory.java:
##########
@@ -0,0 +1,313 @@
+/*
+ * 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.solr.languagemodels.documentenrichment.update.processor;
+
+import dev.langchain4j.model.chat.request.ResponseFormat;
+import dev.langchain4j.model.chat.request.ResponseFormatType;
+import dev.langchain4j.model.chat.request.json.JsonArraySchema;
+import dev.langchain4j.model.chat.request.json.JsonBooleanSchema;
+import dev.langchain4j.model.chat.request.json.JsonIntegerSchema;
+import dev.langchain4j.model.chat.request.json.JsonNumberSchema;
+import dev.langchain4j.model.chat.request.json.JsonObjectSchema;
+import dev.langchain4j.model.chat.request.json.JsonSchema;
+import dev.langchain4j.model.chat.request.json.JsonSchemaElement;
+import dev.langchain4j.model.chat.request.json.JsonStringSchema;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import java.util.Collection;
+import java.util.HashSet;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import org.apache.solr.common.SolrException;
+import org.apache.solr.common.params.RequiredSolrParams;
+import org.apache.solr.common.params.SolrParams;
+import org.apache.solr.common.util.NamedList;
+import org.apache.solr.core.SolrCore;
+import org.apache.solr.core.SolrResourceLoader;
+import org.apache.solr.languagemodels.documentenrichment.model.SolrChatModel;
+import org.apache.solr.languagemodels.documentenrichment.store.rest.ManagedChatModelStore;
+import org.apache.solr.request.SolrQueryRequest;
+import org.apache.solr.response.SolrQueryResponse;
+import org.apache.solr.rest.ManagedResource;
+import org.apache.solr.rest.ManagedResourceObserver;
+import org.apache.solr.schema.BoolField;
+import org.apache.solr.schema.DatePointField;
+import org.apache.solr.schema.DenseVectorField;
+import org.apache.solr.schema.DoublePointField;
+import org.apache.solr.schema.FieldType;
+import org.apache.solr.schema.FloatPointField;
+import org.apache.solr.schema.IndexSchema;
+import org.apache.solr.schema.IntPointField;
+import org.apache.solr.schema.LongPointField;
+import org.apache.solr.schema.SchemaField;
+import org.apache.solr.schema.StrField;
+import org.apache.solr.schema.TextField;
+import org.apache.solr.update.processor.UpdateRequestProcessor;
+import org.apache.solr.update.processor.UpdateRequestProcessorFactory;
+import org.apache.solr.util.plugin.SolrCoreAware;
+
+/**
+ * Insert in an existing field the output of the model coming from one or more textual field values.

Review Comment:
   Generate the content of a field based on other fields specified as input.
   
   



##########
solr/modules/language-models/src/java/org/apache/solr/languagemodels/documentenrichment/update/processor/DocumentEnrichmentUpdateProcessorFactory.java:
##########
@@ -0,0 +1,313 @@
+/*
+ * 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.solr.languagemodels.documentenrichment.update.processor;
+
+import dev.langchain4j.model.chat.request.ResponseFormat;
+import dev.langchain4j.model.chat.request.ResponseFormatType;
+import dev.langchain4j.model.chat.request.json.JsonArraySchema;
+import dev.langchain4j.model.chat.request.json.JsonBooleanSchema;
+import dev.langchain4j.model.chat.request.json.JsonIntegerSchema;
+import dev.langchain4j.model.chat.request.json.JsonNumberSchema;
+import dev.langchain4j.model.chat.request.json.JsonObjectSchema;
+import dev.langchain4j.model.chat.request.json.JsonSchema;
+import dev.langchain4j.model.chat.request.json.JsonSchemaElement;
+import dev.langchain4j.model.chat.request.json.JsonStringSchema;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import java.util.Collection;
+import java.util.HashSet;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import org.apache.solr.common.SolrException;
+import org.apache.solr.common.params.RequiredSolrParams;
+import org.apache.solr.common.params.SolrParams;
+import org.apache.solr.common.util.NamedList;
+import org.apache.solr.core.SolrCore;
+import org.apache.solr.core.SolrResourceLoader;
+import org.apache.solr.languagemodels.documentenrichment.model.SolrChatModel;
+import org.apache.solr.languagemodels.documentenrichment.store.rest.ManagedChatModelStore;
+import org.apache.solr.request.SolrQueryRequest;
+import org.apache.solr.response.SolrQueryResponse;
+import org.apache.solr.rest.ManagedResource;
+import org.apache.solr.rest.ManagedResourceObserver;
+import org.apache.solr.schema.BoolField;
+import org.apache.solr.schema.DatePointField;
+import org.apache.solr.schema.DenseVectorField;
+import org.apache.solr.schema.DoublePointField;
+import org.apache.solr.schema.FieldType;
+import org.apache.solr.schema.FloatPointField;
+import org.apache.solr.schema.IndexSchema;
+import org.apache.solr.schema.IntPointField;
+import org.apache.solr.schema.LongPointField;
+import org.apache.solr.schema.SchemaField;
+import org.apache.solr.schema.StrField;
+import org.apache.solr.schema.TextField;
+import org.apache.solr.update.processor.UpdateRequestProcessor;
+import org.apache.solr.update.processor.UpdateRequestProcessorFactory;
+import org.apache.solr.util.plugin.SolrCoreAware;
+
+/**
+ * Insert in an existing field the output of the model coming from one or more textual field values.
+ *
+ * <p>One or more {@code inputField} parameters specify the Solr fields to use as input. Each field
+ * name must appear as a {@code {fieldName}} placeholder in the prompt. Exactly one of {@code
+ * prompt} or {@code promptFile} must be provided.
+ *
+ * <pre class="prettyprint" >
+ * &lt;processor class=&quot;solr.llm.documentenrichment.update.processor.DocumentEnrichmentUpdateProcessorFactory&quot;&gt;
+ *   &lt;str name=&quot;inputField&quot;&gt;title_field&lt;/str&gt;
+ *   &lt;str name=&quot;inputField&quot;&gt;body_field&lt;/str&gt;
+ *   &lt;str name=&quot;outputField&quot;&gt;enriched_field&lt;/str&gt;
+ *   &lt;str name=&quot;prompt&quot;&gt;Title: {title_field}. Body: {body_field}.&lt;/str&gt;
+ *   &lt;str name=&quot;model&quot;&gt;ChatModel&lt;/str&gt;
+ * &lt;/processor&gt;
+ * </pre>
+ *
+ * <p>Alternatively, the prompt can be loaded from a text file using {@code promptFile}:
+ *
+ * <pre class="prettyprint" >
+ * &lt;processor class=&quot;solr.llm.documentenrichment.update.processor.DocumentEnrichmentUpdateProcessorFactory&quot;&gt;
+ *   &lt;str name=&quot;inputField&quot;&gt;title_field&lt;/str&gt;
+ *   &lt;str name=&quot;outputField&quot;&gt;enriched_field&lt;/str&gt;
+ *   &lt;str name=&quot;promptFile&quot;&gt;prompt.txt&lt;/str&gt;
+ *   &lt;str name=&quot;model&quot;&gt;ChatModel&lt;/str&gt;
+ * &lt;/processor&gt;
+ * </pre>
+ *
+ * <p>Validation rules:
+ *
+ * <ul>
+ *   <li>At least one {@code inputField} must be declared.
+ *   <li>Exactly one of {@code prompt} or {@code promptFile} must be provided.
+ *   <li>Every declared {@code inputField} must have a corresponding {@code {fieldName}} placeholder
+ *       in the prompt.
+ *   <li>Every {@code {placeholder}} in the prompt must correspond to a declared {@code inputField}.
+ * </ul>
+ */
+public class DocumentEnrichmentUpdateProcessorFactory extends UpdateRequestProcessorFactory
+    implements SolrCoreAware, ManagedResourceObserver {
+  private static final String INPUT_FIELD_PARAM = "inputField";
+  private static final String OUTPUT_FIELD_PARAM = "outputField";
+  private static final String PROMPT = "prompt";
+  private static final String PROMPT_FILE = "promptFile";
+  private static final String MODEL_NAME = "model";
+  private static final Pattern PLACEHOLDER_PATTERN = Pattern.compile("\\{([^}]+)\\}");
+
+  private List<String> inputFields;
+  private String outputField;
+  private String promptText;
+  private String promptFile;
+  private String modelName;
+
+  @Override
+  public void init(final NamedList<?> args) {
+    // removeConfigArgs handles both multiple <str name="inputField"> and <arr name="inputField">
+    // and must be called before toSolrParams() since it mutates args in place
+    Collection<String> fieldNames = args.removeConfigArgs(INPUT_FIELD_PARAM);
+    if (fieldNames.isEmpty()) {
+      throw new SolrException(
+          SolrException.ErrorCode.SERVER_ERROR,
+          "At least one 'inputField' must be provided");
+    }
+    inputFields = List.copyOf(fieldNames);
+
+    SolrParams params = args.toSolrParams();
+    RequiredSolrParams required = params.required();
+    outputField = required.get(OUTPUT_FIELD_PARAM);
+    modelName = required.get(MODEL_NAME);
+
+    String inlinePrompt = params.get(PROMPT);
+    String promptFilePath = params.get(PROMPT_FILE);
+
+    if (inlinePrompt == null && promptFilePath == null) {
+      throw new SolrException(
+          SolrException.ErrorCode.SERVER_ERROR,
+          "Either 'prompt' or 'promptFile' must be provided");
+    }
+    if (inlinePrompt != null && promptFilePath != null) {
+      throw new SolrException(
+          SolrException.ErrorCode.SERVER_ERROR,
+          "Only one of 'prompt' or 'promptFile' can be provided, not both");
+    }
+    if (inlinePrompt != null) {
+      validatePromptPlaceholders(inlinePrompt, inputFields);
+      this.promptText = inlinePrompt;
+    }
+    this.promptFile = promptFilePath;
+  }
+
+  @Override
+  public void inform(SolrCore core) {
+    final SolrResourceLoader solrResourceLoader = core.getResourceLoader();
+    ManagedChatModelStore.registerManagedChatModelStore(solrResourceLoader, this);
+    if (promptFile != null) {
+      try (InputStream is = solrResourceLoader.openResource(promptFile)) {
+        promptText = new String(is.readAllBytes(), StandardCharsets.UTF_8).trim();
+      } catch (IOException e) {
+        throw new SolrException(
+            SolrException.ErrorCode.SERVER_ERROR,
+            "Cannot read prompt file: " + promptFile,
+            e);
+      }
+      validatePromptPlaceholders(promptText, inputFields);
+    }
+  }
+
+  @Override
+  public void onManagedResourceInitialized(NamedList<?> args, ManagedResource res)
+      throws SolrException {
+    if (res instanceof ManagedChatModelStore store) {
+      store.loadStoredModels();
+    }
+  }
+
+  @Override
+  public UpdateRequestProcessor getInstance(
+      SolrQueryRequest req, SolrQueryResponse rsp, UpdateRequestProcessor next) {
+    IndexSchema latestSchema = req.getCore().getLatestSchema();
+
+    for (String fieldName : inputFields) {
+      if (!latestSchema.isDynamicField(fieldName) && !latestSchema.hasExplicitField(fieldName)) {
+        throw new SolrException(
+            SolrException.ErrorCode.SERVER_ERROR, "undefined field: \"" + fieldName + "\"");
+      }
+    }
+
+    final SchemaField outputFieldSchema = latestSchema.getField(outputField);
+
+    ResponseFormat responseFormat = buildResponseFormat(outputFieldSchema);
+    boolean multiValued = outputFieldSchema.multiValued();
+
+    ManagedChatModelStore store = ManagedChatModelStore.getManagedModelStore(req.getCore());
+    SolrChatModel chatModel = store.getModel(modelName);
+    if (chatModel == null) {
+      throw new SolrException(
+          SolrException.ErrorCode.SERVER_ERROR,
+          "The model configured in the Update Request Processor '"
+              + modelName
+              + "' can't be found in the store: "
+              + ManagedChatModelStore.REST_END_POINT);
+    }
+
+    return new DocumentEnrichmentUpdateProcessor(
+        inputFields, outputField, promptText, chatModel, multiValued, responseFormat, req, next);
+  }
+
+  /**
+   * Builds a {@link ResponseFormat} that instructs the model to return a JSON object {@code
+   * {"value": ...}} whose value type matches the Solr field type. For multivalued fields the value
+   * is wrapped in a {@link JsonArraySchema} nested inside the root {@link JsonObjectSchema}.
+   *
+   * <p>Nesting {@link JsonArraySchema} inside a {@link JsonObjectSchema} property is supported by
+   * all langchain4j providers that implement structured outputs with {@link JsonObjectSchema} (OpenAI, Azure OpenAI,
+   * Google AI, Gemini, Mistral, Ollama, Amazon Bedrock, Watsonx).
+   */
+  static ResponseFormat buildResponseFormat(SchemaField schemaField) {
+    JsonSchemaElement valueElement = toJsonSchemaElement(schemaField.getType());
+    JsonSchemaElement valueSchema =
+        schemaField.multiValued()
+            ? JsonArraySchema.builder().items(valueElement).build()
+            : valueElement;
+    return ResponseFormat.builder()
+        .type(ResponseFormatType.JSON)
+        .jsonSchema(
+            JsonSchema.builder()
+                .name("output")
+                .rootElement(
+                    JsonObjectSchema.builder()
+                        .addProperty("value", valueSchema)
+                        .required("value")
+                        .build())
+                .build())
+        .build();
+  }
+
+  private static JsonSchemaElement toJsonSchemaElement(FieldType fieldType) {
+    // DenseVectorField extends FloatPointField, so it must be rejected before the numeric checks
+    if (fieldType instanceof DenseVectorField) {
+      throw new SolrException(
+          SolrException.ErrorCode.SERVER_ERROR,
+          "field type is not supported by Document Enrichment: "
+              + fieldType.getClass().getSimpleName());
+    }
+    if (fieldType instanceof StrField
+        || fieldType instanceof TextField
+        || fieldType instanceof DatePointField) {
+      return new JsonStringSchema();
+    } else if (fieldType instanceof IntPointField || fieldType instanceof LongPointField) {
+      return new JsonIntegerSchema();
+    } else if (fieldType instanceof FloatPointField || fieldType instanceof DoublePointField) {
+      return new JsonNumberSchema();
+    } else if (fieldType instanceof BoolField) {
+      return new JsonBooleanSchema();
+    } else {
+      throw new SolrException(
+          SolrException.ErrorCode.SERVER_ERROR,
+          "field type is not supported by Document Enrichment: "
+              + fieldType.getClass().getSimpleName());
+    }
+  }
+
+  private static void validatePromptPlaceholders(String prompt, List<String> fieldNames) {
+    Set<String> promptPlaceholders = new LinkedHashSet<>();
+    Matcher m = PLACEHOLDER_PATTERN.matcher(prompt);
+    while (m.find()) {
+      promptPlaceholders.add(m.group(1));
+    }
+
+    Set<String> missingInPrompt = new LinkedHashSet<>(fieldNames);

Review Comment:
   Check var names



##########
solr/modules/language-models/src/java/org/apache/solr/languagemodels/documentenrichment/model/SolrChatModel.java:
##########
@@ -0,0 +1,215 @@
+/*
+ * 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.solr.languagemodels.documentenrichment.model;
+
+import dev.langchain4j.data.message.UserMessage;
+import dev.langchain4j.model.chat.ChatModel;
+import dev.langchain4j.model.chat.request.ChatRequest;
+import dev.langchain4j.model.chat.request.ResponseFormat;
+import java.lang.invoke.MethodHandles;
+import java.lang.reflect.Method;
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.Map;
+import java.util.Objects;
+import org.apache.lucene.util.Accountable;
+import org.apache.lucene.util.RamUsageEstimator;
+import org.apache.solr.common.SolrException;
+import org.apache.solr.common.util.Utils;
+import org.apache.solr.core.SolrResourceLoader;
+import org.apache.solr.languagemodels.documentenrichment.store.ChatModelException;
+import org.apache.solr.languagemodels.documentenrichment.store.rest.ManagedChatModelStore;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * This object wraps a {@link ChatModel} to produce the content of new fields from another.
+ * It's meant to be used as a managed resource with the {@link
+ * ManagedChatModelStore}
+ */
+public class SolrChatModel implements Accountable {
+  private static final Logger log = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());
+  private static final long BASE_RAM_BYTES =
+      RamUsageEstimator.shallowSizeOfInstance(SolrChatModel.class);
+  // timeout is type Duration

Review Comment:
   remove



##########
solr/modules/language-models/src/java/org/apache/solr/languagemodels/documentenrichment/model/SolrChatModel.java:
##########
@@ -0,0 +1,215 @@
+/*
+ * 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.solr.languagemodels.documentenrichment.model;
+
+import dev.langchain4j.data.message.UserMessage;
+import dev.langchain4j.model.chat.ChatModel;
+import dev.langchain4j.model.chat.request.ChatRequest;
+import dev.langchain4j.model.chat.request.ResponseFormat;
+import java.lang.invoke.MethodHandles;
+import java.lang.reflect.Method;
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.Map;
+import java.util.Objects;
+import org.apache.lucene.util.Accountable;
+import org.apache.lucene.util.RamUsageEstimator;
+import org.apache.solr.common.SolrException;
+import org.apache.solr.common.util.Utils;
+import org.apache.solr.core.SolrResourceLoader;
+import org.apache.solr.languagemodels.documentenrichment.store.ChatModelException;
+import org.apache.solr.languagemodels.documentenrichment.store.rest.ManagedChatModelStore;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * This object wraps a {@link ChatModel} to produce the content of new fields from another.
+ * It's meant to be used as a managed resource with the {@link
+ * ManagedChatModelStore}
+ */
+public class SolrChatModel implements Accountable {
+  private static final Logger log = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());
+  private static final long BASE_RAM_BYTES =
+      RamUsageEstimator.shallowSizeOfInstance(SolrChatModel.class);
+  // timeout is type Duration

Review Comment:
   I can understand what you mean since we discussed this before, but it's not clear to others. I would not specify this here.
   A person would just read that the variable TIMEOUT_PARAM is of type Duration while here it is declared as String.



##########
solr/modules/language-models/src/java/org/apache/solr/languagemodels/documentenrichment/update/processor/DocumentEnrichmentUpdateProcessorFactory.java:
##########
@@ -0,0 +1,313 @@
+/*
+ * 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.solr.languagemodels.documentenrichment.update.processor;
+
+import dev.langchain4j.model.chat.request.ResponseFormat;
+import dev.langchain4j.model.chat.request.ResponseFormatType;
+import dev.langchain4j.model.chat.request.json.JsonArraySchema;
+import dev.langchain4j.model.chat.request.json.JsonBooleanSchema;
+import dev.langchain4j.model.chat.request.json.JsonIntegerSchema;
+import dev.langchain4j.model.chat.request.json.JsonNumberSchema;
+import dev.langchain4j.model.chat.request.json.JsonObjectSchema;
+import dev.langchain4j.model.chat.request.json.JsonSchema;
+import dev.langchain4j.model.chat.request.json.JsonSchemaElement;
+import dev.langchain4j.model.chat.request.json.JsonStringSchema;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import java.util.Collection;
+import java.util.HashSet;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import org.apache.solr.common.SolrException;
+import org.apache.solr.common.params.RequiredSolrParams;
+import org.apache.solr.common.params.SolrParams;
+import org.apache.solr.common.util.NamedList;
+import org.apache.solr.core.SolrCore;
+import org.apache.solr.core.SolrResourceLoader;
+import org.apache.solr.languagemodels.documentenrichment.model.SolrChatModel;
+import org.apache.solr.languagemodels.documentenrichment.store.rest.ManagedChatModelStore;
+import org.apache.solr.request.SolrQueryRequest;
+import org.apache.solr.response.SolrQueryResponse;
+import org.apache.solr.rest.ManagedResource;
+import org.apache.solr.rest.ManagedResourceObserver;
+import org.apache.solr.schema.BoolField;
+import org.apache.solr.schema.DatePointField;
+import org.apache.solr.schema.DenseVectorField;
+import org.apache.solr.schema.DoublePointField;
+import org.apache.solr.schema.FieldType;
+import org.apache.solr.schema.FloatPointField;
+import org.apache.solr.schema.IndexSchema;
+import org.apache.solr.schema.IntPointField;
+import org.apache.solr.schema.LongPointField;
+import org.apache.solr.schema.SchemaField;
+import org.apache.solr.schema.StrField;
+import org.apache.solr.schema.TextField;
+import org.apache.solr.update.processor.UpdateRequestProcessor;
+import org.apache.solr.update.processor.UpdateRequestProcessorFactory;
+import org.apache.solr.util.plugin.SolrCoreAware;
+
+/**
+ * Insert in an existing field the output of the model coming from one or more textual field values.
+ *
+ * <p>One or more {@code inputField} parameters specify the Solr fields to use as input. Each field
+ * name must appear as a {@code {fieldName}} placeholder in the prompt. Exactly one of {@code
+ * prompt} or {@code promptFile} must be provided.
+ *
+ * <pre class="prettyprint" >
+ * &lt;processor class=&quot;solr.llm.documentenrichment.update.processor.DocumentEnrichmentUpdateProcessorFactory&quot;&gt;
+ *   &lt;str name=&quot;inputField&quot;&gt;title_field&lt;/str&gt;
+ *   &lt;str name=&quot;inputField&quot;&gt;body_field&lt;/str&gt;
+ *   &lt;str name=&quot;outputField&quot;&gt;enriched_field&lt;/str&gt;
+ *   &lt;str name=&quot;prompt&quot;&gt;Title: {title_field}. Body: {body_field}.&lt;/str&gt;
+ *   &lt;str name=&quot;model&quot;&gt;ChatModel&lt;/str&gt;
+ * &lt;/processor&gt;
+ * </pre>
+ *
+ * <p>Alternatively, the prompt can be loaded from a text file using {@code promptFile}:
+ *
+ * <pre class="prettyprint" >
+ * &lt;processor class=&quot;solr.llm.documentenrichment.update.processor.DocumentEnrichmentUpdateProcessorFactory&quot;&gt;
+ *   &lt;str name=&quot;inputField&quot;&gt;title_field&lt;/str&gt;
+ *   &lt;str name=&quot;outputField&quot;&gt;enriched_field&lt;/str&gt;
+ *   &lt;str name=&quot;promptFile&quot;&gt;prompt.txt&lt;/str&gt;
+ *   &lt;str name=&quot;model&quot;&gt;ChatModel&lt;/str&gt;
+ * &lt;/processor&gt;
+ * </pre>
+ *
+ * <p>Validation rules:
+ *
+ * <ul>
+ *   <li>At least one {@code inputField} must be declared.
+ *   <li>Exactly one of {@code prompt} or {@code promptFile} must be provided.
+ *   <li>Every declared {@code inputField} must have a corresponding {@code {fieldName}} placeholder
+ *       in the prompt.
+ *   <li>Every {@code {placeholder}} in the prompt must correspond to a declared {@code inputField}.
+ * </ul>
+ */
+public class DocumentEnrichmentUpdateProcessorFactory extends UpdateRequestProcessorFactory
+    implements SolrCoreAware, ManagedResourceObserver {
+  private static final String INPUT_FIELD_PARAM = "inputField";
+  private static final String OUTPUT_FIELD_PARAM = "outputField";
+  private static final String PROMPT = "prompt";
+  private static final String PROMPT_FILE = "promptFile";
+  private static final String MODEL_NAME = "model";
+  private static final Pattern PLACEHOLDER_PATTERN = Pattern.compile("\\{([^}]+)\\}");
+
+  private List<String> inputFields;
+  private String outputField;
+  private String promptText;
+  private String promptFile;
+  private String modelName;
+
+  @Override
+  public void init(final NamedList<?> args) {
+    // removeConfigArgs handles both multiple <str name="inputField"> and <arr name="inputField">
+    // and must be called before toSolrParams() since it mutates args in place
+    Collection<String> fieldNames = args.removeConfigArgs(INPUT_FIELD_PARAM);
+    if (fieldNames.isEmpty()) {
+      throw new SolrException(
+          SolrException.ErrorCode.SERVER_ERROR,
+          "At least one 'inputField' must be provided");
+    }
+    inputFields = List.copyOf(fieldNames);
+
+    SolrParams params = args.toSolrParams();
+    RequiredSolrParams required = params.required();
+    outputField = required.get(OUTPUT_FIELD_PARAM);
+    modelName = required.get(MODEL_NAME);
+
+    String inlinePrompt = params.get(PROMPT);
+    String promptFilePath = params.get(PROMPT_FILE);
+
+    if (inlinePrompt == null && promptFilePath == null) {
+      throw new SolrException(
+          SolrException.ErrorCode.SERVER_ERROR,
+          "Either 'prompt' or 'promptFile' must be provided");
+    }
+    if (inlinePrompt != null && promptFilePath != null) {
+      throw new SolrException(
+          SolrException.ErrorCode.SERVER_ERROR,
+          "Only one of 'prompt' or 'promptFile' can be provided, not both");
+    }
+    if (inlinePrompt != null) {
+      validatePromptPlaceholders(inlinePrompt, inputFields);
+      this.promptText = inlinePrompt;
+    }
+    this.promptFile = promptFilePath;
+  }
+
+  @Override
+  public void inform(SolrCore core) {
+    final SolrResourceLoader solrResourceLoader = core.getResourceLoader();
+    ManagedChatModelStore.registerManagedChatModelStore(solrResourceLoader, this);
+    if (promptFile != null) {
+      try (InputStream is = solrResourceLoader.openResource(promptFile)) {
+        promptText = new String(is.readAllBytes(), StandardCharsets.UTF_8).trim();
+      } catch (IOException e) {
+        throw new SolrException(
+            SolrException.ErrorCode.SERVER_ERROR,
+            "Cannot read prompt file: " + promptFile,
+            e);
+      }
+      validatePromptPlaceholders(promptText, inputFields);
+    }
+  }
+
+  @Override
+  public void onManagedResourceInitialized(NamedList<?> args, ManagedResource res)
+      throws SolrException {
+    if (res instanceof ManagedChatModelStore store) {
+      store.loadStoredModels();
+    }
+  }
+
+  @Override
+  public UpdateRequestProcessor getInstance(
+      SolrQueryRequest req, SolrQueryResponse rsp, UpdateRequestProcessor next) {
+    IndexSchema latestSchema = req.getCore().getLatestSchema();
+
+    for (String fieldName : inputFields) {
+      if (!latestSchema.isDynamicField(fieldName) && !latestSchema.hasExplicitField(fieldName)) {
+        throw new SolrException(
+            SolrException.ErrorCode.SERVER_ERROR, "undefined field: \"" + fieldName + "\"");
+      }
+    }
+
+    final SchemaField outputFieldSchema = latestSchema.getField(outputField);
+
+    ResponseFormat responseFormat = buildResponseFormat(outputFieldSchema);
+    boolean multiValued = outputFieldSchema.multiValued();
+
+    ManagedChatModelStore store = ManagedChatModelStore.getManagedModelStore(req.getCore());
+    SolrChatModel chatModel = store.getModel(modelName);
+    if (chatModel == null) {
+      throw new SolrException(
+          SolrException.ErrorCode.SERVER_ERROR,
+          "The model configured in the Update Request Processor '"
+              + modelName
+              + "' can't be found in the store: "
+              + ManagedChatModelStore.REST_END_POINT);
+    }
+
+    return new DocumentEnrichmentUpdateProcessor(
+        inputFields, outputField, promptText, chatModel, multiValued, responseFormat, req, next);
+  }
+
+  /**
+   * Builds a {@link ResponseFormat} that instructs the model to return a JSON object {@code
+   * {"value": ...}} whose value type matches the Solr field type. For multivalued fields the value
+   * is wrapped in a {@link JsonArraySchema} nested inside the root {@link JsonObjectSchema}.
+   *
+   * <p>Nesting {@link JsonArraySchema} inside a {@link JsonObjectSchema} property is supported by
+   * all langchain4j providers that implement structured outputs with {@link JsonObjectSchema} (OpenAI, Azure OpenAI,
+   * Google AI, Gemini, Mistral, Ollama, Amazon Bedrock, Watsonx).
+   */
+  static ResponseFormat buildResponseFormat(SchemaField schemaField) {
+    JsonSchemaElement valueElement = toJsonSchemaElement(schemaField.getType());
+    JsonSchemaElement valueSchema =
+        schemaField.multiValued()
+            ? JsonArraySchema.builder().items(valueElement).build()
+            : valueElement;
+    return ResponseFormat.builder()
+        .type(ResponseFormatType.JSON)
+        .jsonSchema(
+            JsonSchema.builder()
+                .name("output")
+                .rootElement(
+                    JsonObjectSchema.builder()
+                        .addProperty("value", valueSchema)
+                        .required("value")
+                        .build())
+                .build())
+        .build();
+  }
+
+  private static JsonSchemaElement toJsonSchemaElement(FieldType fieldType) {
+    // DenseVectorField extends FloatPointField, so it must be rejected before the numeric checks
+    if (fieldType instanceof DenseVectorField) {
+      throw new SolrException(
+          SolrException.ErrorCode.SERVER_ERROR,
+          "field type is not supported by Document Enrichment: "
+              + fieldType.getClass().getSimpleName());
+    }
+    if (fieldType instanceof StrField
+        || fieldType instanceof TextField
+        || fieldType instanceof DatePointField) {
+      return new JsonStringSchema();
+    } else if (fieldType instanceof IntPointField || fieldType instanceof LongPointField) {
+      return new JsonIntegerSchema();
+    } else if (fieldType instanceof FloatPointField || fieldType instanceof DoublePointField) {
+      return new JsonNumberSchema();
+    } else if (fieldType instanceof BoolField) {
+      return new JsonBooleanSchema();
+    } else {
+      throw new SolrException(
+          SolrException.ErrorCode.SERVER_ERROR,
+          "field type is not supported by Document Enrichment: "
+              + fieldType.getClass().getSimpleName());
+    }
+  }
+
+  private static void validatePromptPlaceholders(String prompt, List<String> fieldNames) {
+    Set<String> promptPlaceholders = new LinkedHashSet<>();
+    Matcher m = PLACEHOLDER_PATTERN.matcher(prompt);
+    while (m.find()) {
+      promptPlaceholders.add(m.group(1));
+    }
+
+    Set<String> missingInPrompt = new LinkedHashSet<>(fieldNames);
+    missingInPrompt.removeAll(promptPlaceholders);
+    if (!missingInPrompt.isEmpty()) {
+      throw new SolrException(
+          SolrException.ErrorCode.SERVER_ERROR,
+          "prompt is missing placeholders for inputField(s): " + missingInPrompt);
+    }
+
+    Set<String> unknownInPrompt = new LinkedHashSet<>(promptPlaceholders);
+    unknownInPrompt.removeAll(new HashSet<>(fieldNames));

Review Comment:
   HashSet or LinkedHashSet?



##########
solr/modules/language-models/src/java/org/apache/solr/languagemodels/documentenrichment/model/SolrChatModel.java:
##########
@@ -0,0 +1,215 @@
+/*
+ * 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.solr.languagemodels.documentenrichment.model;
+
+import dev.langchain4j.data.message.UserMessage;
+import dev.langchain4j.model.chat.ChatModel;
+import dev.langchain4j.model.chat.request.ChatRequest;
+import dev.langchain4j.model.chat.request.ResponseFormat;
+import java.lang.invoke.MethodHandles;
+import java.lang.reflect.Method;
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.Map;
+import java.util.Objects;
+import org.apache.lucene.util.Accountable;
+import org.apache.lucene.util.RamUsageEstimator;
+import org.apache.solr.common.SolrException;
+import org.apache.solr.common.util.Utils;
+import org.apache.solr.core.SolrResourceLoader;
+import org.apache.solr.languagemodels.documentenrichment.store.ChatModelException;
+import org.apache.solr.languagemodels.documentenrichment.store.rest.ManagedChatModelStore;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * This object wraps a {@link ChatModel} to produce the content of new fields from another.
+ * It's meant to be used as a managed resource with the {@link
+ * ManagedChatModelStore}
+ */
+public class SolrChatModel implements Accountable {
+  private static final Logger log = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());
+  private static final long BASE_RAM_BYTES =
+      RamUsageEstimator.shallowSizeOfInstance(SolrChatModel.class);
+  // timeout is type Duration
+  private static final String TIMEOUT_PARAM = "timeout";
+
+  // the followings are Integer type
+  private static final String MAX_RETRIES_PARAM = "maxRetries";
+  private static final String THINKING_BUDGET_TOKENS = "thinkingBudgetTokens";
+  private static final String RANDOM_SEED = "randomSeed";
+
+  private final String name;
+  private final Map<String, Object> params;
+  private final ChatModel chatModel;
+  private final int hashCode;
+
+  public static SolrChatModel getInstance(
+      SolrResourceLoader solrResourceLoader,
+      String className,
+      String name,
+      Map<String, Object> params)
+      throws ChatModelException {
+    try {
+      /*
+       * The idea here is to build a {@link dev.langchain4j.model.chat.ChatModel} using inversion
+       * of control.
+       * Each model has its own list of parameters we don't know beforehand, but each {@link dev.langchain4j.model.chat.ChatModel} class
+       * has its own builder that uses setters with the same name of the parameter in input.
+       * */
+      ChatModel textToTextModel;
+      Class<?> modelClass = solrResourceLoader.findClass(className, ChatModel.class);
+      var builder = modelClass.getMethod("builder").invoke(null);
+      if (params != null) {
+        /*
+         * This block of code has the responsibility of instantiate a {@link
+         * dev.langchain4j.model.chat.ChatModel} using the params provided.classes have
+         * params of The specific implementation of {@link
+         * dev.langchain4j.model.chat.ChatModel} is not known beforehand. So we benefit of
+         * the design choice in langchain4j that each subclass implementing {@link
+         * dev.langchain4j.model.chat.ChatModel} uses setters with the same name of the
+         * param.
+         */
+        for (String paramName : params.keySet()) {
+          /*
+           * When a param is not primitive, we need to instantiate the object explicitly and then call the
+           * setter method.
+           * N.B. when adding support to new models, pay attention to all the parameters they
+           * support, some of them may require to be handled in here as separate switch cases
+           */
+          switch (paramName) {
+            case TIMEOUT_PARAM -> builder
+                  .getClass()
+                  .getMethod(paramName, Duration.class)
+                  .invoke(builder, Duration.ofSeconds((Long) params.get(paramName)));
+
+            case MAX_RETRIES_PARAM, THINKING_BUDGET_TOKENS, RANDOM_SEED -> builder
+                .getClass()
+                .getMethod(paramName, Integer.class)
+                .invoke(builder, ((Long) params.get(paramName)).intValue());
+
+              /*
+               * For primitive params if there's only one setter available, we call it.
+               * If there's choice we default to the string one
+               */
+            default -> {
+              ArrayList<Method> paramNameMatches = new ArrayList<>();
+              for (var method : builder.getClass().getMethods()) {
+                if (paramName.equals(method.getName()) && method.getParameterCount() == 1) {
+                  paramNameMatches.add(method);
+                }
+              }
+              if (paramNameMatches.size() == 1) {
+                paramNameMatches.getFirst().invoke(builder, params.get(paramName));
+              } else {
+                try {
+                  builder
+                      .getClass()
+                      .getMethod(paramName, String.class)
+                      .invoke(builder, params.get(paramName).toString());
+                } catch (NoSuchMethodException e) {
+                  log.error("Parameter {} not supported by model {}", paramName, className);
+                  throw new SolrException(SolrException.ErrorCode.BAD_REQUEST, e.getMessage(), e);
+                }
+              }
+            }
+          }
+        }
+      }
+      textToTextModel = (ChatModel) builder.getClass().getMethod("build").invoke(builder);

Review Comment:
   Probably I would call it chatModel to maintain the same nomenclature.



##########
solr/modules/language-models/src/java/org/apache/solr/languagemodels/documentenrichment/update/processor/DocumentEnrichmentUpdateProcessorFactory.java:
##########
@@ -0,0 +1,313 @@
+/*
+ * 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.solr.languagemodels.documentenrichment.update.processor;
+
+import dev.langchain4j.model.chat.request.ResponseFormat;
+import dev.langchain4j.model.chat.request.ResponseFormatType;
+import dev.langchain4j.model.chat.request.json.JsonArraySchema;
+import dev.langchain4j.model.chat.request.json.JsonBooleanSchema;
+import dev.langchain4j.model.chat.request.json.JsonIntegerSchema;
+import dev.langchain4j.model.chat.request.json.JsonNumberSchema;
+import dev.langchain4j.model.chat.request.json.JsonObjectSchema;
+import dev.langchain4j.model.chat.request.json.JsonSchema;
+import dev.langchain4j.model.chat.request.json.JsonSchemaElement;
+import dev.langchain4j.model.chat.request.json.JsonStringSchema;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import java.util.Collection;
+import java.util.HashSet;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import org.apache.solr.common.SolrException;
+import org.apache.solr.common.params.RequiredSolrParams;
+import org.apache.solr.common.params.SolrParams;
+import org.apache.solr.common.util.NamedList;
+import org.apache.solr.core.SolrCore;
+import org.apache.solr.core.SolrResourceLoader;
+import org.apache.solr.languagemodels.documentenrichment.model.SolrChatModel;
+import org.apache.solr.languagemodels.documentenrichment.store.rest.ManagedChatModelStore;
+import org.apache.solr.request.SolrQueryRequest;
+import org.apache.solr.response.SolrQueryResponse;
+import org.apache.solr.rest.ManagedResource;
+import org.apache.solr.rest.ManagedResourceObserver;
+import org.apache.solr.schema.BoolField;
+import org.apache.solr.schema.DatePointField;
+import org.apache.solr.schema.DenseVectorField;
+import org.apache.solr.schema.DoublePointField;
+import org.apache.solr.schema.FieldType;
+import org.apache.solr.schema.FloatPointField;
+import org.apache.solr.schema.IndexSchema;
+import org.apache.solr.schema.IntPointField;
+import org.apache.solr.schema.LongPointField;
+import org.apache.solr.schema.SchemaField;
+import org.apache.solr.schema.StrField;
+import org.apache.solr.schema.TextField;
+import org.apache.solr.update.processor.UpdateRequestProcessor;
+import org.apache.solr.update.processor.UpdateRequestProcessorFactory;
+import org.apache.solr.util.plugin.SolrCoreAware;
+
+/**
+ * Insert in an existing field the output of the model coming from one or more textual field values.

Review Comment:
   (not only textual :))



##########
solr/modules/language-models/src/java/org/apache/solr/languagemodels/documentenrichment/model/SolrChatModel.java:
##########
@@ -0,0 +1,215 @@
+/*
+ * 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.solr.languagemodels.documentenrichment.model;
+
+import dev.langchain4j.data.message.UserMessage;
+import dev.langchain4j.model.chat.ChatModel;
+import dev.langchain4j.model.chat.request.ChatRequest;
+import dev.langchain4j.model.chat.request.ResponseFormat;
+import java.lang.invoke.MethodHandles;
+import java.lang.reflect.Method;
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.Map;
+import java.util.Objects;
+import org.apache.lucene.util.Accountable;
+import org.apache.lucene.util.RamUsageEstimator;
+import org.apache.solr.common.SolrException;
+import org.apache.solr.common.util.Utils;
+import org.apache.solr.core.SolrResourceLoader;
+import org.apache.solr.languagemodels.documentenrichment.store.ChatModelException;
+import org.apache.solr.languagemodels.documentenrichment.store.rest.ManagedChatModelStore;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * This object wraps a {@link ChatModel} to produce the content of new fields from another.

Review Comment:
   dev.langchain4j.model.chat.ChatModel



##########
solr/modules/language-models/src/java/org/apache/solr/languagemodels/documentenrichment/model/SolrChatModel.java:
##########
@@ -0,0 +1,215 @@
+/*
+ * 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.solr.languagemodels.documentenrichment.model;
+
+import dev.langchain4j.data.message.UserMessage;
+import dev.langchain4j.model.chat.ChatModel;
+import dev.langchain4j.model.chat.request.ChatRequest;
+import dev.langchain4j.model.chat.request.ResponseFormat;
+import java.lang.invoke.MethodHandles;
+import java.lang.reflect.Method;
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.Map;
+import java.util.Objects;
+import org.apache.lucene.util.Accountable;
+import org.apache.lucene.util.RamUsageEstimator;
+import org.apache.solr.common.SolrException;
+import org.apache.solr.common.util.Utils;
+import org.apache.solr.core.SolrResourceLoader;
+import org.apache.solr.languagemodels.documentenrichment.store.ChatModelException;
+import org.apache.solr.languagemodels.documentenrichment.store.rest.ManagedChatModelStore;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * This object wraps a {@link ChatModel} to produce the content of new fields from another.
+ * It's meant to be used as a managed resource with the {@link
+ * ManagedChatModelStore}
+ */
+public class SolrChatModel implements Accountable {
+  private static final Logger log = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());
+  private static final long BASE_RAM_BYTES =
+      RamUsageEstimator.shallowSizeOfInstance(SolrChatModel.class);
+  // timeout is type Duration
+  private static final String TIMEOUT_PARAM = "timeout";
+
+  // the followings are Integer type
+  private static final String MAX_RETRIES_PARAM = "maxRetries";
+  private static final String THINKING_BUDGET_TOKENS = "thinkingBudgetTokens";
+  private static final String RANDOM_SEED = "randomSeed";
+
+  private final String name;
+  private final Map<String, Object> params;
+  private final ChatModel chatModel;
+  private final int hashCode;
+
+  public static SolrChatModel getInstance(
+      SolrResourceLoader solrResourceLoader,
+      String className,
+      String name,
+      Map<String, Object> params)
+      throws ChatModelException {
+    try {
+      /*
+       * The idea here is to build a {@link dev.langchain4j.model.chat.ChatModel} using inversion
+       * of control.
+       * Each model has its own list of parameters we don't know beforehand, but each {@link dev.langchain4j.model.chat.ChatModel} class
+       * has its own builder that uses setters with the same name of the parameter in input.
+       * */
+      ChatModel textToTextModel;
+      Class<?> modelClass = solrResourceLoader.findClass(className, ChatModel.class);
+      var builder = modelClass.getMethod("builder").invoke(null);
+      if (params != null) {
+        /*
+         * This block of code has the responsibility of instantiate a {@link
+         * dev.langchain4j.model.chat.ChatModel} using the params provided.classes have
+         * params of The specific implementation of {@link
+         * dev.langchain4j.model.chat.ChatModel} is not known beforehand. So we benefit of
+         * the design choice in langchain4j that each subclass implementing {@link
+         * dev.langchain4j.model.chat.ChatModel} uses setters with the same name of the
+         * param.
+         */
+        for (String paramName : params.keySet()) {
+          /*
+           * When a param is not primitive, we need to instantiate the object explicitly and then call the
+           * setter method.
+           * N.B. when adding support to new models, pay attention to all the parameters they
+           * support, some of them may require to be handled in here as separate switch cases
+           */
+          switch (paramName) {
+            case TIMEOUT_PARAM -> builder
+                  .getClass()
+                  .getMethod(paramName, Duration.class)
+                  .invoke(builder, Duration.ofSeconds((Long) params.get(paramName)));
+
+            case MAX_RETRIES_PARAM, THINKING_BUDGET_TOKENS, RANDOM_SEED -> builder
+                .getClass()
+                .getMethod(paramName, Integer.class)
+                .invoke(builder, ((Long) params.get(paramName)).intValue());
+
+              /*
+               * For primitive params if there's only one setter available, we call it.
+               * If there's choice we default to the string one
+               */
+            default -> {
+              ArrayList<Method> paramNameMatches = new ArrayList<>();
+              for (var method : builder.getClass().getMethods()) {
+                if (paramName.equals(method.getName()) && method.getParameterCount() == 1) {
+                  paramNameMatches.add(method);
+                }
+              }
+              if (paramNameMatches.size() == 1) {
+                paramNameMatches.getFirst().invoke(builder, params.get(paramName));
+              } else {
+                try {
+                  builder
+                      .getClass()
+                      .getMethod(paramName, String.class)
+                      .invoke(builder, params.get(paramName).toString());
+                } catch (NoSuchMethodException e) {
+                  log.error("Parameter {} not supported by model {}", paramName, className);
+                  throw new SolrException(SolrException.ErrorCode.BAD_REQUEST, e.getMessage(), e);
+                }
+              }
+            }
+          }
+        }
+      }
+      textToTextModel = (ChatModel) builder.getClass().getMethod("build").invoke(builder);
+      return new SolrChatModel(name, textToTextModel, params);
+    } catch (final Exception e) {
+      throw new ChatModelException("Model loading failed for " + className, e);
+    }
+  }
+
+  public SolrChatModel(
+      String name, ChatModel chatModel, Map<String, Object> params) {
+    this.name = name;
+    this.chatModel = chatModel;
+    this.params = params;
+    this.hashCode = calculateHashCode();
+  }
+
+  /**
+   * Sends a structured chat request and returns the parsed value from the {@code {"value": ...}}
+   * JSON object that the model is instructed to produce via {@code responseFormat}.
+   *
+   * @return the extracted value: a {@link String}, {@link Number}, {@link Boolean}, or {@link
+   *     java.util.List} depending on the Solr output field type
+   */
+  public Object chat(String text, ResponseFormat responseFormat) {

Review Comment:
   I would call the parameter "prompt" instead of "text"



##########
solr/modules/language-models/src/java/org/apache/solr/languagemodels/documentenrichment/model/SolrChatModel.java:
##########
@@ -0,0 +1,215 @@
+/*
+ * 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.solr.languagemodels.documentenrichment.model;
+
+import dev.langchain4j.data.message.UserMessage;
+import dev.langchain4j.model.chat.ChatModel;
+import dev.langchain4j.model.chat.request.ChatRequest;
+import dev.langchain4j.model.chat.request.ResponseFormat;
+import java.lang.invoke.MethodHandles;
+import java.lang.reflect.Method;
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.Map;
+import java.util.Objects;
+import org.apache.lucene.util.Accountable;
+import org.apache.lucene.util.RamUsageEstimator;
+import org.apache.solr.common.SolrException;
+import org.apache.solr.common.util.Utils;
+import org.apache.solr.core.SolrResourceLoader;
+import org.apache.solr.languagemodels.documentenrichment.store.ChatModelException;
+import org.apache.solr.languagemodels.documentenrichment.store.rest.ManagedChatModelStore;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * This object wraps a {@link ChatModel} to produce the content of new fields from another.
+ * It's meant to be used as a managed resource with the {@link
+ * ManagedChatModelStore}
+ */
+public class SolrChatModel implements Accountable {
+  private static final Logger log = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());
+  private static final long BASE_RAM_BYTES =
+      RamUsageEstimator.shallowSizeOfInstance(SolrChatModel.class);
+  // timeout is type Duration
+  private static final String TIMEOUT_PARAM = "timeout";
+
+  // the followings are Integer type

Review Comment:
   not here



##########
solr/modules/language-models/src/java/org/apache/solr/languagemodels/documentenrichment/store/rest/ManagedChatModelStore.java:
##########
@@ -0,0 +1,200 @@
+/*
+ * 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.solr.languagemodels.documentenrichment.store.rest;
+
+import java.lang.invoke.MethodHandles;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+import net.jcip.annotations.ThreadSafe;
+import org.apache.solr.common.SolrException;
+import org.apache.solr.common.util.NamedList;
+import org.apache.solr.core.SolrCore;
+import org.apache.solr.core.SolrResourceLoader;
+import org.apache.solr.languagemodels.documentenrichment.model.SolrChatModel;
+import org.apache.solr.languagemodels.documentenrichment.store.ChatModelException;
+import org.apache.solr.languagemodels.documentenrichment.store.ChatModelStore;
+import org.apache.solr.response.SolrQueryResponse;
+import org.apache.solr.rest.BaseSolrResource;
+import org.apache.solr.rest.ManagedResource;
+import org.apache.solr.rest.ManagedResourceObserver;
+import org.apache.solr.rest.ManagedResourceStorage;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/** Managed Resource wrapper for the {@link ChatModelStore} to expose it via REST */
+@ThreadSafe
+public class ManagedChatModelStore extends ManagedResource
+    implements ManagedResource.ChildResourceSupport {
+  private static final Logger log = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());
+
+  /** the model store rest endpoint */
+  public static final String REST_END_POINT = "/schema/chat-model-store";
+
+  /** Managed model store: the name of the attribute containing all the models of a model store */
+  private static final String MODELS_JSON_FIELD = "models";
+
+  /** name of the attribute containing a class */
+  static final String CLASS_KEY = "class";
+
+  /** name of the attribute containing a name */
+  static final String NAME_KEY = "name";
+
+  /** name of the attribute containing parameters */
+  static final String PARAMS_KEY = "params";
+
+  public static void registerManagedChatModelStore(
+      SolrResourceLoader solrResourceLoader, ManagedResourceObserver managedResourceObserver) {
+    solrResourceLoader
+        .getManagedResourceRegistry()
+        .registerManagedResource(
+            REST_END_POINT, ManagedChatModelStore.class, managedResourceObserver);
+  }
+
+  public static ManagedChatModelStore getManagedModelStore(SolrCore core) {
+    return (ManagedChatModelStore) core.getRestManager().getManagedResource(REST_END_POINT);
+  }
+
+  /**
+   * Returns the available models as a list of Maps objects. After an update the managed resources
+   * needs to return the resources in this format in order to store in json somewhere (zookeeper,
+   * disk...)
+   *
+   * @return the available models as a list of Maps objects
+   */
+  private static List<Object> modelsAsManagedResources(List<SolrChatModel> models) {
+    return models.stream()
+        .map(ManagedChatModelStore::toModelMap)
+        .collect(Collectors.toList());
+  }
+
+  @SuppressWarnings("unchecked")
+  public static SolrChatModel fromModelMap(
+      SolrResourceLoader solrResourceLoader, Map<String, Object> chatModel) {
+    return SolrChatModel.getInstance(
+        solrResourceLoader,
+        (String) chatModel.get(CLASS_KEY), // modelClassName
+        (String) chatModel.get(NAME_KEY), // modelName
+        (Map<String, Object>) chatModel.get(PARAMS_KEY));
+  }
+
+  private static LinkedHashMap<String, Object> toModelMap(SolrChatModel model) {
+    final LinkedHashMap<String, Object> modelMap = new LinkedHashMap<>(5, 1.0f);

Review Comment:
   Why exactly 5?
   I see this is also in this way in the textToVector.. but still wandering..



##########
solr/modules/language-models/src/java/org/apache/solr/languagemodels/documentenrichment/store/rest/ManagedChatModelStore.java:
##########
@@ -0,0 +1,200 @@
+/*
+ * 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.solr.languagemodels.documentenrichment.store.rest;
+
+import java.lang.invoke.MethodHandles;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+import net.jcip.annotations.ThreadSafe;
+import org.apache.solr.common.SolrException;
+import org.apache.solr.common.util.NamedList;
+import org.apache.solr.core.SolrCore;
+import org.apache.solr.core.SolrResourceLoader;
+import org.apache.solr.languagemodels.documentenrichment.model.SolrChatModel;
+import org.apache.solr.languagemodels.documentenrichment.store.ChatModelException;
+import org.apache.solr.languagemodels.documentenrichment.store.ChatModelStore;
+import org.apache.solr.response.SolrQueryResponse;
+import org.apache.solr.rest.BaseSolrResource;
+import org.apache.solr.rest.ManagedResource;
+import org.apache.solr.rest.ManagedResourceObserver;
+import org.apache.solr.rest.ManagedResourceStorage;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/** Managed Resource wrapper for the {@link ChatModelStore} to expose it via REST */
+@ThreadSafe
+public class ManagedChatModelStore extends ManagedResource
+    implements ManagedResource.ChildResourceSupport {
+  private static final Logger log = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());
+
+  /** the model store rest endpoint */
+  public static final String REST_END_POINT = "/schema/chat-model-store";
+
+  /** Managed model store: the name of the attribute containing all the models of a model store */
+  private static final String MODELS_JSON_FIELD = "models";
+
+  /** name of the attribute containing a class */
+  static final String CLASS_KEY = "class";
+
+  /** name of the attribute containing a name */
+  static final String NAME_KEY = "name";
+
+  /** name of the attribute containing parameters */
+  static final String PARAMS_KEY = "params";
+
+  public static void registerManagedChatModelStore(
+      SolrResourceLoader solrResourceLoader, ManagedResourceObserver managedResourceObserver) {
+    solrResourceLoader
+        .getManagedResourceRegistry()
+        .registerManagedResource(
+            REST_END_POINT, ManagedChatModelStore.class, managedResourceObserver);
+  }
+
+  public static ManagedChatModelStore getManagedModelStore(SolrCore core) {
+    return (ManagedChatModelStore) core.getRestManager().getManagedResource(REST_END_POINT);
+  }
+
+  /**
+   * Returns the available models as a list of Maps objects. After an update the managed resources
+   * needs to return the resources in this format in order to store in json somewhere (zookeeper,
+   * disk...)
+   *
+   * @return the available models as a list of Maps objects
+   */
+  private static List<Object> modelsAsManagedResources(List<SolrChatModel> models) {
+    return models.stream()
+        .map(ManagedChatModelStore::toModelMap)
+        .collect(Collectors.toList());
+  }
+
+  @SuppressWarnings("unchecked")
+  public static SolrChatModel fromModelMap(
+      SolrResourceLoader solrResourceLoader, Map<String, Object> chatModel) {
+    return SolrChatModel.getInstance(
+        solrResourceLoader,
+        (String) chatModel.get(CLASS_KEY), // modelClassName
+        (String) chatModel.get(NAME_KEY), // modelName
+        (Map<String, Object>) chatModel.get(PARAMS_KEY));
+  }
+
+  private static LinkedHashMap<String, Object> toModelMap(SolrChatModel model) {
+    final LinkedHashMap<String, Object> modelMap = new LinkedHashMap<>(5, 1.0f);
+    modelMap.put(NAME_KEY, model.getName());
+    modelMap.put(CLASS_KEY, model.getChatModelClassName());
+    modelMap.put(PARAMS_KEY, model.getParams());
+    return modelMap;
+  }
+
+  private final ChatModelStore store;
+  private Object managedData;
+
+  public ManagedChatModelStore(
+      String resourceId, SolrResourceLoader loader, ManagedResourceStorage.StorageIO storageIO)
+      throws SolrException {
+    super(resourceId, loader, storageIO);
+    store = new ChatModelStore();
+  }
+
+  @Override
+  protected ManagedResourceStorage createStorage(
+      ManagedResourceStorage.StorageIO storageIO, SolrResourceLoader loader) throws SolrException {
+    return new ManagedResourceStorage.JsonStorage(storageIO, loader, -1);

Review Comment:
   Why do we override this method?
   (I know this is the same as textToVector) but still wandering..
   Only to add indentation "-1"?



##########
solr/modules/language-models/src/java/org/apache/solr/languagemodels/documentenrichment/update/processor/DocumentEnrichmentUpdateProcessor.java:
##########
@@ -0,0 +1,115 @@
+/*
+ * 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.solr.languagemodels.documentenrichment.update.processor;
+
+import dev.langchain4j.model.chat.request.ResponseFormat;
+import java.io.IOException;
+import java.lang.invoke.MethodHandles;
+import java.util.List;
+import org.apache.solr.common.SolrInputDocument;
+import org.apache.solr.common.SolrInputField;
+import org.apache.solr.languagemodels.documentenrichment.model.SolrChatModel;
+import org.apache.solr.request.SolrQueryRequest;
+import org.apache.solr.schema.IndexSchema;
+import org.apache.solr.schema.SchemaField;
+import org.apache.solr.update.AddUpdateCommand;
+import org.apache.solr.update.processor.UpdateRequestProcessor;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+class DocumentEnrichmentUpdateProcessor extends UpdateRequestProcessor {
+  private static final Logger log = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());
+
+  private final IndexSchema schema;
+  private final List<String> inputFields;
+  private final String outputField;
+  private final String prompt;
+  private final SolrChatModel chatModel;
+  private final boolean multiValued;
+  private final ResponseFormat responseFormat;
+
+  public DocumentEnrichmentUpdateProcessor(
+      List<String> inputFields,
+      String outputField,
+      String prompt,
+      SolrChatModel chatModel,
+      boolean multiValued,
+      ResponseFormat responseFormat,
+      SolrQueryRequest req,
+      UpdateRequestProcessor next) {
+    super(next);
+    this.schema = req.getSchema();
+    this.inputFields = inputFields;
+    this.outputField = outputField;
+    this.prompt = prompt;
+    this.chatModel = chatModel;
+    this.multiValued = multiValued;
+    this.responseFormat = responseFormat;
+  }
+
+  /**
+   * @param cmd the update command in input containing the Document to process
+   * @throws IOException If there is a low-level I/O error
+   */
+  @Override
+  public void processAdd(AddUpdateCommand cmd) throws IOException {
+    SolrInputDocument doc = cmd.getSolrInputDocument();
+
+    // Collect all field values; skip enrichment if any declared field is null or empty
+    String injectedPrompt = prompt;
+    for (String fieldName : inputFields) {
+      SolrInputField field = doc.get(fieldName);
+      if (isNullOrEmpty(field)) {
+        super.processAdd(cmd);
+        return;
+      }
+      injectedPrompt = injectedPrompt.replace("{" + fieldName + "}", field.getValue().toString());
+    }
+
+    try {
+      // as for now, only a plain text as prompt is sent to the model (no support for tools/skills/agents)
+      // chatModel.chat returns the parsed value from the structured JSON response
+      Object value = chatModel.chat(injectedPrompt, responseFormat);
+      if (multiValued && value instanceof List<?> list) {

Review Comment:
   I think this also depends on the fact that the model ensures or does not ensure the format... check if needed



##########
solr/modules/language-models/src/java/org/apache/solr/languagemodels/documentenrichment/update/processor/DocumentEnrichmentUpdateProcessorFactory.java:
##########
@@ -0,0 +1,313 @@
+/*
+ * 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.solr.languagemodels.documentenrichment.update.processor;
+
+import dev.langchain4j.model.chat.request.ResponseFormat;
+import dev.langchain4j.model.chat.request.ResponseFormatType;
+import dev.langchain4j.model.chat.request.json.JsonArraySchema;
+import dev.langchain4j.model.chat.request.json.JsonBooleanSchema;
+import dev.langchain4j.model.chat.request.json.JsonIntegerSchema;
+import dev.langchain4j.model.chat.request.json.JsonNumberSchema;
+import dev.langchain4j.model.chat.request.json.JsonObjectSchema;
+import dev.langchain4j.model.chat.request.json.JsonSchema;
+import dev.langchain4j.model.chat.request.json.JsonSchemaElement;
+import dev.langchain4j.model.chat.request.json.JsonStringSchema;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import java.util.Collection;
+import java.util.HashSet;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import org.apache.solr.common.SolrException;
+import org.apache.solr.common.params.RequiredSolrParams;
+import org.apache.solr.common.params.SolrParams;
+import org.apache.solr.common.util.NamedList;
+import org.apache.solr.core.SolrCore;
+import org.apache.solr.core.SolrResourceLoader;
+import org.apache.solr.languagemodels.documentenrichment.model.SolrChatModel;
+import org.apache.solr.languagemodels.documentenrichment.store.rest.ManagedChatModelStore;
+import org.apache.solr.request.SolrQueryRequest;
+import org.apache.solr.response.SolrQueryResponse;
+import org.apache.solr.rest.ManagedResource;
+import org.apache.solr.rest.ManagedResourceObserver;
+import org.apache.solr.schema.BoolField;
+import org.apache.solr.schema.DatePointField;
+import org.apache.solr.schema.DenseVectorField;
+import org.apache.solr.schema.DoublePointField;
+import org.apache.solr.schema.FieldType;
+import org.apache.solr.schema.FloatPointField;
+import org.apache.solr.schema.IndexSchema;
+import org.apache.solr.schema.IntPointField;
+import org.apache.solr.schema.LongPointField;
+import org.apache.solr.schema.SchemaField;
+import org.apache.solr.schema.StrField;
+import org.apache.solr.schema.TextField;
+import org.apache.solr.update.processor.UpdateRequestProcessor;
+import org.apache.solr.update.processor.UpdateRequestProcessorFactory;
+import org.apache.solr.util.plugin.SolrCoreAware;
+
+/**
+ * Insert in an existing field the output of the model coming from one or more textual field values.
+ *
+ * <p>One or more {@code inputField} parameters specify the Solr fields to use as input. Each field
+ * name must appear as a {@code {fieldName}} placeholder in the prompt. Exactly one of {@code
+ * prompt} or {@code promptFile} must be provided.
+ *
+ * <pre class="prettyprint" >
+ * &lt;processor class=&quot;solr.llm.documentenrichment.update.processor.DocumentEnrichmentUpdateProcessorFactory&quot;&gt;
+ *   &lt;str name=&quot;inputField&quot;&gt;title_field&lt;/str&gt;
+ *   &lt;str name=&quot;inputField&quot;&gt;body_field&lt;/str&gt;
+ *   &lt;str name=&quot;outputField&quot;&gt;enriched_field&lt;/str&gt;
+ *   &lt;str name=&quot;prompt&quot;&gt;Title: {title_field}. Body: {body_field}.&lt;/str&gt;
+ *   &lt;str name=&quot;model&quot;&gt;ChatModel&lt;/str&gt;
+ * &lt;/processor&gt;
+ * </pre>
+ *
+ * <p>Alternatively, the prompt can be loaded from a text file using {@code promptFile}:
+ *
+ * <pre class="prettyprint" >
+ * &lt;processor class=&quot;solr.llm.documentenrichment.update.processor.DocumentEnrichmentUpdateProcessorFactory&quot;&gt;
+ *   &lt;str name=&quot;inputField&quot;&gt;title_field&lt;/str&gt;
+ *   &lt;str name=&quot;outputField&quot;&gt;enriched_field&lt;/str&gt;
+ *   &lt;str name=&quot;promptFile&quot;&gt;prompt.txt&lt;/str&gt;
+ *   &lt;str name=&quot;model&quot;&gt;ChatModel&lt;/str&gt;
+ * &lt;/processor&gt;
+ * </pre>
+ *
+ * <p>Validation rules:
+ *
+ * <ul>
+ *   <li>At least one {@code inputField} must be declared.
+ *   <li>Exactly one of {@code prompt} or {@code promptFile} must be provided.
+ *   <li>Every declared {@code inputField} must have a corresponding {@code {fieldName}} placeholder
+ *       in the prompt.
+ *   <li>Every {@code {placeholder}} in the prompt must correspond to a declared {@code inputField}.
+ * </ul>
+ */
+public class DocumentEnrichmentUpdateProcessorFactory extends UpdateRequestProcessorFactory
+    implements SolrCoreAware, ManagedResourceObserver {
+  private static final String INPUT_FIELD_PARAM = "inputField";
+  private static final String OUTPUT_FIELD_PARAM = "outputField";
+  private static final String PROMPT = "prompt";
+  private static final String PROMPT_FILE = "promptFile";
+  private static final String MODEL_NAME = "model";
+  private static final Pattern PLACEHOLDER_PATTERN = Pattern.compile("\\{([^}]+)\\}");
+
+  private List<String> inputFields;
+  private String outputField;
+  private String promptText;
+  private String promptFile;
+  private String modelName;
+
+  @Override
+  public void init(final NamedList<?> args) {
+    // removeConfigArgs handles both multiple <str name="inputField"> and <arr name="inputField">
+    // and must be called before toSolrParams() since it mutates args in place
+    Collection<String> fieldNames = args.removeConfigArgs(INPUT_FIELD_PARAM);

Review Comment:
   Ask Alessandro also if needed...
   In case we decide to keep this, I would probably add an example or say in the comment above that this is also supported..



##########
solr/modules/language-models/src/java/org/apache/solr/languagemodels/documentenrichment/model/SolrChatModel.java:
##########
@@ -0,0 +1,215 @@
+/*
+ * 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.solr.languagemodels.documentenrichment.model;
+
+import dev.langchain4j.data.message.UserMessage;
+import dev.langchain4j.model.chat.ChatModel;
+import dev.langchain4j.model.chat.request.ChatRequest;
+import dev.langchain4j.model.chat.request.ResponseFormat;
+import java.lang.invoke.MethodHandles;
+import java.lang.reflect.Method;
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.Map;
+import java.util.Objects;
+import org.apache.lucene.util.Accountable;
+import org.apache.lucene.util.RamUsageEstimator;
+import org.apache.solr.common.SolrException;
+import org.apache.solr.common.util.Utils;
+import org.apache.solr.core.SolrResourceLoader;
+import org.apache.solr.languagemodels.documentenrichment.store.ChatModelException;
+import org.apache.solr.languagemodels.documentenrichment.store.rest.ManagedChatModelStore;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * This object wraps a {@link ChatModel} to produce the content of new fields from another.

Review Comment:
   This object wraps a {@link dev.langchain4j.model.chat.ChatModel} to generate the contents of a field based on other fields specified as input.
   
   
   (just one field is generated... right?)



##########
solr/modules/language-models/src/java/org/apache/solr/languagemodels/documentenrichment/model/SolrChatModel.java:
##########
@@ -0,0 +1,215 @@
+/*
+ * 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.solr.languagemodels.documentenrichment.model;
+
+import dev.langchain4j.data.message.UserMessage;
+import dev.langchain4j.model.chat.ChatModel;
+import dev.langchain4j.model.chat.request.ChatRequest;
+import dev.langchain4j.model.chat.request.ResponseFormat;
+import java.lang.invoke.MethodHandles;
+import java.lang.reflect.Method;
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.Map;
+import java.util.Objects;
+import org.apache.lucene.util.Accountable;
+import org.apache.lucene.util.RamUsageEstimator;
+import org.apache.solr.common.SolrException;
+import org.apache.solr.common.util.Utils;
+import org.apache.solr.core.SolrResourceLoader;
+import org.apache.solr.languagemodels.documentenrichment.store.ChatModelException;
+import org.apache.solr.languagemodels.documentenrichment.store.rest.ManagedChatModelStore;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * This object wraps a {@link ChatModel} to produce the content of new fields from another.
+ * It's meant to be used as a managed resource with the {@link
+ * ManagedChatModelStore}
+ */
+public class SolrChatModel implements Accountable {
+  private static final Logger log = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());
+  private static final long BASE_RAM_BYTES =
+      RamUsageEstimator.shallowSizeOfInstance(SolrChatModel.class);
+  // timeout is type Duration
+  private static final String TIMEOUT_PARAM = "timeout";
+
+  // the followings are Integer type

Review Comment:
   as before



##########
solr/modules/language-models/src/java/org/apache/solr/languagemodels/documentenrichment/update/processor/DocumentEnrichmentUpdateProcessorFactory.java:
##########
@@ -0,0 +1,313 @@
+/*
+ * 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.solr.languagemodels.documentenrichment.update.processor;
+
+import dev.langchain4j.model.chat.request.ResponseFormat;
+import dev.langchain4j.model.chat.request.ResponseFormatType;
+import dev.langchain4j.model.chat.request.json.JsonArraySchema;
+import dev.langchain4j.model.chat.request.json.JsonBooleanSchema;
+import dev.langchain4j.model.chat.request.json.JsonIntegerSchema;
+import dev.langchain4j.model.chat.request.json.JsonNumberSchema;
+import dev.langchain4j.model.chat.request.json.JsonObjectSchema;
+import dev.langchain4j.model.chat.request.json.JsonSchema;
+import dev.langchain4j.model.chat.request.json.JsonSchemaElement;
+import dev.langchain4j.model.chat.request.json.JsonStringSchema;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import java.util.Collection;
+import java.util.HashSet;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import org.apache.solr.common.SolrException;
+import org.apache.solr.common.params.RequiredSolrParams;
+import org.apache.solr.common.params.SolrParams;
+import org.apache.solr.common.util.NamedList;
+import org.apache.solr.core.SolrCore;
+import org.apache.solr.core.SolrResourceLoader;
+import org.apache.solr.languagemodels.documentenrichment.model.SolrChatModel;
+import org.apache.solr.languagemodels.documentenrichment.store.rest.ManagedChatModelStore;
+import org.apache.solr.request.SolrQueryRequest;
+import org.apache.solr.response.SolrQueryResponse;
+import org.apache.solr.rest.ManagedResource;
+import org.apache.solr.rest.ManagedResourceObserver;
+import org.apache.solr.schema.BoolField;
+import org.apache.solr.schema.DatePointField;
+import org.apache.solr.schema.DenseVectorField;
+import org.apache.solr.schema.DoublePointField;
+import org.apache.solr.schema.FieldType;
+import org.apache.solr.schema.FloatPointField;
+import org.apache.solr.schema.IndexSchema;
+import org.apache.solr.schema.IntPointField;
+import org.apache.solr.schema.LongPointField;
+import org.apache.solr.schema.SchemaField;
+import org.apache.solr.schema.StrField;
+import org.apache.solr.schema.TextField;
+import org.apache.solr.update.processor.UpdateRequestProcessor;
+import org.apache.solr.update.processor.UpdateRequestProcessorFactory;
+import org.apache.solr.util.plugin.SolrCoreAware;
+
+/**
+ * Insert in an existing field the output of the model coming from one or more textual field values.
+ *
+ * <p>One or more {@code inputField} parameters specify the Solr fields to use as input. Each field
+ * name must appear as a {@code {fieldName}} placeholder in the prompt. Exactly one of {@code
+ * prompt} or {@code promptFile} must be provided.
+ *
+ * <pre class="prettyprint" >
+ * &lt;processor class=&quot;solr.llm.documentenrichment.update.processor.DocumentEnrichmentUpdateProcessorFactory&quot;&gt;
+ *   &lt;str name=&quot;inputField&quot;&gt;title_field&lt;/str&gt;
+ *   &lt;str name=&quot;inputField&quot;&gt;body_field&lt;/str&gt;
+ *   &lt;str name=&quot;outputField&quot;&gt;enriched_field&lt;/str&gt;
+ *   &lt;str name=&quot;prompt&quot;&gt;Title: {title_field}. Body: {body_field}.&lt;/str&gt;
+ *   &lt;str name=&quot;model&quot;&gt;ChatModel&lt;/str&gt;
+ * &lt;/processor&gt;
+ * </pre>
+ *
+ * <p>Alternatively, the prompt can be loaded from a text file using {@code promptFile}:
+ *
+ * <pre class="prettyprint" >
+ * &lt;processor class=&quot;solr.llm.documentenrichment.update.processor.DocumentEnrichmentUpdateProcessorFactory&quot;&gt;
+ *   &lt;str name=&quot;inputField&quot;&gt;title_field&lt;/str&gt;
+ *   &lt;str name=&quot;outputField&quot;&gt;enriched_field&lt;/str&gt;
+ *   &lt;str name=&quot;promptFile&quot;&gt;prompt.txt&lt;/str&gt;
+ *   &lt;str name=&quot;model&quot;&gt;ChatModel&lt;/str&gt;
+ * &lt;/processor&gt;
+ * </pre>
+ *
+ * <p>Validation rules:
+ *
+ * <ul>
+ *   <li>At least one {@code inputField} must be declared.
+ *   <li>Exactly one of {@code prompt} or {@code promptFile} must be provided.
+ *   <li>Every declared {@code inputField} must have a corresponding {@code {fieldName}} placeholder
+ *       in the prompt.
+ *   <li>Every {@code {placeholder}} in the prompt must correspond to a declared {@code inputField}.
+ * </ul>
+ */
+public class DocumentEnrichmentUpdateProcessorFactory extends UpdateRequestProcessorFactory
+    implements SolrCoreAware, ManagedResourceObserver {
+  private static final String INPUT_FIELD_PARAM = "inputField";
+  private static final String OUTPUT_FIELD_PARAM = "outputField";
+  private static final String PROMPT = "prompt";
+  private static final String PROMPT_FILE = "promptFile";
+  private static final String MODEL_NAME = "model";
+  private static final Pattern PLACEHOLDER_PATTERN = Pattern.compile("\\{([^}]+)\\}");
+
+  private List<String> inputFields;
+  private String outputField;
+  private String promptText;
+  private String promptFile;
+  private String modelName;
+
+  @Override
+  public void init(final NamedList<?> args) {
+    // removeConfigArgs handles both multiple <str name="inputField"> and <arr name="inputField">
+    // and must be called before toSolrParams() since it mutates args in place
+    Collection<String> fieldNames = args.removeConfigArgs(INPUT_FIELD_PARAM);
+    if (fieldNames.isEmpty()) {
+      throw new SolrException(
+          SolrException.ErrorCode.SERVER_ERROR,
+          "At least one 'inputField' must be provided");
+    }
+    inputFields = List.copyOf(fieldNames);
+
+    SolrParams params = args.toSolrParams();
+    RequiredSolrParams required = params.required();
+    outputField = required.get(OUTPUT_FIELD_PARAM);
+    modelName = required.get(MODEL_NAME);
+
+    String inlinePrompt = params.get(PROMPT);
+    String promptFilePath = params.get(PROMPT_FILE);
+
+    if (inlinePrompt == null && promptFilePath == null) {
+      throw new SolrException(
+          SolrException.ErrorCode.SERVER_ERROR,
+          "Either 'prompt' or 'promptFile' must be provided");
+    }
+    if (inlinePrompt != null && promptFilePath != null) {
+      throw new SolrException(
+          SolrException.ErrorCode.SERVER_ERROR,
+          "Only one of 'prompt' or 'promptFile' can be provided, not both");
+    }
+    if (inlinePrompt != null) {
+      validatePromptPlaceholders(inlinePrompt, inputFields);
+      this.promptText = inlinePrompt;
+    }
+    this.promptFile = promptFilePath;
+  }
+
+  @Override
+  public void inform(SolrCore core) {
+    final SolrResourceLoader solrResourceLoader = core.getResourceLoader();
+    ManagedChatModelStore.registerManagedChatModelStore(solrResourceLoader, this);
+    if (promptFile != null) {
+      try (InputStream is = solrResourceLoader.openResource(promptFile)) {
+        promptText = new String(is.readAllBytes(), StandardCharsets.UTF_8).trim();
+      } catch (IOException e) {
+        throw new SolrException(
+            SolrException.ErrorCode.SERVER_ERROR,
+            "Cannot read prompt file: " + promptFile,
+            e);
+      }
+      validatePromptPlaceholders(promptText, inputFields);
+    }
+  }
+
+  @Override
+  public void onManagedResourceInitialized(NamedList<?> args, ManagedResource res)
+      throws SolrException {
+    if (res instanceof ManagedChatModelStore store) {
+      store.loadStoredModels();
+    }
+  }
+
+  @Override
+  public UpdateRequestProcessor getInstance(
+      SolrQueryRequest req, SolrQueryResponse rsp, UpdateRequestProcessor next) {
+    IndexSchema latestSchema = req.getCore().getLatestSchema();
+
+    for (String fieldName : inputFields) {
+      if (!latestSchema.isDynamicField(fieldName) && !latestSchema.hasExplicitField(fieldName)) {
+        throw new SolrException(
+            SolrException.ErrorCode.SERVER_ERROR, "undefined field: \"" + fieldName + "\"");
+      }
+    }
+
+    final SchemaField outputFieldSchema = latestSchema.getField(outputField);
+
+    ResponseFormat responseFormat = buildResponseFormat(outputFieldSchema);
+    boolean multiValued = outputFieldSchema.multiValued();
+
+    ManagedChatModelStore store = ManagedChatModelStore.getManagedModelStore(req.getCore());
+    SolrChatModel chatModel = store.getModel(modelName);
+    if (chatModel == null) {
+      throw new SolrException(
+          SolrException.ErrorCode.SERVER_ERROR,
+          "The model configured in the Update Request Processor '"
+              + modelName
+              + "' can't be found in the store: "
+              + ManagedChatModelStore.REST_END_POINT);
+    }
+
+    return new DocumentEnrichmentUpdateProcessor(
+        inputFields, outputField, promptText, chatModel, multiValued, responseFormat, req, next);
+  }
+
+  /**
+   * Builds a {@link ResponseFormat} that instructs the model to return a JSON object {@code
+   * {"value": ...}} whose value type matches the Solr field type. For multivalued fields the value
+   * is wrapped in a {@link JsonArraySchema} nested inside the root {@link JsonObjectSchema}.
+   *
+   * <p>Nesting {@link JsonArraySchema} inside a {@link JsonObjectSchema} property is supported by
+   * all langchain4j providers that implement structured outputs with {@link JsonObjectSchema} (OpenAI, Azure OpenAI,
+   * Google AI, Gemini, Mistral, Ollama, Amazon Bedrock, Watsonx).
+   */
+  static ResponseFormat buildResponseFormat(SchemaField schemaField) {
+    JsonSchemaElement valueElement = toJsonSchemaElement(schemaField.getType());
+    JsonSchemaElement valueSchema =
+        schemaField.multiValued()
+            ? JsonArraySchema.builder().items(valueElement).build()
+            : valueElement;
+    return ResponseFormat.builder()
+        .type(ResponseFormatType.JSON)
+        .jsonSchema(
+            JsonSchema.builder()
+                .name("output")
+                .rootElement(
+                    JsonObjectSchema.builder()
+                        .addProperty("value", valueSchema)
+                        .required("value")
+                        .build())
+                .build())
+        .build();
+  }
+
+  private static JsonSchemaElement toJsonSchemaElement(FieldType fieldType) {

Review Comment:
   I think that for a first contribution this support is enough... to discuss with Alessandro if we are missing some interesting field types that should be added at this step.
   https://solr.apache.org/guide/solr/latest/indexing-guide/field-types-included-with-solr.html#recommended-field-types



##########
solr/modules/language-models/src/java/org/apache/solr/languagemodels/documentenrichment/update/processor/DocumentEnrichmentUpdateProcessorFactory.java:
##########
@@ -0,0 +1,313 @@
+/*
+ * 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.solr.languagemodels.documentenrichment.update.processor;
+
+import dev.langchain4j.model.chat.request.ResponseFormat;
+import dev.langchain4j.model.chat.request.ResponseFormatType;
+import dev.langchain4j.model.chat.request.json.JsonArraySchema;
+import dev.langchain4j.model.chat.request.json.JsonBooleanSchema;
+import dev.langchain4j.model.chat.request.json.JsonIntegerSchema;
+import dev.langchain4j.model.chat.request.json.JsonNumberSchema;
+import dev.langchain4j.model.chat.request.json.JsonObjectSchema;
+import dev.langchain4j.model.chat.request.json.JsonSchema;
+import dev.langchain4j.model.chat.request.json.JsonSchemaElement;
+import dev.langchain4j.model.chat.request.json.JsonStringSchema;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import java.util.Collection;
+import java.util.HashSet;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import org.apache.solr.common.SolrException;
+import org.apache.solr.common.params.RequiredSolrParams;
+import org.apache.solr.common.params.SolrParams;
+import org.apache.solr.common.util.NamedList;
+import org.apache.solr.core.SolrCore;
+import org.apache.solr.core.SolrResourceLoader;
+import org.apache.solr.languagemodels.documentenrichment.model.SolrChatModel;
+import org.apache.solr.languagemodels.documentenrichment.store.rest.ManagedChatModelStore;
+import org.apache.solr.request.SolrQueryRequest;
+import org.apache.solr.response.SolrQueryResponse;
+import org.apache.solr.rest.ManagedResource;
+import org.apache.solr.rest.ManagedResourceObserver;
+import org.apache.solr.schema.BoolField;
+import org.apache.solr.schema.DatePointField;
+import org.apache.solr.schema.DenseVectorField;
+import org.apache.solr.schema.DoublePointField;
+import org.apache.solr.schema.FieldType;
+import org.apache.solr.schema.FloatPointField;
+import org.apache.solr.schema.IndexSchema;
+import org.apache.solr.schema.IntPointField;
+import org.apache.solr.schema.LongPointField;
+import org.apache.solr.schema.SchemaField;
+import org.apache.solr.schema.StrField;
+import org.apache.solr.schema.TextField;
+import org.apache.solr.update.processor.UpdateRequestProcessor;
+import org.apache.solr.update.processor.UpdateRequestProcessorFactory;
+import org.apache.solr.util.plugin.SolrCoreAware;
+
+/**
+ * Insert in an existing field the output of the model coming from one or more textual field values.
+ *
+ * <p>One or more {@code inputField} parameters specify the Solr fields to use as input. Each field
+ * name must appear as a {@code {fieldName}} placeholder in the prompt. Exactly one of {@code
+ * prompt} or {@code promptFile} must be provided.
+ *
+ * <pre class="prettyprint" >
+ * &lt;processor class=&quot;solr.llm.documentenrichment.update.processor.DocumentEnrichmentUpdateProcessorFactory&quot;&gt;
+ *   &lt;str name=&quot;inputField&quot;&gt;title_field&lt;/str&gt;
+ *   &lt;str name=&quot;inputField&quot;&gt;body_field&lt;/str&gt;
+ *   &lt;str name=&quot;outputField&quot;&gt;enriched_field&lt;/str&gt;
+ *   &lt;str name=&quot;prompt&quot;&gt;Title: {title_field}. Body: {body_field}.&lt;/str&gt;
+ *   &lt;str name=&quot;model&quot;&gt;ChatModel&lt;/str&gt;
+ * &lt;/processor&gt;
+ * </pre>
+ *
+ * <p>Alternatively, the prompt can be loaded from a text file using {@code promptFile}:
+ *
+ * <pre class="prettyprint" >
+ * &lt;processor class=&quot;solr.llm.documentenrichment.update.processor.DocumentEnrichmentUpdateProcessorFactory&quot;&gt;
+ *   &lt;str name=&quot;inputField&quot;&gt;title_field&lt;/str&gt;
+ *   &lt;str name=&quot;outputField&quot;&gt;enriched_field&lt;/str&gt;
+ *   &lt;str name=&quot;promptFile&quot;&gt;prompt.txt&lt;/str&gt;
+ *   &lt;str name=&quot;model&quot;&gt;ChatModel&lt;/str&gt;
+ * &lt;/processor&gt;
+ * </pre>
+ *
+ * <p>Validation rules:
+ *
+ * <ul>
+ *   <li>At least one {@code inputField} must be declared.
+ *   <li>Exactly one of {@code prompt} or {@code promptFile} must be provided.
+ *   <li>Every declared {@code inputField} must have a corresponding {@code {fieldName}} placeholder
+ *       in the prompt.
+ *   <li>Every {@code {placeholder}} in the prompt must correspond to a declared {@code inputField}.
+ * </ul>
+ */
+public class DocumentEnrichmentUpdateProcessorFactory extends UpdateRequestProcessorFactory
+    implements SolrCoreAware, ManagedResourceObserver {
+  private static final String INPUT_FIELD_PARAM = "inputField";
+  private static final String OUTPUT_FIELD_PARAM = "outputField";
+  private static final String PROMPT = "prompt";
+  private static final String PROMPT_FILE = "promptFile";
+  private static final String MODEL_NAME = "model";
+  private static final Pattern PLACEHOLDER_PATTERN = Pattern.compile("\\{([^}]+)\\}");
+
+  private List<String> inputFields;
+  private String outputField;
+  private String promptText;
+  private String promptFile;
+  private String modelName;
+
+  @Override
+  public void init(final NamedList<?> args) {
+    // removeConfigArgs handles both multiple <str name="inputField"> and <arr name="inputField">
+    // and must be called before toSolrParams() since it mutates args in place
+    Collection<String> fieldNames = args.removeConfigArgs(INPUT_FIELD_PARAM);
+    if (fieldNames.isEmpty()) {
+      throw new SolrException(
+          SolrException.ErrorCode.SERVER_ERROR,
+          "At least one 'inputField' must be provided");
+    }
+    inputFields = List.copyOf(fieldNames);
+
+    SolrParams params = args.toSolrParams();
+    RequiredSolrParams required = params.required();
+    outputField = required.get(OUTPUT_FIELD_PARAM);
+    modelName = required.get(MODEL_NAME);
+
+    String inlinePrompt = params.get(PROMPT);
+    String promptFilePath = params.get(PROMPT_FILE);
+
+    if (inlinePrompt == null && promptFilePath == null) {
+      throw new SolrException(
+          SolrException.ErrorCode.SERVER_ERROR,
+          "Either 'prompt' or 'promptFile' must be provided");
+    }
+    if (inlinePrompt != null && promptFilePath != null) {
+      throw new SolrException(
+          SolrException.ErrorCode.SERVER_ERROR,
+          "Only one of 'prompt' or 'promptFile' can be provided, not both");
+    }
+    if (inlinePrompt != null) {
+      validatePromptPlaceholders(inlinePrompt, inputFields);
+      this.promptText = inlinePrompt;
+    }
+    this.promptFile = promptFilePath;
+  }
+
+  @Override
+  public void inform(SolrCore core) {
+    final SolrResourceLoader solrResourceLoader = core.getResourceLoader();
+    ManagedChatModelStore.registerManagedChatModelStore(solrResourceLoader, this);
+    if (promptFile != null) {
+      try (InputStream is = solrResourceLoader.openResource(promptFile)) {
+        promptText = new String(is.readAllBytes(), StandardCharsets.UTF_8).trim();
+      } catch (IOException e) {
+        throw new SolrException(
+            SolrException.ErrorCode.SERVER_ERROR,
+            "Cannot read prompt file: " + promptFile,
+            e);
+      }
+      validatePromptPlaceholders(promptText, inputFields);
+    }
+  }
+
+  @Override
+  public void onManagedResourceInitialized(NamedList<?> args, ManagedResource res)
+      throws SolrException {
+    if (res instanceof ManagedChatModelStore store) {
+      store.loadStoredModels();
+    }
+  }
+
+  @Override
+  public UpdateRequestProcessor getInstance(
+      SolrQueryRequest req, SolrQueryResponse rsp, UpdateRequestProcessor next) {
+    IndexSchema latestSchema = req.getCore().getLatestSchema();
+
+    for (String fieldName : inputFields) {
+      if (!latestSchema.isDynamicField(fieldName) && !latestSchema.hasExplicitField(fieldName)) {
+        throw new SolrException(
+            SolrException.ErrorCode.SERVER_ERROR, "undefined field: \"" + fieldName + "\"");
+      }
+    }
+
+    final SchemaField outputFieldSchema = latestSchema.getField(outputField);
+
+    ResponseFormat responseFormat = buildResponseFormat(outputFieldSchema);
+    boolean multiValued = outputFieldSchema.multiValued();
+
+    ManagedChatModelStore store = ManagedChatModelStore.getManagedModelStore(req.getCore());
+    SolrChatModel chatModel = store.getModel(modelName);
+    if (chatModel == null) {
+      throw new SolrException(
+          SolrException.ErrorCode.SERVER_ERROR,
+          "The model configured in the Update Request Processor '"
+              + modelName
+              + "' can't be found in the store: "
+              + ManagedChatModelStore.REST_END_POINT);
+    }
+
+    return new DocumentEnrichmentUpdateProcessor(
+        inputFields, outputField, promptText, chatModel, multiValued, responseFormat, req, next);
+  }
+
+  /**
+   * Builds a {@link ResponseFormat} that instructs the model to return a JSON object {@code
+   * {"value": ...}} whose value type matches the Solr field type. For multivalued fields the value
+   * is wrapped in a {@link JsonArraySchema} nested inside the root {@link JsonObjectSchema}.
+   *
+   * <p>Nesting {@link JsonArraySchema} inside a {@link JsonObjectSchema} property is supported by
+   * all langchain4j providers that implement structured outputs with {@link JsonObjectSchema} (OpenAI, Azure OpenAI,
+   * Google AI, Gemini, Mistral, Ollama, Amazon Bedrock, Watsonx).
+   */
+  static ResponseFormat buildResponseFormat(SchemaField schemaField) {
+    JsonSchemaElement valueElement = toJsonSchemaElement(schemaField.getType());
+    JsonSchemaElement valueSchema =
+        schemaField.multiValued()
+            ? JsonArraySchema.builder().items(valueElement).build()
+            : valueElement;
+    return ResponseFormat.builder()
+        .type(ResponseFormatType.JSON)
+        .jsonSchema(
+            JsonSchema.builder()
+                .name("output")
+                .rootElement(
+                    JsonObjectSchema.builder()
+                        .addProperty("value", valueSchema)
+                        .required("value")
+                        .build())
+                .build())
+        .build();
+  }
+
+  private static JsonSchemaElement toJsonSchemaElement(FieldType fieldType) {
+    // DenseVectorField extends FloatPointField, so it must be rejected before the numeric checks
+    if (fieldType instanceof DenseVectorField) {
+      throw new SolrException(
+          SolrException.ErrorCode.SERVER_ERROR,
+          "field type is not supported by Document Enrichment: "
+              + fieldType.getClass().getSimpleName());
+    }
+    if (fieldType instanceof StrField
+        || fieldType instanceof TextField
+        || fieldType instanceof DatePointField) {
+      return new JsonStringSchema();
+    } else if (fieldType instanceof IntPointField || fieldType instanceof LongPointField) {
+      return new JsonIntegerSchema();
+    } else if (fieldType instanceof FloatPointField || fieldType instanceof DoublePointField) {
+      return new JsonNumberSchema();
+    } else if (fieldType instanceof BoolField) {
+      return new JsonBooleanSchema();
+    } else {
+      throw new SolrException(
+          SolrException.ErrorCode.SERVER_ERROR,
+          "field type is not supported by Document Enrichment: "
+              + fieldType.getClass().getSimpleName());
+    }
+  }
+
+  private static void validatePromptPlaceholders(String prompt, List<String> fieldNames) {
+    Set<String> promptPlaceholders = new LinkedHashSet<>();
+    Matcher m = PLACEHOLDER_PATTERN.matcher(prompt);
+    while (m.find()) {
+      promptPlaceholders.add(m.group(1));
+    }
+
+    Set<String> missingInPrompt = new LinkedHashSet<>(fieldNames);
+    missingInPrompt.removeAll(promptPlaceholders);
+    if (!missingInPrompt.isEmpty()) {
+      throw new SolrException(
+          SolrException.ErrorCode.SERVER_ERROR,
+          "prompt is missing placeholders for inputField(s): " + missingInPrompt);
+    }
+
+    Set<String> unknownInPrompt = new LinkedHashSet<>(promptPlaceholders);

Review Comment:
   Check var names



-- 
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: issues-unsubscribe@solr.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


---------------------------------------------------------------------
To unsubscribe, e-mail: issues-unsubscribe@solr.apache.org
For additional commands, e-mail: issues-help@solr.apache.org


Re: [PR] SOLR-18187: Document enrichment with LLMs [solr]

Posted by "aruggero (via GitHub)" <gi...@apache.org>.
aruggero commented on code in PR #4259:
URL: https://github.com/apache/solr/pull/4259#discussion_r3033113647


##########
solr/modules/language-models/src/test-files/solr/collection1/conf/schema-language-models.xml:
##########
@@ -36,11 +40,31 @@
   <field name="2048_byte_vector" type="high_dimensional_byte_knn_vector" indexed="true" stored="true" />
   <field name="2048_float_vector" type="high_dimensional_float_knn_vector" indexed="true" stored="true" />
   <field name="string_field" type="string" indexed="true" stored="true" multiValued="false" required="false"/>
+  <field name="body_field" type="string" indexed="true" stored="true" multiValued="false" required="false"/>
+  <field name="enriched_field" type="string" indexed="true" stored="true" multiValued="false" required="false"/>
+  <field name="enriched_field_multi" type="string" indexed="true" stored="true" multiValued="true" required="false"/>
+
+  <!-- single-valued output fields for each supported type -->
+  <field name="output_long"    type="plong"   indexed="true" stored="true" multiValued="false"/>
+  <field name="output_int"     type="pint"    indexed="true" stored="true" multiValued="false"/>
+  <field name="output_float"   type="pfloat"  indexed="true" stored="true" multiValued="false"/>
+  <field name="output_double"  type="pdouble" indexed="true" stored="true" multiValued="false"/>
+  <field name="output_boolean" type="boolean" indexed="true" stored="true" multiValued="false"/>
+  <field name="output_date"    type="pdate"   indexed="true" stored="true" multiValued="false"/>
+
+  <!-- multivalued output fields for each supported type -->
+  <field name="output_long_multi"    type="plong"   indexed="true" stored="true" multiValued="true"/>
+  <field name="output_int_multi"     type="pint"    indexed="true" stored="true" multiValued="true"/>
+  <field name="output_float_multi"   type="pfloat"  indexed="true" stored="true" multiValued="true"/>
+  <field name="output_double_multi"  type="pdouble" indexed="true" stored="true" multiValued="true"/>
+  <field name="output_boolean_multi" type="boolean" indexed="true" stored="true" multiValued="true"/>
+  <field name="output_date_multi"    type="pdate"   indexed="true" stored="true" multiValued="true"/>
 
   <field name="_version_" type="plong" indexed="true" stored="true" multiValued="false" />
   <field name="_text_" type="text_general" indexed="true" stored="false" multiValued="true"/>
   <copyField source="*" dest="_text_"/>
   <field name="vectorised" type="boolean" indexed="true" stored="false" docValues="true" default="false"/>
+  <field name="enriched" type="boolean" indexed="true" stored="false" docValues="true" default="false"/>

Review Comment:
   For what is this used?



##########
solr/modules/language-models/src/test-files/modelChatExamples/dummy-chat-model-ambiguous.json:
##########
@@ -0,0 +1,8 @@
+{

Review Comment:
   Is there a way to avoid creating all these different dummy model files?
   like setting the response in the tests and initiating the model just one time



##########
solr/modules/language-models/src/test-files/solr/collection1/conf/solrconfig-document-enrichment-update-request-processor-only.xml:
##########
@@ -0,0 +1,62 @@
+<?xml version="1.0" ?>

Review Comment:
   Couldn't we use the other for everything?



##########
solr/modules/language-models/src/test/org/apache/solr/languagemodels/documentenrichment/update/processor/DocumentEnrichmentUpdateProcessorTest.java:
##########
@@ -0,0 +1,718 @@
+/*
+ * 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.solr.languagemodels.documentenrichment.update.processor;
+
+import java.io.IOException;
+import java.util.Map;
+import org.apache.solr.client.solrj.RemoteSolrException;
+import org.apache.solr.client.solrj.SolrServerException;
+import org.apache.solr.client.solrj.request.SolrQuery;
+import org.apache.solr.client.solrj.request.UpdateRequest;
+import org.apache.solr.common.SolrInputDocument;
+import org.apache.solr.languagemodels.TestLanguageModelBase;
+import org.apache.solr.languagemodels.documentenrichment.store.rest.ManagedChatModelStore;
+import org.junit.After;
+import org.junit.AfterClass;
+import org.junit.BeforeClass;
+import org.junit.Test;
+
+public class DocumentEnrichmentUpdateProcessorTest extends TestLanguageModelBase {
+
+  @BeforeClass
+  public static void init() throws Exception {
+    setupTest("solrconfig-document-enrichment.xml", "schema-language-models.xml", false, false);
+  }
+
+  @AfterClass
+  public static void cleanup() throws Exception {
+    afterTest();
+  }
+
+  private String loadedModelId;
+
+  @After
+  public void afterEachTest() throws Exception {
+    if (loadedModelId != null) {
+      restTestHarness.delete(ManagedChatModelStore.REST_END_POINT + "/" + loadedModelId);
+      loadedModelId = null;
+    }
+  }
+
+  private void loadTestChatModel(String fileName, String modelId) throws Exception {
+    loadChatModel(fileName);
+    loadedModelId = modelId;
+  }
+
+  @Test
+  public void processAdd_inputField_shouldEnrichInputField() throws Exception {
+    loadTestChatModel("dummy-chat-model.json", "dummy-chat-1");
+
+    addWithChain(sdoc("id", "99", "string_field", "Vegeta is the saiyan prince."), "documentEnrichment");
+    addWithChain(sdoc("id", "98", "string_field", "Kakaroth is a saiyan grown up on planet Earth."), "documentEnrichment");
+    assertU(commit());
+
+    final SolrQuery query = getEnrichmentQuery("enriched_field");
+
+    assertJQ(
+        "/query" + query.toQueryString(),
+        "/response/numFound==2]",
+        "/response/docs/[0]/id=='99'",
+        "/response/docs/[0]/enriched_field=='enriched content'",
+        "/response/docs/[1]/id=='98'",
+        "/response/docs/[1]/enriched_field=='enriched content'");
+  }
+
+  /*
+   This test looks for the 'dummy-chat-1' model, but such model is not loaded —
+   the model store is empty, so the update fails.
+  */
+  @Test
+  public void processAdd_modelNotFound_shouldThrowException() {
+    RuntimeException thrown =
+        assertThrows(
+            "model not found should throw an exception",
+            RemoteSolrException.class,
+            () ->
+                addWithChain(
+                    sdoc("id", "99", "string_field", "Vegeta is the saiyan prince."),
+                    "documentEnrichment"));
+    assertTrue(
+        thrown
+            .getMessage()
+            .contains(
+                "The model configured in the Update Request Processor 'dummy-chat-1' can't be found in the store: /schema/chat-model-store"));
+  }
+
+  @Test
+  public void processAdd_emptyInputField_shouldLogAndIndexWithNoEnrichedField() throws Exception {
+    loadTestChatModel("dummy-chat-model.json", "dummy-chat-1");
+    addWithChain(sdoc("id", "99", "string_field", ""), "documentEnrichment");
+    addWithChain(sdoc("id", "98", "string_field", "Vegeta is the saiyan prince."), "documentEnrichment");
+    assertU(commit());
+
+    final SolrQuery query = getEnrichmentQuery("enriched_field");
+
+    assertJQ(
+        "/query" + query.toQueryString(),
+        "/response/numFound==2]",
+        "/response/docs/[0]/id=='99'",
+        "!/response/docs/[0]/enriched_field==", // no enriched field for doc 99
+        "/response/docs/[1]/id=='98'",
+        "/response/docs/[1]/enriched_field=='enriched content'");
+  }
+
+  @Test
+  public void processAdd_nullInputField_shouldLogAndIndexWithNoEnrichedField() throws Exception {
+    loadTestChatModel("dummy-chat-model.json", "dummy-chat-1");
+    addWithChain(sdoc("id", "99", "string_field", "Vegeta is the saiyan prince."), "documentEnrichment");
+    assertU(adoc("id", "98")); // no string_field
+    assertU(commit());
+
+    final SolrQuery query = getEnrichmentQuery("enriched_field");
+
+    assertJQ(
+        "/query" + query.toQueryString(),
+        "/response/numFound==2]",
+        "/response/docs/[0]/id=='99'",
+        "/response/docs/[0]/enriched_field=='enriched content'",
+        "/response/docs/[1]/id=='98'",
+        "!/response/docs/[1]/enriched_field=="); // no enriched field for doc 98
+  }
+
+  @Test
+  public void processAdd_failingEnrichment_shouldLogAndIndexWithNoEnrichedField() throws Exception {
+    loadTestChatModel("exception-throwing-chat-model.json", "exception-throwing-chat-model");
+    addWithChain(sdoc("id", "99", "string_field", "Vegeta is the saiyan prince."), "failingDocumentEnrichment");
+    addWithChain(sdoc("id", "98", "string_field", "Kakaroth is a saiyan grown up on planet Earth."), "failingDocumentEnrichment");
+    assertU(commit());
+
+    final SolrQuery query = getEnrichmentQuery("enriched_field");
+
+    assertJQ(
+        "/query" + query.toQueryString(),
+        "/response/numFound==2]",
+        "/response/docs/[0]/id=='99'",
+        "!/response/docs/[0]/enriched_field==", // no enriched field for doc 99
+        "/response/docs/[1]/id=='98'",
+        "!/response/docs/[1]/enriched_field=="); // no enriched field for doc 98
+  }
+
+  @Test
+  public void processAtomicUpdate_shouldTriggerEnrichmentAndFetchTheStoredContent()
+      throws Exception {
+    // Verifies that when using a processor chain configured for partial updates
+    // (i.e., DistributedUpdateProcessorFactory before DocumentEnrichmentUpdateProcessorFactory),
+    // the system correctly retrieves the stored value of string_field and generates the
+    // enriched content for the document.
+    loadTestChatModel("dummy-chat-model.json", "dummy-chat-1");
+    assertU(adoc("id", "99", "string_field", "Vegeta is the saiyan prince."));
+    assertU(adoc("id", "98", "string_field", "Kakaroth is a saiyan grown up on planet Earth."));
+    assertU(commit());
+
+    SolrInputDocument atomicDoc = new SolrInputDocument();
+    atomicDoc.setField("id", "99");
+    atomicDoc.setField("enriched", Map.of("set", true));
+    addWithChain(atomicDoc, "documentEnrichmentForPartialUpdates");
+    assertU(commit());
+
+    final SolrQuery query = getEnrichmentQuery("enriched_field");
+
+    assertJQ(
+        "/query" + query.toQueryString(),
+        "/response/numFound==2]",
+        "/response/docs/[0]/id=='99'",
+        "/response/docs/[0]/enriched_field=='enriched content'",
+        "/response/docs/[1]/id=='98'",
+        "!/response/docs/[1]/enriched_field==" // no enriched field for document 98
+        );
+  }
+
+  @Test
+  public void processAtomicUpdate_shouldReplaceExistingEnrichedFieldNotAppend() throws Exception {
+    // Verifies that when a document already contains an enriched_field and string_field is
+    // modified via atomic update, the enriched content is recomputed and replaces the previous
+    // value rather than being appended.
+    loadTestChatModel("dummy-chat-model.json", "dummy-chat-1");
+    addWithChain(sdoc("id", "99", "string_field", "Vegeta is the saiyan prince."), "documentEnrichment");
+    addWithChain(sdoc("id", "98", "string_field", "Kakaroth is a saiyan grown up on planet Earth."), "documentEnrichment");
+    assertU(commit());
+
+    SolrInputDocument atomicDoc = new SolrInputDocument();
+    atomicDoc.setField("id", "99");
+    atomicDoc.setField("string_field", Map.of("set", "Vegeta is the saiyan prince from the Dragon Ball series."));
+    addWithChain(atomicDoc, "documentEnrichmentForPartialUpdates");
+    assertU(commit());
+
+    final SolrQuery query = getEnrichmentQuery("enriched_field");
+
+    assertJQ(
+        "/query" + query.toQueryString(),
+        "/response/numFound==2]",
+        "/response/docs/[0]/id=='99'",
+        "/response/docs/[0]/enriched_field=='enriched content'",
+        "/response/docs/[1]/id=='98'",
+        "/response/docs/[1]/enriched_field=='enriched content'");
+  }
+
+  // --- multi-field tests ---
+
+  @Test
+  public void processAdd_multipleInputFields_allPresent_shouldEnrichDocument() throws Exception {
+    loadTestChatModel("dummy-chat-model.json", "dummy-chat-1");
+
+    addWithChain(
+        sdoc("id", "99", "string_field", "Vegeta is the saiyan prince.", "body_field", "He is very proud."),
+        "documentEnrichmentMultiField");
+    addWithChain(
+        sdoc("id", "98", "string_field", "Kakaroth is a saiyan.", "body_field", "He grew up on Earth."),
+        "documentEnrichmentMultiField");
+    assertU(commit());
+
+    final SolrQuery query = getEnrichmentQuery("enriched_field");
+
+    assertJQ(
+        "/query" + query.toQueryString(),
+        "/response/numFound==2]",
+        "/response/docs/[0]/id=='99'",
+        "/response/docs/[0]/enriched_field=='enriched content'",
+        "/response/docs/[1]/id=='98'",
+        "/response/docs/[1]/enriched_field=='enriched content'");
+  }
+
+  @Test
+  public void processAdd_multipleInputFields_firstFieldNull_shouldSkipEnrichment() throws Exception {
+    loadTestChatModel("dummy-chat-model.json", "dummy-chat-1");
+
+    addWithChain(
+        sdoc("id", "99", "body_field", "He is very proud."), // string_field absent
+        "documentEnrichmentMultiField");
+    addWithChain(
+        sdoc("id", "98", "body_field", "He is very jealous."), // string_field absent
+        "documentEnrichmentMultiField");
+    assertU(commit());
+
+    final SolrQuery query = getEnrichmentQuery("enriched_field");
+
+    assertJQ(
+        "/query" + query.toQueryString(),
+        "/response/numFound==2]",
+        "/response/docs/[0]/id=='99'",
+        "!/response/docs/[0]/enriched_field==",
+        "/response/docs/[1]/id=='98'",
+        "!/response/docs/[1]/enriched_field==");
+  }
+
+  @Test
+  public void processAdd_multipleInputFields_secondFieldEmpty_shouldSkipEnrichment() throws Exception {
+    loadTestChatModel("dummy-chat-model.json", "dummy-chat-1");
+
+    addWithChain(
+        sdoc("id", "99", "string_field", "Vegeta is the saiyan prince.", "body_field", ""),
+        "documentEnrichmentMultiField");
+    addWithChain(
+        sdoc("id", "98", "string_field", "Goku is the best saiyan.", "body_field", ""),
+        "documentEnrichmentMultiField");
+    assertU(commit());
+
+    final SolrQuery query = getEnrichmentQuery("enriched_field");
+
+    assertJQ(
+        "/query" + query.toQueryString(),
+        "/response/numFound==2]",
+        "/response/docs/[0]/id=='99'",
+        "!/response/docs/[0]/enriched_field==",
+        "/response/docs/[1]/id=='98'",
+        "!/response/docs/[1]/enriched_field==");
+  }
+
+  @Test
+  public void processAdd_multipleInputFields_bothFieldsAbsent_shouldSkipEnrichment() throws Exception {
+    loadTestChatModel("dummy-chat-model.json", "dummy-chat-1");
+
+    addWithChain(sdoc("id", "99"), "documentEnrichmentMultiField");
+    addWithChain(sdoc("id", "98"), "documentEnrichmentMultiField");
+    assertU(commit());
+
+    final SolrQuery query = getEnrichmentQuery("enriched_field");
+
+    assertJQ(
+        "/query" + query.toQueryString(),
+        "/response/numFound==2]",
+        "/response/docs/[0]/id=='99'",
+        "!/response/docs/[0]/enriched_field==",
+        "/response/docs/[1]/id=='98'",
+        "!/response/docs/[1]/enriched_field==");
+  }
+
+  @Test
+  public void processAdd_multipleInputFields_failingModel_shouldLogAndSkipEnrichment() throws Exception {
+    loadTestChatModel("exception-throwing-chat-model.json", "exception-throwing-chat-model");
+
+    addWithChain(
+        sdoc("id", "99", "string_field", "Vegeta is the saiyan prince.", "body_field", "He is very proud."),
+        "failingDocumentEnrichmentMultiField");
+    addWithChain(
+        sdoc("id", "98", "string_field", "Kakaroth is a saiyan.", "body_field", "He grew up on Earth."),
+        "failingDocumentEnrichmentMultiField");
+    assertU(commit());
+
+    final SolrQuery query = getEnrichmentQuery("enriched_field");
+
+    assertJQ(
+        "/query" + query.toQueryString(),
+        "/response/numFound==2]",
+        "/response/docs/[0]/id=='99'",
+        "!/response/docs/[0]/enriched_field==",
+        "/response/docs/[1]/id=='98'",
+        "!/response/docs/[1]/enriched_field==");
+  }
+
+  @Test
+  public void processAdd_multivaluedStringOutputField_shouldPopulateAllValues() throws Exception {
+    loadTestChatModel("dummy-chat-model-multivalued-string.json", "dummy-chat-multivalued-1");
+
+    addWithChain(
+        sdoc("id", "99", "string_field", "Vegeta is the saiyan prince."),
+        "documentEnrichmentMultivaluedString");
+    addWithChain(
+        sdoc("id", "98", "string_field", "Kakaroth is a saiyan grown up on planet Earth."),
+        "documentEnrichmentMultivaluedString");
+    assertU(commit());
+
+    final SolrQuery query = getEnrichmentQuery("enriched_field_multi");
+
+    assertJQ(
+        "/query" + query.toQueryString(),
+        "/response/numFound==2]",
+        "/response/docs/[0]/id=='99'",
+        "/response/docs/[0]/enriched_field_multi/[0]=='tag1'",
+        "/response/docs/[0]/enriched_field_multi/[1]=='tag2'",
+        "/response/docs/[1]/id=='98'",
+        "/response/docs/[1]/enriched_field_multi/[0]=='tag1'",
+        "/response/docs/[1]/enriched_field_multi/[1]=='tag2'");
+  }
+
+  @Test
+  public void processAdd_multivaluedStringOutputField_emptyInput_shouldSkipEnrichment()
+      throws Exception {
+    loadTestChatModel("dummy-chat-model-multivalued-string.json", "dummy-chat-multivalued-1");
+
+    addWithChain(sdoc("id", "99", "string_field", ""), "documentEnrichmentMultivaluedString");
+    addWithChain(sdoc("id", "98", "string_field", ""), "documentEnrichmentMultivaluedString");
+    assertU(commit());
+
+    final SolrQuery query = getEnrichmentQuery("enriched_field_multi");
+
+    assertJQ(
+        "/query" + query.toQueryString(),
+        "/response/numFound==2]",
+        "/response/docs/[0]/id=='99'",
+        "!/response/docs/[0]/enriched_field_multi==",
+        "/response/docs/[1]/id=='98'",
+        "!/response/docs/[1]/enriched_field_multi==");
+  }
+
+  // --- typed single-valued output field tests ---
+
+  @Test
+  public void processAdd_singleLongOutputField_shouldPopulateValue() throws Exception {

Review Comment:
   I have some doubts on the following tests... the assert itself is not able to differentiate the output type.. double and float are the same...



-- 
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: issues-unsubscribe@solr.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


---------------------------------------------------------------------
To unsubscribe, e-mail: issues-unsubscribe@solr.apache.org
For additional commands, e-mail: issues-help@solr.apache.org


Re: [PR] SOLR-18187: Document enrichment with LLMs [solr]

Posted by "aruggero (via GitHub)" <gi...@apache.org>.
aruggero commented on code in PR #4259:
URL: https://github.com/apache/solr/pull/4259#discussion_r3071683977


##########
solr/modules/language-models/src/test/org/apache/solr/languagemodels/documentenrichment/update/processor/DocumentEnrichmentUpdateProcessorFactoryTest.java:
##########
@@ -0,0 +1,422 @@
+/*
+ * 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.solr.languagemodels.documentenrichment.update.processor;
+
+import java.util.List;
+import org.apache.solr.common.SolrException;
+import org.apache.solr.common.params.ModifiableSolrParams;
+import org.apache.solr.common.util.NamedList;
+import org.apache.solr.core.SolrCore;
+import org.apache.solr.languagemodels.TestLanguageModelBase;
+import org.apache.solr.languagemodels.documentenrichment.model.SolrChatModel;
+import org.apache.solr.languagemodels.documentenrichment.store.rest.ManagedChatModelStore;
+import org.apache.solr.request.SolrQueryRequestBase;
+import org.apache.solr.update.processor.UpdateRequestProcessor;
+import org.junit.After;
+import org.junit.AfterClass;
+import org.junit.Before;
+import org.junit.BeforeClass;
+import org.junit.Test;
+
+public class DocumentEnrichmentUpdateProcessorFactoryTest extends TestLanguageModelBase {
+
+  @BeforeClass
+  public static void init() throws Exception {
+    setupTest("solrconfig-document-enrichment.xml", "schema-language-models.xml", false, false);
+  }
+
+  @AfterClass
+  public static void cleanup() throws Exception {
+    afterTest();
+  }
+
+  SolrCore collection1;
+
+  @Before
+  public void setup() {
+    collection1 = solrTestRule.getCoreContainer().getCore("collection1");
+  }
+
+  @After
+  public void after() {
+    collection1.close();
+  }
+
+  @Test
+  public void init_fullArgs_shouldInitAllParams() {
+    NamedList<String> args = new NamedList<>();
+    args.add("inputField", "string_field");
+    args.add("outputField", "enriched_field");
+    args.add("prompt", "Summarize: {string_field}");
+    args.add("model", "model1");
+
+    DocumentEnrichmentUpdateProcessorFactory factory = new DocumentEnrichmentUpdateProcessorFactory();
+    factory.init(args);
+
+    assertEquals(List.of("string_field"), factory.getInputFields());
+    assertEquals("enriched_field", factory.getOutputField());
+    assertEquals("Summarize: {string_field}", factory.getPrompt());
+    assertEquals("model1", factory.getModelName());
+  }
+
+  @Test
+  public void init_multipleInputFields_shouldInitAllFields() {
+    NamedList<String> args = new NamedList<>();
+    args.add("inputField", "string_field");
+    args.add("inputField", "body_field");
+    args.add("outputField", "enriched_field");
+    args.add("prompt", "Title: {string_field}. Body: {body_field}.");
+    args.add("model", "model1");
+
+    DocumentEnrichmentUpdateProcessorFactory factory = new DocumentEnrichmentUpdateProcessorFactory();
+    factory.init(args);
+
+    assertEquals(List.of("string_field", "body_field"), factory.getInputFields());
+  }
+
+  @Test
+  public void init_noInputField_shouldThrowExceptionWithDetailedMessage() {
+    NamedList<String> args = new NamedList<>();
+    args.add("outputField", "enriched_field");
+    args.add("prompt", "Summarize: {string_field}");
+    args.add("model", "model1");
+
+    DocumentEnrichmentUpdateProcessorFactory factory = new DocumentEnrichmentUpdateProcessorFactory();
+
+    SolrException e = assertThrows(SolrException.class, () -> factory.init(args));
+    assertEquals("At least one 'inputField' must be provided", e.getMessage());
+  }
+
+  @Test
+  public void init_nullOutputField_shouldThrowExceptionWithDetailedMessage() {
+    NamedList<String> args = new NamedList<>();
+    args.add("inputField", "string_field");
+    args.add("prompt", "Summarize: {string_field}");
+    args.add("model", "model1");
+
+    DocumentEnrichmentUpdateProcessorFactory factory = new DocumentEnrichmentUpdateProcessorFactory();
+
+    SolrException e = assertThrows(SolrException.class, () -> factory.init(args));
+    assertEquals("Missing required parameter: outputField", e.getMessage());
+  }
+
+  @Test
+  public void init_neitherPromptNorPromptFile_shouldThrowExceptionWithDetailedMessage() {
+    NamedList<String> args = new NamedList<>();
+    args.add("inputField", "string_field");
+    args.add("outputField", "enriched_field");
+    args.add("model", "model1");
+
+    DocumentEnrichmentUpdateProcessorFactory factory = new DocumentEnrichmentUpdateProcessorFactory();
+
+    SolrException e = assertThrows(SolrException.class, () -> factory.init(args));
+    assertEquals("Either 'prompt' or 'promptFile' must be provided", e.getMessage());
+  }
+
+  @Test
+  public void init_bothPromptAndPromptFile_shouldThrowExceptionWithDetailedMessage() {
+    NamedList<String> args = new NamedList<>();
+    args.add("inputField", "string_field");
+    args.add("outputField", "enriched_field");
+    args.add("prompt", "Summarize: {string_field}");
+    args.add("promptFile", "prompt.txt");
+    args.add("model", "model1");
+
+    DocumentEnrichmentUpdateProcessorFactory factory = new DocumentEnrichmentUpdateProcessorFactory();
+
+    SolrException e = assertThrows(SolrException.class, () -> factory.init(args));
+    assertEquals("Only one of 'prompt' or 'promptFile' can be provided, not both", e.getMessage());
+  }
+
+  @Test
+  public void init_promptMissingPlaceholderForDeclaredField_shouldThrowExceptionWithDetailedMessage() {
+    NamedList<String> args = new NamedList<>();
+    args.add("inputField", "string_field");
+    args.add("outputField", "enriched_field");
+    args.add("prompt", "Summarize:");
+    args.add("model", "model1");
+
+    DocumentEnrichmentUpdateProcessorFactory factory = new DocumentEnrichmentUpdateProcessorFactory();
+
+    SolrException e = assertThrows(SolrException.class, () -> factory.init(args));
+    assertEquals("prompt is missing placeholders for inputField(s): [string_field]", e.getMessage());
+  }
+
+  @Test
+  public void init_promptMissingOnePlaceholderOfMultipleFields_shouldThrowExceptionWithDetailedMessage() {
+    NamedList<String> args = new NamedList<>();
+    args.add("inputField", "string_field");
+    args.add("inputField", "body_field");
+    args.add("outputField", "enriched_field");
+    args.add("prompt", "Title: {string_field}.");
+    args.add("model", "model1");
+
+    DocumentEnrichmentUpdateProcessorFactory factory = new DocumentEnrichmentUpdateProcessorFactory();
+
+    SolrException e = assertThrows(SolrException.class, () -> factory.init(args));
+    assertEquals("prompt is missing placeholders for inputField(s): [body_field]", e.getMessage());
+  }
+
+  @Test
+  public void init_promptHasExtraPlaceholderNotDeclaredAsInputField_shouldThrowExceptionWithDetailedMessage() {
+    NamedList<String> args = new NamedList<>();
+    args.add("inputField", "string_field");
+    args.add("outputField", "enriched_field");
+    args.add("prompt", "Title: {string_field}. Extra: {unknown_field}.");
+    args.add("model", "model1");
+
+    DocumentEnrichmentUpdateProcessorFactory factory = new DocumentEnrichmentUpdateProcessorFactory();
+
+    SolrException e = assertThrows(SolrException.class, () -> factory.init(args));
+    assertEquals(
+        "prompt contains placeholders not declared as inputField(s): [unknown_field]",
+        e.getMessage());
+  }
+
+  @Test
+  public void init_nullModel_shouldThrowExceptionWithDetailedMessage() {
+    NamedList<String> args = new NamedList<>();
+    args.add("inputField", "string_field");
+    args.add("outputField", "enriched_field");
+    args.add("prompt", "Summarize: {string_field}");
+
+    DocumentEnrichmentUpdateProcessorFactory factory = new DocumentEnrichmentUpdateProcessorFactory();
+
+    SolrException e = assertThrows(SolrException.class, () -> factory.init(args));
+    assertEquals("Missing required parameter: model", e.getMessage());
+  }
+
+  @Test
+  public void init_promptFile_shouldLoadPromptFromFile() {
+    NamedList<String> args = new NamedList<>();
+    args.add("inputField", "string_field");
+    args.add("outputField", "enriched_field");
+    args.add("promptFile", "prompt.txt");
+    args.add("model", "model1");
+
+    DocumentEnrichmentUpdateProcessorFactory factory = new DocumentEnrichmentUpdateProcessorFactory();
+    factory.init(args);
+    factory.inform(collection1);
+
+    assertEquals("prompt.txt", factory.getPromptFile());
+    assertNotNull(factory.getPrompt());
+    assertTrue(factory.getPrompt().contains("{string_field}"));
+  }
+
+  @Test
+  public void init_promptFileMultiField_shouldLoadAndValidateBothPlaceholders() {
+    NamedList<String> args = new NamedList<>();
+    args.add("inputField", "string_field");
+    args.add("inputField", "body_field");
+    args.add("outputField", "enriched_field");
+    args.add("promptFile", "prompt-multi-field.txt");
+    args.add("model", "model1");
+
+    DocumentEnrichmentUpdateProcessorFactory factory = new DocumentEnrichmentUpdateProcessorFactory();
+    factory.init(args);
+    factory.inform(collection1);
+
+    assertNotNull(factory.getPrompt());
+    assertTrue(factory.getPrompt().contains("{string_field}"));
+    assertTrue(factory.getPrompt().contains("{body_field}"));
+  }
+
+  @Test
+  public void init_promptFileWithMissingPlaceholder_shouldThrowExceptionInInform() {
+    NamedList<String> args = new NamedList<>();
+    args.add("inputField", "string_field");
+    args.add("outputField", "enriched_field");
+    args.add("promptFile", "prompt-no-placeholder.txt");
+    args.add("model", "model1");
+
+    DocumentEnrichmentUpdateProcessorFactory factory = new DocumentEnrichmentUpdateProcessorFactory();
+    factory.init(args);
+
+    SolrException e = assertThrows(SolrException.class, () -> factory.inform(collection1));
+    assertEquals(
+        "prompt is missing placeholders for inputField(s): [string_field]", e.getMessage());
+  }
+
+  /* Following tests depend on a real solr schema and depend on BeforeClass-AfterClass methods */
+
+  @Test
+  public void init_notExistentOutputField_shouldThrowExceptionWithDetailedMessage() {
+    NamedList<String> args = new NamedList<>();
+    args.add("inputField", "string_field");
+    args.add("outputField", "notExistentOutput");
+    args.add("prompt", "Summarize: {string_field}");
+    args.add("model", "model1");
+
+    DocumentEnrichmentUpdateProcessorFactory factory = new DocumentEnrichmentUpdateProcessorFactory();
+    ModifiableSolrParams params = new ModifiableSolrParams();
+    SolrQueryRequestBase req = new SolrQueryRequestBase(collection1, params) {};
+    factory.init(args);
+
+    SolrException e = assertThrows(SolrException.class, () -> factory.getInstance(req, null, null));
+    assertEquals("undefined field: \"notExistentOutput\"", e.getMessage());
+  }
+
+  @Test
+  public void init_notTextualOutputField_shouldThrowExceptionWithDetailedMessage() {
+    // vector is a DenseVectorField — not a textual field
+    NamedList<String> args = new NamedList<>();
+    args.add("inputField", "string_field");
+    args.add("outputField", "vector");
+    args.add("prompt", "Summarize: {string_field}");
+    args.add("model", "model1");
+
+    DocumentEnrichmentUpdateProcessorFactory factory = new DocumentEnrichmentUpdateProcessorFactory();
+    ModifiableSolrParams params = new ModifiableSolrParams();
+    SolrQueryRequestBase req = new SolrQueryRequestBase(collection1, params) {};
+    factory.init(args);
+
+    SolrException e = assertThrows(SolrException.class, () -> factory.getInstance(req, null, null));
+    assertEquals(
+        "field type is not supported by Document Enrichment: DenseVectorField", e.getMessage());
+  }
+
+  @Test
+  public void init_notExistentInputField_shouldThrowExceptionWithDetailedMessage() {
+    NamedList<String> args = new NamedList<>();
+    args.add("inputField", "notExistentInput");
+    args.add("outputField", "enriched_field");
+    args.add("prompt", "Summarize: {notExistentInput}");
+    args.add("model", "model1");
+
+    DocumentEnrichmentUpdateProcessorFactory factory = new DocumentEnrichmentUpdateProcessorFactory();
+    ModifiableSolrParams params = new ModifiableSolrParams();
+    SolrQueryRequestBase req = new SolrQueryRequestBase(collection1, params) {};
+    factory.init(args);
+
+    SolrException e = assertThrows(SolrException.class, () -> factory.getInstance(req, null, null));
+    assertEquals("undefined field: \"notExistentInput\"", e.getMessage());
+  }
+
+  @Test
+  public void init_multipleInputFields_oneNotExistent_shouldThrowExceptionWithDetailedMessage() {
+    NamedList<String> args = new NamedList<>();
+    args.add("inputField", "string_field");
+    args.add("inputField", "notExistentInput");
+    args.add("outputField", "enriched_field");
+    args.add("prompt", "Title: {string_field}. Body: {notExistentInput}.");
+    args.add("model", "model1");
+
+    DocumentEnrichmentUpdateProcessorFactory factory = new DocumentEnrichmentUpdateProcessorFactory();
+    ModifiableSolrParams params = new ModifiableSolrParams();
+    SolrQueryRequestBase req = new SolrQueryRequestBase(collection1, params) {};
+    factory.init(args);
+
+    SolrException e = assertThrows(SolrException.class, () -> factory.getInstance(req, null, null));
+    assertEquals("undefined field: \"notExistentInput\"", e.getMessage());
+  }
+
+  @Test
+  public void init_multivaluedStringOutputField_shouldNotThrowException() throws Exception {
+    UpdateRequestProcessor instance =
+        createUpdateProcessor("string_field", "enriched_field_multi", collection1, "model-mv");
+    assertNotNull(instance);
+    restTestHarness.delete(ManagedChatModelStore.REST_END_POINT + "/model-mv");
+  }
+
+  @Test
+  public void init_multivaluedStringOutputField_buildResponseFormat_shouldProduceArraySchema() throws Exception {
+    NamedList<String> args = new NamedList<>();
+    ManagedChatModelStore.getManagedModelStore(collection1)
+        .addModel(new SolrChatModel("model-rf", null, null));
+    args.add("inputField", "string_field");
+    args.add("outputField", "enriched_field_multi");
+    args.add("prompt", "Summarize: {string_field}");
+    args.add("model", "model-rf");
+
+    DocumentEnrichmentUpdateProcessorFactory factory = new DocumentEnrichmentUpdateProcessorFactory();
+    factory.init(args);
+    ModifiableSolrParams params = new ModifiableSolrParams();
+    SolrQueryRequestBase req = new SolrQueryRequestBase(collection1, params) {};
+    assertNotNull(factory.getInstance(req, null, null));
+
+    // verify the ResponseFormat is constructed correctly for the multivalued field
+    var schema = collection1.getLatestSchema();
+    var schemaField = schema.getField("enriched_field_multi");
+    assertTrue(schemaField.multiValued());
+    var responseFormat = DocumentEnrichmentUpdateProcessorFactory.buildResponseFormat(schemaField);
+    assertNotNull(responseFormat);
+    assertEquals(
+        dev.langchain4j.model.chat.request.ResponseFormatType.JSON, responseFormat.type());
+    assertNotNull(responseFormat.jsonSchema());
+    restTestHarness.delete(ManagedChatModelStore.REST_END_POINT + "/model-rf");
+  }
+
+  @Test
+  public void init_singleValuedStringOutputField_buildResponseFormat_shouldProduceStringSchema() {
+    var schema = collection1.getLatestSchema();
+    var schemaField = schema.getField("enriched_field");
+    assertFalse(schemaField.multiValued());
+    var responseFormat = DocumentEnrichmentUpdateProcessorFactory.buildResponseFormat(schemaField);
+    assertNotNull(responseFormat);
+    assertEquals(
+        dev.langchain4j.model.chat.request.ResponseFormatType.JSON, responseFormat.type());
+    assertNotNull(responseFormat.jsonSchema());
+  }
+
+  @Test
+  public void init_dynamicInputField_shouldNotThrowException() throws Exception{
+    UpdateRequestProcessor instance =
+        createUpdateProcessor("text_s", "enriched_field", collection1, "model2");
+    assertNotNull(instance);
+    restTestHarness.delete(ManagedChatModelStore.REST_END_POINT + "/model2");
+  }
+
+  @Test
+  public void init_multipleDynamicInputFields_shouldNotThrowException() throws Exception{
+    NamedList<String> args = new NamedList<>();
+    ManagedChatModelStore.getManagedModelStore(collection1)
+        .addModel(new SolrChatModel("model1", null, null));
+    args.add("inputField", "text_s");
+    args.add("inputField", "body_field");
+    args.add("outputField", "enriched_field");
+    args.add("prompt", "Title: {text_s}. Body: {body_field}.");
+    args.add("model", "model1");
+
+    DocumentEnrichmentUpdateProcessorFactory factory = new DocumentEnrichmentUpdateProcessorFactory();
+    ModifiableSolrParams params = new ModifiableSolrParams();
+    factory.init(args);
+
+    SolrQueryRequestBase req = new SolrQueryRequestBase(collection1, params) {};
+    assertNotNull(factory.getInstance(req, null, null));
+    restTestHarness.delete(ManagedChatModelStore.REST_END_POINT + "/model1");
+  }
+
+  private UpdateRequestProcessor createUpdateProcessor(

Review Comment:
   Can't this always be generalised and used for all the tests? In some of them, you are now repeating this code with small changes...



-- 
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: issues-unsubscribe@solr.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


---------------------------------------------------------------------
To unsubscribe, e-mail: issues-unsubscribe@solr.apache.org
For additional commands, e-mail: issues-help@solr.apache.org


Re: [PR] SOLR-18187: Document enrichment with LLMs [solr]

Posted by "nicolo-rinaldi (via GitHub)" <gi...@apache.org>.
nicolo-rinaldi commented on code in PR #4259:
URL: https://github.com/apache/solr/pull/4259#discussion_r3225517967


##########
solr/modules/language-models/src/java/org/apache/solr/languagemodels/store/rest/ManagedLargeLanguageModelStore.java:
##########
@@ -0,0 +1,64 @@
+/*
+ * 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.solr.languagemodels.store.rest;
+
+import java.util.Map;
+import net.jcip.annotations.ThreadSafe;
+import org.apache.solr.common.SolrException;
+import org.apache.solr.core.SolrCore;
+import org.apache.solr.core.SolrResourceLoader;
+import org.apache.solr.languagemodels.model.SolrLargeLanguageModel;
+import org.apache.solr.rest.ManagedResourceObserver;
+import org.apache.solr.rest.ManagedResourceStorage;
+
+/** Managed Resource wrapper for the large language model store, exposed via REST */
+@ThreadSafe
+public class ManagedLargeLanguageModelStore

Review Comment:
   `ManagedLLMStore`?



-- 
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: issues-unsubscribe@solr.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


---------------------------------------------------------------------
To unsubscribe, e-mail: issues-unsubscribe@solr.apache.org
For additional commands, e-mail: issues-help@solr.apache.org


Re: [PR] SOLR-18187: Document enrichment with LLMs [solr]

Posted by "nicolo-rinaldi (via GitHub)" <gi...@apache.org>.
nicolo-rinaldi commented on code in PR #4259:
URL: https://github.com/apache/solr/pull/4259#discussion_r3138166558


##########
solr/modules/language-models/src/java/org/apache/solr/languagemodels/documentenrichment/model/SolrFieldGenerationModel.java:
##########
@@ -0,0 +1,256 @@
+/*
+ * 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.solr.languagemodels.documentenrichment.model;
+
+import dev.langchain4j.data.message.UserMessage;
+import dev.langchain4j.model.chat.Capability;
+import dev.langchain4j.model.chat.ChatModel;
+import dev.langchain4j.model.chat.request.ChatRequest;
+import dev.langchain4j.model.chat.request.ResponseFormat;
+import dev.langchain4j.model.chat.request.json.JsonArraySchema;
+import dev.langchain4j.model.chat.request.json.JsonBooleanSchema;
+import dev.langchain4j.model.chat.request.json.JsonIntegerSchema;
+import dev.langchain4j.model.chat.request.json.JsonNumberSchema;
+import dev.langchain4j.model.chat.request.json.JsonObjectSchema;
+import dev.langchain4j.model.chat.request.json.JsonSchemaElement;
+import dev.langchain4j.model.chat.request.json.JsonStringSchema;
+import java.lang.invoke.MethodHandles;
+import java.lang.reflect.Method;
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.EnumSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+import org.apache.lucene.util.Accountable;
+import org.apache.lucene.util.RamUsageEstimator;
+import org.apache.solr.common.SolrException;
+import org.apache.solr.common.util.Utils;
+import org.apache.solr.core.SolrResourceLoader;
+import org.apache.solr.languagemodels.documentenrichment.update.processor.DocumentEnrichmentUpdateProcessorFactory;
+import org.apache.solr.languagemodels.documentenrichment.store.FieldGenerationModelException;
+import org.apache.solr.languagemodels.documentenrichment.store.rest.ManagedFieldGenerationModelStore;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * This object wraps a {@link dev.langchain4j.model.chat.ChatModel} to produce the content of a
+ * field based on the content of other fields specified as input. It's meant to be used as a managed
+ * resource with the {@link ManagedFieldGenerationModelStore}
+ */
+public class SolrFieldGenerationModel implements Accountable {
+  private static final Logger log = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());
+  private static final long BASE_RAM_BYTES =
+      RamUsageEstimator.shallowSizeOfInstance(SolrFieldGenerationModel.class);
+  private static final String TIMEOUT_PARAM = "timeout";
+  private static final String MAX_RETRIES_PARAM = "maxRetries";
+  private static final String THINKING_BUDGET_TOKENS = "thinkingBudgetTokens";
+  private static final String RANDOM_SEED = "randomSeed";
+
+  private final String name;
+  private final Map<String, Object> params;
+  private final ChatModel chatModel;
+  private final int hashCode;
+
+  public static SolrFieldGenerationModel getInstance(
+      SolrResourceLoader solrResourceLoader,
+      String className,
+      String name,
+      Map<String, Object> params)
+      throws FieldGenerationModelException {
+    try {
+      /*
+       * The idea here is to build a {@link dev.langchain4j.model.chat.ChatModel} using inversion
+       * of control.
+       * Each model has its own list of parameters we don't know beforehand, but each {@link dev.langchain4j.model.chat.ChatModel} class
+       * has its own builder that uses setters with the same name of the parameter in input.
+       * */
+      ChatModel chatModel;
+      Class<?> modelClass = solrResourceLoader.findClass(className, ChatModel.class);
+      var builder = modelClass.getMethod("builder").invoke(null);
+      if (params != null) {
+        /*
+         * This block of code has the responsibility of instantiate a {@link
+         * dev.langchain4j.model.chat.ChatModel} using the params provided. Classes have
+         * params of the specific implementation of {@link
+         * dev.langchain4j.model.chat.ChatModel}, which is not known beforehand. So we benefit of
+         * the design choice in langchain4j that each subclass implementing {@link
+         * dev.langchain4j.model.chat.ChatModel} uses setters with the same name of the
+         * param.
+         */
+        for (String paramName : params.keySet()) {
+          /*
+           * When a param is not primitive, we need to instantiate the object explicitly and then call the
+           * setter method.
+           * N.B. when adding support to new models, pay attention to all the parameters they
+           * support, some of them may require to be handled in here as separate switch cases
+           */
+          switch (paramName) {
+            case TIMEOUT_PARAM -> builder
+                .getClass()
+                .getMethod(paramName, Duration.class)
+                .invoke(builder, Duration.ofSeconds((Long) params.get(paramName)));
+
+            case MAX_RETRIES_PARAM, THINKING_BUDGET_TOKENS, RANDOM_SEED -> builder
+                .getClass()
+                .getMethod(paramName, Integer.class)
+                .invoke(builder, ((Long) params.get(paramName)).intValue());
+
+              /*
+               * For primitive params if there's only one setter available, we call it.
+               * If there's choice we default to the string one
+               */
+            default -> {
+              ArrayList<Method> paramNameMatches = new ArrayList<>();
+              for (var method : builder.getClass().getMethods()) {
+                if (paramName.equals(method.getName()) && method.getParameterCount() == 1) {
+                  paramNameMatches.add(method);
+                }
+              }
+              if (paramNameMatches.size() == 1) {
+                paramNameMatches.getFirst().invoke(builder, params.get(paramName));
+              } else {
+                try {
+                  builder
+                      .getClass()
+                      .getMethod(paramName, String.class)
+                      .invoke(builder, params.get(paramName).toString());
+                } catch (NoSuchMethodException e) {
+                  log.error("Parameter {} not supported by model {}", paramName, className);
+                  throw new SolrException(SolrException.ErrorCode.BAD_REQUEST, e.getMessage(), e);
+                }
+              }
+            }
+          }
+        }
+      }
+
+      // Always enforce strict schema adherence where supported. For Anthropic and Google it's enabled by default
+      if (!"dev.langchain4j.model.anthropic.AnthropicChatModel".equals(className) &&
+          !"dev.langchain4j.model.googleai.GoogleAiGeminiChatModel".equals(className)) {
+        try {
+          builder
+              .getClass()
+              .getMethod("strictJsonSchema", Boolean.class)
+              .invoke(builder, true);
+        } catch (NoSuchMethodException ignored) {
+          log.debug("Model {} does not have strictJsonSchema param, structured output is not enforced", className);
+        }
+      }
+      chatModel = (ChatModel) builder.getClass().getMethod("build").invoke(builder);
+      return new SolrFieldGenerationModel(name, chatModel, params);
+    } catch (final Exception e) {
+      throw new FieldGenerationModelException("Model loading failed for " + className, e);
+    }
+  }
+
+  public SolrFieldGenerationModel(String name, ChatModel chatModel, Map<String, Object> params) {
+    this.name = name;
+    this.chatModel = chatModel;
+    this.params = params;
+    this.hashCode = calculateHashCode();
+  }
+
+  /**
+   * Sends a structured chat request and returns the parsed value from the {@code {"value": ...}}
+   * JSON object that the model is instructed to produce via {@code responseFormat}.
+   *
+   * @return the extracted value: a {@link String}, {@link Number}, {@link Integer}, {@link
+   *     Boolean}, or {@link java.util.List} depending on the Solr output field type
+   */
+  public Object generateFieldValue(String prompt, ResponseFormat responseFormat) {
+    ChatRequest chatRequest =
+        ChatRequest.builder()
+            .responseFormat(responseFormat)
+            .messages(UserMessage.from(prompt))
+            .build();
+    String rawJson = chatModel.chat(chatRequest).aiMessage().text();
+    Object parsed = Utils.fromJSONString(rawJson);
+    // It makes sense to keep this due to Ollama support

Review Comment:
   Changed



##########
solr/modules/language-models/src/test/org/apache/solr/languagemodels/documentenrichment/model/DummyChatModel.java:
##########
@@ -0,0 +1,85 @@
+/*

Review Comment:
   Yes, but this is a mock for the actual langchain4j ChatModel class, indeed it implements the ChatModel interface, so I thought the name must contain some reference to the langchain4j library.



##########
solr/modules/language-models/src/test/org/apache/solr/languagemodels/documentenrichment/model/DummyChatModelTest.java:
##########
@@ -0,0 +1,48 @@
+/*

Review Comment:
   same as DummyChatModel



##########
solr/modules/language-models/src/test-files/solr/collection1/conf/schema-language-models.xml:
##########
@@ -36,11 +40,50 @@
   <field name="2048_byte_vector" type="high_dimensional_byte_knn_vector" indexed="true" stored="true" />
   <field name="2048_float_vector" type="high_dimensional_float_knn_vector" indexed="true" stored="true" />
   <field name="string_field" type="string" indexed="true" stored="true" multiValued="false" required="false"/>
+  <field name="body_field" type="string" indexed="true" stored="true" multiValued="false" required="false"/>
+  <field name="tags_field" type="string" indexed="true" stored="true" multiValued="true" required="false"/>
+  <field name="enriched_field" type="string" indexed="true" stored="true" multiValued="false" required="false"/>
+  <field name="enriched_field_multi" type="string" indexed="true" stored="true" multiValued="true" required="false"/>
+
+  <!-- single-valued output fields for each supported type -->
+  <field name="output_long"    type="plong"   indexed="true" stored="true" multiValued="false"/>
+  <field name="output_int"     type="pint"    indexed="true" stored="true" multiValued="false"/>
+  <field name="output_float"   type="pfloat"  indexed="true" stored="true" multiValued="false"/>
+  <field name="output_double"  type="pdouble" indexed="true" stored="true" multiValued="false"/>
+  <field name="output_boolean" type="boolean" indexed="true" stored="true" multiValued="false"/>
+  <field name="output_date"    type="pdate"   indexed="true" stored="true" multiValued="false"/>
+
+  <!-- multivalued output fields for each supported type -->
+  <field name="output_long_multi"    type="plong"   indexed="true" stored="true" multiValued="true"/>
+  <field name="output_int_multi"     type="pint"    indexed="true" stored="true" multiValued="true"/>
+  <field name="output_float_multi"   type="pfloat"  indexed="true" stored="true" multiValued="true"/>
+  <field name="output_double_multi"  type="pdouble" indexed="true" stored="true" multiValued="true"/>
+  <field name="output_boolean_multi" type="boolean" indexed="true" stored="true" multiValued="true"/>
+  <field name="output_date_multi"    type="pdate"   indexed="true" stored="true" multiValued="true"/>
+  <field name="output_binary"        type="binary"           indexed="false" stored="true"  multiValued="false"/>
+
+  <!-- output fields for unsupported types -->
+  <field name="output_bynary"     type="binary"   indexed="true"  stored="true"  multiValued="false"/>
+
+  <!-- output fields for types explicitly unsupported (without they are supported via inheritance) -->

Review Comment:
   Changed, see if now is clear



##########
solr/modules/language-models/src/test/org/apache/solr/languagemodels/documentenrichment/model/ExceptionThrowingChatModel.java:
##########
@@ -0,0 +1,48 @@
+/*

Review Comment:
   same as DummyChatModel



##########
solr/modules/language-models/src/test-files/solr/collection1/conf/schema-language-models.xml:
##########
@@ -36,11 +40,50 @@
   <field name="2048_byte_vector" type="high_dimensional_byte_knn_vector" indexed="true" stored="true" />
   <field name="2048_float_vector" type="high_dimensional_float_knn_vector" indexed="true" stored="true" />
   <field name="string_field" type="string" indexed="true" stored="true" multiValued="false" required="false"/>
+  <field name="body_field" type="string" indexed="true" stored="true" multiValued="false" required="false"/>
+  <field name="tags_field" type="string" indexed="true" stored="true" multiValued="true" required="false"/>
+  <field name="enriched_field" type="string" indexed="true" stored="true" multiValued="false" required="false"/>
+  <field name="enriched_field_multi" type="string" indexed="true" stored="true" multiValued="true" required="false"/>
+
+  <!-- single-valued output fields for each supported type -->
+  <field name="output_long"    type="plong"   indexed="true" stored="true" multiValued="false"/>
+  <field name="output_int"     type="pint"    indexed="true" stored="true" multiValued="false"/>
+  <field name="output_float"   type="pfloat"  indexed="true" stored="true" multiValued="false"/>
+  <field name="output_double"  type="pdouble" indexed="true" stored="true" multiValued="false"/>
+  <field name="output_boolean" type="boolean" indexed="true" stored="true" multiValued="false"/>
+  <field name="output_date"    type="pdate"   indexed="true" stored="true" multiValued="false"/>
+
+  <!-- multivalued output fields for each supported type -->
+  <field name="output_long_multi"    type="plong"   indexed="true" stored="true" multiValued="true"/>
+  <field name="output_int_multi"     type="pint"    indexed="true" stored="true" multiValued="true"/>
+  <field name="output_float_multi"   type="pfloat"  indexed="true" stored="true" multiValued="true"/>
+  <field name="output_double_multi"  type="pdouble" indexed="true" stored="true" multiValued="true"/>
+  <field name="output_boolean_multi" type="boolean" indexed="true" stored="true" multiValued="true"/>
+  <field name="output_date_multi"    type="pdate"   indexed="true" stored="true" multiValued="true"/>
+  <field name="output_binary"        type="binary"           indexed="false" stored="true"  multiValued="false"/>
+
+  <!-- output fields for unsupported types -->
+  <field name="output_bynary"     type="binary"   indexed="true"  stored="true"  multiValued="false"/>

Review Comment:
   I didn't even noticed this. "output_bynary" is never used. Removed



-- 
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: issues-unsubscribe@solr.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


---------------------------------------------------------------------
To unsubscribe, e-mail: issues-unsubscribe@solr.apache.org
For additional commands, e-mail: issues-help@solr.apache.org


Re: [PR] SOLR-18187: Document enrichment with LLMs [solr]

Posted by "alessandrobenedetti (via GitHub)" <gi...@apache.org>.
alessandrobenedetti commented on code in PR #4259:
URL: https://github.com/apache/solr/pull/4259#discussion_r3122722388


##########
changelog/unreleased/SOLR-18187-llm-document-enrichment.yml:
##########
@@ -2,3 +2,6 @@ title: Add DocumentEnrichmentUpdateProcessorFactory for LLM-based document enric
 type: added # added, changed, fixed, deprecated, removed, dependency_update, security, other
 authors:
 - name: Nicolò Rinaldi

Review Comment:
   feel free to add also Anna and me, given that reviews will be substantial:
   Nicolò Rinaldi, Anna Ruggero, Alessandro Benedetti (preserve this order)



-- 
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: issues-unsubscribe@solr.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


---------------------------------------------------------------------
To unsubscribe, e-mail: issues-unsubscribe@solr.apache.org
For additional commands, e-mail: issues-help@solr.apache.org


Re: [PR] SOLR-18187: Document enrichment with LLMs [solr]

Posted by "alessandrobenedetti (via GitHub)" <gi...@apache.org>.
alessandrobenedetti commented on code in PR #4259:
URL: https://github.com/apache/solr/pull/4259#discussion_r3101163502


##########
solr/modules/language-models/src/java/org/apache/solr/languagemodels/documentenrichment/model/SolrChatModel.java:
##########
@@ -0,0 +1,211 @@
+/*
+ * 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.solr.languagemodels.documentenrichment.model;
+
+import dev.langchain4j.data.message.UserMessage;
+import dev.langchain4j.model.chat.ChatModel;
+import dev.langchain4j.model.chat.request.ChatRequest;
+import dev.langchain4j.model.chat.request.ResponseFormat;
+import java.lang.invoke.MethodHandles;
+import java.lang.reflect.Method;
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.Map;
+import java.util.Objects;
+import org.apache.lucene.util.Accountable;
+import org.apache.lucene.util.RamUsageEstimator;
+import org.apache.solr.common.SolrException;
+import org.apache.solr.common.util.Utils;
+import org.apache.solr.core.SolrResourceLoader;
+import org.apache.solr.languagemodels.documentenrichment.store.ChatModelException;
+import org.apache.solr.languagemodels.documentenrichment.store.rest.ManagedChatModelStore;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * This object wraps a {@link dev.langchain4j.model.chat.ChatModel} to produce the content of a
+ * field based on the content of other fields specified as input. It's meant to be used as a managed
+ * resource with the {@link ManagedChatModelStore}
+ */
+public class SolrChatModel implements Accountable {
+  private static final Logger log = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());
+  private static final long BASE_RAM_BYTES =
+      RamUsageEstimator.shallowSizeOfInstance(SolrChatModel.class);
+  private static final String TIMEOUT_PARAM = "timeout";
+  private static final String MAX_RETRIES_PARAM = "maxRetries";
+  private static final String THINKING_BUDGET_TOKENS = "thinkingBudgetTokens";
+  private static final String RANDOM_SEED = "randomSeed";
+
+  private final String name;
+  private final Map<String, Object> params;
+  private final ChatModel chatModel;
+  private final int hashCode;
+
+  public static SolrChatModel getInstance(
+      SolrResourceLoader solrResourceLoader,
+      String className,
+      String name,
+      Map<String, Object> params)
+      throws ChatModelException {
+    try {
+      /*
+       * The idea here is to build a {@link dev.langchain4j.model.chat.ChatModel} using inversion
+       * of control.
+       * Each model has its own list of parameters we don't know beforehand, but each {@link dev.langchain4j.model.chat.ChatModel} class
+       * has its own builder that uses setters with the same name of the parameter in input.
+       * */
+      ChatModel chatModel;
+      Class<?> modelClass = solrResourceLoader.findClass(className, ChatModel.class);
+      var builder = modelClass.getMethod("builder").invoke(null);
+      if (params != null) {
+        /*
+         * This block of code has the responsibility of instantiate a {@link
+         * dev.langchain4j.model.chat.ChatModel} using the params provided. Classes have
+         * params of the specific implementation of {@link
+         * dev.langchain4j.model.chat.ChatModel}, which is not known beforehand. So we benefit of
+         * the design choice in langchain4j that each subclass implementing {@link
+         * dev.langchain4j.model.chat.ChatModel} uses setters with the same name of the
+         * param.
+         */
+        for (String paramName : params.keySet()) {
+          /*
+           * When a param is not primitive, we need to instantiate the object explicitly and then call the
+           * setter method.
+           * N.B. when adding support to new models, pay attention to all the parameters they
+           * support, some of them may require to be handled in here as separate switch cases
+           */
+          switch (paramName) {
+            case TIMEOUT_PARAM -> builder
+                .getClass()
+                .getMethod(paramName, Duration.class)
+                .invoke(builder, Duration.ofSeconds((Long) params.get(paramName)));
+
+            case MAX_RETRIES_PARAM, THINKING_BUDGET_TOKENS, RANDOM_SEED -> builder
+                .getClass()
+                .getMethod(paramName, Integer.class)
+                .invoke(builder, ((Long) params.get(paramName)).intValue());
+
+              /*
+               * For primitive params if there's only one setter available, we call it.
+               * If there's choice we default to the string one
+               */
+            default -> {
+              ArrayList<Method> paramNameMatches = new ArrayList<>();
+              for (var method : builder.getClass().getMethods()) {
+                if (paramName.equals(method.getName()) && method.getParameterCount() == 1) {
+                  paramNameMatches.add(method);
+                }
+              }
+              if (paramNameMatches.size() == 1) {
+                paramNameMatches.getFirst().invoke(builder, params.get(paramName));
+              } else {
+                try {
+                  builder
+                      .getClass()
+                      .getMethod(paramName, String.class)
+                      .invoke(builder, params.get(paramName).toString());
+                } catch (NoSuchMethodException e) {
+                  log.error("Parameter {} not supported by model {}", paramName, className);
+                  throw new SolrException(SolrException.ErrorCode.BAD_REQUEST, e.getMessage(), e);
+                }
+              }
+            }
+          }
+        }
+      }
+      chatModel = (ChatModel) builder.getClass().getMethod("build").invoke(builder);
+      return new SolrChatModel(name, chatModel, params);
+    } catch (final Exception e) {
+      throw new ChatModelException("Model loading failed for " + className, e);
+    }
+  }
+
+  public SolrChatModel(String name, ChatModel chatModel, Map<String, Object> params) {
+    this.name = name;
+    this.chatModel = chatModel;
+    this.params = params;
+    this.hashCode = calculateHashCode();
+  }
+
+  /**
+   * Sends a structured chat request and returns the parsed value from the {@code {"value": ...}}
+   * JSON object that the model is instructed to produce via {@code responseFormat}.
+   *
+   * @return the extracted value: a {@link String}, {@link Number}, {@link Integer}, {@link
+   *     Boolean}, or {@link java.util.List} depending on the Solr output field type
+   */
+  public Object chat(String prompt, ResponseFormat responseFormat) {
+    ChatRequest chatRequest =
+        ChatRequest.builder()
+            .responseFormat(responseFormat)
+            .messages(UserMessage.from(prompt))
+            .build();
+    String rawJson = chatModel.chat(chatRequest).aiMessage().text();
+    Object parsed = Utils.fromJSONString(rawJson);

Review Comment:
   Is parsing an 'Object' necessary? 



-- 
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: issues-unsubscribe@solr.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


---------------------------------------------------------------------
To unsubscribe, e-mail: issues-unsubscribe@solr.apache.org
For additional commands, e-mail: issues-help@solr.apache.org


Re: [PR] SOLR-18187: Document enrichment with LLMs [solr]

Posted by "alessandrobenedetti (via GitHub)" <gi...@apache.org>.
alessandrobenedetti commented on code in PR #4259:
URL: https://github.com/apache/solr/pull/4259#discussion_r3101187673


##########
solr/modules/language-models/src/java/org/apache/solr/languagemodels/documentenrichment/store/ChatModelStore.java:
##########
@@ -0,0 +1,66 @@
+/*
+ * 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.solr.languagemodels.documentenrichment.store;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import org.apache.solr.languagemodels.documentenrichment.model.SolrChatModel;
+
+/** Simple store to manage CRUD operations on the {@link SolrChatModel} */
+public class ChatModelStore {
+
+  private final Map<String, SolrChatModel> availableModels;
+
+  public ChatModelStore() {
+    availableModels = Collections.synchronizedMap(new LinkedHashMap<>());
+  }
+
+  public SolrChatModel getModel(String name) {
+    return availableModels.get(name);
+  }
+
+  public void clear() {
+    availableModels.clear();
+  }
+
+  public List<SolrChatModel> getModels() {
+    synchronized (availableModels) {
+      final List<SolrChatModel> availableModelsValues = new ArrayList<>(availableModels.values());
+      return Collections.unmodifiableList(availableModelsValues);
+    }
+  }
+
+  @Override
+  public String toString() {
+    return "ChatModelStore [availableModels=" + availableModels.keySet() + "]";
+  }
+
+  public SolrChatModel delete(String modelName) {
+    return availableModels.remove(modelName);
+  }
+
+  public void addModel(SolrChatModel modeldata) throws ChatModelException {
+    final String name = modeldata.getName();
+    if (availableModels.putIfAbsent(modeldata.getName(), modeldata) != null) {
+      throw new ChatModelException(
+          "model '" + name + "' already exists. Please use a different name");
+    }
+  }
+}

Review Comment:
   see above in regards of duplicated code



-- 
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: issues-unsubscribe@solr.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


---------------------------------------------------------------------
To unsubscribe, e-mail: issues-unsubscribe@solr.apache.org
For additional commands, e-mail: issues-help@solr.apache.org


Re: [PR] SOLR-18187: Document enrichment with LLMs [solr]

Posted by "nicolo-rinaldi (via GitHub)" <gi...@apache.org>.
nicolo-rinaldi commented on code in PR #4259:
URL: https://github.com/apache/solr/pull/4259#discussion_r3073651402


##########
solr/modules/language-models/src/java/org/apache/solr/languagemodels/documentenrichment/store/rest/ManagedChatModelStore.java:
##########
@@ -0,0 +1,200 @@
+/*
+ * 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.solr.languagemodels.documentenrichment.store.rest;
+
+import java.lang.invoke.MethodHandles;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+import net.jcip.annotations.ThreadSafe;
+import org.apache.solr.common.SolrException;
+import org.apache.solr.common.util.NamedList;
+import org.apache.solr.core.SolrCore;
+import org.apache.solr.core.SolrResourceLoader;
+import org.apache.solr.languagemodels.documentenrichment.model.SolrChatModel;
+import org.apache.solr.languagemodels.documentenrichment.store.ChatModelException;
+import org.apache.solr.languagemodels.documentenrichment.store.ChatModelStore;
+import org.apache.solr.response.SolrQueryResponse;
+import org.apache.solr.rest.BaseSolrResource;
+import org.apache.solr.rest.ManagedResource;
+import org.apache.solr.rest.ManagedResourceObserver;
+import org.apache.solr.rest.ManagedResourceStorage;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/** Managed Resource wrapper for the {@link ChatModelStore} to expose it via REST */
+@ThreadSafe
+public class ManagedChatModelStore extends ManagedResource
+    implements ManagedResource.ChildResourceSupport {
+  private static final Logger log = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());
+
+  /** the model store rest endpoint */
+  public static final String REST_END_POINT = "/schema/chat-model-store";
+
+  /** Managed model store: the name of the attribute containing all the models of a model store */
+  private static final String MODELS_JSON_FIELD = "models";
+
+  /** name of the attribute containing a class */
+  static final String CLASS_KEY = "class";
+
+  /** name of the attribute containing a name */
+  static final String NAME_KEY = "name";
+
+  /** name of the attribute containing parameters */
+  static final String PARAMS_KEY = "params";
+
+  public static void registerManagedChatModelStore(
+      SolrResourceLoader solrResourceLoader, ManagedResourceObserver managedResourceObserver) {
+    solrResourceLoader
+        .getManagedResourceRegistry()
+        .registerManagedResource(
+            REST_END_POINT, ManagedChatModelStore.class, managedResourceObserver);
+  }
+
+  public static ManagedChatModelStore getManagedModelStore(SolrCore core) {
+    return (ManagedChatModelStore) core.getRestManager().getManagedResource(REST_END_POINT);
+  }
+
+  /**
+   * Returns the available models as a list of Maps objects. After an update the managed resources
+   * needs to return the resources in this format in order to store in json somewhere (zookeeper,
+   * disk...)
+   *
+   * @return the available models as a list of Maps objects
+   */
+  private static List<Object> modelsAsManagedResources(List<SolrChatModel> models) {
+    return models.stream()
+        .map(ManagedChatModelStore::toModelMap)
+        .collect(Collectors.toList());
+  }
+
+  @SuppressWarnings("unchecked")
+  public static SolrChatModel fromModelMap(
+      SolrResourceLoader solrResourceLoader, Map<String, Object> chatModel) {
+    return SolrChatModel.getInstance(
+        solrResourceLoader,
+        (String) chatModel.get(CLASS_KEY), // modelClassName
+        (String) chatModel.get(NAME_KEY), // modelName
+        (Map<String, Object>) chatModel.get(PARAMS_KEY));
+  }
+
+  private static LinkedHashMap<String, Object> toModelMap(SolrChatModel model) {
+    final LinkedHashMap<String, Object> modelMap = new LinkedHashMap<>(3, 1.0f);
+    modelMap.put(NAME_KEY, model.getName());
+    modelMap.put(CLASS_KEY, model.getChatModelClassName());
+    modelMap.put(PARAMS_KEY, model.getParams());
+    return modelMap;
+  }
+
+  private final ChatModelStore store;
+  private Object managedData;
+
+  public ManagedChatModelStore(
+      String resourceId, SolrResourceLoader loader, ManagedResourceStorage.StorageIO storageIO)
+      throws SolrException {
+    super(resourceId, loader, storageIO);
+    store = new ChatModelStore();
+  }
+
+  @Override
+  protected ManagedResourceStorage createStorage(

Review Comment:
   Yes, removed



##########
solr/modules/language-models/src/java/org/apache/solr/languagemodels/documentenrichment/update/processor/DocumentEnrichmentUpdateProcessorFactory.java:
##########
@@ -0,0 +1,330 @@
+/*
+ * 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.solr.languagemodels.documentenrichment.update.processor;
+
+import dev.langchain4j.model.chat.request.ResponseFormat;
+import dev.langchain4j.model.chat.request.ResponseFormatType;
+import dev.langchain4j.model.chat.request.json.JsonArraySchema;
+import dev.langchain4j.model.chat.request.json.JsonBooleanSchema;
+import dev.langchain4j.model.chat.request.json.JsonIntegerSchema;
+import dev.langchain4j.model.chat.request.json.JsonNumberSchema;
+import dev.langchain4j.model.chat.request.json.JsonObjectSchema;
+import dev.langchain4j.model.chat.request.json.JsonSchema;
+import dev.langchain4j.model.chat.request.json.JsonSchemaElement;
+import dev.langchain4j.model.chat.request.json.JsonStringSchema;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import java.util.Collection;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import org.apache.solr.common.SolrException;
+import org.apache.solr.common.params.RequiredSolrParams;
+import org.apache.solr.common.params.SolrParams;
+import org.apache.solr.common.util.NamedList;
+import org.apache.solr.core.SolrCore;
+import org.apache.solr.core.SolrResourceLoader;
+import org.apache.solr.languagemodels.documentenrichment.model.SolrChatModel;
+import org.apache.solr.languagemodels.documentenrichment.store.rest.ManagedChatModelStore;
+import org.apache.solr.request.SolrQueryRequest;
+import org.apache.solr.response.SolrQueryResponse;
+import org.apache.solr.rest.ManagedResource;
+import org.apache.solr.rest.ManagedResourceObserver;
+import org.apache.solr.schema.BoolField;
+import org.apache.solr.schema.DatePointField;
+import org.apache.solr.schema.DenseVectorField;
+import org.apache.solr.schema.DoublePointField;
+import org.apache.solr.schema.FieldType;
+import org.apache.solr.schema.FloatPointField;
+import org.apache.solr.schema.IndexSchema;
+import org.apache.solr.schema.IntPointField;
+import org.apache.solr.schema.LongPointField;
+import org.apache.solr.schema.NestPathField;
+import org.apache.solr.schema.SchemaField;
+import org.apache.solr.schema.StrField;
+import org.apache.solr.schema.TextField;
+import org.apache.solr.schema.UUIDField;
+import org.apache.solr.update.processor.UpdateRequestProcessor;
+import org.apache.solr.update.processor.UpdateRequestProcessorFactory;
+import org.apache.solr.util.plugin.SolrCoreAware;
+
+/**
+ * Generate the content of a field based on other fields specified as input.
+ *
+ * <p>One or more {@code inputField} parameters specify the Solr fields to use as input. Each field
+ * name must appear as a {@code {fieldName}} placeholder in the prompt. Exactly one of {@code
+ * prompt} or {@code promptFile} must be provided.
+ *
+ * <pre class="prettyprint" >
+ * &lt;processor class=&quot;solr.llm.documentenrichment.update.processor.DocumentEnrichmentUpdateProcessorFactory&quot;&gt;
+ *   &lt;str name=&quot;inputField&quot;&gt;title_field&lt;/str&gt;
+ *   &lt;str name=&quot;inputField&quot;&gt;body_field&lt;/str&gt;
+ *   &lt;str name=&quot;outputField&quot;&gt;enriched_field&lt;/str&gt;
+ *   &lt;str name=&quot;prompt&quot;&gt;Title: {title_field}. Body: {body_field}.&lt;/str&gt;
+ *   &lt;str name=&quot;model&quot;&gt;ChatModel&lt;/str&gt;
+ * &lt;/processor&gt;
+ * </pre>
+ *
+ * <p>Multiple {@code inputField} values can also be declared as an array using {@code arr}:
+ *
+ * <pre class="prettyprint" >
+ * &lt;processor class=&quot;solr.llm.documentenrichment.update.processor.DocumentEnrichmentUpdateProcessorFactory&quot;&gt;
+ *   &lt;arr name=&quot;inputField&quot;&gt;
+ *     &lt;str&gt;title_field&lt;/str&gt;
+ *     &lt;str&gt;body_field&lt;/str&gt;
+ *   &lt;/arr&gt;
+ *   &lt;str name=&quot;outputField&quot;&gt;enriched_field&lt;/str&gt;
+ *   &lt;str name=&quot;prompt&quot;&gt;Title: {title_field}. Body: {body_field}.&lt;/str&gt;
+ *   &lt;str name=&quot;model&quot;&gt;ChatModel&lt;/str&gt;
+ * &lt;/processor&gt;
+ * </pre>
+ *
+ * <p>Alternatively, the prompt can be loaded from a text file using {@code promptFile}:
+ *
+ * <pre class="prettyprint" >
+ * &lt;processor class=&quot;solr.llm.documentenrichment.update.processor.DocumentEnrichmentUpdateProcessorFactory&quot;&gt;
+ *   &lt;str name=&quot;inputField&quot;&gt;title_field&lt;/str&gt;
+ *   &lt;str name=&quot;outputField&quot;&gt;enriched_field&lt;/str&gt;
+ *   &lt;str name=&quot;promptFile&quot;&gt;prompt.txt&lt;/str&gt;
+ *   &lt;str name=&quot;model&quot;&gt;ChatModel&lt;/str&gt;
+ * &lt;/processor&gt;
+ * </pre>
+ *
+ * <p>Validation rules:
+ *
+ * <ul>
+ *   <li>At least one {@code inputField} must be declared.
+ *   <li>Exactly one of {@code prompt} or {@code promptFile} must be provided.
+ *   <li>Every declared {@code inputField} must have a corresponding {@code {fieldName}} placeholder
+ *       in the prompt.
+ *   <li>Every {@code {placeholder}} in the prompt must correspond to a declared {@code inputField}.
+ * </ul>
+ */
+public class DocumentEnrichmentUpdateProcessorFactory extends UpdateRequestProcessorFactory
+    implements SolrCoreAware, ManagedResourceObserver {
+  private static final String INPUT_FIELD_PARAM = "inputField";
+  private static final String OUTPUT_FIELD_PARAM = "outputField";
+  private static final String PROMPT = "prompt";
+  private static final String PROMPT_FILE = "promptFile";
+  private static final String MODEL_NAME = "model";
+  private static final Pattern PLACEHOLDER_PATTERN = Pattern.compile("\\{([^}]+)\\}");
+
+  private List<String> inputFields;
+  private String outputField;
+  private String promptText;
+  private String promptFile;
+  private String modelName;
+
+  @Override
+  public void init(final NamedList<?> args) {
+    // removeConfigArgs handles both multiple <str name="inputField"> and <arr name="inputField">
+    // and must be called before toSolrParams() since it mutates args in place
+    Collection<String> fieldNames = args.removeConfigArgs(INPUT_FIELD_PARAM);
+    if (fieldNames.isEmpty()) {
+      throw new SolrException(
+          SolrException.ErrorCode.SERVER_ERROR,
+          "At least one 'inputField' must be provided");
+    }
+    inputFields = List.copyOf(fieldNames);
+
+    SolrParams params = args.toSolrParams();
+    RequiredSolrParams required = params.required();
+    outputField = required.get(OUTPUT_FIELD_PARAM);
+    modelName = required.get(MODEL_NAME);
+
+    String inlinePrompt = params.get(PROMPT);
+    String promptFilePath = params.get(PROMPT_FILE);
+
+    if (inlinePrompt == null && promptFilePath == null) {
+      throw new SolrException(
+          SolrException.ErrorCode.SERVER_ERROR,
+          "Either 'prompt' or 'promptFile' must be provided");
+    }
+    if (inlinePrompt != null && promptFilePath != null) {
+      throw new SolrException(
+          SolrException.ErrorCode.SERVER_ERROR,
+          "Only one of 'prompt' or 'promptFile' can be provided, not both");
+    }
+    if (inlinePrompt != null) {
+      validatePromptPlaceholders(inlinePrompt, inputFields);
+      this.promptText = inlinePrompt;
+    }
+    this.promptFile = promptFilePath;
+  }
+
+  @Override
+  public void inform(SolrCore core) {
+    final SolrResourceLoader solrResourceLoader = core.getResourceLoader();
+    ManagedChatModelStore.registerManagedChatModelStore(solrResourceLoader, this);
+    if (promptFile != null) {
+      try (InputStream is = solrResourceLoader.openResource(promptFile)) {
+        promptText = new String(is.readAllBytes(), StandardCharsets.UTF_8).trim();
+      } catch (IOException e) {
+        throw new SolrException(
+            SolrException.ErrorCode.SERVER_ERROR,
+            "Cannot read prompt file: " + promptFile,
+            e);
+      }
+      validatePromptPlaceholders(promptText, inputFields);
+    }
+  }
+
+  @Override
+  public void onManagedResourceInitialized(NamedList<?> args, ManagedResource res)
+      throws SolrException {
+    if (res instanceof ManagedChatModelStore store) {
+      store.loadStoredModels();
+    }
+  }
+
+  @Override
+  public UpdateRequestProcessor getInstance(
+      SolrQueryRequest req, SolrQueryResponse rsp, UpdateRequestProcessor next) {
+    IndexSchema latestSchema = req.getCore().getLatestSchema();
+
+    for (String fieldName : inputFields) {
+      if (!latestSchema.isDynamicField(fieldName) && !latestSchema.hasExplicitField(fieldName)) {
+        throw new SolrException(
+            SolrException.ErrorCode.SERVER_ERROR, "undefined field: \"" + fieldName + "\"");
+      }
+    }
+
+    final SchemaField outputFieldSchema = latestSchema.getField(outputField);
+
+    ResponseFormat responseFormat = buildResponseFormat(outputFieldSchema);
+    boolean multiValued = outputFieldSchema.multiValued();
+
+    ManagedChatModelStore store = ManagedChatModelStore.getManagedModelStore(req.getCore());
+    SolrChatModel chatModel = store.getModel(modelName);
+    if (chatModel == null) {
+      throw new SolrException(
+          SolrException.ErrorCode.SERVER_ERROR,
+          "The model configured in the Update Request Processor '"
+              + modelName
+              + "' can't be found in the store: "
+              + ManagedChatModelStore.REST_END_POINT);
+    }
+
+    return new DocumentEnrichmentUpdateProcessor(
+        inputFields, outputField, promptText, chatModel, multiValued, responseFormat, req, next);
+  }
+
+  /**
+   * Builds a {@link ResponseFormat} that instructs the model to return a JSON object {@code
+   * {"value": ...}} whose value type matches the Solr field type. For multivalued fields the value
+   * is wrapped in a {@link JsonArraySchema} nested inside the root {@link JsonObjectSchema}.
+   *
+   * <p>Nesting {@link JsonArraySchema} inside a {@link JsonObjectSchema} property is supported by
+   * all langchain4j providers that implement structured outputs with {@link JsonObjectSchema} (OpenAI, Azure OpenAI,
+   * Google AI, Gemini, Mistral, Ollama, Amazon Bedrock, Watsonx).
+   */
+  static ResponseFormat buildResponseFormat(SchemaField schemaField) {
+    JsonSchemaElement valueElement = toJsonSchemaElement(schemaField.getType());
+    JsonSchemaElement valueSchema =
+        schemaField.multiValued()
+            ? JsonArraySchema.builder().items(valueElement).build()
+            : valueElement;
+    return ResponseFormat.builder()
+        .type(ResponseFormatType.JSON)
+        .jsonSchema(
+            JsonSchema.builder()
+                .name("output")
+                .rootElement(
+                    JsonObjectSchema.builder()
+                        .addProperty("value", valueSchema)
+                        .required("value")
+                        .build())
+                .build())
+        .build();
+  }
+
+  private static JsonSchemaElement toJsonSchemaElement(FieldType fieldType) {
+    // DenseVectorField extends FloatPointField, so it must be rejected before the numeric checks
+    if (fieldType instanceof DenseVectorField
+        || fieldType instanceof UUIDField
+        || fieldType instanceof NestPathField) {
+      throw new SolrException(
+          SolrException.ErrorCode.SERVER_ERROR,
+          "field type is not supported by Document Enrichment: "
+              + fieldType.getClass().getSimpleName());
+    }
+    if (fieldType instanceof StrField
+        || fieldType instanceof TextField
+        || fieldType instanceof DatePointField) {
+      return new JsonStringSchema();
+    } else if (fieldType instanceof IntPointField || fieldType instanceof LongPointField) {
+      return new JsonIntegerSchema();
+    } else if (fieldType instanceof FloatPointField || fieldType instanceof DoublePointField) {
+      return new JsonNumberSchema();
+    } else if (fieldType instanceof BoolField) {
+      return new JsonBooleanSchema();
+    } else {
+      throw new SolrException(
+          SolrException.ErrorCode.SERVER_ERROR,
+          "field type is not supported by Document Enrichment: "
+              + fieldType.getClass().getSimpleName());
+    }
+  }
+
+  private static void validatePromptPlaceholders(String prompt, List<String> fieldNames) {
+    Set<String> promptPlaceholders = new HashSet<>();
+    Matcher m = PLACEHOLDER_PATTERN.matcher(prompt);
+    while (m.find()) {
+      promptPlaceholders.add(m.group(1));
+    }
+
+    Set<String> fieldsWithoutPlaceholder = new HashSet<>(fieldNames);

Review Comment:
   changed



##########
solr/modules/language-models/src/test/org/apache/solr/languagemodels/documentenrichment/update/processor/DocumentEnrichmentUpdateProcessorFactoryTest.java:
##########
@@ -0,0 +1,422 @@
+/*
+ * 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.solr.languagemodels.documentenrichment.update.processor;
+
+import java.util.List;
+import org.apache.solr.common.SolrException;
+import org.apache.solr.common.params.ModifiableSolrParams;
+import org.apache.solr.common.util.NamedList;
+import org.apache.solr.core.SolrCore;
+import org.apache.solr.languagemodels.TestLanguageModelBase;
+import org.apache.solr.languagemodels.documentenrichment.model.SolrChatModel;
+import org.apache.solr.languagemodels.documentenrichment.store.rest.ManagedChatModelStore;
+import org.apache.solr.request.SolrQueryRequestBase;
+import org.apache.solr.update.processor.UpdateRequestProcessor;
+import org.junit.After;
+import org.junit.AfterClass;
+import org.junit.Before;
+import org.junit.BeforeClass;
+import org.junit.Test;
+
+public class DocumentEnrichmentUpdateProcessorFactoryTest extends TestLanguageModelBase {
+
+  @BeforeClass
+  public static void init() throws Exception {
+    setupTest("solrconfig-document-enrichment.xml", "schema-language-models.xml", false, false);
+  }
+
+  @AfterClass
+  public static void cleanup() throws Exception {
+    afterTest();
+  }
+
+  SolrCore collection1;
+
+  @Before
+  public void setup() {
+    collection1 = solrTestRule.getCoreContainer().getCore("collection1");
+  }
+
+  @After
+  public void after() {
+    collection1.close();
+  }
+
+  @Test
+  public void init_fullArgs_shouldInitAllParams() {
+    NamedList<String> args = new NamedList<>();
+    args.add("inputField", "string_field");
+    args.add("outputField", "enriched_field");
+    args.add("prompt", "Summarize: {string_field}");
+    args.add("model", "model1");
+
+    DocumentEnrichmentUpdateProcessorFactory factory = new DocumentEnrichmentUpdateProcessorFactory();
+    factory.init(args);
+
+    assertEquals(List.of("string_field"), factory.getInputFields());
+    assertEquals("enriched_field", factory.getOutputField());
+    assertEquals("Summarize: {string_field}", factory.getPrompt());
+    assertEquals("model1", factory.getModelName());
+  }
+
+  @Test
+  public void init_multipleInputFields_shouldInitAllFields() {
+    NamedList<String> args = new NamedList<>();
+    args.add("inputField", "string_field");
+    args.add("inputField", "body_field");
+    args.add("outputField", "enriched_field");
+    args.add("prompt", "Title: {string_field}. Body: {body_field}.");
+    args.add("model", "model1");
+
+    DocumentEnrichmentUpdateProcessorFactory factory = new DocumentEnrichmentUpdateProcessorFactory();
+    factory.init(args);
+
+    assertEquals(List.of("string_field", "body_field"), factory.getInputFields());
+  }
+
+  @Test
+  public void init_noInputField_shouldThrowExceptionWithDetailedMessage() {
+    NamedList<String> args = new NamedList<>();
+    args.add("outputField", "enriched_field");
+    args.add("prompt", "Summarize: {string_field}");
+    args.add("model", "model1");
+
+    DocumentEnrichmentUpdateProcessorFactory factory = new DocumentEnrichmentUpdateProcessorFactory();
+
+    SolrException e = assertThrows(SolrException.class, () -> factory.init(args));
+    assertEquals("At least one 'inputField' must be provided", e.getMessage());
+  }
+
+  @Test
+  public void init_nullOutputField_shouldThrowExceptionWithDetailedMessage() {
+    NamedList<String> args = new NamedList<>();
+    args.add("inputField", "string_field");
+    args.add("prompt", "Summarize: {string_field}");
+    args.add("model", "model1");
+
+    DocumentEnrichmentUpdateProcessorFactory factory = new DocumentEnrichmentUpdateProcessorFactory();
+
+    SolrException e = assertThrows(SolrException.class, () -> factory.init(args));
+    assertEquals("Missing required parameter: outputField", e.getMessage());
+  }
+
+  @Test
+  public void init_neitherPromptNorPromptFile_shouldThrowExceptionWithDetailedMessage() {
+    NamedList<String> args = new NamedList<>();
+    args.add("inputField", "string_field");
+    args.add("outputField", "enriched_field");
+    args.add("model", "model1");
+
+    DocumentEnrichmentUpdateProcessorFactory factory = new DocumentEnrichmentUpdateProcessorFactory();
+
+    SolrException e = assertThrows(SolrException.class, () -> factory.init(args));
+    assertEquals("Either 'prompt' or 'promptFile' must be provided", e.getMessage());
+  }
+
+  @Test
+  public void init_bothPromptAndPromptFile_shouldThrowExceptionWithDetailedMessage() {
+    NamedList<String> args = new NamedList<>();
+    args.add("inputField", "string_field");
+    args.add("outputField", "enriched_field");
+    args.add("prompt", "Summarize: {string_field}");
+    args.add("promptFile", "prompt.txt");
+    args.add("model", "model1");
+
+    DocumentEnrichmentUpdateProcessorFactory factory = new DocumentEnrichmentUpdateProcessorFactory();
+
+    SolrException e = assertThrows(SolrException.class, () -> factory.init(args));
+    assertEquals("Only one of 'prompt' or 'promptFile' can be provided, not both", e.getMessage());
+  }
+
+  @Test
+  public void init_promptMissingPlaceholderForDeclaredField_shouldThrowExceptionWithDetailedMessage() {
+    NamedList<String> args = new NamedList<>();
+    args.add("inputField", "string_field");
+    args.add("outputField", "enriched_field");
+    args.add("prompt", "Summarize:");
+    args.add("model", "model1");
+
+    DocumentEnrichmentUpdateProcessorFactory factory = new DocumentEnrichmentUpdateProcessorFactory();
+
+    SolrException e = assertThrows(SolrException.class, () -> factory.init(args));
+    assertEquals("prompt is missing placeholders for inputField(s): [string_field]", e.getMessage());
+  }
+
+  @Test
+  public void init_promptMissingOnePlaceholderOfMultipleFields_shouldThrowExceptionWithDetailedMessage() {
+    NamedList<String> args = new NamedList<>();
+    args.add("inputField", "string_field");
+    args.add("inputField", "body_field");
+    args.add("outputField", "enriched_field");
+    args.add("prompt", "Title: {string_field}.");
+    args.add("model", "model1");
+
+    DocumentEnrichmentUpdateProcessorFactory factory = new DocumentEnrichmentUpdateProcessorFactory();
+
+    SolrException e = assertThrows(SolrException.class, () -> factory.init(args));
+    assertEquals("prompt is missing placeholders for inputField(s): [body_field]", e.getMessage());
+  }
+
+  @Test
+  public void init_promptHasExtraPlaceholderNotDeclaredAsInputField_shouldThrowExceptionWithDetailedMessage() {
+    NamedList<String> args = new NamedList<>();
+    args.add("inputField", "string_field");
+    args.add("outputField", "enriched_field");
+    args.add("prompt", "Title: {string_field}. Extra: {unknown_field}.");
+    args.add("model", "model1");
+
+    DocumentEnrichmentUpdateProcessorFactory factory = new DocumentEnrichmentUpdateProcessorFactory();
+
+    SolrException e = assertThrows(SolrException.class, () -> factory.init(args));
+    assertEquals(
+        "prompt contains placeholders not declared as inputField(s): [unknown_field]",
+        e.getMessage());
+  }
+
+  @Test
+  public void init_nullModel_shouldThrowExceptionWithDetailedMessage() {
+    NamedList<String> args = new NamedList<>();
+    args.add("inputField", "string_field");
+    args.add("outputField", "enriched_field");
+    args.add("prompt", "Summarize: {string_field}");
+
+    DocumentEnrichmentUpdateProcessorFactory factory = new DocumentEnrichmentUpdateProcessorFactory();
+
+    SolrException e = assertThrows(SolrException.class, () -> factory.init(args));
+    assertEquals("Missing required parameter: model", e.getMessage());
+  }
+
+  @Test
+  public void init_promptFile_shouldLoadPromptFromFile() {
+    NamedList<String> args = new NamedList<>();
+    args.add("inputField", "string_field");
+    args.add("outputField", "enriched_field");
+    args.add("promptFile", "prompt.txt");
+    args.add("model", "model1");
+
+    DocumentEnrichmentUpdateProcessorFactory factory = new DocumentEnrichmentUpdateProcessorFactory();
+    factory.init(args);
+    factory.inform(collection1);
+
+    assertEquals("prompt.txt", factory.getPromptFile());
+    assertNotNull(factory.getPrompt());
+    assertTrue(factory.getPrompt().contains("{string_field}"));
+  }
+
+  @Test
+  public void init_promptFileMultiField_shouldLoadAndValidateBothPlaceholders() {
+    NamedList<String> args = new NamedList<>();
+    args.add("inputField", "string_field");
+    args.add("inputField", "body_field");
+    args.add("outputField", "enriched_field");
+    args.add("promptFile", "prompt-multi-field.txt");
+    args.add("model", "model1");
+
+    DocumentEnrichmentUpdateProcessorFactory factory = new DocumentEnrichmentUpdateProcessorFactory();
+    factory.init(args);
+    factory.inform(collection1);
+
+    assertNotNull(factory.getPrompt());
+    assertTrue(factory.getPrompt().contains("{string_field}"));
+    assertTrue(factory.getPrompt().contains("{body_field}"));
+  }
+
+  @Test
+  public void init_promptFileWithMissingPlaceholder_shouldThrowExceptionInInform() {
+    NamedList<String> args = new NamedList<>();
+    args.add("inputField", "string_field");
+    args.add("outputField", "enriched_field");
+    args.add("promptFile", "prompt-no-placeholder.txt");
+    args.add("model", "model1");
+
+    DocumentEnrichmentUpdateProcessorFactory factory = new DocumentEnrichmentUpdateProcessorFactory();
+    factory.init(args);
+
+    SolrException e = assertThrows(SolrException.class, () -> factory.inform(collection1));
+    assertEquals(
+        "prompt is missing placeholders for inputField(s): [string_field]", e.getMessage());
+  }
+
+  /* Following tests depend on a real solr schema and depend on BeforeClass-AfterClass methods */
+
+  @Test
+  public void init_notExistentOutputField_shouldThrowExceptionWithDetailedMessage() {
+    NamedList<String> args = new NamedList<>();
+    args.add("inputField", "string_field");
+    args.add("outputField", "notExistentOutput");
+    args.add("prompt", "Summarize: {string_field}");
+    args.add("model", "model1");
+
+    DocumentEnrichmentUpdateProcessorFactory factory = new DocumentEnrichmentUpdateProcessorFactory();
+    ModifiableSolrParams params = new ModifiableSolrParams();
+    SolrQueryRequestBase req = new SolrQueryRequestBase(collection1, params) {};
+    factory.init(args);
+
+    SolrException e = assertThrows(SolrException.class, () -> factory.getInstance(req, null, null));
+    assertEquals("undefined field: \"notExistentOutput\"", e.getMessage());
+  }
+
+  @Test
+  public void init_notTextualOutputField_shouldThrowExceptionWithDetailedMessage() {
+    // vector is a DenseVectorField — not a textual field
+    NamedList<String> args = new NamedList<>();
+    args.add("inputField", "string_field");
+    args.add("outputField", "vector");
+    args.add("prompt", "Summarize: {string_field}");
+    args.add("model", "model1");
+
+    DocumentEnrichmentUpdateProcessorFactory factory = new DocumentEnrichmentUpdateProcessorFactory();
+    ModifiableSolrParams params = new ModifiableSolrParams();
+    SolrQueryRequestBase req = new SolrQueryRequestBase(collection1, params) {};
+    factory.init(args);
+
+    SolrException e = assertThrows(SolrException.class, () -> factory.getInstance(req, null, null));
+    assertEquals(
+        "field type is not supported by Document Enrichment: DenseVectorField", e.getMessage());
+  }
+
+  @Test
+  public void init_notExistentInputField_shouldThrowExceptionWithDetailedMessage() {
+    NamedList<String> args = new NamedList<>();
+    args.add("inputField", "notExistentInput");
+    args.add("outputField", "enriched_field");
+    args.add("prompt", "Summarize: {notExistentInput}");
+    args.add("model", "model1");
+
+    DocumentEnrichmentUpdateProcessorFactory factory = new DocumentEnrichmentUpdateProcessorFactory();
+    ModifiableSolrParams params = new ModifiableSolrParams();
+    SolrQueryRequestBase req = new SolrQueryRequestBase(collection1, params) {};
+    factory.init(args);
+
+    SolrException e = assertThrows(SolrException.class, () -> factory.getInstance(req, null, null));
+    assertEquals("undefined field: \"notExistentInput\"", e.getMessage());
+  }
+
+  @Test
+  public void init_multipleInputFields_oneNotExistent_shouldThrowExceptionWithDetailedMessage() {
+    NamedList<String> args = new NamedList<>();
+    args.add("inputField", "string_field");
+    args.add("inputField", "notExistentInput");
+    args.add("outputField", "enriched_field");
+    args.add("prompt", "Title: {string_field}. Body: {notExistentInput}.");
+    args.add("model", "model1");
+
+    DocumentEnrichmentUpdateProcessorFactory factory = new DocumentEnrichmentUpdateProcessorFactory();
+    ModifiableSolrParams params = new ModifiableSolrParams();
+    SolrQueryRequestBase req = new SolrQueryRequestBase(collection1, params) {};
+    factory.init(args);
+
+    SolrException e = assertThrows(SolrException.class, () -> factory.getInstance(req, null, null));
+    assertEquals("undefined field: \"notExistentInput\"", e.getMessage());
+  }
+
+  @Test
+  public void init_multivaluedStringOutputField_shouldNotThrowException() throws Exception {
+    UpdateRequestProcessor instance =
+        createUpdateProcessor("string_field", "enriched_field_multi", collection1, "model-mv");
+    assertNotNull(instance);
+    restTestHarness.delete(ManagedChatModelStore.REST_END_POINT + "/model-mv");
+  }
+
+  @Test
+  public void init_multivaluedStringOutputField_buildResponseFormat_shouldProduceArraySchema() throws Exception {
+    NamedList<String> args = new NamedList<>();
+    ManagedChatModelStore.getManagedModelStore(collection1)
+        .addModel(new SolrChatModel("model-rf", null, null));
+    args.add("inputField", "string_field");
+    args.add("outputField", "enriched_field_multi");
+    args.add("prompt", "Summarize: {string_field}");
+    args.add("model", "model-rf");
+
+    DocumentEnrichmentUpdateProcessorFactory factory = new DocumentEnrichmentUpdateProcessorFactory();
+    factory.init(args);
+    ModifiableSolrParams params = new ModifiableSolrParams();
+    SolrQueryRequestBase req = new SolrQueryRequestBase(collection1, params) {};
+    assertNotNull(factory.getInstance(req, null, null));
+
+    // verify the ResponseFormat is constructed correctly for the multivalued field
+    var schema = collection1.getLatestSchema();
+    var schemaField = schema.getField("enriched_field_multi");
+    assertTrue(schemaField.multiValued());
+    var responseFormat = DocumentEnrichmentUpdateProcessorFactory.buildResponseFormat(schemaField);
+    assertNotNull(responseFormat);
+    assertEquals(
+        dev.langchain4j.model.chat.request.ResponseFormatType.JSON, responseFormat.type());
+    assertNotNull(responseFormat.jsonSchema());
+    restTestHarness.delete(ManagedChatModelStore.REST_END_POINT + "/model-rf");
+  }
+
+  @Test
+  public void init_singleValuedStringOutputField_buildResponseFormat_shouldProduceStringSchema() {
+    var schema = collection1.getLatestSchema();
+    var schemaField = schema.getField("enriched_field");
+    assertFalse(schemaField.multiValued());
+    var responseFormat = DocumentEnrichmentUpdateProcessorFactory.buildResponseFormat(schemaField);
+    assertNotNull(responseFormat);
+    assertEquals(
+        dev.langchain4j.model.chat.request.ResponseFormatType.JSON, responseFormat.type());
+    assertNotNull(responseFormat.jsonSchema());
+  }
+
+  @Test
+  public void init_dynamicInputField_shouldNotThrowException() throws Exception{
+    UpdateRequestProcessor instance =
+        createUpdateProcessor("text_s", "enriched_field", collection1, "model2");
+    assertNotNull(instance);
+    restTestHarness.delete(ManagedChatModelStore.REST_END_POINT + "/model2");
+  }
+
+  @Test
+  public void init_multipleDynamicInputFields_shouldNotThrowException() throws Exception{
+    NamedList<String> args = new NamedList<>();
+    ManagedChatModelStore.getManagedModelStore(collection1)
+        .addModel(new SolrChatModel("model1", null, null));
+    args.add("inputField", "text_s");
+    args.add("inputField", "body_field");
+    args.add("outputField", "enriched_field");
+    args.add("prompt", "Title: {text_s}. Body: {body_field}.");
+    args.add("model", "model1");
+
+    DocumentEnrichmentUpdateProcessorFactory factory = new DocumentEnrichmentUpdateProcessorFactory();
+    ModifiableSolrParams params = new ModifiableSolrParams();
+    factory.init(args);
+
+    SolrQueryRequestBase req = new SolrQueryRequestBase(collection1, params) {};
+    assertNotNull(factory.getInstance(req, null, null));
+    restTestHarness.delete(ManagedChatModelStore.REST_END_POINT + "/model1");
+  }
+
+  private UpdateRequestProcessor createUpdateProcessor(

Review Comment:
   I created a function `initializeUpdateProcessorFactory` that is used inside `createUpdateProcessor`. In this way, the code inside the first one can be reused



##########
solr/modules/language-models/src/java/org/apache/solr/languagemodels/documentenrichment/update/processor/DocumentEnrichmentUpdateProcessorFactory.java:
##########
@@ -0,0 +1,330 @@
+/*
+ * 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.solr.languagemodels.documentenrichment.update.processor;
+
+import dev.langchain4j.model.chat.request.ResponseFormat;
+import dev.langchain4j.model.chat.request.ResponseFormatType;
+import dev.langchain4j.model.chat.request.json.JsonArraySchema;
+import dev.langchain4j.model.chat.request.json.JsonBooleanSchema;
+import dev.langchain4j.model.chat.request.json.JsonIntegerSchema;
+import dev.langchain4j.model.chat.request.json.JsonNumberSchema;
+import dev.langchain4j.model.chat.request.json.JsonObjectSchema;
+import dev.langchain4j.model.chat.request.json.JsonSchema;
+import dev.langchain4j.model.chat.request.json.JsonSchemaElement;
+import dev.langchain4j.model.chat.request.json.JsonStringSchema;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import java.util.Collection;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import org.apache.solr.common.SolrException;
+import org.apache.solr.common.params.RequiredSolrParams;
+import org.apache.solr.common.params.SolrParams;
+import org.apache.solr.common.util.NamedList;
+import org.apache.solr.core.SolrCore;
+import org.apache.solr.core.SolrResourceLoader;
+import org.apache.solr.languagemodels.documentenrichment.model.SolrChatModel;
+import org.apache.solr.languagemodels.documentenrichment.store.rest.ManagedChatModelStore;
+import org.apache.solr.request.SolrQueryRequest;
+import org.apache.solr.response.SolrQueryResponse;
+import org.apache.solr.rest.ManagedResource;
+import org.apache.solr.rest.ManagedResourceObserver;
+import org.apache.solr.schema.BoolField;
+import org.apache.solr.schema.DatePointField;
+import org.apache.solr.schema.DenseVectorField;
+import org.apache.solr.schema.DoublePointField;
+import org.apache.solr.schema.FieldType;
+import org.apache.solr.schema.FloatPointField;
+import org.apache.solr.schema.IndexSchema;
+import org.apache.solr.schema.IntPointField;
+import org.apache.solr.schema.LongPointField;
+import org.apache.solr.schema.NestPathField;
+import org.apache.solr.schema.SchemaField;
+import org.apache.solr.schema.StrField;
+import org.apache.solr.schema.TextField;
+import org.apache.solr.schema.UUIDField;
+import org.apache.solr.update.processor.UpdateRequestProcessor;
+import org.apache.solr.update.processor.UpdateRequestProcessorFactory;
+import org.apache.solr.util.plugin.SolrCoreAware;
+
+/**
+ * Generate the content of a field based on other fields specified as input.
+ *
+ * <p>One or more {@code inputField} parameters specify the Solr fields to use as input. Each field
+ * name must appear as a {@code {fieldName}} placeholder in the prompt. Exactly one of {@code
+ * prompt} or {@code promptFile} must be provided.
+ *
+ * <pre class="prettyprint" >
+ * &lt;processor class=&quot;solr.llm.documentenrichment.update.processor.DocumentEnrichmentUpdateProcessorFactory&quot;&gt;
+ *   &lt;str name=&quot;inputField&quot;&gt;title_field&lt;/str&gt;
+ *   &lt;str name=&quot;inputField&quot;&gt;body_field&lt;/str&gt;
+ *   &lt;str name=&quot;outputField&quot;&gt;enriched_field&lt;/str&gt;
+ *   &lt;str name=&quot;prompt&quot;&gt;Title: {title_field}. Body: {body_field}.&lt;/str&gt;
+ *   &lt;str name=&quot;model&quot;&gt;ChatModel&lt;/str&gt;
+ * &lt;/processor&gt;
+ * </pre>
+ *
+ * <p>Multiple {@code inputField} values can also be declared as an array using {@code arr}:
+ *
+ * <pre class="prettyprint" >
+ * &lt;processor class=&quot;solr.llm.documentenrichment.update.processor.DocumentEnrichmentUpdateProcessorFactory&quot;&gt;
+ *   &lt;arr name=&quot;inputField&quot;&gt;
+ *     &lt;str&gt;title_field&lt;/str&gt;
+ *     &lt;str&gt;body_field&lt;/str&gt;
+ *   &lt;/arr&gt;
+ *   &lt;str name=&quot;outputField&quot;&gt;enriched_field&lt;/str&gt;
+ *   &lt;str name=&quot;prompt&quot;&gt;Title: {title_field}. Body: {body_field}.&lt;/str&gt;
+ *   &lt;str name=&quot;model&quot;&gt;ChatModel&lt;/str&gt;
+ * &lt;/processor&gt;
+ * </pre>
+ *
+ * <p>Alternatively, the prompt can be loaded from a text file using {@code promptFile}:
+ *
+ * <pre class="prettyprint" >
+ * &lt;processor class=&quot;solr.llm.documentenrichment.update.processor.DocumentEnrichmentUpdateProcessorFactory&quot;&gt;
+ *   &lt;str name=&quot;inputField&quot;&gt;title_field&lt;/str&gt;
+ *   &lt;str name=&quot;outputField&quot;&gt;enriched_field&lt;/str&gt;
+ *   &lt;str name=&quot;promptFile&quot;&gt;prompt.txt&lt;/str&gt;
+ *   &lt;str name=&quot;model&quot;&gt;ChatModel&lt;/str&gt;
+ * &lt;/processor&gt;
+ * </pre>
+ *
+ * <p>Validation rules:
+ *
+ * <ul>
+ *   <li>At least one {@code inputField} must be declared.
+ *   <li>Exactly one of {@code prompt} or {@code promptFile} must be provided.
+ *   <li>Every declared {@code inputField} must have a corresponding {@code {fieldName}} placeholder
+ *       in the prompt.
+ *   <li>Every {@code {placeholder}} in the prompt must correspond to a declared {@code inputField}.
+ * </ul>
+ */
+public class DocumentEnrichmentUpdateProcessorFactory extends UpdateRequestProcessorFactory
+    implements SolrCoreAware, ManagedResourceObserver {
+  private static final String INPUT_FIELD_PARAM = "inputField";
+  private static final String OUTPUT_FIELD_PARAM = "outputField";
+  private static final String PROMPT = "prompt";
+  private static final String PROMPT_FILE = "promptFile";
+  private static final String MODEL_NAME = "model";
+  private static final Pattern PLACEHOLDER_PATTERN = Pattern.compile("\\{([^}]+)\\}");
+
+  private List<String> inputFields;
+  private String outputField;
+  private String promptText;
+  private String promptFile;
+  private String modelName;
+
+  @Override
+  public void init(final NamedList<?> args) {
+    // removeConfigArgs handles both multiple <str name="inputField"> and <arr name="inputField">
+    // and must be called before toSolrParams() since it mutates args in place
+    Collection<String> fieldNames = args.removeConfigArgs(INPUT_FIELD_PARAM);
+    if (fieldNames.isEmpty()) {
+      throw new SolrException(
+          SolrException.ErrorCode.SERVER_ERROR,
+          "At least one 'inputField' must be provided");
+    }
+    inputFields = List.copyOf(fieldNames);
+
+    SolrParams params = args.toSolrParams();
+    RequiredSolrParams required = params.required();
+    outputField = required.get(OUTPUT_FIELD_PARAM);
+    modelName = required.get(MODEL_NAME);
+
+    String inlinePrompt = params.get(PROMPT);
+    String promptFilePath = params.get(PROMPT_FILE);
+
+    if (inlinePrompt == null && promptFilePath == null) {
+      throw new SolrException(
+          SolrException.ErrorCode.SERVER_ERROR,
+          "Either 'prompt' or 'promptFile' must be provided");
+    }
+    if (inlinePrompt != null && promptFilePath != null) {
+      throw new SolrException(
+          SolrException.ErrorCode.SERVER_ERROR,
+          "Only one of 'prompt' or 'promptFile' can be provided, not both");
+    }
+    if (inlinePrompt != null) {
+      validatePromptPlaceholders(inlinePrompt, inputFields);
+      this.promptText = inlinePrompt;
+    }
+    this.promptFile = promptFilePath;
+  }
+
+  @Override
+  public void inform(SolrCore core) {
+    final SolrResourceLoader solrResourceLoader = core.getResourceLoader();
+    ManagedChatModelStore.registerManagedChatModelStore(solrResourceLoader, this);
+    if (promptFile != null) {
+      try (InputStream is = solrResourceLoader.openResource(promptFile)) {
+        promptText = new String(is.readAllBytes(), StandardCharsets.UTF_8).trim();
+      } catch (IOException e) {
+        throw new SolrException(
+            SolrException.ErrorCode.SERVER_ERROR,
+            "Cannot read prompt file: " + promptFile,
+            e);
+      }
+      validatePromptPlaceholders(promptText, inputFields);
+    }
+  }
+
+  @Override
+  public void onManagedResourceInitialized(NamedList<?> args, ManagedResource res)
+      throws SolrException {
+    if (res instanceof ManagedChatModelStore store) {
+      store.loadStoredModels();
+    }
+  }
+
+  @Override
+  public UpdateRequestProcessor getInstance(
+      SolrQueryRequest req, SolrQueryResponse rsp, UpdateRequestProcessor next) {
+    IndexSchema latestSchema = req.getCore().getLatestSchema();
+
+    for (String fieldName : inputFields) {
+      if (!latestSchema.isDynamicField(fieldName) && !latestSchema.hasExplicitField(fieldName)) {
+        throw new SolrException(
+            SolrException.ErrorCode.SERVER_ERROR, "undefined field: \"" + fieldName + "\"");
+      }
+    }
+
+    final SchemaField outputFieldSchema = latestSchema.getField(outputField);
+
+    ResponseFormat responseFormat = buildResponseFormat(outputFieldSchema);
+    boolean multiValued = outputFieldSchema.multiValued();
+
+    ManagedChatModelStore store = ManagedChatModelStore.getManagedModelStore(req.getCore());
+    SolrChatModel chatModel = store.getModel(modelName);
+    if (chatModel == null) {
+      throw new SolrException(
+          SolrException.ErrorCode.SERVER_ERROR,
+          "The model configured in the Update Request Processor '"
+              + modelName
+              + "' can't be found in the store: "
+              + ManagedChatModelStore.REST_END_POINT);
+    }
+
+    return new DocumentEnrichmentUpdateProcessor(
+        inputFields, outputField, promptText, chatModel, multiValued, responseFormat, req, next);
+  }
+
+  /**
+   * Builds a {@link ResponseFormat} that instructs the model to return a JSON object {@code
+   * {"value": ...}} whose value type matches the Solr field type. For multivalued fields the value
+   * is wrapped in a {@link JsonArraySchema} nested inside the root {@link JsonObjectSchema}.
+   *
+   * <p>Nesting {@link JsonArraySchema} inside a {@link JsonObjectSchema} property is supported by
+   * all langchain4j providers that implement structured outputs with {@link JsonObjectSchema} (OpenAI, Azure OpenAI,
+   * Google AI, Gemini, Mistral, Ollama, Amazon Bedrock, Watsonx).
+   */
+  static ResponseFormat buildResponseFormat(SchemaField schemaField) {
+    JsonSchemaElement valueElement = toJsonSchemaElement(schemaField.getType());
+    JsonSchemaElement valueSchema =
+        schemaField.multiValued()
+            ? JsonArraySchema.builder().items(valueElement).build()
+            : valueElement;
+    return ResponseFormat.builder()
+        .type(ResponseFormatType.JSON)
+        .jsonSchema(
+            JsonSchema.builder()
+                .name("output")
+                .rootElement(
+                    JsonObjectSchema.builder()
+                        .addProperty("value", valueSchema)
+                        .required("value")
+                        .build())
+                .build())
+        .build();
+  }
+
+  private static JsonSchemaElement toJsonSchemaElement(FieldType fieldType) {
+    // DenseVectorField extends FloatPointField, so it must be rejected before the numeric checks
+    if (fieldType instanceof DenseVectorField
+        || fieldType instanceof UUIDField
+        || fieldType instanceof NestPathField) {
+      throw new SolrException(
+          SolrException.ErrorCode.SERVER_ERROR,
+          "field type is not supported by Document Enrichment: "
+              + fieldType.getClass().getSimpleName());
+    }
+    if (fieldType instanceof StrField
+        || fieldType instanceof TextField
+        || fieldType instanceof DatePointField) {
+      return new JsonStringSchema();
+    } else if (fieldType instanceof IntPointField || fieldType instanceof LongPointField) {
+      return new JsonIntegerSchema();
+    } else if (fieldType instanceof FloatPointField || fieldType instanceof DoublePointField) {
+      return new JsonNumberSchema();
+    } else if (fieldType instanceof BoolField) {
+      return new JsonBooleanSchema();
+    } else {
+      throw new SolrException(
+          SolrException.ErrorCode.SERVER_ERROR,
+          "field type is not supported by Document Enrichment: "
+              + fieldType.getClass().getSimpleName());
+    }
+  }
+
+  private static void validatePromptPlaceholders(String prompt, List<String> fieldNames) {
+    Set<String> promptPlaceholders = new HashSet<>();
+    Matcher m = PLACEHOLDER_PATTERN.matcher(prompt);
+    while (m.find()) {
+      promptPlaceholders.add(m.group(1));
+    }
+
+    Set<String> fieldsWithoutPlaceholder = new HashSet<>(fieldNames);
+    fieldsWithoutPlaceholder.removeAll(promptPlaceholders);
+    if (!fieldsWithoutPlaceholder.isEmpty()) {
+      throw new SolrException(
+          SolrException.ErrorCode.SERVER_ERROR,
+          "prompt is missing placeholders for inputField(s): " + fieldsWithoutPlaceholder);
+    }
+
+    Set<String> placeholdersWithoutField = new HashSet<>(promptPlaceholders);

Review Comment:
   changed



##########
solr/modules/language-models/src/java/org/apache/solr/languagemodels/documentenrichment/update/processor/DocumentEnrichmentUpdateProcessorFactory.java:
##########
@@ -0,0 +1,330 @@
+/*
+ * 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.solr.languagemodels.documentenrichment.update.processor;
+
+import dev.langchain4j.model.chat.request.ResponseFormat;
+import dev.langchain4j.model.chat.request.ResponseFormatType;
+import dev.langchain4j.model.chat.request.json.JsonArraySchema;
+import dev.langchain4j.model.chat.request.json.JsonBooleanSchema;
+import dev.langchain4j.model.chat.request.json.JsonIntegerSchema;
+import dev.langchain4j.model.chat.request.json.JsonNumberSchema;
+import dev.langchain4j.model.chat.request.json.JsonObjectSchema;
+import dev.langchain4j.model.chat.request.json.JsonSchema;
+import dev.langchain4j.model.chat.request.json.JsonSchemaElement;
+import dev.langchain4j.model.chat.request.json.JsonStringSchema;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import java.util.Collection;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import org.apache.solr.common.SolrException;
+import org.apache.solr.common.params.RequiredSolrParams;
+import org.apache.solr.common.params.SolrParams;
+import org.apache.solr.common.util.NamedList;
+import org.apache.solr.core.SolrCore;
+import org.apache.solr.core.SolrResourceLoader;
+import org.apache.solr.languagemodels.documentenrichment.model.SolrChatModel;
+import org.apache.solr.languagemodels.documentenrichment.store.rest.ManagedChatModelStore;
+import org.apache.solr.request.SolrQueryRequest;
+import org.apache.solr.response.SolrQueryResponse;
+import org.apache.solr.rest.ManagedResource;
+import org.apache.solr.rest.ManagedResourceObserver;
+import org.apache.solr.schema.BoolField;
+import org.apache.solr.schema.DatePointField;
+import org.apache.solr.schema.DenseVectorField;
+import org.apache.solr.schema.DoublePointField;
+import org.apache.solr.schema.FieldType;
+import org.apache.solr.schema.FloatPointField;
+import org.apache.solr.schema.IndexSchema;
+import org.apache.solr.schema.IntPointField;
+import org.apache.solr.schema.LongPointField;
+import org.apache.solr.schema.NestPathField;
+import org.apache.solr.schema.SchemaField;
+import org.apache.solr.schema.StrField;
+import org.apache.solr.schema.TextField;
+import org.apache.solr.schema.UUIDField;
+import org.apache.solr.update.processor.UpdateRequestProcessor;
+import org.apache.solr.update.processor.UpdateRequestProcessorFactory;
+import org.apache.solr.util.plugin.SolrCoreAware;
+
+/**
+ * Generate the content of a field based on other fields specified as input.
+ *
+ * <p>One or more {@code inputField} parameters specify the Solr fields to use as input. Each field
+ * name must appear as a {@code {fieldName}} placeholder in the prompt. Exactly one of {@code
+ * prompt} or {@code promptFile} must be provided.
+ *
+ * <pre class="prettyprint" >
+ * &lt;processor class=&quot;solr.llm.documentenrichment.update.processor.DocumentEnrichmentUpdateProcessorFactory&quot;&gt;
+ *   &lt;str name=&quot;inputField&quot;&gt;title_field&lt;/str&gt;
+ *   &lt;str name=&quot;inputField&quot;&gt;body_field&lt;/str&gt;
+ *   &lt;str name=&quot;outputField&quot;&gt;enriched_field&lt;/str&gt;
+ *   &lt;str name=&quot;prompt&quot;&gt;Title: {title_field}. Body: {body_field}.&lt;/str&gt;
+ *   &lt;str name=&quot;model&quot;&gt;ChatModel&lt;/str&gt;
+ * &lt;/processor&gt;
+ * </pre>
+ *
+ * <p>Multiple {@code inputField} values can also be declared as an array using {@code arr}:
+ *
+ * <pre class="prettyprint" >
+ * &lt;processor class=&quot;solr.llm.documentenrichment.update.processor.DocumentEnrichmentUpdateProcessorFactory&quot;&gt;
+ *   &lt;arr name=&quot;inputField&quot;&gt;
+ *     &lt;str&gt;title_field&lt;/str&gt;
+ *     &lt;str&gt;body_field&lt;/str&gt;
+ *   &lt;/arr&gt;
+ *   &lt;str name=&quot;outputField&quot;&gt;enriched_field&lt;/str&gt;
+ *   &lt;str name=&quot;prompt&quot;&gt;Title: {title_field}. Body: {body_field}.&lt;/str&gt;
+ *   &lt;str name=&quot;model&quot;&gt;ChatModel&lt;/str&gt;
+ * &lt;/processor&gt;
+ * </pre>
+ *
+ * <p>Alternatively, the prompt can be loaded from a text file using {@code promptFile}:
+ *
+ * <pre class="prettyprint" >
+ * &lt;processor class=&quot;solr.llm.documentenrichment.update.processor.DocumentEnrichmentUpdateProcessorFactory&quot;&gt;
+ *   &lt;str name=&quot;inputField&quot;&gt;title_field&lt;/str&gt;
+ *   &lt;str name=&quot;outputField&quot;&gt;enriched_field&lt;/str&gt;
+ *   &lt;str name=&quot;promptFile&quot;&gt;prompt.txt&lt;/str&gt;
+ *   &lt;str name=&quot;model&quot;&gt;ChatModel&lt;/str&gt;
+ * &lt;/processor&gt;
+ * </pre>
+ *
+ * <p>Validation rules:
+ *
+ * <ul>
+ *   <li>At least one {@code inputField} must be declared.
+ *   <li>Exactly one of {@code prompt} or {@code promptFile} must be provided.
+ *   <li>Every declared {@code inputField} must have a corresponding {@code {fieldName}} placeholder
+ *       in the prompt.
+ *   <li>Every {@code {placeholder}} in the prompt must correspond to a declared {@code inputField}.
+ * </ul>
+ */
+public class DocumentEnrichmentUpdateProcessorFactory extends UpdateRequestProcessorFactory
+    implements SolrCoreAware, ManagedResourceObserver {
+  private static final String INPUT_FIELD_PARAM = "inputField";
+  private static final String OUTPUT_FIELD_PARAM = "outputField";
+  private static final String PROMPT = "prompt";
+  private static final String PROMPT_FILE = "promptFile";
+  private static final String MODEL_NAME = "model";
+  private static final Pattern PLACEHOLDER_PATTERN = Pattern.compile("\\{([^}]+)\\}");
+
+  private List<String> inputFields;
+  private String outputField;
+  private String promptText;
+  private String promptFile;
+  private String modelName;
+
+  @Override
+  public void init(final NamedList<?> args) {
+    // removeConfigArgs handles both multiple <str name="inputField"> and <arr name="inputField">
+    // and must be called before toSolrParams() since it mutates args in place
+    Collection<String> fieldNames = args.removeConfigArgs(INPUT_FIELD_PARAM);
+    if (fieldNames.isEmpty()) {
+      throw new SolrException(
+          SolrException.ErrorCode.SERVER_ERROR,
+          "At least one 'inputField' must be provided");
+    }
+    inputFields = List.copyOf(fieldNames);
+
+    SolrParams params = args.toSolrParams();
+    RequiredSolrParams required = params.required();
+    outputField = required.get(OUTPUT_FIELD_PARAM);
+    modelName = required.get(MODEL_NAME);
+
+    String inlinePrompt = params.get(PROMPT);
+    String promptFilePath = params.get(PROMPT_FILE);
+
+    if (inlinePrompt == null && promptFilePath == null) {
+      throw new SolrException(
+          SolrException.ErrorCode.SERVER_ERROR,
+          "Either 'prompt' or 'promptFile' must be provided");
+    }
+    if (inlinePrompt != null && promptFilePath != null) {
+      throw new SolrException(
+          SolrException.ErrorCode.SERVER_ERROR,
+          "Only one of 'prompt' or 'promptFile' can be provided, not both");
+    }
+    if (inlinePrompt != null) {
+      validatePromptPlaceholders(inlinePrompt, inputFields);
+      this.promptText = inlinePrompt;
+    }
+    this.promptFile = promptFilePath;
+  }
+
+  @Override
+  public void inform(SolrCore core) {
+    final SolrResourceLoader solrResourceLoader = core.getResourceLoader();
+    ManagedChatModelStore.registerManagedChatModelStore(solrResourceLoader, this);
+    if (promptFile != null) {
+      try (InputStream is = solrResourceLoader.openResource(promptFile)) {
+        promptText = new String(is.readAllBytes(), StandardCharsets.UTF_8).trim();
+      } catch (IOException e) {
+        throw new SolrException(
+            SolrException.ErrorCode.SERVER_ERROR,
+            "Cannot read prompt file: " + promptFile,
+            e);
+      }
+      validatePromptPlaceholders(promptText, inputFields);
+    }
+  }
+
+  @Override
+  public void onManagedResourceInitialized(NamedList<?> args, ManagedResource res)
+      throws SolrException {
+    if (res instanceof ManagedChatModelStore store) {
+      store.loadStoredModels();
+    }
+  }
+
+  @Override
+  public UpdateRequestProcessor getInstance(
+      SolrQueryRequest req, SolrQueryResponse rsp, UpdateRequestProcessor next) {
+    IndexSchema latestSchema = req.getCore().getLatestSchema();
+
+    for (String fieldName : inputFields) {
+      if (!latestSchema.isDynamicField(fieldName) && !latestSchema.hasExplicitField(fieldName)) {
+        throw new SolrException(
+            SolrException.ErrorCode.SERVER_ERROR, "undefined field: \"" + fieldName + "\"");
+      }
+    }
+
+    final SchemaField outputFieldSchema = latestSchema.getField(outputField);
+
+    ResponseFormat responseFormat = buildResponseFormat(outputFieldSchema);
+    boolean multiValued = outputFieldSchema.multiValued();
+
+    ManagedChatModelStore store = ManagedChatModelStore.getManagedModelStore(req.getCore());
+    SolrChatModel chatModel = store.getModel(modelName);
+    if (chatModel == null) {
+      throw new SolrException(
+          SolrException.ErrorCode.SERVER_ERROR,
+          "The model configured in the Update Request Processor '"
+              + modelName
+              + "' can't be found in the store: "
+              + ManagedChatModelStore.REST_END_POINT);
+    }
+
+    return new DocumentEnrichmentUpdateProcessor(
+        inputFields, outputField, promptText, chatModel, multiValued, responseFormat, req, next);
+  }
+
+  /**
+   * Builds a {@link ResponseFormat} that instructs the model to return a JSON object {@code
+   * {"value": ...}} whose value type matches the Solr field type. For multivalued fields the value
+   * is wrapped in a {@link JsonArraySchema} nested inside the root {@link JsonObjectSchema}.
+   *
+   * <p>Nesting {@link JsonArraySchema} inside a {@link JsonObjectSchema} property is supported by
+   * all langchain4j providers that implement structured outputs with {@link JsonObjectSchema} (OpenAI, Azure OpenAI,
+   * Google AI, Gemini, Mistral, Ollama, Amazon Bedrock, Watsonx).
+   */
+  static ResponseFormat buildResponseFormat(SchemaField schemaField) {
+    JsonSchemaElement valueElement = toJsonSchemaElement(schemaField.getType());
+    JsonSchemaElement valueSchema =
+        schemaField.multiValued()
+            ? JsonArraySchema.builder().items(valueElement).build()
+            : valueElement;
+    return ResponseFormat.builder()
+        .type(ResponseFormatType.JSON)
+        .jsonSchema(
+            JsonSchema.builder()
+                .name("output")
+                .rootElement(
+                    JsonObjectSchema.builder()
+                        .addProperty("value", valueSchema)
+                        .required("value")
+                        .build())
+                .build())
+        .build();
+  }
+
+  private static JsonSchemaElement toJsonSchemaElement(FieldType fieldType) {
+    // DenseVectorField extends FloatPointField, so it must be rejected before the numeric checks
+    if (fieldType instanceof DenseVectorField
+        || fieldType instanceof UUIDField
+        || fieldType instanceof NestPathField) {
+      throw new SolrException(
+          SolrException.ErrorCode.SERVER_ERROR,
+          "field type is not supported by Document Enrichment: "
+              + fieldType.getClass().getSimpleName());
+    }
+    if (fieldType instanceof StrField
+        || fieldType instanceof TextField
+        || fieldType instanceof DatePointField) {
+      return new JsonStringSchema();
+    } else if (fieldType instanceof IntPointField || fieldType instanceof LongPointField) {
+      return new JsonIntegerSchema();
+    } else if (fieldType instanceof FloatPointField || fieldType instanceof DoublePointField) {
+      return new JsonNumberSchema();
+    } else if (fieldType instanceof BoolField) {
+      return new JsonBooleanSchema();
+    } else {
+      throw new SolrException(
+          SolrException.ErrorCode.SERVER_ERROR,
+          "field type is not supported by Document Enrichment: "
+              + fieldType.getClass().getSimpleName());
+    }
+  }
+
+  private static void validatePromptPlaceholders(String prompt, List<String> fieldNames) {
+    Set<String> promptPlaceholders = new HashSet<>();
+    Matcher m = PLACEHOLDER_PATTERN.matcher(prompt);

Review Comment:
   changed



-- 
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: issues-unsubscribe@solr.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


---------------------------------------------------------------------
To unsubscribe, e-mail: issues-unsubscribe@solr.apache.org
For additional commands, e-mail: issues-help@solr.apache.org


Re: [PR] SOLR-18187: Document enrichment with LLMs [solr]

Posted by "aruggero (via GitHub)" <gi...@apache.org>.
aruggero commented on code in PR #4259:
URL: https://github.com/apache/solr/pull/4259#discussion_r3219789717


##########
solr/modules/language-models/src/java/org/apache/solr/languagemodels/update/processor/factory/DocumentEnrichmentUpdateProcessorFactory.java:
##########
@@ -0,0 +1,335 @@
+/*
+ * 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.solr.languagemodels.update.processor.factory;
+
+import dev.langchain4j.model.chat.request.ResponseFormat;
+import dev.langchain4j.model.chat.request.ResponseFormatType;
+import dev.langchain4j.model.chat.request.json.JsonArraySchema;
+import dev.langchain4j.model.chat.request.json.JsonBooleanSchema;
+import dev.langchain4j.model.chat.request.json.JsonIntegerSchema;
+import dev.langchain4j.model.chat.request.json.JsonNumberSchema;
+import dev.langchain4j.model.chat.request.json.JsonObjectSchema;
+import dev.langchain4j.model.chat.request.json.JsonSchema;
+import dev.langchain4j.model.chat.request.json.JsonSchemaElement;
+import dev.langchain4j.model.chat.request.json.JsonStringSchema;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import java.util.Collection;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import org.apache.solr.common.SolrException;
+import org.apache.solr.common.params.RequiredSolrParams;
+import org.apache.solr.common.params.SolrParams;
+import org.apache.solr.common.util.NamedList;
+import org.apache.solr.core.SolrCore;
+import org.apache.solr.core.SolrResourceLoader;
+import org.apache.solr.languagemodels.model.SolrLargeLanguageModel;
+import org.apache.solr.languagemodels.store.rest.ManagedLargeLanguageModelStore;
+import org.apache.solr.languagemodels.update.processor.DocumentEnrichmentUpdateProcessor;
+import org.apache.solr.request.SolrQueryRequest;
+import org.apache.solr.response.SolrQueryResponse;
+import org.apache.solr.rest.ManagedResource;
+import org.apache.solr.rest.ManagedResourceObserver;
+import org.apache.solr.schema.BoolField;
+import org.apache.solr.schema.DatePointField;
+import org.apache.solr.schema.DenseVectorField;
+import org.apache.solr.schema.DoublePointField;
+import org.apache.solr.schema.FieldType;
+import org.apache.solr.schema.FloatPointField;
+import org.apache.solr.schema.IndexSchema;
+import org.apache.solr.schema.IntPointField;
+import org.apache.solr.schema.LongPointField;
+import org.apache.solr.schema.NestPathField;
+import org.apache.solr.schema.SchemaField;
+import org.apache.solr.schema.StrField;
+import org.apache.solr.schema.TextField;
+import org.apache.solr.schema.UUIDField;
+import org.apache.solr.update.processor.UpdateRequestProcessor;
+import org.apache.solr.update.processor.UpdateRequestProcessorFactory;
+import org.apache.solr.util.plugin.SolrCoreAware;
+
+/**
+ * Generate the content of {@code outputField} based on other fields specified as {@code
+ * inputField}s.
+ *
+ * <p>The following validation rules are applied:
+ *
+ * <ul>
+ *   <li>At least one {@code inputField} must be declared.
+ *   <li>Exactly one of {@code prompt} or {@code promptFile} must be provided.
+ *   <li>Every declared {@code inputField} must have a corresponding {@code {fieldName}} placeholder
+ *       in the prompt.
+ *   <li>Every {@code {placeholder}} in the prompt must correspond to a declared {@code inputField}.
+ *   <li>One and only one {@code outputField} is allowed.
+ * </ul>
+ *
+ * <pre class="prettyprint" >
+ * &lt;processor class=&quot;solr.llm.documentenrichment.update.processor.DocumentEnrichmentUpdateProcessorFactory&quot;&gt;
+ *   &lt;str name=&quot;inputField&quot;&gt;title_field&lt;/str&gt;
+ *   &lt;str name=&quot;inputField&quot;&gt;body_field&lt;/str&gt;
+ *   &lt;str name=&quot;outputField&quot;&gt;enriched_field&lt;/str&gt;
+ *   &lt;str name=&quot;prompt&quot;&gt;Title: {title_field}. Body: {body_field}.&lt;/str&gt; // or &lt;str name=&quot;promptFile&quot;&gt;prompt.txt&lt;/str&gt;
+ *   &lt;str name=&quot;model&quot;&gt;model-name&lt;/str&gt;
+ * &lt;/processor&gt;
+ * </pre>
+ *
+ * <p>Multiple {@code inputField} values can also be declared as an array using {@code arr}:
+ *
+ * <pre class="prettyprint" >
+ * &lt;arr name=&quot;inputField&quot;&gt;
+ *   &lt;str&gt;title_field&lt;/str&gt;
+ *   &lt;str&gt;body_field&lt;/str&gt;
+ * &lt;/arr&gt;
+ * </pre>
+ */
+public class DocumentEnrichmentUpdateProcessorFactory extends UpdateRequestProcessorFactory
+    implements SolrCoreAware, ManagedResourceObserver {
+  private static final String INPUT_FIELD_PARAM = "inputField";
+  private static final String OUTPUT_FIELD_PARAM = "outputField";
+  private static final String PROMPT = "prompt";
+  private static final String PROMPT_FILE = "promptFile";
+  private static final String MODEL_NAME = "model";
+  private static final String JSON_SCHEMA_NAME = "output";
+  public static final String JSON_FIELD_PROPERTY = "value";
+  private static final Pattern PLACEHOLDER_PATTERN = Pattern.compile("\\{([^}]+)\\}");
+
+  private List<String> inputFields;
+  private String outputField;
+  private String promptText;
+  private String promptFile;
+  private String modelName;
+
+  @Override
+  public void init(final NamedList<?> args) {
+    // removeConfigArgs handles both multiple <str name="inputField"> and <arr name="inputField">
+    // and must be called before toSolrParams() since it mutates args in place
+    Collection<String> fieldNames = args.removeConfigArgs(INPUT_FIELD_PARAM);
+    if (fieldNames.isEmpty()) {
+      throw new SolrException(
+          SolrException.ErrorCode.SERVER_ERROR, "At least one 'inputField' must be provided");
+    }
+    inputFields = List.copyOf(fieldNames);
+
+    Collection<String> outputFields = args.removeConfigArgs(OUTPUT_FIELD_PARAM);
+    if (outputFields.isEmpty()) {
+      throw new SolrException(
+          SolrException.ErrorCode.SERVER_ERROR, "Exactly one 'outputField' must be provided");
+    }
+    if (outputFields.size() > 1) {
+      throw new SolrException(
+          SolrException.ErrorCode.SERVER_ERROR,
+          "Only one 'outputField' can be provided, but found: " + outputFields);
+    }
+    outputField = outputFields.iterator().next();
+
+    SolrParams params = args.toSolrParams();
+    RequiredSolrParams required = params.required();
+    modelName = required.get(MODEL_NAME);
+
+    String inlinePrompt = params.get(PROMPT);
+    String promptFilePath = params.get(PROMPT_FILE);
+
+    if (inlinePrompt == null && promptFilePath == null) {
+      throw new SolrException(
+          SolrException.ErrorCode.SERVER_ERROR, "Either 'prompt' or 'promptFile' must be provided");
+    }
+    if (inlinePrompt != null && promptFilePath != null) {
+      throw new SolrException(
+          SolrException.ErrorCode.SERVER_ERROR,
+          "Only one of 'prompt' or 'promptFile' can be provided, not both");
+    }
+    if (inlinePrompt != null) {
+      validatePromptPlaceholders(inlinePrompt, inputFields);
+      this.promptText = inlinePrompt;
+    }
+    this.promptFile = promptFilePath;
+  }
+
+  @Override
+  public void inform(SolrCore core) {
+    final SolrResourceLoader solrResourceLoader = core.getResourceLoader();
+    ManagedLargeLanguageModelStore.registerManagedLargeLanguageModelStore(solrResourceLoader, this);
+    if (promptFile != null) {
+      try (InputStream is = solrResourceLoader.openResource(promptFile)) {
+        promptText = new String(is.readAllBytes(), StandardCharsets.UTF_8).trim();
+      } catch (IOException e) {
+        throw new SolrException(
+            SolrException.ErrorCode.SERVER_ERROR, "Cannot read prompt file: " + promptFile, e);
+      }
+      validatePromptPlaceholders(promptText, inputFields);
+    }
+  }
+
+  @Override
+  public void onManagedResourceInitialized(NamedList<?> args, ManagedResource res)
+      throws SolrException {
+    if (res instanceof ManagedLargeLanguageModelStore store) {
+      store.loadStoredModels();
+    }
+  }
+
+  @Override
+  public UpdateRequestProcessor getInstance(
+      SolrQueryRequest req, SolrQueryResponse rsp, UpdateRequestProcessor next) {
+    IndexSchema latestSchema = req.getCore().getLatestSchema();
+
+    for (String fieldName : inputFields) {
+      if (!latestSchema.isDynamicField(fieldName) && !latestSchema.hasExplicitField(fieldName)) {
+        throw new SolrException(
+            SolrException.ErrorCode.SERVER_ERROR, "undefined field: \"" + fieldName + "\"");
+      }
+    }
+
+    final SchemaField outputFieldSchema = latestSchema.getField(outputField);
+
+    ResponseFormat responseFormat = getJsonSchema(outputFieldSchema);
+    boolean multiValued = outputFieldSchema.multiValued();
+
+    ManagedLargeLanguageModelStore store =
+        ManagedLargeLanguageModelStore.getManagedModelStore(req.getCore());
+    SolrLargeLanguageModel fieldGenerationModel = store.getModel(modelName);
+    if (fieldGenerationModel == null) {
+      throw new SolrException(
+          SolrException.ErrorCode.SERVER_ERROR,
+          "The model configured in the Update Request Processor '"
+              + modelName
+              + "' can't be found in the store: "
+              + ManagedLargeLanguageModelStore.REST_END_POINT);
+    }
+
+    return new DocumentEnrichmentUpdateProcessor(
+        inputFields,
+        outputField,
+        promptText,
+        fieldGenerationModel,
+        multiValued,
+        responseFormat,
+        req,
+        next);
+  }
+
+  /**
+   * Builds a {@link ResponseFormat} that instructs the model to return a JSON object {@code
+   * {"value": ...}} whose value type matches the Solr field type. For multivalued fields the value
+   * is wrapped in a {@link JsonArraySchema} nested inside the root {@link JsonObjectSchema}.
+   *
+   * <p>Nesting {@link JsonArraySchema} inside a {@link JsonObjectSchema} property is supported by
+   * all langchain4j providers that implement structured outputs with {@link JsonObjectSchema}
+   * (OpenAI, Azure OpenAI, Google AI, Gemini, Mistral, Ollama, Amazon Bedrock, Watsonx).
+   */
+  static ResponseFormat getJsonSchema(SchemaField schemaField) {
+    JsonSchemaElement valueElement = toJsonSchemaElement(schemaField.getType());
+    JsonSchemaElement valueSchema =
+        schemaField.multiValued()
+            ? JsonArraySchema.builder().items(valueElement).build()
+            : valueElement;
+    // estrai costanti output e value

Review Comment:
   Remove



##########
solr/modules/language-models/src/java/org/apache/solr/languagemodels/store/rest/ManagedLargeLanguageModelStore.java:
##########
@@ -0,0 +1,64 @@
+/*
+ * 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.solr.languagemodels.store.rest;
+
+import java.util.Map;
+import net.jcip.annotations.ThreadSafe;
+import org.apache.solr.common.SolrException;
+import org.apache.solr.core.SolrCore;
+import org.apache.solr.core.SolrResourceLoader;
+import org.apache.solr.languagemodels.model.SolrLargeLanguageModel;
+import org.apache.solr.rest.ManagedResourceObserver;
+import org.apache.solr.rest.ManagedResourceStorage;
+
+/** Managed Resource wrapper for the large language model store, exposed via REST */
+@ThreadSafe
+public class ManagedLargeLanguageModelStore

Review Comment:
   I think we need a better name for this... it seems identical to ManagedLanguageModelStore<ModelT>



-- 
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: issues-unsubscribe@solr.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


---------------------------------------------------------------------
To unsubscribe, e-mail: issues-unsubscribe@solr.apache.org
For additional commands, e-mail: issues-help@solr.apache.org


Re: [PR] SOLR-18187: Document enrichment with LLMs [solr]

Posted by "aruggero (via GitHub)" <gi...@apache.org>.
aruggero commented on code in PR #4259:
URL: https://github.com/apache/solr/pull/4259#discussion_r3240237894


##########
solr/modules/language-models/src/test/org/apache/solr/languagemodels/store/rest/TestLargeLanguageModelStoreInitialization.java:
##########
@@ -0,0 +1,52 @@
+/*
+ * 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.solr.languagemodels.store.rest;
+
+import org.apache.solr.languagemodels.TestLanguageModelBase;
+import org.junit.After;
+import org.junit.Test;
+
+public class TestLargeLanguageModelStoreInitialization extends TestLanguageModelBase {
+
+  @After
+  public void cleanUp() throws Exception {
+    afterTest();
+  }
+
+  @Test
+  public void
+      managedLargeLanguageModelStore_whenUpdateRequestComponentConfigured_shouldBeInitialized()

Review Comment:
   @nicolo-rinaldi the name should be largeLanguageModelStore now?
   also for the tests below



-- 
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: issues-unsubscribe@solr.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


---------------------------------------------------------------------
To unsubscribe, e-mail: issues-unsubscribe@solr.apache.org
For additional commands, e-mail: issues-help@solr.apache.org


Re: [PR] SOLR-18187: Document enrichment with LLMs [solr]

Posted by "aruggero (via GitHub)" <gi...@apache.org>.
aruggero commented on code in PR #4259:
URL: https://github.com/apache/solr/pull/4259#discussion_r3241537446


##########
solr/modules/language-models/src/test/org/apache/solr/languagemodels/store/rest/TestLargeLanguageModelManagerPersistence.java:
##########
@@ -0,0 +1,101 @@
+/*
+ * 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.solr.languagemodels.store.rest;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import org.apache.solr.common.util.Utils;
+import org.apache.solr.languagemodels.TestLanguageModelBase;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+
+public class TestLargeLanguageModelManagerPersistence extends TestLanguageModelBase {
+
+  @Before
+  public void init() throws Exception {
+    setupTest("solrconfig-document-enrichment.xml", "schema-language-models.xml", false, true);
+  }
+
+  @After
+  public void cleanup() throws Exception {
+    afterTest();
+  }
+
+  @Test
+  public void testModelAreStoredCompact() throws Exception {

Review Comment:
   @nicolo-rinaldi testLargeLanguageModelAreStoredCompact to be aligned with texttovector



-- 
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: issues-unsubscribe@solr.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


---------------------------------------------------------------------
To unsubscribe, e-mail: issues-unsubscribe@solr.apache.org
For additional commands, e-mail: issues-help@solr.apache.org


Re: [PR] SOLR-18187: Document enrichment with LLMs [solr]

Posted by "nicolo-rinaldi (via GitHub)" <gi...@apache.org>.
nicolo-rinaldi commented on code in PR #4259:
URL: https://github.com/apache/solr/pull/4259#discussion_r3078402269


##########
solr/modules/language-models/src/java/org/apache/solr/languagemodels/documentenrichment/store/rest/ManagedChatModelStore.java:
##########
@@ -0,0 +1,200 @@
+/*
+ * 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.solr.languagemodels.documentenrichment.store.rest;
+
+import java.lang.invoke.MethodHandles;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+import net.jcip.annotations.ThreadSafe;
+import org.apache.solr.common.SolrException;
+import org.apache.solr.common.util.NamedList;
+import org.apache.solr.core.SolrCore;
+import org.apache.solr.core.SolrResourceLoader;
+import org.apache.solr.languagemodels.documentenrichment.model.SolrChatModel;
+import org.apache.solr.languagemodels.documentenrichment.store.ChatModelException;
+import org.apache.solr.languagemodels.documentenrichment.store.ChatModelStore;
+import org.apache.solr.response.SolrQueryResponse;
+import org.apache.solr.rest.BaseSolrResource;
+import org.apache.solr.rest.ManagedResource;
+import org.apache.solr.rest.ManagedResourceObserver;
+import org.apache.solr.rest.ManagedResourceStorage;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/** Managed Resource wrapper for the {@link ChatModelStore} to expose it via REST */
+@ThreadSafe
+public class ManagedChatModelStore extends ManagedResource
+    implements ManagedResource.ChildResourceSupport {
+  private static final Logger log = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());
+
+  /** the model store rest endpoint */
+  public static final String REST_END_POINT = "/schema/chat-model-store";
+
+  /** Managed model store: the name of the attribute containing all the models of a model store */
+  private static final String MODELS_JSON_FIELD = "models";
+
+  /** name of the attribute containing a class */
+  static final String CLASS_KEY = "class";
+
+  /** name of the attribute containing a name */
+  static final String NAME_KEY = "name";
+
+  /** name of the attribute containing parameters */
+  static final String PARAMS_KEY = "params";
+
+  public static void registerManagedChatModelStore(
+      SolrResourceLoader solrResourceLoader, ManagedResourceObserver managedResourceObserver) {
+    solrResourceLoader
+        .getManagedResourceRegistry()
+        .registerManagedResource(
+            REST_END_POINT, ManagedChatModelStore.class, managedResourceObserver);
+  }
+
+  public static ManagedChatModelStore getManagedModelStore(SolrCore core) {
+    return (ManagedChatModelStore) core.getRestManager().getManagedResource(REST_END_POINT);
+  }
+
+  /**
+   * Returns the available models as a list of Maps objects. After an update the managed resources
+   * needs to return the resources in this format in order to store in json somewhere (zookeeper,
+   * disk...)
+   *
+   * @return the available models as a list of Maps objects
+   */
+  private static List<Object> modelsAsManagedResources(List<SolrChatModel> models) {
+    return models.stream()
+        .map(ManagedChatModelStore::toModelMap)
+        .collect(Collectors.toList());
+  }
+
+  @SuppressWarnings("unchecked")
+  public static SolrChatModel fromModelMap(
+      SolrResourceLoader solrResourceLoader, Map<String, Object> chatModel) {
+    return SolrChatModel.getInstance(
+        solrResourceLoader,
+        (String) chatModel.get(CLASS_KEY), // modelClassName
+        (String) chatModel.get(NAME_KEY), // modelName
+        (Map<String, Object>) chatModel.get(PARAMS_KEY));
+  }
+
+  private static LinkedHashMap<String, Object> toModelMap(SolrChatModel model) {
+    final LinkedHashMap<String, Object> modelMap = new LinkedHashMap<>(5, 1.0f);
+    modelMap.put(NAME_KEY, model.getName());
+    modelMap.put(CLASS_KEY, model.getChatModelClassName());
+    modelMap.put(PARAMS_KEY, model.getParams());
+    return modelMap;
+  }
+
+  private final ChatModelStore store;
+  private Object managedData;
+
+  public ManagedChatModelStore(
+      String resourceId, SolrResourceLoader loader, ManagedResourceStorage.StorageIO storageIO)
+      throws SolrException {
+    super(resourceId, loader, storageIO);
+    store = new ChatModelStore();
+  }
+
+  @Override
+  protected ManagedResourceStorage createStorage(
+      ManagedResourceStorage.StorageIO storageIO, SolrResourceLoader loader) throws SolrException {
+    return new ManagedResourceStorage.JsonStorage(storageIO, loader, -1);

Review Comment:
   Changed



-- 
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: issues-unsubscribe@solr.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


---------------------------------------------------------------------
To unsubscribe, e-mail: issues-unsubscribe@solr.apache.org
For additional commands, e-mail: issues-help@solr.apache.org


Re: [PR] SOLR-18187: Document enrichment with LLMs [solr]

Posted by "alessandrobenedetti (via GitHub)" <gi...@apache.org>.
alessandrobenedetti commented on code in PR #4259:
URL: https://github.com/apache/solr/pull/4259#discussion_r3644575193


##########
solr/modules/language-models/src/java/org/apache/solr/languagemodels/store/rest/LargeLanguageModelStore.java:
##########
@@ -0,0 +1,62 @@
+/*
+ * 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.solr.languagemodels.store.rest;
+
+import java.util.Map;
+import net.jcip.annotations.ThreadSafe;
+import org.apache.solr.common.SolrException;
+import org.apache.solr.core.SolrCore;
+import org.apache.solr.core.SolrResourceLoader;
+import org.apache.solr.languagemodels.model.SolrLargeLanguageModel;
+import org.apache.solr.rest.ManagedResourceObserver;
+import org.apache.solr.rest.ManagedResourceStorage;
+
+/** Managed Resource wrapper for the large language model store, exposed via REST */

Review Comment:
   how can a 'LargeLanguageModelStore' be a wrapper for the 'large language model store'? this comment seems suspicious



##########
solr/modules/language-models/src/java/org/apache/solr/languagemodels/update/processor/factory/DocumentEnrichmentUpdateProcessorFactory.java:
##########
@@ -0,0 +1,333 @@
+/*
+ * 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.solr.languagemodels.update.processor.factory;
+
+import dev.langchain4j.model.chat.request.ResponseFormat;
+import dev.langchain4j.model.chat.request.ResponseFormatType;
+import dev.langchain4j.model.chat.request.json.JsonArraySchema;
+import dev.langchain4j.model.chat.request.json.JsonBooleanSchema;
+import dev.langchain4j.model.chat.request.json.JsonIntegerSchema;
+import dev.langchain4j.model.chat.request.json.JsonNumberSchema;
+import dev.langchain4j.model.chat.request.json.JsonObjectSchema;
+import dev.langchain4j.model.chat.request.json.JsonSchema;
+import dev.langchain4j.model.chat.request.json.JsonSchemaElement;
+import dev.langchain4j.model.chat.request.json.JsonStringSchema;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import java.util.Collection;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import org.apache.solr.common.SolrException;
+import org.apache.solr.common.params.RequiredSolrParams;
+import org.apache.solr.common.params.SolrParams;
+import org.apache.solr.common.util.NamedList;
+import org.apache.solr.core.SolrCore;
+import org.apache.solr.core.SolrResourceLoader;
+import org.apache.solr.languagemodels.model.SolrLargeLanguageModel;
+import org.apache.solr.languagemodels.store.rest.LargeLanguageModelStore;
+import org.apache.solr.languagemodels.update.processor.DocumentEnrichmentUpdateProcessor;
+import org.apache.solr.request.SolrQueryRequest;
+import org.apache.solr.response.SolrQueryResponse;
+import org.apache.solr.rest.ManagedResource;
+import org.apache.solr.rest.ManagedResourceObserver;
+import org.apache.solr.schema.BoolField;
+import org.apache.solr.schema.DatePointField;
+import org.apache.solr.schema.DenseVectorField;
+import org.apache.solr.schema.DoublePointField;
+import org.apache.solr.schema.FieldType;
+import org.apache.solr.schema.FloatPointField;
+import org.apache.solr.schema.IndexSchema;
+import org.apache.solr.schema.IntPointField;
+import org.apache.solr.schema.LongPointField;
+import org.apache.solr.schema.NestPathField;
+import org.apache.solr.schema.SchemaField;
+import org.apache.solr.schema.StrField;
+import org.apache.solr.schema.TextField;
+import org.apache.solr.schema.UUIDField;
+import org.apache.solr.update.processor.UpdateRequestProcessor;
+import org.apache.solr.update.processor.UpdateRequestProcessorFactory;
+import org.apache.solr.util.plugin.SolrCoreAware;
+
+/**
+ * Generate the content of {@code outputField} based on other fields specified as {@code
+ * inputField}s.
+ *
+ * <p>The following validation rules are applied:
+ *
+ * <ul>
+ *   <li>At least one {@code inputField} must be declared.
+ *   <li>Exactly one of {@code prompt} or {@code promptFile} must be provided.
+ *   <li>Every declared {@code inputField} must have a corresponding {@code {fieldName}} placeholder
+ *       in the prompt.
+ *   <li>Every {@code {placeholder}} in the prompt must correspond to a declared {@code inputField}.
+ *   <li>One and only one {@code outputField} is allowed.
+ * </ul>
+ *
+ * <pre class="prettyprint" >
+ * &lt;processor class=&quot;solr.languagemodels.update.processor.factory.DocumentEnrichmentUpdateProcessorFactory&quot;&gt;
+ *   &lt;str name=&quot;inputField&quot;&gt;title_field&lt;/str&gt;
+ *   &lt;str name=&quot;inputField&quot;&gt;body_field&lt;/str&gt;
+ *   &lt;str name=&quot;outputField&quot;&gt;enriched_field&lt;/str&gt;
+ *   &lt;str name=&quot;prompt&quot;&gt;Title: {title_field}. Body: {body_field}.&lt;/str&gt; // or &lt;str name=&quot;promptFile&quot;&gt;prompt.txt&lt;/str&gt;
+ *   &lt;str name=&quot;model&quot;&gt;model-name&lt;/str&gt;
+ * &lt;/processor&gt;
+ * </pre>
+ *
+ * <p>Multiple {@code inputField} values can also be declared as an array using {@code arr}:
+ *
+ * <pre class="prettyprint" >
+ * &lt;arr name=&quot;inputField&quot;&gt;
+ *   &lt;str&gt;title_field&lt;/str&gt;
+ *   &lt;str&gt;body_field&lt;/str&gt;
+ * &lt;/arr&gt;
+ * </pre>
+ */
+public class DocumentEnrichmentUpdateProcessorFactory extends UpdateRequestProcessorFactory
+    implements SolrCoreAware, ManagedResourceObserver {
+  private static final String INPUT_FIELD_PARAM = "inputField";
+  private static final String OUTPUT_FIELD_PARAM = "outputField";
+  private static final String PROMPT = "prompt";
+  private static final String PROMPT_FILE = "promptFile";
+  private static final String MODEL_NAME = "model";
+  private static final String JSON_SCHEMA_NAME = "output";
+  public static final String JSON_FIELD_PROPERTY = "value";
+  private static final Pattern PLACEHOLDER_PATTERN = Pattern.compile("\\{([^}]+)\\}");
+
+  private List<String> inputFields;
+  private String outputField;
+  private String promptText;
+  private String promptFile;
+  private String modelName;
+
+  @Override
+  public void init(final NamedList<?> args) {
+    // removeConfigArgs handles both multiple <str name="inputField"> and <arr name="inputField">
+    // and must be called before toSolrParams() since it mutates args in place

Review Comment:
   I'm generally against this type of comments, the way removeConfigArgs works should be documented in that method, not here, it just adds confusion, I would remove it



##########
solr/modules/language-models/src/java/org/apache/solr/languagemodels/model/SolrLargeLanguageModel.java:
##########
@@ -0,0 +1,208 @@
+/*
+ * 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.solr.languagemodels.model;
+
+import dev.langchain4j.data.message.UserMessage;
+import dev.langchain4j.model.chat.ChatModel;
+import dev.langchain4j.model.chat.request.ChatRequest;
+import dev.langchain4j.model.chat.request.ResponseFormat;
+import java.lang.invoke.MethodHandles;
+import java.lang.reflect.Method;
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.Map;
+import java.util.Objects;
+import org.apache.lucene.util.Accountable;
+import org.apache.lucene.util.RamUsageEstimator;
+import org.apache.solr.common.SolrException;
+import org.apache.solr.core.SolrResourceLoader;
+import org.apache.solr.languagemodels.LanguageModelException;
+import org.apache.solr.languagemodels.store.rest.LargeLanguageModelStore;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * This object wraps a {@link dev.langchain4j.model.chat.ChatModel} to some content given a prompt

Review Comment:
   What does it mean that 'wraps a chatModel to some content given a prompt'?



##########
solr/modules/language-models/src/java/org/apache/solr/languagemodels/update/processor/factory/DocumentEnrichmentUpdateProcessorFactory.java:
##########
@@ -0,0 +1,333 @@
+/*
+ * 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.solr.languagemodels.update.processor.factory;
+
+import dev.langchain4j.model.chat.request.ResponseFormat;
+import dev.langchain4j.model.chat.request.ResponseFormatType;
+import dev.langchain4j.model.chat.request.json.JsonArraySchema;
+import dev.langchain4j.model.chat.request.json.JsonBooleanSchema;
+import dev.langchain4j.model.chat.request.json.JsonIntegerSchema;
+import dev.langchain4j.model.chat.request.json.JsonNumberSchema;
+import dev.langchain4j.model.chat.request.json.JsonObjectSchema;
+import dev.langchain4j.model.chat.request.json.JsonSchema;
+import dev.langchain4j.model.chat.request.json.JsonSchemaElement;
+import dev.langchain4j.model.chat.request.json.JsonStringSchema;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import java.util.Collection;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import org.apache.solr.common.SolrException;
+import org.apache.solr.common.params.RequiredSolrParams;
+import org.apache.solr.common.params.SolrParams;
+import org.apache.solr.common.util.NamedList;
+import org.apache.solr.core.SolrCore;
+import org.apache.solr.core.SolrResourceLoader;
+import org.apache.solr.languagemodels.model.SolrLargeLanguageModel;
+import org.apache.solr.languagemodels.store.rest.LargeLanguageModelStore;
+import org.apache.solr.languagemodels.update.processor.DocumentEnrichmentUpdateProcessor;
+import org.apache.solr.request.SolrQueryRequest;
+import org.apache.solr.response.SolrQueryResponse;
+import org.apache.solr.rest.ManagedResource;
+import org.apache.solr.rest.ManagedResourceObserver;
+import org.apache.solr.schema.BoolField;
+import org.apache.solr.schema.DatePointField;
+import org.apache.solr.schema.DenseVectorField;
+import org.apache.solr.schema.DoublePointField;
+import org.apache.solr.schema.FieldType;
+import org.apache.solr.schema.FloatPointField;
+import org.apache.solr.schema.IndexSchema;
+import org.apache.solr.schema.IntPointField;
+import org.apache.solr.schema.LongPointField;
+import org.apache.solr.schema.NestPathField;
+import org.apache.solr.schema.SchemaField;
+import org.apache.solr.schema.StrField;
+import org.apache.solr.schema.TextField;
+import org.apache.solr.schema.UUIDField;
+import org.apache.solr.update.processor.UpdateRequestProcessor;
+import org.apache.solr.update.processor.UpdateRequestProcessorFactory;
+import org.apache.solr.util.plugin.SolrCoreAware;
+
+/**
+ * Generate the content of {@code outputField} based on other fields specified as {@code
+ * inputField}s.
+ *
+ * <p>The following validation rules are applied:
+ *
+ * <ul>
+ *   <li>At least one {@code inputField} must be declared.
+ *   <li>Exactly one of {@code prompt} or {@code promptFile} must be provided.
+ *   <li>Every declared {@code inputField} must have a corresponding {@code {fieldName}} placeholder
+ *       in the prompt.
+ *   <li>Every {@code {placeholder}} in the prompt must correspond to a declared {@code inputField}.
+ *   <li>One and only one {@code outputField} is allowed.
+ * </ul>
+ *
+ * <pre class="prettyprint" >
+ * &lt;processor class=&quot;solr.languagemodels.update.processor.factory.DocumentEnrichmentUpdateProcessorFactory&quot;&gt;
+ *   &lt;str name=&quot;inputField&quot;&gt;title_field&lt;/str&gt;
+ *   &lt;str name=&quot;inputField&quot;&gt;body_field&lt;/str&gt;
+ *   &lt;str name=&quot;outputField&quot;&gt;enriched_field&lt;/str&gt;
+ *   &lt;str name=&quot;prompt&quot;&gt;Title: {title_field}. Body: {body_field}.&lt;/str&gt; // or &lt;str name=&quot;promptFile&quot;&gt;prompt.txt&lt;/str&gt;
+ *   &lt;str name=&quot;model&quot;&gt;model-name&lt;/str&gt;
+ * &lt;/processor&gt;
+ * </pre>
+ *
+ * <p>Multiple {@code inputField} values can also be declared as an array using {@code arr}:
+ *
+ * <pre class="prettyprint" >
+ * &lt;arr name=&quot;inputField&quot;&gt;
+ *   &lt;str&gt;title_field&lt;/str&gt;
+ *   &lt;str&gt;body_field&lt;/str&gt;
+ * &lt;/arr&gt;
+ * </pre>
+ */
+public class DocumentEnrichmentUpdateProcessorFactory extends UpdateRequestProcessorFactory
+    implements SolrCoreAware, ManagedResourceObserver {
+  private static final String INPUT_FIELD_PARAM = "inputField";
+  private static final String OUTPUT_FIELD_PARAM = "outputField";
+  private static final String PROMPT = "prompt";
+  private static final String PROMPT_FILE = "promptFile";
+  private static final String MODEL_NAME = "model";
+  private static final String JSON_SCHEMA_NAME = "output";
+  public static final String JSON_FIELD_PROPERTY = "value";
+  private static final Pattern PLACEHOLDER_PATTERN = Pattern.compile("\\{([^}]+)\\}");
+
+  private List<String> inputFields;
+  private String outputField;
+  private String promptText;
+  private String promptFile;
+  private String modelName;
+
+  @Override
+  public void init(final NamedList<?> args) {
+    // removeConfigArgs handles both multiple <str name="inputField"> and <arr name="inputField">
+    // and must be called before toSolrParams() since it mutates args in place
+    Collection<String> fieldNames = args.removeConfigArgs(INPUT_FIELD_PARAM);
+    if (fieldNames.isEmpty()) {
+      throw new SolrException(
+          SolrException.ErrorCode.SERVER_ERROR, "At least one 'inputField' must be provided");
+    }
+    inputFields = List.copyOf(fieldNames);
+
+    Collection<String> outputFields = args.removeConfigArgs(OUTPUT_FIELD_PARAM);
+    if (outputFields.isEmpty()) {
+      throw new SolrException(
+          SolrException.ErrorCode.SERVER_ERROR, "Exactly one 'outputField' must be provided");
+    }
+    if (outputFields.size() > 1) {
+      throw new SolrException(
+          SolrException.ErrorCode.SERVER_ERROR,
+          "Only one 'outputField' can be provided, but found: " + outputFields);
+    }
+    outputField = outputFields.iterator().next();
+
+    SolrParams params = args.toSolrParams();
+    RequiredSolrParams required = params.required();
+    modelName = required.get(MODEL_NAME);
+
+    String inlinePrompt = params.get(PROMPT);
+    String promptFilePath = params.get(PROMPT_FILE);
+
+    if (inlinePrompt == null && promptFilePath == null) {
+      throw new SolrException(
+          SolrException.ErrorCode.SERVER_ERROR, "Either 'prompt' or 'promptFile' must be provided");
+    }
+    if (inlinePrompt != null && promptFilePath != null) {
+      throw new SolrException(
+          SolrException.ErrorCode.SERVER_ERROR,
+          "Only one of 'prompt' or 'promptFile' can be provided, not both");
+    }
+    if (inlinePrompt != null) {
+      validatePromptPlaceholders(inlinePrompt, inputFields);
+      this.promptText = inlinePrompt;
+    }
+    this.promptFile = promptFilePath;
+  }
+
+  @Override
+  public void inform(SolrCore core) {
+    final SolrResourceLoader solrResourceLoader = core.getResourceLoader();
+    LargeLanguageModelStore.registerManagedLargeLanguageModelStore(solrResourceLoader, this);
+    if (promptFile != null) {
+      try (InputStream is = solrResourceLoader.openResource(promptFile)) {
+        promptText = new String(is.readAllBytes(), StandardCharsets.UTF_8).trim();
+      } catch (IOException e) {
+        throw new SolrException(
+            SolrException.ErrorCode.SERVER_ERROR, "Cannot read prompt file: " + promptFile, e);
+      }
+      validatePromptPlaceholders(promptText, inputFields);
+    }
+  }
+
+  @Override
+  public void onManagedResourceInitialized(NamedList<?> args, ManagedResource res)
+      throws SolrException {
+    if (res instanceof LargeLanguageModelStore store) {
+      store.loadStoredModels();
+    }
+  }
+
+  @Override
+  public UpdateRequestProcessor getInstance(
+      SolrQueryRequest req, SolrQueryResponse rsp, UpdateRequestProcessor next) {
+    IndexSchema latestSchema = req.getCore().getLatestSchema();
+
+    for (String fieldName : inputFields) {
+      if (!latestSchema.isDynamicField(fieldName) && !latestSchema.hasExplicitField(fieldName)) {
+        throw new SolrException(
+            SolrException.ErrorCode.SERVER_ERROR, "undefined field: \"" + fieldName + "\"");
+      }
+    }
+
+    final SchemaField outputFieldSchema = latestSchema.getField(outputField);
+
+    ResponseFormat responseFormat = getJsonSchema(outputFieldSchema);
+    boolean multiValued = outputFieldSchema.multiValued();
+
+    LargeLanguageModelStore store = LargeLanguageModelStore.getManagedModelStore(req.getCore());
+    SolrLargeLanguageModel fieldGenerationModel = store.getModel(modelName);
+    if (fieldGenerationModel == null) {
+      throw new SolrException(
+          SolrException.ErrorCode.SERVER_ERROR,
+          "The model configured in the Update Request Processor '"
+              + modelName
+              + "' can't be found in the store: "
+              + LargeLanguageModelStore.REST_END_POINT);
+    }
+
+    return new DocumentEnrichmentUpdateProcessor(
+        inputFields,
+        outputField,
+        promptText,
+        fieldGenerationModel,
+        multiValued,
+        responseFormat,
+        req,
+        next);
+  }
+
+  /**
+   * Builds a {@link ResponseFormat} that instructs the model to return a JSON object {@code
+   * {"value": ...}} whose value type matches the Solr field type. For multivalued fields the value
+   * is wrapped in a {@link JsonArraySchema} nested inside the root {@link JsonObjectSchema}.

Review Comment:
   add a couple of examples



##########
solr/modules/language-models/src/java/org/apache/solr/languagemodels/update/processor/factory/DocumentEnrichmentUpdateProcessorFactory.java:
##########
@@ -0,0 +1,333 @@
+/*
+ * 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.solr.languagemodels.update.processor.factory;
+
+import dev.langchain4j.model.chat.request.ResponseFormat;
+import dev.langchain4j.model.chat.request.ResponseFormatType;
+import dev.langchain4j.model.chat.request.json.JsonArraySchema;
+import dev.langchain4j.model.chat.request.json.JsonBooleanSchema;
+import dev.langchain4j.model.chat.request.json.JsonIntegerSchema;
+import dev.langchain4j.model.chat.request.json.JsonNumberSchema;
+import dev.langchain4j.model.chat.request.json.JsonObjectSchema;
+import dev.langchain4j.model.chat.request.json.JsonSchema;
+import dev.langchain4j.model.chat.request.json.JsonSchemaElement;
+import dev.langchain4j.model.chat.request.json.JsonStringSchema;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import java.util.Collection;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import org.apache.solr.common.SolrException;
+import org.apache.solr.common.params.RequiredSolrParams;
+import org.apache.solr.common.params.SolrParams;
+import org.apache.solr.common.util.NamedList;
+import org.apache.solr.core.SolrCore;
+import org.apache.solr.core.SolrResourceLoader;
+import org.apache.solr.languagemodels.model.SolrLargeLanguageModel;
+import org.apache.solr.languagemodels.store.rest.LargeLanguageModelStore;
+import org.apache.solr.languagemodels.update.processor.DocumentEnrichmentUpdateProcessor;
+import org.apache.solr.request.SolrQueryRequest;
+import org.apache.solr.response.SolrQueryResponse;
+import org.apache.solr.rest.ManagedResource;
+import org.apache.solr.rest.ManagedResourceObserver;
+import org.apache.solr.schema.BoolField;
+import org.apache.solr.schema.DatePointField;
+import org.apache.solr.schema.DenseVectorField;
+import org.apache.solr.schema.DoublePointField;
+import org.apache.solr.schema.FieldType;
+import org.apache.solr.schema.FloatPointField;
+import org.apache.solr.schema.IndexSchema;
+import org.apache.solr.schema.IntPointField;
+import org.apache.solr.schema.LongPointField;
+import org.apache.solr.schema.NestPathField;
+import org.apache.solr.schema.SchemaField;
+import org.apache.solr.schema.StrField;
+import org.apache.solr.schema.TextField;
+import org.apache.solr.schema.UUIDField;
+import org.apache.solr.update.processor.UpdateRequestProcessor;
+import org.apache.solr.update.processor.UpdateRequestProcessorFactory;
+import org.apache.solr.util.plugin.SolrCoreAware;
+
+/**
+ * Generate the content of {@code outputField} based on other fields specified as {@code
+ * inputField}s.
+ *
+ * <p>The following validation rules are applied:
+ *
+ * <ul>
+ *   <li>At least one {@code inputField} must be declared.
+ *   <li>Exactly one of {@code prompt} or {@code promptFile} must be provided.
+ *   <li>Every declared {@code inputField} must have a corresponding {@code {fieldName}} placeholder
+ *       in the prompt.
+ *   <li>Every {@code {placeholder}} in the prompt must correspond to a declared {@code inputField}.
+ *   <li>One and only one {@code outputField} is allowed.
+ * </ul>
+ *
+ * <pre class="prettyprint" >
+ * &lt;processor class=&quot;solr.languagemodels.update.processor.factory.DocumentEnrichmentUpdateProcessorFactory&quot;&gt;
+ *   &lt;str name=&quot;inputField&quot;&gt;title_field&lt;/str&gt;
+ *   &lt;str name=&quot;inputField&quot;&gt;body_field&lt;/str&gt;
+ *   &lt;str name=&quot;outputField&quot;&gt;enriched_field&lt;/str&gt;
+ *   &lt;str name=&quot;prompt&quot;&gt;Title: {title_field}. Body: {body_field}.&lt;/str&gt; // or &lt;str name=&quot;promptFile&quot;&gt;prompt.txt&lt;/str&gt;
+ *   &lt;str name=&quot;model&quot;&gt;model-name&lt;/str&gt;
+ * &lt;/processor&gt;
+ * </pre>
+ *
+ * <p>Multiple {@code inputField} values can also be declared as an array using {@code arr}:
+ *
+ * <pre class="prettyprint" >
+ * &lt;arr name=&quot;inputField&quot;&gt;
+ *   &lt;str&gt;title_field&lt;/str&gt;
+ *   &lt;str&gt;body_field&lt;/str&gt;
+ * &lt;/arr&gt;
+ * </pre>
+ */
+public class DocumentEnrichmentUpdateProcessorFactory extends UpdateRequestProcessorFactory
+    implements SolrCoreAware, ManagedResourceObserver {
+  private static final String INPUT_FIELD_PARAM = "inputField";
+  private static final String OUTPUT_FIELD_PARAM = "outputField";
+  private static final String PROMPT = "prompt";
+  private static final String PROMPT_FILE = "promptFile";
+  private static final String MODEL_NAME = "model";
+  private static final String JSON_SCHEMA_NAME = "output";
+  public static final String JSON_FIELD_PROPERTY = "value";
+  private static final Pattern PLACEHOLDER_PATTERN = Pattern.compile("\\{([^}]+)\\}");
+
+  private List<String> inputFields;
+  private String outputField;
+  private String promptText;
+  private String promptFile;
+  private String modelName;
+
+  @Override
+  public void init(final NamedList<?> args) {
+    // removeConfigArgs handles both multiple <str name="inputField"> and <arr name="inputField">
+    // and must be called before toSolrParams() since it mutates args in place
+    Collection<String> fieldNames = args.removeConfigArgs(INPUT_FIELD_PARAM);
+    if (fieldNames.isEmpty()) {
+      throw new SolrException(
+          SolrException.ErrorCode.SERVER_ERROR, "At least one 'inputField' must be provided");
+    }
+    inputFields = List.copyOf(fieldNames);
+
+    Collection<String> outputFields = args.removeConfigArgs(OUTPUT_FIELD_PARAM);
+    if (outputFields.isEmpty()) {
+      throw new SolrException(
+          SolrException.ErrorCode.SERVER_ERROR, "Exactly one 'outputField' must be provided");
+    }
+    if (outputFields.size() > 1) {
+      throw new SolrException(
+          SolrException.ErrorCode.SERVER_ERROR,
+          "Only one 'outputField' can be provided, but found: " + outputFields);
+    }

Review Comment:
   I would unify these two exceptional conditions and just throw one exception



##########
solr/modules/language-models/src/java/org/apache/solr/languagemodels/update/processor/DocumentEnrichmentUpdateProcessor.java:
##########
@@ -0,0 +1,159 @@
+/*
+ * 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.solr.languagemodels.update.processor;
+
+import dev.langchain4j.model.chat.request.ResponseFormat;
+import dev.langchain4j.model.chat.request.json.JsonArraySchema;
+import dev.langchain4j.model.chat.request.json.JsonBooleanSchema;
+import dev.langchain4j.model.chat.request.json.JsonIntegerSchema;
+import dev.langchain4j.model.chat.request.json.JsonNumberSchema;
+import dev.langchain4j.model.chat.request.json.JsonObjectSchema;
+import dev.langchain4j.model.chat.request.json.JsonSchemaElement;
+import dev.langchain4j.model.chat.request.json.JsonStringSchema;
+import java.io.IOException;
+import java.lang.invoke.MethodHandles;
+import java.util.List;
+import java.util.Map;
+import org.apache.solr.common.SolrException;
+import org.apache.solr.common.SolrInputDocument;
+import org.apache.solr.common.SolrInputField;
+import org.apache.solr.common.util.Utils;
+import org.apache.solr.languagemodels.model.SolrLargeLanguageModel;
+import org.apache.solr.languagemodels.update.processor.factory.DocumentEnrichmentUpdateProcessorFactory;
+import org.apache.solr.request.SolrQueryRequest;
+import org.apache.solr.schema.IndexSchema;
+import org.apache.solr.schema.SchemaField;
+import org.apache.solr.update.AddUpdateCommand;
+import org.apache.solr.update.processor.UpdateRequestProcessor;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+public class DocumentEnrichmentUpdateProcessor extends UpdateRequestProcessor {
+  private static final Logger log = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());
+
+  private final IndexSchema schema;
+  private final List<String> inputFields;
+  private final String outputField;
+  private final String prompt;
+  private final SolrLargeLanguageModel largeLanguageModel;
+  private final boolean multiValued;
+  private final ResponseFormat responseFormat;
+
+  public DocumentEnrichmentUpdateProcessor(
+      List<String> inputFields,
+      String outputField,
+      String prompt,
+      SolrLargeLanguageModel largeLanguageModel,
+      boolean multiValued,
+      ResponseFormat responseFormat,
+      SolrQueryRequest req,
+      UpdateRequestProcessor next) {
+    super(next);
+    this.schema = req.getSchema();
+    this.inputFields = inputFields;
+    this.outputField = outputField;
+    this.prompt = prompt;
+    this.largeLanguageModel = largeLanguageModel;
+    this.multiValued = multiValued;
+    this.responseFormat = responseFormat;
+  }
+
+  /**
+   * @param cmd the update command in input containing the Document to process
+   * @throws IOException If there is a low-level I/O error
+   */
+  @Override
+  public void processAdd(AddUpdateCommand cmd) throws IOException {
+    SolrInputDocument doc = cmd.getSolrInputDocument();
+
+    // Collect all field values; skip enrichment if any declared field is null or empty
+    String injectedPrompt = prompt;
+    for (String fieldName : inputFields) {
+      SolrInputField field = doc.get(fieldName);
+      if (isNullOrEmpty(field)) {
+        super.processAdd(cmd);
+        return;
+      }
+      injectedPrompt = injectedPrompt.replace("{" + fieldName + "}", field.getValue().toString());
+    }
+
+    try {
+      // as for now, only a plain text as prompt is sent to the model (no support for
+      // tools/skills/agents)
+      // chatModel.chat returns the parsed value from the structured JSON response
+      Object returnValue =
+          Utils.fromJSONString(largeLanguageModel.generate(injectedPrompt, responseFormat));
+      // Guardrails for OllamaChatModel, since it doesn't support strict JSON structured output mode
+      if (!(returnValue instanceof Map<?, ?> map)
+          || !map.containsKey(DocumentEnrichmentUpdateProcessorFactory.JSON_FIELD_PROPERTY)) {
+        throw new SolrException(
+            SolrException.ErrorCode.SERVER_ERROR,
+            "LLM was not able to format the response correctly: " + returnValue);
+      }
+      Object generatedFieldValue =
+          map.get(DocumentEnrichmentUpdateProcessorFactory.JSON_FIELD_PROPERTY);
+      JsonSchemaElement valueSchema =
+          ((JsonObjectSchema) responseFormat.jsonSchema().rootElement())
+              .properties()
+              .get(DocumentEnrichmentUpdateProcessorFactory.JSON_FIELD_PROPERTY);
+      boolean typeOk =
+          switch (valueSchema) {

Review Comment:
   we'll have to clarify this as I don't get the variable name 'ignored'



##########
solr/modules/language-models/src/java/org/apache/solr/languagemodels/update/processor/factory/DocumentEnrichmentUpdateProcessorFactory.java:
##########
@@ -0,0 +1,333 @@
+/*
+ * 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.solr.languagemodels.update.processor.factory;
+
+import dev.langchain4j.model.chat.request.ResponseFormat;
+import dev.langchain4j.model.chat.request.ResponseFormatType;
+import dev.langchain4j.model.chat.request.json.JsonArraySchema;
+import dev.langchain4j.model.chat.request.json.JsonBooleanSchema;
+import dev.langchain4j.model.chat.request.json.JsonIntegerSchema;
+import dev.langchain4j.model.chat.request.json.JsonNumberSchema;
+import dev.langchain4j.model.chat.request.json.JsonObjectSchema;
+import dev.langchain4j.model.chat.request.json.JsonSchema;
+import dev.langchain4j.model.chat.request.json.JsonSchemaElement;
+import dev.langchain4j.model.chat.request.json.JsonStringSchema;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import java.util.Collection;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import org.apache.solr.common.SolrException;
+import org.apache.solr.common.params.RequiredSolrParams;
+import org.apache.solr.common.params.SolrParams;
+import org.apache.solr.common.util.NamedList;
+import org.apache.solr.core.SolrCore;
+import org.apache.solr.core.SolrResourceLoader;
+import org.apache.solr.languagemodels.model.SolrLargeLanguageModel;
+import org.apache.solr.languagemodels.store.rest.LargeLanguageModelStore;
+import org.apache.solr.languagemodels.update.processor.DocumentEnrichmentUpdateProcessor;
+import org.apache.solr.request.SolrQueryRequest;
+import org.apache.solr.response.SolrQueryResponse;
+import org.apache.solr.rest.ManagedResource;
+import org.apache.solr.rest.ManagedResourceObserver;
+import org.apache.solr.schema.BoolField;
+import org.apache.solr.schema.DatePointField;
+import org.apache.solr.schema.DenseVectorField;
+import org.apache.solr.schema.DoublePointField;
+import org.apache.solr.schema.FieldType;
+import org.apache.solr.schema.FloatPointField;
+import org.apache.solr.schema.IndexSchema;
+import org.apache.solr.schema.IntPointField;
+import org.apache.solr.schema.LongPointField;
+import org.apache.solr.schema.NestPathField;
+import org.apache.solr.schema.SchemaField;
+import org.apache.solr.schema.StrField;
+import org.apache.solr.schema.TextField;
+import org.apache.solr.schema.UUIDField;
+import org.apache.solr.update.processor.UpdateRequestProcessor;
+import org.apache.solr.update.processor.UpdateRequestProcessorFactory;
+import org.apache.solr.util.plugin.SolrCoreAware;
+
+/**
+ * Generate the content of {@code outputField} based on other fields specified as {@code
+ * inputField}s.
+ *
+ * <p>The following validation rules are applied:
+ *
+ * <ul>
+ *   <li>At least one {@code inputField} must be declared.
+ *   <li>Exactly one of {@code prompt} or {@code promptFile} must be provided.
+ *   <li>Every declared {@code inputField} must have a corresponding {@code {fieldName}} placeholder
+ *       in the prompt.
+ *   <li>Every {@code {placeholder}} in the prompt must correspond to a declared {@code inputField}.
+ *   <li>One and only one {@code outputField} is allowed.
+ * </ul>
+ *
+ * <pre class="prettyprint" >
+ * &lt;processor class=&quot;solr.languagemodels.update.processor.factory.DocumentEnrichmentUpdateProcessorFactory&quot;&gt;
+ *   &lt;str name=&quot;inputField&quot;&gt;title_field&lt;/str&gt;
+ *   &lt;str name=&quot;inputField&quot;&gt;body_field&lt;/str&gt;
+ *   &lt;str name=&quot;outputField&quot;&gt;enriched_field&lt;/str&gt;
+ *   &lt;str name=&quot;prompt&quot;&gt;Title: {title_field}. Body: {body_field}.&lt;/str&gt; // or &lt;str name=&quot;promptFile&quot;&gt;prompt.txt&lt;/str&gt;
+ *   &lt;str name=&quot;model&quot;&gt;model-name&lt;/str&gt;
+ * &lt;/processor&gt;
+ * </pre>
+ *
+ * <p>Multiple {@code inputField} values can also be declared as an array using {@code arr}:
+ *
+ * <pre class="prettyprint" >
+ * &lt;arr name=&quot;inputField&quot;&gt;
+ *   &lt;str&gt;title_field&lt;/str&gt;
+ *   &lt;str&gt;body_field&lt;/str&gt;
+ * &lt;/arr&gt;
+ * </pre>
+ */
+public class DocumentEnrichmentUpdateProcessorFactory extends UpdateRequestProcessorFactory
+    implements SolrCoreAware, ManagedResourceObserver {
+  private static final String INPUT_FIELD_PARAM = "inputField";
+  private static final String OUTPUT_FIELD_PARAM = "outputField";
+  private static final String PROMPT = "prompt";
+  private static final String PROMPT_FILE = "promptFile";
+  private static final String MODEL_NAME = "model";
+  private static final String JSON_SCHEMA_NAME = "output";
+  public static final String JSON_FIELD_PROPERTY = "value";
+  private static final Pattern PLACEHOLDER_PATTERN = Pattern.compile("\\{([^}]+)\\}");
+
+  private List<String> inputFields;
+  private String outputField;
+  private String promptText;
+  private String promptFile;
+  private String modelName;
+
+  @Override
+  public void init(final NamedList<?> args) {
+    // removeConfigArgs handles both multiple <str name="inputField"> and <arr name="inputField">
+    // and must be called before toSolrParams() since it mutates args in place
+    Collection<String> fieldNames = args.removeConfigArgs(INPUT_FIELD_PARAM);
+    if (fieldNames.isEmpty()) {
+      throw new SolrException(
+          SolrException.ErrorCode.SERVER_ERROR, "At least one 'inputField' must be provided");
+    }
+    inputFields = List.copyOf(fieldNames);
+
+    Collection<String> outputFields = args.removeConfigArgs(OUTPUT_FIELD_PARAM);
+    if (outputFields.isEmpty()) {
+      throw new SolrException(
+          SolrException.ErrorCode.SERVER_ERROR, "Exactly one 'outputField' must be provided");
+    }
+    if (outputFields.size() > 1) {
+      throw new SolrException(
+          SolrException.ErrorCode.SERVER_ERROR,
+          "Only one 'outputField' can be provided, but found: " + outputFields);
+    }
+    outputField = outputFields.iterator().next();
+
+    SolrParams params = args.toSolrParams();
+    RequiredSolrParams required = params.required();
+    modelName = required.get(MODEL_NAME);
+
+    String inlinePrompt = params.get(PROMPT);
+    String promptFilePath = params.get(PROMPT_FILE);
+
+    if (inlinePrompt == null && promptFilePath == null) {
+      throw new SolrException(
+          SolrException.ErrorCode.SERVER_ERROR, "Either 'prompt' or 'promptFile' must be provided");
+    }
+    if (inlinePrompt != null && promptFilePath != null) {
+      throw new SolrException(
+          SolrException.ErrorCode.SERVER_ERROR,
+          "Only one of 'prompt' or 'promptFile' can be provided, not both");

Review Comment:
   -> must be provided



-- 
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: issues-unsubscribe@solr.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


---------------------------------------------------------------------
To unsubscribe, e-mail: issues-unsubscribe@solr.apache.org
For additional commands, e-mail: issues-help@solr.apache.org


Re: [PR] SOLR-18187: Document enrichment with LLMs [solr]

Posted by "dsmiley (via GitHub) via issues" <is...@solr.apache.org>.
dsmiley commented on code in PR #4259:
URL: https://github.com/apache/solr/pull/4259#discussion_r4430010566


##########
changelog/unreleased/SOLR-18187-llm-document-enrichment.yml:
##########
@@ -0,0 +1,7 @@
+title: Add DocumentEnrichmentUpdateProcessorFactory for LLM-based document enrichment at index time
+type: added # added, changed, fixed, deprecated, removed, dependency_update, security, other
+authors:
+- name: Nicolò Rinaldi, Anna Ruggero, Alessandro Benedetti

Review Comment:
   Please don't do that; names are supposed to be listed separately!  This is data that feeds other things, like listing contributors to be thanked, and this confused that script.



-- 
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: issues-unsubscribe@solr.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


---------------------------------------------------------------------
To unsubscribe, e-mail: issues-unsubscribe@solr.apache.org
For additional commands, e-mail: issues-help@solr.apache.org


Re: [PR] SOLR-18187: Document enrichment with LLMs [solr]

Posted by "alessandrobenedetti (via GitHub)" <gi...@apache.org>.
alessandrobenedetti commented on code in PR #4259:
URL: https://github.com/apache/solr/pull/4259#discussion_r3101203078


##########
solr/modules/language-models/src/java/org/apache/solr/languagemodels/documentenrichment/update/processor/DocumentEnrichmentUpdateProcessor.java:
##########
@@ -0,0 +1,116 @@
+/*
+ * 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.solr.languagemodels.documentenrichment.update.processor;
+
+import dev.langchain4j.model.chat.request.ResponseFormat;
+import java.io.IOException;
+import java.lang.invoke.MethodHandles;
+import java.util.List;
+import org.apache.solr.common.SolrInputDocument;
+import org.apache.solr.common.SolrInputField;
+import org.apache.solr.languagemodels.documentenrichment.model.SolrChatModel;
+import org.apache.solr.request.SolrQueryRequest;
+import org.apache.solr.schema.IndexSchema;
+import org.apache.solr.schema.SchemaField;
+import org.apache.solr.update.AddUpdateCommand;
+import org.apache.solr.update.processor.UpdateRequestProcessor;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+class DocumentEnrichmentUpdateProcessor extends UpdateRequestProcessor {
+  private static final Logger log = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());
+
+  private final IndexSchema schema;
+  private final List<String> inputFields;
+  private final String outputField;
+  private final String prompt;
+  private final SolrChatModel chatModel;
+  private final boolean multiValued;
+  private final ResponseFormat responseFormat;
+
+  public DocumentEnrichmentUpdateProcessor(
+      List<String> inputFields,
+      String outputField,
+      String prompt,
+      SolrChatModel chatModel,
+      boolean multiValued,
+      ResponseFormat responseFormat,
+      SolrQueryRequest req,
+      UpdateRequestProcessor next) {
+    super(next);
+    this.schema = req.getSchema();
+    this.inputFields = inputFields;
+    this.outputField = outputField;
+    this.prompt = prompt;
+    this.chatModel = chatModel;
+    this.multiValued = multiValued;
+    this.responseFormat = responseFormat;
+  }
+
+  /**
+   * @param cmd the update command in input containing the Document to process
+   * @throws IOException If there is a low-level I/O error
+   */
+  @Override
+  public void processAdd(AddUpdateCommand cmd) throws IOException {
+    SolrInputDocument doc = cmd.getSolrInputDocument();
+
+    // Collect all field values; skip enrichment if any declared field is null or empty
+    String injectedPrompt = prompt;
+    for (String fieldName : inputFields) {
+      SolrInputField field = doc.get(fieldName);
+      if (isNullOrEmpty(field)) {
+        super.processAdd(cmd);
+        return;
+      }
+      injectedPrompt = injectedPrompt.replace("{" + fieldName + "}", field.getValue().toString());
+    }
+
+    try {
+      // as for now, only a plain text as prompt is sent to the model (no support for
+      // tools/skills/agents)
+      // chatModel.chat returns the parsed value from the structured JSON response
+      Object value = chatModel.chat(injectedPrompt, responseFormat);

Review Comment:
   value? isn't it the output? Also, langchain4j returns an 'Object'? is that a weak typing?



-- 
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: issues-unsubscribe@solr.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


---------------------------------------------------------------------
To unsubscribe, e-mail: issues-unsubscribe@solr.apache.org
For additional commands, e-mail: issues-help@solr.apache.org


Re: [PR] SOLR-18187: Document enrichment with LLMs [solr]

Posted by "alessandrobenedetti (via GitHub)" <gi...@apache.org>.
alessandrobenedetti commented on code in PR #4259:
URL: https://github.com/apache/solr/pull/4259#discussion_r3101203078


##########
solr/modules/language-models/src/java/org/apache/solr/languagemodels/documentenrichment/update/processor/DocumentEnrichmentUpdateProcessor.java:
##########
@@ -0,0 +1,116 @@
+/*
+ * 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.solr.languagemodels.documentenrichment.update.processor;
+
+import dev.langchain4j.model.chat.request.ResponseFormat;
+import java.io.IOException;
+import java.lang.invoke.MethodHandles;
+import java.util.List;
+import org.apache.solr.common.SolrInputDocument;
+import org.apache.solr.common.SolrInputField;
+import org.apache.solr.languagemodels.documentenrichment.model.SolrChatModel;
+import org.apache.solr.request.SolrQueryRequest;
+import org.apache.solr.schema.IndexSchema;
+import org.apache.solr.schema.SchemaField;
+import org.apache.solr.update.AddUpdateCommand;
+import org.apache.solr.update.processor.UpdateRequestProcessor;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+class DocumentEnrichmentUpdateProcessor extends UpdateRequestProcessor {
+  private static final Logger log = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());
+
+  private final IndexSchema schema;
+  private final List<String> inputFields;
+  private final String outputField;
+  private final String prompt;
+  private final SolrChatModel chatModel;
+  private final boolean multiValued;
+  private final ResponseFormat responseFormat;
+
+  public DocumentEnrichmentUpdateProcessor(
+      List<String> inputFields,
+      String outputField,
+      String prompt,
+      SolrChatModel chatModel,
+      boolean multiValued,
+      ResponseFormat responseFormat,
+      SolrQueryRequest req,
+      UpdateRequestProcessor next) {
+    super(next);
+    this.schema = req.getSchema();
+    this.inputFields = inputFields;
+    this.outputField = outputField;
+    this.prompt = prompt;
+    this.chatModel = chatModel;
+    this.multiValued = multiValued;
+    this.responseFormat = responseFormat;
+  }
+
+  /**
+   * @param cmd the update command in input containing the Document to process
+   * @throws IOException If there is a low-level I/O error
+   */
+  @Override
+  public void processAdd(AddUpdateCommand cmd) throws IOException {
+    SolrInputDocument doc = cmd.getSolrInputDocument();
+
+    // Collect all field values; skip enrichment if any declared field is null or empty
+    String injectedPrompt = prompt;
+    for (String fieldName : inputFields) {
+      SolrInputField field = doc.get(fieldName);
+      if (isNullOrEmpty(field)) {
+        super.processAdd(cmd);
+        return;
+      }
+      injectedPrompt = injectedPrompt.replace("{" + fieldName + "}", field.getValue().toString());
+    }
+
+    try {
+      // as for now, only a plain text as prompt is sent to the model (no support for
+      // tools/skills/agents)
+      // chatModel.chat returns the parsed value from the structured JSON response
+      Object value = chatModel.chat(injectedPrompt, responseFormat);

Review Comment:
   value? isn;t it the output?



-- 
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: issues-unsubscribe@solr.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


---------------------------------------------------------------------
To unsubscribe, e-mail: issues-unsubscribe@solr.apache.org
For additional commands, e-mail: issues-help@solr.apache.org


Re: [PR] SOLR-18187: Document enrichment with LLMs [solr]

Posted by "aruggero (via GitHub)" <gi...@apache.org>.
aruggero commented on code in PR #4259:
URL: https://github.com/apache/solr/pull/4259#discussion_r3241538939


##########
solr/modules/language-models/src/test/org/apache/solr/languagemodels/store/rest/TestLargeLanguageModelManagerPersistence.java:
##########
@@ -0,0 +1,101 @@
+/*
+ * 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.solr.languagemodels.store.rest;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import org.apache.solr.common.util.Utils;
+import org.apache.solr.languagemodels.TestLanguageModelBase;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+
+public class TestLargeLanguageModelManagerPersistence extends TestLanguageModelBase {
+
+  @Before
+  public void init() throws Exception {
+    setupTest("solrconfig-document-enrichment.xml", "schema-language-models.xml", false, true);
+  }
+
+  @After
+  public void cleanup() throws Exception {
+    afterTest();
+  }
+
+  @Test
+  public void testModelAreStoredCompact() throws Exception {
+    loadLargeLanguageModel("openai-model.json");
+
+    final String JSONOnDisk = Files.readString(largeLanguageModelStoreFile, StandardCharsets.UTF_8);
+    Object objectFromDisk = Utils.fromJSONString(JSONOnDisk);
+    assertEquals(new String(Utils.toJSON(objectFromDisk), UTF_8), JSONOnDisk);
+  }
+
+  @Test
+  public void testModelStorePersistence() throws Exception {

Review Comment:
   @nicolo-rinaldi testLargeLanguageModelStorePersistence



-- 
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: issues-unsubscribe@solr.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


---------------------------------------------------------------------
To unsubscribe, e-mail: issues-unsubscribe@solr.apache.org
For additional commands, e-mail: issues-help@solr.apache.org


Re: [PR] SOLR-18187: Document enrichment with LLMs [solr]

Posted by "alessandrobenedetti (via GitHub)" <gi...@apache.org>.
alessandrobenedetti commented on code in PR #4259:
URL: https://github.com/apache/solr/pull/4259#discussion_r3101233423


##########
solr/modules/language-models/src/java/org/apache/solr/languagemodels/documentenrichment/update/processor/DocumentEnrichmentUpdateProcessorFactory.java:
##########
@@ -0,0 +1,338 @@
+/*
+ * 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.solr.languagemodels.documentenrichment.update.processor;
+
+import dev.langchain4j.model.chat.request.ResponseFormat;
+import dev.langchain4j.model.chat.request.ResponseFormatType;
+import dev.langchain4j.model.chat.request.json.JsonArraySchema;
+import dev.langchain4j.model.chat.request.json.JsonBooleanSchema;
+import dev.langchain4j.model.chat.request.json.JsonIntegerSchema;
+import dev.langchain4j.model.chat.request.json.JsonNumberSchema;
+import dev.langchain4j.model.chat.request.json.JsonObjectSchema;
+import dev.langchain4j.model.chat.request.json.JsonSchema;
+import dev.langchain4j.model.chat.request.json.JsonSchemaElement;
+import dev.langchain4j.model.chat.request.json.JsonStringSchema;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import java.util.Collection;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import org.apache.solr.common.SolrException;
+import org.apache.solr.common.params.RequiredSolrParams;
+import org.apache.solr.common.params.SolrParams;
+import org.apache.solr.common.util.NamedList;
+import org.apache.solr.core.SolrCore;
+import org.apache.solr.core.SolrResourceLoader;
+import org.apache.solr.languagemodels.documentenrichment.model.SolrChatModel;
+import org.apache.solr.languagemodels.documentenrichment.store.rest.ManagedChatModelStore;
+import org.apache.solr.request.SolrQueryRequest;
+import org.apache.solr.response.SolrQueryResponse;
+import org.apache.solr.rest.ManagedResource;
+import org.apache.solr.rest.ManagedResourceObserver;
+import org.apache.solr.schema.BoolField;
+import org.apache.solr.schema.DatePointField;
+import org.apache.solr.schema.DenseVectorField;
+import org.apache.solr.schema.DoublePointField;
+import org.apache.solr.schema.FieldType;
+import org.apache.solr.schema.FloatPointField;
+import org.apache.solr.schema.IndexSchema;
+import org.apache.solr.schema.IntPointField;
+import org.apache.solr.schema.LongPointField;
+import org.apache.solr.schema.NestPathField;
+import org.apache.solr.schema.SchemaField;
+import org.apache.solr.schema.StrField;
+import org.apache.solr.schema.TextField;
+import org.apache.solr.schema.UUIDField;
+import org.apache.solr.update.processor.UpdateRequestProcessor;
+import org.apache.solr.update.processor.UpdateRequestProcessorFactory;
+import org.apache.solr.util.plugin.SolrCoreAware;
+
+/**
+ * Generate the content of a field based on other fields specified as input.
+ *
+ * <p>One or more {@code inputField} parameters specify the Solr fields to use as input. Each field
+ * name must appear as a {@code {fieldName}} placeholder in the prompt. Exactly one of {@code
+ * prompt} or {@code promptFile} must be provided.
+ *
+ * <pre class="prettyprint" >
+ * &lt;processor class=&quot;solr.llm.documentenrichment.update.processor.DocumentEnrichmentUpdateProcessorFactory&quot;&gt;
+ *   &lt;str name=&quot;inputField&quot;&gt;title_field&lt;/str&gt;
+ *   &lt;str name=&quot;inputField&quot;&gt;body_field&lt;/str&gt;
+ *   &lt;str name=&quot;outputField&quot;&gt;enriched_field&lt;/str&gt;
+ *   &lt;str name=&quot;prompt&quot;&gt;Title: {title_field}. Body: {body_field}.&lt;/str&gt;
+ *   &lt;str name=&quot;model&quot;&gt;ChatModel&lt;/str&gt;
+ * &lt;/processor&gt;
+ * </pre>
+ *
+ * <p>Multiple {@code inputField} values can also be declared as an array using {@code arr}:
+ *
+ * <pre class="prettyprint" >
+ * &lt;processor class=&quot;solr.llm.documentenrichment.update.processor.DocumentEnrichmentUpdateProcessorFactory&quot;&gt;
+ *   &lt;arr name=&quot;inputField&quot;&gt;
+ *     &lt;str&gt;title_field&lt;/str&gt;
+ *     &lt;str&gt;body_field&lt;/str&gt;
+ *   &lt;/arr&gt;
+ *   &lt;str name=&quot;outputField&quot;&gt;enriched_field&lt;/str&gt;
+ *   &lt;str name=&quot;prompt&quot;&gt;Title: {title_field}. Body: {body_field}.&lt;/str&gt;
+ *   &lt;str name=&quot;model&quot;&gt;ChatModel&lt;/str&gt;
+ * &lt;/processor&gt;
+ * </pre>
+ *
+ * <p>Alternatively, the prompt can be loaded from a text file using {@code promptFile}:
+ *
+ * <pre class="prettyprint" >
+ * &lt;processor class=&quot;solr.llm.documentenrichment.update.processor.DocumentEnrichmentUpdateProcessorFactory&quot;&gt;
+ *   &lt;str name=&quot;inputField&quot;&gt;title_field&lt;/str&gt;
+ *   &lt;str name=&quot;outputField&quot;&gt;enriched_field&lt;/str&gt;
+ *   &lt;str name=&quot;promptFile&quot;&gt;prompt.txt&lt;/str&gt;
+ *   &lt;str name=&quot;model&quot;&gt;ChatModel&lt;/str&gt;
+ * &lt;/processor&gt;
+ * </pre>
+ *
+ * <p>Validation rules:
+ *
+ * <ul>
+ *   <li>At least one {@code inputField} must be declared.
+ *   <li>Exactly one of {@code prompt} or {@code promptFile} must be provided.
+ *   <li>Every declared {@code inputField} must have a corresponding {@code {fieldName}} placeholder
+ *       in the prompt.
+ *   <li>Every {@code {placeholder}} in the prompt must correspond to a declared {@code inputField}.

Review Comment:
   this huge comment is unreadable code-wise and suspicious, probably it should be improved



-- 
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: issues-unsubscribe@solr.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


---------------------------------------------------------------------
To unsubscribe, e-mail: issues-unsubscribe@solr.apache.org
For additional commands, e-mail: issues-help@solr.apache.org


Re: [PR] SOLR-18187: Document enrichment with LLMs [solr]

Posted by "nicolo-rinaldi (via GitHub)" <gi...@apache.org>.
nicolo-rinaldi commented on code in PR #4259:
URL: https://github.com/apache/solr/pull/4259#discussion_r3044039972


##########
solr/modules/language-models/src/java/org/apache/solr/languagemodels/documentenrichment/model/SolrChatModel.java:
##########
@@ -0,0 +1,215 @@
+/*
+ * 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.solr.languagemodels.documentenrichment.model;
+
+import dev.langchain4j.data.message.UserMessage;
+import dev.langchain4j.model.chat.ChatModel;
+import dev.langchain4j.model.chat.request.ChatRequest;
+import dev.langchain4j.model.chat.request.ResponseFormat;
+import java.lang.invoke.MethodHandles;
+import java.lang.reflect.Method;
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.Map;
+import java.util.Objects;
+import org.apache.lucene.util.Accountable;
+import org.apache.lucene.util.RamUsageEstimator;
+import org.apache.solr.common.SolrException;
+import org.apache.solr.common.util.Utils;
+import org.apache.solr.core.SolrResourceLoader;
+import org.apache.solr.languagemodels.documentenrichment.store.ChatModelException;
+import org.apache.solr.languagemodels.documentenrichment.store.rest.ManagedChatModelStore;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * This object wraps a {@link ChatModel} to produce the content of new fields from another.
+ * It's meant to be used as a managed resource with the {@link
+ * ManagedChatModelStore}
+ */
+public class SolrChatModel implements Accountable {
+  private static final Logger log = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());
+  private static final long BASE_RAM_BYTES =
+      RamUsageEstimator.shallowSizeOfInstance(SolrChatModel.class);
+  // timeout is type Duration
+  private static final String TIMEOUT_PARAM = "timeout";
+
+  // the followings are Integer type
+  private static final String MAX_RETRIES_PARAM = "maxRetries";
+  private static final String THINKING_BUDGET_TOKENS = "thinkingBudgetTokens";
+  private static final String RANDOM_SEED = "randomSeed";
+
+  private final String name;
+  private final Map<String, Object> params;
+  private final ChatModel chatModel;
+  private final int hashCode;
+
+  public static SolrChatModel getInstance(
+      SolrResourceLoader solrResourceLoader,
+      String className,
+      String name,
+      Map<String, Object> params)
+      throws ChatModelException {
+    try {
+      /*
+       * The idea here is to build a {@link dev.langchain4j.model.chat.ChatModel} using inversion
+       * of control.
+       * Each model has its own list of parameters we don't know beforehand, but each {@link dev.langchain4j.model.chat.ChatModel} class
+       * has its own builder that uses setters with the same name of the parameter in input.
+       * */
+      ChatModel textToTextModel;
+      Class<?> modelClass = solrResourceLoader.findClass(className, ChatModel.class);
+      var builder = modelClass.getMethod("builder").invoke(null);
+      if (params != null) {
+        /*
+         * This block of code has the responsibility of instantiate a {@link

Review Comment:
   Changed



-- 
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: issues-unsubscribe@solr.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


---------------------------------------------------------------------
To unsubscribe, e-mail: issues-unsubscribe@solr.apache.org
For additional commands, e-mail: issues-help@solr.apache.org


Re: [PR] SOLR-18187: Document enrichment with LLMs [solr]

Posted by "aruggero (via GitHub)" <gi...@apache.org>.
aruggero commented on code in PR #4259:
URL: https://github.com/apache/solr/pull/4259#discussion_r3033409964


##########
solr/modules/language-models/src/test/org/apache/solr/languagemodels/documentenrichment/update/processor/DocumentEnrichmentUpdateProcessorTest.java:
##########
@@ -0,0 +1,718 @@
+/*
+ * 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.solr.languagemodels.documentenrichment.update.processor;
+
+import java.io.IOException;
+import java.util.Map;
+import org.apache.solr.client.solrj.RemoteSolrException;
+import org.apache.solr.client.solrj.SolrServerException;
+import org.apache.solr.client.solrj.request.SolrQuery;
+import org.apache.solr.client.solrj.request.UpdateRequest;
+import org.apache.solr.common.SolrInputDocument;
+import org.apache.solr.languagemodels.TestLanguageModelBase;
+import org.apache.solr.languagemodels.documentenrichment.store.rest.ManagedChatModelStore;
+import org.junit.After;
+import org.junit.AfterClass;
+import org.junit.BeforeClass;
+import org.junit.Test;
+
+public class DocumentEnrichmentUpdateProcessorTest extends TestLanguageModelBase {
+
+  @BeforeClass
+  public static void init() throws Exception {
+    setupTest("solrconfig-document-enrichment.xml", "schema-language-models.xml", false, false);
+  }
+
+  @AfterClass
+  public static void cleanup() throws Exception {
+    afterTest();
+  }
+
+  private String loadedModelId;
+
+  @After
+  public void afterEachTest() throws Exception {
+    if (loadedModelId != null) {
+      restTestHarness.delete(ManagedChatModelStore.REST_END_POINT + "/" + loadedModelId);
+      loadedModelId = null;
+    }
+  }
+
+  private void loadTestChatModel(String fileName, String modelId) throws Exception {
+    loadChatModel(fileName);
+    loadedModelId = modelId;
+  }
+
+  @Test
+  public void processAdd_inputField_shouldEnrichInputField() throws Exception {
+    loadTestChatModel("dummy-chat-model.json", "dummy-chat-1");
+
+    addWithChain(sdoc("id", "99", "string_field", "Vegeta is the saiyan prince."), "documentEnrichment");
+    addWithChain(sdoc("id", "98", "string_field", "Kakaroth is a saiyan grown up on planet Earth."), "documentEnrichment");
+    assertU(commit());
+
+    final SolrQuery query = getEnrichmentQuery("enriched_field");
+
+    assertJQ(
+        "/query" + query.toQueryString(),
+        "/response/numFound==2]",
+        "/response/docs/[0]/id=='99'",
+        "/response/docs/[0]/enriched_field=='enriched content'",
+        "/response/docs/[1]/id=='98'",
+        "/response/docs/[1]/enriched_field=='enriched content'");
+  }
+
+  /*
+   This test looks for the 'dummy-chat-1' model, but such model is not loaded —
+   the model store is empty, so the update fails.
+  */
+  @Test
+  public void processAdd_modelNotFound_shouldThrowException() {
+    RuntimeException thrown =
+        assertThrows(
+            "model not found should throw an exception",
+            RemoteSolrException.class,
+            () ->
+                addWithChain(
+                    sdoc("id", "99", "string_field", "Vegeta is the saiyan prince."),
+                    "documentEnrichment"));
+    assertTrue(
+        thrown
+            .getMessage()
+            .contains(
+                "The model configured in the Update Request Processor 'dummy-chat-1' can't be found in the store: /schema/chat-model-store"));
+  }
+
+  @Test
+  public void processAdd_emptyInputField_shouldLogAndIndexWithNoEnrichedField() throws Exception {
+    loadTestChatModel("dummy-chat-model.json", "dummy-chat-1");
+    addWithChain(sdoc("id", "99", "string_field", ""), "documentEnrichment");
+    addWithChain(sdoc("id", "98", "string_field", "Vegeta is the saiyan prince."), "documentEnrichment");
+    assertU(commit());
+
+    final SolrQuery query = getEnrichmentQuery("enriched_field");
+
+    assertJQ(
+        "/query" + query.toQueryString(),
+        "/response/numFound==2]",
+        "/response/docs/[0]/id=='99'",
+        "!/response/docs/[0]/enriched_field==", // no enriched field for doc 99
+        "/response/docs/[1]/id=='98'",
+        "/response/docs/[1]/enriched_field=='enriched content'");
+  }
+
+  @Test
+  public void processAdd_nullInputField_shouldLogAndIndexWithNoEnrichedField() throws Exception {
+    loadTestChatModel("dummy-chat-model.json", "dummy-chat-1");
+    addWithChain(sdoc("id", "99", "string_field", "Vegeta is the saiyan prince."), "documentEnrichment");
+    assertU(adoc("id", "98")); // no string_field
+    assertU(commit());
+
+    final SolrQuery query = getEnrichmentQuery("enriched_field");
+
+    assertJQ(
+        "/query" + query.toQueryString(),
+        "/response/numFound==2]",
+        "/response/docs/[0]/id=='99'",
+        "/response/docs/[0]/enriched_field=='enriched content'",
+        "/response/docs/[1]/id=='98'",
+        "!/response/docs/[1]/enriched_field=="); // no enriched field for doc 98
+  }
+
+  @Test
+  public void processAdd_failingEnrichment_shouldLogAndIndexWithNoEnrichedField() throws Exception {
+    loadTestChatModel("exception-throwing-chat-model.json", "exception-throwing-chat-model");
+    addWithChain(sdoc("id", "99", "string_field", "Vegeta is the saiyan prince."), "failingDocumentEnrichment");
+    addWithChain(sdoc("id", "98", "string_field", "Kakaroth is a saiyan grown up on planet Earth."), "failingDocumentEnrichment");
+    assertU(commit());
+
+    final SolrQuery query = getEnrichmentQuery("enriched_field");
+
+    assertJQ(
+        "/query" + query.toQueryString(),
+        "/response/numFound==2]",
+        "/response/docs/[0]/id=='99'",
+        "!/response/docs/[0]/enriched_field==", // no enriched field for doc 99
+        "/response/docs/[1]/id=='98'",
+        "!/response/docs/[1]/enriched_field=="); // no enriched field for doc 98
+  }
+
+  @Test
+  public void processAtomicUpdate_shouldTriggerEnrichmentAndFetchTheStoredContent()
+      throws Exception {
+    // Verifies that when using a processor chain configured for partial updates
+    // (i.e., DistributedUpdateProcessorFactory before DocumentEnrichmentUpdateProcessorFactory),
+    // the system correctly retrieves the stored value of string_field and generates the
+    // enriched content for the document.
+    loadTestChatModel("dummy-chat-model.json", "dummy-chat-1");
+    assertU(adoc("id", "99", "string_field", "Vegeta is the saiyan prince."));
+    assertU(adoc("id", "98", "string_field", "Kakaroth is a saiyan grown up on planet Earth."));
+    assertU(commit());
+
+    SolrInputDocument atomicDoc = new SolrInputDocument();
+    atomicDoc.setField("id", "99");
+    atomicDoc.setField("enriched", Map.of("set", true));
+    addWithChain(atomicDoc, "documentEnrichmentForPartialUpdates");
+    assertU(commit());
+
+    final SolrQuery query = getEnrichmentQuery("enriched_field");
+
+    assertJQ(
+        "/query" + query.toQueryString(),
+        "/response/numFound==2]",
+        "/response/docs/[0]/id=='99'",
+        "/response/docs/[0]/enriched_field=='enriched content'",
+        "/response/docs/[1]/id=='98'",
+        "!/response/docs/[1]/enriched_field==" // no enriched field for document 98
+        );
+  }
+
+  @Test
+  public void processAtomicUpdate_shouldReplaceExistingEnrichedFieldNotAppend() throws Exception {
+    // Verifies that when a document already contains an enriched_field and string_field is
+    // modified via atomic update, the enriched content is recomputed and replaces the previous
+    // value rather than being appended.
+    loadTestChatModel("dummy-chat-model.json", "dummy-chat-1");
+    addWithChain(sdoc("id", "99", "string_field", "Vegeta is the saiyan prince."), "documentEnrichment");
+    addWithChain(sdoc("id", "98", "string_field", "Kakaroth is a saiyan grown up on planet Earth."), "documentEnrichment");
+    assertU(commit());
+
+    SolrInputDocument atomicDoc = new SolrInputDocument();
+    atomicDoc.setField("id", "99");
+    atomicDoc.setField("string_field", Map.of("set", "Vegeta is the saiyan prince from the Dragon Ball series."));
+    addWithChain(atomicDoc, "documentEnrichmentForPartialUpdates");
+    assertU(commit());
+
+    final SolrQuery query = getEnrichmentQuery("enriched_field");
+
+    assertJQ(
+        "/query" + query.toQueryString(),
+        "/response/numFound==2]",
+        "/response/docs/[0]/id=='99'",
+        "/response/docs/[0]/enriched_field=='enriched content'",
+        "/response/docs/[1]/id=='98'",
+        "/response/docs/[1]/enriched_field=='enriched content'");
+  }
+
+  // --- multi-field tests ---
+
+  @Test
+  public void processAdd_multipleInputFields_allPresent_shouldEnrichDocument() throws Exception {

Review Comment:
   Is this enough to test that both input fields were considered? I would have the same response, even if only one of the two was used.



##########
solr/modules/language-models/src/test/org/apache/solr/languagemodels/documentenrichment/store/rest/TestChatModelManager.java:
##########
@@ -0,0 +1,239 @@
+/*
+ * 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.solr.languagemodels.documentenrichment.store.rest;
+
+import org.apache.solr.common.util.NamedList;
+import org.apache.solr.core.SolrResourceLoader;
+import org.apache.solr.languagemodels.TestLanguageModelBase;
+import org.apache.solr.languagemodels.documentenrichment.update.processor.DocumentEnrichmentUpdateProcessorFactory;
+import org.apache.solr.rest.ManagedResource;
+import org.apache.solr.rest.ManagedResourceStorage;
+import org.apache.solr.rest.RestManager;
+import org.junit.AfterClass;
+import org.junit.BeforeClass;
+import org.junit.Test;
+
+public class TestChatModelManager extends TestLanguageModelBase {
+
+  @BeforeClass
+  public static void init() throws Exception {
+    setupTest("solrconfig-document-enrichment.xml", "schema-language-models.xml", false, false);
+  }
+
+  @AfterClass
+  public static void cleanup() throws Exception {
+    afterTest();
+  }
+
+  @Test
+  public void test() throws Exception {
+    final SolrResourceLoader loader = new SolrResourceLoader(tmpSolrHome);
+
+    final RestManager.Registry registry = loader.getManagedResourceRegistry();
+    assertNotNull(
+        "Expected a non-null RestManager.Registry from the SolrResourceLoader!", registry);
+
+    final String resourceId = "/schema/mstore1";
+    registry.registerManagedResource(
+        resourceId, ManagedChatModelStore.class, new DocumentEnrichmentUpdateProcessorFactory());
+
+    final NamedList<String> initArgs = new NamedList<>();
+
+    final RestManager restManager = new RestManager();
+    restManager.init(loader, initArgs, new ManagedResourceStorage.InMemoryStorageIO());
+
+    final ManagedResource res = restManager.getManagedResource(resourceId);
+    assertTrue(res instanceof ManagedChatModelStore);
+    assertEquals(res.getResourceId(), resourceId);
+  }
+
+  @Test
+  public void testRestManagerEndpoints() throws Exception {
+    assertJQ("/schema/managed", "/responseHeader/status==0");
+
+    final String openAiClassName = "dev.langchain4j.model.openai.OpenAiChatModel";

Review Comment:
   In org.apache.solr.languagemodels.textvectorisation.store.rest.TestModelManager#testRestManagerEndpoints, also failure is tested
   
   Is this due to the fact that there is the loadChatModel_dummyUnsupportedParam_shouldRaiseError?



##########
solr/modules/language-models/src/test/org/apache/solr/languagemodels/documentenrichment/update/processor/DocumentEnrichmentUpdateProcessorTest.java:
##########
@@ -0,0 +1,718 @@
+/*
+ * 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.solr.languagemodels.documentenrichment.update.processor;
+
+import java.io.IOException;
+import java.util.Map;
+import org.apache.solr.client.solrj.RemoteSolrException;
+import org.apache.solr.client.solrj.SolrServerException;
+import org.apache.solr.client.solrj.request.SolrQuery;
+import org.apache.solr.client.solrj.request.UpdateRequest;
+import org.apache.solr.common.SolrInputDocument;
+import org.apache.solr.languagemodels.TestLanguageModelBase;
+import org.apache.solr.languagemodels.documentenrichment.store.rest.ManagedChatModelStore;
+import org.junit.After;
+import org.junit.AfterClass;
+import org.junit.BeforeClass;
+import org.junit.Test;
+
+public class DocumentEnrichmentUpdateProcessorTest extends TestLanguageModelBase {
+
+  @BeforeClass
+  public static void init() throws Exception {
+    setupTest("solrconfig-document-enrichment.xml", "schema-language-models.xml", false, false);
+  }
+
+  @AfterClass
+  public static void cleanup() throws Exception {
+    afterTest();
+  }
+
+  private String loadedModelId;
+
+  @After
+  public void afterEachTest() throws Exception {
+    if (loadedModelId != null) {
+      restTestHarness.delete(ManagedChatModelStore.REST_END_POINT + "/" + loadedModelId);
+      loadedModelId = null;
+    }
+  }
+
+  private void loadTestChatModel(String fileName, String modelId) throws Exception {
+    loadChatModel(fileName);
+    loadedModelId = modelId;
+  }
+
+  @Test
+  public void processAdd_inputField_shouldEnrichInputField() throws Exception {
+    loadTestChatModel("dummy-chat-model.json", "dummy-chat-1");
+
+    addWithChain(sdoc("id", "99", "string_field", "Vegeta is the saiyan prince."), "documentEnrichment");
+    addWithChain(sdoc("id", "98", "string_field", "Kakaroth is a saiyan grown up on planet Earth."), "documentEnrichment");
+    assertU(commit());
+
+    final SolrQuery query = getEnrichmentQuery("enriched_field");
+
+    assertJQ(
+        "/query" + query.toQueryString(),
+        "/response/numFound==2]",
+        "/response/docs/[0]/id=='99'",
+        "/response/docs/[0]/enriched_field=='enriched content'",
+        "/response/docs/[1]/id=='98'",
+        "/response/docs/[1]/enriched_field=='enriched content'");
+  }
+
+  /*
+   This test looks for the 'dummy-chat-1' model, but such model is not loaded —
+   the model store is empty, so the update fails.
+  */
+  @Test
+  public void processAdd_modelNotFound_shouldThrowException() {
+    RuntimeException thrown =
+        assertThrows(
+            "model not found should throw an exception",
+            RemoteSolrException.class,
+            () ->
+                addWithChain(
+                    sdoc("id", "99", "string_field", "Vegeta is the saiyan prince."),
+                    "documentEnrichment"));
+    assertTrue(
+        thrown
+            .getMessage()
+            .contains(
+                "The model configured in the Update Request Processor 'dummy-chat-1' can't be found in the store: /schema/chat-model-store"));
+  }
+
+  @Test
+  public void processAdd_emptyInputField_shouldLogAndIndexWithNoEnrichedField() throws Exception {
+    loadTestChatModel("dummy-chat-model.json", "dummy-chat-1");
+    addWithChain(sdoc("id", "99", "string_field", ""), "documentEnrichment");
+    addWithChain(sdoc("id", "98", "string_field", "Vegeta is the saiyan prince."), "documentEnrichment");
+    assertU(commit());
+
+    final SolrQuery query = getEnrichmentQuery("enriched_field");
+
+    assertJQ(
+        "/query" + query.toQueryString(),
+        "/response/numFound==2]",
+        "/response/docs/[0]/id=='99'",
+        "!/response/docs/[0]/enriched_field==", // no enriched field for doc 99
+        "/response/docs/[1]/id=='98'",
+        "/response/docs/[1]/enriched_field=='enriched content'");
+  }
+
+  @Test
+  public void processAdd_nullInputField_shouldLogAndIndexWithNoEnrichedField() throws Exception {
+    loadTestChatModel("dummy-chat-model.json", "dummy-chat-1");
+    addWithChain(sdoc("id", "99", "string_field", "Vegeta is the saiyan prince."), "documentEnrichment");
+    assertU(adoc("id", "98")); // no string_field
+    assertU(commit());
+
+    final SolrQuery query = getEnrichmentQuery("enriched_field");
+
+    assertJQ(
+        "/query" + query.toQueryString(),
+        "/response/numFound==2]",
+        "/response/docs/[0]/id=='99'",
+        "/response/docs/[0]/enriched_field=='enriched content'",
+        "/response/docs/[1]/id=='98'",
+        "!/response/docs/[1]/enriched_field=="); // no enriched field for doc 98
+  }
+
+  @Test
+  public void processAdd_failingEnrichment_shouldLogAndIndexWithNoEnrichedField() throws Exception {
+    loadTestChatModel("exception-throwing-chat-model.json", "exception-throwing-chat-model");
+    addWithChain(sdoc("id", "99", "string_field", "Vegeta is the saiyan prince."), "failingDocumentEnrichment");
+    addWithChain(sdoc("id", "98", "string_field", "Kakaroth is a saiyan grown up on planet Earth."), "failingDocumentEnrichment");
+    assertU(commit());
+
+    final SolrQuery query = getEnrichmentQuery("enriched_field");
+
+    assertJQ(
+        "/query" + query.toQueryString(),
+        "/response/numFound==2]",
+        "/response/docs/[0]/id=='99'",
+        "!/response/docs/[0]/enriched_field==", // no enriched field for doc 99
+        "/response/docs/[1]/id=='98'",
+        "!/response/docs/[1]/enriched_field=="); // no enriched field for doc 98
+  }
+
+  @Test
+  public void processAtomicUpdate_shouldTriggerEnrichmentAndFetchTheStoredContent()
+      throws Exception {
+    // Verifies that when using a processor chain configured for partial updates
+    // (i.e., DistributedUpdateProcessorFactory before DocumentEnrichmentUpdateProcessorFactory),
+    // the system correctly retrieves the stored value of string_field and generates the
+    // enriched content for the document.
+    loadTestChatModel("dummy-chat-model.json", "dummy-chat-1");
+    assertU(adoc("id", "99", "string_field", "Vegeta is the saiyan prince."));
+    assertU(adoc("id", "98", "string_field", "Kakaroth is a saiyan grown up on planet Earth."));
+    assertU(commit());
+
+    SolrInputDocument atomicDoc = new SolrInputDocument();
+    atomicDoc.setField("id", "99");
+    atomicDoc.setField("enriched", Map.of("set", true));
+    addWithChain(atomicDoc, "documentEnrichmentForPartialUpdates");
+    assertU(commit());
+
+    final SolrQuery query = getEnrichmentQuery("enriched_field");
+
+    assertJQ(
+        "/query" + query.toQueryString(),
+        "/response/numFound==2]",
+        "/response/docs/[0]/id=='99'",
+        "/response/docs/[0]/enriched_field=='enriched content'",
+        "/response/docs/[1]/id=='98'",
+        "!/response/docs/[1]/enriched_field==" // no enriched field for document 98
+        );
+  }
+
+  @Test
+  public void processAtomicUpdate_shouldReplaceExistingEnrichedFieldNotAppend() throws Exception {
+    // Verifies that when a document already contains an enriched_field and string_field is
+    // modified via atomic update, the enriched content is recomputed and replaces the previous
+    // value rather than being appended.
+    loadTestChatModel("dummy-chat-model.json", "dummy-chat-1");
+    addWithChain(sdoc("id", "99", "string_field", "Vegeta is the saiyan prince."), "documentEnrichment");
+    addWithChain(sdoc("id", "98", "string_field", "Kakaroth is a saiyan grown up on planet Earth."), "documentEnrichment");
+    assertU(commit());
+
+    SolrInputDocument atomicDoc = new SolrInputDocument();
+    atomicDoc.setField("id", "99");
+    atomicDoc.setField("string_field", Map.of("set", "Vegeta is the saiyan prince from the Dragon Ball series."));
+    addWithChain(atomicDoc, "documentEnrichmentForPartialUpdates");
+    assertU(commit());
+
+    final SolrQuery query = getEnrichmentQuery("enriched_field");
+
+    assertJQ(
+        "/query" + query.toQueryString(),
+        "/response/numFound==2]",
+        "/response/docs/[0]/id=='99'",
+        "/response/docs/[0]/enriched_field=='enriched content'",
+        "/response/docs/[1]/id=='98'",
+        "/response/docs/[1]/enriched_field=='enriched content'");
+  }
+
+  // --- multi-field tests ---
+
+  @Test
+  public void processAdd_multipleInputFields_allPresent_shouldEnrichDocument() throws Exception {
+    loadTestChatModel("dummy-chat-model.json", "dummy-chat-1");
+
+    addWithChain(
+        sdoc("id", "99", "string_field", "Vegeta is the saiyan prince.", "body_field", "He is very proud."),
+        "documentEnrichmentMultiField");
+    addWithChain(
+        sdoc("id", "98", "string_field", "Kakaroth is a saiyan.", "body_field", "He grew up on Earth."),
+        "documentEnrichmentMultiField");
+    assertU(commit());
+
+    final SolrQuery query = getEnrichmentQuery("enriched_field");
+
+    assertJQ(
+        "/query" + query.toQueryString(),
+        "/response/numFound==2]",
+        "/response/docs/[0]/id=='99'",
+        "/response/docs/[0]/enriched_field=='enriched content'",
+        "/response/docs/[1]/id=='98'",
+        "/response/docs/[1]/enriched_field=='enriched content'");
+  }
+
+  @Test
+  public void processAdd_multipleInputFields_firstFieldNull_shouldSkipEnrichment() throws Exception {
+    loadTestChatModel("dummy-chat-model.json", "dummy-chat-1");
+
+    addWithChain(
+        sdoc("id", "99", "body_field", "He is very proud."), // string_field absent
+        "documentEnrichmentMultiField");
+    addWithChain(
+        sdoc("id", "98", "body_field", "He is very jealous."), // string_field absent
+        "documentEnrichmentMultiField");
+    assertU(commit());
+
+    final SolrQuery query = getEnrichmentQuery("enriched_field");
+
+    assertJQ(
+        "/query" + query.toQueryString(),
+        "/response/numFound==2]",
+        "/response/docs/[0]/id=='99'",
+        "!/response/docs/[0]/enriched_field==",
+        "/response/docs/[1]/id=='98'",
+        "!/response/docs/[1]/enriched_field==");
+  }
+
+  @Test
+  public void processAdd_multipleInputFields_secondFieldEmpty_shouldSkipEnrichment() throws Exception {
+    loadTestChatModel("dummy-chat-model.json", "dummy-chat-1");
+
+    addWithChain(
+        sdoc("id", "99", "string_field", "Vegeta is the saiyan prince.", "body_field", ""),
+        "documentEnrichmentMultiField");
+    addWithChain(
+        sdoc("id", "98", "string_field", "Goku is the best saiyan.", "body_field", ""),
+        "documentEnrichmentMultiField");
+    assertU(commit());
+
+    final SolrQuery query = getEnrichmentQuery("enriched_field");
+
+    assertJQ(
+        "/query" + query.toQueryString(),
+        "/response/numFound==2]",
+        "/response/docs/[0]/id=='99'",
+        "!/response/docs/[0]/enriched_field==",
+        "/response/docs/[1]/id=='98'",
+        "!/response/docs/[1]/enriched_field==");
+  }
+
+  @Test
+  public void processAdd_multipleInputFields_bothFieldsAbsent_shouldSkipEnrichment() throws Exception {
+    loadTestChatModel("dummy-chat-model.json", "dummy-chat-1");
+
+    addWithChain(sdoc("id", "99"), "documentEnrichmentMultiField");
+    addWithChain(sdoc("id", "98"), "documentEnrichmentMultiField");
+    assertU(commit());
+
+    final SolrQuery query = getEnrichmentQuery("enriched_field");
+
+    assertJQ(
+        "/query" + query.toQueryString(),
+        "/response/numFound==2]",
+        "/response/docs/[0]/id=='99'",
+        "!/response/docs/[0]/enriched_field==",
+        "/response/docs/[1]/id=='98'",
+        "!/response/docs/[1]/enriched_field==");
+  }
+
+  @Test
+  public void processAdd_multipleInputFields_failingModel_shouldLogAndSkipEnrichment() throws Exception {
+    loadTestChatModel("exception-throwing-chat-model.json", "exception-throwing-chat-model");
+
+    addWithChain(
+        sdoc("id", "99", "string_field", "Vegeta is the saiyan prince.", "body_field", "He is very proud."),
+        "failingDocumentEnrichmentMultiField");
+    addWithChain(
+        sdoc("id", "98", "string_field", "Kakaroth is a saiyan.", "body_field", "He grew up on Earth."),
+        "failingDocumentEnrichmentMultiField");
+    assertU(commit());
+
+    final SolrQuery query = getEnrichmentQuery("enriched_field");
+
+    assertJQ(
+        "/query" + query.toQueryString(),
+        "/response/numFound==2]",
+        "/response/docs/[0]/id=='99'",
+        "!/response/docs/[0]/enriched_field==",
+        "/response/docs/[1]/id=='98'",
+        "!/response/docs/[1]/enriched_field==");
+  }
+
+  @Test
+  public void processAdd_multivaluedStringOutputField_shouldPopulateAllValues() throws Exception {
+    loadTestChatModel("dummy-chat-model-multivalued-string.json", "dummy-chat-multivalued-1");
+
+    addWithChain(
+        sdoc("id", "99", "string_field", "Vegeta is the saiyan prince."),
+        "documentEnrichmentMultivaluedString");
+    addWithChain(
+        sdoc("id", "98", "string_field", "Kakaroth is a saiyan grown up on planet Earth."),
+        "documentEnrichmentMultivaluedString");
+    assertU(commit());
+
+    final SolrQuery query = getEnrichmentQuery("enriched_field_multi");
+
+    assertJQ(
+        "/query" + query.toQueryString(),
+        "/response/numFound==2]",
+        "/response/docs/[0]/id=='99'",
+        "/response/docs/[0]/enriched_field_multi/[0]=='tag1'",
+        "/response/docs/[0]/enriched_field_multi/[1]=='tag2'",
+        "/response/docs/[1]/id=='98'",
+        "/response/docs/[1]/enriched_field_multi/[0]=='tag1'",
+        "/response/docs/[1]/enriched_field_multi/[1]=='tag2'");
+  }
+
+  @Test
+  public void processAdd_multivaluedStringOutputField_emptyInput_shouldSkipEnrichment()
+      throws Exception {
+    loadTestChatModel("dummy-chat-model-multivalued-string.json", "dummy-chat-multivalued-1");
+
+    addWithChain(sdoc("id", "99", "string_field", ""), "documentEnrichmentMultivaluedString");
+    addWithChain(sdoc("id", "98", "string_field", ""), "documentEnrichmentMultivaluedString");
+    assertU(commit());
+
+    final SolrQuery query = getEnrichmentQuery("enriched_field_multi");
+
+    assertJQ(
+        "/query" + query.toQueryString(),
+        "/response/numFound==2]",
+        "/response/docs/[0]/id=='99'",
+        "!/response/docs/[0]/enriched_field_multi==",
+        "/response/docs/[1]/id=='98'",
+        "!/response/docs/[1]/enriched_field_multi==");
+  }
+
+  // --- typed single-valued output field tests ---
+
+  @Test
+  public void processAdd_singleLongOutputField_shouldPopulateValue() throws Exception {
+    loadTestChatModel("dummy-chat-model-single-long.json", "dummy-long");
+
+    addWithChain(sdoc("id", "99", "string_field", "some content"), "documentEnrichmentSingleLong");
+    addWithChain(sdoc("id", "98", "string_field", "other content"), "documentEnrichmentSingleLong");
+    assertU(commit());
+
+    final SolrQuery query = getEnrichmentQuery("output_long");
+
+    assertJQ(
+        "/query" + query.toQueryString(),
+        "/response/numFound==2]",
+        "/response/docs/[0]/id=='99'",
+        "/response/docs/[0]/output_long==42",
+        "/response/docs/[1]/id=='98'",
+        "/response/docs/[1]/output_long==42");
+  }
+
+  @Test
+  public void processAdd_singleIntOutputField_shouldPopulateValue() throws Exception {
+    loadTestChatModel("dummy-chat-model-single-int.json", "dummy-int");
+
+    addWithChain(sdoc("id", "99", "string_field", "some content"), "documentEnrichmentSingleInt");
+    addWithChain(sdoc("id", "98", "string_field", "other content"), "documentEnrichmentSingleInt");
+    assertU(commit());
+
+    final SolrQuery query = getEnrichmentQuery("output_int");
+
+    assertJQ(
+        "/query" + query.toQueryString(),
+        "/response/numFound==2]",
+        "/response/docs/[0]/id=='99'",
+        "/response/docs/[0]/output_int==7",
+        "/response/docs/[1]/id=='98'",
+        "/response/docs/[1]/output_int==7");
+  }
+
+  @Test
+  public void processAdd_singleFloatOutputField_shouldPopulateValue() throws Exception {
+    loadTestChatModel("dummy-chat-model-single-float.json", "dummy-float");
+
+    addWithChain(sdoc("id", "99", "string_field", "some content"), "documentEnrichmentSingleFloat");
+    addWithChain(sdoc("id", "98", "string_field", "other content"), "documentEnrichmentSingleFloat");
+    assertU(commit());
+
+    final SolrQuery query = getEnrichmentQuery("output_float");
+
+    assertJQ(
+        "/query" + query.toQueryString(),
+        "/response/numFound==2]",
+        "/response/docs/[0]/id=='99'",
+        "/response/docs/[0]/output_float==1.5",
+        "/response/docs/[1]/id=='98'",
+        "/response/docs/[1]/output_float==1.5");
+  }
+
+  @Test
+  public void processAdd_singleDoubleOutputField_shouldPopulateValue() throws Exception {
+    loadTestChatModel("dummy-chat-model-single-double.json", "dummy-double");
+
+    addWithChain(
+        sdoc("id", "99", "string_field", "some content"), "documentEnrichmentSingleDouble");
+    addWithChain(
+        sdoc("id", "98", "string_field", "other content"), "documentEnrichmentSingleDouble");
+    assertU(commit());
+
+    final SolrQuery query = getEnrichmentQuery("output_double");
+
+    assertJQ(
+        "/query" + query.toQueryString(),
+        "/response/numFound==2]",
+        "/response/docs/[0]/id=='99'",
+        "/response/docs/[0]/output_double==2.5",
+        "/response/docs/[1]/id=='98'",
+        "/response/docs/[1]/output_double==2.5");
+  }
+
+  @Test
+  public void processAdd_singleBooleanOutputField_shouldPopulateValue() throws Exception {
+    loadTestChatModel("dummy-chat-model-single-boolean.json", "dummy-boolean");
+
+    addWithChain(
+        sdoc("id", "99", "string_field", "some content"), "documentEnrichmentSingleBoolean");
+    addWithChain(
+        sdoc("id", "98", "string_field", "other content"), "documentEnrichmentSingleBoolean");
+    assertU(commit());
+
+    final SolrQuery query = getEnrichmentQuery("output_boolean");
+
+    assertJQ(
+        "/query" + query.toQueryString(),
+        "/response/numFound==2]",
+        "/response/docs/[0]/id=='99'",
+        "/response/docs/[0]/output_boolean==true",
+        "/response/docs/[1]/id=='98'",
+        "/response/docs/[1]/output_boolean==true");
+  }
+
+  @Test
+  public void processAdd_singleDateOutputField_shouldPopulateValue() throws Exception {
+    loadTestChatModel("dummy-chat-model-single-date.json", "dummy-date");
+
+    addWithChain(sdoc("id", "99", "string_field", "some content"), "documentEnrichmentSingleDate");
+    addWithChain(sdoc("id", "98", "string_field", "other content"), "documentEnrichmentSingleDate");
+    assertU(commit());
+
+    final SolrQuery query = getEnrichmentQuery("output_date");
+
+    assertJQ(
+        "/query" + query.toQueryString(),
+        "/response/numFound==2]",
+        "/response/docs/[0]/id=='99'",
+        "/response/docs/[0]/output_date=='2024-01-15T00:00:00Z'",
+        "/response/docs/[1]/id=='98'",
+        "/response/docs/[1]/output_date=='2024-01-15T00:00:00Z'");
+  }
+
+  // --- typed multivalued output field tests ---
+
+  @Test
+  public void processAdd_multivaluedLongOutputField_shouldPopulateAllValues() throws Exception {
+    loadTestChatModel("dummy-chat-model-multivalued-long.json", "dummy-long-multi");
+
+    addWithChain(
+        sdoc("id", "99", "string_field", "some content"), "documentEnrichmentMultivaluedLong");
+    addWithChain(
+        sdoc("id", "98", "string_field", "other content"), "documentEnrichmentMultivaluedLong");
+    assertU(commit());
+
+    final SolrQuery query = getEnrichmentQuery("output_long_multi");
+
+    assertJQ(
+        "/query" + query.toQueryString(),
+        "/response/numFound==2]",
+        "/response/docs/[0]/id=='99'",
+        "/response/docs/[0]/output_long_multi/[0]==10",
+        "/response/docs/[0]/output_long_multi/[1]==20",
+        "/response/docs/[0]/output_long_multi/[2]==30",
+        "/response/docs/[1]/id=='98'",
+        "/response/docs/[1]/output_long_multi/[0]==10",
+        "/response/docs/[1]/output_long_multi/[1]==20",
+        "/response/docs/[1]/output_long_multi/[2]==30");
+  }
+
+  @Test
+  public void processAdd_multivaluedIntOutputField_shouldPopulateAllValues() throws Exception {
+    loadTestChatModel("dummy-chat-model-multivalued-int.json", "dummy-int-multi");
+
+    addWithChain(
+        sdoc("id", "99", "string_field", "some content"), "documentEnrichmentMultivaluedInt");
+    addWithChain(
+        sdoc("id", "98", "string_field", "other content"), "documentEnrichmentMultivaluedInt");
+    assertU(commit());
+
+    final SolrQuery query = getEnrichmentQuery("output_int_multi");
+
+    assertJQ(
+        "/query" + query.toQueryString(),
+        "/response/numFound==2]",
+        "/response/docs/[0]/id=='99'",
+        "/response/docs/[0]/output_int_multi/[0]==1",
+        "/response/docs/[0]/output_int_multi/[1]==2",
+        "/response/docs/[1]/id=='98'",
+        "/response/docs/[1]/output_int_multi/[0]==1",
+        "/response/docs/[1]/output_int_multi/[1]==2");
+  }
+
+  @Test
+  public void processAdd_multivaluedFloatOutputField_shouldPopulateAllValues() throws Exception {
+    loadTestChatModel("dummy-chat-model-multivalued-float.json", "dummy-float-multi");
+
+    addWithChain(
+        sdoc("id", "99", "string_field", "some content"), "documentEnrichmentMultivaluedFloat");
+    addWithChain(
+        sdoc("id", "98", "string_field", "other content"), "documentEnrichmentMultivaluedFloat");
+    assertU(commit());
+
+    final SolrQuery query = getEnrichmentQuery("output_float_multi");
+
+    assertJQ(
+        "/query" + query.toQueryString(),
+        "/response/numFound==2]",
+        "/response/docs/[0]/id=='99'",
+        "/response/docs/[0]/output_float_multi/[0]==1.5",
+        "/response/docs/[0]/output_float_multi/[1]==2.5",
+        "/response/docs/[1]/id=='98'",
+        "/response/docs/[1]/output_float_multi/[0]==1.5",
+        "/response/docs/[1]/output_float_multi/[1]==2.5");
+  }
+
+  @Test
+  public void processAdd_multivaluedDoubleOutputField_shouldPopulateAllValues() throws Exception {
+    loadTestChatModel("dummy-chat-model-multivalued-double.json", "dummy-double-multi");
+
+    addWithChain(
+        sdoc("id", "99", "string_field", "some content"), "documentEnrichmentMultivaluedDouble");
+    addWithChain(
+        sdoc("id", "98", "string_field", "other content"), "documentEnrichmentMultivaluedDouble");
+    assertU(commit());
+
+    final SolrQuery query = getEnrichmentQuery("output_double_multi");
+
+    assertJQ(
+        "/query" + query.toQueryString(),
+        "/response/numFound==2]",
+        "/response/docs/[0]/id=='99'",
+        "/response/docs/[0]/output_double_multi/[0]==3.14",
+        "/response/docs/[0]/output_double_multi/[1]==2.71",
+        "/response/docs/[1]/id=='98'",
+        "/response/docs/[1]/output_double_multi/[0]==3.14",
+        "/response/docs/[1]/output_double_multi/[1]==2.71");
+  }
+
+  @Test
+  public void processAdd_multivaluedBooleanOutputField_shouldPopulateAllValues() throws Exception {
+    loadTestChatModel("dummy-chat-model-multivalued-boolean.json", "dummy-boolean-multi");
+
+    addWithChain(
+        sdoc("id", "99", "string_field", "some content"), "documentEnrichmentMultivaluedBoolean");
+    addWithChain(
+        sdoc("id", "98", "string_field", "other content"), "documentEnrichmentMultivaluedBoolean");
+    assertU(commit());
+
+    final SolrQuery query = getEnrichmentQuery("output_boolean_multi");
+
+    assertJQ(
+        "/query" + query.toQueryString(),
+        "/response/numFound==2]",
+        "/response/docs/[0]/id=='99'",
+        "/response/docs/[0]/output_boolean_multi/[0]==true",
+        "/response/docs/[0]/output_boolean_multi/[1]==false",
+        "/response/docs/[1]/id=='98'",
+        "/response/docs/[1]/output_boolean_multi/[0]==true",
+        "/response/docs/[1]/output_boolean_multi/[1]==false");
+  }
+
+  @Test
+  public void processAdd_multivaluedDateOutputField_shouldPopulateAllValues() throws Exception {
+    loadTestChatModel("dummy-chat-model-multivalued-date.json", "dummy-date-multi");
+
+    addWithChain(
+        sdoc("id", "99", "string_field", "some content"), "documentEnrichmentMultivaluedDate");
+    addWithChain(
+        sdoc("id", "98", "string_field", "other content"), "documentEnrichmentMultivaluedDate");
+    assertU(commit());
+
+    final SolrQuery query = getEnrichmentQuery("output_date_multi");
+
+    assertJQ(
+        "/query" + query.toQueryString(),
+        "/response/numFound==2]",
+        "/response/docs/[0]/id=='99'",
+        "/response/docs/[0]/output_date_multi/[0]=='2024-01-15T00:00:00Z'",
+        "/response/docs/[0]/output_date_multi/[1]=='2025-06-30T00:00:00Z'",
+        "/response/docs/[1]/id=='98'",
+        "/response/docs/[1]/output_date_multi/[0]=='2024-01-15T00:00:00Z'",
+        "/response/docs/[1]/output_date_multi/[1]=='2025-06-30T00:00:00Z'");
+  }
+
+  // --- LLM response contract violation tests ---
+
+  @Test
+  public void processAdd_llmResponseMissingValueKey_shouldLogAndIndexWithNoEnrichedField()
+      throws Exception {
+    // Model returns valid JSON but without the required "value" key
+    loadTestChatModel("dummy-chat-model-missing-value-key.json", "dummy-chat-1");
+
+    addWithChain(sdoc("id", "99", "string_field", "Vegeta is the saiyan prince."), "documentEnrichment");
+    addWithChain(sdoc("id", "98", "string_field", "Kakaroth is a saiyan grown up on planet Earth."), "documentEnrichment");
+    assertU(commit());
+
+    final SolrQuery query = getEnrichmentQuery("enriched_field");
+
+    assertJQ(
+        "/query" + query.toQueryString(),
+        "/response/numFound==2]",
+        "/response/docs/[0]/id=='99'",
+        "!/response/docs/[0]/enriched_field==",
+        "/response/docs/[1]/id=='98'",
+        "!/response/docs/[1]/enriched_field==");
+  }
+
+  @Test
+  public void processAdd_llmResponseMalformedJson_shouldLogAndIndexWithNoEnrichedField()
+      throws Exception {
+    // Model returns a plain string that cannot be parsed as JSON
+    loadTestChatModel("dummy-chat-model-malformed-json.json", "dummy-chat-1");
+
+    addWithChain(sdoc("id", "99", "string_field", "Vegeta is the saiyan prince."), "documentEnrichment");
+    addWithChain(sdoc("id", "98", "string_field", "Kakaroth is a saiyan grown up on planet Earth."), "documentEnrichment");
+    assertU(commit());
+
+    final SolrQuery query = getEnrichmentQuery("enriched_field");
+
+    assertJQ(
+        "/query" + query.toQueryString(),
+        "/response/numFound==2]",
+        "/response/docs/[0]/id=='99'",
+        "!/response/docs/[0]/enriched_field==",
+        "/response/docs/[1]/id=='98'",
+        "!/response/docs/[1]/enriched_field==");
+  }
+
+  // --- multivalued output field / scalar response test ---
+
+  @Test
+  public void processAdd_multivaluedOutputField_scalarLlmResponse_shouldStoreSingleValue()
+      throws Exception {
+    // Model returns {"value": "a single string"} for a multivalued output field.
+    // The scalar falls through the List<?> check and is stored as a single-element value.

Review Comment:
   Mmmm a bit confusing... the field is anyway multivalued.. even if it contains just one value



##########
solr/modules/language-models/src/test/org/apache/solr/languagemodels/documentenrichment/update/processor/DocumentEnrichmentUpdateProcessorTest.java:
##########
@@ -0,0 +1,718 @@
+/*
+ * 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.solr.languagemodels.documentenrichment.update.processor;
+
+import java.io.IOException;
+import java.util.Map;
+import org.apache.solr.client.solrj.RemoteSolrException;
+import org.apache.solr.client.solrj.SolrServerException;
+import org.apache.solr.client.solrj.request.SolrQuery;
+import org.apache.solr.client.solrj.request.UpdateRequest;
+import org.apache.solr.common.SolrInputDocument;
+import org.apache.solr.languagemodels.TestLanguageModelBase;
+import org.apache.solr.languagemodels.documentenrichment.store.rest.ManagedChatModelStore;
+import org.junit.After;
+import org.junit.AfterClass;
+import org.junit.BeforeClass;
+import org.junit.Test;
+
+public class DocumentEnrichmentUpdateProcessorTest extends TestLanguageModelBase {
+
+  @BeforeClass
+  public static void init() throws Exception {
+    setupTest("solrconfig-document-enrichment.xml", "schema-language-models.xml", false, false);
+  }
+
+  @AfterClass
+  public static void cleanup() throws Exception {
+    afterTest();
+  }
+
+  private String loadedModelId;
+
+  @After
+  public void afterEachTest() throws Exception {
+    if (loadedModelId != null) {
+      restTestHarness.delete(ManagedChatModelStore.REST_END_POINT + "/" + loadedModelId);
+      loadedModelId = null;
+    }
+  }
+
+  private void loadTestChatModel(String fileName, String modelId) throws Exception {
+    loadChatModel(fileName);
+    loadedModelId = modelId;
+  }
+
+  @Test
+  public void processAdd_inputField_shouldEnrichInputField() throws Exception {
+    loadTestChatModel("dummy-chat-model.json", "dummy-chat-1");
+
+    addWithChain(sdoc("id", "99", "string_field", "Vegeta is the saiyan prince."), "documentEnrichment");
+    addWithChain(sdoc("id", "98", "string_field", "Kakaroth is a saiyan grown up on planet Earth."), "documentEnrichment");
+    assertU(commit());
+
+    final SolrQuery query = getEnrichmentQuery("enriched_field");
+
+    assertJQ(
+        "/query" + query.toQueryString(),
+        "/response/numFound==2]",
+        "/response/docs/[0]/id=='99'",
+        "/response/docs/[0]/enriched_field=='enriched content'",
+        "/response/docs/[1]/id=='98'",
+        "/response/docs/[1]/enriched_field=='enriched content'");
+  }
+
+  /*
+   This test looks for the 'dummy-chat-1' model, but such model is not loaded —
+   the model store is empty, so the update fails.
+  */
+  @Test
+  public void processAdd_modelNotFound_shouldThrowException() {
+    RuntimeException thrown =
+        assertThrows(
+            "model not found should throw an exception",
+            RemoteSolrException.class,
+            () ->
+                addWithChain(
+                    sdoc("id", "99", "string_field", "Vegeta is the saiyan prince."),
+                    "documentEnrichment"));
+    assertTrue(
+        thrown
+            .getMessage()
+            .contains(
+                "The model configured in the Update Request Processor 'dummy-chat-1' can't be found in the store: /schema/chat-model-store"));
+  }
+
+  @Test
+  public void processAdd_emptyInputField_shouldLogAndIndexWithNoEnrichedField() throws Exception {
+    loadTestChatModel("dummy-chat-model.json", "dummy-chat-1");
+    addWithChain(sdoc("id", "99", "string_field", ""), "documentEnrichment");
+    addWithChain(sdoc("id", "98", "string_field", "Vegeta is the saiyan prince."), "documentEnrichment");
+    assertU(commit());
+
+    final SolrQuery query = getEnrichmentQuery("enriched_field");
+
+    assertJQ(
+        "/query" + query.toQueryString(),
+        "/response/numFound==2]",
+        "/response/docs/[0]/id=='99'",
+        "!/response/docs/[0]/enriched_field==", // no enriched field for doc 99
+        "/response/docs/[1]/id=='98'",
+        "/response/docs/[1]/enriched_field=='enriched content'");
+  }
+
+  @Test
+  public void processAdd_nullInputField_shouldLogAndIndexWithNoEnrichedField() throws Exception {
+    loadTestChatModel("dummy-chat-model.json", "dummy-chat-1");
+    addWithChain(sdoc("id", "99", "string_field", "Vegeta is the saiyan prince."), "documentEnrichment");
+    assertU(adoc("id", "98")); // no string_field
+    assertU(commit());
+
+    final SolrQuery query = getEnrichmentQuery("enriched_field");
+
+    assertJQ(
+        "/query" + query.toQueryString(),
+        "/response/numFound==2]",
+        "/response/docs/[0]/id=='99'",
+        "/response/docs/[0]/enriched_field=='enriched content'",
+        "/response/docs/[1]/id=='98'",
+        "!/response/docs/[1]/enriched_field=="); // no enriched field for doc 98
+  }
+
+  @Test
+  public void processAdd_failingEnrichment_shouldLogAndIndexWithNoEnrichedField() throws Exception {
+    loadTestChatModel("exception-throwing-chat-model.json", "exception-throwing-chat-model");
+    addWithChain(sdoc("id", "99", "string_field", "Vegeta is the saiyan prince."), "failingDocumentEnrichment");
+    addWithChain(sdoc("id", "98", "string_field", "Kakaroth is a saiyan grown up on planet Earth."), "failingDocumentEnrichment");
+    assertU(commit());
+
+    final SolrQuery query = getEnrichmentQuery("enriched_field");
+
+    assertJQ(
+        "/query" + query.toQueryString(),
+        "/response/numFound==2]",
+        "/response/docs/[0]/id=='99'",
+        "!/response/docs/[0]/enriched_field==", // no enriched field for doc 99
+        "/response/docs/[1]/id=='98'",
+        "!/response/docs/[1]/enriched_field=="); // no enriched field for doc 98
+  }
+
+  @Test
+  public void processAtomicUpdate_shouldTriggerEnrichmentAndFetchTheStoredContent()
+      throws Exception {
+    // Verifies that when using a processor chain configured for partial updates
+    // (i.e., DistributedUpdateProcessorFactory before DocumentEnrichmentUpdateProcessorFactory),
+    // the system correctly retrieves the stored value of string_field and generates the
+    // enriched content for the document.
+    loadTestChatModel("dummy-chat-model.json", "dummy-chat-1");
+    assertU(adoc("id", "99", "string_field", "Vegeta is the saiyan prince."));
+    assertU(adoc("id", "98", "string_field", "Kakaroth is a saiyan grown up on planet Earth."));
+    assertU(commit());
+
+    SolrInputDocument atomicDoc = new SolrInputDocument();
+    atomicDoc.setField("id", "99");
+    atomicDoc.setField("enriched", Map.of("set", true));
+    addWithChain(atomicDoc, "documentEnrichmentForPartialUpdates");
+    assertU(commit());
+
+    final SolrQuery query = getEnrichmentQuery("enriched_field");
+
+    assertJQ(
+        "/query" + query.toQueryString(),
+        "/response/numFound==2]",
+        "/response/docs/[0]/id=='99'",
+        "/response/docs/[0]/enriched_field=='enriched content'",
+        "/response/docs/[1]/id=='98'",
+        "!/response/docs/[1]/enriched_field==" // no enriched field for document 98
+        );
+  }
+
+  @Test
+  public void processAtomicUpdate_shouldReplaceExistingEnrichedFieldNotAppend() throws Exception {

Review Comment:
   I cannot see how this test can check that the value is replaced and this is not the value we had before



##########
solr/modules/language-models/src/test/org/apache/solr/languagemodels/documentenrichment/update/processor/DocumentEnrichmentUpdateProcessorTest.java:
##########
@@ -0,0 +1,718 @@
+/*
+ * 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.solr.languagemodels.documentenrichment.update.processor;
+
+import java.io.IOException;
+import java.util.Map;
+import org.apache.solr.client.solrj.RemoteSolrException;
+import org.apache.solr.client.solrj.SolrServerException;
+import org.apache.solr.client.solrj.request.SolrQuery;
+import org.apache.solr.client.solrj.request.UpdateRequest;
+import org.apache.solr.common.SolrInputDocument;
+import org.apache.solr.languagemodels.TestLanguageModelBase;
+import org.apache.solr.languagemodels.documentenrichment.store.rest.ManagedChatModelStore;
+import org.junit.After;
+import org.junit.AfterClass;
+import org.junit.BeforeClass;
+import org.junit.Test;
+
+public class DocumentEnrichmentUpdateProcessorTest extends TestLanguageModelBase {
+
+  @BeforeClass
+  public static void init() throws Exception {
+    setupTest("solrconfig-document-enrichment.xml", "schema-language-models.xml", false, false);
+  }
+
+  @AfterClass
+  public static void cleanup() throws Exception {
+    afterTest();
+  }
+
+  private String loadedModelId;
+
+  @After
+  public void afterEachTest() throws Exception {
+    if (loadedModelId != null) {
+      restTestHarness.delete(ManagedChatModelStore.REST_END_POINT + "/" + loadedModelId);
+      loadedModelId = null;
+    }
+  }
+
+  private void loadTestChatModel(String fileName, String modelId) throws Exception {
+    loadChatModel(fileName);
+    loadedModelId = modelId;
+  }
+
+  @Test
+  public void processAdd_inputField_shouldEnrichInputField() throws Exception {
+    loadTestChatModel("dummy-chat-model.json", "dummy-chat-1");
+
+    addWithChain(sdoc("id", "99", "string_field", "Vegeta is the saiyan prince."), "documentEnrichment");
+    addWithChain(sdoc("id", "98", "string_field", "Kakaroth is a saiyan grown up on planet Earth."), "documentEnrichment");
+    assertU(commit());
+
+    final SolrQuery query = getEnrichmentQuery("enriched_field");
+
+    assertJQ(
+        "/query" + query.toQueryString(),
+        "/response/numFound==2]",
+        "/response/docs/[0]/id=='99'",
+        "/response/docs/[0]/enriched_field=='enriched content'",
+        "/response/docs/[1]/id=='98'",
+        "/response/docs/[1]/enriched_field=='enriched content'");
+  }
+
+  /*
+   This test looks for the 'dummy-chat-1' model, but such model is not loaded —
+   the model store is empty, so the update fails.
+  */
+  @Test
+  public void processAdd_modelNotFound_shouldThrowException() {
+    RuntimeException thrown =
+        assertThrows(
+            "model not found should throw an exception",
+            RemoteSolrException.class,
+            () ->
+                addWithChain(
+                    sdoc("id", "99", "string_field", "Vegeta is the saiyan prince."),
+                    "documentEnrichment"));
+    assertTrue(
+        thrown
+            .getMessage()
+            .contains(
+                "The model configured in the Update Request Processor 'dummy-chat-1' can't be found in the store: /schema/chat-model-store"));
+  }
+
+  @Test
+  public void processAdd_emptyInputField_shouldLogAndIndexWithNoEnrichedField() throws Exception {
+    loadTestChatModel("dummy-chat-model.json", "dummy-chat-1");
+    addWithChain(sdoc("id", "99", "string_field", ""), "documentEnrichment");
+    addWithChain(sdoc("id", "98", "string_field", "Vegeta is the saiyan prince."), "documentEnrichment");
+    assertU(commit());
+
+    final SolrQuery query = getEnrichmentQuery("enriched_field");
+
+    assertJQ(
+        "/query" + query.toQueryString(),
+        "/response/numFound==2]",
+        "/response/docs/[0]/id=='99'",
+        "!/response/docs/[0]/enriched_field==", // no enriched field for doc 99
+        "/response/docs/[1]/id=='98'",
+        "/response/docs/[1]/enriched_field=='enriched content'");
+  }
+
+  @Test
+  public void processAdd_nullInputField_shouldLogAndIndexWithNoEnrichedField() throws Exception {
+    loadTestChatModel("dummy-chat-model.json", "dummy-chat-1");
+    addWithChain(sdoc("id", "99", "string_field", "Vegeta is the saiyan prince."), "documentEnrichment");
+    assertU(adoc("id", "98")); // no string_field
+    assertU(commit());
+
+    final SolrQuery query = getEnrichmentQuery("enriched_field");
+
+    assertJQ(
+        "/query" + query.toQueryString(),
+        "/response/numFound==2]",
+        "/response/docs/[0]/id=='99'",
+        "/response/docs/[0]/enriched_field=='enriched content'",
+        "/response/docs/[1]/id=='98'",
+        "!/response/docs/[1]/enriched_field=="); // no enriched field for doc 98
+  }
+
+  @Test
+  public void processAdd_failingEnrichment_shouldLogAndIndexWithNoEnrichedField() throws Exception {
+    loadTestChatModel("exception-throwing-chat-model.json", "exception-throwing-chat-model");
+    addWithChain(sdoc("id", "99", "string_field", "Vegeta is the saiyan prince."), "failingDocumentEnrichment");
+    addWithChain(sdoc("id", "98", "string_field", "Kakaroth is a saiyan grown up on planet Earth."), "failingDocumentEnrichment");
+    assertU(commit());
+
+    final SolrQuery query = getEnrichmentQuery("enriched_field");
+
+    assertJQ(
+        "/query" + query.toQueryString(),
+        "/response/numFound==2]",
+        "/response/docs/[0]/id=='99'",
+        "!/response/docs/[0]/enriched_field==", // no enriched field for doc 99
+        "/response/docs/[1]/id=='98'",
+        "!/response/docs/[1]/enriched_field=="); // no enriched field for doc 98
+  }
+
+  @Test
+  public void processAtomicUpdate_shouldTriggerEnrichmentAndFetchTheStoredContent()
+      throws Exception {
+    // Verifies that when using a processor chain configured for partial updates
+    // (i.e., DistributedUpdateProcessorFactory before DocumentEnrichmentUpdateProcessorFactory),
+    // the system correctly retrieves the stored value of string_field and generates the
+    // enriched content for the document.
+    loadTestChatModel("dummy-chat-model.json", "dummy-chat-1");
+    assertU(adoc("id", "99", "string_field", "Vegeta is the saiyan prince."));
+    assertU(adoc("id", "98", "string_field", "Kakaroth is a saiyan grown up on planet Earth."));
+    assertU(commit());
+
+    SolrInputDocument atomicDoc = new SolrInputDocument();
+    atomicDoc.setField("id", "99");
+    atomicDoc.setField("enriched", Map.of("set", true));
+    addWithChain(atomicDoc, "documentEnrichmentForPartialUpdates");
+    assertU(commit());
+
+    final SolrQuery query = getEnrichmentQuery("enriched_field");
+
+    assertJQ(
+        "/query" + query.toQueryString(),
+        "/response/numFound==2]",
+        "/response/docs/[0]/id=='99'",
+        "/response/docs/[0]/enriched_field=='enriched content'",
+        "/response/docs/[1]/id=='98'",
+        "!/response/docs/[1]/enriched_field==" // no enriched field for document 98
+        );
+  }
+
+  @Test
+  public void processAtomicUpdate_shouldReplaceExistingEnrichedFieldNotAppend() throws Exception {

Review Comment:
   If this wasn't working, we should see "enriched content" two times?
   What about if this value is the old one and it was not recomputed at all?



##########
solr/modules/language-models/src/test/org/apache/solr/languagemodels/documentenrichment/update/processor/DocumentEnrichmentUpdateProcessorTest.java:
##########
@@ -0,0 +1,718 @@
+/*
+ * 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.solr.languagemodels.documentenrichment.update.processor;
+
+import java.io.IOException;
+import java.util.Map;
+import org.apache.solr.client.solrj.RemoteSolrException;
+import org.apache.solr.client.solrj.SolrServerException;
+import org.apache.solr.client.solrj.request.SolrQuery;
+import org.apache.solr.client.solrj.request.UpdateRequest;
+import org.apache.solr.common.SolrInputDocument;
+import org.apache.solr.languagemodels.TestLanguageModelBase;
+import org.apache.solr.languagemodels.documentenrichment.store.rest.ManagedChatModelStore;
+import org.junit.After;
+import org.junit.AfterClass;
+import org.junit.BeforeClass;
+import org.junit.Test;
+
+public class DocumentEnrichmentUpdateProcessorTest extends TestLanguageModelBase {
+
+  @BeforeClass
+  public static void init() throws Exception {
+    setupTest("solrconfig-document-enrichment.xml", "schema-language-models.xml", false, false);
+  }
+
+  @AfterClass
+  public static void cleanup() throws Exception {
+    afterTest();
+  }
+
+  private String loadedModelId;
+
+  @After
+  public void afterEachTest() throws Exception {
+    if (loadedModelId != null) {
+      restTestHarness.delete(ManagedChatModelStore.REST_END_POINT + "/" + loadedModelId);
+      loadedModelId = null;
+    }
+  }
+
+  private void loadTestChatModel(String fileName, String modelId) throws Exception {
+    loadChatModel(fileName);
+    loadedModelId = modelId;
+  }
+
+  @Test
+  public void processAdd_inputField_shouldEnrichInputField() throws Exception {
+    loadTestChatModel("dummy-chat-model.json", "dummy-chat-1");
+
+    addWithChain(sdoc("id", "99", "string_field", "Vegeta is the saiyan prince."), "documentEnrichment");
+    addWithChain(sdoc("id", "98", "string_field", "Kakaroth is a saiyan grown up on planet Earth."), "documentEnrichment");
+    assertU(commit());
+
+    final SolrQuery query = getEnrichmentQuery("enriched_field");
+
+    assertJQ(
+        "/query" + query.toQueryString(),
+        "/response/numFound==2]",
+        "/response/docs/[0]/id=='99'",
+        "/response/docs/[0]/enriched_field=='enriched content'",
+        "/response/docs/[1]/id=='98'",
+        "/response/docs/[1]/enriched_field=='enriched content'");
+  }
+
+  /*
+   This test looks for the 'dummy-chat-1' model, but such model is not loaded —
+   the model store is empty, so the update fails.
+  */
+  @Test
+  public void processAdd_modelNotFound_shouldThrowException() {
+    RuntimeException thrown =
+        assertThrows(
+            "model not found should throw an exception",
+            RemoteSolrException.class,
+            () ->
+                addWithChain(
+                    sdoc("id", "99", "string_field", "Vegeta is the saiyan prince."),
+                    "documentEnrichment"));
+    assertTrue(
+        thrown
+            .getMessage()
+            .contains(
+                "The model configured in the Update Request Processor 'dummy-chat-1' can't be found in the store: /schema/chat-model-store"));
+  }
+
+  @Test
+  public void processAdd_emptyInputField_shouldLogAndIndexWithNoEnrichedField() throws Exception {
+    loadTestChatModel("dummy-chat-model.json", "dummy-chat-1");
+    addWithChain(sdoc("id", "99", "string_field", ""), "documentEnrichment");
+    addWithChain(sdoc("id", "98", "string_field", "Vegeta is the saiyan prince."), "documentEnrichment");
+    assertU(commit());
+
+    final SolrQuery query = getEnrichmentQuery("enriched_field");
+
+    assertJQ(
+        "/query" + query.toQueryString(),
+        "/response/numFound==2]",
+        "/response/docs/[0]/id=='99'",
+        "!/response/docs/[0]/enriched_field==", // no enriched field for doc 99
+        "/response/docs/[1]/id=='98'",
+        "/response/docs/[1]/enriched_field=='enriched content'");
+  }
+
+  @Test
+  public void processAdd_nullInputField_shouldLogAndIndexWithNoEnrichedField() throws Exception {
+    loadTestChatModel("dummy-chat-model.json", "dummy-chat-1");
+    addWithChain(sdoc("id", "99", "string_field", "Vegeta is the saiyan prince."), "documentEnrichment");
+    assertU(adoc("id", "98")); // no string_field
+    assertU(commit());
+
+    final SolrQuery query = getEnrichmentQuery("enriched_field");
+
+    assertJQ(
+        "/query" + query.toQueryString(),
+        "/response/numFound==2]",
+        "/response/docs/[0]/id=='99'",
+        "/response/docs/[0]/enriched_field=='enriched content'",
+        "/response/docs/[1]/id=='98'",
+        "!/response/docs/[1]/enriched_field=="); // no enriched field for doc 98
+  }
+
+  @Test
+  public void processAdd_failingEnrichment_shouldLogAndIndexWithNoEnrichedField() throws Exception {
+    loadTestChatModel("exception-throwing-chat-model.json", "exception-throwing-chat-model");
+    addWithChain(sdoc("id", "99", "string_field", "Vegeta is the saiyan prince."), "failingDocumentEnrichment");
+    addWithChain(sdoc("id", "98", "string_field", "Kakaroth is a saiyan grown up on planet Earth."), "failingDocumentEnrichment");
+    assertU(commit());
+
+    final SolrQuery query = getEnrichmentQuery("enriched_field");
+
+    assertJQ(
+        "/query" + query.toQueryString(),
+        "/response/numFound==2]",
+        "/response/docs/[0]/id=='99'",
+        "!/response/docs/[0]/enriched_field==", // no enriched field for doc 99
+        "/response/docs/[1]/id=='98'",
+        "!/response/docs/[1]/enriched_field=="); // no enriched field for doc 98
+  }
+
+  @Test
+  public void processAtomicUpdate_shouldTriggerEnrichmentAndFetchTheStoredContent()
+      throws Exception {
+    // Verifies that when using a processor chain configured for partial updates
+    // (i.e., DistributedUpdateProcessorFactory before DocumentEnrichmentUpdateProcessorFactory),
+    // the system correctly retrieves the stored value of string_field and generates the
+    // enriched content for the document.
+    loadTestChatModel("dummy-chat-model.json", "dummy-chat-1");
+    assertU(adoc("id", "99", "string_field", "Vegeta is the saiyan prince."));
+    assertU(adoc("id", "98", "string_field", "Kakaroth is a saiyan grown up on planet Earth."));
+    assertU(commit());
+
+    SolrInputDocument atomicDoc = new SolrInputDocument();
+    atomicDoc.setField("id", "99");
+    atomicDoc.setField("enriched", Map.of("set", true));
+    addWithChain(atomicDoc, "documentEnrichmentForPartialUpdates");
+    assertU(commit());
+
+    final SolrQuery query = getEnrichmentQuery("enriched_field");
+
+    assertJQ(
+        "/query" + query.toQueryString(),
+        "/response/numFound==2]",
+        "/response/docs/[0]/id=='99'",
+        "/response/docs/[0]/enriched_field=='enriched content'",
+        "/response/docs/[1]/id=='98'",
+        "!/response/docs/[1]/enriched_field==" // no enriched field for document 98
+        );
+  }
+
+  @Test
+  public void processAtomicUpdate_shouldReplaceExistingEnrichedFieldNotAppend() throws Exception {
+    // Verifies that when a document already contains an enriched_field and string_field is
+    // modified via atomic update, the enriched content is recomputed and replaces the previous
+    // value rather than being appended.
+    loadTestChatModel("dummy-chat-model.json", "dummy-chat-1");
+    addWithChain(sdoc("id", "99", "string_field", "Vegeta is the saiyan prince."), "documentEnrichment");
+    addWithChain(sdoc("id", "98", "string_field", "Kakaroth is a saiyan grown up on planet Earth."), "documentEnrichment");
+    assertU(commit());
+
+    SolrInputDocument atomicDoc = new SolrInputDocument();
+    atomicDoc.setField("id", "99");
+    atomicDoc.setField("string_field", Map.of("set", "Vegeta is the saiyan prince from the Dragon Ball series."));
+    addWithChain(atomicDoc, "documentEnrichmentForPartialUpdates");
+    assertU(commit());
+
+    final SolrQuery query = getEnrichmentQuery("enriched_field");
+
+    assertJQ(
+        "/query" + query.toQueryString(),
+        "/response/numFound==2]",
+        "/response/docs/[0]/id=='99'",
+        "/response/docs/[0]/enriched_field=='enriched content'",
+        "/response/docs/[1]/id=='98'",
+        "/response/docs/[1]/enriched_field=='enriched content'");
+  }
+
+  // --- multi-field tests ---
+
+  @Test
+  public void processAdd_multipleInputFields_allPresent_shouldEnrichDocument() throws Exception {
+    loadTestChatModel("dummy-chat-model.json", "dummy-chat-1");
+
+    addWithChain(
+        sdoc("id", "99", "string_field", "Vegeta is the saiyan prince.", "body_field", "He is very proud."),
+        "documentEnrichmentMultiField");
+    addWithChain(
+        sdoc("id", "98", "string_field", "Kakaroth is a saiyan.", "body_field", "He grew up on Earth."),
+        "documentEnrichmentMultiField");
+    assertU(commit());
+
+    final SolrQuery query = getEnrichmentQuery("enriched_field");
+
+    assertJQ(
+        "/query" + query.toQueryString(),
+        "/response/numFound==2]",
+        "/response/docs/[0]/id=='99'",
+        "/response/docs/[0]/enriched_field=='enriched content'",
+        "/response/docs/[1]/id=='98'",
+        "/response/docs/[1]/enriched_field=='enriched content'");
+  }
+
+  @Test
+  public void processAdd_multipleInputFields_firstFieldNull_shouldSkipEnrichment() throws Exception {
+    loadTestChatModel("dummy-chat-model.json", "dummy-chat-1");
+
+    addWithChain(
+        sdoc("id", "99", "body_field", "He is very proud."), // string_field absent
+        "documentEnrichmentMultiField");
+    addWithChain(
+        sdoc("id", "98", "body_field", "He is very jealous."), // string_field absent
+        "documentEnrichmentMultiField");
+    assertU(commit());
+
+    final SolrQuery query = getEnrichmentQuery("enriched_field");
+
+    assertJQ(
+        "/query" + query.toQueryString(),
+        "/response/numFound==2]",
+        "/response/docs/[0]/id=='99'",
+        "!/response/docs/[0]/enriched_field==",
+        "/response/docs/[1]/id=='98'",
+        "!/response/docs/[1]/enriched_field==");
+  }
+
+  @Test
+  public void processAdd_multipleInputFields_secondFieldEmpty_shouldSkipEnrichment() throws Exception {
+    loadTestChatModel("dummy-chat-model.json", "dummy-chat-1");
+
+    addWithChain(
+        sdoc("id", "99", "string_field", "Vegeta is the saiyan prince.", "body_field", ""),
+        "documentEnrichmentMultiField");
+    addWithChain(
+        sdoc("id", "98", "string_field", "Goku is the best saiyan.", "body_field", ""),
+        "documentEnrichmentMultiField");
+    assertU(commit());
+
+    final SolrQuery query = getEnrichmentQuery("enriched_field");
+
+    assertJQ(
+        "/query" + query.toQueryString(),
+        "/response/numFound==2]",
+        "/response/docs/[0]/id=='99'",
+        "!/response/docs/[0]/enriched_field==",
+        "/response/docs/[1]/id=='98'",
+        "!/response/docs/[1]/enriched_field==");
+  }
+
+  @Test
+  public void processAdd_multipleInputFields_bothFieldsAbsent_shouldSkipEnrichment() throws Exception {
+    loadTestChatModel("dummy-chat-model.json", "dummy-chat-1");
+
+    addWithChain(sdoc("id", "99"), "documentEnrichmentMultiField");
+    addWithChain(sdoc("id", "98"), "documentEnrichmentMultiField");
+    assertU(commit());
+
+    final SolrQuery query = getEnrichmentQuery("enriched_field");
+
+    assertJQ(
+        "/query" + query.toQueryString(),
+        "/response/numFound==2]",
+        "/response/docs/[0]/id=='99'",
+        "!/response/docs/[0]/enriched_field==",
+        "/response/docs/[1]/id=='98'",
+        "!/response/docs/[1]/enriched_field==");
+  }
+
+  @Test
+  public void processAdd_multipleInputFields_failingModel_shouldLogAndSkipEnrichment() throws Exception {
+    loadTestChatModel("exception-throwing-chat-model.json", "exception-throwing-chat-model");
+
+    addWithChain(
+        sdoc("id", "99", "string_field", "Vegeta is the saiyan prince.", "body_field", "He is very proud."),
+        "failingDocumentEnrichmentMultiField");
+    addWithChain(
+        sdoc("id", "98", "string_field", "Kakaroth is a saiyan.", "body_field", "He grew up on Earth."),
+        "failingDocumentEnrichmentMultiField");
+    assertU(commit());
+
+    final SolrQuery query = getEnrichmentQuery("enriched_field");
+
+    assertJQ(
+        "/query" + query.toQueryString(),
+        "/response/numFound==2]",
+        "/response/docs/[0]/id=='99'",
+        "!/response/docs/[0]/enriched_field==",
+        "/response/docs/[1]/id=='98'",
+        "!/response/docs/[1]/enriched_field==");
+  }
+
+  @Test
+  public void processAdd_multivaluedStringOutputField_shouldPopulateAllValues() throws Exception {
+    loadTestChatModel("dummy-chat-model-multivalued-string.json", "dummy-chat-multivalued-1");
+
+    addWithChain(
+        sdoc("id", "99", "string_field", "Vegeta is the saiyan prince."),
+        "documentEnrichmentMultivaluedString");
+    addWithChain(
+        sdoc("id", "98", "string_field", "Kakaroth is a saiyan grown up on planet Earth."),
+        "documentEnrichmentMultivaluedString");
+    assertU(commit());
+
+    final SolrQuery query = getEnrichmentQuery("enriched_field_multi");
+
+    assertJQ(
+        "/query" + query.toQueryString(),
+        "/response/numFound==2]",
+        "/response/docs/[0]/id=='99'",
+        "/response/docs/[0]/enriched_field_multi/[0]=='tag1'",
+        "/response/docs/[0]/enriched_field_multi/[1]=='tag2'",
+        "/response/docs/[1]/id=='98'",
+        "/response/docs/[1]/enriched_field_multi/[0]=='tag1'",
+        "/response/docs/[1]/enriched_field_multi/[1]=='tag2'");
+  }
+
+  @Test
+  public void processAdd_multivaluedStringOutputField_emptyInput_shouldSkipEnrichment()
+      throws Exception {
+    loadTestChatModel("dummy-chat-model-multivalued-string.json", "dummy-chat-multivalued-1");
+
+    addWithChain(sdoc("id", "99", "string_field", ""), "documentEnrichmentMultivaluedString");
+    addWithChain(sdoc("id", "98", "string_field", ""), "documentEnrichmentMultivaluedString");
+    assertU(commit());
+
+    final SolrQuery query = getEnrichmentQuery("enriched_field_multi");
+
+    assertJQ(
+        "/query" + query.toQueryString(),
+        "/response/numFound==2]",
+        "/response/docs/[0]/id=='99'",
+        "!/response/docs/[0]/enriched_field_multi==",
+        "/response/docs/[1]/id=='98'",
+        "!/response/docs/[1]/enriched_field_multi==");
+  }
+
+  // --- typed single-valued output field tests ---
+
+  @Test
+  public void processAdd_singleLongOutputField_shouldPopulateValue() throws Exception {
+    loadTestChatModel("dummy-chat-model-single-long.json", "dummy-long");
+
+    addWithChain(sdoc("id", "99", "string_field", "some content"), "documentEnrichmentSingleLong");
+    addWithChain(sdoc("id", "98", "string_field", "other content"), "documentEnrichmentSingleLong");
+    assertU(commit());
+
+    final SolrQuery query = getEnrichmentQuery("output_long");
+
+    assertJQ(
+        "/query" + query.toQueryString(),
+        "/response/numFound==2]",
+        "/response/docs/[0]/id=='99'",
+        "/response/docs/[0]/output_long==42",
+        "/response/docs/[1]/id=='98'",
+        "/response/docs/[1]/output_long==42");
+  }
+
+  @Test
+  public void processAdd_singleIntOutputField_shouldPopulateValue() throws Exception {
+    loadTestChatModel("dummy-chat-model-single-int.json", "dummy-int");
+
+    addWithChain(sdoc("id", "99", "string_field", "some content"), "documentEnrichmentSingleInt");
+    addWithChain(sdoc("id", "98", "string_field", "other content"), "documentEnrichmentSingleInt");
+    assertU(commit());
+
+    final SolrQuery query = getEnrichmentQuery("output_int");
+
+    assertJQ(
+        "/query" + query.toQueryString(),
+        "/response/numFound==2]",
+        "/response/docs/[0]/id=='99'",
+        "/response/docs/[0]/output_int==7",
+        "/response/docs/[1]/id=='98'",
+        "/response/docs/[1]/output_int==7");
+  }
+
+  @Test
+  public void processAdd_singleFloatOutputField_shouldPopulateValue() throws Exception {
+    loadTestChatModel("dummy-chat-model-single-float.json", "dummy-float");
+
+    addWithChain(sdoc("id", "99", "string_field", "some content"), "documentEnrichmentSingleFloat");
+    addWithChain(sdoc("id", "98", "string_field", "other content"), "documentEnrichmentSingleFloat");
+    assertU(commit());
+
+    final SolrQuery query = getEnrichmentQuery("output_float");
+
+    assertJQ(
+        "/query" + query.toQueryString(),
+        "/response/numFound==2]",
+        "/response/docs/[0]/id=='99'",
+        "/response/docs/[0]/output_float==1.5",
+        "/response/docs/[1]/id=='98'",
+        "/response/docs/[1]/output_float==1.5");
+  }
+
+  @Test
+  public void processAdd_singleDoubleOutputField_shouldPopulateValue() throws Exception {
+    loadTestChatModel("dummy-chat-model-single-double.json", "dummy-double");
+
+    addWithChain(
+        sdoc("id", "99", "string_field", "some content"), "documentEnrichmentSingleDouble");
+    addWithChain(
+        sdoc("id", "98", "string_field", "other content"), "documentEnrichmentSingleDouble");
+    assertU(commit());
+
+    final SolrQuery query = getEnrichmentQuery("output_double");
+
+    assertJQ(
+        "/query" + query.toQueryString(),
+        "/response/numFound==2]",
+        "/response/docs/[0]/id=='99'",
+        "/response/docs/[0]/output_double==2.5",
+        "/response/docs/[1]/id=='98'",
+        "/response/docs/[1]/output_double==2.5");
+  }
+
+  @Test
+  public void processAdd_singleBooleanOutputField_shouldPopulateValue() throws Exception {
+    loadTestChatModel("dummy-chat-model-single-boolean.json", "dummy-boolean");
+
+    addWithChain(
+        sdoc("id", "99", "string_field", "some content"), "documentEnrichmentSingleBoolean");
+    addWithChain(
+        sdoc("id", "98", "string_field", "other content"), "documentEnrichmentSingleBoolean");
+    assertU(commit());
+
+    final SolrQuery query = getEnrichmentQuery("output_boolean");
+
+    assertJQ(
+        "/query" + query.toQueryString(),
+        "/response/numFound==2]",
+        "/response/docs/[0]/id=='99'",
+        "/response/docs/[0]/output_boolean==true",
+        "/response/docs/[1]/id=='98'",
+        "/response/docs/[1]/output_boolean==true");
+  }
+
+  @Test
+  public void processAdd_singleDateOutputField_shouldPopulateValue() throws Exception {
+    loadTestChatModel("dummy-chat-model-single-date.json", "dummy-date");
+
+    addWithChain(sdoc("id", "99", "string_field", "some content"), "documentEnrichmentSingleDate");
+    addWithChain(sdoc("id", "98", "string_field", "other content"), "documentEnrichmentSingleDate");
+    assertU(commit());
+
+    final SolrQuery query = getEnrichmentQuery("output_date");
+
+    assertJQ(
+        "/query" + query.toQueryString(),
+        "/response/numFound==2]",
+        "/response/docs/[0]/id=='99'",
+        "/response/docs/[0]/output_date=='2024-01-15T00:00:00Z'",
+        "/response/docs/[1]/id=='98'",
+        "/response/docs/[1]/output_date=='2024-01-15T00:00:00Z'");
+  }
+
+  // --- typed multivalued output field tests ---
+
+  @Test
+  public void processAdd_multivaluedLongOutputField_shouldPopulateAllValues() throws Exception {
+    loadTestChatModel("dummy-chat-model-multivalued-long.json", "dummy-long-multi");
+
+    addWithChain(
+        sdoc("id", "99", "string_field", "some content"), "documentEnrichmentMultivaluedLong");
+    addWithChain(
+        sdoc("id", "98", "string_field", "other content"), "documentEnrichmentMultivaluedLong");
+    assertU(commit());
+
+    final SolrQuery query = getEnrichmentQuery("output_long_multi");
+
+    assertJQ(
+        "/query" + query.toQueryString(),
+        "/response/numFound==2]",
+        "/response/docs/[0]/id=='99'",
+        "/response/docs/[0]/output_long_multi/[0]==10",
+        "/response/docs/[0]/output_long_multi/[1]==20",
+        "/response/docs/[0]/output_long_multi/[2]==30",
+        "/response/docs/[1]/id=='98'",
+        "/response/docs/[1]/output_long_multi/[0]==10",
+        "/response/docs/[1]/output_long_multi/[1]==20",
+        "/response/docs/[1]/output_long_multi/[2]==30");
+  }
+
+  @Test
+  public void processAdd_multivaluedIntOutputField_shouldPopulateAllValues() throws Exception {
+    loadTestChatModel("dummy-chat-model-multivalued-int.json", "dummy-int-multi");
+
+    addWithChain(
+        sdoc("id", "99", "string_field", "some content"), "documentEnrichmentMultivaluedInt");
+    addWithChain(
+        sdoc("id", "98", "string_field", "other content"), "documentEnrichmentMultivaluedInt");
+    assertU(commit());
+
+    final SolrQuery query = getEnrichmentQuery("output_int_multi");
+
+    assertJQ(
+        "/query" + query.toQueryString(),
+        "/response/numFound==2]",
+        "/response/docs/[0]/id=='99'",
+        "/response/docs/[0]/output_int_multi/[0]==1",
+        "/response/docs/[0]/output_int_multi/[1]==2",
+        "/response/docs/[1]/id=='98'",
+        "/response/docs/[1]/output_int_multi/[0]==1",
+        "/response/docs/[1]/output_int_multi/[1]==2");
+  }
+
+  @Test
+  public void processAdd_multivaluedFloatOutputField_shouldPopulateAllValues() throws Exception {
+    loadTestChatModel("dummy-chat-model-multivalued-float.json", "dummy-float-multi");
+
+    addWithChain(
+        sdoc("id", "99", "string_field", "some content"), "documentEnrichmentMultivaluedFloat");
+    addWithChain(
+        sdoc("id", "98", "string_field", "other content"), "documentEnrichmentMultivaluedFloat");
+    assertU(commit());
+
+    final SolrQuery query = getEnrichmentQuery("output_float_multi");
+
+    assertJQ(
+        "/query" + query.toQueryString(),
+        "/response/numFound==2]",
+        "/response/docs/[0]/id=='99'",
+        "/response/docs/[0]/output_float_multi/[0]==1.5",
+        "/response/docs/[0]/output_float_multi/[1]==2.5",
+        "/response/docs/[1]/id=='98'",
+        "/response/docs/[1]/output_float_multi/[0]==1.5",
+        "/response/docs/[1]/output_float_multi/[1]==2.5");
+  }
+
+  @Test
+  public void processAdd_multivaluedDoubleOutputField_shouldPopulateAllValues() throws Exception {
+    loadTestChatModel("dummy-chat-model-multivalued-double.json", "dummy-double-multi");
+
+    addWithChain(
+        sdoc("id", "99", "string_field", "some content"), "documentEnrichmentMultivaluedDouble");
+    addWithChain(
+        sdoc("id", "98", "string_field", "other content"), "documentEnrichmentMultivaluedDouble");
+    assertU(commit());
+
+    final SolrQuery query = getEnrichmentQuery("output_double_multi");
+
+    assertJQ(
+        "/query" + query.toQueryString(),
+        "/response/numFound==2]",
+        "/response/docs/[0]/id=='99'",
+        "/response/docs/[0]/output_double_multi/[0]==3.14",
+        "/response/docs/[0]/output_double_multi/[1]==2.71",
+        "/response/docs/[1]/id=='98'",
+        "/response/docs/[1]/output_double_multi/[0]==3.14",
+        "/response/docs/[1]/output_double_multi/[1]==2.71");
+  }
+
+  @Test
+  public void processAdd_multivaluedBooleanOutputField_shouldPopulateAllValues() throws Exception {
+    loadTestChatModel("dummy-chat-model-multivalued-boolean.json", "dummy-boolean-multi");
+
+    addWithChain(
+        sdoc("id", "99", "string_field", "some content"), "documentEnrichmentMultivaluedBoolean");
+    addWithChain(
+        sdoc("id", "98", "string_field", "other content"), "documentEnrichmentMultivaluedBoolean");
+    assertU(commit());
+
+    final SolrQuery query = getEnrichmentQuery("output_boolean_multi");
+
+    assertJQ(
+        "/query" + query.toQueryString(),
+        "/response/numFound==2]",
+        "/response/docs/[0]/id=='99'",
+        "/response/docs/[0]/output_boolean_multi/[0]==true",
+        "/response/docs/[0]/output_boolean_multi/[1]==false",
+        "/response/docs/[1]/id=='98'",
+        "/response/docs/[1]/output_boolean_multi/[0]==true",
+        "/response/docs/[1]/output_boolean_multi/[1]==false");
+  }
+
+  @Test
+  public void processAdd_multivaluedDateOutputField_shouldPopulateAllValues() throws Exception {
+    loadTestChatModel("dummy-chat-model-multivalued-date.json", "dummy-date-multi");
+
+    addWithChain(
+        sdoc("id", "99", "string_field", "some content"), "documentEnrichmentMultivaluedDate");
+    addWithChain(
+        sdoc("id", "98", "string_field", "other content"), "documentEnrichmentMultivaluedDate");
+    assertU(commit());
+
+    final SolrQuery query = getEnrichmentQuery("output_date_multi");
+
+    assertJQ(
+        "/query" + query.toQueryString(),
+        "/response/numFound==2]",
+        "/response/docs/[0]/id=='99'",
+        "/response/docs/[0]/output_date_multi/[0]=='2024-01-15T00:00:00Z'",
+        "/response/docs/[0]/output_date_multi/[1]=='2025-06-30T00:00:00Z'",
+        "/response/docs/[1]/id=='98'",
+        "/response/docs/[1]/output_date_multi/[0]=='2024-01-15T00:00:00Z'",
+        "/response/docs/[1]/output_date_multi/[1]=='2025-06-30T00:00:00Z'");
+  }
+
+  // --- LLM response contract violation tests ---
+
+  @Test
+  public void processAdd_llmResponseMissingValueKey_shouldLogAndIndexWithNoEnrichedField()
+      throws Exception {
+    // Model returns valid JSON but without the required "value" key
+    loadTestChatModel("dummy-chat-model-missing-value-key.json", "dummy-chat-1");
+
+    addWithChain(sdoc("id", "99", "string_field", "Vegeta is the saiyan prince."), "documentEnrichment");
+    addWithChain(sdoc("id", "98", "string_field", "Kakaroth is a saiyan grown up on planet Earth."), "documentEnrichment");
+    assertU(commit());
+
+    final SolrQuery query = getEnrichmentQuery("enriched_field");
+
+    assertJQ(
+        "/query" + query.toQueryString(),
+        "/response/numFound==2]",
+        "/response/docs/[0]/id=='99'",
+        "!/response/docs/[0]/enriched_field==",
+        "/response/docs/[1]/id=='98'",
+        "!/response/docs/[1]/enriched_field==");
+  }
+
+  @Test
+  public void processAdd_llmResponseMalformedJson_shouldLogAndIndexWithNoEnrichedField()
+      throws Exception {
+    // Model returns a plain string that cannot be parsed as JSON
+    loadTestChatModel("dummy-chat-model-malformed-json.json", "dummy-chat-1");
+
+    addWithChain(sdoc("id", "99", "string_field", "Vegeta is the saiyan prince."), "documentEnrichment");
+    addWithChain(sdoc("id", "98", "string_field", "Kakaroth is a saiyan grown up on planet Earth."), "documentEnrichment");
+    assertU(commit());
+
+    final SolrQuery query = getEnrichmentQuery("enriched_field");
+
+    assertJQ(
+        "/query" + query.toQueryString(),
+        "/response/numFound==2]",
+        "/response/docs/[0]/id=='99'",
+        "!/response/docs/[0]/enriched_field==",
+        "/response/docs/[1]/id=='98'",
+        "!/response/docs/[1]/enriched_field==");
+  }
+
+  // --- multivalued output field / scalar response test ---
+
+  @Test
+  public void processAdd_multivaluedOutputField_scalarLlmResponse_shouldStoreSingleValue()

Review Comment:
   processAdd_multivaluedOutputField_singleValuedLlmResponse_shouldStoreSingleValue



##########
solr/modules/language-models/src/test-files/solr/collection1/conf/solrconfig-document-enrichment-update-request-processor-only.xml:
##########
@@ -0,0 +1,62 @@
+<?xml version="1.0" ?>

Review Comment:
   Why is this needed?



-- 
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: issues-unsubscribe@solr.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


---------------------------------------------------------------------
To unsubscribe, e-mail: issues-unsubscribe@solr.apache.org
For additional commands, e-mail: issues-help@solr.apache.org


Re: [PR] SOLR-18187: Document enrichment with LLMs [solr]

Posted by "aruggero (via GitHub)" <gi...@apache.org>.
aruggero commented on code in PR #4259:
URL: https://github.com/apache/solr/pull/4259#discussion_r3137649258


##########
solr/modules/language-models/src/java/org/apache/solr/languagemodels/documentenrichment/model/SolrFieldGenerationModel.java:
##########
@@ -0,0 +1,256 @@
+/*
+ * 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.solr.languagemodels.documentenrichment.model;
+
+import dev.langchain4j.data.message.UserMessage;
+import dev.langchain4j.model.chat.Capability;
+import dev.langchain4j.model.chat.ChatModel;
+import dev.langchain4j.model.chat.request.ChatRequest;
+import dev.langchain4j.model.chat.request.ResponseFormat;
+import dev.langchain4j.model.chat.request.json.JsonArraySchema;
+import dev.langchain4j.model.chat.request.json.JsonBooleanSchema;
+import dev.langchain4j.model.chat.request.json.JsonIntegerSchema;
+import dev.langchain4j.model.chat.request.json.JsonNumberSchema;
+import dev.langchain4j.model.chat.request.json.JsonObjectSchema;
+import dev.langchain4j.model.chat.request.json.JsonSchemaElement;
+import dev.langchain4j.model.chat.request.json.JsonStringSchema;
+import java.lang.invoke.MethodHandles;
+import java.lang.reflect.Method;
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.EnumSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+import org.apache.lucene.util.Accountable;
+import org.apache.lucene.util.RamUsageEstimator;
+import org.apache.solr.common.SolrException;
+import org.apache.solr.common.util.Utils;
+import org.apache.solr.core.SolrResourceLoader;
+import org.apache.solr.languagemodels.documentenrichment.update.processor.DocumentEnrichmentUpdateProcessorFactory;
+import org.apache.solr.languagemodels.documentenrichment.store.FieldGenerationModelException;
+import org.apache.solr.languagemodels.documentenrichment.store.rest.ManagedFieldGenerationModelStore;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * This object wraps a {@link dev.langchain4j.model.chat.ChatModel} to produce the content of a
+ * field based on the content of other fields specified as input. It's meant to be used as a managed
+ * resource with the {@link ManagedFieldGenerationModelStore}
+ */
+public class SolrFieldGenerationModel implements Accountable {
+  private static final Logger log = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());
+  private static final long BASE_RAM_BYTES =
+      RamUsageEstimator.shallowSizeOfInstance(SolrFieldGenerationModel.class);
+  private static final String TIMEOUT_PARAM = "timeout";
+  private static final String MAX_RETRIES_PARAM = "maxRetries";
+  private static final String THINKING_BUDGET_TOKENS = "thinkingBudgetTokens";
+  private static final String RANDOM_SEED = "randomSeed";
+
+  private final String name;
+  private final Map<String, Object> params;
+  private final ChatModel chatModel;
+  private final int hashCode;
+
+  public static SolrFieldGenerationModel getInstance(
+      SolrResourceLoader solrResourceLoader,
+      String className,
+      String name,
+      Map<String, Object> params)
+      throws FieldGenerationModelException {
+    try {
+      /*
+       * The idea here is to build a {@link dev.langchain4j.model.chat.ChatModel} using inversion
+       * of control.
+       * Each model has its own list of parameters we don't know beforehand, but each {@link dev.langchain4j.model.chat.ChatModel} class
+       * has its own builder that uses setters with the same name of the parameter in input.
+       * */
+      ChatModel chatModel;
+      Class<?> modelClass = solrResourceLoader.findClass(className, ChatModel.class);
+      var builder = modelClass.getMethod("builder").invoke(null);
+      if (params != null) {
+        /*
+         * This block of code has the responsibility of instantiate a {@link
+         * dev.langchain4j.model.chat.ChatModel} using the params provided. Classes have
+         * params of the specific implementation of {@link
+         * dev.langchain4j.model.chat.ChatModel}, which is not known beforehand. So we benefit of
+         * the design choice in langchain4j that each subclass implementing {@link
+         * dev.langchain4j.model.chat.ChatModel} uses setters with the same name of the
+         * param.
+         */
+        for (String paramName : params.keySet()) {
+          /*
+           * When a param is not primitive, we need to instantiate the object explicitly and then call the
+           * setter method.
+           * N.B. when adding support to new models, pay attention to all the parameters they
+           * support, some of them may require to be handled in here as separate switch cases
+           */
+          switch (paramName) {
+            case TIMEOUT_PARAM -> builder
+                .getClass()
+                .getMethod(paramName, Duration.class)
+                .invoke(builder, Duration.ofSeconds((Long) params.get(paramName)));
+
+            case MAX_RETRIES_PARAM, THINKING_BUDGET_TOKENS, RANDOM_SEED -> builder
+                .getClass()
+                .getMethod(paramName, Integer.class)
+                .invoke(builder, ((Long) params.get(paramName)).intValue());
+
+              /*
+               * For primitive params if there's only one setter available, we call it.
+               * If there's choice we default to the string one
+               */
+            default -> {
+              ArrayList<Method> paramNameMatches = new ArrayList<>();
+              for (var method : builder.getClass().getMethods()) {
+                if (paramName.equals(method.getName()) && method.getParameterCount() == 1) {
+                  paramNameMatches.add(method);
+                }
+              }
+              if (paramNameMatches.size() == 1) {
+                paramNameMatches.getFirst().invoke(builder, params.get(paramName));
+              } else {
+                try {
+                  builder
+                      .getClass()
+                      .getMethod(paramName, String.class)
+                      .invoke(builder, params.get(paramName).toString());
+                } catch (NoSuchMethodException e) {
+                  log.error("Parameter {} not supported by model {}", paramName, className);
+                  throw new SolrException(SolrException.ErrorCode.BAD_REQUEST, e.getMessage(), e);
+                }
+              }
+            }
+          }
+        }
+      }
+
+      // Always enforce strict schema adherence where supported. For Anthropic and Google it's enabled by default
+      if (!"dev.langchain4j.model.anthropic.AnthropicChatModel".equals(className) &&
+          !"dev.langchain4j.model.googleai.GoogleAiGeminiChatModel".equals(className)) {
+        try {
+          builder
+              .getClass()
+              .getMethod("strictJsonSchema", Boolean.class)
+              .invoke(builder, true);
+        } catch (NoSuchMethodException ignored) {
+          log.debug("Model {} does not have strictJsonSchema param, structured output is not enforced", className);
+        }
+      }
+      chatModel = (ChatModel) builder.getClass().getMethod("build").invoke(builder);
+      return new SolrFieldGenerationModel(name, chatModel, params);
+    } catch (final Exception e) {
+      throw new FieldGenerationModelException("Model loading failed for " + className, e);
+    }
+  }
+
+  public SolrFieldGenerationModel(String name, ChatModel chatModel, Map<String, Object> params) {
+    this.name = name;
+    this.chatModel = chatModel;
+    this.params = params;
+    this.hashCode = calculateHashCode();
+  }
+
+  /**
+   * Sends a structured chat request and returns the parsed value from the {@code {"value": ...}}
+   * JSON object that the model is instructed to produce via {@code responseFormat}.
+   *
+   * @return the extracted value: a {@link String}, {@link Number}, {@link Integer}, {@link
+   *     Boolean}, or {@link java.util.List} depending on the Solr output field type
+   */
+  public Object generateFieldValue(String prompt, ResponseFormat responseFormat) {
+    ChatRequest chatRequest =
+        ChatRequest.builder()
+            .responseFormat(responseFormat)
+            .messages(UserMessage.from(prompt))
+            .build();
+    String rawJson = chatModel.chat(chatRequest).aiMessage().text();
+    Object parsed = Utils.fromJSONString(rawJson);
+    // It makes sense to keep this due to Ollama support

Review Comment:
   I would change the comment
   "Ollama support" is not clear to me



##########
solr/modules/language-models/src/test-files/solr/collection1/conf/schema-language-models.xml:
##########
@@ -36,11 +40,50 @@
   <field name="2048_byte_vector" type="high_dimensional_byte_knn_vector" indexed="true" stored="true" />
   <field name="2048_float_vector" type="high_dimensional_float_knn_vector" indexed="true" stored="true" />
   <field name="string_field" type="string" indexed="true" stored="true" multiValued="false" required="false"/>
+  <field name="body_field" type="string" indexed="true" stored="true" multiValued="false" required="false"/>
+  <field name="tags_field" type="string" indexed="true" stored="true" multiValued="true" required="false"/>
+  <field name="enriched_field" type="string" indexed="true" stored="true" multiValued="false" required="false"/>
+  <field name="enriched_field_multi" type="string" indexed="true" stored="true" multiValued="true" required="false"/>
+
+  <!-- single-valued output fields for each supported type -->
+  <field name="output_long"    type="plong"   indexed="true" stored="true" multiValued="false"/>
+  <field name="output_int"     type="pint"    indexed="true" stored="true" multiValued="false"/>
+  <field name="output_float"   type="pfloat"  indexed="true" stored="true" multiValued="false"/>
+  <field name="output_double"  type="pdouble" indexed="true" stored="true" multiValued="false"/>
+  <field name="output_boolean" type="boolean" indexed="true" stored="true" multiValued="false"/>
+  <field name="output_date"    type="pdate"   indexed="true" stored="true" multiValued="false"/>
+
+  <!-- multivalued output fields for each supported type -->
+  <field name="output_long_multi"    type="plong"   indexed="true" stored="true" multiValued="true"/>
+  <field name="output_int_multi"     type="pint"    indexed="true" stored="true" multiValued="true"/>
+  <field name="output_float_multi"   type="pfloat"  indexed="true" stored="true" multiValued="true"/>
+  <field name="output_double_multi"  type="pdouble" indexed="true" stored="true" multiValued="true"/>
+  <field name="output_boolean_multi" type="boolean" indexed="true" stored="true" multiValued="true"/>
+  <field name="output_date_multi"    type="pdate"   indexed="true" stored="true" multiValued="true"/>
+  <field name="output_binary"        type="binary"           indexed="false" stored="true"  multiValued="false"/>
+
+  <!-- output fields for unsupported types -->
+  <field name="output_bynary"     type="binary"   indexed="true"  stored="true"  multiValued="false"/>
+
+  <!-- output fields for types explicitly unsupported (without they are supported via inheritance) -->

Review Comment:
   What does this comment mean?



##########
solr/modules/language-models/src/test/org/apache/solr/languagemodels/documentenrichment/model/DummyChatModel.java:
##########
@@ -0,0 +1,85 @@
+/*

Review Comment:
   Didn't we remove any "chatModel" reference from the contribution?



##########
solr/modules/language-models/src/test/org/apache/solr/languagemodels/documentenrichment/update/processor/DocumentEnrichmentUpdateProcessorFactoryTest.java:
##########
@@ -354,64 +325,60 @@ public void init_multivaluedStringOutputField_shouldNotThrowException() throws E
         createUpdateProcessor(
             List.of("string_field"), "enriched_field_multi", null, collection1, "model1");
     assertNotNull(instance);
-    restTestHarness.delete(ManagedChatModelStore.REST_END_POINT + "/model1");
+    restTestHarness.delete(ManagedFieldGenerationModelStore.REST_END_POINT + "/model1");
   }
 
-  /* buildResponseFormat tests for field types from the Solr documentation */
+  /* buildResponseFormat tests for unsupported field types from the Solr documentation: 1 general (Binary) + 3 via inheritance */

Review Comment:
   What does this comment mean?



##########
solr/modules/language-models/src/test/org/apache/solr/languagemodels/documentenrichment/model/DummyChatModelTest.java:
##########
@@ -0,0 +1,48 @@
+/*

Review Comment:
   Didn't we remove any "chatModel" reference from the contribution?



##########
solr/modules/language-models/src/test/org/apache/solr/languagemodels/documentenrichment/model/ExceptionThrowingChatModel.java:
##########
@@ -0,0 +1,48 @@
+/*

Review Comment:
   Didn't we remove any "chatModel" reference from the contribution?



##########
solr/modules/language-models/src/test-files/solr/collection1/conf/schema-language-models.xml:
##########
@@ -36,11 +40,50 @@
   <field name="2048_byte_vector" type="high_dimensional_byte_knn_vector" indexed="true" stored="true" />
   <field name="2048_float_vector" type="high_dimensional_float_knn_vector" indexed="true" stored="true" />
   <field name="string_field" type="string" indexed="true" stored="true" multiValued="false" required="false"/>
+  <field name="body_field" type="string" indexed="true" stored="true" multiValued="false" required="false"/>
+  <field name="tags_field" type="string" indexed="true" stored="true" multiValued="true" required="false"/>
+  <field name="enriched_field" type="string" indexed="true" stored="true" multiValued="false" required="false"/>
+  <field name="enriched_field_multi" type="string" indexed="true" stored="true" multiValued="true" required="false"/>
+
+  <!-- single-valued output fields for each supported type -->
+  <field name="output_long"    type="plong"   indexed="true" stored="true" multiValued="false"/>
+  <field name="output_int"     type="pint"    indexed="true" stored="true" multiValued="false"/>
+  <field name="output_float"   type="pfloat"  indexed="true" stored="true" multiValued="false"/>
+  <field name="output_double"  type="pdouble" indexed="true" stored="true" multiValued="false"/>
+  <field name="output_boolean" type="boolean" indexed="true" stored="true" multiValued="false"/>
+  <field name="output_date"    type="pdate"   indexed="true" stored="true" multiValued="false"/>
+
+  <!-- multivalued output fields for each supported type -->
+  <field name="output_long_multi"    type="plong"   indexed="true" stored="true" multiValued="true"/>
+  <field name="output_int_multi"     type="pint"    indexed="true" stored="true" multiValued="true"/>
+  <field name="output_float_multi"   type="pfloat"  indexed="true" stored="true" multiValued="true"/>
+  <field name="output_double_multi"  type="pdouble" indexed="true" stored="true" multiValued="true"/>
+  <field name="output_boolean_multi" type="boolean" indexed="true" stored="true" multiValued="true"/>
+  <field name="output_date_multi"    type="pdate"   indexed="true" stored="true" multiValued="true"/>
+  <field name="output_binary"        type="binary"           indexed="false" stored="true"  multiValued="false"/>
+
+  <!-- output fields for unsupported types -->
+  <field name="output_bynary"     type="binary"   indexed="true"  stored="true"  multiValued="false"/>

Review Comment:
   Why do we have both binary and bynary?



##########
solr/modules/language-models/src/java/org/apache/solr/languagemodels/documentenrichment/update/processor/DocumentEnrichmentUpdateProcessorFactory.java:
##########
@@ -0,0 +1,324 @@
+/*
+ * 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.solr.languagemodels.documentenrichment.update.processor;
+
+import dev.langchain4j.model.chat.request.ResponseFormat;
+import dev.langchain4j.model.chat.request.ResponseFormatType;
+import dev.langchain4j.model.chat.request.json.JsonArraySchema;
+import dev.langchain4j.model.chat.request.json.JsonBooleanSchema;
+import dev.langchain4j.model.chat.request.json.JsonIntegerSchema;
+import dev.langchain4j.model.chat.request.json.JsonNumberSchema;
+import dev.langchain4j.model.chat.request.json.JsonObjectSchema;
+import dev.langchain4j.model.chat.request.json.JsonSchema;
+import dev.langchain4j.model.chat.request.json.JsonSchemaElement;
+import dev.langchain4j.model.chat.request.json.JsonStringSchema;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import java.util.Collection;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import org.apache.solr.common.SolrException;
+import org.apache.solr.common.params.RequiredSolrParams;
+import org.apache.solr.common.params.SolrParams;
+import org.apache.solr.common.util.NamedList;
+import org.apache.solr.core.SolrCore;
+import org.apache.solr.core.SolrResourceLoader;
+import org.apache.solr.languagemodels.documentenrichment.model.SolrFieldGenerationModel;
+import org.apache.solr.languagemodels.documentenrichment.store.rest.ManagedFieldGenerationModelStore;
+import org.apache.solr.request.SolrQueryRequest;
+import org.apache.solr.response.SolrQueryResponse;
+import org.apache.solr.rest.ManagedResource;
+import org.apache.solr.rest.ManagedResourceObserver;
+import org.apache.solr.schema.BoolField;
+import org.apache.solr.schema.DatePointField;
+import org.apache.solr.schema.DenseVectorField;
+import org.apache.solr.schema.DoublePointField;
+import org.apache.solr.schema.FieldType;
+import org.apache.solr.schema.FloatPointField;
+import org.apache.solr.schema.IndexSchema;
+import org.apache.solr.schema.IntPointField;
+import org.apache.solr.schema.LongPointField;
+import org.apache.solr.schema.NestPathField;
+import org.apache.solr.schema.SchemaField;
+import org.apache.solr.schema.StrField;
+import org.apache.solr.schema.TextField;
+import org.apache.solr.schema.UUIDField;
+import org.apache.solr.update.processor.UpdateRequestProcessor;
+import org.apache.solr.update.processor.UpdateRequestProcessorFactory;
+import org.apache.solr.util.plugin.SolrCoreAware;
+
+/**
+ * Generate the content of {@code outputField} based on other fields specified as {@code inputField}s.
+ *
+ * <p>The following validation rules are applied:
+ *
+ * <ul>
+ *   <li>At least one {@code inputField} must be declared.
+ *   <li>Exactly one of {@code prompt} or {@code promptFile} must be provided.
+ *   <li>Every declared {@code inputField} must have a corresponding {@code {fieldName}} placeholder
+ *       in the prompt.
+ *   <li>Every {@code {placeholder}} in the prompt must correspond to a declared {@code inputField}.
+ *   <li>One and only one {@code outputField} is allowed.
+ * </ul>
+ *
+ * <pre class="prettyprint" >
+ * &lt;processor class=&quot;solr.llm.documentenrichment.update.processor.DocumentEnrichmentUpdateProcessorFactory&quot;&gt;
+ *   &lt;str name=&quot;inputField&quot;&gt;title_field&lt;/str&gt;
+ *   &lt;str name=&quot;inputField&quot;&gt;body_field&lt;/str&gt;
+ *   &lt;str name=&quot;outputField&quot;&gt;enriched_field&lt;/str&gt;
+ *   &lt;str name=&quot;prompt&quot;&gt;Title: {title_field}. Body: {body_field}.&lt;/str&gt; // or &lt;str name=&quot;promptFile&quot;&gt;prompt.txt&lt;/str&gt;
+ *   &lt;str name=&quot;model&quot;&gt;model-name&lt;/str&gt;
+ * &lt;/processor&gt;
+ * </pre>
+ *
+ * <p>Multiple {@code inputField} values can also be declared as an array using {@code arr}:
+ *
+ * <pre class="prettyprint" >
+ * &lt;arr name=&quot;inputField&quot;&gt;
+ *   &lt;str&gt;title_field&lt;/str&gt;
+ *   &lt;str&gt;body_field&lt;/str&gt;
+ * &lt;/arr&gt;
+ * </pre>
+ */
+public class DocumentEnrichmentUpdateProcessorFactory extends UpdateRequestProcessorFactory
+    implements SolrCoreAware, ManagedResourceObserver {
+  private static final String INPUT_FIELD_PARAM = "inputField";
+  private static final String OUTPUT_FIELD_PARAM = "outputField";
+  private static final String PROMPT = "prompt";
+  private static final String PROMPT_FILE = "promptFile";
+  private static final String MODEL_NAME = "model";
+  private static final String JSON_SCHEMA_NAME = "output";
+  public static final String JSON_FIELD_PROPERTY = "value";
+  private static final Pattern PLACEHOLDER_PATTERN = Pattern.compile("\\{([^}]+)\\}");
+
+  private List<String> inputFields;
+  private String outputField;
+  private String promptText;
+  private String promptFile;
+  private String modelName;
+
+  @Override
+  public void init(final NamedList<?> args) {
+    // removeConfigArgs handles both multiple <str name="inputField"> and <arr name="inputField">
+    // and must be called before toSolrParams() since it mutates args in place
+    Collection<String> fieldNames = args.removeConfigArgs(INPUT_FIELD_PARAM);
+    if (fieldNames.isEmpty()) {
+      throw new SolrException(
+          SolrException.ErrorCode.SERVER_ERROR, "At least one 'inputField' must be provided");
+    }
+    inputFields = List.copyOf(fieldNames);
+
+    Collection<String> outputFields = args.removeConfigArgs(OUTPUT_FIELD_PARAM);
+    if (outputFields.isEmpty()) {
+      throw new SolrException(
+          SolrException.ErrorCode.SERVER_ERROR, "Exactly one 'outputField' must be provided");
+    }
+    if (outputFields.size() > 1) {
+      throw new SolrException(
+          SolrException.ErrorCode.SERVER_ERROR,
+          "Only one 'outputField' can be provided, but found: " + outputFields);
+    }
+    outputField = outputFields.iterator().next();
+
+    SolrParams params = args.toSolrParams();
+    RequiredSolrParams required = params.required();
+    modelName = required.get(MODEL_NAME);
+
+    String inlinePrompt = params.get(PROMPT);
+    String promptFilePath = params.get(PROMPT_FILE);
+
+    if (inlinePrompt == null && promptFilePath == null) {
+      throw new SolrException(
+          SolrException.ErrorCode.SERVER_ERROR, "Either 'prompt' or 'promptFile' must be provided");
+    }
+    if (inlinePrompt != null && promptFilePath != null) {
+      throw new SolrException(
+          SolrException.ErrorCode.SERVER_ERROR,
+          "Only one of 'prompt' or 'promptFile' can be provided, not both");
+    }
+    if (inlinePrompt != null) {
+      validatePromptPlaceholders(inlinePrompt, inputFields);
+      this.promptText = inlinePrompt;
+    }
+    this.promptFile = promptFilePath;
+  }
+
+  @Override
+  public void inform(SolrCore core) {
+    final SolrResourceLoader solrResourceLoader = core.getResourceLoader();
+    ManagedFieldGenerationModelStore.registerManagedFieldGenerationModelStore(solrResourceLoader, this);
+    if (promptFile != null) {
+      try (InputStream is = solrResourceLoader.openResource(promptFile)) {
+        promptText = new String(is.readAllBytes(), StandardCharsets.UTF_8).trim();
+      } catch (IOException e) {
+        throw new SolrException(
+            SolrException.ErrorCode.SERVER_ERROR, "Cannot read prompt file: " + promptFile, e);
+      }
+      validatePromptPlaceholders(promptText, inputFields);
+    }
+  }
+
+  @Override
+  public void onManagedResourceInitialized(NamedList<?> args, ManagedResource res)
+      throws SolrException {
+    if (res instanceof ManagedFieldGenerationModelStore store) {
+      store.loadStoredModels();
+    }
+  }
+
+  @Override
+  public UpdateRequestProcessor getInstance(
+      SolrQueryRequest req, SolrQueryResponse rsp, UpdateRequestProcessor next) {
+    IndexSchema latestSchema = req.getCore().getLatestSchema();
+
+    for (String fieldName : inputFields) {
+      if (!latestSchema.isDynamicField(fieldName) && !latestSchema.hasExplicitField(fieldName)) {
+        throw new SolrException(
+            SolrException.ErrorCode.SERVER_ERROR, "undefined field: \"" + fieldName + "\"");
+      }
+    }
+
+    final SchemaField outputFieldSchema = latestSchema.getField(outputField);
+
+    ResponseFormat responseFormat = getJsonSchema(outputFieldSchema);
+    boolean multiValued = outputFieldSchema.multiValued();
+
+    ManagedFieldGenerationModelStore store = ManagedFieldGenerationModelStore.getManagedModelStore(req.getCore());
+    SolrFieldGenerationModel fieldGenerationModel = store.getModel(modelName);
+    if (fieldGenerationModel == null) {
+      throw new SolrException(
+          SolrException.ErrorCode.SERVER_ERROR,
+          "The model configured in the Update Request Processor '"
+              + modelName
+              + "' can't be found in the store: "
+              + ManagedFieldGenerationModelStore.REST_END_POINT);
+    }
+
+    return new DocumentEnrichmentUpdateProcessor(
+        inputFields, outputField, promptText, fieldGenerationModel, multiValued, responseFormat, req, next);
+  }
+
+  /**
+   * Builds a {@link ResponseFormat} that instructs the model to return a JSON object {@code
+   * {"value": ...}} whose value type matches the Solr field type. For multivalued fields the value
+   * is wrapped in a {@link JsonArraySchema} nested inside the root {@link JsonObjectSchema}.
+   *
+   * <p>Nesting {@link JsonArraySchema} inside a {@link JsonObjectSchema} property is supported by
+   * all langchain4j providers that implement structured outputs with {@link JsonObjectSchema}
+   * (OpenAI, Azure OpenAI, Google AI, Gemini, Mistral, Ollama, Amazon Bedrock, Watsonx).
+   */
+  static ResponseFormat getJsonSchema(SchemaField schemaField) {
+    JsonSchemaElement valueElement = toJsonSchemaElement(schemaField.getType());
+    JsonSchemaElement valueSchema =
+        schemaField.multiValued()
+            ? JsonArraySchema.builder().items(valueElement).build()
+            : valueElement;
+    //estrai costanti output e value
+    return ResponseFormat.builder()
+        .type(ResponseFormatType.JSON)
+        .jsonSchema(
+            JsonSchema.builder()
+                .name(JSON_SCHEMA_NAME)
+                .rootElement(
+                    JsonObjectSchema.builder()
+                        .addProperty(JSON_FIELD_PROPERTY, valueSchema)
+                        .required(JSON_FIELD_PROPERTY)
+                        .build())
+                .build())
+        .build();
+  }
+
+  private static JsonSchemaElement toJsonSchemaElement(FieldType fieldType) {
+    SolrException unsupportedFieldTypeException = new SolrException(
+        SolrException.ErrorCode.SERVER_ERROR,
+        "field type is not supported by Document Enrichment: "
+            + fieldType.getClass().getSimpleName());
+
+    return switch (fieldType) {
+      // first check unsupported types and throw SolrException
+      case DenseVectorField f -> throw unsupportedFieldTypeException;
+      case UUIDField f -> throw unsupportedFieldTypeException;
+      case NestPathField f -> throw unsupportedFieldTypeException;
+
+      // build JsonSchemaElement for supported types
+      case StrField f -> new JsonStringSchema();
+      case TextField f -> new JsonStringSchema();
+      case DatePointField f -> new JsonStringSchema();
+
+      case IntPointField f -> new JsonIntegerSchema();
+      case LongPointField f -> new JsonIntegerSchema();
+
+      case FloatPointField f -> new JsonNumberSchema();
+      case DoublePointField f -> new JsonNumberSchema();
+
+      case BoolField f -> new JsonBooleanSchema();
+
+      // fall-back to SolrException
+      default -> throw unsupportedFieldTypeException;
+    };

Review Comment:
   Can't we use
   
   ```
       return switch (fieldType) {
         case StrField _, TextField _, DatePointField _ -> new JsonStringSchema();
         case IntPointField _, LongPointField _         -> new JsonIntegerSchema();
         case FloatPointField _, DoublePointField _     -> new JsonNumberSchema();
         case BoolField _                               -> new JsonBooleanSchema();
         default                                        -> throw unsupportedFieldTypeException;
       };
   ```
   
   with Java 22+?



-- 
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: issues-unsubscribe@solr.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


---------------------------------------------------------------------
To unsubscribe, e-mail: issues-unsubscribe@solr.apache.org
For additional commands, e-mail: issues-help@solr.apache.org


Re: [PR] SOLR-18187: Document enrichment with LLMs [solr]

Posted by "nicolo-rinaldi (via GitHub)" <gi...@apache.org>.
nicolo-rinaldi commented on code in PR #4259:
URL: https://github.com/apache/solr/pull/4259#discussion_r3241558261


##########
solr/modules/language-models/src/test/org/apache/solr/languagemodels/store/rest/TestLargeLanguageModelManagerPersistence.java:
##########
@@ -0,0 +1,101 @@
+/*
+ * 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.solr.languagemodels.store.rest;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import org.apache.solr.common.util.Utils;
+import org.apache.solr.languagemodels.TestLanguageModelBase;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+
+public class TestLargeLanguageModelManagerPersistence extends TestLanguageModelBase {
+
+  @Before
+  public void init() throws Exception {
+    setupTest("solrconfig-document-enrichment.xml", "schema-language-models.xml", false, true);
+  }
+
+  @After
+  public void cleanup() throws Exception {
+    afterTest();
+  }
+
+  @Test
+  public void testModelAreStoredCompact() throws Exception {

Review Comment:
   Removed
   



-- 
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: issues-unsubscribe@solr.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


---------------------------------------------------------------------
To unsubscribe, e-mail: issues-unsubscribe@solr.apache.org
For additional commands, e-mail: issues-help@solr.apache.org


Re: [PR] SOLR-18187: Document enrichment with LLMs [solr]

Posted by "nicolo-rinaldi (via GitHub)" <gi...@apache.org>.
nicolo-rinaldi commented on code in PR #4259:
URL: https://github.com/apache/solr/pull/4259#discussion_r3078878975


##########
solr/modules/language-models/src/test/org/apache/solr/languagemodels/documentenrichment/update/processor/DocumentEnrichmentUpdateProcessorFactoryTest.java:
##########
@@ -0,0 +1,417 @@
+/*
+ * 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.solr.languagemodels.documentenrichment.update.processor;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import org.apache.solr.common.SolrException;
+import org.apache.solr.common.params.ModifiableSolrParams;
+import org.apache.solr.common.util.NamedList;
+import org.apache.solr.core.SolrCore;
+import org.apache.solr.languagemodels.TestLanguageModelBase;
+import org.apache.solr.languagemodels.documentenrichment.model.SolrChatModel;
+import org.apache.solr.languagemodels.documentenrichment.store.rest.ManagedChatModelStore;
+import org.apache.solr.request.SolrQueryRequestBase;
+import org.apache.solr.update.processor.UpdateRequestProcessor;
+import org.junit.After;
+import org.junit.AfterClass;
+import org.junit.Before;
+import org.junit.BeforeClass;
+import org.junit.Test;
+
+public class DocumentEnrichmentUpdateProcessorFactoryTest extends TestLanguageModelBase {
+
+  @BeforeClass
+  public static void init() throws Exception {
+    setupTest("solrconfig-document-enrichment.xml", "schema-language-models.xml", false, false);
+  }
+
+  @AfterClass
+  public static void cleanup() throws Exception {
+    afterTest();
+  }
+
+  SolrCore collection1;
+
+  @Before
+  public void setup() {
+    collection1 = solrTestRule.getCoreContainer().getCore("collection1");
+  }
+
+  @After
+  public void after() {
+    collection1.close();
+  }
+
+  @Test
+  public void init_fullArgs_shouldInitAllParams() {
+    NamedList<String> args = new NamedList<>();
+    args.add("inputField", "string_field");
+    args.add("outputField", "enriched_field");
+    args.add("prompt", "Summarize: {string_field}");
+    args.add("model", "model1");
+
+    DocumentEnrichmentUpdateProcessorFactory factory = new DocumentEnrichmentUpdateProcessorFactory();
+    factory.init(args);
+
+    assertEquals(List.of("string_field"), factory.getInputFields());
+    assertEquals("enriched_field", factory.getOutputField());
+    assertEquals("Summarize: {string_field}", factory.getPrompt());
+    assertEquals("model1", factory.getModelName());
+  }
+
+  @Test
+  public void init_multipleInputFields_shouldInitAllFields() {
+    NamedList<String> args = new NamedList<>();
+    args.add("inputField", "string_field");
+    args.add("inputField", "body_field");
+    args.add("outputField", "enriched_field");
+    args.add("prompt", "Title: {string_field}. Body: {body_field}.");
+    args.add("model", "model1");
+
+    DocumentEnrichmentUpdateProcessorFactory factory = new DocumentEnrichmentUpdateProcessorFactory();
+    factory.init(args);
+
+    assertEquals(List.of("string_field", "body_field"), factory.getInputFields());
+  }
+
+  @Test
+  public void init_arrInputField_shouldInitAllFields() {
+    NamedList<Object> args = new NamedList<>();
+    args.add("inputField", new ArrayList<>(List.of("string_field", "body_field")));
+    args.add("outputField", "enriched_field");
+    args.add("prompt", "Title: {string_field}. Body: {body_field}.");
+    args.add("model", "model1");
+
+    DocumentEnrichmentUpdateProcessorFactory factory = new DocumentEnrichmentUpdateProcessorFactory();
+    factory.init(args);
+
+    assertEquals(List.of("string_field", "body_field"), factory.getInputFields());
+  }
+
+  @Test
+  public void init_noInputField_shouldThrowExceptionWithDetailedMessage() {
+    NamedList<String> args = new NamedList<>();
+    args.add("outputField", "enriched_field");
+    args.add("prompt", "Summarize: {string_field}");
+    args.add("model", "model1");
+
+    DocumentEnrichmentUpdateProcessorFactory factory = new DocumentEnrichmentUpdateProcessorFactory();
+
+    SolrException e = assertThrows(SolrException.class, () -> factory.init(args));
+    assertEquals("At least one 'inputField' must be provided", e.getMessage());
+  }
+
+  @Test
+  public void init_nullOutputField_shouldThrowExceptionWithDetailedMessage() {
+    NamedList<String> args = new NamedList<>();
+    args.add("inputField", "string_field");
+    args.add("prompt", "Summarize: {string_field}");
+    args.add("model", "model1");
+
+    DocumentEnrichmentUpdateProcessorFactory factory = new DocumentEnrichmentUpdateProcessorFactory();
+
+    SolrException e = assertThrows(SolrException.class, () -> factory.init(args));
+    assertEquals("Missing required parameter: outputField", e.getMessage());
+  }
+
+  @Test
+  public void init_neitherPromptNorPromptFile_shouldThrowExceptionWithDetailedMessage() {
+    NamedList<String> args = new NamedList<>();
+    args.add("inputField", "string_field");
+    args.add("outputField", "enriched_field");
+    args.add("model", "model1");
+
+    DocumentEnrichmentUpdateProcessorFactory factory = new DocumentEnrichmentUpdateProcessorFactory();
+
+    SolrException e = assertThrows(SolrException.class, () -> factory.init(args));
+    assertEquals("Either 'prompt' or 'promptFile' must be provided", e.getMessage());
+  }
+
+  @Test
+  public void init_bothPromptAndPromptFile_shouldThrowExceptionWithDetailedMessage() {
+    NamedList<String> args = new NamedList<>();
+    args.add("inputField", "string_field");
+    args.add("outputField", "enriched_field");
+    args.add("prompt", "Summarize: {string_field}");
+    args.add("promptFile", "prompt.txt");
+    args.add("model", "model1");
+
+    DocumentEnrichmentUpdateProcessorFactory factory = new DocumentEnrichmentUpdateProcessorFactory();
+
+    SolrException e = assertThrows(SolrException.class, () -> factory.init(args));
+    assertEquals("Only one of 'prompt' or 'promptFile' can be provided, not both", e.getMessage());
+  }
+
+  @Test
+  public void init_promptMissingPlaceholderForDeclaredField_shouldThrowExceptionWithDetailedMessage() {
+    NamedList<String> args = new NamedList<>();
+    args.add("inputField", "string_field");
+    args.add("outputField", "enriched_field");
+    args.add("prompt", "Summarize:");
+    args.add("model", "model1");
+
+    DocumentEnrichmentUpdateProcessorFactory factory = new DocumentEnrichmentUpdateProcessorFactory();
+
+    SolrException e = assertThrows(SolrException.class, () -> factory.init(args));
+    assertEquals("prompt is missing placeholders for inputField(s): [string_field]", e.getMessage());
+  }
+
+  @Test
+  public void init_promptMissingOnePlaceholderOfMultipleFields_shouldThrowExceptionWithDetailedMessage() {
+    NamedList<String> args = new NamedList<>();
+    args.add("inputField", "string_field");
+    args.add("inputField", "body_field");
+    args.add("outputField", "enriched_field");
+    args.add("prompt", "Title: {string_field}.");
+    args.add("model", "model1");
+
+    DocumentEnrichmentUpdateProcessorFactory factory = new DocumentEnrichmentUpdateProcessorFactory();
+
+    SolrException e = assertThrows(SolrException.class, () -> factory.init(args));
+    assertEquals("prompt is missing placeholders for inputField(s): [body_field]", e.getMessage());
+  }
+
+  @Test
+  public void init_promptHasExtraPlaceholderNotDeclaredAsInputField_shouldThrowExceptionWithDetailedMessage() {
+    NamedList<String> args = new NamedList<>();
+    args.add("inputField", "string_field");
+    args.add("outputField", "enriched_field");
+    args.add("prompt", "Title: {string_field}. Extra: {unknown_field}.");
+    args.add("model", "model1");
+
+    DocumentEnrichmentUpdateProcessorFactory factory = new DocumentEnrichmentUpdateProcessorFactory();
+
+    SolrException e = assertThrows(SolrException.class, () -> factory.init(args));
+    assertEquals(
+        "prompt contains placeholders not declared as inputField(s): [unknown_field]",
+        e.getMessage());
+  }
+
+  @Test
+  public void init_nullModel_shouldThrowExceptionWithDetailedMessage() {
+    NamedList<String> args = new NamedList<>();
+    args.add("inputField", "string_field");
+    args.add("outputField", "enriched_field");
+    args.add("prompt", "Summarize: {string_field}");
+
+    DocumentEnrichmentUpdateProcessorFactory factory = new DocumentEnrichmentUpdateProcessorFactory();
+
+    SolrException e = assertThrows(SolrException.class, () -> factory.init(args));
+    assertEquals("Missing required parameter: model", e.getMessage());
+  }
+
+  @Test
+  public void init_promptFile_shouldLoadPromptFromFile() {
+    NamedList<String> args = new NamedList<>();
+    args.add("inputField", "string_field");
+    args.add("outputField", "enriched_field");
+    args.add("promptFile", "prompt.txt");
+    args.add("model", "model1");
+
+    DocumentEnrichmentUpdateProcessorFactory factory = new DocumentEnrichmentUpdateProcessorFactory();
+    factory.init(args);
+    factory.inform(collection1);
+
+    assertEquals("prompt.txt", factory.getPromptFile());
+    assertNotNull(factory.getPrompt());
+    assertTrue(factory.getPrompt().contains("{string_field}"));
+  }
+
+  @Test
+  public void init_promptFileMultiField_shouldLoadAndValidateBothPlaceholders() {
+    NamedList<String> args = new NamedList<>();
+    args.add("inputField", "string_field");
+    args.add("inputField", "body_field");
+    args.add("outputField", "enriched_field");
+    args.add("promptFile", "prompt-multi-field.txt");
+    args.add("model", "model1");
+
+    DocumentEnrichmentUpdateProcessorFactory factory = new DocumentEnrichmentUpdateProcessorFactory();
+    factory.init(args);
+    factory.inform(collection1);
+
+    assertNotNull(factory.getPrompt());
+    assertTrue(factory.getPrompt().contains("{string_field}"));
+    assertTrue(factory.getPrompt().contains("{body_field}"));
+  }
+
+  @Test
+  public void init_promptFileWithMissingPlaceholder_shouldThrowExceptionInInform() {
+    NamedList<String> args = new NamedList<>();

Review Comment:
   changed and fixed tests



##########
solr/modules/language-models/src/test/org/apache/solr/languagemodels/documentenrichment/update/processor/DocumentEnrichmentUpdateProcessorFactoryTest.java:
##########
@@ -0,0 +1,422 @@
+/*
+ * 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.solr.languagemodels.documentenrichment.update.processor;
+
+import java.util.List;
+import org.apache.solr.common.SolrException;
+import org.apache.solr.common.params.ModifiableSolrParams;
+import org.apache.solr.common.util.NamedList;
+import org.apache.solr.core.SolrCore;
+import org.apache.solr.languagemodels.TestLanguageModelBase;
+import org.apache.solr.languagemodels.documentenrichment.model.SolrChatModel;
+import org.apache.solr.languagemodels.documentenrichment.store.rest.ManagedChatModelStore;
+import org.apache.solr.request.SolrQueryRequestBase;
+import org.apache.solr.update.processor.UpdateRequestProcessor;
+import org.junit.After;
+import org.junit.AfterClass;
+import org.junit.Before;
+import org.junit.BeforeClass;
+import org.junit.Test;
+
+public class DocumentEnrichmentUpdateProcessorFactoryTest extends TestLanguageModelBase {
+
+  @BeforeClass
+  public static void init() throws Exception {
+    setupTest("solrconfig-document-enrichment.xml", "schema-language-models.xml", false, false);
+  }
+
+  @AfterClass
+  public static void cleanup() throws Exception {
+    afterTest();
+  }
+
+  SolrCore collection1;
+
+  @Before
+  public void setup() {
+    collection1 = solrTestRule.getCoreContainer().getCore("collection1");
+  }
+
+  @After
+  public void after() {
+    collection1.close();
+  }
+
+  @Test
+  public void init_fullArgs_shouldInitAllParams() {
+    NamedList<String> args = new NamedList<>();
+    args.add("inputField", "string_field");
+    args.add("outputField", "enriched_field");
+    args.add("prompt", "Summarize: {string_field}");
+    args.add("model", "model1");
+
+    DocumentEnrichmentUpdateProcessorFactory factory = new DocumentEnrichmentUpdateProcessorFactory();
+    factory.init(args);
+
+    assertEquals(List.of("string_field"), factory.getInputFields());
+    assertEquals("enriched_field", factory.getOutputField());
+    assertEquals("Summarize: {string_field}", factory.getPrompt());
+    assertEquals("model1", factory.getModelName());
+  }
+
+  @Test
+  public void init_multipleInputFields_shouldInitAllFields() {
+    NamedList<String> args = new NamedList<>();
+    args.add("inputField", "string_field");
+    args.add("inputField", "body_field");
+    args.add("outputField", "enriched_field");
+    args.add("prompt", "Title: {string_field}. Body: {body_field}.");
+    args.add("model", "model1");
+
+    DocumentEnrichmentUpdateProcessorFactory factory = new DocumentEnrichmentUpdateProcessorFactory();
+    factory.init(args);
+
+    assertEquals(List.of("string_field", "body_field"), factory.getInputFields());
+  }
+
+  @Test
+  public void init_noInputField_shouldThrowExceptionWithDetailedMessage() {
+    NamedList<String> args = new NamedList<>();
+    args.add("outputField", "enriched_field");
+    args.add("prompt", "Summarize: {string_field}");
+    args.add("model", "model1");
+
+    DocumentEnrichmentUpdateProcessorFactory factory = new DocumentEnrichmentUpdateProcessorFactory();
+
+    SolrException e = assertThrows(SolrException.class, () -> factory.init(args));
+    assertEquals("At least one 'inputField' must be provided", e.getMessage());
+  }
+
+  @Test
+  public void init_nullOutputField_shouldThrowExceptionWithDetailedMessage() {
+    NamedList<String> args = new NamedList<>();
+    args.add("inputField", "string_field");
+    args.add("prompt", "Summarize: {string_field}");
+    args.add("model", "model1");
+
+    DocumentEnrichmentUpdateProcessorFactory factory = new DocumentEnrichmentUpdateProcessorFactory();
+
+    SolrException e = assertThrows(SolrException.class, () -> factory.init(args));
+    assertEquals("Missing required parameter: outputField", e.getMessage());
+  }
+
+  @Test
+  public void init_neitherPromptNorPromptFile_shouldThrowExceptionWithDetailedMessage() {
+    NamedList<String> args = new NamedList<>();
+    args.add("inputField", "string_field");
+    args.add("outputField", "enriched_field");
+    args.add("model", "model1");
+
+    DocumentEnrichmentUpdateProcessorFactory factory = new DocumentEnrichmentUpdateProcessorFactory();
+
+    SolrException e = assertThrows(SolrException.class, () -> factory.init(args));
+    assertEquals("Either 'prompt' or 'promptFile' must be provided", e.getMessage());
+  }
+
+  @Test
+  public void init_bothPromptAndPromptFile_shouldThrowExceptionWithDetailedMessage() {
+    NamedList<String> args = new NamedList<>();
+    args.add("inputField", "string_field");
+    args.add("outputField", "enriched_field");
+    args.add("prompt", "Summarize: {string_field}");
+    args.add("promptFile", "prompt.txt");
+    args.add("model", "model1");
+
+    DocumentEnrichmentUpdateProcessorFactory factory = new DocumentEnrichmentUpdateProcessorFactory();
+
+    SolrException e = assertThrows(SolrException.class, () -> factory.init(args));
+    assertEquals("Only one of 'prompt' or 'promptFile' can be provided, not both", e.getMessage());
+  }
+
+  @Test
+  public void init_promptMissingPlaceholderForDeclaredField_shouldThrowExceptionWithDetailedMessage() {
+    NamedList<String> args = new NamedList<>();
+    args.add("inputField", "string_field");
+    args.add("outputField", "enriched_field");
+    args.add("prompt", "Summarize:");
+    args.add("model", "model1");
+
+    DocumentEnrichmentUpdateProcessorFactory factory = new DocumentEnrichmentUpdateProcessorFactory();
+
+    SolrException e = assertThrows(SolrException.class, () -> factory.init(args));
+    assertEquals("prompt is missing placeholders for inputField(s): [string_field]", e.getMessage());
+  }
+
+  @Test
+  public void init_promptMissingOnePlaceholderOfMultipleFields_shouldThrowExceptionWithDetailedMessage() {
+    NamedList<String> args = new NamedList<>();
+    args.add("inputField", "string_field");
+    args.add("inputField", "body_field");
+    args.add("outputField", "enriched_field");
+    args.add("prompt", "Title: {string_field}.");
+    args.add("model", "model1");
+
+    DocumentEnrichmentUpdateProcessorFactory factory = new DocumentEnrichmentUpdateProcessorFactory();
+
+    SolrException e = assertThrows(SolrException.class, () -> factory.init(args));
+    assertEquals("prompt is missing placeholders for inputField(s): [body_field]", e.getMessage());
+  }
+
+  @Test
+  public void init_promptHasExtraPlaceholderNotDeclaredAsInputField_shouldThrowExceptionWithDetailedMessage() {
+    NamedList<String> args = new NamedList<>();
+    args.add("inputField", "string_field");
+    args.add("outputField", "enriched_field");
+    args.add("prompt", "Title: {string_field}. Extra: {unknown_field}.");
+    args.add("model", "model1");
+
+    DocumentEnrichmentUpdateProcessorFactory factory = new DocumentEnrichmentUpdateProcessorFactory();
+
+    SolrException e = assertThrows(SolrException.class, () -> factory.init(args));
+    assertEquals(
+        "prompt contains placeholders not declared as inputField(s): [unknown_field]",
+        e.getMessage());
+  }
+
+  @Test
+  public void init_nullModel_shouldThrowExceptionWithDetailedMessage() {
+    NamedList<String> args = new NamedList<>();
+    args.add("inputField", "string_field");
+    args.add("outputField", "enriched_field");
+    args.add("prompt", "Summarize: {string_field}");
+
+    DocumentEnrichmentUpdateProcessorFactory factory = new DocumentEnrichmentUpdateProcessorFactory();
+
+    SolrException e = assertThrows(SolrException.class, () -> factory.init(args));
+    assertEquals("Missing required parameter: model", e.getMessage());
+  }
+
+  @Test
+  public void init_promptFile_shouldLoadPromptFromFile() {
+    NamedList<String> args = new NamedList<>();
+    args.add("inputField", "string_field");
+    args.add("outputField", "enriched_field");
+    args.add("promptFile", "prompt.txt");
+    args.add("model", "model1");
+
+    DocumentEnrichmentUpdateProcessorFactory factory = new DocumentEnrichmentUpdateProcessorFactory();
+    factory.init(args);
+    factory.inform(collection1);
+
+    assertEquals("prompt.txt", factory.getPromptFile());
+    assertNotNull(factory.getPrompt());
+    assertTrue(factory.getPrompt().contains("{string_field}"));
+  }
+
+  @Test
+  public void init_promptFileMultiField_shouldLoadAndValidateBothPlaceholders() {
+    NamedList<String> args = new NamedList<>();
+    args.add("inputField", "string_field");
+    args.add("inputField", "body_field");
+    args.add("outputField", "enriched_field");
+    args.add("promptFile", "prompt-multi-field.txt");
+    args.add("model", "model1");
+
+    DocumentEnrichmentUpdateProcessorFactory factory = new DocumentEnrichmentUpdateProcessorFactory();
+    factory.init(args);
+    factory.inform(collection1);
+
+    assertNotNull(factory.getPrompt());
+    assertTrue(factory.getPrompt().contains("{string_field}"));
+    assertTrue(factory.getPrompt().contains("{body_field}"));
+  }
+
+  @Test
+  public void init_promptFileWithMissingPlaceholder_shouldThrowExceptionInInform() {
+    NamedList<String> args = new NamedList<>();
+    args.add("inputField", "string_field");
+    args.add("outputField", "enriched_field");
+    args.add("promptFile", "prompt-no-placeholder.txt");
+    args.add("model", "model1");
+
+    DocumentEnrichmentUpdateProcessorFactory factory = new DocumentEnrichmentUpdateProcessorFactory();
+    factory.init(args);
+
+    SolrException e = assertThrows(SolrException.class, () -> factory.inform(collection1));
+    assertEquals(
+        "prompt is missing placeholders for inputField(s): [string_field]", e.getMessage());
+  }
+
+  /* Following tests depend on a real solr schema and depend on BeforeClass-AfterClass methods */
+
+  @Test
+  public void init_notExistentOutputField_shouldThrowExceptionWithDetailedMessage() {
+    NamedList<String> args = new NamedList<>();
+    args.add("inputField", "string_field");
+    args.add("outputField", "notExistentOutput");
+    args.add("prompt", "Summarize: {string_field}");
+    args.add("model", "model1");
+
+    DocumentEnrichmentUpdateProcessorFactory factory = new DocumentEnrichmentUpdateProcessorFactory();
+    ModifiableSolrParams params = new ModifiableSolrParams();
+    SolrQueryRequestBase req = new SolrQueryRequestBase(collection1, params) {};
+    factory.init(args);
+
+    SolrException e = assertThrows(SolrException.class, () -> factory.getInstance(req, null, null));
+    assertEquals("undefined field: \"notExistentOutput\"", e.getMessage());
+  }
+
+  @Test
+  public void init_notTextualOutputField_shouldThrowExceptionWithDetailedMessage() {
+    // vector is a DenseVectorField — not a textual field
+    NamedList<String> args = new NamedList<>();
+    args.add("inputField", "string_field");
+    args.add("outputField", "vector");
+    args.add("prompt", "Summarize: {string_field}");
+    args.add("model", "model1");
+
+    DocumentEnrichmentUpdateProcessorFactory factory = new DocumentEnrichmentUpdateProcessorFactory();
+    ModifiableSolrParams params = new ModifiableSolrParams();
+    SolrQueryRequestBase req = new SolrQueryRequestBase(collection1, params) {};
+    factory.init(args);
+
+    SolrException e = assertThrows(SolrException.class, () -> factory.getInstance(req, null, null));
+    assertEquals(
+        "field type is not supported by Document Enrichment: DenseVectorField", e.getMessage());
+  }
+
+  @Test
+  public void init_notExistentInputField_shouldThrowExceptionWithDetailedMessage() {
+    NamedList<String> args = new NamedList<>();
+    args.add("inputField", "notExistentInput");
+    args.add("outputField", "enriched_field");
+    args.add("prompt", "Summarize: {notExistentInput}");
+    args.add("model", "model1");
+
+    DocumentEnrichmentUpdateProcessorFactory factory = new DocumentEnrichmentUpdateProcessorFactory();
+    ModifiableSolrParams params = new ModifiableSolrParams();
+    SolrQueryRequestBase req = new SolrQueryRequestBase(collection1, params) {};
+    factory.init(args);
+
+    SolrException e = assertThrows(SolrException.class, () -> factory.getInstance(req, null, null));
+    assertEquals("undefined field: \"notExistentInput\"", e.getMessage());
+  }
+
+  @Test
+  public void init_multipleInputFields_oneNotExistent_shouldThrowExceptionWithDetailedMessage() {
+    NamedList<String> args = new NamedList<>();
+    args.add("inputField", "string_field");
+    args.add("inputField", "notExistentInput");
+    args.add("outputField", "enriched_field");
+    args.add("prompt", "Title: {string_field}. Body: {notExistentInput}.");
+    args.add("model", "model1");
+
+    DocumentEnrichmentUpdateProcessorFactory factory = new DocumentEnrichmentUpdateProcessorFactory();
+    ModifiableSolrParams params = new ModifiableSolrParams();
+    SolrQueryRequestBase req = new SolrQueryRequestBase(collection1, params) {};
+    factory.init(args);
+
+    SolrException e = assertThrows(SolrException.class, () -> factory.getInstance(req, null, null));
+    assertEquals("undefined field: \"notExistentInput\"", e.getMessage());
+  }
+
+  @Test
+  public void init_multivaluedStringOutputField_shouldNotThrowException() throws Exception {
+    UpdateRequestProcessor instance =
+        createUpdateProcessor("string_field", "enriched_field_multi", collection1, "model-mv");
+    assertNotNull(instance);
+    restTestHarness.delete(ManagedChatModelStore.REST_END_POINT + "/model-mv");
+  }
+
+  @Test
+  public void init_multivaluedStringOutputField_buildResponseFormat_shouldProduceArraySchema() throws Exception {
+    NamedList<String> args = new NamedList<>();
+    ManagedChatModelStore.getManagedModelStore(collection1)
+        .addModel(new SolrChatModel("model-rf", null, null));
+    args.add("inputField", "string_field");
+    args.add("outputField", "enriched_field_multi");
+    args.add("prompt", "Summarize: {string_field}");
+    args.add("model", "model-rf");
+
+    DocumentEnrichmentUpdateProcessorFactory factory = new DocumentEnrichmentUpdateProcessorFactory();
+    factory.init(args);
+    ModifiableSolrParams params = new ModifiableSolrParams();
+    SolrQueryRequestBase req = new SolrQueryRequestBase(collection1, params) {};
+    assertNotNull(factory.getInstance(req, null, null));
+
+    // verify the ResponseFormat is constructed correctly for the multivalued field
+    var schema = collection1.getLatestSchema();
+    var schemaField = schema.getField("enriched_field_multi");
+    assertTrue(schemaField.multiValued());
+    var responseFormat = DocumentEnrichmentUpdateProcessorFactory.buildResponseFormat(schemaField);
+    assertNotNull(responseFormat);
+    assertEquals(
+        dev.langchain4j.model.chat.request.ResponseFormatType.JSON, responseFormat.type());
+    assertNotNull(responseFormat.jsonSchema());
+    restTestHarness.delete(ManagedChatModelStore.REST_END_POINT + "/model-rf");
+  }
+
+  @Test
+  public void init_singleValuedStringOutputField_buildResponseFormat_shouldProduceStringSchema() {
+    var schema = collection1.getLatestSchema();
+    var schemaField = schema.getField("enriched_field");
+    assertFalse(schemaField.multiValued());
+    var responseFormat = DocumentEnrichmentUpdateProcessorFactory.buildResponseFormat(schemaField);
+    assertNotNull(responseFormat);
+    assertEquals(
+        dev.langchain4j.model.chat.request.ResponseFormatType.JSON, responseFormat.type());
+    assertNotNull(responseFormat.jsonSchema());
+  }
+
+  @Test
+  public void init_dynamicInputField_shouldNotThrowException() throws Exception{
+    UpdateRequestProcessor instance =
+        createUpdateProcessor("text_s", "enriched_field", collection1, "model2");
+    assertNotNull(instance);
+    restTestHarness.delete(ManagedChatModelStore.REST_END_POINT + "/model2");
+  }
+
+  @Test
+  public void init_multipleDynamicInputFields_shouldNotThrowException() throws Exception{
+    NamedList<String> args = new NamedList<>();
+    ManagedChatModelStore.getManagedModelStore(collection1)
+        .addModel(new SolrChatModel("model1", null, null));
+    args.add("inputField", "text_s");
+    args.add("inputField", "body_field");
+    args.add("outputField", "enriched_field");
+    args.add("prompt", "Title: {text_s}. Body: {body_field}.");
+    args.add("model", "model1");
+
+    DocumentEnrichmentUpdateProcessorFactory factory = new DocumentEnrichmentUpdateProcessorFactory();
+    ModifiableSolrParams params = new ModifiableSolrParams();
+    factory.init(args);
+
+    SolrQueryRequestBase req = new SolrQueryRequestBase(collection1, params) {};
+    assertNotNull(factory.getInstance(req, null, null));
+    restTestHarness.delete(ManagedChatModelStore.REST_END_POINT + "/model1");
+  }
+
+  private UpdateRequestProcessor createUpdateProcessor(

Review Comment:
   Fixed tests



-- 
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: issues-unsubscribe@solr.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


---------------------------------------------------------------------
To unsubscribe, e-mail: issues-unsubscribe@solr.apache.org
For additional commands, e-mail: issues-help@solr.apache.org


Re: [PR] SOLR-18187: Document enrichment with LLMs [solr]

Posted by "aruggero (via GitHub)" <gi...@apache.org>.
aruggero commented on code in PR #4259:
URL: https://github.com/apache/solr/pull/4259#discussion_r3084731049


##########
solr/modules/language-models/src/test/org/apache/solr/languagemodels/documentenrichment/update/processor/DocumentEnrichmentUpdateProcessorFactoryTest.java:
##########
@@ -0,0 +1,422 @@
+/*
+ * 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.solr.languagemodels.documentenrichment.update.processor;
+
+import java.util.List;
+import org.apache.solr.common.SolrException;
+import org.apache.solr.common.params.ModifiableSolrParams;
+import org.apache.solr.common.util.NamedList;
+import org.apache.solr.core.SolrCore;
+import org.apache.solr.languagemodels.TestLanguageModelBase;
+import org.apache.solr.languagemodels.documentenrichment.model.SolrChatModel;
+import org.apache.solr.languagemodels.documentenrichment.store.rest.ManagedChatModelStore;
+import org.apache.solr.request.SolrQueryRequestBase;
+import org.apache.solr.update.processor.UpdateRequestProcessor;
+import org.junit.After;
+import org.junit.AfterClass;
+import org.junit.Before;
+import org.junit.BeforeClass;
+import org.junit.Test;
+
+public class DocumentEnrichmentUpdateProcessorFactoryTest extends TestLanguageModelBase {
+
+  @BeforeClass
+  public static void init() throws Exception {
+    setupTest("solrconfig-document-enrichment.xml", "schema-language-models.xml", false, false);
+  }
+
+  @AfterClass
+  public static void cleanup() throws Exception {
+    afterTest();
+  }
+
+  SolrCore collection1;
+
+  @Before
+  public void setup() {
+    collection1 = solrTestRule.getCoreContainer().getCore("collection1");
+  }
+
+  @After
+  public void after() {
+    collection1.close();
+  }
+
+  @Test
+  public void init_fullArgs_shouldInitAllParams() {
+    NamedList<String> args = new NamedList<>();
+    args.add("inputField", "string_field");
+    args.add("outputField", "enriched_field");
+    args.add("prompt", "Summarize: {string_field}");
+    args.add("model", "model1");
+
+    DocumentEnrichmentUpdateProcessorFactory factory = new DocumentEnrichmentUpdateProcessorFactory();
+    factory.init(args);
+
+    assertEquals(List.of("string_field"), factory.getInputFields());
+    assertEquals("enriched_field", factory.getOutputField());
+    assertEquals("Summarize: {string_field}", factory.getPrompt());
+    assertEquals("model1", factory.getModelName());
+  }
+
+  @Test
+  public void init_multipleInputFields_shouldInitAllFields() {
+    NamedList<String> args = new NamedList<>();
+    args.add("inputField", "string_field");
+    args.add("inputField", "body_field");
+    args.add("outputField", "enriched_field");
+    args.add("prompt", "Title: {string_field}. Body: {body_field}.");
+    args.add("model", "model1");
+
+    DocumentEnrichmentUpdateProcessorFactory factory = new DocumentEnrichmentUpdateProcessorFactory();
+    factory.init(args);
+
+    assertEquals(List.of("string_field", "body_field"), factory.getInputFields());
+  }
+
+  @Test
+  public void init_noInputField_shouldThrowExceptionWithDetailedMessage() {
+    NamedList<String> args = new NamedList<>();
+    args.add("outputField", "enriched_field");
+    args.add("prompt", "Summarize: {string_field}");
+    args.add("model", "model1");
+
+    DocumentEnrichmentUpdateProcessorFactory factory = new DocumentEnrichmentUpdateProcessorFactory();
+
+    SolrException e = assertThrows(SolrException.class, () -> factory.init(args));
+    assertEquals("At least one 'inputField' must be provided", e.getMessage());
+  }
+
+  @Test
+  public void init_nullOutputField_shouldThrowExceptionWithDetailedMessage() {
+    NamedList<String> args = new NamedList<>();
+    args.add("inputField", "string_field");
+    args.add("prompt", "Summarize: {string_field}");
+    args.add("model", "model1");
+
+    DocumentEnrichmentUpdateProcessorFactory factory = new DocumentEnrichmentUpdateProcessorFactory();
+
+    SolrException e = assertThrows(SolrException.class, () -> factory.init(args));
+    assertEquals("Missing required parameter: outputField", e.getMessage());
+  }
+
+  @Test
+  public void init_neitherPromptNorPromptFile_shouldThrowExceptionWithDetailedMessage() {
+    NamedList<String> args = new NamedList<>();
+    args.add("inputField", "string_field");
+    args.add("outputField", "enriched_field");
+    args.add("model", "model1");
+
+    DocumentEnrichmentUpdateProcessorFactory factory = new DocumentEnrichmentUpdateProcessorFactory();
+
+    SolrException e = assertThrows(SolrException.class, () -> factory.init(args));
+    assertEquals("Either 'prompt' or 'promptFile' must be provided", e.getMessage());
+  }
+
+  @Test
+  public void init_bothPromptAndPromptFile_shouldThrowExceptionWithDetailedMessage() {
+    NamedList<String> args = new NamedList<>();
+    args.add("inputField", "string_field");
+    args.add("outputField", "enriched_field");
+    args.add("prompt", "Summarize: {string_field}");
+    args.add("promptFile", "prompt.txt");
+    args.add("model", "model1");
+
+    DocumentEnrichmentUpdateProcessorFactory factory = new DocumentEnrichmentUpdateProcessorFactory();
+
+    SolrException e = assertThrows(SolrException.class, () -> factory.init(args));
+    assertEquals("Only one of 'prompt' or 'promptFile' can be provided, not both", e.getMessage());
+  }
+
+  @Test
+  public void init_promptMissingPlaceholderForDeclaredField_shouldThrowExceptionWithDetailedMessage() {
+    NamedList<String> args = new NamedList<>();
+    args.add("inputField", "string_field");
+    args.add("outputField", "enriched_field");
+    args.add("prompt", "Summarize:");
+    args.add("model", "model1");
+
+    DocumentEnrichmentUpdateProcessorFactory factory = new DocumentEnrichmentUpdateProcessorFactory();
+
+    SolrException e = assertThrows(SolrException.class, () -> factory.init(args));
+    assertEquals("prompt is missing placeholders for inputField(s): [string_field]", e.getMessage());
+  }
+
+  @Test
+  public void init_promptMissingOnePlaceholderOfMultipleFields_shouldThrowExceptionWithDetailedMessage() {
+    NamedList<String> args = new NamedList<>();
+    args.add("inputField", "string_field");
+    args.add("inputField", "body_field");
+    args.add("outputField", "enriched_field");
+    args.add("prompt", "Title: {string_field}.");
+    args.add("model", "model1");
+
+    DocumentEnrichmentUpdateProcessorFactory factory = new DocumentEnrichmentUpdateProcessorFactory();
+
+    SolrException e = assertThrows(SolrException.class, () -> factory.init(args));
+    assertEquals("prompt is missing placeholders for inputField(s): [body_field]", e.getMessage());
+  }
+
+  @Test
+  public void init_promptHasExtraPlaceholderNotDeclaredAsInputField_shouldThrowExceptionWithDetailedMessage() {
+    NamedList<String> args = new NamedList<>();
+    args.add("inputField", "string_field");
+    args.add("outputField", "enriched_field");
+    args.add("prompt", "Title: {string_field}. Extra: {unknown_field}.");
+    args.add("model", "model1");
+
+    DocumentEnrichmentUpdateProcessorFactory factory = new DocumentEnrichmentUpdateProcessorFactory();
+
+    SolrException e = assertThrows(SolrException.class, () -> factory.init(args));
+    assertEquals(
+        "prompt contains placeholders not declared as inputField(s): [unknown_field]",
+        e.getMessage());
+  }
+
+  @Test
+  public void init_nullModel_shouldThrowExceptionWithDetailedMessage() {
+    NamedList<String> args = new NamedList<>();
+    args.add("inputField", "string_field");
+    args.add("outputField", "enriched_field");
+    args.add("prompt", "Summarize: {string_field}");
+
+    DocumentEnrichmentUpdateProcessorFactory factory = new DocumentEnrichmentUpdateProcessorFactory();
+
+    SolrException e = assertThrows(SolrException.class, () -> factory.init(args));
+    assertEquals("Missing required parameter: model", e.getMessage());
+  }
+
+  @Test
+  public void init_promptFile_shouldLoadPromptFromFile() {
+    NamedList<String> args = new NamedList<>();
+    args.add("inputField", "string_field");
+    args.add("outputField", "enriched_field");
+    args.add("promptFile", "prompt.txt");
+    args.add("model", "model1");
+
+    DocumentEnrichmentUpdateProcessorFactory factory = new DocumentEnrichmentUpdateProcessorFactory();
+    factory.init(args);
+    factory.inform(collection1);
+
+    assertEquals("prompt.txt", factory.getPromptFile());
+    assertNotNull(factory.getPrompt());
+    assertTrue(factory.getPrompt().contains("{string_field}"));
+  }
+
+  @Test
+  public void init_promptFileMultiField_shouldLoadAndValidateBothPlaceholders() {
+    NamedList<String> args = new NamedList<>();
+    args.add("inputField", "string_field");
+    args.add("inputField", "body_field");
+    args.add("outputField", "enriched_field");
+    args.add("promptFile", "prompt-multi-field.txt");
+    args.add("model", "model1");
+
+    DocumentEnrichmentUpdateProcessorFactory factory = new DocumentEnrichmentUpdateProcessorFactory();
+    factory.init(args);
+    factory.inform(collection1);
+
+    assertNotNull(factory.getPrompt());
+    assertTrue(factory.getPrompt().contains("{string_field}"));
+    assertTrue(factory.getPrompt().contains("{body_field}"));
+  }
+
+  @Test
+  public void init_promptFileWithMissingPlaceholder_shouldThrowExceptionInInform() {
+    NamedList<String> args = new NamedList<>();
+    args.add("inputField", "string_field");
+    args.add("outputField", "enriched_field");
+    args.add("promptFile", "prompt-no-placeholder.txt");
+    args.add("model", "model1");
+
+    DocumentEnrichmentUpdateProcessorFactory factory = new DocumentEnrichmentUpdateProcessorFactory();
+    factory.init(args);
+
+    SolrException e = assertThrows(SolrException.class, () -> factory.inform(collection1));
+    assertEquals(
+        "prompt is missing placeholders for inputField(s): [string_field]", e.getMessage());
+  }
+
+  /* Following tests depend on a real solr schema and depend on BeforeClass-AfterClass methods */
+
+  @Test
+  public void init_notExistentOutputField_shouldThrowExceptionWithDetailedMessage() {
+    NamedList<String> args = new NamedList<>();
+    args.add("inputField", "string_field");
+    args.add("outputField", "notExistentOutput");
+    args.add("prompt", "Summarize: {string_field}");
+    args.add("model", "model1");
+
+    DocumentEnrichmentUpdateProcessorFactory factory = new DocumentEnrichmentUpdateProcessorFactory();
+    ModifiableSolrParams params = new ModifiableSolrParams();
+    SolrQueryRequestBase req = new SolrQueryRequestBase(collection1, params) {};
+    factory.init(args);
+
+    SolrException e = assertThrows(SolrException.class, () -> factory.getInstance(req, null, null));
+    assertEquals("undefined field: \"notExistentOutput\"", e.getMessage());
+  }
+
+  @Test
+  public void init_notTextualOutputField_shouldThrowExceptionWithDetailedMessage() {
+    // vector is a DenseVectorField — not a textual field
+    NamedList<String> args = new NamedList<>();
+    args.add("inputField", "string_field");
+    args.add("outputField", "vector");
+    args.add("prompt", "Summarize: {string_field}");
+    args.add("model", "model1");
+
+    DocumentEnrichmentUpdateProcessorFactory factory = new DocumentEnrichmentUpdateProcessorFactory();
+    ModifiableSolrParams params = new ModifiableSolrParams();
+    SolrQueryRequestBase req = new SolrQueryRequestBase(collection1, params) {};
+    factory.init(args);
+
+    SolrException e = assertThrows(SolrException.class, () -> factory.getInstance(req, null, null));
+    assertEquals(
+        "field type is not supported by Document Enrichment: DenseVectorField", e.getMessage());
+  }
+
+  @Test
+  public void init_notExistentInputField_shouldThrowExceptionWithDetailedMessage() {
+    NamedList<String> args = new NamedList<>();
+    args.add("inputField", "notExistentInput");
+    args.add("outputField", "enriched_field");
+    args.add("prompt", "Summarize: {notExistentInput}");
+    args.add("model", "model1");
+
+    DocumentEnrichmentUpdateProcessorFactory factory = new DocumentEnrichmentUpdateProcessorFactory();
+    ModifiableSolrParams params = new ModifiableSolrParams();
+    SolrQueryRequestBase req = new SolrQueryRequestBase(collection1, params) {};
+    factory.init(args);
+
+    SolrException e = assertThrows(SolrException.class, () -> factory.getInstance(req, null, null));
+    assertEquals("undefined field: \"notExistentInput\"", e.getMessage());
+  }
+
+  @Test
+  public void init_multipleInputFields_oneNotExistent_shouldThrowExceptionWithDetailedMessage() {
+    NamedList<String> args = new NamedList<>();
+    args.add("inputField", "string_field");
+    args.add("inputField", "notExistentInput");
+    args.add("outputField", "enriched_field");
+    args.add("prompt", "Title: {string_field}. Body: {notExistentInput}.");
+    args.add("model", "model1");
+
+    DocumentEnrichmentUpdateProcessorFactory factory = new DocumentEnrichmentUpdateProcessorFactory();
+    ModifiableSolrParams params = new ModifiableSolrParams();
+    SolrQueryRequestBase req = new SolrQueryRequestBase(collection1, params) {};
+    factory.init(args);
+
+    SolrException e = assertThrows(SolrException.class, () -> factory.getInstance(req, null, null));
+    assertEquals("undefined field: \"notExistentInput\"", e.getMessage());
+  }
+
+  @Test
+  public void init_multivaluedStringOutputField_shouldNotThrowException() throws Exception {
+    UpdateRequestProcessor instance =
+        createUpdateProcessor("string_field", "enriched_field_multi", collection1, "model-mv");
+    assertNotNull(instance);
+    restTestHarness.delete(ManagedChatModelStore.REST_END_POINT + "/model-mv");
+  }
+
+  @Test
+  public void init_multivaluedStringOutputField_buildResponseFormat_shouldProduceArraySchema() throws Exception {
+    NamedList<String> args = new NamedList<>();
+    ManagedChatModelStore.getManagedModelStore(collection1)
+        .addModel(new SolrChatModel("model-rf", null, null));
+    args.add("inputField", "string_field");
+    args.add("outputField", "enriched_field_multi");
+    args.add("prompt", "Summarize: {string_field}");
+    args.add("model", "model-rf");
+
+    DocumentEnrichmentUpdateProcessorFactory factory = new DocumentEnrichmentUpdateProcessorFactory();
+    factory.init(args);
+    ModifiableSolrParams params = new ModifiableSolrParams();
+    SolrQueryRequestBase req = new SolrQueryRequestBase(collection1, params) {};
+    assertNotNull(factory.getInstance(req, null, null));
+
+    // verify the ResponseFormat is constructed correctly for the multivalued field
+    var schema = collection1.getLatestSchema();
+    var schemaField = schema.getField("enriched_field_multi");
+    assertTrue(schemaField.multiValued());
+    var responseFormat = DocumentEnrichmentUpdateProcessorFactory.buildResponseFormat(schemaField);
+    assertNotNull(responseFormat);
+    assertEquals(
+        dev.langchain4j.model.chat.request.ResponseFormatType.JSON, responseFormat.type());
+    assertNotNull(responseFormat.jsonSchema());
+    restTestHarness.delete(ManagedChatModelStore.REST_END_POINT + "/model-rf");
+  }
+
+  @Test
+  public void init_singleValuedStringOutputField_buildResponseFormat_shouldProduceStringSchema() {
+    var schema = collection1.getLatestSchema();
+    var schemaField = schema.getField("enriched_field");
+    assertFalse(schemaField.multiValued());
+    var responseFormat = DocumentEnrichmentUpdateProcessorFactory.buildResponseFormat(schemaField);
+    assertNotNull(responseFormat);
+    assertEquals(
+        dev.langchain4j.model.chat.request.ResponseFormatType.JSON, responseFormat.type());
+    assertNotNull(responseFormat.jsonSchema());
+  }
+
+  @Test
+  public void init_dynamicInputField_shouldNotThrowException() throws Exception{
+    UpdateRequestProcessor instance =
+        createUpdateProcessor("text_s", "enriched_field", collection1, "model2");
+    assertNotNull(instance);
+    restTestHarness.delete(ManagedChatModelStore.REST_END_POINT + "/model2");
+  }
+
+  @Test
+  public void init_multipleDynamicInputFields_shouldNotThrowException() throws Exception{
+    NamedList<String> args = new NamedList<>();
+    ManagedChatModelStore.getManagedModelStore(collection1)
+        .addModel(new SolrChatModel("model1", null, null));
+    args.add("inputField", "text_s");
+    args.add("inputField", "body_field");
+    args.add("outputField", "enriched_field");
+    args.add("prompt", "Title: {text_s}. Body: {body_field}.");
+    args.add("model", "model1");
+
+    DocumentEnrichmentUpdateProcessorFactory factory = new DocumentEnrichmentUpdateProcessorFactory();
+    ModifiableSolrParams params = new ModifiableSolrParams();
+    factory.init(args);
+
+    SolrQueryRequestBase req = new SolrQueryRequestBase(collection1, params) {};
+    assertNotNull(factory.getInstance(req, null, null));
+    restTestHarness.delete(ManagedChatModelStore.REST_END_POINT + "/model1");
+  }
+
+  private UpdateRequestProcessor createUpdateProcessor(

Review Comment:
   why some test could not use these new functions?
   e.g. init_multipleInputFields_shouldInitAllFields



-- 
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: issues-unsubscribe@solr.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


---------------------------------------------------------------------
To unsubscribe, e-mail: issues-unsubscribe@solr.apache.org
For additional commands, e-mail: issues-help@solr.apache.org


Re: [PR] SOLR-18187: Document enrichment with LLMs [solr]

Posted by "aruggero (via GitHub)" <gi...@apache.org>.
aruggero commented on code in PR #4259:
URL: https://github.com/apache/solr/pull/4259#discussion_r3086970683


##########
solr/solr-ref-guide/modules/indexing-guide/pages/document-enrichment-with-llms.adoc:
##########
@@ -0,0 +1,543 @@
+= Document Enrichment with LLMs
+// 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.
+
+This module brings the power of *Large Language Models* to Solr.
+
+More specifically, it enables calling an LLM at indexing time to enrich documents with additional/generated/extracted
+data. Given a prompt and a set of input fields, for each document, the LLM is invoked through
+https://github.com/langchain4j/langchain4j[LangChain4j], and the result is stored in an outputField, which can support
+multiple types and may also be multivalued.
+
+_Without_ this module, the LLM calls to enrich documents must be done _outside_ Solr, before indexing.
+
+[IMPORTANT]
+====
+This module sends your documents off to some hosted service on the internet.
+There are cost, privacy, performance, and service availability implications on such a strong dependency that should be
+diligently examined before employing this module in a serious way.
+
+====
+
+At the moment, Solr supports a subset of the LLM providers available in LangChain4j.
+
+*Disclaimer*: Apache Solr is *in no way* affiliated to any of these corporations or services.
+
+If you want to add support for additional services or improve the support for the existing ones, feel free to
+contribute:
+
+* https://github.com/apache/solr/blob/main/CONTRIBUTING.md[Contributing to Solr]
+
+== Module
+
+This is provided via the `language-models` xref:configuration-guide:solr-modules.adoc[Solr Module] that needs to be
+enabled before use.
+
+== Language Model Configuration
+
+Language Models is a module and therefore its plugins must be configured in `solrconfig.xml`.
+
+=== Minimum Requirements
+
+* Enable the `language-models` module to make the Language Models classes available on Solr's classpath.
+See xref:configuration-guide:solr-modules.adoc[Solr Module] for more details.
+
+* An update processor, similar to the one below, must be declared in `solrconfig.xml`:
++
+[source,xml]
+----
+<updateRequestProcessorChain name="documentEnrichment">
+  <processor class="solr.languagemodels.documentenrichment.update.processor.DocumentEnrichmentUpdateProcessorFactory">
+   <str name="inputField">string_field</str>
+   <str name="outputField">summary</str>
+   <str name="prompt">Summarize this content: {string_field}</str>
+   <str name="model">model-name</str>
+  </processor>
+  <processor class="solr.RunUpdateProcessorFactory"/>
+ </updateRequestProcessorChain>
+----
+
+== Chat Model Setup
+
+=== Models
+
+* A model is a chat model that generates a text response given a prompt.
+* A model is a reference to an external API that runs the Large Language Model responsible for chat completion.
+
+[IMPORTANT]
+====
+The Solr chat model specifies the parameters to access the APIs, the LLM doesn't run internally in Solr
+
+====
+
+A model is described by these parameters:
+
+
+`class`::
++
+[%autowidth,frame=none]
+|===
+s|Required |Default: none
+|===
++
+The model implementation.
+Accepted values:
+
+* `dev.langchain4j.model.ollama.OllamaChatModel`
+* `dev.langchain4j.model.mistralai.MistralAiChatModel`
+* `dev.langchain4j.model.anthropic.AnthropicChatModel`
+* `dev.langchain4j.model.openai.OpenAiChatModel`
+* `dev.langchain4j.model.googleai.GoogleAiGeminiChatModel`
+
+`name`::
++
+[%autowidth,frame=none]
+|===
+s|Required |Default: none
+|===
++
+The identifier of your model, this is used by any component that intends to use the model (e.g., `DocumentEnrichmentUpdateProcessorFactory` update processor).
+
+`params`::
++
+[%autowidth,frame=none]
+|===
+|Optional |Default: none
+|===
++
+Each model class has potentially different params.
+Many are shared but for the full set of parameters of the model you are interested in please refer to the official documentation of the LangChain4j version included in Solr: https://docs.langchain4j.dev/category/language-models[Chat Models in LangChain4j].
+
+=== Supported Models
+Apache Solr uses https://github.com/langchain4j/langchain4j[LangChain4j] to support document enrichment with LLMs.
+The models currently supported are:
+
+[tabs#supported-chat-models]
+======
+Ollama::
++
+====
+
+[source,json]
+----
+{
+  "class": "dev.langchain4j.model.ollama.OllamaChatModel",
+  "name": "<a-name-for-your-model>",
+  "params": {
+    "baseUrl": "http://localhost:11434",
+    "modelName": "<a-local/hosted-chat-model>",
+    "timeout": 300,
+    "logRequests": true,
+    "logResponses": true,
+    "maxRetries": 5
+  }
+}
+----
+====
+
+MistralAI::
++
+====
+[source,json]
+----
+{
+  "class": "dev.langchain4j.model.mistralai.MistralAiChatModel",
+  "name": "<a-name-for-your-model>",
+  "params": {
+    "baseUrl": "https://api.mistral.ai/v1",
+    "apiKey": "<your-mistralAI-api-key>",
+    "modelName": "<a-mistralAI-chat-model>",
+    "timeout": 60,
+    "logRequests": true,
+    "logResponses": true,
+    "maxRetries": 5
+  }
+}
+----
+====
+OpenAI::
++
+====
+[source,json]
+----
+{
+  "class": "dev.langchain4j.model.openai.OpenAiChatModel",
+  "name": "<a-name-for-your-model>",
+  "params": {
+    "baseUrl": "https://api.openai.com/v1",
+    "apiKey": "<your-openAI-api-key>",
+    "modelName": "<a-openAI-chat-model>",
+    "timeout": 60,
+    "logRequests": true,
+    "logResponses": true,
+    "maxRetries": 5
+  }
+}
+----
+====
+
+Anthropic::
++
+====
+[source,json]
+----
+{
+  "class": "dev.langchain4j.model.anthropic.AnthropicChatModel",
+  "name": "<a-name-for-your-model>",
+  "params": {
+    "baseUrl": "https://api.anthropic.com/v1/",
+    "apiKey": "<your-anthropic-api-key>",
+    "modelName": "<a-anthropic-chat-model>",
+    "timeout": 60,
+    "logRequests": true,
+    "logResponses": true,
+    "maxRetries": 5
+  }
+}
+----
+====
+
+Gemini::
++
+====
+[source,json]
+----
+{
+  "class": "dev.langchain4j.model.googleai.GoogleAiGeminiChatModel",
+  "name": "<a-name-for-your-model>",
+  "params": {
+    "baseUrl": "https://generativelanguage.googleapis.com/v1beta/",
+    "apiKey": "<your-geminiAi-api-key>",
+    "modelName": "<a-geminiAi-chat-model>",
+    "timeout": 60,
+    "logRequests": true,
+    "logResponses": true,
+    "maxRetries": 5
+  }
+}
+----
+====
+======
+
+=== Uploading a Model
+
+To upload the model in a `/path/myModel.json` file, please run:
+
+[source,bash]
+----
+curl -XPUT 'http://localhost:8983/solr/YOUR_COLLECTION/schema/chat-model-store' --data-binary "@/path/myModel.json" -H 'Content-type:application/json'
+----
+
+To delete the `currentModel` model:
+
+[source,bash]
+----
+curl -XDELETE 'http://localhost:8983/solr/YOUR_COLLECTION/schema/chat-model-store/currentModel'
+----
+
+To view all models:
+
+[source,text]
+http://localhost:8983/solr/YOUR_COLLECTION/schema/chat-model-store
+
+
+.Example: /path/myOpenAIModel.json
+[source,json]
+----
+{
+  "class": "dev.langchain4j.model.openai.OpenAiChatModel",
+  "name": "openai-1",
+  "params": {
+    "baseUrl": "https://api.openai.com/v1",
+    "apiKey": "apiKey-openAI",
+    "modelName": "gpt-5.4-nano",
+    "timeout": 60,
+    "logRequests": true,
+    "logResponses": true,
+    "maxRetries": 5
+  }
+}
+----
+
+== How to Trigger Document Enrichment during Indexing
+To create new fields from existing document fields at indexing time, configure an
+{solr-javadocs}/core/org/apache/solr/update/processor/UpdateRequestProcessorChain.html[UpdateRequestProcessorChain] that
+includes at least one DocumentEnrichmentUpdateProcessor update request processor.
+
+Several parameters must be defined:
+
+`inputField`::
++
+[%autowidth,frame=none]
+|===
+s|Required |Default: none
+|===
++
+One (or more) `inputField` needs to be injected in the prompt. This is  done by some special tokens, that are the
+`fieldName` surrounded by curly brackets (e.g., `{string_field}`, in the example at the
+xref:document-enrichment-with-llms.adoc#minimum-requirements[top of the page]). These tokens are _mandatory_ for this
+module to work properly. Solr will throw an error if the parameters are not properly defined.
+For example, both the prompt or the content of the file `prompt.txt`, must contain the text '{string_field}', which
+will be substituted with the content of the `string_field` field for each document. An example of a valid prompt with
+multiple input fields is as follows:

Review Comment:
   maybe here I would say:
   Multiple `inputField` could also be defined by using one of the following notations:
   
   and then list the two ways



##########
solr/solr-ref-guide/modules/indexing-guide/pages/document-enrichment-with-llms.adoc:
##########
@@ -0,0 +1,543 @@
+= Document Enrichment with LLMs
+// 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.
+
+This module brings the power of *Large Language Models* to Solr.
+
+More specifically, it enables calling an LLM at indexing time to enrich documents with additional/generated/extracted
+data. Given a prompt and a set of input fields, for each document, the LLM is invoked through
+https://github.com/langchain4j/langchain4j[LangChain4j], and the result is stored in an outputField, which can support
+multiple types and may also be multivalued.
+
+_Without_ this module, the LLM calls to enrich documents must be done _outside_ Solr, before indexing.
+
+[IMPORTANT]
+====
+This module sends your documents off to some hosted service on the internet.
+There are cost, privacy, performance, and service availability implications on such a strong dependency that should be
+diligently examined before employing this module in a serious way.
+
+====
+
+At the moment, Solr supports a subset of the LLM providers available in LangChain4j.
+
+*Disclaimer*: Apache Solr is *in no way* affiliated to any of these corporations or services.
+
+If you want to add support for additional services or improve the support for the existing ones, feel free to
+contribute:
+
+* https://github.com/apache/solr/blob/main/CONTRIBUTING.md[Contributing to Solr]
+
+== Module
+
+This is provided via the `language-models` xref:configuration-guide:solr-modules.adoc[Solr Module] that needs to be
+enabled before use.
+
+== Language Model Configuration
+
+Language Models is a module and therefore its plugins must be configured in `solrconfig.xml`.
+
+=== Minimum Requirements
+
+* Enable the `language-models` module to make the Language Models classes available on Solr's classpath.
+See xref:configuration-guide:solr-modules.adoc[Solr Module] for more details.
+
+* An update processor, similar to the one below, must be declared in `solrconfig.xml`:
++
+[source,xml]
+----
+<updateRequestProcessorChain name="documentEnrichment">
+  <processor class="solr.languagemodels.documentenrichment.update.processor.DocumentEnrichmentUpdateProcessorFactory">
+   <str name="inputField">string_field</str>
+   <str name="outputField">summary</str>
+   <str name="prompt">Summarize this content: {string_field}</str>
+   <str name="model">model-name</str>
+  </processor>
+  <processor class="solr.RunUpdateProcessorFactory"/>
+ </updateRequestProcessorChain>
+----
+
+== Chat Model Setup
+
+=== Models
+
+* A model is a chat model that generates a text response given a prompt.
+* A model is a reference to an external API that runs the Large Language Model responsible for chat completion.
+
+[IMPORTANT]
+====
+The Solr chat model specifies the parameters to access the APIs, the LLM doesn't run internally in Solr
+
+====
+
+A model is described by these parameters:
+
+
+`class`::
++
+[%autowidth,frame=none]
+|===
+s|Required |Default: none
+|===
++
+The model implementation.
+Accepted values:
+
+* `dev.langchain4j.model.ollama.OllamaChatModel`
+* `dev.langchain4j.model.mistralai.MistralAiChatModel`
+* `dev.langchain4j.model.anthropic.AnthropicChatModel`
+* `dev.langchain4j.model.openai.OpenAiChatModel`
+* `dev.langchain4j.model.googleai.GoogleAiGeminiChatModel`
+
+`name`::
++
+[%autowidth,frame=none]
+|===
+s|Required |Default: none
+|===
++
+The identifier of your model, this is used by any component that intends to use the model (e.g., `DocumentEnrichmentUpdateProcessorFactory` update processor).
+
+`params`::
++
+[%autowidth,frame=none]
+|===
+|Optional |Default: none
+|===
++
+Each model class has potentially different params.
+Many are shared but for the full set of parameters of the model you are interested in please refer to the official documentation of the LangChain4j version included in Solr: https://docs.langchain4j.dev/category/language-models[Chat Models in LangChain4j].
+
+=== Supported Models
+Apache Solr uses https://github.com/langchain4j/langchain4j[LangChain4j] to support document enrichment with LLMs.
+The models currently supported are:
+
+[tabs#supported-chat-models]
+======
+Ollama::
++
+====
+
+[source,json]
+----
+{
+  "class": "dev.langchain4j.model.ollama.OllamaChatModel",
+  "name": "<a-name-for-your-model>",
+  "params": {
+    "baseUrl": "http://localhost:11434",
+    "modelName": "<a-local/hosted-chat-model>",
+    "timeout": 300,
+    "logRequests": true,
+    "logResponses": true,
+    "maxRetries": 5
+  }
+}
+----
+====
+
+MistralAI::
++
+====
+[source,json]
+----
+{
+  "class": "dev.langchain4j.model.mistralai.MistralAiChatModel",
+  "name": "<a-name-for-your-model>",
+  "params": {
+    "baseUrl": "https://api.mistral.ai/v1",
+    "apiKey": "<your-mistralAI-api-key>",
+    "modelName": "<a-mistralAI-chat-model>",
+    "timeout": 60,
+    "logRequests": true,
+    "logResponses": true,
+    "maxRetries": 5
+  }
+}
+----
+====
+OpenAI::
++
+====
+[source,json]
+----
+{
+  "class": "dev.langchain4j.model.openai.OpenAiChatModel",
+  "name": "<a-name-for-your-model>",
+  "params": {
+    "baseUrl": "https://api.openai.com/v1",
+    "apiKey": "<your-openAI-api-key>",
+    "modelName": "<a-openAI-chat-model>",
+    "timeout": 60,
+    "logRequests": true,
+    "logResponses": true,
+    "maxRetries": 5
+  }
+}
+----
+====
+
+Anthropic::
++
+====
+[source,json]
+----
+{
+  "class": "dev.langchain4j.model.anthropic.AnthropicChatModel",
+  "name": "<a-name-for-your-model>",
+  "params": {
+    "baseUrl": "https://api.anthropic.com/v1/",
+    "apiKey": "<your-anthropic-api-key>",
+    "modelName": "<a-anthropic-chat-model>",
+    "timeout": 60,
+    "logRequests": true,
+    "logResponses": true,
+    "maxRetries": 5
+  }
+}
+----
+====
+
+Gemini::
++
+====
+[source,json]
+----
+{
+  "class": "dev.langchain4j.model.googleai.GoogleAiGeminiChatModel",
+  "name": "<a-name-for-your-model>",
+  "params": {
+    "baseUrl": "https://generativelanguage.googleapis.com/v1beta/",
+    "apiKey": "<your-geminiAi-api-key>",
+    "modelName": "<a-geminiAi-chat-model>",
+    "timeout": 60,
+    "logRequests": true,
+    "logResponses": true,
+    "maxRetries": 5
+  }
+}
+----
+====
+======
+
+=== Uploading a Model
+
+To upload the model in a `/path/myModel.json` file, please run:
+
+[source,bash]
+----
+curl -XPUT 'http://localhost:8983/solr/YOUR_COLLECTION/schema/chat-model-store' --data-binary "@/path/myModel.json" -H 'Content-type:application/json'
+----
+
+To delete the `currentModel` model:
+
+[source,bash]
+----
+curl -XDELETE 'http://localhost:8983/solr/YOUR_COLLECTION/schema/chat-model-store/currentModel'
+----
+
+To view all models:
+
+[source,text]
+http://localhost:8983/solr/YOUR_COLLECTION/schema/chat-model-store
+
+
+.Example: /path/myOpenAIModel.json
+[source,json]
+----
+{
+  "class": "dev.langchain4j.model.openai.OpenAiChatModel",
+  "name": "openai-1",
+  "params": {
+    "baseUrl": "https://api.openai.com/v1",
+    "apiKey": "apiKey-openAI",
+    "modelName": "gpt-5.4-nano",
+    "timeout": 60,
+    "logRequests": true,
+    "logResponses": true,
+    "maxRetries": 5
+  }
+}
+----
+
+== How to Trigger Document Enrichment during Indexing

Review Comment:
   This part has not been moved to the top near the model configuration in the solrconfig



##########
solr/solr-ref-guide/modules/indexing-guide/pages/document-enrichment-with-llms.adoc:
##########
@@ -0,0 +1,543 @@
+= Document Enrichment with LLMs
+// 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.
+
+This module brings the power of *Large Language Models* to Solr.
+
+More specifically, it enables calling an LLM at indexing time to enrich documents with additional/generated/extracted
+data. Given a prompt and a set of input fields, for each document, the LLM is invoked through
+https://github.com/langchain4j/langchain4j[LangChain4j], and the result is stored in an outputField, which can support
+multiple types and may also be multivalued.
+
+_Without_ this module, the LLM calls to enrich documents must be done _outside_ Solr, before indexing.
+
+[IMPORTANT]
+====
+This module sends your documents off to some hosted service on the internet.
+There are cost, privacy, performance, and service availability implications on such a strong dependency that should be
+diligently examined before employing this module in a serious way.
+
+====
+
+At the moment, Solr supports a subset of the LLM providers available in LangChain4j.
+
+*Disclaimer*: Apache Solr is *in no way* affiliated to any of these corporations or services.
+
+If you want to add support for additional services or improve the support for the existing ones, feel free to
+contribute:
+
+* https://github.com/apache/solr/blob/main/CONTRIBUTING.md[Contributing to Solr]
+
+== Module
+
+This is provided via the `language-models` xref:configuration-guide:solr-modules.adoc[Solr Module] that needs to be
+enabled before use.
+
+== Language Model Configuration
+
+Language Models is a module and therefore its plugins must be configured in `solrconfig.xml`.
+
+=== Minimum Requirements
+
+* Enable the `language-models` module to make the Language Models classes available on Solr's classpath.
+See xref:configuration-guide:solr-modules.adoc[Solr Module] for more details.
+
+* An update processor, similar to the one below, must be declared in `solrconfig.xml`:
++
+[source,xml]
+----
+<updateRequestProcessorChain name="documentEnrichment">
+  <processor class="solr.languagemodels.documentenrichment.update.processor.DocumentEnrichmentUpdateProcessorFactory">
+   <str name="inputField">string_field</str>
+   <str name="outputField">summary</str>
+   <str name="prompt">Summarize this content: {string_field}</str>
+   <str name="model">model-name</str>
+  </processor>
+  <processor class="solr.RunUpdateProcessorFactory"/>
+ </updateRequestProcessorChain>
+----
+
+== Chat Model Setup
+
+=== Models
+
+* A model is a chat model that generates a text response given a prompt.
+* A model is a reference to an external API that runs the Large Language Model responsible for chat completion.
+
+[IMPORTANT]
+====
+The Solr chat model specifies the parameters to access the APIs, the LLM doesn't run internally in Solr
+
+====
+
+A model is described by these parameters:
+
+
+`class`::
++
+[%autowidth,frame=none]
+|===
+s|Required |Default: none
+|===
++
+The model implementation.
+Accepted values:
+
+* `dev.langchain4j.model.ollama.OllamaChatModel`
+* `dev.langchain4j.model.mistralai.MistralAiChatModel`
+* `dev.langchain4j.model.anthropic.AnthropicChatModel`
+* `dev.langchain4j.model.openai.OpenAiChatModel`
+* `dev.langchain4j.model.googleai.GoogleAiGeminiChatModel`
+
+`name`::
++
+[%autowidth,frame=none]
+|===
+s|Required |Default: none
+|===
++
+The identifier of your model, this is used by any component that intends to use the model (e.g., `DocumentEnrichmentUpdateProcessorFactory` update processor).
+
+`params`::
++
+[%autowidth,frame=none]
+|===
+|Optional |Default: none
+|===
++
+Each model class has potentially different params.
+Many are shared but for the full set of parameters of the model you are interested in please refer to the official documentation of the LangChain4j version included in Solr: https://docs.langchain4j.dev/category/language-models[Chat Models in LangChain4j].
+
+=== Supported Models
+Apache Solr uses https://github.com/langchain4j/langchain4j[LangChain4j] to support document enrichment with LLMs.
+The models currently supported are:
+
+[tabs#supported-chat-models]
+======
+Ollama::
++
+====
+
+[source,json]
+----
+{
+  "class": "dev.langchain4j.model.ollama.OllamaChatModel",
+  "name": "<a-name-for-your-model>",
+  "params": {
+    "baseUrl": "http://localhost:11434",
+    "modelName": "<a-local/hosted-chat-model>",
+    "timeout": 300,
+    "logRequests": true,
+    "logResponses": true,
+    "maxRetries": 5
+  }
+}
+----
+====
+
+MistralAI::
++
+====
+[source,json]
+----
+{
+  "class": "dev.langchain4j.model.mistralai.MistralAiChatModel",
+  "name": "<a-name-for-your-model>",
+  "params": {
+    "baseUrl": "https://api.mistral.ai/v1",
+    "apiKey": "<your-mistralAI-api-key>",
+    "modelName": "<a-mistralAI-chat-model>",
+    "timeout": 60,
+    "logRequests": true,
+    "logResponses": true,
+    "maxRetries": 5
+  }
+}
+----
+====
+OpenAI::
++
+====
+[source,json]
+----
+{
+  "class": "dev.langchain4j.model.openai.OpenAiChatModel",
+  "name": "<a-name-for-your-model>",
+  "params": {
+    "baseUrl": "https://api.openai.com/v1",
+    "apiKey": "<your-openAI-api-key>",
+    "modelName": "<a-openAI-chat-model>",
+    "timeout": 60,
+    "logRequests": true,
+    "logResponses": true,
+    "maxRetries": 5
+  }
+}
+----
+====
+
+Anthropic::
++
+====
+[source,json]
+----
+{
+  "class": "dev.langchain4j.model.anthropic.AnthropicChatModel",
+  "name": "<a-name-for-your-model>",
+  "params": {
+    "baseUrl": "https://api.anthropic.com/v1/",
+    "apiKey": "<your-anthropic-api-key>",
+    "modelName": "<a-anthropic-chat-model>",
+    "timeout": 60,
+    "logRequests": true,
+    "logResponses": true,
+    "maxRetries": 5
+  }
+}
+----
+====
+
+Gemini::
++
+====
+[source,json]
+----
+{
+  "class": "dev.langchain4j.model.googleai.GoogleAiGeminiChatModel",
+  "name": "<a-name-for-your-model>",
+  "params": {
+    "baseUrl": "https://generativelanguage.googleapis.com/v1beta/",
+    "apiKey": "<your-geminiAi-api-key>",
+    "modelName": "<a-geminiAi-chat-model>",
+    "timeout": 60,
+    "logRequests": true,
+    "logResponses": true,
+    "maxRetries": 5
+  }
+}
+----
+====
+======
+
+=== Uploading a Model
+
+To upload the model in a `/path/myModel.json` file, please run:
+
+[source,bash]
+----
+curl -XPUT 'http://localhost:8983/solr/YOUR_COLLECTION/schema/chat-model-store' --data-binary "@/path/myModel.json" -H 'Content-type:application/json'
+----
+
+To delete the `currentModel` model:
+
+[source,bash]
+----
+curl -XDELETE 'http://localhost:8983/solr/YOUR_COLLECTION/schema/chat-model-store/currentModel'
+----
+
+To view all models:
+
+[source,text]
+http://localhost:8983/solr/YOUR_COLLECTION/schema/chat-model-store
+
+
+.Example: /path/myOpenAIModel.json
+[source,json]
+----
+{
+  "class": "dev.langchain4j.model.openai.OpenAiChatModel",
+  "name": "openai-1",
+  "params": {
+    "baseUrl": "https://api.openai.com/v1",
+    "apiKey": "apiKey-openAI",
+    "modelName": "gpt-5.4-nano",
+    "timeout": 60,
+    "logRequests": true,
+    "logResponses": true,
+    "maxRetries": 5
+  }
+}
+----
+
+== How to Trigger Document Enrichment during Indexing
+To create new fields from existing document fields at indexing time, configure an
+{solr-javadocs}/core/org/apache/solr/update/processor/UpdateRequestProcessorChain.html[UpdateRequestProcessorChain] that
+includes at least one DocumentEnrichmentUpdateProcessor update request processor.
+
+Several parameters must be defined:
+
+`inputField`::
++
+[%autowidth,frame=none]
+|===
+s|Required |Default: none
+|===
++
+One (or more) `inputField` needs to be injected in the prompt. This is  done by some special tokens, that are the

Review Comment:
   At most, you could say that every inputField declared must be referred to in the prompt



##########
solr/solr-ref-guide/modules/indexing-guide/pages/document-enrichment-with-llms.adoc:
##########
@@ -0,0 +1,543 @@
+= Document Enrichment with LLMs
+// 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.
+
+This module brings the power of *Large Language Models* to Solr.
+
+More specifically, it enables calling an LLM at indexing time to enrich documents with additional/generated/extracted
+data. Given a prompt and a set of input fields, for each document, the LLM is invoked through
+https://github.com/langchain4j/langchain4j[LangChain4j], and the result is stored in an outputField, which can support
+multiple types and may also be multivalued.
+
+_Without_ this module, the LLM calls to enrich documents must be done _outside_ Solr, before indexing.
+
+[IMPORTANT]
+====
+This module sends your documents off to some hosted service on the internet.
+There are cost, privacy, performance, and service availability implications on such a strong dependency that should be
+diligently examined before employing this module in a serious way.
+
+====
+
+At the moment, Solr supports a subset of the LLM providers available in LangChain4j.
+
+*Disclaimer*: Apache Solr is *in no way* affiliated to any of these corporations or services.
+
+If you want to add support for additional services or improve the support for the existing ones, feel free to
+contribute:
+
+* https://github.com/apache/solr/blob/main/CONTRIBUTING.md[Contributing to Solr]
+
+== Module
+
+This is provided via the `language-models` xref:configuration-guide:solr-modules.adoc[Solr Module] that needs to be
+enabled before use.
+
+== Language Model Configuration
+
+Language Models is a module and therefore its plugins must be configured in `solrconfig.xml`.
+
+=== Minimum Requirements
+
+* Enable the `language-models` module to make the Language Models classes available on Solr's classpath.
+See xref:configuration-guide:solr-modules.adoc[Solr Module] for more details.
+
+* An update processor, similar to the one below, must be declared in `solrconfig.xml`:
++
+[source,xml]
+----
+<updateRequestProcessorChain name="documentEnrichment">
+  <processor class="solr.languagemodels.documentenrichment.update.processor.DocumentEnrichmentUpdateProcessorFactory">
+   <str name="inputField">string_field</str>
+   <str name="outputField">summary</str>
+   <str name="prompt">Summarize this content: {string_field}</str>
+   <str name="model">model-name</str>
+  </processor>
+  <processor class="solr.RunUpdateProcessorFactory"/>
+ </updateRequestProcessorChain>
+----
+
+== Chat Model Setup
+
+=== Models
+
+* A model is a chat model that generates a text response given a prompt.
+* A model is a reference to an external API that runs the Large Language Model responsible for chat completion.
+
+[IMPORTANT]
+====
+The Solr chat model specifies the parameters to access the APIs, the LLM doesn't run internally in Solr
+
+====
+
+A model is described by these parameters:
+
+
+`class`::
++
+[%autowidth,frame=none]
+|===
+s|Required |Default: none
+|===
++
+The model implementation.
+Accepted values:
+
+* `dev.langchain4j.model.ollama.OllamaChatModel`
+* `dev.langchain4j.model.mistralai.MistralAiChatModel`
+* `dev.langchain4j.model.anthropic.AnthropicChatModel`
+* `dev.langchain4j.model.openai.OpenAiChatModel`
+* `dev.langchain4j.model.googleai.GoogleAiGeminiChatModel`
+
+`name`::
++
+[%autowidth,frame=none]
+|===
+s|Required |Default: none
+|===
++
+The identifier of your model, this is used by any component that intends to use the model (e.g., `DocumentEnrichmentUpdateProcessorFactory` update processor).
+
+`params`::
++
+[%autowidth,frame=none]
+|===
+|Optional |Default: none
+|===
++
+Each model class has potentially different params.
+Many are shared but for the full set of parameters of the model you are interested in please refer to the official documentation of the LangChain4j version included in Solr: https://docs.langchain4j.dev/category/language-models[Chat Models in LangChain4j].
+
+=== Supported Models
+Apache Solr uses https://github.com/langchain4j/langchain4j[LangChain4j] to support document enrichment with LLMs.
+The models currently supported are:
+
+[tabs#supported-chat-models]
+======
+Ollama::
++
+====
+
+[source,json]
+----
+{
+  "class": "dev.langchain4j.model.ollama.OllamaChatModel",
+  "name": "<a-name-for-your-model>",
+  "params": {
+    "baseUrl": "http://localhost:11434",
+    "modelName": "<a-local/hosted-chat-model>",
+    "timeout": 300,
+    "logRequests": true,
+    "logResponses": true,
+    "maxRetries": 5
+  }
+}
+----
+====
+
+MistralAI::
++
+====
+[source,json]
+----
+{
+  "class": "dev.langchain4j.model.mistralai.MistralAiChatModel",
+  "name": "<a-name-for-your-model>",
+  "params": {
+    "baseUrl": "https://api.mistral.ai/v1",
+    "apiKey": "<your-mistralAI-api-key>",
+    "modelName": "<a-mistralAI-chat-model>",
+    "timeout": 60,
+    "logRequests": true,
+    "logResponses": true,
+    "maxRetries": 5
+  }
+}
+----
+====
+OpenAI::
++
+====
+[source,json]
+----
+{
+  "class": "dev.langchain4j.model.openai.OpenAiChatModel",
+  "name": "<a-name-for-your-model>",
+  "params": {
+    "baseUrl": "https://api.openai.com/v1",
+    "apiKey": "<your-openAI-api-key>",
+    "modelName": "<a-openAI-chat-model>",
+    "timeout": 60,
+    "logRequests": true,
+    "logResponses": true,
+    "maxRetries": 5
+  }
+}
+----
+====
+
+Anthropic::
++
+====
+[source,json]
+----
+{
+  "class": "dev.langchain4j.model.anthropic.AnthropicChatModel",
+  "name": "<a-name-for-your-model>",
+  "params": {
+    "baseUrl": "https://api.anthropic.com/v1/",
+    "apiKey": "<your-anthropic-api-key>",
+    "modelName": "<a-anthropic-chat-model>",
+    "timeout": 60,
+    "logRequests": true,
+    "logResponses": true,
+    "maxRetries": 5
+  }
+}
+----
+====
+
+Gemini::
++
+====
+[source,json]
+----
+{
+  "class": "dev.langchain4j.model.googleai.GoogleAiGeminiChatModel",
+  "name": "<a-name-for-your-model>",
+  "params": {
+    "baseUrl": "https://generativelanguage.googleapis.com/v1beta/",
+    "apiKey": "<your-geminiAi-api-key>",
+    "modelName": "<a-geminiAi-chat-model>",
+    "timeout": 60,
+    "logRequests": true,
+    "logResponses": true,
+    "maxRetries": 5
+  }
+}
+----
+====
+======
+
+=== Uploading a Model
+
+To upload the model in a `/path/myModel.json` file, please run:
+
+[source,bash]
+----
+curl -XPUT 'http://localhost:8983/solr/YOUR_COLLECTION/schema/chat-model-store' --data-binary "@/path/myModel.json" -H 'Content-type:application/json'
+----
+
+To delete the `currentModel` model:
+
+[source,bash]
+----
+curl -XDELETE 'http://localhost:8983/solr/YOUR_COLLECTION/schema/chat-model-store/currentModel'
+----
+
+To view all models:
+
+[source,text]
+http://localhost:8983/solr/YOUR_COLLECTION/schema/chat-model-store
+
+
+.Example: /path/myOpenAIModel.json
+[source,json]
+----
+{
+  "class": "dev.langchain4j.model.openai.OpenAiChatModel",
+  "name": "openai-1",
+  "params": {
+    "baseUrl": "https://api.openai.com/v1",
+    "apiKey": "apiKey-openAI",
+    "modelName": "gpt-5.4-nano",
+    "timeout": 60,
+    "logRequests": true,
+    "logResponses": true,
+    "maxRetries": 5
+  }
+}
+----
+
+== How to Trigger Document Enrichment during Indexing
+To create new fields from existing document fields at indexing time, configure an
+{solr-javadocs}/core/org/apache/solr/update/processor/UpdateRequestProcessorChain.html[UpdateRequestProcessorChain] that
+includes at least one DocumentEnrichmentUpdateProcessor update request processor.
+
+Several parameters must be defined:
+
+`inputField`::
++
+[%autowidth,frame=none]
+|===
+s|Required |Default: none
+|===
++
+One (or more) `inputField` needs to be injected in the prompt. This is  done by some special tokens, that are the
+`fieldName` surrounded by curly brackets (e.g., `{string_field}`, in the example at the
+xref:document-enrichment-with-llms.adoc#minimum-requirements[top of the page]). These tokens are _mandatory_ for this
+module to work properly. Solr will throw an error if the parameters are not properly defined.
+For example, both the prompt or the content of the file `prompt.txt`, must contain the text '{string_field}', which
+will be substituted with the content of the `string_field` field for each document. An example of a valid prompt with
+multiple input fields is as follows:
+
++
+[source,xml]
+----
+<updateRequestProcessorChain name="documentEnrichment">
+  <processor class="solr.languagemodels.documentenrichment.update.processor.DocumentEnrichmentUpdateProcessorFactory">
+   <str name="inputField">title</str>
+   <str name="inputField">body</str>
+   <str name="outputField">summary</str>
+   <str name="prompt">Summarize with the following information. Title: {title}. Body: {body}.</str>
+   <str name="model">chat-model</str>
+  </processor>
+  <processor class="solr.RunUpdateProcessorFactory"/>
+ </updateRequestProcessorChain>
+----
+
++
+Multiple `inputField` could also be defined by using the following notation:
+
++
+[source,xml]
+----
+<arr name="inputField">
+    <str>title</str>
+    <str>body</str>
+</arr>
+----
+
+
+`outputField`::
++
+[%autowidth,frame=none]
+|===
+s|Required |Default: none
+|===
++
+The LLM response is mapped to the specified `outputField`, and only one field is supported as output. Note that this
+module only supports a subset of Solr's available field types, which includes:
+
+* *String/Text*: `StrField`, `TextField`, `SortableTextField`
+* *Date*: `DatePointField` (the LLM must return an ISO-8601 date string; it might be useful to tune your prompt accordingly, to avoid indexing errors)
+* *Numeric*: `IntPointField`, `LongPointField`, `FloatPointField`, `DoublePointField`
+* *Boolean*: `BoolField`
+
+
+These fields _can_ be multivalued. Solr uses structured output from LangChain4j to deal with LLMs' responses.
+
+
+`prompt` or `promptFile`::

Review Comment:
   here I would say that there are two ways of defining a prompt, one directly in the config and one through a file...
   then I would explain how the prompt should be structured and the thing related to the inputFields



##########
solr/solr-ref-guide/modules/indexing-guide/pages/document-enrichment-with-llms.adoc:
##########
@@ -0,0 +1,543 @@
+= Document Enrichment with LLMs
+// 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.
+
+This module brings the power of *Large Language Models* to Solr.
+
+More specifically, it enables calling an LLM at indexing time to enrich documents with additional/generated/extracted
+data. Given a prompt and a set of input fields, for each document, the LLM is invoked through
+https://github.com/langchain4j/langchain4j[LangChain4j], and the result is stored in an outputField, which can support
+multiple types and may also be multivalued.
+
+_Without_ this module, the LLM calls to enrich documents must be done _outside_ Solr, before indexing.
+
+[IMPORTANT]
+====
+This module sends your documents off to some hosted service on the internet.
+There are cost, privacy, performance, and service availability implications on such a strong dependency that should be
+diligently examined before employing this module in a serious way.
+
+====
+
+At the moment, Solr supports a subset of the LLM providers available in LangChain4j.
+
+*Disclaimer*: Apache Solr is *in no way* affiliated to any of these corporations or services.
+
+If you want to add support for additional services or improve the support for the existing ones, feel free to
+contribute:
+
+* https://github.com/apache/solr/blob/main/CONTRIBUTING.md[Contributing to Solr]
+
+== Module
+
+This is provided via the `language-models` xref:configuration-guide:solr-modules.adoc[Solr Module] that needs to be
+enabled before use.
+
+== Language Model Configuration
+
+Language Models is a module and therefore its plugins must be configured in `solrconfig.xml`.
+
+=== Minimum Requirements
+
+* Enable the `language-models` module to make the Language Models classes available on Solr's classpath.
+See xref:configuration-guide:solr-modules.adoc[Solr Module] for more details.
+
+* An update processor, similar to the one below, must be declared in `solrconfig.xml`:
++
+[source,xml]
+----
+<updateRequestProcessorChain name="documentEnrichment">
+  <processor class="solr.languagemodels.documentenrichment.update.processor.DocumentEnrichmentUpdateProcessorFactory">
+   <str name="inputField">string_field</str>
+   <str name="outputField">summary</str>
+   <str name="prompt">Summarize this content: {string_field}</str>
+   <str name="model">model-name</str>
+  </processor>
+  <processor class="solr.RunUpdateProcessorFactory"/>
+ </updateRequestProcessorChain>
+----
+
+== Chat Model Setup
+
+=== Models
+
+* A model is a chat model that generates a text response given a prompt.
+* A model is a reference to an external API that runs the Large Language Model responsible for chat completion.
+
+[IMPORTANT]
+====
+The Solr chat model specifies the parameters to access the APIs, the LLM doesn't run internally in Solr
+
+====
+
+A model is described by these parameters:
+
+
+`class`::
++
+[%autowidth,frame=none]
+|===
+s|Required |Default: none
+|===
++
+The model implementation.
+Accepted values:
+
+* `dev.langchain4j.model.ollama.OllamaChatModel`
+* `dev.langchain4j.model.mistralai.MistralAiChatModel`
+* `dev.langchain4j.model.anthropic.AnthropicChatModel`
+* `dev.langchain4j.model.openai.OpenAiChatModel`
+* `dev.langchain4j.model.googleai.GoogleAiGeminiChatModel`
+
+`name`::
++
+[%autowidth,frame=none]
+|===
+s|Required |Default: none
+|===
++
+The identifier of your model, this is used by any component that intends to use the model (e.g., `DocumentEnrichmentUpdateProcessorFactory` update processor).
+
+`params`::
++
+[%autowidth,frame=none]
+|===
+|Optional |Default: none
+|===
++
+Each model class has potentially different params.
+Many are shared but for the full set of parameters of the model you are interested in please refer to the official documentation of the LangChain4j version included in Solr: https://docs.langchain4j.dev/category/language-models[Chat Models in LangChain4j].
+
+=== Supported Models
+Apache Solr uses https://github.com/langchain4j/langchain4j[LangChain4j] to support document enrichment with LLMs.
+The models currently supported are:
+
+[tabs#supported-chat-models]
+======
+Ollama::
++
+====
+
+[source,json]
+----
+{
+  "class": "dev.langchain4j.model.ollama.OllamaChatModel",
+  "name": "<a-name-for-your-model>",
+  "params": {
+    "baseUrl": "http://localhost:11434",
+    "modelName": "<a-local/hosted-chat-model>",
+    "timeout": 300,
+    "logRequests": true,
+    "logResponses": true,
+    "maxRetries": 5
+  }
+}
+----
+====
+
+MistralAI::
++
+====
+[source,json]
+----
+{
+  "class": "dev.langchain4j.model.mistralai.MistralAiChatModel",
+  "name": "<a-name-for-your-model>",
+  "params": {
+    "baseUrl": "https://api.mistral.ai/v1",
+    "apiKey": "<your-mistralAI-api-key>",
+    "modelName": "<a-mistralAI-chat-model>",
+    "timeout": 60,
+    "logRequests": true,
+    "logResponses": true,
+    "maxRetries": 5
+  }
+}
+----
+====
+OpenAI::
++
+====
+[source,json]
+----
+{
+  "class": "dev.langchain4j.model.openai.OpenAiChatModel",
+  "name": "<a-name-for-your-model>",
+  "params": {
+    "baseUrl": "https://api.openai.com/v1",
+    "apiKey": "<your-openAI-api-key>",
+    "modelName": "<a-openAI-chat-model>",
+    "timeout": 60,
+    "logRequests": true,
+    "logResponses": true,
+    "maxRetries": 5
+  }
+}
+----
+====
+
+Anthropic::
++
+====
+[source,json]
+----
+{
+  "class": "dev.langchain4j.model.anthropic.AnthropicChatModel",
+  "name": "<a-name-for-your-model>",
+  "params": {
+    "baseUrl": "https://api.anthropic.com/v1/",
+    "apiKey": "<your-anthropic-api-key>",
+    "modelName": "<a-anthropic-chat-model>",
+    "timeout": 60,
+    "logRequests": true,
+    "logResponses": true,
+    "maxRetries": 5
+  }
+}
+----
+====
+
+Gemini::
++
+====
+[source,json]
+----
+{
+  "class": "dev.langchain4j.model.googleai.GoogleAiGeminiChatModel",
+  "name": "<a-name-for-your-model>",
+  "params": {
+    "baseUrl": "https://generativelanguage.googleapis.com/v1beta/",
+    "apiKey": "<your-geminiAi-api-key>",
+    "modelName": "<a-geminiAi-chat-model>",
+    "timeout": 60,
+    "logRequests": true,
+    "logResponses": true,
+    "maxRetries": 5
+  }
+}
+----
+====
+======
+
+=== Uploading a Model
+
+To upload the model in a `/path/myModel.json` file, please run:
+
+[source,bash]
+----
+curl -XPUT 'http://localhost:8983/solr/YOUR_COLLECTION/schema/chat-model-store' --data-binary "@/path/myModel.json" -H 'Content-type:application/json'
+----
+
+To delete the `currentModel` model:
+
+[source,bash]
+----
+curl -XDELETE 'http://localhost:8983/solr/YOUR_COLLECTION/schema/chat-model-store/currentModel'
+----
+
+To view all models:
+
+[source,text]
+http://localhost:8983/solr/YOUR_COLLECTION/schema/chat-model-store
+
+
+.Example: /path/myOpenAIModel.json
+[source,json]
+----
+{
+  "class": "dev.langchain4j.model.openai.OpenAiChatModel",
+  "name": "openai-1",
+  "params": {
+    "baseUrl": "https://api.openai.com/v1",
+    "apiKey": "apiKey-openAI",
+    "modelName": "gpt-5.4-nano",
+    "timeout": 60,
+    "logRequests": true,
+    "logResponses": true,
+    "maxRetries": 5
+  }
+}
+----
+
+== How to Trigger Document Enrichment during Indexing
+To create new fields from existing document fields at indexing time, configure an
+{solr-javadocs}/core/org/apache/solr/update/processor/UpdateRequestProcessorChain.html[UpdateRequestProcessorChain] that
+includes at least one DocumentEnrichmentUpdateProcessor update request processor.
+
+Several parameters must be defined:
+
+`inputField`::
++
+[%autowidth,frame=none]
+|===
+s|Required |Default: none
+|===
++
+One (or more) `inputField` needs to be injected in the prompt. This is  done by some special tokens, that are the

Review Comment:
   I would just say this is the field whose content is used as input/passed to the LLM to enrich the document. And that there could be more than one inputField defined.
   
   I would move the other part about the special tokens to the prompt parameter explanation.



-- 
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: issues-unsubscribe@solr.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


---------------------------------------------------------------------
To unsubscribe, e-mail: issues-unsubscribe@solr.apache.org
For additional commands, e-mail: issues-help@solr.apache.org


Re: [PR] SOLR-18187: Document enrichment with LLMs [solr]

Posted by "aruggero (via GitHub)" <gi...@apache.org>.
aruggero commented on code in PR #4259:
URL: https://github.com/apache/solr/pull/4259#discussion_r3240140848


##########
changelog/unreleased/PR#4419-lenguage-model-test-refactoring.yml:
##########


Review Comment:
   @nicolo-rinaldi Does this come from the pull of the refactoring branch in this branch?



-- 
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: issues-unsubscribe@solr.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


---------------------------------------------------------------------
To unsubscribe, e-mail: issues-unsubscribe@solr.apache.org
For additional commands, e-mail: issues-help@solr.apache.org


Re: [PR] SOLR-18187: Document enrichment with LLMs [solr]

Posted by "nicolo-rinaldi (via GitHub)" <gi...@apache.org>.
nicolo-rinaldi commented on code in PR #4259:
URL: https://github.com/apache/solr/pull/4259#discussion_r3078397017


##########
solr/modules/language-models/src/java/org/apache/solr/languagemodels/documentenrichment/update/processor/DocumentEnrichmentUpdateProcessorFactory.java:
##########
@@ -0,0 +1,313 @@
+/*
+ * 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.solr.languagemodels.documentenrichment.update.processor;
+
+import dev.langchain4j.model.chat.request.ResponseFormat;
+import dev.langchain4j.model.chat.request.ResponseFormatType;
+import dev.langchain4j.model.chat.request.json.JsonArraySchema;
+import dev.langchain4j.model.chat.request.json.JsonBooleanSchema;
+import dev.langchain4j.model.chat.request.json.JsonIntegerSchema;
+import dev.langchain4j.model.chat.request.json.JsonNumberSchema;
+import dev.langchain4j.model.chat.request.json.JsonObjectSchema;
+import dev.langchain4j.model.chat.request.json.JsonSchema;
+import dev.langchain4j.model.chat.request.json.JsonSchemaElement;
+import dev.langchain4j.model.chat.request.json.JsonStringSchema;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import java.util.Collection;
+import java.util.HashSet;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import org.apache.solr.common.SolrException;
+import org.apache.solr.common.params.RequiredSolrParams;
+import org.apache.solr.common.params.SolrParams;
+import org.apache.solr.common.util.NamedList;
+import org.apache.solr.core.SolrCore;
+import org.apache.solr.core.SolrResourceLoader;
+import org.apache.solr.languagemodels.documentenrichment.model.SolrChatModel;
+import org.apache.solr.languagemodels.documentenrichment.store.rest.ManagedChatModelStore;
+import org.apache.solr.request.SolrQueryRequest;
+import org.apache.solr.response.SolrQueryResponse;
+import org.apache.solr.rest.ManagedResource;
+import org.apache.solr.rest.ManagedResourceObserver;
+import org.apache.solr.schema.BoolField;
+import org.apache.solr.schema.DatePointField;
+import org.apache.solr.schema.DenseVectorField;
+import org.apache.solr.schema.DoublePointField;
+import org.apache.solr.schema.FieldType;
+import org.apache.solr.schema.FloatPointField;
+import org.apache.solr.schema.IndexSchema;
+import org.apache.solr.schema.IntPointField;
+import org.apache.solr.schema.LongPointField;
+import org.apache.solr.schema.SchemaField;
+import org.apache.solr.schema.StrField;
+import org.apache.solr.schema.TextField;
+import org.apache.solr.update.processor.UpdateRequestProcessor;
+import org.apache.solr.update.processor.UpdateRequestProcessorFactory;
+import org.apache.solr.util.plugin.SolrCoreAware;
+
+/**
+ * Insert in an existing field the output of the model coming from one or more textual field values.
+ *
+ * <p>One or more {@code inputField} parameters specify the Solr fields to use as input. Each field
+ * name must appear as a {@code {fieldName}} placeholder in the prompt. Exactly one of {@code
+ * prompt} or {@code promptFile} must be provided.
+ *
+ * <pre class="prettyprint" >
+ * &lt;processor class=&quot;solr.llm.documentenrichment.update.processor.DocumentEnrichmentUpdateProcessorFactory&quot;&gt;
+ *   &lt;str name=&quot;inputField&quot;&gt;title_field&lt;/str&gt;
+ *   &lt;str name=&quot;inputField&quot;&gt;body_field&lt;/str&gt;
+ *   &lt;str name=&quot;outputField&quot;&gt;enriched_field&lt;/str&gt;
+ *   &lt;str name=&quot;prompt&quot;&gt;Title: {title_field}. Body: {body_field}.&lt;/str&gt;
+ *   &lt;str name=&quot;model&quot;&gt;ChatModel&lt;/str&gt;
+ * &lt;/processor&gt;
+ * </pre>
+ *
+ * <p>Alternatively, the prompt can be loaded from a text file using {@code promptFile}:
+ *
+ * <pre class="prettyprint" >
+ * &lt;processor class=&quot;solr.llm.documentenrichment.update.processor.DocumentEnrichmentUpdateProcessorFactory&quot;&gt;
+ *   &lt;str name=&quot;inputField&quot;&gt;title_field&lt;/str&gt;
+ *   &lt;str name=&quot;outputField&quot;&gt;enriched_field&lt;/str&gt;
+ *   &lt;str name=&quot;promptFile&quot;&gt;prompt.txt&lt;/str&gt;
+ *   &lt;str name=&quot;model&quot;&gt;ChatModel&lt;/str&gt;
+ * &lt;/processor&gt;
+ * </pre>
+ *
+ * <p>Validation rules:
+ *
+ * <ul>
+ *   <li>At least one {@code inputField} must be declared.
+ *   <li>Exactly one of {@code prompt} or {@code promptFile} must be provided.
+ *   <li>Every declared {@code inputField} must have a corresponding {@code {fieldName}} placeholder
+ *       in the prompt.
+ *   <li>Every {@code {placeholder}} in the prompt must correspond to a declared {@code inputField}.
+ * </ul>
+ */
+public class DocumentEnrichmentUpdateProcessorFactory extends UpdateRequestProcessorFactory
+    implements SolrCoreAware, ManagedResourceObserver {
+  private static final String INPUT_FIELD_PARAM = "inputField";
+  private static final String OUTPUT_FIELD_PARAM = "outputField";
+  private static final String PROMPT = "prompt";
+  private static final String PROMPT_FILE = "promptFile";
+  private static final String MODEL_NAME = "model";
+  private static final Pattern PLACEHOLDER_PATTERN = Pattern.compile("\\{([^}]+)\\}");
+
+  private List<String> inputFields;
+  private String outputField;
+  private String promptText;
+  private String promptFile;
+  private String modelName;
+
+  @Override
+  public void init(final NamedList<?> args) {
+    // removeConfigArgs handles both multiple <str name="inputField"> and <arr name="inputField">
+    // and must be called before toSolrParams() since it mutates args in place
+    Collection<String> fieldNames = args.removeConfigArgs(INPUT_FIELD_PARAM);
+    if (fieldNames.isEmpty()) {
+      throw new SolrException(
+          SolrException.ErrorCode.SERVER_ERROR,
+          "At least one 'inputField' must be provided");
+    }
+    inputFields = List.copyOf(fieldNames);
+
+    SolrParams params = args.toSolrParams();
+    RequiredSolrParams required = params.required();
+    outputField = required.get(OUTPUT_FIELD_PARAM);
+    modelName = required.get(MODEL_NAME);
+
+    String inlinePrompt = params.get(PROMPT);
+    String promptFilePath = params.get(PROMPT_FILE);
+
+    if (inlinePrompt == null && promptFilePath == null) {
+      throw new SolrException(
+          SolrException.ErrorCode.SERVER_ERROR,
+          "Either 'prompt' or 'promptFile' must be provided");
+    }
+    if (inlinePrompt != null && promptFilePath != null) {
+      throw new SolrException(
+          SolrException.ErrorCode.SERVER_ERROR,
+          "Only one of 'prompt' or 'promptFile' can be provided, not both");
+    }
+    if (inlinePrompt != null) {
+      validatePromptPlaceholders(inlinePrompt, inputFields);
+      this.promptText = inlinePrompt;
+    }
+    this.promptFile = promptFilePath;
+  }
+
+  @Override
+  public void inform(SolrCore core) {
+    final SolrResourceLoader solrResourceLoader = core.getResourceLoader();
+    ManagedChatModelStore.registerManagedChatModelStore(solrResourceLoader, this);
+    if (promptFile != null) {
+      try (InputStream is = solrResourceLoader.openResource(promptFile)) {
+        promptText = new String(is.readAllBytes(), StandardCharsets.UTF_8).trim();
+      } catch (IOException e) {
+        throw new SolrException(
+            SolrException.ErrorCode.SERVER_ERROR,
+            "Cannot read prompt file: " + promptFile,
+            e);
+      }
+      validatePromptPlaceholders(promptText, inputFields);
+    }
+  }
+
+  @Override
+  public void onManagedResourceInitialized(NamedList<?> args, ManagedResource res)
+      throws SolrException {
+    if (res instanceof ManagedChatModelStore store) {
+      store.loadStoredModels();
+    }
+  }
+
+  @Override
+  public UpdateRequestProcessor getInstance(
+      SolrQueryRequest req, SolrQueryResponse rsp, UpdateRequestProcessor next) {
+    IndexSchema latestSchema = req.getCore().getLatestSchema();
+
+    for (String fieldName : inputFields) {
+      if (!latestSchema.isDynamicField(fieldName) && !latestSchema.hasExplicitField(fieldName)) {
+        throw new SolrException(
+            SolrException.ErrorCode.SERVER_ERROR, "undefined field: \"" + fieldName + "\"");
+      }
+    }
+
+    final SchemaField outputFieldSchema = latestSchema.getField(outputField);
+
+    ResponseFormat responseFormat = buildResponseFormat(outputFieldSchema);
+    boolean multiValued = outputFieldSchema.multiValued();
+
+    ManagedChatModelStore store = ManagedChatModelStore.getManagedModelStore(req.getCore());
+    SolrChatModel chatModel = store.getModel(modelName);
+    if (chatModel == null) {
+      throw new SolrException(
+          SolrException.ErrorCode.SERVER_ERROR,
+          "The model configured in the Update Request Processor '"
+              + modelName
+              + "' can't be found in the store: "
+              + ManagedChatModelStore.REST_END_POINT);
+    }
+
+    return new DocumentEnrichmentUpdateProcessor(
+        inputFields, outputField, promptText, chatModel, multiValued, responseFormat, req, next);
+  }
+
+  /**
+   * Builds a {@link ResponseFormat} that instructs the model to return a JSON object {@code
+   * {"value": ...}} whose value type matches the Solr field type. For multivalued fields the value
+   * is wrapped in a {@link JsonArraySchema} nested inside the root {@link JsonObjectSchema}.
+   *
+   * <p>Nesting {@link JsonArraySchema} inside a {@link JsonObjectSchema} property is supported by
+   * all langchain4j providers that implement structured outputs with {@link JsonObjectSchema} (OpenAI, Azure OpenAI,
+   * Google AI, Gemini, Mistral, Ollama, Amazon Bedrock, Watsonx).
+   */
+  static ResponseFormat buildResponseFormat(SchemaField schemaField) {
+    JsonSchemaElement valueElement = toJsonSchemaElement(schemaField.getType());
+    JsonSchemaElement valueSchema =
+        schemaField.multiValued()
+            ? JsonArraySchema.builder().items(valueElement).build()
+            : valueElement;
+    return ResponseFormat.builder()
+        .type(ResponseFormatType.JSON)
+        .jsonSchema(
+            JsonSchema.builder()
+                .name("output")
+                .rootElement(
+                    JsonObjectSchema.builder()
+                        .addProperty("value", valueSchema)
+                        .required("value")
+                        .build())
+                .build())
+        .build();
+  }
+
+  private static JsonSchemaElement toJsonSchemaElement(FieldType fieldType) {

Review Comment:
   Yes, done



-- 
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: issues-unsubscribe@solr.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


---------------------------------------------------------------------
To unsubscribe, e-mail: issues-unsubscribe@solr.apache.org
For additional commands, e-mail: issues-help@solr.apache.org


Re: [PR] SOLR-18187: Document enrichment with LLMs [solr]

Posted by "nicolo-rinaldi (via GitHub)" <gi...@apache.org>.
nicolo-rinaldi commented on code in PR #4259:
URL: https://github.com/apache/solr/pull/4259#discussion_r3086801978


##########
solr/solr-ref-guide/modules/indexing-guide/pages/document-enrichment-with-llms.adoc:
##########
@@ -0,0 +1,500 @@
+= Document Enrichment with LLMs
+// 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.
+
+This module brings the power of *Large Language Models* to Solr.
+
+More specifically, it provides the capability, at indexing time, given a prompt and a set of input fields, of calling an
+LLM through https://github.com/langchain4j/langchain4j[LangChain4j] for each document and store the result of the call
+in an `outputField`, that can be of multiple types and even multivalued.
+
+_Without_ this module, the LLM calls must be done _outside_ Solr, before indexing.
+
+[IMPORTANT]
+====
+This module sends your documents off to some hosted service on the internet.
+There are cost, privacy, performance, and service availability implications on such a strong dependency that should be
+diligently examined before employing this module in a serious way.
+
+====
+
+At the moment a subset of LLM providers supported by LangChain4j is supported by Solr.
+
+*Disclaimer*: Apache Solr is *in no way* affiliated to any of these corporations or services.
+
+If you want to add support for additional services or improve the support for the existing ones, feel free to
+contribute:
+
+* https://github.com/apache/solr/blob/main/CONTRIBUTING.md[Contributing to Solr]
+
+== Module
+
+This is provided via the `language-models` xref:configuration-guide:solr-modules.adoc[Solr Module] that needs to be
+enabled before use.
+
+== Language Model Configuration
+
+Language Models is a module and therefore its plugins must be configured in `solrconfig.xml`.
+
+=== Minimum Requirements
+
+* Enable the `language-models` module to make the Language Models classes available on Solr's classpath.
+See xref:configuration-guide:solr-modules.adoc[Solr Module] for more details.
+
+* An update processor, similar to the one below, must be declared in `solrconfig.xml`:
++
+[source,xml]
+----
+<updateRequestProcessorChain name="documentEnrichment">
+  <processor class="solr.languagemodels.documentenrichment.update.processor.DocumentEnrichmentUpdateProcessorFactory">
+   <str name="inputField">string_field</str>
+   <str name="outputField">summary</str>
+   <str name="prompt">Summarize this content: {string_field}</str>
+   <str name="model">model-name</str>
+  </processor>
+  <processor class="solr.RunUpdateProcessorFactory"/>
+ </updateRequestProcessorChain>
+----
+[NOTE]
+====
+If no component is configured in `solrconfig.xml`, the `ChatModel` store will not be registered and requests to

Review Comment:
   removed



##########
solr/solr-ref-guide/modules/indexing-guide/pages/document-enrichment-with-llms.adoc:
##########
@@ -0,0 +1,500 @@
+= Document Enrichment with LLMs
+// 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.
+
+This module brings the power of *Large Language Models* to Solr.
+
+More specifically, it provides the capability, at indexing time, given a prompt and a set of input fields, of calling an
+LLM through https://github.com/langchain4j/langchain4j[LangChain4j] for each document and store the result of the call
+in an `outputField`, that can be of multiple types and even multivalued.
+
+_Without_ this module, the LLM calls must be done _outside_ Solr, before indexing.
+
+[IMPORTANT]
+====
+This module sends your documents off to some hosted service on the internet.
+There are cost, privacy, performance, and service availability implications on such a strong dependency that should be
+diligently examined before employing this module in a serious way.
+
+====
+
+At the moment a subset of LLM providers supported by LangChain4j is supported by Solr.
+
+*Disclaimer*: Apache Solr is *in no way* affiliated to any of these corporations or services.
+
+If you want to add support for additional services or improve the support for the existing ones, feel free to
+contribute:
+
+* https://github.com/apache/solr/blob/main/CONTRIBUTING.md[Contributing to Solr]
+
+== Module
+
+This is provided via the `language-models` xref:configuration-guide:solr-modules.adoc[Solr Module] that needs to be
+enabled before use.
+
+== Language Model Configuration
+
+Language Models is a module and therefore its plugins must be configured in `solrconfig.xml`.
+
+=== Minimum Requirements
+
+* Enable the `language-models` module to make the Language Models classes available on Solr's classpath.
+See xref:configuration-guide:solr-modules.adoc[Solr Module] for more details.
+
+* An update processor, similar to the one below, must be declared in `solrconfig.xml`:
++
+[source,xml]
+----
+<updateRequestProcessorChain name="documentEnrichment">
+  <processor class="solr.languagemodels.documentenrichment.update.processor.DocumentEnrichmentUpdateProcessorFactory">
+   <str name="inputField">string_field</str>
+   <str name="outputField">summary</str>
+   <str name="prompt">Summarize this content: {string_field}</str>
+   <str name="model">model-name</str>
+  </processor>
+  <processor class="solr.RunUpdateProcessorFactory"/>
+ </updateRequestProcessorChain>
+----
+[NOTE]
+====
+If no component is configured in `solrconfig.xml`, the `ChatModel` store will not be registered and requests to
+`/schema/chat-model-store` will return an error.
+====
+
+== Chat Model Configuration

Review Comment:
   changed



##########
solr/solr-ref-guide/modules/indexing-guide/pages/document-enrichment-with-llms.adoc:
##########
@@ -0,0 +1,500 @@
+= Document Enrichment with LLMs
+// 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.
+
+This module brings the power of *Large Language Models* to Solr.
+
+More specifically, it provides the capability, at indexing time, given a prompt and a set of input fields, of calling an
+LLM through https://github.com/langchain4j/langchain4j[LangChain4j] for each document and store the result of the call
+in an `outputField`, that can be of multiple types and even multivalued.
+
+_Without_ this module, the LLM calls must be done _outside_ Solr, before indexing.
+
+[IMPORTANT]
+====
+This module sends your documents off to some hosted service on the internet.
+There are cost, privacy, performance, and service availability implications on such a strong dependency that should be
+diligently examined before employing this module in a serious way.
+
+====
+
+At the moment a subset of LLM providers supported by LangChain4j is supported by Solr.
+
+*Disclaimer*: Apache Solr is *in no way* affiliated to any of these corporations or services.
+
+If you want to add support for additional services or improve the support for the existing ones, feel free to
+contribute:
+
+* https://github.com/apache/solr/blob/main/CONTRIBUTING.md[Contributing to Solr]
+
+== Module
+
+This is provided via the `language-models` xref:configuration-guide:solr-modules.adoc[Solr Module] that needs to be
+enabled before use.
+
+== Language Model Configuration
+
+Language Models is a module and therefore its plugins must be configured in `solrconfig.xml`.
+
+=== Minimum Requirements
+
+* Enable the `language-models` module to make the Language Models classes available on Solr's classpath.
+See xref:configuration-guide:solr-modules.adoc[Solr Module] for more details.
+
+* An update processor, similar to the one below, must be declared in `solrconfig.xml`:
++
+[source,xml]
+----
+<updateRequestProcessorChain name="documentEnrichment">
+  <processor class="solr.languagemodels.documentenrichment.update.processor.DocumentEnrichmentUpdateProcessorFactory">
+   <str name="inputField">string_field</str>
+   <str name="outputField">summary</str>
+   <str name="prompt">Summarize this content: {string_field}</str>
+   <str name="model">model-name</str>
+  </processor>
+  <processor class="solr.RunUpdateProcessorFactory"/>
+ </updateRequestProcessorChain>
+----
+[NOTE]
+====
+If no component is configured in `solrconfig.xml`, the `ChatModel` store will not be registered and requests to
+`/schema/chat-model-store` will return an error.
+====
+
+== Chat Model Configuration
+
+=== Models
+
+* A model in this module is a chat model, that answers with text given a prompt.
+* A model in this Solr module is a reference to an external API that runs the Large Language Model responsible for chat
+completion.
+
+[IMPORTANT]
+====
+The Solr chat model specifies the parameters to access the APIs, the LLM doesn't run internally in Solr
+
+====
+
+A model is described by these parameters:
+
+
+`class`::
++
+[%autowidth,frame=none]
+|===
+s|Required |Default: none
+|===
++
+The model implementation.
+Accepted values:
+
+* `dev.langchain4j.model.ollama.OllamaChatModel`
+* `dev.langchain4j.model.mistralai.MistralAiChatModel`
+* `dev.langchain4j.model.anthropic.AnthropicChatModel`
+* `dev.langchain4j.model.openai.OpenAiChatModel`
+* `dev.langchain4j.model.googleai.GoogleAiGeminiChatModel`
+
+`name`::
++
+[%autowidth,frame=none]
+|===
+s|Required |Default: none
+|===
++
+The identifier of your model, this is used by any component that intends to use the model (e.g., `DocumentEnrichmentUpdateProcessorFactory` update processor).
+
+`params`::
++
+[%autowidth,frame=none]
+|===
+|Optional |Default: none
+|===
++
+Each model class has potentially different params.
+Many are shared but for the full set of parameters of the model you are interested in please refer to the official documentation of the LangChain4j version included in Solr: https://docs.langchain4j.dev/category/language-models[Chat Models in LangChain4j].
+
+=== Supported Models
+Apache Solr uses https://github.com/langchain4j/langchain4j[LangChain4j] to support document enrichment with LLMs.
+The models currently supported are:
+
+[tabs#supported-chat-models]
+======
+Ollama::
++
+====
+
+[source,json]
+----
+{
+  "class": "dev.langchain4j.model.ollama.OllamaChatModel",
+  "name": "<a-name-for-your-model>",
+  "params": {
+    "baseUrl": "http://localhost:11434",
+    "modelName": "<a-local/hosted-chat-model>",
+    "timeout": 300,
+    "logRequests": true,
+    "logResponses": true,
+    "maxRetries": 5
+  }
+}
+----
+====
+
+MistralAI::
++
+====
+[source,json]
+----
+{
+  "class": "dev.langchain4j.model.mistralai.MistralAiChatModel",
+  "name": "<a-name-for-your-model>",
+  "params": {
+    "baseUrl": "https://api.mistral.ai/v1",
+    "apiKey": "<your-mistralAI-api-key>",
+    "modelName": "<a-mistralAI-chat-model>",
+    "timeout": 60,
+    "logRequests": true,
+    "logResponses": true,
+    "maxRetries": 5
+  }
+}
+----
+====
+OpenAI::
++
+====
+[source,json]
+----
+{
+  "class": "dev.langchain4j.model.openai.OpenAiChatModel",
+  "name": "<a-name-for-your-model>",
+  "params": {
+    "baseUrl": "https://api.openai.com/v1",
+    "apiKey": "<your-openAI-api-key>",
+    "modelName": "<a-openAI-chat-model>",
+    "timeout": 60,
+    "logRequests": true,
+    "logResponses": true,
+    "maxRetries": 5
+  }
+}
+----
+====
+
+Anthropic::
++
+====
+[source,json]
+----
+{
+  "class": "dev.langchain4j.model.anthropic.AnthropicChatModel",
+  "name": "<a-name-for-your-model>",
+  "params": {
+    "baseUrl": "https://api.anthropic.com/v1/",
+    "apiKey": "<your-anthropic-api-key>",
+    "modelName": "<a-anthropic-chat-model>",
+    "timeout": 60,
+    "logRequests": true,
+    "logResponses": true,
+    "maxRetries": 5
+  }
+}
+----
+====
+
+Gemini::
++
+====
+[source,json]
+----
+{
+  "class": "dev.langchain4j.model.googleai.GoogleAiGeminiChatModel",
+  "name": "<a-name-for-your-model>",
+  "params": {
+    "baseUrl": "https://generativelanguage.googleapis.com/v1beta/",
+    "apiKey": "<your-geminiAi-api-key>",
+    "modelName": "<a-geminiAi-chat-model>",
+    "timeout": 60,
+    "logRequests": true,
+    "logResponses": true,
+    "maxRetries": 5
+  }
+}
+----
+====
+======
+
+=== Uploading a Model
+
+To upload the model in a `/path/myModel.json` file, please run:
+
+[source,bash]
+----
+curl -XPUT 'http://localhost:8983/solr/YOUR_COLLECTION/schema/chat-model-store' --data-binary "@/path/myModel.json" -H 'Content-type:application/json'
+----
+
+To delete the `currentModel` model:
+
+[source,bash]
+----
+curl -XDELETE 'http://localhost:8983/solr/YOUR_COLLECTION/schema/chat-model-store/currentModel'
+----
+
+To view all models:
+
+[source,text]
+http://localhost:8983/solr/YOUR_COLLECTION/schema/chat-model-store
+
+
+.Example: /path/myOpenAIModel.json
+[source,json]
+----
+{
+  "class": "dev.langchain4j.model.openai.OpenAiChatModel",
+  "name": "openai-1",
+  "params": {
+    "baseUrl": "https://api.openai.com/v1",
+    "apiKey": "apiKey-openAI",
+    "modelName": "gpt-5.4-nano",
+    "timeout": 60,
+    "logRequests": true,
+    "logResponses": true,
+    "maxRetries": 5
+  }
+}
+----
+
+== How to Trigger Document Enrichment during Indexing
+To create new fields starting from existent ones in your documents at indexing time you need to configure an {solr-javadocs}/core/org/apache/solr/update/processor/UpdateRequestProcessorChain.html[Update Request Processor Chain] that includes at least one `DocumentEnrichmentUpdateProcessor` update request processor in one of the 2 following way:

Review Comment:
   changed



##########
solr/solr-ref-guide/modules/indexing-guide/pages/document-enrichment-with-llms.adoc:
##########
@@ -0,0 +1,500 @@
+= Document Enrichment with LLMs
+// 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.
+
+This module brings the power of *Large Language Models* to Solr.
+
+More specifically, it provides the capability, at indexing time, given a prompt and a set of input fields, of calling an

Review Comment:
   changed



##########
solr/solr-ref-guide/modules/indexing-guide/pages/document-enrichment-with-llms.adoc:
##########
@@ -0,0 +1,500 @@
+= Document Enrichment with LLMs
+// 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.
+
+This module brings the power of *Large Language Models* to Solr.
+
+More specifically, it provides the capability, at indexing time, given a prompt and a set of input fields, of calling an
+LLM through https://github.com/langchain4j/langchain4j[LangChain4j] for each document and store the result of the call
+in an `outputField`, that can be of multiple types and even multivalued.
+
+_Without_ this module, the LLM calls must be done _outside_ Solr, before indexing.
+
+[IMPORTANT]
+====
+This module sends your documents off to some hosted service on the internet.
+There are cost, privacy, performance, and service availability implications on such a strong dependency that should be
+diligently examined before employing this module in a serious way.
+
+====
+
+At the moment a subset of LLM providers supported by LangChain4j is supported by Solr.
+
+*Disclaimer*: Apache Solr is *in no way* affiliated to any of these corporations or services.
+
+If you want to add support for additional services or improve the support for the existing ones, feel free to
+contribute:
+
+* https://github.com/apache/solr/blob/main/CONTRIBUTING.md[Contributing to Solr]
+
+== Module
+
+This is provided via the `language-models` xref:configuration-guide:solr-modules.adoc[Solr Module] that needs to be
+enabled before use.
+
+== Language Model Configuration
+
+Language Models is a module and therefore its plugins must be configured in `solrconfig.xml`.
+
+=== Minimum Requirements
+
+* Enable the `language-models` module to make the Language Models classes available on Solr's classpath.
+See xref:configuration-guide:solr-modules.adoc[Solr Module] for more details.
+
+* An update processor, similar to the one below, must be declared in `solrconfig.xml`:
++
+[source,xml]
+----
+<updateRequestProcessorChain name="documentEnrichment">
+  <processor class="solr.languagemodels.documentenrichment.update.processor.DocumentEnrichmentUpdateProcessorFactory">
+   <str name="inputField">string_field</str>
+   <str name="outputField">summary</str>
+   <str name="prompt">Summarize this content: {string_field}</str>
+   <str name="model">model-name</str>
+  </processor>
+  <processor class="solr.RunUpdateProcessorFactory"/>
+ </updateRequestProcessorChain>
+----
+[NOTE]
+====
+If no component is configured in `solrconfig.xml`, the `ChatModel` store will not be registered and requests to
+`/schema/chat-model-store` will return an error.
+====
+
+== Chat Model Configuration
+
+=== Models
+
+* A model in this module is a chat model, that answers with text given a prompt.
+* A model in this Solr module is a reference to an external API that runs the Large Language Model responsible for chat
+completion.
+
+[IMPORTANT]
+====
+The Solr chat model specifies the parameters to access the APIs, the LLM doesn't run internally in Solr
+
+====
+
+A model is described by these parameters:
+
+
+`class`::
++
+[%autowidth,frame=none]
+|===
+s|Required |Default: none
+|===
++
+The model implementation.
+Accepted values:
+
+* `dev.langchain4j.model.ollama.OllamaChatModel`
+* `dev.langchain4j.model.mistralai.MistralAiChatModel`
+* `dev.langchain4j.model.anthropic.AnthropicChatModel`
+* `dev.langchain4j.model.openai.OpenAiChatModel`
+* `dev.langchain4j.model.googleai.GoogleAiGeminiChatModel`
+
+`name`::
++
+[%autowidth,frame=none]
+|===
+s|Required |Default: none
+|===
++
+The identifier of your model, this is used by any component that intends to use the model (e.g., `DocumentEnrichmentUpdateProcessorFactory` update processor).
+
+`params`::
++
+[%autowidth,frame=none]
+|===
+|Optional |Default: none
+|===
++
+Each model class has potentially different params.
+Many are shared but for the full set of parameters of the model you are interested in please refer to the official documentation of the LangChain4j version included in Solr: https://docs.langchain4j.dev/category/language-models[Chat Models in LangChain4j].
+
+=== Supported Models
+Apache Solr uses https://github.com/langchain4j/langchain4j[LangChain4j] to support document enrichment with LLMs.
+The models currently supported are:
+
+[tabs#supported-chat-models]
+======
+Ollama::
++
+====
+
+[source,json]
+----
+{
+  "class": "dev.langchain4j.model.ollama.OllamaChatModel",
+  "name": "<a-name-for-your-model>",
+  "params": {
+    "baseUrl": "http://localhost:11434",
+    "modelName": "<a-local/hosted-chat-model>",
+    "timeout": 300,
+    "logRequests": true,
+    "logResponses": true,
+    "maxRetries": 5
+  }
+}
+----
+====
+
+MistralAI::
++
+====
+[source,json]
+----
+{
+  "class": "dev.langchain4j.model.mistralai.MistralAiChatModel",
+  "name": "<a-name-for-your-model>",
+  "params": {
+    "baseUrl": "https://api.mistral.ai/v1",
+    "apiKey": "<your-mistralAI-api-key>",
+    "modelName": "<a-mistralAI-chat-model>",
+    "timeout": 60,
+    "logRequests": true,
+    "logResponses": true,
+    "maxRetries": 5
+  }
+}
+----
+====
+OpenAI::
++
+====
+[source,json]
+----
+{
+  "class": "dev.langchain4j.model.openai.OpenAiChatModel",
+  "name": "<a-name-for-your-model>",
+  "params": {
+    "baseUrl": "https://api.openai.com/v1",
+    "apiKey": "<your-openAI-api-key>",
+    "modelName": "<a-openAI-chat-model>",
+    "timeout": 60,
+    "logRequests": true,
+    "logResponses": true,
+    "maxRetries": 5
+  }
+}
+----
+====
+
+Anthropic::
++
+====
+[source,json]
+----
+{
+  "class": "dev.langchain4j.model.anthropic.AnthropicChatModel",
+  "name": "<a-name-for-your-model>",
+  "params": {
+    "baseUrl": "https://api.anthropic.com/v1/",
+    "apiKey": "<your-anthropic-api-key>",
+    "modelName": "<a-anthropic-chat-model>",
+    "timeout": 60,
+    "logRequests": true,
+    "logResponses": true,
+    "maxRetries": 5
+  }
+}
+----
+====
+
+Gemini::
++
+====
+[source,json]
+----
+{
+  "class": "dev.langchain4j.model.googleai.GoogleAiGeminiChatModel",
+  "name": "<a-name-for-your-model>",
+  "params": {
+    "baseUrl": "https://generativelanguage.googleapis.com/v1beta/",
+    "apiKey": "<your-geminiAi-api-key>",
+    "modelName": "<a-geminiAi-chat-model>",
+    "timeout": 60,
+    "logRequests": true,
+    "logResponses": true,
+    "maxRetries": 5
+  }
+}
+----
+====
+======
+
+=== Uploading a Model
+
+To upload the model in a `/path/myModel.json` file, please run:
+
+[source,bash]
+----
+curl -XPUT 'http://localhost:8983/solr/YOUR_COLLECTION/schema/chat-model-store' --data-binary "@/path/myModel.json" -H 'Content-type:application/json'
+----
+
+To delete the `currentModel` model:
+
+[source,bash]
+----
+curl -XDELETE 'http://localhost:8983/solr/YOUR_COLLECTION/schema/chat-model-store/currentModel'
+----
+
+To view all models:
+
+[source,text]
+http://localhost:8983/solr/YOUR_COLLECTION/schema/chat-model-store
+
+
+.Example: /path/myOpenAIModel.json
+[source,json]
+----
+{
+  "class": "dev.langchain4j.model.openai.OpenAiChatModel",
+  "name": "openai-1",
+  "params": {
+    "baseUrl": "https://api.openai.com/v1",
+    "apiKey": "apiKey-openAI",
+    "modelName": "gpt-5.4-nano",
+    "timeout": 60,
+    "logRequests": true,
+    "logResponses": true,
+    "maxRetries": 5
+  }
+}
+----
+
+== How to Trigger Document Enrichment during Indexing
+To create new fields starting from existent ones in your documents at indexing time you need to configure an {solr-javadocs}/core/org/apache/solr/update/processor/UpdateRequestProcessorChain.html[Update Request Processor Chain] that includes at least one `DocumentEnrichmentUpdateProcessor` update request processor in one of the 2 following way:
+
+* Update processor with parameter `prompt`
++
+[source,xml]
+----
+<updateRequestProcessorChain name="documentEnrichment">
+  <processor class="solr.languagemodels.documentenrichment.update.processor.DocumentEnrichmentUpdateProcessorFactory">
+   <str name="inputField">string_field</str>
+   <str name="outputField">summary</str>
+   <str name="prompt">Summarize this content: {string_field}</str>
+   <str name="model">model-name</str>
+  </processor>
+  <processor class="solr.RunUpdateProcessorFactory"/>
+ </updateRequestProcessorChain>
+----
+
+* Update processor with parameter `promptFile`: in this case, the file `prompt.txt` must be uploaded to Solr similarly to any other configuration file (e.g., `solrconfig.xml`, `synonyms.txt`, etc.)
++
+[source,xml]
+----
+<updateRequestProcessorChain name="documentEnrichment">
+  <processor class="solr.languagemodels.documentenrichment.update.processor.DocumentEnrichmentUpdateProcessorFactory">
+   <str name="inputField">string_field</str>
+   <str name="outputField">summary</str>
+   <str name="promptFile">prompt.txt</str>
+   <str name="model">model-name</str>
+  </processor>
+  <processor class="solr.RunUpdateProcessorFactory"/>
+ </updateRequestProcessorChain>
+----
+
+Exactly one of the following parameters is required: `prompt` or `promptFile`.
+
+Another important feature of this module is that one (or more) `inputField` needs to be injected in the prompt. This is
+done by some special tokens, that are the `fieldName` surrounded by curly brackets (e.g., `{string_field}`, in the
+example above). These tokens are _mandatory_ for this module to work properly. Solr will throw an error if the
+parameters are not properly defined.
+For example, both the prompt and the content of the file prompt.txt, must contain the text '{string_field}', which
+will be substituted with the content of the `string_field` field for each document. An example of a valid prompt with
+multiple input fields is as follows:
+
+[source,xml]
+----
+<updateRequestProcessorChain name="documentEnrichment">
+  <processor class="solr.languagemodels.documentenrichment.update.processor.DocumentEnrichmentUpdateProcessorFactory">
+   <str name="inputField">title</str>
+   <str name="inputField">body</str>
+   <str name="outputField">summary</str>
+   <str name="prompt">Summarize with the following information. Title: {title}. Body: {body}.</str>
+   <str name="model">chat-model</str>
+  </processor>
+  <processor class="solr.RunUpdateProcessorFactory"/>
+ </updateRequestProcessorChain>
+----
+
+Another way of using more than one `inputField` is by using the following notation, instead of more than one parameter
+with the same name:
+[source,xml]
+----
+<arr name="inputField">
+    <str>title</str>
+    <str>body</str>
+</arr>
+----
+
+The LLM response is mapped to the specified `outputField`. Note that this module only supports a subset of Solr's
+available field types, which includes:
+
+* *String/Text*: `StrField`, `TextField`, `SortableTextField`
+* *Date*: `DatePointField` (the LLM must return an ISO-8601 date string; it might be useful to tune your prompt accordingly, to avoid indexing errors)
+* *Numeric*: `IntPointField`, `LongPointField`, `FloatPointField`, `DoublePointField`
+* *Boolean*: `BoolField`
+
+
+These fields _can_ be multivalued. Solr uses structured output from LangChain4j to deal with LLMs' responses.
+
+
+For more details on how to work with update request processors in Apache Solr, please refer to the dedicated page:
+xref:configuration-guide:update-request-processors.adoc[Update Request Processor]
+
+[IMPORTANT]
+====
+This update processor sends your document field content off to some hosted service on the internet.
+There are serious performance implications that should be diligently examined before employing this component in production.
+It will slow down substantially your indexing pipeline so make sure to stress test your solution before going live.
+
+====
+
+[NOTE]
+====
+If any `inputField` value is absent or empty for a given document, enrichment is silently skipped for that document:
+the `outputField` is not added and the document is indexed as-is.
+
+If the LLM call fails at runtime (e.g., network error, model timeout), the exception is caught and logged but is
+*non-fatal*: the document is still indexed without the `outputField`.
+Monitor your indexing logs to detect documents that were not enriched as expected.
+====
+
+=== Index first and enrich your documents on a second pass
+LLM calls are usually quite slow, so, depending on your use case it could be a good idea to index first your documents

Review Comment:
   changed



##########
solr/solr-ref-guide/modules/indexing-guide/pages/document-enrichment-with-llms.adoc:
##########
@@ -0,0 +1,500 @@
+= Document Enrichment with LLMs
+// 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.
+
+This module brings the power of *Large Language Models* to Solr.
+
+More specifically, it provides the capability, at indexing time, given a prompt and a set of input fields, of calling an
+LLM through https://github.com/langchain4j/langchain4j[LangChain4j] for each document and store the result of the call
+in an `outputField`, that can be of multiple types and even multivalued.
+
+_Without_ this module, the LLM calls must be done _outside_ Solr, before indexing.
+
+[IMPORTANT]
+====
+This module sends your documents off to some hosted service on the internet.
+There are cost, privacy, performance, and service availability implications on such a strong dependency that should be
+diligently examined before employing this module in a serious way.
+
+====
+
+At the moment a subset of LLM providers supported by LangChain4j is supported by Solr.
+
+*Disclaimer*: Apache Solr is *in no way* affiliated to any of these corporations or services.
+
+If you want to add support for additional services or improve the support for the existing ones, feel free to
+contribute:
+
+* https://github.com/apache/solr/blob/main/CONTRIBUTING.md[Contributing to Solr]
+
+== Module
+
+This is provided via the `language-models` xref:configuration-guide:solr-modules.adoc[Solr Module] that needs to be
+enabled before use.
+
+== Language Model Configuration
+
+Language Models is a module and therefore its plugins must be configured in `solrconfig.xml`.
+
+=== Minimum Requirements
+
+* Enable the `language-models` module to make the Language Models classes available on Solr's classpath.
+See xref:configuration-guide:solr-modules.adoc[Solr Module] for more details.
+
+* An update processor, similar to the one below, must be declared in `solrconfig.xml`:
++
+[source,xml]
+----
+<updateRequestProcessorChain name="documentEnrichment">
+  <processor class="solr.languagemodels.documentenrichment.update.processor.DocumentEnrichmentUpdateProcessorFactory">
+   <str name="inputField">string_field</str>
+   <str name="outputField">summary</str>
+   <str name="prompt">Summarize this content: {string_field}</str>
+   <str name="model">model-name</str>
+  </processor>
+  <processor class="solr.RunUpdateProcessorFactory"/>
+ </updateRequestProcessorChain>
+----
+[NOTE]
+====
+If no component is configured in `solrconfig.xml`, the `ChatModel` store will not be registered and requests to
+`/schema/chat-model-store` will return an error.
+====
+
+== Chat Model Configuration
+
+=== Models
+
+* A model in this module is a chat model, that answers with text given a prompt.
+* A model in this Solr module is a reference to an external API that runs the Large Language Model responsible for chat
+completion.
+
+[IMPORTANT]
+====
+The Solr chat model specifies the parameters to access the APIs, the LLM doesn't run internally in Solr
+
+====
+
+A model is described by these parameters:
+
+
+`class`::
++
+[%autowidth,frame=none]
+|===
+s|Required |Default: none
+|===
++
+The model implementation.
+Accepted values:
+
+* `dev.langchain4j.model.ollama.OllamaChatModel`
+* `dev.langchain4j.model.mistralai.MistralAiChatModel`
+* `dev.langchain4j.model.anthropic.AnthropicChatModel`
+* `dev.langchain4j.model.openai.OpenAiChatModel`
+* `dev.langchain4j.model.googleai.GoogleAiGeminiChatModel`
+
+`name`::
++
+[%autowidth,frame=none]
+|===
+s|Required |Default: none
+|===
++
+The identifier of your model, this is used by any component that intends to use the model (e.g., `DocumentEnrichmentUpdateProcessorFactory` update processor).
+
+`params`::
++
+[%autowidth,frame=none]
+|===
+|Optional |Default: none
+|===
++
+Each model class has potentially different params.
+Many are shared but for the full set of parameters of the model you are interested in please refer to the official documentation of the LangChain4j version included in Solr: https://docs.langchain4j.dev/category/language-models[Chat Models in LangChain4j].
+
+=== Supported Models
+Apache Solr uses https://github.com/langchain4j/langchain4j[LangChain4j] to support document enrichment with LLMs.
+The models currently supported are:
+
+[tabs#supported-chat-models]
+======
+Ollama::
++
+====
+
+[source,json]
+----
+{
+  "class": "dev.langchain4j.model.ollama.OllamaChatModel",
+  "name": "<a-name-for-your-model>",
+  "params": {
+    "baseUrl": "http://localhost:11434",
+    "modelName": "<a-local/hosted-chat-model>",
+    "timeout": 300,
+    "logRequests": true,
+    "logResponses": true,
+    "maxRetries": 5
+  }
+}
+----
+====
+
+MistralAI::
++
+====
+[source,json]
+----
+{
+  "class": "dev.langchain4j.model.mistralai.MistralAiChatModel",
+  "name": "<a-name-for-your-model>",
+  "params": {
+    "baseUrl": "https://api.mistral.ai/v1",
+    "apiKey": "<your-mistralAI-api-key>",
+    "modelName": "<a-mistralAI-chat-model>",
+    "timeout": 60,
+    "logRequests": true,
+    "logResponses": true,
+    "maxRetries": 5
+  }
+}
+----
+====
+OpenAI::
++
+====
+[source,json]
+----
+{
+  "class": "dev.langchain4j.model.openai.OpenAiChatModel",
+  "name": "<a-name-for-your-model>",
+  "params": {
+    "baseUrl": "https://api.openai.com/v1",
+    "apiKey": "<your-openAI-api-key>",
+    "modelName": "<a-openAI-chat-model>",
+    "timeout": 60,
+    "logRequests": true,
+    "logResponses": true,
+    "maxRetries": 5
+  }
+}
+----
+====
+
+Anthropic::
++
+====
+[source,json]
+----
+{
+  "class": "dev.langchain4j.model.anthropic.AnthropicChatModel",
+  "name": "<a-name-for-your-model>",
+  "params": {
+    "baseUrl": "https://api.anthropic.com/v1/",
+    "apiKey": "<your-anthropic-api-key>",
+    "modelName": "<a-anthropic-chat-model>",
+    "timeout": 60,
+    "logRequests": true,
+    "logResponses": true,
+    "maxRetries": 5
+  }
+}
+----
+====
+
+Gemini::
++
+====
+[source,json]
+----
+{
+  "class": "dev.langchain4j.model.googleai.GoogleAiGeminiChatModel",
+  "name": "<a-name-for-your-model>",
+  "params": {
+    "baseUrl": "https://generativelanguage.googleapis.com/v1beta/",
+    "apiKey": "<your-geminiAi-api-key>",
+    "modelName": "<a-geminiAi-chat-model>",
+    "timeout": 60,
+    "logRequests": true,
+    "logResponses": true,
+    "maxRetries": 5
+  }
+}
+----
+====
+======
+
+=== Uploading a Model
+
+To upload the model in a `/path/myModel.json` file, please run:
+
+[source,bash]
+----
+curl -XPUT 'http://localhost:8983/solr/YOUR_COLLECTION/schema/chat-model-store' --data-binary "@/path/myModel.json" -H 'Content-type:application/json'
+----
+
+To delete the `currentModel` model:
+
+[source,bash]
+----
+curl -XDELETE 'http://localhost:8983/solr/YOUR_COLLECTION/schema/chat-model-store/currentModel'
+----
+
+To view all models:
+
+[source,text]
+http://localhost:8983/solr/YOUR_COLLECTION/schema/chat-model-store
+
+
+.Example: /path/myOpenAIModel.json
+[source,json]
+----
+{
+  "class": "dev.langchain4j.model.openai.OpenAiChatModel",
+  "name": "openai-1",
+  "params": {
+    "baseUrl": "https://api.openai.com/v1",
+    "apiKey": "apiKey-openAI",
+    "modelName": "gpt-5.4-nano",
+    "timeout": 60,
+    "logRequests": true,
+    "logResponses": true,
+    "maxRetries": 5
+  }
+}
+----
+
+== How to Trigger Document Enrichment during Indexing
+To create new fields starting from existent ones in your documents at indexing time you need to configure an {solr-javadocs}/core/org/apache/solr/update/processor/UpdateRequestProcessorChain.html[Update Request Processor Chain] that includes at least one `DocumentEnrichmentUpdateProcessor` update request processor in one of the 2 following way:
+
+* Update processor with parameter `prompt`
++
+[source,xml]
+----
+<updateRequestProcessorChain name="documentEnrichment">
+  <processor class="solr.languagemodels.documentenrichment.update.processor.DocumentEnrichmentUpdateProcessorFactory">
+   <str name="inputField">string_field</str>
+   <str name="outputField">summary</str>
+   <str name="prompt">Summarize this content: {string_field}</str>
+   <str name="model">model-name</str>
+  </processor>
+  <processor class="solr.RunUpdateProcessorFactory"/>
+ </updateRequestProcessorChain>
+----
+
+* Update processor with parameter `promptFile`: in this case, the file `prompt.txt` must be uploaded to Solr similarly to any other configuration file (e.g., `solrconfig.xml`, `synonyms.txt`, etc.)
++
+[source,xml]
+----
+<updateRequestProcessorChain name="documentEnrichment">
+  <processor class="solr.languagemodels.documentenrichment.update.processor.DocumentEnrichmentUpdateProcessorFactory">
+   <str name="inputField">string_field</str>
+   <str name="outputField">summary</str>
+   <str name="promptFile">prompt.txt</str>
+   <str name="model">model-name</str>
+  </processor>
+  <processor class="solr.RunUpdateProcessorFactory"/>
+ </updateRequestProcessorChain>
+----
+
+Exactly one of the following parameters is required: `prompt` or `promptFile`.
+
+Another important feature of this module is that one (or more) `inputField` needs to be injected in the prompt. This is
+done by some special tokens, that are the `fieldName` surrounded by curly brackets (e.g., `{string_field}`, in the
+example above). These tokens are _mandatory_ for this module to work properly. Solr will throw an error if the

Review Comment:
   Added explicit reference



##########
solr/solr-ref-guide/modules/indexing-guide/pages/document-enrichment-with-llms.adoc:
##########
@@ -0,0 +1,500 @@
+= Document Enrichment with LLMs
+// 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.
+
+This module brings the power of *Large Language Models* to Solr.
+
+More specifically, it provides the capability, at indexing time, given a prompt and a set of input fields, of calling an
+LLM through https://github.com/langchain4j/langchain4j[LangChain4j] for each document and store the result of the call
+in an `outputField`, that can be of multiple types and even multivalued.
+
+_Without_ this module, the LLM calls must be done _outside_ Solr, before indexing.
+
+[IMPORTANT]
+====
+This module sends your documents off to some hosted service on the internet.
+There are cost, privacy, performance, and service availability implications on such a strong dependency that should be
+diligently examined before employing this module in a serious way.
+
+====
+
+At the moment a subset of LLM providers supported by LangChain4j is supported by Solr.
+
+*Disclaimer*: Apache Solr is *in no way* affiliated to any of these corporations or services.
+
+If you want to add support for additional services or improve the support for the existing ones, feel free to
+contribute:
+
+* https://github.com/apache/solr/blob/main/CONTRIBUTING.md[Contributing to Solr]
+
+== Module
+
+This is provided via the `language-models` xref:configuration-guide:solr-modules.adoc[Solr Module] that needs to be
+enabled before use.
+
+== Language Model Configuration
+
+Language Models is a module and therefore its plugins must be configured in `solrconfig.xml`.
+
+=== Minimum Requirements
+
+* Enable the `language-models` module to make the Language Models classes available on Solr's classpath.
+See xref:configuration-guide:solr-modules.adoc[Solr Module] for more details.
+
+* An update processor, similar to the one below, must be declared in `solrconfig.xml`:
++
+[source,xml]
+----
+<updateRequestProcessorChain name="documentEnrichment">
+  <processor class="solr.languagemodels.documentenrichment.update.processor.DocumentEnrichmentUpdateProcessorFactory">
+   <str name="inputField">string_field</str>
+   <str name="outputField">summary</str>
+   <str name="prompt">Summarize this content: {string_field}</str>
+   <str name="model">model-name</str>
+  </processor>
+  <processor class="solr.RunUpdateProcessorFactory"/>
+ </updateRequestProcessorChain>
+----
+[NOTE]
+====
+If no component is configured in `solrconfig.xml`, the `ChatModel` store will not be registered and requests to
+`/schema/chat-model-store` will return an error.
+====
+
+== Chat Model Configuration
+
+=== Models
+
+* A model in this module is a chat model, that answers with text given a prompt.
+* A model in this Solr module is a reference to an external API that runs the Large Language Model responsible for chat
+completion.
+
+[IMPORTANT]
+====
+The Solr chat model specifies the parameters to access the APIs, the LLM doesn't run internally in Solr
+
+====
+
+A model is described by these parameters:
+
+
+`class`::
++
+[%autowidth,frame=none]
+|===
+s|Required |Default: none
+|===
++
+The model implementation.
+Accepted values:
+
+* `dev.langchain4j.model.ollama.OllamaChatModel`
+* `dev.langchain4j.model.mistralai.MistralAiChatModel`
+* `dev.langchain4j.model.anthropic.AnthropicChatModel`
+* `dev.langchain4j.model.openai.OpenAiChatModel`
+* `dev.langchain4j.model.googleai.GoogleAiGeminiChatModel`
+
+`name`::
++
+[%autowidth,frame=none]
+|===
+s|Required |Default: none
+|===
++
+The identifier of your model, this is used by any component that intends to use the model (e.g., `DocumentEnrichmentUpdateProcessorFactory` update processor).
+
+`params`::
++
+[%autowidth,frame=none]
+|===
+|Optional |Default: none
+|===
++
+Each model class has potentially different params.
+Many are shared but for the full set of parameters of the model you are interested in please refer to the official documentation of the LangChain4j version included in Solr: https://docs.langchain4j.dev/category/language-models[Chat Models in LangChain4j].
+
+=== Supported Models
+Apache Solr uses https://github.com/langchain4j/langchain4j[LangChain4j] to support document enrichment with LLMs.
+The models currently supported are:
+
+[tabs#supported-chat-models]
+======
+Ollama::
++
+====
+
+[source,json]
+----
+{
+  "class": "dev.langchain4j.model.ollama.OllamaChatModel",
+  "name": "<a-name-for-your-model>",
+  "params": {
+    "baseUrl": "http://localhost:11434",
+    "modelName": "<a-local/hosted-chat-model>",
+    "timeout": 300,
+    "logRequests": true,
+    "logResponses": true,
+    "maxRetries": 5
+  }
+}
+----
+====
+
+MistralAI::
++
+====
+[source,json]
+----
+{
+  "class": "dev.langchain4j.model.mistralai.MistralAiChatModel",
+  "name": "<a-name-for-your-model>",
+  "params": {
+    "baseUrl": "https://api.mistral.ai/v1",
+    "apiKey": "<your-mistralAI-api-key>",
+    "modelName": "<a-mistralAI-chat-model>",
+    "timeout": 60,
+    "logRequests": true,
+    "logResponses": true,
+    "maxRetries": 5
+  }
+}
+----
+====
+OpenAI::
++
+====
+[source,json]
+----
+{
+  "class": "dev.langchain4j.model.openai.OpenAiChatModel",
+  "name": "<a-name-for-your-model>",
+  "params": {
+    "baseUrl": "https://api.openai.com/v1",
+    "apiKey": "<your-openAI-api-key>",
+    "modelName": "<a-openAI-chat-model>",
+    "timeout": 60,
+    "logRequests": true,
+    "logResponses": true,
+    "maxRetries": 5
+  }
+}
+----
+====
+
+Anthropic::
++
+====
+[source,json]
+----
+{
+  "class": "dev.langchain4j.model.anthropic.AnthropicChatModel",
+  "name": "<a-name-for-your-model>",
+  "params": {
+    "baseUrl": "https://api.anthropic.com/v1/",
+    "apiKey": "<your-anthropic-api-key>",
+    "modelName": "<a-anthropic-chat-model>",
+    "timeout": 60,
+    "logRequests": true,
+    "logResponses": true,
+    "maxRetries": 5
+  }
+}
+----
+====
+
+Gemini::
++
+====
+[source,json]
+----
+{
+  "class": "dev.langchain4j.model.googleai.GoogleAiGeminiChatModel",
+  "name": "<a-name-for-your-model>",
+  "params": {
+    "baseUrl": "https://generativelanguage.googleapis.com/v1beta/",
+    "apiKey": "<your-geminiAi-api-key>",
+    "modelName": "<a-geminiAi-chat-model>",
+    "timeout": 60,
+    "logRequests": true,
+    "logResponses": true,
+    "maxRetries": 5
+  }
+}
+----
+====
+======
+
+=== Uploading a Model
+
+To upload the model in a `/path/myModel.json` file, please run:
+
+[source,bash]
+----
+curl -XPUT 'http://localhost:8983/solr/YOUR_COLLECTION/schema/chat-model-store' --data-binary "@/path/myModel.json" -H 'Content-type:application/json'
+----
+
+To delete the `currentModel` model:
+
+[source,bash]
+----
+curl -XDELETE 'http://localhost:8983/solr/YOUR_COLLECTION/schema/chat-model-store/currentModel'
+----
+
+To view all models:
+
+[source,text]
+http://localhost:8983/solr/YOUR_COLLECTION/schema/chat-model-store
+
+
+.Example: /path/myOpenAIModel.json
+[source,json]
+----
+{
+  "class": "dev.langchain4j.model.openai.OpenAiChatModel",
+  "name": "openai-1",
+  "params": {
+    "baseUrl": "https://api.openai.com/v1",
+    "apiKey": "apiKey-openAI",
+    "modelName": "gpt-5.4-nano",
+    "timeout": 60,
+    "logRequests": true,
+    "logResponses": true,
+    "maxRetries": 5
+  }
+}
+----
+
+== How to Trigger Document Enrichment during Indexing
+To create new fields starting from existent ones in your documents at indexing time you need to configure an {solr-javadocs}/core/org/apache/solr/update/processor/UpdateRequestProcessorChain.html[Update Request Processor Chain] that includes at least one `DocumentEnrichmentUpdateProcessor` update request processor in one of the 2 following way:
+
+* Update processor with parameter `prompt`
++
+[source,xml]
+----
+<updateRequestProcessorChain name="documentEnrichment">
+  <processor class="solr.languagemodels.documentenrichment.update.processor.DocumentEnrichmentUpdateProcessorFactory">
+   <str name="inputField">string_field</str>
+   <str name="outputField">summary</str>
+   <str name="prompt">Summarize this content: {string_field}</str>
+   <str name="model">model-name</str>
+  </processor>
+  <processor class="solr.RunUpdateProcessorFactory"/>
+ </updateRequestProcessorChain>
+----
+
+* Update processor with parameter `promptFile`: in this case, the file `prompt.txt` must be uploaded to Solr similarly to any other configuration file (e.g., `solrconfig.xml`, `synonyms.txt`, etc.)
++
+[source,xml]
+----
+<updateRequestProcessorChain name="documentEnrichment">
+  <processor class="solr.languagemodels.documentenrichment.update.processor.DocumentEnrichmentUpdateProcessorFactory">
+   <str name="inputField">string_field</str>
+   <str name="outputField">summary</str>
+   <str name="promptFile">prompt.txt</str>
+   <str name="model">model-name</str>
+  </processor>
+  <processor class="solr.RunUpdateProcessorFactory"/>
+ </updateRequestProcessorChain>
+----
+
+Exactly one of the following parameters is required: `prompt` or `promptFile`.
+
+Another important feature of this module is that one (or more) `inputField` needs to be injected in the prompt. This is

Review Comment:
   Done



##########
solr/solr-ref-guide/modules/indexing-guide/pages/document-enrichment-with-llms.adoc:
##########
@@ -0,0 +1,500 @@
+= Document Enrichment with LLMs
+// 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.
+
+This module brings the power of *Large Language Models* to Solr.
+
+More specifically, it provides the capability, at indexing time, given a prompt and a set of input fields, of calling an
+LLM through https://github.com/langchain4j/langchain4j[LangChain4j] for each document and store the result of the call
+in an `outputField`, that can be of multiple types and even multivalued.
+
+_Without_ this module, the LLM calls must be done _outside_ Solr, before indexing.
+
+[IMPORTANT]
+====
+This module sends your documents off to some hosted service on the internet.
+There are cost, privacy, performance, and service availability implications on such a strong dependency that should be
+diligently examined before employing this module in a serious way.
+
+====
+
+At the moment a subset of LLM providers supported by LangChain4j is supported by Solr.
+
+*Disclaimer*: Apache Solr is *in no way* affiliated to any of these corporations or services.
+
+If you want to add support for additional services or improve the support for the existing ones, feel free to
+contribute:
+
+* https://github.com/apache/solr/blob/main/CONTRIBUTING.md[Contributing to Solr]
+
+== Module
+
+This is provided via the `language-models` xref:configuration-guide:solr-modules.adoc[Solr Module] that needs to be
+enabled before use.
+
+== Language Model Configuration
+
+Language Models is a module and therefore its plugins must be configured in `solrconfig.xml`.
+
+=== Minimum Requirements
+
+* Enable the `language-models` module to make the Language Models classes available on Solr's classpath.
+See xref:configuration-guide:solr-modules.adoc[Solr Module] for more details.
+
+* An update processor, similar to the one below, must be declared in `solrconfig.xml`:
++
+[source,xml]
+----
+<updateRequestProcessorChain name="documentEnrichment">
+  <processor class="solr.languagemodels.documentenrichment.update.processor.DocumentEnrichmentUpdateProcessorFactory">
+   <str name="inputField">string_field</str>
+   <str name="outputField">summary</str>
+   <str name="prompt">Summarize this content: {string_field}</str>
+   <str name="model">model-name</str>
+  </processor>
+  <processor class="solr.RunUpdateProcessorFactory"/>
+ </updateRequestProcessorChain>
+----
+[NOTE]
+====
+If no component is configured in `solrconfig.xml`, the `ChatModel` store will not be registered and requests to
+`/schema/chat-model-store` will return an error.
+====
+
+== Chat Model Configuration
+
+=== Models
+
+* A model in this module is a chat model, that answers with text given a prompt.
+* A model in this Solr module is a reference to an external API that runs the Large Language Model responsible for chat
+completion.
+
+[IMPORTANT]
+====
+The Solr chat model specifies the parameters to access the APIs, the LLM doesn't run internally in Solr
+
+====
+
+A model is described by these parameters:
+
+
+`class`::
++
+[%autowidth,frame=none]
+|===
+s|Required |Default: none
+|===
++
+The model implementation.
+Accepted values:
+
+* `dev.langchain4j.model.ollama.OllamaChatModel`
+* `dev.langchain4j.model.mistralai.MistralAiChatModel`
+* `dev.langchain4j.model.anthropic.AnthropicChatModel`
+* `dev.langchain4j.model.openai.OpenAiChatModel`
+* `dev.langchain4j.model.googleai.GoogleAiGeminiChatModel`
+
+`name`::
++
+[%autowidth,frame=none]
+|===
+s|Required |Default: none
+|===
++
+The identifier of your model, this is used by any component that intends to use the model (e.g., `DocumentEnrichmentUpdateProcessorFactory` update processor).
+
+`params`::
++
+[%autowidth,frame=none]
+|===
+|Optional |Default: none
+|===
++
+Each model class has potentially different params.
+Many are shared but for the full set of parameters of the model you are interested in please refer to the official documentation of the LangChain4j version included in Solr: https://docs.langchain4j.dev/category/language-models[Chat Models in LangChain4j].
+
+=== Supported Models
+Apache Solr uses https://github.com/langchain4j/langchain4j[LangChain4j] to support document enrichment with LLMs.
+The models currently supported are:
+
+[tabs#supported-chat-models]
+======
+Ollama::
++
+====
+
+[source,json]
+----
+{
+  "class": "dev.langchain4j.model.ollama.OllamaChatModel",
+  "name": "<a-name-for-your-model>",
+  "params": {
+    "baseUrl": "http://localhost:11434",
+    "modelName": "<a-local/hosted-chat-model>",
+    "timeout": 300,
+    "logRequests": true,
+    "logResponses": true,
+    "maxRetries": 5
+  }
+}
+----
+====
+
+MistralAI::
++
+====
+[source,json]
+----
+{
+  "class": "dev.langchain4j.model.mistralai.MistralAiChatModel",
+  "name": "<a-name-for-your-model>",
+  "params": {
+    "baseUrl": "https://api.mistral.ai/v1",
+    "apiKey": "<your-mistralAI-api-key>",
+    "modelName": "<a-mistralAI-chat-model>",
+    "timeout": 60,
+    "logRequests": true,
+    "logResponses": true,
+    "maxRetries": 5
+  }
+}
+----
+====
+OpenAI::
++
+====
+[source,json]
+----
+{
+  "class": "dev.langchain4j.model.openai.OpenAiChatModel",
+  "name": "<a-name-for-your-model>",
+  "params": {
+    "baseUrl": "https://api.openai.com/v1",
+    "apiKey": "<your-openAI-api-key>",
+    "modelName": "<a-openAI-chat-model>",
+    "timeout": 60,
+    "logRequests": true,
+    "logResponses": true,
+    "maxRetries": 5
+  }
+}
+----
+====
+
+Anthropic::
++
+====
+[source,json]
+----
+{
+  "class": "dev.langchain4j.model.anthropic.AnthropicChatModel",
+  "name": "<a-name-for-your-model>",
+  "params": {
+    "baseUrl": "https://api.anthropic.com/v1/",
+    "apiKey": "<your-anthropic-api-key>",
+    "modelName": "<a-anthropic-chat-model>",
+    "timeout": 60,
+    "logRequests": true,
+    "logResponses": true,
+    "maxRetries": 5
+  }
+}
+----
+====
+
+Gemini::
++
+====
+[source,json]
+----
+{
+  "class": "dev.langchain4j.model.googleai.GoogleAiGeminiChatModel",
+  "name": "<a-name-for-your-model>",
+  "params": {
+    "baseUrl": "https://generativelanguage.googleapis.com/v1beta/",
+    "apiKey": "<your-geminiAi-api-key>",
+    "modelName": "<a-geminiAi-chat-model>",
+    "timeout": 60,
+    "logRequests": true,
+    "logResponses": true,
+    "maxRetries": 5
+  }
+}
+----
+====
+======
+
+=== Uploading a Model
+
+To upload the model in a `/path/myModel.json` file, please run:
+
+[source,bash]
+----
+curl -XPUT 'http://localhost:8983/solr/YOUR_COLLECTION/schema/chat-model-store' --data-binary "@/path/myModel.json" -H 'Content-type:application/json'
+----
+
+To delete the `currentModel` model:
+
+[source,bash]
+----
+curl -XDELETE 'http://localhost:8983/solr/YOUR_COLLECTION/schema/chat-model-store/currentModel'
+----
+
+To view all models:
+
+[source,text]
+http://localhost:8983/solr/YOUR_COLLECTION/schema/chat-model-store
+
+
+.Example: /path/myOpenAIModel.json
+[source,json]
+----
+{
+  "class": "dev.langchain4j.model.openai.OpenAiChatModel",
+  "name": "openai-1",
+  "params": {
+    "baseUrl": "https://api.openai.com/v1",
+    "apiKey": "apiKey-openAI",
+    "modelName": "gpt-5.4-nano",
+    "timeout": 60,
+    "logRequests": true,
+    "logResponses": true,
+    "maxRetries": 5
+  }
+}
+----
+
+== How to Trigger Document Enrichment during Indexing
+To create new fields starting from existent ones in your documents at indexing time you need to configure an {solr-javadocs}/core/org/apache/solr/update/processor/UpdateRequestProcessorChain.html[Update Request Processor Chain] that includes at least one `DocumentEnrichmentUpdateProcessor` update request processor in one of the 2 following way:
+
+* Update processor with parameter `prompt`
++
+[source,xml]
+----
+<updateRequestProcessorChain name="documentEnrichment">
+  <processor class="solr.languagemodels.documentenrichment.update.processor.DocumentEnrichmentUpdateProcessorFactory">
+   <str name="inputField">string_field</str>
+   <str name="outputField">summary</str>
+   <str name="prompt">Summarize this content: {string_field}</str>
+   <str name="model">model-name</str>
+  </processor>
+  <processor class="solr.RunUpdateProcessorFactory"/>
+ </updateRequestProcessorChain>
+----
+
+* Update processor with parameter `promptFile`: in this case, the file `prompt.txt` must be uploaded to Solr similarly to any other configuration file (e.g., `solrconfig.xml`, `synonyms.txt`, etc.)
++
+[source,xml]
+----
+<updateRequestProcessorChain name="documentEnrichment">
+  <processor class="solr.languagemodels.documentenrichment.update.processor.DocumentEnrichmentUpdateProcessorFactory">
+   <str name="inputField">string_field</str>
+   <str name="outputField">summary</str>
+   <str name="promptFile">prompt.txt</str>
+   <str name="model">model-name</str>
+  </processor>
+  <processor class="solr.RunUpdateProcessorFactory"/>
+ </updateRequestProcessorChain>
+----
+
+Exactly one of the following parameters is required: `prompt` or `promptFile`.
+
+Another important feature of this module is that one (or more) `inputField` needs to be injected in the prompt. This is
+done by some special tokens, that are the `fieldName` surrounded by curly brackets (e.g., `{string_field}`, in the
+example above). These tokens are _mandatory_ for this module to work properly. Solr will throw an error if the
+parameters are not properly defined.
+For example, both the prompt and the content of the file prompt.txt, must contain the text '{string_field}', which
+will be substituted with the content of the `string_field` field for each document. An example of a valid prompt with
+multiple input fields is as follows:
+
+[source,xml]
+----
+<updateRequestProcessorChain name="documentEnrichment">
+  <processor class="solr.languagemodels.documentenrichment.update.processor.DocumentEnrichmentUpdateProcessorFactory">
+   <str name="inputField">title</str>
+   <str name="inputField">body</str>
+   <str name="outputField">summary</str>
+   <str name="prompt">Summarize with the following information. Title: {title}. Body: {body}.</str>
+   <str name="model">chat-model</str>
+  </processor>
+  <processor class="solr.RunUpdateProcessorFactory"/>
+ </updateRequestProcessorChain>
+----
+
+Another way of using more than one `inputField` is by using the following notation, instead of more than one parameter
+with the same name:
+[source,xml]
+----
+<arr name="inputField">
+    <str>title</str>
+    <str>body</str>
+</arr>
+----
+
+The LLM response is mapped to the specified `outputField`. Note that this module only supports a subset of Solr's
+available field types, which includes:
+
+* *String/Text*: `StrField`, `TextField`, `SortableTextField`
+* *Date*: `DatePointField` (the LLM must return an ISO-8601 date string; it might be useful to tune your prompt accordingly, to avoid indexing errors)
+* *Numeric*: `IntPointField`, `LongPointField`, `FloatPointField`, `DoublePointField`
+* *Boolean*: `BoolField`
+
+
+These fields _can_ be multivalued. Solr uses structured output from LangChain4j to deal with LLMs' responses.
+
+
+For more details on how to work with update request processors in Apache Solr, please refer to the dedicated page:
+xref:configuration-guide:update-request-processors.adoc[Update Request Processor]
+
+[IMPORTANT]
+====
+This update processor sends your document field content off to some hosted service on the internet.
+There are serious performance implications that should be diligently examined before employing this component in production.
+It will slow down substantially your indexing pipeline so make sure to stress test your solution before going live.
+
+====
+
+[NOTE]
+====
+If any `inputField` value is absent or empty for a given document, enrichment is silently skipped for that document:
+the `outputField` is not added and the document is indexed as-is.
+
+If the LLM call fails at runtime (e.g., network error, model timeout), the exception is caught and logged but is
+*non-fatal*: the document is still indexed without the `outputField`.
+Monitor your indexing logs to detect documents that were not enriched as expected.
+====
+
+=== Index first and enrich your documents on a second pass
+LLM calls are usually quite slow, so, depending on your use case it could be a good idea to index first your documents
+enrich them with new LLM-generated fields later on.
+
+This can be done in Solr defining two update request processors chains: one that includes all the processors you need,
+excluding the `DocumentEnrichmentUpdateProcessor` (let's call it 'no-enrichment') and one that includes the
+`DocumentEnrichmentUpdateProcessor` (let's call it 'enrichment').
+
+[source,xml]
+----
+<updateRequestProcessorChain name="no-enrichment">
+  <processor class="solr.processor1">
+   ...
+  </processor>
+   ...
+  <processor class="solr.processorN">
+   ...
+  </processor>
+  <processor class="solr.RunUpdateProcessorFactory"/>
+</updateRequestProcessorChain>
+----
+
+[source,xml]
+----
+<updateRequestProcessorChain name="enrichment">
+  <processor class="solr.processor1">
+   ...
+  </processor>
+   ...
+  <processor class="solr.processorN">
+   ...
+  </processor>
+  <processor class="solr.languagemodels.documentenrichment.update.processor.DocumentEnrichmentUpdateProcessorFactory">
+   <str name="inputField">string_field</str>
+   <str name="outputField">summary</str>
+   <str name="prompt">Summarize this content: {string_field}</str>
+   <str name="model">chat-model</str>
+  </processor>
+  <processor class="solr.RunUpdateProcessorFactory"/>
+</updateRequestProcessorChain>
+----
+
+You would index your documents first using the 'no-enrichment' and when finished, incrementally repeat the indexing
+targeting the 'enrichment' chain.
+
+[IMPORTANT]
+====
+This implies you need to send the documents you want to index to Solr twice and re-run any other update request
+processor you need, in the second chain. This has data traffic implications (you transfer your documents over the
+network twice) and processing implications (if you have other update request processors in your chain, those must be
+repeated the second time as we are literally replacing the indexed documents one by one).
+====
+
+If your use case is compatible with xref:indexing-guide:partial-document-updates.adoc[Partial Updates], you can do better:
+
+You still define two chains, but this time the 'enrichment' one only includes the 'DocumentEnrichmentUpdateProcessor'
+(and the xref:configuration-guide:update-request-processors.adoc[Mandatory Processors])
+
+[source,xml]
+----
+<updateRequestProcessorChain name="no-enrichment">
+  <processor class="solr.processor1">
+   ...
+  </processor>
+   ...
+  <processor class="solr.processorN">
+   ...
+  </processor>
+  <processor class="solr.RunUpdateProcessorFactory"/>
+</updateRequestProcessorChain>
+----
+
+[source,xml]
+----
+<updateRequestProcessorChain name="enrichment">
+  <processor class="solr.DistributedUpdateProcessorFactory"/>
+  <processor class="solr.languagemodels.documentenrichment.update.processor.DocumentEnrichmentUpdateProcessorFactory">
+   <str name="inputField">string_field</str>
+   <str name="outputField">summary</str>
+   <str name="prompt">Summarize this content: {string_field}</str>
+   <str name="model">chat-model</str>
+  </processor>
+  <processor class="solr.RunUpdateProcessorFactory"/>
+</updateRequestProcessorChain>
+----
+
+[NOTE]
+====
+Since partial updates are resolved by `DistributedUpdateProcessorFactory`, be sure to place
+`DocumentEnrichmentUpdateProcessorFactory` afterwards so that it sees normal/complete documents.
+====
+
+Add to your schema a simple field that will be useful to track the enrichment process and use atomic updates:
+
+[source,xml]
+----
+<field name="enriched" type="boolean" indexed="true" stored="false" docValues="true" default="false"/>
+
+----
+
+In the first pass just index your documents using your reliable and fast 'no-enrichment' chain.
+
+On the second pass, re-index all your documents using atomic updates and targeting the 'enrichment' chain:
+
+[source,json]
+----
+{
+  "id":"mydoc",
+  "enriched": {
+    "set": true
+  }
+}
+----
+
+What will happen is that internally Solr fetches the stored content of the docs to update, all the existing fields are
+retrieved and a re-indexing happens, targeting the 'enrichment' chain that will add the LLM-generated fields and set the
+boolean `enriched` field to `true`.
+
+Faceting or querying on the boolean `enriched` field can also give you a quick idea on how many documents have been
+enriched with the new generated fields.

Review Comment:
   Added a note to link to the section of the documentation related to the use of update chains



##########
solr/solr-ref-guide/modules/indexing-guide/pages/document-enrichment-with-llms.adoc:
##########
@@ -0,0 +1,500 @@
+= Document Enrichment with LLMs
+// 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.
+
+This module brings the power of *Large Language Models* to Solr.
+
+More specifically, it provides the capability, at indexing time, given a prompt and a set of input fields, of calling an
+LLM through https://github.com/langchain4j/langchain4j[LangChain4j] for each document and store the result of the call
+in an `outputField`, that can be of multiple types and even multivalued.
+
+_Without_ this module, the LLM calls must be done _outside_ Solr, before indexing.
+
+[IMPORTANT]
+====
+This module sends your documents off to some hosted service on the internet.
+There are cost, privacy, performance, and service availability implications on such a strong dependency that should be
+diligently examined before employing this module in a serious way.
+
+====
+
+At the moment a subset of LLM providers supported by LangChain4j is supported by Solr.
+
+*Disclaimer*: Apache Solr is *in no way* affiliated to any of these corporations or services.
+
+If you want to add support for additional services or improve the support for the existing ones, feel free to
+contribute:
+
+* https://github.com/apache/solr/blob/main/CONTRIBUTING.md[Contributing to Solr]
+
+== Module
+
+This is provided via the `language-models` xref:configuration-guide:solr-modules.adoc[Solr Module] that needs to be
+enabled before use.
+
+== Language Model Configuration
+
+Language Models is a module and therefore its plugins must be configured in `solrconfig.xml`.
+
+=== Minimum Requirements
+
+* Enable the `language-models` module to make the Language Models classes available on Solr's classpath.
+See xref:configuration-guide:solr-modules.adoc[Solr Module] for more details.
+
+* An update processor, similar to the one below, must be declared in `solrconfig.xml`:
++
+[source,xml]
+----
+<updateRequestProcessorChain name="documentEnrichment">
+  <processor class="solr.languagemodels.documentenrichment.update.processor.DocumentEnrichmentUpdateProcessorFactory">
+   <str name="inputField">string_field</str>
+   <str name="outputField">summary</str>
+   <str name="prompt">Summarize this content: {string_field}</str>
+   <str name="model">model-name</str>
+  </processor>
+  <processor class="solr.RunUpdateProcessorFactory"/>
+ </updateRequestProcessorChain>
+----
+[NOTE]
+====
+If no component is configured in `solrconfig.xml`, the `ChatModel` store will not be registered and requests to
+`/schema/chat-model-store` will return an error.
+====
+
+== Chat Model Configuration
+
+=== Models
+
+* A model in this module is a chat model, that answers with text given a prompt.
+* A model in this Solr module is a reference to an external API that runs the Large Language Model responsible for chat

Review Comment:
   changed



##########
solr/solr-ref-guide/modules/indexing-guide/pages/document-enrichment-with-llms.adoc:
##########
@@ -0,0 +1,500 @@
+= Document Enrichment with LLMs
+// 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.
+
+This module brings the power of *Large Language Models* to Solr.
+
+More specifically, it provides the capability, at indexing time, given a prompt and a set of input fields, of calling an
+LLM through https://github.com/langchain4j/langchain4j[LangChain4j] for each document and store the result of the call
+in an `outputField`, that can be of multiple types and even multivalued.
+
+_Without_ this module, the LLM calls must be done _outside_ Solr, before indexing.
+
+[IMPORTANT]
+====
+This module sends your documents off to some hosted service on the internet.
+There are cost, privacy, performance, and service availability implications on such a strong dependency that should be
+diligently examined before employing this module in a serious way.
+
+====
+
+At the moment a subset of LLM providers supported by LangChain4j is supported by Solr.
+
+*Disclaimer*: Apache Solr is *in no way* affiliated to any of these corporations or services.
+
+If you want to add support for additional services or improve the support for the existing ones, feel free to
+contribute:
+
+* https://github.com/apache/solr/blob/main/CONTRIBUTING.md[Contributing to Solr]
+
+== Module
+
+This is provided via the `language-models` xref:configuration-guide:solr-modules.adoc[Solr Module] that needs to be
+enabled before use.
+
+== Language Model Configuration
+
+Language Models is a module and therefore its plugins must be configured in `solrconfig.xml`.
+
+=== Minimum Requirements
+
+* Enable the `language-models` module to make the Language Models classes available on Solr's classpath.
+See xref:configuration-guide:solr-modules.adoc[Solr Module] for more details.
+
+* An update processor, similar to the one below, must be declared in `solrconfig.xml`:
++
+[source,xml]
+----
+<updateRequestProcessorChain name="documentEnrichment">
+  <processor class="solr.languagemodels.documentenrichment.update.processor.DocumentEnrichmentUpdateProcessorFactory">
+   <str name="inputField">string_field</str>
+   <str name="outputField">summary</str>
+   <str name="prompt">Summarize this content: {string_field}</str>
+   <str name="model">model-name</str>
+  </processor>
+  <processor class="solr.RunUpdateProcessorFactory"/>
+ </updateRequestProcessorChain>
+----
+[NOTE]
+====
+If no component is configured in `solrconfig.xml`, the `ChatModel` store will not be registered and requests to
+`/schema/chat-model-store` will return an error.
+====
+
+== Chat Model Configuration
+
+=== Models
+
+* A model in this module is a chat model, that answers with text given a prompt.

Review Comment:
   changed



##########
solr/solr-ref-guide/modules/indexing-guide/pages/document-enrichment-with-llms.adoc:
##########
@@ -0,0 +1,500 @@
+= Document Enrichment with LLMs
+// 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.
+
+This module brings the power of *Large Language Models* to Solr.
+
+More specifically, it provides the capability, at indexing time, given a prompt and a set of input fields, of calling an
+LLM through https://github.com/langchain4j/langchain4j[LangChain4j] for each document and store the result of the call
+in an `outputField`, that can be of multiple types and even multivalued.
+
+_Without_ this module, the LLM calls must be done _outside_ Solr, before indexing.
+
+[IMPORTANT]
+====
+This module sends your documents off to some hosted service on the internet.
+There are cost, privacy, performance, and service availability implications on such a strong dependency that should be
+diligently examined before employing this module in a serious way.
+
+====
+
+At the moment a subset of LLM providers supported by LangChain4j is supported by Solr.
+
+*Disclaimer*: Apache Solr is *in no way* affiliated to any of these corporations or services.
+
+If you want to add support for additional services or improve the support for the existing ones, feel free to
+contribute:
+
+* https://github.com/apache/solr/blob/main/CONTRIBUTING.md[Contributing to Solr]
+
+== Module
+
+This is provided via the `language-models` xref:configuration-guide:solr-modules.adoc[Solr Module] that needs to be
+enabled before use.
+
+== Language Model Configuration
+
+Language Models is a module and therefore its plugins must be configured in `solrconfig.xml`.
+
+=== Minimum Requirements
+
+* Enable the `language-models` module to make the Language Models classes available on Solr's classpath.
+See xref:configuration-guide:solr-modules.adoc[Solr Module] for more details.
+
+* An update processor, similar to the one below, must be declared in `solrconfig.xml`:
++
+[source,xml]
+----
+<updateRequestProcessorChain name="documentEnrichment">
+  <processor class="solr.languagemodels.documentenrichment.update.processor.DocumentEnrichmentUpdateProcessorFactory">
+   <str name="inputField">string_field</str>
+   <str name="outputField">summary</str>
+   <str name="prompt">Summarize this content: {string_field}</str>
+   <str name="model">model-name</str>
+  </processor>
+  <processor class="solr.RunUpdateProcessorFactory"/>
+ </updateRequestProcessorChain>
+----
+[NOTE]
+====
+If no component is configured in `solrconfig.xml`, the `ChatModel` store will not be registered and requests to
+`/schema/chat-model-store` will return an error.
+====
+
+== Chat Model Configuration
+
+=== Models
+
+* A model in this module is a chat model, that answers with text given a prompt.
+* A model in this Solr module is a reference to an external API that runs the Large Language Model responsible for chat
+completion.
+
+[IMPORTANT]
+====
+The Solr chat model specifies the parameters to access the APIs, the LLM doesn't run internally in Solr
+
+====
+
+A model is described by these parameters:
+
+
+`class`::
++
+[%autowidth,frame=none]
+|===
+s|Required |Default: none
+|===
++
+The model implementation.
+Accepted values:
+
+* `dev.langchain4j.model.ollama.OllamaChatModel`
+* `dev.langchain4j.model.mistralai.MistralAiChatModel`
+* `dev.langchain4j.model.anthropic.AnthropicChatModel`
+* `dev.langchain4j.model.openai.OpenAiChatModel`
+* `dev.langchain4j.model.googleai.GoogleAiGeminiChatModel`
+
+`name`::
++
+[%autowidth,frame=none]
+|===
+s|Required |Default: none
+|===
++
+The identifier of your model, this is used by any component that intends to use the model (e.g., `DocumentEnrichmentUpdateProcessorFactory` update processor).
+
+`params`::
++
+[%autowidth,frame=none]
+|===
+|Optional |Default: none
+|===
++
+Each model class has potentially different params.
+Many are shared but for the full set of parameters of the model you are interested in please refer to the official documentation of the LangChain4j version included in Solr: https://docs.langchain4j.dev/category/language-models[Chat Models in LangChain4j].
+
+=== Supported Models
+Apache Solr uses https://github.com/langchain4j/langchain4j[LangChain4j] to support document enrichment with LLMs.
+The models currently supported are:
+
+[tabs#supported-chat-models]
+======
+Ollama::
++
+====
+
+[source,json]
+----
+{
+  "class": "dev.langchain4j.model.ollama.OllamaChatModel",
+  "name": "<a-name-for-your-model>",
+  "params": {
+    "baseUrl": "http://localhost:11434",
+    "modelName": "<a-local/hosted-chat-model>",
+    "timeout": 300,
+    "logRequests": true,
+    "logResponses": true,
+    "maxRetries": 5
+  }
+}
+----
+====
+
+MistralAI::
++
+====
+[source,json]
+----
+{
+  "class": "dev.langchain4j.model.mistralai.MistralAiChatModel",
+  "name": "<a-name-for-your-model>",
+  "params": {
+    "baseUrl": "https://api.mistral.ai/v1",
+    "apiKey": "<your-mistralAI-api-key>",
+    "modelName": "<a-mistralAI-chat-model>",
+    "timeout": 60,
+    "logRequests": true,
+    "logResponses": true,
+    "maxRetries": 5
+  }
+}
+----
+====
+OpenAI::
++
+====
+[source,json]
+----
+{
+  "class": "dev.langchain4j.model.openai.OpenAiChatModel",
+  "name": "<a-name-for-your-model>",
+  "params": {
+    "baseUrl": "https://api.openai.com/v1",
+    "apiKey": "<your-openAI-api-key>",
+    "modelName": "<a-openAI-chat-model>",
+    "timeout": 60,
+    "logRequests": true,
+    "logResponses": true,
+    "maxRetries": 5
+  }
+}
+----
+====
+
+Anthropic::
++
+====
+[source,json]
+----
+{
+  "class": "dev.langchain4j.model.anthropic.AnthropicChatModel",
+  "name": "<a-name-for-your-model>",
+  "params": {
+    "baseUrl": "https://api.anthropic.com/v1/",
+    "apiKey": "<your-anthropic-api-key>",
+    "modelName": "<a-anthropic-chat-model>",
+    "timeout": 60,
+    "logRequests": true,
+    "logResponses": true,
+    "maxRetries": 5
+  }
+}
+----
+====
+
+Gemini::
++
+====
+[source,json]
+----
+{
+  "class": "dev.langchain4j.model.googleai.GoogleAiGeminiChatModel",
+  "name": "<a-name-for-your-model>",
+  "params": {
+    "baseUrl": "https://generativelanguage.googleapis.com/v1beta/",
+    "apiKey": "<your-geminiAi-api-key>",
+    "modelName": "<a-geminiAi-chat-model>",
+    "timeout": 60,
+    "logRequests": true,
+    "logResponses": true,
+    "maxRetries": 5
+  }
+}
+----
+====
+======
+
+=== Uploading a Model
+
+To upload the model in a `/path/myModel.json` file, please run:
+
+[source,bash]
+----
+curl -XPUT 'http://localhost:8983/solr/YOUR_COLLECTION/schema/chat-model-store' --data-binary "@/path/myModel.json" -H 'Content-type:application/json'
+----
+
+To delete the `currentModel` model:
+
+[source,bash]
+----
+curl -XDELETE 'http://localhost:8983/solr/YOUR_COLLECTION/schema/chat-model-store/currentModel'
+----
+
+To view all models:
+
+[source,text]
+http://localhost:8983/solr/YOUR_COLLECTION/schema/chat-model-store
+
+
+.Example: /path/myOpenAIModel.json
+[source,json]
+----
+{
+  "class": "dev.langchain4j.model.openai.OpenAiChatModel",
+  "name": "openai-1",
+  "params": {
+    "baseUrl": "https://api.openai.com/v1",
+    "apiKey": "apiKey-openAI",
+    "modelName": "gpt-5.4-nano",
+    "timeout": 60,
+    "logRequests": true,
+    "logResponses": true,
+    "maxRetries": 5
+  }
+}
+----
+
+== How to Trigger Document Enrichment during Indexing
+To create new fields starting from existent ones in your documents at indexing time you need to configure an {solr-javadocs}/core/org/apache/solr/update/processor/UpdateRequestProcessorChain.html[Update Request Processor Chain] that includes at least one `DocumentEnrichmentUpdateProcessor` update request processor in one of the 2 following way:
+
+* Update processor with parameter `prompt`
++
+[source,xml]
+----
+<updateRequestProcessorChain name="documentEnrichment">
+  <processor class="solr.languagemodels.documentenrichment.update.processor.DocumentEnrichmentUpdateProcessorFactory">
+   <str name="inputField">string_field</str>
+   <str name="outputField">summary</str>
+   <str name="prompt">Summarize this content: {string_field}</str>
+   <str name="model">model-name</str>
+  </processor>
+  <processor class="solr.RunUpdateProcessorFactory"/>
+ </updateRequestProcessorChain>
+----
+
+* Update processor with parameter `promptFile`: in this case, the file `prompt.txt` must be uploaded to Solr similarly to any other configuration file (e.g., `solrconfig.xml`, `synonyms.txt`, etc.)

Review Comment:
   yes, changed



##########
solr/solr-ref-guide/modules/indexing-guide/pages/document-enrichment-with-llms.adoc:
##########
@@ -0,0 +1,500 @@
+= Document Enrichment with LLMs
+// 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.
+
+This module brings the power of *Large Language Models* to Solr.
+
+More specifically, it provides the capability, at indexing time, given a prompt and a set of input fields, of calling an
+LLM through https://github.com/langchain4j/langchain4j[LangChain4j] for each document and store the result of the call
+in an `outputField`, that can be of multiple types and even multivalued.
+
+_Without_ this module, the LLM calls must be done _outside_ Solr, before indexing.
+
+[IMPORTANT]
+====
+This module sends your documents off to some hosted service on the internet.
+There are cost, privacy, performance, and service availability implications on such a strong dependency that should be
+diligently examined before employing this module in a serious way.
+
+====
+
+At the moment a subset of LLM providers supported by LangChain4j is supported by Solr.
+
+*Disclaimer*: Apache Solr is *in no way* affiliated to any of these corporations or services.
+
+If you want to add support for additional services or improve the support for the existing ones, feel free to
+contribute:
+
+* https://github.com/apache/solr/blob/main/CONTRIBUTING.md[Contributing to Solr]
+
+== Module
+
+This is provided via the `language-models` xref:configuration-guide:solr-modules.adoc[Solr Module] that needs to be
+enabled before use.
+
+== Language Model Configuration
+
+Language Models is a module and therefore its plugins must be configured in `solrconfig.xml`.
+
+=== Minimum Requirements
+
+* Enable the `language-models` module to make the Language Models classes available on Solr's classpath.
+See xref:configuration-guide:solr-modules.adoc[Solr Module] for more details.
+
+* An update processor, similar to the one below, must be declared in `solrconfig.xml`:
++
+[source,xml]
+----
+<updateRequestProcessorChain name="documentEnrichment">
+  <processor class="solr.languagemodels.documentenrichment.update.processor.DocumentEnrichmentUpdateProcessorFactory">
+   <str name="inputField">string_field</str>
+   <str name="outputField">summary</str>
+   <str name="prompt">Summarize this content: {string_field}</str>
+   <str name="model">model-name</str>
+  </processor>
+  <processor class="solr.RunUpdateProcessorFactory"/>
+ </updateRequestProcessorChain>
+----
+[NOTE]
+====
+If no component is configured in `solrconfig.xml`, the `ChatModel` store will not be registered and requests to
+`/schema/chat-model-store` will return an error.
+====
+
+== Chat Model Configuration
+
+=== Models
+
+* A model in this module is a chat model, that answers with text given a prompt.
+* A model in this Solr module is a reference to an external API that runs the Large Language Model responsible for chat
+completion.
+
+[IMPORTANT]
+====
+The Solr chat model specifies the parameters to access the APIs, the LLM doesn't run internally in Solr
+
+====
+
+A model is described by these parameters:
+
+
+`class`::
++
+[%autowidth,frame=none]
+|===
+s|Required |Default: none
+|===
++
+The model implementation.
+Accepted values:
+
+* `dev.langchain4j.model.ollama.OllamaChatModel`
+* `dev.langchain4j.model.mistralai.MistralAiChatModel`
+* `dev.langchain4j.model.anthropic.AnthropicChatModel`
+* `dev.langchain4j.model.openai.OpenAiChatModel`
+* `dev.langchain4j.model.googleai.GoogleAiGeminiChatModel`
+
+`name`::
++
+[%autowidth,frame=none]
+|===
+s|Required |Default: none
+|===
++
+The identifier of your model, this is used by any component that intends to use the model (e.g., `DocumentEnrichmentUpdateProcessorFactory` update processor).
+
+`params`::
++
+[%autowidth,frame=none]
+|===
+|Optional |Default: none
+|===
++
+Each model class has potentially different params.
+Many are shared but for the full set of parameters of the model you are interested in please refer to the official documentation of the LangChain4j version included in Solr: https://docs.langchain4j.dev/category/language-models[Chat Models in LangChain4j].
+
+=== Supported Models
+Apache Solr uses https://github.com/langchain4j/langchain4j[LangChain4j] to support document enrichment with LLMs.
+The models currently supported are:
+
+[tabs#supported-chat-models]
+======
+Ollama::
++
+====
+
+[source,json]
+----
+{
+  "class": "dev.langchain4j.model.ollama.OllamaChatModel",
+  "name": "<a-name-for-your-model>",
+  "params": {
+    "baseUrl": "http://localhost:11434",
+    "modelName": "<a-local/hosted-chat-model>",
+    "timeout": 300,
+    "logRequests": true,
+    "logResponses": true,
+    "maxRetries": 5
+  }
+}
+----
+====
+
+MistralAI::
++
+====
+[source,json]
+----
+{
+  "class": "dev.langchain4j.model.mistralai.MistralAiChatModel",
+  "name": "<a-name-for-your-model>",
+  "params": {
+    "baseUrl": "https://api.mistral.ai/v1",
+    "apiKey": "<your-mistralAI-api-key>",
+    "modelName": "<a-mistralAI-chat-model>",
+    "timeout": 60,
+    "logRequests": true,
+    "logResponses": true,
+    "maxRetries": 5
+  }
+}
+----
+====
+OpenAI::
++
+====
+[source,json]
+----
+{
+  "class": "dev.langchain4j.model.openai.OpenAiChatModel",
+  "name": "<a-name-for-your-model>",
+  "params": {
+    "baseUrl": "https://api.openai.com/v1",
+    "apiKey": "<your-openAI-api-key>",
+    "modelName": "<a-openAI-chat-model>",
+    "timeout": 60,
+    "logRequests": true,
+    "logResponses": true,
+    "maxRetries": 5
+  }
+}
+----
+====
+
+Anthropic::
++
+====
+[source,json]
+----
+{
+  "class": "dev.langchain4j.model.anthropic.AnthropicChatModel",
+  "name": "<a-name-for-your-model>",
+  "params": {
+    "baseUrl": "https://api.anthropic.com/v1/",
+    "apiKey": "<your-anthropic-api-key>",
+    "modelName": "<a-anthropic-chat-model>",
+    "timeout": 60,
+    "logRequests": true,
+    "logResponses": true,
+    "maxRetries": 5
+  }
+}
+----
+====
+
+Gemini::
++
+====
+[source,json]
+----
+{
+  "class": "dev.langchain4j.model.googleai.GoogleAiGeminiChatModel",
+  "name": "<a-name-for-your-model>",
+  "params": {
+    "baseUrl": "https://generativelanguage.googleapis.com/v1beta/",
+    "apiKey": "<your-geminiAi-api-key>",
+    "modelName": "<a-geminiAi-chat-model>",
+    "timeout": 60,
+    "logRequests": true,
+    "logResponses": true,
+    "maxRetries": 5
+  }
+}
+----
+====
+======
+
+=== Uploading a Model
+
+To upload the model in a `/path/myModel.json` file, please run:
+
+[source,bash]
+----
+curl -XPUT 'http://localhost:8983/solr/YOUR_COLLECTION/schema/chat-model-store' --data-binary "@/path/myModel.json" -H 'Content-type:application/json'
+----
+
+To delete the `currentModel` model:
+
+[source,bash]
+----
+curl -XDELETE 'http://localhost:8983/solr/YOUR_COLLECTION/schema/chat-model-store/currentModel'
+----
+
+To view all models:
+
+[source,text]
+http://localhost:8983/solr/YOUR_COLLECTION/schema/chat-model-store
+
+
+.Example: /path/myOpenAIModel.json
+[source,json]
+----
+{
+  "class": "dev.langchain4j.model.openai.OpenAiChatModel",
+  "name": "openai-1",
+  "params": {
+    "baseUrl": "https://api.openai.com/v1",
+    "apiKey": "apiKey-openAI",
+    "modelName": "gpt-5.4-nano",
+    "timeout": 60,
+    "logRequests": true,
+    "logResponses": true,
+    "maxRetries": 5
+  }
+}
+----
+
+== How to Trigger Document Enrichment during Indexing
+To create new fields starting from existent ones in your documents at indexing time you need to configure an {solr-javadocs}/core/org/apache/solr/update/processor/UpdateRequestProcessorChain.html[Update Request Processor Chain] that includes at least one `DocumentEnrichmentUpdateProcessor` update request processor in one of the 2 following way:
+
+* Update processor with parameter `prompt`
++
+[source,xml]
+----
+<updateRequestProcessorChain name="documentEnrichment">
+  <processor class="solr.languagemodels.documentenrichment.update.processor.DocumentEnrichmentUpdateProcessorFactory">
+   <str name="inputField">string_field</str>
+   <str name="outputField">summary</str>
+   <str name="prompt">Summarize this content: {string_field}</str>
+   <str name="model">model-name</str>
+  </processor>
+  <processor class="solr.RunUpdateProcessorFactory"/>
+ </updateRequestProcessorChain>
+----
+
+* Update processor with parameter `promptFile`: in this case, the file `prompt.txt` must be uploaded to Solr similarly to any other configuration file (e.g., `solrconfig.xml`, `synonyms.txt`, etc.)
++
+[source,xml]
+----
+<updateRequestProcessorChain name="documentEnrichment">
+  <processor class="solr.languagemodels.documentenrichment.update.processor.DocumentEnrichmentUpdateProcessorFactory">
+   <str name="inputField">string_field</str>
+   <str name="outputField">summary</str>
+   <str name="promptFile">prompt.txt</str>
+   <str name="model">model-name</str>
+  </processor>
+  <processor class="solr.RunUpdateProcessorFactory"/>
+ </updateRequestProcessorChain>
+----
+
+Exactly one of the following parameters is required: `prompt` or `promptFile`.
+
+Another important feature of this module is that one (or more) `inputField` needs to be injected in the prompt. This is
+done by some special tokens, that are the `fieldName` surrounded by curly brackets (e.g., `{string_field}`, in the
+example above). These tokens are _mandatory_ for this module to work properly. Solr will throw an error if the
+parameters are not properly defined.
+For example, both the prompt and the content of the file prompt.txt, must contain the text '{string_field}', which
+will be substituted with the content of the `string_field` field for each document. An example of a valid prompt with
+multiple input fields is as follows:
+
+[source,xml]
+----
+<updateRequestProcessorChain name="documentEnrichment">
+  <processor class="solr.languagemodels.documentenrichment.update.processor.DocumentEnrichmentUpdateProcessorFactory">
+   <str name="inputField">title</str>
+   <str name="inputField">body</str>
+   <str name="outputField">summary</str>
+   <str name="prompt">Summarize with the following information. Title: {title}. Body: {body}.</str>
+   <str name="model">chat-model</str>
+  </processor>
+  <processor class="solr.RunUpdateProcessorFactory"/>
+ </updateRequestProcessorChain>
+----
+
+Another way of using more than one `inputField` is by using the following notation, instead of more than one parameter

Review Comment:
   changed



##########
solr/solr-ref-guide/modules/indexing-guide/pages/document-enrichment-with-llms.adoc:
##########
@@ -0,0 +1,500 @@
+= Document Enrichment with LLMs
+// 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.
+
+This module brings the power of *Large Language Models* to Solr.
+
+More specifically, it provides the capability, at indexing time, given a prompt and a set of input fields, of calling an
+LLM through https://github.com/langchain4j/langchain4j[LangChain4j] for each document and store the result of the call
+in an `outputField`, that can be of multiple types and even multivalued.
+
+_Without_ this module, the LLM calls must be done _outside_ Solr, before indexing.
+
+[IMPORTANT]
+====
+This module sends your documents off to some hosted service on the internet.
+There are cost, privacy, performance, and service availability implications on such a strong dependency that should be
+diligently examined before employing this module in a serious way.
+
+====
+
+At the moment a subset of LLM providers supported by LangChain4j is supported by Solr.
+
+*Disclaimer*: Apache Solr is *in no way* affiliated to any of these corporations or services.
+
+If you want to add support for additional services or improve the support for the existing ones, feel free to
+contribute:
+
+* https://github.com/apache/solr/blob/main/CONTRIBUTING.md[Contributing to Solr]
+
+== Module
+
+This is provided via the `language-models` xref:configuration-guide:solr-modules.adoc[Solr Module] that needs to be
+enabled before use.
+
+== Language Model Configuration
+
+Language Models is a module and therefore its plugins must be configured in `solrconfig.xml`.
+
+=== Minimum Requirements
+
+* Enable the `language-models` module to make the Language Models classes available on Solr's classpath.
+See xref:configuration-guide:solr-modules.adoc[Solr Module] for more details.
+
+* An update processor, similar to the one below, must be declared in `solrconfig.xml`:
++
+[source,xml]
+----
+<updateRequestProcessorChain name="documentEnrichment">
+  <processor class="solr.languagemodels.documentenrichment.update.processor.DocumentEnrichmentUpdateProcessorFactory">
+   <str name="inputField">string_field</str>
+   <str name="outputField">summary</str>
+   <str name="prompt">Summarize this content: {string_field}</str>
+   <str name="model">model-name</str>
+  </processor>
+  <processor class="solr.RunUpdateProcessorFactory"/>
+ </updateRequestProcessorChain>
+----
+[NOTE]
+====
+If no component is configured in `solrconfig.xml`, the `ChatModel` store will not be registered and requests to
+`/schema/chat-model-store` will return an error.
+====
+
+== Chat Model Configuration
+
+=== Models
+
+* A model in this module is a chat model, that answers with text given a prompt.
+* A model in this Solr module is a reference to an external API that runs the Large Language Model responsible for chat
+completion.
+
+[IMPORTANT]
+====
+The Solr chat model specifies the parameters to access the APIs, the LLM doesn't run internally in Solr
+
+====
+
+A model is described by these parameters:
+
+
+`class`::
++
+[%autowidth,frame=none]
+|===
+s|Required |Default: none
+|===
++
+The model implementation.
+Accepted values:
+
+* `dev.langchain4j.model.ollama.OllamaChatModel`
+* `dev.langchain4j.model.mistralai.MistralAiChatModel`
+* `dev.langchain4j.model.anthropic.AnthropicChatModel`
+* `dev.langchain4j.model.openai.OpenAiChatModel`
+* `dev.langchain4j.model.googleai.GoogleAiGeminiChatModel`
+
+`name`::
++
+[%autowidth,frame=none]
+|===
+s|Required |Default: none
+|===
++
+The identifier of your model, this is used by any component that intends to use the model (e.g., `DocumentEnrichmentUpdateProcessorFactory` update processor).
+
+`params`::
++
+[%autowidth,frame=none]
+|===
+|Optional |Default: none
+|===
++
+Each model class has potentially different params.
+Many are shared but for the full set of parameters of the model you are interested in please refer to the official documentation of the LangChain4j version included in Solr: https://docs.langchain4j.dev/category/language-models[Chat Models in LangChain4j].
+
+=== Supported Models
+Apache Solr uses https://github.com/langchain4j/langchain4j[LangChain4j] to support document enrichment with LLMs.
+The models currently supported are:
+
+[tabs#supported-chat-models]
+======
+Ollama::
++
+====
+
+[source,json]
+----
+{
+  "class": "dev.langchain4j.model.ollama.OllamaChatModel",
+  "name": "<a-name-for-your-model>",
+  "params": {
+    "baseUrl": "http://localhost:11434",
+    "modelName": "<a-local/hosted-chat-model>",
+    "timeout": 300,
+    "logRequests": true,
+    "logResponses": true,
+    "maxRetries": 5
+  }
+}
+----
+====
+
+MistralAI::
++
+====
+[source,json]
+----
+{
+  "class": "dev.langchain4j.model.mistralai.MistralAiChatModel",
+  "name": "<a-name-for-your-model>",
+  "params": {
+    "baseUrl": "https://api.mistral.ai/v1",
+    "apiKey": "<your-mistralAI-api-key>",
+    "modelName": "<a-mistralAI-chat-model>",
+    "timeout": 60,
+    "logRequests": true,
+    "logResponses": true,
+    "maxRetries": 5
+  }
+}
+----
+====
+OpenAI::
++
+====
+[source,json]
+----
+{
+  "class": "dev.langchain4j.model.openai.OpenAiChatModel",
+  "name": "<a-name-for-your-model>",
+  "params": {
+    "baseUrl": "https://api.openai.com/v1",
+    "apiKey": "<your-openAI-api-key>",
+    "modelName": "<a-openAI-chat-model>",
+    "timeout": 60,
+    "logRequests": true,
+    "logResponses": true,
+    "maxRetries": 5
+  }
+}
+----
+====
+
+Anthropic::
++
+====
+[source,json]
+----
+{
+  "class": "dev.langchain4j.model.anthropic.AnthropicChatModel",
+  "name": "<a-name-for-your-model>",
+  "params": {
+    "baseUrl": "https://api.anthropic.com/v1/",
+    "apiKey": "<your-anthropic-api-key>",
+    "modelName": "<a-anthropic-chat-model>",
+    "timeout": 60,
+    "logRequests": true,
+    "logResponses": true,
+    "maxRetries": 5
+  }
+}
+----
+====
+
+Gemini::
++
+====
+[source,json]
+----
+{
+  "class": "dev.langchain4j.model.googleai.GoogleAiGeminiChatModel",
+  "name": "<a-name-for-your-model>",
+  "params": {
+    "baseUrl": "https://generativelanguage.googleapis.com/v1beta/",
+    "apiKey": "<your-geminiAi-api-key>",
+    "modelName": "<a-geminiAi-chat-model>",
+    "timeout": 60,
+    "logRequests": true,
+    "logResponses": true,
+    "maxRetries": 5
+  }
+}
+----
+====
+======
+
+=== Uploading a Model
+
+To upload the model in a `/path/myModel.json` file, please run:
+
+[source,bash]
+----
+curl -XPUT 'http://localhost:8983/solr/YOUR_COLLECTION/schema/chat-model-store' --data-binary "@/path/myModel.json" -H 'Content-type:application/json'
+----
+
+To delete the `currentModel` model:
+
+[source,bash]
+----
+curl -XDELETE 'http://localhost:8983/solr/YOUR_COLLECTION/schema/chat-model-store/currentModel'
+----
+
+To view all models:
+
+[source,text]
+http://localhost:8983/solr/YOUR_COLLECTION/schema/chat-model-store
+
+
+.Example: /path/myOpenAIModel.json
+[source,json]
+----
+{
+  "class": "dev.langchain4j.model.openai.OpenAiChatModel",
+  "name": "openai-1",
+  "params": {
+    "baseUrl": "https://api.openai.com/v1",
+    "apiKey": "apiKey-openAI",
+    "modelName": "gpt-5.4-nano",
+    "timeout": 60,
+    "logRequests": true,
+    "logResponses": true,
+    "maxRetries": 5
+  }
+}
+----
+
+== How to Trigger Document Enrichment during Indexing
+To create new fields starting from existent ones in your documents at indexing time you need to configure an {solr-javadocs}/core/org/apache/solr/update/processor/UpdateRequestProcessorChain.html[Update Request Processor Chain] that includes at least one `DocumentEnrichmentUpdateProcessor` update request processor in one of the 2 following way:
+
+* Update processor with parameter `prompt`
++
+[source,xml]
+----
+<updateRequestProcessorChain name="documentEnrichment">
+  <processor class="solr.languagemodels.documentenrichment.update.processor.DocumentEnrichmentUpdateProcessorFactory">
+   <str name="inputField">string_field</str>
+   <str name="outputField">summary</str>
+   <str name="prompt">Summarize this content: {string_field}</str>
+   <str name="model">model-name</str>
+  </processor>
+  <processor class="solr.RunUpdateProcessorFactory"/>
+ </updateRequestProcessorChain>
+----
+
+* Update processor with parameter `promptFile`: in this case, the file `prompt.txt` must be uploaded to Solr similarly to any other configuration file (e.g., `solrconfig.xml`, `synonyms.txt`, etc.)
++
+[source,xml]
+----
+<updateRequestProcessorChain name="documentEnrichment">
+  <processor class="solr.languagemodels.documentenrichment.update.processor.DocumentEnrichmentUpdateProcessorFactory">
+   <str name="inputField">string_field</str>
+   <str name="outputField">summary</str>
+   <str name="promptFile">prompt.txt</str>
+   <str name="model">model-name</str>
+  </processor>
+  <processor class="solr.RunUpdateProcessorFactory"/>
+ </updateRequestProcessorChain>
+----
+
+Exactly one of the following parameters is required: `prompt` or `promptFile`.
+
+Another important feature of this module is that one (or more) `inputField` needs to be injected in the prompt. This is
+done by some special tokens, that are the `fieldName` surrounded by curly brackets (e.g., `{string_field}`, in the
+example above). These tokens are _mandatory_ for this module to work properly. Solr will throw an error if the
+parameters are not properly defined.
+For example, both the prompt and the content of the file prompt.txt, must contain the text '{string_field}', which
+will be substituted with the content of the `string_field` field for each document. An example of a valid prompt with
+multiple input fields is as follows:
+
+[source,xml]
+----
+<updateRequestProcessorChain name="documentEnrichment">
+  <processor class="solr.languagemodels.documentenrichment.update.processor.DocumentEnrichmentUpdateProcessorFactory">
+   <str name="inputField">title</str>
+   <str name="inputField">body</str>
+   <str name="outputField">summary</str>
+   <str name="prompt">Summarize with the following information. Title: {title}. Body: {body}.</str>
+   <str name="model">chat-model</str>
+  </processor>
+  <processor class="solr.RunUpdateProcessorFactory"/>
+ </updateRequestProcessorChain>
+----
+
+Another way of using more than one `inputField` is by using the following notation, instead of more than one parameter
+with the same name:
+[source,xml]
+----
+<arr name="inputField">
+    <str>title</str>
+    <str>body</str>
+</arr>
+----
+
+The LLM response is mapped to the specified `outputField`. Note that this module only supports a subset of Solr's

Review Comment:
   added



##########
solr/solr-ref-guide/modules/indexing-guide/pages/document-enrichment-with-llms.adoc:
##########
@@ -0,0 +1,500 @@
+= Document Enrichment with LLMs
+// 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.
+
+This module brings the power of *Large Language Models* to Solr.
+
+More specifically, it provides the capability, at indexing time, given a prompt and a set of input fields, of calling an
+LLM through https://github.com/langchain4j/langchain4j[LangChain4j] for each document and store the result of the call
+in an `outputField`, that can be of multiple types and even multivalued.
+
+_Without_ this module, the LLM calls must be done _outside_ Solr, before indexing.
+
+[IMPORTANT]
+====
+This module sends your documents off to some hosted service on the internet.
+There are cost, privacy, performance, and service availability implications on such a strong dependency that should be
+diligently examined before employing this module in a serious way.
+
+====
+
+At the moment a subset of LLM providers supported by LangChain4j is supported by Solr.
+
+*Disclaimer*: Apache Solr is *in no way* affiliated to any of these corporations or services.
+
+If you want to add support for additional services or improve the support for the existing ones, feel free to
+contribute:
+
+* https://github.com/apache/solr/blob/main/CONTRIBUTING.md[Contributing to Solr]
+
+== Module
+
+This is provided via the `language-models` xref:configuration-guide:solr-modules.adoc[Solr Module] that needs to be
+enabled before use.
+
+== Language Model Configuration
+
+Language Models is a module and therefore its plugins must be configured in `solrconfig.xml`.
+
+=== Minimum Requirements
+
+* Enable the `language-models` module to make the Language Models classes available on Solr's classpath.
+See xref:configuration-guide:solr-modules.adoc[Solr Module] for more details.
+
+* An update processor, similar to the one below, must be declared in `solrconfig.xml`:
++
+[source,xml]
+----
+<updateRequestProcessorChain name="documentEnrichment">
+  <processor class="solr.languagemodels.documentenrichment.update.processor.DocumentEnrichmentUpdateProcessorFactory">
+   <str name="inputField">string_field</str>
+   <str name="outputField">summary</str>
+   <str name="prompt">Summarize this content: {string_field}</str>
+   <str name="model">model-name</str>
+  </processor>
+  <processor class="solr.RunUpdateProcessorFactory"/>
+ </updateRequestProcessorChain>
+----
+[NOTE]
+====
+If no component is configured in `solrconfig.xml`, the `ChatModel` store will not be registered and requests to
+`/schema/chat-model-store` will return an error.
+====
+
+== Chat Model Configuration
+
+=== Models
+
+* A model in this module is a chat model, that answers with text given a prompt.
+* A model in this Solr module is a reference to an external API that runs the Large Language Model responsible for chat
+completion.
+
+[IMPORTANT]
+====
+The Solr chat model specifies the parameters to access the APIs, the LLM doesn't run internally in Solr
+
+====
+
+A model is described by these parameters:
+
+
+`class`::
++
+[%autowidth,frame=none]
+|===
+s|Required |Default: none
+|===
++
+The model implementation.
+Accepted values:
+
+* `dev.langchain4j.model.ollama.OllamaChatModel`
+* `dev.langchain4j.model.mistralai.MistralAiChatModel`
+* `dev.langchain4j.model.anthropic.AnthropicChatModel`
+* `dev.langchain4j.model.openai.OpenAiChatModel`
+* `dev.langchain4j.model.googleai.GoogleAiGeminiChatModel`
+
+`name`::
++
+[%autowidth,frame=none]
+|===
+s|Required |Default: none
+|===
++
+The identifier of your model, this is used by any component that intends to use the model (e.g., `DocumentEnrichmentUpdateProcessorFactory` update processor).
+
+`params`::
++
+[%autowidth,frame=none]
+|===
+|Optional |Default: none
+|===
++
+Each model class has potentially different params.
+Many are shared but for the full set of parameters of the model you are interested in please refer to the official documentation of the LangChain4j version included in Solr: https://docs.langchain4j.dev/category/language-models[Chat Models in LangChain4j].
+
+=== Supported Models
+Apache Solr uses https://github.com/langchain4j/langchain4j[LangChain4j] to support document enrichment with LLMs.
+The models currently supported are:
+
+[tabs#supported-chat-models]
+======
+Ollama::
++
+====
+
+[source,json]
+----
+{
+  "class": "dev.langchain4j.model.ollama.OllamaChatModel",
+  "name": "<a-name-for-your-model>",
+  "params": {
+    "baseUrl": "http://localhost:11434",
+    "modelName": "<a-local/hosted-chat-model>",
+    "timeout": 300,
+    "logRequests": true,
+    "logResponses": true,
+    "maxRetries": 5
+  }
+}
+----
+====
+
+MistralAI::
++
+====
+[source,json]
+----
+{
+  "class": "dev.langchain4j.model.mistralai.MistralAiChatModel",
+  "name": "<a-name-for-your-model>",
+  "params": {
+    "baseUrl": "https://api.mistral.ai/v1",
+    "apiKey": "<your-mistralAI-api-key>",
+    "modelName": "<a-mistralAI-chat-model>",
+    "timeout": 60,
+    "logRequests": true,
+    "logResponses": true,
+    "maxRetries": 5
+  }
+}
+----
+====
+OpenAI::
++
+====
+[source,json]
+----
+{
+  "class": "dev.langchain4j.model.openai.OpenAiChatModel",
+  "name": "<a-name-for-your-model>",
+  "params": {
+    "baseUrl": "https://api.openai.com/v1",
+    "apiKey": "<your-openAI-api-key>",
+    "modelName": "<a-openAI-chat-model>",
+    "timeout": 60,
+    "logRequests": true,
+    "logResponses": true,
+    "maxRetries": 5
+  }
+}
+----
+====
+
+Anthropic::
++
+====
+[source,json]
+----
+{
+  "class": "dev.langchain4j.model.anthropic.AnthropicChatModel",
+  "name": "<a-name-for-your-model>",
+  "params": {
+    "baseUrl": "https://api.anthropic.com/v1/",
+    "apiKey": "<your-anthropic-api-key>",
+    "modelName": "<a-anthropic-chat-model>",
+    "timeout": 60,
+    "logRequests": true,
+    "logResponses": true,
+    "maxRetries": 5
+  }
+}
+----
+====
+
+Gemini::
++
+====
+[source,json]
+----
+{
+  "class": "dev.langchain4j.model.googleai.GoogleAiGeminiChatModel",
+  "name": "<a-name-for-your-model>",
+  "params": {
+    "baseUrl": "https://generativelanguage.googleapis.com/v1beta/",
+    "apiKey": "<your-geminiAi-api-key>",
+    "modelName": "<a-geminiAi-chat-model>",
+    "timeout": 60,
+    "logRequests": true,
+    "logResponses": true,
+    "maxRetries": 5
+  }
+}
+----
+====
+======
+
+=== Uploading a Model
+
+To upload the model in a `/path/myModel.json` file, please run:
+
+[source,bash]
+----
+curl -XPUT 'http://localhost:8983/solr/YOUR_COLLECTION/schema/chat-model-store' --data-binary "@/path/myModel.json" -H 'Content-type:application/json'
+----
+
+To delete the `currentModel` model:
+
+[source,bash]
+----
+curl -XDELETE 'http://localhost:8983/solr/YOUR_COLLECTION/schema/chat-model-store/currentModel'
+----
+
+To view all models:
+
+[source,text]
+http://localhost:8983/solr/YOUR_COLLECTION/schema/chat-model-store
+
+
+.Example: /path/myOpenAIModel.json
+[source,json]
+----
+{
+  "class": "dev.langchain4j.model.openai.OpenAiChatModel",
+  "name": "openai-1",
+  "params": {
+    "baseUrl": "https://api.openai.com/v1",
+    "apiKey": "apiKey-openAI",
+    "modelName": "gpt-5.4-nano",
+    "timeout": 60,
+    "logRequests": true,
+    "logResponses": true,
+    "maxRetries": 5
+  }
+}
+----
+
+== How to Trigger Document Enrichment during Indexing
+To create new fields starting from existent ones in your documents at indexing time you need to configure an {solr-javadocs}/core/org/apache/solr/update/processor/UpdateRequestProcessorChain.html[Update Request Processor Chain] that includes at least one `DocumentEnrichmentUpdateProcessor` update request processor in one of the 2 following way:

Review Comment:
   moved



##########
solr/solr-ref-guide/modules/indexing-guide/pages/document-enrichment-with-llms.adoc:
##########
@@ -0,0 +1,500 @@
+= Document Enrichment with LLMs
+// 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.
+
+This module brings the power of *Large Language Models* to Solr.
+
+More specifically, it provides the capability, at indexing time, given a prompt and a set of input fields, of calling an
+LLM through https://github.com/langchain4j/langchain4j[LangChain4j] for each document and store the result of the call
+in an `outputField`, that can be of multiple types and even multivalued.
+
+_Without_ this module, the LLM calls must be done _outside_ Solr, before indexing.
+
+[IMPORTANT]
+====
+This module sends your documents off to some hosted service on the internet.
+There are cost, privacy, performance, and service availability implications on such a strong dependency that should be
+diligently examined before employing this module in a serious way.
+
+====
+
+At the moment a subset of LLM providers supported by LangChain4j is supported by Solr.
+
+*Disclaimer*: Apache Solr is *in no way* affiliated to any of these corporations or services.
+
+If you want to add support for additional services or improve the support for the existing ones, feel free to
+contribute:
+
+* https://github.com/apache/solr/blob/main/CONTRIBUTING.md[Contributing to Solr]
+
+== Module
+
+This is provided via the `language-models` xref:configuration-guide:solr-modules.adoc[Solr Module] that needs to be
+enabled before use.
+
+== Language Model Configuration
+
+Language Models is a module and therefore its plugins must be configured in `solrconfig.xml`.
+
+=== Minimum Requirements
+
+* Enable the `language-models` module to make the Language Models classes available on Solr's classpath.
+See xref:configuration-guide:solr-modules.adoc[Solr Module] for more details.
+
+* An update processor, similar to the one below, must be declared in `solrconfig.xml`:
++
+[source,xml]
+----
+<updateRequestProcessorChain name="documentEnrichment">
+  <processor class="solr.languagemodels.documentenrichment.update.processor.DocumentEnrichmentUpdateProcessorFactory">
+   <str name="inputField">string_field</str>
+   <str name="outputField">summary</str>
+   <str name="prompt">Summarize this content: {string_field}</str>
+   <str name="model">model-name</str>
+  </processor>
+  <processor class="solr.RunUpdateProcessorFactory"/>
+ </updateRequestProcessorChain>
+----
+[NOTE]
+====
+If no component is configured in `solrconfig.xml`, the `ChatModel` store will not be registered and requests to
+`/schema/chat-model-store` will return an error.
+====
+
+== Chat Model Configuration
+
+=== Models
+
+* A model in this module is a chat model, that answers with text given a prompt.
+* A model in this Solr module is a reference to an external API that runs the Large Language Model responsible for chat
+completion.
+
+[IMPORTANT]
+====
+The Solr chat model specifies the parameters to access the APIs, the LLM doesn't run internally in Solr
+
+====
+
+A model is described by these parameters:
+
+
+`class`::
++
+[%autowidth,frame=none]
+|===
+s|Required |Default: none
+|===
++
+The model implementation.
+Accepted values:
+
+* `dev.langchain4j.model.ollama.OllamaChatModel`
+* `dev.langchain4j.model.mistralai.MistralAiChatModel`
+* `dev.langchain4j.model.anthropic.AnthropicChatModel`
+* `dev.langchain4j.model.openai.OpenAiChatModel`
+* `dev.langchain4j.model.googleai.GoogleAiGeminiChatModel`
+
+`name`::
++
+[%autowidth,frame=none]
+|===
+s|Required |Default: none
+|===
++
+The identifier of your model, this is used by any component that intends to use the model (e.g., `DocumentEnrichmentUpdateProcessorFactory` update processor).
+
+`params`::
++
+[%autowidth,frame=none]
+|===
+|Optional |Default: none
+|===
++
+Each model class has potentially different params.
+Many are shared but for the full set of parameters of the model you are interested in please refer to the official documentation of the LangChain4j version included in Solr: https://docs.langchain4j.dev/category/language-models[Chat Models in LangChain4j].
+
+=== Supported Models
+Apache Solr uses https://github.com/langchain4j/langchain4j[LangChain4j] to support document enrichment with LLMs.
+The models currently supported are:
+
+[tabs#supported-chat-models]
+======
+Ollama::
++
+====
+
+[source,json]
+----
+{
+  "class": "dev.langchain4j.model.ollama.OllamaChatModel",
+  "name": "<a-name-for-your-model>",
+  "params": {
+    "baseUrl": "http://localhost:11434",
+    "modelName": "<a-local/hosted-chat-model>",
+    "timeout": 300,
+    "logRequests": true,
+    "logResponses": true,
+    "maxRetries": 5
+  }
+}
+----
+====
+
+MistralAI::
++
+====
+[source,json]
+----
+{
+  "class": "dev.langchain4j.model.mistralai.MistralAiChatModel",
+  "name": "<a-name-for-your-model>",
+  "params": {
+    "baseUrl": "https://api.mistral.ai/v1",
+    "apiKey": "<your-mistralAI-api-key>",
+    "modelName": "<a-mistralAI-chat-model>",
+    "timeout": 60,
+    "logRequests": true,
+    "logResponses": true,
+    "maxRetries": 5
+  }
+}
+----
+====
+OpenAI::
++
+====
+[source,json]
+----
+{
+  "class": "dev.langchain4j.model.openai.OpenAiChatModel",
+  "name": "<a-name-for-your-model>",
+  "params": {
+    "baseUrl": "https://api.openai.com/v1",
+    "apiKey": "<your-openAI-api-key>",
+    "modelName": "<a-openAI-chat-model>",
+    "timeout": 60,
+    "logRequests": true,
+    "logResponses": true,
+    "maxRetries": 5
+  }
+}
+----
+====
+
+Anthropic::
++
+====
+[source,json]
+----
+{
+  "class": "dev.langchain4j.model.anthropic.AnthropicChatModel",
+  "name": "<a-name-for-your-model>",
+  "params": {
+    "baseUrl": "https://api.anthropic.com/v1/",
+    "apiKey": "<your-anthropic-api-key>",
+    "modelName": "<a-anthropic-chat-model>",
+    "timeout": 60,
+    "logRequests": true,
+    "logResponses": true,
+    "maxRetries": 5
+  }
+}
+----
+====
+
+Gemini::
++
+====
+[source,json]
+----
+{
+  "class": "dev.langchain4j.model.googleai.GoogleAiGeminiChatModel",
+  "name": "<a-name-for-your-model>",
+  "params": {
+    "baseUrl": "https://generativelanguage.googleapis.com/v1beta/",
+    "apiKey": "<your-geminiAi-api-key>",
+    "modelName": "<a-geminiAi-chat-model>",
+    "timeout": 60,
+    "logRequests": true,
+    "logResponses": true,
+    "maxRetries": 5
+  }
+}
+----
+====
+======
+
+=== Uploading a Model
+
+To upload the model in a `/path/myModel.json` file, please run:
+
+[source,bash]
+----
+curl -XPUT 'http://localhost:8983/solr/YOUR_COLLECTION/schema/chat-model-store' --data-binary "@/path/myModel.json" -H 'Content-type:application/json'
+----
+
+To delete the `currentModel` model:
+
+[source,bash]
+----
+curl -XDELETE 'http://localhost:8983/solr/YOUR_COLLECTION/schema/chat-model-store/currentModel'
+----
+
+To view all models:
+
+[source,text]
+http://localhost:8983/solr/YOUR_COLLECTION/schema/chat-model-store
+
+
+.Example: /path/myOpenAIModel.json
+[source,json]
+----
+{
+  "class": "dev.langchain4j.model.openai.OpenAiChatModel",
+  "name": "openai-1",
+  "params": {
+    "baseUrl": "https://api.openai.com/v1",
+    "apiKey": "apiKey-openAI",
+    "modelName": "gpt-5.4-nano",
+    "timeout": 60,
+    "logRequests": true,
+    "logResponses": true,
+    "maxRetries": 5
+  }
+}
+----
+
+== How to Trigger Document Enrichment during Indexing
+To create new fields starting from existent ones in your documents at indexing time you need to configure an {solr-javadocs}/core/org/apache/solr/update/processor/UpdateRequestProcessorChain.html[Update Request Processor Chain] that includes at least one `DocumentEnrichmentUpdateProcessor` update request processor in one of the 2 following way:
+
+* Update processor with parameter `prompt`

Review Comment:
   changed



##########
solr/solr-ref-guide/modules/indexing-guide/pages/document-enrichment-with-llms.adoc:
##########
@@ -0,0 +1,500 @@
+= Document Enrichment with LLMs
+// 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.
+
+This module brings the power of *Large Language Models* to Solr.
+
+More specifically, it provides the capability, at indexing time, given a prompt and a set of input fields, of calling an
+LLM through https://github.com/langchain4j/langchain4j[LangChain4j] for each document and store the result of the call
+in an `outputField`, that can be of multiple types and even multivalued.
+
+_Without_ this module, the LLM calls must be done _outside_ Solr, before indexing.
+
+[IMPORTANT]
+====
+This module sends your documents off to some hosted service on the internet.
+There are cost, privacy, performance, and service availability implications on such a strong dependency that should be
+diligently examined before employing this module in a serious way.
+
+====
+
+At the moment a subset of LLM providers supported by LangChain4j is supported by Solr.
+
+*Disclaimer*: Apache Solr is *in no way* affiliated to any of these corporations or services.
+
+If you want to add support for additional services or improve the support for the existing ones, feel free to
+contribute:
+
+* https://github.com/apache/solr/blob/main/CONTRIBUTING.md[Contributing to Solr]
+
+== Module
+
+This is provided via the `language-models` xref:configuration-guide:solr-modules.adoc[Solr Module] that needs to be
+enabled before use.
+
+== Language Model Configuration
+
+Language Models is a module and therefore its plugins must be configured in `solrconfig.xml`.
+
+=== Minimum Requirements
+
+* Enable the `language-models` module to make the Language Models classes available on Solr's classpath.
+See xref:configuration-guide:solr-modules.adoc[Solr Module] for more details.
+
+* An update processor, similar to the one below, must be declared in `solrconfig.xml`:
++
+[source,xml]
+----
+<updateRequestProcessorChain name="documentEnrichment">
+  <processor class="solr.languagemodels.documentenrichment.update.processor.DocumentEnrichmentUpdateProcessorFactory">
+   <str name="inputField">string_field</str>
+   <str name="outputField">summary</str>
+   <str name="prompt">Summarize this content: {string_field}</str>
+   <str name="model">model-name</str>
+  </processor>
+  <processor class="solr.RunUpdateProcessorFactory"/>
+ </updateRequestProcessorChain>
+----
+[NOTE]
+====
+If no component is configured in `solrconfig.xml`, the `ChatModel` store will not be registered and requests to
+`/schema/chat-model-store` will return an error.
+====
+
+== Chat Model Configuration
+
+=== Models
+
+* A model in this module is a chat model, that answers with text given a prompt.
+* A model in this Solr module is a reference to an external API that runs the Large Language Model responsible for chat
+completion.
+
+[IMPORTANT]
+====
+The Solr chat model specifies the parameters to access the APIs, the LLM doesn't run internally in Solr
+
+====
+
+A model is described by these parameters:
+
+
+`class`::
++
+[%autowidth,frame=none]
+|===
+s|Required |Default: none
+|===
++
+The model implementation.
+Accepted values:
+
+* `dev.langchain4j.model.ollama.OllamaChatModel`
+* `dev.langchain4j.model.mistralai.MistralAiChatModel`
+* `dev.langchain4j.model.anthropic.AnthropicChatModel`
+* `dev.langchain4j.model.openai.OpenAiChatModel`
+* `dev.langchain4j.model.googleai.GoogleAiGeminiChatModel`
+
+`name`::
++
+[%autowidth,frame=none]
+|===
+s|Required |Default: none
+|===
++
+The identifier of your model, this is used by any component that intends to use the model (e.g., `DocumentEnrichmentUpdateProcessorFactory` update processor).
+
+`params`::
++
+[%autowidth,frame=none]
+|===
+|Optional |Default: none
+|===
++
+Each model class has potentially different params.
+Many are shared but for the full set of parameters of the model you are interested in please refer to the official documentation of the LangChain4j version included in Solr: https://docs.langchain4j.dev/category/language-models[Chat Models in LangChain4j].
+
+=== Supported Models
+Apache Solr uses https://github.com/langchain4j/langchain4j[LangChain4j] to support document enrichment with LLMs.
+The models currently supported are:
+
+[tabs#supported-chat-models]
+======
+Ollama::
++
+====
+
+[source,json]
+----
+{
+  "class": "dev.langchain4j.model.ollama.OllamaChatModel",
+  "name": "<a-name-for-your-model>",
+  "params": {
+    "baseUrl": "http://localhost:11434",
+    "modelName": "<a-local/hosted-chat-model>",
+    "timeout": 300,
+    "logRequests": true,
+    "logResponses": true,
+    "maxRetries": 5
+  }
+}
+----
+====
+
+MistralAI::
++
+====
+[source,json]
+----
+{
+  "class": "dev.langchain4j.model.mistralai.MistralAiChatModel",
+  "name": "<a-name-for-your-model>",
+  "params": {
+    "baseUrl": "https://api.mistral.ai/v1",
+    "apiKey": "<your-mistralAI-api-key>",
+    "modelName": "<a-mistralAI-chat-model>",
+    "timeout": 60,
+    "logRequests": true,
+    "logResponses": true,
+    "maxRetries": 5
+  }
+}
+----
+====
+OpenAI::
++
+====
+[source,json]
+----
+{
+  "class": "dev.langchain4j.model.openai.OpenAiChatModel",
+  "name": "<a-name-for-your-model>",
+  "params": {
+    "baseUrl": "https://api.openai.com/v1",
+    "apiKey": "<your-openAI-api-key>",
+    "modelName": "<a-openAI-chat-model>",
+    "timeout": 60,
+    "logRequests": true,
+    "logResponses": true,
+    "maxRetries": 5
+  }
+}
+----
+====
+
+Anthropic::
++
+====
+[source,json]
+----
+{
+  "class": "dev.langchain4j.model.anthropic.AnthropicChatModel",
+  "name": "<a-name-for-your-model>",
+  "params": {
+    "baseUrl": "https://api.anthropic.com/v1/",
+    "apiKey": "<your-anthropic-api-key>",
+    "modelName": "<a-anthropic-chat-model>",
+    "timeout": 60,
+    "logRequests": true,
+    "logResponses": true,
+    "maxRetries": 5
+  }
+}
+----
+====
+
+Gemini::
++
+====
+[source,json]
+----
+{
+  "class": "dev.langchain4j.model.googleai.GoogleAiGeminiChatModel",
+  "name": "<a-name-for-your-model>",
+  "params": {
+    "baseUrl": "https://generativelanguage.googleapis.com/v1beta/",
+    "apiKey": "<your-geminiAi-api-key>",
+    "modelName": "<a-geminiAi-chat-model>",
+    "timeout": 60,
+    "logRequests": true,
+    "logResponses": true,
+    "maxRetries": 5
+  }
+}
+----
+====
+======
+
+=== Uploading a Model
+
+To upload the model in a `/path/myModel.json` file, please run:
+
+[source,bash]
+----
+curl -XPUT 'http://localhost:8983/solr/YOUR_COLLECTION/schema/chat-model-store' --data-binary "@/path/myModel.json" -H 'Content-type:application/json'
+----
+
+To delete the `currentModel` model:
+
+[source,bash]
+----
+curl -XDELETE 'http://localhost:8983/solr/YOUR_COLLECTION/schema/chat-model-store/currentModel'
+----
+
+To view all models:
+
+[source,text]
+http://localhost:8983/solr/YOUR_COLLECTION/schema/chat-model-store
+
+
+.Example: /path/myOpenAIModel.json
+[source,json]
+----
+{
+  "class": "dev.langchain4j.model.openai.OpenAiChatModel",
+  "name": "openai-1",
+  "params": {
+    "baseUrl": "https://api.openai.com/v1",
+    "apiKey": "apiKey-openAI",
+    "modelName": "gpt-5.4-nano",
+    "timeout": 60,
+    "logRequests": true,
+    "logResponses": true,
+    "maxRetries": 5
+  }
+}
+----
+
+== How to Trigger Document Enrichment during Indexing
+To create new fields starting from existent ones in your documents at indexing time you need to configure an {solr-javadocs}/core/org/apache/solr/update/processor/UpdateRequestProcessorChain.html[Update Request Processor Chain] that includes at least one `DocumentEnrichmentUpdateProcessor` update request processor in one of the 2 following way:
+
+* Update processor with parameter `prompt`
++
+[source,xml]
+----
+<updateRequestProcessorChain name="documentEnrichment">
+  <processor class="solr.languagemodels.documentenrichment.update.processor.DocumentEnrichmentUpdateProcessorFactory">
+   <str name="inputField">string_field</str>
+   <str name="outputField">summary</str>
+   <str name="prompt">Summarize this content: {string_field}</str>
+   <str name="model">model-name</str>
+  </processor>
+  <processor class="solr.RunUpdateProcessorFactory"/>
+ </updateRequestProcessorChain>
+----
+
+* Update processor with parameter `promptFile`: in this case, the file `prompt.txt` must be uploaded to Solr similarly to any other configuration file (e.g., `solrconfig.xml`, `synonyms.txt`, etc.)
++
+[source,xml]
+----
+<updateRequestProcessorChain name="documentEnrichment">
+  <processor class="solr.languagemodels.documentenrichment.update.processor.DocumentEnrichmentUpdateProcessorFactory">
+   <str name="inputField">string_field</str>
+   <str name="outputField">summary</str>
+   <str name="promptFile">prompt.txt</str>
+   <str name="model">model-name</str>
+  </processor>
+  <processor class="solr.RunUpdateProcessorFactory"/>
+ </updateRequestProcessorChain>
+----
+
+Exactly one of the following parameters is required: `prompt` or `promptFile`.
+
+Another important feature of this module is that one (or more) `inputField` needs to be injected in the prompt. This is
+done by some special tokens, that are the `fieldName` surrounded by curly brackets (e.g., `{string_field}`, in the
+example above). These tokens are _mandatory_ for this module to work properly. Solr will throw an error if the
+parameters are not properly defined.
+For example, both the prompt and the content of the file prompt.txt, must contain the text '{string_field}', which
+will be substituted with the content of the `string_field` field for each document. An example of a valid prompt with

Review Comment:
   Done



-- 
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: issues-unsubscribe@solr.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


---------------------------------------------------------------------
To unsubscribe, e-mail: issues-unsubscribe@solr.apache.org
For additional commands, e-mail: issues-help@solr.apache.org


Re: [PR] SOLR-18187: Document enrichment with LLMs [solr]

Posted by "aruggero (via GitHub)" <gi...@apache.org>.
aruggero commented on code in PR #4259:
URL: https://github.com/apache/solr/pull/4259#discussion_r3033183645


##########
solr/modules/language-models/src/test-files/solr/collection1/conf/solrconfig-document-enrichment.xml:
##########
@@ -0,0 +1,235 @@
+<?xml version="1.0" ?>
+<!-- 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. -->
+
+<config>
+    <luceneMatchVersion>${tests.luceneMatchVersion:LATEST}</luceneMatchVersion>
+ <dataDir>${solr.data.dir:}</dataDir>
+ <directoryFactory name="DirectoryFactory"
+                   class="${solr.directoryFactory:solr.MockDirectoryFactory}" />
+ <schemaFactory class="ClassicIndexSchemaFactory" />
+
+ <requestDispatcher>
+   <requestParsers />
+ </requestDispatcher>
+
+ <query>
+  <filterCache class="solr.CaffeineCache" size="4096"
+   initialSize="2048" autowarmCount="0" />
+ </query>
+ <requestHandler name="/select" class="solr.SearchHandler" />
+
+ <updateHandler class="solr.DirectUpdateHandler2">
+  <autoCommit>
+   <maxTime>15000</maxTime>
+   <openSearcher>false</openSearcher>
+  </autoCommit>
+  <autoSoftCommit>
+   <maxTime>1000</maxTime>
+  </autoSoftCommit>
+  <updateLog>
+   <str name="dir">${solr.data.dir:}</str>
+  </updateLog>
+ </updateHandler>
+
+ <requestHandler name="/query" class="solr.SearchHandler">
+  <lst name="defaults">
+   <str name="echoParams">explicit</str>
+   <str name="wt">json</str>
+   <str name="indent">true</str>
+   <str name="df">id</str>
+  </lst>
+ </requestHandler>
+
+ <updateRequestProcessorChain name="documentEnrichment">
+  <processor class="solr.languagemodels.documentenrichment.update.processor.DocumentEnrichmentUpdateProcessorFactory">
+   <str name="inputField">string_field</str>
+   <str name="outputField">enriched_field</str>
+   <str name="prompt">Summarize this content: {string_field}</str>
+   <str name="model">dummy-chat-1</str>
+  </processor>
+  <processor class="solr.RunUpdateProcessorFactory"/>
+ </updateRequestProcessorChain>
+
+ <updateRequestProcessorChain name="failingDocumentEnrichment">
+  <processor class="solr.languagemodels.documentenrichment.update.processor.DocumentEnrichmentUpdateProcessorFactory">
+   <str name="inputField">string_field</str>
+   <str name="outputField">enriched_field</str>
+   <str name="prompt">Summarize this content: {string_field}</str>
+   <str name="model">exception-throwing-chat-model</str>
+  </processor>
+  <processor class="solr.RunUpdateProcessorFactory"/>
+ </updateRequestProcessorChain>
+
+ <updateRequestProcessorChain name="documentEnrichmentForPartialUpdates">
+  <processor class="solr.DistributedUpdateProcessorFactory"/>
+  <processor class="solr.languagemodels.documentenrichment.update.processor.DocumentEnrichmentUpdateProcessorFactory">
+   <str name="inputField">string_field</str>
+   <str name="outputField">enriched_field</str>
+   <str name="prompt">Summarize this content: {string_field}</str>
+   <str name="model">dummy-chat-1</str>
+  </processor>
+  <processor class="solr.RunUpdateProcessorFactory"/>
+ </updateRequestProcessorChain>
+
+ <updateRequestProcessorChain name="documentEnrichmentMultiField">
+  <processor class="solr.languagemodels.documentenrichment.update.processor.DocumentEnrichmentUpdateProcessorFactory">
+   <str name="inputField">string_field</str>
+   <str name="inputField">body_field</str>
+   <str name="outputField">enriched_field</str>
+   <str name="prompt">Title: {string_field}. Body: {body_field}.</str>
+   <str name="model">dummy-chat-1</str>
+  </processor>
+  <processor class="solr.RunUpdateProcessorFactory"/>
+ </updateRequestProcessorChain>
+
+ <updateRequestProcessorChain name="documentEnrichmentMultivaluedString">
+  <processor class="solr.languagemodels.documentenrichment.update.processor.DocumentEnrichmentUpdateProcessorFactory">
+   <str name="inputField">string_field</str>
+   <str name="outputField">enriched_field_multi</str>
+   <str name="prompt">Extract tags from: {string_field}</str>
+   <str name="model">dummy-chat-multivalued-1</str>
+  </processor>
+  <processor class="solr.RunUpdateProcessorFactory"/>
+ </updateRequestProcessorChain>
+
+ <updateRequestProcessorChain name="failingDocumentEnrichmentMultiField">

Review Comment:
   Difference with failingDocumentEnrichment?



##########
solr/modules/language-models/src/test/org/apache/solr/languagemodels/documentenrichment/update/processor/DocumentEnrichmentUpdateProcessorFactoryTest.java:
##########
@@ -0,0 +1,422 @@
+/*
+ * 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.solr.languagemodels.documentenrichment.update.processor;
+
+import java.util.List;
+import org.apache.solr.common.SolrException;
+import org.apache.solr.common.params.ModifiableSolrParams;
+import org.apache.solr.common.util.NamedList;
+import org.apache.solr.core.SolrCore;
+import org.apache.solr.languagemodels.TestLanguageModelBase;
+import org.apache.solr.languagemodels.documentenrichment.model.SolrChatModel;
+import org.apache.solr.languagemodels.documentenrichment.store.rest.ManagedChatModelStore;
+import org.apache.solr.request.SolrQueryRequestBase;
+import org.apache.solr.update.processor.UpdateRequestProcessor;
+import org.junit.After;
+import org.junit.AfterClass;
+import org.junit.Before;
+import org.junit.BeforeClass;
+import org.junit.Test;
+
+public class DocumentEnrichmentUpdateProcessorFactoryTest extends TestLanguageModelBase {

Review Comment:
   Add a test for a not-supported output field type (dense vector is present, but the other fields of the other exception are not)



##########
solr/modules/language-models/src/test/org/apache/solr/languagemodels/documentenrichment/store/rest/TestChatModelManagerPersistence.java:
##########
@@ -0,0 +1,101 @@
+/*
+ * 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.solr.languagemodels.documentenrichment.store.rest;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import org.apache.solr.common.util.Utils;
+import org.apache.solr.languagemodels.TestLanguageModelBase;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+
+public class TestChatModelManagerPersistence extends TestLanguageModelBase {
+
+  @Before
+  public void init() throws Exception {
+    setupTest("solrconfig-document-enrichment.xml", "schema-language-models.xml", false, true);
+  }
+
+  @After
+  public void cleanup() throws Exception {
+    afterTest();
+  }
+
+  @Test
+  public void testModelAreStoredCompact() throws Exception {
+    loadChatModel("openai-model.json");
+
+    final String JSONOnDisk = Files.readString(chatModelStoreFile, StandardCharsets.UTF_8);
+    Object objectFromDisk = Utils.fromJSONString(JSONOnDisk);
+    assertEquals(new String(Utils.toJSON(objectFromDisk, -1), UTF_8), JSONOnDisk);
+  }
+
+  @Test
+  public void testModelStorePersistence() throws Exception {
+    // check store is empty at start
+    assertJQ(ManagedChatModelStore.REST_END_POINT, "/models/==[]");
+
+    // load a model
+    loadChatModel("openai-model.json");
+
+    final String modelName = "openai-1";
+    assertJQ(ManagedChatModelStore.REST_END_POINT, "/models/[0]/name=='" + modelName + "'");
+    assertJQ(
+        ManagedChatModelStore.REST_END_POINT,
+        "/models/[0]/params/baseUrl=='https://api.openai.com/v1'");
+    assertJQ(
+        ManagedChatModelStore.REST_END_POINT, "/models/[0]/params/apiKey=='apiKey-openAI'");
+    assertJQ(
+        ManagedChatModelStore.REST_END_POINT, "/models/[0]/params/modelName=='gpt-5.4-nano'");
+    assertJQ(ManagedChatModelStore.REST_END_POINT, "/models/[0]/params/timeout==60");
+    assertJQ(ManagedChatModelStore.REST_END_POINT, "/models/[0]/params/logRequests==true");
+    assertJQ(ManagedChatModelStore.REST_END_POINT, "/models/[0]/params/logResponses==true");
+    assertJQ(ManagedChatModelStore.REST_END_POINT, "/models/[0]/params/maxRetries==5");
+
+    // check persistence after reload
+    restTestHarness.reload();
+    assertJQ(ManagedChatModelStore.REST_END_POINT, "/models/[0]/name=='" + modelName + "'");

Review Comment:
   What about the other params?



-- 
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: issues-unsubscribe@solr.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


---------------------------------------------------------------------
To unsubscribe, e-mail: issues-unsubscribe@solr.apache.org
For additional commands, e-mail: issues-help@solr.apache.org


Re: [PR] SOLR-18187: Document enrichment with LLMs [solr]

Posted by "alessandrobenedetti (via GitHub)" <gi...@apache.org>.
alessandrobenedetti commented on code in PR #4259:
URL: https://github.com/apache/solr/pull/4259#discussion_r3101222759


##########
solr/modules/language-models/src/java/org/apache/solr/languagemodels/documentenrichment/update/processor/DocumentEnrichmentUpdateProcessor.java:
##########
@@ -0,0 +1,116 @@
+/*
+ * 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.solr.languagemodels.documentenrichment.update.processor;
+
+import dev.langchain4j.model.chat.request.ResponseFormat;
+import java.io.IOException;
+import java.lang.invoke.MethodHandles;
+import java.util.List;
+import org.apache.solr.common.SolrInputDocument;
+import org.apache.solr.common.SolrInputField;
+import org.apache.solr.languagemodels.documentenrichment.model.SolrChatModel;
+import org.apache.solr.request.SolrQueryRequest;
+import org.apache.solr.schema.IndexSchema;
+import org.apache.solr.schema.SchemaField;
+import org.apache.solr.update.AddUpdateCommand;
+import org.apache.solr.update.processor.UpdateRequestProcessor;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+class DocumentEnrichmentUpdateProcessor extends UpdateRequestProcessor {
+  private static final Logger log = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());
+
+  private final IndexSchema schema;
+  private final List<String> inputFields;
+  private final String outputField;
+  private final String prompt;
+  private final SolrChatModel chatModel;
+  private final boolean multiValued;
+  private final ResponseFormat responseFormat;
+
+  public DocumentEnrichmentUpdateProcessor(
+      List<String> inputFields,
+      String outputField,
+      String prompt,
+      SolrChatModel chatModel,
+      boolean multiValued,
+      ResponseFormat responseFormat,
+      SolrQueryRequest req,
+      UpdateRequestProcessor next) {
+    super(next);
+    this.schema = req.getSchema();
+    this.inputFields = inputFields;
+    this.outputField = outputField;
+    this.prompt = prompt;
+    this.chatModel = chatModel;
+    this.multiValued = multiValued;
+    this.responseFormat = responseFormat;
+  }
+
+  /**
+   * @param cmd the update command in input containing the Document to process
+   * @throws IOException If there is a low-level I/O error
+   */
+  @Override
+  public void processAdd(AddUpdateCommand cmd) throws IOException {
+    SolrInputDocument doc = cmd.getSolrInputDocument();
+
+    // Collect all field values; skip enrichment if any declared field is null or empty
+    String injectedPrompt = prompt;
+    for (String fieldName : inputFields) {
+      SolrInputField field = doc.get(fieldName);
+      if (isNullOrEmpty(field)) {

Review Comment:
   if a field is null you skip enrichment for the entire document? It's suspicious, we should discuss what it means to have multiple fields in input.
   My assumption would be you define a processor for a list of fields, to enrich each of them in the same way.
   having a null field should only skip the field rather than the full document?



-- 
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: issues-unsubscribe@solr.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


---------------------------------------------------------------------
To unsubscribe, e-mail: issues-unsubscribe@solr.apache.org
For additional commands, e-mail: issues-help@solr.apache.org


Re: [PR] SOLR-18187: Document enrichment with LLMs [solr]

Posted by "alessandrobenedetti (via GitHub)" <gi...@apache.org>.
alessandrobenedetti commented on code in PR #4259:
URL: https://github.com/apache/solr/pull/4259#discussion_r3101203078


##########
solr/modules/language-models/src/java/org/apache/solr/languagemodels/documentenrichment/update/processor/DocumentEnrichmentUpdateProcessor.java:
##########
@@ -0,0 +1,116 @@
+/*
+ * 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.solr.languagemodels.documentenrichment.update.processor;
+
+import dev.langchain4j.model.chat.request.ResponseFormat;
+import java.io.IOException;
+import java.lang.invoke.MethodHandles;
+import java.util.List;
+import org.apache.solr.common.SolrInputDocument;
+import org.apache.solr.common.SolrInputField;
+import org.apache.solr.languagemodels.documentenrichment.model.SolrChatModel;
+import org.apache.solr.request.SolrQueryRequest;
+import org.apache.solr.schema.IndexSchema;
+import org.apache.solr.schema.SchemaField;
+import org.apache.solr.update.AddUpdateCommand;
+import org.apache.solr.update.processor.UpdateRequestProcessor;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+class DocumentEnrichmentUpdateProcessor extends UpdateRequestProcessor {
+  private static final Logger log = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());
+
+  private final IndexSchema schema;
+  private final List<String> inputFields;
+  private final String outputField;
+  private final String prompt;
+  private final SolrChatModel chatModel;
+  private final boolean multiValued;
+  private final ResponseFormat responseFormat;
+
+  public DocumentEnrichmentUpdateProcessor(
+      List<String> inputFields,
+      String outputField,
+      String prompt,
+      SolrChatModel chatModel,
+      boolean multiValued,
+      ResponseFormat responseFormat,
+      SolrQueryRequest req,
+      UpdateRequestProcessor next) {
+    super(next);
+    this.schema = req.getSchema();
+    this.inputFields = inputFields;
+    this.outputField = outputField;
+    this.prompt = prompt;
+    this.chatModel = chatModel;
+    this.multiValued = multiValued;
+    this.responseFormat = responseFormat;
+  }
+
+  /**
+   * @param cmd the update command in input containing the Document to process
+   * @throws IOException If there is a low-level I/O error
+   */
+  @Override
+  public void processAdd(AddUpdateCommand cmd) throws IOException {
+    SolrInputDocument doc = cmd.getSolrInputDocument();
+
+    // Collect all field values; skip enrichment if any declared field is null or empty
+    String injectedPrompt = prompt;
+    for (String fieldName : inputFields) {
+      SolrInputField field = doc.get(fieldName);
+      if (isNullOrEmpty(field)) {
+        super.processAdd(cmd);
+        return;
+      }
+      injectedPrompt = injectedPrompt.replace("{" + fieldName + "}", field.getValue().toString());
+    }
+
+    try {
+      // as for now, only a plain text as prompt is sent to the model (no support for
+      // tools/skills/agents)
+      // chatModel.chat returns the parsed value from the structured JSON response
+      Object value = chatModel.chat(injectedPrompt, responseFormat);

Review Comment:
   value?



-- 
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: issues-unsubscribe@solr.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


---------------------------------------------------------------------
To unsubscribe, e-mail: issues-unsubscribe@solr.apache.org
For additional commands, e-mail: issues-help@solr.apache.org


Re: [PR] SOLR-18187: Document enrichment with LLMs [solr]

Posted by "aruggero (via GitHub)" <gi...@apache.org>.
aruggero commented on code in PR #4259:
URL: https://github.com/apache/solr/pull/4259#discussion_r3073160541


##########
solr/modules/language-models/src/java/org/apache/solr/languagemodels/documentenrichment/update/processor/DocumentEnrichmentUpdateProcessorFactory.java:
##########
@@ -0,0 +1,330 @@
+/*
+ * 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.solr.languagemodels.documentenrichment.update.processor;
+
+import dev.langchain4j.model.chat.request.ResponseFormat;
+import dev.langchain4j.model.chat.request.ResponseFormatType;
+import dev.langchain4j.model.chat.request.json.JsonArraySchema;
+import dev.langchain4j.model.chat.request.json.JsonBooleanSchema;
+import dev.langchain4j.model.chat.request.json.JsonIntegerSchema;
+import dev.langchain4j.model.chat.request.json.JsonNumberSchema;
+import dev.langchain4j.model.chat.request.json.JsonObjectSchema;
+import dev.langchain4j.model.chat.request.json.JsonSchema;
+import dev.langchain4j.model.chat.request.json.JsonSchemaElement;
+import dev.langchain4j.model.chat.request.json.JsonStringSchema;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import java.util.Collection;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import org.apache.solr.common.SolrException;
+import org.apache.solr.common.params.RequiredSolrParams;
+import org.apache.solr.common.params.SolrParams;
+import org.apache.solr.common.util.NamedList;
+import org.apache.solr.core.SolrCore;
+import org.apache.solr.core.SolrResourceLoader;
+import org.apache.solr.languagemodels.documentenrichment.model.SolrChatModel;
+import org.apache.solr.languagemodels.documentenrichment.store.rest.ManagedChatModelStore;
+import org.apache.solr.request.SolrQueryRequest;
+import org.apache.solr.response.SolrQueryResponse;
+import org.apache.solr.rest.ManagedResource;
+import org.apache.solr.rest.ManagedResourceObserver;
+import org.apache.solr.schema.BoolField;
+import org.apache.solr.schema.DatePointField;
+import org.apache.solr.schema.DenseVectorField;
+import org.apache.solr.schema.DoublePointField;
+import org.apache.solr.schema.FieldType;
+import org.apache.solr.schema.FloatPointField;
+import org.apache.solr.schema.IndexSchema;
+import org.apache.solr.schema.IntPointField;
+import org.apache.solr.schema.LongPointField;
+import org.apache.solr.schema.NestPathField;
+import org.apache.solr.schema.SchemaField;
+import org.apache.solr.schema.StrField;
+import org.apache.solr.schema.TextField;
+import org.apache.solr.schema.UUIDField;
+import org.apache.solr.update.processor.UpdateRequestProcessor;
+import org.apache.solr.update.processor.UpdateRequestProcessorFactory;
+import org.apache.solr.util.plugin.SolrCoreAware;
+
+/**
+ * Generate the content of a field based on other fields specified as input.
+ *
+ * <p>One or more {@code inputField} parameters specify the Solr fields to use as input. Each field
+ * name must appear as a {@code {fieldName}} placeholder in the prompt. Exactly one of {@code
+ * prompt} or {@code promptFile} must be provided.
+ *
+ * <pre class="prettyprint" >
+ * &lt;processor class=&quot;solr.llm.documentenrichment.update.processor.DocumentEnrichmentUpdateProcessorFactory&quot;&gt;
+ *   &lt;str name=&quot;inputField&quot;&gt;title_field&lt;/str&gt;
+ *   &lt;str name=&quot;inputField&quot;&gt;body_field&lt;/str&gt;
+ *   &lt;str name=&quot;outputField&quot;&gt;enriched_field&lt;/str&gt;
+ *   &lt;str name=&quot;prompt&quot;&gt;Title: {title_field}. Body: {body_field}.&lt;/str&gt;
+ *   &lt;str name=&quot;model&quot;&gt;ChatModel&lt;/str&gt;
+ * &lt;/processor&gt;
+ * </pre>
+ *
+ * <p>Multiple {@code inputField} values can also be declared as an array using {@code arr}:
+ *
+ * <pre class="prettyprint" >
+ * &lt;processor class=&quot;solr.llm.documentenrichment.update.processor.DocumentEnrichmentUpdateProcessorFactory&quot;&gt;
+ *   &lt;arr name=&quot;inputField&quot;&gt;
+ *     &lt;str&gt;title_field&lt;/str&gt;
+ *     &lt;str&gt;body_field&lt;/str&gt;
+ *   &lt;/arr&gt;
+ *   &lt;str name=&quot;outputField&quot;&gt;enriched_field&lt;/str&gt;
+ *   &lt;str name=&quot;prompt&quot;&gt;Title: {title_field}. Body: {body_field}.&lt;/str&gt;
+ *   &lt;str name=&quot;model&quot;&gt;ChatModel&lt;/str&gt;
+ * &lt;/processor&gt;
+ * </pre>
+ *
+ * <p>Alternatively, the prompt can be loaded from a text file using {@code promptFile}:
+ *
+ * <pre class="prettyprint" >
+ * &lt;processor class=&quot;solr.llm.documentenrichment.update.processor.DocumentEnrichmentUpdateProcessorFactory&quot;&gt;
+ *   &lt;str name=&quot;inputField&quot;&gt;title_field&lt;/str&gt;
+ *   &lt;str name=&quot;outputField&quot;&gt;enriched_field&lt;/str&gt;
+ *   &lt;str name=&quot;promptFile&quot;&gt;prompt.txt&lt;/str&gt;
+ *   &lt;str name=&quot;model&quot;&gt;ChatModel&lt;/str&gt;
+ * &lt;/processor&gt;
+ * </pre>
+ *
+ * <p>Validation rules:
+ *
+ * <ul>
+ *   <li>At least one {@code inputField} must be declared.
+ *   <li>Exactly one of {@code prompt} or {@code promptFile} must be provided.
+ *   <li>Every declared {@code inputField} must have a corresponding {@code {fieldName}} placeholder
+ *       in the prompt.
+ *   <li>Every {@code {placeholder}} in the prompt must correspond to a declared {@code inputField}.
+ * </ul>
+ */
+public class DocumentEnrichmentUpdateProcessorFactory extends UpdateRequestProcessorFactory
+    implements SolrCoreAware, ManagedResourceObserver {
+  private static final String INPUT_FIELD_PARAM = "inputField";
+  private static final String OUTPUT_FIELD_PARAM = "outputField";
+  private static final String PROMPT = "prompt";
+  private static final String PROMPT_FILE = "promptFile";
+  private static final String MODEL_NAME = "model";
+  private static final Pattern PLACEHOLDER_PATTERN = Pattern.compile("\\{([^}]+)\\}");
+
+  private List<String> inputFields;
+  private String outputField;
+  private String promptText;
+  private String promptFile;
+  private String modelName;
+
+  @Override
+  public void init(final NamedList<?> args) {
+    // removeConfigArgs handles both multiple <str name="inputField"> and <arr name="inputField">
+    // and must be called before toSolrParams() since it mutates args in place
+    Collection<String> fieldNames = args.removeConfigArgs(INPUT_FIELD_PARAM);
+    if (fieldNames.isEmpty()) {
+      throw new SolrException(
+          SolrException.ErrorCode.SERVER_ERROR,
+          "At least one 'inputField' must be provided");
+    }
+    inputFields = List.copyOf(fieldNames);
+
+    SolrParams params = args.toSolrParams();
+    RequiredSolrParams required = params.required();
+    outputField = required.get(OUTPUT_FIELD_PARAM);
+    modelName = required.get(MODEL_NAME);
+
+    String inlinePrompt = params.get(PROMPT);
+    String promptFilePath = params.get(PROMPT_FILE);
+
+    if (inlinePrompt == null && promptFilePath == null) {
+      throw new SolrException(
+          SolrException.ErrorCode.SERVER_ERROR,
+          "Either 'prompt' or 'promptFile' must be provided");
+    }
+    if (inlinePrompt != null && promptFilePath != null) {
+      throw new SolrException(
+          SolrException.ErrorCode.SERVER_ERROR,
+          "Only one of 'prompt' or 'promptFile' can be provided, not both");
+    }
+    if (inlinePrompt != null) {
+      validatePromptPlaceholders(inlinePrompt, inputFields);
+      this.promptText = inlinePrompt;
+    }
+    this.promptFile = promptFilePath;
+  }
+
+  @Override
+  public void inform(SolrCore core) {
+    final SolrResourceLoader solrResourceLoader = core.getResourceLoader();
+    ManagedChatModelStore.registerManagedChatModelStore(solrResourceLoader, this);
+    if (promptFile != null) {
+      try (InputStream is = solrResourceLoader.openResource(promptFile)) {
+        promptText = new String(is.readAllBytes(), StandardCharsets.UTF_8).trim();
+      } catch (IOException e) {
+        throw new SolrException(
+            SolrException.ErrorCode.SERVER_ERROR,
+            "Cannot read prompt file: " + promptFile,
+            e);
+      }
+      validatePromptPlaceholders(promptText, inputFields);
+    }
+  }
+
+  @Override
+  public void onManagedResourceInitialized(NamedList<?> args, ManagedResource res)
+      throws SolrException {
+    if (res instanceof ManagedChatModelStore store) {
+      store.loadStoredModels();
+    }
+  }
+
+  @Override
+  public UpdateRequestProcessor getInstance(
+      SolrQueryRequest req, SolrQueryResponse rsp, UpdateRequestProcessor next) {
+    IndexSchema latestSchema = req.getCore().getLatestSchema();
+
+    for (String fieldName : inputFields) {
+      if (!latestSchema.isDynamicField(fieldName) && !latestSchema.hasExplicitField(fieldName)) {
+        throw new SolrException(
+            SolrException.ErrorCode.SERVER_ERROR, "undefined field: \"" + fieldName + "\"");
+      }
+    }
+
+    final SchemaField outputFieldSchema = latestSchema.getField(outputField);
+
+    ResponseFormat responseFormat = buildResponseFormat(outputFieldSchema);
+    boolean multiValued = outputFieldSchema.multiValued();
+
+    ManagedChatModelStore store = ManagedChatModelStore.getManagedModelStore(req.getCore());
+    SolrChatModel chatModel = store.getModel(modelName);
+    if (chatModel == null) {
+      throw new SolrException(
+          SolrException.ErrorCode.SERVER_ERROR,
+          "The model configured in the Update Request Processor '"
+              + modelName
+              + "' can't be found in the store: "
+              + ManagedChatModelStore.REST_END_POINT);
+    }
+
+    return new DocumentEnrichmentUpdateProcessor(
+        inputFields, outputField, promptText, chatModel, multiValued, responseFormat, req, next);
+  }
+
+  /**
+   * Builds a {@link ResponseFormat} that instructs the model to return a JSON object {@code
+   * {"value": ...}} whose value type matches the Solr field type. For multivalued fields the value
+   * is wrapped in a {@link JsonArraySchema} nested inside the root {@link JsonObjectSchema}.
+   *
+   * <p>Nesting {@link JsonArraySchema} inside a {@link JsonObjectSchema} property is supported by
+   * all langchain4j providers that implement structured outputs with {@link JsonObjectSchema} (OpenAI, Azure OpenAI,
+   * Google AI, Gemini, Mistral, Ollama, Amazon Bedrock, Watsonx).
+   */
+  static ResponseFormat buildResponseFormat(SchemaField schemaField) {
+    JsonSchemaElement valueElement = toJsonSchemaElement(schemaField.getType());
+    JsonSchemaElement valueSchema =
+        schemaField.multiValued()
+            ? JsonArraySchema.builder().items(valueElement).build()
+            : valueElement;
+    return ResponseFormat.builder()
+        .type(ResponseFormatType.JSON)
+        .jsonSchema(
+            JsonSchema.builder()
+                .name("output")
+                .rootElement(
+                    JsonObjectSchema.builder()
+                        .addProperty("value", valueSchema)
+                        .required("value")
+                        .build())
+                .build())
+        .build();
+  }
+
+  private static JsonSchemaElement toJsonSchemaElement(FieldType fieldType) {
+    // DenseVectorField extends FloatPointField, so it must be rejected before the numeric checks
+    if (fieldType instanceof DenseVectorField
+        || fieldType instanceof UUIDField
+        || fieldType instanceof NestPathField) {
+      throw new SolrException(
+          SolrException.ErrorCode.SERVER_ERROR,
+          "field type is not supported by Document Enrichment: "
+              + fieldType.getClass().getSimpleName());
+    }
+    if (fieldType instanceof StrField
+        || fieldType instanceof TextField
+        || fieldType instanceof DatePointField) {
+      return new JsonStringSchema();
+    } else if (fieldType instanceof IntPointField || fieldType instanceof LongPointField) {
+      return new JsonIntegerSchema();
+    } else if (fieldType instanceof FloatPointField || fieldType instanceof DoublePointField) {
+      return new JsonNumberSchema();
+    } else if (fieldType instanceof BoolField) {
+      return new JsonBooleanSchema();
+    } else {
+      throw new SolrException(
+          SolrException.ErrorCode.SERVER_ERROR,
+          "field type is not supported by Document Enrichment: "
+              + fieldType.getClass().getSimpleName());
+    }
+  }
+
+  private static void validatePromptPlaceholders(String prompt, List<String> fieldNames) {
+    Set<String> promptPlaceholders = new HashSet<>();
+    Matcher m = PLACEHOLDER_PATTERN.matcher(prompt);
+    while (m.find()) {
+      promptPlaceholders.add(m.group(1));
+    }
+
+    Set<String> fieldsWithoutPlaceholder = new HashSet<>(fieldNames);

Review Comment:
   maybe "fieldsWithoutPlaceholderInPrompt"? is a bit longer but it explains better the content



##########
solr/modules/language-models/src/java/org/apache/solr/languagemodels/documentenrichment/update/processor/DocumentEnrichmentUpdateProcessorFactory.java:
##########
@@ -0,0 +1,330 @@
+/*
+ * 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.solr.languagemodels.documentenrichment.update.processor;
+
+import dev.langchain4j.model.chat.request.ResponseFormat;
+import dev.langchain4j.model.chat.request.ResponseFormatType;
+import dev.langchain4j.model.chat.request.json.JsonArraySchema;
+import dev.langchain4j.model.chat.request.json.JsonBooleanSchema;
+import dev.langchain4j.model.chat.request.json.JsonIntegerSchema;
+import dev.langchain4j.model.chat.request.json.JsonNumberSchema;
+import dev.langchain4j.model.chat.request.json.JsonObjectSchema;
+import dev.langchain4j.model.chat.request.json.JsonSchema;
+import dev.langchain4j.model.chat.request.json.JsonSchemaElement;
+import dev.langchain4j.model.chat.request.json.JsonStringSchema;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import java.util.Collection;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import org.apache.solr.common.SolrException;
+import org.apache.solr.common.params.RequiredSolrParams;
+import org.apache.solr.common.params.SolrParams;
+import org.apache.solr.common.util.NamedList;
+import org.apache.solr.core.SolrCore;
+import org.apache.solr.core.SolrResourceLoader;
+import org.apache.solr.languagemodels.documentenrichment.model.SolrChatModel;
+import org.apache.solr.languagemodels.documentenrichment.store.rest.ManagedChatModelStore;
+import org.apache.solr.request.SolrQueryRequest;
+import org.apache.solr.response.SolrQueryResponse;
+import org.apache.solr.rest.ManagedResource;
+import org.apache.solr.rest.ManagedResourceObserver;
+import org.apache.solr.schema.BoolField;
+import org.apache.solr.schema.DatePointField;
+import org.apache.solr.schema.DenseVectorField;
+import org.apache.solr.schema.DoublePointField;
+import org.apache.solr.schema.FieldType;
+import org.apache.solr.schema.FloatPointField;
+import org.apache.solr.schema.IndexSchema;
+import org.apache.solr.schema.IntPointField;
+import org.apache.solr.schema.LongPointField;
+import org.apache.solr.schema.NestPathField;
+import org.apache.solr.schema.SchemaField;
+import org.apache.solr.schema.StrField;
+import org.apache.solr.schema.TextField;
+import org.apache.solr.schema.UUIDField;
+import org.apache.solr.update.processor.UpdateRequestProcessor;
+import org.apache.solr.update.processor.UpdateRequestProcessorFactory;
+import org.apache.solr.util.plugin.SolrCoreAware;
+
+/**
+ * Generate the content of a field based on other fields specified as input.
+ *
+ * <p>One or more {@code inputField} parameters specify the Solr fields to use as input. Each field
+ * name must appear as a {@code {fieldName}} placeholder in the prompt. Exactly one of {@code
+ * prompt} or {@code promptFile} must be provided.
+ *
+ * <pre class="prettyprint" >
+ * &lt;processor class=&quot;solr.llm.documentenrichment.update.processor.DocumentEnrichmentUpdateProcessorFactory&quot;&gt;
+ *   &lt;str name=&quot;inputField&quot;&gt;title_field&lt;/str&gt;
+ *   &lt;str name=&quot;inputField&quot;&gt;body_field&lt;/str&gt;
+ *   &lt;str name=&quot;outputField&quot;&gt;enriched_field&lt;/str&gt;
+ *   &lt;str name=&quot;prompt&quot;&gt;Title: {title_field}. Body: {body_field}.&lt;/str&gt;
+ *   &lt;str name=&quot;model&quot;&gt;ChatModel&lt;/str&gt;
+ * &lt;/processor&gt;
+ * </pre>
+ *
+ * <p>Multiple {@code inputField} values can also be declared as an array using {@code arr}:
+ *
+ * <pre class="prettyprint" >
+ * &lt;processor class=&quot;solr.llm.documentenrichment.update.processor.DocumentEnrichmentUpdateProcessorFactory&quot;&gt;
+ *   &lt;arr name=&quot;inputField&quot;&gt;
+ *     &lt;str&gt;title_field&lt;/str&gt;
+ *     &lt;str&gt;body_field&lt;/str&gt;
+ *   &lt;/arr&gt;
+ *   &lt;str name=&quot;outputField&quot;&gt;enriched_field&lt;/str&gt;
+ *   &lt;str name=&quot;prompt&quot;&gt;Title: {title_field}. Body: {body_field}.&lt;/str&gt;
+ *   &lt;str name=&quot;model&quot;&gt;ChatModel&lt;/str&gt;
+ * &lt;/processor&gt;
+ * </pre>
+ *
+ * <p>Alternatively, the prompt can be loaded from a text file using {@code promptFile}:
+ *
+ * <pre class="prettyprint" >
+ * &lt;processor class=&quot;solr.llm.documentenrichment.update.processor.DocumentEnrichmentUpdateProcessorFactory&quot;&gt;
+ *   &lt;str name=&quot;inputField&quot;&gt;title_field&lt;/str&gt;
+ *   &lt;str name=&quot;outputField&quot;&gt;enriched_field&lt;/str&gt;
+ *   &lt;str name=&quot;promptFile&quot;&gt;prompt.txt&lt;/str&gt;
+ *   &lt;str name=&quot;model&quot;&gt;ChatModel&lt;/str&gt;
+ * &lt;/processor&gt;
+ * </pre>
+ *
+ * <p>Validation rules:
+ *
+ * <ul>
+ *   <li>At least one {@code inputField} must be declared.
+ *   <li>Exactly one of {@code prompt} or {@code promptFile} must be provided.
+ *   <li>Every declared {@code inputField} must have a corresponding {@code {fieldName}} placeholder
+ *       in the prompt.
+ *   <li>Every {@code {placeholder}} in the prompt must correspond to a declared {@code inputField}.
+ * </ul>
+ */
+public class DocumentEnrichmentUpdateProcessorFactory extends UpdateRequestProcessorFactory
+    implements SolrCoreAware, ManagedResourceObserver {
+  private static final String INPUT_FIELD_PARAM = "inputField";
+  private static final String OUTPUT_FIELD_PARAM = "outputField";
+  private static final String PROMPT = "prompt";
+  private static final String PROMPT_FILE = "promptFile";
+  private static final String MODEL_NAME = "model";
+  private static final Pattern PLACEHOLDER_PATTERN = Pattern.compile("\\{([^}]+)\\}");
+
+  private List<String> inputFields;
+  private String outputField;
+  private String promptText;
+  private String promptFile;
+  private String modelName;
+
+  @Override
+  public void init(final NamedList<?> args) {
+    // removeConfigArgs handles both multiple <str name="inputField"> and <arr name="inputField">
+    // and must be called before toSolrParams() since it mutates args in place
+    Collection<String> fieldNames = args.removeConfigArgs(INPUT_FIELD_PARAM);
+    if (fieldNames.isEmpty()) {
+      throw new SolrException(
+          SolrException.ErrorCode.SERVER_ERROR,
+          "At least one 'inputField' must be provided");
+    }
+    inputFields = List.copyOf(fieldNames);
+
+    SolrParams params = args.toSolrParams();
+    RequiredSolrParams required = params.required();
+    outputField = required.get(OUTPUT_FIELD_PARAM);
+    modelName = required.get(MODEL_NAME);
+
+    String inlinePrompt = params.get(PROMPT);
+    String promptFilePath = params.get(PROMPT_FILE);
+
+    if (inlinePrompt == null && promptFilePath == null) {
+      throw new SolrException(
+          SolrException.ErrorCode.SERVER_ERROR,
+          "Either 'prompt' or 'promptFile' must be provided");
+    }
+    if (inlinePrompt != null && promptFilePath != null) {
+      throw new SolrException(
+          SolrException.ErrorCode.SERVER_ERROR,
+          "Only one of 'prompt' or 'promptFile' can be provided, not both");
+    }
+    if (inlinePrompt != null) {
+      validatePromptPlaceholders(inlinePrompt, inputFields);
+      this.promptText = inlinePrompt;
+    }
+    this.promptFile = promptFilePath;
+  }
+
+  @Override
+  public void inform(SolrCore core) {
+    final SolrResourceLoader solrResourceLoader = core.getResourceLoader();
+    ManagedChatModelStore.registerManagedChatModelStore(solrResourceLoader, this);
+    if (promptFile != null) {
+      try (InputStream is = solrResourceLoader.openResource(promptFile)) {
+        promptText = new String(is.readAllBytes(), StandardCharsets.UTF_8).trim();
+      } catch (IOException e) {
+        throw new SolrException(
+            SolrException.ErrorCode.SERVER_ERROR,
+            "Cannot read prompt file: " + promptFile,
+            e);
+      }
+      validatePromptPlaceholders(promptText, inputFields);
+    }
+  }
+
+  @Override
+  public void onManagedResourceInitialized(NamedList<?> args, ManagedResource res)
+      throws SolrException {
+    if (res instanceof ManagedChatModelStore store) {
+      store.loadStoredModels();
+    }
+  }
+
+  @Override
+  public UpdateRequestProcessor getInstance(
+      SolrQueryRequest req, SolrQueryResponse rsp, UpdateRequestProcessor next) {
+    IndexSchema latestSchema = req.getCore().getLatestSchema();
+
+    for (String fieldName : inputFields) {
+      if (!latestSchema.isDynamicField(fieldName) && !latestSchema.hasExplicitField(fieldName)) {
+        throw new SolrException(
+            SolrException.ErrorCode.SERVER_ERROR, "undefined field: \"" + fieldName + "\"");
+      }
+    }
+
+    final SchemaField outputFieldSchema = latestSchema.getField(outputField);
+
+    ResponseFormat responseFormat = buildResponseFormat(outputFieldSchema);
+    boolean multiValued = outputFieldSchema.multiValued();
+
+    ManagedChatModelStore store = ManagedChatModelStore.getManagedModelStore(req.getCore());
+    SolrChatModel chatModel = store.getModel(modelName);
+    if (chatModel == null) {
+      throw new SolrException(
+          SolrException.ErrorCode.SERVER_ERROR,
+          "The model configured in the Update Request Processor '"
+              + modelName
+              + "' can't be found in the store: "
+              + ManagedChatModelStore.REST_END_POINT);
+    }
+
+    return new DocumentEnrichmentUpdateProcessor(
+        inputFields, outputField, promptText, chatModel, multiValued, responseFormat, req, next);
+  }
+
+  /**
+   * Builds a {@link ResponseFormat} that instructs the model to return a JSON object {@code
+   * {"value": ...}} whose value type matches the Solr field type. For multivalued fields the value
+   * is wrapped in a {@link JsonArraySchema} nested inside the root {@link JsonObjectSchema}.
+   *
+   * <p>Nesting {@link JsonArraySchema} inside a {@link JsonObjectSchema} property is supported by
+   * all langchain4j providers that implement structured outputs with {@link JsonObjectSchema} (OpenAI, Azure OpenAI,
+   * Google AI, Gemini, Mistral, Ollama, Amazon Bedrock, Watsonx).
+   */
+  static ResponseFormat buildResponseFormat(SchemaField schemaField) {
+    JsonSchemaElement valueElement = toJsonSchemaElement(schemaField.getType());
+    JsonSchemaElement valueSchema =
+        schemaField.multiValued()
+            ? JsonArraySchema.builder().items(valueElement).build()
+            : valueElement;
+    return ResponseFormat.builder()
+        .type(ResponseFormatType.JSON)
+        .jsonSchema(
+            JsonSchema.builder()
+                .name("output")
+                .rootElement(
+                    JsonObjectSchema.builder()
+                        .addProperty("value", valueSchema)
+                        .required("value")
+                        .build())
+                .build())
+        .build();
+  }
+
+  private static JsonSchemaElement toJsonSchemaElement(FieldType fieldType) {
+    // DenseVectorField extends FloatPointField, so it must be rejected before the numeric checks
+    if (fieldType instanceof DenseVectorField
+        || fieldType instanceof UUIDField
+        || fieldType instanceof NestPathField) {
+      throw new SolrException(
+          SolrException.ErrorCode.SERVER_ERROR,
+          "field type is not supported by Document Enrichment: "
+              + fieldType.getClass().getSimpleName());
+    }
+    if (fieldType instanceof StrField
+        || fieldType instanceof TextField
+        || fieldType instanceof DatePointField) {
+      return new JsonStringSchema();
+    } else if (fieldType instanceof IntPointField || fieldType instanceof LongPointField) {
+      return new JsonIntegerSchema();
+    } else if (fieldType instanceof FloatPointField || fieldType instanceof DoublePointField) {
+      return new JsonNumberSchema();
+    } else if (fieldType instanceof BoolField) {
+      return new JsonBooleanSchema();
+    } else {
+      throw new SolrException(
+          SolrException.ErrorCode.SERVER_ERROR,
+          "field type is not supported by Document Enrichment: "
+              + fieldType.getClass().getSimpleName());
+    }
+  }
+
+  private static void validatePromptPlaceholders(String prompt, List<String> fieldNames) {
+    Set<String> promptPlaceholders = new HashSet<>();
+    Matcher m = PLACEHOLDER_PATTERN.matcher(prompt);
+    while (m.find()) {
+      promptPlaceholders.add(m.group(1));
+    }
+
+    Set<String> fieldsWithoutPlaceholder = new HashSet<>(fieldNames);
+    fieldsWithoutPlaceholder.removeAll(promptPlaceholders);
+    if (!fieldsWithoutPlaceholder.isEmpty()) {
+      throw new SolrException(
+          SolrException.ErrorCode.SERVER_ERROR,
+          "prompt is missing placeholders for inputField(s): " + fieldsWithoutPlaceholder);
+    }
+
+    Set<String> placeholdersWithoutField = new HashSet<>(promptPlaceholders);

Review Comment:
   placeholdersInPromptWithoutField?



##########
solr/modules/language-models/src/java/org/apache/solr/languagemodels/documentenrichment/store/rest/ManagedChatModelStore.java:
##########
@@ -0,0 +1,200 @@
+/*
+ * 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.solr.languagemodels.documentenrichment.store.rest;
+
+import java.lang.invoke.MethodHandles;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+import net.jcip.annotations.ThreadSafe;
+import org.apache.solr.common.SolrException;
+import org.apache.solr.common.util.NamedList;
+import org.apache.solr.core.SolrCore;
+import org.apache.solr.core.SolrResourceLoader;
+import org.apache.solr.languagemodels.documentenrichment.model.SolrChatModel;
+import org.apache.solr.languagemodels.documentenrichment.store.ChatModelException;
+import org.apache.solr.languagemodels.documentenrichment.store.ChatModelStore;
+import org.apache.solr.response.SolrQueryResponse;
+import org.apache.solr.rest.BaseSolrResource;
+import org.apache.solr.rest.ManagedResource;
+import org.apache.solr.rest.ManagedResourceObserver;
+import org.apache.solr.rest.ManagedResourceStorage;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/** Managed Resource wrapper for the {@link ChatModelStore} to expose it via REST */
+@ThreadSafe
+public class ManagedChatModelStore extends ManagedResource
+    implements ManagedResource.ChildResourceSupport {
+  private static final Logger log = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());
+
+  /** the model store rest endpoint */
+  public static final String REST_END_POINT = "/schema/chat-model-store";
+
+  /** Managed model store: the name of the attribute containing all the models of a model store */
+  private static final String MODELS_JSON_FIELD = "models";
+
+  /** name of the attribute containing a class */
+  static final String CLASS_KEY = "class";
+
+  /** name of the attribute containing a name */
+  static final String NAME_KEY = "name";
+
+  /** name of the attribute containing parameters */
+  static final String PARAMS_KEY = "params";
+
+  public static void registerManagedChatModelStore(
+      SolrResourceLoader solrResourceLoader, ManagedResourceObserver managedResourceObserver) {
+    solrResourceLoader
+        .getManagedResourceRegistry()
+        .registerManagedResource(
+            REST_END_POINT, ManagedChatModelStore.class, managedResourceObserver);
+  }
+
+  public static ManagedChatModelStore getManagedModelStore(SolrCore core) {
+    return (ManagedChatModelStore) core.getRestManager().getManagedResource(REST_END_POINT);
+  }
+
+  /**
+   * Returns the available models as a list of Maps objects. After an update the managed resources
+   * needs to return the resources in this format in order to store in json somewhere (zookeeper,
+   * disk...)
+   *
+   * @return the available models as a list of Maps objects
+   */
+  private static List<Object> modelsAsManagedResources(List<SolrChatModel> models) {
+    return models.stream()
+        .map(ManagedChatModelStore::toModelMap)
+        .collect(Collectors.toList());
+  }
+
+  @SuppressWarnings("unchecked")
+  public static SolrChatModel fromModelMap(
+      SolrResourceLoader solrResourceLoader, Map<String, Object> chatModel) {
+    return SolrChatModel.getInstance(
+        solrResourceLoader,
+        (String) chatModel.get(CLASS_KEY), // modelClassName
+        (String) chatModel.get(NAME_KEY), // modelName
+        (Map<String, Object>) chatModel.get(PARAMS_KEY));
+  }
+
+  private static LinkedHashMap<String, Object> toModelMap(SolrChatModel model) {
+    final LinkedHashMap<String, Object> modelMap = new LinkedHashMap<>(3, 1.0f);
+    modelMap.put(NAME_KEY, model.getName());
+    modelMap.put(CLASS_KEY, model.getChatModelClassName());
+    modelMap.put(PARAMS_KEY, model.getParams());
+    return modelMap;
+  }
+
+  private final ChatModelStore store;
+  private Object managedData;
+
+  public ManagedChatModelStore(
+      String resourceId, SolrResourceLoader loader, ManagedResourceStorage.StorageIO storageIO)
+      throws SolrException {
+    super(resourceId, loader, storageIO);
+    store = new ChatModelStore();
+  }
+
+  @Override
+  protected ManagedResourceStorage createStorage(

Review Comment:
   What about removing it at all? It is already implemented in this way in the ManagedResource class



##########
solr/modules/language-models/src/test/org/apache/solr/languagemodels/documentenrichment/update/processor/DocumentEnrichmentUpdateProcessorTest.java:
##########
@@ -0,0 +1,681 @@
+/*
+ * 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.solr.languagemodels.documentenrichment.update.processor;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import org.apache.solr.client.solrj.RemoteSolrException;
+import org.apache.solr.client.solrj.SolrServerException;
+import org.apache.solr.client.solrj.request.SolrQuery;
+import org.apache.solr.client.solrj.request.UpdateRequest;
+import org.apache.solr.common.SolrInputDocument;
+import org.apache.solr.common.util.Utils;
+import org.apache.solr.languagemodels.TestLanguageModelBase;
+import org.apache.solr.languagemodels.documentenrichment.model.DummyChatModel;
+import org.apache.solr.languagemodels.documentenrichment.store.rest.ManagedChatModelStore;
+import org.junit.After;
+import org.junit.AfterClass;
+import org.junit.BeforeClass;
+import org.junit.Test;
+
+public class DocumentEnrichmentUpdateProcessorTest extends TestLanguageModelBase {
+
+  @BeforeClass
+  public static void init() throws Exception {
+    setupTest("solrconfig-document-enrichment.xml", "schema-language-models.xml", false, false);
+  }
+
+  @AfterClass
+  public static void cleanup() throws Exception {
+    afterTest();
+  }
+
+  private String loadedModelId;
+
+  @After
+  public void afterEachTest() throws Exception {
+    if (loadedModelId != null) {
+      restTestHarness.delete(ManagedChatModelStore.REST_END_POINT + "/" + loadedModelId);
+      loadedModelId = null;
+    }
+  }
+
+  private void loadTestChatModel(String fileName, String modelId) throws Exception {
+    loadChatModel(fileName);
+    loadedModelId = modelId;
+  }
+
+  private void loadDummyChatModel(String modelId, String response) throws Exception {
+    Map<String, Object> model = new LinkedHashMap<>();
+    model.put("class", "org.apache.solr.languagemodels.documentenrichment.model.DummyChatModel");
+    model.put("name", modelId);
+    model.put("params", Map.of("response", response));
+    assertJPut(ManagedChatModelStore.REST_END_POINT, Utils.toJSONString(model), "/responseHeader/status==0");
+    loadedModelId = modelId;
+  }
+
+  @Test
+  public void processAdd_inputField_shouldEnrichInputField() throws Exception {
+    loadTestChatModel("dummy-chat-model.json", "dummy-chat-1");
+
+    addWithChain(sdoc("id", "99", "string_field", "Vegeta is the saiyan prince."), "documentEnrichment");
+    addWithChain(sdoc("id", "98", "string_field", "Kakaroth is a saiyan grown up on planet Earth."), "documentEnrichment");
+    assertU(commit());
+
+    final SolrQuery query = getEnrichmentQuery("enriched_field");
+
+    assertJQ(
+        "/query" + query.toQueryString(),
+        "/response/numFound==2]",
+        "/response/docs/[0]/id=='99'",
+        "/response/docs/[0]/enriched_field=='enriched content'",
+        "/response/docs/[1]/id=='98'",
+        "/response/docs/[1]/enriched_field=='enriched content'");
+  }
+
+  /*
+   This test looks for the 'dummy-chat-1' model, but such model is not loaded —
+   the model store is empty, so the update fails.
+  */
+  @Test
+  public void processAdd_modelNotFound_shouldThrowException() {
+    RuntimeException thrown =
+        assertThrows(
+            "model not found should throw an exception",
+            RemoteSolrException.class,
+            () ->
+                addWithChain(
+                    sdoc("id", "99", "string_field", "Vegeta is the saiyan prince."),
+                    "documentEnrichment"));
+    assertTrue(
+        thrown
+            .getMessage()
+            .contains(
+                "The model configured in the Update Request Processor 'dummy-chat-1' can't be found in the store: /schema/chat-model-store"));
+  }
+
+  @Test
+  public void processAdd_emptyInputField_shouldLogAndIndexWithNoEnrichedField() throws Exception {
+    loadTestChatModel("dummy-chat-model.json", "dummy-chat-1");
+    addWithChain(sdoc("id", "99", "string_field", ""), "documentEnrichment");
+    addWithChain(sdoc("id", "98", "string_field", "Vegeta is the saiyan prince."), "documentEnrichment");
+    assertU(commit());
+
+    final SolrQuery query = getEnrichmentQuery("enriched_field");
+
+    assertJQ(
+        "/query" + query.toQueryString(),
+        "/response/numFound==2]",
+        "/response/docs/[0]/id=='99'",
+        "!/response/docs/[0]/enriched_field==", // no enriched field for doc 99
+        "/response/docs/[1]/id=='98'",
+        "/response/docs/[1]/enriched_field=='enriched content'");
+  }
+
+  @Test
+  public void processAdd_nullInputField_shouldLogAndIndexWithNoEnrichedField() throws Exception {
+    loadTestChatModel("dummy-chat-model.json", "dummy-chat-1");
+    addWithChain(sdoc("id", "99", "string_field", "Vegeta is the saiyan prince."), "documentEnrichment");
+    assertU(adoc("id", "98")); // no string_field
+    assertU(commit());
+
+    final SolrQuery query = getEnrichmentQuery("enriched_field");
+
+    assertJQ(
+        "/query" + query.toQueryString(),
+        "/response/numFound==2]",
+        "/response/docs/[0]/id=='99'",
+        "/response/docs/[0]/enriched_field=='enriched content'",
+        "/response/docs/[1]/id=='98'",
+        "!/response/docs/[1]/enriched_field=="); // no enriched field for doc 98
+  }
+
+  @Test
+  public void processAdd_failingEnrichment_shouldLogAndIndexWithNoEnrichedField() throws Exception {
+    loadTestChatModel("exception-throwing-chat-model.json", "exception-throwing-chat-model");
+    addWithChain(sdoc("id", "99", "string_field", "Vegeta is the saiyan prince."), "failingDocumentEnrichment");
+    addWithChain(sdoc("id", "98", "string_field", "Kakaroth is a saiyan grown up on planet Earth."), "failingDocumentEnrichment");
+    assertU(commit());
+
+    final SolrQuery query = getEnrichmentQuery("enriched_field");
+
+    assertJQ(
+        "/query" + query.toQueryString(),
+        "/response/numFound==2]",
+        "/response/docs/[0]/id=='99'",
+        "!/response/docs/[0]/enriched_field==", // no enriched field for doc 99
+        "/response/docs/[1]/id=='98'",
+        "!/response/docs/[1]/enriched_field=="); // no enriched field for doc 98
+  }
+
+  @Test
+  public void processAtomicUpdate_shouldTriggerEnrichmentAndFetchTheStoredContent()
+      throws Exception {
+    // Verifies that when using a processor chain configured for partial updates
+    // (i.e., DistributedUpdateProcessorFactory before DocumentEnrichmentUpdateProcessorFactory),
+    // the system correctly retrieves the stored value of string_field and generates the
+    // enriched content for the document.
+    loadTestChatModel("dummy-chat-model.json", "dummy-chat-1");
+    assertU(adoc("id", "99", "string_field", "Vegeta is the saiyan prince."));
+    assertU(adoc("id", "98", "string_field", "Kakaroth is a saiyan grown up on planet Earth."));
+    assertU(commit());
+
+    SolrInputDocument atomicDoc = new SolrInputDocument();
+    atomicDoc.setField("id", "99");
+    atomicDoc.setField("enriched", Map.of("set", true));

Review Comment:
   Couldn't string_field be used for this purpose? as the test below



##########
solr/modules/language-models/src/test-files/solr/collection1/conf/schema-language-models.xml:
##########
@@ -36,11 +40,31 @@
   <field name="2048_byte_vector" type="high_dimensional_byte_knn_vector" indexed="true" stored="true" />
   <field name="2048_float_vector" type="high_dimensional_float_knn_vector" indexed="true" stored="true" />
   <field name="string_field" type="string" indexed="true" stored="true" multiValued="false" required="false"/>
+  <field name="body_field" type="string" indexed="true" stored="true" multiValued="false" required="false"/>
+  <field name="enriched_field" type="string" indexed="true" stored="true" multiValued="false" required="false"/>
+  <field name="enriched_field_multi" type="string" indexed="true" stored="true" multiValued="true" required="false"/>
+
+  <!-- single-valued output fields for each supported type -->
+  <field name="output_long"    type="plong"   indexed="true" stored="true" multiValued="false"/>
+  <field name="output_int"     type="pint"    indexed="true" stored="true" multiValued="false"/>
+  <field name="output_float"   type="pfloat"  indexed="true" stored="true" multiValued="false"/>
+  <field name="output_double"  type="pdouble" indexed="true" stored="true" multiValued="false"/>
+  <field name="output_boolean" type="boolean" indexed="true" stored="true" multiValued="false"/>
+  <field name="output_date"    type="pdate"   indexed="true" stored="true" multiValued="false"/>
+
+  <!-- multivalued output fields for each supported type -->
+  <field name="output_long_multi"    type="plong"   indexed="true" stored="true" multiValued="true"/>
+  <field name="output_int_multi"     type="pint"    indexed="true" stored="true" multiValued="true"/>
+  <field name="output_float_multi"   type="pfloat"  indexed="true" stored="true" multiValued="true"/>
+  <field name="output_double_multi"  type="pdouble" indexed="true" stored="true" multiValued="true"/>
+  <field name="output_boolean_multi" type="boolean" indexed="true" stored="true" multiValued="true"/>
+  <field name="output_date_multi"    type="pdate"   indexed="true" stored="true" multiValued="true"/>
 
   <field name="_version_" type="plong" indexed="true" stored="true" multiValued="false" />
   <field name="_text_" type="text_general" indexed="true" stored="false" multiValued="true"/>
   <copyField source="*" dest="_text_"/>
   <field name="vectorised" type="boolean" indexed="true" stored="false" docValues="true" default="false"/>
+  <field name="enriched" type="boolean" indexed="true" stored="false" docValues="true" default="false"/>

Review Comment:
   Couldn't string_field be used for this purpose? as the test below



##########
solr/modules/language-models/src/java/org/apache/solr/languagemodels/documentenrichment/update/processor/DocumentEnrichmentUpdateProcessorFactory.java:
##########
@@ -0,0 +1,330 @@
+/*
+ * 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.solr.languagemodels.documentenrichment.update.processor;
+
+import dev.langchain4j.model.chat.request.ResponseFormat;
+import dev.langchain4j.model.chat.request.ResponseFormatType;
+import dev.langchain4j.model.chat.request.json.JsonArraySchema;
+import dev.langchain4j.model.chat.request.json.JsonBooleanSchema;
+import dev.langchain4j.model.chat.request.json.JsonIntegerSchema;
+import dev.langchain4j.model.chat.request.json.JsonNumberSchema;
+import dev.langchain4j.model.chat.request.json.JsonObjectSchema;
+import dev.langchain4j.model.chat.request.json.JsonSchema;
+import dev.langchain4j.model.chat.request.json.JsonSchemaElement;
+import dev.langchain4j.model.chat.request.json.JsonStringSchema;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import java.util.Collection;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import org.apache.solr.common.SolrException;
+import org.apache.solr.common.params.RequiredSolrParams;
+import org.apache.solr.common.params.SolrParams;
+import org.apache.solr.common.util.NamedList;
+import org.apache.solr.core.SolrCore;
+import org.apache.solr.core.SolrResourceLoader;
+import org.apache.solr.languagemodels.documentenrichment.model.SolrChatModel;
+import org.apache.solr.languagemodels.documentenrichment.store.rest.ManagedChatModelStore;
+import org.apache.solr.request.SolrQueryRequest;
+import org.apache.solr.response.SolrQueryResponse;
+import org.apache.solr.rest.ManagedResource;
+import org.apache.solr.rest.ManagedResourceObserver;
+import org.apache.solr.schema.BoolField;
+import org.apache.solr.schema.DatePointField;
+import org.apache.solr.schema.DenseVectorField;
+import org.apache.solr.schema.DoublePointField;
+import org.apache.solr.schema.FieldType;
+import org.apache.solr.schema.FloatPointField;
+import org.apache.solr.schema.IndexSchema;
+import org.apache.solr.schema.IntPointField;
+import org.apache.solr.schema.LongPointField;
+import org.apache.solr.schema.NestPathField;
+import org.apache.solr.schema.SchemaField;
+import org.apache.solr.schema.StrField;
+import org.apache.solr.schema.TextField;
+import org.apache.solr.schema.UUIDField;
+import org.apache.solr.update.processor.UpdateRequestProcessor;
+import org.apache.solr.update.processor.UpdateRequestProcessorFactory;
+import org.apache.solr.util.plugin.SolrCoreAware;
+
+/**
+ * Generate the content of a field based on other fields specified as input.
+ *
+ * <p>One or more {@code inputField} parameters specify the Solr fields to use as input. Each field
+ * name must appear as a {@code {fieldName}} placeholder in the prompt. Exactly one of {@code
+ * prompt} or {@code promptFile} must be provided.
+ *
+ * <pre class="prettyprint" >
+ * &lt;processor class=&quot;solr.llm.documentenrichment.update.processor.DocumentEnrichmentUpdateProcessorFactory&quot;&gt;
+ *   &lt;str name=&quot;inputField&quot;&gt;title_field&lt;/str&gt;
+ *   &lt;str name=&quot;inputField&quot;&gt;body_field&lt;/str&gt;
+ *   &lt;str name=&quot;outputField&quot;&gt;enriched_field&lt;/str&gt;
+ *   &lt;str name=&quot;prompt&quot;&gt;Title: {title_field}. Body: {body_field}.&lt;/str&gt;
+ *   &lt;str name=&quot;model&quot;&gt;ChatModel&lt;/str&gt;
+ * &lt;/processor&gt;
+ * </pre>
+ *
+ * <p>Multiple {@code inputField} values can also be declared as an array using {@code arr}:
+ *
+ * <pre class="prettyprint" >
+ * &lt;processor class=&quot;solr.llm.documentenrichment.update.processor.DocumentEnrichmentUpdateProcessorFactory&quot;&gt;
+ *   &lt;arr name=&quot;inputField&quot;&gt;
+ *     &lt;str&gt;title_field&lt;/str&gt;
+ *     &lt;str&gt;body_field&lt;/str&gt;
+ *   &lt;/arr&gt;
+ *   &lt;str name=&quot;outputField&quot;&gt;enriched_field&lt;/str&gt;
+ *   &lt;str name=&quot;prompt&quot;&gt;Title: {title_field}. Body: {body_field}.&lt;/str&gt;
+ *   &lt;str name=&quot;model&quot;&gt;ChatModel&lt;/str&gt;
+ * &lt;/processor&gt;
+ * </pre>
+ *
+ * <p>Alternatively, the prompt can be loaded from a text file using {@code promptFile}:
+ *
+ * <pre class="prettyprint" >
+ * &lt;processor class=&quot;solr.llm.documentenrichment.update.processor.DocumentEnrichmentUpdateProcessorFactory&quot;&gt;
+ *   &lt;str name=&quot;inputField&quot;&gt;title_field&lt;/str&gt;
+ *   &lt;str name=&quot;outputField&quot;&gt;enriched_field&lt;/str&gt;
+ *   &lt;str name=&quot;promptFile&quot;&gt;prompt.txt&lt;/str&gt;
+ *   &lt;str name=&quot;model&quot;&gt;ChatModel&lt;/str&gt;
+ * &lt;/processor&gt;
+ * </pre>
+ *
+ * <p>Validation rules:
+ *
+ * <ul>
+ *   <li>At least one {@code inputField} must be declared.
+ *   <li>Exactly one of {@code prompt} or {@code promptFile} must be provided.
+ *   <li>Every declared {@code inputField} must have a corresponding {@code {fieldName}} placeholder
+ *       in the prompt.
+ *   <li>Every {@code {placeholder}} in the prompt must correspond to a declared {@code inputField}.
+ * </ul>
+ */
+public class DocumentEnrichmentUpdateProcessorFactory extends UpdateRequestProcessorFactory
+    implements SolrCoreAware, ManagedResourceObserver {
+  private static final String INPUT_FIELD_PARAM = "inputField";
+  private static final String OUTPUT_FIELD_PARAM = "outputField";
+  private static final String PROMPT = "prompt";
+  private static final String PROMPT_FILE = "promptFile";
+  private static final String MODEL_NAME = "model";
+  private static final Pattern PLACEHOLDER_PATTERN = Pattern.compile("\\{([^}]+)\\}");
+
+  private List<String> inputFields;
+  private String outputField;
+  private String promptText;
+  private String promptFile;
+  private String modelName;
+
+  @Override
+  public void init(final NamedList<?> args) {
+    // removeConfigArgs handles both multiple <str name="inputField"> and <arr name="inputField">
+    // and must be called before toSolrParams() since it mutates args in place
+    Collection<String> fieldNames = args.removeConfigArgs(INPUT_FIELD_PARAM);
+    if (fieldNames.isEmpty()) {
+      throw new SolrException(
+          SolrException.ErrorCode.SERVER_ERROR,
+          "At least one 'inputField' must be provided");
+    }
+    inputFields = List.copyOf(fieldNames);
+
+    SolrParams params = args.toSolrParams();
+    RequiredSolrParams required = params.required();
+    outputField = required.get(OUTPUT_FIELD_PARAM);
+    modelName = required.get(MODEL_NAME);
+
+    String inlinePrompt = params.get(PROMPT);
+    String promptFilePath = params.get(PROMPT_FILE);
+
+    if (inlinePrompt == null && promptFilePath == null) {
+      throw new SolrException(
+          SolrException.ErrorCode.SERVER_ERROR,
+          "Either 'prompt' or 'promptFile' must be provided");
+    }
+    if (inlinePrompt != null && promptFilePath != null) {
+      throw new SolrException(
+          SolrException.ErrorCode.SERVER_ERROR,
+          "Only one of 'prompt' or 'promptFile' can be provided, not both");
+    }
+    if (inlinePrompt != null) {
+      validatePromptPlaceholders(inlinePrompt, inputFields);
+      this.promptText = inlinePrompt;
+    }
+    this.promptFile = promptFilePath;
+  }
+
+  @Override
+  public void inform(SolrCore core) {
+    final SolrResourceLoader solrResourceLoader = core.getResourceLoader();
+    ManagedChatModelStore.registerManagedChatModelStore(solrResourceLoader, this);
+    if (promptFile != null) {
+      try (InputStream is = solrResourceLoader.openResource(promptFile)) {
+        promptText = new String(is.readAllBytes(), StandardCharsets.UTF_8).trim();
+      } catch (IOException e) {
+        throw new SolrException(
+            SolrException.ErrorCode.SERVER_ERROR,
+            "Cannot read prompt file: " + promptFile,
+            e);
+      }
+      validatePromptPlaceholders(promptText, inputFields);
+    }
+  }
+
+  @Override
+  public void onManagedResourceInitialized(NamedList<?> args, ManagedResource res)
+      throws SolrException {
+    if (res instanceof ManagedChatModelStore store) {
+      store.loadStoredModels();
+    }
+  }
+
+  @Override
+  public UpdateRequestProcessor getInstance(
+      SolrQueryRequest req, SolrQueryResponse rsp, UpdateRequestProcessor next) {
+    IndexSchema latestSchema = req.getCore().getLatestSchema();
+
+    for (String fieldName : inputFields) {
+      if (!latestSchema.isDynamicField(fieldName) && !latestSchema.hasExplicitField(fieldName)) {
+        throw new SolrException(
+            SolrException.ErrorCode.SERVER_ERROR, "undefined field: \"" + fieldName + "\"");
+      }
+    }
+
+    final SchemaField outputFieldSchema = latestSchema.getField(outputField);
+
+    ResponseFormat responseFormat = buildResponseFormat(outputFieldSchema);
+    boolean multiValued = outputFieldSchema.multiValued();
+
+    ManagedChatModelStore store = ManagedChatModelStore.getManagedModelStore(req.getCore());
+    SolrChatModel chatModel = store.getModel(modelName);
+    if (chatModel == null) {
+      throw new SolrException(
+          SolrException.ErrorCode.SERVER_ERROR,
+          "The model configured in the Update Request Processor '"
+              + modelName
+              + "' can't be found in the store: "
+              + ManagedChatModelStore.REST_END_POINT);
+    }
+
+    return new DocumentEnrichmentUpdateProcessor(
+        inputFields, outputField, promptText, chatModel, multiValued, responseFormat, req, next);
+  }
+
+  /**
+   * Builds a {@link ResponseFormat} that instructs the model to return a JSON object {@code
+   * {"value": ...}} whose value type matches the Solr field type. For multivalued fields the value
+   * is wrapped in a {@link JsonArraySchema} nested inside the root {@link JsonObjectSchema}.
+   *
+   * <p>Nesting {@link JsonArraySchema} inside a {@link JsonObjectSchema} property is supported by
+   * all langchain4j providers that implement structured outputs with {@link JsonObjectSchema} (OpenAI, Azure OpenAI,
+   * Google AI, Gemini, Mistral, Ollama, Amazon Bedrock, Watsonx).
+   */
+  static ResponseFormat buildResponseFormat(SchemaField schemaField) {
+    JsonSchemaElement valueElement = toJsonSchemaElement(schemaField.getType());
+    JsonSchemaElement valueSchema =
+        schemaField.multiValued()
+            ? JsonArraySchema.builder().items(valueElement).build()
+            : valueElement;
+    return ResponseFormat.builder()
+        .type(ResponseFormatType.JSON)
+        .jsonSchema(
+            JsonSchema.builder()
+                .name("output")
+                .rootElement(
+                    JsonObjectSchema.builder()
+                        .addProperty("value", valueSchema)
+                        .required("value")
+                        .build())
+                .build())
+        .build();
+  }
+
+  private static JsonSchemaElement toJsonSchemaElement(FieldType fieldType) {
+    // DenseVectorField extends FloatPointField, so it must be rejected before the numeric checks
+    if (fieldType instanceof DenseVectorField
+        || fieldType instanceof UUIDField
+        || fieldType instanceof NestPathField) {
+      throw new SolrException(
+          SolrException.ErrorCode.SERVER_ERROR,
+          "field type is not supported by Document Enrichment: "
+              + fieldType.getClass().getSimpleName());
+    }
+    if (fieldType instanceof StrField
+        || fieldType instanceof TextField
+        || fieldType instanceof DatePointField) {
+      return new JsonStringSchema();
+    } else if (fieldType instanceof IntPointField || fieldType instanceof LongPointField) {
+      return new JsonIntegerSchema();
+    } else if (fieldType instanceof FloatPointField || fieldType instanceof DoublePointField) {
+      return new JsonNumberSchema();
+    } else if (fieldType instanceof BoolField) {
+      return new JsonBooleanSchema();
+    } else {
+      throw new SolrException(
+          SolrException.ErrorCode.SERVER_ERROR,
+          "field type is not supported by Document Enrichment: "
+              + fieldType.getClass().getSimpleName());
+    }
+  }
+
+  private static void validatePromptPlaceholders(String prompt, List<String> fieldNames) {
+    Set<String> promptPlaceholders = new HashSet<>();
+    Matcher m = PLACEHOLDER_PATTERN.matcher(prompt);

Review Comment:
   Can we use "matcher" instead of "m"? or "matches" or something similar which is easier to understand?



##########
solr/modules/language-models/src/java/org/apache/solr/languagemodels/documentenrichment/store/rest/ManagedChatModelStore.java:
##########
@@ -0,0 +1,200 @@
+/*
+ * 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.solr.languagemodels.documentenrichment.store.rest;
+
+import java.lang.invoke.MethodHandles;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+import net.jcip.annotations.ThreadSafe;
+import org.apache.solr.common.SolrException;
+import org.apache.solr.common.util.NamedList;
+import org.apache.solr.core.SolrCore;
+import org.apache.solr.core.SolrResourceLoader;
+import org.apache.solr.languagemodels.documentenrichment.model.SolrChatModel;
+import org.apache.solr.languagemodels.documentenrichment.store.ChatModelException;
+import org.apache.solr.languagemodels.documentenrichment.store.ChatModelStore;
+import org.apache.solr.response.SolrQueryResponse;
+import org.apache.solr.rest.BaseSolrResource;
+import org.apache.solr.rest.ManagedResource;
+import org.apache.solr.rest.ManagedResourceObserver;
+import org.apache.solr.rest.ManagedResourceStorage;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/** Managed Resource wrapper for the {@link ChatModelStore} to expose it via REST */
+@ThreadSafe
+public class ManagedChatModelStore extends ManagedResource
+    implements ManagedResource.ChildResourceSupport {
+  private static final Logger log = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());
+
+  /** the model store rest endpoint */
+  public static final String REST_END_POINT = "/schema/chat-model-store";
+
+  /** Managed model store: the name of the attribute containing all the models of a model store */
+  private static final String MODELS_JSON_FIELD = "models";
+
+  /** name of the attribute containing a class */
+  static final String CLASS_KEY = "class";
+
+  /** name of the attribute containing a name */
+  static final String NAME_KEY = "name";
+
+  /** name of the attribute containing parameters */
+  static final String PARAMS_KEY = "params";
+
+  public static void registerManagedChatModelStore(
+      SolrResourceLoader solrResourceLoader, ManagedResourceObserver managedResourceObserver) {
+    solrResourceLoader
+        .getManagedResourceRegistry()
+        .registerManagedResource(
+            REST_END_POINT, ManagedChatModelStore.class, managedResourceObserver);
+  }
+
+  public static ManagedChatModelStore getManagedModelStore(SolrCore core) {
+    return (ManagedChatModelStore) core.getRestManager().getManagedResource(REST_END_POINT);
+  }
+
+  /**
+   * Returns the available models as a list of Maps objects. After an update the managed resources
+   * needs to return the resources in this format in order to store in json somewhere (zookeeper,
+   * disk...)
+   *
+   * @return the available models as a list of Maps objects
+   */
+  private static List<Object> modelsAsManagedResources(List<SolrChatModel> models) {
+    return models.stream()
+        .map(ManagedChatModelStore::toModelMap)
+        .collect(Collectors.toList());
+  }
+
+  @SuppressWarnings("unchecked")
+  public static SolrChatModel fromModelMap(
+      SolrResourceLoader solrResourceLoader, Map<String, Object> chatModel) {
+    return SolrChatModel.getInstance(
+        solrResourceLoader,
+        (String) chatModel.get(CLASS_KEY), // modelClassName
+        (String) chatModel.get(NAME_KEY), // modelName
+        (Map<String, Object>) chatModel.get(PARAMS_KEY));
+  }
+
+  private static LinkedHashMap<String, Object> toModelMap(SolrChatModel model) {
+    final LinkedHashMap<String, Object> modelMap = new LinkedHashMap<>(5, 1.0f);
+    modelMap.put(NAME_KEY, model.getName());
+    modelMap.put(CLASS_KEY, model.getChatModelClassName());
+    modelMap.put(PARAMS_KEY, model.getParams());
+    return modelMap;
+  }
+
+  private final ChatModelStore store;
+  private Object managedData;
+
+  public ManagedChatModelStore(
+      String resourceId, SolrResourceLoader loader, ManagedResourceStorage.StorageIO storageIO)
+      throws SolrException {
+    super(resourceId, loader, storageIO);
+    store = new ChatModelStore();
+  }
+
+  @Override
+  protected ManagedResourceStorage createStorage(
+      ManagedResourceStorage.StorageIO storageIO, SolrResourceLoader loader) throws SolrException {
+    return new ManagedResourceStorage.JsonStorage(storageIO, loader, -1);

Review Comment:
   I mean, what about removing it at all? It is already implemented in this way in the ManagedResource class



##########
solr/modules/language-models/src/test/org/apache/solr/languagemodels/documentenrichment/update/processor/DocumentEnrichmentUpdateProcessorFactoryTest.java:
##########
@@ -0,0 +1,422 @@
+/*
+ * 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.solr.languagemodels.documentenrichment.update.processor;
+
+import java.util.List;
+import org.apache.solr.common.SolrException;
+import org.apache.solr.common.params.ModifiableSolrParams;
+import org.apache.solr.common.util.NamedList;
+import org.apache.solr.core.SolrCore;
+import org.apache.solr.languagemodels.TestLanguageModelBase;
+import org.apache.solr.languagemodels.documentenrichment.model.SolrChatModel;
+import org.apache.solr.languagemodels.documentenrichment.store.rest.ManagedChatModelStore;
+import org.apache.solr.request.SolrQueryRequestBase;
+import org.apache.solr.update.processor.UpdateRequestProcessor;
+import org.junit.After;
+import org.junit.AfterClass;
+import org.junit.Before;
+import org.junit.BeforeClass;
+import org.junit.Test;
+
+public class DocumentEnrichmentUpdateProcessorFactoryTest extends TestLanguageModelBase {
+
+  @BeforeClass
+  public static void init() throws Exception {
+    setupTest("solrconfig-document-enrichment.xml", "schema-language-models.xml", false, false);
+  }
+
+  @AfterClass
+  public static void cleanup() throws Exception {
+    afterTest();
+  }
+
+  SolrCore collection1;
+
+  @Before
+  public void setup() {
+    collection1 = solrTestRule.getCoreContainer().getCore("collection1");
+  }
+
+  @After
+  public void after() {
+    collection1.close();
+  }
+
+  @Test
+  public void init_fullArgs_shouldInitAllParams() {
+    NamedList<String> args = new NamedList<>();
+    args.add("inputField", "string_field");
+    args.add("outputField", "enriched_field");
+    args.add("prompt", "Summarize: {string_field}");
+    args.add("model", "model1");
+
+    DocumentEnrichmentUpdateProcessorFactory factory = new DocumentEnrichmentUpdateProcessorFactory();
+    factory.init(args);
+
+    assertEquals(List.of("string_field"), factory.getInputFields());
+    assertEquals("enriched_field", factory.getOutputField());
+    assertEquals("Summarize: {string_field}", factory.getPrompt());
+    assertEquals("model1", factory.getModelName());
+  }
+
+  @Test
+  public void init_multipleInputFields_shouldInitAllFields() {
+    NamedList<String> args = new NamedList<>();
+    args.add("inputField", "string_field");
+    args.add("inputField", "body_field");
+    args.add("outputField", "enriched_field");
+    args.add("prompt", "Title: {string_field}. Body: {body_field}.");
+    args.add("model", "model1");
+
+    DocumentEnrichmentUpdateProcessorFactory factory = new DocumentEnrichmentUpdateProcessorFactory();
+    factory.init(args);
+
+    assertEquals(List.of("string_field", "body_field"), factory.getInputFields());
+  }
+
+  @Test
+  public void init_noInputField_shouldThrowExceptionWithDetailedMessage() {
+    NamedList<String> args = new NamedList<>();
+    args.add("outputField", "enriched_field");
+    args.add("prompt", "Summarize: {string_field}");
+    args.add("model", "model1");
+
+    DocumentEnrichmentUpdateProcessorFactory factory = new DocumentEnrichmentUpdateProcessorFactory();
+
+    SolrException e = assertThrows(SolrException.class, () -> factory.init(args));
+    assertEquals("At least one 'inputField' must be provided", e.getMessage());
+  }
+
+  @Test
+  public void init_nullOutputField_shouldThrowExceptionWithDetailedMessage() {
+    NamedList<String> args = new NamedList<>();
+    args.add("inputField", "string_field");
+    args.add("prompt", "Summarize: {string_field}");
+    args.add("model", "model1");
+
+    DocumentEnrichmentUpdateProcessorFactory factory = new DocumentEnrichmentUpdateProcessorFactory();
+
+    SolrException e = assertThrows(SolrException.class, () -> factory.init(args));
+    assertEquals("Missing required parameter: outputField", e.getMessage());
+  }
+
+  @Test
+  public void init_neitherPromptNorPromptFile_shouldThrowExceptionWithDetailedMessage() {
+    NamedList<String> args = new NamedList<>();
+    args.add("inputField", "string_field");
+    args.add("outputField", "enriched_field");
+    args.add("model", "model1");
+
+    DocumentEnrichmentUpdateProcessorFactory factory = new DocumentEnrichmentUpdateProcessorFactory();
+
+    SolrException e = assertThrows(SolrException.class, () -> factory.init(args));
+    assertEquals("Either 'prompt' or 'promptFile' must be provided", e.getMessage());
+  }
+
+  @Test
+  public void init_bothPromptAndPromptFile_shouldThrowExceptionWithDetailedMessage() {
+    NamedList<String> args = new NamedList<>();
+    args.add("inputField", "string_field");
+    args.add("outputField", "enriched_field");
+    args.add("prompt", "Summarize: {string_field}");
+    args.add("promptFile", "prompt.txt");
+    args.add("model", "model1");
+
+    DocumentEnrichmentUpdateProcessorFactory factory = new DocumentEnrichmentUpdateProcessorFactory();
+
+    SolrException e = assertThrows(SolrException.class, () -> factory.init(args));
+    assertEquals("Only one of 'prompt' or 'promptFile' can be provided, not both", e.getMessage());
+  }
+
+  @Test
+  public void init_promptMissingPlaceholderForDeclaredField_shouldThrowExceptionWithDetailedMessage() {
+    NamedList<String> args = new NamedList<>();
+    args.add("inputField", "string_field");
+    args.add("outputField", "enriched_field");
+    args.add("prompt", "Summarize:");
+    args.add("model", "model1");
+
+    DocumentEnrichmentUpdateProcessorFactory factory = new DocumentEnrichmentUpdateProcessorFactory();
+
+    SolrException e = assertThrows(SolrException.class, () -> factory.init(args));
+    assertEquals("prompt is missing placeholders for inputField(s): [string_field]", e.getMessage());
+  }
+
+  @Test
+  public void init_promptMissingOnePlaceholderOfMultipleFields_shouldThrowExceptionWithDetailedMessage() {
+    NamedList<String> args = new NamedList<>();
+    args.add("inputField", "string_field");
+    args.add("inputField", "body_field");
+    args.add("outputField", "enriched_field");
+    args.add("prompt", "Title: {string_field}.");
+    args.add("model", "model1");
+
+    DocumentEnrichmentUpdateProcessorFactory factory = new DocumentEnrichmentUpdateProcessorFactory();
+
+    SolrException e = assertThrows(SolrException.class, () -> factory.init(args));
+    assertEquals("prompt is missing placeholders for inputField(s): [body_field]", e.getMessage());
+  }
+
+  @Test
+  public void init_promptHasExtraPlaceholderNotDeclaredAsInputField_shouldThrowExceptionWithDetailedMessage() {
+    NamedList<String> args = new NamedList<>();
+    args.add("inputField", "string_field");
+    args.add("outputField", "enriched_field");
+    args.add("prompt", "Title: {string_field}. Extra: {unknown_field}.");
+    args.add("model", "model1");
+
+    DocumentEnrichmentUpdateProcessorFactory factory = new DocumentEnrichmentUpdateProcessorFactory();
+
+    SolrException e = assertThrows(SolrException.class, () -> factory.init(args));
+    assertEquals(
+        "prompt contains placeholders not declared as inputField(s): [unknown_field]",
+        e.getMessage());
+  }
+
+  @Test
+  public void init_nullModel_shouldThrowExceptionWithDetailedMessage() {
+    NamedList<String> args = new NamedList<>();
+    args.add("inputField", "string_field");
+    args.add("outputField", "enriched_field");
+    args.add("prompt", "Summarize: {string_field}");
+
+    DocumentEnrichmentUpdateProcessorFactory factory = new DocumentEnrichmentUpdateProcessorFactory();
+
+    SolrException e = assertThrows(SolrException.class, () -> factory.init(args));
+    assertEquals("Missing required parameter: model", e.getMessage());
+  }
+
+  @Test
+  public void init_promptFile_shouldLoadPromptFromFile() {
+    NamedList<String> args = new NamedList<>();
+    args.add("inputField", "string_field");
+    args.add("outputField", "enriched_field");
+    args.add("promptFile", "prompt.txt");
+    args.add("model", "model1");
+
+    DocumentEnrichmentUpdateProcessorFactory factory = new DocumentEnrichmentUpdateProcessorFactory();
+    factory.init(args);
+    factory.inform(collection1);
+
+    assertEquals("prompt.txt", factory.getPromptFile());
+    assertNotNull(factory.getPrompt());
+    assertTrue(factory.getPrompt().contains("{string_field}"));
+  }
+
+  @Test
+  public void init_promptFileMultiField_shouldLoadAndValidateBothPlaceholders() {
+    NamedList<String> args = new NamedList<>();
+    args.add("inputField", "string_field");
+    args.add("inputField", "body_field");
+    args.add("outputField", "enriched_field");
+    args.add("promptFile", "prompt-multi-field.txt");
+    args.add("model", "model1");
+
+    DocumentEnrichmentUpdateProcessorFactory factory = new DocumentEnrichmentUpdateProcessorFactory();
+    factory.init(args);
+    factory.inform(collection1);
+
+    assertNotNull(factory.getPrompt());
+    assertTrue(factory.getPrompt().contains("{string_field}"));
+    assertTrue(factory.getPrompt().contains("{body_field}"));
+  }
+
+  @Test
+  public void init_promptFileWithMissingPlaceholder_shouldThrowExceptionInInform() {
+    NamedList<String> args = new NamedList<>();
+    args.add("inputField", "string_field");
+    args.add("outputField", "enriched_field");
+    args.add("promptFile", "prompt-no-placeholder.txt");
+    args.add("model", "model1");
+
+    DocumentEnrichmentUpdateProcessorFactory factory = new DocumentEnrichmentUpdateProcessorFactory();
+    factory.init(args);
+
+    SolrException e = assertThrows(SolrException.class, () -> factory.inform(collection1));
+    assertEquals(
+        "prompt is missing placeholders for inputField(s): [string_field]", e.getMessage());
+  }
+
+  /* Following tests depend on a real solr schema and depend on BeforeClass-AfterClass methods */
+
+  @Test
+  public void init_notExistentOutputField_shouldThrowExceptionWithDetailedMessage() {
+    NamedList<String> args = new NamedList<>();
+    args.add("inputField", "string_field");
+    args.add("outputField", "notExistentOutput");
+    args.add("prompt", "Summarize: {string_field}");
+    args.add("model", "model1");
+
+    DocumentEnrichmentUpdateProcessorFactory factory = new DocumentEnrichmentUpdateProcessorFactory();
+    ModifiableSolrParams params = new ModifiableSolrParams();
+    SolrQueryRequestBase req = new SolrQueryRequestBase(collection1, params) {};
+    factory.init(args);
+
+    SolrException e = assertThrows(SolrException.class, () -> factory.getInstance(req, null, null));
+    assertEquals("undefined field: \"notExistentOutput\"", e.getMessage());
+  }
+
+  @Test
+  public void init_notTextualOutputField_shouldThrowExceptionWithDetailedMessage() {
+    // vector is a DenseVectorField — not a textual field
+    NamedList<String> args = new NamedList<>();
+    args.add("inputField", "string_field");
+    args.add("outputField", "vector");
+    args.add("prompt", "Summarize: {string_field}");
+    args.add("model", "model1");
+
+    DocumentEnrichmentUpdateProcessorFactory factory = new DocumentEnrichmentUpdateProcessorFactory();
+    ModifiableSolrParams params = new ModifiableSolrParams();
+    SolrQueryRequestBase req = new SolrQueryRequestBase(collection1, params) {};
+    factory.init(args);
+
+    SolrException e = assertThrows(SolrException.class, () -> factory.getInstance(req, null, null));
+    assertEquals(
+        "field type is not supported by Document Enrichment: DenseVectorField", e.getMessage());
+  }
+
+  @Test
+  public void init_notExistentInputField_shouldThrowExceptionWithDetailedMessage() {
+    NamedList<String> args = new NamedList<>();
+    args.add("inputField", "notExistentInput");
+    args.add("outputField", "enriched_field");
+    args.add("prompt", "Summarize: {notExistentInput}");
+    args.add("model", "model1");
+
+    DocumentEnrichmentUpdateProcessorFactory factory = new DocumentEnrichmentUpdateProcessorFactory();
+    ModifiableSolrParams params = new ModifiableSolrParams();
+    SolrQueryRequestBase req = new SolrQueryRequestBase(collection1, params) {};
+    factory.init(args);
+
+    SolrException e = assertThrows(SolrException.class, () -> factory.getInstance(req, null, null));
+    assertEquals("undefined field: \"notExistentInput\"", e.getMessage());
+  }
+
+  @Test
+  public void init_multipleInputFields_oneNotExistent_shouldThrowExceptionWithDetailedMessage() {
+    NamedList<String> args = new NamedList<>();
+    args.add("inputField", "string_field");
+    args.add("inputField", "notExistentInput");
+    args.add("outputField", "enriched_field");
+    args.add("prompt", "Title: {string_field}. Body: {notExistentInput}.");
+    args.add("model", "model1");
+
+    DocumentEnrichmentUpdateProcessorFactory factory = new DocumentEnrichmentUpdateProcessorFactory();
+    ModifiableSolrParams params = new ModifiableSolrParams();
+    SolrQueryRequestBase req = new SolrQueryRequestBase(collection1, params) {};
+    factory.init(args);
+
+    SolrException e = assertThrows(SolrException.class, () -> factory.getInstance(req, null, null));
+    assertEquals("undefined field: \"notExistentInput\"", e.getMessage());
+  }
+
+  @Test
+  public void init_multivaluedStringOutputField_shouldNotThrowException() throws Exception {
+    UpdateRequestProcessor instance =
+        createUpdateProcessor("string_field", "enriched_field_multi", collection1, "model-mv");
+    assertNotNull(instance);
+    restTestHarness.delete(ManagedChatModelStore.REST_END_POINT + "/model-mv");
+  }
+
+  @Test
+  public void init_multivaluedStringOutputField_buildResponseFormat_shouldProduceArraySchema() throws Exception {
+    NamedList<String> args = new NamedList<>();
+    ManagedChatModelStore.getManagedModelStore(collection1)
+        .addModel(new SolrChatModel("model-rf", null, null));
+    args.add("inputField", "string_field");
+    args.add("outputField", "enriched_field_multi");
+    args.add("prompt", "Summarize: {string_field}");
+    args.add("model", "model-rf");
+
+    DocumentEnrichmentUpdateProcessorFactory factory = new DocumentEnrichmentUpdateProcessorFactory();
+    factory.init(args);
+    ModifiableSolrParams params = new ModifiableSolrParams();
+    SolrQueryRequestBase req = new SolrQueryRequestBase(collection1, params) {};
+    assertNotNull(factory.getInstance(req, null, null));
+
+    // verify the ResponseFormat is constructed correctly for the multivalued field
+    var schema = collection1.getLatestSchema();
+    var schemaField = schema.getField("enriched_field_multi");
+    assertTrue(schemaField.multiValued());
+    var responseFormat = DocumentEnrichmentUpdateProcessorFactory.buildResponseFormat(schemaField);
+    assertNotNull(responseFormat);
+    assertEquals(
+        dev.langchain4j.model.chat.request.ResponseFormatType.JSON, responseFormat.type());
+    assertNotNull(responseFormat.jsonSchema());
+    restTestHarness.delete(ManagedChatModelStore.REST_END_POINT + "/model-rf");
+  }
+
+  @Test
+  public void init_singleValuedStringOutputField_buildResponseFormat_shouldProduceStringSchema() {
+    var schema = collection1.getLatestSchema();
+    var schemaField = schema.getField("enriched_field");
+    assertFalse(schemaField.multiValued());
+    var responseFormat = DocumentEnrichmentUpdateProcessorFactory.buildResponseFormat(schemaField);
+    assertNotNull(responseFormat);
+    assertEquals(
+        dev.langchain4j.model.chat.request.ResponseFormatType.JSON, responseFormat.type());
+    assertNotNull(responseFormat.jsonSchema());
+  }
+
+  @Test
+  public void init_dynamicInputField_shouldNotThrowException() throws Exception{
+    UpdateRequestProcessor instance =
+        createUpdateProcessor("text_s", "enriched_field", collection1, "model2");
+    assertNotNull(instance);
+    restTestHarness.delete(ManagedChatModelStore.REST_END_POINT + "/model2");
+  }
+
+  @Test
+  public void init_multipleDynamicInputFields_shouldNotThrowException() throws Exception{
+    NamedList<String> args = new NamedList<>();
+    ManagedChatModelStore.getManagedModelStore(collection1)
+        .addModel(new SolrChatModel("model1", null, null));
+    args.add("inputField", "text_s");
+    args.add("inputField", "body_field");
+    args.add("outputField", "enriched_field");
+    args.add("prompt", "Title: {text_s}. Body: {body_field}.");
+    args.add("model", "model1");
+
+    DocumentEnrichmentUpdateProcessorFactory factory = new DocumentEnrichmentUpdateProcessorFactory();
+    ModifiableSolrParams params = new ModifiableSolrParams();
+    factory.init(args);
+
+    SolrQueryRequestBase req = new SolrQueryRequestBase(collection1, params) {};
+    assertNotNull(factory.getInstance(req, null, null));
+    restTestHarness.delete(ManagedChatModelStore.REST_END_POINT + "/model1");
+  }
+
+  private UpdateRequestProcessor createUpdateProcessor(

Review Comment:
   this is the same as createUpdateProcessor a part from the creation of the request and getInstance()
   maybe we can exclude the solr request + getInstance() and use that method also here? calling it like "initializeUpdateProcessorFactory"?
   what do you think?



##########
solr/modules/language-models/src/test/org/apache/solr/languagemodels/documentenrichment/update/processor/DocumentEnrichmentUpdateProcessorFactoryTest.java:
##########
@@ -0,0 +1,417 @@
+/*
+ * 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.solr.languagemodels.documentenrichment.update.processor;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import org.apache.solr.common.SolrException;
+import org.apache.solr.common.params.ModifiableSolrParams;
+import org.apache.solr.common.util.NamedList;
+import org.apache.solr.core.SolrCore;
+import org.apache.solr.languagemodels.TestLanguageModelBase;
+import org.apache.solr.languagemodels.documentenrichment.model.SolrChatModel;
+import org.apache.solr.languagemodels.documentenrichment.store.rest.ManagedChatModelStore;
+import org.apache.solr.request.SolrQueryRequestBase;
+import org.apache.solr.update.processor.UpdateRequestProcessor;
+import org.junit.After;
+import org.junit.AfterClass;
+import org.junit.Before;
+import org.junit.BeforeClass;
+import org.junit.Test;
+
+public class DocumentEnrichmentUpdateProcessorFactoryTest extends TestLanguageModelBase {
+
+  @BeforeClass
+  public static void init() throws Exception {
+    setupTest("solrconfig-document-enrichment.xml", "schema-language-models.xml", false, false);
+  }
+
+  @AfterClass
+  public static void cleanup() throws Exception {
+    afterTest();
+  }
+
+  SolrCore collection1;
+
+  @Before
+  public void setup() {
+    collection1 = solrTestRule.getCoreContainer().getCore("collection1");
+  }
+
+  @After
+  public void after() {
+    collection1.close();
+  }
+
+  @Test
+  public void init_fullArgs_shouldInitAllParams() {
+    NamedList<String> args = new NamedList<>();
+    args.add("inputField", "string_field");
+    args.add("outputField", "enriched_field");
+    args.add("prompt", "Summarize: {string_field}");
+    args.add("model", "model1");
+
+    DocumentEnrichmentUpdateProcessorFactory factory = new DocumentEnrichmentUpdateProcessorFactory();
+    factory.init(args);
+
+    assertEquals(List.of("string_field"), factory.getInputFields());
+    assertEquals("enriched_field", factory.getOutputField());
+    assertEquals("Summarize: {string_field}", factory.getPrompt());
+    assertEquals("model1", factory.getModelName());
+  }
+
+  @Test
+  public void init_multipleInputFields_shouldInitAllFields() {
+    NamedList<String> args = new NamedList<>();
+    args.add("inputField", "string_field");
+    args.add("inputField", "body_field");
+    args.add("outputField", "enriched_field");
+    args.add("prompt", "Title: {string_field}. Body: {body_field}.");
+    args.add("model", "model1");
+
+    DocumentEnrichmentUpdateProcessorFactory factory = new DocumentEnrichmentUpdateProcessorFactory();
+    factory.init(args);
+
+    assertEquals(List.of("string_field", "body_field"), factory.getInputFields());
+  }
+
+  @Test
+  public void init_arrInputField_shouldInitAllFields() {
+    NamedList<Object> args = new NamedList<>();
+    args.add("inputField", new ArrayList<>(List.of("string_field", "body_field")));
+    args.add("outputField", "enriched_field");
+    args.add("prompt", "Title: {string_field}. Body: {body_field}.");
+    args.add("model", "model1");
+
+    DocumentEnrichmentUpdateProcessorFactory factory = new DocumentEnrichmentUpdateProcessorFactory();
+    factory.init(args);
+
+    assertEquals(List.of("string_field", "body_field"), factory.getInputFields());
+  }
+
+  @Test
+  public void init_noInputField_shouldThrowExceptionWithDetailedMessage() {
+    NamedList<String> args = new NamedList<>();
+    args.add("outputField", "enriched_field");
+    args.add("prompt", "Summarize: {string_field}");
+    args.add("model", "model1");
+
+    DocumentEnrichmentUpdateProcessorFactory factory = new DocumentEnrichmentUpdateProcessorFactory();
+
+    SolrException e = assertThrows(SolrException.class, () -> factory.init(args));
+    assertEquals("At least one 'inputField' must be provided", e.getMessage());
+  }
+
+  @Test
+  public void init_nullOutputField_shouldThrowExceptionWithDetailedMessage() {
+    NamedList<String> args = new NamedList<>();
+    args.add("inputField", "string_field");
+    args.add("prompt", "Summarize: {string_field}");
+    args.add("model", "model1");
+
+    DocumentEnrichmentUpdateProcessorFactory factory = new DocumentEnrichmentUpdateProcessorFactory();
+
+    SolrException e = assertThrows(SolrException.class, () -> factory.init(args));
+    assertEquals("Missing required parameter: outputField", e.getMessage());
+  }
+
+  @Test
+  public void init_neitherPromptNorPromptFile_shouldThrowExceptionWithDetailedMessage() {
+    NamedList<String> args = new NamedList<>();
+    args.add("inputField", "string_field");
+    args.add("outputField", "enriched_field");
+    args.add("model", "model1");
+
+    DocumentEnrichmentUpdateProcessorFactory factory = new DocumentEnrichmentUpdateProcessorFactory();
+
+    SolrException e = assertThrows(SolrException.class, () -> factory.init(args));
+    assertEquals("Either 'prompt' or 'promptFile' must be provided", e.getMessage());
+  }
+
+  @Test
+  public void init_bothPromptAndPromptFile_shouldThrowExceptionWithDetailedMessage() {
+    NamedList<String> args = new NamedList<>();
+    args.add("inputField", "string_field");
+    args.add("outputField", "enriched_field");
+    args.add("prompt", "Summarize: {string_field}");
+    args.add("promptFile", "prompt.txt");
+    args.add("model", "model1");
+
+    DocumentEnrichmentUpdateProcessorFactory factory = new DocumentEnrichmentUpdateProcessorFactory();
+
+    SolrException e = assertThrows(SolrException.class, () -> factory.init(args));
+    assertEquals("Only one of 'prompt' or 'promptFile' can be provided, not both", e.getMessage());
+  }
+
+  @Test
+  public void init_promptMissingPlaceholderForDeclaredField_shouldThrowExceptionWithDetailedMessage() {
+    NamedList<String> args = new NamedList<>();
+    args.add("inputField", "string_field");
+    args.add("outputField", "enriched_field");
+    args.add("prompt", "Summarize:");
+    args.add("model", "model1");
+
+    DocumentEnrichmentUpdateProcessorFactory factory = new DocumentEnrichmentUpdateProcessorFactory();
+
+    SolrException e = assertThrows(SolrException.class, () -> factory.init(args));
+    assertEquals("prompt is missing placeholders for inputField(s): [string_field]", e.getMessage());
+  }
+
+  @Test
+  public void init_promptMissingOnePlaceholderOfMultipleFields_shouldThrowExceptionWithDetailedMessage() {
+    NamedList<String> args = new NamedList<>();
+    args.add("inputField", "string_field");
+    args.add("inputField", "body_field");
+    args.add("outputField", "enriched_field");
+    args.add("prompt", "Title: {string_field}.");
+    args.add("model", "model1");
+
+    DocumentEnrichmentUpdateProcessorFactory factory = new DocumentEnrichmentUpdateProcessorFactory();
+
+    SolrException e = assertThrows(SolrException.class, () -> factory.init(args));
+    assertEquals("prompt is missing placeholders for inputField(s): [body_field]", e.getMessage());
+  }
+
+  @Test
+  public void init_promptHasExtraPlaceholderNotDeclaredAsInputField_shouldThrowExceptionWithDetailedMessage() {
+    NamedList<String> args = new NamedList<>();
+    args.add("inputField", "string_field");
+    args.add("outputField", "enriched_field");
+    args.add("prompt", "Title: {string_field}. Extra: {unknown_field}.");
+    args.add("model", "model1");
+
+    DocumentEnrichmentUpdateProcessorFactory factory = new DocumentEnrichmentUpdateProcessorFactory();
+
+    SolrException e = assertThrows(SolrException.class, () -> factory.init(args));
+    assertEquals(
+        "prompt contains placeholders not declared as inputField(s): [unknown_field]",
+        e.getMessage());
+  }
+
+  @Test
+  public void init_nullModel_shouldThrowExceptionWithDetailedMessage() {
+    NamedList<String> args = new NamedList<>();
+    args.add("inputField", "string_field");
+    args.add("outputField", "enriched_field");
+    args.add("prompt", "Summarize: {string_field}");
+
+    DocumentEnrichmentUpdateProcessorFactory factory = new DocumentEnrichmentUpdateProcessorFactory();
+
+    SolrException e = assertThrows(SolrException.class, () -> factory.init(args));
+    assertEquals("Missing required parameter: model", e.getMessage());
+  }
+
+  @Test
+  public void init_promptFile_shouldLoadPromptFromFile() {
+    NamedList<String> args = new NamedList<>();
+    args.add("inputField", "string_field");
+    args.add("outputField", "enriched_field");
+    args.add("promptFile", "prompt.txt");
+    args.add("model", "model1");
+
+    DocumentEnrichmentUpdateProcessorFactory factory = new DocumentEnrichmentUpdateProcessorFactory();
+    factory.init(args);
+    factory.inform(collection1);
+
+    assertEquals("prompt.txt", factory.getPromptFile());
+    assertNotNull(factory.getPrompt());
+    assertTrue(factory.getPrompt().contains("{string_field}"));
+  }
+
+  @Test
+  public void init_promptFileMultiField_shouldLoadAndValidateBothPlaceholders() {
+    NamedList<String> args = new NamedList<>();
+    args.add("inputField", "string_field");
+    args.add("inputField", "body_field");
+    args.add("outputField", "enriched_field");
+    args.add("promptFile", "prompt-multi-field.txt");
+    args.add("model", "model1");
+
+    DocumentEnrichmentUpdateProcessorFactory factory = new DocumentEnrichmentUpdateProcessorFactory();
+    factory.init(args);
+    factory.inform(collection1);
+
+    assertNotNull(factory.getPrompt());
+    assertTrue(factory.getPrompt().contains("{string_field}"));
+    assertTrue(factory.getPrompt().contains("{body_field}"));
+  }
+
+  @Test
+  public void init_promptFileWithMissingPlaceholder_shouldThrowExceptionInInform() {
+    NamedList<String> args = new NamedList<>();

Review Comment:
   this is the same as createUpdateProcessor a part from the creation of the request and getInstance()
   maybe we can exclude the solr request + getInstance() and use that method also here? calling it like "initializeUpdateProcessorFactory"? 
   what do you think?



-- 
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: issues-unsubscribe@solr.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


---------------------------------------------------------------------
To unsubscribe, e-mail: issues-unsubscribe@solr.apache.org
For additional commands, e-mail: issues-help@solr.apache.org


Re: [PR] SOLR-18187: Document enrichment with LLMs [solr]

Posted by "nicolo-rinaldi (via GitHub)" <gi...@apache.org>.
nicolo-rinaldi commented on code in PR #4259:
URL: https://github.com/apache/solr/pull/4259#discussion_r3138178894


##########
solr/modules/language-models/src/test/org/apache/solr/languagemodels/documentenrichment/update/processor/DocumentEnrichmentUpdateProcessorFactoryTest.java:
##########
@@ -354,64 +325,60 @@ public void init_multivaluedStringOutputField_shouldNotThrowException() throws E
         createUpdateProcessor(
             List.of("string_field"), "enriched_field_multi", null, collection1, "model1");
     assertNotNull(instance);
-    restTestHarness.delete(ManagedChatModelStore.REST_END_POINT + "/model1");
+    restTestHarness.delete(ManagedFieldGenerationModelStore.REST_END_POINT + "/model1");
   }
 
-  /* buildResponseFormat tests for field types from the Solr documentation */
+  /* buildResponseFormat tests for unsupported field types from the Solr documentation: 1 general (Binary) + 3 via inheritance */

Review Comment:
   Changed, see if now is clear



-- 
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: issues-unsubscribe@solr.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


---------------------------------------------------------------------
To unsubscribe, e-mail: issues-unsubscribe@solr.apache.org
For additional commands, e-mail: issues-help@solr.apache.org