"""
=============================================================================
第 9 讲 · 工具模块：一个迷你的 SLIDERS —— 表结构、分块、带上下文的抽取、对账合并、用 SQL 回答
=============================================================================

对应讲义：lectures/lecture09_sliders.md 第 3~5 节

-----------------------------------------------------------------------------
【SLIDERS 的流程】（幻灯片第 25 页）
-----------------------------------------------------------------------------
    问题 ──► 设计表结构（schematization）
                 │
    一批长文档 ──► 语义分块 + 带上下文的抽取 ──► 原始表（每块抽出零行或多行）
                                                    │
                                              对账合并（reconciliation）──► 对账后的表
                                                                              │
                                                    问题 ──► SQL / SUQL ──► 在数据库上执行 ──► 答案

-----------------------------------------------------------------------------
【这个模块里有什么？】
-----------------------------------------------------------------------------
    induce_schema(问题类型)        模拟的"设计表结构"：列名、类型、单位、说明、规范化规则
    fixed_chunks / semantic_chunks  两种分块：固定 800 词（会把表格切开）vs 按段落和表格分块（不切开）
    Extractor                      模拟的抽取器：读一块（可选：带上文档标题和章节），按表结构抽出若干行，
                                   每行带原文引用（quote）和理由（rationale）；可选的"相关性闸门"
    reconcile                      对账：按主键（公司）分组，去重、合并残缺的信息、解决冲突
    answer_sql                     在对账后的表上用 SQL 回答
    long_context / rag_topk        两个基线：整批文档塞进上下文；检索 top-k 块再读
    make_questions / evaluate      问题和判分

-----------------------------------------------------------------------------
【模拟的边界】（非常重要）
-----------------------------------------------------------------------------
没有真实的 LLM。"抽取器"用正则表达式找出文字里的数字（这一步模拟的是 LLM 的阅读），再按设定的概率犯错：
    · 只有加权平均股数时，以一定概率把它当成流通股数（理由里会写"weighted average"）；
    · 表格的表头被切掉时，不知道单位是"千"，数字差 1000 倍；
    · 这一块里没有公司名、又没有上下文时，不知道这一行属于哪家公司；
    · 没有相关性闸门时，面对不相关的块，以一定概率"编"出一行（这是幻灯片观察到的现象）；
"长上下文 LLM"的精确度随上下文长度和位置下降，这些也都是设定的参数（见 LONG_CONTEXT）。
这里的数字说明的是**机制**：为什么每一步是必要的，而不是真实系统的成绩。
"""

import os
import random
import re
import sqlite3
import sys
from dataclasses import dataclass, field

_HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, os.path.join(_HERE, "..", "lecture01"))
from retriever import BM25  # noqa: E402
from toy_world import Passage  # noqa: E402

from doc_world import make_corpus  # noqa: E402

# 抽取器的出错参数（都是设定的）
EXTRACT = {
    "p_take_distractor": 0.6,   # 块里只有"加权平均股数"/"应付账款及应计费用"时，把它当成要找的值的概率
    "p_misread": 0.02,          # 读错一个数字的概率（多读了一个 0）
    "p_wrong_line": 0.02,       # 读表格时看串了行（拿了相邻一行的数字）的概率
    "p_halluc": 0.3,            # 没有闸门时，面对不相关的块"编"出一行的概率
    "p_gate_miss": 0.03,        # 闸门把相关的块当成不相关的概率
    "p_gate_fp": 0.03,          # 闸门把不相关的块当成相关的概率
    "p_guess_company": 0.5,     # 不知道公司时，瞎猜一个公司名（否则留空）的概率
}
RECONCILE = {"p_wrong_choice": 0.03}   # 对账时选错的概率（LLM 的推理错误）
P_SQL_ERROR = 0.05                      # 在表上回答时写错 SQL 的概率（例如排序方向写反）
# 长上下文 LLM 的参数（设定的；模仿 "Lost in the Middle"、Loong 等研究观察到的现象）
LONG_CONTEXT = {
    "window": 128_000,          # 上下文窗口（token）
    "base": 0.97,               # 上下文很短时，读对一个事实的概率
    "decay": 0.25,              # 上下文每多 10 万 token，读对的概率下降多少
    "middle": 0.10,             # 位于正中间的事实，额外下降多少（两头最好，中间最差）
    "confuse": 0.15,            # 有"干扰项"时（加权平均股数、合并的应付账款），选错的基础概率
    "confuse_per_100k": 0.25,   # 上下文每多 10 万 token，选错的概率增加多少
}


# =============================================================================
# 第 1 部分：表结构（schematization）
# =============================================================================
@dataclass
class Column:
    """表结构里的一列：名字、类型、单位、说明（含常见的说法）、规范化规则。"""
    name: str
    type: str
    unit: str = None
    description: str = ""
    normalization: str = None


@dataclass
class Schema:
    """一张表：名字、说明、列。主键是 company_name。"""
    table: str
    description: str
    columns: list

    def field_names(self):
        return [c.name for c in self.columns if c.name != "company_name"]


