临床 Python 进阶路线图
⌕ /
路线图 › 实战案例 · Agent 开发

案例 09 · SDTM 一致性核查 Agent(记忆检索 / 阈值标定)

case09_SDTM一致性核查Agent.py · 698 行 · 24 KB
案例 09 · SDTM 跨域一致性核查 Agent(规则检索 + 可追溯引用) =============================================================
Python
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
案例 09 · SDTM 跨域一致性核查 Agent(规则检索 + 可追溯引用)
=============================================================

**目标**:让 Agent 的每一句结论都能**指回两样东西** ——
一条工具跑出来的数字,和一条写在核查计划里的规则。

**覆盖章节**:第 21 章(记忆与上下文管理)、第 20 章(工具调用进阶)、
第 12 章(SDTM 数据处理)

为什么这个案例值得单独写
------------------------
单域的格式检查(变量存在、类型正确、主键唯一)用脚本就够了。
真正耗时的是**跨域一致性核查**:

    各域的 USUBJID 都能在 DM 里找到吗?
    AE 的开始日期早于首次给药,该不该算 TEAE?
    "DM 有受试者但 AE 域没有记录" 是错误还是正常?

这些判断依赖两样东西:
  1. **数据**(要靠工具真实计算,不能靠模型回忆)
  2. **规则**(写在核查计划 / SOP 里,模型没读过,必须检索出来)

于是这个案例的结构就是:**工具给数字,检索给依据,事实表把它们绑在一起**。

五个场景
--------
a) **知识库与检索** —— 把核查计划切对,按标题加权召回
b) **核查 Agent 跑通** —— 工具 + 规则检索 + 带引用的结论
c) **记忆与上下文预算** —— 三种记忆策略在 10 轮会话下的成本对比
d) **数字可追溯** —— 从结论里的 306 一路回溯到原始数据行,并**重放验证**
e) **规则检索的阈值靠标定** —— 用 15 条样例扫出最优阈值,并守住
   "检索不到就说没找到"的红线

运行
----
    python cases/case09_SDTM一致性核查Agent.py
    python cases/case09_SDTM一致性核查Agent.py --only e

离线可跑:不需要 API Key。工具**真实执行**,数字来自真实数据。
"""

from __future__ import annotations

import argparse
import json
import re
import sys
from pathlib import Path

BASE = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(BASE))

import clinic.agent_tools as T                                   # noqa: E402
from clinic import agent_core as core                            # noqa: E402
from clinic import agent_memory as mem                           # noqa: E402

OUT = BASE / "outputs"
KB_DIR = OUT / "kb"
ALLOWED = frozenset(T.ALLOWED_DATASETS)


# ==========================================================================
# 「外部文档」:一份核查计划(QC Plan)的节选
# ==========================================================================
# 真实项目里,这段内容来自公司的 SOP / 核查计划 / SAP。
# 我们把它当作"知识库文档",验证 Agent 能不能**检索到规则并引用它**。
QC_PLAN_TEXT = """# 核查计划 · CDISCPILOT01(节选)

## 3. 受试者层面一致性

### 3.1 受试者标识一致性
各数据集的 USUBJID 必须全部存在于 DM 域。

- "某域存在但 DM 中不存在" → **严重级:高**,必须修复(数据链条断裂)。
- "DM 中存在但某域无记录" → **严重级:信息**,通常属正常
  (例如未发生不良事件的受试者,AE 域自然没有记录)。
- 判定不得只看记录数是否相等,必须做集合运算。

### 3.2 受试者数量关系
受试者数量的常规关系(用于交叉核对,不构成单独的错误判定):

    筛选人数 ≥ 随机化人数 ≥ 安全性人群(SAFFL='Y')≥ 有效性人群(EFFFL='Y')

CDISCPILOT01:筛选 306 人,随机化 254 人。
任何一层出现"下层大于上层",都说明分层标志位(SAFFL / EFFFL)有误。

### 3.3 缺失值与零
SDTM 中空值表示"未收集",**不允许用 0 代替缺失**。
数值变量为 0 时必须能溯源到 CRF 上的实际取值。

## 4. 日期与时间一致性

### 4.1 治疗中出现的定义(TEAE)
不良事件开始日期(AESTDTC)**早于**首次给药日期(EX 域最早 EXSTDTC)的,
判定为"用药前事件",不纳入 TEAE 汇总。

