Add FastAPI web/API layer and static site

- queries.py: shared read-only query helpers (feed, brief, category counts)
  returning plain dicts, used by the API and available to the CLI.
- api.py: FastAPI service with Pydantic response models (the companion-app
  contract), CORS, and endpoints for categories, feed, brief, and health;
  mounts a static site at /.
- static/index.html: minimal dependency-free site rendering the daily five
  and topic/flavor category browsing.
- 'goodnews serve' command launches uvicorn (lazy import; core CLI stays
  pure-stdlib). Web deps live behind the optional [web] extra.
- Dockerfile + .dockerignore + build-system metadata so the service installs
  and deploys cleanly, with the DB mounted as a shared volume.
- README: web/API and deployment docs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
jay
2026-05-30 13:51:07 +00:00
parent b33f58e3e5
commit 2f4bdf2d00
10 changed files with 624 additions and 0 deletions
+206
View File
@@ -0,0 +1,206 @@
"""FastAPI service for goodNews.
A read-only JSON API over the ingestion database, plus a small static site that
consumes it. The same endpoints back both the website and any future companion
app; the auto-generated OpenAPI docs at /docs are that shared contract.
Run with the bundled CLI: goodnews serve
Or directly: uvicorn goodnews.api:app --host 0.0.0.0 --port 8000
The database path comes from GOODNEWS_DB (falling back to the repo's data dir),
so the API and CLI always read the same file.
"""
from __future__ import annotations
import os
import sqlite3
from contextlib import contextmanager
from pathlib import Path
from fastapi import FastAPI, HTTPException, Query
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
from . import queries
from .db import connect, init_db
from .taxonomy import FLAVORS, TOPICS
ROOT = Path(__file__).resolve().parents[1]
DEFAULT_DB = ROOT / "data" / "goodnews.sqlite3"
STATIC_DIR = Path(__file__).resolve().parent / "static"
def db_path() -> Path:
return Path(os.environ.get("GOODNEWS_DB", str(DEFAULT_DB)))
@contextmanager
def get_conn():
conn = connect(db_path())
try:
yield conn
finally:
conn.close()
# --- Response models (the companion-app contract) ---------------------------
class Category(BaseModel):
key: str
description: str
class CategoriesResponse(BaseModel):
topics: list[Category]
flavors: list[Category]
class CategoryCount(BaseModel):
topic: str | None
flavor: str | None
count: int
class Article(BaseModel):
id: int
title: str
description: str | None = None
url: str
image_url: str | None = None
published_at: str | None = None
source: str
topic: str | None = None
flavor: str | None = None
accepted: bool
rank_score: int | None = None
reason_code: str | None = None
reason_text: str | None = None
model_name: str | None = None
rank: int | None = None # position within a brief, when applicable
@classmethod
def from_row(cls, row: dict) -> "Article":
return cls(
id=row["id"],
title=row["title"],
description=row.get("description"),
url=row["canonical_url"],
image_url=row.get("image_url"),
published_at=row.get("published_at"),
source=row["source_name"],
topic=row.get("topic"),
flavor=row.get("flavor"),
accepted=bool(row.get("accepted")),
rank_score=row.get("rank_score"),
reason_code=row.get("reason_code"),
reason_text=row.get("reason_text"),
model_name=row.get("model_name"),
rank=row.get("rank"),
)
class FeedResponse(BaseModel):
topic: str | None
flavor: str | None
count: int
items: list[Article]
class BriefResponse(BaseModel):
brief_date: str | None
title: str | None
items: list[Article]
# --- App --------------------------------------------------------------------
def create_app() -> FastAPI:
app = FastAPI(
title="goodNews API",
version="0.1.0",
description="Constructive, uplifting news — metadata and links only.",
)
# The website and companion app may live on other origins; allow them.
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["GET"],
allow_headers=["*"],
)
@app.get("/healthz")
def healthz() -> dict:
with get_conn() as conn:
init_db(conn)
scored = conn.execute("SELECT COUNT(*) FROM article_scores").fetchone()[0]
return {"status": "ok", "scored_articles": scored}
@app.get("/api/categories", response_model=CategoriesResponse)
def categories() -> CategoriesResponse:
return CategoriesResponse(
topics=[Category(key=k, description=v) for k, v in TOPICS.items()],
flavors=[Category(key=k, description=v) for k, v in FLAVORS.items()],
)
@app.get("/api/category-counts", response_model=list[CategoryCount])
def category_counts(accepted_only: bool = True) -> list[CategoryCount]:
with get_conn() as conn:
rows = queries.category_counts(conn, accepted_only=accepted_only)
return [CategoryCount(**row) for row in rows]
@app.get("/api/feed", response_model=FeedResponse)
def feed(
topic: str | None = Query(None),
flavor: str | None = Query(None),
accepted_only: bool = True,
limit: int = Query(30, ge=1, le=100),
offset: int = Query(0, ge=0),
) -> FeedResponse:
if topic and topic.lower() not in TOPICS:
raise HTTPException(400, f"unknown topic: {topic}")
if flavor and flavor.lower() not in FLAVORS:
raise HTTPException(400, f"unknown flavor: {flavor}")
with get_conn() as conn:
rows = queries.feed(
conn,
topic=topic,
flavor=flavor,
accepted_only=accepted_only,
limit=limit,
offset=offset,
)
return FeedResponse(
topic=topic,
flavor=flavor,
count=len(rows),
items=[Article.from_row(r) for r in rows],
)
@app.get("/api/brief", response_model=BriefResponse)
def brief(date: str | None = Query(None), limit: int = Query(10, ge=1, le=50)) -> BriefResponse:
with get_conn() as conn:
data = queries.brief(conn, brief_date=date, limit=limit)
return BriefResponse(
brief_date=data["brief_date"],
title=data["title"],
items=[Article.from_row(r) for r in data["items"]],
)
@app.get("/api/brief-dates", response_model=list[str])
def brief_dates(limit: int = Query(30, ge=1, le=365)) -> list[str]:
with get_conn() as conn:
return queries.available_dates(conn, limit=limit)
# Static site last, mounted at root, so /api/* and /healthz win.
if STATIC_DIR.is_dir():
app.mount("/", StaticFiles(directory=str(STATIC_DIR), html=True), name="site")
return app
app = create_app()
+25
View File
@@ -1,6 +1,7 @@
from __future__ import annotations
import argparse
import os
import sqlite3
from pathlib import Path
@@ -71,7 +72,17 @@ def main() -> None:
show_brief_parser.add_argument("--date", help="Brief date in YYYY-MM-DD format; defaults to latest brief")
show_brief_parser.add_argument("--limit", type=int, default=10)
serve_parser = subparsers.add_parser("serve", help="Run the web/API server (requires the 'web' extra)")
serve_parser.add_argument("--host", default="127.0.0.1", help="Bind host; use 0.0.0.0 to expose")
serve_parser.add_argument("--port", type=int, default=8000)
serve_parser.add_argument("--reload", action="store_true", help="Auto-reload on code changes (dev)")
args = parser.parse_args()
if args.command == "serve":
serve(args)
return
conn = connect(args.db)
if args.command == "init-db":
@@ -190,6 +201,20 @@ def list_recent(conn: sqlite3.Connection, limit: int, accepted_only: bool) -> No
print(f" {row['canonical_url']}")
def serve(args: argparse.Namespace) -> None:
try:
import uvicorn
except ModuleNotFoundError:
raise SystemExit(
"The web server needs the optional 'web' extra. Install it with:\n"
" pip install -e '.[web]'"
)
# Make sure the API reads the same database the CLI was pointed at.
os.environ.setdefault("GOODNEWS_DB", str(args.db))
print(f"Serving goodNews on http://{args.host}:{args.port} (docs at /docs)")
uvicorn.run("goodnews.api:app", host=args.host, port=args.port, reload=args.reload)
def list_category(
conn: sqlite3.Connection,
topic: str | None,
+141
View File
@@ -0,0 +1,141 @@
"""Read-only query helpers over the goodNews database.
Pure stdlib and framework-agnostic: returns plain dicts so the same functions
back both the CLI and the JSON API. All article output is metadata + a link to
the original source — never stored bodies.
"""
from __future__ import annotations
import sqlite3
# Composite ranking used everywhere a "best first" order is needed. Kept as one
# expression so brief, category feeds, and the API all rank identically.
RANK_SCORE_SQL = (
"(s.constructive_score + s.agency_score + s.human_benefit_score + src.trust_score "
"- s.cortisol_score - s.ragebait_score - s.pr_risk_score)"
)
_ARTICLE_COLUMNS = f"""
a.id,
a.title,
a.description,
a.canonical_url,
a.published_at,
a.image_url,
src.name AS source_name,
s.topic,
s.flavor,
s.accepted,
s.constructive_score,
s.cortisol_score,
s.ragebait_score,
s.agency_score,
s.human_benefit_score,
s.pr_risk_score,
s.reason_code,
s.reason_text,
s.model_name,
{RANK_SCORE_SQL} AS rank_score
"""
def feed(
conn: sqlite3.Connection,
topic: str | None = None,
flavor: str | None = None,
accepted_only: bool = True,
limit: int = 30,
offset: int = 0,
) -> list[dict]:
"""Return ranked articles, optionally filtered by topic and/or flavor."""
clauses = []
params: list = []
if accepted_only:
clauses.append("s.accepted = 1")
if topic:
clauses.append("s.topic = ?")
params.append(topic.lower())
if flavor:
clauses.append("s.flavor = ?")
params.append(flavor.lower())
where = ("WHERE " + " AND ".join(clauses)) if clauses else ""
params.extend([limit, offset])
rows = conn.execute(
f"""
SELECT {_ARTICLE_COLUMNS}
FROM articles a
JOIN sources src ON src.id = a.source_id
JOIN article_scores s ON s.article_id = a.id
{where}
ORDER BY rank_score DESC, COALESCE(a.published_at, a.discovered_at) DESC
LIMIT ? OFFSET ?
""",
params,
).fetchall()
return [dict(row) for row in rows]
def brief(conn: sqlite3.Connection, brief_date: str | None = None, limit: int = 10) -> dict:
"""Return a stored daily brief (latest if no date) with its ranked items."""
target_date = brief_date or _latest_brief_date(conn)
if not target_date:
return {"brief_date": None, "title": None, "items": []}
header = conn.execute(
"SELECT brief_date, title FROM daily_briefs WHERE brief_date = ?",
(target_date,),
).fetchone()
if not header:
return {"brief_date": target_date, "title": None, "items": []}
rows = conn.execute(
f"""
SELECT bi.rank, bi.selection_reason, {_ARTICLE_COLUMNS}
FROM daily_briefs b
JOIN daily_brief_items bi ON bi.brief_id = b.id
JOIN articles a ON a.id = bi.article_id
JOIN sources src ON src.id = a.source_id
LEFT JOIN article_scores s ON s.article_id = a.id
WHERE b.brief_date = ?
ORDER BY bi.rank
LIMIT ?
""",
(target_date, limit),
).fetchall()
return {
"brief_date": header["brief_date"],
"title": header["title"],
"items": [dict(row) for row in rows],
}
def category_counts(conn: sqlite3.Connection, accepted_only: bool = True) -> list[dict]:
"""Return per topic/flavor article counts for building browse UIs."""
where = "WHERE s.accepted = 1" if accepted_only else "WHERE s.topic IS NOT NULL"
rows = conn.execute(
f"""
SELECT s.topic, s.flavor, COUNT(*) AS count
FROM article_scores s
{where}
GROUP BY s.topic, s.flavor
ORDER BY s.topic, s.flavor
"""
).fetchall()
return [dict(row) for row in rows]
def available_dates(conn: sqlite3.Connection, limit: int = 30) -> list[str]:
rows = conn.execute(
"SELECT brief_date FROM daily_briefs ORDER BY brief_date DESC LIMIT ?",
(limit,),
).fetchall()
return [row["brief_date"] for row in rows]
def _latest_brief_date(conn: sqlite3.Connection) -> str | None:
row = conn.execute(
"SELECT brief_date FROM daily_briefs ORDER BY brief_date DESC LIMIT 1"
).fetchone()
return row["brief_date"] if row else None
+163
View File
@@ -0,0 +1,163 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>goodNews — calm, constructive news</title>
<style>
:root {
--bg: #f7f5f0; --card: #fff; --ink: #1f2a24; --muted: #6b7770;
--accent: #2f7d5b; --accent-soft: #e4efe8; --line: #e3e0d8;
}
* { box-sizing: border-box; }
body {
margin: 0; background: var(--bg); color: var(--ink);
font: 16px/1.55 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
}
header {
padding: 28px 20px 18px; text-align: center; border-bottom: 1px solid var(--line);
background: var(--card);
}
header h1 { margin: 0; font-size: 1.7rem; letter-spacing: -0.02em; }
header h1 span { color: var(--accent); }
header p { margin: 6px 0 0; color: var(--muted); font-size: 0.95rem; }
main { max-width: 760px; margin: 0 auto; padding: 20px; }
.chips { display: flex; flex-wrap: wrap; gap: 8px; margin: 6px 0 18px; }
.chip {
border: 1px solid var(--line); background: var(--card); color: var(--ink);
border-radius: 999px; padding: 6px 13px; font-size: 0.85rem; cursor: pointer;
transition: all .12s ease;
}
.chip:hover { border-color: var(--accent); }
.chip.active { background: var(--accent); border-color: var(--accent); color: #fff; }
.chip-group-label { font-size: 0.72rem; text-transform: uppercase; letter-spacing: .08em;
color: var(--muted); margin: 4px 0; width: 100%; }
.section-title { font-size: 0.95rem; color: var(--muted); margin: 22px 0 10px;
text-transform: uppercase; letter-spacing: .06em; }
article {
background: var(--card); border: 1px solid var(--line); border-radius: 12px;
padding: 16px 18px; margin-bottom: 12px;
}
article .meta { display: flex; gap: 8px; align-items: center; flex-wrap: wrap;
font-size: 0.78rem; color: var(--muted); margin-bottom: 6px; }
.tag { background: var(--accent-soft); color: var(--accent); border-radius: 6px;
padding: 1px 8px; font-weight: 600; }
article h3 { margin: 0 0 6px; font-size: 1.08rem; line-height: 1.35; }
article h3 a { color: var(--ink); text-decoration: none; }
article h3 a:hover { color: var(--accent); }
article p.desc { margin: 0 0 8px; color: #44514a; font-size: 0.92rem; }
article .why { font-size: 0.8rem; color: var(--muted); font-style: italic; }
.rank-badge { background: var(--accent); color: #fff; border-radius: 50%;
width: 24px; height: 24px; display: inline-flex; align-items: center;
justify-content: center; font-size: 0.8rem; font-weight: 700; }
.empty { color: var(--muted); text-align: center; padding: 30px; }
footer { text-align: center; color: var(--muted); font-size: 0.78rem; padding: 20px; }
footer a { color: var(--accent); }
</style>
</head>
<body>
<header>
<h1>good<span>News</span></h1>
<p>Calm, constructive news worth your attention — and nothing that isn't.</p>
</header>
<main>
<div id="brief-block">
<div class="section-title">Five Good Things</div>
<div id="brief"><div class="empty">Loading…</div></div>
</div>
<div class="section-title">Browse by category</div>
<div id="topic-chips" class="chips"></div>
<div id="flavor-chips" class="chips"></div>
<div id="feed"></div>
</main>
<footer>
goodNews · metadata &amp; links only, no stored articles ·
<a href="/docs">API</a>
</footer>
<script>
const state = { topic: null, flavor: null };
const el = (id) => document.getElementById(id);
async function getJSON(url) {
const r = await fetch(url);
if (!r.ok) throw new Error(await r.text());
return r.json();
}
function articleCard(a, showRank) {
const rank = showRank && a.rank ? `<span class="rank-badge">${a.rank}</span>` : "";
const tags = [a.topic, a.flavor].filter(Boolean)
.map(t => `<span class="tag">${t}</span>`).join(" ");
const desc = a.description ? `<p class="desc">${a.description}</p>` : "";
const why = a.reason_text ? `<div class="why">${a.reason_text}</div>` : "";
return `<article>
<div class="meta">${rank}${tags}<span>${a.source}</span>
${a.published_at ? `<span>· ${a.published_at.slice(0,10)}</span>` : ""}</div>
<h3><a href="${a.url}" target="_blank" rel="noopener">${a.title}</a></h3>
${desc}${why}
</article>`;
}
function renderList(target, items, showRank) {
target.innerHTML = items.length
? items.map(a => articleCard(a, showRank)).join("")
: `<div class="empty">Nothing here yet.</div>`;
}
function chip(label, active, onClick) {
const c = document.createElement("button");
c.className = "chip" + (active ? " active" : "");
c.textContent = label;
c.onclick = onClick;
return c;
}
async function loadBrief() {
try {
const b = await getJSON("/api/brief?limit=5");
if (b.title) el("brief-block").querySelector(".section-title").textContent = b.title;
renderList(el("brief"), b.items, true);
} catch (e) { el("brief").innerHTML = `<div class="empty">No brief yet.</div>`; }
}
async function loadFeed() {
const params = new URLSearchParams({ limit: "30" });
if (state.topic) params.set("topic", state.topic);
if (state.flavor) params.set("flavor", state.flavor);
const f = await getJSON("/api/feed?" + params.toString());
renderList(el("feed"), f.items, false);
}
async function init() {
loadBrief();
const cats = await getJSON("/api/categories");
const tc = el("topic-chips");
tc.appendChild(Object.assign(document.createElement("span"),
{ className: "chip-group-label", textContent: "Topic" }));
tc.appendChild(chip("all", true, () => setTopic(null)));
cats.topics.forEach(t => tc.appendChild(chip(t.key, false, () => setTopic(t.key))));
const fc = el("flavor-chips");
fc.appendChild(Object.assign(document.createElement("span"),
{ className: "chip-group-label", textContent: "Flavor" }));
fc.appendChild(chip("all", true, () => setFlavor(null)));
cats.flavors.forEach(f => fc.appendChild(chip(f.key, false, () => setFlavor(f.key))));
loadFeed();
}
function refreshActive(containerId, value) {
[...el(containerId).querySelectorAll(".chip")].forEach(c => {
c.classList.toggle("active", c.textContent === (value || "all"));
});
}
function setTopic(t) { state.topic = t; refreshActive("topic-chips", t); loadFeed(); }
function setFlavor(f) { state.flavor = f; refreshActive("flavor-chips", f); loadFeed(); }
init();
</script>
</body>
</html>