「脏」源文件 → NMPA eCTD 合规申报包 · 逐字段证据链可溯源
English · An open-source pipeline that triages messy registration source files (mixed PDF / Word / Excel, scanned pages, merged cells), files them into CTD nodes, and assembles an NMPA eCTD-compliant submission package (index.xml + M1–M5). The design center is a per-claim evidence chain: every extracted value carries an anchor (page + bbox for PDF, sheet + cell for xlsx, table index + cell for docx) and can jump back to its source in one click. All model / prompt / contract changes are evaluation-gated — they must pass a frozen gold-set regression with stratified metrics before they count. Full English README
产品中轴线:源文件 → CTD 节点装配 + per-claim 证据链。合规校验是入场券,护城河是「逐字段能证明它对、能一键跳回源证据」。
中国药品注册自 2023 年起推进 eCTD 电子申报(ICH M8 + NMPA《eCTD 实施指南 V1.0》《eCTD 技术规范 V1.0》《eCTD 验证标准 V1.0》),验证报告中任何一条「错误」级信息都会导致光盘拒收。而真实申报的源文件是 PDF / Word / Excel 混排、扫描件、合并单元格、旧格式的「脏」数据,从源文件到 CTD 节点的归位、到逐项数值的核对,高度依赖人工,耗时且高风险。
商业 eCTD 出版工具(docuBridge、EXTEDO、GlobalSubmit 等)解决的是「最后一步出版」,没有解决两件事:
- 源文件 → CTD 节点的语义归位(哪份文件属于哪个节点、把握多大、不确定时怎么办);
- 每个抽取值可证明(这个 Cmax 从哪来、点一下能不能回到源文件那一格)。
本项目把这两件事作为产品中轴线。AI(LLM)在其中承担分类兜底与对话式操作,但所有结论必须可溯源、可撤销、可评测——证据锚点是硬不变量,QC 走确定性指针回跳,不走向量检索。
flowchart LR
S1[PDF / docx / xlsx<br/>混排·扫描件·旧格式] --> T[分诊层:物理测量<br/>文本密度/乱码率]
T --> R[表格结构还原<br/>单元格网格 + 格位锚点]
R --> C[规则优先归位]
C -->|未命中| L[LLM 兜底分类<br/>JSON mode + 校验重试]
R --> E[按契约结构化抽取<br/>claim keys + 单元格锚点]
L --> Q[人工复核队列<br/>低置信优先 · HITL 闸门]
E --> Q
Q --> A[eCTD 申报包装配<br/>index.xml + M1–M5]
O[(SHA-256<br/>内容寻址对象库)] -.-> R
V[(append-only<br/>哈希链事件日志)] -.-> Q
G[(金标评测集<br/>版本化·冻结)] -.-> A
M0 存储/审计地基 + 模型接入 + 编排 + 复核闭环(已跑通,94 项测试全绿); M1 分诊层已跑通;M2 抽取层机制已就位(结构化基线抽取 + 单元格级证据回跳,pdf/docx/xlsx 三格式路由),等 claim 键契约定稿; 评测基础设施(金标 + 分层指标 + 回归门禁)可用;冒烟测试 209 项。
| 已建 | 位置 |
|---|---|
| SQLite schema v0(9 张业务表 + 迁移器) | src/pharma_ectd/sql/001_init.sql、db.py |
| 写操作日志(对话/界面/流水线写入的撤销依据) | sql/002_applied_actions.sql、store/repository.py |
| 金标评测集(版本化、冻结不可改、可导出) | sql/003_gold_sets.sql、store/repository.py |
| 内容寻址对象库(SHA-256 只读落盘) | store/filestore.py |
| append-only 事件日志(哈希链 + 触发器禁改) | store/events.py |
| 仓储层(证据锚点强制、指针回跳、四元组缓存) | store/repository.py |
| 分诊层(物理测量;接住一切输入,测不了进人工队列) | ingest.py |
| 表格结构还原(pdf/docx/xlsx 三载体:单元格网格 + 单元格级锚点 + 数值/单位解析) | tables.py |
| 抽取契约(claim 键清单,草稿待注册专家确认) | M2-抽取契约/、claimkeys.py |
| 结构化基线抽取(按契约定位 + 单元格级锚点;PDF=页码+bbox+格位、xlsx=工作表+格位、docx=表序+格位) | extraction.py |
| 分诊步骤(导入 → 测量 → 表格 → 归位建议 → 自动确认 → 闸门) | steps.py |
| 抽取流水线(表格 → 按契约抽取 → 闸门) | steps.py |
| 分层指标与回归门禁(数值/文本/分类 + Wilson 区间) | evaluation.py |
| LLM 客户端(JSON mode + pydantic 校验重试 + 分层路由) | llm/ |
| 对话指令壳(白名单动作 + 写操作留痕可撤销) | llm/tools.py、llm/chat.py |
| 自写 DAG 状态机(幂等步 + 断点续跑 + HITL 闸门) | pipeline.py |
| REST API + 复核界面(bbox 回跳、确认/改选/驳回、对话框、专家模式金标标注) | api/ |
| 分诊报告排版(中文显示宽度对齐) | report.py |
| 冒烟测试 209 项 | tests/ |
doctor 自检、端到端/分诊/抽取/金标/复核/对话六个演示 |
__main__.py、scripts/ |
尚未开工:LLM 语义抽取(表格里的字段名与契约不一致时的兜底)、PDF 技术规范渲染与 A 类规则 preflight(M1.5,等规则库)、官方验证软件终检流程固化。
待定稿:M2-抽取契约/claim_keys.draft.json(17 条草稿,0 条 confirmed;版式假设已在 FDA 压力集上验证成立)。
FDA 压力集已建(2026-09-21,19 份公开审评文件 1999–2024,物理层表现与版式假设报告见 research/fda-压力集/report.md;文件本体在 var/stress-corpus/fda/ 不入库,可按 manifest.tsv 重下)。
# 项目 venv(需要 Python 3.12+)
python3.12 -m venv .venv
.venv/bin/python -m pip install -e ".[llm,api,dev]"可选 extras:llm(litellm,真实模型调用)、api(fastapi/uvicorn/pymupdf,Web 界面与页面渲染)、dev(pytest/httpx)。核心包只依赖 pydantic——不装 llm/api 也能跑通存储层与测试。
模型调用:
LiteLLMBackend需要对应厂商的 API key(环境变量或.env,.env不入库)。没有 key 时用ScriptedBackend(测试与演示走它,离线可跑通全链路)。代理提示:若你的 shell 配置了本地代理环境变量且代理不在线,联网命令(pip / LLM 调用 / 语料下载)需临时加
NO_PROXY='*' no_proxy='*'前缀单次绕过。
.venv/bin/python -m pytest # 全量测试
.venv/bin/python -m pharma_ectd doctor # 自检:schema / 事件链 / 对象库
.venv/bin/python -m pharma_ectd serve --port 8765 # 复核界面(普通 RA 的唯一入口)+ /v1 API
# M1 分诊:给一堆源文件(或目录)出归位建议报告
.venv/bin/python -m pharma_ectd triage <文件或目录...> --product-name 示例品种
# 没有 API key 也能跑:测量与人工队列照常,归位建议会落进人工队列并写明原因
# --no-autoconfirm 全部留给人工;--autoconfirm-above 0.95 提高自动通过门槛
# M2 抽取:按契约(claim 键清单)取值,出「值 + 出处」表(纯结构化定位,不需要 key)
.venv/bin/python -m pharma_ectd extract # 最近的申报包
.venv/bin/python -m pharma_ectd extract --submission <id> --contract <契约路径>
# 评测:金标集管理与回归门禁(换模型/改 prompt/改契约 必须过这一步)
.venv/bin/python -m pharma_ectd gold list
.venv/bin/python -m pharma_ectd gold export --name <金标集名> --out gold.jsonl
.venv/bin/python -m pharma_ectd eval --gold <名> --min-accuracy 0.9 # 归位回归
.venv/bin/python -m pharma_ectd eval --task extract --gold <名> --min-accuracy 0.9 # 抽取回归
# 端到端演示(都不需要 API key)
PHARMA_ECTD_DATA_DIR=/tmp/ectd-demo \
.venv/bin/python scripts/demo_e2e.py <某个真实文件> # 存储与证据链,含篡改检测
PHARMA_ECTD_DATA_DIR=/tmp/ectd-triage \
.venv/bin/python scripts/demo_triage.py # 脏批次分诊(扫描件/合并单元格/旧格式)
PHARMA_ECTD_DATA_DIR=/tmp/ectd-extract \
.venv/bin/python scripts/demo_extract.py # 带框线表 → 按契约抽取 → 单元格锚点 → 回归
PHARMA_ECTD_DATA_DIR=/tmp/ectd-gold \
.venv/bin/python scripts/demo_gold_eval.py # 标注 → 冻结 → 跑回归(含"换模型后掉下来")
PHARMA_ECTD_DATA_DIR=/tmp/ectd-review \
.venv/bin/python scripts/demo_review.py # 造待办事项,配合 serve 走复核闭环
PHARMA_ECTD_DATA_DIR=/tmp/ectd-review \
.venv/bin/python scripts/demo_chat.py # 对话指令壳:读 → 写 → 撤销 → 打印审计轨
PHARMA_ECTD_DATA_DIR=/tmp/ectd-m2loop \
.venv/bin/python scripts/demo_m2_loop.py # M2 闭环预演:临时 confirmed 契约 → 抽取 → API 复核 → 金标 → eval运行数据默认落在 <repo>/var/(数据库 + 对象库),可用 PHARMA_ECTD_DATA_DIR 覆盖。该目录永不进版本库,备份与客户环境迁移只搬它。
src/pharma_ectd/
├── config.py 数据目录配置(PHARMA_ECTD_DATA_DIR)
├── db.py SQLite 连接 / 迁移器 / 事务原语
├── provenance.py 四元组 provenance 与缓存 key
├── pipeline.py 自写 DAG 状态机(幂等步 / 断点续跑 / HITL 闸门)
├── ingest.py 分诊层:物理测量(PDF/docx/xlsx/未知→人工队列)
├── tables.py 表格结构还原(单元格网格 + 单元格级锚点 + 数值/单位解析)
├── claimkeys.py 抽取契约(claim 键清单)的读取与校验
├── extraction.py 结构化基线抽取(按契约坐标定位取值 + 钉单元格锚点)
├── steps.py 分诊与抽取步骤(导入 → 测量 → 表格 → 归位/抽取 → 自动确认 → 闸门)
├── evaluation.py 分层指标(Wilson 区间)与回归门禁
├── report.py 人看的报告(中文显示宽度对齐)
├── errors.py 异常类型
├── sql/NNN_*.sql 迁移文件(只增不改)
├── llm/
│ ├── routing.py 任务层 → 模型档(flash / strong)路由表
│ ├── backends.py LiteLLM 生产后端(惰性导入)+ ScriptedBackend(离线)
│ ├── client.py JSON mode + 校验回灌重试 + 四元组缓存
│ ├── tools.py 对话可执行的动作白名单(读写;写动作自带反向信息)
│ └── chat.py 对话壳:模型 → 命令 → 校验 → 执行 → 留痕
├── store/
│ ├── filestore.py 内容寻址对象库
│ ├── events.py append-only 事件日志
│ └── repository.py 仓储(唯一写 SQL 的地方)
└── api/
├── app.py REST API(/v1/*)
├── rendering.py 原文页渲染 + 证据 bbox 高亮
└── page.py 复核界面(单页 HTML,零术语,含对话框)
- 只有
events是 append-only(触发器拒绝 UPDATE/DELETE,哈希链检测带外改写)。其余表是可变投影,改动必须配一行事件——数据与审计同事务,同生共死。 - 证据锚点是硬不变量:非人工来源的 claim 必须带至少一条锚点(文件 hash + 页码 + bbox),入口直接拦。QC 走确定性指针回跳,不走向量检索。
- 值不覆盖:抽取/分类修正产生新行,旧行转
superseded,supersedes_id连成历史链。 - 缓存 key 是四元组:(输入 hash, prompt 版本, 模型+版本, 采样参数),缺一即拒绝计算——否则改了 prompt 缓存不失效、provenance 断裂。输入 hash 还覆盖 schema 指纹,改 schema 同样失效。
- 迁移文件只增不改:已应用的迁移内容一变就报错,宁可炸掉也不留语义不明的库。
- 规则 DSL 结论进
rules.formal_json:M0 普查出的条件原语 / 引用链等设计结论不需要 schema 迁移。 - 内容寻址先落盘、后写库:孤儿对象无害,空指针才有害。
- 所有 SQL 集中在
store/repository.py:这是 Postgres 迁口。 - 结构化输出只走 JSON mode + pydantic 校验回灌重试,不用 function-calling(国产模型保真度参差);私有化时同一
Backend接口换成 vLLM guided decoding。 - 步骤跳过看状态,LLM 复用看缓存:DAG 步骤以「这个申报包这一步成功过」为准跳过;模型调用以四元组为准跨包复用。缓存命中也写一条
llm.cache_hit事件(可算命中率)。 - 模型是配置项:任务分三档路由(
llm/routing.py),换模型必须过评测集回归,模型名进 provenance。 - 人工修正即确认,且继承锚点:人改过的值不再排队等自己复核;修正后的行沿用原锚点——出处链断了,「逐字段可证明」就是空话。
- 时间戳微秒精度:毫秒会让同毫秒内写入的多行排序退化成随机。
- 对话可以直接写库,但必须留痕且可撤销(项目 owner 的决定)。三条保证:动作来自固定白名单(
llm/tools.py)、参数 pydantic 校验、事项必须属于当前申报包;每次对话写一条chat.turn事件(原话/动作/参数/模型/结果),写动作再写action.applied事件 +applied_actions行;写动作执行时把反向信息(新建哪些行、原状态是什么)一并存下,POST /v1/actions/{id}/revert一键退回,撤销本身也是新的审计事实。 - 按钮与对话共用同一套写动作:界面点击也走
llm/tools.py的白名单(source=ui),所以同一份状态校验、同一份 effects 记录——界面点出来的操作同样可撤销。要加写能力就加一个动作,别开第二条写路径。 - 撤销不级联:只回放那一次动作的效果。后来的修改各有一条日志,该撤哪条撤哪条——级联撤销会让"为什么现在是这个状态"变得不可回答。
- 对话上下文必须带"这是什么":只给编号和数值是不够的。操作者会说「乳糖那条改一下」,上下文里就得有「乳糖 30.0 mg」这种字样(
review_queue.snippet→chat._label_of),否则模型只能瞎猜。 - 分诊层接住一切输入:收敛的是抽取范围,不是导入范围。测不出来的(扫描件、乱码、旧格式、缺解析库)进明示的人工队列并写明理由,不进也不静默失败。
MIN_CHAR_DENSITY/MAX_GARBLED_RATIO在ingest.py里,是工程启发式(非法规要求),可调。 - 归位建议:规则优先、模型兜底。B 类规则命中就不调模型(省 token 也更可审计);
rules.formal_json里的{"ctd_node","doc_class","match_filename"}是暂定契约,等普查出 DSL 后替换,改动只在steps._match_rule一个函数里。 - 流水线自动确认也是可撤销的写操作:走
autoconfirm_classification(记applied_actions),且新行method='system'——自动确认不能记成 human,否则审计轨上分不清人和机器。 - 密度要带单位:PDF 是每页字符、docx 是总字符、xlsx 是每表单元格。单位不同的数字混在一列里会误导人,所以报告里带后缀(
57/页、38字、6.5格/表),长文本(人工队列的理由)另起一行缩进显示。 - 表格层只还原结构,不猜语义:
tables.py输出单元格网格 + 每格自己的 bbox + 行列号/格位(C2),"哪一格是 Cmax"留给抽取层。单元格锚点就是日后数值 QC 指针回跳的落点。 - 半定量不是数字:
<0.01、≤5、约 45、N/A一律不解析成数字(parse_number/parse_quantity返回 None)。吞掉它们会让 QC 说不清"这个值到底等于多少"。45.0 mg走parse_quantity→(45.0, "mg")。 - 金标只收"人已经判断过"的:
gold_payload_from_item拒绝proposed(没人判断过,等于机器自己出题)与rejected(被否掉的不该当标准答案);对superseded沿 supersede 链找到那条 confirmed 的行——因为人在界面上操作的正是链头那条旧建议,让界面去追链是泄漏内部建模。 - 冻结后不可改,导出即基线:金标集一旦冻结(
frozen_at)就不能再增删项,要新增标注得另建一集。导出的 JSONL 带document_sha256+ 锚点快照,可长期存档。金标禁止进 few-shot——它是度量工具,污染了就没有独立参照。 - 指标必须分层并带区间:数值/文本/表格结构难度差一个量级,混成一个数会掩盖问题;Wilson 区间在小样本下会明显变宽(3 个样本的 100% 区间是
[43.8%, 100.0%]),那正是"现在还不该下结论"的信号。 - 回归靠四元组缓存,所以重跑几乎不花钱:同一份文档 + 同一 prompt + 同一模型 → 命中缓存;而换模型或改 prompt 必然 cache miss,于是回归门禁会真的重新调用并产生新的数字。换句话说:想验证"换模型后会不会变差",就必须真的换模型 id(改
llm/routing.py),用同一个模型跑两遍只会得到同一个答案。 - 对外名字要防 URL 编码坑:
/v1/gold/sets/{id_or_name}支持中文名,但很多 HTTP 客户端不会自动百分号编码,中文名进 URL 会失败——集成时用 id,名字留给 CLI 与人工。 - 抽取契约是数据不是代码:
M2-抽取契约/claim_keys.draft.json由注册专家维护,改它不需要动 Python。加载时校验宁严勿宽(缺 unit、locator 太空、claim_key 重复都直接拒)——契约写错会静默产出错字段,比崩掉贵得多。改claim_key等于换字段:要改就新增一条并把旧的置retired(历史抽取与基线要对得上)。 - 抽取器只做结构定位,不猜语义:位置由契约声明(表头 + 行标签 → 交叉那一格)。三种"不该猜"的情况一律降把握并写明,不替人决定:多命中且值不同(列候选)、单位不符(值照取但降把握)、半定量(
<0.01不产出数值)。找不到就如实说找不到,并把实际表头列出来——人才好判断是契约写错还是文件里真没这张表。 - 身份字段不参与值比对:
claim_key/document_id之类是"哪一条"的标识,不是"值是什么"。(踩过:金标带 claim_key、抽取预测不带 → 回归整批 0 分。) - 版式假设必须对着真实文件确认:契约里的 locator 是按"参数在行、数值在列"写的(PK/毒理表常见版式)。真实源表若是反向版式,定位要改写——
claim_keys.draft.json的risky_parts已列出这类待确认项。
普通 RA 人员 只用中文 Web UI(零术语、默认通过、只打扰低置信、向导式);CLI 与 REST API 只面向开发者与集成方。python -m pharma_ectd 属于后者。
已实现的 REST 端点:
| 端点 | 用途 |
|---|---|
GET / |
复核界面(普通 RA 的唯一入口) |
GET /v1/health |
自检:schema 版本、事件链完整性 |
POST /v1/submissions、GET /v1/submissions[/{id}] |
建包 / 列表 / 详情(含各步执行状态) |
POST /v1/submissions/{id}/ingest |
导入源文件(按路径;挂载目录模型) |
GET /v1/submissions/{id}/triage |
归位建议报告(文件清单 + 建议位置 + 把握程度) |
GET /v1/submissions/{id}/provenance |
溯源:claim → 文件 + 页码 + bbox + 原文片段(抽取项锚点带 cell_ref:第几页哪一格) |
GET /v1/review/queue、POST /v1/review/items/{id} |
待办队列(低置信优先)/ 确认·改选·驳回(返回 action_id 可撤销) |
POST /v1/chat、GET /v1/chat/tools |
对话指令(直接执行写操作,返回 action_id 与 ui_hint)/ 动作白名单 |
GET /v1/submissions/{id}/actions、POST /v1/actions/{id}/revert |
写操作日志(含来源与原话)/ 撤销 |
GET /v1/documents/{id}/page/{n}.png |
原文页渲染 + 证据框高亮(复核界面的证据面板) |
GET /v1/rules[/{id}] |
规则库查询(普查数据入库后即有内容) |
POST/GET /v1/gold/sets…、POST .../freeze、GET .../export |
金标集:建集 / 从已确认事项标注 / 冻结 / 导出 JSONL 基线 |
POST /v1/eval/run |
回归门禁:与金标比对(分层准确率 + 区间)。task=classify 重跑归位;task=extract 重跑抽取(contract 可指定契约路径,缺省草稿契约) |
GET /v1/metrics |
度量导出:「数据不出域、只出度量」通道,只有计数与失败类别,无内容 |
尚未实现:/v1/submissions/{id}/assembly/confirm(装配确认,M1 起)、Webhook 回调、API Key 认证(当前按内网单机部署,鉴权与 HTTPS 由部署层负责)。
- M1.5:PDF 技术规范渲染(PDF/A、书签、超链接、字体嵌入)+ A 类验证规则 preflight
- M2 收尾:claim 键契约由注册专家定稿(17 条草稿 → confirmed),随后接 LLM 语义抽取兜底(字段名与契约不一致时)
- M3:官方验证软件终检流程固化 + 装配确认闭环
- 规则库:M0 规则普查结论 → 归位规则 DSL(替换暂定契约)
- 多机构扩展:FDA / EMA 骨架适配(压力集已按 FDA 公开审评文件验证物理层)
- 本项目是个人开源项目,与任何药品监管机构及其下属单位无关,不代表任何官方立场。
- 仅供技术研究与工程参考,不构成注册申报合规建议。正式申报请以 NMPA / CDE 官方发布的《eCTD 实施指南》《eCTD 技术规范》《eCTD 验证标准》及官方验证软件的结论为准。
- 压力测试语料来自 FDA Drugs@FDA 公开审评文件(公有领域);仓库只收录清单与 SHA-256(
research/fda-压力集/manifest.tsv),不收录文件本体。
MIT © 2026 GGFxx