日期比较要求精确到日;任一侧日期不完整(缺失日或月)时,
**不得直接比较**,应标记为"无法判定"并人工核查 —— 不允许默认按相等处理。

### 4.2 日期先后关系
- AESTDTC ≤ AEENDTC
- 处置日期(DSSTDTC)不应早于首次给药日期
- 派生自同一来源的两个日期变量必须保持一致

## 5. 严重性分级与处置

| 严重级 | 含义 | 处置 |
|---|---|---|
| 高 | 会导致递交物错误 | 必须修复后才能出表 |
| 中 | 可能影响结论,需确认 | 人工确认并留痕 |
| 低 | 格式/记录问题 | 记录,择机修复 |
| 信息 | 状态记录,不是错误 | 无需处理 |

## 6. 结论与可追溯性要求

### 6.1 每个数字必须有出处
核查报告中出现的**每一个数字**,都必须能追溯到
「工具 → 数据集 → 筛选条件 → 记录」这条链路上的具体位置。

模型或人给出的数字,若无法落到工具返回值上,一律不得写入报告。

### 6.2 规则引用要写到章节号
结论中每条判定所依据的规则,必须写明核查计划或 SAP 的**章节号**,
不允许写"根据相关规定"这类无法核查的表述。

## 7. 报告模板要求

