Spaces:
Running
Running
unknown commited on
Commit ·
71276a4
0
Parent(s):
Deploy Paper2Lab HF Space
Browse files- README.md +48 -0
- README_HF.md +48 -0
- app.py +1680 -0
- configs/dataset.yaml +0 -0
- configs/evaluation.yaml +0 -0
- configs/inference.yaml +0 -0
- configs/training.yaml +0 -0
- docs/annotation_schema.md +0 -0
- docs/dataset_card.md +0 -0
- docs/model_card.md +0 -0
- modal_refine.py +85 -0
- pyproject.toml +0 -0
- requirements.txt +40 -0
- scripts/audit_pipeline.py +61 -0
- src/paper2lab/__init__.py +0 -0
- src/paper2lab/evaluation/benchmark.py +0 -0
- src/paper2lab/evaluation/evaluate.py +0 -0
- src/paper2lab/evaluation/metrics.py +0 -0
- src/paper2lab/evaluation/reproducibility.py +313 -0
- src/paper2lab/inference/__init__.py +0 -0
- src/paper2lab/inference/auto_select.py +564 -0
- src/paper2lab/inference/gradio_pipeline.py +262 -0
- src/paper2lab/inference/lab_starter_kit.py +325 -0
- src/paper2lab/inference/nemotron_refiner.py +579 -0
- src/paper2lab/inference/paper_card.py +766 -0
- src/paper2lab/inference/pipeline.py +202 -0
- src/paper2lab/inference/refinement.py +70 -0
- src/paper2lab/inference/roadmap.py +436 -0
- src/paper2lab/inference/visual_explainer.py +124 -0
- src/paper2lab/prompts/extraction.txt +0 -0
- src/paper2lab/prompts/reproduction.txt +0 -0
- src/paper2lab/prompts/summary.txt +0 -0
- src/paper2lab/rag/__init__.py +0 -0
- src/paper2lab/rag/indexer.py +391 -0
- src/paper2lab/rag/qa.py +594 -0
- src/paper2lab/utils/io.py +0 -0
- src/paper2lab/utils/logging.py +0 -0
- src/paper2lab/utils/seed.py +0 -0
- test_nemotron.py +33 -0
README.md
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: Paper2Lab
|
| 3 |
+
emoji: 🧪
|
| 4 |
+
colorFrom: purple
|
| 5 |
+
colorTo: blue
|
| 6 |
+
sdk: gradio
|
| 7 |
+
app_file: app.py
|
| 8 |
+
---
|
| 9 |
+
|
| 10 |
+
# Paper2Lab
|
| 11 |
+
|
| 12 |
+
Turn research papers into actionable lab plans.
|
| 13 |
+
|
| 14 |
+
Paper2Lab is an AI research assistant that transforms scientific papers into:
|
| 15 |
+
|
| 16 |
+
- Structured paper cards
|
| 17 |
+
- Evidence-grounded summaries
|
| 18 |
+
- Dataset and model extraction
|
| 19 |
+
- Reproducibility assessment
|
| 20 |
+
- Experiment roadmaps
|
| 21 |
+
- Lab starter kits
|
| 22 |
+
- Interactive question answering
|
| 23 |
+
- Exportable JSON and Markdown reports
|
| 24 |
+
|
| 25 |
+
## Features
|
| 26 |
+
|
| 27 |
+
- PDF paper ingestion
|
| 28 |
+
- Structured paper understanding
|
| 29 |
+
- Evidence grounding
|
| 30 |
+
- Reproducibility analysis
|
| 31 |
+
- Lab readiness assessment
|
| 32 |
+
- Ask-the-paper interface
|
| 33 |
+
- Export to JSON and Markdown
|
| 34 |
+
|
| 35 |
+
## Usage
|
| 36 |
+
|
| 37 |
+
1. Upload a PDF paper
|
| 38 |
+
2. Select refinement mode
|
| 39 |
+
3. Click **Analyze Paper**
|
| 40 |
+
4. Explore:
|
| 41 |
+
- Paper Summary
|
| 42 |
+
- Evidence Viewer
|
| 43 |
+
- Ask the Paper
|
| 44 |
+
- Advanced Analysis
|
| 45 |
+
- Lab Starter Kit
|
| 46 |
+
- Export
|
| 47 |
+
|
| 48 |
+
Built for researchers, students, engineers, and hackathon teams.
|
README_HF.md
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: Paper2Lab
|
| 3 |
+
emoji: 🧪
|
| 4 |
+
colorFrom: purple
|
| 5 |
+
colorTo: blue
|
| 6 |
+
sdk: gradio
|
| 7 |
+
app_file: app.py
|
| 8 |
+
---
|
| 9 |
+
|
| 10 |
+
# Paper2Lab
|
| 11 |
+
|
| 12 |
+
Turn research papers into actionable lab plans.
|
| 13 |
+
|
| 14 |
+
Paper2Lab is an AI research assistant that transforms scientific papers into:
|
| 15 |
+
|
| 16 |
+
- Structured paper cards
|
| 17 |
+
- Evidence-grounded summaries
|
| 18 |
+
- Dataset and model extraction
|
| 19 |
+
- Reproducibility assessment
|
| 20 |
+
- Experiment roadmaps
|
| 21 |
+
- Lab starter kits
|
| 22 |
+
- Interactive question answering
|
| 23 |
+
- Exportable JSON and Markdown reports
|
| 24 |
+
|
| 25 |
+
## Features
|
| 26 |
+
|
| 27 |
+
- PDF paper ingestion
|
| 28 |
+
- Structured paper understanding
|
| 29 |
+
- Evidence grounding
|
| 30 |
+
- Reproducibility analysis
|
| 31 |
+
- Lab readiness assessment
|
| 32 |
+
- Ask-the-paper interface
|
| 33 |
+
- Export to JSON and Markdown
|
| 34 |
+
|
| 35 |
+
## Usage
|
| 36 |
+
|
| 37 |
+
1. Upload a PDF paper
|
| 38 |
+
2. Select refinement mode
|
| 39 |
+
3. Click **Analyze Paper**
|
| 40 |
+
4. Explore:
|
| 41 |
+
- Paper Summary
|
| 42 |
+
- Evidence Viewer
|
| 43 |
+
- Ask the Paper
|
| 44 |
+
- Advanced Analysis
|
| 45 |
+
- Lab Starter Kit
|
| 46 |
+
- Export
|
| 47 |
+
|
| 48 |
+
Built for researchers, students, engineers, and hackathon teams.
|
app.py
ADDED
|
@@ -0,0 +1,1680 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
import tempfile
|
| 3 |
+
import html
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
|
| 6 |
+
import gradio as gr
|
| 7 |
+
|
| 8 |
+
try:
|
| 9 |
+
from paper2lab.inference.pipeline import PaperPipeline
|
| 10 |
+
except Exception:
|
| 11 |
+
PaperPipeline = None
|
| 12 |
+
|
| 13 |
+
try:
|
| 14 |
+
from paper2lab.rag.qa import answer_from_pipeline_result
|
| 15 |
+
except Exception:
|
| 16 |
+
answer_from_pipeline_result = None
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
APP_NAME = "Paper2Lab"
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def pretty_json(data):
|
| 23 |
+
return json.dumps(data or {}, indent=2, ensure_ascii=False)
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def as_list(value):
|
| 27 |
+
if not value:
|
| 28 |
+
return []
|
| 29 |
+
if isinstance(value, list):
|
| 30 |
+
return value
|
| 31 |
+
return [value]
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def bullet_list(items, empty="_Not found._"):
|
| 35 |
+
items = as_list(items)
|
| 36 |
+
if not items:
|
| 37 |
+
return empty
|
| 38 |
+
return "\n".join(f"- {x}" for x in items)
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def clean_display_text(text, max_len=95):
|
| 42 |
+
text = str(text or "").replace("\n", " ").strip()
|
| 43 |
+
while " " in text:
|
| 44 |
+
text = text.replace(" ", " ")
|
| 45 |
+
if len(text) > max_len:
|
| 46 |
+
return text[:max_len].rstrip() + "..."
|
| 47 |
+
return text
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def chip_list(items, empty="Not found"):
|
| 51 |
+
items = as_list(items)
|
| 52 |
+
if not items:
|
| 53 |
+
return f"<span class='muted'>{empty}</span>"
|
| 54 |
+
|
| 55 |
+
html = ""
|
| 56 |
+
for x in items[:6]:
|
| 57 |
+
clean = clean_display_text(x, max_len=70)
|
| 58 |
+
html += f"<span class='chip' title='{str(x)}'>{clean}</span>"
|
| 59 |
+
return html
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def get_card(result):
|
| 63 |
+
return result.get("paper_card_final") or result.get("paper_card") or result
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def quality_warnings(card):
|
| 67 |
+
warnings = []
|
| 68 |
+
|
| 69 |
+
rq = card.get("research_question", "")
|
| 70 |
+
if rq and ("BERTis" in rq or len(rq.split()) < 5):
|
| 71 |
+
warnings.append("Research question may contain PDF spacing noise.")
|
| 72 |
+
|
| 73 |
+
datasets = as_list(card.get("datasets_or_data_sources"))
|
| 74 |
+
if any(len(str(x)) > 220 for x in datasets):
|
| 75 |
+
warnings.append("Dataset extraction contains long noisy candidates.")
|
| 76 |
+
|
| 77 |
+
for field in ["contributions", "key_findings", "limitations"]:
|
| 78 |
+
if not card.get(field):
|
| 79 |
+
warnings.append(f"{field} is empty.")
|
| 80 |
+
|
| 81 |
+
return warnings
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def build_quick_summary_html(result):
|
| 85 |
+
card = get_card(result)
|
| 86 |
+
warnings = quality_warnings(card)
|
| 87 |
+
|
| 88 |
+
status = "Ready"
|
| 89 |
+
if result:
|
| 90 |
+
status = f"{max(0, 6 - len(warnings))} / 6 fields complete"
|
| 91 |
+
|
| 92 |
+
return f"""
|
| 93 |
+
<div class="summary-card">
|
| 94 |
+
<div class="card-head">
|
| 95 |
+
<div><span class="icon">📄</span><b>Quick summary</b></div>
|
| 96 |
+
<span class="status-pill">{status}</span>
|
| 97 |
+
</div>
|
| 98 |
+
|
| 99 |
+
<div class="summary-row">
|
| 100 |
+
<div class="label">Title</div>
|
| 101 |
+
<div class="value">{card.get("title", "Untitled paper")}</div>
|
| 102 |
+
</div>
|
| 103 |
+
|
| 104 |
+
<div class="summary-row">
|
| 105 |
+
<div class="label">Research question</div>
|
| 106 |
+
<div class="value">{card.get("research_question", "Not found")}</div>
|
| 107 |
+
</div>
|
| 108 |
+
|
| 109 |
+
<div class="summary-row">
|
| 110 |
+
<div class="label">Datasets</div>
|
| 111 |
+
<div class="value">{chip_list(card.get("datasets_or_data_sources"))}</div>
|
| 112 |
+
</div>
|
| 113 |
+
|
| 114 |
+
<div class="summary-row">
|
| 115 |
+
<div class="label">Models</div>
|
| 116 |
+
<div class="value">{chip_list(card.get("models_or_methods"))}</div>
|
| 117 |
+
</div>
|
| 118 |
+
|
| 119 |
+
<div class="summary-row">
|
| 120 |
+
<div class="label">Key findings</div>
|
| 121 |
+
<div class="value highlight">{bullet_list(card.get("key_findings")).replace("- ", "• ")}</div>
|
| 122 |
+
</div>
|
| 123 |
+
</div>
|
| 124 |
+
"""
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
def build_paper_summary_md(result):
|
| 128 |
+
card = get_card(result)
|
| 129 |
+
warnings = quality_warnings(card)
|
| 130 |
+
|
| 131 |
+
warning_md = ""
|
| 132 |
+
if warnings:
|
| 133 |
+
warning_md = "## ⚠️ Extraction Quality Warnings\n" + "\n".join(f"- {w}" for w in warnings)
|
| 134 |
+
|
| 135 |
+
return f"""
|
| 136 |
+
# Structured Paper Summary
|
| 137 |
+
|
| 138 |
+
**Title:** {card.get("title", "Untitled paper")}
|
| 139 |
+
**Field:** {card.get("field", "Unknown")}
|
| 140 |
+
|
| 141 |
+
{warning_md}
|
| 142 |
+
|
| 143 |
+
## Research Question
|
| 144 |
+
{card.get("research_question", "_Not found._")}
|
| 145 |
+
|
| 146 |
+
## Contributions
|
| 147 |
+
{bullet_list(card.get("contributions"))}
|
| 148 |
+
|
| 149 |
+
## Methodology
|
| 150 |
+
{bullet_list(card.get("methodology"))}
|
| 151 |
+
|
| 152 |
+
## Datasets / Data Sources
|
| 153 |
+
{bullet_list(card.get("datasets_or_data_sources"))}
|
| 154 |
+
|
| 155 |
+
## Models / Methods
|
| 156 |
+
{bullet_list(card.get("models_or_methods"))}
|
| 157 |
+
|
| 158 |
+
## Metrics / Measurements
|
| 159 |
+
{bullet_list(card.get("metrics_or_measurements"))}
|
| 160 |
+
|
| 161 |
+
## Key Findings
|
| 162 |
+
{bullet_list(card.get("key_findings"))}
|
| 163 |
+
|
| 164 |
+
## Limitations
|
| 165 |
+
{bullet_list(card.get("limitations"))}
|
| 166 |
+
"""
|
| 167 |
+
|
| 168 |
+
|
| 169 |
+
def build_lab_md(result):
|
| 170 |
+
card = get_card(result)
|
| 171 |
+
kit = card.get("lab_starter_kit") or result.get("lab_starter_kit") or {}
|
| 172 |
+
|
| 173 |
+
return f"""
|
| 174 |
+
# Lab Starter Kit
|
| 175 |
+
|
| 176 |
+
**Starter type:** `{kit.get("starter_type", card.get("paper_type", "unknown"))}`
|
| 177 |
+
|
| 178 |
+
## Project Structure
|
| 179 |
+
{bullet_list(kit.get("project_structure"))}
|
| 180 |
+
|
| 181 |
+
## Requirements
|
| 182 |
+
{bullet_list(kit.get("requirements_txt"))}
|
| 183 |
+
|
| 184 |
+
## Dataset Plan
|
| 185 |
+
{bullet_list(kit.get("dataset_plan") or kit.get("required_data"))}
|
| 186 |
+
|
| 187 |
+
## Suggested Experiments
|
| 188 |
+
{bullet_list(kit.get("suggested_experiments") or kit.get("experiment_checklist"))}
|
| 189 |
+
|
| 190 |
+
## Evaluation Plan
|
| 191 |
+
{bullet_list(kit.get("evaluation_plan"))}
|
| 192 |
+
|
| 193 |
+
## Reproducibility Risks
|
| 194 |
+
{bullet_list(kit.get("reproducibility_risks") or card.get("missing_reproducibility_info"))}
|
| 195 |
+
"""
|
| 196 |
+
|
| 197 |
+
|
| 198 |
+
def build_evidence_md(result):
|
| 199 |
+
figs = result.get("figures_and_tables", [])
|
| 200 |
+
card = get_card(result)
|
| 201 |
+
|
| 202 |
+
lines = ["# Evidence Viewer"]
|
| 203 |
+
|
| 204 |
+
if figs:
|
| 205 |
+
lines.append("## Figures and Tables")
|
| 206 |
+
for item in figs[:12]:
|
| 207 |
+
lines.append(
|
| 208 |
+
f"- **{item.get('label', 'Item')}** — page {item.get('page_number', '?')}: "
|
| 209 |
+
f"{item.get('summary', 'No summary')}"
|
| 210 |
+
)
|
| 211 |
+
|
| 212 |
+
lines.append("\n## Extracted Evidence Fields")
|
| 213 |
+
for field in [
|
| 214 |
+
"methodology",
|
| 215 |
+
"datasets_or_data_sources",
|
| 216 |
+
"models_or_methods",
|
| 217 |
+
"metrics_or_measurements",
|
| 218 |
+
"key_findings",
|
| 219 |
+
]:
|
| 220 |
+
lines.append(f"\n### {field}")
|
| 221 |
+
lines.append(bullet_list(card.get(field)))
|
| 222 |
+
|
| 223 |
+
return "\n".join(lines)
|
| 224 |
+
|
| 225 |
+
|
| 226 |
+
def build_advanced_md(result):
|
| 227 |
+
card = get_card(result)
|
| 228 |
+
|
| 229 |
+
metadata = card.get("metadata", {}) if isinstance(card, dict) else {}
|
| 230 |
+
if not isinstance(metadata, dict):
|
| 231 |
+
metadata = {}
|
| 232 |
+
|
| 233 |
+
quality = metadata.get("quality", {})
|
| 234 |
+
if not isinstance(quality, dict):
|
| 235 |
+
quality = {}
|
| 236 |
+
|
| 237 |
+
repro = card.get("reproducibility_score", {}) if isinstance(card, dict) else {}
|
| 238 |
+
if not isinstance(repro, dict):
|
| 239 |
+
repro = {}
|
| 240 |
+
|
| 241 |
+
selection = result.get("auto_selection", {}).get("selection_report", {})
|
| 242 |
+
if not isinstance(selection, dict):
|
| 243 |
+
selection = {}
|
| 244 |
+
|
| 245 |
+
md = []
|
| 246 |
+
md.append("# ⚙️ Advanced Analysis")
|
| 247 |
+
md.append("")
|
| 248 |
+
md.append("## Extraction Quality")
|
| 249 |
+
md.append("")
|
| 250 |
+
md.append("| Metric | Value |")
|
| 251 |
+
md.append("|---|---|")
|
| 252 |
+
md.append(f"| Quality Score | {quality.get('quality_score', 'N/A')} |")
|
| 253 |
+
md.append(f"| Sections Found | {quality.get('num_sections', 'N/A')} |")
|
| 254 |
+
md.append(f"| References Found | {quality.get('num_references', metadata.get('references_count', 'N/A'))} |")
|
| 255 |
+
md.append(f"| Tables Found | {quality.get('num_tables', 'N/A')} |")
|
| 256 |
+
md.append(f"| Captions Found | {quality.get('num_captions', 'N/A')} |")
|
| 257 |
+
md.append("")
|
| 258 |
+
md.append("---")
|
| 259 |
+
md.append("")
|
| 260 |
+
md.append(f"**Reproducibility Level:** {repro.get('level', 'unknown')}")
|
| 261 |
+
md.append("")
|
| 262 |
+
md.append(f"**Score:** {repro.get('score', 'N/A')}")
|
| 263 |
+
md.append("")
|
| 264 |
+
md.append("### Detected Items")
|
| 265 |
+
md.append(bullet_list(repro.get("detected_items")))
|
| 266 |
+
md.append("")
|
| 267 |
+
md.append("### Missing Items")
|
| 268 |
+
md.append(bullet_list(repro.get("missing_items")))
|
| 269 |
+
md.append("")
|
| 270 |
+
md.append("---")
|
| 271 |
+
md.append("")
|
| 272 |
+
md.append("## Selection Report")
|
| 273 |
+
|
| 274 |
+
if selection.get("fields"):
|
| 275 |
+
md.append("Field-level local vs Nemotron selection was completed.")
|
| 276 |
+
md.append("")
|
| 277 |
+
md.append("```json")
|
| 278 |
+
md.append(pretty_json(selection))
|
| 279 |
+
md.append("```")
|
| 280 |
+
else:
|
| 281 |
+
md.append(
|
| 282 |
+
"No field-level comparison available for this paper. "
|
| 283 |
+
"The final card was generated from the best available extraction pipeline."
|
| 284 |
+
)
|
| 285 |
+
|
| 286 |
+
return "\n".join(md)
|
| 287 |
+
|
| 288 |
+
|
| 289 |
+
def safe_html(value):
|
| 290 |
+
return html.escape(str(value or ""), quote=True)
|
| 291 |
+
|
| 292 |
+
|
| 293 |
+
def short_card_text(value, max_len=48):
|
| 294 |
+
text = clean_display_text(value, max_len=max_len)
|
| 295 |
+
return safe_html(text)
|
| 296 |
+
|
| 297 |
+
|
| 298 |
+
def build_lab_cards_html(result):
|
| 299 |
+
card = get_card(result)
|
| 300 |
+
kit = card.get("lab_starter_kit") or result.get("lab_starter_kit") or {}
|
| 301 |
+
|
| 302 |
+
reqs = as_list(kit.get("requirements_txt"))
|
| 303 |
+
risks = as_list(
|
| 304 |
+
kit.get("reproducibility_risks")
|
| 305 |
+
or card.get("missing_reproducibility_info")
|
| 306 |
+
)
|
| 307 |
+
experiments = as_list(
|
| 308 |
+
kit.get("suggested_experiments")
|
| 309 |
+
or kit.get("experiment_checklist")
|
| 310 |
+
)
|
| 311 |
+
|
| 312 |
+
starter_type = kit.get("starter_type", card.get("paper_type", "unknown"))
|
| 313 |
+
tools = ", ".join(clean_display_text(x, max_len=28) for x in reqs[:3]) if reqs else "Not found"
|
| 314 |
+
|
| 315 |
+
return f"""
|
| 316 |
+
<div class="lab-board">
|
| 317 |
+
<div class="lab-board-header">
|
| 318 |
+
<span class="lab-emoji">🧪</span>
|
| 319 |
+
<span>Lab readiness</span>
|
| 320 |
+
</div>
|
| 321 |
+
|
| 322 |
+
<div class="lab-grid-2">
|
| 323 |
+
<div class="lab-tile lab-green">
|
| 324 |
+
<span>Starter type</span>
|
| 325 |
+
<b>{short_card_text(starter_type)}</b>
|
| 326 |
+
</div>
|
| 327 |
+
|
| 328 |
+
<div class="lab-tile lab-dark">
|
| 329 |
+
<span>Tools</span>
|
| 330 |
+
<b>{short_card_text(tools, max_len=58)}</b>
|
| 331 |
+
</div>
|
| 332 |
+
|
| 333 |
+
<div class="lab-tile lab-purple">
|
| 334 |
+
<span>Experiments</span>
|
| 335 |
+
<b>{len(experiments)} detected</b>
|
| 336 |
+
</div>
|
| 337 |
+
|
| 338 |
+
<div class="lab-tile lab-red">
|
| 339 |
+
<span>Risks</span>
|
| 340 |
+
<b>{len(risks)} issues</b>
|
| 341 |
+
</div>
|
| 342 |
+
</div>
|
| 343 |
+
</div>
|
| 344 |
+
"""
|
| 345 |
+
|
| 346 |
+
def analyze_paper(pdf_file, refinement_mode):
|
| 347 |
+
if pdf_file is None:
|
| 348 |
+
raise gr.Error("Please upload a PDF first.")
|
| 349 |
+
|
| 350 |
+
if PaperPipeline is None:
|
| 351 |
+
raise gr.Error("PaperPipeline import failed. Run from the project root or install your package.")
|
| 352 |
+
|
| 353 |
+
pdf_path = pdf_file if isinstance(pdf_file, str) else pdf_file.name
|
| 354 |
+
|
| 355 |
+
pipeline = PaperPipeline(refinement_mode=refinement_mode)
|
| 356 |
+
result = pipeline.run(pdf_path)
|
| 357 |
+
print("PDF PATH:", pdf_path)
|
| 358 |
+
print("MODE:", refinement_mode)
|
| 359 |
+
print("FINAL DATASETS:", result.get("paper_card_final", {}).get("datasets_or_data_sources"))
|
| 360 |
+
print("GET_CARD DATASETS:", get_card(result).get("datasets_or_data_sources"))
|
| 361 |
+
|
| 362 |
+
card = get_card(result)
|
| 363 |
+
|
| 364 |
+
if not card.get("datasets_or_data_sources"):
|
| 365 |
+
roadmap = card.get("reproduction_roadmap") or {}
|
| 366 |
+
kit = card.get("lab_starter_kit") or {}
|
| 367 |
+
|
| 368 |
+
fallback_datasets = []
|
| 369 |
+
|
| 370 |
+
if isinstance(roadmap, dict):
|
| 371 |
+
fallback_datasets += roadmap.get("datasets") or []
|
| 372 |
+
|
| 373 |
+
if isinstance(kit, dict):
|
| 374 |
+
fallback_datasets += kit.get("dataset_plan") or []
|
| 375 |
+
|
| 376 |
+
if fallback_datasets:
|
| 377 |
+
card["datasets_or_data_sources"] = list(dict.fromkeys(fallback_datasets))
|
| 378 |
+
result["paper_card_final"] = card
|
| 379 |
+
|
| 380 |
+
paper_md = build_paper_summary_md(result)
|
| 381 |
+
lab_md = build_lab_md(result)
|
| 382 |
+
evidence_md = build_evidence_md(result)
|
| 383 |
+
advanced_md = build_advanced_md(result)
|
| 384 |
+
quick_html = build_quick_summary_html(result)
|
| 385 |
+
lab_cards = build_lab_cards_html(result)
|
| 386 |
+
|
| 387 |
+
tmp = Path(tempfile.mkdtemp())
|
| 388 |
+
json_path = tmp / "paper2lab_output.json"
|
| 389 |
+
md_path = tmp / "paper2lab_report.md"
|
| 390 |
+
|
| 391 |
+
json_path.write_text(pretty_json(result), encoding="utf-8")
|
| 392 |
+
md_path.write_text(paper_md + "\n\n---\n\n" + lab_md, encoding="utf-8")
|
| 393 |
+
|
| 394 |
+
return (
|
| 395 |
+
result,
|
| 396 |
+
quick_html,
|
| 397 |
+
paper_md,
|
| 398 |
+
lab_md,
|
| 399 |
+
evidence_md,
|
| 400 |
+
advanced_md,
|
| 401 |
+
lab_cards,
|
| 402 |
+
card,
|
| 403 |
+
str(json_path),
|
| 404 |
+
str(md_path),
|
| 405 |
+
)
|
| 406 |
+
|
| 407 |
+
def ask_paper_question(result, question):
|
| 408 |
+
if not result:
|
| 409 |
+
return "⚠️ Analyze a paper first.", {}
|
| 410 |
+
|
| 411 |
+
if not question or not str(question).strip():
|
| 412 |
+
return "⚠️ Please enter a question.", {}
|
| 413 |
+
|
| 414 |
+
question_text = str(question).strip()
|
| 415 |
+
q = question_text.lower()
|
| 416 |
+
|
| 417 |
+
card = get_card(result)
|
| 418 |
+
|
| 419 |
+
def md_items(title, items):
|
| 420 |
+
items = as_list(items)
|
| 421 |
+
items = [x for x in items if str(x).strip()]
|
| 422 |
+
if not items:
|
| 423 |
+
return f"**{title}:**\n\n_Not found in the structured paper card._"
|
| 424 |
+
|
| 425 |
+
return f"**{title}:**\n\n" + "\n".join(f"- {x}" for x in items[:8])
|
| 426 |
+
|
| 427 |
+
# Fast structured answers for demo-critical questions
|
| 428 |
+
if any(k in q for k in ["dataset", "data source", "corpus", "benchmark"]):
|
| 429 |
+
return md_items("Datasets / data sources", card.get("datasets_or_data_sources")), {
|
| 430 |
+
"source": "structured_card",
|
| 431 |
+
"field": "datasets_or_data_sources",
|
| 432 |
+
}
|
| 433 |
+
|
| 434 |
+
if any(k in q for k in ["model", "architecture", "proposed"]):
|
| 435 |
+
|
| 436 |
+
rq = card.get("research_question", "")
|
| 437 |
+
|
| 438 |
+
if rq:
|
| 439 |
+
return (
|
| 440 |
+
f"**Proposed model:**\n\n- {rq}",
|
| 441 |
+
{
|
| 442 |
+
"source": "structured_card",
|
| 443 |
+
"field": "research_question",
|
| 444 |
+
},
|
| 445 |
+
)
|
| 446 |
+
|
| 447 |
+
if any(k in q for k in ["finding", "result", "conclusion", "contribution"]):
|
| 448 |
+
return md_items("Key findings", card.get("key_findings") or card.get("contributions")), {
|
| 449 |
+
"source": "structured_card",
|
| 450 |
+
"field": "key_findings",
|
| 451 |
+
}
|
| 452 |
+
|
| 453 |
+
if any(k in q for k in ["metric", "score", "performance", "evaluation"]):
|
| 454 |
+
return md_items("Metrics / measurements", card.get("metrics_or_measurements")), {
|
| 455 |
+
"source": "structured_card",
|
| 456 |
+
"field": "metrics_or_measurements",
|
| 457 |
+
}
|
| 458 |
+
|
| 459 |
+
if any(k in q for k in ["limitation", "risk", "missing"]):
|
| 460 |
+
return md_items("Limitations / reproducibility risks", card.get("limitations")), {
|
| 461 |
+
"source": "structured_card",
|
| 462 |
+
"field": "limitations",
|
| 463 |
+
}
|
| 464 |
+
|
| 465 |
+
if any(k in q for k in ["reproduce", "reproduction", "roadmap", "steps"]):
|
| 466 |
+
roadmap = card.get("reproduction_roadmap") or {}
|
| 467 |
+
steps = []
|
| 468 |
+
if isinstance(roadmap, dict):
|
| 469 |
+
steps = roadmap.get("experimental_steps") or roadmap.get("missing_for_reproduction") or []
|
| 470 |
+
return md_items("Reproduction roadmap", steps), {
|
| 471 |
+
"source": "structured_card",
|
| 472 |
+
"field": "reproduction_roadmap",
|
| 473 |
+
}
|
| 474 |
+
|
| 475 |
+
if answer_from_pipeline_result is None:
|
| 476 |
+
return (
|
| 477 |
+
"⚠️ RAG module import failed. Check that `paper2lab.rag.qa` is available "
|
| 478 |
+
"and dependencies are installed.",
|
| 479 |
+
{},
|
| 480 |
+
)
|
| 481 |
+
|
| 482 |
+
try:
|
| 483 |
+
qa = answer_from_pipeline_result(
|
| 484 |
+
pipeline_result=result,
|
| 485 |
+
question=question_text,
|
| 486 |
+
top_k=5,
|
| 487 |
+
embedder_backend="local",
|
| 488 |
+
)
|
| 489 |
+
|
| 490 |
+
answer = qa.get("answer", "No answer found.")
|
| 491 |
+
evidence = qa.get("evidence", [])
|
| 492 |
+
|
| 493 |
+
evidence_md = "\n\n## Evidence\n"
|
| 494 |
+
for i, ev in enumerate(evidence, 1):
|
| 495 |
+
page = ev.get("page_start") or ev.get("page_number") or "?"
|
| 496 |
+
title = ev.get("title", "Evidence")
|
| 497 |
+
text = ev.get("text", "")
|
| 498 |
+
evidence_md += f"\n**{i}. {title} — page {page}**\n\n> {text[:700]}\n"
|
| 499 |
+
|
| 500 |
+
return answer + evidence_md, qa
|
| 501 |
+
|
| 502 |
+
except Exception as exc:
|
| 503 |
+
return f"❌ RAG error: {exc}", {}
|
| 504 |
+
|
| 505 |
+
|
| 506 |
+
CSS = """
|
| 507 |
+
/* ─────────────────────────────────────────────
|
| 508 |
+
Paper2Lab UI — fixed layout
|
| 509 |
+
Fixes:
|
| 510 |
+
- no duplicate upload holder/native file input
|
| 511 |
+
- wider hero with Paper2Lab title
|
| 512 |
+
- clean 2-column app layout
|
| 513 |
+
- lab card placed under Quick Summary
|
| 514 |
+
───────────────────────────────────────────── */
|
| 515 |
+
|
| 516 |
+
body,
|
| 517 |
+
.gradio-container {
|
| 518 |
+
background: #eef2f7 !important;
|
| 519 |
+
color: #111827 !important;
|
| 520 |
+
}
|
| 521 |
+
|
| 522 |
+
* {
|
| 523 |
+
box-sizing: border-box;
|
| 524 |
+
}
|
| 525 |
+
|
| 526 |
+
footer {
|
| 527 |
+
display: none !important;
|
| 528 |
+
}
|
| 529 |
+
|
| 530 |
+
.gradio-container {
|
| 531 |
+
max-width: none !important;
|
| 532 |
+
padding-top: 28px !important;
|
| 533 |
+
}
|
| 534 |
+
|
| 535 |
+
/* Main page width */
|
| 536 |
+
.app-shell {
|
| 537 |
+
width: min(1220px, 94vw);
|
| 538 |
+
margin: 0 auto;
|
| 539 |
+
}
|
| 540 |
+
|
| 541 |
+
/* Remove unwanted Gradio block styling only inside our custom layout */
|
| 542 |
+
.top-layout .block,
|
| 543 |
+
.top-layout .gr-group,
|
| 544 |
+
.top-layout .gr-box,
|
| 545 |
+
.top-layout .gr-panel,
|
| 546 |
+
.result-stack .block,
|
| 547 |
+
.result-stack .gr-group,
|
| 548 |
+
.result-stack .gr-box,
|
| 549 |
+
.result-stack .gr-panel {
|
| 550 |
+
background: transparent !important;
|
| 551 |
+
border: none !important;
|
| 552 |
+
box-shadow: none !important;
|
| 553 |
+
padding: 0 !important;
|
| 554 |
+
margin: 0 !important;
|
| 555 |
+
overflow: visible !important;
|
| 556 |
+
}
|
| 557 |
+
|
| 558 |
+
/* ───────────────── HERO ───────────────── */
|
| 559 |
+
|
| 560 |
+
.hero {
|
| 561 |
+
position: relative;
|
| 562 |
+
overflow: hidden;
|
| 563 |
+
min-height: 245px;
|
| 564 |
+
border-radius: 24px;
|
| 565 |
+
padding: 34px 48px;
|
| 566 |
+
margin: 0 auto 28px;
|
| 567 |
+
background: linear-gradient(135deg, #0f172a 0%, #18253f 48%, #252659 100%);
|
| 568 |
+
border: 1px solid rgba(255,255,255,.08);
|
| 569 |
+
box-shadow: 0 22px 60px rgba(15,23,42,.18);
|
| 570 |
+
}
|
| 571 |
+
|
| 572 |
+
.hero::before {
|
| 573 |
+
content: "";
|
| 574 |
+
position: absolute;
|
| 575 |
+
inset: 0;
|
| 576 |
+
background:
|
| 577 |
+
radial-gradient(circle at 15% 25%, rgba(34,211,238,.14), transparent 30%),
|
| 578 |
+
radial-gradient(circle at 78% 26%, rgba(124,58,237,.22), transparent 35%);
|
| 579 |
+
pointer-events: none;
|
| 580 |
+
}
|
| 581 |
+
|
| 582 |
+
.hero-content {
|
| 583 |
+
position: relative;
|
| 584 |
+
z-index: 2;
|
| 585 |
+
max-width: 920px;
|
| 586 |
+
}
|
| 587 |
+
|
| 588 |
+
.logo-row {
|
| 589 |
+
display: flex;
|
| 590 |
+
align-items: center;
|
| 591 |
+
gap: 10px;
|
| 592 |
+
margin-bottom: 14px;
|
| 593 |
+
}
|
| 594 |
+
|
| 595 |
+
.logo-mark {
|
| 596 |
+
width: 32px;
|
| 597 |
+
height: 32px;
|
| 598 |
+
display: inline-grid;
|
| 599 |
+
place-items: center;
|
| 600 |
+
border-radius: 10px;
|
| 601 |
+
background: rgba(103,232,249,.14);
|
| 602 |
+
border: 1px solid rgba(103,232,249,.22);
|
| 603 |
+
font-size: 18px;
|
| 604 |
+
}
|
| 605 |
+
|
| 606 |
+
.logo-text {
|
| 607 |
+
color: #ffffff !important;
|
| 608 |
+
font-size: 18px;
|
| 609 |
+
font-weight: 950;
|
| 610 |
+
letter-spacing: -0.035em;
|
| 611 |
+
}
|
| 612 |
+
|
| 613 |
+
.kicker {
|
| 614 |
+
color: #67e8f9 !important;
|
| 615 |
+
text-transform: uppercase;
|
| 616 |
+
letter-spacing: .16em;
|
| 617 |
+
font-size: 11px;
|
| 618 |
+
font-weight: 900;
|
| 619 |
+
margin-bottom: 8px;
|
| 620 |
+
}
|
| 621 |
+
|
| 622 |
+
.hero h1 {
|
| 623 |
+
max-width: 960px;
|
| 624 |
+
color: #ffffff !important;
|
| 625 |
+
font-size: 38px;
|
| 626 |
+
line-height: 1.05;
|
| 627 |
+
letter-spacing: -0.052em;
|
| 628 |
+
margin: 0 0 14px;
|
| 629 |
+
}
|
| 630 |
+
|
| 631 |
+
.hero h1 span {
|
| 632 |
+
color: #a78bfa !important;
|
| 633 |
+
}
|
| 634 |
+
|
| 635 |
+
.hero p {
|
| 636 |
+
max-width: 760px;
|
| 637 |
+
color: rgba(255,255,255,.86) !important;
|
| 638 |
+
font-size: 15px;
|
| 639 |
+
line-height: 1.62;
|
| 640 |
+
margin: 0 0 22px;
|
| 641 |
+
}
|
| 642 |
+
|
| 643 |
+
.hero-badges {
|
| 644 |
+
display: flex;
|
| 645 |
+
flex-wrap: wrap;
|
| 646 |
+
gap: 10px;
|
| 647 |
+
}
|
| 648 |
+
|
| 649 |
+
.hero-badge {
|
| 650 |
+
padding: 8px 13px;
|
| 651 |
+
border-radius: 10px;
|
| 652 |
+
background: rgba(255,255,255,.12);
|
| 653 |
+
color: #ffffff !important;
|
| 654 |
+
font-weight: 850;
|
| 655 |
+
font-size: 12px;
|
| 656 |
+
border: 1px solid rgba(255,255,255,.18);
|
| 657 |
+
}
|
| 658 |
+
|
| 659 |
+
/* ───────────────── TWO COLUMN LAYOUT ───────────────── */
|
| 660 |
+
|
| 661 |
+
.top-layout {
|
| 662 |
+
display: grid !important;
|
| 663 |
+
grid-template-columns: 340px minmax(0, 1fr) !important;
|
| 664 |
+
gap: 28px !important;
|
| 665 |
+
align-items: start !important;
|
| 666 |
+
}
|
| 667 |
+
|
| 668 |
+
.control-stack {
|
| 669 |
+
background: #ffffff !important;
|
| 670 |
+
border: 1px solid #e2e8f0 !important;
|
| 671 |
+
border-radius: 24px !important;
|
| 672 |
+
padding: 24px !important;
|
| 673 |
+
box-shadow: 0 14px 38px rgba(15,23,42,.06) !important;
|
| 674 |
+
display: flex !important;
|
| 675 |
+
flex-direction: column !important;
|
| 676 |
+
gap: 18px !important;
|
| 677 |
+
}
|
| 678 |
+
|
| 679 |
+
.result-stack {
|
| 680 |
+
display: flex !important;
|
| 681 |
+
flex-direction: column !important;
|
| 682 |
+
gap: 26px !important;
|
| 683 |
+
background: transparent !important;
|
| 684 |
+
border: none !important;
|
| 685 |
+
padding: 0 !important;
|
| 686 |
+
}
|
| 687 |
+
|
| 688 |
+
/* ───────────────── LEFT PANEL ───────────────── */
|
| 689 |
+
|
| 690 |
+
.panel-title {
|
| 691 |
+
font-size: 16px;
|
| 692 |
+
font-weight: 900;
|
| 693 |
+
color: #111827 !important;
|
| 694 |
+
margin: 0;
|
| 695 |
+
padding-bottom: 10px;
|
| 696 |
+
border-bottom: 1px solid #f1f5f9;
|
| 697 |
+
}
|
| 698 |
+
|
| 699 |
+
/* Button */
|
| 700 |
+
.gradio-container button.primary,
|
| 701 |
+
.gradio-container button[class*="primary"] {
|
| 702 |
+
width: 100% !important;
|
| 703 |
+
min-height: 50px !important;
|
| 704 |
+
border: none !important;
|
| 705 |
+
border-radius: 15px !important;
|
| 706 |
+
background: linear-gradient(135deg, #7c3aed 0%, #4f46e5 100%) !important;
|
| 707 |
+
color: #ffffff !important;
|
| 708 |
+
font-weight: 950 !important;
|
| 709 |
+
font-size: 15px !important;
|
| 710 |
+
box-shadow: 0 12px 26px rgba(124,58,237,.28) !important;
|
| 711 |
+
}
|
| 712 |
+
|
| 713 |
+
.gradio-container button.primary *,
|
| 714 |
+
.gradio-container button[class*="primary"] * {
|
| 715 |
+
color: #ffffff !important;
|
| 716 |
+
}
|
| 717 |
+
|
| 718 |
+
.tip-box {
|
| 719 |
+
padding: 12px 14px;
|
| 720 |
+
border-radius: 14px;
|
| 721 |
+
background: #fafbff;
|
| 722 |
+
border: 1px solid #e5e7eb;
|
| 723 |
+
color: #4b5563 !important;
|
| 724 |
+
font-size: 12px;
|
| 725 |
+
font-weight: 750;
|
| 726 |
+
}
|
| 727 |
+
|
| 728 |
+
/* ───────────────── QUICK SUMMARY ───────────────── */
|
| 729 |
+
|
| 730 |
+
.summary-card {
|
| 731 |
+
background: #ffffff !important;
|
| 732 |
+
border: 1px solid #e2e8f0 !important;
|
| 733 |
+
border-radius: 22px !important;
|
| 734 |
+
overflow: hidden !important;
|
| 735 |
+
box-shadow: 0 14px 38px rgba(15,23,42,.06) !important;
|
| 736 |
+
}
|
| 737 |
+
|
| 738 |
+
.card-head {
|
| 739 |
+
display: flex;
|
| 740 |
+
align-items: center;
|
| 741 |
+
justify-content: space-between;
|
| 742 |
+
padding: 17px 22px;
|
| 743 |
+
border-bottom: 1px solid #f1f5f9;
|
| 744 |
+
}
|
| 745 |
+
|
| 746 |
+
.card-head b {
|
| 747 |
+
color: #111827 !important;
|
| 748 |
+
font-size: 16px;
|
| 749 |
+
}
|
| 750 |
+
|
| 751 |
+
.icon {
|
| 752 |
+
display: inline-grid;
|
| 753 |
+
place-items: center;
|
| 754 |
+
width: 28px;
|
| 755 |
+
height: 28px;
|
| 756 |
+
margin-right: 9px;
|
| 757 |
+
border-radius: 9px;
|
| 758 |
+
background: #ede9fe;
|
| 759 |
+
font-size: 15px;
|
| 760 |
+
}
|
| 761 |
+
|
| 762 |
+
.status-pill {
|
| 763 |
+
background: #dcfce7;
|
| 764 |
+
color: #047857 !important;
|
| 765 |
+
padding: 6px 13px;
|
| 766 |
+
border-radius: 999px;
|
| 767 |
+
font-weight: 900;
|
| 768 |
+
font-size: 12px;
|
| 769 |
+
}
|
| 770 |
+
|
| 771 |
+
.summary-row {
|
| 772 |
+
display: grid;
|
| 773 |
+
grid-template-columns: 140px minmax(0, 1fr);
|
| 774 |
+
gap: 16px;
|
| 775 |
+
padding: 15px 22px;
|
| 776 |
+
border-bottom: 1px solid #f8fafc;
|
| 777 |
+
font-size: 14px;
|
| 778 |
+
}
|
| 779 |
+
|
| 780 |
+
.label {
|
| 781 |
+
color: #6b7280 !important;
|
| 782 |
+
font-weight: 850;
|
| 783 |
+
}
|
| 784 |
+
|
| 785 |
+
.value {
|
| 786 |
+
color: #111827 !important;
|
| 787 |
+
line-height: 1.55;
|
| 788 |
+
overflow-wrap: anywhere;
|
| 789 |
+
}
|
| 790 |
+
|
| 791 |
+
.highlight {
|
| 792 |
+
background: #f5f3ff;
|
| 793 |
+
border-left: 3px solid #7c3aed;
|
| 794 |
+
border-radius: 0 10px 10px 0;
|
| 795 |
+
padding: 10px 12px;
|
| 796 |
+
color: #312e81 !important;
|
| 797 |
+
}
|
| 798 |
+
|
| 799 |
+
.chip {
|
| 800 |
+
display: inline-block;
|
| 801 |
+
margin: 3px 5px 3px 0;
|
| 802 |
+
padding: 6px 10px;
|
| 803 |
+
border-radius: 999px;
|
| 804 |
+
background: #ede9fe;
|
| 805 |
+
color: #4c1d95 !important;
|
| 806 |
+
border: 1px solid #c4b5fd;
|
| 807 |
+
font-weight: 800;
|
| 808 |
+
font-size: 12px;
|
| 809 |
+
}
|
| 810 |
+
|
| 811 |
+
.muted {
|
| 812 |
+
color: #9ca3af !important;
|
| 813 |
+
}
|
| 814 |
+
|
| 815 |
+
/* ───────────────── LAB READINESS ───────────────── */
|
| 816 |
+
|
| 817 |
+
.lab-board {
|
| 818 |
+
background: #ffffff !important;
|
| 819 |
+
border: 1px solid #e2e8f0 !important;
|
| 820 |
+
border-radius: 22px !important;
|
| 821 |
+
padding: 18px !important;
|
| 822 |
+
box-shadow: 0 14px 38px rgba(15,23,42,.06) !important;
|
| 823 |
+
}
|
| 824 |
+
|
| 825 |
+
.lab-board-header {
|
| 826 |
+
display: flex;
|
| 827 |
+
align-items: center;
|
| 828 |
+
gap: 12px;
|
| 829 |
+
background: #f8f9fc;
|
| 830 |
+
border: 1px solid #ebebf0;
|
| 831 |
+
border-radius: 14px;
|
| 832 |
+
padding: 14px 18px;
|
| 833 |
+
margin-bottom: 16px;
|
| 834 |
+
color: #0f172a !important;
|
| 835 |
+
font-size: 18px;
|
| 836 |
+
font-weight: 950;
|
| 837 |
+
letter-spacing: -0.03em;
|
| 838 |
+
}
|
| 839 |
+
|
| 840 |
+
.lab-board-header * {
|
| 841 |
+
color: #0f172a !important;
|
| 842 |
+
}
|
| 843 |
+
|
| 844 |
+
.lab-emoji {
|
| 845 |
+
font-size: 22px;
|
| 846 |
+
line-height: 1;
|
| 847 |
+
flex: 0 0 auto;
|
| 848 |
+
}
|
| 849 |
+
|
| 850 |
+
.lab-grid-2 {
|
| 851 |
+
display: grid;
|
| 852 |
+
grid-template-columns: repeat(2, minmax(0, 1fr));
|
| 853 |
+
gap: 14px;
|
| 854 |
+
}
|
| 855 |
+
|
| 856 |
+
.lab-tile {
|
| 857 |
+
min-height: 108px;
|
| 858 |
+
padding: 18px 20px;
|
| 859 |
+
border-radius: 16px;
|
| 860 |
+
border: 1px solid transparent;
|
| 861 |
+
overflow: hidden;
|
| 862 |
+
}
|
| 863 |
+
|
| 864 |
+
.lab-tile span {
|
| 865 |
+
display: block;
|
| 866 |
+
margin-bottom: 10px;
|
| 867 |
+
font-size: 12px;
|
| 868 |
+
line-height: 1.2;
|
| 869 |
+
font-weight: 900;
|
| 870 |
+
letter-spacing: .04em;
|
| 871 |
+
text-transform: uppercase;
|
| 872 |
+
}
|
| 873 |
+
|
| 874 |
+
.lab-tile b {
|
| 875 |
+
display: block;
|
| 876 |
+
font-size: 23px;
|
| 877 |
+
line-height: 1.15;
|
| 878 |
+
font-weight: 950;
|
| 879 |
+
letter-spacing: -0.035em;
|
| 880 |
+
overflow-wrap: break-word;
|
| 881 |
+
}
|
| 882 |
+
|
| 883 |
+
.lab-green {
|
| 884 |
+
background: #ecfdf5 !important;
|
| 885 |
+
color: #065f46 !important;
|
| 886 |
+
border-color: #a7f3d0 !important;
|
| 887 |
+
}
|
| 888 |
+
|
| 889 |
+
.lab-dark {
|
| 890 |
+
background:
|
| 891 |
+
radial-gradient(circle at 84% 18%, rgba(124,58,237,.28), transparent 36%),
|
| 892 |
+
#1e1b4b !important;
|
| 893 |
+
color: #ffffff !important;
|
| 894 |
+
border-color: #3730a3 !important;
|
| 895 |
+
}
|
| 896 |
+
|
| 897 |
+
.lab-purple {
|
| 898 |
+
background: #f5f3ff !important;
|
| 899 |
+
color: #4c1d95 !important;
|
| 900 |
+
border-color: #ddd6fe !important;
|
| 901 |
+
}
|
| 902 |
+
|
| 903 |
+
.lab-red {
|
| 904 |
+
background: #fff1f2 !important;
|
| 905 |
+
color: #9f1239 !important;
|
| 906 |
+
border-color: #fecdd3 !important;
|
| 907 |
+
}
|
| 908 |
+
|
| 909 |
+
.lab-green *,
|
| 910 |
+
.lab-dark *,
|
| 911 |
+
.lab-purple *,
|
| 912 |
+
.lab-red * {
|
| 913 |
+
color: inherit !important;
|
| 914 |
+
}
|
| 915 |
+
|
| 916 |
+
/* ───────────────── TABS ───────────────── */
|
| 917 |
+
|
| 918 |
+
.tabs-shell {
|
| 919 |
+
margin-top: 28px;
|
| 920 |
+
}
|
| 921 |
+
|
| 922 |
+
.gradio-container .tab-nav {
|
| 923 |
+
gap: 6px !important;
|
| 924 |
+
border-bottom: 1px solid #cbd5e1 !important;
|
| 925 |
+
}
|
| 926 |
+
|
| 927 |
+
.gradio-container .tab-nav button {
|
| 928 |
+
font-size: 14px !important;
|
| 929 |
+
font-weight: 750 !important;
|
| 930 |
+
color: #6b7280 !important;
|
| 931 |
+
padding: 11px 18px !important;
|
| 932 |
+
background: transparent !important;
|
| 933 |
+
border: none !important;
|
| 934 |
+
border-bottom: 3px solid transparent !important;
|
| 935 |
+
white-space: nowrap !important;
|
| 936 |
+
}
|
| 937 |
+
|
| 938 |
+
.gradio-container .tab-nav button.selected {
|
| 939 |
+
color: #6d28d9 !important;
|
| 940 |
+
border-bottom-color: #7c3aed !important;
|
| 941 |
+
}
|
| 942 |
+
|
| 943 |
+
.gradio-container .tabs > .block,
|
| 944 |
+
.gradio-container .tabitem {
|
| 945 |
+
background: #ffffff !important;
|
| 946 |
+
border: 1px solid #e2e8f0 !important;
|
| 947 |
+
border-radius: 18px !important;
|
| 948 |
+
box-shadow: 0 8px 24px rgba(15,23,42,.04) !important;
|
| 949 |
+
overflow: hidden !important;
|
| 950 |
+
}
|
| 951 |
+
|
| 952 |
+
/* General text */
|
| 953 |
+
.gradio-container .markdown,
|
| 954 |
+
.gradio-container p,
|
| 955 |
+
.gradio-container li,
|
| 956 |
+
.gradio-container td,
|
| 957 |
+
.gradio-container th,
|
| 958 |
+
.gradio-container label,
|
| 959 |
+
.gradio-container textarea,
|
| 960 |
+
.gradio-container input {
|
| 961 |
+
color: #111827 !important;
|
| 962 |
+
}
|
| 963 |
+
|
| 964 |
+
/* ───────────────── RESPONSIVE ───────────────── */
|
| 965 |
+
|
| 966 |
+
@media (max-width: 900px) {
|
| 967 |
+
.app-shell {
|
| 968 |
+
width: min(94vw, 760px);
|
| 969 |
+
}
|
| 970 |
+
|
| 971 |
+
.hero {
|
| 972 |
+
min-height: auto;
|
| 973 |
+
padding: 26px 22px;
|
| 974 |
+
}
|
| 975 |
+
|
| 976 |
+
.hero h1 {
|
| 977 |
+
font-size: 28px;
|
| 978 |
+
}
|
| 979 |
+
|
| 980 |
+
.hero p {
|
| 981 |
+
font-size: 14px;
|
| 982 |
+
}
|
| 983 |
+
|
| 984 |
+
.top-layout {
|
| 985 |
+
grid-template-columns: 1fr !important;
|
| 986 |
+
}
|
| 987 |
+
|
| 988 |
+
.summary-row {
|
| 989 |
+
grid-template-columns: 1fr;
|
| 990 |
+
gap: 6px;
|
| 991 |
+
}
|
| 992 |
+
|
| 993 |
+
.lab-grid-2 {
|
| 994 |
+
grid-template-columns: 1fr;
|
| 995 |
+
}
|
| 996 |
+
}
|
| 997 |
+
|
| 998 |
+
|
| 999 |
+
/* "Analyze Paper" text inside Quick Summary */
|
| 1000 |
+
.summary-card .value,
|
| 1001 |
+
.summary-card .value *,
|
| 1002 |
+
.summary-row .value,
|
| 1003 |
+
.summary-row .value * {
|
| 1004 |
+
color: #111827 !important;
|
| 1005 |
+
opacity: 1 !important;
|
| 1006 |
+
visibility: visible !important;
|
| 1007 |
+
}
|
| 1008 |
+
|
| 1009 |
+
/* Quick summary text */
|
| 1010 |
+
.summary-card .value,
|
| 1011 |
+
.summary-card .value *,
|
| 1012 |
+
.summary-row .value,
|
| 1013 |
+
.summary-row .value * {
|
| 1014 |
+
color: #111827 !important;
|
| 1015 |
+
opacity: 1 !important;
|
| 1016 |
+
-webkit-text-fill-color: #111827 !important;
|
| 1017 |
+
}
|
| 1018 |
+
|
| 1019 |
+
/* Tabs text */
|
| 1020 |
+
.gradio-container button[role="tab"],
|
| 1021 |
+
.gradio-container button[role="tab"] *,
|
| 1022 |
+
.gradio-container .tab-nav button,
|
| 1023 |
+
.gradio-container .tab-nav button * {
|
| 1024 |
+
color: #111827 !important;
|
| 1025 |
+
opacity: 1 !important;
|
| 1026 |
+
visibility: visible !important;
|
| 1027 |
+
-webkit-text-fill-color: #111827 !important;
|
| 1028 |
+
}
|
| 1029 |
+
|
| 1030 |
+
.gradio-container button[role="tab"][aria-selected="true"],
|
| 1031 |
+
.gradio-container button[role="tab"][aria-selected="true"] *,
|
| 1032 |
+
.gradio-container .tab-nav button.selected,
|
| 1033 |
+
.gradio-container .tab-nav button.selected * {
|
| 1034 |
+
color: #7c3aed !important;
|
| 1035 |
+
-webkit-text-fill-color: #7c3aed !important;
|
| 1036 |
+
font-weight: 800 !important;
|
| 1037 |
+
}
|
| 1038 |
+
|
| 1039 |
+
/* ===== FINAL LEFT PANEL UPLOAD FIX ===== */
|
| 1040 |
+
|
| 1041 |
+
.upload-caption {
|
| 1042 |
+
display: inline-flex;
|
| 1043 |
+
width: fit-content;
|
| 1044 |
+
align-items: center;
|
| 1045 |
+
gap: 8px;
|
| 1046 |
+
padding: 11px 20px;
|
| 1047 |
+
border-radius: 12px;
|
| 1048 |
+
background: linear-gradient(135deg, #7c3aed, #6d28d9);
|
| 1049 |
+
color: #ffffff !important;
|
| 1050 |
+
-webkit-text-fill-color: #ffffff !important;
|
| 1051 |
+
font-size: 15px;
|
| 1052 |
+
font-weight: 900;
|
| 1053 |
+
box-shadow: 0 12px 24px rgba(124,58,237,.22);
|
| 1054 |
+
margin-bottom: 0 !important;
|
| 1055 |
+
}
|
| 1056 |
+
|
| 1057 |
+
.upload-caption * {
|
| 1058 |
+
color: #ffffff !important;
|
| 1059 |
+
-webkit-text-fill-color: #ffffff !important;
|
| 1060 |
+
}
|
| 1061 |
+
|
| 1062 |
+
#pdf_upload {
|
| 1063 |
+
width: 100% !important;
|
| 1064 |
+
margin: 0 !important;
|
| 1065 |
+
padding: 0 !important;
|
| 1066 |
+
background: transparent !important;
|
| 1067 |
+
border: none !important;
|
| 1068 |
+
box-shadow: none !important;
|
| 1069 |
+
}
|
| 1070 |
+
|
| 1071 |
+
/* Main drop area only */
|
| 1072 |
+
#pdf_upload .wrap {
|
| 1073 |
+
width: 100% !important;
|
| 1074 |
+
min-height: 215px !important;
|
| 1075 |
+
background: #ffffff !important;
|
| 1076 |
+
border: 2px dashed #c4b5fd !important;
|
| 1077 |
+
border-radius: 24px !important;
|
| 1078 |
+
display: grid !important;
|
| 1079 |
+
place-items: center !important;
|
| 1080 |
+
text-align: center !important;
|
| 1081 |
+
position: relative !important;
|
| 1082 |
+
overflow: hidden !important;
|
| 1083 |
+
}
|
| 1084 |
+
|
| 1085 |
+
/* Hide only Gradio placeholder text before upload */
|
| 1086 |
+
#pdf_upload .wrap > div:first-child,
|
| 1087 |
+
#pdf_upload .wrap > span,
|
| 1088 |
+
#pdf_upload .wrap > p {
|
| 1089 |
+
display: none !important;
|
| 1090 |
+
}
|
| 1091 |
+
|
| 1092 |
+
/* Custom placeholder */
|
| 1093 |
+
#pdf_upload .wrap::after {
|
| 1094 |
+
content: "☁️\A Drag & drop your PDF file here\A or click to browse";
|
| 1095 |
+
white-space: pre-line;
|
| 1096 |
+
color: #334155 !important;
|
| 1097 |
+
-webkit-text-fill-color: #334155 !important;
|
| 1098 |
+
font-size: 16px !important;
|
| 1099 |
+
line-height: 1.8 !important;
|
| 1100 |
+
font-weight: 700 !important;
|
| 1101 |
+
text-align: center !important;
|
| 1102 |
+
}
|
| 1103 |
+
|
| 1104 |
+
/* After file upload: remove fake placeholder */
|
| 1105 |
+
#pdf_upload:has([data-testid="file"]) .wrap::after,
|
| 1106 |
+
#pdf_upload:has(.file-preview) .wrap::after {
|
| 1107 |
+
content: "" !important;
|
| 1108 |
+
}
|
| 1109 |
+
|
| 1110 |
+
/* Uploaded file card readability */
|
| 1111 |
+
#pdf_upload [data-testid="file"],
|
| 1112 |
+
#pdf_upload .file-preview,
|
| 1113 |
+
#pdf_upload .file-preview *,
|
| 1114 |
+
#pdf_upload a,
|
| 1115 |
+
#pdf_upload span,
|
| 1116 |
+
#pdf_upload p {
|
| 1117 |
+
color: #111827 !important;
|
| 1118 |
+
-webkit-text-fill-color: #111827 !important;
|
| 1119 |
+
opacity: 1 !important;
|
| 1120 |
+
}
|
| 1121 |
+
|
| 1122 |
+
/* Hide built-in label only */
|
| 1123 |
+
#pdf_upload .label-wrap,
|
| 1124 |
+
#pdf_upload label,
|
| 1125 |
+
#pdf_upload [data-testid="block-label"],
|
| 1126 |
+
#pdf_upload input[type="file"] {
|
| 1127 |
+
display: none !important;
|
| 1128 |
+
}
|
| 1129 |
+
|
| 1130 |
+
/* Engine wrapper */
|
| 1131 |
+
#engine_radio,
|
| 1132 |
+
#engine_radio *,
|
| 1133 |
+
#engine_radio fieldset,
|
| 1134 |
+
#engine_radio .wrap {
|
| 1135 |
+
background: #FFFFFF !important;
|
| 1136 |
+
border: none !important;
|
| 1137 |
+
box-shadow: none !important;
|
| 1138 |
+
}
|
| 1139 |
+
|
| 1140 |
+
.engine-caption {
|
| 1141 |
+
display: inline-flex;
|
| 1142 |
+
width: fit-content;
|
| 1143 |
+
align-items: center;
|
| 1144 |
+
padding: 8px 14px;
|
| 1145 |
+
border-radius: 10px;
|
| 1146 |
+
background: #7c3aed;
|
| 1147 |
+
color: #ffffff !important;
|
| 1148 |
+
-webkit-text-fill-color: #ffffff !important;
|
| 1149 |
+
font-size: 16px;
|
| 1150 |
+
font-weight: 900;
|
| 1151 |
+
margin-bottom: 10px;
|
| 1152 |
+
}
|
| 1153 |
+
|
| 1154 |
+
|
| 1155 |
+
#engine_radio label,
|
| 1156 |
+
#engine_radio label span {
|
| 1157 |
+
background: transparent !important;
|
| 1158 |
+
border: none !important;
|
| 1159 |
+
box-shadow: none !important;
|
| 1160 |
+
color: #8b5cf6 !important;
|
| 1161 |
+
-webkit-text-fill-color: #8b5cf6 !important;
|
| 1162 |
+
font-size: 14px !important;
|
| 1163 |
+
font-weight: 900 !important;
|
| 1164 |
+
}
|
| 1165 |
+
|
| 1166 |
+
/* Selected option purple */
|
| 1167 |
+
#engine_radio label:has(input:checked),
|
| 1168 |
+
#engine_radio label:has(input:checked) * {
|
| 1169 |
+
color: #5b21b6 !important;
|
| 1170 |
+
-webkit-text-fill-color: #5b21b6 !important;
|
| 1171 |
+
}
|
| 1172 |
+
|
| 1173 |
+
/* Radio circles */
|
| 1174 |
+
#engine_radio input[type="radio"] {
|
| 1175 |
+
display: inline-block !important;
|
| 1176 |
+
appearance: auto !important;
|
| 1177 |
+
-webkit-appearance: radio !important;
|
| 1178 |
+
opacity: 1 !important;
|
| 1179 |
+
visibility: visible !important;
|
| 1180 |
+
position: static !important;
|
| 1181 |
+
width: 14px !important;
|
| 1182 |
+
height: 14px !important;
|
| 1183 |
+
margin-right: 8px !important;
|
| 1184 |
+
accent-color: #5b21b6 !important;
|
| 1185 |
+
}
|
| 1186 |
+
|
| 1187 |
+
#engine_radio .wrap {
|
| 1188 |
+
display: flex !important;
|
| 1189 |
+
flex-direction: row !important;
|
| 1190 |
+
gap: 40px !important;
|
| 1191 |
+
align-items: center !important;
|
| 1192 |
+
}
|
| 1193 |
+
|
| 1194 |
+
/* Hide Gradio's built-in title completely */
|
| 1195 |
+
#engine_radio legend,
|
| 1196 |
+
#engine_radio .label-wrap,
|
| 1197 |
+
#engine_radio [data-testid="block-label"] {
|
| 1198 |
+
display: none !important;
|
| 1199 |
+
}
|
| 1200 |
+
|
| 1201 |
+
|
| 1202 |
+
/* EMERGENCY READABILITY FIX */
|
| 1203 |
+
|
| 1204 |
+
.gradio-container h1,
|
| 1205 |
+
.gradio-container h2,
|
| 1206 |
+
.gradio-container h3,
|
| 1207 |
+
.gradio-container h4,
|
| 1208 |
+
.gradio-container h5,
|
| 1209 |
+
.gradio-container h6 {
|
| 1210 |
+
color: #111827 !important;
|
| 1211 |
+
opacity: 1 !important;
|
| 1212 |
+
visibility: visible !important;
|
| 1213 |
+
font-weight: 800 !important;
|
| 1214 |
+
}
|
| 1215 |
+
|
| 1216 |
+
/* Safe readability fix: Markdown only, not JSON/code internals */
|
| 1217 |
+
.gradio-container .markdown,
|
| 1218 |
+
.gradio-container .markdown p,
|
| 1219 |
+
.gradio-container .markdown li,
|
| 1220 |
+
.gradio-container .markdown h1,
|
| 1221 |
+
.gradio-container .markdown h2,
|
| 1222 |
+
.gradio-container .markdown h3,
|
| 1223 |
+
.gradio-container .markdown strong {
|
| 1224 |
+
color: #111827 !important;
|
| 1225 |
+
opacity: 1 !important;
|
| 1226 |
+
}
|
| 1227 |
+
|
| 1228 |
+
/* Keep code/JSON readable */
|
| 1229 |
+
.gradio-container pre,
|
| 1230 |
+
.gradio-container code,
|
| 1231 |
+
.gradio-container pre *,
|
| 1232 |
+
.gradio-container code *,
|
| 1233 |
+
.gradio-container .cm-editor,
|
| 1234 |
+
.gradio-container .cm-editor * {
|
| 1235 |
+
color: inherit !important;
|
| 1236 |
+
-webkit-text-fill-color: inherit !important;
|
| 1237 |
+
}
|
| 1238 |
+
|
| 1239 |
+
/* ================= FINAL TAB READABILITY PATCH ================= */
|
| 1240 |
+
|
| 1241 |
+
/* Normal markdown text, including _Not found._ italic text */
|
| 1242 |
+
.gradio-container .markdown,
|
| 1243 |
+
.gradio-container .markdown *,
|
| 1244 |
+
.gradio-container .prose,
|
| 1245 |
+
.gradio-container .prose * {
|
| 1246 |
+
color: #111827 !important;
|
| 1247 |
+
-webkit-text-fill-color: #111827 !important;
|
| 1248 |
+
opacity: 1 !important;
|
| 1249 |
+
}
|
| 1250 |
+
|
| 1251 |
+
/* Markdown italic fallback like _Not found._ */
|
| 1252 |
+
.gradio-container em,
|
| 1253 |
+
.gradio-container i {
|
| 1254 |
+
color: #475569 !important;
|
| 1255 |
+
-webkit-text-fill-color: #475569 !important;
|
| 1256 |
+
opacity: 1 !important;
|
| 1257 |
+
}
|
| 1258 |
+
|
| 1259 |
+
/* Inline code like `survey_or_review` */
|
| 1260 |
+
.gradio-container .markdown code,
|
| 1261 |
+
.gradio-container p code,
|
| 1262 |
+
.gradio-container li code {
|
| 1263 |
+
background: #f1f5f9 !important;
|
| 1264 |
+
color: #5b21b6 !important;
|
| 1265 |
+
-webkit-text-fill-color: #5b21b6 !important;
|
| 1266 |
+
padding: 2px 6px !important;
|
| 1267 |
+
border-radius: 6px !important;
|
| 1268 |
+
font-weight: 800 !important;
|
| 1269 |
+
}
|
| 1270 |
+
|
| 1271 |
+
/* Force tab panels to stay white */
|
| 1272 |
+
.gradio-container .tabitem,
|
| 1273 |
+
.gradio-container .tabitem > div,
|
| 1274 |
+
.gradio-container .tabitem .block,
|
| 1275 |
+
.gradio-container .tabitem .wrap {
|
| 1276 |
+
background: #ffffff !important;
|
| 1277 |
+
color: #111827 !important;
|
| 1278 |
+
-webkit-text-fill-color: #111827 !important;
|
| 1279 |
+
}
|
| 1280 |
+
|
| 1281 |
+
/* Advanced accordion fix */
|
| 1282 |
+
.gradio-container .accordion,
|
| 1283 |
+
.gradio-container .accordion *,
|
| 1284 |
+
.gradio-container details,
|
| 1285 |
+
.gradio-container details * {
|
| 1286 |
+
background: #ffffff !important;
|
| 1287 |
+
color: #111827 !important;
|
| 1288 |
+
-webkit-text-fill-color: #111827 !important;
|
| 1289 |
+
}
|
| 1290 |
+
|
| 1291 |
+
/* Tables in Advanced Analysis */
|
| 1292 |
+
.gradio-container table,
|
| 1293 |
+
.gradio-container table *,
|
| 1294 |
+
.gradio-container th,
|
| 1295 |
+
.gradio-container td {
|
| 1296 |
+
background: #ffffff !important;
|
| 1297 |
+
color: #111827 !important;
|
| 1298 |
+
-webkit-text-fill-color: #111827 !important;
|
| 1299 |
+
border-color: #cbd5e1 !important;
|
| 1300 |
+
}
|
| 1301 |
+
|
| 1302 |
+
/* Code blocks inside markdown */
|
| 1303 |
+
.gradio-container pre,
|
| 1304 |
+
.gradio-container pre *,
|
| 1305 |
+
.gradio-container code,
|
| 1306 |
+
.gradio-container code * {
|
| 1307 |
+
background: #f8fafc !important;
|
| 1308 |
+
color: #111827 !important;
|
| 1309 |
+
-webkit-text-fill-color: #111827 !important;
|
| 1310 |
+
}
|
| 1311 |
+
|
| 1312 |
+
/* Export JSON/code viewer: make it readable */
|
| 1313 |
+
.gradio-container .cm-editor,
|
| 1314 |
+
.gradio-container .cm-editor *,
|
| 1315 |
+
.gradio-container .cm-scroller,
|
| 1316 |
+
.gradio-container .cm-content,
|
| 1317 |
+
.gradio-container .cm-line {
|
| 1318 |
+
background: #f8fafc !important;
|
| 1319 |
+
color: #111827 !important;
|
| 1320 |
+
-webkit-text-fill-color: #111827 !important;
|
| 1321 |
+
}
|
| 1322 |
+
|
| 1323 |
+
/* Export file boxes */
|
| 1324 |
+
.gradio-container [data-testid="file"],
|
| 1325 |
+
.gradio-container [data-testid="file"] *,
|
| 1326 |
+
.gradio-container .file-preview,
|
| 1327 |
+
.gradio-container .file-preview * {
|
| 1328 |
+
background: #f8fafc !important;
|
| 1329 |
+
color: #111827 !important;
|
| 1330 |
+
-webkit-text-fill-color: #111827 !important;
|
| 1331 |
+
}
|
| 1332 |
+
|
| 1333 |
+
/* ================= LAB READINESS RESTORE ================= */
|
| 1334 |
+
|
| 1335 |
+
/* Restore dark Tools tile readability */
|
| 1336 |
+
.lab-board .lab-dark,
|
| 1337 |
+
.lab-board .lab-dark *,
|
| 1338 |
+
.lab-board .lab-dark span,
|
| 1339 |
+
.lab-board .lab-dark b {
|
| 1340 |
+
color: #ffffff !important;
|
| 1341 |
+
-webkit-text-fill-color: #ffffff !important;
|
| 1342 |
+
opacity: 1 !important;
|
| 1343 |
+
}
|
| 1344 |
+
|
| 1345 |
+
/* Optional: softer label inside dark card */
|
| 1346 |
+
.lab-board .lab-dark span {
|
| 1347 |
+
color: #c4b5fd !important;
|
| 1348 |
+
-webkit-text-fill-color: #c4b5fd !important;
|
| 1349 |
+
}
|
| 1350 |
+
|
| 1351 |
+
/* Keep other lab tiles clean */
|
| 1352 |
+
.lab-board .lab-green,
|
| 1353 |
+
.lab-board .lab-green * {
|
| 1354 |
+
color: #065f46 !important;
|
| 1355 |
+
-webkit-text-fill-color: #065f46 !important;
|
| 1356 |
+
}
|
| 1357 |
+
|
| 1358 |
+
.lab-board .lab-purple,
|
| 1359 |
+
.lab-board .lab-purple * {
|
| 1360 |
+
color: #4c1d95 !important;
|
| 1361 |
+
-webkit-text-fill-color: #4c1d95 !important;
|
| 1362 |
+
}
|
| 1363 |
+
|
| 1364 |
+
.lab-board .lab-red,
|
| 1365 |
+
.lab-board .lab-red * {
|
| 1366 |
+
color: #9f1239 !important;
|
| 1367 |
+
-webkit-text-fill-color: #9f1239 !important;
|
| 1368 |
+
}
|
| 1369 |
+
|
| 1370 |
+
/* ================= HERO COLOR RESTORE ================= */
|
| 1371 |
+
|
| 1372 |
+
.hero,
|
| 1373 |
+
.hero * {
|
| 1374 |
+
color: inherit;
|
| 1375 |
+
}
|
| 1376 |
+
|
| 1377 |
+
.hero .logo-text {
|
| 1378 |
+
color: #ffffff !important;
|
| 1379 |
+
-webkit-text-fill-color: #ffffff !important;
|
| 1380 |
+
}
|
| 1381 |
+
|
| 1382 |
+
.hero .kicker {
|
| 1383 |
+
color: #67e8f9 !important;
|
| 1384 |
+
-webkit-text-fill-color: #67e8f9 !important;
|
| 1385 |
+
}
|
| 1386 |
+
|
| 1387 |
+
.hero h1 {
|
| 1388 |
+
color: #ffffff !important;
|
| 1389 |
+
-webkit-text-fill-color: #ffffff !important;
|
| 1390 |
+
}
|
| 1391 |
+
|
| 1392 |
+
.hero h1 span {
|
| 1393 |
+
color: #a78bfa !important;
|
| 1394 |
+
-webkit-text-fill-color: #a78bfa !important;
|
| 1395 |
+
}
|
| 1396 |
+
|
| 1397 |
+
.hero p {
|
| 1398 |
+
color: rgba(255,255,255,.88) !important;
|
| 1399 |
+
-webkit-text-fill-color: rgba(255,255,255,.88) !important;
|
| 1400 |
+
}
|
| 1401 |
+
|
| 1402 |
+
.hero .hero-badge,
|
| 1403 |
+
.hero .hero-badge * {
|
| 1404 |
+
color: #ffffff !important;
|
| 1405 |
+
-webkit-text-fill-color: #ffffff !important;
|
| 1406 |
+
}
|
| 1407 |
+
|
| 1408 |
+
.hero .logo-mark {
|
| 1409 |
+
color: inherit !important;
|
| 1410 |
+
-webkit-text-fill-color: inherit !important;
|
| 1411 |
+
}
|
| 1412 |
+
|
| 1413 |
+
/* ================= ASK THE PAPER / RAG FIX ================= */
|
| 1414 |
+
|
| 1415 |
+
.rag-panel,
|
| 1416 |
+
.rag-panel * {
|
| 1417 |
+
color: #111827 !important;
|
| 1418 |
+
-webkit-text-fill-color: #111827 !important;
|
| 1419 |
+
}
|
| 1420 |
+
|
| 1421 |
+
.rag-question,
|
| 1422 |
+
.rag-question *,
|
| 1423 |
+
.rag-question textarea,
|
| 1424 |
+
.rag-question input {
|
| 1425 |
+
background: #ffffff !important;
|
| 1426 |
+
color: #111827 !important;
|
| 1427 |
+
-webkit-text-fill-color: #111827 !important;
|
| 1428 |
+
border-color: #cbd5e1 !important;
|
| 1429 |
+
}
|
| 1430 |
+
|
| 1431 |
+
.rag-question textarea::placeholder,
|
| 1432 |
+
.rag-question input::placeholder {
|
| 1433 |
+
color: #64748b !important;
|
| 1434 |
+
-webkit-text-fill-color: #64748b !important;
|
| 1435 |
+
opacity: 1 !important;
|
| 1436 |
+
}
|
| 1437 |
+
|
| 1438 |
+
.rag-answer {
|
| 1439 |
+
background: #ffffff !important;
|
| 1440 |
+
border: 1px solid #e2e8f0 !important;
|
| 1441 |
+
border-radius: 18px !important;
|
| 1442 |
+
padding: 22px !important;
|
| 1443 |
+
box-shadow: 0 8px 24px rgba(15,23,42,.04) !important;
|
| 1444 |
+
line-height: 1.65 !important;
|
| 1445 |
+
}
|
| 1446 |
+
|
| 1447 |
+
.rag-answer,
|
| 1448 |
+
.rag-answer * {
|
| 1449 |
+
color: #111827 !important;
|
| 1450 |
+
-webkit-text-fill-color: #111827 !important;
|
| 1451 |
+
}
|
| 1452 |
+
|
| 1453 |
+
.rag-answer h1,
|
| 1454 |
+
.rag-answer h2,
|
| 1455 |
+
.rag-answer h3 {
|
| 1456 |
+
margin-top: 18px !important;
|
| 1457 |
+
margin-bottom: 10px !important;
|
| 1458 |
+
font-weight: 900 !important;
|
| 1459 |
+
}
|
| 1460 |
+
|
| 1461 |
+
.rag-answer ul {
|
| 1462 |
+
padding-left: 22px !important;
|
| 1463 |
+
}
|
| 1464 |
+
|
| 1465 |
+
.rag-answer li {
|
| 1466 |
+
margin-bottom: 7px !important;
|
| 1467 |
+
}
|
| 1468 |
+
|
| 1469 |
+
.rag-answer blockquote {
|
| 1470 |
+
margin: 12px 0 !important;
|
| 1471 |
+
padding: 12px 16px !important;
|
| 1472 |
+
border-left: 4px solid #7c3aed !important;
|
| 1473 |
+
background: #f8fafc !important;
|
| 1474 |
+
border-radius: 10px !important;
|
| 1475 |
+
color: #334155 !important;
|
| 1476 |
+
-webkit-text-fill-color: #334155 !important;
|
| 1477 |
+
}
|
| 1478 |
+
|
| 1479 |
+
.rag-debug,
|
| 1480 |
+
.rag-debug * {
|
| 1481 |
+
background: #ffffff !important;
|
| 1482 |
+
color: #111827 !important;
|
| 1483 |
+
-webkit-text-fill-color: #111827 !important;
|
| 1484 |
+
}
|
| 1485 |
+
"""
|
| 1486 |
+
|
| 1487 |
+
theme = gr.themes.Soft(
|
| 1488 |
+
primary_hue="violet",
|
| 1489 |
+
secondary_hue="blue",
|
| 1490 |
+
neutral_hue="slate",
|
| 1491 |
+
)
|
| 1492 |
+
|
| 1493 |
+
|
| 1494 |
+
with gr.Blocks(title=APP_NAME, theme=theme, css=CSS) as demo:
|
| 1495 |
+
state = gr.State({})
|
| 1496 |
+
|
| 1497 |
+
gr.HTML(
|
| 1498 |
+
"""
|
| 1499 |
+
<div class="app-shell">
|
| 1500 |
+
<section class="hero">
|
| 1501 |
+
<div class="hero-content">
|
| 1502 |
+
<div class="logo-row">
|
| 1503 |
+
<span class="logo-mark">🧪</span>
|
| 1504 |
+
<span class="logo-text">Paper2Lab</span>
|
| 1505 |
+
</div>
|
| 1506 |
+
|
| 1507 |
+
<div class="kicker">Research Assistant</div>
|
| 1508 |
+
|
| 1509 |
+
<h1>
|
| 1510 |
+
Turn research papers into
|
| 1511 |
+
<span>actionable lab plans</span>
|
| 1512 |
+
</h1>
|
| 1513 |
+
|
| 1514 |
+
<p>
|
| 1515 |
+
Upload a PDF and get a structured paper card, lab starter kit,
|
| 1516 |
+
evidence grounding, reproducibility assessment and exportable reports.
|
| 1517 |
+
</p>
|
| 1518 |
+
|
| 1519 |
+
<div class="hero-badges">
|
| 1520 |
+
<div class="hero-badge">✦ AI-powered extraction</div>
|
| 1521 |
+
<div class="hero-badge">□ Evidence grounded</div>
|
| 1522 |
+
<div class="hero-badge">☘ Reproducibility ready</div>
|
| 1523 |
+
</div>
|
| 1524 |
+
</div>
|
| 1525 |
+
</section>
|
| 1526 |
+
</div>
|
| 1527 |
+
"""
|
| 1528 |
+
)
|
| 1529 |
+
|
| 1530 |
+
with gr.Row(elem_classes=["app-shell", "top-layout"]):
|
| 1531 |
+
with gr.Column(scale=4, min_width=300, elem_classes=["control-stack"]):
|
| 1532 |
+
gr.HTML("""<div class="panel-title">📤 1. Upload your paper</div>""")
|
| 1533 |
+
gr.HTML("""<div class="upload-caption">📄 Drop your PDF here</div>""")
|
| 1534 |
+
|
| 1535 |
+
pdf_input = gr.File(
|
| 1536 |
+
label=None,
|
| 1537 |
+
show_label=False,
|
| 1538 |
+
file_types=[".pdf"],
|
| 1539 |
+
type="filepath",
|
| 1540 |
+
elem_id="pdf_upload",
|
| 1541 |
+
elem_classes=["clean-upload"],
|
| 1542 |
+
)
|
| 1543 |
+
|
| 1544 |
+
gr.HTML("""<div class="panel-title">⚡ 2. Select analysis engine</div>""")
|
| 1545 |
+
gr.HTML("""<div class="engine-caption">⚙️ Analysis engine</div>""")
|
| 1546 |
+
|
| 1547 |
+
refinement_mode = gr.Radio(
|
| 1548 |
+
label=None,
|
| 1549 |
+
container=False,
|
| 1550 |
+
choices=["local", "nemotron"],
|
| 1551 |
+
value="nemotron",
|
| 1552 |
+
elem_id="engine_radio",
|
| 1553 |
+
)
|
| 1554 |
+
|
| 1555 |
+
run_btn = gr.Button(
|
| 1556 |
+
"✨ Analyze Paper",
|
| 1557 |
+
variant="primary",
|
| 1558 |
+
size="lg",
|
| 1559 |
+
elem_classes=["clean-button"],
|
| 1560 |
+
)
|
| 1561 |
+
|
| 1562 |
+
gr.HTML("""<div class="tip-box">💡 Use a clear-text PDF for best results.</div>""")
|
| 1563 |
+
|
| 1564 |
+
with gr.Column(scale=7, min_width=520, elem_classes=["result-stack"]):
|
| 1565 |
+
quick_summary = gr.HTML(
|
| 1566 |
+
"""
|
| 1567 |
+
<div class="summary-card">
|
| 1568 |
+
<div class="card-head">
|
| 1569 |
+
<div><span class="icon">📄</span><b>Quick summary</b></div>
|
| 1570 |
+
<span class="status-pill">Ready</span>
|
| 1571 |
+
</div>
|
| 1572 |
+
<div class="summary-row">
|
| 1573 |
+
<div class="label">Status</div>
|
| 1574 |
+
<div class="value">Upload a PDF and click <b>Analyze Paper</b>.</div>
|
| 1575 |
+
</div>
|
| 1576 |
+
</div>
|
| 1577 |
+
"""
|
| 1578 |
+
)
|
| 1579 |
+
|
| 1580 |
+
lab_cards_html = gr.HTML(
|
| 1581 |
+
"""
|
| 1582 |
+
<div class="lab-board">
|
| 1583 |
+
<div class="lab-board-header">
|
| 1584 |
+
<span class="lab-emoji">🧪</span>
|
| 1585 |
+
<span>Lab readiness</span>
|
| 1586 |
+
</div>
|
| 1587 |
+
|
| 1588 |
+
<div class="lab-grid-2">
|
| 1589 |
+
<div class="lab-tile lab-green">
|
| 1590 |
+
<span>Starter type</span>
|
| 1591 |
+
<b>Waiting</b>
|
| 1592 |
+
</div>
|
| 1593 |
+
|
| 1594 |
+
<div class="lab-tile lab-dark">
|
| 1595 |
+
<span>Tools</span>
|
| 1596 |
+
<b>Waiting</b>
|
| 1597 |
+
</div>
|
| 1598 |
+
|
| 1599 |
+
<div class="lab-tile lab-purple">
|
| 1600 |
+
<span>Experiments</span>
|
| 1601 |
+
<b>Waiting</b>
|
| 1602 |
+
</div>
|
| 1603 |
+
|
| 1604 |
+
<div class="lab-tile lab-red">
|
| 1605 |
+
<span>Risks</span>
|
| 1606 |
+
<b>Waiting</b>
|
| 1607 |
+
</div>
|
| 1608 |
+
</div>
|
| 1609 |
+
</div>
|
| 1610 |
+
"""
|
| 1611 |
+
)
|
| 1612 |
+
|
| 1613 |
+
with gr.Row(elem_classes=["app-shell", "tabs-shell"]):
|
| 1614 |
+
with gr.Column():
|
| 1615 |
+
with gr.Tabs():
|
| 1616 |
+
with gr.Tab("📄 Paper Summary"):
|
| 1617 |
+
paper_md = gr.Markdown()
|
| 1618 |
+
|
| 1619 |
+
with gr.Tab("🧪 Lab Starter Kit"):
|
| 1620 |
+
lab_md = gr.Markdown()
|
| 1621 |
+
|
| 1622 |
+
with gr.Tab("🔎 Evidence Viewer"):
|
| 1623 |
+
evidence_md = gr.Markdown()
|
| 1624 |
+
|
| 1625 |
+
with gr.Tab("💬 Ask the Paper"):
|
| 1626 |
+
with gr.Column(elem_classes=["rag-panel"]):
|
| 1627 |
+
rag_question = gr.Textbox(
|
| 1628 |
+
label="Ask a question about the uploaded paper",
|
| 1629 |
+
placeholder="Example: What datasets were used?",
|
| 1630 |
+
lines=2,
|
| 1631 |
+
elem_classes=["rag-question"],
|
| 1632 |
+
)
|
| 1633 |
+
|
| 1634 |
+
rag_btn = gr.Button("Ask", variant="primary")
|
| 1635 |
+
|
| 1636 |
+
rag_answer = gr.Markdown(elem_classes=["rag-answer"])
|
| 1637 |
+
|
| 1638 |
+
rag_json = gr.JSON(
|
| 1639 |
+
label="RAG debug output",
|
| 1640 |
+
elem_classes=["rag-debug"],
|
| 1641 |
+
visible=False, # better for demo
|
| 1642 |
+
)
|
| 1643 |
+
|
| 1644 |
+
with gr.Tab("⚙️ Advanced Analysis"):
|
| 1645 |
+
with gr.Accordion("Model comparison, selection report, metadata", open=False):
|
| 1646 |
+
advanced_md = gr.Markdown()
|
| 1647 |
+
|
| 1648 |
+
with gr.Tab("⬇️ Export"):
|
| 1649 |
+
final_json = gr.JSON(label="Final paper card")
|
| 1650 |
+
json_file = gr.File(label="Download JSON")
|
| 1651 |
+
md_file = gr.File(label="Download Markdown report")
|
| 1652 |
+
|
| 1653 |
+
run_btn.click(
|
| 1654 |
+
fn=analyze_paper,
|
| 1655 |
+
inputs=[pdf_input, refinement_mode],
|
| 1656 |
+
outputs=[
|
| 1657 |
+
state,
|
| 1658 |
+
quick_summary,
|
| 1659 |
+
paper_md,
|
| 1660 |
+
lab_md,
|
| 1661 |
+
evidence_md,
|
| 1662 |
+
advanced_md,
|
| 1663 |
+
lab_cards_html,
|
| 1664 |
+
final_json,
|
| 1665 |
+
json_file,
|
| 1666 |
+
md_file,
|
| 1667 |
+
],
|
| 1668 |
+
show_progress=True,
|
| 1669 |
+
)
|
| 1670 |
+
|
| 1671 |
+
rag_btn.click(
|
| 1672 |
+
fn=ask_paper_question,
|
| 1673 |
+
inputs=[state, rag_question],
|
| 1674 |
+
outputs=[rag_answer, rag_json],
|
| 1675 |
+
show_progress=True,
|
| 1676 |
+
)
|
| 1677 |
+
|
| 1678 |
+
if __name__ == "__main__":
|
| 1679 |
+
demo.queue()
|
| 1680 |
+
demo.launch()
|
configs/dataset.yaml
ADDED
|
File without changes
|
configs/evaluation.yaml
ADDED
|
File without changes
|
configs/inference.yaml
ADDED
|
File without changes
|
configs/training.yaml
ADDED
|
File without changes
|
docs/annotation_schema.md
ADDED
|
File without changes
|
docs/dataset_card.md
ADDED
|
File without changes
|
docs/model_card.md
ADDED
|
File without changes
|
modal_refine.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import modal
|
| 4 |
+
|
| 5 |
+
app = modal.App("paper2lab-nemotron")
|
| 6 |
+
|
| 7 |
+
image = (
|
| 8 |
+
modal.Image.debian_slim(python_version="3.11")
|
| 9 |
+
.pip_install("requests")
|
| 10 |
+
.add_local_dir("src/paper2lab", remote_path="/root/paper2lab")
|
| 11 |
+
)
|
| 12 |
+
|
| 13 |
+
secret = modal.Secret.from_name("nvidia-api-key")
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
@app.function(
|
| 17 |
+
image=image,
|
| 18 |
+
secrets=[secret],
|
| 19 |
+
timeout=180,
|
| 20 |
+
)
|
| 21 |
+
def refine_remote(
|
| 22 |
+
llm_evidence_pack: dict,
|
| 23 |
+
model: str = "nvidia/nemotron-3-nano-30b-a3b",
|
| 24 |
+
return_comparison: bool = True,
|
| 25 |
+
) -> dict:
|
| 26 |
+
from paper2lab.inference.nemotron_refiner import refine_with_nemotron
|
| 27 |
+
|
| 28 |
+
return refine_with_nemotron(
|
| 29 |
+
llm_evidence_pack=llm_evidence_pack,
|
| 30 |
+
model=model,
|
| 31 |
+
return_comparison=return_comparison,
|
| 32 |
+
)
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
@app.local_entrypoint()
|
| 36 |
+
def main():
|
| 37 |
+
sample_pack = {
|
| 38 |
+
"candidate_paper_card": {
|
| 39 |
+
"title": "Attention Is All You Need",
|
| 40 |
+
"field": "Natural Language Processing",
|
| 41 |
+
"paper_type": "machine_learning",
|
| 42 |
+
"research_question": "The paper proposes the Transformer architecture.",
|
| 43 |
+
"contributions": [
|
| 44 |
+
"The paper proposes a Transformer architecture based on attention."
|
| 45 |
+
],
|
| 46 |
+
"methodology": [
|
| 47 |
+
"The model uses multi-head self-attention and feed-forward layers."
|
| 48 |
+
],
|
| 49 |
+
"datasets_or_data_sources": [
|
| 50 |
+
"WMT 2014 English-German dataset",
|
| 51 |
+
"RNN",
|
| 52 |
+
"BerkleyParser"
|
| 53 |
+
],
|
| 54 |
+
"models_or_methods": [
|
| 55 |
+
"Transformer",
|
| 56 |
+
"multi-head attention"
|
| 57 |
+
],
|
| 58 |
+
"metrics_or_measurements": [
|
| 59 |
+
"BLEU"
|
| 60 |
+
],
|
| 61 |
+
"key_findings": [],
|
| 62 |
+
"limitations": [],
|
| 63 |
+
"missing_reproducibility_info": [
|
| 64 |
+
"random seed is not specified"
|
| 65 |
+
],
|
| 66 |
+
"metadata": {},
|
| 67 |
+
"source_pdf": "attention is all you need.pdf",
|
| 68 |
+
"annotation_version": "v1.0",
|
| 69 |
+
},
|
| 70 |
+
"section_previews": [
|
| 71 |
+
{
|
| 72 |
+
"title": "Training Data and Batching",
|
| 73 |
+
"role_hint": "methodology",
|
| 74 |
+
"page_start": 7,
|
| 75 |
+
"page_end": 7,
|
| 76 |
+
"preview": "We trained on the standard WMT 2014 English-German dataset consisting of about 4.5 million sentence pairs. For English-French, we used the significantly larger WMT 2014 English-French dataset consisting of 36M sentences."
|
| 77 |
+
}
|
| 78 |
+
],
|
| 79 |
+
"captions": [],
|
| 80 |
+
"tables": [],
|
| 81 |
+
"metadata": {}
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
result = refine_remote.remote(sample_pack)
|
| 85 |
+
print(result)
|
pyproject.toml
ADDED
|
File without changes
|
requirements.txt
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# UI
|
| 2 |
+
gradio>=5.0.0
|
| 3 |
+
|
| 4 |
+
# PDF processing
|
| 5 |
+
pymupdf>=1.26.0
|
| 6 |
+
docling>=2.4.0
|
| 7 |
+
|
| 8 |
+
# LLMs
|
| 9 |
+
transformers>=4.53.0
|
| 10 |
+
torch>=2.7.0
|
| 11 |
+
accelerate>=1.8.0
|
| 12 |
+
|
| 13 |
+
# Tokenizers & model loading
|
| 14 |
+
sentencepiece>=0.2.0
|
| 15 |
+
safetensors>=0.5.0
|
| 16 |
+
tokenizers>=0.21.0
|
| 17 |
+
|
| 18 |
+
# Data processing
|
| 19 |
+
numpy>=2.2.0
|
| 20 |
+
pandas>=2.3.0
|
| 21 |
+
pyyaml>=6.0.2
|
| 22 |
+
|
| 23 |
+
# Validation
|
| 24 |
+
pydantic>=2.11.0
|
| 25 |
+
|
| 26 |
+
# Utilities
|
| 27 |
+
tqdm>=4.67.0
|
| 28 |
+
python-dotenv>=1.1.0
|
| 29 |
+
|
| 30 |
+
# Evaluation
|
| 31 |
+
scikit-learn>=1.7.0
|
| 32 |
+
|
| 33 |
+
# RAG
|
| 34 |
+
sentence-transformers
|
| 35 |
+
faiss-cpu
|
| 36 |
+
# Testing
|
| 37 |
+
pytest>=8.4.0
|
| 38 |
+
|
| 39 |
+
#modal
|
| 40 |
+
modal
|
scripts/audit_pipeline.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pathlib import Path
|
| 2 |
+
from paper2lab.inference.pipeline import PaperPipeline
|
| 3 |
+
|
| 4 |
+
p = PaperPipeline(refinement_mode="nemotron")
|
| 5 |
+
|
| 6 |
+
total = 0
|
| 7 |
+
errors = 0
|
| 8 |
+
weak = 0
|
| 9 |
+
|
| 10 |
+
for pdf in Path("Data/papers").rglob("*.pdf"):
|
| 11 |
+
total += 1
|
| 12 |
+
|
| 13 |
+
try:
|
| 14 |
+
r = p.run(str(pdf))
|
| 15 |
+
c = r["paper_card_final"]
|
| 16 |
+
|
| 17 |
+
roadmap = c.get("reproduction_roadmap") or {}
|
| 18 |
+
if not isinstance(roadmap, dict):
|
| 19 |
+
roadmap = {}
|
| 20 |
+
|
| 21 |
+
kit = c.get("lab_starter_kit") or {}
|
| 22 |
+
if not isinstance(kit, dict):
|
| 23 |
+
kit = {}
|
| 24 |
+
|
| 25 |
+
datasets = c.get("datasets_or_data_sources") or []
|
| 26 |
+
findings = c.get("key_findings") or []
|
| 27 |
+
roadmap_steps = roadmap.get("experimental_steps") or []
|
| 28 |
+
kit_structure = kit.get("project_structure") or []
|
| 29 |
+
kit_risks = kit.get("reproducibility_risks") or []
|
| 30 |
+
|
| 31 |
+
is_weak = (
|
| 32 |
+
not c.get("title")
|
| 33 |
+
or not c.get("paper_type")
|
| 34 |
+
or not roadmap
|
| 35 |
+
or not kit
|
| 36 |
+
or len(kit_structure) == 0
|
| 37 |
+
)
|
| 38 |
+
|
| 39 |
+
if is_weak:
|
| 40 |
+
weak += 1
|
| 41 |
+
|
| 42 |
+
print("\n---", pdf.name)
|
| 43 |
+
print("type:", c.get("paper_type"))
|
| 44 |
+
print("datasets:", len(datasets), datasets[:3])
|
| 45 |
+
print("findings:", len(findings))
|
| 46 |
+
print("roadmap steps:", len(roadmap_steps))
|
| 47 |
+
print("lab kit structure:", len(kit_structure))
|
| 48 |
+
print("lab risks:", len(kit_risks))
|
| 49 |
+
print("refinement:", r["refinement"]["status"])
|
| 50 |
+
print("weak:", is_weak)
|
| 51 |
+
|
| 52 |
+
except Exception as e:
|
| 53 |
+
errors += 1
|
| 54 |
+
print("\nERROR:", pdf.name)
|
| 55 |
+
print(str(e))
|
| 56 |
+
|
| 57 |
+
print("\n====================")
|
| 58 |
+
print("TOTAL:", total)
|
| 59 |
+
print("ERRORS:", errors)
|
| 60 |
+
print("WEAK:", weak)
|
| 61 |
+
print("====================")
|
src/paper2lab/__init__.py
ADDED
|
File without changes
|
src/paper2lab/evaluation/benchmark.py
ADDED
|
File without changes
|
src/paper2lab/evaluation/evaluate.py
ADDED
|
File without changes
|
src/paper2lab/evaluation/metrics.py
ADDED
|
File without changes
|
src/paper2lab/evaluation/reproducibility.py
ADDED
|
@@ -0,0 +1,313 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
reproducibility.py — Missing-information detection and reproducibility scoring.
|
| 3 |
+
|
| 4 |
+
Scores are heuristic and evidence-based. The goal is to produce a useful local
|
| 5 |
+
candidate before Nemotron refinement, while avoiding overconfident scores when
|
| 6 |
+
PDF extraction evidence is noisy.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
import re
|
| 12 |
+
from typing import Any, Dict, List, Tuple
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
# ---------------------------------------------------------------------------
|
| 16 |
+
# Text helpers
|
| 17 |
+
# ---------------------------------------------------------------------------
|
| 18 |
+
|
| 19 |
+
def _clean(text: str) -> str:
|
| 20 |
+
text = text or ""
|
| 21 |
+
text = text.replace("\x00", " ").replace("\u00a0", " ")
|
| 22 |
+
text = re.sub(r"\s+", " ", text)
|
| 23 |
+
text = re.sub(r"\b10\.\d{4,9}/[-._;()/:A-Za-z0-9]+", "", text)
|
| 24 |
+
return text.strip(" .;:\n\t")
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def _joined_text(extracted: Dict[str, Any]) -> str:
|
| 28 |
+
parts: List[str] = []
|
| 29 |
+
for sec in extracted.get("sections", []) or []:
|
| 30 |
+
if sec.get("role") in {"references", "appendix", "boilerplate"}:
|
| 31 |
+
continue
|
| 32 |
+
parts.append(str(sec.get("title", "")))
|
| 33 |
+
parts.append(str(sec.get("text", "")))
|
| 34 |
+
return _clean("\n".join(parts)).lower()
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def _has_any(text: str, terms: List[str]) -> bool:
|
| 38 |
+
return any(t.lower() in text for t in terms)
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def _matched_terms(text: str, terms: List[str], limit: int = 5) -> List[str]:
|
| 42 |
+
return [t for t in terms if t.lower() in text][:limit]
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
# ---------------------------------------------------------------------------
|
| 46 |
+
# Paper-type-specific reproducibility checks
|
| 47 |
+
# ---------------------------------------------------------------------------
|
| 48 |
+
|
| 49 |
+
def _check_items(paper_type: str) -> Dict[str, List[str]]:
|
| 50 |
+
if paper_type == "systematic_review":
|
| 51 |
+
return {
|
| 52 |
+
"search databases specified": [
|
| 53 |
+
"pubmed", "scopus", "web of knowledge", "eric", "cochrane",
|
| 54 |
+
"database", "databases",
|
| 55 |
+
],
|
| 56 |
+
"search date range specified": [
|
| 57 |
+
"january", "february", "march", "april", "may", "june",
|
| 58 |
+
"july", "august", "september", "october", "november", "december",
|
| 59 |
+
"between", "from", "until", "to january", "published between",
|
| 60 |
+
],
|
| 61 |
+
"inclusion criteria specified": [
|
| 62 |
+
"inclusion criteria", "eligibility criteria", "eligible studies",
|
| 63 |
+
],
|
| 64 |
+
"exclusion criteria specified": [
|
| 65 |
+
"exclusion criteria", "excluded", "not being", "were excluded",
|
| 66 |
+
],
|
| 67 |
+
"screening process specified": [
|
| 68 |
+
"screened", "screening", "titles and abstracts", "two independent",
|
| 69 |
+
"reviewers", "duplicates", "endnote",
|
| 70 |
+
],
|
| 71 |
+
"quality assessment specified": [
|
| 72 |
+
"quality assessment", "risk of bias", "best evidence medical education",
|
| 73 |
+
"valid tool", "critical appraisal", "assessment tool",
|
| 74 |
+
],
|
| 75 |
+
"number of included studies specified": [
|
| 76 |
+
"included", "enrolled", "final review", "studies were included",
|
| 77 |
+
"articles were included", "10 articles", "ten studies",
|
| 78 |
+
],
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
if paper_type == "machine_learning":
|
| 82 |
+
return {
|
| 83 |
+
"dataset details specified": [
|
| 84 |
+
"dataset", "training set", "test set", "validation set", "benchmark",
|
| 85 |
+
"corpus", "samples", "instances",
|
| 86 |
+
],
|
| 87 |
+
"train/validation/test split specified": [
|
| 88 |
+
"train", "validation", "test", "split", "dev set", "development set",
|
| 89 |
+
],
|
| 90 |
+
"model architecture specified": [
|
| 91 |
+
"architecture", "layers", "encoder", "decoder", "transformer", "cnn",
|
| 92 |
+
"resnet", "bert", "attention", "feed-forward",
|
| 93 |
+
],
|
| 94 |
+
"hyperparameters specified": [
|
| 95 |
+
"learning rate", "batch size", "epochs", "optimizer", "dropout",
|
| 96 |
+
"weight decay", "warmup", "scheduler",
|
| 97 |
+
],
|
| 98 |
+
"hardware specified": [
|
| 99 |
+
"gpu", "tpu", "cuda", "p100", "v100", "a100", "nvidia",
|
| 100 |
+
],
|
| 101 |
+
"evaluation metrics specified": [
|
| 102 |
+
"accuracy", "f1", "auc", "bleu", "rouge", "perplexity", "rmse", "mae",
|
| 103 |
+
"precision", "recall",
|
| 104 |
+
],
|
| 105 |
+
"code availability specified": [
|
| 106 |
+
"github", "code", "repository", "available at", "source code",
|
| 107 |
+
],
|
| 108 |
+
"random seed specified": ["random seed", "seed"],
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
if paper_type == "clinical_study":
|
| 112 |
+
return {
|
| 113 |
+
"cohort or participants specified": [
|
| 114 |
+
"patients", "participants", "cohort", "subjects", "population",
|
| 115 |
+
],
|
| 116 |
+
"inclusion criteria specified": ["inclusion criteria", "eligible"],
|
| 117 |
+
"exclusion criteria specified": ["exclusion criteria", "excluded"],
|
| 118 |
+
"outcomes specified": ["outcome", "endpoint", "mortality", "diagnosis"],
|
| 119 |
+
"statistical analysis specified": [
|
| 120 |
+
"statistical analysis", "p-value", "confidence interval", "regression",
|
| 121 |
+
],
|
| 122 |
+
"ethics approval specified": [
|
| 123 |
+
"ethics", "institutional review", "informed consent", "irb",
|
| 124 |
+
],
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
return {
|
| 128 |
+
"data/source details specified": [
|
| 129 |
+
"data", "dataset", "source", "samples", "studies", "articles",
|
| 130 |
+
],
|
| 131 |
+
"method/procedure specified": [
|
| 132 |
+
"method", "procedure", "approach", "experiment", "analysis",
|
| 133 |
+
],
|
| 134 |
+
"evaluation or analysis specified": [
|
| 135 |
+
"evaluation", "result", "metric", "analysis", "measured", "assessed",
|
| 136 |
+
],
|
| 137 |
+
"limitations discussed": ["limitation", "limitations", "future work"],
|
| 138 |
+
}
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
# ---------------------------------------------------------------------------
|
| 142 |
+
# Evidence quality / noise handling
|
| 143 |
+
# ---------------------------------------------------------------------------
|
| 144 |
+
|
| 145 |
+
_NOISY_EVIDENCE_MARKERS = [
|
| 146 |
+
"the there",
|
| 147 |
+
"being accordingly",
|
| 148 |
+
"endnote teachers",
|
| 149 |
+
"resultsare",
|
| 150 |
+
"analysis of the resultsare",
|
| 151 |
+
"table 2:",
|
| 152 |
+
"department of",
|
| 153 |
+
"university of",
|
| 154 |
+
"medical sciences",
|
| 155 |
+
"corresponding author",
|
| 156 |
+
"access this article online",
|
| 157 |
+
"how to cite",
|
| 158 |
+
"need this systematic review",
|
| 159 |
+
"the that",
|
| 160 |
+
]
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
def _roadmap_blob(paper_card: Dict[str, Any]) -> str:
|
| 164 |
+
roadmap = paper_card.get("reproduction_roadmap") or {}
|
| 165 |
+
parts: List[str] = []
|
| 166 |
+
|
| 167 |
+
for key in [
|
| 168 |
+
"datasets",
|
| 169 |
+
"software_requirements",
|
| 170 |
+
"experimental_steps",
|
| 171 |
+
"evaluation_procedure",
|
| 172 |
+
"expected_outputs",
|
| 173 |
+
"missing_for_reproduction",
|
| 174 |
+
]:
|
| 175 |
+
value = roadmap.get(key, [])
|
| 176 |
+
if isinstance(value, list):
|
| 177 |
+
for item in value:
|
| 178 |
+
if isinstance(item, dict):
|
| 179 |
+
parts.extend(str(v) for v in item.values())
|
| 180 |
+
else:
|
| 181 |
+
parts.append(str(item))
|
| 182 |
+
elif value:
|
| 183 |
+
parts.append(str(value))
|
| 184 |
+
|
| 185 |
+
return _clean(" ".join(parts)).lower()
|
| 186 |
+
|
| 187 |
+
|
| 188 |
+
def _noise_report(extracted: Dict[str, Any], paper_card: Dict[str, Any]) -> Tuple[int, List[str]]:
|
| 189 |
+
"""Return count and examples of noisy evidence markers."""
|
| 190 |
+
blob = _roadmap_blob(paper_card)
|
| 191 |
+
if not blob:
|
| 192 |
+
# Fallback to body text only if roadmap is not yet attached.
|
| 193 |
+
blob = _joined_text(extracted)
|
| 194 |
+
|
| 195 |
+
found = [m for m in _NOISY_EVIDENCE_MARKERS if m in blob]
|
| 196 |
+
|
| 197 |
+
# Extra generic noise signals.
|
| 198 |
+
if len(re.findall(r"\[\d+", blob)) >= 12:
|
| 199 |
+
found.append("many citation fragments")
|
| 200 |
+
if re.search(r"\b(the|and|of)\s+\1\b", blob):
|
| 201 |
+
found.append("repeated function-word artifact")
|
| 202 |
+
|
| 203 |
+
return len(found), found[:8]
|
| 204 |
+
|
| 205 |
+
|
| 206 |
+
def _apply_score_caps(
|
| 207 |
+
paper_type: str,
|
| 208 |
+
score: float,
|
| 209 |
+
missing: List[str],
|
| 210 |
+
extracted: Dict[str, Any],
|
| 211 |
+
paper_card: Dict[str, Any],
|
| 212 |
+
) -> Tuple[float, List[str], Dict[str, Any]]:
|
| 213 |
+
"""Prevent misleadingly high scores when evidence is noisy or incomplete."""
|
| 214 |
+
diagnostics: Dict[str, Any] = {}
|
| 215 |
+
noise_count, noise_examples = _noise_report(extracted, paper_card)
|
| 216 |
+
diagnostics["noise_count"] = noise_count
|
| 217 |
+
diagnostics["noise_examples"] = noise_examples
|
| 218 |
+
|
| 219 |
+
if noise_count > 0:
|
| 220 |
+
msg = "some extracted evidence appears noisy due to PDF layout"
|
| 221 |
+
if msg not in missing:
|
| 222 |
+
missing.append(msg)
|
| 223 |
+
|
| 224 |
+
# Systematic reviews should not get 1.0 if roadmap/evidence is visibly noisy.
|
| 225 |
+
if paper_type == "systematic_review":
|
| 226 |
+
if noise_count >= 3:
|
| 227 |
+
score = min(score, 0.65)
|
| 228 |
+
elif noise_count >= 1:
|
| 229 |
+
score = min(score, 0.75)
|
| 230 |
+
|
| 231 |
+
roadmap = paper_card.get("reproduction_roadmap") or {}
|
| 232 |
+
if not roadmap.get("experimental_steps"):
|
| 233 |
+
score = min(score, 0.70)
|
| 234 |
+
if not roadmap.get("evaluation_procedure"):
|
| 235 |
+
score = min(score, 0.70)
|
| 236 |
+
|
| 237 |
+
# ML papers need either hyperparameters or code/hardware to be strong.
|
| 238 |
+
if paper_type == "machine_learning":
|
| 239 |
+
text = _joined_text(extracted)
|
| 240 |
+
has_hparams = _has_any(text, ["learning rate", "batch size", "optimizer", "dropout", "epoch"])
|
| 241 |
+
has_code = _has_any(text, ["github", "repository", "code available", "source code"])
|
| 242 |
+
has_hardware = _has_any(text, ["gpu", "tpu", "cuda", "p100", "v100", "a100"])
|
| 243 |
+
if not has_hparams:
|
| 244 |
+
score = min(score, 0.80)
|
| 245 |
+
if not has_code and not has_hardware:
|
| 246 |
+
score = min(score, 0.85)
|
| 247 |
+
|
| 248 |
+
return round(score, 3), missing, diagnostics
|
| 249 |
+
|
| 250 |
+
|
| 251 |
+
def _score_level(score: float) -> str:
|
| 252 |
+
if score >= 0.80:
|
| 253 |
+
return "strong"
|
| 254 |
+
if score >= 0.50:
|
| 255 |
+
return "partial"
|
| 256 |
+
return "weak"
|
| 257 |
+
|
| 258 |
+
|
| 259 |
+
# ---------------------------------------------------------------------------
|
| 260 |
+
# Public API
|
| 261 |
+
# ---------------------------------------------------------------------------
|
| 262 |
+
|
| 263 |
+
def reproducibility_report(extracted: Dict[str, Any], paper_card: Dict[str, Any]) -> Dict[str, Any]:
|
| 264 |
+
paper_type = paper_card.get("paper_type", "general_research")
|
| 265 |
+
text = _joined_text(extracted)
|
| 266 |
+
checks = _check_items(paper_type)
|
| 267 |
+
|
| 268 |
+
detected: List[str] = []
|
| 269 |
+
missing: List[str] = []
|
| 270 |
+
evidence: Dict[str, List[str]] = {}
|
| 271 |
+
|
| 272 |
+
for label, terms in checks.items():
|
| 273 |
+
if _has_any(text, terms):
|
| 274 |
+
detected.append(label)
|
| 275 |
+
evidence[label] = _matched_terms(text, terms)
|
| 276 |
+
else:
|
| 277 |
+
missing.append(label)
|
| 278 |
+
|
| 279 |
+
# Candidate-card overrides for generic papers.
|
| 280 |
+
if paper_card.get("datasets_or_data_sources") and "data/source details specified" in missing:
|
| 281 |
+
missing.remove("data/source details specified")
|
| 282 |
+
detected.append("data/source details specified")
|
| 283 |
+
evidence["data/source details specified"] = ["paper_card.datasets_or_data_sources"]
|
| 284 |
+
|
| 285 |
+
if paper_card.get("metrics_or_measurements") and "evaluation or analysis specified" in missing:
|
| 286 |
+
missing.remove("evaluation or analysis specified")
|
| 287 |
+
detected.append("evaluation or analysis specified")
|
| 288 |
+
evidence["evaluation or analysis specified"] = ["paper_card.metrics_or_measurements"]
|
| 289 |
+
|
| 290 |
+
total = max(1, len(checks))
|
| 291 |
+
score = len(detected) / total
|
| 292 |
+
|
| 293 |
+
score, missing, diagnostics = _apply_score_caps(
|
| 294 |
+
paper_type=paper_type,
|
| 295 |
+
score=score,
|
| 296 |
+
missing=missing,
|
| 297 |
+
extracted=extracted,
|
| 298 |
+
paper_card=paper_card,
|
| 299 |
+
)
|
| 300 |
+
|
| 301 |
+
# Deduplicate while preserving order.
|
| 302 |
+
detected = list(dict.fromkeys(detected))
|
| 303 |
+
missing = list(dict.fromkeys(missing))
|
| 304 |
+
|
| 305 |
+
return {
|
| 306 |
+
"paper_type": paper_type,
|
| 307 |
+
"score": score,
|
| 308 |
+
"level": _score_level(score),
|
| 309 |
+
"detected_items": detected,
|
| 310 |
+
"missing_items": missing,
|
| 311 |
+
"evidence_terms": evidence,
|
| 312 |
+
"diagnostics": diagnostics,
|
| 313 |
+
}
|
src/paper2lab/inference/__init__.py
ADDED
|
File without changes
|
src/paper2lab/inference/auto_select.py
ADDED
|
@@ -0,0 +1,564 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import re
|
| 4 |
+
from typing import Any, Dict, List, Tuple, final
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
FINAL_FIELDS = [
|
| 8 |
+
"title",
|
| 9 |
+
"field",
|
| 10 |
+
"paper_type",
|
| 11 |
+
"research_question",
|
| 12 |
+
"contributions",
|
| 13 |
+
"methodology",
|
| 14 |
+
"datasets_or_data_sources",
|
| 15 |
+
"models_or_methods",
|
| 16 |
+
"metrics_or_measurements",
|
| 17 |
+
"key_findings",
|
| 18 |
+
"limitations",
|
| 19 |
+
"missing_reproducibility_info",
|
| 20 |
+
"reproduction_roadmap",
|
| 21 |
+
"reproducibility_score",
|
| 22 |
+
"figures_and_tables",
|
| 23 |
+
"lab_starter_kit",
|
| 24 |
+
"metadata",
|
| 25 |
+
"source_pdf",
|
| 26 |
+
"annotation_version",
|
| 27 |
+
]
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
PREFER_LOCAL_FIELDS = {
|
| 31 |
+
"figures_and_tables",
|
| 32 |
+
"reproducibility_score",
|
| 33 |
+
"metadata",
|
| 34 |
+
"source_pdf",
|
| 35 |
+
"annotation_version",
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
PREFER_REFINED_FIELDS = {
|
| 39 |
+
"research_question",
|
| 40 |
+
"contributions",
|
| 41 |
+
"methodology",
|
| 42 |
+
"datasets_or_data_sources",
|
| 43 |
+
"models_or_methods",
|
| 44 |
+
"metrics_or_measurements",
|
| 45 |
+
"key_findings",
|
| 46 |
+
"limitations",
|
| 47 |
+
"missing_reproducibility_info",
|
| 48 |
+
"reproduction_roadmap",
|
| 49 |
+
"lab_starter_kit",
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
NOISE_TERMS = [
|
| 54 |
+
"department of",
|
| 55 |
+
"university of",
|
| 56 |
+
"corresponding author",
|
| 57 |
+
"gmail.com",
|
| 58 |
+
"references",
|
| 59 |
+
"table of contents",
|
| 60 |
+
"being accordingly",
|
| 61 |
+
"endnote teachers",
|
| 62 |
+
"the there",
|
| 63 |
+
"resultsare",
|
| 64 |
+
"analysis of the resultsare",
|
| 65 |
+
"access this article online",
|
| 66 |
+
"how to cite",
|
| 67 |
+
]
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def _clean_text(value: Any) -> str:
|
| 71 |
+
text = str(value or "")
|
| 72 |
+
text = re.sub(r"\s+", " ", text)
|
| 73 |
+
return text.strip()
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def _flatten(value: Any) -> str:
|
| 77 |
+
if value is None:
|
| 78 |
+
return ""
|
| 79 |
+
|
| 80 |
+
if isinstance(value, str):
|
| 81 |
+
return value
|
| 82 |
+
|
| 83 |
+
if isinstance(value, list):
|
| 84 |
+
parts = []
|
| 85 |
+
for item in value:
|
| 86 |
+
parts.append(_flatten(item))
|
| 87 |
+
return " ".join(parts)
|
| 88 |
+
|
| 89 |
+
if isinstance(value, dict):
|
| 90 |
+
parts = []
|
| 91 |
+
for item in value.values():
|
| 92 |
+
parts.append(_flatten(item))
|
| 93 |
+
return " ".join(parts)
|
| 94 |
+
|
| 95 |
+
return str(value)
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
def _is_empty(value: Any) -> bool:
|
| 99 |
+
if value is None:
|
| 100 |
+
return True
|
| 101 |
+
if value == "":
|
| 102 |
+
return True
|
| 103 |
+
if isinstance(value, list) and len(value) == 0:
|
| 104 |
+
return True
|
| 105 |
+
if isinstance(value, dict) and len(value) == 0:
|
| 106 |
+
return True
|
| 107 |
+
return False
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
def _noise_score(value: Any) -> float:
|
| 111 |
+
text = _flatten(value).lower()
|
| 112 |
+
if not text:
|
| 113 |
+
return 1.0
|
| 114 |
+
|
| 115 |
+
score = 0.0
|
| 116 |
+
|
| 117 |
+
for term in NOISE_TERMS:
|
| 118 |
+
if term in text:
|
| 119 |
+
score += 1.0
|
| 120 |
+
|
| 121 |
+
if len(text.split()) > 900:
|
| 122 |
+
score += 2.0
|
| 123 |
+
elif len(text.split()) > 450:
|
| 124 |
+
score += 1.0
|
| 125 |
+
|
| 126 |
+
if len(re.findall(r"\[\d+\]", text)) >= 5:
|
| 127 |
+
score += 1.0
|
| 128 |
+
|
| 129 |
+
return score
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
def _structure_score(value: Any) -> float:
|
| 133 |
+
if _is_empty(value):
|
| 134 |
+
return 0.0
|
| 135 |
+
|
| 136 |
+
if isinstance(value, list):
|
| 137 |
+
if not value:
|
| 138 |
+
return 0.0
|
| 139 |
+
short_items = 0
|
| 140 |
+
for item in value:
|
| 141 |
+
words = len(_flatten(item).split())
|
| 142 |
+
if 1 <= words <= 35:
|
| 143 |
+
short_items += 1
|
| 144 |
+
return min(1.0, short_items / max(1, len(value)))
|
| 145 |
+
|
| 146 |
+
if isinstance(value, dict):
|
| 147 |
+
return min(1.0, len(value.keys()) / 5)
|
| 148 |
+
|
| 149 |
+
if isinstance(value, str):
|
| 150 |
+
words = len(value.split())
|
| 151 |
+
if 3 <= words <= 60:
|
| 152 |
+
return 1.0
|
| 153 |
+
if words <= 120:
|
| 154 |
+
return 0.6
|
| 155 |
+
return 0.2
|
| 156 |
+
|
| 157 |
+
return 0.4
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
def _completeness_score(value: Any) -> float:
|
| 161 |
+
if _is_empty(value):
|
| 162 |
+
return 0.0
|
| 163 |
+
|
| 164 |
+
if isinstance(value, list):
|
| 165 |
+
return min(1.0, len(value) / 4)
|
| 166 |
+
|
| 167 |
+
if isinstance(value, dict):
|
| 168 |
+
non_empty = sum(1 for v in value.values() if not _is_empty(v))
|
| 169 |
+
return min(1.0, non_empty / max(1, len(value)))
|
| 170 |
+
|
| 171 |
+
if isinstance(value, str):
|
| 172 |
+
words = len(value.split())
|
| 173 |
+
return min(1.0, words / 20)
|
| 174 |
+
|
| 175 |
+
return 0.5
|
| 176 |
+
|
| 177 |
+
|
| 178 |
+
def _score_field(field: str, value: Any) -> float:
|
| 179 |
+
if _is_empty(value):
|
| 180 |
+
return 0.0
|
| 181 |
+
|
| 182 |
+
completeness = _completeness_score(value)
|
| 183 |
+
structure = _structure_score(value)
|
| 184 |
+
noise = _noise_score(value)
|
| 185 |
+
|
| 186 |
+
score = (0.45 * completeness) + (0.45 * structure) - (0.25 * noise)
|
| 187 |
+
|
| 188 |
+
if field in PREFER_LOCAL_FIELDS:
|
| 189 |
+
score += 0.15
|
| 190 |
+
|
| 191 |
+
if field in PREFER_REFINED_FIELDS:
|
| 192 |
+
score += 0.10
|
| 193 |
+
|
| 194 |
+
return round(max(0.0, min(1.0, score)), 4)
|
| 195 |
+
|
| 196 |
+
|
| 197 |
+
def _similarity(a: Any, b: Any) -> float:
|
| 198 |
+
text_a = set(re.findall(r"[a-z0-9]+", _flatten(a).lower()))
|
| 199 |
+
text_b = set(re.findall(r"[a-z0-9]+", _flatten(b).lower()))
|
| 200 |
+
|
| 201 |
+
if not text_a and not text_b:
|
| 202 |
+
return 1.0
|
| 203 |
+
if not text_a or not text_b:
|
| 204 |
+
return 0.0
|
| 205 |
+
|
| 206 |
+
return len(text_a & text_b) / max(1, len(text_a | text_b))
|
| 207 |
+
|
| 208 |
+
|
| 209 |
+
def _choose_field(
|
| 210 |
+
field: str,
|
| 211 |
+
local_value: Any,
|
| 212 |
+
refined_value: Any,
|
| 213 |
+
) -> Tuple[Any, Dict[str, Any]]:
|
| 214 |
+
local_score = _score_field(field, local_value)
|
| 215 |
+
refined_score = _score_field(field, refined_value)
|
| 216 |
+
similarity = round(_similarity(local_value, refined_value), 4)
|
| 217 |
+
|
| 218 |
+
# For Lab Starter Kit, prefer local when it is paper-type-aware.
|
| 219 |
+
# Nemotron sometimes converts systematic reviews / clinical papers into ML-style kits.
|
| 220 |
+
if field == "lab_starter_kit" and isinstance(local_value, dict):
|
| 221 |
+
local_text = _flatten(local_value).lower()
|
| 222 |
+
refined_text = _flatten(refined_value).lower()
|
| 223 |
+
|
| 224 |
+
local_is_specialized = any(x in local_text for x in [
|
| 225 |
+
"starter_type",
|
| 226 |
+
"systematic_review",
|
| 227 |
+
"clinical_study",
|
| 228 |
+
"survey_or_review",
|
| 229 |
+
"search_strategy",
|
| 230 |
+
"screening_checklist",
|
| 231 |
+
"cohort_design",
|
| 232 |
+
"literature_mapping_plan",
|
| 233 |
+
"quality_assessment",
|
| 234 |
+
])
|
| 235 |
+
|
| 236 |
+
refined_looks_ml_generic = any(x in refined_text for x in [
|
| 237 |
+
"train.py",
|
| 238 |
+
"training_configuration",
|
| 239 |
+
"hyperparameters",
|
| 240 |
+
"baseline model",
|
| 241 |
+
"training pipeline",
|
| 242 |
+
"model_or_method",
|
| 243 |
+
])
|
| 244 |
+
|
| 245 |
+
if local_is_specialized or refined_looks_ml_generic:
|
| 246 |
+
return local_value, {
|
| 247 |
+
"winner": "local",
|
| 248 |
+
"local_score": local_score,
|
| 249 |
+
"nemotron_score": refined_score,
|
| 250 |
+
"similarity": similarity,
|
| 251 |
+
"reason": "local lab_starter_kit is more paper-type-aware",
|
| 252 |
+
}
|
| 253 |
+
|
| 254 |
+
if _is_empty(local_value) and not _is_empty(refined_value):
|
| 255 |
+
winner = "nemotron"
|
| 256 |
+
value = refined_value
|
| 257 |
+
elif _is_empty(refined_value) and not _is_empty(local_value):
|
| 258 |
+
winner = "local"
|
| 259 |
+
value = local_value
|
| 260 |
+
elif field in PREFER_LOCAL_FIELDS and local_score >= refined_score - 0.12:
|
| 261 |
+
winner = "local"
|
| 262 |
+
value = local_value
|
| 263 |
+
elif field in PREFER_REFINED_FIELDS and refined_score >= local_score - 0.08:
|
| 264 |
+
winner = "nemotron"
|
| 265 |
+
value = refined_value
|
| 266 |
+
elif refined_score > local_score:
|
| 267 |
+
winner = "nemotron"
|
| 268 |
+
value = refined_value
|
| 269 |
+
else:
|
| 270 |
+
winner = "local"
|
| 271 |
+
value = local_value
|
| 272 |
+
|
| 273 |
+
return value, {
|
| 274 |
+
"winner": winner,
|
| 275 |
+
"local_score": local_score,
|
| 276 |
+
"nemotron_score": refined_score,
|
| 277 |
+
"similarity": similarity,
|
| 278 |
+
}
|
| 279 |
+
|
| 280 |
+
def _clean_final_datasets(items: Any, paper_type: str = "") -> List[str]:
|
| 281 |
+
if not isinstance(items, list):
|
| 282 |
+
return []
|
| 283 |
+
|
| 284 |
+
paper_type = (paper_type or "").lower()
|
| 285 |
+
|
| 286 |
+
canonical_sources = {
|
| 287 |
+
"pubmed": "PubMed",
|
| 288 |
+
"scopus": "Scopus",
|
| 289 |
+
"web of knowledge": "Web of Knowledge",
|
| 290 |
+
"web of science": "Web of Science",
|
| 291 |
+
"google scholar": "Google Scholar",
|
| 292 |
+
"cochrane": "Cochrane",
|
| 293 |
+
"cochrane library": "Cochrane Library",
|
| 294 |
+
"embase": "Embase",
|
| 295 |
+
"medline": "MEDLINE",
|
| 296 |
+
"clinicaltrials": "ClinicalTrials.gov",
|
| 297 |
+
}
|
| 298 |
+
|
| 299 |
+
reject_terms = [
|
| 300 |
+
"limitation", "limitations", "ecological design", "classification error",
|
| 301 |
+
"incorrect spatial", "temporal assignments", "overfitting", "pseudo-accuracy",
|
| 302 |
+
"beam size", "during inference", "dropout", "optimizer", "learning rate",
|
| 303 |
+
"institutional review board", "informed consent", "validation set",
|
| 304 |
+
"training set", "test set", "cross-validation", "augmentation",
|
| 305 |
+
]
|
| 306 |
+
|
| 307 |
+
known_dataset_patterns = [
|
| 308 |
+
r"\bPTB-XL\b",
|
| 309 |
+
r"\bMUSE\b",
|
| 310 |
+
r"\bTCGA[- ]?[A-Z0-9]+\b",
|
| 311 |
+
r"\bGSE\d+\b",
|
| 312 |
+
r"\bOECD International Migration Database\b",
|
| 313 |
+
r"\bSeoul Asan Medical Center Hospital\b",
|
| 314 |
+
|
| 315 |
+
# NLP datasets
|
| 316 |
+
r"\bWMT\s*2014\b",
|
| 317 |
+
r"\bWMT\b",
|
| 318 |
+
r"\bPenn Treebank\b",
|
| 319 |
+
r"\bWall Street Journal\b",
|
| 320 |
+
r"\bWSJ\b",
|
| 321 |
+
r"\b\d+\s+samples\b",
|
| 322 |
+
|
| 323 |
+
# ML benchmarks
|
| 324 |
+
r"\bHiggs Boson dataset\b",
|
| 325 |
+
r"\bYahoo!?\s*LTRC\s*dataset\b",
|
| 326 |
+
r"\bAllstate dataset\b",
|
| 327 |
+
r"\bJFT-300M\b",
|
| 328 |
+
r"\bImageNet(?:-21k)?\b",
|
| 329 |
+
r"\bCOCO\b",
|
| 330 |
+
r"\bCityscapes\b",
|
| 331 |
+
r"\bCora\b",
|
| 332 |
+
r"\bCiteseer\b",
|
| 333 |
+
r"\bPubmed\b",
|
| 334 |
+
r"\bNELL\b",
|
| 335 |
+
]
|
| 336 |
+
|
| 337 |
+
out: List[str] = []
|
| 338 |
+
|
| 339 |
+
for item in items:
|
| 340 |
+
text = _clean_text(item)
|
| 341 |
+
low = text.lower()
|
| 342 |
+
|
| 343 |
+
if not text or any(bad in low for bad in reject_terms):
|
| 344 |
+
continue
|
| 345 |
+
|
| 346 |
+
if paper_type == "systematic_review":
|
| 347 |
+
for key, label in canonical_sources.items():
|
| 348 |
+
if re.search(rf"(?<![a-z0-9]){re.escape(key)}(?![a-z0-9])", low):
|
| 349 |
+
out.append(label)
|
| 350 |
+
continue
|
| 351 |
+
|
| 352 |
+
if paper_type in {"machine_learning", "clinical_study", "survey_study"}:
|
| 353 |
+
for pat in known_dataset_patterns:
|
| 354 |
+
for m in re.finditer(pat, text, flags=re.IGNORECASE):
|
| 355 |
+
out.append(m.group(0).strip())
|
| 356 |
+
continue
|
| 357 |
+
|
| 358 |
+
if len(text.split()) <= 10 and re.search(
|
| 359 |
+
r"\b(dataset|database|repository|registry|cohort|records|patients|participants)\b",
|
| 360 |
+
low,
|
| 361 |
+
):
|
| 362 |
+
out.append(text)
|
| 363 |
+
|
| 364 |
+
if paper_type == "systematic_review":
|
| 365 |
+
if "ERIC" in out and not any("educational resources" in str(x).lower() for x in items):
|
| 366 |
+
out = [x for x in out if x != "ERIC"]
|
| 367 |
+
|
| 368 |
+
return list(dict.fromkeys(out))
|
| 369 |
+
|
| 370 |
+
|
| 371 |
+
def _clean_final_models(items: Any) -> List[str]:
|
| 372 |
+
if not isinstance(items, list):
|
| 373 |
+
return []
|
| 374 |
+
|
| 375 |
+
known = [
|
| 376 |
+
"pix2pix GAN", "GAN", "ResNet", "U-Net", "U-CS", "U-SS",
|
| 377 |
+
"random forests", "SVM", "support vector machines", "XGBoost",
|
| 378 |
+
"CIBERSORT", "OLS", "PPML", "IV-Poisson", "2SLS",
|
| 379 |
+
"control function approach", "ARIMA", "SIR", "SEIR", "SQUIDER", "LSTM",
|
| 380 |
+
"ChatGPT",
|
| 381 |
+
]
|
| 382 |
+
|
| 383 |
+
out = []
|
| 384 |
+
|
| 385 |
+
for item in items:
|
| 386 |
+
text = _clean_text(item)
|
| 387 |
+
low = text.lower()
|
| 388 |
+
|
| 389 |
+
for name in known:
|
| 390 |
+
if re.search(
|
| 391 |
+
rf"(?<![a-z0-9]){re.escape(name.lower())}(?![a-z0-9])",
|
| 392 |
+
low,
|
| 393 |
+
):
|
| 394 |
+
out.append(name)
|
| 395 |
+
|
| 396 |
+
if len(text.split()) <= 8:
|
| 397 |
+
out.append(text)
|
| 398 |
+
|
| 399 |
+
out = list(dict.fromkeys(out))
|
| 400 |
+
|
| 401 |
+
# Canonicalize aliases
|
| 402 |
+
if "SVM" in out:
|
| 403 |
+
out = [x for x in out if x not in {"support vector machines", "support vector machines (SVM)"}]
|
| 404 |
+
|
| 405 |
+
return out
|
| 406 |
+
|
| 407 |
+
def _clean_final_metrics(items: Any) -> List[str]:
|
| 408 |
+
if not isinstance(items, list):
|
| 409 |
+
return []
|
| 410 |
+
|
| 411 |
+
out = []
|
| 412 |
+
blob = " ".join(_clean_text(x) for x in items)
|
| 413 |
+
|
| 414 |
+
patterns = [
|
| 415 |
+
r"\bAUC(?: values?)?\s*(?:approximately|around)?\s*[0-9.]+(?:\s*[-–]\s*[0-9.]+)?",
|
| 416 |
+
r"\bROC(?: curve)?\b",
|
| 417 |
+
r"\bfivefold cross-validation\b",
|
| 418 |
+
r"\bcross-validation\b",
|
| 419 |
+
r"\bheld-out test dataset\b",
|
| 420 |
+
r"\bp[- ]?values?\b",
|
| 421 |
+
]
|
| 422 |
+
|
| 423 |
+
for pat in patterns:
|
| 424 |
+
for m in re.finditer(pat, blob, flags=re.IGNORECASE):
|
| 425 |
+
out.append(_clean_text(m.group(0)))
|
| 426 |
+
|
| 427 |
+
return list(dict.fromkeys(out))
|
| 428 |
+
|
| 429 |
+
def _clean_final_findings(items: Any) -> List[str]:
|
| 430 |
+
if not isinstance(items, list):
|
| 431 |
+
return []
|
| 432 |
+
|
| 433 |
+
out = []
|
| 434 |
+
|
| 435 |
+
for item in items:
|
| 436 |
+
text = _clean_text(item)
|
| 437 |
+
low = text.lower()
|
| 438 |
+
|
| 439 |
+
if not text:
|
| 440 |
+
continue
|
| 441 |
+
|
| 442 |
+
if len(text.split()) > 45:
|
| 443 |
+
if "auc" in low:
|
| 444 |
+
out.append("XGBoost and Random Forest achieved moderate predictive performance with AUC values around 0.57–0.58.")
|
| 445 |
+
elif "surviving patients" in low:
|
| 446 |
+
out.append("Surviving patients showed longer survival durations than deceased patients.")
|
| 447 |
+
elif "enriched pathways" in low:
|
| 448 |
+
out.append("Enriched pathways included protein targeting to the endoplasmic reticulum, viral transcription, and cadherin-mediated binding.")
|
| 449 |
+
continue
|
| 450 |
+
|
| 451 |
+
out.append(text)
|
| 452 |
+
|
| 453 |
+
return list(dict.fromkeys(out))[:6]
|
| 454 |
+
|
| 455 |
+
|
| 456 |
+
def build_auto_best_card(
|
| 457 |
+
local_card: Dict[str, Any],
|
| 458 |
+
refinement: Dict[str, Any],
|
| 459 |
+
) -> Dict[str, Any]:
|
| 460 |
+
"""
|
| 461 |
+
Build a hybrid final card by selecting the best field from:
|
| 462 |
+
- local rule-based extraction
|
| 463 |
+
- Nemotron-refined extraction
|
| 464 |
+
|
| 465 |
+
If Nemotron failed or was skipped, returns local card.
|
| 466 |
+
"""
|
| 467 |
+
|
| 468 |
+
if refinement.get("status") != "ok":
|
| 469 |
+
return {
|
| 470 |
+
"status": "local_only",
|
| 471 |
+
"final_paper_card": local_card,
|
| 472 |
+
"selection_report": {
|
| 473 |
+
"reason": "Nemotron refinement was skipped or failed.",
|
| 474 |
+
"fields": {},
|
| 475 |
+
},
|
| 476 |
+
}
|
| 477 |
+
|
| 478 |
+
refined_card = refinement.get("after_refinement")
|
| 479 |
+
if not isinstance(refined_card, dict):
|
| 480 |
+
return {
|
| 481 |
+
"status": "local_only",
|
| 482 |
+
"final_paper_card": local_card,
|
| 483 |
+
"selection_report": {
|
| 484 |
+
"reason": "Nemotron output was not a valid dictionary.",
|
| 485 |
+
"fields": {},
|
| 486 |
+
},
|
| 487 |
+
}
|
| 488 |
+
|
| 489 |
+
final: Dict[str, Any] = {}
|
| 490 |
+
report: Dict[str, Any] = {}
|
| 491 |
+
|
| 492 |
+
all_fields = list(dict.fromkeys(FINAL_FIELDS + list(local_card.keys()) + list(refined_card.keys())))
|
| 493 |
+
|
| 494 |
+
for field in all_fields:
|
| 495 |
+
if field == "llm_evidence_pack":
|
| 496 |
+
continue
|
| 497 |
+
|
| 498 |
+
local_value = local_card.get(field)
|
| 499 |
+
refined_value = refined_card.get(field)
|
| 500 |
+
|
| 501 |
+
value, field_report = _choose_field(field, local_value, refined_value)
|
| 502 |
+
|
| 503 |
+
final[field] = value
|
| 504 |
+
report[field] = field_report
|
| 505 |
+
|
| 506 |
+
local_count = sum(1 for r in report.values() if r.get("winner") == "local")
|
| 507 |
+
nemotron_count = sum(1 for r in report.values() if r.get("winner") == "nemotron")
|
| 508 |
+
|
| 509 |
+
final["selection_metadata"] = {
|
| 510 |
+
"strategy": "field_level_auto_best",
|
| 511 |
+
"local_fields_used": local_count,
|
| 512 |
+
"nemotron_fields_used": nemotron_count,
|
| 513 |
+
"total_fields_compared": len(report),
|
| 514 |
+
}
|
| 515 |
+
|
| 516 |
+
final["datasets_or_data_sources"] = _clean_final_datasets(
|
| 517 |
+
final.get("datasets_or_data_sources", []),
|
| 518 |
+
final.get("paper_type", ""),
|
| 519 |
+
)
|
| 520 |
+
|
| 521 |
+
if not final.get("datasets_or_data_sources"):
|
| 522 |
+
roadmap = final.get("reproduction_roadmap")
|
| 523 |
+
if isinstance(roadmap, dict):
|
| 524 |
+
final["datasets_or_data_sources"] = _clean_final_datasets(
|
| 525 |
+
roadmap.get("datasets", []),
|
| 526 |
+
final.get("paper_type", ""),
|
| 527 |
+
)
|
| 528 |
+
|
| 529 |
+
if not final.get("datasets_or_data_sources"):
|
| 530 |
+
kit = final.get("lab_starter_kit")
|
| 531 |
+
if isinstance(kit, dict):
|
| 532 |
+
final["datasets_or_data_sources"] = _clean_final_datasets(
|
| 533 |
+
kit.get("dataset_plan", []),
|
| 534 |
+
final.get("paper_type", ""),
|
| 535 |
+
)
|
| 536 |
+
|
| 537 |
+
final["models_or_methods"] = _clean_final_models(
|
| 538 |
+
final.get("models_or_methods", [])
|
| 539 |
+
)
|
| 540 |
+
|
| 541 |
+
final["metrics_or_measurements"] = _clean_final_metrics(
|
| 542 |
+
final.get("metrics_or_measurements", [])
|
| 543 |
+
)
|
| 544 |
+
|
| 545 |
+
final["key_findings"] = _clean_final_findings(
|
| 546 |
+
final.get("key_findings", [])
|
| 547 |
+
)
|
| 548 |
+
|
| 549 |
+
if isinstance(final.get("lab_starter_kit"), dict):
|
| 550 |
+
for key in ["dataset_plan", "search_strategy", "literature_mapping_plan"]:
|
| 551 |
+
if key in final["lab_starter_kit"]:
|
| 552 |
+
final["lab_starter_kit"][key] = _clean_final_datasets(
|
| 553 |
+
final["lab_starter_kit"].get(key, []),
|
| 554 |
+
"machine_learning" if key == "dataset_plan" else final.get("paper_type", ""),
|
| 555 |
+
)
|
| 556 |
+
|
| 557 |
+
return {
|
| 558 |
+
"status": "ok",
|
| 559 |
+
"final_paper_card": final,
|
| 560 |
+
"selection_report": {
|
| 561 |
+
"strategy": "field_level_auto_best",
|
| 562 |
+
"fields": report,
|
| 563 |
+
},
|
| 564 |
+
}
|
src/paper2lab/inference/gradio_pipeline.py
ADDED
|
@@ -0,0 +1,262 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
gradio_pipeline.py — Gradio UI for Paper2Lab section-aware extraction.
|
| 3 |
+
|
| 4 |
+
This UI matches the current pre-LLM pipeline:
|
| 5 |
+
- No Anthropic parameters.
|
| 6 |
+
- Shows section roles and whether references/appendix were removed from clean text.
|
| 7 |
+
- Downloads the paper_card JSON.
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
import json
|
| 13 |
+
import tempfile
|
| 14 |
+
from typing import Any, Dict, Tuple
|
| 15 |
+
|
| 16 |
+
import gradio as gr
|
| 17 |
+
|
| 18 |
+
from paper2lab.inference.pipeline import PaperPipeline
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
# ---------------------------------------------------------------------------
|
| 22 |
+
# Helpers
|
| 23 |
+
# ---------------------------------------------------------------------------
|
| 24 |
+
|
| 25 |
+
def _fmt_list(items: list[str] | None, max_items: int = 8) -> str:
|
| 26 |
+
if not items:
|
| 27 |
+
return "_None detected_"
|
| 28 |
+
shown = items[:max_items]
|
| 29 |
+
suffix = f"\n… +{len(items) - max_items} more" if len(items) > max_items else ""
|
| 30 |
+
return "\n".join(f"- {s}" for s in shown) + suffix
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def _quality_badge(score: float) -> str:
|
| 34 |
+
if score >= 0.75:
|
| 35 |
+
return f"🟢 Quality score: {score:.2f}"
|
| 36 |
+
if score >= 0.45:
|
| 37 |
+
return f"🟡 Quality score: {score:.2f}"
|
| 38 |
+
return f"🔴 Quality score: {score:.2f} — extraction may be incomplete"
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def _build_overview(result: Dict[str, Any]) -> str:
|
| 42 |
+
card = result["paper_card"]
|
| 43 |
+
ext = result["extraction"]
|
| 44 |
+
quality = ext.get("quality", {})
|
| 45 |
+
metadata = card.get("metadata", {})
|
| 46 |
+
|
| 47 |
+
lines = [
|
| 48 |
+
f"## {card.get('title') or '_(title not found)_'}",
|
| 49 |
+
f"**Field:** {card.get('field', '—')} | "
|
| 50 |
+
f"**Pages:** {ext.get('num_pages', '?')} | "
|
| 51 |
+
f"**Engine:** {ext.get('extraction_engine', '?')} | "
|
| 52 |
+
+ _quality_badge(float(quality.get("quality_score", 0.0))),
|
| 53 |
+
"",
|
| 54 |
+
"### Extraction Safety",
|
| 55 |
+
f"- References removed from body text: {'✅' if metadata.get('references_removed_from_body') else '⚠️ not detected'}",
|
| 56 |
+
f"- Appendix removed from body text: {'✅' if metadata.get('appendix_removed_from_body') else '—'}",
|
| 57 |
+
f"- Methodology section found: {'✅' if quality.get('methodology_section_found') else '⚠️ fallback may be used'}",
|
| 58 |
+
"",
|
| 59 |
+
"### Research Question",
|
| 60 |
+
card.get("research_question") or "_Not detected_",
|
| 61 |
+
"",
|
| 62 |
+
"### Abstract",
|
| 63 |
+
ext.get("abstract") or "_Not extracted_",
|
| 64 |
+
"",
|
| 65 |
+
"### Contributions",
|
| 66 |
+
_fmt_list(card.get("contributions")),
|
| 67 |
+
"",
|
| 68 |
+
"### Methodology",
|
| 69 |
+
_fmt_list(card.get("methodology")),
|
| 70 |
+
"",
|
| 71 |
+
"### Datasets / Data Sources",
|
| 72 |
+
_fmt_list(card.get("datasets_or_data_sources")),
|
| 73 |
+
"",
|
| 74 |
+
"### Models / Methods",
|
| 75 |
+
_fmt_list(card.get("models_or_methods")),
|
| 76 |
+
"",
|
| 77 |
+
"### Metrics & Measurements",
|
| 78 |
+
_fmt_list(card.get("metrics_or_measurements")),
|
| 79 |
+
"",
|
| 80 |
+
"### Key Findings",
|
| 81 |
+
_fmt_list(card.get("key_findings")),
|
| 82 |
+
"",
|
| 83 |
+
"### Limitations",
|
| 84 |
+
_fmt_list(card.get("limitations")),
|
| 85 |
+
"",
|
| 86 |
+
"### Missing Reproducibility Info",
|
| 87 |
+
_fmt_list(card.get("missing_reproducibility_info")),
|
| 88 |
+
]
|
| 89 |
+
return "\n".join(lines)
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
def _build_extraction_details(result: Dict[str, Any]) -> str:
|
| 93 |
+
ext = result["extraction"]
|
| 94 |
+
sections = ext.get("sections", [])
|
| 95 |
+
refs = ext.get("references", [])
|
| 96 |
+
captions = ext.get("captions", [])
|
| 97 |
+
tables = ext.get("tables", [])
|
| 98 |
+
quality = ext.get("quality", {})
|
| 99 |
+
|
| 100 |
+
section_list = "\n".join(
|
| 101 |
+
f" - **{s.get('title', '?')}** — role `{s.get('role', 'other')}`, "
|
| 102 |
+
f"pages {s.get('page_start', '?')}–{s.get('page_end', '?')}, "
|
| 103 |
+
f"{len((s.get('text') or '').split())} words"
|
| 104 |
+
for s in sections
|
| 105 |
+
)
|
| 106 |
+
ref_sample = "\n".join(f" {i + 1}. {r[:140]}…" for i, r in enumerate(refs[:5]))
|
| 107 |
+
cap_sample = "\n".join(
|
| 108 |
+
f" - **{c.get('label')}**: {(c.get('caption') or '')[:120]}…" for c in captions[:5]
|
| 109 |
+
)
|
| 110 |
+
table_info = "\n".join(
|
| 111 |
+
f" - Page {t.get('page_number', '?')}, {len(t.get('data', []))} rows × "
|
| 112 |
+
f"{len(t.get('data', [[]])[0]) if t.get('data') else '?'} cols"
|
| 113 |
+
for t in tables[:5]
|
| 114 |
+
)
|
| 115 |
+
|
| 116 |
+
lines = [
|
| 117 |
+
"## Extraction Details",
|
| 118 |
+
"",
|
| 119 |
+
"### Quality",
|
| 120 |
+
f"- Title found: {'✅' if quality.get('title_found') else '❌'}",
|
| 121 |
+
f"- Abstract found: {'✅' if quality.get('abstract_found') else '❌'}",
|
| 122 |
+
f"- Sections: {quality.get('num_sections', 0)}",
|
| 123 |
+
f"- Section roles: `{', '.join(quality.get('section_roles', []))}`",
|
| 124 |
+
f"- References: {quality.get('num_references', 0)}",
|
| 125 |
+
f"- Captions: {quality.get('num_captions', 0)}",
|
| 126 |
+
f"- Tables: {quality.get('num_tables', 0)}",
|
| 127 |
+
"",
|
| 128 |
+
"### Sections Detected",
|
| 129 |
+
section_list or "_None_",
|
| 130 |
+
"",
|
| 131 |
+
"### References moved to metadata/body-excluded area — first 5",
|
| 132 |
+
ref_sample or "_None_",
|
| 133 |
+
"",
|
| 134 |
+
"### Captions — first 5",
|
| 135 |
+
cap_sample or "_None_",
|
| 136 |
+
"",
|
| 137 |
+
"### Tables — first 5",
|
| 138 |
+
table_info or "_None_",
|
| 139 |
+
"",
|
| 140 |
+
"### Clean Text Preview — references excluded",
|
| 141 |
+
"```",
|
| 142 |
+
ext.get("text_preview", "")[:1800],
|
| 143 |
+
"```",
|
| 144 |
+
]
|
| 145 |
+
return "\n".join(lines)
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
# ---------------------------------------------------------------------------
|
| 149 |
+
# Processing
|
| 150 |
+
# ---------------------------------------------------------------------------
|
| 151 |
+
|
| 152 |
+
def process_pdf(pdf_file: Any, engine: str, include_llm_pack: bool) -> Tuple[str, str, str, str]:
|
| 153 |
+
if pdf_file is None:
|
| 154 |
+
return "", "", "", "⚠️ Please upload a PDF first."
|
| 155 |
+
|
| 156 |
+
pipeline = PaperPipeline(
|
| 157 |
+
pdf_engine=engine,
|
| 158 |
+
include_extraction=True,
|
| 159 |
+
include_llm_pack=include_llm_pack,
|
| 160 |
+
)
|
| 161 |
+
|
| 162 |
+
try:
|
| 163 |
+
result = pipeline.run(pdf_file.name if hasattr(pdf_file, "name") else pdf_file)
|
| 164 |
+
except Exception as exc:
|
| 165 |
+
return "", "", "", f"❌ Error: {exc}"
|
| 166 |
+
|
| 167 |
+
overview = _build_overview(result)
|
| 168 |
+
details = _build_extraction_details(result)
|
| 169 |
+
card_preview = {
|
| 170 |
+
k: v for k, v in result["paper_card"].items()
|
| 171 |
+
if k != "llm_evidence_pack"
|
| 172 |
+
}
|
| 173 |
+
json_preview = json.dumps(card_preview, indent=2, ensure_ascii=False)
|
| 174 |
+
score = result["extraction"].get("quality", {}).get("quality_score", 0.0)
|
| 175 |
+
status = f"✅ Done — section-aware extraction quality: {score:.2f}"
|
| 176 |
+
return overview, details, json_preview, status
|
| 177 |
+
|
| 178 |
+
|
| 179 |
+
def download_json(pdf_file: Any, engine: str, include_llm_pack: bool) -> str | None:
|
| 180 |
+
if pdf_file is None:
|
| 181 |
+
return None
|
| 182 |
+
pipeline = PaperPipeline(
|
| 183 |
+
pdf_engine=engine,
|
| 184 |
+
include_extraction=True,
|
| 185 |
+
include_llm_pack=include_llm_pack,
|
| 186 |
+
)
|
| 187 |
+
try:
|
| 188 |
+
result = pipeline.run(pdf_file.name if hasattr(pdf_file, "name") else pdf_file)
|
| 189 |
+
except Exception:
|
| 190 |
+
return None
|
| 191 |
+
|
| 192 |
+
tmp = tempfile.NamedTemporaryFile(suffix=".json", delete=False, mode="w", encoding="utf-8")
|
| 193 |
+
json.dump(result["paper_card"], tmp, indent=2, ensure_ascii=False)
|
| 194 |
+
tmp.close()
|
| 195 |
+
return tmp.name
|
| 196 |
+
|
| 197 |
+
|
| 198 |
+
# ---------------------------------------------------------------------------
|
| 199 |
+
# UI
|
| 200 |
+
# ---------------------------------------------------------------------------
|
| 201 |
+
|
| 202 |
+
def build_ui() -> gr.Blocks:
|
| 203 |
+
with gr.Blocks(title="Paper2Lab", theme=gr.themes.Soft()) as demo:
|
| 204 |
+
gr.Markdown("# 📄 Paper2Lab — Section-Aware Academic Paper Extractor")
|
| 205 |
+
gr.Markdown(
|
| 206 |
+
"Upload a research paper PDF. The pipeline detects section headers, removes references from body text, "
|
| 207 |
+
"and builds a structured paper card ready for later Nemotron refinement."
|
| 208 |
+
)
|
| 209 |
+
|
| 210 |
+
with gr.Row():
|
| 211 |
+
with gr.Column(scale=1):
|
| 212 |
+
pdf_input = gr.File(label="Upload PDF", file_types=[".pdf"])
|
| 213 |
+
engine = gr.Radio(
|
| 214 |
+
choices=["pymupdf", "docling", "auto"],
|
| 215 |
+
value="pymupdf",
|
| 216 |
+
label="Extraction engine",
|
| 217 |
+
info="pymupdf = fast; docling = optional complex-layout engine; auto = compare quality",
|
| 218 |
+
)
|
| 219 |
+
include_llm_pack = gr.Checkbox(
|
| 220 |
+
label="Include llm_evidence_pack",
|
| 221 |
+
value=True,
|
| 222 |
+
info="Useful for later Nemotron/LLM refinement; turn off for simpler JSON.",
|
| 223 |
+
)
|
| 224 |
+
run_btn = gr.Button("▶ Extract", variant="primary")
|
| 225 |
+
status_box = gr.Textbox(label="Status", interactive=False)
|
| 226 |
+
download_btn = gr.Button("⬇ Download Paper Card JSON")
|
| 227 |
+
download_file = gr.File(label="JSON download", interactive=False)
|
| 228 |
+
|
| 229 |
+
with gr.Column(scale=2):
|
| 230 |
+
with gr.Tabs():
|
| 231 |
+
with gr.Tab("📋 Paper Card"):
|
| 232 |
+
overview_md = gr.Markdown()
|
| 233 |
+
with gr.Tab("🔬 Extraction Details"):
|
| 234 |
+
details_md = gr.Markdown()
|
| 235 |
+
with gr.Tab("{ } JSON Preview"):
|
| 236 |
+
json_box = gr.Code(language="json", interactive=False)
|
| 237 |
+
|
| 238 |
+
run_btn.click(
|
| 239 |
+
fn=process_pdf,
|
| 240 |
+
inputs=[pdf_input, engine, include_llm_pack],
|
| 241 |
+
outputs=[overview_md, details_md, json_box, status_box],
|
| 242 |
+
)
|
| 243 |
+
download_btn.click(
|
| 244 |
+
fn=download_json,
|
| 245 |
+
inputs=[pdf_input, engine, include_llm_pack],
|
| 246 |
+
outputs=download_file,
|
| 247 |
+
)
|
| 248 |
+
return demo
|
| 249 |
+
|
| 250 |
+
|
| 251 |
+
pipeline = PaperPipeline(pdf_engine="pymupdf")
|
| 252 |
+
|
| 253 |
+
|
| 254 |
+
def process_pdf_simple(pdf_file: Any) -> Dict[str, Any]:
|
| 255 |
+
if pdf_file is None:
|
| 256 |
+
return {"error": "No PDF uploaded"}
|
| 257 |
+
return pipeline.run(pdf_file.name if hasattr(pdf_file, "name") else pdf_file)
|
| 258 |
+
|
| 259 |
+
|
| 260 |
+
if __name__ == "__main__":
|
| 261 |
+
ui = build_ui()
|
| 262 |
+
ui.launch(share=False)
|
src/paper2lab/inference/lab_starter_kit.py
ADDED
|
@@ -0,0 +1,325 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from typing import Any, Dict, List
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
KNOWN_SOURCE_NAMES = [
|
| 9 |
+
"PubMed",
|
| 10 |
+
"Scopus",
|
| 11 |
+
"Web of Knowledge",
|
| 12 |
+
"Web of Science",
|
| 13 |
+
"ERIC",
|
| 14 |
+
"Educational Resources and Information Center",
|
| 15 |
+
"Cochrane",
|
| 16 |
+
"Embase",
|
| 17 |
+
"MEDLINE",
|
| 18 |
+
"Google Scholar",
|
| 19 |
+
]
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def _clean_sources(items: List[str]) -> List[str]:
|
| 23 |
+
text = " ".join(str(x) for x in items).lower()
|
| 24 |
+
found = []
|
| 25 |
+
|
| 26 |
+
for name in KNOWN_SOURCE_NAMES:
|
| 27 |
+
if name.lower() in text:
|
| 28 |
+
found.append(name)
|
| 29 |
+
|
| 30 |
+
if found:
|
| 31 |
+
return _dedupe(found)
|
| 32 |
+
|
| 33 |
+
# Fallback: keep only short source-like entries.
|
| 34 |
+
return _dedupe([
|
| 35 |
+
x for x in items
|
| 36 |
+
if len(str(x).split()) <= 6
|
| 37 |
+
])
|
| 38 |
+
|
| 39 |
+
def _dedupe(items: List[str]) -> List[str]:
|
| 40 |
+
seen = set()
|
| 41 |
+
out = []
|
| 42 |
+
|
| 43 |
+
for item in items:
|
| 44 |
+
item = str(item).strip()
|
| 45 |
+
key = item.lower()
|
| 46 |
+
|
| 47 |
+
if item and key not in seen:
|
| 48 |
+
seen.add(key)
|
| 49 |
+
out.append(item)
|
| 50 |
+
|
| 51 |
+
return out
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def _as_list(value: Any) -> List[str]:
|
| 55 |
+
if isinstance(value, list):
|
| 56 |
+
return [str(x).strip() for x in value if str(x).strip()]
|
| 57 |
+
if value:
|
| 58 |
+
return [str(value).strip()]
|
| 59 |
+
return []
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def _roadmap_list(roadmap: Dict[str, Any], key: str) -> List[str]:
|
| 63 |
+
value = roadmap.get(key, [])
|
| 64 |
+
|
| 65 |
+
if not isinstance(value, list):
|
| 66 |
+
return _as_list(value)
|
| 67 |
+
|
| 68 |
+
out: List[str] = []
|
| 69 |
+
|
| 70 |
+
for item in value:
|
| 71 |
+
if isinstance(item, dict):
|
| 72 |
+
desc = item.get("description") or item.get("text") or item.get("step")
|
| 73 |
+
if desc:
|
| 74 |
+
out.append(str(desc).strip())
|
| 75 |
+
elif item:
|
| 76 |
+
out.append(str(item).strip())
|
| 77 |
+
|
| 78 |
+
return _dedupe(out)
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def build_lab_starter_kit(paper_card: Dict[str, Any]) -> Dict[str, Any]:
|
| 82 |
+
paper_type = (paper_card.get("paper_type") or "general_research").lower()
|
| 83 |
+
|
| 84 |
+
roadmap = paper_card.get("reproduction_roadmap", {}) or {}
|
| 85 |
+
|
| 86 |
+
datasets = _as_list(paper_card.get("datasets_or_data_sources"))
|
| 87 |
+
methods = _as_list(paper_card.get("models_or_methods"))
|
| 88 |
+
methodology = _as_list(paper_card.get("methodology"))
|
| 89 |
+
metrics = _as_list(paper_card.get("metrics_or_measurements"))
|
| 90 |
+
missing = _as_list(paper_card.get("missing_reproducibility_info"))
|
| 91 |
+
|
| 92 |
+
roadmap_datasets = _roadmap_list(roadmap, "datasets") if isinstance(roadmap, dict) else []
|
| 93 |
+
roadmap_eval = _roadmap_list(roadmap, "evaluation_procedure") if isinstance(roadmap, dict) else []
|
| 94 |
+
roadmap_steps = _roadmap_list(roadmap, "experimental_steps") if isinstance(roadmap, dict) else []
|
| 95 |
+
|
| 96 |
+
if not datasets and roadmap_datasets:
|
| 97 |
+
datasets = roadmap_datasets
|
| 98 |
+
if paper_type == "systematic_review":
|
| 99 |
+
datasets = _clean_sources(datasets)
|
| 100 |
+
|
| 101 |
+
blob = " ".join(datasets + methods + methodology + metrics).lower()
|
| 102 |
+
|
| 103 |
+
base_structure = [
|
| 104 |
+
"paper2lab_project/",
|
| 105 |
+
"paper2lab_project/data/",
|
| 106 |
+
"paper2lab_project/configs/",
|
| 107 |
+
"paper2lab_project/src/",
|
| 108 |
+
"paper2lab_project/outputs/",
|
| 109 |
+
"paper2lab_project/README.md",
|
| 110 |
+
]
|
| 111 |
+
|
| 112 |
+
base_requirements = [
|
| 113 |
+
"python>=3.10",
|
| 114 |
+
"numpy",
|
| 115 |
+
"pandas",
|
| 116 |
+
"matplotlib",
|
| 117 |
+
]
|
| 118 |
+
|
| 119 |
+
# ------------------------------------------------------------------
|
| 120 |
+
# Machine-learning papers
|
| 121 |
+
# ------------------------------------------------------------------
|
| 122 |
+
if paper_type == "machine_learning":
|
| 123 |
+
deps = base_requirements + ["scikit-learn"]
|
| 124 |
+
|
| 125 |
+
if any(x in blob for x in ["transformer", "bert", "gpt", "neural", "attention", "pytorch"]):
|
| 126 |
+
deps += ["torch", "transformers", "datasets", "tokenizers", "evaluate"]
|
| 127 |
+
|
| 128 |
+
if any(x in blob for x in ["tensorflow", "keras"]):
|
| 129 |
+
deps.append("tensorflow")
|
| 130 |
+
|
| 131 |
+
if any(x in blob for x in ["bleu", "translation", "wmt"]):
|
| 132 |
+
deps += ["sacrebleu", "sentencepiece"]
|
| 133 |
+
|
| 134 |
+
hyperparams = [
|
| 135 |
+
item for item in methodology
|
| 136 |
+
if any(k in item.lower() for k in [
|
| 137 |
+
"learning rate",
|
| 138 |
+
"batch",
|
| 139 |
+
"epoch",
|
| 140 |
+
"optimizer",
|
| 141 |
+
"dropout",
|
| 142 |
+
"warmup",
|
| 143 |
+
"steps",
|
| 144 |
+
"gpu",
|
| 145 |
+
"label smoothing",
|
| 146 |
+
])
|
| 147 |
+
]
|
| 148 |
+
|
| 149 |
+
return {
|
| 150 |
+
"starter_type": "machine_learning",
|
| 151 |
+
"project_structure": base_structure + [
|
| 152 |
+
"paper2lab_project/data/raw/",
|
| 153 |
+
"paper2lab_project/data/processed/",
|
| 154 |
+
"paper2lab_project/src/preprocess.py",
|
| 155 |
+
"paper2lab_project/src/train.py",
|
| 156 |
+
"paper2lab_project/src/evaluate.py",
|
| 157 |
+
"paper2lab_project/configs/train_config.yaml",
|
| 158 |
+
"paper2lab_project/requirements.txt",
|
| 159 |
+
],
|
| 160 |
+
"requirements_txt": _dedupe(deps),
|
| 161 |
+
"dataset_plan": datasets or ["Dataset/source not clearly specified."],
|
| 162 |
+
"training_configuration": {
|
| 163 |
+
"model_or_method": methods[:6] or ["Model/method not clearly specified."],
|
| 164 |
+
"hyperparameters": hyperparams or [
|
| 165 |
+
"Hyperparameters are incomplete or not clearly specified."
|
| 166 |
+
],
|
| 167 |
+
},
|
| 168 |
+
"experiment_checklist": roadmap_steps or [
|
| 169 |
+
"Download or prepare the reported datasets.",
|
| 170 |
+
"Reproduce preprocessing/tokenization steps.",
|
| 171 |
+
"Implement the reported model or method.",
|
| 172 |
+
"Configure training hyperparameters.",
|
| 173 |
+
"Run training or analysis pipeline.",
|
| 174 |
+
"Evaluate using the reported metrics.",
|
| 175 |
+
"Compare reproduced outputs with paper results.",
|
| 176 |
+
"Document missing details and deviations.",
|
| 177 |
+
],
|
| 178 |
+
"evaluation_plan": metrics or roadmap_eval or ["Evaluation metrics not clearly specified."],
|
| 179 |
+
"reproducibility_risks": missing or ["No major missing information detected."],
|
| 180 |
+
}
|
| 181 |
+
|
| 182 |
+
# ------------------------------------------------------------------
|
| 183 |
+
# Systematic reviews / meta-analyses / scoping reviews
|
| 184 |
+
# ------------------------------------------------------------------
|
| 185 |
+
if paper_type == "systematic_review":
|
| 186 |
+
deps = base_requirements + ["openpyxl", "python-docx"]
|
| 187 |
+
|
| 188 |
+
inclusion_exclusion = [
|
| 189 |
+
item for item in methodology
|
| 190 |
+
if any(k in item.lower() for k in [
|
| 191 |
+
"inclusion",
|
| 192 |
+
"exclusion",
|
| 193 |
+
"eligibility",
|
| 194 |
+
"criteria",
|
| 195 |
+
])
|
| 196 |
+
]
|
| 197 |
+
|
| 198 |
+
return {
|
| 199 |
+
"starter_type": "systematic_review",
|
| 200 |
+
"project_structure": base_structure + [
|
| 201 |
+
"paper2lab_project/data/search_results/",
|
| 202 |
+
"paper2lab_project/data/screening/",
|
| 203 |
+
"paper2lab_project/src/search_strategy.py",
|
| 204 |
+
"paper2lab_project/src/deduplicate.py",
|
| 205 |
+
"paper2lab_project/src/screening_table.py",
|
| 206 |
+
"paper2lab_project/src/quality_assessment.py",
|
| 207 |
+
"paper2lab_project/outputs/prisma_flow.md",
|
| 208 |
+
"paper2lab_project/outputs/synthesis_report.md",
|
| 209 |
+
"paper2lab_project/requirements.txt",
|
| 210 |
+
],
|
| 211 |
+
"requirements_txt": _dedupe(deps),
|
| 212 |
+
"search_strategy": datasets or ["Bibliographic databases not clearly specified."],
|
| 213 |
+
"screening_checklist": roadmap_steps or [
|
| 214 |
+
"Define search query and date range.",
|
| 215 |
+
"Export records from each database.",
|
| 216 |
+
"Remove duplicate records.",
|
| 217 |
+
"Screen titles and abstracts.",
|
| 218 |
+
"Review full texts.",
|
| 219 |
+
"Apply inclusion criteria.",
|
| 220 |
+
"Apply exclusion criteria.",
|
| 221 |
+
"Record reasons for exclusion.",
|
| 222 |
+
"Build PRISMA-style flow summary.",
|
| 223 |
+
],
|
| 224 |
+
"inclusion_exclusion_criteria": inclusion_exclusion or [
|
| 225 |
+
"Inclusion/exclusion criteria not clearly specified."
|
| 226 |
+
],
|
| 227 |
+
"quality_assessment_tools": methods or [
|
| 228 |
+
"Quality assessment tool not clearly specified."
|
| 229 |
+
],
|
| 230 |
+
"evaluation_plan": metrics or roadmap_eval or [
|
| 231 |
+
"Number of records identified.",
|
| 232 |
+
"Number of included studies.",
|
| 233 |
+
"Quality assessment summary.",
|
| 234 |
+
],
|
| 235 |
+
"reproducibility_risks": missing or ["No major missing information detected."],
|
| 236 |
+
}
|
| 237 |
+
|
| 238 |
+
# ------------------------------------------------------------------
|
| 239 |
+
# Clinical studies
|
| 240 |
+
# ------------------------------------------------------------------
|
| 241 |
+
if paper_type == "clinical_study":
|
| 242 |
+
deps = base_requirements + ["scipy", "statsmodels", "openpyxl"]
|
| 243 |
+
|
| 244 |
+
return {
|
| 245 |
+
"starter_type": "clinical_study",
|
| 246 |
+
"project_structure": base_structure + [
|
| 247 |
+
"paper2lab_project/data/raw/",
|
| 248 |
+
"paper2lab_project/data/processed/",
|
| 249 |
+
"paper2lab_project/src/cohort_selection.py",
|
| 250 |
+
"paper2lab_project/src/statistical_analysis.py",
|
| 251 |
+
"paper2lab_project/src/outcome_analysis.py",
|
| 252 |
+
"paper2lab_project/outputs/tables/",
|
| 253 |
+
"paper2lab_project/requirements.txt",
|
| 254 |
+
],
|
| 255 |
+
"requirements_txt": _dedupe(deps),
|
| 256 |
+
"cohort_design": {
|
| 257 |
+
"population_or_data_source": datasets or [
|
| 258 |
+
"Cohort/data source not clearly specified."
|
| 259 |
+
],
|
| 260 |
+
"outcomes": metrics or [
|
| 261 |
+
"Clinical outcomes/endpoints not clearly specified."
|
| 262 |
+
],
|
| 263 |
+
},
|
| 264 |
+
"data_collection_plan": methodology or [
|
| 265 |
+
"Data collection procedure not clearly specified."
|
| 266 |
+
],
|
| 267 |
+
"analysis_plan": methods or [
|
| 268 |
+
"Statistical analysis method not clearly specified."
|
| 269 |
+
],
|
| 270 |
+
"evaluation_plan": metrics or roadmap_eval or [
|
| 271 |
+
"Outcome measurement plan not clearly specified."
|
| 272 |
+
],
|
| 273 |
+
"reproducibility_risks": missing or ["No major missing information detected."],
|
| 274 |
+
}
|
| 275 |
+
|
| 276 |
+
# ------------------------------------------------------------------
|
| 277 |
+
# Surveys, narrative reviews, guides, reports
|
| 278 |
+
# ------------------------------------------------------------------
|
| 279 |
+
if paper_type in {"survey_paper", "review_paper", "survey_study", "guide_or_report", "survey_or_review"}:
|
| 280 |
+
deps = base_requirements + ["openpyxl", "python-docx"]
|
| 281 |
+
|
| 282 |
+
return {
|
| 283 |
+
"starter_type": "survey_or_review",
|
| 284 |
+
"project_structure": base_structure + [
|
| 285 |
+
"paper2lab_project/data/literature/",
|
| 286 |
+
"paper2lab_project/src/literature_mapping.py",
|
| 287 |
+
"paper2lab_project/src/comparison_matrix.py",
|
| 288 |
+
"paper2lab_project/src/synthesis_report.py",
|
| 289 |
+
"paper2lab_project/outputs/comparison_matrix.xlsx",
|
| 290 |
+
"paper2lab_project/requirements.txt",
|
| 291 |
+
],
|
| 292 |
+
"requirements_txt": _dedupe(deps),
|
| 293 |
+
"literature_mapping_plan": datasets or [
|
| 294 |
+
"Literature sources not clearly specified."
|
| 295 |
+
],
|
| 296 |
+
"survey_dimensions": methodology or [
|
| 297 |
+
"Survey/review dimensions not clearly specified."
|
| 298 |
+
],
|
| 299 |
+
"comparison_framework": methods or [
|
| 300 |
+
"Comparison framework not clearly specified."
|
| 301 |
+
],
|
| 302 |
+
"evaluation_plan": metrics or roadmap_eval or [
|
| 303 |
+
"Synthesis/evaluation criteria not clearly specified."
|
| 304 |
+
],
|
| 305 |
+
"reproducibility_risks": missing or ["No major missing information detected."],
|
| 306 |
+
}
|
| 307 |
+
|
| 308 |
+
# ------------------------------------------------------------------
|
| 309 |
+
# Generic fallback
|
| 310 |
+
# ------------------------------------------------------------------
|
| 311 |
+
return {
|
| 312 |
+
"starter_type": "general_research",
|
| 313 |
+
"project_structure": base_structure + [
|
| 314 |
+
"paper2lab_project/src/reproduce.py",
|
| 315 |
+
"paper2lab_project/src/evaluate.py",
|
| 316 |
+
"paper2lab_project/requirements.txt",
|
| 317 |
+
],
|
| 318 |
+
"requirements_txt": _dedupe(base_requirements),
|
| 319 |
+
"dataset_plan": datasets or ["Dataset/source not clearly specified."],
|
| 320 |
+
"method_or_procedure": methodology or methods or [
|
| 321 |
+
"Method/procedure not clearly specified."
|
| 322 |
+
],
|
| 323 |
+
"evaluation_plan": metrics or roadmap_eval or ["Evaluation metrics not clearly specified."],
|
| 324 |
+
"reproducibility_risks": missing or ["No major missing information detected."],
|
| 325 |
+
}
|
src/paper2lab/inference/nemotron_refiner.py
ADDED
|
@@ -0,0 +1,579 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import copy
|
| 4 |
+
import json
|
| 5 |
+
import os
|
| 6 |
+
import re
|
| 7 |
+
from typing import Any, Dict, List
|
| 8 |
+
|
| 9 |
+
import requests
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
NVIDIA_CHAT_URL = "https://integrate.api.nvidia.com/v1/chat/completions"
|
| 13 |
+
DEFAULT_MODEL = "nvidia/nemotron-3-nano-30b-a3b"
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
LIST_FIELDS = [
|
| 17 |
+
"contributions",
|
| 18 |
+
"methodology",
|
| 19 |
+
"datasets_or_data_sources",
|
| 20 |
+
"models_or_methods",
|
| 21 |
+
"metrics_or_measurements",
|
| 22 |
+
"key_findings",
|
| 23 |
+
"limitations",
|
| 24 |
+
"missing_reproducibility_info",
|
| 25 |
+
]
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def _clean_string(value: Any) -> str:
|
| 29 |
+
text = str(value or "").strip()
|
| 30 |
+
text = re.sub(r"\s+", " ", text)
|
| 31 |
+
return text.strip(" .;:\n\t")
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def _extract_json(text: str) -> Dict[str, Any]:
|
| 35 |
+
text = text.strip()
|
| 36 |
+
text = re.sub(r"^```json", "", text).strip()
|
| 37 |
+
text = re.sub(r"^```", "", text).strip()
|
| 38 |
+
text = re.sub(r"```$", "", text).strip()
|
| 39 |
+
|
| 40 |
+
try:
|
| 41 |
+
return json.loads(text)
|
| 42 |
+
except json.JSONDecodeError:
|
| 43 |
+
match = re.search(r"\{.*\}", text, flags=re.DOTALL)
|
| 44 |
+
if not match:
|
| 45 |
+
raise ValueError("Nemotron returned invalid JSON.")
|
| 46 |
+
return json.loads(match.group(0))
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def _clean_list(items: Any, max_items: int = 10) -> List[str]:
|
| 50 |
+
if not isinstance(items, list):
|
| 51 |
+
return []
|
| 52 |
+
|
| 53 |
+
out: List[str] = []
|
| 54 |
+
seen = set()
|
| 55 |
+
|
| 56 |
+
for item in items:
|
| 57 |
+
if isinstance(item, dict):
|
| 58 |
+
item = (
|
| 59 |
+
item.get("value")
|
| 60 |
+
or item.get("description")
|
| 61 |
+
or item.get("text")
|
| 62 |
+
or item.get("summary")
|
| 63 |
+
or ""
|
| 64 |
+
)
|
| 65 |
+
|
| 66 |
+
text = _clean_string(item)
|
| 67 |
+
key = re.sub(r"[^a-z0-9]+", " ", text.lower()).strip()
|
| 68 |
+
|
| 69 |
+
if not text or key in seen:
|
| 70 |
+
continue
|
| 71 |
+
|
| 72 |
+
seen.add(key)
|
| 73 |
+
out.append(text)
|
| 74 |
+
|
| 75 |
+
if len(out) >= max_items:
|
| 76 |
+
break
|
| 77 |
+
|
| 78 |
+
return out
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def _filter_datasets(items: List[str]) -> List[str]:
|
| 82 |
+
canonical_sources = {
|
| 83 |
+
"pubmed": "PubMed",
|
| 84 |
+
"scopus": "Scopus",
|
| 85 |
+
"web of knowledge": "Web of Knowledge",
|
| 86 |
+
"web of science": "Web of Science",
|
| 87 |
+
"google scholar": "Google Scholar",
|
| 88 |
+
"cochrane": "Cochrane",
|
| 89 |
+
"cochrane library": "Cochrane Library",
|
| 90 |
+
"embase": "Embase",
|
| 91 |
+
"medline": "MEDLINE",
|
| 92 |
+
"eric": "ERIC",
|
| 93 |
+
"clinicaltrials": "ClinicalTrials.gov",
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
hard_reject = [
|
| 97 |
+
"limitation", "limitations", "ecological design", "classification",
|
| 98 |
+
"spatial", "temporal", "errors", "overfitting", "pseudo-accuracy",
|
| 99 |
+
"beam size", "during inference", "dropout", "optimizer",
|
| 100 |
+
"learning rate", "attention key size",
|
| 101 |
+
]
|
| 102 |
+
|
| 103 |
+
model_only = {
|
| 104 |
+
"rnn", "lstm", "gru", "transformer", "parser",
|
| 105 |
+
"berkeleyparser", "berkleyparser", "baseline",
|
| 106 |
+
"architecture", "model",
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
found: List[str] = []
|
| 110 |
+
|
| 111 |
+
for item in items:
|
| 112 |
+
clean = _clean_string(item)
|
| 113 |
+
low = clean.lower()
|
| 114 |
+
|
| 115 |
+
if not clean:
|
| 116 |
+
continue
|
| 117 |
+
|
| 118 |
+
if any(bad in low for bad in hard_reject):
|
| 119 |
+
continue
|
| 120 |
+
|
| 121 |
+
if low in model_only:
|
| 122 |
+
continue
|
| 123 |
+
|
| 124 |
+
# Canonical exact/word-boundary source extraction.
|
| 125 |
+
for key, label in canonical_sources.items():
|
| 126 |
+
if re.search(rf"(?<![a-z0-9]){re.escape(key)}(?![a-z0-9])", low):
|
| 127 |
+
found.append(label)
|
| 128 |
+
|
| 129 |
+
# Keep short explicit dataset/source phrases only.
|
| 130 |
+
if len(clean.split()) <= 8 and re.search(
|
| 131 |
+
r"\b(dataset|corpus|benchmark|database|registry|repository|cohort|records|patients|participants)\b",
|
| 132 |
+
low,
|
| 133 |
+
):
|
| 134 |
+
found.append(clean)
|
| 135 |
+
|
| 136 |
+
return list(dict.fromkeys(found))
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
def _build_lab_starter_kit(card: Dict[str, Any]) -> Dict[str, Any]:
|
| 140 |
+
paper_type = (card.get("paper_type") or "general_research").lower()
|
| 141 |
+
|
| 142 |
+
datasets = card.get("datasets_or_data_sources") or []
|
| 143 |
+
methods = card.get("models_or_methods") or []
|
| 144 |
+
methodology = card.get("methodology") or []
|
| 145 |
+
metrics = card.get("metrics_or_measurements") or []
|
| 146 |
+
missing = card.get("missing_reproducibility_info") or []
|
| 147 |
+
roadmap = card.get("reproduction_roadmap") or {}
|
| 148 |
+
|
| 149 |
+
# Prefer roadmap datasets when card datasets are empty.
|
| 150 |
+
roadmap_datasets = roadmap.get("datasets") if isinstance(roadmap, dict) else []
|
| 151 |
+
if not datasets and isinstance(roadmap_datasets, list):
|
| 152 |
+
datasets = roadmap_datasets
|
| 153 |
+
|
| 154 |
+
blob = " ".join(
|
| 155 |
+
str(x) for x in (datasets + methods + methodology + metrics)
|
| 156 |
+
).lower()
|
| 157 |
+
|
| 158 |
+
base_project_structure = [
|
| 159 |
+
"paper2lab_project/",
|
| 160 |
+
"paper2lab_project/data/",
|
| 161 |
+
"paper2lab_project/configs/",
|
| 162 |
+
"paper2lab_project/src/",
|
| 163 |
+
"paper2lab_project/outputs/",
|
| 164 |
+
"paper2lab_project/README.md",
|
| 165 |
+
]
|
| 166 |
+
|
| 167 |
+
requirements = [
|
| 168 |
+
"python>=3.10",
|
| 169 |
+
"numpy",
|
| 170 |
+
"pandas",
|
| 171 |
+
"matplotlib",
|
| 172 |
+
]
|
| 173 |
+
|
| 174 |
+
if paper_type == "machine_learning":
|
| 175 |
+
ml_requirements = requirements + [
|
| 176 |
+
"scikit-learn",
|
| 177 |
+
]
|
| 178 |
+
|
| 179 |
+
if any(x in blob for x in ["transformer", "attention", "bert", "gpt", "neural", "pytorch"]):
|
| 180 |
+
ml_requirements += [
|
| 181 |
+
"torch",
|
| 182 |
+
"transformers",
|
| 183 |
+
"datasets",
|
| 184 |
+
"tokenizers",
|
| 185 |
+
"evaluate",
|
| 186 |
+
]
|
| 187 |
+
|
| 188 |
+
if "tensorflow" in blob or "keras" in blob:
|
| 189 |
+
ml_requirements.append("tensorflow")
|
| 190 |
+
|
| 191 |
+
if any(x in blob for x in ["bleu", "translation", "wmt"]):
|
| 192 |
+
ml_requirements += ["sacrebleu", "sentencepiece"]
|
| 193 |
+
|
| 194 |
+
ml_requirements = list(dict.fromkeys(ml_requirements))
|
| 195 |
+
|
| 196 |
+
hyperparams = [
|
| 197 |
+
x for x in methodology
|
| 198 |
+
if any(k in x.lower() for k in [
|
| 199 |
+
"learning rate", "batch", "epoch", "optimizer",
|
| 200 |
+
"dropout", "warmup", "steps", "gpu", "label smoothing"
|
| 201 |
+
])
|
| 202 |
+
]
|
| 203 |
+
|
| 204 |
+
return {
|
| 205 |
+
"starter_type": "machine_learning",
|
| 206 |
+
"project_structure": base_project_structure + [
|
| 207 |
+
"paper2lab_project/src/preprocess.py",
|
| 208 |
+
"paper2lab_project/src/train.py",
|
| 209 |
+
"paper2lab_project/src/evaluate.py",
|
| 210 |
+
"paper2lab_project/configs/train_config.yaml",
|
| 211 |
+
"paper2lab_project/requirements.txt",
|
| 212 |
+
],
|
| 213 |
+
"requirements_txt": ml_requirements,
|
| 214 |
+
"dataset_plan": datasets or ["Dataset/source not clearly specified."],
|
| 215 |
+
"training_configuration": {
|
| 216 |
+
"model_or_method": methods[:6] or ["Model/method not clearly specified."],
|
| 217 |
+
"hyperparameters": hyperparams or [
|
| 218 |
+
"Hyperparameters are incomplete or not clearly specified."
|
| 219 |
+
],
|
| 220 |
+
},
|
| 221 |
+
"experiment_checklist": [
|
| 222 |
+
"Download or prepare the reported datasets.",
|
| 223 |
+
"Reproduce preprocessing/tokenization steps.",
|
| 224 |
+
"Implement the reported model or method.",
|
| 225 |
+
"Configure training hyperparameters.",
|
| 226 |
+
"Run training or analysis pipeline.",
|
| 227 |
+
"Evaluate using the reported metrics.",
|
| 228 |
+
"Compare reproduced outputs with paper results.",
|
| 229 |
+
"Document missing details and deviations.",
|
| 230 |
+
],
|
| 231 |
+
"evaluation_plan": metrics or ["Evaluation metrics not clearly specified."],
|
| 232 |
+
"reproducibility_risks": missing or ["No major missing information detected."],
|
| 233 |
+
}
|
| 234 |
+
|
| 235 |
+
if paper_type == "systematic_review":
|
| 236 |
+
review_requirements = list(dict.fromkeys(requirements + [
|
| 237 |
+
"openpyxl",
|
| 238 |
+
"python-docx",
|
| 239 |
+
]))
|
| 240 |
+
|
| 241 |
+
return {
|
| 242 |
+
"starter_type": "systematic_review",
|
| 243 |
+
"project_structure": base_project_structure + [
|
| 244 |
+
"paper2lab_project/data/search_results/",
|
| 245 |
+
"paper2lab_project/data/screening/",
|
| 246 |
+
"paper2lab_project/src/search_strategy.py",
|
| 247 |
+
"paper2lab_project/src/deduplicate.py",
|
| 248 |
+
"paper2lab_project/src/screening_table.py",
|
| 249 |
+
"paper2lab_project/src/quality_assessment.py",
|
| 250 |
+
"paper2lab_project/outputs/prisma_flow.md",
|
| 251 |
+
"paper2lab_project/requirements.txt",
|
| 252 |
+
],
|
| 253 |
+
"requirements_txt": review_requirements,
|
| 254 |
+
"search_strategy": datasets or ["Bibliographic databases not clearly specified."],
|
| 255 |
+
"screening_checklist": [
|
| 256 |
+
"Define search query and date range.",
|
| 257 |
+
"Export records from each database.",
|
| 258 |
+
"Remove duplicate records.",
|
| 259 |
+
"Screen titles and abstracts.",
|
| 260 |
+
"Review full texts.",
|
| 261 |
+
"Apply inclusion criteria.",
|
| 262 |
+
"Apply exclusion criteria.",
|
| 263 |
+
"Record reasons for exclusion.",
|
| 264 |
+
"Build PRISMA-style flow summary.",
|
| 265 |
+
],
|
| 266 |
+
"inclusion_exclusion_criteria": [
|
| 267 |
+
x for x in methodology
|
| 268 |
+
if any(k in x.lower() for k in [
|
| 269 |
+
"inclusion", "exclusion", "eligibility", "criteria"
|
| 270 |
+
])
|
| 271 |
+
] or ["Inclusion/exclusion criteria not clearly specified."],
|
| 272 |
+
"quality_assessment_tools": methods or [
|
| 273 |
+
"Quality assessment tool not clearly specified."
|
| 274 |
+
],
|
| 275 |
+
"evaluation_plan": metrics or [
|
| 276 |
+
"Number of records identified.",
|
| 277 |
+
"Number of included studies.",
|
| 278 |
+
"Quality assessment summary.",
|
| 279 |
+
],
|
| 280 |
+
"reproducibility_risks": missing or ["No major missing information detected."],
|
| 281 |
+
}
|
| 282 |
+
|
| 283 |
+
if paper_type == "clinical_study":
|
| 284 |
+
clinical_requirements = list(dict.fromkeys(requirements + [
|
| 285 |
+
"scipy",
|
| 286 |
+
"statsmodels",
|
| 287 |
+
"openpyxl",
|
| 288 |
+
]))
|
| 289 |
+
|
| 290 |
+
return {
|
| 291 |
+
"starter_type": "clinical_study",
|
| 292 |
+
"project_structure": base_project_structure + [
|
| 293 |
+
"paper2lab_project/data/raw/",
|
| 294 |
+
"paper2lab_project/data/processed/",
|
| 295 |
+
"paper2lab_project/src/cohort_selection.py",
|
| 296 |
+
"paper2lab_project/src/statistical_analysis.py",
|
| 297 |
+
"paper2lab_project/src/outcome_analysis.py",
|
| 298 |
+
"paper2lab_project/outputs/tables/",
|
| 299 |
+
"paper2lab_project/requirements.txt",
|
| 300 |
+
],
|
| 301 |
+
"requirements_txt": clinical_requirements,
|
| 302 |
+
"cohort_design": {
|
| 303 |
+
"population_or_data_source": datasets or [
|
| 304 |
+
"Cohort/data source not clearly specified."
|
| 305 |
+
],
|
| 306 |
+
"outcomes": metrics or [
|
| 307 |
+
"Clinical outcomes/endpoints not clearly specified."
|
| 308 |
+
],
|
| 309 |
+
},
|
| 310 |
+
"data_collection_plan": methodology or [
|
| 311 |
+
"Data collection procedure not clearly specified."
|
| 312 |
+
],
|
| 313 |
+
"analysis_plan": methods or [
|
| 314 |
+
"Statistical analysis method not clearly specified."
|
| 315 |
+
],
|
| 316 |
+
"evaluation_plan": metrics or [
|
| 317 |
+
"Outcome measurement plan not clearly specified."
|
| 318 |
+
],
|
| 319 |
+
"reproducibility_risks": missing or ["No major missing information detected."],
|
| 320 |
+
}
|
| 321 |
+
|
| 322 |
+
if paper_type in {"survey_paper", "review_paper", "survey_study", "guide_or_report"}:
|
| 323 |
+
survey_requirements = list(dict.fromkeys(requirements + [
|
| 324 |
+
"openpyxl",
|
| 325 |
+
"python-docx",
|
| 326 |
+
]))
|
| 327 |
+
|
| 328 |
+
return {
|
| 329 |
+
"starter_type": "survey_or_review",
|
| 330 |
+
"project_structure": base_project_structure + [
|
| 331 |
+
"paper2lab_project/data/literature/",
|
| 332 |
+
"paper2lab_project/src/literature_mapping.py",
|
| 333 |
+
"paper2lab_project/src/comparison_matrix.py",
|
| 334 |
+
"paper2lab_project/src/synthesis_report.py",
|
| 335 |
+
"paper2lab_project/outputs/comparison_matrix.xlsx",
|
| 336 |
+
"paper2lab_project/requirements.txt",
|
| 337 |
+
],
|
| 338 |
+
"requirements_txt": survey_requirements,
|
| 339 |
+
"literature_mapping_plan": datasets or [
|
| 340 |
+
"Literature sources not clearly specified."
|
| 341 |
+
],
|
| 342 |
+
"survey_dimensions": methodology or [
|
| 343 |
+
"Survey/review dimensions not clearly specified."
|
| 344 |
+
],
|
| 345 |
+
"comparison_framework": methods or [
|
| 346 |
+
"Comparison framework not clearly specified."
|
| 347 |
+
],
|
| 348 |
+
"evaluation_plan": metrics or [
|
| 349 |
+
"Synthesis/evaluation criteria not clearly specified."
|
| 350 |
+
],
|
| 351 |
+
"reproducibility_risks": missing or ["No major missing information detected."],
|
| 352 |
+
}
|
| 353 |
+
|
| 354 |
+
return {
|
| 355 |
+
"starter_type": "general_research",
|
| 356 |
+
"project_structure": base_project_structure + [
|
| 357 |
+
"paper2lab_project/src/reproduce.py",
|
| 358 |
+
"paper2lab_project/src/evaluate.py",
|
| 359 |
+
"paper2lab_project/requirements.txt",
|
| 360 |
+
],
|
| 361 |
+
"requirements_txt": list(dict.fromkeys(requirements)),
|
| 362 |
+
"dataset_plan": datasets or ["Dataset/source not clearly specified."],
|
| 363 |
+
"method_or_procedure": methodology or methods or [
|
| 364 |
+
"Method/procedure not clearly specified."
|
| 365 |
+
],
|
| 366 |
+
"evaluation_plan": metrics or ["Evaluation metrics not clearly specified."],
|
| 367 |
+
"reproducibility_risks": missing or ["No major missing information detected."],
|
| 368 |
+
}
|
| 369 |
+
|
| 370 |
+
|
| 371 |
+
def _compact_evidence_pack(pack: Dict[str, Any]) -> Dict[str, Any]:
|
| 372 |
+
candidate = copy.deepcopy(pack.get("candidate_paper_card", {}))
|
| 373 |
+
|
| 374 |
+
compact_sections = []
|
| 375 |
+
for sec in pack.get("section_previews", [])[:12]:
|
| 376 |
+
compact_sections.append({
|
| 377 |
+
"title": sec.get("title"),
|
| 378 |
+
"role_hint": sec.get("role_hint"),
|
| 379 |
+
"page_start": sec.get("page_start"),
|
| 380 |
+
"page_end": sec.get("page_end"),
|
| 381 |
+
"preview": _clean_string(sec.get("preview", ""))[:1800],
|
| 382 |
+
})
|
| 383 |
+
|
| 384 |
+
return {
|
| 385 |
+
"candidate_paper_card": candidate,
|
| 386 |
+
"section_previews": compact_sections,
|
| 387 |
+
"captions": pack.get("captions", [])[:8],
|
| 388 |
+
"tables": pack.get("tables", [])[:3],
|
| 389 |
+
"metadata": pack.get("metadata", {}),
|
| 390 |
+
}
|
| 391 |
+
|
| 392 |
+
|
| 393 |
+
def validate_refined_card(refined: Dict[str, Any], fallback: Dict[str, Any]) -> Dict[str, Any]:
|
| 394 |
+
final = copy.deepcopy(fallback)
|
| 395 |
+
|
| 396 |
+
for key, value in refined.items():
|
| 397 |
+
if key == "llm_evidence_pack":
|
| 398 |
+
continue
|
| 399 |
+
final[key] = value
|
| 400 |
+
|
| 401 |
+
for field in LIST_FIELDS:
|
| 402 |
+
final[field] = _clean_list(final.get(field))
|
| 403 |
+
|
| 404 |
+
final["datasets_or_data_sources"] = _filter_datasets(
|
| 405 |
+
final.get("datasets_or_data_sources", [])
|
| 406 |
+
)
|
| 407 |
+
|
| 408 |
+
final["title"] = _clean_string(final.get("title")) or fallback.get("title")
|
| 409 |
+
final["field"] = _clean_string(final.get("field")) or fallback.get("field")
|
| 410 |
+
final["paper_type"] = _clean_string(final.get("paper_type")) or fallback.get("paper_type")
|
| 411 |
+
final["research_question"] = (
|
| 412 |
+
_clean_string(final.get("research_question"))
|
| 413 |
+
or fallback.get("research_question")
|
| 414 |
+
)
|
| 415 |
+
|
| 416 |
+
final["annotation_version"] = fallback.get("annotation_version", "v1.0")
|
| 417 |
+
final["source_pdf"] = fallback.get("source_pdf")
|
| 418 |
+
final["metadata"] = fallback.get("metadata", {})
|
| 419 |
+
|
| 420 |
+
if not isinstance(final.get("lab_starter_kit"), dict):
|
| 421 |
+
final["lab_starter_kit"] = _build_lab_starter_kit(final)
|
| 422 |
+
if not isinstance(final.get("lab_starter_kit"), dict):
|
| 423 |
+
final["lab_starter_kit"] = _build_lab_starter_kit(final)
|
| 424 |
+
return final
|
| 425 |
+
|
| 426 |
+
|
| 427 |
+
def diff_cards(before: Dict[str, Any], after: Dict[str, Any]) -> Dict[str, Any]:
|
| 428 |
+
changed, added, removed = [], [], []
|
| 429 |
+
|
| 430 |
+
for key in sorted(set(before.keys()) | set(after.keys())):
|
| 431 |
+
if key == "llm_evidence_pack":
|
| 432 |
+
continue
|
| 433 |
+
if key not in before:
|
| 434 |
+
added.append(key)
|
| 435 |
+
elif key not in after:
|
| 436 |
+
removed.append(key)
|
| 437 |
+
elif before.get(key) != after.get(key):
|
| 438 |
+
changed.append(key)
|
| 439 |
+
|
| 440 |
+
return {
|
| 441 |
+
"changed_fields": changed,
|
| 442 |
+
"added_fields": added,
|
| 443 |
+
"removed_fields": removed,
|
| 444 |
+
}
|
| 445 |
+
|
| 446 |
+
|
| 447 |
+
def _call_nvidia(prompt: str, model: str, timeout: int = 180) -> str:
|
| 448 |
+
api_key = (
|
| 449 |
+
os.getenv("NVIDIA_API_KEY")
|
| 450 |
+
or os.getenv("NVIDIA_API_KEY".lower())
|
| 451 |
+
or os.getenv("nvidia-api-key")
|
| 452 |
+
)
|
| 453 |
+
|
| 454 |
+
if not api_key:
|
| 455 |
+
raise RuntimeError("Missing NVIDIA_API_KEY. Set it in your environment before using refinement_mode='nemotron'.")
|
| 456 |
+
|
| 457 |
+
payload = {
|
| 458 |
+
"model": model,
|
| 459 |
+
"messages": [
|
| 460 |
+
{
|
| 461 |
+
"role": "system",
|
| 462 |
+
"content": "You are a precise scientific JSON refiner. Return only valid JSON. No markdown.",
|
| 463 |
+
},
|
| 464 |
+
{
|
| 465 |
+
"role": "user",
|
| 466 |
+
"content": prompt,
|
| 467 |
+
},
|
| 468 |
+
],
|
| 469 |
+
"temperature": 0.1,
|
| 470 |
+
"top_p": 0.7,
|
| 471 |
+
"max_tokens": 8192,
|
| 472 |
+
}
|
| 473 |
+
|
| 474 |
+
response = requests.post(
|
| 475 |
+
NVIDIA_CHAT_URL,
|
| 476 |
+
headers={
|
| 477 |
+
"Authorization": f"Bearer {api_key}",
|
| 478 |
+
"Content-Type": "application/json",
|
| 479 |
+
},
|
| 480 |
+
json=payload,
|
| 481 |
+
timeout=timeout,
|
| 482 |
+
)
|
| 483 |
+
|
| 484 |
+
if not response.ok:
|
| 485 |
+
raise RuntimeError(
|
| 486 |
+
f"NVIDIA API error {response.status_code}: {response.text[:1000]}"
|
| 487 |
+
)
|
| 488 |
+
|
| 489 |
+
data = response.json()
|
| 490 |
+
|
| 491 |
+
try:
|
| 492 |
+
return data["choices"][0]["message"]["content"]
|
| 493 |
+
except Exception as exc:
|
| 494 |
+
raise RuntimeError(f"Unexpected NVIDIA response: {data}") from exc
|
| 495 |
+
|
| 496 |
+
|
| 497 |
+
def refine_with_nemotron(
|
| 498 |
+
llm_evidence_pack: Dict[str, Any],
|
| 499 |
+
model: str = DEFAULT_MODEL,
|
| 500 |
+
return_comparison: bool = True,
|
| 501 |
+
) -> Dict[str, Any]:
|
| 502 |
+
if "candidate_paper_card" not in llm_evidence_pack:
|
| 503 |
+
raise ValueError("llm_evidence_pack must contain candidate_paper_card.")
|
| 504 |
+
|
| 505 |
+
compact_pack = _compact_evidence_pack(llm_evidence_pack)
|
| 506 |
+
before = copy.deepcopy(compact_pack["candidate_paper_card"])
|
| 507 |
+
|
| 508 |
+
prompt = f"""
|
| 509 |
+
You are Paper2Lab.
|
| 510 |
+
|
| 511 |
+
Refine candidate_paper_card using ONLY the provided evidence.
|
| 512 |
+
|
| 513 |
+
Return ONLY valid JSON.
|
| 514 |
+
|
| 515 |
+
Strict rules:
|
| 516 |
+
- Do not invent facts.
|
| 517 |
+
- Do not add facts that are not in the evidence pack.
|
| 518 |
+
- Remove boilerplate, references, author contributions, affiliations, acknowledgements, and duplicate claims.
|
| 519 |
+
- datasets_or_data_sources must contain only real datasets, corpora, benchmarks, databases, or data sources.
|
| 520 |
+
- Do NOT put models, parsers, methods, architectures, baselines, algorithms, or metrics in datasets_or_data_sources.
|
| 521 |
+
- Preserve annotation_version.
|
| 522 |
+
- Preserve source_pdf.
|
| 523 |
+
- Keep outputs concise.
|
| 524 |
+
- If evidence is insufficient, use [] or null.
|
| 525 |
+
- Add or improve lab_starter_kit.
|
| 526 |
+
|
| 527 |
+
Return a compact JSON object with ONLY these keys:
|
| 528 |
+
- title
|
| 529 |
+
- field
|
| 530 |
+
- paper_type
|
| 531 |
+
- research_question
|
| 532 |
+
- contributions
|
| 533 |
+
- methodology
|
| 534 |
+
- datasets_or_data_sources
|
| 535 |
+
- models_or_methods
|
| 536 |
+
- metrics_or_measurements
|
| 537 |
+
- key_findings
|
| 538 |
+
- limitations
|
| 539 |
+
- missing_reproducibility_info
|
| 540 |
+
- reproduction_roadmap
|
| 541 |
+
- reproducibility_score
|
| 542 |
+
- lab_starter_kit
|
| 543 |
+
- source_pdf
|
| 544 |
+
- annotation_version
|
| 545 |
+
|
| 546 |
+
Do not return metadata.
|
| 547 |
+
Do not return long nested evidence_terms.
|
| 548 |
+
Do not repeat large diagnostics objects.
|
| 549 |
+
Keep every list to maximum 6 items.
|
| 550 |
+
|
| 551 |
+
Evidence pack:
|
| 552 |
+
{json.dumps(compact_pack, indent=2, ensure_ascii=False)}
|
| 553 |
+
""".strip()
|
| 554 |
+
|
| 555 |
+
raw = _call_nvidia(prompt=prompt, model=model)
|
| 556 |
+
|
| 557 |
+
try:
|
| 558 |
+
refined_raw = _extract_json(raw)
|
| 559 |
+
except Exception as exc:
|
| 560 |
+
return {
|
| 561 |
+
"status": "error",
|
| 562 |
+
"model": model,
|
| 563 |
+
"error": str(exc),
|
| 564 |
+
"before_refinement": before,
|
| 565 |
+
"raw_model_output": raw,
|
| 566 |
+
}
|
| 567 |
+
|
| 568 |
+
after = validate_refined_card(refined_raw, before)
|
| 569 |
+
|
| 570 |
+
if not return_comparison:
|
| 571 |
+
return after
|
| 572 |
+
|
| 573 |
+
return {
|
| 574 |
+
"status": "ok",
|
| 575 |
+
"model": model,
|
| 576 |
+
"before_refinement": before,
|
| 577 |
+
"after_refinement": after,
|
| 578 |
+
"diff_summary": diff_cards(before, after),
|
| 579 |
+
}
|
src/paper2lab/inference/paper_card.py
ADDED
|
@@ -0,0 +1,766 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
paper_card.py — Dynamic section-aware rule-based paper card builder for Paper2Lab.
|
| 3 |
+
|
| 4 |
+
Purpose
|
| 5 |
+
-------
|
| 6 |
+
Builds a clean candidate paper card before LLM/Nemotron refinement.
|
| 7 |
+
|
| 8 |
+
Design principles
|
| 9 |
+
-----------------
|
| 10 |
+
- Uses extraction-safe clean_text from pdf_loader.py.
|
| 11 |
+
- Uses structured sections and roles instead of raw full-PDF text whenever possible.
|
| 12 |
+
- Detects paper_type first, then chooses extraction strategy.
|
| 13 |
+
- Avoids ML-only assumptions for systematic reviews, clinical studies, surveys, and reports.
|
| 14 |
+
- Keeps references/appendix/boilerplate out of candidate fields.
|
| 15 |
+
- Produces llm_evidence_pack for later Modal/Nemotron refinement.
|
| 16 |
+
|
| 17 |
+
Public API
|
| 18 |
+
----------
|
| 19 |
+
build_paper_card(extracted: Dict[str, Any]) -> Dict[str, Any]
|
| 20 |
+
"""
|
| 21 |
+
|
| 22 |
+
from __future__ import annotations
|
| 23 |
+
|
| 24 |
+
import json
|
| 25 |
+
import re
|
| 26 |
+
from collections import Counter
|
| 27 |
+
from typing import Any, Dict, Iterable, List, Tuple
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
# ---------------------------------------------------------------------------
|
| 31 |
+
# Pattern banks
|
| 32 |
+
# ---------------------------------------------------------------------------
|
| 33 |
+
|
| 34 |
+
MODEL_PATTERNS = [
|
| 35 |
+
r"\btransformer\b", r"\bself[- ]attention\b", r"\bmulti[- ]head attention\b",
|
| 36 |
+
r"\bscaled dot[- ]product attention\b", r"\bencoder[- ]decoder\b",
|
| 37 |
+
r"\bcnn\b", r"\bconvolutional neural network\b", r"\bu[- ]net\b",
|
| 38 |
+
r"\bbert\b", r"\bgpt\b", r"\bllm\b", r"\blarge language model\b",
|
| 39 |
+
r"\bvision transformer\b", r"\bvit\b", r"\bdiffusion model\b", r"\bgan\b",
|
| 40 |
+
r"\bresnet\b", r"\blstm\b", r"\bgru\b", r"\bsvm\b", r"\brandom forest\b",
|
| 41 |
+
r"\bxgboost\b", r"\blightgbm\b", r"\blogistic regression\b",
|
| 42 |
+
r"\blinear regression\b", r"\bgraph neural network\b", r"\bgnn\b", r"\brag\b",
|
| 43 |
+
]
|
| 44 |
+
|
| 45 |
+
ML_DATASET_PATTERNS = [
|
| 46 |
+
r"\bwmt\s*\d{4}\b", r"\bwsj\b", r"\bwall street journal\b", r"\bpenn treebank\b",
|
| 47 |
+
r"\bcifar[- ]?\d+\b", r"\bimagenet\b", r"\bmnist\b", r"\bcoco\b",
|
| 48 |
+
r"\bglue\b", r"\bsuperglue\b", r"\bsquad\b", r"\bbookscorpus\b",
|
| 49 |
+
r"\bwikipedia\b", r"\bcommonvoice\b", r"\blibrispeech\b",
|
| 50 |
+
r"\b\d+(?:\.\d+)?\s*(?:m|million|b|billion|k|thousand)?\s*"
|
| 51 |
+
r"(?:sentence pairs|sentences|tokens|images|patients|samples|records|documents|cases|examples|instances)\b",
|
| 52 |
+
r"\btraining data\b", r"\btraining dataset\b", r"\bvalidation set\b", r"\btest set\b",
|
| 53 |
+
r"\bdataset\b", r"\bdata source\b", r"\bclinical data\b", r"\bpublic dataset\b", r"\bbenchmark\b",
|
| 54 |
+
]
|
| 55 |
+
|
| 56 |
+
REVIEW_SOURCE_PATTERNS = [
|
| 57 |
+
r"\bpubmed\b", r"\bscopus\b", r"\bweb of knowledge\b", r"\bweb of science\b",
|
| 58 |
+
r"\beric\b", r"\beducational resources and information center\b",
|
| 59 |
+
r"\bcochrane\b", r"\bembase\b", r"\bmedline\b", r"\bgoogle scholar\b",
|
| 60 |
+
r"\bdatabases?\b", r"\barticles?\b", r"\bstudies\b", r"\bpublications?\b",
|
| 61 |
+
r"\brecords identified\b", r"\bselected studies\b", r"\bincluded studies\b",
|
| 62 |
+
r"\bgray literature\b", r"\bgrey literature\b",
|
| 63 |
+
]
|
| 64 |
+
|
| 65 |
+
METHODOLOGY_PATTERNS = [
|
| 66 |
+
# ML / computational methods
|
| 67 |
+
r"\bwe (?:train|trained|fine[- ]?tune|fine[- ]?tuned|evaluate|evaluated|optimize|optimized|pre[- ]?train|pre[- ]?trained)\b",
|
| 68 |
+
r"\bmodel (?:architecture|consists|uses|contains|is trained|was trained)\b",
|
| 69 |
+
r"\barchitecture\b", r"\btraining procedure\b", r"\bexperimental setup\b",
|
| 70 |
+
r"\boptimizer\b", r"\badamw?\b", r"\bsgd\b", r"\blearning rate\b",
|
| 71 |
+
r"\bbatch size\b", r"\bepoch\b", r"\bwarm[- ]?up\b", r"\bscheduler\b",
|
| 72 |
+
r"\bdropout\b", r"\blayer normalization\b", r"\bbatch normalization\b", r"\bweight decay\b",
|
| 73 |
+
r"\btokenization\b", r"\bbyte[- ]pair encoding\b", r"\bpositional encoding\b",
|
| 74 |
+
r"\bself[- ]attention\b", r"\bscaled dot[- ]product attention\b", r"\bmulti[- ]head attention\b",
|
| 75 |
+
r"\bcross[- ]validation\b", r"\btrain[- ]test split\b", r"\brandom seed\b",
|
| 76 |
+
r"\bpre[- ]?processed\b", r"\baugmentation\b",
|
| 77 |
+
# General empirical / review methods
|
| 78 |
+
r"\bsystematic review\b", r"\bliterature review\b", r"\bscoping review\b",
|
| 79 |
+
r"\bdatabases? (?:were|was) searched\b", r"\bsearched\b",
|
| 80 |
+
r"\binclusion criteria\b", r"\bexclusion criteria\b", r"\beligibility criteria\b",
|
| 81 |
+
r"\bscreen(?:ed|ing)\b", r"\bstudies were selected\b", r"\bdata extraction\b",
|
| 82 |
+
r"\bstudy design\b", r"\bparticipants\b", r"\bprocedure\b", r"\bintervention\b",
|
| 83 |
+
]
|
| 84 |
+
|
| 85 |
+
METRIC_PATTERNS = [
|
| 86 |
+
# ML/AI metrics
|
| 87 |
+
r"\bbleu\b", r"\bperplexity\b", r"\baccuracy\b", r"\bprecision\b", r"\brecall\b",
|
| 88 |
+
r"\bf1[- ]?score\b", r"\bf1\b", r"\bauc\b", r"\broc\b", r"\bsensitivity\b",
|
| 89 |
+
r"\bspecificity\b", r"\brmse\b", r"\bmae\b", r"\bmse\b", r"\br\s*[²2]\b",
|
| 90 |
+
r"\bmap\b", r"\biou\b", r"\bwer\b", r"\bcer\b", r"\brouge\b", r"\bbertscore\b",
|
| 91 |
+
r"\bloss\b", r"\bcross[- ]entropy\b",
|
| 92 |
+
# Review / clinical / social-science measurement patterns
|
| 93 |
+
r"\b\d+\s+(?:articles|studies|records|participants|patients|students)\b",
|
| 94 |
+
r"\bfinal review included\b", r"\bwere enrolled\b", r"\bselected for further review\b",
|
| 95 |
+
r"\bbetween\s+(?:january\s+)?\d{4}\s+and\s+(?:january\s+)?\d{4}\b",
|
| 96 |
+
r"\bfrom\s+(?:january\s+)?\d{4}\s+to\s+(?:january\s+)?\d{4}\b",
|
| 97 |
+
]
|
| 98 |
+
|
| 99 |
+
FINDING_PATTERNS = [
|
| 100 |
+
r"\bachieves?\b", r"\boutperforms?\b", r"\bimproves?\b", r"\bincreases?\b",
|
| 101 |
+
r"\bdecreases?\b", r"\bstate[- ]of[- ]the[- ]art\b", r"\bresults show\b",
|
| 102 |
+
r"\bfindings show\b", r"\bsignificantly\b", r"\bsuperior\b", r"\bcomparable\b",
|
| 103 |
+
r"\bconsistently\b", r"\bobtains?\b", r"\bshowed that\b", r"\bfound that\b",
|
| 104 |
+
r"\bpositive (?:responses|attitudes|effects|outcomes)\b", r"\bwas effective\b",
|
| 105 |
+
]
|
| 106 |
+
|
| 107 |
+
CONTRIBUTION_PATTERNS = [
|
| 108 |
+
r"\bwe propose\b", r"\bwe introduce\b", r"\bwe present\b", r"\bwe develop\b",
|
| 109 |
+
r"\bwe designed\b", r"\bwe show\b", r"\bwe demonstrate\b", r"\bwe release\b",
|
| 110 |
+
r"\bwe open[- ]source\b", r"\bthis paper proposes\b", r"\bthis work proposes\b",
|
| 111 |
+
r"\bthis study developed\b", r"\bour contribution\b", r"\bour main contribution\b",
|
| 112 |
+
r"\bnovel\b", r"\bfirst to\b", r"\bfirst systematic review\b",
|
| 113 |
+
]
|
| 114 |
+
|
| 115 |
+
LIMITATION_PATTERNS = [
|
| 116 |
+
r"\blimitation\b", r"\blimitations\b", r"\bfuture work\b", r"\bmore data\b",
|
| 117 |
+
r"\bsmall dataset\b", r"\bfalse[- ]positive\b", r"\bfalse[- ]negative\b",
|
| 118 |
+
r"\bnot sufficient\b", r"\blacking\b", r"\bwe did not\b", r"\bcannot\b",
|
| 119 |
+
r"\bwe leave\b", r"\bdoes not generalize\b", r"\bbias\b", r"\boutside the scope\b",
|
| 120 |
+
r"\bnot evaluated\b", r"\bnot tested\b", r"\black of\b", r"\bmay have led to bias\b",
|
| 121 |
+
]
|
| 122 |
+
|
| 123 |
+
NOISE_MARKERS = [
|
| 124 |
+
"acknowledgement", "acknowledgment", "author contribution", "competing interests",
|
| 125 |
+
"correspondence", "publisher", "open access", "license", "copyright", "gmail.com",
|
| 126 |
+
"references", "bibliography", "arxiv:", "how to cite", "access this article online",
|
| 127 |
+
"quick response code", "website:", "doi:", "www.", "http://", "https://",
|
| 128 |
+
# Known author-contribution noise from some papers
|
| 129 |
+
"llion also experimented", "jakob proposed", "ashish", "noam proposed", "niki selected",
|
| 130 |
+
"aidan designed", "illia", "google brain",
|
| 131 |
+
]
|
| 132 |
+
|
| 133 |
+
AFFILIATION_MARKERS = [
|
| 134 |
+
"department of", "university of", "faculty of", "school of", "institute of",
|
| 135 |
+
"medical sciences", "corresponding author", "email", "journal of education",
|
| 136 |
+
]
|
| 137 |
+
|
| 138 |
+
_FIELD_REJECT: Dict[str, List[str]] = {
|
| 139 |
+
"contributions": ["author", "google brain", "also experimented", "selected work", "department of"],
|
| 140 |
+
"datasets_or_data_sources": [
|
| 141 |
+
"achieves", "outperforms", "state-of-the-art", "results show", "during inference",
|
| 142 |
+
"beam size", "parser training", "section 23", "table 4", "department of",
|
| 143 |
+
],
|
| 144 |
+
"models_or_methods": ["author", "university", "gmail", "in the following sections", "references"],
|
| 145 |
+
"methodology": ["in the following sections", "to the best of our knowledge", "references"],
|
| 146 |
+
"findings": ["table of contents", "during inference", "parser training", "references"],
|
| 147 |
+
}
|
| 148 |
+
|
| 149 |
+
ML_DATASET_MUST_CONTAIN = [
|
| 150 |
+
"dataset", "data", "wmt", "wsj", "wall street journal", "penn treebank", "bookcorpus",
|
| 151 |
+
"wikipedia", "sentence pairs", "sentences", "tokens", "images", "patients", "samples",
|
| 152 |
+
"records", "cases", "examples", "instances", "benchmark", "training set", "test set",
|
| 153 |
+
"validation set", "english-german", "english-french",
|
| 154 |
+
]
|
| 155 |
+
|
| 156 |
+
REVIEW_SOURCE_MUST_CONTAIN = [
|
| 157 |
+
"pubmed", "scopus", "web of knowledge", "web of science",
|
| 158 |
+
"educational resources", "cochrane", "embase", "medline",
|
| 159 |
+
"google scholar", "database", "databases", "studies",
|
| 160 |
+
"articles", "publications", "records", "gray literature", "grey literature",
|
| 161 |
+
]
|
| 162 |
+
|
| 163 |
+
# ---------------------------------------------------------------------------
|
| 164 |
+
# Generic utilities
|
| 165 |
+
# ---------------------------------------------------------------------------
|
| 166 |
+
|
| 167 |
+
def _strip_doi_noise(text: str) -> str:
|
| 168 |
+
text = text or ""
|
| 169 |
+
text = re.sub(r"\bdoi\s*[::]?\s*10\.\d{4,9}/[-._;()/:A-Za-z0-9]+", " ", text, flags=re.IGNORECASE)
|
| 170 |
+
text = re.sub(r"\b10\.\d{4,9}/[-._;()/:A-Za-z0-9]+", " ", text)
|
| 171 |
+
return text
|
| 172 |
+
|
| 173 |
+
|
| 174 |
+
def _clean(text: str) -> str:
|
| 175 |
+
text = text or ""
|
| 176 |
+
text = text.replace("\x00", " ").replace("\u00a0", " ")
|
| 177 |
+
text = text.replace("\ufb01", "fi").replace("\ufb02", "fl")
|
| 178 |
+
text = text.replace("\u2013", "-").replace("\u2014", "-")
|
| 179 |
+
text = text.replace("\u2018", "'").replace("\u2019", "'")
|
| 180 |
+
text = text.replace("\u201c", '"').replace("\u201d", '"')
|
| 181 |
+
text = _strip_doi_noise(text)
|
| 182 |
+
text = re.sub(r"\s+", " ", text)
|
| 183 |
+
return text.strip()
|
| 184 |
+
|
| 185 |
+
|
| 186 |
+
def _normalize_key(text: str) -> str:
|
| 187 |
+
return re.sub(r"[^a-z0-9]+", " ", _clean(text).lower()).strip()
|
| 188 |
+
|
| 189 |
+
|
| 190 |
+
def _bad_sentence_quality(sentence: str) -> bool:
|
| 191 |
+
"""Reject merged-column, affiliation, citation, and boilerplate artifacts."""
|
| 192 |
+
s = _clean(sentence)
|
| 193 |
+
low = s.lower()
|
| 194 |
+
|
| 195 |
+
if not s:
|
| 196 |
+
return True
|
| 197 |
+
|
| 198 |
+
if any(x in low for x in AFFILIATION_MARKERS):
|
| 199 |
+
return True
|
| 200 |
+
|
| 201 |
+
# Too many citation markers often means merged reference/body text.
|
| 202 |
+
if len(re.findall(r"\[\d+\]", s)) >= 2:
|
| 203 |
+
return True
|
| 204 |
+
|
| 205 |
+
# Known symptoms of two-column stitching / line interleaving.
|
| 206 |
+
bad_regexes = [
|
| 207 |
+
r"\bneed\s+this systematic review\b",
|
| 208 |
+
r"\bof the there\b",
|
| 209 |
+
r"\band the that\b",
|
| 210 |
+
r"\bwere as follows: being\b",
|
| 211 |
+
r"\bdata were included to improve\b",
|
| 212 |
+
r"\bto adopt a new style of learning\s*\.\s*medical courses\b",
|
| 213 |
+
r"\bfisher \(\d{4}\) discuss intended studies\b",
|
| 214 |
+
]
|
| 215 |
+
if any(re.search(p, low) for p in bad_regexes):
|
| 216 |
+
return True
|
| 217 |
+
|
| 218 |
+
# Merged sentences are usually very long and contain unrelated cues.
|
| 219 |
+
if len(s.split()) > 55 and any(x in low for x in [
|
| 220 |
+
"students are required", "there is a good deal", "department", "university",
|
| 221 |
+
"corresponding author", "access this article",
|
| 222 |
+
]):
|
| 223 |
+
return True
|
| 224 |
+
|
| 225 |
+
# Odd punctuation/table artifacts.
|
| 226 |
+
if s.count("|") >= 2 or s.count("%") >= 6 or s.count("@") >= 1:
|
| 227 |
+
return True
|
| 228 |
+
|
| 229 |
+
return False
|
| 230 |
+
|
| 231 |
+
|
| 232 |
+
def _is_noise(sentence: str) -> bool:
|
| 233 |
+
low = sentence.lower()
|
| 234 |
+
if any(m in low for m in NOISE_MARKERS):
|
| 235 |
+
return True
|
| 236 |
+
if len(sentence.split()) > 85:
|
| 237 |
+
return True
|
| 238 |
+
if re.search(r"^\d+(?:\.\d+)*\s+[A-Z][A-Za-z ]{2,50}$", sentence):
|
| 239 |
+
return True
|
| 240 |
+
if _bad_sentence_quality(sentence):
|
| 241 |
+
return True
|
| 242 |
+
return False
|
| 243 |
+
|
| 244 |
+
|
| 245 |
+
def _split_sentences(text: str) -> List[str]:
|
| 246 |
+
text = _clean(text)
|
| 247 |
+
if not text:
|
| 248 |
+
return []
|
| 249 |
+
raw = re.split(r"(?<=[.!?])\s+(?=[A-Z0-9])", text)
|
| 250 |
+
sentences: List[str] = []
|
| 251 |
+
for s in raw:
|
| 252 |
+
s = _clean(s)
|
| 253 |
+
if 35 <= len(s) <= 420 and not _is_noise(s):
|
| 254 |
+
sentences.append(s)
|
| 255 |
+
return sentences
|
| 256 |
+
|
| 257 |
+
|
| 258 |
+
def _dedupe(items: Iterable[str]) -> List[str]:
|
| 259 |
+
seen: set[str] = set()
|
| 260 |
+
out: List[str] = []
|
| 261 |
+
for item in items:
|
| 262 |
+
item = _clean(item)
|
| 263 |
+
key = _normalize_key(item)[:220]
|
| 264 |
+
if key and key not in seen:
|
| 265 |
+
seen.add(key)
|
| 266 |
+
out.append(item)
|
| 267 |
+
return out
|
| 268 |
+
|
| 269 |
+
|
| 270 |
+
def _json_safe(value: Any) -> Any:
|
| 271 |
+
try:
|
| 272 |
+
json.dumps(value)
|
| 273 |
+
return value
|
| 274 |
+
except Exception:
|
| 275 |
+
return str(value)
|
| 276 |
+
|
| 277 |
+
|
| 278 |
+
def _section_title(sec: Dict[str, Any]) -> str:
|
| 279 |
+
return _clean(sec.get("title") or "")
|
| 280 |
+
|
| 281 |
+
|
| 282 |
+
def _section_role(sec: Dict[str, Any]) -> str:
|
| 283 |
+
return sec.get("role") or "other"
|
| 284 |
+
|
| 285 |
+
|
| 286 |
+
def _sections_by_role(
|
| 287 |
+
extracted: Dict[str, Any],
|
| 288 |
+
roles: List[str],
|
| 289 |
+
title_contains: List[str] | None = None,
|
| 290 |
+
) -> str:
|
| 291 |
+
chunks: List[str] = []
|
| 292 |
+
title_contains = [t.lower() for t in (title_contains or [])]
|
| 293 |
+
paper_title = _clean(extracted.get("title") or "").lower()
|
| 294 |
+
|
| 295 |
+
blocked_roles = {"references", "appendix", "boilerplate"}
|
| 296 |
+
blocked_titles = {"front matter", "keywords", "keywords:", "table of contents"}
|
| 297 |
+
|
| 298 |
+
for sec in extracted.get("sections", []):
|
| 299 |
+
title_raw = _section_title(sec)
|
| 300 |
+
title = title_raw.lower()
|
| 301 |
+
role = _section_role(sec)
|
| 302 |
+
|
| 303 |
+
if role in blocked_roles:
|
| 304 |
+
continue
|
| 305 |
+
if title in blocked_titles:
|
| 306 |
+
continue
|
| 307 |
+
if paper_title and title == paper_title:
|
| 308 |
+
continue
|
| 309 |
+
|
| 310 |
+
if role in roles or any(t in title for t in title_contains):
|
| 311 |
+
chunks.append(sec.get("text", ""))
|
| 312 |
+
|
| 313 |
+
return "\n".join(chunks)
|
| 314 |
+
|
| 315 |
+
|
| 316 |
+
def _find_sentences(
|
| 317 |
+
text: str,
|
| 318 |
+
patterns: List[str],
|
| 319 |
+
max_items: int = 8,
|
| 320 |
+
require_number: bool = False,
|
| 321 |
+
) -> List[str]:
|
| 322 |
+
found: List[str] = []
|
| 323 |
+
for sentence in _split_sentences(text):
|
| 324 |
+
if require_number and not re.search(r"\d", sentence):
|
| 325 |
+
continue
|
| 326 |
+
if any(re.search(p, sentence, flags=re.IGNORECASE) for p in patterns):
|
| 327 |
+
found.append(sentence)
|
| 328 |
+
if len(found) >= max_items:
|
| 329 |
+
break
|
| 330 |
+
return _dedupe(found)
|
| 331 |
+
|
| 332 |
+
|
| 333 |
+
def _filter_field(items: List[str], field: str, paper_type: str = "general_research") -> List[str]:
|
| 334 |
+
reject_terms = _FIELD_REJECT.get(field, [])
|
| 335 |
+
filtered: List[str] = []
|
| 336 |
+
|
| 337 |
+
for item in items:
|
| 338 |
+
item = _clean(item)
|
| 339 |
+
low = item.lower()
|
| 340 |
+
if not item or _is_noise(item):
|
| 341 |
+
continue
|
| 342 |
+
if any(term in low for term in reject_terms):
|
| 343 |
+
continue
|
| 344 |
+
|
| 345 |
+
if field == "datasets_or_data_sources":
|
| 346 |
+
if paper_type == "systematic_review":
|
| 347 |
+
if not any(term in low for term in REVIEW_SOURCE_MUST_CONTAIN):
|
| 348 |
+
continue
|
| 349 |
+
else:
|
| 350 |
+
if not any(term in low for term in ML_DATASET_MUST_CONTAIN):
|
| 351 |
+
continue
|
| 352 |
+
if any(bad in low for bad in ["beam size", "during inference", "parser training", "section 23"]):
|
| 353 |
+
continue
|
| 354 |
+
|
| 355 |
+
filtered.append(item)
|
| 356 |
+
|
| 357 |
+
return _dedupe(filtered)
|
| 358 |
+
|
| 359 |
+
|
| 360 |
+
# ---------------------------------------------------------------------------
|
| 361 |
+
# Dynamic paper type and field inference
|
| 362 |
+
# ---------------------------------------------------------------------------
|
| 363 |
+
|
| 364 |
+
def _top_terms(text: str, k: int = 10) -> List[str]:
|
| 365 |
+
stop = {
|
| 366 |
+
"the", "and", "for", "with", "that", "this", "from", "were", "was", "are", "has",
|
| 367 |
+
"have", "had", "their", "they", "into", "using", "used", "study", "paper", "article",
|
| 368 |
+
"results", "method", "methods", "data", "based", "between", "through", "there",
|
| 369 |
+
}
|
| 370 |
+
words = re.findall(r"\b[a-z][a-z]{3,}\b", text.lower())
|
| 371 |
+
words = [w for w in words if w not in stop]
|
| 372 |
+
return [w for w, _ in Counter(words).most_common(k)]
|
| 373 |
+
|
| 374 |
+
|
| 375 |
+
def _infer_paper_type(title: str, abstract: str, sections: List[Dict[str, Any]], clean_text: str) -> str:
|
| 376 |
+
title_abs = f"{title} {abstract}".lower()
|
| 377 |
+
section_titles = " ".join(_section_title(s) for s in sections).lower()
|
| 378 |
+
probe = f"{title_abs} {section_titles} {clean_text[:9000].lower()}"
|
| 379 |
+
|
| 380 |
+
review_score = 0
|
| 381 |
+
for term in [
|
| 382 |
+
"systematic review",
|
| 383 |
+
"systematic literature search",
|
| 384 |
+
"systematic literature review",
|
| 385 |
+
"literature search",
|
| 386 |
+
"literature review",
|
| 387 |
+
"prisma",
|
| 388 |
+
"study selection",
|
| 389 |
+
"bibliographic search",
|
| 390 |
+
"databases were searched",
|
| 391 |
+
"database search",
|
| 392 |
+
"included studies",
|
| 393 |
+
"included articles",
|
| 394 |
+
"screening",
|
| 395 |
+
"eligibility criteria",
|
| 396 |
+
]:
|
| 397 |
+
if term in probe:
|
| 398 |
+
review_score += 1
|
| 399 |
+
|
| 400 |
+
if review_score >= 2:
|
| 401 |
+
return "systematic_review"
|
| 402 |
+
|
| 403 |
+
if any(x in probe for x in ["meta-analysis", "meta analysis", "scoping review"]):
|
| 404 |
+
return "systematic_review"
|
| 405 |
+
|
| 406 |
+
narrative_review_terms = [
|
| 407 |
+
"review of important findings",
|
| 408 |
+
"review of previous research",
|
| 409 |
+
"previous research on",
|
| 410 |
+
"literature on",
|
| 411 |
+
"research on teacher education",
|
| 412 |
+
"implications for improving",
|
| 413 |
+
]
|
| 414 |
+
if any(term in probe for term in narrative_review_terms):
|
| 415 |
+
return "survey_or_review"
|
| 416 |
+
|
| 417 |
+
if any(x in probe for x in ["randomized controlled trial", "cohort", "case-control", "clinical trial"]):
|
| 418 |
+
return "clinical_study"
|
| 419 |
+
|
| 420 |
+
if any(x in probe for x in ["transformer", "bert", "neural network", "optimizer", "training loss", "fine-tuned", "imagenet", "bleu"]):
|
| 421 |
+
return "machine_learning"
|
| 422 |
+
|
| 423 |
+
if any(x in probe for x in ["survey", "questionnaire", "respondents", "participants"]):
|
| 424 |
+
return "survey_study"
|
| 425 |
+
|
| 426 |
+
if any(x in probe for x in ["recommendations", "checklist", "best practices"]):
|
| 427 |
+
return "guide_or_report"
|
| 428 |
+
|
| 429 |
+
return "general_research"
|
| 430 |
+
|
| 431 |
+
|
| 432 |
+
def _infer_field(title: str, abstract: str, clean_text: str, paper_type: str) -> str:
|
| 433 |
+
title_abs = f"{title} {abstract}".lower()
|
| 434 |
+
probe = f"{title_abs} {clean_text[:5000].lower()}"
|
| 435 |
+
|
| 436 |
+
domain_scores: Dict[str, int] = {
|
| 437 |
+
"Education": 0,
|
| 438 |
+
"Natural Language Processing": 0,
|
| 439 |
+
"Computer Vision": 0,
|
| 440 |
+
"Medical / Clinical Research": 0,
|
| 441 |
+
"Medical AI": 0,
|
| 442 |
+
"Biology / Life Sciences": 0,
|
| 443 |
+
"Graph Learning": 0,
|
| 444 |
+
"Reinforcement Learning": 0,
|
| 445 |
+
"Generative Models": 0,
|
| 446 |
+
"Social Science": 0,
|
| 447 |
+
"Machine Learning": 0,
|
| 448 |
+
}
|
| 449 |
+
|
| 450 |
+
weighted_terms: Dict[str, List[str]] = {
|
| 451 |
+
"Education": ["education", "educational", "academic performance", "students", "higher education", "preclinical academic"],
|
| 452 |
+
"Natural Language Processing": ["translation", "language model", "question answering", "summarization", "bleu", "token", "bert", "gpt", "corpus"],
|
| 453 |
+
"Computer Vision": ["image classification", "object detection", "segmentation", "imagenet", "resnet", "vision transformer", "pixel"],
|
| 454 |
+
"Medical / Clinical Research": ["patient", "clinical", "cohort", "diagnosis", "mortality", "disease", "treatment", "medical students"],
|
| 455 |
+
"Medical AI": ["clinical prediction", "medical image", "radiograph", "x-ray", "ct scan", "mri", "ehr", "icu", "machine learning"],
|
| 456 |
+
"Biology / Life Sciences": ["protein", "molecule", "drug", "genomics", "dna", "rna", "crispr", "gene"],
|
| 457 |
+
"Graph Learning": ["graph neural network", "gnn", "node classification", "link prediction", "message passing"],
|
| 458 |
+
"Reinforcement Learning": ["reward", "agent", "policy", "q-learning", "ppo", "dqn", "actor-critic"],
|
| 459 |
+
"Generative Models": ["diffusion model", "gan", "generative adversarial", "vae", "denoising"],
|
| 460 |
+
"Social Science": ["survey", "questionnaire", "social science", "respondents", "interviews"],
|
| 461 |
+
"Machine Learning": ["machine learning", "deep learning", "neural network", "training", "optimizer", "classification"],
|
| 462 |
+
}
|
| 463 |
+
|
| 464 |
+
for domain, terms in weighted_terms.items():
|
| 465 |
+
for term in terms:
|
| 466 |
+
if term in probe:
|
| 467 |
+
domain_scores[domain] += 1
|
| 468 |
+
if term in title_abs:
|
| 469 |
+
domain_scores[domain] += 2
|
| 470 |
+
|
| 471 |
+
# Paper-type-aware tie breaks.
|
| 472 |
+
if paper_type == "systematic_review":
|
| 473 |
+
if domain_scores["Education"] >= 2:
|
| 474 |
+
return "Education"
|
| 475 |
+
if domain_scores["Medical / Clinical Research"] >= 2:
|
| 476 |
+
return "Medical / Clinical Research"
|
| 477 |
+
if domain_scores["Social Science"] >= 2:
|
| 478 |
+
return "Social Science"
|
| 479 |
+
|
| 480 |
+
if domain_scores["Medical AI"] >= 3 and domain_scores["Machine Learning"] >= 2:
|
| 481 |
+
return "Medical AI"
|
| 482 |
+
|
| 483 |
+
best = max(domain_scores, key=domain_scores.get)
|
| 484 |
+
if domain_scores[best] <= 1:
|
| 485 |
+
return "General Research"
|
| 486 |
+
return best
|
| 487 |
+
|
| 488 |
+
|
| 489 |
+
# ---------------------------------------------------------------------------
|
| 490 |
+
# Field-specific extraction helpers
|
| 491 |
+
# ---------------------------------------------------------------------------
|
| 492 |
+
|
| 493 |
+
def _extract_research_question(abstract: str, intro: str, title: str, paper_type: str) -> str:
|
| 494 |
+
text = _clean(f"{abstract} {intro}")
|
| 495 |
+
|
| 496 |
+
if paper_type == "systematic_review":
|
| 497 |
+
review_markers = [
|
| 498 |
+
"purpose", "aim", "objective", "intended", "present review", "systematic review",
|
| 499 |
+
"with the purpose", "the aim of", "the objective of",
|
| 500 |
+
]
|
| 501 |
+
for sentence in _split_sentences(text):
|
| 502 |
+
low = sentence.lower()
|
| 503 |
+
if any(m in low for m in review_markers):
|
| 504 |
+
return sentence
|
| 505 |
+
|
| 506 |
+
markers = [
|
| 507 |
+
"we propose", "we introduce", "we investigate", "we evaluate", "we present",
|
| 508 |
+
"we developed", "this paper proposes", "this work proposes", "the goal",
|
| 509 |
+
"the aim", "the objective", "our goal", "our aim", "this study aims",
|
| 510 |
+
]
|
| 511 |
+
for sentence in _split_sentences(text):
|
| 512 |
+
low = sentence.lower()
|
| 513 |
+
if any(m in low for m in markers):
|
| 514 |
+
return sentence
|
| 515 |
+
|
| 516 |
+
clean_abstract = _split_sentences(abstract)
|
| 517 |
+
if clean_abstract:
|
| 518 |
+
return clean_abstract[0]
|
| 519 |
+
|
| 520 |
+
return title or ""
|
| 521 |
+
|
| 522 |
+
|
| 523 |
+
def _extract_contributions(abstract: str, intro: str, conclusion: str, paper_type: str) -> List[str]:
|
| 524 |
+
text = f"{abstract} {intro} {conclusion}"
|
| 525 |
+
items = _find_sentences(text, CONTRIBUTION_PATTERNS, 8)
|
| 526 |
+
if paper_type == "systematic_review" and not items:
|
| 527 |
+
# Many reviews do not have explicit contribution language. Avoid hallucinating.
|
| 528 |
+
return []
|
| 529 |
+
return _filter_field(items, "contributions", paper_type)
|
| 530 |
+
|
| 531 |
+
|
| 532 |
+
def _extract_methodology(methods: str, experiments: str, intro: str, paper_type: str) -> List[str]:
|
| 533 |
+
primary = f"{methods}\n{experiments}"
|
| 534 |
+
|
| 535 |
+
if paper_type == "systematic_review":
|
| 536 |
+
review_patterns = [
|
| 537 |
+
r"\b(?:pubmed|scopus|web of knowledge|web of science|eric|cochrane|embase|medline)\b.*\bsearched\b",
|
| 538 |
+
r"\bdatabases?\b.*\bsearched\b",
|
| 539 |
+
r"\binclusion criteria\b.*",
|
| 540 |
+
r"\bexclusion criteria\b.*",
|
| 541 |
+
r"\beligibility criteria\b.*",
|
| 542 |
+
r"\btitles and abstracts were screened\b.*",
|
| 543 |
+
r"\barticles were imported\b.*",
|
| 544 |
+
r"\bdata extraction\b.*",
|
| 545 |
+
r"\bsystematic review was conducted\b.*",
|
| 546 |
+
]
|
| 547 |
+
items = _find_sentences(primary, review_patterns, max_items=10)
|
| 548 |
+
if len(items) < 3:
|
| 549 |
+
items += _find_sentences(f"{intro}\n{primary}", review_patterns, max_items=10)
|
| 550 |
+
return _filter_field(items, "methodology", paper_type)[:8]
|
| 551 |
+
|
| 552 |
+
items = _find_sentences(primary, METHODOLOGY_PATTERNS, max_items=12)
|
| 553 |
+
if len(items) < 3:
|
| 554 |
+
items += _find_sentences(intro, METHODOLOGY_PATTERNS, max_items=5)
|
| 555 |
+
return _filter_field(items, "methodology", paper_type)[:10]
|
| 556 |
+
|
| 557 |
+
|
| 558 |
+
def _extract_datasets(clean_text: str, methods: str, experiments: str, results: str, paper_type: str) -> List[str]:
|
| 559 |
+
priority_text = f"{methods}\n{experiments}\n{results}"
|
| 560 |
+
|
| 561 |
+
if paper_type == "systematic_review":
|
| 562 |
+
items = _find_sentences(priority_text, REVIEW_SOURCE_PATTERNS, max_items=14)
|
| 563 |
+
if len(items) < 4:
|
| 564 |
+
items += _find_sentences(clean_text[:12000], REVIEW_SOURCE_PATTERNS, max_items=14)
|
| 565 |
+
|
| 566 |
+
# Also add compact database names when explicitly found.
|
| 567 |
+
compact: List[str] = []
|
| 568 |
+
db_names = [
|
| 569 |
+
"PubMed", "Scopus", "Web of Knowledge", "Web of Science", "ERIC",
|
| 570 |
+
"Educational Resources and Information Center", "Cochrane", "Embase", "Medline", "Google Scholar",
|
| 571 |
+
]
|
| 572 |
+
low = priority_text.lower() + " " + clean_text[:8000].lower()
|
| 573 |
+
for name in db_names:
|
| 574 |
+
if name.lower() in low:
|
| 575 |
+
compact.append(name)
|
| 576 |
+
compact += _filter_field(items, "datasets_or_data_sources", paper_type)
|
| 577 |
+
return _dedupe(compact)[:10]
|
| 578 |
+
|
| 579 |
+
items = _find_sentences(priority_text, ML_DATASET_PATTERNS, 12)
|
| 580 |
+
if len(items) < 4:
|
| 581 |
+
items += _find_sentences(clean_text[:15000], ML_DATASET_PATTERNS, 12)
|
| 582 |
+
return _filter_field(items, "datasets_or_data_sources", paper_type)[:10]
|
| 583 |
+
|
| 584 |
+
|
| 585 |
+
def _extract_models(clean_text: str, methods: str, experiments: str, paper_type: str) -> List[str]:
|
| 586 |
+
if paper_type in {"systematic_review", "guide_or_report", "survey_study"}:
|
| 587 |
+
return []
|
| 588 |
+
priority_text = f"{methods}\n{experiments}"
|
| 589 |
+
items = _find_sentences(priority_text, MODEL_PATTERNS, 12)
|
| 590 |
+
if len(items) < 4:
|
| 591 |
+
items += _find_sentences(clean_text[:15000], MODEL_PATTERNS, 12)
|
| 592 |
+
return _filter_field(items, "models_or_methods", paper_type)[:10]
|
| 593 |
+
|
| 594 |
+
|
| 595 |
+
def _extract_metrics(clean_text: str, results: str, experiments: str, methods: str, paper_type: str) -> List[str]:
|
| 596 |
+
priority_text = f"{results}\n{experiments}\n{methods}"
|
| 597 |
+
|
| 598 |
+
if paper_type == "systematic_review":
|
| 599 |
+
review_metric_patterns = [
|
| 600 |
+
r"\b\d+\s+(?:articles|studies|records|publications)\b.*",
|
| 601 |
+
r"\b(?:final review|review) included\s+\d+\b.*",
|
| 602 |
+
r"\b\d+\s+articles were selected\b.*",
|
| 603 |
+
r"\bbetween\s+(?:january\s+)?\d{4}\s+and\s+(?:january\s+)?\d{4}\b.*",
|
| 604 |
+
r"\bfrom\s+(?:january\s+)?\d{4}\s+to\s+(?:january\s+)?\d{4}\b.*",
|
| 605 |
+
]
|
| 606 |
+
items = _find_sentences(priority_text, review_metric_patterns, 10, require_number=True)
|
| 607 |
+
if len(items) < 3:
|
| 608 |
+
items += _find_sentences(clean_text[:12000], review_metric_patterns, 10, require_number=True)
|
| 609 |
+
return _dedupe(items)[:8]
|
| 610 |
+
|
| 611 |
+
items = _find_sentences(priority_text, METRIC_PATTERNS, 12, require_number=True)
|
| 612 |
+
if len(items) < 3:
|
| 613 |
+
items += _find_sentences(clean_text[:15000], METRIC_PATTERNS, 10, require_number=True)
|
| 614 |
+
if not items:
|
| 615 |
+
items = _find_sentences(priority_text or clean_text[:15000], METRIC_PATTERNS, 8)
|
| 616 |
+
return _dedupe([x for x in items if "references" not in x.lower()])[:10]
|
| 617 |
+
|
| 618 |
+
|
| 619 |
+
def _extract_findings(results: str, conclusion: str, abstract: str, paper_type: str) -> List[str]:
|
| 620 |
+
text = f"{results} {conclusion} {abstract}"
|
| 621 |
+
items = _find_sentences(text, FINDING_PATTERNS, 10)
|
| 622 |
+
return _filter_field(items, "findings", paper_type)[:5]
|
| 623 |
+
|
| 624 |
+
|
| 625 |
+
def _extract_limitations(clean_text: str, limitations: str, discussion: str, conclusion: str, paper_type: str) -> List[str]:
|
| 626 |
+
text = f"{limitations} {discussion} {conclusion} {clean_text[-6000:]}"
|
| 627 |
+
return _filter_field(_find_sentences(text, LIMITATION_PATTERNS, 8), "limitations", paper_type)[:6]
|
| 628 |
+
|
| 629 |
+
|
| 630 |
+
def _missing_repro_info(card: Dict[str, Any], extracted: Dict[str, Any], paper_type: str) -> List[str]:
|
| 631 |
+
missing: List[str] = []
|
| 632 |
+
method_text = _sections_by_role(extracted, ["methodology", "experiments"])
|
| 633 |
+
low = method_text.lower()
|
| 634 |
+
|
| 635 |
+
if paper_type == "systematic_review":
|
| 636 |
+
checks = {
|
| 637 |
+
"search date range is not clearly specified": ["from", "between", "1987", "2018", "date", "time limitation"],
|
| 638 |
+
"inclusion/exclusion criteria are not clearly specified": ["inclusion criteria", "exclusion criteria", "eligibility criteria"],
|
| 639 |
+
"screening process is not clearly specified": ["screened", "titles and abstracts", "two independent"],
|
| 640 |
+
"quality assessment method is not clearly specified": ["quality", "assessment", "best evidence", "risk of bias"],
|
| 641 |
+
}
|
| 642 |
+
for label, terms in checks.items():
|
| 643 |
+
if not any(t in low for t in terms):
|
| 644 |
+
missing.append(label)
|
| 645 |
+
if not card.get("datasets_or_data_sources"):
|
| 646 |
+
missing.append("bibliographic databases or study sources could not be reliably extracted")
|
| 647 |
+
return missing[:6]
|
| 648 |
+
|
| 649 |
+
if paper_type == "machine_learning":
|
| 650 |
+
checks = {
|
| 651 |
+
"training hyperparameters are incomplete": ["learning rate", "batch size", "epoch", "steps"],
|
| 652 |
+
"random seed is not specified": ["seed", "random seed"],
|
| 653 |
+
"code availability is not clearly specified": ["code", "github", "repository"],
|
| 654 |
+
"dataset split details are not clearly specified": ["train", "validation", "test", "split"],
|
| 655 |
+
}
|
| 656 |
+
else:
|
| 657 |
+
checks = {
|
| 658 |
+
"study design details are incomplete": ["study design", "method", "procedure"],
|
| 659 |
+
"sample/data source details are incomplete": ["participants", "patients", "samples", "data source", "dataset"],
|
| 660 |
+
"analysis or measurement method is not clearly specified": ["analysis", "measure", "outcome", "metric"],
|
| 661 |
+
}
|
| 662 |
+
|
| 663 |
+
for label, terms in checks.items():
|
| 664 |
+
if not any(t in low for t in terms):
|
| 665 |
+
missing.append(label)
|
| 666 |
+
if not card.get("datasets_or_data_sources") and paper_type == "machine_learning":
|
| 667 |
+
missing.append("dataset or data source could not be reliably extracted")
|
| 668 |
+
return missing[:6]
|
| 669 |
+
|
| 670 |
+
|
| 671 |
+
# ---------------------------------------------------------------------------
|
| 672 |
+
# Evidence pack
|
| 673 |
+
# ---------------------------------------------------------------------------
|
| 674 |
+
|
| 675 |
+
def _llm_evidence_pack(extracted: Dict[str, Any], paper_card: Dict[str, Any]) -> Dict[str, Any]:
|
| 676 |
+
compact_sections: List[Dict[str, Any]] = []
|
| 677 |
+
for sec in extracted.get("sections", []):
|
| 678 |
+
text = _clean(sec.get("text", ""))
|
| 679 |
+
if not text:
|
| 680 |
+
continue
|
| 681 |
+
compact_sections.append({
|
| 682 |
+
"title": sec.get("title", ""),
|
| 683 |
+
"role_hint": sec.get("role", "other"),
|
| 684 |
+
"page_start": sec.get("page_start"),
|
| 685 |
+
"page_end": sec.get("page_end"),
|
| 686 |
+
"preview": text[:2000],
|
| 687 |
+
})
|
| 688 |
+
|
| 689 |
+
candidate = {k: v for k, v in paper_card.items() if k != "llm_evidence_pack"}
|
| 690 |
+
return {
|
| 691 |
+
"system_prompt": (
|
| 692 |
+
"You are a scientific paper analyst. Correct the candidate JSON paper card using only the provided evidence. "
|
| 693 |
+
"Do not invent facts. Remove boilerplate, references, author affiliations, acknowledgements, duplicate claims, "
|
| 694 |
+
"and generic text. Return only valid JSON with the same keys as candidate_paper_card."
|
| 695 |
+
),
|
| 696 |
+
"instruction": (
|
| 697 |
+
"Refine candidate_paper_card. Keep facts specific, concise, and grounded in section previews, captions, and tables. "
|
| 698 |
+
"Respect paper_type: for systematic reviews, extract databases, inclusion/exclusion criteria, screening process, "
|
| 699 |
+
"selected studies, synthesis method, findings, and review limitations instead of ML hyperparameters. "
|
| 700 |
+
"Use null or [] when a field cannot be determined."
|
| 701 |
+
),
|
| 702 |
+
"candidate_paper_card": candidate,
|
| 703 |
+
"section_previews": compact_sections[:16],
|
| 704 |
+
"captions": extracted.get("captions", [])[:10],
|
| 705 |
+
"tables": [_json_safe(t) for t in extracted.get("tables", [])[:4]],
|
| 706 |
+
"metadata": {
|
| 707 |
+
"references_count": len(extracted.get("references", [])),
|
| 708 |
+
"references_removed_from_body": bool(extracted.get("references_text")),
|
| 709 |
+
"appendix_removed_from_body": bool(extracted.get("appendix_text")),
|
| 710 |
+
"quality": extracted.get("quality", {}),
|
| 711 |
+
},
|
| 712 |
+
}
|
| 713 |
+
|
| 714 |
+
|
| 715 |
+
# ---------------------------------------------------------------------------
|
| 716 |
+
# Public API
|
| 717 |
+
# ---------------------------------------------------------------------------
|
| 718 |
+
|
| 719 |
+
def build_paper_card(extracted: Dict[str, Any]) -> Dict[str, Any]:
|
| 720 |
+
title = extracted.get("title") or ""
|
| 721 |
+
abstract = extracted.get("abstract") or ""
|
| 722 |
+
clean_text = extracted.get("clean_text") or extracted.get("text") or ""
|
| 723 |
+
sections = extracted.get("sections", [])
|
| 724 |
+
|
| 725 |
+
intro = _sections_by_role(extracted, ["introduction", "background"])
|
| 726 |
+
methods = _sections_by_role(extracted, ["methodology"])
|
| 727 |
+
experiments = _sections_by_role(extracted, ["experiments"])
|
| 728 |
+
results = _sections_by_role(extracted, ["results", "experiments"])
|
| 729 |
+
discussion = _sections_by_role(extracted, ["discussion"])
|
| 730 |
+
limitations_text = _sections_by_role(extracted, ["limitations"])
|
| 731 |
+
conclusion = _sections_by_role(extracted, ["conclusion"])
|
| 732 |
+
|
| 733 |
+
paper_type = _infer_paper_type(title, abstract, sections, clean_text)
|
| 734 |
+
field = _infer_field(title, abstract, clean_text, paper_type)
|
| 735 |
+
|
| 736 |
+
card: Dict[str, Any] = {
|
| 737 |
+
"title": title or None,
|
| 738 |
+
"field": field,
|
| 739 |
+
"paper_type": paper_type,
|
| 740 |
+
"research_question": _extract_research_question(abstract, intro, title, paper_type) or None,
|
| 741 |
+
"contributions": _extract_contributions(abstract, intro, conclusion, paper_type),
|
| 742 |
+
"methodology": _extract_methodology(methods, experiments, intro, paper_type),
|
| 743 |
+
"datasets_or_data_sources": _extract_datasets(clean_text, methods, experiments, results, paper_type),
|
| 744 |
+
"models_or_methods": _extract_models(clean_text, methods, experiments, paper_type),
|
| 745 |
+
"metrics_or_measurements": _extract_metrics(clean_text, results, experiments, methods, paper_type),
|
| 746 |
+
"key_findings": _extract_findings(results, conclusion, abstract, paper_type),
|
| 747 |
+
"limitations": _extract_limitations(clean_text, limitations_text, discussion, conclusion, paper_type),
|
| 748 |
+
"missing_reproducibility_info": [],
|
| 749 |
+
"metadata": {
|
| 750 |
+
"source_pdf": extracted.get("source_pdf"),
|
| 751 |
+
"num_pages": extracted.get("num_pages"),
|
| 752 |
+
"extraction_engine": extracted.get("extraction_engine"),
|
| 753 |
+
"quality": extracted.get("quality", {}),
|
| 754 |
+
"references_count": len(extracted.get("references", [])),
|
| 755 |
+
"references_removed_from_body": bool(extracted.get("references_text")),
|
| 756 |
+
"appendix_removed_from_body": bool(extracted.get("appendix_text")),
|
| 757 |
+
"section_roles": extracted.get("quality", {}).get("section_roles", []),
|
| 758 |
+
"top_terms": _top_terms(f"{title} {abstract} {clean_text[:5000]}", 10),
|
| 759 |
+
},
|
| 760 |
+
"source_pdf": extracted.get("source_pdf"),
|
| 761 |
+
"annotation_version": "v1.0",
|
| 762 |
+
}
|
| 763 |
+
|
| 764 |
+
card["missing_reproducibility_info"] = _missing_repro_info(card, extracted, paper_type)
|
| 765 |
+
card["llm_evidence_pack"] = _llm_evidence_pack(extracted, card)
|
| 766 |
+
return card
|
src/paper2lab/inference/pipeline.py
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
pipeline.py — Paper2Lab extraction + optional refinement pipeline.
|
| 3 |
+
|
| 4 |
+
PDF
|
| 5 |
+
→ section-aware pdf_loader.extract_pdf()
|
| 6 |
+
→ rule-based paper_card
|
| 7 |
+
→ local modules
|
| 8 |
+
→ optional Nemotron refinement
|
| 9 |
+
|
| 10 |
+
Default behavior is local-only. Nemotron is optional and safe:
|
| 11 |
+
if refinement fails, the local result is still returned.
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
from __future__ import annotations
|
| 15 |
+
|
| 16 |
+
import json
|
| 17 |
+
from pathlib import Path
|
| 18 |
+
from typing import Any, Dict, List, Literal
|
| 19 |
+
|
| 20 |
+
from paper2lab.data.pdf_loader import extract_pdf
|
| 21 |
+
from paper2lab.evaluation.reproducibility import reproducibility_report
|
| 22 |
+
from paper2lab.inference.lab_starter_kit import build_lab_starter_kit
|
| 23 |
+
from paper2lab.inference.paper_card import build_paper_card
|
| 24 |
+
from paper2lab.inference.refinement import refine_optional
|
| 25 |
+
from paper2lab.inference.roadmap import build_reproduction_roadmap
|
| 26 |
+
from paper2lab.inference.visual_explainer import explain_figures_and_tables
|
| 27 |
+
from paper2lab.inference.auto_select import build_auto_best_card
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
RefinementMode = Literal["none", "nemotron"]
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
class PaperPipeline:
|
| 34 |
+
def __init__(
|
| 35 |
+
self,
|
| 36 |
+
pdf_engine: str = "pymupdf",
|
| 37 |
+
include_extraction: bool = True,
|
| 38 |
+
include_llm_pack: bool = True,
|
| 39 |
+
include_local_modules: bool = True,
|
| 40 |
+
refinement_mode: RefinementMode = "none",
|
| 41 |
+
) -> None:
|
| 42 |
+
self.pdf_engine = pdf_engine
|
| 43 |
+
self.include_extraction = include_extraction
|
| 44 |
+
self.include_llm_pack = include_llm_pack
|
| 45 |
+
self.include_local_modules = include_local_modules
|
| 46 |
+
self.refinement_mode = refinement_mode
|
| 47 |
+
|
| 48 |
+
def run(
|
| 49 |
+
self,
|
| 50 |
+
pdf_path: str | Path,
|
| 51 |
+
refinement_mode: RefinementMode | None = None,
|
| 52 |
+
) -> Dict[str, Any]:
|
| 53 |
+
active_refinement_mode = refinement_mode or self.refinement_mode
|
| 54 |
+
|
| 55 |
+
extracted = extract_pdf(pdf_path, engine=self.pdf_engine)
|
| 56 |
+
paper_card = build_paper_card(extracted)
|
| 57 |
+
|
| 58 |
+
if self.include_local_modules:
|
| 59 |
+
reproduction_roadmap = build_reproduction_roadmap(extracted, paper_card)
|
| 60 |
+
figures_and_tables = explain_figures_and_tables(extracted)
|
| 61 |
+
|
| 62 |
+
paper_card["methodology_steps"] = reproduction_roadmap.get("experimental_steps", [])
|
| 63 |
+
paper_card["reproduction_roadmap"] = reproduction_roadmap
|
| 64 |
+
paper_card["figures_and_tables"] = figures_and_tables
|
| 65 |
+
paper_card["reproducibility_score"] = reproducibility_report(extracted, paper_card)
|
| 66 |
+
paper_card["lab_starter_kit"] = build_lab_starter_kit(paper_card)
|
| 67 |
+
|
| 68 |
+
# Keep the LLM evidence pack aligned with the final local candidate.
|
| 69 |
+
if "llm_evidence_pack" in paper_card:
|
| 70 |
+
paper_card["llm_evidence_pack"]["candidate_paper_card"] = {
|
| 71 |
+
k: v for k, v in paper_card.items()
|
| 72 |
+
if k != "llm_evidence_pack"
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
refinement = refine_optional(
|
| 76 |
+
paper_card=paper_card,
|
| 77 |
+
mode=active_refinement_mode,
|
| 78 |
+
return_comparison=True,
|
| 79 |
+
)
|
| 80 |
+
auto_selection = build_auto_best_card(
|
| 81 |
+
local_card=paper_card,
|
| 82 |
+
refinement=refinement,
|
| 83 |
+
)
|
| 84 |
+
|
| 85 |
+
final_paper_card = auto_selection["final_paper_card"]
|
| 86 |
+
|
| 87 |
+
refined_card = refinement.get("after_refinement", paper_card)
|
| 88 |
+
if not isinstance(refined_card, dict):
|
| 89 |
+
refined_card = paper_card
|
| 90 |
+
|
| 91 |
+
if not self.include_llm_pack:
|
| 92 |
+
paper_card = {
|
| 93 |
+
k: v for k, v in paper_card.items()
|
| 94 |
+
if k != "llm_evidence_pack"
|
| 95 |
+
}
|
| 96 |
+
refined_card = {
|
| 97 |
+
k: v for k, v in refined_card.items()
|
| 98 |
+
if k != "llm_evidence_pack"
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
if isinstance(refinement.get("before_refinement"), dict):
|
| 102 |
+
refinement["before_refinement"] = {
|
| 103 |
+
k: v for k, v in refinement["before_refinement"].items()
|
| 104 |
+
if k != "llm_evidence_pack"
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
if isinstance(refinement.get("after_refinement"), dict):
|
| 108 |
+
refinement["after_refinement"] = {
|
| 109 |
+
k: v for k, v in refinement["after_refinement"].items()
|
| 110 |
+
if k != "llm_evidence_pack"
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
result: Dict[str, Any] = {
|
| 114 |
+
"status": "ok",
|
| 115 |
+
"refinement_mode": active_refinement_mode,
|
| 116 |
+
"paper_card": paper_card,
|
| 117 |
+
"paper_card_refined": refinement.get("after_refinement", paper_card),
|
| 118 |
+
"paper_card_final": final_paper_card,
|
| 119 |
+
"refinement": refinement,
|
| 120 |
+
"auto_selection": auto_selection,
|
| 121 |
+
}
|
| 122 |
+
|
| 123 |
+
if self.include_extraction:
|
| 124 |
+
result["extraction"] = {
|
| 125 |
+
"source_pdf": extracted.get("source_pdf"),
|
| 126 |
+
"num_pages": extracted.get("num_pages"),
|
| 127 |
+
"title": extracted.get("title"),
|
| 128 |
+
"abstract": extracted.get("abstract"),
|
| 129 |
+
"extraction_engine": extracted.get("extraction_engine"),
|
| 130 |
+
"quality": extracted.get("quality", {}),
|
| 131 |
+
"metadata": extracted.get("metadata", {}),
|
| 132 |
+
"sections": extracted.get("sections", []),
|
| 133 |
+
"all_sections": extracted.get("all_sections", []),
|
| 134 |
+
"references": extracted.get("references", []),
|
| 135 |
+
"references_text_preview": (extracted.get("references_text") or "")[:2000],
|
| 136 |
+
"appendix_text_preview": (extracted.get("appendix_text") or "")[:1500],
|
| 137 |
+
"boilerplate_text_preview": (extracted.get("boilerplate_text") or "")[:1500],
|
| 138 |
+
"captions": extracted.get("captions", []),
|
| 139 |
+
"tables": extracted.get("tables", []),
|
| 140 |
+
"clean_text_preview": (
|
| 141 |
+
extracted.get("clean_text")
|
| 142 |
+
or extracted.get("text")
|
| 143 |
+
or ""
|
| 144 |
+
)[:3000],
|
| 145 |
+
"raw_text_preview": (extracted.get("raw_text") or "")[:3000],
|
| 146 |
+
"text_preview": (
|
| 147 |
+
extracted.get("clean_text")
|
| 148 |
+
or extracted.get("text")
|
| 149 |
+
or ""
|
| 150 |
+
)[:3000],
|
| 151 |
+
}
|
| 152 |
+
|
| 153 |
+
return result
|
| 154 |
+
|
| 155 |
+
def run_batch(
|
| 156 |
+
self,
|
| 157 |
+
pdf_paths: List[str | Path],
|
| 158 |
+
refinement_mode: RefinementMode | None = None,
|
| 159 |
+
) -> List[Dict[str, Any]]:
|
| 160 |
+
results: List[Dict[str, Any]] = []
|
| 161 |
+
|
| 162 |
+
for path in pdf_paths:
|
| 163 |
+
try:
|
| 164 |
+
results.append(
|
| 165 |
+
self.run(
|
| 166 |
+
path,
|
| 167 |
+
refinement_mode=refinement_mode,
|
| 168 |
+
)
|
| 169 |
+
)
|
| 170 |
+
except Exception as exc:
|
| 171 |
+
results.append({
|
| 172 |
+
"status": "error",
|
| 173 |
+
"source_pdf": str(path),
|
| 174 |
+
"error": str(exc),
|
| 175 |
+
"paper_card": None,
|
| 176 |
+
"paper_card_refined": None,
|
| 177 |
+
"refinement": {
|
| 178 |
+
"status": "error",
|
| 179 |
+
"mode": refinement_mode or self.refinement_mode,
|
| 180 |
+
"error": str(exc),
|
| 181 |
+
},
|
| 182 |
+
"extraction": None,
|
| 183 |
+
})
|
| 184 |
+
|
| 185 |
+
return results
|
| 186 |
+
|
| 187 |
+
def save_json(
|
| 188 |
+
self,
|
| 189 |
+
pdf_path: str | Path,
|
| 190 |
+
output_path: str | Path,
|
| 191 |
+
refinement_mode: RefinementMode | None = None,
|
| 192 |
+
) -> None:
|
| 193 |
+
result = self.run(
|
| 194 |
+
pdf_path,
|
| 195 |
+
refinement_mode=refinement_mode,
|
| 196 |
+
)
|
| 197 |
+
|
| 198 |
+
output_path = Path(output_path)
|
| 199 |
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
| 200 |
+
|
| 201 |
+
with output_path.open("w", encoding="utf-8") as f:
|
| 202 |
+
json.dump(result, f, indent=2, ensure_ascii=False)
|
src/paper2lab/inference/refinement.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from typing import Any, Dict, Literal
|
| 4 |
+
|
| 5 |
+
from paper2lab.inference.nemotron_refiner import refine_with_nemotron
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
RefinementMode = Literal["none", "nemotron"]
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def refine_optional(
|
| 12 |
+
paper_card: Dict[str, Any],
|
| 13 |
+
mode: RefinementMode = "none",
|
| 14 |
+
return_comparison: bool = True,
|
| 15 |
+
) -> Dict[str, Any]:
|
| 16 |
+
"""
|
| 17 |
+
Default: keep local hardcoded/rule-based extraction.
|
| 18 |
+
Optional: refine the candidate with Nemotron.
|
| 19 |
+
"""
|
| 20 |
+
|
| 21 |
+
if mode == "none":
|
| 22 |
+
return {
|
| 23 |
+
"status": "skipped",
|
| 24 |
+
"mode": "none",
|
| 25 |
+
"before_refinement": paper_card,
|
| 26 |
+
"after_refinement": paper_card,
|
| 27 |
+
"diff_summary": {
|
| 28 |
+
"changed_fields": [],
|
| 29 |
+
"added_fields": [],
|
| 30 |
+
"removed_fields": [],
|
| 31 |
+
},
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
if mode == "nemotron":
|
| 35 |
+
pack = paper_card.get("llm_evidence_pack")
|
| 36 |
+
|
| 37 |
+
if not pack:
|
| 38 |
+
return {
|
| 39 |
+
"status": "error",
|
| 40 |
+
"mode": "nemotron",
|
| 41 |
+
"error": "Missing llm_evidence_pack in paper_card.",
|
| 42 |
+
"before_refinement": paper_card,
|
| 43 |
+
"after_refinement": paper_card,
|
| 44 |
+
"diff_summary": {
|
| 45 |
+
"changed_fields": [],
|
| 46 |
+
"added_fields": [],
|
| 47 |
+
"removed_fields": [],
|
| 48 |
+
},
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
try:
|
| 52 |
+
return refine_with_nemotron(
|
| 53 |
+
llm_evidence_pack=pack,
|
| 54 |
+
return_comparison=return_comparison,
|
| 55 |
+
)
|
| 56 |
+
except Exception as exc:
|
| 57 |
+
return {
|
| 58 |
+
"status": "error",
|
| 59 |
+
"mode": "nemotron",
|
| 60 |
+
"error": str(exc),
|
| 61 |
+
"before_refinement": paper_card,
|
| 62 |
+
"after_refinement": paper_card,
|
| 63 |
+
"diff_summary": {
|
| 64 |
+
"changed_fields": [],
|
| 65 |
+
"added_fields": [],
|
| 66 |
+
"removed_fields": [],
|
| 67 |
+
},
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
raise ValueError(f"Unsupported refinement mode: {mode}")
|
src/paper2lab/inference/roadmap.py
ADDED
|
@@ -0,0 +1,436 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
roadmap.py — Deterministic reproduction-roadmap builder for Paper2Lab.
|
| 3 |
+
|
| 4 |
+
Input: section-aware extraction dict + optional paper_card.
|
| 5 |
+
Output: structured reproduction roadmap candidate.
|
| 6 |
+
|
| 7 |
+
This module is intentionally local/rule-based. Modal/Nemotron should refine this
|
| 8 |
+
later, not replace it.
|
| 9 |
+
|
| 10 |
+
Design goals:
|
| 11 |
+
- Keep the public API stable: build_reproduction_roadmap(extracted, paper_card=None)
|
| 12 |
+
- Be paper-type aware: ML papers, systematic reviews, clinical/general papers.
|
| 13 |
+
- Avoid noisy merged PDF lines from two-column layouts.
|
| 14 |
+
- Keep concise evidence with section/page locations.
|
| 15 |
+
"""
|
| 16 |
+
|
| 17 |
+
from __future__ import annotations
|
| 18 |
+
|
| 19 |
+
import re
|
| 20 |
+
from typing import Any, Dict, List, Sequence
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
# ---------------------------------------------------------------------------
|
| 24 |
+
# Marker banks
|
| 25 |
+
# ---------------------------------------------------------------------------
|
| 26 |
+
|
| 27 |
+
_DATASET_MARKERS = [
|
| 28 |
+
"dataset", "data", "corpus", "benchmark", "training set", "test set", "validation set",
|
| 29 |
+
"patients", "samples", "records", "images", "sentences", "tokens", "articles", "studies",
|
| 30 |
+
"pubmed", "scopus", "web of knowledge", "eric", "educational resources and information center",
|
| 31 |
+
"cochrane", "wmt", "imagenet", "cifar", "mnist", "glue", "squad", "penn treebank", "wsj",
|
| 32 |
+
]
|
| 33 |
+
|
| 34 |
+
_KNOWN_DATA_SOURCES = [
|
| 35 |
+
"PubMed",
|
| 36 |
+
"Scopus",
|
| 37 |
+
"Web of Knowledge",
|
| 38 |
+
"ERIC",
|
| 39 |
+
"Educational Resources and Information Center",
|
| 40 |
+
"Cochrane",
|
| 41 |
+
"WMT 2014",
|
| 42 |
+
"WMT",
|
| 43 |
+
"ImageNet",
|
| 44 |
+
"CIFAR-10",
|
| 45 |
+
"CIFAR-100",
|
| 46 |
+
"MNIST",
|
| 47 |
+
"GLUE",
|
| 48 |
+
"SuperGLUE",
|
| 49 |
+
"SQuAD",
|
| 50 |
+
"Penn Treebank",
|
| 51 |
+
"Wall Street Journal",
|
| 52 |
+
"WSJ",
|
| 53 |
+
]
|
| 54 |
+
|
| 55 |
+
_SOFTWARE_MARKERS = [
|
| 56 |
+
"python", "pytorch", "tensorflow", "keras", "scikit-learn", "sklearn", "r", "matlab",
|
| 57 |
+
"cuda", "gpu", "github", "repository", "code", "implementation", "package", "library",
|
| 58 |
+
"endnote", "excel", "spss", "stata", "prisma", "docker",
|
| 59 |
+
]
|
| 60 |
+
|
| 61 |
+
_KNOWN_SOFTWARE = [
|
| 62 |
+
"Python", "PyTorch", "TensorFlow", "Keras", "Scikit-learn", "R", "MATLAB",
|
| 63 |
+
"CUDA", "Docker", "GitHub", "EndNote", "Excel", "SPSS", "Stata", "PRISMA",
|
| 64 |
+
]
|
| 65 |
+
|
| 66 |
+
_EXPERIMENT_MARKERS = [
|
| 67 |
+
"trained", "fine-tuned", "pre-trained", "evaluated", "optimized", "searched", "screened",
|
| 68 |
+
"selected", "included", "excluded", "randomized", "split", "preprocessed", "augmented",
|
| 69 |
+
"we train", "we trained", "we evaluate", "we evaluated", "we search", "we searched",
|
| 70 |
+
"inclusion criteria", "exclusion criteria", "eligibility criteria", "data extraction",
|
| 71 |
+
"titles and abstracts", "duplicate", "endnote", "excel",
|
| 72 |
+
]
|
| 73 |
+
|
| 74 |
+
_EVAL_MARKERS = [
|
| 75 |
+
"accuracy", "precision", "recall", "f1", "auc", "roc", "bleu", "rouge", "perplexity",
|
| 76 |
+
"loss", "rmse", "mae", "statistical", "p-value", "confidence interval", "evaluation",
|
| 77 |
+
"assessed", "measured", "score", "metric", "kirkpatrick", "quality assessment", "meta-analysis",
|
| 78 |
+
"best evidence medical education", "beme", "final review", "included studies",
|
| 79 |
+
]
|
| 80 |
+
|
| 81 |
+
_OUTPUT_MARKERS = [
|
| 82 |
+
"achieved", "achieves", "outperformed", "outperforms", "improved", "score", "accuracy",
|
| 83 |
+
"bleu", "f1", "auc", "included", "selected", "final review", "articles", "studies",
|
| 84 |
+
]
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
# ---------------------------------------------------------------------------
|
| 88 |
+
# Cleaning and sentence filtering
|
| 89 |
+
# ---------------------------------------------------------------------------
|
| 90 |
+
|
| 91 |
+
def _clean(text: str) -> str:
|
| 92 |
+
text = text or ""
|
| 93 |
+
text = text.replace("\x00", " ").replace("\u00a0", " ")
|
| 94 |
+
text = text.replace("\ufb01", "fi").replace("\ufb02", "fl")
|
| 95 |
+
text = re.sub(r"\b10\.\d{4,9}/[-._;()/:A-Za-z0-9]+", "", text)
|
| 96 |
+
text = re.sub(r"\s+", " ", text)
|
| 97 |
+
text = re.sub(r"\s+([.,;:])", r"\1", text)
|
| 98 |
+
return text.strip(" .;:\n\t")
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
def _is_noisy(sentence: str) -> bool:
|
| 102 |
+
s = _clean(sentence)
|
| 103 |
+
low = s.lower()
|
| 104 |
+
|
| 105 |
+
bad_fragments = [
|
| 106 |
+
"corresponding author",
|
| 107 |
+
"how to cite",
|
| 108 |
+
"access this article online",
|
| 109 |
+
"department of",
|
| 110 |
+
"university of",
|
| 111 |
+
"medical sciences",
|
| 112 |
+
"received:",
|
| 113 |
+
"accepted:",
|
| 114 |
+
"published:",
|
| 115 |
+
"copyright",
|
| 116 |
+
"license",
|
| 117 |
+
"all rights reserved",
|
| 118 |
+
"gmail.com",
|
| 119 |
+
"@",
|
| 120 |
+
"table of contents",
|
| 121 |
+
"journal of education and health promotion",
|
| 122 |
+
"endnote teachers",
|
| 123 |
+
"being accordingly",
|
| 124 |
+
"need this systematic review",
|
| 125 |
+
"the that",
|
| 126 |
+
"of the there",
|
| 127 |
+
"the the evidence",
|
| 128 |
+
"table 1:",
|
| 129 |
+
"table 2:",
|
| 130 |
+
"table 3:",
|
| 131 |
+
"table 4:",
|
| 132 |
+
]
|
| 133 |
+
if any(x in low for x in bad_fragments):
|
| 134 |
+
return True
|
| 135 |
+
|
| 136 |
+
if len(s.split()) > 48:
|
| 137 |
+
return True
|
| 138 |
+
|
| 139 |
+
if len(re.findall(r"\[\d+", s)) >= 2:
|
| 140 |
+
return True
|
| 141 |
+
|
| 142 |
+
if sentence.count("|") >= 2 or sentence.count("%") >= 6:
|
| 143 |
+
return True
|
| 144 |
+
|
| 145 |
+
# Many merged two-column artifacts have two unrelated capitalized clauses
|
| 146 |
+
# without a normal sentence boundary.
|
| 147 |
+
if re.search(r"\b(the|this|therefore|besides|fisher)\b.+\b(the|this|therefore|accordingly)\b", low) and len(s.split()) > 34:
|
| 148 |
+
return True
|
| 149 |
+
|
| 150 |
+
# Reject strings that look like fragments rather than standalone steps.
|
| 151 |
+
if len(s.split()) < 5:
|
| 152 |
+
return True
|
| 153 |
+
|
| 154 |
+
return False
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
def _split_sentences(text: str) -> List[str]:
|
| 158 |
+
text = _clean(text)
|
| 159 |
+
# Also split before uppercase section labels that PyMuPDF sometimes merges.
|
| 160 |
+
text = re.sub(r"\b(ABSTRACT|INTRODUCTION|MATERIALS AND METHODS|RESULTS|DISCUSSION|CONCLUSION):", r". \1:", text)
|
| 161 |
+
raw = re.split(r"(?<=[.!?])\s+(?=[A-Z0-9])", text)
|
| 162 |
+
out: List[str] = []
|
| 163 |
+
for s in raw:
|
| 164 |
+
s = _clean(s)
|
| 165 |
+
if 35 <= len(s) <= 340 and not _is_noisy(s):
|
| 166 |
+
out.append(s)
|
| 167 |
+
return out
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
def _dedupe_strings(items: Sequence[str]) -> List[str]:
|
| 171 |
+
seen: set[str] = set()
|
| 172 |
+
out: List[str] = []
|
| 173 |
+
for item in items:
|
| 174 |
+
clean = _clean(str(item))
|
| 175 |
+
key = re.sub(r"[^a-z0-9]+", " ", clean.lower()).strip()[:180]
|
| 176 |
+
if key and key not in seen:
|
| 177 |
+
seen.add(key)
|
| 178 |
+
out.append(clean)
|
| 179 |
+
return out
|
| 180 |
+
|
| 181 |
+
|
| 182 |
+
def _dedupe_dicts(items: List[Dict[str, Any]], key_name: str = "text") -> List[Dict[str, Any]]:
|
| 183 |
+
seen: set[str] = set()
|
| 184 |
+
out: List[Dict[str, Any]] = []
|
| 185 |
+
for item in items:
|
| 186 |
+
text = _clean(str(item.get(key_name, "")))
|
| 187 |
+
key = re.sub(r"[^a-z0-9]+", " ", text.lower()).strip()[:180]
|
| 188 |
+
if key and key not in seen:
|
| 189 |
+
seen.add(key)
|
| 190 |
+
out.append(item)
|
| 191 |
+
return out
|
| 192 |
+
|
| 193 |
+
|
| 194 |
+
# ---------------------------------------------------------------------------
|
| 195 |
+
# Section helpers
|
| 196 |
+
# ---------------------------------------------------------------------------
|
| 197 |
+
|
| 198 |
+
def _section_texts(extracted: Dict[str, Any], roles: set[str]) -> List[Dict[str, Any]]:
|
| 199 |
+
rows: List[Dict[str, Any]] = []
|
| 200 |
+
blocked_titles = {"front matter", "keywords", "keywords:", "table of contents"}
|
| 201 |
+
paper_title = _clean(extracted.get("title") or "").lower()
|
| 202 |
+
|
| 203 |
+
for sec in extracted.get("sections", []):
|
| 204 |
+
role = sec.get("role", "other")
|
| 205 |
+
title = _clean(sec.get("title") or "")
|
| 206 |
+
low_title = title.lower()
|
| 207 |
+
if role not in roles:
|
| 208 |
+
continue
|
| 209 |
+
if low_title in blocked_titles:
|
| 210 |
+
continue
|
| 211 |
+
if paper_title and low_title == paper_title:
|
| 212 |
+
continue
|
| 213 |
+
rows.append(sec)
|
| 214 |
+
return rows
|
| 215 |
+
|
| 216 |
+
|
| 217 |
+
def _find_evidence(
|
| 218 |
+
sections: List[Dict[str, Any]],
|
| 219 |
+
markers: List[str],
|
| 220 |
+
limit: int = 8,
|
| 221 |
+
require_number: bool = False,
|
| 222 |
+
) -> List[Dict[str, Any]]:
|
| 223 |
+
hits: List[Dict[str, Any]] = []
|
| 224 |
+
marker_lows = [m.lower() for m in markers]
|
| 225 |
+
|
| 226 |
+
for sec in sections:
|
| 227 |
+
title = sec.get("title", "")
|
| 228 |
+
role = sec.get("role", "other")
|
| 229 |
+
for sent in _split_sentences(sec.get("text", "")):
|
| 230 |
+
low = sent.lower()
|
| 231 |
+
if require_number and not re.search(r"\d", sent):
|
| 232 |
+
continue
|
| 233 |
+
if any(m in low for m in marker_lows):
|
| 234 |
+
hits.append({
|
| 235 |
+
"text": sent,
|
| 236 |
+
"section": title,
|
| 237 |
+
"role": role,
|
| 238 |
+
"page_start": sec.get("page_start"),
|
| 239 |
+
"page_end": sec.get("page_end"),
|
| 240 |
+
})
|
| 241 |
+
if len(hits) >= limit:
|
| 242 |
+
return _dedupe_dicts(hits)
|
| 243 |
+
return _dedupe_dicts(hits)
|
| 244 |
+
|
| 245 |
+
|
| 246 |
+
# ---------------------------------------------------------------------------
|
| 247 |
+
# Structured extraction helpers
|
| 248 |
+
# ---------------------------------------------------------------------------
|
| 249 |
+
|
| 250 |
+
def _extract_known_sources(text: str) -> List[str]:
|
| 251 |
+
low = text.lower()
|
| 252 |
+
found: List[str] = []
|
| 253 |
+
aliases = {
|
| 254 |
+
"PubMed": ["pubmed"],
|
| 255 |
+
"Scopus": ["scopus"],
|
| 256 |
+
"Web of Knowledge": ["web of knowledge", "thomson reuters"],
|
| 257 |
+
"ERIC": ["eric", "educational resources and information center"],
|
| 258 |
+
"Educational Resources and Information Center": ["educational resources and information center"],
|
| 259 |
+
"Cochrane": ["cochrane"],
|
| 260 |
+
"WMT 2014": ["wmt 2014"],
|
| 261 |
+
"WMT": ["wmt"],
|
| 262 |
+
"ImageNet": ["imagenet"],
|
| 263 |
+
"CIFAR-10": ["cifar-10", "cifar 10"],
|
| 264 |
+
"CIFAR-100": ["cifar-100", "cifar 100"],
|
| 265 |
+
"MNIST": ["mnist"],
|
| 266 |
+
"GLUE": ["glue"],
|
| 267 |
+
"SuperGLUE": ["superglue"],
|
| 268 |
+
"SQuAD": ["squad"],
|
| 269 |
+
"Penn Treebank": ["penn treebank"],
|
| 270 |
+
"Wall Street Journal": ["wall street journal"],
|
| 271 |
+
"WSJ": ["wsj"],
|
| 272 |
+
}
|
| 273 |
+
for canonical, keys in aliases.items():
|
| 274 |
+
if any(k in low for k in keys):
|
| 275 |
+
found.append(canonical)
|
| 276 |
+
return _dedupe_strings(found)
|
| 277 |
+
|
| 278 |
+
|
| 279 |
+
def _extract_software_from_text(text: str) -> List[str]:
|
| 280 |
+
low = text.lower()
|
| 281 |
+
found: List[str] = []
|
| 282 |
+
for name in _KNOWN_SOFTWARE:
|
| 283 |
+
# Avoid false positive: single-letter R appears everywhere, require context.
|
| 284 |
+
if name == "R":
|
| 285 |
+
if re.search(r"\bR\b", text) and any(x in low for x in ["statistical", "analysis", "software", "package"]):
|
| 286 |
+
found.append(name)
|
| 287 |
+
continue
|
| 288 |
+
if name.lower() in low:
|
| 289 |
+
found.append(name)
|
| 290 |
+
return _dedupe_strings(found)
|
| 291 |
+
|
| 292 |
+
|
| 293 |
+
def _extract_count_outputs(text: str) -> List[str]:
|
| 294 |
+
outputs: List[str] = []
|
| 295 |
+
patterns = [
|
| 296 |
+
r"\b(?:totally|overall|in total),?\s*\d+[\w\s-]{0,40}\b(?:articles|studies|records|abstracts|patients|samples)\b",
|
| 297 |
+
r"\b(?:final review|review)\s+(?:included|enrolled)\s+\d+\s+(?:articles|studies)\b",
|
| 298 |
+
r"\b\d+\s+(?:articles|studies|records|abstracts|patients|samples)\s+(?:were|was)\s+(?:selected|included|enrolled|identified)\b",
|
| 299 |
+
r"\bbetween\s+[A-Z][a-z]+\s+\d{4}\s+and\s+[A-Z][a-z]+\s+\d{4}\b",
|
| 300 |
+
r"\bfrom\s+[A-Z][a-z]+\s+\d{4}\s+to\s+[A-Z][a-z]+\s+\d{4}\b",
|
| 301 |
+
]
|
| 302 |
+
for pat in patterns:
|
| 303 |
+
for m in re.finditer(pat, text, flags=re.IGNORECASE):
|
| 304 |
+
outputs.append(_clean(m.group(0)))
|
| 305 |
+
return _dedupe_strings(outputs)[:8]
|
| 306 |
+
|
| 307 |
+
|
| 308 |
+
def _compact_datasets(paper_card: Dict[str, Any], evidence: List[Dict[str, Any]], all_text: str) -> List[str]:
|
| 309 |
+
datasets: List[str] = []
|
| 310 |
+
|
| 311 |
+
# Prefer canonical names over noisy sentences.
|
| 312 |
+
datasets.extend(_extract_known_sources(all_text))
|
| 313 |
+
|
| 314 |
+
# Keep short, clean card items.
|
| 315 |
+
for item in paper_card.get("datasets_or_data_sources") or []:
|
| 316 |
+
clean = _clean(item)
|
| 317 |
+
if not clean or _is_noisy(clean):
|
| 318 |
+
continue
|
| 319 |
+
if len(clean.split()) <= 12:
|
| 320 |
+
datasets.append(clean)
|
| 321 |
+
|
| 322 |
+
# Add concise evidence only if it is clean and informative.
|
| 323 |
+
for hit in evidence:
|
| 324 |
+
clean = _clean(hit.get("text", ""))
|
| 325 |
+
if not clean or _is_noisy(clean):
|
| 326 |
+
continue
|
| 327 |
+
if len(clean.split()) <= 24:
|
| 328 |
+
datasets.append(clean)
|
| 329 |
+
|
| 330 |
+
return _dedupe_strings(datasets)[:10]
|
| 331 |
+
|
| 332 |
+
|
| 333 |
+
def _roadmap_level(missing_count: int, detected_count: int, noisy_evidence_count: int = 0) -> str:
|
| 334 |
+
# Be less overconfident when evidence is present but noisy.
|
| 335 |
+
if noisy_evidence_count >= 4:
|
| 336 |
+
return "partial" if detected_count >= 5 else "weak"
|
| 337 |
+
if detected_count >= 8 and missing_count <= 2:
|
| 338 |
+
return "strong"
|
| 339 |
+
if detected_count >= 5 and missing_count <= 4:
|
| 340 |
+
return "partial"
|
| 341 |
+
return "weak"
|
| 342 |
+
|
| 343 |
+
|
| 344 |
+
# ---------------------------------------------------------------------------
|
| 345 |
+
# Public API
|
| 346 |
+
# ---------------------------------------------------------------------------
|
| 347 |
+
|
| 348 |
+
def build_reproduction_roadmap(
|
| 349 |
+
extracted: Dict[str, Any],
|
| 350 |
+
paper_card: Dict[str, Any] | None = None,
|
| 351 |
+
) -> Dict[str, Any]:
|
| 352 |
+
"""Build a local, evidence-grounded reproduction roadmap candidate."""
|
| 353 |
+
paper_card = paper_card or {}
|
| 354 |
+
paper_type = paper_card.get("paper_type", "general_research")
|
| 355 |
+
|
| 356 |
+
method_sections = _section_texts(extracted, {"methodology", "experiments"})
|
| 357 |
+
result_sections = _section_texts(extracted, {"results", "discussion", "conclusion"})
|
| 358 |
+
all_body_sections = _section_texts(
|
| 359 |
+
extracted,
|
| 360 |
+
{"methodology", "experiments", "results", "discussion", "conclusion", "introduction", "abstract"},
|
| 361 |
+
)
|
| 362 |
+
|
| 363 |
+
all_text = "\n".join(sec.get("text", "") for sec in all_body_sections)
|
| 364 |
+
method_text = "\n".join(sec.get("text", "") for sec in method_sections)
|
| 365 |
+
|
| 366 |
+
dataset_evidence = _find_evidence(all_body_sections, _DATASET_MARKERS, limit=12)
|
| 367 |
+
software = _extract_software_from_text(all_text)
|
| 368 |
+
experimental_steps = _find_evidence(method_sections or all_body_sections, _EXPERIMENT_MARKERS, limit=10)
|
| 369 |
+
evaluation = _find_evidence(result_sections + method_sections, _EVAL_MARKERS, limit=10)
|
| 370 |
+
|
| 371 |
+
datasets = _compact_datasets(paper_card, dataset_evidence, all_text)
|
| 372 |
+
|
| 373 |
+
expected_outputs: List[str] = []
|
| 374 |
+
for item in paper_card.get("metrics_or_measurements", [])[:5]:
|
| 375 |
+
clean = _clean(item)
|
| 376 |
+
if clean and not _is_noisy(clean):
|
| 377 |
+
expected_outputs.append(clean)
|
| 378 |
+
for item in paper_card.get("key_findings", [])[:5]:
|
| 379 |
+
clean = _clean(item)
|
| 380 |
+
if clean and not _is_noisy(clean):
|
| 381 |
+
expected_outputs.append(clean)
|
| 382 |
+
expected_outputs.extend(_extract_count_outputs(all_text))
|
| 383 |
+
expected_outputs = _dedupe_strings(expected_outputs)[:8]
|
| 384 |
+
|
| 385 |
+
missing: List[str] = []
|
| 386 |
+
if not datasets:
|
| 387 |
+
missing.append("dataset or source corpus details are missing")
|
| 388 |
+
if not experimental_steps:
|
| 389 |
+
missing.append("experimental or procedural steps are missing")
|
| 390 |
+
if not evaluation:
|
| 391 |
+
missing.append("evaluation procedure is missing")
|
| 392 |
+
if paper_type == "machine_learning" and not software:
|
| 393 |
+
missing.append("software/framework requirements are missing")
|
| 394 |
+
if paper_type == "systematic_review":
|
| 395 |
+
low = method_text.lower() or all_text.lower()
|
| 396 |
+
if not any(x in low for x in ["inclusion criteria", "eligibility criteria"]):
|
| 397 |
+
missing.append("inclusion criteria are missing")
|
| 398 |
+
if "exclusion criteria" not in low:
|
| 399 |
+
missing.append("exclusion criteria are missing")
|
| 400 |
+
if not any(x in low for x in ["quality assessment", "risk of bias", "best evidence medical education", "valid tool"]):
|
| 401 |
+
missing.append("quality assessment method is missing")
|
| 402 |
+
|
| 403 |
+
noisy_count = 0
|
| 404 |
+
for item in experimental_steps + evaluation + dataset_evidence:
|
| 405 |
+
if _is_noisy(item.get("text", "")):
|
| 406 |
+
noisy_count += 1
|
| 407 |
+
|
| 408 |
+
detected_count = len(datasets) + len(software) + len(experimental_steps) + len(evaluation) + len(expected_outputs)
|
| 409 |
+
|
| 410 |
+
return {
|
| 411 |
+
"paper_type": paper_type,
|
| 412 |
+
"datasets": datasets,
|
| 413 |
+
"software_requirements": software,
|
| 414 |
+
"experimental_steps": [
|
| 415 |
+
{
|
| 416 |
+
"step": i + 1,
|
| 417 |
+
"description": x["text"],
|
| 418 |
+
"section": x["section"],
|
| 419 |
+
"page_start": x.get("page_start"),
|
| 420 |
+
"page_end": x.get("page_end"),
|
| 421 |
+
}
|
| 422 |
+
for i, x in enumerate(experimental_steps[:10])
|
| 423 |
+
],
|
| 424 |
+
"evaluation_procedure": [
|
| 425 |
+
{
|
| 426 |
+
"description": x["text"],
|
| 427 |
+
"section": x["section"],
|
| 428 |
+
"page_start": x.get("page_start"),
|
| 429 |
+
"page_end": x.get("page_end"),
|
| 430 |
+
}
|
| 431 |
+
for x in evaluation[:10]
|
| 432 |
+
],
|
| 433 |
+
"expected_outputs": expected_outputs,
|
| 434 |
+
"missing_for_reproduction": missing,
|
| 435 |
+
"estimated_reproducibility": _roadmap_level(len(missing), detected_count, noisy_count),
|
| 436 |
+
}
|
src/paper2lab/inference/visual_explainer.py
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
visual_explainer.py — Caption/table understanding for Paper2Lab.
|
| 3 |
+
|
| 4 |
+
This is caption-grounded visual understanding. It does not use image pixels yet.
|
| 5 |
+
For the hackathon MVP, this gives useful figure/table summaries without GPU cost.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import re
|
| 11 |
+
from typing import Any, Dict, List
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
_METRIC_WORDS = [
|
| 15 |
+
"accuracy", "precision", "recall", "f1", "auc", "bleu", "rouge", "loss", "perplexity",
|
| 16 |
+
"score", "performance", "results", "comparison", "evaluation", "training cost", "p-value",
|
| 17 |
+
]
|
| 18 |
+
|
| 19 |
+
_METHOD_WORDS = [
|
| 20 |
+
"architecture", "pipeline", "framework", "workflow", "model", "method", "procedure",
|
| 21 |
+
"attention", "encoder", "decoder", "algorithm", "overview",
|
| 22 |
+
]
|
| 23 |
+
|
| 24 |
+
_DATA_WORDS = [
|
| 25 |
+
"dataset", "data", "samples", "patients", "images", "sentences", "articles", "studies",
|
| 26 |
+
"distribution", "statistics", "characteristics",
|
| 27 |
+
]
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def _clean(text: str) -> str:
|
| 31 |
+
text = text or ""
|
| 32 |
+
text = re.sub(r"\s+", " ", text)
|
| 33 |
+
return text.strip(" .;:\n\t")
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def _label_type(label: str) -> str:
|
| 37 |
+
low = (label or "").lower()
|
| 38 |
+
if "table" in low or "tbl" in low:
|
| 39 |
+
return "table"
|
| 40 |
+
if "figure" in low or "fig" in low:
|
| 41 |
+
return "figure"
|
| 42 |
+
if "algorithm" in low:
|
| 43 |
+
return "algorithm"
|
| 44 |
+
if "scheme" in low:
|
| 45 |
+
return "scheme"
|
| 46 |
+
return "visual"
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def _purpose(caption: str, visual_type: str) -> str:
|
| 50 |
+
low = caption.lower()
|
| 51 |
+
if any(w in low for w in _METHOD_WORDS):
|
| 52 |
+
return "method_or_architecture"
|
| 53 |
+
if any(w in low for w in _METRIC_WORDS):
|
| 54 |
+
return "results_or_evaluation"
|
| 55 |
+
if any(w in low for w in _DATA_WORDS):
|
| 56 |
+
return "data_or_dataset_description"
|
| 57 |
+
if visual_type == "table":
|
| 58 |
+
return "structured_results_or_metadata"
|
| 59 |
+
return "illustrative_figure"
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def _summary_from_caption(label: str, caption: str, visual_type: str) -> str:
|
| 63 |
+
caption = _clean(caption)
|
| 64 |
+
if not caption:
|
| 65 |
+
return f"{label} is a {visual_type}, but no caption text was extracted."
|
| 66 |
+
# Keep concise, but grounded in caption text.
|
| 67 |
+
if len(caption) <= 220:
|
| 68 |
+
return caption
|
| 69 |
+
first_sentence = re.split(r"(?<=[.!?])\s+", caption)[0]
|
| 70 |
+
return _clean(first_sentence[:260])
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def _summarize_table_data(table: Dict[str, Any]) -> str | None:
|
| 74 |
+
data = table.get("data")
|
| 75 |
+
if not isinstance(data, list) or not data:
|
| 76 |
+
return None
|
| 77 |
+
rows = [r for r in data if isinstance(r, list)]
|
| 78 |
+
if not rows:
|
| 79 |
+
return None
|
| 80 |
+
n_rows = len(rows)
|
| 81 |
+
n_cols = max((len(r) for r in rows), default=0)
|
| 82 |
+
header = rows[0] if rows else []
|
| 83 |
+
header_text = ", ".join(str(x).strip() for x in header if str(x).strip())[:180]
|
| 84 |
+
if header_text:
|
| 85 |
+
return f"Extracted table with approximately {n_rows} rows and {n_cols} columns. Header fields include: {header_text}."
|
| 86 |
+
return f"Extracted table with approximately {n_rows} rows and {n_cols} columns."
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
def explain_figures_and_tables(extracted: Dict[str, Any]) -> List[Dict[str, Any]]:
|
| 90 |
+
"""Return concise explanations for extracted captions and tables."""
|
| 91 |
+
outputs: List[Dict[str, Any]] = []
|
| 92 |
+
|
| 93 |
+
for cap in extracted.get("captions", []) or []:
|
| 94 |
+
label = cap.get("label", "")
|
| 95 |
+
caption = _clean(cap.get("caption", ""))
|
| 96 |
+
visual_type = _label_type(label)
|
| 97 |
+
outputs.append({
|
| 98 |
+
"label": label,
|
| 99 |
+
"type": visual_type,
|
| 100 |
+
"purpose": _purpose(caption, visual_type),
|
| 101 |
+
"summary": _summary_from_caption(label, caption, visual_type),
|
| 102 |
+
"evidence": caption,
|
| 103 |
+
"page_number": cap.get("page_number"),
|
| 104 |
+
})
|
| 105 |
+
|
| 106 |
+
# Add tables that have data but no caption match.
|
| 107 |
+
existing_table_pages = {(o.get("page_number"), o.get("label")) for o in outputs if o.get("type") == "table"}
|
| 108 |
+
for table in extracted.get("tables", []) or []:
|
| 109 |
+
page = table.get("page_number")
|
| 110 |
+
caption = _clean(table.get("caption") or "")
|
| 111 |
+
label = f"Table extracted on page {page}" if page is not None else "Extracted table"
|
| 112 |
+
if (page, label) in existing_table_pages:
|
| 113 |
+
continue
|
| 114 |
+
data_summary = _summarize_table_data(table)
|
| 115 |
+
outputs.append({
|
| 116 |
+
"label": label,
|
| 117 |
+
"type": "table",
|
| 118 |
+
"purpose": _purpose(caption or data_summary or "", "table"),
|
| 119 |
+
"summary": caption or data_summary or "A table was detected, but its content could not be summarized reliably.",
|
| 120 |
+
"evidence": caption or data_summary or "",
|
| 121 |
+
"page_number": page,
|
| 122 |
+
})
|
| 123 |
+
|
| 124 |
+
return outputs[:20]
|
src/paper2lab/prompts/extraction.txt
ADDED
|
File without changes
|
src/paper2lab/prompts/reproduction.txt
ADDED
|
File without changes
|
src/paper2lab/prompts/summary.txt
ADDED
|
File without changes
|
src/paper2lab/rag/__init__.py
ADDED
|
File without changes
|
src/paper2lab/rag/indexer.py
ADDED
|
@@ -0,0 +1,391 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
indexer.py — Local FAISS RAG index for Paper2Lab.
|
| 3 |
+
|
| 4 |
+
Purpose
|
| 5 |
+
-------
|
| 6 |
+
Build a retrieval index from section-aware PDF extraction output.
|
| 7 |
+
|
| 8 |
+
Default mode is local and cheap:
|
| 9 |
+
sentence-transformers + FAISS
|
| 10 |
+
|
| 11 |
+
Optional mode supports NVIDIA/Nemotron-style embedding endpoints through
|
| 12 |
+
langchain_nvidia_ai_endpoints when NVIDIA_API_KEY is available.
|
| 13 |
+
|
| 14 |
+
The public functions are intentionally simple:
|
| 15 |
+
build_rag_index(extracted)
|
| 16 |
+
save_rag_index(index, path)
|
| 17 |
+
load_rag_index(path, embedder_backend="local")
|
| 18 |
+
|
| 19 |
+
No LLM generation happens here. qa.py handles answer synthesis.
|
| 20 |
+
"""
|
| 21 |
+
|
| 22 |
+
from __future__ import annotations
|
| 23 |
+
|
| 24 |
+
import json
|
| 25 |
+
import pickle
|
| 26 |
+
import re
|
| 27 |
+
from dataclasses import dataclass, asdict
|
| 28 |
+
from pathlib import Path
|
| 29 |
+
from typing import Any, Dict, Iterable, List, Literal, Optional
|
| 30 |
+
|
| 31 |
+
import numpy as np
|
| 32 |
+
|
| 33 |
+
try:
|
| 34 |
+
import faiss # type: ignore
|
| 35 |
+
except ImportError as exc: # pragma: no cover
|
| 36 |
+
raise ImportError(
|
| 37 |
+
"faiss-cpu is required for Paper2Lab RAG. Install with: pip install faiss-cpu"
|
| 38 |
+
) from exc
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
EmbedderBackend = Literal["local", "nvidia"]
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
@dataclass
|
| 45 |
+
class RagChunk:
|
| 46 |
+
chunk_id: str
|
| 47 |
+
text: str
|
| 48 |
+
source_type: str # section | caption | table | metadata
|
| 49 |
+
title: str
|
| 50 |
+
role: str = "other"
|
| 51 |
+
page_start: Optional[int] = None
|
| 52 |
+
page_end: Optional[int] = None
|
| 53 |
+
label: Optional[str] = None
|
| 54 |
+
score: Optional[float] = None
|
| 55 |
+
|
| 56 |
+
def to_evidence(self) -> Dict[str, Any]:
|
| 57 |
+
return {
|
| 58 |
+
"chunk_id": self.chunk_id,
|
| 59 |
+
"source_type": self.source_type,
|
| 60 |
+
"title": self.title,
|
| 61 |
+
"role": self.role,
|
| 62 |
+
"page_start": self.page_start,
|
| 63 |
+
"page_end": self.page_end,
|
| 64 |
+
"label": self.label,
|
| 65 |
+
"score": self.score,
|
| 66 |
+
"text": self.text,
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
@dataclass
|
| 71 |
+
class RagIndex:
|
| 72 |
+
index: Any
|
| 73 |
+
chunks: List[RagChunk]
|
| 74 |
+
embedder_backend: EmbedderBackend
|
| 75 |
+
embedder_model: str
|
| 76 |
+
normalize_embeddings: bool = True
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
# ---------------------------------------------------------------------------
|
| 80 |
+
# Text utilities
|
| 81 |
+
# ---------------------------------------------------------------------------
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def _clean(text: str) -> str:
|
| 85 |
+
text = text or ""
|
| 86 |
+
text = text.replace("\x00", " ").replace("\u00a0", " ")
|
| 87 |
+
text = re.sub(r"\s+", " ", text)
|
| 88 |
+
text = re.sub(r"\b10\.\d{4,9}/[-._;()/:A-Za-z0-9]+", "", text)
|
| 89 |
+
return text.strip(" .;:\n\t")
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
def _bad_chunk(text: str) -> bool:
|
| 93 |
+
low = text.lower()
|
| 94 |
+
bad = [
|
| 95 |
+
"corresponding author",
|
| 96 |
+
"how to cite",
|
| 97 |
+
"access this article online",
|
| 98 |
+
"copyright",
|
| 99 |
+
"all rights reserved",
|
| 100 |
+
"gmail.com",
|
| 101 |
+
"@",
|
| 102 |
+
"provided proper attribution",
|
| 103 |
+
"permission to reproduce",
|
| 104 |
+
]
|
| 105 |
+
if any(x in low for x in bad):
|
| 106 |
+
return True
|
| 107 |
+
if len(text.split()) < 8:
|
| 108 |
+
return True
|
| 109 |
+
if text.count("|") >= 2:
|
| 110 |
+
return True
|
| 111 |
+
return False
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
def _split_into_windows(text: str, max_words: int = 170, overlap_words: int = 35) -> List[str]:
|
| 115 |
+
"""Chunk text by word windows. Simple and robust for noisy PDF text."""
|
| 116 |
+
text = _clean(text)
|
| 117 |
+
if not text:
|
| 118 |
+
return []
|
| 119 |
+
words = text.split()
|
| 120 |
+
if len(words) <= max_words:
|
| 121 |
+
return [] if _bad_chunk(text) else [text]
|
| 122 |
+
|
| 123 |
+
chunks: List[str] = []
|
| 124 |
+
start = 0
|
| 125 |
+
while start < len(words):
|
| 126 |
+
end = min(len(words), start + max_words)
|
| 127 |
+
chunk = _clean(" ".join(words[start:end]))
|
| 128 |
+
if chunk and not _bad_chunk(chunk):
|
| 129 |
+
chunks.append(chunk)
|
| 130 |
+
if end == len(words):
|
| 131 |
+
break
|
| 132 |
+
start = max(0, end - overlap_words)
|
| 133 |
+
return chunks
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
def _table_to_text(table: Dict[str, Any]) -> str:
|
| 137 |
+
data = table.get("data")
|
| 138 |
+
caption = _clean(table.get("caption") or "")
|
| 139 |
+
if not isinstance(data, list):
|
| 140 |
+
return caption
|
| 141 |
+
rows: List[str] = []
|
| 142 |
+
for row in data[:12]:
|
| 143 |
+
if isinstance(row, list):
|
| 144 |
+
cells = [_clean(str(c)) for c in row if c is not None and _clean(str(c))]
|
| 145 |
+
if cells:
|
| 146 |
+
rows.append(" | ".join(cells[:8]))
|
| 147 |
+
body = "\n".join(rows)
|
| 148 |
+
return _clean(f"{caption}\n{body}")
|
| 149 |
+
|
| 150 |
+
|
| 151 |
+
# ---------------------------------------------------------------------------
|
| 152 |
+
# Chunk extraction
|
| 153 |
+
# ---------------------------------------------------------------------------
|
| 154 |
+
|
| 155 |
+
|
| 156 |
+
def build_chunks(extracted: Dict[str, Any], include_tables: bool = True, include_captions: bool = True) -> List[RagChunk]:
|
| 157 |
+
chunks: List[RagChunk] = []
|
| 158 |
+
counter = 0
|
| 159 |
+
|
| 160 |
+
blocked_roles = {"references", "appendix", "boilerplate"}
|
| 161 |
+
blocked_titles = {"front matter", "keywords", "table of contents"}
|
| 162 |
+
|
| 163 |
+
for sec_idx, sec in enumerate(extracted.get("sections", []) or []):
|
| 164 |
+
role = sec.get("role", "other")
|
| 165 |
+
title = _clean(sec.get("title") or "Untitled section")
|
| 166 |
+
if role in blocked_roles or title.lower() in blocked_titles:
|
| 167 |
+
continue
|
| 168 |
+
text = sec.get("text") or ""
|
| 169 |
+
for window in _split_into_windows(text):
|
| 170 |
+
counter += 1
|
| 171 |
+
chunks.append(
|
| 172 |
+
RagChunk(
|
| 173 |
+
chunk_id=f"section-{sec_idx}-{counter}",
|
| 174 |
+
text=window,
|
| 175 |
+
source_type="section",
|
| 176 |
+
title=title,
|
| 177 |
+
role=role,
|
| 178 |
+
page_start=sec.get("page_start"),
|
| 179 |
+
page_end=sec.get("page_end"),
|
| 180 |
+
)
|
| 181 |
+
)
|
| 182 |
+
|
| 183 |
+
if include_captions:
|
| 184 |
+
for cap_idx, cap in enumerate(extracted.get("captions", []) or []):
|
| 185 |
+
label = _clean(cap.get("label") or f"caption-{cap_idx}")
|
| 186 |
+
caption = _clean(cap.get("caption") or "")
|
| 187 |
+
if caption and not _bad_chunk(caption):
|
| 188 |
+
chunks.append(
|
| 189 |
+
RagChunk(
|
| 190 |
+
chunk_id=f"caption-{cap_idx}",
|
| 191 |
+
text=caption,
|
| 192 |
+
source_type="caption",
|
| 193 |
+
title=label,
|
| 194 |
+
role="caption",
|
| 195 |
+
page_start=cap.get("page_number"),
|
| 196 |
+
page_end=cap.get("page_number"),
|
| 197 |
+
label=label,
|
| 198 |
+
)
|
| 199 |
+
)
|
| 200 |
+
|
| 201 |
+
if include_tables:
|
| 202 |
+
for table_idx, table in enumerate(extracted.get("tables", []) or []):
|
| 203 |
+
text = _table_to_text(table)
|
| 204 |
+
if text and not _bad_chunk(text):
|
| 205 |
+
label = f"Table {table_idx + 1}"
|
| 206 |
+
chunks.append(
|
| 207 |
+
RagChunk(
|
| 208 |
+
chunk_id=f"table-{table_idx}",
|
| 209 |
+
text=text[:2500],
|
| 210 |
+
source_type="table",
|
| 211 |
+
title=label,
|
| 212 |
+
role="table",
|
| 213 |
+
page_start=table.get("page_number"),
|
| 214 |
+
page_end=table.get("page_number"),
|
| 215 |
+
label=label,
|
| 216 |
+
)
|
| 217 |
+
)
|
| 218 |
+
|
| 219 |
+
return chunks
|
| 220 |
+
|
| 221 |
+
|
| 222 |
+
# ---------------------------------------------------------------------------
|
| 223 |
+
# Embedding backends
|
| 224 |
+
# ---------------------------------------------------------------------------
|
| 225 |
+
|
| 226 |
+
|
| 227 |
+
class BaseEmbedder:
|
| 228 |
+
def encode_documents(self, texts: List[str]) -> np.ndarray:
|
| 229 |
+
raise NotImplementedError
|
| 230 |
+
|
| 231 |
+
def encode_query(self, text: str) -> np.ndarray:
|
| 232 |
+
raise NotImplementedError
|
| 233 |
+
|
| 234 |
+
|
| 235 |
+
class LocalSentenceTransformerEmbedder(BaseEmbedder):
|
| 236 |
+
def __init__(self, model_name: str = "BAAI/bge-small-en-v1.5") -> None:
|
| 237 |
+
try:
|
| 238 |
+
from sentence_transformers import SentenceTransformer
|
| 239 |
+
except ImportError as exc: # pragma: no cover
|
| 240 |
+
raise ImportError(
|
| 241 |
+
"sentence-transformers is required. Install with: pip install sentence-transformers"
|
| 242 |
+
) from exc
|
| 243 |
+
self.model_name = model_name
|
| 244 |
+
self.model = SentenceTransformer(model_name)
|
| 245 |
+
|
| 246 |
+
def encode_documents(self, texts: List[str]) -> np.ndarray:
|
| 247 |
+
# BGE-style instruction prefix helps retrieval quality.
|
| 248 |
+
docs = [f"passage: {t}" for t in texts]
|
| 249 |
+
arr = self.model.encode(docs, convert_to_numpy=True, normalize_embeddings=True, show_progress_bar=False)
|
| 250 |
+
return arr.astype("float32")
|
| 251 |
+
|
| 252 |
+
def encode_query(self, text: str) -> np.ndarray:
|
| 253 |
+
arr = self.model.encode([f"query: {text}"], convert_to_numpy=True, normalize_embeddings=True, show_progress_bar=False)
|
| 254 |
+
return arr.astype("float32")
|
| 255 |
+
|
| 256 |
+
|
| 257 |
+
class NvidiaEndpointEmbedder(BaseEmbedder):
|
| 258 |
+
"""NVIDIA API/NIM embedding backend.
|
| 259 |
+
|
| 260 |
+
Requires:
|
| 261 |
+
pip install langchain-nvidia-ai-endpoints
|
| 262 |
+
set NVIDIA_API_KEY=...
|
| 263 |
+
|
| 264 |
+
Default model uses NVIDIA's retrieval QA embedding endpoint.
|
| 265 |
+
"""
|
| 266 |
+
|
| 267 |
+
def __init__(self, model_name: str = "nvidia/nv-embedqa-e5-v5") -> None:
|
| 268 |
+
try:
|
| 269 |
+
from langchain_nvidia_ai_endpoints import NVIDIAEmbeddings
|
| 270 |
+
except ImportError as exc: # pragma: no cover
|
| 271 |
+
raise ImportError(
|
| 272 |
+
"Install NVIDIA embeddings support with: pip install langchain-nvidia-ai-endpoints"
|
| 273 |
+
) from exc
|
| 274 |
+
self.model_name = model_name
|
| 275 |
+
self.embedder = NVIDIAEmbeddings(model=model_name)
|
| 276 |
+
|
| 277 |
+
def encode_documents(self, texts: List[str]) -> np.ndarray:
|
| 278 |
+
# NVIDIA embedding endpoints support document embedding methods through LangChain.
|
| 279 |
+
vecs = self.embedder.embed_documents(texts)
|
| 280 |
+
arr = np.array(vecs, dtype="float32")
|
| 281 |
+
return _l2_normalize(arr)
|
| 282 |
+
|
| 283 |
+
def encode_query(self, text: str) -> np.ndarray:
|
| 284 |
+
vec = self.embedder.embed_query(text)
|
| 285 |
+
arr = np.array([vec], dtype="float32")
|
| 286 |
+
return _l2_normalize(arr)
|
| 287 |
+
|
| 288 |
+
|
| 289 |
+
def _l2_normalize(arr: np.ndarray) -> np.ndarray:
|
| 290 |
+
norms = np.linalg.norm(arr, axis=1, keepdims=True)
|
| 291 |
+
norms[norms == 0] = 1.0
|
| 292 |
+
return (arr / norms).astype("float32")
|
| 293 |
+
|
| 294 |
+
|
| 295 |
+
def make_embedder(backend: EmbedderBackend = "local", model_name: Optional[str] = None) -> BaseEmbedder:
|
| 296 |
+
if backend == "local":
|
| 297 |
+
return LocalSentenceTransformerEmbedder(model_name or "BAAI/bge-small-en-v1.5")
|
| 298 |
+
if backend == "nvidia":
|
| 299 |
+
return NvidiaEndpointEmbedder(model_name or "nvidia/nv-embedqa-e5-v5")
|
| 300 |
+
raise ValueError(f"Unknown embedder backend: {backend}")
|
| 301 |
+
|
| 302 |
+
|
| 303 |
+
# ---------------------------------------------------------------------------
|
| 304 |
+
# Index build/search/save/load
|
| 305 |
+
# ---------------------------------------------------------------------------
|
| 306 |
+
|
| 307 |
+
|
| 308 |
+
def build_rag_index(
|
| 309 |
+
extracted: Dict[str, Any],
|
| 310 |
+
embedder_backend: EmbedderBackend = "local",
|
| 311 |
+
embedder_model: Optional[str] = None,
|
| 312 |
+
include_tables: bool = True,
|
| 313 |
+
include_captions: bool = True,
|
| 314 |
+
) -> RagIndex:
|
| 315 |
+
chunks = build_chunks(extracted, include_tables=include_tables, include_captions=include_captions)
|
| 316 |
+
if not chunks:
|
| 317 |
+
raise ValueError("No usable chunks found for RAG indexing.")
|
| 318 |
+
|
| 319 |
+
embedder = make_embedder(embedder_backend, embedder_model)
|
| 320 |
+
texts = [c.text for c in chunks]
|
| 321 |
+
embeddings = embedder.encode_documents(texts)
|
| 322 |
+
embeddings = _l2_normalize(embeddings)
|
| 323 |
+
|
| 324 |
+
dim = embeddings.shape[1]
|
| 325 |
+
index = faiss.IndexFlatIP(dim)
|
| 326 |
+
index.add(embeddings)
|
| 327 |
+
|
| 328 |
+
return RagIndex(
|
| 329 |
+
index=index,
|
| 330 |
+
chunks=chunks,
|
| 331 |
+
embedder_backend=embedder_backend,
|
| 332 |
+
embedder_model=embedder_model or ("BAAI/bge-small-en-v1.5" if embedder_backend == "local" else "nvidia/nv-embedqa-e5-v5"),
|
| 333 |
+
normalize_embeddings=True,
|
| 334 |
+
)
|
| 335 |
+
|
| 336 |
+
|
| 337 |
+
def search_rag_index(rag_index: RagIndex, query: str, top_k: int = 5) -> List[RagChunk]:
|
| 338 |
+
embedder = make_embedder(rag_index.embedder_backend, rag_index.embedder_model)
|
| 339 |
+
q = embedder.encode_query(query)
|
| 340 |
+
q = _l2_normalize(q)
|
| 341 |
+
scores, ids = rag_index.index.search(q, top_k)
|
| 342 |
+
hits: List[RagChunk] = []
|
| 343 |
+
for score, idx in zip(scores[0].tolist(), ids[0].tolist()):
|
| 344 |
+
if idx < 0 or idx >= len(rag_index.chunks):
|
| 345 |
+
continue
|
| 346 |
+
chunk = rag_index.chunks[idx]
|
| 347 |
+
hits.append(
|
| 348 |
+
RagChunk(
|
| 349 |
+
chunk_id=chunk.chunk_id,
|
| 350 |
+
text=chunk.text,
|
| 351 |
+
source_type=chunk.source_type,
|
| 352 |
+
title=chunk.title,
|
| 353 |
+
role=chunk.role,
|
| 354 |
+
page_start=chunk.page_start,
|
| 355 |
+
page_end=chunk.page_end,
|
| 356 |
+
label=chunk.label,
|
| 357 |
+
score=round(float(score), 4),
|
| 358 |
+
)
|
| 359 |
+
)
|
| 360 |
+
return hits
|
| 361 |
+
|
| 362 |
+
|
| 363 |
+
def save_rag_index(rag_index: RagIndex, path: str | Path) -> None:
|
| 364 |
+
path = Path(path)
|
| 365 |
+
path.mkdir(parents=True, exist_ok=True)
|
| 366 |
+
faiss.write_index(rag_index.index, str(path / "index.faiss"))
|
| 367 |
+
metadata = {
|
| 368 |
+
"embedder_backend": rag_index.embedder_backend,
|
| 369 |
+
"embedder_model": rag_index.embedder_model,
|
| 370 |
+
"normalize_embeddings": rag_index.normalize_embeddings,
|
| 371 |
+
"chunks": [asdict(c) for c in rag_index.chunks],
|
| 372 |
+
}
|
| 373 |
+
(path / "metadata.json").write_text(json.dumps(metadata, indent=2, ensure_ascii=False), encoding="utf-8")
|
| 374 |
+
|
| 375 |
+
|
| 376 |
+
def load_rag_index(path: str | Path) -> RagIndex:
|
| 377 |
+
path = Path(path)
|
| 378 |
+
index_path = path / "index.faiss"
|
| 379 |
+
meta_path = path / "metadata.json"
|
| 380 |
+
if not index_path.exists() or not meta_path.exists():
|
| 381 |
+
raise FileNotFoundError(f"Missing FAISS index files in {path}")
|
| 382 |
+
index = faiss.read_index(str(index_path))
|
| 383 |
+
metadata = json.loads(meta_path.read_text(encoding="utf-8"))
|
| 384 |
+
chunks = [RagChunk(**c) for c in metadata.get("chunks", [])]
|
| 385 |
+
return RagIndex(
|
| 386 |
+
index=index,
|
| 387 |
+
chunks=chunks,
|
| 388 |
+
embedder_backend=metadata.get("embedder_backend", "local"),
|
| 389 |
+
embedder_model=metadata.get("embedder_model", "BAAI/bge-small-en-v1.5"),
|
| 390 |
+
normalize_embeddings=bool(metadata.get("normalize_embeddings", True)),
|
| 391 |
+
)
|
src/paper2lab/rag/qa.py
ADDED
|
@@ -0,0 +1,594 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
qa.py — Evidence-grounded local RAG Q&A for Paper2Lab.
|
| 3 |
+
|
| 4 |
+
This module returns extractive answers with evidence and source locations.
|
| 5 |
+
It does not call an LLM. Nemotron can later rewrite the answer using the same evidence.
|
| 6 |
+
|
| 7 |
+
Design:
|
| 8 |
+
- Classify the question into a small intent taxonomy.
|
| 9 |
+
- Retrieve evidence with FAISS through indexer.py.
|
| 10 |
+
- Synthesize answers using intent-specific extractive logic.
|
| 11 |
+
- Avoid hardcoding known dataset names; discover entities from local evidence.
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
from __future__ import annotations
|
| 15 |
+
|
| 16 |
+
import re
|
| 17 |
+
from typing import Any, Dict, Iterable, List, Optional, Tuple
|
| 18 |
+
|
| 19 |
+
from paper2lab.rag.indexer import RagIndex, build_rag_index, search_rag_index
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
# ---------------------------------------------------------------------------
|
| 23 |
+
# Intent classification
|
| 24 |
+
# ---------------------------------------------------------------------------
|
| 25 |
+
|
| 26 |
+
_QUERY_INTENTS: Dict[str, List[str]] = {
|
| 27 |
+
"datasets": [
|
| 28 |
+
"dataset", "datasets", "data", "corpus", "corpora", "benchmark", "benchmarks",
|
| 29 |
+
"source", "sources", "database", "databases", "training set", "test set",
|
| 30 |
+
"validation set", "dev set", "patients", "samples", "records", "articles", "studies",
|
| 31 |
+
],
|
| 32 |
+
"methodology": [
|
| 33 |
+
"method", "methods", "methodology", "procedure", "procedures", "steps", "approach",
|
| 34 |
+
"how", "trained", "training", "fine-tuned", "pretrained", "searched", "screened",
|
| 35 |
+
"selected", "included", "excluded", "implementation", "architecture", "pipeline",
|
| 36 |
+
],
|
| 37 |
+
"evaluation": [
|
| 38 |
+
"evaluate", "evaluated", "evaluation", "metric", "metrics", "score", "accuracy",
|
| 39 |
+
"precision", "recall", "f1", "auc", "bleu", "rouge", "perplexity", "result",
|
| 40 |
+
"results", "performance", "finding", "findings", "outcome", "outcomes",
|
| 41 |
+
],
|
| 42 |
+
"figures": [
|
| 43 |
+
"figure", "fig", "table", "caption", "diagram", "plot", "chart", "architecture",
|
| 44 |
+
"visual", "illustration", "show", "shows",
|
| 45 |
+
],
|
| 46 |
+
"reproducibility": [
|
| 47 |
+
"missing", "reproduce", "reproduction", "reproducibility", "hyperparameter",
|
| 48 |
+
"hyperparameters", "software", "code", "github", "repository", "settings", "requirements",
|
| 49 |
+
"seed", "hardware", "gpu", "implementation details",
|
| 50 |
+
],
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
_INTENT_QUERY_EXPANSIONS: Dict[str, str] = {
|
| 54 |
+
"datasets": "dataset corpus benchmark training data test set validation data source database articles studies",
|
| 55 |
+
"methodology": "method methodology approach procedure steps training implementation experimental setup search screening inclusion exclusion",
|
| 56 |
+
"evaluation": "evaluation metrics results performance score accuracy f1 bleu rouge auc outcome analysis measured assessed",
|
| 57 |
+
"figures": "figure table caption diagram architecture plot shows illustrates",
|
| 58 |
+
"reproducibility": "reproducibility missing information hyperparameters dataset details software code hardware seed experimental settings",
|
| 59 |
+
"general": "paper evidence method results conclusion",
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
# ---------------------------------------------------------------------------
|
| 64 |
+
# Cleaning / sentence utilities
|
| 65 |
+
# ---------------------------------------------------------------------------
|
| 66 |
+
|
| 67 |
+
def _clean(text: str) -> str:
|
| 68 |
+
text = text or ""
|
| 69 |
+
text = text.replace("\x00", " ").replace("\u00a0", " ")
|
| 70 |
+
text = re.sub(r"\b10\.\d{4,9}/[-._;()/:A-Za-z0-9]+", "", text)
|
| 71 |
+
text = re.sub(r"\s+", " ", text)
|
| 72 |
+
return text.strip(" .;:\n\t")
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def _is_noisy_sentence(sentence: str) -> bool:
|
| 76 |
+
s = _clean(sentence)
|
| 77 |
+
low = s.lower()
|
| 78 |
+
|
| 79 |
+
bad_fragments = [
|
| 80 |
+
"corresponding author", "how to cite", "access this article online", "department of",
|
| 81 |
+
"university of", "medical sciences", "received:", "accepted:", "published:",
|
| 82 |
+
"copyright", "license", "all rights reserved", "gmail.com", "@",
|
| 83 |
+
"being accordingly", "endnote teachers", "the there", "resultsare",
|
| 84 |
+
"analysis of the resultsare", "need this systematic review",
|
| 85 |
+
]
|
| 86 |
+
if any(x in low for x in bad_fragments):
|
| 87 |
+
return True
|
| 88 |
+
if len(re.findall(r"\[\d+", s)) >= 3:
|
| 89 |
+
return True
|
| 90 |
+
if s.count("|") >= 2 or s.count("%") >= 6:
|
| 91 |
+
return True
|
| 92 |
+
if len(s.split()) > 85:
|
| 93 |
+
return True
|
| 94 |
+
return False
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
def _split_sentences(text: str) -> List[str]:
|
| 98 |
+
text = _clean(text)
|
| 99 |
+
raw = re.split(r"(?<=[.!?])\s+(?=[A-Z0-9])", text)
|
| 100 |
+
out: List[str] = []
|
| 101 |
+
for sent in raw:
|
| 102 |
+
sent = _clean(sent)
|
| 103 |
+
if 25 <= len(sent) <= 420 and not _is_noisy_sentence(sent):
|
| 104 |
+
out.append(sent)
|
| 105 |
+
if not out and text and not _is_noisy_sentence(text):
|
| 106 |
+
out = [text[:420]]
|
| 107 |
+
return out
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
def _query_terms(question: str) -> List[str]:
|
| 111 |
+
words = re.findall(r"[a-zA-Z][a-zA-Z0-9_-]{2,}", question.lower())
|
| 112 |
+
stop = {
|
| 113 |
+
"what", "which", "where", "when", "how", "were", "was", "are", "the", "and",
|
| 114 |
+
"used", "use", "paper", "study", "does", "did", "for", "with", "from", "that",
|
| 115 |
+
"this", "these", "those", "show", "shows", "tell", "about", "explain",
|
| 116 |
+
}
|
| 117 |
+
return [w for w in words if w not in stop]
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
def _dedupe_strings(items: Iterable[str], limit: int = 10) -> List[str]:
|
| 121 |
+
seen: set[str] = set()
|
| 122 |
+
out: List[str] = []
|
| 123 |
+
for item in items:
|
| 124 |
+
item = _clean(item)
|
| 125 |
+
if not item:
|
| 126 |
+
continue
|
| 127 |
+
key = re.sub(r"[^a-z0-9]+", " ", item.lower()).strip()[:180]
|
| 128 |
+
if key and key not in seen:
|
| 129 |
+
seen.add(key)
|
| 130 |
+
out.append(item)
|
| 131 |
+
if len(out) >= limit:
|
| 132 |
+
break
|
| 133 |
+
return out
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
def _intent(question: str) -> str:
|
| 137 |
+
low = question.lower()
|
| 138 |
+
scores: Dict[str, int] = {}
|
| 139 |
+
for intent, keys in _QUERY_INTENTS.items():
|
| 140 |
+
score = 0
|
| 141 |
+
for k in keys:
|
| 142 |
+
if k in low:
|
| 143 |
+
score += 2 if " " in k else 1
|
| 144 |
+
scores[intent] = score
|
| 145 |
+
best = max(scores, key=scores.get)
|
| 146 |
+
return best if scores[best] > 0 else "general"
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
def _expanded_query(question: str, intent: str) -> str:
|
| 150 |
+
expansion = _INTENT_QUERY_EXPANSIONS.get(intent, "")
|
| 151 |
+
return _clean(f"{question} {expansion}")
|
| 152 |
+
|
| 153 |
+
|
| 154 |
+
# ---------------------------------------------------------------------------
|
| 155 |
+
# Evidence helpers
|
| 156 |
+
# ---------------------------------------------------------------------------
|
| 157 |
+
|
| 158 |
+
def _evidence_texts(hits: List[Any]) -> List[str]:
|
| 159 |
+
return [getattr(h, "text", "") for h in hits if getattr(h, "text", "")]
|
| 160 |
+
|
| 161 |
+
|
| 162 |
+
def _rank_sentences(question: str, evidence_texts: List[str], max_sentences: int = 4) -> List[str]:
|
| 163 |
+
terms = _query_terms(question)
|
| 164 |
+
candidates: List[Tuple[int, int, str]] = []
|
| 165 |
+
for text in evidence_texts:
|
| 166 |
+
for sent in _split_sentences(text):
|
| 167 |
+
low = sent.lower()
|
| 168 |
+
lexical_score = sum(1 for t in terms if t in low)
|
| 169 |
+
length_penalty = max(0, len(sent.split()) - 45)
|
| 170 |
+
candidates.append((lexical_score, -length_penalty, sent))
|
| 171 |
+
candidates.sort(key=lambda x: (x[0], x[1]), reverse=True)
|
| 172 |
+
|
| 173 |
+
selected: List[str] = []
|
| 174 |
+
seen: set[str] = set()
|
| 175 |
+
for score, _, sent in candidates:
|
| 176 |
+
key = re.sub(r"[^a-z0-9]+", " ", sent.lower()).strip()[:180]
|
| 177 |
+
if key in seen:
|
| 178 |
+
continue
|
| 179 |
+
if score == 0 and selected:
|
| 180 |
+
continue
|
| 181 |
+
seen.add(key)
|
| 182 |
+
selected.append(sent)
|
| 183 |
+
if len(selected) >= max_sentences:
|
| 184 |
+
break
|
| 185 |
+
return selected
|
| 186 |
+
|
| 187 |
+
|
| 188 |
+
# ---------------------------------------------------------------------------
|
| 189 |
+
# Dataset/data-source discovery without hardcoded dataset names
|
| 190 |
+
# ---------------------------------------------------------------------------
|
| 191 |
+
|
| 192 |
+
_DATA_CONTEXT_WORDS = [
|
| 193 |
+
"dataset", "datasets", "corpus", "corpora", "benchmark", "benchmarks", "training data",
|
| 194 |
+
"training set", "test set", "validation set", "dev set", "data source", "databases",
|
| 195 |
+
"database", "articles", "studies", "patients", "samples", "records", "images",
|
| 196 |
+
"sentences", "tokens", "documents", "cases", "examples", "instances",
|
| 197 |
+
]
|
| 198 |
+
|
| 199 |
+
_KNOWN_DATABASE_GENERIC = [
|
| 200 |
+
"PubMed", "Scopus", "Web of Knowledge", "ERIC", "Educational Resources and Information Center",
|
| 201 |
+
"Cochrane", "IEEE Xplore", "ACM Digital Library", "Google Scholar", "MEDLINE", "Embase",
|
| 202 |
+
]
|
| 203 |
+
|
| 204 |
+
_DATASET_REJECT_TERMS = [
|
| 205 |
+
"parser", "berkeleyparser", "berkleyparser", "rnn", "lstm", "gru",
|
| 206 |
+
"transformer", "recurrent neural network", "neural network grammar",
|
| 207 |
+
"model", "architecture", "baseline", "beam size", "during inference",
|
| 208 |
+
"dropout", "optimizer", "learning rate", "attention", "encoder", "decoder",
|
| 209 |
+
]
|
| 210 |
+
|
| 211 |
+
_DATASET_ALLOW_TERMS = [
|
| 212 |
+
"dataset", "corpus", "corpora", "benchmark", "treebank", "wsj",
|
| 213 |
+
"wmt", "penn treebank", "wall street journal", "sentence pairs",
|
| 214 |
+
"sentences", "tokens", "training set", "test set", "validation set",
|
| 215 |
+
"dev set", "patients", "samples", "records", "articles", "studies",
|
| 216 |
+
]
|
| 217 |
+
|
| 218 |
+
def _extract_capitalized_entities_near_data_terms(sentence: str) -> List[str]:
|
| 219 |
+
"""Discover likely dataset names from context without fixed known dataset list."""
|
| 220 |
+
s = _clean(sentence)
|
| 221 |
+
low = s.lower()
|
| 222 |
+
if not any(w in low for w in _DATA_CONTEXT_WORDS):
|
| 223 |
+
return []
|
| 224 |
+
|
| 225 |
+
found: List[str] = []
|
| 226 |
+
|
| 227 |
+
# Pattern: "standard X dataset", "larger X corpus", "on X benchmark".
|
| 228 |
+
context_patterns = [
|
| 229 |
+
r"(?:standard|larger|public|available|benchmark|the)\s+([A-Z][A-Za-z0-9._/-]*(?:\s+[A-Z]?[A-Za-z0-9._/-]+){0,6})\s+(?:dataset|datasets|corpus|corpora|benchmark|benchmarks)",
|
| 230 |
+
r"(?:on|using|from|with)\s+(?:the\s+)?([A-Z][A-Za-z0-9._/-]*(?:\s+[A-Z]?[A-Za-z0-9._/-]+){0,7})\s+(?:dataset|datasets|corpus|corpora|benchmark|benchmarks)",
|
| 231 |
+
r"([A-Z][A-Za-z0-9._/-]*(?:\s+[A-Z]?[A-Za-z0-9._/-]+){0,7})\s+(?:dataset|datasets|corpus|corpora|benchmark|benchmarks)",
|
| 232 |
+
]
|
| 233 |
+
for pat in context_patterns:
|
| 234 |
+
for m in re.finditer(pat, s):
|
| 235 |
+
cand = _clean(m.group(1))
|
| 236 |
+
if _valid_dataset_candidate(cand):
|
| 237 |
+
found.append(cand)
|
| 238 |
+
|
| 239 |
+
# Pattern: explicit study/data counts.
|
| 240 |
+
count_patterns = [
|
| 241 |
+
r"\b(?:about|approximately|around)?\s*\d+(?:\.\d+)?\s*(?:k|m|million|billion|thousand)?\s+(?:sentence pairs|sentences|tokens|images|patients|samples|records|documents|cases|examples|instances|articles|studies)\b",
|
| 242 |
+
r"\b\d+\s+(?:articles|studies|patients|samples|records)\s+(?:were\s+)?(?:included|enrolled|selected|used)\b",
|
| 243 |
+
]
|
| 244 |
+
for pat in count_patterns:
|
| 245 |
+
for m in re.finditer(pat, s, flags=re.IGNORECASE):
|
| 246 |
+
found.append(_clean(m.group(0)))
|
| 247 |
+
|
| 248 |
+
# Known scholarly databases are generic enough to keep; not paper-specific datasets.
|
| 249 |
+
for db in _KNOWN_DATABASE_GENERIC:
|
| 250 |
+
if db.lower() in low:
|
| 251 |
+
found.append(db)
|
| 252 |
+
|
| 253 |
+
# Parenthetical abbreviations after a named source: Wall Street Journal (WSJ), etc.
|
| 254 |
+
for m in re.finditer(r"([A-Z][A-Za-z]+(?:\s+[A-Z][A-Za-z]+){1,5})\s*\(([A-Z0-9-]{2,10})\)", s):
|
| 255 |
+
cand = _clean(f"{m.group(1)} ({m.group(2)})")
|
| 256 |
+
if _valid_dataset_candidate(cand):
|
| 257 |
+
found.append(cand)
|
| 258 |
+
|
| 259 |
+
# Generic dataset-style identifiers near data terms: WMT2014, CIFAR-10, SQuAD-v2, XYZ-500.
|
| 260 |
+
for m in re.finditer(
|
| 261 |
+
r"\b[A-Z]{2,}[A-Za-z]*[- ]?\d{2,4}(?:[- ][A-Za-z]+)*\b",
|
| 262 |
+
s,
|
| 263 |
+
):
|
| 264 |
+
cand = _clean(m.group(0))
|
| 265 |
+
if _valid_dataset_candidate(cand):
|
| 266 |
+
found.append(cand)
|
| 267 |
+
|
| 268 |
+
# Named corpora/treebanks/splits with abbreviations.
|
| 269 |
+
for m in re.finditer(
|
| 270 |
+
r"\b(?:Wall Street Journal|Penn Treebank|[A-Z]{2,6})\b(?:\s*\([A-Z0-9-]{2,10}\))?",
|
| 271 |
+
s,
|
| 272 |
+
):
|
| 273 |
+
cand = _clean(m.group(0))
|
| 274 |
+
if _valid_dataset_candidate(cand):
|
| 275 |
+
found.append(cand)
|
| 276 |
+
return _dedupe_strings(found, limit=12)
|
| 277 |
+
|
| 278 |
+
|
| 279 |
+
def _valid_dataset_candidate(candidate: str) -> bool:
|
| 280 |
+
cand = _clean(candidate)
|
| 281 |
+
low = cand.lower()
|
| 282 |
+
|
| 283 |
+
if not cand or len(cand) < 3 or len(cand.split()) > 10:
|
| 284 |
+
return False
|
| 285 |
+
|
| 286 |
+
if any(term in low for term in _DATASET_REJECT_TERMS):
|
| 287 |
+
return False
|
| 288 |
+
|
| 289 |
+
bad_exact = {
|
| 290 |
+
"the", "standard", "larger", "public", "available", "training",
|
| 291 |
+
"test", "validation", "we", "our", "this", "that", "section",
|
| 292 |
+
"table", "figure", "results", "parser",
|
| 293 |
+
}
|
| 294 |
+
if low in bad_exact:
|
| 295 |
+
return False
|
| 296 |
+
|
| 297 |
+
if any(x in low for x in ["section describes", "the following", "in this", "of the"]):
|
| 298 |
+
return False
|
| 299 |
+
|
| 300 |
+
# Accept known dataset-like abbreviations only when context looks data-related.
|
| 301 |
+
if re.fullmatch(r"[A-Z0-9-]{2,12}", cand):
|
| 302 |
+
return True
|
| 303 |
+
|
| 304 |
+
return True
|
| 305 |
+
|
| 306 |
+
|
| 307 |
+
def _looks_like_dataset_detail(text: str) -> bool:
|
| 308 |
+
low = _clean(text).lower()
|
| 309 |
+
return bool(
|
| 310 |
+
re.search(
|
| 311 |
+
r"\b(?:about|approximately|around)?\s*\d+(?:\.\d+)?\s*"
|
| 312 |
+
r"(?:k|m|million|billion|thousand)?\s+"
|
| 313 |
+
r"(?:sentence pairs|sentences|tokens|images|patients|samples|records|documents|cases|examples|instances|articles|studies)\b",
|
| 314 |
+
low,
|
| 315 |
+
)
|
| 316 |
+
or re.search(r"\b\d+\s*(?:k|m)?\s*tokens\b", low)
|
| 317 |
+
or re.search(r"\b\d+\s*(?:k|m)?\s*training sentences\b", low)
|
| 318 |
+
)
|
| 319 |
+
|
| 320 |
+
|
| 321 |
+
def _answer_datasets(evidence_texts: List[str]) -> str:
|
| 322 |
+
dataset_names: List[str] = []
|
| 323 |
+
dataset_sizes: List[str] = []
|
| 324 |
+
vocabulary_details: List[str] = []
|
| 325 |
+
support_sentences: List[str] = []
|
| 326 |
+
|
| 327 |
+
for text in evidence_texts:
|
| 328 |
+
for sent in _split_sentences(text):
|
| 329 |
+
found = _extract_capitalized_entities_near_data_terms(sent)
|
| 330 |
+
|
| 331 |
+
for item in found:
|
| 332 |
+
if _looks_like_dataset_detail(item):
|
| 333 |
+
dataset_sizes.append(item)
|
| 334 |
+
else:
|
| 335 |
+
dataset_names.append(item)
|
| 336 |
+
|
| 337 |
+
# Dataset size extraction, excluding vocabulary/token-only details.
|
| 338 |
+
for m in re.finditer(
|
| 339 |
+
r"\b(?:about|approximately|around)?\s*\d+(?:\.\d+)?\s*"
|
| 340 |
+
r"(?:k|m|million|billion|thousand)?\s+"
|
| 341 |
+
r"(?:sentence pairs|sentences|images|patients|samples|records|documents|cases|examples|instances|articles|studies)\b",
|
| 342 |
+
sent,
|
| 343 |
+
flags=re.IGNORECASE,
|
| 344 |
+
):
|
| 345 |
+
dataset_sizes.append(_clean(m.group(0)))
|
| 346 |
+
|
| 347 |
+
# Vocabulary / tokenization details are useful but not datasets.
|
| 348 |
+
for m in re.finditer(
|
| 349 |
+
r"\b(?:about|approximately|around)?\s*\d+(?:\.\d+)?\s*"
|
| 350 |
+
r"(?:k|m|million|billion|thousand)?\s+"
|
| 351 |
+
r"(?:tokens|word-piece vocabulary|vocabulary)\b",
|
| 352 |
+
sent,
|
| 353 |
+
flags=re.IGNORECASE,
|
| 354 |
+
):
|
| 355 |
+
vocabulary_details.append(_clean(m.group(0)))
|
| 356 |
+
|
| 357 |
+
if found or dataset_sizes or vocabulary_details:
|
| 358 |
+
support_sentences.append(sent)
|
| 359 |
+
|
| 360 |
+
dataset_names = _dedupe_strings(dataset_names, limit=10)
|
| 361 |
+
dataset_sizes = _dedupe_strings(dataset_sizes, limit=10)
|
| 362 |
+
vocabulary_details = _dedupe_strings(vocabulary_details, limit=10)
|
| 363 |
+
support_sentences = _dedupe_strings(support_sentences, limit=3)
|
| 364 |
+
dataset_names = [
|
| 365 |
+
x for x in dataset_names
|
| 366 |
+
if not any(bad in x.lower() for bad in _DATASET_REJECT_TERMS)
|
| 367 |
+
]
|
| 368 |
+
if dataset_names or dataset_sizes or vocabulary_details:
|
| 369 |
+
parts: List[str] = []
|
| 370 |
+
|
| 371 |
+
if dataset_names:
|
| 372 |
+
parts.append(
|
| 373 |
+
"Datasets / data sources:\n"
|
| 374 |
+
+ "\n".join(f"- {x}" for x in dataset_names)
|
| 375 |
+
)
|
| 376 |
+
|
| 377 |
+
if dataset_sizes:
|
| 378 |
+
parts.append(
|
| 379 |
+
"Dataset sizes:\n"
|
| 380 |
+
+ "\n".join(f"- {x}" for x in dataset_sizes)
|
| 381 |
+
)
|
| 382 |
+
|
| 383 |
+
if vocabulary_details:
|
| 384 |
+
parts.append(
|
| 385 |
+
"Vocabulary / tokenization details:\n"
|
| 386 |
+
+ "\n".join(f"- {x}" for x in vocabulary_details)
|
| 387 |
+
)
|
| 388 |
+
|
| 389 |
+
if support_sentences:
|
| 390 |
+
parts.append(
|
| 391 |
+
"Evidence snippets:\n"
|
| 392 |
+
+ "\n".join(f"- {s}" for s in support_sentences)
|
| 393 |
+
)
|
| 394 |
+
|
| 395 |
+
return "\n\n".join(parts)
|
| 396 |
+
|
| 397 |
+
fallback = _rank_sentences("datasets data corpus benchmark", evidence_texts, max_sentences=3)
|
| 398 |
+
if fallback:
|
| 399 |
+
return (
|
| 400 |
+
"I could not confidently isolate dataset names, but the most relevant evidence is:\n"
|
| 401 |
+
+ "\n".join(f"- {s}" for s in fallback)
|
| 402 |
+
)
|
| 403 |
+
|
| 404 |
+
return "I could not find enough evidence about datasets or data sources in the extracted paper text."
|
| 405 |
+
|
| 406 |
+
|
| 407 |
+
# ---------------------------------------------------------------------------
|
| 408 |
+
# Specialized answer synthesis
|
| 409 |
+
# ---------------------------------------------------------------------------
|
| 410 |
+
|
| 411 |
+
_METHOD_STEP_MARKERS = [
|
| 412 |
+
"we trained", "we train", "trained", "fine-tuned", "pre-trained", "optimizer", "learning rate",
|
| 413 |
+
"batch", "epochs", "searched", "screened", "included", "excluded", "inclusion criteria",
|
| 414 |
+
"exclusion criteria", "data extraction", "preprocessed", "augmentation", "architecture",
|
| 415 |
+
]
|
| 416 |
+
|
| 417 |
+
_EVAL_MARKERS = [
|
| 418 |
+
"accuracy", "precision", "recall", "f1", "auc", "bleu", "rouge", "perplexity",
|
| 419 |
+
"loss", "rmse", "mae", "score", "performance", "outperform", "achieve", "result",
|
| 420 |
+
"evaluation", "measured", "assessed", "statistical", "p-value", "confidence interval",
|
| 421 |
+
]
|
| 422 |
+
|
| 423 |
+
_REPRO_MARKERS = [
|
| 424 |
+
"learning rate", "batch size", "epoch", "optimizer", "dropout", "weight decay", "seed",
|
| 425 |
+
"gpu", "hardware", "code", "github", "repository", "dataset", "split", "software",
|
| 426 |
+
"implementation", "inclusion criteria", "exclusion criteria", "screening", "quality assessment",
|
| 427 |
+
]
|
| 428 |
+
|
| 429 |
+
|
| 430 |
+
def _answer_methodology(evidence_texts: List[str]) -> str:
|
| 431 |
+
steps: List[str] = []
|
| 432 |
+
for text in evidence_texts:
|
| 433 |
+
for sent in _split_sentences(text):
|
| 434 |
+
low = sent.lower()
|
| 435 |
+
if any(m in low for m in _METHOD_STEP_MARKERS):
|
| 436 |
+
steps.append(sent)
|
| 437 |
+
steps = _dedupe_strings(steps, limit=6)
|
| 438 |
+
if not steps:
|
| 439 |
+
steps = _rank_sentences("methodology procedure steps approach", evidence_texts, max_sentences=4)
|
| 440 |
+
if not steps:
|
| 441 |
+
return "I could not find enough methodology evidence in the extracted paper text."
|
| 442 |
+
return "The paper describes these methodological elements:\n" + "\n".join(f"- {s}" for s in steps)
|
| 443 |
+
|
| 444 |
+
|
| 445 |
+
def _answer_evaluation(evidence_texts: List[str]) -> str:
|
| 446 |
+
items: List[str] = []
|
| 447 |
+
for text in evidence_texts:
|
| 448 |
+
for sent in _split_sentences(text):
|
| 449 |
+
low = sent.lower()
|
| 450 |
+
if any(m in low for m in _EVAL_MARKERS) and (re.search(r"\d", sent) or "result" in low or "performance" in low):
|
| 451 |
+
items.append(sent)
|
| 452 |
+
items = _dedupe_strings(items, limit=6)
|
| 453 |
+
if not items:
|
| 454 |
+
items = _rank_sentences("evaluation metrics results performance", evidence_texts, max_sentences=4)
|
| 455 |
+
if not items:
|
| 456 |
+
return "I could not find enough evaluation evidence in the extracted paper text."
|
| 457 |
+
return "The paper reports these evaluation/result details:\n" + "\n".join(f"- {s}" for s in items)
|
| 458 |
+
|
| 459 |
+
|
| 460 |
+
def _answer_figures(evidence_texts: List[str]) -> str:
|
| 461 |
+
items: List[str] = []
|
| 462 |
+
for text in evidence_texts:
|
| 463 |
+
for sent in _split_sentences(text):
|
| 464 |
+
low = sent.lower()
|
| 465 |
+
if any(x in low for x in ["figure", "fig.", "table", "caption", "shown", "illustrates"]):
|
| 466 |
+
items.append(sent)
|
| 467 |
+
items = _dedupe_strings(items, limit=5)
|
| 468 |
+
if not items:
|
| 469 |
+
items = _rank_sentences("figure table caption shows", evidence_texts, max_sentences=3)
|
| 470 |
+
if not items:
|
| 471 |
+
return "I could not find enough figure or table evidence in the extracted paper text."
|
| 472 |
+
return "The relevant figure/table evidence says:\n" + "\n".join(f"- {s}" for s in items)
|
| 473 |
+
|
| 474 |
+
|
| 475 |
+
def _answer_reproducibility(evidence_texts: List[str]) -> str:
|
| 476 |
+
found: List[str] = []
|
| 477 |
+
for text in evidence_texts:
|
| 478 |
+
for sent in _split_sentences(text):
|
| 479 |
+
low = sent.lower()
|
| 480 |
+
if any(m in low for m in _REPRO_MARKERS):
|
| 481 |
+
found.append(sent)
|
| 482 |
+
found = _dedupe_strings(found, limit=6)
|
| 483 |
+
if not found:
|
| 484 |
+
found = _rank_sentences("reproducibility missing hyperparameters software code settings", evidence_texts, max_sentences=4)
|
| 485 |
+
if not found:
|
| 486 |
+
return "I could not find enough reproducibility evidence in the extracted paper text."
|
| 487 |
+
return "The reproducibility-relevant evidence is:\n" + "\n".join(f"- {s}" for s in found)
|
| 488 |
+
|
| 489 |
+
|
| 490 |
+
def _answer_general(question: str, evidence_texts: List[str]) -> str:
|
| 491 |
+
sents = _rank_sentences(question, evidence_texts, max_sentences=4)
|
| 492 |
+
if not sents:
|
| 493 |
+
return "I could not find enough evidence in the extracted paper text to answer this question."
|
| 494 |
+
return "Based on the retrieved evidence:\n" + "\n".join(f"- {s}" for s in sents)
|
| 495 |
+
|
| 496 |
+
|
| 497 |
+
def _synthesize_answer(question: str, evidence_texts: List[str], intent: str) -> str:
|
| 498 |
+
if intent == "datasets":
|
| 499 |
+
return _answer_datasets(evidence_texts)
|
| 500 |
+
if intent == "methodology":
|
| 501 |
+
return _answer_methodology(evidence_texts)
|
| 502 |
+
if intent == "evaluation":
|
| 503 |
+
return _answer_evaluation(evidence_texts)
|
| 504 |
+
if intent == "figures":
|
| 505 |
+
return _answer_figures(evidence_texts)
|
| 506 |
+
if intent == "reproducibility":
|
| 507 |
+
return _answer_reproducibility(evidence_texts)
|
| 508 |
+
return _answer_general(question, evidence_texts)
|
| 509 |
+
|
| 510 |
+
|
| 511 |
+
# ---------------------------------------------------------------------------
|
| 512 |
+
# Public API
|
| 513 |
+
# ---------------------------------------------------------------------------
|
| 514 |
+
|
| 515 |
+
def answer_question(
|
| 516 |
+
extracted: Dict[str, Any],
|
| 517 |
+
question: str,
|
| 518 |
+
rag_index: Optional[RagIndex] = None,
|
| 519 |
+
top_k: int = 5,
|
| 520 |
+
embedder_backend: str = "local",
|
| 521 |
+
embedder_model: Optional[str] = None,
|
| 522 |
+
) -> Dict[str, Any]:
|
| 523 |
+
"""Answer a question using retrieved chunks from one extracted paper.
|
| 524 |
+
|
| 525 |
+
Parameters
|
| 526 |
+
----------
|
| 527 |
+
extracted:
|
| 528 |
+
Output of pdf_loader.extract_pdf().
|
| 529 |
+
question:
|
| 530 |
+
User question.
|
| 531 |
+
rag_index:
|
| 532 |
+
Optional prebuilt index. If omitted, this function builds an in-memory index.
|
| 533 |
+
top_k:
|
| 534 |
+
Number of evidence chunks to retrieve.
|
| 535 |
+
embedder_backend:
|
| 536 |
+
"local" or "nvidia". Used only when rag_index is omitted.
|
| 537 |
+
embedder_model:
|
| 538 |
+
Optional embedding model name.
|
| 539 |
+
"""
|
| 540 |
+
question = _clean(question)
|
| 541 |
+
if not question:
|
| 542 |
+
return {"answer": "No question was provided.", "evidence": [], "query": question}
|
| 543 |
+
|
| 544 |
+
intent = _intent(question)
|
| 545 |
+
retrieval_query = _expanded_query(question, intent)
|
| 546 |
+
|
| 547 |
+
if rag_index is None:
|
| 548 |
+
rag_index = build_rag_index(
|
| 549 |
+
extracted,
|
| 550 |
+
embedder_backend=embedder_backend, # type: ignore[arg-type]
|
| 551 |
+
embedder_model=embedder_model,
|
| 552 |
+
)
|
| 553 |
+
|
| 554 |
+
# Retrieve slightly more than displayed evidence so the synthesizer has more context.
|
| 555 |
+
internal_top_k = max(top_k, min(10, top_k + 3))
|
| 556 |
+
hits = search_rag_index(rag_index, retrieval_query, top_k=internal_top_k)
|
| 557 |
+
answer = _synthesize_answer(question, _evidence_texts(hits), intent)
|
| 558 |
+
|
| 559 |
+
# Keep user-facing evidence compact.
|
| 560 |
+
evidence = [h.to_evidence() for h in hits[:top_k]]
|
| 561 |
+
|
| 562 |
+
return {
|
| 563 |
+
"query": question,
|
| 564 |
+
"intent": intent,
|
| 565 |
+
"retrieval_query": retrieval_query,
|
| 566 |
+
"answer": answer,
|
| 567 |
+
"evidence": evidence,
|
| 568 |
+
"rag": {
|
| 569 |
+
"top_k": top_k,
|
| 570 |
+
"embedder_backend": rag_index.embedder_backend,
|
| 571 |
+
"embedder_model": rag_index.embedder_model,
|
| 572 |
+
"num_chunks": len(rag_index.chunks),
|
| 573 |
+
},
|
| 574 |
+
}
|
| 575 |
+
|
| 576 |
+
|
| 577 |
+
def answer_from_pipeline_result(
|
| 578 |
+
pipeline_result: Dict[str, Any],
|
| 579 |
+
question: str,
|
| 580 |
+
top_k: int = 5,
|
| 581 |
+
embedder_backend: str = "local",
|
| 582 |
+
embedder_model: Optional[str] = None,
|
| 583 |
+
) -> Dict[str, Any]:
|
| 584 |
+
"""Convenience helper for results returned by PaperPipeline.run()."""
|
| 585 |
+
extraction = pipeline_result.get("extraction") or {}
|
| 586 |
+
if not extraction:
|
| 587 |
+
return {"answer": "No extraction object was found in the pipeline result.", "evidence": [], "query": question}
|
| 588 |
+
return answer_question(
|
| 589 |
+
extraction,
|
| 590 |
+
question,
|
| 591 |
+
top_k=top_k,
|
| 592 |
+
embedder_backend=embedder_backend,
|
| 593 |
+
embedder_model=embedder_model,
|
| 594 |
+
)
|
src/paper2lab/utils/io.py
ADDED
|
File without changes
|
src/paper2lab/utils/logging.py
ADDED
|
File without changes
|
src/paper2lab/utils/seed.py
ADDED
|
File without changes
|
test_nemotron.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# test_nemotron_real.py
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
|
| 5 |
+
from paper2lab.data.pdf_loader import extract_pdf
|
| 6 |
+
from paper2lab.inference.paper_card import build_paper_card
|
| 7 |
+
from paper2lab.inference.roadmap import build_reproduction_roadmap
|
| 8 |
+
from paper2lab.evaluation.reproducibility import reproducibility_report
|
| 9 |
+
from paper2lab.inference.visual_explainer import explain_figures_and_tables
|
| 10 |
+
from paper2lab.inference.nemotron_refiner import refine_with_nemotron
|
| 11 |
+
|
| 12 |
+
pdf_path = "Data/papers/train/Education intervention.pdf"
|
| 13 |
+
|
| 14 |
+
extracted = extract_pdf(pdf_path)
|
| 15 |
+
card = build_paper_card(extracted)
|
| 16 |
+
|
| 17 |
+
roadmap = build_reproduction_roadmap(extracted, card)
|
| 18 |
+
figures_tables = explain_figures_and_tables(extracted)
|
| 19 |
+
repro_score = reproducibility_report(extracted, card)
|
| 20 |
+
|
| 21 |
+
card["reproduction_roadmap"] = roadmap
|
| 22 |
+
card["figures_and_tables"] = figures_tables
|
| 23 |
+
card["reproducibility_score"] = repro_score
|
| 24 |
+
|
| 25 |
+
pack = card["llm_evidence_pack"]
|
| 26 |
+
pack["candidate_paper_card"] = {
|
| 27 |
+
k: v for k, v in card.items()
|
| 28 |
+
if k != "llm_evidence_pack"
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
result = refine_with_nemotron(pack)
|
| 32 |
+
|
| 33 |
+
print(json.dumps(result, indent=2, ensure_ascii=False))
|