Skip to content

Commit da731c1

Browse files
committed
fix: address review feedback for quality score display
- Extract shared critic response parser into helper, removing duplicate Zod schemas and eliminating stale score overwrite from progress log - Use CDK overlay for badge tooltip to prevent table overflow clipping - Add keyboard/screen-reader accessibility to badge tooltip - Replace toFixed() template calls with QualityScoreLabelPipe - Reduce agent timeout from 10min to 5min - Fix Prisma schema field alignment - Make second critic validation score-only (ignore revision suggestions) Signed-off-by: Asitha de Silva <asithade@gmail.com>
1 parent 3a2c3e7 commit da731c1

File tree

9 files changed

+102
-93
lines changed

9 files changed

+102
-93
lines changed

apps/lfx-changelog/prisma/schema.prisma

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -149,9 +149,9 @@ model ChangelogEntry {
149149
title String
150150
description String
151151
version String?
152-
source ChangelogSource @default(manual) @map("source")
153-
status ChangelogStatus @default(draft)
154-
qualityScore Json? @map("quality_score")
152+
source ChangelogSource @default(manual) @map("source")
153+
status ChangelogStatus @default(draft)
154+
qualityScore Json? @map("quality_score")
155155
publishedAt DateTime? @map("published_at")
156156
createdBy String @map("created_by")
157157
createdAt DateTime @default(now()) @map("created_at")

apps/lfx-changelog/src/app/modules/admin/changelog-list/changelog-list.component.html

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@ <h1 data-testid="changelog-list-heading" class="font-display text-text-primary t
8181

8282
<ng-template lfxColumn="Score" let-entry>
8383
@if (entry.qualityScore; as qs) {
84-
<lfx-badge [label]="qs.overall.toFixed(1) + '/5'" [color]="qs.overall | qualityScoreColor" size="sm" [hasTooltip]="true">
84+
<lfx-badge [label]="qs.overall | qualityScoreLabel" [color]="qs.overall | qualityScoreColor" size="sm" [hasTooltip]="true">
8585
<div tooltip class="w-40">
8686
<p class="text-text-secondary mb-1.5 text-[10px] font-semibold tracking-wider uppercase">Quality Breakdown</p>
8787
<div class="flex items-center justify-between py-0.5 text-xs">
@@ -159,7 +159,7 @@ <h1 data-testid="changelog-list-heading" class="font-display text-text-primary t
159159
<div class="flex flex-wrap items-center gap-2">
160160
<lfx-status-badge [status]="entry.status" />
161161
@if (entry.qualityScore; as qs) {
162-
<lfx-badge [label]="qs.overall.toFixed(1) + '/5'" [color]="qs.overall | qualityScoreColor" size="sm" />
162+
<lfx-badge [label]="qs.overall | qualityScoreLabel" [color]="qs.overall | qualityScoreColor" size="sm" />
163163
} @else {
164164
<span class="text-text-muted text-xs">N/A</span>
165165
}

apps/lfx-changelog/src/app/modules/admin/changelog-list/changelog-list.component.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import { DateFormatPipe } from '@shared/pipes/date-format.pipe';
2626
import { MapGetPipe } from '@shared/pipes/map-get.pipe';
2727
import { ProductNamePipe } from '@shared/pipes/product-name.pipe';
2828
import { QualityScoreColorPipe } from '@shared/pipes/quality-score-color.pipe';
29+
import { QualityScoreLabelPipe } from '@shared/pipes/quality-score-label.pipe';
2930
import { BehaviorSubject, catchError, combineLatest, map, of, startWith, switchMap, take, tap } from 'rxjs';
3031

3132
import type { ChangelogEntryWithRelations, PaginatedResponse, Product } from '@lfx-changelog/shared';
@@ -49,6 +50,7 @@ import type { DropdownMenuItem, SelectOption } from '@shared/interfaces/form.int
4950
MapGetPipe,
5051
ProductNamePipe,
5152
QualityScoreColorPipe,
53+
QualityScoreLabelPipe,
5254
],
5355
templateUrl: './changelog-list.component.html',
5456
styleUrl: './changelog-list.component.css',

apps/lfx-changelog/src/app/shared/components/badge/badge.component.html

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,26 @@
22
<!-- SPDX-License-Identifier: MIT -->
33

44
<span
5-
class="group relative inline-flex items-center gap-1 rounded-full font-medium"
5+
class="inline-flex items-center gap-1 rounded-full font-medium"
66
[class]="size() === 'sm' ? 'px-2 py-0.5 text-xs' : 'px-2.5 py-1 text-xs'"
77
[class.cursor-default]="hasTooltip()"
8+
[attr.tabindex]="hasTooltip() ? '0' : null"
9+
cdkOverlayOrigin
10+
#trigger="cdkOverlayOrigin"
11+
(mouseenter)="showTooltip()"
12+
(mouseleave)="hideTooltip()"
13+
(focus)="showTooltip()"
14+
(blur)="hideTooltip()"
815
[style]="bgStyle()">
916
{{ label() }}
10-
11-
@if (hasTooltip()) {
12-
<div
13-
class="bg-surface border-border text-text-primary pointer-events-none absolute bottom-full left-1/2 z-50 mb-2 hidden -translate-x-1/2 rounded-lg border p-2.5 shadow-lg group-hover:block">
14-
<ng-content select="[tooltip]" />
15-
</div>
16-
}
1717
</span>
18+
19+
<ng-template
20+
cdkConnectedOverlay
21+
[cdkConnectedOverlayOrigin]="trigger"
22+
[cdkConnectedOverlayOpen]="hasTooltip() && isTooltipOpen()"
23+
[cdkConnectedOverlayPositions]="positions">
24+
<div role="tooltip" class="bg-surface border-border text-text-primary pointer-events-none rounded-lg border p-2.5 shadow-lg">
25+
<ng-content select="[tooltip]" />
26+
</div>
27+
</ng-template>

apps/lfx-changelog/src/app/shared/components/badge/badge.component.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
// Copyright The Linux Foundation and each contributor to LFX.
22
// SPDX-License-Identifier: MIT
33

4-
import { Component, computed, input } from '@angular/core';
4+
import { CdkConnectedOverlay, CdkOverlayOrigin, ConnectedPosition } from '@angular/cdk/overlay';
5+
import { Component, computed, input, signal } from '@angular/core';
56

67
@Component({
78
selector: 'lfx-badge',
9+
imports: [CdkOverlayOrigin, CdkConnectedOverlay],
810
templateUrl: './badge.component.html',
911
styleUrl: './badge.component.css',
1012
})
@@ -14,8 +16,23 @@ export class BadgeComponent {
1416
public readonly size = input<'sm' | 'md'>('md');
1517
public readonly hasTooltip = input(false);
1618

19+
protected readonly isTooltipOpen = signal(false);
20+
21+
protected readonly positions: ConnectedPosition[] = [
22+
{ originX: 'center', originY: 'bottom', overlayX: 'center', overlayY: 'top', offsetY: 4 },
23+
{ originX: 'center', originY: 'top', overlayX: 'center', overlayY: 'bottom', offsetY: -4 },
24+
];
25+
1726
protected readonly bgStyle = computed(() => {
1827
const hex = this.color();
1928
return `background-color: ${hex}1a; color: ${hex};`;
2029
});
30+
31+
protected showTooltip(): void {
32+
this.isTooltipOpen.set(true);
33+
}
34+
35+
protected hideTooltip(): void {
36+
this.isTooltipOpen.set(false);
37+
}
2138
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
// Copyright The Linux Foundation and each contributor to LFX.
2+
// SPDX-License-Identifier: MIT
3+
4+
import { Pipe, PipeTransform } from '@angular/core';
5+
6+
@Pipe({
7+
name: 'qualityScoreLabel',
8+
standalone: true,
9+
})
10+
export class QualityScoreLabelPipe implements PipeTransform {
11+
public transform(overall: number): string {
12+
return `${overall.toFixed(1)}/5`;
13+
}
14+
}

apps/lfx-changelog/src/server/constants/agent.constants.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
export const AGENT_CONFIG = {
55
MAX_TURNS: 15,
66
MODEL: 'claude-sonnet-4-6',
7-
TIMEOUT_MS: 600_000, // 10 minutes
7+
TIMEOUT_MS: 300_000, // 5 minutes
88
} as const;
99

1010
/** Changelog MCP tools the agent is allowed to call. */
@@ -62,7 +62,7 @@ Your job is to produce a polished, user-focused changelog entry from raw GitHub
6262
- Description has at least 2 grouped headings for non-trivial updates (5+ changes)
6363
- If validation fails, revise and re-validate (up to 2 retries)
6464
5. **REVIEW** — call \`validate_changelog_draft\` to get a quality score from the critic.
65-
- If the critic suggests revisions, apply them using \`update_changelog_draft\`, then call \`validate_changelog_draft\` exactly once more for a final score. You MUST NOT call \`validate_changelog_draft\` more than 2 times total per run.
65+
- If the critic suggests revisions, apply them using \`update_changelog_draft\`, then call \`validate_changelog_draft\` exactly once more to record the final score. **Ignore any revision suggestions from this second call** — it is strictly for scoring. You MUST NOT call \`validate_changelog_draft\` more than 2 times total per run.
6666
- Skip the critic if the activity is trivial (fewer than 3 commits/PRs total).
6767
6. **SAVE** via \`create_changelog_draft\` (new entry) or \`update_changelog_draft\` (existing draft ID provided in context).
6868
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
// Copyright The Linux Foundation and each contributor to LFX.
2+
// SPDX-License-Identifier: MIT
3+
4+
import { z } from 'zod';
5+
6+
const CriticResponseSchema = z.object({
7+
scores: z.object({
8+
accuracy: z.number().min(1).max(5),
9+
clarity: z.number().min(1).max(5),
10+
tone: z.number().min(1).max(5),
11+
completeness: z.number().min(1).max(5),
12+
}),
13+
overall: z.number().min(1).max(5),
14+
});
15+
16+
export type QualityScore = { accuracy: number; clarity: number; tone: number; completeness: number; overall: number };
17+
18+
/**
19+
* Extracts quality scores from a critic response string.
20+
* Handles both raw JSON and JSON wrapped in markdown code fences.
21+
* Returns null if the response cannot be parsed or doesn't match the schema.
22+
*/
23+
export function parseCriticScores(text: string): QualityScore | null {
24+
try {
25+
const jsonMatch = text.match(/```(?:json)?\s*([\s\S]*?)```/) || [null, text];
26+
const raw = JSON.parse(jsonMatch[1]!.trim());
27+
const parseResult = CriticResponseSchema.safeParse(raw);
28+
if (parseResult.success) {
29+
const { scores, overall } = parseResult.data;
30+
return { ...scores, overall };
31+
}
32+
return null;
33+
} catch {
34+
return null;
35+
}
36+
}

apps/lfx-changelog/src/server/services/changelog-agent.service.ts

Lines changed: 7 additions & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
import { AgentServiceError } from '../errors';
1717
import { buildActivityContext } from '../helpers/activity-context.helper';
1818
import { extractAtlassianReferences, formatAtlassianHints } from '../helpers/atlassian-reference.helper';
19+
import { parseCriticScores } from '../helpers/critic-response.helper';
1920
import { serverLogger } from '../server-logger';
2021
import { agentJobEmitter } from './agent-job-emitter.service';
2122
import { AgentMemoryService } from './agent-memory.service';
@@ -473,11 +474,6 @@ export class ChangelogAgentService {
473474
serverLogger.info({ jobId, productId, durationMs, numTurns: message.num_turns }, 'Agent job completed successfully');
474475
agentJobEmitter.emit(jobId, { type: 'status', data: { status: 'completed' } });
475476

476-
// Record quality scores from critic (fire-and-forget)
477-
this.recordScoresFromProgressLog(jobId, productId, updatedJob.changelogEntry?.id ?? null, progressLog).catch((err) =>
478-
serverLogger.warn({ err, jobId }, 'Failed to record quality scores')
479-
);
480-
481477
// Notify configured users via Slack DM — release-triggered jobs only (fire-and-forget)
482478
if (updatedJob.trigger === 'webhook_release' && updatedJob.changelogEntry && updatedJob.product) {
483479
const entry = { id: updatedJob.changelogEntry.id, title: updatedJob.changelogEntry.title };
@@ -847,30 +843,12 @@ export class ChangelogAgentService {
847843
}
848844
}
849845

850-
// Try to extract and record quality scores from the critic response
851-
try {
852-
// Handle pure JSON or JSON wrapped in markdown code fences
853-
const jsonMatch = criticResponse.match(/```(?:json)?\s*([\s\S]*?)```/) || [null, criticResponse];
854-
const raw = JSON.parse(jsonMatch[1]!.trim());
855-
const CriticResponseSchema = z.object({
856-
scores: z.object({
857-
accuracy: z.number().min(1).max(5),
858-
clarity: z.number().min(1).max(5),
859-
tone: z.number().min(1).max(5),
860-
completeness: z.number().min(1).max(5),
861-
}),
862-
overall: z.number().min(1).max(5),
863-
});
864-
const parseResult = CriticResponseSchema.safeParse(raw);
865-
if (parseResult.success) {
866-
const { scores, overall } = parseResult.data;
867-
const qualityScore = { ...scores, overall };
868-
await agentMemoryService.recordQualityScores(jobId, productId, qualityScore);
869-
await prisma.changelogEntry.update({ where: { id: args.draftId }, data: { qualityScore } });
870-
serverLogger.info({ jobId, productId, draftId: args.draftId, overall }, 'Recorded quality scores from critic');
871-
}
872-
} catch {
873-
serverLogger.debug({ jobId, productId }, 'Could not extract quality scores from critic response');
846+
// Extract and record quality scores from the critic response
847+
const qualityScore = parseCriticScores(criticResponse);
848+
if (qualityScore) {
849+
await agentMemoryService.recordQualityScores(jobId, productId, qualityScore);
850+
await prisma.changelogEntry.update({ where: { id: args.draftId }, data: { qualityScore } });
851+
serverLogger.info({ jobId, productId, draftId: args.draftId, overall: qualityScore.overall }, 'Recorded quality scores from critic');
874852
}
875853

876854
return { content: [{ type: 'text' as const, text: criticResponse }] };
@@ -887,52 +865,4 @@ export class ChangelogAgentService {
887865
tools: [searchPastChangelogs, createChangelogDraft, updateChangelogDraft, getLatestVersion, validateChangelogDraft],
888866
});
889867
}
890-
891-
/**
892-
* Walks the progress log to find critic scores from validate_changelog_draft.
893-
* Looks for a text entry after the tool call that contains valid JSON scores.
894-
*/
895-
private async recordScoresFromProgressLog(jobId: string, productId: string, changelogId: string | null, progressLog: ProgressLogEntry[]): Promise<void> {
896-
const CriticResponseSchema = z.object({
897-
scores: z.object({
898-
accuracy: z.number().min(1).max(5),
899-
clarity: z.number().min(1).max(5),
900-
tone: z.number().min(1).max(5),
901-
completeness: z.number().min(1).max(5),
902-
}),
903-
overall: z.number().min(1).max(5),
904-
});
905-
906-
for (let i = 0; i < progressLog.length; i++) {
907-
const entry = progressLog[i];
908-
if (entry.type !== 'tool_call' || entry.tool !== 'validate_changelog_draft') continue;
909-
910-
// The critic response appears as a subsequent text entry
911-
for (let j = i + 1; j < progressLog.length; j++) {
912-
const nextEntry = progressLog[j];
913-
if (nextEntry.type === 'text' && nextEntry.summary) {
914-
try {
915-
const raw = JSON.parse(nextEntry.summary);
916-
const parseResult = CriticResponseSchema.safeParse(raw);
917-
if (parseResult.success) {
918-
const { scores, overall } = parseResult.data;
919-
const qualityScore = { ...scores, overall };
920-
await this.agentMemoryService.recordQualityScores(jobId, productId, qualityScore);
921-
922-
// Denormalize onto the changelog entry for admin UI display
923-
if (changelogId) {
924-
const prisma = getPrismaClient();
925-
await prisma.changelogEntry.update({ where: { id: changelogId }, data: { qualityScore } });
926-
}
927-
return;
928-
}
929-
} catch {
930-
// Not valid JSON — continue looking
931-
}
932-
}
933-
// Stop searching if we hit another tool call
934-
if (nextEntry.type === 'tool_call') break;
935-
}
936-
}
937-
}
938868
}

0 commit comments

Comments
 (0)