ABEJA Tech Blog

中の人の興味のある情報を発信していきます

Google ドキュメント アドオン + LLM でAI校正・レビュー機能を作ってみた

はじめに

この記事は、ABEJAアドベントカレンダー2024 3日目の記事です。

はじめまして。ABEJAのシステム開発部でエンジニアをしている大倉(sheepover96)と申します。

LLMが登場して数年が経ち、多種多様なユースケースを見かけるようになりました。 個人的にはLLMで全部生成!なものにすごいと思わされつつ、いつも利用しているツールに自然にAIが溶け込んでいるものが特に便利だなーと感じています(例えばAIエディタの Cursor など)。

ということで今回は、普段利用しているドキュメントツールにLLMを組み込んでみました。 ABEJA社内ではドキュメントツールとして基本的に Notion が利用されているのですが、 そちらにはすでに Notion AI があるので、今回は Google ドキュメント を対象としました。

目指す機能

文章を書く際に、調べ物をしたり人の力を借りたいと思うケースとして主に以下のようなものが考えられます。

  • 文章を思いつかない
  • 文章の校正
  • 文章の内容・構成に対するレビュー

この中のどれかをいい感じのUXで実現することを目指してみます。

とりあえずGoogle ドキュメント の API やチュートリアルを調べてみると以下のように選択範囲の翻訳を生成、置換してくれるものがありました。

Google ドキュメント エディタ アドオンのクイックスタート  |  Google Workspace Add-ons  |  Google for Developers

このチュートリアルを改造して、選択範囲の文章の校正を行ってくれるものを作ってみようと思います。

実装

UIについては、サンプルのものを軽く変更して以下の形にしてみました。

次にロジックの修正を行なっていきます。

まず、LLMへのリクエストに利用するシークレットの登録を行います。 GASの場合は以下のようにプロパティサービスを利用して登録を行えるようです(アドオン配布を行う場合などはまた別の方法が必要そうですが)。

プロパティ サービス  |  Apps Script  |  Google for Developers

今回はAzure Open AI用に以下の4つを登録しました。

次に、実際のコードの修正を行います。元のコードで翻訳を行なっている箇所を、LLMを呼び出す形に変更します。 雑なコードですが、以下のような形に変更を行いました。

// llm.gs
function getTextAndLLMOutput(pattern) {
  const text = getSelectedText().join('\n');
  const llm_output = callLLM(text, pattern);
  return {
    text: text,
    llm_output: llm_output
  };
}


const PROMPTS = {
  review: `
    以下の文章のレビューを行なってください。
    {text}
  `,
  kosei: `
    以下の文章の校正を行い、その結果の文章のみを出力してください。
    {text}
  `
};

function callLLM(text, pattern) {
  const properties = PropertiesService.getScriptProperties();
  const prompt = PROMPTS[pattern].replace('{text}', text);
  const url = properties.getProperty("OPENAI_API_URL");
  const key = properties.getProperty("OPENAI_API_KEY");
  const model = properties.getProperty("OPENAI_API_MODEL");
  const deploymentID = properties.getProperty("OPENAI_DEPLOYMENT_ID");

  const payload = {
    messages: [{"role": "system", "content": "あなたは文章のプロです"}, {"role": "user", "content": prompt}],
  };
  const options = {
    method: 'post',
    contentType: 'application/json',
    headers: {
      'api-key': key
    },
    payload: JSON.stringify(payload)
  };
  const requestUrl = `${url}/openai/deployments/${deploymentID}/chat/completions?api-version=${model}`;
  const response = UrlFetchApp.fetch(requestUrl, options);
  if (response.getResponseCode() !== 200) {
    throw new Error(`Failed to fetch from LLM. Status: ${response.getResponseCode()}`);
  }
  const jsonResponse = JSON.parse(response.getContentText());
  return jsonResponse['choices'][0]['message']['content'] || 'No response from LLM.';
}
// sidebar.html
  function runLLM() {
    this.disabled = true;
    $('#error').remove();
    const pattern = $('input[name=pattern]:checked').val();
    google.script.run
            .withSuccessHandler(
                    function(textAndLLMOutput, element) {
                      $('#llm-output-text').val(textAndLLMOutput.llm_output);
                      element.disabled = false;
                    })
            .withFailureHandler(
                    function(msg, element) {
                      showError(msg, $('#button-bar'));
                      element.disabled = false;
                    })
            .withUserObject(this)
            .getTextAndLLMOutput(pattern);
  }

結果

こちらを動かしてみると、無事選択した文章に対してLLMによるレビューや校正ができるようになりました🎉

文章の校正

文章のレビュー

Google ドキュメント上でLLMにリクエストしているだけですが、単一のツールで完結している点と、用途を指定するだけで良いのでプロンプトを書き慣れていない人でも直感的に操作できる点がメリットだと感じています。

今回は出力結果をサイドバーに出して、選択箇所と入れ替えられる機能のみを実装しました。しかし、本文にレビューコメントをトグルで差し込んだりする機能などもあったら便利かもしれません。 (文章にコメントをつけられたらより良さそうと思いましたが、現状提供されているAPIだとできないようでした...) #125 Google ドキュメントにコメントを追加する → 不完全😖|ともかつのノート

