Skip to content

feat(pmid): retrieve PublicationTypeList from PubMed metadata#57

Merged
caufieldjh merged 2 commits into
mainfrom
feat/pubmed-publication-types
Jul 9, 2026
Merged

feat(pmid): retrieve PublicationTypeList from PubMed metadata#57
caufieldjh merged 2 commits into
mainfrom
feat/pubmed-publication-types

Conversation

@caufieldjh

Copy link
Copy Markdown
Contributor

Closes #56.

Retrieves PubMed's PublicationTypeList — the MeSH publication-type descriptors that classify a source (e.g. "Case Reports", "Clinical Trial", "Review"; full list at https://www.nlm.nih.gov/mesh/pubtypes.html) — and surfaces it end-to-end.

Changes

  • ReferenceContent gains a publication_types: Optional[list[str]] field, the single carrier for citation metadata.
  • PMIDSource now fetches the article XML once and parses both MeSH terms and publication types from it, avoiding a second NCBI round-trip. Split into _fetch_pubmed_xml (fetch) plus pure parsers _parse_mesh_terms / _parse_publication_types.
  • Persisted to and reloaded from the disk-cache YAML frontmatter (round-trips through the cache).
  • Exposed in lookup json / yaml / text output.

Example

A real lookup of PMID:33301246 (the BNT162b2 trial paper) now caches:

publication_types:
- "Clinical Trial, Phase II"
- "Clinical Trial, Phase III"
- Journal Article
- Multicenter Study
- Randomized Controlled Trial
- "Research Support, Non-U.S. Gov't"

Notes

  • Values are preserved verbatim from PubMed, including the generic Journal Article type that tags nearly every record — not filtered, since it's a legitimate value downstream consumers may want.

Testing

Tests written first (TDD), following the repo's mocked-Entrez pattern: XML parse (present/absent), fetch() population with mocked Entrez.efetch, disk-cache round-trip, plus doctests on the parsers. Verified live against PMID:33301246.

Full just test green: 619 passed, mypy clean, ruff clean.

🤖 Generated with Claude Code

Closes #56. Surface PubMed publication types (MeSH publication-type
descriptors, e.g. "Case Reports", "Clinical Trial", "Review" — see
https://www.nlm.nih.gov/mesh/pubtypes.html) end-to-end.

- ReferenceContent gains a publication_types field.
- PMIDSource fetches the article XML once and parses both MeSH terms and
  publication types from it (no extra NCBI round-trip): _fetch_pubmed_xml
  plus pure parsers _parse_mesh_terms / _parse_publication_types.
- Persisted to and reloaded from the disk cache frontmatter.
- Exposed in lookup json/yaml/text output.

Tests written first: XML parse (present/absent), fetch() population with
mocked Entrez.efetch, cache round-trip, plus doctests on the parsers.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@claude

claude Bot commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

Claude finished @caufieldjh's task in 1m 39s —— View job


PR Review: PublicationTypeList from PubMed metadata

  • Read changed files
  • Review code quality & best practices
  • Check for bugs / edge cases
  • Assess test coverage
  • Post review

Overall this is a clean, well-scoped change. It follows the repo's TDD conventions, adds real (mocked-Entrez) tests plus doctests, threads the new field consistently through model → source → cache → CLI, and the docstrings are genuinely helpful. Nice work. A few notes below, none blocking.

👍 Strengths

  • TDD + doctests on both parsers (_parse_mesh_terms, _parse_publication_types) — matches CLAUDE.md guidance.
  • Good refactor: splitting fetch (_fetch_pubmed_xml) from pure parsing makes the parsers trivially unit-testable without mocking Entrez, and folding MeSH + pub-types into one XML fetch removes a redundant NCBI round-trip.
  • End-to-end threading is complete and symmetric with the existing keywords handling (model default None, cache save/load, json/yaml/text output).
  • find_all("PublicationType") is correctly scoped to type_list rather than the whole document, and the walrus filter drops empty values.

🔧 Minor suggestions (non-blocking)

1. Redundant self-assignment in cache loadreference_fetcher.py:638-644

publication_types = frontmatter.get("publication_types")
if publication_types and isinstance(publication_types, list):
    publication_types = publication_types   # no-op branch
elif publication_types:
    publication_types = [publication_types]
else:
    publication_types = None

The publication_types = publication_types branch is a no-op. This is a faithful copy of the existing authors/keywords blocks just above (lines 622-636), so it's consistent with current style — but all three could collapse to a small helper, e.g.:

def _as_optional_list(value):
    if not value:
        return None
    return value if isinstance(value, list) else [value]

Worth doing only if you want to tidy the trio together; otherwise consistency is a fine reason to leave it.

