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>

Files changed (2) hide show
  1. config.py +1 -1
  2. 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-sonnet-4-6"
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 response.content:
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
- # For any paper with no Claude response, add an empty result so it's not re-processed
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. "