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:
parent
7bfef72f79
commit
94ce814a6f
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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; }
|
||||
|
|
@ -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>
|
||||
}
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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.';
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
}
|
||||
|
|
@ -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>
|
||||
}
|
||||
|
|
@ -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.');
|
||||
});
|
||||
});
|
||||
|
|
@ -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.');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 = {};
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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([]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 }));
|
||||
|
|
|
|||
Loading…
Reference in New Issue