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: [email protected]

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


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to