Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/extensions/copilot/src/extension/inlineEdits/common/userInteractionMonitor.ts
13399 views
1
/*---------------------------------------------------------------------------------------------
2
* Copyright (c) Microsoft Corporation. All rights reserved.
3
* Licensed under the MIT License. See License.txt in the project root for license information.
4
*--------------------------------------------------------------------------------------------*/
5
6
import { ConfigKey, IConfigurationService } from '../../../platform/configuration/common/configurationService';
7
import { AggressivenessLevel, AggressivenessSetting, DEFAULT_USER_HAPPINESS_SCORE_CONFIGURATION, parseUserHappinessScoreConfigurationString, UserHappinessScoreConfiguration } from '../../../platform/inlineEdits/common/dataTypes/xtabPromptOptions';
8
import { ILogService } from '../../../platform/log/common/logService';
9
import { IExperimentationService } from '../../../platform/telemetry/common/nullExperimentationService';
10
import { ITelemetryService } from '../../../platform/telemetry/common/telemetry';
11
import { ErrorUtils } from '../../../util/common/errors';
12
import { DelaySession } from './delay';
13
14
export enum ActionKind {
15
Accepted = 'accepted',
16
Rejected = 'rejected',
17
Ignored = 'ignored',
18
}
19
20
/**
21
* Represents a user interaction wrt an inline edit suggestion.
22
*/
23
export interface NESUserAction {
24
time: number;
25
kind: ActionKind;
26
}
27
28
export const MAX_INTERACTIONS_CONSIDERED = 10;
29
export const MAX_INTERACTIONS_STORED = 30;
30
31
/**
32
* Get window of actions with ignored action limiting via window expansion.
33
*
34
* When ignored limit is reached, skip excess ignored actions but expand window
35
* further back to still get MAX_INTERACTIONS_CONSIDERED items.
36
*/
37
export function getWindowWithIgnoredLimit(
38
actions: NESUserAction[],
39
config: UserHappinessScoreConfiguration
40
): NESUserAction[] {
41
const { limitConsecutiveIgnored, limitTotalIgnored, ignoredLimit } = config;
42
43
if (!limitConsecutiveIgnored && !limitTotalIgnored) {
44
// No limiting - just take last MAX_INTERACTIONS_CONSIDERED
45
return actions.slice(-MAX_INTERACTIONS_CONSIDERED);
46
}
47
48
const result: NESUserAction[] = [];
49
let consecutiveIgnored = 0;
50
let totalIgnored = 0;
51
52
// Walk backwards through history
53
for (let i = actions.length - 1; i >= 0 && result.length < MAX_INTERACTIONS_CONSIDERED; i--) {
54
const action = actions[i];
55
56
if (action.kind === ActionKind.Ignored) {
57
let skip = false;
58
if (limitConsecutiveIgnored && consecutiveIgnored >= ignoredLimit) {
59
skip = true;
60
}
61
if (limitTotalIgnored && totalIgnored >= ignoredLimit) {
62
skip = true;
63
}
64
65
if (skip) {
66
continue;
67
}
68
69
consecutiveIgnored++;
70
totalIgnored++;
71
} else {
72
consecutiveIgnored = 0; // Reset consecutive count on accept/reject
73
}
74
75
result.push(action);
76
}
77
78
// Reverse to get chronological order
79
result.reverse();
80
return result;
81
}
82
83
/**
84
* Calculate user happiness score from actions.
85
* Value between 0 and 1 indicating user happiness.
86
* 1 means very happy, 0 means very unhappy.
87
*
88
* Uses position-weighted scoring with ignored action limiting:
89
* - More recent actions have higher weight
90
* - Ignored actions can be limited (consecutive or total) to prevent score dilution
91
* - Score is adjusted towards neutral (0.5) based on data confidence
92
*/
93
export function getUserHappinessScore(
94
actions: NESUserAction[],
95
config: UserHappinessScoreConfiguration
96
): number {
97
if (actions.length === 0) {
98
return 0.5; // neutral score when no data
99
}
100
101
// Get window of actions with ignored limiting
102
const window = getWindowWithIgnoredLimit(actions, config);
103
104
if (window.length === 0) {
105
return 0.5; // neutral score when no data after filtering
106
}
107
108
// Calculate weighted score
109
let weightedScore = 0; // Sum of weighted normalized scores
110
let totalWeight = 0; // Sum of weights applied
111
let scoredActionCount = 0; // Count of actions that contributed to score
112
113
for (let i = 0; i < window.length; i++) {
114
const action = window[i];
115
116
// Skip ignored actions if not included in score calculation
117
if (action.kind === ActionKind.Ignored && !config.includeIgnored) {
118
continue;
119
}
120
121
scoredActionCount++;
122
123
// Calculate weight based on position (more recent = higher weight)
124
// Position 0 (oldest) has lowest weight, last position has highest weight
125
const weight = i + 1;
126
127
// Get score based on action kind from configuration
128
let score: number;
129
switch (action.kind) {
130
case ActionKind.Accepted:
131
score = config.acceptedScore;
132
break;
133
case ActionKind.Rejected:
134
score = config.rejectedScore;
135
break;
136
case ActionKind.Ignored:
137
score = config.ignoredScore;
138
break;
139
}
140
141
// Normalize score to 0-1 range based on accept/reject weights
142
const normalized = (score - config.rejectedScore) / (config.acceptedScore - config.rejectedScore);
143
144
weightedScore += normalized * weight;
145
totalWeight += weight;
146
}
147
148
const rawScore = totalWeight > 0 ? weightedScore / totalWeight : 0.5;
149
150
// Adjust score towards neutral (0.5) when we have fewer data points
151
// This prevents extreme scores with limited data
152
const dataConfidence = scoredActionCount / MAX_INTERACTIONS_CONSIDERED;
153
return 0.5 + (rawScore - 0.5) * dataConfidence;
154
}
155
156
export class UserInteractionMonitor {
157
158
/**
159
* Used for aggressiveness level calculation.
160
* Includes all action types (accepted, rejected, ignored).
161
*/
162
protected _recentUserActionsForAggressiveness: NESUserAction[] = [];
163
164
/**
165
* Used for timing/debounce calculation.
166
* Only includes accepted and rejected actions (ignored actions don't affect timing).
167
*/
168
protected _recentUserActionsForTiming: (NESUserAction & { kind: ActionKind.Accepted | ActionKind.Rejected })[] = [];
169
170
private _lastActionWasAcceptance = false;
171
172
constructor(
173
@IConfigurationService private readonly _configurationService: IConfigurationService,
174
@IExperimentationService private readonly _experimentationService: IExperimentationService,
175
@ILogService private readonly _logService: ILogService,
176
@ITelemetryService private readonly _telemetryService: ITelemetryService,
177
) { }
178
179
// Capture user interactions
180
181
public handleAcceptance(): void {
182
this._recordUserAction(ActionKind.Accepted);
183
}
184
185
public handleRejection(): void {
186
this._recordUserAction(ActionKind.Rejected);
187
}
188
189
public handleIgnored(): void {
190
this._recordUserAction(ActionKind.Ignored);
191
}
192
193
/**
194
* Returns true if the last recorded user action was an acceptance.
195
* Used to skip aggressiveness min-response-time delay after accepts.
196
*/
197
get wasLastActionAcceptance(): boolean {
198
return this._lastActionWasAcceptance;
199
}
200
201
private _recordUserAction(kind: ActionKind): void {
202
const now = Date.now();
203
204
this._lastActionWasAcceptance = kind === ActionKind.Accepted;
205
206
// Always record for aggressiveness calculation
207
this._recentUserActionsForAggressiveness.push({ time: now, kind });
208
this._recentUserActionsForAggressiveness = this._recentUserActionsForAggressiveness.slice(-MAX_INTERACTIONS_STORED);
209
210
// Only record accepts/rejects for timing calculation
211
if (kind !== ActionKind.Ignored) {
212
this._recentUserActionsForTiming.push({ time: now, kind });
213
this._recentUserActionsForTiming = this._recentUserActionsForTiming.slice(-MAX_INTERACTIONS_CONSIDERED);
214
}
215
}
216
217
// Creates a DelaySession based on recent user interactions
218
219
public createDelaySession(requestTime: number | undefined): DelaySession {
220
const baseDebounceTime = this._configurationService.getExperimentBasedConfig(ConfigKey.TeamInternal.InlineEditsDebounce, this._experimentationService);
221
222
const backoffDebounceEnabled = this._configurationService.getExperimentBasedConfig(ConfigKey.TeamInternal.InlineEditsBackoffDebounceEnabled, this._experimentationService);
223
const expectedTotalTime = backoffDebounceEnabled ? this._getExpectedTotalTime(baseDebounceTime) : undefined;
224
225
return new DelaySession(baseDebounceTime, expectedTotalTime, requestTime);
226
}
227
228
private _getExpectedTotalTime(baseDebounceTime: number): number {
229
const DEBOUNCE_DECAY_TIME_MS = 10 * 60 * 1000; // 10 minutes
230
const MAX_DEBOUNCE_TIME = 3000; // 3 seconds
231
const MIN_DEBOUNCE_TIME = 50; // 50 ms
232
const REJECTION_WEIGHT = 1.5;
233
const ACCEPTANCE_WEIGHT = 0.8;
234
const now = Date.now();
235
let multiplier = 1;
236
237
// Calculate impact of each action with time decay
238
// Uses timing-specific array which only contains accepts/rejects
239
for (const action of this._recentUserActionsForTiming) {
240
const timeSinceAction = now - action.time;
241
if (timeSinceAction > DEBOUNCE_DECAY_TIME_MS) {
242
continue;
243
}
244
245
// Exponential decay: impact decreases as time passes
246
const decayFactor = Math.exp(-timeSinceAction / DEBOUNCE_DECAY_TIME_MS);
247
const actionWeight = action.kind === ActionKind.Rejected ? REJECTION_WEIGHT : ACCEPTANCE_WEIGHT;
248
multiplier *= 1 + ((actionWeight - 1) * decayFactor);
249
}
250
251
let debounceTime = baseDebounceTime * multiplier;
252
253
// Clamp the debounce time to reasonable bounds
254
debounceTime = Math.min(MAX_DEBOUNCE_TIME, Math.max(MIN_DEBOUNCE_TIME, debounceTime));
255
256
return debounceTime;
257
}
258
259
// Determine aggressiveness level based on user interactions
260
261
/**
262
* Returns the aggressiveness level and the user happiness score that was used to derive it.
263
* The score is returned to avoid race conditions when logging telemetry.
264
*/
265
public getAggressivenessLevel(): { aggressivenessLevel: AggressivenessLevel; userHappinessScore: number | undefined } {
266
// User-facing setting takes priority when explicitly set to a non-default value
267
const userAggressiveness = this._configurationService.getExperimentBasedConfig(ConfigKey.Advanced.InlineEditsAggressiveness, this._experimentationService);
268
const userLevel = AggressivenessSetting.toLevel(userAggressiveness);
269
if (userLevel !== undefined) {
270
return { aggressivenessLevel: userLevel, userHappinessScore: undefined };
271
}
272
273
// Team-internal experiment-based override
274
const configuredAggressivenessLevel = this._configurationService.getExperimentBasedConfig(ConfigKey.TeamInternal.InlineEditsXtabAggressivenessLevel, this._experimentationService);
275
276
if (configuredAggressivenessLevel !== undefined) {
277
return { aggressivenessLevel: configuredAggressivenessLevel, userHappinessScore: undefined };
278
}
279
280
// Default or unrecognized: fall through to happiness-score-based logic
281
let level: AggressivenessLevel;
282
const config = this._getUserHappinessScoreConfiguration();
283
const userHappinessScore = this._getUserHappinessScore(config);
284
if (userHappinessScore >= config.highThreshold) {
285
level = AggressivenessLevel.High;
286
} else if (userHappinessScore >= config.mediumThreshold) {
287
level = AggressivenessLevel.Medium;
288
} else {
289
level = AggressivenessLevel.Low;
290
}
291
return { aggressivenessLevel: level, userHappinessScore };
292
}
293
294
protected _getUserHappinessScoreConfiguration(): UserHappinessScoreConfiguration {
295
const configKey = ConfigKey.TeamInternal.InlineEditsUserHappinessScoreConfigurationString;
296
const configString = this._configurationService.getExperimentBasedConfig(configKey, this._experimentationService);
297
if (configString === undefined) {
298
return DEFAULT_USER_HAPPINESS_SCORE_CONFIGURATION;
299
}
300
301
try {
302
return parseUserHappinessScoreConfigurationString(configString);
303
}
304
catch (e) {
305
this._logService.error(e, 'Failed to parse user happiness score configuration, using default config');
306
// Log to telemetry when we fail to parse an experimental config, but still offer the default config to avoid disruption.
307
/* __GDPR__
308
"incorrectNesAdaptiveAggressivenessConfig" : {
309
"owner": "bstee615",
310
"comment": "Capture if model configuration string is invalid JSON.",
311
"configName": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Name of the configuration that failed to parse." },
312
"errorMessage": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Error message from JSON.parse." },
313
"configValue": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The invalid JSON string." }
314
}
315
*/
316
this._telemetryService.sendMSFTTelemetryEvent('incorrectNesAdaptiveAggressivenessConfig', { configName: configKey.id, errorMessage: ErrorUtils.toString(ErrorUtils.fromUnknown(e)), configValue: configString });
317
return DEFAULT_USER_HAPPINESS_SCORE_CONFIGURATION;
318
}
319
}
320
321
private _getUserHappinessScore(config: UserHappinessScoreConfiguration): number {
322
return getUserHappinessScore(this._recentUserActionsForAggressiveness, config);
323
}
324
}
325
326