This repository has no description
0

Configure Feed

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

papers / src / worker.ts
13 kB 436 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 } from "./feed-watcher.js"; 15import { buildSummary, enrichWithLetta } 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 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 authors: value?.authors || [], 68 summary: value?.summary || "", 69 domains: value?.domains || [], 70 posterHandle: value?.posterHandle || "", 71 indexedAt: value?.indexedAt || "", 72 }; 73 } catch { 74 return null; 75 } 76 }).filter(Boolean); 77 } catch { 78 // empty list is fine 79 } 80 81 const page = LANDING_PAGE.replace( 82 "</head>", 83 `<script>window.__SUMMARIES__=${JSON.stringify(summaries)};</script></head>` 84 ); 85 return c.html(page); 86 }); 87 88 // Summary detail page 89 app.get("/paper/:rkey", async (c) => { 90 const rkey = c.req.param("rkey"); 91 await ensureContrailReady(db); 92 93 let summary: any = null; 94 try { 95 const row = await db 96 .prepare("SELECT uri, record, did, rkey FROM records_summary WHERE rkey = ? LIMIT 1") 97 .bind(rkey) 98 .first<{ uri: string; record: string; did: string; rkey: string }>(); 99 if (row) { 100 const value = JSON.parse(row.record); 101 summary = { 102 uri: row.uri, 103 rkey: row.rkey, 104 ...value, 105 }; 106 } 107 } catch { 108 // not found 109 } 110 111 if (!summary) { 112 return c.html("<h1>Not Found</h1>", 404); 113 } 114 115 const page = SUMMARY_PAGE.replace( 116 "</head>", 117 `<script>window.__SUMMARY__=${JSON.stringify(summary)};</script></head>` 118 ); 119 return c.html(page); 120 }); 121 122 // API: list summaries as JSON 123 app.get("/api/summaries", async (c) => { 124 await ensureContrailReady(db); 125 const limit = Math.min(Number(c.req.query("limit") || 50), 200); 126 const cursor = c.req.query("cursor"); 127 128 let rows: D1Result<any>; 129 if (cursor) { 130 rows = await db 131 .prepare("SELECT uri, record, did, rkey, time_us FROM records_summary WHERE time_us < ? ORDER BY time_us DESC LIMIT ?") 132 .bind(cursor, limit) 133 .all(); 134 } else { 135 rows = await db 136 .prepare("SELECT uri, record, did, rkey, time_us FROM records_summary ORDER BY time_us DESC LIMIT ?") 137 .bind(limit) 138 .all(); 139 } 140 141 const summaries = (rows.results || []).map((r: any) => { 142 try { 143 const value = JSON.parse(r.record); 144 return { uri: r.uri, rkey: r.rkey, ...value }; 145 } catch { 146 return null; 147 } 148 }).filter(Boolean); 149 150 const lastRow = rows.results?.[rows.results.length - 1]; 151 const nextCursor = lastRow?.time_us || undefined; 152 153 return c.json({ summaries, cursor: nextCursor }); 154 }); 155 156 // API: trigger feed poll + summary generation 157 app.post("/api/poll", async (c) => { 158 await ensureContrailReady(db); 159 160 // Get last cursor from D1 161 let lastCursor: string | undefined; 162 try { 163 const row = await db 164 .prepare("SELECT value FROM instance_settings WHERE key = 'feed_cursor' LIMIT 1") 165 .first<{ value: string }>(); 166 lastCursor = row?.value || undefined; 167 } catch { 168 // first run 169 } 170 171 const { posts, newCursor } = await pollNewPapers(lastCursor); 172 173 // Deduplicate against existing records 174 const existingUrls = new Set<string>(); 175 if (posts.length > 0) { 176 const placeholders = posts.map(() => "?").join(","); 177 const urls = posts.map(p => p.paperUrl); 178 const rows = await db 179 .prepare( 180 `SELECT DISTINCT json_extract(record, '$.paperUrl') as paperUrl FROM records_summary WHERE paperUrl IN (${placeholders})` 181 ) 182 .bind(...urls) 183 .all<{ paperUrl: string }>(); 184 for (const r of rows.results || []) { 185 if (r.paperUrl) existingUrls.add(r.paperUrl); 186 } 187 } 188 189 const newPosts = posts.filter(p => !existingUrls.has(p.paperUrl)); 190 let written = 0; 191 let errors = 0; 192 193 for (const post of newPosts) { 194 try { 195 const summary = await buildSummary(post); 196 const result = await writeSummaryToPds(post, summary); 197 198 // Notify contrail to index the new record 199 await contrail.notify(result.uri); 200 written++; 201 } catch (err: any) { 202 console.error(`Failed to write summary for ${post.paperUrl}: ${err?.message ?? err}`); 203 errors++; 204 } 205 } 206 207 // Save cursor 208 if (newCursor) { 209 await db 210 .prepare("INSERT OR REPLACE INTO instance_settings (key, value) VALUES ('feed_cursor', ?)") 211 .bind(newCursor) 212 .run(); 213 } 214 215 return c.json({ 216 polled: posts.length, 217 new: newPosts.length, 218 written, 219 errors, 220 }); 221 }); 222 223 // Health check 224 app.get("/api/health", async (c) => { 225 await ensureContrailReady(db); 226 let count = 0; 227 try { 228 const row = await db 229 .prepare("SELECT COUNT(*) as cnt FROM records_summary") 230 .first<{ cnt: number }>(); 231 count = row?.cnt || 0; 232 } catch { 233 // table might not exist yet 234 } 235 return c.json({ ok: true, summaries: count }); 236 }); 237 238 // Enrich existing summaries using Letta API 239 app.post("/api/enrich", async (c) => { 240 const apiKey = process.env.LETTA_API_KEY; 241 const agentId = process.env.LETTA_AGENT_ID; 242 if (!apiKey || !agentId) { 243 return c.json({ error: "Missing LETTA_API_KEY or LETTA_AGENT_ID" }, 500); 244 } 245 246 await ensureContrailReady(db); 247 248 // Get records that need enrichment (empty/placeholder summaries, no takeaway, or Untitled) 249 const rows = await db 250 .prepare( 251 `SELECT rkey, record FROM records_summary 252 WHERE json_extract(record, '$.summary') = '' 253 OR json_extract(record, '$.summary') LIKE 'Published in%' 254 OR json_extract(record, '$.title') = 'Untitled' 255 OR json_extract(record, '$.takeaway') IS NULL 256 OR json_extract(record, '$.takeaway') = '' 257 ORDER BY time_us DESC LIMIT 20` 258 ) 259 .all<{ rkey: string; record: string }>(); 260 261 if (!rows.results?.length) { 262 return c.json({ enriched: 0, message: "No records need enrichment" }); 263 } 264 265 let enriched = 0; 266 let errors = 0; 267 268 for (const row of rows.results) { 269 try { 270 const record = JSON.parse(row.record) as { 271 title: string; 272 paperUrl: string; 273 abstract: string; 274 domains: string[]; 275 venue: string; 276 summary: string; 277 takeaway?: string; 278 }; 279 280 const result = await enrichWithLetta( 281 { 282 title: record.title, 283 paperUrl: record.paperUrl, 284 abstract: record.abstract, 285 domains: record.domains || [], 286 venue: record.venue || "", 287 }, 288 apiKey, 289 agentId, 290 ); 291 292 // Update the record in D1 293 record.summary = result.summary; 294 record.domains = result.domains; 295 if (result.takeaway) record.takeaway = result.takeaway; 296 if (result.title) record.title = result.title; 297 298 await db 299 .prepare("UPDATE records_summary SET record = ? WHERE rkey = ?") 300 .bind(JSON.stringify(record), row.rkey) 301 .run(); 302 303 enriched++; 304 console.log(`Enriched: ${record.title?.substring(0, 50)}...`); 305 } catch (err: any) { 306 console.error(`Enrichment failed for ${row.rkey}: ${err?.message ?? err}`); 307 errors++; 308 } 309 } 310 311 return c.json({ enriched, errors, total: rows.results.length }); 312 }); 313 314 // Crawl endpoint: register a DID and trigger immediate ingest 315 app.post("/xrpc/com.atproto.sync.requestCrawl", async (c) => { 316 let body: { did?: string; hostname?: string }; 317 try { 318 body = await c.req.json(); 319 } catch { 320 body = {}; 321 } 322 const did = body.did; 323 if (!did) { 324 return c.json({ error: "BadRequest", message: "Provide did" }, 400); 325 } 326 327 await ensureContrailReady(db); 328 try { 329 // Ensure identities table has this DID 330 await db.exec( 331 "CREATE TABLE IF NOT EXISTS identities (did TEXT PRIMARY KEY, handle TEXT, time_us INTEGER)" 332 ); 333 await db 334 .prepare("INSERT OR IGNORE INTO identities (did, handle, time_us) VALUES (?, ?, ?)") 335 .bind(did, "researcher.pds.latha.org", Date.now() * 1000) 336 .run(); 337 338 // Ensure records_summary table exists 339 await db.exec( 340 "CREATE TABLE IF NOT EXISTS records_summary (uri TEXT PRIMARY KEY, cid TEXT, did TEXT, rkey TEXT, record TEXT, time_us INTEGER)" 341 ); 342 343 // Fetch records from PDS and insert directly 344 const pdsUrl = `https://pds.latha.org/xrpc/com.atproto.repo.listRecords?repo=${did}&collection=org.latha.papers.summary&limit=100`; 345 const res = await fetch(pdsUrl); 346 if (res.ok) { 347 const data = await res.json() as { 348 records: Array<{ uri: string; cid: string; value: any }>; 349 }; 350 for (const rec of data.records) { 351 const rkey = rec.uri.split("/").pop() || ""; 352 await db 353 .prepare( 354 "INSERT OR REPLACE INTO records_summary (uri, cid, did, rkey, record, time_us) VALUES (?, ?, ?, ?, ?, ?)" 355 ) 356 .bind(rec.uri, rec.cid, did, rkey, JSON.stringify(rec.value), Date.now() * 1000) 357 .run(); 358 } 359 } 360 } catch (err: any) { 361 console.error(`Crawl error: ${err?.message ?? err}`); 362 } 363 364 return c.json({ ok: true, did }); 365 }); 366 367 // All other routes pass through to contrail 368 app.all("*", async (c) => { 369 const response = await contrailWorker.fetch( 370 c.req.raw, 371 c.env as unknown as Record<string, unknown> 372 ); 373 return response; 374 }); 375 376 return app; 377} 378 379export default { 380 fetch(request: Request, env: Env): Response | Promise<Response> { 381 app ??= buildApp(env); 382 return app.fetch(request, env); 383 }, 384 async scheduled(event: ScheduledEvent, env: Env, ctx: ExecutionContext) { 385 // Cron: poll feed and generate summaries 386 const db = env.DB; 387 await ensureContrailReady(db); 388 389 let lastCursor: string | undefined; 390 try { 391 const row = await db 392 .prepare("SELECT value FROM instance_settings WHERE key = 'feed_cursor' LIMIT 1") 393 .first<{ value: string }>(); 394 lastCursor = row?.value || undefined; 395 } catch { 396 // first run 397 } 398 399 const { posts, newCursor } = await pollNewPapers(lastCursor); 400 401 // Deduplicate 402 const existingUrls = new Set<string>(); 403 if (posts.length > 0) { 404 const placeholders = posts.map(() => "?").join(","); 405 const urls = posts.map(p => p.paperUrl); 406 const rows = await db 407 .prepare( 408 `SELECT DISTINCT json_extract(record, '$.paperUrl') as paperUrl FROM records_summary WHERE paperUrl IN (${placeholders})` 409 ) 410 .bind(...urls) 411 .all<{ paperUrl: string }>(); 412 for (const r of rows.results || []) { 413 if (r.paperUrl) existingUrls.add(r.paperUrl); 414 } 415 } 416 417 const newPosts = posts.filter(p => !existingUrls.has(p.paperUrl)); 418 419 for (const post of newPosts) { 420 try { 421 const summary = await buildSummary(post); 422 const result = await writeSummaryToPds(post, summary); 423 await contrail.notify(result.uri); 424 } catch (err: any) { 425 console.error(`Cron: failed to write summary for ${post.paperUrl}: ${err?.message ?? err}`); 426 } 427 } 428 429 if (newCursor) { 430 await db 431 .prepare("INSERT OR REPLACE INTO instance_settings (key, value) VALUES ('feed_cursor', ?)") 432 .bind(newCursor) 433 .run(); 434 } 435 }, 436};