Alexei Rojas Quiroga
← All projects

Personal project

Apagones Habana

Near-real-time Havana blackout map built from a Telegram channel

Status
Live
Role
Solo: architecture, data pipeline, LLM integration, frontend, bot, deployment and ops
Live site GitHub

Architecture

  • Triggered by an external watchdog every 30 min
  • Serverless on Cloudflare + GitHub Actions
  • Rules first, LLM enrichment second
  • Tests gate every publish
  • Supabase PostgreSQL

gha runs the tests, then the ingestor pulls new posts, rules and LLM jobs turn them into events and enrichment, and builders publish fresh static data to Pages.

Architecture · 10 nodes · 1 flows
WHAT I BUILTTelegramOutage sourceOfficial channel anddiscussion groupPython · Telet…Message ingestionIncremental pull ofposts and commentsPython · regexOutage extractionRules by block, circuitand zonePython scriptsLLM enrichment jobsPartes, comments, voicenotes, embeddingsPython scriptsStatic data builderestado.json, circuits,analytics, SEOCloudflare Pag…Site and API edgeStatic site plus /apiendpointsMapLibre · JSOutage mapMap, circuits,analytics, FAQSupabase · Pos…Messages and eventsstoremensajes, eventos,comentarios_llm,reportes, vectorsNaN · NIM · Wo…LLM providersNaN Builders, NVIDIANIM, Workers AIGitHub ActionsPipeline runnerTests, ingest, build,deploy1234567891011
  • Service / compute
  • Data store
  • AI
  • Client
  • External system
  • Synchronous
  • Async / loop
Scroll sideways to see the full diagram

How it flows, step by step

Click a step to jump to it. Click a component for details.

What it does

A public map and Telegram bot that tells Havana residents which blocks and circuits have power. It ingests the utility's Telegram channel and its comment group, extracts outage events with rules plus LLM enrichment, and publishes a static, Cuba-reachable site with analytics, circuit pages and a conversational assistant.

The problem

Outage information in Havana is scattered across free-text posts and comments on a Telegram channel. Residents have no structured, searchable view of current state, hours without power per circuit, or history.

What I built

  • End-to-end pipeline from raw Telegram posts to a published static site, run by GitHub Actions and deployed to Cloudflare Pages with no always-on server.
  • Independent watchdog Worker detects stale published data, cancels zombie CI runs, re-triggers ingestion and opens an alert issue after a real 18-hour stall.
  • Multi-provider LLM client with preference order, fallback and per-provider daily quota tracking; the LLM enriches free text but never replaces deterministic rules.
  • Telegram assistant uses tool-calling over precomputed data and a pgvector RAG fallback, so every figure comes from deterministic tools rather than model arithmetic.
  • Extensive regression suite (about 37 test modules) gates every publish: a failing test stops deployment instead of shipping wrong state.
  • Privacy-aware neighbor reports: IPs are salted and hashed, rate-limited per IP, and location is bounded to Havana.

Key decisions and why

01

Cloudflare for hosting instead of Vercel/AWS

Cloudflare is generally reachable from Cuban IPs, which matters because the users are in Cuba. Tiles and data are served from the same origin so nothing depends on blocked third parties.

02

Pull-based ingestion via CI cron, not a persistent connection

Telethon fetches messages since the last stored id on each run, avoiding a paid always-on server. A user MTProto account is required because a bot cannot read the channel.

03

A Cloudflare Worker as the only trigger and watchdog

GitHub's own scheduler arrived late on average and overlapping triggers cancelled each other. A component outside GitHub fires workflow_dispatch and monitors freshness, so a GitHub stall cannot hide itself.

04

Rules first, LLM as best-effort enrichment

Official posts are highly regular, so regex rules handle most of them cheaply and predictably. LLM steps are non-blocking and state ages to unknown when there is no news, avoiding confident wrong answers.

05

Embeddings in Supabase pgvector instead of a static JSON

The vector index was too large to ship as a static file. Keeping it in the database means vectors never leave the server; the worker sends the query and receives only the top-k fragments, using 1024-dim Matryoshka embeddings to fit ivfflat limits.

Tech stack

Languages
Python 3.12JavaScript
Frontend
Leaflet + GeoJSON
Cloud
Cloudflare PagesCloudflare Workers
Data
Supabase (PostgreSQL)pgvector
Messaging
Telethon (MTProto)Telegram Bot API
AI
NaN Builders / NVIDIA NIM / Cloudflare Workers AIWhisper transcription
DevOps
GitHub Actions
Testing
unittest regression suite