1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

はじめに

プリザンターの長文項目(DescriptionADescriptionZ)は、議事録・報告書・お客様への回答文といった「そこそこ長い日本語」を入れる場所です。数値項目や日付項目には入力チェックをかけられますが、長文項目の中身が日本語として正しいかどうかは誰もチェックしていません

「日程わ」のような助詞の誤字も、「有難う御座います」のような過剰な漢字表記も、そのまま保存されて、そのままお客様に届きます。

この記事では、さくらのAI Engine を使って、長文項目の文章を AI に校正させる仕組みを拡張機能だけで作ります。本体コードの改変は不要です。

作るのは次の 2 つです。

  • 編集画面に「文法チェック」ボタンを追加し、押したときだけ校正する(メイン)
  • 保存時に自動でチェックし、明らかな誤りがあれば保存自体をブロックする(応用)

バージョン 1.5.7.0 を対象にしています

長文項目は Community Edition では DescriptionADescriptionZ の 26 個です。
Enterprise Edition の項目拡張を使うと Description001Description999 が追加で使えるようになります。
この記事は 26 個の範囲で完結します。

さくらのAI Engine とは

さくらインターネットが提供する、OpenAI 互換・Anthropic 互換の生成 AI 推論 API です。国内リージョンで動くので、業務文書を投げる用途とは相性が良いところです。

項目 内容
チャット生成(OpenAI 互換) https://api.ai.sakura.ad.jp/v1/chat/completions
チャット生成(Anthropic 互換) https://api.ai.sakura.ad.jp/v1/messages
ベクトル埋め込み https://api.ai.sakura.ad.jp/v1/embeddings
音声文字起こし https://api.ai.sakura.ad.jp/v1/audio/transcriptions
認証 Authorization: Bearer <UUID>:<シークレット>
今回使うモデル gpt-oss-120b

トークンは コントロールパネル の「アカウントトークン」から発行します。発行時に一度しか表示されないので、その場で控えておいてください。

OpenAI 互換なので、リクエストの形は見慣れたものがそのまま使えます。

curl 'https://api.ai.sakura.ad.jp/v1/chat/completions' \
  -H 'Authorization: Bearer {{さくらのAI Engineのトークン}}' \
  -H 'Content-Type: application/json' \
  -d '{
    "model": "gpt-oss-120b",
    "messages": [{"role": "user", "content": "こんにちは"}],
    "temperature": 0.2,
    "max_tokens": 200
  }'

全体像

プリザンター側は拡張サーバスクリプト(サーバサイドで動く JavaScript)から httpClient で API を叩きます。クライアントサイドの拡張スクリプトからは叩きません。API トークンがブラウザに露出してしまうからです。

下準備1: テーブルと項目を用意する

「記録テーブル」で 議事録 テーブルを作り、テーブルの管理 → エディタで説明項目を 2 つ有効化します。

物理名 表示名 用途
DescriptionA 本文 校正したい文章を入れる
DescriptionB 校正結果 AI の指摘を書き戻す(読み取り専用)

「選択肢一覧」の edit_note アイコンで絞り込むと説明項目だけが並ぶので、説明A説明B を選んで < で有効化します。

エディタの設定

校正結果 は AI が書き込む欄なので、項目の詳細設定で 読み取り専用 にしておきます。

下準備2: プロセスで「文法チェック」ボタンを追加する

テーブルの管理 → プロセスで新規作成します。設定値は次のとおりです。

設定項目 理由
名称 GrammarCheck 内部名
表示名 文法チェック ボタンのラベル
画面の種類 編集 新規作成画面には出さない
現在の状況 * どの状況でも押せるようにする
変更後の状況 * 状況は変えない
アイコン spellcheck Material Symbols の名前を指定
成功メッセージ 文法チェックが完了しました。校正結果を確認してください。
実行の種類 追加したボタン ボタンを押したときだけ動かす
アクションの種類 保存 校正結果を書き戻すので保存が必要

プロセスの詳細設定

保存すると、編集画面のコマンドバーに「文法チェック」ボタンが増えます。

下準備3: サーバスクリプトのタイムアウトを延ばす

ここが最初のつまずきポイントです。 サーバスクリプトの既定タイムアウトは 10 秒です。

App_Data/Parameters/Script.json(既定値)
{
    "ServerScriptTimeOut": 10000,
    "ServerScriptTimeOutChangeable": false,
    "ServerScriptHttpClientTimeOut": 100000
}

