feat(task-execution): show bias annotation details
This commit is contained in:
parent
f633493bd6
commit
c67163faaa
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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> }
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in New Issue