diff --git a/ai/autosummarize/README.md b/ai/autosummarize/README.md new file mode 100644 index 000000000..ae31c5e07 --- /dev/null +++ b/ai/autosummarize/README.md @@ -0,0 +1,35 @@ +# Editor Add-on: Sheets - AutoSummarize AI + +## Project Description + +Google Workspace Editor Add-on for Google Sheets that uses AI to create AI summaries in bulk for a listing of Google Docs, Sheets, and Slides files. + + +## Prerequisites + +* Google Cloud Project (aka Standard Cloud Project for Apps Script) with billing enabled + +## Set up your environment + + +1. Create a Cloud Project + 1. Enable the Vertex AI API + 1. Create a Service Account and grant the role Service Account Token Creator Role + 1. Create a private key with type JSON. This will download the JSON file for use in the next section. +1. Open an Apps Script Project bound to a Google Sheets Spreadsheet. + 1. Rename the script to `Autosummarize AI`. + 1. From Project Settings, change project to GCP project number of Cloud Project from step 1 + 1. Add a Script Property. Enter `model_id` as the property name and `gemini-pro-vision` as the value. + 1. Add a Script Property. Enter `project_location` as the property name and `us-central1` as the value. + 1. Add a Script Property. Enter `service_account_key` as the property name and paste the JSON key from the service account as the value. +1. Add `OAuth2 v43` Apps Script Library using the ID `1B7FSrk5Zi6L1rSxxTDgDEUsPzlukDsi4KGuTMorsTQHhGBzBkMun4iDF`. +1. Enable the `Drive v3` advanced service. +1. Add the project code to Apps Script + + +## Usage + +1. Insert one or more links to any Google Doc, Sheet, or Slides files in a column. +1. Select one or more of the links in the sheet. +1. From the `Sheets` menu, select `Extensions > AutoSummarize AI > Open AutoSummarize AI` +1. Click Get summaries button. diff --git a/ai/autosummarize/appsscript.json b/ai/autosummarize/appsscript.json new file mode 100644 index 000000000..5aed4b13d --- /dev/null +++ b/ai/autosummarize/appsscript.json @@ -0,0 +1,22 @@ +{ + "timeZone": "America/Denver", + "exceptionLogging": "STACKDRIVER", + "runtimeVersion": "V8", + "dependencies": { + "libraries": [ + { + "userSymbol": "OAuth2", + "libraryId": "1B7FSrk5Zi6L1rSxxTDgDEUsPzlukDsi4KGuTMorsTQHhGBzBkMun4iDF", + "version": "43", + "developmentMode": false + } + ], + "enabledAdvancedServices": [ + { + "userSymbol": "Drive", + "version": "v3", + "serviceId": "drive" + } + ] + } +} \ No newline at end of file diff --git a/ai/autosummarize/gemini.js b/ai/autosummarize/gemini.js new file mode 100644 index 000000000..35bbb65bd --- /dev/null +++ b/ai/autosummarize/gemini.js @@ -0,0 +1,112 @@ +/* +Copyright 2024 Google LLC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +function scriptPropertyWithDefault(key, defaultValue = undefined) { + const scriptProperties = PropertiesService.getScriptProperties(); + const value = scriptProperties.getProperty(key); + if (value) { + return value; + } + return defaultValue; +} + +const VERTEX_AI_LOCATION = scriptPropertyWithDefault('project_location', 'us-central1'); +const MODEL_ID = scriptPropertyWithDefault('model_id', 'gemini-pro-vision'); +const SERVICE_ACCOUNT_KEY = scriptPropertyWithDefault('service_account_key'); + + +/** + * Packages prompt and necessary settings, then sends a request to + * Vertex API. Returns the response as an JSON object extracted from the + * Vertex API response object. + * + * @param {string} prompt The prompt to senb to Vertex AI API. + * @param {string} options.temperature The temperature setting set by user. + * @param {string} options.tokens The number of tokens to limit to the prompt. + */ +function getAiSummary(parts, options = {}) { + options = Object.assign({}, { temperature: 0.1, tokens: 8192}, options ?? {}) + const request = { + "contents": [ + { + "role": "user", + "parts": parts, + } + ], + "generationConfig": { + "temperature": options.temperature, + "topK": 1, + "topP": 1, + "maxOutputTokens": options.tokens, + "stopSequences": [] + }, + } + + const credentials = credentialsForVertexAI(); + + const fetchOptions = { + method: 'POST', + headers: { + 'Authorization': `Bearer ${credentials.accessToken}` + }, + contentType: 'application/json', + muteHttpExceptions: true, + payload: JSON.stringify(request) + } + + const url = `https://${VERTEX_AI_LOCATION}-aiplatform.googleapis.com/v1/projects/${credentials.projectId}` + + `/locations/${VERTEX_AI_LOCATION}/publishers/google/models/${MODEL_ID}:generateContent` + const response = UrlFetchApp.fetch(url, fetchOptions); + + + const responseCode = response.getResponseCode(); + if (responseCode >= 400) { + throw new Error(`Unable to process file: Error code ${responseCode}`); + } + + const responseText = response.getContentText(); + const parsedResponse = JSON.parse(responseText); + if (parsedResponse.error) { + throw new Error(parsedResponse.error.message); + } + const text = parsedResponse.candidates[0].content.parts[0].text + return text +} + +/** + * Gets credentials required to call Vertex API using a Service Account. + * Requires use of Service Account Key stored with project + * + * @return {!Object} Containing the Cloud Project Id and the access token. + */ +function credentialsForVertexAI() { + const credentials = SERVICE_ACCOUNT_KEY; + if (!credentials) { + throw new Error("service_account_key script property must be set."); + } + + const parsedCredentials = JSON.parse(credentials); + const service = OAuth2.createService("Vertex") + .setTokenUrl('https://oauth2.googleapis.com/token') + .setPrivateKey(parsedCredentials['private_key']) + .setIssuer(parsedCredentials['client_email']) + .setPropertyStore(PropertiesService.getScriptProperties()) + .setScope("https://www.googleapis.com/auth/cloud-platform"); + return { + projectId: parsedCredentials['project_id'], + accessToken: service.getAccessToken(), + } +} diff --git a/ai/autosummarize/main.js b/ai/autosummarize/main.js new file mode 100644 index 000000000..c6ebe7b39 --- /dev/null +++ b/ai/autosummarize/main.js @@ -0,0 +1,135 @@ +/* +Copyright 2024 Google LLC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +/** + * Creates a menu entry in the Google Sheets Extensions menu when the document is opened. + * + * @param {object} e The event parameter for a simple onOpen trigger. + */ +function onOpen(e) { + SpreadsheetApp.getUi().createAddonMenu() + .addItem('📄 Open AutoSummarize AI', 'showSidebar') + .addSeparator() + .addItem('❎ Quick summary', 'doAutoSummarizeAI') + .addItem('❌ Remove all summaries', 'removeAllSummaries') + .addToUi(); +} + +/** + * Runs when the add-on is installed; calls onOpen() to ensure menu creation and + * any other initializion work is done immediately. This method is only used by + * the desktop add-on and is never called by the mobile version. + * + * @param {object} e The event parameter for a simple onInstall trigger. + */ +function onInstall(e) { + onOpen(e); +} + +/** + * Opens sidebar in Sheets with AutoSummarize AI interface. + */ +function showSidebar() { + const ui = HtmlService.createHtmlOutputFromFile('sidebar') + .setTitle('AutoSummarize AI'); + SpreadsheetApp.getUi().showSidebar(ui); +} + + +/** + * Deletes all of the AutoSummarize AI created sheets + * i.e. any sheets with prefix of 'AutoSummarize AI' + */ +function removeAllSummaries() { + const spreadsheet = SpreadsheetApp.getActiveSpreadsheet(); + const allSheets = spreadsheet.getSheets(); + + allSheets.forEach(function (sheet) { + const sheetName = sheet.getName(); + // Check if the sheet name starts with "AutoSummarize AI" + if (sheetName.startsWith("AutoSummarize AI")) { + spreadsheet.deleteSheet(sheet) + } + }); +} + +/** + * Wrapper function for add-on. + */ +function doAutoSummarizeAI(customPrompt1, customPrompt2, temperature = .1, tokens = 2048) { + // Get selected cell values. + console.log("Getting selection..."); + let selection = SpreadsheetApp.getSelection() + .getActiveRange() + .getRichTextValues() + .map(value => { + if (value[0].getLinkUrl()) { + return value[0].getLinkUrl(); + } + return value[0].getText(); + }); + + // Get AI summary + const data = summarizeFiles(selection, customPrompt1, customPrompt2, temperature, tokens); + + // Add and format a new new sheet. + const now = new Date(); + const nowFormatted = Utilities.formatDate(now, now.getTimezoneOffset().toString(), "MM/dd HH:mm"); + let sheetName = `AutoSummarize AI (${nowFormatted})`; + if (SpreadsheetApp.getActiveSpreadsheet().getSheetByName(sheetName)) { + sheetName = `AutoSummarize AI (${nowFormatted}:${now.getSeconds()})`; + } + let aiSheet = SpreadsheetApp.getActiveSpreadsheet() + .insertSheet() + .setName(sheetName); + let aiSheetHeaderStyle = SpreadsheetApp.newTextStyle() + .setFontSize(12) + .setBold(true) + .setFontFamily("Google Sans") + .setForegroundColor("#ffffff") + .build(); + let aiSheetValuesStyle = SpreadsheetApp.newTextStyle() + .setFontSize(10) + .setBold(false) + .setFontFamily("Google Sans") + .setForegroundColor("#000000") + .build(); + aiSheet.getRange("A1:E1") + .setBackground("#434343") + .setTextStyle(aiSheetHeaderStyle) + .setValues([["Link", "Title",`Summary from Gemini AI [Temperature: ${temperature}]`, `Custom Prompt #1: ${customPrompt1}`, `Custom Prompt #2: ${customPrompt2}`]]) + .setWrap(true); + aiSheet.setColumnWidths(1, 1, 100); + aiSheet.setColumnWidths(2, 1, 300); + aiSheet.setColumnWidths(3, 3, 300); + + // Copy results + aiSheet + .getRange(`A2:E${data.length + 1}`) + .setValues(data); + + aiSheet.getRange(`A2:E${data.length + 1}`) + .setBackground("#ffffff") + .setTextStyle(aiSheetValuesStyle) + .setWrapStrategy(SpreadsheetApp.WrapStrategy.CLIP) + .setVerticalAlignment("top"); + aiSheet.getRange(`C2:E${data.length + 1}`) + .setBackground("#efefef") + .setWrapStrategy(SpreadsheetApp.WrapStrategy.WRAP); + + aiSheet.deleteColumns(8, 19); + aiSheet.deleteRows(aiSheet.getLastRow() + 1, aiSheet.getMaxRows() - aiSheet.getLastRow()); +} diff --git a/ai/autosummarize/sidebar.html b/ai/autosummarize/sidebar.html new file mode 100644 index 000000000..a8889c6ff --- /dev/null +++ b/ai/autosummarize/sidebar.html @@ -0,0 +1,357 @@ + + + + + + + + + + + +
+

