literature-review-agent
Step 3 of the PaperOrchestra pipeline (arXiv:2604.05018). Execute the literature search strategy from outline.json — discover candidate papers via web search, verify them through Semantic Scholar (Levenshtein > 70 fuzzy title match, temporal cutoff, dedup by paperId), cross-corro
Install
npx skills add https://github.com/Ar9av/PaperOrchestra/tree/main/skills/literature-review-agent
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install ar9av-paperorchestra@llmmart
git clone https://github.com/Ar9av/PaperOrchestra.git
The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole ar9av/paperorchestra collection as a plugin from our marketplace. Git is the plain clone.
Skill manifest
Literature Review Agent (Step 3)
Faithful implementation of the Hybrid Literature Agent from PaperOrchestra (Song et al., 2026, arXiv:2604.05018, §4 Step 3, App. D.3, App. F.1 p.46).
Cost: ~20–30 LLM calls. This is one of the two longest steps (the other is plotting). Wall-time floor is set by Semantic Scholar's 1 QPS verification limit.
Inputs
workspace/outline.json— specificallyintro_related_work_planwith the Introduction search directions and the 2-4 Related Work methodology clustersworkspace/inputs/conference_guidelines.md— used to derivecutoff_dateworkspace/inputs/idea.md,workspace/inputs/experimental_log.md— for framing the Intro and grounding the Related Work positioning
Outputs
workspace/citation_pool.json— verified Semantic Scholar metadata for every paper that survived verificationworkspace/refs.bib— BibTeX file generated from the verified poolworkspace/drafts/intro_relwork.tex— drafted Introduction and Related Work sections, written into the template, with the rest of the template preserved verbatim
Two-phase pipeline (App. D.3)
PHASE 1 — Parallel Candidate Discovery
For each search direction in introduction_strategy.search_directions:
For each limitation_search_query in each related_work cluster:
- Use the host's web search tool to discover up to ~10 candidate papers.
- Run up to 10 discovery queries in parallel (host-permitting).
- Collect (title, snippet, url) tuples — no verification yet.
→ PRE-DEDUP before Phase 2 (see Step 1.5 below)
PHASE 2 — Sequential Citation Verification (1 QPS, with cache)
For each candidate (after pre-dedup), sequentially:
0. Check s2_cache.json first (scripts/s2_cache.py --check).
If HIT: use cached response, skip live S2 call. No throttle needed.
If MISS: proceed with live request below.
1. Query Semantic Scholar by title:
GET https://api.semanticscholar.org/graph/v1/paper/search?query=<title>
&fields=title,abstract,year,authors,venue,externalIds&limit=5
(Public endpoint, no key. Throttle to 1 QPS for live requests only.)
2. Store the S2 response in cache: s2_cache.py --store.
3. Pick the top hit. Check Levenshtein title ratio against the original
candidate title. If ratio < 70: discard.
4. Bonus: if year and venue exactly align with hints, add a +5 point
match-quality bonus.
5. Require: abstract is non-empty.
6. Require: paper.year (or month if known) strictly predates cutoff_date.
Months default to day-1: e.g., "October 2024" → 2024-10-01.
7. If all checks pass, add to verified pool.
After all candidates are verified, dedup by Semantic Scholar paperId.
The host agent does the LLM/web work; the deterministic helpers in scripts/
do the math.
Step-by-step
0. Derive cutoff_date
Parse conference_guidelines.md for the submission deadline. The paper aligns
research cutoff with venue submission deadline (App. D.1):
| Venue | Cutoff |
|---|---|
| CVPR 2025 | Nov 2024 |
| ICLR 2025 | Oct 2024 |
| Other | One month before the stated submission deadline |
Encode as YYYY-MM-DD. Months default to day-1 (e.g., 2024-10-01).
1. Phase 1: Parallel Candidate Discovery
From outline.json:
- All
introduction_strategy.search_directions(3-5 queries) - For each cluster in
related_work_strategy.subsections:- The cluster's
sota_investigation_missionbecomes a search query - All
limitation_search_queries(1-3 each)
- The cluster's
For each query, use your host's web search tool (e.g., WebSearch in
Claude Code, @web in Cursor, the search tool in Antigravity). Collect the
top ~10 candidates per query: title, abstract snippet, source URL.
If your host supports parallel sub-tasks, fire up to 10 concurrent search queries. If not, run sequentially — slower but functionally equivalent.
Optional: Exa as a Phase 1 backend
If your host has no native web search, OR you want a research-paper-focused
backend with better signal-to-noise, you can use Exa via
the bundled scripts/exa_search.py helper. It is opt-in and reads
EXA_API_KEY from the environment — the repo never commits a key.
export EXA_API_KEY="your-key-here" # get one at https://dashboard.exa.ai/
python skills/literature-review-agent/scripts/exa_search.py \
--query "Sparse attention long context transformers" \
--num-results 15 \
--discovered-for "related_work[2.1]"
Output is a normalized candidate list ready to merge into
raw_candidates.json. Phase 2 verification (Semantic Scholar fuzzy match,
cutoff, dedup) is unchanged. See references/exa-search-cookbook.md for
the full recipe, query patterns, cost estimates, and security notes.
Optional: Tavily as a Phase 1 backend
If your host has no native web search, OR you want an LLM-optimized search
backend with high relevance scoring, you can use Tavily
via the bundled scripts/tavily_search.py helper. It is opt-in and reads
TAVILY_API_KEY from the environment — the repo never commits a key.
export TAVILY_API_KEY="tvly-your-key-here" # get one at https://app.tavily.com
python skills/literature-review-agent/scripts/tavily_search.py \
--query "Sparse attention long context transformers" \
--num-results 15 \
--academic \
--discovered-for "related_work[2.1]"
Output is a normalized candidate list ready to merge into
raw_candidates.json. Phase 2 verification (Semantic Scholar fuzzy match,
cutoff, dedup) is unchanged. See references/tavily-search-cookbook.md for
the full recipe, query patterns, cost estimates, and security notes.
Combine all discovered candidates into a single working list. Tag each with the originating query ID so you can later attribute it to "intro" vs "related_work[i]".
1.5. Pre-dedup before Phase 2
Always run this before starting Phase 2. Multiple search queries routinely return the same papers (e.g., "Attention is All You Need" appears in almost every NLP discovery query). Verifying duplicates wastes 30-40% of S2 quota at 1 QPS.
python skills/literature-review-agent/scripts/pre_dedup_candidates.py \
--in workspace/raw_candidates.json \
--out workspace/deduped_candidates.json
# Prints: "150 candidates → 97 unique (53 duplicates removed)"
Use workspace/deduped_candidates.json as input to Phase 2.
2. Phase 2: Sequential Verification via Semantic Scholar (with cache)
For each candidate in deduped_candidates.json, in sequential order:
Step A — check cache first (no S2 call, no throttle needed):
python skills/literature-review-agent/scripts/s2_cache.py \
--cache workspace/cache/s2_cache.json \
--check "<candidate title>"
# exit 0 + prints JSON → use cached response, skip Step B
# exit 1 → proceed to Step B
Step B — live S2 request (cache MISS only, throttle to 1 QPS):
Preferred: use the bundled scripts/s2_search.py helper — it handles
auth, retries, and 429 back-off automatically:
python skills/literature-review-agent/scripts/s2_search.py \
--query "<URL-decoded candidate title>" --limit 5
# If SEMANTIC_SCHOLAR_API_KEY is set the key is forwarded automatically.
# If not, the public unauthenticated endpoint is used (≤1 QPS, still works).
Check whether the key is configured before starting Phase 2:
python skills/literature-review-agent/scripts/s2_search.py --check-key
Fallback: if you prefer your host's URL fetch tool, GET:
https://api.semanticscholar.org/graph/v1/paper/search?query=<URL-encoded title>&limit=5&fields=title,abstract,year,authors,venue,externalIds
Add header x-api-key: <SEMANTIC_SCHOLAR_API_KEY> if the env var is set.
Be polite: ≤1 request per second for live requests. Cache hits are free.
Step C — store in cache (after every successful live request):
python skills/literature-review-agent/scripts/s2_cache.py \
--cache workspace/cache/s2_cache.json \
--store "<candidate title>" \
--response '<full S2 JSON response>'
For the top hit:
python skills/literature-review-agent/scripts/levenshtein_match.py \
--candidate "Original candidate title" \
--found "S2 returned title"
# prints integer 0-100. Discard if < 70.
Then check the temporal cutoff:
python skills/literature-review-agent/scripts/check_cutoff.py \
--paper-year 2024 \
--paper-month 9 \
--cutoff 2024-10-01
# exit 0 if strictly predates, exit 1 if not
If both checks pass AND the abstract is non-empty, append the paper's full S2 metadata to the verified pool.
3. Dedup and assemble the pool
After all candidates are verified:
python skills/literature-review-agent/scripts/dedupe_by_id.py \
--in raw_pool.json \
--out workspace/citation_pool.json
The dedupe script keys on paperId (Semantic Scholar's internal unique ID),
falling back to externalIds.DOI, then externalIds.ArXiv, then a
normalized title.
The script also computes and writes min_cite_paper_count =
floor(0.9 * len(papers)) — the minimum number of papers the writing step
must cite (the paper's ≥90% integration rule, App. D.3).
Immediately after dedupe_by_id.py, validate and auto-fix the pool schema:
python skills/literature-review-agent/scripts/validate_pool.py \
--pool workspace/citation_pool.json --fix
# Catches and fixes authors-as-strings, reports missing required fields.
# Must pass before proceeding to Step 4.
3.5. Cross-index verification (Crossref + OpenAlex)
Semantic Scholar is one index and can return a plausible record for a paper that does not exist, or attach wrong metadata. Re-check every S2-verified paper against two independent indices before building the bibliography — this is the practical defense against hallucinated citations leaking in.
# Optional but recommended: a polite-pool email gives faster, more reliable
# service. The repo never commits an address.
export PAPER_ORCHESTRA_MAILTO="you@example.com"
python skills/literature-review-agent/scripts/cross_verify.py \
--pool workspace/citation_pool.json --inplace
# Annotates each paper with a `cross_verification` field and writes
# workspace/cross_verification_report.json.
# exit 0 = all corroborated; exit 1 = WARN (something flagged or an index
# was unreachable); exit 2 = usage error.
This is a WARN gate, not a hard gate (like validate_consistency.py): it
flags suspicious citations but does not block the pipeline or delete anything.
Review the low and conflict tiers in the report:
high— corroborated by ≥1 external index → keep.medium— corroborated but year disagrees → keep, spot-check the year.low— not found in Crossref or OpenAlex → review by hand. Note that arXiv-only preprints (no DOI) are a common benign cause;lowmeans "could not corroborate," not "fabricated." S2 already confirmed it exists.conflict— pool DOI disagrees with the external DOI → likely wrong record.
Drop only the entries you genuinely cannot corroborate, then re-run
dedupe_by_id.py onward. If both indices are unreachable (offline), the script
degrades gracefully and the pipeline continues on S2 verification alone.
See references/cross-index-verification.md for the full rationale, confidence
tiers, and the arXiv false-positive note.
4. Build the BibTeX file
python skills/literature-review-agent/scripts/bibtex_format.py \
--pool workspace/citation_pool.json \
--out workspace/refs.bib
The script generates citation keys deterministically from `firstauthor + year
- first significant word of title
(e.g.,vaswani2017attention). It writes out only@article/@inproceedings/@miscentries — never invents fields. It also writes the canonicalbibtex_keyback into each paper record incitation_pool.json`.
Immediately after bibtex_format.py, sync keys in intro_relwork.tex:
python skills/literature-review-agent/scripts/sync_keys.py \
--pool workspace/citation_pool.json \
--tex workspace/drafts/intro_relwork.tex \
--inplace
# Replaces every \cite{agent_key} with \cite{canonical_bibtex_key}.
# Eliminates citation_coverage gate failures caused by key mismatch.
These two steps replace the manual Python snippets that were previously required. The pipeline is now:
dedupe_by_id → validate_pool --fix → cross_verify --inplace → bibtex_format → sync_keys
5. Draft Introduction + Related Work
This is where you (the host agent) actually write text. Load the
verbatim Literature Review Agent prompt at references/prompt.md.
Substitute the template placeholders:
| Placeholder | Value |
|---|---|
intro_related_work_plan |
full JSON object from outline.json |
project_idea |
contents of idea.md |
project_experimental_log |
contents of experimental_log.md |
citation_checklist |
the BibTeX keys from refs.bib |
collected_papers |
list of {key, title, abstract} from citation_pool.json |
paper_count |
len(citation_pool.papers) |
min_cite_paper_count |
from citation_pool.json |
cutoff_date |
the date you derived in Step 0 |
Also prepend the Anti-Leakage Prompt from
../paper-orchestra/references/anti-leakage-prompt.md.
Also append the Introduction and Related Work templates from
skills/shared/section_rhetoric.md. Two constraints from that file do most
of the work here:
- The Introduction's Part 2 must state a technical challenge as limitation plus cause. "Prior methods are slow" is a symptom; "prior methods re-encode the full context at every step, so latency grows linearly in dialogue length" is a challenge the method can then attack. A Part 2 without a cause makes Part 3 unwritable.
- Each Related Work paragraph runs: scope sentence → representative methods →
the limitation of that group tied to our challenge → transition. Grouping
is by technical theme, never by year. The
min_cite_paper_countgate measures coverage, not positioning — a draft can pass it and still be a citation dump.
Run your LLM with the combined prompt against template.tex. The agent's
job is to fill in the empty Introduction and Related Work sections of the
template and leave everything else untouched. Output: the full
template.tex with those two sections filled. Save to
workspace/drafts/intro_relwork.tex.
5b. Append §2 to research_brief.md
After intro_relwork.tex is drafted and before the citation coverage check,
append §2 to workspace/research_brief.md (see skills/shared/research_brief_template.md).
Template:
## §2 · Literature Landscape
_Written by: literature-review-agent, Step 3_
**What the literature says about the core claim:** <2-3 sentence synthesis>
**Strongest prior work (must address in the paper):**
- <bibtex_key>: <why this is the strongest comparator or predecessor>
**Gaps confirmed by the literature:** <list>
**Baseline comparisons — verification status:**
| Baseline | In citation_pool? | Confidence tier |
|---|---|---|
**Related Work cluster coverage:**
| Cluster | Papers found | Notes |
|---|---|---|
**Anything the section-writing agent should know:** <important context>
This synthesises what was actually found — not what the outline assumed.
6. Verify ≥90% citation coverage
python skills/literature-review-agent/scripts/citation_coverage.py \
--tex workspace/drafts/intro_relwork.tex \
--pool workspace/citation_pool.json
# exit 0 if ≥90% of pool is cited; exit 1 otherwise
If the gate fails, re-prompt the writing step explicitly listing the missing keys and asking the agent to integrate them where contextually appropriate.
Critical rules from the prompt
These are excerpted from references/prompt.md. The host agent MUST honor
them on the writing call:
- Cite ONLY from
collected_papers. Never invent BibTeX keys, never reference papers not in the pool. - Cite at least
min_cite_paper_countof them in Intro + Related Work combined. - TIMELINE RULE: Do not treat any papers published after
cutoff_dateas prior baselines to beat. They are concurrent work only. - EVALUATION RULE: Do not claim our method beats / achieves SOTA over a
specific cited paper UNLESS that paper is explicitly evaluated against in
experimental_log.md. Frame other recent papers strictly as concurrent, orthogonal, or conceptual work. - Output format: return the full code for the updated
template.tex, with the two empty sections (Introduction and Related Work) filled in, and all the other code (packages, styles, other sections) identical to the original template.tex. - Wrap output in
```latex ... ```fences. - Do not change
\usepackage[capitalize]{cleveref}tocleverref(there is nocleverref.sty).
Degraded mode (no web search)
If your host has no web search tool, switch to degraded mode:
- If the user has placed a pre-built
workspace/inputs/refs.bibin the workspace, load it directly intoworkspace/refs.biband skip Phase 1 and Phase 2. - Otherwise, emit
workspace/drafts/intro_relwork.texcontaining the template with two TODO markers in the Intro and Related Work sections, and tell the user the pipeline cannot complete Step 3 without web search.
Resources
references/prompt.md— verbatim Literature Review Agent prompt from App. F.1references/discovery-pipeline.md— Phase 1 + Phase 2 explained in detailreferences/verification-rules.md— Levenshtein cutoff, year alignment, dedupreferences/citation-density-rule.md— the ≥90% integration rulereferences/s2-api-cookbook.md— Semantic Scholar URLs, fields, rate limitsreferences/cross-index-verification.md— Crossref + OpenAlex corroboration, confidence tiers, arXiv false-positive notereferences/exa-search-cookbook.md— optional Exa backend for Phase 1 (research-paper-focused web search)references/tavily-search-cookbook.md— optional Tavily backend for Phase 1 (LLM-optimized web search)scripts/pre_dedup_candidates.py— NEW dedup Phase 1 candidates before Phase 2 (saves 30-40% S2 quota)scripts/s2_cache.py— NEW persistent S2 response cache (eliminates re-verification on re-runs)scripts/validate_pool.py— NEW validate & auto-fix citation_pool.json schema (authors format)scripts/sync_keys.py— NEW sync cite keys in .tex with canonical bibtex_keys after bibtex_format.pyscripts/levenshtein_match.py— fuzzy title match (ratio > 70)scripts/check_cutoff.py— date cmp w/ month → day-1 defaultscripts/dedupe_by_id.py— dedup verified pool by S2 paperIdscripts/bibtex_format.py— build refs.bib from JSON poolscripts/citation_coverage.py— ≥90% citation coverage gatescripts/s2_search.py— NEW Semantic Scholar title-search helper; readsSEMANTIC_SCHOLAR_API_KEYfrom env (optional — falls back to unauthenticated)scripts/exa_search.py— optional Exa Phase 1 backend (readsEXA_API_KEYfrom env)scripts/tavily_search.py— optional Tavily Phase 1 backend (readsTAVILY_API_KEYfrom env)scripts/crossref_client.py— NEW Crossref title/DOI lookup for cross-index corroboration (no key; readsCROSSREF_MAILTO/PAPER_ORCHESTRA_MAILTO)scripts/openalex_client.py— NEW OpenAlex title/DOI lookup for cross-index corroboration (no key; readsOPENALEX_MAILTO/PAPER_ORCHESTRA_MAILTO)scripts/cross_verify.py— NEW cross-corroborate the S2-verified pool against Crossref + OpenAlex; flags hallucinated citations (WARN gate)skills/shared/research_brief_template.md— NEW §2 schema; append after intro_relwork.tex is draftedskills/shared/section_rhetoric.md— NEW Introduction logic chain + Related Work paragraph template
Files (paperorchestra)
-
references
-
citation-density-rule.md 2.8 KB
# Citation Density Rule Source: arXiv:2604.05018, App. D.3. ## The 90% rule > ...the system strictly constrains the model to cite only the provided > verified papers, explicitly mandating that at least 90% of the gathered > literature pool must be actively integrated and cited when synthesizing > the Introduction and Related Work sections. Why: this is the paper's core defense against citation inflation. The literature review pool is built once via the rigorous discovery → verification → dedup pipeline. The writing step must then *use* almost all of it. This prevents the agent from gathering 50 papers and citing only the 3 most famous ones, which would defeat the entire literature search. ## Implementation After the Lit Review writing call produces `intro_relwork.tex`: ```bash python scripts/citation_coverage.py \ --tex workspace/drafts/intro_relwork.tex \ --pool workspace/citation_pool.json \ --threshold 0.90 ``` The script: 1. Reads `citation_pool.json` and counts `papers[]` (= N). 2. Computes `min_required = floor(0.90 * N)`. 3. Greps `intro_relwork.tex` for all `\cite{KEY}`, `\citep{KEY}`, `\citet{KEY}`, `\autocite{KEY}`, `\citeauthor{KEY}`, etc. 4. Counts the **unique** keys actually cited. 5. Reports `cited / N` and exits non-zero if `cited < min_required`. ## What to do on failure The script prints the missing keys grouped by `discovered_for` cluster: ``` FAIL: 17/22 papers cited (77.3%, need ≥90%) Uncited papers (5): - vaswani2017attention [discovered_for: intro] (Attention Is All You Need) - he2016deep [discovered_for: intro] (Deep Residual Learning ...) - liu2024video [discovered_for: related_work[2.1]] (Long Video Generation ...) - chen2024sparse [discovered_for: related_work[2.2]] (Sparse Attention Surveys ...) - kim2024transformer [discovered_for: related_work[2.2]] (Transformer Scaling Laws ...) ``` The host agent should then re-call the Lit Review writing step with an appended instruction: ``` The previous draft cited only 17 out of 22 verified papers (77.3%, threshold is 90%). You MUST integrate the following 5 papers into the appropriate sections: - vaswani2017attention (intro): foundational attention reference - he2016deep (intro): foundational ResNet reference - liu2024video (related work 2.1): direct competing approach for long video - chen2024sparse (related work 2.2): sparse attention survey, group with [...] - kim2024transformer (related work 2.2): scaling-laws context Do not remove any existing citations. Add new ones where contextually appropriate. Re-emit the full template.tex with both sections updated. ``` After 2-3 re-prompts, if coverage still falls short, the pipeline should emit a warning and proceed — the paper does not specify a hard halt on this, only a strong constraint. -
cross-index-verification.md 4.4 KB
# Cross-Index Citation Verification Supplementary verification layer that runs *after* the Semantic Scholar gate (Rules 1–4 in `verification-rules.md`) and *before* `bibtex_format.py`. It re-checks every S2-verified paper against two independent scholarly indices — **Crossref** and **OpenAlex** — and flags any that cannot be corroborated. ## Why A single index can return a plausible-looking record for a paper that does not exist, or attach the wrong metadata to a real title. Triangulating across three independent indices is the standard practical defense: a genuine paper appears in all three with consistent metadata, while a fabricated or mis-attributed record usually does not survive the cross-check. This directly targets the documented failure mode where AI-assisted writing introduces hallucinated citations into the bibliography. S2 verification answers "does a paper with this title plausibly exist?" Cross-index verification answers "do *other* indices agree it exists, with the same year and DOI?" ## Scripts | Script | Role | |---|---| | `scripts/crossref_client.py` | Crossref REST API title/DOI lookup, normalized output | | `scripts/openalex_client.py` | OpenAlex API title/DOI lookup, normalized output | | `scripts/cross_verify.py` | Orchestrates both, classifies confidence, writes report | All three are stdlib-only (`urllib`), need **no API key**, and degrade gracefully if an index is unreachable (the index is disabled for the run and noted in the report; remaining indices still run). ## Polite pool (recommended, not required) Crossref and OpenAlex give faster, more reliable service when you identify yourself by email. Set one shared address: ```bash export PAPER_ORCHESTRA_MAILTO="you@example.com" ``` or per-service `CROSSREF_MAILTO` / `OPENALEX_MAILTO`. The email is sent only as a `mailto` query parameter / User-Agent per each service's etiquette docs. The repo never commits an address. ## Confidence tiers `cross_verify.py --inplace` writes a `cross_verification` object onto each pool paper, and a summary report to `workspace/cross_verification_report.json`: | Tier | Meaning | Host action | |---|---|---| | `high` | Corroborated by ≥1 external index, no metadata conflicts | keep | | `medium` | Corroborated, but publication year disagrees beyond `--year-tolerance` | keep; spot-check the year used in prose | | `low` | Not found in Crossref **or** OpenAlex | **review** — see false-positive note below | | `conflict` | A DOI in the pool disagrees with the external index's DOI | **review** — likely wrong record | Two thresholds keep the tiers honest: - **Corroboration** uses the lenient `> 70` (`--threshold`, same as the S2 gate): "an entry like this exists in the index." - **Conflict downgrades** (`medium`/`conflict`) require a strict `>= 90` (`--strong-threshold`) or an exact DOI hit before an external record's year or DOI is trusted. This stops a noisy near-title hit — a *different* paper with a similar name — from polluting the metadata checks and falsely downgrading a correctly-matched paper. A DOI present in the pool is looked up exactly first; only on a miss does the script fall back to title search. ## This is a WARN gate, not a hard gate `cross_verify.py` mirrors `validate_consistency.py`: it exits non-zero (1) when anything is flagged or an index was unavailable, but it **does not block the pipeline**. Hallucination removal is a judgment call, so the gate surfaces candidates for the host agent to review — it never deletes citations itself. Exit codes: `0` all corroborated · `1` flags present or an index unavailable (WARN) · `2` usage error / unreadable pool. ## Known false positive: arXiv-only preprints Crossref does not index most arXiv preprints (they have no Crossref DOI), and OpenAlex title search may not rank an arXiv-only work in its top hits. A legitimate, S2-verified preprint (e.g. *Proximal Policy Optimization Algorithms*, arXiv:1707.06347) can therefore land in the `low` tier. **`low` means "could not corroborate," not "fabricated."** S2 already confirmed the record exists. Treat `low`/`conflict` as a prompt to look closer: - If the paper is a well-known arXiv preprint you recognize → keep it. - If you cannot find it anywhere by hand → drop it from the pool and re-run `dedupe_by_id.py` onward. Do **not** auto-delete `low`-tier papers. ## Where it fits in the pipeline ``` dedupe_by_id → validate_pool --fix → cross_verify --inplace → bibtex_format → sync_keys ``` See SKILL.md Step 3.5. -
discovery-pipeline.md 4.8 KB
# Discovery Pipeline (Phase 1 + Phase 2) Source: arXiv:2604.05018, App. D.3 ("Citation Verification") and App. B (LLM-call distribution). ## Phase 1 — Parallel Candidate Discovery The paper uses 10 concurrent workers to fan out search-grounded LLM calls ("Gemini-3-Flash with Google Search grounding"). For our host-agent implementation, the equivalent is: spawn up to 10 concurrent search queries using the host's native web search tool. ### Inputs From `outline.json`: ``` introduction_strategy: search_directions: [q1, q2, q3] # 3-5 queries related_work_strategy: subsections: - methodology_cluster: "..." sota_investigation_mission: "..." # 1 derived query limitation_search_queries: [q4, q5] # 1-3 queries - ... ``` Total query budget: typically 10-20 queries per paper. ### Per-query procedure For each search query, instruct your host's search tool: ``` search("<query>", num_results=10) ``` Or, if you've enabled the optional Exa backend (see `exa-search-cookbook.md`): ```bash python scripts/exa_search.py --query "<query>" --num-results 10 ``` Both paths produce the same normalized candidate format. Collect the top 10 results per query. Each result should yield: - `title` — the paper's title from the search snippet - `snippet` — the abstract preview from the search snippet - `source_url` — the result URL (often the arXiv abstract page) Tag each result with `discovered_for: ["intro"]` or `discovered_for: ["related_work[2.1]"]` so you can later trace which cluster each citation supports. Combine all results across all queries into a single `raw_candidates.json`: ```json { "candidates": [ { "title": "Attention Is All You Need", "snippet": "The dominant sequence transduction models...", "source_url": "https://arxiv.org/abs/1706.03762", "discovered_for": ["intro"] }, ... ] } ``` ## Phase 2 — Sequential Verification via Semantic Scholar The paper enforces strict sequential verification at ≤1 QPS via the public Semantic Scholar API. We follow the same constraint. ### Per-candidate procedure 1. **Search S2 by title**. Use the host's URL fetch tool: ``` GET https://api.semanticscholar.org/graph/v1/paper/search ?query=<URL-encoded(title)> &limit=5 &fields=title,abstract,year,authors,venue,externalIds ``` No API key required for the public endpoint. Be polite: 1 QPS. 2. **Take the top hit**. Compare `title` to the candidate `title` via the helper: ```bash python scripts/levenshtein_match.py --candidate "..." --found "..." ``` The helper prints an integer 0-100 (the Levenshtein ratio). - **< 70 → discard the candidate.** Move on. - **≥ 70 → continue to checks 3-5.** 3. **Check abstract presence**. If `abstract` is null or empty → discard. The paper requires every cited entity to have a retrievable abstract for downstream context enrichment in the Section Writing Agent. 4. **Check temporal cutoff**: ```bash python scripts/check_cutoff.py \ --paper-year <year> \ --paper-month <month or omit> \ --cutoff <YYYY-MM-DD> ``` Exit 0 if strictly predates; exit 1 if not. Discard on exit 1. 5. **Year-alignment bonus**. If the candidate's `discovered_for` query mentioned a specific year and the S2 hit's year matches exactly, record `match_score = ratio + 5`. (This is a soft bonus used for tie-breaking when two candidates dedup to similar entries.) 6. **Append to verified pool** if all checks pass. Record: ```json { "paperId": "abc123...", "title": "...", "abstract": "...", "year": 2017, "venue": "NeurIPS", "authors": [{"name": "A. Vaswani"}, ...], "externalIds": {"DOI": "...", "ArXiv": "1706.03762"}, "match_score": 100, "discovered_for": ["intro"] } ``` ### Rate-limit etiquette The S2 public endpoint enforces ~1 QPS without an API key. If you receive HTTP 429, sleep 5 seconds and retry. Do not parallelize Phase 2 — verification must be strictly sequential. If your host has the patience for it, the paper measures ~20-30 LLM/API calls total per Lit Review Agent invocation. With ~30 candidates that's roughly 30 seconds of verification wall-time. With 100 candidates it's ~100 seconds. ## Why two phases The split exists because: - **Discovery is high-throughput, low-stakes**. You want to cast a wide net fast. Search APIs accept high concurrency. - **Verification is low-throughput, high-stakes**. The S2 API protects itself with QPS limits, and the verification step is what keeps the paper honest. Faking a citation is trivially easy without it. The paper's design "successfully combines the high-concurrency tolerance of the LLM API with the strict throughput limits of the Semantic Scholar API to prevent quota-induced latency" (App. B). -
exa-search-cookbook.md 9.1 KB
# Exa Search Cookbook (optional Phase 1 backend) [Exa](https://exa.ai) is a search engine optimized for finding academic papers and other high-quality content. The `literature-review-agent` can use Exa as an **OPTIONAL** backend for Phase 1 candidate discovery — useful when your host coding agent has no native web search tool, or when you want a research-paper-focused search backend with better signal-to-noise than general web search. > **Exa is opt-in.** The literature-review-agent's default Phase 1 path is > "use your host agent's native web search tool" (`WebSearch` in Claude > Code, `@web` in Cursor, the search tool in Antigravity, etc.). That > requires zero configuration and no API key. Use Exa only if you want > to. ## Why use it Exa fills three gaps: 1. **Hosts with no built-in search.** Aider, OpenCode, and generic CLI agents often lack a native web search tool. Exa gives them one. 2. **Research-paper-focused results.** Exa's `category: "research paper"` filter returns higher signal-to-noise than general web search for academic queries. The example response (e.g., for the query "PaperOrchestra") returns arXiv pages, conference proceedings, and academic tools rather than general SEO content. 3. **Batch / non-interactive runs.** When you want a deterministic, scriptable backend rather than going through the host agent's tool interface. Exa returns 10–20 results per call (the helper clamps to that range), and each result includes a `title`, `url`, optional `publishedDate`, and a list of `highlights` (snippets) which the helper joins into a `snippet` field consumable by the rest of the Phase 1 pipeline. ## Get a key 1. Sign up at <https://dashboard.exa.ai/>. 2. Copy your API key (format: `xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx`). 3. Set it in your environment: ```bash export EXA_API_KEY="paste-key-here" ``` Or put it in a `.env` file (which is gitignored — the repo `.gitignore` blocks `*.env` and `.env*` patterns) and source it: ```bash set -a; source .env; set +a ``` **This repo never commits a key.** The helper reads `EXA_API_KEY` from the environment at runtime. The key is your responsibility to provision and secure. ## Run the helper ```bash python skills/literature-review-agent/scripts/exa_search.py \ --query "Sparse attention long context transformers" \ --num-results 15 \ --discovered-for "related_work[2.1]" ``` Output (default — normalized to the literature-review-agent candidate format): ```json { "candidates": [ { "title": "Longformer: The Long-Document Transformer", "snippet": "We present the Longformer, a self-attention mechanism that scales linearly with sequence length...", "source_url": "https://arxiv.org/abs/2004.05150", "discovered_for": ["related_work[2.1]"], "_exa_id": "https://arxiv.org/abs/2004.05150", "_exa_published_date": "2020-04-10T00:00:00.000Z" }, ... ] } ``` This JSON can be merged directly into `workspace/raw_candidates.json` before the Phase 2 sequential verification step. ### Useful flags | Flag | Default | Purpose | |---|---|---| | `--query` | (required) | Search query string | | `--num-results` | `10` | 1–20; the helper clamps to this range | | `--category` | `"research paper"` | Pass `""` to disable category filtering for broader results | | `--highlight-chars` | `4000` | Max characters per highlight (Exa parameter) | | `--discovered-for` | `"intro"` | Tag attached to each candidate; use `"related_work[2.1]"` for cluster queries | | `--raw` | off | Print the full Exa response JSON instead of normalized candidates | ## Direct curl recipe If you'd rather not use the Python helper (for one-off testing, or to invoke from a host agent's `Bash` / `WebFetch` tool directly): ```bash curl -X POST https://api.exa.ai/search \ --header "content-type: application/json" \ --header "x-api-key: $EXA_API_KEY" \ --data '{ "query": "PaperOrchestra automated paper writing", "category": "research paper", "numResults": 10, "type": "auto", "contents": { "highlights": { "maxCharacters": 4000 } } }' ``` The `$EXA_API_KEY` reference assumes the key is in your shell env. **Do not** paste the literal key into the curl command in shell history or chat — use the env var. ## Response shape ```json { "requestId": "52fcb70256224863b33f356fdae37c7f", "resolvedSearchType": "neural", "results": [ { "id": "https://arxiv.org/abs/2604.05018", "title": "PaperOrchestra: A Multi-Agent Framework for ...", "url": "https://arxiv.org/abs/2604.05018", "publishedDate": "2026-04-06T00:00:00.000Z", "highlights": ["...", "..."], "highlightScores": [0.4, 0.3], "image": "https://...", "favicon": "https://..." } ], "searchTime": 975.2, "costDollars": { "total": 0.007, "search": {"neural": 0.007} } } ``` ## Mapping Exa → literature-review-agent candidate format Phase 2 verification (Semantic Scholar fuzzy match → cutoff check → dedup) expects candidates in this shape: ```json { "title": "...", "snippet": "...", "source_url": "...", "discovered_for": ["intro"] } ``` `exa_search.py --normalize` (the default mode) does this mapping: | Exa field | Candidate field | |---|---| | `result.title` | `title` | | `result.url` (fallback `result.id`) | `source_url` | | `result.highlights` joined and capped at 1500 chars | `snippet` | | `--discovered-for` flag | `discovered_for` | | `result.id` | `_exa_id` (preserved for debugging) | | `result.publishedDate` | `_exa_published_date` (preserved for tie-breaking) | Phase 2 verification still goes through Semantic Scholar regardless of whether the candidate came from Exa or from the host's native search. Exa is ONLY a discovery backend; the verification chain (`levenshtein_match.py` → `check_cutoff.py` → `dedupe_by_id.py` → `bibtex_format.py` → `citation_coverage.py`) is unchanged. ## Query patterns Match the literature-review-agent's outline-driven query design. Run one Exa call per query, then merge all candidate lists: | Query type | Source in `outline.json` | Example query | `--discovered-for` | |---|---|---|---| | Macro context | `introduction_strategy.search_directions[i]` | `"Survey of long-context attention mechanisms 2020-2024"` | `"intro"` | | Foundational | same | `"Foundational papers transformer self-attention scaling laws"` | `"intro"` | | SOTA scan | `related_work_strategy.subsections[i].sota_investigation_mission` | `"Recent SOTA sparse attention transformers 2024"` | `"related_work[2.1]"` | | Limitation hunt | `related_work_strategy.subsections[i].limitation_search_queries[j]` | `"Block-sparse attention failure modes long sequences"` | `"related_work[2.1]"` | For the related-work cluster queries, the `--discovered-for` tag matters — the downstream `citation_coverage.py` gate uses it to attribute each citation to the right cluster when reporting which papers were not yet integrated. ## Cost and rate limits Exa pricing is per-query (~$0.007 per neural search at the time of writing). For a typical paper with ~15-20 search queries (3-5 intro queries + 10-15 related-work queries), one full Lit Review Agent run costs ~$0.10-$0.15. Check <https://exa.ai/pricing> for current rates. Exa's rate limits are generous; the paper's 10-worker parallel discovery pattern is well within them. The pipeline's wall-time floor is still set by Semantic Scholar's 1 QPS verification limit, not by Exa. ## Security - **NEVER commit `EXA_API_KEY` to git.** The repo's `.gitignore` blocks `.env`, `*.env`, and `secrets.json` patterns. Keep your key in your shell environment or your secrets manager (1Password CLI, op, doppler, etc.). - The helper reads the key from the environment only. It does NOT accept the key as a command-line argument (which would expose it in shell history). - Exa logs requests for billing and quality. Assume your queries are not private to Exa themselves. Don't include sensitive draft text in queries. ## Troubleshooting | Symptom | Likely cause | Fix | |---|---|---| | `ERROR: EXA_API_KEY environment variable not set` | env var missing | `export EXA_API_KEY="..."` | | `ERROR: Exa HTTP 401` | invalid or expired key | check the dashboard for the current key | | `ERROR: Exa HTTP 429` | rate-limited | back off, lower concurrency | | `WARN: Exa returned 0 results` | query too narrow or odd category | broaden the query or try `--category ""` | | `Exa network error` | no internet, DNS issue | check your connection; the helper uses urllib stdlib only, no proxy support | ## When to prefer Exa vs the host's native search | Use case | Recommended backend | |---|---| | Claude Code, Cursor, Antigravity (have native web search) | host's native search (free, integrated) | | Aider, OpenCode, generic CLI agents | Exa (gives them search) | | Batch reproducible runs | Exa (deterministic backend) | | Research-paper-heavy queries | Exa (better academic signal) | | One-off interactive runs | host's native search (less friction) | You can also mix: use the host's web search for the broad intro queries and Exa for the narrow limitation-search queries where the research-paper-category filter helps the most. -
prompt.md 3.2 KB
# Literature Review Agent — verbatim prompt **Source: arXiv:2604.05018, Appendix F.1, page 46 (verbatim).** This is the exact prompt used by the Literature Review Agent in the paper. Use it as your system message when drafting Introduction and Related Work. Substitute the placeholders before sending. The Anti-Leakage Prompt (`../paper-orchestra/references/anti-leakage-prompt.md`) MUST be prepended. --- ``` Role: Senior AI Researcher. Task: Write the introduction and related work section of a paper. You will be given a template.tex, this is the initial skeleton we outlined for you. Your job is to fill in two sections: Introduction and Related Work. Leave all the other sections untouched. Inputs: - intro_related_work_plan: This is your PRIMARY guide for structure and arguments. - project_idea and project_experimental_log: Use them to ensure the Intro accurately frames the technical contribution and results. - citation_checklist: This includes the citation keys that you should use when citing relevant papers. - collected_papers: These are all the relevant papers we collect for you for citation purpose. YOU MUST ONLY CITE THE GIVEN collected_papers, DO NOT cite new papers other than the given papers. Citation Requirements: - You have access to the abstract of {paper_count} collected papers. - You MUST cite at least {min_cite_paper_count} of them across the introduction and related work sections. - Introduction: Cite key statistics, foundational models (CLIP, etc.), and broad problem statements. - Related Work: Do deep comparative citations. Group distinct works (e.g., "Several methods [A, B, C]..."). - Ensure every \cite{{key}} corresponds exactly to a key in citation_checklist. - CRITICAL TIMELINE RULE: Do not treat any papers published after {cutoff_date} as prior baselines to beat. Treat them strictly as concurrent work. - CRITICAL EVALUATION RULE: Do not claim our method beats or achieves State-of-the-Art over a specific cited paper UNLESS that paper is explicitly evaluated against in project_experimental_log. Frame other recent papers strictly as concurrent, orthogonal, or conceptual work. - You need to return the full code for the new template.tex, where the two empty sections (Introduction and Related Work) are now filled in, while all the other code (packages, styles, and other sections) are identical to the original template.tex. Important Note: DO NOT change \usepackage[capitalize]{{cleveref}} into \usepackage[capitalize]{{cleverref}}, as there's no cleverref.sty. Output Format: You must return the code for the updated template.tex. Make sure to wrap the code with ```latex content ```. ``` --- ## Placeholder substitution table | Placeholder | Source | |---|---| | `{paper_count}` | `len(citation_pool.papers)` from `workspace/citation_pool.json` | | `{min_cite_paper_count}` | `floor(0.9 * paper_count)` — the ≥90% rule | | `{cutoff_date}` | Derived from `conference_guidelines.md` — see App. D.1 of the paper | The other placeholders (`intro_related_work_plan`, `project_idea`, `project_experimental_log`, `citation_checklist`, `collected_papers`) are substituted by passing their full file/JSON contents into the user message. -
s2-api-cookbook.md 4.1 KB
# Semantic Scholar API Cookbook How to verify a candidate paper via the Semantic Scholar Graph API. Base: `https://api.semanticscholar.org/graph/v1` Reference: <https://api.semanticscholar.org/api-docs/graph> ## API key (optional) The pipeline uses the **public, unauthenticated endpoint** by default — no key required. If you have a Semantic Scholar API key you can pass it via the `x-api-key` header to get higher rate limits (useful for large batches). Get a free key at <https://api.semanticscholar.org/> then export it once: ```bash export SEMANTIC_SCHOLAR_API_KEY="your-key-here" ``` The bundled `scripts/s2_search.py` helper picks this up automatically. If the variable is not set the script falls back to the unauthenticated endpoint — the pipeline works fine either way; just keep to ≤1 QPS on live requests. ```bash # check whether the key is configured python skills/literature-review-agent/scripts/s2_search.py --check-key # search by title (key used automatically if set) python skills/literature-review-agent/scripts/s2_search.py \ --query "Attention is All You Need" --limit 5 # print the raw S2 JSON python skills/literature-review-agent/scripts/s2_search.py \ --query "BERT pre-training" --raw ``` The repo never commits a key. Key management is your responsibility (shell environment, 1Password, doppler, etc.). ## Endpoint 1 — Search by title ``` GET /paper/search ?query=<URL-encoded title> &limit=5 &fields=title,abstract,year,authors,venue,externalIds ``` Example: ``` GET https://api.semanticscholar.org/graph/v1/paper/search?query=Attention%20Is%20All%20You%20Need&limit=5&fields=title,abstract,year,authors,venue,externalIds ``` Response (truncated): ```json { "total": 12345, "data": [ { "paperId": "204e3073870fae3d05bcbc2f6a8e263d9b72e776", "title": "Attention is All you Need", "abstract": "The dominant sequence transduction models are based on...", "year": 2017, "venue": "NeurIPS", "authors": [{"name": "Ashish Vaswani"}, ...], "externalIds": { "DBLP": "conf/nips/VaswaniSPUJGKP17", "ArXiv": "1706.03762", "DOI": "10.5555/3295222.3295349" } }, ... ] } ``` ## Endpoint 2 — Get a specific paper by ID ``` GET /paper/<paperId>?fields=title,abstract,year,authors,venue,externalIds,citationCount ``` ## Useful identifiers You can pass these as `<paperId>`: - S2 internal: `204e3073870fae3d05bcbc2f6a8e263d9b72e776` - DOI: `DOI:10.18653/v1/N18-3011` - ArXiv: `ARXIV:1706.03762` - Corpus ID: `CorpusId:13756489` - URL: `URL:https://arxiv.org/abs/1706.03762` ## Rate limits - Unauthenticated: ~1 QPS sustained. Bursts will get 429. - Per the paper, "the strict throughput limits of the Semantic Scholar API (1 query per second)" — App. B. If you get HTTP 429, sleep 5 seconds before retrying. Don't loop tightly. ## Fields cheat sheet | Field | Type | Required by our pipeline? | |---|---|---| | `paperId` | string | yes (dedup key) | | `title` | string | yes (Levenshtein match) | | `abstract` | string | yes (rule 2: must exist) | | `year` | int | yes (cutoff check) | | `authors[].name` | string | yes (BibTeX author field) | | `venue` | string | recommended (BibTeX journal/booktitle) | | `externalIds.DOI` | string | recommended (dedup fallback, BibTeX doi) | | `externalIds.ArXiv` | string | recommended (dedup fallback) | | `publicationDate` | string `YYYY-MM-DD` | optional (more precise cutoff check) | | `citationCount` | int | optional (could inform tie-breaking) | Always pass `fields=...` explicitly — the default response is minimal and will not include the abstract. ## Error handling | Status | Meaning | What to do | |---|---|---| | 200 | OK | proceed | | 400 | bad query syntax | URL-encode the title properly; retry once | | 404 | not found | discard the candidate | | 429 | rate limited | sleep 5s, retry | | 500-503 | S2 down | sleep 30s, retry up to 3 times, then give up | ## Polite use The S2 API is a public service. Do not hammer it. If you have many candidates: - Throttle to 1 QPS. - Cache hits (the dedup script already serves as a deduplication cache). - Do not parallelize. Verification is sequential by design. -
tavily-search-cookbook.md 9.9 KB
# Tavily Search Cookbook (optional Phase 1 backend) [Tavily](https://tavily.com) is a search API designed for LLMs, enabling AI applications to access real-time web data with high relevance scoring. The `literature-review-agent` can use Tavily as an **OPTIONAL** backend for Phase 1 candidate discovery — useful when your host coding agent has no native web search tool, or when you want an LLM-optimized search backend. > **Tavily is opt-in.** The literature-review-agent's default Phase 1 > path is "use your host agent's native web search tool" (`WebSearch` in > Claude Code, `@web` in Cursor, the search tool in Antigravity, etc.). > That requires zero configuration and no API key. Use Tavily only if > you want to. ## Why use it Tavily fills three gaps: 1. **Hosts with no built-in search.** Aider, OpenCode, and generic CLI agents often lack a native web search tool. Tavily gives them one. 2. **LLM-optimized relevance.** Tavily's `search_depth: "advanced"` mode returns higher relevance results for complex queries. The `--academic` flag restricts results to academic domains (arxiv.org, scholar.google.com, semanticscholar.org, aclanthology.org, openreview.net) for research-focused discovery. 3. **Batch / non-interactive runs.** When you want a deterministic, scriptable backend rather than going through the host agent's tool interface. Tavily returns up to 20 results per call (the helper clamps to that range), and each result includes a `title`, `url`, `content` (snippet), and a relevance `score` which the helper preserves as `_tavily_score` for debugging. ## Get a key 1. Sign up at <https://app.tavily.com>. 2. Copy your API key (format: `tvly-xxxxxxxxxxxxxxxxxxxxxxxx`). 3. Set it in your environment: ```bash export TAVILY_API_KEY="tvly-your-key-here" ``` Or put it in a `.env` file (which is gitignored — the repo `.gitignore` blocks `*.env` and `.env*` patterns) and source it: ```bash set -a; source .env; set +a ``` **This repo never commits a key.** The helper reads `TAVILY_API_KEY` from the environment at runtime. The key is your responsibility to provision and secure. ## Run the helper ```bash python skills/literature-review-agent/scripts/tavily_search.py \ --query "Sparse attention long context transformers" \ --num-results 15 \ --academic \ --discovered-for "related_work[2.1]" ``` Output (default — normalized to the literature-review-agent candidate format): ```json { "candidates": [ { "title": "Longformer: The Long-Document Transformer", "snippet": "We present the Longformer, a self-attention mechanism that scales linearly with sequence length...", "source_url": "https://arxiv.org/abs/2004.05150", "discovered_for": ["related_work[2.1]"], "_tavily_score": 0.92 }, ... ] } ``` This JSON can be merged directly into `workspace/raw_candidates.json` before the Phase 2 sequential verification step. ### Useful flags | Flag | Default | Purpose | |---|---|---| | `--query` | (required) | Search query string | | `--num-results` | `10` | 1–20; the helper clamps to this range | | `--topic` | `"general"` | `"general"` or `"news"`; use `"news"` for recent results | | `--academic` | off | Restrict to academic domains (arxiv.org, scholar.google.com, etc.) | | `--discovered-for` | `"intro"` | Tag attached to each candidate; use `"related_work[2.1]"` for cluster queries | | `--raw` | off | Print the full Tavily response JSON instead of normalized candidates | ## Direct curl recipe If you'd rather not use the Python helper (for one-off testing, or to invoke from a host agent's `Bash` / `WebFetch` tool directly): ```bash curl -X POST https://api.tavily.com/search \ --header "Content-Type: application/json" \ --header "Authorization: Bearer $TAVILY_API_KEY" \ --data '{ "query": "PaperOrchestra automated paper writing", "max_results": 10, "search_depth": "advanced", "topic": "general", "include_domains": ["arxiv.org", "scholar.google.com"] }' ``` The `$TAVILY_API_KEY` reference assumes the key is in your shell env. **Do not** paste the literal key into the curl command in shell history or chat — use the env var. ## Response shape ```json { "query": "PaperOrchestra automated paper writing", "results": [ { "title": "PaperOrchestra: A Multi-Agent Framework for ...", "url": "https://arxiv.org/abs/2604.05018", "content": "We present PaperOrchestra, a multi-agent framework...", "score": 0.95 } ] } ``` ## Mapping Tavily → literature-review-agent candidate format Phase 2 verification (Semantic Scholar fuzzy match → cutoff check → dedup) expects candidates in this shape: ```json { "title": "...", "snippet": "...", "source_url": "...", "discovered_for": ["intro"] } ``` `tavily_search.py` (the default mode) does this mapping: | Tavily field | Candidate field | |---|---| | `result.title` | `title` | | `result.url` | `source_url` | | `result.content` capped at 1500 chars | `snippet` | | `--discovered-for` flag | `discovered_for` | | `result.score` | `_tavily_score` (preserved for debugging) | Phase 2 verification still goes through Semantic Scholar regardless of whether the candidate came from Tavily, Exa, or from the host's native search. Tavily is ONLY a discovery backend; the verification chain (`levenshtein_match.py` → `check_cutoff.py` → `dedupe_by_id.py` → `bibtex_format.py` → `citation_coverage.py`) is unchanged. ## Query patterns Match the literature-review-agent's outline-driven query design. Run one Tavily call per query, then merge all candidate lists: | Query type | Source in `outline.json` | Example query | `--discovered-for` | |---|---|---|---| | Macro context | `introduction_strategy.search_directions[i]` | `"Survey of long-context attention mechanisms 2020-2024"` | `"intro"` | | Foundational | same | `"Foundational papers transformer self-attention scaling laws"` | `"intro"` | | SOTA scan | `related_work_strategy.subsections[i].sota_investigation_mission` | `"Recent SOTA sparse attention transformers 2024"` | `"related_work[2.1]"` | | Limitation hunt | `related_work_strategy.subsections[i].limitation_search_queries[j]` | `"Block-sparse attention failure modes long sequences"` | `"related_work[2.1]"` | For the related-work cluster queries, the `--discovered-for` tag matters — the downstream `citation_coverage.py` gate uses it to attribute each citation to the right cluster when reporting which papers were not yet integrated. **Tip:** Use `--academic` for the SOTA scan and limitation hunt queries to keep results focused on research papers. For broad intro queries, omitting `--academic` may yield useful surveys and blog posts that reference foundational work. ## Cost and rate limits Tavily offers 1,000 free API credits per month (no credit card required). The `search_depth: "advanced"` mode used by the helper costs 2 credits per query. For a typical paper with ~15-20 search queries (3-5 intro queries + 10-15 related-work queries), one full Lit Review Agent run costs ~30-40 credits — well within the free tier. For higher volumes, see <https://tavily.com/pricing> for paid plans. Tavily's rate limits are generous; the paper's 10-worker parallel discovery pattern is well within them. The pipeline's wall-time floor is still set by Semantic Scholar's 1 QPS verification limit, not by Tavily. ## SDK alternative If `tavily-python` is installed (`pip install tavily-python`), you can use the SDK directly instead of the bundled helper: ```python from tavily import TavilyClient client = TavilyClient() # reads TAVILY_API_KEY from env response = client.search( query="Sparse attention long context transformers", max_results=15, search_depth="advanced", include_domains=["arxiv.org", "scholar.google.com"], ) ``` The bundled `tavily_search.py` helper uses stdlib `urllib` only (like `exa_search.py`) to avoid mandatory dependencies. The SDK is optional. ## Security - **NEVER commit `TAVILY_API_KEY` to git.** The repo's `.gitignore` blocks `.env`, `*.env`, and `secrets.json` patterns. Keep your key in your shell environment or your secrets manager (1Password CLI, op, doppler, etc.). - The helper reads the key from the environment only. It does NOT accept the key as a command-line argument (which would expose it in shell history). - Tavily logs requests for billing and quality. Assume your queries are not private to Tavily themselves. Don't include sensitive draft text in queries. ## Troubleshooting | Symptom | Likely cause | Fix | |---|---|---| | `ERROR: TAVILY_API_KEY environment variable not set` | env var missing | `export TAVILY_API_KEY="tvly-..."` | | `ERROR: Tavily HTTP 401` | invalid or expired key | check your key at https://app.tavily.com | | `ERROR: Tavily HTTP 429` | rate-limited | back off, lower concurrency | | `WARN: Tavily returned 0 results` | query too narrow | broaden the query or remove `--academic` | | `Tavily network error` | no internet, DNS issue | check your connection; the helper uses urllib stdlib only, no proxy support | ## When to prefer Tavily vs Exa vs the host's native search | Use case | Recommended backend | |---|---| | Claude Code, Cursor, Antigravity (have native web search) | host's native search (free, integrated) | | Aider, OpenCode, generic CLI agents | Tavily or Exa (gives them search) | | Batch reproducible runs | Tavily or Exa (deterministic backend) | | Research-paper-heavy queries | Exa (`category: "research paper"`) or Tavily (`--academic`) | | Free tier / budget-conscious | Tavily (1,000 free credits/month) | | LLM-optimized relevance scoring | Tavily (`search_depth: "advanced"`) | | One-off interactive runs | host's native search (less friction) | You can also mix: use the host's web search for the broad intro queries, Exa for the narrow limitation-search queries where the research-paper category filter helps the most, and Tavily for SOTA scan queries where LLM-optimized relevance scoring shines. -
verification-rules.md 5.4 KB
# Verification Rules Source: arXiv:2604.05018, App. D.3 ("Citation Verification"), verbatim specifications below. ## Rule 1 — Fuzzy title match (Levenshtein > 70) > Each candidate must resolve to a valid Semantic Scholar entity via a fuzzy > title match (Levenshtein distance ratio > 70 [Levenshtein, 1965]), > augmented by a point bonus for exact year alignment. Implementation: `scripts/levenshtein_match.py` uses `Levenshtein.ratio(a, b) * 100` from the `python-Levenshtein` package and returns the integer ratio. Threshold: **strictly greater than 70**. Examples: | Candidate title | S2 title | Ratio | Verdict | |---|---|---|---| | "Attention Is All You Need" | "Attention Is All You Need" | 100 | accept | | "Attention Is All You Need" | "Attention is All You Need." | 96 | accept | | "Sparse Attention for Transformers" | "Sparse Attention in Transformers" | 88 | accept | | "Self-Attention" | "Attention Is All You Need" | 47 | reject | | "Linformer" | "Linformer: Self-Attention with Linear Complexity" | 28 | reject | The Linformer case is the canonical false-negative: a short query against a long title. Workaround: when the candidate title looks abbreviated (< 4 words) and the S2 hit's title contains the candidate as a substring, override the ratio check. The paper does not specify this workaround explicitly; we add it as a soft safety net to avoid losing legitimate short-title hits. See `levenshtein_match.py --substring-bypass`. ## Rule 2 — Abstract must exist > To enter the final context pool, the entity must possess a retrievable > abstract... Discard any verified hit where `abstract` is null, empty, or `"N/A"`. The Section Writing Agent uses the abstract to ground its citations contextually (per the Section Writing Agent prompt: "Read the abstract provided in citation_map.json for the papers you are citing. Use this context to write accurate, specific sentences about those works."). ## Rule 3 — Strict temporal cutoff > ...and strictly predate the research cutoff (when specified down to the > month, the system defaults to the first day of that month). Implementation: `scripts/check_cutoff.py`. Comparison rules: - Cutoff is given as `YYYY-MM-DD`. The paper aligns it to venue submission deadline (Nov 2024 for CVPR 2025, Oct 2024 for ICLR 2025 — App. D.1). - Paper year is required. Paper month is optional. - If paper has only year: assume month=12, day=31 (worst case for the paper — must still be < cutoff). - If paper has year + month: assume day=1 of that month. - "Strictly predate" means `paper_date < cutoff_date`. Equality fails. Examples (cutoff = 2024-10-01): | Paper year | Paper month | Verdict | |---|---|---| | 2017 | — | accept | | 2024 | 9 | accept (2024-09-01 < 2024-10-01) | | 2024 | 10 | reject (2024-10-01 not strictly < 2024-10-01) | | 2024 | — (only year) | reject (2024-12-31 ≥ 2024-10-01) | The strict comparison is intentional: it prevents leakage of papers from the same submission cycle as the target venue. ## Rule 4 — Dedup by Semantic Scholar paperId > Finally, gathered citations are deduplicated using unique paper ID keys. Implementation: `scripts/dedupe_by_id.py`. Key precedence: 1. `paperId` (S2's internal unique ID, always present on a verified hit) 2. `externalIds.DOI` (lowercased) 3. `externalIds.ArXiv` (without version suffix) 4. Normalized title (lowercased, alphanumeric only) — fallback only When two candidates collide, keep the one with the higher `match_score`. ## Rule 5 — ≥90% citation integration > The system constrains the model to cite only the provided verified papers, > explicitly mandating that at least 90% of the gathered literature pool must > be actively integrated and cited when synthesizing the Introduction and > Related Work sections. Implementation: `scripts/citation_coverage.py`. After the Lit Review writing call produces `intro_relwork.tex`, this script: 1. Extracts every `\cite{KEY}` and `\citep{KEY}` (and variants) from the `.tex` file. 2. Counts unique cited keys against `len(citation_pool.papers)`. 3. Requires `cited / total ≥ 0.90`. Exits non-zero if not. If the gate fails, the host agent must re-prompt the writing step, explicitly listing the un-cited keys and asking the agent to integrate them. ## Rule 6 — Cross-index corroboration (supplementary) Rules 1–4 verify candidates against a single index (Semantic Scholar). Rule 6 adds an independent second opinion: every paper that survives S2 verification is re-checked against **Crossref** and **OpenAlex** before the bibliography is built. This is not in the source paper — it is an addition that targets hallucinated citations, which a single index can fail to catch. Implementation: `scripts/cross_verify.py` (+ `crossref_client.py`, `openalex_client.py`). It is a **WARN gate**: it annotates each pool paper with a `cross_verification` confidence tier (`high` / `medium` / `low` / `conflict`) and writes `cross_verification_report.json`, but never deletes citations — the host agent reviews the `low` and `conflict` tiers and decides. Matching reuses the same Levenshtein title threshold as Rule 1 (`> 70`); a DOI present in the pool is looked up exactly first, falling back to title search. ⚠️ `low` means "could not corroborate," not "fabricated": arXiv-only preprints without a DOI are a common benign cause. See `cross-index-verification.md` for the full tier definitions, polite-pool env vars, and the false-positive note.
-
-
scripts
-
bibtex_format.py 5.2 KB
#!/usr/bin/env python3 """ bibtex_format.py — Generate refs.bib from a verified citation pool. Reads citation_pool.json (output of dedupe_by_id.py) and emits a BibTeX file with deterministic citation keys derived from the first author + year + first significant title word. Never invents fields. Only writes fields that are actually present in the S2 metadata. Writes one of: @article{ ... } — when venue looks like a journal @inproceedings{ ... }— when venue looks like a conference @misc{ ... } — fallback (e.g., arXiv-only papers) Usage: python bibtex_format.py --pool citation_pool.json --out refs.bib """ import argparse import json import re import sys CONFERENCE_HINTS = { "neurips", "nips", "icml", "iclr", "cvpr", "iccv", "eccv", "aaai", "ijcai", "acl", "emnlp", "naacl", "kdd", "www", "sigir", "uai", "siggraph", "interspeech", "icassp", "miccai", "wacv", "bmvc", "coling", "conll", } STOPWORDS = { "a", "an", "and", "the", "of", "for", "to", "with", "on", "in", "by", "from", "as", "is", "are", "be", "via", "into", "their", "our", "we", "this", "that", "using", "use", "about", "at", "or", "if", } def normalize(s: str) -> str: return re.sub(r"[^a-z]", "", s.lower()) def first_significant_word(title: str) -> str: for w in re.findall(r"[A-Za-z][A-Za-z\-]*", title): wn = w.lower() if wn not in STOPWORDS and len(wn) > 2: return normalize(wn) return "paper" def first_author_lastname(authors: list[dict]) -> str: if not authors: return "anon" name = authors[0].get("name", "").strip() if not name: return "anon" parts = name.replace(",", "").split() return normalize(parts[-1]) or "anon" def make_key(paper: dict) -> str: last = first_author_lastname(paper.get("authors") or []) year = paper.get("year") or "0000" word = first_significant_word(paper.get("title", "")) return f"{last}{year}{word}" def is_conference(venue: str) -> bool: if not venue: return False v = venue.lower() return any(h in v for h in CONFERENCE_HINTS) def escape_bibtex(s: str) -> str: if not s: return "" return s.replace("{", "\\{").replace("}", "\\}").replace("&", "\\&") def author_field(authors: list[dict]) -> str: names = [a.get("name", "").strip() for a in authors if a.get("name")] return " and ".join(escape_bibtex(n) for n in names) def format_entry(paper: dict, key: str) -> str: venue = paper.get("venue") or "" if is_conference(venue): kind = "inproceedings" venue_key = "booktitle" elif venue: kind = "article" venue_key = "journal" else: kind = "misc" venue_key = None lines = [f"@{kind}{{{key},"] if title := paper.get("title"): lines.append(f' title = {{{escape_bibtex(title)}}},') if authors := paper.get("authors"): lines.append(f' author = {{{author_field(authors)}}},') if year := paper.get("year"): lines.append(f' year = {{{year}}},') if venue and venue_key: lines.append(f' {venue_key:8s} = {{{escape_bibtex(venue)}}},') ext = paper.get("externalIds") or {} if doi := ext.get("DOI"): lines.append(f' doi = {{{doi}}},') if arxiv := ext.get("ArXiv"): lines.append(f' eprint = {{{arxiv}}},') lines.append(f' archivePrefix = {{arXiv}},') # Strip trailing comma on last field if lines[-1].endswith(","): lines[-1] = lines[-1].rstrip(",") lines.append("}") return "\n".join(lines) def main() -> int: p = argparse.ArgumentParser(description=__doc__) p.add_argument("--pool", required=True, help="citation_pool.json") p.add_argument("--out", required=True, help="output refs.bib") args = p.parse_args() with open(args.pool) as f: pool = json.load(f) papers = pool.get("papers", []) if not papers: print("ERROR: pool contains no papers", file=sys.stderr) return 1 keys_used: dict[str, int] = {} entries: list[str] = [] paper_keys: list[str] = [] for paper in papers: base_key = make_key(paper) # Disambiguate collisions with letter suffix if base_key in keys_used: keys_used[base_key] += 1 suffix = chr(ord("a") + keys_used[base_key] - 1) key = base_key + suffix else: keys_used[base_key] = 1 key = base_key paper["bibtex_key"] = key paper_keys.append(key) entries.append(format_entry(paper, key)) with open(args.out, "w") as f: f.write("% Generated by paper-orchestra literature-review-agent/bibtex_format.py\n") f.write(f"% {len(entries)} entries from citation_pool.json\n\n") f.write("\n\n".join(entries)) f.write("\n") # Write the keys back into the pool so the writing step has the # citation_checklist mapping. (Idempotent — overwrites with same data.) with open(args.pool, "w") as f: json.dump(pool, f, indent=2, ensure_ascii=False) print(f"OK: {len(entries)} BibTeX entries → {args.out}") print(f" keys: {', '.join(paper_keys[:5])}{'...' if len(paper_keys) > 5 else ''}") return 0 if __name__ == "__main__": sys.exit(main()) -
check_cutoff.py 2.2 KB
#!/usr/bin/env python3 """ check_cutoff.py — Strict temporal cutoff check for citation verification. Implements the paper's Rule 3 (App. D.3): a paper passes only if its publication date strictly predates the research cutoff. When only the year is known, assume the worst case (Dec 31). When year + month are known, assume day-1 of that month (per the paper's "first day of that month" default). Exit codes: 0 paper strictly predates cutoff (PASS) 1 paper does not strictly predate cutoff (FAIL) 2 argument error Usage: python check_cutoff.py --paper-year 2024 --paper-month 9 --cutoff 2024-10-01 python check_cutoff.py --paper-year 2024 --cutoff 2024-10-01 python check_cutoff.py --paper-date 2024-09-15 --cutoff 2024-10-01 """ import argparse import datetime as dt import sys def main() -> int: p = argparse.ArgumentParser(description=__doc__) p.add_argument("--paper-year", type=int, help="Paper publication year") p.add_argument("--paper-month", type=int, help="Paper publication month (1-12), optional") p.add_argument("--paper-date", help="Full paper date YYYY-MM-DD, overrides year/month") p.add_argument("--cutoff", required=True, help="Research cutoff date YYYY-MM-DD") args = p.parse_args() try: cutoff = dt.date.fromisoformat(args.cutoff) except ValueError: print(f"ERROR: --cutoff must be YYYY-MM-DD, got {args.cutoff}", file=sys.stderr) return 2 if args.paper_date: try: paper_date = dt.date.fromisoformat(args.paper_date) except ValueError: print(f"ERROR: --paper-date must be YYYY-MM-DD, got {args.paper_date}", file=sys.stderr) return 2 elif args.paper_year: if args.paper_month: paper_date = dt.date(args.paper_year, args.paper_month, 1) else: paper_date = dt.date(args.paper_year, 12, 31) else: print("ERROR: must provide --paper-date OR --paper-year", file=sys.stderr) return 2 if paper_date < cutoff: print(f"PASS paper={paper_date} < cutoff={cutoff}") return 0 print(f"FAIL paper={paper_date} not strictly before cutoff={cutoff}") return 1 if __name__ == "__main__": sys.exit(main()) -
citation_coverage.py 3.1 KB
#!/usr/bin/env python3 """ citation_coverage.py — Enforce the paper's ≥90% citation integration rule (App. D.3). Greps a generated .tex file for all citation commands, counts the unique keys actually cited, and compares against the verified citation pool. Exits non-zero if coverage < 90%. Usage: python citation_coverage.py --tex intro_relwork.tex --pool citation_pool.json python citation_coverage.py --tex intro_relwork.tex --pool citation_pool.json --threshold 0.85 """ import argparse import json import re import sys CITE_RE = re.compile( r"\\(?:cite|citep|citet|citeauthor|citeyear|autocite|parencite|textcite)" r"(?:\[[^\]]*\])?" r"\{([^}]+)\}" ) def extract_cited_keys(tex: str) -> set[str]: keys = set() for m in CITE_RE.finditer(tex): for k in m.group(1).split(","): k = k.strip() if k: keys.add(k) return keys def main() -> int: p = argparse.ArgumentParser(description=__doc__) p.add_argument("--tex", required=True, help="LaTeX file to inspect") p.add_argument("--pool", required=True, help="citation_pool.json") p.add_argument("--threshold", type=float, default=0.90, help="Minimum integration ratio (default 0.90 per paper)") args = p.parse_args() with open(args.tex) as f: tex = f.read() with open(args.pool) as f: pool = json.load(f) pool_papers = pool.get("papers", []) pool_keys = {p.get("bibtex_key") for p in pool_papers if p.get("bibtex_key")} if not pool_keys: print("ERROR: pool has no bibtex_keys. Run bibtex_format.py first.", file=sys.stderr) return 1 cited = extract_cited_keys(tex) cited_in_pool = cited & pool_keys n_pool = len(pool_keys) n_cited = len(cited_in_pool) ratio = n_cited / n_pool if n_pool else 0.0 threshold_n = int(args.threshold * n_pool) print(f"Coverage: {n_cited}/{n_pool} = {ratio*100:.1f}% " f"(threshold {args.threshold*100:.0f}% = {threshold_n})") # report keys cited but NOT in pool — those are forbidden by the prompt foreign = cited - pool_keys if foreign: print(f"\nWARNING: {len(foreign)} cited keys NOT in citation pool " f"(violates 'cite ONLY collected_papers' rule):") for k in sorted(foreign): print(f" - {k}") if n_cited < threshold_n: uncited = pool_keys - cited print(f"\nFAIL: missing {len(uncited)} pool papers from .tex:") # show with title for actionable re-prompting title_by_key = {p.get("bibtex_key"): p.get("title", "") for p in pool_papers if p.get("bibtex_key")} discovered_by_key = {p.get("bibtex_key"): p.get("discovered_for", []) for p in pool_papers if p.get("bibtex_key")} for k in sorted(uncited): tag = ",".join(discovered_by_key.get(k, [])) or "?" t = title_by_key.get(k, "") print(f" - {k:40s} [{tag}] {t[:60]}") return 1 print("OK: citation coverage meets threshold") return 0 if __name__ == "__main__": sys.exit(main()) -
crossref_client.py 6 KB
#!/usr/bin/env python3 """ crossref_client.py — Crossref REST API title/DOI lookup for cross-index citation corroboration. Used by cross_verify.py as a second opinion alongside Semantic Scholar: if a paper that S2 "verified" cannot be found in Crossref *or* OpenAlex, it is flagged as a potential phantom record (hallucination risk). See references/cross-index-verification.md for the rationale. No API key is required. Crossref offers a "polite pool" with better reliability when you identify yourself via an email address. Set one of: export CROSSREF_MAILTO="you@example.com" export PAPER_ORCHESTRA_MAILTO="you@example.com" # shared fallback The email is sent only as a `mailto` query parameter / User-Agent, per Crossref's etiquette guidelines. The repo never commits an address. Usage: # title search python crossref_client.py --query "Attention is All You Need" # direct DOI lookup (exact) python crossref_client.py --doi 10.5555/3295222.3295349 # raw Crossref JSON python crossref_client.py --query "BERT pre-training" --raw Output (normalized): {"total": N, "data": [{title, year, doi, venue, authors}, ...]} Exit codes: 0 at least one result returned 1 HTTP error, network error, or zero results 2 usage error (bad arguments) """ import argparse import json import os import sys import time import urllib.error import urllib.parse import urllib.request CROSSREF_BASE = "https://api.crossref.org/works" DEFAULT_LIMIT = 5 MAX_LIMIT = 20 _RETRY_SLEEP = 5 # seconds to wait after a 429 before retrying SELECT_FIELDS = "DOI,title,author,published,published-print,published-online,issued,container-title,type" def _mailto() -> str: return ( os.environ.get("CROSSREF_MAILTO", "").strip() or os.environ.get("PAPER_ORCHESTRA_MAILTO", "").strip() ) def _build_request(url: str) -> urllib.request.Request: mailto = _mailto() ua = "paper-orchestra/1.0 (https://github.com/Ar9av/paper-orchestra)" if mailto: ua = f"paper-orchestra/1.0 (mailto:{mailto})" return urllib.request.Request( url, headers={"Accept": "application/json", "User-Agent": ua}, method="GET" ) def _get(url: str, retries: int = 3) -> dict: for attempt in range(1, retries + 1): try: with urllib.request.urlopen(_build_request(url), timeout=30) as resp: return json.loads(resp.read().decode("utf-8")) except urllib.error.HTTPError as exc: if exc.code == 404: return {"message": {"items": []}} if exc.code == 429 and attempt < retries: print(f"WARN: Crossref rate-limited (429). Sleeping {_RETRY_SLEEP}s " f"before retry {attempt + 1}/{retries}.", file=sys.stderr) time.sleep(_RETRY_SLEEP) continue if exc.code in (500, 502, 503) and attempt < retries: print(f"WARN: Crossref server error ({exc.code}). Retrying.", file=sys.stderr) time.sleep(10) continue print(f"ERROR: Crossref HTTP {exc.code}", file=sys.stderr) sys.exit(1) except urllib.error.URLError as exc: print(f"ERROR: Network error reaching Crossref: {exc.reason}", file=sys.stderr) sys.exit(1) sys.exit(1) def _year_from(work: dict) -> int | None: for key in ("published", "published-print", "published-online", "issued"): dp = (work.get(key) or {}).get("date-parts") if dp and dp[0] and dp[0][0]: return int(dp[0][0]) return None def _normalize_work(work: dict) -> dict: titles = work.get("title") or [] venues = work.get("container-title") or [] authors = [] for a in work.get("author", []) or []: name = " ".join(p for p in [a.get("given"), a.get("family")] if p).strip() if name: authors.append(name) doi = (work.get("DOI") or "").lower().strip() return { "title": titles[0] if titles else "", "year": _year_from(work), "doi": doi, "venue": venues[0] if venues else "", "authors": authors, "type": work.get("type", ""), } def search(query: str, limit: int) -> dict: params = {"query.bibliographic": query, "rows": limit, "select": SELECT_FIELDS} mailto = _mailto() if mailto: params["mailto"] = mailto url = f"{CROSSREF_BASE}?{urllib.parse.urlencode(params)}" resp = _get(url) items = (resp.get("message") or {}).get("items") or [] return {"raw": resp, "data": [_normalize_work(w) for w in items]} def lookup_doi(doi: str) -> dict: url = f"{CROSSREF_BASE}/{urllib.parse.quote(doi)}" mailto = _mailto() if mailto: url += f"?mailto={urllib.parse.quote(mailto)}" resp = _get(url) msg = resp.get("message") data = [_normalize_work(msg)] if msg else [] return {"raw": resp, "data": data} def main() -> int: p = argparse.ArgumentParser( description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter ) p.add_argument("--query", help="Paper title (bibliographic search)") p.add_argument("--doi", help="Look up an exact DOI instead of a title search") p.add_argument("--limit", type=int, default=DEFAULT_LIMIT, help=f"Max hits (default {DEFAULT_LIMIT}, max {MAX_LIMIT})") p.add_argument("--raw", action="store_true", help="Print full Crossref JSON") args = p.parse_args() if not args.query and not args.doi: print("ERROR: provide --query or --doi", file=sys.stderr) return 2 if args.doi: result = lookup_doi(args.doi.lower()) else: result = search(args.query, max(1, min(MAX_LIMIT, args.limit))) if args.raw: json.dump(result["raw"], sys.stdout, indent=2, ensure_ascii=False) sys.stdout.write("\n") return 0 if result["data"] else 1 data = result["data"] json.dump({"total": len(data), "data": data}, sys.stdout, indent=2, ensure_ascii=False) sys.stdout.write("\n") return 0 if data else 1 if __name__ == "__main__": sys.exit(main()) -
cross_verify.py 12.3 KB
#!/usr/bin/env python3 """ cross_verify.py — Cross-index corroboration for a verified citation pool. Semantic Scholar verification (Levenshtein title match + cutoff + dedup) is the pipeline's first gate. This script adds a second: every paper that S2 accepted is re-checked against two *independent* scholarly indices — Crossref and OpenAlex. A genuine paper turns up in all three with matching metadata; a hallucinated or mis-attributed record typically does not. This is the practical defense against the well-documented problem of fabricated citations leaking into AI-assisted writing. The script is a WARN gate, not a hard gate (mirrors validate_consistency.py): it annotates the pool and writes a report, but exits non-zero only to draw the host agent's attention to flagged entries — it does not block the pipeline. The host agent reviews flagged citations and decides whether to drop them. Confidence tiers written onto each paper's `cross_verification` field: high corroborated by >=1 external index, no metadata conflicts medium corroborated, but publication year disagrees beyond tolerance low NOT found in Crossref or OpenAlex — phantom/hallucination risk conflict a DOI present in the pool disagrees with the external index's DOI Network etiquette: queries are throttled (--sleep, default 1.0s between papers) and identify via a polite-pool email if PAPER_ORCHESTRA_MAILTO (or the per-service CROSSREF_MAILTO / OPENALEX_MAILTO) is set. If an index is unreachable the script degrades gracefully — it disables that index, notes it in the report, and continues with whatever indices remain. Usage: python cross_verify.py --pool workspace/citation_pool.json python cross_verify.py --pool workspace/citation_pool.json --inplace python cross_verify.py --pool workspace/citation_pool.json \\ --out workspace/cross_verification_report.json \\ --indices crossref,openalex --threshold 70 --year-tolerance 1 --sleep 1.0 Exit codes: 0 every paper corroborated (all high/medium), no flags 1 one or more papers flagged low/conflict, OR an index was unavailable (WARN) 2 usage error / pool unreadable """ import argparse import json import os import re import sys import time sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) import crossref_client # noqa: E402 import openalex_client # noqa: E402 try: import Levenshtein # noqa: E402 def _ratio(a: str, b: str) -> int: return int(round(Levenshtein.ratio(a, b) * 100)) except ImportError: # graceful fallback — stdlib only from difflib import SequenceMatcher def _ratio(a: str, b: str) -> int: return int(round(SequenceMatcher(None, a, b).ratio() * 100)) def _normalize(s: str) -> str: s = (s or "").lower().strip() s = re.sub(r"[^a-z0-9\s]", " ", s) return re.sub(r"\s+", " ", s).strip() def title_ratio(a: str, b: str) -> int: return _ratio(_normalize(a), _normalize(b)) def _bare_doi(doi: str | None) -> str: if not doi: return "" d = doi.strip().lower() for prefix in ("https://doi.org/", "http://doi.org/", "doi:"): if d.startswith(prefix): d = d[len(prefix):] return d def pool_doi(paper: dict) -> str: """Extract a DOI from a pool record, tolerating several shapes.""" ext = paper.get("externalIds") or {} for k in ("DOI", "doi", "Doi"): if ext.get(k): return _bare_doi(ext[k]) if paper.get("doi"): return _bare_doi(paper["doi"]) return "" def best_match(title: str, hits: list[dict]) -> dict | None: """Pick the external hit whose title best matches `title`.""" best, best_r = None, -1 for h in hits: r = title_ratio(title, h.get("title", "")) if r > best_r: best, best_r = h, r if best is None: return None out = dict(best) out["title_ratio"] = best_r return out def _safe(fn, *args): """Call a client function, converting its SystemExit (network/HTTP error) into a None so cross_verify can degrade instead of dying.""" try: return fn(*args) except SystemExit: return None def check_index(module, paper: dict, threshold: int, strong_threshold: int, limit: int) -> dict: """Look one paper up in a single external index. Returns a per-index dict, or {"available": False} if the index could not be reached. Two match strengths are recorded: found — title_ratio > threshold (lenient): "something like this exists" strong — title_ratio >= strong_threshold, or an exact DOI hit: "this is confidently the same paper". Only `strong` matches are trusted for year/DOI conflict downgrades, so a noisy near-title hit (e.g. a different paper with a similar name) cannot pollute the metadata-conflict checks. """ title = paper.get("title", "") doi = pool_doi(paper) result = None via = "title" if doi: r = _safe(module.lookup_doi, doi) if r is None: return {"available": False} if r["data"]: result = r["data"][0] result["title_ratio"] = title_ratio(title, result.get("title", "")) via = "doi" if result is None: r = _safe(module.search, title, limit) if r is None: return {"available": False} result = best_match(title, r["data"]) if result is None: return {"available": True, "found": False, "strong": False} tr = result.get("title_ratio", 0) found = tr > threshold or via == "doi" strong = tr >= strong_threshold or via == "doi" ext_doi = _bare_doi(result.get("doi")) doi_state = "n/a" if doi and ext_doi: doi_state = "agree" if doi == ext_doi else "conflict" return { "available": True, "found": bool(found), "strong": bool(strong), "via": via, "matched_title": result.get("title", ""), "title_ratio": tr, "matched_year": result.get("year"), "matched_doi": ext_doi, "doi_state": doi_state, } def classify(paper: dict, per_index: dict, year_tolerance: int) -> dict: found_in = [name for name, r in per_index.items() if r.get("found")] # Only confident same-paper matches can trigger a metadata-conflict downgrade. doi_conflict = any( r.get("doi_state") == "conflict" and r.get("strong") for r in per_index.values() ) year = paper.get("year") year_conflict = False if year is not None: for r in per_index.values(): my = r.get("matched_year") if r.get("strong") and my is not None and abs(int(my) - int(year)) > year_tolerance: year_conflict = True notes = [] if doi_conflict: confidence = "conflict" notes.append("DOI in pool disagrees with external index — verify this record") elif not found_in: confidence = "low" notes.append("not found in Crossref or OpenAlex — possible phantom citation") elif year_conflict: confidence = "medium" notes.append("corroborated, but publication year disagrees beyond tolerance") else: confidence = "high" return { "confidence": confidence, "indices_found": found_in, "doi_conflict": doi_conflict, "year_conflict": year_conflict, "per_index": per_index, "notes": notes, } def main() -> int: p = argparse.ArgumentParser( description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter ) p.add_argument("--pool", required=True, help="citation_pool.json path") p.add_argument("--out", default=None, help="Report path (default: <pool dir>/cross_verification_report.json)") p.add_argument("--inplace", action="store_true", help="Annotate each paper in the pool with its cross_verification result") p.add_argument("--indices", default="crossref,openalex", help="Comma-separated external indices to check (default both)") p.add_argument("--threshold", type=int, default=70, help="Levenshtein title-match threshold for corroboration " "(default 70, matches S2 gate)") p.add_argument("--strong-threshold", type=int, default=90, help="Stricter title-match threshold before an external record's " "year/DOI is trusted for a conflict downgrade (default 90)") p.add_argument("--year-tolerance", type=int, default=1, help="Allowed |year| difference before flagging (default 1)") p.add_argument("--limit", type=int, default=5, help="Max hits per title search") p.add_argument("--sleep", type=float, default=1.0, help="Seconds to pause between papers (politeness, default 1.0)") args = p.parse_args() try: with open(args.pool) as f: pool = json.load(f) except (OSError, json.JSONDecodeError) as exc: print(f"ERROR: cannot read pool: {exc}", file=sys.stderr) return 2 papers = pool.get("papers", []) if not papers: print("ERROR: pool['papers'] is empty or missing", file=sys.stderr) return 2 modules = {"crossref": crossref_client, "openalex": openalex_client} requested = [x.strip() for x in args.indices.split(",") if x.strip()] unknown = [x for x in requested if x not in modules] if unknown: print(f"ERROR: unknown index/indices: {', '.join(unknown)}", file=sys.stderr) return 2 disabled: set[str] = set() summary = {"high": 0, "medium": 0, "low": 0, "conflict": 0} flagged = [] for i, paper in enumerate(papers): per_index = {} for name in requested: if name in disabled: continue res = check_index(modules[name], paper, args.threshold, args.strong_threshold, args.limit) if res.get("available") is False: disabled.add(name) print(f"WARN: index '{name}' unreachable — disabling for the rest of this run.", file=sys.stderr) continue per_index[name] = res verdict = classify(paper, per_index, args.year_tolerance) summary[verdict["confidence"]] += 1 if verdict["confidence"] in ("low", "conflict"): flagged.append({ "bibtex_key": paper.get("bibtex_key") or paper.get("key"), "title": paper.get("title"), "confidence": verdict["confidence"], "notes": verdict["notes"], }) if args.inplace: paper["cross_verification"] = verdict # Throttle between papers (skip after the last one). if i < len(papers) - 1 and args.sleep > 0 and requested != list(disabled): time.sleep(args.sleep) report = { "total_papers": len(papers), "indices_checked": [x for x in requested if x not in disabled], "indices_unavailable": sorted(disabled), "summary": summary, "flagged": flagged, } out_path = args.out or os.path.join(os.path.dirname(os.path.abspath(args.pool)), "cross_verification_report.json") with open(out_path, "w") as f: json.dump(report, f, indent=2, ensure_ascii=False) if args.inplace: with open(args.pool, "w") as f: json.dump(pool, f, indent=2, ensure_ascii=False) # Human-readable summary to stdout. print(f"Cross-index verification ({', '.join(report['indices_checked']) or 'none'}):") print(f" {len(papers)} papers | high={summary['high']} " f"medium={summary['medium']} low={summary['low']} conflict={summary['conflict']}") print(f" report → {out_path}") if flagged: print(f"\nWARN: {len(flagged)} citation(s) need review:") for f_ in flagged: print(f" [{f_['confidence'].upper()}] {f_['bibtex_key']}: {f_['title']}") for note in f_["notes"]: print(f" → {note}") print("\nReview these before building refs.bib. Drop any you cannot corroborate.") if disabled: print(f"\nNote: {', '.join(sorted(disabled))} unavailable this run — " "corroboration is partial.", file=sys.stderr) return 1 if (flagged or disabled) else 0 if __name__ == "__main__": sys.exit(main()) -
dedupe_by_id.py 3.1 KB
#!/usr/bin/env python3 """ dedupe_by_id.py — Deduplicate a verified citation pool using Semantic Scholar unique paperId, with DOI / ArXiv / normalized-title fallbacks. Implements the paper's Rule 4 (App. D.3): "gathered citations are deduplicated using unique paper ID keys". Also computes `min_cite_paper_count = floor(0.9 * len(papers))` for the ≥90% citation integration rule. Usage: python dedupe_by_id.py --in raw_pool.json --out citation_pool.json [--cutoff 2024-10-01] """ import argparse import json import math import re import sys def norm_title(t: str) -> str: return re.sub(r"[^a-z0-9]", "", t.lower()) def make_key(paper: dict) -> str: if paper.get("paperId"): return f"s2:{paper['paperId']}" ext = paper.get("externalIds") or {} if ext.get("DOI"): return f"doi:{ext['DOI'].lower()}" if ext.get("ArXiv"): # strip version suffix if any a = ext["ArXiv"].split("v")[0] if "v" in ext["ArXiv"][-3:] else ext["ArXiv"] return f"arxiv:{a.lower()}" title = paper.get("title", "") return f"title:{norm_title(title)}" def main() -> int: p = argparse.ArgumentParser(description=__doc__) p.add_argument("--in", dest="inp", required=True, help="Raw verified pool JSON") p.add_argument("--out", required=True, help="Deduped citation_pool.json") p.add_argument("--cutoff", help="Cutoff date YYYY-MM-DD (recorded in output)") args = p.parse_args() with open(args.inp) as f: raw = json.load(f) candidates = raw.get("papers") or raw.get("candidates") or [] if not candidates: print("ERROR: input has neither 'papers' nor 'candidates' key", file=sys.stderr) return 1 by_key: dict[str, dict] = {} collisions: list[tuple[str, str]] = [] for c in candidates: key = make_key(c) if key in by_key: existing = by_key[key] score_new = c.get("match_score", 0) score_old = existing.get("match_score", 0) if score_new > score_old: # merge discovered_for merged = existing.get("discovered_for", []) + c.get("discovered_for", []) c["discovered_for"] = list(dict.fromkeys(merged)) # preserve order, dedupe by_key[key] = c else: merged = existing.get("discovered_for", []) + c.get("discovered_for", []) existing["discovered_for"] = list(dict.fromkeys(merged)) collisions.append((key, c.get("title", ""))) else: by_key[key] = c deduped = list(by_key.values()) n = len(deduped) min_cite = math.floor(0.9 * n) out = { "papers": deduped, "min_cite_paper_count": min_cite, "n_total": n, "n_collisions_merged": len(collisions), } if args.cutoff: out["cutoff_date"] = args.cutoff with open(args.out, "w") as f: json.dump(out, f, indent=2, ensure_ascii=False) print(f"OK: {len(candidates)} candidates → {n} unique papers") print(f" {len(collisions)} duplicates merged") print(f" min_cite_paper_count (≥90%): {min_cite}") return 0 if __name__ == "__main__": sys.exit(main()) -
exa_search.py 5.9 KB
#!/usr/bin/env python3 """ exa_search.py — Optional Exa (https://exa.ai) backend for the literature review agent's Phase 1 (parallel candidate discovery) step. Exa is a search engine optimized for finding academic papers and other high-quality content. It is OPTIONAL — the literature-review-agent works fine with any host coding agent's native web search tool. Use Exa only if: - Your host has no built-in web search (e.g., Aider, OpenCode, generic CLI agents). - You want a research-paper-focused search backend with better signal-to-noise than general web search. - You're running the pipeline in batch / non-interactive mode and want a deterministic, scriptable backend. This helper reads EXA_API_KEY from the environment. The key is YOUR responsibility to provide; this repo never commits one. Get a key at https://dashboard.exa.ai/. Usage: export EXA_API_KEY="your-key-here" python exa_search.py --query "Sparse attention long context" --num-results 15 python exa_search.py --query "..." --raw # full JSON python exa_search.py --query "..." --discovered-for "related_work[2.1]" Default output: JSON candidates in the literature-review-agent format, ready to be merged into raw_candidates.json before Phase 2 verification. Exit codes: 0 query succeeded 1 EXA_API_KEY missing, HTTP error, network error, or empty results """ import argparse import json import os import sys import urllib.error import urllib.request EXA_ENDPOINT = "https://api.exa.ai/search" DEFAULT_NUM = 10 MAX_NUM = 20 # the user explicitly asked for a 10-20 range SNIPPET_CAP = 1500 def search(query: str, num_results: int, category: str | None, highlight_max_chars: int) -> dict: api_key = os.environ.get("EXA_API_KEY") if not api_key: print( "ERROR: EXA_API_KEY environment variable not set.\n" "Get a key at https://dashboard.exa.ai/ and run:\n" ' export EXA_API_KEY="your-key-here"\n' "Then retry. The literature-review-agent also works without\n" "Exa — see references/discovery-pipeline.md for the default\n" "host-native web search path.", file=sys.stderr, ) sys.exit(1) body: dict = { "query": query, "numResults": num_results, "type": "auto", "contents": {"highlights": {"maxCharacters": highlight_max_chars}}, } if category: body["category"] = category req = urllib.request.Request( EXA_ENDPOINT, data=json.dumps(body).encode("utf-8"), headers={ "content-type": "application/json", "x-api-key": api_key, }, method="POST", ) try: with urllib.request.urlopen(req, timeout=30) as resp: return json.loads(resp.read().decode("utf-8")) except urllib.error.HTTPError as e: body_text = e.read().decode("utf-8", errors="replace")[:500] print(f"ERROR: Exa HTTP {e.code}: {body_text}", file=sys.stderr) sys.exit(1) except urllib.error.URLError as e: print(f"ERROR: Exa network error: {e.reason}", file=sys.stderr) sys.exit(1) def normalize(exa_response: dict, discovered_for: list[str]) -> list[dict]: """Convert Exa results into the literature-review-agent candidate format.""" candidates: list[dict] = [] for r in exa_response.get("results", []): title = (r.get("title") or "").strip() url = r.get("url") or r.get("id") or "" highlights = r.get("highlights") or [] snippet = " ".join(h.strip() for h in highlights)[:SNIPPET_CAP] candidates.append({ "title": title, "snippet": snippet, "source_url": url, "discovered_for": list(discovered_for), "_exa_id": r.get("id"), "_exa_published_date": r.get("publishedDate"), }) return candidates def main() -> int: p = argparse.ArgumentParser( description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter, ) p.add_argument("--query", required=True, help="Search query") p.add_argument("--num-results", type=int, default=DEFAULT_NUM, help=f"Number of results to fetch " f"(default {DEFAULT_NUM}, clamped to [1, {MAX_NUM}])") p.add_argument("--category", default="research paper", help='Exa category filter (default "research paper"; ' 'pass an empty string to disable)') p.add_argument("--highlight-chars", type=int, default=4000, help="Max characters per highlight (default 4000)") p.add_argument("--discovered-for", default="intro", help='Tag to attach to each candidate ' '(default "intro"). Use "related_work[2.1]" or ' 'similar for cluster-specific queries so the ' 'downstream citation_coverage gate can attribute ' 'the citation to the right section.') p.add_argument("--raw", action="store_true", help="Print the full Exa response JSON unmodified " "instead of normalized candidates") args = p.parse_args() n = max(1, min(MAX_NUM, args.num_results)) category = args.category or None response = search(args.query, n, category, args.highlight_chars) if not response.get("results"): print(f"WARN: Exa returned 0 results for query: {args.query!r}", file=sys.stderr) return 1 if args.raw: json.dump(response, sys.stdout, indent=2, ensure_ascii=False) else: candidates = normalize(response, [args.discovered_for]) json.dump({"candidates": candidates}, sys.stdout, indent=2, ensure_ascii=False) sys.stdout.write("\n") return 0 if __name__ == "__main__": sys.exit(main()) -
levenshtein_match.py 2.1 KB
#!/usr/bin/env python3 """ levenshtein_match.py — Fuzzy title match for citation verification. Implements the paper's Rule 1 (App. D.3): a candidate paper passes only if its title's Levenshtein ratio against the Semantic Scholar hit's title is strictly greater than 70. Includes a substring-bypass safety net for short candidate titles (the Linformer false-negative case): if the candidate is < 4 words and is contained as a substring in the S2 hit's title, return 100. Exit code is always 0; the integer ratio is printed to stdout. The caller parses it and decides whether to discard. Usage: python levenshtein_match.py --candidate "..." --found "..." python levenshtein_match.py --candidate "..." --found "..." --substring-bypass """ import argparse import re import sys try: import Levenshtein except ImportError: print("ERROR: python-Levenshtein required. Install with: pip install python-Levenshtein", file=sys.stderr) sys.exit(2) def normalize(s: str) -> str: s = s.lower().strip() s = re.sub(r"[^a-z0-9\s]", " ", s) s = re.sub(r"\s+", " ", s) return s def ratio(a: str, b: str, substring_bypass: bool = False) -> int: na, nb = normalize(a), normalize(b) r = int(round(Levenshtein.ratio(na, nb) * 100)) if substring_bypass and len(na.split()) < 4: if na in nb: return max(r, 95) return r def main() -> int: p = argparse.ArgumentParser(description=__doc__) p.add_argument("--candidate", required=True, help="The original candidate title (from web search)") p.add_argument("--found", required=True, help="The title returned by Semantic Scholar") p.add_argument("--substring-bypass", action="store_true", help="Bump short-candidate substring matches to 95") p.add_argument("--threshold", type=int, default=70, help="Print PASS/FAIL alongside the ratio (default 70)") args = p.parse_args() r = ratio(args.candidate, args.found, args.substring_bypass) verdict = "PASS" if r > args.threshold else "FAIL" print(f"{r} {verdict}") return 0 if __name__ == "__main__": sys.exit(main()) -
openalex_client.py 6 KB
#!/usr/bin/env python3 """ openalex_client.py — OpenAlex API title/DOI lookup for cross-index citation corroboration. The third index (alongside Semantic Scholar and Crossref) used by cross_verify.py. Triangulating across three independent scholarly indices is the practical defense against hallucinated citations: a fabricated paper may slip past one index but is unlikely to appear in all three with matching metadata. See references/cross-index-verification.md. No API key is required. OpenAlex offers a faster "polite pool" when you identify yourself via email. Set one of: export OPENALEX_MAILTO="you@example.com" export PAPER_ORCHESTRA_MAILTO="you@example.com" # shared fallback The email is sent only as the `mailto` query parameter, per OpenAlex docs. Usage: python openalex_client.py --query "Attention is All You Need" python openalex_client.py --doi 10.5555/3295222.3295349 python openalex_client.py --query "BERT pre-training" --raw Output (normalized): {"total": N, "data": [{title, year, doi, venue, authors}, ...]} Exit codes: 0 at least one result returned 1 HTTP error, network error, or zero results 2 usage error (bad arguments) """ import argparse import json import os import sys import time import urllib.error import urllib.parse import urllib.request OPENALEX_BASE = "https://api.openalex.org/works" DEFAULT_LIMIT = 5 MAX_LIMIT = 25 _RETRY_SLEEP = 5 def _mailto() -> str: return ( os.environ.get("OPENALEX_MAILTO", "").strip() or os.environ.get("PAPER_ORCHESTRA_MAILTO", "").strip() ) def _build_request(url: str) -> urllib.request.Request: return urllib.request.Request( url, headers={ "Accept": "application/json", "User-Agent": "paper-orchestra/1.0 (https://github.com/Ar9av/paper-orchestra)", }, method="GET", ) def _get(url: str, retries: int = 3) -> dict: for attempt in range(1, retries + 1): try: with urllib.request.urlopen(_build_request(url), timeout=30) as resp: return json.loads(resp.read().decode("utf-8")) except urllib.error.HTTPError as exc: if exc.code == 404: return {"results": []} if exc.code == 429 and attempt < retries: print(f"WARN: OpenAlex rate-limited (429). Sleeping {_RETRY_SLEEP}s " f"before retry {attempt + 1}/{retries}.", file=sys.stderr) time.sleep(_RETRY_SLEEP) continue if exc.code in (500, 502, 503) and attempt < retries: print(f"WARN: OpenAlex server error ({exc.code}). Retrying.", file=sys.stderr) time.sleep(10) continue print(f"ERROR: OpenAlex HTTP {exc.code}", file=sys.stderr) sys.exit(1) except urllib.error.URLError as exc: print(f"ERROR: Network error reaching OpenAlex: {exc.reason}", file=sys.stderr) sys.exit(1) sys.exit(1) def _bare_doi(doi_url: str | None) -> str: if not doi_url: return "" d = doi_url.strip().lower() for prefix in ("https://doi.org/", "http://doi.org/", "doi:"): if d.startswith(prefix): d = d[len(prefix):] return d def _normalize_work(work: dict) -> dict: venue = "" loc = work.get("primary_location") or {} src = loc.get("source") or {} if src.get("display_name"): venue = src["display_name"] authors = [] for a in work.get("authorships", []) or []: name = (a.get("author") or {}).get("display_name") if name: authors.append(name) return { "title": work.get("title") or work.get("display_name") or "", "year": work.get("publication_year"), "doi": _bare_doi(work.get("doi")), "venue": venue, "authors": authors, "type": work.get("type", ""), } def _with_mailto(params: dict) -> dict: mailto = _mailto() if mailto: params["mailto"] = mailto return params def search(query: str, limit: int) -> dict: params = _with_mailto({"search": query, "per-page": limit}) url = f"{OPENALEX_BASE}?{urllib.parse.urlencode(params)}" resp = _get(url) results = resp.get("results") or [] return {"raw": resp, "data": [_normalize_work(w) for w in results]} def lookup_doi(doi: str) -> dict: # OpenAlex resolves a single work via the /works/doi:<doi> path. path = f"{OPENALEX_BASE}/doi:{urllib.parse.quote(doi)}" mailto = _mailto() if mailto: path += f"?mailto={urllib.parse.quote(mailto)}" resp = _get(path) # A single-work response is the work object itself, not a results list. if "results" in resp: works = resp["results"] elif resp.get("id"): works = [resp] else: works = [] return {"raw": resp, "data": [_normalize_work(w) for w in works]} def main() -> int: p = argparse.ArgumentParser( description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter ) p.add_argument("--query", help="Paper title (full-text search)") p.add_argument("--doi", help="Look up an exact DOI instead of a title search") p.add_argument("--limit", type=int, default=DEFAULT_LIMIT, help=f"Max hits (default {DEFAULT_LIMIT}, max {MAX_LIMIT})") p.add_argument("--raw", action="store_true", help="Print full OpenAlex JSON") args = p.parse_args() if not args.query and not args.doi: print("ERROR: provide --query or --doi", file=sys.stderr) return 2 if args.doi: result = lookup_doi(args.doi.lower()) else: result = search(args.query, max(1, min(MAX_LIMIT, args.limit))) if args.raw: json.dump(result["raw"], sys.stdout, indent=2, ensure_ascii=False) sys.stdout.write("\n") return 0 if result["data"] else 1 data = result["data"] json.dump({"total": len(data), "data": data}, sys.stdout, indent=2, ensure_ascii=False) sys.stdout.write("\n") return 0 if data else 1 if __name__ == "__main__": sys.exit(main()) -
pre_dedup_candidates.py 4.9 KB
#!/usr/bin/env python3 """ pre_dedup_candidates.py — Deduplicate Phase 1 raw candidates by normalized title before Phase 2 Semantic Scholar verification. Multiple search queries in Phase 1 often return the same papers. Verifying duplicates wastes S2 quota (1 QPS hard cap) and adds 30-40% unnecessary wall-time. This script removes obvious duplicates — same paper found via multiple queries — before the sequential verification loop begins. Dedup strategy (in order of preference): 1. Exact arXiv ID match extracted from source URL or snippet. 2. Levenshtein ratio >= 92 on normalized titles (high threshold to avoid false collisions between similarly-named papers). When two candidates are considered the same, we keep the one that appeared earlier in the list and merge their `discovered_for` attribution tags so the surviving entry is credited to all originating queries. Usage: python pre_dedup_candidates.py \\ --in workspace/raw_candidates.json \\ --out workspace/deduped_candidates.json Input JSON shape: {"candidates": [{"title": "...", "url": "...", "snippet": "...", "discovered_for": ["intro.1"]}, ...]} OR a bare list. """ import argparse import json import re import sys ARXIV_RE = re.compile(r"arxiv\.org/(?:abs|pdf)/(\d{4}\.\d{4,5})", re.IGNORECASE) def norm_title(t: str) -> str: t = re.sub(r"[^a-z0-9 ]", " ", t.lower()) return " ".join(t.split()) def levenshtein_ratio(a: str, b: str) -> float: if not a and not b: return 100.0 if not a or not b: return 0.0 la, lb = len(a), len(b) if la < lb: a, b = b, a la, lb = lb, la prev = list(range(lb + 1)) for i, ca in enumerate(a): curr = [i + 1] for j, cb in enumerate(b): cost = 0 if ca == cb else 1 curr.append(min(prev[j + 1] + 1, curr[j] + 1, prev[j] + cost)) prev = curr dist = prev[lb] return (1.0 - dist / max(la, lb)) * 100.0 def extract_arxiv_id(candidate: dict) -> str | None: for text in (candidate.get("url", ""), candidate.get("snippet", "")): m = ARXIV_RE.search(text) if m: return m.group(1) return None def make_exact_key(candidate: dict) -> str: """Canonical key: arXiv ID if extractable, else normalized title.""" aid = extract_arxiv_id(candidate) if aid: return f"arxiv:{aid}" return f"title:{norm_title(candidate.get('title', ''))}" def merge_discovered_for(a: dict, b: dict) -> list: df_a = a.get("discovered_for") or [] df_b = b.get("discovered_for") or [] return list(dict.fromkeys(df_a + df_b)) def dedup(candidates: list[dict], title_ratio_threshold: float = 92.0) -> list[dict]: # Pass 1: exact key dedup (arXiv ID or identical normalized title) by_key: dict[str, dict] = {} for c in candidates: key = make_exact_key(c) if key in by_key: by_key[key]["discovered_for"] = merge_discovered_for(by_key[key], c) else: by_key[key] = dict(c) deduped = list(by_key.values()) # Pass 2: fuzzy title dedup — O(n²) but n is ~50-100 candidates max normed = [norm_title(c.get("title", "")) for c in deduped] drop: set[int] = set() for i in range(len(deduped)): if i in drop: continue for j in range(i + 1, len(deduped)): if j in drop: continue if levenshtein_ratio(normed[i], normed[j]) >= title_ratio_threshold: deduped[i]["discovered_for"] = merge_discovered_for(deduped[i], deduped[j]) drop.add(j) return [c for idx, c in enumerate(deduped) if idx not in drop] def main() -> int: p = argparse.ArgumentParser(description=__doc__) p.add_argument("--in", dest="inp", required=True, help="Raw Phase 1 candidates JSON") p.add_argument("--out", required=True, help="Deduped candidates JSON") p.add_argument("--title-ratio", type=float, default=92.0, help="Levenshtein ratio threshold for fuzzy title match (default: 92)") args = p.parse_args() with open(args.inp) as f: raw = json.load(f) if isinstance(raw, list): candidates = raw else: candidates = raw.get("candidates") or raw.get("papers") or [] if not isinstance(candidates, list): print("ERROR: input must be a JSON array or object with 'candidates' key", file=sys.stderr) return 1 before = len(candidates) result = dedup(candidates, title_ratio_threshold=args.title_ratio) after = len(result) removed = before - after out_obj = { "candidates": result, "n_before_dedup": before, "n_after_dedup": after, "n_removed": removed, } with open(args.out, "w") as f: json.dump(out_obj, f, indent=2, ensure_ascii=False) print(f"OK: {before} candidates → {after} unique ({removed} duplicates removed)") return 0 if __name__ == "__main__": sys.exit(main()) -
s2_cache.py 3.5 KB
#!/usr/bin/env python3 """ s2_cache.py — Persistent Semantic Scholar verification cache. Problem: Phase 2 verification is throttled to 1 QPS. If a pipeline run fails partway through (gate error, network timeout, interrupted session), re-running wastes the full S2 wait time again on already-verified papers. Solution: a flat JSON cache at workspace/cache/s2_cache.json. On a cache HIT the script emits the stored response and exits 0 so the caller can skip the live S2 request. On a cache MISS it exits 1. After a live request the caller stores the result with --store. The cache key is derived from the normalized query title (lowercase, alphanumeric only) so minor whitespace differences still hit. Usage: CHECK mode — exits 0 + prints JSON if cached, else exits 1: python s2_cache.py --cache workspace/cache/s2_cache.json \\ --check "Attention Is All You Need" STORE mode — write a response into the cache: python s2_cache.py --cache workspace/cache/s2_cache.json \\ --store "Attention Is All You Need" \\ --response '{"paperId": "...", "title": "..."}' STATS mode — print cache size and hit rate summary: python s2_cache.py --cache workspace/cache/s2_cache.json --stats """ import argparse import json import os import re import sys def norm_key(title: str) -> str: """Lowercase, alphanumeric-only cache key.""" return re.sub(r"[^a-z0-9]", "", title.lower()) def load_cache(path: str) -> dict: if os.path.isfile(path): with open(path) as f: try: return json.load(f) except json.JSONDecodeError: return {} return {} def save_cache(path: str, cache: dict) -> None: os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True) with open(path, "w") as f: json.dump(cache, f, indent=2, ensure_ascii=False) def main() -> int: p = argparse.ArgumentParser(description=__doc__) p.add_argument("--cache", required=True, help="Path to cache JSON file") mode = p.add_mutually_exclusive_group(required=True) mode.add_argument("--check", metavar="TITLE", help="Check for title; exit 0 + print JSON if found, else exit 1") mode.add_argument("--store", metavar="TITLE", help="Store a response for TITLE (requires --response)") mode.add_argument("--stats", action="store_true", help="Print cache statistics") p.add_argument("--response", metavar="JSON", help="S2 response JSON to store (used with --store)") args = p.parse_args() cache = load_cache(args.cache) if args.stats: print(f"Cache file : {args.cache}") print(f"Entries : {len(cache)}") if cache: print("Sample keys:", list(cache.keys())[:5]) return 0 if args.check: key = norm_key(args.check) if key in cache: print(json.dumps(cache[key])) return 0 # HIT return 1 # MISS # --store mode if not args.response: print("ERROR: --store requires --response", file=sys.stderr) return 2 try: response = json.loads(args.response) except json.JSONDecodeError as e: print(f"ERROR: invalid JSON in --response: {e}", file=sys.stderr) return 2 key = norm_key(args.store) cache[key] = response save_cache(args.cache, cache) print(f"OK: cached '{args.store}' → key '{key}' ({len(cache)} total entries)") return 0 if __name__ == "__main__": sys.exit(main()) -
s2_search.py 7 KB
#!/usr/bin/env python3 """ s2_search.py — Semantic Scholar title-search helper for Phase 2 verification. Queries the Semantic Scholar Graph API for a paper by title and returns the top candidate hits as JSON. Used by the literature-review-agent to verify each candidate from Phase 1 before adding it to citation_pool.json. API key (optional): If SEMANTIC_SCHOLAR_API_KEY is set in the environment the key is forwarded via the ``x-api-key`` header, which raises the rate limit from ~100 req/5 min (unauthenticated) to 1 req/s sustained with higher burst headroom. If the variable is absent the script falls back to the public unauthenticated endpoint — the pipeline works fine without a key; just keep to ≤1 QPS. Get a free key at: https://api.semanticscholar.org/ Then export it once before running the pipeline: export SEMANTIC_SCHOLAR_API_KEY="your-key-here" Usage: # check for key and search python s2_search.py --query "Attention is All You Need" # request more hits and extra fields python s2_search.py --query "BERT pre-training" --limit 10 \\ --fields title,abstract,year,authors,venue,externalIds,citationCount # pretty-print raw S2 JSON python s2_search.py --query "GPT-4 technical report" --raw Exit codes: 0 at least one result returned 1 HTTP error, network error, or zero results 2 usage error (bad arguments) """ import argparse import json import os import sys import time import urllib.error import urllib.parse import urllib.request S2_BASE = "https://api.semanticscholar.org/graph/v1" DEFAULT_FIELDS = "title,abstract,year,authors,venue,externalIds" DEFAULT_LIMIT = 5 MAX_LIMIT = 100 _RETRY_SLEEP = 5 # seconds to wait after a 429 before retrying def _build_headers() -> dict: headers = {"Accept": "application/json"} api_key = os.environ.get("SEMANTIC_SCHOLAR_API_KEY", "").strip() if api_key: headers["x-api-key"] = api_key return headers def search(query: str, limit: int, fields: str, retries: int = 3) -> dict: """ Call /paper/search and return the parsed JSON response. Raises SystemExit on unrecoverable errors so the caller (or CLI) gets a clean non-zero exit code. """ params = urllib.parse.urlencode({ "query": query, "limit": limit, "fields": fields, }) url = f"{S2_BASE}/paper/search?{params}" headers = _build_headers() for attempt in range(1, retries + 1): req = urllib.request.Request(url, headers=headers, method="GET") try: with urllib.request.urlopen(req, timeout=30) as resp: return json.loads(resp.read().decode("utf-8")) except urllib.error.HTTPError as exc: if exc.code == 429: if attempt < retries: print( f"WARN: S2 rate-limited (429). Sleeping {_RETRY_SLEEP}s " f"before retry {attempt + 1}/{retries}.", file=sys.stderr, ) time.sleep(_RETRY_SLEEP) continue print( "ERROR: S2 rate-limited (429) and retries exhausted.\n" "Tip: set SEMANTIC_SCHOLAR_API_KEY to get a higher rate limit.\n" " See https://api.semanticscholar.org/ for a free key.", file=sys.stderr, ) sys.exit(1) if exc.code == 404: # not found — return an empty result set (caller handles this) return {"total": 0, "data": []} if exc.code in (500, 502, 503): if attempt < retries: print( f"WARN: S2 server error ({exc.code}). Sleeping 30s before " f"retry {attempt + 1}/{retries}.", file=sys.stderr, ) time.sleep(30) continue print( f"ERROR: S2 server error ({exc.code}) after {retries} attempts.", file=sys.stderr, ) sys.exit(1) body = exc.read().decode("utf-8", errors="replace")[:400] print(f"ERROR: S2 HTTP {exc.code}: {body}", file=sys.stderr) sys.exit(1) except urllib.error.URLError as exc: print(f"ERROR: Network error reaching Semantic Scholar: {exc.reason}", file=sys.stderr) sys.exit(1) # should never reach here sys.exit(1) def main() -> int: p = argparse.ArgumentParser( description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter, ) p.add_argument( "--query", required=True, help="Paper title (or search query) to look up on Semantic Scholar", ) p.add_argument( "--limit", type=int, default=DEFAULT_LIMIT, help=f"Max hits to return (default {DEFAULT_LIMIT}, max {MAX_LIMIT})", ) p.add_argument( "--fields", default=DEFAULT_FIELDS, help=f"Comma-separated S2 fields to request (default: {DEFAULT_FIELDS})", ) p.add_argument( "--raw", action="store_true", help="Print the full S2 JSON response unmodified instead of normalized output", ) p.add_argument( "--check-key", action="store_true", help="Print whether SEMANTIC_SCHOLAR_API_KEY is set and exit (no network call)", ) args = p.parse_args() if args.check_key: key = os.environ.get("SEMANTIC_SCHOLAR_API_KEY", "").strip() if key: masked = key[:4] + "..." + key[-4:] if len(key) > 8 else "****" print(f"SEMANTIC_SCHOLAR_API_KEY is set ({masked}). " "Authenticated mode: higher rate limits.") else: print( "SEMANTIC_SCHOLAR_API_KEY is NOT set. " "Unauthenticated mode: ~100 req/5 min, keep to ≤1 QPS.\n" "To enable higher rate limits:\n" " 1. Get a free key at https://api.semanticscholar.org/\n" ' 2. export SEMANTIC_SCHOLAR_API_KEY="your-key-here"' ) return 0 limit = max(1, min(MAX_LIMIT, args.limit)) response = search(args.query, limit, args.fields) if args.raw: json.dump(response, sys.stdout, indent=2, ensure_ascii=False) sys.stdout.write("\n") return 0 data = response.get("data") or [] if not data: print( f"WARN: Semantic Scholar returned 0 results for query: {args.query!r}", file=sys.stderr, ) json.dump({"total": 0, "data": []}, sys.stdout, indent=2) sys.stdout.write("\n") return 1 # Emit normalized output (subset of fields used by pipeline) out = { "total": response.get("total", len(data)), "authenticated": bool(os.environ.get("SEMANTIC_SCHOLAR_API_KEY", "").strip()), "data": data, } json.dump(out, sys.stdout, indent=2, ensure_ascii=False) sys.stdout.write("\n") return 0 if __name__ == "__main__": sys.exit(main()) -
sync_keys.py 4 KB
#!/usr/bin/env python3 r""" sync_keys.py — Synchronize citation keys in a .tex file with the canonical bibtex_key values stored in citation_pool.json. Problem: The Literature Review Agent writes cite keys in its own format (e.g. 'lewis2020rag'), while bibtex_format.py generates canonical keys from author + year + first-significant-title-word (e.g. 'lewis2020retrievalaugmented'). After running bibtex_format.py these two sources are out of sync, causing the citation_coverage gate to fail (it looks for \cite{canonical_key} in the .tex). This script reads the 'key' -> 'bibtex_key' mapping from citation_pool.json and performs a targeted substitution inside \cite{}, \citep{}, \citet{} commands in the target .tex file. It handles multi-key citations like \cite{a,b,c} correctly. Run this immediately after bibtex_format.py, before Step 4 (Section Writing). Usage: python sync_keys.py \ --pool workspace/citation_pool.json \ --tex workspace/drafts/intro_relwork.tex \ --inplace # Without --inplace: prints updated content to stdout (safe preview mode). """ import argparse import json import re import sys # Matches \cite, \citep, \citet, \citealt, \citealp, \citeauthor, \citeyear, # starred variants like \cite*, and the optional [prenote][postnote] args. CITE_RE = re.compile( r"(\\cite[a-zA-Z*]*)" # command r"(?:\[[^\]]*\])*" # optional bracket args (prenote/postnote) r"\{([^}]+)\}" # required brace arg with keys ) def build_key_map(pool: dict) -> dict[str, str]: """Return {agent_key: bibtex_key} for every paper where they differ.""" key_map: dict[str, str] = {} for paper in pool.get("papers", []): old = paper.get("key") new = paper.get("bibtex_key") if old and new and old != new: key_map[old] = new return key_map def replace_keys(content: str, key_map: dict[str, str]) -> tuple[str, int]: if not key_map: return content, 0 n_replaced = 0 def replacer(m: re.Match) -> str: nonlocal n_replaced cmd = m.group(1) keys_str = m.group(2) keys = [k.strip() for k in keys_str.split(",")] new_keys: list[str] = [] for k in keys: if k in key_map: new_keys.append(key_map[k]) n_replaced += 1 else: new_keys.append(k) # Reconstruct original bracket args (they were consumed by the regex # but we don't need to preserve them specially — re-emit as matched) full_match = m.group(0) # Rebuild: command + everything between command and { + new keys bracket_part = full_match[len(cmd):full_match.index("{")] return f"{cmd}{bracket_part}{{{', '.join(new_keys)}}}" updated = CITE_RE.sub(replacer, content) return updated, n_replaced def main() -> int: p = argparse.ArgumentParser(description=__doc__) p.add_argument("--pool", required=True, help="citation_pool.json") p.add_argument("--tex", required=True, help="Target .tex file to update") p.add_argument("--inplace", action="store_true", help="Overwrite --tex in place (default: print to stdout)") args = p.parse_args() with open(args.pool) as f: pool = json.load(f) key_map = build_key_map(pool) if not key_map: print("OK: no key differences in citation_pool.json — nothing to sync") return 0 print(f"Key map ({len(key_map)} substitutions):") for old, new in key_map.items(): print(f" {old} → {new}") with open(args.tex) as f: content = f.read() updated, n = replace_keys(content, key_map) if args.inplace: with open(args.tex, "w") as f: f.write(updated) print(f"OK: {n} citation key(s) updated in {args.tex}") else: sys.stdout.write(updated) print(f"\n# sync_keys: {n} substitution(s) would be made", file=sys.stderr) return 0 if __name__ == "__main__": sys.exit(main()) -
tavily_search.py 6.1 KB
#!/usr/bin/env python3 """ tavily_search.py — Optional Tavily (https://tavily.com) backend for the literature review agent's Phase 1 (parallel candidate discovery) step. Tavily is a search API designed for LLMs, enabling AI applications to access real-time web data. It is OPTIONAL — the literature-review-agent works fine with any host coding agent's native web search tool. Use Tavily only if: - Your host has no built-in web search (e.g., Aider, OpenCode, generic CLI agents). - You want an LLM-optimized search backend with high relevance scoring. - You're running the pipeline in batch / non-interactive mode and want a deterministic, scriptable backend. This helper reads TAVILY_API_KEY from the environment. The key is YOUR responsibility to provide; this repo never commits one. Get a key at https://app.tavily.com (1,000 free credits/month). Usage: export TAVILY_API_KEY="tvly-your-key-here" python tavily_search.py --query "Sparse attention long context" --num-results 15 python tavily_search.py --query "..." --raw # full JSON python tavily_search.py --query "..." --discovered-for "related_work[2.1]" Default output: JSON candidates in the literature-review-agent format, ready to be merged into raw_candidates.json before Phase 2 verification. Exit codes: 0 query succeeded 1 TAVILY_API_KEY missing, HTTP error, network error, or empty results """ import argparse import json import os import sys import urllib.error import urllib.request TAVILY_ENDPOINT = "https://api.tavily.com/search" DEFAULT_NUM = 10 MAX_NUM = 20 SNIPPET_CAP = 1500 # Academic domains to boost research-paper results ACADEMIC_DOMAINS = [ "arxiv.org", "scholar.google.com", "semanticscholar.org", "aclanthology.org", "openreview.net", ] def search(query: str, num_results: int, topic: str, include_domains: list[str] | None) -> dict: api_key = os.environ.get("TAVILY_API_KEY") if not api_key: print( "ERROR: TAVILY_API_KEY environment variable not set.\n" "Get a key at https://app.tavily.com and run:\n" ' export TAVILY_API_KEY="tvly-your-key-here"\n' "Then retry. The literature-review-agent also works without\n" "Tavily — see references/discovery-pipeline.md for the default\n" "host-native web search path.", file=sys.stderr, ) sys.exit(1) body: dict = { "query": query, "max_results": num_results, "search_depth": "advanced", "topic": topic, } if include_domains: body["include_domains"] = include_domains req = urllib.request.Request( TAVILY_ENDPOINT, data=json.dumps(body).encode("utf-8"), headers={ "Content-Type": "application/json", "Authorization": f"Bearer {api_key}", }, method="POST", ) try: with urllib.request.urlopen(req, timeout=30) as resp: return json.loads(resp.read().decode("utf-8")) except urllib.error.HTTPError as e: body_text = e.read().decode("utf-8", errors="replace")[:500] print(f"ERROR: Tavily HTTP {e.code}: {body_text}", file=sys.stderr) sys.exit(1) except urllib.error.URLError as e: print(f"ERROR: Tavily network error: {e.reason}", file=sys.stderr) sys.exit(1) def normalize(tavily_response: dict, discovered_for: list[str]) -> list[dict]: """Convert Tavily results into the literature-review-agent candidate format.""" candidates: list[dict] = [] for r in tavily_response.get("results", []): title = (r.get("title") or "").strip() url = r.get("url") or "" content = (r.get("content") or "").strip() snippet = content[:SNIPPET_CAP] candidates.append({ "title": title, "snippet": snippet, "source_url": url, "discovered_for": list(discovered_for), "_tavily_score": r.get("score"), }) return candidates def main() -> int: p = argparse.ArgumentParser( description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter, ) p.add_argument("--query", required=True, help="Search query") p.add_argument("--num-results", type=int, default=DEFAULT_NUM, help=f"Number of results to fetch " f"(default {DEFAULT_NUM}, clamped to [1, {MAX_NUM}])") p.add_argument("--topic", default="general", choices=["general", "news"], help='Tavily topic filter (default "general"; ' 'use "news" for recent results)') p.add_argument("--academic", action="store_true", help="Restrict results to academic domains " "(arxiv.org, scholar.google.com, etc.)") p.add_argument("--discovered-for", default="intro", help='Tag to attach to each candidate ' '(default "intro"). Use "related_work[2.1]" or ' 'similar for cluster-specific queries so the ' 'downstream citation_coverage gate can attribute ' 'the citation to the right section.') p.add_argument("--raw", action="store_true", help="Print the full Tavily response JSON unmodified " "instead of normalized candidates") args = p.parse_args() n = max(1, min(MAX_NUM, args.num_results)) include_domains = ACADEMIC_DOMAINS if args.academic else None response = search(args.query, n, args.topic, include_domains) if not response.get("results"): print(f"WARN: Tavily returned 0 results for query: {args.query!r}", file=sys.stderr) return 1 if args.raw: json.dump(response, sys.stdout, indent=2, ensure_ascii=False) else: candidates = normalize(response, [args.discovered_for]) json.dump({"candidates": candidates}, sys.stdout, indent=2, ensure_ascii=False) sys.stdout.write("\n") return 0 if __name__ == "__main__": sys.exit(main()) -
validate_pool.py 4.8 KB
#!/usr/bin/env python3 """ validate_pool.py — Validate and auto-fix citation_pool.json before it is passed to bibtex_format.py or the Section Writing Agent. Catches the two most common schema errors produced by the Literature Review Agent and fixes them in place with --fix. Error 1 — Authors as plain strings (WRONG format for bibtex_format.py): WRONG: "authors": ["Alice Smith", "Bob Jones"] CORRECT: "authors": [{"name": "Alice Smith"}, {"name": "Bob Jones"}] Error 2 — Missing required fields (title, year). These cause bibtex_format.py to emit incomplete entries. Reported as errors, not auto-fixed. Also checks that the pool has the top-level keys that downstream scripts expect: "papers", "min_cite_paper_count". Exit codes: 0 Pool is valid (or was fully fixed with --fix) 1 Unrecoverable errors remain (missing required fields, no papers) Usage: python validate_pool.py --pool workspace/citation_pool.json python validate_pool.py --pool workspace/citation_pool.json --fix """ import argparse import json import sys REQUIRED_PAPER_FIELDS = ["title", "year"] RECOMMENDED_PAPER_FIELDS = ["paperId", "abstract", "venue", "authors"] REQUIRED_TOP_FIELDS = ["papers", "min_cite_paper_count"] def validate_and_fix(pool: dict, fix: bool) -> tuple[list[str], list[str], int]: """ Returns (errors, warnings, n_fixed). If fix=True, mutates pool in place where possible. """ errors: list[str] = [] warnings: list[str] = [] n_fixed = 0 # Top-level structure for field in REQUIRED_TOP_FIELDS: if field not in pool: warnings.append(f"top-level field '{field}' missing — was dedupe_by_id.py run?") papers = pool.get("papers", []) if not papers: errors.append("pool['papers'] is empty or missing") return errors, warnings, n_fixed for i, paper in enumerate(papers): label = paper.get("title") or f"paper #{i}" # --- Authors format check --- authors = paper.get("authors") if authors is not None: if not isinstance(authors, list): errors.append(f"[{label}] 'authors' must be a list, got {type(authors).__name__}") elif authors: if isinstance(authors[0], str): if fix: paper["authors"] = [{"name": a} for a in authors] n_fixed += 1 else: errors.append( f"[{label}] authors are plain strings " f"(e.g. \"{authors[0]}\") — run with --fix to auto-convert" ) elif not isinstance(authors[0], dict): errors.append( f"[{label}] authors[0] is {type(authors[0]).__name__}, " f"expected dict with 'name' key" ) # --- Required fields --- for field in REQUIRED_PAPER_FIELDS: if not paper.get(field): errors.append(f"[{label}] missing required field '{field}'") # --- Recommended fields --- for field in RECOMMENDED_PAPER_FIELDS: if not paper.get(field): warnings.append(f"[{label}] missing recommended field '{field}'") return errors, warnings, n_fixed def main() -> int: p = argparse.ArgumentParser(description=__doc__) p.add_argument("--pool", required=True, help="citation_pool.json path") p.add_argument("--fix", action="store_true", help="Auto-fix recoverable errors (authors format) and write back") p.add_argument("--quiet", action="store_true", help="Suppress warnings, only show errors") args = p.parse_args() with open(args.pool) as f: pool = json.load(f) errors, warnings, n_fixed = validate_and_fix(pool, fix=args.fix) if not args.quiet: for w in warnings: print(f"WARN: {w}") had_errors = bool(errors) for e in errors: print(f"ERROR: {e}", file=sys.stderr) if had_errors and not args.fix: print( "\nTip: re-run with --fix to auto-correct recoverable issues (authors format).", file=sys.stderr, ) return 1 if n_fixed > 0: with open(args.pool, "w") as f: json.dump(pool, f, indent=2, ensure_ascii=False) print(f"OK: {n_fixed} paper(s) auto-fixed and written back to {args.pool}") n = len(pool.get("papers", [])) if not had_errors and n_fixed == 0: print(f"OK: {n} papers validated — no errors") elif n_fixed > 0 and not errors: print(f"OK: {n} papers validated after auto-fix") return 0 if (not errors or (args.fix and n_fixed > 0 and not [e for e in errors if "missing required" in e])) else 1 if __name__ == "__main__": sys.exit(main())
-
-
SKILL.md 20.3 KB
--- name: literature-review-agent description: Step 3 of the PaperOrchestra pipeline (arXiv:2604.05018). Execute the literature search strategy from outline.json — discover candidate papers via web search, verify them through Semantic Scholar (Levenshtein > 70 fuzzy title match, temporal cutoff, dedup by paperId), cross-corroborate against Crossref + OpenAlex to flag hallucinated citations, build a BibTeX file, and draft Introduction + Related Work using ≥90% of the verified pool. Runs in parallel with the plotting-agent. TRIGGER when the orchestrator delegates Step 3 or when the user asks to "find citations for my paper", "draft the related work", or "build the bibliography". --- # Literature Review Agent (Step 3) Faithful implementation of the Hybrid Literature Agent from PaperOrchestra (Song et al., 2026, arXiv:2604.05018, §4 Step 3, App. D.3, App. F.1 p.46). **Cost: ~20–30 LLM calls.** This is one of the two longest steps (the other is plotting). Wall-time floor is set by Semantic Scholar's 1 QPS verification limit. ## Inputs - `workspace/outline.json` — specifically `intro_related_work_plan` with the Introduction search directions and the 2-4 Related Work methodology clusters - `workspace/inputs/conference_guidelines.md` — used to derive `cutoff_date` - `workspace/inputs/idea.md`, `workspace/inputs/experimental_log.md` — for framing the Intro and grounding the Related Work positioning ## Outputs - `workspace/citation_pool.json` — verified Semantic Scholar metadata for every paper that survived verification - `workspace/refs.bib` — BibTeX file generated from the verified pool - `workspace/drafts/intro_relwork.tex` — drafted Introduction and Related Work sections, written into the template, with the rest of the template preserved verbatim ## Two-phase pipeline (App. D.3) ``` PHASE 1 — Parallel Candidate Discovery For each search direction in introduction_strategy.search_directions: For each limitation_search_query in each related_work cluster: - Use the host's web search tool to discover up to ~10 candidate papers. - Run up to 10 discovery queries in parallel (host-permitting). - Collect (title, snippet, url) tuples — no verification yet. → PRE-DEDUP before Phase 2 (see Step 1.5 below) PHASE 2 — Sequential Citation Verification (1 QPS, with cache) For each candidate (after pre-dedup), sequentially: 0. Check s2_cache.json first (scripts/s2_cache.py --check). If HIT: use cached response, skip live S2 call. No throttle needed. If MISS: proceed with live request below. 1. Query Semantic Scholar by title: GET https://api.semanticscholar.org/graph/v1/paper/search?query=<title> &fields=title,abstract,year,authors,venue,externalIds&limit=5 (Public endpoint, no key. Throttle to 1 QPS for live requests only.) 2. Store the S2 response in cache: s2_cache.py --store. 3. Pick the top hit. Check Levenshtein title ratio against the original candidate title. If ratio < 70: discard. 4. Bonus: if year and venue exactly align with hints, add a +5 point match-quality bonus. 5. Require: abstract is non-empty. 6. Require: paper.year (or month if known) strictly predates cutoff_date. Months default to day-1: e.g., "October 2024" → 2024-10-01. 7. If all checks pass, add to verified pool. After all candidates are verified, dedup by Semantic Scholar paperId. ``` The host agent does the LLM/web work; the deterministic helpers in `scripts/` do the math. ## Step-by-step ### 0. Derive `cutoff_date` Parse `conference_guidelines.md` for the submission deadline. The paper aligns research cutoff with venue submission deadline (App. D.1): | Venue | Cutoff | |---|---| | CVPR 2025 | Nov 2024 | | ICLR 2025 | Oct 2024 | | Other | One month before the stated submission deadline | Encode as `YYYY-MM-DD`. Months default to day-1 (e.g., `2024-10-01`). ### 1. Phase 1: Parallel Candidate Discovery From `outline.json`: - All `introduction_strategy.search_directions` (3-5 queries) - For each cluster in `related_work_strategy.subsections`: - The cluster's `sota_investigation_mission` becomes a search query - All `limitation_search_queries` (1-3 each) For each query, **use your host's web search tool** (e.g., `WebSearch` in Claude Code, `@web` in Cursor, the search tool in Antigravity). Collect the top ~10 candidates per query: title, abstract snippet, source URL. If your host supports parallel sub-tasks, fire up to 10 concurrent search queries. If not, run sequentially — slower but functionally equivalent. #### Optional: Exa as a Phase 1 backend If your host has no native web search, OR you want a research-paper-focused backend with better signal-to-noise, you can use [Exa](https://exa.ai) via the bundled `scripts/exa_search.py` helper. It is **opt-in** and reads `EXA_API_KEY` from the environment — the repo never commits a key. ```bash export EXA_API_KEY="your-key-here" # get one at https://dashboard.exa.ai/ python skills/literature-review-agent/scripts/exa_search.py \ --query "Sparse attention long context transformers" \ --num-results 15 \ --discovered-for "related_work[2.1]" ``` Output is a normalized candidate list ready to merge into `raw_candidates.json`. Phase 2 verification (Semantic Scholar fuzzy match, cutoff, dedup) is unchanged. See `references/exa-search-cookbook.md` for the full recipe, query patterns, cost estimates, and security notes. #### Optional: Tavily as a Phase 1 backend If your host has no native web search, OR you want an LLM-optimized search backend with high relevance scoring, you can use [Tavily](https://tavily.com) via the bundled `scripts/tavily_search.py` helper. It is **opt-in** and reads `TAVILY_API_KEY` from the environment — the repo never commits a key. ```bash export TAVILY_API_KEY="tvly-your-key-here" # get one at https://app.tavily.com python skills/literature-review-agent/scripts/tavily_search.py \ --query "Sparse attention long context transformers" \ --num-results 15 \ --academic \ --discovered-for "related_work[2.1]" ``` Output is a normalized candidate list ready to merge into `raw_candidates.json`. Phase 2 verification (Semantic Scholar fuzzy match, cutoff, dedup) is unchanged. See `references/tavily-search-cookbook.md` for the full recipe, query patterns, cost estimates, and security notes. Combine all discovered candidates into a single working list. Tag each with the originating query ID so you can later attribute it to "intro" vs "related_work[i]". ### 1.5. Pre-dedup before Phase 2 **Always run this before starting Phase 2.** Multiple search queries routinely return the same papers (e.g., "Attention is All You Need" appears in almost every NLP discovery query). Verifying duplicates wastes 30-40% of S2 quota at 1 QPS. ```bash python skills/literature-review-agent/scripts/pre_dedup_candidates.py \ --in workspace/raw_candidates.json \ --out workspace/deduped_candidates.json # Prints: "150 candidates → 97 unique (53 duplicates removed)" ``` Use `workspace/deduped_candidates.json` as input to Phase 2. ### 2. Phase 2: Sequential Verification via Semantic Scholar (with cache) For each candidate in `deduped_candidates.json`, in **sequential** order: **Step A — check cache first** (no S2 call, no throttle needed): ```bash python skills/literature-review-agent/scripts/s2_cache.py \ --cache workspace/cache/s2_cache.json \ --check "<candidate title>" # exit 0 + prints JSON → use cached response, skip Step B # exit 1 → proceed to Step B ``` **Step B — live S2 request** (cache MISS only, throttle to 1 QPS): **Preferred:** use the bundled `scripts/s2_search.py` helper — it handles auth, retries, and 429 back-off automatically: ```bash python skills/literature-review-agent/scripts/s2_search.py \ --query "<URL-decoded candidate title>" --limit 5 # If SEMANTIC_SCHOLAR_API_KEY is set the key is forwarded automatically. # If not, the public unauthenticated endpoint is used (≤1 QPS, still works). ``` Check whether the key is configured before starting Phase 2: ```bash python skills/literature-review-agent/scripts/s2_search.py --check-key ``` **Fallback:** if you prefer your host's URL fetch tool, GET: ``` https://api.semanticscholar.org/graph/v1/paper/search?query=<URL-encoded title>&limit=5&fields=title,abstract,year,authors,venue,externalIds ``` Add header `x-api-key: <SEMANTIC_SCHOLAR_API_KEY>` if the env var is set. Be polite: ≤1 request per second for live requests. Cache hits are free. **Step C — store in cache** (after every successful live request): ```bash python skills/literature-review-agent/scripts/s2_cache.py \ --cache workspace/cache/s2_cache.json \ --store "<candidate title>" \ --response '<full S2 JSON response>' ``` For the top hit: ```bash python skills/literature-review-agent/scripts/levenshtein_match.py \ --candidate "Original candidate title" \ --found "S2 returned title" # prints integer 0-100. Discard if < 70. ``` Then check the temporal cutoff: ```bash python skills/literature-review-agent/scripts/check_cutoff.py \ --paper-year 2024 \ --paper-month 9 \ --cutoff 2024-10-01 # exit 0 if strictly predates, exit 1 if not ``` If both checks pass AND the abstract is non-empty, append the paper's full S2 metadata to the verified pool. ### 3. Dedup and assemble the pool After all candidates are verified: ```bash python skills/literature-review-agent/scripts/dedupe_by_id.py \ --in raw_pool.json \ --out workspace/citation_pool.json ``` The dedupe script keys on `paperId` (Semantic Scholar's internal unique ID), falling back to `externalIds.DOI`, then `externalIds.ArXiv`, then a normalized title. The script also computes and writes `min_cite_paper_count` = `floor(0.9 * len(papers))` — the minimum number of papers the writing step must cite (the paper's ≥90% integration rule, App. D.3). **Immediately after dedupe_by_id.py**, validate and auto-fix the pool schema: ```bash python skills/literature-review-agent/scripts/validate_pool.py \ --pool workspace/citation_pool.json --fix # Catches and fixes authors-as-strings, reports missing required fields. # Must pass before proceeding to Step 4. ``` ### 3.5. Cross-index verification (Crossref + OpenAlex) Semantic Scholar is one index and can return a plausible record for a paper that does not exist, or attach wrong metadata. Re-check every S2-verified paper against two **independent** indices before building the bibliography — this is the practical defense against hallucinated citations leaking in. ```bash # Optional but recommended: a polite-pool email gives faster, more reliable # service. The repo never commits an address. export PAPER_ORCHESTRA_MAILTO="you@example.com" python skills/literature-review-agent/scripts/cross_verify.py \ --pool workspace/citation_pool.json --inplace # Annotates each paper with a `cross_verification` field and writes # workspace/cross_verification_report.json. # exit 0 = all corroborated; exit 1 = WARN (something flagged or an index # was unreachable); exit 2 = usage error. ``` This is a **WARN gate, not a hard gate** (like `validate_consistency.py`): it flags suspicious citations but does not block the pipeline or delete anything. Review the `low` and `conflict` tiers in the report: - `high` — corroborated by ≥1 external index → keep. - `medium` — corroborated but year disagrees → keep, spot-check the year. - `low` — not found in Crossref or OpenAlex → **review by hand**. Note that arXiv-only preprints (no DOI) are a common benign cause; `low` means "could not corroborate," not "fabricated." S2 already confirmed it exists. - `conflict` — pool DOI disagrees with the external DOI → likely wrong record. Drop only the entries you genuinely cannot corroborate, then re-run `dedupe_by_id.py` onward. If both indices are unreachable (offline), the script degrades gracefully and the pipeline continues on S2 verification alone. See `references/cross-index-verification.md` for the full rationale, confidence tiers, and the arXiv false-positive note. ### 4. Build the BibTeX file ```bash python skills/literature-review-agent/scripts/bibtex_format.py \ --pool workspace/citation_pool.json \ --out workspace/refs.bib ``` The script generates citation keys deterministically from `firstauthor + year + first significant word of title` (e.g., `vaswani2017attention`). It writes out only `@article` / `@inproceedings` / `@misc` entries — never invents fields. It also writes the canonical `bibtex_key` back into each paper record in `citation_pool.json`. **Immediately after bibtex_format.py**, sync keys in `intro_relwork.tex`: ```bash python skills/literature-review-agent/scripts/sync_keys.py \ --pool workspace/citation_pool.json \ --tex workspace/drafts/intro_relwork.tex \ --inplace # Replaces every \cite{agent_key} with \cite{canonical_bibtex_key}. # Eliminates citation_coverage gate failures caused by key mismatch. ``` These two steps replace the manual Python snippets that were previously required. The pipeline is now: ``` dedupe_by_id → validate_pool --fix → cross_verify --inplace → bibtex_format → sync_keys ``` ### 5. Draft Introduction + Related Work This is where you (the host agent) actually write text. Load the **verbatim Literature Review Agent prompt** at `references/prompt.md`. Substitute the template placeholders: | Placeholder | Value | |---|---| | `intro_related_work_plan` | full JSON object from `outline.json` | | `project_idea` | contents of `idea.md` | | `project_experimental_log` | contents of `experimental_log.md` | | `citation_checklist` | the BibTeX keys from `refs.bib` | | `collected_papers` | list of `{key, title, abstract}` from `citation_pool.json` | | `paper_count` | `len(citation_pool.papers)` | | `min_cite_paper_count` | from `citation_pool.json` | | `cutoff_date` | the date you derived in Step 0 | **Also prepend the Anti-Leakage Prompt** from `../paper-orchestra/references/anti-leakage-prompt.md`. **Also append the Introduction and Related Work templates** from `skills/shared/section_rhetoric.md`. Two constraints from that file do most of the work here: - The Introduction's Part 2 must state a technical challenge as *limitation plus cause*. "Prior methods are slow" is a symptom; "prior methods re-encode the full context at every step, so latency grows linearly in dialogue length" is a challenge the method can then attack. A Part 2 without a cause makes Part 3 unwritable. - Each Related Work paragraph runs: scope sentence → representative methods → the limitation of that group *tied to our challenge* → transition. Grouping is by technical theme, never by year. The `min_cite_paper_count` gate measures coverage, not positioning — a draft can pass it and still be a citation dump. Run your LLM with the combined prompt against `template.tex`. The agent's job is to fill in the empty Introduction and Related Work sections of the template **and leave everything else untouched**. Output: the full `template.tex` with those two sections filled. Save to `workspace/drafts/intro_relwork.tex`. ### 5b. Append §2 to research_brief.md After `intro_relwork.tex` is drafted and before the citation coverage check, append §2 to `workspace/research_brief.md` (see `skills/shared/research_brief_template.md`). Template: ```markdown ## §2 · Literature Landscape _Written by: literature-review-agent, Step 3_ **What the literature says about the core claim:** <2-3 sentence synthesis> **Strongest prior work (must address in the paper):** - <bibtex_key>: <why this is the strongest comparator or predecessor> **Gaps confirmed by the literature:** <list> **Baseline comparisons — verification status:** | Baseline | In citation_pool? | Confidence tier | |---|---|---| **Related Work cluster coverage:** | Cluster | Papers found | Notes | |---|---|---| **Anything the section-writing agent should know:** <important context> ``` This synthesises what was actually found — not what the outline assumed. ### 6. Verify ≥90% citation coverage ```bash python skills/literature-review-agent/scripts/citation_coverage.py \ --tex workspace/drafts/intro_relwork.tex \ --pool workspace/citation_pool.json # exit 0 if ≥90% of pool is cited; exit 1 otherwise ``` If the gate fails, re-prompt the writing step explicitly listing the missing keys and asking the agent to integrate them where contextually appropriate. ## Critical rules from the prompt These are excerpted from `references/prompt.md`. The host agent MUST honor them on the writing call: - **Cite ONLY from `collected_papers`.** Never invent BibTeX keys, never reference papers not in the pool. - **Cite at least `min_cite_paper_count` of them** in Intro + Related Work combined. - **TIMELINE RULE**: Do not treat any papers published after `cutoff_date` as prior baselines to beat. They are concurrent work only. - **EVALUATION RULE**: Do not claim our method beats / achieves SOTA over a specific cited paper UNLESS that paper is explicitly evaluated against in `experimental_log.md`. Frame other recent papers strictly as concurrent, orthogonal, or conceptual work. - **Output format**: return the full code for the updated `template.tex`, with the two empty sections (Introduction and Related Work) filled in, and **all the other code** (packages, styles, other sections) **identical to the original** template.tex. - Wrap output in ```` ```latex ... ``` ```` fences. - Do not change `\usepackage[capitalize]{cleveref}` to `cleverref` (there is no `cleverref.sty`). ## Degraded mode (no web search) If your host has no web search tool, switch to degraded mode: 1. If the user has placed a pre-built `workspace/inputs/refs.bib` in the workspace, load it directly into `workspace/refs.bib` and skip Phase 1 and Phase 2. 2. Otherwise, emit `workspace/drafts/intro_relwork.tex` containing the template with two TODO markers in the Intro and Related Work sections, and tell the user the pipeline cannot complete Step 3 without web search. ## Resources - `references/prompt.md` — verbatim Literature Review Agent prompt from App. F.1 - `references/discovery-pipeline.md` — Phase 1 + Phase 2 explained in detail - `references/verification-rules.md` — Levenshtein cutoff, year alignment, dedup - `references/citation-density-rule.md` — the ≥90% integration rule - `references/s2-api-cookbook.md` — Semantic Scholar URLs, fields, rate limits - `references/cross-index-verification.md` — Crossref + OpenAlex corroboration, confidence tiers, arXiv false-positive note - `references/exa-search-cookbook.md` — optional Exa backend for Phase 1 (research-paper-focused web search) - `references/tavily-search-cookbook.md` — optional Tavily backend for Phase 1 (LLM-optimized web search) - `scripts/pre_dedup_candidates.py` — **NEW** dedup Phase 1 candidates before Phase 2 (saves 30-40% S2 quota) - `scripts/s2_cache.py` — **NEW** persistent S2 response cache (eliminates re-verification on re-runs) - `scripts/validate_pool.py` — **NEW** validate & auto-fix citation_pool.json schema (authors format) - `scripts/sync_keys.py` — **NEW** sync cite keys in .tex with canonical bibtex_keys after bibtex_format.py - `scripts/levenshtein_match.py` — fuzzy title match (ratio > 70) - `scripts/check_cutoff.py` — date cmp w/ month → day-1 default - `scripts/dedupe_by_id.py` — dedup verified pool by S2 paperId - `scripts/bibtex_format.py` — build refs.bib from JSON pool - `scripts/citation_coverage.py` — ≥90% citation coverage gate - `scripts/s2_search.py` — **NEW** Semantic Scholar title-search helper; reads `SEMANTIC_SCHOLAR_API_KEY` from env (optional — falls back to unauthenticated) - `scripts/exa_search.py` — optional Exa Phase 1 backend (reads `EXA_API_KEY` from env) - `scripts/tavily_search.py` — optional Tavily Phase 1 backend (reads `TAVILY_API_KEY` from env) - `scripts/crossref_client.py` — **NEW** Crossref title/DOI lookup for cross-index corroboration (no key; reads `CROSSREF_MAILTO` / `PAPER_ORCHESTRA_MAILTO`) - `scripts/openalex_client.py` — **NEW** OpenAlex title/DOI lookup for cross-index corroboration (no key; reads `OPENALEX_MAILTO` / `PAPER_ORCHESTRA_MAILTO`) - `scripts/cross_verify.py` — **NEW** cross-corroborate the S2-verified pool against Crossref + OpenAlex; flags hallucinated citations (WARN gate) - `skills/shared/research_brief_template.md` — **NEW** §2 schema; append after intro_relwork.tex is drafted - `skills/shared/section_rhetoric.md` — **NEW** Introduction logic chain + Related Work paragraph template
Comments (0)
Sign in to join the conversation.
Reviews (0)
No reviews yet.
No comments yet.