KevinIsInCoding Claude Fable 5 commited on
Commit
af01504
·
1 Parent(s): 260458a

feat(extraction): Batch API extractor (~50% cheaper), fix stale cost banner

Browse files

Route entity extraction through the Message Batches API instead of
synchronous calls. Offline pipeline, so the 50% batch discount applies
with no quality change. Resumable via .batch_state.json (resumes an
in-flight batch instead of resubmitting). Drop the no-op prompt-cache
prefix (below Haiku 4.5's 4096-token cacheable minimum) and correct the
cost banner to ~$0.015 per 10 papers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

Files changed (3) hide show
  1. config.py +1 -0
  2. extraction/extractor.py +209 -119
  3. scripts/extract_entities.py +4 -1
config.py CHANGED
@@ -17,6 +17,7 @@ TRIALS_PATH = DATA_DIR / "trials" / "trials.jsonl"
17
  ENTITIES_PATH = DATA_DIR / "extracted" / "entities.jsonl"
18
  CANONICAL_IDS_PATH = DATA_DIR / "extracted" / "canonical_ids.json"
19
  EXTRACTION_PROGRESS_PATH = DATA_DIR / "extracted" / ".progress.json"
 
20
  GRAPH_PICKLE_PATH = DATA_DIR / "graph" / "als_graph.pkl"
21
  GRAPH_JSON_PATH = DATA_DIR / "graph" / "als_graph.json"
22
  CHROMA_DIR = DATA_DIR / "chroma"
 
17
  ENTITIES_PATH = DATA_DIR / "extracted" / "entities.jsonl"
18
  CANONICAL_IDS_PATH = DATA_DIR / "extracted" / "canonical_ids.json"
19
  EXTRACTION_PROGRESS_PATH = DATA_DIR / "extracted" / ".progress.json"
20
+ EXTRACTION_BATCH_STATE_PATH = DATA_DIR / "extracted" / ".batch_state.json"
21
  GRAPH_PICKLE_PATH = DATA_DIR / "graph" / "als_graph.pkl"
22
  GRAPH_JSON_PATH = DATA_DIR / "graph" / "als_graph.json"
23
  CHROMA_DIR = DATA_DIR / "chroma"
extraction/extractor.py CHANGED
@@ -1,26 +1,30 @@
1
  """
2
- Claude Haiku entity extractor.
3
- Batches 20 papers per API call; resumable via .progress.json.
4
- Uses full_text when available, otherwise abstract.
5
- Prompt caching on system + tools reduces per-call cost ~40%.
 
 
 
 
6
  """
7
  from __future__ import annotations
8
 
9
  import json
10
- import threading
11
  import time
12
- from concurrent.futures import ThreadPoolExecutor, as_completed
13
  from pathlib import Path
14
 
15
  import anthropic
 
 
16
  from rich.progress import BarColumn, MofNCompleteColumn, Progress, TextColumn, TimeElapsedColumn
17
 
18
  from config import (
19
  ENTITIES_PATH,
20
  EXTRACTION_BATCH_SIZE,
 
21
  EXTRACTION_MODEL,
22
  EXTRACTION_PROGRESS_PATH,
23
- EXTRACTION_WORKERS,
24
  PAPERS_PATH,
25
  )
26
  from extraction.normalizer import CanonicalRegistry, guess_entity_type, normalize_entity
@@ -30,6 +34,10 @@ from tools import EXTRACTION_TOOLS
30
 
31
  _logger = get_logger("extraction.extractor")
32
 
 
 
 
 
33
  _EXTRACTION_SYSTEM = """\
34
  You are a biomedical NLP expert specializing in ALS (amyotrophic lateral sclerosis).
35
  Extract entities and relationships from each paper using the extract_entities tool.
@@ -46,21 +54,26 @@ def extract_all(
46
  papers_path: Path = PAPERS_PATH,
47
  entities_path: Path = ENTITIES_PATH,
48
  progress_path: Path = EXTRACTION_PROGRESS_PATH,
 
49
  client: anthropic.Anthropic | None = None,
50
  ) -> list[PaperExtractionResult]:
51
- """Extract entities from all papers. Skips already-processed PMIDs.
52
 
53
- Runs EXTRACTION_WORKERS batches in parallel. A lock serializes file writes
54
- and progress saves so threads don't corrupt each other.
 
55
  """
56
  if client is None:
57
  client = anthropic.Anthropic()
58
 
59
  papers = _load_papers(papers_path)
 
60
  done_pmids = _load_progress(progress_path)
61
 
62
  pending = [p for p in papers if p.pmid not in done_pmids]
63
- _logger.info(f"{len(papers)} papers total; {len(done_pmids)} already processed; {len(pending)} pending")
 
 
64
 
65
  if not pending:
66
  return []
@@ -68,140 +81,202 @@ def extract_all(
68
  registry = CanonicalRegistry()
69
  entities_path.parent.mkdir(parents=True, exist_ok=True)
70
 
71
- batches = [pending[i : i + EXTRACTION_BATCH_SIZE] for i in range(0, len(pending), EXTRACTION_BATCH_SIZE)]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
72
  results: list[PaperExtractionResult] = []
73
- write_lock = threading.Lock()
74
-
75
- with (
76
- open(entities_path, "a", encoding="utf-8") as out_f,
77
- Progress(
78
- TextColumn("[cyan]{task.description}[/cyan]"),
79
- BarColumn(),
80
- MofNCompleteColumn(),
81
- TimeElapsedColumn(),
82
- ) as progress,
83
- ):
84
- task = progress.add_task("Extracting entities", total=len(pending))
85
-
86
- def _process_batch(batch: list[ALSPaper]) -> list[PaperExtractionResult]:
87
- return _extract_batch(client, batch, registry)
88
-
89
- with ThreadPoolExecutor(max_workers=EXTRACTION_WORKERS) as pool:
90
- futures = {pool.submit(_process_batch, b): b for b in batches}
91
- for future in as_completed(futures):
92
- batch_results = future.result()
93
- with write_lock:
94
- for result in batch_results:
95
- out_f.write(json.dumps(result.to_dict()) + "\n")
96
- done_pmids.add(result.pmid)
97
- results.append(result)
98
- out_f.flush()
99
- _save_progress(progress_path, done_pmids)
100
- registry.save()
101
- progress.advance(task, len(futures[future]))
102
 
 
103
  return results
104
 
105
 
106
- def _extract_batch(
107
- client: anthropic.Anthropic,
108
- batch: list[ALSPaper],
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
109
  registry: CanonicalRegistry,
110
  ) -> list[PaperExtractionResult]:
111
- """Send a batch of papers to Claude and collect one extract_entities call per paper."""
112
- paper_by_pmid = {p.pmid: p for p in batch}
113
- content_blocks = _call_claude(client, batch)
114
-
115
  results: list[PaperExtractionResult] = []
116
- for block in content_blocks:
117
  if block.type != "tool_use" or block.name != "extract_entities":
118
  continue
119
 
120
  inp = block.input
121
  pmid = str(inp.get("pmid", ""))
122
  if not pmid or pmid not in paper_by_pmid:
123
- _logger.warning(f"Extracted PMID {pmid!r} not in batch — skipping")
124
  continue
125
 
126
  paper = paper_by_pmid[pmid]
127
  entities = _parse_entities(inp.get("entities", []), pmid, registry)
128
  relationships = _parse_relationships(inp.get("relationships", []), pmid, registry)
129
 
130
- result = PaperExtractionResult(
131
- pmid=pmid,
132
- entities=entities,
133
- relationships=relationships,
134
  )
135
- results.append(result)
136
- _logger.info(f"PMID {pmid}: {len(entities)} entities, {len(relationships)} relationships")
137
-
138
- # Mark paper entity_names (used downstream by RAG indexer on re-index)
139
  paper.entity_names = [e.canonical_id for e in entities]
140
-
141
- # Retry any papers Claude missed — send them individually
142
- found_pmids = {r.pmid for r in results}
143
- missed = [p for p in batch if p.pmid not in found_pmids]
144
- if missed:
145
- _logger.info(f"Retrying {len(missed)} missed papers individually")
146
- for paper in missed:
147
- retry_results = _call_claude(client, [paper])
148
- for block in retry_results:
149
- if block.type != "tool_use" or block.name != "extract_entities":
150
- continue
151
- inp = block.input
152
- pmid = str(inp.get("pmid", ""))
153
- if not pmid or pmid not in paper_by_pmid:
154
- continue
155
- entities = _parse_entities(inp.get("entities", []), pmid, registry)
156
- relationships = _parse_relationships(inp.get("relationships", []), pmid, registry)
157
- results.append(PaperExtractionResult(pmid=pmid, entities=entities, relationships=relationships))
158
- paper_by_pmid[pmid].entity_names = [e.canonical_id for e in entities]
159
- found_pmids.add(pmid)
160
- _logger.info(f"Retry succeeded for PMID {pmid}")
161
- time.sleep(0.5)
162
-
163
- # Any still-missing after retry → record empty so they're not re-attempted
164
- for p in batch:
165
- if p.pmid not in found_pmids:
166
- _logger.warning(f"No extraction result for PMID {p.pmid} after retry — recording empty")
167
- results.append(PaperExtractionResult(pmid=p.pmid, entities=[], relationships=[]))
168
 
169
  return results
170
 
171
 
172
- def _call_claude(client: anthropic.Anthropic, batch: list[ALSPaper]) -> list:
173
- """Raw Claude call — returns response.content blocks.
174
-
175
- Prompt caching: system and tools are static across all calls; adding
176
- cache_control to the last tool + system block caches the entire prefix
177
- (tools render before system in the API token order). Cache reads cost
178
- ~10% of normal input price, halving the effective per-call overhead.
179
- """
180
- # Cache the static system+tools prefix across batch calls
181
- cached_system = [{"type": "text", "text": _EXTRACTION_SYSTEM, "cache_control": {"type": "ephemeral"}}]
182
- cached_tools = list(EXTRACTION_TOOLS)
183
- if cached_tools:
184
- last = dict(cached_tools[-1])
185
- last["cache_control"] = {"type": "ephemeral"}
186
- cached_tools[-1] = last
187
-
188
- def _request() -> list:
189
- response = client.messages.create(
190
- model=EXTRACTION_MODEL,
191
- max_tokens=8192,
192
- system=cached_system,
193
- tools=cached_tools,
194
- tool_choice={"type": "any"},
195
- messages=[{"role": "user", "content": _format_batch(batch)}],
196
- )
197
- return response.content
198
-
199
- try:
200
- return _request()
201
- except anthropic.RateLimitError:
202
- _logger.warning("Rate limited — sleeping 30s")
203
- time.sleep(30)
204
- return _request()
205
 
206
 
207
  def _format_batch(batch: list[ALSPaper]) -> str:
@@ -299,3 +374,18 @@ def _load_progress(path: Path) -> set[str]:
299
  def _save_progress(path: Path, done: set[str]) -> None:
300
  path.parent.mkdir(parents=True, exist_ok=True)
301
  path.write_text(json.dumps(sorted(done)))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  """
2
+ Claude Haiku entity extractor — Batch API.
3
+
4
+ Submits extraction requests through the Message Batches API (50% cheaper than
5
+ synchronous calls) since this is an offline, non-latency-sensitive pipeline.
6
+ Batches 20 papers per request; uses full_text when available, else abstract.
7
+ Resumable at two levels: completed PMIDs are tracked in .progress.json, and an
8
+ in-flight batch id is persisted in .batch_state.json so an interrupted run
9
+ resumes polling the same (already-paid-for) batch instead of resubmitting.
10
  """
11
  from __future__ import annotations
12
 
13
  import json
 
14
  import time
 
15
  from pathlib import Path
16
 
17
  import anthropic
18
+ from anthropic.types.message_create_params import MessageCreateParamsNonStreaming
19
+ from anthropic.types.messages.batch_create_params import Request
20
  from rich.progress import BarColumn, MofNCompleteColumn, Progress, TextColumn, TimeElapsedColumn
21
 
22
  from config import (
23
  ENTITIES_PATH,
24
  EXTRACTION_BATCH_SIZE,
25
+ EXTRACTION_BATCH_STATE_PATH,
26
  EXTRACTION_MODEL,
27
  EXTRACTION_PROGRESS_PATH,
 
28
  PAPERS_PATH,
29
  )
30
  from extraction.normalizer import CanonicalRegistry, guess_entity_type, normalize_entity
 
34
 
35
  _logger = get_logger("extraction.extractor")
36
 
37
+ # Seconds between batch status polls. Batches usually finish in well under an
38
+ # hour; the ceiling is 24h.
39
+ _POLL_INTERVAL_S = 30
40
+
41
  _EXTRACTION_SYSTEM = """\
42
  You are a biomedical NLP expert specializing in ALS (amyotrophic lateral sclerosis).
43
  Extract entities and relationships from each paper using the extract_entities tool.
 
54
  papers_path: Path = PAPERS_PATH,
55
  entities_path: Path = ENTITIES_PATH,
56
  progress_path: Path = EXTRACTION_PROGRESS_PATH,
57
+ batch_state_path: Path = EXTRACTION_BATCH_STATE_PATH,
58
  client: anthropic.Anthropic | None = None,
59
  ) -> list[PaperExtractionResult]:
