Skip to content

Repository files navigation

ectd-assembler

「脏」源文件 → NMPA eCTD 合规申报包 · 逐字段证据链可溯源

Python 3.12 License: MIT tests status

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 等)解决的是「最后一步出版」,没有解决两件事:

  1. 源文件 → CTD 节点的语义归位(哪份文件属于哪个节点、把握多大、不确定时怎么办);
  2. 每个抽取值可证明(这个 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
Loading

当前进度

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,零术语,含对话框)

设计约定(改代码前先读)

  1. 只有 events 是 append-only(触发器拒绝 UPDATE/DELETE,哈希链检测带外改写)。其余表是可变投影,改动必须配一行事件——数据与审计同事务,同生共死。
  2. 证据锚点是硬不变量:非人工来源的 claim 必须带至少一条锚点(文件 hash + 页码 + bbox),入口直接拦。QC 走确定性指针回跳,不走向量检索。
  3. 值不覆盖:抽取/分类修正产生新行,旧行转 superseded,supersedes_id 连成历史链。
  4. 缓存 key 是四元组:(输入 hash, prompt 版本, 模型+版本, 采样参数),缺一即拒绝计算——否则改了 prompt 缓存不失效、provenance 断裂。输入 hash 还覆盖 schema 指纹,改 schema 同样失效。
  5. 迁移文件只增不改:已应用的迁移内容一变就报错,宁可炸掉也不留语义不明的库。
  6. 规则 DSL 结论进 rules.formal_json:M0 普查出的条件原语 / 引用链等设计结论不需要 schema 迁移。
  7. 内容寻址先落盘、后写库:孤儿对象无害,空指针才有害。
  8. 所有 SQL 集中在 store/repository.py:这是 Postgres 迁口。
  9. 结构化输出只走 JSON mode + pydantic 校验回灌重试,不用 function-calling(国产模型保真度参差);私有化时同一 Backend 接口换成 vLLM guided decoding。
  10. 步骤跳过看状态,LLM 复用看缓存:DAG 步骤以「这个申报包这一步成功过」为准跳过;模型调用以四元组为准跨包复用。缓存命中也写一条 llm.cache_hit 事件(可算命中率)。
  11. 模型是配置项:任务分三档路由(llm/routing.py),换模型必须过评测集回归,模型名进 provenance。
  12. 人工修正即确认,且继承锚点:人改过的值不再排队等自己复核;修正后的行沿用原锚点——出处链断了,「逐字段可证明」就是空话。
  13. 时间戳微秒精度:毫秒会让同毫秒内写入的多行排序退化成随机。
  14. 对话可以直接写库,但必须留痕且可撤销(项目 owner 的决定)。三条保证:动作来自固定白名单(llm/tools.py)、参数 pydantic 校验、事项必须属于当前申报包;每次对话写一条 chat.turn 事件(原话/动作/参数/模型/结果),写动作再写 action.applied 事件 + applied_actions 行;写动作执行时把反向信息(新建哪些行、原状态是什么)一并存下,POST /v1/actions/{id}/revert 一键退回,撤销本身也是新的审计事实。
  15. 按钮与对话共用同一套写动作:界面点击也走 llm/tools.py 的白名单(source=ui),所以同一份状态校验、同一份 effects 记录——界面点出来的操作同样可撤销。要加写能力就加一个动作,别开第二条写路径。
  16. 撤销不级联:只回放那一次动作的效果。后来的修改各有一条日志,该撤哪条撤哪条——级联撤销会让"为什么现在是这个状态"变得不可回答。
  17. 对话上下文必须带"这是什么":只给编号和数值是不够的。操作者会说「乳糖那条改一下」,上下文里就得有「乳糖 30.0 mg」这种字样(review_queue.snippet → chat._label_of),否则模型只能瞎猜。
  18. 分诊层接住一切输入:收敛的是抽取范围,不是导入范围。测不出来的(扫描件、乱码、旧格式、缺解析库)进明示的人工队列并写明理由,不进也不静默失败。MIN_CHAR_DENSITY / MAX_GARBLED_RATIO 在 ingest.py 里,是工程启发式(非法规要求),可调。
  19. 归位建议:规则优先、模型兜底。B 类规则命中就不调模型(省 token 也更可审计);rules.formal_json 里的 {"ctd_node","doc_class","match_filename"} 是暂定契约,等普查出 DSL 后替换,改动只在 steps._match_rule 一个函数里。
  20. 流水线自动确认也是可撤销的写操作:走 autoconfirm_classification(记 applied_actions),且新行 method='system'——自动确认不能记成 human,否则审计轨上分不清人和机器。
  21. 密度要带单位:PDF 是每页字符、docx 是总字符、xlsx 是每表单元格。单位不同的数字混在一列里会误导人,所以报告里带后缀(57/页、38字、6.5格/表),长文本(人工队列的理由)另起一行缩进显示。
  22. 表格层只还原结构,不猜语义:tables.py 输出单元格网格 + 每格自己的 bbox + 行列号/格位(C2),"哪一格是 Cmax"留给抽取层。单元格锚点就是日后数值 QC 指针回跳的落点。
  23. 半定量不是数字:<0.01、≤5、约 45、N/A 一律不解析成数字(parse_number/parse_quantity 返回 None)。吞掉它们会让 QC 说不清"这个值到底等于多少"。45.0 mg 走 parse_quantity → (45.0, "mg")。
  24. 金标只收"人已经判断过"的:gold_payload_from_item 拒绝 proposed(没人判断过,等于机器自己出题)与 rejected(被否掉的不该当标准答案);对 superseded 沿 supersede 链找到那条 confirmed 的行——因为人在界面上操作的正是链头那条旧建议,让界面去追链是泄漏内部建模。
  25. 冻结后不可改,导出即基线:金标集一旦冻结(frozen_at)就不能再增删项,要新增标注得另建一集。导出的 JSONL 带 document_sha256 + 锚点快照,可长期存档。金标禁止进 few-shot——它是度量工具,污染了就没有独立参照。
  26. 指标必须分层并带区间:数值/文本/表格结构难度差一个量级,混成一个数会掩盖问题;Wilson 区间在小样本下会明显变宽(3 个样本的 100% 区间是 [43.8%, 100.0%]),那正是"现在还不该下结论"的信号。
  27. 回归靠四元组缓存,所以重跑几乎不花钱:同一份文档 + 同一 prompt + 同一模型 → 命中缓存;而换模型或改 prompt 必然 cache miss,于是回归门禁会真的重新调用并产生新的数字。换句话说:想验证"换模型后会不会变差",就必须真的换模型 id(改 llm/routing.py),用同一个模型跑两遍只会得到同一个答案。
  28. 对外名字要防 URL 编码坑:/v1/gold/sets/{id_or_name} 支持中文名,但很多 HTTP 客户端不会自动百分号编码,中文名进 URL 会失败——集成时用 id,名字留给 CLI 与人工。
  29. 抽取契约是数据不是代码:M2-抽取契约/claim_keys.draft.json 由注册专家维护,改它不需要动 Python。加载时校验宁严勿宽(缺 unit、locator 太空、claim_key 重复都直接拒)——契约写错会静默产出错字段,比崩掉贵得多。改 claim_key 等于换字段:要改就新增一条并把旧的置 retired(历史抽取与基线要对得上)。
  30. 抽取器只做结构定位,不猜语义:位置由契约声明(表头 + 行标签 → 交叉那一格)。三种"不该猜"的情况一律降把握并写明,不替人决定:多命中且值不同(列候选)、单位不符(值照取但降把握)、半定量(<0.01 不产出数值)。找不到就如实说找不到,并把实际表头列出来——人才好判断是契约写错还是文件里真没这张表。
  31. 身份字段不参与值比对:claim_key/document_id 之类是"哪一条"的标识,不是"值是什么"。(踩过:金标带 claim_key、抽取预测不带 → 回归整批 0 分。)
  32. 版式假设必须对着真实文件确认:契约里的 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),不收录文件本体。

License

MIT © 2026 GGFxx

About

NMPA eCTD submission dossier assembler with per-claim evidence chain — messy PDF/Word/Excel sources → compliant package (index.xml + M1–M5), every value traceable to its source cell. 脏源文件→合规申报包,逐字段可溯源。

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages