diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..7bc07ec --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,10 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Environment-dependent path to Maven home directory +/mavenHomeManager.xml +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..52b9613 --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..59f5144 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/post-hub-platform.iml b/.idea/post-hub-platform.iml new file mode 100644 index 0000000..d6ebd48 --- /dev/null +++ b/.idea/post-hub-platform.iml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..35eb1dd --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/rag-service/.gitignore b/rag-service/.gitignore new file mode 100644 index 0000000..3ef075e --- /dev/null +++ b/rag-service/.gitignore @@ -0,0 +1,43 @@ +HELP.md +target/ +.mvn/wrapper/maven-wrapper.jar +!**/src/main/**/target/ +!**/src/test/**/target/ + +### STS ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache + +### IntelliJ IDEA ### +.idea +*.iws +*.iml +*.ipr + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ +build/ +!**/src/main/**/build/ +!**/src/test/**/build/ + +### VS Code ### +.vscode/ + +README.md +target/ +postgres/data/ + +.idea/ +servers.json/ + +src/test/ +ollama/ diff --git a/rag-service/.mvn/wrapper/maven-wrapper.properties b/rag-service/.mvn/wrapper/maven-wrapper.properties new file mode 100644 index 0000000..c0bcafe --- /dev/null +++ b/rag-service/.mvn/wrapper/maven-wrapper.properties @@ -0,0 +1,3 @@ +wrapperVersion=3.3.4 +distributionType=only-script +distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.11/apache-maven-3.9.11-bin.zip diff --git a/rag-service/mvnw b/rag-service/mvnw new file mode 100644 index 0000000..bd8896b --- /dev/null +++ b/rag-service/mvnw @@ -0,0 +1,295 @@ +#!/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.4 +# +# 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:]' +} + +scriptDir="$(dirname "$0")" +scriptName="$(basename "$0")" + +# 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 <"$scriptDir/.mvn/wrapper/maven-wrapper.properties" +[ -n "${distributionUrl-}" ] || die "cannot read distributionUrl property in $scriptDir/.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${scriptName#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 + +# Find the actual extracted directory name (handles snapshots where filename != directory name) +actualDistributionDir="" + +# First try the expected directory name (for regular distributions) +if [ -d "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" ]; then + if [ -f "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain/bin/$MVN_CMD" ]; then + actualDistributionDir="$distributionUrlNameMain" + fi +fi + +# If not found, search for any directory with the Maven executable (for snapshots) +if [ -z "$actualDistributionDir" ]; then + # enable globbing to iterate over items + set +f + for dir in "$TMP_DOWNLOAD_DIR"/*; do + if [ -d "$dir" ]; then + if [ -f "$dir/bin/$MVN_CMD" ]; then + actualDistributionDir="$(basename "$dir")" + break + fi + fi + done + set -f +fi + +if [ -z "$actualDistributionDir" ]; then + verbose "Contents of $TMP_DOWNLOAD_DIR:" + verbose "$(ls -la "$TMP_DOWNLOAD_DIR")" + die "Could not find Maven distribution directory in extracted archive" +fi + +verbose "Found extracted Maven distribution directory: $actualDistributionDir" +printf %s\\n "$distributionUrl" >"$TMP_DOWNLOAD_DIR/$actualDistributionDir/mvnw.url" +mv -- "$TMP_DOWNLOAD_DIR/$actualDistributionDir" "$MAVEN_HOME" || [ -d "$MAVEN_HOME" ] || die "fail to move MAVEN_HOME" + +clean || : +exec_maven "$@" diff --git a/rag-service/mvnw.cmd b/rag-service/mvnw.cmd new file mode 100644 index 0000000..92450f9 --- /dev/null +++ b/rag-service/mvnw.cmd @@ -0,0 +1,189 @@ +<# : 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.4 +@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 -eq $False) { "/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_M2_PATH = "$HOME/.m2" +if ($env:MAVEN_USER_HOME) { + $MAVEN_M2_PATH = "$env:MAVEN_USER_HOME" +} + +if (-not (Test-Path -Path $MAVEN_M2_PATH)) { + New-Item -Path $MAVEN_M2_PATH -ItemType Directory | Out-Null +} + +$MAVEN_WRAPPER_DISTS = $null +if ((Get-Item $MAVEN_M2_PATH).Target[0] -eq $null) { + $MAVEN_WRAPPER_DISTS = "$MAVEN_M2_PATH/wrapper/dists" +} else { + $MAVEN_WRAPPER_DISTS = (Get-Item $MAVEN_M2_PATH).Target[0] + "/wrapper/dists" +} + +$MAVEN_HOME_PARENT = "$MAVEN_WRAPPER_DISTS/$distributionUrlNameMain" +$MAVEN_HOME_NAME = ([System.Security.Cryptography.SHA256]::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 + +# Find the actual extracted directory name (handles snapshots where filename != directory name) +$actualDistributionDir = "" + +# First try the expected directory name (for regular distributions) +$expectedPath = Join-Path "$TMP_DOWNLOAD_DIR" "$distributionUrlNameMain" +$expectedMvnPath = Join-Path "$expectedPath" "bin/$MVN_CMD" +if ((Test-Path -Path $expectedPath -PathType Container) -and (Test-Path -Path $expectedMvnPath -PathType Leaf)) { + $actualDistributionDir = $distributionUrlNameMain +} + +# If not found, search for any directory with the Maven executable (for snapshots) +if (!$actualDistributionDir) { + Get-ChildItem -Path "$TMP_DOWNLOAD_DIR" -Directory | ForEach-Object { + $testPath = Join-Path $_.FullName "bin/$MVN_CMD" + if (Test-Path -Path $testPath -PathType Leaf) { + $actualDistributionDir = $_.Name + } + } +} + +if (!$actualDistributionDir) { + Write-Error "Could not find Maven distribution directory in extracted archive" +} + +Write-Verbose "Found extracted Maven distribution directory: $actualDistributionDir" +Rename-Item -Path "$TMP_DOWNLOAD_DIR/$actualDistributionDir" -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/rag-service/pom.xml b/rag-service/pom.xml new file mode 100644 index 0000000..aaee388 --- /dev/null +++ b/rag-service/pom.xml @@ -0,0 +1,195 @@ + + + 4.0.0 + + org.springframework.boot + spring-boot-starter-parent + 3.5.7 + + + com.balex + rag + 0.0.1-SNAPSHOT + rag + Backend for queries to RAG + + + + + + + + + + + + + + + 25 + 1.0.3 + 0.2.0 + + + + org.springframework.boot + spring-boot-starter-data-jpa + + + org.springframework.boot + spring-boot-starter-validation + + + org.apache.commons + commons-lang3 + 3.18.0 + + + org.mapstruct + mapstruct + 1.5.5.Final + + + org.mapstruct + mapstruct-processor + 1.5.5.Final + + + org.springframework.boot + spring-boot-starter-thymeleaf + + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.ai + spring-ai-starter-model-ollama + + + org.springframework.ai + spring-ai-starter-model-chat-memory-repository-jdbc + + + org.springframework.ai + spring-ai-starter-vector-store-pgvector + + + org.springframework.ai + spring-ai-advisors-vector-store + + + + org.postgresql + postgresql + runtime + + + + org.projectlombok + lombok + true + + + org.springframework.boot + spring-boot-starter-test + test + + + + com.github.pemistahl + lingua + 1.2.2 + + + org.apache.lucene + lucene-analysis-common + 10.3.1 + + + org.springframework.boot + spring-boot-starter-security + + + org.springframework.security + spring-security-test + test + + + io.jsonwebtoken + jjwt-api + 0.11.5 + + + io.jsonwebtoken + jjwt-impl + 0.11.5 + runtime + + + io.jsonwebtoken + jjwt-jackson + 0.11.5 + runtime + + + org.springdoc + springdoc-openapi-starter-webmvc-ui + 2.8.8 + + + + + + org.springframework.ai + spring-ai-bom + ${spring-ai.version} + pom + import + + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + + + org.projectlombok + lombok + + + org.projectlombok + lombok-mapstruct-binding + ${lombok-mapstruct-binding.version} + + + org.mapstruct + mapstruct-processor + 1.5.5.Final + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + org.projectlombok + lombok + + + + + + + + diff --git a/rag-service/src/main/java/com/balex/rag/RagApplication.java b/rag-service/src/main/java/com/balex/rag/RagApplication.java new file mode 100644 index 0000000..a28cd0f --- /dev/null +++ b/rag-service/src/main/java/com/balex/rag/RagApplication.java @@ -0,0 +1,74 @@ +package com.balex.rag; + +import com.balex.rag.advisors.expansion.ExpansionQueryAdvisor; +import com.balex.rag.advisors.rag.RagAdvisor; +import com.balex.rag.config.RagDefaultsProperties; +import com.balex.rag.config.RagExpansionProperties; +import com.balex.rag.repo.ChatRepository; +import com.balex.rag.service.PostgresChatMemory; +import lombok.RequiredArgsConstructor; +import org.springframework.ai.chat.client.ChatClient; +import org.springframework.ai.chat.client.advisor.MessageChatMemoryAdvisor; +import org.springframework.ai.chat.client.advisor.SimpleLoggerAdvisor; +import org.springframework.ai.chat.client.advisor.api.Advisor; +import org.springframework.ai.chat.memory.ChatMemory; +import org.springframework.ai.chat.model.ChatModel; +import org.springframework.ai.chat.prompt.PromptTemplate; +import org.springframework.ai.ollama.api.OllamaOptions; +import org.springframework.ai.vectorstore.VectorStore; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; + +@SpringBootApplication +@RequiredArgsConstructor +@EnableConfigurationProperties({RagDefaultsProperties.class, RagExpansionProperties.class}) +public class RagApplication { + + private final ChatRepository chatRepository; + private final VectorStore vectorStore; + private final ChatModel chatModel; + private final RagExpansionProperties expansionProperties; + + @Bean + public ChatClient chatClient( + ChatClient.Builder builder, + @Value("${rag.rerank-fetch-multiplier}") int rerankFetchMultiplier, + RagDefaultsProperties ragDefaults) { + return builder + .defaultAdvisors( + getHistoryAdvisor(0), + ExpansionQueryAdvisor.builder(chatModel, expansionProperties).order(1).build(), + SimpleLoggerAdvisor.builder().order(2).build(), + RagAdvisor.build(vectorStore) + .rerankFetchMultiplier(rerankFetchMultiplier) + .searchTopK(ragDefaults.searchTopK()) + .similarityThreshold(ragDefaults.similarityThreshold()) + .order(3).build(), + SimpleLoggerAdvisor.builder().order(4).build() + ) + .defaultOptions(OllamaOptions.builder() + .temperature(ragDefaults.temperature()) + .repeatPenalty(ragDefaults.repeatPenalty()) + .build()) + .build(); + } + + private Advisor getHistoryAdvisor(int order) { + return MessageChatMemoryAdvisor.builder(getChatMemory()).order(order).build(); + } + + private ChatMemory getChatMemory() { + return PostgresChatMemory.builder() + .maxMessages(8) + .chatMemoryRepository(chatRepository) + .build(); + } + + public static void main(String[] args) { + SpringApplication.run(RagApplication.class, args); + } + +} diff --git a/rag-service/src/main/java/com/balex/rag/advice/CommonControllerAdvice.java b/rag-service/src/main/java/com/balex/rag/advice/CommonControllerAdvice.java new file mode 100644 index 0000000..5c356dd --- /dev/null +++ b/rag-service/src/main/java/com/balex/rag/advice/CommonControllerAdvice.java @@ -0,0 +1,121 @@ +package com.balex.rag.advice; + +import com.balex.rag.model.constants.ApiConstants; +import com.balex.rag.model.exception.InvalidDataException; +import com.balex.rag.model.exception.InvalidPasswordException; +import com.balex.rag.model.exception.NotFoundException; +import com.balex.rag.service.model.exception.DataExistException; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.AuthenticationException; +import org.springframework.validation.ObjectError; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ControllerAdvice; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.ResponseBody; +import org.springframework.web.bind.annotation.ResponseStatus; + +import java.nio.file.AccessDeniedException; +import java.util.Arrays; +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; + +@Slf4j +@ControllerAdvice +public class CommonControllerAdvice { + + @ExceptionHandler + @ResponseBody + protected ResponseEntity handleNotFoundException(NotFoundException ex) { + logStackTrace(ex); + + return ResponseEntity + .status(HttpStatus.NOT_FOUND) + .body(ex.getMessage()); + } + + @ExceptionHandler(DataExistException.class) + @ResponseBody + protected ResponseEntity handleDataExistException(DataExistException ex) { + logStackTrace(ex); + + return ResponseEntity + .status(HttpStatus.CONFLICT) + .body(ex.getMessage()); + } + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity> handleMethodArgumentNotValidException(MethodArgumentNotValidException ex) { + logStackTrace(ex); + + Map errors = new HashMap<>(); + for (ObjectError error : ex.getBindingResult().getAllErrors()) { + String errorMessage = error.getDefaultMessage(); + errors.put("error", errorMessage); + } + + return new ResponseEntity<>(errors, HttpStatus.BAD_REQUEST); + } + + @ExceptionHandler(AuthenticationException.class) + @ResponseBody + protected ResponseEntity handleAuthenticationException(AuthenticationException ex) { + logStackTrace(ex); + return ResponseEntity + .status(HttpStatus.UNAUTHORIZED) + .body(ex.getMessage()); + } + + @ExceptionHandler(InvalidDataException.class) + @ResponseBody + protected ResponseEntity handleInvalidDataException(InvalidDataException ex) { + logStackTrace(ex); + return ResponseEntity + .status(HttpStatus.BAD_REQUEST) + .body(ex.getMessage()); + } + + @ExceptionHandler(InvalidPasswordException.class) + @ResponseStatus(HttpStatus.BAD_REQUEST) + @ResponseBody + public String handleInvalidPasswordException(InvalidPasswordException ex) { + return ex.getMessage(); + } + + @ExceptionHandler(AccessDeniedException.class) + @ResponseBody + protected ResponseEntity handleAccessDeniedException(AccessDeniedException ex) { + logStackTrace(ex); + return ResponseEntity + .status(HttpStatus.FORBIDDEN) + .body(ex.getMessage()); + } + + private void logStackTrace(Exception ex) { + StringBuilder stackTrace = new StringBuilder(); + + stackTrace.append(ApiConstants.ANSI_RED); + + stackTrace.append(ex.getMessage()).append(ApiConstants.BREAK_LINE); + + if (Objects.nonNull(ex.getCause())) { + stackTrace.append(ex.getCause().getMessage()).append(ApiConstants.BREAK_LINE); + } + + Arrays.stream(ex.getStackTrace()) + .filter(st -> st.getClassName().startsWith(ApiConstants.TIME_ZONE_PACKAGE_NAME)) + .forEach(st -> stackTrace + .append(st.getClassName()) + .append(".") + .append(st.getMethodName()) + .append(" (") + .append(st.getLineNumber()) + .append(") ") + ); + + log.error(stackTrace.append(ApiConstants.ANSI_WHITE).toString()); + } +} + diff --git a/rag-service/src/main/java/com/balex/rag/advisors/expansion/ExpansionQueryAdvisor.java b/rag-service/src/main/java/com/balex/rag/advisors/expansion/ExpansionQueryAdvisor.java new file mode 100644 index 0000000..0c179ce --- /dev/null +++ b/rag-service/src/main/java/com/balex/rag/advisors/expansion/ExpansionQueryAdvisor.java @@ -0,0 +1,89 @@ +package com.balex.rag.advisors.expansion; + +import com.balex.rag.config.RagExpansionProperties; +import lombok.Builder; +import lombok.Getter; +import org.springframework.ai.chat.client.ChatClient; +import org.springframework.ai.chat.client.ChatClientRequest; +import org.springframework.ai.chat.client.ChatClientResponse; +import org.springframework.ai.chat.client.advisor.api.AdvisorChain; +import org.springframework.ai.chat.client.advisor.api.BaseAdvisor; +import org.springframework.ai.chat.model.ChatModel; +import org.springframework.ai.chat.prompt.PromptTemplate; +import org.springframework.ai.ollama.api.OllamaOptions; + +import java.util.Map; + +@Builder +public class ExpansionQueryAdvisor implements BaseAdvisor { + + + private static final PromptTemplate template = PromptTemplate.builder() + .template(""" + Expand the search query by adding relevant terms. + + Rules: + - Keep all original words + - Add up to 5 specific terms + - Output ONLY the expanded query, nothing else + - No explanations, no formatting, no quotes, no bullet points + + Examples: + Question: what is spring + Query: what is spring framework Java dependency injection + + Question: how to configure security + Query: how to configure security Spring Security authentication authorization filter chain + + Question: {question} + Query: + """).build(); + + + public static final String ENRICHED_QUESTION = "ENRICHED_QUESTION"; + public static final String ORIGINAL_QUESTION = "ORIGINAL_QUESTION"; + public static final String EXPANSION_RATIO = "EXPANSION_RATIO"; + + private ChatClient chatClient; + + public static ExpansionQueryAdvisorBuilder builder(ChatModel chatModel, RagExpansionProperties props) { + return new ExpansionQueryAdvisorBuilder().chatClient(ChatClient.builder(chatModel) + .defaultOptions(OllamaOptions.builder() + .temperature(props.temperature()) + .topK(props.topK()) + .topP(props.topP()) + .repeatPenalty(props.repeatPenalty()) + .build()) + .build()); + } + + @Getter + private final int order; + + @Override + public ChatClientRequest before(ChatClientRequest chatClientRequest, AdvisorChain advisorChain) { + + String userQuestion = chatClientRequest.prompt().getUserMessage().getText(); + String enrichedQuestion = chatClient + .prompt() + .user(template.render(Map.of("question", userQuestion))) + .call() + .content(); + + double ratio = enrichedQuestion.length() / (double) userQuestion.length(); + + return chatClientRequest.mutate() + .context(ORIGINAL_QUESTION, userQuestion) + .context(ENRICHED_QUESTION, enrichedQuestion) + .context(EXPANSION_RATIO, ratio) + .build(); + } + + @Override + public ChatClientResponse after(ChatClientResponse chatClientResponse, AdvisorChain advisorChain) { + + + return chatClientResponse; + } + +} diff --git a/rag-service/src/main/java/com/balex/rag/advisors/rag/BM25RerankEngine.java b/rag-service/src/main/java/com/balex/rag/advisors/rag/BM25RerankEngine.java new file mode 100644 index 0000000..25c238a --- /dev/null +++ b/rag-service/src/main/java/com/balex/rag/advisors/rag/BM25RerankEngine.java @@ -0,0 +1,160 @@ +package com.balex.rag.advisors.rag; + +import com.balex.rag.model.exception.RerankException; +import com.github.pemistahl.lingua.api.Language; +import com.github.pemistahl.lingua.api.LanguageDetector; +import com.github.pemistahl.lingua.api.LanguageDetectorBuilder; +import lombok.Builder; +import org.apache.lucene.analysis.Analyzer; +import org.apache.lucene.analysis.TokenStream; +import org.apache.lucene.analysis.en.EnglishAnalyzer; +import org.apache.lucene.analysis.ru.RussianAnalyzer; +import org.apache.lucene.analysis.tokenattributes.CharTermAttribute; +import org.springframework.ai.document.Document; + +import java.io.IOException; +import java.util.*; +import java.util.stream.Collectors; + +import static com.balex.rag.model.constants.ApiErrorMessage.TOKENIZATION_ERROR; + +@Builder +public class BM25RerankEngine { + @Builder.Default + private static final LanguageDetector languageDetector = LanguageDetectorBuilder + .fromLanguages(Language.ENGLISH, Language.RUSSIAN) + .build(); + + // BM25 parameters + @Builder.Default + private final double K = 1.2; + @Builder.Default + private final double B = 0.75; + + public List rerank(List corpus, String query, int limit) { + + if (corpus == null || corpus.isEmpty()) { + return new ArrayList<>(); + } + + // Compute corpus statistics + CorpusStats stats = computeCorpusStats(corpus); + + // Tokenize query + List queryTerms = tokenize(query); + + // Score and sort documents + return corpus.stream() + .sorted((d1, d2) -> Double.compare( + score(queryTerms, d2, stats), + score(queryTerms, d1, stats) + )) + .limit(limit) + .collect(Collectors.toList()); + } + + private CorpusStats computeCorpusStats(List corpus) { + Map docFreq = new HashMap<>(); + Map> tokenizedDocs = new HashMap<>(); + int totalLength = 0; + int totalDocs = corpus.size(); + + // Process each document + for (Document doc : corpus) { + List tokens = tokenize(doc.getText()); + tokenizedDocs.put(doc, tokens); + totalLength += tokens.size(); + + // Update document frequencies + Set uniqueTerms = new HashSet<>(tokens); + for (String term : uniqueTerms) { + docFreq.put(term, docFreq.getOrDefault(term, 0) + 1); + } + } + + double avgDocLength = (double) totalLength / totalDocs; + + return new CorpusStats(docFreq, tokenizedDocs, avgDocLength, totalDocs); + } + + private double score(List queryTerms, Document doc, CorpusStats stats) { + List tokens = stats.tokenizedDocs.get(doc); + if (tokens == null) { + return 0.0; + } + + // Calculate term frequencies for this document + Map tfMap = new HashMap<>(); + for (String token : tokens) { + tfMap.put(token, tfMap.getOrDefault(token, 0) + 1); + } + + int docLength = tokens.size(); + double score = 0.0; + + // Calculate BM25 score + for (String term : queryTerms) { + int tf = tfMap.getOrDefault(term, 0); //просто его count - то есть этого влияет на его вес в документе + int df = stats.docFreq.getOrDefault(term, 1); + + // BM25 IDF calculation редкость слова - оно поднимает + double idf = Math.log(1 + (stats.totalDocs - df + 0.5) / (df + 0.5)); + + // BM25 term score calculation + double numerator = tf * (K + 1); + double denominator = tf + K * (1 - B + B * docLength / stats.avgDocLength); + score += idf * (numerator / denominator); + } + + return score; + } + + private List tokenize(String text) { + List tokens = new ArrayList<>(); + Analyzer analyzer = detectLanguageAnalyzer(text); + + try (TokenStream stream = analyzer.tokenStream(null, text)) { + stream.reset(); + while (stream.incrementToken()) { + tokens.add(stream.getAttribute(CharTermAttribute.class).toString()); + } + stream.end(); + } catch (IOException e) { + throw new RerankException(TOKENIZATION_ERROR + e.toString()); + } + + return tokens; + } + + private Analyzer detectLanguageAnalyzer(String text) { + Language lang = languageDetector.detectLanguageOf(text); + if (lang == Language.ENGLISH) { + return new EnglishAnalyzer(); + } else if (lang == Language.RUSSIAN) { + return new RussianAnalyzer(); + } else { + // Fallback to English analyzer for unsupported languages + return new EnglishAnalyzer(); + } + } + + // Inner class to hold corpus statistics + private static class CorpusStats { + final Map docFreq; + final Map> tokenizedDocs; + final double avgDocLength; + final int totalDocs; + + CorpusStats(Map docFreq, + Map> tokenizedDocs, + double avgDocLength, + int totalDocs) { + this.docFreq = docFreq; + this.tokenizedDocs = tokenizedDocs; + this.avgDocLength = avgDocLength; + this.totalDocs = totalDocs; + } + } + +} + diff --git a/rag-service/src/main/java/com/balex/rag/advisors/rag/RagAdvisor.java b/rag-service/src/main/java/com/balex/rag/advisors/rag/RagAdvisor.java new file mode 100644 index 0000000..721a3d4 --- /dev/null +++ b/rag-service/src/main/java/com/balex/rag/advisors/rag/RagAdvisor.java @@ -0,0 +1,77 @@ +package com.balex.rag.advisors.rag; + +import lombok.Builder; +import lombok.Getter; +import org.springframework.ai.chat.client.ChatClientRequest; +import org.springframework.ai.chat.client.ChatClientResponse; +import org.springframework.ai.chat.client.advisor.api.AdvisorChain; +import org.springframework.ai.chat.client.advisor.api.BaseAdvisor; +import org.springframework.ai.chat.prompt.PromptTemplate; +import org.springframework.ai.document.Document; +import org.springframework.ai.vectorstore.SearchRequest; +import org.springframework.ai.vectorstore.VectorStore; + +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import static com.balex.rag.advisors.expansion.ExpansionQueryAdvisor.ENRICHED_QUESTION; + + +@Builder +public class RagAdvisor implements BaseAdvisor { + + private static final PromptTemplate template = PromptTemplate.builder().template(""" + CONTEXT: {context} + Question: {question} + """).build(); + + private final int rerankFetchMultiplier; + private final int searchTopK; + private final double similarityThreshold; + private VectorStore vectorStore; + + @Getter + private final int order; + + public static RagAdvisorBuilder build(VectorStore vectorStore) { + return new RagAdvisorBuilder().vectorStore(vectorStore); + } + + @Override + public ChatClientRequest before(ChatClientRequest chatClientRequest, AdvisorChain advisorChain) { + String originalUserQuestion = chatClientRequest.prompt().getUserMessage().getText(); + String queryToRag = chatClientRequest.context().getOrDefault(ENRICHED_QUESTION, originalUserQuestion).toString(); + + SearchRequest searchRequest = SearchRequest.builder() + .query(queryToRag) + .topK(searchTopK * rerankFetchMultiplier) + .similarityThreshold(similarityThreshold) + .build(); + + List documents = vectorStore.similaritySearch(searchRequest); + + if (documents == null || documents.isEmpty()) { + return chatClientRequest.mutate().context("CONTEXT", "EMPTY").build(); + } + + BM25RerankEngine rerankEngine = BM25RerankEngine.builder().build(); + documents = rerankEngine.rerank(documents, queryToRag, searchTopK); + + String llmContext = documents.stream() + .map(Document::getText) + .collect(Collectors.joining(System.lineSeparator())); + + String finalUserPrompt = template.render( + Map.of("context", llmContext, "question", originalUserQuestion)); + + return chatClientRequest.mutate() + .prompt(chatClientRequest.prompt().augmentUserMessage(finalUserPrompt)) + .build(); + } + + @Override + public ChatClientResponse after(ChatClientResponse chatClientResponse, AdvisorChain advisorChain) { + return chatClientResponse; + } +} \ No newline at end of file diff --git a/rag-service/src/main/java/com/balex/rag/config/RagDefaultsProperties.java b/rag-service/src/main/java/com/balex/rag/config/RagDefaultsProperties.java new file mode 100644 index 0000000..d9b5584 --- /dev/null +++ b/rag-service/src/main/java/com/balex/rag/config/RagDefaultsProperties.java @@ -0,0 +1,15 @@ +package com.balex.rag.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.bind.DefaultValue; + +@ConfigurationProperties(prefix = "rag.defaults") +public record RagDefaultsProperties( + @DefaultValue("true") boolean onlyContext, + @DefaultValue("2") int topK, + @DefaultValue("0.7") double topP, + @DefaultValue("0.3") double temperature, + @DefaultValue("1.1") double repeatPenalty, + @DefaultValue("2") int searchTopK, + @DefaultValue("0.3") double similarityThreshold +) {} \ No newline at end of file diff --git a/rag-service/src/main/java/com/balex/rag/config/RagExpansionProperties.java b/rag-service/src/main/java/com/balex/rag/config/RagExpansionProperties.java new file mode 100644 index 0000000..21955cd --- /dev/null +++ b/rag-service/src/main/java/com/balex/rag/config/RagExpansionProperties.java @@ -0,0 +1,12 @@ +package com.balex.rag.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.bind.DefaultValue; + +@ConfigurationProperties(prefix = "rag.expansion") +public record RagExpansionProperties( + @DefaultValue("0.0") double temperature, + @DefaultValue("1") int topK, + @DefaultValue("0.1") double topP, + @DefaultValue("1.0") double repeatPenalty +) {} diff --git a/rag-service/src/main/java/com/balex/rag/config/SecurityConfig.java b/rag-service/src/main/java/com/balex/rag/config/SecurityConfig.java new file mode 100644 index 0000000..d687f58 --- /dev/null +++ b/rag-service/src/main/java/com/balex/rag/config/SecurityConfig.java @@ -0,0 +1,106 @@ +package com.balex.rag.config; + +import com.balex.rag.security.filter.JwtRequestFilter; +import com.balex.rag.security.handler.AccessRestrictionHandler; +import jakarta.servlet.DispatcherType; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.dao.DaoAuthenticationProvider; +import org.springframework.security.config.Customizer; +import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.HttpStatusEntryPoint; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; + +import java.util.List; + +@Configuration +@EnableWebSecurity +@EnableMethodSecurity +@RequiredArgsConstructor +public class SecurityConfig { + private final JwtRequestFilter jwtRequestFilter; + private final AccessRestrictionHandler accessRestrictionHandler; + + +// private static final String GET = "GET"; +// private static final String POST = "POST"; +// private static final AntPathRequestMatcher[] NOT_SECURED_URLS = new AntPathRequestMatcher[] { +// new AntPathRequestMatcher("/auth/login", POST), +// new AntPathRequestMatcher("/auth/register", POST), +// }; + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + http + .cors(Customizer.withDefaults()) + .csrf(AbstractHttpConfigurer::disable) + .authorizeHttpRequests(auth -> auth + .dispatcherTypeMatchers(DispatcherType.ASYNC).permitAll() + .requestMatchers(HttpMethod.OPTIONS, "/**").permitAll() + .requestMatchers(HttpMethod.GET, "/auth/refresh/token").permitAll() + .requestMatchers(HttpMethod.POST, "/auth/login", "/auth/register").permitAll() + .requestMatchers( + "/v3/api-docs/**", + "/swagger-ui/**", + "/swagger-ui.html", + "/webjars/**", + "/actuator/**" + ).permitAll() + .anyRequest().authenticated() + ) + .exceptionHandling(exceptions -> exceptions + .authenticationEntryPoint(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED)) + .accessDeniedHandler(accessRestrictionHandler) + ) + .addFilterBefore(jwtRequestFilter, UsernamePasswordAuthenticationFilter.class); + + return http.build(); + } + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } + + @Bean + public DaoAuthenticationProvider daoAuthenticationProvider(UserDetailsService userDetailsService) { + DaoAuthenticationProvider daoAuthenticationProvider = new DaoAuthenticationProvider(userDetailsService); + daoAuthenticationProvider.setPasswordEncoder(passwordEncoder()); + return daoAuthenticationProvider; + } + + @Bean + public AuthenticationManager authenticationManagerBean(AuthenticationConfiguration authenticationConfiguration) throws Exception { + return authenticationConfiguration.getAuthenticationManager(); + } + + @Bean + public CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration config = new CorsConfiguration(); + config.setAllowedOrigins(List.of("http://localhost:5173")); + config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS")); + config.setAllowedHeaders(List.of("*")); + config.setAllowCredentials(true); + + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", config); + return source; + } + +} + diff --git a/rag-service/src/main/java/com/balex/rag/controller/AuthController.java b/rag-service/src/main/java/com/balex/rag/controller/AuthController.java new file mode 100644 index 0000000..944e1b0 --- /dev/null +++ b/rag-service/src/main/java/com/balex/rag/controller/AuthController.java @@ -0,0 +1,83 @@ +package com.balex.rag.controller; + +import com.balex.rag.model.constants.ApiLogMessage; +import com.balex.rag.model.dto.UserProfileDTO; +import com.balex.rag.model.request.user.LoginRequest; +import com.balex.rag.model.request.user.RegistrationUserRequest; +import com.balex.rag.model.response.RagResponse; +import com.balex.rag.service.AuthService; +import com.balex.rag.utils.ApiUtils; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +@Slf4j +@RestController +@Validated +@RequiredArgsConstructor +@RequestMapping("${end.points.auth}") +public class AuthController { + private final AuthService authService; + + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "Successful authorization", + content = @Content(mediaType = "application/json", + examples = @ExampleObject(value = "{ \"token\": \"eyJhbGcIoIJIuz...\" }"))) + }) + @PostMapping("${end.points.login}") + @Operation(summary = "User login", description = "Authenticates the user and returns an access/refresh token" + ) + public ResponseEntity login( + @RequestBody @Valid LoginRequest request, + HttpServletResponse response) { + log.trace(ApiLogMessage.NAME_OF_CURRENT_METHOD.getValue(), ApiUtils.getMethodName()); + + RagResponse result = authService.login(request); + Cookie authorizationCookie = ApiUtils.createAuthCookie(result.getPayload().getToken()); + response.addCookie(authorizationCookie); + + return ResponseEntity.ok(result); + } + + @GetMapping("${end.points.refresh.token}") + @Operation(summary = "Refresh access token", description = "Generates new access token using provided refresh token" + ) + public ResponseEntity> refreshToken( + @RequestParam(name = "token") String refreshToken, + HttpServletResponse response) { + log.trace(ApiLogMessage.NAME_OF_CURRENT_METHOD.getValue(), ApiUtils.getMethodName()); + + RagResponse result = authService.refreshAccessToken(refreshToken); + Cookie authorizationCookie = ApiUtils.createAuthCookie(result.getPayload().getToken()); + response.addCookie(authorizationCookie); + + return ResponseEntity.ok(result); + } + + @PostMapping("${end.points.register}") + @Operation(summary = "Register a new user", description = "Creates new user and returns authentication details" + ) + public ResponseEntity register( + @RequestBody @Valid RegistrationUserRequest request, + HttpServletResponse response) { + log.trace(ApiLogMessage.NAME_OF_CURRENT_METHOD.getValue(), ApiUtils.getMethodName()); + + RagResponse result = authService.registerUser(request); + Cookie authorizationCookie = ApiUtils.createAuthCookie(result.getPayload().getToken()); + response.addCookie(authorizationCookie); + + return ResponseEntity.ok(result); + } + +} + diff --git a/rag-service/src/main/java/com/balex/rag/controller/ChatController.java b/rag-service/src/main/java/com/balex/rag/controller/ChatController.java new file mode 100644 index 0000000..0cc6a57 --- /dev/null +++ b/rag-service/src/main/java/com/balex/rag/controller/ChatController.java @@ -0,0 +1,52 @@ +package com.balex.rag.controller; + +import com.balex.rag.model.constants.ApiLogMessage; +import com.balex.rag.model.entity.Chat; +import com.balex.rag.service.ChatService; +import com.balex.rag.utils.ApiUtils; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@Slf4j +@RestController +@Validated +@RequiredArgsConstructor +@RequestMapping("${end.points.chat}") +public class ChatController { + + private final ChatService chatService; + + @GetMapping("") + public ResponseEntity> mainPage() { + log.trace(ApiLogMessage.NAME_OF_CURRENT_METHOD.getValue(), ApiUtils.getMethodName()); + List response = chatService.getAllChats(); + return ResponseEntity.ok(response); + } + + @GetMapping("/{chatId}") + public ResponseEntity showChat(@PathVariable Long chatId) { + log.trace(ApiLogMessage.NAME_OF_CURRENT_METHOD.getValue(), ApiUtils.getMethodName()); + Chat response = chatService.getChat(chatId); + return ResponseEntity.ok(response); + } + + @PostMapping("/new") + public ResponseEntity newChat(@RequestParam String title) { + log.trace(ApiLogMessage.NAME_OF_CURRENT_METHOD.getValue(), ApiUtils.getMethodName()); + Chat chat = chatService.createNewChat(title); + return ResponseEntity.ok(chat); + } + + @DeleteMapping("/{chatId}") + public ResponseEntity deleteChat(@PathVariable Long chatId) { + log.trace(ApiLogMessage.NAME_OF_CURRENT_METHOD.getValue(), ApiUtils.getMethodName()); + chatService.deleteChat(chatId); + return ResponseEntity.noContent().build(); + } +} + diff --git a/rag-service/src/main/java/com/balex/rag/controller/ChatEntryController.java b/rag-service/src/main/java/com/balex/rag/controller/ChatEntryController.java new file mode 100644 index 0000000..562a0ba --- /dev/null +++ b/rag-service/src/main/java/com/balex/rag/controller/ChatEntryController.java @@ -0,0 +1,40 @@ +package com.balex.rag.controller; + +import com.balex.rag.config.RagDefaultsProperties; +import com.balex.rag.model.constants.ApiLogMessage; +import com.balex.rag.model.dto.UserEntryRequest; +import com.balex.rag.model.entity.ChatEntry; +import com.balex.rag.service.ChatEntryService; +import com.balex.rag.utils.ApiUtils; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@Slf4j +@RestController +@Validated +@RequiredArgsConstructor +@RequestMapping("${end.points.entry}") +public class ChatEntryController { + + private final ChatEntryService chatEntryService; + private final RagDefaultsProperties ragDefaults; + + @PostMapping("/{chatId}") + public ResponseEntity addUserEntry( + @PathVariable Long chatId, + @RequestBody UserEntryRequest request) { + log.trace(ApiLogMessage.NAME_OF_CURRENT_METHOD.getValue(), ApiUtils.getMethodName()); + + boolean onlyContext = request.onlyContext() != null ? request.onlyContext() : ragDefaults.onlyContext(); + int topK = request.topK() != null ? request.topK() : ragDefaults.topK(); + double topP = request.topP() != null ? request.topP() : ragDefaults.topP(); + + ChatEntry entry = chatEntryService.addUserEntry(chatId, request.content(), onlyContext, topK, topP); + return ResponseEntity.ok(entry); + } +} \ No newline at end of file diff --git a/rag-service/src/main/java/com/balex/rag/controller/DocumentUploadStreamController.java b/rag-service/src/main/java/com/balex/rag/controller/DocumentUploadStreamController.java new file mode 100644 index 0000000..9f3f607 --- /dev/null +++ b/rag-service/src/main/java/com/balex/rag/controller/DocumentUploadStreamController.java @@ -0,0 +1,47 @@ +package com.balex.rag.controller; + +import com.balex.rag.service.UserDocumentService; +import com.balex.rag.utils.ApiUtils; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.MediaType; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestPart; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +import java.util.List; + +@Slf4j +@RestController +@Validated +@RequiredArgsConstructor +@RequestMapping("${end.points.document}") +public class DocumentUploadStreamController { + + private final UserDocumentService userDocumentService; + private final ApiUtils apiUtils; + + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "Document processing progress stream", + content = @Content(mediaType = "text/event-stream", + examples = @ExampleObject( + value = "data: {\"percent\": 33, \"processedFiles\": 1, \"totalFiles\": 3, \"currentFile\": \"doc1.txt\"}\n\n" + ))) + }) + @PostMapping(value = "/upload-stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE) + public SseEmitter uploadDocumentsWithProgress( + @RequestPart("files") @Valid List files + ) { + Integer userId = apiUtils.getUserIdFromAuthentication(); + return userDocumentService.processUploadedFilesWithSse(files, userId.longValue()); + } +} \ No newline at end of file diff --git a/rag-service/src/main/java/com/balex/rag/controller/UserController.java b/rag-service/src/main/java/com/balex/rag/controller/UserController.java new file mode 100644 index 0000000..401c9f5 --- /dev/null +++ b/rag-service/src/main/java/com/balex/rag/controller/UserController.java @@ -0,0 +1,99 @@ +package com.balex.rag.controller; + +import com.balex.rag.model.constants.ApiLogMessage; +import com.balex.rag.model.dto.UserDTO; +import com.balex.rag.model.dto.UserSearchDTO; +import com.balex.rag.model.entity.UserInfo; +import com.balex.rag.model.request.user.NewUserRequest; +import com.balex.rag.model.request.user.UpdateUserRequest; +import com.balex.rag.model.response.PaginationResponse; +import com.balex.rag.model.response.RagResponse; +import com.balex.rag.service.UserService; +import com.balex.rag.utils.ApiUtils; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +@Slf4j +@RestController +@Validated +@RequiredArgsConstructor +@RequestMapping("${end.points.users}") +public class UserController { + private final UserService userService; + + @GetMapping("${end.points.id}") + public ResponseEntity> getUserById( + @PathVariable(name = "id") Integer userId) { + log.trace(ApiLogMessage.NAME_OF_CURRENT_METHOD.getValue(), ApiUtils.getMethodName()); + + RagResponse response = userService.getById(userId); + return ResponseEntity.ok(response); + } + + @PostMapping("${end.points.create}") + public ResponseEntity> createUser( + @RequestBody @Valid NewUserRequest request) { + log.trace(ApiLogMessage.NAME_OF_CURRENT_METHOD.getValue(), ApiUtils.getMethodName()); + + RagResponse createdUser = userService.createUser(request); + return ResponseEntity.ok(createdUser); + } + + @GetMapping("${end.points.userinfo}") + public ResponseEntity> getUserInfo( + @RequestHeader("Authorization") String token) { + log.trace(ApiLogMessage.NAME_OF_CURRENT_METHOD.getValue(), ApiUtils.getMethodName()); + + RagResponse userInfo = userService.getUserInfo(token); + + return ResponseEntity.ok(userInfo); + } + + @DeleteMapping("${end.points.userinfo}") + public ResponseEntity> deleteUserDocuments( + @RequestHeader("Authorization") String token) { + log.trace(ApiLogMessage.NAME_OF_CURRENT_METHOD.getValue(), ApiUtils.getMethodName()); + + RagResponse deletedCount = userService.deleteUserDocuments(token); + + return ResponseEntity.ok(deletedCount); + } + + + @PutMapping("${end.points.id}") + public ResponseEntity> updateUserById( + @PathVariable(name = "id") Integer userId, + @RequestBody @Valid UpdateUserRequest request) { + log.trace(ApiLogMessage.NAME_OF_CURRENT_METHOD.getValue(), ApiUtils.getMethodName()); + + RagResponse updatedPost = userService.updateUser(userId, request); + return ResponseEntity.ok(updatedPost); + } + + @DeleteMapping("${end.points.id}") + public ResponseEntity softDeleteUser( + @PathVariable(name = "id") Integer userId + ) { + log.trace(ApiLogMessage.NAME_OF_CURRENT_METHOD.getValue(), ApiUtils.getMethodName()); + + userService.softDeleteUser(userId); + return ResponseEntity.ok().build(); + } + + @GetMapping("${end.points.all}") + public ResponseEntity>> getAllUsers( + @RequestParam(name = "page", defaultValue = "0") int page, + @RequestParam(name = "limit", defaultValue = "10") int limit) { + log.trace(ApiLogMessage.NAME_OF_CURRENT_METHOD.getValue(), ApiUtils.getMethodName()); + + Pageable pageable = PageRequest.of(page, limit); + RagResponse> response = userService.findAllUsers(pageable); + return ResponseEntity.ok(response); + } +} diff --git a/rag-service/src/main/java/com/balex/rag/mapper/UserMapper.java b/rag-service/src/main/java/com/balex/rag/mapper/UserMapper.java new file mode 100644 index 0000000..83911ec --- /dev/null +++ b/rag-service/src/main/java/com/balex/rag/mapper/UserMapper.java @@ -0,0 +1,51 @@ +package com.balex.rag.mapper; + +import com.balex.rag.model.dto.UserDTO; +import com.balex.rag.model.dto.UserProfileDTO; +import com.balex.rag.model.dto.UserSearchDTO; +import com.balex.rag.model.entity.User; +import com.balex.rag.model.enums.RegistrationStatus; +import com.balex.rag.model.request.user.NewUserRequest; +import com.balex.rag.model.request.user.RegistrationUserRequest; +import com.balex.rag.model.request.user.UpdateUserRequest; +import org.hibernate.type.descriptor.DateTimeUtils; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +import org.mapstruct.MappingTarget; +import org.mapstruct.NullValuePropertyMappingStrategy; + +import java.util.Objects; + +@Mapper( + componentModel = "spring", + nullValuePropertyMappingStrategy = NullValuePropertyMappingStrategy.IGNORE, + imports = {RegistrationStatus.class, Objects.class, DateTimeUtils.class} +) +public interface UserMapper { + + UserDTO toDto(User user); + + @Mapping(target = "id", ignore = true) + @Mapping(target = "created", ignore = true) + @Mapping(target = "registrationStatus", expression = "java(RegistrationStatus.ACTIVE)") + User createUser(NewUserRequest request); + + @Mapping(target = "id", ignore = true) + @Mapping(target = "created", ignore = true) + void updateUser(@MappingTarget User user, UpdateUserRequest request); + + @Mapping(source = "deleted", target = "isDeleted") + UserSearchDTO toUserSearchDto(User user); + + @Mapping(target = "username", source = "user.username") + @Mapping(target = "email", source = "user.email") + @Mapping(target = "token", source = "token") + @Mapping(target = "refreshToken", source = "refreshToken") + UserProfileDTO toUserProfileDto(User user, String token, String refreshToken); + + @Mapping(target = "password", ignore = true) + @Mapping(target = "registrationStatus", expression = "java(RegistrationStatus.ACTIVE)") + User fromDto(RegistrationUserRequest request); + +} + diff --git a/rag-service/src/main/java/com/balex/rag/model/LoadedDocument.java b/rag-service/src/main/java/com/balex/rag/model/LoadedDocument.java new file mode 100644 index 0000000..791779c --- /dev/null +++ b/rag-service/src/main/java/com/balex/rag/model/LoadedDocument.java @@ -0,0 +1,37 @@ +package com.balex.rag.model; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.CreationTimestamp; + +import java.time.LocalDateTime; + +@Entity +@Data +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class LoadedDocument { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private String filename; + private String contentHash; + private String documentType; + private int chunkCount; + + @CreationTimestamp + private LocalDateTime loadedAt; + + @Column(name = "user_id", nullable = false) + private Long userId; +} diff --git a/rag-service/src/main/java/com/balex/rag/model/UploadProgress.java b/rag-service/src/main/java/com/balex/rag/model/UploadProgress.java new file mode 100644 index 0000000..204c869 --- /dev/null +++ b/rag-service/src/main/java/com/balex/rag/model/UploadProgress.java @@ -0,0 +1,18 @@ +package com.balex.rag.model; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class UploadProgress { + private int percent; + private int processedFiles; + private int totalFiles; + private String currentFile; + private String status; // "processing", "completed", "error" +} \ No newline at end of file diff --git a/rag-service/src/main/java/com/balex/rag/model/constants/ApiConstants.java b/rag-service/src/main/java/com/balex/rag/model/constants/ApiConstants.java new file mode 100644 index 0000000..6cb3949 --- /dev/null +++ b/rag-service/src/main/java/com/balex/rag/model/constants/ApiConstants.java @@ -0,0 +1,31 @@ +package com.balex.rag.model.constants; + +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public final class ApiConstants { + + public static final String UNDEFINED = "undefined"; + public static final String EMPTY_FILENAME = "unknown"; + public static final String NO_NEW_DOCUMENTS_UPLOADED = "No new documents uploaded"; + public static final String DOCUMENTS_UPLOADED = "Documents uploaded: "; + public static final String ANSI_RED = "\u001B[31m"; + public static final String ANSI_WHITE = "\u001B[37m"; + public static final String BREAK_LINE = "\n"; + public static final String TIME_ZONE_PACKAGE_NAME = "java.time.zone"; + public static final String DASH = "-"; + public static final String PASSWORD_ALL_CHARACTERS = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789~`!@#$%^&*()-_=+[{]}\\|;:'\",<.>/?"; + public static final String PASSWORD_LETTERS_UPPER_CASE = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"; + public static final String PASSWORD_LETTERS_LOWER_CASE = "abcdefghijklmnopqrstuvwxyz"; + public static final String PASSWORD_DIGITS = "0123456789"; + public static final String PASSWORD_CHARACTERS = "~`!@#$%^&*()-_=+[{]}\\|;:'\",<.>/?"; + public static final Integer REQUIRED_MIN_PASSWORD_LENGTH = 8; + public static final Integer REQUIRED_MIN_LETTERS_NUMBER_EVERY_CASE_IN_PASSWORD = 1; + public static final Integer REQUIRED_MIN_DIGITS_NUMBER_IN_PASSWORD = 1; + public static final Integer REQUIRED_MIN_CHARACTERS_NUMBER_IN_PASSWORD = 1; + public static final String USER_ROLE = "USER_ROLE"; + public static final Integer MAX_FILES_ALLOWED_FOR_LOAD = 10; + +} diff --git a/rag-service/src/main/java/com/balex/rag/model/constants/ApiErrorMessage.java b/rag-service/src/main/java/com/balex/rag/model/constants/ApiErrorMessage.java new file mode 100644 index 0000000..5e2604c --- /dev/null +++ b/rag-service/src/main/java/com/balex/rag/model/constants/ApiErrorMessage.java @@ -0,0 +1,51 @@ +package com.balex.rag.model.constants; + +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public enum ApiErrorMessage { + POST_NOT_FOUND_BY_ID("Post with ID: %s was not found"), + POST_ALREADY_EXISTS("Post with Title: %s already exists"), + USER_NOT_FOUND_BY_ID("User with ID: %s was not found"), + USERNAME_ALREADY_EXISTS("Username: %s already exists"), + USERNAME_NOT_FOUND("Username: %s was not found"), + EMAIL_ALREADY_EXISTS("Email: %s already exists"), + EMAIL_NOT_FOUND("Email: %s was not found"), + USER_ROLE_NOT_FOUND("Role was not found"), + COMMENT_NOT_FOUND_BY_ID("Comment with ID: %s was not found"), + + TOKENIZATION_ERROR("Tokenization failed"), + + UPLOADED_FILENAME_EMPTY("Filename is empty"), + UPLOAD_FILE_READ_ERROR("Failed to read file"), + + INVALID_TOKEN_SIGNATURE("Invalid token signature"), + ERROR_DURING_JWT_PROCESSING("An unexpected error occurred during JWT processing"), + TOKEN_EXPIRED("Token expired."), + UNEXPECTED_ERROR_OCCURRED("An unexpected error occurred. Please try again later."), + + AUTHENTICATION_FAILED_FOR_USER("Authentication failed for user: {}. "), + INVALID_USER_OR_PASSWORD("Invalid email or password. Try again"), + INVALID_USER_REGISTRATION_STATUS("Invalid user registration status: %s. "), + NOT_FOUND_REFRESH_TOKEN("Refresh token not found."), + + MISMATCH_PASSWORDS("Password does not match"), + INVALID_PASSWORD("Invalid password. It must have: " + + "length at least " + ApiConstants.REQUIRED_MIN_PASSWORD_LENGTH + ", including " + + ApiConstants.REQUIRED_MIN_LETTERS_NUMBER_EVERY_CASE_IN_PASSWORD + " letter(s) in upper and lower cases, " + + ApiConstants.REQUIRED_MIN_CHARACTERS_NUMBER_IN_PASSWORD + " character(s), " + + ApiConstants.REQUIRED_MIN_DIGITS_NUMBER_IN_PASSWORD + " digit(s). "), + HAVE_NO_ACCESS("You don't have the necessary permissions"), + KAFKA_SEND_FAILED("Kafka message didn't send."), + ; + + private final String message; + + public String getMessage(Object... args) { + return String.format(message, args); + } +} + diff --git a/rag-service/src/main/java/com/balex/rag/model/constants/ApiLogMessage.java b/rag-service/src/main/java/com/balex/rag/model/constants/ApiLogMessage.java new file mode 100644 index 0000000..529c6e6 --- /dev/null +++ b/rag-service/src/main/java/com/balex/rag/model/constants/ApiLogMessage.java @@ -0,0 +1,13 @@ +package com.balex.rag.model.constants; + +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public enum ApiLogMessage { + NAME_OF_CURRENT_METHOD("Current method: {}"); + private final String value; +} + diff --git a/rag-service/src/main/java/com/balex/rag/model/constants/ApiMessage.java b/rag-service/src/main/java/com/balex/rag/model/constants/ApiMessage.java new file mode 100644 index 0000000..2dbe7be --- /dev/null +++ b/rag-service/src/main/java/com/balex/rag/model/constants/ApiMessage.java @@ -0,0 +1,17 @@ +package com.balex.rag.model.constants; + +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public enum ApiMessage { + TOKEN_CREATED_OR_UPDATED("User's token has been created or updated"), + ; + + private final String message; + +} + + diff --git a/rag-service/src/main/java/com/balex/rag/model/dto/UserDTO.java b/rag-service/src/main/java/com/balex/rag/model/dto/UserDTO.java new file mode 100644 index 0000000..1220ef1 --- /dev/null +++ b/rag-service/src/main/java/com/balex/rag/model/dto/UserDTO.java @@ -0,0 +1,24 @@ +package com.balex.rag.model.dto; + +import com.balex.rag.model.enums.RegistrationStatus; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.io.Serializable; +import java.time.LocalDateTime; + +@Data +@AllArgsConstructor +@NoArgsConstructor +public class UserDTO implements Serializable { + + private Integer id; + private String username; + private String email; + private LocalDateTime created; + private LocalDateTime lastLogin; + + private RegistrationStatus registrationStatus; +} + diff --git a/rag-service/src/main/java/com/balex/rag/model/dto/UserEntryRequest.java b/rag-service/src/main/java/com/balex/rag/model/dto/UserEntryRequest.java new file mode 100644 index 0000000..da5c8a4 --- /dev/null +++ b/rag-service/src/main/java/com/balex/rag/model/dto/UserEntryRequest.java @@ -0,0 +1,8 @@ +package com.balex.rag.model.dto; + +public record UserEntryRequest( + String content, + Boolean onlyContext, + Integer topK, + Double topP +) {} diff --git a/rag-service/src/main/java/com/balex/rag/model/dto/UserProfileDTO.java b/rag-service/src/main/java/com/balex/rag/model/dto/UserProfileDTO.java new file mode 100644 index 0000000..4be506f --- /dev/null +++ b/rag-service/src/main/java/com/balex/rag/model/dto/UserProfileDTO.java @@ -0,0 +1,25 @@ +package com.balex.rag.model.dto; + +import com.balex.rag.model.enums.RegistrationStatus; +import lombok.AllArgsConstructor; +import lombok.Data; + +import java.io.Serializable; +import java.time.LocalDateTime; +import java.util.List; + +@Data +@AllArgsConstructor +public class UserProfileDTO implements Serializable { + private Integer id; + private String username; + private String email; + + private RegistrationStatus registrationStatus; + private LocalDateTime lastLogin; + + private String token; + private String refreshToken; + +} + diff --git a/rag-service/src/main/java/com/balex/rag/model/dto/UserSearchDTO.java b/rag-service/src/main/java/com/balex/rag/model/dto/UserSearchDTO.java new file mode 100644 index 0000000..b71ba7e --- /dev/null +++ b/rag-service/src/main/java/com/balex/rag/model/dto/UserSearchDTO.java @@ -0,0 +1,21 @@ +package com.balex.rag.model.dto; + +import com.balex.rag.model.enums.RegistrationStatus; +import lombok.Data; + +import java.io.Serializable; +import java.time.LocalDateTime; +import java.util.List; + +@Data +public class UserSearchDTO implements Serializable { + + private Integer id; + private String username; + private String email; + private LocalDateTime created; + private Boolean isDeleted; + + private RegistrationStatus registrationStatus; + +} diff --git a/rag-service/src/main/java/com/balex/rag/model/entity/Chat.java b/rag-service/src/main/java/com/balex/rag/model/entity/Chat.java new file mode 100644 index 0000000..b8b5115 --- /dev/null +++ b/rag-service/src/main/java/com/balex/rag/model/entity/Chat.java @@ -0,0 +1,43 @@ +package com.balex.rag.model.entity; + +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.CreationTimestamp; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +@Entity +@Data +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class Chat { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private String title; + + @Column(name = "id_owner", nullable = false) + private Long idOwner; + + @CreationTimestamp + private LocalDateTime createdAt; + + @OrderBy("createdAt ASC") + @OneToMany(mappedBy = "chat", fetch = FetchType.EAGER, cascade = CascadeType.ALL, orphanRemoval = true) + private List history = new ArrayList<>(); + + + public void addChatEntry(ChatEntry entry) { + history.add(entry); + } + +} + diff --git a/rag-service/src/main/java/com/balex/rag/model/entity/ChatEntry.java b/rag-service/src/main/java/com/balex/rag/model/entity/ChatEntry.java new file mode 100644 index 0000000..d826a4e --- /dev/null +++ b/rag-service/src/main/java/com/balex/rag/model/entity/ChatEntry.java @@ -0,0 +1,50 @@ +package com.balex.rag.model.entity; + +import com.balex.rag.model.enums.Role; +import com.fasterxml.jackson.annotation.JsonIgnore; +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.CreationTimestamp; +import org.springframework.ai.chat.messages.Message; + +import java.time.LocalDateTime; + +@Entity +@Data +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class ChatEntry { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private String content; + + @Enumerated(EnumType.STRING) + private Role role; + + @CreationTimestamp + private LocalDateTime createdAt; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "chat_id") + @JsonIgnore + private Chat chat; + + public static ChatEntry toChatEntry(Message message) { + return ChatEntry.builder() + .role(Role.getRole(message.getMessageType().getValue())) + .content(message.getText()) + .build(); + } + + + public Message toMessage() { + return role.getMessage(content); + } +} \ No newline at end of file diff --git a/rag-service/src/main/java/com/balex/rag/model/entity/LoadedDocumentInfo.java b/rag-service/src/main/java/com/balex/rag/model/entity/LoadedDocumentInfo.java new file mode 100644 index 0000000..edfbf90 --- /dev/null +++ b/rag-service/src/main/java/com/balex/rag/model/entity/LoadedDocumentInfo.java @@ -0,0 +1,15 @@ +package com.balex.rag.model.entity; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.io.Serializable; + +@Data +@AllArgsConstructor +@NoArgsConstructor +public class LoadedDocumentInfo implements Serializable { + Long id; + String fileName; +} diff --git a/rag-service/src/main/java/com/balex/rag/model/entity/RefreshToken.java b/rag-service/src/main/java/com/balex/rag/model/entity/RefreshToken.java new file mode 100644 index 0000000..69298e1 --- /dev/null +++ b/rag-service/src/main/java/com/balex/rag/model/entity/RefreshToken.java @@ -0,0 +1,30 @@ +package com.balex.rag.model.entity; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.Setter; + +import java.time.LocalDateTime; + +@Entity +@Getter +@Setter +@Table(name = "refresh_token") +public class RefreshToken { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Integer id; + + @Column(nullable = false, unique = true) + private String token; + + @Column(nullable = false) + private LocalDateTime created; + + @OneToOne(fetch = FetchType.LAZY, cascade = CascadeType.PERSIST) + @JoinColumn(name = "user_id", nullable = false) + private User user; + +} + diff --git a/rag-service/src/main/java/com/balex/rag/model/entity/User.java b/rag-service/src/main/java/com/balex/rag/model/entity/User.java new file mode 100644 index 0000000..3b68e17 --- /dev/null +++ b/rag-service/src/main/java/com/balex/rag/model/entity/User.java @@ -0,0 +1,60 @@ +package com.balex.rag.model.entity; + +import com.balex.rag.model.enums.RegistrationStatus; +import jakarta.persistence.*; +import jakarta.validation.constraints.Size; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.time.LocalDateTime; +import java.util.Collection; +import java.util.List; + +@Entity +@Table(name = "users") +@Getter +@Setter +@NoArgsConstructor +public class User { + public static final String ID_FIELD = "id"; + public static final String USERNAME_NAME_FIELD = "username"; + public static final String EMAIL_NAME_FIELD = "email"; + public static final String DELETED_FIELD = "deleted"; + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Integer id; + + @Size(max = 30) + @Column(nullable = false, length = 30) + private String username; + + @Size(max = 80) + @Column(nullable = false, length = 80) + private String password; + + @Size(max = 50) + @Column(nullable = false, length = 50) + private String email; + + @Column(nullable = false, updatable = false) + private LocalDateTime created = LocalDateTime.now(); + + @Column(nullable = false) + private LocalDateTime updated = LocalDateTime.now(); + + @Column(name = "last_login") + private LocalDateTime lastLogin; + + @Column(nullable = false) + private Boolean deleted = false; + + @Enumerated(EnumType.STRING) + @Column(name = "registration_status", nullable = false) + private RegistrationStatus registrationStatus; + + + +} + diff --git a/rag-service/src/main/java/com/balex/rag/model/entity/UserInfo.java b/rag-service/src/main/java/com/balex/rag/model/entity/UserInfo.java new file mode 100644 index 0000000..0179a5a --- /dev/null +++ b/rag-service/src/main/java/com/balex/rag/model/entity/UserInfo.java @@ -0,0 +1,32 @@ +package com.balex.rag.model.entity; + +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.io.Serializable; +import java.util.List; + +import static com.balex.rag.model.constants.ApiConstants.MAX_FILES_ALLOWED_FOR_LOAD; + +@Data +@NoArgsConstructor +public class UserInfo implements Serializable { + + Integer id; + + String username; + + String email; + + List loadedFiles; + + Integer maxLoadedFiles = MAX_FILES_ALLOWED_FOR_LOAD; + + public UserInfo(Integer id, String username, String email, List loadedFiles) { + this.id = id; + this.username = username; + this.email = email; + this.loadedFiles = loadedFiles; + } + +} diff --git a/rag-service/src/main/java/com/balex/rag/model/enums/RegistrationStatus.java b/rag-service/src/main/java/com/balex/rag/model/enums/RegistrationStatus.java new file mode 100644 index 0000000..830d3ac --- /dev/null +++ b/rag-service/src/main/java/com/balex/rag/model/enums/RegistrationStatus.java @@ -0,0 +1,8 @@ +package com.balex.rag.model.enums; + +public enum RegistrationStatus { + ACTIVE, + INACTIVE +} + + diff --git a/rag-service/src/main/java/com/balex/rag/model/enums/ResponseStyle.java b/rag-service/src/main/java/com/balex/rag/model/enums/ResponseStyle.java new file mode 100644 index 0000000..6dcf80f --- /dev/null +++ b/rag-service/src/main/java/com/balex/rag/model/enums/ResponseStyle.java @@ -0,0 +1,13 @@ +package com.balex.rag.model.enums; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +@Getter +public enum ResponseStyle { + CONCISE("Answer briefly."), + DETAILED("Provide detailed explanations."); + + private final String instruction; +} \ No newline at end of file diff --git a/rag-service/src/main/java/com/balex/rag/model/enums/Role.java b/rag-service/src/main/java/com/balex/rag/model/enums/Role.java new file mode 100644 index 0000000..2534a8d --- /dev/null +++ b/rag-service/src/main/java/com/balex/rag/model/enums/Role.java @@ -0,0 +1,41 @@ +package com.balex.rag.model.enums; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.ai.chat.messages.AssistantMessage; +import org.springframework.ai.chat.messages.Message; +import org.springframework.ai.chat.messages.SystemMessage; +import org.springframework.ai.chat.messages.UserMessage; + +import java.util.Arrays; + +@RequiredArgsConstructor +@Getter +public enum Role { + + USER("user") { + @Override + public Message getMessage(String message) { + return new UserMessage(message); + } + }, ASSISTANT("assistant") { + @Override + public Message getMessage(String message) { + return new AssistantMessage(message); + } + }, SYSTEM("system") { + @Override + public Message getMessage(String prompt) { + return new SystemMessage(prompt); + } + }; + + private final String role; + + + public static Role getRole(String roleName) { + return Arrays.stream(Role.values()).filter(role -> role.role.equals(roleName)).findFirst().orElseThrow(); + } + + public abstract Message getMessage(String prompt); +} diff --git a/rag-service/src/main/java/com/balex/rag/model/exception/DataExistException.java b/rag-service/src/main/java/com/balex/rag/model/exception/DataExistException.java new file mode 100644 index 0000000..a043623 --- /dev/null +++ b/rag-service/src/main/java/com/balex/rag/model/exception/DataExistException.java @@ -0,0 +1,10 @@ +package com.balex.rag.model.exception; + +public class DataExistException extends RuntimeException { + + public DataExistException(String message) { + super(message); + } +} + + diff --git a/rag-service/src/main/java/com/balex/rag/model/exception/InvalidDataException.java b/rag-service/src/main/java/com/balex/rag/model/exception/InvalidDataException.java new file mode 100644 index 0000000..7276403 --- /dev/null +++ b/rag-service/src/main/java/com/balex/rag/model/exception/InvalidDataException.java @@ -0,0 +1,10 @@ +package com.balex.rag.model.exception; + +public class InvalidDataException extends RuntimeException { + + public InvalidDataException(String message) { + super(message); + } + +} + diff --git a/rag-service/src/main/java/com/balex/rag/model/exception/InvalidPasswordException.java b/rag-service/src/main/java/com/balex/rag/model/exception/InvalidPasswordException.java new file mode 100644 index 0000000..d7e405c --- /dev/null +++ b/rag-service/src/main/java/com/balex/rag/model/exception/InvalidPasswordException.java @@ -0,0 +1,10 @@ +package com.balex.rag.model.exception; + +public class InvalidPasswordException extends RuntimeException { + + public InvalidPasswordException(String message) { + super(message); + } + +} + diff --git a/rag-service/src/main/java/com/balex/rag/model/exception/InvalidTokenException.java b/rag-service/src/main/java/com/balex/rag/model/exception/InvalidTokenException.java new file mode 100644 index 0000000..cf2b2d2 --- /dev/null +++ b/rag-service/src/main/java/com/balex/rag/model/exception/InvalidTokenException.java @@ -0,0 +1,9 @@ +package com.balex.rag.model.exception; + +public class InvalidTokenException extends RuntimeException { + + public InvalidTokenException(String message) { + super(message); + } + +} diff --git a/rag-service/src/main/java/com/balex/rag/model/exception/NotFoundException.java b/rag-service/src/main/java/com/balex/rag/model/exception/NotFoundException.java new file mode 100644 index 0000000..cbd245d --- /dev/null +++ b/rag-service/src/main/java/com/balex/rag/model/exception/NotFoundException.java @@ -0,0 +1,11 @@ +package com.balex.rag.model.exception; + +public class NotFoundException extends RuntimeException { + + public NotFoundException(String message) { + super(message); + } + +} + + diff --git a/rag-service/src/main/java/com/balex/rag/model/exception/RerankException.java b/rag-service/src/main/java/com/balex/rag/model/exception/RerankException.java new file mode 100644 index 0000000..285d638 --- /dev/null +++ b/rag-service/src/main/java/com/balex/rag/model/exception/RerankException.java @@ -0,0 +1,10 @@ +package com.balex.rag.model.exception; + +public class RerankException extends RuntimeException { + + public RerankException(String message) { + super(message); + } + +} + diff --git a/rag-service/src/main/java/com/balex/rag/model/exception/UploadException.java b/rag-service/src/main/java/com/balex/rag/model/exception/UploadException.java new file mode 100644 index 0000000..edb1aad --- /dev/null +++ b/rag-service/src/main/java/com/balex/rag/model/exception/UploadException.java @@ -0,0 +1,12 @@ +package com.balex.rag.model.exception; + +public class UploadException extends RuntimeException { + + public UploadException(String message) { + super(message); + } + + public UploadException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/rag-service/src/main/java/com/balex/rag/model/request/user/LoginRequest.java b/rag-service/src/main/java/com/balex/rag/model/request/user/LoginRequest.java new file mode 100644 index 0000000..8e0bbf4 --- /dev/null +++ b/rag-service/src/main/java/com/balex/rag/model/request/user/LoginRequest.java @@ -0,0 +1,25 @@ +package com.balex.rag.model.request.user; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.io.Serializable; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class LoginRequest implements Serializable { + + @Email + @NotNull + private String email; + + @NotEmpty + private String password; + +} + diff --git a/rag-service/src/main/java/com/balex/rag/model/request/user/NewUserRequest.java b/rag-service/src/main/java/com/balex/rag/model/request/user/NewUserRequest.java new file mode 100644 index 0000000..388ee65 --- /dev/null +++ b/rag-service/src/main/java/com/balex/rag/model/request/user/NewUserRequest.java @@ -0,0 +1,27 @@ +package com.balex.rag.model.request.user; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@AllArgsConstructor +@NoArgsConstructor +public class NewUserRequest { + + @NotBlank(message = "Username cannot be empty") + @Size(max = 30) + private String username; + + @NotBlank(message = "Password cannot be empty") + @Size(max = 50) + private String password; + + @NotBlank(message = "Email cannot be empty") + @Size(max = 50) + private String email; + +} + diff --git a/rag-service/src/main/java/com/balex/rag/model/request/user/RegistrationUserRequest.java b/rag-service/src/main/java/com/balex/rag/model/request/user/RegistrationUserRequest.java new file mode 100644 index 0000000..4a5a031 --- /dev/null +++ b/rag-service/src/main/java/com/balex/rag/model/request/user/RegistrationUserRequest.java @@ -0,0 +1,31 @@ +package com.balex.rag.model.request.user; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.io.Serializable; + +@Data +@AllArgsConstructor +@NoArgsConstructor +public class RegistrationUserRequest implements Serializable { + + @NotBlank + private String username; + + @Email + @NotNull + private String email; + + @NotEmpty + private String password; + + @NotEmpty + private String confirmPassword; + +} diff --git a/rag-service/src/main/java/com/balex/rag/model/request/user/UpdateUserRequest.java b/rag-service/src/main/java/com/balex/rag/model/request/user/UpdateUserRequest.java new file mode 100644 index 0000000..6f256af --- /dev/null +++ b/rag-service/src/main/java/com/balex/rag/model/request/user/UpdateUserRequest.java @@ -0,0 +1,17 @@ +package com.balex.rag.model.request.user; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.io.Serializable; + +@Data +@AllArgsConstructor +@NoArgsConstructor +public class UpdateUserRequest implements Serializable { + + private String username; + private String email; + +} diff --git a/rag-service/src/main/java/com/balex/rag/model/response/PaginationResponse.java b/rag-service/src/main/java/com/balex/rag/model/response/PaginationResponse.java new file mode 100644 index 0000000..a676ac0 --- /dev/null +++ b/rag-service/src/main/java/com/balex/rag/model/response/PaginationResponse.java @@ -0,0 +1,30 @@ +package com.balex.rag.model.response; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.io.Serializable; +import java.util.List; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class PaginationResponse implements Serializable { + private List content; + private Pagination pagination; + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class Pagination implements Serializable { + private long total; + private int limit; + private int page; + private int pages; + } +} + diff --git a/rag-service/src/main/java/com/balex/rag/model/response/RagResponse.java b/rag-service/src/main/java/com/balex/rag/model/response/RagResponse.java new file mode 100644 index 0000000..148d6b9 --- /dev/null +++ b/rag-service/src/main/java/com/balex/rag/model/response/RagResponse.java @@ -0,0 +1,32 @@ +package com.balex.rag.model.response; + +import com.balex.rag.model.constants.ApiMessage; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import org.apache.commons.lang3.StringUtils; + +import java.io.Serializable; + +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor + +public class RagResponse