まとめ

今回は、Google Document のアドオンでLLMを動かし、文章レビュー・校正機能を作ってみました。 単純な機能ですが、やはりUXが統合されているだけで便利さが一段階上がるなと感じさせられました。 今回は Google Document で行いましたが、Google Slideなど他サービスや、そもそも Chrome 自体のプラグインを作るのも便利そうだなと思いました。 ぜひ普段使っているツールにLLMを組み込んでみてはいかがでしょうか。

We Are Hiring!

ABEJAは、様々な業界におけるテクノロジーの社会実装に取り組んでいます。 技術はもちろん、技術をどのようにして社会やビジネスに組み込んでいくかを考えるのが好きな方は、下記採用ページからエントリーください! 新卒の方やインターンシップのエントリーもお待ちしております!

careers.abejainc.com

特に下記ポジションの募集を強化しています。ぜひ御覧ください!

プラットフォームグループ:シニアソフトウェアエンジニア | 株式会社ABEJA

トランスフォーメーション領域:ソフトウェアエンジニア(リードクラス) | 株式会社ABEJA

トランスフォーメーション領域:データサイエンティスト(シニアクラス) | 株式会社ABEJA

Appendix

今回利用したコード全体を貼らせていただきます。

参考:Google ドキュメント エディタ アドオンのクイックスタート  |  Google Workspace Add-ons  |  Google for Developers

llm.gs

function onOpen(e) {
  DocumentApp.getUi().createAddonMenu()
      .addItem('Start', 'showSidebar')
      .addToUi();
}

function onInstall(e) {
  onOpen(e);
}


function showSidebar() {
  const ui = HtmlService.createHtmlOutputFromFile('sidebar')
      .setTitle('LLMアシスト');
  DocumentApp.getUi().showSidebar(ui);
}

function getSelectedText() {
  const selection = DocumentApp.getActiveDocument().getSelection();
  const text = [];
  if (selection) {
    const elements = selection.getSelectedElements();
    for (let i = 0; i < elements.length; ++i) {
      if (elements[i].isPartial()) {
        const element = elements[i].getElement().asText();
        const startIndex = elements[i].getStartOffset();
        const endIndex = elements[i].getEndOffsetInclusive();

        text.push(element.getText().substring(startIndex, endIndex + 1));
      } else {
        const element = elements[i].getElement();
        if (element.editAsText) {
          const elementText = element.asText().getText();
          if (elementText) {
            text.push(elementText);
          }
        }
      }
    }
  }
  if (!text.length) throw new Error('Please select some text.');
  return text;
}


function getTextAndLLMOutput(pattern) {
  const text = getSelectedText().join('\n');
  const llm_output = callLLM(text, pattern);
  return {
    text: text,
    llm_output: llm_output
  };
}

function insertText(newText) {
  const selection = DocumentApp.getActiveDocument().getSelection();
  if (selection) {
    let replaced = false;
    const elements = selection.getSelectedElements();
    if (elements.length === 1 && elements[0].getElement().getType() ===
      DocumentApp.ElementType.INLINE_IMAGE) {
      throw new Error('Can\'t insert text into an image.');
    }
    for (let i = 0; i < elements.length; ++i) {
      if (elements[i].isPartial()) {
        const element = elements[i].getElement().asText();
        const startIndex = elements[i].getStartOffset();
        const endIndex = elements[i].getEndOffsetInclusive();
        element.deleteText(startIndex, endIndex);
        if (!replaced) {
          element.insertText(startIndex, newText);
          replaced = true;
        } else {
          // This block handles a selection that ends with a partial element. We
          // want to copy this partial text to the previous element so we don't
          // have a line-break before the last partial.
          const parent = element.getParent();
          const remainingText = element.getText().substring(endIndex + 1);
          parent.getPreviousSibling().asText().appendText(remainingText);
          // We cannot remove the last paragraph of a doc. If this is the case,
          // just remove the text within the last paragraph instead.
          if (parent.getNextSibling()) {
            parent.removeFromParent();
          } else {
            element.removeFromParent();
          }
        }
      } else {
        const element = elements[i].getElement();
        if (!replaced && element.editAsText) {
          // Only translate elements that can be edited as text, removing other
          // elements.
          element.clear();
          element.asText().setText(newText);
          replaced = true;
        } else {
          // We cannot remove the last paragraph of a doc. If this is the case,
          // just clear the element.
          if (element.getNextSibling()) {
            element.removeFromParent();
          } else {
            element.clear();
          }
        }
      }
    }
  } else {
    const cursor = DocumentApp.getActiveDocument().getCursor();
    const surroundingText = cursor.getSurroundingText().getText();
    const surroundingTextOffset = cursor.getSurroundingTextOffset();

    // If the cursor follows or preceds a non-space character, insert a space
    // between the character and the translation. Otherwise, just insert the
    // translation.
    if (surroundingTextOffset > 0) {
      if (surroundingText.charAt(surroundingTextOffset - 1) !== ' ') {
        newText = ' ' + newText;
      }
    }
    if (surroundingTextOffset < surroundingText.length) {
      if (surroundingText.charAt(surroundingTextOffset) !== ' ') {
        newText += ' ';
      }
    }
    cursor.insertText(newText);
  }
}