结论按"问题 → 依据 → 处置建议"三段式组织。
未能完成的核查项必须显式列出,**不允许省略**(省略会让报告被误读为全覆盖)。
"""


def build_knowledge_base(min_score: float = 0.25
                         ) -> tuple[list[mem.Chunk], mem.KeywordRetriever]:
    """构建知识库:把核查计划导出为 Markdown,再切 chunk 建索引。

    ★ 关键设计:**"检索"是一个工具,不是一段提示词。**
    把文档塞进 System Prompt 的做法在文档变大后必然失效,
    而且模型引用的是"它记得的",不是"文档里写的"。

    ``min_score`` 决定"多相关才算找到"。**这个数不能拍脑袋定** ——
    怎么定,见场景 e。
    """
    KB_DIR.mkdir(parents=True, exist_ok=True)
    plan = KB_DIR / "QC_PLAN_v3.md"
    plan.write_text(QC_PLAN_TEXT, encoding="utf-8")

    # 语料 = 核查计划 + 教程里讲方法与陷阱的两章(真实文件)
    paths = [plan,
             BASE / "docs" / "12-SDTM数据处理实战.md",
             BASE / "docs" / "10-日期缺失值格式与数据质量.md",
             BASE / "docs" / "09-合并重塑与分组汇总.md"]
    chunks = mem.load_corpus([p for p in paths if Path(p).exists()], max_chars=1200)
    return chunks, mem.KeywordRetriever(chunks, min_score=min_score)


# ==========================================================================
# 工具层:在案例 08 的基础上,多加一个 search_rules
# ==========================================================================
def build_registry(retriever: mem.KeywordRetriever) -> core.ToolRegistry:
    reg = core.ToolRegistry()

    @reg.tool("list_datasets", "列出可访问的数据集及规模。",
              {"type": "object", "properties": {}, "required": [],
               "additionalProperties": False})
    def _list(args: dict) -> dict:
        return T.list_datasets()

    @reg.tool("run_qc_checks",
              "对数据集执行标准质量检查(必需变量、主键、日期逻辑、受控术语等)。",
              {"type": "object",
               "properties": {
                   "dataset": {"type": "string", "enum": sorted(ALLOWED),
                               "description": "数据集名称"},
                   "dataset_type": {"type": "string",
                                    "enum": ["auto", "sdtm", "adam"],
                                    "description": "标准类型,auto=按名称推断"},
                   "max_issues": {"type": "integer", "minimum": 1,
                                  "maximum": 100,
                                  "description": "最多返回多少条明细,默认 20"}},
               "required": ["dataset"], "additionalProperties": False})
    def _qc(args: dict) -> dict:
        res = T.run_qc_checks(args["dataset"], args.get("dataset_type") or "auto")
        n = int(args.get("max_issues") or 20)
        res = dict(res)
        res["问题"] = mem.cap_rows(res["问题"], n)      # ★ 统一截断并标明"已截断"
        return res

    @reg.tool("check_subject_consistency",
              "检查各域 USUBJID 是否都存在于 DM 中(跨域一致性核查)。",
              {"type": "object",
               "properties": {
                   "domains": {"type": "array",
                               "items": {"type": "string", "enum": sorted(ALLOWED)},
                               "description": "要检查的域,默认 ae/ex/ds/adsl"}},
               "required": [], "additionalProperties": False})
    def _consistency(args: dict) -> dict:
        return T.check_subject_consistency(args.get("domains") or None)

    @reg.tool("search_rules",
              "在核查计划 / SOP / SAP 知识库中检索规则依据。"
              "**凡是要下'是否符合要求'的判断,必须先调用本工具取依据。**"
              "返回结果带章节号,引用时必须照抄章节号。",
              {"type": "object",
               "properties": {
                   "query": {"type": "string",
                             "description": "检索问题,如'USUBJID 一致性 判定规则'"},
                   "top_k": {"type": "integer", "minimum": 1, "maximum": 5,
                             "description": "返回几条依据,默认 3"}},
               "required": ["query"], "additionalProperties": False})
    def _search(args: dict) -> dict:
        hits = retriever.search(args["query"], int(args.get("top_k") or 3))
        good = [(c, s) for c, s in hits if s >= retriever.min_score]
        if not good:                       # ★ 红线:检索不到就明说
            return {"查询": args["query"], "找到": 0, "未找到": True,
                    "说明": "知识库中没有与该问题相关的规定。"
                            "请勿凭推测作答;应列入'未能完成的核查项'。",
                    "最高分": hits[0][1] if hits else 0.0}
        return {"查询": args["query"], "找到": len(good), "未找到": False,
                "依据": [{"引用": c.citation(), "标题": c.title,
                          "相关度": s, "片段": c.text[:600]} for c, s in good]}

    # 事实提取:把关键数字和引用来源一起固化下来
    reg.set_fact_rule("check_subject_consistency", lambda a, d: {
        "DM受试者总数": d.get("DM 受试者总数"),
        "孤儿受试者数": sum(int(v.get("不在DM中") or 0)
                            for v in (d.get("各域明细") or {}).values()),
    })
    reg.set_fact_rule("run_qc_checks", lambda a, d: {
        f"{a.get('dataset')}.问题总数": d.get("问题总数"),
        f"{a.get('dataset')}.高优先级问题数": d.get("高优先级问题数"),
    })
    reg.set_fact_rule("search_rules", lambda a, d: (
        {"规则依据章节": "、".join(x["引用"] for x in d.get("依据", []))}
        if d.get("依据") else {"规则依据章节": "未找到(不得凭推测作答)"}))
    return reg


# ==========================================================================
# 一个"像核查员一样工作"的假模型:先取依据、再取数据、最后带引用作答
# ==========================================================================
class ChecklistMock(core.LLMClient):
    """按真实核查流程走:

    ① 检索规则("该按什么标准判")
    ② 跑跨域一致性核查("数据实际是什么样")
    ③ 跑单域质量检查("有没有格式问题")
    ④ 组织结论:**每条判断都挂上章节号**

    它不做推理(是确定性脚本),但它演示了**正确的工具编排顺序** ——
    真实 LLM 在好的提示词和好的工具描述下,走的也是这个顺序。
    """

    def __init__(self) -> None:
        self._step = 0

    def chat(self, messages, tools):
        plan = [
            ("search_rules", {"query": "USUBJID 受试者标识 一致性 严重性 判定规则"}),
            ("check_subject_consistency", {"domains": ["ae", "ex", "ds", "adsl"]}),
            ("search_rules", {"query": "日期先后关系 部分日期 不允许直接比较"}),
            ("run_qc_checks", {"dataset": "ae"}),
        ]
        if self._step < len(plan):
            name, args = plan[self._step]
            self._step += 1
            return core.Message.assistant(tool_calls=[
                core.ToolCall(f"call_{self._step}", name, args)])
        return core.Message.assistant(self._answer(messages))

    @staticmethod
    def _answer(messages) -> str:
        """从工具返回值里取数字与章节号,拼一份**可核查**的报告。"""
        facts: dict[str, object] = {}
        cites: list[str] = []
        for m in messages:
            if m.role != "tool" or not m.content.strip().startswith("{"):
                continue
            try:
                d = json.loads(m.content)
            except json.JSONDecodeError:
                continue
            if "DM 受试者总数" in d:
                facts["DM 受试者总数"] = d["DM 受试者总数"]
                for k, v in (d.get("各域明细") or {}).items():
                    facts[f"{k}.不在DM中"] = v.get("不在DM中")
            if "依据" in d:
                cites += [x["引用"] for x in d["依据"]]
            if "问题总数" in d:
                facts[f"{d.get('数据集')}.问题总数"] = d["问题总数"]

        orphan = sum(int(v or 0) for k, v in facts.items() if k.endswith("不在DM中"))
        lines = [
            "## 结论:跨域受试者一致性",
            "",
            f"- **DM 受试者总数 {facts.get('DM 受试者总数')} 人**"
            f"(依据:check_subject_consistency 工具返回值)",
            f"- 各域'存在但不在 DM 中'的受试者合计 **{orphan}** 人",
        ]
        if orphan == 0:
            lines.append("  → 判定:**通过**。数据链条完整,无孤儿受试者。")
        else:
            lines.append("  → 判定:**不通过**,需立即修复(见下方依据)。")
        lines += [
            f"- AE 域质量问题 {facts.get('ae.问题总数')} 条(含信息级状态记录)",
            "",
            "### 判定依据(规则出处)",
        ]
        for c in dict.fromkeys(cites):
            lines.append(f"- {c}")
        lines += [
            "",
            "### 说明",
            "- “DM 存在但某域无记录”按规则属正常情况,**不计入错误**;"
            "本结论只统计“某域存在但 DM 不存在”。",
            "- 上述每个数字均来自工具返回,未做任何估算。",
        ]
        return "\n".join(lines)


# ==========================================================================
def _h(title: str) -> None:
    print("\n" + "=" * 72)
    print(title)
    print("=" * 72)


# ==========================================================================
# 场景 a · 知识库与检索
# ==========================================================================
def scenario_a() -> mem.KeywordRetriever:
    _h("场景 a · 知识库构建与检索:文档切对了,召回才准")
    chunks, ret = build_knowledge_base()

    print(f"知识库:{len(chunks)} 个 chunk,来自 "
          f"{len({c.doc for c in chunks})} 个文档")
    by_doc: dict[str, int] = {}
    for c in chunks:
        by_doc[c.doc] = by_doc.get(c.doc, 0) + 1
    for doc, n in sorted(by_doc.items(), key=lambda x: -x[1]):
        print(f"  · {doc:36s} {n:3d} chunk")

    print("\n切分策略:**按 Markdown 标题切** —— 结构化文档的第一原则。\n"
          "命中标题的 chunk 会加权 0.3、命中章节号的加权 0.2,\n"
          "这就是“关键词检索为什么可解释”:你能说清它为什么排第一。\n\n"
          "⚠️ 上表里教程文档的引用只显示文件名、没有章节号,原因是那些\n"
          "   小标题没有编号(“### 任务 1:…”)。另外教程里有大量示例代码,\n"
          "   代码块里的 `# 注释` 会被朴素的行解析误判成标题 ——\n"
          "   load_corpus() 用围栏标记做了规避,这是实测踩出来的坑。")

    queries = [
        "DM 中有但某域没有记录的受试者算错误吗",
        "USUBJID 一致性 严重性分级",
        "部分日期 缺失 能不能直接比较",
        "受试者数量 安全性人群 关系",
    ]
    for q in queries:
        print(f"\n▶ 检索:{q}")
        for c, s in ret.search(q, top_k=3):
            flag = "✓" if s >= ret.min_score else "✗(低于阈值,不采用)"
            print(f"   [{s:5.2f}] {c.citation():34s} {c.title[:26]:28s} {flag}")
        print(f"   → 采用 {sum(1 for _c, s in ret.search(q, 3) if s >= ret.min_score)} 条")

    print("""
