
Paper Extract
- 1 installs
- 1 repo stars
- Updated May 14, 2026
- dreamyingy/arxiv-research-briefing-agent
Extract structured research signals (contribution, method, task, keywords, datasets, limitations) from ranked arXiv papers, then run an agent-review faithfulness pass.
About
Stage 3 of an arXiv briefing agent that pulls structured fields from paper titles and abstracts via rule-based heuristics, then verifies each field is verbatim-grounded. A developer uses it when building an automated daily research-briefing pipeline.
- Deterministic stdlib extractor plus an agent faithfulness review
- Hard-constraint checklist enforces verbatim-grounded fields
Paper Extract by the numbers
- 1 all-time installs (skills.sh)
- Ranked #14,102 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Jul 29, 2026 (Skillselion catalog sync)
npx skills add https://github.com/dreamyingy/arxiv-research-briefing-agent --skill paper-extractAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 1 |
|---|---|
| repo stars | ★ 1 |
| Last updated | May 14, 2026 |
| Repository | dreamyingy/arxiv-research-briefing-agent ↗ |
What it does
Extract structured research signals (contribution, method, task, keywords, datasets, limitations) from ranked arXiv papers, then run an agent-review faithfulness pass.
Files
paper-extract
Stage 3 of the daily arXiv briefing agent. Reads ranked_papers.json from the current run directory, takes the top-N papers, pulls structured research signals out of their title + abstract using rule-based heuristics (stdlib only, no LLM call, no PDF download, no full-text parsing), and writes enriched_papers.json next to the input.
The default invocation is two steps: (1) extract.py runs the deterministic rule-based extractor, (2) the Claude Code agent reviews the output and revises any field that fails the faithfulness contract, writing back to the same file. The Python script itself never calls an LLM and remains byte-stable across reruns; the agent-review pass is performed at the orchestration layer by Claude Code, so no API key or network call is required from the script.
Workflow
Step 1 — Locate the run directory
- If
--input-dir <path>is given, use it directly. - Otherwise, read
./output/latest_run.txtto find the latestrun_idand use./output/<run_id>/.
The directory must contain ranked_papers.json. If not, exit non-zero with a hint to run paper-rank first.
Step 2 — Run the extract script
python extract.py
# or pin a run / change top-N:
python extract.py --input-dir ./output/2026-05-06_2349_jepa --top-n 20The script reads <input-dir>/ranked_papers.json, processes the top-N papers (default 20), and writes <input-dir>/enriched_papers.json next to it. It does not create a new run directory and does not modify latest_run.txt.
Optional flags:
--top-n <int>— default20. Iftop_n > total papers, all papers are processed.--keyword-limit <int>— default8. Max keywords per paper.
Step 3 — Agent review pass (default, automatic)
As soon as extract.py finishes, Claude Code must perform the agent-review pass on <input-dir>/enriched_papers.json and write the revised file back to the same path, before returning control to the user and before any downstream skill (paper-network / paper-report / follow-up) is invoked. The agent does not wait for the user to ask, does not ask for confirmation, and does not skip the pass. It is a non-optional stage of paper-extract. Natural-language phrasings such as "review extraction" / "审阅 enriched" / "复核 extraction" are only relevant for re-running the pass on an already-reviewed file.
Step 3.0 — Backup. Before any edit, copy <input-dir>/enriched_papers.json to <input-dir>/enriched_papers.rule_based.json (skip if it already exists from a prior review). This preserves the deterministic rule-based output so rule-vs-reviewed diffs are always possible.
Step 3.1 — Read inputs. Read these two files from <input-dir>:
ranked_papers.json→ usequery.search_termsand each paper'stitle/abstractas the only sources of ground truth.enriched_papers.json→ the rule-based extraction to review.
Step 3.2 — Apply the hard-constraint checklist per paper. Every field below must satisfy the listed rule. If it does not, modify the field as described.
| field | hard constraint | action when violated |
|---|---|---|
main_contribution | must be a sentence appearing verbatim (whitespace-collapsed) in title + abstract; must actually state this paper's contribution, not background context | replace with the abstract sentence that states the contribution; if the abstract has no contribution statement, set to "" |
method | verbatim sentence in title + abstract; must describe the proposed method/model/algorithm/architecture, not the task or background | replace with the method-describing sentence; must not be identical to main_contribution unless the abstract has exactly one sentence usable for both |
task | verbatim sentence in title + abstract; must describe the task / application / problem setting | replace; set to "" if none |
keywords[] | each must match \b{kw}\b (case-insensitive) in title + abstract; no commonsense additions | delete unmatched items; do not add new keywords not present in the text |
datasets_or_domains[] | each must word-boundary match in title + abstract; must actually denote a dataset, benchmark, or research domain; must not be in the generic blacklist (AI ML DL NLP CV NN GPU TPU CNN RNN DNN MLP LSTM SOTA ICLR NEURIPS CVPR ECCV ICCV ACL EMNLP) | delete unmatched, generic, or non-dataset items |
evaluation_signals[] | each is a verbatim sentence from title + abstract; must actually mention experiments / metrics / comparisons | delete unfit sentences |
limitations | verbatim sentence in title + abstract; must explicitly state a limitation / failure mode / open challenge of the paper's own method. A discourse "However, we propose..." does not count | set to "" when the abstract states no real limitation |
evidence_sentences.{contribution, method, task} | each is a verbatim sentence in title + abstract; should corroborate the corresponding claim field | replace with a corroborating second sentence when one exists; otherwise mirror the claim field |
Step 3.3 — Non-negotiable boundaries. The agent:
- modifies only the
extractionblock of each paper; - never touches
id,version,title,abstract,authors,categories,primary_category,published,updated,url,pdf_url,doi,journal_ref,comment,rank, orscores; - never modifies top-level
query/count/fetched_at/ranked_at/extracted_at/ranking_config; - never adds new fields, never removes fields, never changes a field's type (
""notnull,[]notnull); - never uses outside knowledge or commonsense to backfill information that is not literally in
title + abstract.
Step 3.4 — Record the review. Extend extraction_config (top-level, not per-paper) with a review sub-block:
"extraction_config": {
"method": "agent_reviewed_v1",
"top_n": 20,
"keyword_limit": 8,
"source_fields": ["title", "abstract"],
"review": {
"reviewed_at": "2026-05-12T10:30:00+00:00",
"reviewer": "claude-code-agent",
"papers_reviewed": 20,
"papers_modified": 7,
"fields_modified": {
"main_contribution": 1,
"method": 3,
"task": 0,
"keywords": 2,
"datasets_or_domains": 4,
"evaluation_signals": 0,
"limitations": 5,
"evidence_sentences": 1
}
}
}Bump extraction_config.method from "rule_based_v2" to "agent_reviewed_v1" after the review pass. No downstream skill reads extraction_config, so this is purely traceability metadata.
Step 3.5 — Write back. Save with encoding="utf-8", ensure_ascii=False, indent=2, matching the script's format.
Step 4 — Verify
Run the hard-constraint validator to confirm the (rule-based or reviewed) enriched_papers.json satisfies the contract:
python verify_enriched.py
# or:
python verify_enriched.py --input-dir ./output/2026-05-06_2349_jepaExit code 0 means clean; 1 means at least one violation (each printed with paper id + field name). The validator is stdlib-only and lives next to extract.py.
Coordination with paper-search / paper-rank
paper-extract is a pure consumer of paper-rank's output:
- Reads
<run_dir>/ranked_papers.json(papers must already carryrankandscoresfrompaper-rank). - Uses
query.search_termsfrom the top-level query to boost keyword scoring. - Writes
<run_dir>/enriched_papers.json(rule-based, then revised by the agent-review pass). - Writes
<run_dir>/enriched_papers.rule_based.json(one-time backup created at the start of the first review pass). - Does not touch
latest_run.txt, the stagingquery.json, orcache/.
Downstream skills (paper-network, paper-report, follow-up) read enriched_papers.json from the same <run_dir> and do not inspect extraction_config, so the review's metadata is invisible to them.
Inputs
<input-dir>/ranked_papers.json (produced by paper-rank). The relevant fields per paper are: title, abstract, categories, rank. The query is taken from the top-level query.search_terms.
CLI flags:
| flag | type | default | notes |
|---|---|---|---|
--input-dir | path | resolved from latest_run.txt | per-run directory; must contain ranked_papers.json |
--top-n | int | 20 | how many top-ranked papers to extract from |
--keyword-limit | int | 8 | max keywords per paper |
Output: enriched_papers.json
Same shape as ranked_papers.json, with two additions:
1. Top-level extracted_at and extraction_config (echoes flags + method, plus an optional review sub-block after the agent-review pass). For traceability only — no downstream skill reads extraction_config. 2. Each paper gets an extraction field. All other fields (incl. rank, scores) are preserved verbatim. 3. The papers array contains only the top-N papers, in their existing rank order.
{
"query": { "...": "echo from upstream" },
"fetched_at": "2026-05-06T15:49:44+00:00",
"ranked_at": "2026-05-06T16:02:11+00:00",
"ranking_config": { "...": "echo from paper-rank" },
"extracted_at": "2026-05-06T16:30:00+00:00",
"extraction_config": {
"method": "rule_based_v2",
"top_n": 20,
"keyword_limit": 8,
"source_fields": ["title", "abstract"]
},
"count": 20,
"papers": [
{
"id": "2603.29966",
"rank": 1,
"scores": { "...": "from paper-rank" },
"title": "...",
"abstract": "...",
"...": "all other fields from ranked_papers.json",
"extraction": {
"main_contribution": "We propose a JEPA-based framework that learns ...",
"method": "Self-supervised pretraining with a latent predictor ...",
"task": "representation learning for surgical video understanding",
"keywords": ["jepa", "surgical video", "representation learning", "..."],
"datasets_or_domains": ["EEG", "ImageNet", "surgical"],
"evaluation_signals": [
"outperforms prior baselines on benchmark X",
"improves accuracy by 3.2%"
],
"limitations": "We acknowledge the method is limited to short clips.",
"evidence_sentences": {
"contribution": "Our contribution is a JEPA model that ...",
"method": "The latent predictor is trained with a masked objective ...",
"task": "We address representation learning for surgical video ..."
}
}
}
]
}Field naming convention
All per-paper fields from ranked_papers.json are preserved verbatim (project-wide canonical names). The new extraction block:
| field | type | notes |
|---|---|---|
main_contribution | string | one verbatim sentence from title + abstract stating the paper's contribution; "" if none |
method | string | verbatim sentence describing the proposed method/framework; "" only when the abstract has no description at all |
task | string | verbatim sentence describing the task or application |
keywords | string[] | up to keyword_limit, lowercased; every entry word-boundary matches in title + abstract; may include title bigrams |
datasets_or_domains | string[] | dataset names / domain triggers detected via the acronym, mixed-case, and versioned-dataset patterns; never contains generic-acronym blacklist entries |
evaluation_signals | string[] | verbatim sentences containing evaluation cues (accuracy, benchmark, outperforms, …) |
limitations | string | verbatim sentence explicitly stating a limitation/failure/open challenge; "" when the abstract states none |
evidence_sentences | object | {contribution, method, task} → verbatim sentence corroborating each claim. Prefer the second cue-matching sentence; fall back to the claim sentence when only one match exists |
When a field cannot be filled, the script returns the field's empty default ("" for strings, [] for lists); per-paper extraction never raises.
Extraction methodology
Rule-based, stdlib only — no NLP libraries. Highlights of the rule_based_v2 extractor (the agent-review pass then revises the output as documented in Step 3):
Sentence splitter: re.split(r'(?<=[.!?])\s+(?=[A-Z])'), with an abbreviation guard that masks the dots in et al., e.g., i.e., Fig., Eq., Sec., Tab., vs., cf., approx., Dr., Mr. before splitting and restores them afterward. Eliminates over-splits on "Vaswani et al. We propose…" — the most common abstract abbreviation.
Trigger lists (matched as case-insensitive whole-word regex against each sentence — \bcue\b, with internal spaces in multi-word cues compiled as \s+. This avoids false positives like Models matching the cue model.):
- Contribution:
we propose,we present,we introduce,we develop,we design,we show,this paper proposes,this work presents,we contribute,our work provides. The first sentence containing any cue is selected. - Method:
method,model,framework,architecture,pipeline,approach,algorithm,objective,pretraining,self-supervised,masked,latent,embedding,retrieval,diffusion,encoder,decoder,transformer,convolutional. Three-tier selection: (1) first cue match in the body, skipping the title sentence; (2) longest body sentence ≥8 tokens that is not the contribution sentence; (3) fall back to the contribution sentence. - Task:
task,classification,retrieval,segmentation,prediction,generation,representation learning,video understanding,time-series,inpainting,detection,captioning,tracking,denoising,depth estimation. First match (the title sentence is allowed because task words likeclassificationlegitimately appear in titles). - Limitations: tiered. Strong cues —
limitation,limited,fails to,does not,cannot,drawback,shortcoming,we acknowledge— trigger on the first match. Weak cues —however,challenge(s),constrained,fail,failure,unclear— only trigger when they occur in the second half of the abstract body, so a discourseHowever, we propose...at the abstract's start is no longer mislabelled. If no cue matches,"". - Evaluation signals:
outperform,improve,achieve,accuracy,f1,auroc,auc,map,benchmark,state-of-the-art,baseline,evaluation,experiment,ablation,roc,bleu,rouge,mse,psnr,ssim,dice. All matching sentences, deduped, capped at 6.
Keywords (TF-IDF weighted): tokenize each paper to \b[a-z0-9][a-z0-9-]*\b, drop a built-in stopword set (~80 common English words) and pure-digit / single-char tokens. The IDF is computed over the selected top-N corpus: idf(t) = log((N + 1) / (df + 1)). Score per unigram is tf * (1 + idf) * title_boost * query_boost, where title_boost = 2 if the token appears in the title and query_boost = 3 if a token from query.search_terms matches. Title bigrams are added with prior 5.0 * (1 + avg_idf) so a bigram of two corpus-common words ranks lower than a bigram of two distinctive ones. Sort descending, keep keyword_limit. Effect: corpus-common tokens like model, method, approach no longer dominate top-3.
Evidence sentences: pick the second cue-matching sentence per family (contribution / method / task), and fall back to the corresponding claim sentence when only one match exists. The fallback preserves backward compatibility with the v1 contract on short abstracts.
Datasets / domains: union of four patterns, in order, deduplicated case-insensitively, capped at 10:
- Hyphenated versioned dataset names:
\b[A-Z][A-Za-z]+-\d+[A-Za-z]*\b(catchesCIFAR-10,ImageNet-1K,COCO-2017,MNIST-1D). - All-caps acronyms:
\b[A-Z][A-Z0-9]{1,7}\b, excluding a blacklist of generic research/hardware acronyms (AI ML DL NLP CV NN GPU TPU CNN RNN DNN MLP LSTM SOTA ICLR NEURIPS CVPR ECCV ICCV ACL EMNLP). - Mixed-case dataset names:
\b[A-Z][a-z]+(?:[A-Z][a-z]+)+\b|\b[A-Z][a-zA-Z]*\d+[a-zA-Z]*\b(catchesImageNet,ETTh1). - Domain triggers (
dataset,benchmark,corpus,cohort,domain,video,image,eeg,sonar,medical,surgical,audio,text) matched with\b{trig}\b, soaudiono longer fires insideaudiobook.
Example
# After paper-rank has populated the latest run
python extract.py
# the agent then performs Step 3 (review) automatically; then:
python verify_enriched.py
# Pin a specific run, top-30, more keywords:
python extract.py --input-dir ./output/2026-05-06_2349_jepa --top-n 30 --keyword-limit 12After running, <input-dir>/enriched_papers.json is the artifact for paper-network / paper-report / follow-up, and <input-dir>/enriched_papers.rule_based.json is the pre-review snapshot.
Error handling
| condition | behavior |
|---|---|
--input-dir not given AND ./output/latest_run.txt missing | exit non-zero with hint: run paper-search then paper-rank first |
--input-dir given but does not exist | exit non-zero, name the path |
ranked_papers.json not found in input dir | exit non-zero with hint: run paper-rank first |
ranked_papers.json malformed JSON / missing papers | exit non-zero with a clear message |
papers is empty | write enriched_papers.json with count: 0, empty papers, emit WARN: empty corpus; exit 0 |
top_n > len(papers) | process all available papers; not an error |
| individual extraction field has no match | return field's empty default ("" or []); per-paper extraction never raises |
paper has empty abstract | extraction fields default to empty; emit WARN: paper <id> has empty abstract to stderr |
top_n <= 0 or keyword_limit <= 0 | exit non-zero with a clear message |
| agent review finds a field that cannot be made faithful | leave that field as ""/[] rather than fabricating content |
verify_enriched.py finds violations | exit code 1, prints one line per violation; the offending file is left in place for human inspection |
Dependencies
- Python ≥ 3.9
- No third-party dependencies — stdlib only (
extract.pyandverify_enriched.py).
Independent test hooks (for course evaluation)
- count check —
enriched.count == min(top_n, ranked.count). - schema preservation — every per-paper field present in
ranked_papers.jsonis also present inenriched_papers.jsonwith the same value. - extraction completeness — every paper has an
extractionblock with the eight expected keys (main_contribution,method,task,keywords,datasets_or_domains,evaluation_signals,limitations,evidence_sentences);evidence_sentencesalways has the three keyscontribution/method/task. - type check —
keywords/datasets_or_domains/evaluation_signalsare lists of strings; the rest ofextractionare strings (or sub-objects of strings). - script stability — running
extract.pytwice on the sameranked_papers.jsonproduces a byte-identicalenriched_papers.json(after strippingextracted_at). The agent-review pass is not required to be byte-identical across sessions, but the rule-based snapshot inenriched_papers.rule_based.jsonalways is. - faithfulness (rule-based) —
python verify_enriched.pyexits 0 on the rule-based output: every keyword / dataset / sentence is grounded in the paper's owntitle + abstract. - faithfulness (reviewed) —
python verify_enriched.pyexits 0 on the reviewed output as well; the review pass must not introduce ungrounded content. - acronym hygiene —
datasets_or_domainscontains no entries from the generic acronym blacklist (AI ML DL NLP CV NN GPU TPU CNN RNN DNN MLP LSTM SOTA ICLR NEURIPS CVPR ECCV ICCV ACL EMNLP). - tiered limitation — for an abstract starting with
However, we propose...but containing no explicit limitation language,limitations == "". - TF-IDF de-noising — top-3 keywords for any paper do not contain corpus-common tokens like
model,method,approach,propose(these accumulate IDF ≈ 0 when present in many papers and thus rank below distinctive terms). - empty handling — when
ranked_papers.jsonhaspapers: [], the script writesenriched_papers.jsonwithpapers: []and exits 0; the agent-review pass and verifier both treat it as a clean run. - review backup — if any agent review has been performed,
enriched_papers.rule_based.jsonexists alongsideenriched_papers.jsonand can be diffed for rule-vs-reviewed comparison.
#!/usr/bin/env python3
"""paper-extract: rule-based information extraction for the daily arXiv briefing agent.
Reads <input-dir>/ranked_papers.json, processes top-N papers, writes
<input-dir>/enriched_papers.json. No third-party deps, no LLM, no PDF parsing.
"""
from __future__ import annotations
import argparse
import json
import math
import re
import sys
from collections import Counter
from datetime import datetime, timezone
from pathlib import Path
CONTRIBUTION_CUES = [
"we propose", "we present", "we introduce", "we develop",
"we design", "we show", "this paper proposes",
"this work presents", "we contribute", "our work provides",
]
METHOD_CUES = [
"method", "model", "framework", "architecture", "pipeline", "approach",
"algorithm", "objective", "pretraining", "self-supervised", "masked",
"latent", "embedding", "retrieval", "diffusion",
"encoder", "decoder", "transformer", "convolutional",
]
TASK_CUES = [
"task", "classification", "retrieval", "segmentation", "prediction",
"generation", "representation learning", "video understanding",
"time-series", "inpainting",
"detection", "captioning", "tracking", "denoising", "depth estimation",
]
# Limitations: tiered. Strong cues mean the paper itself states a limitation;
# weak cues (e.g. discourse "however") only count when they appear in the
# second half of the abstract, where real limitations tend to live.
LIMITATION_CUES_STRONG = [
"limitation", "limited", "fails to", "does not", "cannot",
"drawback", "shortcoming", "we acknowledge",
]
LIMITATION_CUES_WEAK = [
"however", "challenge", "challenges", "constrained", "fail", "failure",
"unclear",
]
EVALUATION_CUES = [
"outperform", "improve", "achieve", "accuracy", "f1", "auroc", "auc",
"map", "benchmark", "state-of-the-art", "baseline", "evaluation",
"experiment",
"ablation", "roc", "bleu", "rouge", "mse", "psnr", "ssim", "dice",
]
DOMAIN_TRIGGERS = [
"dataset", "benchmark", "corpus", "cohort", "domain",
"video", "image", "eeg", "sonar", "medical", "surgical", "audio", "text",
]
# Generic research / hardware acronyms that should not appear in
# datasets_or_domains; they pollute the field without identifying a dataset.
ACRONYM_BLACKLIST = {
"AI", "ML", "DL", "NLP", "CV", "NN", "GPU", "TPU",
"CNN", "RNN", "DNN", "MLP", "LSTM", "SOTA",
"ICLR", "NEURIPS", "CVPR", "ECCV", "ICCV", "ACL", "EMNLP",
}
# Common English abbreviations whose internal dot must NOT split sentences.
ABBREVIATIONS = [
"et al.", "e.g.", "i.e.", "Fig.", "Eq.", "Sec.", "Tab.",
"vs.", "cf.", "approx.", "Dr.", "Mr.",
]
_ABBR_DOT = "\x00DOT\x00"
STOPWORDS = {
"a", "an", "and", "are", "as", "at", "be", "but", "by", "for", "from",
"has", "have", "had", "in", "into", "is", "it", "its", "of", "on", "or",
"that", "the", "this", "to", "was", "we", "were", "with", "which", "while",
"when", "where", "who", "what", "how", "why", "our", "their", "they",
"them", "these", "those", "such", "using", "used", "based", "also", "can",
"may", "more", "than", "not", "no", "both", "other", "one", "two", "each",
"any", "all", "very", "most", "some", "new", "via", "through", "over",
"between", "among", "without", "within", "across", "about", "upon",
"should", "would", "could", "will", "shall", "might", "must", "do",
"does", "did", "been", "being", "there", "here", "however", "thus",
"hence", "i", "you", "he", "she", "his", "her", "its", "their", "if",
"then", "so", "yet", "still", "only", "just", "even",
}
SENT_SPLIT_RE = re.compile(r"(?<=[.!?])\s+(?=[A-Z])")
TOKEN_RE = re.compile(r"[a-z0-9][a-z0-9-]*")
ACRONYM_RE = re.compile(r"\b[A-Z][A-Z0-9]{1,7}\b")
MIXED_RE = re.compile(r"\b[A-Z][a-z]+(?:[A-Z][a-z]+)+\b|\b[A-Z][a-zA-Z]*\d+[a-zA-Z]*\b")
# Hyphenated dataset names: CIFAR-10, ImageNet-1K, COCO-2017, MNIST-1D, ...
DATASET_VERSIONED_RE = re.compile(r"\b[A-Z][A-Za-z]+-\d+[A-Za-z]*\b")
def _compile_cues(cues: list[str]) -> list[re.Pattern]:
pats: list[re.Pattern] = []
for cue in cues:
body = re.escape(cue).replace(r"\ ", r"\s+")
pats.append(re.compile(rf"\b{body}\b", re.IGNORECASE))
return pats
CONTRIBUTION_PATS = _compile_cues(CONTRIBUTION_CUES)
METHOD_PATS = _compile_cues(METHOD_CUES)
TASK_PATS = _compile_cues(TASK_CUES)
LIMITATION_STRONG_PATS = _compile_cues(LIMITATION_CUES_STRONG)
LIMITATION_WEAK_PATS = _compile_cues(LIMITATION_CUES_WEAK)
EVALUATION_PATS = _compile_cues(EVALUATION_CUES)
def split_sentences(text: str) -> list[str]:
"""Sentence-split with abbreviation guard.
The naive `(?<=[.!?])\\s+(?=[A-Z])` splitter over-cuts at common research
abbreviations like "et al.", "Fig.", "e.g." when the next clause starts
with a capital letter. We mask their internal dots with a sentinel before
splitting and restore them afterward.
"""
if not text:
return []
masked = text
for abbr in ABBREVIATIONS:
masked_abbr = abbr.replace(".", _ABBR_DOT)
masked = re.sub(re.escape(abbr), masked_abbr, masked, flags=re.IGNORECASE)
parts = [s.strip() for s in SENT_SPLIT_RE.split(masked) if s.strip()]
return [s.replace(_ABBR_DOT, ".") for s in parts]
def find_first_with_cue(sentences: list[str], patterns: list[re.Pattern],
skip: int = 0) -> str:
for sent in sentences[skip:]:
if any(p.search(sent) for p in patterns):
return sent
return ""
def find_nth_with_cue(sentences: list[str], patterns: list[re.Pattern],
n: int, skip: int = 0) -> str:
"""Return the n-th (0-indexed) sentence containing any cue, else ""."""
matches: list[str] = []
for sent in sentences[skip:]:
if any(p.search(sent) for p in patterns):
matches.append(sent)
if len(matches) > n:
return matches[n]
return matches[n] if len(matches) > n else ""
def find_all_with_cue(sentences: list[str], patterns: list[re.Pattern],
cap: int) -> list[str]:
seen: set[str] = set()
out: list[str] = []
for sent in sentences:
if any(p.search(sent) for p in patterns) and sent not in seen:
seen.add(sent)
out.append(sent)
if len(out) >= cap:
break
return out
def find_method_sentence(sentences: list[str], contrib_sent: str) -> str:
"""Choose the method sentence with a 3-tier fallback.
1. First method-cue match in the body (skip title sentence).
2. Longest body sentence that is not the contribution sentence.
Rationale: in abstracts without an explicit "method"/"framework" cue,
the descriptive method sentence is usually the longest non-title one.
3. Fall back to the contribution sentence (legacy behavior).
"""
method_sent = find_first_with_cue(sentences, METHOD_PATS, skip=1)
if method_sent:
return method_sent
body = sentences[1:] if len(sentences) > 1 else sentences
candidates = [s for s in body if s != contrib_sent and len(s.split()) >= 8]
if candidates:
return max(candidates, key=len)
return contrib_sent
def find_limitation_sentence(sentences: list[str]) -> str:
"""Tiered limitation pick.
Strong cues (e.g. "fails to", "limitation", "we acknowledge") trigger
immediately. Weak cues (e.g. "however", "challenge") only trigger when
they appear in the second half of the abstract body; a discourse
"However, we propose..." at the start of an abstract is not a limitation.
"""
for sent in sentences:
if any(p.search(sent) for p in LIMITATION_STRONG_PATS):
return sent
if len(sentences) <= 2:
return ""
# Skip title (index 0) and the first half of the body sentences.
body_start = 1
body_len = len(sentences) - body_start
half = body_start + body_len // 2
for sent in sentences[half:]:
if any(p.search(sent) for p in LIMITATION_WEAK_PATS):
return sent
return ""
def compute_idf(papers: list[dict]) -> dict[str, float]:
"""Inverse document frequency over the selected top-N corpus.
df is the number of papers whose title+abstract contains the token
(counted once per paper, irrespective of in-paper frequency). Returns
log((N + 1) / (df + 1)); unseen tokens get 0.0 from the caller's .get().
"""
n = len(papers)
if n == 0:
return {}
df: Counter[str] = Counter()
for p in papers:
text = ((p.get("title") or "") + " " + (p.get("abstract") or "")).lower()
seen_in_doc: set[str] = set()
for tok in TOKEN_RE.findall(text):
if tok in STOPWORDS or tok.isdigit() or len(tok) <= 1:
continue
if tok in seen_in_doc:
continue
seen_in_doc.add(tok)
df[tok] += 1
return {tok: math.log((n + 1) / (c + 1)) for tok, c in df.items()}
def extract_keywords(title: str, abstract: str, search_terms: list[str],
limit: int, idf: dict[str, float]) -> list[str]:
"""TF-IDF-weighted keyword extraction with title + query boosts.
Score per unigram: ``tf * (1 + idf) * title_boost * query_boost``.
Title bigrams keep their high prior but are scaled by the average IDF of
their two tokens, so a bigram of two corpus-common words ranks lower than
a bigram of two distinctive ones.
"""
title_low = title.lower()
abs_low = abstract.lower()
text = title_low + " " + abs_low
tokens = [t for t in TOKEN_RE.findall(text)
if t not in STOPWORDS and not t.isdigit() and len(t) > 1]
counts = Counter(tokens)
title_tokens = set(t for t in TOKEN_RE.findall(title_low)
if t not in STOPWORDS and len(t) > 1)
boost_terms: set[str] = set()
for st in search_terms:
for tok in TOKEN_RE.findall(st.lower()):
if tok not in STOPWORDS and len(tok) > 1:
boost_terms.add(tok)
scored: list[tuple[str, float]] = []
for tok, count in counts.items():
score = float(count) * (1.0 + idf.get(tok, 0.0))
if tok in title_tokens:
score *= 2
if tok in boost_terms:
score *= 3
scored.append((tok, score))
# Title bigrams must be verbatim-adjacent (only whitespace between the two
# tokens). Iterating over the stopword-stripped sequence would jump across
# stopwords; iterating over the raw token list still misses punctuation
# separators (e.g. `GeoMeld: Toward` tokenizes as ['geomeld','toward']
# but the colon means the two words are not adjacent in the title).
raw_title_tokens = TOKEN_RE.findall(title_low)
seen_bigrams: set[str] = set()
for i in range(len(raw_title_tokens) - 1):
t1, t2 = raw_title_tokens[i], raw_title_tokens[i + 1]
if (t1 in STOPWORDS or t2 in STOPWORDS or
len(t1) <= 1 or len(t2) <= 1 or
t1.isdigit() or t2.isdigit()):
continue
adj_pat = re.compile(
rf"\b{re.escape(t1)}\s+{re.escape(t2)}\b", re.IGNORECASE)
if not adj_pat.search(title_low):
continue
bg = f"{t1} {t2}"
if bg in seen_bigrams:
continue
seen_bigrams.add(bg)
avg_idf = (idf.get(t1, 0.0) + idf.get(t2, 0.0)) / 2
scored.append((bg, 5.0 * (1.0 + avg_idf)))
scored.sort(key=lambda x: (-x[1], x[0]))
seen: set[str] = set()
selected: list[str] = []
for tok, _ in scored:
if tok in seen:
continue
seen.add(tok)
selected.append(tok)
if len(selected) >= limit:
break
return selected
def extract_datasets_or_domains(text: str, cap: int = 10) -> list[str]:
seen_lower: set[str] = set()
out: list[str] = []
# Hyphenated versioned dataset names first (CIFAR-10, ImageNet-1K, ...).
for m in DATASET_VERSIONED_RE.findall(text):
key = m.lower()
if key not in seen_lower:
seen_lower.add(key)
out.append(m)
# All-caps acronyms, skipping generic research/hardware abbreviations.
for m in ACRONYM_RE.findall(text):
if m.upper() in ACRONYM_BLACKLIST:
continue
key = m.lower()
if key not in seen_lower:
seen_lower.add(key)
out.append(m)
# Mixed-case dataset names (ImageNet, MiniImageNet, ETTh1, ...).
for m in MIXED_RE.findall(text):
key = m.lower()
if key not in seen_lower:
seen_lower.add(key)
out.append(m)
# Domain triggers — word-boundary match so "audio" doesn't fire on
# "audiobook" and "image" doesn't fire on "imagery".
for trig in DOMAIN_TRIGGERS:
pat = re.compile(rf"\b{re.escape(trig)}\b", re.IGNORECASE)
key = trig.lower()
if pat.search(text) and key not in seen_lower:
seen_lower.add(key)
out.append(trig)
return out[:cap]
def extract_for_paper(paper: dict, search_terms: list[str],
keyword_limit: int, idf: dict[str, float]) -> dict:
title = paper.get("title", "") or ""
abstract = paper.get("abstract", "") or ""
if not abstract.strip():
print(f"WARN: paper {paper.get('id', '?')} has empty abstract",
file=sys.stderr)
full_text = title + ". " + abstract
sentences = split_sentences(full_text)
contrib_sent = find_first_with_cue(sentences, CONTRIBUTION_PATS)
method_sent = find_method_sentence(sentences, contrib_sent)
task_sent = find_first_with_cue(sentences, TASK_PATS)
limit_sent = find_limitation_sentence(sentences)
eval_sents = find_all_with_cue(sentences, EVALUATION_PATS, cap=6)
# Evidence sentences: pick the *second* cue-matching sentence when one
# exists, so evidence is a corroborating second statement rather than a
# verbatim copy of the claim. When only one match exists (short abstract),
# fall back to the claim sentence — preserves the v1 contract.
contrib_evidence = find_nth_with_cue(sentences, CONTRIBUTION_PATS, n=1) \
or contrib_sent
method_evidence = find_nth_with_cue(sentences, METHOD_PATS, n=1, skip=1) \
or method_sent
task_evidence = find_nth_with_cue(sentences, TASK_PATS, n=1) or task_sent
return {
"main_contribution": contrib_sent,
"method": method_sent,
"task": task_sent,
"keywords": extract_keywords(title, abstract, search_terms,
keyword_limit, idf),
"datasets_or_domains": extract_datasets_or_domains(abstract),
"evaluation_signals": eval_sents,
"limitations": limit_sent,
"evidence_sentences": {
"contribution": contrib_evidence,
"method": method_evidence,
"task": task_evidence,
},
}
def resolve_input_dir(input_dir_flag: Path | None) -> Path:
if input_dir_flag is not None:
if not input_dir_flag.is_dir():
sys.exit(f"ERROR: --input-dir not found or not a directory: {input_dir_flag}")
return input_dir_flag
latest = Path("output") / "latest_run.txt"
if not latest.exists():
sys.exit(
"ERROR: ./output/latest_run.txt not found. "
"Run paper-search then paper-rank first, or pass --input-dir."
)
run_id = latest.read_text(encoding="utf-8").strip()
run_dir = Path("output") / run_id
if not run_dir.is_dir():
sys.exit(f"ERROR: run directory referenced by latest_run.txt is missing: {run_dir}")
return run_dir
def load_ranked(run_dir: Path) -> dict:
p = run_dir / "ranked_papers.json"
if not p.exists():
sys.exit(
f"ERROR: ranked_papers.json not found in {run_dir}. "
"Run paper-rank first."
)
try:
data = json.loads(p.read_text(encoding="utf-8"))
except json.JSONDecodeError as e:
sys.exit(f"ERROR: ranked_papers.json is not valid JSON: {e}")
if "papers" not in data:
sys.exit("ERROR: ranked_papers.json missing required key 'papers'")
return data
def main() -> int:
p = argparse.ArgumentParser(
description="Rule-based extraction of structured info from top-N ranked papers")
p.add_argument("--input-dir", type=Path, default=None,
help="Run directory containing ranked_papers.json "
"(default: resolved from ./output/latest_run.txt)")
p.add_argument("--top-n", type=int, default=20,
help="How many top-ranked papers to process (default 20)")
p.add_argument("--keyword-limit", type=int, default=8,
help="Max keywords per paper (default 8)")
args = p.parse_args()
if args.top_n <= 0:
sys.exit(f"ERROR: --top-n must be positive, got {args.top_n}")
if args.keyword_limit <= 0:
sys.exit(f"ERROR: --keyword-limit must be positive, got {args.keyword_limit}")
run_dir = resolve_input_dir(args.input_dir)
ranked = load_ranked(run_dir)
papers = ranked["papers"]
search_terms = ranked.get("query", {}).get("search_terms", [])
config = {
"method": "rule_based_v2",
"top_n": args.top_n,
"keyword_limit": args.keyword_limit,
"source_fields": ["title", "abstract"],
}
selected = papers[: args.top_n]
idf = compute_idf(selected)
enriched: list[dict] = []
for paper in selected:
ext = extract_for_paper(paper, search_terms, args.keyword_limit, idf)
enriched.append({**paper, "extraction": ext})
payload = {
"query": ranked.get("query"),
"fetched_at": ranked.get("fetched_at"),
"ranked_at": ranked.get("ranked_at"),
"ranking_config": ranked.get("ranking_config"),
"extracted_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
"extraction_config": config,
"count": len(enriched),
"papers": enriched,
}
out_path = run_dir / "enriched_papers.json"
out_path.write_text(
json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
if not enriched:
print(f"WARN: empty corpus -> {out_path}", file=sys.stderr)
else:
print(f"INFO: extracted {len(enriched)} papers -> {out_path}",
file=sys.stderr)
return 0
if __name__ == "__main__":
raise SystemExit(main())
#!/usr/bin/env python3
"""verify_enriched: hard-constraint validator for enriched_papers.json.
Used in two places:
1. As an agent self-check after the agent-review pass overwrites
enriched_papers.json (see paper-extract/SKILL.md "Agent review pass").
2. As a smoke test that any enriched_papers.json (rule-based or reviewed)
satisfies the project's extraction contract.
Checks (per paper):
- Schema: every paper has an `extraction` block with the 8 expected keys,
`evidence_sentences` has the 3 expected sub-keys, list/string types
match the canonical schema.
- Faithfulness: each keyword / dataset / sentence is grounded in the
paper's own (title + abstract). Tokens must word-boundary match
(case-insensitive); sentences must appear verbatim (whitespace-collapsed)
in title + abstract.
- Acronym hygiene: datasets_or_domains MUST NOT contain entries from the
generic-acronym blacklist.
Exit code 0 = clean; 1 = at least one violation; 2 = file/IO error.
Prints a human-readable report to stdout; counts to stderr.
"""
from __future__ import annotations
import argparse
import json
import re
import sys
from pathlib import Path
# Must stay in sync with extract.py:ACRONYM_BLACKLIST.
ACRONYM_BLACKLIST = {
"AI", "ML", "DL", "NLP", "CV", "NN", "GPU", "TPU",
"CNN", "RNN", "DNN", "MLP", "LSTM", "SOTA",
"ICLR", "NEURIPS", "CVPR", "ECCV", "ICCV", "ACL", "EMNLP",
}
EXPECTED_EXTRACTION_KEYS = {
"main_contribution", "method", "task", "keywords",
"datasets_or_domains", "evaluation_signals", "limitations",
"evidence_sentences",
}
EXPECTED_EVIDENCE_KEYS = {"contribution", "method", "task"}
WS_RE = re.compile(r"\s+")
def normalize_ws(s: str) -> str:
return WS_RE.sub(" ", s).strip()
def token_in_text(token: str, text: str) -> bool:
"""Whole-word, case-insensitive presence of token in text.
A multi-word token must appear with whitespace between its parts;
internal hyphens are honored literally.
"""
if not token:
return False
# Treat any run of whitespace inside the token as `\s+`.
parts = re.escape(token).replace(r"\ ", r"\s+")
return re.search(rf"\b{parts}\b", text, re.IGNORECASE) is not None
def sentence_in_text(sentence: str, text: str) -> bool:
"""A sentence is verbatim-grounded if it (modulo whitespace and one
trailing `.!?`) appears as a substring of (title + abstract). The trailing
punctuation tolerance is needed because extract.py joins title and
abstract with a synthetic `". "` separator so the title can be the first
sentence; that adds a period to the title sentence that is absent from
the raw `title` / `abstract` fields.
"""
if not sentence:
return True # empty string is a valid "no match" output
s = normalize_ws(sentence).rstrip(".!?").strip()
return s in normalize_ws(text)
def check_paper(paper: dict) -> list[str]:
"""Return a list of human-readable violations for one paper. Empty = OK."""
pid = paper.get("id", "?")
violations: list[str] = []
ext = paper.get("extraction")
if not isinstance(ext, dict):
return [f"{pid}: missing or non-dict `extraction` block"]
missing = EXPECTED_EXTRACTION_KEYS - set(ext.keys())
if missing:
violations.append(f"{pid}: extraction missing keys: {sorted(missing)}")
# Type checks.
for k in ("main_contribution", "method", "task", "limitations"):
v = ext.get(k, "")
if not isinstance(v, str):
violations.append(f"{pid}: extraction.{k} must be str, got {type(v).__name__}")
for k in ("keywords", "datasets_or_domains", "evaluation_signals"):
v = ext.get(k, [])
if not isinstance(v, list) or not all(isinstance(x, str) for x in v):
violations.append(f"{pid}: extraction.{k} must be list[str]")
ev = ext.get("evidence_sentences", {})
if not isinstance(ev, dict):
violations.append(f"{pid}: extraction.evidence_sentences must be dict")
ev = {}
else:
ev_missing = EXPECTED_EVIDENCE_KEYS - set(ev.keys())
if ev_missing:
violations.append(
f"{pid}: evidence_sentences missing keys: {sorted(ev_missing)}")
for k in EXPECTED_EVIDENCE_KEYS:
if k in ev and not isinstance(ev[k], str):
violations.append(
f"{pid}: evidence_sentences.{k} must be str, "
f"got {type(ev[k]).__name__}")
# Grounding: build a corpus from this paper's own title + abstract.
title = paper.get("title", "") or ""
abstract = paper.get("abstract", "") or ""
corpus = title + " " + abstract
# Sentence-level grounding.
for k in ("main_contribution", "method", "task", "limitations"):
v = ext.get(k, "")
if isinstance(v, str) and not sentence_in_text(v, corpus):
violations.append(
f"{pid}: extraction.{k} not found verbatim in title+abstract")
for k in EXPECTED_EVIDENCE_KEYS:
v = ev.get(k, "") if isinstance(ev, dict) else ""
if isinstance(v, str) and not sentence_in_text(v, corpus):
violations.append(
f"{pid}: evidence_sentences.{k} not found verbatim in title+abstract")
for i, sent in enumerate(ext.get("evaluation_signals", []) or []):
if isinstance(sent, str) and not sentence_in_text(sent, corpus):
violations.append(
f"{pid}: evaluation_signals[{i}] not found verbatim in title+abstract")
# Token-level grounding.
for i, kw in enumerate(ext.get("keywords", []) or []):
if isinstance(kw, str) and not token_in_text(kw, corpus):
violations.append(
f"{pid}: keywords[{i}]={kw!r} not found as whole word in title+abstract")
for i, ds in enumerate(ext.get("datasets_or_domains", []) or []):
if not isinstance(ds, str):
continue
if ds.upper() in ACRONYM_BLACKLIST:
violations.append(
f"{pid}: datasets_or_domains[{i}]={ds!r} is in the "
f"generic-acronym blacklist")
if not token_in_text(ds, corpus):
violations.append(
f"{pid}: datasets_or_domains[{i}]={ds!r} not found as whole word "
f"in title+abstract")
return violations
def resolve_input_dir(input_dir_flag: Path | None) -> Path:
if input_dir_flag is not None:
if not input_dir_flag.is_dir():
sys.exit(f"ERROR: --input-dir not found or not a directory: {input_dir_flag}")
return input_dir_flag
latest = Path("output") / "latest_run.txt"
if not latest.exists():
sys.exit(
"ERROR: ./output/latest_run.txt not found. "
"Pass --input-dir <path> to verify a specific run."
)
run_id = latest.read_text(encoding="utf-8").strip()
run_dir = Path("output") / run_id
if not run_dir.is_dir():
sys.exit(f"ERROR: run directory referenced by latest_run.txt is missing: {run_dir}")
return run_dir
def main() -> int:
p = argparse.ArgumentParser(
description="Validate enriched_papers.json against the extraction contract")
p.add_argument("--input-dir", type=Path, default=None,
help="Run directory containing enriched_papers.json "
"(default: resolved from ./output/latest_run.txt)")
p.add_argument("--file", type=Path, default=None,
help="Validate this specific file instead of "
"<input-dir>/enriched_papers.json")
args = p.parse_args()
if args.file is not None:
target = args.file
else:
run_dir = resolve_input_dir(args.input_dir)
target = run_dir / "enriched_papers.json"
if not target.exists():
sys.exit(f"ERROR: file not found: {target}")
try:
data = json.loads(target.read_text(encoding="utf-8"))
except json.JSONDecodeError as e:
sys.exit(f"ERROR: {target} is not valid JSON: {e}")
papers = data.get("papers", [])
if not isinstance(papers, list):
sys.exit(f"ERROR: {target} has non-list `papers` field")
all_violations: list[str] = []
for paper in papers:
all_violations.extend(check_paper(paper))
if all_violations:
print(f"FAIL: {len(all_violations)} violation(s) in {target}")
for v in all_violations:
print(f" - {v}")
print(f"\nChecked {len(papers)} paper(s).", file=sys.stderr)
return 1
print(f"OK: {len(papers)} paper(s) in {target} satisfy the extraction contract.")
return 0
if __name__ == "__main__":
raise SystemExit(main())