---
title: "Trending Search"
method: GET
path: "/v1/trending/search"
---

# Trending Search

`GET /v1/trending/search`

## Request

- Base URL: `https://api.storyflo.com` (the document declares no server; this is the origin it was published from)
- URL: `https://api.storyflo.com/v1/trending/search`
- Auth: none declared

Discovery + shareability surface — keyword search across the
same 7d quality-article corpus that powers ``/v1/trending``'s
``top_articles`` list.

Operator directive 2026-06-04: listeners should "always see the
trending 10 stories in order on trending and initially with search
to drive discovery and shareability." ``/v1/trending`` shipped the
ordered list (PR #451 + #461 + the 7d-window widening); this is the
missing search surface. The response per-item shape MIRRORS
``top_articles`` (slug + title + publisher_slug + publisher_name +
vertical + trending_score + summary + created_at) so the frontend
can render search hits with the exact same card component — only a
new ``match_rank`` field (0-1 relevance score) is added.

Implementation: Postgres full-text search against
``articles.search_vector`` (the generated tsvector column codified
by migration 0124, indexed by the partial GIN
``ix_articles_search_vector_recent`` from migration 0136).
``plainto_tsquery('english', :q)`` parses user input safely (no
tsquery operator injection); the match predicate is
``search_vector @@ tsq`` so the GIN bitmap scan drives candidate
selection.

Ranking blend (operator-mandated):

    score = trending_score * 0.7
          + ts_rank(search_vector, tsq) * 0.3 * 5.0

``trending_score`` is the prebuilt freshness/landing signal
(typical range 0-5) and ``ts_rank`` returns 0-1. The 5.0 multiplier
rescales ts_rank into the same magnitude bracket so the 70/30
weighting is meaningful in practice — without it the trending term
would always dominate and search-relevance would be cosmetic.

Eligibility filter mirrors ``top_articles`` so the search surface
only returns the same rows the trending list could surface:
  - created_at >= now - ``window_days``  (default 7d, max 30d)
  - is_quality = True
  - summary is not null + not empty
  - slug NOT LIKE 'daily-brief-%'  (compilation is featured separately)
  - vertical = :vertical when filter supplied (must be one of the
    7 priority verticals per ``app.vertical_classifier.PRIORITY_VERTICALS``)

Validation:
  - q          2-100 chars (FastAPI Query enforces, returns 422)
  - limit      1-50      (FastAPI Query enforces)
  - window_days 1-30     (matches PR #457's expanded default)
  - vertical   must be in PRIORITY_VERTICALS or 400

Empty result is a 200 with ``count: 0`` + ``results: []`` — NOT
404, because the empty case is a normal search outcome (typo, term
with no hits this week) and the listener UI shouldn't treat it as
an error state.

Sqlite fallback (tests): the ``search_vector`` column + tsvector
operators are Postgres-only. On sqlite the implementation switches
to an ILIKE-based match (title OR summary) with a token-overlap
``match_rank`` proxy. Same response contract, same eligibility
filter, same ranking blend (with ``match_rank`` substituting for
ts_rank). Used only by the test suite — production is always
Postgres.

Pool-exhaustion fix 2026-06-21 (follow-up to PRs #657 + #658 +
#674 + #677): deliberately no ``db: AsyncSession = Depends(get_db)``
parameter. ``/v1/trending/search`` shares the trending-cache
singleton with ``/v1/trending`` AND is wrapped by the same
single-flight ``cache.get_or_compute`` primitive — so the same
pool-pressure anti-pattern applies. With basic-plan PG sized at
``DB_POOL_SIZE=5 + MAX_OVERFLOW=3`` (total 8 conns), a request-
scoped Depends session held across ``get_or_compute`` exhausts the
pool on a concurrent-burst storm even when only one DB query is
actually running (every non-owner is parked on the shared Future
while still holding its Depends-session checkout).

Same fix shape as the four previous endpoints: only the single-
flight OWNER opens a short-lived session inside ``_compute_search``
and releases it via ``_close_session_safely`` BEFORE the function
returns. Non-owners hold zero connections while awaiting the
shared Future inside ``trending_cache.get_or_compute``.

## Query parameters

- `q` string, required
- `limit` integer
- `vertical` string, nullable
- `window_days` integer

## Response `200`

Successful Response

- object

## Other responses

- `422`: Validation Error

---

[API](https://skmtc.dev/storyflo/apis/storyflo-inference-router.md) · [All operations](https://skmtc.dev/storyflo/apis/storyflo-inference-router/llms.txt) · [OpenAPI document](https://skmtc.dev/storyflo/apis/storyflo-inference-router/revisions/c3a1a735ac7a?raw)
