AI Lead Scoring Architecture โ What Actually Goes Into the Number
Every CRM has a lead score. Most are useless. They are useless for the same reason most "AI features" are useless: someone added a column called score, plugged it into a model, and shipped a number with no audit trail, no explainability, and no honest sense of when to trust it.
I've worked on lead scoring across three products. The version I'd defend looks nothing like a single number. It's a layered system โ deterministic rules at the bottom, AI signals in the middle, a human override at the top, and a clear audit trail under all of it. This post is the architecture, the data shape, and the design decisions that I'd push back on if you proposed otherwise.
What the score is for
Before architecture, motive. A lead score answers exactly one operational question:
Of all the leads that came in today, which ones should a human contact first?
Not "are they qualified." Not "will they convert." Not "what's their lifetime value." Just the prioritisation question. Everything else is a different problem.
If you can't tie your score back to that question, you have a vanity metric. If you can, the rest of the design follows.
The four layers
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ 4. Human override (notes, manual) โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโค
โ 3. AI signals (intent, sentiment) โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโค
โ 2. Behavioural rules (engagement) โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโค
โ 1. Demographic rules (fit) โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโEach layer adds (or subtracts) points. Each layer's contribution is recorded separately. The total is the sum. The system never produces "73" without also producing "+30 because they're in your target ICP, +25 because they viewed pricing twice, +20 because the AI classified their message as high-intent, -2 because the sales rep noted they're a student."
Layer 1 โ Demographic fit
Hardcoded, deterministic, fast. A lead's company size, industry, geography, job title give you a fit signal that doesn't need AI. If your ideal customer is "B2B SaaS, 20โ500 employees, North America," you can score that with a table.
class FitScorer
{
private array $rules;
public function __construct(FitRulesModel $rules)
{
$this->rules = $rules->getActive();
}
public function score(array $lead): array
{
$score = 0;
$reasons = [];
foreach ($this->rules as $rule) {
$value = $lead[$rule['field']] ?? null;
if ($value === null) {
continue;
}
if ($this->matches($value, $rule)) {
$score += $rule['points'];
$reasons[] = [
'rule_id' => $rule['id'],
'field' => $rule['field'],
'value' => $value,
'points' => $rule['points'],
];
}
}
return ['score' => $score, 'reasons' => $reasons];
}
}Rules live in a table, not in code. Sales operations changes the ICP this quarter โ they change a row. The codebase has no idea.
Layer 2 โ Behavioural rules
Engagement signals from what the lead actually does. Pricing page views, demo requests, content downloads, email opens. Time-decayed so a visit from six months ago counts less than yesterday.
class BehaviourScorer
{
public function score(array $lead): array
{
$score = 0;
$reasons = [];
$events = $this->events->forLead($lead['id'], days: 30);
foreach ($events as $event) {
$weight = $this->weightFor($event['type']);
$decay = $this->timeDecay($event['occurred_at']);
$points = $weight * $decay;
$score += $points;
$reasons[] = [
'event_type' => $event['type'],
'occurred_at' => $event['occurred_at'],
'weight' => $weight,
'decay' => $decay,
'points' => round($points, 2),
];
}
return ['score' => $score, 'reasons' => $reasons];
}
private function timeDecay(string $occurredAt): float
{
$age = time() - strtotime($occurredAt);
$halfLifeSeconds = 7 * 86400; // 7-day half life
return pow(0.5, $age / $halfLifeSeconds);
}
}Exponential decay with a configurable half-life. Same record-the-reason pattern. A lead who watched a product demo last week and downloaded a whitepaper today shows two distinct events with two distinct point contributions.
Layer 3 โ AI signals
The AI layer reads free-text fields โ the lead's first message, comment, email โ and produces signals a rule can't easily capture: intent classification, sentiment, urgency, fit-to-product-pitch.
Crucially, this is one input among many, not the whole score. The AI signal contributes points to the total. If the AI is unavailable, scores still compute; they just don't have the AI component.
class AISignalScorer
{
public function __construct(
private AIManager $ai,
private AIRulesModel $rules,
) {}
public function score(array $lead): array
{
$text = trim($lead['comment'] ?? $lead['first_message'] ?? '');
if ($text === '') {
return ['score' => 0, 'reasons' => []];
}
try {
$intent = $this->ai->complete('lead_intent', $text);
$sentiment = $this->ai->complete('lead_sentiment', $text);
} catch (\Throwable $e) {
log_message('warning', 'AI scoring unavailable: ' . $e->getMessage());
return ['score' => 0, 'reasons' => ['ai_unavailable' => true]];
}
$score = 0;
$reasons = [];
// Map intent classes to points
$intentMap = $this->rules->intentMap();
$points = $intentMap[$intent['class']] ?? 0;
$score += $points;
$reasons[] = [
'signal' => 'intent',
'class' => $intent['class'],
'confidence' => $intent['confidence'] ?? null,
'points' => $points,
];
// Sentiment: positive helps, negative hurts
$sentimentPoints = ($sentiment['polarity'] ?? 0) * 5;
$score += $sentimentPoints;
$reasons[] = [
'signal' => 'sentiment',
'polarity' => $sentiment['polarity'] ?? 0,
'points' => round($sentimentPoints, 2),
];
return ['score' => $score, 'reasons' => $reasons];
}
}Two AI skills, two contributions. If the AI provider is down, the function returns zero contribution with a flag, and the overall scorer carries on. Catastrophic failure mode for lead scoring is "the system stops producing scores at all." That doesn't happen here.
Layer 4 โ Human override
The sales rep noticed the lead is a student writing a thesis. The score said 75; the rep adjusts to 20 with a note. The note is the audit trail. The original AI/rule scores are preserved.
CREATE TABLE lead_scores (
id INT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
lead_id INT UNSIGNED NOT NULL,
fit_score DECIMAL(6,2),
behaviour_score DECIMAL(6,2),
ai_score DECIMAL(6,2),
manual_override DECIMAL(6,2) NULL,
manual_reason TEXT NULL,
final_score DECIMAL(6,2),
reasons_json JSON,
computed_at DATETIME,
computed_by VARCHAR(80), -- 'system' or user id
INDEX (lead_id),
INDEX (final_score)
);final_score = manual_override ?? (fit + behaviour + ai). The presence of manual_reason is what makes overrides legitimate audit events instead of unexplained edits.
The scorer that ties it together
class LeadScoringService
{
public function __construct(
private FitScorer $fit,
private BehaviourScorer $behaviour,
private AISignalScorer $ai,
private LeadScoreModel $scores,
) {}
public function recompute(array $lead): array
{
$fit = $this->fit->score($lead);
$behaviour = $this->behaviour->score($lead);
$ai = $this->ai->score($lead);
$componentTotal = $fit['score'] + $behaviour['score'] + $ai['score'];
$existing = $this->scores->latestForLead($lead['id']);
$override = $existing['manual_override'] ?? null;
$final = $override ?? max(0, min(100, $componentTotal));
$reasons = [
'fit' => $fit['reasons'],
'behaviour' => $behaviour['reasons'],
'ai' => $ai['reasons'],
];
$row = [
'lead_id' => $lead['id'],
'fit_score' => $fit['score'],
'behaviour_score' => $behaviour['score'],
'ai_score' => $ai['score'],
'manual_override' => $override,
'manual_reason' => $existing['manual_reason'] ?? null,
'final_score' => $final,
'reasons_json' => json_encode($reasons),
'computed_at' => date('Y-m-d H:i:s'),
'computed_by' => 'system',
];
$this->scores->insert($row);
return $row;
}
}The three layer scores compute independently. They sum, with manual override winning when set. The reasons array โ every fact that contributed to the score โ is serialised into a JSON column. The lead detail screen renders this as a readable breakdown. The sales rep doesn't see "75." They see "75 = 30 (fit: ICP match) + 25 (behaviour: pricing page x2, demo request) + 20 (AI: intent=sales_inquiry, sentiment=+0.8)."
That is the difference between a score people trust and a score people ignore.
Temperature: a categorical alias
Most teams don't want to talk in numbers. They want to talk in hot, warm, cool, cold. Map the score to a temperature once and use it everywhere:
final_score >= 90 โ hot
final_score >= 70 โ warm
final_score >= 40 โ cool
final_score < 40 โ coldBands live in a settings table so operations can tune them without a deploy. A hot lead triggers a Telegram alert with a deep link to the lead record. A warm lead triggers a same-day email task. Cool leads enter the nurture sequence. Cold leads sit in the queue.
The numbers under the categories are not hidden โ sales reps can hover and see the breakdown. But the day-to-day decision interface speaks in temperatures.
Recompute, not store
I recompute scores on a schedule (every 15 minutes for fresh leads, every 24 hours for older leads). Each recompute writes a new row in lead_scores. The current score is the most recent row. Historical scores are preserved.
This matters more than people think:
- You can see how a lead's score changed over time. The pipeline analysis becomes obvious.
- When you change a scoring rule, you don't retroactively rewrite history. You just start producing different scores going forward; old scores stay as evidence of the old rules.
- A lead that was hot in March and cold in May is interesting. The graph tells you the story.
Storage cost: maybe 1 KB per score row. For 100K leads recomputed daily for a year, that's about 35 GB. Trivial.
What goes wrong
Score inflation. Sales reps notice the system credits hot leads. They start manually tagging events to push scores up. The system rewards gaming. Mitigation: behaviour weights should be tuned by post-hoc analysis, not by what feels right at design time. Look at which signals actually correlated with closed-won deals and adjust.
AI hallucinated intent. The AI says "intent: sales_inquiry, confidence: 0.45" on a message that's plainly spam. Mitigation: require minimum confidence before contributing AI points. Below threshold, AI contributes zero.
Time decay too aggressive. Half-life too short and the system forgets a hot lead's history within a week. Half-life too long and a lead who was active a year ago looks current. I default to 7-day half-life; tune by behavioural evidence.
Manual override theatre. Reps override every score because they "know better." If the override rate is above 30%, the underlying rules are wrong. Don't argue with reps; fix the rules. The reps are giving you free feedback about how the system fails.
Score thrash on event arrival. A new pricing page view triggers a recompute that bumps the score from 72 to 76, which crosses no temperature band, which still triggers a notification because some other system was watching the raw number. Mitigation: notifications fire on temperature transitions, not score changes.
Why explainability matters
A lead score that you can't explain is a lead score that sales will stop trusting within a week. The first time a rep calls a "hot" lead and gets brushed off as a tire-kicker, they want to know why the system thought it was hot. If your answer is "the model said so," they will run their own ranked list out of a spreadsheet by the end of the month and your CRM will be a database of business cards.
The architecture above costs maybe one extra evening of work compared to "model output is the score." That evening is the difference between a CRM feature and a CRM ignored.
How AI fits โ and how it doesn't
AI in this architecture is one of three input layers. It is not the scorer. It is not the truth. It produces signals that contribute points, with reasons recorded.
This is the design pattern I'd push on any "AI feature" in a business system:
- The AI produces a structured output (intent class + confidence, polarity score, etc.), not a freeform answer.
- The structured output maps to points, not to decisions.
- Decisions are made by a deterministic layer above, with rules a human can read and modify.
- If the AI is unavailable, the deterministic layer still produces a sensible answer.
In a working version of this in production, I track three things alongside the score:
- The model that produced each AI signal (so I can see which model misclassified what).
- The confidence of each classification.
- Whether the AI layer contributed, fell back, or was unavailable.
That data answers the only questions that matter when the score is wrong: was it the rule, the model, or the human?
Wrap-up
Lead scoring is the canonical example of a feature that's easy to ship and hard to ship well. The single mistake to avoid: collapsing a multi-input, multi-source decision into a single opaque number. Keep the layers separate. Record the reasons. Let humans override with audit. Let the AI contribute, not decide.
What you'll get is a number that sales actually uses, that operations can tune, that compliance can audit, and that you can change without breaking trust. That's worth more than any model accuracy metric.