SCHEMAS = {
    "shares": Schema("SharesOutstanding", "Total shares outstanding for each company as reported in the financial "
                     "statements, normalized to shares as of the reporting period end.", [
        Column("company_name", "str", None, "Name of the company as reported in the financial statements."),
        Column("total_shares_outstanding", "float", "shares",
               "Total number of shares outstanding as of the period end date, as reported in the balance sheet or notes. "
               "Common surface forms: 'shares outstanding', 'shares issued and outstanding'.")]),
    "ap": Schema("AccountsPayable", "Accounts payable (not including accrued expenses) for each company.", [
        Column("company_name", "str", None, "Name of the company."),
        Column("accounts_payable", "float", "USD", "Accounts payable as of the period end, separate from accrued expenses.",
               "Convert 'in thousands' to USD.")]),
    "debt": Schema("Borrowings", "Borrowings of each company as of the period end.", [
        Column("company_name", "str", None, "Name of the company."),
        Column("total_borrowings", "float", "USD", "Total borrowings = short-term borrowings + long-term debt.",
               "Convert 'in thousands' to USD."),
        Column("short_term_borrowings", "float", "USD", "Short-term borrowings."),
        Column("long_term_debt", "float", "USD", "Long-term debt, net of current portion.")]),
    "results": Schema("QuarterlyResults", "Revenue and net income (loss) for the quarter.", [
        Column("company_name", "str", None, "Name of the company."),
        Column("revenue", "float", "USD", "Revenue for the quarter.", "Convert 'in thousands' / 'million' to USD."),
        Column("net_income", "float", "USD", "Net income; negative for a net loss.", "Losses in parentheses are negative.")]),
}


def induce_schema(kind):
    """
    模拟的"根据问题设计表结构"（幻灯片第 28~30 页：LLM 根据问题和文档集合的描述生成表的说明和列的说明）。

    【参数】kind：问题的类型，"shares" / "ap" / "debt" / "results"
    【返回】Schema
    【边界】真实的 SLIDERS 用 LLM 生成，也可以为几个问题一起设计一张表（摊薄抽取的成本），领域专家还能修改
        —— 因为表结构是看得懂的。这里直接查表。
    """
    return SCHEMAS[kind]


# =============================================================================
# 第 2 部分：分块
# =============================================================================
@dataclass
class Chunk:
    """
    一块文字。

    【字段】
        doc_id : 来自哪份文档（**只用于评估**；抽取器只有在"带上下文"时才能看到文档标题）
        text   : 这一块的文字
        title  : 文档标题（语义分块时记下；固定分块没有）
        section: 章节标题（语义分块时记下）
        table_header : 这一块里的表格的表头（语义分块时记下，例如 "(in thousands, except share data) | …"）
    """
    doc_id: int
    idx: int
    text: str
    title: str = None
    section: str = None
    table_header: str = None

    @property
    def n_tokens(self):
        return len(self.text.split())

    @property
    def canon(self):
        """规范化之后的文字（找公司名用；第一次用到时计算，之后直接取）。"""
        if not hasattr(self, "_canon"):
            self._canon = " " + (canonical(self.text.replace("\n", " ")) or "") + " "
        return self._canon


_CACHE = {}     # 分块的结果缓存起来：同一份文档、同一种分块，只切一次


def fixed_chunks(doc, size=400):
    """
    固定大小的分块（嵌入式检索常用的做法）：把整份文档的词按顺序每 size 个切一刀。

    【问题】（幻灯片第 34~37 页）
        · 表格、句子会被切开：后半截表格没有表头，不知道单位是"千"；
        · 块里没有文档标题："这是哪家公司？"；也不知道属于哪一节。
    【返回】list[Chunk]：记下了文档标题（"带上下文"的抽取可以把它交给抽取器），但没有章节和表头
        —— 块的边界是随意的，"这一块属于哪一节、哪张表的表头"说不清。
    """
    key = ("fixed", doc.doc_id, size)      # 用文档编号做键（不能用 id(doc)：对象被回收后，id 会被别的对象重新使用）
    if key in _CACHE:
        return _CACHE[key]
    words = []
    for b in doc.blocks:
        for line in b.lines:
            words += line.split() + ["\n"]                        # "\n" 记下换行的位置（不算一个词）
    chunks, cur, count = [], [], 0
    for w in words:
        cur.append(w)
        if w != "\n":
            count += 1
        if count >= size:
            chunks.append(cur)
            cur, count = [], 0
    if cur:
        chunks.append(cur)
    out = []
    for i, ws in enumerate(chunks):
        text = " ".join(ws).replace(" \n ", "\n").replace(" \n", "\n").replace("\n ", "\n").strip()
        out.append(Chunk(doc.doc_id, i, text, title=doc.title))  # 标题由流水线记下（只有"带上下文"时才交给抽取器）
    _CACHE[key] = out
    return out


