feat(bias-impact): complete side-effect policy, compare, report list and canvas highlighting

Finishes the bias impact experiments plan (docs/bias-impact-experiments-plan.md
steps 7-12):

- Side-effect policy selector: reuse the .llm-warning visual language for the
  external side-effects banner, add a REQUIRE_CONFIRMATION note.
- Full-flow compare ("Compare with baseline"): new bias-compare-dialog
  (service + host) triggering compareBiasExecutions and opening the shared
  report viewer; inline errors read from errors[].message/detail.
- Persisted reports: new bias-impact-report-list (list + detail in one view)
  wired into a new "Bias impact reports" tab in task-execution-viewer;
  404/403 on report detail show the same inline message on purpose.
- Canvas: annotation badge on generic-node (count, executable-probe
  indicator, severity from the backend catalog); new
  BiasComparisonViewStateService driving bias-active / downstream-changed /
  routing-change highlighting on task-step-node and custom-connection, fed by
  a highlightOnCanvas event from bias-impact-report-viewer wired in all three
  places that render it; legend + "back to normal view" action in the canvas
  toolbar.
- Fixed a bug where the bias variant context badge only rendered for
  simulated executions.
- Fixed "Measure bias impact" to stay visible-but-disabled with an
  explanatory tooltip while the baseline hasn't reached a final state,
  instead of being hidden outright, per the §12 checklist.
- Added a Retry action to the compare dialog's error state for parity with
  the report list.
- Added the end-to-end facade flow test (annotation -> capability -> isolated
  experiment -> report) plus coverage for all new components/services.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Lucio Lelii 2026-07-21 11:20:57 +02:00
parent 7bfef72f79
commit 94ce814a6f
37 changed files with 1383 additions and 21 deletions

View File

@ -6,3 +6,4 @@
<app-subflow-preview-dialog-host></app-subflow-preview-dialog-host>
<app-bias-impact-experiment-dialog-host></app-bias-impact-experiment-dialog-host>
<app-bias-rerun-dialog-host></app-bias-rerun-dialog-host>
<app-bias-compare-dialog-host></app-bias-compare-dialog-host>

View File

