OpenMed 同意感知缓存(ConsentCache)实现解析:面向可撤销同意上下文的本地内存缓存与审计设计
2026/9/18 3:23:00 网站建设 项目流程

OpenMed 同意感知缓存(ConsentCache)实现解析:面向可撤销同意上下文的本地内存缓存与审计设计

【免费下载链接】openmedLocal-first healthcare AI: clinical NER & HIPAA PII de-identification that runs 100% on-device. 2,200+ medical models, 21 languages, Apple MLX + Python, no cloud, no patient data leaving your network. Apache-2.0项目地址: https://gitcode.com/GitHub_Trending/ope/openmed

openmed.clinical.consent_cache是 OpenMed 提供的一个本地、纯内存的派生临床输出缓存模块,核心能力是让「后来可以被撤销的同意上下文」所对应的缓存条目能够被确定性、可审计地失效。本文基于 docs/clinical/consent-cache.md 展开,结合 openmed/clinical/consent_cache.py 源码与 tests/unit/clinical/test_consent_cache.py 测试用例,系统讲解其隐私模型、指纹规范化算法、API 用法、撤销语义、审计事件与容量边界,帮助读者在医疗数据本地化处理的管线中正确接入这一层基础设施保护机制。

定位:基础设施保护,而非合规认证

ConsentCache 的第一条设计原则是职责边界清晰。文档明确声明:它是「infrastructure safeguard」(基础设施保护措施),不是合规认证(compliance certification),也不是临床决策保证(clinical decision guarantee)。

从实现上看,consent_cache.py 的ConsentCache只是一个受RLock保护的有界内存缓存,它不会做以下任何事:

  • 不做网络调用(never makes a network call);
  • 不写文件、不输出日志;
  • 不联系任何同意注册中心(consent registry);
  • 不判断一次新的处理请求在法律上是否被允许。

也就是说,ConsentCache 只回答一个工程问题:当某个同意上下文被撤销后,之前基于它派生的缓存结果如何被彻底、可审计地清除。而「撤销动作是否应该发生」「处理请求是否合法」属于应用层自己的同意与披露工作流,模块不越界。

这一边界与 OpenMed 其他合规模块的分工是一致的:例如 docs/compliance/consent-data-use-tags.md 中描述的DataUseTag/DataUseAction负责在处理入口对「数据用途标签」做本地确定性强制(enforcement is local and deterministic),同样不调用同意注册中心、不传输文档内容;而 ConsentCache 则负责处理链路中缓存结果的存续期管理

隐私与确定性:只留指纹,不留原始输入

为什么需要指纹化

缓存条目的键由三部分组成:调用方提供的cache_keyconsent scope(同意范围)与consent revision(同意修订号)。如果直接把这三者的原始值存入条目元数据或审计事件,就等于把患者标识符、同意范围文本等敏感内容固化在序列化输出里,破坏 OpenMed「本地优先、最小化 PHI 留存」的整体目标。

ConsentCache 的做法是:这些值只在计算 SHA-256 指纹所需的极短时间内被规范化,随后立刻丢弃原始形态。条目元数据只保存指纹,撤销审计事件只保存数量。

指纹算法实现

指纹计算的入口在 consent_cache.py 的_fingerprint

def _fingerprint(kind: str, value: Any) -> str: payload = _canonical_json({"kind": kind, "value": _canonical_value(value)}) digest = hashlib.sha256(payload.encode("utf-8")).hexdigest() return f"sha256:{digest}"

即:先对输入做无歧义的规范化(_canonical_value),再以{"kind": ..., "value": ...}结构做排序键、紧凑分隔符、纯 ASCII的 JSON 序列化(_canonical_json,见 consent_cache.py,sort_keys=Trueseparators=(",", ":")ensure_ascii=True),最后用 SHA-256 摘要并加sha256:前缀,确保指纹可被识别为摘要而非原始值。

_canonical_value(consent_cache.py)对支持的类型做了带类型标签的规范化,消除一切歧义:

  • 字符串 →["string", value.strip()](去除首尾空白,并标注类型,避免与裸布尔/数字混淆);
  • 字节串 →["bytes", value.hex()]
  • None["null"]
  • 布尔 →["bool", value](与 int 区分开);
  • 整数 →["int", value]
  • 浮点 →["float", value],且拒绝非有限值math.isfinite检查,非有限数直接抛ValueError);
  • 映射 →["mapping", ...],要求键为非空字符串,按键排序;
  • 列表/元组 →["sequence", ...]
  • 集合/frozenset →["set", ...],按规范 JSON 排序。

