3
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

はじめに

これまで さくらのAI Engine を使って、
プリザンターに文法チェック・ベクトル検索・画像認識を足してきました。
どれも「1 記事分の分量がある」ネタでしたが、実際に業務で効くのは
もっと小さくて地味なものだったりします。

この記事では、実装が 100 行以下で終わる小ネタを 3 本まとめて紹介します。

# やること トリガー
1 問い合わせ内容から種別・緊急度を自動で埋める 保存時
2 コメントのやりとりを引き継ぎ用に 3 行で要約する ボタン
3 状況が変わったときの通知文を AI に書かせる 保存後

いずれも拡張機能だけで完結し、本体コードの改変は不要です。

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

共通の土台(httpClient の使い方、トークンの置き場所)は
プリザンターの長文項目にAI文法チェッカーをつけてみる」に書いたので、
そちらも合わせて見てみてください。


小ネタ1: 分類項目をAIに埋めさせる

問い合わせ管理でいちばん面倒なのが、種別緊急度 の選択です。
一次受付が毎回ドロップダウンを開いて選んでいますが、本文を読めば決まる話です。

実装

App_Data/Parameters/ExtendedServerScripts/AutoClassify.json
{
  "Name": "AutoClassify",
  "Description": "問い合わせ内容から種別・緊急度をAIに推定させる",
  "SiteIdList": [5],
  "BeforeCreate": true,
  "BeforeUpdate": true
}
App_Data/Parameters/ExtendedServerScripts/AutoClassify.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 text = String(model.DescriptionA == null ? '' : model.DescriptionA).trim();
  if (text === '') {
    return;
  }

  // すでに人が選んでいる場合は上書きしない
  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 = ['', '', ''];

  var systemPrompt = [
    'あなたはヘルプデスクの一次受付担当です。',
    '問い合わせ内容を読み、種別と緊急度を分類してください。',
    '種別は次のいずれかから必ず 1 つ選んでください: ' + types.join(' / '),
    '緊急度は次のいずれかから必ず 1 つ選んでください: ' + urgencies.join(' / '),
    '出力は次の JSON のみとし、前後に説明文やコードフェンスを付けないでください。',
    '{"type":"種別","urgency":"緊急度","reason":"判断理由を1文で"}'
  ].join('\n');

  httpClient.RequestUri = 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: MODEL,
    messages: [
      { role: 'system', content: systemPrompt },
      { role: 'user', content: text }
    ],
    temperature: 0,
    max_tokens: 800
  });

  var raw = httpClient.Post();
  if (httpClient.IsTimeOut || !httpClient.IsSuccess) {
    context.AddMessage('自動分類を実行できませんでした。手動で選択してください。', 'alert-warning');
    return;
  }

  var result = parseLooseJson(JSON.parse(raw).choices[0].message.content);
  if (result === null) {
    return;
  }

  // AI が存在しない選択肢を返しても取り込まない
  if (!hasType && types.indexOf(result.type) >= 0) {
    model.ClassA = result.type;
  }
  if (!hasUrgency && urgencies.indexOf(result.urgency) >= 0) {
    model.ClassB = result.urgency;
  }
  context.AddMessage(
    'AI が種別「' + model.ClassA + '」/緊急度「' + model.ClassB + '」を設定しました。' + (result.reason || ''),
    'alert-success');

  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;
    }
  }
})();

結果

自動分類された編集画面

「経理部の共有プリンタから印刷しようとすると、用紙が詰まったというエラーが出て止まって
しまいます。紙は入っていますし、詰まっている紙も見当たりません。月末の請求書処理があるので
今日中に何とかしたいです。」

→ 種別: ハードウェア障害 / 緊急度: 高

「月末の請求書処理があるので今日中に」という記述から緊急度を にしています。
キーワードマッチでは出せない判断です。

ポイント: AIの答えをそのまま入れない

分類項目は選択肢が決まっています。AI が存在しない値を返すと、
選択肢に無い値がレコードに入って一覧のフィルタが壊れます。
必ずホワイトリストで照合してから代入してください。

