Make the bias experiment reachable, and say why when it is not
The Bias impact tab is empty because no experiment has ever run - zero rows in bias_impact_report_entity - and nothing in the UI said how one is started. The empty state now explains what a report is, says how many nodes carry a probe that can be activated on this run, and offers the action. When something is in the way it states that instead: a subflow cannot be a baseline, an unfinished run cannot be compared against a rerun, a flow with no activatable probe has nothing to measure. "Create biased rerun" no longer returns silently when no node qualifies - a button that does nothing and explains nothing is indistinguishable from a broken one. It now names the reason. The per-node measure control is shown on interactive blocks instead of hidden. ChatInteraction cannot be replayed in isolation, so the isolated experiment is genuinely unavailable there - but hiding the control made an annotated node look identical to an unannotated one. It is rendered disabled, and the tooltip points at the full-flow route that does work. That is a rule of the domain, worth stating rather than concealing. Splitting the node scan out of biasRerunCandidates keeps this free: the count is computed from the flow snapshot, while only the capability check needs the network. 506 frontend tests green; each new assertion fails when its behaviour is undone. Initial bundle now 2.86 kB over budget, up from 2.21 kB - reported, not raised. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
53ac229790
commit
b2d744fe42
|
|
@ -2,7 +2,32 @@
|
|||
.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; }
|
||||
/* An empty tab that only said "none yet" left the user with no idea how one is produced. */
|
||||
.bias-report-list__empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: .5rem;
|
||||
color: #64748b;
|
||||
font-size: .84rem;
|
||||
padding: .5rem;
|
||||
}
|
||||
|
||||
.bias-report-list__empty p { margin: 0; }
|
||||
|
||||
.bias-report-list__empty-lead { color: #334155; font-weight: 600; }
|
||||
|
||||
.bias-report-list__empty-body { line-height: 1.45; }
|
||||
|
||||
/* The precondition that is in the way, stated instead of left to be guessed from a dead button. */
|
||||
.bias-report-list__empty-blocked {
|
||||
border-left: 3px solid #f59e0b;
|
||||
background: #fffbeb;
|
||||
border-radius: 4px;
|
||||
padding: .4rem .55rem;
|
||||
color: #92400e;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.bias-report-list__row {
|
||||
align-items: center;
|
||||
|
|
|
|||
|
|
@ -21,7 +21,25 @@
|
|||
<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>
|
||||
<div class="bias-report-list__empty">
|
||||
<p class="bias-report-list__empty-lead">No bias impact reports for this run yet.</p>
|
||||
<p class="bias-report-list__empty-body">
|
||||
A report compares this run against the same run with its bias or mitigation probes turned
|
||||
on, and shows which node outputs it changed. Reports appear here once you produce one.
|
||||
</p>
|
||||
@if (blockedReason(); as reason) {
|
||||
<p class="bias-report-list__empty-blocked">{{ reason }}</p>
|
||||
} @else {
|
||||
<p class="bias-report-list__empty-body">
|
||||
{{ annotatedNodeCount() }}
|
||||
{{ annotatedNodeCount() === 1 ? 'node carries a probe' : 'nodes carry a probe' }}
|
||||
that can be activated on this run.
|
||||
</p>
|
||||
<button type="button" mat-flat-button (click)="startExperimentRequested.emit()">
|
||||
Run a biased rerun
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
} @else {
|
||||
@for (report of reports(); track report.id) {
|
||||
<button type="button" class="bias-report-list__row" (click)="openDetail(report.id)">
|
||||
|
|
|
|||
|
|
@ -53,6 +53,46 @@ describe('BiasImpactReportListComponent', () => {
|
|||
fixture = TestBed.createComponent(BiasImpactReportListComponent);
|
||||
});
|
||||
|
||||
it('explains how a report is produced, and offers the action, when there are none', () => {
|
||||
// "No bias impact reports yet" alone left no clue that reports come from an experiment you
|
||||
// have to start, nor where to start one.
|
||||
listBiasImpactReports.mockReturnValue(of([]));
|
||||
fixture.componentRef.setInput('executionId', 'execution-1');
|
||||
fixture.componentRef.setInput('annotatedNodeCount', 3);
|
||||
fixture.componentRef.setInput('blockedReason', null);
|
||||
fixture.detectChanges();
|
||||
|
||||
const text = fixture.nativeElement.textContent;
|
||||
expect(text).toContain('No bias impact reports for this run yet');
|
||||
expect(text).toContain('3 nodes carry a probe');
|
||||
|
||||
const started = vi.fn();
|
||||
fixture.componentInstance.startExperimentRequested.subscribe(started);
|
||||
fixture.nativeElement.querySelector('.bias-report-list__empty button').click();
|
||||
expect(started).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('states the precondition in the way, instead of offering an action that cannot work', () => {
|
||||
listBiasImpactReports.mockReturnValue(of([]));
|
||||
fixture.componentRef.setInput('executionId', 'execution-1');
|
||||
fixture.componentRef.setInput('annotatedNodeCount', 0);
|
||||
fixture.componentRef.setInput('blockedReason', 'This run has not finished yet.');
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.nativeElement.querySelector('.bias-report-list__empty-blocked').textContent)
|
||||
.toContain('has not finished yet');
|
||||
expect(fixture.nativeElement.querySelector('.bias-report-list__empty button')).toBeNull();
|
||||
});
|
||||
|
||||
it('counts one annotated node in the singular', () => {
|
||||
listBiasImpactReports.mockReturnValue(of([]));
|
||||
fixture.componentRef.setInput('executionId', 'execution-1');
|
||||
fixture.componentRef.setInput('annotatedNodeCount', 1);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.nativeElement.textContent).toContain('1 node carries a probe');
|
||||
});
|
||||
|
||||
it('reloads when a dialog reports that one has just been produced', () => {
|
||||
// The experiment and compare dialogs render over this still-mounted tab. Without this the tab
|
||||
// kept saying there were no reports for the execution whose report the user was just reading.
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { CommonModule } from '@angular/common';
|
||||
import { ChangeDetectionStrategy, Component, computed, effect, inject, input, signal } from '@angular/core';
|
||||
import { ChangeDetectionStrategy, Component, computed, effect, inject, input, output, signal } from '@angular/core';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { BiasImpactReport } from '@models/bias-impact';
|
||||
import { TaskExecutionsService } from '@services/task-executions/task-executions';
|
||||
|
|
@ -23,6 +23,13 @@ export class BiasImpactReportListComponent {
|
|||
private lastReloadToken = 0;
|
||||
|
||||
readonly executionId = input<string | null>(null);
|
||||
/** How many nodes an experiment could act on, so the empty state can be concrete. */
|
||||
readonly annotatedNodeCount = input<number>(0);
|
||||
/** Why an experiment cannot be started, or null when it can; supplied by the host. */
|
||||
readonly blockedReason = input<string | null>(null);
|
||||
|
||||
/** The user wants to start one. The host owns the dialog, so it decides what "start" means. */
|
||||
readonly startExperimentRequested = output<void>();
|
||||
|
||||
readonly reports = signal<BiasImpactReport[]>([]);
|
||||
readonly loading = signal(false);
|
||||
|
|
|
|||
|
|
@ -121,6 +121,72 @@ describe('TaskStepNodeComponent bias canvas highlighting', () => {
|
|||
fixture.detectChanges();
|
||||
}
|
||||
|
||||
it('shows the measure control on an interactive node, disabled, saying what is possible instead', () => {
|
||||
// ChatInteraction cannot be replayed in isolation. Hiding the control made an annotated node
|
||||
// look identical to an unannotated one, with nothing to indicate the full-flow route exists.
|
||||
component.data.data.capabilities = {
|
||||
visualRole: 'ACTIVITY',
|
||||
terminal: false,
|
||||
biasAnnotationsAllowed: true,
|
||||
allowsIncomingConnections: true,
|
||||
allowsOutgoingConnections: true,
|
||||
canDependOnOtherNodes: false,
|
||||
canHaveDependentNodes: false
|
||||
};
|
||||
component.data.data.biasAnnotations = [
|
||||
{ id: 'a1', biasProbe: { activationMode: 'PROMPT_DIRECTIVE', instruction: 'Nudge it' } }
|
||||
];
|
||||
(component as any).biasCapabilities = { isolatedExperimentSupported: false };
|
||||
setStepConfig({ __executionStatusGroup: 'FINAL' });
|
||||
|
||||
expect(component.hasMeasurableBiasAnnotations()).toBe(true);
|
||||
expect(component.canMeasureBiasImpact()).toBe(false);
|
||||
const button = fixture.nativeElement.querySelector('.llm-bias-impact-trigger');
|
||||
expect(button).not.toBeNull();
|
||||
expect(button.disabled).toBe(true);
|
||||
expect(button.getAttribute('title')).toContain('Create biased rerun');
|
||||
});
|
||||
|
||||
it('enables the measure control where an isolated experiment is supported and the run is final', () => {
|
||||
component.data.data.capabilities = {
|
||||
visualRole: 'ACTIVITY',
|
||||
terminal: false,
|
||||
biasAnnotationsAllowed: true,
|
||||
allowsIncomingConnections: true,
|
||||
allowsOutgoingConnections: true,
|
||||
canDependOnOtherNodes: false,
|
||||
canHaveDependentNodes: false
|
||||
};
|
||||
component.data.data.biasAnnotations = [
|
||||
{ id: 'a1', biasProbe: { activationMode: 'PROMPT_DIRECTIVE', instruction: 'Nudge it' } }
|
||||
];
|
||||
(component as any).biasCapabilities = { isolatedExperimentSupported: true };
|
||||
setStepConfig({ __executionStatusGroup: 'FINAL' });
|
||||
|
||||
expect(component.canMeasureBiasImpact()).toBe(true);
|
||||
expect(component.measureBiasImpactTooltip()).toBe('Measure bias impact');
|
||||
});
|
||||
|
||||
it('still points at the final-state precondition when the run is not finished', () => {
|
||||
component.data.data.capabilities = {
|
||||
visualRole: 'ACTIVITY',
|
||||
terminal: false,
|
||||
biasAnnotationsAllowed: true,
|
||||
allowsIncomingConnections: true,
|
||||
allowsOutgoingConnections: true,
|
||||
canDependOnOtherNodes: false,
|
||||
canHaveDependentNodes: false
|
||||
};
|
||||
component.data.data.biasAnnotations = [
|
||||
{ id: 'a1', biasProbe: { activationMode: 'PROMPT_DIRECTIVE', instruction: 'Nudge it' } }
|
||||
];
|
||||
(component as any).biasCapabilities = { isolatedExperimentSupported: true };
|
||||
setStepConfig({ __executionStatusGroup: 'RUNNING' });
|
||||
|
||||
expect(component.canMeasureBiasImpact()).toBe(false);
|
||||
expect(component.measureBiasImpactTooltip()).toContain('final state');
|
||||
});
|
||||
|
||||
it('shows a container working on its subflow, which its own status never said', () => {
|
||||
// The container's status is WAITING_FOR_SUBFLOW, so it took none of the in-progress styling
|
||||
// and sat inert for minutes while its child ran - indistinguishable from a stuck run.
|
||||
|
|
|
|||
|
|
@ -501,17 +501,34 @@ export class TaskStepNodeComponent {
|
|||
].join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the node shows the measure control at all: it has probes worth measuring.
|
||||
*
|
||||
* It deliberately does *not* require isolatedExperimentSupported. A user-interactive block -
|
||||
* ChatInteraction, HumanDecision - cannot be replayed in isolation, and hiding the button there
|
||||
* left an annotated node looking identical to an unannotated one. The control is shown and
|
||||
* disabled, and the tooltip says which experiment is available instead.
|
||||
*/
|
||||
hasMeasurableBiasAnnotations(): boolean {
|
||||
return this.isBiasCapable()
|
||||
&& this.executableBiasAnnotations().length > 0
|
||||
&& this.biasCapabilities?.isolatedExperimentSupported === true;
|
||||
return this.isBiasCapable() && this.executableBiasAnnotations().length > 0;
|
||||
}
|
||||
|
||||
private supportsIsolatedBiasExperiment(): boolean {
|
||||
return this.biasCapabilities?.isolatedExperimentSupported === true;
|
||||
}
|
||||
|
||||
canMeasureBiasImpact(): boolean {
|
||||
return this.hasMeasurableBiasAnnotations() && this.blockConfiguration?.['__executionStatusGroup'] === 'FINAL';
|
||||
return this.hasMeasurableBiasAnnotations()
|
||||
&& this.supportsIsolatedBiasExperiment()
|
||||
&& this.blockConfiguration?.['__executionStatusGroup'] === 'FINAL';
|
||||
}
|
||||
|
||||
measureBiasImpactTooltip(): string {
|
||||
if (!this.supportsIsolatedBiasExperiment()) {
|
||||
// A rule of the domain, not a fault: say what *is* possible rather than only what is not.
|
||||
return 'This node cannot be replayed on its own, so there is no isolated experiment for it. '
|
||||
+ 'Use "Create biased rerun" on the toolbar to measure it as part of the whole flow.';
|
||||
}
|
||||
if (this.blockConfiguration?.['__executionStatusGroup'] !== 'FINAL') {
|
||||
return 'Available once the execution reaches a final state';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -535,7 +535,11 @@
|
|||
}
|
||||
</div>
|
||||
} @else {
|
||||
<app-bias-impact-report-list [executionId]="execution()!.id" />
|
||||
<app-bias-impact-report-list
|
||||
[executionId]="execution()!.id"
|
||||
[annotatedNodeCount]="biasAnnotatedNodeCount()"
|
||||
[blockedReason]="biasExperimentBlockedReason()"
|
||||
(startExperimentRequested)="openBiasedRerunDialog()" />
|
||||
}
|
||||
</div>
|
||||
</aside>
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { CommonModule } from '@angular/common';
|
|||
import { FormsModule } from '@angular/forms';
|
||||
import { ChangeDetectionStrategy, Component, computed, effect, ElementRef, HostListener, inject, input, OnDestroy, signal, viewChild } from '@angular/core';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { NotificationService } from '@services/notifications/notification';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
|
|
@ -120,6 +121,7 @@ export class TaskExecutionViewerComponent implements OnDestroy {
|
|||
private biasRerunDialog = inject(BiasRerunDialogService);
|
||||
private biasCompareDialog = inject(BiasCompareDialogService);
|
||||
private biasComparisonViewState = inject(BiasComparisonViewStateService);
|
||||
private notifications = inject(NotificationService);
|
||||
private router = inject(Router);
|
||||
private route = inject(ActivatedRoute);
|
||||
private lastExecutionId: string | null = null;
|
||||
|
|
@ -682,6 +684,28 @@ export class TaskExecutionViewerComponent implements OnDestroy {
|
|||
&& getExecutionStatusGroup(this.execution()?.context.status) === 'FINAL'
|
||||
&& !this.biasRerunOpening()
|
||||
);
|
||||
/** How many nodes an experiment could act on. Sync, so the empty state can say it for free. */
|
||||
readonly biasAnnotatedNodeCount = computed(() => this.biasAnnotatedNodes().length);
|
||||
|
||||
/**
|
||||
* Why a bias experiment cannot be started here, or null when it can.
|
||||
*
|
||||
* The preconditions were only ever expressed as a disabled control or, worse, a silent return.
|
||||
* Stated as a sentence they are also what the empty Bias impact tab needs to explain itself.
|
||||
*/
|
||||
readonly biasExperimentBlockedReason = computed<string | null>(() => {
|
||||
if (this.isSubflowExecution()) {
|
||||
return 'A subflow run cannot be a baseline. Open its parent run to start an experiment.';
|
||||
}
|
||||
if (getExecutionStatusGroup(this.execution()?.context.status) !== 'FINAL') {
|
||||
return 'This run has not finished yet. An experiment compares it against a rerun, so it needs a completed baseline.';
|
||||
}
|
||||
if (this.biasAnnotatedNodeCount() === 0) {
|
||||
return 'No node in this flow carries a bias probe that can be activated. Add an instruction to a bias or mitigation annotation in the editor first.';
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
readonly canCompareBiasExecution = computed(() =>
|
||||
!this.isSubflowExecution()
|
||||
&& this.isBiasVariant()
|
||||
|
|
@ -1081,7 +1105,18 @@ export class TaskExecutionViewerComponent implements OnDestroy {
|
|||
this.biasRerunOpening.set(true);
|
||||
try {
|
||||
const candidates = await this.biasRerunCandidates();
|
||||
if (!candidates.length) return;
|
||||
if (!candidates.length) {
|
||||
// Returning silently made the button indistinguishable from a broken one. The reason is
|
||||
// knowable: either nothing carries an activatable probe, or the nodes that do are of a
|
||||
// type the backend will not run a full-flow experiment on.
|
||||
this.notifications.show(
|
||||
this.biasExperimentBlockedReason()
|
||||
?? 'None of the annotated nodes in this flow support a full-flow bias experiment.',
|
||||
'info',
|
||||
6000
|
||||
);
|
||||
return;
|
||||
}
|
||||
this.biasRerunDialog.open({
|
||||
executionId: execution.id,
|
||||
candidates,
|
||||
|
|
@ -1534,8 +1569,13 @@ export class TaskExecutionViewerComponent implements OnDestroy {
|
|||
return this.getExecutionDependencies().some((dependency) => String(dependency.targetId) === stepId);
|
||||
}
|
||||
|
||||
private async biasRerunCandidates(): Promise<BiasRerunCandidate[]> {
|
||||
const candidates = this.stepsArray().flatMap((step): Array<{ nodeId: string; nodeName: string; node: FlowNode }> => {
|
||||
/**
|
||||
* The nodes carrying something an experiment could activate. Split out of biasRerunCandidates
|
||||
* because it needs no network: the capability check does, and the empty state wants a count
|
||||
* without firing a request per node type just to render a sentence.
|
||||
*/
|
||||
private biasAnnotatedNodes(): Array<{ nodeId: string; nodeName: string; node: FlowNode }> {
|
||||
return this.stepsArray().flatMap((step): Array<{ nodeId: string; nodeName: string; node: FlowNode }> => {
|
||||
const node = mergeExecutionStepNode(
|
||||
step,
|
||||
this.execution()?.flowSnapshot ?? this.sourceFlowData()
|
||||
|
|
@ -1552,7 +1592,10 @@ export class TaskExecutionViewerComponent implements OnDestroy {
|
|||
const annotations = (block.biasAnnotations ?? []).filter((annotation) => isProbeExecutable(annotation.biasProbe) || isProbeExecutable(annotation.mitigationProbe));
|
||||
return annotations.length ? [{ nodeId: step.id, nodeName: node.name || step.id, node: { ...block, biasAnnotations: annotations } }] : [];
|
||||
});
|
||||
}
|
||||
|
||||
private async biasRerunCandidates(): Promise<BiasRerunCandidate[]> {
|
||||
const candidates = this.biasAnnotatedNodes();
|
||||
const resolved = await Promise.all(candidates.map(async (candidate) => {
|
||||
const capabilities = await firstValueFrom(
|
||||
candidate.node.nodeFamily === 'container'
|
||||
|
|
|
|||
Loading…
Reference in New Issue