def semantic_chunks(doc, target=1000):
    """
    语义分块（幻灯片第 38~40 页）：按"块"（段落、表格）切，**一个段落、一张表格不会被切开**；
    每块记下文档标题、所在的章节、表格的表头（真实的 SLIDERS 用 Docling 把 PDF 转成 Markdown，
    再用一个 LLM 标注章节标题、表格的起止行和附注）。

    【参数】target：每块大约多少词（一张表格本身超过 target 时，单独成为一块）
    【返回】list[Chunk]
    """
    key = ("semantic", doc.doc_id, target)
    if key in _CACHE:
        return _CACHE[key]
    out, cur, cur_n, section, header = [], [], 0, None, None

    def flush():
        nonlocal cur, cur_n, header
        if cur:
            out.append(Chunk(doc.doc_id, len(out), "\n".join(cur), doc.title, section, header))
        cur, cur_n, header = [], 0, None

    for b in doc.blocks:
        if b.section != section:
            flush()
            section = b.section
        if cur_n + b.n_tokens > target:
            flush()
        cur += b.lines
        cur_n += b.n_tokens
        if b.kind == "table":
            header = b.lines[0]
    flush()
    _CACHE[key] = out
    return out


# =============================================================================
# 第 3 部分：带上下文的抽取
# =============================================================================
@dataclass
class Evidence:
    """
    原始表里的一行（SLIDERS 的"证据"）。

    【字段】
        company   : 抽取器认为这一行属于哪家公司（可能是 None 或猜错的）
        field     : 哪一列，例如 "total_shares_outstanding"
        value     : 规范化之后的值（股数，或美元）
        quote     : 支持这个值的原文（编出来的行可能没有，也可能是编的）
        rationale : 理由，例如 "weighted average shares, not total shares outstanding"
        kind      : 来源的种类：explicit（明确写着）/ weighted / combined / component_short / component_long
        chunk     : 来自哪一块（"doc:idx"）；doc_id 只用于评估
    """
    company: str
    field: str
    value: float
    quote: str
    rationale: str
    kind: str
    chunk: str
    doc_id: int = -1
    supported: bool = True     # 引用的原文是否真的出现在这一块里（编出来的行是 False）
    unit_explicit: bool = True # 单位是不是明确的（原文写了 thousand / million，或者看得到表头）


_NUM = r"\(?\$?([\d,]+(?:\.\d+)?)\)?"
# 每一列的"说法"：(正则表达式, 种类, 单位来源)。单位来源："table" 表示看表头，"thousand" / "million" / "shares" 是写死的
PATTERNS = {
    "total_shares_outstanding": [
        (r"([\d,]+) shares of common stock were issued and outstanding", "explicit", "shares"),
        (r"; ([\d,]+) shares issued and outstanding", "explicit", "shares"),
        (r"[Ww]eighted average (?:number of )?shares outstanding(?:, basic)?(?: \| | was )([\d,]+)", "weighted", "shares"),
    ],
    "accounts_payable": [
        (r"^Accounts payable \| ([\d,]+)", "explicit", "table"),
        (r"Accounts payable were \$([\d,]+) thousand", "explicit", "thousand"),
        (r"^Accounts payable and accrued expenses \| ([\d,]+)", "combined", "table"),
    ],
    "total_borrowings": [(r"Total borrowings were \$([\d,]+) thousand", "explicit", "thousand")],
    "short_term_borrowings": [(r"short-term borrowings of \$([\d,]+) thousand", "component_short", "thousand"),
                              (r"^Short-term borrowings \| ([\d,]+)", "component_short", "table")],
    "long_term_debt": [(r"long-term debt of \$([\d,]+) thousand", "component_long", "thousand"),
                       (r"^Long-term debt, net of current portion \| ([\d,]+)", "component_long", "table")],
    "revenue": [(r"^Revenue \| ([\d,]+)", "explicit", "table"),
                (r"revenue of \$([\d.]+) million", "explicit", "million")],
    "net_income": [(r"^(Net loss|Net income) \| \(?([\d,]+)\)?", "explicit", "table"),
                   (r"(Net loss|Net income) was \$([\d.]+) million", "explicit", "million")],
}
RATIONALE = {"explicit": "explicitly reported", "weighted": "weighted average shares, not total shares outstanding",
             "combined": "accounts payable combined with accrued expenses", "component_short": "short-term component",
             "component_long": "long-term component"}


def canonical(name):
    """
    公司名的规范形式（对账时用来分组）："UMBRA ROBOTICS, CORP." 和 "Umbra Robotics Corp." 都变成 "umbra robotics"。

    【思路】小写、去标点、去掉 inc / corp / llc / ltd 这些后缀。
    【边界】真实的情况复杂得多（缩写、改名、母子公司），SLIDERS 用 LLM 结合当前的问题来规范化。
    """
    if not name:
        return None
    s = re.sub(r"[^a-z0-9 ]", " ", name.lower())
    words = [w for w in s.split() if w not in {"inc", "corp", "llc", "ltd", "the", "company"}]
    return " ".join(words) or None


