Path: blob/main/extensions/copilot/src/extension/prompt/node/feedbackGenerator.ts
13399 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import * as l10n from '@vscode/l10n';67import { TelemetryEventMeasurements, TelemetryEventProperties } from '@vscode/extension-telemetry';8import { RenderPromptResult } from '@vscode/prompt-tsx';9import type { CancellationToken, Progress } from 'vscode';10import { ChatLocation } from '../../../platform/chat/common/commonTypes';11import { EditSurvivalReporter, EditSurvivalResult } from '../../../platform/editSurvivalTracking/common/editSurvivalReporter';12import { IEndpointProvider } from '../../../platform/endpoint/common/endpointProvider';13import { IIgnoreService } from '../../../platform/ignore/common/ignoreService';14import { ILogService } from '../../../platform/log/common/logService';15import { ReviewComment, ReviewRequest } from '../../../platform/review/common/reviewService';16import { ITelemetryService } from '../../../platform/telemetry/common/telemetry';17import { isNotebookCellOrNotebookChatInput } from '../../../util/common/notebooks';18import { coalesce } from '../../../util/vs/base/common/arrays';19import * as path from '../../../util/vs/base/common/path';20import { generateUuid } from '../../../util/vs/base/common/uuid';21import { StringEdit } from '../../../util/vs/editor/common/core/edits/stringEdit';22import { OffsetRange } from '../../../util/vs/editor/common/core/ranges/offsetRange';23import { IInstantiationService } from '../../../util/vs/platform/instantiation/common/instantiation';24import { MarkdownString, Range } from '../../../vscodeTypes';25import { PromptRenderer } from '../../prompts/node/base/promptRenderer';26import { CurrentChangeInput } from '../../prompts/node/feedback/currentChange';27import { ProvideFeedbackPrompt } from '../../prompts/node/feedback/provideFeedback';28import { sendUserActionTelemetry } from './telemetry';2930export type FeedbackResult = { type: 'success'; comments: ReviewComment[]; excludedComments?: ReviewComment[]; reason?: string } | { type: 'error'; severity?: 'info'; reason: string } | { type: 'cancelled' };3132export class FeedbackGenerator {33constructor(34@ITelemetryService private readonly telemetryService: ITelemetryService,35@IEndpointProvider private readonly endpointProvider: IEndpointProvider,36@ILogService private readonly logService: ILogService,37@IInstantiationService private readonly instantiationService: IInstantiationService,38@IIgnoreService private readonly ignoreService: IIgnoreService,39) { }4041async generateComments(input: CurrentChangeInput[], token: CancellationToken, progress?: Progress<ReviewComment[]>): Promise<FeedbackResult> {42const startTime = Date.now();4344const ignoreService = this.ignoreService;45const ignored = await Promise.all(input.map(i => ignoreService.isCopilotIgnored(i.document.uri)));46const filteredInput = input.filter((_, i) => !ignored[i]);47if (filteredInput.length === 0) {48this.logService.info('All input documents are ignored. Skipping feedback generation.');49return {50type: 'error',51severity: 'info',52reason: l10n.t('All input documents are ignored by configuration. Check your .copilotignore file.')53};54}5556const endpoint = await this.endpointProvider.getChatEndpoint('copilot-base');5758const prompts: RenderPromptResult[] = [];59const batches = [filteredInput];60while (batches.length) {61const batch = batches.shift()!;62try {63const promptRenderer = PromptRenderer.create(this.instantiationService, endpoint, ProvideFeedbackPrompt, {64input: batch,65logService: this.logService,66});67const prompt = await promptRenderer.render();68this.logService.debug(`[FeedbackGenerator] Rendered batch of ${batch.length} inputs.`);69prompts.push(prompt);70} catch (err) {71if (err.code === 'split_input') {72const i = Math.floor(batch.length / 2);73batches.unshift(batch.slice(0, i), batch.slice(i));74this.logService.debug(`[FeedbackGenerator] Splitting in batches of ${batches[0].length} and ${batches[1].length} inputs due to token limit.`);75} else {76throw err;77}78}79}8081if (token.isCancellationRequested) {82return { type: 'cancelled' };83}8485const inputType = filteredInput[0]?.selection ? 'selection' : 'change';86const maxPrompts = 10;87if (prompts.length > maxPrompts) {88return {89type: 'error',90reason: inputType === 'selection' ? l10n.t('There is too much text to review, try reviewing a smaller selection.') : l10n.t('There are too many changes to review, try reviewing a smaller set of changes.'),91};92}9394const request: ReviewRequest = {95source: 'vscodeCopilotChat',96promptCount: prompts.length,97messageId: generateUuid(),98inputType,99inputRanges: filteredInput.map(input => ({100uri: input.document.uri,101ranges: input.selection ? [input.selection] : input.change?.hunks.map(hunk => hunk.range) || [],102})),103};104105const requestStartTime = Date.now();106const results = await Promise.all(prompts.map(async prompt => {107let receivedComments: ReviewComment[] = [];108const finishedCb = progress ? async (text: string) => {109const comments = parseReviewComments(request, filteredInput, text, true);110if (comments.length > receivedComments.length) {111progress.report(comments.slice(receivedComments.length));112receivedComments = comments;113}114return undefined;115} : undefined;116117const fetchResult = await endpoint118.makeChatRequest(119'feedbackGenerator',120prompt.messages,121finishedCb,122token,123ChatLocation.Other,124undefined,125undefined,126false,127{128messageId: request.messageId,129}130);131132const comments = fetchResult.type === 'success' ? parseReviewComments(request, filteredInput, fetchResult.value, false) : [];133134if (progress && comments && comments.length > receivedComments.length) {135progress.report(comments.slice(receivedComments.length));136receivedComments = comments;137}138139return {140fetchResult,141comments,142};143}));144145const fetchResult = results.find(r => r.fetchResult.type !== 'success')?.fetchResult || results[0].fetchResult;146const comments = results.map(r => r.comments).flat();147148/* __GDPR__149"feedback.generateDiagnostics" : {150"owner": "chrmarti",151"comment": "Metadata about the code feedback generation",152"model": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The model that is used in the endpoint." },153"requestId": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The id of the current request turn." },154"source": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Which backend generated the comment." },155"messageId": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The id of the current request." },156"responseType": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The result type of the response." },157"documentType": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "What kind of document (e.g., text or notebook)." },158"languageId": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The current file language." },159"inputType": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "What type of input (e.g., selection or change)." },160"commentTypes": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "What kind of comment (e.g., correctness or performance)." },161"promptCount": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true, "comment": "The number of prompts run." },162"numberOfDiagnostics": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true, "comment": "The number of diagnostics." },163"inputDocumentCount": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true, "comment": "How many documents were part of the input." },164"inputLineCount": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true, "comment": "How many (selected or changed) lines were part of the input." },165"timeToRequest": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "isMeasurement": true, "comment": "How long it took to start the request." },166"timeToComplete": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "isMeasurement": true, "comment": "How long it took to complete the request." }167}168*/169this.telemetryService.sendMSFTTelemetryEvent('feedback.generateDiagnostics', {170model: endpoint.model,171requestId: fetchResult.requestId,172responseType: fetchResult.type,173source: request.source,174messageId: request.messageId,175documentType: filteredInput[0] && isNotebookCellOrNotebookChatInput(filteredInput[0]?.document.uri) ? 'notebook' : 'text',176languageId: filteredInput[0]?.document.languageId,177inputType: request.inputType,178commentTypes: [...new Set(179comments?.map(c => knownKinds.has(c.kind) ? c.kind : 'unknown')).values()180].sort().join(',') || undefined,181}, {182promptCount: prompts.length,183numberOfDiagnostics: comments?.length ?? -1,184inputDocumentCount: request.inputRanges.length,185inputLineCount: request.inputRanges186.reduce((acc, r) => acc + r.ranges187.reduce((acc, r) => acc + (r.end.line - r.start.line), 0), 0),188timeToRequest: requestStartTime - startTime,189timeToComplete: Date.now() - startTime190});191192return token.isCancellationRequested193? { type: 'cancelled' }194: fetchResult.type === 'success'195? { type: 'success', comments: comments || [] }196: { type: 'error', reason: fetchResult.reason };197}198}199200const knownKinds = new Set(['bug', 'performance', 'consistency', 'documentation', 'naming', 'readability', 'style', 'other']);201202export function parseReviewComments(request: ReviewRequest, input: CurrentChangeInput[], message: string, dropPartial = false): ReviewComment[] {203const comments: ReviewComment[] = [];204205// Extract the messages from the comment206for (const match of parseFeedbackResponse(message, dropPartial)) {207const { relativeDocumentPath, from, to, kind, severity, content } = match;208if (!knownKinds.has(kind)) {209continue;210}211212const i = relativeDocumentPath && input.find(i => i.relativeDocumentPath === relativeDocumentPath);213if (!i) {214continue;215}216217const document = i.document;218const filterRanges = i.selection ? [i.selection!] : i.change?.hunks.map(hunk => hunk.range);219220const fromLine = document.lineAt(from >= 0 ? from : 0);221const toLine = document.lineAt((to <= document.lineCount ? to : document.lineCount) - 1);222const lastNonWhitespaceCharacterIndex = toLine.text.trimEnd().length;223224// Create a Diagnostic object for each message225const range = new Range(fromLine.lineNumber, fromLine.firstNonWhitespaceCharacterIndex, toLine.lineNumber, lastNonWhitespaceCharacterIndex);226if (filterRanges && !filterRanges.some(r => r.intersection(range))) {227continue;228}229const comment: ReviewComment = {230request,231document,232uri: document.uri,233languageId: document.languageId,234range,235body: new MarkdownString(content),236kind,237severity,238originalIndex: comments.length,239actionCount: 0,240};241comments.push(comment);242}243244return comments;245}246247export function parseFeedbackResponse(response: string, dropPartial = false) {248const regex = /(?<num>\d+)\. Line (?<from>\d+)(-(?<to>\d+))?([^:]*)( in `?(?<relativeDocumentPath>[^,:`]+))`?(, (?<kind>\w+))?(, (?<severity>\w+) severity)?: (?<content>.+?)((?=\n\d+\.|\n\n)|(?<earlyEnd>$))/gs;249return coalesce(Array.from(response.matchAll(regex), match => {250const groups = match.groups!;251if (dropPartial && typeof groups.earlyEnd === 'string') {252return undefined;253}254const from = parseInt(groups.from) - 1;255const to = groups.to ? parseInt(groups.to) : from + 1;256const relativeDocumentPath = groups.relativeDocumentPath?.replaceAll(path.sep === '/' ? '\\' : '/', path.sep);257const kind = groups.kind || 'other';258const severity = groups.severity || 'unknown';259let content = groups.content.trim();260// Remove trailing code block (which sometimes suggests a fix) because that interfers with the suggestion rendering later.261if (content.endsWith('```')) {262const i = content.lastIndexOf('```', content.length - 4);263if (i !== -1) {264content = content.substring(0, i)265.trim();266}267}268// Remove broken block.269const blockBorders = [...content.matchAll(/```/g)];270if (blockBorders.length % 2) {271const odd = blockBorders[blockBorders.length - 1];272content = content.substring(0, odd.index)273.trim();274}275return {276relativeDocumentPath,277from,278to,279linkOffset: match.index! + groups.num.length + 2,280linkLength: 5 + groups.from.length + (groups.to ? groups.to.length + 1 : 0),281kind,282severity,283content284};285}));286}287288export function sendReviewActionTelemetry(reviewCommentOrComments: ReviewComment | ReviewComment[], totalComments: number, userAction: 'helpful' | 'unhelpful' | string, logService: ILogService, telemetryService: ITelemetryService, instantiationService: IInstantiationService): void {289logService.debug('[FeedbackGenerator] user feedback received');290const reviewComments = Array.isArray(reviewCommentOrComments) ? reviewCommentOrComments : [reviewCommentOrComments];291const reviewComment = reviewComments[0];292if (!reviewComment) {293logService.warn('[FeedbackGenerator] No review comment found for user feedback');294return;295}296297const userActionProperties = {298source: reviewComment.request.source,299messageId: reviewComment.request.messageId,300userAction,301};302303const commentType = knownKinds.has(reviewComment.kind) ? reviewComment.kind : 'unknown';304const sharedProps: TelemetryEventProperties = {305source: reviewComment.request.source,306requestId: reviewComment.request.messageId,307documentType: isNotebookCellOrNotebookChatInput(reviewComment.uri) ? 'notebook' : 'text',308languageId: reviewComment.languageId,309inputType: reviewComment.request.inputType,310commentType,311userAction,312};313const sharedMeasures: TelemetryEventMeasurements = {314commentIndex: reviewComment.originalIndex,315actionCount: reviewComment.actionCount,316inputDocumentCount: reviewComment.request.inputRanges.length,317inputLineCount: reviewComment.request.inputRanges318.reduce((acc, r) => acc + r.ranges319.reduce((acc, r) => acc + (r.end.line - r.start.line), 0), 0),320promptCount: reviewComment.request.promptCount,321totalComments,322comments: reviewComments.length,323commentLength: reviewComments.reduce((acc, c) => acc + (typeof c.body === 'string' ? c.body.length : c.body.value.length), 0),324};325326if (userAction === 'helpful' || userAction === 'unhelpful') {327/* __GDPR__328"review.comment.vote" : {329"owner": "chrmarti",330"comment": "Metadata about votes on review comments",331"source": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Which backend generated the comment." },332"requestId": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The id of the current request turn." },333"documentType": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "What kind of document (e.g., text or notebook)." },334"languageId": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The current file language." },335"inputType": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "What type of input (e.g., selection or change)." },336"commentType": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "What kind of comment (e.g., correctness or performance)." },337"userAction": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "What action the user triggered (e.g., helpful, unhelpful, apply or discard)." },338"commentIndex": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true, "comment": "Original index of the comment in the generated comments." },339"actionCount": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true, "comment": "Number of previously logged actions on the comment." },340"inputDocumentCount": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true, "comment": "How many documents were part of the input." },341"inputLineCount": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true, "comment": "How many (selected or changed) lines were part of the input." },342"promptCount": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true, "comment": "The number of prompts run." },343"totalComments": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true, "comment": "Total number of comments." },344"comments": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true, "comment": "How many comments are affected by the action." },345"commentLength": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true, "comment": "How many characters long the review comment is." }346}347*/348telemetryService.sendMSFTTelemetryEvent('review.comment.vote', sharedProps, sharedMeasures);349telemetryService.sendInternalMSFTTelemetryEvent('review.comment.vote', sharedProps);350sendUserActionTelemetry(telemetryService, undefined, userActionProperties, {}, 'review.comment.vote');351} else {352reviewComment.actionCount++;353/* __GDPR__354"review.comment.action" : {355"owner": "chrmarti",356"comment": "Metadata about actions on review comments",357"source": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Which backend generated the comment." },358"requestId": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The id of the current request turn." },359"documentType": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "What kind of document (e.g., text or notebook)." },360"languageId": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The current file language." },361"inputType": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "What type of input (e.g., selection or change)." },362"commentType": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "What kind of comment (e.g., correctness or performance)." },363"userAction": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "What action the user triggered (e.g., helpful, unhelpful, apply or discard)." },364"commentIndex": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true, "comment": "Original index of the comment in the generated comments." },365"actionCount": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true, "comment": "Number of previously logged actions on the comment." },366"inputDocumentCount": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true, "comment": "How many documents were part of the input." },367"inputLineCount": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true, "comment": "How many (selected or changed) lines were part of the input." },368"promptCount": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true, "comment": "The number of prompts run." },369"totalComments": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true, "comment": "Total number of comments." },370"comments": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true, "comment": "How many comments are affected by the action." },371"commentLength": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true, "comment": "How many characters long the review comment is." }372}373*/374telemetryService.sendMSFTTelemetryEvent('review.comment.action', sharedProps, sharedMeasures);375telemetryService.sendInternalMSFTTelemetryEvent('review.comment.action', sharedProps);376sendUserActionTelemetry(telemetryService, undefined, userActionProperties, {}, 'review.comment.action');377}378if (userAction === 'discardComment') {379const { document, range } = reviewComment;380const from = document.offsetAt(range.start);381const to = document.offsetAt(range.end);382const text = document.getText(range);383instantiationService.createInstance(EditSurvivalReporter, document.document, document.getText(), StringEdit.replace(OffsetRange.ofStartAndLength(from, to - from), text), StringEdit.empty, {}, discardCommentSurvivalEvent(sharedProps, sharedMeasures));384}385}386387function discardCommentSurvivalEvent(sharedProps: TelemetryEventProperties | undefined, sharedMeasures: TelemetryEventMeasurements | undefined) {388return (res: EditSurvivalResult) => {389/* __GDPR__390"review.discardCommentRangeSurvival" : {391"owner": "chrmarti",392"comment": "Tracks how much percent of the commented range surived after 5 minutes of discarding",393"survivalRateFourGram": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true, "comment": "The rate between 0 and 1 of how much of the AI edit is still present in the document." },394"survivalRateNoRevert": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true, "comment": "The rate between 0 and 1 of how much of the ranges the AI touched ended up being reverted." },395"didBranchChange": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true, "comment": "Indicates if the branch changed in the meantime. If the branch changed (value is 1), this event should probably be ignored." },396"timeDelayMs": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true, "comment": "The time delay between the user accepting the edit and measuring the survival rate." },397"source": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Which backend generated the comment." },398"requestId": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The id of the current request turn." },399"documentType": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "What kind of document (e.g., text or notebook)." },400"languageId": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The current file language." },401"inputType": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "What type of input (e.g., selection or change)." },402"commentType": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "What kind of comment (e.g., correctness or performance)." },403"userAction": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "What action the user triggered (e.g., helpful, unhelpful, apply or discard)." },404"commentIndex": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true, "comment": "Original index of the comment in the generated comments." },405"actionCount": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true, "comment": "Number of previously logged actions on the comment." },406"inputDocumentCount": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true, "comment": "How many documents were part of the input." },407"inputLineCount": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true, "comment": "How many (selected or changed) lines were part of the input." },408"promptCount": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true, "comment": "The number of prompts run." },409"totalComments": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true, "comment": "Total number of comments." },410"comments": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true, "comment": "How many comments are affected by the action." },411"commentLength": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true, "comment": "How many characters long the review comment is." }412}413*/414res.telemetryService.sendMSFTTelemetryEvent('review.discardCommentRangeSurvival', sharedProps, {415...sharedMeasures,416survivalRateFourGram: res.fourGram,417survivalRateNoRevert: res.noRevert,418timeDelayMs: res.timeDelayMs,419didBranchChange: res.didBranchChange ? 1 : 0,420});421};422}423424425