/** * Paper summarizer — fetches paper content and generates a structured summary. * * Two modes: * 1. Worker mode: heuristic extraction from embed + OpenGraph enrichment * 2. Letta enrichment: calls Letta API for rich LLM-generated summaries */ import { mystTextToOxaRecord } from "@nandithebull/myst-oxa"; import type { PaperPost } from "./feed-watcher.js"; export interface PaperSummary { paperUrl: string; title: string; authors: string[]; abstract: string; summary: string; summaryDoc?: any; domains: string[]; venue: string; year: number | null; publishedDate: string | null; } export function extractFromPost(post: PaperPost): Partial { const result: Partial = { paperUrl: post.paperUrl }; if (post.embedTitle) result.title = post.embedTitle; if (post.embedDescription) result.abstract = post.embedDescription; const authorMatch = post.postText.match(/from\s+(.+?)(?:\s+https?:\/\/|$)/); if (authorMatch) { result.authors = authorMatch[1] .split(/,\s*|\s+and\s+/) .map(a => a.trim()) .filter(a => a.length > 0); } if (post.paperUrl.includes("arxiv.org")) result.venue = "arXiv"; else if (post.paperUrl.includes("nber.org")) result.venue = "NBER"; else if (post.paperUrl.includes("openreview.net")) result.venue = "OpenReview"; else if (post.paperUrl.includes("ssrn.com")) result.venue = "SSRN"; else if (post.paperUrl.includes("biorxiv.org")) result.venue = "bioRxiv"; else if (post.paperUrl.includes("medrxiv.org")) result.venue = "medRxiv"; else if (post.paperUrl.includes("neurips.cc")) result.venue = "NeurIPS"; else if (post.paperUrl.includes("mlr.press")) result.venue = "JMLR/PMLR"; else if (post.paperUrl.includes("aclanthology.org")) result.venue = "ACL"; else if (post.paperUrl.includes("thecvf.com")) result.venue = "CVPR/ICCV/ECCV"; // Extract year from URL — venue-specific patterns to avoid matching IDs const yearPatterns: Array<[RegExp, number]> = [ [/arxiv\.org\/abs\/(\d{2})(\d{2})\./, 2], // arXiv: YYMM -> 20YY [/biorxiv\.org\/content\/.*?(\d{4})\./, 1], // bioRxiv: YYYY.MM [/medrxiv\.org\/content\/.*?(\d{4})\./, 1], // medRxiv: YYYY.MM [/nature\.com\/.*?(\d{4})/, 1], // Nature: embedded in article ID [/nber\.org\/papers\/w(\d{2})/, 1], // NBER: wYYnnn [/20(\d{2})/, 0], // Fallback: 20XX anywhere ]; for (const [pattern, groupIdx] of yearPatterns) { const m = post.paperUrl.match(pattern); if (m) { const raw = m[groupIdx + 1] || m[1]; const y = raw.length === 2 ? 2000 + parseInt(raw) : parseInt(raw); if (y >= 1990 && y <= 2030) { result.year = y; break; } } } if (post.embedDescription) { const domainKeywords = extractDomainKeywords(post.embedDescription); if (domainKeywords.length > 0) result.domains = domainKeywords; } return result; } const DOMAIN_PATTERNS: Array<{ pattern: RegExp; domain: string }> = [ { pattern: /\bmachine learning\b/i, domain: "machine learning" }, { pattern: /\bdeep learning\b/i, domain: "deep learning" }, { pattern: /\bnatural language processing\b|\bNLP\b/i, domain: "NLP" }, { pattern: /\bcomputer vision\b/i, domain: "computer vision" }, { pattern: /\breinforcement learning\b/i, domain: "reinforcement learning" }, { pattern: /\bgenerative AI\b|\bgenerative model/i, domain: "generative AI" }, { pattern: /\bneural network\b/i, domain: "neural networks" }, { pattern: /\btransformer\b/i, domain: "transformers" }, { pattern: /\blanguage model\b|\bLLM\b/i, domain: "language models" }, { pattern: /\brobotics\b/i, domain: "robotics" }, { pattern: /\bbioinformatics\b/i, domain: "bioinformatics" }, { pattern: /\bneuroscience\b/i, domain: "neuroscience" }, { pattern: /\bclimate\b/i, domain: "climate science" }, { pattern: /\beconomics?\b/i, domain: "economics" }, { pattern: /\bpsychology\b/i, domain: "psychology" }, { pattern: /\bsociology\b/i, domain: "sociology" }, { pattern: /\bphilosophy\b/i, domain: "philosophy" }, { pattern: /\bepidemiol/i, domain: "epidemiology" }, { pattern: /\bgenomics?\b/i, domain: "genomics" }, { pattern: /\bquantum\b/i, domain: "quantum computing" }, ]; function extractDomainKeywords(text: string): string[] { const domains: string[] = []; for (const { pattern, domain } of DOMAIN_PATTERNS) { if (pattern.test(text) && !domains.includes(domain)) { domains.push(domain); } } return domains; } export function generateHeuristicSummary(post: PaperPost, metadata: Partial): string { const parts: string[] = []; if (metadata.title) parts.push(metadata.title); if (metadata.abstract) { const sentences = metadata.abstract.split(/\.\s+/).slice(0, 2); parts.push(sentences.join(". ") + "."); } if (metadata.authors?.length) { const authorStr = metadata.authors.length <= 3 ? metadata.authors.join(", ") : `${metadata.authors[0]} et al.`; parts.push(`By ${authorStr}.`); } if (metadata.venue) { parts.push(`Published in ${metadata.venue}${metadata.year ? ` (${metadata.year})` : ""}.`); } return parts.join(" "); } export async function fetchOpenGraph(paperUrl: string): Promise<{ title?: string; description?: string }> { try { const res = await fetch(paperUrl, { headers: { "User-Agent": "papers-appview/0.1 (mailto:researcher@pds.latha.org)" }, signal: AbortSignal.timeout(10000), }); if (!res.ok) return {}; const html = await res.text(); const ogTitle = html.match(/([^<]+)<\/title>/)?.[1]; return { title: ogTitle || titleTag || undefined, description: ogDesc || undefined, }; } catch { return {}; } } export async function buildSummary(post: PaperPost): Promise { const metadata = extractFromPost(post); if (!metadata.title || !metadata.abstract) { const og = await fetchOpenGraph(post.paperUrl); if (!metadata.title && og.title) metadata.title = og.title; if (!metadata.abstract && og.description) metadata.abstract = og.description; } const summary = generateHeuristicSummary(post, metadata); const mystText = [ metadata.title ? `# ${metadata.title}` : null, metadata.authors?.length ? `**Authors:** ${metadata.authors.join(", ")}` : null, metadata.venue ? `**Venue:** ${metadata.venue}${metadata.year ? ` (${metadata.year})` : ""}` : null, "", metadata.abstract || "", "", "## Summary", "", summary, ].filter(Boolean).join("\n"); let summaryDoc; try { summaryDoc = mystTextToOxaRecord(mystText); } catch { // OXA conversion is best-effort } return { paperUrl: post.paperUrl, title: metadata.title || "Untitled", authors: metadata.authors || [], abstract: metadata.abstract || "", summary, summaryDoc, domains: metadata.domains || [], venue: metadata.venue || "", year: metadata.year || null, }; } // --- Letta-powered enrichment --- const LETTA_API_BASE = "https://api.letta.com/v1"; const ENRICHMENT_PROMPT = `You are a research paper summarizer. Given a paper's URL, title, abstract, and existing metadata, produce a concise, insightful summary. Return ONLY a JSON object with these fields (no markdown, no code fences): - "title": the paper's real title. If the current title is "Untitled" or placeholder, derive it from the URL or abstract. - "summary": 2-3 sentence summary in your own words. Focus on what the paper does and why it matters. Not a restatement of the abstract. - "domains": array of 1-3 research domain strings (e.g. ["machine learning", "optimization"]) - "takeaway": one sentence — the single most important thing a reader should remember - "publishedDate": the publication date in YYYY-MM-DD format. Derive from the URL (e.g. biorxiv.org URLs contain YYYY.MM.DD, arXiv URLs contain YYMM), abstract, or title. If only month is known, use YYYY-MM-01. If only year, use YYYY-01-01. Paper: {title} URL: {paperUrl} Abstract: {abstract} Existing domains: {domains} Venue: {venue}`; export interface LettaEnrichment { title?: string; summary: string; domains: string[]; takeaway: string; year?: number; publishedDate?: string; } export async function enrichWithLetta( paper: { title: string; paperUrl: string; abstract: string; domains: string[]; venue: string }, apiKey: string, agentId: string, ): Promise { const prompt = ENRICHMENT_PROMPT .replace("{title}", paper.title) .replace("{paperUrl}", paper.paperUrl) .replace("{abstract}", paper.abstract || "(no abstract available)") .replace("{domains}", paper.domains.join(", ") || "unknown") .replace("{venue}", paper.venue || "unknown"); const res = await fetch(`${LETTA_API_BASE}/agents/${agentId}/messages`, { method: "POST", headers: { "Authorization": `Bearer ${apiKey}`, "Content-Type": "application/json", "Accept": "application/json", }, body: JSON.stringify({ messages: [{ role: "user", content: prompt }], }), }); if (!res.ok) { const body = await res.text(); throw new Error(`Letta API error (${res.status}): ${body}`); } const data = await res.json() as { messages: Array<{ message_type: string; content?: string }>; }; // Find the assistant message const assistantMsg = data.messages.find( (m) => m.message_type === "assistant_message" && m.content, ); if (!assistantMsg?.content) { throw new Error("No assistant message in Letta response"); } // Parse JSON from the response — handle possible markdown code fences let content = assistantMsg.content; const jsonMatch = content.match(/```(?:json)?\s*([\s\S]*?)```/); if (jsonMatch) content = jsonMatch[1].trim(); let parsed: LettaEnrichment; try { parsed = JSON.parse(content); } catch { // If JSON parse fails, use the raw content as summary parsed = { summary: content, domains: paper.domains, takeaway: "" }; } return { title: parsed.title || undefined, summary: parsed.summary || content, domains: parsed.domains || paper.domains, takeaway: parsed.takeaway || "", year: parsed.year || undefined, publishedDate: parsed.publishedDate || undefined, }; }