Skip to content

サーバースクリプトから生成 AI を使う ​

第4版作成 最終更新 (日本時間)
確認バージョン1.5.7.01.5.8.1

拡張サーバースクリプトの httpClient から OpenAI 互換の生成 AI API を呼び出すと、本体コードを改変せずに「長文項目の文法チェック」「意味の近いレコードの検索」「添付画像の読み取り」などを実装できます。 このページでは、さくらのAI Engine を例に、共通の土台(トークンの置き場所・タイムアウト・レスポンスの扱い)と、用途別の実装例、つまずきやすい点をまとめます。

共通の土台 ​

さくらの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
利用可能モデルの一覧https://api.ai.sakura.ad.jp/v1/models
認証Authorization: Bearer <UUID>:<シークレット>

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

このページの例で使うモデルは次のとおりです。

用途モデル
チャット(文法チェック・分類・要約など)gpt-oss-120b
埋め込み(1024 次元)multilingual-e5-large
画像認識preview/Qwen3-VL-30B-A3B-Instruct

使えるモデルは /v1/models で確認する

preview/ が付くモデルは提供が変わる可能性があります。また、マニュアルに記載があってもアカウントで使えないモデルがあります(音声合成の zundamon は This model is not available. が返りました)。実装前に /v1/models で確認してください。

呼び出しは拡張サーバースクリプトから行う ​

API はクライアントサイドのスクリプトからは呼びません。API トークンがブラウザに露出するためです。また、サーバースクリプトの中でも「テーブルの管理」のサーバースクリプトではなく、App_Data/Parameters/ExtendedServerScripts/ に置く拡張サーバースクリプトを使います。

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

拡張サーバースクリプトは、適用条件を書いた .json と本体の .json.js のペアで置きます。SiteIdList を省くと全テーブルで動いてしまうので、必ず対象サイトの ID を指定してください。拡張サーバースクリプトは起動時に読み込まれるため、ファイルを置いたらプリザンターを再起動します。

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

サーバースクリプトのタイムアウトを延ばす ​

サーバースクリプトの既定タイムアウトは 10 秒です。httpClient 側は 100 秒あっても、それを包むサーバースクリプトが 10 秒で打ち切られます。LLM の応答は数秒〜十数秒かかるため、既定のままではたまに途中で終わる挙動になります。

json
{
    "ServerScriptTimeOut": 10000,
    "ServerScriptTimeOutChangeable": false,
    "ServerScriptHttpClientTimeOut": 100000
}

例えば次のように変更してプリザンターを再起動します。ServerScriptTimeOutChangeable を true にすると、サーバースクリプト単位でもタイムアウトを指定できます。

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
}

WARNING

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

httpClient は使う直前に毎回すべて設定し直す ​

同じタイミング(条件)で動くサーバースクリプトは、1.5.8.1 のソースでは本文を連結して 1 つのエンジンで実行されるため、httpClient のインスタンスを共有します(ServerScriptUtilities.cs)。別のスクリプトが Encoding や RequestHeaders を変更していると、その状態のまま次のリクエストが飛びます。ResponseHeaders は送信のたびに内部でクリアされますが、RequestHeaders・Encoding・MediaType・Content はクリアされません(ServerScriptModelHttpClient.cs)。

実際に、検証用スクリプトが Encoding を iso-8859-1 にしたまま終了したため、埋め込み API に送る日本語が壊れ、類似度が軒並み 0.77 前後に落ちました。値は返ってくるので気づきにくい不具合です。

js
httpClient.RequestUri = EMBED_ENDPOINT;
httpClient.RequestHeaders.Clear();   // ヘッダも残っている
httpClient.MediaType = 'application/json';
httpClient.Encoding = 'utf-8';       // 明示的に戻す
httpClient.RequestHeaders.Add('Authorization', 'Bearer ' + TOKEN);

ボタンを押したときだけ呼ぶ(context.ControlId) ​

「テーブルの管理 → プロセス」でボタンを追加し、BeforeUpdate の中で context.ControlId を見て発火元を判定すると、「押したときだけ AI を呼ぶ」ができます。

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

プロセスを複数作ると Process_2、Process_3 と増えていくので、対象プロセスの Id と一致しているか確認してください。

プロセスの設定例(「文法チェック」ボタン)は次のとおりです。

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

WARNING

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

レスポンスは JSON で返させ、ゆるくパースする ​

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

js
// モデルが ```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;
  }
}

推論モデルの content が空になることがある ​

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

