Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
76c5d6e
feat(ai): map translated compliance issues instead of re-analyzing ta…
ge94zec May 5, 2026
9adc3e9
updated openapi
ge94zec May 5, 2026
7946480
refactor: code
ge94zec May 5, 2026
7433eeb
Merge branch 'main' into chore/2346-enhance-performance-for-compliance
ge94zec May 8, 2026
5579180
updates:
ge94zec May 9, 2026
97f4653
Merge remote-tracking branch 'origin/chore/2346-enhance-performance-f…
ge94zec May 9, 2026
bd80f96
Merge branch 'main' into chore/2346-enhance-performance-for-compliance
ge94zec May 9, 2026
2d02039
updates:
ge94zec May 9, 2026
23549fd
Merge remote-tracking branch 'origin/chore/2346-enhance-performance-f…
ge94zec May 9, 2026
102ac7b
chore: update OpenAPI spec and generated client
github-actions[bot] May 9, 2026
b0c19fb
Merge branch 'main' into chore/2346-enhance-performance-for-compliance
ge94zec May 9, 2026
8d001be
Merge branch 'main' into chore/2346-enhance-performance-for-compliance
ge94zec May 9, 2026
ee463f7
Merge branch 'main' into chore/2346-enhance-performance-for-compliance
ge94zec May 11, 2026
b7e0862
change requests
ge94zec May 12, 2026
11e553a
chore: update OpenAPI spec and generated client
github-actions[bot] May 12, 2026
26f3cbd
updated client
ge94zec May 12, 2026
702dd6c
added AiServiceTest for analyze and map
ge94zec May 12, 2026
3aa2f39
Merge remote-tracking branch 'origin/main' into chore/2346-enhance-pe…
ge94zec May 12, 2026
dced024
\`Bugfix\`: Restore full entity graph on findByIdWithCompliance
az108 May 12, 2026
9f21677
Merge branch 'main' into chore/2346-enhance-performance-for-compliance
ge94zec May 13, 2026
630a36f
Merge branch 'main' into chore/2346-enhance-performance-for-compliance
ge94zec May 14, 2026
ba210c9
Merge branch 'main' into chore/2346-enhance-performance-for-compliance
ge94zec Jun 10, 2026
a035c0d
Merge branch 'main' into chore/2346-enhance-performance-for-compliance
ge94zec Aug 1, 2026
71a0b76
feat: replace second compliance analysis with snippet mapping for tra…
ge94zec Aug 1, 2026
8820354
ffix server test
ge94zec Aug 1, 2026
d8d704b
Merge branch 'main' into chore/2346-enhance-performance-for-compliance
ge94zec Aug 13, 2026
48f651c
fix: improve compliance issue mapping for translated job descriptions
ge94zec Aug 17, 2026
860c930
chore: update OpenAPI spec and generated client
github-actions[bot] Aug 17, 2026
ba889b1
fix client test
ge94zec Aug 17, 2026
ca5d5cc
Merge remote-tracking branch 'origin/chore/2346-enhance-performance-f…
ge94zec Aug 17, 2026
62be17d
removed tests
ge94zec Aug 17, 2026
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
27 changes: 27 additions & 0 deletions openapi/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,23 @@ paths:
schema:
type: array
items: {type: string}
/api/ai/map-compliance-issues:
post:
tags: [ai-resource]
operationId: mapComplianceIssues
requestBody:
content:
application/json:
schema: {$ref: '#/components/schemas/MapComplianceIssuesRequestDTO'}
required: true
responses:
'200':
description: OK
content:
application/json:
schema:
type: array
items: {$ref: '#/components/schemas/ComplianceIssue'}
/api/ai/translateJobDescriptionStream:
put:
tags: [ai-resource]
Expand Down Expand Up @@ -3880,6 +3897,16 @@ components:
email: {type: string, format: email, minLength: 1}
password: {type: string, minLength: 1}
required: [email, password]
MapComplianceIssuesRequestDTO:
type: object
properties:
complianceIssues:
type: array
items: {$ref: '#/components/schemas/ComplianceIssue'}
jobId: {type: string, format: uuid}
toLang: {type: string}
translatedText: {type: string, minLength: 1}
required: [complianceIssues, translatedText]
MultipartUploadRequest:
type: object
properties:
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package de.tum.cit.aet.ai.dto;

import com.fasterxml.jackson.annotation.JsonInclude;
import de.tum.cit.aet.ai.domain.ComplianceIssue;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import java.util.List;
import java.util.UUID;

@JsonInclude
public record MapComplianceIssuesRequestDTO(
Comment thread
ge94zec marked this conversation as resolved.
String toLang,
UUID jobId,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

jobId is the only field here without validation, while translatedText and complianceIssues carry @NotBlank / @NotNull. applyJobChangeForAnalysis opens with if (jobId == null) { return; } — no log, no exception. A request without it runs the expensive LLM mapping, returns 200 with the mapped issues, and persists nothing. The empty-source-issues early return is affected too. The generated OpenAPI model has readonly jobId?: string, so a spec-compliant caller is allowed to omit it.

@NotNull UUID jobId is what the server guidelines document — the input-DTO example in server-development.mdx (Request Validation) is literally public record CreateApplicationDTO(@NotBlank String motivation, @NotNull UUID jobId) {} — and what AssignSlotRequestDTO, BookSlotRequestDTO and UpdateApplicationDTO already do.

@NotBlank String translatedText,
@NotNull List<ComplianceIssue> complianceIssues
) {}
74 changes: 74 additions & 0 deletions src/main/java/de/tum/cit/aet/ai/service/AiService.java
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
import de.tum.cit.aet.ai.domain.ComplianceIssue;
import de.tum.cit.aet.ai.dto.ExtractedApplicationDataDTO;
import de.tum.cit.aet.ai.dto.ExtractedCertificateDataDTO;
import de.tum.cit.aet.ai.dto.MapComplianceIssuesRequestDTO;
import de.tum.cit.aet.ai.util.SnippetMatcher;
import de.tum.cit.aet.application.service.ApplicationService;
import de.tum.cit.aet.core.documents.service.DocumentService;
import de.tum.cit.aet.core.dto.GenderBiasAnalysisResponse;
Expand All @@ -27,6 +29,7 @@
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicReference;
import java.util.stream.Collectors;
import javax.imageio.ImageIO;
import lombok.extern.slf4j.Slf4j;
import org.apache.pdfbox.Loader;
Expand Down Expand Up @@ -74,6 +77,9 @@ public class AiService {
@Value("classpath:prompts/AnalyzeComplianceText.st")
private Resource complianceResource;

@Value("classpath:prompts/SnippetMapping.st")
private Resource snippetMappingResource;

private final ChatClient chatClient;

private final JobService jobService;
Expand Down Expand Up @@ -496,4 +502,72 @@ public List<ComplianceIssue> analyzeJobDescription(

return complianceIssues;
}

/**
* Maps the snippets of an existing source-language compliance analysis onto the
* translated job description, avoiding a second full LLM compliance analysis.
*
* @param request DTO containing the source compliance issues, translated text, target language, and job ID
* @return the persisted list of mapped issues, in the same order as sourceIssues
*/
public List<ComplianceIssue> mapComplianceIssues(MapComplianceIssuesRequestDTO request) {
// Empty source issues mean "no issues found" -> clear stale target-language issues.
if (request.complianceIssues().isEmpty()) {
jobService.updateComplianceIssues(request.jobId(), List.of(), request.toLang());
return List.of();
}

String snippets = java.util.stream.IntStream.range(0, request.complianceIssues().size())
.mapToObj(index -> (index + 1) + "\t" + request.complianceIssues().get(index).getText().trim())
.collect(Collectors.joining("\n"));

List<String> mappedTexts;
try {
mappedTexts = chatClient
.prompt()
.user(u ->
u
.text(snippetMappingResource)
.param("count", String.valueOf(request.complianceIssues().size()))
.param("snippets", snippets)
.param("translatedText", request.translatedText())
)
.call()
.entity(new ParameterizedTypeReference<List<String>>() {});
aiFeatureToggleService.recordSuccess();
} catch (Exception e) {
aiFeatureToggleService.recordFailure();
throw new InternalServerException("Compliance issue mapping failed", e);
}

if (mappedTexts == null || mappedTexts.size() != request.complianceIssues().size()) {
aiFeatureToggleService.recordFailure();
throw new InternalServerException("Mapping returned an invalid number of snippets");
}

List<ComplianceIssue> mappedIssues = new ArrayList<>();
for (int i = 0; i < request.complianceIssues().size(); i++) {
String mappedText = mappedTexts.get(i);
String mapped = mappedText == null ? null : mappedText.trim();
if (!SnippetMatcher.isVerbatim(request.translatedText(), mapped)) {
log.warn("Snippet {} not found in translated text, dropping", i);
continue;
}
ComplianceIssue sourceIssue = request.complianceIssues().get(i);
mappedIssues.add(
new ComplianceIssue(
sourceIssue.getId(),
sourceIssue.getCategory(),
mapped,
sourceIssue.getArticle(),
sourceIssue.getExplanation(),
sourceIssue.getAction(),
request.toLang()
)
);
}

jobService.updateComplianceIssues(request.jobId(), mappedIssues, request.toLang());
return mappedIssues;
}
}
22 changes: 22 additions & 0 deletions src/main/java/de/tum/cit/aet/ai/util/SnippetMatcher.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package de.tum.cit.aet.ai.util;

/**
* Validates mapped compliance snippets against the translated target text.
*/
public final class SnippetMatcher {

private SnippetMatcher() {}

/**
* Checks whether a non-empty candidate occurs verbatim in the target text.
* Matching is case-sensitive because the model copies the phrase verbatim and
* the client searches for that exact phrase in the editor.
*
* @param targetText translated job description
* @param candidate mapped compliance snippet
* @return {@code true} when the candidate is non-empty and occurs verbatim
*/
public static boolean isVerbatim(String targetText, String candidate) {
return candidate != null && !candidate.isEmpty() && targetText.contains(candidate);
}
}
23 changes: 21 additions & 2 deletions src/main/java/de/tum/cit/aet/ai/web/AiResource.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,16 @@

import de.tum.cit.aet.ai.domain.ComplianceIssue;
import de.tum.cit.aet.ai.dto.ExtractedApplicationDataDTO;
import de.tum.cit.aet.ai.dto.MapComplianceIssuesRequestDTO;
import de.tum.cit.aet.ai.dto.TranslateComplianceDTO;
import de.tum.cit.aet.ai.service.AiFeatureToggleService;
import de.tum.cit.aet.ai.service.AiService;
import de.tum.cit.aet.core.security.annotations.ApplicantOrAdmin;
import de.tum.cit.aet.core.security.annotations.ProfessorOrEmployeeOrAdmin;
import de.tum.cit.aet.job.dto.JobFormDTO;
import jakarta.validation.Valid;
import java.util.List;
import java.util.UUID;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Profile;
import org.springframework.http.HttpStatus;
Expand Down Expand Up @@ -70,7 +73,7 @@ public ResponseEntity<Flux<String>> generateJobApplicationDraftStream(
@PutMapping(value = "translateJobDescriptionStream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public ResponseEntity<Flux<String>> translateJobDescriptionStream(
@RequestParam("toLang") String toLang,
@RequestBody TranslateComplianceDTO request
@Valid @RequestBody TranslateComplianceDTO request
) {
if (!aiFeatureToggleService.isAiAvailable()) {
return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE).build();
Expand All @@ -79,6 +82,22 @@ public ResponseEntity<Flux<String>> translateJobDescriptionStream(
return ResponseEntity.ok(aiService.translateTextStream(request.text(), toLang));
}

/**
* Maps compliance text snippets from original lang to target lang during stream-translate.
*
* @param request A DTO containing the text to translate
* @return a ResponseEntity of mapped snippets for target compliance analysis
*/
@ProfessorOrEmployeeOrAdmin
@PostMapping(value = "map-compliance-issues", produces = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<List<ComplianceIssue>> mapComplianceIssues(@Valid @RequestBody MapComplianceIssuesRequestDTO request) {
if (!aiFeatureToggleService.isAiAvailable()) {
return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE).build();
}
log.info("POST /api/ai/map-compliance-issues - Compliance snippet-mapping request received (toLang={})", request.toLang());
return ResponseEntity.ok(aiService.mapComplianceIssues(request));
}

/**
* Extracts applicant data from PDF files using AI and persists the extracted
* values into the application entity.
Expand Down Expand Up @@ -132,7 +151,7 @@ public ResponseEntity<List<ComplianceIssue>> analyzeJobDescriptionForCompliance(
@RequestParam(defaultValue = "en") String userLanguage
) {
// Service skips LLM calls internally when AI is disabled, rule-based gender bias analysis and score computation remain enabled
log.info("POST /api/ai/analyzeJobDescription - Request received (toLang={})", descriptionLanguage);
log.info("POST /api/ai/analyzeJobDescription - Compliance analysis request received (toLang={})", descriptionLanguage);
return ResponseEntity.ok(aiService.analyzeCurrentJobDescription(jobForm, descriptionLanguage, userLanguage));
}
}
52 changes: 41 additions & 11 deletions src/main/java/de/tum/cit/aet/job/service/JobService.java
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
import java.util.Objects;
import java.util.Set;
import java.util.UUID;
import java.util.function.Consumer;
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
Expand Down Expand Up @@ -543,30 +544,59 @@ public void updateJobDescriptionLanguage(String jobId, String toLang, String tra
}

/**
* Updates AI-generated analysis fields for a job.
* Updates AI-generated analysis fields for a job: replaces the compliance
* issues for the given language and overwrites the combined gender bias score.
*
* @param jobId the job identifier
* @param score the combined AI score to persist
* @param complianceAnalysis the compliance issues detected for the job description
* @param lang the language for which existing issues should be replaced
* @param jobId the job identifier
* @param score the combined AI score to persist
* @param complianceAnalysis compliance issues detected for the given language
* @param lang the analyzed language ("de" or "en")
*/
public void updateAiAnalysis(UUID jobId, int score, List<ComplianceIssue> complianceAnalysis, String lang) {
applyJobChangeForAnalysis(jobId, job -> {
replaceComplianceIssuesForLanguage(job, complianceAnalysis, lang);
job.setGenderBiasScore(score);
});
}

/**
* Replaces the compliance issues for a single language without touching the
* gender bias score. Used by the snippet-mapping flow, where the score has
* already been written by the source-language analysis and must not be reset.
*
* @param jobId the job identifier
* @param complianceAnalysis compliance issues for the target language
* @param lang the target language ("de" or "en")
*/
public void updateComplianceIssues(UUID jobId, List<ComplianceIssue> complianceAnalysis, String lang) {
applyJobChangeForAnalysis(jobId, job -> replaceComplianceIssuesForLanguage(job, complianceAnalysis, lang));
}

/**
* Loads the job, applies the given change, and persists in a single repository write.
*/
private void applyJobChangeForAnalysis(UUID jobId, Consumer<Job> changes) {
if (jobId == null) {
return;
}

Job job = jobRepository.findByIdWithCompliance(jobId).orElseThrow(() -> EntityNotFoundException.forId("Job", jobId));
currentUserService.isAdminOrMemberOf(job.getResearchGroup());
changes.accept(job);
jobRepository.save(job);
}

// Keep issues from the other language, add new ones for target language
List issuesToSave = job
/**
* Replaces compliance issues for the given language.
* Issues from other languages stay unchanged.
* Updates the job in place and caller saves it.
*/
private void replaceComplianceIssuesForLanguage(Job job, List<ComplianceIssue> complianceAnalysis, String lang) {
List<ComplianceIssue> issuesToSave = job
.getComplianceIssues()
.stream()
.filter(issue -> !Objects.equals(issue.getLanguage(), lang))
.collect(Collectors.toCollection(ArrayList::new));

issuesToSave.addAll(complianceAnalysis);
job.setGenderBiasScore(score);
job.setComplianceIssues(issuesToSave);
jobRepository.save(job);
}
}
Loading
Loading