open-navigator / api /routes /browse.py
jcbowyer's picture
Clean HuggingFace deployment without binary files
e59d91d
Raw
History Blame Contribute Delete
16.4 kB
"""
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
@router.get("/summary", response_model=BrowseSummary)
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)
@router.get("/top-items", response_model=BrowseTopItems)
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
]
)
@router.get("/place-map", response_model=PlaceMapResponse)
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)