Skip to content

Commit ccc4397

Browse files
realmarcinclaude
andauthored
Follow-ups #181 + #184: enforce taxa term-gate in qc/CI; scout DOI backfill (#186)
#181: add validate-taxa + validate-terms-taxa to the `qc` target, and a validate-terms-taxa step to the label-correspondence CI workflow, so the GeneAnnotation.go_terms id↔label binding on kb/taxa/ CommonTaxon records is actually enforced (both taxa records pass). #184: scout-communities/scout_loop backfill a DOI via CrossRef for ref-less hits (e.g. Europe PMC AGR-source records with no PMID/DOI) before dedup, so an already-curated paper no longer re-surfaces as NEW. Requires >=70% title-token overlap with the CrossRef match to avoid grabbing an unrelated DOI. Verified: the cigar-tobacco lead (already CommunityMech:000278) now dedups instead of re-appearing. Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 1dbd65a commit ccc4397

4 files changed

Lines changed: 219 additions & 52 deletions

File tree

.github/workflows/label-correspondence.yaml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,3 +86,10 @@ jobs:
8686
# MISSING_COLUMN). Curator-accepted residuals pass as OK_EXCEPTION.
8787
- name: Enforce id↔label correspondence
8888
run: just validate-products
89+
90+
# Engine A (LinkML-native) for the CommonTaxon gene records: enforce that
91+
# GeneAnnotation.go_terms[].label is the canonical GO label (the
92+
# go_terms id↔label binding). kb/taxa/ roots are CommonTaxon, not the
93+
# schema tree_root, so this uses --target-class CommonTaxon.
94+
- name: Enforce id↔label correspondence (kb/taxa go_terms)
95+
run: just validate-terms-taxa

justfile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -250,7 +250,7 @@ lint:
250250
uv run mypy src/
251251

252252
# Full QC (validate + strict validate + lint + test)
253-
qc: validate-all validate-strict validate-terms-all validate-references-all lint test
253+
qc: validate-all validate-taxa validate-strict validate-terms-all validate-terms-taxa validate-references-all lint test
254254
@echo "✅ All QC checks passed!"
255255

256256
# Check which community strains are represented in UniProt reference proteomes

scripts/scout_communities.py

Lines changed: 111 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -83,20 +83,41 @@
8383
}
8484

8585
STOPWORDS = {
86-
"the", "a", "an", "of", "and", "in", "on", "for", "with", "to", "from",
87-
"community", "communities", "consortium", "consortia", "coculture",
88-
"co", "culture", "microbial", "synthetic", "syncom", "defined", "system",
89-
"model", "based", "using", "study", "novel", "new",
86+
"the",
87+
"a",
88+
"an",
89+
"of",
90+
"and",
91+
"in",
92+
"on",
93+
"for",
94+
"with",
95+
"to",
96+
"from",
97+
"community",
98+
"communities",
99+
"consortium",
100+
"consortia",
101+
"coculture",
102+
"co",
103+
"culture",
104+
"microbial",
105+
"synthetic",
106+
"syncom",
107+
"defined",
108+
"system",
109+
"model",
110+
"based",
111+
"using",
112+
"study",
113+
"novel",
114+
"new",
90115
}
91116

92117

93118
def _tokens(text: str) -> set[str]:
94119
"""Lowercase alphanumeric tokens minus stopwords, length >= 4."""
95-
return {
96-
t
97-
for t in re.findall(r"[a-z0-9]+", text.lower())
98-
if len(t) >= 4 and t not in STOPWORDS
99-
}
120+
return {t for t in re.findall(r"[a-z0-9]+", text.lower()) if len(t) >= 4 and t not in STOPWORDS}
100121

101122

