import { detectWpNumber } from "@/lib/papers/identifiers"; /** Heuristic metadata proposal from title-page text. Every field is a suggestion for human review, never a fact. */ export 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 }; const STOP = /^(abstract|résumé|keywords?|jel|working paper|université|university|department|département|version|draft|preliminary|email|e-mail|tel|http)/i; export function proposeMetadata(firstPages: string, pdfMetaTitle?: string | null, pdfMetaAuthor?: string | null): Proposal { const text = firstPages.replace(/\r/g, ""); const lines = text.split("\n").map((l) => l.trim()).filter(Boolean); const confidence: Record = {}; // Title: longest run of up to 3 consecutive Title-Case-ish lines near the top, before "Abstract", not a stop line. let title: string | null = null; const abstractIdx = lines.findIndex((l) => /^abstract\b/i.test(l)); const head = lines.slice(0, abstractIdx > 0 ? abstractIdx : 40); const cands: string[] = []; for (let i = 0; i < head.length; i++) { const l = head[i]; if (STOP.test(l) || l.length < 12 || /@|\d{4}-\d{2}/.test(l)) continue; if (/^[A-Z]/.test(l) && !/[.;]$/.test(l) && !/^(by|and)\b/i.test(l)) { let acc = l; let j = i + 1; 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; } cands.push(acc); } } cands.sort((a, b) => b.length - a.length); title = cands[0] ?? null; 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; } } confidence.title = title ? (pdfMetaTitle && title === pdfMetaTitle ? 0.8 : 0.5) : 0; // Authors: lines with 2–4 capitalised words (allowing accents/hyphens), before Abstract, excluding title lines. const authors: string[] = []; const nameRe = /^(?:[A-ZÀ-Ý][a-zà-ÿ'’-]+(?:\s|-)){1,3}[A-ZÀ-Ý][a-zà-ÿ'’-]+(?:\s*[,*†‡§¶0-9]+)?$/; 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); } if (!authors.length && pdfMetaAuthor) authors.push(...pdfMetaAuthor.split(/\s*(?:,|;|&|\band\b)\s*/).filter((a) => a.length > 3)); confidence.authors = authors.length ? 0.5 : 0; // Abstract: text after "Abstract" until Keywords/JEL/1 Introduction. let abstract: string | null = null; if (abstractIdx >= 0) { const rest = lines.slice(abstractIdx + 1); const end = rest.findIndex((l) => /^(keywords?|jel|1\.?\s+introduction|introduction$|mots-clés)/i.test(l)); abstract = rest.slice(0, end > 0 ? end : 40).join(" ").replace(/^abstract[:.]?\s*/i, "").replace(/\s+/g, " ").trim() || null; if (abstract && abstract.length < 80) abstract = null; } confidence.abstract = abstract ? 0.7 : 0; const kwLine = lines.find((l) => /^(keywords?|mots-clés)\s*[:.]/i.test(l)); 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) : []; const jelLine = lines.find((l) => /^jel\b/i.test(l)); const jel = jelLine ? (jelLine.match(/\b[A-Z]\d{1,2}\b/g) ?? []) : []; const wp = detectWpNumber(text.slice(0, 4000)); const vm = /\bversion\s*v?(\d+(?:\.\d+)?)/i.exec(text.slice(0, 6000)); const version = vm ? vm[1] : null; 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)); const dateText = dm ? `${dm[1]} ${dm[2]}` : null; confidence.wp = wp ? 0.9 : 0; confidence.date = dateText ? 0.6 : 0; return { title, authors, abstract, keywords, jel, wp, version, dateText, confidence }; } /** Rough duplicate scoring between a candidate and an existing paper. */ export function titleSimilarity(a: string, b: string): number { const norm = (s: string) => s.toLowerCase().normalize("NFD").replace(/[̀-ͯ]/g, "").replace(/[^a-z0-9 ]+/g, " ").split(/\s+/).filter((w) => w.length > 2); const A = new Set(norm(a)), B = new Set(norm(b)); if (!A.size || !B.size) return 0; let inter = 0; for (const w of A) if (B.has(w)) inter++; return inter / Math.max(A.size, B.size); }