open-navigator / api /routes /topics.py
jcbowyer's picture
Clean HuggingFace deployment without binary files
e59d91d
Raw
History Blame Contribute Delete
4.61 kB
"""
Civic-topic catalog API — GET /api/topics.
Serves the CivicSearch topic taxonomy (public.civicsearch_topic): a small,
fixed list (~75 rows) of named topics, each with its query slug and the
keyword set used to recall it. Reads the published serving view over gold
(resolves as public.civicsearch_topic / gold.civicsearch_topic via the
connection search_path, matching the other routes).
asyncpg gotcha: the pool has no JSONB codec, so `keyword_stats` arrives as a
TEXT blob and must be json.loads()'d before it is a real list.
"""
from __future__ import annotations
import json
from typing import 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/topics", tags=["topics"])
tracer = trace.get_tracer(__name__)
class TopicSummary(BaseModel):
topic_id: int
name: str
query_id: Optional[str] = None
keywords: List[str] = []
# How many CivicSearch transcript snippets are tagged with this topic — the
# "how often it comes up" signal the catalog is sorted by (desc).
transcript_occurrences: int = 0
# Sorted by transcript occurrences (most-discussed first) so the browse flyout's
# top-N is the topics that actually come up most in the record; name breaks ties.
# keyword_stats is JSONB (returned as TEXT by this pool) — parsed below.
#
# $2 = optional 2-letter state code. When given, restrict to topics actually
# discussed in that state via browse_entity_state_transcript_count (the per-state
# bridge) — this is what lets the homepage "Browse topics" land pre-filtered to
# the user's selected place. NULL state = the full national catalog.
_TOPICS_SQL = """
SELECT topic_id, name, query_id, keyword_stats, transcript_occurrences
FROM civicsearch_topic t
WHERE ($1::text IS NULL OR name ILIKE $1)
AND ($2::text IS NULL OR EXISTS (
SELECT 1 FROM browse_entity_state_transcript_count b
WHERE b.entity_type = 'topic'
AND b.entity_id = t.topic_id::text
AND b.state_code = $2::text
))
ORDER BY transcript_occurrences DESC NULLS LAST, name ASC
"""
def _parse_keywords(raw) -> List[str]:
"""Coerce the JSONB keyword_stats column into a clean list[str].
The pool hands JSONB back as a TEXT string, so json.loads it; tolerate an
already-parsed list, a null, or malformed JSON (-> empty list) rather than
500-ing the whole catalog over one bad row.
"""
if raw is None:
return []
if isinstance(raw, list):
return [str(k) for k in raw]
try:
parsed = json.loads(raw)
except (TypeError, ValueError):
return []
if isinstance(parsed, list):
return [str(k) for k in parsed]
return []
@router.get("", response_model=List[TopicSummary])
async def list_topics(
q: Optional[str] = Query(None, description="Optional case-insensitive substring filter on topic name."),
state: Optional[str] = Query(None, description="Optional 2-letter state code; restricts to topics discussed in that state."),
) -> List[TopicSummary]:
"""Civic topics (most-discussed first), optionally narrowed by a name
substring and/or restricted to a single state."""
with tracer.start_as_current_span("topics-list") as span:
span.set_attribute("topics.q", (q or "").strip())
like = f"%{q.strip()}%" if q and q.strip() else None
# Normalize to the 2-letter upper-case form the bridge stores; blank -> None.
state_code = state.strip().upper() if state and state.strip() else None
span.set_attribute("topics.state", state_code or "")
try:
pool = await get_db_pool()
async with pool.acquire() as conn:
with tracer.start_as_current_span("topics-query"):
rows = await conn.fetch(_TOPICS_SQL, like, state_code)
except Exception as exc: # noqa: BLE001
logger.exception("topics list failed")
span.record_exception(exc)
# Empty list over a 500 — the frontend renders an empty state.
return []
span.set_attribute("topics.count", len(rows))
return [
TopicSummary(
topic_id=r["topic_id"],
name=r["name"],
query_id=r["query_id"],
keywords=_parse_keywords(r["keyword_stats"]),
transcript_occurrences=r["transcript_occurrences"] or 0,
)
for r in rows
]