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..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,6 +188,7 @@

icon="circle-info" [shouldTranslate]="true" [showGenderDecoderButton]="true" + [showGenderBiasHighlights]="activeComplianceFilter() === undefined || activeComplianceFilter() === genderBiasFilter" 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..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/atoms/editor/editor.component.ts b/src/main/webapp/app/shared/components/atoms/editor/editor.component.ts index bc0ed0ef40..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'; @@ -94,6 +95,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]', + 'decoration-text-tertiary', + '[text-decoration-thickness:1.5px]', + 'underline-offset-2', + '[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 +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); + 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. @@ -127,7 +162,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 +186,12 @@ export class EditorComponent extends BaseInputDirective { return this.showGenderDecoderButton() && this.analysisResult() !== undefined; }); + readonly genderBiasHighlights = computed(() => { + if (!this.showGenderDecoderButton() || !this.showGenderBiasHighlights()) return []; + + return getUniqueNonInclusiveWords(this.analysisResult()?.biasedWords).map(text => ({ text })); + }); + // Check if error message should be displayed isOverCharLimit = computed(() => { const limit = this.characterLimit(); @@ -273,11 +314,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 +425,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 +435,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 +462,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..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 @@ -75,6 +75,59 @@
+ +
+

+ +
+ +
+

+ + + +
+

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

('custom-sparkle'); complianceIssues = input([]); currentLang = input('en'); + genderBiasAnalysis = input(undefined); // ═══════════════════════════════════════════════════════════════════════════ // CONSTANTS @@ -55,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); @@ -134,7 +141,24 @@ export class AiAssistantCardComponent { () => this.issueCountForLang().filter(i => i.category === ComplianceIssueCategoryEnum.PublicSector).length, ); + /** Position of the gender decoder pointer on the sidebar scale. */ + readonly genderDecoderPointerClass = computed(() => { + switch (this.genderBiasAnalysis()?.coding) { + case 'non-inclusive-coded': + return 'left-[14%]'; + case 'inclusive-coded': + return 'left-[86%]'; + case 'neutral': + case 'empty': + default: + return 'left-1/2'; + } + }); + + readonly genderDecoderReviewCount = computed(() => getUniqueNonInclusiveWords(this.genderBiasAnalysis()?.biasedWords).length); + protected readonly ComplianceIssueCategoryEnum = ComplianceIssueCategoryEnum; + protected readonly genderBiasFilter = GENDER_BIAS_FILTER_CATEGORY; // ═══════════════════════════════════════════════════════════════════════════ // EFFECTS @@ -155,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 new file mode 100644 index 0000000000..8f02aa566b --- /dev/null +++ b/src/main/webapp/app/shared/gender-bias-analysis/gender-bias-analysis.utils.ts @@ -0,0 +1,16 @@ +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/main/webapp/i18n/de/job.json b/src/main/webapp/i18n/de/job.json index 22f979e409..3671299408 100644 --- a/src/main/webapp/i18n/de/job.json +++ b/src/main/webapp/i18n/de/job.json @@ -188,7 +188,21 @@ "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.", + "relevanceText": "Inklusive Sprache hilft, mehr Bewerbende anzusprechen.", + "pill": { + "fix": "Gender-Bias korrigieren" + }, + "balanceLabel": "Inklusive Balance", + "scale": { + "exclusive": "Exklusiv", + "neutral": "Neutral", + "inclusive": "Inklusiv" + } + } }, "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..8818499ab7 100644 --- a/src/main/webapp/i18n/en/job.json +++ b/src/main/webapp/i18n/en/job.json @@ -188,7 +188,21 @@ "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.", + "relevanceText": "Inclusive wording helps the posting appeal to a broader applicant pool.", + "pill": { + "fix": "Fix gender-bias wording" + }, + "balanceLabel": "Inclusive balance", + "scale": { + "exclusive": "Exclusive", + "neutral": "Neutral", + "inclusive": "Inclusive" + } + } }, "positionDetailsSection": { "selectedDeadline": "Selected Deadline", 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..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 @@ -292,6 +292,25 @@ describe('EditorComponent', () => { }); }); + describe('gender decoder 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); + analysisSubject.next({ + biasedWords: [{ word: 'dominant', type: 'non-inclusive' }], + }); + fixture.detectChanges(); + + expect(comp.genderBiasHighlights()).toHaveLength(1); + + fixture.componentRef.setInput('showGenderBiasHighlights', false); + fixture.detectChanges(); + expect(comp.genderBiasHighlights()).toHaveLength(0); + }); + }); + describe('mapToLanguageCode', () => { it.each([ ['deu', 'de'], 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..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 @@ -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'], @@ -47,4 +52,36 @@ describe('AiAssistantCardComponent', () => { fixture.detectChanges(); expect(component.displayedScore()).toBe(84); }); + + it.each([ + [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(); + + const pointer = fixture.debugElement.query(By.css('[data-testid="gender-decoder-pointer"]')); + expect(pointer).not.toBeNull(); + expect(pointer?.nativeElement.classList).toContain(pointerClass); + }); + + 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); + }); }); 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([]); + }); +});