article-q 0.2.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,394 @@
1
+ Metadata-Version: 2.4
2
+ Name: article-q
3
+ Version: 0.2.1
4
+ Summary: Agent/LLM-enabled narrative reviews of academic manuscripts
5
+ License-Expression: MIT
6
+ License-File: LICENSE
7
+ Requires-Python: >=3.11
8
+ Requires-Dist: jinja2>=3.1
9
+ Requires-Dist: marker-pdf>=1.0
10
+ Requires-Dist: openpyxl>=3.1
11
+ Requires-Dist: pandas>=2.0
12
+ Requires-Dist: pydantic-ai>=0.1.0
13
+ Requires-Dist: pydantic>=2.0
14
+ Requires-Dist: pymupdf4llm>=0.0.17
15
+ Requires-Dist: pymupdf>=1.24
16
+ Requires-Dist: rich>=13.0
17
+ Requires-Dist: tomli-w>=1.0
18
+ Requires-Dist: typer>=0.12
19
+ Description-Content-Type: text/markdown
20
+
21
+ # Article-Q
22
+
23
+ Agent/LLM-enabled narrative reviews of academic manuscripts. Parses PDFs, extracts structured data using LLM agents guided by a questions spreadsheet, and validates results through a multi-agent consensus mechanism.
24
+
25
+ ## Installation
26
+
27
+ Requires Python 3.11+.
28
+
29
+ ```bash
30
+ pip install -e .
31
+ ```
32
+
33
+ ## Step-by-step workflow
34
+
35
+ ### Step 1: Initialize the project
36
+
37
+ ```bash
38
+ articleq init
39
+ ```
40
+
41
+ This creates `articleq.toml` with default settings. Open it and set:
42
+
43
+ - `project.papers_dir` — directory containing your PDF manuscripts
44
+ - `project.questions_file` — path to your questions CSV (see Step 2)
45
+ - `llm.api_keys.openai` or `llm.api_keys.google` — your API key (or set the `OPENAI_API_KEY` / `GEMINI_API_KEY` environment variables)
46
+
47
+ ### Step 2: Create a questions file
48
+
49
+ Prepare a CSV (or Excel) file defining the data you want to extract. Required columns are `id` and `question`. Optional columns:
50
+
51
+ | Column | Description | Default |
52
+ |---|---|---|
53
+ | `id` | Unique identifier for the question | *(required)* |
54
+ | `question` | The question text | *(required)* |
55
+ | `category` | Grouping label (e.g. "methods", "outcomes") | `general` |
56
+ | `output_type` | One of `text`, `category`, `numeric`, `boolean`, `list` | `text` |
57
+ | `options` | Comma-separated valid answers (for `category` type) | |
58
+ | `description` | Additional guidance for the extraction agent | |
59
+ | `depends_on` | Comma-separated IDs of questions this one depends on | |
60
+
61
+ Example:
62
+
63
+ ```csv
64
+ id,question,category,output_type,options,description,depends_on
65
+ sample_size,What was the total sample size?,demographics,numeric,,Total number of participants enrolled,
66
+ primary_outcome,What was the primary outcome?,outcomes,text,,The main outcome measure,
67
+ study_design,What was the study design?,methods,category,"RCT,cohort,case-control,cross-sectional",,
68
+ study_design_other,If other please specify,methods,text,,,study_design
69
+ blinding,Was the study blinded?,methods,boolean,,Whether any form of blinding was used,
70
+ ```
71
+
72
+ ### Step 3: Parse PDFs
73
+
74
+ ```bash
75
+ articleq parse -c articleq.toml
76
+ ```
77
+
78
+ This converts each PDF to structured markdown and saves the output to `output/parsed/`. Each paper produces:
79
+ - A `.json` file containing the parsed blocks (the source of truth)
80
+ - A `.md` file for human-readable review
81
+ - Extracted figures saved as PNGs in `output/parsed/figures/`
82
+
83
+ Two parsing backends are available (set `parsing.backend` in config):
84
+ - `pymupdf` (default) — fast, uses pymupdf4llm layout detection
85
+ - `marker` — uses marker-pdf with OCR; better for scanned documents
86
+
87
+ ### Step 4: Review and clean parsed content (optional)
88
+
89
+ Preview the parsed papers in a browser:
90
+
91
+ ```bash
92
+ articleq visualize --parsed-dir output/parsed/
93
+ ```
94
+
95
+ The JSON files in `output/parsed/` are the source of truth. Each file contains a `blocks` array — the LLM agents read from the `content` field of each block, so edits there directly affect extraction. Do **not** edit the `.md` files or the `raw_markdown` field in the JSON; both are regenerated by `articleq rebuild`.
96
+
97
+ Each block looks like this:
98
+
99
+ ```json
100
+ {
101
+ "block_type": "text",
102
+ "content": "The study enrolled 150 partcipants between Jan and Dec 2020.",
103
+ "page_number": 3,
104
+ "section": "Methods"
105
+ }
106
+ ```
107
+
108
+ Common edits:
109
+
110
+ - **Fix OCR errors** — correct garbled text, broken words, or misrecognized characters (e.g. `"partcipants"` → `"participants"`)
111
+ - **Remove noise** — delete blocks containing headers, footers, page numbers, or watermarks that the parser picked up
112
+ - **Fix broken tables** — repair malformed markdown tables in `"table"` blocks
113
+ - **Remove irrelevant blocks** — delete entire blocks (e.g. reference lists, copyright notices) that add noise without useful content
114
+
115
+ After editing blocks, rebuild the markdown:
116
+
117
+ ```bash
118
+ articleq rebuild -c articleq.toml
119
+ ```
120
+
121
+ This regenerates the `.md` files and updates `raw_markdown` in the JSON caches to match the block content.
122
+
123
+ ### Step 5: Run LLM extraction
124
+
125
+ ```bash
126
+ articleq extract -c articleq.toml
127
+ ```
128
+
129
+ This sends each question to the extraction agents for every paper, runs validation, and writes results to `output/results.json`. The `extract` command will refuse to run if the markdown is out of sync with the blocks — run `articleq rebuild` first if you've edited blocks.
130
+
131
+ Alternatively, run everything (parse + extract) in one shot:
132
+
133
+ ```bash
134
+ articleq run -c articleq.toml
135
+ ```
136
+
137
+ ### Step 6: Export and visualize results
138
+
139
+ Convert results to CSV or Excel:
140
+
141
+ ```bash
142
+ articleq export output/results.json --format csv
143
+ articleq export output/results.json --format excel
144
+ ```
145
+
146
+ Generate an interactive HTML evidence viewer:
147
+
148
+ ```bash
149
+ articleq visualize -r output/results.json
150
+ ```
151
+
152
+ The viewer shows each paper's content alongside extracted answers, with evidence passages highlighted in the text. Pass `-q questions.csv` to include question text in the results panel.
153
+
154
+ ## How it works
155
+
156
+ Each question for each paper goes through a three-agent workflow:
157
+
158
+ ```
159
+ Paper + Question
160
+ |
161
+ v
162
+ Extraction Agent --> Answer A
163
+ |
164
+ v
165
+ Validation Agent --> Answer B (blind, independent)
166
+ |
167
+ v
168
+ Compare A and B
169
+ |
170
+ +-- AGREE + high confidence --> Accept A as final
171
+ |
172
+ +-- DISAGREE --> Consensus Agent reviews both --> Final answer
173
+ ```
174
+
175
+ - The **extraction agent** reads the paper and extracts an answer with evidence quotes, page numbers, and a confidence score.
176
+ - The **validation agent** performs a blind, independent re-extraction (it does not see the first answer).
177
+ - If the two answers **agree** and both have confidence above `auto_accept_threshold`, the answer is accepted directly.
178
+ - If they **disagree**, the **consensus agent** reviews both answers against the source material and produces a final arbitrated answer.
179
+
180
+ ### Question dependencies
181
+
182
+ Some questions depend on the answers to earlier questions. For example, a follow-up like "If other, please specify" only makes sense after the study type has been determined. Use the `depends_on` column to declare these relationships:
183
+
184
+ ```csv
185
+ id,question,depends_on
186
+ study_type,What was the study design?,
187
+ study_type_other,"If other, please specify",study_type
188
+ ```
189
+
190
+ When dependencies are present, questions are processed in **waves** — all questions with no unmet dependencies run concurrently, then questions whose dependencies are satisfied by the previous wave, and so on. Within each wave, concurrency is controlled by `pipeline.concurrency` as usual. Dependent questions receive a "Prior Answers" section in their prompt containing the question text and answer of each dependency.
191
+
192
+ The `depends_on` column is optional. CSVs without it continue to work as before (all questions run concurrently in a single wave). Circular dependencies and references to nonexistent question IDs are caught at load time.
193
+
194
+ Agreement checking is type-aware:
195
+ - **Categorical/boolean**: exact match
196
+ - **Numeric**: within 5% tolerance
197
+ - **Text**: normalized string comparison
198
+
199
+ ## Configuration reference
200
+
201
+ ```toml
202
+ [project]
203
+ name = "my-review" # Project name used in output
204
+ papers_dir = "./papers" # Directory containing PDF files
205
+ questions_file = "./questions.csv" # Path to questions CSV/Excel
206
+ output_dir = "./output" # Where results are written
207
+ # context_file = "./context.md" # Optional: additional instructions for the LLM
208
+
209
+ [parsing]
210
+ backend = "pymupdf" # "pymupdf" or "marker"
211
+ reparse = false # Force re-parsing even if cached results exist
212
+
213
+ [llm]
214
+ extraction_model = "openai:gpt-4o" # Model for primary extraction
215
+ validation_model = "openai:gpt-4o" # Model for validation pass
216
+ consensus_model = "openai:gpt-4o" # Model for arbitration
217
+ # temperature = 0.0 # LLM sampling temperature (omit to use provider default)
218
+
219
+ [llm.api_keys]
220
+ openai = "${OPENAI_API_KEY}" # Supports environment variable expansion
221
+ google = "${GEMINI_API_KEY}"
222
+
223
+ [pipeline]
224
+ concurrency = 5 # Max concurrent agent calls
225
+ skip_validation = false # Set true to skip the validation/consensus step
226
+ checkpoint = true # Save per-paper checkpoints for resume
227
+ chunk_max_tokens = 8000 # Max tokens per chunk for large PDFs
228
+
229
+ [validation]
230
+ auto_accept_threshold = 0.9 # Min confidence to auto-accept agreement
231
+ always_validate_categories = ["primary_outcome"] # Always run full 3-agent flow for these
232
+ ```
233
+
234
+ ## Additional topics
235
+
236
+ ### Caching and re-parsing
237
+
238
+ During `parse`, each parsed PDF is saved as both a markdown file and a JSON cache file under `{output_dir}/parsed/`. On subsequent runs, cached JSON files are loaded automatically, skipping PDF re-parsing.
239
+
240
+ To force re-parsing (e.g. after replacing a PDF or upgrading the parser), use the `--reparse` flag:
241
+
242
+ ```bash
243
+ articleq parse -c articleq.toml --reparse
244
+ ```
245
+
246
+ To re-parse a single paper, delete its cached `.json` file and run `parse` again.
247
+
248
+ Output directory structure:
249
+
250
+ ```
251
+ output/
252
+ ├── parsed/
253
+ │ ├── study_smith_2020.pdf.md
254
+ │ ├── study_smith_2020.pdf.json # cached ParsedPaper (used on re-runs)
255
+ │ ├── study_jones_2021.pdf.md
256
+ │ ├── study_jones_2021.pdf.json
257
+ │ └── figures/
258
+ │ ├── study_smith_2020_img1.png
259
+ │ ├── study_smith_2020_img2.png
260
+ │ └── study_jones_2021_img1.png
261
+ └── results.json
262
+ ```
263
+
264
+ ### Context file
265
+
266
+ You can provide a markdown file with additional instructions and domain knowledge to guide the LLM agents. Set `context_file` in the `[project]` section of your config:
267
+
268
+ ```toml
269
+ [project]
270
+ context_file = "./context.md"
271
+ ```
272
+
273
+ The contents are passed as additional system instructions to all three agents (extraction, validation, consensus). Use this for:
274
+
275
+ - Domain-specific definitions and terminology
276
+ - Important distinctions the LLM should be aware of
277
+ - Guidance on how to handle ambiguous cases
278
+ - Any background knowledge relevant to the review
279
+
280
+ Example `context.md`:
281
+
282
+ ```markdown
283
+ # Extraction Context
284
+
285
+ This review focuses on dentin hypersensitivity (DH) clinical trials.
286
+
287
+ ## Key Definitions
288
+
289
+ The Holland 1997 definition of DH: "short, sharp pain arising from exposed
290
+ dentine in response to stimuli typically thermal, evaporative, tactile, osmotic
291
+ or chemical and which cannot be ascribed to any other form of dental defect or
292
+ pathology."
293
+
294
+ ## Important Distinctions
295
+
296
+ - Distinguish between stimuli used for DIAGNOSIS versus OUTCOME MEASURES
297
+ - "dh_threshold_teeth" refers to minimum teeth per PATIENT, not total in study
298
+ ```
299
+
300
+ ### Large PDFs
301
+
302
+ Papers exceeding `chunk_max_tokens` are handled with a two-pass approach:
303
+
304
+ 1. The paper is split into chunks by content blocks.
305
+ 2. Chunks are scored for relevance to the current question using keyword overlap.
306
+ 3. Only the most relevant chunks (within the token budget) are sent to the agent.
307
+
308
+ ### Multimodal support
309
+
310
+ Figures extracted from PDFs are sent to the LLM as binary images alongside the text content. This happens automatically — if a parsed paper contains image data, the images are included in the prompt sent to the extraction, validation, and consensus agents.
311
+
312
+ - Both the `pymupdf` and `marker` backends extract images and store them as base64 in the parsed data.
313
+ - Text placeholders like `[Image from page N]` remain in the text for positional context, and the actual image binaries are appended after the text.
314
+ - No configuration is needed. If image data exists in the parsed paper, it is included. Models that do not support vision will receive only the text portion.
315
+
316
+ ### Evaluation
317
+
318
+ You can evaluate extraction results against manually-created ground truth using the `eval` command. This is useful for benchmarking accuracy across models, prompts, or configurations.
319
+
320
+ **Benchmark layout:**
321
+
322
+ ```
323
+ benchmarks/
324
+ └── example/
325
+ ├── papers/ # PDF manuscripts
326
+ ├── questions.csv # Questions used for extraction
327
+ └── expected.csv # Ground truth answers
328
+ ```
329
+
330
+ The `expected.csv` uses the same column format as `articleq export` output. At minimum it needs `paper`, `question_id`, and `final_value` columns:
331
+
332
+ ```csv
333
+ paper,question_id,final_value
334
+ study_smith_2020.pdf,sample_size,150
335
+ study_smith_2020.pdf,study_design,RCT
336
+ study_smith_2020.pdf,primary_outcome,overall survival
337
+ ```
338
+
339
+ **Running an evaluation:**
340
+
341
+ ```bash
342
+ articleq run -c benchmarks/example/config.toml
343
+ articleq export output/results.json --format csv
344
+ articleq eval output/results.csv benchmarks/example/expected.csv -q benchmarks/example/questions.csv
345
+ ```
346
+
347
+ The `-q` flag is optional but recommended — it enables type-aware comparison (numeric tolerance, boolean normalization, etc.) by reading each question's `output_type` from the questions file.
348
+
349
+ The report shows:
350
+ - **Overall accuracy** — percentage of answers matching ground truth
351
+ - **Per-question breakdown** — accuracy for each question across all papers
352
+ - **Detailed mismatches** — expected vs actual value for every disagreement
353
+
354
+ **LLM-as-judge evaluation:**
355
+
356
+ Strict string comparison can produce false negatives for free-text answers where the meaning matches but the wording differs (e.g. "RCT, parallel group" vs "Randomised controlled trial - Parallel group trial"). The `--judge-model` option enables an LLM judge that re-evaluates deterministic mismatches for semantic equivalence:
357
+
358
+ ```bash
359
+ articleq eval output/results.csv benchmarks/example/expected.csv \
360
+ -q benchmarks/example/questions.csv \
361
+ --judge-model openai:gpt-4o-mini \
362
+ -c benchmarks/example/config.toml
363
+ ```
364
+
365
+ When enabled:
366
+ - Answers that match deterministically are accepted as before (no LLM call).
367
+ - Mismatches on `text`, `category`, and `list` type questions are sent to the judge model, which decides whether the answers are semantically equivalent.
368
+ - `boolean` and `numeric` types keep their existing deterministic checks only.
369
+ - The `-q` questions file is required when using `--judge-model` (the question text provides context to the judge).
370
+ - The `-c` config file is optional — used to resolve API keys. Without it, keys are read from environment variables.
371
+
372
+ The report distinguishes strict matches from judge matches and includes the judge's reasoning for any answers it accepted:
373
+
374
+ ```
375
+ Strict matches: 12
376
+ Judge matches: 3
377
+ Mismatches: 5
378
+ Matched: 15
379
+ Accuracy: 75.0%
380
+ ```
381
+
382
+ ## CLI reference
383
+
384
+ ```
385
+ articleq init [-o PATH] Generate a starter config file
386
+ articleq run -c CONFIG [--reparse] Run the full pipeline (parse + extract)
387
+ articleq parse -c CONFIG [--reparse] Parse PDFs and save as markdown (no LLM calls)
388
+ articleq rebuild -c CONFIG Rebuild markdown and JSON from edited blocks
389
+ articleq extract -c CONFIG Run LLM extraction on pre-parsed papers
390
+ articleq export RESULTS [--format csv|excel] [-o PATH] Export to CSV/Excel
391
+ articleq eval RESULTS EXPECTED [-q QUESTIONS] [--judge-model MODEL] [-c CONFIG] Evaluate against ground truth
392
+ articleq visualize -r RESULTS [-o PATH] [-q QUESTIONS] Generate HTML evidence viewer
393
+ articleq visualize --parsed-dir DIR [-o PATH] Preview parsed papers (no results)
394
+ ```
@@ -0,0 +1,38 @@
1
+ articleq/__init__.py,sha256=hJa3j5tTWGLGfxhcBJnqApk9ecJe8FMNcPNdUDLCsDQ,102
2
+ articleq/__main__.py,sha256=kQjqn0nE4dGCi5GfZdSEkDyvqFXbvDglc5YuD99L4Cs,82
3
+ articleq/cli.py,sha256=AP9IRafQVZL04i4iskx_NOeOT6ZPDEJ_v63F1sb2D7c,9853
4
+ articleq/config.py,sha256=E6NAwnL5nqGZkUjhgB7hXqFay32SIZtJIC5RvpvPaIw,3426
5
+ articleq/eval.py,sha256=Cx6fc_2wOijpyiO5Vv_hhCvDHdsLNflXIyHccqhjCYg,11683
6
+ articleq/agents/__init__.py,sha256=K46MIirsgru8iy_An5uXy9HcKE4pulG_VpehPErMRxk,71
7
+ articleq/agents/consensus.py,sha256=ADlWfvMDSlPyA_CYROvef5dSAOjF0DusBlXi2cVLEoo,5405
8
+ articleq/agents/extraction.py,sha256=0VYw4LX8oexJLceRWbl8ca7vV72e2euvp65b4_WTGPo,2314
9
+ articleq/agents/prompts.py,sha256=D9er8HXnS_auT8Dz-0L7OAnRjHapTRPGIFL5ftOT-7g,5033
10
+ articleq/agents/provider.py,sha256=1FKygHxpw8E0cFsZMgBu-vro72ERHbSc590Piy08Qp8,2059
11
+ articleq/agents/validation.py,sha256=TASWjYaNFyApvuFn1CnWy2aUPyQbCrse_huJzotCr8o,1880
12
+ articleq/models/__init__.py,sha256=SbXB5Az3POGNVABxjajUEjKK6EW4JgARuA1U2Cjg9FA,556
13
+ articleq/models/answer.py,sha256=w206D6FMIl0CfYVoNhWB1ixNaxph9T8MuN22_u5MBVQ,1870
14
+ articleq/models/config.py,sha256=pUBV2kucxh9CM23jEI9NP59EPcjr4YjtYeBBxFGWkUQ,1527
15
+ articleq/models/paper.py,sha256=dE-6krRg3elwoGWwmfuxVef9WgpSSoE5lBCrdgp2KpY,1367
16
+ articleq/models/question.py,sha256=QG8XONoC2ycveyFfhMjNOQJOP7KwmoAhwIWT-i806U0,3756
17
+ articleq/models/review.py,sha256=1pQVjRDz73ENoi30ZIQTDjdk8oP02hjhIQN8s9MNZvg,1364
18
+ articleq/output/__init__.py,sha256=VXE4mlDcmboNTxWgY9HW8_S2gkvEULpsjuAnF94PhpM,36
19
+ articleq/output/json_output.py,sha256=ct-z8C87mCVvw4e5l9l9wAPZiymSAkMFNjq3Brb4eDg,579
20
+ articleq/output/tabular_output.py,sha256=_IR3evkvvLuJ_928D2F0aRq6sw_YmfvUxCnhhH7J4f0,1959
21
+ articleq/output/visualizer.py,sha256=bWFSDEmGRkAeLmoBtbjf5RKb8RgpAJ7147UGSU8yDO0,32100
22
+ articleq/parsing/__init__.py,sha256=Oc4bsKILgWJfyhgnVpl_V7TwivVOINpnGGLsqkElz58,37
23
+ articleq/parsing/base.py,sha256=FoyaaCgReuGMBPNRQ9_OO1NpClwGumKIqSNUJEtFw_k,860
24
+ articleq/parsing/marker_parser.py,sha256=J-aHKWUyBXNBlcdK8JdRD7ft3_YxCJLOq0b147GCyLo,2792
25
+ articleq/parsing/pymupdf_parser.py,sha256=iMwU1nxfZ1lgF_jL_4LvkULNH_zur0OG_Zt3CSy3mBw,2582
26
+ articleq/parsing/questions.py,sha256=0qX00z784n-Nkmdgnkh2ZWmABzFivlMZ5xe6RUYNZnY,3342
27
+ articleq/pipeline/__init__.py,sha256=Ax0qANiE6rHN63MS3zxVoWw-Av2ErW1a2FPhxA6IZ2U,45
28
+ articleq/pipeline/orchestrator.py,sha256=9WfMQ6QhYekHvMnxnV0QDjW8dv8-G-5twt1qr2-GV7c,15714
29
+ articleq/pipeline/progress.py,sha256=dxXki2JhzWZU-W-mwC1oiiBOXvWh9daZ85jgX_0-abc,585
30
+ articleq/pipeline/scheduler.py,sha256=ely-FMYkowzRkiDMgCwy7qal-A-_uyI1wHruzedQAhs,1309
31
+ articleq/utils/__init__.py,sha256=qHkwUQnAipyD7orncfDbJCaQ47mc02h90FgWy9ad_2o,25
32
+ articleq/utils/chunking.py,sha256=vHxkG7D9VIbReqWR11ogyo0L1j_9xy5_QXDaKPHIFmo,3279
33
+ articleq/utils/logging.py,sha256=s_GmTgbhm76yOJFQ5JK15kV9-HpdPrbFXi-Anxg6AZk,569
34
+ article_q-0.2.1.dist-info/METADATA,sha256=bToGWOYtRAOv-o1GRjUhSGjFWxpSbn2YcfnhU_dpAt8,15562
35
+ article_q-0.2.1.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
36
+ article_q-0.2.1.dist-info/entry_points.txt,sha256=IxiLsAd-HFFLZ9UBH_3Y3tYjo8IB9cTuYJnxIe0b6G8,46
37
+ article_q-0.2.1.dist-info/licenses/LICENSE,sha256=-DFkyJHAyYUuOpZrx6-HECSjR92SJgfdliRWi16VIkQ,1068
38
+ article_q-0.2.1.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ articleq = articleq.cli:app
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Tom Pollard
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
articleq/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Article-Q - Agent/LLM-enabled narrative reviews of academic manuscripts."""
2
+
3
+ __version__ = "0.1.0"
articleq/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ """Allow running as `python -m articleq`."""
2
+
3
+ from articleq.cli import app
4
+
5
+ app()
@@ -0,0 +1 @@
1
+ """LLM agent definitions for extraction, validation, and consensus."""
@@ -0,0 +1,158 @@
1
+ """Consensus/arbitration agent for resolving disagreements."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+
8
+ from pydantic_ai import Agent
9
+ from pydantic_ai.settings import ModelSettings
10
+ from pydantic_ai.usage import RunUsage, UsageLimits
11
+
12
+ from articleq.agents.extraction import _collect_images
13
+ from articleq.agents.prompts import (
14
+ CONSENSUS_SYSTEM_PROMPT,
15
+ build_consensus_user_prompt,
16
+ )
17
+ from articleq.models.answer import (
18
+ AnswerSource,
19
+ ConsensusResult,
20
+ EvidenceSpan,
21
+ ExtractionAnswer,
22
+ )
23
+ from articleq.models.paper import ContentBlock, ParsedPaper
24
+ from articleq.models.question import OutputType, StudyQuestion
25
+
26
+
27
+ consensus_agent = Agent(
28
+ system_prompt=CONSENSUS_SYSTEM_PROMPT,
29
+ output_type=ExtractionAnswer,
30
+ defer_model_check=True,
31
+ )
32
+
33
+
34
+ def _normalize_text(value: object) -> str:
35
+ """Normalize a text value for comparison."""
36
+ if value is None:
37
+ return ""
38
+ return re.sub(r"\s+", " ", str(value).strip().lower())
39
+
40
+
41
+ def _answers_agree(
42
+ answer_a: ExtractionAnswer,
43
+ answer_b: ExtractionAnswer,
44
+ question: StudyQuestion,
45
+ threshold: float = 0.9,
46
+ ) -> bool:
47
+ """Check if two answers agree based on the question type."""
48
+ # Both not found
49
+ if answer_a.not_found and answer_b.not_found:
50
+ return True
51
+ # One found, one not
52
+ if answer_a.not_found != answer_b.not_found:
53
+ return False
54
+
55
+ val_a = answer_a.value
56
+ val_b = answer_b.value
57
+
58
+ if question.output_type in (OutputType.CATEGORY, OutputType.BOOLEAN):
59
+ return _normalize_text(val_a) == _normalize_text(val_b)
60
+
61
+ if question.output_type == OutputType.NUMERIC:
62
+ try:
63
+ num_a = float(str(val_a))
64
+ num_b = float(str(val_b))
65
+ if num_a == 0 and num_b == 0:
66
+ return True
67
+ # Within 5% tolerance
68
+ return abs(num_a - num_b) / max(abs(num_a), abs(num_b)) < 0.05
69
+ except (ValueError, TypeError, ZeroDivisionError):
70
+ return _normalize_text(val_a) == _normalize_text(val_b)
71
+
72
+ if question.output_type == OutputType.LIST:
73
+ list_a = sorted(str(v).strip().lower() for v in (val_a if isinstance(val_a, list) else [val_a]))
74
+ list_b = sorted(str(v).strip().lower() for v in (val_b if isinstance(val_b, list) else [val_b]))
75
+ return list_a == list_b
76
+
77
+ # Text: normalized comparison
78
+ return _normalize_text(val_a) == _normalize_text(val_b)
79
+
80
+
81
+ def _merge_evidence(a: ExtractionAnswer, b: ExtractionAnswer | None) -> list[EvidenceSpan]:
82
+ """Merge evidence from both answers, deduplicating."""
83
+ evidence = list(a.evidence)
84
+ if b:
85
+ seen_texts = {e.text.strip().lower() for e in evidence}
86
+ for e in b.evidence:
87
+ if e.text.strip().lower() not in seen_texts:
88
+ evidence.append(e)
89
+ return evidence
90
+
91
+
92
+ async def resolve_consensus(
93
+ paper: ParsedPaper,
94
+ question: StudyQuestion,
95
+ answer_a: ExtractionAnswer,
96
+ answer_b: ExtractionAnswer,
97
+ model: str,
98
+ auto_accept_threshold: float = 0.9,
99
+ paper_content: str | None = None,
100
+ image_blocks: list[ContentBlock] | None = None,
101
+ context: str | None = None,
102
+ model_settings: ModelSettings | None = None,
103
+ usage: RunUsage | None = None,
104
+ ) -> ConsensusResult:
105
+ """Run the full consensus workflow for a question.
106
+
107
+ If answers agree and confidence is high, accept without arbitration.
108
+ Otherwise, run the consensus agent.
109
+ """
110
+ content = paper_content if paper_content is not None else paper.text
111
+
112
+ agreed = _answers_agree(answer_a, answer_b, question)
113
+
114
+ if agreed and min(answer_a.confidence, answer_b.confidence) >= auto_accept_threshold:
115
+ return ConsensusResult(
116
+ question_id=question.id,
117
+ final_value=answer_a.value,
118
+ source=AnswerSource.EXTRACTION,
119
+ extraction_answer=answer_a,
120
+ validation_answer=answer_b,
121
+ agreed=True,
122
+ final_confidence=max(answer_a.confidence, answer_b.confidence),
123
+ all_evidence=_merge_evidence(answer_a, answer_b),
124
+ )
125
+
126
+ # Run consensus agent
127
+ evidence_a = "; ".join(e.text for e in answer_a.evidence) or "No evidence cited"
128
+ evidence_b = "; ".join(e.text for e in answer_b.evidence) or "No evidence cited"
129
+ images = _collect_images(image_blocks) if image_blocks else None
130
+
131
+ user_prompt = build_consensus_user_prompt(
132
+ question_text=question.question,
133
+ output_type=question.output_type.value,
134
+ options=question.options,
135
+ answer_a_value=str(answer_a.value),
136
+ answer_a_reasoning=answer_a.reasoning,
137
+ answer_a_evidence=evidence_a,
138
+ answer_b_value=str(answer_b.value),
139
+ answer_b_reasoning=answer_b.reasoning,
140
+ answer_b_evidence=evidence_b,
141
+ paper_content=content,
142
+ images=images,
143
+ )
144
+
145
+ result = await consensus_agent.run(user_prompt, model=model, instructions=context, model_settings=model_settings, usage=usage, usage_limits=UsageLimits(request_limit=None))
146
+ final = result.output
147
+
148
+ return ConsensusResult(
149
+ question_id=question.id,
150
+ final_value=final.value,
151
+ source=AnswerSource.CONSENSUS,
152
+ extraction_answer=answer_a,
153
+ validation_answer=answer_b,
154
+ consensus_reasoning=final.reasoning,
155
+ agreed=agreed,
156
+ final_confidence=final.confidence,
157
+ all_evidence=_merge_evidence(answer_a, answer_b) + final.evidence,
158
+ )