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?

正しい文に警告が出たら再生成する? 語順で変わるLLM検証器の誤検知をPythonで試した

0
Last updated at Posted at 2026-10-13

LLMの文章検査で生じる誤検知と再試行の関係を説明する記事表紙

同じ関係を説明する二つの文なのに、語順を変えると検査結果が変わる。GenesisCoreの過去の実装を調べると、文章を短い文字列として照合する検査器が、この問題を起こすことを再現できました。

AETHERCOREは、占星術の計算結果をAIで文章化するGenesisCoreを開発・運営しています。今回扱うのは、計算済みのデータと文章を突き合わせる検査です。LLMを使うサービスで、検査の警告をそのまま再生成へつないでいる人に向けて書きます。

短いPython例で誤検知を確かめ、その警告にどこまで処理を任せるかを考えます。再現にはAPIキーも外部ライブラリも要りません。

「担当する星」と「星がある場所」を取り違えた

次の二文を見比べてください。どちらも、月が第12ハウスの支配星で、そのハウスが蟹座にあるという関係を説明しています。

月は第12ハウスの支配星です。第12ハウスは蟹座です。

第12ハウスは蟹座で、支配星は月です。

「支配星」は占星術の対応関係を示す言葉です。「担当者が田中さん」と書かれていても、それだけでは田中さんの居場所が決まらないのと同じで、月の実際の位置は別に確認する必要があります。

旧検査器には、惑星名から後ろ32文字以内にある星座名を、その惑星の位置として拾う処理がありました。月の実際の位置を水瓶座として検査すると、一つ目は「月=蟹座」と読み違えて不一致になります。二つ目では、月より前にある蟹座を拾わず、この不一致を返しません。

これらは原因を確認するために作った合成文です。顧客の分析結果からの引用ではありません。

まずは手元で再現する

Python 3.10以降の標準ライブラリだけで動く縮小例です。説明を短くするため、星座は三つ、惑星名は最初の出現だけを扱います。製品の検査器全体を再実装したものではありません。

import re

def suspect_placement(text, planet, expected_sign):
    start = text.find(planet)
    if start < 0:
        return None
    window = text[start:start + 32]
    found = re.search(r"蟹座|水瓶座|乙女座", window)
    if found and found.group() != expected_sign:
        return f"{planet}: expected={expected_sign} got={found.group()}"
    return None

first = "月は第12ハウスの支配星です。第12ハウスは蟹座です。"
last = "第12ハウスは蟹座で、支配星は月です。"
wrong = "月は蟹座にあります。"

assert suspect_placement(first, "月", "水瓶座") == "月: expected=水瓶座 got=蟹座"
assert suspect_placement(last, "月", "水瓶座") is None
assert suspect_placement(wrong, "月", "水瓶座") == "月: expected=水瓶座 got=蟹座"
print("3 checks passed")

最後の wrong は、期待値が水瓶座である場合に、本当に配置が違う文です。検査器は、正しい関係の説明と、誤った配置の断定に同じ警告を返します。警告だけを見ても、どちらを直すべきか決められません。

また、None は「この検査では警告が出なかった」という意味です。この縮小例では獅子座を検査していないため、月は獅子座にあります。 も通ります。警告なしを文章の正しさの証明にすると、別の見逃しが生まれます。

固定した過去実装への合成入力結果

確認対象は保存済みcommit 05f01b654437d4bc63b123605499a4968ed13181 です。guard 6件、validator 3件、runner 3件の計12ケースを、すべて合成入力で確認しました。顧客事例や実LLM出力を12件集めた実験ではありません。製品の履歴コードは非公開であり、読者が手元で実行できるのは本文の縮小例と末尾のサンプルです。

GenesisCoreの保存済みの過去実装にある find_text_fact_mismatch に、同じ合成入力を与えました。

合成入力の種類 履歴中の検査器の結果
月の名が先にある支配星の説明 月の位置が蟹座だとして警告
月の名が後ろにある同じ関係の説明 この不一致の警告なし
水星の名が先にある支配星の説明 水星の位置が乙女座だとして警告
月が実際と違う蟹座にあるという文 警告
月が期待値どおり水瓶座にあるという文 警告なし
月が獅子座にあるという文 警告

