Share pages: lazy, cached, our-own-words article summaries
The /a/<id> page now carries an original short summary so it stands on its own,
without republishing the publisher's article:
- summarize.py: transient SSRF-guarded fetch of the article text → local LLM
writes a 2-4 sentence ORIGINAL summary (our words). Cached in article_summaries
forever; we store only our summary, never the body. Generated lazily (only for
shared/viewed articles), de-duped so concurrent hits don't double-generate.
- /a serves cached-or-pending; when pending it shows a calm "summary on its way,
read at {source}" note and self-polls /api/summary/<id>, swapping the summary
in the moment it's ready (never blocks the page on the batch-tier LLM).
- Share menu warms generation on open so recipients usually get the rich version.
- Container reaches the arbiter at arbiter:8080 over caddy_web (LLM env added to
the API container). 124 tests pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+40
-6
@@ -31,7 +31,7 @@ from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from pydantic import BaseModel
|
||||
|
||||
from . import auth, email_send, feeds, oauth_google, queries, share
|
||||
from . import auth, email_send, feeds, oauth_google, queries, share, summarize
|
||||
from .db import connect
|
||||
from .filters import filter_articles, prefs_from_json
|
||||
from .hero import safe_to_lead
|
||||
@@ -113,6 +113,28 @@ def _user_out(user: sqlite3.Row) -> dict:
|
||||
}
|
||||
|
||||
|
||||
# Articles whose summary is being generated right now — so concurrent pollers /
|
||||
# scrapers don't each kick off a duplicate LLM call.
|
||||
_summarizing: set[int] = set()
|
||||
|
||||
|
||||
def _run_summary(article_id: int) -> None:
|
||||
try:
|
||||
with get_conn() as conn:
|
||||
summarize.generate_summary(conn, article_id)
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
_summarizing.discard(article_id)
|
||||
|
||||
|
||||
def _kick_summary(article_id: int, background_tasks: BackgroundTasks) -> None:
|
||||
if article_id in _summarizing:
|
||||
return
|
||||
_summarizing.add(article_id)
|
||||
background_tasks.add_task(_run_summary, article_id)
|
||||
|
||||
|
||||
def _send_link_safe(email: str, link: str) -> None:
|
||||
"""Send the magic link, swallowing failures (runs off the request path)."""
|
||||
try:
|
||||
@@ -622,7 +644,7 @@ def create_app() -> FastAPI:
|
||||
# --- Public share/landing page for an article -------------------------
|
||||
|
||||
@app.get("/a/{article_id}", response_class=HTMLResponse)
|
||||
def share_page(article_id: str) -> HTMLResponse:
|
||||
def share_page(article_id: str, background_tasks: BackgroundTasks) -> HTMLResponse:
|
||||
not_found = HTMLResponse(share.render_not_found(PUBLIC_BASE_URL), status_code=404)
|
||||
try:
|
||||
aid = int(article_id)
|
||||
@@ -636,10 +658,22 @@ def create_app() -> FastAPI:
|
||||
"LEFT JOIN article_scores s ON s.article_id = a.id WHERE a.id = ?",
|
||||
(aid,),
|
||||
).fetchone()
|
||||
# Only render real, accepted, non-duplicate stories.
|
||||
if not row or row["duplicate_of"] is not None or not row["accepted"]:
|
||||
return not_found
|
||||
return HTMLResponse(share.render_share_page(dict(row), PUBLIC_BASE_URL))
|
||||
# Only render real, accepted, non-duplicate stories.
|
||||
if not row or row["duplicate_of"] is not None or not row["accepted"]:
|
||||
return not_found
|
||||
summary = summarize.get_summary(conn, aid)
|
||||
if not summary:
|
||||
_kick_summary(aid, background_tasks) # generate for next time; page polls
|
||||
return HTMLResponse(share.render_share_page(dict(row), PUBLIC_BASE_URL, summary=summary))
|
||||
|
||||
@app.get("/api/summary/{article_id}")
|
||||
def article_summary(article_id: int, background_tasks: BackgroundTasks) -> dict:
|
||||
with get_conn() as conn:
|
||||
summary = summarize.get_summary(conn, article_id)
|
||||
if summary:
|
||||
return {"status": "ready", "summary": summary}
|
||||
_kick_summary(article_id, background_tasks)
|
||||
return {"status": "pending", "summary": None}
|
||||
|
||||
@app.post("/api/import")
|
||||
def import_local(body: ImportBody, request: Request) -> dict:
|
||||
|
||||
Reference in New Issue
Block a user