2. Still two efetch calls per PMIDpmid.py:140-141
_fetch_abstract (rettype="abstract", text) and _fetch_pubmed_xml (rettype="xml") are separate calls, each with its own rate_limit_delay sleep. The article XML already contains the abstract, so in principle both could come from the single XML fetch. This is a pre-existing pattern (the abstract was always fetched separately) and the formatted text abstract is convenient, so it's reasonable to defer — just flagging it as the natural next optimization now that the XML is already in hand.

3. Test gap: empty PublicationTypeListtest_sources.py
test_parse_publication_types_absent covers the missing-element path, but not an element that's present-but-empty (<PublicationTypeList></PublicationTypeList>), which exercises the return types if types else None branch. A one-line case would close that. Same untested branch exists for _parse_mesh_terms.

Bugs / security / performance

No correctness bugs spotted. No security concerns (read-only parsing of NCBI XML, values preserved verbatim). Performance is neutral-to-slightly-better (one fewer XML fetch than before). The is not None handling for None article XML is correct.

Test coverage is solid for the happy paths and the round-trip; the only real gap is the empty-list edge case above.

- Collapse the repeated authors/keywords/publication_types frontmatter
  list-coercion into a shared _as_optional_list helper (review note 1).
- Add empty-list edge-case tests for _parse_publication_types and
  _parse_mesh_terms, covering the `return x if x else None` branch (note 3).

Deferring note 2 (folding the abstract into the single article-XML fetch):
the text-mode abstract fetch returns a richer formatted citation block than
the raw AbstractText XML nodes and feeds supporting-text validation, so the
swap is a behavior change rather than a mechanical cleanup — left as a
follow-up, as the reviewer flagged it deferrable.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@caufieldjh

Copy link
Copy Markdown
Contributor Author

Thanks for the review! Addressed in 9caecc9:

Note 1 (redundant self-assignment) — collapsed the authors / keywords / publication_types frontmatter blocks into a shared _as_optional_list staticmethod (with a doctest). The no-op branches are gone and all three are consistent.

Note 3 (empty-list edge case) — added test_parse_publication_types_empty_list and test_parse_mesh_terms_empty_list, both exercising the present-but-empty element path (return x if x else None).

Note 2 (still two efetch calls) — deferring for now. The rettype="abstract"&retmode="text" fetch returns a fuller human-formatted citation block than the raw AbstractText XML nodes, and that text is what feeds supporting-text validation. There's no test currently pinning the abstract format, so folding it into the XML fetch is a behavior change rather than a mechanical cleanup — better as its own PR with a format-equivalence test. Flagging as follow-up, as you noted it's deferrable.

just test green: 621 passed, mypy + ruff clean.

@claude

claude Bot commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

Claude finished @caufieldjh's task in 1m 14s —— View job


Follow-up review — fixes in 9caecc9

I re-reviewed the commit that addressed my earlier notes. All three items are handled correctly.

  • Read the follow-up commit (9caecc9)
  • Verify review notes were addressed correctly
  • Check new helper + tests for bugs/edge cases
  • Post review

Note 1 — list-coercion helper ✅

_as_optional_list (reference_fetcher.py:600) cleanly collapses the three duplicated authors / keywords / publication_types blocks, no-op branches gone. Behavior is equivalent to the originals: the old code used if value and isinstance(value, list), so an empty list [] was falsy and fell through to None — the new if not value: return None preserves that exactly, and also folds in the empty-string / empty-list cases. The doctest covers list / scalar / None. Good.

Note 3 — empty-element edge cases ✅

test_parse_publication_types_empty_list and test_parse_mesh_terms_empty_list correctly target the intended branch: soup.find("...") on a present-but-empty element returns a truthy Tag, so it passes the if not ...: return None guard, then find_all yields nothing → the return x if x else None branch fires. This is genuinely the previously-untested path, not a duplicate of the _absent case.

Note 2 — single-fetch optimization

Reasonable deferral, and the reasoning in the commit message is sound: the rettype="abstract"&retmode="text" block is a richer human-formatted citation than raw AbstractText nodes and feeds supporting-text validation, so folding it into the XML fetch is a behavior change needing a format-equivalence test — better as its own PR. Agreed it's a follow-up, not a blocker.

Minor nit (non-blocking)

_as_optional_list is annotated Optional[list] rather than Optional[list[str]]. It's a generic coercion helper so the bare list is defensible, and mypy is clean — leaving it is fine.


Nothing outstanding on my end. The change is complete, consistent, and the edge cases my earlier review flagged are now covered. LGTM 🚀

Note: I couldn't run the test suite here (test execution wasn't permitted in this job), so I'm relying on the reported just test green (621 passed, mypy + ruff clean) plus static analysis of the diff.

@caufieldjh caufieldjh merged commit 4a5b1b3 into main Jul 9, 2026
5 checks passed
@caufieldjh caufieldjh deleted the feat/pubmed-publication-types branch July 9, 2026 18:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Retrieve PublicationTypeList from PubMed citation metadata

1 participant