Max2191 commited on
Commit
14c7fcf
·
0 Parent(s):

Initial Build

Browse files
.env.example ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ GRANITE_MODEL_ID=ibm-granite/granite-docling-258M
2
+ SUMMARIZER_MODEL_ID=google/pegasus-x-base-arxiv
3
+ ENABLE_PEGASUS=1
4
+ ENABLE_KOKORO=1
5
+ MAX_AUDIO_CHARS=12000
6
+ OUTPUT_DIR=outputs
.github/workflows/tests.yml ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: tests
2
+
3
+ on:
4
+ push:
5
+ pull_request:
6
+
7
+ jobs:
8
+ test:
9
+ runs-on: ubuntu-latest
10
+ steps:
11
+ - uses: actions/checkout@v4
12
+ - uses: actions/setup-python@v5
13
+ with:
14
+ python-version: "3.11"
15
+ - name: Install lightweight test dependencies
16
+ run: |
17
+ python -m pip install --upgrade pip
18
+ pip install pytest numpy
19
+ - name: Run tests that do not download models
20
+ run: pytest
.gitignore ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ .venv/
2
+ __pycache__/
3
+ *.py[cod]
4
+ .pytest_cache/
5
+ .ruff_cache/
6
+ .env
7
+ outputs/*
8
+ !outputs/.gitkeep
9
+ .huggingface/
10
+ .DS_Store
11
+ .vscode/settings.json
Dockerfile ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+
3
+ ENV PYTHONDONTWRITEBYTECODE=1 \
4
+ PYTHONUNBUFFERED=1 \
5
+ GRADIO_SERVER_NAME=0.0.0.0 \
6
+ GRADIO_SERVER_PORT=7860
7
+
8
+ RUN apt-get update && apt-get install -y --no-install-recommends \
9
+ espeak-ng \
10
+ libsndfile1 \
11
+ git \
12
+ && rm -rf /var/lib/apt/lists/*
13
+
14
+ WORKDIR /app
15
+ COPY requirements.txt .
16
+ RUN pip install --no-cache-dir --upgrade pip && \
17
+ pip install --no-cache-dir -r requirements.txt
18
+
19
+ COPY . .
20
+ EXPOSE 7860
21
+ CMD ["python", "app.py"]
LICENSE ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ MIT License
2
+
3
+ Copyright (c) 2026
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
app.py ADDED
@@ -0,0 +1,170 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import traceback
4
+
5
+ import gradio as gr
6
+
7
+ from src.pipeline import (
8
+ audio_from_state,
9
+ narration_from_state,
10
+ process_paper,
11
+ summarize_state,
12
+ )
13
+ from src.sections import section_table
14
+
15
+ try:
16
+ import spaces
17
+
18
+ gpu_task = spaces.GPU
19
+ except ImportError:
20
+ def gpu_task(function):
21
+ return function
22
+
23
+
24
+ def _friendly_error(exc: Exception) -> str:
25
+ return f"{type(exc).__name__}: {exc}"
26
+
27
+
28
+ @gpu_task
29
+ def handle_process(uploaded_file, source_text, parser_label, page_limit):
30
+ try:
31
+ parser_mode = "granite" if parser_label.startswith("Granite") else "standard"
32
+ paper = process_paper(
33
+ uploaded_file=uploaded_file,
34
+ source_text=source_text,
35
+ parser_mode=parser_mode,
36
+ page_limit=int(page_limit),
37
+ )
38
+ state = paper.to_state()
39
+ metadata = paper.metadata
40
+ status = (
41
+ f"Processed with **{paper.parser_mode}** mode in "
42
+ f"**{metadata['conversion_seconds']} seconds**. "
43
+ f"Extracted **{metadata['word_count']} words** across "
44
+ f"**{metadata['section_count']} sections**."
45
+ )
46
+ return state, paper.markdown, section_table(paper.sections), status
47
+ except Exception as exc: # Gradio callback boundary
48
+ traceback.print_exc()
49
+ return {}, "", [], f"Processing failed: {_friendly_error(exc)}"
50
+
51
+
52
+ @gpu_task
53
+ def handle_summary(state, technicality_label, summarizer_label):
54
+ try:
55
+ technicality = technicality_label.lower()
56
+ mode = "pegasus" if summarizer_label.startswith("PEGASUS") else "extractive"
57
+ state, summary = summarize_state(state or {}, technicality, mode)
58
+ return state, summary, f"Summary generated with **{mode}** mode."
59
+ except Exception as exc:
60
+ traceback.print_exc()
61
+ return state or {}, "", f"Summary failed: {_friendly_error(exc)}"
62
+
63
+
64
+ def handle_narration(state):
65
+ try:
66
+ state, narration = narration_from_state(state or {})
67
+ return state, narration, "Narration script prepared."
68
+ except Exception as exc:
69
+ traceback.print_exc()
70
+ return state or {}, "", f"Narration failed: {_friendly_error(exc)}"
71
+
72
+
73
+ @gpu_task
74
+ def handle_audio(state, voice, speed):
75
+ try:
76
+ path = audio_from_state(state or {}, voice=voice, speed=float(speed))
77
+ return path, "Audio generated with Kokoro-82M."
78
+ except Exception as exc:
79
+ traceback.print_exc()
80
+ return None, f"Audio failed: {_friendly_error(exc)}"
81
+
82
+
83
+
84
+ with gr.Blocks(title="PaperCast") as demo:
85
+ state = gr.State({})
86
+ gr.Markdown(
87
+ "# PaperCast \n"
88
+ "Convert scientific PDFs with Granite-Docling, summarize them, and generate an audio briefing."
89
+ )
90
+
91
+ with gr.Tab("1. Process paper"):
92
+ with gr.Row():
93
+ uploaded_file = gr.File(label="Upload a PDF", file_types=[".pdf"], type="filepath")
94
+ source_text = gr.Textbox(
95
+ label="Or enter an arXiv ID / PDF URL",
96
+ placeholder="2501.17887 or https://arxiv.org/pdf/2501.17887",
97
+ )
98
+ with gr.Row():
99
+ parser_label = gr.Radio(
100
+ ["Granite VLM", "Standard Docling"],
101
+ value="Granite VLM",
102
+ label="Parser",
103
+ )
104
+ page_limit = gr.Slider(1, 12, value=3, step=1, label="Pages to process")
105
+ process_button = gr.Button("Process paper", variant="primary")
106
+ process_status = gr.Markdown()
107
+ with gr.Row():
108
+ markdown_output = gr.Markdown(label="Structured output")
109
+ section_output = gr.Dataframe(
110
+ headers=["#", "Level", "Section", "Words"],
111
+ datatype=["number", "number", "str", "number"],
112
+ interactive=False,
113
+ label="Detected sections",
114
+ )
115
+ process_button.click(
116
+ handle_process,
117
+ inputs=[uploaded_file, source_text, parser_label, page_limit],
118
+ outputs=[state, markdown_output, section_output, process_status],
119
+ )
120
+
121
+ with gr.Tab("2. Summarize"):
122
+ with gr.Row():
123
+ technicality = gr.Radio(
124
+ ["Overview", "Intermediate", "Technical"],
125
+ value="Intermediate",
126
+ label="Technicality",
127
+ )
128
+ summarizer_label = gr.Radio(
129
+ ["Extractive baseline (fast)", "PEGASUS-X"],
130
+ value="Extractive baseline (fast)",
131
+ label="Summarizer",
132
+ )
133
+ summary_button = gr.Button("Generate summary", variant="primary")
134
+ summary_status = gr.Markdown()
135
+ summary_output = gr.Textbox(label="Summary", lines=18)
136
+ summary_button.click(
137
+ handle_summary,
138
+ inputs=[state, technicality, summarizer_label],
139
+ outputs=[state, summary_output, summary_status],
140
+ )
141
+
142
+ with gr.Tab("3. Audio"):
143
+ narration_button = gr.Button("Prepare narration script")
144
+ narration_status = gr.Markdown()
145
+ narration_output = gr.Textbox(label="Speech-friendly script", lines=15)
146
+ narration_button.click(
147
+ handle_narration,
148
+ inputs=[state],
149
+ outputs=[state, narration_output, narration_status],
150
+ )
151
+
152
+ with gr.Row():
153
+ voice = gr.Dropdown(
154
+ ["af_heart", "af_bella", "am_adam", "am_michael"],
155
+ value="af_heart",
156
+ label="Kokoro voice",
157
+ )
158
+ speed = gr.Slider(0.8, 1.25, value=1.0, step=0.05, label="Speech speed")
159
+ audio_button = gr.Button("Generate audio", variant="primary")
160
+ audio_status = gr.Markdown()
161
+ audio_output = gr.Audio(label="PaperCast audio", type="filepath")
162
+ audio_button.click(
163
+ handle_audio,
164
+ inputs=[state, voice, speed],
165
+ outputs=[audio_output, audio_status],
166
+ )
167
+
168
+
169
+ if __name__ == "__main__":
170
+ demo.queue().launch()
outputs/.gitkeep ADDED
File without changes
packages.txt ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ espeak-ng
2
+ libsndfile1
pyproject.toml ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ [tool.pytest.ini_options]
2
+ pythonpath = ["."]
3
+ testpaths = ["tests"]
4
+
5
+ [tool.ruff]
6
+ line-length = 100
7
+ target-version = "py311"
8
+
9
+ [tool.ruff.lint]
10
+ select = ["E", "F", "I", "UP", "B"]
requirements-dev.txt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ -r requirements.txt
2
+ pytest>=8,<10
3
+ ruff>=0.9,<1
requirements.txt ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Application and UI
2
+ gradio>=5.0,<7
3
+
4
+ # Document AI and model inference
5
+ docling>=2.115,<3
6
+ transformers>=4.51,<5
7
+ torch>=2.8,<3
8
+ accelerate>=1.0,<2
9
+ sentencepiece>=0.2,<1
10
+ protobuf>=5,<7
11
+
12
+ # Text-to-speech
13
+ kokoro>=0.9.2,<1
14
+ soundfile>=0.12,<1
15
+ numpy>=1.26,<3
16
+
17
+ # Utilities
18
+ huggingface_hub>=0.34,<2
19
+ requests>=2.32,<3
scripts/smoke_test.py ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Small local smoke test that does not download neural models."""
2
+
3
+ from pathlib import Path
4
+ import sys
5
+
6
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
7
+
8
+ from src.narration import prepare_narration
9
+ from src.sections import split_markdown_sections
10
+ from src.sources import normalize_arxiv_reference
11
+ from src.summarization import extractive_summary
12
+
13
+ sample = """
14
+ # Abstract
15
+ We introduce a document-understanding system for scientific papers. The system preserves formulas and structure.
16
+
17
+ # Method
18
+ A multimodal transformer converts document-page images into structured tokens. A sequence-to-sequence transformer then summarizes selected sections.
19
+
20
+ # Conclusion
21
+ The combined pipeline produces text suitable for a neural text-to-speech model.
22
+ """
23
+
24
+ sections = split_markdown_sections(sample)
25
+ summary = extractive_summary(sections, "overview")
26
+ narration = prepare_narration(summary, "overview")
27
+
28
+ assert normalize_arxiv_reference("2501.17887")
29
+ assert sections
30
+ assert summary
31
+ assert narration
32
+ print("Smoke test passed.")
src/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """PaperCast core package."""
src/config.py ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from dataclasses import dataclass
5
+ from pathlib import Path
6
+
7
+
8
+ @dataclass(frozen=True, slots=True)
9
+ class Settings:
10
+ app_name: str = "PaperCast"
11
+ granite_model_id: str = os.getenv(
12
+ "GRANITE_MODEL_ID", "ibm-granite/granite-docling-258M"
13
+ )
14
+ summarizer_model_id: str = os.getenv(
15
+ "SUMMARIZER_MODEL_ID", "google/pegasus-x-base-arxiv"
16
+ )
17
+
18
+ summarizer_tokenizer_id: str = os.getenv(
19
+ "SUMMARIZER_TOKENIZER_ID", "google/pegasus-x-base"
20
+ )
21
+ output_dir: Path = Path(os.getenv("OUTPUT_DIR", "outputs"))
22
+ max_audio_chars: int = int(os.getenv("MAX_AUDIO_CHARS", "12000"))
23
+ enable_pegasus: bool = os.getenv("ENABLE_PEGASUS", "1") == "1"
24
+ enable_kokoro: bool = os.getenv("ENABLE_KOKORO", "1") == "1"
25
+
26
+
27
+ SETTINGS = Settings()
28
+ SETTINGS.output_dir.mkdir(parents=True, exist_ok=True)
src/models.py ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import asdict, dataclass, field
4
+ from typing import Any
5
+
6
+
7
+ @dataclass(slots=True)
8
+ class Section:
9
+ """A Markdown section extracted from a converted paper."""
10
+
11
+ title: str
12
+ level: int
13
+ content: str
14
+
15
+ @property
16
+ def word_count(self) -> int:
17
+ return len(self.content.split())
18
+
19
+ def to_dict(self) -> dict[str, Any]:
20
+ return asdict(self)
21
+
22
+
23
+ @dataclass(slots=True)
24
+ class PaperDocument:
25
+ """Application-friendly representation of a processed paper."""
26
+
27
+ source: str
28
+ parser_mode: str
29
+ markdown: str
30
+ sections: list[Section] = field(default_factory=list)
31
+ metadata: dict[str, Any] = field(default_factory=dict)
32
+
33
+ def to_state(self) -> dict[str, Any]:
34
+ return {
35
+ "source": self.source,
36
+ "parser_mode": self.parser_mode,
37
+ "markdown": self.markdown,
38
+ "sections": [section.to_dict() for section in self.sections],
39
+ "metadata": self.metadata,
40
+ "summary": "",
41
+ "narration": "",
42
+ }
src/narration.py ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import re
4
+
5
+ _MARKDOWN_LINK = re.compile(r"\[([^\]]+)\]\([^\)]+\)")
6
+ _INLINE_CODE = re.compile(r"`([^`]+)`")
7
+ _HEADING = re.compile(r"^#{1,6}\s*", re.MULTILINE)
8
+ _LATEX_BLOCK = re.compile(r"\$\$(.+?)\$\$", re.DOTALL)
9
+ _LATEX_INLINE = re.compile(r"(?<!\$)\$(?!\$)(.+?)(?<!\$)\$(?!\$)")
10
+
11
+
12
+ def _verbalize_math(match: re.Match[str]) -> str:
13
+ expression = " ".join(match.group(1).split())
14
+ expression = expression.replace("\\", " ")
15
+ expression = expression.replace("_", " sub ").replace("^", " to the power of ")
16
+ expression = expression.replace("{", " ").replace("}", " ")
17
+ return f" The paper gives the mathematical expression: {expression}. "
18
+
19
+
20
+ def prepare_narration(summary: str, technicality: str) -> str:
21
+ """Convert a written summary into a speech-friendly script.
22
+
23
+ This first version performs deterministic NLP cleanup. A later milestone can
24
+ add a generative 'explain this equation conceptually' model.
25
+ """
26
+
27
+ cleaned = _MARKDOWN_LINK.sub(r"\1", summary)
28
+ cleaned = _INLINE_CODE.sub(r"\1", cleaned)
29
+ cleaned = _HEADING.sub("", cleaned)
30
+ cleaned = _LATEX_BLOCK.sub(_verbalize_math, cleaned)
31
+ cleaned = _LATEX_INLINE.sub(_verbalize_math, cleaned)
32
+ cleaned = cleaned.replace("*", "").replace("#", "")
33
+ cleaned = re.sub(r"\s+", " ", cleaned).strip()
34
+
35
+ intros = {
36
+ "overview": "Here is a concise overview of the paper. ",
37
+ "intermediate": "Here is an explanatory walkthrough of the paper. ",
38
+ "technical": "Here is a technical briefing on the paper. ",
39
+ }
40
+ outro = " That concludes the PaperCast briefing."
41
+ return intros.get(technicality, intros["intermediate"]) + cleaned + outro
src/parsing.py ADDED
@@ -0,0 +1,85 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import time
4
+ from functools import lru_cache
5
+ from pathlib import Path
6
+ from typing import Literal
7
+
8
+ from .models import PaperDocument
9
+ from .sections import split_markdown_sections
10
+
11
+ ParserMode = Literal["standard", "granite"]
12
+
13
+
14
+ @lru_cache(maxsize=1)
15
+ def _standard_converter():
16
+ from docling.document_converter import DocumentConverter
17
+
18
+ return DocumentConverter()
19
+
20
+
21
+ @lru_cache(maxsize=1)
22
+ def _granite_converter():
23
+ from docling.datamodel import vlm_model_specs
24
+ from docling.datamodel.base_models import InputFormat
25
+ from docling.datamodel.pipeline_options import VlmPipelineOptions
26
+ from docling.document_converter import DocumentConverter, PdfFormatOption
27
+ from docling.pipeline.vlm_pipeline import VlmPipeline
28
+
29
+ pipeline_options = VlmPipelineOptions(
30
+ vlm_options=vlm_model_specs.GRANITEDOCLING_TRANSFORMERS,
31
+ )
32
+ return DocumentConverter(
33
+ format_options={
34
+ InputFormat.PDF: PdfFormatOption(
35
+ pipeline_cls=VlmPipeline,
36
+ pipeline_options=pipeline_options,
37
+ )
38
+ }
39
+ )
40
+
41
+
42
+ def parse_document(
43
+ source: str,
44
+ parser_mode: ParserMode = "granite",
45
+ page_limit: int = 3,
46
+ ) -> PaperDocument:
47
+ """Convert a PDF/URL into structured Markdown with Docling.
48
+
49
+ Granite mode is the multimodal transformer path. Standard mode is a faster
50
+ fallback and a useful baseline for later extraction-quality comparisons.
51
+ """
52
+
53
+ if parser_mode not in {"standard", "granite"}:
54
+ raise ValueError(f"Unknown parser mode: {parser_mode}")
55
+ if page_limit < 1:
56
+ raise ValueError("page_limit must be at least 1")
57
+
58
+ converter = _granite_converter() if parser_mode == "granite" else _standard_converter()
59
+
60
+ started = time.perf_counter()
61
+ result = converter.convert(
62
+ source=source,
63
+ page_range=(1, page_limit),
64
+ raises_on_error=True,
65
+ )
66
+ elapsed = time.perf_counter() - started
67
+ markdown = result.document.export_to_markdown()
68
+ sections = split_markdown_sections(markdown)
69
+
70
+ filename = Path(source).name if not source.startswith("http") else source
71
+ metadata = {
72
+ "display_name": filename,
73
+ "pages_requested": page_limit,
74
+ "conversion_seconds": round(elapsed, 2),
75
+ "section_count": len(sections),
76
+ "word_count": len(markdown.split()),
77
+ "formula_markers": markdown.count("$$") + markdown.count("\\[") + markdown.count("\\("),
78
+ }
79
+ return PaperDocument(
80
+ source=source,
81
+ parser_mode=parser_mode,
82
+ markdown=markdown,
83
+ sections=sections,
84
+ metadata=metadata,
85
+ )
src/pipeline.py ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from .models import PaperDocument, Section
4
+ from .narration import prepare_narration
5
+ from .parsing import parse_document
6
+ from .sources import resolve_source
7
+ from .summarization import summarize
8
+ from .tts import generate_audio
9
+
10
+
11
+ def process_paper(
12
+ uploaded_file: str | None,
13
+ source_text: str | None,
14
+ parser_mode: str,
15
+ page_limit: int,
16
+ ) -> PaperDocument:
17
+ source = resolve_source(uploaded_file, source_text)
18
+ return parse_document(source, parser_mode=parser_mode, page_limit=page_limit)
19
+
20
+
21
+ def sections_from_state(state: dict) -> list[Section]:
22
+ return [Section(**item) for item in state.get("sections", [])]
23
+
24
+
25
+ def summarize_state(state: dict, technicality: str, mode: str) -> tuple[dict, str]:
26
+ sections = sections_from_state(state)
27
+ if not sections:
28
+ raise ValueError("Process a paper before generating a summary.")
29
+ summary = summarize(sections, technicality=technicality, mode=mode)
30
+ updated = dict(state)
31
+ updated["summary"] = summary
32
+ updated["technicality"] = technicality
33
+ updated["summarizer_mode"] = mode
34
+ return updated, summary
35
+
36
+
37
+ def narration_from_state(state: dict) -> tuple[dict, str]:
38
+ summary = state.get("summary", "").strip()
39
+ if not summary:
40
+ raise ValueError("Generate a summary before preparing narration.")
41
+ technicality = state.get("technicality", "intermediate")
42
+ narration = prepare_narration(summary, technicality)
43
+ updated = dict(state)
44
+ updated["narration"] = narration
45
+ return updated, narration
46
+
47
+
48
+ def audio_from_state(state: dict, voice: str, speed: float) -> str:
49
+ narration = state.get("narration", "").strip()
50
+ if not narration:
51
+ raise ValueError("Prepare narration before generating audio.")
52
+ return str(generate_audio(narration, voice=voice, speed=speed))
src/sections.py ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import re
4
+
5
+ from .models import Section
6
+
7
+ _HEADING = re.compile(r"^(#{1,6})\s+(.+?)\s*$")
8
+
9
+
10
+ def split_markdown_sections(markdown: str) -> list[Section]:
11
+ """Split Markdown into heading-aware sections while preserving source order."""
12
+
13
+ lines = markdown.splitlines()
14
+ sections: list[Section] = []
15
+ current_title = "Document opening"
16
+ current_level = 1
17
+ current_lines: list[str] = []
18
+
19
+ def flush() -> None:
20
+ nonlocal current_lines
21
+ content = "\n".join(current_lines).strip()
22
+ if content:
23
+ sections.append(
24
+ Section(title=current_title, level=current_level, content=content)
25
+ )
26
+ current_lines = []
27
+
28
+ for line in lines:
29
+ heading = _HEADING.match(line)
30
+ if heading:
31
+ flush()
32
+ current_level = len(heading.group(1))
33
+ current_title = heading.group(2).strip()
34
+ else:
35
+ current_lines.append(line)
36
+
37
+ flush()
38
+ return sections
39
+
40
+
41
+ def section_table(sections: list[Section]) -> list[list[object]]:
42
+ return [[index + 1, item.level, item.title, item.word_count] for index, item in enumerate(sections)]
src/sources.py ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from pathlib import Path
5
+ from urllib.parse import urlparse
6
+
7
+ _ARXIV_ID = re.compile(
8
+ r"^(?:arxiv:)?(?P<id>(?:\d{4}\.\d{4,5}|[a-z-]+/\d{7})(?:v\d+)?)$",
9
+ re.IGNORECASE,
10
+ )
11
+ _ARXIV_URL = re.compile(
12
+ r"https?://(?:www\.)?arxiv\.org/(?:abs|pdf)/(?P<id>[^?#]+?)(?:\.pdf)?(?:[?#].*)?$",
13
+ re.IGNORECASE,
14
+ )
15
+
16
+
17
+ def normalize_arxiv_reference(value: str) -> str | None:
18
+ """Return a canonical arXiv PDF URL, or None when value is not arXiv."""
19
+
20
+ cleaned = value.strip()
21
+ direct_match = _ARXIV_ID.match(cleaned)
22
+ if direct_match:
23
+ return f"https://arxiv.org/pdf/{direct_match.group('id')}"
24
+
25
+ url_match = _ARXIV_URL.match(cleaned)
26
+ if url_match:
27
+ paper_id = url_match.group("id").removesuffix(".pdf")
28
+ return f"https://arxiv.org/pdf/{paper_id}"
29
+
30
+ return None
31
+
32
+
33
+ def resolve_source(uploaded_file: str | Path | None, source_text: str | None) -> str:
34
+ """Resolve an uploaded PDF or an arXiv/HTTP source into a Docling input."""
35
+
36
+ if uploaded_file:
37
+ path = Path(uploaded_file)
38
+ if not path.exists():
39
+ raise FileNotFoundError(f"Uploaded file does not exist: {path}")
40
+ if path.suffix.lower() != ".pdf":
41
+ raise ValueError("The uploaded file must be a PDF.")
42
+ return str(path)
43
+
44
+ if not source_text or not source_text.strip():
45
+ raise ValueError("Upload a PDF or enter an arXiv ID/URL.")
46
+
47
+ source_text = source_text.strip()
48
+ arxiv_url = normalize_arxiv_reference(source_text)
49
+ if arxiv_url:
50
+ return arxiv_url
51
+
52
+ parsed = urlparse(source_text)
53
+ if parsed.scheme in {"http", "https"} and parsed.netloc:
54
+ return source_text
55
+
56
+ raise ValueError("Enter a valid arXiv ID, arXiv URL, HTTP URL, or upload a PDF.")
src/summarization.py ADDED
@@ -0,0 +1,169 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import math
4
+ import re
5
+ from collections import Counter
6
+ from functools import lru_cache
7
+ from typing import Literal
8
+
9
+ from .config import SETTINGS
10
+ from .models import Section
11
+
12
+ Technicality = Literal["overview", "intermediate", "technical"]
13
+ SummarizerMode = Literal["extractive", "pegasus"]
14
+
15
+ _SENTENCE = re.compile(r"(?<=[.!?])\s+(?=[A-Z0-9])")
16
+ _WORD = re.compile(r"[A-Za-z][A-Za-z'-]{2,}")
17
+ _STOPWORDS = {
18
+ "the", "and", "that", "with", "from", "this", "were", "have", "has", "had",
19
+ "for", "are", "was", "but", "not", "into", "their", "they", "our", "using",
20
+ "used", "than", "then", "which", "also", "can", "may", "such", "these",
21
+ "those", "between", "within", "where", "when", "while", "about", "paper",
22
+ }
23
+
24
+
25
+ def _preferred_text(sections: list[Section], technicality: Technicality) -> str:
26
+ if not sections:
27
+ return ""
28
+
29
+ preferred_terms = {
30
+ "overview": ("abstract", "introduction", "conclusion", "discussion"),
31
+ "intermediate": ("abstract", "introduction", "method", "result", "conclusion", "discussion"),
32
+ "technical": (),
33
+ }[technicality]
34
+
35
+ if not preferred_terms:
36
+ return "\n\n".join(section.content for section in sections)
37
+
38
+ selected = [
39
+ section.content
40
+ for section in sections
41
+ if any(term in section.title.lower() for term in preferred_terms)
42
+ ]
43
+ return "\n\n".join(selected or [section.content for section in sections])
44
+
45
+
46
+ def extractive_summary(sections: list[Section], technicality: Technicality) -> str:
47
+ """Transparent baseline summarizer used when the neural model is unavailable."""
48
+
49
+ text = _preferred_text(sections, technicality)
50
+ sentences = [item.strip() for item in _SENTENCE.split(text) if len(item.split()) >= 7]
51
+ if not sentences:
52
+ return text[:3000].strip()
53
+
54
+ words = [word.lower() for word in _WORD.findall(text)]
55
+ frequencies = Counter(word for word in words if word not in _STOPWORDS)
56
+ if not frequencies:
57
+ return " ".join(sentences[:8])
58
+
59
+ max_frequency = max(frequencies.values())
60
+ normalized = {word: count / max_frequency for word, count in frequencies.items()}
61
+
62
+ scored: list[tuple[int, float, str]] = []
63
+ for index, sentence in enumerate(sentences):
64
+ sentence_words = [word.lower() for word in _WORD.findall(sentence)]
65
+ if not sentence_words:
66
+ continue
67
+ score = sum(normalized.get(word, 0.0) for word in sentence_words)
68
+ score /= math.sqrt(len(sentence_words))
69
+ scored.append((index, score, sentence))
70
+
71
+ target = {"overview": 8, "intermediate": 14, "technical": 22}[technicality]
72
+ chosen = sorted(sorted(scored, key=lambda item: item[1], reverse=True)[:target])
73
+ return "\n\n".join(sentence for _, _, sentence in chosen)
74
+
75
+
76
+ @lru_cache(maxsize=1)
77
+ def _load_pegasus():
78
+ import torch
79
+ from transformers import AutoModelForSeq2SeqLM, AutoTokenizer
80
+
81
+ tokenizer = AutoTokenizer.from_pretrained(
82
+ SETTINGS.summarizer_tokenizer_id,
83
+ use_fast=False,
84
+ )
85
+ model = AutoModelForSeq2SeqLM.from_pretrained(SETTINGS.summarizer_model_id)
86
+
87
+ device = "cuda" if torch.cuda.is_available() else "cpu"
88
+ model.to(device)
89
+ model.eval()
90
+ return tokenizer, model, device
91
+
92
+
93
+ def _token_chunks(tokenizer, text: str, chunk_size: int) -> list[list[int]]:
94
+ token_ids = tokenizer.encode(text, add_special_tokens=False)
95
+ return [token_ids[index:index + chunk_size] for index in range(0, len(token_ids), chunk_size)]
96
+
97
+
98
+ def pegasus_summary(sections: list[Section], technicality: Technicality) -> str:
99
+ """Abstractive scientific summarization with a pretrained PEGASUS-X model."""
100
+
101
+ if not SETTINGS.enable_pegasus:
102
+ raise RuntimeError("PEGASUS is disabled. Set ENABLE_PEGASUS=1 to enable it.")
103
+
104
+ import torch
105
+
106
+ text = _preferred_text(sections, technicality)
107
+ if not text.strip():
108
+ raise ValueError("No paper text is available to summarize.")
109
+
110
+ tokenizer, model, device = _load_pegasus()
111
+ model_limit = getattr(tokenizer, "model_max_length", 4096)
112
+ if not isinstance(model_limit, int) or model_limit > 32768:
113
+ model_limit = 4096
114
+ chunk_size = min(model_limit - 64, 4096)
115
+
116
+ length_settings = {
117
+ "overview": (96, 260),
118
+ "intermediate": (160, 420),
119
+ "technical": (240, 620),
120
+ }
121
+ min_new_tokens, max_new_tokens = length_settings[technicality]
122
+
123
+ partial_summaries: list[str] = []
124
+ for ids in _token_chunks(tokenizer, text, chunk_size):
125
+ input_ids = torch.tensor([ids], device=device)
126
+ attention_mask = torch.ones_like(input_ids)
127
+ with torch.inference_mode():
128
+ output_ids = model.generate(
129
+ input_ids=input_ids,
130
+ attention_mask=attention_mask,
131
+ num_beams=4,
132
+ min_new_tokens=min_new_tokens,
133
+ max_new_tokens=max_new_tokens,
134
+ no_repeat_ngram_size=3,
135
+ length_penalty=1.0,
136
+ early_stopping=True,
137
+ )
138
+ partial_summaries.append(tokenizer.decode(output_ids[0], skip_special_tokens=True))
139
+
140
+ if len(partial_summaries) == 1:
141
+ return partial_summaries[0]
142
+
143
+ merged = "\n\n".join(partial_summaries)
144
+ merged_ids = tokenizer.encode(merged, add_special_tokens=False)[:chunk_size]
145
+ input_ids = torch.tensor([merged_ids], device=device)
146
+ attention_mask = torch.ones_like(input_ids)
147
+ with torch.inference_mode():
148
+ output_ids = model.generate(
149
+ input_ids=input_ids,
150
+ attention_mask=attention_mask,
151
+ num_beams=4,
152
+ min_new_tokens=min_new_tokens,
153
+ max_new_tokens=max_new_tokens,
154
+ no_repeat_ngram_size=3,
155
+ early_stopping=True,
156
+ )
157
+ return tokenizer.decode(output_ids[0], skip_special_tokens=True)
158
+
159
+
160
+ def summarize(
161
+ sections: list[Section],
162
+ technicality: Technicality,
163
+ mode: SummarizerMode,
164
+ ) -> str:
165
+ if mode == "extractive":
166
+ return extractive_summary(sections, technicality)
167
+ if mode == "pegasus":
168
+ return pegasus_summary(sections, technicality)
169
+ raise ValueError(f"Unknown summarizer mode: {mode}")
src/tts.py ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import uuid
4
+ from functools import lru_cache
5
+ from pathlib import Path
6
+
7
+ import numpy as np
8
+
9
+ from .config import SETTINGS
10
+
11
+
12
+ @lru_cache(maxsize=1)
13
+ def _kokoro_pipeline():
14
+ if not SETTINGS.enable_kokoro:
15
+ raise RuntimeError("Kokoro is disabled. Set ENABLE_KOKORO=1 to enable it.")
16
+ from kokoro import KPipeline
17
+
18
+ return KPipeline(lang_code="a")
19
+
20
+
21
+ def generate_audio(
22
+ text: str,
23
+ voice: str = "af_heart",
24
+ speed: float = 1.0,
25
+ ) -> Path:
26
+ """Generate a WAV narration using the open-weight Kokoro TTS model."""
27
+
28
+ import soundfile as sf
29
+
30
+ normalized = text.strip()
31
+ if not normalized:
32
+ raise ValueError("Narration text is empty.")
33
+ normalized = normalized[: SETTINGS.max_audio_chars]
34
+
35
+ pipeline = _kokoro_pipeline()
36
+ audio_segments: list[np.ndarray] = []
37
+ for _, _, audio in pipeline(normalized, voice=voice, speed=speed):
38
+ audio_segments.append(np.asarray(audio, dtype=np.float32))
39
+
40
+ if not audio_segments:
41
+ raise RuntimeError("Kokoro returned no audio segments.")
42
+
43
+ combined = np.concatenate(audio_segments)
44
+ output_path = SETTINGS.output_dir / f"papercast-{uuid.uuid4().hex[:10]}.wav"
45
+ sf.write(output_path, combined, 24000)
46
+ return output_path
tests/test_narration.py ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ from src.narration import prepare_narration
2
+
3
+
4
+ def test_narration_removes_markdown_and_verbalizes_math():
5
+ text = "# Result\nThe state obeys $H|psi> = E|psi>$ and [details](https://example.com)."
6
+ narration = prepare_narration(text, "technical")
7
+ assert "#" not in narration
8
+ assert "https://" not in narration
9
+ assert "mathematical expression" in narration
tests/test_sections.py ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ from src.sections import split_markdown_sections
2
+
3
+
4
+ def test_split_sections():
5
+ markdown = """Opening text.\n\n# Introduction\nIntro content.\n\n## Method\nMethod content."""
6
+ sections = split_markdown_sections(markdown)
7
+ assert [section.title for section in sections] == ["Document opening", "Introduction", "Method"]
8
+ assert sections[-1].level == 2
tests/test_sources.py ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pathlib import Path
2
+
3
+ import pytest
4
+
5
+ from src.sources import normalize_arxiv_reference, resolve_source
6
+
7
+
8
+ def test_normalize_arxiv_id():
9
+ assert normalize_arxiv_reference("2501.17887") == "https://arxiv.org/pdf/2501.17887"
10
+
11
+
12
+ def test_normalize_arxiv_url():
13
+ assert normalize_arxiv_reference("https://arxiv.org/abs/2501.17887") == "https://arxiv.org/pdf/2501.17887"
14
+
15
+
16
+ def test_rejects_invalid_source():
17
+ with pytest.raises(ValueError):
18
+ resolve_source(None, "not a source")
19
+
20
+
21
+ def test_local_pdf(tmp_path: Path):
22
+ paper = tmp_path / "paper.pdf"
23
+ paper.write_bytes(b"%PDF-test")
24
+ assert resolve_source(paper, None) == str(paper)