Overview
DUBTEL AI sells AI voice agents for outreach and support. Its sales team had a large CSV of target companies, mostly healthcare providers, with company names and often nothing else. Finding a contact for each one by hand is slow, repetitive work.
I built a Python job that does the searching with Gemini. For each company it looks up the publicly listed operating manager or administrator, or the general contact email and phone if no named person is listed. The results go back into the lead list.
How it works
- Load and resume. The job downloads the master lead CSV, or resumes from the newest timestamped output file if a previous run exists.
- Pick targets. It skips rows that already have a contact or were claimed by a salesperson, and takes the next batch of empty rows, up to a per-run limit set in the environment.
- Ask Gemini, with search. It sends ten companies per request to
gemini-2.5-flashwith the Google Search grounding tool enabled, so the model searches the live web for each company instead of answering from memory. The prompt asks for a JSON array with one contact string per company. - Parse and normalise. It strips Markdown fences the model sometimes adds, parses the JSON, pulls emails and phone numbers out of each contact string with regular expressions, and normalises "no public contact found" answers to
N/A. If a company returns several emails, the row is duplicated so each row holds one email. - Tag and save. Filled rows are tagged
Maslan AIin the Owner column, and the file is saved after every batch.
Working within the quota
The Gemini quota at the time allowed about five requests a minute, and that shaped the whole design:
- Batching. Ten companies per request instead of one: ten times fewer calls for the same work.
- Pacing. A fixed 15-second pause between batches keeps the job under the per-minute ceiling.
- Backoff. On 429 or 503 it waits 20, 40, then 60 seconds before giving up on the batch. A batch that fails for a non-quota reason is marked with the error, so it isn't retried forever.
- Resumability. Because each batch is saved, a crash or a quota wall costs one batch, not the run.
Deployment
The server runs an old Ubuntu with an outdated system Python. Rather than fight apt, the GitLab CI pipeline installs uv, uses it to fetch a standalone Python 3.10, builds a fresh virtualenv, and installs the dependencies. The qa branch deploys to a QA path and main to production. Each has its own output directory, and the API key is injected from CI variables into an .env on the server. The pipeline starts the job in the background once the deploy finishes.
What I'd change
This was a fast, practical build, and I'd tighten a few things next time:
- Structured output instead of fence-stripping. Parsing free-form JSON worked, but a response schema would remove a whole class of retries.
- Stricter matching. Results are matched back to rows by substring on the company name. That's fine for distinct names and risky for similar ones. Returning an ID with each company would make it exact.
- Verification. Emails are taken as found. A cheap check (MX lookup, or a bounce-rate report from the outreach tool) would score contact quality instead of assuming it.
Those are the same lessons that went into the JD Analyzer on this site, which uses schema-validated output and checks evidence against the source text.
