medawar 0.1.0__tar.gz
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.
- medawar-0.1.0/.gitignore +11 -0
- medawar-0.1.0/PKG-INFO +48 -0
- medawar-0.1.0/medawar/README.md +31 -0
- medawar-0.1.0/medawar/__init__.py +0 -0
- medawar-0.1.0/medawar/cache.py +117 -0
- medawar-0.1.0/medawar/cli/__init__.py +1 -0
- medawar-0.1.0/medawar/cli/commands.py +429 -0
- medawar-0.1.0/medawar/cli/credentials.py +33 -0
- medawar-0.1.0/medawar/cli/device_login.py +84 -0
- medawar-0.1.0/medawar/config.py +49 -0
- medawar-0.1.0/medawar/engine/__init__.py +5 -0
- medawar-0.1.0/medawar/engine/client.py +197 -0
- medawar-0.1.0/medawar/engine/flow.py +128 -0
- medawar-0.1.0/medawar/engine/models.py +126 -0
- medawar-0.1.0/medawar/main.py +21 -0
- medawar-0.1.0/medawar/papers/__init__.py +1 -0
- medawar-0.1.0/medawar/papers/document.py +56 -0
- medawar-0.1.0/medawar/papers/errors.py +10 -0
- medawar-0.1.0/medawar/papers/hashing.py +57 -0
- medawar-0.1.0/medawar/papers/latex.py +867 -0
- medawar-0.1.0/medawar/papers/markdown.py +74 -0
- medawar-0.1.0/medawar/papers/overleaf.py +192 -0
- medawar-0.1.0/medawar/papers/payload.py +244 -0
- medawar-0.1.0/medawar/papers/prepare.py +56 -0
- medawar-0.1.0/medawar/papers/sources.py +44 -0
- medawar-0.1.0/medawar/report.py +195 -0
- medawar-0.1.0/pyproject.toml +60 -0
medawar-0.1.0/.gitignore
ADDED
medawar-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: medawar
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Review your scientific papers from the terminal with Medawar
|
|
5
|
+
Project-URL: Homepage, https://medawar.science
|
|
6
|
+
Project-URL: Documentation, https://medawar.science/docs/cli/
|
|
7
|
+
Requires-Python: >=3.11
|
|
8
|
+
Requires-Dist: httpx>=0.27
|
|
9
|
+
Requires-Dist: platformdirs>=4.2
|
|
10
|
+
Requires-Dist: pydantic-settings>=2.3
|
|
11
|
+
Requires-Dist: pydantic>=2.7
|
|
12
|
+
Requires-Dist: rich>=13.7
|
|
13
|
+
Requires-Dist: typer>=0.12
|
|
14
|
+
Provides-Extra: pdf
|
|
15
|
+
Requires-Dist: weasyprint>=62; extra == 'pdf'
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
|
|
18
|
+
# Medawar CLI
|
|
19
|
+
|
|
20
|
+
Review your scientific paper from the terminal with
|
|
21
|
+
[Medawar](https://medawar.science): structure, abstract, language, references,
|
|
22
|
+
statistics, ethics statements and more, with each finding located in your text.
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
pip install medawar # or: uv tool install medawar / pipx install medawar
|
|
26
|
+
|
|
27
|
+
medawar login # approve in your browser
|
|
28
|
+
medawar review paper.pdf # every check
|
|
29
|
+
medawar review ./latex-project --only title,abstract --report review.html
|
|
30
|
+
medawar findings paper.pdf # cached results, no network
|
|
31
|
+
medawar skills # the checks, and what each costs
|
|
32
|
+
medawar expert-review paper.pdf # independent human reviewers (asks first)
|
|
33
|
+
medawar expert-review status # where your expert reviews stand
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Papers can be a PDF or Word file, a `.tex` file or LaTeX project directory,
|
|
37
|
+
Markdown, or an Overleaf project (`overleaf://<id>` or its URL). LaTeX,
|
|
38
|
+
Markdown and Overleaf projects are parsed on your machine; PDF and Word files
|
|
39
|
+
are uploaded and read by the review engine.
|
|
40
|
+
|
|
41
|
+
Extractions and reviews are cached locally (`medawar cache path`), so running
|
|
42
|
+
one more check never re-uploads or re-reads the paper.
|
|
43
|
+
|
|
44
|
+
PDF reports (`--report review.pdf`) need `pip install "medawar[pdf]"`;
|
|
45
|
+
`.html` reports work out of the box.
|
|
46
|
+
|
|
47
|
+
Reviews use your Medawar plan's credits: a free trial covers one full review.
|
|
48
|
+
Plans, credits and the full documentation: <https://medawar.science/docs/cli/>.
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# Medawar CLI
|
|
2
|
+
|
|
3
|
+
Review your scientific paper from the terminal with
|
|
4
|
+
[Medawar](https://medawar.science): structure, abstract, language, references,
|
|
5
|
+
statistics, ethics statements and more, with each finding located in your text.
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install medawar # or: uv tool install medawar / pipx install medawar
|
|
9
|
+
|
|
10
|
+
medawar login # approve in your browser
|
|
11
|
+
medawar review paper.pdf # every check
|
|
12
|
+
medawar review ./latex-project --only title,abstract --report review.html
|
|
13
|
+
medawar findings paper.pdf # cached results, no network
|
|
14
|
+
medawar skills # the checks, and what each costs
|
|
15
|
+
medawar expert-review paper.pdf # independent human reviewers (asks first)
|
|
16
|
+
medawar expert-review status # where your expert reviews stand
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Papers can be a PDF or Word file, a `.tex` file or LaTeX project directory,
|
|
20
|
+
Markdown, or an Overleaf project (`overleaf://<id>` or its URL). LaTeX,
|
|
21
|
+
Markdown and Overleaf projects are parsed on your machine; PDF and Word files
|
|
22
|
+
are uploaded and read by the review engine.
|
|
23
|
+
|
|
24
|
+
Extractions and reviews are cached locally (`medawar cache path`), so running
|
|
25
|
+
one more check never re-uploads or re-reads the paper.
|
|
26
|
+
|
|
27
|
+
PDF reports (`--report review.pdf`) need `pip install "medawar[pdf]"`;
|
|
28
|
+
`.html` reports work out of the box.
|
|
29
|
+
|
|
30
|
+
Reviews use your Medawar plan's credits: a free trial covers one full review.
|
|
31
|
+
Plans, credits and the full documentation: <https://medawar.science/docs/cli/>.
|
|
File without changes
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Local cache of papers, extractions and reviews, keyed by content hash.
|
|
2
|
+
|
|
3
|
+
Layout under the cache dir (platformdirs user_cache_dir("medawar")):
|
|
4
|
+
|
|
5
|
+
papers/<content_hash>/
|
|
6
|
+
meta.json source path, kind, title, extraction_id, timestamps
|
|
7
|
+
payload.json engine extraction payload
|
|
8
|
+
reviews/<key>.json one review (engine Review JSON + fetched_at),
|
|
9
|
+
key = "all" or the sorted requested skills joined by "+"
|
|
10
|
+
|
|
11
|
+
A cached payload means `review --only x` never re-uploads or re-extracts.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import shutil
|
|
17
|
+
from datetime import datetime, timezone
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
from medawar.engine.models import Finding, Review
|
|
22
|
+
|
|
23
|
+
ALL_SKILLS_KEY = "all"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _now() -> str:
|
|
27
|
+
return datetime.now(timezone.utc).isoformat()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def review_key(skills: list[str] | None) -> str:
|
|
31
|
+
return "+".join(sorted(set(skills))) if skills else ALL_SKILLS_KEY
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _write_json(path: Path, data: Any) -> None:
|
|
35
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
36
|
+
tmp = path.with_suffix(path.suffix + ".tmp")
|
|
37
|
+
tmp.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
38
|
+
tmp.replace(path)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _read_json(path: Path) -> Any | None:
|
|
42
|
+
try:
|
|
43
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
44
|
+
except (OSError, ValueError):
|
|
45
|
+
return None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class PaperCache:
|
|
49
|
+
"""One paper's entry."""
|
|
50
|
+
|
|
51
|
+
def __init__(self, root: Path) -> None:
|
|
52
|
+
self.root = root
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def exists(self) -> bool:
|
|
56
|
+
return (self.root / "meta.json").exists()
|
|
57
|
+
|
|
58
|
+
def meta(self) -> dict[str, Any]:
|
|
59
|
+
return _read_json(self.root / "meta.json") or {}
|
|
60
|
+
|
|
61
|
+
def update_meta(self, **fields: Any) -> None:
|
|
62
|
+
meta = self.meta()
|
|
63
|
+
meta.setdefault("created_at", _now())
|
|
64
|
+
meta.update(fields, updated_at=_now())
|
|
65
|
+
_write_json(self.root / "meta.json", meta)
|
|
66
|
+
|
|
67
|
+
@property
|
|
68
|
+
def extraction_id(self) -> int | str | None:
|
|
69
|
+
return self.meta().get("extraction_id")
|
|
70
|
+
|
|
71
|
+
def payload(self) -> dict[str, Any] | None:
|
|
72
|
+
return _read_json(self.root / "payload.json")
|
|
73
|
+
|
|
74
|
+
def save_extraction(self, payload: dict[str, Any], extraction_id: int | str | None) -> None:
|
|
75
|
+
_write_json(self.root / "payload.json", payload)
|
|
76
|
+
self.update_meta(extraction_id=extraction_id, title=payload.get("title"))
|
|
77
|
+
|
|
78
|
+
def save_review(self, review: Review, skills: list[str] | None) -> Path:
|
|
79
|
+
path = self.root / "reviews" / f"{review_key(skills)}.json"
|
|
80
|
+
_write_json(path, {"fetched_at": _now(), "review": review.model_dump(mode="json")})
|
|
81
|
+
return path
|
|
82
|
+
|
|
83
|
+
def reviews(self) -> list[tuple[str, Review]]:
|
|
84
|
+
"""Cached reviews, oldest first."""
|
|
85
|
+
entries = []
|
|
86
|
+
for path in (self.root / "reviews").glob("*.json"):
|
|
87
|
+
data = _read_json(path)
|
|
88
|
+
if data and "review" in data:
|
|
89
|
+
entries.append((data.get("fetched_at", ""), Review.model_validate(data["review"])))
|
|
90
|
+
return sorted(entries, key=lambda e: e[0])
|
|
91
|
+
|
|
92
|
+
def merged_findings(self) -> tuple[list[Finding], list[str]]:
|
|
93
|
+
"""Each skill's findings from the newest review that ran it, so
|
|
94
|
+
`review --only x` refreshes x without hiding the other skills.
|
|
95
|
+
Returns (findings, skills covered)."""
|
|
96
|
+
latest_by_skill: dict[str, Review] = {}
|
|
97
|
+
for _, review in self.reviews():
|
|
98
|
+
if review.status != "completed":
|
|
99
|
+
continue
|
|
100
|
+
for skill in review.skills_run() or review.skills_requested or []:
|
|
101
|
+
latest_by_skill[skill] = review
|
|
102
|
+
findings = [
|
|
103
|
+
f for skill, review in latest_by_skill.items()
|
|
104
|
+
for f in review.findings if f.skill == skill
|
|
105
|
+
]
|
|
106
|
+
return findings, sorted(latest_by_skill)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class LocalCache:
|
|
110
|
+
def __init__(self, root: Path) -> None:
|
|
111
|
+
self.root = root
|
|
112
|
+
|
|
113
|
+
def paper(self, content_hash: str) -> PaperCache:
|
|
114
|
+
return PaperCache(self.root / "papers" / content_hash)
|
|
115
|
+
|
|
116
|
+
def clear(self) -> None:
|
|
117
|
+
shutil.rmtree(self.root / "papers", ignore_errors=True)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""User-facing commands that work on papers through medawar-api."""
|
|
@@ -0,0 +1,429 @@
|
|
|
1
|
+
"""Paper commands of the `medawar` CLI: login, skills, extract, review,
|
|
2
|
+
findings, cache. They talk to medawar-api (MEDAWAR_API_URL), which proxies
|
|
3
|
+
the CMM engine, and keep extractions and reviews in the local cache.
|
|
4
|
+
"""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import asyncio
|
|
8
|
+
import json
|
|
9
|
+
import webbrowser
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
import httpx
|
|
13
|
+
import typer
|
|
14
|
+
from rich.console import Console
|
|
15
|
+
from rich.table import Table
|
|
16
|
+
|
|
17
|
+
from medawar.cache import LocalCache
|
|
18
|
+
from medawar.cli import device_login
|
|
19
|
+
from medawar.cli.credentials import credentials_path, load_token, save_token
|
|
20
|
+
from medawar.config import get_medawar_settings
|
|
21
|
+
from medawar.engine.client import EngineClient, EngineError
|
|
22
|
+
from medawar.engine.flow import ReviewRunner
|
|
23
|
+
from medawar.engine.models import SEVERITIES, Finding, Progress, Review
|
|
24
|
+
from medawar.papers.errors import PaperError
|
|
25
|
+
from medawar.papers.overleaf import OverleafAuth
|
|
26
|
+
from medawar.papers.prepare import PreparedPaper, prepare
|
|
27
|
+
from medawar.papers.sources import resolve
|
|
28
|
+
from medawar.report import ReportError, write_report
|
|
29
|
+
|
|
30
|
+
console = Console()
|
|
31
|
+
err_console = Console(stderr=True)
|
|
32
|
+
|
|
33
|
+
_SEVERITY_STYLE = {"critical": "bold red", "major": "yellow", "minor": "dim"}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def make_client() -> EngineClient:
|
|
37
|
+
"""Engine client for medawar-api with the user's token. Tests patch this."""
|
|
38
|
+
token = load_token()
|
|
39
|
+
if not token:
|
|
40
|
+
err_console.print("[red]Not logged in.[/red] Run `medawar login` first.")
|
|
41
|
+
raise typer.Exit(1)
|
|
42
|
+
settings = get_medawar_settings()
|
|
43
|
+
return EngineClient.for_user(
|
|
44
|
+
settings.medawar_api_url, token, timeout=settings.engine_http_timeout_s
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def make_api_client() -> httpx.Client:
|
|
49
|
+
"""medawar-api client with the user's token, for the account routes the
|
|
50
|
+
engine client does not cover (expert reviews). Tests patch this."""
|
|
51
|
+
token = load_token()
|
|
52
|
+
if not token:
|
|
53
|
+
err_console.print("[red]Not logged in.[/red] Run `medawar login` first.")
|
|
54
|
+
raise typer.Exit(1)
|
|
55
|
+
settings = get_medawar_settings()
|
|
56
|
+
return httpx.Client(base_url=settings.medawar_api_url.rstrip("/"),
|
|
57
|
+
headers={"Authorization": f"Bearer {token}"},
|
|
58
|
+
timeout=settings.engine_http_timeout_s)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def make_login_client() -> httpx.Client:
|
|
62
|
+
"""Unauthenticated client for the login endpoints. Tests patch this."""
|
|
63
|
+
return httpx.Client(base_url=get_medawar_settings().medawar_api_url.rstrip("/"), timeout=30)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def make_cache() -> LocalCache:
|
|
67
|
+
return LocalCache(get_medawar_settings().cache_dir())
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _fail(message: str) -> typer.Exit:
|
|
71
|
+
err_console.print(f"[red]{message}[/red]")
|
|
72
|
+
return typer.Exit(1)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _engine_fail(e: PaperError | EngineError) -> typer.Exit:
|
|
76
|
+
if isinstance(e, EngineError) and e.status_code == 401:
|
|
77
|
+
return _fail(f"{e}. Run `medawar login` again.")
|
|
78
|
+
return _fail(str(e))
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _parse_only(only: str | None) -> list[str] | None:
|
|
82
|
+
skills = [s.strip() for s in (only or "").split(",") if s.strip()]
|
|
83
|
+
return skills or None
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _prepared(path: Path) -> PreparedPaper:
|
|
87
|
+
try:
|
|
88
|
+
return prepare(path)
|
|
89
|
+
except PaperError as e:
|
|
90
|
+
raise _fail(str(e))
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _print_findings(title: str | None, findings: list[Finding], skills: list[str]) -> None:
|
|
94
|
+
counts = {sev: sum(1 for f in findings if f.severity == sev) for sev in SEVERITIES}
|
|
95
|
+
console.print(f"\n[bold]{title or '(untitled manuscript)'}[/bold]")
|
|
96
|
+
console.print(
|
|
97
|
+
f"{len(findings)} findings: [bold red]{counts['critical']} critical[/bold red], "
|
|
98
|
+
f"[yellow]{counts['major']} major[/yellow], {counts['minor']} minor "
|
|
99
|
+
f"[dim]({len(skills)} checks: {', '.join(skills)})[/dim]\n"
|
|
100
|
+
)
|
|
101
|
+
for f in sorted(findings, key=lambda x: (SEVERITIES.index(x.severity), x.skill)):
|
|
102
|
+
style = _SEVERITY_STYLE[f.severity]
|
|
103
|
+
where = f" [dim]{f.location}[/dim]" if f.location else ""
|
|
104
|
+
tags = f" [magenta]{', '.join(f.tags)}[/magenta]" if f.tags else ""
|
|
105
|
+
console.print(f"[{style}]{f.severity.upper():8}[/{style}] [cyan]{f.skill}[/cyan]{where}{tags}")
|
|
106
|
+
if f.quote:
|
|
107
|
+
console.print(f" [italic]\"{f.quote}\"[/italic]")
|
|
108
|
+
if f.explanation:
|
|
109
|
+
console.print(f" {f.explanation}")
|
|
110
|
+
if f.suggestion:
|
|
111
|
+
console.print(f" [green]-> {f.suggestion}[/green]")
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _write_report(review: Review, out: Path, findings=None, skills=None) -> None:
|
|
115
|
+
try:
|
|
116
|
+
written = write_report(review, out, findings=findings, skills=skills)
|
|
117
|
+
except ReportError as e:
|
|
118
|
+
err_console.print(f"[yellow]{e}[/yellow]")
|
|
119
|
+
return
|
|
120
|
+
err_console.print(f"Report written to {written}")
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _progress_printer(status):
|
|
124
|
+
def on_progress(job_status: str, progress: Progress | None) -> None:
|
|
125
|
+
detail = ""
|
|
126
|
+
if progress is not None and progress.percentage is not None:
|
|
127
|
+
detail = f" {progress.percentage:.0f}%"
|
|
128
|
+
if progress is not None and progress.message:
|
|
129
|
+
detail += f" {progress.message}"
|
|
130
|
+
status.update(f"{job_status}{detail}")
|
|
131
|
+
return on_progress
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
async def _with_runner(coro_factory):
|
|
135
|
+
settings = get_medawar_settings()
|
|
136
|
+
with err_console.status("contacting medawar-api") as status:
|
|
137
|
+
async with make_client() as client:
|
|
138
|
+
runner = ReviewRunner(
|
|
139
|
+
client, cache=make_cache(), timeout=settings.engine_poll_timeout_s,
|
|
140
|
+
on_progress=_progress_printer(status),
|
|
141
|
+
)
|
|
142
|
+
return await coro_factory(runner)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _device_login() -> str:
|
|
146
|
+
with make_login_client() as client:
|
|
147
|
+
try:
|
|
148
|
+
grant = device_login.start(client)
|
|
149
|
+
console.print(
|
|
150
|
+
f"\nOpen [cyan]{grant.verification_uri}[/cyan] and enter the code "
|
|
151
|
+
f"[bold]{grant.user_code}[/bold]\n(or open {grant.verification_uri_complete})\n"
|
|
152
|
+
)
|
|
153
|
+
webbrowser.open(grant.verification_uri_complete)
|
|
154
|
+
with console.status("Waiting for you to approve the login in your browser..."):
|
|
155
|
+
return device_login.wait_for_token(client, grant)
|
|
156
|
+
except device_login.LoginError as e:
|
|
157
|
+
raise _fail(str(e))
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def login(
|
|
161
|
+
service: str = typer.Argument("medawar", help="'medawar' (your account) or 'overleaf'."),
|
|
162
|
+
token: str | None = typer.Option(None, "--token", help="API token (medawar only)."),
|
|
163
|
+
) -> None:
|
|
164
|
+
"""Sign in to Medawar (in your browser), or store Overleaf access."""
|
|
165
|
+
if service == "medawar":
|
|
166
|
+
value = token.strip() if token is not None else _device_login()
|
|
167
|
+
if not value:
|
|
168
|
+
raise _fail("Nothing stored.")
|
|
169
|
+
path = save_token(value)
|
|
170
|
+
console.print(f"[green]Signed in.[/green] Token saved to {path} (mode 0600)")
|
|
171
|
+
return
|
|
172
|
+
if service != "overleaf":
|
|
173
|
+
raise _fail(f"Unknown service: {service}. Use 'medawar' or 'overleaf'.")
|
|
174
|
+
existing = OverleafAuth.load()
|
|
175
|
+
console.print(
|
|
176
|
+
"\n[bold cyan]Overleaf credentials[/bold cyan]\n\n"
|
|
177
|
+
"Two transports are supported, in order:\n"
|
|
178
|
+
" 1. Git Bridge (preferred). Create a token at\n"
|
|
179
|
+
" https://www.overleaf.com/user/settings, Account, Git Authentication Tokens.\n"
|
|
180
|
+
" 2. Zip download (fallback). Copy your [cyan]overleaf_session2[/cyan] cookie\n"
|
|
181
|
+
" from a logged-in browser session. Lasts as long as the cookie does.\n\n"
|
|
182
|
+
"Press Enter to leave a field unchanged.\n"
|
|
183
|
+
)
|
|
184
|
+
git_input = console.input(
|
|
185
|
+
f" Git Bridge token [dim]{'(keep)' if existing.git_token else '(none)'}[/dim]: "
|
|
186
|
+
).strip()
|
|
187
|
+
cookie_input = console.input(
|
|
188
|
+
f" Session cookie [dim]{'(keep)' if existing.session_cookie else '(none)'}[/dim]: "
|
|
189
|
+
).strip()
|
|
190
|
+
new_git = git_input or existing.git_token
|
|
191
|
+
new_cookie = cookie_input or existing.session_cookie
|
|
192
|
+
if not new_git and not new_cookie:
|
|
193
|
+
raise _fail("Nothing stored. At least one of the two is required.")
|
|
194
|
+
path = OverleafAuth(git_token=new_git, session_cookie=new_cookie).save()
|
|
195
|
+
console.print(f"\n[green]Saved.[/green] {path} (mode 0600)")
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def skills() -> None:
|
|
199
|
+
"""List the review checks the engine can run."""
|
|
200
|
+
|
|
201
|
+
async def run():
|
|
202
|
+
async with make_client() as client:
|
|
203
|
+
return await client.list_skills()
|
|
204
|
+
|
|
205
|
+
try:
|
|
206
|
+
catalog = asyncio.run(run())
|
|
207
|
+
except EngineError as e:
|
|
208
|
+
raise _engine_fail(e)
|
|
209
|
+
table = Table("name", "description", "depends on", "credits")
|
|
210
|
+
for s in catalog:
|
|
211
|
+
table.add_row(s.name, s.description or "", ", ".join(s.depends_on),
|
|
212
|
+
"" if s.credits is None else str(s.credits))
|
|
213
|
+
console.print(table)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def extract(
|
|
217
|
+
paper: str = typer.Argument(..., help="File, LaTeX project directory, or Overleaf URL."),
|
|
218
|
+
) -> None:
|
|
219
|
+
"""Extract a paper once and cache the result; later reviews reuse it."""
|
|
220
|
+
try:
|
|
221
|
+
with resolve(paper) as path:
|
|
222
|
+
prepared = _prepared(path)
|
|
223
|
+
payload, extraction_id = asyncio.run(_with_runner(lambda r: r.extract(prepared)))
|
|
224
|
+
except (PaperError, EngineError) as e:
|
|
225
|
+
raise _engine_fail(e)
|
|
226
|
+
console.print(f"[bold]{payload.get('title') or '(untitled manuscript)'}[/bold]")
|
|
227
|
+
console.print(
|
|
228
|
+
f"{len(payload.get('sections') or [])} sections, "
|
|
229
|
+
f"{len(payload.get('references') or [])} references, "
|
|
230
|
+
f"{len(payload.get('visual_elements') or [])} figures/tables"
|
|
231
|
+
)
|
|
232
|
+
source = f"engine extraction {extraction_id}" if extraction_id else "parsed locally"
|
|
233
|
+
console.print(f"[dim]{source}; cached as {prepared.content_hash[:12]}[/dim]")
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def review(
|
|
237
|
+
paper: str = typer.Argument(..., help="File, LaTeX project directory, or Overleaf URL."),
|
|
238
|
+
only: str | None = typer.Option(None, "--only", help="Comma-separated checks, e.g. title,abstract."),
|
|
239
|
+
as_json: bool = typer.Option(False, "--json", help="Print the engine review as JSON."),
|
|
240
|
+
report: Path | None = typer.Option(None, "--report", help="Write an .html or .pdf report."),
|
|
241
|
+
) -> None:
|
|
242
|
+
"""Review a paper (all checks, or --only some), reusing its cached extraction."""
|
|
243
|
+
skill_list = _parse_only(only)
|
|
244
|
+
try:
|
|
245
|
+
with resolve(paper) as path:
|
|
246
|
+
prepared = _prepared(path)
|
|
247
|
+
result = asyncio.run(_with_runner(lambda r: r.review(prepared, skill_list)))
|
|
248
|
+
except (PaperError, EngineError) as e:
|
|
249
|
+
raise _engine_fail(e)
|
|
250
|
+
if as_json:
|
|
251
|
+
console.print_json(json.dumps(result.model_dump(mode="json")))
|
|
252
|
+
elif result.status != "completed":
|
|
253
|
+
raise _fail(f"Review {result.status}: {result.error or 'unknown error'}")
|
|
254
|
+
else:
|
|
255
|
+
_print_findings(result.title, result.findings, result.skills_run())
|
|
256
|
+
if result.unavailable_reviews:
|
|
257
|
+
console.print(f"[yellow]Not available right now: "
|
|
258
|
+
f"{', '.join(result.unavailable_reviews)}[/yellow]")
|
|
259
|
+
if report is not None and result.status == "completed":
|
|
260
|
+
_write_report(result, report)
|
|
261
|
+
if result.status != "completed":
|
|
262
|
+
raise typer.Exit(1)
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def findings(
|
|
266
|
+
paper: str = typer.Argument(..., help="File or LaTeX project directory reviewed before."),
|
|
267
|
+
as_json: bool = typer.Option(False, "--json"),
|
|
268
|
+
report: Path | None = typer.Option(None, "--report", help="Write an .html or .pdf report."),
|
|
269
|
+
) -> None:
|
|
270
|
+
"""Show cached findings: each check's newest result, no network call."""
|
|
271
|
+
try:
|
|
272
|
+
with resolve(paper) as path:
|
|
273
|
+
prepared = _prepared(path)
|
|
274
|
+
except PaperError as e:
|
|
275
|
+
raise _fail(str(e))
|
|
276
|
+
entry = make_cache().paper(prepared.content_hash)
|
|
277
|
+
reviews = [r for _, r in entry.reviews() if r.status == "completed"]
|
|
278
|
+
if not reviews:
|
|
279
|
+
raise _fail("No cached review for this paper. Run `medawar review` first.")
|
|
280
|
+
merged, covered = entry.merged_findings()
|
|
281
|
+
latest = reviews[-1]
|
|
282
|
+
if as_json:
|
|
283
|
+
console.print_json(json.dumps(
|
|
284
|
+
{"title": latest.title, "skills": covered,
|
|
285
|
+
"findings": [f.model_dump(mode="json") for f in merged]}
|
|
286
|
+
))
|
|
287
|
+
else:
|
|
288
|
+
_print_findings(latest.title, merged, covered)
|
|
289
|
+
if report is not None:
|
|
290
|
+
_write_report(latest, report, findings=merged, skills=covered)
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def cache(
|
|
294
|
+
action: str = typer.Argument("path", help="'path' or 'clear'."),
|
|
295
|
+
yes: bool = typer.Option(False, "--yes", "-y", help="Clear without asking."),
|
|
296
|
+
) -> None:
|
|
297
|
+
"""Show where the cache lives, or clear it."""
|
|
298
|
+
local = make_cache()
|
|
299
|
+
if action == "path":
|
|
300
|
+
console.print(str(local.root))
|
|
301
|
+
console.print(f"[dim]credentials: {credentials_path()}[/dim]")
|
|
302
|
+
return
|
|
303
|
+
if action != "clear":
|
|
304
|
+
raise _fail(f"Unknown action: {action}. Use 'path' or 'clear'.")
|
|
305
|
+
if not yes and not typer.confirm(f"Delete every cached paper under {local.root}?"):
|
|
306
|
+
raise typer.Exit(1)
|
|
307
|
+
local.clear()
|
|
308
|
+
console.print("[green]Cache cleared.[/green]")
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
# Expert reviews: independent human reviewers, double-blind (medawar-api
|
|
312
|
+
# /v1/expert-reviews). Reviewers read PDF and Word files only.
|
|
313
|
+
_EXPERT_REVIEW_SUFFIXES = (".pdf", ".docx", ".doc")
|
|
314
|
+
_EXPERT_STATUS = {
|
|
315
|
+
"pending_payment": "waiting for payment",
|
|
316
|
+
"submitting": "sending to the reviewers",
|
|
317
|
+
"submitted": "received",
|
|
318
|
+
"analyzing": "reading the manuscript",
|
|
319
|
+
"matching_reviewers": "finding reviewers",
|
|
320
|
+
"inviting": "inviting reviewers",
|
|
321
|
+
"in_review": "in review",
|
|
322
|
+
"reviews_ready": "reviews ready",
|
|
323
|
+
"failed": "failed",
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def _api_error(response: httpx.Response) -> str:
|
|
328
|
+
try:
|
|
329
|
+
detail = response.json().get("detail")
|
|
330
|
+
except ValueError:
|
|
331
|
+
return f"medawar-api answered {response.status_code}"
|
|
332
|
+
if isinstance(detail, dict):
|
|
333
|
+
return detail.get("error") or str(detail)
|
|
334
|
+
return str(detail or f"medawar-api answered {response.status_code}")
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def _api(client: httpx.Client, method: str, url: str, **kwargs) -> dict:
|
|
338
|
+
try:
|
|
339
|
+
response = client.request(method, url, **kwargs)
|
|
340
|
+
except httpx.HTTPError as e:
|
|
341
|
+
raise _fail(f"Could not reach medawar-api: {e}")
|
|
342
|
+
if response.status_code >= 400:
|
|
343
|
+
raise _fail(_api_error(response))
|
|
344
|
+
return response.json()
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def _request_expert_review(client: httpx.Client, paper: Path, yes: bool) -> None:
|
|
348
|
+
if not paper.is_file():
|
|
349
|
+
raise _fail(f"Not a file: {paper}")
|
|
350
|
+
if paper.suffix.lower() not in _EXPERT_REVIEW_SUFFIXES:
|
|
351
|
+
raise _fail("Expert reviewers read PDF and Word files: export your manuscript to PDF.")
|
|
352
|
+
offer = _api(client, "GET", "/v1/expert-reviews/offer")
|
|
353
|
+
if offer["mode"] in ("upgrade", "unavailable"):
|
|
354
|
+
raise _fail(offer.get("reason") or "Expert reviews are not available.")
|
|
355
|
+
console.print("\n[bold]Expert review[/bold]: independent human reviewers read your manuscript.\n")
|
|
356
|
+
for point in offer["consent"]["points"]:
|
|
357
|
+
console.print(f" - {point}")
|
|
358
|
+
if offer["mode"] == "included":
|
|
359
|
+
console.print(f"\nIncluded in your plan ({offer['included_left']} left this period).")
|
|
360
|
+
else:
|
|
361
|
+
console.print(f"\nPrice: {offer['price']['display']}, paid by card on a Stripe page.")
|
|
362
|
+
if not yes and not typer.confirm("\nDo you agree and want to go ahead?"):
|
|
363
|
+
raise typer.Exit(1)
|
|
364
|
+
with open(paper, "rb") as fh:
|
|
365
|
+
upload = _api(client, "POST", "/v1/uploads", files={"file": (paper.name, fh)})
|
|
366
|
+
request = _api(client, "POST", "/v1/expert-reviews",
|
|
367
|
+
json={"upload_path": upload["path"], "consent": True})
|
|
368
|
+
if request.get("checkout_url"):
|
|
369
|
+
console.print(f"\nPay here to start it: {request['checkout_url']}")
|
|
370
|
+
webbrowser.open(request["checkout_url"])
|
|
371
|
+
else:
|
|
372
|
+
console.print("\n[green]Requested.[/green] We email you when the reviews are ready.")
|
|
373
|
+
console.print(f"Follow it: medawar expert-review status {request['id']}")
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def _print_expert_review(item: dict) -> None:
|
|
377
|
+
console.print(f"[bold]{item.get('title') or item['filename']}[/bold] "
|
|
378
|
+
f"[dim]{item['id']}[/dim]")
|
|
379
|
+
console.print(f"Status: {_EXPERT_STATUS.get(item['status'], item['status'])}")
|
|
380
|
+
if item.get("error"):
|
|
381
|
+
console.print(f"[yellow]{item['error']}[/yellow]")
|
|
382
|
+
counts = item.get("counts")
|
|
383
|
+
if counts:
|
|
384
|
+
console.print(f"Reviewers: {counts['invited']} invited, {counts['accepted']} accepted, "
|
|
385
|
+
f"{counts['completed']} finished")
|
|
386
|
+
if item.get("summary_markdown"):
|
|
387
|
+
console.print(f"\n[bold]Summary[/bold]\n{item['summary_markdown']}")
|
|
388
|
+
for review in item.get("reviews") or []:
|
|
389
|
+
console.print(f"\n[bold]{review['reviewer']}[/bold]"
|
|
390
|
+
+ (" [dim](late)[/dim]" if review["late"] else ""))
|
|
391
|
+
for comment in review["comments"]:
|
|
392
|
+
if comment["quote"]:
|
|
393
|
+
console.print(f" [dim]> {comment['quote']}[/dim]")
|
|
394
|
+
console.print(f" {comment['comment']}")
|
|
395
|
+
publication = item.get("publication")
|
|
396
|
+
if publication and publication["eligible"] and not publication["published_url"]:
|
|
397
|
+
console.print(f"\nThe reviewers endorse it: you may publish it with its reviews on "
|
|
398
|
+
f"our partner's public journal: {publication['publish_url']}")
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def expert_review(
|
|
402
|
+
target: str = typer.Argument(..., help="A PDF or Word manuscript, or 'status'."),
|
|
403
|
+
review_id: str | None = typer.Argument(None, help="With 'status': one request's id."),
|
|
404
|
+
yes: bool = typer.Option(False, "--yes", "-y", help="Agree without asking."),
|
|
405
|
+
) -> None:
|
|
406
|
+
"""Ask independent human expert reviewers to review a manuscript, or follow requests."""
|
|
407
|
+
with make_api_client() as client:
|
|
408
|
+
if target != "status":
|
|
409
|
+
_request_expert_review(client, Path(target), yes)
|
|
410
|
+
return
|
|
411
|
+
if review_id:
|
|
412
|
+
_print_expert_review(_api(client, "GET", f"/v1/expert-reviews/{review_id}"))
|
|
413
|
+
return
|
|
414
|
+
items = _api(client, "GET", "/v1/expert-reviews")["expert_reviews"]
|
|
415
|
+
if not items:
|
|
416
|
+
console.print("No expert reviews yet.")
|
|
417
|
+
return
|
|
418
|
+
table = Table("Id", "Manuscript", "Status", "Updated")
|
|
419
|
+
for item in items:
|
|
420
|
+
table.add_row(item["id"], item.get("title") or item["filename"],
|
|
421
|
+
_EXPERT_STATUS.get(item["status"], item["status"]),
|
|
422
|
+
(item.get("updated_at") or "")[:10])
|
|
423
|
+
console.print(table)
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def register(app: typer.Typer) -> None:
|
|
427
|
+
for command in (login, skills, extract, review, findings, cache):
|
|
428
|
+
app.command()(command)
|
|
429
|
+
app.command("expert-review")(expert_review)
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""The user's medawar-api token, stored by `medawar login` (device flow, see
|
|
2
|
+
medawar/cli/device_login.py, or `--token`)."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from medawar.config import get_medawar_settings
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def credentials_path() -> Path:
|
|
13
|
+
return get_medawar_settings().config_dir() / "credentials.json"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def load_token() -> str | None:
|
|
17
|
+
"""MEDAWAR_TOKEN wins over the stored token."""
|
|
18
|
+
settings = get_medawar_settings()
|
|
19
|
+
if settings.medawar_token:
|
|
20
|
+
return settings.medawar_token
|
|
21
|
+
path = credentials_path()
|
|
22
|
+
try:
|
|
23
|
+
return json.loads(path.read_text()).get("api_token") or None
|
|
24
|
+
except (OSError, ValueError):
|
|
25
|
+
return None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def save_token(token: str) -> Path:
|
|
29
|
+
path = credentials_path()
|
|
30
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
31
|
+
path.write_text(json.dumps({"api_token": token}, indent=2))
|
|
32
|
+
os.chmod(path, 0o600)
|
|
33
|
+
return path
|