Peer_Reviewer / server.js
KChad's picture
Prepare Peer Reviewer for Hugging Face Spaces
6b5c39c
Raw
History Blame Contribute Delete
8.71 kB
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}`);
});