Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,7 @@ <h1 jhiTranslate="{{ pageTitle() }}"></h1>
icon="circle-info"
[shouldTranslate]="true"
[showGenderDecoderButton]="true"
[showGenderBiasHighlights]="activeComplianceFilter() === undefined || activeComplianceFilter() === genderBiasFilter"
height="20rem"
(highlightHovered)="onHighlightHovered($event)"
/>
Expand Down Expand Up @@ -218,6 +219,7 @@ <h1 jhiTranslate="{{ pageTitle() }}"></h1>
[isRewriteMode]="rewriteButtonSignal()"
[currentLang]="currentDescriptionLanguage()"
[complianceIssues]="complianceIssues()"
[genderBiasAnalysis]="jobDescriptionEditor.analysisResult()"
(generate)="generateJobApplicationDraft()"
(filterComplianceCat)="onComplianceFilterChange($event)"
/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -326,7 +327,9 @@ export class JobCreationFormComponent {
readonly popoverY = signal<number>(0);

/** When set, only issues of this category are highlighted in the editor. (undefined = all categories shown) */
readonly activeComplianceFilter = signal<string | undefined>(undefined);
readonly activeComplianceFilter = signal<FilterCategory | undefined>(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(() => {
Expand Down Expand Up @@ -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);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;

Expand All @@ -119,6 +153,7 @@ export class EditorComponent extends BaseInputDirective<string> {
height = input<string>('12.5rem');
helperText = input<string | undefined>(undefined); // Optional helper text to display below the editor field
showGenderDecoderButton = input<boolean>(false);
showGenderBiasHighlights = input<boolean>(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.
Expand All @@ -127,7 +162,7 @@ export class EditorComponent extends BaseInputDirective<string> {
openAnalysisDialog = output<GenderBiasAnalysisResponse>();
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);
Expand All @@ -151,6 +186,12 @@ export class EditorComponent extends BaseInputDirective<string> {
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();
Expand Down Expand Up @@ -273,11 +314,13 @@ export class EditorComponent extends BaseInputDirective<string> {
* 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());
});

Expand Down Expand Up @@ -382,7 +425,7 @@ export class EditorComponent extends BaseInputDirective<string> {
* @param highlights Array of {text, category} to highlight
*/
public highlightTexts(highlights: { text: string; category: ComplianceIssueCategoryEnum }[]): void {
this.pendingHighlights.set(highlights);
this.pendingComplianceHighlights.set(highlights);
}

/**
Expand All @@ -392,20 +435,22 @@ export class EditorComponent extends BaseInputDirective<string> {
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;

Expand All @@ -417,6 +462,18 @@ export class EditorComponent extends BaseInputDirective<string> {
startIndex = index + text.length;
}
}

for (const { text } of genderBiasHighlights) {
Comment thread
ge94zec marked this conversation as resolved.
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;
}
}
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,59 @@

<hr class="m-0 w-full border-0 border-t border-border-default opacity-70" />

<!-- Gender Decoder -->
<div class="flex items-center justify-center gap-2 lg:justify-start">
<h3
class="m-0 text-base font-semibold text-text-secondary text-center lg:text-left"
jhiTranslate="jobCreationForm.aiSidebar.genderDecoder.header"
></h3>
<jhi-info-icon tooltip="jobCreationForm.aiSidebar.genderDecoder.tooltipText" tooltipPosition="top" [shouldTranslate]="true" />
</div>

<div class="flex flex-col gap-3">
<p
class="m-0 text-sm leading-6 text-text-tertiary text-center lg:text-left"
jhiTranslate="jobCreationForm.aiSidebar.genderDecoder.relevanceText"
></p>

<jhi-status-pill
data-testid="gender-decoder-pill"
labelKey="jobCreationForm.aiSidebar.genderDecoder.pill.fix"
dotColor="bg-text-secondary"
[count]="genderDecoderReviewCount()"
[isActive]="activeFilter() === genderBiasFilter"
[loading]="isAnalyzing() && genderBiasAnalysis() === undefined"

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

isAnalyzing here is the compliance/score-processing state — the parent binds [isAnalyzing]="isScoreProcessing()", which is isGeneratingDraft() || isTranslating() || isAnalyzing(). The gender-bias analysis runs on a completely separate pipeline (its own service, debounceTime(400), triggered from the editor's analyzeEffect), and the two are not simultaneous: compliance analysis starts after the 2s autosave, the gender analysis 400ms after each keystroke and immediately on load.

Two reproducible states:

  1. False "all clear" when opening an existing draft. Compliance issues are loaded from the persisted job without running an analysis, so isScoreProcessing() is false while the gender request is still in flight. With loading=false and count=0, status-pill renders the green check — the user is told there are no gender-bias findings until the count jumps in.
  2. The spinner can never appear again after the first result, because genderBiasAnalysis() === undefined is permanently false from then on. Re-analysis while typing shows a stale count with no loading indicator, whereas the four compliance pills next to it spin on every re-analysis.

Exposing a loading state from GenderBiasAnalysisService (or emitting one from the editor) and binding that would fix both.

(selected)="selectCategoryFilter(genderBiasFilter)"
/>

<div class="flex w-full flex-col gap-1">
<p class="m-0 text-xs text-text-secondary" jhiTranslate="jobCreationForm.aiSidebar.genderDecoder.balanceLabel"></p>
<div>
<div class="relative h-7">
<div class="absolute left-0 right-0 top-3 flex items-center gap-px">
<span class="h-[2px] flex-1 rounded-full bg-border-default"></span>
Comment thread
ge94zec marked this conversation as resolved.
<span class="h-[2px] flex-1 rounded-full bg-text-tertiary"></span>
<span class="h-[2px] flex-1 rounded-full bg-text-disabled"></span>
</div>
<span
data-testid="gender-decoder-pointer"
class="absolute top-[0.45rem] h-3 w-3 -translate-x-1/2 rounded-full border-2 border-background-default bg-text-secondary ring-1 ring-border-default"
aria-hidden="true"
[class]="genderDecoderPointerClass()"
></span>
</div>

<div class="-mt-1 flex items-center justify-between gap-2 text-xs leading-4 text-text-tertiary">
<span jhiTranslate="jobCreationForm.aiSidebar.genderDecoder.scale.exclusive"></span>
<span jhiTranslate="jobCreationForm.aiSidebar.genderDecoder.scale.neutral"></span>
<span jhiTranslate="jobCreationForm.aiSidebar.genderDecoder.scale.inclusive"></span>
</div>
</div>
</div>
</div>

<hr class="m-0 w-full border-0 border-t border-border-default opacity-70" />

<div class="flex items-start justify-center gap-2 lg:justify-start">
<h3
class="m-0 text-base font-semibold text-text-secondary text-center lg:text-left"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,15 @@ import { AiScoreRingComponent } from 'app/shared/components/atoms/ai-score-ring/
import { DialogComponent } from 'app/shared/components/atoms/dialog/dialog.component';
import { TooltipModule } from 'primeng/tooltip';
import { ComplianceIssue, ComplianceIssueCategoryEnum } from 'app/generated/model/compliance-issue';
import { GenderBiasAnalysisResponse } from 'app/generated/model/gender-bias-analysis-response';
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 {
FilterCategory,
GENDER_BIAS_FILTER_CATEGORY,
getUniqueNonInclusiveWords,
} from 'app/shared/gender-bias-analysis/gender-bias-analysis.utils';

@Component({
selector: 'jhi-ai-assistant-card',
Expand Down Expand Up @@ -41,6 +47,7 @@ export class AiAssistantCardComponent {
buttonIcon = input<string>('custom-sparkle');
complianceIssues = input<ComplianceIssue[]>([]);
currentLang = input<string>('en');
genderBiasAnalysis = input<GenderBiasAnalysisResponse | undefined>(undefined);

// ═══════════════════════════════════════════════════════════════════════════
// CONSTANTS
Expand All @@ -55,13 +62,13 @@ export class AiAssistantCardComponent {
// ═══════════════════════════════════════════════════════════════════════════

generate = output();
filterComplianceCat = output<string | undefined>();
filterComplianceCat = output<FilterCategory | undefined>();

// ═══════════════════════════════════════════════════════════════════════════
// SIGNALS
// ═══════════════════════════════════════════════════════════════════════════

readonly activeFilter = signal<string | undefined>(undefined);
readonly activeFilter = signal<FilterCategory | undefined>(undefined);
readonly displayedScore = signal<number | undefined>(undefined);
readonly scoreDialogVisible = signal(false);

Expand Down Expand Up @@ -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
Expand All @@ -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);
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
}
16 changes: 15 additions & 1 deletion src/main/webapp/i18n/de/job.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
16 changes: 15 additions & 1 deletion src/main/webapp/i18n/en/job.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading
Loading