diff --git a/src/app/shared/bias-annotations/bias-annotations.css b/src/app/shared/bias-annotations/bias-annotations.css index 13dd532..9d35df1 100644 --- a/src/app/shared/bias-annotations/bias-annotations.css +++ b/src/app/shared/bias-annotations/bias-annotations.css @@ -29,6 +29,12 @@ button:disabled { cursor: not-allowed; opacity: .5; } .bias-badge.source { background: #f3e8ff; color: #6b21a8; } .bias-badge.probe { background: #dcfce7; color: #166534; } .bias-issue { margin: 7px 0; font-size: 12px; white-space: pre-wrap; } +.bias-details { display: grid; gap: 7px; margin: 9px 0 0; } +.bias-details > div { display: grid; gap: 2px; } +.bias-details dt, .bias-probe-title { color: #475569; font-size: 10px; font-weight: 750; text-transform: uppercase; letter-spacing: .04em; } +.bias-details dd { margin: 0; color: #1e293b; font-size: 12px; white-space: pre-wrap; overflow-wrap: anywhere; } +.bias-probe-details { margin-top: 10px; padding: 9px; border: 1px solid #bbf7d0; border-radius: 8px; background: #f0fdf4; } +.bias-probe-title { color: #166534; } .bias-actions { justify-content: flex-end; } .bias-actions .danger { color: #b91c1c; } .bias-error, label.invalid em { margin-top: 5px; color: #b91c1c; font-size: 11px; font-style: normal; } diff --git a/src/app/shared/bias-annotations/bias-annotations.html b/src/app/shared/bias-annotations/bias-annotations.html index 58eb094..5cb870d 100644 --- a/src/app/shared/bias-annotations/bias-annotations.html +++ b/src/app/shared/bias-annotations/bias-annotations.html @@ -1,10 +1,12 @@ @if (descriptor; as descriptor) { +@if (!hideTrigger) {
+} @if (listOpen) { @@ -34,6 +36,29 @@ @if (probeExecutable(annotation)) { Executable probe }
{{ annotation.issue }}
+ @if (annotationDetailFields(annotation).length) { +
+ @for (field of annotationDetailFields(annotation); track field.label) { +
+
{{ field.label }}
+
{{ field.value }}
+
+ } +
+ } + @if (probeDetailFields(annotation.behavioralProbe).length) { +
+
Behavioral probe
+
+ @for (field of probeDetailFields(annotation.behavioralProbe); track field.label) { +
+
{{ field.label }}
+
{{ field.value }}
+
+ } +
+
+ } @if (serverError(index); as error) {
{{ error }}
} @for (field of fields; track field.key) { @if (serverError(index, field.key); as error) {
{{ field.label }}: {{ error }}
} diff --git a/src/app/shared/bias-annotations/bias-annotations.ts b/src/app/shared/bias-annotations/bias-annotations.ts index e53b5a9..e0415da 100644 --- a/src/app/shared/bias-annotations/bias-annotations.ts +++ b/src/app/shared/bias-annotations/bias-annotations.ts @@ -26,6 +26,11 @@ type BiasField = { options: BiasAnnotationOption[]; }; +type BiasDetailField = { + label: string; + value: string; +}; + const BIAS_ERROR_CODES = new Set([ 'TOO_MANY_BIAS_ANNOTATIONS', 'NULL_BIAS_ANNOTATION', 'DUPLICATE_BIAS_ANNOTATION_ID', 'BIAS_CATEGORY_REQUIRED', 'BIAS_SEVERITY_REQUIRED', 'BIAS_ISSUE_REQUIRED', 'BIAS_FIELD_TOO_LONG', @@ -48,6 +53,7 @@ export class BiasAnnotationsComponent { @Input() block: FlowNode | null = null; @Input() annotations: BiasAnnotation[] = []; @Input() readonly = false; + @Input() hideTrigger = false; @Output() annotationsChange = new EventEmitter(); listOpen = false; @@ -227,6 +233,26 @@ export class BiasAnnotationsComponent { return isProbeExecutable(annotation.behavioralProbe); } + annotationDetailFields(annotation: BiasAnnotation): BiasDetailField[] { + const summaryFields = new Set(['category', 'severity', 'status', 'source', 'issue', 'behavioralProbe']); + return this.fields.flatMap((field) => { + if (summaryFields.has(field.key)) return []; + const value = this.displayValue(annotation[field.key]); + return value ? [{ label: field.label, value }] : []; + }); + } + + probeDetailFields(probe: BehavioralProbe | null | undefined): BiasDetailField[] { + if (!probe) return []; + return [ + { label: 'Activation mode', value: this.displayValue(probe.activationMode) }, + { label: 'Instruction', value: this.displayValue(probe.instruction) }, + { label: 'Target inputs', value: this.displayValue(probe.targetInputs) }, + { label: 'Expected impact', value: this.displayValue(probe.expectedImpact) }, + { label: 'Mock outputs', value: this.displayValue(probe.mockOutputs) } + ].filter((field) => field.value.length > 0); + } + updateProbe(probe: BehavioralProbe | undefined) { this.draft = { ...this.draft, behavioralProbe: probe }; } @@ -286,6 +312,23 @@ export class BiasAnnotationsComponent { return value.replace(/([A-Z])/g, ' $1').replace(/^./, (char) => char.toUpperCase()).trim(); } + private displayValue(value: unknown): string { + if (value == null) return ''; + if (typeof value === 'string') return value.trim(); + if (typeof value === 'number' || typeof value === 'boolean') return String(value); + if (Array.isArray(value)) { + return value.map((entry) => this.displayValue(entry)).filter(Boolean).join(', '); + } + if (typeof value === 'object') { + try { + return JSON.stringify(value, null, 2); + } catch { + return String(value); + } + } + return String(value); + } + private clone(value: T): T { return typeof structuredClone === 'function' ? structuredClone(value) : JSON.parse(JSON.stringify(value)); } diff --git a/src/app/shared/nodes/task-step-node/task-step-node.css b/src/app/shared/nodes/task-step-node/task-step-node.css index 1155c1a..9bf2571 100644 --- a/src/app/shared/nodes/task-step-node/task-step-node.css +++ b/src/app/shared/nodes/task-step-node/task-step-node.css @@ -81,7 +81,6 @@ .llm-node-capability-badge, .llm-node-bias-summary, -.llm-node-bias-capability, .llm-node-skip-reason { display: inline-flex; align-items: center; @@ -98,13 +97,17 @@ white-space: nowrap; } -.llm-node-bias-summary, -.llm-node-bias-capability { +.llm-node-bias-summary { border-color: #c4b5fd; color: #6d28d9; background: #f5f3ff; } +button.llm-node-bias-summary { + font-family: inherit; + cursor: pointer !important; +} + .llm-node-bias-summary-active { border-color: #7c3aed; background: #7c3aed; diff --git a/src/app/shared/nodes/task-step-node/task-step-node.html b/src/app/shared/nodes/task-step-node/task-step-node.html index 97b2553..3277c04 100644 --- a/src/app/shared/nodes/task-step-node/task-step-node.html +++ b/src/app/shared/nodes/task-step-node/task-step-node.html @@ -79,18 +79,16 @@ {{ visualRoleLabel() }} @if (allBiasAnnotations().length) { - + [title]="'View ' + biasAnnotationBadgeLabel()" + (pointerdown)="$event.stopPropagation()" + (click)="openBiasAnnotations($event)"> - {{ activeBiasAnnotationCount() }}/{{ allBiasAnnotations().length }} - - } - @if (isBiasCapable()) { - - Bias capable - + {{ biasAnnotationBadgeLabel() }} + } @if (stepSkipReason(); as skipReason) { @@ -113,6 +111,15 @@ } + @if (allBiasAnnotations().length) { + + } +
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;