OpenMed 插件 SDK 实战:从一个可复制的示例包理解 entry-point 契约、离线合规校验与隐私边界
【免费下载链接】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 仓库中的示例插件包 examples/openmed-plugin-example/README.md 展开,完整讲解一个最小完整 OpenMed 插件分发的构成:确定性玩具识别器、隐私安全导出器、Python entry-point 元数据与离线合规测试。读完后,你将掌握如何按照openmed.plugins入口点契约开发自己的插件包、如何使用离线 conformance kit 进行自我认证,以及插件在本地优先(local-first)策略下必须遵守的隐私边界。需要说明的是:这是一个供扩展作者参考的示例,不是临床模型也不是医疗设备。
示例包定位:最小完整插件分发
examples/openmed-plugin-example是一个可以直接拷贝改名的包,展示了 OpenMed 插件 SDK 的最小完整形态。整个包只依赖本地运行时行为:不发起任何网络调用、不启用遥测、不写任何文件。目录结构非常精简:
examples/openmed-plugin-example/ ├── src/openmed_example_plugin/__init__.py # 两个组件 + 无参工厂 ├── tests/ │ ├── test_conformance.py # 离线自我认证测试 │ └── fixtures/malformed_plugin.py # 故意写坏的反例夹具 ├── pyproject.toml # entry-point 元数据 ├── LICENSE └── README.md示例中唯一的"识别对象"是虚构标记OPENMED_SYNTHETIC_PERSON——识别器只对这个合成标记做出响应,README 明确要求不要将真实患者数据用于其测试。这个设计贯穿了 conformance kit 的探针文本,后文会看到两者共用同一个合成面。
离线自我认证:两条命令 + 一个离线安装方式
前提是从仓库根目录运行,且已安装 OpenMed 及其开发依赖。README 给出的两条命令分别验证"命令行工具形态"和"测试断言形态":
PYTHONPATH="examples/openmed-plugin-example/src${PYTHONPATH:+:${PYTHONPATH}}" \ python -m openmed.plugins.conformance \ openmed_example_plugin:plugin_components PYTHONPATH="examples/openmed-plugin-example/src${PYTHONPATH:+:${PYTHONPATH}}" \ python -m pytest \ examples/openmed-plugin-example/tests/test_conformance.py -q两条命令的共同特点是只使用已提交的源码和合成值:不会枚举已安装的插件、不会访问包索引(package index),因此可以在完全离线的环境中执行。
对于已备好的离线环境,README 还给出了一个可编辑安装的命令,其中三个开关各有明确含义:
python -m pip install --no-index --no-deps --no-build-isolation -e \ examples/openmed-plugin-example--no-index:明确禁用 PyPI 等包索引,避免联网解析;--no-deps:不解析依赖(示例包声明的openmed>=2.0.0依赖在此环境中已预先满足);--no-build-isolation:不复现独立的构建隔离环境,直接使用当前环境中的构建后端。
entry-point 契约:pyproject.toml 中的注册声明
插件包通过[project.entry-points."openmed.plugins"]这一稳定的入口点组注册组件。示例的 pyproject.toml 声明了:
[project] name = "openmed-example-plugin" version = "0.1.0" requires-python = ">=3.10" license = "Apache-2.0" dependencies = ["openmed>=2.0.0"] [project.entry-points."openmed.plugins"] example = "openmed_example_plugin:plugin_components" [tool.hatch.build.targets.wheel] packages = ["src/openmed_example_plugin"]openmed_example_plugin:plugin_components指向一个无参组件工厂:
def plugin_components() -> tuple[ToyRecognizer, ToyExporter]: """Return the components loaded from the package entry point.""" return ToyRecognizer(), ToyExporter()从源码看,入口点引用的对象并不局限于无参工厂。registry.py 中的_coerce_components会递归处理三种形态:单个组件对象、可调用工厂、组件可迭代对象。conformance kit 的_coerce_components采用了同样的归一化逻辑,并对工厂抛异常、迭代器抛异常等情况分别给出component_contract失败原因而不是直接崩溃。
组件实现:元数据与两个契约方法
ToyRecognizer:只识别虚构标记的本地识别器
示例源码 中的ToyRecognizer不加载任何权重、不使用网络,仅用str.find循环定位标记并构造 span:
SYNTHETIC_PERSON_MARKER = "OPENMED_SYNTHETIC_PERSON" class ToyRecognizer: metadata = { "plugin_id": "openmed-example-plugin", "component_id": "toy-recognizer", "kind": "recognizer", "sdk_version": "1.0.0", "license": "Apache-2.0", "network_egress": False, "labels": ("PERSON",), "languages": ("en",), "name": "Toy synthetic marker recognizer", "description": "Recognizes one fictional conformance marker locally.", "metadata": { "fixture_policy": "synthetic_only", "local_first": True, }, } def recognize(self, text: str, **kwargs) -> Sequence[OpenMedSpan]: ...它返回的每个OpenMedSpan都遵循几个关键点:
- 偏移量指向传入的源文本(
start/end是相对于text的字符偏移,conformance kit 会校验0 <= start < end <= len(text)); text_hash使用openmed.core.schemas.span中的hmac_text_hash(marker, _HASH_KEY)生成 HMAC 哈希,而非原文,保证 span 记录本身不含源文本面(surface);canonical_label="PERSON"必须落在元数据声明的labels内;detector字段采用plugin:openmed-example-plugin:toy-recognizer的限定 ID 形式,与 SDK 中qualified_id = plugin_id:component_id的命名规则一致;evidence与metadata中只放安全说明(如"source": "synthetic_literal_marker"),绝不复制源文本。
ToyExporter:只序列化隐私安全字段
ToyExporter的契约方法export(spans, **kwargs)返回一个 JSON 兼容的映射:
def export(self, spans, **kwargs) -> Mapping[str, Any]: return { "schema": "openmed.example-plugin.spans.v1", "spans": [span.to_dict() for span in spans], }它的元数据同样声明network_egress = False、Apache-2.0许可,但languages为("*",)——导出器不产出语言相关的 span,声明通配语言是合理做法。README 对此的概括是:导出器序列化偏移量、哈希、标签和安全溯源信息,永不序列化源文本面。
静态元数据字段契约
每个组件都通过类属性metadata暴露静态元数据。示例中用到的字段与 protocols.py 中PluginComponentMetadata的完整字段定义对齐:
| 字段 | 契约 | 示例取值 |
|---|---|---|
plugin_id | 非空、稳定、不得含: | openmed-example-plugin |
component_id | 包内唯一、非空、不得含: | toy-recognizer/toy-exporter |
kind | 五类组件之一 | recognizer/exporter |
sdk_version | 目标 SDK 的语义化版本,主版本须匹配 | 1.0.0 |
license | SPDX 许可表达式,须为宽松许可 | Apache-2.0 |
network_egress | 布尔值,声明是否可发起网络调用 | False |
labels | 规范 OpenMed 标签序列;识别器与匿名化提供者必须至少声明一个 | ("PERSON",) |
languages | 语言标签序列或"*" | ("en",)/("*",) |
name/description | 可选的人类可读信息 | — |
metadata | 可选的静态、非 PHI 映射 | {"fixture_policy": "synthetic_only"} |
类型校验非常严格:_metadata_field_failure要求plugin_id、component_id、kind、sdk_version、license必须是字符串,network_egress必须是布尔,labels/languages必须是字符串序列(裸字符串会被拒绝,因为str本身也是Sequence)。SDK 主版本不匹配会以protocol_version_mismatch报告——openmed.plugins.protocols中PLUGIN_SDK_MAJOR = 1,所以1.x系列版本都能通过。
离线 conformance kit:合成探针如何驱动校验
python -m openmed.plugins.conformance的实现在 conformance.py,它的头部文档注释明确:只验证静态元数据、只用确定性合成值调用公开方法,不枚举入口点、不安装包、不打开 socket、不持久化源文本。
探针文本与 span 校验
conformance kit 内部定义了一个与示例插件完全同源的探针面:
_PROBE_SURFACE = "OPENMED_SYNTHETIC_PERSON" _PROBE_TEXT = f"conformance {_PROBE_SURFACE} fixture"对识别器,kit 会调用recognize(_PROBE_TEXT)并逐项检查(见_probe_recognizer):
- 返回值必须是
Sequence,且每个元素必须是OpenMedSpan; - 偏移量必须落在探针文本范围内(
span.start < 0或span.end > len(_PROBE_TEXT)即失败); canonical_label必须在该组件声明的labels之内;- 用
_contains_surface递归扫描 span 的evidence和metadata子树,一旦在其中发现探针源文本面,即以recognize() copied source text into span metadata or evidence判为runtime_contract失败——这是"span 记录不得含原文"这一隐私约束的机器化执行。
对导出器,kit 调用export((_PROBE_SPAN,)),先验证返回类型(字符串、bytes、Mapping、或 Mapping 序列),再用同样的_contains_surface检查导出产物中不含探针面(_probe_exporter)。
失败原因码:稳定、机器可读、安全
所有失败都被归一化为PluginConformanceFailure(reason, message, component_id),其中message保证不含探针源文本。conformance kit 定义了一组稳定 reason(conformance.py L21-L30):
| reason | 触发场景 |
|---|---|
invalid_metadata | 元数据字段缺失、类型错误、空 id、id 含: |
unknown_component_kind | kind不在五种支持类型中 |
protocol_version_mismatch | sdk_version主版本不支持 |
non_permissive_license | 许可表达式不是宽松 SPDX 许可 |
network_egress_not_local_first | 声明了network_egress = True(自我认证路径下) |
invalid_label | 标签不在规范 OpenMed 标签 schema 中 |
missing_labels | 识别器未声明任何标签 |
duplicate_component | 限定组件 id 在包内重复 |
component_contract | 工厂/迭代器抛异常或缺少必需方法 |
runtime_contract | 探针调用抛异常或返回值违反契约 |
宽松许可白名单(registry.py 中的PERMISSIVE_LICENSES,conformance kit 有等价的兜底常量)包括Apache-2.0、MIT、BSD-2-Clause、BSD-3-Clause、ISC、CC0-1.0、CC-BY-4.0、0BSD、Unlicense、Zlib;_is_permissive_license会解析复合 SPDX 表达式,要求其中每一个许可 token 都在白名单内,AND/OR/WITH等连接词被过滤后参与判断。
反例夹具:故意写坏的插件如何被拒绝
tests/fixtures/malformed_plugin.py 是一个故意错误的夹具,它把网络策略声明成了字符串:
metadata = { ... "network_egress": "false", # 字符串,而非布尔 False ... }这个差异恰好演示了开发者能收到的反馈精度。对应测试 test_conformance.py 断言了三件事:
def test_malformed_fixture_has_specific_failure() -> None: report = check_plugin_conformance(_malformed_components()) assert not report.passed assert report.failures[0].reason == REASON_INVALID_METADATA assert report.failures[0].message == "network_egress must be a boolean" with pytest.raises( PluginConformanceError, match="invalid_metadata: network_egress must be a boolean", ): assert_plugin_conforms(_malformed_components())check_plugin_conformance返回报告但不抛异常,assert_plugin_conforms则在失败时抛出PluginConformanceError(其消息形如OpenMed plugin conformance failed:后跟逐条- reason: component_id: message)。同一文件中的正向测试还断言示例包report.passed为真且report.components_checked == 2。值得注意的是,夹具中recognize方法的 docstring 特别说明:元数据校验失败发生在探针探测之前,所以它的空实现根本不会被执行——元数据契约先于行为契约被验证。
五种组件类型与方法契约
虽然示例只实现了 recognizer 和 exporter,但 SDK 定义了完整的五类组件(protocols.py),conformance kit 对每类都有对应的方法名要求(_COMPONENT_METHODS,conformance.py L56-L62)和探针:
| kind | 必需公共方法 | 探针行为 |
|---|---|---|
recognizer | recognize(text, **kwargs) | 用探针文本调用,校验 span 类型、偏移、标签、无原文泄漏 |
anonymizer_provider | replacement_for(span, surface, **kwargs) | 替换必须非空且不包含源文本面 |
exporter | export(spans, **kwargs) | 校验返回类型与产物不含源文本面 |
interop_adapter | to_openmed_spans/from_openmed_spans | 双向转换通过规范 span 进行 |
language_pack | language_code()/canonical_labels() | 语言码非空,标签须在规范 schema 内 |
这些方法与 protocols.py 中typing.Protocol声明的契约一一对应,例如ExporterPlugin.export的返回类型注解为str | bytes | Mapping[str, Any] | Sequence[Mapping[str, Any]],与 conformance kit 中_probe_exporter接受的"有效输出"判断完全一致。
与运行时注册表的衔接(补充背景)
自我认证通过后,真正在 OpenMed 进程中加载插件的是 registry.py 的PluginRegistry。理解这段实现有助于理解示例包各项声明的意义:
- 发现始终是调用方触发、惰性、进程级作用域的:
discover_plugins()才枚举openmed.plugins组,导入openmed本身不会导入插件依赖; - 每个入口点独立隔离加载,损坏或策略受限的插件变成结构化的
PluginQuarantineRecord,不会拖垮其他插件或 OpenMed 本体; - 默认策略(
allow_network_egress=False、allow_non_permissive_licenses=False)只自动加载声明无网络出网、且许可表达式完全宽松的组件;network_egress=True或受限许可的组件会因network_egress_opt_in_required/non_permissive_license_opt_in_required被隔离,除非调用方通过opt_in_plugins(支持plugin_id或限定plugin_id:component_id)显式点名放行; - 重复的限定组件 id 会以
duplicate_component隔离——这正是 README 改编指南第 2 条"保持每个component_id在分发内唯一"的底层原因。
换句话说,示例包在元数据里写死network_egress = False与宽松许可,是为了走"默认策略即可自动加载"这条最快路径,而无需调用方做任何 opt-in。
改编此包的五步流程
README 给出的改编步骤(Adapt this package)与上述实现细节相互印证,可以逐条对照执行:
- 拷贝目录并重命名:项目名(
name)、导入包名(openmed_example_plugin)、plugin_id、entry-point 名都要一起改,保持四者一致; - 保证
component_id唯一:分发内每个组件的component_id不得重复,否则 conformance 与 registry 都会以duplicate_component拒绝; - 替换玩具方法、保留契约:把
recognize/export等方法的实现换成真实逻辑,但保持元数据字段与方法签名不变——kind决定了 kit 会检查哪些方法,返回类型必须仍是OpenMedSpan序列 / 结构化映射等契约类型; - 保持夹具合成化:测试数据必须是合成值;原始源文本面(raw source surfaces)永远不得进入日志、span metadata、导出产物、缓存或遥测——这是 conformance kit 用
_contains_surface在evidence/metadata子树与导出产物中递归强制的不变量; - 发布前运行 conformance 命令:以仓库中给出的两条命令为准,确保离线、合成输入下全部通过。
适用前提与限制
- 本流程要求 OpenMed 已安装(示例包声明
dependencies = ["openmed>=2.0.0"]、requires-python = ">=3.10"),conformance 命令通过PYTHONPATH指向src目录即可运行,无需先安装示例包本身; - 自我认证路径是纯离线的:不枚举已装插件、不访问包索引;只有显式执行
pip install -e时涉及安装,且离线环境必须加--no-index --no-deps --no-build-isolation; - 注册表验证的是兼容性声明与策略声明,如 plugin-sdk.md 所述,它不是针对不可信代码的沙箱——插件代码在安装并 opt-in 后运行于 OpenMed 进程内,这一信任边界应作为分发前的评估前提;
- 更完整的 SDK 稳定性策略(entry-point 契约、语义化版本规则、弃用窗口等)见 docs/plugin-sdk.md,其中明确:任何对入口点组重命名、稳定元数据字段/组件类型删改、必需方法契约变更、
OpenMedSpan偏移或标签语义变更,都要求 SDK 主版本号递增。
小结
examples/openmed-plugin-example用极小的代价演示了 OpenMed 插件体系的全部关键机制:一个openmed.pluginsentry-point 注册声明、两个带静态元数据的组件、一套只用合成探针驱动的离线 conformance kit,以及一个字符串"false"与布尔False之差就能触发invalid_metadata: network_egress must be a boolean的精确反馈闭环。对于扩展作者而言,以这个包为模板,严格遵守"合成夹具、无源文本面泄漏、本地优先"三条底线,即可完成从开发到自我认证的完整闭环。
【免费下载链接】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),仅供参考