Claude Media
Claude APIで事故受付の初報を構造化 — 未決案件の台帳へ夜間バッチ

Claude APIで事故受付の初報を構造化 — 未決案件の台帳へ夜間バッチ

電話・Webで受けた事故の初報テキストを、JSONスキーマ固定のStructured Outputsで事故日・場所・当事者・負傷・車両状態に項目化し、Message Batchesで夜間に処理して未決案件の台帳へ流すPythonの手順です。

日中に電話やWebフォームで受けた事故の初報は、自由な文章のまま受付票に溜まっていきます。翌朝までに事故日・場所・当事者・負傷の有無・車両の状態を揃えて未決案件の台帳に載せたい場合は、Claude APIのStructured Outputs(JSONスキーマ固定の出力)とMessage Batchesの組み合わせが使えます。バッチ料金は標準の50%で、ほとんどのバッチは1時間以内に終わります。夜間に1回まとめて流す運用と相性がよい仕組みです。

ここでは、初報から取る項目の決め方、スキーマ、1件での試し打ち、バッチの組み立て、結果の回収、台帳への書き込みまでをPythonで通します。画面でチャットしながら保険の書類を整える手順は保険の苦情を分類して管理簿に集計する手順にあります。PDFを一括で台帳にする型は契約台帳をPDFから一括抽出する手順で扱っていて、ここでは入力が短い自由文であること、出力の形をAPI側で固定することが違いです。

初報から取る項目と、取らない項目

最初に決めるのは台帳の列です。初報は話し手の記憶と焦りが混ざった文章なので、取れる項目と取ってはいけない項目を分けておきます。

取るのは、初報の文面に書かれている事実だけです。過失の割合、保険金が出るかどうか、負傷の程度の評価はClaudeに判断させません。これらは担当者が書類と照らして決めることで、初報の抽出に混ぜると台帳の信用が落ちます。

台帳の列JSONのキー抽出時の注意
事故日JSONのキーaccident_date抽出時の注意「昨日」などの相対表現は受付日から換算する。書かれていなければ null
日付の根拠JSONのキーaccident_date_basis抽出時の注意明記 / 換算 / 不明の3値
時間帯(原文)JSONのキーaccident_time_text抽出時の注意「夕方6時ごろ」のまま。換算しない
場所(原文)JSONのキーlocation_text抽出時の注意住所に直さず、話された言い方のまま
当事者JSONのキーparties抽出時の注意配列。役割・名前・車両の言い方を対で
負傷の有無JSONのキーinjury抽出時の注意あり / なし / 不明の3値
負傷の記述JSONのキーinjury_detail抽出時の注意話された内容のまま。程度は評価しない
車両の状態JSONのキーvehicle_status抽出時の注意走行可能 / 走行不可 / 不明の3値
損傷の記述JSONのキーdamage_detail抽出時の注意話された内容のまま
警察への連絡JSONのキーpolice_reported抽出時の注意済 / 未 / 不明の3値
聞き取り漏れJSONのキーmissing_items抽出時の注意初報に無く、折り返しで聞くべき項目名の配列

事故日だけは受付日時から換算を許し、そのかわり根拠の列(accident_date_basis)を別に持たせます。「明記」と「換算」を区別しておけば、台帳を見る人が換算された日付だけを優先して確かめられます。時間帯と場所は原文のまま残し、Claudeに正規化させません。住所への変換は、誤変換が起きたときに初報と見比べないと気づけないからです。

負傷と車両状態を3値の選択肢にするのは、台帳の絞り込みに使うためです。「あり」の案件を朝一番に見る、といった使い方を想定しています。

スキーマを書く

Structured Outputsは、スキーマを output_config.format に type: "json_schema" として渡すと、スキーマに合うJSONが応答のテキストブロックに返る機能です。形式崩れによるパースエラーや必須項目の欠落を、リトライなしで避けられると説明されています。

