Claude Media
SDSのPDFからリスクアセスメントの下書きをClaudeで作る — 人が確認する欄の分け方

SDSのPDFからリスクアセスメントの下書きをClaudeで作る — 人が確認する欄の分け方

SDSのPDFをClaude APIに渡し、GHS区分・ばく露限界値・SDS記載の保護具を根拠ページと原文つきで抜き出して、実施シートの下書きにする手順。リスクの見積りを人に残す列の切り方も示します。

化学物質のリスクアセスメントは、SDS(安全データシート)を読んで危険有害性を洗い出すところから始まります。取り扱う製品が増えるほど、この転記が作業の大半を占めます。Claude APIにSDSのPDFを渡せば、GHS区分・ばく露限界値・SDSが挙げる保護具を、根拠のページと原文つきで表に抜き出せます。

ただし、抜き出せるのは「SDSに書いてあること」だけです。作業の条件を踏まえたリスクの見積りと、低減措置の決定は人の仕事として残ります。この記事では、抽出する列と人が入れる列を先に分け、抽出結果を原文と突き合わせてから実施シートに流し込む手順を、動くスクリプトつきで示します。

SDSから機械に拾わせる範囲と、人に残す範囲

化学物質のリスクアセスメントは、労働安全衛生法に基づく義務として、SDSの交付義務がある物質を製造または取り扱う事業場が対象です。厚生労働省の職場のあんぜんサイトによると、業種や事業場の規模は問われず、製造業や建設業に限らず清掃業、卸売・小売業、飲食店、医療・福祉業なども含まれます。対象に当たらない物質でも、リスクアセスメントを行うよう努めるものとされています。

リスクアセスメントの中身は、危険性・有害性の特定、リスクの見積り、リスク低減措置の検討という一連の流れです。国の指針は、化学物質による疾病については、有害性の度合いとばく露の量をそれぞれ考慮して見積もればよいとしています。SDSが答えるのは、このうち「有害性の度合い」の側です。ばく露の量は、取扱量、換気、作業時間といった現場の条件で決まり、SDSのPDFには載っていません。

つまり、PDFから拾う作業と、現場の条件を入れて判断する作業は、きれいに二つに分かれます。

くらべる

Claudeに任せる列と、人が入れる列

Claude

PDFから抜き出す

製品名、供給者、改訂日、成分とCAS番号、GHS区分、ばく露限界値、SDS記載の保護具。いずれも根拠ページと原文の引用を添える。

人

現場の条件で決める

作業内容、取扱量と含有率、換気、作業時間と頻度、見積りの方法、リスクレベル、低減措置、実施者と承認日。

この切り分けは、厚生労働省が紹介する簡易ツールの入力とも対応します。たとえばCREATE-SIMPLEは、取扱量・含有率・換気条件・作業時間と頻度・保護具の有無から推定したばく露濃度を、ばく露限界値(またはGHS区分の情報)と比較する方式のツールです。ツール側が必要とする「限界値かGHS区分」はSDS由来の列、「取扱い条件」は人が入れる列にあたります。

もう一つ、列を設計するうえで押さえておく点があります。指針が示すリスク低減措置の優先順位は、危険な作業の廃止や変更、工学的対策、管理的対策、個人用保護具の使用の順です。SDS由来の「保護具」の列は、その最後の段にあたります。保護具の欄が埋まったことをもって検討を終えたことにしないよう、低減措置の列は人の欄として別に立てます。

実施シートの列を決める

シートの様式は事業場ごとに違っていて、厚生労働省も結果のまとめ方に規定はないとしたうえで、結果記入シートを一例として公開しています。指針が記録を求める項目は、洗い出した作業、特定した危険性または有害性、見積もったリスク、リスク低減措置の優先度、実施した措置の内容です。自社の様式がある場合は、その列名をそのまま使うのが近道です。

ここでは、次の列を例にします。

