Metadata-Version: 2.4
Name: hallguard
Version: 0.8.2
Summary: Deterministic, hallucination-resistant multi-agent framework with structured output, source grounding, confidence gates, and an independent critic loop.
Author: NamuGororo
License-Expression: MIT
Keywords: llm,hallucination,multi-agent,structured-output,rag,pydantic,langgraph
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Classifier: Typing :: Typed
Requires-Python: >=3.11
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: pydantic>=2.0.0
Provides-Extra: graph
Requires-Dist: langgraph>=0.2.0; extra == "graph"
Provides-Extra: openai
Requires-Dist: openai>=1.0.0; extra == "openai"
Provides-Extra: all
Requires-Dist: langgraph>=0.2.0; extra == "all"
Requires-Dist: openai>=1.0.0; extra == "all"
Provides-Extra: dev
Requires-Dist: langgraph>=0.2.0; extra == "dev"
Requires-Dist: openai>=1.0.0; extra == "dev"
Requires-Dist: pytest>=8.0.0; extra == "dev"
Requires-Dist: pytest-asyncio>=0.23.0; extra == "dev"
Requires-Dist: pytest-mock>=3.12.0; extra == "dev"
Requires-Dist: mypy>=1.8.0; extra == "dev"
Dynamic: license-file

# hallguard

ハルシネーションを極限まで抑え、決定論的な出力に特化した
**軽量 Python マルチエージェントフレームワーク**。
PyPI 上の distribution 名は `hallguard`、Python の import 名は `hallucination_guard`。

LangChain / CrewAI のように「できることを増やす」のではなく、
**「信頼性を保証する」** ことに振り切った逆張りポジションを取る。

