中小製造業の受注は、整った形では届きません。FAXの紙、電話の口頭、メールの本文——形式もフォーマットもバラバラな、非構造なデータが入ってきます。取引先ごとに書き方が違い、同じFAXでも手書きと印字が混ざり、電話では図番が口頭で飛んでくる。標準的な受注SaaSがなかなかフィットしないのは、この「入口の非構造さ」が理由でした。だからこそ、この非構造を構造化することこそ、我々のやるべきことです。
やることは一貫しています。入口が3つあっても、構造化した先の業務フローは1本にする。FAXの画像も、電話の音声も、メールのテキストも、いったん受注データへと変換してしまえば、あとの処理——見積・承認・検査——は入口を区別しません。3系統のロジックを並走させず、変換の部分だけを入口ごとに用意して、あとは合流させる。
とはいえ、3つの入口は「確実性」が異なります。メール本文は文字そのものなので正確ですが、かすれたFAXや聞き取りにくい電話は、読み間違いが起こる。だから、ただ合流させるだけでなく、入口ごとにどれだけ正しく読めたかを測り、それを信頼度スコアとして承認の際に反映させます。
この記事では、その合流の作り方、入口ごとの信頼度の扱い、そして外へ返す出口までを、実際のコードと共に見ていきます。
この業務システムの設計に興味がある方は、以下の記事を参照してください。

また、環境構築を含めた実装の部分について知りたい方は、以下の記事をご覧ください。

入口は3つ業務フローは1本
チャネル抽象化(Channel Abstraction)——入口の違いをすぐに合流させて、業務フローに持ち込まない設計です。
なぜ「チャネル抽象化」なのか?
もし入口ごとに処理を分けて書くと、FAX用・電話用・メール用の3系統が並走し、見積や承認のロジックが3重にメンテナンスされます。
それは完全に非効率なので、入口の直後で「受注データ」という共通の形に落とし、そこから先は1本にするとう形にします。この設計であれば、例え新しい入口が増えたとしても、変換を1つ足すだけで既存のフローに乗せることができます。

