chore: batch commit remaining changes
CI / backend (push) Canceled after 0s
CI / frontend (push) Canceled after 0s

Includes search engine improvements, Alembic migrations,
new services (pubmed_daily_update, query_expansion),
frontend updates, and documentation sync.
This commit is contained in:
34047007@qq.com
2026-07-27 08:35:12 +08:00
parent 35b0a5c565
commit 62ca8fa6b8
82 changed files with 314248 additions and 1519 deletions
+285 -85
View File
@@ -8,37 +8,110 @@
## 一、PubMed 数据获取
### 1.1 实际运行模式API 优先
### 1.1 运行模式(决策:2026-07-25
当前部署使用 **NCBI E-utilities API****Europe PMC API** 作为数据源(`app/services/pubmed_api.py`),而非 FTP baseline
数据源统一为 **FTP 每日更新文件**`ftp://ftp.ncbi.nlm.nih.gov/pubmed/updatefiles/pubmed25updateNNN.xml.gz`),取代原有的 E-utilities API 多路搜索策略
| 数据源 | 协议 | 限速 | 用途 |
| 数据源 | 协议 | 内容 | 用途 |
|--------|------|------|------|
| NCBI E-utilities | REST XML | 3 req/s(免费)/ 10 req/s(有 API Key | 每日精搜(MAJR |
| Europe PMC | REST JSON | 无严格限速,支持游标分页 | MAJR 精搜 + 标题/摘要宽搜 |
| **FTP 每日更新文件** | FTP (gzip XML) | `<PubmedArticle>`(新增+修改)+ `<DeleteCitation>`(删除) | **唯一每日数据源** |
| NCBI E-utilities | REST XML | 搜索 + 抓取 | **仅降级后备**FTP 不可用时) |
| Europe PMC | REST JSON | 搜索 + 抓取 | 仅基线导入后一次性回填(`rebuild_from_baseline.py` |
```
精搜(每日 03:07 UTC):
ONCOLOGY_SEARCH_QUERIES × [MAJR] → NCBI esearch → efetch
约 20 篇/query × 10 queries = 200 篇/天
宽搜(每周日 03:37 UTC):
BROAD_ONCOLOGY_QUERIES × Title/Abstract → Europe PMC 游标分页
→ 覆盖 in-process + publisher 记录
→ 与精搜通过 seen_pmids 自动去重
每日更新(03:07 UTC):
FTP 下载 pubmed25updateNNN.xml.gz → lxml 解析
_extract_article() 提取字段(复用 baseline 解析器)
→ _is_oncology() 肿瘤科过滤(复用 baseline 过滤逻辑)
→ _update_lit_from_article() upsert(幂等,同 PMID 覆盖)
→ <DeleteCitation> → 标记 retracted=True
```
### 1.2 暂未使用 FTP
### 放弃 E-utilities 搜索的原因
FTP baseline 导入(`scripts/pubmed_baseline.py`)已完成开发但尚未在生产中执行,原因:
| 维度 | 旧: E-utilities API 多路搜索 | 新: FTP 每日更新文件 |
|------|------------------------------|---------------------|
| 完整性 | 仅返回匹配搜索词的结果。宽搜词覆盖不到的文章→**永恒漏掉** | **100%** — 当天所有增/改/删的记录都在一个文件里 |
| 删除检测 | 搜不到已删的记录 → 无法标记删除 | `<DeleteCitation>` 明确列出被删 PMID |
| MeSH 过渡 | 需 retagger 回查 in-process→medline 变化 | 更新文件直接包含 MeSH 状态变化后的完整记录 |
| 限速 | 3 req/s(免费) → 大结果集分页慢 | 单文件下载,无 API 限速 |
| 代码复杂度 | 2 套搜索(MAJR + 宽搜)+ 3 条解析路径 + retagger | 1 套下载 + 1 套解析(复用 baseline |
| 定时任务 | 精搜 + 宽搜 + retagger = 3 个 cron | 1 个 cron |
| 维度 | API | FTP |
|------|-----|-----|
| 初始数据量 | ~200 篇/天自动积累 | 一次导入 ~30GB |
| 部署复杂度 | 零额外配置 | 需下载 1200+ 个 XML 文件 |
| 推荐时机 | 每日自动运行 | 仅在需要全量历史文献时手动触发 |
FTP baseline 导入(`scripts/pubmed_baseline.py`)用于**首次全量数据**,完成后每日增量数据完全由 FTP update files 接管。
### 1.3 配置文件驱动过滤
### 1.2 FTP 每日更新文件结构
文件名格式:`pubmed25updateNNNN.xml.gz`(NNNN 为 4 位顺序号,每年重置)
文件包含三种顶层元素:
- `<PubmedArticle>` — **新增**或**已修改**的文献记录
- `<PubmedBookArticle>` — 图书章节(跳过,非期刊文献)
- `<DeleteCitation>` — 被删除的 PMID(含 PubMed/PMC 两种来源)
NLM 每天发布一个新文件(约 5-20MB 压缩后),全年约 365 个文件。
### 1.3 基线过渡(Baseline → FTP 衔接)
基线导入和 FTP 每日更新之间存在**年份衔接问题**:
| 问题 | 说明 | 处理方式 |
|------|------|---------|
| 基线年份 | 基线文件 `pubmed26n*.xml.gz` 中的 "26" 表示 2026 年 | **自动检测**`rebuild_from_baseline.py` 导入完成后自动记录 `pipeline_runs(run_type='baseline_import', run_metadata={'year': 2026})` |
| FTP 文件前缀 | 2026 年的增量文件为 `pubmed26updateNNNN.xml.gz` | 从基线记录读取年份,构造前缀 `pubmed{year}update` |
| 首次运行 | 基线刚导入完,FTP 文件可能从年初就开始有了 | 按 `sequence > 0` 拉取所有文件(与基线无交叠问题) |
| 跨年份过渡 | 12 月→1 月,文件前缀从 `pubmed25update``pubmed26update` | 前一年没文件时自动尝试上年;从 `pipeline_runs``run_metadata.last_sequence` 判断跨年 |
具体流程:
```
首次运行(无 checkpoint):
1. pipeline_runs 查 baseline_import 记录 → year=2026
2. 构造前缀 pubmed26update → 列出 FTP 文件
3. 全部下载处理(sequence 从 0001 开始)
4. 记录 run_metadata = {ftp_year: 2026, last_sequence: 最大序号}
后续运行(有 checkpoint):
1. 读上次 run_metadata → ftp_year=2026, last_sequence=1234
2. 列出 pubmed26update*,筛选 sequence > 1234
3. 处理 → 更新 run_metadata.last_sequence
```
### 1.4 检查点(Checkpoint)机制
使用 `pipeline_runs` 表的多字段组合追踪已处理状态:
| 字段 | 用途 |
|------|------|
| `run_type` = `"daily_ftp_update"` | 标记 FTP 更新运行 |
| `processed_date` (DATE) | 最后一个已处理的 EDAT 日期 |
| `run_metadata.ftp_year` (JSON) | FTP 文件年份(决定文件前缀) |
| `run_metadata.last_sequence` (JSON) | 最后一个已处理的文件序号 |
| `articles_new` | 新增文献数 |
| `articles_updated` | 更新文献数 |
| `articles_deleted` | DeleteCitation 处理数 |
**检查点行为:**
- 每天运行时,读 `pipeline_runs WHERE run_type='daily_ftp_update' ORDER BY created_at DESC LIMIT 1`
- 从上次 `last_sequence` 的序号+1开始拉取
- 如果当天无新 update 文件发布,跳过(非错误)
- 运行失败 → 不写 PipelineRun → 下次重跑同序列号(幂等)
- FTP 文件含多天的累积更新 → 按 `_update_lit_from_article()` 覆盖不会重复
### 1.5 EDAT 作为判断依据
EDATEntrez Date)是 PubMed 记录首次进入 Entrez 系统的日期,是判断"新记录"的最可靠依据:
| 特性 | EDAT |
|------|------|
| 是否可变 | **不可变** — 记录一旦入库,EDAT 终生不变 |
| 精度 | 精确到日(Y/M/D),无时间分量 → DATE 类型完全匹配 |
| 唯一性 | 同一天两条记录可能有不同 EDAT?不可能 — 所有新记录取当天日期 |
| 删除检测 | 不依赖 EDAT — `<DeleteCitation>` 独立处理 |
| 是否适用于 FTP | ✅ FTP 文件中可提取每条记录的 History/PubMedPubDate[@PubStatus="entrez"] |
EDAT 对应字段:`GlobalLiterature.entrez_date`DATE,新增字段,迁移:`1421ea169bb6`)。
### 1.5 配置文件驱动过滤
```yaml
# config/specialties/oncology.yaml
@@ -67,63 +140,61 @@ pubmed_filter:
## 二、Pipeline 完整流程
```
ARQ 定时任务 (每日精搜 03:07 UTC / 每周宽搜 周日 03:37 UTC)
ARQ 定时任务 (每日 FTP 更新 03:07 UTC)
┌─────────────────────────────────────────────────────────┐
│ Step 1: API 搜索
NCBI E-utilities (esearch + efetch) — MAJR 精搜
Europe PMC REST API — 标题/摘要宽搜
精搜 ~200 篇/天,宽搜 ~2000 篇/周日
自动去重 (seen_pmids 集合)
│ 预计耗时:1-2 分钟 │
│ Step 1: FTP 文件下载 + 检查点检测
从 pipeline_runs 读上次处理的 processed_date
下载 pubmed25updateNNNN.xml.gz(序号 > 上次处理的)
失败 → 降级到 E-utilities reldate=1&datetype=edat 后备
预计耗时:10-30 秒(gzip ~10MB 解压后 ~200MB
└───────────────────┬─────────────────────────────────────┘
┌─────────────────────────────────────────────────────────┐
│ Step 2: XML/JSON 解析 │
│ Step 2: XML 解析
│ │
│ 对每条 PubMedArticle 提取
│ 对每条 <PubmedArticle> 提取(复用 _extract_article()):
│ - PMID (PubMed ID) │
│ - Title, Abstract │
│ - Authors [{given, family, affiliation}] │
│ - DOI, Journal (name, ISSN, ISOAbbreviation, │
│ volume, issue, pages) │
│ - PublicationDate (year, month, day) │
│ - History (entrez_date, create_date, pubmed_revised,│
│ date_completed) │
│ - PublicationType (期刊文章/综述/RCT/指南...) │
│ - MeSH Headings [{descriptor, ui, major}] │
│ - Language │
│ - ChemicalList [{registry_number, ui, name}] │ ← 增强字段
│ - GeneSymbolList [string] │ ← 增强字段
│ - NumberOfReferences (int) │ ← 增强字段
│ - PublicationStatus (epublish/ppublish/aheadofprint)│ ← 增强字段
│ - ArticleDate (电子出版日期, DateType="Electronic") │ ← 增强字段
│ - DataBankList [{name, accession_numbers[]}] │ ← 增强字段
│ - SupplMeshList [{ui, name, type}] │ ← 增强字段
│ - ChemicalList [{registry_number, ui, name}] │
│ - GeneSymbolList [string] │
│ - NumberOfReferences (int) │
│ - PublicationStatus (epublish/ppublish/aheadofprint)│
│ - ArticleDate (电子出版日期, DateType="Electronic") │
│ - DataBankList [{name, accession_numbers[]}] │
│ - SupplMeshList [{ui, name, type}] │
│ │
增强字段覆盖率(实测 1369 篇):
publication_status ~100% article_date ~73%
│ chemical_list ~37% num_refs ~91% │
│ gene_symbols ~10% databank_list ~25% │
│ suppl_mesh_list ~35% │
对每条 <DeleteCitation>
查本地 PMID → 标记 retracted=True + updated_at
│ │
特殊处理:DeleteCitation → 标记删除不处理
│ 预计耗时:1-2 分钟 (2000-4000条) │
预计耗时:30-60 秒(单个文件 ~500-5000 条记录)
│ │
三处解析器(需保持同步):
│ - _parse_pubmed_xml() — NCBI E-utilities, ElementTree
│ - _extract_article() — FTP baseline, lxml
- _parse_europe_pmc_article() — Europe PMC, JSON
单一解析器(统一维护):
│ - _extract_article() — FTP, lxml (pubmed_baseline.py)
│ - 注:pubmed_api.py 中的_parse_pubmed_xml()
是 E-utilities 降级路径,需与 _extract_article()
│ 保持同步 │
│ │
两条入库路径(均需传递增强字段):
│ - _process_article() 创建新 GlobalLiterature()
│ - _update_lit_from_article() — 更新已有记录
│ 入库路径(已存在):
│ - _update_lit_from_article() — upsert 更新已有记录
│ - 新 PMID 通过 GlobalLiterature() 构造函数创建
│ │
└───────────────────┬─────────────────────────────────────┘
┌─────────────────────────────────────────────────────────┐
│ Step 3: 肿瘤科过滤(配置驱动
│ Step 3: 肿瘤科过滤(配置驱动,复用 _is_oncology()
│ │
│ 对每篇文章的 MeSH Headings[] 逐一检查: │
│ if 任一 heading.ui 在 mesh_include_categories 中 │
@@ -133,7 +204,7 @@ ARQ 定时任务 (每日精搜 03:07 UTC / 每周宽搜 周日 03:37 UTC)
│ else │
│ → 丢弃 │
│ │
│ 过滤结果:日均 3000 条 → 约 400-600 条肿瘤相关
│ 过滤结果:日均 ~5000 条总记录 → 约 400-800 条肿瘤相关 │
│ 预计耗时:1-2 分钟 │
└───────────────────┬─────────────────────────────────────┘
@@ -163,7 +234,11 @@ ARQ 定时任务 (每日精搜 03:07 UTC / 每周宽搜 周日 03:37 UTC)
│ INSERT INTO global_literature ... ON CONFLICT (pmid) │
│ DO UPDATE (如果已存在则更新) │
│ INSERT INTO global_literature_tags (literature_id, │
│ tag_id, is_major)
│ tag_id, is_major) ON CONFLICT DO UPDATE
│ │
│ DeleteCitation 处理: │
│ UPDATE global_literature SET retracted=TRUE, │
│ updated_at=NOW() WHERE pmid IN (...) │
│ │
│ 预计耗时:< 1 分钟(批量 INSERT
└───────────────────┬─────────────────────────────────────┘
@@ -220,6 +295,15 @@ ARQ 定时任务 (每日精搜 03:07 UTC / 每周宽搜 周日 03:37 UTC)
│ - 领域匹配 → related │
│ │
│ 预计耗时:< 1 分钟(千级用户,百级文献/用户) │
└───────────────────────┬─────────────────────────────────┘
┌─────────────────────────────────────────────────────────┐
│ Step 7: 更新检查点 │
│ INSERT INTO pipeline_runs (run_type='daily_ftp_update',│
│ processed_date=今天, status='success', │
│ articles_new=..., articles_updated=..., │
│ articles_deleted=...) │
└─────────────────────────────────────────────────────────┘
总计耗时:每日常规 5-10 分钟
@@ -231,11 +315,12 @@ ARQ 定时任务 (每日精搜 03:07 UTC / 每周宽搜 周日 03:37 UTC)
| 环节 | 数据量 | 预计耗时 |
|------|--------|:---:|
| API 搜索 + 抓取 | 200-2000 篇/次 | 1-2 分钟 |
| XML/JSON 解析 | 2000-4000 篇/天 | 1-2 分钟 |
| FTP 文件下载 + 解压 | 1 个文件,~10MB gzip | 10-30 秒 |
| XML 解析(含 DeleteCitation | 单文件 ~500-5000 条 | 30-60 秒 |
| MeSH 过滤 | — | 1-2 分钟 |
| 打标 | 每篇 5-15 个 MeSH | 1-2 分钟 |
| 入库 + 引用查询 | 400-600 INSERT/UPDATE + elink | 1-2 分钟 |
| 入库 + 引用查询 | 400-800 ON CONFLICT + elink | 1-2 分钟 |
| DeleteCitation 更新 | 通常 < 10 条/天 | < 1 秒 |
| OA 全文抓取 | ~15% 有 PMC | 每篇 1-3 秒 |
| 期刊去重同步 | 每篇 | < 1 秒 |
| 用户匹配 | 千级用户 | < 1 分钟 |
@@ -253,10 +338,9 @@ from arq.connections import RedisSettings
class WorkerSettings:
functions = [
"app.tasks.pubmed_pipeline.daily_pubmed_pipeline",
"app.tasks.pubmed_pipeline.weekly_broad_pipeline",
"app.tasks.pubmed_pipeline.daily_citation_update",
"app.tasks.pubmed_pipeline.daily_digest_task",
"app.tasks.pubmed_pipeline.daily_ftp_update", # FTP 每日增量(取代精搜+宽搜+retagger)
"app.tasks.pubmed_pipeline.daily_citation_update", # 每日引用刷新
"app.tasks.pubmed_pipeline.daily_digest_task", # 每日摘要邮件推送
"app.tasks.pubmed_pipeline.refresh_hot_articles_cache",
"app.tasks.pubmed_pipeline.refresh_homepage_feed",
]
@@ -266,10 +350,8 @@ class WorkerSettings:
password=settings.REDIS_PASSWORD,
)
cron_jobs = [
# 每日精搜(MAJR MeSH 高精度,20 篇/query
cron(daily_pubmed_pipeline, minute=7, hour=3),
# 每周宽搜(Title/Abstract 覆盖 in-process + publisher
cron(weekly_broad_pipeline, minute=37, hour=3, day_of_week=0),
# 每日 FTP 增量更新(取代 daily_pubmed_pipeline + weekly_broad_pipeline
cron(daily_ftp_update, minute=7, hour=3),
# 每日引用刷新
cron(daily_citation_update, minute=13, hour=5),
# 每日摘要邮件推送
@@ -282,17 +364,19 @@ class WorkerSettings:
# app/tasks/pubmed_pipeline.py
async def daily_pubmed_pipeline(ctx):
"""每日 PubMed 精搜"""
# 通过 pubmed_api.py _run_pipeline(use_majr=True, use_broad=False) 执行
# 约 10 queries × 20 篇 = 200 篇/天
...
async def daily_ftp_update(ctx):
"""每日 FTP 增量更新(取代旧 daily_pubmed_pipeline + weekly_broad_pipeline
async def weekly_broad_pipeline(ctx):
"""每周宽搜"""
# 通过 pubmed_api.py _run_pipeline(use_majr=False, use_broad=True) 执行
# 覆盖 in-process + publisher 记录,自动 seen_pmids 去重
...
流程:
1. 查 pipeline_runs 获取上次处理的 EDAT 检查点
2. 下载 FTP pubmed25update* 文件(序号 > 上次处理的值)
3. 解析 <PubmedArticle> → _extract_article() → _is_oncology() 过滤
4. 解析 <DeleteCitation> → 标记 retracted=True
5. upsert → _update_lit_from_article() 或 GlobalLiterature() 新建
6. MeSH 映射 → tag_article() 打标
7. 生成 Feed → generate_feeds_for_literature()
8. 更新检查点 → INSERT pipeline_runs
"""
async def daily_citation_update(ctx):
"""刷新最近文献被引次数"""
@@ -306,16 +390,70 @@ async def daily_citation_update(ctx):
| 场景 | 处理 |
|------|------|
| FTP 连接超时 | 重试 3 次,间隔 60s,仍失败则告警 |
| XML 解析错误 | 跳过单条,记录错误 PMID |
| FTP 连接超时 | 重试 3 次,间隔 60s,仍失败则降级到 E-utilities reldate |
| FTP 文件破损(gzip 校验失败) | 跳过该文件,记录日志,下次重试 |
| XML 解析错误 | 跳过单条 record,记录错误 PMID |
| 入库冲突 | `ON CONFLICT (pmid) DO UPDATE`,幂等 |
| MeSH 映射找不到 | 记录 `unmapped_mesh_terms` 日志,定期人工审查 |
| 用户匹配超时 | 分批处理,每批 500 用户 |
| 整管道失败 | 告警通知 → 次日重试前一天数据 |
| 整管道失败 | `processed_date` 不更新 → 次日重跑同一天数据(幂等保证) |
---
## 六、数据质量监控
## 六、代码实现
### 6.1 文件清单
| 文件 | 状态 | 说明 |
|------|------|------|
| `app/services/pubmed_daily_update.py` | ✅ **新增** | 核心服务:FTP 下载、解析、过滤、upsert、删除标记、检查点 |
| `app/tasks/worker.py` | ✅ **已改** | cron 替换:`daily_ftp_update` 取代 `daily_pubmed_pipeline` + `weekly_broad_pipeline` + `daily_mesh_retagger` |
| `app/api/v1/admin.py` | ✅ **已改** | `POST /pipeline/run` 默认 mode 改为 `ftp` |
| `app/models/operations.py` | ✅ **已改** | `PipelineRun.processed_date` DATE + `run_metadata` JSON(检查点) |
| `alembic/versions/f3b135d62407_...` | ✅ **已迁移** | 新增 `pipeline_runs.processed_date` 列 |
| `alembic/versions/0ee585329fc6_...` | ✅ **已迁移** | 新增 `pipeline_runs.metadata` JSON 列 |
### 6.2 核心流程
```
run_daily_ftp_update()
├─ 1. get_last_checkpoint()
│ SELECT run_metadata FROM pipeline_runs
│ WHERE run_type='daily_ftp_update' AND status='success'
│ ORDER BY created_at DESC LIMIT 1
│ → {ftp_year, last_sequence} 或 None(首次运行)
├─ 2. 确定文件年份:checkpoint.ftp_year ?? baseline_year ?? 当前年
├─ 3. list_update_files(year, after_sequence)
│ FTP NLST → pubmed{year}updateNNNN.xml.gz 列表
│ 筛选 sequence > last_sequence
├─ 4. 逐个 download_file() + gzip.decompress()
├─ 5. process_update_file()
│ ├─ DeleteCitation PMIDs → UPDATE retracted=TRUE
│ ├─ _extract_article() 解析(复用 pubmed_baseline.py
│ ├─ _is_oncology() 过滤(复用 pubmed_baseline.py
│ ├─ upsert: 已存在 → _update_lit_from_article() + tag_article()
│ └─ 新建 → GlobalLiterature() + tag_article() + generate_feeds()
└─ 6. PipelineRun(processed_date=当天, run_metadata={ftp_year, last_sequence})
```
### 6.3 降级策略
FTP 不可用时(网络故障、NLM FTP 宕机),回退到 E-utilities `run_eutils_fallback()`
```python
async def run_eutils_fallback(last_checkpoint):
# 使用 reldate=1&datetype=edat 查询最近 1 天新增
# 调用 _run_pipeline(use_majr=True, use_broad=True)
```
---
## 七、数据质量监控
```sql
-- 查看每天的采集量(异常检测)
@@ -335,13 +473,13 @@ AND created_at > now() - interval '7 days';
---
## 、热搜缓存管道
## 、热搜缓存管道
### 7.1 概述
### 8.1 概述
首页侧栏"热搜"展示近期高被引文献(支持分页 + "换一批")。首页文献列表也使用预缓存策略。两者都避免每次请求走全套查询。
### 7.2 数据流
### 8.2 数据流
```
ARQ 定时任务(每 30 分钟):
@@ -359,7 +497,7 @@ API 请求:
未命中 → 回退实时查询 DB → 写回缓存 → 返回
```
### 7.3 关键文件
### 8.3 关键文件
| 文件 | 说明 |
|------|------|
@@ -368,7 +506,7 @@ API 请求:
| `app/tasks/worker.py` | `refresh_hot_articles_cache` — 定时任务(`*/30` 分钟 UTC |
| `app/core/cache.py` | `CacheService` — Redis 优先,不可用时降级为内存缓存 |
### 7.4 配置参数
### 8.4 配置参数
| 参数 | 值 | 说明 |
|------|-----|------|
@@ -377,8 +515,70 @@ API 请求:
| `page_size` | 5 | 每页展示数 |
| 最大页数 | 3(前端限制,后端支持更多) |
### 7.5 缓存失效说明
### 8.5 缓存失效说明
- 定时任务每 30 分钟覆盖写入,完全刷新缓存
- 缓存通过 `CACHE_TTL` 自动过期,Redis 重启后首次请求回退实时查询
- 实时查询回退时仅第 1 页写入缓存,避免分页碎片
---
## 九、MeSH 年更维护
### 9.1 背景
NLM 每年 11 月中–12 月中进行 MeSH 词汇年更(新词加入、旧词合并、树号变更)。期间 indexed 记录暂停灌入,只发 in-process/publisher 记录。年更过后,大量已有记录的 MeSH 标引会被刷新。
### 9.2 刷新判断
`meshed_date` 字段记录 **MeSH 数据的版本时间**(NLM 实际完成 MeSH 标引的时间),不是我们的刷新操作时间:
| 状态 | meshed_date | 含义 |
|------|-------------|------|
| 已标引 | = date_completed | 当前 MeSH 是最新的 |
| 年更后已刷 | = 新的 date_completed | 已通过年更刷新 |
| 待刷新 | < 年更新阈值 | MeSH 可能已过时 |
| 无 MeSH | NULL | in-process/publisher,无标引 |
> ⚠️ `meshed_date` 始终使用 NLM 端的时间戳(`date_completed`),不写 `datetime.now()`。仅在 NLM 未返回 `date_completed` 的极少数情况下降级使用当前时间。
### 9.3 年更期间的应对
FTP 每日更新文件在年更期间的行为:
- Indexed 记录**暂停**出现在更新文件中(NLM 暂停新标引)
- In-process 和 publisher 记录**照常**出现在更新文件中
- 年更结束后,重标引的记录**一次性在更新文件中出现**(当天的 update 文件会异常大)
这意味着 **FTP 方案天然适配年更** — 不需要特殊的管道逻辑,更新文件里有什么就处理什么。
### 9.4 刷新操作(手工执行)
> **⚠️ 此操作为手工任务**。不要自动执行。需先观察 PubMed 状态判断年更是否完成。
判断时机:
1. 观察 NLM 公告(https://www.nlm.nih.gov/pubs/techbull/
2. 检查 pipeline 日志:indexed 记录是否恢复灌入
3. 确认年更窗口结束(通常 12 月中)
执行命令(admin token 需要有 `platform_operator` 角色):
```bash
# 查询待刷新文献数
psql -U scilit_dev -d scilit_dev -c "
SELECT count(*) FROM global_literature
WHERE meshed_date IS NOT NULL
AND meshed_date < '2025-11-01'; # 去年年更前
# 全量刷新 MeSH(按 PMID 批次 re-fetch
curl -X POST localhost:8000/api/v1/admin/pipeline/run \
-H "Authorization: Bearer $(admin_token)"
```
> Pipeline 正常运行时 `_update_lit_from_article()` 会自动覆盖 `meshed_date = date_completed`。但对于年更场景,重抓后 MeSH 已变,NLM 返回的 date_completed 仍是原值,所以 pipeline 更新不会正确标记。正确的年更刷新机制为一个独立脚本(待实现):按 `meshed_date < 年更新阈值` + `mesh_headings IS NOT NULL` 条件批量 efetch → 对比 MeSH 是否有变 → 有变则更新并用 `now()` 覆盖 `meshed_date`。
### 9.5 待实现脚本(低优先级)
| 任务 | 文件 | 优先级 | 说明 |
|------|------|--------|------|
| MeSH 年更批量刷新脚本 | `scripts/refresh_mesh_annual.py` | P2 | 按 meshed_date 阈值批量 efetch,检测 MeSH 变化,更新 meshed_date=now() |
| — | `app/services/mesh_retagger.py` | ❌ **已废弃** | FTP 每日更新文件直接包含 in-process→medline 过渡的完整记录,retagger 不再需要 |