Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion docs/CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,4 +43,8 @@ These are all environment variables that can be used to configure the applicatio
| INACTIVE_USER_DAYS | server | 365 | Number of days of inactivity after which student accounts are automatically disabled. |
| LIQUIBASE_CONTEXTS | server | prod | Comma-separated list of Liquibase contexts to execute on startup. **Must be `prod` (or unset) in production** — the value `dev` activates the `23_seed_dev_test_data.xml` changelog, which seeds fake users, topics, applications, and theses, and `application-dev.yml` already sets this to `dev` for the dev profile. Never set this to `dev` (or include `dev`) on a production deployment. |
| CHAIR_NAME | client | Thesis Management | Chair name |
| CHAIR_URL | client | window.origin | URL to chair website |
| CHAIR_URL | client | window.origin | URL to chair website |
| AI_FEATURES_ENABLED | server | false | Master switch for the AI feedback module (`/v2/ai-review/**`, `ReviewService`, `PdfService`). **Work in progress — not yet fully integrated or guaranteed to work.** Leave at `false` in production. When `true`, also set `OPENAI_API_KEY` / `OPENAI_BASE_URL` / `OPENAI_CHAT_MODEL` to point at a reachable LLM endpoint. Enabled by default in `application-dev.yml` for local development. |
| OPENAI_API_KEY | server | | API key used by Spring AI's OpenAI client. Only consulted when `AI_FEATURES_ENABLED=true`; the prod default placeholder exists only so the OpenAI auto-configuration can construct its bean. |
| OPENAI_BASE_URL | server | https://gpu.aet.cit.tum.de/api | Base URL of the OpenAI-compatible endpoint used by the AI feedback module. |
| OPENAI_CHAT_MODEL | server | google/gemma-4-26B-A4B-it | Chat model name passed to the OpenAI-compatible endpoint. |
18 changes: 18 additions & 0 deletions docs/DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,24 @@ Open the Mailpit web UI to browse captured emails:

All emails (including attachments) sent by the application are available there for inspection. This replaces the previous console-only logging approach and makes it easy to verify email content, formatting, and recipients during development and testing.

## AI Feedback (Experimental)

> **Status: work in progress.** The AI feedback module (`/v2/ai-review/**`, `ReviewService`, `PdfService`) is being developed and is **not necessarily working or integrated end-to-end yet**. Treat it as an opt-in preview, not a supported feature.

The module is gated by a single feature flag implemented as a Spring `Condition` (`de.tum.cit.aet.thesis.feedback.config.AIFeaturesEnabled`). When the flag is off, the controller and services are not registered with the application context and the `/v2/ai-review/**` endpoints return 404.

| Setting | Default | Notes |
|----------------------------------|---------|--------------------------------------------------------------------------------------|
| `thesis-management.ai.enabled` | `false` | Master switch. Env var: `AI_FEATURES_ENABLED`. Set to `true` in `application-dev.yml`. |
| `spring.ai.openai.api-key` | — | Env var: `OPENAI_API_KEY`. Required when the flag is on so Spring AI can build the chat model. |
| `spring.ai.openai.base-url` | `https://gpu.aet.cit.tum.de/api` | Env var: `OPENAI_BASE_URL`. Point at any OpenAI-compatible endpoint. |
| `spring.ai.openai.chat.model` | `google/gemma-4-26B-A4B-it` | Env var: `OPENAI_CHAT_MODEL`. |

Notes for local dev:
- The `dev` profile already sets `thesis-management.ai.enabled: true`, so `./gradlew bootRun --args='--spring.profiles.active=dev'` exposes the endpoints. You still need to point `spring.ai.openai.*` at a reachable LLM (e.g. a local model on `http://localhost:1234/v1`) for actual responses.
- The `test` profile keeps the flag off by default; the only test that exercises the controller (`ReviewControllerTest`) re-enables it via `@TestPropertySource`.
- Spring AI's `OpenAiChatAutoConfiguration` instantiates `openAiChatModel` eagerly whenever the Spring AI jars are on the classpath, regardless of this flag. That is why `application.yml` ships a non-empty `OPENAI_API_KEY` placeholder and the test config sets a stub key — neither is used unless the flag is on, but both are needed so the autoconfig can build the bean.