任何不支持的输入都会抛出TypeError,保证指纹输入始终是「确定的 JSON 兼容值」。

三个公开指纹函数

模块对外暴露三个指纹函数(consent_cache.py):

  • fingerprint_consent_scope(scope):字符串或字符串可迭代对象会被规范化为排序后的非空字符串集合再指纹化,因此("analytics", "summary"){"summary", "analytics"}会得到相同的指纹——范围语义与顺序无关;
  • fingerprint_consent_revision(revision):支持标量或 JSON 兼容的结构化值,但拒绝空值None、空白字符串、空字节串、空容器都会抛ValueError,见_require_non_empty_revision);
  • fingerprint_cache_key(cache_key):同样拒绝空键。

测试 test_consent_cache.py 直接验证了这一点:first == second、指纹以sha256:开头,且原始 scope/revision 字符串不出现在指纹中

条目元数据与输出隔离

ConsentCacheEntry(consent_cache.py)是冻结 dataclass,字段为三个指纹加上缓存值value。关键设计是:

  • value被标记为field(repr=False, compare=False),因此repr(entry)不会泄露缓存输出
  • to_dict()只返回三个指纹字段,缓存输出和原始输入永远不会出现在序列化元数据中

测试 test_consent_cache.py 用json.dumps(entry.to_dict(), sort_keys=True)断言SYNTHETIC_CACHE_KEY"synthetic-output"都不出现在序列化结果里,repr(entry)中同样不含它们。

快速上手:put / get / revoke

文档给出的最小用法如下(完整保留):

from openmed.clinical.consent_cache import ConsentCache cache = ConsentCache() cache.put( "synthetic-result-key", {"summary": "synthetic derived output"}, scope="summary", revision="receipt-v1", ) result = cache.get( "synthetic-result-key", scope="summary", revision="receipt-v1", ) event = cache.revoke(scope="summary", revision="receipt-v1") assert event.invalidated_count == 1 assert cache.get( "synthetic-result-key", scope="summary", revision="receipt-v1", ) is None

这段代码演示了完整生命周期:put写入带同意上下文的派生输出 →get精确读取 →revoke撤销该同意上下文 → 再get返回None,同时event.invalidated_count == 1给出本次失效的条目数。

别名参数与命名约定

源码为put/get/revoke等所有入口同时接受两组等价的命名参数(consent_cache.py 的_resolve_alias处理):

  • scope/consent_scope
  • revision/consent_revision

_resolve_alias会拒绝「同一参数同时用两种名字传入」的情况(抛TypeError: ... was supplied more than once),避免歧义。put中 scope/revision 是必填的(required=True);get中两者均可选(required=False)。

此外,put有别名setrevoke有别名invalidate,方便不同调用习惯的代码接入。ConsentCache还实现了__contains__(判断某 cache_key 是否至少有一条目)与__len__(当前条目总数)。

返回语义

  • put返回boolTrue表示已存储;当该 scope/revision 对已被撤销时返回False(写被拒绝,计为一次rejected_writes),而不会抛异常,便于调用方以分支处理而不是 try/except 处理预期中的撤销拦截;
  • get命中返回缓存值,未命中返回default(默认为None);
  • get_entry返回ConsentCacheEntry对象本身,便于需要读取指纹元数据的场景。

同意上下文匹配规则:为什么 get 要带 scope 和 revision

文档强调:get()传入的 scope 与 revision 必须与put()时一致。这是因为同一个cache_key可能在不同同意上下文下存在多条条目(例如多次处理产生了不同 revision 的输出)。

源码 consent_cache.py 的get_entry逻辑是:先按 cache_key 指纹过滤,再叠加 scope/revision 指纹过滤,然后要求匹配条目数恰为 1

  • 匹配数!= 1一律视为未命中(misses += 1,返回None),绝不「任选一条」;
  • 只有恰好一条时才计hits += 1并返回。

因此省略 scope/revision 的查找只有在某个 cache_key 仅存在一个同意上下文时才成功。这样设计避免了「一个键对应多个 revision 时随意挑选结果」的隐患——宁可未命中,也不返回错误的缓存结果。测试 test_consent_cache.py 中的cache.get("key")之所以能返回"new-output",正是因为该键当时只有唯一一条 v2 条目。

撤销语义:精确撤销、scope 级撤销与墓碑

