Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 0 additions & 45 deletions backend/routers/papers.py
Original file line number Diff line number Diff line change
Expand Up @@ -526,51 +526,6 @@ def search_papers_feed(
)


@router.get(
"/papers/x-trending",
response_model=Papers,
summary="Papers trending on X, ordered by trending score",
)
def get_x_trending_papers(
page: int = Query(1, ge=1, le=100, description="Page number (1-based)"),
page_size: int = Query(30, ge=1, le=100, description="Papers per page"),
start_date: Optional[date] = Query(
None, description="Inclusive earliest publication date (YYYY-MM-DD)"
),
end_date: Optional[date] = Query(
None, description="Inclusive latest publication date (YYYY-MM-DD)"
),
include_resources: bool = Query(
False, description="Include repositories and project pages"
),
has_code: bool = Query(
False, description="Only include papers with a linked GitHub repository"
),
has_hf_artifacts: bool = Query(
False, description="Only include papers with linked Hugging Face artifacts"
),
db: Session = Depends(get_database_session),
):
"""List papers from the daily "AI Papers on X" picks.

Like the homepage trending feed, the date window filters on publication
date and papers are ordered by the same trending score.
"""
if start_date is not None and end_date is not None and start_date > end_date:
raise HTTPException(
status_code=422, detail="start_date must be on or before end_date"
)
return XTrendingService(db).feed(
page=page,
page_size=page_size,
start_date=start_date,
end_date=end_date,
include_resources=include_resources,
has_code=has_code,
has_hf_artifacts=has_hf_artifacts,
)


@router.put(
"/papers/x-trending/{shared_on}",
response_model=XTrendingDayResult,
Expand Down
44 changes: 1 addition & 43 deletions backend/services/x_trending_service.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
"""Papers trending on X: the daily "AI Papers on X" sheet and its homepage feed.
"""Papers trending on X: the daily "AI Papers on X" sheet.

A headless routine exports each dated sheet tab and replaces that day's rows
through the admin API. ArXiv papers missing from the catalog are imported;
Expand All @@ -8,22 +8,18 @@

from datetime import date

from sqlalchemy import or_, select
from sqlalchemy.orm import Session

from backend.models.database import Paper as DBPaper, PaperXTrending
from backend.models.papers import Papers
from backend.models.x_trending import (
XTrendingDayResult,
XTrendingPaperInput,
XTrendingUnresolved,
)
from backend.services.paper_feed_executor import GENERAL_ORDERING, PaperFeedExecutor
from backend.services.paper_ingestion_service import (
PaperIngestionError,
PaperIngestionService,
)
from backend.services.paper_service import PaperService


class XTrendingService:
Expand Down Expand Up @@ -100,41 +96,3 @@ async def _resolve(
.first()
)
return paper, "" if paper else "no external paper with this source URL"

def feed(
self,
*,
page: int,
page_size: int,
start_date: date | None,
end_date: date | None,
include_resources: bool,
has_code: bool,
has_hf_artifacts: bool,
) -> Papers:
"""Papers ever shared on X, published within the window, by trending score.

Like the other homepage tabs, the window filters on publication date.
"""
shared = select(PaperXTrending.paper_id)
# Match the stored paper's arXiv id too, so a newer version that has
# since become the latest row stays in the feed.
shared_arxiv_ids = select(DBPaper.arxiv_id).where(
DBPaper.id.in_(shared), DBPaper.arxiv_id.is_not(None)
)
papers = PaperService(self.db)
return PaperFeedExecutor(papers).execute(
self.db.query(DBPaper).filter(
or_(DBPaper.id.in_(shared), DBPaper.arxiv_id.in_(shared_arxiv_ids))
),
page=page,
page_size=page_size,
order_by="trending",
order_dir="desc",
ordering=GENERAL_ORDERING,
has_code=has_code,
has_hf_artifacts=has_hf_artifacts,
start_date=start_date,
end_date=end_date,
include_resources=include_resources,
)
59 changes: 1 addition & 58 deletions backend/tests/paper/test_x_trending.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,8 @@
PaperXTrending,
Repository,
)
from backend.models.papers import Paper as PaperCard
from backend.models.x_trending import XTrendingPaperInput
from backend.services.paper_ingestion_service import PaperIngestionError
from backend.services.paper_service import PaperService
from backend.services.x_trending_service import XTrendingService


