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.
@@ -0,0 +1,11 @@
1
+ .venv
2
+ **/__pycache__/**
3
+ *.pyc
4
+ .pytest_cache/
5
+ .env
6
+
7
+ # agent-core copy for the Docker build (scripts/fetch-agent-core.sh)
8
+ .agent-core/
9
+
10
+ # Local credentials (service-account keys): never committed
11
+ secrets/
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