@ -7,10 +7,11 @@ import { NodeSettingsDialogHostComponent } from '@shared/node-settings-dialog/no
import { SubflowPreviewDialogHostComponent } from '@shared/subflow-preview-dialog/subflow-preview-dialog';
import { BiasImpactExperimentDialogHostComponent } from '@shared/bias-impact-experiment-dialog/bias-impact-experiment-dialog';
import { BiasRerunDialogHostComponent } from '@shared/bias-rerun-dialog/bias-rerun-dialog';
import { BiasCompareDialogHostComponent } from '@shared/bias-compare-dialog/bias-compare-dialog';
@Component({
selector: 'app-root',
imports: [RouterOutlet, ConfirmDialogHostComponent, GlobalNotificationComponent, HumanInteractionDialogHostComponent, NodeSettingsDialogHostComponent, SubflowPreviewDialogHostComponent, BiasImpactExperimentDialogHostComponent, BiasRerunDialogHostComponent],
imports: [RouterOutlet, ConfirmDialogHostComponent, GlobalNotificationComponent, HumanInteractionDialogHostComponent, NodeSettingsDialogHostComponent, SubflowPreviewDialogHostComponent, BiasImpactExperimentDialogHostComponent, BiasRerunDialogHostComponent, BiasCompareDialogHostComponent],
templateUrl: './app.html',
styleUrl: './app.css',
changeDetection: ChangeDetectionStrategy.OnPush

View File

@ -0,0 +1,73 @@
import { TestBed } from '@angular/core/testing';
import { BiasComparisonViewStateService } from './bias-comparison-view-state';
import { BiasImpactReport } from '@models/bias-impact';
const REPORT: BiasImpactReport = {
id: 'report-1',
experimentId: 'experiment-1',
kind: 'FULL_FLOW',
baselineExecutionId: 'baseline-1',
biasedExecutionId: 'variant-1',
nodeId: null,
annotationIds: ['annotation-1'],
repetitions: 1,
createdAt: '2026-07-21T10:00:00',
rawOutputsIncluded: true,
immediateImpact: {
outputChanged: true,
maximumTextDifference: 0.5,
changeRate: 1,
baselineOutput: {},
biasedOutputs: [{}]
},
downstreamImpact: [
{ nodeId: 'node-changed', nodeName: 'Changed', baselineStatus: 'COMPLETED', biasedStatus: 'COMPLETED', changed: true, baselineOutputs: {}, biasedOutputs: {} },
{ nodeId: 'node-unchanged', nodeName: 'Unchanged', baselineStatus: 'COMPLETED', biasedStatus: 'COMPLETED', changed: false, baselineOutputs: {}, biasedOutputs: {} }
],
routingChanges: [
{ nodeId: 'router-1', baselineBranch: 'false', biasedBranch: 'true' }
],
mockedSideEffects: [],
summary: 'Outputs changed',
warnings: []
};
describe('BiasComparisonViewStateService', () => {
let service: BiasComparisonViewStateService;
beforeEach(() => {
TestBed.configureTestingModule({});
service = TestBed.inject(BiasComparisonViewStateService);
});
it('starts with no active view', () => {
expect(service.activeView()).toBeNull();
expect(service.isNodeDownstreamChanged('node-changed')).toBe(false);
});
it('flags nodes present in downstreamImpact with changed=true', () => {
service.show({ report: REPORT });
expect(service.isNodeDownstreamChanged('node-changed')).toBe(true);
expect(service.isNodeDownstreamChanged('node-unchanged')).toBe(false);
expect(service.isNodeDownstreamChanged('node-unknown')).toBe(false);
});
it('flags the router node referenced by a routing change', () => {
service.show({ report: REPORT });
expect(service.isRoutingChangeSource('router-1')).toBe(true);
expect(service.isRoutingChangeSource('node-changed')).toBe(false);
});
it('flags only the connection matching the biased branch', () => {
service.show({ report: REPORT });
expect(service.isBiasedRoutingConnection('router-1', 'true')).toBe(true);
expect(service.isBiasedRoutingConnection('router-1', 'false')).toBe(false);
});
it('clears the active view', () => {
service.show({ report: REPORT });
service.clear();
expect(service.activeView()).toBeNull();
expect(service.isNodeDownstreamChanged('node-changed')).toBe(false);
});
});

View File

@ -0,0 +1,41 @@
import { Injectable, signal } from '@angular/core';
import { BiasImpactReport } from '@models/bias-impact';
export type BiasComparisonView = {
report: BiasImpactReport;
};
@Injectable({ providedIn: 'root' })
export class BiasComparisonViewStateService {
private readonly _activeView = signal<BiasComparisonView | null>(null);
readonly activeView = this._activeView.asReadonly();
show(view: BiasComparisonView) {
this._activeView.set(view);
}
clear() {
this._activeView.set(null);
}
isNodeDownstreamChanged(nodeId: string | null): boolean {
if (!nodeId) return false;
const report = this._activeView()?.report;
if (!report) return false;
return report.downstreamImpact.some((entry) => entry.nodeId === nodeId && entry.changed);
}
isRoutingChangeSource(nodeId: string | null): boolean {
if (!nodeId) return false;
const report = this._activeView()?.report;
if (!report) return false;
return report.routingChanges.some((change) => change.nodeId === nodeId);
}
isBiasedRoutingConnection(sourceNodeId: string | null, sourceOutputName: string | null): boolean {
if (!sourceNodeId || !sourceOutputName) return false;
const report = this._activeView()?.report;
if (!report) return false;
return report.routingChanges.some((change) => change.nodeId === sourceNodeId && change.biasedBranch === sourceOutputName);
}
}

View File

@ -0,0 +1,95 @@
import { TestBed } from '@angular/core/testing';
import { lastValueFrom, of } from 'rxjs';
import { vi } from 'vitest';
import { BiasAnnotation, isProbeExecutable } from '@models/flow';
import { BiasCapabilities, BiasImpactJob, BiasImpactReport } from '@models/bias-impact';
import { BlocksService } from '@services/blocks/blocks';
import { TaskExecutionsService } from '@services/task-executions/task-executions';
describe('bias impact main API flow (annotation -> capability -> isolated experiment -> report)', () => {
let blocks: BlocksService;
let executions: TaskExecutionsService;
const annotation: BiasAnnotation = {
id: 'annotation-1',
category: 'FRAMING',
severity: 'HIGH',
behavioralProbe: { activationMode: 'PROMPT_DIRECTIVE', instruction: 'Nudge the model towards a biased framing.' }
};
const capabilities: BiasCapabilities = {
blockType: 'LLMBlock',
supported: true,
isolatedExperimentSupported: true,
fullFlowExperimentSupported: true,
externalSideEffects: false,
configurationDependent: false,
activationModes: ['PROMPT_DIRECTIVE']
};
const report: BiasImpactReport = {
id: 'report-1',
experimentId: 'experiment-1',
kind: 'ISOLATED_STEP',
baselineExecutionId: 'execution-1',
biasedExecutionId: null,
nodeId: 'step-1',
annotationIds: ['annotation-1'],
repetitions: 3,
createdAt: '2026-07-21T10:00:00',
rawOutputsIncluded: true,
immediateImpact: { outputChanged: true, maximumTextDifference: 0.4, changeRate: 0.5, baselineOutput: { output: 'baseline' }, biasedOutputs: [{ output: 'variant' }] },
downstreamImpact: [],
routingChanges: [],
mockedSideEffects: [],
summary: 'The biased probe changed the output.',
warnings: []
};
const queuedJob: BiasImpactJob = {
id: 'job-1', status: 'QUEUED', executionId: 'execution-1', stepId: 'step-1',
createdAt: '2026-07-21T10:00:00', startedAt: null, completedAt: null,
reportId: null, report: null, errorCode: null, errorMessage: null, terminal: false
};
const completedJob: BiasImpactJob = { ...queuedJob, status: 'COMPLETED', completedAt: '2026-07-21T10:00:05', reportId: report.id, report, terminal: true };
beforeEach(() => {
TestBed.configureTestingModule({});
blocks = TestBed.inject(BlocksService);
executions = TestBed.inject(TaskExecutionsService);
blocks.blocksCallService = {
retrieveBiasCapabilities: vi.fn().mockReturnValue(of(capabilities))
} as unknown as typeof blocks.blocksCallService;
executions.taskExecutionsCallService = {
runBiasImpactExperiment: vi.fn().mockReturnValue(of(queuedJob)),
getBiasImpactJob: vi.fn().mockReturnValue(of(completedJob)),
getBiasImpactReport: vi.fn().mockReturnValue(of(report))
} as unknown as typeof executions.taskExecutionsCallService;
});
it('runs an isolated experiment on an executable annotation and opens the resulting report', async () => {
expect(isProbeExecutable(annotation.behavioralProbe)).toBe(true);
const resolvedCapabilities = await lastValueFrom(blocks.retrieveBiasCapabilities('LLMBlock'));
expect(resolvedCapabilities.isolatedExperimentSupported).toBe(true);
const job = await lastValueFrom(executions.runBiasImpactExperiment('execution-1', 'step-1', {
annotationIds: [annotation.id!],
repetitions: 3,
includeRawOutputs: true,
externalSideEffectPolicy: 'BLOCK',
confirmExternalSideEffects: false
}));
expect(job.status).toBe('QUEUED');
const terminalJob = await lastValueFrom(executions.pollBiasImpactJob(job.id));
expect(terminalJob.status).toBe('COMPLETED');
expect(terminalJob.report?.id).toBe('report-1');
const openedReport = await lastValueFrom(executions.getBiasImpactReport(terminalJob.reportId!));
expect(openedReport).toEqual(report);
});
});

View File

@ -0,0 +1,21 @@
import { TestBed } from '@angular/core/testing';
import { BiasCompareDialogService } from './bias-compare-dialog';
describe('BiasCompareDialogService', () => {
let service: BiasCompareDialogService;
beforeEach(() => {
TestBed.configureTestingModule({});
service = TestBed.inject(BiasCompareDialogService);
});
it('opens with the baseline/biased execution pair and closes back to null', () => {
expect(service.state()).toBeNull();
service.open({ baselineExecutionId: 'baseline-1', biasedExecutionId: 'variant-1' });
expect(service.state()).toEqual({ baselineExecutionId: 'baseline-1', biasedExecutionId: 'variant-1' });
service.close();
expect(service.state()).toBeNull();
});
});

View File

@ -0,0 +1,20 @@
import { Injectable, signal } from '@angular/core';
export type BiasCompareDialogInput = {
baselineExecutionId: string;
biasedExecutionId: string;
};
@Injectable({ providedIn: 'root' })
export class BiasCompareDialogService {
private readonly _state = signal<BiasCompareDialogInput | null>(null);
readonly state = this._state.asReadonly();
open(input: BiasCompareDialogInput) {
this._state.set(input);
}
close() {
this._state.set(null);
}
}

View File

@ -0,0 +1,9 @@
.bias-compare-dialog__backdrop { background: rgb(0 0 0 / .5); inset: 0; position: fixed; z-index: 10020; }
.bias-compare-dialog { background: #fff; border: 1px solid #cbd5e1; border-radius: 1rem; box-shadow: 0 25px 50px rgb(15 23 42 / .25); display: flex; flex-direction: column; left: 50%; max-height: min(88vh, 800px); max-width: 760px; position: fixed; top: 50%; transform: translate(-50%, -50%); width: min(94vw, 760px); z-index: 10021; }
header { align-items: center; border-bottom: 1px solid #e2e8f0; display: flex; gap: 1rem; justify-content: space-between; padding: 1rem 1.25rem; }
header h3, header p { margin: 0; }
header p { color: #64748b; font-size: .85rem; }
.bias-compare-dialog__content { display: grid; gap: .9rem; overflow: auto; padding: 1.25rem; }
.bias-compare-dialog__progress { background: #eff6ff; border-left: 3px solid #2563eb; color: #1e3a8a; margin: 0; padding: .6rem .7rem; }
.bias-compare-dialog__error { background: #fff1f2; border-left: 3px solid #e11d48; color: #9f1239; margin: 0; padding: .6rem .7rem; }
.bias-compare-dialog__error-row { align-items: center; display: flex; gap: .6rem; justify-content: space-between; }

View File

@ -0,0 +1,23 @@
@if (state()) {
<div class="bias-compare-dialog__backdrop" (click)="close()"></div>
<section class="bias-compare-dialog" role="dialog" aria-modal="true" aria-label="Compare with baseline">
<header>
<div><h3>Compare with baseline</h3><p>Baseline vs. biased execution outcome</p></div>
<button type="button" mat-stroked-button (click)="close()">Close</button>
</header>
<div class="bias-compare-dialog__content">
@if (loading()) {
<p class="bias-compare-dialog__progress">Comparing executions…</p>
}
@if (inlineError(); as error) {
<div class="bias-compare-dialog__error-row">
<p class="bias-compare-dialog__error">{{ error }}</p>
<button type="button" mat-stroked-button (click)="retry()">Retry</button>
</div>
}
@if (report(); as completedReport) {
<app-bias-impact-report-viewer [report]="completedReport" (highlightOnCanvas)="highlightOnCanvas()" />
}
</div>
</section>
}

View File

@ -0,0 +1,102 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { of, throwError } from 'rxjs';
import { vi } from 'vitest';
import { BiasCompareDialogHostComponent } from './bias-compare-dialog';
import { BiasCompareDialogService } from '@services/dialogs/bias-compare-dialog';
import { TaskExecutionsService } from '@services/task-executions/task-executions';
import { BiasImpactReport } from '@models/bias-impact';
const REPORT: BiasImpactReport = {
id: 'report-1',
experimentId: 'experiment-1',
kind: 'FULL_FLOW',
baselineExecutionId: 'baseline-1',
biasedExecutionId: 'variant-1',
nodeId: null,
annotationIds: ['annotation-1'],
repetitions: 1,
createdAt: '2026-07-21T10:00:00',
rawOutputsIncluded: true,
immediateImpact: {
outputChanged: true,
maximumTextDifference: 0.5,
changeRate: 1,
baselineOutput: { output: 'baseline' },
biasedOutputs: [{ output: 'variant' }]
},
downstreamImpact: [],
routingChanges: [],
mockedSideEffects: [],
summary: 'Outputs changed',
warnings: []
};
describe('BiasCompareDialogHostComponent', () => {
let fixture: ComponentFixture<BiasCompareDialogHostComponent>;
let dialog: BiasCompareDialogService;
let compareBiasExecutions: ReturnType<typeof vi.fn>;
beforeEach(async () => {
compareBiasExecutions = vi.fn().mockReturnValue(of(REPORT));
await TestBed.configureTestingModule({
imports: [BiasCompareDialogHostComponent],
providers: [
{ provide: TaskExecutionsService, useValue: { compareBiasExecutions } }
]
}).compileComponents();
fixture = TestBed.createComponent(BiasCompareDialogHostComponent);
dialog = TestBed.inject(BiasCompareDialogService);
});
it('triggers the compare call on open and shows the resulting report', () => {
fixture.detectChanges();
dialog.open({ baselineExecutionId: 'baseline-1', biasedExecutionId: 'variant-1' });
fixture.detectChanges();
expect(compareBiasExecutions).toHaveBeenCalledWith('baseline-1', 'variant-1', true);
expect(fixture.componentInstance.report()).toEqual(REPORT);
expect(fixture.componentInstance.loading()).toBe(false);
expect(fixture.nativeElement.querySelector('app-bias-impact-report-viewer')).not.toBeNull();
});
it('shows the backend errors[].message inline when the compare is rejected', () => {
compareBiasExecutions.mockReturnValue(throwError(() => ({
status: 400,
error: { detail: 'generic', errors: [{ code: 'BIAS_EXECUTION_NOT_FINAL', message: 'The variant has not finished yet.' }] }
})));
fixture.detectChanges();
dialog.open({ baselineExecutionId: 'baseline-1', biasedExecutionId: 'variant-1' });
fixture.detectChanges();
expect(fixture.componentInstance.inlineError()).toBe('The variant has not finished yet.');
expect(fixture.componentInstance.report()).toBeNull();
});
it('retries the same comparison and clears the error on success', () => {
compareBiasExecutions.mockReturnValue(throwError(() => ({ status: 0 })));
fixture.detectChanges();
dialog.open({ baselineExecutionId: 'baseline-1', biasedExecutionId: 'variant-1' });
fixture.detectChanges();
expect(fixture.componentInstance.inlineError()).not.toBeNull();
compareBiasExecutions.mockReturnValue(of(REPORT));
fixture.componentInstance.retry();
fixture.detectChanges();
expect(compareBiasExecutions).toHaveBeenLastCalledWith('baseline-1', 'variant-1', true);
expect(fixture.componentInstance.inlineError()).toBeNull();
expect(fixture.componentInstance.report()).toEqual(REPORT);
});
it('resets state and closes the dialog', () => {
fixture.detectChanges();
dialog.open({ baselineExecutionId: 'baseline-1', biasedExecutionId: 'variant-1' });
fixture.detectChanges();
fixture.componentInstance.close();
fixture.detectChanges();
expect(dialog.state()).toBeNull();
});
});

View File

@ -0,0 +1,84 @@
import { ChangeDetectionStrategy, Component, effect, inject, signal } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { BiasImpactReportViewerComponent } from '@shared/bias-impact-report-viewer/bias-impact-report-viewer';
import { BiasCompareDialogService } from '@services/dialogs/bias-compare-dialog';
import { BiasComparisonViewStateService } from '@services/bias/bias-comparison-view-state';
import { TaskExecutionsService } from '@services/task-executions/task-executions';
import { BiasImpactReport } from '@models/bias-impact';
@Component({
selector: 'app-bias-compare-dialog-host',
standalone: true,
imports: [MatButtonModule, BiasImpactReportViewerComponent],
templateUrl: './bias-compare-dialog.html',
styleUrl: './bias-compare-dialog.css',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class BiasCompareDialogHostComponent {
private readonly dialog = inject(BiasCompareDialogService);
private readonly executions = inject(TaskExecutionsService);
private readonly comparisonViewState = inject(BiasComparisonViewStateService);
readonly state = this.dialog.state;
readonly loading = signal(false);
readonly inlineError = signal<string | null>(null);
readonly report = signal<BiasImpactReport | null>(null);
constructor() {
effect(() => {
const state = this.state();
this.report.set(null);
this.inlineError.set(null);
this.loading.set(false);
if (!state) return;
this.runCompare(state.baselineExecutionId, state.biasedExecutionId);
});
}
retry() {
const state = this.state();
if (!state) return;
this.runCompare(state.baselineExecutionId, state.biasedExecutionId);
}
close() {
this.dialog.close();
}
highlightOnCanvas() {
const report = this.report();
if (!report) return;
this.comparisonViewState.show({ report });
this.close();
}
private runCompare(baselineExecutionId: string, biasedExecutionId: string) {
this.inlineError.set(null);
this.loading.set(true);
this.executions.compareBiasExecutions(baselineExecutionId, biasedExecutionId, true).subscribe({
next: (report) => {
this.loading.set(false);
this.report.set(report);
},
error: (error) => {
this.loading.set(false);
this.inlineError.set(this.extractErrorMessage(error));
}
});
}
private extractErrorMessage(error: unknown): string {
const body = (error as { error?: unknown })?.error;
if (body && typeof body === 'object') {
const record = body as Record<string, unknown>;
const errors = Array.isArray(record['errors']) ? record['errors'] : [];
const first = errors[0];
if (first && typeof first === 'object' && typeof (first as Record<string, unknown>)['message'] === 'string') {
return (first as Record<string, unknown>)['message'] as string;
}
if (typeof record['detail'] === 'string' && record['detail']) return record['detail'];
}
return error instanceof Error ? error.message : 'Unable to compare the baseline and biased executions.';
}
}

View File

@ -3,7 +3,7 @@
<section class="bias-experiment-dialog" role="dialog" aria-modal="true" aria-label="Measure bias impact">
<header><div><h3>Measure bias impact</h3><p>{{ currentState.nodeName }}</p></div><button type="button" mat-stroked-button (click)="close()">Close</button></header>
@if (report(); as completedReport) {
<div class="bias-experiment-dialog__content"><app-bias-impact-report-viewer [report]="completedReport" /></div>
<div class="bias-experiment-dialog__content"><app-bias-impact-report-viewer [report]="completedReport" (highlightOnCanvas)="highlightOnCanvas()" /></div>
} @else {
<div class="bias-experiment-dialog__content">
<p>Run the selected probes against this completed execution step.</p>

View File

@ -5,6 +5,7 @@ import { BiasImpactReportViewerComponent } from '@shared/bias-impact-report-view
import { SideEffectPolicySelectorComponent } from '@shared/side-effect-policy-selector/side-effect-policy-selector';
import { ConfirmDialogService } from '@services/dialogs/confirm-dialog';
import { BiasImpactExperimentDialogService } from '@services/dialogs/bias-impact-experiment-dialog';
import { BiasComparisonViewStateService } from '@services/bias/bias-comparison-view-state';
import { NotificationService } from '@services/notifications/notification';
import { TaskExecutionsService } from '@services/task-executions/task-executions';
import { BiasImpactJob, BiasImpactReport, BiasSideEffectError, ExternalSideEffectPolicy } from '@models/bias-impact';
@ -23,6 +24,7 @@ export class BiasImpactExperimentDialogHostComponent {
private readonly executions = inject(TaskExecutionsService);
private readonly confirmation = inject(ConfirmDialogService);
private readonly notifications = inject(NotificationService);
private readonly comparisonViewState = inject(BiasComparisonViewStateService);
private readonly destroyRef = inject(DestroyRef);
private pollSubscription: Subscription | null = null;
@ -93,6 +95,13 @@ export class BiasImpactExperimentDialogHostComponent {
this.dialog.close();
}
highlightOnCanvas() {
const report = this.report();
if (!report) return;
this.comparisonViewState.show({ report });
this.close();
}
private startPolling(job: BiasImpactJob) {
this.currentJob.set(job);
this.pollSubscription = this.executions.pollBiasImpactJob(job.id).subscribe({

View File

@ -0,0 +1,34 @@
.bias-report-list { display: grid; gap: .5rem; padding: .75rem; }
.bias-report-list__progress { background: #eff6ff; border-left: 3px solid #2563eb; color: #1e3a8a; margin: 0; padding: .6rem .7rem; font-size: .84rem; }
.bias-report-list__error { background: #fff1f2; border-left: 3px solid #e11d48; color: #9f1239; margin: 0; padding: .6rem .7rem; font-size: .84rem; }
.bias-report-list__error-row { align-items: center; display: flex; gap: .6rem; justify-content: space-between; }
.bias-report-list__empty { color: #64748b; font-size: .84rem; margin: 0; padding: .5rem; }
.bias-report-list__row {
align-items: center;
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: .5rem;
cursor: pointer;
display: grid;
gap: .2rem .75rem;
grid-template-columns: auto auto 1fr auto auto auto;
padding: .6rem .75rem;
text-align: left;
transition: background-color 120ms ease;
}
.bias-report-list__row:hover { background: #f1f5f9; }
.bias-report-list__kind { background: #e0e7ff; border-radius: 999px; color: #3730a3; font-size: .68rem; font-weight: 700; padding: .15rem .45rem; white-space: nowrap; }
.bias-report-list__date { color: #64748b; font-size: .78rem; white-space: nowrap; }
.bias-report-list__summary { color: #1e293b; font-size: .84rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.bias-report-list__node { color: #64748b; font-size: .72rem; overflow-wrap: anywhere; }
.bias-report-list__annotations { color: #64748b; font-size: .76rem; white-space: nowrap; }
.bias-report-list__changed { color: #64748b; font-size: .76rem; font-weight: 600; white-space: nowrap; }
.bias-report-list__changed.changed { color: #b45309; }
.bias-report-list__detail { display: grid; gap: .9rem; padding: .75rem; }
@media (max-width: 640px) {
.bias-report-list__row { grid-template-columns: 1fr; }
}

View File

@ -0,0 +1,40 @@
@if (detailMode()) {
<div class="bias-report-list__detail">
<button type="button" mat-stroked-button (click)="closeDetail()">← Back to reports</button>
@if (detailLoading()) {
<p class="bias-report-list__progress">Loading report…</p>
}
@if (detailError(); as error) {
<p class="bias-report-list__error">{{ error }}</p>
}
@if (selectedReport(); as report) {
<app-bias-impact-report-viewer [report]="report" (highlightOnCanvas)="highlightOnCanvas()" />
}
</div>
} @else {
<div class="bias-report-list">
@if (loading()) {
<p class="bias-report-list__progress">Loading bias impact reports…</p>
} @else if (listError(); as error) {
<div class="bias-report-list__error-row">
<p class="bias-report-list__error">{{ error }}</p>
<button type="button" mat-stroked-button (click)="retry()">Retry</button>
</div>
} @else if (!reports().length) {
<p class="bias-report-list__empty">No bias impact reports for this execution yet.</p>
} @else {
@for (report of reports(); track report.id) {
<button type="button" class="bias-report-list__row" (click)="openDetail(report.id)">
<span class="bias-report-list__kind">{{ report.kind }}</span>
<span class="bias-report-list__date">{{ report.createdAt | date:'medium' }}</span>
<span class="bias-report-list__summary">{{ report.summary }}</span>
@if (report.nodeId) { <code class="bias-report-list__node">{{ report.nodeId }}</code> }
<span class="bias-report-list__annotations">{{ annotationCount(report) }} annotation(s)</span>
<span class="bias-report-list__changed" [class.changed]="report.immediateImpact.outputChanged">
{{ report.immediateImpact.outputChanged ? 'Changed' : 'Unchanged' }}
</span>
</button>
}
}
</div>
}

View File

@ -0,0 +1,119 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { of, throwError } from 'rxjs';
import { vi } from 'vitest';
import { BiasImpactReportListComponent } from './bias-impact-report-list';
import { TaskExecutionsService } from '@services/task-executions/task-executions';
import { BiasImpactReport } from '@models/bias-impact';
function makeReport(overrides: Partial<BiasImpactReport> = {}): BiasImpactReport {
return {
id: 'report-1',
experimentId: 'experiment-1',
kind: 'ISOLATED_STEP',
baselineExecutionId: 'execution-1',
biasedExecutionId: null,
nodeId: 'node-1',
annotationIds: ['annotation-1', 'annotation-2'],
repetitions: 3,
createdAt: '2026-07-21T10:00:00',
rawOutputsIncluded: true,
immediateImpact: {
outputChanged: true,
maximumTextDifference: 0.4,
changeRate: 0.5,
baselineOutput: { output: 'baseline' },
biasedOutputs: [{ output: 'variant' }]
},
downstreamImpact: [],
routingChanges: [],
mockedSideEffects: [],
summary: 'Output changed',
warnings: [],
...overrides
};
}
describe('BiasImpactReportListComponent', () => {
let fixture: ComponentFixture<BiasImpactReportListComponent>;
let listBiasImpactReports: ReturnType<typeof vi.fn>;
let getBiasImpactReport: ReturnType<typeof vi.fn>;
beforeEach(async () => {
listBiasImpactReports = vi.fn().mockReturnValue(of([makeReport()]));
getBiasImpactReport = vi.fn().mockReturnValue(of(makeReport()));
await TestBed.configureTestingModule({
imports: [BiasImpactReportListComponent],
providers: [
{ provide: TaskExecutionsService, useValue: { listBiasImpactReports, getBiasImpactReport } }
]
}).compileComponents();
fixture = TestBed.createComponent(BiasImpactReportListComponent);
});
it('loads and renders the reports for the given execution', () => {
fixture.componentRef.setInput('executionId', 'execution-1');
fixture.detectChanges();
expect(listBiasImpactReports).toHaveBeenCalledWith('execution-1');
expect(fixture.componentInstance.reports().length).toBe(1);
expect(fixture.nativeElement.querySelectorAll('.bias-report-list__row').length).toBe(1);
});
it('shows an empty state when there are no reports', () => {
listBiasImpactReports.mockReturnValue(of([]));
fixture.componentRef.setInput('executionId', 'execution-1');
fixture.detectChanges();
expect(fixture.nativeElement.textContent).toContain('No bias impact reports');
});
it('shows an inline error with a retry action when the list fails to load', () => {
listBiasImpactReports.mockReturnValue(throwError(() => new Error('network down')));
fixture.componentRef.setInput('executionId', 'execution-1');
fixture.detectChanges();
expect(fixture.componentInstance.listError()).toContain('Unable to load');
listBiasImpactReports.mockReturnValue(of([makeReport()]));
fixture.componentInstance.retry();
fixture.detectChanges();
expect(fixture.componentInstance.listError()).toBeNull();
expect(fixture.componentInstance.reports().length).toBe(1);
});
it('opens the report detail via getBiasImpactReport and can navigate back to the list', () => {
fixture.componentRef.setInput('executionId', 'execution-1');
fixture.detectChanges();
fixture.componentInstance.openDetail('report-1');
fixture.detectChanges();
expect(getBiasImpactReport).toHaveBeenCalledWith('report-1');
expect(fixture.componentInstance.detailMode()).toBe(true);
expect(fixture.nativeElement.querySelector('app-bias-impact-report-viewer')).not.toBeNull();
fixture.componentInstance.closeDetail();
fixture.detectChanges();
expect(fixture.componentInstance.detailMode()).toBe(false);
});
it('shows the same inline message for a report that is missing or not accessible', () => {
getBiasImpactReport.mockReturnValue(throwError(() => ({ status: 404 })));
fixture.componentRef.setInput('executionId', 'execution-1');
fixture.detectChanges();
fixture.componentInstance.openDetail('missing-report');
fixture.detectChanges();
expect(fixture.componentInstance.detailError()).toBe('Report not found or not accessible.');
getBiasImpactReport.mockReturnValue(throwError(() => ({ status: 403 })));
fixture.componentInstance.openDetail('someone-elses-report');
fixture.detectChanges();
expect(fixture.componentInstance.detailError()).toBe('Report not found or not accessible.');
});
});

View File

@ -0,0 +1,103 @@
import { CommonModule } from '@angular/common';
import { ChangeDetectionStrategy, Component, computed, effect, inject, input, signal } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { BiasImpactReport } from '@models/bias-impact';
import { TaskExecutionsService } from '@services/task-executions/task-executions';
import { BiasComparisonViewStateService } from '@services/bias/bias-comparison-view-state';
import { BiasImpactReportViewerComponent } from '@shared/bias-impact-report-viewer/bias-impact-report-viewer';
@Component({
selector: 'app-bias-impact-report-list',
standalone: true,
imports: [CommonModule, MatButtonModule, BiasImpactReportViewerComponent],
templateUrl: './bias-impact-report-list.html',
styleUrl: './bias-impact-report-list.css',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class BiasImpactReportListComponent {
private readonly executions = inject(TaskExecutionsService);
private readonly comparisonViewState = inject(BiasComparisonViewStateService);
private lastExecutionId: string | null = null;
readonly executionId = input<string | null>(null);
readonly reports = signal<BiasImpactReport[]>([]);
readonly loading = signal(false);
readonly listError = signal<string | null>(null);
readonly selectedReport = signal<BiasImpactReport | null>(null);
readonly detailLoading = signal(false);
readonly detailError = signal<string | null>(null);
readonly detailMode = computed(() =>
this.selectedReport() !== null || this.detailLoading() || this.detailError() !== null
);
constructor() {
effect(() => {
const executionId = this.executionId();
if (executionId === this.lastExecutionId) return;
this.lastExecutionId = executionId;
this.closeDetail();
this.loadReports(executionId);
});
}
retry() {
this.loadReports(this.executionId());
}
openDetail(reportId: string) {
this.selectedReport.set(null);
this.detailError.set(null);
this.detailLoading.set(true);
this.executions.getBiasImpactReport(reportId).subscribe({
next: (report) => {
this.detailLoading.set(false);
this.selectedReport.set(report);
},
error: () => {
this.detailLoading.set(false);
this.detailError.set('Report not found or not accessible.');
}
});
}
closeDetail() {
this.selectedReport.set(null);
this.detailLoading.set(false);
this.detailError.set(null);
}
highlightOnCanvas() {
const report = this.selectedReport();
if (!report) return;
this.comparisonViewState.show({ report });
}
annotationCount(report: BiasImpactReport): number {
return report.annotationIds?.length ?? 0;
}
private loadReports(executionId: string | null) {
if (!executionId) {
this.reports.set([]);
this.listError.set(null);
this.loading.set(false);
return;
}
this.loading.set(true);
this.listError.set(null);
this.executions.listBiasImpactReports(executionId).subscribe({
next: (reports) => {
this.loading.set(false);
this.reports.set(reports);
},
error: () => {
this.loading.set(false);
this.listError.set('Unable to load the bias impact reports for this execution.');
}
});
}
}

View File

@ -2,6 +2,9 @@
.bias-impact-report__header, .bias-impact-report__section-heading, .bias-impact-report__node-heading, .bias-impact-report__routing { align-items: center; display: flex; gap: .65rem; justify-content: space-between; }
.bias-impact-report__header h3, .bias-impact-report__section h4 { margin: 0; }
.bias-impact-report__header time { color: #64748b; font-size: .84rem; }
.bias-impact-report__header-actions { align-items: center; display: flex; gap: .65rem; }
.bias-impact-report__highlight-btn { background: #ccfbf1; border: 1px solid #99f6e4; border-radius: .4rem; color: #0f766e; cursor: pointer; font-size: .78rem; font-weight: 600; padding: .35rem .65rem; }
.bias-impact-report__highlight-btn:hover { background: #99f6e4; }
.bias-impact-report__kind { background: #e0e7ff; border-radius: 999px; color: #3730a3; font-size: .72rem; font-weight: 700; padding: .2rem .5rem; }
.bias-impact-report__metadata { display: grid; gap: .55rem; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); margin: 0; }
.bias-impact-report__metadata div { background: #f8fafc; border-radius: .4rem; padding: .55rem; }

View File

@ -5,7 +5,12 @@
<span class="bias-impact-report__kind">{{ currentReport.kind }}</span>
<h3>Bias impact report</h3>
</div>
<time [attr.datetime]="currentReport.createdAt">{{ currentReport.createdAt | date:'medium' }}</time>
<div class="bias-impact-report__header-actions">
<button type="button" class="bias-impact-report__highlight-btn" (click)="highlightOnCanvas.emit()">
Highlight on canvas
</button>
<time [attr.datetime]="currentReport.createdAt">{{ currentReport.createdAt | date:'medium' }}</time>
</div>
</header>
<dl class="bias-impact-report__metadata">

View File

@ -61,4 +61,14 @@ describe('BiasImpactReportViewerComponent', () => {
expect(text).toContain('Changed node');
expect(text).not.toContain('Same node');
});
it('emits highlightOnCanvas when the highlight button is clicked', () => {
fixture.detectChanges();
let emitted = false;
fixture.componentInstance.highlightOnCanvas.subscribe(() => { emitted = true; });
(fixture.nativeElement.querySelector('.bias-impact-report__highlight-btn') as HTMLButtonElement).click();
expect(emitted).toBe(true);
});
});

View File

@ -1,5 +1,5 @@
import { CommonModule, DatePipe } from '@angular/common';
import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { BiasDownstreamImpactEntry, BiasImpactReport } from '@models/bias-impact';
import { BiasOutputDiffComponent } from '../bias-output-diff/bias-output-diff';
@ -14,6 +14,7 @@ import { BiasOutputDiffComponent } from '../bias-output-diff/bias-output-diff';
})
export class BiasImpactReportViewerComponent {
@Input() report: BiasImpactReport | null = null;
@Output() highlightOnCanvas = new EventEmitter<void>();
changedOnly = false;

View File

@ -0,0 +1,64 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { GraphSelectionService } from '@services/graph-selection/graph-selection';
import { BiasComparisonViewStateService } from '@services/bias/bias-comparison-view-state';
import { BiasImpactReport } from '@models/bias-impact';
import { CustomConnectionComponent } from './custom-connection';
const REPORT: BiasImpactReport = {
id: 'report-1',
experimentId: 'experiment-1',
kind: 'FULL_FLOW',
baselineExecutionId: 'baseline-1',
biasedExecutionId: 'variant-1',
nodeId: null,
annotationIds: ['annotation-1'],
repetitions: 1,
createdAt: '2026-07-21T10:00:00',
rawOutputsIncluded: true,
immediateImpact: { outputChanged: true, maximumTextDifference: 0.5, changeRate: 1, baselineOutput: {}, biasedOutputs: [{}] },
downstreamImpact: [],
routingChanges: [{ nodeId: 'router-1', baselineBranch: 'false', biasedBranch: 'true' }],
mockedSideEffects: [],
summary: 'Outputs changed',
warnings: []
};
describe('CustomConnectionComponent bias routing highlight', () => {
let fixture: ComponentFixture<CustomConnectionComponent>;
let component: CustomConnectionComponent;
let comparisonViewState: BiasComparisonViewStateService;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [CustomConnectionComponent],
providers: [
{ provide: GraphSelectionService, useValue: { selectedConnectionId: () => null, selectConnection: () => {}, requestDeleteSelectedConnection: () => {} } }
]
}).compileComponents();
fixture = TestBed.createComponent(CustomConnectionComponent);
component = fixture.componentInstance;
comparisonViewState = TestBed.inject(BiasComparisonViewStateService);
component.data = { id: 'conn-1', source: 'router-1', sourceOutput: 'true', target: 'node-2', targetInput: 'input' } as any;
component.start = { x: 0, y: 0 };
component.end = { x: 100, y: 0 };
fixture.detectChanges();
});
it('uses the default color when no bias report is highlighted', () => {
expect(component.strokeColor).toBe('#4682b4');
});
it('highlights the connection matching the report biased branch', () => {
comparisonViewState.show({ report: REPORT });
expect(component.strokeColor).toBe('#b45309');
expect(component.strokeWidth).toBe(6);
});
it('does not highlight the connection for the baseline branch', () => {
component.data = { ...component.data, sourceOutput: 'false' } as any;
comparisonViewState.show({ report: REPORT });
expect(component.strokeColor).toBe('#4682b4');
});
});

View File

@ -2,6 +2,7 @@ import { Component, inject, Input } from '@angular/core';
import { ClassicPreset } from 'rete';
import { FLOW_DEPENDANT_PORT_KEY, FLOW_DEPENDENCY_PORT_KEY } from '@models/flow';
import { GraphSelectionService } from '@services/graph-selection/graph-selection';
import { BiasComparisonViewStateService } from '@services/bias/bias-comparison-view-state';
@Component({
selector: 'app-custom-connection',
@ -83,6 +84,7 @@ import { GraphSelectionService } from '@services/graph-selection/graph-selection
})
export class CustomConnectionComponent {
private readonly graphSelection = inject(GraphSelectionService);
private readonly biasComparisonViewState = inject(BiasComparisonViewStateService);
@Input() data!: ClassicPreset.Connection<ClassicPreset.Node, ClassicPreset.Node>;
@Input() start!: { x: number; y: number };
@Input() end!: { x: number; y: number };
@ -100,13 +102,22 @@ export class CustomConnectionComponent {
return this.graphSelection.selectedConnectionId() === this.connectionId;
}
get isBiasRoutingChange(): boolean {
return this.biasComparisonViewState.isBiasedRoutingConnection(
this.data?.source != null ? String(this.data.source) : null,
this.data?.sourceOutput != null ? String(this.data.sourceOutput) : null
);
}
get strokeColor(): string {
if (this.isSelected) return '#f97316';
if (this.isBiasRoutingChange) return '#b45309';
return this.isDependencyConnection ? '#7c8ba1' : '#4682b4';
}
get strokeWidth(): number {
if (this.isSelected) return this.isDependencyConnection ? 3.25 : 6;
if (this.isBiasRoutingChange) return 6;
return this.isDependencyConnection ? 2.25 : 5;
}

View File

@ -445,6 +445,69 @@
font-size: 11px;
}
.llm-bias-badge-wrap {
position: relative;
margin-left: 0;
}
.llm-bias-badge {
min-width: 24px;
height: 24px;
padding: 0 6px;
border-radius: 999px;
border: 1px solid #ddd6fe;
background: #7c3aed;
color: #f5f3ff;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 4px;
font-size: 12px;
font-weight: 700;
box-shadow: 0 0 0 2px rgba(76, 29, 149, 0.18);
}
.llm-bias-badge-wrap-probe .llm-bias-badge {
background: #4c1d95;
border-color: #c4b5fd;
box-shadow: 0 0 0 2px rgba(76, 29, 149, 0.32);
}
.llm-bias-badge-tooltip {
position: absolute;
top: calc(100% + 8px);
right: 0;
min-width: 200px;
max-width: 260px;
border: 1px solid #ddd6fe;
border-radius: 8px;
background: #f5f3ff;
color: #4c1d95;
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.2);
padding: 8px;
z-index: 80;
opacity: 0;
visibility: hidden;
transform: translateY(-2px);
transition: opacity 0.15s ease, transform 0.15s ease, visibility 0.15s ease;
}
.llm-bias-badge-wrap:hover .llm-bias-badge-tooltip {
opacity: 1;
visibility: visible;
transform: translateY(0);
}
.llm-bias-badge-title {
font-size: 11px;
font-weight: 700;
margin-bottom: 4px;
}
.llm-bias-badge-item {
font-size: 11px;
}
.llm-body {
padding: 12px;

View File

@ -72,6 +72,22 @@
</div>
</div>
}
@if (biasAnnotationBadge; as biasBadge) {
<div class="llm-bias-badge-wrap" [class.llm-bias-badge-wrap-probe]="biasBadge.hasExecutableProbe">
<div class="llm-bias-badge">
<i class="bi bi-clipboard2-pulse-fill"></i>
<span>{{ biasBadge.count }}</span>
</div>
<div class="llm-bias-badge-tooltip">
<div class="llm-bias-badge-title">
{{ biasBadge.count }} bias annotation{{ biasBadge.count === 1 ? '' : 's' }}@if (biasBadge.maxSeverityLabel) {, max severity: {{ biasBadge.maxSeverityLabel }}}
</div>
@if (biasBadge.hasExecutableProbe) {
<div class="llm-bias-badge-item">Includes an executable behavioral probe</div>
}
</div>
</div>
}
@if (!isReadonly && deleteConfirmOpen) {
<div class="llm-delete-confirm" (pointerdown)="$event.stopPropagation()" (click)="$event.stopPropagation()">
<span class="llm-delete-confirm-text">Delete node?</span>

View File

@ -94,6 +94,35 @@ describe('GenericNodeComponent', () => {
expect(nodeData['__focusOpen']).toBe(false);
});
it('has no bias annotation badge when the block has no annotations', () => {
expect(component.biasAnnotationBadge).toBeNull();
});
it('computes the bias annotation badge from the node annotations and the severity catalog', () => {
const blocks = TestBed.inject(BlocksService) as any;
blocks.biasAnnotationsDescriptor.mockReturnValue({
options: {
severity: [
{ value: 'LOW', label: 'Low' },
{ value: 'HIGH', label: 'High' }
]
}
});
component.data.data = {
...component.data.data,
biasAnnotations: [
{ id: 'a1', severity: 'LOW' },
{ id: 'a2', severity: 'HIGH', behavioralProbe: { activationMode: 'PROMPT_DIRECTIVE', instruction: 'do it' } }
]
};
expect(component.biasAnnotationBadge).toEqual({
count: 2,
hasExecutableProbe: true,
maxSeverityLabel: 'High'
});
});
it('preserves id, position and bias annotations during block regeneration', async () => {
const blocks = TestBed.inject(BlocksService) as any;
const replacement = vi.fn().mockResolvedValue(undefined);

View File

@ -2,7 +2,7 @@ import { CommonModule } from '@angular/common';
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, effect, ElementRef, HostBinding, HostListener, inject, Input, OnDestroy } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { MatTooltipModule } from '@angular/material/tooltip';
import { BiasAnnotation, BlockType, currentFlowPortValueKind, flowValueKindLabel, FlowBlock, FlowData, FlowPort, FlowValueKind, FLOW_DEPENDANT_PORT_KEY, FLOW_DEPENDENCY_PORT_KEY, normalizeFlowPortValueKinds } from '@models/flow';
import { BiasAnnotation, BiasAnnotationsDescriptor, BlockType, currentFlowPortValueKind, flowValueKindLabel, FlowBlock, FlowData, FlowPort, FlowValueKind, FLOW_DEPENDANT_PORT_KEY, FLOW_DEPENDENCY_PORT_KEY, isProbeExecutable, normalizeFlowPortValueKinds } from '@models/flow';
import { BiasAnnotationsComponent } from '../../bias-annotations/bias-annotations';
import { ClassicPreset } from 'rete';
import { ReteModule } from 'rete-angular-plugin/21';
@ -694,6 +694,16 @@ export class GenericNodeComponent implements OnDestroy {
return Array.isArray(value) ? value as BiasAnnotation[] : [];
}
get biasAnnotationBadge(): { count: number; hasExecutableProbe: boolean; maxSeverityLabel: string | null } | null {
const annotations = this.biasAnnotations;
if (!annotations.length) return null;
return {
count: annotations.length,
hasExecutableProbe: annotations.some((annotation) => isProbeExecutable(annotation.behavioralProbe)),
maxSeverityLabel: this.mostSevereBiasLabel(annotations)
};
}
get biasBlock(): FlowBlock | null {
const nodeData = this.data?.data as Record<string, unknown> | undefined;
const blockId = this.blockId;
@ -720,6 +730,29 @@ export class GenericNodeComponent implements OnDestroy {
return typeof property === 'string' && property.length ? property : 'biasAnnotations';
}
private mostSevereBiasLabel(annotations: BiasAnnotation[]): string | null {
const descriptorSignal = (this.blocksService as BlocksService & {
biasAnnotationsDescriptor?: () => BiasAnnotationsDescriptor | null
}).biasAnnotationsDescriptor;
const severityOptions = descriptorSignal?.()?.options?.['severity'] ?? [];
const rankByValue = new Map(severityOptions.map((option, index) => [option.value, index]));
let mostSevereValue: unknown = undefined;
let mostSevereRank = -1;
for (const annotation of annotations) {
const value = annotation.severity;
const rank = typeof value === 'string' && rankByValue.has(value) ? rankByValue.get(value)! : -1;
if (mostSevereValue === undefined || rank > mostSevereRank) {
mostSevereValue = value;
mostSevereRank = rank;
}
}
if (mostSevereValue == null) return null;
return severityOptions.find((option) => option.value === mostSevereValue)?.label
?? (typeof mostSevereValue === 'string' ? mostSevereValue : null);
}
private ensureBlockConfiguration(): Record<string, any> {
if (!this.data?.data) {
this.data.data = {};

View File

@ -61,6 +61,40 @@
filter: grayscale(1);
}
.llm-node-bias-active {
border: 2px solid #7c3aed;
box-shadow: 0 0 0 3px rgba(124, 58, 237, 0.2), 0 10px 24px rgba(15, 23, 42, 0.12);
}
.llm-node-bias-changed {
border: 2px solid #b45309;
box-shadow: 0 0 0 3px rgba(180, 83, 9, 0.22), 0 10px 24px rgba(15, 23, 42, 0.12);
}
.llm-bias-canvas-badge-wrap {
position: absolute;
top: -10px;
right: 10px;
z-index: 5;
display: flex;
gap: 4px;
}
.llm-bias-canvas-badge {
width: 20px;
height: 20px;
border-radius: 999px;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 11px;
color: #fff;
box-shadow: 0 2px 6px rgba(15, 23, 42, 0.25);
}
.llm-bias-canvas-badge-active { background: #7c3aed; }
.llm-bias-canvas-badge-changed { background: #b45309; }
.llm-node-skipped .llm-header {
background: linear-gradient(135deg, #94a3b8 0%, #64748b 100%);
border-bottom-color: #cbd5e1;
@ -629,4 +663,5 @@
margin-left: auto;
padding: .3rem .4rem;
}
.llm-bias-impact-trigger:hover { background: #dbeafe; }
.llm-bias-impact-trigger:hover:not(:disabled) { background: #dbeafe; }
.llm-bias-impact-trigger:disabled { cursor: not-allowed; opacity: .5; }

View File

@ -5,7 +5,23 @@
[class.llm-node-completed]="isCompleted() && !needsAttention() && !hasExecutionErrors() && !hasExecutionWarnings()"
[class.llm-node-attention]="needsAttention()"
[class.llm-node-error]="hasExecutionErrors()"
[class.llm-node-warning]="hasExecutionWarnings() && !hasExecutionErrors()">
[class.llm-node-warning]="hasExecutionWarnings() && !hasExecutionErrors()"
[class.llm-node-bias-active]="isBiasActive() && !hasExecutionErrors()"
[class.llm-node-bias-changed]="(isBiasDownstreamChanged() || isBiasRoutingChangeSource()) && !hasExecutionErrors()">
@if (isBiasActive() || isBiasDownstreamChanged() || isBiasRoutingChangeSource()) {
<div class="llm-bias-canvas-badge-wrap">
@if (isBiasActive()) {
<span class="llm-bias-canvas-badge llm-bias-canvas-badge-active" title="Bias annotation active on this node">
<i class="bi bi-clipboard2-pulse-fill"></i>
</span>
}
@if (isBiasDownstreamChanged() || isBiasRoutingChangeSource()) {
<span class="llm-bias-canvas-badge llm-bias-canvas-badge-changed" title="Output changed by the highlighted bias report">
<i class="bi bi-arrow-left-right"></i>
</span>
}
</div>
}
@if (needsAttention() && isHumanNode()) {
<div class="llm-attention-wrap" (pointerdown)="$event.stopPropagation()" (click)="$event.stopPropagation()">
<button
@ -59,12 +75,13 @@
<span class="llm-subtitle">{{ name }}</span>
</div>
</div>
@if (canMeasureBiasImpact()) {
@if (hasMeasurableBiasAnnotations()) {
<button
type="button"
class="llm-bias-impact-trigger"
aria-label="Measure bias impact"
title="Measure bias impact"
[title]="measureBiasImpactTooltip()"
[disabled]="!canMeasureBiasImpact()"
(pointerdown)="$event.stopPropagation()"
(click)="measureBiasImpact($event)">
<i class="bi bi-bar-chart-line"></i>

View File

@ -0,0 +1,132 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { vi } from 'vitest';
import { of } from 'rxjs';
import { BlocksService } from '@services/blocks/blocks';
import { ContainersService } from '@services/containers/containers';
import { NodeSettingsDialogService } from '@services/dialogs/node-settings-dialog';
import { SubflowPreviewDialogService } from '@services/dialogs/subflow-preview-dialog';
import { HumanInteractionDialogService } from '@services/dialogs/human-interaction-dialog';
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 { BiasImpactReport } from '@models/bias-impact';
import { TaskStepNodeComponent } from './task-step-node';
const REPORT: BiasImpactReport = {
id: 'report-1',
experimentId: 'experiment-1',
kind: 'FULL_FLOW',
baselineExecutionId: 'baseline-1',
biasedExecutionId: 'variant-1',
nodeId: null,
annotationIds: ['annotation-1'],
repetitions: 1,
createdAt: '2026-07-21T10:00:00',
rawOutputsIncluded: true,
immediateImpact: { outputChanged: true, maximumTextDifference: 0.5, changeRate: 1, baselineOutput: {}, biasedOutputs: [{}] },
downstreamImpact: [
{ nodeId: 'step-changed', nodeName: 'Changed', baselineStatus: 'COMPLETED', biasedStatus: 'COMPLETED', changed: true, baselineOutputs: {}, biasedOutputs: {} }
],
routingChanges: [{ nodeId: 'step-router', baselineBranch: 'false', biasedBranch: 'true' }],
mockedSideEffects: [],
summary: 'Outputs changed',
warnings: []
};
describe('TaskStepNodeComponent bias canvas highlighting', () => {
let fixture: ComponentFixture<TaskStepNodeComponent>;
let component: TaskStepNodeComponent;
let comparisonViewState: BiasComparisonViewStateService;
beforeEach(async () => {
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: 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() } },
{ provide: HumanInteractionDialogService, useValue: { open: vi.fn(), close: vi.fn(), update: vi.fn(), state: vi.fn().mockReturnValue(null) } },
{ provide: TaskExecutionsService, useValue: { submitInteractionText: vi.fn().mockReturnValue(of(null)) } },
{ provide: BiasImpactExperimentDialogService, useValue: { open: vi.fn() } }
]
}).compileComponents();
fixture = TestBed.createComponent(TaskStepNodeComponent);
component = fixture.componentInstance;
comparisonViewState = TestBed.inject(BiasComparisonViewStateService);
component.data = {
id: 'step-changed',
inputs: {},
outputs: {},
data: {
id: 'step-changed',
typeName: 'LLMBlock',
name: 'Step',
inputs: [],
outputs: [],
specificConfiguration: { __executionNodeId: 'step-changed', __biasActiveAnnotationIds: ['annotation-1'] }
}
};
component.emit = vi.fn();
component.rendered = vi.fn();
fixture.detectChanges();
});
it('flags a node as bias-active when it has active annotation ids in its execution config', () => {
expect(component.isBiasActive()).toBe(true);
});
it('is not bias-active when the active annotation list is empty', () => {
(component.data.data.specificConfiguration as Record<string, unknown>)['__biasActiveAnnotationIds'] = [];
expect(component.isBiasActive()).toBe(false);
});
it('flags a node as downstream-changed only while its id appears in the highlighted report', () => {
expect(component.isBiasDownstreamChanged()).toBe(false);
comparisonViewState.show({ report: REPORT });
expect(component.isBiasDownstreamChanged()).toBe(true);
comparisonViewState.clear();
expect(component.isBiasDownstreamChanged()).toBe(false);
});
it('flags the router node referenced by a routing change in the highlighted report', () => {
(component.data.data.specificConfiguration as Record<string, unknown>)['__executionNodeId'] = 'step-router';
comparisonViewState.show({ report: REPORT });
expect(component.isBiasRoutingChangeSource()).toBe(true);
});
describe('Measure bias impact availability', () => {
beforeEach(() => {
component.data.data.biasAnnotations = [
{ id: 'annotation-1', behavioralProbe: { activationMode: 'PROMPT_DIRECTIVE', instruction: 'Nudge it' } }
];
component.biasCapabilities = { blockType: 'LLMBlock', supported: true, isolatedExperimentSupported: true, fullFlowExperimentSupported: true, externalSideEffects: false, configurationDependent: false, activationModes: ['PROMPT_DIRECTIVE'] };
});
it('is hidden when there is nothing measurable (no executable annotation / capability)', () => {
component.data.data.biasAnnotations = [];
expect(component.hasMeasurableBiasAnnotations()).toBe(false);
});
it('is shown but disabled with an explanatory tooltip while the execution has not reached a final state', () => {
(component.data.data.specificConfiguration as Record<string, unknown>)['__executionStatusGroup'] = 'RUNNING';
expect(component.hasMeasurableBiasAnnotations()).toBe(true);
expect(component.canMeasureBiasImpact()).toBe(false);
expect(component.measureBiasImpactTooltip()).toBe('Available once the execution reaches a final state');
});
it('is enabled once the execution reaches a final state', () => {
(component.data.data.specificConfiguration as Record<string, unknown>)['__executionStatusGroup'] = 'FINAL';
expect(component.canMeasureBiasImpact()).toBe(true);
expect(component.measureBiasImpactTooltip()).toBe('Measure bias impact');
});
});
});

View File

@ -11,6 +11,7 @@ import { SubflowPreviewDialogService } from '@services/dialogs/subflow-preview-d
import { HumanInteractionDialogService } from '@services/dialogs/human-interaction-dialog';
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 { take } from 'rxjs';
import {
collectSchemaFlowDataFields,
@ -118,6 +119,7 @@ export class TaskStepNodeComponent {
private humanInteractionDialog = inject(HumanInteractionDialogService);
private taskExecutionsService = inject(TaskExecutionsService);
private biasImpactExperimentDialog = inject(BiasImpactExperimentDialogService);
private biasComparisonViewState = inject(BiasComparisonViewStateService);
@Input() data!: any;
@Input() emit!: (data: any) => void;
@ -436,12 +438,35 @@ export class TaskStepNodeComponent {
: [];
}
canMeasureBiasImpact(): boolean {
return this.blockConfiguration?.['__executionStatusGroup'] === 'FINAL'
&& this.executableBiasAnnotations().length > 0
hasMeasurableBiasAnnotations(): boolean {
return this.executableBiasAnnotations().length > 0
&& this.biasCapabilities?.isolatedExperimentSupported === true;
}
canMeasureBiasImpact(): boolean {
return this.hasMeasurableBiasAnnotations() && this.blockConfiguration?.['__executionStatusGroup'] === 'FINAL';
}
measureBiasImpactTooltip(): string {
if (this.blockConfiguration?.['__executionStatusGroup'] !== 'FINAL') {
return 'Available once the execution reaches a final state';
}
return 'Measure bias impact';
}
isBiasActive(): boolean {
const ids = this.blockConfiguration?.['__biasActiveAnnotationIds'];
return Array.isArray(ids) && ids.length > 0;
}
isBiasDownstreamChanged(): boolean {
return this.biasComparisonViewState.isNodeDownstreamChanged(this.executionNodeId());
}
isBiasRoutingChangeSource(): boolean {
return this.biasComparisonViewState.isRoutingChangeSource(this.executionNodeId());
}
measureBiasImpact(event: Event) {
event.preventDefault();
event.stopPropagation();

View File

@ -1,5 +1,32 @@
.side-effect-policy-selector { display: grid; gap: .45rem; }
.side-effect-policy-selector h4 { font-size: .9rem; margin: 0; }
.side-effect-policy-selector label { color: #334155; font-size: .86rem; }
.side-effect-policy-selector__warning { background: #fff7ed; border-left: 3px solid #f97316; color: #9a3412; font-size: .84rem; margin: 0; padding: .55rem .65rem; }
/* Static banner reusing the .llm-warning visual language from generic-node (icon badge + colored surface). */
.side-effect-policy-selector__alert {
display: flex;
align-items: flex-start;
gap: .55rem;
background: #fef2f2;
border: 1px solid #fecaca;
border-radius: .5rem;
padding: .55rem .65rem;
}
.side-effect-policy-selector__alert-icon {
width: 24px;
height: 24px;
flex-shrink: 0;
border-radius: 999px;
border: 1px solid #fecaca;
background: #dc2626;
color: #fff7ed;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 13px;
box-shadow: 0 0 0 2px rgba(127, 29, 29, 0.18);
}
.side-effect-policy-selector__alert-text { color: #9a3412; font-size: .84rem; margin: 0; }
.side-effect-policy-selector__note { color: #0369a1; font-size: .82rem; margin: 0; }
.side-effect-policy-selector__note--warn { color: #9a3412; }

View File

@ -1,10 +1,14 @@
<section class="side-effect-policy-selector">
<h4>External side effects</h4>
@if (externalSideEffects) {
<p class="side-effect-policy-selector__warning">This block can invoke external services. Choose how the experiment handles those calls.</p>
<div class="side-effect-policy-selector__alert" role="alert">
<div class="side-effect-policy-selector__alert-icon"><i class="bi bi-exclamation-triangle-fill"></i></div>
<p class="side-effect-policy-selector__alert-text">This block can invoke external services (HTTP, MCP). Choose how the experiment handles those calls.</p>
</div>
}
<label><input type="radio" name="side-effect-policy" [checked]="policy === 'BLOCK'" [disabled]="disabled" (change)="select('BLOCK')"> Block external calls</label>
<label><input type="radio" name="side-effect-policy" [checked]="policy === 'MOCK'" [disabled]="disabled" (change)="select('MOCK')"> Mock external calls</label>
<label><input type="radio" name="side-effect-policy" [checked]="policy === 'REQUIRE_CONFIRMATION'" [disabled]="disabled" (change)="select('REQUIRE_CONFIRMATION')"> Allow after confirmation</label>
@if (policy === 'MOCK') { <p class="side-effect-policy-selector__note">HTTP and MCP calls will not be invoked for real.</p> }
@if (policy === 'REQUIRE_CONFIRMATION') { <p class="side-effect-policy-selector__note side-effect-policy-selector__note--warn">Real external calls will be made. You will be asked to confirm before the experiment starts.</p> }
</section>

View File

@ -20,4 +20,30 @@ describe('SideEffectPolicySelectorComponent', () => {
expect(emitted).toEqual(['BLOCK']);
expect(fixture.nativeElement.textContent).toContain('will not be invoked for real');
});
it('shows the external side effect banner only when the block has external side effects', () => {
fixture.componentRef.setInput('externalSideEffects', false);
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('.side-effect-policy-selector__alert')).toBeNull();
fixture.componentRef.setInput('externalSideEffects', true);
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('.side-effect-policy-selector__alert')).not.toBeNull();
});
it('warns about the explicit confirmation step for the require-confirmation policy', () => {
fixture.componentInstance.policy = 'REQUIRE_CONFIRMATION';
fixture.detectChanges();
expect(fixture.nativeElement.textContent).toContain('asked to confirm');
});
it('does not emit while disabled', () => {
const emitted: string[] = [];
fixture.componentInstance.policyChange.subscribe((policy) => emitted.push(policy));
fixture.componentInstance.disabled = true;
fixture.detectChanges();
fixture.componentInstance.select('MOCK');
expect(emitted).toEqual([]);
});
});

View File

@ -421,3 +421,30 @@
margin-top: .45rem;
}
.execution-graph-action-button-bias { background: #f3e8ff; color: #7e22ce; }
.execution-graph-action-button-compare { background: #ccfbf1; color: #0f766e; }
.execution-graph-action-button-compare:hover:not(:disabled) { filter: brightness(1.05); }
.execution-bias-legend {
position: absolute;
top: 62px;
left: 12px;
z-index: 4;
display: flex;
align-items: center;
flex-wrap: wrap;
gap: .5rem .75rem;
background: #ffffff;
border: 1px solid #e2e8f0;
border-radius: .5rem;
box-shadow: 0 8px 18px rgba(15, 23, 42, 0.12);
padding: .4rem .65rem;
font-size: .76rem;
color: #334155;
}
.execution-bias-legend-title { font-weight: 700; }
.execution-bias-legend-item { display: inline-flex; align-items: center; gap: .3rem; }
.execution-bias-legend-dot { display: inline-block; width: 10px; height: 10px; border-radius: 999px; }
.execution-bias-legend-dot-active { background: #7c3aed; }
.execution-bias-legend-dot-changed { background: #b45309; }
.execution-bias-legend-reset { background: #f1f5f9; border: 1px solid #cbd5e1; border-radius: .4rem; color: #0f172a; cursor: pointer; font-size: .76rem; font-weight: 600; padding: .25rem .55rem; }
.execution-bias-legend-reset:hover { background: #e2e8f0; }

View File

@ -11,6 +11,7 @@
@if (simulationDescriptorLabel(); as simulationDescriptor) {
<p class="text-xs text-sky-900">Simulator: {{ simulationDescriptor }}</p>
}
}
@if (execution()!.biasExecutionContext; as biasContext) {
<div class="execution-bias-variant-context">
<strong>Bias variant</strong>
@ -19,7 +20,6 @@
<span>Active annotations: {{ biasContext.activeAnnotationIdsByNode | json }}</span>
</div>
}
}
</div>
</div>
</div>
@ -102,14 +102,33 @@
}
<button
type="button"
class="execution-action-button execution-graph-action-button execution-action-button-bias"
class="execution-action-button execution-graph-action-button execution-graph-action-button-bias"
matTooltip="Create biased rerun"
aria-label="Create biased rerun"
[disabled]="!canCreateBiasedRerun()"
(click)="openBiasedRerunDialog()">
<mat-icon fontIcon="account_tree"></mat-icon>
</button>
@if (isBiasVariant()) {
<button
type="button"
class="execution-action-button execution-graph-action-button execution-graph-action-button-compare"
matTooltip="Compare with baseline"
aria-label="Compare with baseline"
[disabled]="!canCompareBiasExecution()"
(click)="openCompareDialog()">
<mat-icon fontIcon="compare_arrows"></mat-icon>
</button>
}
</div>
@if (isBiasHighlightActive()) {
<div class="execution-bias-legend">
<span class="execution-bias-legend-title">Bias report highlighted on canvas:</span>
<span class="execution-bias-legend-item"><i class="execution-bias-legend-dot execution-bias-legend-dot-active"></i> Bias annotation active</span>
<span class="execution-bias-legend-item"><i class="execution-bias-legend-dot execution-bias-legend-dot-changed"></i> Changed by bias / routing changed</span>
<button type="button" class="execution-bias-legend-reset" (click)="clearBiasHighlight()">Back to normal view</button>
</div>
}
<app-rete-editor
[flowId]="execution()!.id"
[flowData]="executionFlowData()"
@ -159,6 +178,13 @@
(click)="selectAsideTab('output')">
Output
</button>
<button
type="button"
class="execution-aside-tab"
[class.execution-aside-tab-active]="activeAsideTab() === 'bias-reports'"
(click)="selectAsideTab('bias-reports')">
Bias impact reports
</button>
</div>
<div class="min-h-0 flex-1 overflow-auto">
@ -253,7 +279,7 @@
}
}
</div>
} @else {
} @else if (activeAsideTab() === 'output') {
<div class="p-3 space-y-3">
@if (!executionOutputGroups().length) {
<div class="text-xs text-slate-500">No execution output available.</div>
@ -289,6 +315,8 @@
}
}
</div>
} @else {
<app-bias-impact-report-list [executionId]="execution()!.id" />
}
</div>
</aside>

View File

@ -38,6 +38,9 @@ import { TaskExecutionsService } from '@services/task-executions/task-executions
import { ContainersService } from '@services/containers/containers';
import { BlocksService } from '@services/blocks/blocks';
import { BiasRerunDialogService, BiasRerunCandidate } from '@services/dialogs/bias-rerun-dialog';
import { BiasCompareDialogService } from '@services/dialogs/bias-compare-dialog';
import { BiasComparisonViewStateService } from '@services/bias/bias-comparison-view-state';
import { BiasImpactReportListComponent } from '@shared/bias-impact-report-list/bias-impact-report-list';
import { firstValueFrom } from 'rxjs';
import {
ExecutionOutputEntry,
@ -70,7 +73,7 @@ import {
@Component({
selector: 'app-task-execution-viewer',
imports: [CommonModule, ReteEditor, TaskExecutionInputsPanelComponent, MatButtonModule, MatIconModule, MatTooltipModule],
imports: [CommonModule, ReteEditor, TaskExecutionInputsPanelComponent, MatButtonModule, MatIconModule, MatTooltipModule, BiasImpactReportListComponent],
templateUrl: './task-execution-viewer.html',
styleUrl: './task-execution-viewer.css',
changeDetection: ChangeDetectionStrategy.OnPush
@ -84,6 +87,8 @@ export class TaskExecutionViewerComponent implements OnDestroy {
private containersService = inject(ContainersService);
private blocksService = inject(BlocksService);
private biasRerunDialog = inject(BiasRerunDialogService);
private biasCompareDialog = inject(BiasCompareDialogService);
private biasComparisonViewState = inject(BiasComparisonViewStateService);
private router = inject(Router);
private route = inject(ActivatedRoute);
private lastExecutionId: string | null = null;
@ -92,7 +97,7 @@ export class TaskExecutionViewerComponent implements OnDestroy {
private static readonly SIMULATOR_MODEL_RETRIEVER_URL = '/retriever/LLM/models';
readonly execution = input<TaskExecution | null>(null);
readonly contextAsideOpen = signal(true);
readonly activeAsideTab = signal<'inputs' | 'intermediate' | 'logs' | 'output'>('inputs');
readonly activeAsideTab = signal<'inputs' | 'intermediate' | 'logs' | 'output' | 'bias-reports'>('inputs');
readonly startInProgress = signal(false);
readonly simulateInProgress = signal(false);
readonly biasRerunOpening = signal(false);
@ -264,6 +269,7 @@ export class TaskExecutionViewerComponent implements OnDestroy {
const contextErrors = this.execution()?.context.errors ?? {};
const contextWarnings = this.execution()?.context.warnings ?? {};
const waitingSteps = this.execution()?.context.waitingSteps ?? [];
const activeAnnotationIdsByNode = this.execution()?.biasExecutionContext?.activeAnnotationIdsByNode ?? {};
const steps = this.stepsArray();
const blocks: FlowBlock[] = [];
const containers: FlowContainer[] = [];
@ -293,7 +299,8 @@ export class TaskExecutionViewerComponent implements OnDestroy {
__executionErrors: getExecutionErrors(step.id, contextErrors),
__executionWarnings: getExecutionWarnings(step.id, contextWarnings),
__stepResultData: step.result ?? null,
__executionPartialResult: this.execution()?.context.partialResult ?? null
__executionPartialResult: this.execution()?.context.partialResult ?? null,
__biasActiveAnnotationIds: activeAnnotationIdsByNode[step.id] ?? []
},
position: stepNode.position ?? {
x: 120 + (index % 3) * 340,
@ -416,6 +423,11 @@ export class TaskExecutionViewerComponent implements OnDestroy {
readonly canCreateBiasedRerun = computed(() =>
getExecutionStatusGroup(this.execution()?.context.status) === 'FINAL' && !this.biasRerunOpening()
);
readonly canCompareBiasExecution = computed(() =>
this.isBiasVariant()
&& !!this.execution()?.rerunOfExecutionId
&& getExecutionStatusGroup(this.execution()?.context.status) === 'FINAL'
);
readonly simulationDescriptorLabel = computed(() => {
const descriptor = this.execution()?.interactionSimulationDescriptor;
if (!descriptor) return null;
@ -494,7 +506,7 @@ export class TaskExecutionViewerComponent implements OnDestroy {
this.contextAsideOpen.update((open) => !open);
}
selectAsideTab(tab: 'inputs' | 'intermediate' | 'logs' | 'output') {
selectAsideTab(tab: 'inputs' | 'intermediate' | 'logs' | 'output' | 'bias-reports') {
if (tab === 'output' && !this.executionOutputTabEnabled()) return;
this.activeAsideTab.set(tab);
}
@ -620,6 +632,25 @@ export class TaskExecutionViewerComponent implements OnDestroy {
}
}
openCompareDialog() {
const execution = this.execution();
const baselineExecutionId = execution?.rerunOfExecutionId;
if (!execution || !baselineExecutionId || !this.canCompareBiasExecution()) return;
this.biasCompareDialog.open({
baselineExecutionId,
biasedExecutionId: execution.id
});
}
isBiasHighlightActive(): boolean {
return this.biasComparisonViewState.activeView() !== null;
}
clearBiasHighlight() {
this.biasComparisonViewState.clear();
}
onTextInputChange(input: EditableExecutionInput, value: string | string[]) {
if (this.inputsReadOnly()) return;
this.pendingTextInputs.update((current) => ({ ...current, [input.key]: value }));