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.
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
Response
Successful Response
Changes
No recorded changes to this operation across all 1 revision of this API.