Spaces:
Running on CPU Upgrade
Running on CPU Upgrade
| """ | |
| 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 [] | |
| 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 | |
| ] | |