/** * @OnlyCurrentDoc */ const API_BASE = 'https://getlikely.app'; const KEY_PROP = 'LIKELY_KEY'; // ---------- custom functions ---------- /** * Probability (0 to 1) that the question is true for each cell. * * @param {A2:A100} range The cells to judge. * @param {"Is the customer asking for a refund?"} question What to judge, in plain English. * @return A probability between 0 and 1 for every cell. * @customfunction */ function LIKELY(range, question) { return judgeGrid_('probability', range, question, {}, r => r.p); } /** * Picks the best-fitting label for each cell. * * @param {A2:A100} range The cells to judge. * @param {"Which team should handle this?"} question What to decide. * @param {"billing, technical, sales"} labels The allowed labels: a range, or a comma-separated list. * @param {TRUE} with_confidence Optional. TRUE adds a confidence column (0 to 1) next to the label. * @return The chosen label for every cell. * @customfunction */ function LIKELY_CHOICE(range, question, labels, with_confidence) { const opts = { labels: toList_(labels, 'labels') }; if (!with_confidence) return judgeGrid_('choice', range, question, opts, r => r.label); const cells = toGrid_(range); if (cells[0].length !== 1) throw new Error('with_confidence works on a single column.'); const results = callJudge_(docKey_(), 'choice', question, opts, cells.map(row => row[0])); return results.map(r => (r ? [r.label, r.confidence] : ['', ''])); } /** * Scores each cell on your scale, from 0 (first level) to n-1 (last level). * * @param {A2:A100} range The cells to judge. * @param {"How upset is the customer?"} question What to score. * @param {"Happy, Neutral, Annoyed, Furious"} levels The scale from lowest to highest: a range, or a comma-separated list. * @return A score from 0 to (number of levels - 1) for every cell. * @customfunction */ function LIKELY_SCORE(range, question, levels) { return judgeGrid_('score', range, question, { levels: toList_(levels, 'levels') }, r => r.score); } function judgeGrid_(kind, range, question, opts, pick) { const cells = toGrid_(range); const flat = cells.reduce((all, row) => all.concat(row), []); const results = callJudge_(docKey_(), kind, question, opts, flat); const width = cells[0].length; return cells.map((row, i) => row.map((_, j) => { const r = results[i * width + j]; return r ? pick(r) : ''; })); } function toGrid_(range) { return Array.isArray(range) ? range : [[range]]; } function toList_(x, name) { const flat = Array.isArray(x) ? x.reduce((all, row) => all.concat(row), []).map(function (v) { return String(v == null ? '' : v); }) : [String(x == null ? '' : x)]; // One cell holding "billing, bug, praise" is a comma list, not a single label. const list = flat.length === 1 ? flat[0].split(',') : flat; const clean = list.map(function (s) { return s.trim(); }).filter(Boolean); if (clean.length < 2) throw new Error('Give at least two ' + name + ', as a range or a comma-separated list.'); return clean; } // ---------- API ---------- function callJudge_(key, kind, question, opts, values) { if (!question) throw new Error('Add a question, e.g. "Is the customer asking for a refund?"'); const body = { kind: kind, question: String(question), labels: opts.labels, levels: opts.levels, values: values.map(v => (v === '' || v == null ? null : String(v))) }; return api_('post', '/v1/judge', key, body).results; } function api_(method, path, key, body) { const res = UrlFetchApp.fetch(API_BASE + path, { method: method, contentType: 'application/json', headers: key ? { 'X-Likely-Key': key } : {}, payload: body ? JSON.stringify(body) : undefined, muteHttpExceptions: true, }); const code = res.getResponseCode(); const text = res.getContentText(); let json = {}; try { json = JSON.parse(text); } catch (e) { /* non-JSON error page */ } if (code >= 200 && code < 300) return json; const err = new Error(json.error || ('Likely API error ' + code)); err.status = code; throw err; } function docKey_() { const key = PropertiesService.getDocumentProperties().getProperty(KEY_PROP); if (!key) throw new Error('No Likely key in this spreadsheet. Open Extensions > Likely > Open to add one.'); return key; } // ---------- menu & sidebar ---------- function onInstall(e) { onOpen(e); } function onOpen() { SpreadsheetApp.getUi().createAddonMenu().addItem('Open', 'showSidebar').addToUi(); } function showSidebar() { SpreadsheetApp.getUi().showSidebar(HtmlService.createHtmlOutputFromFile('Sidebar').setTitle('Likely')); } function getState() { const key = PropertiesService.getDocumentProperties().getProperty(KEY_PROP); if (!key) return { hasKey: false }; try { return { hasKey: true, keyHint: key.slice(0, 6) + '…' + key.slice(-4), usage: api_('get', '/v1/usage', key) }; } catch (e) { // A key the server does not know (revoked, or pasted wrong) would otherwise keep the signed-in // view up with a blank plan. It is no use to anyone editing this sheet, so drop it and send the // sidebar back to the first screen, where getting or pasting a key is the next step. if (e.status === 401) { PropertiesService.getDocumentProperties().deleteProperty(KEY_PROP); return { hasKey: false, error: 'That key is not valid any more. Get a free key or paste another one.' }; } return { hasKey: true, keyHint: key.slice(0, 6) + '…' + key.slice(-4), error: e.message }; } } function saveKey(key) { key = String(key || '').trim(); api_('get', '/v1/usage', key); PropertiesService.getDocumentProperties().setProperty(KEY_PROP, key); return getState(); } function getFreeKey(email) { const res = api_('post', '/v1/keys/free', null, { email: String(email || '').trim() }); PropertiesService.getDocumentProperties().setProperty(KEY_PROP, res.key); return getState(); } function removeKey() { PropertiesService.getDocumentProperties().deleteProperty(KEY_PROP); return getState(); } function selectionInfo() { const range = SpreadsheetApp.getActiveRange(); return range ? { a1: range.getA1Notation(), rows: range.getNumRows(), cols: range.getNumColumns() } : null; } /** Starts a bulk run over the selected single column; results go to the first empty column(s) on the right. */ function startBulk(opts) { const range = SpreadsheetApp.getActiveRange(); if (!range || range.getNumColumns() !== 1) throw new Error('Select a single column of cells to judge.'); const sheet = range.getSheet(); const values = range.getValues().map(row => (row[0] === '' ? null : String(row[0]))); const body = { kind: opts.kind, question: opts.question, values: values }; if (opts.kind === 'choice') body.labels = toList_(opts.list, 'labels'); if (opts.kind === 'score') body.levels = toList_(opts.list, 'levels'); const job = api_('post', '/v1/jobs', docKey_(), body); return { id: job.id, sheet: sheet.getName(), row: range.getRow(), col: sheet.getLastColumn() + 1, kind: opts.kind, written: 0, total: values.length }; } /** Fetches finished rows since the last poll and writes them. The sidebar calls this until status is done. */ function pollBulk(state) { const job = api_('get', '/v1/jobs/' + state.id + '?from=' + state.written, docKey_()); const rows = job.results.map(r => { if (!r) return state.kind === 'choice' ? ['', ''] : ['']; if (state.kind === 'probability') return [r.p]; if (state.kind === 'choice') return [r.label, r.confidence]; return [r.score]; }); if (rows.length) { const sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName(state.sheet); sheet.getRange(state.row + state.written, state.col, rows.length, rows[0].length).setValues(rows); state.written += rows.length; } state.status = job.status; state.error = job.error; return state; } /** Adds a conditional format highlighting probabilities between 0.35 and 0.65 in the selection. */ function highlightUncertain() { const range = SpreadsheetApp.getActiveRange(); if (!range) throw new Error('Select the probability column first.'); const sheet = range.getSheet(); const rule = SpreadsheetApp.newConditionalFormatRule() .whenNumberBetween(0.35, 0.65) .setBackground('#fff2cc') .setRanges([range]) .build(); sheet.setConditionalFormatRules(sheet.getConditionalFormatRules().concat([rule])); return 'Rows between 0.35 and 0.65 are highlighted — those are the ones worth a human look.'; } /** Replaces formulas in the selection with their current values, so they never recalculate. */ function freezeSelection() { const range = SpreadsheetApp.getActiveRange(); if (!range) throw new Error('Select the cells to freeze.'); range.setValues(range.getValues()); return 'Frozen ' + range.getA1Notation() + ' to plain values.'; } function checkout(plan) { return api_('post', '/v1/billing/checkout', docKey_(), { plan: plan }).url; } function manageBilling() { return api_('post', '/v1/billing/portal', docKey_()).url; }