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

案例 A · 外部数据源客户端(第 23 章配套)

case_http_demo.py · 639 行 · 20 KB
案例 · 带审计的外部数据源客户端(第 23 章配套) ================================================
Python
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
案例 · 带审计的外部数据源客户端(第 23 章配套)
================================================

**目标**:把"调用外部 API"这件事从 ``requests.get(url)`` 变成
**能在受监管环境里活下来**的工程实现 —— 超时会分类、失败会退避、
权限错不重试、返回值不轻信、发出去的数据先去标识化、每一步都留审计。

服务端:本案例**一个真实网络请求都不发**。全部走
:class:`clinic.agent_http.MockTransport`(可注入的传输层)。

**覆盖章节**:第 23 章(外部 API 与服务集成)

为什么"本地能跑"和"上线能用"差这么远
--------------------------------------
因为真实世界里:网络会抖、对方会限流、token 会过期、返回的 JSON
会少字段、服务会挂。这些都不是"异常情况",而是**常态**。

所以本章的每一条规则背后都有一个具体的失败现场:

    不设 timeout      →  任务永久挂起,值班的人只能重启进程
    401 自动重试      →  **账号被锁**,全组停摆
    POST 失败就重试   →  重复创建,数据里多出一批脏记录
    重试不加抖动      →  对方刚恢复就被你的重试风暴再打垮一次
    轻信返回值        →  KeyError 在凌晨两点炸在报表生成脚本里
    先更新水位线再落盘→  **静默丢数据**,而且永远补不回来
    token 写进日志    →  密钥泄漏,且没人知道泄漏了多久

六个场景
--------
a) **基本取数 + 审计脱敏** —— 请求头里有什么、日志里能看到什么
b) **超时重试与退避** —— 指数退避 + 抖动 + 尊重 ``Retry-After``
c) **401 立即终止** —— 为什么不重试比重试更"健壮"
d) **返回值不可信** —— 契约校验与"不猜、不吞"的错误
e) **水位线与降级兜底** —— 落盘成功才推进;挂了要有缓存可退
f) **合规红线** —— 去标识化、最小必要、可追溯

运行
----
    python cases/case_http_demo.py
    python cases/case_http_demo.py --only b

