Skip to content

Commit 3589428

Browse files
authored
Ollama
1 parent 99bb094 commit 3589428

File tree

23 files changed

+1035
-184
lines changed

23 files changed

+1035
-184
lines changed

.github/workflows/ci.yml

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
branches: [ main ]
6+
pull_request:
7+
branches: [ main ]
8+
9+
jobs:
10+
build:
11+
name: build
12+
runs-on: ubuntu-latest
13+
steps:
14+
15+
- name: Checkout
16+
uses: actions/checkout@v4
17+
18+
- name: Ollama cache
19+
uses: actions/cache@v4
20+
with:
21+
path: ./chat-server/.ollama
22+
key: ollama-${{ runner.os }}
23+
restore-keys: |
24+
ollama-${{ runner.os }}
25+
26+
- name: Set up Java
27+
uses: actions/setup-java@v4
28+
with:
29+
java-version: 21
30+
distribution: temurin
31+
32+
- name: Build mcp-server
33+
working-directory: mcp-server
34+
run: ./gradlew build --no-daemon
35+
36+
- name: Build chat-server
37+
working-directory: chat-server
38+
run: ./gradlew build --no-daemon
39+
40+
- name: Ollama cache permissions
41+
run: sudo chown -R $USER:$USER ./chat-server/.ollama

