restore rag-service

This commit is contained in:
2026-03-18 23:40:34 +01:00
parent 908206b5cd
commit 80bc42c785
77 changed files with 3691 additions and 0 deletions

43
rag-service/.gitignore vendored Normal file
View File

@@ -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/

View File

@@ -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

View File

@@ -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"]

295
rag-service/mvnw vendored Normal file
View File

@@ -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-<version>,maven-mvnd-<version>-<platform>}/<hash>
[ -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 "$@"

189
rag-service/mvnw.cmd vendored Normal file
View File

@@ -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-<version>,maven-mvnd-<version>-<platform>}/<hash>
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"

204
rag-service/pom.xml Normal file
View File

@@ -0,0 +1,204 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.5.7</version>
<relativePath/>
</parent>
<groupId>com.balex</groupId>
<artifactId>rag</artifactId>
<version>0.0.1-SNAPSHOT</version>
<description>Backend for queries to RAG</description>
<url/>
<licenses>
<license/>
</licenses>
<developers>
<developer/>
</developers>
<scm>
<connection/>
<developerConnection/>
<tag/>
<url/>
</scm>
<properties>
<java.version>25</java.version>
<spring-ai.version>1.0.3</spring-ai.version>
<spring-cloud.version>2025.0.0</spring-cloud.version>
<lombok-mapstruct-binding.version>0.2.0</lombok-mapstruct-binding.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<!-- Actuator for health checks and Consul registration -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<!-- Consul service discovery -->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-consul-discovery</artifactId>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
<version>3.18.0</version>
</dependency>
<dependency>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct</artifactId>
<version>1.5.5.Final</version>
</dependency>
<dependency>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct-processor</artifactId>
<version>1.5.5.Final</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- OpenAI-compatible API: Groq for chat, OpenAI for embedding -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-model-openai</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-model-chat-memory-repository-jdbc</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-vector-store-pgvector</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-advisors-vector-store</artifactId>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.github.pemistahl</groupId>
<artifactId>lingua</artifactId>
<version>1.2.2</version>
</dependency>
<dependency>
<groupId>org.apache.lucene</groupId>
<artifactId>lucene-analysis-common</artifactId>
<version>10.3.1</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
<version>2.8.8</version>
</dependency>
<dependency>
<groupId>org.springframework.kafka</groupId>
<artifactId>spring-kafka</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-tika-document-reader</artifactId>
</dependency>
</dependencies>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-bom</artifactId>
<version>${spring-ai.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-dependencies</artifactId>
<version>${spring-cloud.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<annotationProcessorPaths>
<path>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</path>
<path>
<groupId>org.projectlombok</groupId>
<artifactId>lombok-mapstruct-binding</artifactId>
<version>${lombok-mapstruct-binding.version}</version>
</path>
<path>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct-processor</artifactId>
<version>1.5.5.Final</version>
</path>
</annotationProcessorPaths>
</configuration>
</plugin>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<excludes>
<exclude>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</exclude>
</excludes>
</configuration>
</plugin>
</plugins>
</build>
</project>

View File

@@ -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);
}
}

View File

@@ -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<String> handleNotFoundException(NotFoundException ex) {
logStackTrace(ex);
return ResponseEntity
.status(HttpStatus.NOT_FOUND)
.body(ex.getMessage());
}
@ExceptionHandler(DataExistException.class)
@ResponseBody
protected ResponseEntity<String> handleDataExistException(DataExistException ex) {
logStackTrace(ex);
return ResponseEntity
.status(HttpStatus.CONFLICT)
.body(ex.getMessage());
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<Map<String, String>> handleMethodArgumentNotValidException(MethodArgumentNotValidException ex) {
logStackTrace(ex);
Map<String, String> 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<String> handleAuthenticationException(AuthenticationException ex) {
logStackTrace(ex);
return ResponseEntity
.status(HttpStatus.UNAUTHORIZED)
.body(ex.getMessage());
}
@ExceptionHandler(InvalidDataException.class)
@ResponseBody
protected ResponseEntity<String> 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<String> 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());
}
}

