paperlab 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
paperlab/__init__.py ADDED
@@ -0,0 +1,9 @@
1
+ """paperlab — multi-agent LLM tool for critically reading biomedical research papers."""
2
+
3
+ try:
4
+ from importlib.metadata import PackageNotFoundError
5
+ from importlib.metadata import version as _v
6
+
7
+ __version__ = _v("paperlab")
8
+ except PackageNotFoundError:
9
+ __version__ = "0.0.0+unknown"
@@ -0,0 +1,19 @@
1
+ """paperlab.agents — Agent base class and concrete agents."""
2
+
3
+ from paperlab.agents.base import Agent, AgentReport
4
+ from paperlab.agents.contextualizer import ContextualizerAgent
5
+ from paperlab.agents.critic import CriticAgent
6
+ from paperlab.agents.methodologist import MethodologistAgent
7
+ from paperlab.agents.summarizer import SummarizerAgent
8
+
9
+ ALL_AGENTS = [SummarizerAgent, MethodologistAgent, CriticAgent, ContextualizerAgent]
10
+
11
+ __all__ = [
12
+ "Agent",
13
+ "AgentReport",
14
+ "SummarizerAgent",
15
+ "MethodologistAgent",
16
+ "CriticAgent",
17
+ "ContextualizerAgent",
18
+ "ALL_AGENTS",
19
+ ]
@@ -0,0 +1,82 @@
1
+ """Base Agent class and AgentReport model."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ from typing import ClassVar
8
+
9
+ from pydantic import BaseModel
10
+
11
+ from paperlab.prompts import load_prompt, render
12
+ from paperlab.providers.base import LLMProvider
13
+
14
+
15
+ class AgentReport(BaseModel):
16
+ agent_name: str
17
+ mode: str
18
+ lang: str
19
+ model: str
20
+ output: dict
21
+ raw: str
22
+ error: str | None = None
23
+
24
+
25
+ def _parse_json(raw: str) -> tuple[dict, str | None]:
26
+ """Try to parse *raw* as JSON.
27
+
28
+ First attempt: parse the whole string directly.
29
+ Second attempt: extract the first ``{...}`` block (greedy, DOTALL) and parse that.
30
+ Failure: return ``({}, error_message)``.
31
+ """
32
+ # Direct parse
33
+ try:
34
+ obj = json.loads(raw)
35
+ if isinstance(obj, dict):
36
+ return obj, None
37
+ except json.JSONDecodeError:
38
+ pass
39
+
40
+ # Extract first {...} block
41
+ match = re.search(r"\{.*\}", raw, re.DOTALL)
42
+ if match:
43
+ try:
44
+ obj = json.loads(match.group())
45
+ if isinstance(obj, dict):
46
+ return obj, None
47
+ except json.JSONDecodeError:
48
+ pass
49
+
50
+ return {}, "JSON parse error: could not extract a JSON object from raw response"
51
+
52
+
53
+ class Agent:
54
+ NAME: ClassVar[str] = ""
55
+
56
+ def __init__(
57
+ self,
58
+ provider: LLMProvider,
59
+ mode: str,
60
+ lang: str,
61
+ model: str,
62
+ ) -> None:
63
+ self._provider = provider
64
+ self._mode = mode
65
+ self._lang = lang
66
+ self._model = model
67
+
68
+ async def run(self, paper_text: str) -> AgentReport:
69
+ name = self.NAME
70
+ prompt = load_prompt(name, self._mode, self._lang)
71
+ user = render(prompt["user_template"], paper_text=paper_text)
72
+ raw = await self._provider.complete(prompt["system"], user, self._model)
73
+ output, error = _parse_json(raw)
74
+ return AgentReport(
75
+ agent_name=name,
76
+ mode=self._mode,
77
+ lang=self._lang,
78
+ model=self._model,
79
+ output=output,
80
+ raw=raw,
81
+ error=error,
82
+ )
@@ -0,0 +1,5 @@
1
+ from paperlab.agents.base import Agent
2
+
3
+
4
+ class ContextualizerAgent(Agent):
5
+ NAME = "contextualizer"
@@ -0,0 +1,5 @@
1
+ from paperlab.agents.base import Agent
2
+
3
+
4
+ class CriticAgent(Agent):
5
+ NAME = "critic"
@@ -0,0 +1,5 @@
1
+ from paperlab.agents.base import Agent
2
+
3
+
4
+ class MethodologistAgent(Agent):
5
+ NAME = "methodologist"
@@ -0,0 +1,5 @@
1
+ from paperlab.agents.base import Agent
2
+
3
+
4
+ class SummarizerAgent(Agent):
5
+ NAME = "summarizer"
File without changes
paperlab/cli/config.py ADDED
@@ -0,0 +1,105 @@
1
+ """paperlab configuration model and helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import tomllib
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ import tomli_w
11
+ from pydantic import BaseModel, Field
12
+
13
+
14
+ class PaperlabConfig(BaseModel):
15
+ provider: str = "ollama"
16
+ model: str = "qwen2.5:7b"
17
+ mode: str = "rigorous"
18
+ lang: str = "en"
19
+ extra: dict = Field(default_factory=dict)
20
+
21
+
22
+ _TOP_LEVEL_FIELDS: frozenset[str] = frozenset(PaperlabConfig.model_fields)
23
+
24
+
25
+ def default_home() -> Path:
26
+ """Return the paperlab home directory.
27
+
28
+ Uses PAPERLAB_HOME environment variable if set, otherwise ~/.paperlab.
29
+ """
30
+ home = os.environ.get("PAPERLAB_HOME")
31
+ if home:
32
+ return Path(home)
33
+ return Path.home() / ".paperlab"
34
+
35
+
36
+ def default_config_path() -> Path:
37
+ """Return the default config.toml path."""
38
+ return default_home() / "config.toml"
39
+
40
+
41
+ def load_config(path: Path | None = None) -> PaperlabConfig:
42
+ """Load configuration from *path*.
43
+
44
+ Returns a default ``PaperlabConfig`` if the file does not exist.
45
+ """
46
+ path = path or default_config_path()
47
+ if not path.exists():
48
+ return PaperlabConfig()
49
+ with open(path, "rb") as fh:
50
+ data = tomllib.load(fh)
51
+ return PaperlabConfig(**data)
52
+
53
+
54
+ def save_config(cfg: PaperlabConfig, path: Path | None = None) -> Path:
55
+ """Write *cfg* to *path* in TOML format. Creates parent directories."""
56
+ path = path or default_config_path()
57
+ path.parent.mkdir(parents=True, exist_ok=True)
58
+ with open(path, "wb") as fh:
59
+ tomli_w.dump(cfg.model_dump(), fh, multiline_strings=True)
60
+ return path
61
+
62
+
63
+ def get_field(cfg: PaperlabConfig, dotted: str) -> Any:
64
+ """Return a config value by dotted key.
65
+
66
+ Supports top-level keys (``provider``, ``model``, ``mode``, ``lang``,
67
+ ``extra``) and one level of nesting for ``extra`` (e.g. ``extra.foo``).
68
+
69
+ Raises
70
+ ------
71
+ KeyError
72
+ If the key is not valid.
73
+ """
74
+ parts = dotted.split(".", 1)
75
+ if parts[0] not in _TOP_LEVEL_FIELDS:
76
+ raise KeyError(f"Unknown config key: {dotted!r}")
77
+ if len(parts) == 1:
78
+ return getattr(cfg, parts[0])
79
+ if parts[0] != "extra":
80
+ raise KeyError(f"Nested access only supported for 'extra', not {parts[0]!r}")
81
+ extra: dict = cfg.extra
82
+ if parts[1] not in extra:
83
+ raise KeyError(f"Key not found in extra: {parts[1]!r}")
84
+ return extra[parts[1]]
85
+
86
+
87
+ def set_field(cfg: PaperlabConfig, dotted: str, value: str) -> PaperlabConfig:
88
+ """Return a new config with *dotted* field set to *value*.
89
+
90
+ Raises
91
+ ------
92
+ KeyError
93
+ If the key is not valid.
94
+ """
95
+ parts = dotted.split(".", 1)
96
+ if parts[0] not in _TOP_LEVEL_FIELDS:
97
+ raise KeyError(f"Unknown config key: {dotted!r}")
98
+ data = cfg.model_dump()
99
+ if len(parts) == 1:
100
+ data[parts[0]] = value
101
+ else:
102
+ if parts[0] != "extra":
103
+ raise KeyError(f"Nested access only supported for 'extra', not {parts[0]!r}")
104
+ data["extra"][parts[1]] = value
105
+ return PaperlabConfig(**data)
paperlab/cli/main.py ADDED
@@ -0,0 +1,205 @@
1
+ """paperlab CLI entrypoint."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from pathlib import Path
7
+ from types import SimpleNamespace
8
+
9
+ import typer
10
+ from rich.console import Console
11
+ from rich.table import Table
12
+
13
+ from paperlab import __version__
14
+ from paperlab.ingest import extract_text
15
+ from paperlab.providers.factory import make_provider
16
+
17
+ # ---------------------------------------------------------------------------
18
+ # Runtime shim — replace fields in tests to avoid real I/O.
19
+ # ---------------------------------------------------------------------------
20
+ _RUNTIME = SimpleNamespace(
21
+ extract_text=extract_text,
22
+ make_provider=make_provider,
23
+ )
24
+
25
+ # ---------------------------------------------------------------------------
26
+ # Typer apps
27
+ # ---------------------------------------------------------------------------
28
+ app = typer.Typer(
29
+ name="paperlab",
30
+ help="Multi-agent LLM tool for critically reading biomedical research papers.",
31
+ no_args_is_help=True,
32
+ add_completion=False,
33
+ )
34
+ config_app = typer.Typer(
35
+ name="config",
36
+ help="Get or set configuration values.",
37
+ no_args_is_help=True,
38
+ )
39
+ app.add_typer(config_app, name="config")
40
+
41
+ console = Console()
42
+
43
+
44
+ # ---------------------------------------------------------------------------
45
+ # Commands
46
+ # ---------------------------------------------------------------------------
47
+
48
+
49
+ @app.command()
50
+ def version() -> None:
51
+ """Show paperlab version."""
52
+ typer.echo(f"paperlab {__version__}")
53
+
54
+
55
+ @app.command()
56
+ def init(
57
+ force: bool = typer.Option(False, "--force", help="Overwrite existing config."),
58
+ ) -> None:
59
+ """Create ~/.paperlab with config.toml and sessions/."""
60
+ from paperlab.cli.config import PaperlabConfig, default_config_path, default_home, save_config
61
+
62
+ home = default_home()
63
+ sessions_dir = home / "sessions"
64
+ config_path = default_config_path()
65
+
66
+ if config_path.exists() and not force:
67
+ typer.echo(f"Config already exists: {config_path}", err=True)
68
+ typer.echo("Use --force to overwrite.", err=True)
69
+ raise typer.Exit(code=1)
70
+
71
+ sessions_dir.mkdir(parents=True, exist_ok=True)
72
+ save_config(PaperlabConfig(), config_path)
73
+
74
+ typer.echo(f"home: {home}")
75
+ typer.echo(f"sessions: {sessions_dir}")
76
+ typer.echo(f"config: {config_path}")
77
+
78
+
79
+ @app.command()
80
+ def read(
81
+ paper: Path = typer.Argument(..., help="Path to the PDF file"),
82
+ mode: str | None = typer.Option(None, help="rigorous | learning"),
83
+ lang: str | None = typer.Option(None, help="en | ru"),
84
+ model: str | None = typer.Option(None, help="Override the configured model"),
85
+ provider: str | None = typer.Option(None, help="Provider name"),
86
+ format: str = typer.Option("markdown", help="markdown | json"),
87
+ output: Path | None = typer.Option(None, help="Write report to file"),
88
+ ) -> None:
89
+ """Run the multi-agent review on a paper."""
90
+ from paperlab.cli.config import load_config
91
+ from paperlab.orchestrator import review
92
+ from paperlab.sessions import save_report, to_json, to_markdown
93
+
94
+ cfg = load_config()
95
+ effective_mode = mode or cfg.mode
96
+ effective_lang = lang or cfg.lang
97
+ effective_model = model or cfg.model
98
+ effective_provider = provider or cfg.provider
99
+
100
+ provider_instance = _RUNTIME.make_provider(effective_provider)
101
+ paper_obj = _RUNTIME.extract_text(paper)
102
+
103
+ report = asyncio.run(
104
+ review(paper_obj, provider_instance, effective_mode, effective_lang, effective_model)
105
+ )
106
+ save_report(report)
107
+
108
+ content = to_json(report) if format == "json" else to_markdown(report)
109
+ print(content)
110
+
111
+ if output:
112
+ output.write_text(content, encoding="utf-8")
113
+
114
+ print(f"session: {report.session_id}")
115
+
116
+
117
+ @app.command(name="list")
118
+ def list_sessions_cmd() -> None:
119
+ """List past review sessions."""
120
+ from paperlab.sessions import list_sessions
121
+
122
+ sessions = list_sessions()
123
+ if not sessions:
124
+ typer.echo("No sessions found.")
125
+ raise typer.Exit(code=0)
126
+
127
+ table = Table(show_header=True, header_style="bold")
128
+ table.add_column("session_id")
129
+ table.add_column("created_at")
130
+ table.add_column("mode")
131
+ table.add_column("lang")
132
+ table.add_column("model")
133
+ for s in sessions:
134
+ table.add_row(s.session_id, s.created_at, s.mode, s.lang, s.model)
135
+ console.print(table)
136
+
137
+
138
+ @app.command()
139
+ def show(
140
+ session_id: str = typer.Argument(..., help="Session ID to display"),
141
+ format: str = typer.Option("markdown", help="markdown | json"),
142
+ ) -> None:
143
+ """Show a past review session."""
144
+ from paperlab.sessions import load_report, to_json, to_markdown
145
+
146
+ report = load_report(session_id)
147
+ print(to_json(report) if format == "json" else to_markdown(report))
148
+
149
+
150
+ @app.command()
151
+ def web() -> None:
152
+ """Launch the local Gradio web dashboard."""
153
+ try:
154
+ from paperlab.web import launch
155
+ except ImportError as exc:
156
+ typer.echo(
157
+ f"Could not import paperlab.web: {exc}\n"
158
+ "Install the web extras: pip install paperlab[web]",
159
+ err=True,
160
+ )
161
+ raise typer.Exit(code=1) from None
162
+ launch()
163
+
164
+
165
+ # ---------------------------------------------------------------------------
166
+ # config sub-commands
167
+ # ---------------------------------------------------------------------------
168
+
169
+
170
+ @config_app.command(name="get")
171
+ def config_get(
172
+ key: str = typer.Argument(..., help="Config key (e.g. provider, model, extra.foo)"),
173
+ ) -> None:
174
+ """Print the value of a config key."""
175
+ from paperlab.cli.config import get_field, load_config
176
+
177
+ cfg = load_config()
178
+ try:
179
+ value = get_field(cfg, key)
180
+ except KeyError:
181
+ typer.echo(f"Unknown key: {key}", err=True)
182
+ raise typer.Exit(code=1) from None
183
+ typer.echo(value)
184
+
185
+
186
+ @config_app.command(name="set")
187
+ def config_set(
188
+ key: str = typer.Argument(..., help="Config key"),
189
+ value: str = typer.Argument(..., help="New value"),
190
+ ) -> None:
191
+ """Set a config key to a new value."""
192
+ from paperlab.cli.config import load_config, save_config, set_field
193
+
194
+ cfg = load_config()
195
+ try:
196
+ cfg = set_field(cfg, key, value)
197
+ except KeyError:
198
+ typer.echo(f"Unknown key: {key}", err=True)
199
+ raise typer.Exit(code=1) from None
200
+ save_config(cfg)
201
+ typer.echo(f"{key} = {value}")
202
+
203
+
204
+ if __name__ == "__main__":
205
+ app()
@@ -0,0 +1,5 @@
1
+ """paperlab.ingest — PDF ingestion utilities."""
2
+
3
+ from paperlab.ingest.pdf import IngestedPaper, IngestError, extract_text
4
+
5
+ __all__ = ["extract_text", "IngestedPaper", "IngestError"]
paperlab/ingest/pdf.py ADDED
@@ -0,0 +1,70 @@
1
+ """PDF text extraction for paperlab."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from pydantic import BaseModel
8
+
9
+
10
+ class IngestError(Exception):
11
+ """Raised when PDF conversion fails."""
12
+
13
+
14
+ class IngestedPaper(BaseModel):
15
+ source_path: str
16
+ title: str | None = None
17
+ text: str
18
+ num_pages: int | None = None
19
+ backend: str
20
+
21
+
22
+ def extract_text(path: Path | str, converter=None) -> IngestedPaper:
23
+ """Extract text from a PDF file.
24
+
25
+ Parameters
26
+ ----------
27
+ path:
28
+ Path to the PDF file.
29
+ converter:
30
+ Optional docling DocumentConverter instance. Created lazily if None.
31
+
32
+ Returns
33
+ -------
34
+ IngestedPaper
35
+ """
36
+ path = Path(path)
37
+
38
+ if not path.exists():
39
+ raise FileNotFoundError(f"PDF file not found: {path}")
40
+
41
+ if path.suffix.lower() != ".pdf":
42
+ raise ValueError(f"Expected a .pdf file, got: {path.suffix!r} ({path})")
43
+
44
+ if converter is None:
45
+ from docling.document_converter import DocumentConverter # lazy import
46
+
47
+ converter = DocumentConverter()
48
+
49
+ try:
50
+ result = converter.convert(str(path))
51
+ except Exception as exc:
52
+ raise IngestError(f"Failed to convert {path}: {exc}") from exc
53
+
54
+ text = result.document.export_to_markdown()
55
+
56
+ num_pages: int | None = None
57
+ if hasattr(result, "pages"):
58
+ num_pages = len(result.pages)
59
+
60
+ title: str | None = None
61
+ if hasattr(result.document, "title"):
62
+ title = result.document.title
63
+
64
+ return IngestedPaper(
65
+ source_path=str(path),
66
+ title=title,
67
+ text=text,
68
+ num_pages=num_pages,
69
+ backend="docling",
70
+ )
@@ -0,0 +1,5 @@
1
+ """paperlab.orchestrator — parallel review orchestration."""
2
+
3
+ from paperlab.orchestrator.runner import ReviewReport, review
4
+
5
+ __all__ = ["review", "ReviewReport"]
@@ -0,0 +1,67 @@
1
+ """paperlab.orchestrator.runner — parallel review runner."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import uuid
7
+ from datetime import UTC, datetime
8
+
9
+ from pydantic import BaseModel
10
+
11
+ from paperlab.agents import ALL_AGENTS, AgentReport
12
+ from paperlab.ingest import IngestedPaper
13
+ from paperlab.providers.base import LLMProvider
14
+
15
+
16
+ class ReviewReport(BaseModel):
17
+ paper: IngestedPaper
18
+ mode: str
19
+ lang: str
20
+ model: str
21
+ session_id: str
22
+ created_at: str # ISO 8601 UTC
23
+ agents: dict[str, AgentReport]
24
+
25
+
26
+ async def review(
27
+ paper: IngestedPaper,
28
+ provider: LLMProvider,
29
+ mode: str,
30
+ lang: str,
31
+ model: str,
32
+ session_id: str | None = None,
33
+ ) -> ReviewReport:
34
+ session_id = session_id or uuid.uuid4().hex[:12]
35
+ created_at = datetime.now(UTC).isoformat()
36
+
37
+ agent_instances = [cls(provider, mode, lang, model) for cls in ALL_AGENTS]
38
+
39
+ results = await asyncio.gather(
40
+ *[agent.run(paper.text) for agent in agent_instances],
41
+ return_exceptions=True,
42
+ )
43
+
44
+ agents: dict[str, AgentReport] = {}
45
+ for agent, result in zip(agent_instances, results, strict=False):
46
+ if isinstance(result, Exception):
47
+ agents[agent.NAME] = AgentReport(
48
+ agent_name=agent.NAME,
49
+ mode=mode,
50
+ lang=lang,
51
+ model=model,
52
+ output={},
53
+ raw="",
54
+ error=str(result),
55
+ )
56
+ else:
57
+ agents[agent.NAME] = result
58
+
59
+ return ReviewReport(
60
+ paper=paper,
61
+ mode=mode,
62
+ lang=lang,
63
+ model=model,
64
+ session_id=session_id,
65
+ created_at=created_at,
66
+ agents=agents,
67
+ )
@@ -0,0 +1,5 @@
1
+ """Prompt loading for paperlab agents."""
2
+
3
+ from paperlab.prompts.loader import load_prompt, render
4
+
5
+ __all__ = ["load_prompt", "render"]
@@ -0,0 +1,63 @@
1
+ en:
2
+ rigorous:
3
+ system: >
4
+ You are an expert in mapping scientific papers to their research landscape.
5
+ Determine where this paper sits within its field based solely on what the authors state,
6
+ identify related work they explicitly cite or compare against, and assess the novelty claims
7
+ the authors make without adding external knowledge.
8
+ Be precise and cite specific passages where possible.
9
+ user_template: |
10
+ Paper text:
11
+ {paper_text}
12
+
13
+ Return a JSON object with fields:
14
+ - field_position: string describing where this paper positions itself within its research field
15
+ - related_work: list of prior works or comparators the authors explicitly mention
16
+ - novelty: list of novelty claims the authors make for their contribution
17
+
18
+ learning:
19
+ system: >
20
+ You are a research context tutor helping a student understand where a paper fits in the literature.
21
+ Explain in accessible terms how the authors describe their field, what prior work they compare to,
22
+ and what they claim is new about their contribution.
23
+ Draw only from the text of the paper itself.
24
+ user_template: |
25
+ Paper text:
26
+ {paper_text}
27
+
28
+ Return a JSON object with fields:
29
+ - field_position: plain-language description of where this paper places itself in its field
30
+ - related_work: list of prior works or comparators the authors mention
31
+ - novelty: list of what the authors claim is new or different about their work
32
+
33
+ ru:
34
+ rigorous:
35
+ system: >
36
+ Вы — эксперт по позиционированию научных работ в исследовательском ландшафте.
37
+ Определяйте место статьи в её области, опираясь исключительно на то, что утверждают авторы;
38
+ выявляйте работы, которые они явно цитируют или с которыми сравниваются;
39
+ оценивайте заявленную новизну без привлечения внешних знаний.
40
+ Будьте точны и по возможности ссылайтесь на конкретные фрагменты текста.
41
+ user_template: |
42
+ Текст статьи:
43
+ {paper_text}
44
+
45
+ Верните JSON-объект с полями:
46
+ - field_position: строка, описывающая, как авторы позиционируют работу в своей области
47
+ - related_work: список предшествующих работ или объектов сравнения, явно упомянутых авторами
48
+ - novelty: список заявленных авторами новшеств их вклада
49
+
50
+ learning:
51
+ system: >
52
+ Вы — наставник по научному контексту, помогающий студенту понять, какое место статья занимает в литературе.
53
+ Доступным языком объясните, как авторы описывают своё поле, с какими предшествующими работами сравниваются
54
+ и что именно они считают новым в своём вкладе.
55
+ Опирайтесь только на текст самой статьи.
56
+ user_template: |
57
+ Текст статьи:
58
+ {paper_text}
59
+
60
+ Верните JSON-объект с полями:
61
+ - field_position: описание простым языком того, как авторы позиционируют работу в своей области
62
+ - related_work: список предшествующих работ или объектов сравнения, упомянутых авторами
63
+ - novelty: список того, что авторы считают новым или отличительным в своей работе