+ + AutoSummarize AI +

+ + + + +

 How to + Use...

+ + + + + +

 Custom + Prompts

+ +
+
+ + +
+
+ + +
+

+


+

+ +

+ + + +

 Prompt + Settings

+ + + + + + + +
+ + + +
+
+ +
+
+ + + + + + \ No newline at end of file diff --git a/ai/autosummarize/summarize.js b/ai/autosummarize/summarize.js new file mode 100644 index 000000000..fae7101eb --- /dev/null +++ b/ai/autosummarize/summarize.js @@ -0,0 +1,145 @@ +/* +Copyright 2024 Google LLC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +/** + * Exports a Google Doc/Sheet/Slide to the requested format. + * + * @param {string} fileId - ID of file to export + * @param {string} targetType - MIME type to export as + * @return Base64 encoded file content + */ +function exportFile(fileId, targetType = "application/pdf") { + const exportUrl = `https://www.googleapis.com/drive/v3/files/${fileId}/export?mimeType=${encodeURIComponent(targetType)}&supportsAllDrives=true`; + + const requestOptions = { + headers: { + Authorization: `Bearer ${ScriptApp.getOAuthToken()}`, + }, + }; + const response = UrlFetchApp.fetch(exportUrl, requestOptions); + const blob = response.getBlob(); + + return Utilities.base64Encode(blob.getBytes()); +} + +/** + * Downloads a binary file from Drive. + * + * @param {string} fileId - ID of file to export + * @param {string} targetType - MIME type to export as + * @return Base64 encoded file content + */ +function downloadFile(fileId) { + const exportUrl = `https://www.googleapis.com/drive/v3/files/${fileId}?alt=media&supportsAllDrives=true`; + + const requestOptions = { + headers: { + Authorization: `Bearer ${ScriptApp.getOAuthToken()}`, + }, + }; + const response = UrlFetchApp.fetch(exportUrl, requestOptions); + const blob = response.getBlob(); + + return Utilities.base64Encode(blob.getBytes()); +} + +/** + * Main function for AutoSummarize AI process. + */ +function summarizeFiles(sourceSheetLinks, customPrompt1, customPrompt2, temperature, tokens) { + return sourceSheetLinks.map(function (fileUrl) { + console.log("Processing:", fileUrl); + + let fileName = ""; + let summary = ""; + let customPrompt1Response = ""; + let customPrompt2Response = ""; + + if (!fileUrl) { + return ["", fileName, summary, customPrompt1Response, customPrompt2Response]; + } + try { + const promptParts = [ + { + text: 'Summarize the following document.', + }, + { + text: 'Return your response as a single paragraph. Reformat any lists as part of the paragraph. Output only the single paragraph as plain text. Do not use more than 3 sentences. Do not use markdown.' + } + ] + let fileIdMatchPattern = new RegExp("/d/(.*?)/", "gi"); + let fileId = fileIdMatchPattern.exec(fileUrl)[1]; + + // Get file title and type. + let currentFile = Drive.Files.get(fileId, { "supportsAllDrives": true }); + let fileMimeType = currentFile.mimeType; + fileName = currentFile.name; + + console.log(`Processing ${fileName} (ID: ${fileId})...`); + + // Add file content to the prompt + switch(fileMimeType) { + case "application/vnd.google-apps.presentation": + case "application/vnd.google-apps.document": + case "application/vnd.google-apps.spreadsheet": + promptParts.push({ + inlineData: { + mimeType: 'application/pdf', + data: exportFile(fileId, 'application/pdf'), + } + }) + break; + case "application/pdf": + case "image/gif": + case "image/jpeg": + case "image/png": + promptParts.push({ + inlineData: { + mimeType: fileMimeType, + data: downloadFile(fileId), + } + }) + break; + default: + console.log(`Unsupported file type: ${fileMimeType}`); + return [fileUrl, fileName, summary, customPrompt1Response, customPrompt2Response]; + } + + // Prompt for summary + let geminiOptions = { + temperature, + tokens, + }; + summary = getAiSummary(promptParts, geminiOptions); + + // If any custom prompts, request those too + if (customPrompt1) { + promptParts[0].text = customPrompt1; + customPrompt1Response = getAiSummary(promptParts, geminiOptions); + } + if (customPrompt2) { + promptParts[0].text = customPrompt2; + customPrompt2Response = getAiSummary(promptParts, geminiOptions); + } + + return [fileUrl, fileName, summary, customPrompt1Response, customPrompt2Response]; + } catch (e) { + // Add error row values if anything else goes wrong. + console.log(e); + return [fileUrl, fileName, "Something went wrong. Make sure you have access to this row's link.", "", ""]; + } + }); +} \ No newline at end of file