
Search Lit
- 46 installs
- 236 repo stars
- Updated August 3, 2026
- aperivue/medsci-skills
search-lit is a Claude Code skill for medical literature search and citation management that queries PubMed, Semantic Scholar, and bioRxiv/medRxiv, verifies each reference against a live database, and generates BibTeX.
About
This skill runs literature searches and manages citations for medical research. It queries PubMed, Semantic Scholar, and preprint servers, deduplicates and verifies each reference against a live API, and generates BibTeX entries. A researcher uses it to build a verified reference library for a manuscript section without fabricated citations.
- Searches PubMed, Semantic Scholar, and bioRxiv/medRxiv and deduplicates results across databases
- Verifies every reference against a live database before inclusion; never generates citations from memory
- Generates BibTeX entries and falls back from MCP tools to NCBI E-utilities scripts when MCP is unavailable
Search Lit by the numbers
- 46 all-time installs (skills.sh)
- Ranked #1,109 of 2,715 Automation & Workflows skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
search-lit capabilities & compatibility
- Capabilities
- research · web search · citation management
- Works with
- github
- Use cases
- research · web search
What search-lit says it does
Every reference you produce must be verified against a live database -- never generate citations from memory alone.
npx skills add https://github.com/aperivue/medsci-skills --skill search-litAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 46 |
|---|---|
| repo stars | ★ 236 |
| Last updated | August 3, 2026 |
| Repository | aperivue/medsci-skills ↗ |
What it does
Search PubMed, Semantic Scholar, and preprints and produce API-verified BibTeX citations.
Who is it for?
Researchers building a verified reference library or background section for a medical manuscript.
Skip if: Bibliography management or reference auditing (use /lit-sync and /verify-refs).
When should I use this skill?
You need references for a manuscript section and want every citation verified against a live database.
What you get
A deduplicated, API-verified set of references with BibTeX entries ready for the manuscript.
- Verified reference table
- BibTeX entries
- Deduplicated search results
By the numbers
- searches 3 databases: PubMed, Semantic Scholar, bioRxiv/medRxiv
- E-utilities rate limit 3 requests/second without API key
Files
Literature Search Skill
You are assisting a medical researcher with literature searches and citation management for medical research papers. Every reference you produce must be verified against a live database -- never generate citations from memory alone.
Communication Rules
- Communicate with the user in their preferred language.
- All citation content (titles, abstracts, BibTeX) in English.
- Medical terminology is always in English.
Key Directories
- BibTeX output: User-specified directory (default: current working directory)
- Manuscript workspace: determined by the user or the calling skill
Search Tools: MCP (Primary) + E-utilities (Fallback)
Primary: MCP Tools (Claude.ai Remote)
| Database | MCP Tool | Purpose |
|---|---|---|
| PubMed | mcp__claude_ai_PubMed__search_articles | Search by query, MeSH terms |
| PubMed | mcp__claude_ai_PubMed__get_article_metadata | Full metadata for a PMID |
| PubMed | mcp__claude_ai_PubMed__find_related_articles | Related articles for a PMID |
| PubMed | mcp__claude_ai_PubMed__lookup_article_by_citation | Verify a citation |
| PubMed | mcp__claude_ai_PubMed__convert_article_ids | Convert between PMID/DOI/PMCID |
| Semantic Scholar | mcp__claude_ai_Scholar_Gateway__semanticSearch | Semantic search across all fields |
| bioRxiv/medRxiv | mcp__claude_ai_bioRxiv__search_preprints | Search preprint servers |
| bioRxiv/medRxiv | mcp__claude_ai_bioRxiv__get_preprint | Full preprint metadata |
| CrossRef | WebFetch with https://api.crossref.org/works/{DOI} | DOI verification |
Fallback: NCBI E-utilities (Direct API via Bash)
When PubMed MCP is unavailable (session timeout, "MCP session has been terminated" error, or "No such tool available" error), fall back to NCBI E-utilities via bundled scripts.
Detection: If any mcp__claude_ai_PubMed__* call returns an error containing "terminated", "not found", "not available", or "not connected", switch ALL subsequent PubMed calls in this session to E-utilities. Do not retry MCP after a disconnect — it will not recover within the same conversation.
Scripts (in ${CLAUDE_SKILL_DIR}/references/):
pubmed_eutils.sh— Bash wrapper for NCBI E-utilities APIparse_pubmed.py— Python parser for E-utilities responses
Usage patterns:
EUTILS="${CLAUDE_SKILL_DIR}/references/pubmed_eutils.sh"
PARSER="${CLAUDE_SKILL_DIR}/references/parse_pubmed.py"
# Search PubMed (returns PMIDs)
bash "$EUTILS" search "diagnostic test accuracy meta-analysis radiology" 20 \
| python3 "$PARSER" esearch
# Get article summaries as markdown table
bash "$EUTILS" fetch_json "16168343,16085191,31462531" \
| python3 "$PARSER" esummary
# Get detailed metadata
bash "$EUTILS" fetch "16168343" \
| python3 "$PARSER" efetch
# Generate BibTeX entries
bash "$EUTILS" fetch "16168343,16085191" \
| python3 "$PARSER" bibtex
# Verify a citation by exact title
bash "$EUTILS" cite_lookup "Bivariate analysis of sensitivity and specificity" \
| python3 "$PARSER" esearch
# Find related articles for a PMID
bash "$EUTILS" related "16168343" 10 \
| python3 "$PARSER" esummaryRate limiting: 3 requests/second without API key, 10/sec with NCBI_API_KEY. The script auto-sleeps 350ms between calls. For batch operations, keep calls sequential.
E-utilities → MCP equivalence:
| MCP Tool | E-utilities Command | Parser Mode |
|---|---|---|
search_articles | search <query> [retmax] | esearch |
get_article_metadata | fetch <pmids> | efetch or bibtex |
find_related_articles | related <pmid> [retmax] | esummary |
lookup_article_by_citation | cite_lookup <title> | esearch → fetch |
convert_article_ids | Not available (use CrossRef DOI lookup) | — |
---
Workflow
Phase 1: Search Strategy
1. Understand the need: Get the research topic, specific question, or manuscript section that needs references. 2. Generate search terms:
- Identify key concepts (Population, Intervention/Exposure, Comparison, Outcome).
- Generate MeSH terms for PubMed queries.
- Build Boolean queries:
(concept1 OR synonym1) AND (concept2 OR synonym2).
3. Define scope:
- Date range (default: last 10 years unless user specifies).
- Article types (original research, review, meta-analysis, etc.).
- Language filter (default: English).
4. Present the search plan to the user before executing. Include the Boolean query, databases to search, and filters.
Gate: Wait for user approval before running searches.
Phase 2: Execute Search
1. Search PubMed using search_articles with the Boolean query. 2. Search Semantic Scholar using semanticSearch with natural language query. 3. Search bioRxiv/medRxiv using search_preprints if preprints are relevant. 4. Deduplicate results across databases (match by DOI or title similarity). 5. Present results in a structured table:
| # | Title | Authors (first + last) | Year | Journal | PMID/DOI | Relevance |
|---|-------|----------------------|------|---------|----------|-----------|
| 1 | ... | Kim J, ... Lee S | 2024 | Radiology | 12345678 | High |6. Ask the user to select which papers to include.
Phase 2.5: Citation Searching (Snowballing)
Optional but recommended for systematic reviews and thorough background work (PRISMA item 7, "records identified through citation searching"). Expands a seed set along the citation graph instead of relying on Boolean recall alone.
Use the deterministic helper references/snowball.py (Semantic Scholar Graph API; nothing generated from memory):
# Expand seed DOIs/PMIDs in all directions, dedup against the existing pool,
# append verified candidates to references/library.bib
python3 references/snowball.py \
--seed DOI:10.1148/radiol.2024123,PMID:38000001 \
--direction all \
--pool references/library.bib \
--out references/library.bib- Directions:
backward(references the seeds cite),forward(papers
citing the seeds), similar (S2 recommendations), or all (default).
- Dedup: against the current
references/library.bibby DOI and
normalized title, and within the harvested set.
- Trust flag: snowball candidates are written
verified=false+
verified_by=semantic_scholar. They are candidates, not confirmed citations — run /verify-refs (or Phase 4 verification) to confirm each against PubMed/CrossRef before citing.
- Output contract: appends to
references/library.bibonly. NEVER writes
manuscript/_src/refs.bib (the script hard-refuses that path).
- PRISMA line: the script prints, e.g., `Records identified through
citation searching (snowballing): N raw (backward=…, forward=…, similar=…); after dedup against existing pool: M new candidates.` — record M in the PRISMA flow's citation-searching box.
A deterministic, network-free challenge card (recorded fixtures + expected output + verify.sh) lives in references/snowball_challenge/.
Phase 3: Deep Read
For each selected paper:
1. Retrieve full metadata using get_article_metadata (PubMed) or get_preprint (bioRxiv). 2. Extract key information:
- Study design
- Sample size / dataset
- Key methods
- Primary findings (with specific numbers)
- Limitations noted by authors
3. Build a literature matrix if multiple papers selected:
| Paper | Design | N | Key Finding | Limitation | Relevance to Our Study |
|-------|--------|---|-------------|------------|----------------------|4. Present the matrix to the user for review.
Phase 4: Citation Management
Anti-Hallucination Protocol
This is the most critical part of the skill. Follow these rules without exception:
1. NEVER generate a reference from memory alone. Every reference must come from an API search result. 2. NEVER fabricate DOIs or PMIDs. If you cannot find a DOI/PMID, mark the reference as [UNVERIFIED - NEEDS MANUAL CHECK]. 3. Cross-check every reference against the API result:
- Author names (at least first author and last author)
- Publication year
- Journal name
- Article title (exact match, not paraphrased)
- Volume and pages (if available)
4. If any field does not match, flag the specific mismatch. 5. For DOI verification, use WebFetch with https://api.crossref.org/works/{DOI} to confirm the DOI resolves correctly.
BibTeX Generation
For each reference (verified or not), generate a BibTeX entry with an explicit verified flag so downstream skills (/lit-sync, /verify-refs, /write-paper) can reason about trust without re-running verification:
@article{FirstAuthorLastName_Year_ShortKey,
author = {Last1, First1 and Last2, First2 and Last3, First3},
title = {Full Title As Retrieved From Database},
journal = {Journal Name},
year = {2024},
volume = {310},
number = {2},
pages = {e234567},
doi = {10.1001/jama.2024.12345},
pmid = {12345678},
verified = {true},
verified_by = {pubmed+crossref},
verified_on = {2026-04-24},
}`verified` flag values (required on every entry):
| Value | Meaning | Downstream behavior |
|---|---|---|
true | DOI or PMID confirmed via PubMed/CrossRef; title, authors, year all match | Safe to cite; /write-paper citekey-only gate passes |
false | Parsed from text but API lookup failed or returned mismatch | /verify-refs flags as UNVERIFIED; manuscript MUST show [UNVERIFIED - NEEDS MANUAL CHECK] |
manual | User explicitly added despite lookup failure | Treated as verified=false by /verify-refs but suppresses repeat warnings |
verified_by lists the data sources that confirmed the entry (e.g., pubmed, crossref, semantic_scholar, or a combination). verified_on is the ISO date of the most recent successful verification.
BibTeX key convention: FirstAuthorLastName_Year_OneWord (e.g., Kim_2024_Validation).
Output
1. Save BibTeX entries to the specified .bib file (append, do not overwrite). Target: references/library.bib (candidate pool for /lit-sync to import into Zotero). NEVER write to manuscript/_src/refs.bib — that is /lit-sync's sole-writer path per docs/artifact_contract.md. 2. Print a summary of all references with verification status:
Verified: 12 references (verified=true)
Unverified: 1 reference (verified=false) [NEEDS MANUAL CHECK]
Total: 13 referencesPhase 4b: Zotero Library Integration
If a Zotero MCP server is available, integrate search results with the user's library:
1. Add papers to Zotero: Use zotero_add_by_doi for DOI-based import (auto-downloads OA PDFs). 2. Organize into collections: Use zotero_manage_collections to file into the relevant project collection. 3. Check for duplicates: Use zotero_search_items to avoid adding papers already in the library. 4. Leverage annotations: Use zotero_get_annotations to reference the user's prior reading notes. 5. Write sync audit: Record collection key, added/skipped/failed counts, and unsynced entries in references/zotero_collection.json so Zotero status is auditable rather than a hidden optional side effect.
Requires Zotero Desktop running with MCP server. Skip this phase if unavailable.
If skipped, still write references/zotero_collection.json withstatus: "skipped" and the reason.Phase 5: Full-Text Retrieval
After identifying relevant papers, retrieve full-text PDFs for detailed review. This is especially important for meta-analyses where data extraction requires full text.
Phase 5a: Open Access Auto-Retrieval
Try sources in order of reliability:
1. Unpaywall API (highest quality OA links):
import os, requests
email = os.environ.get("UNPAYWALL_EMAIL", "user@example.com")
url = f"https://api.unpaywall.org/v2/{doi}?email={email}"
r = requests.get(url).json()
if r.get("best_oa_location", {}).get("url_for_pdf"):
pdf_url = r["best_oa_location"]["url_for_pdf"]2. PubMed Central (PMC):
- Convert PMID to PMCID via NCBI ID Converter
- Download from PMC OA service:
https://www.ncbi.nlm.nih.gov/pmc/articles/PMC{id}/pdf/
3. OpenAlex API (additional OA discovery):
url = f"https://api.openalex.org/works/https://doi.org/{doi}"
# Requires polite pool: add email in User-Agent header or mailto= param
r = requests.get(url, headers={"User-Agent": f"MyApp/1.0 (mailto:{email})"}).json()
oa_url = r.get("open_access", {}).get("oa_url")4. CrossRef landing page: Follow https://api.crossref.org/works/{doi} → publisher link → scrape <meta name="citation_pdf_url"> tag
Phase 5b: Alternative Sources
Some researchers use alternative access methods for paywalled content. Users are responsible for ensuring compliance with their institutional access policies.
If an environment variable (e.g., SCIHUB_BASE) is set, the skill may use it as an alternative PDF source. No specific URLs are provided here — users configure this themselves.
Other options:
- Institutional proxy/VPN: Access publisher sites through institutional EZproxy or VPN
- Interlibrary loan (ILL): Request through library services for papers not otherwise available
- Author contact: Email corresponding authors for preprints
PDF Validation
Always validate downloaded files before use:
def is_valid_pdf(filepath):
"""Check that a downloaded file is actually a PDF, not an HTML redirect."""
import os
if os.path.getsize(filepath) < 10240: # < 10KB is likely a stub/redirect
return False
with open(filepath, 'rb') as f:
header = f.read(5)
return header == b'%PDF-'Additional checks:
- Verify HTTP
Content-Type: application/pdfheader before saving - Files under 10KB are almost always HTML login/redirect pages, not real PDFs
- Some publishers return CAPTCHA pages — these fail the
%PDF-check
Rate Limiting
- Unpaywall: Polite pool (no hard limit with email parameter)
- OpenAlex: Include email in User-Agent for polite pool access
- NCBI/PMC: 3 requests/sec without API key, 10/sec with
NCBI_API_KEY - General: 2-second minimum interval between requests to any single host
Phase 6: Gap Analysis
When called during manuscript writing (especially by /write-paper Phase 7):
1. Read the manuscript to extract all inline citations. 2. Compare cited references against the search results. 3. Identify gaps:
- Key papers in the field that are not cited.
- Outdated references when newer versions exist.
- Missing methodological references (e.g., statistical methods, reporting guidelines).
4. Report findings to the user with specific suggestions.
---
Specialized Search Modes
Mode: Manuscript Paper Reference Pool
For supplying a manuscript's reference pool — typically invoked by /write-paper Step 7.3c (or /self-review Phase 2.5c-2) when the reference adequacy gate finds the draft under target or a named method uncited, but usable directly when building out an original-research bibliography.
This mode is deliberately broad: for an original-research article, return 25–40 verified candidates, not the ~10 a quick search settles on. Do not stop early unless the field is genuinely sparse — and if it is, say so explicitly rather than returning a thin list silently. Respect a narrower journal reference cap or user scope when one is given.
Structure the pool across six candidate categories so the gaps the adequacy gate cares about are all covered:
1. Background / disease burden / clinical context — establishes why the question matters. 2. Gap-defining prior studies — the work the manuscript extends or contradicts. 3. Comparator / comparable-design cohorts — studies the Results will be measured against. 4. Methods / statistical canonical sources — the originating reference for every named method, model, score, equation, or diagnostic criterion (e.g. competing-risk model, multiple imputation, E-value, eGFR equation, concordance statistic). This is the category that clears Methods named-method gaps. 5. Reporting-guideline sources — STROBE, TRIPOD(+AI), CONSORT, PRISMA(-DTA), STARD, etc. 6. Interpretation / mechanism / limitation support — grounds Discussion claims.
For each candidate, report: PMID/DOI, verification status, candidate category, the target manuscript section it belongs in, and a one-line why it is needed.
Boundary (unchanged): every entry is API-verified before inclusion, and BibTeX is appended only to references/library.bib — the candidate pool for /lit-sync to import into Zotero. Never write to manuscript/_src/refs.bib; that SSOT belongs to /lit-sync. This mode produces candidates; it does not decide inclusion (the user does) and it does not insert references into the manuscript bib.
Mode: Systematic Search
For systematic reviews or comprehensive literature sections:
1. Document the full search strategy (PRISMA-compliant). 2. Record: database, date of search, query string, number of results. 3. Track inclusion/exclusion at each screening step. 4. Output a PRISMA flow diagram data summary.
Mode: Quick Cite
For quickly finding a single reference the user describes:
1. User says something like "that 2023 paper by Smith about AI in chest X-ray." 2. Search PubMed and Semantic Scholar with the described details. 3. Present top 3 candidates. 4. User confirms which one. 5. Generate BibTeX entry.
Mode: Related Papers
For expanding from a known paper:
1. User provides a PMID or DOI. 2. Use find_related_articles to get related papers. 3. Use Semantic Scholar for citation-based recommendations. 4. Present results ranked by relevance.
For a structured, dedup-aware, PRISMA-countable expansion (backward + forward + similar) prefer Phase 2.5: Citation Searching with references/snowball.py, which appends verified candidates to references/library.bib and reports a citation-searching count.
Mode: Embase Browser Automation
Embase has no public API. Use Chrome browser automation (MCP) to search and export:
1. Navigate to embase.com — institutional SSO authenticates automatically. If cookie error (login?error#), clear Elsevier/Embase cookies and retry. 2. Go to Advanced Search tab. 3. Enter Embase-syntax query (Emtree /exp + :ab,ti field tags). Uncheck "Map to preferred term in Emtree" when using explicit /exp terms. 4. After results appear, use "Select number of items" dropdown → select total count. 5. Click Export (in Results section) → choose CSV format → check fields: Title, Author names, Source, Publication year, Publication type, DOI, Abstract, Language of article, Medline PMID. 6. Click Export → Download tab opens → click Download. 7. CSV is in row format (records separated by blank rows) — parse with:
# Each record = consecutive rows until blank row
# Row format: [FIELD_NAME, value1, value2, ...]
# AUTHOR NAMES row has multiple values (one per author)PubMed → Embase query translation:
- MeSH
[Mesh]→ Emtree/exp [tiab]→:ab,ti[Title/Abstract]→:ab,ti- Boolean operators stay the same (AND, OR)
- Phrase search: use single quotes in Embase (
'artificial ascites')
---
Error Handling
- If a search returns 0 results, broaden the query (remove one concept or use broader MeSH terms) and retry.
- CrossRef HTTP errors (token-saving rules):
- 403 (rate-limited): Do NOT retry. Skip CrossRef silently → verify via PubMed title search instead.
- 303 (redirect): Follow the redirect if possible. If not, skip CrossRef → PubMed fallback.
- Any repeated failure: After the first CrossRef 403/303 in a session, assume CrossRef is
rate-limiting and skip CrossRef for ALL remaining references. Go directly to PubMed title verification. This avoids N×retry token waste.
- Never print raw error messages like "Request failed with status code 403." Collect
failures silently and report a single summary line at the end: CrossRef unavailable for {N} references (rate-limited). Verified via PubMed instead.
- If a DOI does not resolve via CrossRef (after applying the rules above), try searching PubMed by title to confirm the reference exists.
- If the user provides a reference that cannot be verified by any method, clearly state: "This reference could not be verified. Please check manually before submission."
- Never silently include an unverified reference.
What This Skill Does NOT Do
- Does not download from paywalled journals without user-provided credentials or institutional access.
- Does not assess the quality of evidence (use
/analyze-statsor/check-reportingfor that). - Does not write the literature review text (use
/write-paperfor that). - Does not fabricate any part of a citation.
#!/usr/bin/env python3
"""
Parse PubMed E-utilities responses into structured data.
Usage:
# Parse esearch JSON → list of PMIDs
echo '<json>' | python3 parse_pubmed.py esearch
# Parse esummary JSON → markdown table
echo '<json>' | python3 parse_pubmed.py esummary
# Parse efetch XML → detailed metadata (for BibTeX generation)
echo '<xml>' | python3 parse_pubmed.py efetch
# Parse efetch XML → BibTeX entries
echo '<xml>' | python3 parse_pubmed.py bibtex
"""
import sys
import json
import re
import xml.etree.ElementTree as ET
from datetime import date
from textwrap import shorten
# Heuristic for East Asian name reverse-encoding in PubMed XML.
# Cases observed: <LastName>Qiaoling</LastName><ForeName>Fu</ForeName> where
# Fu is the actual family name. Pattern: LastName looks like a long given
# name (≥3 alpha chars, no spaces) AND ForeName looks like a short surname
# fragment (1-2 chars, no period). The naive test catches the common reverse
# encoding without flagging legitimate short-surname authors.
_EAST_ASIAN_REVERSE_THRESHOLD = 3 # LastName length lower bound for suspicion
def _looks_east_asian_reversed(last: str, fore: str) -> bool:
"""Return True if (LastName, ForeName) look swapped per PubMed encoding bug."""
if not last or not fore:
return False
# ForeName should look like a surname (1-2 chars, no spaces, no period)
# AND LastName should look like a multi-char given name.
return (
1 <= len(fore) <= 2
and fore.isalpha()
and "." not in fore
and len(last) >= _EAST_ASIAN_REVERSE_THRESHOLD
and last.isalpha()
)
def _extract_authors(author_list_el):
"""Walk an <AuthorList> element. Return (bib_authors, display_authors,
first_author_last, suspicions, has_collective_only).
- bib_authors: list of "Family, Given" strings for BibTeX `author = {...}`.
Corporate (<CollectiveName>) authors are double-braced.
- display_authors: list of "Last First" strings for human-readable output.
- first_author_last: surname of the first listed author (used for cite key).
- suspicions: list of human-readable warning strings (East Asian reverse
encoding, missing LastName, etc.).
- has_collective_only: True if AuthorList contains only <CollectiveName>
entries (no individual <LastName>). Caller should consider emitting
`@misc` instead of `@article` (guideline / consortium pattern).
"""
bib_authors: list[str] = []
display_authors: list[str] = []
first_author_last = ""
suspicions: list[str] = []
individual_count = 0
collective_count = 0
if author_list_el is None:
return bib_authors, display_authors, "", suspicions, False
for au in author_list_el.findall("Author"):
last = au.findtext("LastName", "") or ""
fore = au.findtext("ForeName", "") or ""
collective = au.findtext("CollectiveName", "") or ""
if collective:
collective_count += 1
# Double-brace to prevent BibTeX from splitting on the comma /
# spaces inside the corporate name.
bib_authors.append("{" + collective + "}")
display_authors.append(collective)
if not first_author_last:
first_author_last = re.sub(r"[^A-Za-z]+", "", collective.split()[0]) or "Group"
continue
if last:
individual_count += 1
if _looks_east_asian_reversed(last, fore):
suspicions.append(
f"East Asian name order suspected for '{last} {fore}' — "
"PubMed XML may have LastName/ForeName swapped"
)
bib_authors.append(f"{last}, {fore}")
display_authors.append(f"{last} {fore}".strip())
if not first_author_last:
first_author_last = last
continue
# Author element with neither <LastName> nor <CollectiveName>: rare
# but possible. Record as suspicion, otherwise skip.
suspicions.append("Author element with no LastName and no CollectiveName")
has_collective_only = collective_count > 0 and individual_count == 0
return bib_authors, display_authors, first_author_last, suspicions, has_collective_only
def parse_esearch(data: str) -> None:
"""Parse esearch JSON response, print PMIDs and count."""
result = json.loads(data)
esearch = result.get("esearchresult", {})
count = esearch.get("count", "0")
ids = esearch.get("idlist", [])
print(f"Total results: {count}")
print(f"Returned: {len(ids)}")
print(f"PMIDs: {','.join(ids)}")
def parse_esummary(data: str) -> None:
"""Parse esummary JSON response into a markdown table."""
result = json.loads(data)
docs = result.get("result", {})
uids = docs.get("uids", [])
if not uids:
print("No results found.")
return
print("| # | PMID | Year | Journal | Title | Authors |")
print("|---|------|------|---------|-------|---------|")
for i, uid in enumerate(uids, 1):
doc = docs.get(uid, {})
title = shorten(doc.get("title", "N/A"), width=80, placeholder="...")
authors_raw = doc.get("authors", [])
if authors_raw:
first = authors_raw[0].get("name", "")
last = authors_raw[-1].get("name", "") if len(authors_raw) > 1 else ""
authors = f"{first}, ... {last}" if last and last != first else first
else:
authors = "N/A"
journal = shorten(doc.get("fulljournalname", doc.get("source", "N/A")),
width=40, placeholder="...")
pubdate = doc.get("pubdate", "N/A")
year = pubdate[:4] if pubdate else "N/A"
doi_list = doc.get("articleids", [])
doi = next((d["value"] for d in doi_list if d.get("idtype") == "doi"), "")
print(f"| {i} | {uid} | {year} | {journal} | {title} | {authors} |")
print(f"\n*{len(uids)} articles retrieved*")
def parse_efetch(data: str) -> None:
"""Parse efetch XML response into structured metadata."""
root = ET.fromstring(data)
articles = root.findall(".//PubmedArticle")
for article in articles:
medline = article.find("MedlineCitation")
if medline is None:
continue
pmid = medline.findtext("PMID", "N/A")
art = medline.find("Article")
if art is None:
continue
title = art.findtext("ArticleTitle", "N/A")
journal_el = art.find("Journal")
journal = journal_el.findtext("Title", "N/A") if journal_el is not None else "N/A"
journal_abbrev = journal_el.findtext("ISOAbbreviation", "") if journal_el is not None else ""
# Year
ji = journal_el.find("JournalIssue") if journal_el is not None else None
pd = ji.find("PubDate") if ji is not None else None
year = pd.findtext("Year", "") if pd is not None else ""
if not year:
medline_date = pd.findtext("MedlineDate", "") if pd is not None else ""
year = medline_date[:4] if medline_date else "N/A"
volume = ji.findtext("Volume", "") if ji is not None else ""
issue = ji.findtext("Issue", "") if ji is not None else ""
# Pages
pages = art.findtext("Pagination/MedlinePgn", "")
# Authors (handles East Asian reverse encoding + CollectiveName)
author_list = art.find("AuthorList")
_, authors, _, suspicions, _ = _extract_authors(author_list)
# DOI
doi = ""
for aid in art.findall("ELocationID"):
if aid.get("EIdType") == "doi":
doi = aid.text or ""
# Abstract
abstract_el = art.find("Abstract")
abstract = ""
if abstract_el is not None:
parts = abstract_el.findall("AbstractText")
abstract = " ".join(
(p.get("Label", "") + ": " if p.get("Label") else "") + (p.text or "")
for p in parts
)
print(f"## PMID: {pmid}")
print(f"**Title**: {title}")
print(f"**Authors**: {'; '.join(authors)}")
print(f"**Journal**: {journal} ({journal_abbrev})")
print(f"**Year**: {year} **Volume**: {volume} **Issue**: {issue} **Pages**: {pages}")
print(f"**DOI**: {doi}")
if abstract:
print(f"**Abstract**: {shorten(abstract, width=500, placeholder='...')}")
for note in suspicions:
print(f"> ⚠ {note}")
print()
def generate_bibtex(data: str) -> None:
"""Parse efetch XML and generate BibTeX entries."""
root = ET.fromstring(data)
articles = root.findall(".//PubmedArticle")
for article in articles:
medline = article.find("MedlineCitation")
if medline is None:
continue
pmid = medline.findtext("PMID", "")
art = medline.find("Article")
if art is None:
continue
title = art.findtext("ArticleTitle", "")
journal_el = art.find("Journal")
journal_abbrev = journal_el.findtext("ISOAbbreviation", "") if journal_el is not None else ""
journal_full = journal_el.findtext("Title", "") if journal_el is not None else ""
ji = journal_el.find("JournalIssue") if journal_el is not None else None
pd = ji.find("PubDate") if ji is not None else None
year = pd.findtext("Year", "") if pd is not None else ""
if not year:
md = pd.findtext("MedlineDate", "") if pd is not None else ""
year = md[:4] if md else ""
volume = ji.findtext("Volume", "") if ji is not None else ""
issue = ji.findtext("Issue", "") if ji is not None else ""
pages = art.findtext("Pagination/MedlinePgn", "")
doi = ""
for aid in art.findall("ELocationID"):
if aid.get("EIdType") == "doi":
doi = aid.text or ""
author_list = art.find("AuthorList")
bib_authors, _, first_author_last, suspicions, has_collective_only = \
_extract_authors(author_list)
# Generate citation key
key = f"{first_author_last}_{year}_{pmid}" if first_author_last else f"PMID_{pmid}"
# Corporate / consortium guideline (e.g., KDIGO, AHA/ACC) — emit as
# @misc so BibTeX styles render the body of the entry without trying
# to format a personal author. Vancouver / AMA CSL handle both
# @article and @misc with author = {{Organization Name}}.
entry_type = "misc" if has_collective_only else "article"
# Prepend suspicion comments so they survive .bib copy/paste audits.
for note in suspicions:
print(f"% [VERIFY] {note}")
print(f"@{entry_type}{{{key},")
print(f" author = {{{' and '.join(bib_authors)}}},")
print(f" title = {{{title}}},")
print(f" journal = {{{journal_full}}},")
print(f" year = {{{year}}},")
if volume:
print(f" volume = {{{volume}}},")
if issue:
print(f" number = {{{issue}}},")
if pages:
print(f" pages = {{{pages}}},")
if doi:
print(f" doi = {{{doi}}},")
print(f" pmid = {{{pmid}}},")
# Anti-hallucination verification flag. Entries emitted by this script
# originate from PubMed efetch XML, so a non-empty PMID is proof of
# API provenance (verified=true). Missing PMID → verified=false and
# downstream tooling (/verify-refs) will flag for manual check.
verified = bool(pmid)
verified_by = "pubmed+crossref" if (pmid and doi) else ("pubmed" if pmid else "")
print(f" verified = {{{'true' if verified else 'false'}}},")
if verified_by:
print(f" verified_by = {{{verified_by}}},")
print(f" verified_on = {{{date.today().isoformat()}}},")
print("}")
print()
if __name__ == "__main__":
if len(sys.argv) < 2:
print(__doc__)
sys.exit(1)
mode = sys.argv[1]
data = sys.stdin.read()
dispatch = {
"esearch": parse_esearch,
"esummary": parse_esummary,
"efetch": parse_efetch,
"bibtex": generate_bibtex,
}
func = dispatch.get(mode)
if func is None:
print(f"Unknown mode: {mode}. Use: {', '.join(dispatch.keys())}")
sys.exit(1)
func(data)
#!/bin/bash
# PubMed E-utilities CLI wrapper
# Fallback when PubMed MCP server is unavailable
# Usage: bash pubmed_eutils.sh <command> <args...>
#
# Commands:
# search <query> [retmax] -- Search PubMed, return PMIDs
# fetch <pmid1,pmid2,...> -- Fetch article metadata (XML)
# fetch_json <pmid1,pmid2,...> -- Fetch article summary (JSON, DocSum)
# related <pmid> [retmax] -- Find related articles
# cite_lookup <title> -- Search by exact title to verify citation
#
# Environment:
# NCBI_API_KEY -- Optional. Increases rate limit from 3/sec to 10/sec.
# Register at https://www.ncbi.nlm.nih.gov/account/settings/
#
# Rate limiting: 3 requests/second without API key, 10/sec with key.
# This script sleeps 350ms between calls for safety.
set -euo pipefail
BASE="https://eutils.ncbi.nlm.nih.gov/entrez/eutils"
TOOL="claude-code-search-lit"
EMAIL="noreply@example.com"
DB="pubmed"
SLEEP=0.35
# Build API key param if available
API_KEY_PARAM=""
if [ -n "${NCBI_API_KEY:-}" ]; then
API_KEY_PARAM="&api_key=${NCBI_API_KEY}"
SLEEP=0.1
fi
_sleep() { sleep "$SLEEP"; }
_curl() {
local http_code body
body=$(curl -sS -w '\n%{http_code}' -A "Mozilla/5.0 (${TOOL})" "$@")
http_code=$(echo "$body" | tail -n1)
body=$(echo "$body" | sed '$d')
if [ "$http_code" -ge 400 ] 2>/dev/null; then
echo "{\"error\": \"HTTP ${http_code}\", \"url\": \"$1\"}" >&2
return 1
fi
echo "$body"
}
cmd_search() {
local query="${1:?Usage: search <query> [retmax]}"
local retmax="${2:-20}"
local url="${BASE}/esearch.fcgi?db=${DB}&term=$(python3 -c "import urllib.parse; print(urllib.parse.quote('${query}'))")&retmax=${retmax}&retmode=json&tool=${TOOL}&email=${EMAIL}${API_KEY_PARAM}"
_curl "$url"
}
cmd_fetch() {
local ids="${1:?Usage: fetch <pmid1,pmid2,...>}"
local url="${BASE}/efetch.fcgi?db=${DB}&id=${ids}&rettype=xml&retmode=xml&tool=${TOOL}&email=${EMAIL}${API_KEY_PARAM}"
_curl "$url"
}
cmd_fetch_json() {
local ids="${1:?Usage: fetch_json <pmid1,pmid2,...>}"
local url="${BASE}/esummary.fcgi?db=${DB}&id=${ids}&retmode=json&tool=${TOOL}&email=${EMAIL}${API_KEY_PARAM}"
_curl "$url"
}
cmd_related() {
local pmid="${1:?Usage: related <pmid> [retmax]}"
local retmax="${2:-10}"
local url="${BASE}/elink.fcgi?dbfrom=${DB}&db=${DB}&id=${pmid}&cmd=neighbor_score&retmode=json&tool=${TOOL}&email=${EMAIL}${API_KEY_PARAM}"
local result
result=$(_curl "$url")
# Extract linked PMIDs and fetch their summaries
local linked_ids
linked_ids=$(echo "$result" | python3 -c "
import sys, json
data = json.load(sys.stdin)
links = data.get('linksets', [{}])[0].get('linksetdbs', [{}])
for db in links:
if db.get('linkname') == 'pubmed_pubmed':
ids = [str(l['id']) for l in db.get('links', [])[:${retmax}]]
print(','.join(ids))
break
" 2>/dev/null || echo "")
if [ -n "$linked_ids" ]; then
_sleep
cmd_fetch_json "$linked_ids"
else
echo '{"error": "No related articles found"}'
fi
}
cmd_cite_lookup() {
local title="${1:?Usage: cite_lookup <title>}"
# Search by title field for exact verification
cmd_search "${title}[Title]" 5
}
# Dispatch
case "${1:-help}" in
search) shift; cmd_search "$@" ;;
fetch) shift; cmd_fetch "$@" ;;
fetch_json) shift; cmd_fetch_json "$@" ;;
related) shift; cmd_related "$@" ;;
cite_lookup) shift; cmd_cite_lookup "$@" ;;
help|*)
echo "Usage: bash pubmed_eutils.sh <command> <args...>"
echo "Commands: search, fetch, fetch_json, related, cite_lookup"
;;
esac
@article{Researcher_2019_Synthetic,
author = {Researcher, Ada and Coauthor, Ben},
title = {Synthetic Backward Paper One},
journal = {Journal of Synthetic Radiology},
year = {2019},
doi = {10.0/back-one},
pmid = {30000001},
verified = {false},
verified_by = {semantic_scholar},
verified_on = {2026-06-14},
source = {citation_snowball},
snowball_direction = {backward},
}
@article{Writer_2022_Synthetic,
author = {Writer, Dan},
title = {Synthetic Forward Paper Alpha},
journal = {AI in Synthetic Medicine},
year = {2022},
doi = {10.0/fwd-alpha},
pmid = {30000003},
verified = {false},
verified_by = {semantic_scholar},
verified_on = {2026-06-14},
source = {citation_snowball},
snowball_direction = {forward},
}
@article{Scholar_2023_Synthetic,
author = {Scholar, Eve and Helper, Finn},
title = {Synthetic Forward Paper Beta},
journal = {Synthetic Cardiology},
year = {2023},
doi = {10.0/fwd-beta},
verified = {false},
verified_by = {semantic_scholar},
verified_on = {2026-06-14},
source = {citation_snowball},
snowball_direction = {forward},
}
@article{Expert_2021_Synthetic,
author = {Expert, Gail},
title = {Synthetic Similar Recommendation},
journal = {Synthetic Methods},
year = {2021},
doi = {10.0/sim-one},
pmid = {30000005},
verified = {false},
verified_by = {semantic_scholar},
verified_on = {2026-06-14},
source = {citation_snowball},
snowball_direction = {similar},
}
{
"data": [
{
"citedPaper": {
"title": "Synthetic Backward Paper One",
"year": 2019,
"venue": "Journal of Synthetic Radiology",
"externalIds": {"DOI": "10.0/back-one", "PubMed": "30000001"},
"authors": [{"name": "Ada Researcher"}, {"name": "Ben Coauthor"}]
}
},
{
"citedPaper": {
"title": "Synthetic Duplicate In Pool",
"year": 2018,
"venue": "Synthetic Imaging",
"externalIds": {"DOI": "10.0/dup-backward", "PubMed": "30000002"},
"authors": [{"name": "Cara Author"}]
}
}
]
}
{
"data": [
{
"citingPaper": {
"title": "Synthetic Forward Paper Alpha",
"year": 2022,
"venue": "AI in Synthetic Medicine",
"externalIds": {"DOI": "10.0/fwd-alpha", "PubMed": "30000003"},
"authors": [{"name": "Dan Writer"}]
}
},
{
"citingPaper": {
"title": "Synthetic Forward Paper Beta",
"year": 2023,
"venue": "Synthetic Cardiology",
"externalIds": {"DOI": "10.0/fwd-beta"},
"authors": [{"name": "Eve Scholar"}, {"name": "Finn Helper"}]
}
}
]
}
{
"recommendedPapers": [
{
"title": "Synthetic Similar Recommendation",
"year": 2021,
"venue": "Synthetic Methods",
"externalIds": {"DOI": "10.0/sim-one", "PubMed": "30000005"},
"authors": [{"name": "Gail Expert"}]
}
]
}
@article{Cara_2018_Duplicate,
author = {Author, Cara},
title = {Synthetic Duplicate In Pool},
journal = {Synthetic Imaging},
year = {2018},
doi = {10.0/dup-backward},
verified = {true},
verified_by = {pubmed},
verified_on = {2026-06-01},
}
Challenge card — Citation snowballing (search-lit Phase 2.5)
Problem
Keyword/Boolean search alone misses relevant studies that are only reachable through the citation graph. Recognized systematic-review practice ("snowballing" / "citation searching", PRISMA item 7) requires expanding a seed set backward (references the seeds cite), forward (papers citing the seeds), and laterally (algorithmically similar papers), then reporting how many records that step contributed.
Before this gate, search-lit had only a lightweight "Related Papers" mode and no structured citation-searching workflow, no dedup against the existing candidate pool, and no PRISMA citation-search count.
What the new gate does
references/snowball.py expands seed DOIs/PMIDs via the Semantic Scholar Graph API (/references, /citations, /recommendations), deduplicates against the existing references/library.bib pool (by DOI and normalized title), and emits API-verified BibTeX candidates carrying verified=false + verified_by=semantic_scholar (downstream /verify-refs confirms each). It prints a PRISMA "records identified through citation searching" line.
Output is appended to the candidate pool; it never writes manuscript/_src/refs.bib (that is /lit-sync's sole path).
Fixture (synthetic only — no real papers/PII)
fixture/DOI_10_0_seed1.{backward,forward,similar}.json— recorded
Semantic-Scholar-shaped responses for one synthetic seed.
fixture/library.bib— an existing candidate pool containing one paper
(10.0/dup-backward) that also appears in the backward results.
Expected
expected/snowball.bib— 4 new candidates (1 backward, 2 forward, 1 similar).- The 2nd backward paper is dropped because its DOI already exists in the
pool. PRISMA line: 5 raw (backward=1, forward=2, similar=1) ... 4 new.
Baseline vs new gate
| Baseline (Related Papers mode) | New snowballing gate | |
|---|---|---|
| Directions | forward-ish recommendations only | backward + forward + similar |
| Dedup vs pool | none | DOI + normalized title |
| PRISMA citation-search count | none | printed |
| Output | ad hoc | verified BibTeX appended to library.bib |
Verifier (deterministic, no network)
bash verify.shReads the recorded fixtures, runs snowball.py --offline-fixture, and diffs against expected/snowball.bib. Exit 0 = match.
Acknowledgement
The reproducible "fixture + expected + deterministic verifier" packaging is inspired by public reproducible-audit layouts such as EinsteinArena (design inspiration only; no code, solutions, or data were copied).
#!/usr/bin/env bash
# Deterministic verifier for the citation-snowballing challenge card.
# No network: reads recorded Semantic Scholar responses from fixture/.
# Exit 0 = output matches expected/snowball.bib ; non-zero = regression.
set -euo pipefail
HERE="$(cd "$(dirname "$0")" && pwd)"
SNOWBALL="$HERE/../snowball.py"
actual="$(python3 "$SNOWBALL" \
--seed DOI:10.0/seed1 \
--direction all \
--offline-fixture "$HERE/fixture" \
--pool "$HERE/fixture/library.bib" \
--as-of 2026-06-14 \
--stdout 2>/dev/null)"
if diff -u "$HERE/expected/snowball.bib" <(printf '%s\n' "$actual"); then
echo "PASS: snowball output matches expected (4 new candidates; 1 backward dup removed)."
else
echo "FAIL: snowball output drifted from expected/snowball.bib" >&2
exit 1
fi
#!/usr/bin/env python3
"""
Citation snowballing for search-lit (Phase 2.5 Citation Searching).
Expands a seed set of papers along the citation graph and emits API-verified
BibTeX candidates, deduplicated against an existing candidate pool.
Directions
----------
backward : references the seed papers cite (cited-by-the-seed)
forward : papers that cite the seed (citing-the-seed)
similar : Semantic Scholar recommendations for the seed
all : backward + forward + similar (default)
Data source: Semantic Scholar Graph API (deterministic, no model memory).
references GET /graph/v1/paper/{id}/references
citations GET /graph/v1/paper/{id}/citations
recommendations GET /recommendations/v1/papers/forpaper/{id}
Anti-hallucination contract
---------------------------
Snowball candidates carry `verified=false` + `verified_by=semantic_scholar`.
They are NOT cross-checked against PubMed/CrossRef here; downstream
`/verify-refs` confirms each entry and upgrades the flag. Nothing is ever
generated from memory.
Output contract (matches search-lit BibTeX section)
---------------------------------------------------
- BibTeX is APPENDED to the candidate pool (default references/library.bib).
- NEVER writes to manuscript/_src/refs.bib (that is /lit-sync's sole path).
- A PRISMA "records identified through citation searching" line is printed.
Usage
-----
# Live (network) — expand one DOI in all directions, dedup against pool
python3 snowball.py --seed DOI:10.1148/radiol.2024123 \
--pool references/library.bib --out references/library.bib
# Multiple seeds from a file (one id per line), backward only
python3 snowball.py --seed @seeds.txt --direction backward
# Deterministic / offline (challenge-card verifier): read recorded JSON
python3 snowball.py --seed DOI:10.0/seed1 --direction all \
--offline-fixture fixture --as-of 2026-06-14 --stdout
Seed id formats accepted: `DOI:10.x/...`, `PMID:123456`, bare `10.x/...`
(treated as DOI), bare digits (treated as PMID), or a raw S2 paper id.
"""
import argparse
import json
import re
import sys
import urllib.parse
import urllib.request
from datetime import date
from pathlib import Path
S2_GRAPH = "https://api.semanticscholar.org/graph/v1/paper"
S2_REC = "https://api.semanticscholar.org/recommendations/v1/papers/forpaper"
S2_FIELDS = "title,year,venue,externalIds,authors.name"
DIRECTIONS = ("backward", "forward", "similar")
# --------------------------------------------------------------------------- #
# Seed id normalization
# --------------------------------------------------------------------------- #
def normalize_seed_id(raw: str) -> str:
"""Return an S2-acceptable paper id token (DOI:/PMID:/raw)."""
s = raw.strip()
if not s:
return ""
up = s.upper()
if up.startswith("DOI:") or up.startswith("PMID:") or up.startswith("ARXIV:"):
return s
if s.startswith("10.") or "doi.org/" in s.lower():
return "DOI:" + s.split("doi.org/")[-1]
if s.isdigit():
return "PMID:" + s
return s # assume raw S2 id
def fixture_slug(seed_id: str) -> str:
"""Filesystem-safe slug for an offline fixture filename."""
return re.sub(r"[^A-Za-z0-9]+", "_", seed_id).strip("_")
# --------------------------------------------------------------------------- #
# Fetch (network or offline fixture)
# --------------------------------------------------------------------------- #
def _http_get_json(url: str) -> dict:
req = urllib.request.Request(url, headers={"User-Agent": "medsci-skills/snowball"})
with urllib.request.urlopen(req, timeout=30) as resp: # noqa: S310 (trusted host)
return json.loads(resp.read().decode("utf-8"))
def fetch_direction(seed_id: str, direction: str, limit: int,
fixture_dir: Path | None) -> list[dict]:
"""Return a list of paper dicts for one seed+direction.
Each returned dict has at least: title, year, venue, externalIds, authors.
"""
if fixture_dir is not None:
fpath = fixture_dir / f"{fixture_slug(seed_id)}.{direction}.json"
if not fpath.exists():
return []
payload = json.loads(fpath.read_text())
else:
enc = urllib.parse.quote(seed_id, safe="")
if direction == "backward":
url = f"{S2_GRAPH}/{enc}/references?fields={S2_FIELDS}&limit={limit}"
elif direction == "forward":
url = f"{S2_GRAPH}/{enc}/citations?fields={S2_FIELDS}&limit={limit}"
elif direction == "similar":
url = f"{S2_REC}/{enc}?fields={S2_FIELDS}&limit={limit}"
else:
raise ValueError(f"unknown direction: {direction}")
try:
payload = _http_get_json(url)
except Exception as exc: # noqa: BLE001 — network failure is non-fatal
sys.stderr.write(f"[snowball] {direction} fetch failed for {seed_id}: {exc}\n")
return []
# Normalize payload shapes:
# references/citations: {"data": [{"citedPaper"|"citingPaper": {...}}]}
# recommendations: {"recommendedPapers": [{...}]} or {"data": [{...}]}
rows = payload.get("data") or payload.get("recommendedPapers") or []
papers = []
for row in rows:
paper = row.get("citedPaper") or row.get("citingPaper") or row
if isinstance(paper, dict) and paper.get("title"):
papers.append(paper)
return papers
# --------------------------------------------------------------------------- #
# Dedup
# --------------------------------------------------------------------------- #
def norm_doi(doi: str | None) -> str:
if not doi:
return ""
return doi.strip().lower().replace("https://doi.org/", "")
def norm_title(title: str | None) -> str:
if not title:
return ""
return re.sub(r"[^a-z0-9]+", "", title.lower())
def parse_pool_keys(pool_path: Path | None) -> tuple[set[str], set[str]]:
"""Extract existing DOIs and normalized titles from a BibTeX pool."""
dois: set[str] = set()
titles: set[str] = set()
if pool_path is None or not pool_path.exists():
return dois, titles
text = pool_path.read_text(errors="ignore")
for m in re.finditer(r"doi\s*=\s*[{\"]([^}\"]+)[}\"]", text, re.I):
dois.add(norm_doi(m.group(1)))
for m in re.finditer(r"title\s*=\s*[{\"](.+?)[}\"]\s*,?\s*\n", text, re.I | re.S):
titles.add(norm_title(m.group(1)))
return dois, titles
# --------------------------------------------------------------------------- #
# BibTeX emit
# --------------------------------------------------------------------------- #
def _author_last(name: str) -> str:
parts = name.strip().split()
return parts[-1] if parts else "Anon"
def bibtex_key(paper: dict) -> str:
authors = paper.get("authors") or []
last = _author_last(authors[0]["name"]) if authors and authors[0].get("name") else "Anon"
last = re.sub(r"[^A-Za-z]", "", last) or "Anon"
year = str(paper.get("year") or "ND")
title_word = ""
for w in re.findall(r"[A-Za-z]{4,}", paper.get("title") or ""):
if w.lower() not in {"with", "from", "using", "study", "analysis", "based"}:
title_word = w.capitalize()
break
return f"{last}_{year}_{title_word or 'Snowball'}"
def to_bibtex(paper: dict, direction: str, as_of: str, key: str) -> str:
ext = paper.get("externalIds") or {}
doi = ext.get("DOI", "")
pmid = ext.get("PubMed", "")
authors = paper.get("authors") or []
author_str = " and ".join(
f"{_author_last(a['name'])}, {' '.join(a['name'].split()[:-1])}".strip().rstrip(",")
for a in authors if a.get("name")
) or "Unknown"
lines = [
f"@article{{{key},",
f" author = {{{author_str}}},",
f" title = {{{paper.get('title', '').strip()}}},",
f" journal = {{{paper.get('venue', '') or ''}}},",
f" year = {{{paper.get('year') or ''}}},",
]
if doi:
lines.append(f" doi = {{{doi}}},")
if pmid:
lines.append(f" pmid = {{{pmid}}},")
lines += [
" verified = {false},",
" verified_by = {semantic_scholar},",
f" verified_on = {{{as_of}}},",
" source = {citation_snowball},",
f" snowball_direction = {{{direction}}},",
"}",
]
return "\n".join(lines)
# --------------------------------------------------------------------------- #
# Main
# --------------------------------------------------------------------------- #
def load_seeds(raw: str) -> list[str]:
if raw.startswith("@"):
lines = Path(raw[1:]).read_text().splitlines()
items = [ln for ln in lines if ln.strip() and not ln.strip().startswith("#")]
else:
items = re.split(r"[,\s]+", raw)
return [normalize_seed_id(x) for x in items if x.strip()]
def main(argv: list[str] | None = None) -> int:
ap = argparse.ArgumentParser(description="Citation snowballing for search-lit.")
ap.add_argument("--seed", required=True,
help="comma/space-separated ids, or @file with one id per line")
ap.add_argument("--direction", default="all",
choices=("all", *DIRECTIONS))
ap.add_argument("--pool", default=None,
help="existing library.bib to dedup against")
ap.add_argument("--out", default="references/library.bib",
help="BibTeX append target (NEVER manuscript/_src/refs.bib)")
ap.add_argument("--limit", type=int, default=50, help="max per seed per direction")
ap.add_argument("--offline-fixture", default=None,
help="dir of recorded JSON (<slug>.<direction>.json) for deterministic runs")
ap.add_argument("--as-of", default=date.today().isoformat(),
help="verified_on date stamp (default today; set for reproducible output)")
ap.add_argument("--stdout", action="store_true",
help="print BibTeX to stdout instead of appending to --out")
args = ap.parse_args(argv)
out_path = Path(args.out)
if out_path.name == "refs.bib" and "_src" in str(out_path):
ap.error("refusing to write manuscript/_src/refs.bib — that is /lit-sync's sole path")
fixture_dir = Path(args.offline_fixture) if args.offline_fixture else None
pool_dois, pool_titles = parse_pool_keys(Path(args.pool) if args.pool else None)
seeds = load_seeds(args.seed)
directions = list(DIRECTIONS) if args.direction == "all" else [args.direction]
seen_doi = set(pool_dois)
seen_title = set(pool_titles)
seen_key: set[str] = set()
counts = {d: 0 for d in directions}
entries: list[str] = []
raw_found = 0
for seed in seeds:
for direction in directions:
papers = fetch_direction(seed, direction, args.limit, fixture_dir)
for paper in papers:
raw_found += 1
ext = paper.get("externalIds") or {}
d = norm_doi(ext.get("DOI"))
t = norm_title(paper.get("title"))
if (d and d in seen_doi) or (t and t in seen_title):
continue
if d:
seen_doi.add(d)
if t:
seen_title.add(t)
key = bibtex_key(paper)
base_key, n = key, 1
while key in seen_key:
n += 1
key = f"{base_key}{chr(96 + n)}" # _b, _c, ...
seen_key.add(key)
entries.append(to_bibtex(paper, direction, args.as_of, key))
counts[direction] += 1
new_total = len(entries)
bibtex_blob = ("\n\n".join(entries) + "\n") if entries else ""
if args.stdout or not entries:
sys.stdout.write(bibtex_blob)
else:
out_path.parent.mkdir(parents=True, exist_ok=True)
with out_path.open("a", encoding="utf-8") as fh:
if out_path.exists() and out_path.stat().st_size > 0:
fh.write("\n")
fh.write(bibtex_blob)
# PRISMA citation-searching line (stderr so --stdout BibTeX stays clean)
breakdown = ", ".join(f"{d}={counts[d]}" for d in directions)
pool_n = len(pool_dois) + len(pool_titles)
sys.stderr.write(
f"Records identified through citation searching (snowballing): "
f"{raw_found} raw ({breakdown}); after dedup against existing pool: "
f"{new_total} new candidates.\n"
)
sys.stderr.write(
f"[snowball] seeds={len(seeds)} directions={'+'.join(directions)} "
f"pool_keys={pool_n} -> {new_total} appended"
f"{' (stdout)' if args.stdout else f' to {out_path}'}\n"
)
return 0
if __name__ == "__main__":
raise SystemExit(main())
schema_version: 2
name: search-lit
layer: A
owner_domain: literature_discovery
maturity: official
when_to_use:
- User asks to find papers, related work, citations, or background literature on a topic
- Generating a verified candidate citation pool (PubMed / Semantic Scholar / bioRxiv / medRxiv)
- Pre-/lit-sync candidate sourcing (search-lit produces candidates → /lit-sync syncs to Zotero + refs.bib)
- Building a BibTeX library for a topic without yet committing to inclusion
when_NOT_to_use:
- Verifying citations already in a manuscript (use /verify-refs)
- Syncing to Zotero or writing manuscript/_src/refs.bib (use /lit-sync — sole writer)
- Generating references from model memory (forbidden — every entry must be API-verified)
inputs:
- literature_query
outputs:
- references/library.bib # search-result candidate pool; NOT the manuscript SSOT bib
- references/search_results.tsv
deterministic_scripts:
- references/pubmed_eutils.sh
- references/parse_pubmed.py
- references/snowball.py # Phase 2.5 citation snowballing (S2 Graph API); challenge card in references/snowball_challenge/
side_effects:
- may_call_external_literature_apis
downstream_consumers:
- verify-refs
- lit-sync # confirmed candidates flow through Zotero, then lit-sync refreshes manuscript/_src/refs.bib
- write-paper
ssot_boundary:
- manuscript/_src/refs.bib is OWNED by /lit-sync (Better BibTeX auto-export). search-lit MUST NOT write to that path.
forbidden_actions:
- generate_references_from_memory
- silently_include_unverified_references
- write_to_manuscript_refs_bib # SSOT owner is /lit-sync
# v2.1 quality card
purpose: "Search PubMed, Semantic Scholar, and bioRxiv/medRxiv and generate API-verified BibTeX (anti-hallucination: every reference verified before inclusion)."
safety_boundaries:
- "Never generates references from memory; unverified references are not silently included."
- "Does not write to the manuscript refs.bib (that SSOT belongs to lit-sync)."
known_limitations:
- "Depends on PubMed/Semantic Scholar availability; rate limits/outages reduce recall."
- "Verification confirms existence/metadata, not topical relevance."
validation_commands:
- "bash references/pubmed_eutils.sh <query>"
- "bash references/snowball_challenge/verify.sh # deterministic, network-free"
- "/verify-refs --strict"
evidence_surface: bundled_script
Related skills
FAQ
Which databases does it search?
PubMed, Semantic Scholar, and bioRxiv/medRxiv, with CrossRef used for DOI verification, deduplicated across databases by DOI or title.
What if the PubMed MCP is down?
It falls back to NCBI E-utilities via bundled pubmed_eutils.sh and parse_pubmed.py scripts, rate-limited to 3 requests/second.