diff --git a/samples/pom.xml b/samples/pom.xml
index 529e06ce6..c2acc9c41 100644
--- a/samples/pom.xml
+++ b/samples/pom.xml
@@ -20,6 +20,7 @@
review-triagesecure-fraud-detectionsecure-poem-multiple-models
+ secure-sql-chatbotsql-chatbot
diff --git a/samples/secure-fraud-detection/src/main/java/io/quarkiverse/langchain4j/sample/LogoutResource.java b/samples/secure-fraud-detection/src/main/java/io/quarkiverse/langchain4j/sample/LogoutResource.java
index 7db2ad4c9..19660a95f 100644
--- a/samples/secure-fraud-detection/src/main/java/io/quarkiverse/langchain4j/sample/LogoutResource.java
+++ b/samples/secure-fraud-detection/src/main/java/io/quarkiverse/langchain4j/sample/LogoutResource.java
@@ -24,6 +24,6 @@ public Response logout(@Context UriInfo uriInfo) {
// remove the local session cookie
session.logout().await().indefinitely();
// redirect to the login page
- return Response.seeOther(uriInfo.getBaseUriBuilder().path("login").build()).build();
+ return Response.seeOther(uriInfo.getBaseUriBuilder().build()).build();
}
}
diff --git a/samples/secure-sql-chatbot/README.md b/samples/secure-sql-chatbot/README.md
new file mode 100644
index 000000000..358337982
--- /dev/null
+++ b/samples/secure-sql-chatbot/README.md
@@ -0,0 +1,101 @@
+# Secure chatbot using advanced RAG and a SQL database
+
+## Secure Movie Muse
+
+This example demonstrates how to create a secure chatbot with RAG using
+`quarkus-langchain4j`. This chatbot internally uses LLM-generated SQL
+queries to retrieve the relevant information from a PostgreSQL database.
+
+### Setup
+
+The demo requires that your Google account's full name and email are configured.
+You can use system or env properties, see `Running the example` section below.
+
+When the application starts, a registered user, the movie watcher, is allocated a random preferred movie genre .
+
+The setup is defined in the [Setup.java](./src/main/java/io/quarkiverse/langchain4j/samples/chatbot/Setup.java) class.
+
+The registered movie watchers are stored in a PostgreSQL database. When running the demo in dev mode (recommended), the database is automatically created and populated.
+
+The genre preferred by the registered movie watcher is used by a Movie `ContentRetriever` to sort the list of movies.
+
+## Running the example
+
+Users must authenticate with Google before they can start working with the Movie Muse.
+They also must register their OpenAI API key.
+
+### Google Authentication
+
+In order to register an application with Google, follow steps listed in the [Quarkus Google](https://quarkus.io/guides/security-openid-connect-providers#google) section.
+Name your Google application as `Quarkus LangChain4j AI`, and make sure an allowed callback URL is set to `https://localhost:8443/login`.
+Google will generate a client id and secret, use them to set `quarkus.oidc.client-id` and `quarkus.oidc.credentials.secret` properties.
+
+### OpenAI API key
+
+You must provide your OpenAI API key:
+
+```
+export QUARKUS_LANGCHAIN4J_OPENAI_API_KEY=
+```
+
+Then, simply run the project in Dev mode:
+
+```shell
+mvn quarkus:dev -Dname="Firstname Familyname" -Demail=someuser@gmail.com
+```
+
+Note, you should use double quotes to register your Google account's full name.
+
+## Using the example
+
+Open your browser and navigate to `https://localhost:8443`, accept the demo certificate.
+Now, choose a `Login with Google` option.
+Once logged in, click an orange robot icon in the bottom right corner to open a chat window.
+Ask questions such as `Give me movies with a rating higher than 8`.
+
+The chatbot is available at the secure `wss:` scheme.
+
+It uses a SQL database with information about movies with their
+basic metadata (the database is populated with data from
+`src/main/resources/data/movies.csv` at startup). When you ask a question, an
+LLM is used to generate SQL queries necessary for answering your question.
+Check the application's log, the SQL queries and the retrieved data will be
+printed there.
+
+The chatbot will refer to you by your name during the conversation.
+
+## Security Considerations
+
+### HTTPS and WSS
+
+This demo requires the use of secure HTTPS and WSS WebSockets protocols only.
+
+### Chatbot is accessible to authenticated users only
+
+Only users who have authenticated with Google will see a page which contains a chatbot icon.
+It eliminates a risk of non-authenticated users attempting to trick LLM.
+
+### CORS
+
+Even though browsers do not enforce Single Origin Policy (SOP) for WebSockets HTTP upgrade requests, enabling
+CORS origin check can add an extra protection in combination with verifying the expected authentication credentials.
+
+For example, attackers can set `Origin` themselves but they will not have the HTTPS bound authentication session cookie
+which can be used to authenticate a WSS WebSockets upgrade request.
+Or if the authenticated user is tricked into visiting an unfriendly web site, then a WSS WebSockets upgrade request will fail
+at the Quarkus CORS check level.
+
+### Custom WebSocket ticket system
+
+In addition to requiring authentication over secure HTTPS, in combination with the CORS constraint for
+the HTTP upgrade to succeed, this demo shows a simple WebSockets ticket system to verify that the current HTTP upgrade request
+was made from the page allocated to authenticated users only.
+
+When the user completes the OpenId Connect Google authentication, a dynamically generated HTML page will contain a WSS HTTP upgrade link
+with a randomly generated ticket added at the authentication time, for example: `wss://localhost/chatbot?ticket=random_ticket_value`.
+
+HTTP upgrade checker will ensure that a matching ticket is found before permitting an upgrade.
+
+### User identity is visible to AI services and tools
+
+AI service and tools can access an ID `JsonWebToken` token which represents a successful user authentication and use it to complete its work.
diff --git a/samples/secure-sql-chatbot/pom.xml b/samples/secure-sql-chatbot/pom.xml
new file mode 100644
index 000000000..16c90f548
--- /dev/null
+++ b/samples/secure-sql-chatbot/pom.xml
@@ -0,0 +1,200 @@
+
+
+ 4.0.0
+
+ io.quarkiverse.langchain4j
+ quarkus-langchain4j-sample-secure-sql-chatbot
+ Quarkus LangChain4j - Sample - Secure Chatbot & RAG from a SQL database
+ 1.0-SNAPSHOT
+
+
+ 3.13.0
+ true
+ 17
+ UTF-8
+ UTF-8
+ quarkus-bom
+ io.quarkus
+ 3.15.1
+ true
+ 3.2.5
+ 0.21.0
+
+
+
+
+
+ ${quarkus.platform.group-id}
+ ${quarkus.platform.artifact-id}
+ ${quarkus.platform.version}
+ pom
+ import
+
+
+
+
+
+
+ io.quarkus
+ quarkus-rest-jackson
+
+
+ io.quarkus
+ quarkus-websockets-next
+
+
+ io.quarkus
+ quarkus-oidc
+
+
+ io.quarkus
+ quarkus-jdbc-postgresql
+
+
+ io.quarkus
+ quarkus-hibernate-orm
+
+
+ io.quarkus
+ quarkus-hibernate-orm-panache
+
+
+ io.quarkus
+ quarkus-rest-qute
+
+
+ io.quarkiverse.langchain4j
+ quarkus-langchain4j-openai
+ ${quarkus-langchain4j.version}
+
+
+ com.fasterxml.jackson.dataformat
+ jackson-dataformat-csv
+
+
+
+
+
+ io.mvnpm
+ importmap
+ 1.0.11
+
+
+ org.mvnpm
+ lit
+ 3.2.0
+ runtime
+
+
+ org.mvnpm
+ wc-chatbot
+ 0.2.0
+ runtime
+
+
+
+
+ io.quarkiverse.langchain4j
+ quarkus-langchain4j-openai-deployment
+ ${quarkus-langchain4j.version}
+ test
+ pom
+
+
+ *
+ *
+
+
+
+
+
+
+
+ io.quarkus
+ quarkus-maven-plugin
+ ${quarkus.platform.version}
+
+
+
+ build
+
+
+
+
+
+ maven-compiler-plugin
+ ${compiler-plugin.version}
+
+
+ maven-surefire-plugin
+ 3.5.1
+
+
+ org.jboss.logmanager.LogManager
+ ${maven.home}
+
+
+
+
+
+
+
+
+ native
+
+
+ native
+
+
+
+
+
+ maven-failsafe-plugin
+ 3.5.1
+
+
+
+ integration-test
+ verify
+
+
+
+
+ ${project.build.directory}/${project.build.finalName}-runner
+
+ org.jboss.logmanager.LogManager
+
+ ${maven.home}
+
+
+
+
+
+
+
+
+ native
+
+
+
+
+ mvnpm
+
+
+ central
+ central
+ https://repo.maven.apache.org/maven2
+
+
+
+ false
+
+ mvnpm.org
+ mvnpm
+ https://repo.mvnpm.org/maven2
+
+
+
+
+
+
diff --git a/samples/secure-sql-chatbot/src/main/java/io/quarkiverse/langchain4j/sample/chatbot/ChatBotWebSocket.java b/samples/secure-sql-chatbot/src/main/java/io/quarkiverse/langchain4j/sample/chatbot/ChatBotWebSocket.java
new file mode 100644
index 000000000..874f627fe
--- /dev/null
+++ b/samples/secure-sql-chatbot/src/main/java/io/quarkiverse/langchain4j/sample/chatbot/ChatBotWebSocket.java
@@ -0,0 +1,51 @@
+package io.quarkiverse.langchain4j.sample.chatbot;
+
+import org.eclipse.microprofile.jwt.Claims;
+import org.eclipse.microprofile.jwt.JsonWebToken;
+
+import io.quarkus.logging.Log;
+import io.quarkus.oidc.IdToken;
+import io.quarkus.security.Authenticated;
+import jakarta.inject.Inject;
+
+import io.quarkus.websockets.next.OnOpen;
+import io.quarkus.websockets.next.OnTextMessage;
+import io.quarkus.websockets.next.WebSocket;
+
+@WebSocket(path = "/chatbot")
+@Authenticated
+public class ChatBotWebSocket {
+
+ private final MovieMuse bot;
+
+ @Inject
+ @IdToken
+ JsonWebToken idToken;
+
+ public ChatBotWebSocket(MovieMuse bot) {
+ this.bot = bot;
+ }
+
+ @OnOpen
+ public String onOpen() {
+ return "Hello, " + idToken.getName() + ", I'm MovieMuse, how can I help you?";
+ }
+
+ @OnTextMessage
+ public String onMessage(String message) {
+ try {
+ return idToken.getName() + ", " + bot.chat(message);
+ } catch (MissingMovieWatcherException ex) {
+ Log.error(ex);
+ return """
+ Sorry, %s, looks like you did not register your name and email correctly.
+ Please use '-Dname="%s"' (keep double quotes around your name) and '-Demail=%s' system properties at startup
+ """
+ .formatted(idToken.getName(), idToken.getName(), idToken.getClaim(Claims.email));
+ } catch (Throwable ex) {
+ Log.error(ex);
+ return "Sorry, " + idToken.getName()
+ + ", an unexpected error occurred, can you please ask your question again ?";
+ }
+ }
+}
diff --git a/samples/secure-sql-chatbot/src/main/java/io/quarkiverse/langchain4j/sample/chatbot/CsvIngestorExample.java b/samples/secure-sql-chatbot/src/main/java/io/quarkiverse/langchain4j/sample/chatbot/CsvIngestorExample.java
new file mode 100644
index 000000000..575a541f4
--- /dev/null
+++ b/samples/secure-sql-chatbot/src/main/java/io/quarkiverse/langchain4j/sample/chatbot/CsvIngestorExample.java
@@ -0,0 +1,45 @@
+package io.quarkiverse.langchain4j.sample.chatbot;
+
+import java.io.File;
+import java.io.IOException;
+import java.util.List;
+
+import jakarta.enterprise.event.Observes;
+
+import org.eclipse.microprofile.config.inject.ConfigProperty;
+import org.hibernate.StatelessSession;
+import org.hibernate.Transaction;
+
+import com.fasterxml.jackson.databind.MappingIterator;
+import com.fasterxml.jackson.dataformat.csv.CsvMapper;
+import com.fasterxml.jackson.dataformat.csv.CsvSchema;
+
+import io.quarkus.logging.Log;
+import io.quarkus.runtime.StartupEvent;
+
+public class CsvIngestorExample {
+
+ private final File file;
+ private final StatelessSession session;
+
+ public CsvIngestorExample(@ConfigProperty(name = "csv.file") File file,
+ StatelessSession session) {
+ this.file = file;
+ this.session = session;
+ }
+
+ public void ingest(@Observes StartupEvent event) throws IOException {
+ try (MappingIterator it = new CsvMapper()
+ .readerFor(Movie.class)
+ .with(CsvSchema.emptySchema().withHeader())
+ .readValues(file)) {
+ List movies = it.readAll();
+ Transaction transaction = session.beginTransaction();
+ for (Movie movie : movies) {
+ session.insert(movie);
+ }
+ transaction.commit();
+ Log.infof("Ingested %d movies.%n", movies.size());
+ }
+ }
+}
diff --git a/samples/secure-sql-chatbot/src/main/java/io/quarkiverse/langchain4j/sample/chatbot/ImportmapResource.java b/samples/secure-sql-chatbot/src/main/java/io/quarkiverse/langchain4j/sample/chatbot/ImportmapResource.java
new file mode 100644
index 000000000..f8270adee
--- /dev/null
+++ b/samples/secure-sql-chatbot/src/main/java/io/quarkiverse/langchain4j/sample/chatbot/ImportmapResource.java
@@ -0,0 +1,54 @@
+package io.quarkiverse.langchain4j.sample.chatbot;
+
+import io.mvnpm.importmap.model.Imports;
+import io.quarkus.runtime.annotations.RegisterForReflection;
+import jakarta.annotation.PostConstruct;
+import jakarta.enterprise.context.ApplicationScoped;
+import jakarta.ws.rs.GET;
+import jakarta.ws.rs.Path;
+import jakarta.ws.rs.Produces;
+
+import io.mvnpm.importmap.Aggregator;
+
+/**
+ * Dynamically create the import map
+ */
+@ApplicationScoped
+@Path("/_importmap")
+@RegisterForReflection(targets = {Aggregator.class, Imports.class})
+public class ImportmapResource {
+ private String importmap;
+
+ // See https://github.com/WICG/import-maps/issues/235
+ // This does not seem to be supported by browsers yet...
+ @GET
+ @Path("/dynamic.importmap")
+ @Produces("application/importmap+json")
+ public String importMap() {
+ return this.importmap;
+ }
+
+ @GET
+ @Path("/dynamic-importmap.js")
+ @Produces("application/javascript")
+ public String importMapJson() {
+ return JAVASCRIPT_CODE.formatted(this.importmap);
+ }
+
+ @PostConstruct
+ void init() {
+ Aggregator aggregator = new Aggregator();
+ // Add our own mappings
+ aggregator.addMapping("icons/", "/icons/");
+ aggregator.addMapping("components/", "/components/");
+ aggregator.addMapping("fonts/", "/fonts/");
+ this.importmap = aggregator.aggregateAsJson();
+ }
+
+ private static final String JAVASCRIPT_CODE = """
+ const im = document.createElement('script');
+ im.type = 'importmap';
+ im.textContent = JSON.stringify(%s);
+ document.currentScript.after(im);
+ """;
+}
diff --git a/samples/secure-sql-chatbot/src/main/java/io/quarkiverse/langchain4j/sample/chatbot/LoginResource.java b/samples/secure-sql-chatbot/src/main/java/io/quarkiverse/langchain4j/sample/chatbot/LoginResource.java
new file mode 100644
index 000000000..26427fea0
--- /dev/null
+++ b/samples/secure-sql-chatbot/src/main/java/io/quarkiverse/langchain4j/sample/chatbot/LoginResource.java
@@ -0,0 +1,35 @@
+package io.quarkiverse.langchain4j.sample.chatbot;
+import org.eclipse.microprofile.jwt.JsonWebToken;
+
+import io.quarkus.oidc.IdToken;
+import io.quarkus.qute.Template;
+import io.quarkus.qute.TemplateInstance;
+import io.quarkus.security.Authenticated;
+import jakarta.inject.Inject;
+
+import jakarta.ws.rs.GET;
+import jakarta.ws.rs.Path;
+import jakarta.ws.rs.Produces;
+
+/**
+ * Login resource which returns a movie muse page to the authenticated user
+ */
+@Path("/login")
+@Authenticated
+public class LoginResource {
+ @Inject
+ @IdToken
+ JsonWebToken idToken;
+
+ @Inject
+ WebSocketTickets tickets;
+
+ @Inject
+ Template movieMuse;
+
+ @GET
+ @Produces("text/html")
+ public TemplateInstance movieMuse() {
+ return movieMuse.data("name", idToken.getName()).data("ticket", tickets.getTicket());
+ }
+}
diff --git a/samples/secure-sql-chatbot/src/main/java/io/quarkiverse/langchain4j/sample/chatbot/LogoutResource.java b/samples/secure-sql-chatbot/src/main/java/io/quarkiverse/langchain4j/sample/chatbot/LogoutResource.java
new file mode 100644
index 000000000..ca96e1baa
--- /dev/null
+++ b/samples/secure-sql-chatbot/src/main/java/io/quarkiverse/langchain4j/sample/chatbot/LogoutResource.java
@@ -0,0 +1,29 @@
+package io.quarkiverse.langchain4j.sample.chatbot;
+
+import io.quarkus.oidc.OidcSession;
+import io.quarkus.security.Authenticated;
+import jakarta.inject.Inject;
+import jakarta.ws.rs.GET;
+import jakarta.ws.rs.Path;
+import jakarta.ws.rs.core.Context;
+import jakarta.ws.rs.core.Response;
+import jakarta.ws.rs.core.UriInfo;
+
+/**
+ * Logout resource
+ */
+@Path("/logout")
+@Authenticated
+public class LogoutResource {
+
+ @Inject
+ OidcSession session;
+
+ @GET
+ public Response logout(@Context UriInfo uriInfo) {
+ // remove the local session cookie
+ session.logout().await().indefinitely();
+ // redirect to the login page
+ return Response.seeOther(uriInfo.getBaseUriBuilder().build()).build();
+ }
+}
diff --git a/samples/secure-sql-chatbot/src/main/java/io/quarkiverse/langchain4j/sample/chatbot/MissingMovieWatcherException.java b/samples/secure-sql-chatbot/src/main/java/io/quarkiverse/langchain4j/sample/chatbot/MissingMovieWatcherException.java
new file mode 100644
index 000000000..02af0341e
--- /dev/null
+++ b/samples/secure-sql-chatbot/src/main/java/io/quarkiverse/langchain4j/sample/chatbot/MissingMovieWatcherException.java
@@ -0,0 +1,4 @@
+package io.quarkiverse.langchain4j.sample.chatbot;
+
+public class MissingMovieWatcherException extends RuntimeException {
+}
diff --git a/samples/secure-sql-chatbot/src/main/java/io/quarkiverse/langchain4j/sample/chatbot/Movie.java b/samples/secure-sql-chatbot/src/main/java/io/quarkiverse/langchain4j/sample/chatbot/Movie.java
new file mode 100644
index 000000000..3d5606335
--- /dev/null
+++ b/samples/secure-sql-chatbot/src/main/java/io/quarkiverse/langchain4j/sample/chatbot/Movie.java
@@ -0,0 +1,135 @@
+package io.quarkiverse.langchain4j.sample.chatbot;
+
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.GeneratedValue;
+import jakarta.persistence.Id;
+import jakarta.persistence.Table;
+
+import org.hibernate.annotations.Comment;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+
+@Entity
+@Table(name = "movie", schema = "public")
+public class Movie {
+
+ @Id
+ @GeneratedValue
+ private int id;
+
+ @Column(name = "index")
+ private int index;
+
+ @Column(name = "movie_name")
+ @JsonProperty("movie_name")
+ private String movieName;
+
+ @Column(name = "year_of_release")
+ @JsonProperty("year_of_release")
+ private int yearOfRelease;
+
+ @Column(name = "category")
+ private String category;
+
+ @Column(name = "run_time")
+ @JsonProperty("run_time")
+ @Comment("in minutes")
+ private int runTime;
+
+ @Column(name = "genre")
+ @Comment("this is a comma-separated list of genres")
+ private String genre;
+
+ @Column(name = "imdb_rating")
+ @JsonProperty("imdb_rating")
+ private float imdbRating;
+
+ @Column(name = "votes")
+ private Integer votes;
+
+ @Column(name = "gross_total")
+ @JsonProperty("gross_total")
+ @Comment("in millions of US dollars")
+ private float grossTotal;
+
+ public int getId() {
+ return id;
+ }
+
+ public void setId(int id) {
+ this.id = id;
+ }
+
+ public int getIndex() {
+ return index;
+ }
+
+ public void setIndex(int index) {
+ this.index = index;
+ }
+
+ public String getMovieName() {
+ return movieName;
+ }
+
+ public void setMovieName(String movieName) {
+ this.movieName = movieName;
+ }
+
+ public int getYearOfRelease() {
+ return yearOfRelease;
+ }
+
+ public void setYearOfRelease(int yearOfRelease) {
+ this.yearOfRelease = yearOfRelease;
+ }
+
+ public String getCategory() {
+ return category;
+ }
+
+ public void setCategory(String category) {
+ this.category = category;
+ }
+
+ public int getRunTime() {
+ return runTime;
+ }
+
+ public void setRunTime(int runTime) {
+ this.runTime = runTime;
+ }
+
+ public String getGenre() {
+ return genre;
+ }
+
+ public void setGenre(String genre) {
+ this.genre = genre;
+ }
+
+ public float getImdbRating() {
+ return imdbRating;
+ }
+
+ public void setImdbRating(float imdbRating) {
+ this.imdbRating = imdbRating;
+ }
+
+ public Integer getVotes() {
+ return votes;
+ }
+
+ public void setVotes(Integer votes) {
+ this.votes = votes;
+ }
+
+ public float getGrossTotal() {
+ return grossTotal;
+ }
+
+ public void setGrossTotal(float grossTotal) {
+ this.grossTotal = grossTotal;
+ }
+}
diff --git a/samples/secure-sql-chatbot/src/main/java/io/quarkiverse/langchain4j/sample/chatbot/MovieDatabaseContentRetriever.java b/samples/secure-sql-chatbot/src/main/java/io/quarkiverse/langchain4j/sample/chatbot/MovieDatabaseContentRetriever.java
new file mode 100644
index 000000000..0329a4330
--- /dev/null
+++ b/samples/secure-sql-chatbot/src/main/java/io/quarkiverse/langchain4j/sample/chatbot/MovieDatabaseContentRetriever.java
@@ -0,0 +1,76 @@
+package io.quarkiverse.langchain4j.sample.chatbot;
+
+import java.sql.Connection;
+import java.sql.ResultSet;
+import java.sql.SQLException;
+import java.sql.Statement;
+import java.util.ArrayList;
+import java.util.List;
+
+import javax.sql.DataSource;
+
+import org.eclipse.microprofile.jwt.Claims;
+import org.eclipse.microprofile.jwt.JsonWebToken;
+
+import dev.langchain4j.rag.content.Content;
+import dev.langchain4j.rag.content.retriever.ContentRetriever;
+import dev.langchain4j.rag.query.Query;
+import io.quarkus.logging.Log;
+import io.quarkus.oidc.IdToken;
+import io.quarkus.security.Authenticated;
+import io.vertx.core.json.JsonObject;
+import jakarta.enterprise.context.ApplicationScoped;
+import jakarta.inject.Inject;
+
+@ApplicationScoped
+@Authenticated
+public class MovieDatabaseContentRetriever implements ContentRetriever {
+
+ @Inject
+ MovieMuseSupport support;
+
+ @Inject
+ DataSource dataSource;
+
+ @Inject
+ MovieWatcherRepository movieWatchers;
+
+ @Inject
+ @IdToken
+ JsonWebToken idToken;
+
+ @Override
+ public List retrieve(Query query) {
+ String question = query.text();
+ String preferredGenre = movieWatchers.findPreferredGenre(idToken.getName(), idToken.getClaim(Claims.email));
+ Log.infof("%s prefers the %s movies", idToken.getName(), preferredGenre);
+
+ String sqlQuery = support.createSqlQuery(question, MovieSchemaSupport.getSchemaString(), preferredGenre);
+ if (sqlQuery.contains("```sql")) { // strip the formatting if it's there
+ sqlQuery = sqlQuery.substring(sqlQuery.indexOf("```sql") + 6, sqlQuery.lastIndexOf("```"));
+ }
+ Log.infof("%s asked a question %s: ", idToken.getName(), question);
+ Log.info("Supporting SQL query: " + sqlQuery);
+ List results = new ArrayList<>();
+ Log.info("Retrieved relevant movie data: ");
+ try (Connection connection = dataSource.getConnection()) {
+ try (Statement statement = connection.createStatement()) {
+ try (ResultSet resultSet = statement.executeQuery(sqlQuery)) {
+ while (resultSet.next()) {
+ JsonObject json = new JsonObject();
+ int columnCount = resultSet.getMetaData().getColumnCount();
+ for (int i = 1; i <= columnCount; i++) {
+ String columnName = resultSet.getMetaData().getColumnName(i);
+ json.put(columnName, resultSet.getObject(i));
+ }
+ Log.info("Item: " + json);
+ results.add(Content.from(json.toString()));
+ }
+ }
+ }
+ } catch (SQLException e) {
+ throw new RuntimeException(e);
+ }
+ return results;
+ }
+}
diff --git a/samples/secure-sql-chatbot/src/main/java/io/quarkiverse/langchain4j/sample/chatbot/MovieMuse.java b/samples/secure-sql-chatbot/src/main/java/io/quarkiverse/langchain4j/sample/chatbot/MovieMuse.java
new file mode 100644
index 000000000..a5c682d4d
--- /dev/null
+++ b/samples/secure-sql-chatbot/src/main/java/io/quarkiverse/langchain4j/sample/chatbot/MovieMuse.java
@@ -0,0 +1,18 @@
+package io.quarkiverse.langchain4j.sample.chatbot;
+
+import dev.langchain4j.service.SystemMessage;
+import dev.langchain4j.service.UserMessage;
+import io.quarkiverse.langchain4j.RegisterAiService;
+import jakarta.enterprise.context.SessionScoped;
+
+@RegisterAiService(retrievalAugmentor = MovieMuseRetrievalAugmentor.class)
+@SessionScoped
+public interface MovieMuse {
+
+ @SystemMessage("""
+ You are MovieMuse, an AI answering questions about the top 100 movies from IMDB.
+ Your response must be polite, use the same language as the question, and be relevant to the question.
+ Don't use any knowledge that is not in the database.
+ """)
+ String chat(@UserMessage String question);
+}
diff --git a/samples/secure-sql-chatbot/src/main/java/io/quarkiverse/langchain4j/sample/chatbot/MovieMuseRetrievalAugmentor.java b/samples/secure-sql-chatbot/src/main/java/io/quarkiverse/langchain4j/sample/chatbot/MovieMuseRetrievalAugmentor.java
new file mode 100644
index 000000000..5882d4196
--- /dev/null
+++ b/samples/secure-sql-chatbot/src/main/java/io/quarkiverse/langchain4j/sample/chatbot/MovieMuseRetrievalAugmentor.java
@@ -0,0 +1,46 @@
+package io.quarkiverse.langchain4j.sample.chatbot;
+
+import java.util.function.Supplier;
+
+import org.eclipse.microprofile.context.ManagedExecutor;
+
+import jakarta.enterprise.context.ApplicationScoped;
+import jakarta.inject.Inject;
+
+import dev.langchain4j.model.chat.ChatLanguageModel;
+import dev.langchain4j.rag.DefaultRetrievalAugmentor;
+import dev.langchain4j.rag.RetrievalAugmentor;
+import dev.langchain4j.rag.query.transformer.CompressingQueryTransformer;
+
+@ApplicationScoped
+public class MovieMuseRetrievalAugmentor implements Supplier {
+
+ @Inject
+ ChatLanguageModel model;
+
+ @Inject
+ MovieDatabaseContentRetriever contentRetriever;
+
+ @Inject
+ ManagedExecutor executor;
+
+ @Override
+ public RetrievalAugmentor get() {
+ return DefaultRetrievalAugmentor.builder()
+ // The compressing transformer compresses the history of
+ // chat messages to add the necessary context
+ // to the current query. For example, if the user asks
+ // "What is the length of Inception?" and then the next query is
+ // "When was it released?", then the transformer will compress
+ // the second query to something like "Release year of Inception",
+ // which is then used by the content retriever to generate
+ // the necessary SQL query.
+ .queryTransformer(new CompressingQueryTransformer(model))
+ // The content retriever takes a user's question (including
+ // the context provided by the transformer in the previous step), asks a LLM
+ // to generate a SQL query from it, then executes the query and
+ // provides the resulting data back to the LLM, so it can
+ // generate a proper text response from it.
+ .contentRetriever(contentRetriever).executor(executor).build();
+ }
+}
diff --git a/samples/secure-sql-chatbot/src/main/java/io/quarkiverse/langchain4j/sample/chatbot/MovieMuseSupport.java b/samples/secure-sql-chatbot/src/main/java/io/quarkiverse/langchain4j/sample/chatbot/MovieMuseSupport.java
new file mode 100644
index 000000000..b7b64e123
--- /dev/null
+++ b/samples/secure-sql-chatbot/src/main/java/io/quarkiverse/langchain4j/sample/chatbot/MovieMuseSupport.java
@@ -0,0 +1,33 @@
+package io.quarkiverse.langchain4j.sample.chatbot;
+
+import jakarta.inject.Singleton;
+
+import dev.langchain4j.service.SystemMessage;
+import dev.langchain4j.service.UserMessage;
+import io.quarkiverse.langchain4j.RegisterAiService;
+
+@RegisterAiService
+@Singleton
+public interface MovieMuseSupport {
+
+ @SystemMessage("""
+ Create a SQL query to retrieve the data necessary to
+ answer the user's question using data from the database.
+ The database contains information about top rated movies from IMDB.
+ The dialect is PostgreSQL and the relevant table is called 'movie'.
+ Always include `movie_name` in the SELECT clause.
+
+ The user might have not provided the movie name exactly, in that case
+ try to correct it to the official movie name, or match it using a LIKE clause.
+
+ The table has the following columns:
+ {schemaStr}
+
+ Sort the list of movies by the genre preferred by the movie watcher:
+ {preferredGenre}
+
+ Answer only with the query and nothing else.
+ """)
+ String createSqlQuery(@UserMessage String question, String schemaStr, String preferredGenre);
+
+}
diff --git a/samples/secure-sql-chatbot/src/main/java/io/quarkiverse/langchain4j/sample/chatbot/MovieSchemaSupport.java b/samples/secure-sql-chatbot/src/main/java/io/quarkiverse/langchain4j/sample/chatbot/MovieSchemaSupport.java
new file mode 100644
index 000000000..5d9beb9e5
--- /dev/null
+++ b/samples/secure-sql-chatbot/src/main/java/io/quarkiverse/langchain4j/sample/chatbot/MovieSchemaSupport.java
@@ -0,0 +1,12 @@
+package io.quarkiverse.langchain4j.sample.chatbot;
+
+public class MovieSchemaSupport {
+
+ public static String getSchemaString() {
+ String columnsStr = MovieTableIntegrator.schemaStr;
+ if (columnsStr.isEmpty()) {
+ throw new IllegalStateException("MovieSchemaSupport#getSchemaString called too early");
+ }
+ return columnsStr;
+ }
+}
diff --git a/samples/secure-sql-chatbot/src/main/java/io/quarkiverse/langchain4j/sample/chatbot/MovieTableIntegrator.java b/samples/secure-sql-chatbot/src/main/java/io/quarkiverse/langchain4j/sample/chatbot/MovieTableIntegrator.java
new file mode 100644
index 000000000..8ac91037d
--- /dev/null
+++ b/samples/secure-sql-chatbot/src/main/java/io/quarkiverse/langchain4j/sample/chatbot/MovieTableIntegrator.java
@@ -0,0 +1,59 @@
+package io.quarkiverse.langchain4j.sample.chatbot;
+
+import java.util.Collection;
+
+import org.hibernate.boot.Metadata;
+import org.hibernate.boot.spi.BootstrapContext;
+import org.hibernate.engine.spi.SessionFactoryImplementor;
+import org.hibernate.integrator.spi.Integrator;
+import org.hibernate.mapping.Column;
+import org.hibernate.mapping.PersistentClass;
+import org.hibernate.mapping.Table;
+import org.hibernate.service.spi.SessionFactoryServiceRegistry;
+
+public class MovieTableIntegrator implements Integrator {
+
+ static volatile String schemaStr = "";
+
+ @Override
+ public void integrate(
+ Metadata metadata,
+ BootstrapContext bootstrapContext,
+ SessionFactoryImplementor sessionFactory) {
+ PersistentClass moviePC = null;
+ for (PersistentClass entityBinding : metadata.getEntityBindings()) {
+ if (Movie.class.getName().equals(entityBinding.getClassName())) {
+ moviePC = entityBinding;
+ break;
+ }
+ }
+ if (moviePC == null) {
+ throw new IllegalStateException("Unable to determine metadata of Movie");
+ }
+
+ Table table = moviePC.getTable();
+ Collection columns = table.getColumns();
+ StringBuilder result = new StringBuilder();
+ boolean first = true;
+ for (Column column : columns) {
+ if (first) {
+ first = false;
+ } else {
+ result.append("\n - ");
+ }
+ StringBuilder sb = new StringBuilder(column.getName()).append(" (").append(column.getSqlType(metadata))
+ .append(")");
+ if (column.getComment() != null) {
+ sb.append(" - ").append(column.getComment());
+ }
+ result.append(sb);
+ }
+ schemaStr = result.toString();
+ }
+
+ @Override
+ public void disintegrate(SessionFactoryImplementor sessionFactory,
+ SessionFactoryServiceRegistry serviceRegistry) {
+
+ }
+}
diff --git a/samples/secure-sql-chatbot/src/main/java/io/quarkiverse/langchain4j/sample/chatbot/MovieWatcher.java b/samples/secure-sql-chatbot/src/main/java/io/quarkiverse/langchain4j/sample/chatbot/MovieWatcher.java
new file mode 100644
index 000000000..097022346
--- /dev/null
+++ b/samples/secure-sql-chatbot/src/main/java/io/quarkiverse/langchain4j/sample/chatbot/MovieWatcher.java
@@ -0,0 +1,16 @@
+package io.quarkiverse.langchain4j.sample.chatbot;
+
+import jakarta.persistence.Entity;
+import jakarta.persistence.GeneratedValue;
+import jakarta.persistence.Id;
+
+@Entity
+public class MovieWatcher {
+
+ @Id
+ @GeneratedValue
+ public Long id;
+ public String name;
+ public String email;
+ public String preferredGenre;
+}
diff --git a/samples/secure-sql-chatbot/src/main/java/io/quarkiverse/langchain4j/sample/chatbot/MovieWatcherConfig.java b/samples/secure-sql-chatbot/src/main/java/io/quarkiverse/langchain4j/sample/chatbot/MovieWatcherConfig.java
new file mode 100644
index 000000000..6d74da799
--- /dev/null
+++ b/samples/secure-sql-chatbot/src/main/java/io/quarkiverse/langchain4j/sample/chatbot/MovieWatcherConfig.java
@@ -0,0 +1,11 @@
+package io.quarkiverse.langchain4j.sample.chatbot;
+
+import io.smallrye.config.ConfigMapping;
+
+@ConfigMapping(prefix = "movie.watcher")
+public interface MovieWatcherConfig {
+
+ String name();
+
+ String email();
+}
diff --git a/samples/secure-sql-chatbot/src/main/java/io/quarkiverse/langchain4j/sample/chatbot/MovieWatcherRepository.java b/samples/secure-sql-chatbot/src/main/java/io/quarkiverse/langchain4j/sample/chatbot/MovieWatcherRepository.java
new file mode 100644
index 000000000..c187e52ba
--- /dev/null
+++ b/samples/secure-sql-chatbot/src/main/java/io/quarkiverse/langchain4j/sample/chatbot/MovieWatcherRepository.java
@@ -0,0 +1,16 @@
+package io.quarkiverse.langchain4j.sample.chatbot;
+
+import io.quarkus.hibernate.orm.panache.PanacheRepository;
+import jakarta.enterprise.context.ApplicationScoped;
+
+@ApplicationScoped
+public class MovieWatcherRepository implements PanacheRepository {
+
+ public String findPreferredGenre(String movieWatcherName, String movieWatcherEmail) {
+ MovieWatcher movieWatcher = find("name = ?1 and email = ?2", movieWatcherName, movieWatcherEmail).firstResult();
+ if (movieWatcher == null) {
+ throw new MissingMovieWatcherException();
+ }
+ return movieWatcher.preferredGenre;
+ }
+}
diff --git a/samples/secure-sql-chatbot/src/main/java/io/quarkiverse/langchain4j/sample/chatbot/Setup.java b/samples/secure-sql-chatbot/src/main/java/io/quarkiverse/langchain4j/sample/chatbot/Setup.java
new file mode 100644
index 000000000..e0dc4f596
--- /dev/null
+++ b/samples/secure-sql-chatbot/src/main/java/io/quarkiverse/langchain4j/sample/chatbot/Setup.java
@@ -0,0 +1,39 @@
+package io.quarkiverse.langchain4j.sample.chatbot;
+
+import java.util.List;
+import java.util.Random;
+
+import io.quarkus.logging.Log;
+import io.quarkus.runtime.StartupEvent;
+import jakarta.enterprise.context.ApplicationScoped;
+import jakarta.enterprise.event.Observes;
+import jakarta.inject.Inject;
+import jakarta.transaction.Transactional;
+
+@ApplicationScoped
+public class Setup {
+
+ public static List GENRES = List.of("Crime", "Drama", "Thriller", "Action", "Adventure", "Fantasy",
+ "Horror", "Romance", "Sci-Fi", "Biography", "History", "Mystery", "War", "Animation",
+ "Comedy");
+
+ static String getARandomGenre() {
+ return GENRES.get(new Random().nextInt(GENRES.size()));
+ }
+
+ @Inject
+ MovieWatcherConfig config;
+
+ @Transactional
+ public void init(@Observes StartupEvent ev, MovieWatcherRepository movieWatchers) {
+ movieWatchers.deleteAll();
+
+ var movieWatcher = new MovieWatcher();
+ movieWatcher.name = config.name();
+ movieWatcher.email = config.email();
+ movieWatcher.preferredGenre = getARandomGenre();
+ movieWatchers.persist(movieWatcher);
+
+ Log.infof("Movie watcher name: %s, email: %s, preferred genre: %s", movieWatcher.name, movieWatcher.email, movieWatcher.preferredGenre);
+ }
+}
diff --git a/samples/secure-sql-chatbot/src/main/java/io/quarkiverse/langchain4j/sample/chatbot/TicketHttpUpgradeCheck.java b/samples/secure-sql-chatbot/src/main/java/io/quarkiverse/langchain4j/sample/chatbot/TicketHttpUpgradeCheck.java
new file mode 100644
index 000000000..b3579b541
--- /dev/null
+++ b/samples/secure-sql-chatbot/src/main/java/io/quarkiverse/langchain4j/sample/chatbot/TicketHttpUpgradeCheck.java
@@ -0,0 +1,32 @@
+package io.quarkiverse.langchain4j.sample.chatbot;
+
+import io.quarkus.websockets.next.HttpUpgradeCheck;
+import io.smallrye.mutiny.Uni;
+import jakarta.enterprise.context.ApplicationScoped;
+import jakarta.inject.Inject;
+import io.quarkus.logging.Log;
+
+@ApplicationScoped
+public class TicketHttpUpgradeCheck implements HttpUpgradeCheck {
+
+ @Inject
+ WebSocketTickets tickets;
+
+ @Override
+ public Uni perform(HttpUpgradeContext context) {
+ String ticket = context.httpRequest().getParam("ticket");
+ if (ticket == null) {
+ Log.warn("WebSocket ticket is null");
+ return Uni.createFrom().item(CheckResult.rejectUpgradeSync(401));
+ }
+ if (tickets.removeTicket(ticket) == null) {
+ Log.warnf("WebSocket ticket %s is invalid", ticket);
+ return Uni.createFrom().item(CheckResult.rejectUpgradeSync(401));
+ }
+ // If the ticket was backed up by a ticket cookie then it can be checked here as
+ // well.
+ Log.infof("WebSocket ticket %s is valid", ticket);
+ return Uni.createFrom().item(CheckResult.permitUpgradeSync());
+ }
+
+}
diff --git a/samples/secure-sql-chatbot/src/main/java/io/quarkiverse/langchain4j/sample/chatbot/WebSocketTickets.java b/samples/secure-sql-chatbot/src/main/java/io/quarkiverse/langchain4j/sample/chatbot/WebSocketTickets.java
new file mode 100644
index 000000000..3b54c2eac
--- /dev/null
+++ b/samples/secure-sql-chatbot/src/main/java/io/quarkiverse/langchain4j/sample/chatbot/WebSocketTickets.java
@@ -0,0 +1,24 @@
+package io.quarkiverse.langchain4j.sample.chatbot;
+
+import java.util.UUID;
+import java.util.concurrent.ConcurrentHashMap;
+
+import io.quarkus.logging.Log;
+import jakarta.enterprise.context.ApplicationScoped;
+
+@ApplicationScoped
+public class WebSocketTickets {
+ ConcurrentHashMap tickets = new ConcurrentHashMap<>();
+
+ public String getTicket() {
+ String ticket = UUID.randomUUID().toString();
+ tickets.put(ticket, ticket);
+ Log.infof("New WebSocket ticket %s", ticket);
+ // This ticket may also be backed up by a ticket cookie
+ return ticket;
+ }
+
+ public String removeTicket(String ticket) {
+ return tickets.get(ticket);
+ }
+}
diff --git a/samples/secure-sql-chatbot/src/main/resources/META-INF/resources/components/demo-title.js b/samples/secure-sql-chatbot/src/main/resources/META-INF/resources/components/demo-title.js
new file mode 100644
index 000000000..0b61eea50
--- /dev/null
+++ b/samples/secure-sql-chatbot/src/main/resources/META-INF/resources/components/demo-title.js
@@ -0,0 +1,72 @@
+import {LitElement, html, css} from 'lit';
+
+export class DemoTitle extends LitElement {
+
+ static styles = css`
+ h1 {
+ font-family: "Red Hat Mono", monospace;
+ font-size: 60px;
+ font-style: normal;
+ font-variant: normal;
+ font-weight: 700;
+ line-height: 26.4px;
+ color: var(--main-highlight-text-color);
+ }
+
+ .title {
+ text-align: center;
+ padding: 1em;
+ background: var(--main-bg-color);
+ }
+
+ .explanation {
+ margin-left: auto;
+ margin-right: auto;
+ width: 50%;
+ text-align: justify;
+ font-size: 20px;
+ }
+
+ .explanation img {
+ max-width: 60%;
+ display: block;
+ float:left;
+ margin-right: 2em;
+ margin-top: 1em;
+ }
+ `
+
+ render() {
+ return html`
+
+
Secure Movie Muse
+
+
+ This demo shows how to build a chatbot powered by GPT 3.5 and advanced retrieval augmented generation.
+ The application ingests a CSV file listing the top 100 movies from IMDB.
+ You can now ask questions about the movies and the application will answer.
+ For example What is the rating of the movie Inception? or What comedy movies are in the database?
+
+
+
+
+
+
+
+
+ You must login with Google first before you can interact with the chatbot
+
+