From 11b04272f477b65ea51d7a8254b5781861ac4f62 Mon Sep 17 00:00:00 2001 From: ge64rov Date: Thu, 11 Jun 2026 16:56:37 +0200 Subject: [PATCH 1/7] added llm microservice as shown in lecture, roadmap-service delegates AI processing to llm-service --- .../workflows/build_and_deploy_docker_VM.yml | 8 + compose.azure.yml | 12 + .../templates/llm-service/deployment.yaml | 39 +++ .../templates/llm-service/service.yaml | 12 + .../templates/roadmap-service/deployment.yaml | 2 + helm/team-devvopps/values.yaml | 2 + infra/docker-compose.yml | 24 ++ infra/k8s/llm-service/deployment.yml | 32 ++ infra/k8s/llm-service/service.yml | 12 + infra/k8s/roadmap-service/deployment.yaml | 2 + server/llm-service/Dockerfile | 21 ++ server/llm-service/main.py | 325 ++++++++++++++++++ .../com/tum/roadmap/dto/MilestoneDto.java | 9 + .../com/tum/roadmap/dto/RoadmapRequest.java | 7 + .../com/tum/roadmap/dto/RoadmapResponse.java | 9 + .../java/com/tum/roadmap/dto/TaskDto.java | 6 + .../tum/roadmap/service/RoadmapService.java | 128 ++++--- 17 files changed, 597 insertions(+), 53 deletions(-) create mode 100644 helm/team-devvopps/templates/llm-service/deployment.yaml create mode 100644 helm/team-devvopps/templates/llm-service/service.yaml create mode 100644 infra/k8s/llm-service/deployment.yml create mode 100644 infra/k8s/llm-service/service.yml create mode 100644 server/llm-service/Dockerfile create mode 100644 server/llm-service/main.py create mode 100644 server/roadmap-service/src/main/java/com/tum/roadmap/dto/MilestoneDto.java create mode 100644 server/roadmap-service/src/main/java/com/tum/roadmap/dto/RoadmapRequest.java create mode 100644 server/roadmap-service/src/main/java/com/tum/roadmap/dto/RoadmapResponse.java create mode 100644 server/roadmap-service/src/main/java/com/tum/roadmap/dto/TaskDto.java diff --git a/.github/workflows/build_and_deploy_docker_VM.yml b/.github/workflows/build_and_deploy_docker_VM.yml index d15c058..a97ddb8 100644 --- a/.github/workflows/build_and_deploy_docker_VM.yml +++ b/.github/workflows/build_and_deploy_docker_VM.yml @@ -68,6 +68,14 @@ jobs: file: ./client/Dockerfile push: true tags: ghcr.io/aet-devops26/team-devvopps/client:latest + + - name: Build and Push llm-service + uses: docker/build-push-action@v5 + with: + context: ./llm-service + file: ./llm-service/Dockerfile + push: true + tags: ghcr.io/aet-devops26/team-devvopps/llm-service:latest - name: Build and Push course-seeder uses: docker/build-push-action@v5 diff --git a/compose.azure.yml b/compose.azure.yml index 4953b49..b263807 100644 --- a/compose.azure.yml +++ b/compose.azure.yml @@ -67,6 +67,7 @@ services: DB_USER: postgres DB_PASSWORD: postgres USER_SERVICE_HOST: user-service + LLM_SERVICE_HOST: llm-service depends_on: postgres: condition: service_healthy @@ -107,6 +108,17 @@ services: - "traefik.http.routers.client.tls.certresolver=letsencrypt" - "traefik.http.middlewares.client-compress.compress=true" - "traefik.http.routers.client.middlewares=client-compress" + + 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/llm-service/deployment.yaml b/helm/team-devvopps/templates/llm-service/deployment.yaml new file mode 100644 index 0000000..a364494 --- /dev/null +++ b/helm/team-devvopps/templates/llm-service/deployment.yaml @@ -0,0 +1,39 @@ +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 + - name: COURSE_SERVICE_HOST + value: course-service + - name: LLM_API_URL + value: http://llm-backend:1234/v1/chat/completions + readinessProbe: + httpGet: + path: /actuator/health + port: {{ .Values.services.llmService.port }} + initialDelaySeconds: 30 + periodSeconds: 10 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..dd09fa7 100644 --- a/helm/team-devvopps/values.yaml +++ b/helm/team-devvopps/values.yaml @@ -34,4 +34,6 @@ services: port: 8080 client: port: 80 + llmService: + port: 8084 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/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..f0e8134 --- /dev/null +++ b/server/llm-service/main.py @@ -0,0 +1,325 @@ +import os +import json +import requests +from typing import Dict, Any, List, Optional +from fastapi import FastAPI, HTTPException +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 + +# Environment configuration: pick the upstream provider based on env. +# +# If LOGOS_API_KEY is set we point at the TUM Logos endpoint and default +# to the openai/gpt-oss-120b model. Otherwise we default to LM Studio +# running on the host (host.docker.internal:1234 from inside Docker on +# macOS/Windows) with gemma-4-e2b. The LM Studio path can still be +# overridden by setting LLM_API_URL / LLM_MODEL explicitly. +LOGOS_API_KEY = os.getenv("LOGOS_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 +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") + +COURSE_SERVICE_URL = os.getenv("COURSE_SERVICE_URL", "http://course-service:8082/courses") + +# Create FastAPI application instance +app = FastAPI( + title="LLM Recommendation Service", + description="Service that generates personalized food recommendations using an LLM", + version="1.0.0" +) + + +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[str] = Field(default=[], description="Learning milestones") + tasks: List[str] = Field(default=[], description="Concrete tasks to complete the 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=30 + ) + 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)}") + + +# Initialize the LLM +KEYWORD_PROMPT = """ +You are a keyword extraction engine for an academic course recommendation system. + +Extract the most relevant search keywords from the student's learning goal. + +Rules: +- Return ONLY valid JSON +- No markdown, no explanation +- Keywords should be short (1–3 words) +- Focus on technical concepts, skills, and domains +- Do NOT repeat stopwords or generic words like "learn", "study" + +Student goal: +{goal} + +Return format: +{ + "keywords": [ + "keyword 1", + "keyword 2", + "keyword 3" + ] +} +""" + + +_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 (e.g. "Take course: Linear Algebra"). +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: +""" + +keyword_chain = PromptTemplate( + input_variables=["goal"], + template=KEYWORD_PROMPT, +) | OpenAICompatibleLLM() + +chain = PromptTemplate( + input_variables=["goal", "courses"], + template=_PROMPT, +) | OpenAICompatibleLLM() + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- +def parse_keywords(raw: str) -> List[str]: + try: + cleaned = ( + raw.strip() + .removeprefix("```json") + .removeprefix("```") + .removesuffix("```") + .strip() + ) + + data = json.loads(cleaned) + keywords = data.get("keywords", []) + + if not isinstance(keywords, list): + return [] + + return [k.strip() for k in keywords if isinstance(k, str) and k.strip()] + + except Exception: + return [] + +async def extract_keywords_llm(goal: str) -> List[str]: + raw = await keyword_chain.ainvoke({"goal": goal}) + return parse_keywords(raw) + +def search_course_by_keyword(keyword: str) -> List[Dict[str, Any]]: + try: + resp = requests.get( + f"{COURSE_SERVICE_URL}/search", + params={"title": keyword}, + timeout=10 + ) + resp.raise_for_status() + data = resp.json() + + # backend returns single Course → wrap into list + return [data] if isinstance(data, dict) else [] + + except Exception as e: + print(f"Warning: search failed for '{keyword}': {e}") + return [] + + +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"} + + +@app.post("/generate", response_model=RoadmapResponse) +async def generate(req: RoadmapRequest) -> RoadmapResponse: + if not req.goal.strip(): + raise HTTPException(status_code=422, detail="goal cannot be empty") + + # Extract keywords + keywords = await extract_keywords_llm(req.goal) + + # Search courses from course-service + all_courses = [] + for kw in keywords: + results = search_course_by_keyword(kw) + all_courses.extend(results) + + # Deduplicate by course_id + unique_courses = { + c["course_id"]: c for c in all_courses if "course_id" in c + }.values() + + # Convert enriched course data into LLM input + courses_str = "\n".join( + f"- {c.get('title')} | {c.get('content','')[:200]}" + for c in unique_courses + ) + + # fallback if nothing found + if not courses_str.strip(): + courses_str = "- No matching courses found" + + # 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": "1.0.0", + "description": "Generates personalized roadmaps using LangChain against an OpenAI-compatible LLM endpoint (e.g. LM Studio).", + "endpoints": { + "health": "/health", + "generate": "/generate", + } + } + +# 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) \ No newline at end of file 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/service/RoadmapService.java b/server/roadmap-service/src/main/java/com/tum/roadmap/service/RoadmapService.java index 654fe1b..9e3dc19 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 + "/generate", + new RoadmapRequest(goal), + RoadmapResponse.class + ); + + } catch (Exception e) { + System.err.println("LLM service not reachable: " + e.getMessage()); + return null; + } + } } From 84cd36ca5cab481ec311a980c1d1018e18ad47ea Mon Sep 17 00:00:00 2001 From: ge64rov Date: Thu, 11 Jun 2026 17:00:58 +0200 Subject: [PATCH 2/7] fixed deployment.yml file of llm-service for helm --- helm/team-devvopps/templates/llm-service/deployment.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/helm/team-devvopps/templates/llm-service/deployment.yaml b/helm/team-devvopps/templates/llm-service/deployment.yaml index a364494..fb99c9b 100644 --- a/helm/team-devvopps/templates/llm-service/deployment.yaml +++ b/helm/team-devvopps/templates/llm-service/deployment.yaml @@ -27,6 +27,7 @@ spec: limits: cpu: 200m memory: 512Mi + env: - name: COURSE_SERVICE_HOST value: course-service - name: LLM_API_URL From ea00da639d91dada4076ba188ed00ec49ce7c59e Mon Sep 17 00:00:00 2001 From: hafizenursahbudak Date: Sun, 14 Jun 2026 22:51:16 +0300 Subject: [PATCH 3/7] fix server deployment issue. --- .github/workflows/build_and_deploy_docker_VM.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build_and_deploy_docker_VM.yml b/.github/workflows/build_and_deploy_docker_VM.yml index a97ddb8..afba2a4 100644 --- a/.github/workflows/build_and_deploy_docker_VM.yml +++ b/.github/workflows/build_and_deploy_docker_VM.yml @@ -72,8 +72,8 @@ jobs: - name: Build and Push llm-service uses: docker/build-push-action@v5 with: - context: ./llm-service - file: ./llm-service/Dockerfile + context: ./server/llm-service + file: ./server/llm-service/Dockerfile push: true tags: ghcr.io/aet-devops26/team-devvopps/llm-service:latest From db31c225f05314fcc505c5b4ee41ab0de74f0ff0 Mon Sep 17 00:00:00 2001 From: hafizenursahbudak Date: Mon, 15 Jun 2026 00:53:47 +0300 Subject: [PATCH 4/7] Add v1 ui and groq logic api to llm. --- .github/workflows/deploy-k8s.yml | 4 + client/.env.production | 1 + client/package-lock.json | 36 +-- client/src/App.tsx | 12 +- client/src/index.css | 4 + client/src/pages/RoadmapChat.tsx | 247 ++++++++++++++++++ helm/team-devvopps/templates/ingress.yaml | 7 + .../templates/llm-service/deployment.yaml | 22 +- .../templates/llm-service/secret.yaml | 9 + helm/team-devvopps/values.yaml | 4 + server/llm-service/main.py | 239 ++++++++--------- server/llm-service/requirements.txt | 6 + 12 files changed, 428 insertions(+), 163 deletions(-) create mode 100644 client/.env.production create mode 100644 client/src/pages/RoadmapChat.tsx create mode 100644 helm/team-devvopps/templates/llm-service/secret.yaml create mode 100644 server/llm-service/requirements.txt diff --git a/.github/workflows/deploy-k8s.yml b/.github/workflows/deploy-k8s.yml index 5204fd0..edb171e 100644 --- a/.github/workflows/deploy-k8s.yml +++ b/.github/workflows/deploy-k8s.yml @@ -62,11 +62,15 @@ jobs: 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 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..c016a0c --- /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}/generate`, { + 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/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 index fb99c9b..55e68c2 100644 --- a/helm/team-devvopps/templates/llm-service/deployment.yaml +++ b/helm/team-devvopps/templates/llm-service/deployment.yaml @@ -28,13 +28,25 @@ spec: cpu: 200m memory: 512Mi env: - - name: COURSE_SERVICE_HOST - value: course-service - - name: LLM_API_URL - value: http://llm-backend:1234/v1/chat/completions + - 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: /actuator/health + 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/values.yaml b/helm/team-devvopps/values.yaml index dd09fa7..45c4bd7 100644 --- a/helm/team-devvopps/values.yaml +++ b/helm/team-devvopps/values.yaml @@ -37,3 +37,7 @@ services: llmService: port: 8084 +# LLM Service API keys (override in values-aet.yaml or via --set) +llmService: + groqApiKey: "" + logosApiKey: "" diff --git a/server/llm-service/main.py b/server/llm-service/main.py index f0e8134..eab3e45 100644 --- a/server/llm-service/main.py +++ b/server/llm-service/main.py @@ -1,12 +1,18 @@ import os import json import requests -from typing import Dict, Any, List, Optional +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 # Environment configuration: pick the upstream provider based on env. # @@ -16,6 +22,8 @@ # macOS/Windows) with gemma-4-e2b. The LM Studio path can still be # overridden by setting LLM_API_URL / LLM_MODEL explicitly. 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 @@ -23,6 +31,11 @@ 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. @@ -32,12 +45,85 @@ LLM_API_KEY = os.getenv("LLM_API_KEY") or os.getenv("CHAIR_API_KEY") COURSE_SERVICE_URL = os.getenv("COURSE_SERVICE_URL", "http://course-service:8082/courses") +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 + + 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 food recommendations using an LLM", - version="1.0.0" + description="Service that generates personalized learning roadmaps using an LLM", + version="2.0.0", + lifespan=lifespan, +) + +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_methods=["*"], + allow_headers=["*"], ) @@ -52,8 +138,7 @@ class RoadmapResponse(BaseModel): """ Response schema for generate endpoint. """ - milestones: List[str] = Field(default=[], description="Learning milestones") - tasks: List[str] = Field(default=[], description="Concrete tasks to complete the milestones") + milestones: List[Any] = Field(default=[], description="Learning milestones") class OpenAICompatibleLLM(LLM): @@ -82,107 +167,75 @@ def _call( } 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=30 + 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)}") -# Initialize the LLM -KEYWORD_PROMPT = """ -You are a keyword extraction engine for an academic course recommendation system. - -Extract the most relevant search keywords from the student's learning goal. - -Rules: -- Return ONLY valid JSON -- No markdown, no explanation -- Keywords should be short (1–3 words) -- Focus on technical concepts, skills, and domains -- Do NOT repeat stopwords or generic words like "learn", "study" - -Student goal: -{goal} - -Return format: -{ - "keywords": [ - "keyword 1", - "keyword 2", - "keyword 3" - ] -} -""" - - _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 (e.g. "Take course: Linear Algebra"). +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: """ -keyword_chain = PromptTemplate( - input_variables=["goal"], - template=KEYWORD_PROMPT, -) | OpenAICompatibleLLM() - chain = PromptTemplate( input_variables=["goal", "courses"], template=_PROMPT, @@ -191,49 +244,6 @@ def _call( # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- -def parse_keywords(raw: str) -> List[str]: - try: - cleaned = ( - raw.strip() - .removeprefix("```json") - .removeprefix("```") - .removesuffix("```") - .strip() - ) - - data = json.loads(cleaned) - keywords = data.get("keywords", []) - - if not isinstance(keywords, list): - return [] - - return [k.strip() for k in keywords if isinstance(k, str) and k.strip()] - - except Exception: - return [] - -async def extract_keywords_llm(goal: str) -> List[str]: - raw = await keyword_chain.ainvoke({"goal": goal}) - return parse_keywords(raw) - -def search_course_by_keyword(keyword: str) -> List[Dict[str, Any]]: - try: - resp = requests.get( - f"{COURSE_SERVICE_URL}/search", - params={"title": keyword}, - timeout=10 - ) - resp.raise_for_status() - data = resp.json() - - # backend returns single Course → wrap into list - return [data] if isinstance(data, dict) else [] - - except Exception as e: - print(f"Warning: search failed for '{keyword}': {e}") - return [] - - def parse_llm_response(raw: str) -> RoadmapResponse: """ Parses the LLM JSON output into a RoadmapResponse. @@ -262,7 +272,7 @@ def parse_llm_response(raw: str) -> RoadmapResponse: @app.get("/health") async def health_check(): """Health check endpoint.""" - return {"status": "healthy", "service": "LLM Roadmap Generation Service"} + return {"status": "healthy", "service": "LLM Roadmap Generation Service", "model": MODEL_NAME} @app.post("/generate", response_model=RoadmapResponse) @@ -270,29 +280,8 @@ async def generate(req: RoadmapRequest) -> RoadmapResponse: if not req.goal.strip(): raise HTTPException(status_code=422, detail="goal cannot be empty") - # Extract keywords - keywords = await extract_keywords_llm(req.goal) - - # Search courses from course-service - all_courses = [] - for kw in keywords: - results = search_course_by_keyword(kw) - all_courses.extend(results) - - # Deduplicate by course_id - unique_courses = { - c["course_id"]: c for c in all_courses if "course_id" in c - }.values() - - # Convert enriched course data into LLM input - courses_str = "\n".join( - f"- {c.get('title')} | {c.get('content','')[:200]}" - for c in unique_courses - ) - - # fallback if nothing found - if not courses_str.strip(): - courses_str = "- No matching courses found" + # Use TF-IDF to find the most relevant courses (replaces keyword search) + courses_str = filter_courses(req.goal) # Call LLM try: @@ -311,8 +300,8 @@ async def root(): """Root endpoint with service information.""" return { "service": "LLM Roadmap Service", - "version": "1.0.0", - "description": "Generates personalized roadmaps using LangChain against an OpenAI-compatible LLM endpoint (e.g. LM Studio).", + "version": "2.0.0", + "description": "Generates personalized roadmaps using TF-IDF filtering + LLM.", "endpoints": { "health": "/health", "generate": "/generate", @@ -322,4 +311,4 @@ async def root(): # 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) \ No newline at end of file + 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 From 8af1b765283851ae713502950bd214de4662dfcf Mon Sep 17 00:00:00 2001 From: hafizenursahbudak Date: Mon, 15 Jun 2026 01:17:06 +0300 Subject: [PATCH 5/7] Change langchain prompt chain api. --- client/src/pages/RoadmapChat.tsx | 2 +- server/llm-service/main.py | 6 +++--- .../main/java/com/tum/roadmap/service/RoadmapService.java | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/client/src/pages/RoadmapChat.tsx b/client/src/pages/RoadmapChat.tsx index c016a0c..68e2119 100644 --- a/client/src/pages/RoadmapChat.tsx +++ b/client/src/pages/RoadmapChat.tsx @@ -32,7 +32,7 @@ export default function RoadmapChat() { setRoadmap(null); try { - const res = await fetch(`${LLM_SERVICE_URL}/generate`, { + const res = await fetch(`${LLM_SERVICE_URL}/recommend`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ goal }), diff --git a/server/llm-service/main.py b/server/llm-service/main.py index eab3e45..a9c173b 100644 --- a/server/llm-service/main.py +++ b/server/llm-service/main.py @@ -275,8 +275,8 @@ async def health_check(): return {"status": "healthy", "service": "LLM Roadmap Generation Service", "model": MODEL_NAME} -@app.post("/generate", response_model=RoadmapResponse) -async def generate(req: RoadmapRequest) -> RoadmapResponse: +@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") @@ -304,7 +304,7 @@ async def root(): "description": "Generates personalized roadmaps using TF-IDF filtering + LLM.", "endpoints": { "health": "/health", - "generate": "/generate", + "recommend": "/recommend", } } 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 9e3dc19..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 @@ -126,7 +126,7 @@ private RoadmapResponse callLLM(String goal) { RestTemplate rt = new RestTemplate(); return rt.postForObject( - LLM_URL + "/generate", + LLM_URL + "/recommend", new RoadmapRequest(goal), RoadmapResponse.class ); From 87d2cebf625da2fda4930203f0d36204ac19295c Mon Sep 17 00:00:00 2001 From: ge64rov Date: Mon, 22 Jun 2026 12:47:45 +0200 Subject: [PATCH 6/7] - build and deploy docker images scripts seperately - fixed postgres image version to 17.5 - added proper error handling in RoadmapService - added caching - added comments to workflow files, ansible script, GatewayController, compose.azure.yml, llm-service/main.py --- ...ild_and_deploy_docker_VM.yml => build.yml} | 73 ++++++++----------- .github/workflows/deploy-k8s.yml | 14 ++++ .github/workflows/deploy-vm.yml | 64 ++++++++++++++++ .github/workflows/lint.yml | 9 +++ .github/workflows/provision.yml | 18 ++++- ansible/playbook.yml | 9 ++- compose.azure.yml | 19 ++++- .../com/tum/gateway/GatewayController.java | 26 +++++++ server/compose.yaml | 2 +- server/llm-service/main.py | 21 ++++-- .../java/com/tum/roadmap/model/Milestone.java | 10 ++- .../java/com/tum/roadmap/model/Roadmap.java | 10 ++- .../main/java/com/tum/roadmap/model/Task.java | 9 ++- 13 files changed, 223 insertions(+), 61 deletions(-) rename .github/workflows/{build_and_deploy_docker_VM.yml => build.yml} (60%) create mode 100644 .github/workflows/deploy-vm.yml diff --git a/.github/workflows/build_and_deploy_docker_VM.yml b/.github/workflows/build.yml similarity index 60% rename from .github/workflows/build_and_deploy_docker_VM.yml rename to .github/workflows/build.yml index afba2a4..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,8 @@ 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 @@ -76,6 +98,8 @@ jobs: 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 @@ -84,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 edb171e..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,6 +66,9 @@ 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' }} @@ -75,6 +86,7 @@ jobs: --wait \ --timeout 5m + # Quick sanity check — prints pod/service/ingress state immediately after deploy. - name: Verify deployment run: | echo "Checking pod status..." @@ -86,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..22b56fa --- /dev/null +++ b/.github/workflows/deploy-vm.yml @@ -0,0 +1,64 @@ +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: + needs: build + 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/compose.azure.yml b/compose.azure.yml index b263807..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: @@ -75,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: @@ -93,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: @@ -109,6 +118,8 @@ services: - "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: 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/main.py b/server/llm-service/main.py index a9c173b..a4df355 100644 --- a/server/llm-service/main.py +++ b/server/llm-service/main.py @@ -14,13 +14,15 @@ from sklearn.metrics.pairwise import cosine_similarity import numpy as np -# Environment configuration: pick the upstream provider based on env. -# -# If LOGOS_API_KEY is set we point at the TUM Logos endpoint and default -# to the openai/gpt-oss-120b model. Otherwise we default to LM Studio -# running on the host (host.docker.internal:1234 from inside Docker on -# macOS/Windows) with gemma-4-e2b. The LM Studio path can still be -# overridden by setting LLM_API_URL / LLM_MODEL explicitly. +# --------------------------------------------------------------------------- +# 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") @@ -44,7 +46,10 @@ # 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")) # --------------------------------------------------------------------------- @@ -67,6 +72,7 @@ def build_index() -> int: 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", "") @@ -119,6 +125,7 @@ async def lifespan(app: FastAPI): lifespan=lifespan, ) +# Allow all origins so the frontend and API gateway can call this service freely. app.add_middleware( CORSMiddleware, allow_origins=["*"], 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) */ From 66202ceb8c24b8f29608900fee0f0dcf929c144c Mon Sep 17 00:00:00 2001 From: ge64rov Date: Mon, 22 Jun 2026 12:49:12 +0200 Subject: [PATCH 7/7] fix deploy vm script --- .github/workflows/deploy-vm.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/deploy-vm.yml b/.github/workflows/deploy-vm.yml index 22b56fa..c6fabfd 100644 --- a/.github/workflows/deploy-vm.yml +++ b/.github/workflows/deploy-vm.yml @@ -18,7 +18,6 @@ permissions: jobs: deploy: - needs: build runs-on: ubuntu-latest steps: