Spaces:
Sleeping
Sleeping
KevinIsInCoding Claude Sonnet 4.6 commited on
Commit ·
ac8f675
1
Parent(s): d515e2b
perf(extraction): switch to Haiku + add per-paper retry for missed PMIDs
Browse files- EXTRACTION_MODEL: claude-sonnet-4-6 → claude-haiku-4-5-20251001
Expected: 60min → 10min, $1.50 → $0.12 for 500 papers
- Refactor _extract_batch to use shared _call_claude() helper
- Add retry loop: papers missed by Claude in a batch are retried
individually before being recorded as empty
(root cause: tool_choice=any only guarantees ≥1 call, not N calls)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- config.py +1 -1
- extraction/extractor.py +51 -27
config.py
CHANGED
|
@@ -2,7 +2,7 @@ from pathlib import Path
|
|
| 2 |
|
| 3 |
# Models
|
| 4 |
SYNTHESIS_MODEL = "claude-sonnet-4-6"
|
| 5 |
-
EXTRACTION_MODEL = "claude-
|
| 6 |
|
| 7 |
# External API endpoints
|
| 8 |
CTGOV_BASE = "https://clinicaltrials.gov/api/v2/studies"
|
|
|
|
| 2 |
|
| 3 |
# Models
|
| 4 |
SYNTHESIS_MODEL = "claude-sonnet-4-6"
|
| 5 |
+
EXTRACTION_MODEL = "claude-haiku-4-5-20251001"
|
| 6 |
|
| 7 |
# External API endpoints
|
| 8 |
CTGOV_BASE = "https://clinicaltrials.gov/api/v2/studies"
|
extraction/extractor.py
CHANGED
|
@@ -99,34 +99,11 @@ def _extract_batch(
|
|
| 99 |
registry: CanonicalRegistry,
|
| 100 |
) -> list[PaperExtractionResult]:
|
| 101 |
"""Send a batch of papers to Claude and collect one extract_entities call per paper."""
|
| 102 |
-
user_content = _format_batch(batch)
|
| 103 |
-
|
| 104 |
-
try:
|
| 105 |
-
response = client.messages.create(
|
| 106 |
-
model=EXTRACTION_MODEL,
|
| 107 |
-
max_tokens=4096,
|
| 108 |
-
system=_EXTRACTION_SYSTEM,
|
| 109 |
-
tools=EXTRACTION_TOOLS,
|
| 110 |
-
tool_choice={"type": "any"},
|
| 111 |
-
messages=[{"role": "user", "content": user_content}],
|
| 112 |
-
)
|
| 113 |
-
except anthropic.RateLimitError:
|
| 114 |
-
_logger.warning("Rate limited — sleeping 30s")
|
| 115 |
-
time.sleep(30)
|
| 116 |
-
response = client.messages.create(
|
| 117 |
-
model=EXTRACTION_MODEL,
|
| 118 |
-
max_tokens=4096,
|
| 119 |
-
system=_EXTRACTION_SYSTEM,
|
| 120 |
-
tools=EXTRACTION_TOOLS,
|
| 121 |
-
tool_choice={"type": "any"},
|
| 122 |
-
messages=[{"role": "user", "content": user_content}],
|
| 123 |
-
)
|
| 124 |
-
|
| 125 |
-
# Build a PMID→paper lookup so we can match extracted results back
|
| 126 |
paper_by_pmid = {p.pmid: p for p in batch}
|
|
|
|
| 127 |
|
| 128 |
results: list[PaperExtractionResult] = []
|
| 129 |
-
for block in
|
| 130 |
if block.type != "tool_use" or block.name != "extract_entities":
|
| 131 |
continue
|
| 132 |
|
|
@@ -151,16 +128,63 @@ def _extract_batch(
|
|
| 151 |
# Mark paper entity_names (used downstream by RAG indexer on re-index)
|
| 152 |
paper.entity_names = [e.canonical_id for e in entities]
|
| 153 |
|
| 154 |
-
#
|
| 155 |
found_pmids = {r.pmid for r in results}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 156 |
for p in batch:
|
| 157 |
if p.pmid not in found_pmids:
|
| 158 |
-
_logger.warning(f"No extraction result for PMID {p.pmid} — recording empty")
|
| 159 |
results.append(PaperExtractionResult(pmid=p.pmid, entities=[], relationships=[]))
|
| 160 |
|
| 161 |
return results
|
| 162 |
|
| 163 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 164 |
def _format_batch(batch: list[ALSPaper]) -> str:
|
| 165 |
parts = [
|
| 166 |
f"Extract entities from each of the following {len(batch)} ALS papers. "
|
|
|
|
| 99 |
registry: CanonicalRegistry,
|
| 100 |
) -> list[PaperExtractionResult]:
|
| 101 |
"""Send a batch of papers to Claude and collect one extract_entities call per paper."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 102 |
paper_by_pmid = {p.pmid: p for p in batch}
|
| 103 |
+
content_blocks = _call_claude(client, batch)
|
| 104 |
|
| 105 |
results: list[PaperExtractionResult] = []
|
| 106 |
+
for block in content_blocks:
|
| 107 |
if block.type != "tool_use" or block.name != "extract_entities":
|
| 108 |
continue
|
| 109 |
|
|
|
|
| 128 |
# Mark paper entity_names (used downstream by RAG indexer on re-index)
|
| 129 |
paper.entity_names = [e.canonical_id for e in entities]
|
| 130 |
|
| 131 |
+
# Retry any papers Claude missed — send them individually
|
| 132 |
found_pmids = {r.pmid for r in results}
|
| 133 |
+
missed = [p for p in batch if p.pmid not in found_pmids]
|
| 134 |
+
if missed:
|
| 135 |
+
_logger.info(f"Retrying {len(missed)} missed papers individually")
|
| 136 |
+
for paper in missed:
|
| 137 |
+
retry_results = _call_claude(client, [paper])
|
| 138 |
+
for block in retry_results:
|
| 139 |
+
if block.type != "tool_use" or block.name != "extract_entities":
|
| 140 |
+
continue
|
| 141 |
+
inp = block.input
|
| 142 |
+
pmid = str(inp.get("pmid", ""))
|
| 143 |
+
if not pmid or pmid not in paper_by_pmid:
|
| 144 |
+
continue
|
| 145 |
+
entities = _parse_entities(inp.get("entities", []), pmid, registry)
|
| 146 |
+
relationships = _parse_relationships(inp.get("relationships", []), pmid, registry)
|
| 147 |
+
results.append(PaperExtractionResult(pmid=pmid, entities=entities, relationships=relationships))
|
| 148 |
+
paper_by_pmid[pmid].entity_names = [e.canonical_id for e in entities]
|
| 149 |
+
found_pmids.add(pmid)
|
| 150 |
+
_logger.info(f"Retry succeeded for PMID {pmid}")
|
| 151 |
+
time.sleep(0.5)
|
| 152 |
+
|
| 153 |
+
# Any still-missing after retry → record empty so they're not re-attempted
|
| 154 |
for p in batch:
|
| 155 |
if p.pmid not in found_pmids:
|
| 156 |
+
_logger.warning(f"No extraction result for PMID {p.pmid} after retry — recording empty")
|
| 157 |
results.append(PaperExtractionResult(pmid=p.pmid, entities=[], relationships=[]))
|
| 158 |
|
| 159 |
return results
|
| 160 |
|
| 161 |
|
| 162 |
+
def _call_claude(client: anthropic.Anthropic, batch: list[ALSPaper]) -> list:
|
| 163 |
+
"""Raw Claude call — returns response.content blocks."""
|
| 164 |
+
try:
|
| 165 |
+
response = client.messages.create(
|
| 166 |
+
model=EXTRACTION_MODEL,
|
| 167 |
+
max_tokens=4096,
|
| 168 |
+
system=_EXTRACTION_SYSTEM,
|
| 169 |
+
tools=EXTRACTION_TOOLS,
|
| 170 |
+
tool_choice={"type": "any"},
|
| 171 |
+
messages=[{"role": "user", "content": _format_batch(batch)}],
|
| 172 |
+
)
|
| 173 |
+
return response.content
|
| 174 |
+
except anthropic.RateLimitError:
|
| 175 |
+
_logger.warning("Rate limited — sleeping 30s")
|
| 176 |
+
time.sleep(30)
|
| 177 |
+
response = client.messages.create(
|
| 178 |
+
model=EXTRACTION_MODEL,
|
| 179 |
+
max_tokens=4096,
|
| 180 |
+
system=_EXTRACTION_SYSTEM,
|
| 181 |
+
tools=EXTRACTION_TOOLS,
|
| 182 |
+
tool_choice={"type": "any"},
|
| 183 |
+
messages=[{"role": "user", "content": _format_batch(batch)}],
|
| 184 |
+
)
|
| 185 |
+
return response.content
|
| 186 |
+
|
| 187 |
+
|
| 188 |
def _format_batch(batch: list[ALSPaper]) -> str:
|
| 189 |
parts = [
|
| 190 |
f"Extract entities from each of the following {len(batch)} ALS papers. "
|