diff --git a/openapi/openapi.yaml b/openapi/openapi.yaml index 99364ae450..daebe8d3dc 100644 --- a/openapi/openapi.yaml +++ b/openapi/openapi.yaml @@ -117,9 +117,7 @@ paths: description: OK content: application/json: - schema: - type: array - items: {$ref: '#/components/schemas/ComplianceIssue'} + schema: {$ref: '#/components/schemas/JobAnalysisDTO'} /api/ai/extractPdfData: put: tags: [ai-resource] @@ -3334,6 +3332,20 @@ components: id: {type: string} language: {type: string} text: {type: string} + ComplianceIssueDTO: + type: object + properties: + action: + type: string + enum: [REPLACE, ADD, REMOVE] + article: {type: string} + category: + type: string + enum: [CRITICAL_AGG, TRANSPARENCY, DSGVO_MINIMIZATION, PUBLIC_SECTOR] + explanation: {type: string} + id: {type: string} + language: {type: string} + text: {type: string} ConflictDataDTO: type: object properties: @@ -3636,6 +3648,13 @@ components: firstName: {type: string} lastName: {type: string} userId: {type: string, format: uuid} + JobAnalysisDTO: + type: object + properties: + issues: + type: array + items: {$ref: '#/components/schemas/ComplianceIssueDTO'} + score: {type: integer, format: int32} JobCardDTO: type: object properties: @@ -4307,6 +4326,7 @@ components: TranslateComplianceDTO: type: object properties: + jobId: {type: string, format: uuid} originalAnalysis: {$ref: '#/components/schemas/GenderBiasAnalysisResponse'} text: {type: string, minLength: 1} required: [text] diff --git a/src/main/java/de/tum/cit/aet/ai/dto/ComplianceIssueDTO.java b/src/main/java/de/tum/cit/aet/ai/dto/ComplianceIssueDTO.java new file mode 100644 index 0000000000..1c062761ea --- /dev/null +++ b/src/main/java/de/tum/cit/aet/ai/dto/ComplianceIssueDTO.java @@ -0,0 +1,36 @@ +package de.tum.cit.aet.ai.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; +import de.tum.cit.aet.ai.constants.ComplianceAction; +import de.tum.cit.aet.ai.constants.ComplianceCategory; +import de.tum.cit.aet.ai.domain.ComplianceIssue; + +/** Response DTO for one compliance issue. */ +@JsonInclude(JsonInclude.Include.NON_EMPTY) +public record ComplianceIssueDTO( + String id, + ComplianceCategory category, + String text, + String article, + String explanation, + ComplianceAction action, + String language +) { + /** + * Creates an API response from the persisted compliance value. + * + * @param issue the persisted compliance issue + * @return the response DTO + */ + public static ComplianceIssueDTO from(ComplianceIssue issue) { + return new ComplianceIssueDTO( + issue.getId(), + issue.getCategory(), + issue.getText(), + issue.getArticle(), + issue.getExplanation(), + issue.getAction(), + issue.getLanguage() + ); + } +} diff --git a/src/main/java/de/tum/cit/aet/ai/dto/JobAnalysisDTO.java b/src/main/java/de/tum/cit/aet/ai/dto/JobAnalysisDTO.java new file mode 100644 index 0000000000..9bb05e82c3 --- /dev/null +++ b/src/main/java/de/tum/cit/aet/ai/dto/JobAnalysisDTO.java @@ -0,0 +1,20 @@ +package de.tum.cit.aet.ai.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; +import de.tum.cit.aet.ai.domain.ComplianceIssue; +import java.util.List; + +/** Response DTO for job-description analysis. */ +@JsonInclude(JsonInclude.Include.NON_EMPTY) +public record JobAnalysisDTO(int score, List issues) { + /** + * Creates the response while keeping persistence models behind the DTO boundary. + * + * @param score the persisted combined score + * @param issues the detected compliance issues + * @return the analysis response + */ + public static JobAnalysisDTO from(int score, List issues) { + return new JobAnalysisDTO(score, issues.stream().map(ComplianceIssueDTO::from).toList()); + } +} diff --git a/src/main/java/de/tum/cit/aet/ai/dto/TranslateComplianceDTO.java b/src/main/java/de/tum/cit/aet/ai/dto/TranslateComplianceDTO.java index 71c55b0cb1..d61d8232a1 100644 --- a/src/main/java/de/tum/cit/aet/ai/dto/TranslateComplianceDTO.java +++ b/src/main/java/de/tum/cit/aet/ai/dto/TranslateComplianceDTO.java @@ -4,6 +4,7 @@ import de.tum.cit.aet.core.dto.GenderBiasAnalysisResponse; import jakarta.annotation.Nullable; import jakarta.validation.constraints.NotBlank; +import java.util.UUID; @JsonInclude(JsonInclude.Include.NON_EMPTY) -public record TranslateComplianceDTO(@NotBlank String text, @Nullable GenderBiasAnalysisResponse originalAnalysis) {} +public record TranslateComplianceDTO(@NotBlank String text, @Nullable GenderBiasAnalysisResponse originalAnalysis, @Nullable UUID jobId) {} diff --git a/src/main/java/de/tum/cit/aet/ai/service/AiPriorityService.java b/src/main/java/de/tum/cit/aet/ai/service/AiPriorityService.java new file mode 100644 index 0000000000..4b619369bb --- /dev/null +++ b/src/main/java/de/tum/cit/aet/ai/service/AiPriorityService.java @@ -0,0 +1,72 @@ +package de.tum.cit.aet.ai.service; + +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.CancellationException; +import java.util.concurrent.ConcurrentHashMap; +import org.springframework.stereotype.Service; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.core.publisher.Sinks; + +/** Coordinates foreground generation and cancellable background AI work per job. */ +@Service +public class AiPriorityService { + + private final Map>> backgroundCancellations = new ConcurrentHashMap<>(); + + /** + * Cancels existing background work for the job before generation starts. + * + * @param jobId the job owning the AI workflow + * @param source the generation stream + * @return the generation stream + * @param the streamed response type + */ + public Flux foreground(UUID jobId, Flux source) { + if (jobId == null) { + return source; + } + return Flux.defer(() -> { + cancelBackground(jobId); + return source; + }); + } + + /** + * Registers background work so foreground generation for the same job can cancel it. + * + * @param jobId the job owning the AI workflow + * @param source the background stream + * @return the cancellable background stream + * @param the streamed response type + */ + public Flux background(UUID jobId, Flux source) { + if (jobId == null) { + return source; + } + return Flux.defer(() -> { + Sinks.Empty cancellation = Sinks.empty(); + backgroundCancellations.computeIfAbsent(jobId, _ -> ConcurrentHashMap.newKeySet()).add(cancellation); + Mono cancellationError = cancellation + .asMono() + .then(Mono.error(new CancellationException("AI request superseded by generation"))); + return source.takeUntilOther(cancellationError).doFinally(_ -> unregister(jobId, cancellation)); + }); + } + + private void cancelBackground(UUID jobId) { + Set> cancellations = backgroundCancellations.remove(jobId); + if (cancellations != null) { + cancellations.forEach(Sinks.Empty::tryEmitEmpty); + } + } + + private void unregister(UUID jobId, Sinks.Empty cancellation) { + backgroundCancellations.computeIfPresent(jobId, (_, cancellations) -> { + cancellations.remove(cancellation); + return cancellations.isEmpty() ? null : cancellations; + }); + } +} 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..6086e68425 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.JobAnalysisDTO; 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; @@ -26,6 +27,7 @@ import java.util.List; import java.util.Set; import java.util.UUID; +import java.util.concurrent.CancellationException; import java.util.concurrent.atomic.AtomicReference; import javax.imageio.ImageIO; import lombok.extern.slf4j.Slf4j; @@ -37,6 +39,7 @@ import org.springframework.ai.chat.client.ResponseEntity; import org.springframework.ai.chat.metadata.Usage; import org.springframework.ai.chat.model.ChatResponse; +import org.springframework.ai.converter.BeanOutputConverter; import org.springframework.beans.factory.annotation.Value; import org.springframework.core.ParameterizedTypeReference; import org.springframework.core.io.ByteArrayResource; @@ -76,6 +79,10 @@ public class AiService { private final ChatClient chatClient; + private final BeanOutputConverter> complianceOutputConverter = new BeanOutputConverter<>( + new ParameterizedTypeReference<>() {} + ); + private final JobService jobService; private final ApplicationService applicationService; @@ -92,6 +99,8 @@ public class AiService { private final AiUsageEventService aiUsageEventService; + private final AiPriorityService aiPriorityService; + public AiService( ChatClient.Builder chatClientBuilder, JobService jobService, @@ -101,7 +110,8 @@ public AiService( GenderBiasAnalysisService genderBiasAnalysisService, ComplianceScoreService complianceScoreService, AiFeatureToggleService aiFeatureToggleService, - AiUsageEventService aiUsageEventService + AiUsageEventService aiUsageEventService, + AiPriorityService aiPriorityService ) { this.chatClient = chatClientBuilder.build(); this.jobService = jobService; @@ -112,6 +122,7 @@ public AiService( this.complianceScoreService = complianceScoreService; this.aiFeatureToggleService = aiFeatureToggleService; this.aiUsageEventService = aiUsageEventService; + this.aiPriorityService = aiPriorityService; } /** @@ -177,11 +188,13 @@ private Flux recordAndStream(Flux responses, AiUsageFeatur aiFeatureToggleService.recordSuccess(); recordAiUsageSafely(feature, true, userId, AiUsageMetrics.from(usageResponse.get())); }) - .doOnError(_ -> { + .doOnError(error -> { + if (error instanceof CancellationException) { + return; + } aiFeatureToggleService.recordFailure(); recordAiUsageSafely(feature, false, userId, AiUsageMetrics.from(usageResponse.get())); - }) - .delayElements(Duration.ofMillis(35)); + }); } /** @@ -203,7 +216,6 @@ public Flux generateJobApplicationDraftStream(JobFormDTO jobFormDTO, Str Set inclusive = "de".equals(descriptionLanguage) ? GERMAN_INCLUSIVE : ENGLISH_INCLUSIVE; Set nonInclusive = "de".equals(descriptionLanguage) ? GERMAN_NON_INCLUSIVE : ENGLISH_NON_INCLUSIVE; final String locationText = jobFormDTO.location() != null ? jobFormDTO.location().correctLanguageValue(descriptionLanguage) : ""; - Flux responses = chatClient .prompt() .user(u -> @@ -224,7 +236,10 @@ public Flux generateJobApplicationDraftStream(JobFormDTO jobFormDTO, Str .stream() .chatResponse(); - return recordAndStream(responses, AiUsageFeature.JOB_DESCRIPTION_GENERATION, triggeredBy); + return aiPriorityService.foreground( + jobFormDTO.jobId(), + recordAndStream(responses, AiUsageFeature.JOB_DESCRIPTION_GENERATION, triggeredBy) + ); } /** @@ -233,9 +248,10 @@ public Flux generateJobApplicationDraftStream(JobFormDTO jobFormDTO, Str * * @param text the text to translate * @param toLang the target language ("de" or "en") + * @param jobId the job owning the AI workflow * @return Flux of content chunks as they are generated */ - public Flux translateTextStream(String text, String toLang) { + public Flux translateTextStream(String text, String toLang, UUID jobId) { // Resolve the triggering user on the request thread; the stream hooks run on reactor threads. UUID triggeredBy = currentUserService.getUserIdIfAvailable().orElse(null); @@ -255,7 +271,7 @@ public Flux translateTextStream(String text, String toLang) { .stream() .chatResponse(); - return recordAndStream(responses, AiUsageFeature.TRANSLATION, triggeredBy); + return aiPriorityService.background(jobId, recordAndStream(responses, AiUsageFeature.TRANSLATION, triggeredBy)); } /** @@ -422,9 +438,9 @@ public ExtractedApplicationDataDTO extractAndPersistPdfData( * @param jobFormDTO The data transfer object containing the current state of the job posting. * @param lang The language identifier (de/en) currently active in the editor. * @param userLang controls the language of explanation texts in the returned issues. - * @return A list of compliance issues containing the combined legal and linguistic findings. + * @return the combined score and localized compliance findings */ - public List analyzeCurrentJobDescription(JobFormDTO jobFormDTO, String lang, String userLang) { + public JobAnalysisDTO analyzeCurrentJobDescription(JobFormDTO jobFormDTO, String lang, String userLang) { String raw = "de".equals(lang) ? jobFormDTO.jobDescriptionDE() : jobFormDTO.jobDescriptionEN(); String input = raw != null ? Jsoup.parse(raw).text() : ""; GenderBiasAnalysisResponse genderAnalysis = genderBiasAnalysisService.analyzeText(input, lang); @@ -435,11 +451,8 @@ public List analyzeCurrentJobDescription(JobFormDTO jobFormDTO, * Analyzes the job description using the compliance prompt * Passes the selected description language, the job description text, * and optionally the job title to the AI model. - * Executes a hybrid compliance analysis using a dual-track processing model. - * 1. Immediately calculates the gender bias scores using rule-based dictionary matching (GenderBiasAnalysisService). - * 2. Asynchronous LLM-based audit for legal risks (AGG violations,transparency requirements) via CompletableFuture - * to minimize latency. The results are merged using a geometric mean to ensure that a failure in one - * dimension (e.g., severe legal risk) significantly impacts the total score. + * Executes a hybrid compliance analysis: rule-based gender analysis and a cancellable + * streamed LLM audit for legal risks. The results are merged using a geometric mean. * * @param title the job form title * @param jobId Unique identifier for the job. @@ -448,10 +461,10 @@ public List analyzeCurrentJobDescription(JobFormDTO jobFormDTO, * @param userLang controls the language of explanation texts in the returned issues. * @param analysis Result of the primary linguistic gender analysis. * @param translatedAnalysis Second analysis of the translated counterpart. - * @return A list containing all identified compliance issues. + * @return the combined score and all identified compliance issues */ - public List analyzeJobDescription( + public JobAnalysisDTO analyzeJobDescription( String title, UUID jobId, String text, @@ -463,20 +476,35 @@ public List analyzeJobDescription( List complianceIssues; if (aiFeatureToggleService.isAiAvailable()) { try { - complianceIssues = chatClient - .prompt() - .user(u -> - u - .text(complianceResource) - .param("descriptionLanguage", lang) - .param("userLang", userLang) - .param("jobDescription", text) - .param("title", title != null ? title : "") + String response = aiPriorityService + .background( + jobId, + chatClient + .prompt() + .user(u -> + u + .text(complianceResource) + .param("descriptionLanguage", lang) + .param("userLang", userLang) + .param("jobDescription", text) + .param("title", title != null ? title : "") + .param("format", complianceOutputConverter.getFormat()) + ) + .stream() + .chatResponse() ) - .call() - .entity(new ParameterizedTypeReference<>() {}); + .mapNotNull(chatResponse -> chatResponse.getResult() != null ? chatResponse.getResult().getOutput().getText() : null) + .collect(StringBuilder::new, StringBuilder::append) + .map(StringBuilder::toString) + .block(); + if (response == null || response.isBlank()) { + throw new IllegalStateException("Compliance analysis returned an empty response"); + } + complianceIssues = complianceOutputConverter.convert(response); complianceIssues.forEach(issue -> issue.setLanguage(lang)); aiFeatureToggleService.recordSuccess(); + } catch (CancellationException e) { + throw e; } catch (Exception e) { aiFeatureToggleService.recordFailure(); throw new InternalServerException("Compliance analysis parsing failed", e); @@ -494,6 +522,6 @@ public List analyzeJobDescription( jobService.updateAiAnalysis(jobId, combinedScore, complianceIssues, lang); - return complianceIssues; + return JobAnalysisDTO.from(combinedScore, complianceIssues); } } 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..8bbea4dd46 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 @@ -1,14 +1,16 @@ package de.tum.cit.aet.ai.web; -import de.tum.cit.aet.ai.domain.ComplianceIssue; import de.tum.cit.aet.ai.dto.ExtractedApplicationDataDTO; +import de.tum.cit.aet.ai.dto.JobAnalysisDTO; 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.concurrent.CancellationException; import lombok.extern.slf4j.Slf4j; import org.springframework.context.annotation.Profile; import org.springframework.http.HttpStatus; @@ -70,13 +72,13 @@ 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(); } log.info("PUT /api/ai/translateJobDescriptionStream - Streaming translation request received (toLang={})", toLang); - return ResponseEntity.ok(aiService.translateTextStream(request.text(), toLang)); + return ResponseEntity.ok(aiService.translateTextStream(request.text(), toLang, request.jobId())); } /** @@ -126,13 +128,17 @@ public ResponseEntity extractPdfData( @ProfessorOrEmployeeOrAdmin @PostMapping(value = "analyze-job-description", produces = MediaType.APPLICATION_JSON_VALUE) - public ResponseEntity> analyzeJobDescriptionForCompliance( + public ResponseEntity analyzeJobDescriptionForCompliance( @RequestBody JobFormDTO jobForm, @RequestParam("lang") String descriptionLanguage, @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); - return ResponseEntity.ok(aiService.analyzeCurrentJobDescription(jobForm, descriptionLanguage, userLanguage)); + try { + return ResponseEntity.ok(aiService.analyzeCurrentJobDescription(jobForm, descriptionLanguage, userLanguage)); + } catch (CancellationException e) { + return ResponseEntity.status(HttpStatus.CONFLICT).build(); + } } } 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..20598891ef 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 @@ -558,7 +558,7 @@ public void updateAiAnalysis(UUID jobId, int score, List compli Job job = jobRepository.findByIdWithCompliance(jobId).orElseThrow(() -> EntityNotFoundException.forId("Job", jobId)); // Keep issues from the other language, add new ones for target language - List issuesToSave = job + List issuesToSave = job .getComplianceIssues() .stream() .filter(issue -> !Objects.equals(issue.getLanguage(), lang)) diff --git a/src/main/resources/prompts/AnalyzeComplianceText.st b/src/main/resources/prompts/AnalyzeComplianceText.st index 81be2bbdc7..22675b1762 100644 --- a/src/main/resources/prompts/AnalyzeComplianceText.st +++ b/src/main/resources/prompts/AnalyzeComplianceText.st @@ -1,51 +1,47 @@ -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: +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 violations for this task. They are mandatory, not guidance. +- Apply them exactly even when your general legal knowledge, common practice, plausibility, or contextual interpretation suggests a different result. +- Never weaken, reinterpret, balance, or invent an exception to a stated trigger. Only an exception explicitly written below is valid. +- If a rule matches, its finding MUST be returned. Omitting one matching occurrence makes the answer incorrect. +- Match meaning case-insensitively. Examples illustrate the rule; they are not an exhaustive keyword list. +- In one internal scan, check every text occurrence against all four categories, retain all matches, then output immediately. Do not narrate this scan. + +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 external partners, companies, recipients, or tools (for example Workday, headhunter, partner university, consortium) when the full posting lacks an explicit disclosure that applicant data is shared with them. A cooperation statement alone is not a data-sharing disclosure. Do not use this category for requested data or links. Suggest one concise independent consent sentence naming the recipient and application purpose ("By applying, you consent to ... (Art. 13 DSGVO)"). + +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 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 []. + +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/JobDescriptionGeneration.st b/src/main/resources/prompts/JobDescriptionGeneration.st index 2365c264dc..29600b42c1 100644 --- a/src/main/resources/prompts/JobDescriptionGeneration.st +++ b/src/main/resources/prompts/JobDescriptionGeneration.st @@ -4,13 +4,20 @@ TASK: Generate a polished job posting paragraph in {descriptionLanguage} based o Your output must be directly usable in a real advertisement with minimal editing --- -### 1. GENDER-INCLUSIVE RULES (HIGHEST PRIORITY) -- Scan input for forbidden stems: [{nonInclusiveWords}] +### 1.a GENDER-INCLUSIVE RULES (HIGHEST PRIORITY) +Scan input for forbidden stems: [{nonInclusiveWords}] - If a word starts with or contains a forbidden stem, you MUST replace it with a neutral alternative from: [{inclusiveWords}] or a synonym. - Ensure the text is AGG compliant. - +- Apply the following policy only as writing constraints. +### 1.b LEGAL & COMPLIANCE BY DESIGN +- AGG: Remove explicit or implicit exclusion based on age, origin, disability, gender, religion, or sexual identity. Replace subjective language requirements such as "good" or "fluent" with an appropriate CEFR level such as B2 or C1. Preserve lawful affirmative-action wording for women or disabled people under equal qualifications. +- DATA MINIMIZATION (Art. 5 GDPR/DSGVO): Never request unnecessary personal data such as marital status, photos, religion, identity-document copies, or criminal records. Omit external application links and unverified forms. +- TRANSPARENCY (Art. 13/14 GDPR/DSGVO): Whenever the input mentions cooperation or collaboration with any external tool, partner, company, university, consortium, or other third party, you MUST add a concise disclosure that application data is shared with them for the application process. Reuse the exact name or generic label from the input; do not invent a specific name or repeat an existing disclosure. +- PUBLIC SECTOR (WissZeitVG): State that a doctoral or academic position supports a doctorate or individual scientific qualification. Do not duplicate an existing statement. +Do not analyze the input for violations. Do not output categories, actions, issues, or legal notes. +Write a compliant job posting that would not trigger these rules. Do not mention the rules. --- -### 2. COMPRESSION & STRUCTURE (MAX 2000 CHARS) +### 2. COMPRESSION & STRUCTURE (MAX 5000 CHARS) You are an EXECUTIVE EDITOR. If the input is long, MERGE and DISTILL information. - Description: MAX 3 short sentences. -> Offers/Benefits (research context, goals, and scope) - Tasks: EXACTLY 3 concise bullet points. Group similar technical terms together. -> Duties/Activities (tasks, responsibilities) @@ -67,4 +74,3 @@ FORMAT: Use HTML like:

,,

    ,
  • - HTML tags allowed:

    , ,

      ,
    • . - Escape all double quotes (\") inside the HTML content. - Put the entire HTML on one single line in the JSON value. - diff --git a/src/main/webapp/app/generated/.openapi-generator/FILES b/src/main/webapp/app/generated/.openapi-generator/FILES index db7d82624d..cf5bea0305 100644 --- a/src/main/webapp/app/generated/.openapi-generator/FILES +++ b/src/main/webapp/app/generated/.openapi-generator/FILES @@ -90,6 +90,7 @@ model/biased-word-dto.ts model/book-slot-request-dto.ts model/booking-dto.ts model/cancel-interview-dto.ts +model/compliance-issue-dto.ts model/compliance-issue.ts model/conflict-data-dto.ts model/counts.ts @@ -121,6 +122,7 @@ model/interview-slot-dto.ts model/interviewee-detail-dto.ts model/interviewee-dto.ts model/interviewee-user-dto.ts +model/job-analysis-dto.ts model/job-card-dto.ts model/job-detail-dto.ts model/job-dto.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..3f03a17910 100644 --- a/src/main/webapp/app/generated/api/ai-resource-api.ts +++ b/src/main/webapp/app/generated/api/ai-resource-api.ts @@ -15,7 +15,7 @@ import { HttpClient } from '@angular/common/http'; import { inject, Injectable } from '@angular/core'; import { Observable } from 'rxjs'; -import { ComplianceIssue } from '../model/compliance-issue'; +import { JobAnalysisDTO } from '../model/job-analysis-dto'; import { JobFormDTO } from '../model/job-form-dto'; import { ExtractedApplicationDataDTO } from '../model/extracted-application-data-dto'; import { TranslateComplianceDTO } from '../model/translate-compliance-dto'; @@ -32,7 +32,7 @@ export class AiResourceApi { * @param jobFormDTO * @param userLanguage */ - analyzeJobDescriptionForCompliance(lang: string, jobFormDTO: JobFormDTO, userLanguage?: string): Observable> { + analyzeJobDescriptionForCompliance(lang: string, jobFormDTO: JobFormDTO, userLanguage?: string): Observable { const queryParams = new URLSearchParams(); if (lang !== undefined && lang !== null) { queryParams.set('lang', String(lang)); @@ -42,7 +42,7 @@ export class AiResourceApi { } const queryString = queryParams.toString(); const url = `${this.basePath}/api/ai/analyze-job-description${queryString ? `?${queryString}` : ''}`; - return this.http.post>(url, jobFormDTO); + return this.http.post(url, jobFormDTO); } /** diff --git a/src/main/webapp/app/generated/model/compliance-issue-dto.ts b/src/main/webapp/app/generated/model/compliance-issue-dto.ts new file mode 100644 index 0000000000..4bfc8c0c5d --- /dev/null +++ b/src/main/webapp/app/generated/model/compliance-issue-dto.ts @@ -0,0 +1,42 @@ +/** + * 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. + */ + + +export interface ComplianceIssueDTO { + readonly action?: ComplianceIssueDTOActionEnum; + readonly article?: string; + readonly category?: ComplianceIssueDTOCategoryEnum; + readonly explanation?: string; + readonly id?: string; + readonly language?: string; + readonly text?: string; +} + +export type ComplianceIssueDTOActionEnum = 'REPLACE' | 'ADD' | 'REMOVE'; + +export const ComplianceIssueDTOActionEnum = { + Replace: 'REPLACE' as const, + Add: 'ADD' as const, + Remove: 'REMOVE' as const, +} as const; + +export const ComplianceIssueDTOActionEnumValues = ['REPLACE', 'ADD', 'REMOVE'] as const; + +export type ComplianceIssueDTOCategoryEnum = 'CRITICAL_AGG' | 'TRANSPARENCY' | 'DSGVO_MINIMIZATION' | 'PUBLIC_SECTOR'; + +export const ComplianceIssueDTOCategoryEnum = { + CriticalAgg: 'CRITICAL_AGG' as const, + Transparency: 'TRANSPARENCY' as const, + DsgvoMinimization: 'DSGVO_MINIMIZATION' as const, + PublicSector: 'PUBLIC_SECTOR' as const, +} as const; + +export const ComplianceIssueDTOCategoryEnumValues = ['CRITICAL_AGG', 'TRANSPARENCY', 'DSGVO_MINIMIZATION', 'PUBLIC_SECTOR'] as const; + diff --git a/src/main/webapp/app/generated/model/job-analysis-dto.ts b/src/main/webapp/app/generated/model/job-analysis-dto.ts new file mode 100644 index 0000000000..05e57a3cbb --- /dev/null +++ b/src/main/webapp/app/generated/model/job-analysis-dto.ts @@ -0,0 +1,16 @@ +/** + * 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 { ComplianceIssueDTO } from './compliance-issue-dto'; + +export interface JobAnalysisDTO { + readonly issues?: Array; + readonly score?: number; +} diff --git a/src/main/webapp/app/generated/model/translate-compliance-dto.ts b/src/main/webapp/app/generated/model/translate-compliance-dto.ts index 8d35fb497c..520519efc5 100644 --- a/src/main/webapp/app/generated/model/translate-compliance-dto.ts +++ b/src/main/webapp/app/generated/model/translate-compliance-dto.ts @@ -11,6 +11,7 @@ import type { GenderBiasAnalysisResponse } from './gender-bias-analysis-response'; export interface TranslateComplianceDTO { + readonly jobId?: string; readonly originalAnalysis?: GenderBiasAnalysisResponse; readonly text: string; } diff --git a/src/main/webapp/app/job/job-creation-form/ai-run.ts b/src/main/webapp/app/job/job-creation-form/ai-run.ts new file mode 100644 index 0000000000..eee8625ed0 --- /dev/null +++ b/src/main/webapp/app/job/job-creation-form/ai-run.ts @@ -0,0 +1,18 @@ +/** Owns cancellation and stale-state detection for one AI workflow. */ +export class AiRun { + private cancelled = false; + private readonly abortController = new AbortController(); + + get signal(): AbortSignal { + return this.abortController.signal; + } + + isStale(): boolean { + return this.cancelled; + } + + cancel(): void { + this.cancelled = true; + this.abortController.abort(); + } +} diff --git a/src/main/webapp/app/job/job-creation-form/job-creation-form.component.html b/src/main/webapp/app/job/job-creation-form/job-creation-form.component.html index b4b068c6d1..3a3f7012ea 100644 --- a/src/main/webapp/app/job/job-creation-form/job-creation-form.component.html +++ b/src/main/webapp/app/job/job-creation-form/job-creation-form.component.html @@ -182,7 +182,7 @@

      tooltipText="jobCreationForm.positionDetailsSection.jobDescription.toolTipText" [required]="true" [characterLimit]="5000" - [loading]="isViewingTranslationTarget()" + [loading]="isGeneratingDraft() || isViewingTranslationTarget()" [model]="jobDescriptionSignal()" [control]="basicInfoForm.get('jobDescription') ?? undefined" icon="circle-info" @@ -214,7 +214,7 @@

      option.value === RecommendationType.LetterAndEvaluation) ?? DropdownOptions.recommendationTypes[0]; +const STREAM_RENDER_INTERVAL_MS = 100; /** * JobCreationFormComponent @@ -197,9 +212,6 @@ export class JobCreationFormComponent { () => this.isTranslating() && this.translationTargetLang() === this.currentDescriptionLanguage(), ); - /** AbortController for cancelling active translation streams */ - private translationAbortController: AbortController | undefined; - /** Last successfully translated English text (used to avoid redundant translations) */ lastTranslatedEN = signal(''); @@ -212,6 +224,9 @@ export class JobCreationFormComponent { /** Last analyzed description text per language (used to avoid redundant compliance analysis) */ private lastAnalyzedText: Record = {}; + /** Owns the requests and callbacks belonging to the current AI workflow. */ + private activeAiRun = new AiRun(); + // ═══════════════════════════════════════════════════════════════════════════ // AI GENERATION SIGNALS // ═══════════════════════════════════════════════════════════════════════════ @@ -269,6 +284,8 @@ export class JobCreationFormComponent { // ═══════════════════════════════════════════════════════════════════════════ private fb = inject(FormBuilder); + private changeDetectorRef = inject(ChangeDetectorRef); + private destroyRef = inject(DestroyRef); private jobApi = inject(JobResourceApi); private imageApi = inject(ImageResourceApi); private accountService = inject(AccountService); @@ -607,8 +624,6 @@ export class JobCreationFormComponent { /** Flag to prevent auto-save from triggering during initial form population */ private autoSaveInitialized = false; - private isAutoScrolling = false; - // ═══════════════════════════════════════════════════════════════════════════ // CONSTRUCTOR // ═══════════════════════════════════════════════════════════════════════════ @@ -650,34 +665,33 @@ export class JobCreationFormComponent { this.aiInfoDialogVisible.set(true); } - /** Aborts the active translation stream (if any) and resets translation state. */ + /** Resets the UI state owned by the active translation. */ private cancelTranslation(): void { - if (this.translationAbortController) { - this.translationAbortController.abort(); - this.translationAbortController = undefined; - } this.activeTranslationRequest = undefined; this.isTranslating.set(false); this.translationTargetLang.set(undefined); } + /** Starts a workflow after cancelling requests and callbacks owned by its predecessor. */ + private startAiRun(): AiRun { + this.activeAiRun.cancel(); + const run = new AiRun(); + this.activeAiRun = run; + this.isAnalyzing.set(false); + this.cancelTranslation(); + return run; + } + /** * Clears the transient translation state for a run, but only when it is still * the active one. A newer translation that superseded this run owns the state. * - * @param abortController - The AbortController created for this run * @param activeRequest - The dedup descriptor created for this run */ - private clearTranslationState( - abortController: AbortController, - activeRequest: { sourceLang: Language; sourceText: string; targetLang: Language }, - ): void { - if (this.translationAbortController === abortController) { + private clearTranslationState(activeRequest: { sourceLang: Language; sourceText: string; targetLang: Language }): void { + if (this.activeTranslationRequest === activeRequest) { this.isTranslating.set(false); this.translationTargetLang.set(undefined); - this.translationAbortController = undefined; - } - if (this.activeTranslationRequest === activeRequest) { this.activeTranslationRequest = undefined; } } @@ -733,13 +747,14 @@ export class JobCreationFormComponent { const translating = this.isTranslating(); const translationTarget = this.translationTargetLang(); if (!this.autoSaveInitialized) return; + if (this.isGeneratingDraft()) return; // If switching to a language that is currently being translated, show placeholder if (translating && translationTarget === newLanguage) { const placeholder = `

      ${this.translate.instant('jobCreationForm.positionDetailsSection.jobDescription.translatingPlaceholder') as string}

      `; this.basicInfoForm.get('jobDescription')?.setValue('', { emitEvent: false }); this.jobDescriptionSignal.set(''); - this.jobDescriptionEditor()?.forceUpdate(placeholder); + this.jobDescriptionEditor()?.forceStreamingUpdate(placeholder); return; } @@ -999,26 +1014,34 @@ export class JobCreationFormComponent { } const originalContent = this.basicInfoForm.get('jobDescription')?.value; const language = this.currentDescriptionLanguage(); + this.autoSave.reset(); - // Abort any in-flight translation. Generation will re-trigger a fresh - // translation in postGenerationSaveAndProcess once it completes, so an - // active translation against the soon-to-be-replaced text is wasted work. - if (this.isTranslating()) { - this.cancelTranslation(); - } - - // 1) Enter generation mode and show placeholder + // Enter generation mode before cancellation so stale translation effects + // cannot restore the previous editor content while requests are aborted. this.isGeneratingDraft.set(true); this.rewriteButtonSignal.set(true); - this.isAutoScrolling = true; - this.jobDescriptionEditor()?.forceUpdate( - `

      ${this.translate.instant('jobCreationForm.positionDetailsSection.jobDescription.aiFillerText') as string}

      `, - ); + const run = this.startAiRun(); + + // 1) Enter generation mode and show placeholder + this.changeDetectorRef.detectChanges(); + await new Promise(resolve => { + const editor = this.jobDescriptionEditor(); + if (!editor) { + resolve(); + return; + } + editor.forceStreamingUpdate( + `

      ${this.translate.instant('jobCreationForm.positionDetailsSection.jobDescription.aiFillerText') as string}

      `, + resolve, + ); + }); + if (run.isStale()) return; try { // 2) Sync current editor content and build the AI prompt request this.syncCurrentEditorIntoLanguageSignals(); const request: JobFormDTO = { + jobId: this.jobId() || undefined, title: this.basicInfoForm.get('title')?.value ?? '', researchArea: this.basicInfoForm.get('researchArea')?.value ?? '', subjectArea: this.basicInfoForm.get('subjectArea')?.value?.value as JobFormDTOSubjectAreaEnum, @@ -1028,20 +1051,31 @@ export class JobCreationFormComponent { jobDescriptionDE: this.jobDescriptionDE() || '', state: JobFormDTOStateEnum.Draft, }; - this.autoScrollStreaming(); - - // 3) Stream the AI response, updating the editor with each chunk + // 3) Replace the filler with the first complete chunk, then limit editor rebuilds. + let hasRenderedChunk = false; let lastRendered = ''; - const accumulatedContent = await this.aiStreamingService.generateJobApplicationDraftStream(language, request, content => { - const extractedContent = this.extractJobDescriptionFromStream(content); - if (extractedContent?.startsWith('<') !== true) return; - const safeHtml = extractCompleteHtmlTags(extractedContent); - if (safeHtml && safeHtml !== lastRendered) { + let lastRenderTime = 0; + const accumulatedContent = await this.aiStreamingService.generateJobApplicationDraftStream( + language, + request, + content => { + if (run.isStale()) return; + const now = Date.now(); + if (hasRenderedChunk && now - lastRenderTime < STREAM_RENDER_INTERVAL_MS) return; + + const extractedContent = this.extractJobDescriptionFromStream(content); + if (extractedContent?.startsWith('<') !== true) return; + const safeHtml = extractCompleteHtmlTags(extractedContent); + if (!safeHtml || safeHtml === lastRendered) return; + + hasRenderedChunk = true; + lastRenderTime = now; lastRendered = safeHtml; - this.jobDescriptionEditor()?.forceUpdate(safeHtml); - } - }); - this.isAutoScrolling = false; + this.jobDescriptionEditor()?.forceStreamingUpdate(safeHtml); + }, + run.signal, + ); + if (run.isStale()) return; // 4) Finalize: parse the complete response and update form + signals if (accumulatedContent) { @@ -1063,43 +1097,46 @@ export class JobCreationFormComponent { this.jobDescriptionDE.set(finalContent); } - // 6) Immediately save + analyze + translate (skip autosave delay). - // Pre-set isAnalyzing so isScoreProcessing stays true when - // isGeneratingDraft goes false in finally (postGenerationSaveAndProcess - // is async and hasn't reached its own pre-set yet). + // 6) Save immediately and process the generated text without waiting for autosave. this.syncCurrentEditorIntoLanguageSignals(); - this.isAnalyzing.set(true); - void this.postGenerationSaveAndProcess(language, finalContent); + void this.postGenerationSaveAndProcess(language, finalContent, run); } else { this.jobDescriptionEditor()?.forceUpdate(originalContent); this.toastService.showErrorKey('jobCreationForm.toastMessages.aiGenerationFailed'); } } } catch (error) { + if (run.isStale() || run.signal.aborted || (error instanceof DOMException && error.name === 'AbortError')) return; this.jobDescriptionEditor()?.forceUpdate(originalContent); - this.isAutoScrolling = false; if (error instanceof Error && error.message.includes('HTTP error')) { this.toastService.showErrorKey('jobCreationForm.toastMessages.aiGenerationFailed'); } else { this.toastService.showErrorKey('jobCreationForm.toastMessages.saveFailed'); } } finally { - this.isAutoScrolling = false; - this.isGeneratingDraft.set(false); + if (!run.isStale()) { + this.isGeneratingDraft.set(false); + } } } /** - * Immediately saves the generated content and fires analysis + translation in parallel. - * Called directly after AI draft generation to skip the 5s autosave delay. + * Saves generated content, prioritizes its analysis, and then translates it. */ - private async postGenerationSaveAndProcess(sourceLang: Language, sourceText: string): Promise { - const currentData = this.createJobDTO(JobFormDTOStateEnum.Draft); + private async postGenerationSaveAndProcess(sourceLang: Language, sourceText: string, run: AiRun): Promise { this.autoSave.setState(SavingStates.SAVING); try { + // Generation is already complete; only serialize its save behind an older autosave. + if (this.autoSaveInFlight) { + await this.autoSaveInFlight; + } + if (run.isStale()) return; + // 1) Persist the generated content to the server + const currentData = this.createJobDTO(JobFormDTOStateEnum.Draft); const saved = await this.saveDraft(currentData); + if (run.isStale()) return; // 2) Sync local state with server response this.lastSavedData.set(saved); @@ -1107,11 +1144,16 @@ export class JobCreationFormComponent { this.jobDescriptionDE.set(saved.jobDescriptionDE ?? this.jobDescriptionDE()); this.autoSave.setState(SavingStates.SAVED); - // 3) Analyze source language first so the user sees highlights + score immediately. + // 3) Analyze first so the source result gets the model's full throughput, + // then translate the already persisted generated text. if (this.aiToggleSignal() && this.aiSystemEnabled()) { - void Promise.all([this.analyzeAndUpdateScore(sourceLang), this.translateAndStoreOtherLanguage(sourceLang, sourceText)]); + await this.analyzeAndUpdateScore(sourceLang, run); + if (!run.isStale()) { + await this.translateAndStoreOtherLanguage(sourceLang, sourceText, run); + } } } catch { + if (run.isStale()) return; this.autoSave.setState(SavingStates.FAILED); this.isAnalyzing.set(false); this.toastService.showErrorKey('toast.saveFailed'); @@ -1212,30 +1254,6 @@ export class JobCreationFormComponent { return unescapeJsonString(rawValue); } - /** - * Automatically scrolls the editor to the bottom during AI streaming. - * Runs every 200ms while isAutoScrolling is true. - */ - private autoScrollStreaming(): void { - const editorContainer = document.querySelector('.ql-editor') as HTMLElement; - let lastScrollTop = editorContainer.scrollTop; - - const smoothScroll = (): void => { - if (!this.isAutoScrolling) return; - if (editorContainer.scrollTop < lastScrollTop) { - this.isAutoScrolling = false; - return; - } - editorContainer.scrollTo({ - top: editorContainer.scrollHeight, - behavior: 'smooth', - }); - lastScrollTop = editorContainer.scrollTop; - setTimeout(() => requestAnimationFrame(smoothScroll), 200); - }; - requestAnimationFrame(smoothScroll); - } - // ═══════════════════════════════════════════════════════════════════════════ // FORM CREATION METHODS // ═══════════════════════════════════════════════════════════════════════════ @@ -1641,6 +1659,8 @@ export class JobCreationFormComponent { private async executeAutoSave(): Promise { // 1) Capture current form state before any async work + if (this.isGeneratingDraft()) return true; + const run = this.startAiRun(); this.syncCurrentEditorIntoLanguageSignals(); const currentLang = this.currentDescriptionLanguage(); const description = this.basicInfoForm.get('jobDescription')?.value ?? ''; @@ -1650,20 +1670,25 @@ export class JobCreationFormComponent { // 2) Create or update the job on the server const saved = await this.saveDraft(currentData); + if (run.isStale()) return true; + // 3) Sync local state with server response this.lastSavedData.set(saved); 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. + // A manually requested generation owns the AI capacity. Its completion path + // will translate and analyze the new text in the correct order. + // 4) Start background AI work. Generate can cancel both requests immediately. if (this.aiToggleSignal() && this.aiSystemEnabled()) { - // highlighting before translation - void Promise.all([this.analyzeAndUpdateScore(currentLang), this.translateAndStoreOtherLanguage(currentLang, description)]); + void Promise.all([ + this.analyzeAndUpdateScore(currentLang, run), + this.translateAndStoreOtherLanguage(currentLang, description, run), + ]); } return true; } catch { + if (run.isStale()) return true; this.toastService.showErrorKey('toast.saveFailed'); return false; } @@ -1704,7 +1729,8 @@ export class JobCreationFormComponent { * @param currentLang - The language the user wrote in ('en' or 'de') * @param currentText - The source text to translate */ - private async translateAndStoreOtherLanguage(currentLang: Language, currentText: string): Promise { + private async translateAndStoreOtherLanguage(currentLang: Language, currentText: string, run = this.activeAiRun): Promise { + if (run.isStale()) return; const text = currentText.trim(); if (!text) return; @@ -1719,20 +1745,16 @@ export class JobCreationFormComponent { const lastBaseline = currentLang === 'en' ? this.lastTranslatedEN() : this.lastTranslatedDE(); if (text === lastBaseline) return; - // 2) Cancel any active translation and set up fresh state - this.cancelTranslation(); - const abortController = new AbortController(); - // If a newer request exists, keep it to avoid breaking duplicate checks. + // 2) Set up state owned by this workflow. const activeRequest = { sourceLang: currentLang, sourceText: text, targetLang }; this.activeTranslationRequest = activeRequest; - this.translationAbortController = abortController; this.isTranslating.set(true); this.translationTargetLang.set(targetLang); // 3) If user is already viewing the target language, show placeholder if (this.currentDescriptionLanguage() === targetLang) { const placeholder = `

      ${this.translate.instant('jobCreationForm.positionDetailsSection.jobDescription.translatingPlaceholder') as string}

      `; - this.jobDescriptionEditor()?.forceUpdate(placeholder); + this.jobDescriptionEditor()?.forceStreamingUpdate(placeholder); } try { @@ -1741,20 +1763,24 @@ export class JobCreationFormComponent { const accumulatedContent = await this.aiStreamingService.translateJobDescriptionStream( targetLang, text, + this.jobId() || undefined, content => { + if (run.isStale()) return; const extracted = this.extractTranslatedTextFromStream(content); if (extracted?.startsWith('<') !== true) return; const safeHtml = extractCompleteHtmlTags(extracted); if (safeHtml && safeHtml !== lastRendered) { lastRendered = safeHtml; if (this.currentDescriptionLanguage() === targetLang) { - this.jobDescriptionEditor()?.forceUpdate(safeHtml); + this.jobDescriptionEditor()?.forceStreamingUpdate(safeHtml); } } }, - abortController.signal, + run.signal, ); + if (run.isStale() || this.activeTranslationRequest !== activeRequest) return; + let hasTranslation = false; if (accumulatedContent) { const finalContent = this.extractTranslatedTextFromStream(accumulatedContent); @@ -1786,11 +1812,11 @@ export class JobCreationFormComponent { // 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; + const runAnalysis = !run.isStale() && hasTranslation && !!jobId; if (runAnalysis) { this.isAnalyzing.set(true); } - this.clearTranslationState(abortController, activeRequest); + this.clearTranslationState(activeRequest); // 8) Persist the translated content and run compliance analysis for the // freshly translated language, decoupled from the translation spinner. @@ -1798,16 +1824,17 @@ export class JobCreationFormComponent { try { const currentData = this.createJobDTO(JobFormDTOStateEnum.Draft); const saved = await firstValueFrom(this.jobApi.updateJob(jobId, currentData)); + if (run.isStale()) return; this.lastSavedData.set(saved); - await this.analyzeAndUpdateScore(targetLang); + await this.analyzeAndUpdateScore(targetLang, run); } catch { // Silent save failure — will be caught by next autosave this.isAnalyzing.set(false); } } } catch (e) { - this.clearTranslationState(abortController, activeRequest); - if (e instanceof DOMException && e.name === 'AbortError') { + this.clearTranslationState(activeRequest); + if (run.isStale() || run.signal.aborted || (e instanceof DOMException && e.name === 'AbortError')) { return; // Cancelled — silently ignore } this.toastService.showErrorKey('jobCreationForm.toastMessages.aiTranslationFailed'); @@ -1820,7 +1847,9 @@ export class JobCreationFormComponent { * * @param lang - The language to analyze ('en' or 'de') */ - private async analyzeAndUpdateScore(lang: string): Promise { + private async analyzeAndUpdateScore(lang: string, run = this.activeAiRun): Promise { + if (run.isStale()) return; + const jobId = this.jobId(); if (!jobId) return; @@ -1836,35 +1865,31 @@ export class JobCreationFormComponent { this.isAnalyzing.set(true); try { // 2) Send the description to the analysis endpoint (persists score on the backend) - const compliance = await firstValueFrom(this.aiApi.analyzeJobDescriptionForCompliance(lang, jobForm, userLang)); + const analysis = await firstValueFrom( + this.aiApi + .analyzeJobDescriptionForCompliance(lang, jobForm, userLang) + .pipe(takeUntil(fromEvent(run.signal, 'abort')), takeUntilDestroyed(this.destroyRef)), + ); + if (run.isStale()) return; + const compliance = analysis.issues ?? []; this.lastAnalyzedText[lang] = descriptionText; // Keep issues from other languages, but replace all issues for the current language with the latest analysis. const otherLang = lang === 'en' ? 'de' : 'en'; const existingLang = this.complianceIssues().filter(issue => issue.language === otherLang); this.complianceIssues.set(existingLang.concat(compliance)); + this.aiScore.set(analysis.score); - // 3) Fetch the updated job to retrieve the persisted score. - // Retry once with a short delay if the score is still missing - // (DB transaction may not have committed yet). - for (let attempt = 0; attempt < 2; attempt++) { - const updatedJob = await firstValueFrom(this.jobApi.getJobById(jobId)); - if (updatedJob.genderBiasScore !== undefined) { - this.aiScore.set(updatedJob.genderBiasScore); - break; - } - if (attempt === 0) { - await new Promise(resolve => setTimeout(resolve, 500)); - } - } + // 3) Apply the response to the language currently shown in the editor. const currentLang = this.currentDescriptionLanguage(); if (currentLang === lang) { this.applyHighlights(compliance, lang); } - } catch { + } catch (error) { + if (run.isStale() || run.signal.aborted || (error instanceof HttpErrorResponse && error.status === 409)) return; this.toastService.showErrorKey('jobCreationForm.toastMessages.aiComplianceFailed'); } finally { - this.isAnalyzing.set(false); + if (!run.isStale()) this.isAnalyzing.set(false); } } diff --git a/src/main/webapp/app/service/ai-streaming.service.ts b/src/main/webapp/app/service/ai-streaming.service.ts index 8eb7e1c23c..37927970aa 100644 --- a/src/main/webapp/app/service/ai-streaming.service.ts +++ b/src/main/webapp/app/service/ai-streaming.service.ts @@ -31,6 +31,7 @@ export class AiStreamingService { * @param lang The language for the generated job description ('en' or 'de') * @param jobFormDTO The job form data used to build the AI prompt * @param onChunk Callback invoked with the accumulated content after each SSE chunk + * @param signal Signal owned by the active AI workflow * @returns Promise resolving to the full accumulated content on stream completion * @throws Error on HTTP errors or network failures */ @@ -38,9 +39,10 @@ export class AiStreamingService { lang: string, jobFormDTO: JobFormDTO, onChunk: (accumulatedContent: string) => void, + signal: AbortSignal, ): Promise { const url = `/api/ai/generateJobApplicationDraftStream?lang=${encodeURIComponent(lang)}`; - return this.streamSSE(url, JSON.stringify(jobFormDTO), onChunk); + return this.streamSSE(url, JSON.stringify(jobFormDTO), onChunk, signal); } /** @@ -53,8 +55,9 @@ export class AiStreamingService { * * @param toLang The target language ('en' or 'de') * @param text The HTML job description text to translate + * @param jobId The job whose background translation may be superseded by generation * @param onChunk Callback invoked with the accumulated content after each SSE chunk - * @param signal Optional {@link AbortSignal} for cancellation; when aborted, the + * @param signal {@link AbortSignal} for cancellation; when aborted, the * stream reader is cancelled and the promise rejects with an AbortError * @returns Promise resolving to the full accumulated content on stream completion * @throws DOMException with name 'AbortError' if the signal is aborted @@ -63,11 +66,12 @@ export class AiStreamingService { async translateJobDescriptionStream( toLang: string, text: string, + jobId: string | undefined, onChunk: (accumulatedContent: string) => void, - signal?: AbortSignal, + signal: AbortSignal, ): Promise { const url = `/api/ai/translateJobDescriptionStream?toLang=${encodeURIComponent(toLang)}`; - return this.streamSSE(url, JSON.stringify({ text }), onChunk, signal); + return this.streamSSE(url, JSON.stringify({ text, jobId }), onChunk, signal); } /** @@ -79,7 +83,7 @@ export class AiStreamingService { * The processing follows these steps: * * 1) Build authenticated request headers (Bearer token from Keycloak) - * 2) Open the SSE connection via fetch() with the given body and optional AbortSignal + * 2) Open the SSE connection via fetch() with the given body and AbortSignal * 3) Read the response stream chunk by chunk using a ReadableStream reader * 4) Buffer incomplete lines across chunk boundaries (SSE lines end with \n) * 5) For each complete `data:` line, strip the prefix, append to accumulated content, @@ -90,10 +94,10 @@ export class AiStreamingService { * @param url The full API URL including query parameters * @param body The JSON-serialized request body * @param onChunk Callback invoked with accumulated content after each SSE data line - * @param signal Optional AbortSignal for cancellation support + * @param signal AbortSignal for cancellation support * @returns Promise resolving to the full accumulated content */ - private async streamSSE(url: string, body: string, onChunk: (accumulatedContent: string) => void, signal?: AbortSignal): Promise { + private async streamSSE(url: string, body: string, onChunk: (accumulatedContent: string) => void, signal: AbortSignal): Promise { // 1) Build authenticated request headers const token = this.keycloakService.getToken(); const headers: Record = { 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..f48b408d35 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 @@ -192,6 +192,10 @@ export class EditorComponent extends BaseInputDirective { }); editorValue = computed(() => { + const forcedValue = this.displayOverride(); + if (forcedValue !== undefined) { + return forcedValue; + } if (this.hasFormControl()) { return this.formControl().value ?? ''; } else { @@ -246,6 +250,7 @@ export class EditorComponent extends BaseInputDirective { protected currentLang = toSignal(this.translate.onLangChange.pipe(map(e => e.lang)), { initialValue: this.translate.getCurrentLang() }); + private readonly displayOverride = signal(undefined); private htmlValue = signal(''); // eslint-disable-next-line @typescript-eslint/strict-boolean-expressions private hasFormControl = computed(() => !!this.formControl()); @@ -256,7 +261,7 @@ export class EditorComponent extends BaseInputDirective { }); private analyzeEffect = effect(() => { - if (!this.showGenderDecoderButton()) return; + if (!this.showGenderDecoderButton() || this.loading()) return; const html = this.htmlValue(); const plainText = extractTextFromHtml(html); @@ -283,6 +288,9 @@ export class EditorComponent extends BaseInputDirective { textChanged(event: ContentChange): void { const { source, oldDelta, editor } = event; + if (source === 'user') { + this.displayOverride.set(undefined); + } const limit = this.characterLimit(); // Only check limit if it is defined @@ -346,34 +354,17 @@ export class EditorComponent extends BaseInputDirective { * */ public forceUpdate(newValue: string, onComplete?: () => void): void { - this.htmlValue.set(newValue); - - const editor = this.quillEditorComponent()?.quillEditor; - if (!editor) { - // Quill instance isn't created yet, retry on next frame - requestAnimationFrame(() => this.forceUpdate(newValue, onComplete)); - return; - } - - // Preserve cursor/selection if editor currently focused - const hadFocus = editor.hasFocus(); - const range = hadFocus ? editor.getSelection() : null; - - const content = editor.clipboard.convert({ html: newValue }); - editor.setContents(content, 'api'); - - // Restore selection (clamp to doc length) - if (hadFocus && range) { - const len = editor.getLength(); - const index = Math.min(range.index, Math.max(0, len - 1)); - editor.setSelection(index, range.length, 'silent'); - } - - this.cdRef.markForCheck(); + this.updateContent(newValue, false, onComplete); + } - if (onComplete) { - requestAnimationFrame(() => onComplete()); - } + /** + * Displays streamed HTML until a final form-backed update replaces it. + * + * @param newValue The temporary streamed HTML to display + * @param onComplete Optional callback fired after Quill finishes updating the DOM + */ + public forceStreamingUpdate(newValue: string, onComplete?: () => void): void { + this.updateContent(newValue, true, onComplete); } /** @@ -451,6 +442,40 @@ export class EditorComponent extends BaseInputDirective { } } + private updateContent(newValue: string, temporary: boolean, onComplete?: () => void): void { + this.displayOverride.set(temporary ? newValue : undefined); + this.htmlValue.set(newValue); + + const editor = this.quillEditorComponent()?.quillEditor; + if (!editor) { + requestAnimationFrame(() => this.updateContent(newValue, temporary, onComplete)); + return; + } + + // Preserve cursor/selection if editor currently focused + const hadFocus = editor.hasFocus(); + const range = hadFocus ? editor.getSelection() : null; + + const content = editor.clipboard.convert({ html: newValue }); + editor.setContents(content, 'api'); + + // Restore selection (clamp to doc length) + if (hadFocus && range) { + const len = editor.getLength(); + const index = Math.min(range.index, Math.max(0, len - 1)); + editor.setSelection(index, range.length, 'silent'); + } + + this.cdRef.markForCheck(); + + if (temporary || onComplete) { + requestAnimationFrame(() => { + if (temporary) editor.root.scrollTop = editor.root.scrollHeight; + onComplete?.(); + }); + } + } + /** * Removes compliance-highlight span wrappers from serialized editor HTML while * keeping their inner content. Highlights are a visual-only overlay, so their 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..4cc9026a46 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.JobAnalysisDTO; import de.tum.cit.aet.ai.dto.TranslateComplianceDTO; import de.tum.cit.aet.ai.service.AiFeatureToggleService; import de.tum.cit.aet.ai.service.AiService; @@ -21,6 +22,7 @@ import de.tum.cit.aet.utility.security.JwtPostProcessors; import java.util.List; import java.util.UUID; +import java.util.concurrent.CancellationException; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; @@ -29,7 +31,6 @@ import org.springframework.http.MediaType; import org.springframework.test.util.ReflectionTestUtils; import reactor.core.publisher.Flux; -import tools.jackson.core.type.TypeReference; class AiResourceTest extends AbstractResourceTest { @@ -69,9 +70,9 @@ class TranslateJobDescriptionStreamTests { @Test void shouldReturnStreamWhenProfessorTranslatesJobDescription() { String toLang = "de"; - TranslateComplianceDTO request = new TranslateComplianceDTO(input, null); + TranslateComplianceDTO request = new TranslateComplianceDTO(input, null, JOB_ID); - given(aiService.translateTextStream(anyString(), anyString())).willReturn(Flux.just("Hallo", " Welt")); + given(aiService.translateTextStream(anyString(), anyString(), any(UUID.class))).willReturn(Flux.just("Hallo", " Welt")); String url = TRANSLATE_STREAM_URL + "?toLang=" + toLang; api @@ -82,7 +83,7 @@ void shouldReturnStreamWhenProfessorTranslatesJobDescription() { @Test void shouldReturnForbiddenWhenApplicantTranslatesJobDescription() { String url = TRANSLATE_STREAM_URL + "?toLang=de"; - TranslateComplianceDTO request = new TranslateComplianceDTO(input, null); + TranslateComplianceDTO request = new TranslateComplianceDTO(input, null, JOB_ID); api .with(JwtPostProcessors.jwtUser(APPLICANT_USER_ID, "ROLE_APPLICANT")) .putAndRead(url, request, Void.class, 403, MediaType.TEXT_EVENT_STREAM); @@ -91,7 +92,7 @@ void shouldReturnForbiddenWhenApplicantTranslatesJobDescription() { @Test void shouldReturnUnauthorizedWhenTranslateJobDescriptionWithoutAuthentication() { String url = TRANSLATE_STREAM_URL + "?toLang=de"; - TranslateComplianceDTO request = new TranslateComplianceDTO(input, null); + TranslateComplianceDTO request = new TranslateComplianceDTO(input, null, JOB_ID); api.withoutPostProcessors().putAndRead(url, request, Void.class, 401, MediaType.TEXT_EVENT_STREAM); } } @@ -114,14 +115,27 @@ void shouldReturnComplianceIssuesWhenProfessorAnalyzesJobDescription() { ) ); - given(aiService.analyzeCurrentJobDescription(any(JobFormDTO.class), anyString(), anyString())).willReturn(expectedIssues); + JobAnalysisDTO expectedAnalysis = JobAnalysisDTO.from(82, expectedIssues); + given(aiService.analyzeCurrentJobDescription(any(JobFormDTO.class), anyString(), anyString())).willReturn(expectedAnalysis); - List response = api + JobAnalysisDTO response = api .with(JwtPostProcessors.jwtUser(PROFESSOR_USER_ID, "ROLE_PROFESSOR")) - .postAndRead(ANALYZE_URL + "?lang=en", createValidJobForm(), new TypeReference>() {}, 200); + .postAndRead(ANALYZE_URL + "?lang=en", createValidJobForm(), JobAnalysisDTO.class, 200); - assertThat(response).hasSize(1); - assertThat(response.getFirst().getCategory()).isEqualTo(ComplianceCategory.CRITICAL_AGG); + assertThat(response.score()).isEqualTo(82); + assertThat(response.issues()).hasSize(1); + assertThat(response.issues().getFirst().category()).isEqualTo(ComplianceCategory.CRITICAL_AGG); + } + + @Test + void shouldReturnConflictWhenAnalysisIsCancelled() { + given(aiService.analyzeCurrentJobDescription(any(JobFormDTO.class), anyString(), anyString())).willThrow( + new CancellationException("superseded") + ); + + api + .with(JwtPostProcessors.jwtUser(PROFESSOR_USER_ID, "ROLE_PROFESSOR")) + .postAndRead(ANALYZE_URL + "?lang=en", createValidJobForm(), Void.class, 409); } @Test 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..917b189a78 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 @@ -1,4 +1,5 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { HttpErrorResponse } from '@angular/common/http'; import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; import { of, Subject, throwError } from 'rxjs'; import { UrlSegment } from '@angular/router'; @@ -6,6 +7,7 @@ import { signal, TemplateRef } from '@angular/core'; import { JobCreationFormComponent } from 'app/job/job-creation-form/job-creation-form.component'; import { JobResourceApi } from 'app/generated/api/job-resource-api'; +import { AiResourceApi } from 'app/generated/api/ai-resource-api'; import { ImageResourceApi } from 'app/generated/api/image-resource-api'; import { User } from 'app/core/auth/account.service'; import { @@ -95,8 +97,9 @@ 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, run?: unknown) => Promise; + analyzeAndUpdateScore: (lang: string, run?: unknown) => Promise; + startAiRun: () => unknown; }; function getPrivate(component: JobCreationFormComponent): ComponentPrivate { @@ -658,7 +661,14 @@ describe('JobCreationFormComponent', () => { function setupGen() { component.jobId.set('job123'); fillValidJobForm(component); - const mockEditor = { forceUpdate: vi.fn() }; + const mockEditor = { + forceUpdate: vi.fn((_content: string, onComplete?: () => void) => { + onComplete?.(); + }), + forceStreamingUpdate: vi.fn((_content: string, onComplete?: () => void) => { + onComplete?.(); + }), + }; Object.defineProperty(component, 'jobDescriptionEditor', { value: () => mockEditor, configurable: true, @@ -680,6 +690,60 @@ describe('JobCreationFormComponent', () => { expect(component.rewriteButtonSignal()).toBe(true); }); + it('should expose the generation state before the stream responds', async () => { + const editor = setupGen(); + component.aiSystemEnabled.set(true); + let finishGeneration: ((content: string) => void) | undefined; + mockAiStreamingService.generateJobApplicationDraftStream.mockReturnValue( + new Promise(resolve => { + finishGeneration = resolve; + }), + ); + + const generation = component.generateJobApplicationDraft(); + + expect(component.isGeneratingDraft()).toBe(true); + expect(editor.forceStreamingUpdate.mock.calls[0]?.[0]).toContain('aiFillerText'); + + await Promise.resolve(); + if (!finishGeneration) { + throw new Error('Generation stream did not start'); + } + finishGeneration(''); + await generation; + expect(component.isGeneratingDraft()).toBe(false); + }); + + it('should replace the filler with the first streamed HTML chunk', async () => { + const editor = setupGen(); + component.aiSystemEnabled.set(true); + mockAiStreamingService.generateJobApplicationDraftStream.mockImplementation( + (_lang: string, _request: JobFormDTO, onChunk: (content: string) => void) => { + onChunk('{"jobDescription":"

      First chunk

      "}'); + return Promise.resolve(''); + }, + ); + + await component.generateJobApplicationDraft(); + + expect(editor.forceStreamingUpdate.mock.calls[0]?.[0]).toContain('aiFillerText'); + expect(editor.forceStreamingUpdate.mock.calls[1]?.[0]).toBe('

      First chunk

      '); + }); + + it('should not let translation state changes overwrite the generation filler', () => { + const editor = setupGen(); + editor.forceUpdate.mockClear(); + editor.forceStreamingUpdate.mockClear(); + component.isGeneratingDraft.set(true); + component.isTranslating.set(true); + component.translationTargetLang.set('de'); + + fixture.detectChanges(); + + expect(editor.forceUpdate).not.toHaveBeenCalled(); + expect(editor.forceStreamingUpdate).not.toHaveBeenCalled(); + }); + it('should cancel translation when in flight', async () => { setupGen(); component.isTranslating.set(true); @@ -689,13 +753,13 @@ describe('JobCreationFormComponent', () => { expect(cancelSpy).toHaveBeenCalledOnce(); }); - it('should not cancel translation when not in flight', async () => { + it('should reset the AI workflow even when no translation is in flight', async () => { setupGen(); component.isTranslating.set(false); const cancelSpy = vi.spyOn(component as unknown as { cancelTranslation: () => void }, 'cancelTranslation'); mockAiStreamingService.generateJobApplicationDraftStream.mockRejectedValue(new Error('fail')); await component.generateJobApplicationDraft(); - expect(cancelSpy).not.toHaveBeenCalled(); + expect(cancelSpy).toHaveBeenCalledOnce(); }); }); @@ -746,7 +810,7 @@ describe('JobCreationFormComponent', () => { expect(component.isTranslating()).toBe(false); expect(component.isAnalyzing()).toBe(true); - expect(analyzeSpy).toHaveBeenCalledWith('de'); + expect(analyzeSpy).toHaveBeenCalledWith('de', expect.anything()); resolveAnalysis(); await promise; @@ -763,5 +827,50 @@ describe('JobCreationFormComponent', () => { expect(mockAiStreamingService.translateJobDescriptionStream).not.toHaveBeenCalled(); expect(component.isTranslating()).toBe(false); }); + + it('should not show an error when translation is cancelled', async () => { + component.jobId.set('job1'); + component.currentDescriptionLanguage.set('en'); + component.lastTranslatedEN.set(''); + mockAiStreamingService.translateJobDescriptionStream.mockImplementation( + (_targetLang: string, _text: string, _jobId: string | undefined, _onChunk: (content: string) => void, signal: AbortSignal) => + new Promise((_resolve, reject) => { + signal?.addEventListener('abort', () => reject(new TypeError('cancelled'))); + }), + ); + + const translation = getPrivate(component).translateAndStoreOtherLanguage('en', 'Hello EN'); + getPrivate(component).startAiRun(); + await translation; + + expect(mockToastService.showErrorKey).not.toHaveBeenCalledWith('jobCreationForm.toastMessages.aiTranslationFailed'); + }); + + it('should not show an error when compliance analysis is cancelled', async () => { + component.jobId.set('job1'); + fillValidJobForm(component); + const pendingAnalysis = new Subject(); + const aiApi = (component as unknown as { aiApi: AiResourceApi }).aiApi; + vi.spyOn(aiApi, 'analyzeJobDescriptionForCompliance').mockReturnValue(pendingAnalysis.asObservable()); + + const analysis = getPrivate(component).analyzeAndUpdateScore('en'); + getPrivate(component).startAiRun(); + await analysis; + + expect(mockToastService.showErrorKey).not.toHaveBeenCalledWith('jobCreationForm.toastMessages.aiComplianceFailed'); + }); + + it('should not show an error when the server reports superseded analysis', async () => { + component.jobId.set('job1'); + fillValidJobForm(component); + const aiApi = (component as unknown as { aiApi: AiResourceApi }).aiApi; + vi.spyOn(aiApi, 'analyzeJobDescriptionForCompliance').mockReturnValue( + throwError(() => new HttpErrorResponse({ status: 409, statusText: 'Conflict' })), + ); + + await getPrivate(component).analyzeAndUpdateScore('en'); + + expect(mockToastService.showErrorKey).not.toHaveBeenCalledWith('jobCreationForm.toastMessages.aiComplianceFailed'); + }); }); }); diff --git a/src/test/webapp/app/service/ai-streaming.service.spec.ts b/src/test/webapp/app/service/ai-streaming.service.spec.ts index 16f7b64f4e..e3b2b7a6ac 100644 --- a/src/test/webapp/app/service/ai-streaming.service.spec.ts +++ b/src/test/webapp/app/service/ai-streaming.service.spec.ts @@ -37,6 +37,7 @@ describe('AiStreamingService', () => { let service: AiStreamingService; let mockKeycloakService: { getToken: Mock }; let fetchSpy: Mock; + let signal: AbortSignal; beforeEach(() => { mockKeycloakService = { @@ -48,6 +49,7 @@ describe('AiStreamingService', () => { }); service = TestBed.inject(AiStreamingService); + signal = new AbortController().signal; // Mock global fetch fetchSpy = vi.fn(); @@ -64,7 +66,7 @@ describe('AiStreamingService', () => { fetchSpy.mockResolvedValue(createMockResponse(chunks)); const onChunk = vi.fn(); - const result = await service.generateJobApplicationDraftStream('en', {} as never, onChunk); + const result = await service.generateJobApplicationDraftStream('en', {} as never, onChunk, signal); expect(result).toBe('{"jobDescription":"Hello"}'); expect(onChunk).toHaveBeenCalledWith('{"jobDescription":"Hello"}'); @@ -75,7 +77,7 @@ describe('AiStreamingService', () => { fetchSpy.mockResolvedValue(createMockResponse(chunks)); const onChunk = vi.fn(); - const result = await service.generateJobApplicationDraftStream('en', {} as never, onChunk); + const result = await service.generateJobApplicationDraftStream('en', {} as never, onChunk, signal); expect(result).toBe('part1part2'); expect(onChunk).toHaveBeenCalledTimes(2); @@ -92,7 +94,7 @@ describe('AiStreamingService', () => { fetchSpy.mockResolvedValue(createMockResponse(chunks)); const onChunk = vi.fn(); - const result = await service.generateJobApplicationDraftStream('en', {} as never, onChunk); + const result = await service.generateJobApplicationDraftStream('en', {} as never, onChunk, signal); // The complete content should be assembled correctly expect(result).toBe('{"jobDescription":"test"}'); @@ -107,7 +109,7 @@ describe('AiStreamingService', () => { fetchSpy.mockResolvedValue(createMockResponse(chunks)); const onChunk = vi.fn(); - const result = await service.generateJobApplicationDraftStream('en', {} as never, onChunk); + const result = await service.generateJobApplicationDraftStream('en', {} as never, onChunk, signal); expect(result).toBe('{"content":"value"}'); }); @@ -117,7 +119,7 @@ describe('AiStreamingService', () => { fetchSpy.mockResolvedValue(createMockResponse(chunks)); const onChunk = vi.fn(); - const result = await service.generateJobApplicationDraftStream('en', {} as never, onChunk); + const result = await service.generateJobApplicationDraftStream('en', {} as never, onChunk, signal); // Only data: line should be processed expect(result).toBe('content'); @@ -130,7 +132,7 @@ describe('AiStreamingService', () => { fetchSpy.mockResolvedValue(createMockResponse(chunks)); const onChunk = vi.fn(); - const result = await service.generateJobApplicationDraftStream('en', {} as never, onChunk); + const result = await service.generateJobApplicationDraftStream('en', {} as never, onChunk, signal); expect(result).toBe('final-content'); }); @@ -138,7 +140,9 @@ describe('AiStreamingService', () => { it('should throw error on non-ok response', async () => { fetchSpy.mockResolvedValue(createMockResponse([], 401)); - await expect(service.generateJobApplicationDraftStream('en', {} as never, vi.fn())).rejects.toThrow('HTTP error! status: 401'); + await expect(service.generateJobApplicationDraftStream('en', {} as never, vi.fn(), signal)).rejects.toThrow( + 'HTTP error! status: 401', + ); }); it('should return empty string when response body is null', async () => { @@ -148,7 +152,7 @@ describe('AiStreamingService', () => { body: null, }); - const result = await service.generateJobApplicationDraftStream('en', {} as never, vi.fn()); + const result = await service.generateJobApplicationDraftStream('en', {} as never, vi.fn(), signal); expect(result).toBe(''); }); @@ -157,7 +161,7 @@ describe('AiStreamingService', () => { const chunks = ['data:test\n\n']; fetchSpy.mockResolvedValue(createMockResponse(chunks)); - await service.generateJobApplicationDraftStream('en', {} as never, vi.fn()); + await service.generateJobApplicationDraftStream('en', {} as never, vi.fn(), signal); expect(fetchSpy).toHaveBeenCalledWith( expect.any(String), @@ -174,7 +178,7 @@ describe('AiStreamingService', () => { const chunks = ['data:test\n\n']; fetchSpy.mockResolvedValue(createMockResponse(chunks)); - await service.generateJobApplicationDraftStream('en', {} as never, vi.fn()); + await service.generateJobApplicationDraftStream('en', {} as never, vi.fn(), signal); const callHeaders = fetchSpy.mock.calls[0][1].headers; expect(callHeaders.Authorization).toBeUndefined(); @@ -195,7 +199,7 @@ describe('AiStreamingService', () => { fetchSpy.mockResolvedValue(createMockResponse(chunks)); const onChunk = vi.fn(); - const result = await service.generateJobApplicationDraftStream('en', {} as never, onChunk); + const result = await service.generateJobApplicationDraftStream('en', {} as never, onChunk, signal); expect(result).toBe(fullContent); });