View File

@@ -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;
}
}

View File

@@ -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<Document> rerank(List<Document> corpus, String query, int limit) {
if (corpus == null || corpus.isEmpty()) {
return new ArrayList<>();
}
// Compute corpus statistics
CorpusStats stats = computeCorpusStats(corpus);
// Tokenize query
List<String> 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<Document> corpus) {
Map<String, Integer> docFreq = new HashMap<>();
Map<Document, List<String>> tokenizedDocs = new HashMap<>();
int totalLength = 0;
int totalDocs = corpus.size();
// Process each document
for (Document doc : corpus) {
List<String> tokens = tokenize(doc.getText());
tokenizedDocs.put(doc, tokens);
totalLength += tokens.size();
// Update document frequencies
Set<String> 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<String> queryTerms, Document doc, CorpusStats stats) {
List<String> tokens = stats.tokenizedDocs.get(doc);
if (tokens == null) {
return 0.0;
}
// Calculate term frequencies for this document
Map<String, Integer> 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<String> tokenize(String text) {
List<String> 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<String, Integer> docFreq;
final Map<Document, List<String>> tokenizedDocs;
final double avgDocLength;
final int totalDocs;
CorpusStats(Map<String, Integer> docFreq,
Map<Document, List<String>> tokenizedDocs,
double avgDocLength,
int totalDocs) {
this.docFreq = docFreq;
this.tokenizedDocs = tokenizedDocs;
this.avgDocLength = avgDocLength;
this.totalDocs = totalDocs;
}
}
}

View File

@@ -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<Document> 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;
}
}

View File

@@ -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);
}
}

View File

@@ -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<String, UserEvent> producerFactory() {
ObjectMapper mapper = new ObjectMapper();
mapper.registerModule(new JavaTimeModule());
mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
Map<String, Object> 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<String, UserEvent> kafkaTemplate() {
return new KafkaTemplate<>(producerFactory());
}
}

View File

@@ -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
) {}

View File

@@ -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
) {}

View File

@@ -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();
}
}

View File

@@ -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<List<Chat>> mainPage(HttpServletRequest request) {
Long ownerId = UserContext.getUserId(request).longValue();
List<Chat> response = chatService.getAllChatsByOwner(ownerId);
return ResponseEntity.ok(response);
}
@GetMapping("/{chatId}")
public ResponseEntity<Chat> 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<Chat> 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<Void> 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();
}
}

View File

@@ -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<ChatEntry> 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);
}
}

View File

@@ -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<MultipartFile> files,
HttpServletRequest request) {
Integer userId = UserContext.getUserId(request);
return userDocumentService.processUploadedFilesWithSse(files, userId.longValue());
}
}

View File