class Extractor:
    """
    模拟的抽取器（论文定义 2：EE(q, c, m_c, Δ)）。

    【用法】
        ex = Extractor(contextualized=True, gate=True, seed=0)
        rows = ex.extract(chunk, schema, known_companies)
        ex.calls → 调用次数（闸门算一次"便宜的"调用，抽取算一次"贵的"调用）

    【参数】
        contextualized : 是否把文档标题、章节、表头一起交给抽取器（幻灯片第 42~43 页）
        gate           : 是否先用一个轻量的 LLM 判断这一块相不相关（幻灯片第 45 页的"相关性闸门"）
    """

    def __init__(self, contextualized=True, gate=True, seed=0, params=None):
        self.contextualized, self.gate, self.seed = contextualized, gate, seed
        self.p = dict(EXTRACT)
        if params:
            self.p.update(params)
        self.calls = {"gate": 0, "extract": 0}

    def _rng(self, chunk, *keys):
        """
        为某一个随机决定单独造一个随机数发生器（由块和这个决定的名字决定）。

        【为什么】"公共随机数"：比较两种配置（带 / 不带上下文、有 / 没有闸门）时，同一块里同一个数字
            "会不会被读错"应该一样 —— 差别只来自配置本身。如果所有决定共用一个随机数流，一种配置多抽了
            一次随机数，后面的结果就全变了，比较就混进了偶然性。
        """
        return random.Random(f"extract|{self.seed}|{chunk.doc_id}|{chunk.idx}|{'|'.join(map(str, keys))}")

    def _company(self, chunk, known, rng):
        """这一块属于哪家公司？块里有公司名就用它；带上下文时用文档标题；都没有时瞎猜或留空。"""
        for name in known:
            base = canonical(name)
            if base and f" {base} " in chunk.canon:
                return name
        if self.contextualized and chunk.title:
            return chunk.title
        if rng.random() < self.p["p_guess_company"]:
            return rng.choice(known)
        return None

    def extract(self, chunk, schema, known):
        """
        从一块里抽出若干行。

        【参数】chunk：Chunk；schema：Schema；known：所有公司的名字（模拟 LLM 认得文中出现的公司名）
        【返回】list[Evidence]
        【思路】
            1. （闸门）先判断相关不相关：不相关就直接返回空（只花一次便宜的调用）；
            2. 按每一列的"说法"在块里找数字；带上下文时，表头里的 "(in thousands" 决定单位；
            3. 只找到"干扰项"（加权平均股数、合并的应付账款）时，以 p_take_distractor 的概率把它当成答案，
               但理由里会如实写明（对账时会用到）；
            4. 没有闸门、又什么也没找到时，以 p_halluc 的概率"编"一行（一半的时候连原文引用也是编的）。
        """
        fields = schema.field_names()
        hits = self._find(chunk, fields)
        relevant = bool(hits)
        if self.gate:
            self.calls["gate"] += 1
            g = self._rng(chunk, schema.table, "gate").random()
            judged = (not relevant and g < self.p["p_gate_fp"]) or (relevant and g >= self.p["p_gate_miss"])
            if not judged:
                return []
        self.calls["extract"] += 1
        company = self._company(chunk, known, self._rng(chunk, "company"))
        rows = []
        kinds_here = {h[2] for h in hits}
        for f, value, kind, quote, unit_ok in hits:
            rng = self._rng(chunk, f, kind, quote)                 # 这个数字自己的随机数
            if kind in ("weighted", "combined") and "explicit" in kinds_here:
                continue                                           # 同一块里有明确的值，就不要干扰项
            if kind in ("weighted", "combined") and rng.random() >= self.p["p_take_distractor"]:
                continue
            if rng.random() < self.p["p_misread"]:
                value = value * 10                                 # 读错（多读了一个 0）
            elif quote.count("|") and rng.random() < self.p["p_wrong_line"]:
                value = round(value * rng.uniform(0.3, 3))         # 看串了行：拿了相邻一行的数字
            rows.append(Evidence(company, f, value, quote, RATIONALE[kind] + ("" if unit_ok else "; unit not stated"),
                                 kind, f"{chunk.doc_id}:{chunk.idx}", chunk.doc_id, unit_explicit=unit_ok))
        rng = self._rng(chunk, schema.table, "halluc")
        if not rows and not relevant and rng.random() < self.p["p_halluc"]:
            f = fields[0]
            fake = round(rng.choice([1e6, 5e6, 2e7, 8e7]) * rng.uniform(0.5, 2))
            quote = f"{f.replace('_', ' ')} of {fake:,}" if rng.random() < 0.5 else ""   # 一半的时候连引用也编出来
            rows.append(Evidence(company or rng.choice(known), f, fake, quote, "inferred", "explicit",
                                 f"{chunk.doc_id}:{chunk.idx}", chunk.doc_id, supported=False))
        return rows

    def _find(self, chunk, fields):
        """在块里找每一列的说法。【返回】list[(列, 规范化的值, 种类, 原文, 单位是否明确)]"""
        found = []
        thousands_known = self.contextualized and chunk.table_header and "thousand" in chunk.table_header
        for f in fields:
            for pattern, kind, unit in PATTERNS.get(f, []):
                for m in re.finditer(pattern, chunk.text, flags=re.M):
                    groups = m.groups()
                    raw = groups[-1]
                    value = float(raw.replace(",", ""))
                    unit_ok = True
                    if unit == "thousand":
                        value *= 1000
                    elif unit == "million":
                        value *= 1_000_000
                    elif unit == "table":
                        start = chunk.text[:m.start()]
                        unit_ok = bool(thousands_known or "(in thousands" in start)
                        if unit_ok:
                            value *= 1000                          # 表头在这一块里（或者带了上下文）
                    if f == "net_income" and (groups[0] == "Net loss" or m.group(0).count("(") > 0):
                        value = -abs(value)
                    found.append((f, round(value), kind, m.group(0)[:80], unit_ok))
        return found


