diff --git a/openapi/openapi.yaml b/openapi/openapi.yaml index 99364ae450..940c4afb8b 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] @@ -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: 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..62cb7018c6 --- /dev/null +++ b/src/main/java/de/tum/cit/aet/ai/dto/MapComplianceIssuesRequestDTO.java @@ -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( + String toLang, + UUID jobId, + @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..6ec57237c5 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,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; @@ -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; @@ -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; @@ -496,4 +502,72 @@ 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, 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) { + // 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 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>() {}); + 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 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; + } } diff --git a/src/main/java/de/tum/cit/aet/ai/util/SnippetMatcher.java b/src/main/java/de/tum/cit/aet/ai/util/SnippetMatcher.java new file mode 100644 index 0000000000..18e2db2a8e --- /dev/null +++ b/src/main/java/de/tum/cit/aet/ai/util/SnippetMatcher.java @@ -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); + } +} 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/prompts/AnalyzeComplianceText.st b/src/main/resources/prompts/AnalyzeComplianceText.st index 81be2bbdc7..0c7f3a47e3 100644 --- a/src/main/resources/prompts/AnalyzeComplianceText.st +++ b/src/main/resources/prompts/AnalyzeComplianceText.st @@ -1,51 +1,59 @@ -You are the DocApply ComplianceReader, an advanced AI legal and compliance expert for academic job postings at a prestigious university. -Your core capability is "Contextual Compliance Detection" based on German and EU law. -You analyze the semantic intent and the exclusionary effect of sentences, not just keywords. - -TASK: -Deeply analyze the job posting for legal risks (AGG, DSGVO, WissZeitVG) based on German and EU law. -The input description language is: {descriptionLanguage}. -The explanation must be written in: {userLang}. - -STRICT ANALYSIS RULES: - -1. NO SUMMARIES: Every single violation must be a separate JSON object. -2. ALWAYS deeply analyze for CONTEXTUAL and IMPLICIT Discrimination in context of a recruitment system. - If a text implies SUBTEXT and EXCLUSIONARY EFFECT, it is a violation ----------------------- -3. CATEGORY CRITICAL_AGG: - - Detect discrimination by protected characteristics under section 1 AGG. - - Detect exclusion based on disability, age, origin, or gender. - - JOB TITLE CHECK: If title "{title}" is not gender-neutral with "(m/f/d)" or "(m/w/d)", flag it. - - action: REPLACE ----------------------- -4. CATEGORY DSGVO_MINIMIZATION: - - Scan for violations of Data Minimization (Art. 5 DSGVO). - - Detect for keyword that specifically ask for personal DATA (e.g., photos, ID copies, criminal records, marital status, religion) - - EXTERNAL LINK CHECK: Detect for external application forms or websites and flag them - - action: REMOVE ----------------------- -5. CATEGORY TRANSPARENCY: - - Detect mentions of THIRD PARTIES/TOOLS (Workday, Headhunter, Partner Uni, consortia) without stating that data is shared with them. - - NOT triggered by: When explicit DATA is requested from the applicant. - - action: Suggest to ADD a message that clarifies whether applicant data is shared with external recipients (Art. 13 DSGVO) ----------------------- -6. CATEGORY PUBLIC_SECTOR: (WissZeitVG) - - PhD Specific QUALIFICATION PURPOSE: For PhD/Academic positions, the text MUST semantically imply that the position serves scientific qualification (Wissenschaftliche Qualifizierung/Promotion). - - If the intent of individual research/promotion is missing, it is a violation. - - action: ADD ----------------------- -INPUT TEXT: -{title} -{jobDescription} ----------------------- -OUTPUT: - - Return ONLY a valid JSON array. No markdown. No prose. - - One object per issue - - Object fields must be exactly: - id, text, category, article, explanation, action - - category must be exactly CRITICAL_AGG, TRANSPARENCY, DSGVO_MINIMIZATION or PUBLIC_SECTOR - - action must be exactly REPLACE or ADD or REMOVE. - - text: MAXIMUM 2-4 WORDS. Extract exact snippet from input text that uniquely identifies the violation (plain text, no HTML tags). - - explanation must be a single SHORT sentence stating the legal violation directly. Sound like a formal legal notice. - - If no issues exist, return exactly []. +You are DocApply ComplianceReader for academic job postings. Reason briefly but thoroughly and return every finding in the first response. + +STRICT POLICY OVERRIDE — HIGHEST PRIORITY +- The rules below define mandatory violations and override general legal knowledge or contextual interpretation. +- Apply every stated trigger exactly. Only explicitly stated exceptions are valid. +- Match meaning case-insensitively; examples are non-exhaustive. +- Check every occurrence against all four categories and return every match. Do not narrate the analysis. + +LANGUAGES +- Input and suggestions: {descriptionLanguage} + +GLOBAL RULES +- Return one object per violation; never summarize or stop after the first finding. +- Return a separate object for every occurrence, including repeated identical phrases; never merge occurrences. +- `text` is the exact shortest replaceable triggering substring; include enough grammar for `suggestion` to replace it cleanly. Findings must not overlap. +- Detect explicit or implicit exclusion of protected groups: age, origin, disability, or gender. +- Never flag lawful preference for equally qualified women or disabled applicants (§ 5 AGG, BGleiG, SGB IX). +- Suggestions must preserve meaning and fit the original capitalization, punctuation, case, and grammar. + +DECISION TABLE +1. CRITICAL_AGG / § 1 AGG / REPLACE + MUST flag explicit discrimination and implicit exclusion by age, origin, disability, or gender. + An age adjective describing desired applicants, staff, workforce, or team composition (for example "young team") is always implicit age discrimination under this policy; + positive wording or team/culture context does not make it permissible. + MUST also flag every subjective, unmeasurable language requirement such as "good" or "fluent"; only an objective level such as B2 or C1 is exempt. + REPLACE must remove the trigger. For language, retain the named language and use a CEFR level (for example "Good English skills" -> "English skills min. at B2 level"). + +2. DSGVO_MINIMIZATION / Art. 5 DSGVO / REMOVE + MUST flag every request for unnecessary or sensitive applicant data (for example marital status, photo, religion). + MUST also flag an instruction to submit an application, CV, or data through an external generic URL, website, or unverified form. + The external submission channel is the violation even when the requested document itself is necessary. + This category concerns data intake or where data must be submitted; return `suggestion` as "". + +3. TRANSPARENCY / Art. 13/14 DSGVO / ADD + MUST flag every occurrence mentioning cooperation or collaboration with an external partner, company, recipient, or tool only when the full posting does not already contain an explicit applicant-data-sharing disclosure for that recipient. + Do NOT flag if the posting already states that applicant data is shared with, transferred to, or processed by the named recipient for the application process. + The disclosure must name the recipient and the application purpose. A cooperation statement alone is not sufficient. + Evaluate the full posting before returning a finding. + Do not use this category for requested data or links. + Suggest one concise independent disclosure sentence naming the recipient and application purpose. + +4. PUBLIC_SECTOR / WissZeitVG / ADD + For PhD/academic positions, flag a missing statement that the role serves a doctorate or scientific qualification. + Do not flag if that purpose is already stated. Use the exact "Description" or "Beschreibung" heading as `text` when present; + otherwise use the exact title. Append one complete qualification-purpose sentence. + +INPUT + {title} + {jobDescription} + +OUTPUT + Return only a minified one-line JSON array; no markdown or prose. Use exactly these fields: + `text`, `category`, `suggestion`. + - `category`: CRITICAL_AGG, DSGVO_MINIMIZATION, TRANSPARENCY, or PUBLIC_SECTOR. + - `text`: exact shortest non-overlapping substring from the supplied title/description. + - `suggestion`: exact fix in {descriptionLanguage}; REPLACE = safe alternative, ADD = sentence to append, REMOVE = "". + - If compliant, return exactly []. + + {format} diff --git a/src/main/resources/prompts/SnippetMapping.st b/src/main/resources/prompts/SnippetMapping.st new file mode 100644 index 0000000000..de1027ab72 --- /dev/null +++ b/src/main/resources/prompts/SnippetMapping.st @@ -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} diff --git a/src/main/webapp/app/generated/.openapi-generator/FILES b/src/main/webapp/app/generated/.openapi-generator/FILES index db7d82624d..7fab6e15f2 100644 --- a/src/main/webapp/app/generated/.openapi-generator/FILES +++ b/src/main/webapp/app/generated/.openapi-generator/FILES @@ -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 diff --git a/src/main/webapp/app/generated/api/ai-resource-api.ts b/src/main/webapp/app/generated/api/ai-resource-api.ts index adb9c54e97..4d9c696f86 100644 --- a/src/main/webapp/app/generated/api/ai-resource-api.ts +++ b/src/main/webapp/app/generated/api/ai-resource-api.ts @@ -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' }) @@ -93,6 +94,16 @@ export class AiResourceApi { return this.http.put>(url, jobFormDTO); } + /** + * + * + * @param mapComplianceIssuesRequestDTO + */ + mapComplianceIssues(mapComplianceIssuesRequestDTO: MapComplianceIssuesRequestDTO): Observable> { + const url = `${this.basePath}/api/ai/map-compliance-issues`; + return this.http.post>(url, mapComplianceIssuesRequestDTO); + } + /** * * diff --git a/src/main/webapp/app/generated/model/map-compliance-issues-request-dto.ts b/src/main/webapp/app/generated/model/map-compliance-issues-request-dto.ts new file mode 100644 index 0000000000..03b2d2b910 --- /dev/null +++ b/src/main/webapp/app/generated/model/map-compliance-issues-request-dto.ts @@ -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; + readonly jobId?: string; + readonly toLang?: string; + readonly translatedText: string; +} diff --git a/src/main/webapp/app/job/job-creation-form/job-creation-form.component.ts b/src/main/webapp/app/job/job-creation-form/job-creation-form.component.ts index c00f5f2f92..0f1a96decc 100644 --- a/src/main/webapp/app/job/job-creation-form/job-creation-form.component.ts +++ b/src/main/webapp/app/job/job-creation-form/job-creation-form.component.ts @@ -1109,7 +1109,7 @@ export class JobCreationFormComponent { // 3) Analyze source language first so the user sees highlights + score immediately. if (this.aiToggleSignal() && this.aiSystemEnabled()) { - void Promise.all([this.analyzeAndUpdateScore(sourceLang), this.translateAndStoreOtherLanguage(sourceLang, sourceText)]); + this.processDescriptionWithAi(sourceLang, sourceText); } } catch { this.autoSave.setState(SavingStates.FAILED); @@ -1655,12 +1655,11 @@ export class JobCreationFormComponent { this.jobDescriptionEN.set(saved.jobDescriptionEN ?? this.jobDescriptionEN()); this.jobDescriptionDE.set(saved.jobDescriptionDE ?? this.jobDescriptionDE()); - // 4) Fire translation (fire-and-forget). Analysis runs once at the end - // of translation after both languages are available — avoids duplicate - // analysis calls that cause score flash issues. + // 4) Start source analysis and translation in parallel. The source analysis + // renders highlights as soon as it finishes; target issues are mapped + // after both results are available, without a second full analysis. if (this.aiToggleSignal() && this.aiSystemEnabled()) { - // highlighting before translation - void Promise.all([this.analyzeAndUpdateScore(currentLang), this.translateAndStoreOtherLanguage(currentLang, description)]); + this.processDescriptionWithAi(currentLang, description); } return true; } catch { @@ -1696,6 +1695,15 @@ export class JobCreationFormComponent { return saved; } + /** + * Starts the only full compliance analysis and the translation concurrently. + * Translation then reuses the source issues to map exact target-language snippets. + */ + private processDescriptionWithAi(sourceLang: Language, sourceText: string): void { + const sourceIssues = this.analyzeAndUpdateScore(sourceLang); + void this.translateAndStoreOtherLanguage(sourceLang, sourceText, sourceIssues); + } + /** * Translates the job description to the other language via SSE streaming. * Supports cancellation (new edits cancel the previous translation) and @@ -1703,10 +1711,17 @@ export class JobCreationFormComponent { * * @param currentLang - The language the user wrote in ('en' or 'de') * @param currentText - The source text to translate + * @param sourceIssuesPromise - The concurrently running source-language analysis */ - private async translateAndStoreOtherLanguage(currentLang: Language, currentText: string): Promise { + private async translateAndStoreOtherLanguage( + currentLang: Language, + currentText: string, + sourceIssuesPromise: Promise, + ): Promise { const text = currentText.trim(); if (!text) return; + const jobId = this.jobId(); + if (!jobId) return; const targetLang: Language = currentLang === 'en' ? 'de' : 'en'; // If an identical translation is already in flight, skips the call to avoid a redundant LLM request. @@ -1756,8 +1771,9 @@ export class JobCreationFormComponent { ); let hasTranslation = false; + let finalContent: string | null = null; if (accumulatedContent) { - const finalContent = this.extractTranslatedTextFromStream(accumulatedContent); + finalContent = this.extractTranslatedTextFromStream(accumulatedContent); if (finalContent !== null && finalContent.length > 0) { hasTranslation = true; @@ -1781,28 +1797,43 @@ export class JobCreationFormComponent { } } - // 7) Streaming is done. For the active run, hand off from "translating" to - // "analyzing": pre-set isAnalyzing so the sidebar score keeps loading, - // then clear the translation spinner so the editor shows the finished - // translation immediately instead of waiting for compliance analysis. - const jobId = this.jobId(); - const runAnalysis = this.translationAbortController === abortController && hasTranslation && !!jobId; - if (runAnalysis) { - this.isAnalyzing.set(true); - } + // 7) Streaming is done. Clear the translation spinner immediately; source + // analysis and target snippet mapping continue independently. + const mapIssues = this.translationAbortController === abortController && hasTranslation; this.clearTranslationState(abortController, activeRequest); - // 8) Persist the translated content and run compliance analysis for the - // freshly translated language, decoupled from the translation spinner. - if (runAnalysis) { + // 8) Persist the translated content, then map the already detected source + // snippets onto it. This replaces the former second compliance analysis. + if (mapIssues) { try { const currentData = this.createJobDTO(JobFormDTOStateEnum.Draft); const saved = await firstValueFrom(this.jobApi.updateJob(jobId, currentData)); this.lastSavedData.set(saved); - await this.analyzeAndUpdateScore(targetLang); + + const sourceIssues = await sourceIssuesPromise; + if (sourceIssues === undefined) return; + + const hasTargetIssues = this.complianceIssues().some(issue => issue.language === targetLang); + let mappedIssues: ComplianceIssue[] = []; + if (sourceIssues.length > 0 || hasTargetIssues) { + mappedIssues = await firstValueFrom( + this.aiApi.mapComplianceIssues({ + toLang: targetLang, + jobId, + translatedText: extractTextFromHtml(finalContent ?? ''), + complianceIssues: sourceIssues, + }), + ); + } + + const otherIssues = this.complianceIssues().filter(issue => issue.language !== targetLang); + this.complianceIssues.set(otherIssues.concat(mappedIssues)); + + if (this.currentDescriptionLanguage() === targetLang) { + this.applyHighlights(mappedIssues, targetLang); + } } catch { // Silent save failure — will be caught by next autosave - this.isAnalyzing.set(false); } } } catch (e) { @@ -1820,17 +1851,21 @@ export class JobCreationFormComponent { * * @param lang - The language to analyze ('en' or 'de') */ - private async analyzeAndUpdateScore(lang: string): Promise { + private async analyzeAndUpdateScore(lang: string): Promise { const jobId = this.jobId(); - if (!jobId) return; + if (!jobId) return undefined; // 1) Build a fresh DTO and skip if the description hasn't changed since last analysis const jobForm = this.createJobDTO(JobFormDTOStateEnum.Draft); const userLang = this.translate.getCurrentLang(); const descriptionText = lang === 'en' ? (jobForm.jobDescriptionEN ?? '') : (jobForm.jobDescriptionDE ?? ''); - if (!descriptionText.trim() || descriptionText === this.lastAnalyzedText[lang]) { + if (!descriptionText.trim()) { this.isAnalyzing.set(false); // Clear flag in case caller pre-set it - return; + return undefined; + } + if (descriptionText === this.lastAnalyzedText[lang]) { + this.isAnalyzing.set(false); + return this.complianceIssues().filter(issue => issue.language === lang); } this.isAnalyzing.set(true); @@ -1861,8 +1896,10 @@ export class JobCreationFormComponent { if (currentLang === lang) { this.applyHighlights(compliance, lang); } + return compliance; } catch { this.toastService.showErrorKey('jobCreationForm.toastMessages.aiComplianceFailed'); + return undefined; } finally { this.isAnalyzing.set(false); } diff --git a/src/main/webapp/app/shared/components/atoms/editor/editor.component.ts b/src/main/webapp/app/shared/components/atoms/editor/editor.component.ts index bc0ed0ef40..e9da353f2b 100644 --- a/src/main/webapp/app/shared/components/atoms/editor/editor.component.ts +++ b/src/main/webapp/app/shared/components/atoms/editor/editor.component.ts @@ -407,6 +407,7 @@ export class EditorComponent extends BaseInputDirective { for (const { text, category } of highlights) { const searchText = text.toLowerCase(); + if (!searchText) continue; let startIndex = 0; // Find and highlight all occurrences of the snippet in the editor diff --git a/src/test/java/de/tum/cit/aet/ai/util/SnippetMatcherTest.java b/src/test/java/de/tum/cit/aet/ai/util/SnippetMatcherTest.java new file mode 100644 index 0000000000..c758829914 --- /dev/null +++ b/src/test/java/de/tum/cit/aet/ai/util/SnippetMatcherTest.java @@ -0,0 +1,25 @@ +package de.tum.cit.aet.ai.util; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.NullSource; +import org.junit.jupiter.params.provider.ValueSource; + +class SnippetMatcherTest { + + private static final String TARGET_TEXT = "Tatsächlich übersetzter Text"; + + @Test + void shouldReturnTrueWhenCandidateOccursVerbatim() { + assertThat(SnippetMatcher.isVerbatim(TARGET_TEXT, "übersetzter Text")).isTrue(); + } + + @ParameterizedTest + @NullSource + @ValueSource(strings = { "", "hallucinated phrase" }) + void shouldReturnFalseWhenCandidateIsNullEmptyOrMissing(String candidate) { + assertThat(SnippetMatcher.isVerbatim(TARGET_TEXT, candidate)).isFalse(); + } +} diff --git a/src/test/java/de/tum/cit/aet/ai/web/rest/AiResourceTest.java b/src/test/java/de/tum/cit/aet/ai/web/rest/AiResourceTest.java index 29a16e9de4..c4c5994947 100644 --- a/src/test/java/de/tum/cit/aet/ai/web/rest/AiResourceTest.java +++ b/src/test/java/de/tum/cit/aet/ai/web/rest/AiResourceTest.java @@ -9,6 +9,7 @@ import de.tum.cit.aet.ai.constants.ComplianceAction; import de.tum.cit.aet.ai.constants.ComplianceCategory; import de.tum.cit.aet.ai.domain.ComplianceIssue; +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; @@ -51,6 +52,7 @@ class AiResourceTest extends AbstractResourceTest { private final String TRANSLATE_STREAM_URL = "/api/ai/translateJobDescriptionStream"; private final String ANALYZE_URL = "/api/ai/analyze-job-description"; + private final String MAP_COMPLIANCE_URL = "/api/ai/map-compliance-issues"; private final String input = "Hello World"; @@ -96,6 +98,36 @@ void shouldReturnUnauthorizedWhenTranslateJobDescriptionWithoutAuthentication() } } + // ===== MAP COMPLIANCE ISSUES ===== + @Nested + class MapComplianceIssuesTests { + + @Test + void shouldReturnMappedComplianceIssuesWhenProfessorMapsComplianceIssues() { + List sourceIssues = List.of(createComplianceIssue("young and dynamic", "en")); + List mappedIssues = List.of(createComplianceIssue("jung und dynamisch", "de")); + MapComplianceIssuesRequestDTO request = new MapComplianceIssuesRequestDTO("de", JOB_ID, "jung und dynamisch", sourceIssues); + + given(aiService.mapComplianceIssues(any(MapComplianceIssuesRequestDTO.class))).willReturn(mappedIssues); + + List response = api + .with(JwtPostProcessors.jwtUser(PROFESSOR_USER_ID, "ROLE_PROFESSOR")) + .postAndRead(MAP_COMPLIANCE_URL, request, new TypeReference>() {}, 200); + + assertThat(response).hasSize(1); + assertThat(response.getFirst().getText()).isEqualTo("jung und dynamisch"); + } + + @Test + void shouldReturnBadRequestWhenMappingRequestIsMissingTranslatedText() { + MapComplianceIssuesRequestDTO request = new MapComplianceIssuesRequestDTO("de", JOB_ID, null, List.of()); + + api + .with(JwtPostProcessors.jwtUser(PROFESSOR_USER_ID, "ROLE_PROFESSOR")) + .postAndRead(MAP_COMPLIANCE_URL, request, Void.class, 400); + } + } + // ===== ANALYZE JOB DESCRIPTION ===== @Nested class AnalyzeJobDescriptionTests { @@ -137,6 +169,18 @@ void shouldReturnUnauthorizedWhenAnalyzeJobDescriptionWithoutAuthentication() { } } + private ComplianceIssue createComplianceIssue(String text, String language) { + return new ComplianceIssue( + "1", + ComplianceCategory.CRITICAL_AGG, + text, + "§ 1 AGG", + "Discriminatory sentence", + ComplianceAction.REPLACE, + language + ); + } + private JobFormDTO createValidJobForm() { return new JobFormDTO( JOB_ID, diff --git a/src/test/webapp/app/job/job-creation-form/job-creation-form.component.spec.ts b/src/test/webapp/app/job/job-creation-form/job-creation-form.component.spec.ts index 42c6e32517..702c167dd1 100644 --- a/src/test/webapp/app/job/job-creation-form/job-creation-form.component.spec.ts +++ b/src/test/webapp/app/job/job-creation-form/job-creation-form.component.spec.ts @@ -19,6 +19,8 @@ import { UserShortDTORolesEnum } from 'app/generated/model/user-short-dto'; import { ImageDTOImageTypeEnum } from 'app/generated/model/image-dto'; import { JobDTO } from 'app/generated/model/job-dto'; import { ImageDTO } from 'app/generated/model/image-dto'; +import { ComplianceIssue } from 'app/generated/model/compliance-issue'; +import { AiResourceApi } from 'app/generated/api/ai-resource-api'; import { RecommendationType } from 'app/generated/model/recommendation-type'; import * as DropdownOptions from 'app/job/dropdown-options'; import { unescapeJsonString } from 'app/shared/util/util'; @@ -95,8 +97,13 @@ type ComponentPrivate = { extractJobDescriptionFromStream: (content: string) => string | null; loadSupervisingProfessors: () => Promise; setDefaultSupervisingProfessor: (preselectId?: string) => void; - translateAndStoreOtherLanguage: (currentLang: 'en' | 'de', currentText: string) => Promise; - analyzeAndUpdateScore: (lang: string) => Promise; + translateAndStoreOtherLanguage: ( + currentLang: 'en' | 'de', + currentText: string, + sourceIssues: Promise, + ) => Promise; + analyzeAndUpdateScore: (lang: string) => Promise; + aiApi: AiResourceApi; }; function getPrivate(component: JobCreationFormComponent): ComponentPrivate { @@ -726,31 +733,36 @@ describe('JobCreationFormComponent', () => { }); describe('Translation and compliance', () => { - it('should clear the translation spinner once streaming ends, before compliance analysis finishes', async () => { + it('should map source issues after translation without running target compliance analysis', async () => { component.jobId.set('job1'); component.currentDescriptionLanguage.set('en'); component.lastTranslatedEN.set(''); mockAiStreamingService.translateJobDescriptionStream.mockResolvedValue('{"translatedText":"

Hallo

"}'); - let resolveAnalysis!: () => void; - const analysisDone = new Promise(resolve => { + const sourceIssue: ComplianceIssue = { text: 'Hello', language: 'en' }; + const mappedIssue: ComplianceIssue = { text: 'Hallo', language: 'de' }; + let resolveAnalysis!: (issues: ComplianceIssue[]) => void; + const sourceIssues = new Promise(resolve => { resolveAnalysis = resolve; }); - const analyzeSpy = vi.spyOn(getPrivate(component), 'analyzeAndUpdateScore').mockImplementation(async () => { - await analysisDone; - component.isAnalyzing.set(false); - }); + const mapSpy = vi.spyOn(getPrivate(component).aiApi, 'mapComplianceIssues').mockReturnValue(of([mappedIssue])); - const promise = getPrivate(component).translateAndStoreOtherLanguage('en', 'Hello EN'); + const promise = getPrivate(component).translateAndStoreOtherLanguage('en', '

Hello

', sourceIssues); await new Promise(resolve => setTimeout(resolve, 0)); expect(component.isTranslating()).toBe(false); - expect(component.isAnalyzing()).toBe(true); - expect(analyzeSpy).toHaveBeenCalledWith('de'); + expect(mapSpy).not.toHaveBeenCalled(); - resolveAnalysis(); + resolveAnalysis([sourceIssue]); await promise; - expect(component.isAnalyzing()).toBe(false); + + expect(mapSpy).toHaveBeenCalledWith({ + toLang: 'de', + jobId: 'job1', + translatedText: 'Hallo', + complianceIssues: [sourceIssue], + }); + expect(component.complianceIssues()).toEqual([mappedIssue]); }); it('should skip translation when the source text matches the last translated baseline', async () => { @@ -758,7 +770,7 @@ describe('JobCreationFormComponent', () => { component.currentDescriptionLanguage.set('en'); component.lastTranslatedEN.set('Hello EN'); - await getPrivate(component).translateAndStoreOtherLanguage('en', 'Hello EN'); + await getPrivate(component).translateAndStoreOtherLanguage('en', 'Hello EN', Promise.resolve([])); expect(mockAiStreamingService.translateJobDescriptionStream).not.toHaveBeenCalled(); expect(component.isTranslating()).toBe(false);