This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

papers / src / paper-summarizer.ts
9.4 kB 264 lines
1/** 2 * Paper summarizer — fetches paper content and generates a structured summary. 3 * 4 * Two modes: 5 * 1. Worker mode: heuristic extraction from embed + OpenGraph enrichment 6 * 2. Letta enrichment: calls Letta API for rich LLM-generated summaries 7 */ 8 9import { mystTextToOxaRecord } from "@nandithebull/myst-oxa"; 10import type { PaperPost } from "./feed-watcher.js"; 11 12export interface PaperSummary { 13 paperUrl: string; 14 title: string; 15 authors: string[]; 16 abstract: string; 17 summary: string; 18 summaryDoc?: any; 19 domains: string[]; 20 venue: string; 21 year: number | null; 22} 23 24export function extractFromPost(post: PaperPost): Partial<PaperSummary> { 25 const result: Partial<PaperSummary> = { paperUrl: post.paperUrl }; 26 27 if (post.embedTitle) result.title = post.embedTitle; 28 if (post.embedDescription) result.abstract = post.embedDescription; 29 30 const authorMatch = post.postText.match(/from\s+(.+?)(?:\s+https?:\/\/|$)/); 31 if (authorMatch) { 32 result.authors = authorMatch[1] 33 .split(/,\s*|\s+and\s+/) 34 .map(a => a.trim()) 35 .filter(a => a.length > 0); 36 } 37 38 if (post.paperUrl.includes("arxiv.org")) result.venue = "arXiv"; 39 else if (post.paperUrl.includes("nber.org")) result.venue = "NBER"; 40 else if (post.paperUrl.includes("openreview.net")) result.venue = "OpenReview"; 41 else if (post.paperUrl.includes("ssrn.com")) result.venue = "SSRN"; 42 else if (post.paperUrl.includes("biorxiv.org")) result.venue = "bioRxiv"; 43 else if (post.paperUrl.includes("medrxiv.org")) result.venue = "medRxiv"; 44 else if (post.paperUrl.includes("neurips.cc")) result.venue = "NeurIPS"; 45 else if (post.paperUrl.includes("mlr.press")) result.venue = "JMLR/PMLR"; 46 else if (post.paperUrl.includes("aclanthology.org")) result.venue = "ACL"; 47 else if (post.paperUrl.includes("thecvf.com")) result.venue = "CVPR/ICCV/ECCV"; 48 49 const yearMatch = post.paperUrl.match(/(\d{4})/); 50 if (yearMatch) { 51 const y = parseInt(yearMatch[1]); 52 if (y >= 1990 && y <= 2030) result.year = y; 53 } 54 55 if (post.embedDescription) { 56 const domainKeywords = extractDomainKeywords(post.embedDescription); 57 if (domainKeywords.length > 0) result.domains = domainKeywords; 58 } 59 60 return result; 61} 62 63const DOMAIN_PATTERNS: Array<{ pattern: RegExp; domain: string }> = [ 64 { pattern: /\bmachine learning\b/i, domain: "machine learning" }, 65 { pattern: /\bdeep learning\b/i, domain: "deep learning" }, 66 { pattern: /\bnatural language processing\b|\bNLP\b/i, domain: "NLP" }, 67 { pattern: /\bcomputer vision\b/i, domain: "computer vision" }, 68 { pattern: /\breinforcement learning\b/i, domain: "reinforcement learning" }, 69 { pattern: /\bgenerative AI\b|\bgenerative model/i, domain: "generative AI" }, 70 { pattern: /\bneural network\b/i, domain: "neural networks" }, 71 { pattern: /\btransformer\b/i, domain: "transformers" }, 72 { pattern: /\blanguage model\b|\bLLM\b/i, domain: "language models" }, 73 { pattern: /\brobotics\b/i, domain: "robotics" }, 74 { pattern: /\bbioinformatics\b/i, domain: "bioinformatics" }, 75 { pattern: /\bneuroscience\b/i, domain: "neuroscience" }, 76 { pattern: /\bclimate\b/i, domain: "climate science" }, 77 { pattern: /\beconomics?\b/i, domain: "economics" }, 78 { pattern: /\bpsychology\b/i, domain: "psychology" }, 79 { pattern: /\bsociology\b/i, domain: "sociology" }, 80 { pattern: /\bphilosophy\b/i, domain: "philosophy" }, 81 { pattern: /\bepidemiol/i, domain: "epidemiology" }, 82 { pattern: /\bgenomics?\b/i, domain: "genomics" }, 83 { pattern: /\bquantum\b/i, domain: "quantum computing" }, 84]; 85 86function extractDomainKeywords(text: string): string[] { 87 const domains: string[] = []; 88 for (const { pattern, domain } of DOMAIN_PATTERNS) { 89 if (pattern.test(text) && !domains.includes(domain)) { 90 domains.push(domain); 91 } 92 } 93 return domains; 94} 95 96export function generateHeuristicSummary(post: PaperPost, metadata: Partial<PaperSummary>): string { 97 const parts: string[] = []; 98 if (metadata.title) parts.push(metadata.title); 99 if (metadata.abstract) { 100 const sentences = metadata.abstract.split(/\.\s+/).slice(0, 2); 101 parts.push(sentences.join(". ") + "."); 102 } 103 if (metadata.authors?.length) { 104 const authorStr = metadata.authors.length <= 3 105 ? metadata.authors.join(", ") 106 : `${metadata.authors[0]} et al.`; 107 parts.push(`By ${authorStr}.`); 108 } 109 if (metadata.venue) { 110 parts.push(`Published in ${metadata.venue}${metadata.year ? ` (${metadata.year})` : ""}.`); 111 } 112 return parts.join(" "); 113} 114 115export async function fetchOpenGraph(paperUrl: string): Promise<{ title?: string; description?: string }> { 116 try { 117 const res = await fetch(paperUrl, { 118 headers: { "User-Agent": "papers-appview/0.1 (mailto:researcher@pds.latha.org)" }, 119 signal: AbortSignal.timeout(10000), 120 }); 121 if (!res.ok) return {}; 122 const html = await res.text(); 123 const ogTitle = html.match(/<meta\s+property="og:title"\s+content="([^"]+)"/)?.[1]; 124 const ogDesc = html.match(/<meta\s+property="og:description"\s+content="([^"]+)"/)?.[1]; 125 const titleTag = html.match(/<title>([^<]+)<\/title>/)?.[1]; 126 return { 127 title: ogTitle || titleTag || undefined, 128 description: ogDesc || undefined, 129 }; 130 } catch { 131 return {}; 132 } 133} 134 135export async function buildSummary(post: PaperPost): Promise<PaperSummary> { 136 const metadata = extractFromPost(post); 137 138 if (!metadata.title || !metadata.abstract) { 139 const og = await fetchOpenGraph(post.paperUrl); 140 if (!metadata.title && og.title) metadata.title = og.title; 141 if (!metadata.abstract && og.description) metadata.abstract = og.description; 142 } 143 144 const summary = generateHeuristicSummary(post, metadata); 145 146 const mystText = [ 147 metadata.title ? `# ${metadata.title}` : null, 148 metadata.authors?.length ? `**Authors:** ${metadata.authors.join(", ")}` : null, 149 metadata.venue ? `**Venue:** ${metadata.venue}${metadata.year ? ` (${metadata.year})` : ""}` : null, 150 "", 151 metadata.abstract || "", 152 "", 153 "## Summary", 154 "", 155 summary, 156 ].filter(Boolean).join("\n"); 157 158 let summaryDoc; 159 try { 160 summaryDoc = mystTextToOxaRecord(mystText); 161 } catch { 162 // OXA conversion is best-effort 163 } 164 165 return { 166 paperUrl: post.paperUrl, 167 title: metadata.title || "Untitled", 168 authors: metadata.authors || [], 169 abstract: metadata.abstract || "", 170 summary, 171 summaryDoc, 172 domains: metadata.domains || [], 173 venue: metadata.venue || "", 174 year: metadata.year || null, 175 }; 176} 177 178// --- Letta-powered enrichment --- 179 180const LETTA_API_BASE = "https://api.letta.com/v1"; 181 182const ENRICHMENT_PROMPT = `You are a research paper summarizer. Given a paper's URL, title, abstract, and existing metadata, produce a concise, insightful summary. 183 184Return ONLY a JSON object with these fields (no markdown, no code fences): 185- "title": the paper's real title. If the current title is "Untitled" or placeholder, derive it from the URL or abstract. 186- "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. 187- "domains": array of 1-3 research domain strings (e.g. ["machine learning", "optimization"]) 188- "takeaway": one sentence — the single most important thing a reader should remember 189 190Paper: {title} 191URL: {paperUrl} 192Abstract: {abstract} 193Existing domains: {domains} 194Venue: {venue}`; 195 196export interface LettaEnrichment { 197 title?: string; 198 summary: string; 199 domains: string[]; 200 takeaway: string; 201} 202 203export async function enrichWithLetta( 204 paper: { title: string; paperUrl: string; abstract: string; domains: string[]; venue: string }, 205 apiKey: string, 206 agentId: string, 207): Promise<LettaEnrichment> { 208 const prompt = ENRICHMENT_PROMPT 209 .replace("{title}", paper.title) 210 .replace("{paperUrl}", paper.paperUrl) 211 .replace("{abstract}", paper.abstract || "(no abstract available)") 212 .replace("{domains}", paper.domains.join(", ") || "unknown") 213 .replace("{venue}", paper.venue || "unknown"); 214 215 const res = await fetch(`${LETTA_API_BASE}/agents/${agentId}/messages`, { 216 method: "POST", 217 headers: { 218 "Authorization": `Bearer ${apiKey}`, 219 "Content-Type": "application/json", 220 "Accept": "application/json", 221 }, 222 body: JSON.stringify({ 223 messages: [{ role: "user", content: prompt }], 224 }), 225 }); 226 227 if (!res.ok) { 228 const body = await res.text(); 229 throw new Error(`Letta API error (${res.status}): ${body}`); 230 } 231 232 const data = await res.json() as { 233 messages: Array<{ message_type: string; content?: string }>; 234 }; 235 236 // Find the assistant message 237 const assistantMsg = data.messages.find( 238 (m) => m.message_type === "assistant_message" && m.content, 239 ); 240 241 if (!assistantMsg?.content) { 242 throw new Error("No assistant message in Letta response"); 243 } 244 245 // Parse JSON from the response — handle possible markdown code fences 246 let content = assistantMsg.content; 247 const jsonMatch = content.match(/```(?:json)?\s*([\s\S]*?)```/); 248 if (jsonMatch) content = jsonMatch[1].trim(); 249 250 let parsed: LettaEnrichment; 251 try { 252 parsed = JSON.parse(content); 253 } catch { 254 // If JSON parse fails, use the raw content as summary 255 parsed = { summary: content, domains: paper.domains, takeaway: "" }; 256 } 257 258 return { 259 title: parsed.title || undefined, 260 summary: parsed.summary || content, 261 domains: parsed.domains || paper.domains, 262 takeaway: parsed.takeaway || "", 263 }; 264}