From 94ce814a6f5a4199101341a55bb50f72d01c3a73 Mon Sep 17 00:00:00 2001 From: Lucio Lelii Date: Tue, 21 Jul 2026 11:20:57 +0200 Subject: [PATCH] feat(bias-impact): complete side-effect policy, compare, report list and canvas highlighting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/app/app.html | 1 + src/app/app.ts | 3 +- .../bias/bias-comparison-view-state.spec.ts | 73 ++++++++++ .../bias/bias-comparison-view-state.ts | 41 ++++++ src/app/services/bias/bias-flow.spec.ts | 95 +++++++++++++ .../dialogs/bias-compare-dialog.spec.ts | 21 +++ .../services/dialogs/bias-compare-dialog.ts | 20 +++ .../bias-compare-dialog.css | 9 ++ .../bias-compare-dialog.html | 23 +++ .../bias-compare-dialog.spec.ts | 102 ++++++++++++++ .../bias-compare-dialog.ts | 84 +++++++++++ .../bias-impact-experiment-dialog.html | 2 +- .../bias-impact-experiment-dialog.ts | 9 ++ .../bias-impact-report-list.css | 34 +++++ .../bias-impact-report-list.html | 40 ++++++ .../bias-impact-report-list.spec.ts | 119 ++++++++++++++++ .../bias-impact-report-list.ts | 103 ++++++++++++++ .../bias-impact-report-viewer.css | 3 + .../bias-impact-report-viewer.html | 7 +- .../bias-impact-report-viewer.spec.ts | 10 ++ .../bias-impact-report-viewer.ts | 3 +- .../custom-connection.spec.ts | 64 +++++++++ .../custom-connection/custom-connection.ts | 11 ++ .../nodes/generic-node/generic-node.css | 63 +++++++++ .../nodes/generic-node/generic-node.html | 16 +++ .../nodes/generic-node/generic-node.spec.ts | 29 ++++ .../shared/nodes/generic-node/generic-node.ts | 35 ++++- .../nodes/task-step-node/task-step-node.css | 37 ++++- .../nodes/task-step-node/task-step-node.html | 23 ++- .../task-step-node/task-step-node.spec.ts | 132 ++++++++++++++++++ .../nodes/task-step-node/task-step-node.ts | 31 +++- .../side-effect-policy-selector.css | 29 +++- .../side-effect-policy-selector.html | 6 +- .../side-effect-policy-selector.spec.ts | 26 ++++ .../task-execution-viewer.css | 27 ++++ .../task-execution-viewer.html | 34 ++++- .../task-execution-viewer.ts | 39 +++++- 37 files changed, 1383 insertions(+), 21 deletions(-) create mode 100644 src/app/services/bias/bias-comparison-view-state.spec.ts create mode 100644 src/app/services/bias/bias-comparison-view-state.ts create mode 100644 src/app/services/bias/bias-flow.spec.ts create mode 100644 src/app/services/dialogs/bias-compare-dialog.spec.ts create mode 100644 src/app/services/dialogs/bias-compare-dialog.ts create mode 100644 src/app/shared/bias-compare-dialog/bias-compare-dialog.css create mode 100644 src/app/shared/bias-compare-dialog/bias-compare-dialog.html create mode 100644 src/app/shared/bias-compare-dialog/bias-compare-dialog.spec.ts create mode 100644 src/app/shared/bias-compare-dialog/bias-compare-dialog.ts create mode 100644 src/app/shared/bias-impact-report-list/bias-impact-report-list.css create mode 100644 src/app/shared/bias-impact-report-list/bias-impact-report-list.html create mode 100644 src/app/shared/bias-impact-report-list/bias-impact-report-list.spec.ts create mode 100644 src/app/shared/bias-impact-report-list/bias-impact-report-list.ts create mode 100644 src/app/shared/custom-connection/custom-connection.spec.ts create mode 100644 src/app/shared/nodes/task-step-node/task-step-node.spec.ts diff --git a/src/app/app.html b/src/app/app.html index a2be06e..295075f 100644 --- a/src/app/app.html +++ b/src/app/app.html @@ -6,3 +6,4 @@ + diff --git a/src/app/app.ts b/src/app/app.ts index 050f53b..334b091 100644 --- a/src/app/app.ts +++ b/src/app/app.ts @@ -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 diff --git a/src/app/services/bias/bias-comparison-view-state.spec.ts b/src/app/services/bias/bias-comparison-view-state.spec.ts new file mode 100644 index 0000000..8a00e92 --- /dev/null +++ b/src/app/services/bias/bias-comparison-view-state.spec.ts @@ -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); + }); +}); diff --git a/src/app/services/bias/bias-comparison-view-state.ts b/src/app/services/bias/bias-comparison-view-state.ts new file mode 100644 index 0000000..2f2e8b1 --- /dev/null +++ b/src/app/services/bias/bias-comparison-view-state.ts @@ -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(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); + } +} diff --git a/src/app/services/bias/bias-flow.spec.ts b/src/app/services/bias/bias-flow.spec.ts new file mode 100644 index 0000000..7dda5ce --- /dev/null +++ b/src/app/services/bias/bias-flow.spec.ts @@ -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); + }); +}); diff --git a/src/app/services/dialogs/bias-compare-dialog.spec.ts b/src/app/services/dialogs/bias-compare-dialog.spec.ts new file mode 100644 index 0000000..d67b1b3 --- /dev/null +++ b/src/app/services/dialogs/bias-compare-dialog.spec.ts @@ -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(); + }); +}); diff --git a/src/app/services/dialogs/bias-compare-dialog.ts b/src/app/services/dialogs/bias-compare-dialog.ts new file mode 100644 index 0000000..c011cce --- /dev/null +++ b/src/app/services/dialogs/bias-compare-dialog.ts @@ -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(null); + readonly state = this._state.asReadonly(); + + open(input: BiasCompareDialogInput) { + this._state.set(input); + } + + close() { + this._state.set(null); + } +} diff --git a/src/app/shared/bias-compare-dialog/bias-compare-dialog.css b/src/app/shared/bias-compare-dialog/bias-compare-dialog.css new file mode 100644 index 0000000..6734aba --- /dev/null +++ b/src/app/shared/bias-compare-dialog/bias-compare-dialog.css @@ -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; } diff --git a/src/app/shared/bias-compare-dialog/bias-compare-dialog.html b/src/app/shared/bias-compare-dialog/bias-compare-dialog.html new file mode 100644 index 0000000..8be6c0a --- /dev/null +++ b/src/app/shared/bias-compare-dialog/bias-compare-dialog.html @@ -0,0 +1,23 @@ +@if (state()) { +
+ +} diff --git a/src/app/shared/bias-compare-dialog/bias-compare-dialog.spec.ts b/src/app/shared/bias-compare-dialog/bias-compare-dialog.spec.ts new file mode 100644 index 0000000..26159a5 --- /dev/null +++ b/src/app/shared/bias-compare-dialog/bias-compare-dialog.spec.ts @@ -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; + let dialog: BiasCompareDialogService; + let compareBiasExecutions: ReturnType; + + 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(); + }); +}); diff --git a/src/app/shared/bias-compare-dialog/bias-compare-dialog.ts b/src/app/shared/bias-compare-dialog/bias-compare-dialog.ts new file mode 100644 index 0000000..6d6fc90 --- /dev/null +++ b/src/app/shared/bias-compare-dialog/bias-compare-dialog.ts @@ -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(null); + readonly report = signal(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; + const errors = Array.isArray(record['errors']) ? record['errors'] : []; + const first = errors[0]; + if (first && typeof first === 'object' && typeof (first as Record)['message'] === 'string') { + return (first as Record)['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.'; + } +} diff --git a/src/app/shared/bias-impact-experiment-dialog/bias-impact-experiment-dialog.html b/src/app/shared/bias-impact-experiment-dialog/bias-impact-experiment-dialog.html index 9e7188c..57ccb99 100644 --- a/src/app/shared/bias-impact-experiment-dialog/bias-impact-experiment-dialog.html +++ b/src/app/shared/bias-impact-experiment-dialog/bias-impact-experiment-dialog.html @@ -3,7 +3,7 @@