CrisisEcho
Real-time AI crisis intelligence, from social signal to verified map pin
Jan 2026 — Mar 2026 · Virginia Tech · Blacksburg, VA
Documents
In one paragraph
CrisisEcho watches eight public data sources — Reddit, Twitter, Bluesky, RSS feeds, USGS, GDACS, ReliefWeb and NASA FIRMS — and turns the noise into a map of verified, GPS-precise emergencies. A post about a fire becomes a dot on a map in under three minutes, and only if six independent gates agree it is real. There is no human reviewer anywhere in the loop. It also carries an SOS system that broadcasts your location to nearby helpers in waves, with an encrypted chat and a live shared map, because detecting a crisis and surviving one are different problems.
The problem
Official emergency channels — 911 dispatch, government sensors, police scanners — routinely lag fast-moving events by minutes or hours. In that gap, people are making decisions with no information.
Meanwhile social media is the largest real-time sensor network on the planet. Ordinary people post hyperlocal signals long before any official report exists. The tools that try to exploit this fail in three consistent ways:
They are keyword-based. Inverted-index matching misses indirect language entirely. “The sky is orange and I can't breathe” never matches “wildfire”, and sarcasm inverts meaning without changing a single keyword.
They are researcher-facing. Dashboards and datasets for academics, not something a person standing near the event can open on their phone.
They are single-domain. Built for earthquakes, or for floods, and useless for everything else.
CrisisEcho was built to fail none of those three ways: semantic rather than lexical, consumer mobile rather than dashboard, and 51 crisis categories rather than one.
But the requirement that shaped every decision was harder than detection. It was trust. A crisis map is only useful if people act on it, and people only act on it if it has never lied to them. A handful of false alarms teaches users to ignore the map — and then it fails precisely when it matters. So the real design problem was: how do you build something aggressive enough to catch events in minutes, and conservative enough that it never invents one, with nobody checking its work?
System architecture
Two container images, four pods, on GKE Autopilot in us-east4. A Go Fiber API serving HTTP, WebSocket and gRPC; a Python AI sidecar hosting FastAPI, a gRPC server and the shared agent code; a Celery ingestion worker running the eight source workers and the preprocessing pipeline at concurrency 4; and a Celery agent worker running retrieval and the LLM chains at concurrency 2. Google Cloud Build rebuilds both images and performs rolling updates of all four deployments on every push to main.

