Trending Search

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.

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

Query parameters

qstring required
limitinteger
verticalstring nullable
window_daysinteger

Response

Successful Response

object required

Changes

No recorded changes to this operation across all 1 revision of this API.