"""
=============================================================================
第 1 讲 · 工具模块：模拟 LLM（以及可选的真实 Claude 接口）
=============================================================================

对应讲义：lectures/lecture01_intro_hallucination.md 第 3、4、5 节

-----------------------------------------------------------------------------
【为什么要"模拟"一个 LLM？】
-----------------------------------------------------------------------------
这门课研究的是：一个**很强但会犯错**的 LLM，怎样被组织成可靠的系统。要把这件事讲清楚，
我们需要能精确地回答"错误是从哪一步来的"。真实的 LLM 做不到这一点：
    · 我们不知道它在训练时见过某条知识多少次；
    · 它的错误难以复现（换个时间、换个版本就不一样）；
    · 每次调用都要花钱、花时间。

所以这里写了一个**模拟 LLM**：它不是神经网络，而是一组规则，但它的"行为模式"刻意模仿了真实
LLM 的几个关键特点（讲义第 3 节逐条解释了这些特点的来源）：

    1. 参数里的记忆：一条事实在"预训练语料"里出现得越多，越可能被记住。
       记住的概率 = m / (m + κ)，m 是出现次数，κ 是一个常数（默认 8）。
    2. 不知道也照样回答：没记住的事实，它不会说"我不知道"，而是生成一个**看起来很合理**的值
       （按常见程度去猜，或者把别的疾病的基因"张冠李戴"过来）。这就是幻觉。
    3. 自信程度不可靠：记住的事实信心通常更高，但没记住的也可能有不低的信心（两者有重叠）。
    4. 读资料时也会出错：资料里有答案时，偶尔读错；资料和记忆冲突时，有时会相信自己的记忆；
       资料里没有答案时，往往用记忆补上（即使被要求"只根据资料回答"，也有一定概率这样做）。
    5. 每个子任务（抽取声明、核查声明、起草回复……）都有各自的出错率。

【边界】模拟 LLM 说明的是**机制**，它的错误率是我们设定的参数，不代表任何真实模型的错误率。
讲义里凡是"真实系统的数字"都来自论文，和模拟实验的数字分开写。

模拟 LLM 的另一个"作弊"之处：它通过 toy_world 里的模板来"理解"问题和句子。真实的 LLM 靠
语言能力理解任意的说法；本讲关心的不是理解，而是"知识从哪里来、对不对"。

-----------------------------------------------------------------------------
【"任务"式的接口】
-----------------------------------------------------------------------------
模拟 LLM 的每个方法对应**一种 LLM 任务**（真实系统里，每种任务是一个精心写好的提示词）：

    answer(question, passages)   回答一道题（闭卷，或者给它几段资料）
    respond(utterance, passages) 用户说 "Tell me about X."，生成一段介绍（含若干条事实声明）
    generate_query(utterance)    把用户的话改写成检索查询
    filter_passage(utt, passage) 从一段资料里挑出和话题相关的句子
    extract_claims(text)         把一段回复拆成一条条独立的事实声明
    verify(claim, evidence)      判断证据是否支持一条声明
    draft(utt, facts)            根据一组事实起草回复
    refine(utt, text)            润色回复（去重、控制长度）

每调用一次，self.calls 里对应任务的计数就加 1 —— 这样就能数出一个流水线用了多少次 LLM 调用
（讲义第 5.3 节：WikiChat 每轮对话用 5 + n + c 次）。

"把一个大问题分解成一个个定义清楚、各自可靠的 LLM 任务"，正是本讲的核心思想。

-----------------------------------------------------------------------------
【可选：真实的 Claude】
-----------------------------------------------------------------------------
文件末尾的 ClaudeLLM 是一个很薄的包装，05 号文件会用它把同样的任务交给真实的 Claude。
需要 `pip install anthropic` 和 API 密钥；不使用它时，本模块不需要任何第三方库。
"""

import random
from collections import Counter
from dataclasses import dataclass