区分列埋める主体
SDS由来列製品名、供給者、改訂日埋める主体Claude(人が確認)
SDS由来列成分とCAS番号埋める主体Claude(人が確認)
SDS由来列GHS区分(危険性・有害性)埋める主体Claude(人が確認)
SDS由来列ばく露限界値(機関名つき)埋める主体Claude(人が確認)
SDS由来列SDS記載の保護具埋める主体Claude(人が確認)
監査用列根拠ページ、照合結果、SDSに記載なし埋める主体スクリプト
現場条件列作業内容、取扱量・含有率、換気、作業時間・頻度埋める主体人
判断列見積り方法、リスクレベル、低減措置埋める主体人
承認列実施者、承認日埋める主体人

「SDS由来」の列も、Claudeの出力をそのまま確定させるわけではありません。次節以降の突き合わせを通したうえで、人が目を通す前提にします。

SDSのPDFをAPIに渡す前に決めること

PDFの要件は、リクエスト全体で見ます。上限は、リクエストサイズが32MB、1リクエストのページ数が600ページ(コンテキストウィンドウが1Mトークン未満のリクエストでは100ページ)です。パスワードや暗号化のかかったPDFは扱えません。上限は添付する他の内容も含めた合計に対する値なので、PDFを何本も詰めるほど余裕は減ります。

そこで、この手順では1つのSDSを1リクエストで処理する形にします。理由は二つです。一つは、複数製品の値が一つの応答に混ざると、ある限界値がどの製品のものか取り違えやすくなること。もう一つは、1製品ずつなら、失敗したときにその製品だけやり直せることです。PDFの渡し方には、URL参照、base64、Files APIのfile_idの3通りがあります。ローカルのSDSを渡す今回は、base64を使います。

読み取りの仕組みも知っておくと、スキャンしたSDSで迷いません。PDFは、1ページごとに画像へ変換され、そのページから抽出したテキストが画像と並べて渡されます。表も読み取りの対象になります。文字が細かい、表が複雑といったPDFは、ページ数の上限に届く前にコンテキストを使い切ることもあります。その場合はセクションごとに分割します。

精度を上げるための基本は、次の4点です。

  • PDFをテキストの指示より前に置く
  • 標準的なフォントで、文字がはっきり読める版を使う
  • ページを正しい向きに直す
  • 指示の中では、PDFビューアーが示すページ番号を使う

最後の点は、この手順の根拠ページ欄に直結します。SDSには印刷されたページ番号と、PDFビューアー上の通し番号が食い違うものがあります。プロンプトでは「PDFビューアーのページ番号」と指定し、後の照合でもその番号を使います。

抽出スクリプト — 根拠ページと原文をセットで返させる

要点は三つです。SDSにない項目は推測で埋めさせず、not_foundに書かせること。ばく露限界値は機関名を原文のまま持たせること。すべての値に、ページと原文の引用を付けさせることです。

import base64
import json
import sys
from pathlib import Path
 
import anthropic
 
MODEL = "claude-opus-5-5"
client = anthropic.Anthropic()
 
PROMPT = """このSDS(安全データシート)から、次のJSONだけを出力してください。
ルール:
- SDSに書いてある内容だけを使い、推測や補完をしない
- 各項目に page(PDFビューアーのページ番号)と
  quote(SDSの原文のまま。要約しない)を付ける
- 見つからない項目は空配列にし、not_found に項目名を書く
- hazard には対象(成分名または製品全体)を含める
- ばく露限界値は、機関名をSDSの表記のまま body に入れる
- 保護具は、SDSが挙げているものをそのまま列挙する
 
{"product": "", "supplier": "", "revision_date": "",
 "components": [{"name": "", "cas": "", "content": ""}],
 "ghs": [{"hazard": "", "category": "", "page": 0, "quote": ""}],
 "exposure_limits": [{"body": "", "value": "", "page": 0, "quote": ""}],
 "ppe": [{"item": "", "page": 0, "quote": ""}],
 "not_found": []}
"""
 
 
def extract(pdf: Path) -> dict:
    data = base64.standard_b64encode(pdf.read_bytes()).decode("utf-8")
    msg = client.messages.create(
        model=MODEL,
        max_tokens=4096,
        messages=[{
            "role": "user",
            "content": [
                {"type": "document", "source": {
                    "type": "base64",
                    "media_type": "application/pdf",
                    "data": data}},
                {"type": "text", "text": PROMPT},
            ],
        }],
    )
    if msg.stop_reason == "max_tokens":
        raise RuntimeError("出力が途中で切れました")
    text = "".join(b.text for b in msg.content if b.type == "text")
    text = text.strip().removeprefix("```json").removesuffix("```")
    return json.loads(text)
 
 