⚠️ 上面用的阈值是库默认的 0.25 —— 它其实**偏低**,会捞回不相关的内容。
   场景 e 会用一组标定样例把它定到 0.40,并讲清为什么"不能拍脑袋"。

为什么不用向量检索?
  · 知识库只有几十个 chunk —— 关键词检索足够;
  · 临床场景要求**可解释**:被审计问"为什么引用了这一条"时,
    你能回答"因为查询里的'一致性'和标题命中";向量相似度很难解释;
  · 零依赖、离线可用、结果确定(同一问题永远同一答案)。
  文档超过约 200 个 chunk 再考虑向量库,且要保留本方法作为兜底。""")
    return ret


# ==========================================================================
# 场景 b · 核查 Agent 跑通
# ==========================================================================
def scenario_b(retriever: mem.KeywordRetriever,
               verbose: bool) -> core.AgentState:
    _h("场景 b · 核查 Agent:工具给数字,检索给依据")

    reg = build_registry(retriever)
    policy = core.Policy(max_steps=12, allowed_datasets=ALLOWED)
    audit = OUT / "audit" / "case09_trace.jsonl"
    agent = core.Agent(ChecklistMock(), reg, policy,
                       observer=core.ConsoleObserver(verbose=verbose),
                       metrics=(met := core.Metrics()))
    state = agent.run("对 CDISCPILOT01 做跨域一致性核查,并给出带依据的结论")

    print(f"\n【Agent 最终结论】\n{state.answer}")
    print(f"\n【结构化事实表】\n{state.fact_table()}")
    print(f"\n【指标】{met.summary()}")
    print(f"【审计】{audit.relative_to(BASE)}")

    print("""
