Skip to content

Commit 310e136

Browse files
Lr-2002jackwener
andauthored
feat(paperreview): add paperreview.ai adapter (#464)
* feat(paperreview): add paperreview.ai adapter * fix(cli): normalize boolean command options --------- Co-authored-by: jackwener <jakevingoo@gmail.com>
1 parent 776674c commit 310e136

15 files changed

Lines changed: 1054 additions & 13 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,7 @@ Run `opencli list` for the live registry.
166166
| **devto** | `top` `tag` `user` | Public |
167167
| **dictionary** | `search` `synonyms` `examples` | Public |
168168
| **arxiv** | `search` `paper` | Public |
169+
| **paperreview** | `submit` `review` `feedback` | Public |
169170
| **wikipedia** | `search` `summary` `random` `trending` | Public |
170171
| **hackernews** | `top` `new` `best` `ask` `show` `jobs` `search` `user` | Public |
171172
| **jd** | `item` | Browser |

README.zh-CN.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,7 @@ npm install -g @jackwener/opencli@latest
148148
| **devto** | `top` `tag` `user` | 公开 |
149149
| **dictionary** | `search` `synonyms` `examples` | 公开 |
150150
| **arxiv** | `search` `paper` | 公开 |
151+
| **paperreview** | `submit` `review` `feedback` | 公开 |
151152
| **wikipedia** | `search` `summary` `random` `trending` | 公开 |
152153
| **hackernews** | `top` `new` `best` `ask` `show` `jobs` `search` `user` | 公共 API |
153154
| **jd** | `item` | 浏览器 |

docs/.vitepress/config.mts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,7 @@ export default defineConfig({
100100
{ text: 'Xiaoyuzhou', link: '/adapters/browser/xiaoyuzhou' },
101101
{ text: 'Yahoo Finance', link: '/adapters/browser/yahoo-finance' },
102102
{ text: 'arXiv', link: '/adapters/browser/arxiv' },
103+
{ text: 'paperreview.ai', link: '/adapters/browser/paperreview' },
103104
{ text: 'Barchart', link: '/adapters/browser/barchart' },
104105
{ text: 'Hugging Face', link: '/adapters/browser/hf' },
105106
{ text: 'Sina Finance', link: '/adapters/browser/sinafinance' },
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
# paperreview.ai
2+
3+
**Mode**: 🌐 Public · **Domain**: `paperreview.ai`
4+
5+
## Commands
6+
7+
| Command | Description |
8+
|---------|-------------|
9+
| `opencli paperreview submit` | Submit a PDF to paperreview.ai for review |
10+
| `opencli paperreview review` | Fetch a review by token |
11+
| `opencli paperreview feedback` | Send feedback on a completed review |
12+
13+
## Usage Examples
14+
15+
```bash
16+
# Validate a local PDF without uploading it
17+
opencli paperreview submit ./paper.pdf --email you@example.com --venue RAL --dry-run true
18+
19+
# Request an upload slot but stop before the actual upload
20+
opencli paperreview submit ./paper.pdf --email you@example.com --venue RAL --prepare-only true
21+
22+
# Submit a paper for review
23+
opencli paperreview submit ./paper.pdf --email you@example.com --venue RAL -f json
24+
25+
# Check the review status or fetch the final review
26+
opencli paperreview review tok_123 -f json
27+
28+
# Submit feedback on the review quality
29+
opencli paperreview feedback tok_123 --helpfulness 4 --critical-error no --actionable-suggestions yes
30+
```
31+
32+
## Prerequisites
33+
34+
- No browser required — uses public paperreview.ai endpoints
35+
- The input file must be a local `.pdf`
36+
- paperreview.ai currently rejects files larger than `10MB`
37+
- `submit` requires `--email`; `--venue` is optional
38+
39+
## Notes
40+
41+
- `submit` returns both the review token and the review URL when submission succeeds
42+
- `review` returns `processing` until the paperreview.ai result is ready
43+
- `feedback` expects `yes` / `no` values for `--critical-error` and `--actionable-suggestions`

docs/adapters/index.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ Run `opencli list` for the live registry.
5858
| **[xiaoyuzhou](/adapters/browser/xiaoyuzhou)** | `podcast` `podcast-episodes` `episode` | 🌐 Public |
5959
| **[yahoo-finance](/adapters/browser/yahoo-finance)** | `quote` | 🌐 Public |
6060
| **[arxiv](/adapters/browser/arxiv)** | `search` `paper` | 🌐 Public |
61+
| **[paperreview](/adapters/browser/paperreview)** | `submit` `review` `feedback` | 🌐 Public |
6162
| **[barchart](/adapters/browser/barchart)** | `quote` `options` `greeks` `flow` | 🌐 Public |
6263
| **[hf](/adapters/browser/hf)** | `top` | 🌐 Public |
6364
| **[sinafinance](/adapters/browser/sinafinance)** | `news` | 🌐 Public |
Lines changed: 283 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,283 @@
1+
import { beforeEach, describe, expect, it, vi } from 'vitest';
2+
3+
const {
4+
mockReadPdfFile,
5+
mockRequestJson,
6+
mockUploadPresignedPdf,
7+
mockValidateHelpfulness,
8+
mockParseYesNo,
9+
} = vi.hoisted(() => ({
10+
mockReadPdfFile: vi.fn(),
11+
mockRequestJson: vi.fn(),
12+
mockUploadPresignedPdf: vi.fn(),
13+
mockValidateHelpfulness: vi.fn(),
14+
mockParseYesNo: vi.fn(),
15+
}));
16+
17+
vi.mock('./utils.js', async () => {
18+
const actual = await vi.importActual<typeof import('./utils.js')>('./utils.js');
19+
return {
20+
...actual,
21+
readPdfFile: mockReadPdfFile,
22+
requestJson: mockRequestJson,
23+
uploadPresignedPdf: mockUploadPresignedPdf,
24+
validateHelpfulness: mockValidateHelpfulness,
25+
parseYesNo: mockParseYesNo,
26+
};
27+
});
28+
29+
import { getRegistry } from '../../registry.js';
30+
import './submit.js';
31+
import './review.js';
32+
import './feedback.js';
33+
34+
describe('paperreview submit command', () => {
35+
beforeEach(() => {
36+
mockReadPdfFile.mockReset();
37+
mockRequestJson.mockReset();
38+
mockUploadPresignedPdf.mockReset();
39+
mockValidateHelpfulness.mockReset();
40+
mockParseYesNo.mockReset();
41+
});
42+
43+
it('supports dry run without any remote request', async () => {
44+
const cmd = getRegistry().get('paperreview/submit');
45+
expect(cmd?.func).toBeTypeOf('function');
46+
47+
mockReadPdfFile.mockResolvedValue({
48+
buffer: Buffer.from('%PDF'),
49+
fileName: 'paper.pdf',
50+
resolvedPath: '/tmp/paper.pdf',
51+
sizeBytes: 4096,
52+
});
53+
54+
const result = await cmd!.func!(null as any, {
55+
pdf: './paper.pdf',
56+
email: 'wang2629651228@gmail.com',
57+
venue: 'RAL',
58+
'dry-run': true,
59+
'prepare-only': false,
60+
});
61+
62+
expect(mockRequestJson).not.toHaveBeenCalled();
63+
expect(result).toMatchObject({
64+
status: 'dry-run',
65+
file: 'paper.pdf',
66+
email: 'wang2629651228@gmail.com',
67+
venue: 'RAL',
68+
});
69+
});
70+
71+
it('treats explicit false flags as false and performs the real submission path', async () => {
72+
const cmd = getRegistry().get('paperreview/submit');
73+
expect(cmd?.func).toBeTypeOf('function');
74+
75+
mockReadPdfFile.mockResolvedValue({
76+
buffer: Buffer.from('%PDF'),
77+
fileName: 'paper.pdf',
78+
resolvedPath: '/tmp/paper.pdf',
79+
sizeBytes: 4096,
80+
});
81+
mockRequestJson
82+
.mockResolvedValueOnce({
83+
response: { ok: true, status: 200 } as Response,
84+
payload: {
85+
success: true,
86+
presigned_url: 'https://upload.example.com',
87+
presigned_fields: { key: 'uploads/paper.pdf' },
88+
s3_key: 'uploads/paper.pdf',
89+
},
90+
})
91+
.mockResolvedValueOnce({
92+
response: { ok: true, status: 200 } as Response,
93+
payload: {
94+
success: true,
95+
token: 'tok_false',
96+
message: 'Submission accepted',
97+
},
98+
});
99+
100+
const result = await cmd!.func!(null as any, {
101+
pdf: './paper.pdf',
102+
email: 'wang2629651228@gmail.com',
103+
venue: 'RAL',
104+
'dry-run': false,
105+
'prepare-only': false,
106+
});
107+
108+
expect(mockUploadPresignedPdf).toHaveBeenCalledTimes(1);
109+
expect(result).toMatchObject({
110+
status: 'submitted',
111+
token: 'tok_false',
112+
review_url: 'https://paperreview.ai/review?token=tok_false',
113+
});
114+
});
115+
116+
it('supports prepare-only without uploading the PDF', async () => {
117+
const cmd = getRegistry().get('paperreview/submit');
118+
expect(cmd?.func).toBeTypeOf('function');
119+
120+
mockReadPdfFile.mockResolvedValue({
121+
buffer: Buffer.from('%PDF'),
122+
fileName: 'paper.pdf',
123+
resolvedPath: '/tmp/paper.pdf',
124+
sizeBytes: 4096,
125+
});
126+
mockRequestJson.mockResolvedValueOnce({
127+
response: { ok: true, status: 200 } as Response,
128+
payload: {
129+
success: true,
130+
presigned_url: 'https://upload.example.com',
131+
presigned_fields: { key: 'uploads/paper.pdf' },
132+
s3_key: 'uploads/paper.pdf',
133+
},
134+
});
135+
136+
const result = await cmd!.func!(null as any, {
137+
pdf: './paper.pdf',
138+
email: 'wang2629651228@gmail.com',
139+
venue: 'RAL',
140+
'dry-run': false,
141+
'prepare-only': true,
142+
});
143+
144+
expect(mockUploadPresignedPdf).not.toHaveBeenCalled();
145+
expect(mockRequestJson).toHaveBeenCalledTimes(1);
146+
expect(result).toMatchObject({
147+
status: 'prepared',
148+
s3_key: 'uploads/paper.pdf',
149+
});
150+
});
151+
152+
it('requests an upload URL, uploads the PDF, and confirms the submission', async () => {
153+
const cmd = getRegistry().get('paperreview/submit');
154+
expect(cmd?.func).toBeTypeOf('function');
155+
156+
mockReadPdfFile.mockResolvedValue({
157+
buffer: Buffer.from('%PDF'),
158+
fileName: 'paper.pdf',
159+
resolvedPath: '/tmp/paper.pdf',
160+
sizeBytes: 4096,
161+
});
162+
mockRequestJson
163+
.mockResolvedValueOnce({
164+
response: { ok: true, status: 200 } as Response,
165+
payload: {
166+
success: true,
167+
presigned_url: 'https://upload.example.com',
168+
presigned_fields: { key: 'uploads/paper.pdf' },
169+
s3_key: 'uploads/paper.pdf',
170+
},
171+
})
172+
.mockResolvedValueOnce({
173+
response: { ok: true, status: 200 } as Response,
174+
payload: {
175+
success: true,
176+
token: 'tok_123',
177+
message: 'Submission accepted',
178+
},
179+
});
180+
181+
const result = await cmd!.func!(null as any, {
182+
pdf: './paper.pdf',
183+
email: 'wang2629651228@gmail.com',
184+
venue: 'RAL',
185+
'dry-run': false,
186+
'prepare-only': false,
187+
});
188+
189+
expect(mockRequestJson).toHaveBeenNthCalledWith(1, '/api/get-upload-url', expect.objectContaining({
190+
method: 'POST',
191+
body: JSON.stringify({
192+
filename: 'paper.pdf',
193+
venue: 'RAL',
194+
}),
195+
}));
196+
expect(mockUploadPresignedPdf).toHaveBeenCalledWith(
197+
'https://upload.example.com',
198+
expect.objectContaining({ fileName: 'paper.pdf' }),
199+
expect.objectContaining({ s3_key: 'uploads/paper.pdf' }),
200+
);
201+
expect(mockRequestJson).toHaveBeenNthCalledWith(2, '/api/confirm-upload', expect.objectContaining({
202+
method: 'POST',
203+
body: expect.any(FormData),
204+
}));
205+
expect(result).toMatchObject({
206+
status: 'submitted',
207+
token: 'tok_123',
208+
review_url: 'https://paperreview.ai/review?token=tok_123',
209+
});
210+
});
211+
});
212+
213+
describe('paperreview review command', () => {
214+
beforeEach(() => {
215+
mockRequestJson.mockReset();
216+
});
217+
218+
it('returns processing status when the review is not ready yet', async () => {
219+
const cmd = getRegistry().get('paperreview/review');
220+
expect(cmd?.func).toBeTypeOf('function');
221+
222+
mockRequestJson.mockResolvedValue({
223+
response: { status: 202 } as Response,
224+
payload: { detail: 'Review is still processing.' },
225+
});
226+
227+
const result = await cmd!.func!(null as any, { token: 'tok_123' });
228+
229+
expect(result).toMatchObject({
230+
status: 'processing',
231+
token: 'tok_123',
232+
review_url: 'https://paperreview.ai/review?token=tok_123',
233+
message: 'Review is still processing.',
234+
});
235+
});
236+
});
237+
238+
describe('paperreview feedback command', () => {
239+
beforeEach(() => {
240+
mockRequestJson.mockReset();
241+
mockValidateHelpfulness.mockReset();
242+
mockParseYesNo.mockReset();
243+
});
244+
245+
it('normalizes feedback inputs and posts them to the API', async () => {
246+
const cmd = getRegistry().get('paperreview/feedback');
247+
expect(cmd?.func).toBeTypeOf('function');
248+
249+
mockValidateHelpfulness.mockReturnValue(4);
250+
mockParseYesNo.mockReturnValueOnce(true).mockReturnValueOnce(false);
251+
mockRequestJson.mockResolvedValue({
252+
response: { ok: true, status: 200 } as Response,
253+
payload: { message: 'Thanks for the feedback.' },
254+
});
255+
256+
const result = await cmd!.func!(null as any, {
257+
token: 'tok_123',
258+
helpfulness: 4,
259+
'critical-error': 'yes',
260+
'actionable-suggestions': 'no',
261+
'additional-comments': 'Helpful summary, but the contribution section needs more detail.',
262+
});
263+
264+
expect(mockRequestJson).toHaveBeenCalledWith('/api/feedback/tok_123', {
265+
method: 'POST',
266+
headers: { 'Content-Type': 'application/json' },
267+
body: JSON.stringify({
268+
helpfulness: 4,
269+
has_critical_error: true,
270+
has_actionable_suggestions: false,
271+
additional_comments: 'Helpful summary, but the contribution section needs more detail.',
272+
}),
273+
});
274+
expect(result).toMatchObject({
275+
status: 'submitted',
276+
token: 'tok_123',
277+
helpfulness: 4,
278+
critical_error: true,
279+
actionable_suggestions: false,
280+
message: 'Thanks for the feedback.',
281+
});
282+
});
283+
});

0 commit comments

Comments
 (0)