Expand All @@ -48,7 +46,7 @@ def paper(ident, *, score=None, arxiv_id=None, source="arxiv", source_url=None):


@pytest.fixture
def db(monkeypatch):
def db():
engine = create_engine("sqlite://")
Base.metadata.create_all(
engine,
Expand All @@ -64,17 +62,7 @@ def db(monkeypatch):
)
],
)
monkeypatch.setattr(
PaperService,
"hydrate_cards",
lambda self, rows, **_: [
PaperCard.model_construct(id=str(row.id)) for row in rows
],
)
with Session(engine) as session:
monkeypatch.setattr(
PaperService(session).settings, "trending_ranking_mode", "composite"
)
session.add_all(
[
paper(1, score=5.0, arxiv_id="2610.00001"),
Expand Down Expand Up @@ -117,19 +105,6 @@ def replace(db, day, rows):
return asyncio.run(XTrendingService(db, FakeIngestion(db)).replace_day(day, rows))


def feed(db, **window):
response = XTrendingService(db).feed(
page=1,
page_size=10,
start_date=window.get("start_date"),
end_date=window.get("end_date"),
include_resources=False,
has_code=False,
has_hf_artifacts=False,
)
return [card.id for card in response.results]


def test_replace_day_imports_matches_and_reports_unresolved(db):
result = replace(
db,
Expand Down Expand Up @@ -157,38 +132,6 @@ def test_replace_day_imports_matches_and_reports_unresolved(db):
assert [entry.paper_id for entry in db.query(PaperXTrending).all()] == [2]


def test_feed_orders_by_trending_score_within_publication_window(db):
replace(
db,
date(2026, 10, 29),
[row(1, arxiv_id="2610.00001"), row(2, arxiv_id="2610.00003")],
)
replace(
db,
date(2026, 10, 30),
[row(1, arxiv_id="2610.00002"), row(2, arxiv_id="2610.00004")],
)
# Paper 4 has the top score and was shared this week, but was published
# in August, outside the window.
db.get(Paper, 4).date_published = date(2026, 8, 1)
db.commit()

window = {"start_date": date(2026, 8, 25), "end_date": date(2026, 9, 1)}
# Unscored paper 3 stays listed after the scored ones.
assert feed(db, **window) == ["2", "1", "3"]
assert feed(db) == ["4", "2", "1", "3"]


def test_input_needs_an_identifier():
with pytest.raises(ValueError):
XTrendingPaperInput(rank=1, title="No link")


def test_feed_follows_a_newer_version_of_a_shared_paper(db):
replace(db, date(2026, 10, 30), [row(1, arxiv_id="2610.00001")])
db.get(Paper, 1).is_latest_version = False
newer = paper(7, score=5.0, arxiv_id="2610.00001")
newer.version = "v2"
db.add(newer)
db.commit()
assert feed(db) == ["7"]
3 changes: 2 additions & 1 deletion frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -285,7 +285,8 @@ const Shell: React.FC = () => {
<Route element={<SiteLayout />}>
<Route path="/" element={<FeedView />} />
<Route path="/following" element={<FollowingView />} />
<Route path="/x-trending" element={<FeedView xTrending />} />
{/* The trending (X) tab merged into the homepage feed. */}
<Route path="/x-trending" element={<Navigate to="/" replace />} />
{/* Keep new and persisted chats on the same route instance. A new
turn replaces /chat with /chat/:sessionId as soon as the server
allocates it; sibling routes would unmount ChatView and abort
Expand Down
5 changes: 0 additions & 5 deletions frontend/src/lib/api/papers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -173,11 +173,6 @@ export class PapersApi {
});
}

/** Papers from the daily "AI Papers on X" picks; dates filter publication, as on other feeds. */
xTrending(params?: Pick<PaperFeedParams, "page" | "page_size" | "include_resources" | "has_code" | "has_hf_artifacts" | "start_date" | "end_date">): Promise<PaginatedResponse<Paper>> {
return this.request(`/papers/x-trending${query(params)}`);
}

forTask(
taskId: string,
params?: PaperFeedParams & { repository_limit?: number },
Expand Down
1 change: 0 additions & 1 deletion frontend/src/lib/paperQueryKeys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ export const paperQueryKeys = {
feeds: {
list: ["papers"],
infinite: ["papers-infinite"],
xTrending: ["x-trending-papers-infinite"],
following: ["research-feed"],
search: ["paper-search-feed"],
task: ["task-papers-infinite"],
Expand Down
17 changes: 7 additions & 10 deletions frontend/src/views/FeedView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -307,7 +307,7 @@ const FOLLOW_KIND_LABELS: Record<FollowKind, string> = {
};
const FOLLOW_KIND_ORDER = Object.keys(FOLLOW_KIND_LABELS) as FollowKind[];

type FeedMode = "trending" | "x" | "following";
type FeedMode = "trending" | "following";

const FEED_PAGE_SIZE = 30;
const FOLLOWING_PAGE_SIZE = 40;
Expand Down Expand Up @@ -373,17 +373,15 @@ export const HomeFeed: React.FC<{ mode: FeedMode }> = ({ mode }) => {
const infinite = usePaperFeedQuery<FeedPage>(
following
? [...paperQueryKeys.feeds.following, user?.hf_subject, followingFilters]
: [...(mode === "x" ? paperQueryKeys.feeds.xTrending : paperQueryKeys.feeds.trending), dateFilters],
: [...paperQueryKeys.feeds.trending, dateFilters],
async (page) => {
if (following) {
const entries = await researchApi.following(page, followingFilters);
const next_page = entries.length === FOLLOWING_PAGE_SIZE ? page + FOLLOWING_PAGE_SIZE : null;
return { results: entries, next_page };
}
const params = { ...dateFilters, page, page_size: FEED_PAGE_SIZE, include_resources: true };
return toFeedPage(mode === "x"
? papersApi.xTrending(params)
: papersApi.list({ ...params, order_by: "trending", order_dir: "desc", latest_only: true }));
return toFeedPage(papersApi.list({ ...params, order_by: "trending", order_dir: "desc", latest_only: true }));
},
{ enabled: !following || !!user, initialPage: following ? 0 : 1 },
);
Expand Down Expand Up @@ -465,7 +463,7 @@ export const HomeFeed: React.FC<{ mode: FeedMode }> = ({ mode }) => {
<h1 className="feed-title">
Trending <em>Research</em>
</h1>
<div className="feed-subtitle">{mode === "x" ? "Papers people share most on X, picked daily" : "Curated daily from arXiv and Hugging Face"}</div>
<div className="feed-subtitle">Curated daily from arXiv and Hugging Face</div>
</div>
</div>

Expand Down Expand Up @@ -578,7 +576,6 @@ export const HomeFeed: React.FC<{ mode: FeedMode }> = ({ mode }) => {
<div className="paper-feed-toolbar-controls">
<div className="sort-options">
<button className={`sort-btn ${mode === "trending" ? "active" : ""}`} onClick={() => showTab("/")}>trending</button>
<button className={`sort-btn ${mode === "x" ? "active" : ""}`} onClick={() => showTab("/x-trending")}>trending (X)</button>
<button className={`sort-btn ${following ? "active" : ""}`} onClick={() => showTab("/following")}>following</button>
</div>
</div>
Expand Down Expand Up @@ -642,7 +639,7 @@ export const HomeFeed: React.FC<{ mode: FeedMode }> = ({ mode }) => {
cards={cards}
skeleton="card"
rootMargin="400px"
empty={error ? null : topicOnly ? <>No papers about your topic match these filters. Widen the date range or <button type="button" className="link-button" onClick={() => setEditingTopic(true)}>edit your topic</button>.</> : following ? <>No papers from your topic or follows match these filters. Widen the date range, or follow more from <Link to="/tasks">tasks</Link>, <Link to="/methods">methods</Link>, benchmark, author, or organization pages.</> : mode === "x" ? "No papers shared on X in this window." : "No papers in this window."}
empty={error ? null : topicOnly ? <>No papers about your topic match these filters. Widen the date range or <button type="button" className="link-button" onClick={() => setEditingTopic(true)}>edit your topic</button>.</> : following ? <>No papers from your topic or follows match these filters. Widen the date range, or follow more from <Link to="/tasks">tasks</Link>, <Link to="/methods">methods</Link>, benchmark, author, or organization pages.</> : "No papers in this window."}
/>
)}
</div>
Expand All @@ -653,8 +650,8 @@ export const HomeFeed: React.FC<{ mode: FeedMode }> = ({ mode }) => {
};

// Routes render the public feeds through this; FollowingView renders the following mode.
const FeedView: React.FC<{ xTrending?: boolean }> = ({ xTrending = false }) => (
<HomeFeed mode={xTrending ? "x" : "trending"} />
const FeedView: React.FC = () => (
<HomeFeed mode="trending" />
);

export default FeedView;
18 changes: 4 additions & 14 deletions frontend/src/views/FollowingView.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,6 @@ function renderFeed(path = '/following') {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(<QueryClientProvider client={client}><MemoryRouter initialEntries={[path]}><Routes>
<Route path="/" element={<FeedView />} /><Route path="/following" element={<FollowingView />} />
<Route path="/x-trending" element={<FeedView xTrending />} />
</Routes></MemoryRouter></QueryClientProvider>);
}
beforeEach(() => {
Expand Down Expand Up @@ -57,7 +56,7 @@ it('renders following in the existing tab row with standard paper cards and thum
expect(screen.getByText('Full abstract.')).toBeInTheDocument();
const tab = screen.getByRole('button', { name: 'following' });
expect(tab).toHaveClass('active');
expect(tab.parentElement?.textContent).toBe('trendingtrending (X)following');
expect(tab.parentElement?.textContent).toBe('trendingfollowing');
expect(screen.queryByText('Discover')).not.toBeInTheDocument();
expect(papersApi.list).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole('button', { name: 'trending' }));
Expand Down Expand Up @@ -415,17 +414,8 @@ it('shows the feed for an account that follows only a topic', async () => {
expect(await screen.findByText('A paper')).toBeInTheDocument();
expect(screen.queryByRole('heading', { name: 'Build your personal research feed' })).not.toBeInTheDocument();
});
it('lists papers shared on X under the trending (X) tab, over a month window by default', async () => {
const xTrending = vi.spyOn(papersApi, 'xTrending').mockResolvedValue({ count: 1, results: [paper], next_page: null, previous_page: null } as never);
it('has no separate trending (X) tab: X likes are part of the trending score', async () => {
renderFeed('/');
fireEvent.click(await screen.findByRole('button', { name: 'trending (X)' }));
expect(await screen.findByText('A paper')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'trending (X)' })).toHaveClass('active');
expect(screen.getByRole('button', { name: 'trending' })).not.toHaveClass('active');
expect(screen.getByText('Papers people share most on X, picked daily')).toBeInTheDocument();
expect(xTrending).toHaveBeenCalledWith(expect.objectContaining({ page: 1, page_size: 30, include_resources: true }));
const { start_date, end_date } = xTrending.mock.calls[0][0]!;
expect(start_date && end_date && start_date < end_date).toBeTruthy();
expect((Date.parse(end_date!) - Date.parse(start_date!)) / 86_400_000).toBeGreaterThan(7);
expect(researchApi.following).not.toHaveBeenCalled();
expect(await screen.findByRole('button', { name: 'trending' })).toHaveClass('active');
expect(screen.queryByRole('button', { name: 'trending (X)' })).not.toBeInTheDocument();
});
Loading