注意结论里的三个特征(第 21.8 节的标准):
  1. **有数字** —— 306 / 0,全部来自工具返回值
  2. **有出处** —— 判定规则写到章节号(QC_PLAN_v3 第 3.1 节)
  3. **有反例提示** —— 明确说明"DM 有但某域无记录"不算错误

三者缺一,结论就不可核查。第 3 条尤其重要:
如果只报"AE 域有 81 人缺失",核查人员会白忙一场 ——
那 81 人只是**没有发生不良事件**,按规则属正常情况。""")
    return state


# ==========================================================================
# 场景 c · 记忆与上下文预算
# ==========================================================================
def scenario_c(retriever: mem.KeywordRetriever) -> None:
    _h("场景 c · 记忆与上下文预算:10 轮核查会话的真实成本")

    # 用**真实工具返回值**构造一段 10 轮会话(不是编造的假数据)
    reg = build_registry(retriever)
    real = {
        "consistency": reg.execute("check_subject_consistency", {}).data,
        "ae_qc": reg.execute("run_qc_checks", {"dataset": "ae"}).data,
        "adsl_qc": reg.execute("run_qc_checks", {"dataset": "adsl"}).data,
        "rules": reg.execute("search_rules",
                             {"query": "受试者标识一致性 判定规则"}).data,
    }

    msgs: list[core.Message] = [core.Message.system("你是临床统计编程核查助手。")]
    turns = [
        ("核查各域受试者一致性", "consistency"),
        ("AE 域有哪些质量问题", "ae_qc"),
        ("ADSL 域呢", "adsl_qc"),
        ("判定规则是怎么规定的", "rules"),
    ]
    for i in range(3):                                   # 重复追问,模拟真实的多轮
        for q, key in turns:
            msgs.append(core.Message.user(f"(第 {i + 1} 轮){q}"))
            msgs.append(core.Message.assistant(
                tool_calls=[core.ToolCall(f"c{i}{key}", key, {})]))
            msgs.append(core.Message.tool(
                f"c{i}{key}", mem.render_tool_result(real[key], max_chars=2500),
                name=key))
    print(f"会话规模:{len(msgs)} 条消息")

    strategies = [("全量保留 FullMemory", mem.FullMemory()),
                  ("滑窗 WindowMemory(6)", mem.WindowMemory(keep_recent=6)),
                  ("摘要 SummaryMemory(4)", mem.SummaryMemory(keep_recent=4))]
    print(f"\n{'策略':24s} {'轮数':>5s} {'字符数':>9s} {'估算token':>10s} "
          f"{'相对全量':>9s}")
    base = None
    for name, st in strategies:
        s = st.stats(msgs)
        base = base or s["字符数"]
        print(f"{name:24s} {s['轮数']:>5d} {s['字符数']:>9,d} "
              f"{s['估算token']:>10,d} {s['字符数'] / base * 100:>8.1f}%")

    sm = mem.SummaryMemory(keep_recent=4)
    ctx = sm.build(msgs)
    text = "\n".join(m.content for m in ctx)
    print(f"\n【摘要记忆保留了哪些数字】")
    nums = sorted({n for n in re.findall(r"\d+(?:\.\d+)?", text) if len(n) >= 2},
                  key=lambda x: -len(x))[:12]
    print(f"  摘要+近期消息中出现的数字:{nums}")
    print(f"  关键数字是否还在:306 → {'306' in text},254 → {'254' in text}")
    print(f"\n【摘要文本前 500 字】\n{text[:500]}")

    print("\n【上下文预算分配】")
    print(mem.ContextBudget(total=32_000).report())
    print("\n故意把总额压到 8K 试试(会报警):")
    print(mem.ContextBudget(total=8_000).report())

    print("""