# =============================================================================
# 第 4 部分：对账合并（reconciliation）
# =============================================================================
@dataclass
class ReconcileReport:
    """对账的记录：每种操作做了几次、比较了多少对、原始行数和对账后的行数。"""
    dedup: int = 0
    consolidate: int = 0
    resolve: int = 0
    dropped_unsupported: int = 0
    dropped_no_key: int = 0
    raw_rows: int = 0
    final_rows: int = 0
    pairs_all: int = 0
    pairs_grouped: int = 0
    log: list = field(default_factory=list)


def quote_consistent(r):
    """
    值和它引用的原文对得上吗？（对账时"对照出处检查"的一个程序化版本）

    【思路】从原文里取出所有数字，看值是否等于其中某个数（或它的一千倍、一百万倍）。
        读错的值（多读了一个 0、看串了行）和原文里的数字对不上。
    """
    for raw in re.findall(r"[\d,]+(?:\.\d+)?", r.quote):
        try:
            n = float(raw.replace(",", ""))
        except ValueError:
            continue
        if any(abs(abs(r.value) - n * k) <= 0.5 * k for k in (1, 1000, 1_000_000)):
            return True
    return False


def merge_near_duplicates(rs):
    """
    同一个数字的不同写法：利润表里是 "99,242"（千美元），MD&A 里是 "$99.24 million"。规范化之后差一点点
    （99,242,000 vs 99,240,000），但显然是同一个值。相差不到 0.5% 的值算作重复，统一成**更精确**的那个
    （末尾的 0 更少的）。

    【返回】新的行列表（值已经统一；行数不变，之后按"相同的值"去重）
    """
    def precision(v):
        s = str(int(abs(v)))
        return len(s) - len(s.rstrip("0"))                    # 末尾的 0 越少越精确

    out = []
    for r in rs:
        best = min((x for x in rs if abs(x.value - r.value) <= 0.005 * max(abs(r.value), 1)),
                   key=lambda x: (precision(x.value), x.value))
        out.append(Evidence(r.company, r.field, best.value, r.quote, r.rationale, r.kind, r.chunk, r.doc_id,
                            r.supported, r.unit_explicit) if best.value != r.value else r)
    return out


