evistreams
analysisExtract systematic-review fields from clinical trial PDFs with the open EviStreams engine — typed forms, staged LLM extraction with page-linked quotes, a quote audit, dual-review comparison and field-level scoring.
EviStreams — quote-traceable extraction for systematic reviews
EviStreams is a research platform for the data-extraction step of a systematic review. A reviewer describes the fields they want from each study as a typed form, a language model fills the form from the full text of each trial report, and every value comes back with the sentence it was taken from and the page that sentence sits on. Two reviewers then work the same studies independently, their entries are compared field by field, and an adjudicator settles what they disagree on. The design is described in Kosuri et al., EviStreams: Human-in-the-Loop AI Data Extraction for Systematic Reviews in Medicine, arXiv:2609.27418 (2026).
The value of the quote is that it makes a model's answer checkable in seconds rather than re-read from scratch. The catch, which this skill is mostly about, is that a quote being present is not the same as a quote being evidence. Section 5 shows the engine handing back a real, verbatim sentence that does not contain the value it is offered in support of.
Use this skill to:
- run the extraction engine standalone on trial reports, with a local model or a hosted one;
- check which extracted values are actually supported by verbatim text;
- compare two reviewers' records and produce an adjudicated table;
- score an extraction against a reference sheet with the project's field-typed scorer.
Do not use it to screen abstracts, to rate risk of bias, or to pool results. It extracts;
it does not decide inclusion or synthesise. For risk-of-bias and GRADE appraisal, use the
scientific-critical-thinking skill.
What a reader needs before starting
Read this before any block that runs the engine.
- The code is Apache-2.0 —
karthik-strikes/evistream_backendon GitHub, with the matching web frontend under the same licence inkarthik-strikes/evistream_frontend. Both repositories were published on 19 October 2026 with no tagged release, so this skill pins a commit. - Extraction calls a language model, and that is the only thing in this skill that can
cost money. Any model LiteLLM can reach will do. Two routes:
- No key — a local model through Ollama. This skill was verified
with
gemma3:12b(digestf4031aab637d), an 8.1 GB download that occupied 18 GB of memory on the verifying machine with a 131,072-token context. Slow, and how its accuracy compares with a hosted model was not measured, but nothing leaves the machine. - Paid key — an OpenAI, Google Gemini or other provider key. The upstream defaults point at hosted commercial models.
- No key — a local model through Ollama. This skill was verified
with
- The full web platform is a deployment, not an install. Reviewer accounts, blinded assignments and the adjudication screens need your own Supabase (Postgres) project, an S3 bucket, Redis, a Datalab API key for PDF conversion (a paid, per-page service) and a model key, with the frontend pointed at the same backend. Section 8 sketches it. Nothing in sections 1 to 7 needs any of it.
- A hosted instance exists at evistreams.com. Its interim terms describe academic and research use. This skill does not use it; everything here runs from the open code.
- Trial reports are third-party publications. The project's evaluation folder ships reference annotations but no PDFs, and says to obtain articles under their own terms. The worked example below uses a CC BY 4.0 trial report so it can be downloaded freely.
1. Install
Python 3.11 exactly, matching the release lock file. The lock is large — about 550 MB once
installed — because it carries the API server, workers and three provider SDKs. A Python
3.11 interpreter is needed; uv will fetch one if the system does not have it.
git clone https://github.com/karthik-strikes/evistream_backend.git
cd evistream_backend
git checkout aae3b7699526fe3d636340c850a63e6da052b288
uv venv -p 3.11 .venv
uv pip install --python .venv -r requirements.lock.txt
# The locked scipy 1.15.2 wheel fails to load on recent macOS with
# "section '__DATA/__thread_bss' has a zero-fill section type". 1.16.3 loads and
# passes the project's own checks; harmless to apply on Linux too.
uv pip install --python .venv "scipy==1.16.3"
Without uv, upstream documents python3.11 -m venv .venv followed by
.venv/bin/pip install -r requirements.lock.txt; that route was not timed here. The clone
itself is about 10 MB.
Every block after this one runs from inside evistream_backend/ with .venv active.
2. Check the install without an account
source .venv/bin/activate
PYTHON_DOTENV_DISABLED=1 python -m pytest -q tests
python eval/smoke_test.py
Expect 16 passed and a single PASS: line from the scorer smoke test. The repository
also ships scripts/smoke_api.py; at this commit it fails offline with
AssertionError: Network access is forbidden in the offline smoke test, because the test
blocks sockets and the Redis cache only tolerates a redis.ConnectionError. That is a
defect in the check, not in your install.
PYTHON_DOTENV_DISABLED=1 stops the modules that call load_dotenv() at import time from
reading a .env file in the repository root. It does not cover the two settings classes,
core/config.py and app/config.py, which read .env through pydantic — the first from
the current directory, the second from the repository root. For standalone use, keep no
.env in the checkout, so nothing can silently supply keys or switch models.
3. Turn a PDF into paged text
The platform sends PDFs to Datalab, which returns Markdown with tables preserved and a
page separator line between pages — {N} (zero-based) followed by at least 48 dashes. The source
linker in section 5 reads those separators to report page numbers, so a local conversion
has to write the same separator. PyMuPDF, already in the lock file, does the job:
# to_md.py — PDF to page-separated text the engine can cite pages from
import sys
import fitz # PyMuPDF
pdf_path, out_path = sys.argv[1], sys.argv[2]
doc = fitz.open(pdf_path)
pages = []
for i, page in enumerate(doc):
pages.append("{%d}%s\n\n%s" % (i, "-" * 48, page.get_text("text").strip()))
with open(out_path, "w", encoding="utf-8") as fh:
fh.write("\n\n".join(pages) + "\n")
print(f"{doc.page_count} pages -> {out_path}")
curl -fsSL -o trial.pdf \
"https://pmc-oa-opendata.s3.amazonaws.com/PMC7706009.1/PMC7706009.1.pdf"
python to_md.py trial.pdf trial.md
The example is Purngpipattrakul et al., Comparison of GlideScope visualization and neck
flexion with lateral neck pressure nasogastric tube insertion techniques in anesthetized
patients, Trials 21:990 (2020), doi:10.1186/s13063-020-04911-0, CC BY 4.0, fetched
from NCBI's copy of the PMC Open Access Subset (PMC7706009), which needs no account. It
prints 7 pages -> trial.md.
What the local route gives up: plain text extraction flattens tables into one value per line, so a value that lives only in a table is harder for the model to quote and harder for the linker to place. Datalab output also carries block coordinates the platform uses to highlight the quote on the page. This skill verified the local route on prose-reported fields only; table-heavy outcome fields were not exercised on it.
4. Describe the form and run the extraction
A form is a JSON schema_def. Each signature is one model call: its docstring is the
instruction, it takes the paper as markdown_content, and it returns one or more
output fields. Stages say which signatures run together and which wait for
earlier answers.
{
"task_name": "rct_basics",
"signatures": [
{
"class_name": "ExtractTrialSetting",
"docstring": "Extract where a randomised trial was run and how many participants it randomised.",
"input_fields": [{"name": "markdown_content", "type": "str", "desc": "Full text of the trial report."}],
"output_fields": [
{"name": "country", "type": "Dict[str, Any]", "source_grounded": true,
"description": "Country where participants were recruited, in English.",
"rules": ["Use NR if the report does not state it"]},
{"name": "n_randomised", "type": "Dict[str, Any]", "source_grounded": true,
"description": "Total number of participants randomised, as an integer.",
"rules": ["Count randomised participants, not those screened or analysed"]}
]
},
{
"class_name": "ClassifyDesign",
"docstring": "Classify the design of a clinical study.",
"input_fields": [{"name": "markdown_content", "type": "str", "desc": "Full text of the trial report."}],
"output_fields": [
{"name": "design", "type": "Dict[str, Any]", "source_grounded": true,
"description": "Study design.",
"options": ["parallel RCT", "crossover RCT", "cluster RCT", "non-randomised"]}
]
}
],
"pipeline_stages": [
{"stage": 1, "execution": "parallel",
"signatures": ["ExtractTrialSetting", "ClassifyDesign"],
"provides_fields": ["country", "n_randomised", "design"], "requires_fields": []}
]
}
Save that as form.json. What the field keys do:
| key | effect |
|---|---|
description |
what the field is; becomes the field's instruction |
options |
allowed values; add "multiple": true for multi-select |
hints, rules, examples |
appended to the instruction as lists |
source_grounded |
asks for {"value", "source_text"} with a verbatim quote, and NR when absent |
subform_fields |
makes the field a table of rows, each cell carrying its own quote |
A stage with "execution": "sequential" and "requires_fields": [...] receives those
earlier answers as extra inputs — the way to classify something from values already
extracted. Two signatures in the same parallel stage may not produce the same field name;
building the pipeline then fails immediately with
ValueError: Duplicate output field 'country' across parallel signatures ....
On the platform, a reviewer lists the fields and a model turns that list into signatures and stages, which the reviewer then approves. Writing the JSON yourself skips that step and keeps the form under version control.
# extract.py — run one paper through a form with the EviStreams engine
import asyncio
import json
import sys
from pathlib import Path
from schemas.config import DynamicSchemaConfig
form = json.loads(Path(sys.argv[1]).read_text())
markdown = Path(sys.argv[2]).read_text(encoding="utf-8")
config = DynamicSchemaConfig(
schema_name=form["task_name"], task_name=form["task_name"],
module_path="", signatures_path="",
signature_class_names=[s["class_name"] for s in form["signatures"]],
pipeline_stages=form["pipeline_stages"],
project_id="", form_id="", form_name=form["task_name"],
schema_def=form,
)
pipeline = config.build_pipeline()
# Do not use asyncio.run() here: the pipeline finishes, then asyncio.run() hangs
# indefinitely while cancelling a leftover background task. Drive the loop directly.
loop = asyncio.new_event_loop()
result = loop.run_until_complete(pipeline(markdown_content=markdown))
Path(sys.argv[3]).write_text(json.dumps(result, indent=2, ensure_ascii=False))
for name, cell in result.items():
loc = cell.get("source_location") or {}
print(f"{name:14} {str(cell.get('value')):16} page={loc.get('page')} {loc.get('grounding_method')}")
Run it against a local model. Start Ollama with a context long enough for a whole paper —
the server otherwise picks a default from available memory, and Ollama truncates a prompt
longer than its window — then confirm the CONTEXT column of ollama ps once the model
loads. The recorded runs used that machine's server default, 131,072 tokens; 32,768 covers
the trial reports used here.
Pulling needs Ollama 0.30.0 or later: the registry build's config declares
requires: 0.30.0, and on 2026-10-21 it refused ollama pull gemma3:12b from 0.20.7 with
"requires a newer version of Ollama". The registry's current gemma3:12b is also a
different build from the one verified here — a separate vision-projector layer and a
different model-layer digest — so expect drift from the observations in section 5.
# In another terminal: OLLAMA_CONTEXT_LENGTH=32768 ollama serve
ollama pull gemma3:12b
PYTHON_DOTENV_DISABLED=1 PYTHONPATH="$PWD" \
DEFAULT_MODEL=ollama_chat/gemma3:12b FALLBACK_MODELS='[]' ENABLE_MODEL_FALLBACK=false \
python extract.py form.json trial.md result.json
For a hosted provider, change the model id and supply that provider's key. This route was not executed for this skill:
export GEMINI_API_KEY=... # your own key; usage is billed by the provider
PYTHON_DOTENV_DISABLED=1 PYTHONPATH="$PWD" \
DEFAULT_MODEL=gemini/gemini-2.5-flash FALLBACK_MODELS='[]' ENABLE_MODEL_FALLBACK=false \
python extract.py form.json trial.md result.json
Three settings are doing real work in those commands:
DEFAULT_MODELreplaces the primary model. Any LiteLLM id works.FALLBACK_MODELS='[]'empties the router's fallback list, which otherwise names two hosted models — a local run should never route to a provider you hold no key for.- Each signature whose fields all come back empty or
NRis retried, up to three calls in total. On a hosted model, a form that does not fit the paper costs three times over.
Each field comes back as a cell:
{"value": "Thailand",
"source_text": "This randomized clinical trial was performed at a teaching hospital on 86 adult patients undergoing abdominal surgery",
"status": "reported",
"source_location": {"page": 1, "start_char": 797, "end_char": 914, "confidence": 1.0,
"section": "Abstract", "grounding_method": "quote_exact"}}
status is reported, not_reported (the model said NR) or missing (the model left
the field out, which is a failure rather than an absence). page is one-based. A field
with options gets its value matched to an option case-insensitively; anything that
matches no option is kept and listed under off_options rather than rejected.
5. Audit the quotes before trusting a value
The cell above is a real result from this paper, and it is the reason this section exists. The quote is verbatim — the linker found it character for character on page 1 — and it does not mention Thailand at all. The value may be right; the quote is not evidence for it.
grounding_method says how the linker placed the quote, and only one value means what a
reader assumes:
grounding_method |
what happened | what it proves |
|---|---|---|
quote_exact |
the quote occurs in the text, case-sensitively, after normalising line breaks, hyphenation and ligatures | the sentence exists — not that it supports the value |
quote_fuzzy |
a passage scored at least 0.65 on partial fuzzy matching | anything from a changed capital letter to a paraphrase; the location may be a whole page |
value_text, value_table |
the quote was not found, so the linker searched for the value instead (value_table needs Datalab block data) |
the quote is unverified; a number happens to occur somewhere |
| absent | neither quote nor value could be placed | nothing |
The value fallback is the subtle one. A distinctive value — a decimal, a percentage, a
code with a digit in it — is located even when the quote offered for it was invented, so
79.1% with a made-up sentence still gets a page and a section. A bare integer is only
placed when other tokens of the value sit near it, and a plain word such as Thailand
never is.
section is the nearest recognised heading above the match in reading order, not the
section the text belongs to. Figure text floats: this paper's participant-flow figure is
reported under Discussion.
So audit every record with two tests — was the quote found verbatim, and does it contain the value:
# audit.py — classify each extracted value by the evidence behind it
import json
import re
import sys
from pathlib import Path
record = json.loads(Path(sys.argv[1]).read_text())
def squash(text):
# Letters and digits only, so PDF line breaks and hyphenation do not matter.
return re.sub(r"[^0-9a-z]+", "", str(text).lower())
needs_review = []
for field, cell in record.items():
value = cell.get("value")
quote = cell.get("source_text") or ""
loc = cell.get("source_location") or {}
method = loc.get("grounding_method")
if "value" not in cell:
verdict = f"malformed cell, keys {sorted(cell)}"
elif str(value).strip().upper() == "NR":
verdict = "not reported"
elif method == "quote_exact" and squash(value) in squash(quote):
verdict = "supported by a verbatim quote"
elif method == "quote_exact":
verdict = "verbatim quote that does not contain the value"
elif method == "quote_fuzzy":
verdict = "approximate quote"
elif method in ("value_text", "value_table"):
verdict = "quote not found, value located instead"
else:
verdict = "no evidence located"
if verdict not in ("supported by a verbatim quote", "not reported"):
needs_review.append(field)
print(f"{field:14} {str(value):16} page={loc.get('page')!s:5} {verdict}")
print("needs review:", needs_review)
python audit.py result.json
Everything below in this section is observed, not invariant. It comes from one local
model — gemma3:12b, Ollama digest f4031aab637d, at the engine's temperature of 1.0 with a
131,072-token context — on 2026-10-21. Hosted models, which upstream defaults to and its
paper evaluated, were not tested here and may behave quite differently.
Five runs of the same command on this paper gave these audit verdicts:
| run | country |
n_randomised |
design |
|---|---|---|---|
| 1 | Thailand — verbatim quote without the value | 86 — approximate quote | parallel RCT — no quote |
| 2 | Thailand — verbatim quote without the value | 86 — approximate quote | malformed cell |
| 3 | malformed cell | 86 — approximate quote | parallel RCT — no quote |
| 4 | malformed cell | 86 — supported by a verbatim quote | malformed cell |
| 5 | NR |
86 — supported by a verbatim quote | parallel RCT — no quote |
Read the n_randomised column against the paper. It is wrong in all five runs: the
abstract's "86 adult patients" are the patients analysed, while the participant-flow figure
on page 5 reads Randomized (n=92) and the Results say 92 were enrolled. The form's own
rule told the model not to make this slip. In runs 4 and 5 the wrong value passed the audit,
because the abstract sentence really is in the paper and really does contain 86.
So the audit is a filter, not a verdict. "Supported by a verbatim quote" means the quote is real and contains the value — not that the value is the right answer to the field. A real sentence about a different quantity passes. Catching that takes a person reading the field definition against the paper, which is what section 6 is for.
A "malformed cell" is the model putting its answer under "type" instead of "value". The
engine keeps the cell, finds no value, and marks it not_reported, so a possibly right
answer reads downstream as "the paper does not say". That is why the audit checks each
cell's shape before anything else. Run 5's NR for country is simply wrong — Thailand is
on pages 1, 2 and 7.
A normalised value — parallel RCT from "randomized into two groups" — will often fail the
containment test legitimately; that is a cell for a person to confirm, not an error.
The same form and model were also run once on each of three more CC BY trial reports, on the same date:
- Donoghue et al., Double-blind, placebo-controlled trial of mifepristone on cognition and depression in alcohol dependence, Trials 21:796 (2020), PMC7493392 — a phase 2 trial that recruited 27 of a planned 120.
- Nitroglycerin for treatment of retained placenta: a randomised, placebo-controlled, multicentre, double-blind trial in the UK, PLOS Medicine 16(12):e1003001 (2019), PMC6936786 — 1,107 women randomised.
- The CHIRPY DRAGON cluster-randomised trial of a school obesity intervention in China, PLOS Medicine 16(11):e1002971 (2019), PMC6879117 — 20 pages.
What those single runs showed, again as observations of this one model:
- The audit passed one value of nine: 1,107 randomised, quoted as "1,107".
- Two country values were backed by quotes that name no place, and one by "the UK" for
United Kingdom. The mifepristone total of 27 was quoted from "Fourteen ... mifepristone and 13 ... placebo", a correct sum the containment test cannot see. - Exact matching is case-sensitive. A quote the model began with a capital letter that the
paper does not have scored
quote_fuzzyat 0.988 — near 1 is cosmetic, near 0.65 is a different sentence. designcame back unquoted on the two parallel-group trials and quoted on the cluster trial.- The 20-page paper took eight minutes, and one call was cut off at exactly ten minutes while another job shared the GPU. Allow for it in batch runs.
6. Two reviewers, one adjudicated table
How upstream does it: two people extract each study independently as reviewer 1 and reviewer 2 — optionally blind to each other, a per-project setting that is off by default. The platform compares their two records field by field and shows the model's extraction beside them as a reference value; the model is not one of the two reviewers. An adjudicator then settles every disagreement.
The comparison itself lives in the API service, whose database calls need the full
platform. The three helpers that decide agreement do not, so the script below imports
them from the checkout rather than re-implementing them: _unwrap_for_compare,
_align_multiselect and _canon from app/services/adjudication_service.py, at the pinned
commit aae3b76. What they do:
- quotes and other metadata are ignored — only values are compared;
- strings compare case-insensitively after trimming the ends; internal spaces still count;
- a number and its string are equal (
"86",86,86.0), as areyes/y/trueandno/false; - a multi-select agrees in any order, including a list on one side and
"a, b"on the other; - table rows from
subform_fieldsagree in any order, cell by cell; - a field one reviewer left out agrees only with an empty (
null) value, never with a non-null one such as the text"None".
Those helpers are private (_-prefixed), so this holds at commit aae3b76 and may break
on a newer upstream. The script refuses to run without them, or from a checkout at any
other commit, rather than falling back to a copy. Importing their module builds the
platform's settings, so the script sets inert placeholders for the seven required ones
first; environment variables win over a .env file, which that settings class still
reads. Nothing the script calls touches the database, storage or the network. Run it like
the extraction, from the checkout with PYTHONPATH="$PWD".
The script's own part is the glue and one rule upstream does not have, aimed at the
problem in section 5. A quote is carried into the finished table only if it is found in
the paper's text, under the same normalisation as the audit, whoever wrote it. Among
verbatim quotes, one that also contains the value wins — matched as whole words and whole
numbers, so 86.0 is found in "86 adult patients", 1107 in "1,107", and 9 is not
found in "(n=92)". If none does, a verbatim quote is still carried but flagged under
outstanding; with no verbatim quote at all, the cell is
left blank and flagged. List and table values are carried with a note that the quote was
not checked against them.
Write two reviewers' records. Reviewer 1 made the abstract slip; reviewer 2 read the participant-flow figure:
cat > reviewer1.json <<'JSON'
{
"country": {"value": "Thailand", "source_text": "Songklanagarind Hospital, Thailand"},
"n_randomised": {"value": "86", "source_text": "performed at a teaching hospital on 86 adult patients"},
"design": {"value": "parallel RCT", "source_text": "The patients were randomized into two groups"}
}
JSON
cat > reviewer2.json <<'JSON'
{
"country": {"value": "thailand", "source_text": "study was conducted in the operating theater of Songklanagarind Hospital, Thailand."},
"n_randomised": {"value": "92", "source_text": "Randomized (n=92)"},
"design": {"value": "parallel RCT", "source_text": "patients were randomised into two groups"}
}
JSON
Reviewer 2's design quote says "randomised" where the paper says "randomized". It is
not verbatim, so it is never carried.
# adjudicate.py — compare two reviewers' records and write the adjudicated table.
# Agreement is decided by upstream's own helpers, imported rather than re-implemented:
# _unwrap_for_compare, _align_multiselect and _canon from app/services/adjudication_service.py
# in evistream_backend at commit aae3b76 (Apache-2.0). They are private, so they may move
# or change in later commits; this script is pinned to that commit.
import csv
import json
import os
import re
import subprocess
import sys
from pathlib import Path
PINNED = "aae3b76"
NEEDED = ("evistream_backend at commit aae3b76 is required: run from that checkout with it "
"on PYTHONPATH. app.services.adjudication_service must provide the private "
"helpers _unwrap_for_compare, _align_multiselect and _canon, which may have "
"moved in later commits.")
# The module reads the platform's settings when imported. Give the required ones inert
# placeholders; environment variables take precedence over a .env file in the checkout,
# which the settings class still reads. Nothing below touches the database, storage or
# any network service.
for key in ("SECRET_KEY", "SUPABASE_URL", "SUPABASE_KEY", "SUPABASE_SERVICE_KEY",
"AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY", "S3_BUCKET"):
os.environ[key] = "offline-placeholder"
try:
from app.services import adjudication_service as upstream
from app.services.adjudication_service import (
_align_multiselect, _canon, _unwrap_for_compare)
except Exception as exc:
sys.exit(f"{NEEDED} Import failed with: {exc!r}")
checkout = Path(upstream.__file__).resolve().parents[2]
head = subprocess.run(["git", "-C", str(checkout), "rev-parse", "HEAD"],
capture_output=True, text=True).stdout.strip()
if not head.startswith(PINNED):
sys.exit(f"{NEEDED} The checkout at {checkout} is at {head or 'an unknown commit'}.")
def agree(a, b):
left, right = _align_multiselect(_unwrap_for_compare(a), _unwrap_for_compare(b))
return _canon(left) == _canon(right)
def squash(text):
return re.sub(r"[^0-9a-z]+", "", str(text).lower())
def cell_text(value):
if value is None:
return ""
return value if isinstance(value, str) else json.dumps(value, ensure_ascii=False)
TOKEN = re.compile(r"\d{1,3}(?:,\d{3})+(?:\.\d+)?|\d+(?:\.\d+)?|[a-z]+")
def tokens(text):
# Words and numbers; numbers lose thousands separators and compare as floats,
# so 86, 86.0 and "86" are one token, and 9 is not found inside 92.
out = []
for t in TOKEN.findall(str(text).lower()):
out.append(float(t.replace(",", "")) if t[0].isdigit() else t)
return out
def contains_value(value, quote):
v, q = tokens(value), tokens(quote)
return bool(v) and any(q[i:i + len(v)] == v for i in range(len(q) - len(v) + 1))
def choose_quote(value, cells, paper):
"""Return (quote, note). Only quotes found verbatim in the paper are eligible; one that
also contains the value wins. Anything weaker comes back with a note for the summary."""
quotes = [c.get("source_text") for c in cells if isinstance(c, dict)]
found = [q for q in quotes
if isinstance(q, str) and len(squash(q)) >= 5 and squash(q) in paper]
if not found:
return "", "no verbatim quote"
if isinstance(value, (list, dict)):
return found[0], "quote not checked against a list or table value"
for q in found:
if contains_value(value, q):
return q, None
return found[0], "quote does not contain the value"
if __name__ == "__main__":
paper = squash(Path(sys.argv[1]).read_text(encoding="utf-8"))
r1 = json.loads(Path(sys.argv[2]).read_text())
r2 = json.loads(Path(sys.argv[3]).read_text())
ai = json.loads(Path(sys.argv[4]).read_text()) if len(sys.argv) > 4 else {}
decisions = json.loads(Path(sys.argv[5]).read_text()) if len(sys.argv) > 5 else {}
rows, outstanding = [], []
for field in sorted(set(r1) | set(r2)):
a, b = r1.get(field), r2.get(field)
if field in decisions:
value, status = _unwrap_for_compare(decisions[field]), "adjudicated"
quote, note = choose_quote(value, [decisions[field]], paper)
elif agree(a, b):
value, status = _unwrap_for_compare(a), "agreed"
quote, note = choose_quote(value, [a, b], paper)
else:
value, status, quote, note = "", "conflict", "", None
outstanding.append(f"{field}: reviewers disagree, "
f"{_unwrap_for_compare(a)!r} vs {_unwrap_for_compare(b)!r}")
if note:
outstanding.append(f"{field}: {note} for the {status} value")
ref = cell_text(_unwrap_for_compare(ai[field])) if field in ai else ""
rows.append({"field": field, "value": cell_text(value), "status": status,
"quote": quote, "ai_value": ref})
with open("adjudicated.csv", "w", newline="") as fh:
writer = csv.DictWriter(fh, fieldnames=["field", "value", "status", "quote", "ai_value"])
writer.writeheader()
writer.writerows(rows)
for r in rows:
print(f"{r['field']:14} {r['status']:12} {r['value']:14} "
f"quote={'yes' if r['quote'] else 'NO'} ai={r['ai_value']}")
print("outstanding:", outstanding or "none")
The arguments are the paper's text, reviewer 1, reviewer 2, and optionally the model's record as the reference column and an adjudicator's decisions:
PYTHONPATH="$PWD" python adjudicate.py trial.md reviewer1.json reviewer2.json result.json
cat > decisions.json <<'JSON'
{"n_randomised": {"value": 92, "source_text": "Randomized (n=92)"}}
JSON
PYTHONPATH="$PWD" python adjudicate.py trial.md reviewer1.json reviewer2.json result.json decisions.json
The first command agrees country (Thailand and thailand are one value), carrying
reviewer 1's quote because it contains the value. It agrees design too, but its only
verbatim quote, "randomized into two groups", does not contain parallel RCT, so that is
listed under outstanding for a person to confirm. It leaves n_randomised blank as a
conflict, with the model's 86 beside it as ai_value. The second command records the
adjudicator's 92, quoted from the flow figure; only the design note stays open. The
decisions.json here stands in for that person; in real use it is theirs to write, and a
model should never fill it in.
The quote rule, tested. On one run the model backed country with "This randomized
clinical trial was performed at a teaching hospital in Thailand" — a sentence that is not in
the paper. Using a model as one of the reviewers is this skill's variant, not upstream's
design, and it is exactly where the rule matters. The second case is a real sentence that
does not name the country:
cat > model_invented.json <<'JSON'
{
"country": {"value": "Thailand", "source_text": "This randomized clinical trial was performed at a teaching hospital in Thailand"},
"n_randomised": {"value": 86, "source_text": "86 adult patients were randomized"}
}
JSON
PYTHONPATH="$PWD" python adjudicate.py trial.md model_invented.json reviewer2.json
grep '^country' adjudicated.csv
PYTHONPATH="$PWD" python adjudicate.py trial.md model_invented.json model_invented.json
cat > quote_without_value.json <<'JSON'
{"country": {"value": "Thailand", "source_text": "This randomized clinical trial was performed at a teaching hospital on 86 adult patients undergoing abdominal surgery"}}
JSON
cat > quote_with_value.json <<'JSON'
{"country": {"value": "Thailand", "source_text": "Songklanagarind Hospital, Thailand"}}
JSON
PYTHONPATH="$PWD" python adjudicate.py trial.md quote_without_value.json quote_with_value.json
grep '^country' adjudicated.csv
PYTHONPATH="$PWD" python adjudicate.py trial.md quote_without_value.json quote_without_value.json
- Invented quote against reviewer 2:
countryagrees and carries reviewer 2's real quote, although the invented sentence contains "Thailand" and comes first. - The model compared with itself: both fields agree, both quote cells stay blank, and each
is listed as
no verbatim quote for the agreed value. - A real sentence without the value against one with it: the one naming Thailand is carried, with nothing outstanding.
- The real sentence without the value on its own: it is carried, and listed as
quote does not contain the value for the agreed value.
Containment is matched on whole tokens, not substrings:
cat > token_cases.json <<'JSON'
{
"n_randomised": {"value": 9, "source_text": "Randomized (n=92)"},
"n_analysed": {"value": 86.0, "source_text": "performed at a teaching hospital on 86 adult patients"},
"country": {"value": "Thailand", "source_text": "Songklanagarind Hospital, Thailand"}
}
JSON
PYTHONPATH="$PWD" python adjudicate.py trial.md token_cases.json token_cases.json
Only n_randomised is listed under outstanding: a 9 is not supported by "(n=92)",
while 86.0 is supported by "86 adult" and the plain text Thailand by its quote.
Without the checkout on the path, the script stops instead of guessing:
# Expected to fail: the copy outside the checkout cannot import upstream's helpers.
(d=$(mktemp -d) && cp adjudicate.py "$d" && cd "$d" && env -u PYTHONPATH python adjudicate.py trial.md reviewer1.json reviewer2.json || echo "exit status $?")
evistream_backend at commit aae3b76 is required: run from that checkout with it on PYTHONPATH. app.services.adjudication_service must provide the private helpers _unwrap_for_compare, _align_multiselect and _canon, which may have moved in later commits. Import failed with: ModuleNotFoundError("No module named 'app'")
exit status 1
The finished table has no placeholders: conflicts, missing quotes and agreed empty
values are blank cells, explained under outstanding. List and table values are written
as JSON. Pass the
outstanding list on as the summary next to the table — which values were agreed, which
were adjudicated and by whom, and which remain open and why.
7. Score against a reference sheet
eval/score_form.py is the project's field-typed scorer. It reads an extraction CSV with
one row per study and a reference workbook, matches studies by a fuzzy name, infers a
comparison per column (exact number, normalised text, or a model judge for free text),
and writes per-field precision, recall, F1 and a colour-coded comparison workbook.
# make_sheets.py — one-study extraction CSV and reference workbook
import json
import pandas as pd
record = json.load(open("result.json"))
row = {"Paper": "Purngpipattrakul 2020"}
row.update({k: v.get("value") for k, v in record.items()})
pd.DataFrame([row]).to_csv("extraction.csv", index=False)
reference = pd.DataFrame([{"Paper": "Purngpipattrakul 2020", "country": "Thailand",
"n_randomised": 92, "design": "parallel RCT"}])
reference.to_excel("reference.xlsx", sheet_name="study_char", index=False)
print(open("extraction.csv").read())
python make_sheets.py
python eval/score_form.py --ai extraction.csv --gt reference.xlsx \
--gt-sheet study_char --no-llm --out-dir scores
Against the reference value of 92, the first run scores macro_f1: 0.667 — country
and design right, n_randomised counted as a wrong value — and the second run
macro_f1: 0.333, its design scored as empty. Read that next to section 5: the scorer
compares values only, so country scores as correct although its quote supports nothing.
Score and quote audit answer different questions, and a review needs both.
--no-llm drops free-text fields from the score instead of sending them to a model judge;
the scores it prints are partial and are not the paper's numbers. The scorer handles one
row per study. Per-arm and per-outcome tables need an explicit field configuration — the
project's eval/engine/forms/ folder carries the ones it used for its benchmark corpora.
8. The full platform, briefly
Only when a team needs the shared web workflow — accounts, blinded assignments, a pilot
calibration round, the adjudication screens, exports. Fill .env from .env.example
with your own credentials, create the database by following docs/DATABASE.md in the
repository, then start three processes from the repository root:
# each in its own terminal, all from the repository root with .venv active
redis-server --port 6380
python -m uvicorn app.main:app --host 127.0.0.1 --port 8001
celery -A app.workers.celery_app:celery_app worker --loglevel=info --concurrency=2 \
-Q pdf_processing,code_generation,extraction,celery
Then run the frontend repository against it and register reviewers. The shared demo account is switched off in this release.
Try it
A self-contained check of the parts of this skill that need no model and no account: the page-separated conversion, the source linker's four grounding outcomes, and the dual-review rules. It installs two pinned packages into its own virtual environment rather than the 550 MB lock file, because the source linker depends on nothing else.
Data — the CC BY 4.0 trial report from section 3 (Trials 21:990, 2020; PMC7706009),
from NCBI's public PMC Open Access bucket, which tags it CC BY and needs no account. The
file's MD5 matches the checksum in the bucket's own metadata for this article. Confirmed reachable on 2026-10-21;
744,750 bytes, 7 pages.
The six quotes below are written inline: three real passages from the paper, one real
sentence reworded, and two invented sentences carrying real values — each standing for
something a model returned in section 4 or could return.
Run
set -euo pipefail
git clone --quiet https://github.com/karthik-strikes/evistream_backend.git
git -C evistream_backend checkout --quiet aae3b7699526fe3d636340c850a63e6da052b288
python3 -m venv tryit-venv
tryit-venv/bin/pip install --quiet "pymupdf==1.26.0" "rapidfuzz==3.13.0"
curl -fsSL -o trial.pdf \
"https://pmc-oa-opendata.s3.amazonaws.com/PMC7706009.1/PMC7706009.1.pdf"
cat > tryit.py <<'PY'
import sys
import fitz
sys.path.insert(0, sys.argv[1])
from utils.source_linker import enrich_extraction_results, parse_page_boundaries
doc = fitz.open(sys.argv[2])
md = "\n\n".join("{%d}%s\n\n%s" % (i, "-" * 48, p.get_text("text").strip())
for i, p in enumerate(doc))
pages = parse_page_boundaries(md)
print("pages:", doc.page_count, len(pages))
assert len(pages) == doc.page_count == 7
cells = {
# verbatim, crosses a line break in the PDF text
"n_analysed": {"value": "86", "source_text": "performed at a teaching hospital on 86 adult patients undergoing abdominal surgery"},
# verbatim, text inside the participant-flow figure
"n_randomised": {"value": "92", "source_text": "Randomized (n=92)"},
# verbatim, crosses a hyphenated line break ("com-" / "plications")
"complications": {"value": "33", "source_text": "A total of 33 of the 86 study patients developed complications"},
# the same abstract sentence, reworded
"reworded": {"value": "86", "source_text": "This randomised clinical trial was done at a teaching hospital on 86 adult patients"},
# invented sentence, real and distinctive value
"invented": {"value": "79.1%", "source_text": "Group G succeeded in four of five patients."},
# invented sentence, plain integer value
"invented_int": {"value": "86", "source_text": "Eighty-six adults were enrolled across four sites."},
}
out = enrich_extraction_results(cells, md)
for k, v in out.items():
loc = v.get("source_location") or {}
print(f"{k:14} page={loc.get('page')} method={loc.get('grounding_method')} conf={loc.get('confidence')}")
loc = {k: v.get("source_location") or {} for k, v in out.items()}
assert (loc["n_analysed"]["grounding_method"], loc["n_analysed"]["page"]) == ("quote_exact", 1)
assert (loc["n_randomised"]["grounding_method"], loc["n_randomised"]["page"]) == ("quote_exact", 5)
assert (loc["complications"]["grounding_method"], loc["complications"]["page"]) == ("quote_exact", 3)
assert loc["reworded"]["grounding_method"] == "quote_fuzzy" and loc["reworded"]["page"] == 1
assert loc["invented"]["grounding_method"] == "value_text" and loc["invented"]["page"] == 1
assert loc["invented_int"] == {}
def canon(v):
s = str(v).strip().lower()
if s in ("yes", "y", "true"):
return "true"
if s in ("no", "false"):
return "false"
try:
return float(s)
except ValueError:
return s
assert canon("Thailand") == canon(" thailand ") and canon("86") == canon(86.0)
assert canon(True) == canon("Y") and canon("86") != canon("92")
# canon() above is a compact stand-in for the single-value rules only; section 6 uses
# upstream's own helpers, which also cover lists, table rows and missing fields.
print("all assertions passed")
PY
tryit-venv/bin/python tryit.py evistream_backend trial.pdf
Expect
Invariants (a failure here means this skill is wrong):
- The PDF converts to 7 pages and the linker reads back 7 page spans.
- A verbatim quote is placed as
quote_exacton the right page, including across a line break (page 1), across a hyphenated break (page 3) and inside the participant-flow figure (page 5). - A reworded sentence is still placed, as
quote_fuzzy— the reason to trust onlyquote_exact. - An invented quote with a distinctive value is placed as
value_texton the page where the value occurs, and an invented quote with a bare integer is not placed at all. - Reviewer values compare case- and type-insensitively, and
86against92is a conflict.
Observed, commit aae3b76 on 2026-10-21 (drift to investigate, not a bug):
pages: 7 7
n_analysed page=1 method=quote_exact conf=1.0
n_randomised page=5 method=quote_exact conf=1.0
complications page=3 method=quote_exact conf=1.0
reworded page=1 method=quote_fuzzy conf=0.904
invented page=1 method=value_text conf=0.7
invented_int page=None method=None conf=None
all assertions passed
The fuzzy score and the fixed 0.7 for value placement are the linker's own constants and scoring, so they move only when upstream changes the matching.
Troubleshooting
- The extraction script finishes its model calls and never exits — it used
asyncio.run(). Drive the event loop directly, asextract.pydoes. ImportError: dlopen(... _spropack ...) section '__DATA/__thread_bss'— the locked scipy wheel on recent macOS. Installscipy==1.16.3into the same environment.- Pydantic
Field requirederrors namingSUPABASE_SERVICE_KEY,S3_BUCKETand others — you imported something underapp/, which reads the platform settings at import. The extraction engine (schemas/,dspy_components/,utils/) does not need them; stay out ofapp/for standalone use. - Answers that ignore the later pages on a local model — check the
CONTEXTcolumn ofollama psagainst the length of the paper. A full trial report with references is commonly 10,000 to 25,000 tokens.