From 506a38b1ad69c2284634cfb35b18d7fda8d961dd Mon Sep 17 00:00:00 2001 From: Melissa Date: Tue, 4 Aug 2026 13:31:28 +0200 Subject: [PATCH 1/7] feat: add genderDecoder UI in sidebar --- .run/DocApplyApp.run.xml | 4 +- .../job-creation-form.component.html | 2 + .../job-creation-form.component.ts | 83 +++++++++++++++---- .../atoms/editor/editor.component.ts | 74 +++++++++++++++-- .../ai-assistant-card.component.html | 57 +++++++++++++ .../ai-assistant-card.component.ts | 43 ++++++++++ src/main/webapp/i18n/de/job.json | 29 ++++++- src/main/webapp/i18n/en/job.json | 29 ++++++- .../job-creation-form.component.spec.ts | 34 +++++++- .../atoms/editor/editor.component.spec.ts | 65 +++++++++++++++ .../ai-assistant-card.component.spec.ts | 29 +++++++ 11 files changed, 421 insertions(+), 28 deletions(-) diff --git a/.run/DocApplyApp.run.xml b/.run/DocApplyApp.run.xml index aa365b9cba..39d74e5ba3 100644 --- a/.run/DocApplyApp.run.xml +++ b/.run/DocApplyApp.run.xml @@ -2,10 +2,10 @@ - + \ No newline at end of file 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..70dda347de 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 @@ -188,6 +188,7 @@

icon="circle-info" [shouldTranslate]="true" [showGenderDecoderButton]="true" + [pauseGenderDecoderAnalysis]="isGeneratingDraft()" height="20rem" (highlightHovered)="onHighlightHovered($event)" /> @@ -218,6 +219,7 @@