履歴中の実装は十二星座を扱うため、最後の行は三つの星座に絞った縮小例と結果が異なります。

本文を検査する上位関数も、必要なデータの形だけを用意して確認しました。根拠IDがある場合、誤検知された記述は取り除かれ、再生成を要求する needs_expand=True のエラーになります。この版には根拠IDがない記述をそのまま残す分岐もあります。文章なら必ず同じ検査を通る、という実装ではありませんでした。

警告から生成呼び出しが増えるまで

同じ履歴にある生成処理は、初回1回と再試行2回、合計最大3回の呼び出しを持っていました。検査エラーのうち一つでも needs_expand が真なら、次の試行へ進みます。

流れを抜き出すと、次のようになります。これは説明用の擬似コードです。

for attempt in range(3):  # 初回1回 + 再試行2回
    result = generate()
    checked = validate(result)
    if not any(error.needs_expand for error in checked.errors):
        return checked

return fallback_or_error()

履歴中の生成関数をそのまま取り出し、モデル呼び出しと検査結果を通信しない代用品へ差し替えて確認しました。

検査結果の与え方 生成の呼び出し回数 代替処理の呼び出し
初回から警告なし 1 なし
警告が3回とも続く 3 あり
初回に警告、次で解消 2 なし

ここで数えたのは代用品への呼び出しです。実モデルの出力、品質変化、費用は測っていません。確認できたのは、誤検知から追加生成へ進める制御が存在したことです。

警告に与える権限を決める

開発担当からは、この問題を踏まえて文章の警告を監査へ移し、全文を自動生成し直す処理を外したとの報告を受けています。今回の確認は履歴コードと合成入力を対象としており、現在の本番動作の再検証ではありません。

この設計判断を手元で試すために、JSONの構造検査と文章の監査を分けたサンプルも用意しました。quality_gate_demo.py は、末尾に全文を載せた説明用コードです。

構造検査では、JSONが壊れている、必要な sections がない、本文が空、といった入力を止めます。構造を満たした後に文章の警告を記録し、生成をもう一度呼ぶ処理は置きません。

そのサンプルでは、次の結果を確認しました。

入力 処理 生成呼び出し回数
支配星の説明で誤検知 元の文を保持し警告を記録 1
本当に配置が違う文 元の文を保持し警告を記録 1
壊れたJSON エラーで停止 1
必須項目の欠落・本文が空 エラーで停止 1

二行目が、この判断の代償です。警告を監査に回せば、本当の間違いも通る場合があります。安全性や契約上、誤りを届けられない用途なら、警告を出した結果を人の確認まで保留する方法も必要です。

停止、監査、再生成のどれを選ぶにしても、検査器の誤検知率と見逃し率、間違った結果を返した場合の影響を先に考える必要があります。

自分のサービスで試すなら

まず、正常な文章を一つ保存し、意味を保ったまま語順を変えた文を作ります。どちらでも結果が変わらないかを調べ、実際に誤りを含む文も隣に置いてください。

次に、検査結果だけを差し替えて、警告が生成回数や返却本文に与える影響を数えます。実モデルを毎回呼ばなくても、誤検知がどこまで処理を動かすかは確認できます。

今回、履歴コードを12ケースで確認し、読者向けサンプルは13チェックを実行しました。二つは役割の異なる検査であり、25件の顧客事例を試したという意味ではありません。

記事の短い例だけでも、同じ意味の文章、明確な誤り、検出対象外の表現を並べる考え方は持ち帰れます。

サンプル全文

以下を quality_gate_demo.py として保存し、python quality_gate_demo.py で実行できます。正常終了時は status: PASS、checks_passed: 13、network_calls: 0 を出力します。すべて合成入力で、APIへ通信しません。

Pythonコード全文を開く
"""Standalone teaching example; not GenesisCore's production implementation.

Run: python quality_gate_demo.py
Python 3.10+, standard library only. All input and output are synthetic.
"""
from __future__ import annotations
import json
import re


def suspect_placement(text: str, planet: str, expected_sign: str) -> str | None:
    """Reduced forward-window heuristic. Only three signs are supported here."""
    start = text.find(planet)
    if start < 0:
        return None
    found = re.search(r"蟹座|水瓶座|乙女座", text[start:start + 32])
    if found and found.group() != expected_sign:
        return f"{planet}: expected={expected_sign} got={found.group()}"
    return None  # No warning means undetected, not proven correct.


