diff --git a/openapi/openapi.yaml b/openapi/openapi.yaml index 99364ae450..0aa1becea5 100644 --- a/openapi/openapi.yaml +++ b/openapi/openapi.yaml @@ -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] @@ -3333,6 +3350,7 @@ components: explanation: {type: string} id: {type: string} language: {type: string} + suggestion: {type: string} text: {type: string} ConflictDataDTO: type: object @@ -3880,6 +3898,17 @@ 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} + text: {type: string, minLength: 1} + toLang: {type: string} + translatedText: {type: string, minLength: 1} + required: [complianceIssues, text, translatedText] MultipartUploadRequest: type: object properties: diff --git a/src/main/java/de/tum/cit/aet/ai/domain/ComplianceIssue.java b/src/main/java/de/tum/cit/aet/ai/domain/ComplianceIssue.java index d02ae136b6..75eb9cb0b0 100644 --- a/src/main/java/de/tum/cit/aet/ai/domain/ComplianceIssue.java +++ b/src/main/java/de/tum/cit/aet/ai/domain/ComplianceIssue.java @@ -29,5 +29,6 @@ public class ComplianceIssue { @Enumerated(EnumType.STRING) private ComplianceAction action; + private String suggestion; private String language; } diff --git a/src/main/java/de/tum/cit/aet/ai/dto/MapComplianceIssuesRequestDTO.java b/src/main/java/de/tum/cit/aet/ai/dto/MapComplianceIssuesRequestDTO.java new file mode 100644 index 0000000000..e7d1668ab7 --- /dev/null +++ b/src/main/java/de/tum/cit/aet/ai/dto/MapComplianceIssuesRequestDTO.java @@ -0,0 +1,17 @@ +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( + String toLang, + UUID jobId, + @NotBlank String text, + @NotBlank String translatedText, + @NotNull List complianceIssues +) {} diff --git a/src/main/java/de/tum/cit/aet/ai/service/AiService.java b/src/main/java/de/tum/cit/aet/ai/service/AiService.java index 1523387201..20c3a52b7e 100644 --- a/src/main/java/de/tum/cit/aet/ai/service/AiService.java +++ b/src/main/java/de/tum/cit/aet/ai/service/AiService.java @@ -6,6 +6,7 @@ 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.application.service.ApplicationService; import de.tum.cit.aet.core.documents.service.DocumentService; import de.tum.cit.aet.core.dto.GenderBiasAnalysisResponse; @@ -27,6 +28,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; @@ -74,6 +76,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; @@ -475,7 +480,10 @@ public List analyzeJobDescription( ) .call() .entity(new ParameterizedTypeReference<>() {}); - complianceIssues.forEach(issue -> issue.setLanguage(lang)); + complianceIssues.forEach(issue -> { + issue.setId(UUID.randomUUID().toString()); + issue.setLanguage(lang); + }); aiFeatureToggleService.recordSuccess(); } catch (Exception e) { aiFeatureToggleService.recordFailure(); @@ -496,4 +504,88 @@ public List 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, original text, translated text, target language, and job ID + * @return the persisted list of mapped issues, in the same order as sourceIssues + */ + public List mapComplianceIssues(MapComplianceIssuesRequestDTO request) { + if (request.complianceIssues() == null) { + return List.of(); + } + List mappableIssues = request + .complianceIssues() + .stream() + .filter(issue -> issue != null && issue.getText() != null && !issue.getText().isBlank()) + .toList(); + + // Issues without a text snippet cannot produce a highlight and must not + // invalidate the complete mapping batch. + if (mappableIssues.isEmpty()) { + jobService.updateComplianceIssues(request.jobId(), List.of(), request.toLang()); + return List.of(); + } + // Missing target text only means "cannot map" -> do not clear existing issues. + if (request.translatedText() == null || request.translatedText().isBlank()) { + return List.of(); + } + if (!aiFeatureToggleService.isAiAvailable()) { + return List.of(); + } + + String issues = mappableIssues + .stream() + .map( + issue -> "Text: " + issue.getText().trim() + "\nSuggestion: " + (issue.getSuggestion() == null ? "" : issue.getSuggestion()) + ) + .collect(Collectors.joining("\n---\n")); + + List mappedTexts; + try { + mappedTexts = chatClient + .prompt() + .user(u -> + u + .text(snippetMappingResource) + .param("issues", issues) + .param("jobDescription", request.text()) + .param("translatedText", request.translatedText()) + .param("targetLanguage", request.toLang()) + ) + .call() + .entity(new ParameterizedTypeReference>() {}); + aiFeatureToggleService.recordSuccess(); + } catch (Exception e) { + aiFeatureToggleService.recordFailure(); + throw new InternalServerException("Compliance issue mapping failed", e); + } + + if (mappedTexts == null || mappedTexts.size() != mappableIssues.size() * 2) { + aiFeatureToggleService.recordFailure(); + throw new InternalServerException("Mapping returned an invalid number of snippets"); + } + + List mappedIssues = new ArrayList<>(); + for (int i = 0; i < mappableIssues.size(); i++) { + ComplianceIssue sourceIssue = mappableIssues.get(i); + mappedIssues.add( + new ComplianceIssue( + sourceIssue.getId(), + sourceIssue.getCategory(), + mappedTexts.get(i * 2).trim(), + sourceIssue.getArticle(), + sourceIssue.getExplanation(), + sourceIssue.getAction(), + mappedTexts.get(i * 2 + 1).trim(), + request.toLang() + ) + ); + } + + jobService.updateComplianceIssues(request.jobId(), mappedIssues, request.toLang()); + return mappedIssues; + } } diff --git a/src/main/java/de/tum/cit/aet/ai/service/ComplianceScoreService.java b/src/main/java/de/tum/cit/aet/ai/service/ComplianceScoreService.java index b60c83307e..658e97ba08 100644 --- a/src/main/java/de/tum/cit/aet/ai/service/ComplianceScoreService.java +++ b/src/main/java/de/tum/cit/aet/ai/service/ComplianceScoreService.java @@ -18,7 +18,7 @@ public class ComplianceScoreService { /** * Calculates a legal compliance score based on a hierarchical risk model. * * The calculation follows the Gatekeeper-Principle for severe risks and Exponential Decay - * for minor issues. If a CRITICAL_AGG violation is detected, the score is immediately 0 + * for minor issues. If a CRITICAL or DSGVO violation is detected, the score is immediately 0 * (Veto-Principle), as these represent non-negotiable legal liabilities. * * For transparency issues, the score is reduced multiplicatively using the formula * S(n) = 100 * 0.85^n. The decay factor of 0.85 is set to trigger a critical @@ -39,16 +39,32 @@ protected int calculateLegalScore(List compliance) { .filter(i -> ComplianceCategory.CRITICAL_AGG == i.getCategory()) .count(); + long dsgvoCount = compliance + .stream() + .filter(i -> ComplianceCategory.DSGVO_MINIMIZATION == i.getCategory()) + .count(); + if (criticalCount > 0) { return 0; } + if (dsgvoCount > 0) { + return 0; + } + long transparencyCount = compliance .stream() .filter(i -> ComplianceCategory.TRANSPARENCY == i.getCategory()) .count(); - double score = 100.0 * Math.pow(PENALTY_FACTOR, transparencyCount); + long publicSectorCount = compliance + .stream() + .filter(i -> ComplianceCategory.PUBLIC_SECTOR == i.getCategory()) + .count(); + + long totalCount = transparencyCount + publicSectorCount; + + double score = 100.0 * Math.pow(PENALTY_FACTOR, totalCount); return (int) Math.max(0, Math.round(score)); } diff --git a/src/main/java/de/tum/cit/aet/ai/web/AiResource.java b/src/main/java/de/tum/cit/aet/ai/web/AiResource.java index 957c1a98b7..7ff17b0ac4 100644 --- a/src/main/java/de/tum/cit/aet/ai/web/AiResource.java +++ b/src/main/java/de/tum/cit/aet/ai/web/AiResource.java @@ -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; @@ -70,7 +73,7 @@ public ResponseEntity> generateJobApplicationDraftStream( @PutMapping(value = "translateJobDescriptionStream", produces = MediaType.TEXT_EVENT_STREAM_VALUE) public ResponseEntity> translateJobDescriptionStream( @RequestParam("toLang") String toLang, - @RequestBody TranslateComplianceDTO request + @Valid @RequestBody TranslateComplianceDTO request ) { if (!aiFeatureToggleService.isAiAvailable()) { return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE).build(); @@ -79,6 +82,22 @@ public ResponseEntity> 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> 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. @@ -132,7 +151,7 @@ public ResponseEntity> 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)); } } diff --git a/src/main/java/de/tum/cit/aet/job/service/JobService.java b/src/main/java/de/tum/cit/aet/job/service/JobService.java index 231f80229f..1d7b57ceba 100644 --- a/src/main/java/de/tum/cit/aet/job/service/JobService.java +++ b/src/main/java/de/tum/cit/aet/job/service/JobService.java @@ -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; @@ -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 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 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 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 complianceAnalysis, String lang) { + List 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); } } diff --git a/src/main/resources/config/liquibase/changelog/00000000000052_add_suggestion_field_to_complianceIssue.xml b/src/main/resources/config/liquibase/changelog/00000000000052_add_suggestion_field_to_complianceIssue.xml new file mode 100644 index 0000000000..f01653ed83 --- /dev/null +++ b/src/main/resources/config/liquibase/changelog/00000000000052_add_suggestion_field_to_complianceIssue.xml @@ -0,0 +1,13 @@ + + + + + + + + + + diff --git a/src/main/resources/config/liquibase/master.xml b/src/main/resources/config/liquibase/master.xml index 9e8760e0c1..2a9a3a2fd7 100644 --- a/src/main/resources/config/liquibase/master.xml +++ b/src/main/resources/config/liquibase/master.xml @@ -61,6 +61,7 @@ +