Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add example implementing historian service with LangChain #73

Merged
merged 17 commits into from
Nov 1, 2023
5 changes: 4 additions & 1 deletion .github/workflows/gradle.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,10 @@ jobs:
uses: gradle/gradle-build-action@v2
- name: Run build with Gradle wrapper
id: gradle
run: ./gradlew build --scan
env:
LANGCHAIN4J_CHAT_MODEL_OPENAI_API_KEY: "${{ secrets.LANGCHAIN4J_CHAT_MODEL_OPENAI_API_KEY }}"
run: |
./gradlew build --scan
- name: "Add Build Scan URL as PR comment"
uses: actions/github-script@v6
if: github.event_name == 'pull_request' && failure()
Expand Down
1 change: 1 addition & 0 deletions README.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ separate project.
|link:data-mongodb-transactional[Spring Data MongoDB: Transactional] |Enable `@Transactional` support for Spring Data MongoDB
|link:data-rest-validation[Spring Data REST: Validation] |Perform validation with Spring Data REST
|link:graphql[Spring GraphQL Server] |Implement GraphQL server with Spring GrapQL Server
|link:langchain4j[LangChain4J] | Implement a Historian powered by OpenAI using https://github.com/langchain4j[LangChain4J]
|link:data-mongodb-tc-data-load[Spring Test: Load data with Testcontainers] |Load test data with Testcontainers instead of `BeforeEach`
|link:test-execution-listeners[Spring Test: Test Execution Listeners] |Implement custom `TestExecutionListener` to manage data in tests
|link:test-rest-assured[Spring Test: Integration with RestAssured] | Implement Behaviour Driven Development with https://rest-assured.io/[RestAssured]
Expand Down
37 changes: 37 additions & 0 deletions langchain4j/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
HELP.md
.gradle
build/
!gradle/wrapper/gradle-wrapper.jar
!**/src/main/**/build/
!**/src/test/**/build/

### STS ###
.apt_generated
.classpath
.factorypath
.project
.settings
.springBeans
.sts4-cache
bin/
!**/src/main/**/bin/
!**/src/test/**/bin/

### IntelliJ IDEA ###
.idea
*.iws
*.iml
*.ipr
out/
!**/src/main/**/out/
!**/src/test/**/out/

### NetBeans ###
/nbproject/private/
/nbbuild/
/dist/
/nbdist/
/.nb-gradle/

### VS Code ###
.vscode/
232 changes: 232 additions & 0 deletions langchain4j/README.adoc
Original file line number Diff line number Diff line change
@@ -0,0 +1,232 @@
= LangChain4J Spring Boot Starter: Implement a Historian Service Powered by OpenAI
:source-highlighter: highlightjs
:source-language: java
Rashidi Zin <rashidi@zin.my>
1.0, November 01, 2023: Initial version
:toc:
:icons: font
:url-quickref: https://github.com/rashidi/spring-boot-tutorials/tree/master/langchain4j

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

https://github.com/langchain4j[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 https://openai.com/[OpenAI] by using
https://github.com/langchain4j/langchain4j/tree/main/langchain4j-spring-boot-starter[LangChain4J Spring Boot Starter]
and https://github.com/langchain4j/langchain4j/tree/main/langchain4j-elasticsearch[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

link:{url-quickref}/src/main/java/zin/rashidi/boot/langchain4j/history/Historian.java[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
link:src/main/java/zin/rashidi/boot/langchain4j/history/History.java[History]. `LangChain` will help us to map the
response to respective fields in `History`.

[source,java]
----
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, link:{url-quickref}/src/main/java/zin/rashidi/boot/langchain4j/history/HistorianTool.java[HistorianTool]
to check if the year is supported by the historian.

[source,java]
----
@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.

[source,java]
----
@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 `Retriever` 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.

[source,java]
----
@Configuration
class HistorianConfiguration {

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

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

}
----

Full implementation can be found in link:{url-quickref}/src/main/java/zin/rashidi/boot/langchain4j/history/HistorianConfiguration.java[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".

[source,java]
----
@Testcontainers
@SpringBootTest
class HistorianTests {

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

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

if (getenv("LANGCHAIN4J_CHAT_MODEL_OPENAI_API_KEY") != null) {
registry.add("langchain4j.chat-model.openai.api-key", () -> getenv("LANGCHAIN4J_CHAT_MODEL_OPENAI_API_KEY"));
}
}

@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.

[source,java]
----
@Testcontainers
@SpringBootTest
class HistorianTests {

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

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

if (getenv("LANGCHAIN4J_CHAT_MODEL_OPENAI_API_KEY") != null) {
registry.add("langchain4j.chat-model.openai.api-key", () -> getenv("LANGCHAIN4J_CHAT_MODEL_OPENAI_API_KEY"));
}
}

@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 link:src/test/java/zin/rashidi/boot/langchain4j/history/HistorianTests.java[HistorianTests], we
will verify that our implementation is working as expected.
46 changes: 46 additions & 0 deletions langchain4j/build.gradle
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
plugins {
id 'java'
id 'org.springframework.boot' version '3.1.5'
id 'io.spring.dependency-management' version '1.1.3'
}

group = 'zin.rashidi.boot'
version = '0.0.1-SNAPSHOT'

java {
sourceCompatibility = '17'
}

configurations {
compileOnly {
extendsFrom annotationProcessor
}
}

repositories {
mavenCentral()
}

dependencies {
implementation 'dev.langchain4j:langchain4j-elasticsearch'
implementation 'dev.langchain4j:langchain4j-embeddings-all-minilm-l6-v2'
implementation 'dev.langchain4j:langchain4j-spring-boot-starter'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.springframework.boot:spring-boot-testcontainers'
testImplementation 'org.testcontainers:elasticsearch'
testImplementation 'org.testcontainers:junit-jupiter'
}

dependencyManagement {
dependencies {
dependencySet(group: 'dev.langchain4j', version: '0.23.0') {
entry 'langchain4j-elasticsearch'
entry 'langchain4j-embeddings-all-minilm-l6-v2'
entry 'langchain4j-spring-boot-starter'
}
}
}

tasks.named('test') {
useJUnitPlatform()
}
1 change: 1 addition & 0 deletions langchain4j/settings.gradle
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
rootProject.name = 'langchain4j'
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package zin.rashidi.boot.langchain4j;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class Langchain4jApplication {

public static void main(String[] args) {
SpringApplication.run(Langchain4jApplication.class, args);
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package zin.rashidi.boot.langchain4j.history;

import dev.langchain4j.service.SystemMessage;
import dev.langchain4j.service.UserMessage;
import dev.langchain4j.service.V;

/**
* @author Rashidi Zin
*/
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);

}
Loading