三个结论:
  1. **全量保留必然超窗** —— 只是早晚问题;10 轮就已经很贵了。
  2. **摘要必须保留数字** —— rule_based_summary 的第一条规则就是"所有数字都保留"。
     在临床场景里,254 变成"约 250"是不可接受的(那是结论本身)。
  3. **预算是分配问题,不是容量问题** —— 把 32K 分给 8 个用途,
     每处都有上限,超了就在那一处截断,而不是让某一处无限膨胀。

⚠️ 滑窗有个真实的坑:窗口边界如果正好切在 assistant 的 tool_call 和
   tool 结果之间,API 会报 400。WindowMemory 的 align 参数就是干这个的。""")


# ==========================================================================
# 场景 d · 数字可追溯
# ==========================================================================
def scenario_d(state: core.AgentState) -> None:
    _h("场景 d · 数字可追溯:从结论里的 306 一路回到原始数据行")

    print("结论里的第一个数字:DM 受试者总数 = 306")
    print("要回答的问题是:**这个 306 是谁算出来的?怎么复现?**\n")

    # ---- ① 从事实表找出来源 ----
    fact = next((f for f in state.facts if f.key == "DM受试者总数"), None)
    print(f"① 事实表记录:{fact.key} = {fact.value}")
    print(f"   出处(source):{fact.source}")
    print(f"   发生在第 {fact.step} 步")

    # ---- ② 从轨迹里找到那一步,取出当时的参数 ----
    step = next((s for s in state.steps if s.tool == "check_subject_consistency"), None)
    print(f"\n② 轨迹里的那一步:tool={step.tool} args={step.args}")
    print(f"   耗时 {step.elapsed:.3f}s,结果摘要 {str(step.result)[:90]}…")

    # ---- ③ 重放:用同样的参数再跑一次,看数字是否一致 ----
    print("\n③ 重放验证(用轨迹里记下的参数再跑一次):")
    reg = build_registry(build_knowledge_base()[1])
    replay = reg.execute(step.tool, step.args or {})
    got = (replay.data or {}).get("DM 受试者总数")
    print(f"   重放结果 = {got}   与结论一致:**{got == fact.value}**")

    # ---- ④ 落到原始数据行 ----
    print("\n④ 从工具回到原始数据(工具内部读的是 data/samples/dm.csv):")
    import pandas as pd
    dm = pd.read_csv(BASE / "data" / "samples" / "dm.csv", dtype=str,
                     low_memory=False)
    n_rows = len(dm)
    n_subj = dm["USUBJID"].nunique()
    print(f"   dm.csv 行数 = {n_rows},USUBJID 去重数 = {n_subj}")
    print(f"   与结论数字一致:**{n_subj == fact.value}**")
    print("""
这就是"可追溯"的完整链路:

    结论数字 306
      → 事实表 Fact(key, value, source='check_subject_consistency({...})', step=)
        → 轨迹 Step(tool, args, result, elapsed)      ← 当时用的参数
          → 工具实现(clinic/agent_tools.check_subject_consistency)
            → 数据文件 data/samples/dm.csv 的 306 个不重复 USUBJID

四段里任何一段缺失,复核人员就得重做一遍核查。
而只要四段都在,复核只需要 **30 秒**(重放一遍,比对数字)。

