portfolio view
This commit is contained in:
@@ -1,4 +1,3 @@
|
|||||||
|
|
||||||
stages:
|
stages:
|
||||||
- build
|
- build
|
||||||
- publish
|
- publish
|
||||||
@@ -131,6 +130,26 @@ build-auth-view:
|
|||||||
changes:
|
changes:
|
||||||
- auth-view/**/*
|
- auth-view/**/*
|
||||||
|
|
||||||
|
build-portfolio-view:
|
||||||
|
stage: build
|
||||||
|
image: node:22-alpine
|
||||||
|
cache:
|
||||||
|
key: "${CI_COMMIT_REF_SLUG}-portfolio-view"
|
||||||
|
paths:
|
||||||
|
- portfolio-view/node_modules
|
||||||
|
script:
|
||||||
|
- cd portfolio-view
|
||||||
|
- npm ci
|
||||||
|
- npm run build
|
||||||
|
artifacts:
|
||||||
|
paths:
|
||||||
|
- portfolio-view/dist
|
||||||
|
expire_in: 1h
|
||||||
|
rules:
|
||||||
|
- if: $CI_COMMIT_BRANCH == "main"
|
||||||
|
changes:
|
||||||
|
- portfolio-view/**/*
|
||||||
|
|
||||||
# ══════════════════════════════════════════════════════════
|
# ══════════════════════════════════════════════════════════
|
||||||
# PUBLISH DOCKER IMAGES
|
# PUBLISH DOCKER IMAGES
|
||||||
# ══════════════════════════════════════════════════════════
|
# ══════════════════════════════════════════════════════════
|
||||||
@@ -249,6 +268,25 @@ publish-auth-view:
|
|||||||
changes:
|
changes:
|
||||||
- auth-view/**/*
|
- auth-view/**/*
|
||||||
|
|
||||||
|
publish-portfolio-view:
|
||||||
|
stage: publish
|
||||||
|
image: docker:27
|
||||||
|
services:
|
||||||
|
- docker:27-dind
|
||||||
|
variables:
|
||||||
|
DOCKER_TLS_CERTDIR: ""
|
||||||
|
before_script:
|
||||||
|
- echo "$CI_REGISTRY_PASSWORD" | docker login $CI_REGISTRY -u $CI_REGISTRY_USER --password-stdin
|
||||||
|
script:
|
||||||
|
- docker build -t $REGISTRY/portfolio-view:${CI_COMMIT_SHORT_SHA} -t $REGISTRY/portfolio-view:latest -f portfolio-view/docker/Dockerfile portfolio-view/
|
||||||
|
- docker push $REGISTRY/portfolio-view:${CI_COMMIT_SHORT_SHA}
|
||||||
|
- docker push $REGISTRY/portfolio-view:latest
|
||||||
|
needs: [build-portfolio-view]
|
||||||
|
rules:
|
||||||
|
- if: $CI_COMMIT_BRANCH == "main"
|
||||||
|
changes:
|
||||||
|
- portfolio-view/**/*
|
||||||
|
|
||||||
# ══════════════════════════════════════════════════════════
|
# ══════════════════════════════════════════════════════════
|
||||||
# DEPLOY TO VPS
|
# DEPLOY TO VPS
|
||||||
# ══════════════════════════════════════════════════════════
|
# ══════════════════════════════════════════════════════════
|
||||||
@@ -281,6 +319,7 @@ deploy-rag:
|
|||||||
cd /opt/services
|
cd /opt/services
|
||||||
export CI_COMMIT_SHORT_SHA=${CI_COMMIT_SHORT_SHA}
|
export CI_COMMIT_SHORT_SHA=${CI_COMMIT_SHORT_SHA}
|
||||||
docker compose -f docker-compose.yml -f docker-compose.prod.yml pull rag-service
|
docker compose -f docker-compose.yml -f docker-compose.prod.yml pull rag-service
|
||||||
|
curl -s http://localhost:8500/v1/health/state/critical | jq -r '.[].ServiceID' | xargs -I{} curl -X PUT http://localhost:8500/v1/agent/service/deregister/{}
|
||||||
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d rag-service
|
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d rag-service
|
||||||
docker image prune -af
|
docker image prune -af
|
||||||
ENDSSH
|
ENDSSH
|
||||||
@@ -300,6 +339,7 @@ deploy-gateway:
|
|||||||
cd /opt/services
|
cd /opt/services
|
||||||
export CI_COMMIT_SHORT_SHA=${CI_COMMIT_SHORT_SHA}
|
export CI_COMMIT_SHORT_SHA=${CI_COMMIT_SHORT_SHA}
|
||||||
docker compose -f docker-compose.yml -f docker-compose.prod.yml pull gateway-service
|
docker compose -f docker-compose.yml -f docker-compose.prod.yml pull gateway-service
|
||||||
|
curl -s http://localhost:8500/v1/health/state/critical | jq -r '.[].ServiceID' | xargs -I{} curl -X PUT http://localhost:8500/v1/agent/service/deregister/{}
|
||||||
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d gateway-service
|
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d gateway-service
|
||||||
docker image prune -af
|
docker image prune -af
|
||||||
ENDSSH
|
ENDSSH
|
||||||
@@ -319,6 +359,7 @@ deploy-analytics:
|
|||||||
cd /opt/services
|
cd /opt/services
|
||||||
export CI_COMMIT_SHORT_SHA=${CI_COMMIT_SHORT_SHA}
|
export CI_COMMIT_SHORT_SHA=${CI_COMMIT_SHORT_SHA}
|
||||||
docker compose -f docker-compose.yml -f docker-compose.prod.yml pull analytics-service
|
docker compose -f docker-compose.yml -f docker-compose.prod.yml pull analytics-service
|
||||||
|
curl -s http://localhost:8500/v1/health/state/critical | jq -r '.[].ServiceID' | xargs -I{} curl -X PUT http://localhost:8500/v1/agent/service/deregister/{}
|
||||||
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d analytics-service
|
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d analytics-service
|
||||||
docker image prune -af
|
docker image prune -af
|
||||||
ENDSSH
|
ENDSSH
|
||||||
@@ -380,6 +421,25 @@ deploy-auth-view:
|
|||||||
docker image prune -af
|
docker image prune -af
|
||||||
ENDSSH
|
ENDSSH
|
||||||
|
|
||||||
|
deploy-portfolio-view:
|
||||||
|
<<: *deploy_setup
|
||||||
|
needs: [publish-portfolio-view]
|
||||||
|
rules:
|
||||||
|
- if: $CI_COMMIT_BRANCH == "main"
|
||||||
|
changes:
|
||||||
|
- portfolio-view/**/*
|
||||||
|
script:
|
||||||
|
- |
|
||||||
|
ssh $VPS_USER@$VPS_HOST << ENDSSH
|
||||||
|
set -e
|
||||||
|
echo "$CI_REGISTRY_PASSWORD" | docker login registry.gitlab.com -u "$CI_REGISTRY_USER" --password-stdin
|
||||||
|
cd /opt/services
|
||||||
|
export CI_COMMIT_SHORT_SHA=${CI_COMMIT_SHORT_SHA}
|
||||||
|
docker compose -f docker-compose.yml -f docker-compose.prod.yml pull portfolio-view
|
||||||
|
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d portfolio-view
|
||||||
|
docker image prune -af
|
||||||
|
ENDSSH
|
||||||
|
|
||||||
# Deploy all services at once (manual trigger)
|
# Deploy all services at once (manual trigger)
|
||||||
deploy-all:
|
deploy-all:
|
||||||
<<: *deploy_setup
|
<<: *deploy_setup
|
||||||
|
|||||||
@@ -43,6 +43,8 @@ spring:
|
|||||||
prefer-ip-address: true
|
prefer-ip-address: true
|
||||||
instance-id: ${spring.application.name}:${random.value}
|
instance-id: ${spring.application.name}:${random.value}
|
||||||
deregister-critical-service-after: 1m
|
deregister-critical-service-after: 1m
|
||||||
|
lifecycle:
|
||||||
|
enabled: true
|
||||||
|
|
||||||
analytics:
|
analytics:
|
||||||
kafka:
|
kafka:
|
||||||
|
|||||||
@@ -54,6 +54,8 @@ spring:
|
|||||||
prefer-ip-address: true
|
prefer-ip-address: true
|
||||||
instance-id: ${spring.application.name}:${random.value}
|
instance-id: ${spring.application.name}:${random.value}
|
||||||
deregister-critical-service-after: 1m
|
deregister-critical-service-after: 1m
|
||||||
|
lifecycle:
|
||||||
|
enabled: true
|
||||||
|
|
||||||
gateway:
|
gateway:
|
||||||
server:
|
server:
|
||||||
|
|||||||
25
portfolio-view/.gitignore
vendored
Normal file
25
portfolio-view/.gitignore
vendored
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
# Dependencies
|
||||||
|
node_modules/
|
||||||
|
.pnp
|
||||||
|
.pnp.js
|
||||||
|
|
||||||
|
# Build output
|
||||||
|
dist/
|
||||||
|
dist-ssr/
|
||||||
|
|
||||||
|
# Editor
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
|
||||||
|
# Environment
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
11
portfolio-view/docker/Dockerfile
Normal file
11
portfolio-view/docker/Dockerfile
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
FROM node:22-alpine AS build
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
RUN npm ci
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM nginx:alpine
|
||||||
|
COPY docker/nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
COPY --from=build /app/dist /usr/share/nginx/html
|
||||||
|
EXPOSE 80
|
||||||
9
portfolio-view/docker/nginx.conf
Normal file
9
portfolio-view/docker/nginx.conf
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
}
|
||||||
14
portfolio-view/index.html
Normal file
14
portfolio-view/index.html
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en" class="dark">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="description" content="Alexander — Java / Fullstack Developer. Switzerland, Bern. Spring Boot, React, AI." />
|
||||||
|
<title>Alexander — Java / Fullstack Developer</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
2490
portfolio-view/package-lock.json
generated
Normal file
2490
portfolio-view/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
24
portfolio-view/package.json
Normal file
24
portfolio-view/package.json
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"name": "portfolio-view",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"react": "^19.0.0",
|
||||||
|
"react-dom": "^19.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tailwindcss/vite": "^4.0.0",
|
||||||
|
"@types/react": "^19.0.0",
|
||||||
|
"@types/react-dom": "^19.0.0",
|
||||||
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
|
"tailwindcss": "^4.0.0",
|
||||||
|
"typescript": "~5.7.2",
|
||||||
|
"vite": "^6.0.5"
|
||||||
|
}
|
||||||
|
}
|
||||||
21
portfolio-view/src/App.tsx
Normal file
21
portfolio-view/src/App.tsx
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { Header } from './components/layout/Header'
|
||||||
|
import { Footer } from './components/layout/Footer'
|
||||||
|
import { HeroSection } from './components/hero/HeroSection'
|
||||||
|
import { ProjectsSection } from './components/projects/ProjectsSection'
|
||||||
|
import { ChatWidget } from './components/chat/ChatWidget'
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-zinc-950 text-zinc-50">
|
||||||
|
<Header />
|
||||||
|
<main>
|
||||||
|
<HeroSection />
|
||||||
|
<ProjectsSection />
|
||||||
|
</main>
|
||||||
|
<Footer />
|
||||||
|
<ChatWidget />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App
|
||||||
67
portfolio-view/src/components/chat/ChatInput.tsx
Normal file
67
portfolio-view/src/components/chat/ChatInput.tsx
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import { useState, useRef, type KeyboardEvent } from 'react'
|
||||||
|
|
||||||
|
interface ChatInputProps {
|
||||||
|
onSend: (message: string) => void
|
||||||
|
disabled: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChatInput({ onSend, disabled }: ChatInputProps) {
|
||||||
|
const [value, setValue] = useState('')
|
||||||
|
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||||
|
|
||||||
|
const handleSend = () => {
|
||||||
|
const trimmed = value.trim()
|
||||||
|
if (!trimmed || disabled) return
|
||||||
|
onSend(trimmed)
|
||||||
|
setValue('')
|
||||||
|
if (textareaRef.current) {
|
||||||
|
textareaRef.current.style.height = 'auto'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
|
e.preventDefault()
|
||||||
|
handleSend()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleInput = () => {
|
||||||
|
const el = textareaRef.current
|
||||||
|
if (!el) return
|
||||||
|
el.style.height = 'auto'
|
||||||
|
el.style.height = `${Math.min(el.scrollHeight, 120)}px`
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-end gap-2 p-3 border-t border-zinc-800 bg-zinc-950/50">
|
||||||
|
<textarea
|
||||||
|
ref={textareaRef}
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => setValue(e.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
onInput={handleInput}
|
||||||
|
disabled={disabled}
|
||||||
|
rows={1}
|
||||||
|
placeholder="Ask me about my experience, projects, or tech stack..."
|
||||||
|
className="flex-1 resize-none bg-zinc-800/60 border border-zinc-700 rounded-md px-3 py-2 text-sm text-zinc-100 placeholder-zinc-600 font-body focus:outline-none focus:border-accent/50 focus:ring-1 focus:ring-accent/20 transition-colors disabled:opacity-40 disabled:cursor-not-allowed min-h-[38px] max-h-[120px] leading-relaxed"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={handleSend}
|
||||||
|
disabled={disabled || !value.trim()}
|
||||||
|
aria-label="Send message"
|
||||||
|
className="shrink-0 w-9 h-9 flex items-center justify-center rounded-md bg-accent text-zinc-950 hover:bg-accent-hover transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
<svg width="15" height="15" viewBox="0 0 15 15" fill="none">
|
||||||
|
<path
|
||||||
|
d="M7.5 1v13M1 7.5l6.5-6.5 6.5 6.5"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
35
portfolio-view/src/components/chat/ChatMessage.tsx
Normal file
35
portfolio-view/src/components/chat/ChatMessage.tsx
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import type { ChatMessageType } from '../../types'
|
||||||
|
|
||||||
|
interface ChatMessageProps {
|
||||||
|
message: ChatMessageType
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChatMessage({ message }: ChatMessageProps) {
|
||||||
|
const isUser = message.role === 'user'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`flex ${isUser ? 'justify-end' : 'justify-start'} mb-3`}>
|
||||||
|
{!isUser && (
|
||||||
|
<div className="shrink-0 w-6 h-6 rounded-full bg-accent/20 border border-accent/30 flex items-center justify-center mr-2 mt-0.5">
|
||||||
|
<svg width="12" height="12" viewBox="0 0 12 12" fill="none">
|
||||||
|
<circle cx="6" cy="6" r="3" fill="#22d3ee" />
|
||||||
|
<circle cx="6" cy="6" r="5.5" stroke="#22d3ee" strokeOpacity="0.4" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={`max-w-[80%] px-3 py-2 rounded-lg text-sm leading-relaxed font-body ${
|
||||||
|
isUser
|
||||||
|
? 'bg-accent/15 text-zinc-100 border border-accent/20'
|
||||||
|
: 'bg-zinc-800 text-zinc-200 border border-zinc-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{message.content}
|
||||||
|
{message.isStreaming && (
|
||||||
|
<span className="inline-block w-1 h-3.5 bg-accent ml-0.5 align-text-bottom animate-pulse" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
213
portfolio-view/src/components/chat/ChatWidget.tsx
Normal file
213
portfolio-view/src/components/chat/ChatWidget.tsx
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
import { useState, useRef, useEffect, useCallback } from 'react'
|
||||||
|
import type { ChatMessageType } from '../../types'
|
||||||
|
import { useHrGuestAuth } from '../../hooks/useHrGuestAuth'
|
||||||
|
import { createChat, streamChatQuery } from '../../services/api'
|
||||||
|
import { ChatMessage } from './ChatMessage'
|
||||||
|
import { ChatInput } from './ChatInput'
|
||||||
|
|
||||||
|
type ChatStatus = 'idle' | 'creating' | 'ready' | 'error'
|
||||||
|
|
||||||
|
export function ChatWidget() {
|
||||||
|
const [isOpen, setIsOpen] = useState(false)
|
||||||
|
const [messages, setMessages] = useState<ChatMessageType[]>([])
|
||||||
|
const [chatId, setChatId] = useState<string | null>(null)
|
||||||
|
const [chatStatus, setChatStatus] = useState<ChatStatus>('idle')
|
||||||
|
const [isSending, setIsSending] = useState(false)
|
||||||
|
|
||||||
|
const { token, isLoading: tokenLoading, error: tokenError } = useHrGuestAuth()
|
||||||
|
const messagesEndRef = useRef<HTMLDivElement>(null)
|
||||||
|
const abortRef = useRef<(() => void) | null>(null)
|
||||||
|
|
||||||
|
// Auto-scroll to latest message
|
||||||
|
useEffect(() => {
|
||||||
|
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
|
||||||
|
}, [messages])
|
||||||
|
|
||||||
|
// Create chat session when widget opens and token is available
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen || !token || chatId || chatStatus !== 'idle') return
|
||||||
|
|
||||||
|
setChatStatus('creating')
|
||||||
|
createChat(token)
|
||||||
|
.then((id) => {
|
||||||
|
setChatId(id)
|
||||||
|
setChatStatus('ready')
|
||||||
|
})
|
||||||
|
.catch(() => setChatStatus('error'))
|
||||||
|
}, [isOpen, token, chatId, chatStatus])
|
||||||
|
|
||||||
|
const handleSend = useCallback(
|
||||||
|
(text: string) => {
|
||||||
|
if (!token || !chatId || isSending) return
|
||||||
|
|
||||||
|
const userMsg: ChatMessageType = {
|
||||||
|
id: `u-${Date.now()}`,
|
||||||
|
role: 'user',
|
||||||
|
content: text,
|
||||||
|
}
|
||||||
|
const assistantMsgId = `a-${Date.now()}`
|
||||||
|
const assistantMsg: ChatMessageType = {
|
||||||
|
id: assistantMsgId,
|
||||||
|
role: 'assistant',
|
||||||
|
content: '',
|
||||||
|
isStreaming: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
setMessages((prev) => [...prev, userMsg, assistantMsg])
|
||||||
|
setIsSending(true)
|
||||||
|
|
||||||
|
const abort = streamChatQuery(
|
||||||
|
chatId,
|
||||||
|
text,
|
||||||
|
token,
|
||||||
|
(chunk) => {
|
||||||
|
setMessages((prev) =>
|
||||||
|
prev.map((m) =>
|
||||||
|
m.id === assistantMsgId
|
||||||
|
? { ...m, content: m.content + chunk }
|
||||||
|
: m
|
||||||
|
)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
setMessages((prev) =>
|
||||||
|
prev.map((m) =>
|
||||||
|
m.id === assistantMsgId ? { ...m, isStreaming: false } : m
|
||||||
|
)
|
||||||
|
)
|
||||||
|
setIsSending(false)
|
||||||
|
abortRef.current = null
|
||||||
|
},
|
||||||
|
(err) => {
|
||||||
|
setMessages((prev) =>
|
||||||
|
prev.map((m) =>
|
||||||
|
m.id === assistantMsgId
|
||||||
|
? { ...m, content: `Error: ${err.message}`, isStreaming: false }
|
||||||
|
: m
|
||||||
|
)
|
||||||
|
)
|
||||||
|
setIsSending(false)
|
||||||
|
abortRef.current = null
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
abortRef.current = abort
|
||||||
|
},
|
||||||
|
[token, chatId, isSending]
|
||||||
|
)
|
||||||
|
|
||||||
|
// Clean up on unmount
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
abortRef.current?.()
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const isInputDisabled =
|
||||||
|
tokenLoading ||
|
||||||
|
!!tokenError ||
|
||||||
|
chatStatus !== 'ready' ||
|
||||||
|
isSending
|
||||||
|
|
||||||
|
const statusText = (() => {
|
||||||
|
if (tokenLoading) return 'Connecting...'
|
||||||
|
if (tokenError) return 'Connection failed'
|
||||||
|
if (chatStatus === 'creating') return 'Starting session...'
|
||||||
|
if (chatStatus === 'error') return 'Session error'
|
||||||
|
return null
|
||||||
|
})()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Floating toggle button */}
|
||||||
|
<button
|
||||||
|
onClick={() => setIsOpen((v) => !v)}
|
||||||
|
aria-label={isOpen ? 'Close chat' : 'Open chat'}
|
||||||
|
className="fixed bottom-6 right-6 z-50 w-14 h-14 rounded-full bg-accent text-zinc-950 shadow-lg shadow-accent/20 hover:bg-accent-hover transition-all duration-200 flex items-center justify-center glow-accent"
|
||||||
|
>
|
||||||
|
{isOpen ? (
|
||||||
|
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
|
||||||
|
<path
|
||||||
|
d="M4 4l12 12M16 4L4 16"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<svg width="22" height="22" viewBox="0 0 22 22" fill="none">
|
||||||
|
<path
|
||||||
|
d="M19 4H3a1 1 0 0 0-1 1v10a1 1 0 0 0 1 1h4l4 4 4-4h4a1 1 0 0 0 1-1V5a1 1 0 0 0-1-1z"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.8"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Chat panel */}
|
||||||
|
{isOpen && (
|
||||||
|
<div className="fixed bottom-24 right-6 z-50 w-80 sm:w-96 flex flex-col rounded-xl border border-zinc-800 bg-zinc-950 shadow-2xl shadow-black/60 overflow-hidden">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between px-4 py-3 border-b border-zinc-800 bg-zinc-900/80">
|
||||||
|
<div>
|
||||||
|
<p className="font-heading font-semibold text-sm text-white">
|
||||||
|
Ask about me
|
||||||
|
</p>
|
||||||
|
<p className="font-mono text-[10px] text-accent tracking-wider mt-0.5">
|
||||||
|
Powered by RAG
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span
|
||||||
|
className={`w-1.5 h-1.5 rounded-full ${
|
||||||
|
chatStatus === 'ready' ? 'bg-emerald-400' : 'bg-zinc-600 animate-pulse'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-zinc-500 font-mono">
|
||||||
|
{chatStatus === 'ready' ? 'online' : 'connecting'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Messages */}
|
||||||
|
<div className="flex-1 overflow-y-auto chat-scroll px-3 py-4 min-h-[280px] max-h-[400px]">
|
||||||
|
{messages.length === 0 ? (
|
||||||
|
<div className="h-full flex flex-col items-center justify-center gap-3 text-center px-4">
|
||||||
|
<div className="w-10 h-10 rounded-full bg-accent/10 border border-accent/20 flex items-center justify-center">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 18 18" fill="none">
|
||||||
|
<circle cx="9" cy="9" r="4" fill="#22d3ee" fillOpacity="0.6" />
|
||||||
|
<circle cx="9" cy="9" r="7.5" stroke="#22d3ee" strokeOpacity="0.25" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-zinc-500 font-body leading-relaxed">
|
||||||
|
Ask me about my experience,<br />
|
||||||
|
projects, or tech stack.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{messages.map((msg) => (
|
||||||
|
<ChatMessage key={msg.id} message={msg} />
|
||||||
|
))}
|
||||||
|
<div ref={messagesEndRef} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status banner */}
|
||||||
|
{statusText && (
|
||||||
|
<div className="px-4 py-1.5 bg-zinc-900/80 border-t border-zinc-800">
|
||||||
|
<p className="text-xs text-zinc-500 font-mono">{statusText}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Input */}
|
||||||
|
<ChatInput onSend={handleSend} disabled={isInputDisabled} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
113
portfolio-view/src/components/hero/HeroSection.tsx
Normal file
113
portfolio-view/src/components/hero/HeroSection.tsx
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
export function HeroSection() {
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
id="hero"
|
||||||
|
className="relative min-h-screen flex items-center justify-center overflow-hidden"
|
||||||
|
>
|
||||||
|
{/* Background grid */}
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 opacity-[0.03]"
|
||||||
|
style={{
|
||||||
|
backgroundImage:
|
||||||
|
'linear-gradient(#fafafa 1px, transparent 1px), linear-gradient(90deg, #fafafa 1px, transparent 1px)',
|
||||||
|
backgroundSize: '48px 48px',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Accent glow */}
|
||||||
|
<div className="absolute top-1/3 left-1/2 -translate-x-1/2 -translate-y-1/2 w-96 h-96 rounded-full bg-accent/5 blur-3xl pointer-events-none" />
|
||||||
|
|
||||||
|
<div className="relative z-10 max-w-4xl mx-auto px-6 py-24 text-center">
|
||||||
|
{/* Location badge */}
|
||||||
|
<div className="inline-flex items-center gap-2 px-3 py-1.5 mb-8 rounded-full border border-zinc-800 bg-zinc-900/60 text-xs text-zinc-400 font-mono tracking-wider">
|
||||||
|
<span className="w-1.5 h-1.5 rounded-full bg-accent animate-pulse" />
|
||||||
|
Switzerland, Bern
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Name */}
|
||||||
|
<h1 className="font-heading font-extrabold text-6xl md:text-8xl tracking-tight mb-4 leading-none">
|
||||||
|
<span className="text-white">Alexander</span>
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
{/* Role */}
|
||||||
|
<p className="font-heading font-medium text-2xl md:text-3xl text-gradient mb-6 tracking-tight">
|
||||||
|
Java / Fullstack Developer
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
<p className="text-zinc-400 text-lg max-w-xl mx-auto mb-10 font-body leading-relaxed">
|
||||||
|
Building backend systems and intelligent applications with{' '}
|
||||||
|
<span className="text-zinc-200 font-mono text-base">Spring Boot</span>,{' '}
|
||||||
|
<span className="text-zinc-200 font-mono text-base">React</span>, and{' '}
|
||||||
|
<span className="text-zinc-200 font-mono text-base">AI</span>.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* CTA buttons */}
|
||||||
|
<div className="flex flex-wrap items-center justify-center gap-4">
|
||||||
|
<a
|
||||||
|
href="/static/cv.pdf"
|
||||||
|
className="inline-flex items-center gap-2.5 px-6 py-3 bg-accent text-zinc-950 font-heading font-semibold text-sm tracking-wide hover:bg-accent-hover transition-colors duration-200 rounded-sm glow-accent"
|
||||||
|
>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||||
|
<path
|
||||||
|
d="M8 1v9M4 7l4 4 4-4M2 14h12"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
Download CV
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a
|
||||||
|
href="https://github.com/Mirage74/post-hub-platform"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
aria-label="GitHub repository"
|
||||||
|
className="inline-flex items-center gap-2.5 px-6 py-3 border border-zinc-700 text-zinc-300 font-heading font-medium text-sm tracking-wide hover:border-zinc-500 hover:text-white transition-all duration-200 rounded-sm"
|
||||||
|
>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0 1 12 6.844a9.59 9.59 0 0 1 2.504.337c1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.02 10.02 0 0 0 22 12.017C22 6.484 17.522 2 12 2z" />
|
||||||
|
</svg>
|
||||||
|
GitHub
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a
|
||||||
|
href="#"
|
||||||
|
aria-label="LinkedIn profile"
|
||||||
|
className="inline-flex items-center gap-2.5 px-6 py-3 border border-zinc-700 text-zinc-300 font-heading font-medium text-sm tracking-wide hover:border-zinc-500 hover:text-white transition-all duration-200 rounded-sm"
|
||||||
|
>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 20 20" fill="currentColor">
|
||||||
|
<path d="M16.7 1.7H3.3C2.4 1.7 1.7 2.4 1.7 3.3v13.4c0 .9.7 1.6 1.6 1.6h13.4c.9 0 1.6-.7 1.6-1.6V3.3c0-.9-.7-1.6-1.6-1.6zM6.7 15H4.4V7.8h2.3V15zm-1.2-8.2c-.7 0-1.3-.6-1.3-1.3S4.8 4.2 5.5 4.2s1.3.6 1.3 1.3-.6 1.3-1.3 1.3zm10 8.2h-2.3v-3.5c0-.8 0-1.9-1.2-1.9s-1.3.9-1.3 1.8V15H8.4V7.8h2.2v1h.1c.3-.6 1-1.2 2.1-1.2 2.3 0 2.7 1.5 2.7 3.4V15z" />
|
||||||
|
</svg>
|
||||||
|
LinkedIn
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Scroll hint */}
|
||||||
|
<div className="mt-16 flex flex-col items-center gap-2 text-zinc-600 text-xs font-mono tracking-widest">
|
||||||
|
<span>SCROLL</span>
|
||||||
|
<svg
|
||||||
|
width="14"
|
||||||
|
height="20"
|
||||||
|
viewBox="0 0 14 20"
|
||||||
|
fill="none"
|
||||||
|
className="animate-bounce"
|
||||||
|
>
|
||||||
|
<rect
|
||||||
|
x="1"
|
||||||
|
y="1"
|
||||||
|
width="12"
|
||||||
|
height="18"
|
||||||
|
rx="6"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
/>
|
||||||
|
<circle cx="7" cy="6" r="1.5" fill="currentColor" className="animate-pulse" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
36
portfolio-view/src/components/layout/Footer.tsx
Normal file
36
portfolio-view/src/components/layout/Footer.tsx
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
export function Footer() {
|
||||||
|
const year = new Date().getFullYear()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<footer id="footer" className="border-t border-zinc-800 bg-zinc-950 py-10">
|
||||||
|
<div className="max-w-6xl mx-auto px-6 flex flex-col md:flex-row items-center justify-between gap-4">
|
||||||
|
<p className="text-zinc-500 text-sm font-body">
|
||||||
|
© {year} Alexander — Java / Fullstack Developer
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-6">
|
||||||
|
<a
|
||||||
|
href="https://github.com/Mirage74/post-hub-platform"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
aria-label="GitHub"
|
||||||
|
className="text-zinc-500 hover:text-accent transition-colors"
|
||||||
|
>
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0 1 12 6.844a9.59 9.59 0 0 1 2.504.337c1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.02 10.02 0 0 0 22 12.017C22 6.484 17.522 2 12 2z" />
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="#"
|
||||||
|
aria-label="LinkedIn"
|
||||||
|
className="text-zinc-500 hover:text-accent transition-colors"
|
||||||
|
>
|
||||||
|
<svg width="20" height="20" viewBox="0 0 20 20" fill="currentColor">
|
||||||
|
<path d="M16.7 1.7H3.3C2.4 1.7 1.7 2.4 1.7 3.3v13.4c0 .9.7 1.6 1.6 1.6h13.4c.9 0 1.6-.7 1.6-1.6V3.3c0-.9-.7-1.6-1.6-1.6zM6.7 15H4.4V7.8h2.3V15zm-1.2-8.2c-.7 0-1.3-.6-1.3-1.3S4.8 4.2 5.5 4.2s1.3.6 1.3 1.3-.6 1.3-1.3 1.3zm10 8.2h-2.3v-3.5c0-.8 0-1.9-1.2-1.9s-1.3.9-1.3 1.8V15H8.4V7.8h2.2v1h.1c.3-.6 1-1.2 2.1-1.2 2.3 0 2.7 1.5 2.7 3.4V15z" />
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
)
|
||||||
|
}
|
||||||
64
portfolio-view/src/components/layout/Header.tsx
Normal file
64
portfolio-view/src/components/layout/Header.tsx
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
|
||||||
|
const navLinks = [
|
||||||
|
{ label: 'About', href: '#hero' },
|
||||||
|
{ label: 'Projects', href: '#projects' },
|
||||||
|
{ label: 'Contact', href: '#footer' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export function Header() {
|
||||||
|
const [scrolled, setScrolled] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onScroll = () => setScrolled(window.scrollY > 24)
|
||||||
|
window.addEventListener('scroll', onScroll, { passive: true })
|
||||||
|
return () => window.removeEventListener('scroll', onScroll)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header
|
||||||
|
className={`fixed top-0 left-0 right-0 z-50 transition-all duration-300 ${
|
||||||
|
scrolled
|
||||||
|
? 'bg-zinc-950/90 backdrop-blur-md border-b border-zinc-800'
|
||||||
|
: 'bg-transparent'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="max-w-6xl mx-auto px-6 h-16 flex items-center justify-between">
|
||||||
|
<a
|
||||||
|
href="#hero"
|
||||||
|
className="font-heading font-bold text-lg tracking-tight text-white hover:text-accent transition-colors"
|
||||||
|
>
|
||||||
|
Alexander<span className="text-accent">.</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<nav className="hidden md:flex items-center gap-8">
|
||||||
|
{navLinks.map((link) => (
|
||||||
|
<a
|
||||||
|
key={link.href}
|
||||||
|
href={link.href}
|
||||||
|
className="text-sm text-zinc-400 hover:text-white transition-colors font-body tracking-wide"
|
||||||
|
>
|
||||||
|
{link.label}
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<a
|
||||||
|
href="/static/cv.pdf"
|
||||||
|
className="hidden md:inline-flex items-center gap-2 px-4 py-2 text-sm font-medium border border-accent text-accent hover:bg-accent hover:text-zinc-950 transition-all duration-200 rounded-sm"
|
||||||
|
>
|
||||||
|
<span>Download CV</span>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
|
||||||
|
<path
|
||||||
|
d="M7 1v8M3.5 5.5L7 9l3.5-3.5M2 12h10"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
)
|
||||||
|
}
|
||||||
114
portfolio-view/src/components/projects/ProjectCard.tsx
Normal file
114
portfolio-view/src/components/projects/ProjectCard.tsx
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
import { useEffect, useRef } from 'react'
|
||||||
|
import type { Project } from '../../types'
|
||||||
|
|
||||||
|
interface ProjectCardProps {
|
||||||
|
project: Project
|
||||||
|
index: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const INFRA_ICON = (
|
||||||
|
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||||
|
<rect x="2" y="3" width="20" height="14" rx="2" />
|
||||||
|
<path d="M8 21h8M12 17v4" strokeLinecap="round" />
|
||||||
|
<path d="M7 8h10M7 11h6" strokeLinecap="round" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
|
||||||
|
const DEFAULT_ICON = (
|
||||||
|
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||||
|
<path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z" />
|
||||||
|
<polyline points="14 2 14 8 20 8" />
|
||||||
|
<line x1="16" y1="13" x2="8" y2="13" />
|
||||||
|
<line x1="16" y1="17" x2="8" y2="17" />
|
||||||
|
<line x1="10" y1="9" x2="8" y2="9" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
|
||||||
|
export function ProjectCard({ project, index }: ProjectCardProps) {
|
||||||
|
const ref = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const el = ref.current
|
||||||
|
if (!el) return
|
||||||
|
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
([entry]) => {
|
||||||
|
if (entry.isIntersecting) {
|
||||||
|
el.classList.add('visible')
|
||||||
|
observer.disconnect()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ threshold: 0.12 }
|
||||||
|
)
|
||||||
|
observer.observe(el)
|
||||||
|
return () => observer.disconnect()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const delayClass = `reveal-delay-${Math.min(index + 1, 5)}`
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={`reveal ${delayClass} group relative flex flex-col bg-zinc-900 border border-zinc-800 rounded-lg p-6 hover:border-zinc-700 hover:bg-zinc-900/80 transition-all duration-300`}
|
||||||
|
>
|
||||||
|
{/* Top: icon + title + demo link */}
|
||||||
|
<div className="flex items-start justify-between mb-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="text-accent/70 group-hover:text-accent transition-colors">
|
||||||
|
{project.isInfrastructure ? INFRA_ICON : DEFAULT_ICON}
|
||||||
|
</div>
|
||||||
|
<h3 className="font-heading font-semibold text-lg text-zinc-100 group-hover:text-white transition-colors">
|
||||||
|
{project.title}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{project.demoUrl && (
|
||||||
|
<a
|
||||||
|
href={project.demoUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
aria-label={`Open ${project.title} demo`}
|
||||||
|
className="ml-2 shrink-0 text-zinc-600 hover:text-accent transition-colors"
|
||||||
|
>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||||
|
<path
|
||||||
|
d="M6 2H2v12h12v-4M9 2h5v5M14 2L7 9"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
<p className="text-zinc-400 text-sm leading-relaxed mb-5 flex-1 font-body">
|
||||||
|
{project.description}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Stack tags */}
|
||||||
|
<div className="flex flex-wrap gap-1.5 mt-auto">
|
||||||
|
{project.stack.map((tech) => (
|
||||||
|
<span
|
||||||
|
key={tech}
|
||||||
|
className="font-mono text-xs px-2 py-0.5 rounded-sm bg-zinc-800 text-zinc-400 border border-zinc-700/60 group-hover:border-zinc-600/60 transition-colors"
|
||||||
|
>
|
||||||
|
{tech}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Demo badge */}
|
||||||
|
{project.demoUrl && (
|
||||||
|
<div className="absolute top-4 right-4">
|
||||||
|
<span className="inline-flex items-center gap-1 text-[10px] font-mono px-1.5 py-0.5 rounded-sm bg-accent/10 text-accent border border-accent/20">
|
||||||
|
<span className="w-1 h-1 rounded-full bg-accent" />
|
||||||
|
LIVE
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
73
portfolio-view/src/components/projects/ProjectsSection.tsx
Normal file
73
portfolio-view/src/components/projects/ProjectsSection.tsx
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import type { Project } from '../../types'
|
||||||
|
import { ProjectCard } from './ProjectCard'
|
||||||
|
|
||||||
|
const PROJECTS: Project[] = [
|
||||||
|
{
|
||||||
|
id: 'gateway',
|
||||||
|
title: 'Gateway Service',
|
||||||
|
description:
|
||||||
|
'API Gateway with JWT authentication, OAuth2 (Google, GitHub, Facebook), reactive routing, and per-user rate limiting built on Spring Cloud Gateway.',
|
||||||
|
stack: ['Java 25', 'Spring Boot 3.5', 'Spring Cloud Gateway', 'WebFlux', 'R2DBC', 'PostgreSQL', 'jjwt'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'rag',
|
||||||
|
title: 'RAG Service',
|
||||||
|
description:
|
||||||
|
'AI-powered chat with document context. Upload PDF/DOCX, vector semantic search, BM25 reranking, and real-time SSE streaming responses.',
|
||||||
|
stack: ['Java 25', 'Spring Boot 3.5', 'Spring AI', 'pgvector', 'Kafka', 'TikaDocumentReader'],
|
||||||
|
demoUrl: '/ragview/',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'analytics',
|
||||||
|
title: 'Analytics Service',
|
||||||
|
description:
|
||||||
|
'Kafka consumer that aggregates platform events into PostgreSQL with a dynamic REST API supporting complex filtering and pagination.',
|
||||||
|
stack: ['Java 25', 'Spring Boot 3.5', 'Kafka', 'JPA', 'Flyway', 'JpaSpecificationExecutor'],
|
||||||
|
demoUrl: '/analyticsview/',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'audio-foreign',
|
||||||
|
title: 'Audio Foreign',
|
||||||
|
description:
|
||||||
|
'Voice-based language learning app: speak — get instant AI feedback. Speech-to-text via Whisper, grammar correction via Claude, playback via ElevenLabs.',
|
||||||
|
stack: ['Node.js', 'Express', 'OpenAI Whisper', 'Anthropic Claude API', 'ElevenLabs TTS'],
|
||||||
|
demoUrl: '/deutsch/',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'infra',
|
||||||
|
title: 'Infrastructure',
|
||||||
|
description:
|
||||||
|
'Monorepo DevOps: Docker Compose orchestration, GitLab CI/CD with path-based triggers, Nginx reverse proxy, Let\'s Encrypt SSL, and Consul service discovery.',
|
||||||
|
stack: ['Docker', 'Docker Compose', 'GitLab CI/CD', 'Nginx', 'Let\'s Encrypt', 'Consul'],
|
||||||
|
isInfrastructure: true,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export function ProjectsSection() {
|
||||||
|
return (
|
||||||
|
<section id="projects" className="py-24 px-6">
|
||||||
|
<div className="max-w-6xl mx-auto">
|
||||||
|
{/* Section header */}
|
||||||
|
<div className="mb-14">
|
||||||
|
<p className="font-mono text-xs text-accent tracking-widest mb-3 uppercase">
|
||||||
|
Work
|
||||||
|
</p>
|
||||||
|
<h2 className="font-heading font-bold text-4xl md:text-5xl text-white tracking-tight">
|
||||||
|
Projects
|
||||||
|
</h2>
|
||||||
|
<p className="mt-4 text-zinc-400 max-w-xl font-body text-base leading-relaxed">
|
||||||
|
Microservices platform — each service independently deployable, all
|
||||||
|
connected through the gateway.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Grid */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-5">
|
||||||
|
{PROJECTS.map((project, index) => (
|
||||||
|
<ProjectCard key={project.id} project={project} index={index} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
57
portfolio-view/src/hooks/useHrGuestAuth.ts
Normal file
57
portfolio-view/src/hooks/useHrGuestAuth.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import { useState, useEffect, useRef } from 'react'
|
||||||
|
import { fetchHrGuestToken } from '../services/api'
|
||||||
|
|
||||||
|
const MAX_RETRIES = 3
|
||||||
|
const RETRY_DELAY_MS = 3000
|
||||||
|
|
||||||
|
interface HrGuestAuthResult {
|
||||||
|
token: string | null
|
||||||
|
isLoading: boolean
|
||||||
|
error: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useHrGuestAuth(): HrGuestAuthResult {
|
||||||
|
const [token, setToken] = useState<string | null>(null)
|
||||||
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const attemptsRef = useRef(0)
|
||||||
|
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
|
const mountedRef = useRef(true)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
mountedRef.current = true
|
||||||
|
|
||||||
|
const attempt = async () => {
|
||||||
|
if (!mountedRef.current) return
|
||||||
|
attemptsRef.current += 1
|
||||||
|
|
||||||
|
try {
|
||||||
|
const t = await fetchHrGuestToken()
|
||||||
|
if (!mountedRef.current) return
|
||||||
|
setToken(t)
|
||||||
|
setIsLoading(false)
|
||||||
|
setError(null)
|
||||||
|
} catch (err) {
|
||||||
|
if (!mountedRef.current) return
|
||||||
|
|
||||||
|
const message = err instanceof Error ? err.message : 'Unknown error'
|
||||||
|
|
||||||
|
if (attemptsRef.current < MAX_RETRIES) {
|
||||||
|
timeoutRef.current = setTimeout(attempt, RETRY_DELAY_MS)
|
||||||
|
} else {
|
||||||
|
setError(`Auth failed after ${MAX_RETRIES} attempts: ${message}`)
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
attempt()
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
mountedRef.current = false
|
||||||
|
if (timeoutRef.current) clearTimeout(timeoutRef.current)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return { token, isLoading, error }
|
||||||
|
}
|
||||||
118
portfolio-view/src/index.css
Normal file
118
portfolio-view/src/index.css
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
@import url('https://fonts.googleapis.com/css2?family=Syne:wght@400;500;600;700;800&family=DM+Sans:ital,opsz,wght@0,9..40,300;0,9..40,400;0,9..40,500;1,9..40,300&family=JetBrains+Mono:wght@400;500&display=swap');
|
||||||
|
|
||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
@theme {
|
||||||
|
--font-heading: "Syne", sans-serif;
|
||||||
|
--font-body: "DM Sans", sans-serif;
|
||||||
|
--font-mono: "JetBrains Mono", monospace;
|
||||||
|
|
||||||
|
--color-accent: #22d3ee;
|
||||||
|
--color-accent-hover: #67e8f9;
|
||||||
|
--color-surface: #18181b;
|
||||||
|
--color-surface-2: #27272a;
|
||||||
|
--color-border: #3f3f46;
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
html {
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background-color: #09090b;
|
||||||
|
color: #fafafa;
|
||||||
|
font-family: var(--font-body);
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1, h2, h3, h4, h5, h6 {
|
||||||
|
font-family: var(--font-heading);
|
||||||
|
}
|
||||||
|
|
||||||
|
code, pre, .mono {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
border-color: #3f3f46;
|
||||||
|
}
|
||||||
|
|
||||||
|
::selection {
|
||||||
|
background-color: rgba(34, 211, 238, 0.25);
|
||||||
|
color: #fafafa;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: #09090b;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: #3f3f46;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: #52525b;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Scroll-triggered animation */
|
||||||
|
@keyframes fadeInUp {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(28px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.reveal {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(28px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reveal.visible {
|
||||||
|
animation: fadeInUp 0.55s ease-out forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Stagger delays for project cards */
|
||||||
|
.reveal-delay-1 { animation-delay: 0.05s; }
|
||||||
|
.reveal-delay-2 { animation-delay: 0.12s; }
|
||||||
|
.reveal-delay-3 { animation-delay: 0.19s; }
|
||||||
|
.reveal-delay-4 { animation-delay: 0.26s; }
|
||||||
|
.reveal-delay-5 { animation-delay: 0.33s; }
|
||||||
|
|
||||||
|
/* Gradient text utility */
|
||||||
|
.text-gradient {
|
||||||
|
background: linear-gradient(135deg, #22d3ee 0%, #67e8f9 50%, #a5f3fc 100%);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
background-clip: text;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Accent glow effect */
|
||||||
|
.glow-accent {
|
||||||
|
box-shadow: 0 0 24px rgba(34, 211, 238, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Chat scrollbar */
|
||||||
|
.chat-scroll::-webkit-scrollbar {
|
||||||
|
width: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-scroll::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-scroll::-webkit-scrollbar-thumb {
|
||||||
|
background: #3f3f46;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
13
portfolio-view/src/main.tsx
Normal file
13
portfolio-view/src/main.tsx
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { StrictMode } from 'react'
|
||||||
|
import { createRoot } from 'react-dom/client'
|
||||||
|
import './index.css'
|
||||||
|
import App from './App'
|
||||||
|
|
||||||
|
const root = document.getElementById('root')
|
||||||
|
if (!root) throw new Error('Root element not found')
|
||||||
|
|
||||||
|
createRoot(root).render(
|
||||||
|
<StrictMode>
|
||||||
|
<App />
|
||||||
|
</StrictMode>
|
||||||
|
)
|
||||||
109
portfolio-view/src/services/api.ts
Normal file
109
portfolio-view/src/services/api.ts
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
const BASE_URL = ''
|
||||||
|
|
||||||
|
export async function fetchHrGuestToken(): Promise<string> {
|
||||||
|
const response = await fetch(`${BASE_URL}/api/auth/hr-guest-token`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
})
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Token request failed: ${response.status}`)
|
||||||
|
}
|
||||||
|
const data: { token: string } = await response.json()
|
||||||
|
return data.token
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createChat(token: string): Promise<string> {
|
||||||
|
const response = await fetch(`${BASE_URL}/api/rag/chats`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Chat creation failed: ${response.status}`)
|
||||||
|
}
|
||||||
|
const data: { id: string } = await response.json()
|
||||||
|
return data.id
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends a chat query and streams the SSE response token by token.
|
||||||
|
* Returns a cleanup function that aborts the request.
|
||||||
|
*/
|
||||||
|
export function streamChatQuery(
|
||||||
|
chatId: string,
|
||||||
|
message: string,
|
||||||
|
token: string,
|
||||||
|
onChunk: (chunk: string) => void,
|
||||||
|
onDone: () => void,
|
||||||
|
onError: (error: Error) => void
|
||||||
|
): () => void {
|
||||||
|
const controller = new AbortController()
|
||||||
|
|
||||||
|
fetch(`${BASE_URL}/api/rag/chats/${chatId}/query`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
Accept: 'text/event-stream',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ message }),
|
||||||
|
signal: controller.signal,
|
||||||
|
})
|
||||||
|
.then(async (response) => {
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Query failed: ${response.status}`)
|
||||||
|
}
|
||||||
|
if (!response.body) {
|
||||||
|
throw new Error('Empty response body')
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = response.body.getReader()
|
||||||
|
const decoder = new TextDecoder()
|
||||||
|
let buffer = ''
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read()
|
||||||
|
if (done) {
|
||||||
|
onDone()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
buffer += decoder.decode(value, { stream: true })
|
||||||
|
const lines = buffer.split('\n')
|
||||||
|
// Keep the last (possibly incomplete) line in the buffer
|
||||||
|
buffer = lines.pop() ?? ''
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
if (!line.startsWith('data:')) continue
|
||||||
|
const data = line.slice(5).trim()
|
||||||
|
if (data === '[DONE]' || data === '') continue
|
||||||
|
|
||||||
|
// Handle both plain text and JSON payloads: {"content": "..."}
|
||||||
|
try {
|
||||||
|
const parsed: unknown = JSON.parse(data)
|
||||||
|
if (
|
||||||
|
parsed !== null &&
|
||||||
|
typeof parsed === 'object' &&
|
||||||
|
'content' in parsed &&
|
||||||
|
typeof (parsed as Record<string, unknown>).content === 'string'
|
||||||
|
) {
|
||||||
|
onChunk((parsed as { content: string }).content)
|
||||||
|
} else {
|
||||||
|
onChunk(data)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
onChunk(data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((error: unknown) => {
|
||||||
|
if (error instanceof Error && error.name !== 'AbortError') {
|
||||||
|
onError(error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => controller.abort()
|
||||||
|
}
|
||||||
23
portfolio-view/src/types/index.ts
Normal file
23
portfolio-view/src/types/index.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
export interface Project {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
stack: string[]
|
||||||
|
demoUrl?: string
|
||||||
|
isInfrastructure?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChatMessageType {
|
||||||
|
id: string
|
||||||
|
role: 'user' | 'assistant'
|
||||||
|
content: string
|
||||||
|
isStreaming?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HrGuestTokenResponse {
|
||||||
|
token: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChatCreateResponse {
|
||||||
|
id: string
|
||||||
|
}
|
||||||
21
portfolio-view/tailwind.config.ts
Normal file
21
portfolio-view/tailwind.config.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
// Tailwind CSS v4 uses CSS-based configuration via @theme in index.css.
|
||||||
|
// This file is a reference for theme tokens used in the project.
|
||||||
|
// Actual configuration lives in src/index.css inside the @theme block.
|
||||||
|
|
||||||
|
export const theme = {
|
||||||
|
fonts: {
|
||||||
|
heading: '"Syne", sans-serif',
|
||||||
|
body: '"DM Sans", sans-serif',
|
||||||
|
mono: '"JetBrains Mono", monospace',
|
||||||
|
},
|
||||||
|
colors: {
|
||||||
|
accent: '#22d3ee', // cyan-400
|
||||||
|
accentHover: '#67e8f9', // cyan-300
|
||||||
|
bg: '#09090b', // zinc-950
|
||||||
|
surface: '#18181b', // zinc-900
|
||||||
|
surface2: '#27272a', // zinc-800
|
||||||
|
border: '#3f3f46', // zinc-700
|
||||||
|
textPrimary: '#fafafa', // zinc-50
|
||||||
|
textMuted: '#a1a1aa', // zinc-400
|
||||||
|
},
|
||||||
|
} as const
|
||||||
20
portfolio-view/tsconfig.json
Normal file
20
portfolio-view/tsconfig.json
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true
|
||||||
|
},
|
||||||
|
"include": ["src", "vite.config.ts"]
|
||||||
|
}
|
||||||
1
portfolio-view/tsconfig.tsbuildinfo
Normal file
1
portfolio-view/tsconfig.tsbuildinfo
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"root":["./src/app.tsx","./src/main.tsx","./src/components/chat/chatinput.tsx","./src/components/chat/chatmessage.tsx","./src/components/chat/chatwidget.tsx","./src/components/hero/herosection.tsx","./src/components/layout/footer.tsx","./src/components/layout/header.tsx","./src/components/projects/projectcard.tsx","./src/components/projects/projectssection.tsx","./src/hooks/usehrguestauth.ts","./src/services/api.ts","./src/types/index.ts","./vite.config.ts"],"version":"5.7.3"}
|
||||||
19
portfolio-view/vite.config.ts
Normal file
19
portfolio-view/vite.config.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
import tailwindcss from '@tailwindcss/vite'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
base: '/',
|
||||||
|
plugins: [
|
||||||
|
react(),
|
||||||
|
tailwindcss(),
|
||||||
|
],
|
||||||
|
server: {
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://localhost:8080',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
43
rag-service/.gitignore
vendored
43
rag-service/.gitignore
vendored
@@ -1,43 +0,0 @@
|
|||||||
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/
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
# 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
295
rag-service/mvnw
vendored
@@ -1,295 +0,0 @@
|
|||||||
#!/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
189
rag-service/mvnw.cmd
vendored
@@ -1,189 +0,0 @@
|
|||||||
<# : 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"
|
|
||||||
@@ -1,204 +0,0 @@
|
|||||||
<?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>
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -1,121 +0,0 @@
|
|||||||
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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -1,160 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
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
|
|
||||||
) {}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
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
|
|
||||||
) {}
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,105 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
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"
|
|
||||||
}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
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;
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
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;
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
package com.balex.rag.model.dto;
|
|
||||||
|
|
||||||
public record UserEntryRequest(
|
|
||||||
String content,
|
|
||||||
Boolean onlyContext,
|
|
||||||
Double topP
|
|
||||||
) {}
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
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;
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
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;
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
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;
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
package com.balex.rag.model.enums;
|
|
||||||
|
|
||||||
public enum RegistrationStatus {
|
|
||||||
ACTIVE,
|
|
||||||
INACTIVE
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
package com.balex.rag.model.exception;
|
|
||||||
|
|
||||||
public class DataExistException extends RuntimeException {
|
|
||||||
|
|
||||||
public DataExistException(String message) {
|
|
||||||
super(message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
package com.balex.rag.model.exception;
|
|
||||||
|
|
||||||
public class InvalidDataException extends RuntimeException {
|
|
||||||
|
|
||||||
public InvalidDataException(String message) {
|
|
||||||
super(message);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
package com.balex.rag.model.exception;
|
|
||||||
|
|
||||||
public class InvalidPasswordException extends RuntimeException {
|
|
||||||
|
|
||||||
public InvalidPasswordException(String message) {
|
|
||||||
super(message);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
package com.balex.rag.model.exception;
|
|
||||||
|
|
||||||
public class NotFoundException extends RuntimeException {
|
|
||||||
|
|
||||||
public NotFoundException(String message) {
|
|
||||||
super(message);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
package com.balex.rag.model.exception;
|
|
||||||
|
|
||||||
public class RerankException extends RuntimeException {
|
|
||||||
|
|
||||||
public RerankException(String message) {
|
|
||||||
super(message);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
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;
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
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;
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
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);
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
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);
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
//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();
|
|
||||||
// }
|
|
||||||
//}
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,256 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -1,157 +0,0 @@
|
|||||||
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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
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";
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user