def reconcile(rows, schema, seed=0, params=None):
    """
    对账（论文定义 3）：把原始表变成"可以直接回答问题"的表。

    【思路】
        1. 选主键（这里是公司名），规范化（canonical）后分组 —— 只在组内比较，比较次数从 O(n²) 降到 Σ|g|²；
        2. 核对出处：引用的原文必须真的出现在那一块里，否则丢掉（编出来的行）；丢掉不知道属于哪家公司的行；
        3. 组内的三种操作（幻灯片第 48~55 页）：
             · 去重：几块里都抽到了同一个值（"2,979,692" 千美元和 "$2.98 million" 规范化后只差四舍五入，
               算作同一个值，见 merge_near_duplicates）；
             · 合并残缺的信息：没有明确的"借款总额"时，把短期借款和长期借款加起来；
             · 解决冲突：值不同时，根据理由选 —— "明确写着已发行并流通的股数"优先于"加权平均股数"；
               和引用的原文对不上的值（读错了）排在最后；单位明确的优先于单位不明的（看不到表头的表格数字）；
               还不能决定时取出现次数最多的；
               有 RECONCILE["p_wrong_choice"] 的概率选错（LLM 的推理错误）。
    【返回】(对账后的表：{公司: {列: 值}}, ReconcileReport)
    """
    p = dict(RECONCILE)
    if params:
        p.update(params)
    rep = ReconcileReport(raw_rows=len(rows))
    n = len(rows)
    rep.pairs_all = n * (n - 1) // 2
    groups = {}
    for r in rows:
        if not r.quote or not r.supported:
            rep.dropped_unsupported += 1
            continue
        key = canonical(r.company)
        if key is None:
            rep.dropped_no_key += 1
            continue
        groups.setdefault(key, []).append(r)
    rep.pairs_grouped = sum(len(g) * (len(g) - 1) // 2 for g in groups.values())
    table = {}
    for key in sorted(groups):
        g = groups[key]
        rng = random.Random(f"reconcile|{seed}|{schema.table}|{key}")
        name = max((r.company for r in g), key=len)
        out = {}
        by_field = {}
        for r in g:
            by_field.setdefault(r.field, []).append(r)
        for f, rs in sorted(by_field.items()):
            rs = merge_near_duplicates(rs)
            values = sorted({r.value for r in rs})
            if len(rs) > len(values):
                rep.dedup += len(rs) - len(values)
            if len(values) == 1:
                out[f] = values[0]
                continue
            # 依据出处和理由排优先级：和引用的原文对得上的 > 对不上的（读错了）；明确写着的 > 干扰项（加权平均、合并的）；
            # 单位明确的 > 单位不明的；出现次数多的 > 少的
            def score(v):
                rv = [r for r in rs if r.value == v]
                return (any(quote_consistent(r) for r in rv),
                        any(r.kind in ("explicit", "component_short", "component_long") for r in rv),
                        any(r.unit_explicit for r in rv), len(rv))
            ordered = []
            for r in rs:                                           # 按第一次出现的顺序，打破平局
                if r.value not in ordered:
                    ordered.append(r.value)
            best = max(ordered, key=score)
            if rng.random() < p["p_wrong_choice"]:
                others = [v for v in values if v != best]
                best = rng.choice(others) if others else best
            out[f] = best
            rep.resolve += 1
            rep.log.append(f"{name} · {f}: {len(values)} 个不同的值 {[f'{v:,.0f}' for v in ordered[:3]]} → 选 {best:,.0f}")
        if "total_borrowings" in [c.name for c in schema.columns] and "total_borrowings" not in out:
            if "short_term_borrowings" in out and "long_term_debt" in out:
                out["total_borrowings"] = out["short_term_borrowings"] + out["long_term_debt"]
                rep.consolidate += 1
        table[name] = out
    rep.final_rows = len(table)
    return table, rep


# =============================================================================
# 第 5 部分：在表上用 SQL 回答
# =============================================================================
def to_db(table, schema):
    """把对账后的表放进 SQLite（一行一家公司）。"""
    db = sqlite3.connect(":memory:")
    cols = [c.name for c in schema.columns]
    db.execute(f"CREATE TABLE {schema.table} ({', '.join(c + (' TEXT' if c == 'company_name' else ' REAL') for c in cols)})")
    for name, vals in table.items():
        db.execute(f"INSERT INTO {schema.table} VALUES ({','.join('?' * len(cols))})",
                   [name] + [vals.get(c) for c in cols[1:]])
    return db


def answer_sql(table, schema, q):
    """
    把问题翻译成 SQL（这里按问题类型直接写好；真实的 SLIDERS 用第 7 讲的语义解析器生成 SUQL）并执行。

    【返回】答案：公司名 / 数值 / 计数
    """
    db = to_db(table, schema)
    t, f = schema.table, q["field"]
    slip = random.Random(f"sql|{q['id']}").random() < P_SQL_ERROR   # 写错 SQL（论文：在表上推理也会出错）
    if q["type"] == "spotlight":
        rows = db.execute(f"SELECT company_name, {f} FROM {t}").fetchall()
        if slip:
            rows = db.execute(f"SELECT company_name, {[c.name for c in schema.columns][-1]} FROM {t}").fetchall()
        target = canonical(q["company"])
        vals = [v for n, v in rows if canonical(n) == target and v is not None]
        return vals[0] if vals else None
    if q["type"] in ("max", "min"):
        order = "DESC" if (q["type"] == "max") != slip else "ASC"
        row = db.execute(f"SELECT company_name FROM {t} WHERE {f} IS NOT NULL ORDER BY {f} {order} LIMIT 1").fetchone()
        return row[0] if row else None
    if q["type"] == "count_negative":
        return db.execute(f"SELECT COUNT(*) FROM {t} WHERE {f} {'<=' if not slip else '>'} 0").fetchone()[0]
    if q["type"] == "avg":
        return db.execute(f"SELECT AVG({f}) FROM {t}").fetchone()[0]
    raise ValueError(q["type"])


_TABLES = {}    # 证据表的缓存：同一批文档、同一张表结构、同一种配置，只建一次（后续的问题直接复用）


def build_table(corpus, schema_kind, chunker="semantic", contextualized=True, gate=True, do_reconcile=True, seed=0):
    """
    为一批文档建一张证据表：设计表结构 → 分块 → 抽取 → 对账。

    【为什么要缓存】SLIDERS 的证据表是**可以重复使用**的：同一批文档上的后续问题，只要表结构里有需要的列，
        就不用再读一遍文档（论文的 WikiCeleb100、FinQ100 就是在测这一点）。
    【返回】dict：{"table": 对账后的表, "rows": 原始行, "report": 对账报告（不对账时为 None）, "calls": 调用次数, "schema"}
    """
    # 键里用全部文档的编号（文档编号全局唯一）。不能用 id(corpus)：Python 会把回收掉的对象的 id
    # 重新分给新对象，那样就会取到另一批文档的旧表 —— 编写本讲时真的踩过这个坑。
    key = (tuple(d.doc_id for d in corpus.docs), schema_kind, chunker, contextualized, gate, do_reconcile, seed)
    if key in _TABLES:
        return _TABLES[key]
    schema = induce_schema(schema_kind)
    known = [d.company for d in corpus.docs]
    ex = Extractor(contextualized=contextualized, gate=gate, seed=seed)
    rows = []
    for d in corpus.docs:
        chunks = semantic_chunks(d) if chunker == "semantic" else fixed_chunks(d)
        for c in chunks:
            rows += ex.extract(c, schema, known)
    if do_reconcile:
        table, rep = reconcile(rows, schema, seed=seed)
    else:
        table, rep = naive_table(rows, schema), None
    _TABLES[key] = {"table": table, "rows": rows, "report": rep, "calls": dict(ex.calls), "schema": schema}
    return _TABLES[key]


def run_sliders(corpus, q, **config):
    """
    完整的 SLIDERS：取（或建）这批文档的证据表，再把问题翻译成 SQL、在表上执行。

    【参数】corpus：这个实例的全部文档；q：问题；config：build_table 的配置（用于消融）
    【返回】(答案, build_table 返回的信息)
    """
    info = build_table(corpus, q["schema"], **config)
    return answer_sql(info["table"], info["schema"], q), info


def naive_table(rows, schema):
    """
    消融"不对账"：每家公司每一列取第一次抽到的值（不去重、不解决冲突、不合并残缺的信息、不丢掉编出来的行）。
    """
    table = {}
    for r in rows:
        if r.company is None:
            continue
        table.setdefault(r.company, {})
        table[r.company].setdefault(r.field, r.value)
    return table


# =============================================================================
# 第 6 部分：基线
# =============================================================================
def long_context(corpus, docs, q, seed=0, params=None):
    """
    基线 1：把问题涉及的全部文档塞进 LLM 的上下文，直接回答。

    【思路】（参数见 LONG_CONTEXT）
        · 超出窗口的部分被截掉，看不到；
        · 对每个需要的事实，读对的概率 = base − decay × (上下文长度 / 10 万) − middle × sin(π × 位置)，
          位置 0 表示开头、1 表示结尾（"Lost in the Middle"：两头好、中间差）；
        · 有干扰项时（加权平均股数、合并的应付账款），按 confuse + confuse_per_100k × 长度 的概率选了干扰项；
        · 读错时，给出一个偏差很大的数（相当于看错了行）。
    【返回】答案
    """
    import math
    p = dict(LONG_CONTEXT)
    if params:
        p.update(params)
    total = sum(d.n_tokens for d in docs)
    length = min(total, p["window"])
    perceived, pos = {}, 0
    for d in docs:
        start = pos
        pos += d.n_tokens
        if start >= p["window"]:
            continue                                              # 被截掉了
        rng = random.Random(f"long|{seed}|{q['id']}|{d.doc_id}")
        where = (start + d.n_tokens / 2) / max(length, 1)
        p_read = max(0.05, p["base"] - p["decay"] * length / 100_000 - p["middle"] * math.sin(math.pi * min(where, 1.0)))
        value = truth_value(d, q["field"])
        distractor = distractor_value(d, q["field"])
        if distractor is not None and rng.random() < p["confuse"] + p["confuse_per_100k"] * length / 100_000:
            value = distractor
        elif rng.random() > p_read:
            value = value * rng.choice([0.1, 0.5, 2, 10]) if value else value
        perceived[d.company] = value
    return decide(perceived, q)


def rag_topk(corpus, docs, q, k=8, seed=0):
    """
    基线 2：检索 + 阅读。把文档切成固定大小的块，用 BM25 按问题检索前 k 块，从这 k 块里读出数字再回答。

    【问题】一个问题涉及几十份文档时，k 块根本覆盖不了；块里常常没有公司名、没有表头（单位）。
    【返回】答案
    """
    chunks = [c for d in docs for c in fixed_chunks(d)]
    index = BM25([Passage(pid=f"{c.doc_id}:{c.idx}", title="", sentences=[c.text]) for c in chunks])
    by_id = {f"{c.doc_id}:{c.idx}": c for c in chunks}
    hits = [by_id[p.pid] for p in index.search(q["text"], k=k)]
    schema = induce_schema(q["schema"])
    ex = Extractor(contextualized=False, gate=True, seed=seed)
    known = [d.company for d in corpus.docs]
    rows = [r for c in hits for r in ex.extract(c, schema, known)]
    table = naive_table(rows, schema)
    if q["schema"] == "debt":
        for name, vals in table.items():
            if "total_borrowings" not in vals and "short_term_borrowings" in vals and "long_term_debt" in vals:
                vals["total_borrowings"] = vals["short_term_borrowings"] + vals["long_term_debt"]
    perceived = {n: v.get(q["field"]) for n, v in table.items() if v.get(q["field"]) is not None}
    return decide(perceived, q)


def truth_value(doc, f):
    """一份文档里某一列的真实值。"""
    t = doc.truth
    return {"total_shares_outstanding": t["shares_outstanding"], "accounts_payable": t["accounts_payable"],
            "total_borrowings": t["total_debt"], "revenue": t["revenue"], "net_income": t["net_income"]}[f]


def distractor_value(doc, f):
    """一份文档里某一列的"干扰项"（没有就返回 None）。"""
    t = doc.truth
    if f == "total_shares_outstanding":
        return t["weighted_shares"]
    if f == "accounts_payable" and t["combined_ap"]:
        return t["accounts_payable"] + t["accrued_expenses"]
    return None


def decide(perceived, q):
    """根据"看到的"各公司的值回答问题（长上下文和检索两个基线共用）。"""
    vals = {n: v for n, v in perceived.items() if v is not None}
    if q["type"] == "spotlight":
        return next((v for n, v in vals.items() if canonical(n) == canonical(q["company"])), None)
    if not vals:
        return None if q["type"] in ("max", "min") else 0
    if q["type"] == "max":
        return max(vals, key=lambda n: vals[n])
    if q["type"] == "min":
        return min(vals, key=lambda n: vals[n])
    if q["type"] == "count_negative":
        return sum(v < 0 for v in vals.values())
    if q["type"] == "avg":
        return sum(vals.values()) / len(vals)
    raise ValueError(q["type"])


# =============================================================================
# 第 7 部分：问题与判分
# =============================================================================
def make_questions(corpus, seed=0):
    """
    为一批文档（一个"实例"）出 5 道题（仿照 Loong 的"聚光灯""比较""聚合"三类）：

        聚光灯（答案在一份文档里）：某家公司的流通股数；某家公司的应付账款；
        比较：流通股数最多的公司；借款总额最少的公司；
        聚合：有几家公司亏损。
    和 Loong 一样，每道题都给出这个实例的**全部**文档（聚光灯题也要先在一大批文档里找到那一份）。

    【参数】corpus：这个实例的全部文档；seed：用来区分不同实例（也决定聚光灯题选哪家公司）
    【返回】list[dict]：{"id", "type", "schema", "field", "text", "docs"（全部文档）, "gold", "company"（聚光灯题）}
    【为什么每个实例用自己的一批文档】抽取的错误是"按块"固定的：同一份文档被读错一次，用到它的所有问题都会错。
        如果很多题共用同一批文档，准确率就取决于少数几份文档的运气；每个实例独立，错误才互相独立。
    """
    rng = random.Random(f"questions|{seed}")
    a, b = rng.sample(corpus.docs, 2)
    qs = [
        {"type": "spotlight", "schema": "shares", "field": "total_shares_outstanding", "docs": corpus.docs, "company": a.company,
         "text": f"What is the total number of shares outstanding of {a.company}?"},
        {"type": "spotlight", "schema": "ap", "field": "accounts_payable", "docs": corpus.docs, "company": b.company,
         "text": f"What are the accounts payable of {b.company}?"},
        {"type": "max", "schema": "shares", "field": "total_shares_outstanding", "docs": corpus.docs,
         "text": "Which company has the highest total shares outstanding?"},
        {"type": "min", "schema": "debt", "field": "total_borrowings", "docs": corpus.docs,
         "text": "Which company has the lowest total borrowings?"},
        {"type": "count_negative", "schema": "results", "field": "net_income", "docs": corpus.docs,
         "text": "How many of these companies reported a net loss for the quarter?"},
    ]
    for k, q in enumerate(qs):
        q["id"] = f"{seed}-{k}"
        q["gold"] = gold_answer(q)
    return qs


def make_instances(n_docs, n_instances, seed=0):
    """
    生成 n_instances 个实例，每个实例有自己的 n_docs 份文档和 5 道题。

    【返回】list[(Corpus, 问题列表)]
    """
    out = []
    for i in range(n_instances):
        s = seed * 1000 + i
        corpus = make_corpus(n=n_docs, seed=s)
        out.append((corpus, make_questions(corpus, seed=s)))
    return out


def gold_answer(q):
    """标准答案（用真实的数字算）。"""
    vals = {d.company: truth_value(d, q["field"]) for d in q["docs"]}
    return decide(vals, q)


def correct(pred, q):
    """答案对不对：数值允许 1% 的误差，公司名比较规范形式，计数要完全一致。"""
    gold = q["gold"]
    if pred is None:
        return False
    if q["type"] in ("max", "min"):
        return canonical(pred) == canonical(gold)
    if q["type"] == "count_negative":
        return pred == gold
    return abs(pred - gold) <= 0.01 * abs(gold)
