AI lead generation
B2B Prospecting Platform
AI-generated B2B leads with verified records, no CRM duplicates, and a model that learns why sales discards them.
- deals extracted to train the model
- 27.4k
- faster enrichment query
- 167×
- commits in 5 months
- 233
- backend test files
- 92
Figures measured from the repository.
Activity codes suggested by AI
- 2221-8/00
- 2222-6/00
- 1731-1/00
- 1732-0/00
| Company | Tax ID | State | Since | Gate |
|---|---|---|---|---|
| Embalagens Exemplo Ltda | 00.000.000/0001-00 | PR | 2009 | PERMIT |
| Plásticos Fictícios S.A. | 00.000.000/0001-00 | SC | 1998 | PERMIT |
| Caixas Modelo Indústria | 00.000.000/0001-00 | RS | 2015 | REVIEW |
| Papelão Demonstração Ltda | 00.000.000/0001-00 | PR | 2021 | BLOCK |
| Filmes Teste Industrial | 00.000.000/0001-00 | SC | 2004 | PERMIT |
Context and problem
Building a B2B prospecting list takes three things that usually live apart: finding companies worth contacting, checking each one’s company record and putting the result into the CRM without duplicating what is already there. Each step fails in its own way. Closed companies get in, a branch gets in instead of the head office, the same CNPJ shows up twice in different pipelines.
The answer was to bring the three steps into one internal platform. The user describes a market or uploads a spreadsheet, the platform qualifies every company against a single rule set and imports into the CRM in batches, with live progress and rollback. Over time more modules came: one that rescues discarded deals, a model that tries to learn why sales discards leads and a sales intelligence area with a funnel and a map.
The “Technical details” blocks open with a click, for anyone who wants to see how each part works inside.
Project constraints
- One rule set. A company rejected by the registry check cannot get through because it arrived another way, be it AI, spreadsheet or rescue.
- The CRM has a life of its own. It runs native automations that react to every write, and the platform has to live with them.
- Cost controlled per person. Each user has monthly credits, and no failure or retry can charge twice.
- Company login. Corporate SSO with OIDC and PKCE. Role-based authorization lives in the platform itself.
- Searches that can be huge. The registry data covers the whole country, and a badly scoped search takes the server down.
Decisions that shaped the product
Architecture
A company comes in from AI generation, a spreadsheet, or as a discarded deal. The registry lookup goes through cache, the official source and public providers, always with the same rule. Then come the blocklist, the business rules and the activity-code scorer. The model score is shown but does not block. When the deal already exists in the CRM, moving it takes two phases: first the flag, then the stage change. Credit is charged once per deal.If phase B fails, the deal stays flagged and parked. Resume resends phase B only, never flag and move together, because the CRM deleted deals that got both in the same write. A per-deal idempotency key and a unique index in the database make sure resuming never charges credit twice.
The backend is a FastAPI app on Python 3.12 that also serves the frontend. The source of truth is a remote PostgreSQL, which holds what has already been referred and imported. The application’s local state lives in SQLite: sessions, jobs, CNPJ cache and credits. Long work (generation, import, rescue) runs in the background, and progress reaches the browser over SSE.
Technical details: stack and infrastructure choicesFastAPI · React without build · OIDC
The frontend is a React 18 SPA with no build step: JSX is transpiled in the browser and the API itself serves the page. I chose this so the deploy would be a single container, with no frontend pipeline. The cost shows up in a slower first load and in the lack of typing and component tests.
The split between the two databases follows who owns the data. The remote PostgreSQL is shared with other systems and says what already exists. SQLite stores what only matters to this application, so it sits on a Docker volume, outside the container, to survive a rebuild.
Login is OIDC with PKCE against the company SSO, with a secure cookie by default. Roles (user and admin) and monthly credits live in the platform itself, because the SSO says who the person is, not what they can spend. The test suite runs in a CI workflow.
What the platform delivers
Registry check
Every CNPJ goes through the same rule set, wherever it came from. The result is cached for 120 days.
Technical details: cache, official source and three providerscache · 120-day TTL
The order is fixed. The SQLite cache answers if it has a record less than 120 days old. Without one, the lookup goes to the official registry API. If the official one fails, and the admin allows it, three public providers are tried in sequence. The rule set (active, head office, size, founded 3 or more years ago, legal nature other than sole proprietor) is applied after the response, the same at every layer, so the choice of provider does not change the verdict.
The price of the cache is staleness: a company whose status changes within the window is only seen on the next trip to the source. I accepted that because the same company is looked up many times by different users, and every trip to the source pays latency and rate limits.
AI lead generation
The user describes the market and gets a list of companies already qualified and deduplicated against the database.
Technical details: Gemini-suggested CNAEs, dedup and per-user lockjob · per-user lock
Gemini researches the described market and suggests economic-activity codes (CNAE). From those the platform extracts companies, runs each one through the registry rule set and removes what already exists in the source of truth. Secondary CNAEs come in only if the user ticks the option. Generation runs in the background, and lists are saved: a reload does not lose the work.
Each user has a lock against concurrent generation, so two of their generations never compete for the same credits and the same companies. When generation runs out of new companies, it falls back to the rescue and moves deals that already exist in the CRM, instead of creating duplicates. That path is best effort: if it fails, generation delivers what it has and does not crash.
CRM import
An XLSX or CSV spreadsheet goes in, deals come out in the CRM, with live progress and the option to undo the whole job.
Technical details: batches, throttling, exponential retry and per-job rollbackSSE · per-job rollback
The import is asynchronous and gets a job id as soon as it starts. Rows are deduplicated and sent in throttled batches, so as not to hit the CRM’s rate limit. Transient failures are retried with exponential backoff. Progress goes to the browser over SSE.
Everything a job created is tied to its id, and rollback undoes only that job. What failed, and what was blocked for already existing in the source of truth, can be exported to a file for fixing and resending, instead of getting lost in the log.
Rescuing discarded deals
Discarded deals are evaluated again with the same rules as a new lead, and the ones that pass go back into the funnel.
Technical details: layered gate and two idempotent phasesL1 to L4 gate · 2 phases
| Layer | What it does | Blocks? |
|---|---|---|
| L1 | Heuristic blocklist (condominiums, funds, sole proprietors by legal nature) | Yes |
| L2 | 5 qualification business rules | Yes |
| L3 | CNAE scorer | Yes |
| L4 | LightGBM score | No, advice only |
What passes goes to the two-phase move. Phase A writes the tag, the platform waits a configurable interval, and phase B moves the deal and clears the owner. I split the phases after seeing the CRM send to deduplication, and delete, deals that got the tag and the move in the same write.
The idempotency key combines run and deal. A deal stuck between phases is recognized, and the resume resends only phase B. The credit is charged once per moved deal, guaranteed by a unique index on the credit ledger. One limit remains open: one of the pipelines silently reverts category changes made through the API, even while returning success. That led to the rule of taking a snapshot of every field before any write, so changes can be reverted with values that were read, not inferred.
Discard model
A model that learns from history why sales discards leads and classifies each new lead as PERMIT, REVIEW or BLOCK before it becomes a deal.
Technical details: calibrated LightGBM, temporal split and promotion gateLightGBM · calibrated
27.4k deals were extracted, 14.8k of them with a known label. The split is temporal, 80/10/10 ordered by creation date, because a random split would leak the future into training. Categorical variables use target encoding with smoothing. Imbalance is handled with scale_pos_weight instead of SMOTE, the output goes through isotonic calibration, and every prediction comes with a SHAP explanation.
On the test set: ROC-AUC of 0.797, PR-AUC of 0.095 and precision@10 of 0.239. The PR-AUC looks low, but the positive class is rare, and the number has to be read against that base rate. A new model is only promoted in the registry if it wins on PR-AUC and keeps calibration error at or below 0.05. For concept drift, the training window dropped from 365 to 180 days, with a 60-day half-life on the weights.
Enrichment joins the deals against a 71-million-row table. The first version filtered with ANY() over a text array and fell into a full scan. Switching to a JOIN with unnest made the query 167× faster.
Sales intelligence
A 7-stage prospecting funnel, a map with a per-city drill-down and import straight from the chosen city.
Technical details: scoped similar-company search and size checkHTTP 422 · ≥N
The first version of the similar-company search used all of the target company’s CNAEs as the seed. In a real case, one secondary transport code was enough for 87.5% of the candidates to be trucking companies. Today the seed is the primary CNAE, expanded through the official hierarchy with exact, group or sector scope, and secondary codes come in only as a user option.
Broad searches hit the 120 s timeout for two reasons: counts with no state filter and a secondary-CNAE index used even when it was not needed. Now the index is only used when the search asks for it, a pre-check rejects a scope that is too large with HTTP 422, and counts have a low cap: above it the screen shows “≥N” instead of waiting for the exact number.
On the map, import by city uses the exact city and state, with no spelling normalization, so the credit count matches what the map showed. The sales analysis tabs were ported from a colleague’s Streamlit apps, with a numeric check against the originals.
My role
I conceived and built the platform: product, backend, frontend, ML model, integrations and operations. That came to 233 commits between May and October 2026, 92 backend test files and CI running the suite. The sales analysis tabs came from a colleague’s apps; I ported those tabs and checked that the numbers matched the originals.
What I would do differently
- Snapshot fields before writing, from day one. One of the pipelines still silently reverts changes made through the API. I would have adopted the rule before discovering the problem.
- Label the right population for the rescue. The discard model was trained on a different population and so only gives advice. With labels from the rescued deals themselves, it could decide.
- Test the frontend. The no-build SPA is simple to serve, but it has 3 tests, against 92 files on the backend.