diff --git a/report-app/src/app/pages/report-list/prompt-badge/prompt-badge-list.html b/report-app/src/app/pages/report-list/prompt-badge/prompt-badge-list.html new file mode 100644 index 00000000..1da62d80 --- /dev/null +++ b/report-app/src/app/pages/report-list/prompt-badge/prompt-badge-list.html @@ -0,0 +1,21 @@ + diff --git a/report-app/src/app/pages/report-list/prompt-badge/prompt-badge-list.ts b/report-app/src/app/pages/report-list/prompt-badge/prompt-badge-list.ts new file mode 100644 index 00000000..36ae26b2 --- /dev/null +++ b/report-app/src/app/pages/report-list/prompt-badge/prompt-badge-list.ts @@ -0,0 +1,140 @@ +import { + Component, + ElementRef, + Injector, + PLATFORM_ID, + afterNextRender, + computed, + effect, + inject, + input, + signal, + viewChild, +} from '@angular/core'; +import {isPlatformServer} from '@angular/common'; + +@Component({ + selector: 'prompt-badge-list', + templateUrl: './prompt-badge-list.html', + styles: ` + .prompt-names { + margin-top: 0.4rem; + display: flex; + width: 100%; + } + + .status-badge { + font-size: 0.75rem; + font-weight: 400; + } + + .toggle-badge { + cursor: pointer; + font-weight: 500; + + &:hover { + opacity: 0.8; + } + } + `, +}) +export class PromptBadgeList { + readonly promptNames = input.required(); + + private readonly isServer = isPlatformServer(inject(PLATFORM_ID)); + private readonly injector = inject(Injector); + private lastContainerWidth = 0; + private pendingMeasure = false; + private containerRef = viewChild.required>('container'); + + protected measuring = signal(true); + protected expanded = signal(false); + protected visibleCount = signal(Infinity); + + protected visibleNames = computed(() => { + const names = this.promptNames(); + if (this.expanded() || this.measuring()) { + return names; + } + const count = this.visibleCount(); + return isFinite(count) ? names.slice(0, count) : names; + }); + + protected hiddenCount = computed(() => { + if (this.expanded() || this.measuring() || !isFinite(this.visibleCount())) { + return 0; + } + return Math.max(0, this.promptNames().length - this.visibleCount()); + }); + + protected showToggle = computed(() => { + if (this.measuring()) { + return false; + } + if (this.expanded()) { + return isFinite(this.visibleCount()); + } + return this.hiddenCount() > 0; + }); + + constructor() { + effect(onCleanup => { + if (this.isServer) { + this.measuring.set(false); + return; + } + const el = this.containerRef().nativeElement; + const observer = new ResizeObserver(entries => { + if (this.pendingMeasure) { + return; + } + const newWidth = Math.round(entries[0].contentRect.width); + if (newWidth !== this.lastContainerWidth) { + this.lastContainerWidth = newWidth; + this.scheduleMeasure(); + } + }); + observer.observe(el); + this.scheduleMeasure(); + + onCleanup(() => observer.disconnect()); + }); + } + + protected toggle(event: Event): void { + event.preventDefault(); + event.stopPropagation(); + this.expanded.update(v => !v); + if (!this.expanded()) { + this.scheduleMeasure(); + } + } + + private scheduleMeasure(): void { + if (this.pendingMeasure) { + return; + } + this.pendingMeasure = true; + this.measuring.set(true); + afterNextRender({read: () => this.doMeasure()}, {injector: this.injector}); + } + + private doMeasure(): void { + this.pendingMeasure = false; + + const container = this.containerRef().nativeElement; + const badges = Array.from(container.querySelectorAll('[data-badge]')) as HTMLElement[]; + + if (badges.length === 0) { + this.measuring.set(false); + return; + } + + const firstTop = badges[0].offsetTop; + const overflowIdx = badges.findIndex(b => b.offsetTop > firstTop); + + // If overflow is detected, reserve one slot for the toggle badge + this.visibleCount.set(overflowIdx === -1 ? Infinity : Math.max(1, overflowIdx - 1)); + this.measuring.set(false); + } +} diff --git a/report-app/src/app/pages/report-list/report-list.html b/report-app/src/app/pages/report-list/report-list.html index 2093eca0..dcc1ca3c 100644 --- a/report-app/src/app/pages/report-list/report-list.html +++ b/report-app/src/app/pages/report-list/report-list.html @@ -37,6 +37,9 @@ } + @if (group.promptNames.length) { + + }
diff --git a/report-app/src/app/pages/report-list/report-list.scss b/report-app/src/app/pages/report-list/report-list.scss index d8ccf521..e3bd3552 100644 --- a/report-app/src/app/pages/report-list/report-list.scss +++ b/report-app/src/app/pages/report-list/report-list.scss @@ -106,6 +106,7 @@ h1, h2 { padding: 0 20px; } + .select-for-comparison input[type='checkbox'] { width: 20px; height: 20px; diff --git a/report-app/src/app/pages/report-list/report-list.ts b/report-app/src/app/pages/report-list/report-list.ts index 6ae2df3d..02787622 100644 --- a/report-app/src/app/pages/report-list/report-list.ts +++ b/report-app/src/app/pages/report-list/report-list.ts @@ -1,4 +1,4 @@ -import {Component, computed, inject, PLATFORM_ID, signal} from '@angular/core'; +import {Component, inject, PLATFORM_ID, signal} from '@angular/core'; import {Router, RouterLink} from '@angular/router'; import {ReportsFetcher} from '../../services/reports-fetcher'; import {DatePipe, isPlatformServer} from '@angular/common'; @@ -12,6 +12,7 @@ import {Score} from '../../shared/score/score'; import {ProviderLabel} from '../../shared/provider-label'; import {bucketToScoreVariable} from '../../shared/scoring'; import {ReportFilters} from '../../shared/report-filters/report-filters'; +import {PromptBadgeList} from './prompt-badge/prompt-badge-list'; @Component({ selector: 'app-report-list', @@ -23,6 +24,7 @@ import {ReportFilters} from '../../shared/report-filters/report-filters'; Score, ProviderLabel, ReportFilters, + PromptBadgeList, ], templateUrl: './report-list.html', styleUrls: ['./report-list.scss'], diff --git a/runner/orchestration/grouping.ts b/runner/orchestration/grouping.ts index 0135cf70..054c17c6 100644 --- a/runner/orchestration/grouping.ts +++ b/runner/orchestration/grouping.ts @@ -55,6 +55,7 @@ export function groupSimilarReports(inputRuns: RunInfo[]): RunGroup[] { const groupResults: AssessmentResult[] = []; const firstRun = groupRuns[0]; const labels = new Set(); + const promptNames = new Set(); let totalForGroup = 0; let maxForGroup = 0; let appsCount = 0; @@ -70,6 +71,7 @@ export function groupSimilarReports(inputRuns: RunInfo[]): RunGroup[] { totalForRun += result.score.totalPoints; maxForRun += result.score.maxOverallPoints; groupResults.push(result); + promptNames.add(result.promptDef.name); } // `|| 0` in case there are no results, otherwise we'll get NaN. @@ -90,6 +92,7 @@ export function groupSimilarReports(inputRuns: RunInfo[]): RunGroup[] { maxOverallPoints: maxForGroup / groupRuns.length || 0, appsCount, labels: Array.from(labels), + promptNames: Array.from(promptNames), environmentId: firstRun.details.summary.environmentId, framework: firstRun.details.summary.framework, model: firstRun.details.summary.model, diff --git a/runner/reporting/report-local-disk.ts b/runner/reporting/report-local-disk.ts index 1d3b8039..b695c364 100644 --- a/runner/reporting/report-local-disk.ts +++ b/runner/reporting/report-local-disk.ts @@ -37,6 +37,11 @@ export async function fetchReportsFromDisk(directory: string): Promise r.promptDef.name); + data.set(group.id, {group, run}); }), ); diff --git a/runner/shared-interfaces.ts b/runner/shared-interfaces.ts index b08c8980..9a61c19f 100644 --- a/runner/shared-interfaces.ts +++ b/runner/shared-interfaces.ts @@ -637,6 +637,8 @@ export interface RunGroup { }; /** Runner used to generate code for the runs in the group. */ runner?: CodegenRunnerInfo; + /** Names of prompts that were evaluated in this group. */ + promptNames: string[]; } /** Request information for a file generation. */