README.md

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
# Spring Boot AI
2+
3+
Inspired by [Building Agents with AWS: Complete Tutorial (Java, Spring AI, Amazon Bedrock & MCP)](https://youtu.be/Y291afdLroQ?si=3xFBJo0Nfa-RmPkV) and [spring-ai-java-bedrock-mcp-rag](https://github.com/aws-samples/Sample-Model-Context-Protocol-Demos/tree/main/modules/spring-ai-java-bedrock-mcp-rag)
4+
5+
## Run locally
6+
7+
1. Start MCP server
8+
```shell
9+
cd mcp-server
10+
./gradlew bootRun
11+
```
12+
13+
2. Start docker compose
14+
```shell
15+
cd chat-server
16+
docker compose up -d
17+
```
18+
19+
3. Start Chat Server
20+
```shell
21+
cd chat-server
22+
./gradlew bootRun
23+
```
24+
25+
4. Execute queries
26+
27+
```shell
28+
curl -X POST "http://localhost:8080/2/chat" \
29+
-H "Content-Type: application/x-www-form-urlencoded" \
30+
-d "question=I want to go to a city with a beach. Where should I go?"
31+
```
32+
33+
```shell
34+
curl -X POST "http://localhost:8080/2/chat" \
35+
-H "Content-Type: application/x-www-form-urlencoded" \
36+
-d "question=How is the weather like in Madrid for the weekend?"
37+
```
38+
39+
## Documentation
40+
41+
* [Spring Boot AI](https://docs.spring.io/spring-ai/reference/index.html)
42+
*

chat-server/.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
.idea
22
.kotlin
33
.gradle
4+
.ollama
45
build

chat-server/build.gradle.kts

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
import org.gradle.api.tasks.testing.logging.TestLogEvent.FAILED
2+
import org.gradle.api.tasks.testing.logging.TestLogEvent.PASSED
3+
import org.gradle.api.tasks.testing.logging.TestLogEvent.SKIPPED
4+
15
plugins {
26
val kotlinVersion = "2.1.20"
37
kotlin("jvm") version kotlinVersion
@@ -20,20 +24,20 @@ repositories {
2024
}
2125

2226
val springAiVersion = "1.0.0-M6"
23-
val flywayVersion = "11.6.0"
2427

2528
dependencies {
2629
implementation("org.springframework.ai:spring-ai-mcp-client-spring-boot-starter")
27-
implementation("org.springframework.ai:spring-ai-bedrock-converse-spring-boot-starter")
28-
implementation("org.springframework.ai:spring-ai-bedrock-ai-spring-boot-starter")
30+
31+
// ollama
32+
implementation("org.springframework.ai:spring-ai-ollama-spring-boot-starter")
33+
34+
// bedrock
35+
// implementation("org.springframework.ai:spring-ai-bedrock-converse-spring-boot-starter")
36+
// implementation("org.springframework.ai:spring-ai-bedrock-ai-spring-boot-starter")
2937

3038
implementation("org.springframework.ai:spring-ai-pgvector-store-spring-boot-starter")
3139
runtimeOnly("org.postgresql:postgresql")
3240

33-
implementation("org.flywaydb:flyway-core:$flywayVersion")
34-
implementation("org.flywaydb:flyway-database-postgresql:$flywayVersion")
35-
36-
implementation("org.springframework.boot:spring-boot-starter-data-jdbc")
3741
implementation("org.springframework.boot:spring-boot-starter-web")
3842

3943
implementation("org.jetbrains.kotlin:kotlin-reflect")
@@ -44,6 +48,12 @@ dependencies {
4448
testImplementation("org.junit.jupiter:junit-jupiter")
4549
testImplementation("org.junit.jupiter:junit-jupiter-params")
4650
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
51+
52+
testImplementation("org.mockito:mockito-core:5.17.0")
53+
testImplementation("org.mockito:mockito-junit-jupiter:5.17.0")
54+
testImplementation("com.nhaarman.mockitokotlin2:mockito-kotlin:2.2.0")
55+
56+
testImplementation("org.testcontainers:junit-jupiter:1.20.6")
4757
}
4858

4959
dependencyManagement {
@@ -60,4 +70,7 @@ kotlin {
6070

6171
tasks.withType<Test> {
6272
useJUnitPlatform()
73+
testLogging {
74+
events(PASSED, SKIPPED, FAILED)
75+
}
6376
}

chat-server/docker-compose.yml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,10 @@ services:
77
- POSTGRES_PASSWORD=password
88
ports:
99
- "5432:5432"
10+
11+
ollama:
12+
image: ollama/ollama:0.6.5
13+
volumes:
14+
- ./.ollama:/root/.ollama
15+
ports:
16+
- "11434:11434"

chat-server/requests.http

Lines changed: 0 additions & 11 deletions
This file was deleted.

chat-server/src/main/kotlin/com/rogervinas/ChatServerApplication.kt

Lines changed: 29 additions & 99 deletions
Original file line numberDiff line numberDiff line change
@@ -1,121 +1,51 @@
11
package com.rogervinas
22

3-
import io.modelcontextprotocol.client.McpClient
4-
import io.modelcontextprotocol.client.McpSyncClient
5-
import io.modelcontextprotocol.client.transport.HttpClientSseClientTransport
6-
import org.springframework.ai.chat.client.ChatClient
7-
import org.springframework.ai.chat.client.advisor.PromptChatMemoryAdvisor
8-
import org.springframework.ai.chat.client.advisor.QuestionAnswerAdvisor
9-
import org.springframework.ai.chat.memory.InMemoryChatMemory
3+
import com.fasterxml.jackson.databind.ObjectMapper
4+
import org.slf4j.LoggerFactory
105
import org.springframework.ai.document.Document
11-
import org.springframework.ai.mcp.SyncMcpToolCallbackProvider
126
import org.springframework.ai.vectorstore.VectorStore
13-
import org.springframework.beans.factory.annotation.Value
147
import org.springframework.boot.ApplicationRunner
158
import org.springframework.boot.autoconfigure.SpringBootApplication
169
import org.springframework.boot.runApplication
1710
import org.springframework.context.annotation.Bean
1811
import org.springframework.context.annotation.Configuration
19-
import org.springframework.data.annotation.Id
20-
import org.springframework.data.repository.ListCrudRepository
21-
import org.springframework.stereotype.Controller
22-
import org.springframework.web.bind.annotation.PathVariable
23-
import org.springframework.web.bind.annotation.PostMapping
24-
import org.springframework.web.bind.annotation.RequestParam
25-
import org.springframework.web.bind.annotation.ResponseBody
26-
import java.util.concurrent.ConcurrentHashMap
12+
import org.springframework.core.io.ClassPathResource
13+
import org.springframework.jdbc.core.JdbcTemplate
2714

2815
@SpringBootApplication
2916
class ChatServerApplication
3017

3118
@Configuration
32-
class ConversationalConfiguration {
33-
@Bean
34-
fun mcpClient(@Value("\${mcp-server.url}") url: String) = McpClient
35-
.sync(HttpClientSseClientTransport(url))
36-
.build().apply {
37-
initialize()
38-
}
39-
40-
@Bean
41-
fun chatClient(
42-
mcpSyncClient: McpSyncClient,
43-
builder: ChatClient.Builder
44-
): ChatClient {
45-
val system = """
46-
You are an AI powered assistant to help people adopt a dog from the adoption
47-
agency named Pooch Palace with locations in Atlanta, Antwerp, Seoul, Tokyo, Singapore, Paris,
48-
Mumbai, New Delhi, Barcelona, San Francisco, and London. Information about the dogs available
49-
will be presented below. If there is no information, then return a polite response suggesting we
50-
don't have any dogs available.
51-
52-
If the response involves a timestamp, be sure to convert it to something human-readable.
53-
54-
Do _not_ include any indication of what you're thinking. Nothing should be sent to the client between <thinking> tags.
55-
Just give the answer.
56-
57-
""".trimIndent()
58-
return builder
59-
.defaultSystem(system)
60-
.defaultTools(SyncMcpToolCallbackProvider(mcpSyncClient))
61-
.build()
62-
}
63-
}
64-
65-
interface DogRepository : ListCrudRepository<Dog, Int>
66-
67-
data class Dog(@Id val id: Int, val name: String, val owner: String?, val description: String)
19+
class VectorStoreConfiguration {
6820

69-
@Controller
70-
@ResponseBody
71-
class ConversationalController(vectorStore: VectorStore, private val chatClient: ChatClient) {
72-
private val questionAnswerAdvisor = QuestionAnswerAdvisor(vectorStore)
73-
private val chatMemory = ConcurrentHashMap<String, PromptChatMemoryAdvisor>()
74-
75-
@PostMapping("/{id}/inquire")
76-
fun inquire(@PathVariable id: String, @RequestParam question: String): String? {
77-
val promptChatMemoryAdvisor = chatMemory
78-
.computeIfAbsent(id) { _: String -> PromptChatMemoryAdvisor.builder(InMemoryChatMemory()).build() }
79-
return chatClient
80-
.prompt()
81-
.user(question)
82-
.advisors(questionAnswerAdvisor, promptChatMemoryAdvisor)
83-
.call()
84-
.content()
85-
}
86-
}
87-
88-
@Configuration
89-
class DogDataInitializerConfiguration {
21+
data class City(val name: String, val country: String, val description: String)
9022

9123
@Bean
92-
fun initializerRunner(vectorStore: VectorStore, dogRepository: DogRepository): ApplicationRunner {
93-
return ApplicationRunner {
94-
dogRepository.deleteAll()
95-
if (dogRepository.count() == 0L) {
96-
println("initializing vector store");
97-
var map = mapOf(
98-
"Rocky" to "A Boxer that needs to always say 'hi' and play with everybody he sees",
99-
"Jasper" to "A grey Shih Tzu known for being protective.",
100-
"Toby" to "A grey Doberman known for being playful.",
101-
"Nala" to "A spotted German Shepherd known for being loyal.",
102-
"Penny" to "A white Great Dane known for being protective.",
103-
"Bella" to "A golden Poodle known for being calm.",
104-
"Willow" to "A brindle Great Dane known for being calm.",
105-
"Daisy" to "A spotted Poodle known for being affectionate.",
106-
"Mia" to "A grey Great Dane known for being loyal.",
107-
"Molly" to "A golden Chihuahua known for being curious.",
108-
"Prancer" to "A demonic, neurotic, man hating, animal hating, children hating dogs that look like gremlins."
109-
)
110-
map.forEach { name, description ->
111-
var dog = dogRepository.save(Dog(0, name, null, description));
112-
var dogument = Document("id: ${dog.id}, name: ${dog.name}, description: ${dog.description}")
113-
vectorStore.add(listOf(dogument));
114-
}
115-
println("finished initializing vector store")
24+
fun vectorStoreInitializer(
25+
vectorStore: VectorStore,
26+
jdbcTemplate: JdbcTemplate,
27+
objectMapper: ObjectMapper
28+
) = ApplicationRunner {
29+
val logger = LoggerFactory.getLogger(ChatServerApplication::class.java)
30+
val vectorStoreCount = vectorStoreCount(jdbcTemplate)
31+
if (vectorStoreCount == 0) {
32+
logger.info("Initializing vector store ...")
33+
val cities = ClassPathResource("cities.json").inputStream.use {
34+
objectMapper.readValue(it, Array<City>::class.java).toList()
35+
}
36+
cities.forEach { city ->
37+
logger.info("Adding ${city.name} to vector store ...")
38+
val document = Document("name: ${city.name} country: ${city.country} description: ${city.description}")
39+
vectorStore.add(listOf(document))
11640
}
117-
};
41+
logger.info("Vector store initialized with ${cities.size} cities")
42+
} else {
43+
logger.info("Vector store already contains $vectorStoreCount cities")
44+
}
11845
}
46+
47+
private fun vectorStoreCount(jdbcTemplate: JdbcTemplate) =
48+
jdbcTemplate.queryForObject("SELECT COUNT(*) FROM vector_store", Int::class.java)
11949
}
12050

12151
fun main(args: Array<String>) {
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
package com.rogervinas.chat
2+
3+
import org.springframework.ai.chat.client.ChatClient
4+
import org.springframework.ai.tool.ToolCallbackProvider
5+
import org.springframework.context.annotation.Bean
6+
import org.springframework.context.annotation.Configuration
7+
8+
9+
@Configuration
10+
class ChatClientConfiguration {
11+
@Bean
12+
fun chatClient(
13+
builder: ChatClient.Builder,
14+
toolCallbackProviders: List<ToolCallbackProvider>
15+
): ChatClient {
16+
return chatClientBuilder(builder, toolCallbackProviders).build()
17+
}
18+
19+
private fun chatClientBuilder(
20+
builder: ChatClient.Builder,
21+
toolCallbackProviders: List<ToolCallbackProvider>
22+
): ChatClient.Builder {
23+
val system = """
24+
You are an AI powered assistant to help people book accommodation in touristic cities around the world.
25+
If there is no information, then return a polite response suggesting you don't know.
26+
If the response involves a timestamp, be sure to convert it to something human-readable.
27+
Do not include any indication of what you're thinking.
28+
Use the tools available to you to answer the questions.
29+
Just give the answer.
30+
""".trimIndent()
31+
return builder
32+
.defaultSystem(system)
33+
.defaultTools(*toolCallbackProviders.toTypedArray())
34+
}
35+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
package com.rogervinas.chat
2+
3+
import org.springframework.web.bind.annotation.PathVariable
4+
import org.springframework.web.bind.annotation.PostMapping
5+
import org.springframework.web.bind.annotation.RequestParam
6+
import org.springframework.web.bind.annotation.RestController
7+
8+
@RestController
9+
class ChatController(private val chatService: ChatService) {
10+
11+
@PostMapping("/{chatId}/chat")
12+
fun chat(@PathVariable chatId: String, @RequestParam question: String): String? {
13+
return chatService.chat(chatId, question)
14+
}
15+
}

0 commit comments

Comments
 (0)