[isRewriteMode]="rewriteButtonSignal()" [currentLang]="currentDescriptionLanguage()" [complianceIssues]="complianceIssues()" + [genderBiasAnalysis]="jobDescriptionEditor.analysisResult()" (generate)="generateJobApplicationDraft()" (filterComplianceCat)="onComplianceFilterChange($event)" /> 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..9eea2f2b91 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 @@ -212,6 +212,9 @@ export class JobCreationFormComponent { /** Last analyzed description text per language (used to avoid redundant compliance analysis) */ private lastAnalyzedText: Record = {}; + /** Incremented whenever background AI/save work should be ignored from now on. */ + private backgroundProcessVersion = 0; + // ═══════════════════════════════════════════════════════════════════════════ // AI GENERATION SIGNALS // ═══════════════════════════════════════════════════════════════════════════ @@ -661,6 +664,22 @@ export class JobCreationFormComponent { this.translationTargetLang.set(undefined); } + /** Stops background work before AI generation takes ownership of the editor. */ + private cancelBackgroundProcessesBeforeGeneration(): number { + this.backgroundProcessVersion++; + this.autoSave.reset(); + if (this.isTranslating()) { + this.cancelTranslation(); + } + this.isAnalyzing.set(false); + this.activePopoverIssue.set(undefined); + return this.backgroundProcessVersion; + } + + private isCurrentBackgroundProcess(version: number): boolean { + return version === this.backgroundProcessVersion; + } + /** * 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. @@ -999,13 +1018,7 @@ export class JobCreationFormComponent { } const originalContent = this.basicInfoForm.get('jobDescription')?.value; const language = this.currentDescriptionLanguage(); - - // 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(); - } + const processVersion = this.cancelBackgroundProcessesBeforeGeneration(); // 1) Enter generation mode and show placeholder this.isGeneratingDraft.set(true); @@ -1069,7 +1082,7 @@ export class JobCreationFormComponent { // is async and hasn't reached its own pre-set yet). this.syncCurrentEditorIntoLanguageSignals(); this.isAnalyzing.set(true); - void this.postGenerationSaveAndProcess(language, finalContent); + void this.postGenerationSaveAndProcess(language, finalContent, processVersion); } else { this.jobDescriptionEditor()?.forceUpdate(originalContent); this.toastService.showErrorKey('jobCreationForm.toastMessages.aiGenerationFailed'); @@ -1093,13 +1106,18 @@ export class JobCreationFormComponent { * Immediately saves the generated content and fires analysis + translation in parallel. * Called directly after AI draft generation to skip the 5s autosave delay. */ - private async postGenerationSaveAndProcess(sourceLang: Language, sourceText: string): Promise { + private async postGenerationSaveAndProcess( + sourceLang: Language, + sourceText: string, + processVersion = this.backgroundProcessVersion, + ): Promise { const currentData = this.createJobDTO(JobFormDTOStateEnum.Draft); this.autoSave.setState(SavingStates.SAVING); try { // 1) Persist the generated content to the server const saved = await this.saveDraft(currentData); + if (!this.isCurrentBackgroundProcess(processVersion)) return; // 2) Sync local state with server response this.lastSavedData.set(saved); @@ -1109,9 +1127,15 @@ 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)]); + void Promise.all([ + this.analyzeAndUpdateScore(sourceLang, processVersion), + this.translateAndStoreOtherLanguage(sourceLang, sourceText, processVersion), + ]); } } catch { + if (!this.isCurrentBackgroundProcess(processVersion)) { + return; + } this.autoSave.setState(SavingStates.FAILED); this.isAnalyzing.set(false); this.toastService.showErrorKey('toast.saveFailed'); @@ -1640,6 +1664,8 @@ export class JobCreationFormComponent { } private async executeAutoSave(): Promise { + const processVersion = this.backgroundProcessVersion; + // 1) Capture current form state before any async work this.syncCurrentEditorIntoLanguageSignals(); const currentLang = this.currentDescriptionLanguage(); @@ -1649,6 +1675,7 @@ export class JobCreationFormComponent { try { // 2) Create or update the job on the server const saved = await this.saveDraft(currentData); + if (!this.isCurrentBackgroundProcess(processVersion)) return true; // 3) Sync local state with server response this.lastSavedData.set(saved); @@ -1660,10 +1687,16 @@ export class JobCreationFormComponent { // analysis calls that cause score flash issues. if (this.aiToggleSignal() && this.aiSystemEnabled()) { // highlighting before translation - void Promise.all([this.analyzeAndUpdateScore(currentLang), this.translateAndStoreOtherLanguage(currentLang, description)]); + void Promise.all([ + this.analyzeAndUpdateScore(currentLang, processVersion), + this.translateAndStoreOtherLanguage(currentLang, description, processVersion), + ]); } return true; } catch { + if (!this.isCurrentBackgroundProcess(processVersion)) { + return true; + } this.toastService.showErrorKey('toast.saveFailed'); return false; } @@ -1704,9 +1737,14 @@ 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, + processVersion = this.backgroundProcessVersion, + ): Promise { const text = currentText.trim(); if (!text) return; + if (!this.isCurrentBackgroundProcess(processVersion)) 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. @@ -1754,6 +1792,7 @@ export class JobCreationFormComponent { }, abortController.signal, ); + if (!this.isCurrentBackgroundProcess(processVersion)) return; let hasTranslation = false; if (accumulatedContent) { @@ -1798,8 +1837,9 @@ export class JobCreationFormComponent { try { const currentData = this.createJobDTO(JobFormDTOStateEnum.Draft); const saved = await firstValueFrom(this.jobApi.updateJob(jobId, currentData)); + if (!this.isCurrentBackgroundProcess(processVersion)) return; this.lastSavedData.set(saved); - await this.analyzeAndUpdateScore(targetLang); + await this.analyzeAndUpdateScore(targetLang, processVersion); } catch { // Silent save failure — will be caught by next autosave this.isAnalyzing.set(false); @@ -1810,6 +1850,7 @@ export class JobCreationFormComponent { if (e instanceof DOMException && e.name === 'AbortError') { return; // Cancelled — silently ignore } + if (!this.isCurrentBackgroundProcess(processVersion)) return; this.toastService.showErrorKey('jobCreationForm.toastMessages.aiTranslationFailed'); } } @@ -1820,9 +1861,10 @@ export class JobCreationFormComponent { * * @param lang - The language to analyze ('en' or 'de') */ - private async analyzeAndUpdateScore(lang: string): Promise { + private async analyzeAndUpdateScore(lang: string, processVersion = this.backgroundProcessVersion): Promise { const jobId = this.jobId(); if (!jobId) return; + if (!this.isCurrentBackgroundProcess(processVersion)) return; // 1) Build a fresh DTO and skip if the description hasn't changed since last analysis const jobForm = this.createJobDTO(JobFormDTOStateEnum.Draft); @@ -1837,6 +1879,9 @@ export class JobCreationFormComponent { try { // 2) Send the description to the analysis endpoint (persists score on the backend) const compliance = await firstValueFrom(this.aiApi.analyzeJobDescriptionForCompliance(lang, jobForm, userLang)); + if (!this.isCurrentBackgroundProcess(processVersion)) { + return; + } 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'; @@ -1849,6 +1894,9 @@ export class JobCreationFormComponent { // (DB transaction may not have committed yet). for (let attempt = 0; attempt < 2; attempt++) { const updatedJob = await firstValueFrom(this.jobApi.getJobById(jobId)); + if (!this.isCurrentBackgroundProcess(processVersion)) { + return; + } if (updatedJob.genderBiasScore !== undefined) { this.aiScore.set(updatedJob.genderBiasScore); break; @@ -1862,9 +1910,14 @@ export class JobCreationFormComponent { this.applyHighlights(compliance, lang); } } catch { + if (!this.isCurrentBackgroundProcess(processVersion)) { + return; + } this.toastService.showErrorKey('jobCreationForm.toastMessages.aiComplianceFailed'); } finally { - this.isAnalyzing.set(false); + if (this.isCurrentBackgroundProcess(processVersion)) { + 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..c91ab06eba 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 @@ -94,6 +94,39 @@ class HighlightBlot extends Inline { // Register in Quill so the editor recognizes it Quill.register(HighlightBlot); +/** + * Inline marker for wording flagged by the Gender Decoder. It is visually + * separate from compliance highlights so both can be rendered together. + */ +class GenderBiasHighlightBlot extends Inline { + static blotName = 'genderBiasHighlight'; + static tagName = 'span'; + static className = 'gender-bias-highlight'; + + static baseClasses = [ + '[text-decoration-line:underline]', + '[text-decoration-style:wavy]', + '[text-decoration-color:var(--color-text-tertiary)]', + '[text-decoration-thickness:1.5px]', + 'underline-offset-4', + '[box-decoration-break:clone]', + '[-webkit-box-decoration-break:clone]', + ]; + + static create(): HTMLElement { + const node = super.create() as HTMLElement; + GenderBiasHighlightBlot.baseClasses.forEach((cls: string) => node.classList.add(cls)); + node.dataset['genderBiasHighlight'] = 'non-inclusive'; + return node; + } + + static formats(node: HTMLElement): string | undefined { + return node.dataset['genderBiasHighlight']; + } +} + +Quill.register(GenderBiasHighlightBlot); + const STANDARD_CHARACTER_LIMIT = 500; const STANDARD_CHARACTER_BUFFER = 300; @@ -119,6 +152,7 @@ export class EditorComponent extends BaseInputDirective { height = input('12.5rem'); helperText = input(undefined); // Optional helper text to display below the editor field showGenderDecoderButton = input(false); + pauseGenderDecoderAnalysis = input(false); // When true the editor is showing externally-streamed content (e.g. an AI // translation); the empty/required error is suppressed so it does not flash // while the first chunks arrive. @@ -127,7 +161,7 @@ export class EditorComponent extends BaseInputDirective { openAnalysisDialog = output(); quillEditorComponent = viewChild(QuillEditorComponent); highlightHovered = output<{ text: string; x: number; y: number } | undefined>(); - pendingHighlights = signal<{ text: string; category: ComplianceIssueCategoryEnum }[]>([]); + pendingComplianceHighlights = signal<{ text: string; category: ComplianceIssueCategoryEnum }[]>([]); readonly genderBiasService = inject(GenderBiasAnalysisService); readonly translateService = inject(TranslateService); @@ -151,6 +185,15 @@ export class EditorComponent extends BaseInputDirective { return this.showGenderDecoderButton() && this.analysisResult() !== undefined; }); + readonly genderBiasHighlights = computed(() => { + if (!this.showGenderDecoderButton()) return []; + + const words = this.analysisResult()?.biasedWords?.filter(word => word.type === 'non-inclusive') ?? []; + const uniqueWords = [...new Set(words.map(word => word.word?.trim()).filter((word): word is string => Boolean(word)))]; + + return uniqueWords.map(text => ({ text })); + }); + // Check if error message should be displayed isOverCharLimit = computed(() => { const limit = this.characterLimit(); @@ -257,6 +300,7 @@ export class EditorComponent extends BaseInputDirective { private analyzeEffect = effect(() => { if (!this.showGenderDecoderButton()) return; + if (this.pauseGenderDecoderAnalysis()) return; const html = this.htmlValue(); const plainText = extractTextFromHtml(html); @@ -273,11 +317,13 @@ export class EditorComponent extends BaseInputDirective { * Re-runs highlight application whenever: * - the QuillEditor view child becomes available * - forceUpdate pushes new content (via editorReady) - * - new highlights are requested via highlightTexts() + * - new compliance highlights are requested via highlightTexts() + * - new Gender Decoder analysis results arrive */ private reapplyHighlightsEffect = effect(() => { this.quillEditorComponent(); - this.pendingHighlights(); + this.pendingComplianceHighlights(); + this.genderBiasHighlights(); requestAnimationFrame(() => this.applyPendingHighlights()); }); @@ -382,7 +428,7 @@ export class EditorComponent extends BaseInputDirective { * @param highlights Array of {text, category} to highlight */ public highlightTexts(highlights: { text: string; category: ComplianceIssueCategoryEnum }[]): void { - this.pendingHighlights.set(highlights); + this.pendingComplianceHighlights.set(highlights); } /** @@ -392,20 +438,22 @@ export class EditorComponent extends BaseInputDirective { const editor = this.quillEditorComponent()?.quillEditor; // Retry next frame if editor not ready and highlights pending if (!editor) { - if (this.pendingHighlights().length > 0) { + if (this.pendingComplianceHighlights().length > 0 || this.genderBiasHighlights().length > 0) { requestAnimationFrame(() => this.applyPendingHighlights()); } return; } - const highlights = this.pendingHighlights(); + const complianceHighlights = this.pendingComplianceHighlights(); + const genderBiasHighlights = this.genderBiasHighlights(); // Clear all existing highlights first editor.formatText(0, editor.getLength(), 'background', false); editor.formatText(0, editor.getLength(), 'customHighlight', false); + editor.formatText(0, editor.getLength(), 'genderBiasHighlight', false); const fullText = editor.getText().toLowerCase(); - for (const { text, category } of highlights) { + for (const { text, category } of complianceHighlights) { const searchText = text.toLowerCase(); let startIndex = 0; @@ -417,6 +465,18 @@ export class EditorComponent extends BaseInputDirective { startIndex = index + text.length; } } + + for (const { text } of genderBiasHighlights) { + const searchText = text.toLowerCase(); + let startIndex = 0; + + while (startIndex < fullText.length) { + const index = fullText.indexOf(searchText, startIndex); + if (index === -1) break; + editor.formatText(index, text.length, 'genderBiasHighlight', true); + startIndex = index + text.length; + } + } } /** diff --git a/src/main/webapp/app/shared/components/molecules/ai-assistant-card/ai-assistant-card.component.html b/src/main/webapp/app/shared/components/molecules/ai-assistant-card/ai-assistant-card.component.html index d1f1f298b9..2ee7a7dc25 100644 --- a/src/main/webapp/app/shared/components/molecules/ai-assistant-card/ai-assistant-card.component.html +++ b/src/main/webapp/app/shared/components/molecules/ai-assistant-card/ai-assistant-card.component.html @@ -75,6 +75,63 @@
+ +
+

+ +
+ +
+

+ +
+ + + + + @if (genderDecoderReviewCount() > 0) { + {{ genderDecoderReviewCount() }} + } @else if (genderBiasAnalysis() !== undefined && !hasGenderDecoderReview()) { + + } + +
+ +
+

+
+
+
+ + + +
+ +
+ +
+ + + +
+
+
+
+ +
+

('custom-sparkle'); complianceIssues = input([]); currentLang = input('en'); + genderBiasAnalysis = input(undefined); // ═══════════════════════════════════════════════════════════════════════════ // CONSTANTS @@ -134,6 +136,47 @@ export class AiAssistantCardComponent { () => this.issueCountForLang().filter(i => i.category === ComplianceIssueCategoryEnum.PublicSector).length, ); + /** Position of the gender decoder pointer on the sidebar scale. */ + readonly genderDecoderPointerPosition = computed(() => { + switch (this.genderBiasAnalysis()?.coding) { + case 'non-inclusive-coded': + return 14; + case 'inclusive-coded': + return 86; + case 'neutral': + case 'empty': + default: + return 50; + } + }); + + readonly genderDecoderWordsToImprove = computed(() => { + const words = this.genderBiasAnalysis()?.biasedWords?.filter(word => word.type === 'non-inclusive') ?? []; + return [...new Set(words.map(word => word.word?.trim()).filter((word): word is string => Boolean(word)))]; + }); + + readonly genderDecoderReviewCount = computed(() => this.genderDecoderWordsToImprove().length); + + readonly hasGenderDecoderReview = computed( + () => this.genderBiasAnalysis()?.coding === 'non-inclusive-coded' || this.genderDecoderReviewCount() > 0, + ); + + readonly genderDecoderPillLabelKey = computed(() => { + if (this.genderBiasAnalysis() === undefined) { + return 'jobCreationForm.aiSidebar.genderDecoder.pill.pending'; + } + + return 'jobCreationForm.aiSidebar.genderDecoder.pill.fix'; + }); + + readonly genderDecoderDotColor = computed(() => { + if (this.genderBiasAnalysis() === undefined) { + return 'var(--color-text-disabled)'; + } + + return 'var(--color-text-secondary)'; + }); + protected readonly ComplianceIssueCategoryEnum = ComplianceIssueCategoryEnum; // ═══════════════════════════════════════════════════════════════════════════ diff --git a/src/main/webapp/i18n/de/job.json b/src/main/webapp/i18n/de/job.json index 22f979e409..694d43d18c 100644 --- a/src/main/webapp/i18n/de/job.json +++ b/src/main/webapp/i18n/de/job.json @@ -188,7 +188,34 @@ "dsgvo": "Datenschutz anpassen", "publicSector": "Wissenschaftsrecht prüfen", "filterByCategory": "Nach Kategorie filtern", - "complianceTooltipText": "Farben dienen nur der Kategorisierung, nicht der Priorisierung. Es gibt keine Unterschiede in der Wichtigkeit." + "complianceTooltipText": "Farben dienen nur der Kategorisierung, nicht der Priorisierung. Es gibt keine Unterschiede in der Wichtigkeit.", + "genderDecoder": { + "header": "Gender Decoder", + "tooltipText": "Prüft, ob die Stellenbeschreibung inklusiv, neutral oder exklusiv wirkt. Verbesserungsvorschläge für Wörter erscheinen hier.", + "states": { + "pending": "Wartet auf Gender Decoding", + "exclusive": "Exklusiv gender-decoded", + "neutral": "Neutral gender-decoded", + "inclusive": "Inklusiv gender-decoded" + }, + "relevanceText": "Inklusive Sprache hilft, mehr Bewerbende anzusprechen.", + "pill": { + "pending": "Wartet auf Analyse", + "fix": "Gender-exklusive Formulierungen korrigieren", + "review": "Wörter prüfen", + "neutral": "Neutral formuliert", + "inclusive": "Exklusiv" + }, + "balanceLabel": "Inklusive Balance", + "scale": { + "exclusive": "Exklusiv", + "neutral": "Neutral", + "inclusive": "Inklusiv" + }, + "highlightHint": "Grauer Wellenstrich markiert Wörter zum Prüfen im Editor.", + "wordsToImprove": "Wörter verbessern", + "noWordsToImprove": "Keine Verbesserungen gefunden" + } }, "positionDetailsSection": { "selectedDeadline": "Gewählte Bewerbungsfrist", diff --git a/src/main/webapp/i18n/en/job.json b/src/main/webapp/i18n/en/job.json index 8830e3e23c..1fc60321a5 100644 --- a/src/main/webapp/i18n/en/job.json +++ b/src/main/webapp/i18n/en/job.json @@ -188,7 +188,34 @@ "dsgvo": "Fix Data privacy", "publicSector": "Check Academic Law", "filterByCategory": "Filter by category", - "complianceTooltipText": "Colors indicate category, not severity. All items are equally important." + "complianceTooltipText": "Colors indicate category, not severity. All items are equally important.", + "genderDecoder": { + "header": "Gender Decoder", + "tooltipText": "Checks whether the job description reads inclusive, neutral, or exclusive. Suggested wording improvements will appear here.", + "states": { + "pending": "Awaiting gender decoding", + "exclusive": "Exclusive gender decoded", + "neutral": "Neutral gender decoded", + "inclusive": "Inclusive gender decoded" + }, + "relevanceText": "Inclusive wording helps the posting appeal to a broader applicant pool.", + "pill": { + "pending": "Awaiting analysis", + "fix": "Fix gender-exclusive wording", + "review": "Words to review", + "neutral": "Neutral wording", + "inclusive": "Exclusive" + }, + "balanceLabel": "Inclusive balance", + "scale": { + "exclusive": "Exclusive", + "neutral": "Neutral", + "inclusive": "Inclusive" + }, + "highlightHint": "Grey wavy underline marks wording to review in the editor.", + "wordsToImprove": "Words to improve", + "noWordsToImprove": "No wording improvements found" + } }, "positionDetailsSection": { "selectedDeadline": "Selected Deadline", 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..89fbe1c136 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 @@ -20,6 +20,7 @@ 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 { RecommendationType } from 'app/generated/model/recommendation-type'; +import { ComplianceIssue, ComplianceIssueCategoryEnum } from 'app/generated/model/compliance-issue'; import * as DropdownOptions from 'app/job/dropdown-options'; import { unescapeJsonString } from 'app/shared/util/util'; @@ -96,7 +97,8 @@ type ComponentPrivate = { loadSupervisingProfessors: () => Promise; setDefaultSupervisingProfessor: (preselectId?: string) => void; translateAndStoreOtherLanguage: (currentLang: 'en' | 'de', currentText: string) => Promise; - analyzeAndUpdateScore: (lang: string) => Promise; + cancelBackgroundProcessesBeforeGeneration: () => number; + analyzeAndUpdateScore: (lang: string, processVersion?: number) => Promise; }; function getPrivate(component: JobCreationFormComponent): ComponentPrivate { @@ -683,10 +685,14 @@ describe('JobCreationFormComponent', () => { it('should cancel translation when in flight', async () => { setupGen(); component.isTranslating.set(true); + component.isAnalyzing.set(true); + const resetSpy = vi.spyOn(component.autoSave, 'reset'); const cancelSpy = vi.spyOn(component as unknown as { cancelTranslation: () => void }, 'cancelTranslation'); mockAiStreamingService.generateJobApplicationDraftStream.mockRejectedValue(new Error('fail')); await component.generateJobApplicationDraft(); + expect(resetSpy).toHaveBeenCalledOnce(); expect(cancelSpy).toHaveBeenCalledOnce(); + expect(component.isAnalyzing()).toBe(false); }); it('should not cancel translation when not in flight', async () => { @@ -697,6 +703,30 @@ describe('JobCreationFormComponent', () => { await component.generateJobApplicationDraft(); expect(cancelSpy).not.toHaveBeenCalled(); }); + + it('should ignore stale analysis results after generation cancels background work', async () => { + setupGen(); + const analysisSubject = new Subject(); + const staleIssue: ComplianceIssue = { + text: 'old wording', + category: ComplianceIssueCategoryEnum.CriticalAgg, + language: 'en', + }; + Object.defineProperty(component, 'aiApi', { + value: { analyzeJobDescriptionForCompliance: vi.fn().mockReturnValue(analysisSubject.asObservable()) }, + configurable: true, + }); + mockJobApi.getJobById.mockReturnValue(of({ genderBiasScore: 99 })); + const analysisPromise = getPrivate(component).analyzeAndUpdateScore('en'); + expect(component.isAnalyzing()).toBe(true); + getPrivate(component).cancelBackgroundProcessesBeforeGeneration(); + analysisSubject.next([staleIssue]); + analysisSubject.complete(); + await analysisPromise; + expect(component.complianceIssues()).toEqual([]); + expect(component.aiScore()).toBeUndefined(); + expect(mockToastService.showErrorKey).not.toHaveBeenCalledWith('jobCreationForm.toastMessages.aiComplianceFailed'); + }); }); describe('Language switching', () => { @@ -746,7 +776,7 @@ describe('JobCreationFormComponent', () => { expect(component.isTranslating()).toBe(false); expect(component.isAnalyzing()).toBe(true); - expect(analyzeSpy).toHaveBeenCalledWith('de'); + expect(analyzeSpy).toHaveBeenCalledWith('de', 0); resolveAnalysis(); await promise; diff --git a/src/test/webapp/app/shared/components/atoms/editor/editor.component.spec.ts b/src/test/webapp/app/shared/components/atoms/editor/editor.component.spec.ts index 7e2e5d1993..ef3053e16b 100644 --- a/src/test/webapp/app/shared/components/atoms/editor/editor.component.spec.ts +++ b/src/test/webapp/app/shared/components/atoms/editor/editor.component.spec.ts @@ -14,6 +14,7 @@ import { import { BehaviorSubject } from 'rxjs'; import { GenderBiasAnalysisResponse } from 'app/generated/model/gender-bias-analysis-response'; import { ContentChange } from 'ngx-quill'; +import { ComplianceIssueCategoryEnum } from 'app/generated/model/compliance-issue'; function makeEditorEvent(html: string, overrides: Partial = {}): ContentChange { const plainText = extractTextFromHtml(html); @@ -292,6 +293,54 @@ describe('EditorComponent', () => { }); }); + describe('gender decoder editor highlights', () => { + it('should expose unique non-inclusive words as editor highlights', () => { + const fixture = createFixture(); + const comp = fixture.componentInstance; + + fixture.componentRef.setInput('showGenderDecoderButton', true); + vi.spyOn(comp, 'analysisResult').mockReturnValue({ + biasedWords: [ + { word: 'dominant', type: 'non-inclusive' }, + { word: 'collaborative', type: 'inclusive' }, + { word: 'dominant', type: 'non-inclusive' }, + ], + } as GenderBiasAnalysisResponse); + fixture.detectChanges(); + + expect(comp.genderBiasHighlights()).toEqual([{ text: 'dominant' }]); + }); + + it('should apply gender decoder highlights without clearing compliance highlights', () => { + const fixture = createFixture(); + const comp = fixture.componentInstance; + const formatText = vi.fn(); + + fixture.componentRef.setInput('showGenderDecoderButton', true); + vi.spyOn(comp, 'analysisResult').mockReturnValue({ + biasedWords: [{ word: 'dominant', type: 'non-inclusive' }], + } as GenderBiasAnalysisResponse); + vi.spyOn(comp, 'quillEditorComponent').mockReturnValue({ + quillEditor: { + getLength: () => 28, + getText: () => 'dominant researcher dominant', + formatText, + }, + } as unknown as ReturnType); + + comp.highlightTexts([{ text: 'researcher', category: ComplianceIssueCategoryEnum.CriticalAgg }]); + comp.applyPendingHighlights(); + + expect(formatText).toHaveBeenCalledWith(0, 28, 'customHighlight', false); + expect(formatText).toHaveBeenCalledWith(0, 28, 'genderBiasHighlight', false); + expect(formatText).toHaveBeenCalledWith(9, 'researcher'.length, 'customHighlight', { + category: ComplianceIssueCategoryEnum.CriticalAgg, + }); + expect(formatText).toHaveBeenCalledWith(0, 'dominant'.length, 'genderBiasHighlight', true); + expect(formatText).toHaveBeenCalledWith(20, 'dominant'.length, 'genderBiasHighlight', true); + }); + }); + describe('mapToLanguageCode', () => { it.each([ ['deu', 'de'], @@ -342,6 +391,22 @@ describe('EditorComponent', () => { expect(genderBiasService.triggerAnalysis).not.toHaveBeenCalled(); }); + + it('should not trigger analysis while gender decoder analysis is paused', async () => { + const fixture = createFixture(); + + fixture.componentRef.setInput('showGenderDecoderButton', true); + fixture.componentRef.setInput('pauseGenderDecoderAnalysis', true); + fixture.detectChanges(); + await fixture.whenStable(); + vi.mocked(genderBiasService.triggerAnalysis).mockClear(); + + const event = makeEditorEvent('

Some text

'); + (fixture.componentInstance as unknown as { textChanged: (e: unknown) => void }).textChanged(event); + await fixture.whenStable(); + + expect(genderBiasService.triggerAnalysis).not.toHaveBeenCalled(); + }); }); describe('Clipboard Text Styling', () => { diff --git a/src/test/webapp/app/shared/components/molecules/ai-assistant-card/ai-assistant-card.component.spec.ts b/src/test/webapp/app/shared/components/molecules/ai-assistant-card/ai-assistant-card.component.spec.ts index c6d0112d3d..e8d1006ff8 100644 --- a/src/test/webapp/app/shared/components/molecules/ai-assistant-card/ai-assistant-card.component.spec.ts +++ b/src/test/webapp/app/shared/components/molecules/ai-assistant-card/ai-assistant-card.component.spec.ts @@ -47,4 +47,33 @@ describe('AiAssistantCardComponent', () => { fixture.detectChanges(); expect(component.displayedScore()).toBe(84); }); + + it.each([ + ['non-inclusive-coded', 14], + ['neutral', 50], + ['empty', 50], + ['inclusive-coded', 86], + ])('should map %s gender decoder coding to the sidebar scale and pill', (coding, pointerPosition) => { + fixture.componentRef.setInput('genderBiasAnalysis', { coding }); + fixture.detectChanges(); + + expect(component.genderDecoderPointerPosition()).toBe(pointerPosition); + expect(component.genderDecoderPillLabelKey()).toBe('jobCreationForm.aiSidebar.genderDecoder.pill.fix'); + }); + + it('should expose unique non-inclusive words as improvement candidates', () => { + fixture.componentRef.setInput('genderBiasAnalysis', { + coding: 'non-inclusive-coded', + biasedWords: [ + { word: 'dominant', type: 'non-inclusive' }, + { word: 'collaborative', type: 'inclusive' }, + { word: 'dominant', type: 'non-inclusive' }, + ], + }); + fixture.detectChanges(); + + expect(component.genderDecoderWordsToImprove()).toEqual(['dominant']); + expect(component.genderDecoderReviewCount()).toBe(1); + expect(component.hasGenderDecoderReview()).toBe(true); + }); }); From 89f7d0e44aa4825d3892d498156c0ae36e314139 Mon Sep 17 00:00:00 2001 From: Melissa Date: Mon, 17 Aug 2026 16:56:37 +0200 Subject: [PATCH 2/7] feat: add gender decoder insights and filtering to AI sidebar - display gender decoder results and balance indicator in the AI sidebar - reuse status pills with counts, loading state, and active styling - highlight non-inclusive wording with wavy underlines in the editor - allow filtering between gender and compliance highlights - deduplicate and sanitize non-inclusive words in a shared utility - add English and German translations for the gender pill - remove unrelated background process and analysis pause changes - simplify tests --- .run/DocApplyApp.run.xml | 4 +- .../job-creation-form.component.html | 2 +- .../job-creation-form.component.ts | 83 ++++--------------- .../atoms/editor/editor.component.ts | 15 ++-- .../ai-assistant-card.component.html | 30 +++---- .../ai-assistant-card.component.ts | 35 ++------ .../gender-bias-analysis.utils.ts | 6 ++ src/main/webapp/i18n/de/job.json | 17 +--- src/main/webapp/i18n/en/job.json | 17 +--- .../job-creation-form.component.spec.ts | 34 +------- .../atoms/editor/editor.component.spec.ts | 62 ++------------ .../ai-assistant-card.component.spec.ts | 42 +++++----- .../gender-bias-analysis.utils.spec.ts | 22 +++++ 13 files changed, 106 insertions(+), 263 deletions(-) create mode 100644 src/main/webapp/app/shared/gender-bias-analysis/gender-bias-analysis.utils.ts create mode 100644 src/test/webapp/app/shared/gender-bias-analysis/gender-bias-analysis.utils.spec.ts diff --git a/.run/DocApplyApp.run.xml b/.run/DocApplyApp.run.xml index 39d74e5ba3..aa365b9cba 100644 --- a/.run/DocApplyApp.run.xml +++ b/.run/DocApplyApp.run.xml @@ -2,10 +2,10 @@ - \ No newline at end of file + 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 70dda347de..bdd5a3bc72 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 @@ -188,7 +188,7 @@

icon="circle-info" [shouldTranslate]="true" [showGenderDecoderButton]="true" - [pauseGenderDecoderAnalysis]="isGeneratingDraft()" + [showGenderBiasHighlights]="activeComplianceFilter() === undefined || activeComplianceFilter() === 'GENDER_BIAS'" height="20rem" (highlightHovered)="onHighlightHovered($event)" /> 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 9eea2f2b91..c00f5f2f92 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 @@ -212,9 +212,6 @@ export class JobCreationFormComponent { /** Last analyzed description text per language (used to avoid redundant compliance analysis) */ private lastAnalyzedText: Record = {}; - /** Incremented whenever background AI/save work should be ignored from now on. */ - private backgroundProcessVersion = 0; - // ═══════════════════════════════════════════════════════════════════════════ // AI GENERATION SIGNALS // ═══════════════════════════════════════════════════════════════════════════ @@ -664,22 +661,6 @@ export class JobCreationFormComponent { this.translationTargetLang.set(undefined); } - /** Stops background work before AI generation takes ownership of the editor. */ - private cancelBackgroundProcessesBeforeGeneration(): number { - this.backgroundProcessVersion++; - this.autoSave.reset(); - if (this.isTranslating()) { - this.cancelTranslation(); - } - this.isAnalyzing.set(false); - this.activePopoverIssue.set(undefined); - return this.backgroundProcessVersion; - } - - private isCurrentBackgroundProcess(version: number): boolean { - return version === this.backgroundProcessVersion; - } - /** * 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. @@ -1018,7 +999,13 @@ export class JobCreationFormComponent { } const originalContent = this.basicInfoForm.get('jobDescription')?.value; const language = this.currentDescriptionLanguage(); - const processVersion = this.cancelBackgroundProcessesBeforeGeneration(); + + // 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 this.isGeneratingDraft.set(true); @@ -1082,7 +1069,7 @@ export class JobCreationFormComponent { // is async and hasn't reached its own pre-set yet). this.syncCurrentEditorIntoLanguageSignals(); this.isAnalyzing.set(true); - void this.postGenerationSaveAndProcess(language, finalContent, processVersion); + void this.postGenerationSaveAndProcess(language, finalContent); } else { this.jobDescriptionEditor()?.forceUpdate(originalContent); this.toastService.showErrorKey('jobCreationForm.toastMessages.aiGenerationFailed'); @@ -1106,18 +1093,13 @@ export class JobCreationFormComponent { * Immediately saves the generated content and fires analysis + translation in parallel. * Called directly after AI draft generation to skip the 5s autosave delay. */ - private async postGenerationSaveAndProcess( - sourceLang: Language, - sourceText: string, - processVersion = this.backgroundProcessVersion, - ): Promise { + private async postGenerationSaveAndProcess(sourceLang: Language, sourceText: string): Promise { const currentData = this.createJobDTO(JobFormDTOStateEnum.Draft); this.autoSave.setState(SavingStates.SAVING); try { // 1) Persist the generated content to the server const saved = await this.saveDraft(currentData); - if (!this.isCurrentBackgroundProcess(processVersion)) return; // 2) Sync local state with server response this.lastSavedData.set(saved); @@ -1127,15 +1109,9 @@ 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, processVersion), - this.translateAndStoreOtherLanguage(sourceLang, sourceText, processVersion), - ]); + void Promise.all([this.analyzeAndUpdateScore(sourceLang), this.translateAndStoreOtherLanguage(sourceLang, sourceText)]); } } catch { - if (!this.isCurrentBackgroundProcess(processVersion)) { - return; - } this.autoSave.setState(SavingStates.FAILED); this.isAnalyzing.set(false); this.toastService.showErrorKey('toast.saveFailed'); @@ -1664,8 +1640,6 @@ export class JobCreationFormComponent { } private async executeAutoSave(): Promise { - const processVersion = this.backgroundProcessVersion; - // 1) Capture current form state before any async work this.syncCurrentEditorIntoLanguageSignals(); const currentLang = this.currentDescriptionLanguage(); @@ -1675,7 +1649,6 @@ export class JobCreationFormComponent { try { // 2) Create or update the job on the server const saved = await this.saveDraft(currentData); - if (!this.isCurrentBackgroundProcess(processVersion)) return true; // 3) Sync local state with server response this.lastSavedData.set(saved); @@ -1687,16 +1660,10 @@ export class JobCreationFormComponent { // analysis calls that cause score flash issues. if (this.aiToggleSignal() && this.aiSystemEnabled()) { // highlighting before translation - void Promise.all([ - this.analyzeAndUpdateScore(currentLang, processVersion), - this.translateAndStoreOtherLanguage(currentLang, description, processVersion), - ]); + void Promise.all([this.analyzeAndUpdateScore(currentLang), this.translateAndStoreOtherLanguage(currentLang, description)]); } return true; } catch { - if (!this.isCurrentBackgroundProcess(processVersion)) { - return true; - } this.toastService.showErrorKey('toast.saveFailed'); return false; } @@ -1737,14 +1704,9 @@ 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, - processVersion = this.backgroundProcessVersion, - ): Promise { + private async translateAndStoreOtherLanguage(currentLang: Language, currentText: string): Promise { const text = currentText.trim(); if (!text) return; - if (!this.isCurrentBackgroundProcess(processVersion)) 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. @@ -1792,7 +1754,6 @@ export class JobCreationFormComponent { }, abortController.signal, ); - if (!this.isCurrentBackgroundProcess(processVersion)) return; let hasTranslation = false; if (accumulatedContent) { @@ -1837,9 +1798,8 @@ export class JobCreationFormComponent { try { const currentData = this.createJobDTO(JobFormDTOStateEnum.Draft); const saved = await firstValueFrom(this.jobApi.updateJob(jobId, currentData)); - if (!this.isCurrentBackgroundProcess(processVersion)) return; this.lastSavedData.set(saved); - await this.analyzeAndUpdateScore(targetLang, processVersion); + await this.analyzeAndUpdateScore(targetLang); } catch { // Silent save failure — will be caught by next autosave this.isAnalyzing.set(false); @@ -1850,7 +1810,6 @@ export class JobCreationFormComponent { if (e instanceof DOMException && e.name === 'AbortError') { return; // Cancelled — silently ignore } - if (!this.isCurrentBackgroundProcess(processVersion)) return; this.toastService.showErrorKey('jobCreationForm.toastMessages.aiTranslationFailed'); } } @@ -1861,10 +1820,9 @@ export class JobCreationFormComponent { * * @param lang - The language to analyze ('en' or 'de') */ - private async analyzeAndUpdateScore(lang: string, processVersion = this.backgroundProcessVersion): Promise { + private async analyzeAndUpdateScore(lang: string): Promise { const jobId = this.jobId(); if (!jobId) return; - if (!this.isCurrentBackgroundProcess(processVersion)) return; // 1) Build a fresh DTO and skip if the description hasn't changed since last analysis const jobForm = this.createJobDTO(JobFormDTOStateEnum.Draft); @@ -1879,9 +1837,6 @@ export class JobCreationFormComponent { try { // 2) Send the description to the analysis endpoint (persists score on the backend) const compliance = await firstValueFrom(this.aiApi.analyzeJobDescriptionForCompliance(lang, jobForm, userLang)); - if (!this.isCurrentBackgroundProcess(processVersion)) { - return; - } 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'; @@ -1894,9 +1849,6 @@ export class JobCreationFormComponent { // (DB transaction may not have committed yet). for (let attempt = 0; attempt < 2; attempt++) { const updatedJob = await firstValueFrom(this.jobApi.getJobById(jobId)); - if (!this.isCurrentBackgroundProcess(processVersion)) { - return; - } if (updatedJob.genderBiasScore !== undefined) { this.aiScore.set(updatedJob.genderBiasScore); break; @@ -1910,14 +1862,9 @@ export class JobCreationFormComponent { this.applyHighlights(compliance, lang); } } catch { - if (!this.isCurrentBackgroundProcess(processVersion)) { - return; - } this.toastService.showErrorKey('jobCreationForm.toastMessages.aiComplianceFailed'); } finally { - if (this.isCurrentBackgroundProcess(processVersion)) { - this.isAnalyzing.set(false); - } + 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 c91ab06eba..c83fdf326a 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 @@ -6,6 +6,7 @@ import { TooltipModule } from 'primeng/tooltip'; import { ContentChange, QuillEditorComponent } from 'ngx-quill'; import { FormsModule } from '@angular/forms'; import { extractTextFromHtml } from 'app/shared/util/text.util'; +import { getUniqueNonInclusiveWords } from 'app/shared/gender-bias-analysis/gender-bias-analysis.utils'; import { GenderBiasAnalysisService } from 'app/shared/gender-bias-analysis/gender-bias-analysis'; import { GenderBiasAnalysisResponse } from 'app/generated/model/gender-bias-analysis-response'; import { toObservable, toSignal } from '@angular/core/rxjs-interop'; @@ -106,9 +107,9 @@ class GenderBiasHighlightBlot extends Inline { static baseClasses = [ '[text-decoration-line:underline]', '[text-decoration-style:wavy]', - '[text-decoration-color:var(--color-text-tertiary)]', + 'decoration-text-tertiary', '[text-decoration-thickness:1.5px]', - 'underline-offset-4', + 'underline-offset-2', '[box-decoration-break:clone]', '[-webkit-box-decoration-break:clone]', ]; @@ -152,7 +153,7 @@ export class EditorComponent extends BaseInputDirective { height = input('12.5rem'); helperText = input(undefined); // Optional helper text to display below the editor field showGenderDecoderButton = input(false); - pauseGenderDecoderAnalysis = input(false); + showGenderBiasHighlights = input(true); // When true the editor is showing externally-streamed content (e.g. an AI // translation); the empty/required error is suppressed so it does not flash // while the first chunks arrive. @@ -186,12 +187,9 @@ export class EditorComponent extends BaseInputDirective { }); readonly genderBiasHighlights = computed(() => { - if (!this.showGenderDecoderButton()) return []; + if (!this.showGenderDecoderButton() || !this.showGenderBiasHighlights()) return []; - const words = this.analysisResult()?.biasedWords?.filter(word => word.type === 'non-inclusive') ?? []; - const uniqueWords = [...new Set(words.map(word => word.word?.trim()).filter((word): word is string => Boolean(word)))]; - - return uniqueWords.map(text => ({ text })); + return getUniqueNonInclusiveWords(this.analysisResult()?.biasedWords).map(text => ({ text })); }); // Check if error message should be displayed @@ -300,7 +298,6 @@ export class EditorComponent extends BaseInputDirective { private analyzeEffect = effect(() => { if (!this.showGenderDecoderButton()) return; - if (this.pauseGenderDecoderAnalysis()) return; const html = this.htmlValue(); const plainText = extractTextFromHtml(html); diff --git a/src/main/webapp/app/shared/components/molecules/ai-assistant-card/ai-assistant-card.component.html b/src/main/webapp/app/shared/components/molecules/ai-assistant-card/ai-assistant-card.component.html index 2ee7a7dc25..e02b72e1b0 100644 --- a/src/main/webapp/app/shared/components/molecules/ai-assistant-card/ai-assistant-card.component.html +++ b/src/main/webapp/app/shared/components/molecules/ai-assistant-card/ai-assistant-card.component.html @@ -90,21 +90,15 @@ jhiTranslate="jobCreationForm.aiSidebar.genderDecoder.relevanceText" >

-
- - - - - @if (genderDecoderReviewCount() > 0) { - {{ genderDecoderReviewCount() }} - } @else if (genderBiasAnalysis() !== undefined && !hasGenderDecoderReview()) { - - } - -
+

@@ -116,8 +110,10 @@

diff --git a/src/main/webapp/app/shared/components/molecules/ai-assistant-card/ai-assistant-card.component.ts b/src/main/webapp/app/shared/components/molecules/ai-assistant-card/ai-assistant-card.component.ts index c6ea7de49b..541cffaf96 100644 --- a/src/main/webapp/app/shared/components/molecules/ai-assistant-card/ai-assistant-card.component.ts +++ b/src/main/webapp/app/shared/components/molecules/ai-assistant-card/ai-assistant-card.component.ts @@ -12,6 +12,7 @@ import { GenderBiasAnalysisResponse } from 'app/generated/model/gender-bias-anal import { StatusPillComponent } from 'app/shared/components/atoms/status-pill/status-pill.component'; import { InfoBoxComponent } from 'app/shared/components/atoms/info-box/info-box.component'; import { InfoIconComponent } from 'app/shared/components/atoms/info-icon/info-icon.component'; +import { getUniqueNonInclusiveWords } from 'app/shared/gender-bias-analysis/gender-bias-analysis.utils'; @Component({ selector: 'jhi-ai-assistant-card', @@ -137,47 +138,25 @@ export class AiAssistantCardComponent { ); /** Position of the gender decoder pointer on the sidebar scale. */ - readonly genderDecoderPointerPosition = computed(() => { + readonly genderDecoderPointerClass = computed(() => { switch (this.genderBiasAnalysis()?.coding) { case 'non-inclusive-coded': - return 14; + return 'left-[14%]'; case 'inclusive-coded': - return 86; + return 'left-[86%]'; case 'neutral': case 'empty': default: - return 50; + return 'left-1/2'; } }); - readonly genderDecoderWordsToImprove = computed(() => { - const words = this.genderBiasAnalysis()?.biasedWords?.filter(word => word.type === 'non-inclusive') ?? []; - return [...new Set(words.map(word => word.word?.trim()).filter((word): word is string => Boolean(word)))]; - }); + readonly genderDecoderWordsToImprove = computed(() => getUniqueNonInclusiveWords(this.genderBiasAnalysis()?.biasedWords)); readonly genderDecoderReviewCount = computed(() => this.genderDecoderWordsToImprove().length); - readonly hasGenderDecoderReview = computed( - () => this.genderBiasAnalysis()?.coding === 'non-inclusive-coded' || this.genderDecoderReviewCount() > 0, - ); - - readonly genderDecoderPillLabelKey = computed(() => { - if (this.genderBiasAnalysis() === undefined) { - return 'jobCreationForm.aiSidebar.genderDecoder.pill.pending'; - } - - return 'jobCreationForm.aiSidebar.genderDecoder.pill.fix'; - }); - - readonly genderDecoderDotColor = computed(() => { - if (this.genderBiasAnalysis() === undefined) { - return 'var(--color-text-disabled)'; - } - - return 'var(--color-text-secondary)'; - }); - protected readonly ComplianceIssueCategoryEnum = ComplianceIssueCategoryEnum; + protected readonly genderBiasFilter = 'GENDER_BIAS'; // ═══════════════════════════════════════════════════════════════════════════ // EFFECTS diff --git a/src/main/webapp/app/shared/gender-bias-analysis/gender-bias-analysis.utils.ts b/src/main/webapp/app/shared/gender-bias-analysis/gender-bias-analysis.utils.ts new file mode 100644 index 0000000000..767c7f64d0 --- /dev/null +++ b/src/main/webapp/app/shared/gender-bias-analysis/gender-bias-analysis.utils.ts @@ -0,0 +1,6 @@ +import { BiasedWordDTO } from 'app/generated/model/biased-word-dto'; + +export function getUniqueNonInclusiveWords(biasedWords: BiasedWordDTO[] | undefined): string[] { + const words = biasedWords?.filter(word => word.type === 'non-inclusive').map(word => word.word?.trim()) ?? []; + return words.filter((word): word is string => Boolean(word)).filter((word, index, values) => values.indexOf(word) === index); +} diff --git a/src/main/webapp/i18n/de/job.json b/src/main/webapp/i18n/de/job.json index 694d43d18c..3671299408 100644 --- a/src/main/webapp/i18n/de/job.json +++ b/src/main/webapp/i18n/de/job.json @@ -192,29 +192,16 @@ "genderDecoder": { "header": "Gender Decoder", "tooltipText": "Prüft, ob die Stellenbeschreibung inklusiv, neutral oder exklusiv wirkt. Verbesserungsvorschläge für Wörter erscheinen hier.", - "states": { - "pending": "Wartet auf Gender Decoding", - "exclusive": "Exklusiv gender-decoded", - "neutral": "Neutral gender-decoded", - "inclusive": "Inklusiv gender-decoded" - }, "relevanceText": "Inklusive Sprache hilft, mehr Bewerbende anzusprechen.", "pill": { - "pending": "Wartet auf Analyse", - "fix": "Gender-exklusive Formulierungen korrigieren", - "review": "Wörter prüfen", - "neutral": "Neutral formuliert", - "inclusive": "Exklusiv" + "fix": "Gender-Bias korrigieren" }, "balanceLabel": "Inklusive Balance", "scale": { "exclusive": "Exklusiv", "neutral": "Neutral", "inclusive": "Inklusiv" - }, - "highlightHint": "Grauer Wellenstrich markiert Wörter zum Prüfen im Editor.", - "wordsToImprove": "Wörter verbessern", - "noWordsToImprove": "Keine Verbesserungen gefunden" + } } }, "positionDetailsSection": { diff --git a/src/main/webapp/i18n/en/job.json b/src/main/webapp/i18n/en/job.json index 1fc60321a5..8818499ab7 100644 --- a/src/main/webapp/i18n/en/job.json +++ b/src/main/webapp/i18n/en/job.json @@ -192,29 +192,16 @@ "genderDecoder": { "header": "Gender Decoder", "tooltipText": "Checks whether the job description reads inclusive, neutral, or exclusive. Suggested wording improvements will appear here.", - "states": { - "pending": "Awaiting gender decoding", - "exclusive": "Exclusive gender decoded", - "neutral": "Neutral gender decoded", - "inclusive": "Inclusive gender decoded" - }, "relevanceText": "Inclusive wording helps the posting appeal to a broader applicant pool.", "pill": { - "pending": "Awaiting analysis", - "fix": "Fix gender-exclusive wording", - "review": "Words to review", - "neutral": "Neutral wording", - "inclusive": "Exclusive" + "fix": "Fix gender-bias wording" }, "balanceLabel": "Inclusive balance", "scale": { "exclusive": "Exclusive", "neutral": "Neutral", "inclusive": "Inclusive" - }, - "highlightHint": "Grey wavy underline marks wording to review in the editor.", - "wordsToImprove": "Words to improve", - "noWordsToImprove": "No wording improvements found" + } } }, "positionDetailsSection": { 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 89fbe1c136..42c6e32517 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 @@ -20,7 +20,6 @@ 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 { RecommendationType } from 'app/generated/model/recommendation-type'; -import { ComplianceIssue, ComplianceIssueCategoryEnum } from 'app/generated/model/compliance-issue'; import * as DropdownOptions from 'app/job/dropdown-options'; import { unescapeJsonString } from 'app/shared/util/util'; @@ -97,8 +96,7 @@ type ComponentPrivate = { loadSupervisingProfessors: () => Promise; setDefaultSupervisingProfessor: (preselectId?: string) => void; translateAndStoreOtherLanguage: (currentLang: 'en' | 'de', currentText: string) => Promise; - cancelBackgroundProcessesBeforeGeneration: () => number; - analyzeAndUpdateScore: (lang: string, processVersion?: number) => Promise; + analyzeAndUpdateScore: (lang: string) => Promise; }; function getPrivate(component: JobCreationFormComponent): ComponentPrivate { @@ -685,14 +683,10 @@ describe('JobCreationFormComponent', () => { it('should cancel translation when in flight', async () => { setupGen(); component.isTranslating.set(true); - component.isAnalyzing.set(true); - const resetSpy = vi.spyOn(component.autoSave, 'reset'); const cancelSpy = vi.spyOn(component as unknown as { cancelTranslation: () => void }, 'cancelTranslation'); mockAiStreamingService.generateJobApplicationDraftStream.mockRejectedValue(new Error('fail')); await component.generateJobApplicationDraft(); - expect(resetSpy).toHaveBeenCalledOnce(); expect(cancelSpy).toHaveBeenCalledOnce(); - expect(component.isAnalyzing()).toBe(false); }); it('should not cancel translation when not in flight', async () => { @@ -703,30 +697,6 @@ describe('JobCreationFormComponent', () => { await component.generateJobApplicationDraft(); expect(cancelSpy).not.toHaveBeenCalled(); }); - - it('should ignore stale analysis results after generation cancels background work', async () => { - setupGen(); - const analysisSubject = new Subject(); - const staleIssue: ComplianceIssue = { - text: 'old wording', - category: ComplianceIssueCategoryEnum.CriticalAgg, - language: 'en', - }; - Object.defineProperty(component, 'aiApi', { - value: { analyzeJobDescriptionForCompliance: vi.fn().mockReturnValue(analysisSubject.asObservable()) }, - configurable: true, - }); - mockJobApi.getJobById.mockReturnValue(of({ genderBiasScore: 99 })); - const analysisPromise = getPrivate(component).analyzeAndUpdateScore('en'); - expect(component.isAnalyzing()).toBe(true); - getPrivate(component).cancelBackgroundProcessesBeforeGeneration(); - analysisSubject.next([staleIssue]); - analysisSubject.complete(); - await analysisPromise; - expect(component.complianceIssues()).toEqual([]); - expect(component.aiScore()).toBeUndefined(); - expect(mockToastService.showErrorKey).not.toHaveBeenCalledWith('jobCreationForm.toastMessages.aiComplianceFailed'); - }); }); describe('Language switching', () => { @@ -776,7 +746,7 @@ describe('JobCreationFormComponent', () => { expect(component.isTranslating()).toBe(false); expect(component.isAnalyzing()).toBe(true); - expect(analyzeSpy).toHaveBeenCalledWith('de', 0); + expect(analyzeSpy).toHaveBeenCalledWith('de'); resolveAnalysis(); await promise; diff --git a/src/test/webapp/app/shared/components/atoms/editor/editor.component.spec.ts b/src/test/webapp/app/shared/components/atoms/editor/editor.component.spec.ts index ef3053e16b..d3821c30a1 100644 --- a/src/test/webapp/app/shared/components/atoms/editor/editor.component.spec.ts +++ b/src/test/webapp/app/shared/components/atoms/editor/editor.component.spec.ts @@ -14,7 +14,6 @@ import { import { BehaviorSubject } from 'rxjs'; import { GenderBiasAnalysisResponse } from 'app/generated/model/gender-bias-analysis-response'; import { ContentChange } from 'ngx-quill'; -import { ComplianceIssueCategoryEnum } from 'app/generated/model/compliance-issue'; function makeEditorEvent(html: string, overrides: Partial = {}): ContentChange { const plainText = extractTextFromHtml(html); @@ -294,50 +293,21 @@ describe('EditorComponent', () => { }); describe('gender decoder editor highlights', () => { - it('should expose unique non-inclusive words as editor highlights', () => { + it('should show received gender highlights and hide them when filtered out', () => { const fixture = createFixture(); const comp = fixture.componentInstance; fixture.componentRef.setInput('showGenderDecoderButton', true); - vi.spyOn(comp, 'analysisResult').mockReturnValue({ - biasedWords: [ - { word: 'dominant', type: 'non-inclusive' }, - { word: 'collaborative', type: 'inclusive' }, - { word: 'dominant', type: 'non-inclusive' }, - ], - } as GenderBiasAnalysisResponse); - fixture.detectChanges(); - - expect(comp.genderBiasHighlights()).toEqual([{ text: 'dominant' }]); - }); - - it('should apply gender decoder highlights without clearing compliance highlights', () => { - const fixture = createFixture(); - const comp = fixture.componentInstance; - const formatText = vi.fn(); - - fixture.componentRef.setInput('showGenderDecoderButton', true); - vi.spyOn(comp, 'analysisResult').mockReturnValue({ + analysisSubject.next({ biasedWords: [{ word: 'dominant', type: 'non-inclusive' }], - } as GenderBiasAnalysisResponse); - vi.spyOn(comp, 'quillEditorComponent').mockReturnValue({ - quillEditor: { - getLength: () => 28, - getText: () => 'dominant researcher dominant', - formatText, - }, - } as unknown as ReturnType); + }); + fixture.detectChanges(); - comp.highlightTexts([{ text: 'researcher', category: ComplianceIssueCategoryEnum.CriticalAgg }]); - comp.applyPendingHighlights(); + expect(comp.genderBiasHighlights()).toHaveLength(1); - expect(formatText).toHaveBeenCalledWith(0, 28, 'customHighlight', false); - expect(formatText).toHaveBeenCalledWith(0, 28, 'genderBiasHighlight', false); - expect(formatText).toHaveBeenCalledWith(9, 'researcher'.length, 'customHighlight', { - category: ComplianceIssueCategoryEnum.CriticalAgg, - }); - expect(formatText).toHaveBeenCalledWith(0, 'dominant'.length, 'genderBiasHighlight', true); - expect(formatText).toHaveBeenCalledWith(20, 'dominant'.length, 'genderBiasHighlight', true); + fixture.componentRef.setInput('showGenderBiasHighlights', false); + fixture.detectChanges(); + expect(comp.genderBiasHighlights()).toHaveLength(0); }); }); @@ -391,22 +361,6 @@ describe('EditorComponent', () => { expect(genderBiasService.triggerAnalysis).not.toHaveBeenCalled(); }); - - it('should not trigger analysis while gender decoder analysis is paused', async () => { - const fixture = createFixture(); - - fixture.componentRef.setInput('showGenderDecoderButton', true); - fixture.componentRef.setInput('pauseGenderDecoderAnalysis', true); - fixture.detectChanges(); - await fixture.whenStable(); - vi.mocked(genderBiasService.triggerAnalysis).mockClear(); - - const event = makeEditorEvent('

Some text

'); - (fixture.componentInstance as unknown as { textChanged: (e: unknown) => void }).textChanged(event); - await fixture.whenStable(); - - expect(genderBiasService.triggerAnalysis).not.toHaveBeenCalled(); - }); }); describe('Clipboard Text Styling', () => { diff --git a/src/test/webapp/app/shared/components/molecules/ai-assistant-card/ai-assistant-card.component.spec.ts b/src/test/webapp/app/shared/components/molecules/ai-assistant-card/ai-assistant-card.component.spec.ts index e8d1006ff8..7813814b40 100644 --- a/src/test/webapp/app/shared/components/molecules/ai-assistant-card/ai-assistant-card.component.spec.ts +++ b/src/test/webapp/app/shared/components/molecules/ai-assistant-card/ai-assistant-card.component.spec.ts @@ -1,5 +1,6 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { By } from '@angular/platform-browser'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { provideFontAwesomeTesting } from 'util/fontawesome.testing'; import { provideTranslateMock } from 'util/translate.mock'; import { AiAssistantCardComponent } from 'app/shared/components/molecules/ai-assistant-card/ai-assistant-card.component'; @@ -20,6 +21,10 @@ describe('AiAssistantCardComponent', () => { fixture.detectChanges(); }); + afterEach(() => { + vi.restoreAllMocks(); + }); + it.each([ ['critical', (cmp: AiAssistantCardComponent) => cmp.DANGER_THRESHOLD, 'critical'], ['warning lower bound', (cmp: AiAssistantCardComponent) => cmp.DANGER_THRESHOLD + 1, 'warning'], @@ -49,31 +54,24 @@ describe('AiAssistantCardComponent', () => { }); it.each([ - ['non-inclusive-coded', 14], - ['neutral', 50], - ['empty', 50], - ['inclusive-coded', 86], - ])('should map %s gender decoder coding to the sidebar scale and pill', (coding, pointerPosition) => { - fixture.componentRef.setInput('genderBiasAnalysis', { coding }); + [undefined, 'left-1/2'], + ['non-inclusive-coded', 'left-[14%]'], + ['neutral', 'left-1/2'], + ['empty', 'left-1/2'], + ['inclusive-coded', 'left-[86%]'], + ])('should map %s gender decoder coding to the sidebar scale', (coding, pointerClass) => { + fixture.componentRef.setInput('genderBiasAnalysis', coding === undefined ? undefined : { coding }); fixture.detectChanges(); - expect(component.genderDecoderPointerPosition()).toBe(pointerPosition); - expect(component.genderDecoderPillLabelKey()).toBe('jobCreationForm.aiSidebar.genderDecoder.pill.fix'); + const pointer = fixture.debugElement.query(By.css('[data-testid="gender-decoder-pointer"]')); + expect(pointer).not.toBeNull(); + expect(pointer?.nativeElement.classList).toContain(pointerClass); }); - it('should expose unique non-inclusive words as improvement candidates', () => { - fixture.componentRef.setInput('genderBiasAnalysis', { - coding: 'non-inclusive-coded', - biasedWords: [ - { word: 'dominant', type: 'non-inclusive' }, - { word: 'collaborative', type: 'inclusive' }, - { word: 'dominant', type: 'non-inclusive' }, - ], - }); - fixture.detectChanges(); + it('should wire the gender decoder pill to the fix label', () => { + const genderPill = fixture.debugElement.query(By.css('[data-testid="gender-decoder-pill"]')); - expect(component.genderDecoderWordsToImprove()).toEqual(['dominant']); - expect(component.genderDecoderReviewCount()).toBe(1); - expect(component.hasGenderDecoderReview()).toBe(true); + expect(genderPill).not.toBeNull(); + expect(genderPill?.componentInstance.labelKey()).toBe('jobCreationForm.aiSidebar.genderDecoder.pill.fix'); }); }); diff --git a/src/test/webapp/app/shared/gender-bias-analysis/gender-bias-analysis.utils.spec.ts b/src/test/webapp/app/shared/gender-bias-analysis/gender-bias-analysis.utils.spec.ts new file mode 100644 index 0000000000..b3c3de92ce --- /dev/null +++ b/src/test/webapp/app/shared/gender-bias-analysis/gender-bias-analysis.utils.spec.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest'; +import { getUniqueNonInclusiveWords } from 'app/shared/gender-bias-analysis/gender-bias-analysis.utils'; + +describe('getUniqueNonInclusiveWords', () => { + it('should return trimmed unique non-inclusive words', () => { + const result = getUniqueNonInclusiveWords([ + { word: ' dominant ', type: 'non-inclusive' }, + { word: 'collaborative', type: 'inclusive' }, + { word: 'dominant', type: 'non-inclusive' }, + { word: ' ', type: 'non-inclusive' }, + ]); + + expect(result).toEqual(['dominant']); + }); + + it.each([ + ['undefined', undefined], + ['empty array', []], + ])('should return an empty array for %s input', (_label, words) => { + expect(getUniqueNonInclusiveWords(words)).toEqual([]); + }); +}); From 3e7d3a0c98811ded5efb25827f3a7f3c001ed87b Mon Sep 17 00:00:00 2001 From: Melissa Date: Mon, 17 Aug 2026 22:12:34 +0200 Subject: [PATCH 3/7] `fix(ui): tighten gender bias filter typing and pill count coverage` --- .../job-creation-form.component.html | 2 +- .../job-creation-form.component.ts | 7 +++++-- .../ai-assistant-card.component.ts | 18 ++++++++++-------- .../gender-bias-analysis.utils.ts | 10 ++++++++++ .../ai-assistant-card.component.spec.ts | 12 +++++++++++- 5 files changed, 37 insertions(+), 12 deletions(-) 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 bdd5a3bc72..c0e378f847 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 @@ -188,7 +188,7 @@

icon="circle-info" [shouldTranslate]="true" [showGenderDecoderButton]="true" - [showGenderBiasHighlights]="activeComplianceFilter() === undefined || activeComplianceFilter() === 'GENDER_BIAS'" + [showGenderBiasHighlights]="activeComplianceFilter() === undefined || activeComplianceFilter() === genderBiasFilter" height="20rem" (highlightHovered)="onHighlightHovered($event)" /> 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..492203cfe3 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 @@ -61,6 +61,7 @@ import { UserShortDTORolesEnum } from 'app/generated/model/user-short-dto'; import { RecommendationType } from 'app/generated/model/recommendation-type'; import { ComplianceIssue, ComplianceIssueCategoryEnum } from 'app/generated/model/compliance-issue'; import { CompliancePopoverComponent } from 'app/shared/components/molecules/ai-compliance-popover/ai-compliance-popover.component'; +import { FilterCategory, GENDER_BIAS_FILTER_CATEGORY } from 'app/shared/gender-bias-analysis/gender-bias-analysis.utils'; import { JobDetailComponent } from '../job-detail/job-detail.component'; import * as DropdownOptions from '.././dropdown-options'; @@ -326,7 +327,9 @@ export class JobCreationFormComponent { readonly popoverY = signal(0); /** When set, only issues of this category are highlighted in the editor. (undefined = all categories shown) */ - readonly activeComplianceFilter = signal(undefined); + readonly activeComplianceFilter = signal(undefined); + + protected readonly genderBiasFilter = GENDER_BIAS_FILTER_CATEGORY; /** Returns the explanation of a compliance issue whose text appears in the job title, if any. */ readonly titleComplianceError = computed(() => { @@ -961,7 +964,7 @@ export class JobCreationFormComponent { * Handles category filter changes from the AI assistant sidebar. * Updates filter signal to show only the selected category */ - onComplianceFilterChange(category: string | undefined): void { + onComplianceFilterChange(category: FilterCategory | undefined): void { this.activeComplianceFilter.set(category); } diff --git a/src/main/webapp/app/shared/components/molecules/ai-assistant-card/ai-assistant-card.component.ts b/src/main/webapp/app/shared/components/molecules/ai-assistant-card/ai-assistant-card.component.ts index 541cffaf96..cca054e229 100644 --- a/src/main/webapp/app/shared/components/molecules/ai-assistant-card/ai-assistant-card.component.ts +++ b/src/main/webapp/app/shared/components/molecules/ai-assistant-card/ai-assistant-card.component.ts @@ -12,7 +12,11 @@ import { GenderBiasAnalysisResponse } from 'app/generated/model/gender-bias-anal import { StatusPillComponent } from 'app/shared/components/atoms/status-pill/status-pill.component'; import { InfoBoxComponent } from 'app/shared/components/atoms/info-box/info-box.component'; import { InfoIconComponent } from 'app/shared/components/atoms/info-icon/info-icon.component'; -import { getUniqueNonInclusiveWords } from 'app/shared/gender-bias-analysis/gender-bias-analysis.utils'; +import { + FilterCategory, + GENDER_BIAS_FILTER_CATEGORY, + getUniqueNonInclusiveWords, +} from 'app/shared/gender-bias-analysis/gender-bias-analysis.utils'; @Component({ selector: 'jhi-ai-assistant-card', @@ -58,13 +62,13 @@ export class AiAssistantCardComponent { // ═══════════════════════════════════════════════════════════════════════════ generate = output(); - filterComplianceCat = output(); + filterComplianceCat = output(); // ═══════════════════════════════════════════════════════════════════════════ // SIGNALS // ═══════════════════════════════════════════════════════════════════════════ - readonly activeFilter = signal(undefined); + readonly activeFilter = signal(undefined); readonly displayedScore = signal(undefined); readonly scoreDialogVisible = signal(false); @@ -151,12 +155,10 @@ export class AiAssistantCardComponent { } }); - readonly genderDecoderWordsToImprove = computed(() => getUniqueNonInclusiveWords(this.genderBiasAnalysis()?.biasedWords)); - - readonly genderDecoderReviewCount = computed(() => this.genderDecoderWordsToImprove().length); + readonly genderDecoderReviewCount = computed(() => getUniqueNonInclusiveWords(this.genderBiasAnalysis()?.biasedWords).length); protected readonly ComplianceIssueCategoryEnum = ComplianceIssueCategoryEnum; - protected readonly genderBiasFilter = 'GENDER_BIAS'; + protected readonly genderBiasFilter = GENDER_BIAS_FILTER_CATEGORY; // ═══════════════════════════════════════════════════════════════════════════ // EFFECTS @@ -177,7 +179,7 @@ export class AiAssistantCardComponent { // ═══════════════════════════════════════════════════════════════════════════ /** Selects the given category as the active filter, or clears it if already selected. */ - selectCategoryFilter(category: string): void { + selectCategoryFilter(category: FilterCategory): void { const next = this.activeFilter() === category ? undefined : category; this.activeFilter.set(next); this.filterComplianceCat.emit(next); diff --git a/src/main/webapp/app/shared/gender-bias-analysis/gender-bias-analysis.utils.ts b/src/main/webapp/app/shared/gender-bias-analysis/gender-bias-analysis.utils.ts index 767c7f64d0..8f02aa566b 100644 --- a/src/main/webapp/app/shared/gender-bias-analysis/gender-bias-analysis.utils.ts +++ b/src/main/webapp/app/shared/gender-bias-analysis/gender-bias-analysis.utils.ts @@ -1,5 +1,15 @@ import { BiasedWordDTO } from 'app/generated/model/biased-word-dto'; +import { ComplianceIssueCategoryEnum } from 'app/generated/model/compliance-issue'; +export const GENDER_BIAS_FILTER_CATEGORY = 'GENDER_BIAS' as const; + +export type FilterCategory = ComplianceIssueCategoryEnum | typeof GENDER_BIAS_FILTER_CATEGORY; + +/** + * Extracts the unique, non-empty words marked as non-inclusive. + * @param biasedWords Gender-bias findings returned by the analysis. + * @returns The unique non-inclusive words in their original order. + */ export function getUniqueNonInclusiveWords(biasedWords: BiasedWordDTO[] | undefined): string[] { const words = biasedWords?.filter(word => word.type === 'non-inclusive').map(word => word.word?.trim()) ?? []; return words.filter((word): word is string => Boolean(word)).filter((word, index, values) => values.indexOf(word) === index); diff --git a/src/test/webapp/app/shared/components/molecules/ai-assistant-card/ai-assistant-card.component.spec.ts b/src/test/webapp/app/shared/components/molecules/ai-assistant-card/ai-assistant-card.component.spec.ts index 7813814b40..c04fab71fd 100644 --- a/src/test/webapp/app/shared/components/molecules/ai-assistant-card/ai-assistant-card.component.spec.ts +++ b/src/test/webapp/app/shared/components/molecules/ai-assistant-card/ai-assistant-card.component.spec.ts @@ -68,10 +68,20 @@ describe('AiAssistantCardComponent', () => { expect(pointer?.nativeElement.classList).toContain(pointerClass); }); - it('should wire the gender decoder pill to the fix label', () => { + it('should wire the gender decoder pill to the fix label and review count', () => { + fixture.componentRef.setInput('genderBiasAnalysis', { + biasedWords: [ + { type: 'non-inclusive', word: 'driven' }, + { type: 'non-inclusive', word: 'dominant' }, + { type: 'inclusive', word: 'collaborative' }, + ], + }); + fixture.detectChanges(); + const genderPill = fixture.debugElement.query(By.css('[data-testid="gender-decoder-pill"]')); expect(genderPill).not.toBeNull(); expect(genderPill?.componentInstance.labelKey()).toBe('jobCreationForm.aiSidebar.genderDecoder.pill.fix'); + expect(genderPill?.componentInstance.count()).toBe(2); }); }); From b55e5c004a7e44f53c2275a84c748d91eb60e9bf Mon Sep 17 00:00:00 2001 From: Melissa Date: Wed, 19 Aug 2026 14:44:30 +0200 Subject: [PATCH 4/7] fix(ui): correct gender bias highlights and loading feedback - strip compliance and gender highlight markup from editor values - track gender analysis loading independently per editor field - bind the gender pill to its dedicated loading state - center the gender balance indicator without fixed offsets - cover editor event handling and pill loading states --- .../job-creation-form.component.html | 1 + .../job-creation-form.component.ts | 2 ++ .../atoms/editor/editor.component.ts | 9 +++--- .../ai-assistant-card.component.html | 10 +++--- .../ai-assistant-card.component.ts | 1 + .../gender-bias-analysis.ts | 32 +++++++++++++++++-- .../atoms/editor/editor.component.spec.ts | 17 ++++++---- .../ai-assistant-card.component.spec.ts | 11 +++++++ .../util/gender-bias-analysis.service.mock.ts | 3 +- 9 files changed, 66 insertions(+), 20 deletions(-) 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 c0e378f847..14d7f46f66 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 @@ -220,6 +220,7 @@

[currentLang]="currentDescriptionLanguage()" [complianceIssues]="complianceIssues()" [genderBiasAnalysis]="jobDescriptionEditor.analysisResult()" + [isGenderAnalyzing]="genderBiasService.isAnalyzing(jobDescriptionEditor.fieldId())" (generate)="generateJobApplicationDraft()" (filterComplianceCat)="onComplianceFilterChange($event)" /> 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 492203cfe3..0034d44611 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 @@ -62,6 +62,7 @@ import { RecommendationType } from 'app/generated/model/recommendation-type'; import { ComplianceIssue, ComplianceIssueCategoryEnum } from 'app/generated/model/compliance-issue'; import { CompliancePopoverComponent } from 'app/shared/components/molecules/ai-compliance-popover/ai-compliance-popover.component'; import { FilterCategory, GENDER_BIAS_FILTER_CATEGORY } from 'app/shared/gender-bias-analysis/gender-bias-analysis.utils'; +import { GenderBiasAnalysisService } from 'app/shared/gender-bias-analysis/gender-bias-analysis'; import { JobDetailComponent } from '../job-detail/job-detail.component'; import * as DropdownOptions from '.././dropdown-options'; @@ -283,6 +284,7 @@ export class JobCreationFormComponent { private aiStreamingService = inject(AiStreamingService); private aiFeatureStatusService = inject(AiFeatureStatusService); private researchGroupApi = inject(ResearchGroupResourceApi); + private genderBiasService = inject(GenderBiasAnalysisService); // ═══════════════════════════════════════════════════════════════════════════ // AI SIGNALS 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 c83fdf326a..905953f36a 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 @@ -509,18 +509,19 @@ export class EditorComponent extends BaseInputDirective { } /** - * Removes compliance-highlight span wrappers from serialized editor HTML while + * Removes highlight span wrappers from serialized editor HTML while * keeping their inner content. Highlights are a visual-only overlay, so their * markup must never reach the form control or model value. * * @param html - The raw editor HTML, possibly containing highlight spans - * @returns The HTML with all compliance-highlight wrappers unwrapped + * @returns The HTML with all highlight wrappers unwrapped */ private stripHighlightMarkup(html: string): string { - if (!html.includes(HighlightBlot.className)) return html; + const highlightClasses = [HighlightBlot.className, GenderBiasHighlightBlot.className]; + if (!highlightClasses.some(className => html.includes(className))) return html; const container = document.createElement('div'); container.innerHTML = html; - container.querySelectorAll(`span.${HighlightBlot.className}`).forEach(span => { + container.querySelectorAll(highlightClasses.map(className => `span.${className}`).join(', ')).forEach(span => { const parent = span.parentNode; if (!parent) return; // Unwrap the highlight span: move each child out in place, then drop the span. diff --git a/src/main/webapp/app/shared/components/molecules/ai-assistant-card/ai-assistant-card.component.html b/src/main/webapp/app/shared/components/molecules/ai-assistant-card/ai-assistant-card.component.html index e02b72e1b0..e51c00ec46 100644 --- a/src/main/webapp/app/shared/components/molecules/ai-assistant-card/ai-assistant-card.component.html +++ b/src/main/webapp/app/shared/components/molecules/ai-assistant-card/ai-assistant-card.component.html @@ -96,28 +96,28 @@ dotColor="bg-text-secondary" [count]="genderDecoderReviewCount()" [isActive]="activeFilter() === genderBiasFilter" - [loading]="isAnalyzing() && genderBiasAnalysis() === undefined" + [loading]="isGenderAnalyzing()" (selected)="selectCategoryFilter(genderBiasFilter)" />

-
-
+
+
-
+
diff --git a/src/main/webapp/app/shared/components/molecules/ai-assistant-card/ai-assistant-card.component.ts b/src/main/webapp/app/shared/components/molecules/ai-assistant-card/ai-assistant-card.component.ts index cca054e229..3fac7ec852 100644 --- a/src/main/webapp/app/shared/components/molecules/ai-assistant-card/ai-assistant-card.component.ts +++ b/src/main/webapp/app/shared/components/molecules/ai-assistant-card/ai-assistant-card.component.ts @@ -48,6 +48,7 @@ export class AiAssistantCardComponent { complianceIssues = input([]); currentLang = input('en'); genderBiasAnalysis = input(undefined); + isGenderAnalyzing = input(false); // ═══════════════════════════════════════════════════════════════════════════ // CONSTANTS diff --git a/src/main/webapp/app/shared/gender-bias-analysis/gender-bias-analysis.ts b/src/main/webapp/app/shared/gender-bias-analysis/gender-bias-analysis.ts index 2f31ea63fd..a10c432345 100644 --- a/src/main/webapp/app/shared/gender-bias-analysis/gender-bias-analysis.ts +++ b/src/main/webapp/app/shared/gender-bias-analysis/gender-bias-analysis.ts @@ -1,5 +1,5 @@ -import { Injectable, inject } from '@angular/core'; -import { Observable, Subject, catchError, debounceTime, merge, of, shareReplay, switchMap } from 'rxjs'; +import { Injectable, inject, signal } from '@angular/core'; +import { Observable, Subject, catchError, debounceTime, finalize, merge, of, shareReplay, switchMap } from 'rxjs'; import { GenderBiasAnalysisRequest } from 'app/generated/model/gender-bias-analysis-request'; import { GenderBiasAnalysisResourceApi } from 'app/generated/api/gender-bias-analysis-resource-api'; import { GenderBiasAnalysisResponse } from 'app/generated/model/gender-bias-analysis-response'; @@ -11,6 +11,7 @@ export class GenderBiasAnalysisService { private readonly analyses = new Map>(); private readonly lastLanguages = new Map(); private readonly firstLoads = new Set(); + private readonly analyzingFields = signal>(new Set()); private readonly genderBiasApi = inject(GenderBiasAnalysisResourceApi); @@ -27,7 +28,11 @@ export class GenderBiasAnalysisService { if (!text || text.trim() === '') { return of(undefined); } - return this.analyzeHtmlContent({ text, language }).pipe(catchError(() => of(undefined))); + this.setAnalyzing(fieldId, true); + return this.analyzeHtmlContent({ text, language }).pipe( + catchError(() => of(undefined)), + finalize(() => this.setAnalyzing(fieldId, false)), + ); }), shareReplay(1), ); @@ -38,6 +43,15 @@ export class GenderBiasAnalysisService { return this.analyses.get(fieldId) ?? of(undefined); } + /** + * Whether a gender-bias request for the given field is currently in flight. + * + * @param fieldId the analyzed form field + */ + isAnalyzing(fieldId: string): boolean { + return this.analyzingFields().has(fieldId); + } + analyzeHtmlContent(request: GenderBiasAnalysisRequest): Observable { return this.genderBiasApi.analyzeHtmlContent(request); } @@ -67,4 +81,16 @@ export class GenderBiasAnalysisService { this.lastLanguages.set(fieldId, language); this.firstLoads.add(fieldId); } + + private setAnalyzing(fieldId: string, analyzing: boolean): void { + this.analyzingFields.update(fields => { + const next = new Set(fields); + if (analyzing) { + next.add(fieldId); + } else { + next.delete(fieldId); + } + return next; + }); + } } diff --git a/src/test/webapp/app/shared/components/atoms/editor/editor.component.spec.ts b/src/test/webapp/app/shared/components/atoms/editor/editor.component.spec.ts index d3821c30a1..ac12fb036d 100644 --- a/src/test/webapp/app/shared/components/atoms/editor/editor.component.spec.ts +++ b/src/test/webapp/app/shared/components/atoms/editor/editor.component.spec.ts @@ -143,15 +143,18 @@ describe('EditorComponent', () => { it('should strip compliance-highlight spans before writing to the form control', () => { const fixture = createFixture(); - const comp = fixture.componentInstance; - const ctrl = new FormControl(''); - vi.spyOn(comp, 'formControl').mockReturnValue(ctrl); - vi.spyOn(comp as unknown as { hasFormControl: () => boolean }, 'hasFormControl').mockReturnValue(true); + const control = new FormControl('

a young team

'); + fixture.componentRef.setInput('control', control); + fixture.detectChanges(); + const highlighted = + '

a young, ' + + 'dominant candidate

'; + const editor = fixture.debugElement.query(By.css('quill-editor')); - const highlighted = '

Hello young world

'; - (comp as unknown as { textChanged: (e: unknown) => void }).textChanged(makeEditorEvent(highlighted)); + expect(editor).not.toBeNull(); + editor.triggerEventHandler('onContentChanged', makeEditorEvent(highlighted)); - expect(ctrl.value).toBe('

Hello young world

'); + expect(control.value).toBe('

a young, dominant candidate

'); }); it('should keep inner formatting when stripping a compliance-highlight span', () => { diff --git a/src/test/webapp/app/shared/components/molecules/ai-assistant-card/ai-assistant-card.component.spec.ts b/src/test/webapp/app/shared/components/molecules/ai-assistant-card/ai-assistant-card.component.spec.ts index c04fab71fd..b64e9ecab7 100644 --- a/src/test/webapp/app/shared/components/molecules/ai-assistant-card/ai-assistant-card.component.spec.ts +++ b/src/test/webapp/app/shared/components/molecules/ai-assistant-card/ai-assistant-card.component.spec.ts @@ -84,4 +84,15 @@ describe('AiAssistantCardComponent', () => { expect(genderPill?.componentInstance.labelKey()).toBe('jobCreationForm.aiSidebar.genderDecoder.pill.fix'); expect(genderPill?.componentInstance.count()).toBe(2); }); + + it.each([true, false])('should show the gender decoder spinner while an analysis is in flight: %s', analyzing => { + fixture.componentRef.setInput('genderBiasAnalysis', { coding: 'neutral', biasedWords: [] }); + fixture.componentRef.setInput('isGenderAnalyzing', analyzing); + fixture.detectChanges(); + + const genderPill = fixture.debugElement.query(By.css('[data-testid="gender-decoder-pill"]')); + + expect(genderPill).not.toBeNull(); + expect(genderPill.componentInstance.loading()).toBe(analyzing); + }); }); diff --git a/src/test/webapp/util/gender-bias-analysis.service.mock.ts b/src/test/webapp/util/gender-bias-analysis.service.mock.ts index 993ad90300..6742dfa2b6 100644 --- a/src/test/webapp/util/gender-bias-analysis.service.mock.ts +++ b/src/test/webapp/util/gender-bias-analysis.service.mock.ts @@ -4,13 +4,14 @@ import { BehaviorSubject, of } from 'rxjs'; import { GenderBiasAnalysisResponse } from 'app/generated/model/gender-bias-analysis-response'; import { GenderBiasAnalysisService } from 'app/shared/gender-bias-analysis/gender-bias-analysis'; -export type GenderBiasAnalysisServiceMock = Pick; +export type GenderBiasAnalysisServiceMock = Pick; export function createGenderBiasAnalysisServiceMock(): GenderBiasAnalysisServiceMock { const analysisSubject = new BehaviorSubject(undefined); return { triggerAnalysis: vi.fn(), getAnalysisForField: vi.fn().mockReturnValue(analysisSubject.asObservable()), + isAnalyzing: vi.fn().mockReturnValue(false), }; } From 90312938330a93ecf93ac72f0820c0f5cde3eda7 Mon Sep 17 00:00:00 2001 From: Melissa Date: Wed, 19 Aug 2026 14:45:36 +0200 Subject: [PATCH 5/7] rename test --- .../shared/components/atoms/editor/editor.component.spec.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/test/webapp/app/shared/components/atoms/editor/editor.component.spec.ts b/src/test/webapp/app/shared/components/atoms/editor/editor.component.spec.ts index ac12fb036d..2fc9889430 100644 --- a/src/test/webapp/app/shared/components/atoms/editor/editor.component.spec.ts +++ b/src/test/webapp/app/shared/components/atoms/editor/editor.component.spec.ts @@ -1,4 +1,5 @@ import { TestBed } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { EditorComponent } from 'app/shared/components/atoms/editor/editor.component'; import { provideFontAwesomeTesting } from 'util/fontawesome.testing'; @@ -141,7 +142,7 @@ describe('EditorComponent', () => { expect(ctrl.dirty).toBe(true); }); - it('should strip compliance-highlight spans before writing to the form control', () => { + it('should keep highlight markup out of the form control when the editor content changes', () => { const fixture = createFixture(); const control = new FormControl('

a young team

'); fixture.componentRef.setInput('control', control); From 23324c86d7b7f2cfd3d77bddc03ddc68f35f88f4 Mon Sep 17 00:00:00 2001 From: Melissa Date: Wed, 19 Aug 2026 15:12:35 +0200 Subject: [PATCH 6/7] fix lighthouse scan --- .../app/job/job-creation-form/job-creation-form.component.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 0034d44611..92f68e6221 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 @@ -284,7 +284,7 @@ export class JobCreationFormComponent { private aiStreamingService = inject(AiStreamingService); private aiFeatureStatusService = inject(AiFeatureStatusService); private researchGroupApi = inject(ResearchGroupResourceApi); - private genderBiasService = inject(GenderBiasAnalysisService); + protected readonly genderBiasService = inject(GenderBiasAnalysisService); // ═══════════════════════════════════════════════════════════════════════════ // AI SIGNALS From 3ce670ad9e3bdc979e3ecfd4207c9d2469137184 Mon Sep 17 00:00:00 2001 From: Melissa Date: Sat, 22 Aug 2026 13:31:34 +0200 Subject: [PATCH 7/7] fix: prevent partial-word gender highlights in the editor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - add Unicode-aware word-boundary detection to the gender-bias analysis utility - update EditorComponent to skip gender findings inside longer words - test that Führung is not highlighted inside Personalführung - test that lead is not highlighted inside misleading --- .../atoms/editor/editor.component.ts | 9 ++++--- .../gender-bias-analysis.utils.ts | 12 +++++++++ .../atoms/editor/editor.component.spec.ts | 25 +++++++++++++++++++ 3 files changed, 43 insertions(+), 3 deletions(-) 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 b9ea8284b3..9ff98efbe4 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 @@ -6,7 +6,7 @@ import { TooltipModule } from 'primeng/tooltip'; import { ContentChange, QuillEditorComponent } from 'ngx-quill'; import { FormsModule } from '@angular/forms'; import { extractTextFromHtml } from 'app/shared/util/text.util'; -import { getUniqueNonInclusiveWords } from 'app/shared/gender-bias-analysis/gender-bias-analysis.utils'; +import { getUniqueNonInclusiveWords, isWordChar } from 'app/shared/gender-bias-analysis/gender-bias-analysis.utils'; import { GenderBiasAnalysisService } from 'app/shared/gender-bias-analysis/gender-bias-analysis'; import { GenderBiasAnalysisResponse } from 'app/generated/model/gender-bias-analysis-response'; import { toObservable, toSignal } from '@angular/core/rxjs-interop'; @@ -466,13 +466,16 @@ export class EditorComponent extends BaseInputDirective { for (const { text } of genderBiasHighlights) { const searchText = text.toLowerCase(); + if (!searchText) continue; let startIndex = 0; while (startIndex < fullText.length) { const index = fullText.indexOf(searchText, startIndex); if (index === -1) break; - editor.formatText(index, text.length, 'genderBiasHighlight', true); - startIndex = index + text.length; + if (!isWordChar(fullText[index - 1]) && !isWordChar(fullText[index + searchText.length])) { + editor.formatText(index, searchText.length, 'genderBiasHighlight', true); + } + startIndex = index + searchText.length; } } } diff --git a/src/main/webapp/app/shared/gender-bias-analysis/gender-bias-analysis.utils.ts b/src/main/webapp/app/shared/gender-bias-analysis/gender-bias-analysis.utils.ts index 8f02aa566b..820d64e7bc 100644 --- a/src/main/webapp/app/shared/gender-bias-analysis/gender-bias-analysis.utils.ts +++ b/src/main/webapp/app/shared/gender-bias-analysis/gender-bias-analysis.utils.ts @@ -14,3 +14,15 @@ export function getUniqueNonInclusiveWords(biasedWords: BiasedWordDTO[] | undefi const words = biasedWords?.filter(word => word.type === 'non-inclusive').map(word => word.word?.trim()) ?? []; return words.filter((word): word is string => Boolean(word)).filter((word, index, values) => values.indexOf(word) === index); } + +/** + * Whether the character is part of a word for highlight-boundary purposes. + * Uses \p{L} rather than \w so umlauts and ß count; the hyphen is excluded to + * mirror deHyphenNonCodedWords on the server. + * + * @param char the character to test, or undefined at the text boundary + * @returns true if the character continues a word + */ +export function isWordChar(char: string | undefined): boolean { + return char !== undefined && /[\p{L}\p{N}]/u.test(char); +} diff --git a/src/test/webapp/app/shared/components/atoms/editor/editor.component.spec.ts b/src/test/webapp/app/shared/components/atoms/editor/editor.component.spec.ts index 2fc9889430..b8170a7f86 100644 --- a/src/test/webapp/app/shared/components/atoms/editor/editor.component.spec.ts +++ b/src/test/webapp/app/shared/components/atoms/editor/editor.component.spec.ts @@ -313,6 +313,31 @@ describe('EditorComponent', () => { fixture.detectChanges(); expect(comp.genderBiasHighlights()).toHaveLength(0); }); + + it.each([ + ['führung', 'Führung Personalführung\n'], + ['lead', 'lead misleading\n'], + ])('should highlight %s only as a complete word', (word, text) => { + const fixture = createFixture(); + const comp = fixture.componentInstance; + const formatText = vi.fn(); + Object.defineProperty(comp.quillEditorComponent()!, 'quillEditor', { + configurable: true, + value: { formatText, getLength: () => text.length, getText: () => text }, + }); + + fixture.componentRef.setInput('showGenderDecoderButton', true); + analysisSubject.next({ biasedWords: [{ word, type: 'non-inclusive' }] }); + fixture.detectChanges(); + formatText.mockClear(); + + comp.applyPendingHighlights(); + + const appliedGenderHighlights = formatText.mock.calls.filter( + ([, , format, value]) => format === 'genderBiasHighlight' && value === true, + ); + expect(appliedGenderHighlights).toEqual([[0, word.length, 'genderBiasHighlight', true]]); + }); }); describe('mapToLanguageCode', () => {