60
+ """Extract entities from all papers via the Batch API. Skips done PMIDs.
61
 
62
+ Runs one main batch round (20 papers/request), then an individual retry
63
+ round for any papers Claude skipped, then records empty results for papers
64
+ still missing so they aren't re-attempted on the next run.
65
  """
66
  if client is None:
67
  client = anthropic.Anthropic()
68
 
69
  papers = _load_papers(papers_path)
70
+ paper_by_pmid = {p.pmid: p for p in papers}
71
  done_pmids = _load_progress(progress_path)
72
 
73
  pending = [p for p in papers if p.pmid not in done_pmids]
74
+ _logger.info(
75
+ f"{len(papers)} papers total; {len(done_pmids)} already processed; {len(pending)} pending"
76
+ )
77
 
78
  if not pending:
79
  return []
 
81
  registry = CanonicalRegistry()
82
  entities_path.parent.mkdir(parents=True, exist_ok=True)
83
 
84
+ all_results: list[PaperExtractionResult] = []
85
+ with open(entities_path, "a", encoding="utf-8") as out_f:
86
+ # Round 1 — main batches of EXTRACTION_BATCH_SIZE papers each.
87
+ batches = [
88
+ pending[i : i + EXTRACTION_BATCH_SIZE]
89
+ for i in range(0, len(pending), EXTRACTION_BATCH_SIZE)
90
+ ]
91
+ main_map = {f"batch-{i}": batch for i, batch in enumerate(batches)}
92
+ round1 = _run_batch_round(client, main_map, registry, paper_by_pmid, batch_state_path)
93
+ _write_results(out_f, round1, done_pmids, progress_path, registry)
94
+ all_results.extend(round1)
95
+
96
+ found = {r.pmid for r in round1}
97
+ missed = [p for p in pending if p.pmid not in found]
98
+
99
+ # Round 2 — retry missed papers one per request.
100
+ if missed:
101
+ _logger.info(f"Retrying {len(missed)} missed papers individually")
102
+ retry_map = {f"retry-{p.pmid}": [p] for p in missed}
103
+ round2 = _run_batch_round(client, retry_map, registry, paper_by_pmid, batch_state_path)
104
+ _write_results(out_f, round2, done_pmids, progress_path, registry)
105
+ all_results.extend(round2)
106
+ found |= {r.pmid for r in round2}
107
+
108
+ # Record empty results for anything still missing after retry.
109
+ still_missing = [p for p in pending if p.pmid not in found]
110
+ if still_missing:
111
+ empties = []
112
+ for p in still_missing:
113
+ _logger.warning(
114
+ f"No extraction result for PMID {p.pmid} after retry — recording empty"
115
+ )
116
+ empties.append(PaperExtractionResult(pmid=p.pmid, entities=[], relationships=[]))
117
+ _write_results(out_f, empties, done_pmids, progress_path, registry)
118
+ all_results.extend(empties)
119
+
120
+ return all_results
121
+
122
+
123
+ def _run_batch_round(
124
+ client: anthropic.Anthropic,
125
+ custom_id_to_papers: dict[str, list[ALSPaper]],
126
+ registry: CanonicalRegistry,
127
+ paper_by_pmid: dict[str, ALSPaper],
128
+ state_path: Path,
129
+ ) -> list[PaperExtractionResult]:
130
+ """Submit (or resume) one batch, poll to completion, and parse its results.
131
+
132
+ Persists the batch id + custom_id→PMID mapping to state_path on submit so an
133
+ interrupted process resumes the same batch. Clears the state on completion.
134
+ """
135
+ batch = None
136
+ state = _load_batch_state(state_path)
137
+ if state and state.get("batch_id"):
138
+ try:
139
+ existing = client.messages.batches.retrieve(state["batch_id"])
140
+ except anthropic.NotFoundError:
141
+ _logger.warning("Persisted batch id not found — submitting a fresh batch")
142
+ else:
143
+ if existing.processing_status in {"in_progress", "validating", "finalizing", "ended"}:
144
+ _logger.info(f"Resuming in-flight batch {existing.id}")
145
+ batch = existing
146
+ # Rebuild the mapping from persisted PMIDs so results match.
147
+ custom_id_to_papers = {
148
+ cid: [paper_by_pmid[pmid] for pmid in pmids if pmid in paper_by_pmid]
149
+ for cid, pmids in state.get("papers", {}).items()
150
+ }
151
+
152
+ if batch is None:
153
+ requests = [
154
+ Request(custom_id=cid, params=_build_params(papers))
155
+ for cid, papers in custom_id_to_papers.items()
156
+ ]
157
+ batch = client.messages.batches.create(requests=requests)
158
+ _save_batch_state(
159
+ state_path,
160
+ {
161
+ "batch_id": batch.id,
162
+ "papers": {
163
+ cid: [p.pmid for p in papers] for cid, papers in custom_id_to_papers.items()
164
+ },
165
+ },
166
+ )
167
+ _logger.info(f"Submitted batch {batch.id} with {len(requests)} requests")
168
+
169
+ batch = _poll_until_done(client, batch)
170
+
171
  results: list[PaperExtractionResult] = []