The canonical data flow, end to end:
sources → Kafka (social_raw | official_alerts) → preprocess_post → SourcePost + SigLIP embeddings → run_pipeline every 60s → hybrid retrieval → 3-step LLM agent → Crisis (if verified) → Alert → Redis pub/sub → FCM/APNs → Flutter
Why two services instead of one
This was the hardest early decision and I deliberated on it for a while. Go is excellent at HTTP serving and goroutine concurrency and poor at ML. Python owns the ML ecosystem — LangChain, transformers, PyMongo, vector maths — and is comparatively weak as a high-concurrency HTTP layer. Collapsing them into one service would have been simpler on day one.
I split them because they are genuinely different workloads with different failure modes and different scaling curves. An ML pipeline spikes unpredictably; an API must stay responsive regardless. Separate containers mean each scales, deploys and crashes independently. The cost is real — two images, two dependency trees, health-check ordering — but scaling the pipeline is now a worker-count change rather than an architectural rewrite.
Three databases, on purpose
Database | What it holds and why it is separate |
crisisecho (main) | 30+ collections: per-source SourcePosts, pipeline entities, SOS sessions, users, billing, the 51 categories. Operational CRUD with 2dsphere indexes on every location field. |
crisisecho_vector | One collection, source_post_embeddings, with two Atlas Vector Search indexes at 768 dimensions, cosine. Kept apart so a large vector index can never degrade user-facing queries — and so the vector store can be swapped without touching the main database. |
crisisecho_location | Geocoding support: a TTL-indexed location_cache keyed by SHA-256 of the text, geo_priors for known reference points, and place_index for the NLP place gazetteer. |
The ingestion layer
Eight workers poll or stream their sources and produce into two Kafka topics on Aiven. Each inherits a KafkaWorker base class providing a stream() generator interface, envelope wrapping with topic/source/timestamp metadata, SHA-256 hashing of user identifiers before serialization, and retry with exponential backoff from 1s to 60s over five attempts.
Social — Reddit (PRAW streaming), Twitter (twscrape polling), Bluesky (AT Protocol firehose with keyword filtering), and configurable RSS/Atom feeds.
Official — USGS earthquakes at magnitude ≥ 2.5, GDACS (UN-backed GeoRSS), ReliefWeb (UN OCHA), and NASA FIRMS satellite wildfire detection.
Two changes mattered more than the rest. The Kafka producer became non-blocking: workers no longer wait for broker acknowledgement before pulling the next post, with delivery confirmed by a future-based callback that surfaces failures to the retry layer. Under bursty events — a high-magnitude quake, a firehose spike — the synchronous producer had been a per-message bottleneck.
And worker allocation became dynamic. The original design bound one worker to one source, so a quiet source held a worker idle while another drowned. Now four workers pull from a single queue with fair dispatch (worker_prefetch_multiplier=1) and per-source rate-limit tokens that stop any one source monopolising the queue.
Sources are not equal, and the ranking knows it. Authority weights: USGS, GDACS, ReliefWeb and NASA FIRMS at 1.0; Reddit 0.7; Twitter and Bluesky 0.6; RSS 0.5.
Preprocessing: eight steps per post
Every post runs through the same sequence as a Celery task. ML models load once per worker process at import, not per invocation.
Step | What happens |
1 · Clean | Strip URLs and emojis, normalise whitespace, optional spaCy tokenisation. |
2 · Locate | spaCy NER extracts GPE/LOC entities and resolves them against a cached place gazetteer. The EVENT's location wins over the poster's geotag. |
3 · Deduplicate | MinHash LSH with 128 permutations, 0.85 Jaccard, inside a 5-minute sliding window shared across workers via Redis. |
4 · Filter | A DistilBERT cross-encoder scores crisis relevance against a 0.6 threshold. Official sources bypass this gate entirely. |
5 · Embed text | SigLIP produces a 768-dim text vector. |
6 · Embed images | SigLIP image encoder, 768-dim normalised, up to four images per post. |
7 · Store images | Download and upload to S3, keeping original URLs as fallback. |
8 · Persist | Three writes: the SourcePost to its per-source collection, the text embedding, and one document per image embedding. |
Step 2 deserves its own note, because it is the difference between a map that means something and one that does not. A geotag tells you where the poster is, not where the event is. Someone in San Francisco tweeting about a Tokyo earthquake must not put a dot in San Francisco. So NLP extraction runs first and, on a confident hit, sets location_source=“nlp” with confidence 0.9. A GPS pair is only a fallback, and a deliberately weaker one at 0.6. If both fail the post is marked unresolved and handed to a location-enrichment cascade later in retrieval.
Step 5 and 6 used to live in different vector spaces — Vertex AI text embeddings at 1408 dimensions, SigLIP images at 512. Unifying both on SigLIP at 768 dimensions means text and image vectors share one cosine space, which is what makes image-text alignment a directly meaningful number rather than a comparison across incompatible geometries.
Retrieval and the LLM agent
Every 60 seconds — or immediately when a volume spike is detected, defined as more than 10 posts in 30 seconds from the same 0.5° grid cell — APScheduler dispatches a pipeline run. Retrieval executes four sub-queries in parallel:
Q1 · Vector search — Atlas Vector Search over text embeddings, top 50 semantically similar posts within a 50km bounding box and a 2-hour lookback.
Q2 · Geo search — MongoDB $near across all eight per-source collections, 50km, 2 hours, merged to 200 results.
Q3 · Official signals — official collections only, used purely as a boolean corroboration signal.
Q4 · Location enrichment — for unresolved posts, checks the location cache and geo priors.
Results merge, deduplicate by post ID, and rank on a composite: 0.5 × vector similarity + 0.3 × recency + 0.2 × source authority.
The agent is then three LangChain chains rather than one prompt:
Cluster — given up to 50 retrieved posts, identify distinct real-world events and return clusters with event type, location description, contributing post IDs and a confidence score.
Severity — rate each cluster 1–5, from “unconfirmed minor” to “confirmed mass casualty or critical infrastructure”.
Alert — write a two-to-three sentence public alert: calm, factual, actionable, no usernames.
Rule-based clustering was the obvious cheaper option and it does not work here. DBSCAN or k-means cannot separate “earthquake” from “gas explosion” in the same city when both produce nearby posts at the same time. Only semantic understanding splits events by meaning rather than proximity.
Six gates, because a false alert is worse than no alert
Nothing reaches a user until it has cleared six independent gates.
Gate | Rule | Rationale |
1 · Relevance | DistilBERT cross-encoder ≥ 0.6; official sources exempt | Cheapest filter first — discard obvious noise before spending embeddings on it |
2 · Deduplication | MinHash LSH, 0.85 Jaccard, 5-minute window | Viral reshares would otherwise inflate the contributor counts that gate 5 depends on |
3 · Cluster confidence | Below 0.6, the cluster is dropped | The LLM's own uncertainty is treated as a signal, not ignored |
4 · Severity | Below 3, never promoted | A confirmed-minor event is not worth waking someone for |
5 · Corroboration | Additive: ≥2 sources and ≥3 users +0.5; official corroboration +0.4; image-text alignment ≥ 0.75 with ≥2 users +0.2 | A single account, however convincing, is never enough |
6 · Location integrity | Map pin computed only from full-confidence GPS posts | No estimated or inferred coordinate ever becomes a dot |
Clusters that fail still write a UnifiedPost with verified=false, so the data survives for analytics. They simply never become a Crisis or an Alert.
Every threshold is externalised to one config.yaml, mounted via ConfigMap and re-readable on SIGHUP — an operator can tighten severity to 4 during a heat wave without rolling a new image.
And every gate writes a record to a dedicated gate_metrics collection on every drop: gate name, source, post or cluster ID, drop reason, timestamp. Logging is ephemeral; this makes drop rates queryable per source, per gate, per minute. When the map looks too sparse, the question “which gate is being over-aggressive?” has an answer you can query instead of guess.
The result is a complete audit trail. Every dot traces back: Crisis → UnifiedPost → Cluster → SourcePost → original URL. The system is not a black box; any claim it makes can be inspected back to the posts that produced it.
The SOS system
Detection is passive. Halfway through the build it became obvious that a person in immediate danger needs something else entirely, so the platform grew an active side using an Uber-style proximity broadcast.
Triggering an SOS — rate-limited to three per ten minutes — creates a session, publishes to Redis, notifies saved emergency contacts, and starts a wave-broadcast goroutine. Each wave queries a 2dsphere index for the nearest 20 opted-in users, excluding the sender, anyone already notified, and anyone with their own active SOS. It creates pending responses, pushes notifications, waits 60 seconds, expires the unanswered, and repeats until four helpers accept or nobody is left.
Push is VoIP-first with FCM fallback. On iOS with a VoIP token, an Apple PushKit push over HTTP/2 triggers a full-screen CallKit incoming-call UI with native Accept and Decline — which works even when the app is force-quit or the phone is locked. That property is the entire point; a notification you have to unlock a phone to see is not an emergency mechanism. Stale tokens fall through to FCM automatically and are cleared from user records.
Once helpers accept, everyone joins a WebSocket room backed by Redis pub/sub for live location relay, with echo prevention via per-connection IDs, a 5-second fallback poll against a durable Redis key, and a 15-second health ping so the broker does not drop idle subscriptions. Sender and each helper get a private chat encrypted at rest with AES-256-GCM — a random 12-byte nonce prepended to the ciphertext — auto-purged 24 hours after the session resolves. Only the sender can resolve a session.
The Go API and the mobile app
The API is Fiber v2, domain-driven, with 20 modules each split into model, repository, service and controller — crisis, unifiedpost, cluster, alert, user, auth, sos, community, analytics, billing, category, notify, upload, query, rag, responder, location and more. Three middleware layers: Firebase-verified JWT auth, sliding-window per-user rate limiting, and plan gating against Stripe subscriptions.
Internal calls to the sidecar moved from HTTP to gRPC — TriggerPipeline, RunQuery and Health — with stubs generated at Docker build time from a shared .proto.
The Flutter app is 24+ screens on Riverpod, Go Router and Dio: an interactive map with severity-coloured dots and category icons across 51 categories, drill-down analysis with confidence scores and corroboration badges, the alerts feed, SOS, community reports with image upload, a plan-gated analytics dashboard, saved locations with custom alert radii, and Stripe billing.
Results
Verified alert on a device in under three minutes of an event occurring, fully automated, zero human reviewers.
Zero false positives across 53 distinct global crisis test scenarios — every single-source event and every low-confidence cluster was correctly blocked.
Continuous processing across 8 sources on a 60-second cycle, covering 51 parent crisis categories and 78 subcategories.
Full data lineage preserved at every stage, so any dot on the map is auditable back to its original source URLs.
A synthetic SeedWorker generating 636 posts across 53 scenarios in 30+ countries, so the whole pipeline can be exercised without depending on live external APIs.
What I would do differently
I would build the gate instrumentation first instead of last. For weeks I tuned six thresholds against intuition and spot checks, which is slow and unfalsifiable. The moment every gate emitted a structured drop record, tuning stopped being an argument and became a measurement. Everything I learned in those weeks I could have learned in an afternoon with the metrics in place.
I would also have unified the embedding space earlier. Running Vertex AI text vectors at 1408 dimensions against SigLIP image vectors at 512 meant image-text alignment was never quite trustworthy, and I spent time trying to calibrate a number that was structurally meaningless until both modalities shared one space.
Stack
Go · Fiber · gRPC · Python · FastAPI · Celery · APScheduler · LangChain · Gemini 2.0 Flash · DistilBERT · SigLIP · spaCy · Apache Kafka · MongoDB Atlas + Atlas Vector Search · Redis/Valkey · AWS S3 · Flutter · Riverpod · Firebase Auth · FCM · APNs/PushKit · Stripe · GKE Autopilot · Cloud Build