Systems · 2025 to 2026

A social and market signal intelligence backend

Built a client backend alone: it ingests Telegram, Discord and X, summarises with AI, searches semantically and raises alerts.

The problem

What was needed, and what made it hard.

A client wanted chatter from three unreliable platforms, some of it about crypto tokens, turned into something they could search and be alerted on. Each platform fails in its own way, so each needed its own listener and parser. On top of that sat AI summaries, semantic search, live market data and alerts that had to mean something when they fired. It was one engineer, and the client would own the code afterwards, so it had to survive a handover to people who were not in the room.

The system

One message, from a platform to an alert in Discord.

Listeners 1 Parsers and store 2 Queue and workers 3 AI and search 4 Alert engine 5 Discord output 6 Listeners 1 Parsers and store 2 Queue and workers 3 AI and search 4 Alert engine 5 Discord output 6
One message, from a platform to an alert in Discord.

Select a component to read what it does and how it fails.

  1. Listeners. One listener per platform. Telegram backs off from 5 to 60 seconds and stops at 10 retries. It fails when a connection drops.
  2. Parsers and store. Parsers write messages into a 10-table Postgres schema. Bad input gets in: numbers like 114 were once stored as coin tickers.
  3. Queue and workers. Redis with ACL auth carries cache, queue and rate control. ARQ workers run the jobs and will not start if their tasks are unregistered.
  4. AI and search. Summarises each message, then embeds it into pgvector for semantic search. It failed quietly once: alerts fired before the summary was attached.
  5. Alert engine. Gates on source count and trust score, and deduplicates on a content hash. Too tight and nothing fires. The thresholds are configuration.
  6. Discord output. Posts each alert to Discord. Long alerts were silently truncated by the platform's embed limits, so I chunk them to fit.

Decisions

What I chose, what I turned down, and what it cost.

1Hosted embeddings on a purpose-built pgvector image

I chose
I moved embeddings from a local model at 384 dimensions to a hosted model at 768, on a database image built for pgvector.
I turned down
Keeping the local model and the alpine Postgres image my first version used.
Why
That image could never run pgvector's own migrations, so my first vector search sat on a base that could not work. I did not record why I preferred hosted over local, so I will not invent a reason now.
What it cost
A migration to widen every embedding column, and search now depends on an external API.

2Plan the queue migration before touching code

I chose
I wrote a JSON baseline of the existing task graph, then moved background jobs from Celery to ARQ in a planned run of commits.
I turned down
A rewrite done in the middle of an incident.
Why
A queue change touches every ingestion path. The baseline gave me something to check the new workers against.
What it cost
Work before any code moved, and the migration still hit two bugs: worker registration and an event-loop conflict.

3Ship a known limitation and write it down

I chose
I accepted a locking refresh for the materialised views and recorded the reason in the commit.
I turned down
A non-locking refresh, which needs a unique index the views did not have.
Why
A limitation written down can be fixed by the next engineer. One left silent cannot.
What it cost
Reads lock during every refresh, and the indexes were never added.

What broke

What I noticed, what caused it, and how I fixed it.

Messages arrived and no alert fired

What I saw
Messages were being listened to and saved, and the alert engine never triggered.
The cause
Two quality gates and a duplicate check stood in front of every alert. I put them in from the first version, so a strict threshold and a broken path looked identical.
The fix
I replaced the trigger with one that alerts on every call, marked plainly as a debugging change. Alerts fired, so the path was sound. The thresholds came back later as configuration.

Alerts fired before the analysis arrived

What I saw
The AI service answered correctly and nobody received the answer.
The cause
The analysis call was fire and forget, so alerts left first. Separately, the service address did not work across the container network.
The fix
I made the analysis blocking for alerts and corrected the address.

Three failures in one X session

What I saw
The X scraper was non-functional and its worker refused to start.
The cause
Async database calls mixed with a thread pool caused event-loop errors. An async generator was iterated synchronously. Inconsistent import paths left the task functions unregistered.
The fix
I fixed all three in one session and removed the thread mixing entirely.

Outcome

What came of it, and what it was built with.

  • The backend was completed, not left as a prototype, and handed over to the client's own repository.
  • It covers a 10-table schema, three ingestion listeners, AI summaries, semantic search, alerts and live market data, with tests across ingestion, alerting and search.
  • I have no deployment, usage or accuracy figures, and I claim none.

Built with

  • Python
  • FastAPI
  • PostgreSQL
  • pgvector
  • Redis
  • ARQ
  • Google Gemini embeddings
  • Docker Compose
  • Alembic
  • Telegram
  • Discord
  • X

The rule I took from this

The line I keep from this project.

Prove the path end to end before you tune the thresholds.

Rule 3 of 10

Keep reading