スキーマの書き方には制約があります。すべてのオブジェクトに additionalProperties: false が必要で、minimum や maxLength などの数値・文字列の制約、再帰的なスキーマは使えません。配列の minItems は0か1だけです。使える機能の範囲は、公式の「JSON Schema limitations」の節に一覧があります。

SCHEMA = {
    "type": "object",
    "properties": {
        "accident_date": {
            "type": ["string", "null"],
            "description": "事故日。YYYY-MM-DD。書かれていなければnull",
        },
        "accident_date_basis": {
            "type": "string",
            "enum": ["明記", "換算", "不明"],
            "description": "明記=初報に日付がある / 換算=受付日時から計算した",
        },
        "accident_time_text": {"type": ["string", "null"]},
        "location_text": {"type": ["string", "null"]},
        "parties": {
            "type": "array",
            "items": {
                "type": "object",
                "properties": {
                    "role": {"type": "string",
                             "enum": ["通報者", "相手方", "同乗者", "目撃者", "その他"]},
                    "name_text": {"type": ["string", "null"]},
                    "vehicle_text": {"type": ["string", "null"]},
                },
                "required": ["role", "name_text", "vehicle_text"],
                "additionalProperties": False,
            },
        },
        "injury": {"type": "string", "enum": ["あり", "なし", "不明"]},
        "injury_detail": {"type": ["string", "null"]},
        "vehicle_status": {"type": "string",
                           "enum": ["走行可能", "走行不可", "不明"]},
        "damage_detail": {"type": ["string", "null"]},
        "police_reported": {"type": "string", "enum": ["済", "未", "不明"]},
        "missing_items": {"type": "array", "items": {"type": "string"}},
    },
    "required": [
        "accident_date", "accident_date_basis", "accident_time_text",
        "location_text", "parties", "injury", "injury_detail",
        "vehicle_status", "damage_detail", "police_reported", "missing_items",
    ],
    "additionalProperties": False,
}

全項目をrequiredにしてnullで逃がす

任意項目(required に入れない項目)は、公式の上限で1リクエストあたり合計24個までです。anyOf や ["string", "null"] のような型配列を使う項目も、合計16個までに制限されています。さらに任意項目は、1つ増えるごとに文法の状態数がおよそ倍になると説明されています。

上のスキーマは全項目を required にして、書かれていない値は null で返させる形です。任意項目は0個、型配列を使う項目は7個で、どちらの上限にも収まります。型配列の項目が上限に近づいたら、null を許す項目を空文字列の許容に変えるなど、構造を平らにする方向で見直します。

順序と列挙値の大文字小文字

出力されるプロパティの順序は、必須項目が先、任意項目が後になります。全項目を必須にしておけば、スキーマに書いた順番で出力されます。

列挙値の綴りは、応答の保証が完全ではありません。公式は、enum の文字列が大文字小文字だけ違う値で返ることがあると注意しています。「明記」「換算」のような日本語の値では大文字小文字の問題は起きにくいものの、英語のコード値を使う台帳に流すなら、比較は大文字小文字を区別せずに行います。

1件を同期で試す

バッチに組む前に、1件をふつうのMessages APIで試します。公式のバッチ処理の節にも、リクエストの形を1件だけ同期で試して検証エラーを避けるよう書かれています。

初報の例を1件用意します。以下は架空の文面です。

受付日時: 2026-10-27 09:12 / 受付経路: 電話
昨日の夕方6時ごろ、市役所の前の交差点で追突されました。
相手の方は軽トラックで、こちらのバンパーが外れていて自走はできません。
首が少し痛いですが、救急車は呼んでいません。警察にはまだ連絡していません。
SYSTEM = """あなたは事故受付の初報を台帳の項目に分ける担当です。
初報に書かれていることだけを根拠にし、書かれていない項目は null か「不明」にします。
過失の有無、保険金の支払可否、負傷の程度は判断しません。
accident_date は、初報に日付があれば「明記」、「昨日」などから受付日時で換算したら「換算」、
決められなければ null と「不明」にします。
聞き取れていない項目は missing_items に項目名で入れます。"""
 
 
def make_params(receipt: dict) -> dict:
    prompt = (f"受付日時: {receipt['received_at']} / 受付経路: {receipt['channel']}\n"
              f"{receipt['text']}")
    return {
        "model": "claude-sonnet-5-5",
        "max_tokens": 1500,
        "system": SYSTEM,
        "messages": [{"role": "user", "content": prompt}],
        "output_config": {"format": {"type": "json_schema", "schema": SCHEMA}},
    }
