Path: blob/main/extensions/copilot/src/extension/inlineEdits/common/userInteractionMonitor.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 { ConfigKey, IConfigurationService } from '../../../platform/configuration/common/configurationService';6import { AggressivenessLevel, AggressivenessSetting, DEFAULT_USER_HAPPINESS_SCORE_CONFIGURATION, parseUserHappinessScoreConfigurationString, UserHappinessScoreConfiguration } from '../../../platform/inlineEdits/common/dataTypes/xtabPromptOptions';7import { ILogService } from '../../../platform/log/common/logService';8import { IExperimentationService } from '../../../platform/telemetry/common/nullExperimentationService';9import { ITelemetryService } from '../../../platform/telemetry/common/telemetry';10import { ErrorUtils } from '../../../util/common/errors';11import { DelaySession } from './delay';1213export enum ActionKind {14Accepted = 'accepted',15Rejected = 'rejected',16Ignored = 'ignored',17}1819/**20* Represents a user interaction wrt an inline edit suggestion.21*/22export interface NESUserAction {23time: number;24kind: ActionKind;25}2627export const MAX_INTERACTIONS_CONSIDERED = 10;28export const MAX_INTERACTIONS_STORED = 30;2930/**31* Get window of actions with ignored action limiting via window expansion.32*33* When ignored limit is reached, skip excess ignored actions but expand window34* further back to still get MAX_INTERACTIONS_CONSIDERED items.35*/36export function getWindowWithIgnoredLimit(37actions: NESUserAction[],38config: UserHappinessScoreConfiguration39): NESUserAction[] {40const { limitConsecutiveIgnored, limitTotalIgnored, ignoredLimit } = config;4142if (!limitConsecutiveIgnored && !limitTotalIgnored) {43// No limiting - just take last MAX_INTERACTIONS_CONSIDERED44return actions.slice(-MAX_INTERACTIONS_CONSIDERED);45}4647const result: NESUserAction[] = [];48let consecutiveIgnored = 0;49let totalIgnored = 0;5051// Walk backwards through history52for (let i = actions.length - 1; i >= 0 && result.length < MAX_INTERACTIONS_CONSIDERED; i--) {53const action = actions[i];5455if (action.kind === ActionKind.Ignored) {56let skip = false;57if (limitConsecutiveIgnored && consecutiveIgnored >= ignoredLimit) {58skip = true;59}60if (limitTotalIgnored && totalIgnored >= ignoredLimit) {61skip = true;62}6364if (skip) {65continue;66}6768consecutiveIgnored++;69totalIgnored++;70} else {71consecutiveIgnored = 0; // Reset consecutive count on accept/reject72}7374result.push(action);75}7677// Reverse to get chronological order78result.reverse();79return result;80}8182/**83* Calculate user happiness score from actions.84* Value between 0 and 1 indicating user happiness.85* 1 means very happy, 0 means very unhappy.86*87* Uses position-weighted scoring with ignored action limiting:88* - More recent actions have higher weight89* - Ignored actions can be limited (consecutive or total) to prevent score dilution90* - Score is adjusted towards neutral (0.5) based on data confidence91*/92export function getUserHappinessScore(93actions: NESUserAction[],94config: UserHappinessScoreConfiguration95): number {96if (actions.length === 0) {97return 0.5; // neutral score when no data98}99100// Get window of actions with ignored limiting101const window = getWindowWithIgnoredLimit(actions, config);102103if (window.length === 0) {104return 0.5; // neutral score when no data after filtering105}106107// Calculate weighted score108let weightedScore = 0; // Sum of weighted normalized scores109let totalWeight = 0; // Sum of weights applied110let scoredActionCount = 0; // Count of actions that contributed to score111112for (let i = 0; i < window.length; i++) {113const action = window[i];114115// Skip ignored actions if not included in score calculation116if (action.kind === ActionKind.Ignored && !config.includeIgnored) {117continue;118}119120scoredActionCount++;121122// Calculate weight based on position (more recent = higher weight)123// Position 0 (oldest) has lowest weight, last position has highest weight124const weight = i + 1;125126// Get score based on action kind from configuration127let score: number;128switch (action.kind) {129case ActionKind.Accepted:130score = config.acceptedScore;131break;132case ActionKind.Rejected:133score = config.rejectedScore;134break;135case ActionKind.Ignored:136score = config.ignoredScore;137break;138}139140// Normalize score to 0-1 range based on accept/reject weights141const normalized = (score - config.rejectedScore) / (config.acceptedScore - config.rejectedScore);142143weightedScore += normalized * weight;144totalWeight += weight;145}146147const rawScore = totalWeight > 0 ? weightedScore / totalWeight : 0.5;148149// Adjust score towards neutral (0.5) when we have fewer data points150// This prevents extreme scores with limited data151const dataConfidence = scoredActionCount / MAX_INTERACTIONS_CONSIDERED;152return 0.5 + (rawScore - 0.5) * dataConfidence;153}154155export class UserInteractionMonitor {156157/**158* Used for aggressiveness level calculation.159* Includes all action types (accepted, rejected, ignored).160*/161protected _recentUserActionsForAggressiveness: NESUserAction[] = [];162163/**164* Used for timing/debounce calculation.165* Only includes accepted and rejected actions (ignored actions don't affect timing).166*/167protected _recentUserActionsForTiming: (NESUserAction & { kind: ActionKind.Accepted | ActionKind.Rejected })[] = [];168169private _lastActionWasAcceptance = false;170171constructor(172@IConfigurationService private readonly _configurationService: IConfigurationService,173@IExperimentationService private readonly _experimentationService: IExperimentationService,174@ILogService private readonly _logService: ILogService,175@ITelemetryService private readonly _telemetryService: ITelemetryService,176) { }177178// Capture user interactions179180public handleAcceptance(): void {181this._recordUserAction(ActionKind.Accepted);182}183184public handleRejection(): void {185this._recordUserAction(ActionKind.Rejected);186}187188public handleIgnored(): void {189this._recordUserAction(ActionKind.Ignored);190}191192/**193* Returns true if the last recorded user action was an acceptance.194* Used to skip aggressiveness min-response-time delay after accepts.195*/196get wasLastActionAcceptance(): boolean {197return this._lastActionWasAcceptance;198}199200private _recordUserAction(kind: ActionKind): void {201const now = Date.now();202203this._lastActionWasAcceptance = kind === ActionKind.Accepted;204205// Always record for aggressiveness calculation206this._recentUserActionsForAggressiveness.push({ time: now, kind });207this._recentUserActionsForAggressiveness = this._recentUserActionsForAggressiveness.slice(-MAX_INTERACTIONS_STORED);208209// Only record accepts/rejects for timing calculation210if (kind !== ActionKind.Ignored) {211this._recentUserActionsForTiming.push({ time: now, kind });212this._recentUserActionsForTiming = this._recentUserActionsForTiming.slice(-MAX_INTERACTIONS_CONSIDERED);213}214}215216// Creates a DelaySession based on recent user interactions217218public createDelaySession(requestTime: number | undefined): DelaySession {219const baseDebounceTime = this._configurationService.getExperimentBasedConfig(ConfigKey.TeamInternal.InlineEditsDebounce, this._experimentationService);220221const backoffDebounceEnabled = this._configurationService.getExperimentBasedConfig(ConfigKey.TeamInternal.InlineEditsBackoffDebounceEnabled, this._experimentationService);222const expectedTotalTime = backoffDebounceEnabled ? this._getExpectedTotalTime(baseDebounceTime) : undefined;223224return new DelaySession(baseDebounceTime, expectedTotalTime, requestTime);225}226227private _getExpectedTotalTime(baseDebounceTime: number): number {228const DEBOUNCE_DECAY_TIME_MS = 10 * 60 * 1000; // 10 minutes229const MAX_DEBOUNCE_TIME = 3000; // 3 seconds230const MIN_DEBOUNCE_TIME = 50; // 50 ms231const REJECTION_WEIGHT = 1.5;232const ACCEPTANCE_WEIGHT = 0.8;233const now = Date.now();234let multiplier = 1;235236// Calculate impact of each action with time decay237// Uses timing-specific array which only contains accepts/rejects238for (const action of this._recentUserActionsForTiming) {239const timeSinceAction = now - action.time;240if (timeSinceAction > DEBOUNCE_DECAY_TIME_MS) {241continue;242}243244// Exponential decay: impact decreases as time passes245const decayFactor = Math.exp(-timeSinceAction / DEBOUNCE_DECAY_TIME_MS);246const actionWeight = action.kind === ActionKind.Rejected ? REJECTION_WEIGHT : ACCEPTANCE_WEIGHT;247multiplier *= 1 + ((actionWeight - 1) * decayFactor);248}249250let debounceTime = baseDebounceTime * multiplier;251252// Clamp the debounce time to reasonable bounds253debounceTime = Math.min(MAX_DEBOUNCE_TIME, Math.max(MIN_DEBOUNCE_TIME, debounceTime));254255return debounceTime;256}257258// Determine aggressiveness level based on user interactions259260/**261* Returns the aggressiveness level and the user happiness score that was used to derive it.262* The score is returned to avoid race conditions when logging telemetry.263*/264public getAggressivenessLevel(): { aggressivenessLevel: AggressivenessLevel; userHappinessScore: number | undefined } {265// User-facing setting takes priority when explicitly set to a non-default value266const userAggressiveness = this._configurationService.getExperimentBasedConfig(ConfigKey.Advanced.InlineEditsAggressiveness, this._experimentationService);267const userLevel = AggressivenessSetting.toLevel(userAggressiveness);268if (userLevel !== undefined) {269return { aggressivenessLevel: userLevel, userHappinessScore: undefined };270}271272// Team-internal experiment-based override273const configuredAggressivenessLevel = this._configurationService.getExperimentBasedConfig(ConfigKey.TeamInternal.InlineEditsXtabAggressivenessLevel, this._experimentationService);274275if (configuredAggressivenessLevel !== undefined) {276return { aggressivenessLevel: configuredAggressivenessLevel, userHappinessScore: undefined };277}278279// Default or unrecognized: fall through to happiness-score-based logic280let level: AggressivenessLevel;281const config = this._getUserHappinessScoreConfiguration();282const userHappinessScore = this._getUserHappinessScore(config);283if (userHappinessScore >= config.highThreshold) {284level = AggressivenessLevel.High;285} else if (userHappinessScore >= config.mediumThreshold) {286level = AggressivenessLevel.Medium;287} else {288level = AggressivenessLevel.Low;289}290return { aggressivenessLevel: level, userHappinessScore };291}292293protected _getUserHappinessScoreConfiguration(): UserHappinessScoreConfiguration {294const configKey = ConfigKey.TeamInternal.InlineEditsUserHappinessScoreConfigurationString;295const configString = this._configurationService.getExperimentBasedConfig(configKey, this._experimentationService);296if (configString === undefined) {297return DEFAULT_USER_HAPPINESS_SCORE_CONFIGURATION;298}299300try {301return parseUserHappinessScoreConfigurationString(configString);302}303catch (e) {304this._logService.error(e, 'Failed to parse user happiness score configuration, using default config');305// Log to telemetry when we fail to parse an experimental config, but still offer the default config to avoid disruption.306/* __GDPR__307"incorrectNesAdaptiveAggressivenessConfig" : {308"owner": "bstee615",309"comment": "Capture if model configuration string is invalid JSON.",310"configName": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Name of the configuration that failed to parse." },311"errorMessage": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Error message from JSON.parse." },312"configValue": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The invalid JSON string." }313}314*/315this._telemetryService.sendMSFTTelemetryEvent('incorrectNesAdaptiveAggressivenessConfig', { configName: configKey.id, errorMessage: ErrorUtils.toString(ErrorUtils.fromUnknown(e)), configValue: configString });316return DEFAULT_USER_HAPPINESS_SCORE_CONFIGURATION;317}318}319320private _getUserHappinessScore(config: UserHappinessScoreConfiguration): number {321return getUserHappinessScore(this._recentUserActionsForAggressiveness, config);322}323}324325326