> ⚠️ **本リポジトリは現在 初期実装フェーズ です。**
> State / DomainConfig / Retry 層 / 全ノード / `Graph`（LangGraph 組み立て）/
> OpenAI アダプタ / ベンチマーク CLI まで完成しており、
> `Graph(...).run("query")` でエンドツーエンドに動きます。
> 詳細は [実装ステータス](#実装ステータス) を参照。

---

## コアコンセプト（変えてはいけない設計思想）

| # | 仕組み | 目的 |
|---|--------|------|
| 1 | **型強制 (Structured Output)** | LLM の出力を Pydantic スキーマで拘束する |
| 2 | **出典の強制 (Source Grounding)** | RAG で渡したドキュメントに無い内容は答えさせない |
| 3 | **確信度ゲート (FactCheckGate)** | 閾値未満の出力をパイプライン下流に流さない |
| 4 | **批評エージェント (CriticNode)** | 生成 LLM とは独立した第三者で矛盾検出 |
| 5 | **ドメイン注入 (DomainConfig)** | フレームワークは特定ドメインを知らない。Strategy で外部注入 |

---

## アーキテクチャ

```
Input
  │
  ▼
StructuredNode         ← DomainConfig.output_schema() で型強制 / temperature=0
  │
  ▼
FactCheckGate          ← DomainConfig.confidence_threshold / is_valid_source
  ├─ FAIL ──▶ RetryNode ──▶ retry_count >= max_retries → ErrorOutput
  │               └──────────────────────────────────▶ StructuredNode（再試行）
  └─ PASS
        ▼
     CriticNode         ← DomainConfig.critic_prompt()
        ├─ FAIL ──▶ RetryNode（同上）
        └─ PASS ──▶ FinalOutput
```

---

## ディレクトリ構成

```
hallguard/
├── hallucination_guard/
│   ├── __init__.py
│   ├── state.py            ← GraphState / FailReason（イミュータブル）
│   ├── exceptions.py       ← GraphError 階層
│   ├── schemas.py          ← Claim / GroundedOutput / CriticVerdict
│   ├── graph.py            ← Graph（LangGraph 組み立て）
│   ├── llm/
│   │   ├── protocols.py    ← StructuredLLM / JudgeLLM プロトコル
│   │   └── openai_adapter.py ← OpenAI Structured Outputs アダプタ
│   ├── nodes/
│   │   ├── structured_node.py  ← schema 強制 + retry directive 注入
│   │   ├── factcheck_gate.py   ← confidence / source 検証
│   │   ├── critic_node.py      ← 独立判定 + final_output 確定
│   │   ├── retry_node.py       ← retry_count++ / 信号リセット
│   │   └── error_output.py     ← max_retries 超過時の終端
│   ├── domain/
│   │   ├── base.py         ← DomainConfig 抽象クラス
│   │   ├── general.py      ← GeneralDomain（許容的なデフォルト）
│   │   └── medical.py      ← MedicalDomain（厳格なデモ）
│   └── retry/
│       ├── directive.py    ← RetryDirective（注入型を frozen で制限）
│       └── hint_builder.py ← RetryHintBuilder（プロンプト汚染防止）
├── tests/
│   ├── test_state.py
│   ├── test_hint_builder.py
│   ├── test_general_domain.py
│   ├── test_medical_domain.py
│   ├── test_protocols.py
│   ├── test_structured_node.py
│   ├── test_factcheck_gate.py
│   ├── test_critic_node.py
│   ├── test_retry_node.py
│   ├── test_error_output.py
│   └── test_graph_integration.py
├── examples/
│   └── research_agent.py   ← Graph をモック LLM で回すデモ
├── benchmarks/
│   ├── hallucination_rate.py ← 成功率 / 再試行数 / 失敗種別を集計する CLI
│   └── datasets/
│       ├── synthetic_qa.json ← デフォルトのベンチマーク用 QA セット
│       └── medical_qa.json   ← MedicalDomain 用 QA セット
├── CHANGELOG.md
├── Makefile                ← build / release / quality-gate ターゲット
├── pyproject.toml
└── README.md
```

---

## 必要環境

- Python **3.11 以上**（開発・動作確認は 3.13 で実施）
- macOS / Linux を想定（Windows は未検証）

---

## セットアップ

コアの依存は `pydantic` のみ。LangGraph / OpenAI は extras で必要な分だけ
追加します。

### A. State / Retry 層だけ使う（最軽量）

```bash
python3 -m venv .venv
.venv/bin/pip install --upgrade pip
.venv/bin/pip install -e .
```

`Graph` は不要だが `GraphState` / `RetryHintBuilder` 等は使う、という構成。

### B. `Graph` を回す（LangGraph 込み）

```bash
.venv/bin/pip install -e ".[graph]"
```

### C. OpenAI アダプタ込み

```bash
.venv/bin/pip install -e ".[openai]"
# graph と両方欲しければ:
.venv/bin/pip install -e ".[all]"
```

### D. 開発用フルセット（テスト・mypy 含む）

```bash
.venv/bin/pip install -e ".[dev]"
```

### 仮想環境の有効化（任意）

```bash
source .venv/bin/activate
# 以降は `pytest` や `mypy` をそのまま打てる
deactivate    # 抜けるとき
```

---

## 動作確認

### テスト

```bash
.venv/bin/python -m pytest tests/ -v
```

現状 **193 件すべて通過** します（うち統合テスト 40 件は `Graph` を
モック LLM で実行し、成功経路・リトライ・max_retries 超過・`max_retries=0`
エッジケース・checkpointer 経由の状態永続化・`Graph.stream()` /
`Graph.astream()` の進捗イベント・`Graph.arun()` を含む async-native
経路と sync/async 混在時のフォールバック・`asyncio.wait_for` / 明示
キャンセル経由でのキャンセル経路・`asyncio.Semaphore(32)` 下の
高並列度 `arun` フェイク fan-out での state 分離 / fail_history 非共有
ストレステストを検証）。LLM プロトコルの
`@runtime_checkable` 判定は 6 件で sync/async 双方および両方を実装した
クラスの分岐を、OpenAI アダプタは in-memory fake で 9 件、ベンチマーク CLI は
23 件で JSON データセット読み込み・`--domain` 切替・`suggested_domain` 不一致
警告（および `--strict-domain` で非ゼロ終了）・`--async --concurrency N` での
並列実行と並列度上限・並列度=1 の直列退行検出 を含む CLI 引数を、
checkpointer 用シリアライザは 11 件で msgpack allow-list と
`Graph(auto_serialize=True)` オプト機構を回帰チェックします。retry hint の
ロケール対応（`INSTRUCTION_MAPS["en" | "ja"]` と
`DomainConfig.retry_locale()` の連動）は `test_hint_builder.py`（12 件、
英日両方の固定文言マップを parametrize）で固定します。ドメインごとの
retry directive テンプレートは `test_general_domain.py`（19 件、`locale="ja"`
の日本語版および `retry_locale()` 整合性含む）と `test_medical_domain.py`
（22 件、`_ALLOWED_HOSTS` ↔ retry テンプレ整合性チェックと `locale="ja"`
日本語版および `retry_locale()` 整合性含む）が網羅します。

### 型チェック

```bash
.venv/bin/mypy hallucination_guard/
```

`Success: no issues found in 24 source files` が出れば OK。

### デモ実行

```bash
.venv/bin/python -m examples.research_agent
```

モック LLM を使って 5 つのシナリオを順に流します:
1. 1 回目は低 confidence で `FactCheckGate` が差し戻し、リトライで成功
2. 常に低 confidence を返す LLM で `max_retries` を使い切り、`ErrorOutput` 経路
3. `Graph.stream()` でノード単位の進捗イベントを観測（同じシナリオ #1）
4. `Graph.astream()` を `asyncio.run()` 経由で消費（#3 と同じイベント列を非同期 API で）
5. `AsyncStructuredLLM` / `AsyncJudgeLLM` を直接実装したクライアントを
   `Graph.arun()` で駆動（ブリッジ不要のネイティブ async 経路）

### ベンチマーク

```bash
.venv/bin/python -m benchmarks.hallucination_rate                       # 集計のみ
.venv/bin/python -m benchmarks.hallucination_rate --verbose             # 各クエリの結果も
.venv/bin/python -m benchmarks.hallucination_rate --dataset my_qa.json  # 任意 JSON で
.venv/bin/python -m benchmarks.hallucination_rate \
    --domain medical \
    --dataset benchmarks/datasets/medical_qa.json                       # MedicalDomain で
.venv/bin/python -m benchmarks.hallucination_rate --async --concurrency 8  # 非同期並列で
```

決定論的な疑似 LLM を使って `is_success` 率・平均 `retry_count`・
失敗種別（`low_confidence` / `no_source` / `critic_rejected`）の内訳を出力します。
デフォルトは `GeneralDomain` + `benchmarks/datasets/synthetic_qa.json`。
タグ仕様は各データセット JSON 内に記述してあります。`--dataset` でファイル、
`--domain {general,medical}` でドメインを切り替えられ、ペアとなる JSON を
組み合わせると同じ集計値が再現可能です（疑似 LLM がドメインの
`is_valid_source` を満たす引用 URL を自動で選ぶため）。
各データセット JSON は `suggested_domain` メタフィールドを持ち、
`--domain` がそれと食い違うと stderr に `WARNING:` 行が出ます
（実行自体は止まりません）。CI で集計のミスマッチを失敗扱いにしたい
場合は `--strict-domain` を付けてください — `WARNING:` が `ERROR:` に
変わり、集計を生成せず非ゼロ終了します。
実 LLM で測りたい場合は `--real --model <openai-model-id>` を付けて
`OPENAI_API_KEY` を環境変数に設定してください。`--async` と組み合わせると
`AsyncOpenAI` 系のアダプタが選ばれ、`--concurrency N` で並列度を制御できます
（デフォルト 4、`--async` 無しでは無視）。集計結果は sync 版と一致することを
回帰テストで保証しています。

---

## 使い方

### エンドツーエンドで回す（モック LLM）

```python
from pydantic import BaseModel

from hallucination_guard.domain.general import GeneralDomain
from hallucination_guard.graph import Graph
from hallucination_guard.schemas import Claim, CriticVerdict, GroundedOutput


class MyStructuredLLM:
    def generate(self, *, system: str, user: str, schema: type[BaseModel]) -> BaseModel:
        return GroundedOutput(
            claims=[
                Claim(
                    text="The capital of France is Paris",
                    confidence=0.98,
                    sources=["https://en.wikipedia.org/wiki/Paris"],
                )
            ]
        )


class MyJudgeLLM:
    def judge(self, *, system: str, content: str) -> CriticVerdict:
        return CriticVerdict(verdict="PASS")


result = Graph(
    domain=GeneralDomain(),
    structured_llm=MyStructuredLLM(),
    judge_llm=MyJudgeLLM(),
).run("What is the capital of France?")

print(result.is_success)     # True
print(result.final_output)   # JSON 文字列
print(result.retry_count)    # 0
```

実 LLM を繋ぐ場合は、`StructuredLLM` / `JudgeLLM` プロトコル
（`hallucination_guard/llm/protocols.py`）を満たすクラスを書けば差し替え可能です。
OpenAI 用のアダプタは同梱しています:

```python
from hallucination_guard.llm.openai_adapter import (
    OpenAIJudgeAdapter,
    OpenAIStructuredAdapter,
)

graph = Graph(
    domain=GeneralDomain(),
    structured_llm=OpenAIStructuredAdapter(model="<openai-model-id>"),
    judge_llm=OpenAIJudgeAdapter(model="<openai-model-id>"),
)
```

`OPENAI_API_KEY` 環境変数からキーを読みます。`model` は明示指定が必須
（デフォルトを設けないことで、誤ったモデルが裏で使われる事故を防ぐ）。
Structured Outputs（`chat.completions.parse` + `response_format=<Pydantic class>`）
を内部で使うため、対応モデルが必要です。

### State を永続化する（checkpointer）

LangGraph の checkpointer を渡すと、各ノード境界でのスナップショットが
保存されます。スレッド ID で世帯を分け、後から `get_state()` で復元可能:

```python
from langgraph.checkpoint.memory import InMemorySaver

graph = Graph(
    domain=GeneralDomain(),
    structured_llm=MyStructuredLLM(),
    judge_llm=MyJudgeLLM(),
    checkpointer=InMemorySaver(),
)

graph.run("first question",  thread_id="alice")
graph.run("second question", thread_id="bob")

snapshot = graph.get_state("alice")
print(snapshot.is_success, snapshot.retry_count)
```

`checkpointer` を渡した場合は `run(..., thread_id=...)` 必須です。

LangGraph 1.1+ は checkpoint に書き戻されない型を deserialize すると
`Deserializing unregistered type ... This will be blocked in a future
version.` という警告を出し、将来のリリースでハードエラーになります。
本フレームワークの Pydantic 型（`GroundedOutput` / `Claim` / `CriticVerdict`
/ `GraphState` / `FailReason`）を許可リストに登録した serializer を
`hallucination_guard.serde.build_serializer()` から取得できます:

```python
from langgraph.checkpoint.memory import InMemorySaver
from hallucination_guard.serde import build_serializer

saver = InMemorySaver(serde=build_serializer())
graph = Graph(
    domain=GeneralDomain(),
    structured_llm=MyStructuredLLM(),
    judge_llm=MyJudgeLLM(),
    checkpointer=saver,
)
```

`DomainConfig.output_schema()` を独自クラスで上書きしているなら
`build_serializer(MyCustomOutput)` で追加もできます。

`Graph(auto_serialize=True)` を渡せば、`Graph` 側が checkpointer の
デフォルト serializer をフレームワーク用 allow-list 付きに自動で
差し替えます（カスタム serializer がすでに設定されている場合は
誤って上書きしないよう `ValueError` を出します）:

```python
saver = InMemorySaver()
graph = Graph(
    domain=GeneralDomain(),
    structured_llm=MyStructuredLLM(),
    judge_llm=MyJudgeLLM(),
    checkpointer=saver,
    auto_serialize=True,    # saver.serde を build_serializer() に置換
)
```

### ストリーミング（ノード単位の進捗）

`Graph.stream()` は各ノードの実行が終わるたびに `StreamEvent` を
yield します。`StreamEvent.state` には **その時点までの累積
`GraphState`** が入っているため、最終イベントの state は `run()` の
戻り値と同じになります。

```python
for event in graph.stream("What is the capital of France?"):
    s = event.state
    print(f"{event.node}: retry={s.retry_count} gate={s.gate_result}")
```

進行中の UI 表示や per-node テレメトリに使えます。`thread_id` の扱いは
`run()` と同じ規約です（checkpointer がある場合は必須）。

`async` 文脈では `Graph.astream()` を使うと、LangGraph の `astream`
を経由して同じ `StreamEvent` 列を非同期に受け取れます:

```python
async for event in graph.astream("What is the capital of France?"):
    print(event.node, event.state.retry_count)
```

`stream()` と同じ累積方式で `research_output` の Pydantic インスタンスを
保持します。実 LLM を `asyncio` で叩く場合の進捗観測に使えます。

単発実行が欲しいだけなら `Graph.arun()` が `run()` の async 版です:

```python
result = await graph.arun("What is the capital of France?")
```

`AsyncStructuredLLM` / `AsyncJudgeLLM` を実装したクライアントを
コンストラクタに渡せば、`Graph` は async-native 経路（LangGraph の async
ノードラッパー）に切り替わります。この場合、誤って `run()` / `stream()`
を呼んだら `RuntimeError` が即時に投げられます（LangGraph から
coroutine が透過的に返るのを防ぐため）。

```python
from hallucination_guard.llm.openai_adapter import (
    AsyncOpenAIJudgeAdapter,
    AsyncOpenAIStructuredAdapter,
)

graph = Graph(
    domain=GeneralDomain(),
    structured_llm=AsyncOpenAIStructuredAdapter(model="<your-model-id>"),
    judge_llm=AsyncOpenAIJudgeAdapter(model="<your-model-id>"),
)
result = await graph.arun("Who painted Guernica?")
```

混在モード（structured だけ async、judge は sync、など）も許容されます。
どちらか一方でも async-only なら `graph.is_async is True` になり、async
エントリポイント（`arun` / `astream`）のみが利用できます。

### 低レベル API（State / Retry 層）

```python
from hallucination_guard.state import GraphState, FailReason
from hallucination_guard.retry.hint_builder import RetryHintBuilder

# 1) イミュータブルな State
state = GraphState(user_query="緑茶は癌を予防しますか？")

# 2) 更新は必ず with_update() 経由 — 元は破壊されない
next_state = state.with_update(
    retry_count=state.retry_count + 1,
    fail_reason=FailReason.NO_SOURCE,
)
assert state.retry_count == 0
assert next_state.retry_count == 1

# 3) RetryDirective を組み立てる（プロンプト注入の唯一の入口）
directive = RetryHintBuilder.build(next_state)
print(directive.fix_instruction)
# → "主張ごとに出典URLを必ず添付してください"
print(directive.forbidden_claims)
# → []
```

CRITIC が前回否定した主張は `fail_history` に
`"critic_rejected:<claim>"` の形式で追記する規約です。

```python
s = GraphState(
    user_query="...",
    fail_reason=FailReason.CRITIC_REJECTED,
    fail_history=["critic_rejected:緑茶は癌を治す"],
)
RetryHintBuilder.build(s).forbidden_claims
# → ['緑茶は癌を治す']
```

---

## 実装ステータス

| ファイル | 状態 |
|----------|------|
| `state.py` | ✅ 完了 |
| `exceptions.py` | ✅ 完了 |
| `schemas.py` | ✅ 完了 |
| `llm/protocols.py` | ✅ 完了（`StructuredLLM` / `JudgeLLM` / `AsyncStructuredLLM` / `AsyncJudgeLLM`） |
| `domain/base.py` | ✅ 完了 |
| `domain/general.py` | ✅ 完了 |
| `retry/directive.py` | ✅ 完了 |
| `retry/hint_builder.py` | ✅ 完了 |
| `nodes/structured_node.py` | ✅ 完了（retry プロンプト組み立ては `DomainConfig.format_retry_directive` に委譲・`acall()` で async クライアント対応） |
| `nodes/factcheck_gate.py` | ✅ 完了 |
| `nodes/critic_node.py` | ✅ 完了（`acall()` で async クライアント対応） |
| `nodes/retry_node.py` | ✅ 完了 |
| `nodes/error_output.py` | ✅ 完了 |
| `tests/test_state.py` | ✅ 7 件通過 |
| `tests/test_hint_builder.py` | ✅ 12 件通過（`INSTRUCTION_MAPS` の `"en"` / `"ja"` 両方を parametrize） |
| `tests/test_general_domain.py` | ✅ 19 件通過（`format_retry_directive` / `locale="ja"` / `retry_locale()` 含む） |
| `tests/test_retry_node.py` | ✅ 5 件通過 |
| `tests/test_error_output.py` | ✅ 5 件通過 |
| `tests/test_factcheck_gate.py` | ✅ 10 件通過 |
| `tests/test_structured_node.py` | ✅ 13 件通過（`acall` / 非同期クライアント / sync 呼び出し拒否 / `retry_locale` 連動 含む） |
| `tests/test_critic_node.py` | ✅ 11 件通過（`acall` / 非同期クライアント / sync 呼び出し拒否 含む） |
| `tests/test_protocols.py` | ✅ 6 件通過（`@runtime_checkable` の sync/async 判定） |
| `tests/test_medical_domain.py` | ✅ 22 件通過（`format_retry_directive` / `_ALLOWED_HOSTS` 整合性 / `locale="ja"` / `retry_locale()` 含む） |
| `tests/test_graph_integration.py` | ✅ 40 件通過（`max_retries=0` / checkpointer / `Graph.stream()` / `Graph.astream()` / `Graph.arun()` + async-native 経路 / `asyncio.wait_for` / 明示キャンセル経由のキャンセル経路 / 高並列度 `arun` の state 分離・`fail_history` 非共有ストレス 含む） |
| `tests/test_openai_adapter.py` | ✅ 9 件通過 |
| `tests/test_benchmark_smoke.py` | ✅ 23 件通過（CLI / データセット読込 / `--domain` 切替 / `suggested_domain` 不一致警告 / `--strict-domain` / `--async --concurrency N` 並列実行 / 並列度=1 の直列退行検出） |
| `tests/test_serde.py` | ✅ 11 件通過（msgpack allow-list / `install_framework_serializer` / `Graph(auto_serialize=True)`） |
| `domain/medical.py` | ✅ 完了（デモ用厳格ドメイン） |
| `graph.py` | ✅ 完了（LangGraph 組み立て・`run()` / `arun()` / `stream()` / `astream()` / `auto_serialize` 対応・sync/async クライアント自動判定） |
| `serde.py` | ✅ 完了（LangGraph checkpoint 用 allow-list serializer） |
| `examples/research_agent.py` | ✅ 完了（モック LLM デモ + ストリーミング + `Graph.astream()` 非同期デモ + `Graph.arun()` async-native デモ） |
| `llm/openai_adapter.py` | ✅ 完了（Structured Outputs / 注入クライアント / sync + async アダプタ） |
| `benchmarks/hallucination_rate.py` | ✅ 完了（`--domain {general,medical}` 切替 / `--strict-domain` / `--async --concurrency N` 並列実行 対応） |
| `benchmarks/datasets/synthetic_qa.json` | ✅ デフォルト（General）データセット |
| `benchmarks/datasets/medical_qa.json` | ✅ MedicalDomain 用データセット |
| `Makefile` | ✅ 完了（build / release / quality-gate 一式） |

実装の優先順位は以下のとおり：

1. `state.py` — `GraphState` / `FailReason`
2. `domain/base.py` — `DomainConfig` 抽象クラス
3. `retry/directive.py` + `retry/hint_builder.py` — プロンプト汚染防止層
4. `nodes/structured_node.py` — schema 強制 / `temperature=0`
5. `nodes/factcheck_gate.py` — confidence / source バリデーション
6. `nodes/critic_node.py` — 矛盾検出
7. `nodes/retry_node.py` — イミュータブル更新 / hint 注入
8. `graph.py` — 全体組み立て
9. `tests/` — 各ノード単体テスト
10. `examples/research_agent.py` — デモアプリ

---

## 設計上の重要ルール（破ると思想が崩れる）

開発に参加する場合は、以下を必ず守ること。

### 1. State は必ずイミュータブル更新

```python
# ✅ OK
new_state = state.with_update(retry_count=state.retry_count + 1)

# ❌ NG（直接ミューテーション禁止）
state.retry_count += 1
```

### 2. プロンプトに `fail_history` の生文字列を埋めない

`fail_history` にはユーザー入力や LLM の前回出力に由来する文字列が含まれるため、
**プロンプトインジェクションの侵入経路** になります。
プロンプトに注入できるのは `RetryDirective` 型だけ、と決まっています。

```python
# ✅ OK
directive = RetryHintBuilder.build(state)
prompt = build_prompt(directive)

# ❌ NG
prompt = f"前回の失敗: {state.fail_history}"
```

`RetryHintBuilder` は `INSTRUCTION_MAPS[locale]` の **固定文言** からのみ
`fix_instruction` を生成します。動的な文字列は混入しません。`locale` は
`DomainConfig.retry_locale()` 経由で `StructuredNode` から渡され、デフォルト
は `"en"` です。

`StructuredNode` から見える retry プロンプトの組み立て窓口は
`DomainConfig.format_retry_directive(base_prompt, directive)` です。
セパレータの文言や禁止クレームの並べ方を変えたいときは、ドメイン側で
このメソッドだけを差し替えれば足ります（`StructuredNode` を継承する
必要はありません）。実装上、`fail_history` の生文字列を組み込んではいけない
という不変条件は引き続きこの境界で保たれます。

ビルトインの `GeneralDomain` / `MedicalDomain` は `locale="en"`（既定）と
`locale="ja"` を受け付け、`system_prompt` / `critic_prompt` /
`format_retry_directive` / `retry_locale` の 4 つを同時に切り替えます。
これにより `RetryHintBuilder` が返す `fix_instruction` も locale に追従し、
英語プロンプトに日本語の指示文が混入しません。`verdict=PASS` /
`verdict=FAIL` などの構造化出力マーカーや出典ブランド名（PubMed, WHO,
CDC, Cochrane, NEJM）は、日本語版でも ASCII のまま保たれ、ホスト
allow-list や `CriticVerdict` のパースを壊さない設計です。`Locale` 型
（`Literal["en", "ja"]`）は `hallucination_guard.domain.base.Locale` から
import できます。

### 3. ドメイン知識をフレームワーク本体に書かない

```python
# ✅ OK
graph = Graph(domain=MedicalDomain())

# ❌ NG（フレームワーク本体の if 文に書かない）
if domain == "medical":
    threshold = 0.95
```

閾値・出典バリデーション・Critic プロンプト・出力スキーマは
**すべて `DomainConfig` サブクラスに閉じ込める**。

### 4. 無限ループ防止

ルーティング関数では **最初に** `retry_count >= max_retries` をチェックすること。

```python
def route_after_gate(state: GraphState) -> str:
    if state.retry_count >= state.max_retries:
        return "error_output"   # ← 必ず先頭
    if state.gate_result == "FAIL":
        return "retry"
    return "critic"
```

`max_retries` のデフォルトは `3`。`Graph` 初期化時に上書き可能にする予定。

### 5. LLM 呼び出しは `nodes/` 以下にだけ書く

`state.py` / `domain/` / `retry/` から LLM API を直接叩かない。
テスト容易性と関心分離のため。

---

## `fail_history` のエントリ形式

`get_rejected_claims()` が動くために、
`fail_history` の各エントリは次の形式で書きます:

```
"<FailReason.value>:<本文>"
```

例:

```python
"low_confidence:確信度0.4 を返した"
"no_source:出典なしで断定した"
"critic_rejected:緑茶は癌を治す"
```

このうち `critic_rejected:` プレフィックスのものだけが
`get_rejected_claims()` および `RetryDirective.forbidden_claims` に流れます。
**プロンプトに到達するのは prefix を除いた本文のみ** です（プレフィックス文字列は混入しません）。

---

## トラブルシュート

### `ModuleNotFoundError: No module named 'pydantic'`
仮想環境を有効化し忘れているか、依存をインストールしていません。
[セットアップ](#セットアップ) を参照。

### `pytest` が `tests/` を見つけない
ルートディレクトリ（`pyproject.toml` のある場所）で実行してください。
`pyproject.toml` の `[tool.pytest.ini_options]` で `testpaths = ["tests"]` を指定しています。

### `mypy` が import エラーを出す
`langgraph` のような外部依存を `pip install -e ".[dev]"` で
入れていない可能性があります。`hallucination_guard/graph.py` は
`langgraph` を import するため、未インストールだと `mypy` も失敗します。
extras を絞っている場合（例: `.[openai]` だけで `graph` を含めていない）も
同じ事象が起きるので、フル開発時は `.[dev]` または `.[all]` を使ってください。

---

## リリース手順（PyPI）

すべて `Makefile` のターゲットに集約してあります。事前に `make install-dev`
を済ませて `build` / `twine` を取り込んでおくこと。

```bash
make check          # pytest + mypy + ポリシー grep
make build          # dist/*.tar.gz と *.whl を作る
make release-check  # check + build + twine check
make release-test   # TestPyPI へアップロード（事前に ~/.pypirc を設定）
make release        # 本番 PyPI へアップロード
```

リリースを切るときの手順:

1. `pyproject.toml` の `version` を bump
2. `CHANGELOG.md` 先頭に新エントリを追記（Keep a Changelog 形式）
3. `make release-check` で artifacts を検証
4. `make release-test` で TestPyPI に上げて `pip install -i https://test.pypi.org/simple/ hallguard==<ver>` を確認
5. 問題なければ `make release`
6. Git タグを切って push

認証は `~/.pypirc` か `TWINE_USERNAME` / `TWINE_PASSWORD` 環境変数で渡します
（API トークンを推奨）。

---

## ライセンス

未定。