python try_one.py intake/sample.json

出力は、例えば次のような形になります(スキーマに沿った例示です)。

{
  "accident_date": "2026-10-26",
  "accident_date_basis": "換算",
  "accident_time_text": "夕方6時ごろ",
  "location_text": "市役所の前の交差点",
  "parties": [
    {"role": "通報者", "name_text": null, "vehicle_text": "バン"},
    {"role": "相手方", "name_text": null, "vehicle_text": "軽トラック"}
  ],
  "injury": "あり",
  "injury_detail": "首が少し痛い。救急車は呼んでいない",
  "vehicle_status": "走行不可",
  "damage_detail": "バンパーが外れている",
  "police_reported": "未",
  "missing_items": ["通報者の氏名", "相手方の氏名と連絡先", "事故の正確な場所"]
}

ここで見るのは、日付の換算が受付日から正しく1日戻っているか、書かれていない氏名を埋めていないか、の2点です。負傷が「あり」なのか「不明」なのかも、実際の文面で確かめます。判定が割れる言い回しが見つかったら、システムプロンプトに規則を足してからバッチに進みます。

同じスキーマで何が変わるか

スキーマを使うと、公式の説明では追加のシステムプロンプトが自動で付き、入力トークンがわずかに増えます。また、output_config.format を変えると、その会話のプロンプトキャッシュが無効になります。スキーマの文法は初回リクエストでコンパイルされるぶん遅くなり、その後は最後の使用から24時間キャッシュされます。夜間バッチを毎晩同じスキーマで流すなら、2回目以降はこのキャッシュが効く可能性があります。

夜間バッチに組む

受付票を1日分ためた intake/2026-10-27.jsonl から、1件1リクエストでバッチを作ります。custom_id は1〜64文字の英数字・ハイフン・アンダースコアに限られます。受付番号が R-20261027-0012 のような形ならそのまま使えます。

import json
from pathlib import Path
 
import anthropic
from anthropic.types.message_create_params import MessageCreateParamsNonStreaming
from anthropic.types.messages.batch_create_params import Request
 
client = anthropic.Anthropic()
 
receipts = [json.loads(line)
            for line in Path("intake/2026-10-27.jsonl").read_text(encoding="utf-8").splitlines()]
 
requests = [
    Request(custom_id=r["receipt_id"],
            params=MessageCreateParamsNonStreaming(**make_params(r)))
    for r in receipts
]
 
batch = client.messages.batches.create(requests=requests)
Path("batch_id.txt").write_text(batch.id, encoding="utf-8")
print(batch.id, batch.processing_status)

1つのバッチは10万リクエストか256MBの先に届くほうまでです。1日の初報なら、テキストだけで256MBに届くことはまずありません。上限に近づくのは、写真や書類のPDFを添付するようになってからです。

バッチの結果は、作成から29日間取得できます。その間は、同じ custom_id で何度でも取り直せます。夜間に起動した処理が途中で落ちても、次の朝に同じバッチIDから回収できます。

結果の取得に使うのは、完了を待つスクリプトです。ポーリングの組み方はMessage Batches SDKの実装手順にあるものがそのまま使えます。

結果を回収して台帳に流す

バッチの結果は、1行1リクエストのJSONLで返ります。順序は投入順と一致しません。必ず custom_id で受付票と突き合わせます。

result.type は、成功したものが succeeded で、検証エラー、サーバーエラー、期限切れが別の種別で返ります。ところが成功の中にも、台帳に流してはいけないものが混ざります。

