Track 3: read-only source preview (vet a feed before adding)
- feeds.preview_feed(): fetch + score a sample WITHOUT persisting; returns freshness, acceptance rate, cortisol/ragebait/PR averages, and example accepted/rejected items. With an LLM client it also returns topic/flavor mix and the model's (accurate) acceptance view. - CLI 'preview-source URL [--sample] [--classify]'. - API 'GET /api/source-preview?url=&sample=&classify=' with an http(s)-only guard (SSRF note left for go-public hardening). - Site 'Suggest a source' panel with Quick check (heuristic, instant) and Deep check (model, accurate), rendered DOM-safely. - Tests: network-free preview_feed tests via monkeypatched fetch (45 total). - README documents the command, endpoint, and updated roadmap. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+43
-1
@@ -14,6 +14,7 @@ so the API and CLI always read the same file.
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import sqlite3
|
||||
from collections import Counter
|
||||
from contextlib import contextmanager
|
||||
@@ -25,9 +26,10 @@ from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from pydantic import BaseModel
|
||||
|
||||
from . import queries
|
||||
from . import feeds, queries
|
||||
from .db import connect, init_db
|
||||
from .filters import filter_articles, prefs_from_json
|
||||
from .llm import LocalModelClient
|
||||
from .taxonomy import FLAVORS, TOPICS
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
@@ -118,6 +120,28 @@ class BriefResponse(BaseModel):
|
||||
items: list[Article]
|
||||
|
||||
|
||||
class RejectedExample(BaseModel):
|
||||
title: str
|
||||
reason: str
|
||||
|
||||
|
||||
class SourcePreview(BaseModel):
|
||||
url: str
|
||||
sampled: int
|
||||
classified: bool
|
||||
accepted: int
|
||||
acceptance_rate: float
|
||||
avg_cortisol: float
|
||||
avg_ragebait: float
|
||||
avg_pr_risk: float
|
||||
newest_published: str | None
|
||||
recent_7d: int
|
||||
topic_mix: dict[str, int]
|
||||
flavor_mix: dict[str, int]
|
||||
examples_accepted: list[str]
|
||||
examples_rejected: list[RejectedExample]
|
||||
|
||||
|
||||
# --- App --------------------------------------------------------------------
|
||||
|
||||
|
||||
@@ -227,6 +251,24 @@ def create_app() -> FastAPI:
|
||||
with get_conn() as conn:
|
||||
return queries.available_dates(conn, limit=limit)
|
||||
|
||||
@app.get("/api/source-preview", response_model=SourcePreview)
|
||||
def source_preview(
|
||||
url: str = Query(..., max_length=2048),
|
||||
sample: int = Query(25, ge=1, le=50),
|
||||
classify: bool = Query(False, description="Also classify with the local model (accurate but slower)"),
|
||||
) -> SourcePreview:
|
||||
# Read-only sample scoring; nothing is persisted. Only http(s) is allowed.
|
||||
# NOTE: fetching a user-supplied URL is an SSRF surface — before exposing
|
||||
# this publicly, also block private/loopback/link-local address ranges.
|
||||
if not re.match(r"^https?://", url, re.IGNORECASE):
|
||||
raise HTTPException(400, "url must start with http:// or https://")
|
||||
client = LocalModelClient.from_env() if classify else None
|
||||
try:
|
||||
data = feeds.preview_feed(url, sample=sample, client=client)
|
||||
except Exception as exc:
|
||||
raise HTTPException(502, f"could not preview feed: {exc}")
|
||||
return SourcePreview(**data)
|
||||
|
||||
# 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")
|
||||
|
||||
Reference in New Issue
Block a user