はじめに
問い合わせ管理をプリザンターで運用していると、必ずこうなります。
- タイトル: 「エラーが出ました」
- 内容: (空欄)
- 添付:
スクリーンショット 2026-09-03 104512.png
画面のスクショだけ貼って、本文を書いてくれない。 一次受付の担当者は毎回画像を開いて、
エラーコードを目視で書き写すことになります。
この記事では さくらのAI Engine の
画像認識モデルを使って、添付されたスクリーンショットを AI に読ませて問い合わせ票に転記する
仕組みを作ります。拡張機能だけで完結し、本体コードの改変は不要です。
バージョン 1.5.7.0 を対象にしています
最初に音声で失敗した話
もともとは「会議の録音を添付したら議事録が起こされる」を作ろうとしていました。
さくらのAI Engine には whisper-large-v3-turbo があり、API 単体では非常に優秀です。
STATUS: 200 1216ms
{"text":"それでは、8月度の定例会議を始めます。1点目、新機能のリリース日ですが、
来週の火曜日を予定しています。…","model":"whisper-large-v3-turbo"}
1.2 秒でほぼ完璧に書き起こします。ところが、プリザンターのサーバスクリプトからは呼べません。
/v1/audio/transcriptions は multipart/form-data を要求します。しかしサーバスクリプトの
httpClient は文字列本文(StringContent)しか送れず、MediaType に boundary 付きの値を
入れると送信時に落ちます。
httpClient.MediaType = 'multipart/form-data; boundary=----abc123'; // 代入はできる
httpClient.Post();
// → The format of value 'multipart/form-data; boundary=----abc123' is invalid.
JSON に base64 で詰めて送る手も試しましたが、こちらは API 側に拒否されました。
JSON+base64 STATUS 400
{"error":{"message":"invalid form"}}
一方で画像は JSON で送れます。 OpenAI 互換のチャット API は、画像を
image_url の data URL として本文に埋め込む形式を受け付けます。つまり multipart は不要です。
そこで音声をあきらめ、画像に振り直しました。
使うモデル
/v1/models を叩くと、アカウントで使えるモデルが分かります。
curl 'https://api.ai.sakura.ad.jp/v1/models' \
-H 'Authorization: Bearer {{さくらのAI Engineのトークン}}'
この中に preview/Qwen3-VL-30B-A3B-Instruct という画像認識対応モデルがありました。
呼び出し口はチャットと同じ /v1/chat/completions です。
preview/ が付くモデルは提供が変わる可能性があります。また、マニュアルに記載があっても
アカウントで使えないモデルもあります(音声合成の zundamon は This model is not available.
が返りました)。実装前に /v1/models で確認してください。
難所: 添付ファイルのバイナリをどう取り出すか
サーバスクリプトには添付ファイルの中身を読む API がありません。_file_cs(ファイル操作)は
ReadAllText しか持たず、しかも既定で無効化されています。
添付ファイルの実体は Binaries テーブルにあります。
select "BinaryId","ReferenceId","BinaryType","FileName","ContentType","Size" from "Binaries";
| BinaryId | ReferenceId | BinaryType | FileName | ContentType | Size |
|---|---|---|---|---|---|
| 1 | 19 | Attachments | error-screen.png | image/png | 32884 |
Bin 列(bytea / varbinary)にバイト列が入っています。拡張SQL から
encode(..., 'base64') で取り出せば、JS 側でバイナリを扱わずに済みます。
data URL に必要なのは base64 文字列なので、これで十分です。
BinaryStorage.json の Provider が Rds(既定)のときの話です。
Path を指定してファイルシステムに保存している環境では、この方法は使えません。
拡張SQL を用意する
App_Data/Parameters/ExtendedSqls/ に置きます。
{
"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 を忘れると動きません。 サーバスクリプトの extendedSql.* から呼べる拡張SQLは
Api が true のものだけです。しかも「見つかりません」ではなく NullReferenceException で落ちます。
System.NullReferenceException: Object reference not set to an instance of an object.
at Implem.Pleasanter.Models.ExtensionUtilities.DataSetToExpando(DataSet dataSet)
名前の綴り間違いでも同じ例外になるので、まずここを疑ってください。
BinaryType の値にも注意が必要です。
| 添付のしかた | BinaryType |
|---|---|
| 添付ファイル項目にドロップ | Attachments |
| 長文項目(Markdown)に画像を貼り付け | Images |
両方拾いたいなら "BinaryType" in ('Attachments','Images') にしてください。
実装
下準備
記録テーブル 問い合わせ管理 に項目を用意します。
| 物理名 | 表示名 | 用途 |
|---|---|---|
AttachmentsA |
スクリーンショット | 画像の添付先 |
DescriptionA |
問い合わせ内容 | 空なら AI が埋める |
DescriptionB |
画像から読み取った内容 | 結果(読み取り専用) |
プロセス機能で「画像を読む」ボタン(アイコン image_search)を追加します。
実行の種類は「追加したボタン」、アクションの種類は「保存」です。
拡張サーバスクリプト
{
"Name": "ReadScreenshot",
"Description": "添付されたエラー画面のスクリーンショットをVLモデルに読ませる",
"SiteIdList": [5],
"BeforeUpdate": true
}
(function () {
'use strict';
var ENDPOINT = 'https://api.ai.sakura.ad.jp/v1/chat/completions';
var MODEL = 'preview/Qwen3-VL-30B-A3B-Instruct';
var TOKEN = '{{さくらのAI Engineのトークン}}';
var TRIGGER_CONTROL_ID = 'Process_1'; // 「画像を読む」ボタン
var MAX_BYTES = 4 * 1024 * 1024;
if (context.ControlId !== TRIGGER_CONTROL_ID) {
return;
}
// 添付ファイルのバイナリは Binaries テーブルにある。
// サーバスクリプトからバイナリを直接読む API が無いので拡張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);
var fileName = String(row.FileName == null ? '' : row.FileName);
if (b64 === '') {
context.Error('添付画像のデータを取得できませんでした。');
return;
}
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: [
{ type: 'text', text: 'このエラー画面を読み取ってください。' },
{ type: 'image_url', image_url: { url: 'data:' + contentType + ';base64,' + b64 } }
]
}
],
temperature: 0,
max_tokens: 1500
});
var raw = httpClient.Post();
if (httpClient.IsTimeOut) {
context.Error('画像認識がタイムアウトしました。');
return;
}
if (!httpClient.IsSuccess) {
context.Error('画像認識でエラーが返りました。HTTP ' + httpClient.StatusCode);
return;
}
var content;
try {
content = JSON.parse(raw).choices[0].message.content;
} catch (e) {
context.Error('レスポンスを解釈できませんでした。');
return;
}
var r = parseLooseJson(content);
if (r === null) {
model.DescriptionB = '■ 画像から読み取った内容(' + fileName + ')\n\n' + content;
return;
}
var lines = ['■ 画像から読み取った内容(' + fileName + ' / ' + MODEL + ')', ''];
if (r.app) lines.push('アプリ : ' + r.app);
if (r.errorCode) lines.push('エラーコード: ' + r.errorCode);
if (r.summary) lines.push('概要 : ' + r.summary);
if (r.messages && r.messages.length) {
lines.push('');
lines.push('画面上の文言:');
for (var i = 0; i < r.messages.length; i++) {
lines.push(' - ' + String(r.messages[i]).replace(/\r?\n/g, ' '));
}
}
if (r.nextAction) {
lines.push('');
lines.push('一次対応: ' + r.nextAction);
}
model.DescriptionB = lines.join('\n');
// 問い合わせ内容が空なら、読み取った概要で埋めておく
if (String(model.DescriptionA == null ? '' : model.DescriptionA).trim() === '' && r.summary) {
model.DescriptionA = r.summary;
}
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 a = t.indexOf('{');
var b = t.lastIndexOf('}');
if (a < 0 || b <= a) return null;
try {
return JSON.parse(t.substring(a, b + 1));
} catch (e) {
return null;
}
}
})();
画像を渡す部分
肝はここだけです。テキストと画像を配列にして content に渡します。
{
role: 'user',
content: [
{ type: 'text', text: 'このエラー画面を読み取ってください。' },
{ type: 'image_url', image_url: { url: 'data:image/png;base64,iVBORw0KGgo...' } }
]
}
content が文字列ではなく配列になる点が、通常のチャットとの違いです。
動かしてみる
本文を一切書かずに、エラー画面のスクショだけ添付して起票します。
現場でよく見る「タイトルと画像だけ」の状態です。
「画像を読む」ボタンを押すと、画像から読み取った内容 が埋まります。
■ 画像から読み取った内容(error-screen.png / preview/Qwen3-VL-30B-A3B-Instruct)
アプリ : 販売管理システム
エラーコード: DB-4021
概要 : データベースへの接続に失敗し、処理が取り消された
画面上の文言:
- データベースへの接続に失敗しました
- 受注データの保存中にエラーが発生しました。処理は取り消されました。
- エラーコード: DB-4021
- 詳細: タイムアウトしました(接続先: SV-EIGYO-01 / 待機 30 秒)
- この状態が続く場合はシステム管理者に連絡してください。
一次対応: 再試行ボタンを押して処理を再開するか、システム管理者に連絡する
エラーコード DB-4021 はもちろん、接続先ホスト名 SV-EIGYO-01 やタイムアウト秒数まで
拾えています。問い合わせ内容 が空だったので、概要文がそちらにも転記されました。
画像を貼っただけの問い合わせが、検索できるテキストになりました。
前回作ったベクトル検索と組み合わせれば、
「同じエラーコードの過去案件」も自動で並びます。
運用するときの注意
プロンプトで推測を禁止する
画像認識は「それっぽく」埋めてきます。システムプロンプトに
「画面に書かれている文言だけを根拠にし、推測で情報を補わないでください」
を入れておくのが効きました。これが無いと、画面に無いはずの対処手順まで書いてきます。
サイズ制限を入れる
base64 は元データの約 1.33 倍になります。スマートフォンで撮った写真をそのまま添付されると
リクエストが数 MB になるので、Size 列で足切りしています。
個人情報が写り込む
スクリーンショットには顧客名や個人情報が写っていることがあります。外部 API に送る以上、
どこに送っているかを利用者に明示する運用が必要です。
さくらのAI Engine は国内リージョンで動くので、この点は説明しやすいところです。
まとめ
添付されたスクリーンショットを AI に読ませて、問い合わせ票に転記しました。
-
音声(whisper)はサーバスクリプトから呼べない。
httpClientが multipart を送れないため - 画像は JSON の data URL で送れるので multipart 問題を回避できる
- 添付の実体は
Binaries.Bin。拡張SQL のencode(..., 'base64')で取り出す - 拡張SQL は
"Api": trueが必須。忘れると NullReferenceException で落ちる -
BinaryTypeは添付欄がAttachments、Markdown 貼付がImages - 使えるモデルは
/v1/modelsで確認する。マニュアルにあっても使えないものがある - プロンプトで推測を明示的に禁止する
音声をやりたい場合は、サーバスクリプトの外——プリザンターの API を叩く常駐ワーカーを別に
用意する形になります。そちらはまた機会があれば。

