"""
=============================================================================
第 2 讲 · 工具模块：会做研究的模拟 LLM
=============================================================================

对应讲义：lectures/lecture02_knowledge_curation.md 第 2、3 节

-----------------------------------------------------------------------------
【这个模块做什么？】
-----------------------------------------------------------------------------
第 1 讲的 SimulatedLLM 会回答问题、抽取声明、核查。研究型智能体还需要另外几种"LLM 任务"：

    recall_sentence(...)       凭记忆写出一条事实（直接生成的基线用；没记住就编）
    draft_outline(d)           凭自己的知识拟一个大纲（只会想到常见的方面）
    generic_questions(d, n)    不带视角地提 n 个问题
    identify_perspectives(...) 读几篇相关话题的目录，找出值得从哪些视角去调研
    perspective_questions(...) 从某个视角提问
    expert_answer(...)         "专家"根据检索到的资料回答，并给出引用
    follow_up(finding)         读完一条信息之后，提一个追问
    write_sentence(finding)    把一条信息写进文章（带引用）

和第 1 讲一样，每个任务都会在 self.calls 里计数；每个任务都有自己的出错率：
    read_error   专家读资料时把关键值读错
    cite_error   引用了检索结果里的另一篇文档（"引用了，但引用不支持这句话"）
    write_error  写作时把一条事实写走样
    follow_skill 读完根事实后，能提出正确追问的概率

【边界】和第 1 讲一样，这些出错率是我们设定的参数，说明的是机制，不代表真实模型的错误率。
"理解问题"和"判断一篇文档讲的是哪条事实"依靠玩具世界的模板（上帝视角），真实系统里这些都靠 LLM 的语言能力。
"""

import random
from collections import Counter
from dataclasses import dataclass

from research_world import DEFAULT_FACETS, FACET_NAMES, FACETS, GENERIC_QUESTIONS  # 同时把第 1 讲加进了 sys.path
from llm import SimulatedLLM
from retriever import tokenize


@dataclass
class Finding:
    """
    专家回答里的一条信息。

    【字段】
        fact     : 来源文档讲的那条事实（上帝视角，用于评估；算法只能用下面几个字段）
        value    : 专家读出的关键值（可能读错）
        sentence : 专家说出的句子
        cite     : 引用的文档编号
        question : 是哪个问题检索到它的
    """
    fact: object
    value: str
    sentence: str
    cite: str
    question: str


