spb/wp-spboucher-ai
Public
1import { detectWpNumber } from "@/lib/papers/identifiers";2/** Heuristic metadata proposal from title-page text. Every field is a suggestion for human review, never a fact. */3export type Proposal = { title: string | null; authors: string[]; abstract: string | null; keywords: string[]; jel: string[]; wp: { seriesCode: string; number: number } | null; version: string | null; dateText: string | null; confidence: Record<string, number> };4const STOP = /^(abstract|résumé|keywords?|jel|working paper|université|university|department|département|version|draft|preliminary|email|e-mail|tel|http)/i;5export function proposeMetadata(firstPages: string, pdfMetaTitle?: string | null, pdfMetaAuthor?: string | null): Proposal {6 const text = firstPages.replace(/\r/g, "");7 const lines = text.split("\n").map((l) => l.trim()).filter(Boolean);8 const confidence: Record<string, number> = {};9 // Title: longest run of up to 3 consecutive Title-Case-ish lines near the top, before "Abstract", not a stop line.10 let title: string | null = null;11 const abstractIdx = lines.findIndex((l) => /^abstract\b/i.test(l));12 const head = lines.slice(0, abstractIdx > 0 ? abstractIdx : 40);13 const cands: string[] = [];14 for (let i = 0; i < head.length; i++) {15 const l = head[i]; if (STOP.test(l) || l.length < 12 || /@|\d{4}-\d{2}/.test(l)) continue;16 if (/^[A-Z]/.test(l) && !/[.;]$/.test(l) && !/^(by|and)\b/i.test(l)) {17 let acc = l; let j = i + 1;18 while (j < head.length && head[j].length > 8 && !STOP.test(head[j]) && !/@/.test(head[j]) && !/^(by|and)\b/i.test(head[j]) && acc.length < 300 && !/^[A-Z][a-z]+ [A-Z]/.test(head[j]) /* author-like */) { acc += " " + head[j]; j++; if (/[?:.]$/.test(head[j - 1])) break; }19 cands.push(acc);20 }21 }22 cands.sort((a, b) => b.length - a.length);23 title = cands[0] ?? null;24 if (pdfMetaTitle && pdfMetaTitle.length > 12 && !/^(untitled|main|paper|subject)/i.test(pdfMetaTitle)) { if (!title || pdfMetaTitle.length > 20) { title = title && title.length > pdfMetaTitle.length + 20 ? title : pdfMetaTitle; } }25 confidence.title = title ? (pdfMetaTitle && title === pdfMetaTitle ? 0.8 : 0.5) : 0;26 // Authors: lines with 2–4 capitalised words (allowing accents/hyphens), before Abstract, excluding title lines.27 const authors: string[] = [];28 const nameRe = /^(?:[A-ZÀ-Ý][a-zà-ÿ'’-]+(?:\s|-)){1,3}[A-ZÀ-Ý][a-zà-ÿ'’-]+(?:\s*[,*†‡§¶0-9]+)?$/;29 for (const l of head) { const cleaned = l.replace(/[*†‡§¶0-9,]+$/g, "").trim(); if (nameRe.test(cleaned) && !(title ?? "").includes(cleaned) && !/university|université|department|école|school/i.test(cleaned)) authors.push(cleaned); }30 if (!authors.length && pdfMetaAuthor) authors.push(...pdfMetaAuthor.split(/\s*(?:,|;|&|\band\b)\s*/).filter((a) => a.length > 3));31 confidence.authors = authors.length ? 0.5 : 0;32 // Abstract: text after "Abstract" until Keywords/JEL/1 Introduction.33 let abstract: string | null = null;34 if (abstractIdx >= 0) {35 const rest = lines.slice(abstractIdx + 1); const end = rest.findIndex((l) => /^(keywords?|jel|1\.?\s+introduction|introduction$|mots-clés)/i.test(l));36 abstract = rest.slice(0, end > 0 ? end : 40).join(" ").replace(/^abstract[:.]?\s*/i, "").replace(/\s+/g, " ").trim() || null;37 if (abstract && abstract.length < 80) abstract = null;38 }39 confidence.abstract = abstract ? 0.7 : 0;40 const kwLine = lines.find((l) => /^(keywords?|mots-clés)\s*[:.]/i.test(l));41 const keywords = kwLine ? kwLine.replace(/^(keywords?|mots-clés)\s*[:.]\s*/i, "").split(/[;,·•]/).map((k) => k.trim().replace(/\.$/, "")).filter((k) => k.length > 1 && k.length < 60) : [];42 const jelLine = lines.find((l) => /^jel\b/i.test(l));43 const jel = jelLine ? (jelLine.match(/\b[A-Z]\d{1,2}\b/g) ?? []) : [];44 const wp = detectWpNumber(text.slice(0, 4000));45 const vm = /\bversion\s*v?(\d+(?:\.\d+)?)/i.exec(text.slice(0, 6000)); const version = vm ? vm[1] : null;46 const dm = /\b(January|February|March|April|May|June|July|August|September|October|November|December|janvier|février|mars|avril|mai|juin|juillet|août|septembre|octobre|novembre|décembre)\s+(\d{4})\b/i.exec(text.slice(0, 6000));47 const dateText = dm ? `${dm[1]} ${dm[2]}` : null;48 confidence.wp = wp ? 0.9 : 0; confidence.date = dateText ? 0.6 : 0;49 return { title, authors, abstract, keywords, jel, wp, version, dateText, confidence };50}51/** Rough duplicate scoring between a candidate and an existing paper. */52export function titleSimilarity(a: string, b: string): number {53 const norm = (s: string) => s.toLowerCase().normalize("NFD").replace(/[̀-ͯ]/g, "").replace(/[^a-z0-9 ]+/g, " ").split(/\s+/).filter((w) => w.length > 2);54 const A = new Set(norm(a)), B = new Set(norm(b)); if (!A.size || !B.size) return 0;55 let inter = 0; for (const w of A) if (B.has(w)) inter++;56 return inter / Math.max(A.size, B.size);57}58