メールからのJSON抽出:スキーマの妥当性が真実性を保証しない理由と自動化の保護策
JSON Schemaに対する構文的な適合性が、抽出されたデータの事実としての正確性(真実性)を保証しない技術的理由を解説します。LLMの構造化出力(Structured Outputs)が防げるのは構文エラーのみであり、ハルシネーションや文脈上の曖昧さまでは防げません。本記事では、社内プロダクションシステムへ安全にデータをルーティングする前に、厳格な正規表現チェックと人間による確認(Human-in-the-loop)を組み合わせた2段階のアプリケーション検証パイプラインを構築・運用する実践的なアプローチを提示します。
目次

現代のLLMにおけるStructured Outputs(構造化出力)機能は、モデルの応答がJSON Schemaの規約やPydanticモデルに厳密に従うことを保証し、エンジニアリング上の根本的な課題を解決しました。しかし、スキーマとして妥当(valid)であることは、ドキュメントの構文的な形状を保証しているにすぎず、その内容が事実として正しい(truth)かどうかまでは保証しません。
公式のGoogle Gemini API documentation on Structured Outputsでも明示的に警告されているように、出力が構文的に正しくても、開発者はアプリケーション側で値を検証し、意味的な不整合(セマンティックな矛盾)に対処する必要があります。スキーマはパーサーをJSONDecodeError例外から守ってはくれますが、ハルシネーション(幻覚)や誤った推論、あるいは見落とされた条件付き表現の前には無力です。
本記事では、カスタマーサポートに届く問い合わせメールの処理を題材に、生のテキストをAPIに渡すところから、アプリケーション層でのセマンティックな検証、そして安全なフィールドルーティングに至るまでの一連のエンドツーエンドのシナリオを解説します。
1. 入力データ:曖昧さを含むサポートメール
自然言語特有の典型的な曖昧さ(条件付きの期限や未確認の前提)を含むようにあらかじめ設計された、以下の合成(サンプル)サポートメールを考えてみます。
email_text = """От: alex.m@partner-example.com
Тема: Проблема с выгрузкой отчетов и планы по релизу
Привет! У нас со вчерашнего дня сбоит генерация PDF в биллинге.
Мы хотели бы закрыть этот вопрос как можно быстрее, в идеале к концу недели,
но если ваш релиз 2.4 задерживается, то крайний срок сдвигается на следующий вторник.
Кстати, затронут ли европейский кластер платежей eu-central-1, мы пока точно не знаем — коллеги еще перепроверяют трейсы."""
この本文には、2つの決定的なニュアンスが含まれています。
- 条件付きの期限(Conditional deadline):締め切りが外部要因(リリース2.4の遅延)に依存しており、絶対的なカレンダー日付ではなく「来週の火曜日(следующий вторник)」という相対的な表現で記載されています。
- 未確認の事実(Unconfirmed fact):
eu-central-1クラスターへの言及は、トレースログの再確認を待つ単なる疑問や仮説にすぎません。また、該当の文字列は途中に改行を含まない形式になっており、完全な部分文字列マッチングが可能です。
2. Pydanticスキーマの定義とInteractions API経由での呼び出し
Pydanticを用いてデータ抽出の規約(コントラクト)を定義し、Google GenAI SDKの公式インターフェース経由でモデルを呼び出します。
from typing import Optional
from google import genai
from pydantic import BaseModel, Field
class TicketExtraction(BaseModel):
issue_summary: str = Field(
description="Краткая суть проблемы."
)
deadline_iso: Optional[str] = Field(
default=None,
description="Однозначно зафиксированный срок в формате YYYY-MM-DD. Если дата не определена точно или зависит от условий, вернуть null."
)
deadline_context: Optional[str] = Field(
default=None,
description="Оговорки, условия или контекст, сопровождающие срок."
)
affected_cluster: Optional[str] = Field(
default=None,
description="Точно подтвержденный затронутый кластер или сервис. Если факт не подтвержден или выражено сомнение, вернуть null."
)
raw_quote: Optional[str] = Field(
default=None,
description="Точная цитата из текста, обосновывающая извлеченные факты."
)
client = genai.Client()
prompt = f"""Извлеки параметры инцидента из письма.
Строго следуй правилу: если автор выражает сомнение или значение зависит от условий,
записывай в соответствующее поле null, а контекст выноси в deadline_context.
Письмо:
{email_text}"""
interaction = client.interactions.create(
model="gemini-3.8-flash",
input=prompt,
response_format={
"type": "text",
"mime_type": "application/json",
"schema": TicketExtraction.model_json_schema(),
},
)
live_extraction = TicketExtraction.model_validate_json(interaction.output_text)
3. 典型的な失敗例:構文上の成功と意味上の失敗
以下のスニペットは、特定のAPI実行結果を保証するものではなく、起こりがちな論理的エラーを説明するための仮定の例です。プロンプトで指定されたセマンティックな制約を無視しながらも、モデルがいかに形式上は妥当なJSONを返してしまうかを示しています。
{
"issue_summary": "Сбой генерации PDF в биллинге",
"deadline_iso": "следующий вторник",
"deadline_context": "в идеале к концу недели, но если релиз 2.4 задерживается, то следующий вторник",
"affected_cluster": "eu-central-1",
"raw_quote": "затронут ли европейский кластер платежей eu-central-1, мы пока точно не знаем"
}
JSON SchemaバリデータやPydanticライブラリの観点から見れば、このオブジェクトには何ら不備がありません。すべてのキーが存在し、型も一致しています。しかし、後続の自動化処理にとっては致命的なエラーが含まれています。
- 文字列型が任意のテキストを許容してしまう理由:
生成されたスキーマにおいて、
Optional[str]フィールドはanyOf構造または型のユニオン(stringとnull)に変換されます。スキーマバリデータにとって、"следующий вторник"(来週の火曜日)という値は完全に正当な文字列です。description内の指示文はパーサーレベルの制約にはならないため、空でない任意の文字列がそのまま検証を通過してしまいます。 - 仮説の確定事実化:
モデルは発言の疑問符や推測という性質を無視し、
eu-central-1という識別子を確定事項として抽出してしまいました。 - プロンプト指示による
null返却の脆弱性: 不確実な場合はnullを返すようにプロンプトで厳格に指示していても、対象のエンティティ名がソーステキスト内に明示的に現れると、エンティティ抽出のバイアスが勝ってしまいがちです。
4. 単純なチェック手法の限界:引用検証・ストップワード・インフラ台帳
開発者はヒューリスティクスを用いてLLMの意味的な信頼性の低さを補おうとすることがよくあります。しかし、安易なアプローチでは安全性を保証できない理由がここにあります。
- 引用の存在確認(
raw_quote in email_text): 今回の例では、"затронут ли европейский кластер платежей eu-central-1, мы пока точно не знаем"(決済欧州クラスターeu-central-1が影響を受けているかはまだ正確には分からない)というフレーズはメール本文と完全に一致する連続した部分文字列であるため、この判定はTrueになります。しかし、テキスト内に文字通り存在していることは、引用がハルシネーションではないことを証明しているにすぎません。意味論的にはむしろその逆で、送信者が障害の実際の発生に確信を持っていないことを証明しています。 - ヒューリスティックなストップワード判定:
特定のマーカー単語(ロシア語の助詞
"ли"や、"возможно"、"не знаем"などのフレーズ)を検索する手法は極めて脆弱です。特に短いマーカーは他の単語に含まれて誤検知を引き起こしやすく、文脈やモダリティ(文の確信度)が全く異なる文でもヒットしてしまいます。 - インフラのホワイトリスト(Allowed Clusters)との照合:
社内のサーバー台帳と照らし合わせて
eu-central-1の存在を確認しても、それは該当するクラスターが実在すること(エンティティの特定)を裏付けるだけです。そのノードで障害やインシデントが実際に発生したこと(障害イベント)を証明するものではありません。外部の信頼できる証拠(監視シグナルや確認済みの監視アラートなど)がなければ、受信メール内でのクラスターへの言及は未検証の仮説の域を出ません。
5. アプリケーション検証パイプラインと安全なルーティング
内部の後続システムで障害が発生するのを防ぐため、アプリケーション側で第2の検証レイヤーを実装する必要があります。このレイヤーでは、日付形式を独立して検証し、疑わしいエンティティを排除し、不完全または曖昧なデータを人間のレビュー担当者へとルーティングします。
Pythonのdate.fromisoformat()ヘルパー関数は、それ単体では厳格なYYYY-MM-DD形式への準拠を保証しません(近年のPythonバージョンでは拡張ISO文字列も受け入れられます)。したがって、形式チェックには厳格な正規表現と、それに続くカレンダー日付パース処理を組み合わせる必要があります。
このバリデータの挙動を実演するため、先ほどの仮定の無効なJSONから初期化された独立したdemo_extractionオブジェクトを使用します。
import re
from datetime import date
from typing import Any, Optional
from pydantic import BaseModel, Field
class ValidationResult(BaseModel):
is_safe_for_automation: bool
confirmed_deadline: Optional[date] = None
confirmed_cluster: Optional[str] = None
review_reasons: list[str] = Field(default_factory=list)
downstream_payload: dict[str, Any] = Field(default_factory=dict)
def validate_and_route_ticket(
extracted: TicketExtraction,
source_text: str
) -> ValidationResult:
reasons: list[str] = []
if extracted.raw_quote and extracted.raw_quote not in source_text:
reasons.append("Указанная цитата отсутствует в исходном письме.")
parsed_date: Optional[date] = None
if extracted.deadline_iso:
if not re.fullmatch(r"\d{4}-\d{2}-\d{2}", extracted.deadline_iso):
reasons.append(
f"Значение deadline_iso не соответствует строгому формату YYYY-MM-DD: '{extracted.deadline_iso}'."
)
else:
try:
parsed_date = date.fromisoformat(extracted.deadline_iso)
except ValueError:
reasons.append(
f"Значение deadline_iso содержит недопустимую календарную дату: '{extracted.deadline_iso}'."
)
parsed_date = None
if parsed_date and extracted.deadline_context:
reasons.append(
f"Срок {parsed_date.isoformat()} сопровождается оговорками ('{extracted.deadline_context}') и требует ручного согласования."
)
parsed_date = None
validated_cluster: Optional[str] = None
if extracted.affected_cluster:
reasons.append(
f"Кластер '{extracted.affected_cluster}' упомянут в письме как предположение; без внешнего подтверждения из мониторинга требуется проверка оператором."
)
needs_manual_review = len(reasons) > 0 or parsed_date is None or validated_cluster is None
safe_payload = {
"summary": extracted.issue_summary,
"deadline": parsed_date.isoformat() if parsed_date else None,
"cluster": validated_cluster,
"routing_status": "MANUAL_REVIEW" if needs_manual_review else "AUTO_APPROVED",
}
return ValidationResult(
is_safe_for_automation=not needs_manual_review,
confirmed_deadline=parsed_date,
confirmed_cluster=validated_cluster,
review_reasons=reasons,
downstream_payload=safe_payload,
)
hypothetical_bad_json = """{
"issue_summary": "Сбой генерации PDF в биллинге",
"deadline_iso": "следующий вторник",
"deadline_context": "в идеале к концу недели, но если релиз 2.4 задерживается, то следующий вторник",
"affected_cluster": "eu-central-1",
"raw_quote": "затронут ли европейский кластер платежей eu-central-1, мы пока точно не знаем"
}"""
demo_extraction = TicketExtraction.model_validate_json(hypothetical_bad_json)
decision = validate_and_route_ticket(demo_extraction, email_text)
6. 最終状態:手動レビューとフィールドの隔離
アプリケーション側の検証が実行された後、decisionオブジェクトは厳格に制御された状態へと遷移します。
{
"is_safe_for_automation": false,
"confirmed_deadline": null,
"confirmed_cluster": null,
"review_reasons": [
"Значение deadline_iso не соответствует строгому формату YYYY-MM-DD: 'следующий вторник'.",
"Кластер 'eu-central-1' упомянут в письме как предположение; без внешнего подтверждения из мониторинга требуется проверка оператором."
],
"downstream_payload": {
"summary": "Сбой генерации PDF в биллинге",
"deadline": null,
"cluster": null,
"routing_status": "MANUAL_REVIEW"
}
}
信頼レベルに応じたフィールドのトリアージ
| フィールド | ルーティング状態 | 根拠 |
|---|---|---|
summary | ドラフトチケット | 請求処理でのPDF生成の不具合が明示的に記載されています。このフィールドは、課題管理ツールの一時的なチケット説明として設定しても安全です。 |
cluster | ブロック(null) | クラスターへの言及は送信者の未確認の推測にすぎません。誤報を防ぐため、インフラのオンコール当番への自動割り当てはブロックされます。 |
deadline | ブロック(null) | 非構造化された文言はカレンダー日付ではなく、期限自体も条件付きです。厳格なSLAに自動で紐付ける処理はブロックされます。 |
routing_status | オペレーターキュー | MANUAL_REVIEWステータスは自動トリガーが保留されたことを示し、個別の確認理由リストとともにチケットを一次対応担当者へルーティングします。 |
SLAの算出、優先度のエスカレーション、専門のエンジニアリングチームへのルーティングを行う前に、人間による確認が必須となります。アプリケーションは報告された問題の要約を含むドラフトチケットを作成できますが、重要インシデントの属性はオペレーターが明示的に確認するまで未設定のまま保持されます。
まとめ
Structured Outputsはスキーマレベルでの形式の安定性を確保し、予期しない構文エラーから後続のコードを保護します。しかし、スキーマ契約への準拠はデータの真実性と同義ではありません。LLMをビジネスプロセスに安全に組み込むには、2層のアーキテクチャが必要です。API境界での構文の強制、アプリケーションコード内での厳格な正規表現と型チェック、そして不確実なフィールドを人手によるレビューキュー(Human-in-the-loop)へ隔離する仕組みの導入が不可欠です。