for pdf in sorted(Path(sys.argv[1]).glob("*.pdf")):
    try:
        out = extract(pdf)
    except (json.JSONDecodeError, RuntimeError) as e:
        print(f"要手作業: {pdf.name} ({e})")
        continue
    pdf.with_suffix(".json").write_text(
        json.dumps(out, ensure_ascii=False, indent=2), encoding="utf-8")
    print(f"抽出: {pdf.name}")

実行は、SDSを1フォルダにまとめて、そのフォルダを渡すだけです。

pip install anthropic
export ANTHROPIC_API_KEY="sk-ant-..."
python extract_sds.py ./sds

JSONとして読めなかった製品や、出力が途中で切れた製品は、その場で「要手作業」と表示して飛ばします。成分が多い混合物のSDSは、max_tokensを引き上げて再実行する対象です。

原文との突き合わせ — 引用が本当にそのページにあるかを確かめる

Claudeが返したquoteが実在するかどうかは、スクリプトで確かめられます。SDSのPDFにテキスト層があれば、ページごとにテキストを取り出し、引用が指定ページに含まれるかを見るだけです。ここでは、pdftotext(poppler)を使います。

import json
import re
import subprocess
import sys
from pathlib import Path
 
 
def norm(s: str) -> str:
    return re.sub(r"\s+", "", s)
 
 
def page_texts(pdf: Path) -> list[str]:
    out = subprocess.run(
        ["pdftotext", "-layout", str(pdf), "-"],
        capture_output=True, text=True, check=True).stdout
    pages = out.split("\f")
    if pages and not pages[-1].strip():
        pages.pop()
    return [norm(p) for p in pages]
 
 
def verify(pdf: Path) -> dict:
    rec = json.loads(pdf.with_suffix(".json").read_text(encoding="utf-8"))
    texts = page_texts(pdf)
    rec["text_layer"] = any(texts)
    for key in ("ghs", "exposure_limits", "ppe"):
        for row in rec[key]:
            p = row["page"]
            row["verified"] = (
                rec["text_layer"]
                and 1 <= p <= len(texts)
                and norm(row["quote"]) in texts[p - 1]
            )
    return rec
 
 
for pdf in sorted(Path(sys.argv[1]).glob("*.pdf")):
    if not pdf.with_suffix(".json").exists():
        continue
    pdf.with_suffix(".json").write_text(
        json.dumps(verify(pdf), ensure_ascii=False, indent=2),
        encoding="utf-8")
    print(f"照合: {pdf.name}")

抽出で.jsonができたフォルダに対して、続けて実行します。結果は同じ.jsonに書き戻されます。pdftotextは、macOSならHomebrewのpoppler、Debian系ならpoppler-utilsで入ります。

python verify_sds.py ./sds

照合の結果は、3通りに読み分けます。

  • verifiedが真: 引用がそのページに実在します。値の読み違いは残りうるので、人の確認は省きません。
  • verifiedが偽: 引用が見つからないか、ページが違います。多段組や表の崩れで偽になる場合もあるため、誤りと決めつけず、人がPDFの該当箇所を開きます。
  • text_layerが偽: スキャン画像のSDSで、照合自体ができません。全項目を目視で確認します。

この照合で確かめられるのは、あくまで「引用の実在」です。区分の数字を拾い間違えていても、引用のほうが正しければ照合は通ります。そのため、人が見るときは値と引用の二つを並べて読み比べます。

実施シートへの流し込み — 人の欄を空のまま残す

最後に、照合済みのJSONを一覧のCSVに書き出します。ここで大事なのは、現場条件と判断の列を空のまま出力することです。Claudeに埋めさせない列は、スクリプトの側でも触りません。

import csv
import json
import sys
from pathlib import Path
 
CLAUDE_COLS = ["製品名", "供給者", "SDS改訂日", "成分(CAS)", "GHS区分",
               "ばく露限界値", "SDS記載の保護具", "根拠ページ",
               "照合結果", "SDSに記載なし"]
