Skip to content

Commit 279188f

Browse files
committed
feat(web): track context source lift
1 parent 09c7a7d commit 279188f

4 files changed

Lines changed: 420 additions & 31 deletions

File tree

TODO.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@ This roadmap is derived from deep research into Greptile's public docs, blog, MC
8383
47. [x] Add explicit "intent mismatch" review checks comparing PR changes to ticket acceptance criteria.
8484
48. [x] Add review artifacts that show which external context sources influenced a finding.
8585
49. [x] Add tests for pattern repository resolution across local paths, Git URLs, and broken sources.
86-
50. [ ] Add analytics on which context sources actually improve acceptance and fix rates.
86+
50. [x] Add analytics on which context sources actually improve acceptance and fix rates.
8787

8888
## 6. Review UX and Workflow Integration
8989

web/src/lib/analytics.ts

Lines changed: 276 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ const FEEDBACK_LEARNING_REJECT_TAGS = new Set([
1919
'semantic-feedback:rejected',
2020
])
2121

22+
const CONTEXT_SOURCE_TAG_PREFIX = 'context-source:'
2223
const PATTERN_REPOSITORY_TAG_PREFIX = 'pattern-repository:'
2324

2425
function isLabeledFeedbackComment(comment: ReviewComment): boolean {
@@ -58,6 +59,58 @@ function isPatternRepositoryComment(comment: ReviewComment): boolean {
5859
return extractPatternRepositorySources(comment).length > 0
5960
}
6061

62+
function extractContextSources(comment: ReviewComment): string[] {
63+
const explicit = comment.tags
64+
.filter(tag => tag.startsWith(CONTEXT_SOURCE_TAG_PREFIX))
65+
.map(tag => tag.slice(CONTEXT_SOURCE_TAG_PREFIX.length))
66+
.filter(Boolean)
67+
68+
if (explicit.length > 0) {
69+
return Array.from(new Set(explicit))
70+
}
71+
72+
return Array.from(new Set(
73+
extractPatternRepositorySources(comment).map(source => `pattern-repository:${source}`),
74+
))
75+
}
76+
77+
function isContextSourceComment(comment: ReviewComment): boolean {
78+
return extractContextSources(comment).length > 0
79+
}
80+
81+
function formatContextSourceName(name: string): string {
82+
if (name.startsWith('pattern-repository:')) {
83+
const source = name.slice('pattern-repository:'.length) || 'external'
84+
return `Pattern repository · ${source}`
85+
}
86+
87+
const knownLabels: Record<string, string> = {
88+
'custom-context': 'Custom context',
89+
'dependency-graph': 'Dependency graph',
90+
'jira-issue': 'Jira issue',
91+
'linear-issue': 'Linear issue',
92+
'path-focus': 'Path focus',
93+
'related-test-file': 'Related test file',
94+
'repository-graph': 'Repository graph',
95+
'reverse-dependency-summary': 'Reverse dependency summary',
96+
'semantic-retrieval': 'Semantic retrieval',
97+
'similar-implementation': 'Similar implementation',
98+
'symbol-graph': 'Symbol graph',
99+
}
100+
101+
if (knownLabels[name]) {
102+
return knownLabels[name]
103+
}
104+
105+
return name
106+
.split(':')
107+
.map(part => part
108+
.split('-')
109+
.map(token => token ? token[0].toUpperCase() + token.slice(1) : token)
110+
.join(' '))
111+
.join(' · ')
112+
}
113+
61114
export function computeAnalytics(reviews: ReviewSession[]) {
62115
const completed = getCompletedReviews(reviews)
63116

@@ -242,6 +295,90 @@ export function computeAnalytics(reviews: ReviewSession[]) {
242295
}))
243296
.sort((left, right) => right.total - left.total || right.accepted - left.accepted)
244297