from toy_world import (INHERITANCE_PRIOR, ONSET_PRIOR, PHENOTYPES, PREVALENCE_PRIOR, QA_RELATIONS,
                       fact_sentence, make_gene_symbol, make_person_name, parse_claim, parse_features,
                       parse_question, parse_topic, split_sentences, weighted_choice)

# 核查结果的三种标签（和事实核查数据集 FEVER 的标签一致）。
SUPPORTS = "SUPPORTS"                    # 证据支持这条声明
REFUTES = "REFUTES"                      # 证据和这条声明矛盾
NOT_ENOUGH_INFO = "NOT ENOUGH INFO"      # 证据里没有相关信息

IDK = "I don't know."                                                   # 闭卷时弃权
NOT_IN_DOCS = "The provided documents do not contain this information."  # 只根据资料回答时弃权


@dataclass
class MemoryItem:
    """
    模型"脑子里"关于一条事实的记忆。

    【字段】
        value      : 模型以为的值（可能是对的，也可能是编的）
        recalled   : True = 真的记住了（值一定正确）；False = 没记住、编了一个
        confidence : 模型自己的信心，0~1
    【边界】recalled 是只有"上帝视角"才看得到的信息 —— 真实的 LLM 不会告诉你它是记得还是在编，
        我们在实验里用它来分析错误的来源，算法本身不许使用它。
    """
    value: str
    recalled: bool
    confidence: float


@dataclass
class Answer:
    """
    answer() 的返回值。

    【字段】
        text       : 模型说出口的话（一句完整的句子，或者弃权的话）
        value      : 答案的值；弃权时为 None
        source     : 答案从哪里来（上帝视角的诊断信息，算法不许使用）：
                       "memory"          闭卷，凭记忆
                       "context"         从资料里读到的
                       "memory-fallback" 资料里没有答案，用记忆补上（讲义第 4.1 节的第二类失败）
                       "memory-override" 资料里有答案，但模型更相信自己的记忆
                       "misread"         资料里有答案，但读错了
                       "abstain"         弃权（"不知道" / "资料里没有"）
        confidence : 信心，0~1
        citation   : 引用的段落编号（从资料里读到答案时才有）
    """
    text: str
    value: object
    source: str
    confidence: float
    citation: object = None


