feat(task-execution): show bias annotation details

This commit is contained in:
Lucio Lelii 2026-07-23 11:21:34 +02:00
parent f633493bd6
commit c67163faaa
7 changed files with 204 additions and 16 deletions

View File

@ -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; }

View File

@ -1,10 +1,12 @@
@if (descriptor; as descriptor) {
@if (!hideTrigger) {
<section class="bias-section" (pointerdown)="$event.stopPropagation()" (click)="$event.stopPropagation()">
<button type="button" class="bias-open-trigger" (click)="openList($event)">
<span class="bias-title">Bias annotations</span>
<span class="bias-counter">{{ annotations.length }}{{ countSuffix }}</span>
</button>
</section>
}
@if (listOpen) {
<dialog #biasListDialog class="bias-modal-backdrop" (pointerdown)="$event.stopPropagation()" (wheel)="$event.stopPropagation()" (click)="onListDialogClick($event)" (cancel)="closeList($event)">
@ -34,6 +36,29 @@
@if (probeExecutable(annotation)) { <span class="bias-badge probe">Executable probe</span> }
</div>
<div class="bias-issue">{{ annotation.issue }}</div>
@if (annotationDetailFields(annotation).length) {
<dl class="bias-details">
@for (field of annotationDetailFields(annotation); track field.label) {
<div>
<dt>{{ field.label }}</dt>
<dd>{{ field.value }}</dd>
</div>
}
</dl>
}
@if (probeDetailFields(annotation.behavioralProbe).length) {
<div class="bias-probe-details">
<div class="bias-probe-title">Behavioral probe</div>
<dl class="bias-details">
@for (field of probeDetailFields(annotation.behavioralProbe); track field.label) {
<div>
<dt>{{ field.label }}</dt>
<dd>{{ field.value }}</dd>
</div>
}
</dl>
</div>
}
@if (serverError(index); as error) { <div class="bias-error">{{ error }}</div> }
@for (field of fields; track field.key) {
@if (serverError(index, field.key); as error) { <div class="bias-error">{{ field.label }}: {{ error }}</div> }

View File

@ -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<BiasAnnotation[]>();
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<T>(value: T): T {
return typeof structuredClone === 'function' ? structuredClone(value) : JSON.parse(JSON.stringify(value));
}

View File

@ -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;

View File

@ -79,18 +79,16 @@
{{ visualRoleLabel() }}
</span>
@if (allBiasAnnotations().length) {
<span
<button
type="button"
class="llm-node-bias-summary"
[class.llm-node-bias-summary-active]="activeBiasAnnotationCount() > 0"
[title]="allBiasAnnotations().length + ' bias annotations; ' + activeBiasAnnotationCount() + ' active in this execution'">
[title]="'View ' + biasAnnotationBadgeLabel()"
(pointerdown)="$event.stopPropagation()"
(click)="openBiasAnnotations($event)">
<i class="bi bi-clipboard2-pulse-fill"></i>
{{ activeBiasAnnotationCount() }}/{{ allBiasAnnotations().length }}
</span>
}
@if (isBiasCapable()) {
<span class="llm-node-bias-capability" title="Bias annotations supported by this node type">
Bias capable
</span>
{{ biasAnnotationBadgeLabel() }}
</button>
}
@if (stepSkipReason(); as skipReason) {
<span class="llm-node-skip-reason" [title]="skipReason">
@ -113,6 +111,15 @@
}
</div>
@if (allBiasAnnotations().length) {
<app-bias-annotations
[blockId]="data?.data?.id ?? ''"
[block]="biasAnnotationsBlock()"
[annotations]="allBiasAnnotations()"
[readonly]="true"
[hideTrigger]="true" />
}
<div class="llm-body">
<div class="llm-column">
<div class="llm-column-title">Inputs</div>

View File

@ -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<TaskStepNodeComponent>;
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;

View File

@ -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;