| """ |
| roadmap.py — Deterministic reproduction-roadmap builder for Paper2Lab. |
| |
| Input: section-aware extraction dict + optional paper_card. |
| Output: structured reproduction roadmap candidate. |
| |
| This module is intentionally local/rule-based. Modal/Nemotron should refine this |
| later, not replace it. |
| |
| Design goals: |
| - Keep the public API stable: build_reproduction_roadmap(extracted, paper_card=None) |
| - Be paper-type aware: ML papers, systematic reviews, clinical/general papers. |
| - Avoid noisy merged PDF lines from two-column layouts. |
| - Keep concise evidence with section/page locations. |
| """ |
|
|
| from __future__ import annotations |
|
|
| import re |
| from typing import Any, Dict, List, Sequence |
|
|
|
|
| |
| |
| |
|
|
| _DATASET_MARKERS = [ |
| "dataset", "data", "corpus", "benchmark", "training set", "test set", "validation set", |
| "patients", "samples", "records", "images", "sentences", "tokens", "articles", "studies", |
| "pubmed", "scopus", "web of knowledge", "eric", "educational resources and information center", |
| "cochrane", "wmt", "imagenet", "cifar", "mnist", "glue", "squad", "penn treebank", "wsj", |
| ] |
|
|
| _KNOWN_DATA_SOURCES = [ |
| "PubMed", |
| "Scopus", |
| "Web of Knowledge", |
| "ERIC", |
| "Educational Resources and Information Center", |
| "Cochrane", |
| "WMT 2014", |
| "WMT", |
| "ImageNet", |
| "CIFAR-10", |
| "CIFAR-100", |
| "MNIST", |
| "GLUE", |
| "SuperGLUE", |
| "SQuAD", |
| "Penn Treebank", |
| "Wall Street Journal", |
| "WSJ", |
| ] |
|
|
| _SOFTWARE_MARKERS = [ |
| "python", "pytorch", "tensorflow", "keras", "scikit-learn", "sklearn", "r", "matlab", |
| "cuda", "gpu", "github", "repository", "code", "implementation", "package", "library", |
| "endnote", "excel", "spss", "stata", "prisma", "docker", |
| ] |
|
|
| _KNOWN_SOFTWARE = [ |
| "Python", "PyTorch", "TensorFlow", "Keras", "Scikit-learn", "R", "MATLAB", |
| "CUDA", "Docker", "GitHub", "EndNote", "Excel", "SPSS", "Stata", "PRISMA", |
| ] |
|
|
| _EXPERIMENT_MARKERS = [ |
| "trained", "fine-tuned", "pre-trained", "evaluated", "optimized", "searched", "screened", |
| "selected", "included", "excluded", "randomized", "split", "preprocessed", "augmented", |
| "we train", "we trained", "we evaluate", "we evaluated", "we search", "we searched", |
| "inclusion criteria", "exclusion criteria", "eligibility criteria", "data extraction", |
| "titles and abstracts", "duplicate", "endnote", "excel", |
| ] |
|
|
| _EVAL_MARKERS = [ |
| "accuracy", "precision", "recall", "f1", "auc", "roc", "bleu", "rouge", "perplexity", |
| "loss", "rmse", "mae", "statistical", "p-value", "confidence interval", "evaluation", |
| "assessed", "measured", "score", "metric", "kirkpatrick", "quality assessment", "meta-analysis", |
| "best evidence medical education", "beme", "final review", "included studies", |
| ] |
|
|
| _OUTPUT_MARKERS = [ |
| "achieved", "achieves", "outperformed", "outperforms", "improved", "score", "accuracy", |
| "bleu", "f1", "auc", "included", "selected", "final review", "articles", "studies", |
| ] |
|
|
|
|
| |
| |
| |
|
|
| def _clean(text: str) -> str: |
| text = text or "" |
| text = text.replace("\x00", " ").replace("\u00a0", " ") |
| text = text.replace("\ufb01", "fi").replace("\ufb02", "fl") |
| text = re.sub(r"\b10\.\d{4,9}/[-._;()/:A-Za-z0-9]+", "", text) |
| text = re.sub(r"\s+", " ", text) |
| text = re.sub(r"\s+([.,;:])", r"\1", text) |
| return text.strip(" .;:\n\t") |
|
|
|
|
| def _is_noisy(sentence: str) -> bool: |
| s = _clean(sentence) |
| low = s.lower() |
|
|
| bad_fragments = [ |
| "corresponding author", |
| "how to cite", |
| "access this article online", |
| "department of", |
| "university of", |
| "medical sciences", |
| "received:", |
| "accepted:", |
| "published:", |
| "copyright", |
| "license", |
| "all rights reserved", |
| "gmail.com", |
| "@", |
| "table of contents", |
| "journal of education and health promotion", |
| "endnote teachers", |
| "being accordingly", |
| "need this systematic review", |
| "the that", |
| "of the there", |
| "the the evidence", |
| "table 1:", |
| "table 2:", |
| "table 3:", |
| "table 4:", |
| ] |
| if any(x in low for x in bad_fragments): |
| return True |
|
|
| if len(s.split()) > 48: |
| return True |
|
|
| if len(re.findall(r"\[\d+", s)) >= 2: |
| return True |
|
|
| if sentence.count("|") >= 2 or sentence.count("%") >= 6: |
| return True |
|
|
| |
| |
| if re.search(r"\b(the|this|therefore|besides|fisher)\b.+\b(the|this|therefore|accordingly)\b", low) and len(s.split()) > 34: |
| return True |
|
|
| |
| if len(s.split()) < 5: |
| return True |
|
|
| return False |
|
|
|
|
| def _split_sentences(text: str) -> List[str]: |
| text = _clean(text) |
| |
| text = re.sub(r"\b(ABSTRACT|INTRODUCTION|MATERIALS AND METHODS|RESULTS|DISCUSSION|CONCLUSION):", r". \1:", text) |
| raw = re.split(r"(?<=[.!?])\s+(?=[A-Z0-9])", text) |
| out: List[str] = [] |
| for s in raw: |
| s = _clean(s) |
| if 35 <= len(s) <= 340 and not _is_noisy(s): |
| out.append(s) |
| return out |
|
|
|
|
| def _dedupe_strings(items: Sequence[str]) -> List[str]: |
| seen: set[str] = set() |
| out: List[str] = [] |
| for item in items: |
| clean = _clean(str(item)) |
| key = re.sub(r"[^a-z0-9]+", " ", clean.lower()).strip()[:180] |
| if key and key not in seen: |
| seen.add(key) |
| out.append(clean) |
| return out |
|
|
|
|
| def _dedupe_dicts(items: List[Dict[str, Any]], key_name: str = "text") -> List[Dict[str, Any]]: |
| seen: set[str] = set() |
| out: List[Dict[str, Any]] = [] |
| for item in items: |
| text = _clean(str(item.get(key_name, ""))) |
| key = re.sub(r"[^a-z0-9]+", " ", text.lower()).strip()[:180] |
| if key and key not in seen: |
| seen.add(key) |
| out.append(item) |
| return out |
|
|
|
|
| |
| |
| |
|
|
| def _section_texts(extracted: Dict[str, Any], roles: set[str]) -> List[Dict[str, Any]]: |
| rows: List[Dict[str, Any]] = [] |
| blocked_titles = {"front matter", "keywords", "keywords:", "table of contents"} |
| paper_title = _clean(extracted.get("title") or "").lower() |
|
|
| for sec in extracted.get("sections", []): |
| role = sec.get("role", "other") |
| title = _clean(sec.get("title") or "") |
| low_title = title.lower() |
| if role not in roles: |
| continue |
| if low_title in blocked_titles: |
| continue |
| if paper_title and low_title == paper_title: |
| continue |
| rows.append(sec) |
| return rows |
|
|
|
|
| def _find_evidence( |
| sections: List[Dict[str, Any]], |
| markers: List[str], |
| limit: int = 8, |
| require_number: bool = False, |
| ) -> List[Dict[str, Any]]: |
| hits: List[Dict[str, Any]] = [] |
| marker_lows = [m.lower() for m in markers] |
|
|
| for sec in sections: |
| title = sec.get("title", "") |
| role = sec.get("role", "other") |
| for sent in _split_sentences(sec.get("text", "")): |
| low = sent.lower() |
| if require_number and not re.search(r"\d", sent): |
| continue |
| if any(m in low for m in marker_lows): |
| hits.append({ |
| "text": sent, |
| "section": title, |
| "role": role, |
| "page_start": sec.get("page_start"), |
| "page_end": sec.get("page_end"), |
| }) |
| if len(hits) >= limit: |
| return _dedupe_dicts(hits) |
| return _dedupe_dicts(hits) |
|
|
|
|
| |
| |
| |
|
|
| def _extract_known_sources(text: str) -> List[str]: |
| low = text.lower() |
| found: List[str] = [] |
| aliases = { |
| "PubMed": ["pubmed"], |
| "Scopus": ["scopus"], |
| "Web of Knowledge": ["web of knowledge", "thomson reuters"], |
| "ERIC": ["eric", "educational resources and information center"], |
| "Educational Resources and Information Center": ["educational resources and information center"], |
| "Cochrane": ["cochrane"], |
| "WMT 2014": ["wmt 2014"], |
| "WMT": ["wmt"], |
| "ImageNet": ["imagenet"], |
| "CIFAR-10": ["cifar-10", "cifar 10"], |
| "CIFAR-100": ["cifar-100", "cifar 100"], |
| "MNIST": ["mnist"], |
| "GLUE": ["glue"], |
| "SuperGLUE": ["superglue"], |
| "SQuAD": ["squad"], |
| "Penn Treebank": ["penn treebank"], |
| "Wall Street Journal": ["wall street journal"], |
| "WSJ": ["wsj"], |
| } |
| for canonical, keys in aliases.items(): |
| if any(k in low for k in keys): |
| found.append(canonical) |
| return _dedupe_strings(found) |
|
|
|
|
| def _extract_software_from_text(text: str) -> List[str]: |
| low = text.lower() |
| found: List[str] = [] |
| for name in _KNOWN_SOFTWARE: |
| |
| if name == "R": |
| if re.search(r"\bR\b", text) and any(x in low for x in ["statistical", "analysis", "software", "package"]): |
| found.append(name) |
| continue |
| if name.lower() in low: |
| found.append(name) |
| return _dedupe_strings(found) |
|
|
|
|
| def _extract_count_outputs(text: str) -> List[str]: |
| outputs: List[str] = [] |
| patterns = [ |
| r"\b(?:totally|overall|in total),?\s*\d+[\w\s-]{0,40}\b(?:articles|studies|records|abstracts|patients|samples)\b", |
| r"\b(?:final review|review)\s+(?:included|enrolled)\s+\d+\s+(?:articles|studies)\b", |
| r"\b\d+\s+(?:articles|studies|records|abstracts|patients|samples)\s+(?:were|was)\s+(?:selected|included|enrolled|identified)\b", |
| r"\bbetween\s+[A-Z][a-z]+\s+\d{4}\s+and\s+[A-Z][a-z]+\s+\d{4}\b", |
| r"\bfrom\s+[A-Z][a-z]+\s+\d{4}\s+to\s+[A-Z][a-z]+\s+\d{4}\b", |
| ] |
| for pat in patterns: |
| for m in re.finditer(pat, text, flags=re.IGNORECASE): |
| outputs.append(_clean(m.group(0))) |
| return _dedupe_strings(outputs)[:8] |
|
|
|
|
| def _compact_datasets(paper_card: Dict[str, Any], evidence: List[Dict[str, Any]], all_text: str) -> List[str]: |
| datasets: List[str] = [] |
|
|
| |
| datasets.extend(_extract_known_sources(all_text)) |
|
|
| |
| for item in paper_card.get("datasets_or_data_sources") or []: |
| clean = _clean(item) |
| if not clean or _is_noisy(clean): |
| continue |
| if len(clean.split()) <= 12: |
| datasets.append(clean) |
|
|
| |
| for hit in evidence: |
| clean = _clean(hit.get("text", "")) |
| if not clean or _is_noisy(clean): |
| continue |
| if len(clean.split()) <= 24: |
| datasets.append(clean) |
|
|
| return _dedupe_strings(datasets)[:10] |
|
|
|
|
| def _roadmap_level(missing_count: int, detected_count: int, noisy_evidence_count: int = 0) -> str: |
| |
| if noisy_evidence_count >= 4: |
| return "partial" if detected_count >= 5 else "weak" |
| if detected_count >= 8 and missing_count <= 2: |
| return "strong" |
| if detected_count >= 5 and missing_count <= 4: |
| return "partial" |
| return "weak" |
|
|
|
|
| |
| |
| |
|
|
| def build_reproduction_roadmap( |
| extracted: Dict[str, Any], |
| paper_card: Dict[str, Any] | None = None, |
| ) -> Dict[str, Any]: |
| """Build a local, evidence-grounded reproduction roadmap candidate.""" |
| paper_card = paper_card or {} |
| paper_type = paper_card.get("paper_type", "general_research") |
|
|
| method_sections = _section_texts(extracted, {"methodology", "experiments"}) |
| result_sections = _section_texts(extracted, {"results", "discussion", "conclusion"}) |
| all_body_sections = _section_texts( |
| extracted, |
| {"methodology", "experiments", "results", "discussion", "conclusion", "introduction", "abstract"}, |
| ) |
|
|
| all_text = "\n".join(sec.get("text", "") for sec in all_body_sections) |
| method_text = "\n".join(sec.get("text", "") for sec in method_sections) |
|
|
| dataset_evidence = _find_evidence(all_body_sections, _DATASET_MARKERS, limit=12) |
| software = _extract_software_from_text(all_text) |
| experimental_steps = _find_evidence(method_sections or all_body_sections, _EXPERIMENT_MARKERS, limit=10) |
| evaluation = _find_evidence(result_sections + method_sections, _EVAL_MARKERS, limit=10) |
|
|
| datasets = _compact_datasets(paper_card, dataset_evidence, all_text) |
|
|
| expected_outputs: List[str] = [] |
| for item in paper_card.get("metrics_or_measurements", [])[:5]: |
| clean = _clean(item) |
| if clean and not _is_noisy(clean): |
| expected_outputs.append(clean) |
| for item in paper_card.get("key_findings", [])[:5]: |
| clean = _clean(item) |
| if clean and not _is_noisy(clean): |
| expected_outputs.append(clean) |
| expected_outputs.extend(_extract_count_outputs(all_text)) |
| expected_outputs = _dedupe_strings(expected_outputs)[:8] |
|
|
| missing: List[str] = [] |
| if not datasets: |
| missing.append("dataset or source corpus details are missing") |
| if not experimental_steps: |
| missing.append("experimental or procedural steps are missing") |
| if not evaluation: |
| missing.append("evaluation procedure is missing") |
| if paper_type == "machine_learning" and not software: |
| missing.append("software/framework requirements are missing") |
| if paper_type == "systematic_review": |
| low = method_text.lower() or all_text.lower() |
| if not any(x in low for x in ["inclusion criteria", "eligibility criteria"]): |
| missing.append("inclusion criteria are missing") |
| if "exclusion criteria" not in low: |
| missing.append("exclusion criteria are missing") |
| if not any(x in low for x in ["quality assessment", "risk of bias", "best evidence medical education", "valid tool"]): |
| missing.append("quality assessment method is missing") |
|
|
| noisy_count = 0 |
| for item in experimental_steps + evaluation + dataset_evidence: |
| if _is_noisy(item.get("text", "")): |
| noisy_count += 1 |
|
|
| detected_count = len(datasets) + len(software) + len(experimental_steps) + len(evaluation) + len(expected_outputs) |
|
|
| return { |
| "paper_type": paper_type, |
| "datasets": datasets, |
| "software_requirements": software, |
| "experimental_steps": [ |
| { |
| "step": i + 1, |
| "description": x["text"], |
| "section": x["section"], |
| "page_start": x.get("page_start"), |
| "page_end": x.get("page_end"), |
| } |
| for i, x in enumerate(experimental_steps[:10]) |
| ], |
| "evaluation_procedure": [ |
| { |
| "description": x["text"], |
| "section": x["section"], |
| "page_start": x.get("page_start"), |
| "page_end": x.get("page_end"), |
| } |
| for x in evaluation[:10] |
| ], |
| "expected_outputs": expected_outputs, |
| "missing_for_reproduction": missing, |
| "estimated_reproducibility": _roadmap_level(len(missing), detected_count, noisy_count), |
| } |
|
|