0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Python で生成 AI の引用を検証し、回答の受理条件を判定する

0
Posted at

引用 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 として保存します。

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:

answer.json
{
  "claims": [
    {
      "id": "C1",
      "text": "検証環境の API は HTTP 200 を返しました。",
      "citation_id": "S1"
    }
  ]
}

sources.json:

sources.json
{
  "sources": [
    {
      "id": "S1",
      "content": "実行ログ: 検証環境の API は HTTP 200 を返しました。"
    }
  ]
}

reviews.json:

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 として、プログラムと同じディレクトリーに保存します。

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 として確定する経路を制限できます。

参考文献

  1. id774, AI の「正しい」を決める検証契約(2026-09-24). https://blog.id774.net/entry/2026/09/24/5642/
  2. Python Software Foundation, json — JSON encoder and decoder. https://docs.python.org/3.13/library/json.html
  3. Python Software Foundation, unittest — Unit testing framework. https://docs.python.org/3.13/library/unittest.html
0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?