サーバースクリプトから生成 AI を使う
拡張サーバースクリプトの 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 を指定してください。拡張サーバースクリプトは起動時に読み込まれるため、ファイルを置いたらプリザンターを再起動します。
{
"Name": "GrammarCheck",
"Description": "さくらのAI Engine で長文項目を文法チェックする",
"SiteIdList": [1],
"BeforeUpdate": true
}サーバースクリプトのタイムアウトを延ばす
サーバースクリプトの既定タイムアウトは 10 秒です。httpClient 側は 100 秒あっても、それを包むサーバースクリプトが 10 秒で打ち切られます。LLM の応答は数秒〜十数秒かかるため、既定のままではたまに途中で終わる挙動になります。
{
"ServerScriptTimeOut": 10000,
"ServerScriptTimeOutChangeable": false,
"ServerScriptHttpClientTimeOut": 100000
}例えば次のように変更してプリザンターを再起動します。ServerScriptTimeOutChangeable を true にすると、サーバースクリプト単位でもタイムアウトを指定できます。
{
"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 前後に落ちました。値は返ってくるので気づきにくい不具合です。
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 | 内部名 |
| 表示名 | 文法チェック | ボタンのラベル |
| 画面の種類 | 編集 | 新規作成画面には出さない |
| 現在の状況 | * | どの状況でも押せるようにする |
| 変更後の状況 | * | 状況は変えない |
| アイコン | spellcheck | Material Symbols の名前を指定 |
| 実行の種類 | 追加したボタン | ボタンを押したときだけ動かす |
| アクションの種類 | 保存 | 結果を書き戻すので保存が必要 |
WARNING
context.ControlId は編集画面のボタンから起動したときの値です。API 経由の更新では別の値になるので、API も使う環境では context.Controller や context.Action と組み合わせて判定してください。
レスポンスは JSON で返させ、ゆるくパースする
システムプロンプトで「JSON のみを出力」と指示しても、モデルがコードフェンス(```json)で包んでくることがあり、そのままでは JSON.parse が失敗します(10 回に 1 回程度)。フェンスを剥がし、最初の { から最後の } までを切り出してからパースします。
// モデルが ```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(校正結果・読み取り専用)に書き戻します。プロセスで追加した「文法チェック」ボタンを押したときだけ動きます。
図を読み込み中…
(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() で更新自体を中断します。
// 「文法チェック」ボタン経由のときは 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 程度で、長文項目にそのまま収まります。
処理の流れ
図を読み込み中…
- 保存時(
BeforeCreate/BeforeUpdate)に本文を/v1/embeddingsへ送り、1024 次元ベクトルを取得してDescriptionCに JSON で保存する items.Get(context.SiteId)で同じテーブルのレコードを取得し、各レコードのDescriptionCを復元する- コサイン類似度を総当たりで計算し、上位を
DescriptionDに書き戻す
(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 で拒否されました。
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 を指定してファイルシステムに保存している環境では使えません。
{
"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 のものだけです。「見つかりません」ではなく次の例外で落ちます。名前の綴り間違いでも同じ例外になるので、まずここを疑ってください。
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、実行の種類「追加したボタン」、アクションの種類「保存」)を追加します。
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)。
// すでに人が選んでいる場合は上書きしない
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 配列で、新しい順に入っています。
// コメントは 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 へ送ります。
// --- 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 で送れる |
| トークンをどこに置くか | テーブルの管理ではなく拡張サーバースクリプトのファイルに置く |