Paywall awareness (#6) + replace-an-article (#7)

- paywall.py: conservative domain-level paywall detection (New Scientist,
  Nature, and common hard/soft paywalls). Never fetches pages — an honest hint.
- API: Article gains a 'paywalled' flag; the brief now leads with a gentle AND
  readable story (paywalled/charged stories stay in the five, just not first).
- New GET /api/replacement returns the next-best readable, unshown article
  (honors mood+prefs via the merged prefs param; gentle=true for hero swaps).
- UI: paywalled cards show 'May need a subscription'; a Replace / 'Find one I
  can read' action (always visible, while tuning actions stay tucked) swaps the
  card for a readable alternative, with a gentle notice when none remain.
- Tests: paywall detection + replacement behavior (77 total).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
jay
2026-05-31 00:39:13 +00:00
parent 06c2704ae0
commit bfd612eb9b
7 changed files with 226 additions and 16 deletions
+57 -5
View File
@@ -30,9 +30,10 @@ from pydantic import BaseModel
from . import feeds, queries
from .db import connect, init_db
from .filters import filter_articles, prefs_from_json
from .hero import lead_with_gentle
from .hero import safe_to_lead
from .llm import LocalModelClient
from .moods import MOODS
from .moods import MOODS, mood_filter
from .paywall import is_paywalled
from .taxonomy import FLAVORS, TOPICS
ROOT = Path(__file__).resolve().parents[1]
@@ -56,6 +57,22 @@ def get_conn():
conn.close()
def _pick_lead(items: list[dict]) -> list[dict]:
"""Lead with a gentle, readable story when possible.
Tries gentle-and-readable first, then gentle, then leaves the order alone.
Charged or paywalled stories still appear in the set — they just don't lead.
"""
for ok in (
lambda a: safe_to_lead(a) and not is_paywalled(a.get("canonical_url")),
safe_to_lead,
):
for i, a in enumerate(items):
if ok(a):
return items if i == 0 else [a, *items[:i], *items[i + 1:]]
return items
# --- Response models (the companion-app contract) ---------------------------
@@ -91,6 +108,7 @@ class Article(BaseModel):
reason_text: str | None = None
model_name: str | None = None
rank: int | None = None # position within a brief, when applicable
paywalled: bool = False
@classmethod
def from_row(cls, row: dict) -> "Article":
@@ -110,6 +128,7 @@ class Article(BaseModel):
reason_text=row.get("reason_text"),
model_name=row.get("model_name"),
rank=row.get("rank"),
paywalled=is_paywalled(row.get("canonical_url")),
)
@@ -283,9 +302,9 @@ def create_app() -> FastAPI:
# MVP: filter the stored brief DOWN; no refill from outside the brief.
# Runs before hero selection, so personal avoid-terms take precedence.
items = filter_articles(items, fp, datetime.now(timezone.utc))
# Lead with an emotionally-safe story (constructive-but-charged stories
# stay in the five, just not as the first thing seen).
items = lead_with_gentle(items)
# Lead with a gentle, readable story (charged or paywalled stories stay
# in the five, just not as the first thing seen).
items = _pick_lead(items)
return BriefResponse(
brief_date=data["brief_date"],
title=data["title"],
@@ -297,6 +316,39 @@ def create_app() -> FastAPI:
with get_conn() as conn:
return queries.available_dates(conn, limit=limit)
@app.get("/api/replacement", response_model=Article | None)
def replacement(
exclude: str = Query("", description="comma-separated article ids already shown"),
prefs: str | None = Query(None),
avoid_paywall: bool = True,
gentle: bool = Query(False, description="also require lead-safe (for replacing the hero)"),
) -> Article | None:
# Swap a read or paywalled item for the next-best one the reader can
# actually open. The client merges any active mood into `prefs` (same as
# the feed), so this needs no mood param.
fp = prefs_from_json(prefs)
excl = {int(x) for x in exclude.split(",") if x.strip().lstrip("-").isdigit()}
now = datetime.now(timezone.utc)
kw = dict(
include_topics=fp.include_topics or None,
include_flavors=fp.include_flavors or None,
mute_topics=list(fp.muted_topics(now)) or None,
mute_flavors=list(fp.muted_flavors(now)) or None,
max_cortisol=fp.max_cortisol,
max_ragebait=fp.max_ragebait,
)
with get_conn() as conn:
rows = queries.feed(conn, accepted_only=True, limit=120, offset=0, **kw)
for r in filter_articles(rows, fp, now):
if r["id"] in excl:
continue
if avoid_paywall and is_paywalled(r["canonical_url"]):
continue
if gentle and not safe_to_lead(r):
continue
return Article.from_row(r)
return None
@app.get("/api/candidates", response_model=list[Candidate])
def candidates(status: str | None = Query(None)) -> list[Candidate]:
from .sources import list_candidates