102123
def build_dedup_index(communities_dir: Path) -> dict:
@@ -190,7 +211,9 @@ def query_epmc(query: str, since: int, limit: int) -> list[dict]:
190211
{
191212
"pmid": r.get("pmid", ""),
192213
"doi": r.get("doi", ""),
193-
"title": re.sub(r"<[^>]+>", "", html.unescape(r.get("title") or "")).rstrip("."),
214+
"title": re.sub(r"<[^>]+>", "", html.unescape(r.get("title") or "")).rstrip(
215+
"."
216+
),
194217
"abstract": re.sub(r"<[^>]+>", "", html.unescape(r.get("abstractText") or "")),
195218
"year": r.get("pubYear", ""),
196219
"journal": (r.get("journalInfo", {}) or {}).get("journal", {}).get("title", ""),
@@ -205,6 +228,49 @@ def query_epmc(query: str, since: int, limit: int) -> list[dict]:
205228
return hits[:limit]
206229

207230

231+
def crossref_doi_for_title(title: str, session: requests.Session) -> str:
232+
"""Best-effort DOI for a title via CrossRef, so ref-less hits can dedup by DOI.
233+
234+
Europe PMC AGR-source records often carry no PMID/DOI; without one a hit for an
235+
already-curated paper re-surfaces as NEW. Resolving the DOI lets dedup_status
236+
catch it against cited_dois. Requires a high title-token overlap to avoid
237+
grabbing an unrelated DOI.
238+
"""
239+
if not title:
240+
return ""
241+
try:
242+
resp = session.get(
243+
"https://api.crossref.org/works",
244+
params={"query.bibliographic": title, "rows": "1"},
245+
timeout=30,
246+
)
247+
resp.raise_for_status()
248+
items = resp.json().get("message", {}).get("items", [])
249+
except (requests.exceptions.RequestException, ValueError):
250+
return ""
251+
if not items:
252+
return ""
253+
it = items[0]
254+
cand_title = (it.get("title") or [""])[0]
255+
# Require the CrossRef hit's title to substantially match the query title.
256+
q, c = _tokens(title), _tokens(cand_title)
257+
if q and len(q & c) / len(q) >= 0.7:
258+
return (it.get("DOI") or "").strip()
259+
return ""
260+
261+
262+
def backfill_missing_dois(hits: list[dict], session: requests.Session) -> int:
263+
"""Fill hit['doi'] from CrossRef for hits lacking both PMID and DOI. Returns count filled."""
264+
filled = 0
265+
for h in hits:
266+
if not (h.get("pmid") or "").strip() and not (h.get("doi") or "").strip():
267+
doi = crossref_doi_for_title(h.get("title", ""), session)
268+
if doi:
269+
h["doi"] = doi
270+
filled += 1
271+
return filled
272+
273+
208274
def slugify(text: str) -> str:
209275
return re.sub(r"[^a-z0-9]+", "-", text.lower()).strip("-")[:40] or "scout"
210276

@@ -213,7 +279,11 @@ def emit_stub(hit: dict, out_dir: Path) -> Path:
213279
"""Write a minimal review-only draft record (placeholder id, NOT minted)."""
214280
stub_dir = out_dir / "stubs"
215281
stub_dir.mkdir(parents=True, exist_ok=True)
216-
ref = f"PMID:{hit['pmid']}" if hit.get("pmid") else (f"doi:{hit['doi']}" if hit.get("doi") else "")
282+
ref = (
283+
f"PMID:{hit['pmid']}"
284+
if hit.get("pmid")
285+
else (f"doi:{hit['doi']}" if hit.get("doi") else "")
286+
)
217287
name = hit["title"][:120]
218288
stub = {
219289
"id": "CommunityMech:XXXXXX", # placeholder — mint via manage-identifiers
@@ -235,14 +305,24 @@ def emit_stub(hit: dict, out_dir: Path) -> Path:
235305

236306

237307
def main(argv: list[str] | None = None) -> int:
238-
p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
308+
p = argparse.ArgumentParser(
309+
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
310+
)
239311
grp = p.add_mutually_exclusive_group(required=True)
240312
grp.add_argument("--query", help="Free-text Europe PMC query.")
241313
grp.add_argument("--preset", choices=sorted(PRESETS), help="Ready-made query angle.")
242-
p.add_argument("--since", type=int, default=2024, help="Earliest first-publication year (default 2024).")
243-
p.add_argument("--limit", type=int, default=40, help="Max Europe PMC hits to fetch (default 40).")
244-
p.add_argument("--min-score", type=int, default=1, help="Drop hits below this community-signal score.")
245-
p.add_argument("--include-cited", action="store_true", help="Keep hits already cited by a record.")
314+
p.add_argument(
315+
"--since", type=int, default=2024, help="Earliest first-publication year (default 2024)."
316+
)
317+
p.add_argument(
318+
"--limit", type=int, default=40, help="Max Europe PMC hits to fetch (default 40)."
319+
)
320+
p.add_argument(
321+
"--min-score", type=int, default=1, help="Drop hits below this community-signal score."
322+
)
323+
p.add_argument(
324+
"--include-cited", action="store_true", help="Keep hits already cited by a record."
325+
)
246326
p.add_argument("--emit-stubs", action="store_true", help="Write review-only draft stub YAMLs.")
247327
p.add_argument("--out-dir", type=Path, default=DEFAULT_OUT_DIR)
248328
args = p.parse_args(argv)
@@ -260,6 +340,12 @@ def main(argv: list[str] | None = None) -> int:
260340
return 1
261341
print(f"[scout] fetched {len(hits)} hits", file=sys.stderr)
262342

343+
# Ref-less hits (e.g. Europe PMC AGR source) can't dedup by PMID/DOI; try to
344+
# resolve a DOI via CrossRef so already-curated papers don't re-surface as NEW.
345+
n_filled = backfill_missing_dois(hits, requests.Session())
346+
if n_filled:
347+
print(f"[scout] backfilled DOIs for {n_filled} ref-less hits (CrossRef)", file=sys.stderr)
348+
263349
index = build_dedup_index(COMMUNITIES_DIR)
264350
print(
265351
f"[scout] dedup index: {len(index['cited_pmids'])} PMIDs, "
@@ -306,14 +392,20 @@ def main(argv: list[str] | None = None) -> int:
306392
(f"_{h['dedup_detail']}_ " if h["dedup_detail"] else ""),
307393
f"Signals: {', '.join(h['signals']) or '—'} ",
308394
"",
309-
(h["abstract"][:600] + ("…" if len(h["abstract"]) > 600 else "")) if h["abstract"] else "_(no abstract)_",
395+
(
396+
(h["abstract"][:600] + ("…" if len(h["abstract"]) > 600 else ""))
397+
if h["abstract"]
398+
else "_(no abstract)_"
399+
),
310400
"",
311401
]
312402
report_path.write_text("\n".join(lines))
313403

314404
queue = [
315405
{
316-
"reference": f"PMID:{h['pmid']}" if h["pmid"] else (f"doi:{h['doi']}" if h["doi"] else ""),
406+
"reference": (
407+
f"PMID:{h['pmid']}" if h["pmid"] else (f"doi:{h['doi']}" if h["doi"] else "")
408+
),
317409
"title": h["title"],
318410
"year": h["year"],
319411
"journal": h["journal"],
@@ -334,8 +426,7 @@ def main(argv: list[str] | None = None) -> int:
334426

335427
n_new = sum(1 for h in candidates if h["dedup"] == "NEW")
336428
print(
337-
f"[scout] {len(candidates)} candidates ({n_new} NEW). "
338-
f"Report: {report_path}{stub_note}",
429+
f"[scout] {len(candidates)} candidates ({n_new} NEW). " f"Report: {report_path}{stub_note}",
339430
file=sys.stderr,
340431
)
341432
return 0

0 commit comments

Comments
 (0)