class ResearchLLM(SimulatedLLM):
    """
    会做研究的模拟 LLM（继承第 1 讲的 SimulatedLLM，所以"参数记忆"的规律完全一样：
    在预训练语料里出现 m 次的疾病，每条事实被记住的概率是 m / (m + κ)）。

    【构造参数】
        rw           : ResearchWorld
        seed         : 随机种子
        read_error   : 专家读资料时读错关键值的概率
        cite_error   : 引用错文档的概率（在回答和写作两个阶段各有一次机会出错）
        write_error  : 写作时把事实写走样的概率
        follow_skill : 读完一条根事实后，提出正确追问的概率
        relevance    : 专家只采用分数不低于"最高分 × relevance"的文档（太不相关的不用）
        其余参数传给第 1 讲的 SimulatedLLM（例如 kappa）

    【Python 小知识】class ResearchLLM(SimulatedLLM) 表示"继承"：ResearchLLM 自动拥有
        SimulatedLLM 的全部方法（recall、recall_probability、calls 计数……），只需要写新增的部分。
        super().__init__(...) 调用父类的构造函数，完成父类那部分的初始化。
    """

    def __init__(self, rw, seed=0, read_error=0.02, cite_error=0.03, write_error=0.02, follow_skill=0.9,
                 relevance=0.75, **kwargs):
        super().__init__(rw.base, seed=seed, read_error=read_error, **kwargs)
        self.rw = rw
        self.cite_error = cite_error
        self.write_error = write_error
        self.follow_skill = follow_skill
        self.relevance = relevance
        self._head = [d.name for d in rw.base.diseases if d.tier == "head"]

    # ------------------------------------------------------------------
    # 记忆与编造
    # ------------------------------------------------------------------
    def _hallucinate(self, d, slot, rng):
        """
        编一个"看起来合理"的值：借用某种常见病同一个位置上的值（张冠李戴）。

        【边界】借来的值如果碰巧和正确值相同，就再换一个（保证编出来的是错的）。
        """
        true = self.rw.slots(d)[slot]
        for _ in range(20):
            v = self.rw.slots(rng.choice(self._head))[slot]
            if v != true:
                return v
        return true + " (variant)"

    def recall_sentence(self, d, facet, line, depth):
        """
        凭记忆写出一条事实（**不算**一次 LLM 调用：一次"直接生成"的调用里会写很多句）。

        【返回】(句子, 是否真的记得)
        【思路】以第 1 讲的概率 m / (m + κ) 记得 → 写出真实的句子；
            否则把关键值换成一个借来的值 → 格式正确、内容错误的句子。
        """
        rng = self._rng("rmem", d, facet, line, depth)
        spec = FACETS[facet]["lines"][line]
        slot = spec["key"][depth]
        if rng.random() < self.recall_probability(d):
            return self.rw.render_any(d, facet, line, depth), True
        return self.rw.render_any(d, facet, line, depth, {slot: self._hallucinate(d, slot, rng)}), False

    # ------------------------------------------------------------------
    # 规划与提问
    # ------------------------------------------------------------------
    def draft_outline(self, d):
        """
        【任务】凭自己的知识为话题拟一个大纲（方面的列表）。

        【思路】模型总会想到最常见的三个方面（DEFAULT_FACETS）；话题真正有的其他方面，
            每个以"记住的概率"被想起来 —— 越有名的话题，模型拟的大纲越接近真实的目录。
        """
        self.calls["outline"] += 1
        rng = self._rng("outline", d)
        p = self.recall_probability(d)
        extra = [f for f in self.rw.toc(d) if f not in DEFAULT_FACETS and rng.random() < p]
        return [f for f in FACET_NAMES if f in DEFAULT_FACETS or f in extra]

    def generic_questions(self, d, n, offset=0):
        """
        【任务】不带任何视角，就话题提 n 个问题。

        【返回】list[str]
        【参数】offset：从问题池的第几个开始（多次采样时每次从不同位置开始，模拟"换一批问题"）
        【边界】问题来自一个固定的问题池：前 3 个是默认三个方面的基本问题，其余都是它们的含糊说法
            （"How serious is X?""Can X be treated?"）。n 超过问题池的大小时循环使用 ——
            模拟"多采样几次，只是同样的问题换个说法"（讲义第 2.2 节）。
        """
        self.calls["ask"] += 1
        return [GENERIC_QUESTIONS[(offset + i) % len(GENERIC_QUESTIONS)].format(d=d) for i in range(n)]

    def identify_perspectives(self, tocs, n):
        """
        【任务】读几篇相关话题的目录，找出 n 个值得调研的视角（STORM 的视角发现）。

        【参数】tocs：list[list[str]]，每篇相关文章的目录；n：要几个视角
        【返回】list[str]：方面名，按在相关文章里出现的次数从多到少排；次数相同按 FACET_NAMES 的顺序
        【思路】一个方面在相关话题里反复出现，说明"这类话题通常要讲这个"——这就是一个值得采用的视角。
            每个视角对应一个人物（persona），见 FACETS[facet]["persona"]。
        """
        self.calls["perspectives"] += 1
        counts = Counter(f for toc in tocs for f in toc)
        ranked = sorted(counts, key=lambda f: (-counts[f], FACET_NAMES.index(f)))
        return ranked[:n]

    def perspective_questions(self, d, facet):
        """
        【任务】以某个视角的身份提问（每个视角对应一个方面，提它的两个"根问题"）。

        【返回】list[str]
        """
        self.calls["ask"] += 1
        return [line["ask"].format(d=d) for line in FACETS[facet]["lines"]]

    def follow_up(self, finding):
        """
        【任务】读完一条信息之后，提一个追问。

        【参数】finding：Finding
        【返回】str 或 None（这条信息没有可追问的内容，或者模型没想到）
        【思路】只有根事实可以追问。追问的问题用的是**专家读出的值**：如果专家把药名读错了，
            追问也会问错的药名 —— 早期的错误会沿着对话传下去（讲义第 2.1 节"早期的错误会累积"）。
        """
        self.calls["ask"] += 1
        f = finding.fact
        if f.depth != 0:
            return None
        rng = self._rng("follow", finding.sentence)
        if rng.random() >= self.follow_skill:
            return None
        spec = FACETS[f.facet]["lines"][f.line]
        values = dict(self.rw.slots(f.topic))
        values[f.slot] = finding.value
        return spec["follow"].format(d=f.topic, **values)

    # ------------------------------------------------------------------
    # 基于检索的回答与写作
    # ------------------------------------------------------------------
    def expert_answer(self, question, results, d, require_overlap=True):
        """
        【任务】"专家"根据检索到的资料回答问题，每条信息附上引用（STORM 对话里的专家一方）。

        【参数】
            question : 问题
            results  : 检索结果 list[(Passage, 分数)]
            d        : 正在调研的话题（专家只采用关于这个话题的文档）
            require_overlap : True = 文档除了话题名以外，至少要和问题共享一个实词，才算"回答了这个问题"
                       （只提到了病名的页面，不能当作"What causes X?"的答案）；
                       False = 检索到的、关于这个话题的资料全部采用（"检索一次再写"的 RAG 就是这样）
        【返回】list[Finding]；找不到相关资料时返回空列表（专家说"没有找到"，**不编**）
        【思路】
            1. 只采用关于话题 d、而且分数不低于最高分 × relevance 的文档（太不相关的不用）；
               require_overlap 时还要求和问题有实词重合；
            2. 读出每篇文档的关键值，以 read_error 的概率读错；
            3. 引用这篇文档，以 cite_error 的概率引用成检索结果里的另一篇。
        【边界】真实的专家回答会把几段资料综合成一段话；这里每篇文档变成一条信息，便于逐条评估。
        """
        self.calls["answer"] += 1
        if not results:
            return []
        top = results[0][1]
        rng = self._rng("expert", question, *[p.pid for p, _ in results])
        q_words = set(tokenize(question)) - set(tokenize(d))
        out = []
        for p, score in results:
            fact = self.rw.doc_fact(p.pid)
            if fact is None or fact.topic != d or score < self.relevance * top:
                continue
            if require_overlap and not (q_words & set(tokenize(p.text))):
                continue
            value = fact.value
            if rng.random() < self.read_error:
                value = self._hallucinate(d, fact.slot, rng)
            cite = p.pid
            others = [q.pid for q, _ in results if q.pid != p.pid]
            if others and rng.random() < self.cite_error:
                cite = rng.choice(others)
            out.append(Finding(fact=fact, value=value, sentence=self.rw.render(fact, value), cite=cite,
                               question=question))
        return out

    def write_sentence(self, finding):
        """
        把一条信息写进文章：返回 (句子, 引用)。**不算**一次 LLM 调用（一次写一整节）。

        【思路】以 write_error 的概率把关键值写走样，以 cite_error 的概率把引用换成别的编号
            （模拟"引用列表里的编号对错了位置"）。
        """
        rng = self._rng("write", finding.sentence, finding.cite)
        sentence, cite = finding.sentence, finding.cite
        if rng.random() < self.write_error:
            sentence = self.rw.render(finding.fact, self._hallucinate(finding.fact.topic, finding.fact.slot, rng))
        if rng.random() < self.cite_error:
            cite = rng.choice(self.rw.corpus).pid
        return sentence, cite