思考で max_tokens を使い切ると、content が null のまま HTTP 200 で返ります。 要約処理の例では max_tokens: 800 で null になり、2000 にしたら通りました。max_tokens は余裕をもって取り、content が空の場合を明示的にハンドリングして null という文字列を書き込まないようにします。

用途別の実装 ​

長文項目の文法チェック(ボタン起動) ​

長文項目 DescriptionA(本文)の文章を AI に校正させ、結果を DescriptionB(校正結果・読み取り専用)に書き戻します。プロセスで追加した「文法チェック」ボタンを押したときだけ動きます。

図を読み込み中…

js
(function () {
  'use strict';

  // ---- 設定 -------------------------------------------------------------
  var ENDPOINT = 'https://api.ai.sakura.ad.jp/v1/chat/completions';
  var MODEL = 'gpt-oss-120b';
  var TOKEN = 'YOUR_SAKURA_AI_TOKEN'; // <UUID>:<シークレット>
  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);

  // ---- ここから下はヘルパー ------------------------------------------------
  // parseLooseJson() は「レスポンスは JSON で返させ、ゆるくパースする」を参照

  // 読み取り専用の長文項目は 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, ' ');
  }
})();

読み取り専用の長文項目では Markdown が描画されない

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

INFO

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

保存時に自動チェックして保存をブロックする ​

ボタン起動だと押さない人が出るため、「明らかな誤りがあれば保存させない」ようにする応用です。context.Error() で更新自体を中断します。

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

// …(システムプロンプトで「明らかな誤りだけを severity: high として挙げる」と指示して httpClient で呼び出し)…