implements Serializable { + private String message; + private P payload; + private boolean success; + + public static

RagResponse

createSuccessful(P payload) { + return new RagResponse<>(StringUtils.EMPTY, payload, true); + } + + public static

RagResponse

createSuccessfulWithNewToken(P payload) { + return new RagResponse<>(ApiMessage.TOKEN_CREATED_OR_UPDATED.getMessage(), payload, true); + } + +} + + diff --git a/rag-service/src/main/java/com/balex/rag/repo/ChatEntryRepository.java b/rag-service/src/main/java/com/balex/rag/repo/ChatEntryRepository.java new file mode 100644 index 0000000..96cb33b --- /dev/null +++ b/rag-service/src/main/java/com/balex/rag/repo/ChatEntryRepository.java @@ -0,0 +1,13 @@ +package com.balex.rag.repo; + +import com.balex.rag.model.entity.ChatEntry; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface ChatEntryRepository extends JpaRepository { + + List findByChatIdOrderByCreatedAtAsc(Long chatId); +} diff --git a/rag-service/src/main/java/com/balex/rag/repo/ChatRepository.java b/rag-service/src/main/java/com/balex/rag/repo/ChatRepository.java new file mode 100644 index 0000000..78adbcf --- /dev/null +++ b/rag-service/src/main/java/com/balex/rag/repo/ChatRepository.java @@ -0,0 +1,9 @@ +package com.balex.rag.repo; + +import com.balex.rag.model.entity.Chat; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ChatRepository extends JpaRepository { + +} + diff --git a/rag-service/src/main/java/com/balex/rag/repo/DocumentRepository.java b/rag-service/src/main/java/com/balex/rag/repo/DocumentRepository.java new file mode 100644 index 0000000..a7ea5fd --- /dev/null +++ b/rag-service/src/main/java/com/balex/rag/repo/DocumentRepository.java @@ -0,0 +1,15 @@ +package com.balex.rag.repo; + +import com.balex.rag.model.LoadedDocument; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface DocumentRepository extends JpaRepository { + + boolean existsByFilenameAndContentHash(String filename, String contentHash); + + List findByUserId(Integer userId); + +} + diff --git a/rag-service/src/main/java/com/balex/rag/repo/RefreshTokenRepository.java b/rag-service/src/main/java/com/balex/rag/repo/RefreshTokenRepository.java new file mode 100644 index 0000000..9ba5031 --- /dev/null +++ b/rag-service/src/main/java/com/balex/rag/repo/RefreshTokenRepository.java @@ -0,0 +1,15 @@ +package com.balex.rag.repo; + +import com.balex.rag.model.entity.RefreshToken; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface RefreshTokenRepository extends JpaRepository { + + Optional findByToken(String token); + + Optional findByUserId(Integer userId); + +} + diff --git a/rag-service/src/main/java/com/balex/rag/repo/UserRepository.java b/rag-service/src/main/java/com/balex/rag/repo/UserRepository.java new file mode 100644 index 0000000..3db7437 --- /dev/null +++ b/rag-service/src/main/java/com/balex/rag/repo/UserRepository.java @@ -0,0 +1,27 @@ +package com.balex.rag.repo; + +import com.balex.rag.model.entity.User; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface UserRepository extends JpaRepository, JpaSpecificationExecutor { + + boolean existsByEmail(String email); + + boolean existsByUsername(String username); + + Optional findByIdAndDeletedFalse (Integer id); + + Optional findUserByEmailAndDeletedFalse(String email); + + Optional findByEmail(String email); + + Optional findByUsername(String username); + +} + + diff --git a/rag-service/src/main/java/com/balex/rag/repo/VectorStoreRepository.java b/rag-service/src/main/java/com/balex/rag/repo/VectorStoreRepository.java new file mode 100644 index 0000000..89d4c37 --- /dev/null +++ b/rag-service/src/main/java/com/balex/rag/repo/VectorStoreRepository.java @@ -0,0 +1,13 @@ +package com.balex.rag.repo; + +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface VectorStoreRepository { + + void deleteBySourceIn(List sources); + + void deleteByUserId(Long userId); +} \ No newline at end of file diff --git a/rag-service/src/main/java/com/balex/rag/repo/impl/VectorStoreRepositoryImpl.java b/rag-service/src/main/java/com/balex/rag/repo/impl/VectorStoreRepositoryImpl.java new file mode 100644 index 0000000..48c8826 --- /dev/null +++ b/rag-service/src/main/java/com/balex/rag/repo/impl/VectorStoreRepositoryImpl.java @@ -0,0 +1,36 @@ +package com.balex.rag.repo.impl; + +import com.balex.rag.repo.VectorStoreRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +@RequiredArgsConstructor +public class VectorStoreRepositoryImpl implements VectorStoreRepository { + + private final JdbcTemplate jdbcTemplate; + + @Override + public void deleteBySourceIn(List sources) { + if (sources == null || sources.isEmpty()) { + return; + } + + String placeholders = String.join(",", sources.stream() + .map(s -> "?") + .toList()); + + String sql = "DELETE FROM vector_store WHERE metadata->>'source' IN (" + placeholders + ")"; + + jdbcTemplate.update(sql, sources.toArray()); + } + + @Override + public void deleteByUserId(Long userId) { + String sql = "DELETE FROM vector_store WHERE (metadata->>'user_id')::bigint = ?"; + jdbcTemplate.update(sql, userId); + } +} \ No newline at end of file diff --git a/rag-service/src/main/java/com/balex/rag/security/JwtTokenProvider.java b/rag-service/src/main/java/com/balex/rag/security/JwtTokenProvider.java new file mode 100644 index 0000000..8d1c2c4 --- /dev/null +++ b/rag-service/src/main/java/com/balex/rag/security/JwtTokenProvider.java @@ -0,0 +1,97 @@ +package com.balex.rag.security; + +import com.balex.rag.model.entity.User; +import com.balex.rag.service.model.AuthenticationConstants; +import io.jsonwebtoken.*; +import io.jsonwebtoken.io.Decoders; +import io.jsonwebtoken.security.Keys; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.lang.NonNull; +import org.springframework.stereotype.Component; + +import javax.crypto.SecretKey; +import java.time.LocalDateTime; +import java.util.Date; +import java.util.HashMap; +import java.util.Map; + +@Slf4j +@Component +public class JwtTokenProvider { + private final SecretKey secretKey; + private final Long jwtValidityInMilliseconds; + + public JwtTokenProvider(@Value("${jwt.secret}") String secret, + @Value("${jwt.expiration:3600000}") long jwtValidityInMilliseconds) { + this.secretKey = getKey(secret); + this.jwtValidityInMilliseconds = jwtValidityInMilliseconds; + } + + public String generateToken(@NonNull User user) { + Map claims = new HashMap<>(); + claims.put(AuthenticationConstants.USER_ID, user.getId()); + claims.put(AuthenticationConstants.USERNAME, user.getUsername()); + claims.put(AuthenticationConstants.USER_EMAIL, user.getEmail()); + claims.put(AuthenticationConstants.USER_REGISTRATION_STATUS, user.getRegistrationStatus().name()); + claims.put(AuthenticationConstants.LAST_UPDATE, LocalDateTime.now().toString()); + + return createToken(claims, user.getEmail()); + } + + public String refreshToken(String token) { + Claims claims = getAllClaimsFromToken(token); + return createToken(claims, claims.getSubject()); + } + + public boolean validateToken(String token) { + try { + Jws claims = Jwts.parserBuilder() + .setSigningKey(secretKey) + .build() + .parseClaimsJws(token); + return !claims.getBody().getExpiration().before(new Date()); + } catch (JwtException | IllegalArgumentException e) { + return false; + } + } + + public String getUsername(String token) { + Claims claims = getAllClaimsFromToken(token); + return claims.get(AuthenticationConstants.USERNAME).toString(); + } + + public String getUserId(String token) { + Claims claims = getAllClaimsFromToken(token); + return String.valueOf(claims.get(AuthenticationConstants.USER_ID)); + } + + + private Claims getAllClaimsFromToken(String token) { + try { + return Jwts.parserBuilder() + .setSigningKey(secretKey) + .build() + .parseClaimsJws(token) + .getBody(); + } catch (ExpiredJwtException e) { + return e.getClaims(); + } + } + + private SecretKey getKey(String secretKey64) { + byte[] decode64 = Decoders.BASE64.decode(secretKey64); + return Keys.hmacShaKeyFor(decode64); + } + + private String createToken(Map claims, String subject) { + return Jwts.builder() + .setClaims(claims) + .setSubject(subject) + .setIssuedAt(new Date()) + .setExpiration(new Date(System.currentTimeMillis() + jwtValidityInMilliseconds)) + .signWith(secretKey, SignatureAlgorithm.HS512) + .compact(); + } +} + diff --git a/rag-service/src/main/java/com/balex/rag/security/filter/JwtRequestFilter.java b/rag-service/src/main/java/com/balex/rag/security/filter/JwtRequestFilter.java new file mode 100644 index 0000000..2d8b5b7 --- /dev/null +++ b/rag-service/src/main/java/com/balex/rag/security/filter/JwtRequestFilter.java @@ -0,0 +1,121 @@ +package com.balex.rag.security.filter; + +import com.balex.rag.model.constants.ApiErrorMessage; +import com.balex.rag.security.JwtTokenProvider; +import io.jsonwebtoken.ExpiredJwtException; +import io.jsonwebtoken.MalformedJwtException; +import io.jsonwebtoken.SignatureException; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.validation.constraints.NotNull; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.util.Collections; +import java.util.List; +import java.util.Optional; + +import static com.balex.rag.model.constants.ApiConstants.USER_ROLE; + +@Slf4j +@Component +@RequiredArgsConstructor +public class JwtRequestFilter extends OncePerRequestFilter { + private static final String AUTHORIZATION_HEADER = "Authorization"; + private static final String BEARER_PREFIX = "Bearer "; + private static final String LOGIN_PATH = "/auth/login"; + private static final String REGISTER_PATH = "/auth/register"; + + private final JwtTokenProvider jwtTokenProvider; + + @Override + protected void doFilterInternal( + @NotNull HttpServletRequest request, + @NotNull HttpServletResponse response, + @NotNull FilterChain filterChain) + throws ServletException, IOException { + + log.info("JWT filter: method={}, uri={}, header={}", + request.getMethod(), + request.getRequestURI(), + request.getHeader(AUTHORIZATION_HEADER)); + + Optional authHeader = Optional.ofNullable(request.getHeader(AUTHORIZATION_HEADER)); + String requestURI = request.getRequestURI(); + + if (authHeader.isPresent() && authHeader.get().startsWith(BEARER_PREFIX)) { + String jwt = authHeader.get().substring(BEARER_PREFIX.length()); + try { + if (!jwtTokenProvider.validateToken(jwt)) { + throw new ExpiredJwtException(null, null, ApiErrorMessage.TOKEN_EXPIRED.getMessage()); + } + + Optional emailOpt = Optional.ofNullable(jwtTokenProvider.getUsername(jwt)); + Optional userIdOpt = Optional.ofNullable(jwtTokenProvider.getUserId(jwt)); + + if (emailOpt.isPresent() && userIdOpt.isPresent()) { + if (SecurityContextHolder.getContext().getAuthentication() == null) { + List authorities = Collections.singletonList(new SimpleGrantedAuthority(USER_ROLE)); + + UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken( + emailOpt.get(), + jwt, + authorities + ); + SecurityContextHolder.getContext().setAuthentication(authenticationToken); + } + } + + + } catch (ExpiredJwtException e) { + handleTokenExpiration(requestURI, jwt, response); + return; + } catch (SignatureException | MalformedJwtException e) { + handleSignatureException(response); + return; + } catch (Exception e) { + handleUnexpectedException(response, e); + return; + } + } + filterChain.doFilter(request, response); + } + + private void handleTokenExpiration(String requestURI, String jwt, HttpServletResponse response) throws IOException { + if (isAuthEndpoint(requestURI)) { + String refreshedToken = jwtTokenProvider.refreshToken(jwt); + response.setHeader(AUTHORIZATION_HEADER, BEARER_PREFIX + refreshedToken); + } else { + sendErrorResponse(response, HttpStatus.UNAUTHORIZED, ApiErrorMessage.TOKEN_EXPIRED.getMessage()); + } + } + + private void handleSignatureException(HttpServletResponse response) throws IOException { + sendErrorResponse(response, HttpStatus.UNAUTHORIZED, ApiErrorMessage.INVALID_TOKEN_SIGNATURE.getMessage()); + } + + private void handleUnexpectedException(HttpServletResponse response, Exception e) throws IOException { + log.error(ApiErrorMessage.ERROR_DURING_JWT_PROCESSING.getMessage(), e); + sendErrorResponse(response, HttpStatus.INTERNAL_SERVER_ERROR, ApiErrorMessage.UNEXPECTED_ERROR_OCCURRED.getMessage()); + } + + private void sendErrorResponse(HttpServletResponse response, HttpStatus status, String message) throws IOException { + response.setStatus(status.value()); + response.getWriter().write(message); + } + + private boolean isAuthEndpoint(String uri) { + return uri.equals(LOGIN_PATH) || uri.equals(REGISTER_PATH); + } +} + + diff --git a/rag-service/src/main/java/com/balex/rag/security/handler/AccessRestrictionHandler.java b/rag-service/src/main/java/com/balex/rag/security/handler/AccessRestrictionHandler.java new file mode 100644 index 0000000..4bfeb02 --- /dev/null +++ b/rag-service/src/main/java/com/balex/rag/security/handler/AccessRestrictionHandler.java @@ -0,0 +1,25 @@ +package com.balex.rag.security.handler; + +import com.balex.rag.model.constants.ApiErrorMessage; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.SneakyThrows; +import org.springframework.http.HttpStatus; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.web.access.AccessDeniedHandler; +import org.springframework.stereotype.Component; + +@Component +public class AccessRestrictionHandler implements AccessDeniedHandler { + + @Override + @SneakyThrows + public void handle(HttpServletRequest request, + HttpServletResponse response, + AccessDeniedException accessDeniedException) { + response.setStatus(HttpStatus.FORBIDDEN.value()); + response.getWriter().write(ApiErrorMessage.HAVE_NO_ACCESS.getMessage()); + } + +} + diff --git a/rag-service/src/main/java/com/balex/rag/security/validation/AccessValidator.java b/rag-service/src/main/java/com/balex/rag/security/validation/AccessValidator.java new file mode 100644 index 0000000..90b692d --- /dev/null +++ b/rag-service/src/main/java/com/balex/rag/security/validation/AccessValidator.java @@ -0,0 +1,52 @@ +package com.balex.rag.security.validation; + +import com.balex.rag.model.constants.ApiErrorMessage; +import com.balex.rag.model.entity.User; +import com.balex.rag.model.exception.InvalidDataException; +import com.balex.rag.model.exception.InvalidPasswordException; +import com.balex.rag.model.exception.NotFoundException; +import com.balex.rag.repo.UserRepository; +import com.balex.rag.service.model.exception.DataExistException; +import com.balex.rag.utils.ApiUtils; +import com.balex.rag.utils.PasswordUtils; +import lombok.RequiredArgsConstructor; +import lombok.SneakyThrows; +import org.springframework.stereotype.Component; + +import java.nio.file.AccessDeniedException; + +@Component +@RequiredArgsConstructor +public class AccessValidator { + private final UserRepository userRepository; + private final ApiUtils apiUtils; + + public void validateNewUser(String username, String email, String password, String confirmPassword) { + userRepository.findByUsername(username).ifPresent(existingUser -> { + throw new DataExistException(ApiErrorMessage.USERNAME_ALREADY_EXISTS.getMessage(username)); + }); + + userRepository.findByEmail(email).ifPresent(existingUser -> { + throw new DataExistException(ApiErrorMessage.EMAIL_ALREADY_EXISTS.getMessage(email)); + }); + + if (!password.equals(confirmPassword)) { + throw new InvalidDataException(ApiErrorMessage.MISMATCH_PASSWORDS.getMessage()); + } + + if (PasswordUtils.isNotValidPassword(password)) { + throw new InvalidPasswordException(ApiErrorMessage.INVALID_PASSWORD.getMessage()); + } + } + + @SneakyThrows + public void validateOwnerAccess(Integer ownerId) { + Integer currentUserId = apiUtils.getUserIdFromAuthentication(); + + if (!currentUserId.equals(ownerId)) { + throw new AccessDeniedException(ApiErrorMessage.HAVE_NO_ACCESS.getMessage()); + } + } + +} + diff --git a/rag-service/src/main/java/com/balex/rag/security/validation/PasswordMatchesValidator.java b/rag-service/src/main/java/com/balex/rag/security/validation/PasswordMatchesValidator.java new file mode 100644 index 0000000..7b58c2c --- /dev/null +++ b/rag-service/src/main/java/com/balex/rag/security/validation/PasswordMatchesValidator.java @@ -0,0 +1,16 @@ +package com.balex.rag.security.validation; + +import com.balex.rag.model.request.user.RegistrationUserRequest; +import com.balex.rag.utils.PasswordMatches; +import jakarta.validation.ConstraintValidator; +import jakarta.validation.ConstraintValidatorContext; + +public class PasswordMatchesValidator implements ConstraintValidator { + + @Override + public boolean isValid(RegistrationUserRequest request, ConstraintValidatorContext constraintValidatorContext) { + return request.getPassword().equals(request.getConfirmPassword()); + } + +} + diff --git a/rag-service/src/main/java/com/balex/rag/service/AuthService.java b/rag-service/src/main/java/com/balex/rag/service/AuthService.java new file mode 100644 index 0000000..bb88578 --- /dev/null +++ b/rag-service/src/main/java/com/balex/rag/service/AuthService.java @@ -0,0 +1,16 @@ +package com.balex.rag.service; + +import com.balex.rag.model.dto.UserProfileDTO; +import com.balex.rag.model.request.user.LoginRequest; +import com.balex.rag.model.request.user.RegistrationUserRequest; +import com.balex.rag.model.response.RagResponse; + +public interface AuthService { + + RagResponse login(LoginRequest request); + + RagResponse refreshAccessToken(String refreshToken); + + RagResponse registerUser(RegistrationUserRequest request); + +} diff --git a/rag-service/src/main/java/com/balex/rag/service/ChatEntryService.java b/rag-service/src/main/java/com/balex/rag/service/ChatEntryService.java new file mode 100644 index 0000000..4d77213 --- /dev/null +++ b/rag-service/src/main/java/com/balex/rag/service/ChatEntryService.java @@ -0,0 +1,12 @@ +package com.balex.rag.service; + +import com.balex.rag.model.entity.ChatEntry; + +import java.util.List; + +public interface ChatEntryService { + + List getEntriesByChatId(Long chatId); + + ChatEntry addUserEntry(Long chatId, String content, boolean onlyContext, int topK, double topP); +} \ No newline at end of file diff --git a/rag-service/src/main/java/com/balex/rag/service/ChatService.java b/rag-service/src/main/java/com/balex/rag/service/ChatService.java new file mode 100644 index 0000000..bc7e48d --- /dev/null +++ b/rag-service/src/main/java/com/balex/rag/service/ChatService.java @@ -0,0 +1,20 @@ +package com.balex.rag.service; + +import com.balex.rag.model.entity.Chat; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +import java.util.List; + +public interface ChatService { + + Chat createNewChat(String title); + + List getAllChats(); + + Chat getChat(Long chatId); + + void deleteChat(Long chatId); + + SseEmitter proceedInteractionWithStreaming(Long chatId, String userPrompt); + +} diff --git a/rag-service/src/main/java/com/balex/rag/service/PostgresChatMemory.java b/rag-service/src/main/java/com/balex/rag/service/PostgresChatMemory.java new file mode 100644 index 0000000..3076d1b --- /dev/null +++ b/rag-service/src/main/java/com/balex/rag/service/PostgresChatMemory.java @@ -0,0 +1,51 @@ +package com.balex.rag.service; + +import com.balex.rag.model.entity.Chat; +import com.balex.rag.model.entity.ChatEntry; +import com.balex.rag.repo.ChatRepository; +import lombok.Builder; +import org.springframework.ai.chat.memory.ChatMemory; +import org.springframework.ai.chat.messages.Message; + +import java.util.List; + +@Builder +public class PostgresChatMemory implements ChatMemory { + + private ChatRepository chatMemoryRepository; + + private int maxMessages; + + @Override + public void add(String conversationId, List messages) { +// Chat chat = chatMemoryRepository.findById(Long.valueOf(conversationId)).orElseThrow(); +// for (Message message : messages) { +// chat.addChatEntry(ChatEntry.toChatEntry(message)); +// } +// chatMemoryRepository.save(chat); + + // No-op: messages are saved manually in ChatEntryServiceImpl + + } + + + @Override + public List get(String conversationId) { + Chat chat = chatMemoryRepository.findById(Long.valueOf(conversationId)).orElseThrow(); + Long messagesToSkip= (long) Math.max(0, chat.getHistory().size() - maxMessages); + return chat.getHistory().stream() + .skip(messagesToSkip) + //.sorted(Comparator.comparing(ChatEntry::getCreatedAt)) + //.sorted(Comparator.comparing(ChatEntry::getCreatedAt).reversed()) + .map(ChatEntry::toMessage) + .limit(maxMessages) + .toList(); + + } + + @Override + public void clear(String conversationId) { + //not implemented + } +} + diff --git a/rag-service/src/main/java/com/balex/rag/service/RefreshTokenService.java b/rag-service/src/main/java/com/balex/rag/service/RefreshTokenService.java new file mode 100644 index 0000000..1e7b71c --- /dev/null +++ b/rag-service/src/main/java/com/balex/rag/service/RefreshTokenService.java @@ -0,0 +1,12 @@ +package com.balex.rag.service; + +import com.balex.rag.model.entity.RefreshToken; +import com.balex.rag.model.entity.User; + +public interface RefreshTokenService { + + RefreshToken generateOrUpdateRefreshToken(User user); + + RefreshToken validateAndRefreshToken(String refreshToken); + +} diff --git a/rag-service/src/main/java/com/balex/rag/service/UserDocumentService.java b/rag-service/src/main/java/com/balex/rag/service/UserDocumentService.java new file mode 100644 index 0000000..a789393 --- /dev/null +++ b/rag-service/src/main/java/com/balex/rag/service/UserDocumentService.java @@ -0,0 +1,10 @@ +package com.balex.rag.service; + +import org.springframework.web.multipart.MultipartFile; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +import java.util.List; + +public interface UserDocumentService { + SseEmitter processUploadedFilesWithSse(List files, Long userId); +} \ No newline at end of file diff --git a/rag-service/src/main/java/com/balex/rag/service/UserService.java b/rag-service/src/main/java/com/balex/rag/service/UserService.java new file mode 100644 index 0000000..cd2ab88 --- /dev/null +++ b/rag-service/src/main/java/com/balex/rag/service/UserService.java @@ -0,0 +1,33 @@ +package com.balex.rag.service; + +import com.balex.rag.model.dto.UserDTO; +import com.balex.rag.model.dto.UserSearchDTO; +import com.balex.rag.model.entity.UserInfo; +import com.balex.rag.model.request.user.NewUserRequest; +import com.balex.rag.model.request.user.UpdateUserRequest; +import com.balex.rag.model.response.PaginationResponse; +import com.balex.rag.model.response.RagResponse; +import jakarta.validation.constraints.NotNull; +import org.springframework.data.domain.Pageable; +import org.springframework.security.core.userdetails.UserDetailsService; + +public interface UserService extends UserDetailsService { + + RagResponse getById(@NotNull Integer userId); + + RagResponse createUser(@NotNull NewUserRequest request); + + RagResponse updateUser(@NotNull Integer postId, @NotNull UpdateUserRequest request); + + void softDeleteUser(Integer userId); + + RagResponse> findAllUsers(Pageable pageable); + + RagResponse getUserInfo(String token); + + RagResponse deleteUserDocuments(String token); + +} + + + diff --git a/rag-service/src/main/java/com/balex/rag/service/autostart/DocumentLoaderService.java b/rag-service/src/main/java/com/balex/rag/service/autostart/DocumentLoaderService.java new file mode 100644 index 0000000..b779d7a --- /dev/null +++ b/rag-service/src/main/java/com/balex/rag/service/autostart/DocumentLoaderService.java @@ -0,0 +1,85 @@ +//package com.balex.rag.service.autostart; +// +//import com.balex.rag.model.LoadedDocument; +//import com.balex.rag.repo.DocumentRepository; +//import lombok.SneakyThrows; +//import org.springframework.ai.document.Document; +//import org.springframework.ai.reader.TextReader; +//import org.springframework.ai.transformer.splitter.TokenTextSplitter; +//import org.springframework.ai.vectorstore.VectorStore; +//import org.springframework.beans.factory.annotation.Autowired; +//import org.springframework.boot.CommandLineRunner; +//import org.springframework.core.io.Resource; +//import org.springframework.core.io.support.ResourcePatternResolver; +//import org.springframework.data.util.Pair; +//import org.springframework.stereotype.Service; +//import org.springframework.util.DigestUtils; +// +//import java.util.Arrays; +//import java.util.List; +// +//@Service +//public class DocumentLoaderService implements CommandLineRunner { +// +// @Autowired +// private DocumentRepository documentRepository; +// +// @Autowired +// private ResourcePatternResolver resolver; +// +// @Autowired +// private VectorStore vectorStore; +// +// +// @SneakyThrows +// public void loadDocuments() { +// List resources = Arrays.stream(resolver.getResources("classpath:/knowledgebase/**/*.txt")).toList(); +// +// resources.stream() +// .map(r -> Pair.of(r, calcContentHash(r))) +// .filter(p -> !documentRepository.existsByFilenameAndContentHash(p.getFirst().getFilename(), p.getSecond())) +// .forEach(p -> { +// Resource resource = p.getFirst(); +// List docs = new TextReader(resource).get(); +// TokenTextSplitter splitter = TokenTextSplitter.builder().withChunkSize(200).build(); +// List chunks = splitter.apply(docs); +// +// for (Document c : chunks) { +// acceptWithRetry(vectorStore, List.of(c), 3, 1500); +// } +// +// LoadedDocument loaded = LoadedDocument.builder() +// .documentType("txt") +// .chunkCount(chunks.size()) +// .filename(resource.getFilename()) +// .contentHash(p.getSecond()) +// .build(); +// documentRepository.save(loaded); +// }); +// +// } +// +// private static void acceptWithRetry(VectorStore vs, List part, int attempts, long sleepMs) { +// RuntimeException last = null; +// for (int i = 0; i < attempts; i++) { +// try { +// vs.accept(part); +// return; +// } catch (RuntimeException e) { +// last = e; +// try { Thread.sleep(sleepMs); } catch (InterruptedException ignored) {} +// } +// } +// throw last; +// } +// +// @SneakyThrows +// private String calcContentHash(Resource resource) { +// return DigestUtils.md5DigestAsHex(resource.getInputStream()); +// } +// +// @Override +// public void run(String... args) { +// loadDocuments(); +// } +//} diff --git a/rag-service/src/main/java/com/balex/rag/service/impl/AuthServiceImpl.java b/rag-service/src/main/java/com/balex/rag/service/impl/AuthServiceImpl.java new file mode 100644 index 0000000..baf93df --- /dev/null +++ b/rag-service/src/main/java/com/balex/rag/service/impl/AuthServiceImpl.java @@ -0,0 +1,98 @@ +package com.balex.rag.service.impl; + +import com.balex.rag.mapper.UserMapper; +import com.balex.rag.model.constants.ApiErrorMessage; +import com.balex.rag.model.dto.UserProfileDTO; +import com.balex.rag.model.entity.RefreshToken; +import com.balex.rag.model.entity.User; +import com.balex.rag.model.exception.InvalidDataException; +import com.balex.rag.model.request.user.LoginRequest; +import com.balex.rag.model.request.user.RegistrationUserRequest; +import com.balex.rag.model.response.RagResponse; +import com.balex.rag.repo.UserRepository; +import com.balex.rag.security.JwtTokenProvider; +import com.balex.rag.security.validation.AccessValidator; +import com.balex.rag.service.AuthService; +import com.balex.rag.service.RefreshTokenService; +import jakarta.validation.constraints.NotNull; +import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; + +import java.util.HashSet; +import java.util.Set; + +@Slf4j +@Service +@AllArgsConstructor +public class AuthServiceImpl implements AuthService { + private final UserRepository userRepository; + private final UserMapper userMapper; + private final JwtTokenProvider jwtTokenProvider; + private final AuthenticationManager authenticationManager; + private final RefreshTokenService refreshTokenService; + private final PasswordEncoder passwordEncoder; + private final AccessValidator accessValidator; + + + @Override + public RagResponse login(@NotNull LoginRequest request) { + try { + authenticationManager.authenticate( + new UsernamePasswordAuthenticationToken(request.getEmail(), request.getPassword()) + ); + } catch (BadCredentialsException e) { + throw new InvalidDataException(ApiErrorMessage.INVALID_USER_OR_PASSWORD.getMessage()); + } + + User user = userRepository.findUserByEmailAndDeletedFalse(request.getEmail()) + .orElseThrow(() -> new InvalidDataException(ApiErrorMessage.INVALID_USER_OR_PASSWORD.getMessage())); + + RefreshToken refreshToken = refreshTokenService.generateOrUpdateRefreshToken(user); + String token = jwtTokenProvider.generateToken(user); + UserProfileDTO userProfileDTO = userMapper.toUserProfileDto(user, token, refreshToken.getToken()); + userProfileDTO.setToken(token); + + return RagResponse.createSuccessfulWithNewToken(userProfileDTO); + } + + @Override + public RagResponse refreshAccessToken(String refreshTokenValue) { + RefreshToken refreshToken = refreshTokenService.validateAndRefreshToken(refreshTokenValue); + User user = refreshToken.getUser(); + + String accessToken = jwtTokenProvider.generateToken(user); + + return RagResponse.createSuccessfulWithNewToken( + userMapper.toUserProfileDto(user, accessToken, refreshToken.getToken()) + ); + } + + @Override + public RagResponse registerUser(@NotNull RegistrationUserRequest request) { + + accessValidator.validateNewUser( + request.getUsername(), + request.getEmail(), + request.getPassword(), + request.getConfirmPassword() + ); + + User newUser = userMapper.fromDto(request); + newUser.setPassword(passwordEncoder.encode(request.getPassword())); + userRepository.save(newUser); + + RefreshToken refreshToken = refreshTokenService.generateOrUpdateRefreshToken(newUser); + String token = jwtTokenProvider.generateToken(newUser); + UserProfileDTO userProfileDTO = userMapper.toUserProfileDto(newUser, token, refreshToken.getToken()); + userProfileDTO.setToken(token); + + return RagResponse.createSuccessfulWithNewToken(userProfileDTO); + } + +} + diff --git a/rag-service/src/main/java/com/balex/rag/service/impl/ChatEntryServiceImpl.java b/rag-service/src/main/java/com/balex/rag/service/impl/ChatEntryServiceImpl.java new file mode 100644 index 0000000..4f2ebaf --- /dev/null +++ b/rag-service/src/main/java/com/balex/rag/service/impl/ChatEntryServiceImpl.java @@ -0,0 +1,81 @@ +package com.balex.rag.service.impl; + +import com.balex.rag.model.entity.Chat; +import com.balex.rag.model.entity.ChatEntry; +import com.balex.rag.model.enums.Role; +import com.balex.rag.repo.ChatEntryRepository; +import com.balex.rag.repo.ChatRepository; +import com.balex.rag.service.ChatEntryService; +import jakarta.persistence.EntityNotFoundException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.ai.chat.client.ChatClient; +import org.springframework.ai.chat.memory.ChatMemory; +import org.springframework.ai.ollama.api.OllamaOptions; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Slf4j +@Service +@RequiredArgsConstructor +public class ChatEntryServiceImpl implements ChatEntryService { + + private final ChatEntryRepository chatEntryRepository; + private final ChatRepository chatRepository; + private final ChatClient chatClient; + + @Override + public List getEntriesByChatId(Long chatId) { + return chatEntryRepository.findByChatIdOrderByCreatedAtAsc(chatId); + } + + @Override + @Transactional + public ChatEntry addUserEntry(Long chatId, String content, boolean onlyContext, int topK, double topP) { + Chat chat = chatRepository.findById(chatId) + .orElseThrow(() -> new EntityNotFoundException("Chat not found with id: " + chatId)); + + ChatEntry userEntry = ChatEntry.builder() + .chat(chat) + .content(content) + .role(Role.USER) + .build(); + chatEntryRepository.save(userEntry); + + String systemPrompt = onlyContext + ? """ + The question may be about a CONSEQUENCE of a fact from Context. + ALWAYS connect: Context fact → question. + No connection, even indirect = answer ONLY: "The request is not related to the uploaded context." + Connection exists = answer using ONLY the context. + Do NOT use any knowledge outside the provided context. + """ + : """ + The question may be about a CONSEQUENCE of a fact from Context. + ALWAYS connect: Context fact → question. + If context contains relevant information, use it in your answer. + If context does not contain relevant information, answer using your general knowledge. + """; + + String response = chatClient.prompt() + .system(systemPrompt) + .user(content) + .advisors(a -> a.param(ChatMemory.CONVERSATION_ID, String.valueOf(chatId))) + .options(OllamaOptions.builder() + .topK(topK) + .topP(topP) + .build()) + .call() + .content(); + + ChatEntry assistantEntry = ChatEntry.builder() + .chat(chat) + .content(response) + .role(Role.ASSISTANT) + .build(); + + return chatEntryRepository.save(assistantEntry); + } +} \ No newline at end of file diff --git a/rag-service/src/main/java/com/balex/rag/service/impl/ChatServiceImpl.java b/rag-service/src/main/java/com/balex/rag/service/impl/ChatServiceImpl.java new file mode 100644 index 0000000..6e323e9 --- /dev/null +++ b/rag-service/src/main/java/com/balex/rag/service/impl/ChatServiceImpl.java @@ -0,0 +1,76 @@ +package com.balex.rag.service.impl; + +import com.balex.rag.model.entity.Chat; +import com.balex.rag.repo.ChatRepository; +import com.balex.rag.service.ChatService; +import com.balex.rag.utils.ApiUtils; +import lombok.RequiredArgsConstructor; +import lombok.SneakyThrows; +import org.springframework.ai.chat.client.ChatClient; +import org.springframework.ai.chat.memory.ChatMemory; +import org.springframework.ai.chat.model.ChatResponse; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Service; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +import java.util.List; + +@Service +@RequiredArgsConstructor +public class ChatServiceImpl implements ChatService { + + private final ChatRepository chatRepo; + + private final ChatClient chatClient; + + private final ApiUtils apiUtils; + + public List getAllChats() { + return chatRepo.findAll(Sort.by(Sort.Direction.DESC, "createdAt")); + } + + public Chat createNewChat(String title) { + Long ownerId = apiUtils.getUserIdFromAuthentication().longValue(); + Chat chat = Chat.builder() + .title(title) + .idOwner(ownerId) + .build(); + chatRepo.save(chat); + return chat; + } + + public Chat getChat(Long chatId) { + return chatRepo.findById(chatId).orElseThrow(); + } + + public void deleteChat(Long chatId) { + chatRepo.deleteById(chatId); + } + + + public SseEmitter proceedInteractionWithStreaming(Long chatId, String userPrompt) { + + SseEmitter sseEmitter = new SseEmitter(0L); + final StringBuilder answer = new StringBuilder(); + + chatClient + .prompt(userPrompt) + .advisors(advisorSpec -> advisorSpec.param(ChatMemory.CONVERSATION_ID, chatId)) + .stream() + .chatResponse() + .subscribe( + (ChatResponse response) -> processToken(response, sseEmitter, answer), + sseEmitter::completeWithError, + sseEmitter::complete); + return sseEmitter; + } + + + @SneakyThrows + private static void processToken(ChatResponse response, SseEmitter emitter, StringBuilder answer) { + var token = response.getResult().getOutput(); + emitter.send(token); + answer.append(token.getText()); + } + +} diff --git a/rag-service/src/main/java/com/balex/rag/service/impl/DocumentTransactionalHelper.java b/rag-service/src/main/java/com/balex/rag/service/impl/DocumentTransactionalHelper.java new file mode 100644 index 0000000..af3523d --- /dev/null +++ b/rag-service/src/main/java/com/balex/rag/service/impl/DocumentTransactionalHelper.java @@ -0,0 +1,38 @@ +package com.balex.rag.service.impl; + +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; + +/** + * Helper component to handle transactional file processing. + * + * This separate bean is necessary because Spring's @Transactional relies on proxies, + * and self-invocation within the same class bypasses the proxy, causing transactions + * to not be applied. + */ +@Component +public class DocumentTransactionalHelper { + + /** + * Processes a single file within a transaction boundary. + * + * @param file the file to process + * @param userId the user ID + * @param filename the filename + * @param processor the processing function to execute + * @return true if file was processed, false if skipped + */ + @Transactional + public boolean processFileInTransaction(MultipartFile file, + Long userId, + String filename, + FileProcessor processor) { + return processor.process(file, userId, filename); + } + + @FunctionalInterface + public interface FileProcessor { + boolean process(MultipartFile file, Long userId, String filename); + } +} \ No newline at end of file diff --git a/rag-service/src/main/java/com/balex/rag/service/impl/RefreshTokenServiceImpl.java b/rag-service/src/main/java/com/balex/rag/service/impl/RefreshTokenServiceImpl.java new file mode 100644 index 0000000..3cd536b --- /dev/null +++ b/rag-service/src/main/java/com/balex/rag/service/impl/RefreshTokenServiceImpl.java @@ -0,0 +1,50 @@ +package com.balex.rag.service.impl; + +import com.balex.rag.model.constants.ApiErrorMessage; +import com.balex.rag.model.entity.RefreshToken; +import com.balex.rag.model.entity.User; +import com.balex.rag.model.exception.NotFoundException; +import com.balex.rag.repo.RefreshTokenRepository; +import com.balex.rag.service.RefreshTokenService; +import com.balex.rag.utils.ApiUtils; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.time.LocalDateTime; + +@Slf4j +@Service +@RequiredArgsConstructor +public class RefreshTokenServiceImpl implements RefreshTokenService { + private final RefreshTokenRepository refreshTokenRepository; + + @Override + public RefreshToken generateOrUpdateRefreshToken(User user) { + return refreshTokenRepository.findByUserId(user.getId()) + .map(refreshToken -> { + refreshToken.setCreated(LocalDateTime.now()); + refreshToken.setToken(ApiUtils.generateUuidWithoutDash()); + return refreshTokenRepository.save(refreshToken); + }) + .orElseGet(() -> { + RefreshToken newToken = new RefreshToken(); + newToken.setUser(user); + newToken.setCreated(LocalDateTime.now()); + newToken.setToken(ApiUtils.generateUuidWithoutDash()); + return refreshTokenRepository.save(newToken); + }); + } + + @Override + public RefreshToken validateAndRefreshToken(String requestRefreshToken) { + RefreshToken refreshToken = refreshTokenRepository.findByToken(requestRefreshToken) + .orElseThrow(() -> new NotFoundException(ApiErrorMessage.NOT_FOUND_REFRESH_TOKEN.getMessage())); + + refreshToken.setCreated(LocalDateTime.now()); + refreshToken.setToken(ApiUtils.generateUuidWithoutDash()); + return refreshTokenRepository.save(refreshToken); + } + +} + diff --git a/rag-service/src/main/java/com/balex/rag/service/impl/UserDocumentServiceImpl.java b/rag-service/src/main/java/com/balex/rag/service/impl/UserDocumentServiceImpl.java new file mode 100644 index 0000000..7a8cc78 --- /dev/null +++ b/rag-service/src/main/java/com/balex/rag/service/impl/UserDocumentServiceImpl.java @@ -0,0 +1,249 @@ +package com.balex.rag.service.impl; + +import com.balex.rag.model.LoadedDocument; +import com.balex.rag.model.UploadProgress; +import com.balex.rag.model.constants.ApiLogMessage; +import com.balex.rag.model.exception.UploadException; +import com.balex.rag.repo.DocumentRepository; +import com.balex.rag.service.UserDocumentService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.ai.document.Document; +import org.springframework.ai.reader.TextReader; +import org.springframework.ai.transformer.splitter.TokenTextSplitter; +import org.springframework.ai.vectorstore.VectorStore; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.io.ByteArrayResource; +import org.springframework.core.io.Resource; +import org.springframework.retry.annotation.Backoff; +import org.springframework.retry.annotation.Retryable; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +import java.io.IOException; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.HexFormat; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.atomic.AtomicBoolean; + +import static com.balex.rag.model.constants.ApiConstants.EMPTY_FILENAME; +import static com.balex.rag.model.constants.ApiErrorMessage.UPLOADED_FILENAME_EMPTY; +import static com.balex.rag.model.constants.ApiErrorMessage.UPLOAD_FILE_READ_ERROR; + +@Slf4j +@Service +@RequiredArgsConstructor +public class UserDocumentServiceImpl implements UserDocumentService { + + private final DocumentRepository documentRepository; + private final VectorStore vectorStore; + private final DocumentTransactionalHelper transactionalHelper; + + private static final String TXT_EXTENSION = "txt"; + private static final String USER_ID_FIELD_NAME = "user_id"; + + private static final String STATUS_PROCESSING = "processing"; + private static final String STATUS_COMPLETED = "completed"; + private static final String STATUS_SKIPPED = "skipped"; + private static final Long SSE_EMITTER_TIMEOUT_IN_MILLIS = 120000L; + + @Value("${app.document.chunk-size:200}") + private int chunkSize; + + public SseEmitter processUploadedFilesWithSse(List files, Long userId) { + SseEmitter emitter = new SseEmitter(SSE_EMITTER_TIMEOUT_IN_MILLIS); + + AtomicBoolean isCompleted = new AtomicBoolean(false); + + emitter.onCompletion(() -> { + log.debug("SSE completed"); + isCompleted.set(true); + }); + emitter.onTimeout(() -> { + log.debug("SSE timeout"); + isCompleted.set(true); + }); + emitter.onError(e -> { + log.debug("SSE client disconnected: {}", e.getMessage()); + isCompleted.set(true); + }); + + List validFiles = files.stream() + .filter(f -> !f.isEmpty()) + .toList(); + + CompletableFuture.runAsync(() -> { + try { + int totalFiles = validFiles.size(); + int processedCount = 0; + + for (MultipartFile file : validFiles) { + if (isCompleted.get()) { + log.debug("Upload cancelled, stopping at file: {}", processedCount); + return; + } + + String filename = getFilename(file); + + sendProgress(emitter, isCompleted, processedCount, totalFiles, filename, STATUS_PROCESSING); + + if (isCompleted.get()) return; // Проверка после отправки + + boolean processed = transactionalHelper.processFileInTransaction( + file, userId, filename, this::processFileInternal); + + processedCount++; + String status = processed ? STATUS_PROCESSING : STATUS_SKIPPED; + sendProgress(emitter, isCompleted, processedCount, totalFiles, filename, status); + } + + if (!isCompleted.get()) { + try { + emitter.send(SseEmitter.event() + .data(UploadProgress.builder() + .percent(100) + .processedFiles(processedCount) + .totalFiles(totalFiles) + .currentFile("") + .status(STATUS_COMPLETED) + .build())); + emitter.complete(); + } catch (IOException | IllegalStateException e) { + log.debug("Could not send completion: {}", e.getMessage()); + } + } + + } catch (Exception e) { + if (!isCompleted.get()) { + log.error("SSE processing error", e); + emitter.completeWithError(e); + } + } + }); + + return emitter; + } + + private void sendProgress(SseEmitter emitter, AtomicBoolean isCompleted, + int processed, int total, String filename, String status) { + if (isCompleted.get()) { + return; + } + + try { + int percent = total > 0 ? (int) Math.round((double) processed / total * 100) : 0; + + emitter.send(SseEmitter.event() + .data(UploadProgress.builder() + .percent(percent) + .processedFiles(processed) + .totalFiles(total) + .currentFile(filename) + .status(status) + .build())); + } catch (IOException | IllegalStateException e) { + // Client disconnected - this is normal for cancel + log.debug("Client disconnected: {}", e.getMessage()); + isCompleted.set(true); + } + } + boolean processFileInternal(MultipartFile file, Long userId, String filename) { + byte[] content; + try { + content = file.getBytes(); + } catch (IOException e) { + throw new UploadException(UPLOAD_FILE_READ_ERROR + filename, e); + } + + String contentHash = computeSha256Hash(content); + + if (documentRepository.existsByFilenameAndContentHash(filename, contentHash)) { + log.debug("Skipping duplicate file: {} with hash: {}", filename, contentHash); + return false; + } + + processTextAndStore(userId, filename, content, contentHash); + return true; + } + + private void processTextAndStore(Long userId, String filename, byte[] content, String contentHash) { + Resource resource = new ByteArrayResource(content) { + @Override + public String getFilename() { + return filename; + } + }; + + List docs = new TextReader(resource).get(); + + TokenTextSplitter splitter = TokenTextSplitter.builder() + .withChunkSize(chunkSize) + .build(); + List chunks = splitter.apply(docs); + + for (Document chunk : chunks) { + chunk.getMetadata().put(USER_ID_FIELD_NAME, userId); + } + + storeInVectorDb(chunks); + + LoadedDocument loaded = LoadedDocument.builder() + .documentType(getExtensionOrTxt(filename)) + .chunkCount(chunks.size()) + .filename(filename) + .contentHash(contentHash) + .userId(userId) + .build(); + + documentRepository.save(loaded); + + log.info("Successfully processed file: {} with {} chunks for user: {}", + filename, chunks.size(), userId); + } + + /** + * Stores documents in vector store with retry via Spring Retry. + */ + @Retryable( + retryFor = RuntimeException.class, + maxAttemptsExpression = "${app.document.retry.max-attempts:3}", + backoff = @Backoff( + delayExpression = "${app.document.retry.delay-ms:1500}", + multiplier = 2 + ) + ) + public void storeInVectorDb(List chunks) { + vectorStore.accept(chunks); + } + + private String getFilename(MultipartFile file) { + String filename = file.getOriginalFilename(); + if (filename == null || filename.isBlank()) { + log.trace(ApiLogMessage.NAME_OF_CURRENT_METHOD.getValue(), UPLOADED_FILENAME_EMPTY); + return EMPTY_FILENAME; + } + return filename; + } + + private String getExtensionOrTxt(String filename) { + int idx = filename.lastIndexOf('.'); + if (idx == -1 || idx == filename.length() - 1) { + return TXT_EXTENSION; + } + return filename.substring(idx + 1).toLowerCase(); + } + + private String computeSha256Hash(byte[] content) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] hash = digest.digest(content); + return HexFormat.of().formatHex(hash); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("SHA-256 algorithm not available", e); + } + } + +} \ No newline at end of file diff --git a/rag-service/src/main/java/com/balex/rag/service/impl/UserServiceImpl.java b/rag-service/src/main/java/com/balex/rag/service/impl/UserServiceImpl.java new file mode 100644 index 0000000..24aed75 --- /dev/null +++ b/rag-service/src/main/java/com/balex/rag/service/impl/UserServiceImpl.java @@ -0,0 +1,204 @@ +package com.balex.rag.service.impl; + +import com.balex.rag.mapper.UserMapper; +import com.balex.rag.model.LoadedDocument; +import com.balex.rag.model.constants.ApiErrorMessage; +import com.balex.rag.model.dto.UserDTO; +import com.balex.rag.model.dto.UserSearchDTO; +import com.balex.rag.model.entity.LoadedDocumentInfo; +import com.balex.rag.model.entity.User; +import com.balex.rag.model.entity.UserInfo; +import com.balex.rag.model.exception.InvalidTokenException; +import com.balex.rag.model.exception.NotFoundException; +import com.balex.rag.model.request.user.NewUserRequest; +import com.balex.rag.model.request.user.UpdateUserRequest; +import com.balex.rag.model.response.PaginationResponse; +import com.balex.rag.model.response.RagResponse; +import com.balex.rag.repo.DocumentRepository; +import com.balex.rag.repo.UserRepository; +import com.balex.rag.repo.VectorStoreRepository; +import com.balex.rag.security.JwtTokenProvider; +import com.balex.rag.security.validation.AccessValidator; +import com.balex.rag.service.UserService; +import com.balex.rag.service.model.exception.DataExistException; +import jakarta.validation.constraints.NotNull; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.Collections; +import java.util.List; + +import static com.balex.rag.model.constants.ApiConstants.USER_ROLE; + +@Service +@RequiredArgsConstructor +public class UserServiceImpl implements UserService { + private final UserRepository userRepository; + private final UserMapper userMapper; + private final PasswordEncoder passwordEncoder; + private final AccessValidator accessValidator; + private final JwtTokenProvider jwtTokenProvider; + private final DocumentRepository documentRepository; + private final VectorStoreRepository vectorStoreRepository; + + @Override + @Transactional(readOnly = true) + public RagResponse getById(@NotNull Integer userId) { + User user = userRepository.findByIdAndDeletedFalse(userId) + .orElseThrow(() -> new NotFoundException(ApiErrorMessage.USER_NOT_FOUND_BY_ID.getMessage(userId))); + + return RagResponse.createSuccessful(userMapper.toDto(user)); + } + + @Override + @Transactional + public RagResponse createUser(@NotNull NewUserRequest request) { + if (userRepository.existsByEmail(request.getEmail())) { + throw new DataExistException(ApiErrorMessage.EMAIL_ALREADY_EXISTS.getMessage(request.getEmail())); + } + + if (userRepository.existsByUsername(request.getUsername())) { + throw new DataExistException(ApiErrorMessage.USERNAME_ALREADY_EXISTS.getMessage(request.getUsername())); + } + + User user = userMapper.createUser(request); + user.setPassword(passwordEncoder.encode(request.getPassword())); + User savedUser = userRepository.save(user); + + return RagResponse.createSuccessful(userMapper.toDto(savedUser)); + } + + @Override + @Transactional + public RagResponse updateUser(@NotNull Integer userId, UpdateUserRequest request) { + User user = userRepository.findByIdAndDeletedFalse(userId) + .orElseThrow(() -> new NotFoundException(ApiErrorMessage.USER_NOT_FOUND_BY_ID.getMessage(userId))); + + if (!user.getUsername().equals(request.getUsername()) && userRepository.existsByUsername(request.getUsername())) { + throw new DataExistException(ApiErrorMessage.USERNAME_ALREADY_EXISTS.getMessage(request.getUsername())); + } + + if (!user.getEmail().equals(request.getEmail()) && userRepository.existsByEmail(request.getEmail())) { + throw new DataExistException(ApiErrorMessage.EMAIL_ALREADY_EXISTS.getMessage(request.getEmail())); + } + + userMapper.updateUser(user, request); + user = userRepository.save(user); + + return RagResponse.createSuccessful(userMapper.toDto(user)); + } + + @Override + @Transactional + public void softDeleteUser(Integer userId) { + User user = userRepository.findByIdAndDeletedFalse(userId) + .orElseThrow(() -> new NotFoundException(ApiErrorMessage.USER_NOT_FOUND_BY_ID.getMessage(userId))); + + accessValidator.validateOwnerAccess(userId); + + user.setDeleted(true); + userRepository.save(user); + + } + + @Override + @Transactional(readOnly = true) + public RagResponse> findAllUsers(Pageable pageable) { + Page users = userRepository.findAll(pageable) + .map(userMapper::toUserSearchDto); + + PaginationResponse paginationResponse = new PaginationResponse<>( + users.getContent(), + new PaginationResponse.Pagination( + users.getTotalElements(), + pageable.getPageSize(), + users.getNumber() + 1, + users.getTotalPages() + ) + ); + + return RagResponse.createSuccessful(paginationResponse); + } + + @Override + public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException { + return getUserDetails(email, userRepository); + } + + static UserDetails getUserDetails(String email, UserRepository userRepository) { + User user = userRepository.findByEmail(email) + .orElseThrow(() -> new NotFoundException(ApiErrorMessage.EMAIL_NOT_FOUND.getMessage(email))); + + user.setLastLogin(LocalDateTime.now()); + userRepository.save(user); + return new org.springframework.security.core.userdetails.User( + user.getEmail(), + user.getPassword(), + Collections.singletonList(new SimpleGrantedAuthority(USER_ROLE)) + ); + } + + @Override + @Transactional(readOnly = true) + public RagResponse getUserInfo(String token) { + User user = getUserInfoFromToken(token); + + List loadedFiles = documentRepository + .findByUserId(user.getId()) + .stream() + .map(doc -> new LoadedDocumentInfo(doc.getId(), doc.getFilename())) + .toList(); + + UserInfo userInfo = new UserInfo(user.getId(), + user.getUsername(), + user.getEmail(), + loadedFiles); + + return RagResponse.createSuccessful(userInfo); + } + + @Override + @Transactional + public RagResponse deleteUserDocuments(String token) { + getUserInfoFromToken(token); + User user = getUserInfoFromToken(token); + + List documents = documentRepository.findByUserId(user.getId()); + + if (documents.isEmpty()) { + return RagResponse.createSuccessful(0); + } + + // Удаляем чанки по user_id + vectorStoreRepository.deleteByUserId(user.getId().longValue()); + + // Удаляем записи из loaded_document + documentRepository.deleteAll(documents); + + return RagResponse.createSuccessful(documents.size()); + } + + private User getUserInfoFromToken(String token) { + if (token == null || token.isBlank()) { + throw new InvalidTokenException("Token is empty or null"); + } + + String cleanToken = token.startsWith("Bearer ") + ? token.substring(7) + : token; + + String username = jwtTokenProvider.getUsername(cleanToken); + + return userRepository.findByUsername(username) + .orElseThrow(() -> new InvalidTokenException("User not found: " + username)); + } +} + diff --git a/rag-service/src/main/java/com/balex/rag/service/model/AuthenticationConstants.java b/rag-service/src/main/java/com/balex/rag/service/model/AuthenticationConstants.java new file mode 100644 index 0000000..c6bafcd --- /dev/null +++ b/rag-service/src/main/java/com/balex/rag/service/model/AuthenticationConstants.java @@ -0,0 +1,16 @@ +package com.balex.rag.service.model; + +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public final class AuthenticationConstants { + + public static final String USER_ID = "userId"; + public static final String USERNAME = "username"; + public static final String USER_EMAIL = "email"; + public static final String USER_REGISTRATION_STATUS = "userRegistrationStatus"; + public static final String LAST_UPDATE = "lastUpdate"; + public static final String ACCESS_KEY_HEADER_NAME = "key"; + +} diff --git a/rag-service/src/main/java/com/balex/rag/service/model/exception/DataExistException.java b/rag-service/src/main/java/com/balex/rag/service/model/exception/DataExistException.java new file mode 100644 index 0000000..c3fd09c --- /dev/null +++ b/rag-service/src/main/java/com/balex/rag/service/model/exception/DataExistException.java @@ -0,0 +1,14 @@ +package com.balex.rag.service.model.exception; + +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class DataExistException extends RuntimeException { + + public DataExistException(String message) { + super(message); + } +} + + diff --git a/rag-service/src/main/java/com/balex/rag/utils/ApiUtils.java b/rag-service/src/main/java/com/balex/rag/utils/ApiUtils.java new file mode 100644 index 0000000..a6ef40b --- /dev/null +++ b/rag-service/src/main/java/com/balex/rag/utils/ApiUtils.java @@ -0,0 +1,53 @@ +package com.balex.rag.utils; + +import com.balex.rag.model.constants.ApiConstants; +import com.balex.rag.security.JwtTokenProvider; +import jakarta.servlet.http.Cookie; +import lombok.RequiredArgsConstructor; +import org.apache.commons.lang3.StringUtils; +import org.springframework.http.HttpHeaders; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; + +import java.util.UUID; + +@Component +@RequiredArgsConstructor +public class ApiUtils { + + private final JwtTokenProvider jwtTokenProvider; + + + public static String getMethodName() { + try { + return new Throwable().getStackTrace()[1].getMethodName(); + } catch (Exception cause) { + return ApiConstants.UNDEFINED; + } + } + + public static Cookie createAuthCookie(String value) { + Cookie authorizationCookie = new Cookie(HttpHeaders.AUTHORIZATION, value); + authorizationCookie.setHttpOnly(true); + authorizationCookie.setSecure(true); + authorizationCookie.setPath("/"); + authorizationCookie.setMaxAge(300); + return authorizationCookie; + } + + public static String generateUuidWithoutDash() { + return UUID.randomUUID().toString().replace(ApiConstants.DASH, StringUtils.EMPTY); + } + + + public static String getCurrentUsername() { + return SecurityContextHolder.getContext().getAuthentication().getName(); + } + + public Integer getUserIdFromAuthentication() { + String jwtToken = SecurityContextHolder.getContext().getAuthentication().getCredentials().toString(); + return Integer.parseInt(jwtTokenProvider.getUserId(jwtToken)); + } + +} + diff --git a/rag-service/src/main/java/com/balex/rag/utils/PasswordMatches.java b/rag-service/src/main/java/com/balex/rag/utils/PasswordMatches.java new file mode 100644 index 0000000..1f1ae9e --- /dev/null +++ b/rag-service/src/main/java/com/balex/rag/utils/PasswordMatches.java @@ -0,0 +1,23 @@ +package com.balex.rag.utils; + +import com.balex.rag.security.validation.PasswordMatchesValidator; +import jakarta.validation.Constraint; +import jakarta.validation.Payload; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Constraint(validatedBy = PasswordMatchesValidator.class) +@Target({ ElementType.TYPE }) +@Retention(RetentionPolicy.RUNTIME) +public @interface PasswordMatches { + String message() default "Passwords do not match"; + + Class[] groups() default {}; + + Class[] payload() default {}; +} + + diff --git a/rag-service/src/main/java/com/balex/rag/utils/PasswordUtils.java b/rag-service/src/main/java/com/balex/rag/utils/PasswordUtils.java new file mode 100644 index 0000000..ebc73df --- /dev/null +++ b/rag-service/src/main/java/com/balex/rag/utils/PasswordUtils.java @@ -0,0 +1,84 @@ +package com.balex.rag.utils; + +import com.balex.rag.model.constants.ApiConstants; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; +import org.apache.commons.lang3.RandomStringUtils; + +import java.security.SecureRandom; +import java.util.ArrayList; +import java.util.Random; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public final class PasswordUtils { + private static final Random RND = new Random(); + + public static boolean isNotValidPassword(String password) { + if (password == null || password.isEmpty() || password.trim().isEmpty()) { + return true; + } + String trim = password.trim(); + if (trim.length() < ApiConstants.REQUIRED_MIN_PASSWORD_LENGTH) { + return true; + } + int charactersNumber = ApiConstants.REQUIRED_MIN_CHARACTERS_NUMBER_IN_PASSWORD; + int lettersUCaseNumber = ApiConstants.REQUIRED_MIN_LETTERS_NUMBER_EVERY_CASE_IN_PASSWORD; + int lettersLCaseNumber = ApiConstants.REQUIRED_MIN_LETTERS_NUMBER_EVERY_CASE_IN_PASSWORD; + int digitsNumber = ApiConstants.REQUIRED_MIN_DIGITS_NUMBER_IN_PASSWORD; + for (int i = 0; i < trim.length(); i++) { + String currentLetter = String.valueOf(trim.charAt(i)); + if (!ApiConstants.PASSWORD_ALL_CHARACTERS.contains(currentLetter)) { + return true; + } + charactersNumber -= ApiConstants.PASSWORD_CHARACTERS.contains(currentLetter) ? 1 : 0; + lettersUCaseNumber -= ApiConstants.PASSWORD_LETTERS_UPPER_CASE.contains(currentLetter) ? 1 : 0; + lettersLCaseNumber -= ApiConstants.PASSWORD_LETTERS_LOWER_CASE.contains(currentLetter) ? 1 : 0; + digitsNumber -= ApiConstants.PASSWORD_DIGITS.contains(currentLetter) ? 1 : 0; + } + return ((charactersNumber > 0) || (lettersUCaseNumber > 0) || (lettersLCaseNumber > 0) || (digitsNumber > 0)); + } + + private static String randomFromChars(int count, String chars) { + final SecureRandom RANDOM = new SecureRandom(); + StringBuilder sb = new StringBuilder(count); + for (int i = 0; i < count; i++) { + int idx = RANDOM.nextInt(chars.length()); + sb.append(chars.charAt(idx)); + } + return sb.toString(); + } + + public static String generatePassword() { + int charactersNumber = ApiConstants.REQUIRED_MIN_CHARACTERS_NUMBER_IN_PASSWORD; + int digitsNumber = ApiConstants.REQUIRED_MIN_DIGITS_NUMBER_IN_PASSWORD; + int lettersUCaseNumber = ApiConstants.REQUIRED_MIN_LETTERS_NUMBER_EVERY_CASE_IN_PASSWORD; + int lettersLCaseNumber = ApiConstants.REQUIRED_MIN_PASSWORD_LENGTH + - charactersNumber - digitsNumber - lettersUCaseNumber; +// String characters = RandomStringUtils.random(charactersNumber, ApiConstants.PASSWORD_CHARACTERS); +// String digits = RandomStringUtils.random(digitsNumber, ApiConstants.PASSWORD_DIGITS); +// String lettersUCase = RandomStringUtils.random(lettersUCaseNumber, ApiConstants.PASSWORD_LETTERS_UPPER_CASE); +// String lettersLCase = RandomStringUtils.random(lettersLCaseNumber, ApiConstants.PASSWORD_LETTERS_LOWER_CASE); + String characters = randomFromChars(charactersNumber, ApiConstants.PASSWORD_CHARACTERS); + String digits = randomFromChars(digitsNumber, ApiConstants.PASSWORD_DIGITS); + String lettersUCase = randomFromChars(lettersUCaseNumber, ApiConstants.PASSWORD_LETTERS_UPPER_CASE); + String lettersLCase = randomFromChars(lettersLCaseNumber, ApiConstants.PASSWORD_LETTERS_LOWER_CASE); + + + ArrayList randomPasswordCharacters = new ArrayList<>(); + for (char character : (characters + digits + lettersUCase + lettersLCase).toCharArray()) { + randomPasswordCharacters.add(character); + } + + StringBuilder password = new StringBuilder(); + int length = randomPasswordCharacters.size(); + for (int i = 0; i < length; i++) { + int randomPosition = RND.nextInt((randomPasswordCharacters.size())); + password.append(randomPasswordCharacters.get(randomPosition)); + randomPasswordCharacters.remove(randomPosition); + } + + return password.toString(); + } + +} + diff --git a/rag-service/src/main/resources/application.properties b/rag-service/src/main/resources/application.properties new file mode 100644 index 0000000..217b255 --- /dev/null +++ b/rag-service/src/main/resources/application.properties @@ -0,0 +1,37 @@ +spring.application.name=rag +spring.ai.ollama.base-url=http://localhost:11431 +#spring.ai.ollama.chat.model=gemma3:4b-it-q4_K_M +spring.ai.ollama.chat.model=llama3.1:8b-instruct-q4_K_M +jwt.secret=ywfI6dBznYmHbokihB/OBzZz6E0Fj+6PiqrM8dQ5c3t0HeYarblCbOGM8vQtOt472AtQ+MsCH7OVIKHOzjrPsQ== +jwt.expiration=103600000 +spring.datasource.url=jdbc:postgresql://localhost:5432/ragdb +spring.datasource.username=postgres +spring.datasource.password=postgres +logging.level.org.springframework.ai.chat.client.advisor=DEBUG +logging.level.org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping=DEBUG +logging.level.org.springframework.web=DEBUG +logging.level.org.flywaydb=DEBUG +logging.level.com.balex.rag.controller=DEBUG +app.document.chunk-size=200 +#spring.main.allow-circular-references=true +server.compression.enabled=false +server.tomcat.connection-timeout=60000 +spring.mvc.async.request-timeout=60000 +#spring.main.web-application-type=reactive +end.points.users=/users +end.points.id=/{id} +end.points.all=/all +end.points.create=/create +end.points.userinfo=/userinfo +end.points.refresh.token=/refresh/token +end.points.auth=/auth +end.points.login=/login +end.points.register=/register +end.points.chat=/chat +end.points.entry=/entry +end.points.document=/documents +rag.rerank-fetch-multiplier = 2 +#Swagger +swagger.servers.first=http://localhost:8080 +springdoc.swagger-ui.path=/swagger-ui.html +springdoc.api-docs.path=/v3/api-docs \ No newline at end of file