离线可跑,零依赖(标准库 + clinic 包)。
"""

from __future__ import annotations

import argparse
import json
import sys
from pathlib import Path
from typing import Any

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

import pandas as pd                                              # noqa: E402
from clinic import agent_http as H                               # noqa: E402

OUT = BASE / "outputs"
HTTP_DIR = OUT / "http"
AUDIT_DIR = OUT / "audit"
CACHE_DIR = OUT / "cache"
AUDIT_JSONL = AUDIT_DIR / "http.jsonl"            # 流式追加,一行一条
AUDIT_SNAPSHOT = HTTP_DIR / "audit_snapshot.json"  # 批量快照,一个 JSON 数组
ADSL = BASE / "data" / "samples" / "adsl.csv"

BASE_URL = "https://ct.example.org"

# 预置的"服务端"回包。真实项目里这些来自 CDISC / MedDRA / 内部 CT 服务。
CODELIST_AE: dict[str, Any] = {
    "version": "2024-06-01",
    "items": [
        {"code": "HEADACHE", "term": "Headache"},
        {"code": "NAUSEA", "term": "Nausea"},
        {"code": "DIZZINESS", "term": "Dizziness"},
        {"code": "FATIGUE", "term": "Fatigue"},
    ],
}
SUBJECTS_P1: dict[str, Any] = {
    "results": [
        {"subjectId": "01-701-1015", "age": "63", "sex": "M"},
        {"subjectId": "01-701-1023", "age": "70", "sex": "F"},
        {"subjectId": "01-701-1028", "age": "81", "sex": "M"},
    ],
    "next_cursor": "c2",
    "server_earliest_ts": "2026-09-01T00:00:00",
}
SUBJECTS_P2: dict[str, Any] = {
    "results": [
        {"subjectId": "01-701-1033", "age": "77", "sex": "F"},
        {"subjectId": "01-701-1034", "age": "74", "sex": "M"},
    ],
    "next_cursor": None,
}


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


def _p(label: str, msg: str) -> None:
    print(f"  {label} {msg}")


def _make_client(**kw: Any) -> H.HttpClient:
    """建一个**不真的睡**、**抖动固定**的客户端。

    ★ ``sleep`` 与 ``rand`` 可注入,是本章最实用的一处设计:
      · 测试/演示里不真的等 → 跑得快
      · 抖动值可控 → 断言能写死("等待序列必须是 1.5 / 2.25 / 3.375")

    如果只能靠 ``time.sleep`` 和真随机,重试逻辑就**没法被自动验证** ——
    而"没法验证的逻辑"在生产里等于"没写"。
    """
    sleeps: list[float] = []
    kw.setdefault("transport", H.MockTransport(routes={
        "/api/v2/codelists/AE": CODELIST_AE,
    }))
    kw.setdefault("rate_limit_per_sec", 1000.0)   # 演示里不触发真实限流
    kw.setdefault("sleep", sleeps.append)
    kw.setdefault("rand", lambda: 0.5)            # 抖动系数固定为 0.5
    kw.setdefault("audit_path", AUDIT_JSONL)
    client = H.HttpClient(BASE_URL, **kw)
    client._sleeps = sleeps                        # type: ignore[attr-defined]
    return client


# ==========================================================================
# 场景 a · 基本取数 + 审计脱敏
# ==========================================================================
def scenario_a() -> None:
    _h("场景 a · 取数与审计:token 绝不能出现在任何输出里")
    print("""
第 23.2 节的"三不进":**凭证不进代码、不进日志、不进 Agent 上下文。**

这三条听起来像常识,但泄漏几乎都发生在"只是想看一眼"的时候 ——
打一行日志、贴一段调试输出、把请求头塞进异常信息,
密钥就进了日志系统,然后被采集、被索引、被留存。
""")
    client = _make_client(token="sk-ct-demo-9f3a7c1e5b2d4a6f")

    print("【请求头预览(脱敏后可以安全打印/落日志)】")
    for k, v in client.preview_headers().items():
        _p("·", f"{k}: {v}")
    print("""
  ↑ ``Authorization: Bearer sk-c***`` —— 打码后**还留了凭证的前 4 位**。
    保留前几位不是为了"好看",而是为了**确认用的是哪把钥匙**
    (多环境多密钥时,这一点能救你一次排查)。

  ⚠️ 一个很容易踩的坑:如果打码逻辑只是简单地"保留前 4 个字符",
     那 ``Bearer sk-xxx`` 会被打成 ``Bear***`` —— 等于什么都没保留。
     "方案 + 凭证"这种写法必须单独处理(见 clinic/agent_http.py 的 _mask)。
""")

    print("【取数】")
    resp = client.get("/api/v2/codelists/AE")
    data = resp.json()
    _p("·", f"GET /api/v2/codelists/AE → HTTP {resp.status},"
            f"{len(data['items'])} 条术语,版本 {data['version']}")
    for it in data["items"][:3]:
        _p("  ", f"{it['code']:10s} {it['term']}")
    print("""
  注意这里取的是 **CT 版本号**:术语表按版本变,核查结论必须写明
  "基于哪一版 CT"。不锁版本,"同样一份数据今天跑和半年前跑结论不同",
  而没有任何人会想到是这个原因。
