#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
03_groundedness_simulator.py — 接地性 (Groundedness) 检查模拟器 + 幻觉率代理

不调用任何 LLM：用"内容词覆盖"近似"主张是否被证据支撑"（lexical entailment 的
玩具版），演示官方 groundedness 检查的核心机制（模块 12 README §6.4）：
  1. 把回答拆成主张 (claim)，逐主张找支撑证据 (evidence span)；
  2. supported → 记录证据 id（= Citations 的思路：每条主张可回溯到原文）；
  3. unsupported → 幻觉候选；
  4. 弃答（"我不知道/I don't know"）单独分类——不算幻觉，但计入弃答率
     （防"用拒答刷低幻觉率"的作弊，官方要求给 judge "Unknown 出口"的同款思想）。

真实系统里"支撑判定"由校准过的 LLM-as-judge 或平台 Citations 完成；
本脚本的词覆盖判定只是教学替身。

运行：python3 03_groundedness_simulator.py   （无网络、无 key、纯标准库）
对应教学点：模块 12 README §6.4 / §11.13
"""

# ---------------------------------------------------------------------------
# 检索证据（evidence spans，模拟 RAG 检索到的 chunk）与待评回答
# ---------------------------------------------------------------------------

EVIDENCE = {
    "e1": "本产品基础版价格为每席位每月 12 美元，年付享 8 折。",
    "e2": "企业版支持 SSO 单点登录与审计日志导出，最低开通 20 席位。",
    "e3": "技术支持响应时间：标准工单 24 小时内，关键故障 1 小时内。",
}

ANSWERS = {
    "A-全有据": [
        "基础版价格是每席位每月 12 美元。",
        "企业版支持 SSO 单点登录。",
    ],
    "B-部分幻觉": [
        "基础版价格是每席位每月 12 美元。",
        "所有版本都提供 7×24 专属客户经理。",        # 证据里没有
        "企业版支持审计日志导出。",
        "基础版免费提供无限存储空间。",               # 证据里没有
    ],
    "C-会弃答": [
        "基础版价格是每席位每月 12 美元。",
        "关于数据驻留选项我目前没有足够的信息，无法确认。",
    ],
}

ABSTAIN_PATTERNS = ("没有足够的信息", "无法确认", "我不知道", "不确定",
                    "don't have enough information", "unknown")

STOPWORDS = set("的了 是在 都 提供 支持 与 和 请 您 的 所有 版本 中 上下"
                .split())


def content_words(text):
    """英文/数字按词，中文串取全部相邻 2-gram（含奇偶两种偏移），去停用词。"""
    tokens, i = [], 0
    cjk = ""
    while i < len(text):
        ch = text[i]
        if ch.isascii() and (ch.isalnum() or ch == "-"):
            j = i
            while j < len(text) and (text[j].isascii()
                                    and (text[j].isalnum() or text[j] == "-")):
                j += 1
            tokens.append(text[i:j].lower())
            i = j
        elif not ch.isspace() and ch not in "，。：；？！、":
            cjk += ch
            i += 1
        else:
            i += 1
    grams = {cjk[i:i + 2] for i in range(len(cjk) - 1)}
    return set(t for t in tokens if t not in STOPWORDS) | \
           set(t for t in grams if t not in STOPWORDS)


def is_abstain(claim):
    low = claim.lower()
    return any(p in low for p in ABSTAIN_PATTERNS)


def find_support(claim):
    """返回支撑该主张的证据 id；判定=主张内容词被某证据覆盖的比例 ≥ 阈值。"""
    cw = content_words(claim)
    if not cw:
        return None
    threshold = 0.6
    for eid, text in EVIDENCE.items():
        ew = content_words(text)
        covered = len(cw & ew)
        if cw and covered / len(cw) >= threshold:
            return eid
    return None


def evaluate_answer(name, claims):
    print(f"\n### 回答 {name}")
    stats = {"supported": [], "unsupported": [], "abstain": []}
    for claim in claims:
        if is_abstain(claim):
            stats["abstain"].append((claim, None))
            tag = "ABSTAIN（弃答，不计幻觉）"
        else:
            eid = find_support(claim)
            if eid:
                stats["supported"].append((claim, eid))
                tag = f"supported ← {eid}   （主张可回溯到证据 = citation 思路）"
            else:
                stats["unsupported"].append((claim, None))
                tag = "UNSUPPORTED ← 幻觉候选"
        print(f"  - {claim}")
        print(f"      {tag}")

    n_total = len(stats["supported"]) + len(stats["unsupported"])
    groundedness = (len(stats["supported"]) / n_total) if n_total else None
    abstain_rate = len(stats["abstain"]) / len(claims)
    return {
        "name": name,
        "claims": len(claims),
        "supported": len(stats["supported"]),
        "unsupported": len(stats["unsupported"]),
        "abstain": len(stats["abstain"]),
        "groundedness": groundedness,
        "abstain_rate": abstain_rate,
        "unsupported_detail": [c for c, _ in stats["unsupported"]],
    }


def main():
    print("=" * 80)
    print("接地性 (Groundedness) 检查模拟器 —— 主张级支撑判定 / 幻觉率 / 弃答率")
    print("=" * 80)
    print("证据库:")
    for eid, text in EVIDENCE.items():
        print(f"  [{eid}] {text}")

    results = [evaluate_answer(name, claims) for name, claims in ANSWERS.items()]

    print("\n" + "=" * 80)
    print("汇总（groundedness 与 弃答率分开报 —— 防'用拒答刷分'）")
    print("=" * 80)
    print(f"{'回答':<12}{'主张数':>6}{'有据':>6}{'无据':>6}{'弃答':>6}"
          f"{'groundedness':>15}{'弃答率':>8}")
    for r in results:
        g = f"{r['groundedness']:.0%}" if r['groundedness'] is not None else "n/a"
        print(f"{r['name']:<12}{r['claims']:>6}{r['supported']:>6}"
              f"{r['unsupported']:>6}{r['abstain']:>6}{g:>15}{r['abstain_rate']:>8.0%}")

    print("\n解读:")
    print("1) A-全有据: 每条主张都能挂到证据 id —— 这就是 Citations 的价值:")
    print("   逐主张可回溯，验证与展示来源都程序化可查。")
    print("2) B-部分幻觉: '专属客户经理'与'免费无限存储'无证据支撑 → 幻觉候选。")
    print("   注意: 有据 ≠ 正确（证据本身可能过时），groundedness 不替代 accuracy。")
    print("3) C-会弃答: '没有足够信息'被单独归类 —— 官方减幻觉第一招(允许说不知道),")
    print("   judge/回答都该有 Unknown 出口; 但弃答率要单独上报,")
    print("   否则'全拒答'就成了 groundedness=100% 的作弊解。")
    print("4) 局限: 本脚本用词覆盖近似'支撑'; 真实系统应使用校准过的 LLM-as-judge")
    print("   或平台 Citations 做支撑判定, 并定期人工抽检校准。")


if __name__ == "__main__":
    main()
