引用 ID が存在しても回答の正しさは確定しない
検索結果を参照して回答を生成する場合、回答中の引用 ID が今回取得した資料に存在することと、その資料が主張を支持することは別の検証条件です。引用形式、参照先の存在、意味的な支持関係を分けて検証する考え方は、検証対象・検証単位・判定規則・保証範囲を明確にする設計に基づきます[1]。
例えば、回答が「検証環境の API は HTTP 500 を返しました」と記述し、引用先の実行ログには「HTTP 200」と記録されていた場合、引用 ID の照合だけではこの不一致を検出できません。
本記事では、主張と引用資料の対応を 1 件ずつ検証し、 独立した根拠確認が完了した回答だけを ACCEPT とする Python の判定プログラムを作成します。意味的な支持関係は人間などの別工程で確認し、Python は確認結果と対象データの一致を検査します。
Python で検証する対象と実行環境
動作確認には Python 3.13.5 を使用しました。外部パッケージ、ネットワーク接続、認証情報は必要ありません。次のコマンドで Python のバージョンを確認します。
python3 --version
今回の入力は 3 種類の JSON ファイルです。json は Python 標準ライブラリの JSON 処理機能です[2]。
| ファイル | 内容 | 入力元の役割 |
|---|---|---|
answer.json |
主張 ID、主張本文、引用 ID | 回答生成工程の出力 |
sources.json |
今回取得した資料の ID と内容 | 検索工程の出力 |
reviews.json |
主張ごとの支持確認結果と対象の指紋値 | 独立した確認工程の出力 |
1 件の主張に 1 件の引用を対応させる最小構成です。複数資料で 1 件の主張を支持するケースは対象外とします。
引用と根拠の検証契約を定義する
検証対象と検証単位
検証対象は引用付き回答、検証単位は主張と引用 ID の組です。たとえば、C1 という主張が S1 を引用した場合、S1 が今回取得した資料に存在することを確認し、C1 と S1 の組に対する根拠確認結果を照合します。
reviews.json の verdict は supported(支持する)または unsupported(支持しない)のどちらかです。未確認の主張には確認結果のレコードを作成しません。
ACCEPT・REVIEW・REJECT の判定規則
| 条件 | 判定 | 理由 |
|---|---|---|
全主張の引用 ID が有効で、対象の内容に対応する supported がそろう |
ACCEPT |
定義済みの受理条件を満たす |
| 必要な確認結果が欠落している、または以前の確認後に主張・資料が変更された | REVIEW |
現在の内容に対する支持確認が残っている |
JSON の項目不正、ID 重複、存在しない引用、確認済みの unsupported
|
REJECT |
入力契約違反または支持の否定が確定している |
複数の主張が混在する場合、REJECT を最優先し、次に REVIEW、最後に ACCEPT を適用します。
検証結果が保証する範囲
確認結果には、主張 ID、主張本文、引用 ID、引用資料の内容から計算した SHA-256 の指紋値を記録します。これにより、確認済みの対象と現在の対象をバイト列として比較できます。
指紋値は 対象の一致確認 に使います。改ざん防止用の署名や、確認者の身元証明ではありません。reviews.json は回答生成とは独立した信頼できる工程から受け取ることを前提とします。プログラム単体では、確認者が本当に資料を読んだかを証明できません。
Python で引用検証と受理判定を実装する
次の内容を citation_contract.py として保存します。
import argparse
import hashlib
import json
import re
ID_PATTERN = re.compile(r"[A-Za-z0-9][A-Za-z0-9_-]*\Z")
HASH_PATTERN = re.compile(r"[0-9a-f]{64}\Z")
def records(document, key, fields):
if type(document) is not dict or set(document) != {key}:
raise ValueError(f"{key}: 最上位のキーが不正です")
items = document[key]
if type(items) is not list:
raise ValueError(f"{key}: 配列が必要です")
for index, item in enumerate(items):
if type(item) is not dict or set(item) != fields:
raise ValueError(f"{key}[{index}]: 項目が不正です")
return items
def nonempty(value):
if type(value) is not str or not value.strip():
return False
try:
value.encode("utf-8")
except UnicodeEncodeError:
return False
return True
def identifier(value):
return type(value) is str and ID_PATTERN.fullmatch(value) is not None
def fingerprint(claim, source):
target = {
"claim_id": claim["id"],
"claim_text": claim["text"],
"citation_id": claim["citation_id"],
"source_content": source["content"],
}
payload = json.dumps(
target, ensure_ascii=False, sort_keys=True, separators=(",", ":")
).encode("utf-8")
return hashlib.sha256(payload).hexdigest()
def evaluate(answer, sources, reviews):
try:
claims = records(answer, "claims", {"id", "text", "citation_id"})
documents = records(sources, "sources", {"id", "content"})
checks = records(reviews, "reviews", {"claim_id", "fingerprint", "verdict"})
if not claims:
raise ValueError("claims: 主張が空です")
for claim in claims:
if not (identifier(claim["id"]) and nonempty(claim["text"])
and identifier(claim["citation_id"])):
raise ValueError("claims: 主張または引用 ID が不正です")
for document in documents:
if not (identifier(document["id"]) and nonempty(document["content"])):
raise ValueError("sources: 資料が不正です")
for check in checks:
if not (identifier(check["claim_id"])
and type(check["fingerprint"]) is str
and HASH_PATTERN.fullmatch(check["fingerprint"])
and check["verdict"] in ("supported", "unsupported")):
raise ValueError("reviews: 確認結果が不正です")
for values, key, label in (
(claims, "id", "claims"),
(documents, "id", "sources"),
(checks, "claim_id", "reviews"),
):
identifiers = [item[key] for item in values]
if len(identifiers) != len(set(identifiers)):
raise ValueError(f"{label}: ID が重複しています")
source_by_id = {item["id"]: item for item in documents}
claim_ids = {item["id"] for item in claims}
if any(item["citation_id"] not in source_by_id for item in claims):
raise ValueError("claims: 今回取得した資料にない引用 ID があります")
if any(item["claim_id"] not in claim_ids for item in checks):
raise ValueError("reviews: 存在しない主張 ID があります")
except ValueError as exc:
return "REJECT", [str(exc)]
check_by_id = {item["claim_id"]: item for item in checks}
rejected = []
pending = []
for claim in claims:
review = check_by_id.get(claim["id"])
if review is None:
pending.append(f"{claim['id']}: 根拠確認が未実施です")
elif review["fingerprint"] != fingerprint(
claim, source_by_id[claim["citation_id"]]
):
pending.append(f"{claim['id']}: 確認対象の内容が一致しません")
elif review["verdict"] == "unsupported":
rejected.append(f"{claim['id']}: 引用資料が主張を支持しません")
if rejected:
return "REJECT", rejected + pending
if pending:
return "REVIEW", pending
return "ACCEPT", ["すべての主張について支持確認が完了しました"]
def main():
parser = argparse.ArgumentParser(description="引用の検証契約を判定します")
parser.add_argument("answer")
parser.add_argument("sources")
parser.add_argument("reviews")
args = parser.parse_args()
try:
documents = []
for path in (args.answer, args.sources, args.reviews):
with open(path, encoding="utf-8") as stream:
documents.append(json.load(stream))
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
parser.error(str(exc))
status, reasons = evaluate(*documents)
print(json.dumps({"status": status, "reasons": reasons}, ensure_ascii=False, indent=2))
return 0 if status == "ACCEPT" else 1
if __name__ == "__main__":
raise SystemExit(main())
今回取得した資料と引用 ID を照合する
records() は JSON の項目名とデータ型を確認します。evaluate() は主張 ID、資料 ID、根拠確認結果の主張 ID に重複がある場合に REJECT を返します。主張の citation_id が sources.json に存在するかも確認します。
根拠確認結果と主張を対応づける
fingerprint() は主張 ID・本文・引用 ID・資料本文を、キー順序と区切り文字を固定した JSON に変換して SHA-256 を計算します。主張本文または資料本文を変更すると、通常は保存済みの指紋値と一致しなくなり、判定は REVIEW になります。
確認結果の verdict は外部の確認工程が与えた値です。この関数は supported を自動推定しません。
検証状態から受理可否を決定する
引用先の未発見や入力不正を検出した場合は REJECT、既存の確認結果が現在の入力に対応しない場合は REVIEW にします。unsupported が確認済みの主張を含む場合は、同じ回答に未確認の主張があっても REJECT です。
JSON 入力で受理判定を再現する
以下は架空の実行ログを使った例です。主張と引用資料の文字列が対応していることを、独立した確認工程で確認済みという前提で supported を記録しています。
answer.json:
{
"claims": [
{
"id": "C1",
"text": "検証環境の API は HTTP 200 を返しました。",
"citation_id": "S1"
}
]
}
sources.json:
{
"sources": [
{
"id": "S1",
"content": "実行ログ: 検証環境の API は HTTP 200 を返しました。"
}
]
}
reviews.json:
{
"reviews": [
{
"claim_id": "C1",
"fingerprint": "6709ca2e356511f1a0e55ccef6996d8f15a71a74411df7698a0b3823784d19e2",
"verdict": "supported"
}
]
}
保存済みの指紋値は、上記 1 件の主張と資料内容から fingerprint() で計算した値です。実運用では、確認担当者が現在の資料を照合した後、その対象に対応する確認結果を保存します。
python3 citation_contract.py answer.json sources.json reviews.json
出力:
{
"status": "ACCEPT",
"reasons": [
"すべての主張について支持確認が完了しました"
]
}
reviews.json の reviews を空配列 [] に変更すると REVIEW、verdict を unsupported に変更すると REJECT になります。ACCEPT 以外の終了コードは 1、ファイル読込失敗などの引数処理エラーは 2 です。
unittest で受理・保留・拒否を検証する
unittest は Python 標準ライブラリの単体テスト機能です[3]。次のテストを test_citation_contract.py として、プログラムと同じディレクトリーに保存します。
import copy
import unittest
from citation_contract import evaluate, fingerprint
def fixture():
answer = {"claims": [
{"id": "C1", "text": "検証環境の API は HTTP 200 を返しました。", "citation_id": "S1"}
]}
sources = {"sources": [
{"id": "S1", "content": "実行ログ: 検証環境の API は HTTP 200 を返しました。"}
]}
reviews = {"reviews": [
{"claim_id": "C1", "fingerprint": fingerprint(
answer["claims"][0], sources["sources"][0]
), "verdict": "supported"}
]}
return answer, sources, reviews
class CitationContractTests(unittest.TestCase):
def test_t1_all_supported(self):
self.assertEqual(evaluate(*fixture())[0], "ACCEPT")
def test_t2_no_review(self):
answer, sources, reviews = fixture()
reviews["reviews"] = []
self.assertEqual(evaluate(answer, sources, reviews)[0], "REVIEW")
def test_t3_missing_source(self):
answer, sources, reviews = fixture()
answer["claims"][0]["citation_id"] = "S9"
self.assertEqual(evaluate(answer, sources, reviews)[0], "REJECT")
def test_t4_no_citation(self):
answer, sources, reviews = fixture()
answer["claims"][0]["citation_id"] = ""
self.assertEqual(evaluate(answer, sources, reviews)[0], "REJECT")
def test_t5_unsupported(self):
answer, sources, reviews = fixture()
reviews["reviews"][0]["verdict"] = "unsupported"
self.assertEqual(evaluate(answer, sources, reviews)[0], "REJECT")
def test_t6_some_unreviewed(self):
answer, sources, reviews = fixture()
answer["claims"].append({
"id": "C2", "text": "別の主張です。", "citation_id": "S1"
})
self.assertEqual(evaluate(answer, sources, reviews)[0], "REVIEW")
def test_t7_reject_precedes_review(self):
answer, sources, reviews = fixture()
answer["claims"].append({
"id": "C2", "text": "別の主張です。", "citation_id": "S1"
})
reviews["reviews"][0]["verdict"] = "unsupported"
self.assertEqual(evaluate(answer, sources, reviews)[0], "REJECT")
def test_t8_changed_claim_or_source(self):
for target in ("claim", "source"):
with self.subTest(target=target):
answer, sources, reviews = fixture()
if target == "claim":
answer["claims"][0]["text"] = "書き換えられた主張です。"
else:
sources["sources"][0]["content"] = "書き換えられた資料です。"
self.assertEqual(evaluate(answer, sources, reviews)[0], "REVIEW")
def test_t9_malformed_or_duplicate(self):
for variant in ("empty_text", "duplicate_claim", "invalid_review",
"invalid_claim_unicode", "invalid_source_unicode"):
with self.subTest(variant=variant):
answer, sources, reviews = fixture()
if variant == "empty_text":
answer["claims"][0]["text"] = " "
elif variant == "duplicate_claim":
answer["claims"].append(copy.deepcopy(answer["claims"][0]))
elif variant == "invalid_claim_unicode":
answer["claims"][0]["text"] = "\ud800"
elif variant == "invalid_source_unicode":
sources["sources"][0]["content"] = "\ud800"
else:
reviews["reviews"][0]["fingerprint"] = "bad"
self.assertEqual(evaluate(answer, sources, reviews)[0], "REJECT")
if __name__ == "__main__":
unittest.main()
python3 -m unittest -v
Python 3.13.5 で実行した結果、9 件のテストがすべて成功しました。
Ran 9 tests in 0.001s
OK
| テスト | 検証条件 | 期待値 |
|---|---|---|
| T1 | 全主張の支持確認が完了 | ACCEPT |
| T2 | 確認結果が未登録 | REVIEW |
| T3 | 引用 ID が資料一覧に存在しない | REJECT |
| T4 | 引用 ID が空 | REJECT |
| T5 | 根拠確認で不支持と判定 | REJECT |
| T6 | 複数主張の一部が未確認 | REVIEW |
| T7 | 不支持と未確認が混在 | REJECT |
| T8 | 確認後に主張本文または資料内容が変化 | REVIEW |
| T9 | 空の主張、ID 重複、不正な指紋値、UTF-8 変換不能な文字 | REJECT |
T8 は以前の根拠確認を現在の主張へ流用することを防ぐテストです。supported という判定だけを保存する場合と異なり、どの内容に対する確認だったかまで照合できます。
引用検証で保証できない条件
ACCEPT は、入力された検索済み資料と独立した確認結果が、ここで定義した検証契約を満たすことを意味します。適用範囲は次の条件で決まります。
-
sources.jsonに本当に検索工程で取得した資料が収録されていることは、入力を供給する工程で保証する必要があります。 -
reviews.jsonの独立性、真正性、確認担当者の判断精度は、確認工程とアクセス制御に依存します。SHA-256 の指紋値だけでは真正性を保証できません。 - 引用資料の事実上の正確性、検索漏れ、回答全体の論理的な妥当性は、この受理判定の外側にあります。
- 1 主張に複数の引用が必要な場合は、検証単位と指紋値に含める対象を拡張する必要があります。
生成された引用を最終回答の根拠として扱う際には、引用 ID の解決可能性と主張の支持確認を別々に記録することで、未確認の内容を ACCEPT として確定する経路を制限できます。
参考文献
- id774, AI の「正しい」を決める検証契約(2026-09-24). https://blog.id774.net/entry/2026/09/24/5642/
- Python Software Foundation, json — JSON encoder and decoder. https://docs.python.org/3.13/library/json.html
- Python Software Foundation, unittest — Unit testing framework. https://docs.python.org/3.13/library/unittest.html