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/docker/Dockerfile b/rag-service/docker/Dockerfile new file mode 100644 index 0000000..f7df7f7 --- /dev/null +++ b/rag-service/docker/Dockerfile @@ -0,0 +1,19 @@ +# Stage 1: Build +FROM eclipse-temurin:25-jdk AS build +WORKDIR /app +COPY ../pom.xml . +COPY ../.mvn .mvn +COPY ../mvnw . +RUN chmod +x mvnw && ./mvnw dependency:go-offline -B +COPY ../src src +RUN ./mvnw package -DskipTests -B + +# Stage 2: Run +FROM eclipse-temurin:25-jre +WORKDIR /app +RUN groupadd -r appgroup && useradd -r -g appgroup appuser +COPY --from=build /app/target/*.jar app.jar +RUN chown appuser:appgroup app.jar +USER appuser +EXPOSE 8081 +ENTRYPOINT ["java", "-jar", "app.jar"] \ No newline at end of file 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..a5861c3 --- /dev/null +++ b/rag-service/pom.xml @@ -0,0 +1,204 @@ + + + 4.0.0 + + org.springframework.boot + spring-boot-starter-parent + 3.5.7 + + + com.balex + rag + 0.0.1-SNAPSHOT + Backend for queries to RAG + + + + + + + + + + + + + + + 25 + 1.0.3 + 2025.0.0 + 0.2.0 + + + + org.springframework.boot + spring-boot-starter-data-jpa + + + org.springframework.boot + spring-boot-starter-validation + + + + org.springframework.boot + spring-boot-starter-actuator + + + + org.springframework.cloud + spring-cloud-starter-consul-discovery + + + 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-openai + + + 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 + + + org.springdoc + springdoc-openapi-starter-webmvc-ui + 2.8.8 + + + org.springframework.kafka + spring-kafka + + + org.springframework.ai + spring-ai-tika-document-reader + + + + + + org.springframework.ai + spring-ai-bom + ${spring-ai.version} + pom + import + + + org.springframework.cloud + spring-cloud-dependencies + ${spring-cloud.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 + + + + + + + + \ No newline at end of file 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..a09cd18 --- /dev/null +++ b/rag-service/src/main/java/com/balex/rag/RagApplication.java @@ -0,0 +1,76 @@ +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.openai.OpenAiChatOptions; +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(OpenAiChatOptions.builder() + .model(ragDefaults.model()) + .temperature(ragDefaults.temperature()) + .topP(ragDefaults.topP()) + .frequencyPenalty(ragDefaults.repeatPenalty() - 1.0) // Ollama repeatPenalty 1.1 -> frequencyPenalty 0.1 + .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); + } + +} \ No newline at end of file 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..ce4485f --- /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.openai.OpenAiChatOptions; + +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(OpenAiChatOptions.builder() + .model(props.model()) + .temperature(props.temperature()) + .topP(props.topP()) + .frequencyPenalty(props.repeatPenalty() - 1.0) // Ollama repeatPenalty 1.0 -> frequencyPenalty 0.0 + .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; + } + +} \ No newline at end of file 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..2f9d442 --- /dev/null +++ b/rag-service/src/main/java/com/balex/rag/advisors/rag/RagAdvisor.java @@ -0,0 +1,82 @@ +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(); + + Object userIdObj = chatClientRequest.context().get("USER_ID"); + + SearchRequest.Builder searchBuilder = SearchRequest.builder() + .query(queryToRag) + .topK(searchTopK * rerankFetchMultiplier) + .similarityThreshold(similarityThreshold); + + if (userIdObj != null) { + searchBuilder.filterExpression("user_id == " + userIdObj); + } + + List documents = vectorStore.similaritySearch(searchBuilder.build()); + + 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/EmbeddingConfig.java b/rag-service/src/main/java/com/balex/rag/config/EmbeddingConfig.java new file mode 100644 index 0000000..aefd5ea --- /dev/null +++ b/rag-service/src/main/java/com/balex/rag/config/EmbeddingConfig.java @@ -0,0 +1,26 @@ +package com.balex.rag.config; + +import org.springframework.ai.openai.OpenAiEmbeddingModel; +import org.springframework.ai.openai.api.OpenAiApi; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class EmbeddingConfig { + + @Value("${embedding.openai.api-key:${OPENAI_API_KEY:}}") + private String openaiApiKey; + + @Value("${embedding.openai.model:text-embedding-3-small}") + private String embeddingModel; + + @Bean + public OpenAiEmbeddingModel embeddingModel() { + OpenAiApi openAiApi = OpenAiApi.builder() + .baseUrl("https://api.openai.com") + .apiKey(openaiApiKey) + .build(); + return new OpenAiEmbeddingModel(openAiApi); + } +} \ No newline at end of file diff --git a/rag-service/src/main/java/com/balex/rag/config/KafkaProducerConfig.java b/rag-service/src/main/java/com/balex/rag/config/KafkaProducerConfig.java new file mode 100644 index 0000000..00e1354 --- /dev/null +++ b/rag-service/src/main/java/com/balex/rag/config/KafkaProducerConfig.java @@ -0,0 +1,43 @@ +package com.balex.rag.config; + +import com.balex.rag.model.dto.UserEvent; +import org.apache.kafka.clients.producer.ProducerConfig; +import org.apache.kafka.common.serialization.StringSerializer; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.kafka.core.DefaultKafkaProducerFactory; +import org.springframework.kafka.core.KafkaTemplate; +import org.springframework.kafka.core.ProducerFactory; +import org.springframework.kafka.support.serializer.JsonSerializer; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; + +import java.util.Map; + +@Configuration +public class KafkaProducerConfig { + + @Value("${spring.kafka.bootstrap-servers:localhost:9092}") + private String bootstrapServers; + + @Bean + public ProducerFactory producerFactory() { + ObjectMapper mapper = new ObjectMapper(); + mapper.registerModule(new JavaTimeModule()); + mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); + + Map props = Map.of( + ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapServers, + ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class + ); + + return new DefaultKafkaProducerFactory<>(props, new StringSerializer(), new JsonSerializer<>(mapper)); + } + + @Bean + public KafkaTemplate kafkaTemplate() { + return new KafkaTemplate<>(producerFactory()); + } +} 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..e72dfd2 --- /dev/null +++ b/rag-service/src/main/java/com/balex/rag/config/RagDefaultsProperties.java @@ -0,0 +1,16 @@ +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, + @DefaultValue("llama-3.3-70b-versatile") String model +) {} \ 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..c7144dd --- /dev/null +++ b/rag-service/src/main/java/com/balex/rag/config/RagExpansionProperties.java @@ -0,0 +1,13 @@ +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, + @DefaultValue("llama-3.3-70b-versatile") String model +) {} 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..1186a96 --- /dev/null +++ b/rag-service/src/main/java/com/balex/rag/config/SecurityConfig.java @@ -0,0 +1,26 @@ +package com.balex.rag.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.SecurityFilterChain; + +@Configuration +public class SecurityConfig { + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + return http + .csrf(AbstractHttpConfigurer::disable) + .authorizeHttpRequests(auth -> auth.anyRequest().permitAll()) + .build(); + } + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } +} \ No newline at end of file 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..56ef1aa --- /dev/null +++ b/rag-service/src/main/java/com/balex/rag/controller/ChatController.java @@ -0,0 +1,64 @@ +package com.balex.rag.controller; + +import com.balex.rag.model.entity.Chat; +import com.balex.rag.service.ChatService; +import com.balex.rag.service.EventPublisher; +import com.balex.rag.utils.UserContext; +import jakarta.servlet.http.HttpServletRequest; +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; + private final EventPublisher eventPublisher; + + @GetMapping("") + public ResponseEntity> mainPage(HttpServletRequest request) { + Long ownerId = UserContext.getUserId(request).longValue(); + List response = chatService.getAllChatsByOwner(ownerId); + return ResponseEntity.ok(response); + } + + @GetMapping("/{chatId}") + public ResponseEntity showChat(@PathVariable Long chatId, HttpServletRequest request) { + Long ownerId = UserContext.getUserId(request).longValue(); + Chat response = chatService.getChat(chatId, ownerId); + return ResponseEntity.ok(response); + } + + @PostMapping("/new") + public ResponseEntity newChat(@RequestParam String title, HttpServletRequest request) { + Long ownerId = UserContext.getUserId(request).longValue(); + Chat chat = chatService.createNewChat(title, ownerId); + + eventPublisher.publishChatCreated( + chat.getIdOwner().toString(), + chat.getId().toString()); + + return ResponseEntity.ok(chat); + } + + @DeleteMapping("/{chatId}") + public ResponseEntity deleteChat(@PathVariable Long chatId, HttpServletRequest request) { + Long ownerId = UserContext.getUserId(request).longValue(); + Chat chat = chatService.getChat(chatId, ownerId); + chatService.deleteChat(chatId, ownerId); + + eventPublisher.publishChatDeleted( + chat.getIdOwner().toString(), + chatId.toString()); + + return ResponseEntity.noContent().build(); + } +} \ No newline at end of file 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..6ff1045 --- /dev/null +++ b/rag-service/src/main/java/com/balex/rag/controller/ChatEntryController.java @@ -0,0 +1,53 @@ +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.Chat; +import com.balex.rag.model.entity.ChatEntry; +import com.balex.rag.service.ChatEntryService; +import com.balex.rag.service.ChatService; +import com.balex.rag.service.EventPublisher; +import com.balex.rag.utils.ApiUtils; +import com.balex.rag.utils.UserContext; +import jakarta.servlet.http.HttpServletRequest; +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.entry}") +public class ChatEntryController { + + private final ChatEntryService chatEntryService; + private final ChatService chatService; + private final RagDefaultsProperties ragDefaults; + private final EventPublisher eventPublisher; + + @PostMapping("/{chatId}") + public ResponseEntity addUserEntry( + @PathVariable Long chatId, + @RequestBody UserEntryRequest request, + HttpServletRequest httpRequest) { + log.trace(ApiLogMessage.NAME_OF_CURRENT_METHOD.getValue(), ApiUtils.getMethodName()); + + Long ownerId = UserContext.getUserId(httpRequest).longValue(); + + boolean onlyContext = request.onlyContext() != null ? request.onlyContext() : ragDefaults.onlyContext(); + double topP = request.topP() != null ? request.topP() : ragDefaults.topP(); + + Chat chat = chatService.getChat(chatId, ownerId); + ChatEntry entry = chatEntryService.addUserEntry(chatId, request.content(), onlyContext, topP, chat.getIdOwner()); + + eventPublisher.publishQuerySent( + chat.getIdOwner().toString(), + chatId.toString()); + + 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..ae82b95 --- /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.UserContext; +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.HttpServletRequest; +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; + + @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, + HttpServletRequest request) { + Integer userId = UserContext.getUserId(request); + 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..9b40e5e --- /dev/null +++ b/rag-service/src/main/java/com/balex/rag/controller/UserController.java @@ -0,0 +1,105 @@ +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.EventPublisher; +import com.balex.rag.service.UserService; +import com.balex.rag.utils.ApiUtils; +import com.balex.rag.utils.UserContext; +import jakarta.servlet.http.HttpServletRequest; +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; + private final EventPublisher eventPublisher; + + @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); + + eventPublisher.publishUserCreated(createdUser.getPayload().getId().toString()); + + return ResponseEntity.ok(createdUser); + } + + @GetMapping("${end.points.userinfo}") + public ResponseEntity> getUserInfo(HttpServletRequest request) { + log.trace(ApiLogMessage.NAME_OF_CURRENT_METHOD.getValue(), ApiUtils.getMethodName()); + + Integer userId = UserContext.getUserId(request); + RagResponse userInfo = userService.getUserInfo(userId); + + return ResponseEntity.ok(userInfo); + } + + @DeleteMapping("${end.points.userinfo}") + public ResponseEntity> deleteUserDocuments(HttpServletRequest request) { + log.trace(ApiLogMessage.NAME_OF_CURRENT_METHOD.getValue(), ApiUtils.getMethodName()); + + Integer userId = UserContext.getUserId(request); + RagResponse deletedCount = userService.deleteUserDocuments(userId); + + 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, + HttpServletRequest request) { + log.trace(ApiLogMessage.NAME_OF_CURRENT_METHOD.getValue(), ApiUtils.getMethodName()); + + Integer currentUserId = UserContext.getUserId(request); + userService.softDeleteUser(userId, currentUserId); + 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); + } +} \ No newline at end of file diff --git a/rag-service/src/main/java/com/balex/rag/filter/GatewayAuthFilter.java b/rag-service/src/main/java/com/balex/rag/filter/GatewayAuthFilter.java new file mode 100644 index 0000000..94e4ace --- /dev/null +++ b/rag-service/src/main/java/com/balex/rag/filter/GatewayAuthFilter.java @@ -0,0 +1,37 @@ +package com.balex.rag.filter; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +@Slf4j +@Component +public class GatewayAuthFilter extends OncePerRequestFilter { + + @Override + protected void doFilterInternal(HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + + String userId = request.getHeader("X-User-Id"); + String email = request.getHeader("X-User-Email"); + String username = request.getHeader("X-User-Name"); + String role = request.getHeader("X-User-Role"); + + if (userId != null) { + request.setAttribute("userId", userId); + request.setAttribute("userEmail", email); + request.setAttribute("userName", username); + request.setAttribute("userRole", role); + log.debug("Gateway user: id={}, email={}, role={}", userId, email, role); + } + + filterChain.doFilter(request, response); + } +} \ No newline at end of file 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..605dda5 --- /dev/null +++ b/rag-service/src/main/java/com/balex/rag/mapper/UserMapper.java @@ -0,0 +1,36 @@ +package com.balex.rag.mapper; + +import com.balex.rag.model.dto.UserDTO; +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.UpdateUserRequest; +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} +) +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); +} \ No newline at end of file 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..ce506cc --- /dev/null +++ b/rag-service/src/main/java/com/balex/rag/model/constants/ApiConstants.java @@ -0,0 +1,29 @@ +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 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..0a5387d --- /dev/null +++ b/rag-service/src/main/java/com/balex/rag/model/dto/UserEntryRequest.java @@ -0,0 +1,7 @@ +package com.balex.rag.model.dto; + +public record UserEntryRequest( + String content, + Boolean onlyContext, + Double topP +) {} \ No newline at end of file diff --git a/rag-service/src/main/java/com/balex/rag/model/dto/UserEvent.java b/rag-service/src/main/java/com/balex/rag/model/dto/UserEvent.java new file mode 100644 index 0000000..43f716c --- /dev/null +++ b/rag-service/src/main/java/com/balex/rag/model/dto/UserEvent.java @@ -0,0 +1,36 @@ +package com.balex.rag.model.dto; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.Instant; + +/** + * Event published to Kafka topic "user-events". + * Consumed by analytics-service. + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@JsonIgnoreProperties(ignoreUnknown = true) +public class UserEvent { + + private EventType type; + private String userId; + private String chatId; + + @Builder.Default + private Instant timestamp = Instant.now(); + + public enum EventType { + USER_CREATED, + CHAT_CREATED, + CHAT_DELETED, + QUERY_SENT + } +} + 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..2503023 --- /dev/null +++ b/rag-service/src/main/java/com/balex/rag/model/entity/ChatEntry.java @@ -0,0 +1,51 @@ +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; + + @Column(columnDefinition = "TEXT") + 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/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/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/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/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..901a49e --- /dev/null +++ b/rag-service/src/main/java/com/balex/rag/repo/ChatRepository.java @@ -0,0 +1,11 @@ +package com.balex.rag.repo; + +import com.balex.rag.model.entity.Chat; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface ChatRepository extends JpaRepository { + List findByIdOwnerOrderByCreatedAtDesc(Long idOwner); +} + 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/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/validation/AccessValidator.java b/rag-service/src/main/java/com/balex/rag/security/validation/AccessValidator.java new file mode 100644 index 0000000..c035964 --- /dev/null +++ b/rag-service/src/main/java/com/balex/rag/security/validation/AccessValidator.java @@ -0,0 +1,22 @@ +package com.balex.rag.security.validation; + +import com.balex.rag.model.constants.ApiErrorMessage; +import com.balex.rag.repo.UserRepository; +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; + + @SneakyThrows + public void validateOwnerAccess(Integer ownerId, Integer currentUserId) { + if (!currentUserId.equals(ownerId)) { + throw new AccessDeniedException(ApiErrorMessage.HAVE_NO_ACCESS.getMessage()); + } + } +} \ No newline at end of file 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..be9723e --- /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, double topP, Long userId); +} \ 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..e07fc5f --- /dev/null +++ b/rag-service/src/main/java/com/balex/rag/service/ChatService.java @@ -0,0 +1,19 @@ +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, Long ownerId); + + List getAllChatsByOwner(Long ownerId); + + Chat getChat(Long chatId, Long ownerId); + + void deleteChat(Long chatId, Long ownerId); + + SseEmitter proceedInteractionWithStreaming(Long chatId, String userPrompt); +} diff --git a/rag-service/src/main/java/com/balex/rag/service/EventPublisher.java b/rag-service/src/main/java/com/balex/rag/service/EventPublisher.java new file mode 100644 index 0000000..3d5825c --- /dev/null +++ b/rag-service/src/main/java/com/balex/rag/service/EventPublisher.java @@ -0,0 +1,12 @@ +package com.balex.rag.service; + +public interface EventPublisher { + + void publishChatCreated(String userId, String chatId); + + void publishChatDeleted(String userId, String chatId); + + void publishQuerySent(String userId, String chatId); + + void publishUserCreated(String userId); +} \ No newline at end of file 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/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..87a8db1 --- /dev/null +++ b/rag-service/src/main/java/com/balex/rag/service/UserService.java @@ -0,0 +1,28 @@ +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; + +public interface UserService { + + RagResponse getById(@NotNull Integer userId); + + RagResponse createUser(@NotNull NewUserRequest request); + + RagResponse updateUser(@NotNull Integer postId, @NotNull UpdateUserRequest request); + + void softDeleteUser(Integer userId, Integer currentUserId); + + RagResponse> findAllUsers(Pageable pageable); + + RagResponse getUserInfo(Integer userId); + + RagResponse deleteUserDocuments(Integer userId); +} \ No newline at end of file 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/ChatEntryServiceImpl.java b/rag-service/src/main/java/com/balex/rag/service/impl/ChatEntryServiceImpl.java new file mode 100644 index 0000000..f61ea94 --- /dev/null +++ b/rag-service/src/main/java/com/balex/rag/service/impl/ChatEntryServiceImpl.java @@ -0,0 +1,85 @@ +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.openai.OpenAiChatOptions; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import com.balex.rag.config.RagDefaultsProperties; + +import java.util.List; + +@Slf4j +@Service +@RequiredArgsConstructor +public class ChatEntryServiceImpl implements ChatEntryService { + + private final ChatEntryRepository chatEntryRepository; + private final ChatRepository chatRepository; + private final ChatClient chatClient; + private final RagDefaultsProperties ragDefaults; + + @Override + public List getEntriesByChatId(Long chatId) { + return chatEntryRepository.findByChatIdOrderByCreatedAtAsc(chatId); + } + + @Override + @Transactional + public ChatEntry addUserEntry(Long chatId, String content, boolean onlyContext, double topP, Long userId) { + 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)) + .param("USER_ID", userId)) + .options(OpenAiChatOptions.builder() + .model(ragDefaults.model()) + .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..cde046f --- /dev/null +++ b/rag-service/src/main/java/com/balex/rag/service/impl/ChatServiceImpl.java @@ -0,0 +1,62 @@ +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 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.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.web.server.ResponseStatusException; +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; + + public List getAllChatsByOwner(Long ownerId) { + return chatRepo.findByIdOwnerOrderByCreatedAtDesc(ownerId); + } + + public Chat createNewChat(String title, Long ownerId) { + Chat chat = Chat.builder().title(title).idOwner(ownerId).build(); + chatRepo.save(chat); + return chat; + } + + public Chat getChat(Long chatId, Long ownerId) { + Chat chat = chatRepo.findById(chatId).orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Chat not found")); + if (!chat.getIdOwner().equals(ownerId)) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Access denied"); + } + return chat; + } + + public void deleteChat(Long chatId, Long ownerId) { + Chat chat = getChat(chatId, ownerId); + chatRepo.deleteById(chat.getId()); + } + + 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()); + } +} \ No newline at end of file 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/EventPublisherImpl.java b/rag-service/src/main/java/com/balex/rag/service/impl/EventPublisherImpl.java new file mode 100644 index 0000000..042c377 --- /dev/null +++ b/rag-service/src/main/java/com/balex/rag/service/impl/EventPublisherImpl.java @@ -0,0 +1,68 @@ +package com.balex.rag.service.impl; + +import com.balex.rag.model.dto.UserEvent; +import com.balex.rag.model.dto.UserEvent.EventType; +import com.balex.rag.service.EventPublisher; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.kafka.core.KafkaTemplate; +import org.springframework.stereotype.Service; + +@Slf4j +@Service +@RequiredArgsConstructor +public class EventPublisherImpl implements EventPublisher { + + private final KafkaTemplate kafkaTemplate; + + @Value("${analytics.kafka.topic:user-events}") + private String topic; + + @Override + public void publishChatCreated(String userId, String chatId) { + publish(UserEvent.builder() + .type(EventType.CHAT_CREATED) + .userId(userId) + .chatId(chatId) + .build()); + } + + @Override + public void publishChatDeleted(String userId, String chatId) { + publish(UserEvent.builder() + .type(EventType.CHAT_DELETED) + .userId(userId) + .chatId(chatId) + .build()); + } + + @Override + public void publishQuerySent(String userId, String chatId) { + publish(UserEvent.builder() + .type(EventType.QUERY_SENT) + .userId(userId) + .chatId(chatId) + .build()); + } + + + private void publish(UserEvent event) { + kafkaTemplate.send(topic, event.getUserId(), event) + .whenComplete((result, ex) -> { + if (ex != null) { + log.error("Failed to send event {}: {}", event.getType(), ex.getMessage()); + } else { + log.info("Event sent: type={}, userId={}", event.getType(), event.getUserId()); + } + }); + } + + @Override + public void publishUserCreated(String userId) { + publish(UserEvent.builder() + .type(EventType.USER_CREATED) + .userId(userId) + .build()); + } +} \ No newline at end of file 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..ca25460 --- /dev/null +++ b/rag-service/src/main/java/com/balex/rag/service/impl/UserDocumentServiceImpl.java @@ -0,0 +1,256 @@ +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 org.springframework.ai.reader.tika.TikaDocumentReader; + +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; + String ext = getExtensionOrTxt(filename); + if (ext.equals("txt")) { + docs = new TextReader(resource).get(); + } else { + docs = new TikaDocumentReader(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..cba4559 --- /dev/null +++ b/rag-service/src/main/java/com/balex/rag/service/impl/UserServiceImpl.java @@ -0,0 +1,157 @@ +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.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.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.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@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 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, Integer currentUserId) { + User user = userRepository.findByIdAndDeletedFalse(userId) + .orElseThrow(() -> new NotFoundException(ApiErrorMessage.USER_NOT_FOUND_BY_ID.getMessage(userId))); + + accessValidator.validateOwnerAccess(userId, currentUserId); + + 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 + @Transactional(readOnly = true) + public RagResponse getUserInfo(Integer userId) { + User user = userRepository.findByIdAndDeletedFalse(userId) + .orElseThrow(() -> new NotFoundException(ApiErrorMessage.USER_NOT_FOUND_BY_ID.getMessage(userId))); + + 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(Integer userId) { + User user = userRepository.findByIdAndDeletedFalse(userId) + .orElseThrow(() -> new NotFoundException(ApiErrorMessage.USER_NOT_FOUND_BY_ID.getMessage(userId))); + + List documents = documentRepository.findByUserId(user.getId()); + + if (documents.isEmpty()) { + return RagResponse.createSuccessful(0); + } + + vectorStoreRepository.deleteByUserId(user.getId().longValue()); + documentRepository.deleteAll(documents); + + return RagResponse.createSuccessful(documents.size()); + } +} \ No newline at end of file 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..2460543 --- /dev/null +++ b/rag-service/src/main/java/com/balex/rag/service/model/AuthenticationConstants.java @@ -0,0 +1,17 @@ +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 SESSION_ID = "sessionId"; + 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..a87affb --- /dev/null +++ b/rag-service/src/main/java/com/balex/rag/utils/ApiUtils.java @@ -0,0 +1,16 @@ +package com.balex.rag.utils; + +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public final class ApiUtils { + + public static String getMethodName() { + try { + return new Throwable().getStackTrace()[1].getMethodName(); + } catch (Exception cause) { + return "undefined"; + } + } +} \ No newline at end of file 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..4c9602c --- /dev/null +++ b/rag-service/src/main/java/com/balex/rag/utils/PasswordUtils.java @@ -0,0 +1,80 @@ +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 = 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/java/com/balex/rag/utils/UserContext.java b/rag-service/src/main/java/com/balex/rag/utils/UserContext.java new file mode 100644 index 0000000..a6f8392 --- /dev/null +++ b/rag-service/src/main/java/com/balex/rag/utils/UserContext.java @@ -0,0 +1,25 @@ +package com.balex.rag.utils; + +import jakarta.servlet.http.HttpServletRequest; + +public final class UserContext { + + private UserContext() {} + + public static Integer getUserId(HttpServletRequest request) { + String userId = (String) request.getAttribute("userId"); + return userId != null ? Integer.parseInt(userId) : null; + } + + public static String getUserEmail(HttpServletRequest request) { + return (String) request.getAttribute("userEmail"); + } + + public static String getUserName(HttpServletRequest request) { + return (String) request.getAttribute("userName"); + } + + public static String getUserRole(HttpServletRequest request) { + return (String) request.getAttribute("userRole"); + } +} \ No newline at end of file diff --git a/rag-service/src/main/resources/application.properties b/rag-service/src/main/resources/application.properties new file mode 100644 index 0000000..9095258 --- /dev/null +++ b/rag-service/src/main/resources/application.properties @@ -0,0 +1,62 @@ +spring.application.name=rag-service + +# --- LLM Provider: Groq (OpenAI-compatible API) --- +spring.ai.openai.base-url=${SPRING_AI_OPENAI_BASE_URL:https://api.groq.com/openai} +spring.ai.openai.api-key=${SPRING_AI_OPENAI_API_KEY:} +spring.ai.openai.chat.model=${SPRING_AI_OPENAI_CHAT_MODEL:llama-3.3-70b-versatile} +spring.jpa.hibernate.ddl-auto=update + +# Embedding via separate OpenAI API bean (see EmbeddingConfig.java) +embedding.openai.api-key=${OPENAI_API_KEY:} +embedding.openai.model=text-embedding-3-small +spring.ai.vectorstore.pgvector.initialize-schema=true + +# --- Consul service discovery --- +spring.cloud.consul.host=${SPRING_CLOUD_CONSUL_HOST:localhost} +spring.cloud.consul.port=${SPRING_CLOUD_CONSUL_PORT:8500} +spring.cloud.consul.discovery.service-name=rag-service +spring.cloud.consul.discovery.instance-id=${spring.application.name}-${random.value} +spring.cloud.consul.discovery.prefer-ip-address=true +spring.cloud.consul.discovery.health-check-interval=15s +spring.cloud.consul.discovery.deregister-critical-service-after=1m + +# --- Actuator --- +management.endpoints.web.exposure.include=health,info +management.endpoint.health.show-details=always + +spring.servlet.multipart.max-file-size=5MB +spring.servlet.multipart.max-request-size=10MB + +spring.datasource.url=${SPRING_DATASOURCE_URL:jdbc:postgresql://localhost:5432/ragdb} +spring.datasource.username=${SPRING_DATASOURCE_USERNAME:postgres} +spring.datasource.password=${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 +server.compression.enabled=false +server.tomcat.connection-timeout=60000 +spring.mvc.async.request-timeout=60000 +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 +server.forward-headers-strategy=framework +#Kafka +spring.kafka.bootstrap-servers=${KAFKA_BOOTSTRAP_SERVERS:localhost:9092} +analytics.kafka.topic=user-events \ No newline at end of file