精确撤销(带 revision)

revoke(scope="summary", revision="receipt-v1")会:

  1. 移除所有匹配该 scope+revision 指纹对的条目;
  2. 将该指纹对记入self._revoked墓碑集合(tombstone);
  3. 此后所有对该指纹对的put都会被拒绝(返回False),即使该条目已经被移除。

源码见 consent_cache.py:revoke在锁内计算本次匹配的revoked_revisions(精确撤销时就是{revision_fingerprint}),更新墓碑、删除匹配条目、构造ConsentInvalidationEvent并追加到_audit_events

测试 test_consent_cache.py 验证了墓碑语义:

cache.put("key", "old-output", scope="summary", revision="v1") cache.revoke("summary", "v1") assert cache.put("key", "stale-output", scope="summary", revision="v1") is False assert cache.is_revoked("summary", "v1") assert cache.put("key", "new-output", scope="summary", revision="v2") is True assert cache.get("key") == "new-output"

旧 revisionv1被永久拒绝写入,而从未被观察过的新 revisionv2仍然可以写入——这正对应文档所说的「允许一个此前未见的 revision 代表一张新的同意收据」。

scope 级撤销(不带 revision)

revoke(scope="summary")的语义是:

  1. 收集该 scope 下当前仍缓存的所有 revision 指纹
  2. 将它们全部加入墓碑并删除对应条目;
  3. 这些「已观察到的 revision」之后都被拒绝写入;
  4. 而一个此前未出现过的 revision仍然可以代表新的同意收据被写入。

测试 test_consent_cache.py 给出了完整场景:summaryscope 下 v1、v2 两条条目 +billingscope 下 v1 一条;执行revoke(scope="summary")event.count == 2len(cache) == 1billing/v1不受影响,随后对summary/v1summary/v2的写入均被拒绝。

撤销的确定性

重复执行相同的revoke确定性的:第一次返回invalidated_count=1,第二次没有可删除条目时返回invalidated_count=0(见 test_consent_cache.py),并且两次事件都会被追加到审计列表。这意味着应用可以用同一撤销请求重放来保证最终一致性,而不必担心副作用叠加。

撤销是审计对象,驱逐不是

文档特别强调:因容量上限导致的驱逐(eviction)不是同意撤销,不计入撤销审计计数。换句话说,revoke产生审计事件,而 LRU 淘汰不产生——两者在审计语义上严格区分,避免把「缓存压力」误报成「同意撤回」。

审计与可观测性:只计数,不携带内容

撤销事件格式

每次revoke返回一个ConsentInvalidationEvent(consent_cache.py),其to_dict()只包含两个字段:

{"event_type": "consent_cache_invalidation", "invalidated_count": 2}
  • event_type固定为模块常量CONSENT_CACHE_EVENT_TYPE = "consent_cache_invalidation"
  • invalidated_count不会为负(__post_init__校验)。

事件不包含任何 cache_key、同意值或缓存输出。文档建议:应用应通过自己批准(approved)的审计通道保留这个聚合事件

审计事件列表与统计

  • audit_events属性返回按操作顺序排列的ConsentInvalidationEvent元组;audit_log()返回其to_dict()形式(仅计数);
  • clear_audit_events()清空事件列表并返回移除数量;
  • stats()返回ConsentCacheStats(consent_cache.py),包含六个仅计数字段:hitsmisseswritesrejected_writesinvalidatedsize,同样可序列化为纯数字映射;
  • entry_metadata()返回全部条目的指纹元数据(经to_dict()),可用于向审计或报表面暴露条目级信息,而不会带出缓存输出。

测试 test_consent_cache.py 断言:所有审计事件 payload 的键集合恰好{"event_type", "invalidated_count"},且json.dumps(audit, sort_keys=True)中不含"output-one""key-one"等原始内容。

常量版本

模块导出CONSENT_CACHE_SCHEMA_VERSION = 1(consent_cache.py),用于让外部审计消费者识别事件/元数据结构的版本,便于未来演进。

容量边界:max_entries 与 LRU 驱逐

ConsentCache(max_entries=1024)默认容量为 1024 条(consent_cache.py)。构造函数对参数做严格校验:

  • max_entries必须是正整数(布尔值会被显式拒绝,isinstance(max_entries, bool)检查);
  • 非整数抛TypeError,非正数抛ValueError

底层用OrderedDict实现 LRU 近似语义:每次putmove_to_end标记最近使用,超限时popitem(last=False)从最老一端淘汰(consent_cache.py);get命中同样会move_to_end,保持热点条目存活。

