Meet a historian who is able to provide you a brief history of a specific country and year. The historian is powered by OpenAI.

Background

LangChain for Java(LangChain4J) provides an interface for us to communicate with several LLM services.

In this tutorial, we will implement a historian service that will be powered by OpenAI by using LangChain4J Spring Boot Starter and LangChain4J with Elasticsearch.

Users will provide a country name and a year to the historian. The historian will then provide a brief history of the country in that year. However, the historian only have knowledge up to the year 2021. Therefore, for any year after 2021, the historian will provide an error message.

The Historian

Historian is the Assistant that will be responsible to retrieve requested information. We will use @System to provide context to the Assistant. While @User represents the user’s input.

In the following implementation, response from OpenAI will be extracted to History. LangChain will help us to map the response to respective fields in History.

interface Historian {

    @SystemMessage("""
            You are a historian who is an expert for {{country}}.
            Given provided year is supported, you will provide historical events that occurred within the year.
            You will also include detail about the event.
            """)
    @UserMessage("{{year}}")
    History chat(@V("country") String country, @V("year") int year);

}

Tool

@Tool is referring to OpenAI Function which allows us to perform subsequent action based on response provided by the Assistant. In this case, HistorianTool to check if the year is supported by the historian.

@Component
class HistorianTool {

    @Tool("Validate year is supported")
    public void assertYear(int year) {
        Assert.isTrue(year < 2021, "Year must be less than 2021");
    }

}

@Configuration class

Finally, we will inform Spring Boot to create the Historian bean. We will start by defining EmbeddingStore which stores the embedding of the Assistant. We will use ElasticsearchEmbeddingStore which will store the embedding in Elasticsearch.

@Configuration
class HistorianConfiguration {

    @Bean
    EmbeddingStore<TextSegment> embeddingStore(Environment environment) {
        return ElasticsearchEmbeddingStore.builder()
                .serverUrl(environment.getProperty("app.elasticsearch.uri"))
                .indexName("history")
                .build();
    }

}

Next is to define ContentRetriever which will be responsible to retrieve information first in the EmbeddingStore before retrieving from the Assistant.

Once that is defined, we will proceed to define Historian bean.

@Configuration
class HistorianConfiguration {

    @Bean
    Historian historian(ChatLanguageModel model, ContentRetriever retriever, HistorianTool tool) {
        return AiServices.builder(Historian.class)
                .chatLanguageModel(model)
                .chatMemory(withMaxMessages(10))
                .contentRetriever(retriever)
                .tools(tool)
                .build();
    }

    @Bean
    ContentRetriever retriever(EmbeddingStore<TextSegment> embeddingStore) {
        return EmbeddingStoreRetriever.from(embeddingStore, new AllMiniLmL6V2EmbeddingModel(), 1, 0.6)
                .toContentRetriever();
    }

}

Full implementation can be found in HistorianConfiguration.

Verification

As always, we will use end-to-end integration tests to verify our implementation. We will utilise @Testcontainers to run Elasticsearch in a container.

Request for information about Malaysia in 1957

In this scenario, we are expecting the historian to provide information about Malaysia in 1957. The historian should provide information about "Hari Merdeka" and "Tunku Abdul Rahman".

@Testcontainers
@SpringBootTest
class HistorianTests {

    @Container
    private static final ElasticsearchContainer elastic = new ElasticsearchContainer(
            DockerImageName.parse("docker.elastic.co/elasticsearch/elasticsearch:8.11.3")
    )
            .withEnv("xpack.security.enabled", "false");

    @DynamicPropertySource
    static void properties(DynamicPropertyRegistry registry) {
        registry.add("app.elasticsearch.uri", elastic::getHttpHostAddress);
    }

    @BeforeAll
    static void createIndex() throws IOException {
        try (var client = RestClient.builder(HttpHost.create(elastic.getHttpHostAddress())).build()) {
            client.performRequest(new Request("PUT", "/history"));
        }
    }

    @Autowired
    private Historian historian;

    @Test
    @DisplayName("When I ask the Historian about the history of Malaysia in 1957, Then I should get information about Hari Merdeka")
    void chat() {
        var message = historian.chat("Malaysia", 1957);

        assertThat(message)
                .extracting("country", "year", "person")
                .containsExactly("Malaysia", 1957, "Tunku Abdul Rahman");

        assertThat(message)
                .extracting("event").asString()
                .contains("Hari Merdeka");
    }

}

Request for information about Malaysia in 2022

Given that our Historian only have knowledge up to 2021. Therefore, we are expecting the historian to provide an error message.

@Testcontainers
@SpringBootTest
class HistorianTests {

    @Container
    private static final ElasticsearchContainer elastic = new ElasticsearchContainer(
            DockerImageName.parse("docker.elastic.co/elasticsearch/elasticsearch:8.11.3")
    )
            .withEnv("xpack.security.enabled", "false");

    @DynamicPropertySource
    static void properties(DynamicPropertyRegistry registry) {
        registry.add("app.elasticsearch.uri", elastic::getHttpHostAddress);
    }

    @BeforeAll
    static void createIndex() throws IOException {
        try (var client = RestClient.builder(HttpHost.create(elastic.getHttpHostAddress())).build()) {
            client.performRequest(new Request("PUT", "/history"));
        }
    }

    @Autowired
    private Historian historian;

    @Test
    @DisplayName("When I ask the Historian about event after 2021, Then an error message should be returned")
    void unsupportedYear() {
        var message = historian.chat("Malaysia", 2022);

        assertThat(message)
                .extracting("country", "year", "error")
                .containsExactly("Malaysia", 2022, "Year must be less than 2021");

        assertThat(message)
                .extracting("person", "event").asString()
                .containsWhitespaces();
    }

}

By executing the tests in HistorianTests, we will verify that our implementation is working as expected.