The counterpart to gr_compare(): that runs several recipes over one
document, this runs one recipe over many. Returns one tidy row per document,
so the result goes straight into a data frame you can write out, join, or
code against.
Usage
gr_read_many(
sources,
question,
recipe = "thorough",
client = NULL,
store = NULL,
on_error = c("continue", "stop"),
max_total_usd = NULL,
max_total_calls = NULL,
keep_answers = TRUE,
recursive = FALSE,
trace = NULL,
...
)Arguments
- sources
A character vector of file paths, or a single directory, or raw text, or a
gr_records()or agr_screen()result, either of which also carries the search forward to the audit. A directory, and a vector of paths that all exist, are both filtered to the extensions some registered extractor claims, so which files are picked up followsgr_extractors(), including any you registered yourself. Raw text, a mixed vector and alist()of sources are passed through untouched, and a web address is downloaded asgr_ingest()downloads one.- question
The question, asked of every document.
- recipe
One recipe, applied to every document.
- client
A
gr_client. Wrap it ingr_cache_client()for a long run: with a durable cache directory, a restart pays for nothing it has already answered. A closure-backed client (gr_backend_client()orgr_mock_client()) reuses a cache or astoreacross sessions only if it was given a stableid; seegr_backend_client()for why.- store
Optional directory. Each document's result is written there as it completes and restored on a later run instead of being read again. This is what makes a four-hour run survive being interrupted.
- on_error
"continue"(default) records the failure and moves on;"stop"aborts. One unreadable file in two hundred should not cost you the other hundred and ninety-nine.- max_total_usd
Stop once the run has spent this much, marking the remaining documents
"skipped". This is a corpus ceiling and is separate fromgr_options(max_cost_usd =), which is a limit per document. It needs a model with a registered price: against one without, cost is unknown rather than zero, the ceiling cannot be enforced, and you get agr_corpus_cost_unknownwarning instead of a silent free pass.- max_total_calls
Stop before a document once the run has made this many model calls, marking the rest
"skipped". The counterpart tomax_total_usdfor runs whose model has no registered price, and the only ceiling that bounds the run rather than each document:gr_options(max_calls =)is per document, so a corpus can makelength(sources)times that many. Checked before each document, because a call ceiling noticed after the calls is not a ceiling. This means the run can overshoot by at most one document's worth, exactly asmax_total_usddoes.- keep_answers
Keep every gr_answer in the result. Set
FALSEfor a large corpus, where holding every trace and evidence table is the thing that runs you out of memory.- recursive
Descend into subdirectories when
sourcesis a directory.- trace
A
gr_trace()to fold this run's accounting into, so several stages of one review add up to one figure. It is a parent: this run still gets its own trace, which is what$tracereturns and whatgr_options(max_calls =)is measured against. Running a stage directly on a shared trace would charge the previous stage's calls against this one's ceiling. Omit it and there is no parent.- ...
Overrides applied to the recipe, as in
answer_document().
Value
An object of class gr_corpus: summary (one row per document),
answers (named list, empty when keep_answers = FALSE), sources (the
sources as read, aligned row for row with summary; summary$document is
a display label and cannot be turned back into a path), records (the
gr_records() the corpus came from, or NULL), trace (every call
made this run) and store.
The summary
document, document_id, answer, not_found, partial, reader,
chunks, chunks_used, calls, cached, tokens_in, tokens_out,
cost_usd, seconds, status, duplicate_of, error, warnings.
warnings holds what readgpt warned about while reading that document,
joined with " | ", or NA when it raised nothing. The warnings still print
as they happen; this column is what lets you tie them to a document afterwards.
document is a filename and document_id is the hash of the cleaned text
(and of the pages that never became text, when there are any). Cite with the
second: a filename changes when the file is renamed, collides between
folders, and does not exist at all for a document passed as text, while the
id is the same string for the same document in every run and on every machine
with the same OCR setup. Two copies of one paper share an id, which is the
same fact as the duplicate detection below.
status is "ok", "failed", "skipped" (the corpus ceiling was reached
first), "restored" (read from store, not re-read now) or "duplicate"
(see below). A document that max_calls or max_cost_usd stopped before it
was read in full is "failed" too, with the limit in error and its partial
answer in answers; it is not written to store, so a resumed run with a
higher limit reads it again. A restored row keeps the numbers from when that
document was first read, so its cost_usd is what it cost then, not what this
run spent. That is why the run's own spend comes from gr_trace_cost(x$trace)
and not from summing the column.
Documents that are the same document
The same paper reaches you from three databases under three filenames. Each
source is extracted and cleaned, and a document whose cleaned text (and set
of unread pages) is identical to one already read this run is not read
again: its row is filled in from the first copy, except for warnings,
which are its own; status is "duplicate" and duplicate_of
names the row it repeats. Nothing is dropped (every source you passed still
has a row), so subset(x$summary, is.na(duplicate_of)) is the deduplicated
set and sum(!is.na(x$summary$duplicate_of)) is the number to report as
removed.
This is about the table, not the bill: a gr_cache_client() already makes the
second copy's calls free. What it could not do is stop the duplicate from
appearing in the results as a second, independent document, which is how one
study gets counted twice in a synthesis.
The comparison is exact, on cleaned text. Two typesettings of one paper are two documents here; matching those needs bibliographic metadata, not text.
Budgets
Every document gets its own trace, so gr_options(max_calls =) and
gr_options(max_cost_usd =) apply per document exactly as they would if you
read it alone. One enormous document therefore cannot starve the rest.
That is a deliberate design and it leaves the run itself unbounded: two
hundred documents under a 400-call ceiling is a corpus ceiling of eighty
thousand calls. The run-level ceilings are max_total_calls, checked before
each document, and max_total_usd, checked after each one because what a
document costs is not knowable until it has been read. With neither set, the
run says once what its worst case is rather than leaving you to multiply.
What this does not do
It reads documents one at a time. Per-document work is embarrassingly
parallel, and the per-worker traces the parallel helper already builds would
carry the accounting across, so the obstacle is not the trace: it is that
duplicate detection, the resume store and both run-level ceilings are all
order-dependent, and a parallel loop would have to serialise on each of them. Within a document,
gr_options(parallel = TRUE) already applies.
Examples
cl <- gr_mock_client(function(m, p) "Revenue was 45.2 million dollars.")
a <- tempfile(fileext = ".txt"); writeLines("Revenue was 45.2 million.", a)
b <- tempfile(fileext = ".txt"); writeLines("Revenue was 51.8 million.", b)
out <- gr_read_many(c(a, b), "What was revenue?", "fast", client = cl)
#> 2 document(s), and gr_options(max_calls) is 400 PER DOCUMENT, so this run may make up to 800 call(s). Pass max_total_calls = to cap the run.
#> [1/2] file1dcd2173fabc.txt
#> Extracting 'file1dcd2173fabc.txt' with the 'txt' extractor.
#> Ingested 1 block(s), ~11 tokens (0 chars removed by cleaning).
#> Segmenting with 'paragraph' (cap 4000 tokens, overlap 0).
#> Reading with 'stuff' (all|1|none) over 1 chunk(s).
#> [2/2] file1dcd602b5e17.txt ($0.0003 spent so far)
#> Extracting 'file1dcd602b5e17.txt' with the 'txt' extractor.
#> Ingested 1 block(s), ~11 tokens (0 chars removed by cleaning).
#> Segmenting with 'paragraph' (cap 4000 tokens, overlap 0).
#> Reading with 'stuff' (all|1|none) over 1 chunk(s).
out$summary[, c("document", "answer", "not_found", "status")]
#> document answer not_found status
#> 1 file1dcd2173fabc.txt Revenue was 45.2 million dollars. FALSE ok
#> 2 file1dcd602b5e17.txt Revenue was 45.2 million dollars. FALSE ok
# A missing file is one bad row, not a failed run.
bad <- gr_read_many(c(a, "no-such-file.txt"), "What was revenue?", "fast", client = cl)
#> 2 document(s), and gr_options(max_calls) is 400 PER DOCUMENT, so this run may make up to 800 call(s). Pass max_total_calls = to cap the run.
#> [1/2] file1dcd2173fabc.txt
#> Using cached ingestion for this document + settings.
#> Segmenting with 'paragraph' (cap 4000 tokens, overlap 0).
#> Reading with 'stuff' (all|1|none) over 1 chunk(s).
#> [2/2] no-such-file.txt ($0.0003 spent so far)
#> Warning: Document 'no-such-file.txt' failed: File not found: 'no-such-file.txt'. If you meant to pass document text rather than a path, it must not end in something that looks like a file extension.
bad$summary[, c("document", "status", "error")]
#> document status
#> 1 file1dcd2173fabc.txt ok
#> 2 no-such-file.txt failed
#> error
#> 1 <NA>
#> 2 File not found: 'no-such-file.txt'. If you meant to pass document text rather than a path, it must not end in something that looks like a file extension.
# What the run actually cost, counting only calls that were really issued.
gr_trace_cost(out$trace)
#> model calls paid_calls paid_in paid_out usd
#> 1 gpt-5.6-terra 2 2 184 26 0.00068