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 +9 -0
- paperlab/agents/__init__.py +19 -0
- paperlab/agents/base.py +82 -0
- paperlab/agents/contextualizer.py +5 -0
- paperlab/agents/critic.py +5 -0
- paperlab/agents/methodologist.py +5 -0
- paperlab/agents/summarizer.py +5 -0
- paperlab/cli/__init__.py +0 -0
- paperlab/cli/config.py +105 -0
- paperlab/cli/main.py +205 -0
- paperlab/ingest/__init__.py +5 -0
- paperlab/ingest/pdf.py +70 -0
- paperlab/orchestrator/__init__.py +5 -0
- paperlab/orchestrator/runner.py +67 -0
- paperlab/prompts/__init__.py +5 -0
- paperlab/prompts/contextualizer.yaml +63 -0
- paperlab/prompts/critic.yaml +63 -0
- paperlab/prompts/loader.py +56 -0
- paperlab/prompts/methodologist.yaml +63 -0
- paperlab/prompts/summarizer.yaml +61 -0
- paperlab/providers/__init__.py +13 -0
- paperlab/providers/base.py +16 -0
- paperlab/providers/factory.py +41 -0
- paperlab/providers/fake.py +34 -0
- paperlab/providers/litellm_provider.py +26 -0
- paperlab/sessions/__init__.py +20 -0
- paperlab/sessions/export.py +53 -0
- paperlab/sessions/store.py +74 -0
- paperlab/web/__init__.py +5 -0
- paperlab/web/app.py +100 -0
- paperlab-0.1.0.dist-info/METADATA +204 -0
- paperlab-0.1.0.dist-info/RECORD +35 -0
- paperlab-0.1.0.dist-info/WHEEL +4 -0
- paperlab-0.1.0.dist-info/entry_points.txt +2 -0
- paperlab-0.1.0.dist-info/licenses/LICENSE +21 -0
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
|
+
]
|
paperlab/agents/base.py
ADDED
|
@@ -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
|
+
)
|
paperlab/cli/__init__.py
ADDED
|
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()
|
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,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,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: список того, что авторы считают новым или отличительным в своей работе
|