@@ -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<RagResponse<UserDTO>> getUserById(
@PathVariable(name = "id") Integer userId) {
log.trace(ApiLogMessage.NAME_OF_CURRENT_METHOD.getValue(), ApiUtils.getMethodName());
RagResponse<UserDTO> response = userService.getById(userId);
return ResponseEntity.ok(response);
}
@PostMapping("${end.points.create}")
public ResponseEntity<RagResponse<UserDTO>> createUser(
@RequestBody @Valid NewUserRequest request) {
log.trace(ApiLogMessage.NAME_OF_CURRENT_METHOD.getValue(), ApiUtils.getMethodName());
RagResponse<UserDTO> createdUser = userService.createUser(request);
eventPublisher.publishUserCreated(createdUser.getPayload().getId().toString());
return ResponseEntity.ok(createdUser);
}
@GetMapping("${end.points.userinfo}")
public ResponseEntity<RagResponse<UserInfo>> getUserInfo(HttpServletRequest request) {
log.trace(ApiLogMessage.NAME_OF_CURRENT_METHOD.getValue(), ApiUtils.getMethodName());
Integer userId = UserContext.getUserId(request);
RagResponse<UserInfo> userInfo = userService.getUserInfo(userId);
return ResponseEntity.ok(userInfo);
}
@DeleteMapping("${end.points.userinfo}")
public ResponseEntity<RagResponse<Integer>> deleteUserDocuments(HttpServletRequest request) {
log.trace(ApiLogMessage.NAME_OF_CURRENT_METHOD.getValue(), ApiUtils.getMethodName());
Integer userId = UserContext.getUserId(request);
RagResponse<Integer> deletedCount = userService.deleteUserDocuments(userId);
return ResponseEntity.ok(deletedCount);
}
@PutMapping("${end.points.id}")
public ResponseEntity<RagResponse<UserDTO>> updateUserById(
@PathVariable(name = "id") Integer userId,
@RequestBody @Valid UpdateUserRequest request) {
log.trace(ApiLogMessage.NAME_OF_CURRENT_METHOD.getValue(), ApiUtils.getMethodName());
RagResponse<UserDTO> updatedPost = userService.updateUser(userId, request);
return ResponseEntity.ok(updatedPost);
}
@DeleteMapping("${end.points.id}")
public ResponseEntity<Void> 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<RagResponse<PaginationResponse<UserSearchDTO>>> 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<PaginationResponse<UserSearchDTO>> response = userService.findAllUsers(pageable);
return ResponseEntity.ok(response);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}

View File

@@ -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;
}

View File

@@ -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"
}

View File

@@ -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;
}

View File

@@ -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);
}
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -0,0 +1,7 @@
package com.balex.rag.model.dto;
public record UserEntryRequest(
String content,
Boolean onlyContext,
Double topP
) {}

View File

@@ -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
}
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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<ChatEntry> history = new ArrayList<>();
public void addChatEntry(ChatEntry entry) {
history.add(entry);
}
}

View File

@@ -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);
}
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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<LoadedDocumentInfo> loadedFiles;
Integer maxLoadedFiles = MAX_FILES_ALLOWED_FOR_LOAD;
public UserInfo(Integer id, String username, String email, List<LoadedDocumentInfo> loadedFiles) {
this.id = id;
this.username = username;
this.email = email;
this.loadedFiles = loadedFiles;
}
}

View File

@@ -0,0 +1,8 @@
package com.balex.rag.model.enums;
public enum RegistrationStatus {
ACTIVE,
INACTIVE
}

View File

@@ -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;
}

View File

@@ -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);
}

View File

@@ -0,0 +1,10 @@
package com.balex.rag.model.exception;
public class DataExistException extends RuntimeException {
public DataExistException(String message) {
super(message);
}
}

View File

@@ -0,0 +1,10 @@
package com.balex.rag.model.exception;
public class InvalidDataException extends RuntimeException {
public InvalidDataException(String message) {
super(message);
}
}

View File

@@ -0,0 +1,10 @@
package com.balex.rag.model.exception;
public class InvalidPasswordException extends RuntimeException {
public InvalidPasswordException(String message) {
super(message);
}
}

View File

@@ -0,0 +1,11 @@
package com.balex.rag.model.exception;
public class NotFoundException extends RuntimeException {
public NotFoundException(String message) {
super(message);
}
}

View File

@@ -0,0 +1,10 @@
package com.balex.rag.model.exception;
public class RerankException extends RuntimeException {
public RerankException(String message) {
super(message);
}
}

View File

@@ -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);
}
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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<T> implements Serializable {
private List<T> 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;
}
}

View File

@@ -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<P extends Serializable> implements Serializable {
private String message;
private P payload;
private boolean success;
public static <P extends Serializable> RagResponse<P> createSuccessful(P payload) {
return new RagResponse<>(StringUtils.EMPTY, payload, true);
}
public static <P extends Serializable> RagResponse<P> createSuccessfulWithNewToken(P payload) {
return new RagResponse<>(ApiMessage.TOKEN_CREATED_OR_UPDATED.getMessage(), payload, true);
}
}

View File

@@ -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<ChatEntry, Long> {
List<ChatEntry> findByChatIdOrderByCreatedAtAsc(Long chatId);
}