if (types.indexOf(result.type) >= 0) {
  model.ClassA = result.type;
}

ポイント: 選択肢は自動では取れない

「項目定義から選択肢を読めばプロンプトと二重管理しなくて済むのでは」と考えて
columns.ClassA.ChoiceHash を読んでみたのですが、常に null が返ります。

columns.ClassA = ok
LabelText = 種別
ChoiceHash = null      ← 読めない

ChoiceHash はサーバスクリプトから選択肢を設定するためのプロパティで、
取得用ではありませんでした。選択肢はスクリプト側に持つか、
拡張SQL で Sites.SiteSettings を読んでパースするかの二択です。

ポイント: 人の判断を上書きしない

一度でも人が選んだ項目を AI が塗り替えると、使ってもらえなくなります。
値が入っていれば触らない、という条件を先頭に置いています。


小ネタ2: 経緯を3行で要約する

長引いた案件を引き継ぐとき、コメント欄を上から全部読むことになります。
「何が起きて、何をして、次に何をするのか」だけ知りたいのに。

実装

プロセス機能で「経緯を3行で」ボタン(アイコン summarize)を追加し、
BeforeUpdatecontext.ControlId を見て分岐します。

App_Data/Parameters/ExtendedServerScripts/SummarizeHistory.json
{
  "Name": "SummarizeHistory",
  "Description": "コメントのやりとりを引き継ぎ用に3行で要約する",
  "SiteIdList": [5],
  "BeforeUpdate": true
}
App_Data/Parameters/ExtendedServerScripts/SummarizeHistory.json.js(抜粋)
var TRIGGER_CONTROL_ID = 'Process_2'; // 「経緯を3行で」ボタン

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

var comments = readComments();
if (comments.length === 0) {
  context.Error('コメントがありません。要約する経緯がありません。');
  return;
}

var systemPrompt = [
  'あなたはヘルプデスクの引き継ぎ担当です。',
  '案件のこれまでの経緯を、引き継ぎ用に 3 行で要約してください。',
  '1 行目は「何が起きたか」、2 行目は「これまでに何をしたか」、3 行目は「次に何をすべきか」にしてください。',
  '各行は 60 文字以内。行頭に番号や記号を付けないでください。',
  'やりとりに書かれていないことを推測で補わないでください。',
  '出力は 3 行のテキストのみとし、前置きを付けないでください。'
].join('\n');

// …(httpClient で呼び出し)…

var text;
try {
  var c = JSON.parse(raw).choices[0].message.content;
  text = c == null ? '' : String(c).trim();
} catch (e) {
  text = '';
}
// 推論モデルは max_tokens を思考に使い切ると content が空(null)で返ることがある
if (text === '') {
  context.Error('要約結果が空でした。max_tokens を増やして再実行してください。');
  return;
}

model.DescriptionE = '■ 経緯の要約(コメント ' + comments.length + ' 件から)\n\n' + text;

// コメントは 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;
}

結果

コメント 4 件(一次受付 → 原因特定 → 暫定対応 → 経過報告)が付いた案件で実行しました。

経緯の要約

■ 経緯の要約(コメント 4 件から / 2026/09/08 19:42)

経費精算システムで半角数字入力の金額が保存されず0円になる不具合が発生。
再現確認、開発で原因特定、全角入力の暫定運用を経理部に案内し、パッチを金曜にリリース予定。
金曜リリース後にパッチ適用を確認し、金額保存が正常か再テストする。

「何が起きて/何をして/次に何を」の 3 行に、指示どおり収まりました。

ポイント: 推論モデルは content が空で返ることがある

最初 max_tokens: 800 で実行したら、要約結果が null になりました。

■ 経緯の要約(コメント 4 件から / 2026/09/08 19:40)

null

gpt-oss-120b は推論モデルで、レスポンスは message.content(答え)と
message.reasoning(思考過程)に分かれています。思考でトークンを使い切ると
contentnull のまま返ってきます。
HTTP ステータスは 200 なので気づきにくいところです。