回収時の分岐

succeeded でも台帳に流さない3つの場合

  • 拒否(refusal)

    拒否されたリクエストは result.type: "succeeded" で返り、stop_reason が refusal になります。出力がスキーマに合わない可能性があります。erroredの監視だけでは見落とすので、stop_reason を必ず見ます。

  • max_tokens での打ち切り

    出力が途中で切れると stop_reason が max_tokens になり、JSONが完結しません。max_tokens を上げて再投入します。

  • コード側の検証に落ちた

    スキーマは日付の実在や受付日との前後関係を保証しません。パース後にコードで確かめ、落ちたものは担当者の確認待ちに回します。

拒否の詳しい扱いはBatch APIの拒否が成功扱いになる落とし穴にまとめています。バッチでは、サーバー側のフォールバック(fallbacks)が使えないため、拒否された項目を集めて別のモデルで新しいバッチとして再投入する流れになります。事故の初報は負傷の記述を含むため、内容によっては拒否に当たる場面を想定しておきます。

import sqlite3
from datetime import date
 
db = sqlite3.connect("open_cases.db")
db.execute("""CREATE TABLE IF NOT EXISTS open_cases (
    receipt_id TEXT PRIMARY KEY,
    status TEXT NOT NULL,
    review_reason TEXT,
    extracted TEXT,
    batch_id TEXT
)""")
 
 
def check(extracted: dict, received_at: str) -> list[str]:
    reasons = []
    try:
        if extracted["accident_date"]:
            d = date.fromisoformat(extracted["accident_date"])
            if d > date.fromisoformat(received_at[:10]):
                reasons.append("事故日が受付日より後")
    except ValueError:
        reasons.append("事故日が日付として不正")
    if extracted["injury"] != "なし":
        reasons.append("負傷あり/不明")
    if extracted["missing_items"]:
        reasons.append("聞き取り漏れあり")
    return reasons
 
 
received = {r["receipt_id"]: r["received_at"] for r in receipts}
batch_id = Path("batch_id.txt").read_text(encoding="utf-8")
 
for res in client.messages.batches.results(batch_id):
    rid = res.custom_id
    if res.result.type != "succeeded":
        db.execute("INSERT OR IGNORE INTO open_cases VALUES (?,?,?,?,?)",
                   (rid, "要再投入", res.result.type, None, batch_id))
        continue
    msg = res.result.message
    if msg.stop_reason != "end_turn":
        db.execute("INSERT OR IGNORE INTO open_cases VALUES (?,?,?,?,?)",
                   (rid, "要再投入", f"stop_reason={msg.stop_reason}", None, batch_id))
        continue
    extracted = json.loads(msg.content[0].text)
    reasons = check(extracted, received[rid])
    status = "要確認" if reasons else "自動抽出"
    db.execute(
        """INSERT INTO open_cases VALUES (?,?,?,?,?)
           ON CONFLICT(receipt_id) DO UPDATE SET
             extracted=excluded.extracted, review_reason=excluded.review_reason,
             status=excluded.status, batch_id=excluded.batch_id
           WHERE open_cases.status IN ('自動抽出','要確認','要再投入')""",
        (rid, status, " / ".join(reasons), json.dumps(extracted, ensure_ascii=False), batch_id),
    )
db.commit()

書き込みは、receipt_id を主キーにした上書き更新にしています。ただし担当者が手を入れた後の行(ステータスが上の3つ以外)は更新しません。バッチを取り直して流し直しても、人が直した値は消えません。

台帳の側で持たせる列

抽出結果だけを台帳に載せると、あとで「この値はどこから来たのか」が追えなくなります。次の列を足しておくと、担当者が確認するときに手が止まりません。

  • 初報の原文への参照: 受付票のIDだけでなく、原文を開ける場所。抽出結果は原文の要約であり、原本ではありません
  • ステータス: 自動抽出 / 要確認 / 要再投入 / 確認済み。人が確認した行を機械の流し直しから守るのに使います
  • 要確認の理由: 上のコードの review_reason。「負傷あり/不明」の案件を先に見る並べ方ができます
  • 折り返し期限: missing_items が空でない案件に、担当者が決めた期限を入れる列

