Open a bias report in its own window, and make the reports list a list

A report is a wide, two-column document. It rendered inside the run's side
panel - a few hundred pixels at most - where the two-column diff collapsed
into a ribbon and the summary ran off the row's right edge with a horizontal
scrollbar to prove it. It now opens in a dialog of its own, at the same width
the comparison already uses, wired next to the other dialog hosts in the app
shell. The list behind it goes back to being a list: one row read top to
bottom (kind, changed/unchanged, date; a two-line summary; annotation count
and node id), and it no longer owns the fetching or the LLM-assessment state
that the detail view needs - the dialog host does, the same way the compare
dialog already did.

Separately, the empty Bias impact tab offered "Run a biased rerun" on a run
that already is one - asking to make a variant of a variant. On a run that is
itself a comparable variant, the tab now offers "Compare with baseline"
instead, wired to the same dialog the toolbar button opens.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Lucio Lelii 2026-09-08 11:41:24 +02:00
parent c298c352b3
commit 68f4b6db85
11 changed files with 496 additions and 173 deletions

View File

@ -7,6 +7,7 @@
<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>
<app-bias-report-dialog-host></app-bias-report-dialog-host>
<app-project-dialog></app-project-dialog>
<app-project-delete-dialog></app-project-delete-dialog>
<app-project-context-dialog></app-project-context-dialog>

View File

