AI lead scoring for local businesses: a rubric that survives real data
“Score this lead from 0 to 100” is not a rubric, it is a wish. A score is useful only if two different runs give the same number for the same business, if you can explain the number to a client, and if the cut-off you set corresponds to something you can actually sell. Here is the rubric we ship, and how to tune it without guessing.
Rule zero: the model scores, the code decides what is even worth scoring
Anything you can compute from the raw data must be computed in code — cheaper, deterministic, auditable, free. What is left for the model is judgement: is this category worth a proposal, what is the most likely commercial pain, how would a consultant open the conversation.
In practice that means the pipeline computes a cheap prior first (no website, mobile-only site,
few reviews, permanently closed) and passes it to the model as context under a name like
score_previo. The model sees it, may disagree with it, and the final number is the model's.
Two numbers, two purposes: the prior tells you where to look, the score tells you whom to call.
The rubric: six positive signals and one veto
Weights are the part clients argue about, so they should be written down and visible rather than buried in a prompt. Ours sum to exactly 100:
| Signal | Weight | Why it predicts a sale |
|---|---|---|
| No website, or the site returns an error | +35 | The most sellable gap in local B2B. It is visible, it is binary, and it has a fixed price. |
| Fewer than 30 Google reviews | +15 | Low review volume correlates with low digital maturity and with an owner who still answers the phone. |
| Rating between 3.0 and 4.5 | +10 | Good enough to be operating, bad enough that reputation work has a measurable upside. |
| Public phone number available | +15 | A lead you cannot call is a row, not a lead. This is also the field that moves the maps SKU. |
| Business is operational | +15 | Closed permanently or temporarily is a wasted call, and a quick way to burn the list. |
| High value per client category | +10 | Health, dental, legal, real estate, accounting, construction: ticket sizes that absorb a retainer. |
| Looks like a chain, franchise or national branch | -20 | Veto. The decision is made at headquarters, not by the person who picks up the phone. |
Two consequences worth stating out loud. First, the positive signals already add to 100, so a franchise with every other signal present lands on 80 — the veto has to be strong enough to matter, and clamping to 0–100 must be explicit or you will get 115s in your sheet. Second, the weights encode a business model: if you sell reputation management instead of websites, +35 belongs on reviews and the website drops to +10. The rubric is not universal; it is a statement about what you can deliver.
Make the model return structure, not prose
The output is a fixed JSON object with no extra keys. That is not stylistic taste — the next node needs to read fields, and every free-text sentence is a field you will end up parsing with a regex at 2 a.m.
{
"id": "the exact id you received, unmodified",
"score": 0,
"motivo": "one sentence, max 20 words, justifying the score",
"dolor_principal": "the most likely commercial pain, max 12 words",
"servicio_sugerido": "one of: web, seo_local, automatizacion, reputacion, crm",
"pitch": "first-contact message, max 45 words, personalised with the business name"
}
The id field is load-bearing. Batched requests come back reordered and sometimes
short one item. The merging node builds a placeId → business dictionary and drops anything
it cannot match. If you delete id from the output schema, every row is discarded and the
flow looks broken when it is being perfectly disciplined.
Determinism is a setting, not a hope
- Temperature 0.2 (or lower for extraction). At 0.8 the same dental clinic gets 71 on Monday and 89 on Tuesday, and your threshold stops meaning anything.
- No dynamic values in the system prompt. Today's date, the city, the config — all of it goes into the user message. A static system prompt is what earns the cache discount (~50x on the cached portion, measured: 512 of 660 input tokens per lead came from cache). Move one variable into the system prompt and the input cost multiplies.
- One output language, injected as a field, not by translating the prompt.
Calibrate minScore with the discards, not with your intuition
Every threshold you set produces two lists: what passed and what did not. Most templates throw the second one away. Keep it — route the failures into a no-op node so the execution shows the count, then look at them:
- Nothing above 60? Your niche is saturated with businesses that already have a website. That is information about the market, not about the model: change the niche before you change the weights.
- Everything above 90? The model is agreeing with the prior instead of judging. Lower the temperature first, then rewrite the rubric with harder language (“be strict”, explicit negative examples).
- A suspicious cluster at exactly the same number? You are reading the prior, not the score. Check that the prompt asks for a computed score and that the merge node takes the model's field.
A practical sequence: start at 60, run a week against real data, then raise to 70. Raising costs nothing and saves tokens; lowering is how you end up with 200 rows and no calls made.
What not to ask the model for
- Guarantees. If the prompt says “promise 50 new patients a month”, the model will write it, and you will send it. Our prompt forbids promises, prices and discounts on purpose.
- Facts that are not in the input. Revenue, employee count, technology stack: if the model invents them, your pitch is wrong on the first line and the prospect knows it before you do.
- A different scale per lead. “Rate this one from 1 to 10 with comments” cannot be thresholded. Same rubric, same keys, same temperature, every time.
The rubric is the free part; the wiring is the work
The Starter kit ships this rubric as a configuration node you edit in one place — niche, city,
minimum score, model, output language — with the batching, the identifier pairing and the
try/catch around parsing already solved.
Related: the five stages of the pipeline · why the second run returns nothing · all articles