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.

+
+ +
+ setGoal(e.target.value)} + disabled={loading} + /> + +
+ + {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; + } + } }