@ -8,13 +8,14 @@ import { SubflowPreviewDialogHostComponent } from '@shared/subflow-preview-dialo
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';
import { BiasReportDialogHostComponent } from '@shared/bias-report-dialog/bias-report-dialog';
import { ProjectContextDialogComponent } from '@shared/project-context-dialog/project-context-dialog';
import { ProjectDeleteDialogComponent } from '@shared/project-delete-dialog/project-delete-dialog';
import { ProjectDialogComponent } from '@shared/project-dialog/project-dialog';
@Component({
selector: 'app-root',
imports: [RouterOutlet, ConfirmDialogHostComponent, GlobalNotificationComponent, HumanInteractionDialogHostComponent, NodeSettingsDialogHostComponent, SubflowPreviewDialogHostComponent, BiasImpactExperimentDialogHostComponent, BiasRerunDialogHostComponent, BiasCompareDialogHostComponent, ProjectDialogComponent, ProjectDeleteDialogComponent, ProjectContextDialogComponent],
imports: [RouterOutlet, ConfirmDialogHostComponent, GlobalNotificationComponent, HumanInteractionDialogHostComponent, NodeSettingsDialogHostComponent, SubflowPreviewDialogHostComponent, BiasImpactExperimentDialogHostComponent, BiasRerunDialogHostComponent, BiasCompareDialogHostComponent, BiasReportDialogHostComponent, ProjectDialogComponent, ProjectDeleteDialogComponent, ProjectContextDialogComponent],
templateUrl: './app.html',
styleUrl: './app.css',
changeDetection: ChangeDetectionStrategy.OnPush

View File

@ -0,0 +1,26 @@
import { Injectable, signal } from '@angular/core';
export type BiasReportDialogInput = {
reportId: string;
};
/**
* Opens a persisted bias impact report on its own.
*
* <p>A report is a wide, two-column document: it was rendered inside the run's side panel, where the
* columns collapsed into a ribbon a few words wide and the heading wrapped one word per line. It
* belongs in a modal, the same one the comparison already opens itself in.
*/
@Injectable({ providedIn: 'root' })
export class BiasReportDialogService {
private readonly _state = signal<BiasReportDialogInput | null>(null);
readonly state = this._state.asReadonly();
open(input: BiasReportDialogInput) {
this._state.set(input);
}
close() {
this._state.set(null);
}
}

View File

@ -29,31 +29,46 @@
line-height: 1.45;
}
/*
* One row, read downwards. It used to be six columns on one line, which in the run's side panel -
* a few hundred pixels wide - pushed the summary off the right edge and gave the whole tab a
* horizontal scrollbar.
*/
.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;
gap: .3rem;
min-width: 0;
padding: .6rem .75rem;
text-align: left;
transition: background-color 120ms ease;
width: 100%;
}
.bias-report-list__row:hover { background: #f1f5f9; }
.bias-report-list__row-head { align-items: center; display: flex; flex-wrap: wrap; gap: .4rem; }
.bias-report-list__row-meta { align-items: center; display: flex; flex-wrap: wrap; gap: .35rem .6rem; min-width: 0; }
.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__date { color: #64748b; font-size: .74rem; margin-left: auto; white-space: nowrap; }
.bias-report-list__detail { display: grid; gap: .9rem; padding: .75rem; }
@media (max-width: 640px) {
.bias-report-list__row { grid-template-columns: 1fr; }
/* Two lines of the summary: enough to tell two reports apart, not enough to bury the next one. */
.bias-report-list__summary {
color: #1e293b;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
line-clamp: 2;
font-size: .84rem;
line-height: 1.4;
overflow: hidden;
}
.bias-report-list__node { color: #94a3b8; font-size: .7rem; max-width: 12rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.bias-report-list__annotations { color: #64748b; font-size: .74rem; white-space: nowrap; }
.bias-report-list__judged { background: #ede9fe; border-radius: 999px; color: #5b21b6; font-size: .68rem; font-weight: 700; padding: .1rem .4rem; white-space: nowrap; }
.bias-report-list__changed { color: #64748b; font-size: .72rem; font-weight: 700; letter-spacing: .03em; text-transform: uppercase; white-space: nowrap; }
.bias-report-list__changed.changed { color: #b45309; }

View File

@ -1,63 +1,64 @@
@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) {
<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>
}
@if (selectedReport(); as report) {
<app-bias-impact-report-viewer
[report]="report"
[judging]="judging()"
[judgeError]="judgeError()"
(highlightOnCanvas)="highlightOnCanvas()"
(evaluateWithLlm)="evaluateWithLlm()" />
}
</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) {
<div class="bias-report-list__empty">
<p class="bias-report-list__empty-lead">No bias impact reports for this run yet.</p>
<button type="button" mat-stroked-button (click)="retry()">Retry</button>
</div>
} @else if (!reports().length) {
<div class="bias-report-list__empty">
<p class="bias-report-list__empty-lead">No bias impact reports for this run yet.</p>
@if (comparableVariant()) {
<p class="bias-report-list__empty-body">
This run is the variant: its probes were active while it ran. Comparing it with the run it
repeats is what produces the report, and the report then appears here.
</p>
<button type="button" mat-flat-button (click)="compareRequested.emit()">
Compare with baseline
</button>
} @else {
<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">
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.
{{ annotatedNodeCount() }}
{{ annotatedNodeCount() === 1 ? 'node carries a probe' : 'nodes carry a probe' }}
that can be activated on this run.
</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)">
<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)">
<span class="bias-report-list__row-head">
<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>
}
<span class="bias-report-list__date">{{ report.createdAt | date:'short' }}</span>
</span>
<span class="bias-report-list__summary">{{ report.summary }}</span>
<span class="bias-report-list__row-meta">
<span class="bias-report-list__annotations">{{ annotationCount(report) }} annotation(s)</span>
@if (report.judgements?.length) {
<span class="bias-report-list__judged">
LLM assessed{{ (report.judgements?.length ?? 0) > 1 ? ' ×' + report.judgements?.length : '' }}
</span>
}
@if (report.nodeId) {
<code class="bias-report-list__node" [title]="report.nodeId">{{ report.nodeId }}</code>
}
</span>
</button>
}
</div>
}
}
</div>

View File

@ -5,6 +5,7 @@ import { BiasImpactReportListComponent } from './bias-impact-report-list';
import { TaskExecutionsService } from '@services/task-executions/task-executions';
import { BiasImpactReport } from '@models/bias-impact';
import { BiasReportsRevisionService } from '@services/bias/bias-reports-revision';
import { BiasReportDialogService } from '@services/dialogs/bias-report-dialog';
function makeReport(overrides: Partial<BiasImpactReport> = {}): BiasImpactReport {
return {
@ -116,24 +117,6 @@ describe('BiasImpactReportListComponent', () => {
expect(listBiasImpactReports).toHaveBeenCalledTimes(1);
});
it('keeps an open report open across a reload, and closes it when the execution changes', () => {
fixture.componentRef.setInput('executionId', 'execution-1');
fixture.detectChanges();
fixture.componentInstance.openDetail('report-1');
fixture.detectChanges();
expect(fixture.componentInstance.selectedReport()).not.toBeNull();
// A reload of the same run must not yank away what the user is reading.
TestBed.inject(BiasReportsRevisionService).reportProduced();
fixture.detectChanges();
expect(fixture.componentInstance.selectedReport()).not.toBeNull();
// A different run is a clean slate.
fixture.componentRef.setInput('executionId', 'execution-2');
fixture.detectChanges();
expect(fixture.componentInstance.selectedReport()).toBeNull();
});
it('loads and renders the reports for the given execution', () => {
fixture.componentRef.setInput('executionId', 'execution-1');
fixture.detectChanges();
@ -165,37 +148,49 @@ describe('BiasImpactReportListComponent', () => {
expect(fixture.componentInstance.reports().length).toBe(1);
});
it('opens the report detail via getBiasImpactReport and can navigate back to the list', () => {
it('opens a report in a window of its own rather than inside the panel', () => {
fixture.componentRef.setInput('executionId', 'execution-1');
fixture.detectChanges();
fixture.componentInstance.openDetail('report-1');
fixture.detectChanges();
(fixture.nativeElement.querySelector('.bias-report-list__row') as HTMLButtonElement).click();
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);
// The report is a two-column document and this panel is a few words wide; the dialog host owns
// the loading, so the list itself never fetches one.
expect(TestBed.inject(BiasReportDialogService).state()).toEqual({ reportId: 'report-1' });
expect(getBiasImpactReport).not.toHaveBeenCalled();
expect(fixture.nativeElement.querySelector('app-bias-impact-report-viewer')).toBeNull();
});
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');
it('offers the comparison, not another rerun, when the run on screen is the variant', async () => {
listBiasImpactReports.mockReturnValue(of([]));
fixture.componentRef.setInput('executionId', 'variant-1');
fixture.componentRef.setInput('comparableVariant', true);
fixture.detectChanges();
await fixture.whenStable();
fixture.detectChanges();
fixture.componentInstance.openDetail('missing-report');
const text = fixture.nativeElement.textContent;
expect(text).toContain('This run is the variant');
expect(text).not.toContain('Run a biased rerun');
let compareAsked = 0;
fixture.componentInstance.compareRequested.subscribe(() => { compareAsked += 1; });
const button = fixture.nativeElement.querySelector('.bias-report-list__empty button') as HTMLButtonElement;
expect(button.textContent).toContain('Compare with baseline');
button.click();
expect(compareAsked).toBe(1);
});
it('still offers a rerun on a run that is not a variant', async () => {
listBiasImpactReports.mockReturnValue(of([]));
fixture.componentRef.setInput('executionId', 'baseline-1');
fixture.componentRef.setInput('annotatedNodeCount', 2);
fixture.detectChanges();
await fixture.whenStable();
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.');
const text = fixture.nativeElement.textContent;
expect(text).toContain('Run a biased rerun');
expect(text).not.toContain('This run is the variant');
});
});

View File

@ -1,27 +1,23 @@
import { CommonModule } from '@angular/common';
import { ChangeDetectionStrategy, Component, computed, effect, inject, input, output, signal } from '@angular/core';
import { ChangeDetectionStrategy, Component, 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';
import { BiasComparisonViewStateService } from '@services/bias/bias-comparison-view-state';
import { BiasReportsRevisionService } from '@services/bias/bias-reports-revision';
import { BiasReportJudgeService } from '@services/bias/bias-report-judge';
import { extractBiasErrorMessage } from '@services/bias/bias-error.util';
import { BiasImpactReportViewerComponent } from '@shared/bias-impact-report-viewer/bias-impact-report-viewer';
import { BiasReportDialogService } from '@services/dialogs/bias-report-dialog';
@Component({
selector: 'app-bias-impact-report-list',
standalone: true,
imports: [CommonModule, MatButtonModule, BiasImpactReportViewerComponent],
imports: [CommonModule, MatButtonModule],
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 readonly reportsRevision = inject(BiasReportsRevisionService);
private readonly reportJudge = inject(BiasReportJudgeService);
private readonly reportDialog = inject(BiasReportDialogService);
private lastExecutionId: string | null = null;
private lastReloadToken = 0;
@ -30,24 +26,25 @@ export class BiasImpactReportListComponent {
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);
/**
* True when the run on screen is itself a bias variant with a baseline to compare against.
*
* <p>Without this the empty tab offered "Run a biased rerun" on a run that already is one, which
* reads as an instruction to make a variant of a variant. What such a run needs is the comparison
* with the run it repeats - which is also the thing that produces the report this tab is waiting
* for.
*/
readonly comparableVariant = input<boolean>(false);
/** The user wants to start one. The host owns the dialog, so it decides what "start" means. */
readonly startExperimentRequested = output<void>();
/** The user wants this variant compared with its baseline. The host owns that dialog too. */
readonly compareRequested = output<void>();
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 judging = signal(false);
readonly judgeError = signal<string | null>(null);
readonly detailMode = computed(() =>
this.selectedReport() !== null || this.detailLoading() || this.detailError() !== null
);
constructor() {
effect(() => {
const executionId = this.executionId();
@ -57,9 +54,6 @@ export class BiasImpactReportListComponent {
if (!executionChanged && !reloadRequested) return;
this.lastExecutionId = executionId;
this.lastReloadToken = reloadToken;
// A different execution starts from a clean slate; a reload of the same one keeps whatever
// report the user was reading open.
if (executionChanged) this.closeDetail();
this.loadReports(executionId);
});
}
@ -68,50 +62,16 @@ export class BiasImpactReportListComponent {
this.loadReports(this.executionId());
}
/**
* Reads a report in a window of its own.
*
* <p>It used to open in place, inside the run's side panel: the report is a two-column document
* and the panel is a few words wide, so the two sides collapsed into a ribbon and the heading
* wrapped one word per line. The dialog host owns the loading, the LLM assessment and the canvas
* highlight, exactly as it does for a comparison.
*/
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);
this.judgeError.set(null);
}
/** The assessment is stored on the report, so the open detail is replaced with the judged one. */
async evaluateWithLlm() {
const report = this.selectedReport();
if (!report || this.judging()) return;
this.judging.set(true);
this.judgeError.set(null);
try {
const judged = await this.reportJudge.assess(report.id);
if (judged) this.selectedReport.set(judged);
} catch (error) {
this.judgeError.set(extractBiasErrorMessage(error, 'Unable to evaluate this comparison with an LLM.'));
} finally {
this.judging.set(false);
}
}
highlightOnCanvas() {
const report = this.selectedReport();
if (!report) return;
this.comparisonViewState.show({ report });
this.reportDialog.open({ reportId });
}
annotationCount(report: BiasImpactReport): number {

View File

@ -0,0 +1,3 @@
.bias-report-dialog__progress { background: #eff6ff; border-left: 3px solid #2563eb; color: #1e3a8a; margin: 0; padding: .6rem .7rem; }
.bias-report-dialog__error { background: #fff1f2; border-left: 3px solid #e11d48; color: #9f1239; margin: 0; padding: .6rem .7rem; }
.bias-report-dialog__error-row { align-items: center; display: flex; gap: .6rem; justify-content: space-between; }

View File

@ -0,0 +1,27 @@
@if (state()) {
<app-modal-shell
title="Bias impact report"
subtitle="Persisted comparison for this run"
ariaLabel="Bias impact report"
maxWidth="1040px"
(backdropClick)="close()"
(closeClick)="close()">
@if (loading()) {
<p class="bias-report-dialog__progress">Loading report…</p>
}
@if (inlineError(); as error) {
<div class="bias-report-dialog__error-row">
<p class="bias-report-dialog__error">{{ error }}</p>
<button type="button" mat-stroked-button (click)="retry()">Retry</button>
</div>
}
@if (report(); as loadedReport) {
<app-bias-impact-report-viewer
[report]="loadedReport"
[judging]="judging()"
[judgeError]="judgeError()"
(highlightOnCanvas)="highlightOnCanvas()"
(evaluateWithLlm)="evaluateWithLlm()" />
}
</app-modal-shell>
}

View File

@ -0,0 +1,187 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { of, throwError } from 'rxjs';
import { vi } from 'vitest';
import { BiasImpactReport } from '@models/bias-impact';
import { BiasComparisonViewStateService } from '@services/bias/bias-comparison-view-state';
import { BiasReportJudgeService } from '@services/bias/bias-report-judge';
import { BiasReportDialogService } from '@services/dialogs/bias-report-dialog';
import { TaskExecutionsService } from '@services/task-executions/task-executions';
import { BiasReportDialogHostComponent } from './bias-report-dialog';
function makeReport(overrides: Partial<BiasImpactReport> = {}): BiasImpactReport {
return {
id: 'report-1',
experimentId: 'experiment-1',
kind: 'FULL_FLOW',
interventionDirection: 'BIAS',
baselineExecutionId: 'baseline-1',
biasedExecutionId: 'variant-1',
nodeId: null,
annotationIds: ['annotation-1'],
repetitions: 1,
createdAt: '2026-09-08T11:02:32.000Z',
rawOutputsIncluded: true,
immediateImpact: {
outputChanged: true,
maximumTextDifference: 0.9,
changeRate: 1,
baselineOutput: {},
biasedOutputs: [],
values: [],
iterations: []
},
downstreamImpact: [],
routingChanges: [],
mockedSideEffects: [],
summary: '3 of 3 evaluated subjects changed.',
warnings: [],
...overrides
};
}
describe('BiasReportDialogHostComponent', () => {
let fixture: ComponentFixture<BiasReportDialogHostComponent>;
let getBiasImpactReport: ReturnType<typeof vi.fn>;
let assess: ReturnType<typeof vi.fn>;
let show: ReturnType<typeof vi.fn>;
let dialog: BiasReportDialogService;
beforeEach(async () => {
getBiasImpactReport = vi.fn().mockReturnValue(of(makeReport()));
assess = vi.fn();
show = vi.fn();
await TestBed.configureTestingModule({
imports: [BiasReportDialogHostComponent],
providers: [
{ provide: TaskExecutionsService, useValue: { getBiasImpactReport } },
{ provide: BiasReportJudgeService, useValue: { assess } },
{ provide: BiasComparisonViewStateService, useValue: { show } }
]
}).compileComponents();
fixture = TestBed.createComponent(BiasReportDialogHostComponent);
dialog = TestBed.inject(BiasReportDialogService);
});
it('shows nothing until a report is asked for', () => {
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('app-modal-shell')).toBeNull();
expect(getBiasImpactReport).not.toHaveBeenCalled();
});
it('loads the report it was opened on and renders it at full width', () => {
dialog.open({ reportId: 'report-1' });
fixture.detectChanges();
expect(getBiasImpactReport).toHaveBeenCalledWith('report-1');
expect(fixture.componentInstance.report()?.id).toBe('report-1');
expect(fixture.nativeElement.textContent).toContain('3 of 3 evaluated subjects changed.');
});
it('offers a retry on a report that could not be loaded', () => {
getBiasImpactReport.mockReturnValue(throwError(() => ({ status: 404 })));
dialog.open({ reportId: 'missing' });
fixture.detectChanges();
expect(fixture.componentInstance.inlineError()).toContain('not found');
getBiasImpactReport.mockReturnValue(of(makeReport({ id: 'missing' })));
fixture.componentInstance.retry();
fixture.detectChanges();
expect(fixture.componentInstance.inlineError()).toBeNull();
expect(fixture.componentInstance.report()?.id).toBe('missing');
});
it('replaces the report on screen with the assessed one', async () => {
const judged = makeReport({ summary: 'assessed' });
assess.mockResolvedValue(judged);
dialog.open({ reportId: 'report-1' });
fixture.detectChanges();
await fixture.componentInstance.evaluateWithLlm();
expect(assess).toHaveBeenCalledWith('report-1');
expect(fixture.componentInstance.report()).toBe(judged);
expect(fixture.componentInstance.judging()).toBe(false);
});
it('keeps the report readable when the assessment fails', async () => {
assess.mockRejectedValue(new Error('the judge provider is unreachable'));
dialog.open({ reportId: 'report-1' });
fixture.detectChanges();
await fixture.componentInstance.evaluateWithLlm();
expect(fixture.componentInstance.judgeError()).toContain('unreachable');
expect(fixture.componentInstance.report()?.id).toBe('report-1');
});
it('closes itself when the report is sent to the canvas, which is behind it', () => {
dialog.open({ reportId: 'report-1' });
fixture.detectChanges();
fixture.componentInstance.highlightOnCanvas();
expect(show).toHaveBeenCalledWith({ report: expect.objectContaining({ id: 'report-1' }) });
expect(dialog.state()).toBeNull();
});
it('drops a verdict that arrives after the dialog was closed, and does not stay stuck evaluating', async () => {
let resolveAssessment: (report: BiasImpactReport) => void = () => undefined;
assess.mockReturnValue(new Promise<BiasImpactReport>((resolve) => { resolveAssessment = resolve; }));
dialog.open({ reportId: 'report-1' });
fixture.detectChanges();
const running = fixture.componentInstance.evaluateWithLlm();
expect(fixture.componentInstance.judging()).toBe(true);
// The job keeps running on the server and stores its verdicts on the report; the dialog that
// asked for it is simply no longer there.
fixture.componentInstance.close();
fixture.detectChanges();
resolveAssessment(makeReport({ summary: 'assessed late' }));
await running;
expect(fixture.componentInstance.judging()).toBe(false);
expect(fixture.componentInstance.report()).toBeNull();
});
it('does not let a late verdict land on the next report opened', async () => {
let resolveAssessment: (report: BiasImpactReport) => void = () => undefined;
assess.mockReturnValue(new Promise<BiasImpactReport>((resolve) => { resolveAssessment = resolve; }));
dialog.open({ reportId: 'report-1' });
fixture.detectChanges();
const running = fixture.componentInstance.evaluateWithLlm();
getBiasImpactReport.mockReturnValue(of(makeReport({ id: 'report-2', summary: 'another report' })));
dialog.open({ reportId: 'report-2' });
fixture.detectChanges();
resolveAssessment(makeReport({ id: 'report-1', summary: 'assessed late' }));
await running;
expect(fixture.componentInstance.report()?.id).toBe('report-2');
expect(fixture.componentInstance.judging()).toBe(false);
});
it('reports an assessment stored earlier when the report is opened again', () => {
getBiasImpactReport.mockReturnValue(of(makeReport({
judgements: [{
judge: { provider: 'InternalOllama', model: 'gemma:7b' },
judgedAt: '2026-09-08T11:30:00.000Z',
impact: 'SUBSTANTIVE',
attribution: 'INJECTION',
narrative: 'Two subjects were assessed differently.',
judgedPairs: 2,
skippedPairs: 1,
errors: []
}]
})));
dialog.open({ reportId: 'report-1' });
fixture.detectChanges();
expect(fixture.nativeElement.textContent).toContain('Two subjects were assessed differently.');
});
});

View File

@ -0,0 +1,107 @@
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 { ModalShellComponent } from '@shared/modal-shell/modal-shell';
import { BiasReportDialogService } from '@services/dialogs/bias-report-dialog';
import { BiasComparisonViewStateService } from '@services/bias/bias-comparison-view-state';
import { BiasReportJudgeService } from '@services/bias/bias-report-judge';
import { extractBiasErrorMessage } from '@services/bias/bias-error.util';
import { TaskExecutionsService } from '@services/task-executions/task-executions';
import { BiasImpactReport } from '@models/bias-impact';
/** Reads one persisted report in the width it needs, and offers the same actions the compare does. */
@Component({
selector: 'app-bias-report-dialog-host',
standalone: true,
imports: [MatButtonModule, BiasImpactReportViewerComponent, ModalShellComponent],
templateUrl: './bias-report-dialog.html',
styleUrl: './bias-report-dialog.css',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class BiasReportDialogHostComponent {
private readonly dialog = inject(BiasReportDialogService);
private readonly executions = inject(TaskExecutionsService);
private readonly comparisonViewState = inject(BiasComparisonViewStateService);
private readonly reportJudge = inject(BiasReportJudgeService);
readonly state = this.dialog.state;
readonly loading = signal(false);
readonly inlineError = signal<string | null>(null);
readonly report = signal<BiasImpactReport | null>(null);
readonly judging = signal(false);
readonly judgeError = signal<string | null>(null);
/**
* Which assessment is the one still worth showing.
*
* <p>Closing the dialog does not stop the job - it runs on the server and stores its verdicts on
* the report, so nothing is lost and reopening shows them. What must not happen is a late answer
* landing on a dialog that has since been closed, or reopened on a different report.
*/
private judgeToken = 0;
constructor() {
effect(() => {
const state = this.state();
this.report.set(null);
this.inlineError.set(null);
this.judgeError.set(null);
this.loading.set(false);
this.judging.set(false);
this.judgeToken++;
if (!state) return;
this.load(state.reportId);
});
}
retry() {
const state = this.state();
if (state) this.load(state.reportId);
}
close() {
this.dialog.close();
}
highlightOnCanvas() {
const report = this.report();
if (!report) return;
this.comparisonViewState.show({ report });
this.close();
}
async evaluateWithLlm() {
const report = this.report();
if (!report || this.judging()) return;
const token = ++this.judgeToken;
this.judging.set(true);
this.judgeError.set(null);
try {
const judged = await this.reportJudge.assess(report.id);
if (token !== this.judgeToken) return;
if (judged) this.report.set(judged);
} catch (error) {
if (token !== this.judgeToken) return;
this.judgeError.set(extractBiasErrorMessage(error, 'Unable to evaluate this comparison with an LLM.'));
} finally {
if (token === this.judgeToken) this.judging.set(false);
}
}
private load(reportId: string) {
this.inlineError.set(null);
this.loading.set(true);
this.executions.getBiasImpactReport(reportId).subscribe({
next: (report) => {
this.loading.set(false);
this.report.set(report);
},
error: (error) => {
this.loading.set(false);
this.inlineError.set(extractBiasErrorMessage(error, 'Report not found or not accessible.'));
}
});
}
}