HUMAN_COLS = ["作業内容", "取扱量・含有率", "換気", "作業時間・頻度",
              "見積り方法", "リスクレベル", "低減措置", "実施者", "承認日"]
 
 
def row(rec: dict) -> list[str]:
    rows = rec["ghs"] + rec["exposure_limits"] + rec["ppe"]
    ok = rec["text_layer"] and all(r["verified"] for r in rows)
    return [
        rec["product"], rec["supplier"], rec["revision_date"],
        "; ".join(f'{c["name"]}({c["cas"]})' for c in rec["components"]),
        "; ".join(f'{g["hazard"]} {g["category"]}' for g in rec["ghs"]),
        "; ".join(f'{e["body"]} {e["value"]}' for e in rec["exposure_limits"]),
        "; ".join(p["item"] for p in rec["ppe"]),
        ", ".join(sorted({str(r["page"]) for r in rows})),
        "照合済み" if ok else "要目視",
        ", ".join(rec["not_found"]),
    ] + [""] * len(HUMAN_COLS)
 
 
records = [json.loads(Path(p).read_text(encoding="utf-8"))
           for p in sys.argv[2:]]
with open(sys.argv[1], "w", newline="", encoding="utf-8-sig") as f:
    w = csv.writer(f)
    w.writerow(CLAUDE_COLS + HUMAN_COLS)
    w.writerows(row(r) for r in records)

出力先のCSVと、照合済みのJSONを順に渡します。

python to_csv.py sheet.csv ./sds/*.json

照合結果の列が「要目視」の行は、人が最初に開く順番の先頭にします。出力されたCSVは、自社のシートの列に貼り付けるか、様式に合わせて列名を直して使います。

間違えやすいところ

落とし穴

混合物のSDSは成分ごとに区分が違う

成分ごとの区分と製品全体の区分の両方が書かれているSDSでは、どちらの値を拾ったのかが分かるよう、プロンプトのhazardには対象(成分名または製品全体)を含めて書かせています。後から読み違えにくくなります。

落とし穴

ばく露限界値は機関が違えば値も違う

SDSに複数の機関の値が並ぶ場合、Claudeに「一つに絞らせる」のは避けます。スクリプトのbodyに機関名を原文のまま持たせているのはそのためです。どの値を評価に使うかは、見積りの方法とあわせて人が決めます。

PDFそのものが処理できずAPIがエラーを返したときの切り分けは、「Could not process PDF」400エラーの原因と対処法にまとめています。

SDSの版も見落としやすい点です。抽出スクリプトはrevision_dateを拾いますが、それが最新かどうかはPDFだけでは分かりません。供給者から新しいSDSが届いていないかは、人が確かめる項目としてシートに残します。

繰り返し使うときのコスト

PDFのトークン数は、ページ数と内容の密度で変わります。テキスト分が1ページあたり1,500〜3,000トークン程度で、これに画像としての読み取り分が加わります。PDFだけの追加料金はなく、通常のAPI料金で計算されます。自社の典型的なSDSで実測するには、トークンカウントのAPIが使えます。

同じSDSに質問を変えて何度も問い合わせる場合は、PDFのブロックにcache_controlを付けて、プロンプトキャッシュを使います。保護具だけ、限界値だけといった追加の質問を後から投げる運用のときに効きます。

数十製品を一度に処理する棚卸しでは、結果を急がなければ、Message Batches APIとの組み合わせも選べます。PDFを1件ずつ抜き出す設計は、契約台帳をPDFから一括抽出する手順と同じ型です。契約書のように、抽出結果のスキーマを先に固めてから流す考え方は、契約書PDFから履行義務を抜き出す手順にあります。

まとめ

SDSの転記を機械に任せても、リスクアセスメントそのものは自動化されません。それでも、転記の誤りを引用の照合で拾え、人の時間を現場条件の入力と低減措置の判断に回せる点に、この構成の価値があります。

導入の順番としては、まず1製品でCSVを出し、自社のシート様式との列の差を見るところから始める形が現実的です。原因の確定を人に残すという同じ線引きは、顧客クレームの8D報告書を下書きする手順でも使えます。

この記事を共有:XはてブLinkedIn