Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,7 @@ Run `opencli list` for the live registry.
| **devto** | `top` `tag` `user` | Public |
| **dictionary** | `search` `synonyms` `examples` | Public |
| **arxiv** | `search` `paper` | Public |
| **paperreview** | `submit` `review` `feedback` | Public |
| **wikipedia** | `search` `summary` `random` `trending` | Public |
| **hackernews** | `top` `new` `best` `ask` `show` `jobs` `search` `user` | Public |
| **jd** | `item` | Browser |
Expand Down
1 change: 1 addition & 0 deletions README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,7 @@ npm install -g @jackwener/opencli@latest
| **devto** | `top` `tag` `user` | 公开 |
| **dictionary** | `search` `synonyms` `examples` | 公开 |
| **arxiv** | `search` `paper` | 公开 |
| **paperreview** | `submit` `review` `feedback` | 公开 |
| **wikipedia** | `search` `summary` `random` `trending` | 公开 |
| **hackernews** | `top` `new` `best` `ask` `show` `jobs` `search` `user` | 公共 API |
| **jd** | `item` | 浏览器 |
Expand Down
1 change: 1 addition & 0 deletions docs/.vitepress/config.mts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ export default defineConfig({
{ text: 'Xiaoyuzhou', link: '/adapters/browser/xiaoyuzhou' },
{ text: 'Yahoo Finance', link: '/adapters/browser/yahoo-finance' },
{ text: 'arXiv', link: '/adapters/browser/arxiv' },
{ text: 'paperreview.ai', link: '/adapters/browser/paperreview' },
{ text: 'Barchart', link: '/adapters/browser/barchart' },
{ text: 'Hugging Face', link: '/adapters/browser/hf' },
{ text: 'Sina Finance', link: '/adapters/browser/sinafinance' },
Expand Down
43 changes: 43 additions & 0 deletions docs/adapters/browser/paperreview.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# paperreview.ai

**Mode**: 🌐 Public · **Domain**: `paperreview.ai`

## Commands

| Command | Description |
|---------|-------------|
| `opencli paperreview submit` | Submit a PDF to paperreview.ai for review |
| `opencli paperreview review` | Fetch a review by token |
| `opencli paperreview feedback` | Send feedback on a completed review |

## Usage Examples

```bash
# Validate a local PDF without uploading it
opencli paperreview submit ./paper.pdf --email you@example.com --venue RAL --dry-run true

# Request an upload slot but stop before the actual upload
opencli paperreview submit ./paper.pdf --email you@example.com --venue RAL --prepare-only true

# Submit a paper for review
opencli paperreview submit ./paper.pdf --email you@example.com --venue RAL -f json

# Check the review status or fetch the final review
opencli paperreview review tok_123 -f json

# Submit feedback on the review quality
opencli paperreview feedback tok_123 --helpfulness 4 --critical-error no --actionable-suggestions yes
```

## Prerequisites

- No browser required — uses public paperreview.ai endpoints
- The input file must be a local `.pdf`
- paperreview.ai currently rejects files larger than `10MB`
- `submit` requires `--email`; `--venue` is optional

## Notes

- `submit` returns both the review token and the review URL when submission succeeds
- `review` returns `processing` until the paperreview.ai result is ready
- `feedback` expects `yes` / `no` values for `--critical-error` and `--actionable-suggestions`
1 change: 1 addition & 0 deletions docs/adapters/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ Run `opencli list` for the live registry.
| **[xiaoyuzhou](/adapters/browser/xiaoyuzhou)** | `podcast` `podcast-episodes` `episode` | 🌐 Public |
| **[yahoo-finance](/adapters/browser/yahoo-finance)** | `quote` | 🌐 Public |
| **[arxiv](/adapters/browser/arxiv)** | `search` `paper` | 🌐 Public |
| **[paperreview](/adapters/browser/paperreview)** | `submit` `review` `feedback` | 🌐 Public |
| **[barchart](/adapters/browser/barchart)** | `quote` `options` `greeks` `flow` | 🌐 Public |
| **[hf](/adapters/browser/hf)** | `top` | 🌐 Public |
| **[sinafinance](/adapters/browser/sinafinance)** | `news` | 🌐 Public |
Expand Down
283 changes: 283 additions & 0 deletions src/clis/paperreview/commands.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,283 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';

const {
mockReadPdfFile,
mockRequestJson,
mockUploadPresignedPdf,
mockValidateHelpfulness,
mockParseYesNo,
} = vi.hoisted(() => ({
mockReadPdfFile: vi.fn(),
mockRequestJson: vi.fn(),
mockUploadPresignedPdf: vi.fn(),
mockValidateHelpfulness: vi.fn(),
mockParseYesNo: vi.fn(),
}));

vi.mock('./utils.js', async () => {
const actual = await vi.importActual<typeof import('./utils.js')>('./utils.js');
return {
...actual,
readPdfFile: mockReadPdfFile,
requestJson: mockRequestJson,
uploadPresignedPdf: mockUploadPresignedPdf,
validateHelpfulness: mockValidateHelpfulness,
parseYesNo: mockParseYesNo,
};
});

import { getRegistry } from '../../registry.js';
import './submit.js';
import './review.js';
import './feedback.js';

describe('paperreview submit command', () => {
beforeEach(() => {
mockReadPdfFile.mockReset();
mockRequestJson.mockReset();
mockUploadPresignedPdf.mockReset();
mockValidateHelpfulness.mockReset();
mockParseYesNo.mockReset();
});

it('supports dry run without any remote request', async () => {
const cmd = getRegistry().get('paperreview/submit');
expect(cmd?.func).toBeTypeOf('function');

mockReadPdfFile.mockResolvedValue({
buffer: Buffer.from('%PDF'),
fileName: 'paper.pdf',
resolvedPath: '/tmp/paper.pdf',
sizeBytes: 4096,
});

const result = await cmd!.func!(null as any, {
pdf: './paper.pdf',
email: 'wang2629651228@gmail.com',
venue: 'RAL',
'dry-run': true,
'prepare-only': false,
});

expect(mockRequestJson).not.toHaveBeenCalled();
expect(result).toMatchObject({
status: 'dry-run',
file: 'paper.pdf',
email: 'wang2629651228@gmail.com',
venue: 'RAL',
});
});

it('treats explicit false flags as false and performs the real submission path', async () => {
const cmd = getRegistry().get('paperreview/submit');
expect(cmd?.func).toBeTypeOf('function');

mockReadPdfFile.mockResolvedValue({
buffer: Buffer.from('%PDF'),
fileName: 'paper.pdf',
resolvedPath: '/tmp/paper.pdf',
sizeBytes: 4096,
});
mockRequestJson
.mockResolvedValueOnce({
response: { ok: true, status: 200 } as Response,
payload: {
success: true,
presigned_url: 'https://upload.example.com',
presigned_fields: { key: 'uploads/paper.pdf' },
s3_key: 'uploads/paper.pdf',
},
})
.mockResolvedValueOnce({
response: { ok: true, status: 200 } as Response,
payload: {
success: true,
token: 'tok_false',
message: 'Submission accepted',
},
});

const result = await cmd!.func!(null as any, {
pdf: './paper.pdf',
email: 'wang2629651228@gmail.com',
venue: 'RAL',
'dry-run': false,
'prepare-only': false,
});

expect(mockUploadPresignedPdf).toHaveBeenCalledTimes(1);
expect(result).toMatchObject({
status: 'submitted',
token: 'tok_false',
review_url: 'https://paperreview.ai/review?token=tok_false',
});
});

it('supports prepare-only without uploading the PDF', async () => {
const cmd = getRegistry().get('paperreview/submit');
expect(cmd?.func).toBeTypeOf('function');

mockReadPdfFile.mockResolvedValue({
buffer: Buffer.from('%PDF'),
fileName: 'paper.pdf',
resolvedPath: '/tmp/paper.pdf',
sizeBytes: 4096,
});
mockRequestJson.mockResolvedValueOnce({
response: { ok: true, status: 200 } as Response,
payload: {
success: true,
presigned_url: 'https://upload.example.com',
presigned_fields: { key: 'uploads/paper.pdf' },
s3_key: 'uploads/paper.pdf',
},
});

const result = await cmd!.func!(null as any, {
pdf: './paper.pdf',
email: 'wang2629651228@gmail.com',
venue: 'RAL',
'dry-run': false,
'prepare-only': true,
});

expect(mockUploadPresignedPdf).not.toHaveBeenCalled();
expect(mockRequestJson).toHaveBeenCalledTimes(1);
expect(result).toMatchObject({
status: 'prepared',
s3_key: 'uploads/paper.pdf',
});
});

it('requests an upload URL, uploads the PDF, and confirms the submission', async () => {
const cmd = getRegistry().get('paperreview/submit');
expect(cmd?.func).toBeTypeOf('function');

mockReadPdfFile.mockResolvedValue({
buffer: Buffer.from('%PDF'),
fileName: 'paper.pdf',
resolvedPath: '/tmp/paper.pdf',
sizeBytes: 4096,
});
mockRequestJson
.mockResolvedValueOnce({
response: { ok: true, status: 200 } as Response,
payload: {
success: true,
presigned_url: 'https://upload.example.com',
presigned_fields: { key: 'uploads/paper.pdf' },
s3_key: 'uploads/paper.pdf',
},
})
.mockResolvedValueOnce({
response: { ok: true, status: 200 } as Response,
payload: {
success: true,
token: 'tok_123',
message: 'Submission accepted',
},
});

const result = await cmd!.func!(null as any, {
pdf: './paper.pdf',
email: 'wang2629651228@gmail.com',
venue: 'RAL',
'dry-run': false,
'prepare-only': false,
});

expect(mockRequestJson).toHaveBeenNthCalledWith(1, '/api/get-upload-url', expect.objectContaining({
method: 'POST',
body: JSON.stringify({
filename: 'paper.pdf',
venue: 'RAL',
}),
}));
expect(mockUploadPresignedPdf).toHaveBeenCalledWith(
'https://upload.example.com',
expect.objectContaining({ fileName: 'paper.pdf' }),
expect.objectContaining({ s3_key: 'uploads/paper.pdf' }),
);
expect(mockRequestJson).toHaveBeenNthCalledWith(2, '/api/confirm-upload', expect.objectContaining({
method: 'POST',
body: expect.any(FormData),
}));
expect(result).toMatchObject({
status: 'submitted',
token: 'tok_123',
review_url: 'https://paperreview.ai/review?token=tok_123',
});
});
});

describe('paperreview review command', () => {
beforeEach(() => {
mockRequestJson.mockReset();
});

it('returns processing status when the review is not ready yet', async () => {
const cmd = getRegistry().get('paperreview/review');
expect(cmd?.func).toBeTypeOf('function');

mockRequestJson.mockResolvedValue({
response: { status: 202 } as Response,
payload: { detail: 'Review is still processing.' },
});

const result = await cmd!.func!(null as any, { token: 'tok_123' });

expect(result).toMatchObject({
status: 'processing',
token: 'tok_123',
review_url: 'https://paperreview.ai/review?token=tok_123',
message: 'Review is still processing.',
});
});
});

describe('paperreview feedback command', () => {
beforeEach(() => {
mockRequestJson.mockReset();
mockValidateHelpfulness.mockReset();
mockParseYesNo.mockReset();
});

it('normalizes feedback inputs and posts them to the API', async () => {
const cmd = getRegistry().get('paperreview/feedback');
expect(cmd?.func).toBeTypeOf('function');

mockValidateHelpfulness.mockReturnValue(4);
mockParseYesNo.mockReturnValueOnce(true).mockReturnValueOnce(false);
mockRequestJson.mockResolvedValue({
response: { ok: true, status: 200 } as Response,
payload: { message: 'Thanks for the feedback.' },
});

const result = await cmd!.func!(null as any, {
token: 'tok_123',
helpfulness: 4,
'critical-error': 'yes',
'actionable-suggestions': 'no',
'additional-comments': 'Helpful summary, but the contribution section needs more detail.',
});

expect(mockRequestJson).toHaveBeenCalledWith('/api/feedback/tok_123', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
helpfulness: 4,
has_critical_error: true,
has_actionable_suggestions: false,
additional_comments: 'Helpful summary, but the contribution section needs more detail.',
}),
});
expect(result).toMatchObject({
status: 'submitted',
token: 'tok_123',
helpfulness: 4,
critical_error: true,
actionable_suggestions: false,
message: 'Thanks for the feedback.',
});
});
});
Loading