Inputs
diff --git a/src/app/shared/nodes/task-step-node/task-step-node.spec.ts b/src/app/shared/nodes/task-step-node/task-step-node.spec.ts
index 822fd49..d386a52 100644
--- a/src/app/shared/nodes/task-step-node/task-step-node.spec.ts
+++ b/src/app/shared/nodes/task-step-node/task-step-node.spec.ts
@@ -1,3 +1,4 @@
+import { signal } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { vi } from 'vitest';
import { of } from 'rxjs';
@@ -34,6 +35,25 @@ const REPORT: BiasImpactReport = {
warnings: []
};
+const BIAS_DESCRIPTOR = {
+ type: 'BiasAnnotation',
+ blockProperty: 'biasAnnotations',
+ multiple: true,
+ maxItems: 10,
+ schema: {
+ type: 'object',
+ properties: {
+ category: { type: 'string', 'x-ui-label': 'Category' },
+ severity: { type: 'string', 'x-ui-label': 'Severity' },
+ issue: { type: 'string', 'x-ui-label': 'Issue' },
+ rationale: { type: 'string', 'x-ui-label': 'Rationale' }
+ }
+ },
+ options: {},
+ defaults: {},
+ serverGeneratedFields: []
+};
+
describe('TaskStepNodeComponent bias canvas highlighting', () => {
let fixture: ComponentFixture
;
let component: TaskStepNodeComponent;
@@ -43,7 +63,16 @@ describe('TaskStepNodeComponent bias canvas highlighting', () => {
await TestBed.configureTestingModule({
imports: [TaskStepNodeComponent],
providers: [
- { provide: BlocksService, useValue: { peekBlockType: vi.fn().mockReturnValue(null), getBlockType: vi.fn().mockResolvedValue(null), retrieveBiasCapabilities: vi.fn().mockReturnValue(of(null)) } },
+ {
+ provide: BlocksService,
+ useValue: {
+ peekBlockType: vi.fn().mockReturnValue(null),
+ getBlockType: vi.fn().mockResolvedValue(null),
+ retrieveBiasCapabilities: vi.fn().mockReturnValue(of(null)),
+ getBiasAnnotationsDescriptor: vi.fn().mockResolvedValue(BIAS_DESCRIPTOR),
+ biasAnnotationsDescriptor: signal(BIAS_DESCRIPTOR)
+ }
+ },
{ provide: ContainersService, useValue: { peekContainerType: vi.fn().mockReturnValue(null), getContainerType: vi.fn().mockResolvedValue(null) } },
{ provide: NodeSettingsDialogService, useValue: { open: vi.fn().mockResolvedValue(null) } },
{ provide: SubflowPreviewDialogService, useValue: { open: vi.fn() } },
@@ -123,6 +152,53 @@ describe('TaskStepNodeComponent bias canvas highlighting', () => {
expect(component.isBiasCapable()).toBe(true);
});
+ it('shows a readable bias count and opens the readonly annotations detail from the badge', () => {
+ fixture.componentRef.setInput('data', {
+ ...component.data,
+ data: {
+ ...component.data.data,
+ biasAnnotations: [
+ {
+ id: 'annotation-1',
+ category: 'SELECTION_BIAS',
+ severity: 'HIGH',
+ issue: 'First issue',
+ rationale: 'First rationale',
+ behavioralProbe: {
+ activationMode: 'INPUT_TRANSFORMATION',
+ instruction: 'Transform the candidate profile'
+ }
+ },
+ { id: 'annotation-2', category: 'ACCESSIBILITY_BIAS', severity: 'MEDIUM', issue: 'Second issue' }
+ ]
+ }
+ });
+ fixture.detectChanges();
+
+ const badge = fixture.nativeElement.querySelector('.llm-node-bias-summary') as HTMLButtonElement;
+ expect(badge.textContent).toContain('2 bias annotations');
+ expect(badge.textContent).not.toContain('0/2');
+
+ badge.click();
+ fixture.detectChanges();
+
+ const dialog = fixture.nativeElement.querySelector('dialog.bias-modal-backdrop');
+ expect(dialog).not.toBeNull();
+ expect(dialog.textContent).toContain('First issue');
+ expect(dialog.textContent).toContain('Second issue');
+ expect(dialog.textContent).toContain('First rationale');
+ expect(dialog.textContent).toContain('Transform the candidate profile');
+ });
+
+ it('uses the singular label and hides the annotation badge when no annotations exist', () => {
+ component.data.data.biasAnnotations = [{ id: 'annotation-1' }];
+ expect(component.biasAnnotationBadgeLabel()).toBe('1 bias annotation');
+
+ component.data.data.biasAnnotations = [];
+ fixture.detectChanges();
+ expect(fixture.nativeElement.querySelector('.llm-node-bias-summary')).toBeNull();
+ });
+
it('does not mark EndBlock as bias capable when the catalog forbids bias annotations', () => {
component.data.data.typeName = 'EndBlock';
component.data.data.capabilities = undefined;
diff --git a/src/app/shared/nodes/task-step-node/task-step-node.ts b/src/app/shared/nodes/task-step-node/task-step-node.ts
index a1e01db..95c401e 100644
--- a/src/app/shared/nodes/task-step-node/task-step-node.ts
+++ b/src/app/shared/nodes/task-step-node/task-step-node.ts
@@ -1,8 +1,8 @@
import { CommonModule } from '@angular/common';
-import { ChangeDetectionStrategy, ChangeDetectorRef, Component, HostBinding, Input, inject } from '@angular/core';
+import { ChangeDetectionStrategy, ChangeDetectorRef, Component, HostBinding, Input, inject, viewChild } from '@angular/core';
import { ClassicPreset } from 'rete';
import { ReteModule } from 'rete-angular-plugin/21';
-import { BiasAnnotation, BlockInteractionContract, BlockType, DEFAULT_NODE_CAPABILITIES, FlowData, FlowPort, isProbeExecutable, FLOW_DEPENDANT_PORT_KEY, FLOW_DEPENDENCY_PORT_KEY, NodeTypeCapabilities } from '@models/flow';
+import { BiasAnnotation, BlockInteractionContract, BlockType, DEFAULT_NODE_CAPABILITIES, FlowData, FlowNode, FlowPort, isProbeExecutable, FLOW_DEPENDANT_PORT_KEY, FLOW_DEPENDENCY_PORT_KEY, NodeTypeCapabilities } from '@models/flow';
import { BiasCapabilities } from '@models/bias-impact';
import { BlocksService } from '@services/blocks/blocks';
import { ContainersService } from '@services/containers/containers';
@@ -12,6 +12,7 @@ import { HumanInteractionDialogService } from '@services/dialogs/human-interacti
import { TaskExecutionsService } from '@services/task-executions/task-executions';
import { BiasImpactExperimentDialogService } from '@services/dialogs/bias-impact-experiment-dialog';
import { BiasComparisonViewStateService } from '@services/bias/bias-comparison-view-state';
+import { BiasAnnotationsComponent } from '@shared/bias-annotations/bias-annotations';
import { take } from 'rxjs';
import {
collectSchemaFlowDataFields,
@@ -98,7 +99,7 @@ type FieldUiMeta = {
@Component({
selector: 'app-task-step-node',
- imports: [CommonModule, ReteModule],
+ imports: [CommonModule, ReteModule, BiasAnnotationsComponent],
templateUrl: './task-step-node.html',
styleUrl: './task-step-node.css',
host: {
@@ -120,6 +121,7 @@ export class TaskStepNodeComponent {
private taskExecutionsService = inject(TaskExecutionsService);
private biasImpactExperimentDialog = inject(BiasImpactExperimentDialogService);
private biasComparisonViewState = inject(BiasComparisonViewStateService);
+ private readonly biasAnnotationsViewer = viewChild(BiasAnnotationsComponent);
@Input() data!: any;
@Input() emit!: (data: any) => void;
@@ -179,6 +181,7 @@ export class TaskStepNodeComponent {
this.rebuildDisplayState();
void this.loadSchemaContext();
+ void this.loadBiasAnnotationsDescriptor();
this.loadBiasCapabilities();
}
@@ -450,6 +453,22 @@ export class TaskStepNodeComponent {
return Array.isArray(ids) ? ids.length : 0;
}
+ biasAnnotationBadgeLabel(): string {
+ const count = this.allBiasAnnotations().length;
+ return `${count} bias ${count === 1 ? 'annotation' : 'annotations'}`;
+ }
+
+ openBiasAnnotations(event?: Event) {
+ event?.preventDefault();
+ event?.stopPropagation();
+ this.biasAnnotationsViewer()?.openList(event);
+ }
+
+ biasAnnotationsBlock(): FlowNode | null {
+ const node = this.data?.data;
+ return node && typeof node === 'object' ? node as FlowNode : null;
+ }
+
typeCapabilities(): NodeTypeCapabilities {
return this.resolvedTypeCapabilities()
?? DEFAULT_NODE_CAPABILITIES;
@@ -608,6 +627,15 @@ export class TaskStepNodeComponent {
});
}
+ private async loadBiasAnnotationsDescriptor() {
+ try {
+ await this.blocksService.getBiasAnnotationsDescriptor();
+ this.cdr.markForCheck();
+ } catch {
+ // The annotations remain available in the execution payload even if their descriptor cannot be loaded.
+ }
+ }
+
private get blockType(): string | null {
const typeName = this.data?.data?.typeName;
return typeof typeName === 'string' && typeName.length > 0 ? typeName : null;