Paper2Lab / src /paper2lab /inference /paper_card.py
unknown
Deploy Paper2Lab HF Space
71276a4
Raw
History Blame Contribute Delete
33.7 kB
"""
paper_card.py — Dynamic section-aware rule-based paper card builder for Paper2Lab.
Purpose
-------
Builds a clean candidate paper card before LLM/Nemotron refinement.
Design principles
-----------------
- Uses extraction-safe clean_text from pdf_loader.py.
- Uses structured sections and roles instead of raw full-PDF text whenever possible.
- Detects paper_type first, then chooses extraction strategy.
- Avoids ML-only assumptions for systematic reviews, clinical studies, surveys, and reports.
- Keeps references/appendix/boilerplate out of candidate fields.
- Produces llm_evidence_pack for later Modal/Nemotron refinement.
Public API
----------
build_paper_card(extracted: Dict[str, Any]) -> Dict[str, Any]
"""
from __future__ import annotations
import json
import re
from collections import Counter
from typing import Any, Dict, Iterable, List, Tuple
# ---------------------------------------------------------------------------
# Pattern banks
# ---------------------------------------------------------------------------
MODEL_PATTERNS = [
r"\btransformer\b", r"\bself[- ]attention\b", r"\bmulti[- ]head attention\b",
r"\bscaled dot[- ]product attention\b", r"\bencoder[- ]decoder\b",
r"\bcnn\b", r"\bconvolutional neural network\b", r"\bu[- ]net\b",
r"\bbert\b", r"\bgpt\b", r"\bllm\b", r"\blarge language model\b",
r"\bvision transformer\b", r"\bvit\b", r"\bdiffusion model\b", r"\bgan\b",
r"\bresnet\b", r"\blstm\b", r"\bgru\b", r"\bsvm\b", r"\brandom forest\b",
r"\bxgboost\b", r"\blightgbm\b", r"\blogistic regression\b",
r"\blinear regression\b", r"\bgraph neural network\b", r"\bgnn\b", r"\brag\b",
]
ML_DATASET_PATTERNS = [
r"\bwmt\s*\d{4}\b", r"\bwsj\b", r"\bwall street journal\b", r"\bpenn treebank\b",
r"\bcifar[- ]?\d+\b", r"\bimagenet\b", r"\bmnist\b", r"\bcoco\b",
r"\bglue\b", r"\bsuperglue\b", r"\bsquad\b", r"\bbookscorpus\b",
r"\bwikipedia\b", r"\bcommonvoice\b", r"\blibrispeech\b",
r"\b\d+(?:\.\d+)?\s*(?:m|million|b|billion|k|thousand)?\s*"
r"(?:sentence pairs|sentences|tokens|images|patients|samples|records|documents|cases|examples|instances)\b",
r"\btraining data\b", r"\btraining dataset\b", r"\bvalidation set\b", r"\btest set\b",
r"\bdataset\b", r"\bdata source\b", r"\bclinical data\b", r"\bpublic dataset\b", r"\bbenchmark\b",
]
REVIEW_SOURCE_PATTERNS = [
r"\bpubmed\b", r"\bscopus\b", r"\bweb of knowledge\b", r"\bweb of science\b",
r"\beric\b", r"\beducational resources and information center\b",
r"\bcochrane\b", r"\bembase\b", r"\bmedline\b", r"\bgoogle scholar\b",
r"\bdatabases?\b", r"\barticles?\b", r"\bstudies\b", r"\bpublications?\b",
r"\brecords identified\b", r"\bselected studies\b", r"\bincluded studies\b",
r"\bgray literature\b", r"\bgrey literature\b",
]
METHODOLOGY_PATTERNS = [
# ML / computational methods
r"\bwe (?:train|trained|fine[- ]?tune|fine[- ]?tuned|evaluate|evaluated|optimize|optimized|pre[- ]?train|pre[- ]?trained)\b",
r"\bmodel (?:architecture|consists|uses|contains|is trained|was trained)\b",
r"\barchitecture\b", r"\btraining procedure\b", r"\bexperimental setup\b",
r"\boptimizer\b", r"\badamw?\b", r"\bsgd\b", r"\blearning rate\b",
r"\bbatch size\b", r"\bepoch\b", r"\bwarm[- ]?up\b", r"\bscheduler\b",
r"\bdropout\b", r"\blayer normalization\b", r"\bbatch normalization\b", r"\bweight decay\b",
r"\btokenization\b", r"\bbyte[- ]pair encoding\b", r"\bpositional encoding\b",
r"\bself[- ]attention\b", r"\bscaled dot[- ]product attention\b", r"\bmulti[- ]head attention\b",
r"\bcross[- ]validation\b", r"\btrain[- ]test split\b", r"\brandom seed\b",
r"\bpre[- ]?processed\b", r"\baugmentation\b",
# General empirical / review methods
r"\bsystematic review\b", r"\bliterature review\b", r"\bscoping review\b",
r"\bdatabases? (?:were|was) searched\b", r"\bsearched\b",
r"\binclusion criteria\b", r"\bexclusion criteria\b", r"\beligibility criteria\b",
r"\bscreen(?:ed|ing)\b", r"\bstudies were selected\b", r"\bdata extraction\b",
r"\bstudy design\b", r"\bparticipants\b", r"\bprocedure\b", r"\bintervention\b",
]
METRIC_PATTERNS = [
# ML/AI metrics
r"\bbleu\b", r"\bperplexity\b", r"\baccuracy\b", r"\bprecision\b", r"\brecall\b",
r"\bf1[- ]?score\b", r"\bf1\b", r"\bauc\b", r"\broc\b", r"\bsensitivity\b",
r"\bspecificity\b", r"\brmse\b", r"\bmae\b", r"\bmse\b", r"\br\s*[²2]\b",
r"\bmap\b", r"\biou\b", r"\bwer\b", r"\bcer\b", r"\brouge\b", r"\bbertscore\b",
r"\bloss\b", r"\bcross[- ]entropy\b",
# Review / clinical / social-science measurement patterns
r"\b\d+\s+(?:articles|studies|records|participants|patients|students)\b",
r"\bfinal review included\b", r"\bwere enrolled\b", r"\bselected for further review\b",
r"\bbetween\s+(?:january\s+)?\d{4}\s+and\s+(?:january\s+)?\d{4}\b",
r"\bfrom\s+(?:january\s+)?\d{4}\s+to\s+(?:january\s+)?\d{4}\b",
]
FINDING_PATTERNS = [
r"\bachieves?\b", r"\boutperforms?\b", r"\bimproves?\b", r"\bincreases?\b",
r"\bdecreases?\b", r"\bstate[- ]of[- ]the[- ]art\b", r"\bresults show\b",
r"\bfindings show\b", r"\bsignificantly\b", r"\bsuperior\b", r"\bcomparable\b",
r"\bconsistently\b", r"\bobtains?\b", r"\bshowed that\b", r"\bfound that\b",
r"\bpositive (?:responses|attitudes|effects|outcomes)\b", r"\bwas effective\b",
]
CONTRIBUTION_PATTERNS = [
r"\bwe propose\b", r"\bwe introduce\b", r"\bwe present\b", r"\bwe develop\b",
r"\bwe designed\b", r"\bwe show\b", r"\bwe demonstrate\b", r"\bwe release\b",
r"\bwe open[- ]source\b", r"\bthis paper proposes\b", r"\bthis work proposes\b",
r"\bthis study developed\b", r"\bour contribution\b", r"\bour main contribution\b",
r"\bnovel\b", r"\bfirst to\b", r"\bfirst systematic review\b",
]
LIMITATION_PATTERNS = [
r"\blimitation\b", r"\blimitations\b", r"\bfuture work\b", r"\bmore data\b",
r"\bsmall dataset\b", r"\bfalse[- ]positive\b", r"\bfalse[- ]negative\b",
r"\bnot sufficient\b", r"\blacking\b", r"\bwe did not\b", r"\bcannot\b",
r"\bwe leave\b", r"\bdoes not generalize\b", r"\bbias\b", r"\boutside the scope\b",
r"\bnot evaluated\b", r"\bnot tested\b", r"\black of\b", r"\bmay have led to bias\b",
]
NOISE_MARKERS = [
"acknowledgement", "acknowledgment", "author contribution", "competing interests",
"correspondence", "publisher", "open access", "license", "copyright", "gmail.com",
"references", "bibliography", "arxiv:", "how to cite", "access this article online",
"quick response code", "website:", "doi:", "www.", "http://", "https://",
# Known author-contribution noise from some papers
"llion also experimented", "jakob proposed", "ashish", "noam proposed", "niki selected",
"aidan designed", "illia", "google brain",
]
AFFILIATION_MARKERS = [
"department of", "university of", "faculty of", "school of", "institute of",
"medical sciences", "corresponding author", "email", "journal of education",
]
_FIELD_REJECT: Dict[str, List[str]] = {
"contributions": ["author", "google brain", "also experimented", "selected work", "department of"],
"datasets_or_data_sources": [
"achieves", "outperforms", "state-of-the-art", "results show", "during inference",
"beam size", "parser training", "section 23", "table 4", "department of",
],
"models_or_methods": ["author", "university", "gmail", "in the following sections", "references"],
"methodology": ["in the following sections", "to the best of our knowledge", "references"],
"findings": ["table of contents", "during inference", "parser training", "references"],
}
ML_DATASET_MUST_CONTAIN = [
"dataset", "data", "wmt", "wsj", "wall street journal", "penn treebank", "bookcorpus",
"wikipedia", "sentence pairs", "sentences", "tokens", "images", "patients", "samples",
"records", "cases", "examples", "instances", "benchmark", "training set", "test set",
"validation set", "english-german", "english-french",
]
REVIEW_SOURCE_MUST_CONTAIN = [
"pubmed", "scopus", "web of knowledge", "web of science",
"educational resources", "cochrane", "embase", "medline",
"google scholar", "database", "databases", "studies",
"articles", "publications", "records", "gray literature", "grey literature",
]
# ---------------------------------------------------------------------------
# Generic utilities
# ---------------------------------------------------------------------------
def _strip_doi_noise(text: str) -> str:
text = text or ""
text = re.sub(r"\bdoi\s*[::]?\s*10\.\d{4,9}/[-._;()/:A-Za-z0-9]+", " ", text, flags=re.IGNORECASE)
text = re.sub(r"\b10\.\d{4,9}/[-._;()/:A-Za-z0-9]+", " ", text)
return text
def _clean(text: str) -> str:
text = text or ""
text = text.replace("\x00", " ").replace("\u00a0", " ")
text = text.replace("\ufb01", "fi").replace("\ufb02", "fl")
text = text.replace("\u2013", "-").replace("\u2014", "-")
text = text.replace("\u2018", "'").replace("\u2019", "'")
text = text.replace("\u201c", '"').replace("\u201d", '"')
text = _strip_doi_noise(text)
text = re.sub(r"\s+", " ", text)
return text.strip()
def _normalize_key(text: str) -> str:
return re.sub(r"[^a-z0-9]+", " ", _clean(text).lower()).strip()
def _bad_sentence_quality(sentence: str) -> bool:
"""Reject merged-column, affiliation, citation, and boilerplate artifacts."""
s = _clean(sentence)
low = s.lower()
if not s:
return True
if any(x in low for x in AFFILIATION_MARKERS):
return True
# Too many citation markers often means merged reference/body text.
if len(re.findall(r"\[\d+\]", s)) >= 2:
return True
# Known symptoms of two-column stitching / line interleaving.
bad_regexes = [
r"\bneed\s+this systematic review\b",
r"\bof the there\b",
r"\band the that\b",
r"\bwere as follows: being\b",
r"\bdata were included to improve\b",
r"\bto adopt a new style of learning\s*\.\s*medical courses\b",
r"\bfisher \(\d{4}\) discuss intended studies\b",
]
if any(re.search(p, low) for p in bad_regexes):
return True
# Merged sentences are usually very long and contain unrelated cues.
if len(s.split()) > 55 and any(x in low for x in [
"students are required", "there is a good deal", "department", "university",
"corresponding author", "access this article",
]):
return True
# Odd punctuation/table artifacts.
if s.count("|") >= 2 or s.count("%") >= 6 or s.count("@") >= 1:
return True
return False
def _is_noise(sentence: str) -> bool:
low = sentence.lower()
if any(m in low for m in NOISE_MARKERS):
return True
if len(sentence.split()) > 85:
return True
if re.search(r"^\d+(?:\.\d+)*\s+[A-Z][A-Za-z ]{2,50}$", sentence):
return True
if _bad_sentence_quality(sentence):
return True
return False
def _split_sentences(text: str) -> List[str]:
text = _clean(text)
if not text:
return []
raw = re.split(r"(?<=[.!?])\s+(?=[A-Z0-9])", text)
sentences: List[str] = []
for s in raw:
s = _clean(s)
if 35 <= len(s) <= 420 and not _is_noise(s):
sentences.append(s)
return sentences
def _dedupe(items: Iterable[str]) -> List[str]:
seen: set[str] = set()
out: List[str] = []
for item in items:
item = _clean(item)
key = _normalize_key(item)[:220]
if key and key not in seen:
seen.add(key)
out.append(item)
return out
def _json_safe(value: Any) -> Any:
try:
json.dumps(value)
return value
except Exception:
return str(value)
def _section_title(sec: Dict[str, Any]) -> str:
return _clean(sec.get("title") or "")
def _section_role(sec: Dict[str, Any]) -> str:
return sec.get("role") or "other"
def _sections_by_role(
extracted: Dict[str, Any],
roles: List[str],
title_contains: List[str] | None = None,
) -> str:
chunks: List[str] = []
title_contains = [t.lower() for t in (title_contains or [])]
paper_title = _clean(extracted.get("title") or "").lower()
blocked_roles = {"references", "appendix", "boilerplate"}
blocked_titles = {"front matter", "keywords", "keywords:", "table of contents"}
for sec in extracted.get("sections", []):
title_raw = _section_title(sec)
title = title_raw.lower()
role = _section_role(sec)
if role in blocked_roles:
continue
if title in blocked_titles:
continue
if paper_title and title == paper_title:
continue
if role in roles or any(t in title for t in title_contains):
chunks.append(sec.get("text", ""))
return "\n".join(chunks)
def _find_sentences(
text: str,
patterns: List[str],
max_items: int = 8,
require_number: bool = False,
) -> List[str]:
found: List[str] = []
for sentence in _split_sentences(text):
if require_number and not re.search(r"\d", sentence):
continue
if any(re.search(p, sentence, flags=re.IGNORECASE) for p in patterns):
found.append(sentence)
if len(found) >= max_items:
break
return _dedupe(found)
def _filter_field(items: List[str], field: str, paper_type: str = "general_research") -> List[str]:
reject_terms = _FIELD_REJECT.get(field, [])
filtered: List[str] = []
for item in items:
item = _clean(item)
low = item.lower()
if not item or _is_noise(item):
continue
if any(term in low for term in reject_terms):
continue
if field == "datasets_or_data_sources":
if paper_type == "systematic_review":
if not any(term in low for term in REVIEW_SOURCE_MUST_CONTAIN):
continue
else:
if not any(term in low for term in ML_DATASET_MUST_CONTAIN):
continue
if any(bad in low for bad in ["beam size", "during inference", "parser training", "section 23"]):
continue
filtered.append(item)
return _dedupe(filtered)
# ---------------------------------------------------------------------------
# Dynamic paper type and field inference
# ---------------------------------------------------------------------------
def _top_terms(text: str, k: int = 10) -> List[str]:
stop = {
"the", "and", "for", "with", "that", "this", "from", "were", "was", "are", "has",
"have", "had", "their", "they", "into", "using", "used", "study", "paper", "article",
"results", "method", "methods", "data", "based", "between", "through", "there",
}
words = re.findall(r"\b[a-z][a-z]{3,}\b", text.lower())
words = [w for w in words if w not in stop]
return [w for w, _ in Counter(words).most_common(k)]
def _infer_paper_type(title: str, abstract: str, sections: List[Dict[str, Any]], clean_text: str) -> str:
title_abs = f"{title} {abstract}".lower()
section_titles = " ".join(_section_title(s) for s in sections).lower()
probe = f"{title_abs} {section_titles} {clean_text[:9000].lower()}"
review_score = 0
for term in [
"systematic review",
"systematic literature search",
"systematic literature review",
"literature search",
"literature review",
"prisma",
"study selection",
"bibliographic search",
"databases were searched",
"database search",
"included studies",
"included articles",
"screening",
"eligibility criteria",
]:
if term in probe:
review_score += 1
if review_score >= 2:
return "systematic_review"
if any(x in probe for x in ["meta-analysis", "meta analysis", "scoping review"]):
return "systematic_review"
narrative_review_terms = [
"review of important findings",
"review of previous research",
"previous research on",
"literature on",
"research on teacher education",
"implications for improving",
]
if any(term in probe for term in narrative_review_terms):
return "survey_or_review"
if any(x in probe for x in ["randomized controlled trial", "cohort", "case-control", "clinical trial"]):
return "clinical_study"
if any(x in probe for x in ["transformer", "bert", "neural network", "optimizer", "training loss", "fine-tuned", "imagenet", "bleu"]):
return "machine_learning"
if any(x in probe for x in ["survey", "questionnaire", "respondents", "participants"]):
return "survey_study"
if any(x in probe for x in ["recommendations", "checklist", "best practices"]):
return "guide_or_report"
return "general_research"
def _infer_field(title: str, abstract: str, clean_text: str, paper_type: str) -> str:
title_abs = f"{title} {abstract}".lower()
probe = f"{title_abs} {clean_text[:5000].lower()}"
domain_scores: Dict[str, int] = {
"Education": 0,
"Natural Language Processing": 0,
"Computer Vision": 0,
"Medical / Clinical Research": 0,
"Medical AI": 0,
"Biology / Life Sciences": 0,
"Graph Learning": 0,
"Reinforcement Learning": 0,
"Generative Models": 0,
"Social Science": 0,
"Machine Learning": 0,
}
weighted_terms: Dict[str, List[str]] = {
"Education": ["education", "educational", "academic performance", "students", "higher education", "preclinical academic"],
"Natural Language Processing": ["translation", "language model", "question answering", "summarization", "bleu", "token", "bert", "gpt", "corpus"],
"Computer Vision": ["image classification", "object detection", "segmentation", "imagenet", "resnet", "vision transformer", "pixel"],
"Medical / Clinical Research": ["patient", "clinical", "cohort", "diagnosis", "mortality", "disease", "treatment", "medical students"],
"Medical AI": ["clinical prediction", "medical image", "radiograph", "x-ray", "ct scan", "mri", "ehr", "icu", "machine learning"],
"Biology / Life Sciences": ["protein", "molecule", "drug", "genomics", "dna", "rna", "crispr", "gene"],
"Graph Learning": ["graph neural network", "gnn", "node classification", "link prediction", "message passing"],
"Reinforcement Learning": ["reward", "agent", "policy", "q-learning", "ppo", "dqn", "actor-critic"],
"Generative Models": ["diffusion model", "gan", "generative adversarial", "vae", "denoising"],
"Social Science": ["survey", "questionnaire", "social science", "respondents", "interviews"],
"Machine Learning": ["machine learning", "deep learning", "neural network", "training", "optimizer", "classification"],
}
for domain, terms in weighted_terms.items():
for term in terms:
if term in probe:
domain_scores[domain] += 1
if term in title_abs:
domain_scores[domain] += 2
# Paper-type-aware tie breaks.
if paper_type == "systematic_review":
if domain_scores["Education"] >= 2:
return "Education"
if domain_scores["Medical / Clinical Research"] >= 2:
return "Medical / Clinical Research"
if domain_scores["Social Science"] >= 2:
return "Social Science"
if domain_scores["Medical AI"] >= 3 and domain_scores["Machine Learning"] >= 2:
return "Medical AI"
best = max(domain_scores, key=domain_scores.get)
if domain_scores[best] <= 1:
return "General Research"
return best
# ---------------------------------------------------------------------------
# Field-specific extraction helpers
# ---------------------------------------------------------------------------
def _extract_research_question(abstract: str, intro: str, title: str, paper_type: str) -> str:
text = _clean(f"{abstract} {intro}")
if paper_type == "systematic_review":
review_markers = [
"purpose", "aim", "objective", "intended", "present review", "systematic review",
"with the purpose", "the aim of", "the objective of",
]
for sentence in _split_sentences(text):
low = sentence.lower()
if any(m in low for m in review_markers):
return sentence
markers = [
"we propose", "we introduce", "we investigate", "we evaluate", "we present",
"we developed", "this paper proposes", "this work proposes", "the goal",
"the aim", "the objective", "our goal", "our aim", "this study aims",
]
for sentence in _split_sentences(text):
low = sentence.lower()
if any(m in low for m in markers):
return sentence
clean_abstract = _split_sentences(abstract)
if clean_abstract:
return clean_abstract[0]
return title or ""
def _extract_contributions(abstract: str, intro: str, conclusion: str, paper_type: str) -> List[str]:
text = f"{abstract} {intro} {conclusion}"
items = _find_sentences(text, CONTRIBUTION_PATTERNS, 8)
if paper_type == "systematic_review" and not items:
# Many reviews do not have explicit contribution language. Avoid hallucinating.
return []
return _filter_field(items, "contributions", paper_type)
def _extract_methodology(methods: str, experiments: str, intro: str, paper_type: str) -> List[str]:
primary = f"{methods}\n{experiments}"
if paper_type == "systematic_review":
review_patterns = [
r"\b(?:pubmed|scopus|web of knowledge|web of science|eric|cochrane|embase|medline)\b.*\bsearched\b",
r"\bdatabases?\b.*\bsearched\b",
r"\binclusion criteria\b.*",
r"\bexclusion criteria\b.*",
r"\beligibility criteria\b.*",
r"\btitles and abstracts were screened\b.*",
r"\barticles were imported\b.*",
r"\bdata extraction\b.*",
r"\bsystematic review was conducted\b.*",
]
items = _find_sentences(primary, review_patterns, max_items=10)
if len(items) < 3:
items += _find_sentences(f"{intro}\n{primary}", review_patterns, max_items=10)
return _filter_field(items, "methodology", paper_type)[:8]
items = _find_sentences(primary, METHODOLOGY_PATTERNS, max_items=12)
if len(items) < 3:
items += _find_sentences(intro, METHODOLOGY_PATTERNS, max_items=5)
return _filter_field(items, "methodology", paper_type)[:10]
def _extract_datasets(clean_text: str, methods: str, experiments: str, results: str, paper_type: str) -> List[str]:
priority_text = f"{methods}\n{experiments}\n{results}"
if paper_type == "systematic_review":
items = _find_sentences(priority_text, REVIEW_SOURCE_PATTERNS, max_items=14)
if len(items) < 4:
items += _find_sentences(clean_text[:12000], REVIEW_SOURCE_PATTERNS, max_items=14)
# Also add compact database names when explicitly found.
compact: List[str] = []
db_names = [
"PubMed", "Scopus", "Web of Knowledge", "Web of Science", "ERIC",
"Educational Resources and Information Center", "Cochrane", "Embase", "Medline", "Google Scholar",
]
low = priority_text.lower() + " " + clean_text[:8000].lower()
for name in db_names:
if name.lower() in low:
compact.append(name)
compact += _filter_field(items, "datasets_or_data_sources", paper_type)
return _dedupe(compact)[:10]
items = _find_sentences(priority_text, ML_DATASET_PATTERNS, 12)
if len(items) < 4:
items += _find_sentences(clean_text[:15000], ML_DATASET_PATTERNS, 12)
return _filter_field(items, "datasets_or_data_sources", paper_type)[:10]
def _extract_models(clean_text: str, methods: str, experiments: str, paper_type: str) -> List[str]:
if paper_type in {"systematic_review", "guide_or_report", "survey_study"}:
return []
priority_text = f"{methods}\n{experiments}"
items = _find_sentences(priority_text, MODEL_PATTERNS, 12)
if len(items) < 4:
items += _find_sentences(clean_text[:15000], MODEL_PATTERNS, 12)
return _filter_field(items, "models_or_methods", paper_type)[:10]
def _extract_metrics(clean_text: str, results: str, experiments: str, methods: str, paper_type: str) -> List[str]:
priority_text = f"{results}\n{experiments}\n{methods}"
if paper_type == "systematic_review":
review_metric_patterns = [
r"\b\d+\s+(?:articles|studies|records|publications)\b.*",
r"\b(?:final review|review) included\s+\d+\b.*",
r"\b\d+\s+articles were selected\b.*",
r"\bbetween\s+(?:january\s+)?\d{4}\s+and\s+(?:january\s+)?\d{4}\b.*",
r"\bfrom\s+(?:january\s+)?\d{4}\s+to\s+(?:january\s+)?\d{4}\b.*",
]
items = _find_sentences(priority_text, review_metric_patterns, 10, require_number=True)
if len(items) < 3:
items += _find_sentences(clean_text[:12000], review_metric_patterns, 10, require_number=True)
return _dedupe(items)[:8]
items = _find_sentences(priority_text, METRIC_PATTERNS, 12, require_number=True)
if len(items) < 3:
items += _find_sentences(clean_text[:15000], METRIC_PATTERNS, 10, require_number=True)
if not items:
items = _find_sentences(priority_text or clean_text[:15000], METRIC_PATTERNS, 8)
return _dedupe([x for x in items if "references" not in x.lower()])[:10]
def _extract_findings(results: str, conclusion: str, abstract: str, paper_type: str) -> List[str]:
text = f"{results} {conclusion} {abstract}"
items = _find_sentences(text, FINDING_PATTERNS, 10)
return _filter_field(items, "findings", paper_type)[:5]
def _extract_limitations(clean_text: str, limitations: str, discussion: str, conclusion: str, paper_type: str) -> List[str]:
text = f"{limitations} {discussion} {conclusion} {clean_text[-6000:]}"
return _filter_field(_find_sentences(text, LIMITATION_PATTERNS, 8), "limitations", paper_type)[:6]
def _missing_repro_info(card: Dict[str, Any], extracted: Dict[str, Any], paper_type: str) -> List[str]:
missing: List[str] = []
method_text = _sections_by_role(extracted, ["methodology", "experiments"])
low = method_text.lower()
if paper_type == "systematic_review":
checks = {
"search date range is not clearly specified": ["from", "between", "1987", "2018", "date", "time limitation"],
"inclusion/exclusion criteria are not clearly specified": ["inclusion criteria", "exclusion criteria", "eligibility criteria"],
"screening process is not clearly specified": ["screened", "titles and abstracts", "two independent"],
"quality assessment method is not clearly specified": ["quality", "assessment", "best evidence", "risk of bias"],
}
for label, terms in checks.items():
if not any(t in low for t in terms):
missing.append(label)
if not card.get("datasets_or_data_sources"):
missing.append("bibliographic databases or study sources could not be reliably extracted")
return missing[:6]
if paper_type == "machine_learning":
checks = {
"training hyperparameters are incomplete": ["learning rate", "batch size", "epoch", "steps"],
"random seed is not specified": ["seed", "random seed"],
"code availability is not clearly specified": ["code", "github", "repository"],
"dataset split details are not clearly specified": ["train", "validation", "test", "split"],
}
else:
checks = {
"study design details are incomplete": ["study design", "method", "procedure"],
"sample/data source details are incomplete": ["participants", "patients", "samples", "data source", "dataset"],
"analysis or measurement method is not clearly specified": ["analysis", "measure", "outcome", "metric"],
}
for label, terms in checks.items():
if not any(t in low for t in terms):
missing.append(label)
if not card.get("datasets_or_data_sources") and paper_type == "machine_learning":
missing.append("dataset or data source could not be reliably extracted")
return missing[:6]
# ---------------------------------------------------------------------------
# Evidence pack
# ---------------------------------------------------------------------------
def _llm_evidence_pack(extracted: Dict[str, Any], paper_card: Dict[str, Any]) -> Dict[str, Any]:
compact_sections: List[Dict[str, Any]] = []
for sec in extracted.get("sections", []):
text = _clean(sec.get("text", ""))
if not text:
continue
compact_sections.append({
"title": sec.get("title", ""),
"role_hint": sec.get("role", "other"),
"page_start": sec.get("page_start"),
"page_end": sec.get("page_end"),
"preview": text[:2000],
})
candidate = {k: v for k, v in paper_card.items() if k != "llm_evidence_pack"}
return {
"system_prompt": (
"You are a scientific paper analyst. Correct the candidate JSON paper card using only the provided evidence. "
"Do not invent facts. Remove boilerplate, references, author affiliations, acknowledgements, duplicate claims, "
"and generic text. Return only valid JSON with the same keys as candidate_paper_card."
),
"instruction": (
"Refine candidate_paper_card. Keep facts specific, concise, and grounded in section previews, captions, and tables. "
"Respect paper_type: for systematic reviews, extract databases, inclusion/exclusion criteria, screening process, "
"selected studies, synthesis method, findings, and review limitations instead of ML hyperparameters. "
"Use null or [] when a field cannot be determined."
),
"candidate_paper_card": candidate,
"section_previews": compact_sections[:16],
"captions": extracted.get("captions", [])[:10],
"tables": [_json_safe(t) for t in extracted.get("tables", [])[:4]],
"metadata": {
"references_count": len(extracted.get("references", [])),
"references_removed_from_body": bool(extracted.get("references_text")),
"appendix_removed_from_body": bool(extracted.get("appendix_text")),
"quality": extracted.get("quality", {}),
},
}
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
def build_paper_card(extracted: Dict[str, Any]) -> Dict[str, Any]:
title = extracted.get("title") or ""
abstract = extracted.get("abstract") or ""
clean_text = extracted.get("clean_text") or extracted.get("text") or ""
sections = extracted.get("sections", [])
intro = _sections_by_role(extracted, ["introduction", "background"])
methods = _sections_by_role(extracted, ["methodology"])
experiments = _sections_by_role(extracted, ["experiments"])
results = _sections_by_role(extracted, ["results", "experiments"])
discussion = _sections_by_role(extracted, ["discussion"])
limitations_text = _sections_by_role(extracted, ["limitations"])
conclusion = _sections_by_role(extracted, ["conclusion"])
paper_type = _infer_paper_type(title, abstract, sections, clean_text)
field = _infer_field(title, abstract, clean_text, paper_type)
card: Dict[str, Any] = {
"title": title or None,
"field": field,
"paper_type": paper_type,
"research_question": _extract_research_question(abstract, intro, title, paper_type) or None,
"contributions": _extract_contributions(abstract, intro, conclusion, paper_type),
"methodology": _extract_methodology(methods, experiments, intro, paper_type),
"datasets_or_data_sources": _extract_datasets(clean_text, methods, experiments, results, paper_type),
"models_or_methods": _extract_models(clean_text, methods, experiments, paper_type),
"metrics_or_measurements": _extract_metrics(clean_text, results, experiments, methods, paper_type),
"key_findings": _extract_findings(results, conclusion, abstract, paper_type),
"limitations": _extract_limitations(clean_text, limitations_text, discussion, conclusion, paper_type),
"missing_reproducibility_info": [],
"metadata": {
"source_pdf": extracted.get("source_pdf"),
"num_pages": extracted.get("num_pages"),
"extraction_engine": extracted.get("extraction_engine"),
"quality": extracted.get("quality", {}),
"references_count": len(extracted.get("references", [])),
"references_removed_from_body": bool(extracted.get("references_text")),
"appendix_removed_from_body": bool(extracted.get("appendix_text")),
"section_roles": extracted.get("quality", {}).get("section_roles", []),
"top_terms": _top_terms(f"{title} {abstract} {clean_text[:5000]}", 10),
},
"source_pdf": extracted.get("source_pdf"),
"annotation_version": "v1.0",
}
card["missing_reproducibility_info"] = _missing_repro_info(card, extracted, paper_type)
card["llm_evidence_pack"] = _llm_evidence_pack(extracted, card)
return card