対処は 2 つです。

  • max_tokens を余裕をもって取る(今回は 2000 にしたら通りました)
  • content が空だった場合を明示的にハンドリングして、null という文字列を書き込まない

小ネタ3: 通知文をAIに書かせる

状況が変わったときの Slack 通知、既定のフォーマットだと項目名と値が並ぶだけで、
何が起きたのか読み取れません。ここも AI に書かせます。

実装

App_Data/Parameters/ExtendedServerScripts/NotifySummary.json
{
  "Name": "NotifySummary",
  "Description": "状況が変わったときに、AIが書いた通知文をWebhookへ送る",
  "SiteIdList": [5],
  "BeforeUpdate": true,
  "AfterUpdate": true
}
App_Data/Parameters/ExtendedServerScripts/NotifySummary.json.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();

結果

Webhook の受信内容がこちらです。

{
  "text": "*[問い合わせ #19] 販売管理システムでエラーが出ました*\n案件番号19、販売管理システムでソフトウェア不具合が発生し、緊急度は高です。\nステータスが200から900に変更されました。\nデータベースへの接続に失敗し、処理が取り消されました。",
  "username": "\"Administrator\" <>"
}

項目の羅列ではなく、読める日本語になりました。

ポイント: AfterUpdate では saved が使えない

「状況が変わったときだけ通知する」を素直に書くと saved.Statusmodel.Status
比較したくなりますが、AfterUpdate の時点では saved も更新後の値になっています。

After: saved.Status=300 / model.Status=300   ← 変化を検知できない

かといって BeforeUpdate で通知すると、保存が失敗しても通知だけ飛んでしまいます。

そこで context.UserData を使います。これは同一リクエスト内のサーバスクリプト間で
共有される入れ物で、BeforeUpdate で書いた値を AfterUpdate で読めます。

Before: saved=300 model=900
After : UserData before=300 after=900   ← 変化を検知できた

「更新前後の差分で何かする」ときの定番パターンとして使えます。

ポイント: 汎用Webhook(HttpClient型)は使えない

通知の種類には HttpClient(汎用 Webhook、Type = 9)があります。
一見これが本命ですが、サーバスクリプトからは使えません。

まず Notification.json で既定が無効です。

App_Data/Parameters/Notification.json
{
    "HttpClient": false,
    
}

true に変えて再挑戦すると、今度は例外になります。

ERR Error: Value cannot be null. (Parameter 'name')

HttpClient 型の送信処理は Encoding / MediaType / MethodType / Headers を使いますが、
サーバスクリプト側のラッパー(notifications.New() が返すオブジェクト)は
これらのプロパティを持っておらず、Send() で組み立てられる通知にも引き継がれません。
結果 Encodingnull のまま Encoding.GetEncoding(null) が呼ばれて落ちます。

Slack 型(Type = 2)や Teams 型(Type = 6)はこれらを使わないので問題なく動きます。
Slack 型は Address{"text": "..."} を POST するだけなので、
受け口が Slack でなくても、その形式を受け取れる Webhook なら何でも使えます。

ポイント: Send() の戻り値はあてにならない

notificationsSend()送信できてもできなくても true を返します。
通知が無効化されていても true です。戻り値で成否を判定しないでください。


まとめ

小さい実装でも効くものを 3 本紹介しました。

  • 分類の自動補完 — AI の答えは必ずホワイトリストで照合してから代入する。人の判断は上書きしない
  • 経緯の3行要約 — 推論モデルは max_tokens を使い切ると contentnull で返る。空チェックは必須
  • 通知文の生成AfterUpdate では saved が使えない。context.UserDataBeforeUpdate から橋渡しする
  • 通知の HttpClient 型はサーバスクリプトからは使えない。Slack 型 / Teams 型を使う

どれも「AI に判断させる」のではなく、**「人がやると面倒な転記を肩代わりさせる」**という
使い方です。判断は人に残しておいたほうが、結局は長く使ってもらえます。

参考

3
1
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
3
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?