Update to ResponseMetadata design

* Remove inheritance from HashMap
* No more subclasses per model provider
* Builder class for ChatResponse
* Fix the AbstractResponseMetadata#AI_METADATA_STRING parameter order
* ChatResponseMetadata ignore Null values.
This commit is contained in:
Mark Pollack
2024-07-15 17:45:08 -04:00
committed by Christian Tzolov
parent 17c44237a5
commit 97f443d615
39 changed files with 778 additions and 732 deletions

View File

@@ -23,6 +23,7 @@ import java.util.stream.Collectors;
import org.springframework.ai.chat.client.AdvisedRequest;
import org.springframework.ai.chat.client.RequestResponseAdvisor;
import org.springframework.ai.chat.metadata.ChatResponseMetadata;
import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.ai.document.Document;
import org.springframework.ai.model.Content;
@@ -127,15 +128,17 @@ public class QuestionAnswerAdvisor implements RequestResponseAdvisor {
@Override
public ChatResponse adviseResponse(ChatResponse response, Map<String, Object> context) {
response.getMetadata().put(RETRIEVED_DOCUMENTS, context.get(RETRIEVED_DOCUMENTS));
return response;
ChatResponse.Builder chatResponseBuilder = ChatResponse.builder().from(response);
chatResponseBuilder.withMetadata(RETRIEVED_DOCUMENTS, context.get(RETRIEVED_DOCUMENTS));
return chatResponseBuilder.build();
}
@Override
public Flux<ChatResponse> adviseResponse(Flux<ChatResponse> fluxResponse, Map<String, Object> context) {
return fluxResponse.map(cr -> {
cr.getMetadata().put(RETRIEVED_DOCUMENTS, context.get(RETRIEVED_DOCUMENTS));
return cr;
ChatResponse.Builder chatResponseBuilder = ChatResponse.builder().from(cr);
chatResponseBuilder.withMetadata(RETRIEVED_DOCUMENTS, context.get(RETRIEVED_DOCUMENTS));
return chatResponseBuilder.build();
});
}

View File

@@ -15,40 +15,51 @@
*/
package org.springframework.ai.chat.metadata;
import java.util.Map;
import java.util.Objects;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.ai.model.AbstractResponseMetadata;
import org.springframework.ai.model.ResponseMetadata;
import java.util.HashMap;
/**
* Abstract Data Type (ADT) modeling common AI provider metadata returned in an AI
* response.
* Models common AI provider metadata returned in an AI response.
*
* @author John Blum
* @author Thomas Vitale
* @since 0.7.0
* @author Mark Pollack
* @since 1.0.0
*/
public interface ChatResponseMetadata extends ResponseMetadata {
public class ChatResponseMetadata extends AbstractResponseMetadata implements ResponseMetadata {
class DefaultChatResponseMetadata extends HashMap<String, Object> implements ChatResponseMetadata {
private final static Logger logger = LoggerFactory.getLogger(ChatResponseMetadata.class);
}
private String id = ""; // Set to blank to preserve backward compat with previous
// interface default methods
ChatResponseMetadata NULL = new DefaultChatResponseMetadata();
private String model = "";
private RateLimit rateLimit = new EmptyRateLimit();
private Usage usage = new EmptyUsage();
private PromptMetadata promptMetadata = PromptMetadata.empty();
/**
* A unique identifier for the chat completion operation.
* @return unique operation identifier.
*/
default String getId() {
return "";
public String getId() {
return this.id;
}
/**
* The model that handled the request.
* @return the model that handled the request.
*/
default String getModel() {
return "";
public String getModel() {
return this.model;
}
/**
@@ -56,8 +67,8 @@ public interface ChatResponseMetadata extends ResponseMetadata {
* @return AI provider specific metadata on rate limits.
* @see RateLimit
*/
default RateLimit getRateLimit() {
return new EmptyRateLimit();
public RateLimit getRateLimit() {
return this.rateLimit;
}
/**
@@ -65,12 +76,98 @@ public interface ChatResponseMetadata extends ResponseMetadata {
* @return AI provider specific metadata on API usage.
* @see Usage
*/
default Usage getUsage() {
return new EmptyUsage();
public Usage getUsage() {
return this.usage;
}
default PromptMetadata getPromptMetadata() {
return PromptMetadata.empty();
/**
* Returns the prompt metadata gathered by the AI during request processing.
* @return the prompt metadata.
*/
public PromptMetadata getPromptMetadata() {
return this.promptMetadata;
}
public static class Builder {
private final ChatResponseMetadata chatResponseMetadata;
public Builder() {
this.chatResponseMetadata = new ChatResponseMetadata();
}
public Builder withMetadata(Map<String, Object> mapToCopy) {
this.chatResponseMetadata.map.putAll(mapToCopy);
return this;
}
public Builder withKeyValue(String key, Object value) {
if (key == null) {
throw new IllegalArgumentException("Key must not be null");
}
if (value != null) {
this.chatResponseMetadata.map.put(key, value);
}
else {
logger.debug("Ignore null value for key [{}]", key);
}
return this;
}
public Builder withId(String id) {
this.chatResponseMetadata.id = id;
return this;
}
public Builder withModel(String model) {
this.chatResponseMetadata.model = model;
return this;
}
public Builder withRateLimit(RateLimit rateLimit) {
this.chatResponseMetadata.rateLimit = rateLimit;
return this;
}
public Builder withUsage(Usage usage) {
this.chatResponseMetadata.usage = usage;
return this;
}
public Builder withPromptMetadata(PromptMetadata promptMetadata) {
this.chatResponseMetadata.promptMetadata = promptMetadata;
return this;
}
public ChatResponseMetadata build() {
return this.chatResponseMetadata;
}
}
public static Builder builder() {
return new Builder();
}
@Override
public boolean equals(Object o) {
if (this == o)
return true;
if (!(o instanceof ChatResponseMetadata that))
return false;
return Objects.equals(this.id, that.id) && Objects.equals(this.model, that.model)
&& Objects.equals(this.rateLimit, that.rateLimit) && Objects.equals(this.usage, that.usage)
&& Objects.equals(this.promptMetadata, that.promptMetadata);
}
@Override
public int hashCode() {
return Objects.hash(this.id, this.model, this.rateLimit, this.usage, this.promptMetadata);
}
@Override
public String toString() {
return AI_METADATA_STRING.formatted(getId(), getUsage(), getRateLimit());
}
}

View File

@@ -16,7 +16,9 @@
package org.springframework.ai.chat.model;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import org.springframework.ai.model.ModelResponse;
import org.springframework.util.CollectionUtils;
@@ -40,7 +42,7 @@ public class ChatResponse implements ModelResponse<Generation> {
* provider.
*/
public ChatResponse(List<Generation> generations) {
this(generations, ChatResponseMetadata.NULL);
this(generations, new ChatResponseMetadata());
}
/**
@@ -107,4 +109,44 @@ public class ChatResponse implements ModelResponse<Generation> {
return Objects.hash(chatResponseMetadata, generations);
}
public static ChatResponse.Builder builder() {
return new ChatResponse.Builder();
}
public static class Builder {
private List<Generation> generations;
private ChatResponseMetadata.Builder chatResponseMetadataBuilder;
private Builder() {
this.chatResponseMetadataBuilder = ChatResponseMetadata.builder();
}
public Builder from(ChatResponse other) {
this.generations = other.generations;
Set<Map.Entry<String, Object>> entries = other.chatResponseMetadata.entrySet();
for (Map.Entry<String, Object> entry : entries) {
this.chatResponseMetadataBuilder.withKeyValue(entry.getKey(), entry.getValue());
}
return this;
}
public Builder withMetadata(String key, Object value) {
this.chatResponseMetadataBuilder.withKeyValue(key, value);
return this;
}
public Builder withGenerations(List<Generation> generations) {
this.generations = generations;
return this;
}
public ChatResponse build() {
return new ChatResponse(generations, chatResponseMetadataBuilder.build());
}
}
}

View File

@@ -15,24 +15,20 @@
*/
package org.springframework.ai.embedding;
import java.io.Serial;
import java.util.HashMap;
import java.util.Map;
import org.springframework.ai.chat.metadata.EmptyUsage;
import org.springframework.ai.chat.metadata.Usage;
import org.springframework.ai.model.AbstractResponseMetadata;
import org.springframework.ai.model.ResponseMetadata;
import java.util.Map;
/**
* Common AI provider metadata returned in an embedding response.
*
* @author Christian Tzolov
* @author Thomas Vitale
*/
public class EmbeddingResponseMetadata extends HashMap<String, Object> implements ResponseMetadata {
@Serial
private static final long serialVersionUID = 1L;
public class EmbeddingResponseMetadata extends AbstractResponseMetadata implements ResponseMetadata {
private String model;
@@ -42,12 +38,15 @@ public class EmbeddingResponseMetadata extends HashMap<String, Object> implement
}
public EmbeddingResponseMetadata(String model, Usage usage) {
this.model = model;
this.usage = usage;
this(model, usage, Map.of());
}
public EmbeddingResponseMetadata(Map<String, ?> metadata) {
super(metadata);
public EmbeddingResponseMetadata(String model, Usage usage, Map<String, Object> metadata) {
this.model = model;
this.usage = usage;
for (Map.Entry<String, Object> entry : metadata.entrySet()) {
this.map.put(entry.getKey(), entry.getValue());
}
}
/**

View File

@@ -43,7 +43,7 @@ public class ImageResponse implements ModelResponse<ImageGeneration> {
* provider.
*/
public ImageResponse(List<ImageGeneration> generations) {
this(generations, ImageResponseMetadata.NULL);
this(generations, new ImageResponseMetadata());
}
/**

View File

@@ -15,6 +15,7 @@
*/
package org.springframework.ai.image;
import org.springframework.ai.model.MutableResponseMetadata;
import org.springframework.ai.model.ResponseMetadata;
import java.util.HashMap;
@@ -28,16 +29,20 @@ import java.util.HashMap;
* @author Thomas Vitale
* @since 1.0.0
*/
public interface ImageResponseMetadata extends ResponseMetadata {
public class ImageResponseMetadata extends MutableResponseMetadata {
class DefaultImageResponseMetadata extends HashMap<String, Object> implements ImageResponseMetadata {
private Long created;
public ImageResponseMetadata() {
this.created = System.currentTimeMillis();
}
ImageResponseMetadata NULL = new DefaultImageResponseMetadata();
public ImageResponseMetadata(Long created) {
this.created = created;
}
default Long getCreated() {
return System.currentTimeMillis();
public Long getCreated() {
return this.created;
}
}

View File

@@ -0,0 +1,76 @@
package org.springframework.ai.model;
import io.micrometer.common.lang.NonNull;
import io.micrometer.common.lang.Nullable;
import java.util.Collections;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
public class AbstractResponseMetadata {
protected static final String AI_METADATA_STRING = "{ id: %1$s, usage: %2$s, rateLimit: %3$s }";
protected final Map<String, Object> map = new ConcurrentHashMap<>();
/**
* Gets an entry from the context. Returns {@code null} when entry is not present.
* @param key key
* @param <T> value type
* @return entry or {@code null} if not present
*/
@Nullable
public <T> T get(String key) {
return (T) this.map.get(key);
}
/**
* Gets an entry from the context. Throws exception when entry is not present.
* @param key key
* @param <T> value type
* @return entry
* @throws IllegalArgumentException if not present
*/
@NonNull
public <T> T getRequired(Object key) {
T object = (T) this.map.get(key);
if (object == null) {
throw new IllegalArgumentException("Context does not have an entry for key [" + key + "]");
}
return object;
}
/**
* Checks if context contains a key.
* @param key key
* @return {@code true} when the context contains the entry with the given key
*/
public boolean containsKey(Object key) {
return this.map.containsKey(key);
}
/**
* Returns an element or default if not present.
* @param key key
* @param defaultObject default object to return
* @param <T> value type
* @return object or default if not present
*/
public <T> T getOrDefault(Object key, T defaultObject) {
return (T) this.map.getOrDefault(key, defaultObject);
}
public Set<Map.Entry<String, Object>> entrySet() {
return Collections.unmodifiableMap(this.map).entrySet();
}
public Set<String> keySet() {
return Collections.unmodifiableSet(this.map.keySet());
}
public boolean isEmpty() {
return this.map.isEmpty();
}
}

View File

@@ -0,0 +1,126 @@
package org.springframework.ai.model;
import io.micrometer.common.lang.NonNull;
import io.micrometer.common.lang.Nullable;
import java.util.Collections;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.Function;
public class MutableResponseMetadata implements ResponseMetadata {
private final Map<String, Object> map = new ConcurrentHashMap<>();
/**
* Puts an element to the context.
* @param key key
* @param object value
* @param <T> value type
* @return this for chaining
*/
public <T> MutableResponseMetadata put(String key, T object) {
this.map.put(key, object);
return this;
}
/**
* Gets an entry from the context. Returns {@code null} when entry is not present.
* @param key key
* @param <T> value type
* @return entry or {@code null} if not present
*/
@Override
@Nullable
public <T> T get(String key) {
return (T) this.map.get(key);
}
/**
* Removes an entry from the context.
* @param key key by which to remove an entry
* @return the previous value associated with the key, or null if there was no mapping
* for the key
*/
public Object remove(Object key) {
return this.map.remove(key);
}
/**
* Gets an entry from the context. Throws exception when entry is not present.
* @param key key
* @param <T> value type
* @throws IllegalArgumentException if not present
* @return entry
*/
@Override
@NonNull
public <T> T getRequired(Object key) {
T object = (T) this.map.get(key);
if (object == null) {
throw new IllegalArgumentException("Context does not have an entry for key [" + key + "]");
}
return object;
}
/**
* Checks if context contains a key.
* @param key key
* @return {@code true} when the context contains the entry with the given key
*/
@Override
public boolean containsKey(Object key) {
return this.map.containsKey(key);
}
/**
* Returns an element or default if not present.
* @param key key
* @param defaultObject default object to return
* @param <T> value type
* @return object or default if not present
*/
@Override
public <T> T getOrDefault(Object key, T defaultObject) {
return (T) this.map.getOrDefault(key, defaultObject);
}
@Override
public Set<Map.Entry<String, Object>> entrySet() {
return Collections.unmodifiableMap(this.map).entrySet();
}
public Set<String> keySet() {
return Collections.unmodifiableSet(this.map.keySet());
}
@Override
public boolean isEmpty() {
return this.map.isEmpty();
}
/**
* Returns an element or calls a mapping function if entry not present. The function
* will insert the value to the map.
* @param key key
* @param mappingFunction mapping function
* @param <T> value type
* @return object or one derived from the mapping function if not present
*/
public <T> T computeIfAbsent(String key, Function<Object, ? extends T> mappingFunction) {
return (T) this.map.computeIfAbsent(key, mappingFunction);
}
/**
* Clears the entries from the context.
*/
public void clear() {
this.map.clear();
}
public Map<String, Object> getRawMap() {
return map;
}
}

View File

@@ -15,18 +15,77 @@
*/
package org.springframework.ai.model;
import io.micrometer.common.lang.NonNull;
import io.micrometer.common.lang.Nullable;
import java.util.Map;
import java.util.Set;
import java.util.function.Supplier;
/**
* Interface representing metadata associated with an AI model's response. This interface
* is designed to provide additional information about the generative response from an AI
* model, including processing details and model-specific data. It serves as a value
* object within the core domain, enhancing the understanding and management of AI model
* responses in various applications.
* Interface representing metadata associated with an AI model's response.
*
* @author Mark Pollack
* @since 0.8.0
* @since 1.0.0
*/
public interface ResponseMetadata extends Map<String, Object> {
public interface ResponseMetadata {
/**
* Gets an entry from the context. Returns {@code null} when entry is not present.
* @param key key
* @param <T> value type
* @return entry or {@code null} if not present
*/
@Nullable
<T> T get(String key);
/**
* Gets an entry from the context. Throws exception when entry is not present.
* @param key key
* @param <T> value type
* @throws IllegalArgumentException if not present
* @return entry
*/
@NonNull
<T> T getRequired(Object key);
/**
* Checks if context contains a key.
* @param key key
* @return {@code true} when the context contains the entry with the given key
*/
boolean containsKey(Object key);
/**
* Returns an element or default if not present.
* @param key key
* @param defaultObject default object to return
* @param <T> value type
* @return object or default if not present
*/
<T> T getOrDefault(Object key, T defaultObject);
/**
* Returns an element or default if not present.
* @param key key
* @param defaultObjectSupplier supplier for default object to return
* @param <T> value type
* @return object or default if not present
* @since 1.11.0
*/
default <T> T getOrDefault(String key, Supplier<T> defaultObjectSupplier) {
T value = get(key);
return value != null ? value : defaultObjectSupplier.get();
}
Set<Map.Entry<String, Object>> entrySet();
public Set<String> keySet();
/**
* Returns {@code true} if this map contains no key-value mappings.
* @return {@code true} if this map contains no key-value mappings
*/
boolean isEmpty();
}

View File

@@ -136,9 +136,7 @@ public abstract class AbstractFunctionCallSupport<Msg, Req, Resp> {
// The chat completion tool call requires the complete conversation
// history. Including the initial user message.
List<Msg> conversationHistory = new ArrayList<>();
conversationHistory.addAll(this.doGetUserMessages(request));
List<Msg> conversationHistory = new ArrayList<>(this.doGetUserMessages(request));
Msg responseMessage = this.doGetToolResponseMessage(response);
@@ -164,9 +162,7 @@ public abstract class AbstractFunctionCallSupport<Msg, Req, Resp> {
// The chat completion tool call requires the complete conversation
// history. Including the initial user message.
List<Msg> conversationHistory = new ArrayList<>();
conversationHistory.addAll(this.doGetUserMessages(request));
List<Msg> conversationHistory = new ArrayList<>(this.doGetUserMessages(request));
Msg responseMessage = this.doGetToolResponseMessage(resp);

View File

@@ -29,7 +29,6 @@ import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.ai.chat.messages.Message;
import org.springframework.ai.chat.messages.MessageType;
import org.springframework.ai.chat.metadata.ChatResponseMetadata;
import org.springframework.ai.chat.metadata.ChatResponseMetadata.DefaultChatResponseMetadata;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.ai.chat.model.Generation;
@@ -58,8 +57,7 @@ public class ChatClientResponseEntityTests {
@Test
public void responseEntityTest() {
ChatResponseMetadata metadata = new DefaultChatResponseMetadata();
metadata.put("key1", "value1");
ChatResponseMetadata metadata = ChatResponseMetadata.builder().withKeyValue("key1", "value1").build();
var chatResponse = new ChatResponse(List.of(new Generation("""
{"name":"John", "age":30}
@@ -75,7 +73,7 @@ public class ChatClientResponseEntityTests {
.responseEntity(MyBean.class);
assertThat(responseEntity.getResponse()).isEqualTo(chatResponse);
assertThat(responseEntity.getResponse().getMetadata().get("key1")).isEqualTo("value1");
assertThat(responseEntity.getResponse().getMetadata().get("key1").toString()).isEqualTo("value1");
assertThat(responseEntity.getEntity()).isEqualTo(new MyBean("John", 30));