class SimulatedLLM:
    """
    模拟 LLM。

    【构造参数】
        world            : toy_world.World —— 模型"生活"的世界（用来知道每种疾病在预训练语料里出现过几次）
        seed             : 随机种子。同一个 seed + 同样的输入 → 同样的输出（相当于温度为 0）
        kappa            : 记忆曲线的常数 κ：出现 m 次的事实被记住的概率 = m / (m + κ)
        memory_override  : 资料和"很自信的记忆"冲突时，相信记忆的概率
        read_error       : 资料里有答案时读错的概率
        leak             : 被要求"只根据资料回答"、资料里却没有答案时，仍然凭记忆回答的概率
        extract_miss     : 挑选句子 / 抽取声明时漏掉一条的概率
        verify_error     : 核查时给出错误标签的概率
        draft_error      : 起草回复时把一条事实写错的概率
        n_aspects        : 被要求介绍一种疾病时，会提到几个方面（7 种关系中的几种）

    【边界】所有出错率都是"每次调用、每个对象"独立的。真实 LLM 的错误往往是相关的
        （同一个模型在同一类问题上反复犯同一个错），04 号文件演示 4 专门讨论这一点。

    【智能体联系】后面每一讲的代码都会用到这个类，通常只调整这些出错率参数。
    """

    def __init__(self, world, seed=0, kappa=8.0, memory_override=0.3, read_error=0.02, leak=0.15,
                 extract_miss=0.03, verify_error=0.03, draft_error=0.01, n_aspects=4):
        self.world = world
        self.seed = seed
        self.kappa = kappa
        self.memory_override = memory_override
        self.read_error = read_error
        self.leak = leak
        self.extract_miss = extract_miss
        self.verify_error = verify_error
        self.draft_error = draft_error
        self.n_aspects = n_aspects
        self.calls = Counter()          # 任务名 → 调用次数
        self._memory = {}               # (疾病, 关系) → MemoryItem，记忆一旦形成就不再变化
        # 编造基因符号时，要避开世界里已有的基因（否则"编的"可能碰巧和别的病的真基因重名）。
        self._known_genes = [d.facts["gene"] for d in world.diseases if d.tier == "head"]
        self._known_people = [d.facts["discoverer"] for d in world.diseases if d.tier == "head"]
        self._all_genes = {d.facts["gene"] for d in world.diseases}

    # ------------------------------------------------------------------
    # 内部工具
    # ------------------------------------------------------------------
    def _rng(self, *keys):
        """
        为"某一次具体的决定"造一个独立的随机数生成器。

        【思路】随机数生成器的种子由 (全局 seed, 任务名, 输入内容……) 拼成。这样：
            · 同样的输入总是得到同样的输出（可复现，相当于温度为 0 的 LLM）；
            · 不同的输入之间互不影响（多问一个问题，不会改变其他问题的答案）。
        【Python 小知识】
            *keys 表示"把所有位置参数收集成一个元组"，例如 _rng("verify", claim, 3) 里
            keys = ("verify", claim, 3)。random.Random(字符串) 用这个字符串做种子，结果是确定的。
        """
        return random.Random("|".join([str(self.seed)] + [str(k) for k in keys]))

    def reset_calls(self):
        """把调用计数清零。"""
        self.calls = Counter()

    @property
    def total_calls(self):
        return sum(self.calls.values())

    def recall_probability(self, d):
        """
        疾病 d 的一条事实被记住的概率 m / (m + κ)。

        【参数】d：疾病名
        【返回】float，0~1。世界里没有这种病（模型被问到一个不存在的病）时返回 0。
        【例子】κ = 8 时：出现 0 次 → 0；8 次 → 0.5；100 次 → 0.93；1000 次 → 0.99
        """
        disease = self.world.by_name.get(d)
        m = disease.mentions if disease else 0
        return m / (m + self.kappa)

    def _plausible(self, rel, rng):
        """
        编一个"看起来合理"的值 —— 幻觉的来源。

        【思路】真实 LLM 编造时，生成的是"这个位置上最像样的词"：格式正确、取值常见、
            往往还是别的相似实体的真实属性（张冠李戴）。这里分关系模拟：
            · 遗传方式、起病年龄、患病率：按常见程度的比例去猜（常见值更容易被猜中）；
            · 基因、报告者：一半概率借用某种常见病的真实值（张冠李戴），一半概率编一个新的；
            · 标志性症状：从症状表里随便选一个；
            · 年份：1950~2015 之间随便选一个（注意：新近疾病的真实年份是 2025 年以后，永远猜不中）。
        """
        if rel == "inheritance":
            return weighted_choice(rng, INHERITANCE_PRIOR)
        if rel == "onset":
            return weighted_choice(rng, ONSET_PRIOR)
        if rel == "prevalence":
            return weighted_choice(rng, PREVALENCE_PRIOR)
        if rel == "hallmark":
            return rng.choice(PHENOTYPES)
        if rel == "year":
            return str(rng.randint(1950, 2015))
        if rel == "gene":
            if rng.random() < 0.5:
                return rng.choice(self._known_genes)
            return make_gene_symbol(rng, set(self._all_genes))     # set(...) 复制一份，不改动原集合
        if rel == "discoverer":
            if rng.random() < 0.5:
                return rng.choice(self._known_people)
            return make_person_name(rng)
        raise KeyError(rel)

    def recall(self, d, rel):
        """
        模型"脑子里"关于 (d, rel) 的记忆。**不算一次 LLM 调用**：它是模型内部的状态，
        answer()、respond() 等方法会用到它。

        【返回】MemoryItem
        【思路】
            1. 以概率 m / (m + κ) 记住了 → 值是正确的，信心通常很高（Beta(8, 2) 分布，平均 0.8）；
            2. 没记住 → 编一个合理的值；信心中等（Beta(3, 3)，平均 0.5）；
               如果这种病在语料里一次都没出现过，信心再低一点（Beta(2, 3)，平均 0.4）。
            两种情况的信心分布有重叠，所以"按信心决定答不答"不能完全挡住幻觉（01 号文件演示 5）。
        【边界】记忆在第一次被用到时形成，之后不再改变（缓存在 self._memory 里）。

        【Python 小知识】rng.betavariate(a, b) 从 Beta(a, b) 分布里抽一个 0~1 之间的数，
            平均值是 a / (a + b)。a、b 越大，抽出的数越集中在平均值附近。
        """
        key = (d, rel)
        if key not in self._memory:
            rng = self._rng("memory", d, rel)
            disease = self.world.by_name.get(d)
            recalled = disease is not None and rng.random() < self.recall_probability(d)
            if recalled:
                value, conf = disease.facts[rel], rng.betavariate(8, 2)
            else:
                value = self._plausible(rel, rng)
                seen = disease is not None and disease.mentions > 0
                conf = rng.betavariate(3, 3) if seen else rng.betavariate(2, 3)
            self._memory[key] = MemoryItem(value=value, recalled=recalled, confidence=conf)
        return self._memory[key]

    @staticmethod
    def _lookup(d, rel, passages):
        """
        在资料里找 (d, rel) 这条事实。

        【返回】(值, 段落编号)；找不到时返回 (None, None)
        【Python 小知识】@staticmethod 表示这个方法不需要 self（不用到对象的任何状态）。
        """
        for p in passages:
            for s in p.sentences:
                parsed = parse_claim(s)
                if parsed and parsed[0] == d and parsed[1] == rel:
                    return parsed[2], p.pid
        return None, None

    def _read(self, d, rel, passages, rng, grounded):
        """
        "读资料回答"的核心逻辑（answer 和 respond 共用）。

        【返回】(值或 None, source, 引用)
        【思路】见 Answer 类的 source 说明。顺序是：
            资料里有答案 → (a) 和很自信的记忆冲突时，以 memory_override 的概率相信记忆；
                           (b) 否则以 read_error 的概率读错；(c) 否则正确读出。
            资料里没有   → 朴素的提示（grounded=False）：用记忆补上；
                           严格的提示（grounded=True）：弃权，但仍有 leak 的概率用记忆补上。
        """
        mem = self.recall(d, rel)
        value, pid = self._lookup(d, rel, passages)
        if value is not None:
            if mem.value != value and mem.confidence >= 0.8 and rng.random() < self.memory_override:
                return mem.value, "memory-override", None
            if rng.random() < self.read_error:
                wrong = mem.value if mem.value != value else self._plausible(rel, rng)
                return wrong, "misread", pid
            return value, "context", pid
        if grounded and rng.random() >= self.leak:
            return None, "abstain", None
        return mem.value, "memory-fallback", None

    # ------------------------------------------------------------------
    # LLM 任务
    # ------------------------------------------------------------------
    def answer(self, question, passages=None, grounded=False, abstain_below=None):
        """
        【任务】回答一道题。

        【参数】
            question      : 问题文本（toy_world 的模板问句）
            passages      : None = 闭卷；否则是 list[Passage]（检索到的资料）
            grounded      : 有资料时，提示词是否要求"只根据资料回答，资料里没有就说没有"
            abstain_below : 闭卷时，信心低于这个阈值就回答"我不知道"；None = 从不弃权
        【返回】Answer
        【边界】认不出的问题（不符合任何模板）直接弃权。
        """
        self.calls["answer"] += 1
        parsed = parse_question(question)
        if parsed is None:
            return Answer(IDK, None, "abstain", 0.0)
        d, rel = parsed
        mem = self.recall(d, rel)
        if passages is None:
            if abstain_below is not None and mem.confidence < abstain_below:
                return Answer(IDK, None, "abstain", mem.confidence)
            return Answer(fact_sentence(rel, d, mem.value), mem.value, "memory", mem.confidence)
        rng = self._rng("answer", question, *[p.pid for p in passages])
        value, source, cite = self._read(d, rel, passages, rng, grounded)
        if value is None:
            return Answer(NOT_IN_DOCS, None, "abstain", mem.confidence)
        conf = 0.95 if source == "context" else mem.confidence
        return Answer(fact_sentence(rel, d, value), value, source, conf, cite)

    def aspects(self, d):
        """被要求介绍疾病 d 时，模型会谈到的 n_aspects 个方面（关系名的列表，固定不变）。"""
        return self._rng("aspects", d).sample(QA_RELATIONS, self.n_aspects)

    def respond(self, utterance, passages=None, abstain_below=None):
        """
        【任务】用户说 "Tell me about X."，生成一段介绍。

        【参数】
            utterance     : 用户的话
            passages      : None = 闭卷（只凭记忆）；否则是检索到的资料（朴素 RAG：资料里没有的就用记忆补上）
            abstain_below : 闭卷时，信心低于阈值的事实不说；None = 全都说
        【返回】str：若干句事实声明连成的一段话。可能是空字符串（什么都不说）。
        【边界】只认识 "Tell me about X." 这一种说法，其他说法返回一句道歉。
        """
        self.calls["respond"] += 1
        d = parse_topic(utterance)
        if d is None:
            return "Sorry, I am not sure what you are asking about."
        claims = []
        pids = [p.pid for p in passages] if passages else []
        for rel in self.aspects(d):
            if passages is None:
                mem = self.recall(d, rel)
                if abstain_below is not None and mem.confidence < abstain_below:
                    continue
                claims.append(fact_sentence(rel, d, mem.value))
            else:
                rng = self._rng("respond", utterance, rel, *pids)
                value, _source, _cite = self._read(d, rel, passages, rng, grounded=False)
                claims.append(fact_sentence(rel, d, value))
        return " ".join(claims)

    def generate_query(self, utterance):
        """
        【任务】把用户的话改写成检索查询。

        【返回】str。"Tell me about X." → "X"；其他内容原样返回。
        【边界】真实系统里这一步要结合多轮对话的上下文（例如把 "它" 换成具体的名字），也会出错。
            这里假设它总是对的。
        """
        self.calls["query"] += 1
        d = parse_topic(utterance)
        return d if d is not None else utterance

    def filter_passage(self, utterance, passage):
        """
        【任务】读一段检索到的资料，挑出和用户话题相关的事实句子（WikiChat 的第 2 步：摘要与过滤）。

        【返回】list[str]：相关的句子。每条相关句子以 extract_miss 的概率被漏掉。
        【边界】"相关" = 是关于用户所问疾病的一条事实（能被 parse_claim 解析、疾病名一致）。
        """
        self.calls["filter"] += 1
        topic = parse_topic(utterance)
        rng = self._rng("filter", utterance, passage.pid)
        kept = []
        for s in passage.sentences:
            parsed = parse_claim(s)
            if parsed and (topic is None or parsed[0] == topic) and rng.random() >= self.extract_miss:
                kept.append(s)
        return kept

    def extract_claims(self, text):
        """
        【任务】把一段回复拆成一条条独立的事实声明（WikiChat 的第 4 步）。

        【返回】list[str]
        【边界】真实系统里这一步还要做"去语境化"：把 "它是 1987 年发现的" 改写成
            "Velmora 综合征是 1987 年发现的"，这样每条声明单独拿出去检索时也能看懂。
            本世界的句子本来就是独立的，所以只需要切分；每条以 extract_miss 的概率被漏掉。
        """
        self.calls["extract"] += 1
        rng = self._rng("extract", text)
        return [s for s in split_sentences(text) if parse_claim(s) and rng.random() >= self.extract_miss]

    def verify(self, claim, evidence):
        """
        【任务】判断证据是否支持一条声明（WikiChat 的第 5 步：事实核查）。

        【参数】claim：一条声明；evidence：list[Passage]（为这条声明专门检索到的资料）
        【返回】SUPPORTS / REFUTES / NOT_ENOUGH_INFO
        【思路】在证据里找关于同一疾病、同一关系的句子：值相同 → 支持；值不同 → 矛盾；找不到 → 信息不足。
            然后以 verify_error 的概率出错：本该"支持"的判成"信息不足"（错杀），
            本该"不支持"的判成"支持"（放过）。
        【为什么这一步比"直接生成正确答案"可靠】核查只需要比较"声明"和"摆在眼前的证据"，
            不需要回忆任何东西 —— 这是一个阅读理解任务，正是 LLM 最擅长的。讲义第 5.2 节详细讨论。
        """
        self.calls["verify"] += 1
        parsed = parse_claim(claim)
        rng = self._rng("verify", claim, *[p.pid for p in evidence])
        if parsed is None:
            label = NOT_ENOUGH_INFO
        else:
            d, rel, v = parsed
            found, _pid = self._lookup(d, rel, evidence)
            label = NOT_ENOUGH_INFO if found is None else (SUPPORTS if found == v else REFUTES)
        if rng.random() < self.verify_error:
            label = NOT_ENOUGH_INFO if label == SUPPORTS else SUPPORTS
        return label

    def draft(self, utterance, facts):
        """
        【任务】根据一组事实起草回复（WikiChat 的第 6 步）。

        【参数】facts：list[str]，事实句子（排在前面的优先：同一疾病、同一关系只保留第一条）
        【返回】str
        【边界】起草时也可能出错：每条事实以 draft_error 的概率被"写走样"（换成一个编造的值）。
            这提醒我们：即使喂给 LLM 的全是正确的事实，"生成"这一步本身仍有风险。
        """
        self.calls["draft"] += 1
        rng = self._rng("draft", utterance, *facts)
        seen, out = set(), []
        for s in facts:
            parsed = parse_claim(s)
            if parsed is None or parsed[:2] in seen:
                continue
            seen.add(parsed[:2])
            d, rel, v = parsed
            if rng.random() < self.draft_error:
                v = self._plausible(rel, rng)
            out.append(fact_sentence(rel, d, v))
        return " ".join(out)

    def refine(self, utterance, text, max_facts=7):
        """
        【任务】润色回复（WikiChat 的第 7 步）：去掉重复，控制长度（最多 max_facts 句；
            默认 7 句，正好是本世界里一种疾病全部可以提问的事实）。

        【边界】真实的 WikiChat 在这一步还会改善语气、相关性、时态（"现任总统"还是"前总统"）。
            这里只做去重和截断，不引入新的错误。
        """
        self.calls["refine"] += 1
        out, seen = [], set()
        for s in split_sentences(text):
            if s not in seen:
                seen.add(s)
                out.append(s)
        return " ".join(out[:max_facts])

    def recall_phenotypes(self, d):
        """
        【任务】闭卷回答"疾病 d 有哪些症状？"（02 号文件的诊断演示用）。

        【返回】list[str]：4 个症状（标志性症状 + 3 个其他症状），每个都可能是记住的，也可能是编的。
        """
        self.calls["answer"] += 1
        out = [self.recall(d, "hallmark").value]
        disease = self.world.by_name.get(d)
        p = self.recall_probability(d)
        for i in range(3):
            rng = self._rng("feature", d, i)
            if disease is not None and rng.random() < p:
                out.append(disease.features[i])
            else:
                out.append(rng.choice(PHENOTYPES))
        return out

    def read_phenotypes(self, d, passages):
        """
        【任务】读资料，列出疾病 d 的症状（02 号文件的诊断演示用）。

        【返回】(症状列表, 是否来自资料)
        【思路】在资料里找关于 d 的"标志性症状"句和"其他症状"句，每个症状以 extract_miss 的概率被漏掉。
            资料里一个都没找到时 —— 和 answer() 的朴素提示一样 —— 用记忆补上（recall_phenotypes），
            这正是"LLM 用记忆补上答案"的失败模式。
        """
        self.calls["answer"] += 1
        rng = self._rng("read_phenotypes", d, *[p.pid for p in passages])
        found = []
        for p in passages:
            for s in p.sentences:
                claim, feats = parse_claim(s), parse_features(s)
                if claim and claim[0] == d and claim[1] == "hallmark":
                    found.append(claim[2])
                elif feats and feats[0] == d:
                    found.extend(feats[1])
        found = [f for f in found if rng.random() >= self.extract_miss]
        if found:
            return found, True
        self.calls["answer"] -= 1          # recall_phenotypes 自己会再计一次，这里避免重复计数
        return self.recall_phenotypes(d), False