⚠️ 注意 source 里存的是**工具的调用参数**,不是"工具名"。
   只记工具名,你无法知道它当时查了哪些域 —— 也就无法重放。""")


# ==========================================================================
# 场景 e · 红线:检索不到就说没找到(阈值靠标定,不靠感觉)
# ==========================================================================
# 标定集:**该命中的问题**(覆盖各个章节)
SHOULD_HIT: list[str] = [
    "DM 中有但某域没有记录的受试者算错误吗",
    "USUBJID 一致性 严重性分级 处置",
    "部分日期 缺失 能不能直接比较",
    "安全性人群 与 随机化人数 是什么关系",
    "TEAE 如何定义 用药前事件怎么处理",
    "每个数字 必须有出处 可以追溯",
    "结论引用要写到章节号",
    "空值 能不能用 0 代替",
]
# 标定集:**不该命中的问题**(知识库里根本没有这些内容)
SHOULD_MISS: list[str] = [
    "PD 与 ADA 免疫原性的剪接分析方法学要求",
    "生物标志物 检测方法 验证 要求",
    "PK 参数 药代动力学 采血点 设计",
    "随机化分层因素与区组大小如何确定",
    "统计分析计划中的多重性调整策略",
    "深度学习 模型 微调 学习率",
    "基因测序 变异注释 流程",
]


def scenario_e(retriever: mem.KeywordRetriever) -> None:
    _h("场景 e · 红线:检索不到就说“没找到”(阈值靠标定,不靠感觉)")

    print("先看看默认阈值(clinic 里给的 0.25)会遇到什么麻烦 ——\n"
          "问一个知识库里**根本没有**的问题:\n")
    q = SHOULD_MISS[0]
    print(f"▶ {q}")
    for c, s in retriever.search(q, top_k=3):
        print(f"   [{s:5.2f}] {c.citation()[:44]:46s} {c.title[:24]}")
    print("""
  ↑ 三条命中的相关度 0.37~0.39,看起来"还行",
    但内容是**完全不相关**的(报告模板、数据体检清单、pandas 多级索引)。
    如果这些被当成"依据"喂给模型,它很可能据此写出一段
    很有说服力的错误结论 —— 这比明说"没找到"危险得多。

  原因:中文关键词检索按 2-gram 切分,"要求""方法""参数""数据"这类
  高频二字组合几乎在任何文档里都能命中。所以——
  **阈值必须靠标定,不能拍脑袋。**""")

    # ---- 标定:在候选阈值上统计"召回"与"误报" ----
    print("\n【标定过程】用上面 8 + 7 条样例,扫一遍候选阈值:\n")
    print(f"{'阈值':>6s}  {'该命中(召回)':>14s}  {'不该命中(误报)':>16s}  说明")
    best = None
    for th in (0.20, 0.25, 0.30, 0.35, 0.40, 0.45, 0.50, 0.55, 0.65):
        probe = mem.KeywordRetriever(retriever.chunks, min_score=th)
        rec = sum(1 for x in SHOULD_HIT
                  if any(s >= th for _c, s in probe.search(x, 3)))
        fp = sum(1 for x in SHOULD_MISS
                 if any(s >= th for _c, s in probe.search(x, 3)))
        note = ""
        if fp == 0 and rec == len(SHOULD_HIT):
            note = "← 召回满、零误报(选它)"
            best = th if best is None else best
        elif fp == 0:
            note = "零误报,但已经漏掉该命中的"
        elif rec == len(SHOULD_HIT):
            note = "召回满,但会误报"
        print(f"{th:>6.2f}  {rec:>10d}/{len(SHOULD_HIT):<3d}  "
              f"{fp:>12d}/{len(SHOULD_MISS):<3d}  {note}")

    print(f"""
  → 选 **{best:.2f}**:在这个阈值上召回和误报同时最优。
     0.40 以下开始出现误报(0.25 时 7 条里误报 4 条);
     0.45 开始漏掉"空值能不能用 0 代替"(它的最高分是 0.43)。

  ⚠️ 两点必须说清楚(否则这个标定会误导你):
     1. **样例太少**(15 条)。真实项目应该上百条,并且覆盖每个章节。
     2. **裕度很薄**:0.43 对 0.40,只差 0.03。所以上线前必须扩大标定集,
        并且把标定集做成**回归测试**(第 24 章):改切分方式、换模型、
        文档更新后,重跑一遍看召回/误报有没有变化。
        这类阈值是"配"出来的,不是"写"出来的 —— 所以要能一键重跑。""")

    # ---- 用标定出的阈值重跑那个问题 ----
    tuned = mem.KeywordRetriever(retriever.chunks, min_score=best)
    reg = build_registry(tuned)
    print(f"\n【用 min_score={best:.2f} 重跑同一个问题】")
    r = reg.execute("search_rules", {"query": q})
    print(f"  结构化返回:{json.dumps(r.data, ensure_ascii=False)}")
    print(f"  事实表会记下:"
          f"{reg.extract_facts('search_rules', {'query': q}, r.data)}")

    print("""
