From 68f4b6db8586c6a8b54fd70a4856fd29bd449a44 Mon Sep 17 00:00:00 2001 From: Lucio Lelii Date: Tue, 8 Sep 2026 11:41:24 +0200 Subject: [PATCH] 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 --- src/app/app.html | 1 + src/app/app.ts | 3 +- .../services/dialogs/bias-report-dialog.ts | 26 +++ .../bias-impact-report-list.css | 41 ++-- .../bias-impact-report-list.html | 111 +++++------ .../bias-impact-report-list.spec.ts | 75 ++++--- .../bias-impact-report-list.ts | 88 +++------ .../bias-report-dialog/bias-report-dialog.css | 3 + .../bias-report-dialog.html | 27 +++ .../bias-report-dialog.spec.ts | 187 ++++++++++++++++++ .../bias-report-dialog/bias-report-dialog.ts | 107 ++++++++++ 11 files changed, 496 insertions(+), 173 deletions(-) create mode 100644 src/app/services/dialogs/bias-report-dialog.ts create mode 100644 src/app/shared/bias-report-dialog/bias-report-dialog.css create mode 100644 src/app/shared/bias-report-dialog/bias-report-dialog.html create mode 100644 src/app/shared/bias-report-dialog/bias-report-dialog.spec.ts create mode 100644 src/app/shared/bias-report-dialog/bias-report-dialog.ts diff --git a/src/app/app.html b/src/app/app.html index 501c7f9..69ca37e 100644 --- a/src/app/app.html +++ b/src/app/app.html @@ -7,6 +7,7 @@ + diff --git a/src/app/app.ts b/src/app/app.ts index 2630d20..71434be 100644 --- a/src/app/app.ts +++ b/src/app/app.ts @@ -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 diff --git a/src/app/services/dialogs/bias-report-dialog.ts b/src/app/services/dialogs/bias-report-dialog.ts new file mode 100644 index 0000000..49375f2 --- /dev/null +++ b/src/app/services/dialogs/bias-report-dialog.ts @@ -0,0 +1,26 @@ +import { Injectable, signal } from '@angular/core'; + +export type BiasReportDialogInput = { + reportId: string; +}; + +/** + * Opens a persisted bias impact report on its own. + * + *

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(null); + readonly state = this._state.asReadonly(); + + open(input: BiasReportDialogInput) { + this._state.set(input); + } + + close() { + this._state.set(null); + } +} diff --git a/src/app/shared/bias-impact-report-list/bias-impact-report-list.css b/src/app/shared/bias-impact-report-list/bias-impact-report-list.css index 0d5ecac..decef27 100644 --- a/src/app/shared/bias-impact-report-list/bias-impact-report-list.css +++ b/src/app/shared/bias-impact-report-list/bias-impact-report-list.css @@ -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; } diff --git a/src/app/shared/bias-impact-report-list/bias-impact-report-list.html b/src/app/shared/bias-impact-report-list/bias-impact-report-list.html index 0cfbf88..47651fc 100644 --- a/src/app/shared/bias-impact-report-list/bias-impact-report-list.html +++ b/src/app/shared/bias-impact-report-list/bias-impact-report-list.html @@ -1,63 +1,64 @@ -@if (detailMode()) { -

- - @if (detailLoading()) { -

Loading report…

- } - @if (detailError(); as error) { +
+ @if (loading()) { +

Loading bias impact reports…

+ } @else if (listError(); as error) { +

{{ error }}

- } - @if (selectedReport(); as report) { - - } -
-} @else { -
- @if (loading()) { -

Loading bias impact reports…

- } @else if (listError(); as error) { -
-

{{ error }}

- -
- } @else if (!reports().length) { -
-

No bias impact reports for this run yet.

+ +
+ } @else if (!reports().length) { +
+

No bias impact reports for this run yet.

+ @if (comparableVariant()) { +

+ 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. +

+ + } @else { +

+ 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. +

+ @if (blockedReason(); as reason) { +

{{ reason }}

+ } @else {

- 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.

- @if (blockedReason(); as reason) { -

{{ reason }}

- } @else { -

- {{ annotatedNodeCount() }} - {{ annotatedNodeCount() === 1 ? 'node carries a probe' : 'nodes carry a probe' }} - that can be activated on this run. -

- - } -
- } @else { - @for (report of reports(); track report.id) { - + } + } +
+ } @else { + @for (report of reports(); track report.id) { + - } + {{ report.createdAt | date:'short' }} + + {{ report.summary }} + + {{ annotationCount(report) }} annotation(s) + @if (report.judgements?.length) { + + LLM assessed{{ (report.judgements?.length ?? 0) > 1 ? ' ×' + report.judgements?.length : '' }} + + } + @if (report.nodeId) { + {{ report.nodeId }} + } + + } -
-} + } +
diff --git a/src/app/shared/bias-impact-report-list/bias-impact-report-list.spec.ts b/src/app/shared/bias-impact-report-list/bias-impact-report-list.spec.ts index d6d62d4..23e9652 100644 --- a/src/app/shared/bias-impact-report-list/bias-impact-report-list.spec.ts +++ b/src/app/shared/bias-impact-report-list/bias-impact-report-list.spec.ts @@ -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 { 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'); }); }); diff --git a/src/app/shared/bias-impact-report-list/bias-impact-report-list.ts b/src/app/shared/bias-impact-report-list/bias-impact-report-list.ts index 1ad6de5..45ed865 100644 --- a/src/app/shared/bias-impact-report-list/bias-impact-report-list.ts +++ b/src/app/shared/bias-impact-report-list/bias-impact-report-list.ts @@ -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(0); /** Why an experiment cannot be started, or null when it can; supplied by the host. */ readonly blockedReason = input(null); + /** + * True when the run on screen is itself a bias variant with a baseline to compare against. + * + *

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(false); /** The user wants to start one. The host owns the dialog, so it decides what "start" means. */ readonly startExperimentRequested = output(); + /** The user wants this variant compared with its baseline. The host owns that dialog too. */ + readonly compareRequested = output(); readonly reports = signal([]); readonly loading = signal(false); readonly listError = signal(null); - readonly selectedReport = signal(null); - readonly detailLoading = signal(false); - readonly detailError = signal(null); - readonly judging = signal(false); - readonly judgeError = signal(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. + * + *

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 { diff --git a/src/app/shared/bias-report-dialog/bias-report-dialog.css b/src/app/shared/bias-report-dialog/bias-report-dialog.css new file mode 100644 index 0000000..66c1a3b --- /dev/null +++ b/src/app/shared/bias-report-dialog/bias-report-dialog.css @@ -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; } diff --git a/src/app/shared/bias-report-dialog/bias-report-dialog.html b/src/app/shared/bias-report-dialog/bias-report-dialog.html new file mode 100644 index 0000000..41be28c --- /dev/null +++ b/src/app/shared/bias-report-dialog/bias-report-dialog.html @@ -0,0 +1,27 @@ +@if (state()) { + + @if (loading()) { +

Loading report…

+ } + @if (inlineError(); as error) { +
+

{{ error }}

+ +
+ } + @if (report(); as loadedReport) { + + } + +} diff --git a/src/app/shared/bias-report-dialog/bias-report-dialog.spec.ts b/src/app/shared/bias-report-dialog/bias-report-dialog.spec.ts new file mode 100644 index 0000000..6249001 --- /dev/null +++ b/src/app/shared/bias-report-dialog/bias-report-dialog.spec.ts @@ -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 { + 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; + let getBiasImpactReport: ReturnType; + let assess: ReturnType; + let show: ReturnType; + 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((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((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.'); + }); +}); diff --git a/src/app/shared/bias-report-dialog/bias-report-dialog.ts b/src/app/shared/bias-report-dialog/bias-report-dialog.ts new file mode 100644 index 0000000..c055173 --- /dev/null +++ b/src/app/shared/bias-report-dialog/bias-report-dialog.ts @@ -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(null); + readonly report = signal(null); + readonly judging = signal(false); + readonly judgeError = signal(null); + + /** + * Which assessment is the one still worth showing. + * + *

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.')); + } + }); + } +}