diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6a0c9b9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +target/ +.classpath +target/ +.project +.settings/ +.vscode +.factorypath diff --git a/.mvn/wrapper/maven-wrapper.properties b/.mvn/wrapper/maven-wrapper.properties new file mode 100644 index 0000000..2f94e61 --- /dev/null +++ b/.mvn/wrapper/maven-wrapper.properties @@ -0,0 +1,19 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +wrapperVersion=3.3.2 +distributionType=only-script +distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.10/apache-maven-3.9.10-bin.zip diff --git a/HELP.md b/HELP.md new file mode 100644 index 0000000..c81f554 --- /dev/null +++ b/HELP.md @@ -0,0 +1,28 @@ +# Getting Started + +### Reference Documentation +For further reference, please consider the following sections: + +* [Official Apache Maven documentation](https://maven.apache.org/guides/index.html) +* [Spring Boot Maven Plugin Reference Guide](https://docs.spring.io/spring-boot/3.4.7/maven-plugin) +* [Create an OCI image](https://docs.spring.io/spring-boot/3.4.7/maven-plugin/build-image.html) +* [Ollama](https://docs.spring.io/spring-ai/reference/api/chat/ollama-chat.html) +* [PGvector Vector Database](https://docs.spring.io/spring-ai/reference/api/vectordbs/pgvector.html) +* [Spring Web](https://docs.spring.io/spring-boot/3.4.7/reference/web/servlet.html) +* [Spring Data JPA](https://docs.spring.io/spring-boot/3.4.7/reference/data/sql.html#data.sql.jpa-and-spring-data) + +### Guides +The following guides illustrate how to use some features concretely: + +* [Building a RESTful Web Service](https://spring.io/guides/gs/rest-service/) +* [Serving Web Content with Spring MVC](https://spring.io/guides/gs/serving-web-content/) +* [Building REST services with Spring](https://spring.io/guides/tutorials/rest/) +* [Accessing Data with JPA](https://spring.io/guides/gs/accessing-data-jpa/) + +### Maven Parent overrides + +Due to Maven's design, elements are inherited from the parent POM to the project POM. +While most of the inheritance is fine, it also inherits unwanted elements like `` and `` from the parent. +To prevent this, the project POM contains empty overrides for these elements. +If you manually switch to a different parent and actually want the inheritance, you need to remove those overrides. + diff --git a/README.md b/README.md new file mode 100644 index 0000000..5f27338 --- /dev/null +++ b/README.md @@ -0,0 +1,34 @@ + +| Model | Size | Context Window | Notes | +| :---- | :--: | :------------: | :---: | +| qwen3:4b-q4_K_M | 2.6 GB | 40K | Verbose response | +| deepseek-r1:1.5b | 1.1 GB | 128K | + + +export OPENAI_API_KEY=$(cat $HOME/.config/openai/api_key) + +```bash +curl -X POST http://localhost:8080/api/ai/chat-with-sources -H "Content-Type: application/json" -d '{"question": ""}' +``` + + + +```bash +# Ch 12 Q2 + +curl -X POST http://localhost:8080/api/ai/chat-with-sources -H "Content-Type: application/json" -d '{"question": "You are an IT Specialist at a technology company, and your Dataproc cluster runs in a single Virtual Private Cloud (VPC) network in a single subnetwork with range 172.16.20.128/25. The subnetwork runs out of private IP addresses. Your manager asks you to find a way to add new VMs for communication with the cluster while minimizing the steps involved. What should you do? A. Create a new subnetwork in the existing VPC with a range of 172.16.21.0/24 and configure the VMs to use that subnetwork. B. Create a new VPC network for the VMs with a subnet of 172.32.0.0/16. Enable VPC network Peering between the Dataproc VPC network and the VMs VPC network. Configure a custom Route exchange. C. Configure Shared VPC for the existing VPC and add the VMs to a new subnetwork in the Shared VPC. D. Modify the existing subnet range to 172.16.20.0/24."}' +``` + +```bash +curl -X POST http://localhost:8080/api/ai/chat-with-sources -H "Content-Type: application/json" -d '{"question": "As a developer at a software company, you have been working on a project that utilizes Google Cloud services. Initially, you used your personal credit card for the expenses and later got reimbursed by your company. However, your company now wants to directly handle the billing for these services in their monthly invoice. What should you do to make this happen? A. Use Google Cloud Pub/Sub to send billing notifications to your finance team. B. Change the billing account of your projects to the billing account of your company. C. Enable Google Cloud Monitoring alerts for billing thresholds to notify your financial team. D. Share your credit card details with your financial team and have them add it to a new billing account."}' +``` + +```bash +curl -X POST http://localhost:8080/api/ai/chat-with-sources -H "Content-Type: application/json" -d '{"question": "As a database administrator for a rapidly growing tech company, you have been tasked with migrating the following on-premises data manage- ment solutions to Google Cloud in order to maximize scalability and minimize operational and infrastructure management: • One MySQL cluster for your company’s primary database • Apache Kafka for your company’s event streaming platform • One Cloud SQL for PostgreSQL database for your company’s analytical and reporting needs Which Google-recommended solutions should you implement for the migration? A. Migrate from MySQL to Cloud SQL, from Kafka to Dataflow, and from Cloud SQL for PostgreSQL to Cloud Bigtable. B. Migrate from MySQL to Cloud Spanner, from Kafka to Pub/Sub, and from Cloud SQL for PostgreSQL to BigQuery. C. Migrate from MySQL to Cloud SQL, from Kafka to Pub/Sub, and from Cloud SQL for PostgreSQL to BigQuery. D. Migrate from MySQL to Firestore, from Kafka to Pub/Sub, and from Cloud SQL for PostgreSQL to Bigtable."}' +``` + +```bash +#e4q1 + +curl -X POST http://localhost:8080/api/ai/chat-with-sources -H "Content-Type: application/json" -d '{"question": "You are working as a network administrator for a company with two subnets (subnet-a and subnet-b) in their default VPC. The company’s database servers are located in subnet-a, while the application servers and web servers operate in subnet-b. Your task is to configure a firewall rule that permits database traffic exclusively from the application servers to the database servers. What steps should be taken to accomplish this? A. • Create service accounts sa-app and sa-db. • Associate service account sa- app with the application servers and the service account sa-db with the database servers. • Create an ingress firewall rule to allow network traffic from source service account sa-app to target service account sa-db. B. Create network tags db-server and app-server. • Add the db-server tag to the application servers and the app-server tag to the database servers. • Create an egress firewall rule to allow network traffic from source network tag db-server to target network tag app-server. C. Create a service account sa-app and a network tag db-server. • Associate the service account sa-app with the database servers and the network tag db-server with the application servers. • Create an ingress firewall rule to allow network traffic from source service account sa-app to target network tag db-server. D. Create a service account sa-app and a network tag app-server. • Add the service account sa-app to the application servers and the network tag app-server to the database servers. • Create an ingress firewall rule to allow network traffic from source VPC IP addresses and target the subnet-b IP addresses"}' +``` \ No newline at end of file diff --git a/curl_example.md b/curl_example.md new file mode 100644 index 0000000..376e8b0 --- /dev/null +++ b/curl_example.md @@ -0,0 +1,3 @@ +curl -X POST http://localhost:8080/api/ai/chat-with-sources \ + -H "Content-Type: application/json" \ + -d '{"question": "What is the maximum size of a Pub/Sub message payload?"}' diff --git a/mvnw b/mvnw new file mode 100755 index 0000000..19529dd --- /dev/null +++ b/mvnw @@ -0,0 +1,259 @@ +#!/bin/sh +# ---------------------------------------------------------------------------- +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# ---------------------------------------------------------------------------- + +# ---------------------------------------------------------------------------- +# Apache Maven Wrapper startup batch script, version 3.3.2 +# +# Optional ENV vars +# ----------------- +# JAVA_HOME - location of a JDK home dir, required when download maven via java source +# MVNW_REPOURL - repo url base for downloading maven distribution +# MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven +# MVNW_VERBOSE - true: enable verbose log; debug: trace the mvnw script; others: silence the output +# ---------------------------------------------------------------------------- + +set -euf +[ "${MVNW_VERBOSE-}" != debug ] || set -x + +# OS specific support. +native_path() { printf %s\\n "$1"; } +case "$(uname)" in +CYGWIN* | MINGW*) + [ -z "${JAVA_HOME-}" ] || JAVA_HOME="$(cygpath --unix "$JAVA_HOME")" + native_path() { cygpath --path --windows "$1"; } + ;; +esac + +# set JAVACMD and JAVACCMD +set_java_home() { + # For Cygwin and MinGW, ensure paths are in Unix format before anything is touched + if [ -n "${JAVA_HOME-}" ]; then + if [ -x "$JAVA_HOME/jre/sh/java" ]; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + JAVACCMD="$JAVA_HOME/jre/sh/javac" + else + JAVACMD="$JAVA_HOME/bin/java" + JAVACCMD="$JAVA_HOME/bin/javac" + + if [ ! -x "$JAVACMD" ] || [ ! -x "$JAVACCMD" ]; then + echo "The JAVA_HOME environment variable is not defined correctly, so mvnw cannot run." >&2 + echo "JAVA_HOME is set to \"$JAVA_HOME\", but \"\$JAVA_HOME/bin/java\" or \"\$JAVA_HOME/bin/javac\" does not exist." >&2 + return 1 + fi + fi + else + JAVACMD="$( + 'set' +e + 'unset' -f command 2>/dev/null + 'command' -v java + )" || : + JAVACCMD="$( + 'set' +e + 'unset' -f command 2>/dev/null + 'command' -v javac + )" || : + + if [ ! -x "${JAVACMD-}" ] || [ ! -x "${JAVACCMD-}" ]; then + echo "The java/javac command does not exist in PATH nor is JAVA_HOME set, so mvnw cannot run." >&2 + return 1 + fi + fi +} + +# hash string like Java String::hashCode +hash_string() { + str="${1:-}" h=0 + while [ -n "$str" ]; do + char="${str%"${str#?}"}" + h=$(((h * 31 + $(LC_CTYPE=C printf %d "'$char")) % 4294967296)) + str="${str#?}" + done + printf %x\\n $h +} + +verbose() { :; } +[ "${MVNW_VERBOSE-}" != true ] || verbose() { printf %s\\n "${1-}"; } + +die() { + printf %s\\n "$1" >&2 + exit 1 +} + +trim() { + # MWRAPPER-139: + # Trims trailing and leading whitespace, carriage returns, tabs, and linefeeds. + # Needed for removing poorly interpreted newline sequences when running in more + # exotic environments such as mingw bash on Windows. + printf "%s" "${1}" | tr -d '[:space:]' +} + +# parse distributionUrl and optional distributionSha256Sum, requires .mvn/wrapper/maven-wrapper.properties +while IFS="=" read -r key value; do + case "${key-}" in + distributionUrl) distributionUrl=$(trim "${value-}") ;; + distributionSha256Sum) distributionSha256Sum=$(trim "${value-}") ;; + esac +done <"${0%/*}/.mvn/wrapper/maven-wrapper.properties" +[ -n "${distributionUrl-}" ] || die "cannot read distributionUrl property in ${0%/*}/.mvn/wrapper/maven-wrapper.properties" + +case "${distributionUrl##*/}" in +maven-mvnd-*bin.*) + MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ + case "${PROCESSOR_ARCHITECTURE-}${PROCESSOR_ARCHITEW6432-}:$(uname -a)" in + *AMD64:CYGWIN* | *AMD64:MINGW*) distributionPlatform=windows-amd64 ;; + :Darwin*x86_64) distributionPlatform=darwin-amd64 ;; + :Darwin*arm64) distributionPlatform=darwin-aarch64 ;; + :Linux*x86_64*) distributionPlatform=linux-amd64 ;; + *) + echo "Cannot detect native platform for mvnd on $(uname)-$(uname -m), use pure java version" >&2 + distributionPlatform=linux-amd64 + ;; + esac + distributionUrl="${distributionUrl%-bin.*}-$distributionPlatform.zip" + ;; +maven-mvnd-*) MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ ;; +*) MVN_CMD="mvn${0##*/mvnw}" _MVNW_REPO_PATTERN=/org/apache/maven/ ;; +esac + +# apply MVNW_REPOURL and calculate MAVEN_HOME +# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/ +[ -z "${MVNW_REPOURL-}" ] || distributionUrl="$MVNW_REPOURL$_MVNW_REPO_PATTERN${distributionUrl#*"$_MVNW_REPO_PATTERN"}" +distributionUrlName="${distributionUrl##*/}" +distributionUrlNameMain="${distributionUrlName%.*}" +distributionUrlNameMain="${distributionUrlNameMain%-bin}" +MAVEN_USER_HOME="${MAVEN_USER_HOME:-${HOME}/.m2}" +MAVEN_HOME="${MAVEN_USER_HOME}/wrapper/dists/${distributionUrlNameMain-}/$(hash_string "$distributionUrl")" + +exec_maven() { + unset MVNW_VERBOSE MVNW_USERNAME MVNW_PASSWORD MVNW_REPOURL || : + exec "$MAVEN_HOME/bin/$MVN_CMD" "$@" || die "cannot exec $MAVEN_HOME/bin/$MVN_CMD" +} + +if [ -d "$MAVEN_HOME" ]; then + verbose "found existing MAVEN_HOME at $MAVEN_HOME" + exec_maven "$@" +fi + +case "${distributionUrl-}" in +*?-bin.zip | *?maven-mvnd-?*-?*.zip) ;; +*) die "distributionUrl is not valid, must match *-bin.zip or maven-mvnd-*.zip, but found '${distributionUrl-}'" ;; +esac + +# prepare tmp dir +if TMP_DOWNLOAD_DIR="$(mktemp -d)" && [ -d "$TMP_DOWNLOAD_DIR" ]; then + clean() { rm -rf -- "$TMP_DOWNLOAD_DIR"; } + trap clean HUP INT TERM EXIT +else + die "cannot create temp dir" +fi + +mkdir -p -- "${MAVEN_HOME%/*}" + +# Download and Install Apache Maven +verbose "Couldn't find MAVEN_HOME, downloading and installing it ..." +verbose "Downloading from: $distributionUrl" +verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName" + +# select .zip or .tar.gz +if ! command -v unzip >/dev/null; then + distributionUrl="${distributionUrl%.zip}.tar.gz" + distributionUrlName="${distributionUrl##*/}" +fi + +# verbose opt +__MVNW_QUIET_WGET=--quiet __MVNW_QUIET_CURL=--silent __MVNW_QUIET_UNZIP=-q __MVNW_QUIET_TAR='' +[ "${MVNW_VERBOSE-}" != true ] || __MVNW_QUIET_WGET='' __MVNW_QUIET_CURL='' __MVNW_QUIET_UNZIP='' __MVNW_QUIET_TAR=v + +# normalize http auth +case "${MVNW_PASSWORD:+has-password}" in +'') MVNW_USERNAME='' MVNW_PASSWORD='' ;; +has-password) [ -n "${MVNW_USERNAME-}" ] || MVNW_USERNAME='' MVNW_PASSWORD='' ;; +esac + +if [ -z "${MVNW_USERNAME-}" ] && command -v wget >/dev/null; then + verbose "Found wget ... using wget" + wget ${__MVNW_QUIET_WGET:+"$__MVNW_QUIET_WGET"} "$distributionUrl" -O "$TMP_DOWNLOAD_DIR/$distributionUrlName" || die "wget: Failed to fetch $distributionUrl" +elif [ -z "${MVNW_USERNAME-}" ] && command -v curl >/dev/null; then + verbose "Found curl ... using curl" + curl ${__MVNW_QUIET_CURL:+"$__MVNW_QUIET_CURL"} -f -L -o "$TMP_DOWNLOAD_DIR/$distributionUrlName" "$distributionUrl" || die "curl: Failed to fetch $distributionUrl" +elif set_java_home; then + verbose "Falling back to use Java to download" + javaSource="$TMP_DOWNLOAD_DIR/Downloader.java" + targetZip="$TMP_DOWNLOAD_DIR/$distributionUrlName" + cat >"$javaSource" <<-END + public class Downloader extends java.net.Authenticator + { + protected java.net.PasswordAuthentication getPasswordAuthentication() + { + return new java.net.PasswordAuthentication( System.getenv( "MVNW_USERNAME" ), System.getenv( "MVNW_PASSWORD" ).toCharArray() ); + } + public static void main( String[] args ) throws Exception + { + setDefault( new Downloader() ); + java.nio.file.Files.copy( java.net.URI.create( args[0] ).toURL().openStream(), java.nio.file.Paths.get( args[1] ).toAbsolutePath().normalize() ); + } + } + END + # For Cygwin/MinGW, switch paths to Windows format before running javac and java + verbose " - Compiling Downloader.java ..." + "$(native_path "$JAVACCMD")" "$(native_path "$javaSource")" || die "Failed to compile Downloader.java" + verbose " - Running Downloader.java ..." + "$(native_path "$JAVACMD")" -cp "$(native_path "$TMP_DOWNLOAD_DIR")" Downloader "$distributionUrl" "$(native_path "$targetZip")" +fi + +# If specified, validate the SHA-256 sum of the Maven distribution zip file +if [ -n "${distributionSha256Sum-}" ]; then + distributionSha256Result=false + if [ "$MVN_CMD" = mvnd.sh ]; then + echo "Checksum validation is not supported for maven-mvnd." >&2 + echo "Please disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2 + exit 1 + elif command -v sha256sum >/dev/null; then + if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | sha256sum -c >/dev/null 2>&1; then + distributionSha256Result=true + fi + elif command -v shasum >/dev/null; then + if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | shasum -a 256 -c >/dev/null 2>&1; then + distributionSha256Result=true + fi + else + echo "Checksum validation was requested but neither 'sha256sum' or 'shasum' are available." >&2 + echo "Please install either command, or disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2 + exit 1 + fi + if [ $distributionSha256Result = false ]; then + echo "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised." >&2 + echo "If you updated your Maven version, you need to update the specified distributionSha256Sum property." >&2 + exit 1 + fi +fi + +# unzip and move +if command -v unzip >/dev/null; then + unzip ${__MVNW_QUIET_UNZIP:+"$__MVNW_QUIET_UNZIP"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -d "$TMP_DOWNLOAD_DIR" || die "failed to unzip" +else + tar xzf${__MVNW_QUIET_TAR:+"$__MVNW_QUIET_TAR"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -C "$TMP_DOWNLOAD_DIR" || die "failed to untar" +fi +printf %s\\n "$distributionUrl" >"$TMP_DOWNLOAD_DIR/$distributionUrlNameMain/mvnw.url" +mv -- "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" "$MAVEN_HOME" || [ -d "$MAVEN_HOME" ] || die "fail to move MAVEN_HOME" + +clean || : +exec_maven "$@" diff --git a/mvnw.cmd b/mvnw.cmd new file mode 100644 index 0000000..249bdf3 --- /dev/null +++ b/mvnw.cmd @@ -0,0 +1,149 @@ +<# : batch portion +@REM ---------------------------------------------------------------------------- +@REM Licensed to the Apache Software Foundation (ASF) under one +@REM or more contributor license agreements. See the NOTICE file +@REM distributed with this work for additional information +@REM regarding copyright ownership. The ASF licenses this file +@REM to you under the Apache License, Version 2.0 (the +@REM "License"); you may not use this file except in compliance +@REM with the License. You may obtain a copy of the License at +@REM +@REM http://www.apache.org/licenses/LICENSE-2.0 +@REM +@REM Unless required by applicable law or agreed to in writing, +@REM software distributed under the License is distributed on an +@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +@REM KIND, either express or implied. See the License for the +@REM specific language governing permissions and limitations +@REM under the License. +@REM ---------------------------------------------------------------------------- + +@REM ---------------------------------------------------------------------------- +@REM Apache Maven Wrapper startup batch script, version 3.3.2 +@REM +@REM Optional ENV vars +@REM MVNW_REPOURL - repo url base for downloading maven distribution +@REM MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven +@REM MVNW_VERBOSE - true: enable verbose log; others: silence the output +@REM ---------------------------------------------------------------------------- + +@IF "%__MVNW_ARG0_NAME__%"=="" (SET __MVNW_ARG0_NAME__=%~nx0) +@SET __MVNW_CMD__= +@SET __MVNW_ERROR__= +@SET __MVNW_PSMODULEP_SAVE=%PSModulePath% +@SET PSModulePath= +@FOR /F "usebackq tokens=1* delims==" %%A IN (`powershell -noprofile "& {$scriptDir='%~dp0'; $script='%__MVNW_ARG0_NAME__%'; icm -ScriptBlock ([Scriptblock]::Create((Get-Content -Raw '%~f0'))) -NoNewScope}"`) DO @( + IF "%%A"=="MVN_CMD" (set __MVNW_CMD__=%%B) ELSE IF "%%B"=="" (echo %%A) ELSE (echo %%A=%%B) +) +@SET PSModulePath=%__MVNW_PSMODULEP_SAVE% +@SET __MVNW_PSMODULEP_SAVE= +@SET __MVNW_ARG0_NAME__= +@SET MVNW_USERNAME= +@SET MVNW_PASSWORD= +@IF NOT "%__MVNW_CMD__%"=="" (%__MVNW_CMD__% %*) +@echo Cannot start maven from wrapper >&2 && exit /b 1 +@GOTO :EOF +: end batch / begin powershell #> + +$ErrorActionPreference = "Stop" +if ($env:MVNW_VERBOSE -eq "true") { + $VerbosePreference = "Continue" +} + +# calculate distributionUrl, requires .mvn/wrapper/maven-wrapper.properties +$distributionUrl = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionUrl +if (!$distributionUrl) { + Write-Error "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties" +} + +switch -wildcard -casesensitive ( $($distributionUrl -replace '^.*/','') ) { + "maven-mvnd-*" { + $USE_MVND = $true + $distributionUrl = $distributionUrl -replace '-bin\.[^.]*$',"-windows-amd64.zip" + $MVN_CMD = "mvnd.cmd" + break + } + default { + $USE_MVND = $false + $MVN_CMD = $script -replace '^mvnw','mvn' + break + } +} + +# apply MVNW_REPOURL and calculate MAVEN_HOME +# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/ +if ($env:MVNW_REPOURL) { + $MVNW_REPO_PATTERN = if ($USE_MVND) { "/org/apache/maven/" } else { "/maven/mvnd/" } + $distributionUrl = "$env:MVNW_REPOURL$MVNW_REPO_PATTERN$($distributionUrl -replace '^.*'+$MVNW_REPO_PATTERN,'')" +} +$distributionUrlName = $distributionUrl -replace '^.*/','' +$distributionUrlNameMain = $distributionUrlName -replace '\.[^.]*$','' -replace '-bin$','' +$MAVEN_HOME_PARENT = "$HOME/.m2/wrapper/dists/$distributionUrlNameMain" +if ($env:MAVEN_USER_HOME) { + $MAVEN_HOME_PARENT = "$env:MAVEN_USER_HOME/wrapper/dists/$distributionUrlNameMain" +} +$MAVEN_HOME_NAME = ([System.Security.Cryptography.MD5]::Create().ComputeHash([byte[]][char[]]$distributionUrl) | ForEach-Object {$_.ToString("x2")}) -join '' +$MAVEN_HOME = "$MAVEN_HOME_PARENT/$MAVEN_HOME_NAME" + +if (Test-Path -Path "$MAVEN_HOME" -PathType Container) { + Write-Verbose "found existing MAVEN_HOME at $MAVEN_HOME" + Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD" + exit $? +} + +if (! $distributionUrlNameMain -or ($distributionUrlName -eq $distributionUrlNameMain)) { + Write-Error "distributionUrl is not valid, must end with *-bin.zip, but found $distributionUrl" +} + +# prepare tmp dir +$TMP_DOWNLOAD_DIR_HOLDER = New-TemporaryFile +$TMP_DOWNLOAD_DIR = New-Item -Itemtype Directory -Path "$TMP_DOWNLOAD_DIR_HOLDER.dir" +$TMP_DOWNLOAD_DIR_HOLDER.Delete() | Out-Null +trap { + if ($TMP_DOWNLOAD_DIR.Exists) { + try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null } + catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" } + } +} + +New-Item -Itemtype Directory -Path "$MAVEN_HOME_PARENT" -Force | Out-Null + +# Download and Install Apache Maven +Write-Verbose "Couldn't find MAVEN_HOME, downloading and installing it ..." +Write-Verbose "Downloading from: $distributionUrl" +Write-Verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName" + +$webclient = New-Object System.Net.WebClient +if ($env:MVNW_USERNAME -and $env:MVNW_PASSWORD) { + $webclient.Credentials = New-Object System.Net.NetworkCredential($env:MVNW_USERNAME, $env:MVNW_PASSWORD) +} +[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 +$webclient.DownloadFile($distributionUrl, "$TMP_DOWNLOAD_DIR/$distributionUrlName") | Out-Null + +# If specified, validate the SHA-256 sum of the Maven distribution zip file +$distributionSha256Sum = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionSha256Sum +if ($distributionSha256Sum) { + if ($USE_MVND) { + Write-Error "Checksum validation is not supported for maven-mvnd. `nPlease disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." + } + Import-Module $PSHOME\Modules\Microsoft.PowerShell.Utility -Function Get-FileHash + if ((Get-FileHash "$TMP_DOWNLOAD_DIR/$distributionUrlName" -Algorithm SHA256).Hash.ToLower() -ne $distributionSha256Sum) { + Write-Error "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised. If you updated your Maven version, you need to update the specified distributionSha256Sum property." + } +} + +# unzip and move +Expand-Archive "$TMP_DOWNLOAD_DIR/$distributionUrlName" -DestinationPath "$TMP_DOWNLOAD_DIR" | Out-Null +Rename-Item -Path "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" -NewName $MAVEN_HOME_NAME | Out-Null +try { + Move-Item -Path "$TMP_DOWNLOAD_DIR/$MAVEN_HOME_NAME" -Destination $MAVEN_HOME_PARENT | Out-Null +} catch { + if (! (Test-Path -Path "$MAVEN_HOME" -PathType Container)) { + Write-Error "fail to move MAVEN_HOME" + } +} finally { + try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null } + catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" } +} + +Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD" diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..ae47a73 --- /dev/null +++ b/pom.xml @@ -0,0 +1,111 @@ + + + 4.0.0 + + org.springframework.boot + spring-boot-starter-parent + 3.4.7 + + + net.curtlewis + gcprag + 0.0.1-SNAPSHOT + gcprag + Demo project for Spring Boot + + + + + + + + + + + + + + + 21 + 1.0.0 + + + + org.projectlombok + lombok + + + + org.springframework.boot + spring-boot-starter-data-jpa + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.ai + spring-ai-advisors-vector-store + + + org.springframework.ai + spring-ai-starter-model-ollama + + + org.springframework.ai + spring-ai-starter-vector-store-pgvector + + + + org.springframework.ai + spring-ai-starter-model-openai + + + + com.vladmihalcea + hibernate-types-60 + 2.21.1 + + + + org.postgresql + postgresql + runtime + + + org.springframework.boot + spring-boot-starter-test + test + + + + + + + org.antlr + antlr4-runtime + 4.13.1 + + + + org.springframework.ai + spring-ai-bom + ${spring-ai.version} + pom + import + + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + + diff --git a/schema.sql b/schema.sql new file mode 100644 index 0000000..0b184e1 --- /dev/null +++ b/schema.sql @@ -0,0 +1,23 @@ +CREATE EXTENSION IF NOT EXISTS vector; +CREATE EXTENSION IF NOT EXISTS hstore; +CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; + +-- Documents table +CREATE TABLE documents ( + id uuid DEFAULT uuid_generate_v4() PRIMARY KEY, + source_url text UNIQUE NOT NULL, + title text, + date_last_modified timestamp, + metadata jsonb, + created_at timestamp DEFAULT CURRENT_TIMESTAMP +); + +-- Chunks table +CREATE TABLE chunks ( + id uuid DEFAULT uuid_generate_v4() PRIMARY KEY, + document_id uuid REFERENCES documents(id) ON DELETE CASCADE, + chunk_index text, + content text, + embedding vector, + created_at timestamp DEFAULT CURRENT_TIMESTAMP +); \ No newline at end of file diff --git a/src/main/java/net/curtlewis/gcprag/Application.java b/src/main/java/net/curtlewis/gcprag/Application.java new file mode 100644 index 0000000..7265122 --- /dev/null +++ b/src/main/java/net/curtlewis/gcprag/Application.java @@ -0,0 +1,13 @@ +package net.curtlewis.gcprag; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class Application { + + public static void main(String[] args) { + SpringApplication.run(Application.class, args); + } + +} diff --git a/src/main/java/net/curtlewis/gcprag/ai/AiController.java b/src/main/java/net/curtlewis/gcprag/ai/AiController.java new file mode 100644 index 0000000..b163e42 --- /dev/null +++ b/src/main/java/net/curtlewis/gcprag/ai/AiController.java @@ -0,0 +1,67 @@ +package net.curtlewis.gcprag.ai; + +import java.util.Map; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import net.curtlewis.gcprag.service.RagService; + +@RestController +@RequestMapping("/api/ai") +public class AiController { + + private final RagService ragService; + + public AiController(RagService ragService) { + this.ragService = ragService; + } + + // === RAG Endpoints === + + @PostMapping("/chat") + public ResponseEntity> ragChat(@RequestBody Map request) { + String question = request.get("question"); + if (question == null || question.trim().isEmpty()) { + return ResponseEntity.badRequest().body(Map.of("error", "Question is required")); + } + + String response = ragService.chat(question); + return ResponseEntity.ok(Map.of("response", response)); + } + + @PostMapping("/chat-with-sources") + public ResponseEntity> ragChatWithSources( + @RequestBody Map request) { + String question = (String) request.get("question"); + if (question == null || question.trim().isEmpty()) { + return ResponseEntity.badRequest().body(Map.of("error", "Question is required")); + } + + // Optional parameters + int topK = request.containsKey("topK") ? ((Number) request.get("topK")).intValue() : 5; + double threshold = + request.containsKey("threshold") ? ((Number) request.get("threshold")).doubleValue() + : 0.7; + + RagService.RagResponse ragResponse = ragService.chatWithSources(question, topK, threshold); + + return ResponseEntity.ok(Map.of("response", ragResponse.getResponse(), "sources", + ragResponse.getSources(), "context", ragResponse.getContext(), "sourceCount", + ragResponse.getSources().size())); + } + + + @GetMapping("/stats") + public ResponseEntity> getDatabaseStats() { + RagService.DatabaseStats stats = ragService.getDatabaseStats(); + return ResponseEntity.ok(Map.of("totalDocuments", stats.getTotalDocuments())); + } + + // === Original AI Endpoints === + + + +} diff --git a/src/main/java/net/curtlewis/gcprag/ai/openai/OpenAiController.java b/src/main/java/net/curtlewis/gcprag/ai/openai/OpenAiController.java new file mode 100644 index 0000000..eee4650 --- /dev/null +++ b/src/main/java/net/curtlewis/gcprag/ai/openai/OpenAiController.java @@ -0,0 +1,27 @@ +package net.curtlewis.gcprag.ai.openai; + +import java.util.Map; +import org.springframework.ai.chat.model.ChatModel; +import org.springframework.ai.chat.model.ChatResponse; +import org.springframework.ai.chat.prompt.Prompt; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +public class OpenAiController { + + + private final ChatModel chatModel; + + public OpenAiController(@Qualifier("openAiChatModel") ChatModel chatModel) { + this.chatModel = chatModel; + } + + @GetMapping("/ai/generate") + public Map generate(@RequestParam(value = "message", defaultValue = "Tell me a joke") String message) { + return Map.of("generation", chatModel.call(new Prompt(message))); + } + +} diff --git a/src/main/java/net/curtlewis/gcprag/chunk/ChunkController.java b/src/main/java/net/curtlewis/gcprag/chunk/ChunkController.java new file mode 100644 index 0000000..a9e9050 --- /dev/null +++ b/src/main/java/net/curtlewis/gcprag/chunk/ChunkController.java @@ -0,0 +1,25 @@ +package net.curtlewis.gcprag.chunk; + +import java.util.List; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/chunks") +public class ChunkController { + + private ChunkService chunkService; + + public ChunkController(ChunkService chunkService) { + this.chunkService = chunkService; + } + + /** Get all documents */ + @GetMapping + public ResponseEntity> getAllChunks() { + List chunks = chunkService.getAllDocuments(); + return ResponseEntity.ok(chunks); + } +} diff --git a/src/main/java/net/curtlewis/gcprag/chunk/ChunkEntity.java b/src/main/java/net/curtlewis/gcprag/chunk/ChunkEntity.java new file mode 100644 index 0000000..0d1d643 --- /dev/null +++ b/src/main/java/net/curtlewis/gcprag/chunk/ChunkEntity.java @@ -0,0 +1,114 @@ +package net.curtlewis.gcprag.chunk; + +import java.time.LocalDateTime; +import java.util.UUID; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; + +@Entity +// @Table(name = "vector_store") +@Table(name = "chunks") +public class ChunkEntity { + + @Id + // @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id") + private UUID id; + + @Column(name = "document_id") + private UUID documentId; + + // @Column(name = "title") + // private String title; + + @Column(name = "chunk_index") + private String chunkIndex; + + @Column(name = "content", columnDefinition = "TEXT") + private String content; + + // @Column(name = "source_url") + // private String sourceUrl; + + // @Column(name = "date_last_modified") + // private LocalDateTime dateLastModified; + + // @Type(JsonType.class) + // @Column(name = "metadata", columnDefinition = "jsonb") + // private Map metadata; + + @Column(name = "embedding", columnDefinition = "vector") + private float[] embedding; + + @Column(name="created_at") + private LocalDateTime createdAt; + + + // Constructors + public ChunkEntity() {} + + public ChunkEntity(UUID documentId, String chunkIndex, String content, + LocalDateTime createdAt, float[] embedding) { + this.documentId = documentId; + this.chunkIndex = chunkIndex; + this.content = content; + // this.sourceUrl = sourceUrl; + this.embedding = embedding; + this.createdAt = createdAt; + } + + // Getters and Setters + public UUID getId() { + return id; + } + + public void setId(UUID id) { + this.id = id; + } + + public UUID getDocumentId() { + return documentId; + } + + public void setDocumentId(UUID documentId) { + this.documentId = documentId; + } + + + public String getChunkIndex() { + return chunkIndex; + } + + public void setChunkIndex(String chunkIndex) { + this.chunkIndex = chunkIndex; + } + + public String getContent() { + return content; + } + + public void setContent(String content) { + this.content = content; + } + + public LocalDateTime getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(LocalDateTime createdAt) { + this.createdAt = createdAt; + } + + + public float[] getEmbedding() { + return embedding; + } + + public void setEmbedding(float[] embedding) { + this.embedding = embedding; + } + + +} diff --git a/src/main/java/net/curtlewis/gcprag/chunk/ChunkRepository.java b/src/main/java/net/curtlewis/gcprag/chunk/ChunkRepository.java new file mode 100644 index 0000000..1ec6a73 --- /dev/null +++ b/src/main/java/net/curtlewis/gcprag/chunk/ChunkRepository.java @@ -0,0 +1,43 @@ +package net.curtlewis.gcprag.chunk; + +import java.util.List; +import java.util.UUID; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +@Repository +public interface ChunkRepository extends JpaRepository { + + @Query(value = """ + SELECT c.id, + c.document_id, + c.chunk_index, + c.content, + (1 - (c.embedding <=> CAST(:queryEmbedding AS vector))) as similarity + FROM chunks c + WHERE c.embedding IS NOT NULL + AND (1 - (c.embedding <=> CAST(:queryEmbedding AS vector))) >= :threshold + ORDER BY c.embedding <=> CAST(:queryEmbedding AS vector) + LIMIT :limit + """, nativeQuery = true) + List findSimilarDocuments(@Param("queryEmbedding") String queryEmbedding, + @Param("limit") int limit); + + /** + * Find similar documents with minimum similarity threshold + */ + @Query(value = """ + SELECT c.*, + (1 - (c.embedding <=> CAST(:queryEmbedding AS vector))) as similarity + FROM chunks c + WHERE c.embedding IS NOT NULL + AND (1 - (c.embedding <=> CAST(:queryEmbedding AS vector))) >= :threshold + ORDER BY c.embedding <=> CAST(:queryEmbedding AS vector) + LIMIT :limit + """, nativeQuery = true) + List findSimilarDocumentsWithThreshold(@Param("queryEmbedding") String queryEmbedding, + @Param("threshold") double threshold, @Param("limit") int limit); + +} diff --git a/src/main/java/net/curtlewis/gcprag/chunk/ChunkService.java b/src/main/java/net/curtlewis/gcprag/chunk/ChunkService.java new file mode 100644 index 0000000..d702651 --- /dev/null +++ b/src/main/java/net/curtlewis/gcprag/chunk/ChunkService.java @@ -0,0 +1,82 @@ +package net.curtlewis.gcprag.chunk; + +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import org.springframework.ai.embedding.EmbeddingModel; +import org.springframework.ai.embedding.EmbeddingRequest; +import org.springframework.ai.embedding.EmbeddingResponse; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.stereotype.Service; + +@Service +public class ChunkService { + + private final ChunkRepository chunkRepository; + private final EmbeddingModel embeddingModel; + + public ChunkService(ChunkRepository chunkRepository, @Qualifier("ollamaEmbeddingModel") EmbeddingModel embeddingModel) { + this.chunkRepository = chunkRepository; + this.embeddingModel = embeddingModel; + } + + /** + * Get all documents + */ + public List getAllDocuments() { + return chunkRepository.findAll(); + } + + /** + * Regenerate embeddings for all documents (useful for migration or model + * changes) + */ + public void regenerateAllEmbeddings() { + List documents = chunkRepository.findAll(); + + for (ChunkEntity document : documents) { + List embedding = generateEmbedding(document.getContent()); + + float[] embeddingFloats = new float[embedding.size()]; + int index = 0; + for (Double d : embedding) { + embeddingFloats[index++] = d.floatValue(); + } + + float[] embeddingArray = new float[embeddingFloats.length]; + for (int i = 0; i < embeddingFloats.length; i++) { + embeddingArray[i] = embeddingFloats[i]; + } + + document.setEmbedding(embeddingArray); + // document.setDateLastModified(LocalDateTime.now()); + + } + + chunkRepository.saveAll(documents); + } + + /** + * Get document by ID + */ + public ChunkEntity getDocument(UUID id) { + return chunkRepository.findById(id) + .orElseThrow(() -> new RuntimeException("Document not found")); + } + + /** + * Generate embedding for text + */ + private List generateEmbedding(String text) { + EmbeddingRequest request = new EmbeddingRequest(List.of(text), null); + EmbeddingResponse response = embeddingModel.call(request); + float[] floatArray = response.getResults().get(0).getOutput(); + List doublesList = new ArrayList<>(); + for (float f : floatArray) { + doublesList.add((double) f); + } + + return doublesList; + } + +} diff --git a/src/main/java/net/curtlewis/gcprag/config/ModelConfig.java b/src/main/java/net/curtlewis/gcprag/config/ModelConfig.java new file mode 100644 index 0000000..7a4bab0 --- /dev/null +++ b/src/main/java/net/curtlewis/gcprag/config/ModelConfig.java @@ -0,0 +1,70 @@ +package net.curtlewis.gcprag.config; + +import org.springframework.ai.chat.model.ChatModel; +import org.springframework.ai.embedding.EmbeddingModel; +import org.springframework.ai.ollama.OllamaChatModel; +import org.springframework.ai.ollama.api.OllamaApi; +import org.springframework.ai.ollama.api.OllamaOptions; +import org.springframework.ai.openai.OpenAiChatModel; +import org.springframework.ai.openai.OpenAiChatOptions; +import org.springframework.ai.openai.api.OpenAiApi; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Primary; + +@Configuration +public class ModelConfig { + + @Value("${spring.ai.openai.api-key}") + private String openAiApiKey; + + @Value("${spring.ai.openai.chat.options.model:gpt-4o}") + private String openAiModel; + + @Value("${spring.ai.openai.chat.options.temperature:0.7}") + private Double openAiTemperature; + + // Ollama Configuration + @Value("${spring.ai.ollama.base-url:http://localhost:11434}") + private String ollamaBaseUrl; + + @Value("${spring.ai.ollama.chat.options.model:llama2}") + private String ollamaModel; + + @Value("${spring.ai.ollama.chat.options.temperature:0.7}") + private Double ollamaTemperature; + + @Bean("openAiChatModel") + @Primary + public ChatModel openAiChatModel() { + OpenAiApi openAiApi = OpenAiApi.builder().apiKey(openAiApiKey).build(); + OpenAiChatOptions options = OpenAiChatOptions.builder() + .model(openAiModel) + .temperature(openAiTemperature) + .build(); + return new OpenAiChatModel(openAiApi, options, null, null, null, null); + } + + @Bean("ollamaChatModel") + public ChatModel ollamaChatModel() { + var ollamaApi = OllamaApi.builder().build(); + + return OllamaChatModel.builder().ollamaApi(ollamaApi) + .defaultOptions( + OllamaOptions.builder() + .model(ollamaModel) + .temperature(ollamaTemperature) + .build()) + .build(); + } + + @Bean + @Primary + public EmbeddingModel primaryEmbeddingModel( + @Qualifier("ollamaEmbeddingModel") EmbeddingModel ollamaEmbeddingModel) { + return ollamaEmbeddingModel; + } + +} diff --git a/src/main/java/net/curtlewis/gcprag/config/OpenAiChatModel.java b/src/main/java/net/curtlewis/gcprag/config/OpenAiChatModel.java new file mode 100644 index 0000000..4eacc68 --- /dev/null +++ b/src/main/java/net/curtlewis/gcprag/config/OpenAiChatModel.java @@ -0,0 +1,5 @@ +package net.curtlewis.gcprag.config; + +public class OpenAiChatModel { + +} diff --git a/src/main/java/net/curtlewis/gcprag/document/DocumentController.java b/src/main/java/net/curtlewis/gcprag/document/DocumentController.java new file mode 100644 index 0000000..a662ad7 --- /dev/null +++ b/src/main/java/net/curtlewis/gcprag/document/DocumentController.java @@ -0,0 +1,117 @@ +package net.curtlewis.gcprag.document; + +import java.util.UUID; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/documents") +public class DocumentController { + + private final DocumentService documentService; + + public DocumentController(DocumentService documentService) { + this.documentService = documentService; + } + + /** + * Add a new document + */ + // @PostMapping + // public ResponseEntity addDocument( + // @RequestBody DocumentService.DocumentRequest request) { + // try { + // DocumentEntity document = documentService.addDocument(request.getDocumentId(), + // request.getTitle(), request.getChunkIndex(), request.getContent(), + // request.getSourceUrl(), request.getMetadata()); + // return ResponseEntity.ok(document); + // } catch (Exception e) { + // return ResponseEntity.badRequest().build(); + // } + // } + + /** + * Add multiple documents in batch + */ + // @PostMapping("/batch") + // public ResponseEntity> addDocuments( + // @RequestBody List requests) { + // try { + // List documents = documentService.addDocuments(requests); + // return ResponseEntity.ok(documents); + // } catch (Exception e) { + // return ResponseEntity.badRequest().build(); + // } + // } + + /** + * Get document by ID + */ + @GetMapping("/{id}") + public ResponseEntity getDocument(@PathVariable UUID id) { + try { + DocumentEntity document = documentService.getDocument(id); + return ResponseEntity.ok(document); + } catch (RuntimeException e) { + return ResponseEntity.notFound().build(); + } + } + + + + /** + * Get documents by document_id + */ + // @GetMapping("/by-document-id/{documentId}") + // public ResponseEntity> getDocumentsByDocumentId( + // @PathVariable UUID documentId) { + // List documents = documentService.getDocumentsByDocumentId(documentId); + // return ResponseEntity.ok(documents); + // } + + /** + * Search documents by title + */ + // @GetMapping("/search") + // public ResponseEntity> searchDocumentsByTitle(@RequestParam String title) { + // List documents = documentService.searchDocumentsByTitle(title); + // return ResponseEntity.ok(documents); + // } + + /** + * Update document content + */ + // @PutMapping("/{id}") + // public ResponseEntity updateDocument(@PathVariable UUID id, + // @RequestBody Map request) { + // try { + // String content = request.get("content"); + // if (content == null || content.trim().isEmpty()) { + // return ResponseEntity.badRequest().build(); + // } + + // DocumentEntity document = documentService.updateDocument(id, content); + // return ResponseEntity.ok(document); + // } catch (RuntimeException e) { + // return ResponseEntity.notFound().build(); + // } + // } + + /** + * Delete document + */ + // @DeleteMapping("/{id}") + // public ResponseEntity deleteDocument(@PathVariable UUID id) { + // try { + // documentService.deleteDocument(id); + // return ResponseEntity.noContent().build(); + // } catch (Exception e) { + // return ResponseEntity.notFound().build(); + // } + // } + + +} diff --git a/src/main/java/net/curtlewis/gcprag/document/DocumentEntity.java b/src/main/java/net/curtlewis/gcprag/document/DocumentEntity.java new file mode 100644 index 0000000..1a1c36b --- /dev/null +++ b/src/main/java/net/curtlewis/gcprag/document/DocumentEntity.java @@ -0,0 +1,105 @@ +package net.curtlewis.gcprag.document; + +import java.time.LocalDateTime; +import java.util.Map; +import java.util.UUID; +import org.hibernate.annotations.Type; +import com.vladmihalcea.hibernate.type.json.JsonType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; + +@Entity +// @Table(name = "vector_store") +@Table(name = "documents") +public class DocumentEntity { + + @Id + // @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id") + private UUID id; + + + @Column(name = "title") + private String title; + + + @Column(name = "source_url") + private String sourceUrl; + + @Column(name = "date_last_modified") + private LocalDateTime dateLastModified; + + @Type(JsonType.class) + @Column(name = "metadata", columnDefinition = "jsonb") + private Map metadata; + + @Column(name="created_at") + private LocalDateTime createdAt; + + // Constructors + public DocumentEntity() {} + + public DocumentEntity(UUID id, String title, String sourceUrl, + LocalDateTime dateLastModified, Map metadata, + LocalDateTime createdAt) { + this.id = id; + this.title = title; + this.sourceUrl = sourceUrl; + this.dateLastModified = dateLastModified; + this.metadata = metadata; + this.createdAt = createdAt; + } + + // Getters and Setters + public UUID getId() { + return id; + } + + public void setId(UUID id) { + this.id = id; + } + + + public String getTitle() { + return title; + } + + public void setTitle(String title) { + this.title = title; + } + + public String getSourceUrl() { + return sourceUrl; + } + + public void setSourceUrl(String sourceUrl) { + this.sourceUrl = sourceUrl; + } + + public LocalDateTime getDateLastModified() { + return dateLastModified; + } + + public void setDateLastModified(LocalDateTime dateLastModified) { + this.dateLastModified = dateLastModified; + } + + public Map getMetadata() { + return metadata; + } + + public void setMetadata(Map metadata) { + this.metadata = metadata; + } + + public LocalDateTime getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(LocalDateTime createdAt) { + this.createdAt = createdAt; + } + +} diff --git a/src/main/java/net/curtlewis/gcprag/document/DocumentRepository.java b/src/main/java/net/curtlewis/gcprag/document/DocumentRepository.java new file mode 100644 index 0000000..658df1c --- /dev/null +++ b/src/main/java/net/curtlewis/gcprag/document/DocumentRepository.java @@ -0,0 +1,57 @@ +package net.curtlewis.gcprag.document; + +import java.util.UUID; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.stereotype.Repository; + +@Repository +public interface DocumentRepository extends JpaRepository { +// public interface DocumentRepository extends JpaRepository { + + /** + * Find similar documents using cosine similarity + * + * @param queryEmbedding The embedding vector to search for + * @param limit Maximum number of results to return + * @return List of similar documents ordered by similarity (most similar first) + */ +// @Query(value = """ +// SELECT d.*, +// (1 - (d.embedding <=> CAST(:queryEmbedding AS vector))) as similarity +// FROM vector_store d +// WHERE d.embedding IS NOT NULL +// ORDER BY d.embedding <=> CAST(:queryEmbedding AS vector) +// LIMIT :limit +// """, nativeQuery = true) + +/* + * @Query(value = """ SELECT d.id, d.title, d.chunk_index, d.content, d.source_url, + * d.date_last_modified, d.metadata, (1 - (d.embedding <=> CAST(:queryEmbedding AS vector))) as + * similarity FROM vector_store d WHERE d.embedding IS NOT NULL AND (1 - (d.embedding <=> + * CAST(:queryEmbedding AS vector))) >= :threshold ORDER BY d.embedding <=> CAST(:queryEmbedding AS + * vector) LIMIT :limit """, nativeQuery = true) + */ + + + + + /** + * Find documents by document_id + */ + // List findById(UUID documentId); + +// @Query("SELECT d FROM DocumentEntity d WHERE d.id = ?1") +// List findByPositional(String id); + + /** + * Find documents by title containing text (case insensitive) + */ +// List findByTitleContainingIgnoreCase(String title); + + /** + * Count total documents + */ + @Query("SELECT COUNT(d) FROM DocumentEntity d") + long countAllDocuments(); +} diff --git a/src/main/java/net/curtlewis/gcprag/document/DocumentService.java b/src/main/java/net/curtlewis/gcprag/document/DocumentService.java new file mode 100644 index 0000000..5d2432a --- /dev/null +++ b/src/main/java/net/curtlewis/gcprag/document/DocumentService.java @@ -0,0 +1,212 @@ +package net.curtlewis.gcprag.document; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import org.springframework.ai.embedding.EmbeddingModel; +import org.springframework.ai.embedding.EmbeddingRequest; +import org.springframework.ai.embedding.EmbeddingResponse; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional +public class DocumentService { + + private final DocumentRepository documentRepository; + private final EmbeddingModel embeddingModel; + + public DocumentService(DocumentRepository documentRepository, @Qualifier("ollamaEmbeddingModel") EmbeddingModel embeddingModel) { + this.documentRepository = documentRepository; + this.embeddingModel = embeddingModel; + } + + /** + * Add a new document with automatic embedding generation + */ + // public DocumentEntity addDocument(String documentId, String title, String chunkIndex, + // String content, String sourceUrl, Map metadata) { + + // // Generate embedding for the content + // List embedding = generateEmbedding(content); + + // float[] embeddingFloats = new float[embedding.size()]; + // int index = 0; + // for(Double d : embedding) { + // embeddingFloats[index++] = d.floatValue(); + // } + + // DocumentEntity document = new DocumentEntity(documentId, title, chunkIndex, content, + // sourceUrl, LocalDateTime.now(), metadata, embeddingFloats); + + // return documentRepository.save(document); + // } + + /** + * Add multiple documents in batch + */ + // public List addDocuments(List documentRequests) { + // return documentRequests.stream() + // .map(req -> addDocument(req.getDocumentId(), req.getTitle(), req.getChunkIndex(), + // req.getContent(), req.getSourceUrl(), req.getMetadata())) + // .collect(Collectors.toList()); + // } + + /** + * Update document content and regenerate embedding + */ + public DocumentEntity updateDocument(UUID id, String content) { + // public DocumentEntity updateDocument(String id, String content) { + DocumentEntity document = documentRepository.findById(id) + .orElseThrow(() -> new RuntimeException("Document not found")); + + // Generate new embedding + List embedding = generateEmbedding(content); + float[] embeddingFloats = new float[embedding.size()]; + int index = 0; + for (Double d : embedding) { + embeddingFloats[index++] = d.floatValue(); + } + + // Convert Float[] to float[] + float[] embeddingArray = new float[embeddingFloats.length]; + for (int i = 0; i < embeddingFloats.length; i++) { + embeddingArray[i] = embeddingFloats[i]; + } + + // document.setContent(content); + // document.setEmbedding(embeddingArray); + document.setDateLastModified(LocalDateTime.now()); + + return documentRepository.save(document); + } + + /** + * Delete document by ID + */ + public void deleteDocument(UUID id) { + documentRepository.deleteById(id); + } + + /** + * Get document by ID + */ + public DocumentEntity getDocument(UUID id) { + return documentRepository.findById(id) + .orElseThrow(() -> new RuntimeException("Document not found")); + } + + /** + * Get all documents by document_id + */ + // public List getDocumentsByDocumentId(UUID documentId) { + // return documentRepository.findByDocumentId(documentId); + // } + + + + /** + * Search documents by title + */ + // public List searchDocumentsByTitle(String title) { + // return documentRepository.findByTitleContainingIgnoreCase(title); + // } + + /** + * Get all documents + */ + public List getAllDocuments() { + return documentRepository.findAll(); + } + + /** + * Generate embedding for text + */ + private List generateEmbedding(String text) { + EmbeddingRequest request = new EmbeddingRequest(List.of(text), null); + EmbeddingResponse response = embeddingModel.call(request); + float[] floatArray = response.getResults().get(0).getOutput(); + List doublesList = new ArrayList<>(); + for(float f: floatArray) { + doublesList.add((double) f); + } + + return doublesList; + } + + + // Inner class for document creation requests + public static class DocumentRequest { + private String documentId; + private String title; + private String chunkIndex; + private String content; + private String sourceUrl; + private Map metadata; + + // Constructors + public DocumentRequest() {} + + public DocumentRequest(String documentId, String title, String chunkIndex, String content, + String sourceUrl, Map metadata) { + this.documentId = documentId; + this.title = title; + this.chunkIndex = chunkIndex; + this.content = content; + this.sourceUrl = sourceUrl; + this.metadata = metadata; + } + + // Getters and Setters + public String getDocumentId() { + return documentId; + } + + public void setDocumentId(String documentId) { + this.documentId = documentId; + } + + public String getTitle() { + return title; + } + + public void setTitle(String title) { + this.title = title; + } + + public String getChunkIndex() { + return chunkIndex; + } + + public void setChunkIndex(String chunkIndex) { + this.chunkIndex = chunkIndex; + } + + public String getContent() { + return content; + } + + public void setContent(String content) { + this.content = content; + } + + public String getSourceUrl() { + return sourceUrl; + } + + public void setSourceUrl(String sourceUrl) { + this.sourceUrl = sourceUrl; + } + + public Map getMetadata() { + return metadata; + } + + public void setMetadata(Map metadata) { + this.metadata = metadata; + } + } +} diff --git a/src/main/java/net/curtlewis/gcprag/service/RagService.java b/src/main/java/net/curtlewis/gcprag/service/RagService.java new file mode 100644 index 0000000..34c250f --- /dev/null +++ b/src/main/java/net/curtlewis/gcprag/service/RagService.java @@ -0,0 +1,288 @@ +package net.curtlewis.gcprag.service; + +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import java.util.stream.Collectors; +import org.springframework.ai.chat.client.ChatClient; +import org.springframework.ai.chat.model.ChatModel; +import org.springframework.ai.embedding.EmbeddingModel; +import org.springframework.ai.embedding.EmbeddingRequest; +import org.springframework.ai.embedding.EmbeddingResponse; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.stereotype.Service; +import net.curtlewis.gcprag.chunk.ChunkEntity; +import net.curtlewis.gcprag.chunk.ChunkRepository; +import net.curtlewis.gcprag.document.DocumentRepository; + +@Service +public class RagService { + + + private final ChatModel openAiChatModel; + private final ChatModel ollamaChatModel; + private final ChatClient chatClient; + private final EmbeddingModel embeddingModel; + private final DocumentRepository documentRepository; + private final ChunkRepository chunkRepository; + + + // private final OpenAiChatModel openAiChatModel; + + // RAG configuration + private static final int DEFAULT_TOP_K = 5; + private static final double DEFAULT_SIMILARITY_THRESHOLD = 0.7; + + public RagService(@Qualifier("openAiChatModel") ChatModel chatModel, + @Qualifier("ollamaChatModel") ChatModel ollamaChatModel, + @Qualifier("ollamaEmbeddingModel") EmbeddingModel embeddingModel, + DocumentRepository documentRepository, ChunkRepository chunkRepository) { + + this.openAiChatModel = chatModel; + this.ollamaChatModel = ollamaChatModel; + this.chatClient = ChatClient.create(this.ollamaChatModel); + this.embeddingModel = embeddingModel; + this.documentRepository = documentRepository; + this.chunkRepository = chunkRepository; + // this.openAiChatModel = (OpenAiChatModel) model; + } + + // public RagService( + // ChatClient chatClientBuilder, EmbeddingModel embeddingModel, + // DocumentRepository documentRepository, ChunkRepository chunkRepository) { + // this.chatClient = chatClientBuilder; + // this.embeddingModel = embeddingModel; + // this.documentRepository = documentRepository; + // this.chunkRepository = chunkRepository; + // // this.openAiChatModel = (OpenAiChatModel) model; + // } + + /** + * Main RAG chat method - takes user question and returns AI response with context + */ + public String chat(String userQuestion) { + return chat(userQuestion, DEFAULT_TOP_K, DEFAULT_SIMILARITY_THRESHOLD); + } + + /** + * RAG chat with custom parameters + */ + public String chat(String userQuestion, int topK, double similarityThreshold) { + // 1. Generate embedding for the user question + List queryEmbedding = generateEmbedding(userQuestion); + + // 2. Retrieve relevant documents + List relevantDocs = + retrieveRelevantDocuments(queryEmbedding, topK, similarityThreshold); + + // 3. Create context from retrieved documents + String context = createContext(relevantDocs); + + // 4. Generate response using the context + String prompt = buildRagPrompt(userQuestion, context); + + return chatClient.prompt().user(prompt).call().content(); + } + + /** + * RAG chat with detailed response including sources + */ + public RagResponse chatWithSources(String userQuestion) { + return chatWithSources(userQuestion, DEFAULT_TOP_K, DEFAULT_SIMILARITY_THRESHOLD); + } + + public RagResponse chatWithSources(String userQuestion, int topK, double similarityThreshold) { + // Generate embedding for the user question + List queryEmbedding = generateEmbedding(userQuestion); + + // Retrieve relevant documents + List relevantDocs = + retrieveRelevantDocuments(queryEmbedding, topK, similarityThreshold); + + // Create context from retrieved documents + String context = createContext(relevantDocs); + + // Generate response using the context + String prompt = buildRagPrompt(userQuestion, context); + String response = chatClient.prompt().user(prompt).call().content(); + + // Return detailed response with sources + return new RagResponse(response, relevantDocs, context); + } + + // public RagResponse openAiChatWithSources(String userQuestion, int topK, double similarityThreshold) { + // // Generate embedding for the user question + // List queryEmbedding = generateEmbedding(userQuestion); + + // // Retrieve relevant documents + // List relevantDocs = + // retrieveRelevantDocuments(queryEmbedding, topK, similarityThreshold); + + // // Create context from retrieved documents + // String context = createContext(relevantDocs); + + // // Generate response using the context + // String prompt = buildRagPrompt(userQuestion, context); + // String response = openAiChatModel.call(prompt); + + // // Return detailed response with sources + // return new RagResponse(response, relevantDocs, context); + // } + + /** + * Generate embedding for text + */ + private List generateEmbedding(String text) { + EmbeddingRequest request = new EmbeddingRequest(List.of(text), null); + EmbeddingResponse response = embeddingModel.call(request); + float[] floatArray = response.getResults().get(0).getOutput(); + List doublesList = new ArrayList<>(); + for(float f : floatArray) { + doublesList.add((double) f); + } + return doublesList; + } + + /** + * Retrieve relevant documents using vector similarity + * + * d.id, + * d.title, + * d.chunk_index, + * d.content, + * d.source_url, + * d.date_last_modified, + * d.metadata, + */ + private List retrieveRelevantDocuments(List queryEmbedding, int topK, + double threshold) { + // Convert embedding to string format for PostgreSQL + String embeddingStr = + "[" + queryEmbedding.stream().map(String::valueOf).collect(Collectors.joining(",")) + + "]"; + + // Query database for similar documents + List results = + chunkRepository.findSimilarDocumentsWithThreshold(embeddingStr, threshold, topK); + + // return results.stream().map(row -> { + // DocumentEntity entity = new DocumentEntity(); + // entity.setId((UUID) row[0]); // ID + // entity.setDocumentId((String) row[1]); // document_id + // entity.setTitle((String) row[2]); // title + // entity.setContent((String) row[3]); // content + // return entity; + // }).collect(Collectors.toList()); + + return results.stream().map(row -> { + ChunkEntity entity = new ChunkEntity(); + entity.setId((UUID) row[0]); // ID + entity.setDocumentId((UUID) row[1]); + entity.setChunkIndex((String) row[2]); + entity.setContent((String) row[3]); // content + + // Timestamp timestamp = (Timestamp) row[5]; + // entity.setCreatedAt(timestamp.toLocalDateTime()); + + // entity.setDateLastModified(timestamp.toLocalDateTime()); + // entity.setMetadata((Map) row[6]); + return entity; + }).collect(Collectors.toList()); + + + } + + /** + * Create context string from retrieved documents + */ + private String createContext(List documents) { + if (documents.isEmpty()) { + return "No relevant context found."; + } + + StringBuilder contextBuilder = new StringBuilder(); + contextBuilder.append("Relevant Information:\n\n"); + + for (int i = 0; i < documents.size(); i++) { + ChunkEntity doc = documents.get(i); + contextBuilder.append("Document ").append(i + 1).append(":\n"); + // contextBuilder.append("Title: ").append(doc.getTitle()).append("\n"); + contextBuilder.append("Content: ").append(doc.getContent()).append("\n"); + // if (doc.getSourceUrl() != null) { + // contextBuilder.append("Source: ").append(doc.getSourceUrl()).append("\n"); + // } + contextBuilder.append("\n"); + } + + return contextBuilder.toString(); + } + + /** + * Build RAG prompt with context and user question + */ + private String buildRagPrompt(String userQuestion, String context) { + return String.format(""" + You are an expert in Google Cloud Platform that answers questions based on the provided context. + + Context: + %s + + Question: %s + + Instructions: + - If given a choice of answers, state the letter that corresponds to your choice. + - Answer the question based on the provided context and provide the answer as the first sentence in your response + - If the context doesn't contain enough information to answer the question, say so + - Be specific and cite information from the context when possible + - Keep your answer short, concise and relevant + - The first sentence should be "The correct answer is: " and give the letter to the answer you think is most correct + + Answer: + """, context, userQuestion); + } + + /** + * Get database statistics + */ + public DatabaseStats getDatabaseStats() { + long totalDocuments = documentRepository.countAllDocuments(); + return new DatabaseStats(totalDocuments); + } + + // Inner classes for response objects + public static class RagResponse { + private final String response; + private final List sources; + private final String context; + + public RagResponse(String response, List sources, String context) { + this.response = response; + this.sources = sources; + this.context = context; + } + + public String getResponse() { + return response; + } + + public List getSources() { + return sources; + } + + public String getContext() { + return context; + } + } + + public static class DatabaseStats { + private final long totalDocuments; + + public DatabaseStats(long totalDocuments) { + this.totalDocuments = totalDocuments; + } + + public long getTotalDocuments() { + return totalDocuments; + } + } +} diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml new file mode 100644 index 0000000..f22fff3 --- /dev/null +++ b/src/main/resources/application.yaml @@ -0,0 +1,53 @@ +spring: + main: + allow-bean-definition-overriding: true + application: + name: gcprag + ai: + openai: + api_key: ${OPENAI_API_KEY} + chat: + options: + model: gpt-4o + temperature: 0.3 + ollama: + base-url: http://localhost:11434 + chat: + model: phi3:3.8b # Lightweight model for text generation + options: + temperature: 0.3 + top-p: 0.9 + max-tokens: 1000 + embedding: + model: nomic-embed-text + # options: + # Embedding-specific options can be added here + vectorstore: + pgvector: + index-type: HNSW + distance-type: COSINE_DISTANCE + dimensions: 768 # nomic-embed-text embedding dimensions + + datasource: + url: jdbc:postgresql://localhost:15432/gcp_docs + username: admin + password: password + driver-class-name: org.postgresql.Driver + + jpa: + # hibernate: + # ddl-auto: validate + show-sql: true + properties: + hibernate: + dialect: org.hibernate.dialect.PostgreSQLDialect + format_sql: true + +server: + port: 8080 + +logging: + level: + org.springframework.ai: DEBUG + org.springframework.jdbc: DEBUG + root: INFO diff --git a/src/test/java/net/curtlewis/gcprag/GcpragApplicationTests.java b/src/test/java/net/curtlewis/gcprag/GcpragApplicationTests.java new file mode 100644 index 0000000..d650c57 --- /dev/null +++ b/src/test/java/net/curtlewis/gcprag/GcpragApplicationTests.java @@ -0,0 +1,13 @@ +package net.curtlewis.gcprag; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest +class GcpragApplicationTests { + + @Test + void contextLoads() { + } + +}