すべてのチャネルを1つの関数に合流させる
合流点は run_intake ひとつです。メールも、Vision で読みこんだFAXも、文字起こしされた電話も、ここに raw_text として集まり、同じグラフ上を走ります。
def run_intake(channel, raw_text, source_ref=None, input_confidence=None):
# 受注が生まれる唯一の場所。緊急停止を最後に守る砦でもある。
if is_halted():
raise RuntimeError("system halted — 新規の受注は取り込まない")
order_id = db.table("orders").insert(
{"channel": channel, "status": "received", "source_ref": source_ref}
).execute().data[0]["id"]
state = {"order_id": order_id, "raw_input": raw_text}
result = get_graph().invoke(state, ...) # ← 入口によらず同じグラフ3つの入口をどう構造化するか?
合流点に届くまでに、各入口が「非構造」を「テキスト」に変換します。3つとも、変換と同時にどれだけ正しく読めたかを測って返すのが共通する作法です。
メール ── IMAPと二重処理対策
メールは IMAP で受信します。ここでいちばん怖いのは、ポーリングが同じメールを二度取り込み、受注を二重で受けてしまうことです。そこで Message-ID をキーにした受信台帳 email_intake に、処理の前に行を確保して、2度目に受信した場合は弾きます。処理に失敗しても行は status='failed' で残り、追跡できます。
# 処理の“前”に Message-ID を確保する。二度目は一意制約で弾かれる(冪等性)
def _claim(message_id, uid, mailbox, from_addr, subject) -> bool:
try:
db.table("email_intake").insert({
"message_id": message_id, "uid": uid, "mailbox": mailbox,
"from_addr": from_addr, "subject": subject, "status": "processing",
}).execute()
return True # 確保できた=初めて見るメール
except UniqueViolation:
return False # 既に取り込み済み → スキップさらに2つ処理を施しました。自分が送ったメールは読まない(返信で無限ループしないよう送信元を除外)。そして解析に失敗しても生のメール(.eml)は保存する——FAXの添付画像も同様に原本を残し、後から人が確認できるようにします。
今からIMAPでの受信・SMTPでの送信を組む場合は、OAuthでの接続を前提にしてください。Google Workspaceは2025年からパスワードだけのアプリに対応しておらず、Microsoft 365はIMAPの基本認証がすでに使えず、SMTPの基本認証も廃止が予定されています。
FAX ── 画像をClaude Visionで読む
FAXはインターネットFAXでメール添付の画像として届きます。Claude Visionが受け付ける画像はJPEG・PNG・GIF・WebPだけなので、TIFFで届く場合はPNGなどに変換してから渡します。PDFで届く場合は、画像ではなくPDFとして渡します。
これをClaude Vision(画像を渡して読ませるマルチモーダル)で構造化します。ポイントは、Claude にそのシートの読み取りやすさ(legibility 0..1)も一緒に返させること。かすれたFAXは、定数ではなく証拠に基づいて信頼度を下げられます。
読み取りの精度と料金は、画像の大きさで変わります。Claude 4.7以降のモデルは長い辺2576ピクセルまで細かく読み、それ以外のモデルは1568ピクセルまでです。画像のトークン数は縦横をそれぞれ28ピクセルで割った数の積で、大きい画像ほど料金が上がります。
def fax_to_text(data: bytes, media_type: str) -> tuple[str, float | None]:
"""FAXの注文書を読む。 (テキスト, legibility 0..1 or None) を返す。"""
raw = complete_with_media(_PROMPT, data, media_type, label="read_fax")
result = extract_json(raw)
return result["text"], float(result.get("legibility")) # 読みやすさも測る
電話 ── 録音をWhisperで文字起こし
電話は、AIが対話で応答する方式は採りません。
人がいつも通り受け、その通話を録音 → Whisper で文字起こし → 同じ合流点へ、という事後処理型です。リアルタイム対話AIの弱点(聞き返し・誤認)を、人が受けることで回避します。文字起こしは faster-whisper をローカルで回し、Whisper の avg_logprob(1トークンあたりの平均対数確率)から 0..1 のASR信頼度を作って返します。
文字起こしをOpenAIのAPIに替える場合、今の推奨はwhisper-1ではなく gpt-transcribe(1分あたり0.0045ドル)です。gpt-4o-transcribe・gpt-4o-mini-transcribe は出力の形式がJSONだけで、ここで使うセグメントごとの avg_logprob は返らないため、信頼度は include に logprobs を指定して取ります。
def transcribe(data: bytes) -> tuple[str, float | None]:
"""音声→テキスト。 (テキスト, asr_confidence 0..1 or None) を返す。"""
segments, _ = _model().transcribe(...) # faster-whisper(ローカル)
# 各セグメントの avg_logprob を exp して 0..1 に均す=聞き取りの確からしさ
...電話の音声データを録音してそのまま文字化すると、以下のようになります。
あ、もしもし。三河機械工業の山田です。いつもお世話になってます。
えーっと、注文をお願いしたくて。
図番がですね……ビー・アール・ケーの、4471、アール2、ブラケットなんですけど。
材質はエー・5052で、数量は30個でお願いします。
単価は、前回と同じで大丈夫です。
納期は、今月末……えっと、8月28日でお願いできますか。
あ、それともう一件、図番シー・エル・アール3007、カラーのやつ、これも300個、追加でお願いします。こっちも前回同条件で。
はい、じゃあ、よろしくお願いします。失礼します。
このぐらいのレベルであれば、問題なく構造化してくれます。
| drawing | item | qty | unit price | due |
|---|---|---|---|---|
| BRK-4471-R2 | ブラケット | 30 | (quote) | 2025-08-28 |
| CLR-3007 | カラー | 300 | (quote) | 2025-08-28 |
order 0c051d98-4f2e-4ebf-97d0-32d44bcf478e · status classified · partner — · confidence 0.6
入口ごとに「信頼度」を変える
3つの入口は、精度の「信頼度」が違います。メール本文は文字そのものなので正確、FAXはかすれ、電話は聞き間違いが起きる。この違いを、後の承認工程に反映させます。
チャネルで信頼度の天井を決める
各チャネルに信頼度の天井を設けました。そして最終的な信頼度は、分かっている値のうち最も悲観的なものを採ります——構造化の自信・入口の読み取り精度(Vision の legibility や Whisper の確度)・チャネル上限、この最小値です。
# 入口が不確かなほど天井を下げる。電話は人の承認を厚くする方針。
_CHANNEL_MAX_CONFIDENCE = {"email": 1.0, "fax": 0.9, "phone": 0.8}
def _final_confidence(channel, structured, input_conf) -> float:
known = [v for v in (structured, input_conf) if v is not None]
base = min(known) if known else 0.5
return round(min(base, _CHANNEL_MAX_CONFIDENCE.get(channel, 1.0)), 3)低い信頼度は、承認画面で警告として現れ、人の確認を促します。「電話は信頼度を低く扱い、人の承認を厚く」という安全設計が、ここに表れています。
推測で埋めさせない
もう一つポイントとしては、構造化プロンプト(FAX用の read_fax など)には推測で欄を埋めさせない指示を入れています。読めない図番を、それらしく創作されるのが一番危ないからです。これは多くの経営者もAIに対して抱えている懸念点でしょう。
読めなければ空欄のまま人に渡す——確からしさを測るのと同じ思想です。
構造化に使うClaudeの古いモデルは、今はAPIで呼べなくなっています。Claude Sonnet 4・Opus 4は2026年6月15日、Opus 4.1は2026年8月5日に提供を終えました。今から作る場合は移行先のモデルと料金をAnthropicの公式ドキュメントで確かめ、Opus 4.7以降のモデルには temperature・top_p・top_k を付けずに送ってください(付けるとエラー(400)になります)。
出口の設計──受信チャネルで返す
入口を1本化したように、出口の設計も考えています。外へ出る場合は失敗が許されないので慎重に設計します。
返信は受信チャネルで返す
返信は原則的に受信したチャネルで返します。
FAXで来たらFAX、メールで来たらメール。受信チャネルはメタデータ(fax/email/phone)として受注データに保存してあります。ただし電話だけは自動で返答することが現実的でない、また、返信に適したチャネルが明白ではないので、取引先マスタの既定チャネルにフォールバックします。
帳票はPDFで生成する
受注書・見積書・請求書は、Jinja2 テンプレートで HTML を組み、Playwright(ヘッドレスChromium)でPDFに変換します。
テンプレートから決定論的に組むので、レイアウトがブレません。注意点として、Linux本番ではCJKフォント(fonts-noto-cjk 等)を入れておかないと日本語が四角(□)になります。Playwright は遅延importにして、この重い依存を使わない経路に持ち込まないようにしています。
def render_pdf(doc_type: str, context: dict) -> bytes:
from playwright.sync_api import sync_playwright # 遅延import
html = render_html(doc_type, context) # Jinja2(受注書/見積書/請求書)
with sync_playwright() as p:
... # HTML → PDF bytes以下が生成されたPDFファイルのサンプルです。