httpClient 側は 100 秒あるのに、それを包んでいるサーバスクリプトが 10 秒で打ち切られます。LLM の応答は数秒〜十数秒かかるので、素直に書くとたまに落ちるという気持ちの悪い挙動になります。

Script.json を次のように変更して、プリザンターを再起動してください。

App_Data/Parameters/Script.json
{
    "ServerScript": true,
    "BackgroundServerScript": false,
    "DisableServerScriptHttpClient": false,
    "ServerScriptTimeOut": 60000,
    "ServerScriptTimeOutChangeable": true,
    "ServerScriptTimeOutMin": 0,
    "ServerScriptTimeOutMax": 86400000,
    "ServerScriptHttpClientTimeOut": 100000,
    "ServerScriptHttpClientTimeOutMin": 0,
    "ServerScriptHttpClientTimeOutMax": 86400000,
    "ServerScriptIncludeDepthLimit": 10,
    "DisableServerScriptFile": true,
    "ServerScriptFileSizeMax": 1,
    "ServerScriptFilePath": null
}

ServerScriptTimeOutChangeabletrue にしておくと、サーバスクリプト単位でもタイムアウトを指定できるようになります。

DisableServerScriptHttpClienttrue の環境では httpClient そのものが使えません。既定は false ですが、セキュリティ要件で閉じている場合は先に確認してください。

実装: 拡張サーバスクリプト

なぜサイトのサーバスクリプトではなく拡張サーバスクリプトなのか

サーバスクリプトは「テーブルの管理 → サーバスクリプト」からも書けますが、今回は App_Data/Parameters/ExtendedServerScripts/ に置く拡張サーバスクリプトを使います。理由は API トークンの置き場所です。

置き場所 トークンを置いたときの問題
テーブルの管理のサーバスクリプト テーブル管理権限を持つ利用者が画面から読める。サイトパッケージにも含まれる
拡張サーバスクリプト(ファイル) サーバ上のファイルなので、画面からもサイトパッケージからも見えない

適用条件のファイル

拡張サーバスクリプトは、条件を書いた .json と本体の .json.js のペアで置きます。

App_Data/Parameters/ExtendedServerScripts/GrammarCheck.json
{
  "Name": "GrammarCheck",
  "Description": "さくらのAI Engine で長文項目を文法チェックする",
  "SiteIdList": [1],
  "BeforeUpdate": true
}

SiteIdList には対象テーブルのサイト ID を指定します。ここを省くと全テーブルで動いてしまうので必ず指定してください。

スクリプト本体