class ContractError(ValueError):
    pass


def validate_contract(raw: str) -> dict:
    """Small example contract, not a substitute for the product schema."""
    try:
        result = json.loads(raw)
    except (json.JSONDecodeError, TypeError) as error:
        raise ContractError("invalid JSON") from error
    if not isinstance(result, dict):
        raise ContractError("root must be an object")
    sections = result.get("sections")
    if not isinstance(sections, list) or not sections:
        raise ContractError("sections must be a nonempty list")
    for section in sections:
        if not isinstance(section, dict) or not isinstance(section.get("text"), str) or not section["text"].strip():
            raise ContractError("each section needs nonempty text")
    return result


def generate_once(generate, audit_log: list[dict]) -> dict:
    raw = generate()
    result = validate_contract(raw)
    for index, section in enumerate(result["sections"]):
        warning = suspect_placement(section["text"], "月", "水瓶座")
        if warning:
            audit_log.append({"section": index, "warning": warning})
    return result  # Preserve the returned text; do not invoke generate again.


def run_checks() -> dict:
    checks = []

    def require(name, condition, observed):
        if not condition:
            raise AssertionError((name, observed))
        checks.append({"name": name, "passed": True, "observed": observed})

    sentences = {
        "same_relation_planet_first": "月は第12ハウスの支配星です。第12ハウスは蟹座です。",
        "same_relation_planet_last": "第12ハウスは蟹座で、支配星は月です。",
        "actual_wrong_placement": "月は蟹座にあります。",
        "correct_placement": "月は水瓶座にあります。",
        "unknown_sign_limitation": "月は獅子座にあります。",
        "planet_absent": "本文に対象の惑星は登場しません。",
    }
    wanted = {
        "same_relation_planet_first": "月: expected=水瓶座 got=蟹座",
        "same_relation_planet_last": None,
        "actual_wrong_placement": "月: expected=水瓶座 got=蟹座",
        "correct_placement": None,
        "unknown_sign_limitation": None,
        "planet_absent": None,
    }
    for name, sentence in sentences.items():
        result = suspect_placement(sentence, "月", "水瓶座")
        require(name, result == wanted[name], result)

    for name in ("same_relation_planet_first", "actual_wrong_placement"):
        calls = []
        audit = []
        text = sentences[name]
        raw = json.dumps({"sections": [{"text": text}]}, ensure_ascii=False)

        def generate():
            calls.append(1)
            return raw

        result = generate_once(generate, audit)
        require(name + "_policy", len(calls) == 1 and len(audit) == 1 and result["sections"][0]["text"] == text,
                {"generation_calls": len(calls), "warnings": len(audit), "text_preserved": result["sections"][0]["text"] == text})

    for name, raw in (
        ("invalid_json", "{"),
        ("missing_sections", "{}"),
        ("empty_sections", '{"sections":[]}'),
        ("empty_text", '{"sections":[{"text":" "}]}'),
        ("wrong_text_type", '{"sections":[{"text":7}]}'),
    ):
        calls = []

        def generate_bad():
            calls.append(1)
            return raw

        try:
            generate_once(generate_bad, [])
        except ContractError as error:
            require(name, len(calls) == 1, {"blocked": True, "generation_calls": len(calls), "error": str(error)})
        else:
            raise AssertionError(name + " did not block")

    return {"status": "PASS", "synthetic": True, "checks_passed": len(checks), "network_calls": 0, "checks": checks}


if __name__ == "__main__":
    print(json.dumps(run_checks(), ensure_ascii=False, indent=2))

どこに人の判断を残すか

あなたのサービスで、正常文かもしれない文章に検査の警告が出たら、どう扱いますか。

  • 元文を保持して監査する
  • 人が確認するまで保留する
  • 再生成する
  • 決定論的な代替結果を返す

近い選択肢を一つと、その判断を変える条件があれば教えてください。


公開研究リポジトリは研究内容の入口です。この記事で検査した製品の過去コード全体を公開しているリポジトリではありません。

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?