diff --git a/.github/workflows/build_and_deploy_docker_VM.yml b/.github/workflows/build.yml
similarity index 54%
rename from .github/workflows/build_and_deploy_docker_VM.yml
rename to .github/workflows/build.yml
index d15c058..866b485 100644
--- a/.github/workflows/build_and_deploy_docker_VM.yml
+++ b/.github/workflows/build.yml
@@ -1,10 +1,15 @@
-name: Build and Deploy Docker Images (on VM)
+name: Build Docker Images
+
+# Triggered on every push to main.
+# Builds all service images and pushes them to GitHub Container Registry (GHCR).
+# The deploy workflow listens for this workflow's completion before deploying.
on:
push:
branches:
- - main
-
+ - main
+
+# Read access to repo contents; write access to push images to GHCR.
permissions:
contents: read
packages: write
@@ -16,6 +21,8 @@ jobs:
- name: Checkout Code
uses: actions/checkout@v4
+ # Authenticate with GHCR so we can push images.
+ # GITHUB_TOKEN is automatically provided by GitHub Actions.
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
@@ -23,12 +30,17 @@ jobs:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
+ # QEMU enables building images for non-native architectures.
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
+ # Buildx is required for advanced build features including GHA layer caching.
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
+ # Each service uses a separate cache scope so they don't overwrite each other.
+ # cache-from: restores cached layers from the previous build.
+ # cache-to mode=max: saves all intermediate layers, not just the final image.
- name: Build and Push api-gateway
uses: docker/build-push-action@v5
with:
@@ -36,6 +48,8 @@ jobs:
file: ./server/api-gateway/Dockerfile
push: true
tags: ghcr.io/aet-devops26/team-devvopps/api-gateway:latest
+ cache-from: type=gha,scope=api-gateway
+ cache-to: type=gha,scope=api-gateway,mode=max
- name: Build and Push user-service
uses: docker/build-push-action@v5
@@ -44,6 +58,8 @@ jobs:
file: ./server/user-service/Dockerfile
push: true
tags: ghcr.io/aet-devops26/team-devvopps/user-service:latest
+ cache-from: type=gha,scope=user-service
+ cache-to: type=gha,scope=user-service,mode=max
- name: Build and Push course-service
uses: docker/build-push-action@v5
@@ -52,6 +68,8 @@ jobs:
file: ./server/course-service/Dockerfile
push: true
tags: ghcr.io/aet-devops26/team-devvopps/course-service:latest
+ cache-from: type=gha,scope=course-service
+ cache-to: type=gha,scope=course-service,mode=max
- name: Build and Push roadmap-service
uses: docker/build-push-action@v5
@@ -60,6 +78,8 @@ jobs:
file: ./server/roadmap-service/Dockerfile
push: true
tags: ghcr.io/aet-devops26/team-devvopps/roadmap-service:latest
+ cache-from: type=gha,scope=roadmap-service
+ cache-to: type=gha,scope=roadmap-service,mode=max
- name: Build and Push client
uses: docker/build-push-action@v5
@@ -68,6 +88,18 @@ jobs:
file: ./client/Dockerfile
push: true
tags: ghcr.io/aet-devops26/team-devvopps/client:latest
+ cache-from: type=gha,scope=client
+ cache-to: type=gha,scope=client,mode=max
+
+ - name: Build and Push llm-service
+ uses: docker/build-push-action@v5
+ with:
+ context: ./server/llm-service
+ file: ./server/llm-service/Dockerfile
+ push: true
+ tags: ghcr.io/aet-devops26/team-devvopps/llm-service:latest
+ cache-from: type=gha,scope=llm-service
+ cache-to: type=gha,scope=llm-service,mode=max
- name: Build and Push course-seeder
uses: docker/build-push-action@v5
@@ -76,44 +108,5 @@ jobs:
file: ./server/course-service/Dockerfile.seeder
push: true
tags: ghcr.io/aet-devops26/team-devvopps/course-seeder:latest
-
- deploy:
- needs: build
- runs-on: ubuntu-latest
-
- steps:
- - name: Checkout Code
- uses: actions/checkout@v4
-
- - name: Copy Files to VM
- uses: appleboy/scp-action@v0.1.7
- with:
- host: ${{ vars.AZURE_PUBLIC_IP }}
- username: ${{ vars.AZURE_USER }}
- key: ${{ secrets.AZURE_PRIVATE_KEY }}
- source: "compose.azure.yml,server/init-databases.sql"
- target: /home/${{ vars.AZURE_USER }}
-
- - name: SSH to VM and Create .env.prod
- uses: appleboy/ssh-action@v1.0.3
- with:
- host: ${{ vars.AZURE_PUBLIC_IP }}
- username: ${{ vars.AZURE_USER }}
- key: ${{ secrets.AZURE_PRIVATE_KEY }}
- script: |
- rm -f .env.prod
- touch .env.prod
- echo "CLIENT_HOST=client.${{ vars.AZURE_PUBLIC_IP }}.nip.io" >> .env.prod
- echo "SERVER_HOST=api.${{ vars.AZURE_PUBLIC_IP }}.nip.io" >> .env.prod
- echo "PUBLIC_API_URL=https://api.${{ vars.AZURE_PUBLIC_IP }}.nip.io" >> .env.prod
-
- - name: SSH to VM and Execute Docker-Compose Up
- uses: appleboy/ssh-action@v1.0.3
- with:
- host: ${{ vars.AZURE_PUBLIC_IP }}
- username: ${{ vars.AZURE_USER }}
- key: ${{ secrets.AZURE_PRIVATE_KEY }}
- command_timeout: 5m
- script: |
- echo ${{ secrets.GITHUB_TOKEN }} | docker login ghcr.io -u ${{ github.actor }} --password-stdin
- docker compose -f compose.azure.yml --env-file=.env.prod up --pull=always -d
+ cache-from: type=gha,scope=course-seeder
+ cache-to: type=gha,scope=course-seeder,mode=max
\ No newline at end of file
diff --git a/.github/workflows/deploy-k8s.yml b/.github/workflows/deploy-k8s.yml
index 5204fd0..a81677a 100644
--- a/.github/workflows/deploy-k8s.yml
+++ b/.github/workflows/deploy-k8s.yml
@@ -1,5 +1,8 @@
name: Deploy to AET Kubernetes Cluster
+# Can be triggered manually via workflow_dispatch, or automatically on every push to main.
+# concurrency ensures only one deploy runs at a time per branch
+# if a new push comes in while deploying, the in-progress deploy is cancelled.
on:
push:
branches:
@@ -26,6 +29,8 @@ jobs:
with:
version: 'latest'
+ # Write the kubeconfig from secrets so kubectl and Helm can reach the cluster.
+ # Fails early with a clear message if the secret is missing.
- name: Configure kubectl
env:
KUBECONFIG_CONTENT: ${{ secrets.KUBECONFIG }}
@@ -42,6 +47,8 @@ jobs:
echo "kubectl configured successfully"
+ # Validate the Helm chart before attempting a deploy.
+ # Catches templating errors and missing required values early.
- name: Lint Helm chart
env:
POSTGRES_USER: ${{ secrets.POSTGRES_USER || 'postgres' }}
@@ -51,6 +58,7 @@ jobs:
--set postgres.credentials.username="$POSTGRES_USER" \
--set postgres.credentials.password="$POSTGRES_PASSWORD"
+ # Remove pods stuck in Failed/Unknown state that would block the Helm rollout.
- name: Clean up failed deployments
run: |
echo "Cleaning up failed and unknown pods..."
@@ -58,19 +66,27 @@ jobs:
kubectl delete pods -n "$K8S_NAMESPACE" --field-selector=status.phase=Unknown 2>/dev/null || true
echo "Cleanup complete"
+ # helm upgrade --install: creates the release if it doesn't exist, upgrades if it does.
+ # Sensitive values (DB credentials, API keys) are passed at deploy time, not stored in values files.
+ # --wait blocks until all pods are ready or the timeout is hit.
- name: Deploy with Helm
env:
POSTGRES_USER: ${{ secrets.POSTGRES_USER || 'postgres' }}
POSTGRES_PASSWORD: ${{ secrets.POSTGRES_PASSWORD || 'postgres' }}
+ GROQ_API_KEY: ${{ secrets.GROQ_API_KEY }}
+ LOGOS_API_KEY: ${{ secrets.LOGOS_API_KEY }}
run: |
helm upgrade --install "$HELM_RELEASE_NAME" helm/team-devvopps/ \
-f helm/team-devvopps/values-aet.yaml \
--set postgres.credentials.username="$POSTGRES_USER" \
--set postgres.credentials.password="$POSTGRES_PASSWORD" \
+ --set llmService.groqApiKey="$GROQ_API_KEY" \
+ --set llmService.logosApiKey="$LOGOS_API_KEY" \
-n "$K8S_NAMESPACE" \
--wait \
--timeout 5m
+ # Quick sanity check — prints pod/service/ingress state immediately after deploy.
- name: Verify deployment
run: |
echo "Checking pod status..."
@@ -82,6 +98,8 @@ jobs:
echo "Release status:"
helm status "$HELM_RELEASE_NAME" -n "$K8S_NAMESPACE"
+ # Exclude the course-seeder job from readiness check (it is a one-off job
+ # that runs to completion and exits, so it will never reach Ready state).
- name: Wait for pods to be ready
run: |
echo "Waiting for deployment pods to be ready..."
diff --git a/.github/workflows/deploy-vm.yml b/.github/workflows/deploy-vm.yml
new file mode 100644
index 0000000..c6fabfd
--- /dev/null
+++ b/.github/workflows/deploy-vm.yml
@@ -0,0 +1,63 @@
+name: Deploy Docker Images (on VM)
+
+# Runs automatically after the Build workflow completes successfully on main.
+# Copies the compose file to the Azure VM and restarts all services via Docker Compose.
+on:
+ workflow_run:
+ workflows:
+ - Build Docker Images
+ types:
+ - completed
+ branches:
+ - main
+
+
+permissions:
+ contents: read
+ packages: write
+
+jobs:
+ deploy:
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout Code
+ uses: actions/checkout@v4
+
+ # Copy the Docker Compose file and database init script to the VM.
+ # These are the only files needed on the VM; images are pulled from GHCR.
+ - name: Copy Files to VM
+ uses: appleboy/scp-action@v0.1.7
+ with:
+ host: ${{ vars.AZURE_PUBLIC_IP }}
+ username: ${{ vars.AZURE_USER }}
+ key: ${{ secrets.AZURE_PRIVATE_KEY }}
+ source: "compose.azure.yml,server/init-databases.sql"
+ target: /home/${{ vars.AZURE_USER }}
+
+ # Write a .env.prod file on the VM with hostnames derived from the public IP.
+ # nip.io provides free wildcard DNS so we don't need a custom domain.
+ - name: SSH to VM and Create .env.prod
+ uses: appleboy/ssh-action@v1.0.3
+ with:
+ host: ${{ vars.AZURE_PUBLIC_IP }}
+ username: ${{ vars.AZURE_USER }}
+ key: ${{ secrets.AZURE_PRIVATE_KEY }}
+ script: |
+ rm -f .env.prod
+ touch .env.prod
+ echo "CLIENT_HOST=client.${{ vars.AZURE_PUBLIC_IP }}.nip.io" >> .env.prod
+ echo "SERVER_HOST=api.${{ vars.AZURE_PUBLIC_IP }}.nip.io" >> .env.prod
+ echo "PUBLIC_API_URL=https://api.${{ vars.AZURE_PUBLIC_IP }}.nip.io" >> .env.prod
+
+ # Pull the latest images from GHCR and restart all services.
+ - name: SSH to VM and Execute Docker-Compose Up
+ uses: appleboy/ssh-action@v1.0.3
+ with:
+ host: ${{ vars.AZURE_PUBLIC_IP }}
+ username: ${{ vars.AZURE_USER }}
+ key: ${{ secrets.AZURE_PRIVATE_KEY }}
+ command_timeout: 5m
+ script: |
+ echo ${{ secrets.GITHUB_TOKEN }} | docker login ghcr.io -u ${{ github.actor }} --password-stdin
+ docker compose -f compose.azure.yml --env-file=.env.prod up --pull=always -d
diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml
index d2ee763..c3c8248 100644
--- a/.github/workflows/lint.yml
+++ b/.github/workflows/lint.yml
@@ -1,5 +1,7 @@
name: Lint Code
+# Runs on PRs to main and on any branch push except main.
+# Acts as a quality gate before code is merged.
on:
pull_request:
branches:
@@ -9,6 +11,7 @@ on:
- main
jobs:
+ # Lints the React frontend using ESLint.
lint-client:
name: Lint React Client (ESLint)
runs-on: ubuntu-latest
@@ -30,6 +33,8 @@ jobs:
run: npm run lint
working-directory: client
+ # Lints all Java services using SpotBugs via Gradle.
+ # -x test skips unit tests; --scan uploads a build scan for inspection.
lint-java:
name: Lint Java Services (SpotBugs)
runs-on: ubuntu-latest
@@ -48,6 +53,8 @@ jobs:
run: ./gradlew check -x test --scan
working-directory: server
+ # Validates GitHub Actions workflow files themselves for correctness.
+ # Catches common mistakes like invalid expressions or missing required fields.
lint-actions:
name: Lint GitHub Actions Workflows
runs-on: ubuntu-latest
@@ -59,6 +66,8 @@ jobs:
- name: Run actionlint
uses: devops-actions/actionlint@v0.1.12
+ # Validates the Helm chart template rendering and value schema.
+ # Uses dummy credentials so required secret values don't block CI.
lint-helm:
name: Lint Helm Chart
runs-on: ubuntu-latest
diff --git a/.github/workflows/provision.yml b/.github/workflows/provision.yml
index 9e1eb38..d77bccd 100644
--- a/.github/workflows/provision.yml
+++ b/.github/workflows/provision.yml
@@ -1,7 +1,9 @@
name: Provision Azure VM
+# Manual trigger only.
+# Run this once to set up the VM, or again to apply Terraform changes.
on:
- workflow_dispatch: # manual trigger only
+ workflow_dispatch:
jobs:
provision:
@@ -16,6 +18,8 @@ jobs:
with:
terraform_wrapper: false
+ # Initialise Terraform with the Azure backend.
+ # State is stored remotely in Azure Blob Storage so the team shares the same state.
- name: Terraform Init
working-directory: ./terraform
env:
@@ -25,6 +29,8 @@ jobs:
ARM_TENANT_ID: ${{ secrets.ARM_TENANT_ID }}
run: terraform init
+ # Import pre-existing Azure resources into Terraform state so they are managed
+ # without being recreated. The || true prevents failure if already imported.
- name: Terraform Import Existing Resources
working-directory: ./terraform
env:
@@ -38,6 +44,8 @@ jobs:
terraform import -var="ssh_public_key=${{ secrets.AZURE_SSH_PUBLIC_KEY }}" azurerm_network_security_group.main /subscriptions/${{ secrets.ARM_SUBSCRIPTION_ID }}/resourceGroups/team-devvopps/providers/Microsoft.Network/networkSecurityGroups/team-devvopps-nsg || true
terraform import -var="ssh_public_key=${{ secrets.AZURE_SSH_PUBLIC_KEY }}" azurerm_subnet.main /subscriptions/${{ secrets.ARM_SUBSCRIPTION_ID }}/resourceGroups/team-devvopps/providers/Microsoft.Network/virtualNetworks/team-devvopps-vnet/subnets/internal || true
+ # Apply the Terraform plan without interactive confirmation (-auto-approve).
+ # Creates or updates the VM, networking, and NSG rules.
- name: Terraform Apply
working-directory: ./terraform
env:
@@ -47,6 +55,7 @@ jobs:
ARM_TENANT_ID: ${{ secrets.ARM_TENANT_ID }}
run: terraform apply -auto-approve -var="ssh_public_key=${{ secrets.AZURE_SSH_PUBLIC_KEY }}"
+ # Capture the VM's public IP from Terraform output for use in later steps.
- name: Get VM IP
working-directory: ./terraform
env:
@@ -60,14 +69,19 @@ jobs:
- name: Install Ansible
run: pip install ansible
+ # Write the SSH private key to a temp file so Ansible can connect to the VM.
+ # chmod 400 prevents SSH from rejecting the key due to loose permissions.
- name: Write SSH private key
run: |
echo "${{ secrets.AZURE_PRIVATE_KEY }}" > /tmp/azure_key.pem
chmod 400 /tmp/azure_key.pem
+ # Give the VM time to finish booting before Ansible tries to connect.
- name: Wait for VM to be ready
run: sleep 30
+ # Run the Ansible playbook to install Docker and configure the VM.
+ # StrictHostKeyChecking=no skips the host fingerprint prompt for new VMs.
- name: Run Ansible Playbook
run: |
ansible-playbook -i "${{ env.VM_IP }}," \
@@ -76,6 +90,8 @@ jobs:
--ssh-extra-args="-o StrictHostKeyChecking=no" \
ansible/playbook.yml
+ # Update the AZURE_PUBLIC_IP Actions variable so the deploy workflow
+ # always has the correct IP, even after VM reprovisioning.
- name: Update GitHub Variable AZURE_PUBLIC_IP
run: |
curl -X PATCH \
diff --git a/ansible/playbook.yml b/ansible/playbook.yml
index cf00f6d..207424a 100644
--- a/ansible/playbook.yml
+++ b/ansible/playbook.yml
@@ -1,8 +1,9 @@
- name: Configure Azure VM
hosts: all
- become: true
+ become: true # Run all tasks as root (sudo)
tasks:
+ # Install prerequisites needed to add the Docker apt repository.
- name: Install required packages
apt:
name:
@@ -11,18 +12,21 @@
state: present
update_cache: true
+ # Docker's GPG key will be stored here to verify package authenticity.
- name: Create Docker keyring directory
file:
path: /etc/apt/keyrings
state: directory
mode: '0755'
+ # Download Docker's official GPG key so apt can verify packages from the Docker repo.
- name: Add Docker GPG key
get_url:
url: https://download.docker.com/linux/ubuntu/gpg
dest: /etc/apt/keyrings/docker.asc
mode: '0644'
+ # Add the official Docker apt repository for Ubuntu Noble.
- name: Add Docker repository
apt_repository:
repo: >
@@ -30,6 +34,7 @@
https://download.docker.com/linux/ubuntu noble stable
state: present
+ # Install Docker Engine and the Compose plugin.
- name: Install Docker
apt:
name:
@@ -41,12 +46,14 @@
state: present
update_cache: true
+ # Allow azureuser to run Docker commands without sudo.
- name: Add azureuser to docker group
user:
name: azureuser
groups: docker
append: true
+ # Ensure Docker starts on boot and is running now.
- name: Start and enable Docker
systemd:
name: docker
diff --git a/client/.env.production b/client/.env.production
new file mode 100644
index 0000000..47bed08
--- /dev/null
+++ b/client/.env.production
@@ -0,0 +1 @@
+VITE_LLM_SERVICE_URL=https://team-devvopps.rancher.ase.cit.tum.de/llm
diff --git a/client/package-lock.json b/client/package-lock.json
index 4085214..cc5189e 100644
--- a/client/package-lock.json
+++ b/client/package-lock.json
@@ -58,6 +58,7 @@
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@babel/code-frame": "^7.29.0",
"@babel/generator": "^7.29.0",
@@ -267,29 +268,6 @@
"node": ">=6.9.0"
}
},
- "node_modules/@emnapi/core": {
- "version": "1.10.0",
- "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
- "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==",
- "dev": true,
- "license": "MIT",
- "optional": true,
- "dependencies": {
- "@emnapi/wasi-threads": "1.2.1",
- "tslib": "^2.4.0"
- }
- },
- "node_modules/@emnapi/runtime": {
- "version": "1.10.0",
- "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
- "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
- "dev": true,
- "license": "MIT",
- "optional": true,
- "dependencies": {
- "tslib": "^2.4.0"
- }
- },
"node_modules/@emnapi/wasi-threads": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
@@ -876,6 +854,7 @@
"integrity": "sha512-GUUEShf+PBCGW2KaXwcIt3Yk+e3pkKwWKb9GSyM9WQVE+ep2jzmHdGsHzu4wgcZy5fN9FBdVzjpBQsYlpfpgLA==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"undici-types": "~7.16.0"
}
@@ -886,6 +865,7 @@
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"csstype": "^3.2.2"
}
@@ -945,6 +925,7 @@
"integrity": "sha512-HPwA+hVkfcriajbNvTmZv4VRauibay+cWArYUYq7u7W7PmGShMxbPxLvrwDme55a6d5alG3nrYfhyJ/G28XlLg==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.59.3",
"@typescript-eslint/types": "8.59.3",
@@ -1175,6 +1156,7 @@
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
"dev": true,
"license": "MIT",
+ "peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -1265,6 +1247,7 @@
}
],
"license": "MIT",
+ "peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.10.12",
"caniuse-lite": "^1.0.30001782",
@@ -1413,6 +1396,7 @@
"integrity": "sha512-XbEXaRva5cF0ZQB8w6MluHA0kZZfV2DuCMJ3ozyEOHLwDpZX2Lmm/7Pp0xdJmI0GL1W05VH5VwIFHEm1Vcw2gw==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.2",
@@ -2301,6 +2285,7 @@
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true,
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=12"
},
@@ -2362,6 +2347,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz",
"integrity": "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==",
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -2371,6 +2357,7 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.6.tgz",
"integrity": "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
@@ -2569,6 +2556,7 @@
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
"dev": true,
"license": "Apache-2.0",
+ "peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -2655,6 +2643,7 @@
"integrity": "sha512-w2dDofOWv2QB09ZITZBsvKTVAlYvPR4IAmrY/v0ir9KvLs0xybR7i48wxhM1/oyBWO34wPns+bPGw5ZrZqDpZg==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"lightningcss": "^1.32.0",
"picomatch": "^4.0.4",
@@ -2779,6 +2768,7 @@
"integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
"dev": true,
"license": "MIT",
+ "peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
diff --git a/client/src/App.tsx b/client/src/App.tsx
index fce81cb..821eb27 100644
--- a/client/src/App.tsx
+++ b/client/src/App.tsx
@@ -1,20 +1,12 @@
import { BrowserRouter, Routes, Route } from "react-router-dom";
import AdminPanel from "./pages/AdminPanel";
-
-function RoadmapPage() {
- return (
-
-
TUMgoal Roadmap
-
Personalized learning roadmap generation coming soon.
-
- );
-}
+import RoadmapChat from "./pages/RoadmapChat";
export default function App() {
return (
- } />
+ } />
} />
diff --git a/client/src/index.css b/client/src/index.css
index 5fb3313..519fe0f 100644
--- a/client/src/index.css
+++ b/client/src/index.css
@@ -1,3 +1,7 @@
+@keyframes spin {
+ to { transform: rotate(360deg); }
+}
+
:root {
--text: #6b6375;
--text-h: #08060d;
diff --git a/client/src/pages/RoadmapChat.tsx b/client/src/pages/RoadmapChat.tsx
new file mode 100644
index 0000000..68e2119
--- /dev/null
+++ b/client/src/pages/RoadmapChat.tsx
@@ -0,0 +1,247 @@
+import { useState } from "react";
+
+const LLM_SERVICE_URL = import.meta.env.VITE_LLM_SERVICE_URL || "http://localhost:8004";
+
+interface Task {
+ title: string;
+ completed: boolean;
+}
+
+interface Milestone {
+ title: string;
+ description: string;
+ tasks: Task[];
+}
+
+interface RoadmapResponse {
+ milestones: Milestone[];
+}
+
+export default function RoadmapChat() {
+ const [goal, setGoal] = useState("");
+ const [roadmap, setRoadmap] = useState(null);
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
+
+ async function handleSubmit(e: React.FormEvent) {
+ e.preventDefault();
+ if (!goal.trim()) return;
+
+ setLoading(true);
+ setError(null);
+ setRoadmap(null);
+
+ try {
+ const res = await fetch(`${LLM_SERVICE_URL}/recommend`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ goal }),
+ });
+
+ if (!res.ok) throw new Error(`Error ${res.status}: ${res.statusText}`);
+ const data: RoadmapResponse = await res.json();
+ setRoadmap(data);
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "Something went wrong");
+ } finally {
+ setLoading(false);
+ }
+ }
+
+ return (
+
+
+
TUMgoal
+
Tell us your learning goal — we'll build your roadmap.
+
+
+
+
+ {error && (
+
+ ⚠️ {error}
+
+ )}
+
+ {loading && (
+
+
+
Searching courses and building your roadmap...
+
+ )}
+
+ {roadmap && roadmap.milestones.length > 0 && (
+
+
Your Learning Roadmap
+ {roadmap.milestones.map((milestone, i) => (
+
+
+
{i + 1}
+
+
{milestone.title}
+
{milestone.description}
+
+
+
+ {milestone.tasks?.map((task, j) => (
+ -
+ →
+ {task.title}
+
+ ))}
+
+
+ ))}
+
+ )}
+
+ );
+}
+
+const styles: Record = {
+ container: {
+ maxWidth: 720,
+ margin: "0 auto",
+ padding: "48px 24px",
+ fontFamily: "'Segoe UI', sans-serif",
+ color: "#1a1a1a",
+ },
+ header: {
+ textAlign: "center",
+ marginBottom: 40,
+ },
+ title: {
+ fontSize: 36,
+ fontWeight: 700,
+ margin: 0,
+ color: "#0065BD",
+ },
+ subtitle: {
+ color: "#666",
+ marginTop: 8,
+ fontSize: 16,
+ },
+ form: {
+ display: "flex",
+ gap: 12,
+ marginBottom: 32,
+ },
+ input: {
+ flex: 1,
+ padding: "14px 16px",
+ fontSize: 15,
+ border: "2px solid #e0e0e0",
+ borderRadius: 10,
+ outline: "none",
+ },
+ button: {
+ padding: "14px 24px",
+ fontSize: 15,
+ fontWeight: 600,
+ background: "#0065BD",
+ color: "#fff",
+ border: "none",
+ borderRadius: 10,
+ cursor: "pointer",
+ whiteSpace: "nowrap",
+ },
+ error: {
+ background: "#fff3f3",
+ border: "1px solid #ffcdd2",
+ color: "#c62828",
+ padding: "12px 16px",
+ borderRadius: 8,
+ marginBottom: 24,
+ },
+ loadingBox: {
+ textAlign: "center",
+ padding: "48px 0",
+ },
+ spinner: {
+ width: 40,
+ height: 40,
+ border: "4px solid #e0e0e0",
+ borderTop: "4px solid #0065BD",
+ borderRadius: "50%",
+ animation: "spin 0.8s linear infinite",
+ margin: "0 auto",
+ },
+ roadmap: {
+ display: "flex",
+ flexDirection: "column",
+ gap: 20,
+ },
+ roadmapTitle: {
+ fontSize: 22,
+ fontWeight: 700,
+ marginBottom: 8,
+ color: "#0065BD",
+ },
+ milestone: {
+ background: "#f8f9ff",
+ border: "1px solid #dde3ff",
+ borderRadius: 12,
+ padding: "20px 24px",
+ },
+ milestoneHeader: {
+ display: "flex",
+ gap: 16,
+ alignItems: "flex-start",
+ marginBottom: 14,
+ },
+ milestoneNumber: {
+ background: "#0065BD",
+ color: "#fff",
+ borderRadius: "50%",
+ width: 32,
+ height: 32,
+ display: "flex",
+ alignItems: "center",
+ justifyContent: "center",
+ fontWeight: 700,
+ fontSize: 14,
+ flexShrink: 0,
+ },
+ milestoneTitle: {
+ margin: 0,
+ fontSize: 16,
+ fontWeight: 700,
+ },
+ milestoneDesc: {
+ margin: "4px 0 0",
+ color: "#555",
+ fontSize: 14,
+ },
+ taskList: {
+ listStyle: "none",
+ padding: 0,
+ margin: 0,
+ display: "flex",
+ flexDirection: "column",
+ gap: 8,
+ },
+ task: {
+ display: "flex",
+ gap: 10,
+ fontSize: 14,
+ color: "#333",
+ alignItems: "flex-start",
+ },
+ taskDot: {
+ color: "#0065BD",
+ fontWeight: 700,
+ flexShrink: 0,
+ },
+};
diff --git a/compose.azure.yml b/compose.azure.yml
index 4953b49..a27c816 100644
--- a/compose.azure.yml
+++ b/compose.azure.yml
@@ -1,4 +1,6 @@
services:
+ # Traefik acts as a reverse proxy and handles TLS termination.
+ # It automatically obtains Let's Encrypt certificates via HTTP challenge and redirects all HTTP traffic to HTTPS.
reverse-proxy:
image: traefik:v3.6.15
command:
@@ -17,9 +19,11 @@ services:
- "80:80"
- "443:443"
volumes:
- - /var/run/docker.sock:/var/run/docker.sock
- - ./letsencrypt:/letsencrypt
+ - /var/run/docker.sock:/var/run/docker.sock # Needed for Traefik to discover services
+ - ./letsencrypt:/letsencrypt # Persists TLS certificates across restarts
+ # Shared PostgreSQL instance for all services.
+ # Each service uses its own database (userdb, coursedb, roadmapdb) created by init-databases.sql.
postgres:
image: postgres:16
environment:
@@ -27,7 +31,7 @@ services:
POSTGRES_PASSWORD: postgres
volumes:
- postgres_data:/var/lib/postgresql/data
- - ./init-databases.sql:/docker-entrypoint-initdb.d/init.sql
+ - ./init-databases.sql:/docker-entrypoint-initdb.d/init.sql # Runs once on first start
healthcheck:
test: ["CMD", "pg_isready", "-U", "postgres"]
interval: 5s
@@ -44,7 +48,7 @@ services:
DB_PASSWORD: postgres
depends_on:
postgres:
- condition: service_healthy
+ condition: service_healthy # Wait for postgres to be ready before starting
restart: unless-stopped
course-service:
@@ -67,6 +71,7 @@ services:
DB_USER: postgres
DB_PASSWORD: postgres
USER_SERVICE_HOST: user-service
+ LLM_SERVICE_HOST: llm-service
depends_on:
postgres:
condition: service_healthy
@@ -74,6 +79,9 @@ services:
condition: service_started
restart: unless-stopped
+ # The API gateway is the single entry point for all frontend requests.
+ # It proxies /users/**, /courses/**, /roadmaps/** to the respective services.
+ # Traefik routes external HTTPS traffic to this container on port 8080.
api-gateway:
image: ghcr.io/aet-devops26/team-devvopps/api-gateway:latest
environment:
@@ -92,6 +100,8 @@ services:
- "traefik.http.routers.api-gateway.entrypoints=websecure"
- "traefik.http.routers.api-gateway.tls.certresolver=letsencrypt"
+ # The React frontend. Traefik routes traffic and applies gzip compression
+ # via the client-compress middleware to reduce transfer size.
client:
image: ghcr.io/aet-devops26/team-devvopps/client:latest
environment:
@@ -107,6 +117,19 @@ services:
- "traefik.http.routers.client.tls.certresolver=letsencrypt"
- "traefik.http.middlewares.client-compress.compress=true"
- "traefik.http.routers.client.middlewares=client-compress"
+
+ # LLM service is internal only: Traefik is explicitly disabled.
+ # It is only reachable by other services within the Docker network.
+ llm-service:
+ image: ghcr.io/aet-devops26/team-devvopps/llm-service:latest
+ environment:
+ LLM_API_URL: http://llm-backend:1234/v1/chat/completions
+ COURSE_SERVICE_HOST: course-service
+ depends_on:
+ - course-service
+ restart: unless-stopped
+ labels:
+ - "traefik.enable=false"
volumes:
postgres_data:
diff --git a/helm/team-devvopps/templates/ingress.yaml b/helm/team-devvopps/templates/ingress.yaml
index 204332d..96912e0 100644
--- a/helm/team-devvopps/templates/ingress.yaml
+++ b/helm/team-devvopps/templates/ingress.yaml
@@ -16,6 +16,13 @@ spec:
name: api-gateway
port:
number: {{ .Values.services.apiGateway.port }}
+ - path: /llm
+ pathType: Prefix
+ backend:
+ service:
+ name: llm-service
+ port:
+ number: {{ .Values.services.llmService.port }}
- path: /
pathType: Prefix
backend:
diff --git a/helm/team-devvopps/templates/llm-service/deployment.yaml b/helm/team-devvopps/templates/llm-service/deployment.yaml
new file mode 100644
index 0000000..55e68c2
--- /dev/null
+++ b/helm/team-devvopps/templates/llm-service/deployment.yaml
@@ -0,0 +1,52 @@
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: llm-service
+ namespace: {{ .Values.namespace }}
+spec:
+ replicas: {{ .Values.replicas }}
+ selector:
+ matchLabels:
+ app: llm-service
+ template:
+ metadata:
+ labels:
+ app: llm-service
+ monitoring: "true"
+ spec:
+ containers:
+ - name: llm-service
+ image: {{ .Values.imageRegistry }}/llm-service:{{ .Values.imageTag }}
+ imagePullPolicy: {{ .Values.imagePullPolicy }}
+ ports:
+ - containerPort: {{ .Values.services.llmService.port }}
+ resources:
+ requests:
+ cpu: 150m
+ memory: 256Mi
+ limits:
+ cpu: 200m
+ memory: 512Mi
+ env:
+ - name: COURSE_SERVICE_URL
+ value: http://course-service:8082/courses
+ - name: PORT
+ value: "{{ .Values.services.llmService.port }}"
+ - name: GROQ_API_KEY
+ valueFrom:
+ secretKeyRef:
+ name: llm-secret
+ key: groq-api-key
+ optional: true
+ - name: LOGOS_API_KEY
+ valueFrom:
+ secretKeyRef:
+ name: llm-secret
+ key: logos-api-key
+ optional: true
+ readinessProbe:
+ httpGet:
+ path: /health
+ port: {{ .Values.services.llmService.port }}
+ initialDelaySeconds: 30
+ periodSeconds: 10
diff --git a/helm/team-devvopps/templates/llm-service/secret.yaml b/helm/team-devvopps/templates/llm-service/secret.yaml
new file mode 100644
index 0000000..4abb582
--- /dev/null
+++ b/helm/team-devvopps/templates/llm-service/secret.yaml
@@ -0,0 +1,9 @@
+apiVersion: v1
+kind: Secret
+metadata:
+ name: llm-secret
+ namespace: {{ .Values.namespace }}
+type: Opaque
+stringData:
+ groq-api-key: {{ .Values.llmService.groqApiKey | default "" }}
+ logos-api-key: {{ .Values.llmService.logosApiKey | default "" }}
diff --git a/helm/team-devvopps/templates/llm-service/service.yaml b/helm/team-devvopps/templates/llm-service/service.yaml
new file mode 100644
index 0000000..73984aa
--- /dev/null
+++ b/helm/team-devvopps/templates/llm-service/service.yaml
@@ -0,0 +1,12 @@
+apiVersion: v1
+kind: Service
+metadata:
+ name: llm-service
+ namespace: {{ .Values.namespace }}
+spec:
+ type: ClusterIP
+ selector:
+ app: llm-service
+ ports:
+ - port: {{ .Values.services.llmService.port }}
+ targetPort: {{ .Values.services.llmService.port }}
diff --git a/helm/team-devvopps/templates/roadmap-service/deployment.yaml b/helm/team-devvopps/templates/roadmap-service/deployment.yaml
index 01bf366..73981a5 100644
--- a/helm/team-devvopps/templates/roadmap-service/deployment.yaml
+++ b/helm/team-devvopps/templates/roadmap-service/deployment.yaml
@@ -44,6 +44,8 @@ spec:
key: password
- name: USER_SERVICE_HOST
value: user-service
+ - name: LLM_SERVICE_HOST
+ value: llm-service
readinessProbe:
httpGet:
path: /actuator/health
diff --git a/helm/team-devvopps/values.yaml b/helm/team-devvopps/values.yaml
index 767d26a..45c4bd7 100644
--- a/helm/team-devvopps/values.yaml
+++ b/helm/team-devvopps/values.yaml
@@ -34,4 +34,10 @@ services:
port: 8080
client:
port: 80
+ llmService:
+ port: 8084
+# LLM Service API keys (override in values-aet.yaml or via --set)
+llmService:
+ groqApiKey: ""
+ logosApiKey: ""
diff --git a/infra/docker-compose.yml b/infra/docker-compose.yml
index a3b0c58..de4446e 100644
--- a/infra/docker-compose.yml
+++ b/infra/docker-compose.yml
@@ -52,6 +52,7 @@ services:
DB_USER: postgres
DB_PASSWORD: postgres
USER_SERVICE_HOST: user-service
+ LLM_SERVICE_HOST: llm-service
depends_on:
postgres:
condition: service_healthy
@@ -84,5 +85,28 @@ services:
depends_on:
- api-gateway
+ # ── LLM Service ────────────────────────────────────────────────────────────
+ llm-service:
+ image: ghcr.io/aet-devops26/w06-template/llm:latest
+ build:
+ context: ../server
+ dockerfile: llm-service/Dockerfile
+ ports:
+ - "8084:8084"
+ environment:
+ # LM Studio on the host machine. host.docker.internal works on
+ # Docker Desktop (macOS/Windows) out of the box; extra_hosts below
+ # extends the same name to Linux hosts.
+ - LLM_API_URL=${LLM_API_URL:-http://host.docker.internal:1234/v1/chat/completions}
+ - LLM_MODEL=${LLM_MODEL:-gemma-4-e2b}
+ - LLM_API_KEY=${LLM_API_KEY:-}
+ # Set LOGOS_API_KEY in .env to switch to the TUM-hosted Logos
+ # endpoint (openai/gpt-oss-120b). When set, it overrides the
+ # LM Studio defaults above. Unset = LM Studio.
+ - LOGOS_API_KEY=${LOGOS_API_KEY:-}
+ - COURSE_SERVICE_HOST: course-service
+ extra_hosts:
+ - "host.docker.internal:host-gateway"
+
volumes:
postgres_data:
diff --git a/infra/k8s/llm-service/deployment.yml b/infra/k8s/llm-service/deployment.yml
new file mode 100644
index 0000000..75fbd79
--- /dev/null
+++ b/infra/k8s/llm-service/deployment.yml
@@ -0,0 +1,32 @@
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: llm-service
+ namespace: team-devvopps
+spec:
+ replicas: 1
+ selector:
+ matchLabels:
+ app: llm-service
+ template:
+ metadata:
+ labels:
+ app: llm-service
+ spec:
+ containers:
+ - name: llm-service
+ image: ghcr.io/aet-devops26/team-devvopps/llm-service:latest
+ imagePullPolicy: Always
+ ports:
+ - containerPort: 8084
+ env:
+ - name: COURSE_SERVICE_HOST
+ value: course-service
+ - name: LLM_API_URL
+ value: http://llm-backend:1234/v1/chat/completions
+ readinessProbe:
+ httpGet:
+ path: /health
+ port: 8084
+ initialDelaySeconds: 20
+ periodSeconds: 10
\ No newline at end of file
diff --git a/infra/k8s/llm-service/service.yml b/infra/k8s/llm-service/service.yml
new file mode 100644
index 0000000..5c8cc98
--- /dev/null
+++ b/infra/k8s/llm-service/service.yml
@@ -0,0 +1,12 @@
+apiVersion: v1
+kind: Service
+metadata:
+ name: llm-service
+ namespace: team-devvopps
+spec:
+ type: ClusterIP
+ selector:
+ app: llm-service
+ ports:
+ - port: 8084
+ targetPort: 8084
\ No newline at end of file
diff --git a/infra/k8s/roadmap-service/deployment.yaml b/infra/k8s/roadmap-service/deployment.yaml
index 617c119..cdf0e79 100644
--- a/infra/k8s/roadmap-service/deployment.yaml
+++ b/infra/k8s/roadmap-service/deployment.yaml
@@ -37,6 +37,8 @@ spec:
key: password
- name: USER_SERVICE_HOST
value: user-service
+ - name: LLM_SERVICE_HOST
+ value: llm-service
readinessProbe:
httpGet:
path: /actuator/health
diff --git a/server/api-gateway/src/main/java/com/tum/gateway/GatewayController.java b/server/api-gateway/src/main/java/com/tum/gateway/GatewayController.java
index abd32ea..67f0445 100644
--- a/server/api-gateway/src/main/java/com/tum/gateway/GatewayController.java
+++ b/server/api-gateway/src/main/java/com/tum/gateway/GatewayController.java
@@ -11,6 +11,18 @@
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.client.RestTemplate;
+/**
+ * API Gateway controller that proxies all incoming requests to the appropriate downstream service.
+ *
+ * Routes:
+ * /users/** → user-service
+ * /courses/** → course-service
+ * /roadmaps/** → roadmap-service
+ *
+ * The full request (method, headers, body, query params) is forwarded as-is.
+ * Responses are passed back to the caller unchanged, except Transfer-Encoding
+ * which is stripped to avoid chunked-encoding conflicts with Spring's response writing.
+ */
@RestController
@CrossOrigin
public class GatewayController {
@@ -41,11 +53,25 @@ public ResponseEntity forwardRoadmap(HttpServletRequest request, HttpEnt
return forward(request, entity, roadmapServiceUrl);
}
+ /**
+ * Forwards the incoming HTTP request to the target service and returns its response.
+ *
+ * Transfer-Encoding is removed from the response headers because Spring sets its own
+ * transfer encoding when writing the response body, and keeping the upstream value
+ * causes encoding conflicts on the client side.
+ *
+ * @param request the original incoming HTTP request
+ * @param entity the request body and headers
+ * @param targetBaseUrl the base URL of the downstream service to forward to
+ */
private ResponseEntity forward(HttpServletRequest request, HttpEntity entity, String targetBaseUrl) {
String path = request.getRequestURI();
String query = request.getQueryString();
String url = targetBaseUrl + path + (query != null ? "?" + query : "");
ResponseEntity response = restTemplate.exchange(url, HttpMethod.valueOf(request.getMethod()), entity, byte[].class);
+
+ // Copy response headers, excluding Transfer-Encoding to avoid chunked encoding conflicts.
+
HttpHeaders headers = new HttpHeaders();
response.getHeaders().forEach((key, values) -> {
if (!key.equalsIgnoreCase("Transfer-Encoding")) {
diff --git a/server/compose.yaml b/server/compose.yaml
index f9fd65b..ea8a0d1 100644
--- a/server/compose.yaml
+++ b/server/compose.yaml
@@ -1,6 +1,6 @@
services:
postgres:
- image: 'postgres:latest'
+ image: 'postgres:17.5'
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
diff --git a/server/llm-service/Dockerfile b/server/llm-service/Dockerfile
new file mode 100644
index 0000000..99b24b8
--- /dev/null
+++ b/server/llm-service/Dockerfile
@@ -0,0 +1,21 @@
+# Build a virtualenv using the appropriate Debian release
+# * Install python3-venv for the built-in Python3 venv module (not installed by default)
+# * Install gcc libpython3-dev to compile C Python modules
+# * In the virtualenv: Update pip setuputils and wheel to support building new packages
+FROM debian:12-slim AS build
+RUN apt-get update && \
+ apt-get install --no-install-suggests --no-install-recommends --yes python3-venv gcc libpython3-dev && \
+ python3 -m venv /venv && \
+ /venv/bin/pip install --upgrade pip setuptools wheel
+
+# Build the virtualenv as a separate step: Only re-execute this step when requirements.txt changes
+FROM build AS build-venv
+COPY requirements.txt /requirements.txt
+RUN /venv/bin/pip install --disable-pip-version-check -r /requirements.txt
+
+# Copy the virtualenv into a distroless image
+FROM gcr.io/distroless/python3-debian12
+COPY --from=build-venv /venv /venv
+COPY . /app
+WORKDIR /app
+ENTRYPOINT ["/venv/bin/python3", "main.py"]
\ No newline at end of file
diff --git a/server/llm-service/main.py b/server/llm-service/main.py
new file mode 100644
index 0000000..a4df355
--- /dev/null
+++ b/server/llm-service/main.py
@@ -0,0 +1,321 @@
+import os
+import json
+import requests
+import uvicorn
+from typing import Any, List, Optional
+from contextlib import asynccontextmanager
+from fastapi import FastAPI, HTTPException
+from fastapi.middleware.cors import CORSMiddleware
+from pydantic import BaseModel, Field
+from langchain_core.prompts import PromptTemplate
+from langchain_core.language_models.llms import LLM
+from langchain_core.callbacks.manager import CallbackManagerForLLMRun
+from sklearn.feature_extraction.text import TfidfVectorizer
+from sklearn.metrics.pairwise import cosine_similarity
+import numpy as np
+
+# ---------------------------------------------------------------------------
+# LLM provider selection
+# ---------------------------------------------------------------------------
+# The service supports three LLM backends, selected by environment variables:
+# 1. Logos (TUM-hosted GPT): set LOGOS_API_KEY. Requires eduVPN off-campus.
+# 2. Groq (free cloud API): set GROQ_API_KEY. Uses llama-3.3-70b.
+# 3. LM Studio (local): set neither. Defaults to localhost:1234.
+# Override with LLM_API_URL and LLM_MODEL for a different local model.
+# ---------------------------------------------------------------------------
+LOGOS_API_KEY = os.getenv("LOGOS_API_KEY")
+GROQ_API_KEY = os.getenv("GROQ_API_KEY")
+
+if LOGOS_API_KEY:
+ # Logos profile: TUM-hosted gpt-oss-120b. Off-campus needs eduVPN.
+ # Hardcoded so a single LOGOS_API_KEY in .env is the only switch
+ # students need to flip.
+ API_URL = "https://logos.aet.cit.tum.de/v1/chat/completions"
+ MODEL_NAME = "openai/gpt-oss-120b"
+ LLM_API_KEY = LOGOS_API_KEY
+elif GROQ_API_KEY:
+ # Groq profile: free tier, llama-3.3-70b-versatile.
+ API_URL = "https://api.groq.com/openai/v1/chat/completions"
+ MODEL_NAME = "llama-3.3-70b-versatile"
+ LLM_API_KEY = GROQ_API_KEY
+else:
+ # LM Studio profile: local model on host. Defaults match compose.yml
+ # so both `docker compose up` and `python main.py` work.
+ API_URL = os.getenv("LLM_API_URL", "http://localhost:1234/v1/chat/completions")
+ MODEL_NAME = os.getenv("LLM_MODEL", "gemma-4-e2b")
+ # LM Studio doesn't require a key; CHAIR_API_KEY is left for back-compat.
+ LLM_API_KEY = os.getenv("LLM_API_KEY") or os.getenv("CHAIR_API_KEY")
+
+# URL of the course-service REST API used to fetch the course catalogue.
+COURSE_SERVICE_URL = os.getenv("COURSE_SERVICE_URL", "http://course-service:8082/courses")
+
+# Number of courses passed to the LLM after TF-IDF filtering.
+TOP_K = int(os.getenv("TOP_K", "30"))
+
+# ---------------------------------------------------------------------------
+# TF-IDF index: built once at startup, held in memory.
+# Replaces keyword search — finds the TOP_K most relevant courses
+# for a given goal without burning LLM tokens on all 929 courses.
+# ---------------------------------------------------------------------------
+_courses: List[dict] = []
+_vectorizer: Optional[TfidfVectorizer] = None
+_matrix = None
+
+
+def build_index() -> int:
+ global _courses, _vectorizer, _matrix
+ try:
+ resp = requests.get(COURSE_SERVICE_URL, timeout=15)
+ resp.raise_for_status()
+ _courses = resp.json()
+ except Exception as e:
+ print(f"[RAG] Could not fetch courses: {e}")
+ return 0
+
+ # Build a document per course combining title and objective for better matching.
+ documents = []
+ for c in _courses:
+ title = c.get("title", "")
+ objective = (c.get("objective") or c.get("content") or "")[:300]
+ documents.append(f"{title} {objective}")
+
+ _vectorizer = TfidfVectorizer(stop_words="english", ngram_range=(1, 2))
+ _matrix = _vectorizer.fit_transform(documents)
+ print(f"[RAG] Indexed {len(_courses)} courses with TF-IDF.")
+ return len(_courses)
+
+
+def filter_courses(goal: str, k: int = TOP_K) -> str:
+ """Return the top-k most relevant courses for the given goal as a formatted string."""
+ if _vectorizer is None or _matrix is None or not _courses:
+ return "- No matching courses found"
+
+ query_vec = _vectorizer.transform([goal])
+ scores = cosine_similarity(query_vec, _matrix).flatten()
+ top_idx = np.argsort(scores)[::-1][:k]
+
+ lines = []
+ for i in top_idx:
+ if scores[i] > 0:
+ c = _courses[i]
+ code = c.get("tum_number", "")
+ title = c.get("title", "")
+ objective = (c.get("objective") or c.get("content") or "")[:200]
+ lines.append(f"- [{code}] {title} | {objective}")
+
+ return "\n".join(lines) if lines else "- No matching courses found"
+
+
+# ---------------------------------------------------------------------------
+# App lifecycle
+# ---------------------------------------------------------------------------
+@asynccontextmanager
+async def lifespan(app: FastAPI):
+ print(f"[RAG] Building TF-IDF index... (model: {MODEL_NAME})")
+ count = build_index()
+ print(f"[RAG] Ready — {count} courses indexed.")
+ yield
+
+
+# Create FastAPI application instance
+app = FastAPI(
+ title="LLM Recommendation Service",
+ description="Service that generates personalized learning roadmaps using an LLM",
+ version="2.0.0",
+ lifespan=lifespan,
+)
+
+# Allow all origins so the frontend and API gateway can call this service freely.
+app.add_middleware(
+ CORSMiddleware,
+ allow_origins=["*"],
+ allow_methods=["*"],
+ allow_headers=["*"],
+)
+
+
+class RoadmapRequest(BaseModel):
+ """
+ Request schema for generate endpoint.
+ """
+ goal: str = Field(..., description="The user's learning goal in natural language")
+
+
+class RoadmapResponse(BaseModel):
+ """
+ Response schema for generate endpoint.
+ """
+ milestones: List[Any] = Field(default=[], description="Learning milestones")
+
+
+class OpenAICompatibleLLM(LLM):
+ """
+ LangChain LLM wrapper for any OpenAI-compatible /v1/chat/completions
+ endpoint (LM Studio, Ollama in OpenAI mode, OpenAI itself, etc.).
+ """
+
+ api_url: str = API_URL
+ api_key: Optional[str] = LLM_API_KEY
+ model_name: str = MODEL_NAME
+
+ @property
+ def _llm_type(self) -> str:
+ return "openai_compatible"
+
+ def _call(
+ self,
+ prompt: str,
+ stop: Optional[List[str]] = None,
+ run_manager: Optional[CallbackManagerForLLMRun] = None,
+ **kwargs: Any,
+ ) -> str:
+ headers = {
+ "Content-Type": "application/json",
+ }
+ if self.api_key:
+ headers["Authorization"] = f"Bearer {self.api_key}"
+
+ # Build messages for chat completion
+ messages = [
+ {"role": "user", "content": prompt}
+ ]
+
+ payload = {
+ "model": self.model_name,
+ "messages": messages,
+ }
+
+ try:
+ response = requests.post(
+ self.api_url,
+ headers=headers,
+ json=payload,
+ timeout=120
+ )
+ response.raise_for_status()
+
+ result = response.json()
+
+ # Extract the response content
+ if "choices" in result and len(result["choices"]) > 0:
+ content = result["choices"][0]["message"]["content"]
+ return content.strip()
+ else:
+ raise ValueError("Unexpected response format from API")
+
+ except requests.RequestException as e:
+ raise Exception(f"API request failed: {str(e)}")
+ except (KeyError, IndexError, ValueError) as e:
+ raise Exception(f"Failed to parse API response: {str(e)}")
+
+
+_PROMPT = """You are an expert academic advisor creating a personalised learning roadmap.
+
+Student's learning goal: {goal}
+
+Available courses in the catalogue:
+{courses}
+
+Instructions:
+1. Select the most relevant courses from the list above to reach the student's goal.
+2. Break the journey into clear milestones (e.g. "Complete foundational mathematics"). Also include external milestones that are not courses.
+3. For each milestone, define concrete tasks the student should do. For course tasks, include the course code in brackets (e.g. "Enroll in [IN2064] Machine Learning").
+4. Each milestone MUST contain at least 2–4 tasks. Tasks MUST belong to their milestone (nested structure)
+5. Respond with ONLY valid JSON.
+
+Required JSON format:
+
+{{
+ "milestones": [
+ {{
+ "title": "Milestone name",
+ "description": "What this milestone achieves",
+ "tasks": [
+ {{
+ "title": "Task description",
+ "completed": false
+ }}
+ ]
+ }}
+ ]
+}}
+
+JSON response:
+"""
+
+chain = PromptTemplate(
+ input_variables=["goal", "courses"],
+ template=_PROMPT,
+) | OpenAICompatibleLLM()
+
+# ---------------------------------------------------------------------------
+# Helpers
+# ---------------------------------------------------------------------------
+def parse_llm_response(raw: str) -> RoadmapResponse:
+ """
+ Parses the LLM JSON output into a RoadmapResponse.
+ Falls back to empty lists if the JSON is malformed.
+ """
+ try:
+ cleaned = (
+ raw.strip()
+ .removeprefix("```json")
+ .removeprefix("```")
+ .removesuffix("```")
+ .strip()
+ )
+
+ data = json.loads(cleaned)
+
+ return RoadmapResponse.model_validate(data)
+
+ except Exception:
+ return RoadmapResponse(milestones=[])
+
+# ---------------------------------------------------------------------------
+# Endpoints
+# ---------------------------------------------------------------------------
+
+@app.get("/health")
+async def health_check():
+ """Health check endpoint."""
+ return {"status": "healthy", "service": "LLM Roadmap Generation Service", "model": MODEL_NAME}
+
+
+@app.post("/recommend", response_model=RoadmapResponse)
+async def recommend(req: RoadmapRequest) -> RoadmapResponse:
+ if not req.goal.strip():
+ raise HTTPException(status_code=422, detail="goal cannot be empty")
+
+ # Use TF-IDF to find the most relevant courses (replaces keyword search)
+ courses_str = filter_courses(req.goal)
+
+ # Call LLM
+ try:
+ raw = await chain.ainvoke({
+ "goal": req.goal,
+ "courses": courses_str
+ })
+ except RuntimeError as e:
+ raise HTTPException(status_code=503, detail=str(e)) from e
+
+ return parse_llm_response(raw)
+
+
+@app.get("/")
+async def root():
+ """Root endpoint with service information."""
+ return {
+ "service": "LLM Roadmap Service",
+ "version": "2.0.0",
+ "description": "Generates personalized roadmaps using TF-IDF filtering + LLM.",
+ "endpoints": {
+ "health": "/health",
+ "recommend": "/recommend",
+ }
+ }
+
+# Entry point for direct execution
+if __name__ == "__main__":
+ port = int(os.getenv("PORT", 8004))
+ uvicorn.run("main:app", host="0.0.0.0", port=port, reload=True)
diff --git a/server/llm-service/requirements.txt b/server/llm-service/requirements.txt
new file mode 100644
index 0000000..7e78171
--- /dev/null
+++ b/server/llm-service/requirements.txt
@@ -0,0 +1,6 @@
+fastapi==0.115.0
+uvicorn==0.30.6
+pydantic==2.9.2
+requests==2.32.3
+langchain-core==0.3.0
+scikit-learn==1.5.2
diff --git a/server/roadmap-service/src/main/java/com/tum/roadmap/dto/MilestoneDto.java b/server/roadmap-service/src/main/java/com/tum/roadmap/dto/MilestoneDto.java
new file mode 100644
index 0000000..31b0d53
--- /dev/null
+++ b/server/roadmap-service/src/main/java/com/tum/roadmap/dto/MilestoneDto.java
@@ -0,0 +1,9 @@
+package com.tum.roadmap.dto;
+
+import java.util.List;
+
+public record MilestoneDto(
+ String title,
+ String description,
+ List tasks
+) {}
diff --git a/server/roadmap-service/src/main/java/com/tum/roadmap/dto/RoadmapRequest.java b/server/roadmap-service/src/main/java/com/tum/roadmap/dto/RoadmapRequest.java
new file mode 100644
index 0000000..624541e
--- /dev/null
+++ b/server/roadmap-service/src/main/java/com/tum/roadmap/dto/RoadmapRequest.java
@@ -0,0 +1,7 @@
+package com.tum.roadmap.dto;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+
+public record RoadmapRequest(
+ @JsonProperty("goal") String goal
+) {}
diff --git a/server/roadmap-service/src/main/java/com/tum/roadmap/dto/RoadmapResponse.java b/server/roadmap-service/src/main/java/com/tum/roadmap/dto/RoadmapResponse.java
new file mode 100644
index 0000000..5aad921
--- /dev/null
+++ b/server/roadmap-service/src/main/java/com/tum/roadmap/dto/RoadmapResponse.java
@@ -0,0 +1,9 @@
+package com.tum.roadmap.dto;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+
+import java.util.List;
+
+public record RoadmapResponse(
+ @JsonProperty("milestones") List milestones
+) {}
\ No newline at end of file
diff --git a/server/roadmap-service/src/main/java/com/tum/roadmap/dto/TaskDto.java b/server/roadmap-service/src/main/java/com/tum/roadmap/dto/TaskDto.java
new file mode 100644
index 0000000..5229c0d
--- /dev/null
+++ b/server/roadmap-service/src/main/java/com/tum/roadmap/dto/TaskDto.java
@@ -0,0 +1,6 @@
+package com.tum.roadmap.dto;
+
+public record TaskDto(
+ String title,
+ boolean completed
+) {}
diff --git a/server/roadmap-service/src/main/java/com/tum/roadmap/model/Milestone.java b/server/roadmap-service/src/main/java/com/tum/roadmap/model/Milestone.java
index 3319144..b872642 100644
--- a/server/roadmap-service/src/main/java/com/tum/roadmap/model/Milestone.java
+++ b/server/roadmap-service/src/main/java/com/tum/roadmap/model/Milestone.java
@@ -3,7 +3,12 @@
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Data;
+import lombok.EqualsAndHashCode;
+import lombok.Getter;
import lombok.NoArgsConstructor;
+import lombok.Setter;
+import lombok.ToString;
+
import java.util.ArrayList;
import java.util.List;
@@ -15,9 +20,12 @@
*/
@Entity
@Table(name = "milestones")
-@Data
+@Getter
+@Setter
@NoArgsConstructor
@AllArgsConstructor
+@ToString(exclude = {"roadmap", "tasks"})
+@EqualsAndHashCode(exclude = {"roadmap", "tasks"})
public class Milestone {
/** Unique identifier for the milestone (auto-generated) */
diff --git a/server/roadmap-service/src/main/java/com/tum/roadmap/model/Roadmap.java b/server/roadmap-service/src/main/java/com/tum/roadmap/model/Roadmap.java
index 9e5f5fc..1c9f667 100644
--- a/server/roadmap-service/src/main/java/com/tum/roadmap/model/Roadmap.java
+++ b/server/roadmap-service/src/main/java/com/tum/roadmap/model/Roadmap.java
@@ -3,7 +3,12 @@
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Data;
+import lombok.EqualsAndHashCode;
+import lombok.Getter;
import lombok.NoArgsConstructor;
+import lombok.Setter;
+import lombok.ToString;
+
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
@@ -19,9 +24,12 @@
*/
@Entity
@Table(name = "roadmaps")
-@Data
+@Getter
+@Setter
@NoArgsConstructor
@AllArgsConstructor
+@ToString(exclude = "milestones")
+@EqualsAndHashCode(exclude = "milestones")
public class Roadmap {
/** Unique identifier for the roadmap (auto-generated) */
diff --git a/server/roadmap-service/src/main/java/com/tum/roadmap/model/Task.java b/server/roadmap-service/src/main/java/com/tum/roadmap/model/Task.java
index c448b81..656bb55 100644
--- a/server/roadmap-service/src/main/java/com/tum/roadmap/model/Task.java
+++ b/server/roadmap-service/src/main/java/com/tum/roadmap/model/Task.java
@@ -3,7 +3,11 @@
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Data;
+import lombok.EqualsAndHashCode;
+import lombok.Getter;
import lombok.NoArgsConstructor;
+import lombok.Setter;
+import lombok.ToString;
/**
* Task entity representing an individual action item within a milestone.
@@ -12,9 +16,12 @@
*/
@Entity
@Table(name = "tasks")
-@Data
+@Getter
+@Setter
@NoArgsConstructor
@AllArgsConstructor
+@ToString(exclude = "milestone")
+@EqualsAndHashCode(exclude = "milestone")
public class Task {
/** Unique identifier for the task (auto-generated) */
diff --git a/server/roadmap-service/src/main/java/com/tum/roadmap/service/RoadmapService.java b/server/roadmap-service/src/main/java/com/tum/roadmap/service/RoadmapService.java
index 654fe1b..eff5f8d 100644
--- a/server/roadmap-service/src/main/java/com/tum/roadmap/service/RoadmapService.java
+++ b/server/roadmap-service/src/main/java/com/tum/roadmap/service/RoadmapService.java
@@ -1,5 +1,9 @@
package com.tum.roadmap.service;
+import com.tum.roadmap.dto.MilestoneDto;
+import com.tum.roadmap.dto.RoadmapRequest;
+import com.tum.roadmap.dto.RoadmapResponse;
+import com.tum.roadmap.dto.TaskDto;
import com.tum.roadmap.model.*;
import com.tum.roadmap.repository.GoalRepository;
import com.tum.roadmap.repository.RoadmapRepository;
@@ -11,6 +15,8 @@
import org.springframework.web.client.RestTemplate;
import java.time.LocalDateTime;
+import java.util.ArrayList;
+import java.util.List;
/**
* Service layer for Roadmap-related business logic.
@@ -23,8 +29,22 @@ public class RoadmapService {
private final GoalRepository goalRepository;
private final RestTemplate restTemplate;
- @Value("${user.service.url:http://localhost:8081}/users/")
- private String USER_URL;
+ @Value("${llm.service.host:llm-service}")
+ private String llmHost;
+
+ @Value("${llm.service.port:8084}")
+ private String llmPort;
+
+ @Value("${llm.service.host:user-service}")
+ private String userHost;
+
+ @Value("${llm.service.port:8081}")
+ private String userPort;
+
+
+ private String USER_URL = "http://" + userHost + ":" + userPort + "/users/";
+
+ private String LLM_URL = "http://" + llmHost + ":" + llmPort;;
/**
* Calls user-service to verify that the user exists.
@@ -44,65 +64,50 @@ public Roadmap generateRoadmap(Long userId, String user_goal) {
// Verify user exists via user-service
Object user = getUser(userId);
-
- Roadmap roadmap = new Roadmap();
-
+
+ // Create Goal
Goal goal = new Goal();
goal.setCreated_date(LocalDateTime.now());
goal.setDescription(user_goal);
goalRepository.save(goal);
+ // Create Roadmap
+ Roadmap roadmap = new Roadmap();
roadmap.setGoal(goal);
roadmap.setCreated_date(LocalDateTime.now());
- /*
- * FUTURE AI SERVICE COMMUNICATION
- *
- * The roadmap-service should send the user's goal to the AI microservice.
- * Example: POST http://localhost:8084/ai/generate
- *
- * Request body:
- * {
- * "goal": "Learn Machine Learning"
- * }
- *
- * The AI service should:
- *
- * 1. Extract keywords from the goal
- * Example:
- * ["machine learning", "python", "statistics"]
- *
- * 2. Search matching courses from course-service
- *
- * 3. Generate milestones and tasks
- *
- * 4. Return structured roadmap data
- *
- * Example response:
- * {
- * "milestones": [...],
- * "tasks": [...],
- * "recommendedCourses": [...]
- * }
- *
- */
-
- /*
- * TODO:
- * Add generated milestones
- *
- * roadmap.setMilestones(...)
- */
-
- /*
- * TODO:
- * Add generated tasks
- */
-
- /*
- * TODO:
- * Add recommended courses
- */
+ // Call LLM
+ RoadmapResponse llmResponse = callLLM(user_goal);
+
+ List milestones = new ArrayList<>();
+
+ if (llmResponse != null && llmResponse.milestones() != null) {
+
+ for (MilestoneDto m : llmResponse.milestones()) {
+ Milestone milestone = new Milestone();
+ milestone.setTitle(m.title());
+ milestone.setDescription(m.description());
+ milestone.setRoadmap(roadmap);
+
+ List tasks = new ArrayList<>();
+
+ if (m.tasks() != null) {
+ for (TaskDto t : m.tasks()) {
+ Task task = new Task();
+ task.setTitle(t.title());
+ task.setCompleted(false);
+ task.setMilestone(milestone);
+
+ tasks.add(task);
+ }
+ }
+
+ milestone.setTasks(tasks);
+ milestones.add(milestone);
+ }
+ }
+
+ roadmap.setMilestones(milestones);
return roadmapRepository.save(roadmap);
}
@@ -114,4 +119,21 @@ public Roadmap getRoadmap(Long id) {
return roadmapRepository.findById(id).orElseThrow(() -> new RuntimeException("Roadmap not found"));
}
+
+ // Private Helper
+ private RoadmapResponse callLLM(String goal) {
+ try {
+ RestTemplate rt = new RestTemplate();
+
+ return rt.postForObject(
+ LLM_URL + "/recommend",
+ new RoadmapRequest(goal),
+ RoadmapResponse.class
+ );
+
+ } catch (Exception e) {
+ System.err.println("LLM service not reachable: " + e.getMessage());
+ return null;
+ }
+ }
}