Guidance for AI agents (and humans) working in this codebase.
SF Pulse Python is a FastAPI port of the original TypeScript app at render-examples/sf-pulse-ts. It tracks SF restaurant openings.
This repo is also a GitHub template for the Should Agents Be Durable? workshop at AI Council. Workshop attendees extend the durable Render Workflows pipeline with an SF events feature. The implementation spec for that exercise lives in demo_prompt.md; follow it verbatim when asked to "add the Events feature."
It uses:
- FastAPI + Jinja2 templates for HTML pages and JSON APIs
- asyncpg + plain SQL migrations
- Render Workflows (Python SDK) for the daily scraping pipeline
- LLM-based extraction (OpenAI or Anthropic) with regex fallback
- Web push + SSE realtime
- A small React + Vite sub-project at
web/diagram/for the workflow visualization (kept verbatim from the TS repo)
- PostgreSQL is the source of truth. Never rewrite application SQL or behavior to work around test infrastructure.
- Tests use real Postgres. The test suite spins up an actual PostgreSQL container via testcontainers-python. No mocks, no fakes for the database layer. If a feature works in pg-mem-style mocks but breaks against real Postgres, that's a real bug.
- Tests are mandatory. Every feature, bug fix, or behavior change must include or update tests.
Web service (uvicorn app.main:app):
- Renders HTML pages from Jinja2 templates (home, map, detail pages, diagram iframe shell)
- Exposes JSON API at
/api/* - Streams realtime updates via SSE (Redis pub/sub when
REDIS_URLis set; in-process fallback otherwise) - Sends web push notifications via pywebpush
Workflow service (python -m workflow.main):
- Registers tasks via
@app.taskdecorators on theWorkflows()instance defined inworkflow/_app.py - The
daily_refreshorchestrator fans out to source-fetch tasks viaasyncio.gather, runs LLM extraction conditionally, dedupes, and callsapply_discovered_items - Each source task is a thin wrapper around an
app.sources.*function
Cron job (python -m bin.trigger_workflow):
- Uses the Render Python SDK (
Renderclient) to start thedaily-refreshtask by slug - Polls until completion. Exits 0 on success, 1 on failure.
Database:
- Plain SQL migrations in
migrations/(numeric prefix). Migrations copied verbatim from sf-pulse-ts and are standard PostgreSQL. bin/migrate.pyruns them. Tracked inschema_migrations. Idempotent.
Realtime:
app.sseexposesbroadcast(event, data)and anEventSourceResponsestream.- When
REDIS_URLis set, broadcasts go to a Redis pub/sub channelsf-pulse:realtimeso multiple web service instances see each other's events. The managed Render product backing this is Render Key Value (Valkey), which is Redis-compatible.
LLM extraction:
app.llmis provider-agnostic. The factory (get_llm_client()) auto-detects fromLLM_API_KEY(sk-ant-prefix means Anthropic, otherwise OpenAI) or fromLLM_PROVIDER.- Returns
Nonegracefully when no API key is set. Callers degrade to regex-only extraction (SFist and Michelin still produce results).
For a deeper "why each component exists" walkthrough plus the daily-refresh sequence diagram, see docs/architecture.md.
- Python 3.12+,
from __future__ import annotationsat the top of every module. - Pydantic v2 for request/response models, validators, and settings.
- asyncpg with parameterized queries (
$1,$2, …). Never f-string SQL. - Logging:
logging.getLogger(__name__). INFO for lifecycle, WARNING for degraded states, ERROR for failures. Use stable prefixes like[migrate],[realtime],[push]. - No comments unless something is genuinely non-obvious. Don't explain WHAT; well-named identifiers do that.
- Type hints everywhere. Pyright runs in CI.
- Ruff for lint (config in
pyproject.toml). - No semicolons (Python doesn't use them; this matches the original TS Prettier config aesthetically too).
app.storage accepts an optional pool keyword argument on every function for test injection. ON CONFLICT upserts use identity_key (restaurants) to prevent duplicates.
Plain SQL files in migrations/. Each runs in a single transaction. Must be:
- Idempotent: use
IF NOT EXISTS,ON CONFLICT,WHERE NOT EXISTSguards - Standard PostgreSQL: no testcontainer-specific workarounds
Run uv run pytest tests/test_migrate.py before the full suite when editing migrations.
- Mutation endpoints require
x-cron-secretheader matchingCRON_SECRET. - Push endpoints validate trusted provider hostnames (
fcm.googleapis.com,*.push.apple.com, and so on). Seeapp.security.is_trusted_push_endpoint. - All user input goes through Pydantic schemas in
app.securityor directly on FastAPI route handlers.
When adding or changing features, update:
README.mdfor user-facing setup, workshop steps, and env varsAGENTS.mdfor architecture and conventionsdocs/architecture.mdfor non-trivial structural changesdocs/deployment.mdfor deploy-related changes
Local secrets go in .env.local (gitignored). Only DATABASE_URL is required for the app to boot. Tests don't need any env vars (they spin up their own Postgres).
For full LLM extraction set LLM_API_KEY. For push notifications set VAPID_PUBLIC_KEY and VAPID_PRIVATE_KEY.
Two supported flows for running the app outside Render:
- Docker Compose (preferred for the workshop):
docker compose up --buildbrings up Postgres, Valkey, and the FastAPI app with migrations applied. The app is at http://localhost:8000. Postgres and Valkey are exposed onlocalhost:5432andlocalhost:6379sorender workflows devon the host can connect to them. Seecompose.yamlanddocker/Dockerfile. The Dockerfile lives underdocker/(not the repo root) so Render's "New Workflow" flow doesn't auto-detect the workflow service as Docker — workflow attendees pick Python 3 explicitly. - Native uv:
uv sync && uv run python -m bin.migrate && uv run uvicorn app.main:app --reload. Requires a local Postgres install.
When implementing workshop changes, prefer the Docker Compose flow so the agent's setup matches the attendee's.