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