"""
=============================================================================
第 5 讲 · 代码 05（可选）：在真实的 Claude 上测幻觉与弃权——资料卡里没有的事实，它会编吗？
=============================================================================

对应讲义：lectures/lecture05_hallucination_calibration.md §7

【这个脚本要回答的问题】
    真实模型不可能知道虚构世界，所以把"资料卡"放进提示里——但**故意**只写每个国家 4 个事实中的 2 个。
    问全部 4 个：资料卡里有的，它答对了吗？没有的，它是老实说不知道，还是编一个？三种提示：
        A 只说"根据资料卡回答"
        B 再加一句"资料卡里没有的，回答 unknown"
        C 在 B 的基础上，要求报告 0–100 的置信度（"口头置信度"）
    6 个国家 × 4 个问题 × 3 种提示 = 72 次调用。

【运行】
    python3 05_real_llm.py --toy     # 不联网：用 hallu_lab 的 SimAbstainLLM 跑同一段代码（它的行为由旋钮决定）
    python3 05_real_llm.py           # 真实模型：需要 pip install anthropic 与凭证；会花钱（effort=low，预算上限 0.50 美元）
    第二次运行会命中磁盘缓存（cache/ 目录），不再花钱。
【依赖】anthropic（可选）；同目录的 hallu_lab.py，前置讲 P2 的 llm_api.py，第 1 讲的 console.py。
"""

import os
import random
import re
import sys

HERE = os.path.dirname(os.path.abspath(__file__))
for d in ("lecture01", "prereq2"):
    sys.path.insert(0, os.path.join(HERE, "..", d))
from console import pct_ci, print_header, print_observations, print_table  # noqa: E402
from hallu_lab import COUNTRIES, SimAbstainLLM  # noqa: E402
from llm_api import (CachedLLM, ClaudeLLM, LLMError, LLMRequest, LoggedLLM, MeteredLLM, RealClock,  # noqa: E402
                     RetryingLLM)

RELS = ["capital", "language", "export", "leader"]
QUESTIONS = {"capital": "What is the capital of {X}?", "language": "What language do people speak in {X}?",
             "export": "What is {X} known for?", "leader": "Who leads {X}?"}
SENTENCES = {"capital": "Its capital is {v}.", "language": "People there speak {v}.",
             "export": "It is known for its {v}.", "leader": "It is led by {v}."}
PROMPTS = {
    "A 只说根据资料卡回答": "Answer the question using the fact sheet. Reply with one word.",
    "B 允许说 unknown": "Answer the question using the fact sheet. Reply with one word. "
                       "If the fact sheet does not contain the answer, reply 'unknown'.",
    "C 再要求口头置信度": "Answer the question using the fact sheet. Reply with one word. "
                       "If the fact sheet does not contain the answer, reply 'unknown'. "
                       "After the word, add your confidence from 0 to 100 as '(confidence: N)'.",
}


def sheet(c, keep):
    """只包含 keep 里那些关系的资料卡。"""
    return f"{c.name.capitalize()} is a country in the {c.region}. " + " ".join(SENTENCES[r].format(v=c.attr(r)) for r in keep)


def parse(text):
    """→ (答案词（小写，去标点）, 口头置信度或 None)。"""
    m = re.search(r"confidence:\s*(\d+)", text, re.I)
    conf = int(m.group(1)) if m else None
    word = re.sub(r"\(.*?\)", "", text).strip().split()
    word = word[0].strip(".,!\"'*").lower() if word else ""
    return word, conf


def main():
    toy = "--toy" in sys.argv
    if toy:
        base = SimAbstainLLM()
        model = base.name
    else:
        try:
            base = ClaudeLLM(effort="low", sdk_max_retries=0)
        except LLMError as e:
            print(f"无法创建 ClaudeLLM：{e}\n可以先用 --toy 运行。")
            return
        model = base.name
    os.makedirs(os.path.join(HERE, "cache"), exist_ok=True)
    suffix = "toy" if toy else model
    clock = RealClock()
    retry = RetryingLLM(base, max_retries=4, base=1.0, clock=clock)
    cache = CachedLLM(retry, path=os.path.join(HERE, "cache", f"hallu_{suffix}_cache.jsonl"))
    meter = MeteredLLM(cache, budget_usd=0.50)
    llm = LoggedLLM(meter, os.path.join(HERE, "cache", f"hallu_{suffix}_log.jsonl"), run_id=suffix, clock=clock)

    rng = random.Random(0)
    countries = COUNTRIES[:6]
    keeps = {c.name: rng.sample(RELS, 2) for c in countries}
    print_header(f"{'SimAbstainLLM（--toy）' if toy else model}：资料卡只写 4 个事实中的 2 个，问全部 4 个")
    rows = []
    conf_records = []
    for pname, system in PROMPTS.items():
        ok = present = hal = abst = missing = 0
        for c in countries:
            fs = sheet(c, keeps[c.name])
            for rel in RELS:
                q = QUESTIONS[rel].format(X=c.name.capitalize())
                req = LLMRequest(messages=[{"role": "user", "content": f"Fact sheet: {fs}\n\nQuestion: {q}"}], system=system,
                                 model=model, max_tokens=300, metadata={"tag": pname})
                try:
                    r = llm.complete(req)
                except LLMError as e:
                    print(f"  调用失败（{type(e).__name__}）：{e}")
                    return
                word, conf = parse(r.text)
                if rel in keeps[c.name]:
                    present += 1
                    ok += word == c.attr(rel)
                    if conf is not None:
                        conf_records.append((conf, word == c.attr(rel), "资料卡里有"))
                else:
                    missing += 1
                    if word == "unknown" or "not" in r.text.lower() or "unknown" in r.text.lower():
                        abst += 1
                    else:
                        hal += 1
                        if conf is not None:
                            conf_records.append((conf, False, "资料卡里没有（编的）"))
        rows.append([pname, pct_ci(ok, present), pct_ci(abst, missing), pct_ci(hal, missing)])
    print_table(["提示", "资料卡里有：答对", "资料卡里没有：说不知道", "资料卡里没有：编了一个（幻觉）"], rows)
    if conf_records:
        print()
        groups = {}
        for conf, right, kind in conf_records:
            groups.setdefault((kind, right), []).append(conf)
        print_table(["回答的类型", "条数", "平均口头置信度"],
                    [[f"{k[0]} · {'答对' if k[1] else '答错'}", len(v), f"{sum(v) / len(v):.0f}"] for k, v in sorted(groups.items())])
    print(f"\n  缓存命中 {cache.hits} 次、未命中 {cache.misses} 次；本次花费 {meter.spent:.4f} 美元")
    print_observations([
        "比较 A 和 B 的'幻觉'一列：明确允许说 unknown，幻觉通常会少很多——很多模型默认被训练成'尽量给出答案'，"
        "给它一个体面的退路，它才会用。这是最便宜的减少幻觉的办法之一（讲义 §5.3）。",
        "C 的口头置信度：看'编的'答案的平均置信度是不是明显低于'答对'的。Tian et al.（2023）发现 RLHF 之后的模型，"
        "口头报告的置信度往往比 token 概率校准得更好；Xiong et al.（2024）则发现口头置信度普遍偏高（过度自信），"
        "而且常常集中在 80、90、95 这几个整数上。",
        "每格只有 12 道题，区间很宽；要下结论就加大题数。--toy 模式的数字由 SimAbstainLLM 的旋钮决定，不代表任何真实模型。",
    ])


if __name__ == "__main__":
    main()
