Skip to content

Web App & Backend

cd-webapp is CivicDog’s core product: find your representatives by address or by your current location, open a member’s page, search their voting record in plain language, and get an AI summary of how they voted on a topic. It’s a React app that talks to exactly one backend, cd-server, a GraphQL service built for it.

graph LR
W["cd-webapp\nReact + Vite"] -->|"GraphQL\nBearer ID token"| S["cd-server\nFastAPI + Strawberry"]
W -->|PKCE sign-in| C["Cognito\nUser Pool"]
S -->|"verify JWT\n(JWKS)"| C
S -->|"Lambda invoke\n(boto3, IAM)"| A["cd-api\nLambda"]
S --> G["Census\ngeocoder"]
S -->|Converse API| B["AWS Bedrock\nClaude"]
S --> D[("cd_customers\nPostgres")]

style W fill:#eef3fa,stroke:#27619c
style S fill:#cfe0f2,stroke:#0a2246
style C fill:#eef3fa,stroke:#27619c
style A fill:#eef3fa,stroke:#27619c
style G fill:#ffffff,stroke:#8ba0bd
style B fill:#eef3fa,stroke:#27619c
style D fill:#cfe0f2,stroke:#0a2246
  • React 19 + TypeScript, built with Vite, styled with Tailwind CSS v4, tested with Vitest and React Testing Library. It’s hosted on AWS Amplify, which deploys every push to main. Every pull request runs tests, a production build, and lint first.
  • Sign-in uses Cognito’s managed login with the authorization code + PKCE flow, so no client secret ever ships to the browser. Requests to cd-server carry the resulting ID token as a bearer token.
  • “Use my location” resolves the browser’s coordinates to a congressional district through cd-server, since the Census geocoder sends no CORS headers and can’t be called from the browser directly.

A FastAPI app serving a Strawberry GraphQL schema at server.civicdog.com. It has its own Postgres database, cd_customers, which nothing else touches.

Each resolver delegates to a service in services/, using a deliberate naming split:

  • A client wraps one external system and is named after what it’s a client of.
  • A service is what a resolver depends on. It may use several clients, owns the real logic, and is named after the capability it provides.

CdApiService, for example, picks between two interchangeable clients behind one interface. Locally it uses plain HTTP. In production it invokes cd-api’s Lambda directly with boto3, skipping API Gateway entirely: authorization is an IAM lambda:InvokeFunction grant, so no network path or API key is needed between the two. boto3 has no async API, so that call runs in asyncio.to_thread(). Every resolver is async, so a query asking for several fields fans out its cd-api calls concurrently instead of one after another. cd-api’s JSON:API responses are validated against shared cd-lib models before a resolver ever sees them.

Field What it does
getDistrict(address) / getDistrictByCoords(lat, lng) Address or coordinates → state + district, via the Census geocoder
getSenators / getRepresentatives Who represents a state or district
getMember(bioguideId) One member, for a deep-linkable detail page, including members who’ve left office
discoverBills(q) Semantic search over bills by topic
searchBills(bioguideId, q) The same search, joined with how that member voted on each bill
getStates States and territories, with seat counts and voting status
summarizeVotingRecord (mutation) An AI summary of a member’s votes on a topic. Requires sign-in
myAiSummaries / features A user’s past summaries, and what they can still do today. Requires sign-in

searchBills is a composition of two cd-api endpoints: topic search, then a member’s votes on exactly those bills. A matched bill the member never voted on comes back with votes: [], a first-class “no vote on record” state rather than a silently dropped row.

Every GraphQL request passes through a context hook that inspects the Authorization header:

  • No token: the request goes through anonymously. Lookup and search don’t need an account.
  • A valid token: verified against the Cognito user pool’s published signing keys (JWKS), with the issuer, audience, and token type all checked. The user is then upserted into cd_customers.
  • An invalid token: the whole request gets an HTTP 401 before any resolver runs. A bad signature, wrong audience, or wrong token type is never quietly treated as anonymous.
  • cd-server’s own problems (a database hiccup during the upsert, or a failure fetching Cognito’s keys) degrade to anonymous instead. A caller’s request shouldn’t fail because of infrastructure unrelated to whether their token is valid.

