Follow source/topic — account-backed personalization (v1)
Per Codex — turn accounts into a real reason to return, without an algorithmic feed. Durable interests (sources + tags), not moods. * DB: user_follows (user_id, kind source|tag, value, unique). * queries.feed gains follow_sources/follow_tags → the Following feed is "articles from a followed source OR carrying a followed tag", still respecting calm filters/boundaries. * API: GET/POST/DELETE /api/follows (sign-in required; source ids validated); /api/feed?following=true resolves the user's follows (anon → empty, not error). * Frontend: follows store (followKeys + toggleFollow, mirrors savedIds); a Follow button on source + tag/topic views; a "Following" lane in the nav with a tailored empty state; a Following management section in Account (unfollow). Digest "From what you follow" deferred to v2 (brief stays first). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -415,6 +415,11 @@ class DigestBody(BaseModel):
|
||||
enabled: bool = True
|
||||
|
||||
|
||||
class FollowBody(BaseModel):
|
||||
kind: str # 'source' | 'tag'
|
||||
value: str # source id (as text) or tag key
|
||||
|
||||
|
||||
class SourceReviewBody(BaseModel):
|
||||
flag: bool = False
|
||||
reason: str | None = None
|
||||
@@ -640,6 +645,62 @@ def create_app() -> FastAPI:
|
||||
user = _require_user(conn, request)
|
||||
return queries.saved_ids(conn, user["id"])
|
||||
|
||||
# --- Follows: durable source / tag interests (require sign-in) ---
|
||||
|
||||
def _follows_for(conn, user_id: int) -> list[dict]:
|
||||
rows = conn.execute(
|
||||
"SELECT kind, value FROM user_follows WHERE user_id = ? ORDER BY created_at DESC", (user_id,)
|
||||
).fetchall()
|
||||
out = []
|
||||
for r in rows:
|
||||
d = {"kind": r["kind"], "value": r["value"], "name": r["value"]}
|
||||
if r["kind"] == "source" and r["value"].isdigit():
|
||||
src = conn.execute("SELECT name FROM sources WHERE id = ?", (int(r["value"]),)).fetchone()
|
||||
if src:
|
||||
d["name"] = src["name"]
|
||||
out.append(d)
|
||||
return out
|
||||
|
||||
@app.get("/api/follows")
|
||||
def follows_list(request: Request) -> list[dict]:
|
||||
with get_conn() as conn:
|
||||
user = _require_user(conn, request)
|
||||
return _follows_for(conn, user["id"])
|
||||
|
||||
@app.post("/api/follows")
|
||||
def follow_add(body: FollowBody, request: Request) -> dict:
|
||||
if body.kind not in ("source", "tag"):
|
||||
raise HTTPException(status_code=422, detail="kind must be 'source' or 'tag'")
|
||||
value = (body.value or "").strip()
|
||||
if body.kind == "tag":
|
||||
value = value.lower()
|
||||
if not value:
|
||||
raise HTTPException(status_code=422, detail="value is required")
|
||||
with get_conn() as conn:
|
||||
user = _require_user(conn, request)
|
||||
if body.kind == "source":
|
||||
if not value.isdigit() or not conn.execute(
|
||||
"SELECT 1 FROM sources WHERE id = ?", (int(value),)
|
||||
).fetchone():
|
||||
raise HTTPException(status_code=404, detail="source not found")
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO user_follows (user_id, kind, value) VALUES (?, ?, ?)",
|
||||
(user["id"], body.kind, value),
|
||||
)
|
||||
conn.commit()
|
||||
return {"ok": True, "kind": body.kind, "value": value}
|
||||
|
||||
@app.delete("/api/follows")
|
||||
def follow_remove(request: Request, kind: str = Query(...), value: str = Query(...)) -> dict:
|
||||
v = value.strip().lower() if kind == "tag" else value.strip()
|
||||
with get_conn() as conn:
|
||||
user = _require_user(conn, request)
|
||||
conn.execute(
|
||||
"DELETE FROM user_follows WHERE user_id = ? AND kind = ? AND value = ?", (user["id"], kind, v)
|
||||
)
|
||||
conn.commit()
|
||||
return {"ok": True}
|
||||
|
||||
@app.post("/api/saved/{article_id}")
|
||||
def save_article(article_id: int, request: Request) -> dict:
|
||||
with get_conn() as conn:
|
||||
@@ -1309,6 +1370,8 @@ def create_app() -> FastAPI:
|
||||
tag: str | None = Query(None, description="grouping tag to browse"),
|
||||
source_id: int | None = Query(None, ge=1, description="show only this source's articles"),
|
||||
sort: str = Query("ranked", pattern="^(ranked|latest)$", description="ranked (best-first) or latest (newest-first)"),
|
||||
following: bool = Query(False, description="restrict to the signed-in user's followed sources/tags"),
|
||||
request: Request = None,
|
||||
) -> FeedResponse:
|
||||
if topic and topic.lower() not in TOPICS:
|
||||
raise HTTPException(400, f"unknown topic: {topic}")
|
||||
@@ -1322,6 +1385,15 @@ def create_app() -> FastAPI:
|
||||
# word-boundary avoid-terms and dismissals need a Python pass.
|
||||
kw = _prefs_sql_kw(fp, now)
|
||||
with get_conn() as conn:
|
||||
if following:
|
||||
user = _current_user(conn, request)
|
||||
if not user:
|
||||
return FeedResponse(topic=topic, flavor=flavor, count=0, items=[])
|
||||
frows = conn.execute(
|
||||
"SELECT kind, value FROM user_follows WHERE user_id = ?", (user["id"],)
|
||||
).fetchall()
|
||||
kw["follow_sources"] = [int(r["value"]) for r in frows if r["kind"] == "source" and r["value"].isdigit()]
|
||||
kw["follow_tags"] = [r["value"] for r in frows if r["kind"] == "tag"]
|
||||
if fp.avoid_terms or excl:
|
||||
# Over-fetch enough to cover what the Python pass might remove.
|
||||
fetch_n = min(2000, (offset + limit) * 4 + 50 + len(excl))
|
||||
|
||||
Reference in New Issue
Block a user