Path: blob/main/extensions/copilot/test/simulation/inlineEdit/inlineEditScoringService.ts
13394 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 { mkdir } from 'fs/promises';6import { dirname } from 'path';7import { IRecordingInformation } from '../../../src/extension/inlineEdits/common/observableWorkspaceRecordingReplayer';8import { DocumentId } from '../../../src/platform/inlineEdits/common/dataTypes/documentId';9import { RootedEdit } from '../../../src/platform/inlineEdits/common/dataTypes/edit';10import { deserializeStringEdit, serializeStringEdit } from '../../../src/platform/inlineEdits/common/dataTypes/editUtils';11import { ISerializedEdit } from '../../../src/platform/workspaceRecorder/common/workspaceLog';12import { JSONFile } from '../../../src/util/node/jsonFile';13import { CachedFunction } from '../../../src/util/vs/base/common/cache';14import { equalsIfDefined, thisEqualsC } from '../../../src/util/vs/base/common/equals';15import { isDefined } from '../../../src/util/vs/base/common/types';16import { StringEdit } from '../../../src/util/vs/editor/common/core/edits/stringEdit';17import { StringText } from '../../../src/util/vs/editor/common/core/text/abstractText';1819export interface IInlineEditScoringService {20scoreEdit(scoredEditsFilePath: string, context: ScoringContext, docId: DocumentId, editDocumentValue: StringText, edit: RootedEdit | undefined): Promise<EditScoreResult | undefined>;21}2223/** JSON Serializable */24export type ScoringContext = { kind: 'unknown'; documentValueBeforeEdit: string } | { kind: 'recording'; recording: IRecordingInformation };2526export type EditScoreResultCategory = 'bad' | 'valid' | 'nextEdit';2728const USE_SIMPLE_SCORING = true;2930export class EditScoreResult {31constructor(32public readonly category: EditScoreResultCategory,33/**34* When comparing two edits with the same scoreCategory, the one with the higher score is considered better.35* The score does not convey any other meaning (such as its absolute value).36* Should be below 100.37*/38public readonly score: number,39) { }4041toString() {42return `${this.category}#${this.score}`;43}4445getScoreValue(): number {46if (USE_SIMPLE_SCORING) {47switch (this.category) {48case 'bad': return 0;49case 'valid': return 0.1;50case 'nextEdit': return 1;51}52} else {53const getVal = () => {54switch (this.category) {55case 'bad': return 0;56case 'valid': return 10 + (this.score / 100) * 3;57case 'nextEdit': return 100 + 10 * (this.score / 100);58}59};60const maxValue = 110;61return Math.round(Math.min(getVal() / maxValue, maxValue) * 1000) / 1000;62}63}64}6566class InlineEditScoringService implements IInlineEditScoringService {67private readonly _scoredEdits = new CachedFunction(async (path: string) => {68await mkdir(dirname(path), { recursive: true });69const file = await JSONFile.readOrCreate<IScoredEdits | null>(path, null, '\t');7071return {72scoredEdits: undefined as undefined | ScoredEdits,73file,74};75});7677async scoreEdit(scoredEditsFilePath: string, context: ScoringContext, docId: DocumentId, editDocumentValue: StringText, edit: RootedEdit | undefined): Promise<EditScoreResult | undefined> {78const existing = await this._scoredEdits.get(scoredEditsFilePath);7980let shouldWrite = false;8182if (!existing.scoredEdits) {83const value = existing.file.value;84if (!value) {85existing.scoredEdits = ScoredEdits.create(context);86shouldWrite = true; // first test run87} else {88existing.scoredEdits = ScoredEdits.fromJson(value, context);89shouldWrite = existing.scoredEdits.removeUnscored(); // we deleted all unscored edits (might be re-added though)90const shouldNormalizeExisting = false; // Edits are now normalized before adding to the score database.91if (shouldNormalizeExisting) {92shouldWrite = existing.scoredEdits.normalizeEdits(editDocumentValue.value) || shouldWrite;93}94}95}9697const result = existing.scoredEdits.getScoreOrAddAsUnscored(docId, edit);98if (!result) {99shouldWrite = true; // edit was added as unscored100}101102if (shouldWrite) {103const newData = existing.scoredEdits.serialize();104await existing.file.setValue(newData);105}106107return result;108}109}110111class ScoredEdits {112public static fromJson(data: IScoredEdits, scoringContext: ScoringContext): ScoredEdits {113// TOD check if context matches!114return new ScoredEdits(scoringContext, data.edits);115}116117public static create(scoringContext: ScoringContext): ScoredEdits {118return new ScoredEdits(scoringContext, []);119}120121private _edits: IScoredEdit[];122private _editMatchers: EditMatcher[] = [];123124private constructor(125private readonly _scoringContext: ScoringContext,126edits: IScoredEdit[],127) {128this._edits = edits;129this._editMatchers = edits.map(e => new EditMatcher(e));130}131132hasUnscored(): boolean {133return this._edits.some(e => !isScoredEdit(e));134}135136normalizeEdits(source: string): boolean {137const existing = new Set<string>();138139this._edits = this._edits.map(e => {140let n = e.edit ? deserializeStringEdit(e.edit).normalizeOnSource(source) : undefined;141if (n?.isEmpty()) {142n = undefined;143}144const key = e.documentUri + '#' + JSON.stringify(n?.toJson());145if (existing.has(key)) {146return null;147}148existing.add(key);149150return {151...e,152edit: n ? serializeStringEdit(n) : null,153};154}).filter(isDefined);155156this._editMatchers = this._edits.map(e => new EditMatcher(e));157158return true;159}160161removeUnscored(): boolean {162if (!this.hasUnscored()) {163return false;164}165this._edits = this._edits.filter(e => isScoredEdit(e));166this._editMatchers = this._editMatchers.filter(e => e.isScored());167return true;168}169170getScoreOrAddAsUnscored(docId: DocumentId, edit: RootedEdit | undefined): EditScoreResult | undefined {171edit = edit?.normalize();172if (edit?.edit.isEmpty()) {173edit = undefined;174}175176const documentUri = docId.uri;177178let existingEdit = this._editMatchers.find(e => e.matches(documentUri, edit));179if (!existingEdit) {180const e: IScoredEdit = {181documentUri: documentUri,182edit: edit ? serializeStringEdit(edit.edit) : null,183score: 'unscored',184scoreCategory: 'unscored',185};186const m = new EditMatcher(e);187this._edits.push(e);188this._editMatchers.push(m);189190existingEdit = m;191}192return existingEdit.getScore();193}194195serialize(): IScoredEdits {196return {197...{198'$web-editor.format-json': true,199'$web-editor.default-url': 'https://microsoft.github.io/vscode-workbench-recorder-viewer/?editRating',200},201edits: this._edits,202// Last, so that it is easier to review the file203scoringContext: this._scoringContext,204};205}206}207208class EditMatcher {209public readonly documentUri = this.data.documentUri;210public readonly edit: StringEdit | undefined;211212constructor(213private readonly data: IScoredEdit,214) {215this.edit = data.edit ? deserializeStringEdit(data.edit) : undefined;216}217218isScored(): boolean {219return isScoredEdit(this.data);220}221222getScore(): EditScoreResult | undefined {223if (!isScoredEdit(this.data)) {224return undefined;225}226return new EditScoreResult(this.data.scoreCategory, this.data.score);227}228229matches(editDocumentUri: string, edit: RootedEdit | undefined): boolean {230if (editDocumentUri !== this.documentUri) {231return false;232}233// TODO improve! (check if strings after applied the edits are the same)234return equalsIfDefined(this.edit, edit?.edit, thisEqualsC());235}236}237238/** JSON Serializable */239interface IScoredEdits {240edits: IScoredEdit[];241scoringContext: ScoringContext;242}243244/** JSON Serializable */245interface IScoredEdit<TUnscored = 'unscored'> {246documentUri: string;247edit: ISerializedEdit | null;248scoreCategory: EditScoreResultCategory | TUnscored;249250/**251* When comparing two edits with the same scoreCategory, the one with the higher score is considered better.252* The score does not convey any other meaning (such as its absolute value).253*/254score: number | TUnscored;255}256257function isScoredEdit(edit: IScoredEdit<any>): edit is IScoredEdit<never> {258return edit.score !== 'unscored' && edit.scoreCategory !== 'unscored';259}260261// Has to be a singleton to avoid writing race conditions262export const inlineEditScoringService = new InlineEditScoringService();263264265