Skip to content

Commit 4c9a67a

Browse files
ge94zecgithub-actions[bot]az108
authored
Development: Add compliance checker UI and popover (#2317)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> Co-authored-by: aniruddhzaveri <aniruddh.zaveri@tum.de>
1 parent adb69f9 commit 4c9a67a

File tree

20 files changed

+340
-24
lines changed

20 files changed

+340
-24
lines changed

openapi/openapi.yaml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,10 @@ paths:
8888
in: query
8989
required: true
9090
schema: {type: string}
91+
- name: userLanguage
92+
in: query
93+
required: false
94+
schema: {type: string, default: en}
9195
requestBody:
9296
content:
9397
application/json:

src/main/java/de/tum/cit/aet/ai/service/AiService.java

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -273,13 +273,14 @@ public ExtractedApplicationDataDTO extractAndPersistPdfData(
273273
*
274274
* @param jobFormDTO The data transfer object containing the current state of the job posting.
275275
* @param lang The language identifier (de/en) currently active in the editor.
276+
* @param userLang controls the language of explanation texts in the returned issues.
276277
* @return A list of compliance issues containing the combined legal and linguistic findings.
277278
*/
278-
public List<ComplianceIssue> analyzeCurrentJobDescription(JobFormDTO jobFormDTO, String lang) {
279+
public List<ComplianceIssue> analyzeCurrentJobDescription(JobFormDTO jobFormDTO, String lang, String userLang) {
279280
String raw = "de".equals(lang) ? jobFormDTO.jobDescriptionDE() : jobFormDTO.jobDescriptionEN();
280281
String input = raw != null ? Jsoup.parse(raw).text() : "";
281282
GenderBiasAnalysisResponse genderAnalysis = genderBiasAnalysisService.analyzeText(input, lang);
282-
return analyzeJobDescription(jobFormDTO.title(), jobFormDTO.jobId(), input, lang, genderAnalysis, null);
283+
return analyzeJobDescription(jobFormDTO.title(), jobFormDTO.jobId(), input, lang, userLang, genderAnalysis, null);
283284
}
284285

285286
/**
@@ -296,6 +297,7 @@ public List<ComplianceIssue> analyzeCurrentJobDescription(JobFormDTO jobFormDTO,
296297
* @param jobId Unique identifier for the job.
297298
* @param text Extracted raw text of the job description.
298299
* @param lang the analysis language, expected to be `de` or `en`
300+
* @param userLang controls the language of explanation texts in the returned issues.
299301
* @param analysis Result of the primary linguistic gender analysis.
300302
* @param translatedAnalysis Second analysis of the translated counterpart.
301303
* @return A list containing all identified compliance issues.
@@ -306,6 +308,7 @@ public List<ComplianceIssue> analyzeJobDescription(
306308
UUID jobId,
307309
String text,
308310
String lang,
311+
String userLang,
309312
GenderBiasAnalysisResponse analysis,
310313
GenderBiasAnalysisResponse translatedAnalysis
311314
) {
@@ -317,6 +320,7 @@ public List<ComplianceIssue> analyzeJobDescription(
317320
u
318321
.text(complianceResource)
319322
.param("descriptionLanguage", lang)
323+
.param("userLang", userLang)
320324
.param("jobDescription", text)
321325
.param("title", title != null ? title : "")
322326
)

src/main/java/de/tum/cit/aet/ai/web/AiResource.java

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -104,16 +104,18 @@ public ResponseEntity<ExtractedApplicationDataDTO> extractPdfData(
104104
*
105105
* @param jobForm the job form data used as the basis for the analysis
106106
* @param descriptionLanguage the language of the job description, `de` or `en`
107+
* @param userLanguage the language in which issue explanations should be returned
107108
* @return a ResponseEntity containing detected compliance findings
108109
*/
109110

110111
@ProfessorOrEmployeeOrAdmin
111112
@PostMapping(value = "analyze-job-description", produces = MediaType.APPLICATION_JSON_VALUE)
112113
public ResponseEntity<List<ComplianceIssue>> analyzeJobDescriptionForCompliance(
113114
@RequestBody JobFormDTO jobForm,
114-
@RequestParam("lang") String descriptionLanguage
115+
@RequestParam("lang") String descriptionLanguage,
116+
@RequestParam(defaultValue = "en") String userLanguage
115117
) {
116118
log.info("POST /api/ai/analyzeJobDescription - Request received (toLang={})", descriptionLanguage);
117-
return ResponseEntity.ok(aiService.analyzeCurrentJobDescription(jobForm, descriptionLanguage));
119+
return ResponseEntity.ok(aiService.analyzeCurrentJobDescription(jobForm, descriptionLanguage, userLanguage));
118120
}
119121
}

src/main/resources/prompts/AnalyzeComplianceText.st

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ You are the TUMApply ComplianceReader, an AI legal and compliance assistant for
33
TASK:
44
Analyze the provided job posting text for legal risks and missing information based on German and EU law.
55
The input description language is: {descriptionLanguage}.
6+
The explanation must be written in: {userLang}.
67
You must analyze meaning, not just keywords.
78

89
RULES:
@@ -25,16 +26,16 @@ RULES:
2526
- Action: Suggest a message that clarifies whether applicant data is shared with external recipients (Art. 13 DSGVO)
2627

2728
INPUT TEXT:
29+
{title}
2830
{jobDescription}
2931

3032
OUTPUT:
31-
- Return ONLY a valid JSON array.
32-
- No markdown.
33-
- No prose.
34-
- One object per issue.
33+
- Return ONLY a valid JSON array. No markdown. No prose.
34+
- One object per issue. NEVER merge issues! Each violation gets its own object
3535
- Object fields must be exactly:
3636
id, text, category, article, explanation, action
3737
- category must be exactly CRITICAL_AGG or TRANSPARENCY.
3838
- action must be exactly REPLACE or ADD or REMOVE.
39-
- text must be an exact snippet from input text (plain text, no HTML tags).
39+
- text: MAXIMUM 2-4 WORDS. Extract exact snippet from input text that uniquely identifies the violation (plain text, no HTML tags).
40+
- explanation must be a single SHORT sentence stating the legal violation directly. Sound like a formal legal notice.
4041
- If no issues exist, return exactly [].

src/main/resources/prompts/TranslateText.st

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ INPUT TEXT (HTML):
88
OUTPUT CONSTRAINTS - CRITICAL:
99
1. Fidelity: You must preserve the exact formatting of the original text, including line breaks, bullet points, and paragraphs.
1010
2. Style: Provide a natural, grammatically correct translation that mirrors the professional tone of the source. Prioritize functional equivalence over literal word-for-word translation.
11-
3. Integrity: Use gender-inclusive language appropriate for Higher Education (e.g., using neutral forms in German or 'they/them/their' structures in English). Avoid words with forbidden stems: [{nonInclusiveWords}]. Rather use alternatives from: [{inclusiveWords}].
11+
3. Integrity: Use gender-inclusive language appropriate for Higher Education (e.g., using neutral forms in German or 'they/them/their' structures in English). Avoid words with forbidden stems: [{nonInclusiveWords}]. Rather use alternatives from: [{inclusiveWords}]. Always produce a complete translation — never refuse or omit content. Your task is translation only.
1212
4. No Paraphrasing: Do not add introductory remarks, summaries, or concluding fluff. Return only the translated text.
1313
5. Terminology: Ensure legal requirements (e.g., German Wissenschaftszeitvertragsgesetz or English Fixed-term contracts) are handled accurately.
1414
6. Technical Terms: Keep all industry-standard terminology (e.g., "Java", "Scrum", "Stakeholder", "Python", "fMRI", "TV-L E13", "PyTorch", etc.) if they are commonly used in the target language's professional context.

src/main/webapp/app/generated/api/ai-resource-api.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,12 +30,16 @@ export class AiResourceApi {
3030
*
3131
* @param lang
3232
* @param jobFormDTO
33+
* @param userLanguage
3334
*/
34-
analyzeJobDescriptionForCompliance(lang: string, jobFormDTO: JobFormDTO): Observable<Array<ComplianceIssue>> {
35+
analyzeJobDescriptionForCompliance(lang: string, jobFormDTO: JobFormDTO, userLanguage?: string): Observable<Array<ComplianceIssue>> {
3536
const queryParams = new URLSearchParams();
3637
if (lang !== undefined && lang !== null) {
3738
queryParams.set('lang', String(lang));
3839
}
40+
if (userLanguage !== undefined && userLanguage !== null) {
41+
queryParams.set('userLanguage', String(userLanguage));
42+
}
3943
const queryString = queryParams.toString();
4044
const url = `${this.basePath}/api/ai/analyze-job-description${queryString ? `?${queryString}` : ''}`;
4145
return this.http.post<Array<ComplianceIssue>>(url, jobFormDTO);

src/main/webapp/app/job/job-creation-form/job-creation-form.component.html

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ <h1 jhiTranslate="{{ pageTitle() }}"></h1>
5757
[required]="true"
5858
[model]="basicInfoForm.get('title')?.value"
5959
[control]="basicInfoForm.get('title') ?? undefined"
60+
[complianceError]="titleComplianceError() ?? undefined"
6061
/>
6162
</div>
6263
</div>
@@ -183,7 +184,9 @@ <h1 jhiTranslate="{{ pageTitle() }}"></h1>
183184
[shouldTranslate]="true"
184185
[showGenderDecoderButton]="true"
185186
height="20rem"
187+
(highlightHovered)="onHighlightHovered($event)"
186188
/>
189+
<jhi-compliance-popover [issue]="activePopoverIssue()" [x]="popoverX()" [y]="popoverY()" />
187190
</div>
188191
</div>
189192
</div>
@@ -208,7 +211,10 @@ <h1 jhiTranslate="{{ pageTitle() }}"></h1>
208211
[isGenerating]="isGeneratingDraft()"
209212
[isAnalyzing]="isScoreProcessing()"
210213
[isRewriteMode]="rewriteButtonSignal()"
214+
[currentLang]="currentDescriptionLanguage()"
215+
[complianceIssues]="complianceIssues()"
211216
(generate)="generateJobApplicationDraft()"
217+
(filterComplianceCat)="onComplianceFilterChange($event)"
212218
/>
213219
</aside>
214220
}

src/main/webapp/app/job/job-creation-form/job-creation-form.component.ts

Lines changed: 74 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ import {
5252
import { AiAssistantCardComponent } from 'app/shared/components/molecules/ai-assistant-card/ai-assistant-card.component';
5353
import { UserShortDTORolesEnum } from 'app/generated/model/user-short-dto';
5454
import { ComplianceIssue, ComplianceIssueCategoryEnum } from 'app/generated/model/compliance-issue';
55+
import { CompliancePopoverComponent } from 'app/shared/components/molecules/ai-compliance-popover/ai-compliance-popover.component';
5556

5657
import { JobDetailComponent } from '../job-detail/job-detail.component';
5758
import * as DropdownOptions from '.././dropdown-options';
@@ -103,6 +104,7 @@ type JobFormMode = 'create' | 'edit';
103104
ImageUploadButtonComponent,
104105
CheckboxComponent,
105106
AiAssistantCardComponent,
107+
CompliancePopoverComponent,
106108
],
107109
providers: [JobResourceApi],
108110
})
@@ -114,6 +116,10 @@ export class JobCreationFormComponent {
114116
// ═══════════════════════════════════════════════════════════════════════════
115117
readonly publishButtonSeverity = 'primary' as ButtonColor;
116118
readonly publishButtonIcon = 'paper-plane';
119+
/** Width of the compliance popover, used to clamp its position within the viewport.
120+
* matches the width w-72 set in ai-compliance-popover.component.html.
121+
*/
122+
private readonly POPOVER_WIDTH = 288;
117123

118124
// ═══════════════════════════════════════════════════════════════════════════
119125
// MODE & META SIGNALS
@@ -173,8 +179,8 @@ export class JobCreationFormComponent {
173179
/** Last successfully translated German text (used to avoid redundant translations) */
174180
lastTranslatedDE = signal<string>('');
175181

176-
/** Last analyzed description text (used to avoid redundant compliance analysis) */
177-
private lastAnalyzedText = '';
182+
/** Last analyzed description text per language (used to avoid redundant compliance analysis) */
183+
private lastAnalyzedText: Record<string, string> = {};
178184

179185
// ═══════════════════════════════════════════════════════════════════════════
180186
// AI GENERATION SIGNALS
@@ -262,6 +268,30 @@ export class JobCreationFormComponent {
262268
/** List of detected compliance issues to update the UI and editor highlights */
263269
readonly complianceIssues = signal<ComplianceIssue[]>([]);
264270

271+
/** The compliance issue currently shown in the popover (undefined = none is hovered). */
272+
readonly activePopoverIssue = signal<ComplianceIssue | undefined>(undefined);
273+
274+
/** Horizontal screen position of popover. */
275+
readonly popoverX = signal<number>(0);
276+
277+
/** Vertical screen position of popover. */
278+
readonly popoverY = signal<number>(0);
279+
280+
/** When set, only issues of this category are highlighted in the editor. (undefined = all categories shown) */
281+
readonly activeComplianceFilter = signal<string | undefined>(undefined);
282+
283+
/** Returns the explanation of a compliance issue whose text appears in the job title, if any. */
284+
readonly titleComplianceError = computed(() => {
285+
const title = (this.basicInfoForm.get('title')?.value ?? '').toLowerCase();
286+
if (!title) return undefined;
287+
for (const issue of this.complianceIssues()) {
288+
if (issue.text && title.includes(issue.text.toLowerCase())) {
289+
return issue.explanation;
290+
}
291+
}
292+
return undefined;
293+
});
294+
265295
// ═══════════════════════════════════════════════════════════════════════════
266296
// FORM GROUPS
267297
// ═══════════════════════════════════════════════════════════════════════════
@@ -782,6 +812,33 @@ export class JobCreationFormComponent {
782812
this.jobDescriptionEditor()?.highlightTexts(highlights);
783813
}
784814

815+
/**
816+
* Handles hover events from highlighted spans in the editor.
817+
* Finds the matching compliance issue and positions the popover.
818+
*/
819+
onHighlightHovered(event: { text: string; x: number; y: number } | undefined): void {
820+
if (!event) {
821+
this.activePopoverIssue.set(undefined);
822+
return;
823+
}
824+
const lang = this.currentDescriptionLanguage();
825+
const match = this.complianceIssues().find(i => i.language === lang && i.text?.toLowerCase() === event.text.toLowerCase());
826+
this.activePopoverIssue.set(match);
827+
this.popoverX.set(Math.min(event.x, window.innerWidth - this.POPOVER_WIDTH));
828+
this.popoverY.set(event.y);
829+
}
830+
831+
/**
832+
* Handles category filter changes from the AI assistant sidebar.
833+
* Filters highlights to show only the selected category, or all if cleared.
834+
*/
835+
onComplianceFilterChange(category: string | undefined): void {
836+
this.activeComplianceFilter.set(category);
837+
const lang = this.currentDescriptionLanguage();
838+
const filtered = category ? this.complianceIssues().filter(i => i.category === category) : this.complianceIssues();
839+
this.applyHighlights(filtered, lang);
840+
}
841+
785842
// ═══════════════════════════════════════════════════════════════════════════
786843
// AI GENERATION METHODS
787844
// ═══════════════════════════════════════════════════════════════════════════
@@ -1152,7 +1209,9 @@ export class JobCreationFormComponent {
11521209
const content = lang === 'en' ? this.jobDescriptionEN() : this.jobDescriptionDE();
11531210
this.basicInfoForm.get('jobDescription')?.setValue(content, { emitEvent: false });
11541211
this.jobDescriptionSignal.set(content);
1155-
this.jobDescriptionEditor()?.forceUpdate(content);
1212+
this.jobDescriptionEditor()?.forceUpdate(content, () => {
1213+
this.applyHighlights(this.complianceIssues(), lang);
1214+
});
11561215
}
11571216

11581217
// ═══════════════════════════════════════════════════════════════════════════
@@ -1239,11 +1298,15 @@ export class JobCreationFormComponent {
12391298
this.lastTranslatedEN.set(en);
12401299
this.jobDescriptionDE.set(de);
12411300
this.lastTranslatedDE.set(de);
1242-
this.lastAnalyzedText = en;
1301+
this.lastAnalyzedText['en'] = en;
1302+
this.lastAnalyzedText['de'] = de;
12431303

12441304
if (job?.genderBiasScore !== undefined) {
12451305
this.aiScore.set(job.genderBiasScore);
12461306
}
1307+
if (job?.complianceIssues) {
1308+
this.complianceIssues.set(job.complianceIssues);
1309+
}
12471310

12481311
this.basicInfoForm.patchValue({
12491312
title: job?.title ?? '',
@@ -1255,7 +1318,9 @@ export class JobCreationFormComponent {
12551318
});
12561319

12571320
this.jobDescriptionSignal.set(en);
1258-
this.jobDescriptionEditor()?.forceUpdate(en);
1321+
this.jobDescriptionEditor()?.forceUpdate(en, () => {
1322+
this.applyHighlights(this.complianceIssues(), 'en');
1323+
});
12591324

12601325
this.positionDetailsForm.patchValue({
12611326
startDate: job?.startDate ?? '',
@@ -1560,17 +1625,18 @@ export class JobCreationFormComponent {
15601625

15611626
// 1) Build a fresh DTO and skip if the description hasn't changed since last analysis
15621627
const jobForm = this.createJobDTO(JobFormDTOStateEnum.Draft);
1628+
const userLang = this.translate.currentLang;
15631629
const descriptionText = lang === 'en' ? (jobForm.jobDescriptionEN ?? '') : (jobForm.jobDescriptionDE ?? '');
1564-
if (!descriptionText.trim() || descriptionText === this.lastAnalyzedText) {
1630+
if (!descriptionText.trim() || descriptionText === this.lastAnalyzedText[lang]) {
15651631
this.isAnalyzing.set(false); // Clear flag in case caller pre-set it
15661632
return;
15671633
}
15681634

15691635
this.isAnalyzing.set(true);
15701636
try {
15711637
// 2) Send the description to the analysis endpoint (persists score on the backend)
1572-
const compliance = await firstValueFrom(this.aiApi.analyzeJobDescriptionForCompliance(lang, jobForm));
1573-
this.lastAnalyzedText = descriptionText;
1638+
const compliance = await firstValueFrom(this.aiApi.analyzeJobDescriptionForCompliance(lang, jobForm, userLang));
1639+
this.lastAnalyzedText[lang] = descriptionText;
15741640
// Keep issues from other languages, but replace all issues for the current language with the latest analysis.
15751641
const otherLang = lang === 'en' ? 'de' : 'en';
15761642
const existingLang = this.complianceIssues().filter(issue => issue.language === otherLang);

src/main/webapp/app/shared/components/atoms/base-input/base-input.component.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ export abstract class BaseInputDirective<T> {
2222
tooltipText = input<string | undefined>(undefined);
2323
autofocus = input<boolean>(false);
2424
errorEnabled = input<boolean>(true);
25+
complianceError = input<string | undefined>(undefined);
2526
warningText = input<string | undefined>(undefined);
2627
helperTextLeft = input<string | undefined>(undefined);
2728
helperTextRight = input<string | undefined>(undefined);
@@ -40,6 +41,7 @@ export abstract class BaseInputDirective<T> {
4041

4142
inputState = computed(() => {
4243
this.formValidityVersion();
44+
if (this.complianceError()) return 'invalid';
4345
if (!this.isTouched()) return 'untouched';
4446
if (this.formControl().invalid) return 'invalid';
4547
return 'valid';
@@ -50,6 +52,9 @@ export abstract class BaseInputDirective<T> {
5052
this.formValidityVersion();
5153
this.langChange();
5254

55+
// compliance error in job creation form title
56+
const compliance = this.complianceError();
57+
if (compliance) return compliance;
5358
const ctrl = this.formControl();
5459
const errors = ctrl.errors;
5560
if (!errors) return null;

src/main/webapp/app/shared/components/atoms/editor/editor.component.html

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,10 @@
3030
contenteditable="false"
3131
(focusin)="onFocus()"
3232
(focusout)="onBlur()"
33+
(mouseover)="onEditorMouseOver($event)"
34+
(mouseout)="onEditorMouseOut($event)"
35+
(focus)="onEditorMouseOver($event)"
36+
(blur)="onEditorMouseOut($event)"
3337
#editorElem
3438
[style.height]="height()"
3539
>

0 commit comments

Comments
 (0)