Locally, auth verification is simply off when Cognito isn’t configured, so the backend runs with zero AWS setup. In any other environment, missing configuration fails at startup instead.

summarizeVotingRecord(bioguideId, q) produces a short, nonpartisan summary of how a member voted on the bills matching a topic. It’s the first feature that costs real money per call, so it got the most care.

It’s retrieval-augmented generation (RAG). The model never answers from what it already knows about a member or a bill. Every summary is generated from records CivicDog retrieved for that request, so each claim traces back to a real bill and a real recorded vote.

graph LR
Q["Topic
'drug prices'"] --> R["Retrieve
semantic search
+ member's votes"]
R --> A["Augment
bills + votes as JSON
in the prompt"]
A --> G["Generate
Claude on Bedrock"]
G --> S["Summary
stored with prompt
+ model ID"]

style Q fill:#ffffff,stroke:#8ba0bd
style R fill:#eef3fa,stroke:#27619c
style A fill:#eef3fa,stroke:#27619c
style G fill:#eef3fa,stroke:#27619c
style S fill:#ffffff,stroke:#8ba0bd
  1. Retrieve. The topic runs through the same semantic search as searchBills: an exact policy-area or subject match first, then Titan embedding similarity over bill summaries in pgvector. The matching bills are joined with this member’s recorded votes on them, and the member’s name is fetched concurrently.
  2. Augment. The retrieved bills and votes go into the prompt as structured JSON. They carry each bill’s match tier, so the model knows a similarity match is lower confidence than an exact tag match.
  3. Generate. An Anthropic Claude model writes the summary through Bedrock’s Converse API, with a fixed system prompt that limits it to only the provided data: no outside knowledge, and nothing the data doesn’t support.
  4. A truncated or empty completion is rejected, not stored as if it were finished.
  5. The result is stored and returned.

The system prompt turns the retrieved data’s structure into rules the model must follow, aimed at the ways a voting-record summary typically goes wrong:

  • A bill with no recorded vote is stated explicitly. It is never dropped, and a vote is never invented for it.
  • Procedural votes are labelled as procedural. For example, a motion to recommit is never reported as a vote on the bill itself.
  • Similarity matches are flagged as lower confidence, so a tangentially related bill isn’t presented as squarely on-topic.
  • The member’s name always comes from the server, never from the client.
  • The user’s topic is capped at 200 characters. It sits in a natural-language wrapper outside the JSON bill data, which is the only part the system prompt treats as trusted.

The ai_summaries table is general-purpose: a kind column plus a self-describing JSONB subject, so a future summary type needs no schema change. Each row also records the exact system prompt and model ID that produced it, so a later prompt change can be compared against output quality. The stored bill data is complete enough for a history view to re-render the original results, even after the underlying search would return something different.

Every call is real Bedrock spend, so two limits apply, both counted per UTC day:

  • a per-user free-tier allowance (10 by default)
  • a global cost ceiling (100 by default), which takes precedence

A features query tells the web app whether “Summarize with AI” is available and why not: personal limit reached or globally unavailable, plus usage so far and when it resets. The mutation enforces the same check server-side before any cd-api or Bedrock work, so the query is only a UI hint, not the control.

Checking the count, generating, and inserting isn’t one atomic step, so a burst of concurrent requests can overshoot a cap by a handful. That’s an accepted trade-off: this is a cost guardrail, not a security boundary. A failed generation stores nothing, so it doesn’t count against anyone.

cd-server is containerized, like cd-etl. make start-server runs it in Docker Compose with its own migrations applied, and GraphiQL comes up at localhost:8000/graphql. In production it runs on ECS behind an ALB with zero-downtime rolling deploys. See CI/CD & Automation for how splitting migrations into their own task made that possible.