## Database Migrations (Liquibase)

Liquibase migrations run automatically when the server starts. All migrations are defined under `server/src/main/resources/db/changelog/changes`.
Expand Down
4 changes: 4 additions & 0 deletions server/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,10 @@ dependencies {
implementation "org.springframework.boot:spring-boot-starter-thymeleaf"
implementation "org.springframework.boot:spring-boot-starter-actuator"

implementation platform("org.springframework.ai:spring-ai-bom:2.0.0")
implementation "org.springframework.ai:spring-ai-pdf-document-reader"
implementation 'org.springframework.ai:spring-ai-starter-model-openai'
Comment thread
bensofficial marked this conversation as resolved.

developmentOnly "org.springframework.boot:spring-boot-devtools"

implementation "org.hibernate.orm:hibernate-core"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package de.tum.cit.aet.thesis.feedback.config;

import org.springframework.context.annotation.Condition;
import org.springframework.context.annotation.ConditionContext;
import org.springframework.core.type.AnnotatedTypeMetadata;

public class AIFeaturesEnabled implements Condition {
public static final String PROPERTY = "thesis-management.ai.enabled";

@Override
public boolean matches(ConditionContext context, AnnotatedTypeMetadata metadata) {
return Boolean.TRUE.equals(
context.getEnvironment().getProperty(PROPERTY, Boolean.class, false));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
package de.tum.cit.aet.thesis.feedback.controller;

import de.tum.cit.aet.thesis.feedback.config.AIFeaturesEnabled;
import de.tum.cit.aet.thesis.feedback.dto.ProviderCategory;
import de.tum.cit.aet.thesis.feedback.dto.ReviewRequestDTO;
import de.tum.cit.aet.thesis.feedback.dto.ReviewResultDTO;
import de.tum.cit.aet.thesis.feedback.service.ReviewService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Conditional;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;

/** REST controller for AI generated feedback. */
@Slf4j
@RestController
@RequestMapping("/v2/ai-review")
@Conditional(AIFeaturesEnabled.class)
public class ReviewController {
private final ReviewService reviewService;

/**
* Creates the controller with its review service collaborator.
*
* @param reviewService service that runs the AI review pipeline
*/
public ReviewController(ReviewService reviewService) {
this.reviewService = reviewService;
}

/**
* Runs the AI review pipeline against an uploaded proposal PDF.
*
* @param request multipart payload containing the proposal file and the provider category
* @return the merged review result produced by the LLM pipeline
*/
@PostMapping(value = "review-proposal", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@PreAuthorize("hasAnyRole('admin', 'advisor', 'supervisor')")
public ResponseEntity<ReviewResultDTO> reviewProposal(@ModelAttribute ReviewRequestDTO request) {
// TODO: Use already uploaded file from the thesis service instead of uploading it again

if (request.providerCategory().equals(ProviderCategory.AZURE)) {
Comment thread
bensofficial marked this conversation as resolved.
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Azure provider is not supported yet.");
}

ReviewResultDTO reviewResult = reviewService.review(request);
return ResponseEntity.ok().body(reviewResult);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package de.tum.cit.aet.thesis.feedback.dto;

import com.fasterxml.jackson.annotation.JsonProperty;

public enum AssessmentCategory {
@JsonProperty("good")
GOOD,
@JsonProperty("acceptable")
ACCEPTABLE,
@JsonProperty("needs-work")
NEEDS_WORK
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package de.tum.cit.aet.thesis.feedback.dto;

import java.util.List;

public record FindingDTO(String severity, String category, String title, String description, List<Location> locations) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package de.tum.cit.aet.thesis.feedback.dto;

import java.util.List;

public record IntermediateReviewResult(List<FindingDTO> findings) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
package de.tum.cit.aet.thesis.feedback.dto;

public record Location(Integer page, String section, String quote) {
}
Comment thread
bensofficial marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package de.tum.cit.aet.thesis.feedback.dto;

public enum ProviderCategory {
AZURE, LOCAL;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package de.tum.cit.aet.thesis.feedback.dto;

import org.springframework.web.multipart.MultipartFile;

public record ReviewRequestDTO(ProviderCategory providerCategory, MultipartFile file) {
Comment thread
bensofficial marked this conversation as resolved.
Outdated
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package de.tum.cit.aet.thesis.feedback.dto;

import java.util.List;

public record ReviewResultDTO(AssessmentCategory category, String summary, List<FindingDTO> findings) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
package de.tum.cit.aet.thesis.feedback.service;

import de.tum.cit.aet.thesis.feedback.config.AIFeaturesEnabled;
import org.apache.pdfbox.Loader;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.rendering.PDFRenderer;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.ai.content.Media;
import org.springframework.ai.document.Document;
import org.springframework.ai.reader.pdf.PagePdfDocumentReader;
import org.springframework.ai.reader.pdf.config.PdfDocumentReaderConfig;
import org.springframework.context.annotation.Conditional;
import org.springframework.core.io.ByteArrayResource;
import org.springframework.stereotype.Service;
import org.springframework.util.MimeTypeUtils;
import org.springframework.web.multipart.MultipartFile;

import javax.imageio.ImageIO;

import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;

/**
* Extracts per-page text and renders per-page PNG images from an uploaded PDF so the LLM
* pipeline can reason about both modalities.
*/
@Service
@Conditional(AIFeaturesEnabled.class)
public class PdfService {
private static final Logger log = LoggerFactory.getLogger(PdfService.class);

/**
* Extracts the text content of each page of the uploaded PDF.
*
* @param file uploaded PDF file
* @return one string per page in document order
*/
public List<String> extractTextFromPdf(MultipartFile file) {
log.debug("Extracting text from PDF file: {}", file.getOriginalFilename());
ByteArrayResource resource;
try {
resource = new ByteArrayResource(file.getBytes());
} catch (IOException e) {
throw new RuntimeException("Failed to extract text of file", e);
}

PdfDocumentReaderConfig config = PdfDocumentReaderConfig.builder().withPagesPerDocument(1).build();
PagePdfDocumentReader reader = new PagePdfDocumentReader(resource, config);

List<Document> docs = reader.read();
return docs.stream().map(Document::getText).toList();
Comment thread
bensofficial marked this conversation as resolved.
}

/**
* Renders each page of the uploaded PDF to a PNG image at 300 DPI.
*
* @param file uploaded PDF file
* @return one PNG-encoded {@link Media} per page in document order
*/
public List<Media> extractImagesFromPdf(MultipartFile file) {
log.debug("Extracting images from PDF file: {}", file.getOriginalFilename());
List<Media> images = new ArrayList<>();

try (PDDocument document = Loader.loadPDF(file.getBytes())) {
PDFRenderer renderer = new PDFRenderer(document);

for (int page = 0; page < document.getNumberOfPages(); page++) {
Comment thread
bensofficial marked this conversation as resolved.
var image = renderer.renderImageWithDPI(page, 300);

ByteArrayOutputStream stream = new ByteArrayOutputStream();
ImageIO.write(image, "png", stream);
byte[] imageBytes = stream.toByteArray();

ByteArrayResource resource = new ByteArrayResource(imageBytes);

Media media = Media.builder().mimeType(MimeTypeUtils.IMAGE_PNG).data(resource).name("page_" + (page + 1) + ".png").build();

images.add(media);
}
} catch (IOException e) {
throw new RuntimeException("Failed to extract images of file", e);
}

return images;
}
}
Loading
Loading