mirror of
				https://github.com/gristlabs/grist-core.git
				synced 2025-06-13 20:53:59 +00:00 
			
		
		
		
	(core) updates from grist-core
This commit is contained in:
		
						commit
						5e9f2e06ea
					
				| @ -4,6 +4,7 @@ import {CellRec, DocModel, IRowModel, recordSet, | ||||
|         refRecord, TableRec, ViewFieldRec} from 'app/client/models/DocModel'; | ||||
| import {urlState} from 'app/client/models/gristUrlState'; | ||||
| import {jsonObservable, ObjObservable} from 'app/client/models/modelUtil'; | ||||
| import {AssistanceState} from 'app/common/AssistancePrompts'; | ||||
| import * as gristTypes from 'app/common/gristTypes'; | ||||
| import {getReferencedTableId} from 'app/common/gristTypes'; | ||||
| import { | ||||
| @ -83,7 +84,7 @@ export interface ColumnRec extends IRowModel<"_grist_Tables_column"> { | ||||
|   /** | ||||
|    * Current history of chat. This is a temporary array used only in the ui. | ||||
|    */ | ||||
|   chatHistory: ko.PureComputed<Observable<ChatMessage[]>>; | ||||
|   chatHistory: ko.PureComputed<Observable<ChatHistory>>; | ||||
| 
 | ||||
|   // Helper which adds/removes/updates column's displayCol to match the formula.
 | ||||
|   saveDisplayFormula(formula: string): Promise<void>|undefined; | ||||
| @ -162,8 +163,9 @@ export function createColumnRec(this: ColumnRec, docModel: DocModel): void { | ||||
| 
 | ||||
|   this.chatHistory = this.autoDispose(ko.computed(() => { | ||||
|     const docId = urlState().state.get().doc ?? ''; | ||||
|     const key = `formula-assistant-history-${docId}-${this.table().tableId()}-${this.colId()}`; | ||||
|     return localStorageJsonObs(key, [] as ChatMessage[]); | ||||
|     // Changed key name from history to history-v2 when ChatHistory changed in incompatible way.
 | ||||
|     const key = `formula-assistant-history-v2-${docId}-${this.table().tableId()}-${this.colId()}`; | ||||
|     return localStorageJsonObs(key, {messages: []} as ChatHistory); | ||||
|   })); | ||||
| } | ||||
| 
 | ||||
| @ -196,8 +198,20 @@ export interface ChatMessage { | ||||
|    */ | ||||
|   sender: 'user' | 'ai'; | ||||
|   /** | ||||
|    * The formula returned from the AI. It is only set when the sender is the AI. For now it is the same | ||||
|    * value as the message, but it might change in the future when we use more conversational AI. | ||||
|    * The formula returned from the AI. It is only set when the sender is the AI. | ||||
|    */ | ||||
|   formula?: string; | ||||
| } | ||||
| 
 | ||||
| /** | ||||
|  * The state of assistance for a particular column. | ||||
|  * ChatMessages are what are shown in the UI, whereas state is | ||||
|  * how the back-end represents the conversation. The two are | ||||
|  * similar but not the same because of post-processing. | ||||
|  * It may be possible to reconcile them when things settle down | ||||
|  * a bit? | ||||
|  */ | ||||
| export interface ChatHistory { | ||||
|   messages: ChatMessage[]; | ||||
|   state?: AssistanceState; | ||||
| } | ||||
|  | ||||
| @ -8,7 +8,7 @@ import {basicButton, primaryButton, textButton} from 'app/client/ui2018/buttons' | ||||
| import {theme} from 'app/client/ui2018/cssVars'; | ||||
| import {cssTextInput, rawTextInput} from 'app/client/ui2018/editableLabel'; | ||||
| import {icon} from 'app/client/ui2018/icons'; | ||||
| import {Suggestion} from 'app/common/AssistancePrompts'; | ||||
| import {AssistanceResponse, AssistanceState} from 'app/common/AssistancePrompts'; | ||||
| import {Disposable, dom, makeTestId, MultiHolder, obsArray, Observable, styled} from 'grainjs'; | ||||
| import noop from 'lodash/noop'; | ||||
| 
 | ||||
| @ -78,7 +78,7 @@ function buildControls( | ||||
|   } | ||||
| ) { | ||||
| 
 | ||||
|   const hasHistory = props.column.chatHistory.peek().get().length > 0; | ||||
|   const hasHistory = props.column.chatHistory.peek().get().messages.length > 0; | ||||
| 
 | ||||
|   // State variables, to show various parts of the UI.
 | ||||
|   const saveButtonVisible = Observable.create(owner, true); | ||||
| @ -153,36 +153,46 @@ function buildControls( | ||||
|   }; | ||||
| } | ||||
| 
 | ||||
| function buildChat(owner: Disposable, context: Context & { formulaClicked: (formula: string) => void }) { | ||||
| function buildChat(owner: Disposable, context: Context & { formulaClicked: (formula?: string) => void }) { | ||||
|   const { grist, column } = context; | ||||
| 
 | ||||
|   const history = owner.autoDispose(obsArray(column.chatHistory.peek().get())); | ||||
|   const history = owner.autoDispose(obsArray(column.chatHistory.peek().get().messages)); | ||||
|   const hasHistory = history.get().length > 0; | ||||
|   const enabled = Observable.create(owner, hasHistory); | ||||
|   const introVisible = Observable.create(owner, !hasHistory); | ||||
|   owner.autoDispose(history.addListener((cur) => { | ||||
|     column.chatHistory.peek().set([...cur]); | ||||
|     const chatHistory = column.chatHistory.peek(); | ||||
|     chatHistory.set({...chatHistory.get(), messages: [...cur]}); | ||||
|   })); | ||||
| 
 | ||||
|   const submit = async () => { | ||||
|     // Ask about suggestion, and send the whole history. Currently the chat is implemented by just sending
 | ||||
|     // all previous user prompts back to the AI. This is subject to change (and probably should be done in the backend).
 | ||||
|     const prompt = history.get().filter(x => x.sender === 'user') | ||||
|                                 .map(entry => entry.message) | ||||
|                                 .filter(Boolean) | ||||
|                                 .join("\n"); | ||||
|     console.debug('prompt', prompt); | ||||
|     const { suggestedActions } = await askAI(grist, column, prompt); | ||||
|     console.debug('suggestedActions', suggestedActions); | ||||
|   const submit = async (regenerate: boolean = false) => { | ||||
|     // Send most recent question, and send back any conversation
 | ||||
|     // state we have been asked to track.
 | ||||
|     const chatHistory = column.chatHistory.peek().get(); | ||||
|     const messages = chatHistory.messages.filter(msg => msg.sender === 'user'); | ||||
|     const description = messages[messages.length - 1]?.message || ''; | ||||
|     console.debug('description', {description}); | ||||
|     const {reply, suggestedActions, state} = await askAI(grist, { | ||||
|       column, description, state: chatHistory.state, | ||||
|       regenerate, | ||||
|     }); | ||||
|     console.debug('suggestedActions', {suggestedActions, reply}); | ||||
|     const firstAction = suggestedActions[0] as any; | ||||
|     // Add the formula to the history.
 | ||||
|     const formula = firstAction[3].formula as string; | ||||
|     const formula = firstAction ? firstAction[3].formula as string : undefined; | ||||
|     // Add to history
 | ||||
|     history.push({ | ||||
|       message: formula, | ||||
|       message: formula || reply || '(no reply)', | ||||
|       sender: 'ai', | ||||
|       formula | ||||
|     }); | ||||
|     // If back-end is capable of conversation, keep its state.
 | ||||
|     if (state) { | ||||
|       const chatHistoryNew = column.chatHistory.peek(); | ||||
|       const value = chatHistoryNew.get(); | ||||
|       value.state = state; | ||||
|       chatHistoryNew.set(value); | ||||
|     } | ||||
|     return formula; | ||||
|   }; | ||||
| 
 | ||||
| @ -203,12 +213,13 @@ function buildChat(owner: Disposable, context: Context & { formulaClicked: (form | ||||
|     // Remove the last AI response from the history.
 | ||||
|     history.pop(); | ||||
|     // And submit again.
 | ||||
|     context.formulaClicked(await submit()); | ||||
|     context.formulaClicked(await submit(true)); | ||||
|   }; | ||||
| 
 | ||||
|   const newChat = () => { | ||||
|     // Clear the history.
 | ||||
|     history.set([]); | ||||
|     column.chatHistory.peek().set({messages: []}); | ||||
|     // Show intro.
 | ||||
|     introVisible.set(true); | ||||
|   }; | ||||
| @ -371,9 +382,11 @@ function openAIAssistant(grist: GristDoc, column: ColumnRec) { | ||||
|   const chat = buildChat(owner, {...props, | ||||
|     // When a formula is clicked (or just was returned from the AI), we set it in the formula editor and hit
 | ||||
|     // the preview button.
 | ||||
|     formulaClicked: (formula: string) => { | ||||
|       formulaEditor.set(formula); | ||||
|       controls.preview().catch(reportError); | ||||
|     formulaClicked: (formula?: string) => { | ||||
|       if (formula) { | ||||
|         formulaEditor.set(formula); | ||||
|         controls.preview().catch(reportError); | ||||
|       } | ||||
|     }, | ||||
|   }); | ||||
| 
 | ||||
| @ -397,11 +410,22 @@ function openAIAssistant(grist: GristDoc, column: ColumnRec) { | ||||
|   grist.formulaPopup.autoDispose(popup); | ||||
| } | ||||
| 
 | ||||
| async function askAI(grist: GristDoc, column: ColumnRec, description: string): Promise<Suggestion> { | ||||
| async function askAI(grist: GristDoc, options: { | ||||
|   column: ColumnRec, | ||||
|   description: string, | ||||
|   regenerate?: boolean, | ||||
|   state?: AssistanceState | ||||
| }): Promise<AssistanceResponse> { | ||||
|   const {column, description, state, regenerate} = options; | ||||
|   const tableId = column.table.peek().tableId.peek(); | ||||
|   const colId = column.colId.peek(); | ||||
|   try { | ||||
|     const result = await grist.docComm.getAssistance({tableId, colId, description}); | ||||
|     const result = await grist.docComm.getAssistance({ | ||||
|       context: {type: 'formula', tableId, colId}, | ||||
|       text: description, | ||||
|       state, | ||||
|       regenerate, | ||||
|     }); | ||||
|     return result; | ||||
|   } catch (error) { | ||||
|     reportError(error); | ||||
|  | ||||
| @ -1,5 +1,5 @@ | ||||
| import {ActionGroup} from 'app/common/ActionGroup'; | ||||
| import {Prompt, Suggestion} from 'app/common/AssistancePrompts'; | ||||
| import {AssistanceRequest, AssistanceResponse} from 'app/common/AssistancePrompts'; | ||||
| import {BulkAddRecord, CellValue, TableDataAction, UserAction} from 'app/common/DocActions'; | ||||
| import {FormulaProperties} from 'app/common/GranularAccessClause'; | ||||
| import {UIRowId} from 'app/common/UIRowId'; | ||||
| @ -323,7 +323,7 @@ export interface ActiveDocAPI { | ||||
|   /** | ||||
|    * Generates a formula code based on the AI suggestions, it also modifies the column and sets it type to a formula. | ||||
|    */ | ||||
|   getAssistance(userPrompt: Prompt): Promise<Suggestion>; | ||||
|   getAssistance(request: AssistanceRequest): Promise<AssistanceResponse>; | ||||
| 
 | ||||
|   /** | ||||
|    * Fetch content at a url. | ||||
|  | ||||
| @ -1,11 +1,56 @@ | ||||
| import {DocAction} from 'app/common/DocActions'; | ||||
| 
 | ||||
| export interface Prompt { | ||||
|   tableId: string; | ||||
|   colId: string | ||||
|   description: string; | ||||
| /** | ||||
|  * State related to a request for assistance. | ||||
|  * | ||||
|  * If an AssistanceResponse contains state, that state can be | ||||
|  * echoed back in an AssistanceRequest to continue a "conversation." | ||||
|  * | ||||
|  * Ideally, the state should not be modified or relied upon | ||||
|  * by the client, so as not to commit too hard to a particular | ||||
|  * model at this time (it is a bit early for that). | ||||
|  */ | ||||
| export interface AssistanceState { | ||||
|   messages?: Array<{ | ||||
|     role: string; | ||||
|     content: string; | ||||
|   }>; | ||||
| } | ||||
| 
 | ||||
| export interface Suggestion { | ||||
|   suggestedActions: DocAction[]; | ||||
| /** | ||||
|  * Currently, requests for assistance always happen in the context | ||||
|  * of the column of a particular table. | ||||
|  */ | ||||
| export interface FormulaAssistanceContext { | ||||
|   type: 'formula'; | ||||
|   tableId: string; | ||||
|   colId: string; | ||||
| } | ||||
| 
 | ||||
| export type AssistanceContext = FormulaAssistanceContext; | ||||
| 
 | ||||
| /** | ||||
|  * A request for assistance. | ||||
|  */ | ||||
| export interface AssistanceRequest { | ||||
|   context: AssistanceContext; | ||||
|   state?: AssistanceState; | ||||
|   text: string; | ||||
|   regenerate?: boolean;  // Set if there was a previous request
 | ||||
|                          // and response that should be omitted
 | ||||
|                          // from history, or (if available) an
 | ||||
|                          // alternative response generated.
 | ||||
| } | ||||
| 
 | ||||
| /** | ||||
|  * A response to a request for assistance. | ||||
|  * The client should preserve the state and include it in | ||||
|  * any follow-up requests. | ||||
|  */ | ||||
| export interface AssistanceResponse { | ||||
|   suggestedActions: DocAction[]; | ||||
|   state?: AssistanceState; | ||||
|   // If the model can be trusted to issue a self-contained
 | ||||
|   // markdown-friendly string, it can be included here.
 | ||||
|   reply?: string; | ||||
| } | ||||
|  | ||||
| @ -14,7 +14,7 @@ import { | ||||
| } from 'app/common/ActionBundle'; | ||||
| import {ActionGroup, MinimalActionGroup} from 'app/common/ActionGroup'; | ||||
| import {ActionSummary} from "app/common/ActionSummary"; | ||||
| import {Prompt, Suggestion} from "app/common/AssistancePrompts"; | ||||
| import {AssistanceRequest, AssistanceResponse} from "app/common/AssistancePrompts"; | ||||
| import { | ||||
|   AclResources, | ||||
|   AclTableDescription, | ||||
| @ -85,7 +85,7 @@ import {Document} from 'app/gen-server/entity/Document'; | ||||
| import {ParseOptions} from 'app/plugin/FileParserAPI'; | ||||
| import {AccessTokenOptions, AccessTokenResult, GristDocAPI} from 'app/plugin/GristAPI'; | ||||
| import {compileAclFormula} from 'app/server/lib/ACLFormula'; | ||||
| import {sendForCompletion} from 'app/server/lib/Assistance'; | ||||
| import {AssistanceDoc, AssistanceSchemaPromptV1Context, sendForCompletion} from 'app/server/lib/Assistance'; | ||||
| import {Authorizer} from 'app/server/lib/Authorizer'; | ||||
| import {checksumFile} from 'app/server/lib/checksumFile'; | ||||
| import {Client} from 'app/server/lib/Client'; | ||||
| @ -180,7 +180,7 @@ interface UpdateUsageOptions { | ||||
|  * either .loadDoc() or .createEmptyDoc() is called. | ||||
|  * @param {String} docName - The document's filename, without the '.grist' extension. | ||||
|  */ | ||||
| export class ActiveDoc extends EventEmitter { | ||||
| export class ActiveDoc extends EventEmitter implements AssistanceDoc { | ||||
|   /** | ||||
|    * Decorator for ActiveDoc methods that prevents shutdown while the method is running, i.e. | ||||
|    * until the returned promise is resolved. | ||||
| @ -1112,7 +1112,7 @@ export class ActiveDoc extends EventEmitter { | ||||
|    * @param {Integer} rowId - Row number | ||||
|    * @returns {Promise} Promise for a error message | ||||
|    */ | ||||
|   public async getFormulaError(docSession: DocSession, tableId: string, colId: string, | ||||
|   public async getFormulaError(docSession: OptDocSession, tableId: string, colId: string, | ||||
|                                rowId: number): Promise<CellValue> { | ||||
|     // Throw an error if the user doesn't have access to read this cell.
 | ||||
|     await this._granularAccess.getCellValue(docSession, {tableId, colId, rowId}); | ||||
| @ -1260,22 +1260,28 @@ export class ActiveDoc extends EventEmitter { | ||||
|     return this._pyCall('autocomplete', txt, tableId, columnId, rowId, user.toJSON()); | ||||
|   } | ||||
| 
 | ||||
|   public async getAssistance(docSession: DocSession, userPrompt: Prompt): Promise<Suggestion> { | ||||
|     // Making a prompt can leak names of tables and columns.
 | ||||
|   public async getAssistance(docSession: DocSession, request: AssistanceRequest): Promise<AssistanceResponse> { | ||||
|     return this.getAssistanceWithOptions(docSession, request); | ||||
|   } | ||||
| 
 | ||||
|   public async getAssistanceWithOptions(docSession: DocSession, | ||||
|                                         request: AssistanceRequest): Promise<AssistanceResponse> { | ||||
|     // Making a prompt leaks names of tables and columns etc.
 | ||||
|     if (!await this._granularAccess.canScanData(docSession)) { | ||||
|       throw new Error("Permission denied"); | ||||
|     } | ||||
|     await this.waitForInitialization(); | ||||
|     const { tableId, colId, description } = userPrompt; | ||||
|     const prompt = await this._pyCall('get_formula_prompt', tableId, colId, description); | ||||
|     this._log.debug(docSession, 'getAssistance prompt', {prompt}); | ||||
|     const completion = await sendForCompletion(prompt); | ||||
|     this._log.debug(docSession, 'getAssistance completion', {completion}); | ||||
|     const formula = await this._pyCall('convert_formula_completion', completion); | ||||
|     const action: DocAction = ["ModifyColumn", tableId, colId, {formula}]; | ||||
|     return { | ||||
|       suggestedActions: [action], | ||||
|     }; | ||||
|     return sendForCompletion(this, request); | ||||
|   } | ||||
| 
 | ||||
|   // Callback to make a data-engine formula tweak for assistance.
 | ||||
|   public assistanceFormulaTweak(txt: string) { | ||||
|     return this._pyCall('convert_formula_completion', txt); | ||||
|   } | ||||
| 
 | ||||
|   // Callback to generate a prompt containing schema info for assistance.
 | ||||
|   public assistanceSchemaPromptV1(options: AssistanceSchemaPromptV1Context): Promise<string> { | ||||
|     return this._pyCall('get_formula_prompt', options.tableId, options.colId, options.docString); | ||||
|   } | ||||
| 
 | ||||
|   public fetchURL(docSession: DocSession, url: string, options?: FetchUrlOptions): Promise<UploadResult> { | ||||
|  | ||||
| @ -2,116 +2,320 @@ | ||||
|  * Module with functions used for AI formula assistance. | ||||
|  */ | ||||
| 
 | ||||
| import {AssistanceRequest, AssistanceResponse} from 'app/common/AssistancePrompts'; | ||||
| import {delay} from 'app/common/delay'; | ||||
| import {DocAction} from 'app/common/DocActions'; | ||||
| import log from 'app/server/lib/log'; | ||||
| import fetch from 'node-fetch'; | ||||
| 
 | ||||
| export const DEPS = { fetch }; | ||||
| 
 | ||||
| export async function sendForCompletion(prompt: string): Promise<string> { | ||||
|   let completion: string|null = null; | ||||
|   let retries: number = 0; | ||||
|   const openApiKey = process.env.OPENAI_API_KEY; | ||||
|   const model = process.env.COMPLETION_MODEL || "text-davinci-002"; | ||||
| /** | ||||
|  * An assistant can help a user do things with their document, | ||||
|  * by interfacing with an external LLM endpoint. | ||||
|  */ | ||||
| export interface Assistant { | ||||
|   apply(doc: AssistanceDoc, request: AssistanceRequest): Promise<AssistanceResponse>; | ||||
| } | ||||
| 
 | ||||
| /** | ||||
|  * Document-related methods for use in the implementation of assistants. | ||||
|  * Somewhat ad-hoc currently. | ||||
|  */ | ||||
| export interface AssistanceDoc { | ||||
|   /** | ||||
|    * Generate a particular prompt coded in the data engine for some reason. | ||||
|    * It makes python code for some tables, and starts a function body with | ||||
|    * the given docstring. | ||||
|    * Marked "V1" to suggest that it is a particular prompt and it would | ||||
|    * be great to try variants. | ||||
|    */ | ||||
|   assistanceSchemaPromptV1(options: AssistanceSchemaPromptV1Context): Promise<string>; | ||||
| 
 | ||||
|   /** | ||||
|    * Some tweaks to a formula after it has been generated. | ||||
|    */ | ||||
|   assistanceFormulaTweak(txt: string): Promise<string>; | ||||
| } | ||||
| 
 | ||||
| export interface AssistanceSchemaPromptV1Context { | ||||
|   tableId: string, | ||||
|   colId: string, | ||||
|   docString: string, | ||||
| } | ||||
| 
 | ||||
| /** | ||||
|  * A flavor of assistant for use with the OpenAI API. | ||||
|  * Tested primarily with text-davinci-002 and gpt-3.5-turbo. | ||||
|  */ | ||||
| export class OpenAIAssistant implements Assistant { | ||||
|   private _apiKey: string; | ||||
|   private _model: string; | ||||
|   private _chatMode: boolean; | ||||
|   private _endpoint: string; | ||||
| 
 | ||||
|   public constructor() { | ||||
|     const apiKey = process.env.OPENAI_API_KEY; | ||||
|     if (!apiKey) { | ||||
|       throw new Error('OPENAI_API_KEY not set'); | ||||
|     } | ||||
|     this._apiKey = apiKey; | ||||
|     this._model = process.env.COMPLETION_MODEL || "text-davinci-002"; | ||||
|     this._chatMode = this._model.includes('turbo'); | ||||
|     this._endpoint = `https://api.openai.com/v1/${this._chatMode ? 'chat/' : ''}completions`; | ||||
|   } | ||||
| 
 | ||||
|   public async apply(doc: AssistanceDoc, request: AssistanceRequest): Promise<AssistanceResponse> { | ||||
|     const messages = request.state?.messages || []; | ||||
|     const chatMode = this._chatMode; | ||||
|     if (chatMode) { | ||||
|       if (messages.length === 0) { | ||||
|         messages.push({ | ||||
|           role: 'system', | ||||
|           content: 'The user gives you one or more Python classes, ' + | ||||
|             'with one last method that needs completing. Write the ' + | ||||
|             'method body as a single code block, ' + | ||||
|             'including the docstring the user gave. ' + | ||||
|             'Just give the Python code as a markdown block, ' + | ||||
|             'do not give any introduction, that will just be ' + | ||||
|             'awkward for the user when copying and pasting. ' + | ||||
|             'You are working with Grist, an environment very like ' + | ||||
|             'regular Python except `rec` (like record) is used ' + | ||||
|             'instead of `self`. ' + | ||||
|             'Include at least one `return` statement or the method ' + | ||||
|             'will fail, disappointing the user. ' + | ||||
|             'Your answer should be the body of a single method, ' + | ||||
|             'not a class, and should not include `dataclass` or ' + | ||||
|             '`class` since the user is counting on you to provide ' + | ||||
|             'a single method. Thanks!' | ||||
|         }); | ||||
|         messages.push({ | ||||
|           role: 'user', content: await makeSchemaPromptV1(doc, request), | ||||
|         }); | ||||
|       } else { | ||||
|         if (request.regenerate) { | ||||
|           if (messages[messages.length - 1].role !== 'user') { | ||||
|             messages.pop(); | ||||
|           } | ||||
|         } | ||||
|         messages.push({ | ||||
|           role: 'user', content: request.text, | ||||
|         }); | ||||
|       } | ||||
|     } else { | ||||
|       messages.length = 0; | ||||
|       messages.push({ | ||||
|         role: 'user', content: await makeSchemaPromptV1(doc, request), | ||||
|       }); | ||||
|     } | ||||
| 
 | ||||
|     const apiResponse = await DEPS.fetch( | ||||
|       this._endpoint, | ||||
|       { | ||||
|         method: "POST", | ||||
|         headers: { | ||||
|           "Authorization": `Bearer ${this._apiKey}`, | ||||
|           "Content-Type": "application/json", | ||||
|         }, | ||||
|         body: JSON.stringify({ | ||||
|           ...(!this._chatMode ? { | ||||
|             prompt: messages[messages.length - 1].content, | ||||
|           } : { messages }), | ||||
|           max_tokens: 1500, | ||||
|           temperature: 0, | ||||
|           model: this._model, | ||||
|           stop: this._chatMode ? undefined : ["\n\n"], | ||||
|         }), | ||||
|       }, | ||||
|     ); | ||||
|     if (apiResponse.status !== 200) { | ||||
|       log.error(`OpenAI API returned ${apiResponse.status}: ${await apiResponse.text()}`); | ||||
|       throw new Error(`OpenAI API returned status ${apiResponse.status}`); | ||||
|     } | ||||
|     const result = await apiResponse.json(); | ||||
|     let completion: string = String(chatMode ? result.choices[0].message.content : result.choices[0].text); | ||||
|     const reply = completion; | ||||
|     const history = { messages }; | ||||
|     if (chatMode) { | ||||
|       history.messages.push(result.choices[0].message); | ||||
|       // This model likes returning markdown. Code will typically
 | ||||
|       // be in a code block with ``` delimiters.
 | ||||
|       let lines = completion.split('\n'); | ||||
|       if (lines[0].startsWith('```')) { | ||||
|         lines.shift(); | ||||
|         completion = lines.join('\n'); | ||||
|         const parts = completion.split('```'); | ||||
|         if (parts.length > 1) { | ||||
|           completion = parts[0]; | ||||
|         } | ||||
|         lines = completion.split('\n'); | ||||
|       } | ||||
| 
 | ||||
|       // This model likes repeating the function signature and
 | ||||
|       // docstring, so we try to strip that out.
 | ||||
|       completion = lines.join('\n'); | ||||
|       while (completion.includes('"""')) { | ||||
|         const parts = completion.split('"""'); | ||||
|         completion = parts[parts.length - 1]; | ||||
|       } | ||||
| 
 | ||||
|       // If there's no code block, don't treat the answer as a formula.
 | ||||
|       if (!reply.includes('```')) { | ||||
|         completion = ''; | ||||
|       } | ||||
|     } | ||||
| 
 | ||||
|     const response = await completionToResponse(doc, request, completion, reply); | ||||
|     if (chatMode) { | ||||
|       response.state = history; | ||||
|     } | ||||
|     return response; | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| export class HuggingFaceAssistant implements Assistant { | ||||
|   private _apiKey: string; | ||||
|   private _completionUrl: string; | ||||
| 
 | ||||
|   public constructor() { | ||||
|     const apiKey = process.env.HUGGINGFACE_API_KEY; | ||||
|     if (!apiKey) { | ||||
|       throw new Error('HUGGINGFACE_API_KEY not set'); | ||||
|     } | ||||
|     this._apiKey = apiKey; | ||||
|     // COMPLETION_MODEL values I've tried:
 | ||||
|     //   - codeparrot/codeparrot
 | ||||
|     //   - NinedayWang/PolyCoder-2.7B
 | ||||
|     //   - NovelAI/genji-python-6B
 | ||||
|     let completionUrl = process.env.COMPLETION_URL; | ||||
|     if (!completionUrl) { | ||||
|       if (process.env.COMPLETION_MODEL) { | ||||
|         completionUrl = `https://api-inference.huggingface.co/models/${process.env.COMPLETION_MODEL}`; | ||||
|       } else { | ||||
|         completionUrl = 'https://api-inference.huggingface.co/models/NovelAI/genji-python-6B'; | ||||
|       } | ||||
|     } | ||||
|     this._completionUrl = completionUrl; | ||||
| 
 | ||||
|   } | ||||
| 
 | ||||
|   public async apply(doc: AssistanceDoc, request: AssistanceRequest): Promise<AssistanceResponse> { | ||||
|     if (request.state) { | ||||
|       throw new Error("HuggingFaceAssistant does not support state"); | ||||
|     } | ||||
|     const prompt = await makeSchemaPromptV1(doc, request); | ||||
|     const response = await DEPS.fetch( | ||||
|       this._completionUrl, | ||||
|       { | ||||
|         method: "POST", | ||||
|         headers: { | ||||
|           "Authorization": `Bearer ${this._apiKey}`, | ||||
|           "Content-Type": "application/json", | ||||
|         }, | ||||
|         body: JSON.stringify({ | ||||
|           inputs: prompt, | ||||
|           parameters: { | ||||
|             return_full_text: false, | ||||
|             max_new_tokens: 50, | ||||
|           }, | ||||
|         }), | ||||
|       }, | ||||
|     ); | ||||
|     if (response.status === 503) { | ||||
|       log.error(`Sleeping for 10s - HuggingFace API returned ${response.status}: ${await response.text()}`); | ||||
|       await delay(10000); | ||||
|     } | ||||
|     if (response.status !== 200) { | ||||
|       const text = await response.text(); | ||||
|       log.error(`HuggingFace API returned ${response.status}: ${text}`); | ||||
|       throw new Error(`HuggingFace API returned status ${response.status}: ${text}`); | ||||
|     } | ||||
|     const result = await response.json(); | ||||
|     let completion = result[0].generated_text; | ||||
|     completion = completion.split('\n\n')[0]; | ||||
|     return completionToResponse(doc, request, completion); | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| /** | ||||
|  * Instantiate an assistant, based on environment variables. | ||||
|  */ | ||||
| function getAssistant() { | ||||
|   if (process.env.OPENAI_API_KEY) { | ||||
|     return new OpenAIAssistant(); | ||||
|   } | ||||
|   if (process.env.HUGGINGFACE_API_KEY) { | ||||
|     return new HuggingFaceAssistant(); | ||||
|   } | ||||
|   throw new Error('Please set OPENAI_API_KEY or HUGGINGFACE_API_KEY'); | ||||
| } | ||||
| 
 | ||||
| /** | ||||
|  * Service a request for assistance, with a little retry logic | ||||
|  * since these endpoints can be a bit flakey. | ||||
|  */ | ||||
| export async function sendForCompletion(doc: AssistanceDoc, | ||||
|                                         request: AssistanceRequest): Promise<AssistanceResponse> { | ||||
|   const assistant = getAssistant(); | ||||
| 
 | ||||
|   let retries: number = 0; | ||||
| 
 | ||||
|   let response: AssistanceResponse|null = null; | ||||
|   while(retries++ < 3) { | ||||
|     try { | ||||
|       if (openApiKey) { | ||||
|         completion = await sendForCompletionOpenAI(prompt, openApiKey, model); | ||||
|       } | ||||
|       if (process.env.HUGGINGFACE_API_KEY) { | ||||
|         completion = await sendForCompletionHuggingFace(prompt); | ||||
|       } | ||||
|       response = await assistant.apply(doc, request); | ||||
|       break; | ||||
|     } catch(e) { | ||||
|       log.error(`Completion error: ${e}`); | ||||
|       await delay(1000); | ||||
|     } | ||||
|   } | ||||
|   if (completion === null) { | ||||
|     throw new Error("Please set OPENAI_API_KEY or HUGGINGFACE_API_KEY (and optionally COMPLETION_MODEL)"); | ||||
|   if (!response) { | ||||
|     throw new Error('Failed to get response from assistant'); | ||||
|   } | ||||
|   log.debug(`Received completion:`, {completion}); | ||||
|   completion = completion.split(/\n {4}[^ ]/)[0]; | ||||
|   return completion; | ||||
|   return response; | ||||
| } | ||||
| 
 | ||||
| 
 | ||||
| async function sendForCompletionOpenAI(prompt: string, apiKey: string, model = "text-davinci-002") { | ||||
|   if (!apiKey) { | ||||
|     throw new Error("OPENAI_API_KEY not set"); | ||||
| async function makeSchemaPromptV1(doc: AssistanceDoc, request: AssistanceRequest) { | ||||
|   if (request.context.type !== 'formula') { | ||||
|     throw new Error('makeSchemaPromptV1 only works for formulas'); | ||||
|   } | ||||
|   const response = await DEPS.fetch( | ||||
|     "https://api.openai.com/v1/completions", | ||||
|     { | ||||
|       method: "POST", | ||||
|       headers: { | ||||
|         "Authorization": `Bearer ${apiKey}`, | ||||
|         "Content-Type": "application/json", | ||||
|       }, | ||||
|       body: JSON.stringify({ | ||||
|         prompt, | ||||
|         max_tokens: 150, | ||||
|         temperature: 0, | ||||
|         // COMPLETION_MODEL of `code-davinci-002` may be better if you have access to it.
 | ||||
|         model, | ||||
|         stop: ["\n\n"], | ||||
|       }), | ||||
|     }, | ||||
|   ); | ||||
|   if (response.status !== 200) { | ||||
|     log.error(`OpenAI API returned ${response.status}: ${await response.text()}`); | ||||
|     throw new Error(`OpenAI API returned status ${response.status}`); | ||||
|   } | ||||
|   const result = await response.json(); | ||||
|   const completion = result.choices[0].text; | ||||
|   return completion; | ||||
|   return doc.assistanceSchemaPromptV1({ | ||||
|     tableId: request.context.tableId, | ||||
|     colId: request.context.colId, | ||||
|     docString: request.text, | ||||
|   }); | ||||
| } | ||||
| 
 | ||||
| async function sendForCompletionHuggingFace(prompt: string) { | ||||
|   const apiKey = process.env.HUGGINGFACE_API_KEY; | ||||
|   if (!apiKey) { | ||||
|     throw new Error("HUGGINGFACE_API_KEY not set"); | ||||
| async function completionToResponse(doc: AssistanceDoc, request: AssistanceRequest, | ||||
|                                     completion: string, reply?: string): Promise<AssistanceResponse> { | ||||
|   if (request.context.type !== 'formula') { | ||||
|     throw new Error('completionToResponse only works for formulas'); | ||||
|   } | ||||
|   // COMPLETION_MODEL values I've tried:
 | ||||
|   //   - codeparrot/codeparrot
 | ||||
|   //   - NinedayWang/PolyCoder-2.7B
 | ||||
|   //   - NovelAI/genji-python-6B
 | ||||
|   let completionUrl = process.env.COMPLETION_URL; | ||||
|   if (!completionUrl) { | ||||
|     if (process.env.COMPLETION_MODEL) { | ||||
|       completionUrl = `https://api-inference.huggingface.co/models/${process.env.COMPLETION_MODEL}`; | ||||
|     } else { | ||||
|       completionUrl = 'https://api-inference.huggingface.co/models/NovelAI/genji-python-6B'; | ||||
|   completion = await doc.assistanceFormulaTweak(completion); | ||||
|   // A leading newline is common.
 | ||||
|   if (completion.charAt(0) === '\n') { | ||||
|     completion = completion.slice(1); | ||||
|   } | ||||
|   // If all non-empty lines have four spaces, remove those spaces.
 | ||||
|   // They are common for GPT-3.5, which matches the prompt carefully.
 | ||||
|   const lines = completion.split('\n'); | ||||
|   const ok = lines.every(line => line === '\n' || line.startsWith('    ')); | ||||
|   if (ok) { | ||||
|     completion = lines.map(line => line === '\n' ? line : line.slice(4)).join('\n'); | ||||
|   } | ||||
| 
 | ||||
|   // Suggest an action only if the completion is non-empty (that is,
 | ||||
|   // it actually looked like code).
 | ||||
|   const suggestedActions: DocAction[] = completion ? [[ | ||||
|     "ModifyColumn", | ||||
|     request.context.tableId, | ||||
|     request.context.colId, { | ||||
|       formula: completion, | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   const response = await DEPS.fetch( | ||||
|     completionUrl, | ||||
|     { | ||||
|       method: "POST", | ||||
|       headers: { | ||||
|         "Authorization": `Bearer ${apiKey}`, | ||||
|         "Content-Type": "application/json", | ||||
|       }, | ||||
|       body: JSON.stringify({ | ||||
|         inputs: prompt, | ||||
|         parameters: { | ||||
|           return_full_text: false, | ||||
|           max_new_tokens: 50, | ||||
|         }, | ||||
|       }), | ||||
|     }, | ||||
|   ); | ||||
|   if (response.status === 503) { | ||||
|     log.error(`Sleeping for 10s - HuggingFace API returned ${response.status}: ${await response.text()}`); | ||||
|     await delay(10000); | ||||
|   } | ||||
|   if (response.status !== 200) { | ||||
|     const text = await response.text(); | ||||
|     log.error(`HuggingFace API returned ${response.status}: ${text}`); | ||||
|     throw new Error(`HuggingFace API returned status ${response.status}: ${text}`); | ||||
|   } | ||||
|   const result = await response.json(); | ||||
|   const completion = result[0].generated_text; | ||||
|   return completion.split('\n\n')[0]; | ||||
|   ]] : []; | ||||
|   return { | ||||
|     suggestedActions, | ||||
|     reply, | ||||
|   }; | ||||
| } | ||||
|  | ||||
| @ -376,7 +376,8 @@ export class GranularAccess implements GranularAccessForBundle { | ||||
|     function fail(): never { | ||||
|       throw new ErrorWithCode('ACL_DENY', 'Cannot access cell'); | ||||
|     } | ||||
|     if (!await this.hasTableAccess(docSession, cell.tableId)) { fail(); } | ||||
|     const hasExceptionalAccess = this._hasExceptionalFullAccess(docSession); | ||||
|     if (!hasExceptionalAccess && !await this.hasTableAccess(docSession, cell.tableId)) { fail(); } | ||||
|     let rows: TableDataAction|null = null; | ||||
|     if (docData) { | ||||
|       const record = docData.getTable(cell.tableId)?.getRecord(cell.rowId); | ||||
| @ -393,16 +394,18 @@ export class GranularAccess implements GranularAccessForBundle { | ||||
|       return fail(); | ||||
|     } | ||||
|     const rec = new RecordView(rows, 0); | ||||
|     const input: AclMatchInput = {...await this.inputs(docSession), rec, newRec: rec}; | ||||
|     const rowPermInfo = new PermissionInfo(this._ruler.ruleCollection, input); | ||||
|     const rowAccess = rowPermInfo.getTableAccess(cell.tableId).perms.read; | ||||
|     if (rowAccess === 'deny') { fail(); } | ||||
|     if (rowAccess !== 'allow') { | ||||
|       const colAccess = rowPermInfo.getColumnAccess(cell.tableId, cell.colId).perms.read; | ||||
|       if (colAccess === 'deny') { fail(); } | ||||
|     if (!hasExceptionalAccess) { | ||||
|       const input: AclMatchInput = {...await this.inputs(docSession), rec, newRec: rec}; | ||||
|       const rowPermInfo = new PermissionInfo(this._ruler.ruleCollection, input); | ||||
|       const rowAccess = rowPermInfo.getTableAccess(cell.tableId).perms.read; | ||||
|       if (rowAccess === 'deny') { fail(); } | ||||
|       if (rowAccess !== 'allow') { | ||||
|         const colAccess = rowPermInfo.getColumnAccess(cell.tableId, cell.colId).perms.read; | ||||
|         if (colAccess === 'deny') { fail(); } | ||||
|       } | ||||
|       const colValues = rows[3]; | ||||
|       if (!(cell.colId in colValues)) { fail(); } | ||||
|     } | ||||
|     const colValues = rows[3]; | ||||
|     if (!(cell.colId in colValues)) { fail(); } | ||||
|     return rec.get(cell.colId); | ||||
|   } | ||||
| 
 | ||||
|  | ||||
| @ -37,7 +37,6 @@ def column_type(engine, table_id, col_id): | ||||
|       Attachments="Any", | ||||
|     )[parts[0]] | ||||
| 
 | ||||
| 
 | ||||
| def choices(col_rec): | ||||
|   try: | ||||
|     widget_options = json.loads(col_rec.widgetOptions) | ||||
|  | ||||
| @ -984,6 +984,7 @@ | ||||
|         "Column description": "Column description", | ||||
|         "Column label": "Column label", | ||||
|         "Provide a column label": "Provide a column label", | ||||
|         "Save": "Save" | ||||
|         "Save": "Save", | ||||
|         "Close": "Close" | ||||
|     } | ||||
| } | ||||
|  | ||||
| @ -1,12 +1,17 @@ | ||||
| #!/usr/bin/env node
 | ||||
| "use strict"; | ||||
| const fs = require('fs'); | ||||
| const path = require('path'); | ||||
| const codeRoot = path.dirname(path.dirname(path.dirname(__dirname))); | ||||
| 
 | ||||
| let codeRoot = path.dirname(path.dirname(__dirname)); | ||||
| if (!fs.existsSync(path.join(codeRoot, '_build'))) { | ||||
|   codeRoot = path.dirname(codeRoot); | ||||
| } | ||||
| 
 | ||||
| process.env.DATA_PATH = path.join(__dirname, 'data'); | ||||
| 
 | ||||
| 
 | ||||
| require('app-module-path').addPath(path.join(codeRoot, '_build')); | ||||
| require('app-module-path').addPath(path.join(codeRoot, '_build', 'core')); | ||||
| require('app-module-path').addPath(path.join(codeRoot, '_build', 'ext')); | ||||
| require('app-module-path').addPath(path.join(codeRoot, '_build', 'stubs')); | ||||
| require('test/formula-dataset/runCompletion_impl').runCompletion().catch(console.error); | ||||
|  | ||||
| @ -24,7 +24,7 @@ | ||||
|  */ | ||||
| 
 | ||||
| 
 | ||||
| import { ActiveDoc } from "app/server/lib/ActiveDoc"; | ||||
| import { ActiveDoc, Deps as ActiveDocDeps } from "app/server/lib/ActiveDoc"; | ||||
| import { DEPS } from "app/server/lib/Assistance"; | ||||
| import log from 'app/server/lib/log'; | ||||
| import crypto from 'crypto'; | ||||
| @ -38,11 +38,14 @@ import * as os from 'os'; | ||||
| import { pipeline } from 'stream'; | ||||
| import { createDocTools } from "test/server/docTools"; | ||||
| import { promisify } from 'util'; | ||||
| import { AssistanceResponse, AssistanceState } from "app/common/AssistancePrompts"; | ||||
| import { CellValue } from "app/plugin/GristData"; | ||||
| 
 | ||||
| const streamPipeline = promisify(pipeline); | ||||
| 
 | ||||
| const DATA_PATH = process.env.DATA_PATH || path.join(__dirname, 'data'); | ||||
| const PATH_TO_DOC = path.join(DATA_PATH, 'templates'); | ||||
| const PATH_TO_RESULTS = path.join(DATA_PATH, 'results'); | ||||
| const PATH_TO_CSV = path.join(DATA_PATH, 'formula-dataset-index.csv'); | ||||
| const PATH_TO_CACHE = path.join(DATA_PATH, 'cache'); | ||||
| const TEMPLATE_URL = "https://grist-static.com/datasets/grist_dataset_formulai_2023_02_20.zip"; | ||||
| @ -60,8 +63,11 @@ const _stats = { | ||||
|   callCount: 0, | ||||
| }; | ||||
| 
 | ||||
| const SEEMS_CHATTY = (process.env.COMPLETION_MODEL || '').includes('turbo'); | ||||
| const SIMULATE_CONVERSATION = SEEMS_CHATTY; | ||||
| 
 | ||||
| export async function runCompletion() { | ||||
|   ActiveDocDeps.ACTIVEDOC_TIMEOUT = 600000; | ||||
| 
 | ||||
|   // if template directory not exists, make it
 | ||||
|   if (!fs.existsSync(path.join(PATH_TO_DOC))) { | ||||
| @ -107,11 +113,12 @@ export async function runCompletion() { | ||||
|   if (!process.env.VERBOSE) { | ||||
|     log.transports.file.level = 'error';  // Suppress most of log output.
 | ||||
|   } | ||||
|   let activeDoc: ActiveDoc|undefined; | ||||
|   const docTools = createDocTools(); | ||||
|   const session = docTools.createFakeSession('owners'); | ||||
|   await docTools.before(); | ||||
|   let successCount = 0; | ||||
|   let caseCount = 0; | ||||
|   fs.mkdirSync(path.join(PATH_TO_RESULTS), {recursive: true}); | ||||
| 
 | ||||
|   console.log('Testing AI assistance: '); | ||||
| 
 | ||||
| @ -119,38 +126,109 @@ export async function runCompletion() { | ||||
| 
 | ||||
|     DEPS.fetch = fetchWithCache; | ||||
| 
 | ||||
|     let activeDoc: ActiveDoc|undefined; | ||||
|     for (const rec of records) { | ||||
|       let success: boolean = false; | ||||
|       let suggestedActions: AssistanceResponse['suggestedActions'] | undefined; | ||||
|       let newValues: CellValue[] | undefined; | ||||
|       let expected: CellValue[] | undefined; | ||||
|       let formula: string | undefined; | ||||
|       let history: AssistanceState = {messages: []}; | ||||
|       let lastFollowUp: string | undefined; | ||||
| 
 | ||||
|       // load new document
 | ||||
|       if (!activeDoc || activeDoc.docName !== rec.doc_id) { | ||||
|         const docPath = path.join(PATH_TO_DOC, rec.doc_id + '.grist'); | ||||
|         activeDoc = await docTools.loadLocalDoc(docPath); | ||||
|         await activeDoc.waitForInitialization(); | ||||
|       try { | ||||
|         async function sendMessage(followUp?: string) { | ||||
|           // load new document
 | ||||
|           if (!activeDoc || activeDoc.docName !== rec.doc_id) { | ||||
|             const docPath = path.join(PATH_TO_DOC, rec.doc_id + '.grist'); | ||||
|             activeDoc = await docTools.loadLocalDoc(docPath); | ||||
|             await activeDoc.waitForInitialization(); | ||||
|           } | ||||
| 
 | ||||
|           if (!activeDoc) { throw new Error("No doc"); } | ||||
| 
 | ||||
|           // get values
 | ||||
|           await activeDoc.docData!.fetchTable(rec.table_id); | ||||
|           expected = activeDoc.docData!.getTable(rec.table_id)!.getColValues(rec.col_id)!.slice(); | ||||
| 
 | ||||
|           // send prompt
 | ||||
|           const tableId = rec.table_id; | ||||
|           const colId = rec.col_id; | ||||
|           const description = rec.Description; | ||||
|           const colInfo = await activeDoc.docStorage.get(` | ||||
| select * from _grist_Tables_column as c | ||||
| left join _grist_Tables as t on t.id = c.parentId | ||||
| where c.colId = ? and t.tableId = ? | ||||
| `, rec.col_id, rec.table_id);
 | ||||
|           formula = colInfo?.formula; | ||||
| 
 | ||||
|           const result = await activeDoc.getAssistanceWithOptions(session, { | ||||
|             context: {type: 'formula', tableId, colId}, | ||||
|             state: history, | ||||
|             text: followUp || description, | ||||
|           }); | ||||
|           if (result.state) { | ||||
|             history = result.state; | ||||
|           } | ||||
|           suggestedActions = result.suggestedActions; | ||||
|           // apply modification
 | ||||
|           const {actionNum} = await activeDoc.applyUserActions(session, suggestedActions); | ||||
| 
 | ||||
|           // get new values
 | ||||
|           newValues = activeDoc.docData!.getTable(rec.table_id)!.getColValues(rec.col_id)!.slice(); | ||||
| 
 | ||||
|           // revert modification
 | ||||
|           const [bundle] = await activeDoc.getActions([actionNum]); | ||||
|           await activeDoc.applyUserActionsById(session, [bundle!.actionNum], [bundle!.actionHash!], true); | ||||
| 
 | ||||
|           // compare values
 | ||||
|           success = isEqual(expected, newValues); | ||||
| 
 | ||||
|           if (!success) { | ||||
|             const rowIds = activeDoc.docData!.getTable(rec.table_id)!.getRowIds(); | ||||
|             const result = await activeDoc.getFormulaError({client: null, mode: 'system'} as any, rec.table_id, | ||||
|                                                            rec.col_id, rowIds[0]); | ||||
|             if (Array.isArray(result) && result[0] === 'E') { | ||||
|               result.shift(); | ||||
|               const txt = `I got a \`${result.shift()}\` error:\n` + | ||||
|                 '```\n' + | ||||
|                 result.shift() + '\n' + | ||||
|                 result.shift() + '\n' + | ||||
|                 '```\n' + | ||||
|                 'Please answer with the code block you (the assistant) just gave, ' + | ||||
|                 'revised based on this error. Your answer must include a code block. ' + | ||||
|                 'If you have to explain anything, do it after. ' + | ||||
|                 'It is perfectly acceptable (and may be necessary) to do ' + | ||||
|                 'imports from within a method body.\n'; | ||||
|               return { followUp: txt }; | ||||
|             } else { | ||||
|               for (let i = 0; i < expected.length; i++) { | ||||
|                 const e = expected[i]; | ||||
|                 const v = newValues[i]; | ||||
|                 if (String(e) !== String(v)) { | ||||
|                   const txt = `I got \`${v}\` where I expected \`${e}\`\n` + | ||||
|                     'Please answer with the code block you (the assistant) just gave, ' + | ||||
|                     'revised based on this information. Your answer must include a code ' + | ||||
|                     'block. If you have to explain anything, do it after.\n'; | ||||
|                   return { followUp: txt }; | ||||
|                 } | ||||
|               } | ||||
|             } | ||||
|           } | ||||
|           return null; | ||||
|         } | ||||
|         const result = await sendMessage(); | ||||
|         if (result?.followUp && SIMULATE_CONVERSATION) { | ||||
|           // Allow one follow up message, based on error or differences.
 | ||||
|           const result2 = await sendMessage(result.followUp); | ||||
|           if (result2?.followUp) { | ||||
|             lastFollowUp = result2.followUp; | ||||
|           } | ||||
|         } | ||||
|       } catch (e) { | ||||
|         console.error(e); | ||||
|       } | ||||
| 
 | ||||
|       // get values
 | ||||
|       await activeDoc.docData!.fetchTable(rec.table_id); | ||||
|       const expected = activeDoc.docData!.getTable(rec.table_id)!.getColValues(rec.col_id)!.slice(); | ||||
| 
 | ||||
|       // send prompt
 | ||||
|       const tableId = rec.table_id; | ||||
|       const colId = rec.col_id; | ||||
|       const description = rec.Description; | ||||
|       const {suggestedActions} = await activeDoc.getAssistance(session, {tableId, colId, description}); | ||||
| 
 | ||||
|       // apply modification
 | ||||
|       const {actionNum} = await activeDoc.applyUserActions(session, suggestedActions); | ||||
| 
 | ||||
|       // get new values
 | ||||
|       const newValues = activeDoc.docData!.getTable(rec.table_id)!.getColValues(rec.col_id)!.slice(); | ||||
| 
 | ||||
|       // revert modification
 | ||||
|       const [bundle] = await activeDoc.getActions([actionNum]); | ||||
|       await activeDoc.applyUserActionsById(session, [bundle!.actionNum], [bundle!.actionHash!], true); | ||||
| 
 | ||||
|       // compare values
 | ||||
|       const success = isEqual(expected, newValues); | ||||
| 
 | ||||
|       console.log(` ${success ? 'Successfully' : 'Failed to'} complete formula ` + | ||||
|         `for column ${rec.table_id}.${rec.col_id} (doc=${rec.doc_id})`); | ||||
| 
 | ||||
| @ -162,6 +240,23 @@ export async function runCompletion() { | ||||
|         // console.log('expected=', expected);
 | ||||
|         // console.log('actual=', newValues);
 | ||||
|       } | ||||
|       const suggestedFormula = suggestedActions?.length === 1 && | ||||
|         suggestedActions[0][0] === 'ModifyColumn' && | ||||
|         suggestedActions[0][3].formula || suggestedActions; | ||||
|       fs.writeFileSync( | ||||
|         path.join( | ||||
|           PATH_TO_RESULTS, | ||||
|           `${rec.table_id}_${rec.col_id}_` + | ||||
|             caseCount.toLocaleString('en', {minimumIntegerDigits: 8, useGrouping: false}) + '.json'), | ||||
|         JSON.stringify({ | ||||
|           formula, | ||||
|           suggestedFormula, success, | ||||
|           expectedValues: expected, | ||||
|           suggestedValues: newValues, | ||||
|           history, | ||||
|           lastFollowUp, | ||||
|         }, null, 2)); | ||||
|       caseCount++; | ||||
|     } | ||||
|   } finally { | ||||
|     await docTools.after(); | ||||
| @ -171,6 +266,13 @@ export async function runCompletion() { | ||||
|     console.log( | ||||
|       `AI Assistance completed ${successCount} successful prompt on a total of ${records.length};` | ||||
|     ); | ||||
|     console.log(JSON.stringify( | ||||
|       { | ||||
|         hit: successCount, | ||||
|         total: records.length, | ||||
|         percentage: (100.0 * successCount) / Math.max(records.length, 1), | ||||
|       } | ||||
|     )); | ||||
|   } | ||||
| } | ||||
| 
 | ||||
|  | ||||
		Loading…
	
		Reference in New Issue
	
	Block a user