298+
const contextSourceTotals: Record<string, {
299+
total: number
300+
labeled: number
301+
accepted: number
302+
rejected: number
303+
resolved: number
304+
reviewIds: Set<string>
305+
}> = {}
306+
307+
const contextSourceSeries = completed.map((r, i) => {
308+
const contextSourceComments = r.comments.filter(isContextSourceComment)
309+
const labeledContextSourceComments = contextSourceComments.filter(isLabeledFeedbackComment)
310+
const accepted = labeledContextSourceComments.filter(comment => comment.feedback === 'accept').length
311+
const rejected = labeledContextSourceComments.filter(comment => comment.feedback === 'reject').length
312+
const resolved = contextSourceComments.filter(comment => comment.status === 'Resolved').length
313+
const reviewSources = new Set<string>()
314+
315+
for (const comment of contextSourceComments) {
316+
const sources = Array.from(new Set(extractContextSources(comment)))
317+
const isLabeled = isLabeledFeedbackComment(comment)
318+
const isResolved = comment.status === 'Resolved'
319+
320+
for (const source of sources) {
321+
reviewSources.add(source)
322+
const current = contextSourceTotals[source] ?? {
323+
total: 0,
324+
labeled: 0,
325+
accepted: 0,
326+
rejected: 0,
327+
resolved: 0,
328+
reviewIds: new Set<string>(),
329+
}
330+
331+
current.total += 1
332+
if (isLabeled) {
333+
current.labeled += 1
334+
if (comment.feedback === 'accept') {
335+
current.accepted += 1
336+
} else if (comment.feedback === 'reject') {
337+
current.rejected += 1
338+
}
339+
}
340+
if (isResolved) {
341+
current.resolved += 1
342+
}
343+
current.reviewIds.add(r.id)
344+
contextSourceTotals[source] = current
345+
}
346+
}
347+
348+
return {
349+
reviewId: r.id,
350+
idx: i + 1,
351+
label: `#${i + 1}`,
352+
findings: contextSourceComments.length,
353+
labeled: labeledContextSourceComments.length,
354+
accepted,
355+
rejected,
356+
resolved,
357+
sourceCount: reviewSources.size,
358+
acceptanceRate: labeledContextSourceComments.length > 0
359+
? accepted / labeledContextSourceComments.length
360+
: null,
361+
fixRate: contextSourceComments.length > 0
362+
? resolved / contextSourceComments.length
363+
: null,
364+
}
365+
})
366+
367+
const contextSourceData = Object.entries(contextSourceTotals)
368+
.map(([name, totals]) => ({
369+
name,
370+
label: formatContextSourceName(name),
371+
total: totals.total,
372+
labeled: totals.labeled,
373+
accepted: totals.accepted,
374+
rejected: totals.rejected,
375+
resolved: totals.resolved,
376+
reviewCount: totals.reviewIds.size,
377+
acceptanceRate: totals.labeled > 0 ? totals.accepted / totals.labeled : 0,
378+
fixRate: totals.total > 0 ? totals.resolved / totals.total : 0,
379+
}))
380+
.sort((left, right) => right.total - left.total || right.accepted - left.accepted)
381+
245382
const feedbackCategoryData = Object.entries(feedbackTotalsByCategory)
246383
.map(([name, totals]) => {
247384
const total = totals.accepted + totals.rejected
@@ -458,6 +595,57 @@ export function computeAnalytics(reviews: ReviewSession[]) {
458595
&& patternRepositoryLabeledTotal > 0
459596
? patternRepositoryAcceptanceRate - patternRepositoryBaselineAcceptanceRate
460597
: null
598+
const contextSourceFindingTotal = contextSourceSeries.reduce(
599+
(sum, point) => sum + point.findings,
600+
0,
601+
)
602+
const contextSourceLabeledTotal = contextSourceSeries.reduce(
603+
(sum, point) => sum + point.labeled,
604+
0,
605+
)
606+
const contextSourceAcceptedTotal = contextSourceSeries.reduce(
607+
(sum, point) => sum + point.accepted,
608+
0,
609+
)
610+
const contextSourceRejectedTotal = contextSourceSeries.reduce(
611+
(sum, point) => sum + point.rejected,
612+
0,
613+
)
614+
const contextSourceResolvedTotal = contextSourceSeries.reduce(
615+
(sum, point) => sum + point.resolved,
616+
0,
617+
)
618+
const contextSourceReviewCount = contextSourceSeries.filter(
619+
point => point.findings > 0,
620+
).length
621+
const contextSourceSourceCount = contextSourceData.length
622+
const contextSourceUtilizationRate = completed.length > 0
623+
? contextSourceReviewCount / completed.length
624+
: 0
625+
const contextSourceAcceptanceRate = contextSourceLabeledTotal > 0
626+
? contextSourceAcceptedTotal / contextSourceLabeledTotal
627+
: 0
628+
const contextSourceBaselineLabeledTotal = labeledFeedbackTotal - contextSourceLabeledTotal
629+
const contextSourceBaselineAcceptedTotal = acceptedFeedbackTotal - contextSourceAcceptedTotal
630+
const contextSourceBaselineAcceptanceRate = contextSourceBaselineLabeledTotal > 0
631+
? contextSourceBaselineAcceptedTotal / contextSourceBaselineLabeledTotal
632+
: null
633+
const contextSourceAcceptanceLift = contextSourceBaselineAcceptanceRate != null
634+
&& contextSourceLabeledTotal > 0
635+
? contextSourceAcceptanceRate - contextSourceBaselineAcceptanceRate
636+
: null
637+
const contextSourceFixRate = contextSourceFindingTotal > 0
638+
? contextSourceResolvedTotal / contextSourceFindingTotal
639+
: 0
640+
const contextSourceBaselineFindingTotal = totalCommentCount - contextSourceFindingTotal
641+
const contextSourceBaselineResolvedTotal = totalResolvedComments - contextSourceResolvedTotal
642+
const contextSourceBaselineFixRate = contextSourceBaselineFindingTotal > 0
643+
? contextSourceBaselineResolvedTotal / contextSourceBaselineFindingTotal
644+
: null
645+
const contextSourceFixLift = contextSourceBaselineFixRate != null
646+
&& contextSourceFindingTotal > 0
647+
? contextSourceFixRate - contextSourceBaselineFixRate
648+
: null
461649

462650
const sevTotals: Record<Severity, number> = { Error: 0, Warning: 0, Info: 0, Suggestion: 0 }
463651
for (const r of completed) {
@@ -480,6 +668,8 @@ export function computeAnalytics(reviews: ReviewSession[]) {
480668
feedbackLearningSeries,
481669
patternRepositorySeries,
482670
patternRepositorySourceData,
671+
contextSourceSeries,
672+
contextSourceData,
483673
topAcceptedCategories,
484674
topRejectedCategories,
485675
topAcceptedRules,
@@ -535,6 +725,21 @@ export function computeAnalytics(reviews: ReviewSession[]) {
535725
patternRepositoryBaselineLabeledTotal,
536726
patternRepositoryBaselineAcceptanceRate,
537727
patternRepositoryAcceptanceLift,
728+
contextSourceFindingTotal,
729+
contextSourceLabeledTotal,
730+
contextSourceAcceptedTotal,
731+
contextSourceRejectedTotal,
732+
contextSourceResolvedTotal,
733+
contextSourceReviewCount,
734+
contextSourceSourceCount,
735+
contextSourceUtilizationRate,
736+
contextSourceAcceptanceRate,
737+
contextSourceBaselineLabeledTotal,
738+
contextSourceBaselineAcceptanceRate,
739+
contextSourceAcceptanceLift,
740+
contextSourceFixRate,
741+
contextSourceBaselineFixRate,
742+
contextSourceFixLift,
538743
},
539744
}
540745
}
@@ -543,6 +748,7 @@ export type AnalyticsDrilldownSelection =
543748
| { type: 'review'; reviewId: string }
544749
| { type: 'category'; category: string }
545750
| { type: 'rule'; ruleId: string }
751+
| { type: 'contextSource'; source: string }
546752
| { type: 'patternRepositorySource'; source: string }
547753

548754
export interface AnalyticsDrilldown {
@@ -622,6 +828,9 @@ export function buildAnalyticsDrilldown(
622828
if (selection.type === 'rule') {
623829
return comment.rule_id?.trim() === selection.ruleId
624830
}
831+
if (selection.type === 'contextSource') {
832+
return extractContextSources(comment).includes(selection.source)
833+
}
625834
return extractPatternRepositorySources(comment).includes(selection.source)
626835
})
627836
.map(comment => ({ review, label, comment })))
@@ -654,6 +863,8 @@ export function buildAnalyticsDrilldown(
654863
? `Category · ${selection.category}`
655864
: selection.type === 'rule'
656865
? `Rule · ${selection.ruleId}`
866+
: selection.type === 'contextSource'
867+
? `Context source · ${formatContextSourceName(selection.source)}`
657868
: `Pattern repository · ${selection.source}`,
658869
description: `${matches.length} finding${matches.length === 1 ? '' : 's'} across ${reviewMap.size} review${reviewMap.size === 1 ? '' : 's'}.`,
659870
reviews: Array.from(reviewMap.values()),
@@ -945,6 +1156,21 @@ export interface AnalyticsExportReport {
9451156
patternRepositoryBaselineLabeledTotal: number
9461157
patternRepositoryBaselineAcceptanceRate?: number
9471158
patternRepositoryAcceptanceLift?: number
1159+
contextSourceFindingTotal: number
1160+
contextSourceLabeledTotal: number
1161+
contextSourceAcceptedTotal: number
1162+
contextSourceRejectedTotal: number
1163+
contextSourceResolvedTotal: number
1164+
contextSourceReviewCount: number
1165+
contextSourceSourceCount: number
1166+
contextSourceUtilizationRate: number
1167+
contextSourceAcceptanceRate: number
1168+
contextSourceBaselineLabeledTotal: number
1169+
contextSourceBaselineAcceptanceRate?: number
1170+
contextSourceAcceptanceLift?: number
1171+
contextSourceFixRate: number
1172+
contextSourceBaselineFixRate?: number
1173+
contextSourceFixLift?: number
9481174
latestMicroF1?: number
9491175
latestWeightedScore?: number
9501176
latestAcceptanceRate?: number
@@ -954,6 +1180,8 @@ export interface AnalyticsExportReport {
9541180
feedbackLearningByReview: AnalyticsSnapshot['feedbackLearningSeries']
9551181
patternRepositoryByReview: AnalyticsSnapshot['patternRepositorySeries']
9561182
patternRepositorySources: AnalyticsSnapshot['patternRepositorySourceData']
1183+
contextSourceByReview: AnalyticsSnapshot['contextSourceSeries']
1184+
contextSources: AnalyticsSnapshot['contextSourceData']
9571185
topAcceptedCategories: AnalyticsSnapshot['topAcceptedCategories']
9581186
topRejectedCategories: AnalyticsSnapshot['topRejectedCategories']
9591187
topAcceptedRules: AnalyticsSnapshot['topAcceptedRules']
@@ -1024,6 +1252,22 @@ function appendPatternRepositoryRows(
10241252
})
10251253
}
10261254

1255+
function appendContextSourceRows(
1256+
rows: AnalyticsExportCsvRow[],
1257+
items: AnalyticsSnapshot['contextSourceData'],
1258+
) {
1259+
items.forEach(item => {
1260+
rows.push({ report: 'reinforcement', group: 'context_sources', label: item.name, metric: 'total', value: item.total })
1261+
rows.push({ report: 'reinforcement', group: 'context_sources', label: item.name, metric: 'labeled', value: item.labeled })
1262+
rows.push({ report: 'reinforcement', group: 'context_sources', label: item.name, metric: 'accepted', value: item.accepted })
1263+
rows.push({ report: 'reinforcement', group: 'context_sources', label: item.name, metric: 'rejected', value: item.rejected })
1264+
rows.push({ report: 'reinforcement', group: 'context_sources', label: item.name, metric: 'resolved', value: item.resolved })
1265+
rows.push({ report: 'reinforcement', group: 'context_sources', label: item.name, metric: 'review_count', value: item.reviewCount })
1266+
rows.push({ report: 'reinforcement', group: 'context_sources', label: item.name, metric: 'acceptance_rate', value: item.acceptanceRate })
1267+
rows.push({ report: 'reinforcement', group: 'context_sources', label: item.name, metric: 'fix_rate', value: item.fixRate })
1268+
})
1269+
}
1270+
10271271
export function buildAnalyticsExportReport(
10281272
reviews: ReviewSession[],
10291273
trends: AnalyticsTrendsResponse | undefined,
@@ -1102,6 +1346,21 @@ export function buildAnalyticsExportReport(
11021346
patternRepositoryBaselineLabeledTotal: analytics.stats.patternRepositoryBaselineLabeledTotal,
11031347
patternRepositoryBaselineAcceptanceRate: analytics.stats.patternRepositoryBaselineAcceptanceRate ?? undefined,
11041348
patternRepositoryAcceptanceLift: analytics.stats.patternRepositoryAcceptanceLift ?? undefined,
1349+
contextSourceFindingTotal: analytics.stats.contextSourceFindingTotal,
1350+
contextSourceLabeledTotal: analytics.stats.contextSourceLabeledTotal,
1351+
contextSourceAcceptedTotal: analytics.stats.contextSourceAcceptedTotal,
1352+
contextSourceRejectedTotal: analytics.stats.contextSourceRejectedTotal,
1353+
contextSourceResolvedTotal: analytics.stats.contextSourceResolvedTotal,
1354+
contextSourceReviewCount: analytics.stats.contextSourceReviewCount,
1355+
contextSourceSourceCount: analytics.stats.contextSourceSourceCount,
1356+
contextSourceUtilizationRate: analytics.stats.contextSourceUtilizationRate,
1357+
contextSourceAcceptanceRate: analytics.stats.contextSourceAcceptanceRate,
1358+
contextSourceBaselineLabeledTotal: analytics.stats.contextSourceBaselineLabeledTotal,
1359+
contextSourceBaselineAcceptanceRate: analytics.stats.contextSourceBaselineAcceptanceRate ?? undefined,
1360+
contextSourceAcceptanceLift: analytics.stats.contextSourceAcceptanceLift ?? undefined,
1361+
contextSourceFixRate: analytics.stats.contextSourceFixRate,
1362+
contextSourceBaselineFixRate: analytics.stats.contextSourceBaselineFixRate ?? undefined,
1363+
contextSourceFixLift: analytics.stats.contextSourceFixLift ?? undefined,
11051364
latestMicroF1: trendAnalytics.latestEval?.micro_f1,
11061365
latestWeightedScore: trendAnalytics.latestEval?.weighted_score,
11071366
latestAcceptanceRate: trendAnalytics.latestFeedback?.acceptance_rate,
@@ -1111,6 +1370,8 @@ export function buildAnalyticsExportReport(
11111370
feedbackLearningByReview: analytics.feedbackLearningSeries,
11121371
patternRepositoryByReview: analytics.patternRepositorySeries,
11131372
patternRepositorySources: analytics.patternRepositorySourceData,
1373+
contextSourceByReview: analytics.contextSourceSeries,
1374+
contextSources: analytics.contextSourceData,
11141375
topAcceptedCategories: analytics.topAcceptedCategories,
11151376
topRejectedCategories: analytics.topRejectedCategories,
11161377
topAcceptedRules: analytics.topAcceptedRules,
@@ -1207,6 +1468,21 @@ export function buildAnalyticsCsv(report: AnalyticsExportReport): string {
12071468
}
12081469
})
12091470
appendPatternRepositoryRows(rows, report.reinforcement.patternRepositorySources)
1471+
report.reinforcement.contextSourceByReview.forEach(point => {
1472+
rows.push({ report: 'reinforcement', group: 'context_source_by_review', label: point.label, metric: 'findings', value: point.findings })
1473+
rows.push({ report: 'reinforcement', group: 'context_source_by_review', label: point.label, metric: 'labeled', value: point.labeled })
1474+
rows.push({ report: 'reinforcement', group: 'context_source_by_review', label: point.label, metric: 'accepted', value: point.accepted })
1475+
rows.push({ report: 'reinforcement', group: 'context_source_by_review', label: point.label, metric: 'rejected', value: point.rejected })
1476+
rows.push({ report: 'reinforcement', group: 'context_source_by_review', label: point.label, metric: 'resolved', value: point.resolved })
1477+
rows.push({ report: 'reinforcement', group: 'context_source_by_review', label: point.label, metric: 'source_count', value: point.sourceCount })
1478+
if (point.acceptanceRate != null) {
1479+
rows.push({ report: 'reinforcement', group: 'context_source_by_review', label: point.label, metric: 'acceptance_rate', value: point.acceptanceRate })
1480+
}
1481+
if (point.fixRate != null) {
1482+
rows.push({ report: 'reinforcement', group: 'context_source_by_review', label: point.label, metric: 'fix_rate', value: point.fixRate })
1483+
}
1484+
})
1485+
appendContextSourceRows(rows, report.reinforcement.contextSources)
12101486
appendFeedbackBreakdownRows(rows, 'top_accepted_categories', report.reinforcement.topAcceptedCategories)
12111487
appendFeedbackBreakdownRows(rows, 'top_rejected_categories', report.reinforcement.topRejectedCategories)
12121488
appendFeedbackBreakdownRows(rows, 'top_accepted_rules', report.reinforcement.topAcceptedRules)

0 commit comments

Comments
 (0)