# =============================================================================
# 可选：真实的 Claude
# =============================================================================
DEFAULT_CLAUDE_MODEL = "claude-opus-5-5"


class ClaudeLLM:
    """
    真实 Claude 的一个很薄的包装：给一段系统提示和一段用户输入，返回模型的文字回复。

    【构造参数】
        model      : 模型名，默认 "claude-opus-5-5"（便宜一些可以用 "claude-haiku-4-5"）
        effort     : 思考的投入程度 "low" / "medium" / "high" / "xhigh" / "max"。
                     本课的任务都很短（核查一条声明、回答一个问题），用 "low" 足够，也最省钱
        max_tokens : 回复的最大长度（包括模型内部思考用掉的部分），给足余量以免回复被截断
    【边界】
        - 需要 `pip install anthropic`，并且配置好凭据（环境变量 ANTHROPIC_API_KEY，或者 `ant auth login`）。
        - 每次调用都会产生费用。
        - 打开了"服务器端回退"（fallbacks="default"）：如果安全分类器拒绝了请求，API 会自动换一个
          合适的模型重试。最终仍被拒绝时（stop_reason == "refusal"），ask() 返回空字符串。

    【智能体联系】模拟 LLM 的每个方法，换成真实 LLM 时都对应"一段提示词 + 一次 ask() 调用"。
        05 号文件给出了 answer 和 verify 两个任务的提示词。
    """

    def __init__(self, model=DEFAULT_CLAUDE_MODEL, effort="low", max_tokens=16000):
        try:
            import anthropic                     # 放在函数里导入：不用 Claude 的人不需要安装它
        except ImportError as e:
            raise RuntimeError("没有安装 anthropic：请先运行 pip install anthropic") from e
        self.client = anthropic.Anthropic()      # 自动从环境变量或 `ant auth login` 的配置里读取凭据
        self.model, self.effort, self.max_tokens = model, effort, max_tokens
        self.calls = Counter()

    def ask(self, system, user, task="ask"):
        """
        发一次请求。

        【参数】system：系统提示（说明任务和规则）；user：用户输入；task：任务名（只用于计数）
        【返回】str：模型回复里的全部文字
        【Python 小知识】resp.content 是一个"内容块"的列表，每块有 .type 属性：
            "text" 是模型说的话，"thinking" 是模型的思考（默认不显示内容）。这里只取文字块。
        """
        self.calls[task] += 1
        resp = self.client.beta.messages.create(
            model=self.model,
            max_tokens=self.max_tokens,
            system=system,
            messages=[{"role": "user", "content": user}],
            output_config={"effort": self.effort},
            betas=["server-side-fallback-2026-07-01"],
            fallbacks="default",
        )
        if resp.stop_reason == "refusal":
            return ""
        return "".join(b.text for b in resp.content if b.type == "text").strip()


if __name__ == "__main__":
    from toy_world import make_world
    world = make_world(seed=0)
    llm = SimulatedLLM(world)
    for d in [world.diseases[0], world.diseases[25], world.diseases[50]]:
        q = f"Which gene is mutated in {d.name}?"
        a = llm.answer(q)
        print(f"[{d.tier:6s} 出现 {d.mentions:4d} 次] {q}")
        print(f"    模型：{a.text}（信心 {a.confidence:.2f}）  正确答案：{d.facts['gene']}")
