Skip to content

Commit d94fcf6

Browse files
realmarcinclaude
andauthored
add-growth-conditions: batch sweep runner + skill batch section (#202)
Extends the add-growth-conditions skill (v1.0.0 -> v1.1.0) with a deterministic sweep-prep runner so a whole-KB growth-conditions pass starts from ready source bundles instead of each extraction agent re-fetching. - scripts/growth_conditions_sweep.py: enumerate records lacking growth_media AND cultivation_setup; resolve each record's primary (most-cited) reference; fetch the Europe PMC abstract; run the legal full-text access ladder (reuses scripts/fulltext_access.py). Writes per-record staging bundles + an INDEX.md progress report bucketing OA_FULLTEXT / ABSTRACT_ONLY / NO_REFERENCE. Never edits kb/ — extraction + YAML writes stay the agent's job. - skill.md: rewrote the "whole KB (sweep)" section around the runner + INDEX report; flagged the most-cited-ref-may-be-a-review pitfall (bundle lists every cited ref by frequency so agents can pick the community's methods paper). - reports/growth_conditions_sweep/.gitignore: per-record bundles are regenerable; track only INDEX.md. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 6bb8ddd commit d94fcf6

3 files changed

Lines changed: 273 additions & 11 deletions

File tree

.claude/skills/add-growth-conditions/skill.md

Lines changed: 38 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ description: Deep-research and add source-backed growth/cultivation conditions (
44
category: research
55
requires_database: false
66
requires_internet: true
7-
version: 1.0.0
7+
version: 1.1.0
88
---
99

1010
# Add Growth Conditions to Community Records
@@ -87,19 +87,44 @@ growth_media:
8787
8888
## Running for the whole KB (sweep)
8989
90-
1. Enumerate records lacking `growth_media`:
90+
Use the **sweep runner** — `scripts/growth_conditions_sweep.py` — to do the
91+
deterministic prep (enumerate candidates, resolve each record's primary
92+
reference, fetch the abstract, run the access ladder) *once*, so extraction
93+
agents start from a ready source bundle instead of each re-fetching. The runner
94+
**never edits `kb/`**; it only writes staging files under
95+
`reports/growth_conditions_sweep/`, so a curator can review the prep first.
96+
97+
1. **List** candidates (records with neither `growth_media` nor
98+
`cultivation_setup`):
99+
```bash
100+
uv run python scripts/growth_conditions_sweep.py --list
101+
```
102+
2. **Prep** the source bundles (live network fetch; `--limit N` for a test batch,
103+
or pass explicit record paths):
91104
```bash
92-
for f in kb/communities/*.yaml; do
93-
grep -qE '^growth_media:|^cultivation_setup:' "$f" || echo "$f"
94-
done
105+
uv run python scripts/growth_conditions_sweep.py --prep # all missing
106+
uv run python scripts/growth_conditions_sweep.py --prep --limit 5 # test batch
95107
```
96-
2. Fan out one agent per record (or a few records per agent), each following the
97-
per-record workflow. Cap concurrency; process in waves.
98-
3. **Expect partial yield.** Many sources are paywalled, so a large fraction will
108+
This writes one `reports/growth_conditions_sweep/<Record>.md` per record
109+
(primary ref, title, **frequency-ranked list of every cited ref**, abstract,
110+
and either the OA Methods URL to `curl --compressed` or an author-request
111+
draft) plus an **`INDEX.md`** progress report bucketing each record as
112+
`OA_FULLTEXT` / `ABSTRACT_ONLY` / `NO_REFERENCE` with counts.
113+
3. **Read `INDEX.md`.** Start with the `OA_FULLTEXT` rows — Methods are reachable,
114+
so those yield the richest blocks. `ABSTRACT_ONLY` rows are paywalled: extract
115+
only abstract-level conditions (often just `atmosphere` + notes) or leave
116+
unchanged and keep the author-request draft.
117+
4. **Fan out** one agent per record (or a few per agent), each fed its bundle +
118+
the per-record workflow above. Cap concurrency; process in waves. ⚠️ The
119+
*primary* (most-cited) ref is sometimes a **review**, not the community's
120+
methods paper — if its abstract/full text describes no cultivation of *this*
121+
community, have the agent pick a better ref from the ranked list in the bundle.
122+
5. **Expect partial yield.** Many sources are paywalled, so a large fraction will
99123
legitimately get no conditions — that is the correct outcome, not a failure.
100-
Track: enriched vs. no-conditions-reported vs. paywalled.
101-
4. After each wave, run `just validate-all` + `just validate-terms-all` and
102-
commit the enriched records.
124+
The `INDEX.md` buckets set the expectation up front.
125+
6. After each wave, run `just validate-all` + `just validate-terms-all` and
126+
commit the enriched records. Re-running `--prep` regenerates `INDEX.md`, and
127+
enriched records drop out of `--list` automatically (they now have a block).
103128

104129
## Interpreting results
105130

@@ -118,6 +143,8 @@ growth_media:
118143

119144
## Related scripts / schema
120145

146+
- `scripts/growth_conditions_sweep.py` — sweep prep runner (`--list` / `--prep`);
147+
reuses `scripts/fulltext_access.py` for the access ladder.
121148
- Slots: `MicrobialCommunity.growth_media` (`GrowthMedia`),
122149
`MicrobialCommunity.cultivation_setup` (`CultivationSetup`) — see
123150
`src/communitymech/schema/communitymech.yaml`.
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
# Per-record prep bundles are regenerable via `growth_conditions_sweep.py --prep`.
2+
# Track only the INDEX progress report and this ignore file.
3+
*.md
4+
!INDEX.md

scripts/growth_conditions_sweep.py

Lines changed: 231 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,231 @@
1+
#!/usr/bin/env python3
2+
"""Prep a growth-conditions deep-research sweep over CommunityMech records.
3+
4+
Companion to the `add-growth-conditions` skill. This script does the *deterministic*
5+
part of the whole-KB sweep so the (LLM) extraction agents get a ready source bundle
6+
instead of each re-fetching:
7+
8+
1. Enumerate records that lack a `growth_media` (and `cultivation_setup`) block.
9+
2. For each, pick the **primary reference** — the PMID/DOI cited most often in the
10+
record's evidence items.
11+
3. Fetch the Europe PMC abstract and run the legal full-text **access ladder**
12+
(Europe PMC OA -> Unpaywall -> CORE; else an author-request draft) via
13+
`fulltext_access.py`.
14+
4. Write one staging bundle per record under `reports/growth_conditions_sweep/`
15+
(abstract + access verdict + full-text URL to fetch Methods from, or the
16+
author-request draft) and an `INDEX.md` with per-record access status + counts.
17+
18+
It never edits kb/ — extraction and YAML writes are the agent's job, so a curator
19+
can review this prep before any record changes. Access status buckets each record as
20+
OA_FULLTEXT (Methods reachable), ABSTRACT_ONLY (paywalled; abstract-level only), or
21+
NO_REFERENCE (no citable PMID/DOI found).
22+
23+
Usage:
24+
python scripts/growth_conditions_sweep.py --list
25+
python scripts/growth_conditions_sweep.py --prep # all missing records
26+
python scripts/growth_conditions_sweep.py --prep --limit 5 # first 5 (test batch)
27+
python scripts/growth_conditions_sweep.py --prep FILE [FILE ...] # explicit records
28+
"""
29+
30+
from __future__ import annotations
31+
32+
import argparse
33+
import re
34+
import sys
35+
from collections import Counter
36+
from pathlib import Path
37+
38+
# fulltext_access.py lives beside this script; its dir is on sys.path when run directly.
39+
from fulltext_access import (
40+
author_request_draft,
41+
crossref_meta,
42+
epmc_core,
43+
try_core,
44+
try_europepmc,
45+
try_unpaywall,
46+
)
47+
48+
REPO = Path(__file__).resolve().parent.parent
49+
COMMUNITIES = REPO / "kb" / "communities"
50+
OUT_DIR = REPO / "reports" / "growth_conditions_sweep"
51+
52+
REF_RE = re.compile(r"^\s*-?\s*reference:\s*(\S+)", re.MULTILINE)
53+
HAS_BLOCK_RE = re.compile(r"^(growth_media|cultivation_setup):", re.MULTILINE)
54+
55+
56+
def lacks_conditions(text: str) -> bool:
57+
"""True if the record has neither a growth_media nor cultivation_setup block."""
58+
return not HAS_BLOCK_RE.search(text)
59+
60+
61+
def candidates(explicit: list[Path] | None) -> list[Path]:
62+
files = explicit or sorted(COMMUNITIES.glob("*.yaml"))
63+
return [f for f in files if lacks_conditions(f.read_text())]
64+
65+
66+
def primary_reference(text: str) -> tuple[str | None, list[tuple[str, int]]]:
67+
"""Most-cited reference in the record, plus the frequency-ranked full list."""
68+
refs = [r.strip().strip("\"'") for r in REF_RE.findall(text)]
69+
if not refs:
70+
return None, []
71+
ranked = Counter(refs).most_common()
72+
return ranked[0][0], ranked
73+
74+
75+
def split_ref(ref: str) -> tuple[str | None, str | None]:
76+
"""Split a 'PMID:123' / 'doi:10.x' reference into (pmid, doi)."""
77+
low = ref.lower()
78+
if low.startswith("pmid:"):
79+
return ref.split(":", 1)[1].strip(), None
80+
if low.startswith("doi:"):
81+
return None, ref.split(":", 1)[1].strip()
82+
if ref.startswith("10."): # bare DOI
83+
return None, ref
84+
return None, None
85+
86+
87+
def access_ladder(rec: dict, doi: str | None, title: str | None) -> tuple[str, str | None]:
88+
"""Return (method, url) for the first legal full-text hit, or ('none', None)."""
89+
for method, fn in (
90+
("europepmc_oa", lambda: try_europepmc(rec)),
91+
("unpaywall", lambda: try_unpaywall(doi)),
92+
("core", lambda: try_core(doi, title)),
93+
):
94+
url = fn()
95+
if url:
96+
return method, url
97+
return "none", None
98+
99+
100+
def strip_html(s: str) -> str:
101+
return re.sub(r"<[^>]+>", "", s or "").strip()
102+
103+
104+
def prep_record(path: Path) -> dict:
105+
"""Fetch sources for one record and write its staging bundle. Returns a summary row."""
106+
text = path.read_text()
107+
ref, ranked = primary_reference(text)
108+
row = {"record": path.stem, "ref": ref or "—", "status": "", "method": "", "url": ""}
109+
110+
if not ref:
111+
row["status"] = "NO_REFERENCE"
112+
_write_bundle(path.stem, ref, {}, ranked, "none", None, None)
113+
return row
114+
115+
pmid, doi = split_ref(ref)
116+
rec = epmc_core(pmid, doi)
117+
doi = doi or rec.get("doi")
118+
if not rec.get("title") and doi: # DOI-only / not in EPMC -> CrossRef metadata
119+
rec = {**crossref_meta(doi), **rec}
120+
title = strip_html(rec.get("title", ""))
121+
method, url = access_ladder(rec, doi, title)
122+
123+
row["method"] = method
124+
row["url"] = url or ""
125+
row["status"] = "OA_FULLTEXT" if url else "ABSTRACT_ONLY"
126+
_write_bundle(path.stem, ref, rec, ranked, method, url, doi)
127+
return row
128+
129+
130+
def _write_bundle(
131+
stem: str,
132+
ref: str | None,
133+
rec: dict,
134+
ranked: list[tuple[str, int]],
135+
method: str,
136+
url: str | None,
137+
doi: str | None,
138+
) -> None:
139+
OUT_DIR.mkdir(parents=True, exist_ok=True)
140+
title = strip_html(rec.get("title", ""))
141+
abstract = strip_html(rec.get("abstractText", ""))
142+
lines = [
143+
f"# Growth-conditions prep — {stem}",
144+
"",
145+
f"- **Primary reference:** `{ref or 'none found'}`",
146+
f"- **Title:** {title or '(unresolved)'}",
147+
f"- **All cited refs (freq):** {', '.join(f'{r}×{n}' for r, n in ranked) or '—'}",
148+
]
149+
if url:
150+
lines += [
151+
f"- **Full-text access:** `{method}` — OA full text available",
152+
f"- **Methods URL (fetch this):** {url}",
153+
" - Europe PMC XML: fetch with `curl --compressed <url>` and read the Methods.",
154+
]
155+
else:
156+
lines += [
157+
"- **Full-text access:** `NO_LEGAL_OA` — abstract-level conditions only.",
158+
" - Use only conditions stated in the abstract below; do **not** invent any.",
159+
]
160+
lines += ["", "## Abstract", "", abstract or "_(no abstract retrieved)_", ""]
161+
if not url and (rec.get("title") or ref):
162+
lines += [
163+
"## Author-request draft (for closed-access follow-up)",
164+
"",
165+
"```",
166+
author_request_draft(rec, doi),
167+
"```",
168+
"",
169+
]
170+
(OUT_DIR / f"{stem}.md").write_text("\n".join(lines))
171+
172+
173+
def write_index(rows: list[dict]) -> Path:
174+
OUT_DIR.mkdir(parents=True, exist_ok=True)
175+
counts = Counter(r["status"] for r in rows)
176+
lines = [
177+
"# Growth-conditions sweep — prep index",
178+
"",
179+
f"- Records prepped: **{len(rows)}**",
180+
f"- OA full text (Methods reachable): **{counts.get('OA_FULLTEXT', 0)}**",
181+
f"- Abstract-only (paywalled): **{counts.get('ABSTRACT_ONLY', 0)}**",
182+
f"- No citable reference: **{counts.get('NO_REFERENCE', 0)}**",
183+
"",
184+
"| Record | Primary ref | Access | Method | Methods URL |",
185+
"|---|---|---|---|---|",
186+
]
187+
for r in sorted(rows, key=lambda x: (x["status"], x["record"])):
188+
url = f"[link]({r['url']})" if r["url"] else "—"
189+
lines.append(
190+
f"| {r['record']} | `{r['ref']}` | {r['status']} | {r['method'] or '—'} | {url} |"
191+
)
192+
lines += ["", "Per-record source bundles are in this directory (`<Record>.md`)."]
193+
path = OUT_DIR / "INDEX.md"
194+
path.write_text("\n".join(lines))
195+
return path
196+
197+
198+
def main(argv: list[str] | None = None) -> int:
199+
p = argparse.ArgumentParser(
200+
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
201+
)
202+
mode = p.add_mutually_exclusive_group(required=True)
203+
mode.add_argument("--list", action="store_true", help="List candidate records and exit.")
204+
mode.add_argument("--prep", action="store_true", help="Fetch sources + write staging bundles.")
205+
p.add_argument(
206+
"files", nargs="*", type=Path, help="Explicit record paths (default: all missing)."
207+
)
208+
p.add_argument("--limit", type=int, default=0, help="Cap the number of records processed.")
209+
args = p.parse_args(argv)
210+
211+
cand = candidates(args.files or None)
212+
if args.limit:
213+
cand = cand[: args.limit]
214+
215+
if args.list:
216+
for f in cand:
217+
print(f.name)
218+
print(f"\n{len(cand)} record(s) lack growth_media/cultivation_setup", file=sys.stderr)
219+
return 0
220+
221+
rows = []
222+
for i, f in enumerate(cand, 1):
223+
print(f"[sweep] ({i}/{len(cand)}) {f.stem}", file=sys.stderr)
224+
rows.append(prep_record(f))
225+
idx = write_index(rows)
226+
print(f"[sweep] wrote {len(rows)} bundles + {idx.relative_to(REPO)}", file=sys.stderr)
227+
return 0
228+
229+
230+
if __name__ == "__main__":
231+
raise SystemExit(main())

0 commit comments

Comments
 (0)