Spaces:
Running on CPU Upgrade
Running on CPU Upgrade
| """ | |
| Homepage "Browse" card directory API. | |
| Serves the four browse categories (place / topic / question / cause) for the | |
| homepage Browse cards, plus a per-category top-items list, both backed by two | |
| published serving views over gold: | |
| - public.browse_directory_summary β one national row (state_code IS NULL) per | |
| entity_type plus per-state rows for place/topic. Used by GET /summary. | |
| - public.browse_transcript_count β item grain (PK entity_type, entity_id) with a | |
| genuine distinct-transcript count per entity. Used by GET /top-items. | |
| Both resolve as bare table names via the connection search_path (matching the | |
| other routes β see topics.py / search_postgres.py). The columns are plain | |
| text/int, so the pool's missing JSONB codec is irrelevant here. | |
| """ | |
| from __future__ import annotations | |
| from typing import Dict, List, Optional | |
| from fastapi import APIRouter, Query | |
| from loguru import logger | |
| from opentelemetry import trace | |
| from pydantic import BaseModel | |
| from api.routes.search_postgres import get_db_pool | |
| router = APIRouter(prefix="/api/browse", tags=["browse"]) | |
| tracer = trace.get_tracer(__name__) | |
| # The four browse entity types and their card labels (plural noun). Order here is | |
| # the canonical fallback order; the served list is re-sorted by transcript_count | |
| # but `cause` is always forced last (it is genuinely 0 transcripts). | |
| _LABELS: Dict[str, str] = { | |
| "place": "places", | |
| "topic": "topics", | |
| "question": "questions", | |
| "cause": "causes", | |
| } | |
| class BrowseCategory(BaseModel): | |
| entity_type: str | |
| label: str | |
| transcript_count: int | |
| entity_count: int | |
| has_transcripts: bool | |
| class BrowseSummary(BaseModel): | |
| categories: List[BrowseCategory] | |
| class BrowseTopItem(BaseModel): | |
| entity_id: str | |
| entity_name: str | |
| transcript_count: int | |
| # Nullable: question/cause items have no state_code; place/topic carry one. | |
| state_code: Optional[str] = None | |
| class BrowseTopItems(BaseModel): | |
| items: List[BrowseTopItem] | |
| class PlaceMapPin(BaseModel): | |
| """A single map pin for an indexed place (or a state/county rollup of them).""" | |
| geoid: str | |
| name: str | |
| state_code: Optional[str] = None | |
| latitude: float | |
| longitude: float | |
| # Distinct indexed places represented by this pin (1 for an individual | |
| # place; the rollup count for a state pin). | |
| place_count: int | |
| transcript_count: int | |
| class PlaceMapLevel(BaseModel): | |
| level: str # state | county | city | school_district | |
| label: str # display label for the filter toggle | |
| count: int # number of pins at this level | |
| pins: List[PlaceMapPin] | |
| class PlaceMapResponse(BaseModel): | |
| levels: List[PlaceMapLevel] | |
| # One round-trip: pull the national row (state_code IS NULL) and, when a state is | |
| # given, the matching per-state row for each entity_type. We pick the per-state | |
| # row in Python (preferring it over the national fallback). $1 is the upper-cased | |
| # state code or NULL. | |
| _SUMMARY_SQL = """ | |
| SELECT entity_type, state_code, transcript_count, entity_count | |
| FROM browse_directory_summary | |
| WHERE state_code IS NULL OR state_code = $1::text | |
| """ | |
| # Top items in a category by transcript count, NATIONAL (no place filter). | |
| # browse_transcript_count is the national rollup (one row per entity, with a | |
| # representative state_code), so this is correct only when no state is selected. | |
| _TOP_ITEMS_SQL = """ | |
| SELECT entity_id, entity_name, transcript_count, state_code | |
| FROM browse_transcript_count | |
| WHERE entity_type = $1::text | |
| ORDER BY transcript_count DESC NULLS LAST, entity_name ASC | |
| LIMIT $2::int | |
| """ | |
| # Top items SCOPED TO A STATE (place/topic). Reads the per-state grain mart so a | |
| # topic that spans many states is ranked by its count IN THIS state β the | |
| # national rollup above only carries a representative state and would drop most | |
| # topics under a state filter. | |
| _TOP_ITEMS_STATE_SQL = """ | |
| SELECT entity_id, entity_name, transcript_count, state_code | |
| FROM browse_entity_state_transcript_count | |
| WHERE entity_type = $1::text | |
| AND state_code = $2::text | |
| ORDER BY transcript_count DESC NULLS LAST, entity_name ASC | |
| LIMIT $3::int | |
| """ | |
| # Questions are special: they surface from the curated/pinned registry, NOT by | |
| # transcript count. The homepage "Browse questions" dropdown (and any other | |
| # top-items question consumer) shows ONLY the featured policy questions, in | |
| # editorial order (display_order), matching the /policy-questions page. Real | |
| # distinct-transcript counts are LEFT-joined so a pinned question with no linked | |
| # transcripts honestly shows 0 rather than being dropped. | |
| _TOP_QUESTIONS_FEATURED_SQL = """ | |
| SELECT q.question_id AS entity_id, | |
| q.canonical_text AS entity_name, | |
| COALESCE(b.transcript_count, 0) AS transcript_count, | |
| NULL::text AS state_code | |
| FROM public.policy_question q | |
| LEFT JOIN browse_transcript_count b | |
| ON b.entity_type = 'question' | |
| AND b.entity_id = q.question_id | |
| WHERE q.is_featured = true | |
| ORDER BY q.display_order ASC NULLS LAST, q.instances_total DESC NULLS LAST | |
| LIMIT $1::int | |
| """ | |
| # --- Place map (clustered pins of the places we index) ------------------------ | |
| # | |
| # The honest "places we index" universe is browse_transcript_count where | |
| # entity_type='place' (one row per indexed place geoid, with its real distinct | |
| # transcript_count). We join each place to its census centroid in `jurisdictions` | |
| # for lat/lon + jurisdiction_type. geoid is NOT unique in `jurisdictions` | |
| # (city/school_district collisions), so DISTINCT ON (geoid) with a type-priority | |
| # tiebreaker emits exactly one centroid per place. | |
| # | |
| # Individual-place levels (city / county / school_district) are bucketed in | |
| # Python from this one query by jurisdiction_type. The STATE level is a genuine | |
| # rollup: one pin per state that has any indexed place, at the real state | |
| # centroid, sized by the count of distinct indexed places in that state. | |
| _PLACE_MAP_PLACES_SQL = """ | |
| SELECT DISTINCT ON (b.entity_id) | |
| b.entity_id AS geoid, | |
| b.entity_name AS name, | |
| b.state_code AS state_code, | |
| j.jurisdiction_type AS jurisdiction_type, | |
| j.latitude::float8 AS latitude, | |
| j.longitude::float8 AS longitude, | |
| b.transcript_count AS transcript_count | |
| FROM browse_transcript_count b | |
| JOIN jurisdictions j | |
| ON j.geoid = b.entity_id | |
| AND j.latitude IS NOT NULL | |
| AND j.longitude IS NOT NULL | |
| WHERE b.entity_type = 'place' | |
| ORDER BY b.entity_id, | |
| CASE j.jurisdiction_type | |
| WHEN 'city' THEN 0 WHEN 'town' THEN 1 | |
| WHEN 'county' THEN 2 WHEN 'school_district' THEN 3 ELSE 4 | |
| END | |
| """ | |
| _PLACE_MAP_STATE_SQL = """ | |
| WITH place AS ( | |
| SELECT DISTINCT ON (b.entity_id) | |
| b.entity_id AS geoid, b.state_code, b.transcript_count | |
| FROM browse_transcript_count b | |
| WHERE b.entity_type = 'place' AND b.state_code IS NOT NULL | |
| ) | |
| SELECT | |
| s.geoid AS geoid, | |
| s.name AS name, | |
| s.state_code AS state_code, | |
| s.latitude::float8 AS latitude, | |
| s.longitude::float8 AS longitude, | |
| count(DISTINCT p.geoid)::int AS place_count, | |
| sum(p.transcript_count)::int AS transcript_count | |
| FROM place p | |
| JOIN jurisdictions s | |
| ON s.jurisdiction_type = 'state' | |
| AND s.state_code = p.state_code | |
| AND s.latitude IS NOT NULL | |
| AND s.longitude IS NOT NULL | |
| GROUP BY s.geoid, s.name, s.state_code, s.latitude, s.longitude | |
| ORDER BY place_count DESC | |
| """ | |
| # jurisdiction_type -> (level key, plural label). town folds into the city level | |
| # (both are sub-county localities); state is built from its own rollup query. | |
| _PLACE_LEVEL_LABELS: Dict[str, str] = { | |
| "state": "States", | |
| "county": "Counties", | |
| "city": "Cities & towns", | |
| "school_district": "School districts", | |
| } | |
| _TYPE_TO_LEVEL: Dict[str, str] = { | |
| "city": "city", | |
| "town": "city", | |
| "county": "county", | |
| "school_district": "school_district", | |
| } | |
| def _normalize_state(state: Optional[str]) -> Optional[str]: | |
| """Upper-case a 2-letter state code, or None when blank/absent.""" | |
| if state is None: | |
| return None | |
| s = state.strip().upper() | |
| return s or None | |
| async def browse_summary( | |
| state: Optional[str] = Query( | |
| None, description="Optional 2-letter state code. Scopes place/topic to that state; question/cause stay national." | |
| ), | |
| ) -> BrowseSummary: | |
| """The four homepage Browse cards, sorted by transcript_count desc (cause last).""" | |
| with tracer.start_as_current_span("browse-summary") as span: | |
| state_code = _normalize_state(state) | |
| span.set_attribute("browse.state", state_code or "") | |
| try: | |
| pool = await get_db_pool() | |
| async with pool.acquire() as conn: | |
| with tracer.start_as_current_span("browse-summary-query"): | |
| rows = await conn.fetch(_SUMMARY_SQL, state_code) | |
| except Exception as exc: # noqa: BLE001 | |
| logger.exception("browse summary failed") | |
| span.record_exception(exc) | |
| # Empty list over a 500 β the homepage renders an empty Browse state. | |
| return BrowseSummary(categories=[]) | |
| # For each entity_type prefer the per-state row over the national one. | |
| chosen: Dict[str, dict] = {} | |
| for r in rows: | |
| et = r["entity_type"] | |
| is_state_row = r["state_code"] is not None | |
| existing = chosen.get(et) | |
| if existing is None or (is_state_row and existing["state_code"] is None): | |
| chosen[et] = dict(r) | |
| categories = [ | |
| BrowseCategory( | |
| entity_type=et, | |
| label=_LABELS.get(et, f"{et}s"), | |
| transcript_count=row["transcript_count"] or 0, | |
| entity_count=row["entity_count"] or 0, | |
| has_transcripts=(row["transcript_count"] or 0) > 0, | |
| ) | |
| for et, row in chosen.items() | |
| ] | |
| # Sort by transcript_count desc, but always pin `place` to the very end so | |
| # the "Browse places" card sits at the far right of the homepage row (a | |
| # product choice β places dwarfs the others on transcript count, but we | |
| # want it last). `cause` (genuinely 0 transcripts) then naturally falls | |
| # just before place via the descending count. | |
| categories.sort( | |
| key=lambda c: (c.entity_type == "place", -c.transcript_count) | |
| ) | |
| span.set_attribute("browse.categories", len(categories)) | |
| return BrowseSummary(categories=categories) | |
| async def browse_top_items( | |
| entity_type: str = Query(..., description="One of: place, topic, question, cause."), | |
| state: Optional[str] = Query( | |
| None, description="Optional 2-letter state code (applies to place/topic; ignored for question/cause)." | |
| ), | |
| limit: int = Query(8, ge=1, le=50, description="Max items (default 8, cap 50)."), | |
| ) -> BrowseTopItems: | |
| """Top items in a browse category, ranked by transcript_count desc.""" | |
| with tracer.start_as_current_span("browse-top-items") as span: | |
| et = (entity_type or "").strip().lower() | |
| span.set_attribute("browse.entity_type", et) | |
| if et not in _LABELS: | |
| # Unknown entity_type β empty list rather than a 4xx, matching the | |
| # forgiving posture of the other browse/topic routes. | |
| span.set_attribute("browse.unknown_entity_type", True) | |
| return BrowseTopItems(items=[]) | |
| state_code = _normalize_state(state) | |
| # question/cause have no per-state rows; never filter them by state. | |
| if et in ("question", "cause"): | |
| state_code = None | |
| span.set_attribute("browse.state", state_code or "") | |
| try: | |
| pool = await get_db_pool() | |
| async with pool.acquire() as conn: | |
| with tracer.start_as_current_span("browse-top-items-query"): | |
| if et == "question": | |
| # Pinned/featured registry questions only, editorial order. | |
| rows = await conn.fetch(_TOP_QUESTIONS_FEATURED_SQL, limit) | |
| elif state_code: | |
| # Place/topic scoped to the selected state β per-state mart. | |
| rows = await conn.fetch(_TOP_ITEMS_STATE_SQL, et, state_code, limit) | |
| else: | |
| # National (no place filter) β representative-state rollup. | |
| rows = await conn.fetch(_TOP_ITEMS_SQL, et, limit) | |
| except Exception as exc: # noqa: BLE001 | |
| logger.exception("browse top-items failed") | |
| span.record_exception(exc) | |
| return BrowseTopItems(items=[]) | |
| span.set_attribute("browse.items", len(rows)) | |
| return BrowseTopItems( | |
| items=[ | |
| BrowseTopItem( | |
| entity_id=r["entity_id"], | |
| entity_name=r["entity_name"], | |
| transcript_count=r["transcript_count"] or 0, | |
| state_code=r["state_code"], | |
| ) | |
| for r in rows | |
| ] | |
| ) | |
| async def browse_place_map() -> PlaceMapResponse: | |
| """Clustered map pins for every place we index, grouped into filterable levels. | |
| Returns four levels β state (a per-state rollup), county, city (cities + | |
| towns) and school_district β each an independently toggleable layer of pins | |
| plotted at real census centroids. Every pin is a place with >=1 transcript; | |
| no fabricated coordinates or counts. | |
| """ | |
| with tracer.start_as_current_span("browse-place-map") as span: | |
| try: | |
| pool = await get_db_pool() | |
| async with pool.acquire() as conn: | |
| with tracer.start_as_current_span("browse-place-map-query"): | |
| place_rows = await conn.fetch(_PLACE_MAP_PLACES_SQL) | |
| state_rows = await conn.fetch(_PLACE_MAP_STATE_SQL) | |
| except Exception as exc: # noqa: BLE001 | |
| logger.exception("browse place-map failed") | |
| span.record_exception(exc) | |
| # Empty levels over a 500 β the map renders an empty state. | |
| return PlaceMapResponse(levels=[]) | |
| # Bucket the individual indexed places by jurisdiction_type into levels. | |
| buckets: Dict[str, List[PlaceMapPin]] = { | |
| "city": [], "county": [], "school_district": [], | |
| } | |
| for r in place_rows: | |
| level = _TYPE_TO_LEVEL.get(r["jurisdiction_type"]) | |
| if level is None: | |
| continue # an indexed place of an unmapped type β skip, don't guess | |
| buckets[level].append( | |
| PlaceMapPin( | |
| geoid=r["geoid"], | |
| name=r["name"], | |
| state_code=r["state_code"], | |
| latitude=r["latitude"], | |
| longitude=r["longitude"], | |
| place_count=1, | |
| transcript_count=r["transcript_count"] or 0, | |
| ) | |
| ) | |
| state_pins = [ | |
| PlaceMapPin( | |
| geoid=r["geoid"], | |
| name=r["name"], | |
| state_code=r["state_code"], | |
| latitude=r["latitude"], | |
| longitude=r["longitude"], | |
| place_count=r["place_count"] or 0, | |
| transcript_count=r["transcript_count"] or 0, | |
| ) | |
| for r in state_rows | |
| ] | |
| # Stable level order: state, county, city, school_district. | |
| ordered = [("state", state_pins)] + [ | |
| (lvl, buckets[lvl]) for lvl in ("county", "city", "school_district") | |
| ] | |
| levels = [ | |
| PlaceMapLevel( | |
| level=lvl, | |
| label=_PLACE_LEVEL_LABELS[lvl], | |
| count=len(pins), | |
| pins=pins, | |
| ) | |
| for lvl, pins in ordered | |
| ] | |
| span.set_attribute( | |
| "browse.place_map_pins", sum(len(p) for _, p in ordered) | |
| ) | |
| return PlaceMapResponse(levels=levels) | |