import { createServer } from "node:http"; import { readFile } from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const PORT = Number(process.env.PORT || 3000); const MODEL = process.env.GEMINI_MODEL || "gemini-2.5-flash"; const API_URL = `https://generativelanguage.googleapis.com/v1beta/models/${MODEL}:generateContent`; const MAX_BODY_SIZE = 6 * 1024 * 1024; const DEFAULT_ALLOWED_ORIGINS = [ "http://127.0.0.1:3000", "http://localhost:3000", ]; const PROMPT_TEMPLATE = `You are a senior researcher reviewing a paper for a colleague who needs to decide in 3 minutes whether this work is credible and worth citing. Do not summarize. Do not be polite. Be useful. Return a JSON object with exactly these five string fields: - claim - keyNumber - assumption - gap - citeCheck Write each field using these rules: - claim: the single falsifiable claim this paper makes, one sentence, no hedging - keyNumber: the one statistic or result the entire argument depends on; explain why it is load-bearing - assumption: the hidden premise the authors treated as given but never tested or justified; be specific - gap: the missing experiment, analysis, or robustness check that would most meaningfully confirm or break the conclusion; if one issue dominates, give one compact paragraph, but if there are multiple distinct load-bearing gaps, use a short list with up to 3 items separated by line breaks inside the same string - citeCheck: start with exactly one of "SAFE TO CITE -", "CITE WITH CAUTION -", or "DO NOT CITE -" and then give the concrete reason Every field must be non-empty. Do not include markdown fences. Do not include any keys other than the five above. Paper text: `; const REVIEW_SCHEMA = { type: "object", properties: { claim: { type: "string", description: "The single falsifiable claim the paper makes.", }, keyNumber: { type: "string", description: "The one load-bearing statistic or result and why it matters.", }, assumption: { type: "string", description: "The hidden premise the authors never tested or justified.", }, gap: { type: "string", description: "The main missing experiment, analysis, or a short list of up to 3 load-bearing gaps separated by line breaks when more than one is truly necessary.", }, citeCheck: { type: "string", description: "A verdict starting with SAFE TO CITE -, CITE WITH CAUTION -, or DO NOT CITE - followed by the reason.", }, }, required: ["claim", "keyNumber", "assumption", "gap", "citeCheck"], }; function sendJson(response, statusCode, payload) { response.writeHead(statusCode, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store", }); response.end(JSON.stringify(payload)); } function sendText(response, statusCode, text) { response.writeHead(statusCode, { "content-type": "text/plain; charset=utf-8", "cache-control": "no-store", }); response.end(text); } function normalizeField(value) { return typeof value === "string" ? value.trim() : ""; } function formatReview(reviewObject) { const claim = normalizeField(reviewObject.claim); const keyNumber = normalizeField(reviewObject.keyNumber); const assumption = normalizeField(reviewObject.assumption); const gap = normalizeField(reviewObject.gap); const citeCheck = normalizeField(reviewObject.citeCheck); if (!claim || !keyNumber || !assumption || !gap || !citeCheck) { return ""; } return [ `CLAIM: ${claim}`, `KEY NUMBER: ${keyNumber}`, `ASSUMPTION: ${assumption}`, `GAP: ${gap}`, `CITE CHECK: ${citeCheck}`, ].join("\n\n"); } function getAllowedOrigins() { const configuredOrigins = (process.env.ALLOWED_ORIGINS || "") .split(",") .map((origin) => origin.trim()) .filter(Boolean); return new Set([...DEFAULT_ALLOWED_ORIGINS, ...configuredOrigins]); } function applyCorsHeaders(request, response) { const origin = request.headers.origin; if (!origin) { return; } const allowedOrigins = getAllowedOrigins(); const isLocalOrigin = /^https?:\/\/(127\.0\.0\.1|localhost)(:\d+)?$/i.test(origin); if (!allowedOrigins.has(origin) && !isLocalOrigin) { return; } response.setHeader("access-control-allow-origin", origin); response.setHeader("vary", "Origin"); response.setHeader("access-control-allow-methods", "GET, HEAD, POST, OPTIONS"); response.setHeader("access-control-allow-headers", "Content-Type"); } async function readJsonBody(request) { return new Promise((resolve, reject) => { const chunks = []; let size = 0; request.on("data", (chunk) => { size += chunk.length; if (size > MAX_BODY_SIZE) { reject(new Error("Request body too large.")); request.destroy(); return; } chunks.push(chunk); }); request.on("end", () => { try { const raw = Buffer.concat(chunks).toString("utf8"); resolve(raw ? JSON.parse(raw) : {}); } catch { reject(new Error("Invalid JSON body.")); } }); request.on("error", reject); }); } async function handleIndex(response) { const html = await readFile(path.join(__dirname, "index.html"), "utf8"); response.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store", }); response.end(html); } async function handleReview(request, response) { const apiKey = process.env.GEMINI_API_KEY; if (!apiKey) { sendJson(response, 500, { error: "Server is missing GEMINI_API_KEY.", }); return; } const body = await readJsonBody(request); const paperText = typeof body.paperText === "string" ? body.paperText.trim() : ""; if (!paperText) { sendJson(response, 400, { error: "Paper text is required.", }); return; } const upstreamResponse = await fetch(API_URL, { method: "POST", headers: { "content-type": "application/json", "x-goog-api-key": apiKey, }, body: JSON.stringify({ contents: [ { parts: [ { text: `${PROMPT_TEMPLATE}${paperText}`, }, ], }, ], generationConfig: { temperature: 0, maxOutputTokens: 1400, thinkingConfig: { thinkingBudget: 0, }, responseMimeType: "application/json", responseJsonSchema: REVIEW_SCHEMA, }, }), }); const payload = await upstreamResponse.json().catch(() => ({})); if (!upstreamResponse.ok) { const message = payload?.error?.message || `Gemini request failed with status ${upstreamResponse.status}.`; sendJson(response, upstreamResponse.status, { error: message }); return; } const reviewText = Array.isArray(payload.candidates) ? payload.candidates .flatMap((candidate) => candidate.content?.parts || []) .map((part) => part.text || "") .join("\n") .trim() : ""; if (!reviewText) { sendJson(response, 502, { error: "Gemini returned an empty review.", }); return; } let reviewObject; try { reviewObject = JSON.parse(reviewText); } catch { sendJson(response, 502, { error: "Gemini returned malformed structured output.", }); return; } const review = formatReview(reviewObject); if (!review) { sendJson(response, 502, { error: "Gemini returned an incomplete review.", }); return; } sendJson(response, 200, { review }); } const server = createServer(async (request, response) => { try { const method = request.method || "GET"; const url = new URL(request.url || "/", `http://${request.headers.host || "localhost"}`); applyCorsHeaders(request, response); if (method === "OPTIONS") { response.writeHead(204); response.end(); return; } if (method === "GET" && url.pathname === "/") { await handleIndex(response); return; } if (method === "GET" && url.pathname === "/health") { sendJson(response, 200, { ok: true }); return; } if (method === "POST" && url.pathname === "/api/review") { await handleReview(request, response); return; } sendText(response, 404, "Not found"); } catch (error) { const message = error instanceof Error ? error.message : "Unexpected server error."; sendJson(response, 500, { error: message }); } }); server.listen(PORT, () => { console.log(`Peer Reviewer listening on http://localhost:${PORT}`); });