missing_items を空にできない案件は、朝の最初の仕事が折り返しの電話になります。ここを台帳の先頭に出すだけで、夜間バッチの価値はほぼ出ます。初報の一次対応は、人の仕事のままです。

負傷の有無を機械が「なし」と抽出した案件でも、それは「初報にそう語られていた」という意味にとどまります。後から症状が出る事案もあります。そのため、injury が「なし」の行を自動でクローズ扱いにするような使い方は避けます。

初報に個人情報が含まれるとき

初報には氏名や連絡先、負傷の状況が含まれます。扱いで押さえておく点は2つです。

1つ目は、スキーマには案件固有の情報を入れないことです。スキーマ定義は、プロンプトや応答とは別に最大24時間キャッシュされます。公式は、HIPAAの対象情報(PHI)をプロパティ名・enum・const・pattern に入れないよう注意しています。この注意は米国の医療情報の枠組みで書かれたものですが、考え方は日本の個人情報にも持ち込めます。実名や病名を enum に埋めず、案件ごとの値は常にメッセージ本文に置きます。

2つ目は、バッチのデータの保存期間です。リクエストと応答のデータは、バッチの作成から最大29日間保存されます。処理が終わったバッチは、DELETE /v1/messages/batches/{batch_id} でいつでも削除できます。台帳に取り込んだ後にバッチを削除する運用にすれば、初報のコピーがAPI側に残る期間を縮められます。進行中のバッチを消すには、先に取り消す必要があります。

保険金請求の書類のように、健康に関わる記述を含む書類をClaudeに渡す前の整理は、保険の健康告知書をClaudeに入れる前にで扱っています。画像から事故の状況を整理する作業はドラレコ静止画から事故の時系列を整理する手順が受け皿になります。

使えない組み合わせと詰まりどころ

Structured Outputsはバッチ処理と組み合わせられます。一方、使えない組み合わせがあります。

やりたいこと結果
スキーマ固定の出力と引用(citations)を同時に使う結果400エラー。引用ブロックとJSON制約が衝突する
メッセージのプリフィル(応答の書き出しを指定する)と併用する結果非対応
複雑なスキーマ結果「Schema is too complex for compilation」の400エラー。文法のコンパイルには180秒のタイムアウトもある
バッチに stream: true や fallbacks を含める結果検証エラー、またはリクエスト単位のエラー

引用を併用できないので、「根拠となる発言をそのまま引かせる」用途は別の項目(injury_detail のような原文抜粋の列)で代用します。初報の該当部分を引き写させるだけなら、文字列の項目で足ります。

詰まりやすいのは、スキーマを広げすぎたときです。聞き取り項目が増えて型配列の項目が16個を超える、任意項目が24個を超える、といった形で上限に当たります。上限を超えたときは、1リクエストで全部を抜かず、初報の種類(物損のみ・人身あり)でスキーマを2つに分け、分類の段階を前に置く構成が現実的な逃げ道です。

まとめ

初報の項目化は、抽出の精度よりも、台帳に入れてよい値と人が見る値の境目を設計できるかで価値が決まります。スキーマ固定とバッチで夜間の処理は安く安定して回せますが、拒否・打ち切り・日付の不整合は成功扱いのまま届くため、回収側で分岐させておく必要があります。まず1日分の初報で試し、「要確認」に回った行の理由を眺めると、システムプロンプトとスキーマのどこを直すかが見えてきます。

参照した一次ソース

この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。

  1. platform.claude.com/docs/en/build-with-claude/structured-outputs
  2. platform.claude.com/docs/en/build-with-claude/batch-processing
  3. platform.claude.com/docs/en/build-with-claude/refusals-and-fallback
この記事を共有:XはてブLinkedIn