【对照:同一个问题在 0.25 下的返回】
  找到 3 条"依据",内容是报告模板、数据体检清单、pandas 多级索引 ——
  全都与"免疫原性剪接分析"无关(见本节开头的分数表)。

这就是"红线"的落点 —— 它不是一个提示词,而是**工具里的一个 if**:

    if not good:                       # 低于阈值
        return {"未找到": True,
                "说明": "知识库中没有相关规定。请勿凭推测作答;"
                        "应列入'未能完成的核查项'。"}

为什么必须是默认拒绝而不是"尽量给点东西":
  通用场景里,模型编一条"看起来对"的规则,用户可能察觉不到;
  临床场景里,一条编造的规则会直接进入递交物 —— 那是监管问题。
  所以:
    · 低于阈值 → 工具直接返回"未找到 + 请勿凭推测作答"
    · 事实表记下"规则依据章节 = 未找到(不得凭推测作答)"
    · 结论里必须列入"未能完成的核查项"(核查计划第 7 节就是这么要求的)
    这三步走完,"不知道"才变成一个**可见的、有人负责的状态**。""")

    # ---- 阈值放宽不会影响正常问题 ----
    print("\n【会不会误伤正常问题?检查场景 a/b 用的那几条询问】")
    for x in ("DM 中有但某域没有记录的受试者算错误吗",
              "USUBJID 一致性 严重性分级",
              "部分日期 缺失 能不能直接比较",
              "受试者数量 安全性人群 关系",
              "日期先后关系 部分日期 不允许直接比较"):
        top = tuned.search(x, 3)
        ok = bool(top) and top[0][1] >= tuned.min_score
        print(f"  [{top[0][1] if top else 0:5.2f}] {'仍命中 ' if ok else '被拦下 '} {x}")
    print("  → 调高阈值后,这些真实问题全部不受影响。")
    print("""
一个反直觉的取舍要记住:
    · 阈值低 → 引了不相关的依据(误报)→ **结论有依据,但依据是错的** ★ 最危险
    · 阈值高 → 该引的没引到(漏报)→ 结论缺依据,但不会错
  在临床场景里,**宁可漏报**。漏掉的会被"未完成项"清单暴露出来,
  误报则会静悄悄地混进递交物。""")


# ==========================================================================
def main() -> None:
    ap = argparse.ArgumentParser(
        description="案例 09 · SDTM 跨域一致性核查 Agent",
        formatter_class=argparse.RawDescriptionHelpFormatter)
    ap.add_argument("--only", default="all",
                    choices=["all", "a", "b", "c", "d", "e"])
    ap.add_argument("--quiet", action="store_true")
    args = ap.parse_args()
    v = not args.quiet

    state = None
    ret = None
    if args.only in ("all", "a"):
        ret = scenario_a()
    elif args.only in ("b", "c", "d", "e"):
        ret = build_knowledge_base()[1]        # 静默构建,不重复打印场景 a
    if args.only in ("all", "b", "d"):
        state = scenario_b(ret, v)
    if args.only in ("all", "c"):
        scenario_c(ret)
    if args.only in ("all", "d"):
        scenario_d(state)
    if args.only in ("all", "e"):
        scenario_e(ret)

    print("\n" + "=" * 72)
    print("案例 09 结束。下一步:")
    print("  · 案例 10 —— 把核查步骤组织成可并行、可重试、可断点续跑的任务图")
    print("  · 案例 11 —— 双编程:让两个 Agent 独立实现后互相挑错")
    print("=" * 72)


if __name__ == "__main__":
    main()