App_Data/Parameters/ExtendedServerScripts/GrammarCheck.json.js
(function () {
  'use strict';

  // ---- 設定 -------------------------------------------------------------
  var ENDPOINT = 'https://api.ai.sakura.ad.jp/v1/chat/completions';
  var MODEL = 'gpt-oss-120b';
  var TOKEN = '{{さくらのAI Engineのトークン}}';
  var TRIGGER_CONTROL_ID = 'Process_1'; // 「文法チェック」ボタン
  var MAX_CHARS = 4000;

  // 「文法チェック」ボタン以外の更新では何もしない
  if (context.ControlId !== TRIGGER_CONTROL_ID) {
    return;
  }

  var text = String(model.DescriptionA == null ? '' : model.DescriptionA).trim();
  if (text === '') {
    context.Error('本文が空です。校正したい文章を入力してから実行してください。');
    return;
  }
  if (text.length > MAX_CHARS) {
    context.Error('本文が長すぎます(' + text.length + ' 文字)。' + MAX_CHARS + ' 文字以内にしてください。');
    return;
  }

  // ---- プロンプト ---------------------------------------------------------
  var systemPrompt = [
    'あなたは日本語ビジネス文書の校正者です。',
    '入力された本文から、誤字脱字・助詞の誤り・敬語の誤用・表記ゆれ・不自然な言い回しを指摘してください。',
    '出力は次の JSON のみとし、前後に説明文やコードフェンスを付けないでください。',
    '{"summary":"全体の講評を1〜2文で","issues":[{"severity":"high|medium|low","original":"該当箇所の原文","suggestion":"修正案","reason":"指摘理由"}]}',
    '指摘が無い場合は issues を空配列にしてください。'
  ].join('\n');

  // ---- さくらのAI Engine を呼び出す ----------------------------------------
  httpClient.RequestUri = ENDPOINT;
  httpClient.RequestHeaders.Clear();
  httpClient.RequestHeaders.Add('Authorization', 'Bearer ' + TOKEN);
  httpClient.MediaType = 'application/json';
  httpClient.TimeOut = 45000;
  httpClient.Content = JSON.stringify({
    model: MODEL,
    messages: [
      { role: 'system', content: systemPrompt },
      { role: 'user', content: text }
    ],
    temperature: 0.2,
    max_tokens: 2000
  });

  var raw = httpClient.Post();

  if (httpClient.IsTimeOut) {
    context.Error('さくらのAI Engine への接続がタイムアウトしました。時間をおいて再実行してください。');
    return;
  }
  if (!httpClient.IsSuccess) {
    context.Error('さくらのAI Engine がエラーを返しました。HTTP ' + httpClient.StatusCode);
    return;
  }

  // ---- レスポンスを解釈する ------------------------------------------------
  var content;
  try {
    content = JSON.parse(raw).choices[0].message.content;
  } catch (e) {
    context.Error('さくらのAI Engine のレスポンスを解釈できませんでした。');
    return;
  }

  var review = parseLooseJson(content);
  if (review === null) {
    // JSON として読めなかったときは生のテキストをそのまま残す
    model.DescriptionB = header(0) + '\n' + content;
    return;
  }

  model.DescriptionB = render(review);

  // ---- ここから下はヘルパー ------------------------------------------------

  // モデルが ```json ... ``` で包んでくることがあるので剥がしてから読む
  function parseLooseJson(s) {
    var t = String(s == null ? '' : s).trim();
    var fence = t.match(/^```(?:json)?\s*([\s\S]*?)\s*```$/);
    if (fence) {
      t = fence[1];
    }
    var start = t.indexOf('{');
    var end = t.lastIndexOf('}');
    if (start < 0 || end <= start) {
      return null;
    }
    try {
      return JSON.parse(t.substring(start, end + 1));
    } catch (e) {
      return null;
    }
  }

  // 読み取り専用の長文項目は Markdown が描画されないため、プレーンテキストで組み立てる
  function header(count) {
    var d = new Date();
    var stamp =
      d.getFullYear() +
      '/' + pad(d.getMonth() + 1) +
      '/' + pad(d.getDate()) +
      ' ' + pad(d.getHours()) +
      ':' + pad(d.getMinutes());
    return '■ 校正結果(' + stamp + ' / ' + MODEL + ' / 指摘 ' + count + ' 件)';
  }

  function pad(n) {
    return (n < 10 ? '0' : '') + n;
  }

  function render(review) {
    var issues = review && review.issues ? review.issues : [];
    var lines = [header(issues.length)];
    if (review && review.summary) {
      lines.push('');
      lines.push(oneLine(review.summary));
    }
    if (issues.length === 0) {
      lines.push('');
      lines.push('指摘はありませんでした。');
      return lines.join('\n');
    }
    for (var i = 0; i < issues.length; i++) {
      var it = issues[i] || {};
      lines.push('');
      lines.push('[' + severityLabel(it.severity) + '] ' + oneLine(it.original));
      lines.push('' + oneLine(it.suggestion));
      lines.push('  理由: ' + oneLine(it.reason));
    }
    return lines.join('\n');
  }

  function severityLabel(s) {
    if (s === 'high') return '';
    if (s === 'medium') return '';
    if (s === 'low') return '';
    return '-';
  }

  function oneLine(v) {
    return String(v == null ? '' : v).replace(/\r?\n/g, ' ');
  }
})();

拡張サーバスクリプトは起動時に読み込まれるので、ファイルを置いたらプリザンターを再起動してください。

ポイント1: context.ControlId で発火元を判定する

BeforeUpdate は「更新」ボタンでもプロセスボタンでも発火します。両者を見分けるのが context.ControlId です。

操作 context.ControlId
「更新」ボタン UpdateCommand
プロセスで追加したボタン Process_1(末尾はプロセスの Id)

Process_1 の数字はプロセスの Id です。プロセスを複数作ると Process_2Process_3 と増えていくので、対象のプロセスを追加した順番と一致しているか確認してください。

context.ControlId は編集画面のボタンから起動したときの値です。API 経由の更新では別の値になるので、API も使う環境では context.Controllercontext.Action と組み合わせて判定してください。

ポイント2: JSON で返させて、それでもゆるくパースする

