This repository has no description
0

Configure Feed

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

papers / src / worker.ts
20 kB 604 lines
1/** 2 * Papers Appview — watches the Paper Skygest feed, detects linked papers, 3 * generates summaries, and writes org.latha.papers.summary records to PDS. 4 * 5 * Hosted at papers.latha.org 6 */ 7 8import { Hono } from "hono"; 9import { cors } from "hono/cors"; 10import { createWorker } from "@atmo-dev/contrail/worker"; 11import { Contrail } from "@atmo-dev/contrail"; 12import { lexicons } from "../lexicons/generated/index.js"; 13import { config } from "./contrail.config.js"; 14import { pollNewPapers, pollFollowedAccounts, type PaperPost } from "./feed-watcher.js"; 15import { buildSummary, enrichWithLetta, type PaperSummary } from "./paper-summarizer.js"; 16import { writeSummaryToPds } from "./pds-writer.js"; 17import { LANDING_PAGE } from "./landing-page.js"; 18import { SUMMARY_PAGE } from "./summary-page.js"; 19 20interface Env { 21 DB: D1Database; 22 FEED_CURSOR?: string; 23 RESEARCHER_KEY_PEM?: string; 24 LETTA_API_KEY?: string; 25 LETTA_AGENT_ID?: string; 26} 27 28const contrailWorker = createWorker(config, { lexicons }); 29const contrail = new Contrail(config); 30let contrailReady = false; 31 32async function ensureContrailReady(db: D1Database): Promise<void> { 33 if (contrailReady) return; 34 await contrail.init(db); 35 contrailReady = true; 36} 37 38let app: Hono<{ Bindings: Env }> | null = null; 39 40function buildApp(env: Env): Hono<{ Bindings: Env }> { 41 const app = new Hono<{ Bindings: Env }>(); 42 const db = env.DB; 43 44 app.use("*", cors()); 45 46 // Landing page — browse all indexed summaries 47 app.get("/", async (c) => { 48 await ensureContrailReady(db); 49 50 let summaries: any[] = []; 51 try { 52 const rows = await db 53 .prepare( 54 "SELECT uri, record, did, rkey, time_us FROM records_summary ORDER BY json_extract(record, '$.publishedDate') DESC NULLS LAST, time_us DESC LIMIT 100" 55 ) 56 .all(); 57 summaries = (rows.results || []).map((r: any) => { 58 try { 59 const value = JSON.parse(r.record); 60 return { 61 uri: r.uri, 62 rkey: r.rkey, 63 title: value?.title || "Untitled", 64 paperUrl: value?.paperUrl || "", 65 venue: value?.venue || "", 66 year: value?.year || null, 67 publishedDate: value?.publishedDate || null, 68 authors: value?.authors || [], 69 summary: value?.summary || "", 70 domains: value?.domains || [], 71 posterHandle: value?.posterHandle || "", 72 indexedAt: value?.indexedAt || "", 73 }; 74 } catch { 75 return null; 76 } 77 }).filter(Boolean); 78 } catch { 79 // empty list is fine 80 } 81 82 const page = LANDING_PAGE.replace( 83 "</head>", 84 `<script>window.__SUMMARIES__=${JSON.stringify(summaries).replace(/<\//g, "<\\/")};</script></head>` 85 ); 86 return c.html(page); 87 }); 88 89 // Summary detail page 90 app.get("/paper/:rkey", async (c) => { 91 const rkey = c.req.param("rkey"); 92 await ensureContrailReady(db); 93 94 let summary: any = null; 95 try { 96 const row = await db 97 .prepare("SELECT uri, record, did, rkey FROM records_summary WHERE rkey = ? LIMIT 1") 98 .bind(rkey) 99 .first<{ uri: string; record: string; did: string; rkey: string }>(); 100 if (row) { 101 const value = JSON.parse(row.record); 102 summary = { 103 uri: row.uri, 104 rkey: row.rkey, 105 ...value, 106 }; 107 } 108 } catch { 109 // not found 110 } 111 112 if (!summary) { 113 return c.html("<h1>Not Found</h1>", 404); 114 } 115 116 const page = SUMMARY_PAGE.replace( 117 "</head>", 118 `<script>window.__SUMMARY__=${JSON.stringify(summary)};</script></head>` 119 ); 120 return c.html(page); 121 }); 122 123 // API: list summaries as JSON 124 app.get("/api/summaries", async (c) => { 125 await ensureContrailReady(db); 126 const limit = Math.min(Number(c.req.query("limit") || 50), 200); 127 const cursor = c.req.query("cursor"); 128 129 let rows: D1Result<any>; 130 if (cursor) { 131 rows = await db 132 .prepare("SELECT uri, record, did, rkey, time_us FROM records_summary WHERE time_us < ? ORDER BY time_us DESC LIMIT ?") 133 .bind(cursor, limit) 134 .all(); 135 } else { 136 rows = await db 137 .prepare("SELECT uri, record, did, rkey, time_us FROM records_summary ORDER BY time_us DESC LIMIT ?") 138 .bind(limit) 139 .all(); 140 } 141 142 const summaries = (rows.results || []).map((r: any) => { 143 try { 144 const value = JSON.parse(r.record); 145 return { uri: r.uri, rkey: r.rkey, ...value }; 146 } catch { 147 return null; 148 } 149 }).filter(Boolean); 150 151 const lastRow = rows.results?.[rows.results.length - 1]; 152 const nextCursor = lastRow?.time_us || undefined; 153 154 return c.json({ summaries, cursor: nextCursor }); 155 }); 156 157 // API: trigger feed poll + summary generation 158 app.post("/api/poll", async (c) => { 159 await ensureContrailReady(db); 160 161 // Get last cursor from D1 162 let lastCursor: string | undefined; 163 try { 164 const row = await db 165 .prepare("SELECT value FROM instance_settings WHERE key = 'feed_cursor' LIMIT 1") 166 .first<{ value: string }>(); 167 lastCursor = row?.value || undefined; 168 } catch { 169 // first run 170 } 171 172 const { posts: feedPosts, newCursor } = await pollNewPapers(lastCursor); 173 174 // Also poll followed accounts for paper links 175 let followedPosts: PaperPost[] = []; 176 try { 177 followedPosts = await pollFollowedAccounts(5); 178 } catch (err: any) { 179 console.error(`Followed accounts poll failed: ${err?.message ?? err}`); 180 } 181 182 // Merge and deduplicate 183 const seenPaperUrls = new Set<string>(); 184 const posts: PaperPost[] = []; 185 for (const p of [...feedPosts, ...followedPosts]) { 186 if (!seenPaperUrls.has(p.paperUrl)) { 187 seenPaperUrls.add(p.paperUrl); 188 posts.push(p); 189 } 190 } 191 192 // Deduplicate against existing records 193 const existingUrls = new Set<string>(); 194 if (posts.length > 0) { 195 const placeholders = posts.map(() => "?").join(","); 196 const urls = posts.map(p => p.paperUrl); 197 const rows = await db 198 .prepare( 199 `SELECT DISTINCT json_extract(record, '$.paperUrl') as paperUrl FROM records_summary WHERE paperUrl IN (${placeholders})` 200 ) 201 .bind(...urls) 202 .all<{ paperUrl: string }>(); 203 for (const r of rows.results || []) { 204 if (r.paperUrl) existingUrls.add(r.paperUrl); 205 } 206 } 207 208 const newPosts = posts.filter(p => !existingUrls.has(p.paperUrl)); 209 let written = 0; 210 let errors = 0; 211 const errorDetails: string[] = []; 212 213 const apiKey = process.env.LETTA_API_KEY; 214 const agentId = process.env.LETTA_AGENT_ID; 215 216 // Build heuristic summaries in parallel 217 const heuristicResults = await Promise.all(newPosts.map(async (post) => { 218 try { 219 return { post, summary: await buildSummary(post) }; 220 } catch (err: any) { 221 return { post, error: err?.message ?? err }; 222 } 223 })); 224 225 // Enrich in parallel (batch of 5 to avoid rate limits) 226 const enrichedResults: Array<{ post: PaperPost; summary: PaperSummary; error?: string }> = []; 227 for (let i = 0; i < heuristicResults.length; i += 5) { 228 const batch = heuristicResults.slice(i, i + 5); 229 const batchResults = await Promise.all(batch.map(async ({ post, summary, error }) => { 230 if (error || !summary) return { post, summary: summary!, error }; 231 if (!apiKey || !agentId) return { post, summary }; 232 try { 233 const enrichment = await enrichWithLetta( 234 { title: summary.title, paperUrl: summary.paperUrl, abstract: summary.abstract, domains: summary.domains, venue: summary.venue }, 235 apiKey, 236 agentId, 237 ); 238 if (enrichment.title) summary.title = enrichment.title; 239 summary.summary = enrichment.summary; 240 summary.domains = enrichment.domains; 241 if (enrichment.takeaway) (summary as any).takeaway = enrichment.takeaway; 242 if (enrichment.year) summary.year = enrichment.year; 243 if (enrichment.publishedDate) (summary as any).publishedDate = enrichment.publishedDate; 244 return { post, summary }; 245 } catch (err: any) { 246 console.error(`Enrichment failed for ${post.paperUrl}: ${err?.message ?? err}, using heuristic`); 247 return { post, summary }; 248 } 249 })); 250 enrichedResults.push(...batchResults); 251 } 252 253 // Write all to PDS and D1 254 for (const { post, summary, error } of enrichedResults) { 255 if (error || !summary) { 256 const msg = `${post.paperUrl}: ${error}`; 257 console.error(`Failed to build summary for ${msg}`); 258 if (errors < 3) errorDetails.push(msg.substring(0, 200)); 259 errors++; 260 continue; 261 } 262 try { 263 const result = await writeSummaryToPds(post, summary); 264 265 const rkey = result.uri.split("/").pop() || ""; 266 await db 267 .prepare("INSERT OR REPLACE INTO records_summary (uri, cid, did, rkey, record, time_us, indexed_at) VALUES (?, ?, ?, ?, ?, ?, ?)") 268 .bind(result.uri, result.cid, "did:plc:3kkhul7jznlb6ba7rprzawnj", rkey, JSON.stringify({ 269 $type: "org.latha.papers.summary", 270 ...summary, 271 sourceUri: post.postUri, 272 posterDid: post.posterDid, 273 posterHandle: post.posterHandle, 274 postText: post.postText, 275 indexedAt: new Date().toISOString(), 276 }), Date.now() * 1000, Date.now() * 1000) 277 .run(); 278 written++; 279 } catch (err: any) { 280 const msg = `${post.paperUrl}: ${err?.message ?? err}`; 281 console.error(`Failed to write summary for ${msg}`); 282 if (errors < 3) errorDetails.push(msg.substring(0, 200)); 283 errors++; 284 } 285 } 286 287 // Save cursor 288 if (newCursor) { 289 await db 290 .prepare("INSERT OR REPLACE INTO instance_settings (key, value) VALUES ('feed_cursor', ?)") 291 .bind(newCursor) 292 .run(); 293 } 294 295 return c.json({ 296 polled: posts.length, 297 fromFeed: feedPosts.length, 298 fromFollows: followedPosts.length, 299 new: newPosts.length, 300 written, 301 errors, 302 lastError: errors > 0 ? errorDetails[0] : undefined, 303 }); 304 }); 305 306 // Health check 307 app.get("/api/health", async (c) => { 308 await ensureContrailReady(db); 309 let count = 0; 310 try { 311 const row = await db 312 .prepare("SELECT COUNT(*) as cnt FROM records_summary") 313 .first<{ cnt: number }>(); 314 count = row?.cnt || 0; 315 } catch { 316 // table might not exist yet 317 } 318 const hasKey = !!process.env.RESEARCHER_KEY_PEM; 319 return c.json({ ok: true, summaries: count, hasPdsKey: hasKey }); 320 }); 321 322 // Enrich existing summaries using Letta API 323 app.post("/api/enrich", async (c) => { 324 const apiKey = process.env.LETTA_API_KEY; 325 const agentId = process.env.LETTA_AGENT_ID; 326 if (!apiKey || !agentId) { 327 return c.json({ error: "Missing LETTA_API_KEY or LETTA_AGENT_ID" }, 500); 328 } 329 330 await ensureContrailReady(db); 331 332 // Get records that need enrichment 333 const rows = await db 334 .prepare( 335 `SELECT rkey, record FROM records_summary 336 WHERE json_extract(record, '$.summary') = '' 337 OR json_extract(record, '$.summary') LIKE 'Published in%' 338 OR json_extract(record, '$.title') = 'Untitled' 339 OR json_extract(record, '$.takeaway') IS NULL 340 OR json_extract(record, '$.takeaway') = '' 341 OR json_extract(record, '$.year') IS NULL 342 OR json_extract(record, '$.year') = 0 343 OR json_extract(record, '$.publishedDate') IS NULL 344 ORDER BY time_us DESC LIMIT 20` 345 ) 346 .all<{ rkey: string; record: string }>(); 347 348 if (!rows.results?.length) { 349 return c.json({ enriched: 0, message: "No records need enrichment" }); 350 } 351 352 let enriched = 0; 353 let errors = 0; 354 355 // Enrich in parallel batches of 5 356 for (let i = 0; i < rows.results.length; i += 5) { 357 const batch = rows.results.slice(i, i + 5); 358 const results = await Promise.allSettled(batch.map(async (row) => { 359 const record = JSON.parse(row.record) as { 360 title: string; 361 paperUrl: string; 362 abstract: string; 363 domains: string[]; 364 venue: string; 365 summary: string; 366 takeaway?: string; 367 year?: number; 368 }; 369 370 const result = await enrichWithLetta( 371 { 372 title: record.title, 373 paperUrl: record.paperUrl, 374 abstract: record.abstract, 375 domains: record.domains || [], 376 venue: record.venue || "", 377 }, 378 apiKey, 379 agentId, 380 ); 381 382 record.summary = result.summary; 383 record.domains = result.domains; 384 if (result.takeaway) record.takeaway = result.takeaway; 385 if (result.title) record.title = result.title; 386 if (result.year) record.year = result.year; 387 if (result.publishedDate) record.publishedDate = result.publishedDate; 388 389 await db 390 .prepare("UPDATE records_summary SET record = ? WHERE rkey = ?") 391 .bind(JSON.stringify(record), row.rkey) 392 .run(); 393 394 return record.title; 395 })); 396 397 for (const r of results) { 398 if (r.status === "fulfilled") { 399 enriched++; 400 console.log(`Enriched: ${r.value?.substring(0, 50)}...`); 401 } else { 402 console.error(`Enrichment failed: ${r.reason}`); 403 errors++; 404 } 405 } 406 } 407 408 return c.json({ enriched, errors, total: rows.results.length }); 409 }); 410 411 // Crawl endpoint: register a DID and trigger immediate ingest 412 app.post("/xrpc/com.atproto.sync.requestCrawl", async (c) => { 413 let body: { did?: string; hostname?: string }; 414 try { 415 body = await c.req.json(); 416 } catch { 417 body = {}; 418 } 419 const did = body.did; 420 if (!did) { 421 return c.json({ error: "BadRequest", message: "Provide did" }, 400); 422 } 423 424 await ensureContrailReady(db); 425 try { 426 // Ensure identities table has this DID 427 await db.exec( 428 "CREATE TABLE IF NOT EXISTS identities (did TEXT PRIMARY KEY, handle TEXT, time_us INTEGER)" 429 ); 430 await db 431 .prepare("INSERT OR IGNORE INTO identities (did, handle, time_us) VALUES (?, ?, ?)") 432 .bind(did, "researcher.pds.latha.org", Date.now() * 1000) 433 .run(); 434 435 // Ensure records_summary table exists 436 await db.exec( 437 "CREATE TABLE IF NOT EXISTS records_summary (uri TEXT PRIMARY KEY, cid TEXT, did TEXT, rkey TEXT, record TEXT, time_us INTEGER)" 438 ); 439 440 // Fetch records from PDS and insert directly 441 const pdsUrl = `https://pds.latha.org/xrpc/com.atproto.repo.listRecords?repo=${did}&collection=org.latha.papers.summary&limit=100`; 442 const res = await fetch(pdsUrl); 443 if (res.ok) { 444 const data = await res.json() as { 445 records: Array<{ uri: string; cid: string; value: any }>; 446 }; 447 for (const rec of data.records) { 448 const rkey = rec.uri.split("/").pop() || ""; 449 await db 450 .prepare( 451 "INSERT OR REPLACE INTO records_summary (uri, cid, did, rkey, record, time_us) VALUES (?, ?, ?, ?, ?, ?)" 452 ) 453 .bind(rec.uri, rec.cid, did, rkey, JSON.stringify(rec.value), Date.now() * 1000) 454 .run(); 455 } 456 } 457 } catch (err: any) { 458 console.error(`Crawl error: ${err?.message ?? err}`); 459 } 460 461 return c.json({ ok: true, did }); 462 }); 463 464 // All other routes pass through to contrail 465 app.all("*", async (c) => { 466 const response = await contrailWorker.fetch( 467 c.req.raw, 468 c.env as unknown as Record<string, unknown> 469 ); 470 return response; 471 }); 472 473 return app; 474} 475 476export default { 477 fetch(request: Request, env: Env): Response | Promise<Response> { 478 app ??= buildApp(env); 479 return app.fetch(request, env); 480 }, 481 async scheduled(event: ScheduledEvent, env: Env, ctx: ExecutionContext) { 482 // Cron: poll feed and generate summaries 483 const db = env.DB; 484 await ensureContrailReady(db); 485 486 let lastCursor: string | undefined; 487 try { 488 const row = await db 489 .prepare("SELECT value FROM instance_settings WHERE key = 'feed_cursor' LIMIT 1") 490 .first<{ value: string }>(); 491 lastCursor = row?.value || undefined; 492 } catch { 493 // first run 494 } 495 496 const { posts: feedPosts, newCursor } = await pollNewPapers(lastCursor); 497 498 // Also poll followed accounts 499 let followedPosts: PaperPost[] = []; 500 try { 501 followedPosts = await pollFollowedAccounts(5); 502 } catch {} 503 504 // Merge and deduplicate 505 const seenPaperUrls = new Set<string>(); 506 const posts: PaperPost[] = []; 507 for (const p of [...feedPosts, ...followedPosts]) { 508 if (!seenPaperUrls.has(p.paperUrl)) { 509 seenPaperUrls.add(p.paperUrl); 510 posts.push(p); 511 } 512 } 513 514 // Deduplicate against existing records 515 const existingUrls = new Set<string>(); 516 if (posts.length > 0) { 517 const placeholders = posts.map(() => "?").join(","); 518 const urls = posts.map(p => p.paperUrl); 519 const rows = await db 520 .prepare( 521 `SELECT DISTINCT json_extract(record, '$.paperUrl') as paperUrl FROM records_summary WHERE paperUrl IN (${placeholders})` 522 ) 523 .bind(...urls) 524 .all<{ paperUrl: string }>(); 525 for (const r of rows.results || []) { 526 if (r.paperUrl) existingUrls.add(r.paperUrl); 527 } 528 } 529 530 const newPosts = posts.filter(p => !existingUrls.has(p.paperUrl)); 531 const apiKey = process.env.LETTA_API_KEY; 532 const agentId = process.env.LETTA_AGENT_ID; 533 534 // Build heuristic summaries in parallel 535 const heuristicResults = await Promise.all(newPosts.map(async (post) => { 536 try { 537 return { post, summary: await buildSummary(post) }; 538 } catch (err: any) { 539 return { post, error: err?.message ?? err }; 540 } 541 })); 542 543 // Enrich in parallel (batch of 5) 544 const enrichedResults: Array<{ post: PaperPost; summary: PaperSummary; error?: string }> = []; 545 for (let i = 0; i < heuristicResults.length; i += 5) { 546 const batch = heuristicResults.slice(i, i + 5); 547 const batchResults = await Promise.all(batch.map(async ({ post, summary, error }) => { 548 if (error || !summary) return { post, summary: summary!, error }; 549 if (!apiKey || !agentId) return { post, summary }; 550 try { 551 const enrichment = await enrichWithLetta( 552 { title: summary.title, paperUrl: summary.paperUrl, abstract: summary.abstract, domains: summary.domains, venue: summary.venue }, 553 apiKey, 554 agentId, 555 ); 556 if (enrichment.title) summary.title = enrichment.title; 557 summary.summary = enrichment.summary; 558 summary.domains = enrichment.domains; 559 if (enrichment.takeaway) (summary as any).takeaway = enrichment.takeaway; 560 if (enrichment.year) summary.year = enrichment.year; 561 if (enrichment.publishedDate) (summary as any).publishedDate = enrichment.publishedDate; 562 return { post, summary }; 563 } catch { 564 return { post, summary }; 565 } 566 })); 567 enrichedResults.push(...batchResults); 568 } 569 570 // Write all to PDS and D1 571 for (const { post, summary, error } of enrichedResults) { 572 if (error || !summary) { 573 console.error(`Cron: failed to build summary for ${post.paperUrl}: ${error}`); 574 continue; 575 } 576 try { 577 const result = await writeSummaryToPds(post, summary); 578 579 const rkey = result.uri.split("/").pop() || ""; 580 await db 581 .prepare("INSERT OR REPLACE INTO records_summary (uri, cid, did, rkey, record, time_us, indexed_at) VALUES (?, ?, ?, ?, ?, ?, ?)") 582 .bind(result.uri, result.cid, "did:plc:3kkhul7jznlb6ba7rprzawnj", rkey, JSON.stringify({ 583 $type: "org.latha.papers.summary", 584 ...summary, 585 sourceUri: post.postUri, 586 posterDid: post.posterDid, 587 posterHandle: post.posterHandle, 588 postText: post.postText, 589 indexedAt: new Date().toISOString(), 590 }), Date.now() * 1000, Date.now() * 1000) 591 .run(); 592 } catch (err: any) { 593 console.error(`Cron: failed to write summary for ${post.paperUrl}: ${err?.message ?? err}`); 594 } 595 } 596 597 if (newCursor) { 598 await db 599 .prepare("INSERT OR REPLACE INTO instance_settings (key, value) VALUES ('feed_cursor', ?)") 600 .bind(newCursor) 601 .run(); 602 } 603 }, 604};