const PROMPTS = {
  review: `
    以下の文章のレビューを行なってください。
    {text}
  `,
  kosei: `
    以下の文章の校正を行い、その結果の文章のみを出力してください。
    {text}
  `
};

function callLLM(text, pattern) {
  const properties = PropertiesService.getScriptProperties();
  const prompt = PROMPTS[pattern].replace('{text}', text);
  const url = properties.getProperty("OPENAI_API_URL");
  const key = properties.getProperty("OPENAI_API_KEY");
  const model = properties.getProperty("OPENAI_API_MODEL");
  const deploymentID = properties.getProperty("OPENAI_DEPLOYMENT_ID");

  const payload = {
    messages: [{"role": "system", "content": "あなたは文章のプロです"}, {"role": "user", "content": prompt}],
  };
  const options = {
    method: 'post',
    contentType: 'application/json',
    headers: {
      'api-key': key
    },
    payload: JSON.stringify(payload)
  };
  const requestUrl = `${url}/openai/deployments/${deploymentID}/chat/completions?api-version=${model}`;
  const response = UrlFetchApp.fetch(requestUrl, options);
  if (response.getResponseCode() !== 200) {
    throw new Error(`Failed to fetch from LLM. Status: ${response.getResponseCode()}`);
  }
  const jsonResponse = JSON.parse(response.getContentText());
  return jsonResponse['choices'][0]['message']['content'] || 'No response from LLM.';
}

sidebar.html

<!DOCTYPE html>
<html>
<head>
  <base target="_top">
  <link rel="stylesheet" href="https://ssl.gstatic.com/docs/script/css/add-ons1.css">
  <!-- The CSS package above applies Google styling to buttons and other elements. -->

  <style>
    .branding-below {
      bottom: 56px;
      top: 0;
    }
    .branding-text {
      left: 7px;
      position: relative;
      top: 3px;
    }
    .col-contain {
      overflow: hidden;
    }
    .col-one {
      float: left;
      width: 50%;
    }
    .logo {
      vertical-align: middle;
    }
    .radio-spacer {
      height: 20px;
    }
    .width-100 {
      width: 100%;
    }
  </style>
  <title></title>
</head>
<body>
<div class="sidebar branding-below">
  <form>
    <div class="block col-contain">
      <div class="col-one">
        <b>Selected text</b>
        <div>
          <input type="radio" name="pattern" id="radio-pattern-kosei" value="kosei">
          <label for="radio-pattern-kosei">校正</label>
        </div>
        <div>
          <input type="radio" name="pattern" id="radio-pattern-review" value="review">
          <label for="radio-pattern-review">レビュー</label>
        </div>
      </div>
    </div>
    <div class="block form-group">
      <label for="llm-output-text"><b>LLMアウトプット</b></label>
      <textarea class="width-100" id="llm-output-text" rows="10"></textarea>
    </div>
    <div class="block" id="button-bar">
      <button class="blue" id="run-llm">実行</button>
      <button id="insert-text">Insert</button>
    </div>
  </form>
</div>

<script src="//ajax.googleapis.com/ajax/libs/jquery/1.9.1/jquery.min.js"></script>
<script>
  /**
   * On document load, assign click handlers to each button and try to load the
   * user's origin and destination language preferences if previously set.
   */
  $(function() {
    $('#run-llm').click(runLLM);
    $('#insert-text').click(insertText);
    google.script.run.withSuccessHandler(loadPreferences)
            .withFailureHandler(showError).getPreferences();
  });


  function runLLM() {
    this.disabled = true;
    $('#error').remove();
    const pattern = $('input[name=pattern]:checked').val();
    google.script.run
            .withSuccessHandler(
                    function(textAndLLMOutput, element) {
                      $('#llm-output-text').val(textAndLLMOutput.llm_output);
                      element.disabled = false;
                    })
            .withFailureHandler(
                    function(msg, element) {
                      showError(msg, $('#button-bar'));
                      element.disabled = false;
                    })
            .withUserObject(this)
            .getTextAndLLMOutput(pattern);
  }

  /**
   * Runs a server-side function to insert the translated text into the document
   * at the user's cursor or selection.
   */
  function insertText() {
    this.disabled = true;
    $('#error').remove();
    google.script.run
            .withSuccessHandler(
                    function(returnSuccess, element) {
                      element.disabled = false;
                    })
            .withFailureHandler(
                    function(msg, element) {
                      showError(msg, $('#button-bar'));
                      element.disabled = false;
                    })
            .withUserObject(this)
            .insertText($('#llm-output-text').val());
  }

  /**
   * Inserts a div that contains an error message after a given element.
   *
   * @param {string} msg The error message to display.
   * @param {DOMElement} element The element after which to display the error.
   */
  function showError(msg, element) {
    const div = $('<div id="error" class="error">' + msg + '</div>');
    $(element).after(div);
  }
</script>
</body>
</html>