問い合わせ急増の原因をClaudeで説明する — 変更カレンダーとの突合手順
Zendeskの増分エクスポートと社内の変更カレンダーを渡し、日次の山にリリース・障害・キャンペーンを当てて、確度を高・中・低で書き分ける手順です。
日次の山を見つけてから、予定表を当てる
問い合わせが急に増えた日の原因は、日次の件数を出し、山の日付を社内の変更カレンダー(リリース・障害・キャンペーンの予定表)と突き合わせると絞れます。山の検出は手元のスクリプトに任せ、Claudeには「どの予定がどの山の原因らしいか」の説明と、確度の書き分けを頼む構成が安定します。
件数を数える作業をClaudeに任せると、集計が一度ずれただけで説明全体が崩れます。数える工程は決まった手順のコードで固定し、読む工程だけを言葉で頼みます。
この記事で作るのは、次の3段の流れです。
山に要因を当てるまでの流れ
- 1
チケットの増分エクスポートを取る
IDと作成日時と件名だけを残したCSVにします。
- 2
山を機械的に拾い、予定表と照合する
直近の平常値から外れた日だけを抜き出し、前2日以内の予定を候補にします。
- 3
Claudeに説明を書かせる
候補ごとに確度を高・中・低で書き分けさせ、予定が無い山は「不明」のまま残します。
週次の内容分類はClaudeでVOC分析の守備範囲です。ここで扱うのは、件数そのものが増えた日の説明です。
材料その1: Zendeskの増分エクスポートでチケットを取る
Zendesk Supportには、前回の取得以降に作成・変更されたチケットを差分で返すIncremental Export APIがあります。呼び出せるのは管理者(Admins)だけです。
初回はstart_time(Unix時刻)を指定し、2回目以降は返ってきたafter_cursorを渡します。start_timeは1分より前の時刻でなければならず、直近1分のデータは返りません。カーソル方式は、時刻方式より性能と応答サイズが安定するため、ドキュメントで強く推奨されています。
# 初回: 2026-09-01 00:00 JST(= 2026-08-31T15:00:00Z)以降
curl "https://{subdomain}.zendesk.com/api/v2/incremental/tickets/cursor\
?start_time=1788188400&exclude_deleted=true" \
-H "Authorization: Bearer {access_token}" > page1.json
# 2回目以降: 前回の after_cursor を渡す
curl "https://{subdomain}.zendesk.com/api/v2/incremental/tickets/cursor\
?cursor={after_cursor}&exclude_deleted=true" \
-H "Authorization: Bearer {access_token}" > page2.json応答のend_of_streamがtrueになるまで、after_cursorをたどって取得を続けます。1ページは最大1,000件で、per_pageで減らせます。レート制限は、これらのエンドポイントで1分あたり10リクエストまでです。
件数を数えるときに外れやすい3点
増分エクスポートは「変更されたチケット」を返すAPIで、「新しく作られたチケット」だけを返すAPIではありません。問い合わせの流入数として数えるなら、次の3点を押さえます。
- 日次の件数は
created_atで数える。updated_atで数えると、過去のチケットへの返信が当日の件数に混ざる - 削除済みチケットは既定でストリームに出る。
exclude_deleted=trueで除く - 取得対象は
support_type_scopeで変わる。既定はagentで、AIエージェントが対応したチケットも数えるならallにする
created_atはUTC(末尾がZ)で返ります。日本の業務日で数えるなら、集計の前にJSTへ直す必要があります。この変換を忘れると、深夜帯の問い合わせが前日側に入ります。
渡すCSVは、idとcreated_atとsubjectの3列で足ります。氏名やメールアドレスの列は分析に要らないので、取得した時点で落とします。idは件数のためではなく、同じチケットを二重に数えないための列です。更新されたチケットは何度でも返るので、集計の前にidで重複を除きます。本文を使いたいときのマスキングは、VOC分析のスクリプトが使えます。
材料その2: 変更カレンダーを1行1予定のCSVにする
予定表は、社内の複数の場所に散らばっています。リリースはリリースノートやデプロイ記録、障害は障害対応の記録、キャンペーンは販促の計画表にあります。これを次の4列のCSVに寄せます。
| 列 | 内容 | 例 |
|---|---|---|
| date | 内容影響が出始める日(JST) | 例2026-09-11 |
| kind | 内容リリース / 障害 / キャンペーン | 例リリース |
| name | 内容予定の名前 | 例v4.2配信開始 |
| keyword | 内容問い合わせの件名に出そうな語 | 例ログイン |
keywordの列が、突合の精度を決めます。件数の山が予定の翌日にあっても、それだけでは偶然かもしれません。件名にkeywordが出ていて初めて、因果を疑う根拠になります。
date,kind,name,keyword
2026-09-11,リリース,v4.2 配信開始,ログイン
2026-09-24,障害,メール配信遅延,メール
2026-10-01,キャンペーン,秋のクーポン配布,クーポン
2026-09-16,リリース,管理画面の文言変更,管理画面dateは予定の日ではなく、ユーザーに影響が出始める日にします。アプリの段階配信なら配信が始まった日、メールのキャンペーンなら配信日です。リリース日程の台帳を持っていない場合は、Claudeで広報の年間計画を組む手順のように過去の発表を一覧にして作り始めます。
山の検出と予定の照合は、スクリプトで固定する
山の検出に使うのは、直近14日の中央値と、中央値からの絶対偏差の中央値(MAD)です。平均と標準偏差だと、過去の山に基準が引きずられます。中央値ベースなら、過去に1日だけ跳ねた日があっても平常値は動きません。
スコアが3以上の日を山とみなし、その日の最多の件名と、山の前2日以内にある予定を並べます。件名の一致が出た予定はkeywordで判定します。
import csv, sys, statistics as st
from collections import Counter
from datetime import datetime, timedelta, timezone, date
JST = timezone(timedelta(hours=9))
tickets_path, events_path = sys.argv[1], sys.argv[2]
daily, subjects, seen = Counter(), {}, set()
with open(tickets_path, newline="", encoding="utf-8") as f:
for r in csv.DictReader(f):
if r["id"] in seen: # 更新で再び返ったチケットは数えない
continue
seen.add(r["id"])
t = datetime.strptime(r["created_at"], "%Y-%m-%dT%H:%M:%SZ")
d = t.replace(tzinfo=timezone.utc).astimezone(JST).date()
daily[d] += 1
subjects.setdefault(d, []).append(r["subject"])
events = [dict(r, date=date.fromisoformat(r["date"]))
for r in csv.DictReader(open(events_path, encoding="utf-8"))]
days = sorted(daily)
print(f"{days[0]}〜{days[-1]} 計{sum(daily.values())}件")
for i, d in enumerate(days):
prev = [daily[x] for x in days[max(0, i - 14):i]]
if len(prev) < 7:
continue
med = st.median(prev)
mad = st.median(abs(x - med) for x in prev) or 1
score = (daily[d] - med) / (1.4826 * mad)
if score < 3:
continue
top, n = Counter(subjects[d]).most_common(1)[0]
share = n / daily[d]
print(f"\n{d} {daily[d]}件 (直近14日の中央値{med:.0f}, スコア{score:.1f})")
print(f" 最多の件名: {top}({share:.0%})")
cand = [e for e in events if timedelta(0) <= d - e["date"] <= timedelta(days=2)]
if not cand:
print(" 確度: 低 / 該当する予定なし(要因は不明。件名の傾向だけ報告)")
for e in cand:
hit = e["keyword"] in top
level = "高" if hit and share >= 0.4 else "中"
why = "件名が一致" if hit else "件名の一致なし"
print(f" 確度: {level} / {e['kind']}「{e['name']}」({e['date']}) {why}")動作の確認には、1日あたり18〜26件の平常値に、4つの山を人工的に足した40日分のサンプル(997件)を使いました。Python 3.12.2での実行結果は次のとおりです。サンプルは手元で作った架空のデータで、実際のチケットではありません。
python3 spike_explain.py tickets.csv events.csv2026-09-01〜2026-10-10 計997件
2026-09-12 54件 (直近14日の中央値22, スコア21.6)
最多の件名: アプリ更新後にログインできない(56%)
確度: 高 / リリース「v4.2 配信開始」(2026-09-11) 件名が一致
2026-09-13 33件 (直近14日の中央値22, スコア7.1)
最多の件名: アプリ更新後にログインできない(36%)
確度: 中 / リリース「v4.2 配信開始」(2026-09-11) 件名が一致
2026-09-19 43件 (直近14日の中央値23, スコア6.7)
最多の件名: 設定の変更(65%)
確度: 低 / 該当する予定なし(要因は不明。件名の傾向だけ報告)
2026-09-25 48件 (直近14日の中央値24, スコア8.3)
最多の件名: メールが届かない 障害(58%)
確度: 高 / 障害「メール配信遅延」(2026-09-24) 件名が一致
2026-10-03 47件 (直近14日の中央値22, スコア6.9)
最多の件名: クーポンが使えない(47%)
確度: 高 / キャンペーン「秋のクーポン配布」(2026-10-01) 件名が一致9月13日の判定が「中」になっているのは、同じ予定の2日目で、件名の占有率が36%と閾値の40%を下回ったためです。9月19日は予定表のどこにも当たらず、「不明」のまま残りました。
Claudeに同じCSVを渡して「山を探して」と頼む手もありますが、会話のたびに閾値や窓の幅が変わり、先週と今週の山を比べられなくなります。コードで固定しておけば、判定の基準を週をまたいで同じにできます。手元で動かしても、Claudeのコード実行(隔離されたサンドボックス)に走らせても、同じコードなら同じ判定規則で動きます。
確度を高・中・低で書き分ける
予定表との重なりは、因果の証拠にはなりません。「リリースの翌日に山がある」は、毎週のようにリリースしている組織なら、ほぼ必ず成り立ちます。そこで、説明文に確度を付けて、読む人が裏取りの優先度を決められるようにします。
| 確度 | 条件 | 説明文での書き方 |
|---|---|---|
| 高 | 条件山の前2日以内に予定があり、最多の件名がkeywordと一致し、占有率が4割以上 | 説明文での書き方原因として書く。裏取りの確認先を添え、確認を必須にする |
| 中 | 条件予定は重なるが、件名の一致が弱い、または占有率が低い | 説明文での書き方「関係している可能性」と書き、追加で見る項目を挙げる |
| 低 | 条件該当する予定がない | 説明文での書き方原因は書かない。件名の傾向と、予定表に載っていない要因の確認先を挙げる |
占有率4割は、このサンプルでの目安です。件名が散らばりやすいサービスでは、閾値を下げる必要があります。分類の粒度を揃えると占有率が安定するため、件名ではなく問い合わせの分類を使う方法もあります。
「低」の山こそ、レポートに残す価値があります。予定表に載っていない要因として、SNSでの言及、他社のサービス障害、請求日や年度末といったカレンダー上の周期が考えられます。ここを埋めずに「不明」と書けば、次の担当者が予定表に載せ忘れた施策を探すきっかけになります。
Claudeに説明文を書かせるプロンプト
スクリプトの出力を貼り付けて、説明文の作成を頼みます。コードの出力は事実として渡し、Claudeには言葉にする工程だけを任せます。
添付のspike_output.txtは、問い合わせ件数の山と、前2日以内の
予定の照合結果です。次の形式で、山ごとに説明を書いてください。
- 日付と件数(平常値との差も)
- 確度(高/中/低。出力の判定をそのまま使う)
- 原因の説明(確度が高なら言い切る。中は「可能性」と書く。
低は原因を書かず「不明」とし、確認先を2つ挙げる)
- 裏取りに使える情報(ヘルプデスクのどの項目を見るか)
出力にない事実は書かないでください。推測の箇所は「推測」と明記してください。「出力にない事実は書かない」の一文が肝です。Claudeは文脈から、もっともらしい因果を補いやすいからです。「推測」と明記させておけば、読み手が事実と区別できます。
返ってきた説明は、裏取りの手順を入れて確認します。確度が高の山でも、件名が一致した問い合わせを5件ほど読み、本文にリリース由来の症状(バージョン、画面名、エラー文言)が出ているかを確かめます。CSVを渡した分析はコード実行とファイル作成の機能で行え、アップロードできるサイズは1ファイル30MBまでです。
障害が原因とわかった山は、顧客向けのお知らせ文と想定問答に進めます。手順は障害のお知らせ文とサポート想定問答にあります。
山の説明が外れやすい場面
落とし穴
予定の日付が、影響の出る日とずれている
アプリの段階配信やメールの時間差配信は、予定の日と問い合わせが増える日が一致しません。dateの列には、影響が出始める日を入れます。突合の窓が前2日以内でも、配信が3日以上かかる施策では窓を広げます。
- 同じ日に予定が複数ある場合は、件名の
keywordが一致した予定だけを上位にする。両方が一致するなら、件名を予定ごとに分けて占有率を出す - 曜日の周期は、中央値では吸収されない。14日のうち月曜は2日だけなので、月曜が毎週多いサービスでは月曜が毎回山として拾われる。曜日ごとに、直近数週の同じ曜日の中央値(たとえば過去4週の月曜)と比べると誤検出が減る
- 件数の山が小さい日は、スコアが高く出ても絶対数が小さいことがある。実務では「スコア3以上かつ平常値の1.5倍以上」のように、絶対値の条件を足すと誤検出が減る
- 問い合わせの窓口を変えた直後は、件数の水準そのものが変わる。チャネルの追加や自動応答の導入日を、予定表に載せておく
増分エクスポートで取れるのは、問い合わせの作成日時と件名までです。山の日に何が起きたかの裏付けは、リリースの記録や障害の対応履歴といった別の記録に当たる必要があります。Claudeは、その記録を渡されて初めて突合に使えます。
毎週の運用に落とす
毎週回すなら、次の3つの入力が揃っていれば再実行できます。
- 前回の
after_cursor(増分取得の続きから始めるため) - 更新した
events.csv(その週の予定を追記したもの) - 固定のプロンプトと
spike_explain.py
カーソル方式を使えば、全件を取り直さずに差分を足せます。ただし差分には、前の週に作られて今週更新されたチケットも含まれます。取得したCSVはid、created_at、subjectの3列だけを残して追記し、集計の前にidで重複を除きます。ログに残す列を最小にしておけば、個人情報の管理も楽になります。
説明文は、確度「高」でも裏取りを済ませてから原因として確定する運用が安全です。確度が「中」「低」の山は、次の週にevents.csvへ施策を足して再実行し、確度が上がるかを確かめます。予定表の抜けが埋まるほど、「不明」の山が減っていきます。