gpt-oss-120b は推論モデルなので、レスポンスの message.content に答え、message.reasoning に思考過程が入ります。パースするのは content だけです。

システムプロンプトで「JSON のみ」と指示していても、モデルが ```json で包んでくることがあります。そのままだと JSON.parse が落ちるので、コードフェンスを剥がして最初の { から最後の } までを切り出す parseLooseJson() を挟んでいます。この保険がないと、10 回に 1 回くらい失敗します。

ポイント3: 読み取り専用の長文項目は Markdown が描画されない

最初は校正結果を Markdown のテーブルで組み立てていたのですが、読み取り専用にした長文項目は Markdown ビューアが働かず、| の並んだ生テキストがそのまま表示されます。項目の詳細設定で「ビューアの切替」を 自動 にしても変わりませんでした。

そのため、この記事のコードでは Markdown をやめてプレーンテキストで整形しています。

動かしてみる

わざと崩した文章を 本文 に入れます。

本日はお忙しい中、弊社の打ち合わせにご参加頂き有難う御座います。
前回の議事録の内容につきまして、下記の通り修正致しましたのでご確認をお願い致します。
なお、次回の打ち合わせの日程わ、来週の火曜日に開催させて頂きたく存じます。

チェック前

「文法チェック」ボタンを押すと、3 秒ほどで 校正結果 が埋まります。

チェック後

実際に返ってきた内容です。

■ 校正結果(2026/08/17 16:55 / gpt-oss-120b / 指摘 3 件)

敬語表記や助詞の誤りが複数あり、全体的に敬語の統一と表記の統一が必要です。

[高] 本日はお忙しい中、弊社の打ち合わせにご参加頂き有難う御座います。
  → 本日はお忙しい中、弊社の打ち合わせにご参加いただきありがとうございます。
  理由: 「ご参加頂き」はひらがなの「いただき」にすべき、また「有難う御座います」は「ありがとうございます」または「有難うございます」と表記するのが適切です。

[中] 前回の議事録の内容につきまして、下記の通り修正致しましたのでご確認をお願い致します。
  → 前回の議事録の内容につきまして、下記の通り修正いたしましたのでご確認をお願いいたします。
  理由: 敬語の「いたす」はひらがなで表記し、「お願い致します」も「お願いいたします」と統一すべきです。

[中] なお、次回の打ち合わせの日程わ、来週の火曜日に開催させて頂きたく存じます。
  → なお、次回の打ち合わせの日程は、来週の火曜日に開催させていただきたく存じます。
  理由: 助詞の誤り「わ」は「は」に直し、敬語の「いただく」もひらがなで統一する必要があります。

「日程わ」の誤字はもちろん、「有難う御座います」のような過剰な漢字表記まで拾えています。重要度が付いているので、直すかどうかの判断もしやすいところです。

temperature を下げていても、LLM の出力は実行ごとにぶれます。指摘を文単位でまとめてくることもあれば、語句単位で細かく挙げてくることもあります。件数や粒度を固定したい場合は、システムプロンプトで「1 つの指摘は語句単位にする」のように明示してください。

応用: 保存時に自動でチェックしてブロックする

ボタンを押してもらう運用だと、押さない人が出ます。明らかな誤りだけは保存させないようにしてみます。

こちらは context.Error() を使って更新自体を中断します。

App_Data/Parameters/ExtendedServerScripts/GrammarGate.json
{
  "Name": "GrammarGate",
  "Description": "保存時に自動で文法チェックし、明らかな誤りがあれば保存をブロックする",
  "SiteIdList": [1],
  "BeforeUpdate": true
}
App_Data/Parameters/ExtendedServerScripts/GrammarGate.json.js
(function () {
  'use strict';

  var ENDPOINT = 'https://api.ai.sakura.ad.jp/v1/chat/completions';
  var MODEL = 'gpt-oss-120b';
  var TOKEN = '{{さくらのAI Engineのトークン}}';
  var MAX_CHARS = 4000;

  // 「文法チェック」ボタン経由のときは GrammarCheck 側が処理するので二重に呼ばない
  if (context.ControlId === 'Process_1') {
    return;
  }

  var text = String(model.DescriptionA == null ? '' : model.DescriptionA).trim();
  if (text === '' || text.length > MAX_CHARS) {
    return;
  }

  var systemPrompt = [
    'あなたは日本語ビジネス文書の校正者です。',
    '入力された本文から、誤字脱字・助詞の誤り・敬語の誤用を指摘してください。',
    '出力は次の JSON のみとし、前後に説明文やコードフェンスを付けないでください。',
    '{"issues":[{"severity":"high|medium|low","original":"該当箇所","suggestion":"修正案"}]}',
    '明らかな誤りだけを severity: high として挙げ、好みの問題は挙げないでください。'
  ].join('\n');

  httpClient.RequestUri = ENDPOINT;
  httpClient.RequestHeaders.Clear();
  httpClient.RequestHeaders.Add('Authorization', 'Bearer ' + TOKEN);
  httpClient.MediaType = 'application/json';
  httpClient.TimeOut = 45000;
  httpClient.Content = JSON.stringify({
    model: MODEL,
    messages: [
      { role: 'system', content: systemPrompt },
      { role: 'user', content: text }
    ],
    temperature: 0.2,
    max_tokens: 1500
  });

  var raw = httpClient.Post();

  // AI 側の障害で業務を止めないよう、失敗時は警告だけ出して保存は通す
  if (httpClient.IsTimeOut || !httpClient.IsSuccess) {
    context.AddMessage('文法チェックを実行できませんでした。校正なしで保存します。', 'alert-warning');
    return;
  }

  var review;
  try {
    review = JSON.parse(JSON.parse(raw).choices[0].message.content);
  } catch (e) {
    context.AddMessage('文法チェックの結果を解釈できませんでした。校正なしで保存します。', 'alert-warning');
    return;
  }

  var fatal = (review.issues || []).filter(function (i) {
    return i && i.severity === 'high';
  });
  if (fatal.length === 0) {
    return;
  }

  var lines = ['本文に ' + fatal.length + ' 件の誤りがあります。修正してから保存してください。'];
  for (var i = 0; i < fatal.length; i++) {
    lines.push('' + fatal[i].original + '' + fatal[i].suggestion);
  }
  context.Error(lines.join('\n'));
})();

先ほどの崩れた文章のまま「更新」を押すと、保存が止まります。

保存がブロックされた状態

本文に 6 件の誤りがあります。修正してから保存してください。
・ご参加頂き → ご参加いただき
・有難う御座います → ありがとうございます
・修正致しました → 修正いたしました
・お願い致します → お願いいたします
・日程わ → 日程は
・開催させて頂きたく → 開催させていただきたく

この方式を採るときは、次の 2 点に気をつけてください。

  • AI が落ちたら保存できない、という作りにしない。 上のコードでは、タイムアウトや HTTP エラーのときは警告だけ出して保存を通しています。外部 API の障害で業務が止まるのは本末転倒です
  • 保存のたびにリクエストを消費する。 更新が多いテーブルだと、あっという間に無料枠を使い切ります。ボタン起動と併用するか、saved.DescriptionA と比較して本文が変わったときだけ呼ぶようにするのがおすすめです

つまずいたところまとめ

現象 原因と対処
たまにスクリプトが途中で終わる ServerScriptTimeOut の既定が 10 秒。Script.json で延ばす
「更新」でも AI が呼ばれてしまう context.ControlId で発火元を判定する
JSON.parse が落ちることがある モデルがコードフェンスで包む。剥がしてからパースする
校正結果が生の Markdown で表示される 読み取り専用の長文項目はビューアが働かない。プレーンテキストで整形する
トークンをどこに置くか テーブルの管理ではなく拡張サーバスクリプトのファイルに置く

まとめ

プリザンターの長文項目に AI 文法チェッカーを組み込みました。ポイントは次のとおりです。

  • さくらのAI Engine は OpenAI 互換なので、httpClient から素直に叩ける
  • 呼び出しは拡張サーバスクリプトから行う。API トークンをブラウザに出さないため
  • プロセス機能でボタンを追加し、context.ControlId で発火元を判定すれば「押したときだけ AI を呼ぶ」が実現できる
  • サーバスクリプトの既定タイムアウト 10 秒は LLM には短い。Script.json で延ばす
  • LLM の出力は必ずぶれる。JSON で返させたうえで、ゆるくパースする保険を入れておく
  • 保存ブロック方式にするなら、AI 障害時は素通しさせる

今回は文法チェックでしたが、同じ骨組みで「議事録の要約を生成する」「問い合わせ内容から分類項目を自動で埋める」といった応用もできます。長文項目は、これまで検索の対象でしかありませんでしたが、AI を挟むと入力支援の対象に変わります。

参考

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?