Previous Module Versions
+
> getAllGuidelines() {
+ return ResponseEntity.ok(aiReviewGuidelineService.getAllGuidelines());
+ }
+
+ @GetMapping("/{id}")
+ public ResponseEntity getGuideline(@PathVariable Long id) {
+ return ResponseEntity.ok(aiReviewGuidelineService.getGuideline(id));
+ }
+
+ @PostMapping
+ public ResponseEntity createGuideline(
+ @CurrentUser User user,
+ @Valid @RequestBody CreateAiReviewGuidelineDTO dto) {
+ return ResponseEntity.ok(aiReviewGuidelineService.createGuideline(user, dto));
+ }
+
+ @PutMapping("/{id}")
+ public ResponseEntity updateGuideline(
+ @CurrentUser User user,
+ @PathVariable Long id,
+ @Valid @RequestBody UpdateAiReviewGuidelineDTO dto) {
+ return ResponseEntity.ok(aiReviewGuidelineService.updateGuideline(id, user, dto));
+ }
+
+ @DeleteMapping("/{id}")
+ public ResponseEntity deleteGuideline(@PathVariable Long id) {
+ aiReviewGuidelineService.deleteGuideline(id);
+ return ResponseEntity.noContent().build();
+ }
+}
diff --git a/Server/src/main/java/modulemanagement/ls1/controllers/ModuleVersionController.java b/Server/src/main/java/modulemanagement/ls1/controllers/ModuleVersionController.java
index 97cb8b44..1b6b9d6b 100644
--- a/Server/src/main/java/modulemanagement/ls1/controllers/ModuleVersionController.java
+++ b/Server/src/main/java/modulemanagement/ls1/controllers/ModuleVersionController.java
@@ -5,9 +5,11 @@
import modulemanagement.ls1.dtos.CompletionServiceResponseDTO;
import modulemanagement.ls1.dtos.CompletionServiceRequestDTO;
import modulemanagement.ls1.dtos.ModuleVersionViewFeedbackDTO;
+import modulemanagement.ls1.dtos.ProposalAiReviewDTO;
import modulemanagement.ls1.dtos.SimilarModuleDTO;
import modulemanagement.ls1.models.User;
import modulemanagement.ls1.services.LLMGenerationService;
+import modulemanagement.ls1.services.ProposalAiReviewService;
import modulemanagement.ls1.shared.LLMPromptUtil;
import modulemanagement.ls1.services.ModuleVersionService;
import jakarta.validation.Valid;
@@ -32,11 +34,14 @@ public class ModuleVersionController {
private final ModuleVersionService moduleVersionService;
private final LLMGenerationService llmGenerationService;
+ private final ProposalAiReviewService proposalAiReviewService;
public ModuleVersionController(ModuleVersionService moduleVersionService,
- LLMGenerationService llmGenerationService) {
+ LLMGenerationService llmGenerationService,
+ ProposalAiReviewService proposalAiReviewService) {
this.moduleVersionService = moduleVersionService;
this.llmGenerationService = llmGenerationService;
+ this.proposalAiReviewService = proposalAiReviewService;
}
@PutMapping("/{moduleVersionId}")
@@ -105,6 +110,18 @@ public ResponseEntity generateTeachingMethods(
return ResponseEntity.ok(new CompletionServiceResponseDTO(response));
}
+ @GetMapping("/{moduleVersionId}/ai-review")
+ @PreAuthorize("hasAnyRole('PROFESSOR', 'QUALITY_MANAGEMENT', 'ACADEMIC_PROGRAM_ADVISOR', 'EXAMINATION_BOARD', 'PROGRAM_COORDINATOR', 'SPECIALIZATION_AREA_COORDINATOR')")
+ public ResponseEntity getProposalAiReview(
+ @CurrentUser User user,
+ @PathVariable Long moduleVersionId,
+ @RequestParam(defaultValue = "false") boolean regenerate) {
+ if (regenerate) {
+ log.info("Regenerating AI review for module version {}", moduleVersionId);
+ }
+ return ResponseEntity.ok(proposalAiReviewService.getOrGenerateReview(moduleVersionId, user, regenerate));
+ }
+
@PostMapping("/overlap-detection/check-similarity/{moduleVersionId}")
@PreAuthorize("hasAnyRole('PROFESSOR', 'QUALITY_MANAGEMENT', 'ACADEMIC_PROGRAM_ADVISOR', 'EXAMINATION_BOARD')")
public ResponseEntity> checkSimilarity(@CurrentUser User user,
diff --git a/Server/src/main/java/modulemanagement/ls1/dtos/AiReviewGuidelineDTO.java b/Server/src/main/java/modulemanagement/ls1/dtos/AiReviewGuidelineDTO.java
new file mode 100644
index 00000000..9c6e3eee
--- /dev/null
+++ b/Server/src/main/java/modulemanagement/ls1/dtos/AiReviewGuidelineDTO.java
@@ -0,0 +1,55 @@
+package modulemanagement.ls1.dtos;
+
+import lombok.Data;
+import modulemanagement.ls1.enums.ProposalReviewSection;
+import modulemanagement.ls1.models.AiReviewGuideline;
+
+import java.time.LocalDateTime;
+
+@Data
+public class AiReviewGuidelineDTO {
+ private Long guidelineId;
+ private ProposalReviewSection section;
+ private String title;
+ private String instruction;
+ private int sortOrder;
+ private String createdByUserId;
+ private String createdByDisplayName;
+ private String updatedByUserId;
+ private String updatedByDisplayName;
+ private LocalDateTime createdAt;
+ private LocalDateTime updatedAt;
+
+ public static AiReviewGuidelineDTO fromEntity(AiReviewGuideline entity) {
+ if (entity == null) {
+ return null;
+ }
+ AiReviewGuidelineDTO dto = new AiReviewGuidelineDTO();
+ dto.setGuidelineId(entity.getGuidelineId());
+ dto.setSection(entity.getSection());
+ dto.setTitle(entity.getTitle());
+ dto.setInstruction(entity.getInstruction());
+ dto.setSortOrder(entity.getSortOrder());
+ if (entity.getCreatedBy() != null) {
+ dto.setCreatedByUserId(entity.getCreatedBy().getUserId().toString());
+ dto.setCreatedByDisplayName(formatDisplayName(entity.getCreatedBy()));
+ }
+ if (entity.getUpdatedBy() != null) {
+ dto.setUpdatedByUserId(entity.getUpdatedBy().getUserId().toString());
+ dto.setUpdatedByDisplayName(formatDisplayName(entity.getUpdatedBy()));
+ }
+ dto.setCreatedAt(entity.getCreatedAt());
+ dto.setUpdatedAt(entity.getUpdatedAt());
+ return dto;
+ }
+
+ private static String formatDisplayName(modulemanagement.ls1.models.User user) {
+ String first = user.getFirstName() != null ? user.getFirstName().trim() : "";
+ String last = user.getLastName() != null ? user.getLastName().trim() : "";
+ String combined = (first + " " + last).trim();
+ if (!combined.isEmpty()) {
+ return combined;
+ }
+ return user.getUserName() != null ? user.getUserName() : user.getUserId().toString();
+ }
+}
diff --git a/Server/src/main/java/modulemanagement/ls1/dtos/CreateAiReviewGuidelineDTO.java b/Server/src/main/java/modulemanagement/ls1/dtos/CreateAiReviewGuidelineDTO.java
new file mode 100644
index 00000000..f04b3231
--- /dev/null
+++ b/Server/src/main/java/modulemanagement/ls1/dtos/CreateAiReviewGuidelineDTO.java
@@ -0,0 +1,22 @@
+package modulemanagement.ls1.dtos;
+
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+import jakarta.validation.constraints.Size;
+import lombok.Data;
+import modulemanagement.ls1.enums.ProposalReviewSection;
+
+@Data
+public class CreateAiReviewGuidelineDTO {
+ @NotNull
+ private ProposalReviewSection section;
+
+ @NotBlank
+ @Size(max = 256)
+ private String title;
+
+ @NotBlank
+ private String instruction;
+
+ private Integer sortOrder;
+}
diff --git a/Server/src/main/java/modulemanagement/ls1/dtos/ProposalAiReviewDTO.java b/Server/src/main/java/modulemanagement/ls1/dtos/ProposalAiReviewDTO.java
new file mode 100644
index 00000000..282cd3b3
--- /dev/null
+++ b/Server/src/main/java/modulemanagement/ls1/dtos/ProposalAiReviewDTO.java
@@ -0,0 +1,17 @@
+package modulemanagement.ls1.dtos;
+
+import lombok.Data;
+
+import java.time.LocalDateTime;
+import java.util.ArrayList;
+import java.util.List;
+
+@Data
+public class ProposalAiReviewDTO {
+ private Long moduleVersionId;
+ private String summary;
+ private List sections = new ArrayList<>();
+ private LocalDateTime generatedAt;
+ /** False when no guidelines existed at generation time; review then relies on generic standards only. */
+ private boolean guidelinesConfigured;
+}
diff --git a/Server/src/main/java/modulemanagement/ls1/dtos/ProposalAiReviewSectionDTO.java b/Server/src/main/java/modulemanagement/ls1/dtos/ProposalAiReviewSectionDTO.java
new file mode 100644
index 00000000..74deb79f
--- /dev/null
+++ b/Server/src/main/java/modulemanagement/ls1/dtos/ProposalAiReviewSectionDTO.java
@@ -0,0 +1,14 @@
+package modulemanagement.ls1.dtos;
+
+import lombok.Data;
+import modulemanagement.ls1.enums.AiReviewSeverity;
+import modulemanagement.ls1.enums.ProposalReviewSection;
+
+@Data
+public class ProposalAiReviewSectionDTO {
+ private ProposalReviewSection section;
+ private String sectionLabel;
+ private AiReviewSeverity severity;
+ private String findings;
+ private String suggestions;
+}
diff --git a/Server/src/main/java/modulemanagement/ls1/dtos/UpdateAiReviewGuidelineDTO.java b/Server/src/main/java/modulemanagement/ls1/dtos/UpdateAiReviewGuidelineDTO.java
new file mode 100644
index 00000000..3c57dd07
--- /dev/null
+++ b/Server/src/main/java/modulemanagement/ls1/dtos/UpdateAiReviewGuidelineDTO.java
@@ -0,0 +1,22 @@
+package modulemanagement.ls1.dtos;
+
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+import jakarta.validation.constraints.Size;
+import lombok.Data;
+import modulemanagement.ls1.enums.ProposalReviewSection;
+
+@Data
+public class UpdateAiReviewGuidelineDTO {
+ @NotNull
+ private ProposalReviewSection section;
+
+ @NotBlank
+ @Size(max = 256)
+ private String title;
+
+ @NotBlank
+ private String instruction;
+
+ private Integer sortOrder;
+}
diff --git a/Server/src/main/java/modulemanagement/ls1/enums/AiReviewSeverity.java b/Server/src/main/java/modulemanagement/ls1/enums/AiReviewSeverity.java
new file mode 100644
index 00000000..eabb96c9
--- /dev/null
+++ b/Server/src/main/java/modulemanagement/ls1/enums/AiReviewSeverity.java
@@ -0,0 +1,7 @@
+package modulemanagement.ls1.enums;
+
+public enum AiReviewSeverity {
+ OK,
+ ATTENTION,
+ CRITICAL
+}
diff --git a/Server/src/main/java/modulemanagement/ls1/enums/ProposalReviewSection.java b/Server/src/main/java/modulemanagement/ls1/enums/ProposalReviewSection.java
new file mode 100644
index 00000000..27c233b5
--- /dev/null
+++ b/Server/src/main/java/modulemanagement/ls1/enums/ProposalReviewSection.java
@@ -0,0 +1,37 @@
+package modulemanagement.ls1.enums;
+
+/**
+ * Proposal sections that AI review guidelines can target. Aligns with {@code ModuleVersion} fields
+ * and reviewer feedback dimensions so the LLM can load only relevant rules per field.
+ */
+public enum ProposalReviewSection {
+ GENERAL,
+ TITLE_ENG,
+ TITLE_DE,
+ LEVEL_ENG,
+ LANGUAGE_ENG,
+ FREQUENCY_ENG,
+ CREDITS,
+ DURATION,
+ HOURS_LECTURE,
+ HOURS_EXERCISE,
+ HOURS_PRACTICAL,
+ HOURS_SEMINAR,
+ FIRST_SEMESTER_AVAILABLE,
+ SUCCESSOR_MODULE_NAME,
+ HOURS_TOTAL,
+ HOURS_SELF_STUDY,
+ HOURS_PRESENCE,
+ BULLET_POINTS,
+ EXAMINATION_ACHIEVEMENTS,
+ REPETITION,
+ RECOMMENDED_PREREQUISITES,
+ CONTENT,
+ LEARNING_OUTCOMES,
+ TEACHING_METHODS,
+ MEDIA,
+ LITERATURE,
+ RESPONSIBLES,
+ LV_SWS_LECTURER,
+ DEGREE_PROGRAM_ASSIGNMENTS
+}
diff --git a/Server/src/main/java/modulemanagement/ls1/enums/UserRole.java b/Server/src/main/java/modulemanagement/ls1/enums/UserRole.java
index b80b267c..0b7626cf 100644
--- a/Server/src/main/java/modulemanagement/ls1/enums/UserRole.java
+++ b/Server/src/main/java/modulemanagement/ls1/enums/UserRole.java
@@ -9,5 +9,7 @@ public enum UserRole {
/** User is responsible for at least one degree program; can receive and respond to feedback requests for those. */
PROGRAM_COORDINATOR,
/** User is coordinator for at least one area of specialization; can receive and respond to feedback requests for those. */
- SPECIALIZATION_AREA_COORDINATOR
+ SPECIALIZATION_AREA_COORDINATOR,
+ /** User can maintain shared AI review guidelines applied when generating automated proposal reviews. */
+ AI_REVIEW_GUIDELINE_MANAGER
}
diff --git a/Server/src/main/java/modulemanagement/ls1/models/AiReviewGuideline.java b/Server/src/main/java/modulemanagement/ls1/models/AiReviewGuideline.java
new file mode 100644
index 00000000..31b3356a
--- /dev/null
+++ b/Server/src/main/java/modulemanagement/ls1/models/AiReviewGuideline.java
@@ -0,0 +1,49 @@
+package modulemanagement.ls1.models;
+
+import jakarta.persistence.*;
+import jakarta.validation.constraints.NotNull;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+import modulemanagement.ls1.enums.ProposalReviewSection;
+
+import java.time.LocalDateTime;
+
+@Data
+@NoArgsConstructor
+@Entity
+@Table(name = "ai_review_guideline")
+public class AiReviewGuideline {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ @Column(name = "guideline_id")
+ private Long guidelineId;
+
+ @Enumerated(EnumType.STRING)
+ @Column(name = "section", nullable = false)
+ @NotNull
+ private ProposalReviewSection section;
+
+ @Column(name = "title", nullable = false, length = 256)
+ private String title;
+
+ @Column(name = "instruction", nullable = false, columnDefinition = "CLOB")
+ private String instruction;
+
+ @Column(name = "sort_order", nullable = false)
+ private int sortOrder;
+
+ @ManyToOne(fetch = FetchType.EAGER)
+ @JoinColumn(name = "created_by", nullable = false)
+ private User createdBy;
+
+ @ManyToOne(fetch = FetchType.EAGER)
+ @JoinColumn(name = "updated_by")
+ private User updatedBy;
+
+ @Column(name = "created_at", nullable = false)
+ private LocalDateTime createdAt;
+
+ @Column(name = "updated_at", nullable = false)
+ private LocalDateTime updatedAt;
+}
diff --git a/Server/src/main/java/modulemanagement/ls1/models/ProposalAiReview.java b/Server/src/main/java/modulemanagement/ls1/models/ProposalAiReview.java
new file mode 100644
index 00000000..6dec73bc
--- /dev/null
+++ b/Server/src/main/java/modulemanagement/ls1/models/ProposalAiReview.java
@@ -0,0 +1,47 @@
+package modulemanagement.ls1.models;
+
+import jakarta.persistence.*;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import java.time.LocalDateTime;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Persisted result of an AI proposal review. One row per module version;
+ * regenerating replaces the previous result.
+ */
+@Data
+@NoArgsConstructor
+@Entity
+@Table(name = "proposal_ai_review", uniqueConstraints = @UniqueConstraint(columnNames = { "module_version_id" }))
+public class ProposalAiReview {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ @Column(name = "review_id")
+ private Long reviewId;
+
+ @ManyToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "module_version_id", nullable = false)
+ private ModuleVersion moduleVersion;
+
+ @Column(name = "summary", columnDefinition = "CLOB")
+ private String summary;
+
+ /** Whether any guidelines existed when this review was generated. */
+ @Column(name = "guidelines_configured", nullable = false)
+ private boolean guidelinesConfigured;
+
+ @ManyToOne(fetch = FetchType.EAGER)
+ @JoinColumn(name = "generated_by")
+ private User generatedBy;
+
+ @Column(name = "generated_at", nullable = false)
+ private LocalDateTime generatedAt;
+
+ @OneToMany(mappedBy = "review", cascade = CascadeType.ALL, orphanRemoval = true)
+ @OrderBy("sortOrder ASC")
+ private List sections = new ArrayList<>();
+}
diff --git a/Server/src/main/java/modulemanagement/ls1/models/ProposalAiReviewSection.java b/Server/src/main/java/modulemanagement/ls1/models/ProposalAiReviewSection.java
new file mode 100644
index 00000000..3493ca3c
--- /dev/null
+++ b/Server/src/main/java/modulemanagement/ls1/models/ProposalAiReviewSection.java
@@ -0,0 +1,43 @@
+package modulemanagement.ls1.models;
+
+import jakarta.persistence.*;
+import jakarta.validation.constraints.NotNull;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+import modulemanagement.ls1.enums.AiReviewSeverity;
+import modulemanagement.ls1.enums.ProposalReviewSection;
+
+@Data
+@NoArgsConstructor
+@Entity
+@Table(name = "proposal_ai_review_section")
+public class ProposalAiReviewSection {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ @Column(name = "section_id")
+ private Long sectionId;
+
+ @ManyToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "review_id", nullable = false)
+ private ProposalAiReview review;
+
+ @Enumerated(EnumType.STRING)
+ @Column(name = "section", nullable = false)
+ @NotNull
+ private ProposalReviewSection section;
+
+ @Enumerated(EnumType.STRING)
+ @Column(name = "severity", nullable = false)
+ @NotNull
+ private AiReviewSeverity severity;
+
+ @Column(name = "findings", columnDefinition = "CLOB")
+ private String findings;
+
+ @Column(name = "suggestions", columnDefinition = "CLOB")
+ private String suggestions;
+
+ @Column(name = "sort_order", nullable = false)
+ private int sortOrder;
+}
diff --git a/Server/src/main/java/modulemanagement/ls1/repositories/AiReviewGuidelineRepository.java b/Server/src/main/java/modulemanagement/ls1/repositories/AiReviewGuidelineRepository.java
new file mode 100644
index 00000000..88d1d828
--- /dev/null
+++ b/Server/src/main/java/modulemanagement/ls1/repositories/AiReviewGuidelineRepository.java
@@ -0,0 +1,14 @@
+package modulemanagement.ls1.repositories;
+
+import modulemanagement.ls1.enums.ProposalReviewSection;
+import modulemanagement.ls1.models.AiReviewGuideline;
+import org.springframework.data.jpa.repository.JpaRepository;
+
+import java.util.List;
+
+public interface AiReviewGuidelineRepository extends JpaRepository {
+
+ List findAllByOrderBySectionAscSortOrderAscGuidelineIdAsc();
+
+ List findBySectionOrderBySortOrderAscGuidelineIdAsc(ProposalReviewSection section);
+}
diff --git a/Server/src/main/java/modulemanagement/ls1/repositories/FeedbackRepository.java b/Server/src/main/java/modulemanagement/ls1/repositories/FeedbackRepository.java
index 33b2122d..dc4a5612 100644
--- a/Server/src/main/java/modulemanagement/ls1/repositories/FeedbackRepository.java
+++ b/Server/src/main/java/modulemanagement/ls1/repositories/FeedbackRepository.java
@@ -15,4 +15,7 @@ public interface FeedbackRepository extends JpaRepository {
/** All feedbacks for a proposal that are not invalidated (for display on view/edit). */
List findByModuleVersion_Proposal_ProposalIdAndInvalidatedFalse(Long proposalId);
+
+ boolean existsByModuleVersion_Proposal_ProposalIdAndInvalidatedFalseAndAssignedReviewer_UserId(
+ Long proposalId, UUID userId);
}
diff --git a/Server/src/main/java/modulemanagement/ls1/repositories/ProposalAiReviewRepository.java b/Server/src/main/java/modulemanagement/ls1/repositories/ProposalAiReviewRepository.java
new file mode 100644
index 00000000..fcdd8b0d
--- /dev/null
+++ b/Server/src/main/java/modulemanagement/ls1/repositories/ProposalAiReviewRepository.java
@@ -0,0 +1,11 @@
+package modulemanagement.ls1.repositories;
+
+import modulemanagement.ls1.models.ProposalAiReview;
+import org.springframework.data.jpa.repository.JpaRepository;
+
+import java.util.Optional;
+
+public interface ProposalAiReviewRepository extends JpaRepository {
+
+ Optional findByModuleVersion_ModuleVersionId(Long moduleVersionId);
+}
diff --git a/Server/src/main/java/modulemanagement/ls1/services/AiReviewGuidelineService.java b/Server/src/main/java/modulemanagement/ls1/services/AiReviewGuidelineService.java
new file mode 100644
index 00000000..4140dc89
--- /dev/null
+++ b/Server/src/main/java/modulemanagement/ls1/services/AiReviewGuidelineService.java
@@ -0,0 +1,74 @@
+package modulemanagement.ls1.services;
+
+import modulemanagement.ls1.dtos.AiReviewGuidelineDTO;
+import modulemanagement.ls1.dtos.CreateAiReviewGuidelineDTO;
+import modulemanagement.ls1.dtos.UpdateAiReviewGuidelineDTO;
+import modulemanagement.ls1.models.AiReviewGuideline;
+import modulemanagement.ls1.models.User;
+import modulemanagement.ls1.repositories.AiReviewGuidelineRepository;
+import modulemanagement.ls1.shared.ResourceNotFoundException;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.time.LocalDateTime;
+import java.util.List;
+import java.util.stream.Collectors;
+
+@Service
+public class AiReviewGuidelineService {
+
+ private final AiReviewGuidelineRepository aiReviewGuidelineRepository;
+
+ public AiReviewGuidelineService(AiReviewGuidelineRepository aiReviewGuidelineRepository) {
+ this.aiReviewGuidelineRepository = aiReviewGuidelineRepository;
+ }
+
+ public List getAllGuidelines() {
+ return aiReviewGuidelineRepository.findAllByOrderBySectionAscSortOrderAscGuidelineIdAsc().stream()
+ .map(AiReviewGuidelineDTO::fromEntity)
+ .collect(Collectors.toList());
+ }
+
+ public AiReviewGuidelineDTO getGuideline(Long id) {
+ AiReviewGuideline guideline = aiReviewGuidelineRepository.findById(id)
+ .orElseThrow(() -> new ResourceNotFoundException("AI review guideline not found: " + id));
+ return AiReviewGuidelineDTO.fromEntity(guideline);
+ }
+
+ @Transactional
+ public AiReviewGuidelineDTO createGuideline(User actor, CreateAiReviewGuidelineDTO dto) {
+ LocalDateTime now = LocalDateTime.now();
+ AiReviewGuideline guideline = new AiReviewGuideline();
+ guideline.setSection(dto.getSection());
+ guideline.setTitle(dto.getTitle().trim());
+ guideline.setInstruction(dto.getInstruction().trim());
+ guideline.setSortOrder(dto.getSortOrder() != null ? dto.getSortOrder() : 0);
+ guideline.setCreatedBy(actor);
+ guideline.setUpdatedBy(actor);
+ guideline.setCreatedAt(now);
+ guideline.setUpdatedAt(now);
+ guideline = aiReviewGuidelineRepository.save(guideline);
+ return AiReviewGuidelineDTO.fromEntity(guideline);
+ }
+
+ @Transactional
+ public AiReviewGuidelineDTO updateGuideline(Long id, User actor, UpdateAiReviewGuidelineDTO dto) {
+ AiReviewGuideline guideline = aiReviewGuidelineRepository.findById(id)
+ .orElseThrow(() -> new ResourceNotFoundException("AI review guideline not found: " + id));
+ guideline.setSection(dto.getSection());
+ guideline.setTitle(dto.getTitle().trim());
+ guideline.setInstruction(dto.getInstruction().trim());
+ guideline.setSortOrder(dto.getSortOrder() != null ? dto.getSortOrder() : guideline.getSortOrder());
+ guideline.setUpdatedBy(actor);
+ guideline.setUpdatedAt(LocalDateTime.now());
+ guideline = aiReviewGuidelineRepository.save(guideline);
+ return AiReviewGuidelineDTO.fromEntity(guideline);
+ }
+
+ @Transactional
+ public void deleteGuideline(Long id) {
+ AiReviewGuideline guideline = aiReviewGuidelineRepository.findById(id)
+ .orElseThrow(() -> new ResourceNotFoundException("AI review guideline not found: " + id));
+ aiReviewGuidelineRepository.delete(guideline);
+ }
+}
diff --git a/Server/src/main/java/modulemanagement/ls1/services/ProposalAiReviewService.java b/Server/src/main/java/modulemanagement/ls1/services/ProposalAiReviewService.java
new file mode 100644
index 00000000..a104283e
--- /dev/null
+++ b/Server/src/main/java/modulemanagement/ls1/services/ProposalAiReviewService.java
@@ -0,0 +1,166 @@
+package modulemanagement.ls1.services;
+
+import modulemanagement.ls1.dtos.ProposalAiReviewDTO;
+import modulemanagement.ls1.dtos.ProposalAiReviewSectionDTO;
+import modulemanagement.ls1.enums.UserRole;
+import modulemanagement.ls1.models.AiReviewGuideline;
+import modulemanagement.ls1.models.ModuleVersion;
+import modulemanagement.ls1.models.Proposal;
+import modulemanagement.ls1.models.ProposalAiReview;
+import modulemanagement.ls1.models.ProposalAiReviewSection;
+import modulemanagement.ls1.models.User;
+import modulemanagement.ls1.repositories.AiReviewGuidelineRepository;
+import modulemanagement.ls1.repositories.FeedbackRepository;
+import modulemanagement.ls1.repositories.ModuleVersionRepository;
+import modulemanagement.ls1.repositories.ProposalAiReviewRepository;
+import modulemanagement.ls1.shared.AiReviewGuidelinePromptUtil;
+import modulemanagement.ls1.shared.ProposalAiReviewParser;
+import modulemanagement.ls1.shared.ProposalAiReviewPromptUtil;
+import modulemanagement.ls1.shared.ResourceNotFoundException;
+import org.springframework.http.HttpStatus;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import org.springframework.web.server.ResponseStatusException;
+
+import java.time.LocalDateTime;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Optional;
+
+@Service
+public class ProposalAiReviewService {
+
+ private final ModuleVersionRepository moduleVersionRepository;
+ private final AiReviewGuidelineRepository aiReviewGuidelineRepository;
+ private final FeedbackRepository feedbackRepository;
+ private final ProposalAiReviewRepository proposalAiReviewRepository;
+ private final LLMGenerationService llmGenerationService;
+
+ public ProposalAiReviewService(ModuleVersionRepository moduleVersionRepository,
+ AiReviewGuidelineRepository aiReviewGuidelineRepository,
+ FeedbackRepository feedbackRepository,
+ ProposalAiReviewRepository proposalAiReviewRepository,
+ LLMGenerationService llmGenerationService) {
+ this.moduleVersionRepository = moduleVersionRepository;
+ this.aiReviewGuidelineRepository = aiReviewGuidelineRepository;
+ this.feedbackRepository = feedbackRepository;
+ this.proposalAiReviewRepository = proposalAiReviewRepository;
+ this.llmGenerationService = llmGenerationService;
+ }
+
+ /**
+ * Returns the stored AI review, or generates one if none exists yet.
+ * Pass {@code regenerate=true} to force a fresh LLM run and replace the stored result.
+ */
+ @Transactional
+ public ProposalAiReviewDTO getOrGenerateReview(Long moduleVersionId, User user, boolean regenerate) {
+ if (!regenerate) {
+ requireAccessibleModuleVersion(moduleVersionId, user);
+ Optional stored = proposalAiReviewRepository
+ .findByModuleVersion_ModuleVersionId(moduleVersionId)
+ .map(this::toDto);
+ if (stored.isPresent()) {
+ return stored.get();
+ }
+ }
+ return generateReview(moduleVersionId, user);
+ }
+
+ /** Generates a fresh review, replacing any stored one for this module version. */
+ @Transactional
+ public ProposalAiReviewDTO generateReview(Long moduleVersionId, User user) {
+ ModuleVersion moduleVersion = requireAccessibleModuleVersion(moduleVersionId, user);
+ List guidelines = aiReviewGuidelineRepository
+ .findAllByOrderBySectionAscSortOrderAscGuidelineIdAsc();
+
+ String basePrompt = ProposalAiReviewPromptUtil.buildReviewPrompt(moduleVersion, guidelines);
+ String prompt = ProposalAiReviewPromptUtil.appendSectionGuidelines(basePrompt, guidelines);
+ String llmResponse = llmGenerationService.generate(prompt, "proposal-ai-review");
+
+ ProposalAiReviewDTO dto = ProposalAiReviewParser.parse(llmResponse);
+ dto.setModuleVersionId(moduleVersionId);
+ dto.setGeneratedAt(LocalDateTime.now());
+ dto.setGuidelinesConfigured(!guidelines.isEmpty());
+
+ persistReview(moduleVersion, user, dto);
+ return dto;
+ }
+
+ private void persistReview(ModuleVersion moduleVersion, User user, ProposalAiReviewDTO dto) {
+ ProposalAiReview review = proposalAiReviewRepository
+ .findByModuleVersion_ModuleVersionId(moduleVersion.getModuleVersionId())
+ .orElseGet(ProposalAiReview::new);
+ review.setModuleVersion(moduleVersion);
+ review.setSummary(dto.getSummary());
+ review.setGuidelinesConfigured(dto.isGuidelinesConfigured());
+ review.setGeneratedBy(user);
+ review.setGeneratedAt(dto.getGeneratedAt());
+
+ review.getSections().clear();
+ int order = 0;
+ for (ProposalAiReviewSectionDTO sectionDto : dto.getSections()) {
+ ProposalAiReviewSection section = new ProposalAiReviewSection();
+ section.setReview(review);
+ section.setSection(sectionDto.getSection());
+ section.setSeverity(sectionDto.getSeverity());
+ section.setFindings(sectionDto.getFindings());
+ section.setSuggestions(sectionDto.getSuggestions());
+ section.setSortOrder(order++);
+ review.getSections().add(section);
+ }
+ proposalAiReviewRepository.save(review);
+ }
+
+ private ProposalAiReviewDTO toDto(ProposalAiReview review) {
+ ProposalAiReviewDTO dto = new ProposalAiReviewDTO();
+ dto.setModuleVersionId(review.getModuleVersion().getModuleVersionId());
+ dto.setSummary(review.getSummary());
+ dto.setGeneratedAt(review.getGeneratedAt());
+ dto.setGuidelinesConfigured(review.isGuidelinesConfigured());
+ List sections = new ArrayList<>();
+ for (ProposalAiReviewSection section : review.getSections()) {
+ ProposalAiReviewSectionDTO sectionDto = new ProposalAiReviewSectionDTO();
+ sectionDto.setSection(section.getSection());
+ sectionDto.setSectionLabel(AiReviewGuidelinePromptUtil.getSectionLabel(section.getSection()));
+ sectionDto.setSeverity(section.getSeverity());
+ sectionDto.setFindings(section.getFindings());
+ sectionDto.setSuggestions(section.getSuggestions());
+ sections.add(sectionDto);
+ }
+ dto.setSections(sections);
+ return dto;
+ }
+
+ private ModuleVersion requireAccessibleModuleVersion(Long moduleVersionId, User user) {
+ ModuleVersion moduleVersion = moduleVersionRepository.findById(moduleVersionId)
+ .orElseThrow(() -> new ResourceNotFoundException("Could not find a module version with this ID."));
+ if (!canAccessModuleVersionForAiReview(moduleVersion, user)) {
+ throw new ResponseStatusException(HttpStatus.FORBIDDEN, "You do not have access to review this proposal.");
+ }
+ return moduleVersion;
+ }
+
+ private boolean canAccessModuleVersionForAiReview(ModuleVersion moduleVersion, User user) {
+ Proposal proposal = moduleVersion.getProposal();
+ if (proposal == null || proposal.getCreatedBy() == null) {
+ return false;
+ }
+ if (proposal.getCreatedBy().getUserId().equals(user.getUserId())) {
+ return true;
+ }
+ if (user.getRoles() == null) {
+ return false;
+ }
+ if (user.getRoles().contains(UserRole.QUALITY_MANAGEMENT)
+ || user.getRoles().contains(UserRole.EXAMINATION_BOARD)
+ || user.getRoles().contains(UserRole.ACADEMIC_PROGRAM_ADVISOR)) {
+ return true;
+ }
+ if (user.getRoles().contains(UserRole.PROGRAM_COORDINATOR)
+ || user.getRoles().contains(UserRole.SPECIALIZATION_AREA_COORDINATOR)) {
+ return feedbackRepository.existsByModuleVersion_Proposal_ProposalIdAndInvalidatedFalseAndAssignedReviewer_UserId(
+ proposal.getProposalId(), user.getUserId());
+ }
+ return false;
+ }
+}
diff --git a/Server/src/main/java/modulemanagement/ls1/shared/AiReviewGuidelinePromptUtil.java b/Server/src/main/java/modulemanagement/ls1/shared/AiReviewGuidelinePromptUtil.java
new file mode 100644
index 00000000..f9c99340
--- /dev/null
+++ b/Server/src/main/java/modulemanagement/ls1/shared/AiReviewGuidelinePromptUtil.java
@@ -0,0 +1,159 @@
+package modulemanagement.ls1.shared;
+
+import modulemanagement.ls1.enums.ProposalReviewSection;
+import modulemanagement.ls1.models.AiReviewGuideline;
+import modulemanagement.ls1.models.ModuleVersion;
+
+import java.util.ArrayList;
+import java.util.EnumMap;
+import java.util.List;
+import java.util.Map;
+import java.util.function.Function;
+import java.util.stream.Collectors;
+
+/**
+ * Maps proposal sections to module fields and formats stored guidelines for LLM review prompts.
+ */
+public final class AiReviewGuidelinePromptUtil {
+
+ private static final Map SECTION_LABELS = Map.ofEntries(
+ Map.entry(ProposalReviewSection.GENERAL, "General (whole proposal)"),
+ Map.entry(ProposalReviewSection.TITLE_ENG, "Title (English)"),
+ Map.entry(ProposalReviewSection.TITLE_DE, "Title (German)"),
+ Map.entry(ProposalReviewSection.LEVEL_ENG, "Level"),
+ Map.entry(ProposalReviewSection.LANGUAGE_ENG, "Language"),
+ Map.entry(ProposalReviewSection.FREQUENCY_ENG, "Frequency"),
+ Map.entry(ProposalReviewSection.CREDITS, "Credits"),
+ Map.entry(ProposalReviewSection.DURATION, "Duration"),
+ Map.entry(ProposalReviewSection.HOURS_LECTURE, "Hours (Lecture)"),
+ Map.entry(ProposalReviewSection.HOURS_EXERCISE, "Hours (Exercise)"),
+ Map.entry(ProposalReviewSection.HOURS_PRACTICAL, "Hours (Practical)"),
+ Map.entry(ProposalReviewSection.HOURS_SEMINAR, "Hours (Seminar)"),
+ Map.entry(ProposalReviewSection.FIRST_SEMESTER_AVAILABLE, "First semester available"),
+ Map.entry(ProposalReviewSection.SUCCESSOR_MODULE_NAME, "Successor module"),
+ Map.entry(ProposalReviewSection.HOURS_TOTAL, "Total hours"),
+ Map.entry(ProposalReviewSection.HOURS_SELF_STUDY, "Self-study hours"),
+ Map.entry(ProposalReviewSection.HOURS_PRESENCE, "Presence hours"),
+ Map.entry(ProposalReviewSection.BULLET_POINTS, "Key points"),
+ Map.entry(ProposalReviewSection.EXAMINATION_ACHIEVEMENTS, "Examination achievements"),
+ Map.entry(ProposalReviewSection.REPETITION, "Repetition"),
+ Map.entry(ProposalReviewSection.RECOMMENDED_PREREQUISITES, "Recommended prerequisites"),
+ Map.entry(ProposalReviewSection.CONTENT, "Module content"),
+ Map.entry(ProposalReviewSection.LEARNING_OUTCOMES, "Learning outcomes"),
+ Map.entry(ProposalReviewSection.TEACHING_METHODS, "Teaching methods"),
+ Map.entry(ProposalReviewSection.MEDIA, "Media"),
+ Map.entry(ProposalReviewSection.LITERATURE, "Literature"),
+ Map.entry(ProposalReviewSection.RESPONSIBLES, "Responsibles"),
+ Map.entry(ProposalReviewSection.LV_SWS_LECTURER, "Lecturer"),
+ Map.entry(ProposalReviewSection.DEGREE_PROGRAM_ASSIGNMENTS, "Degree program assignments"));
+
+ private static final Map> SECTION_VALUE_EXTRACTORS =
+ new EnumMap<>(ProposalReviewSection.class);
+
+ static {
+ SECTION_VALUE_EXTRACTORS.put(ProposalReviewSection.TITLE_ENG, ModuleVersion::getTitleEng);
+ SECTION_VALUE_EXTRACTORS.put(ProposalReviewSection.TITLE_DE, ModuleVersion::getTitleDe);
+ SECTION_VALUE_EXTRACTORS.put(ProposalReviewSection.LEVEL_ENG, ModuleVersion::getLevelEng);
+ SECTION_VALUE_EXTRACTORS.put(ProposalReviewSection.LANGUAGE_ENG,
+ mv -> mv.getLanguageEng() != null ? mv.getLanguageEng().name() : null);
+ SECTION_VALUE_EXTRACTORS.put(ProposalReviewSection.FREQUENCY_ENG, ModuleVersion::getFrequencyEng);
+ SECTION_VALUE_EXTRACTORS.put(ProposalReviewSection.CREDITS, mv -> integerToString(mv.getCredits()));
+ SECTION_VALUE_EXTRACTORS.put(ProposalReviewSection.DURATION, ModuleVersion::getDuration);
+ SECTION_VALUE_EXTRACTORS.put(ProposalReviewSection.HOURS_LECTURE, mv -> integerToString(mv.getHoursLecture()));
+ SECTION_VALUE_EXTRACTORS.put(ProposalReviewSection.HOURS_EXERCISE, mv -> integerToString(mv.getHoursExercise()));
+ SECTION_VALUE_EXTRACTORS.put(ProposalReviewSection.HOURS_PRACTICAL, mv -> integerToString(mv.getHoursPractical()));
+ SECTION_VALUE_EXTRACTORS.put(ProposalReviewSection.HOURS_SEMINAR, mv -> integerToString(mv.getHoursSeminar()));
+ SECTION_VALUE_EXTRACTORS.put(ProposalReviewSection.FIRST_SEMESTER_AVAILABLE, ModuleVersion::getFirstSemesterAvailable);
+ SECTION_VALUE_EXTRACTORS.put(ProposalReviewSection.SUCCESSOR_MODULE_NAME, ModuleVersion::getSuccessorModuleName);
+ SECTION_VALUE_EXTRACTORS.put(ProposalReviewSection.HOURS_TOTAL, mv -> integerToString(mv.getHoursTotal()));
+ SECTION_VALUE_EXTRACTORS.put(ProposalReviewSection.HOURS_SELF_STUDY, mv -> integerToString(mv.getHoursSelfStudy()));
+ SECTION_VALUE_EXTRACTORS.put(ProposalReviewSection.HOURS_PRESENCE, mv -> integerToString(mv.getHoursPresence()));
+ SECTION_VALUE_EXTRACTORS.put(ProposalReviewSection.BULLET_POINTS, ModuleVersion::getBulletPoints);
+ SECTION_VALUE_EXTRACTORS.put(ProposalReviewSection.EXAMINATION_ACHIEVEMENTS, ModuleVersion::getExaminationAchievementsEng);
+ SECTION_VALUE_EXTRACTORS.put(ProposalReviewSection.REPETITION, ModuleVersion::getRepetitionEng);
+ SECTION_VALUE_EXTRACTORS.put(ProposalReviewSection.RECOMMENDED_PREREQUISITES, ModuleVersion::getRecommendedPrerequisitesEng);
+ SECTION_VALUE_EXTRACTORS.put(ProposalReviewSection.CONTENT, ModuleVersion::getContentEng);
+ SECTION_VALUE_EXTRACTORS.put(ProposalReviewSection.LEARNING_OUTCOMES, ModuleVersion::getLearningOutcomesEng);
+ SECTION_VALUE_EXTRACTORS.put(ProposalReviewSection.TEACHING_METHODS, ModuleVersion::getTeachingMethodsEng);
+ SECTION_VALUE_EXTRACTORS.put(ProposalReviewSection.MEDIA, ModuleVersion::getMediaEng);
+ SECTION_VALUE_EXTRACTORS.put(ProposalReviewSection.LITERATURE, ModuleVersion::getLiteratureEng);
+ SECTION_VALUE_EXTRACTORS.put(ProposalReviewSection.RESPONSIBLES, ModuleVersion::getResponsiblesEng);
+ SECTION_VALUE_EXTRACTORS.put(ProposalReviewSection.LV_SWS_LECTURER, ModuleVersion::getLvSwsLecturerEng);
+ }
+
+ private AiReviewGuidelinePromptUtil() {
+ }
+
+ public static String getSectionLabel(ProposalReviewSection section) {
+ return SECTION_LABELS.getOrDefault(section, section.name());
+ }
+
+ public static String extractFieldValue(ModuleVersion moduleVersion, ProposalReviewSection section) {
+ if (moduleVersion == null || section == null) {
+ return null;
+ }
+ Function extractor = SECTION_VALUE_EXTRACTORS.get(section);
+ if (extractor == null) {
+ return null;
+ }
+ String value = extractor.apply(moduleVersion);
+ return value != null && !value.isBlank() ? value : null;
+ }
+
+ public static Map> groupBySection(List guidelines) {
+ if (guidelines == null || guidelines.isEmpty()) {
+ return Map.of();
+ }
+ return guidelines.stream().collect(Collectors.groupingBy(AiReviewGuideline::getSection, Collectors.toList()));
+ }
+
+ /**
+ * Formats guidelines for a single section (including {@link ProposalReviewSection#GENERAL} when passed).
+ */
+ public static String formatGuidelinesForSection(List guidelines, ProposalReviewSection section) {
+ if (guidelines == null || guidelines.isEmpty() || section == null) {
+ return "";
+ }
+ List matching = guidelines.stream()
+ .filter(g -> g.getSection() == section)
+ .toList();
+ if (matching.isEmpty()) {
+ return "";
+ }
+ StringBuilder sb = new StringBuilder();
+ sb.append("Review guidelines for ").append(getSectionLabel(section)).append(":\n");
+ int index = 1;
+ for (AiReviewGuideline guideline : matching) {
+ sb.append(index++).append(". ").append(guideline.getTitle()).append("\n");
+ sb.append(guideline.getInstruction().trim()).append("\n\n");
+ }
+ return sb.toString().trim();
+ }
+
+ /**
+ * Builds a section-scoped review prompt block: field value plus GENERAL and section-specific guidelines.
+ */
+ public static String buildSectionReviewContext(ModuleVersion moduleVersion, ProposalReviewSection section,
+ List allGuidelines) {
+ List parts = new ArrayList<>();
+ String fieldValue = extractFieldValue(moduleVersion, section);
+ if (fieldValue != null) {
+ parts.add(getSectionLabel(section) + " (submitted value):\n" + fieldValue);
+ }
+ String generalRules = formatGuidelinesForSection(allGuidelines, ProposalReviewSection.GENERAL);
+ if (!generalRules.isEmpty()) {
+ parts.add(generalRules);
+ }
+ if (section != ProposalReviewSection.GENERAL) {
+ String sectionRules = formatGuidelinesForSection(allGuidelines, section);
+ if (!sectionRules.isEmpty()) {
+ parts.add(sectionRules);
+ }
+ }
+ return String.join("\n\n", parts);
+ }
+
+ private static String integerToString(Integer value) {
+ return value != null ? value.toString() : null;
+ }
+}
diff --git a/Server/src/main/java/modulemanagement/ls1/shared/ProposalAiReviewParser.java b/Server/src/main/java/modulemanagement/ls1/shared/ProposalAiReviewParser.java
new file mode 100644
index 00000000..befa0f20
--- /dev/null
+++ b/Server/src/main/java/modulemanagement/ls1/shared/ProposalAiReviewParser.java
@@ -0,0 +1,130 @@
+package modulemanagement.ls1.shared;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import modulemanagement.ls1.dtos.ProposalAiReviewDTO;
+import modulemanagement.ls1.dtos.ProposalAiReviewSectionDTO;
+import modulemanagement.ls1.enums.AiReviewSeverity;
+import modulemanagement.ls1.enums.ProposalReviewSection;
+
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.List;
+
+/**
+ * Parses the LLM JSON response of a proposal review into a {@link ProposalAiReviewDTO}.
+ * Tolerates markdown code fences, surrounding prose, unknown section names, and
+ * missing/invalid severities. Falls back to using the raw response as summary when
+ * no JSON object can be parsed at all.
+ */
+public final class ProposalAiReviewParser {
+
+ public static final String FALLBACK_SUMMARY = "Review completed. See section details below.";
+ public static final String UNPARSEABLE_SUMMARY = "Review could not be parsed.";
+
+ private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
+
+ private ProposalAiReviewParser() {
+ }
+
+ public static ProposalAiReviewDTO parse(String llmResponse) {
+ ProposalAiReviewDTO dto = new ProposalAiReviewDTO();
+ if (llmResponse == null || llmResponse.isBlank()) {
+ dto.setSummary(UNPARSEABLE_SUMMARY);
+ dto.setSections(List.of());
+ return dto;
+ }
+ try {
+ String json = extractJson(llmResponse);
+ JsonNode root = OBJECT_MAPPER.readTree(json);
+ if (!root.isObject()) {
+ throw new IllegalArgumentException("Response is not a JSON object");
+ }
+ dto.setSummary(textOrDefault(root.get("summary"), FALLBACK_SUMMARY));
+ dto.setSections(parseSections(root.get("sections")));
+ } catch (Exception e) {
+ String raw = llmResponse != null ? llmResponse.trim() : "";
+ dto.setSummary(raw.isEmpty() ? UNPARSEABLE_SUMMARY : raw);
+ dto.setSections(List.of());
+ }
+ return dto;
+ }
+
+ private static List parseSections(JsonNode sectionsNode) {
+ List sections = new ArrayList<>();
+ if (sectionsNode == null || !sectionsNode.isArray()) {
+ return sections;
+ }
+ for (JsonNode node : sectionsNode) {
+ ProposalAiReviewSectionDTO section = parseSectionNode(node);
+ if (section != null) {
+ sections.add(section);
+ }
+ }
+ sections.sort(Comparator.comparingInt(s -> severityOrder(s.getSeverity())));
+ return sections;
+ }
+
+ private static ProposalAiReviewSectionDTO parseSectionNode(JsonNode node) {
+ if (node == null || !node.has("section")) {
+ return null;
+ }
+ ProposalAiReviewSectionDTO section = new ProposalAiReviewSectionDTO();
+ try {
+ section.setSection(ProposalReviewSection.valueOf(node.get("section").asText().trim().toUpperCase()));
+ } catch (IllegalArgumentException e) {
+ return null;
+ }
+ section.setSectionLabel(AiReviewGuidelinePromptUtil.getSectionLabel(section.getSection()));
+ section.setSeverity(parseSeverity(node.get("severity")));
+ section.setFindings(textOrDefault(node.get("findings"), ""));
+ section.setSuggestions(textOrDefault(node.get("suggestions"), ""));
+ return section;
+ }
+
+ private static AiReviewSeverity parseSeverity(JsonNode node) {
+ if (node == null || node.isNull()) {
+ return AiReviewSeverity.ATTENTION;
+ }
+ try {
+ return AiReviewSeverity.valueOf(node.asText().trim().toUpperCase());
+ } catch (IllegalArgumentException e) {
+ return AiReviewSeverity.ATTENTION;
+ }
+ }
+
+ private static String textOrDefault(JsonNode node, String defaultValue) {
+ if (node == null || node.isNull()) {
+ return defaultValue;
+ }
+ String text = node.asText().trim();
+ return text.isEmpty() ? defaultValue : text;
+ }
+
+ /**
+ * Extracts the outermost JSON object from a response that may contain code fences
+ * or surrounding prose.
+ */
+ static String extractJson(String response) {
+ if (response == null) {
+ return "{}";
+ }
+ String trimmed = response.trim();
+ int start = trimmed.indexOf('{');
+ int end = trimmed.lastIndexOf('}');
+ if (start >= 0 && end > start) {
+ return trimmed.substring(start, end + 1);
+ }
+ return trimmed;
+ }
+
+ public static int severityOrder(AiReviewSeverity severity) {
+ if (severity == AiReviewSeverity.CRITICAL) {
+ return 0;
+ }
+ if (severity == AiReviewSeverity.ATTENTION) {
+ return 1;
+ }
+ return 2;
+ }
+}
diff --git a/Server/src/main/java/modulemanagement/ls1/shared/ProposalAiReviewPromptUtil.java b/Server/src/main/java/modulemanagement/ls1/shared/ProposalAiReviewPromptUtil.java
new file mode 100644
index 00000000..04a921c7
--- /dev/null
+++ b/Server/src/main/java/modulemanagement/ls1/shared/ProposalAiReviewPromptUtil.java
@@ -0,0 +1,117 @@
+package modulemanagement.ls1.shared;
+
+import modulemanagement.ls1.enums.ProposalReviewSection;
+import modulemanagement.ls1.models.AiReviewGuideline;
+import modulemanagement.ls1.models.ModuleVersion;
+import modulemanagement.ls1.models.ModuleVersionDegreeProgramAssignment;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.stream.Collectors;
+
+public final class ProposalAiReviewPromptUtil {
+
+ private static final List REVIEWABLE_SECTIONS = Arrays.stream(ProposalReviewSection.values())
+ .filter(s -> s != ProposalReviewSection.GENERAL)
+ .toList();
+
+ private ProposalAiReviewPromptUtil() {
+ }
+
+ private static final String REVIEW_INSTRUCTION = """
+ Provide an objective academic review suitable for both authors improving the proposal \
+ and reviewers assessing it. Highlight strengths, gaps, compliance issues, and actionable \
+ improvements. Flag blockers as CRITICAL.""";
+
+ public static String buildReviewPrompt(ModuleVersion moduleVersion, List guidelines) {
+ String generalGuidelines = AiReviewGuidelinePromptUtil.formatGuidelinesForSection(guidelines,
+ ProposalReviewSection.GENERAL);
+
+ return """
+ You are an expert academic module proposal reviewer at the Technical University of Munich (TUM).
+ %s
+
+ Review the module proposal below against the provided guidelines.
+ Only evaluate sections that have submitted content and/or applicable guidelines.
+ Be concise but thorough. Flag missing critical information as CRITICAL.
+
+ === MODULE PROPOSAL ===
+ %s
+
+ === REVIEW GUIDELINES (apply all relevant rules) ===
+ %s
+
+ === RESPONSE FORMAT ===
+ Respond with valid JSON only (no markdown fences), using this exact structure:
+ {
+ "summary": "2-4 sentence overall assessment",
+ "sections": [
+ {
+ "section": "SECTION_ENUM_NAME",
+ "severity": "OK|ATTENTION|CRITICAL",
+ "findings": "what you observed",
+ "suggestions": "actionable advice (empty string if severity is OK)"
+ }
+ ]
+ }
+
+ Allowed section enum values: %s
+ Use severity OK when the section meets guidelines; ATTENTION for improvable issues; CRITICAL for blockers or major gaps.
+ """
+ .formatted(
+ REVIEW_INSTRUCTION,
+ buildProposalDataBlock(moduleVersion),
+ generalGuidelines.isEmpty() ? "(No general guidelines configured.)" : generalGuidelines,
+ REVIEWABLE_SECTIONS.stream().map(Enum::name).collect(Collectors.joining(", ")));
+ }
+
+ private static String buildProposalDataBlock(ModuleVersion mv) {
+ List parts = new ArrayList<>();
+ for (ProposalReviewSection section : REVIEWABLE_SECTIONS) {
+ if (section == ProposalReviewSection.DEGREE_PROGRAM_ASSIGNMENTS) {
+ String assignments = formatDegreeProgramAssignments(mv);
+ if (!assignments.isBlank()) {
+ parts.add(AiReviewGuidelinePromptUtil.getSectionLabel(section) + ":\n" + assignments);
+ }
+ continue;
+ }
+ String value = AiReviewGuidelinePromptUtil.extractFieldValue(mv, section);
+ if (value != null) {
+ parts.add(AiReviewGuidelinePromptUtil.getSectionLabel(section) + ":\n" + value);
+ }
+ }
+ if (parts.isEmpty()) {
+ return "(No proposal fields filled in yet.)";
+ }
+ return String.join("\n\n", parts);
+ }
+
+ private static String formatDegreeProgramAssignments(ModuleVersion mv) {
+ if (mv.getDegreeProgramAssignments() == null || mv.getDegreeProgramAssignments().isEmpty()) {
+ return "";
+ }
+ return mv.getDegreeProgramAssignments().stream()
+ .map(ProposalAiReviewPromptUtil::formatAssignment)
+ .collect(Collectors.joining("\n"));
+ }
+
+ private static String formatAssignment(ModuleVersionDegreeProgramAssignment a) {
+ String program = a.getDegreeProgram() != null ? a.getDegreeProgram().getName() : "Unknown program";
+ String specialization = a.getDegreeProgramSpecialization() != null
+ ? a.getDegreeProgramSpecialization().getName()
+ : "Unknown specialization";
+ return "- " + program + " / " + specialization;
+ }
+
+ public static String appendSectionGuidelines(String basePrompt, List guidelines) {
+ StringBuilder sb = new StringBuilder(basePrompt);
+ for (ProposalReviewSection section : REVIEWABLE_SECTIONS) {
+ String sectionRules = AiReviewGuidelinePromptUtil.formatGuidelinesForSection(guidelines, section);
+ if (!sectionRules.isEmpty()) {
+ sb.append("\n\n").append(sectionRules);
+ }
+ }
+ return sb.toString();
+ }
+}
diff --git a/Server/src/main/resources/application.yaml b/Server/src/main/resources/application.yaml
index 881eb687..05b4e587 100644
--- a/Server/src/main/resources/application.yaml
+++ b/Server/src/main/resources/application.yaml
@@ -37,6 +37,11 @@ spring:
starttls:
enable: ${MAIL_STARTTLS_ENABLE:false}
required: ${MAIL_STARTTLS_REQUIRED:false}
+ # Generous timeouts for LLM calls (full proposal AI reviews can take >1 minute on local models).
+ http:
+ clients:
+ connect-timeout: 30s
+ read-timeout: 5m
ai:
openai:
api-key: ${OPENAI_API_KEY:not-needed}
diff --git a/Server/src/main/resources/db/changelog/changes/0017_ai_review_guidelines.yaml b/Server/src/main/resources/db/changelog/changes/0017_ai_review_guidelines.yaml
new file mode 100644
index 00000000..67d94270
--- /dev/null
+++ b/Server/src/main/resources/db/changelog/changes/0017_ai_review_guidelines.yaml
@@ -0,0 +1,75 @@
+databaseChangeLog:
+ - changeSet:
+ id: 0017a_ai_review_guideline_table
+ author: module-management
+ changes:
+ - createTable:
+ tableName: ai_review_guideline
+ columns:
+ - column:
+ name: guideline_id
+ type: BIGINT
+ autoIncrement: true
+ constraints:
+ primaryKey: true
+ - column:
+ name: section
+ type: VARCHAR(64)
+ constraints:
+ nullable: false
+ - column:
+ name: title
+ type: VARCHAR(256)
+ constraints:
+ nullable: false
+ - column:
+ name: instruction
+ type: CLOB
+ constraints:
+ nullable: false
+ - column:
+ name: sort_order
+ type: INT
+ defaultValueNumeric: 0
+ constraints:
+ nullable: false
+ - column:
+ name: created_by
+ type: UUID
+ constraints:
+ nullable: false
+ - column:
+ name: updated_by
+ type: UUID
+ constraints:
+ nullable: true
+ - column:
+ name: created_at
+ type: TIMESTAMP
+ constraints:
+ nullable: false
+ - column:
+ name: updated_at
+ type: TIMESTAMP
+ constraints:
+ nullable: false
+ - addForeignKeyConstraint:
+ baseTableName: ai_review_guideline
+ baseColumnNames: created_by
+ referencedTableName: app_user
+ referencedColumnNames: user_id
+ constraintName: fk_ai_review_guideline_created_by
+ - addForeignKeyConstraint:
+ baseTableName: ai_review_guideline
+ baseColumnNames: updated_by
+ referencedTableName: app_user
+ referencedColumnNames: user_id
+ constraintName: fk_ai_review_guideline_updated_by
+ - createIndex:
+ tableName: ai_review_guideline
+ indexName: idx_ai_review_guideline_section_sort
+ columns:
+ - column:
+ name: section
+ - column:
+ name: sort_order
diff --git a/Server/src/main/resources/db/changelog/changes/0018_proposal_ai_review.yaml b/Server/src/main/resources/db/changelog/changes/0018_proposal_ai_review.yaml
new file mode 100644
index 00000000..af5a1547
--- /dev/null
+++ b/Server/src/main/resources/db/changelog/changes/0018_proposal_ai_review.yaml
@@ -0,0 +1,104 @@
+databaseChangeLog:
+ - changeSet:
+ id: 0018a_proposal_ai_review_tables
+ author: module-management
+ changes:
+ - createTable:
+ tableName: proposal_ai_review
+ columns:
+ - column:
+ name: review_id
+ type: BIGINT
+ autoIncrement: true
+ constraints:
+ primaryKey: true
+ - column:
+ name: module_version_id
+ type: BIGINT
+ constraints:
+ nullable: false
+ - column:
+ name: summary
+ type: CLOB
+ constraints:
+ nullable: true
+ - column:
+ name: guidelines_configured
+ type: BOOLEAN
+ defaultValueBoolean: false
+ constraints:
+ nullable: false
+ - column:
+ name: generated_by
+ type: UUID
+ constraints:
+ nullable: true
+ - column:
+ name: generated_at
+ type: TIMESTAMP
+ constraints:
+ nullable: false
+ - addUniqueConstraint:
+ tableName: proposal_ai_review
+ columnNames: module_version_id
+ constraintName: uq_proposal_ai_review_module_version
+ - addForeignKeyConstraint:
+ baseTableName: proposal_ai_review
+ baseColumnNames: module_version_id
+ referencedTableName: module_version
+ referencedColumnNames: module_version_id
+ constraintName: fk_proposal_ai_review_module_version
+ onDelete: CASCADE
+ - addForeignKeyConstraint:
+ baseTableName: proposal_ai_review
+ baseColumnNames: generated_by
+ referencedTableName: app_user
+ referencedColumnNames: user_id
+ constraintName: fk_proposal_ai_review_generated_by
+ - createTable:
+ tableName: proposal_ai_review_section
+ columns:
+ - column:
+ name: section_id
+ type: BIGINT
+ autoIncrement: true
+ constraints:
+ primaryKey: true
+ - column:
+ name: review_id
+ type: BIGINT
+ constraints:
+ nullable: false
+ - column:
+ name: section
+ type: VARCHAR(64)
+ constraints:
+ nullable: false
+ - column:
+ name: severity
+ type: VARCHAR(32)
+ constraints:
+ nullable: false
+ - column:
+ name: findings
+ type: CLOB
+ constraints:
+ nullable: true
+ - column:
+ name: suggestions
+ type: CLOB
+ constraints:
+ nullable: true
+ - column:
+ name: sort_order
+ type: INT
+ defaultValueNumeric: 0
+ constraints:
+ nullable: false
+ - addForeignKeyConstraint:
+ baseTableName: proposal_ai_review_section
+ baseColumnNames: review_id
+ referencedTableName: proposal_ai_review
+ referencedColumnNames: review_id
+ constraintName: fk_proposal_ai_review_section_review
+ onDelete: CASCADE
diff --git a/Server/src/main/resources/db/changelog/master.yaml b/Server/src/main/resources/db/changelog/master.yaml
index ee7d4285..276614fe 100644
--- a/Server/src/main/resources/db/changelog/master.yaml
+++ b/Server/src/main/resources/db/changelog/master.yaml
@@ -47,3 +47,9 @@ databaseChangeLog:
- include:
relativeToChangelogFile: true
file: changes/0016_feedback_assigned_reviewer.yaml
+ - include:
+ relativeToChangelogFile: true
+ file: changes/0017_ai_review_guidelines.yaml
+ - include:
+ relativeToChangelogFile: true
+ file: changes/0018_proposal_ai_review.yaml
diff --git a/Server/src/test/java/modulemanagement/ls1/shared/AiReviewGuidelinePromptUtilTest.java b/Server/src/test/java/modulemanagement/ls1/shared/AiReviewGuidelinePromptUtilTest.java
new file mode 100644
index 00000000..efc20048
--- /dev/null
+++ b/Server/src/test/java/modulemanagement/ls1/shared/AiReviewGuidelinePromptUtilTest.java
@@ -0,0 +1,81 @@
+package modulemanagement.ls1.shared;
+
+import modulemanagement.ls1.enums.Language;
+import modulemanagement.ls1.enums.ProposalReviewSection;
+import modulemanagement.ls1.models.AiReviewGuideline;
+import modulemanagement.ls1.models.ModuleVersion;
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+class AiReviewGuidelinePromptUtilTest {
+
+ private AiReviewGuideline guideline(ProposalReviewSection section, String title, String instruction) {
+ AiReviewGuideline g = new AiReviewGuideline();
+ g.setSection(section);
+ g.setTitle(title);
+ g.setInstruction(instruction);
+ return g;
+ }
+
+ @Test
+ void extractsStringAndNumericAndEnumFields() {
+ ModuleVersion mv = new ModuleVersion();
+ mv.setTitleEng("Databases");
+ mv.setCredits(6);
+ mv.setLanguageEng(Language.English);
+
+ assertEquals("Databases", AiReviewGuidelinePromptUtil.extractFieldValue(mv, ProposalReviewSection.TITLE_ENG));
+ assertEquals("6", AiReviewGuidelinePromptUtil.extractFieldValue(mv, ProposalReviewSection.CREDITS));
+ assertEquals("English", AiReviewGuidelinePromptUtil.extractFieldValue(mv, ProposalReviewSection.LANGUAGE_ENG));
+ }
+
+ @Test
+ void returnsNullForEmptyOrMissingValues() {
+ ModuleVersion mv = new ModuleVersion();
+ mv.setTitleEng(" ");
+
+ assertNull(AiReviewGuidelinePromptUtil.extractFieldValue(mv, ProposalReviewSection.TITLE_ENG));
+ assertNull(AiReviewGuidelinePromptUtil.extractFieldValue(mv, ProposalReviewSection.CONTENT));
+ assertNull(AiReviewGuidelinePromptUtil.extractFieldValue(null, ProposalReviewSection.CONTENT));
+ assertNull(AiReviewGuidelinePromptUtil.extractFieldValue(mv, null));
+ }
+
+ @Test
+ void formatsGuidelinesForMatchingSectionOnly() {
+ List guidelines = List.of(
+ guideline(ProposalReviewSection.CONTENT, "Depth", "Three thematic blocks."),
+ guideline(ProposalReviewSection.MEDIA, "Media rule", "List used media."));
+
+ String formatted = AiReviewGuidelinePromptUtil.formatGuidelinesForSection(guidelines,
+ ProposalReviewSection.CONTENT);
+
+ assertTrue(formatted.contains("Depth"));
+ assertTrue(formatted.contains("Three thematic blocks."));
+ assertFalse(formatted.contains("Media rule"));
+ }
+
+ @Test
+ void returnsEmptyStringWhenNoGuidelinesMatch() {
+ List guidelines = List.of(
+ guideline(ProposalReviewSection.MEDIA, "Media rule", "List used media."));
+
+ assertEquals("", AiReviewGuidelinePromptUtil.formatGuidelinesForSection(guidelines,
+ ProposalReviewSection.CONTENT));
+ assertEquals("", AiReviewGuidelinePromptUtil.formatGuidelinesForSection(List.of(),
+ ProposalReviewSection.CONTENT));
+ assertEquals("", AiReviewGuidelinePromptUtil.formatGuidelinesForSection(null,
+ ProposalReviewSection.CONTENT));
+ }
+
+ @Test
+ void everySectionHasALabel() {
+ for (ProposalReviewSection section : ProposalReviewSection.values()) {
+ String label = AiReviewGuidelinePromptUtil.getSectionLabel(section);
+ assertNotNull(label);
+ assertFalse(label.isBlank());
+ }
+ }
+}
diff --git a/Server/src/test/java/modulemanagement/ls1/shared/ProposalAiReviewParserTest.java b/Server/src/test/java/modulemanagement/ls1/shared/ProposalAiReviewParserTest.java
new file mode 100644
index 00000000..1ce436bd
--- /dev/null
+++ b/Server/src/test/java/modulemanagement/ls1/shared/ProposalAiReviewParserTest.java
@@ -0,0 +1,150 @@
+package modulemanagement.ls1.shared;
+
+import modulemanagement.ls1.dtos.ProposalAiReviewDTO;
+import modulemanagement.ls1.dtos.ProposalAiReviewSectionDTO;
+import modulemanagement.ls1.enums.AiReviewSeverity;
+import modulemanagement.ls1.enums.ProposalReviewSection;
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+class ProposalAiReviewParserTest {
+
+ @Test
+ void parsesValidJsonResponse() {
+ String response = """
+ {
+ "summary": "Solid proposal with minor gaps.",
+ "sections": [
+ {"section": "CONTENT", "severity": "OK", "findings": "Content is well structured.", "suggestions": ""},
+ {"section": "CREDITS", "severity": "CRITICAL", "findings": "Credits missing.", "suggestions": "Add ECTS credits."}
+ ]
+ }
+ """;
+
+ ProposalAiReviewDTO dto = ProposalAiReviewParser.parse(response);
+
+ assertEquals("Solid proposal with minor gaps.", dto.getSummary());
+ assertEquals(2, dto.getSections().size());
+ // Sorted by severity: CRITICAL first
+ assertEquals(ProposalReviewSection.CREDITS, dto.getSections().get(0).getSection());
+ assertEquals(AiReviewSeverity.CRITICAL, dto.getSections().get(0).getSeverity());
+ assertEquals("Add ECTS credits.", dto.getSections().get(0).getSuggestions());
+ assertEquals(ProposalReviewSection.CONTENT, dto.getSections().get(1).getSection());
+ assertEquals(AiReviewSeverity.OK, dto.getSections().get(1).getSeverity());
+ }
+
+ @Test
+ void parsesJsonWrappedInMarkdownCodeFence() {
+ String response = """
+ Here is the review:
+ ```json
+ {"summary": "Looks good.", "sections": [{"section": "TITLE_ENG", "severity": "ATTENTION", "findings": "Too vague.", "suggestions": "Be specific."}]}
+ ```
+ """;
+
+ ProposalAiReviewDTO dto = ProposalAiReviewParser.parse(response);
+
+ assertEquals("Looks good.", dto.getSummary());
+ assertEquals(1, dto.getSections().size());
+ assertEquals(ProposalReviewSection.TITLE_ENG, dto.getSections().get(0).getSection());
+ }
+
+ @Test
+ void skipsUnknownSectionNames() {
+ String response = """
+ {"summary": "S", "sections": [
+ {"section": "NOT_A_REAL_SECTION", "severity": "OK", "findings": "x", "suggestions": ""},
+ {"section": "MEDIA", "severity": "OK", "findings": "fine", "suggestions": ""}
+ ]}
+ """;
+
+ ProposalAiReviewDTO dto = ProposalAiReviewParser.parse(response);
+
+ assertEquals(1, dto.getSections().size());
+ assertEquals(ProposalReviewSection.MEDIA, dto.getSections().get(0).getSection());
+ }
+
+ @Test
+ void acceptsLowercaseSectionAndSeverity() {
+ String response = """
+ {"summary": "S", "sections": [{"section": "content", "severity": "critical", "findings": "x", "suggestions": "y"}]}
+ """;
+
+ ProposalAiReviewDTO dto = ProposalAiReviewParser.parse(response);
+
+ assertEquals(1, dto.getSections().size());
+ assertEquals(ProposalReviewSection.CONTENT, dto.getSections().get(0).getSection());
+ assertEquals(AiReviewSeverity.CRITICAL, dto.getSections().get(0).getSeverity());
+ }
+
+ @Test
+ void fallsBackToAttentionForInvalidSeverity() {
+ String response = """
+ {"summary": "S", "sections": [{"section": "CONTENT", "severity": "BANANAS", "findings": "x", "suggestions": ""}]}
+ """;
+
+ ProposalAiReviewDTO dto = ProposalAiReviewParser.parse(response);
+
+ assertEquals(AiReviewSeverity.ATTENTION, dto.getSections().get(0).getSeverity());
+ }
+
+ @Test
+ void usesRawResponseAsSummaryWhenNotJson() {
+ String response = "Sorry, I cannot produce JSON right now.";
+
+ ProposalAiReviewDTO dto = ProposalAiReviewParser.parse(response);
+
+ assertEquals(response, dto.getSummary());
+ assertTrue(dto.getSections().isEmpty());
+ }
+
+ @Test
+ void handlesNullAndEmptyResponses() {
+ assertEquals(ProposalAiReviewParser.UNPARSEABLE_SUMMARY, ProposalAiReviewParser.parse(null).getSummary());
+ assertEquals(ProposalAiReviewParser.UNPARSEABLE_SUMMARY, ProposalAiReviewParser.parse(" ").getSummary());
+ assertTrue(ProposalAiReviewParser.parse(null).getSections().isEmpty());
+ }
+
+ @Test
+ void usesFallbackSummaryWhenSummaryMissing() {
+ String response = """
+ {"sections": [{"section": "CONTENT", "severity": "OK", "findings": "fine", "suggestions": ""}]}
+ """;
+
+ ProposalAiReviewDTO dto = ProposalAiReviewParser.parse(response);
+
+ assertEquals(ProposalAiReviewParser.FALLBACK_SUMMARY, dto.getSummary());
+ assertEquals(1, dto.getSections().size());
+ }
+
+ @Test
+ void sortsSectionsBySeverityCriticalFirst() {
+ String response = """
+ {"summary": "S", "sections": [
+ {"section": "MEDIA", "severity": "OK", "findings": "a", "suggestions": ""},
+ {"section": "CONTENT", "severity": "ATTENTION", "findings": "b", "suggestions": ""},
+ {"section": "CREDITS", "severity": "CRITICAL", "findings": "c", "suggestions": ""}
+ ]}
+ """;
+
+ List sections = ProposalAiReviewParser.parse(response).getSections();
+
+ assertEquals(AiReviewSeverity.CRITICAL, sections.get(0).getSeverity());
+ assertEquals(AiReviewSeverity.ATTENTION, sections.get(1).getSeverity());
+ assertEquals(AiReviewSeverity.OK, sections.get(2).getSeverity());
+ }
+
+ @Test
+ void setsSectionLabelFromSectionEnum() {
+ String response = """
+ {"summary": "S", "sections": [{"section": "LEARNING_OUTCOMES", "severity": "OK", "findings": "x", "suggestions": ""}]}
+ """;
+
+ ProposalAiReviewDTO dto = ProposalAiReviewParser.parse(response);
+
+ assertEquals("Learning outcomes", dto.getSections().get(0).getSectionLabel());
+ }
+}
diff --git a/Server/src/test/java/modulemanagement/ls1/shared/ProposalAiReviewPromptUtilTest.java b/Server/src/test/java/modulemanagement/ls1/shared/ProposalAiReviewPromptUtilTest.java
new file mode 100644
index 00000000..7318018c
--- /dev/null
+++ b/Server/src/test/java/modulemanagement/ls1/shared/ProposalAiReviewPromptUtilTest.java
@@ -0,0 +1,103 @@
+package modulemanagement.ls1.shared;
+
+import modulemanagement.ls1.enums.ProposalReviewSection;
+import modulemanagement.ls1.models.AiReviewGuideline;
+import modulemanagement.ls1.models.ModuleVersion;
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+class ProposalAiReviewPromptUtilTest {
+
+ private ModuleVersion sampleModuleVersion() {
+ ModuleVersion mv = new ModuleVersion();
+ mv.setTitleEng("Advanced Databases");
+ mv.setCredits(6);
+ mv.setContentEng("Query optimization, transactions, distributed storage.");
+ return mv;
+ }
+
+ private AiReviewGuideline guideline(ProposalReviewSection section, String title, String instruction) {
+ AiReviewGuideline g = new AiReviewGuideline();
+ g.setSection(section);
+ g.setTitle(title);
+ g.setInstruction(instruction);
+ return g;
+ }
+
+ @Test
+ void promptContainsFilledProposalFields() {
+ String prompt = ProposalAiReviewPromptUtil.buildReviewPrompt(sampleModuleVersion(), List.of());
+
+ assertTrue(prompt.contains("Advanced Databases"));
+ assertTrue(prompt.contains("6"));
+ assertTrue(prompt.contains("Query optimization"));
+ }
+
+ @Test
+ void promptOmitsEmptyFields() {
+ ModuleVersion mv = new ModuleVersion();
+ mv.setTitleEng("Only Title");
+
+ String prompt = ProposalAiReviewPromptUtil.buildReviewPrompt(mv, List.of());
+
+ assertTrue(prompt.contains("Only Title"));
+ assertFalse(prompt.contains("Learning outcomes:\n"));
+ }
+
+ @Test
+ void promptUsesBalancedReviewInstruction() {
+ String prompt = ProposalAiReviewPromptUtil.buildReviewPrompt(sampleModuleVersion(), List.of());
+
+ assertTrue(prompt.contains("authors improving the proposal"));
+ assertTrue(prompt.contains("reviewers assessing it"));
+ }
+
+ @Test
+ void generalGuidelinesAreIncludedInBasePrompt() {
+ AiReviewGuideline general = guideline(ProposalReviewSection.GENERAL, "Tone", "Use academic English.");
+
+ String prompt = ProposalAiReviewPromptUtil.buildReviewPrompt(sampleModuleVersion(), List.of(general));
+
+ assertTrue(prompt.contains("Tone"));
+ assertTrue(prompt.contains("Use academic English."));
+ }
+
+ @Test
+ void missingGuidelinesAreMarkedInPrompt() {
+ String prompt = ProposalAiReviewPromptUtil.buildReviewPrompt(sampleModuleVersion(), List.of());
+
+ assertTrue(prompt.contains("No general guidelines configured"));
+ }
+
+ @Test
+ void appendSectionGuidelinesAddsSectionSpecificRules() {
+ AiReviewGuideline contentRule = guideline(ProposalReviewSection.CONTENT, "Depth",
+ "Describe at least three thematic blocks.");
+ String base = "BASE";
+
+ String result = ProposalAiReviewPromptUtil.appendSectionGuidelines(base, List.of(contentRule));
+
+ assertTrue(result.startsWith("BASE"));
+ assertTrue(result.contains("Depth"));
+ assertTrue(result.contains("three thematic blocks"));
+ }
+
+ @Test
+ void appendSectionGuidelinesWithNoRulesReturnsBase() {
+ String result = ProposalAiReviewPromptUtil.appendSectionGuidelines("BASE", List.of());
+
+ assertEquals("BASE", result);
+ }
+
+ @Test
+ void promptListsAllowedSectionEnumNames() {
+ String prompt = ProposalAiReviewPromptUtil.buildReviewPrompt(sampleModuleVersion(), List.of());
+
+ assertTrue(prompt.contains("CONTENT"));
+ assertTrue(prompt.contains("LEARNING_OUTCOMES"));
+ assertFalse(prompt.contains("Allowed section enum values: GENERAL"));
+ }
+}
|