Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 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
b757097
- changed Analyze prompt to original
ge94zec Aug 19, 2026
520103a
chore: update OpenAPI spec and generated client
github-actions[bot] Aug 19, 2026
c046f8d
Merge branch 'main' into chore/2346-enhance-performance-for-compliance
ge94zec Aug 21, 2026
ad2aa73
- fix empty-value serialization
ge94zec Aug 21, 2026
7c3b5ab
Merge remote-tracking branch 'origin/chore/2346-enhance-performance-f…
ge94zec Aug 21, 2026
617f7d3
- avoid unnecessary null initialization
ge94zec Aug 21, 2026
66c54e0
- fix empty-value serialization
ge94zec Aug 21, 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, jobId, 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(JsonInclude.Include.NON_NULL)
public record MapComplianceIssuesRequestDTO(
Comment thread
ge94zec marked this conversation as resolved.
String toLang,
@NotNull UUID jobId,
@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);
}
}
18 changes: 18 additions & 0 deletions src/main/resources/prompts/SnippetMapping.st
Comment thread
ge94zec marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
Map every numbered source issue to the exact phrase in TARGET that expresses the same issue.
Return exactly {count} strings, one per issue, preserving order and duplicates.
Every non-empty result MUST be copied verbatim from TARGET. Use "" only if no matching phrase exists.
Do not analyze compliance. Output one JSON string array immediately, without reasoning, prose, or markdown.

--- EXAMPLE ---
TARGET: Wir suchen ein junges, dynamisches Team für unsere Arbeitsgruppe.
ISSUES:
1 young and dynamic team
2 must hold a German passport
OUTPUT: ["junges, dynamisches Team", ""]

--- INPUT ---
TARGET:
{translatedText}

ISSUES:
{snippets}
1 change: 1 addition & 0 deletions src/main/webapp/app/generated/.openapi-generator/FILES
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,7 @@ model/job-preview-request.ts
model/keycloak-config.ts
model/keycloak-user-dto.ts
model/login-request-dto.ts
model/map-compliance-issues-request-dto.ts
model/otp-complete-dto.ts
model/otp-config.ts
model/overall-recommendation.ts
Expand Down
11 changes: 11 additions & 0 deletions src/main/webapp/app/generated/api/ai-resource-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { Observable } from 'rxjs';
import { ComplianceIssue } from '../model/compliance-issue';
import { JobFormDTO } from '../model/job-form-dto';
import { ExtractedApplicationDataDTO } from '../model/extracted-application-data-dto';
import { MapComplianceIssuesRequestDTO } from '../model/map-compliance-issues-request-dto';
import { TranslateComplianceDTO } from '../model/translate-compliance-dto';

@Injectable({ providedIn: 'root' })
Expand Down Expand Up @@ -93,6 +94,16 @@ export class AiResourceApi {
return this.http.put<Array<string>>(url, jobFormDTO);
}

/**
*
*
* @param mapComplianceIssuesRequestDTO
*/
mapComplianceIssues(mapComplianceIssuesRequestDTO: MapComplianceIssuesRequestDTO): Observable<Array<ComplianceIssue>> {
const url = `${this.basePath}/api/ai/map-compliance-issues`;
return this.http.post<Array<ComplianceIssue>>(url, mapComplianceIssuesRequestDTO);
}

/**
*
*
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
/**
* OpenAPI definition
* No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator)
*
* API Version: v0
*
*
* NOTE: This file is auto-generated. Do not edit manually.
*/

import type { ComplianceIssue } from './compliance-issue';

export interface MapComplianceIssuesRequestDTO {
readonly complianceIssues: Array<ComplianceIssue>;
readonly jobId: string;
readonly toLang?: string;
readonly translatedText: string;
}
Loading
Loading