Creating an Application With a Personality
Learn how to create an AI assistant with LangChain4j in a Spring Boot application, which personalizes the application itself and has access to its actuators.
Join the DZone community and get the full member experience.
Join For FreeMany years ago, one of my favorite former colleagues left the company where we worked together, and he wrote a goodbye email. I've seen a few, but this was the most imaginative of them all. He wrote it in the form of a Jenkins notification email about a failed Maven build. If you read the build logs carefully, you could see that the build failed because my colleague left the company.
This memory gave me the idea of what an application would say about itself if we could ask.
- How does the application feel about the operating system it runs on?
- Is there enough space for it on the hard drive?
- In general, how would the application describe itself?
So, I made a small experiment on a Spring Boot application with the help of the LangChain4j library and a GPT4o chat model. I've created a small application with a management endpoint, which you can use to communicate with an AI assistant that personalizes the application itself. All the below-described snippets can be found here on GitHub.
Knowledge
Normally, if we personalize an assistant as an application, it will not know anything about its technical details.
As this is a Spring Boot application, the most obvious thing to use are the out-of-the-box available actuators for this purpose.
You can easily define tools for such an assistant, but if you make the results of some actuators available through these tools, you get an assistant with whom you can chat about the current status of the application.
To use the results of the health, info, and metrics actuators, at first, you need to enable these in your application.properties file.
management.endpoints.web.exposure.include=dialogue,health,info,metrics
management.info.build.enabled=true
management.info.java.enabled=true
management.info.os.enabled=true
management.info.env.enabled=true
management.info.process.enabled=true
Note: The extra dialogue endpoint is where you can communicate with the assistant, and as you can see, I've enabled some of the InfoContributors.
To gather the information from these actuators, I've created a collector class for each of them. The below class is used to get the health information from all the available health indicators:
public class HealthCollector implements DialogueDataCollector {
private static final Logger LOGGER = LoggerFactory.getLogger(HealthCollector.class);
private final Map<String, HealthIndicator> healthIndicators;
private final ObjectMapper objectMapper;
public HealthCollector(Map<String, HealthIndicator> healthIndicators, ObjectMapper objectMapper) {
this.healthIndicators = healthIndicators;
this.objectMapper = objectMapper;
}
@Override
public Optional<String> collect() {
try {
var aggregatedHealthResponses = new AggregatedHealthResponse(healthIndicators.entrySet()
.stream()
.collect(Collectors.toMap(Map.Entry::getKey, entry -> entry.getValue().getHealth(true))));
var result = objectMapper.writeValueAsString(aggregatedHealthResponses);
if (LOGGER.isDebugEnabled()) {
LOGGER.debug("The collected health information:{}", result);
}
return Optional.of(result);
} catch (Exception e) {
LOGGER.error("Collecting health information failed: ", e);
return Optional.empty();
}
}
private record AggregatedHealthResponse(Map<String, Health> healths) {
}
}
This one is responsible for gathering general information from the enabled InfoContributors
:
public class InfoCollector implements DialogueDataCollector {
private static final Logger LOGGER = LoggerFactory.getLogger(InfoCollector.class);
private final Map<String, InfoContributor> infoContributors;
private final ObjectMapper objectMapper;
public InfoCollector(Map<String, InfoContributor> infoContributors, ObjectMapper objectMapper) {
this.infoContributors = infoContributors;
this.objectMapper = objectMapper;
}
@Override
public Optional<String> collect() {
try {
var infoBuilder = new Info.Builder();
infoContributors.forEach((name, contributor) -> contributor.contribute(infoBuilder));
var info = infoBuilder.build();
var result = objectMapper.writeValueAsString(info);
if (LOGGER.isDebugEnabled()) {
LOGGER.debug("The collected general information:{}", result);
}
return Optional.of(result);
} catch (Exception e) {
LOGGER.error("Collecting info from InfoContributors failed: ", e);
return Optional.empty();
}
}
}
And the last one, which collects the metrics directly from the MetricsEndpoint
:
public class MetricsCollector implements DialogueDataCollector {
private static final Logger LOGGER = LoggerFactory.getLogger(MetricsCollector.class);
private final MetricsEndpoint metricsEndpoint;
private final ObjectMapper objectMapper;
public MetricsCollector(MetricsEndpoint metricsEndpoint, ObjectMapper objectMapper) {
this.metricsEndpoint = metricsEndpoint;
this.objectMapper = objectMapper;
}
@Override
public Optional<String> collect() {
try {
var metricNames = metricsEndpoint.listNames().getNames();
List<MetricsEndpoint.MetricDescriptor> metricDescriptors = new ArrayList<>(metricNames.size());
metricNames.forEach(name -> metricDescriptors.add(metricsEndpoint.metric(name, null)));
var aggregatedMetricResponses = new AggregatedMetricResponse(metricDescriptors);
var result = objectMapper.writeValueAsString(aggregatedMetricResponses);
if (LOGGER.isDebugEnabled()) {
LOGGER.debug("The collected metrics information:{}", result);
}
return Optional.of(result);
} catch (Exception e) {
LOGGER.error("Collecting metrics failed: ", e);
return Optional.empty();
}
}
private record AggregatedMetricResponse(List<MetricsEndpoint.MetricDescriptor> metrics) {
}
}
One way of creating tools that you can configure for an assistant in LangChain4j is to create ToolExecutors
for them:
@Bean("healthCollectorToolExecutor")
public ToolExecutor healthCollectorToolExecutor(HealthCollector healthCollector) {
return (toolExecutionRequest, memoryId) -> {
if(LOGGER.isDebugEnabled()) {
LOGGER.debug("Executing health tool with request: {}", toolExecutionRequest);
}
return healthCollector.collect()
.orElse("There is no available health information!");
};
}
@Bean("infoCollectorToolExecutor")
public ToolExecutor infoCollectorToolExecutor(InfoCollector infoCollector) {
return (toolExecutionRequest, memoryId) -> {
if(LOGGER.isDebugEnabled()){
LOGGER.debug("Executing info tool with request: {}", toolExecutionRequest);
}
return infoCollector.collect()
.orElse("There is no available general information!");
};
}
@Bean("metricsCollectorToolExecutor")
public ToolExecutor metricsCollectorToolExecutor(MetricsCollector metricsCollector) {
return (toolExecutionRequest, memoryId) -> {
if(LOGGER.isDebugEnabled()){
LOGGER.debug("Executing metrics tool with request: {}", toolExecutionRequest);
}
return metricsCollector.collect()
.orElse("There is no available metrics information!");
};
}
The Assistant
In such a simple use case (when you just send a message and wait for a string response), the interface of our assistant can look like this:
public interface ApplicationAssistant {
@UserMessage("{{message}}")
String send(@V("message") String message);
}
We need to configure the ChatLanguageModel
(in this case, to a GPT4-o model):
@Bean
ChatLanguageModel chatLanguageModel(AiConfigurationProperties aiConfigurationProperties) {
return OpenAiChatModel.builder()
.apiKey(aiConfigurationProperties.apiKey())
.modelName(OpenAiChatModelName.GPT_4_O)
.temperature(0.5)
.logRequests(true)
.logResponses(true)
.build();
}
And the most important part is the assistant configuration, where:
- We define a system message to personalize it as the application.
- We configure the created
ToolExecutors
to it with a short description. - We add a simple in-memory
ChatMemory
, that turns the communication into a real conversation.
@Bean
ApplicationAssistant applicationAssistant(ChatLanguageModel chatLanguageModel,
@Qualifier("healthCollectorToolExecutor") ToolExecutor healthCollectorToolExecutor,
@Qualifier("infoCollectorToolExecutor") ToolExecutor infoCollectorToolExecutor,
@Qualifier("metricsCollectorToolExecutor") ToolExecutor metricsCollectorToolExecutor) {
return AiServices
.builder(ApplicationAssistant.class)
.chatLanguageModel(chatLanguageModel)
.systemMessageProvider(memoryId -> """
You are an application called dialogue-sense.
Answer in a human way how you feel mentally or physically.
You can get information about yourself through the provided tools.
Although these are technical information, try transform them to your current feelings.
""")
.tools(Map.of(
ToolSpecification.builder()
.name("healthCollectorTool")
.description("This tool provides information about your health or the health of your most important components.")
.build(), healthCollectorToolExecutor,
ToolSpecification.builder()
.name("infoCollectorTool")
.description("This tool provides general information about you.")
.build(), infoCollectorToolExecutor,
ToolSpecification.builder()
.name("metricsCollectorTool")
.description("This tool provides information about the metrics of you.")
.build(), metricsCollectorToolExecutor
))
.chatMemory(TokenWindowChatMemory.builder()
.chatMemoryStore(new InMemoryChatMemoryStore())
.maxTokens(20000, new OpenAiTokenizer(OpenAiChatModelName.GPT_4_O))
.build())
.build();
}
Endpoint
After these, we need to reach an endpoint where this assistant can be tested. For this purpose, I defined a dialogue actuator endpoint.
@Component
@WebEndpoint(id = "dialogue")
public class DialogueManagementEndpoint {
private final ApplicationAssistant assistant;
public DialogueManagementEndpoint(ApplicationAssistant assistant) {
this.assistant = assistant;
}
@WriteOperation
ResponseEntity<String> sendMessage(String message) {
return ResponseEntity.ok(assistant.send(message));
}
}
How It Works
Now, we could try it and POST some messages to our application, but before we do that, I configured two extra HealthIndicators
.
One is for a dummy user service that is always healthy, and one is for a dummy order service that is always out of service to see if the assistant can see them through the dedicated health tool.
@Bean
HealthIndicator userServiceHealthIndicator() {
return new HealthIndicator() {
@Override
public Health getHealth(boolean includeDetails) {
return HealthIndicator.super.getHealth(includeDetails);
}
@Override
public Health health() {
return Health
.up()
.build();
}
};
}
@Bean
HealthIndicator orderServiceHealthIndicator() {
return new HealthIndicator() {
@Override
public Health getHealth(boolean includeDetails) {
return HealthIndicator.super.getHealth(includeDetails);
}
@Override
public Health health() {
return Health
.outOfService()
.build();
}
};
}
I also created a GreetingController
to see if, by calling the controller a few times, the assistant could tell me something about the received requests based on the metrics tool.
@RestController
public class GreetingController {
@GetMapping("/greeting")
public ResponseEntity<String> greeting(@RequestParam(value = "name", defaultValue = "World") String name) {
return ResponseEntity.ok(String.format("Hello %s!", name));
}
}
Now we can try it. At first, I wanted to know how the application would describe itself.
What does it think about its current OS?
I asked about its health.
I tried to find out if it needs anything.
And finally, after calling the GreetingController
a few times, I asked about the requests.
Conclusion
I hope you enjoyed this little experiment. Although I know that the application in this form has little business value, it's not hard to think further. What if, instead of a chat endpoint where you can just chat unnecessarily, you create a scheduled task that periodically checks the actuators with the help of an AI and reacts to them if it detects something important? This could turn the AI into a proactive assistant, making applications smarter and more autonomous.
Published at DZone with permission of Tamas Kiss. See the original article here.
Opinions expressed by DZone contributors are their own.
Comments