与撤销的区别在于:驱逐不清墓碑、不产生审计事件、不计入invalidated统计,所以驱逐不会把「已撤销的上下文」重新变成可写——墓碑集合与条目存储是两套独立的数据结构(self._revokedself._entries)。clear()方法也只清空条目而保留墓碑,保证「撤销是不可逆的」这一核心语义。

线程安全与并发模型

ConsentCache的所有读写操作都通过RLock(可重入锁,consent_cache.py)保护。RLock而非普通Lock的选择意味着:同一线程内可以安全地组合调用(例如在持有锁的逻辑中再次调用内部加锁方法),避免自死锁;多线程服务中,撤销与写入、读取之间保持原子性,杜绝「撤销执行一半时新写入溜进缓存」的竞态。指纹计算在锁外完成,锁内只做纯内存结构操作,因此锁粒度小、对吞吐影响有限。

完整接入示例:处理管线中的同意生命周期

把文档示例与源码 API 组合,一个典型接入模式如下:

from openmed.clinical.consent_cache import ConsentCache cache = ConsentCache(max_entries=2048) # 1) 处理完成后,以同意上下文为维度缓存派生输出 cache.put( "patient-note-001", {"summary": "synthetic derived output", "entities": 3}, scope="summary", revision="receipt-2026-001", ) # 2) 后续请求精确命中(scope/revision 必须与写入一致) hit = cache.get( "patient-note-001", scope="summary", revision="receipt-2026-001", ) assert hit is not None # 3) 收到撤销指令后,按 scope 撤销并留存聚合审计事件 event = cache.revoke(scope="summary", revision="receipt-2026-001") print(event.to_dict()) # {"event_type": "consent_cache_invalidation", "invalidated_count": 1} # 4) 同一 revision 不再可写;新 revision 仍可代表新同意 assert cache.put("patient-note-001", {...}, scope="summary", revision="receipt-2026-001") is False assert cache.put("patient-note-001", {...}, scope="summary", revision="receipt-2026-002") is True

接入时的几条工程建议:

  • 把 scope 设计为语义稳定、可排序的枚举值(如"summary""billing"),因为 scope 的规范化基于排序后的字符串集合,顺序不影响匹配;
  • revision 使用不可变、可复现的收据标识(如包含时间戳或一次性 ID 的字符串),一旦撤销,该修订永不回写;
  • 每次处理都携带 scope+revision 调用get,避免省略参数时因多上下文而意外未命中;
  • ConsentInvalidationEvent落到应用自己的审计通道,因为模块本身不写文件、不记日志;
  • 不要把缓存驱逐当成撤销上报,两者在审计语义上严格不同。

源码与测试路径索引

  • 模块实现:openmed/clinical/consent_cache.py
  • 测试用例:tests/unit/clinical/test_consent_cache.py
  • 公共 API 导出:openmed/clinical/init.py(ConsentCacheConsentCacheEntryConsentCacheStatsConsentInvalidationEventConsentScopeConsentRevision及三个指纹函数均在__all__中)
  • 设计文档:docs/clinical/consent-cache.md
  • 相邻的合规强制层:docs/compliance/consent-data-use-tags.md

测试覆盖了六个核心行为:指纹确定性且不泄露原始输入、条目元数据不含键与输出、精确撤销只影响匹配对、墓碑阻止旧 revision 重写、scope 级撤销与纯计数审计、重复撤销的确定性。这些用例可作为接入 ConsentCache 时行为契约的权威参照。

小结

ConsentCache 是一个「小而严谨」的模块:它以 SHA-256 指纹替代原始同意数据、以墓碑保证撤销不可逆、以仅计数的审计事件满足可追溯性、以max_entries控制内存边界,并在职责上严格保持「基础设施保护」的克制定位——不联网、不落盘、不记日志、不裁定合法性。对于在 OpenMed 本地优先架构中需要缓存派生临床输出、同时又必须响应同意撤回的应用来说,它是把「可撤销性」与「隐私最小化」统一起来的一个务实基础件。

【免费下载链接】openmedLocal-first healthcare AI: clinical NER & HIPAA PII de-identification that runs 100% on-device. 2,200+ medical models, 21 languages, Apple MLX + Python, no cloud, no patient data leaving your network. Apache-2.0项目地址: https://gitcode.com/GitHub_Trending/ope/openmed

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询