Skip to content
Closed
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,8 @@ <h1 jhiTranslate="{{ pageTitle() }}"></h1>
[isRewriteMode]="rewriteButtonSignal()"
[currentLang]="currentDescriptionLanguage()"
[complianceIssues]="complianceIssues()"
[genderBiasAnalysis]="jobDescriptionEditor.analysisResult()"
[isGenderAnalyzing]="genderBiasService.isAnalyzing(jobDescriptionEditor.fieldId())"
(generate)="generateJobApplicationDraft()"
(filterComplianceCat)="onComplianceFilterChange($event)"
/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,8 @@ 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 { GenderBiasAnalysisService } from 'app/shared/gender-bias-analysis/gender-bias-analysis';

import { JobDetailComponent } from '../job-detail/job-detail.component';
import * as DropdownOptions from '.././dropdown-options';
Expand Down Expand Up @@ -282,6 +284,7 @@ export class JobCreationFormComponent {
private aiStreamingService = inject(AiStreamingService);
private aiFeatureStatusService = inject(AiFeatureStatusService);
private researchGroupApi = inject(ResearchGroupResourceApi);
protected readonly genderBiasService = inject(GenderBiasAnalysisService);

// ═══════════════════════════════════════════════════════════════════════════
// AI SIGNALS
Expand Down Expand Up @@ -326,7 +329,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 +966,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.
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 Expand Up @@ -452,18 +509,19 @@ export class EditorComponent extends BaseInputDirective<string> {
}

/**
* 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.
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]="isGenderAnalyzing()"
(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-4">
<div class="absolute left-0 right-0 top-1/2 flex -translate-y-1/2 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-1/2 h-3 w-3 -translate-x-1/2 -translate-y-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="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,8 @@ export class AiAssistantCardComponent {
buttonIcon = input<string>('custom-sparkle');
complianceIssues = input<ComplianceIssue[]>([]);
currentLang = input<string>('en');
genderBiasAnalysis = input<GenderBiasAnalysisResponse | undefined>(undefined);
isGenderAnalyzing = input(false);

// ═══════════════════════════════════════════════════════════════════════════
// CONSTANTS
Expand All @@ -55,13 +63,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 +142,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 +180,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
Loading
Loading