Weekly literature reading list (Jev), tests for the Jev tools, maintenance tools documented
This commit is contained in:
1 parent
01e60ba817
commit
f4bd9de93b
4 files changed
+208
No files matched your search
@@ -2,6 +2,11 @@
|
|||||||
|
|
||||||
## Unreleased
|
## Unreleased
|
||||||
|
|
||||||
|
**Maintenance tools (TypeSafe Jev).** Spec coverage audit (152 of 354 testable spec items cited by tests, 23 more
|
||||||
|
judged covered, 150 without a test), brand voice check, automatic triage of failed nightly runs and of new Gitea
|
||||||
|
issues (labels, missing information, privacy and clinical use flags), and a weekly literature reading list. See
|
||||||
|
`CONTRIBUTING.md`.
|
||||||
|
|
||||||
**Documentation checks.** `scripts/docs/check_claims.py` checks the numeric claims of public texts against the
|
**Documentation checks.** `scripts/docs/check_claims.py` checks the numeric claims of public texts against the
|
||||||
validation reports (code retrieves evidence, a TypeSafe System One model judges support). Its first run corrected the
|
validation reports (code retrieves evidence, a TypeSafe System One model judges support). Its first run corrected the
|
||||||
manifest loading times (Omni2.5 about 18 s on a Pi, now measured in `docs/validation/P9.md`) and a demo timing
|
manifest loading times (Omni2.5 about 18 s on a Pi, now measured in `docs/validation/P9.md`) and a demo timing
|
||||||
|
|||||||
@@ -67,6 +67,23 @@ is sent to the API, never data. The key comes from `TYPESAFE_API_KEY` or `~/.con
|
|||||||
enters the repository; a full check costs well under one cent. `--dry-run` shows the claims and evidence without
|
enters the repository; a full check costs well under one cent. `--dry-run` shows the claims and evidence without
|
||||||
calling the API.
|
calling the API.
|
||||||
|
|
||||||
|
## Maintenance tools (TypeSafe Jev)
|
||||||
|
|
||||||
|
Small tools in `scripts/jev/` use TypeSafe System One judgments (Jev) where plain code needs semantic understanding.
|
||||||
|
Code always collects the evidence and decides what to do; the model only answers typed questions. They are never
|
||||||
|
part of the `simurg` commands, which stay offline. The key comes from `TYPESAFE_API_KEY` or
|
||||||
|
`~/.config/typesafe/credentials` (a repository secret of the same name for the issue workflow).
|
||||||
|
|
||||||
|
* `scripts/docs/check_claims.py FILES`: numeric claims of public texts against the validation reports.
|
||||||
|
* `scripts/jev/spec_coverage.py`: which spec items have a test; writes `spec_coverage_report.md`.
|
||||||
|
* `scripts/jev/voice_check.py FILES`: brand voice rules (dashes, banned words, claims without limits, hype, tone).
|
||||||
|
* `scripts/jev/nightly_triage.py NIGHT`: which known finding explains a failed nightly step; `scripts/nightly.py`
|
||||||
|
runs it automatically when a night fails.
|
||||||
|
* `scripts/jev/issue_triage.py`: first triage of new issues (`.gitea/workflows/issue_triage.yml`).
|
||||||
|
* `scripts/jev/literature_watch.py`: weekly reading list from bioRxiv and PubMed into `data/literature/`.
|
||||||
|
|
||||||
|
Each run costs a fraction of a cent; answers are cached in `.cache/jev/`.
|
||||||
|
|
||||||
## Pull requests
|
## Pull requests
|
||||||
|
|
||||||
* One topic per pull request, with tests.
|
* One topic per pull request, with tests.
|
||||||
|
|||||||
@@ -0,0 +1,160 @@
|
|||||||
|
"""Weekly reading list: new bioRxiv preprints and PubMed papers ranked by relevance to SimurgArray.
|
||||||
|
|
||||||
|
Code fetches the last N days from the bioRxiv API (bioinformatics, genomics, genetics, pharmacology) and PubMed
|
||||||
|
E-utilities, and keeps the abstracts that mention array, copy number, pharmacogenomics or methylation array terms.
|
||||||
|
For each kept paper Jev scores its relevance on four concrete levels (Score) and says whether it releases open code
|
||||||
|
or data (Noul). Only public titles and abstracts are sent. Output: ``data/literature/<date>.md`` (not committed).
|
||||||
|
|
||||||
|
python scripts/jev/literature_watch.py # last 7 days
|
||||||
|
python scripts/jev/literature_watch.py --days 14 --min-score 1.5
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import datetime as dt
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
|
import xml.etree.ElementTree as ET
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||||
|
from client import ROOT, noul, score, system_one
|
||||||
|
|
||||||
|
OUT = ROOT / "data" / "literature"
|
||||||
|
BIORXIV_CATEGORIES = {"bioinformatics", "genomics", "genetics", "pharmacology and toxicology"}
|
||||||
|
PUBMED_TERM = (
|
||||||
|
'("genotyping array" OR "SNP array" OR microarray OR BeadChip OR Infinium OR "copy number variation" OR '
|
||||||
|
'"mosaic chromosomal" OR pharmacogenomic* OR "star allele" OR CYP2D6 OR "methylation array" OR "EPIC array") '
|
||||||
|
"AND (genotyp* OR calling OR pipeline OR software OR algorithm OR benchmark)"
|
||||||
|
)
|
||||||
|
KEYWORDS = re.compile(
|
||||||
|
r"\b(genotyping arrays?|snp arrays?|microarray|beadchip|infinium|idat|genotype calling|cluster(ing)? file|"
|
||||||
|
r"copy number|cnv|mosaic|loss of heterozygosity|loh|pharmacogen\w*|star allele|cyp2d6|methylation arrays?|"
|
||||||
|
r"epic array|biobank genotyp\w*|imputation|baf|log r ratio)\b",
|
||||||
|
re.I,
|
||||||
|
)
|
||||||
|
LEVELS = [
|
||||||
|
"Unrelated to genotyping arrays, copy number, pharmacogenomics or methylation arrays",
|
||||||
|
"Related field, but not about the methods or data SimurgArray handles; for example an association study that only "
|
||||||
|
"used an array",
|
||||||
|
"Processes or evaluates array data the way SimurgArray does: genotype calling, QC, CNV or LOH calling, star allele "
|
||||||
|
"calling, cluster training or methylation QC",
|
||||||
|
"Proposes or benchmarks a method, tool, dataset or reference resource that SimurgArray could adopt or validate "
|
||||||
|
"against",
|
||||||
|
]
|
||||||
|
QUESTIONS = {
|
||||||
|
"relevance": score(
|
||||||
|
"How relevant is `paper` to SimurgArray, an open toolkit for genotyping array analysis?", LEVELS
|
||||||
|
),
|
||||||
|
"open": noul("Does `paper` release open source code or a public dataset that others can download?"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Paper:
|
||||||
|
source: str
|
||||||
|
title: str
|
||||||
|
abstract: str
|
||||||
|
url: str
|
||||||
|
date: str
|
||||||
|
|
||||||
|
|
||||||
|
def _get(url: str) -> bytes:
|
||||||
|
req = urllib.request.Request(url, headers={"User-Agent": "simurgarray-literature/1 ([email protected])"})
|
||||||
|
for attempt in range(4):
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=60) as r:
|
||||||
|
return r.read()
|
||||||
|
except OSError:
|
||||||
|
if attempt == 3:
|
||||||
|
raise
|
||||||
|
time.sleep(2**attempt)
|
||||||
|
raise RuntimeError("unreachable")
|
||||||
|
|
||||||
|
|
||||||
|
def biorxiv(start: dt.date, end: dt.date) -> list[Paper]:
|
||||||
|
out, cursor = [], 0
|
||||||
|
while True:
|
||||||
|
data = json.loads(_get(f"https://api.biorxiv.org/details/biorxiv/{start}/{end}/{cursor}"))
|
||||||
|
coll = data.get("collection", [])
|
||||||
|
for p in coll:
|
||||||
|
if p.get("category", "").lower() in BIORXIV_CATEGORIES:
|
||||||
|
out.append(Paper("bioRxiv", p["title"], p.get("abstract", ""), f"https://doi.org/{p['doi']}",
|
||||||
|
p.get("date", ""))) # fmt: skip
|
||||||
|
total = int(data.get("messages", [{}])[0].get("total", 0) or 0)
|
||||||
|
cursor += len(coll)
|
||||||
|
if not coll or cursor >= total:
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def pubmed(days: int) -> list[Paper]:
|
||||||
|
base = "https://eutils.ncbi.nlm.nih.gov/entrez/eutils"
|
||||||
|
q = urllib.parse.urlencode({"db": "pubmed", "term": PUBMED_TERM, "reldate": days, "datetype": "edat",
|
||||||
|
"retmax": 300, "retmode": "json"}) # fmt: skip
|
||||||
|
ids = json.loads(_get(f"{base}/esearch.fcgi?{q}"))["esearchresult"]["idlist"]
|
||||||
|
out: list[Paper] = []
|
||||||
|
for i in range(0, len(ids), 100):
|
||||||
|
xml = _get(f"{base}/efetch.fcgi?db=pubmed&retmode=xml&id={','.join(ids[i : i + 100])}")
|
||||||
|
for art in ET.fromstring(xml).iter("PubmedArticle"):
|
||||||
|
pmid = art.findtext(".//PMID") or ""
|
||||||
|
title = "".join(art.find(".//ArticleTitle").itertext()) if art.find(".//ArticleTitle") is not None else ""
|
||||||
|
abstract = " ".join("".join(t.itertext()) for t in art.iter("AbstractText"))
|
||||||
|
year = art.findtext(".//PubDate/Year") or ""
|
||||||
|
out.append(Paper("PubMed", title, abstract, f"https://pubmed.ncbi.nlm.nih.gov/{pmid}/", year))
|
||||||
|
time.sleep(0.4) # NCBI asks for at most three requests a second without a key
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def prefilter(papers: list[Paper]) -> list[Paper]:
|
||||||
|
seen, keep = set(), []
|
||||||
|
for p in papers:
|
||||||
|
key = re.sub(r"\W+", "", p.title.lower())[:80]
|
||||||
|
if key in seen or not p.abstract:
|
||||||
|
continue
|
||||||
|
seen.add(key)
|
||||||
|
if KEYWORDS.search(p.title + " " + p.abstract):
|
||||||
|
keep.append(p)
|
||||||
|
return keep
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str] | None = None) -> int:
|
||||||
|
ap = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
|
||||||
|
ap.add_argument("--days", type=int, default=7)
|
||||||
|
ap.add_argument("--min-score", type=float, default=1.8)
|
||||||
|
ap.add_argument("--dry-run", action="store_true", help="fetch and prefilter only, no API calls")
|
||||||
|
a = ap.parse_args(argv)
|
||||||
|
end = dt.date.today()
|
||||||
|
start = end - dt.timedelta(days=a.days)
|
||||||
|
papers = prefilter(biorxiv(start, end) + pubmed(a.days))
|
||||||
|
print(f"{len(papers)} candidate papers after the keyword filter")
|
||||||
|
if a.dry_run:
|
||||||
|
for p in papers[:20]:
|
||||||
|
print(f" {p.source}: {p.title[:110]}")
|
||||||
|
return 0
|
||||||
|
rows = []
|
||||||
|
for p in papers:
|
||||||
|
ans = system_one({"paper": {"title": p.title, "abstract": p.abstract[:4000]}}, QUESTIONS)
|
||||||
|
rows.append((float(ans["relevance"]["score"]), float(ans["open"]["noul"]), p))
|
||||||
|
rows.sort(key=lambda r: -r[0])
|
||||||
|
picked = [r for r in rows if r[0] >= a.min_score]
|
||||||
|
OUT.mkdir(parents=True, exist_ok=True)
|
||||||
|
path = OUT / f"{end}.md"
|
||||||
|
summary = f"{len(papers)} papers matched the keywords; {len(picked)} scored at least {a.min_score} of 3."
|
||||||
|
lines = [f"# Reading list {start} to {end}", "", summary, ""]
|
||||||
|
for s, o, p in picked:
|
||||||
|
tag = " · open code or data" if o >= 0.6 else ""
|
||||||
|
lines += [f"* **{s:.1f}** [{p.title}]({p.url}) ({p.source}, {p.date}){tag}",
|
||||||
|
f" > {p.abstract[:500]}"] # fmt: skip
|
||||||
|
path.write_text("\n".join(lines) + "\n")
|
||||||
|
print(f"{len(picked)} papers worth reading → {path}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -76,3 +76,29 @@ def test_known_findings_and_evidence(tmp_path: Path) -> None:
|
|||||||
assert ev["first_rows"][1] == "1\tGT" and "log" not in ev # another step's log line must not leak in
|
assert ev["first_rows"][1] == "1\tGT" and "log" not in ev # another step's log line must not leak in
|
||||||
ev2 = tri.evidence(tmp_path, "t", {"verdict": "ERROR", "error": "exit 137"})
|
ev2 = tri.evidence(tmp_path, "t", {"verdict": "ERROR", "error": "exit 137"})
|
||||||
assert ev2["log"] == ["pi_run: MemAvailable 500 MiB < 800 MiB -- stopping the job"]
|
assert ev2["log"] == ["pi_run: MemAvailable 500 MiB < 800 MiB -- stopping the job"]
|
||||||
|
|
||||||
|
|
||||||
|
issue = _load("issue_triage")
|
||||||
|
lit = _load("literature_watch")
|
||||||
|
|
||||||
|
|
||||||
|
def test_issue_missing_info_and_comment() -> None:
|
||||||
|
body = "### SimurgArray version\n\n_No response_\n\n### Command\n\n```shell\nsimurg genotype\n```\n"
|
||||||
|
assert issue.missing_info(body, "bug") == ["the SimurgArray version"]
|
||||||
|
assert issue.missing_info(body.replace("_No response_", "2.0.0"), "bug") == []
|
||||||
|
assert issue.missing_info("no template at all", "feature") == []
|
||||||
|
d = {"kind": "security", "labels": ["kind/security"], "privacy": ["credentials"], "clinical": True,
|
||||||
|
"missing": []} # fmt: skip
|
||||||
|
text = issue.comment_text(d)
|
||||||
|
assert "[email protected]" in text and "credentials" in text and "research tool" in text
|
||||||
|
|
||||||
|
|
||||||
|
def test_literature_prefilter() -> None:
|
||||||
|
p = lit.Paper
|
||||||
|
papers = [
|
||||||
|
p("bioRxiv", "Fast genotype calling for Infinium arrays", "We call genotypes from IDAT files.", "u1", "d"),
|
||||||
|
p("PubMed", "Fast genotype calling for Infinium arrays!", "Duplicate title.", "u2", "d"),
|
||||||
|
p("bioRxiv", "Protein folding at scale", "Nothing about arrays here.", "u3", "d"),
|
||||||
|
p("bioRxiv", "No abstract", "", "u4", "d"),
|
||||||
|
]
|
||||||
|
assert [x.url for x in lit.prefilter(papers)] == ["u1"]
|
||||||
Reference in new issue
Block a user