// AI 側の障害で業務を止めないよう、失敗時は警告だけ出して保存は通す
if (httpClient.IsTimeOut || !httpClient.IsSuccess) {
  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'));

この方式では次の 2 点に注意します。

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

ベクトル検索(意味が近いレコードを探す) ​

キーワード検索は「言葉が違うと引っかからない」という限界があります(例:「複合機で印刷すると紙詰まりエラーになる」と「2階のプリンタが紙づまりで止まる」)。埋め込み API で本文をベクトル化し、コサイン類似度で意味の近いレコードを探します。DB 拡張(pgvector 等)は使わないため、SQL Server / PostgreSQL / MySQL のどれでも同じコードが動きます。

項目の構成 ​

物理名表示名用途設定
DescriptionA問い合わせ内容検索対象の本文
DescriptionCベクトル埋め込みの保管先非表示
DescriptionD類似案件検索結果の表示先読み取り専用

1024 次元のベクトルを JSON 文字列にすると 19KB 程度で、長文項目にそのまま収まります。

処理の流れ ​

図を読み込み中…

  1. 保存時(BeforeCreate / BeforeUpdate)に本文を /v1/embeddings へ送り、1024 次元ベクトルを取得して DescriptionC に JSON で保存する
  2. items.Get(context.SiteId) で同じテーブルのレコードを取得し、各レコードの DescriptionC を復元する
  3. コサイン類似度を総当たりで計算し、上位を DescriptionD に書き戻す
js
(function () {
  'use strict';

  var EMBED_ENDPOINT = 'https://api.ai.sakura.ad.jp/v1/embeddings';
  var EMBED_MODEL = 'multilingual-e5-large';
  var TOKEN = 'YOUR_SAKURA_AI_TOKEN';
  var TOP_N = 3;
  // multilingual-e5 は無関係な日本語文でもコサイン類似度が 0.80 前後になる。
  // 絶対値のしきい値では切れないので「1位との差」で足切りする。
  var SCORE_MARGIN = 0.05;

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

  // 1. 自レコードの本文をベクトル化して保存する
  var vec = embed(text);
  if (vec === null) {
    context.AddMessage('ベクトル化に失敗しました。類似案件は更新されません。', 'alert-warning');
    return;
  }
  model.DescriptionC = JSON.stringify(vec);

  // 2. 同じテーブルの既存レコードと総当たりでコサイン類似度を計算する
  var rows = items.Get(context.SiteId);
  var myId = Number(context.Id);
  var scored = [];
  for (var i = 0; i < rows.Length; i++) {
    var r = rows[i];
    if (Number(r.ResultId) === myId) {
      continue;
    }
    var raw = String(r.DescriptionC == null ? '' : r.DescriptionC);
    if (raw === '') {
      continue;
    }
    var other;
    try {
      other = JSON.parse(raw);
    } catch (e) {
      continue;
    }
    if (!other || other.length !== vec.length) {
      continue;
    }
    scored.push({
      id: Number(r.ResultId),
      title: String(r.Title == null ? '' : r.Title),
      type: String(r.ClassA == null ? '' : r.ClassA),
      score: cosine(vec, other)
    });
  }

  scored.sort(function (a, b) {
    return b.score - a.score;
  });

  // 3. 1位との差が SCORE_MARGIN 以内のものだけを、上位 TOP_N 件まで書き戻す
  if (scored.length === 0) {
    model.DescriptionD = '照合できる過去の問い合わせがありません。';
    return;
  }
  var best = scored[0].score;
  var hits = [];
  for (var j = 0; j < scored.length && hits.length < TOP_N; j++) {
    if (best - scored[j].score <= SCORE_MARGIN) {
      hits.push(scored[j]);
    }
  }

  var lines = ['■ 類似する過去の問い合わせ(照合対象 ' + scored.length + ' 件)', ''];
  for (var k = 0; k < hits.length; k++) {
    var h = hits[k];
    lines.push(
      '類似度 ' + h.score.toFixed(3) +
      '  [#' + h.id + '] ' + h.title +
      (h.type === '' ? '' : '(' + h.type + ')')
    );
    lines.push('  ' + context.ApplicationPath + 'items/' + h.id + '/edit');
  }
  model.DescriptionD = lines.join('\n');

  // ---- ヘルパー ------------------------------------------------------------

  function embed(s) {
    httpClient.RequestUri = EMBED_ENDPOINT;
    httpClient.RequestHeaders.Clear();
    httpClient.MediaType = 'application/json';
    httpClient.Encoding = 'utf-8';
    httpClient.RequestHeaders.Add('Authorization', 'Bearer ' + TOKEN);
    httpClient.TimeOut = 45000;
    httpClient.Content = JSON.stringify({ model: EMBED_MODEL, input: [s] });
    var raw = httpClient.Post();
    if (httpClient.IsTimeOut || !httpClient.IsSuccess) {
      return null;
    }
    try {
      return JSON.parse(raw).data[0].embedding;
    } catch (e) {
      return null;
    }
  }

  // 正規化済みでないベクトルも来るので、内積をノルムで割る
  function cosine(a, b) {
    var dot = 0;
    var na = 0;
    var nb = 0;
    for (var i = 0; i < a.length; i++) {
      dot += a[i] * b[i];
      na += a[i] * a[i];
      nb += b[i] * b[i];
    }
    if (na === 0 || nb === 0) {
      return 0;
    }
    return dot / (Math.sqrt(na) * Math.sqrt(nb));
  }
})();

items.Get() の戻り値は .Length(大文字)

items.Get(context.SiteId) が返すのは .NET の配列です。rows.length(小文字)は undefined になり、ループが 1 回も回らないままエラーも出ずに「照合対象 0 件」になります。

しきい値は「1位との差」で決める ​

multilingual-e5 は無関係な日本語文どうしでも 0.80 前後のスコアを返します。実測値は次のとおりで、1 位と最下位の差はわずか 0.10 でした。

文書類似度
紙詰まり(正解)0.9215
ドライバ手順0.8374
CSV出力の要望0.8337
有給休暇の残日数0.8168

「0.80 以上」のような絶対値のしきい値は安定しないため、1 位のスコアとの差(上のコードでは 0.05)で足切りします。

e5 系モデルで推奨される query: / passage: プレフィックスも試されていますが、分離幅(1 位と最下位の差)は 0.1047 → 0.1035 とむしろ僅かに縮まりました。少なくともさくらのAI Engine の multilingual-e5-large では効果は見られませんでした。

この方式の限界 ​

総当たりで計算するため、レコード数に比例して遅くなります。

レコード数1 回の保存でやること
数十件実用範囲
数百件JSON パースが効いてくる。バックグラウンド化を検討
数千件以上この方式は破綻する

件数が増えた場合の対策には次があります。

  • ベクトルを DB 側に持たせる(PostgreSQL の pgvector、SQL Server 2025 / MySQL 9 以降のネイティブ VECTOR 型)。拡張 SQL から近傍検索クエリを呼ぶ
  • items.Get(siteId, view) の第 2 引数にビューを渡し、母数を減らしてから総当たりする
  • 本文が変わったときだけ埋め込みし直す(saved.DescriptionA と比較する)

添付されたスクリーンショットを AI に読ませる ​

「画面のスクショだけ貼って本文を書かない」問い合わせに対し、添付画像を画像認識モデルに読ませて問い合わせ票に転記します。

画像は送れるが、音声(multipart)は送れない ​

サーバースクリプトの httpClient は文字列本文(StringContent)しか送れず、MediaType に boundary 付きの値を入れると送信時に失敗します。そのため multipart/form-data を要求する音声文字起こし(/v1/audio/transcriptions)はサーバースクリプトから呼べません。JSON に base64 で詰める方法も API 側に 400 invalid form で拒否されました。

js
httpClient.MediaType = 'multipart/form-data; boundary=----abc123'; // 代入はできる
httpClient.Post();
// → The format of value 'multipart/form-data; boundary=----abc123' is invalid.

一方、OpenAI 互換のチャット API は画像を image_url の data URL として JSON 本文に埋め込めるため、multipart は不要です。

添付ファイルのバイナリを拡張 SQL で base64 化して取り出す ​

サーバースクリプトには添付ファイルの中身を読む API がありません(_file_cs は ReadAllText しか持たず、既定で無効)。添付ファイルの実体は Binaries テーブルの Bin 列にあるため、拡張 SQL で encode(..., 'base64') して取り出します。

INFO

BinaryStorage.json の Provider が Rds(既定)のときの方法です。Path を指定してファイルシステムに保存している環境では使えません。

json
{
  "Name": "GetLatestImage",
  "Description": "レコードに添付された画像のうち最新の1件を base64 で取り出す",
  "Api": true,
  "SiteIdList": [5],
  "CommandText": "select \"FileName\", \"ContentType\", \"Size\", encode(\"Bin\", 'base64') as \"Base64\" from \"Implem.Pleasanter\".\"Binaries\" where \"ReferenceId\" = @ReferenceId and \"BinaryType\" = 'Attachments' and \"ContentType\" like 'image/%' order by \"BinaryId\" desc limit 1"
}

"Api": true を忘れると NullReferenceException で落ちる

サーバースクリプトの extendedSql.* から呼べる拡張 SQL は Api が true のものだけです。「見つかりません」ではなく次の例外で落ちます。名前の綴り間違いでも同じ例外になるので、まずここを疑ってください。

text
System.NullReferenceException: Object reference not set to an instance of an object.
   at Implem.Pleasanter.Models.ExtensionUtilities.DataSetToExpando(DataSet dataSet)

BinaryType の値は添付のしかたで異なります。両方拾いたい場合は "BinaryType" in ('Attachments','Images') にします。

添付のしかたBinaryType
添付ファイル項目にドロップAttachments
長文項目(Markdown)に画像を貼り付けImages

拡張サーバースクリプト ​

項目は AttachmentsA(スクリーンショット)、DescriptionA(問い合わせ内容。空なら AI が埋める)、DescriptionB(読み取り結果・読み取り専用)を使い、プロセスで「画像を読む」ボタン(アイコン image_search、実行の種類「追加したボタン」、アクションの種類「保存」)を追加します。

js
var ENDPOINT = 'https://api.ai.sakura.ad.jp/v1/chat/completions';
var MODEL = 'preview/Qwen3-VL-30B-A3B-Instruct';
var TOKEN = 'YOUR_SAKURA_AI_TOKEN';
var TRIGGER_CONTROL_ID = 'Process_1'; // 「画像を読む」ボタン
var MAX_BYTES = 4 * 1024 * 1024;

if (context.ControlId !== TRIGGER_CONTROL_ID) {
  return;
}

// 拡張SQLで base64 にして取り出す
var row = extendedSql.ExecuteRow('GetLatestImage', JSON.stringify({ ReferenceId: context.Id }));
if (!row) {
  context.Error('画像が添付されていません。スクリーンショットを添付してから実行してください。');
  return;
}

var b64 = String(row.Base64 == null ? '' : row.Base64).replace(/\s+/g, '');
var contentType = String(row.ContentType == null ? 'image/png' : row.ContentType);
if (Number(row.Size) > MAX_BYTES) {
  context.Error('画像が大きすぎます(' + Math.round(Number(row.Size) / 1024) + ' KB)。4MB 以内にしてください。');
  return;
}

var systemPrompt = [
  'あなたはヘルプデスクの一次受付担当です。',
  '利用者が添付したエラー画面のスクリーンショットを読み取り、問い合わせ票に転記してください。',
  '画面に書かれている文言だけを根拠にし、推測で情報を補わないでください。',
  '出力は次の JSON のみとし、前後に説明文やコードフェンスを付けないでください。',
  '{"app":"アプリ名","summary":"何が起きているかを1文で","errorCode":"エラーコード(無ければ空文字)","messages":["画面上の主要な文言"],"nextAction":"一次対応として案内すべきこと"}'
].join('\n');

httpClient.RequestUri = ENDPOINT;
httpClient.RequestHeaders.Clear();
httpClient.MediaType = 'application/json';
httpClient.Encoding = 'utf-8';
httpClient.RequestHeaders.Add('Authorization', 'Bearer ' + TOKEN);
httpClient.TimeOut = 60000;
httpClient.Content = JSON.stringify({
  model: MODEL,
  messages: [
    { role: 'system', content: systemPrompt },
    {
      role: 'user',
      // content を文字列ではなく配列にし、テキストと画像を並べる
      content: [
        { type: 'text', text: 'このエラー画面を読み取ってください。' },
        { type: 'image_url', image_url: { url: 'data:' + contentType + ';base64,' + b64 } }
      ]
    }
  ],
  temperature: 0,
  max_tokens: 1500
});

var raw = httpClient.Post();
// …(エラー判定、parseLooseJson で解釈し DescriptionB に整形して書き込む)…

// 問い合わせ内容が空なら、読み取った概要で埋めておく
if (String(model.DescriptionA == null ? '' : model.DescriptionA).trim() === '' && r.summary) {
  model.DescriptionA = r.summary;
}

運用上の注意は次のとおりです。

  • プロンプトで推測を禁止する。 「画面に書かれている文言だけを根拠にし、推測で情報を補わないでください」が無いと、画面に無い対処手順まで書いてきます
  • サイズ制限を入れる。 base64 は元データの約 1.33 倍になるため、Size 列で足切りします
  • 個人情報の写り込みに注意する。 外部 API に送る以上、どこに送っているかを利用者に明示する運用が必要です

分類項目を AI に埋めさせる ​

問い合わせ内容(DescriptionA)から種別(ClassA)と緊急度(ClassB)を推定させます(BeforeCreate / BeforeUpdate)。

js
// すでに人が選んでいる場合は上書きしない
var hasType = String(model.ClassA == null ? '' : model.ClassA).trim() !== '';
var hasUrgency = String(model.ClassB == null ? '' : model.ClassB).trim() !== '';
if (hasType && hasUrgency) {
  return;
}

// 種別・緊急度の選択肢。テーブルの管理の「選択肢一覧」と揃えておく
var types = ['ハードウェア障害', 'ソフトウェア不具合', '操作方法の問い合わせ', '仕様確認', '要望・改善提案'];
var urgencies = ['高', '中', '低'];

// …(「次のいずれかから必ず 1 つ選んでください」と指示し、{"type","urgency","reason"} の JSON で返させる)…

// AI が存在しない選択肢を返しても取り込まない
if (!hasType && types.indexOf(result.type) >= 0) {
  model.ClassA = result.type;
}
if (!hasUrgency && urgencies.indexOf(result.urgency) >= 0) {
  model.ClassB = result.urgency;
}
  • AI の答えは必ずホワイトリストで照合してから代入します。 選択肢に無い値が入ると一覧のフィルタが壊れます
  • 人の判断を上書きしません。 値が入っていれば触らない条件を先頭に置きます
  • 選択肢はサーバースクリプトから自動では取れません。 columns.ClassA.ChoiceHash は常に null を返します(選択肢を設定するためのプロパティで、取得用ではありません)。選択肢はスクリプト側に持つか、拡張 SQL で Sites.SiteSettings を読んでパースします

コメントの経緯を 3 行で要約する ​

プロセスで「経緯を3行で」ボタン(アイコン summarize)を追加し、BeforeUpdate で context.ControlId を判定して、コメントのやりとりを要約して DescriptionE に書き込みます。コメントは model.Comments に JSON 配列で、新しい順に入っています。

js
// コメントは JSON 配列で入っている。形が変わっても落ちないよう防御的に読む。
function readComments() {
  var raw = model.Comments;
  if (raw == null) {
    return [];
  }
  var list;
  try {
    list = typeof raw === 'string' ? JSON.parse(raw) : JSON.parse(JsonConvert.SerializeObject(raw));
  } catch (e) {
    var s = String(raw).trim();
    return s === '' ? [] : [s];
  }
  if (!list || typeof list.length !== 'number') {
    return [];
  }
  var out = [];
  for (var i = list.length - 1; i >= 0; i--) {  // 新しい順で入っているので古い順に直す
    var c = list[i];
    var body = c && c.Body != null ? String(c.Body) : String(c);
    body = body.replace(/\r?\n/g, ' ').trim();
    if (body !== '') {
      out.push(body);
    }
  }
  return out;
}

システムプロンプトでは「1 行目は何が起きたか、2 行目はこれまでに何をしたか、3 行目は次に何をすべきか」「やりとりに書かれていないことを推測で補わない」と指示しています。前述のとおり、content が空で返る場合のハンドリングが必須です。

状況が変わったときの通知文を AI に書かせる ​

状況(Status)が変わったときだけ、AI が書いた通知文を Webhook へ送ります。

js
// --- BeforeUpdate: 更新前の状況を控えておく -------------------------------
// AfterUpdate では saved も更新後の値になっているため、ここで拾って
// context.UserData に載せ替える。UserData は同一リクエスト内で共有される。
if (context.Condition === 'BeforeUpdate') {
  context.UserData.statusBefore = String(saved.Status == null ? '' : saved.Status);
  return;
}

// --- AfterUpdate: 状況が変わっていれば通知する ----------------------------
var before = String(context.UserData.statusBefore == null ? '' : context.UserData.statusBefore);
var after = String(model.Status == null ? '' : model.Status);
if (before === '' || before === after) {
  return;
}

// …(httpClient で通知文を生成)…

// AI が使えなくても通知そのものは止めない
var body = '状況が ' + before + ' から ' + after + ' に変わりました。';
if (!httpClient.IsTimeOut && httpClient.IsSuccess) {
  try {
    body = String(JSON.parse(raw).choices[0].message.content).trim();
  } catch (e) {
    // 既定の文面のまま送る
  }
}

var n = notifications.New();
n.Type = 2;                 // Slack
n.Address = WEBHOOK;        // Incoming Webhook URL
n.Title = '[問い合わせ #' + context.Id + '] ' + String(model.Title == null ? '' : model.Title);
n.Body = body;
n.Send();
  • AfterUpdate の時点では saved も更新後の値になっています。 更新前後の差分を取るには、BeforeUpdate で context.UserData に控え、AfterUpdate で読みます。BeforeUpdate で通知すると、保存が失敗しても通知だけ飛んでしまいます
  • 通知の HttpClient 型(Type = 9)はサーバースクリプトからは使えません。 Notification.json で既定が無効で、true にしても Value cannot be null. (Parameter 'name') の例外になります。サーバースクリプトの notifications.New() が返すオブジェクトは Encoding / MediaType / MethodType / Headers を持たず(ServerScriptModelNotificationModel.cs)、Encoding が null のまま Encoding.GetEncoding に渡るためです(HttpClient.cs)
  • Slack 型(Type = 2)や Teams 型(Type = 6)は動きます。 Slack 型は Address に {"text": "..."} を POST するだけなので、その形式を受け取れる Webhook なら Slack でなくても使えます
  • Send() の戻り値はあてになりません。 送信できてもできなくても、通知が無効化されていても true を返します

つまずきやすい点のまとめ ​

現象原因と対処
たまにスクリプトが途中で終わるServerScriptTimeOut の既定が 10 秒。Script.json で延ばす
「更新」でも AI が呼ばれてしまうcontext.ControlId で発火元を判定する
JSON.parse が落ちることがあるモデルがコードフェンスで包む。剥がしてからパースする
結果が null になる推論モデルが max_tokens を思考で使い切った。max_tokens を増やし、空チェックを入れる
結果が生の Markdown で表示される読み取り専用の長文項目はビューアが働かない。プレーンテキストで整形する
類似度が全体的に低い/おかしいhttpClient の状態が他スクリプトと共有されている。使う直前に全設定し直す
照合対象が 0 件items.Get() の戻り値は .NET 配列。Length を使う
拡張 SQL 呼び出しで NullReferenceException"Api": true の付け忘れ、または名前の綴り間違い
音声ファイルを送れないhttpClient は multipart を送れない。画像は data URL の JSON で送れる
トークンをどこに置くかテーブルの管理ではなく拡張サーバースクリプトのファイルに置く

関連ページ ​

変更履歴

第4版「外部連携・AI」を 1.5.8.1 のソースで検証して修正
第3版元記事への言及を整理し、必要なコードをページに収録。検索機能に一覧の検索と絞り込みを追加
第2版記事のファイル名に並び順の番号を付け、元記事リンクを frontmatter の sources に移行
第1版「外部連携・AI」セクションの記事を追加