View File

@@ -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<Chat, Long> {
List<Chat> findByIdOwnerOrderByCreatedAtDesc(Long idOwner);
}

View File

@@ -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<LoadedDocument, Long> {
boolean existsByFilenameAndContentHash(String filename, String contentHash);
List<LoadedDocument> findByUserId(Integer userId);
}

View File

@@ -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<User, Integer>, JpaSpecificationExecutor<User> {
boolean existsByEmail(String email);
boolean existsByUsername(String username);
Optional<User> findByIdAndDeletedFalse (Integer id);
Optional<User> findUserByEmailAndDeletedFalse(String email);
Optional<User> findByEmail(String email);
Optional<User> findByUsername(String username);
}

View File

@@ -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<String> sources);
void deleteByUserId(Long userId);
}

View File

@@ -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<String> 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);
}
}

View File

@@ -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());
}
}
}

View File

@@ -0,0 +1,12 @@
package com.balex.rag.service;
import com.balex.rag.model.entity.ChatEntry;
import java.util.List;
public interface ChatEntryService {
List<ChatEntry> getEntriesByChatId(Long chatId);
ChatEntry addUserEntry(Long chatId, String content, boolean onlyContext, double topP, Long userId);
}

View File

@@ -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<Chat> getAllChatsByOwner(Long ownerId);
Chat getChat(Long chatId, Long ownerId);
void deleteChat(Long chatId, Long ownerId);
SseEmitter proceedInteractionWithStreaming(Long chatId, String userPrompt);
}

View File

@@ -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);
}

View File

@@ -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<Message> 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<Message> 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
}
}

View File

@@ -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<MultipartFile> files, Long userId);
}

View File

@@ -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<UserDTO> getById(@NotNull Integer userId);
RagResponse<UserDTO> createUser(@NotNull NewUserRequest request);
RagResponse<UserDTO> updateUser(@NotNull Integer postId, @NotNull UpdateUserRequest request);
void softDeleteUser(Integer userId, Integer currentUserId);
RagResponse<PaginationResponse<UserSearchDTO>> findAllUsers(Pageable pageable);
RagResponse<UserInfo> getUserInfo(Integer userId);
RagResponse<Integer> deleteUserDocuments(Integer userId);
}

View File

@@ -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<Resource> 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<Document> docs = new TextReader(resource).get();
// TokenTextSplitter splitter = TokenTextSplitter.builder().withChunkSize(200).build();
// List<Document> 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<Document> 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();
// }
//}

View File

@@ -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<ChatEntry> 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);
}
}

View File

@@ -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<Chat> 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());
}
}

View File

@@ -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);
}
}

View File

@@ -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<String, UserEvent> 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());
}
}

View File

@@ -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<MultipartFile> 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<MultipartFile> 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<Document> 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<Document> 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<Document> 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);
}
}
}

View File

@@ -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<UserDTO> 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<UserDTO> 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<UserDTO> 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<PaginationResponse<UserSearchDTO>> findAllUsers(Pageable pageable) {
Page<UserSearchDTO> users = userRepository.findAll(pageable)
.map(userMapper::toUserSearchDto);
PaginationResponse<UserSearchDTO> 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<UserInfo> getUserInfo(Integer userId) {
User user = userRepository.findByIdAndDeletedFalse(userId)
.orElseThrow(() -> new NotFoundException(ApiErrorMessage.USER_NOT_FOUND_BY_ID.getMessage(userId)));
List<LoadedDocumentInfo> 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<Integer> deleteUserDocuments(Integer userId) {
User user = userRepository.findByIdAndDeletedFalse(userId)
.orElseThrow(() -> new NotFoundException(ApiErrorMessage.USER_NOT_FOUND_BY_ID.getMessage(userId)));
List<LoadedDocument> 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());
}
}

View File

@@ -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";
}

View File

@@ -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);
}
}

View File

@@ -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";
}
}
}

View File

@@ -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<Character> 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();
}
}

View File

@@ -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");
}
}

View File

@@ -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