""")

    print("【审计日志】")
    snapshot = client.dump_audit(AUDIT_SNAPSHOT)
    rows = json.loads(snapshot.read_text(encoding="utf-8"))
    _p("·", f"快照(JSON 数组){len(rows)} 条 → {snapshot.relative_to(BASE)}")
    print("  " + json.dumps(rows[-1], ensure_ascii=False)[:170] + "……")
    lines = [json.loads(ln) for ln
             in AUDIT_JSONL.read_text(encoding="utf-8").splitlines() if ln.strip()]
    _p("·", f"流式 JSONL {len(lines)} 行 → {AUDIT_JSONL.relative_to(BASE)}")
    print(f"""
  ★ 这两种审计产物**不能写进同一个文件**:

      audit_path(JSONL)    一行一条,只增不改 → 适合喂给日志采集系统
      dump_audit(JSON 数组)批量快照          → 适合打包交给别人

    混在一起的结果是**谁也解析不了** —— 第一行是 ``[``,
    后面的行又各自是完整 JSON。这类问题的排查成本很高,
    因为它看起来只是"文件格式怪怪的"。本案例把它们分开写。

  在整份审计文件里搜密钥片段:
""")
    secrets = ("sk-ct-demo", "9f3a7c1e")
    for p in (AUDIT_JSONL, snapshot):
        raw = p.read_text(encoding="utf-8")
        leaked = [s for s in secrets if s in raw]
        _p("·", f"{p.name:24s} → "
                f"{'⚠️ 找到了!' if leaked else '未找到 ✓(脱敏生效)'}")
    mem = json.dumps(client.audit, ensure_ascii=False)
    _p("·", f"{'内存审计记录':22s} → "
            f"{'⚠️ 找到了!' if any(s in mem for s in secrets) else '未找到 ✓'}")
    _p("·", f"{'请求头预览':22s} → {client.preview_headers()['Authorization']}")
    print("""
  ★ 这里要分清两件事,它们的作用不一样:

    ① **审计记录里干脆不写凭证**(``_record`` 的字段里没有 Authorization)
       —— "不写"永远比"写了再脱敏"可靠。少一个字段,就少一条泄漏路径。

    ② 而**必须让凭证经过的地方**(请求头预览、异常信息、调试输出)
       才需要 :func:`redact` 兜底。

    ★ 脱敏必须在**入库前**做,而不是事后清理日志文件:
      事后清理必然漏 —— 日志在写下的那一刻就已经被采集系统拉走了,
      你清理的只是本地副本。
""")


# ==========================================================================
# 场景 b · 重试、指数退避与抖动
# ==========================================================================
def scenario_b() -> None:
    _h("场景 b · 网络抖了:指数退避 + 抖动 + 尊重 Retry-After")
    print("""
两种情况要分开:

  · **瞬时故障**(连接被重置、502/503、读超时)→ 重试
  · **确定性失败**(400 参数错、404 不存在、401 权限)→ **立即终止**

把后者也去重试,就是在拿"本可以立刻发现的错"换"更晚才发现的错",
而且会浪费配额、刷爆对方的日志。
""")

    # ---- 前两次超时,第三次成功 ----
    print("【① 前两次读超时、第三次成功:验证「重试真的会发生,且只发生必要次数」】")
    transport = H.MockTransport(
        routes={"/api/v2/codelists/AE": CODELIST_AE},
        # fail_times 的语义:**前 N 次失败,之后成功** —— 这样才能验证重试
        fail_times={"/api/v2/codelists/AE": 2},
        fail_paths={"/api/v2/codelists/AE": TimeoutError("模拟读超时")},
    )
    client = _make_client(transport=transport, token="sk-demo",
                          max_retries=3)
    resp = client.get("/api/v2/codelists/AE")
    _p("·", f"最终 HTTP {resp.status} ✓(前两次失败)")
    _p("·", f"实际发出的请求次数:{len(transport.calls)}(= 2 次失败 + 1 次成功)")
    _p("·", f"退避等待序列:{client.backoff_log} 秒")
    print("""
     1.15 → 1.725 是**几何增长**(比例 = ``backoff_base`` = 1.5),
     每个间隔再叠加 ±30% 的抖动(本演示把抖动系数固定为 0.5,便于断言)。

  ⚠️ 注意第一次重试只等了约 1 秒,**不是 1.5 秒**:
     ``backoff_base ** attempt`` 里 ``attempt`` 从 0 开始。
     这个细节值得较真 —— "第一次就退避很久"是常见写法错误,
     而抖动类故障(丢包、瞬间限流)往往重试一次就过了,
     让用户白等几秒毫无收益。

  ★ 为什么要抖动:如果 1000 个客户端都用**同样的间隔**重试,
    对方服务刚恢复的瞬间就会被同一波重试再打垮 —— 这叫重试风暴。
    抖动把这 1000 个客户端错开,恢复才真的能稳住。
    教科书上常写"指数退避",但漏掉抖动那一半的人,往往在
    第一次真实故障里才学到这一课。
""")

    # ---- Retry-After ----
    print("【② 服务端说「等一下」:必须听它的】")
    # MockTransport 的 fail_paths 支持直接写一个 Response ——
    # 这样就能离线构造"429 + Retry-After"这种带响应头的失败。
    transport2 = H.MockTransport(
        routes={"/api/v2/subjects": SUBJECTS_P1},
        fail_times={"/api/v2/subjects": 1},
        fail_paths={"/api/v2/subjects": H.Response(
            status=429, text='{"error":"too many requests"}',
            headers={"Retry-After": "7"})},
    )
    client2 = _make_client(transport=transport2, token="sk-demo")
    resp2 = client2.get("/api/v2/subjects")
    _p("·", f"最终 HTTP {resp2.status} ✓(第一次被 429 挡住)")
    _p("·", f"实际请求次数:{len(transport2.calls)}")
    _p("·", f"退避等待序列:{client2.backoff_log} 秒(服务端要求 7 秒)")
    print("""
  ★ ``Retry-After`` **优先级高于**我们自己的退避公式。
    对方说 7 秒就等 7 秒 —— 你算出来的 1.5 秒不但没用,还会让它
    把你当攻击者。所以 :meth:`HttpClient.backoff` 里第一件事就是
    判断 ``retry_after is not None``。
""")


# ==========================================================================
# 场景 c · 401 立即终止
# ==========================================================================
def scenario_c() -> None:
    _h("场景 c · 401 绝不重试:为什么「更少的重试」等于「更健壮」")
    print("""
这是本章最反直觉、也最容易写错的一条。

    "重试"看起来是健壮性的表现。但对 **401 / 403** 来说,
    重试的每一次都是在**拿错误凭证敲门** —— 很多网关的账号锁定策略
    就是按"短时间内认证失败次数"触发的。

后果不是"这次请求失败",而是**整个组的账号被锁**,
所有并行任务一起停摆,而根因只是某个人把 token 配错了。
""")
    transport = H.MockTransport(
        routes={"/api/v2/codelists/AE": CODELIST_AE},
        fail_paths={"/api/v2/codelists/AE": 401},
    )
    client = _make_client(transport=transport, token="sk-过期了", max_retries=5)
    try:
        client.get("/api/v2/codelists/AE")
        _p("✗", "竟然成功了(不该)")
    except H.AuthError as e:
        _p("✓", f"抛出 AuthError:{str(e).splitlines()[0]}")
    _p("·", f"实际请求次数:{len(transport.calls)}"
            f"(max_retries=5,但**一次都没重试**)")
    _p("·", f"退避等待序列:{client.backoff_log}(空 = 没有等待)")
    print("""
  → 判断依据在 :func:`clinic.agent_http.classify` 里:
    ``retryable=False`` 的错误,主循环直接 ``raise``,不进入退避分支。

  ★ 这条规则的正确表述是:**重试只适用于"再试一次可能就好了"的错。**
    权限错、参数错、不存在 —— 再试一万次也是同样的结果。
    把这类错误重试,只是在把"早发现"改成"晚发现",
    顺便多烧一份配额、多留一百行无用日志。

  ⚠️ 现实里正确的动作是:AuthError 触发**告警**(而不是重试),
     让值班的人去看"是 token 过期了,还是配置写错了"。
""")


# ==========================================================================
# 场景 d · 返回值不可信
# ==========================================================================
def scenario_d() -> None:
    _h("场景 d · 对方返回的 JSON 不可信:显式校验,不猜、不吞")
    print("""
"对方是标准接口,返回格式不会变" —— 这个假设在凌晨两点会被打破。

对方可能:升级 API 改了字段名、错误时返回 HTML 页面、
限流时返回一个半截 JSON、字段类型从 str 变成 int。
如果你的代码直接 ``data["results"][0]["subject_id"]``,
得到的是 ``KeyError`` / ``TypeError`` —— **在报表生成脚本里,
在最不容易复现的时间点**。

正确做法:在边界处**显式校验**,并给出能自己修错的错误信息。
""")

    # ① 缺字段
    print("【① 对方把顶层字段改名了:results → items】")
    try:
        H.parse_subjects({"items": [{"subjectId": "01-701-1015"}]})
    except H.ContractError as e:
        _p("✓", f"ContractError:{str(e).splitlines()[0]}")
    print("""
   ↑ 错误信息里带上了**实际的顶层键** —— 这是关键。
     Agent 或人都能立刻看出"对方把 results 改叫 items 了",
     而不是收到一句 "KeyError: 'results'" 然后去翻对方文档。
     这就是第 20 章讲的"结构化错误":**错误信息要能驱动下一步动作。**

  ⚠️ 注意这里**不能"猜"**:看到 items 就直接当 results 用,
     看起来"兼容性更好",实际上是**静默接受了一份来源不明的结构**。
     如果对方返回的 items 是另一种含义(比如"待处理项"),
     你的报表会安静地错到底。宁可失败,也不要猜。
""")

    # ② 单条记录字段改名
    print("【② 单条记录里的字段改名了:subjectId → id】")
    try:
        H.parse_subjects({"results": [{"id": "01-701-1015", "age": "63"}]})
        _p("·", "(被接受了(不该))")
    except H.ContractError as e:
        _p("✓", f"ContractError:{str(e).splitlines()[0]}")

    # ③ 正常
    print("【③ 正常响应】")
    rows = H.parse_subjects(SUBJECTS_P1)
    _p("·", f"校验通过,{len(rows)} 条受试者;"
            f"下页游标 {SUBJECTS_P1['next_cursor']!r}")
    for r in rows:
        _p("  ", f"{r['subject_id']}  age={r['age']}  sex={r['sex']}")
    print("""
  ★ 注意校验后的结构是**我们自己的**(``subject_id`` / ``age`` / ``sex``),
    而不是对方的原样。这层"防腐层"的价值在于:
    对方改字段名时,只需要改这一处的映射,业务代码一行不动。
    同时注意 ``age`` 被宽容地转成了数值、``'N/A'`` 会变成 ``None`` ——
    **宽进严出**:入口容错,出口受控。

  ★ 还有"游标"这个概念:**不要自己拼页码**。
    外部服务的分页规则会变(offset → cursor → 时间戳),
    跟着它给的 ``next_cursor`` 走,是最省心的做法。
    同时记住:**翻页期间数据可能在变**,所以增量同步要靠
    "水位线 + 幂等写入",而不是靠"页码算得对"。
""")

    # ④ 返回 HTML
    print("【④ 对方错误时返回了 HTML 页面(而不是 JSON)】")
    bad = H.Response(status=200, text="<html><body>506 Gateway Timeout</body></html>")
    try:
        bad.json()
    except H.ContractError as e:
        _p("✓", f"ContractError:{str(e).splitlines()[0][:88]})")
    print("""
  ⚠️ 特别注意:这个响应 **HTTP 状态码是 200**。
     只看状态码的代码会以为成功,然后拿着 HTML 去解析。
     "200 就一定是好数据"是外部集成里最常见的错误假设之一。
""")


# ==========================================================================
# 场景 e · 水位线与降级兜底
# ==========================================================================
def scenario_e() -> None:
    _h("场景 e · 水位线:落盘成功之后才能推进")
    print("""
本案例最贵的一张教训表:

    先落盘、后更新水位线  →  崩溃时最多"重复拉一次",**幂等写入能兜住**
    先更新水位线、后落盘  →  崩溃时**这段数据永远不会被再拉到**

后者是**静默丢数据**:报表少了一批受试者,而所有日志都是绿的。
临床统计里"少了几个人"和"多了几个人"一样致命,
而且"少了"更难发现 —— 因为没有任何报错。
""")
    WM = HTTP_DIR / "watermark.json"
    if WM.exists():
        WM.unlink()

    print("【① 第一轮增量拉取:拉完 → 落盘 → 才 mark()】")
    wm = H.Watermark.load(WM)
    _p("·", f"起始水位线:last_ts={wm.last_ts}(第一次同步,从零开始)")

    data_path = HTTP_DIR / "subjects_batch1.json"
    data_path.parent.mkdir(parents=True, exist_ok=True)
    first = H.parse_subjects(SUBJECTS_P1)
    data_path.write_text(json.dumps(first, ensure_ascii=False), encoding="utf-8")
    _p("·", f"落盘 {len(first)} 条 → {data_path.relative_to(BASE)}")
    wm.mark(last_id=first[-1]["subject_id"], last_ts="2026-09-10T00:00:00")
    wm.save(WM)
    _p("·", f"落盘**成功之后**才推进水位线:last_ts={wm.last_ts}")
    _p("·", f"(顺序反了就是静默丢数:崩溃点之后的数据再也不会被拉到)")

    print("\n【② 第二轮:水位线继续往前推】")
    wm2 = H.Watermark.load(WM)
    second = H.parse_subjects(SUBJECTS_P2)
    _p("·", f"从磁盘恢复水位线 last_ts={wm2.last_ts} 继续拉,拿到 {len(second)} 条")
    wm2.mark(last_id=second[-1]["subject_id"], last_ts="2026-09-15T00:00:00")
    wm2.save(WM)
    _p("·", f"新水位线 last_ts={wm2.last_ts}")

    print("\n【③ 缺口检测:本地水位线早于「服务端最早可提供」】")
    warn = wm2.verify(SUBJECTS_P1["server_earliest_ts"])
    if warn:
        _p("⚠️", warn)
    print("""
  ↑ 这一步是**必须有**的:增量同步最危险的失败不是"拉不到",
    而是"拉到了,但中间缺了一段"。
    对方做了归档、只保留最近 N 天 —— 你的水位线只要老过那个边界,
    就已经漏数据了。此时正确动作是**报警 + 全量重拉**,不是继续增量。
""")

    print("【④ 降级兜底:服务挂了,用缓存顶上,并明确标注「这是缓存」】")
    cache_path = CACHE_DIR / "codelist_AE.json"
    live = H.CachedFallback(_make_client(token="sk-demo"), cache_path)
    d1, src1 = live.get("/api/v2/codelists/AE")
    _p("·", f"第一次:{src1} → {len(d1['items'])} 条")

    dead = H.CachedFallback(
        _make_client(token="sk-demo", transport=H.MockTransport(
            fail_paths={"/api/v2/codelists/AE": TimeoutError("服务不可用")})),
        cache_path)
    d2, src2 = dead.get("/api/v2/codelists/AE")
    _p("·", f"服务挂掉后:{src2} → {len(d2['items'])} 条(与缓存一致:{d2 == d1})")
    print("""
  ⚠️ 降级的铁律:**必须把"这是缓存"写进结果本身**,而不是只写进日志。

     "用 3 天前的 CT 版本做的核查" —— 如果不标注,交付时会被当成
     "用最新版本核实过"。这两句话在监管眼里是完全不同的东西。
     所以 ``CachedFallback.get()`` 的返回值是 ``(数据, 来源说明)`` 两元组,
     逼着调用方**没法忽略**这件事。
""")


# ==========================================================================
# 场景 f · 合规红线
# ==========================================================================
def scenario_f() -> None:
    _h("场景 f · 合规:把数据发给外部模型之前的四道检查")
    print("""
第 23.8 节的自查表,落到代码上就是四步:

    ① 直接标识符剔除   ② 只发必需字段   ③ 记录审计   ④ 结论可追溯

第 ① 步最难,因为**列名看不出来**。下面这个例子就是真实情况:
两列有明显问题(列名直接命中),还有一列列名是 ``VAR1``、里面装的是人名 ——
只看列名永远发现不了。
""")
    df = pd.read_csv(ADSL, dtype=str, low_memory=False).head(40)
    df = df[["USUBJID", "SITEID", "AGE", "SEX", "TRT01P"]].copy()
    # 人为构造三种"不该外发"的列(真实数据里它们常常是导出时顺手带进来的)
    df["PATIENT_NAME"] = ["张伟", "李娜", "王强", "刘洋", "陈静"] * 8
    df["BIRTHDT"] = "1950-03-17"
    df["VAR1"] = ["Michael Smith", "Anna Jones", "David Brown",
                  "Sarah Lee", "Tom King"] * 8

    print("【扫描 1:列名 + 取值双层检查】")
    problems = H.check_deidentification(df)
    if not problems:
        _p("·", "未发现疑似直接标识符")
    for p in problems:
        _p("⚠️", p)
    print("""
  ★ 注意前两条是**列名**命中的(``PATIENT_NAME`` / ``BIRTHDT``),
    第三条 ``VAR1`` 靠的是**取值形态** —— 只看列名的检查会放过它,
    而它恰恰是最容易真实发生的:ETL 里加了个中间列,名字毫无信息量。

  ⚠️ 这类检查宁可误报不可漏报:
     误报的成本是"多看一眼",漏报的成本是"患者信息进了第三方服务"。
""")

    print("【扫描 2:剔除后复检】")
    safe = df.drop(columns=["PATIENT_NAME", "BIRTHDT", "VAR1"])
    left = H.check_deidentification(safe)
    _p("·", f"剔除三列后剩余问题:{left if left else '无 ✓'}")

    print("\n【最小必要:只发需要的列】")
    need = ["USUBJID", "AGE", "SEX", "TRT01P"]
    payload = H.build_llm_payload(safe, need)
    _p("·", f"只发 {len(need)} 列,{len(payload)} 字符")
    print("  " + payload.replace("\n", "\n  ").rstrip())
    try:
        H.build_llm_payload(safe, ["USUBJID", "RACE"])
    except ValueError as e:
        _p("✓", f"要求不存在的列时立刻报错:{str(e).splitlines()[0]}")
    print("""
  ★ "最小必要"有两个好处,而且第二个常被忽略:

      合规:少一列就少一分泄漏面
      成本:少一列就少一分 token —— 40 列的表全发和发 4 列,
            费用差一个量级,而结论质量几乎不受影响
""")

    print("【审计:每次模型调用落一行(存摘要,不存全文)】")
    path = H.log_llm_call(prompt=payload, response="(演示用空响应)",
                          model="内部网关/医学模型-v2", audit_dir=AUDIT_DIR)
    rec = json.loads(path.read_text(encoding="utf-8").splitlines()[-1])
    _p("·", f"{path.relative_to(BASE)}")
    _p("·", f"prompt_sha256={rec['prompt_sha256']} "
            f"chars={rec['prompt_chars']} model={rec['model']}")
    print("""
  ★ 存 **sha256 摘要**而不是全文,是一个刻意的取舍:

      能证明:这次发给模型的,就是这一份内容(可复算、可对账)
      不能:把完整数据留在日志里 —— 日志的访问控制通常比数据库松得多

    但注意这里的短板:``prompt_preview`` 里**前 200 字符是明文**。
    真实项目里 200 字符也可能含标识符,所以更严的做法是
    预览也做二次脱敏,或者只留字段名不留值。
""")

    print(f"""【一张自查表(第 23.8 节)】

  ┌────────────────────────────┬──────────────────────────────────────────┐
  │ 检查项                     │ 本案例对应的代码                         │
  ├────────────────────────────┼──────────────────────────────────────────┤
  │ 外发数据里有直接标识符吗   │ check_deidentification()  双层判据       │
  │ 只发了必需字段吗           │ build_llm_payload(df, need)  必须显式列  │
  │ 有数据出境问题吗           │ 由 base_url + DPA 决定,代码管不了 ——    │
  │                            │ 所以它必须是**上线检查清单**的一项       │
  │ 有 DPA / 授权吗            │ 同上,流程问题                           │
  │ 审计日志记了吗             │ log_llm_call() + HttpClient.dump_audit() │
  │ 能人工复核吗               │ 结论带 source/版本;见第 21 章引用机制    │
  └────────────────────────────┴──────────────────────────────────────────┘

  ⚠️ 最后一行"能人工复核吗"是临床统计的**根本**:

     无论 Agent 多自动化,**最终对递交物负责的仍然是人**。
     所以系统设计的目标不是"全自动出结果",而是
     "**让人的复核效率更高、更可靠**"。

     一个把结论写得像"系统说的"的 Agent,反而降低了复核质量 ——
     因为它让人失去了质疑的入口。
""")


# ==========================================================================
def main() -> None:
    ap = argparse.ArgumentParser(description="案例 · 带审计的外部数据源客户端")
    ap.add_argument("--only", default="all",
                    help="只跑某个场景:a/b/c/d/e/f/all")
    args = ap.parse_args()

    HTTP_DIR.mkdir(parents=True, exist_ok=True)
    AUDIT_DIR.mkdir(parents=True, exist_ok=True)
    CACHE_DIR.mkdir(parents=True, exist_ok=True)
    # 每次从干净的审计文件开始,这样计数与"有没有泄漏"的结论才可信
    if AUDIT_JSONL.exists():
        AUDIT_JSONL.unlink()

    print("=" * 74)
    print("案例 · 带审计的外部数据源客户端(第 23 章配套)")
    print("=" * 74)
    print(f"""
一个真实网络请求都不发 —— 全部走 MockTransport(可注入的传输层)。
这正是本章最核心的工程手法:**把外部依赖变成可注入的接口**,
使系统能在离线环境下被完整验证。

产出目录:
  {HTTP_DIR.relative_to(BASE)}      水位线、批次数据
  {AUDIT_DIR.relative_to(BASE)}     审计(已脱敏)
  {CACHE_DIR.relative_to(BASE)}     降级用的缓存
""")

    picked = {s.strip().lower() for s in args.only.split(",") if s.strip()}
    want = (lambda k: "all" in picked or k in picked)

    if want("a"):
        scenario_a()
    if want("b"):
        scenario_b()
    if want("c"):
        scenario_c()
    if want("d"):
        scenario_d()
    if want("e"):
        scenario_e()
    if want("f"):
        scenario_f()

    print("\n" + "=" * 74)
    print("案例结束。把第 23 章的规则压缩成四句话:")
    print("  ① 超时、重试、退避、限流 —— 一个都不能省,且必须能被自动断言")
    print("  ② 401/403 立即终止:重试「看起来健壮」的错,代价可能是全组账号被锁")
    print("  ③ 先落盘、后推水位线 —— 顺序反了就是永远补不回来的静默丢数")
    print("  ④ 外发数据先去标识化;结论必须能追溯到人、到版本、到源数据")
    print("=" * 74)


if __name__ == "__main__":
    main()