172
+ for res in client.messages.batches.results(batch.id):
173
+ papers = custom_id_to_papers.get(res.custom_id, [])
174
+ local_by_pmid = {p.pmid: p for p in papers}
175
+ if res.result.type == "succeeded":
176
+ results.extend(
177
+ _parse_response_blocks(res.result.message.content, local_by_pmid, registry)
178
+ )
179
+ elif res.result.type == "errored":
180
+ _logger.warning(f"Batch request {res.custom_id} errored: {res.result.error}")
181
+ else:
182
+ _logger.warning(f"Batch request {res.custom_id} {res.result.type}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
183
 
184
+ _clear_batch_state(state_path)
185
  return results
186
 
187
 
188
+ def _poll_until_done(client: anthropic.Anthropic, batch) -> object:
189
+ """Poll a batch until it reaches a terminal status, showing progress."""
190
+ total = (
191
+ batch.request_counts.processing
192
+ + batch.request_counts.succeeded
193
+ + batch.request_counts.errored
194
+ + batch.request_counts.canceled
195
+ + batch.request_counts.expired
196
+ )
197
+ with Progress(
198
+ TextColumn("[cyan]{task.description}[/cyan]"),
199
+ BarColumn(),
200
+ MofNCompleteColumn(),
201
+ TimeElapsedColumn(),
202
+ ) as progress:
203
+ task = progress.add_task("Extracting entities (batch)", total=total or None)
204
+ while batch.processing_status != "ended":
205
+ if batch.processing_status in {"canceling", "canceled", "expired"}:
206
+ _logger.warning(f"Batch {batch.id} ended early with status {batch.processing_status}")
207
+ break
208
+ time.sleep(_POLL_INTERVAL_S)
209
+ batch = client.messages.batches.retrieve(batch.id)
210
+ counts = batch.request_counts
211
+ completed = counts.succeeded + counts.errored + counts.canceled + counts.expired
212
+ progress.update(task, completed=completed)
213
+ progress.update(task, completed=total)
214
+ return batch
215
+
216
+
217
+ def _build_params(batch: list[ALSPaper]) -> MessageCreateParamsNonStreaming:
218
+ """Build the per-request Messages params for a batch of papers.
219
+
220
+ system + tools are identical across every request, but on Haiku 4.5 the
221
+ combined prefix is far below the 4096-token minimum cacheable size, so
222
+ prompt caching would silently no-op — we don't set cache_control here.
223
+ """
224
+ return MessageCreateParamsNonStreaming(
225
+ model=EXTRACTION_MODEL,
226
+ max_tokens=8192,
227
+ system=_EXTRACTION_SYSTEM,
228
+ tools=list(EXTRACTION_TOOLS),
229
+ tool_choice={"type": "any"},
230
+ messages=[{"role": "user", "content": _format_batch(batch)}],
231
+ )
232
+
233
+
234
+ def _parse_response_blocks(
235
+ blocks: list,
236
+ paper_by_pmid: dict[str, ALSPaper],
237
  registry: CanonicalRegistry,
238
  ) -> list[PaperExtractionResult]:
239
+ """Parse extract_entities tool_use blocks from one response into results."""
 
 
 
240
  results: list[PaperExtractionResult] = []
241
+ for block in blocks:
242
  if block.type != "tool_use" or block.name != "extract_entities":
243
  continue
244
 
245
  inp = block.input
246
  pmid = str(inp.get("pmid", ""))
247
  if not pmid or pmid not in paper_by_pmid:
248
+ _logger.warning(f"Extracted PMID {pmid!r} not in request — skipping")
249
  continue
250
 
251
  paper = paper_by_pmid[pmid]
252
  entities = _parse_entities(inp.get("entities", []), pmid, registry)
253
  relationships = _parse_relationships(inp.get("relationships", []), pmid, registry)
254
 
255
+ results.append(
256
+ PaperExtractionResult(pmid=pmid, entities=entities, relationships=relationships)
 
 
257
  )
 
 
 
 
258
  paper.entity_names = [e.canonical_id for e in entities]
259
+ _logger.info(f"PMID {pmid}: {len(entities)} entities, {len(relationships)} relationships")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
260
 
261
  return results
262
 
263
 
264
+ def _write_results(
265
+ out_f,
266
+ results: list[PaperExtractionResult],
267
+ done_pmids: set[str],
268
+ progress_path: Path,
269
+ registry: CanonicalRegistry,
270
+ ) -> None:
271
+ """Append results to the output file and advance the resumability trackers."""
272
+ if not results:
273
+ return
274
+ for result in results:
275
+ out_f.write(json.dumps(result.to_dict()) + "\n")
276
+ done_pmids.add(result.pmid)
277
+ out_f.flush()
278
+ _save_progress(progress_path, done_pmids)
279
+ registry.save()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
280
 
281
 
282
  def _format_batch(batch: list[ALSPaper]) -> str:
 
374
  def _save_progress(path: Path, done: set[str]) -> None:
375
  path.parent.mkdir(parents=True, exist_ok=True)
376
  path.write_text(json.dumps(sorted(done)))
377
+
378
+
379
+ def _load_batch_state(path: Path) -> dict | None:
380
+ if path.exists():
381
+ return json.loads(path.read_text())
382
+ return None
383
+
384
+
385
+ def _save_batch_state(path: Path, state: dict) -> None:
386
+ path.parent.mkdir(parents=True, exist_ok=True)
387
+ path.write_text(json.dumps(state))
388
+
389
+
390
+ def _clear_batch_state(path: Path) -> None:
391
+ path.unlink(missing_ok=True)
scripts/extract_entities.py CHANGED
@@ -63,7 +63,10 @@ def main() -> None:
63
  console.print(f"[dim]Testing with {args.max} papers[/dim]")
64
 
65
  console.print(f"[cyan]Starting entity extraction from {papers_path}...[/cyan]")
66
- console.print("[dim]Rate-limited to ~1 batch/second. Cost: ~$0.03 per 10 papers.[/dim]\n")
 
 
 
67
 
68
  client = anthropic.Anthropic()
69
  results = extract_all(papers_path=papers_path, client=client)
 
63
  console.print(f"[dim]Testing with {args.max} papers[/dim]")
64
 
65
  console.print(f"[cyan]Starting entity extraction from {papers_path}...[/cyan]")
66
+ console.print(
67
+ "[dim]Submitted via the Batch API (async, usually <1h). "
68
+ "Cost: ~$0.015 per 10 papers (~50% off vs. synchronous).[/dim]\n"
69
+ )
70
 
71
  client = anthropic.Anthropic()
72
  results = extract_all(papers_path=papers_path, client=client)