送信するのは検証してから ── dry-runで安全に確かめる
メールにおけるドキュメントの外部送信は、既定ではオフになっています。
環境変数 MAIL_ENABLED が厳密に "true" のときだけ実際に送信し、それ以外はすべてdry-run(ログには出すが、実際には送信しない)します。緊急停止と同じ「手動運用への戻り道」の思想です。しかも send_mail はSMTPで失敗してもエラーを発しません——通知の失敗が受注処理そのものを止めることを避けるためにこうした仕様となっています。
def mail_enabled() -> bool:
return os.environ.get("MAIL_ENABLED", "").strip().lower() == "true"
def send_mail(to, subject, body, attachments=None) -> dict:
if not mail_enabled():
print(f"[MAIL] dry-run to={to} subject={subject!r}") # 送らずログだけ
return {"sent": False, "reason": "dry_run"}
...まとめ
非構造なワークフローの入口を綺麗に捌くコツは、入口の違いを早めに消して、業務フローを1本化することです。
FAXはVisionで、電話はWhisperで、メールはIMAPで——それぞれテキストに変え、同時に「どれだけ正しく読めたか」を測る。合流点は run_intake ひとつで、緊急停止の砦もそこに置く。入口ごとの信頼度の最大値を設定して、信頼度が低い場合は人間の確実なチェックを促す。
入口は違っても、通す道は一本。「非構造を構造化する」とは、結局この一本道にすべてを合流させることです。
この記事は2026年9月に、Claudeのモデル・画像の扱い・文字起こしのAPI・メールの認証を今の状態に合わせて見直しました。
愛知・岐阜・三重の製造業の業務を、無料でAI診断します
担当者が御社へお伺いし、業務の流れを見させていただいたうえでヒアリングを行います。業務ごとの自動化案と想定される効果を、A4一枚にまとめてお渡しします。
対象エリア:愛知・岐阜・三重
診断レポートで分かること
- 業務ごとの削減時間と、人件費に換算した効果
- 人件費以外の効果(属人化の解消、納期回答の早さなど)
- 自動化しないほうがよい業務
- どの業務から始めるかの進め方
ご用意いただくもの
- 普段お使いの書類の見本(注文書・見積書・Excelなど)
- 業務のご担当者の同席(30分程度でも構いません)
社外に出せない書類は、その場で拝見するだけで持ち帰りません。


