/ clis / paperreview / utils.js
utils.js
  1  import fs from 'node:fs/promises';
  2  import path from 'node:path';
  3  import { CliError, getErrorMessage } from '@jackwener/opencli/errors';
  4  export const PAPERREVIEW_DOMAIN = 'paperreview.ai';
  5  export const PAPERREVIEW_BASE_URL = `https://${PAPERREVIEW_DOMAIN}`;
  6  export const MAX_PDF_BYTES = 10 * 1024 * 1024;
  7  function asText(value) {
  8      return value == null ? '' : String(value);
  9  }
 10  function trimOrEmpty(value) {
 11      return asText(value).trim();
 12  }
 13  function toErrorMessage(payload, fallback) {
 14      if (payload && typeof payload === 'object') {
 15          const detail = trimOrEmpty(payload.detail);
 16          const message = trimOrEmpty(payload.message);
 17          const error = trimOrEmpty(payload.error);
 18          if (detail)
 19              return detail;
 20          if (message)
 21              return message;
 22          if (error)
 23              return error;
 24      }
 25      const text = trimOrEmpty(payload);
 26      return text || fallback;
 27  }
 28  export function buildReviewUrl(token) {
 29      return `${PAPERREVIEW_BASE_URL}/review?token=${encodeURIComponent(token)}`;
 30  }
 31  export function parseYesNo(value, name) {
 32      const normalized = trimOrEmpty(value).toLowerCase();
 33      if (normalized === 'yes')
 34          return true;
 35      if (normalized === 'no')
 36          return false;
 37      throw new CliError('ARGUMENT', `"${name}" must be either "yes" or "no".`);
 38  }
 39  export function normalizeVenue(value) {
 40      return trimOrEmpty(value);
 41  }
 42  export function validateHelpfulness(value) {
 43      const numeric = Number(value);
 44      if (!Number.isInteger(numeric) || numeric < 1 || numeric > 5) {
 45          throw new CliError('ARGUMENT', '"helpfulness" must be an integer from 1 to 5.');
 46      }
 47      return numeric;
 48  }
 49  export async function readPdfFile(inputPath) {
 50      const rawPath = trimOrEmpty(inputPath);
 51      if (!rawPath) {
 52          throw new CliError('ARGUMENT', 'A PDF path is required.', 'Provide a local PDF file path');
 53      }
 54      const resolvedPath = path.resolve(rawPath);
 55      const fileName = path.basename(resolvedPath);
 56      if (!fileName.toLowerCase().endsWith('.pdf')) {
 57          throw new CliError('ARGUMENT', 'The input file must end with .pdf.', 'Provide a PDF file path');
 58      }
 59      let fileStat;
 60      try {
 61          fileStat = await fs.stat(resolvedPath);
 62      }
 63      catch (error) {
 64          if (error?.code === 'ENOENT') {
 65              throw new CliError('FILE_NOT_FOUND', `File not found: ${resolvedPath}`, 'Provide a valid PDF file path');
 66          }
 67          throw new CliError('FILE_READ_ERROR', `Unable to inspect file: ${resolvedPath}`, 'Check file permissions and try again');
 68      }
 69      if (!fileStat.isFile()) {
 70          throw new CliError('FILE_NOT_FOUND', `Not a file: ${resolvedPath}`, 'Provide a valid PDF file path');
 71      }
 72      if (fileStat.size < 100) {
 73          throw new CliError('ARGUMENT', 'The PDF is too small. paperreview.ai requires at least 100 bytes.', 'Provide the final paper PDF');
 74      }
 75      if (fileStat.size > MAX_PDF_BYTES) {
 76          throw new CliError('FILE_TOO_LARGE', 'The PDF is larger than paperreview.ai\'s 10MB limit.', 'Compress the PDF or submit a smaller file');
 77      }
 78      let buffer;
 79      try {
 80          buffer = await fs.readFile(resolvedPath);
 81      }
 82      catch {
 83          throw new CliError('FILE_READ_ERROR', `Unable to read file: ${resolvedPath}`, 'Check file permissions and try again');
 84      }
 85      return {
 86          buffer,
 87          fileName,
 88          resolvedPath,
 89          sizeBytes: buffer.byteLength,
 90      };
 91  }
 92  export async function requestJson(pathname, init = {}) {
 93      let response;
 94      try {
 95          response = await fetch(`${PAPERREVIEW_BASE_URL}${pathname}`, init);
 96      }
 97      catch (error) {
 98          throw new CliError('FETCH_ERROR', `Unable to reach paperreview.ai: ${getErrorMessage(error)}`, 'Check your network connection and try again');
 99      }
100      const rawText = await response.text();
101      let payload = rawText;
102      if (rawText) {
103          try {
104              payload = JSON.parse(rawText);
105          }
106          catch {
107              payload = rawText;
108          }
109      }
110      return { response, payload };
111  }
112  export function ensureSuccess(response, payload, fallback, hint) {
113      if (!response.ok) {
114          const code = response.status === 404 ? 'NOT_FOUND' : 'API_ERROR';
115          throw new CliError(code, toErrorMessage(payload, fallback), hint);
116      }
117  }
118  export function ensureApiSuccess(payload, fallback, hint) {
119      if (!payload || typeof payload !== 'object' || payload.success !== true) {
120          throw new CliError('API_ERROR', toErrorMessage(payload, fallback), hint);
121      }
122  }
123  export function createUploadForm(urlData, pdfFile) {
124      const form = new FormData();
125      for (const [key, value] of Object.entries(urlData.presigned_fields ?? {})) {
126          form.append(key, value);
127      }
128      form.append('file', new Blob([new Uint8Array(pdfFile.buffer)], { type: 'application/pdf' }), pdfFile.fileName);
129      return form;
130  }
131  export async function uploadPresignedPdf(presignedUrl, pdfFile, urlData) {
132      let response;
133      try {
134          response = await fetch(presignedUrl, {
135              method: 'POST',
136              body: createUploadForm(urlData, pdfFile),
137          });
138      }
139      catch (error) {
140          throw new CliError('UPLOAD_ERROR', `S3 upload failed: ${getErrorMessage(error)}`, 'Try again in a moment');
141      }
142      if (!response.ok) {
143          const body = await response.text();
144          throw new CliError('UPLOAD_ERROR', body || `S3 upload failed with status ${response.status}.`, 'Try again in a moment');
145      }
146  }
147  export function summarizeSubmission(options) {
148      const { pdfFile, email, venue, token, message, s3Key, dryRun = false, status } = options;
149      return {
150          status: status ?? (dryRun ? 'dry-run' : 'submitted'),
151          file: pdfFile.fileName,
152          file_path: pdfFile.resolvedPath,
153          size_bytes: pdfFile.sizeBytes,
154          email,
155          venue,
156          token: token ?? '',
157          review_url: token ? buildReviewUrl(token) : '',
158          message: message ?? '',
159          s3_key: s3Key ?? '',
160      };
161  }
162  export function summarizeReview(token, payload, status = 'ready') {
163      const sections = payload?.sections ?? {};
164      const availableSections = Object.keys(sections);
165      return {
166          status,
167          token,
168          review_url: buildReviewUrl(token),
169          title: trimOrEmpty(payload?.title),
170          venue: trimOrEmpty(payload?.venue),
171          submission_date: trimOrEmpty(payload?.submission_date),
172          numerical_score: payload?.numerical_score ?? '',
173          has_feedback: payload?.has_feedback ?? '',
174          available_sections: availableSections.join(', '),
175          section_count: availableSections.length,
176          summary: trimOrEmpty(sections.summary),
177          strengths: trimOrEmpty(sections.strengths),
178          weaknesses: trimOrEmpty(sections.weaknesses),
179          detailed_comments: trimOrEmpty(sections.detailed_comments),
180          questions: trimOrEmpty(sections.questions),
181          assessment: trimOrEmpty(sections.assessment),
182          content: trimOrEmpty(payload?.content),
183          sections,
184      };
185  }
186  export function summarizeFeedback(options) {
187      const { token, helpfulness, criticalError, actionableSuggestions, comments, payload } = options;
188      return {
189          status: 'submitted',
190          token,
191          helpfulness,
192          critical_error: criticalError,
193          actionable_suggestions: actionableSuggestions,
194          additional_comments: comments,
195          message: trimOrEmpty(payload?.message) || 'Feedback submitted.',
196      };
197  }