# Lead Priority / Scoring

Which of the opportunities in the inbox needs answering first.

```
A mail already detected as a lead        messages.is_potential_lead = 1
      |                                  messages.lead_type  = "pricing_request"
      |                                  messages.lead_score = 92
      v
Its type's band                          lead_types.priority = "high"
      |
Lowered one step if the score only       92 >= 50 + 10, so it keeps High
just cleared that type's floor           (51 would have shown as Medium)
      v
High                                     nothing stored - worked out on every read
      |
      v
Read back by five surfaces               Priority pill in the Lead column
                                         "Highest priority leads" sort
                                         High / Medium / Low in the Leads menu
                                         /mails/lead_priority_* listings
                                         Priority tile in AI Insights
                                         High / Medium / Low on the dashboard
```

The rule the feature is built around: **the type sets the ceiling, and certainty
can only lower it.** There is a demotion and deliberately no promotion - a
Product Enquiry the model is 99% sure of is still a Product Enquiry, and being
certain that somebody is only browsing is not a reason to interrupt a
salesperson.

## What the user sees

The Lead column becomes **Lead & Priority**, urgency first:

| Sender | Subject | Lead & Priority |
| --- | --- | --- |
| Vallabh Enterprises | Rate card for 250 seats | **High** - Pricing Request - 92 |
| Vallabh Enterprises | Who do I raise the PO with? | **High** - Purchase Requirement - 88 |
| Team Twilio | Might need a quotation eventually | **Medium** - Quotation Request - 51 |
| Vallabh Enterprises | Which connectors do you support? | **Medium** - Product Enquiry - 95 |
| Vallabh Enterprises | Does it do reporting? | **Low** - Product Enquiry - 63 |

Rows three and five are the interesting ones. A Quotation Request is normally
High, but 51 against a floor of 45 is the classifier only just believing its own
answer, so it shows one band down. A Product Enquiry is normally Medium, and 63
against a floor of 60 drops it to Low - which is how the **Low** band gets
filled at all, without an admin having to configure a lead type nobody wanted to
detect.

Sorting the column (**Highest priority leads**) sorts by band first and
certainty within it, so no amount of confidence lifts a Medium above a High.

The **Leads** chip splits both ways:

```
Leads  8
--------------------------
      8   All Leads
-- By priority -----------
      3   High
      3   Medium
      1   Low
-- By type ---------------
      1   Purchase Requirement
      1   Quotation Request
      1   Pricing Request
      0   Demo Request
      1   Meeting Request
      1   Renewal or Upsell
      2   Product Enquiry
```

The bands need not sum to All Leads. In the listing above they come to 7, not 8:
one mail is typed under a lead type the vocabulary no longer configures, so it is
still an opportunity but nothing can say how urgent it is. It shows a Lead pill
and no Priority pill, and appears only in the All Leads listing.

Opening a mail, **AI Insights** gains a Priority tile beside Lead score, and
explains itself when the band disagrees with the type:

> **Why medium and not high:** Quotation Request is normally high priority, but
> the classifier scored this mail 51 against a floor of 45 - inside the 10-point
> margin where it only just believed its own answer - so it is shown one band
> lower.

Only the surprising case is spelled out. A band that matches its type needs no
explanation beyond the pill and its tooltip.

## Nothing is asked of the AI, and nothing is stored on the mail

Both are deliberate, and they are the two things to understand before changing
any of this.

**The AI is not asked.** The classifier is qualified to say "this is a pricing
request, and I am 80 sure of that". It is not qualified to know that this
business drops everything for a tender and lets general enquiries wait a day.
So the prompt is untouched by this feature: the model keeps supplying the type
and the certainty, and the configuration turns those two facts into a band.

**The band is not stored.** A mail's priority is worked out from its stored
`lead_type` and `lead_score` every time it is displayed. The alternative -
stamping a band onto `messages` at classification time - fails at the one moment
the setting is worth changing: an admin moving Renewal or Upsell from Medium to
High would have re-prioritised nothing already in the inbox without a backfill.
Derived, the same edit re-ranks every lead in every mailbox on the next page
load.

The cost of deriving it is that a band cannot be a `WHERE` on one column. It can
still be a `WHERE`, which is what `LeadType::applyPriorityFilter()` builds: one
`OR` group per lead type, because each type reaches a band on its own terms.

```
its own band is the one asked for   -> its mail qualifies unless demoted
demoting it lands in the one asked  -> only its demoted mail qualifies
both (a type in the bottom band)    -> all of its mail, no score clause
```

That is the same three-line rule `LeadType::bandForWord()` applies to a single
mail. The two disagreeing would show a chip count that its own listing
contradicts, so `tools/lead-priority-smoke.php` checks them against each other
over every vocabulary word crossed with every interesting score.

## The bands are data

`lead_types.priority` is one column, edited at **Lead Types** in the sidebar,
alongside the score floor it interacts with. The row says how they interact -
*High, under 60: medium* - rather than leaving it to be inferred from two
separate controls.

The starting bands:

| Lead type | Floor | Band | Why |
| --- | --- | --- | --- |
| Purchase Requirement | 40 | High | Has decided to buy |
| Quotation Request | 45 | High | A competitor can answer it first |
| Pricing Request | 50 | High | Same |
| Demo Request | 50 | High | The next step is a diary entry |
| Meeting Request | 55 | Medium | Wants time, but has asked for nothing |
| Renewal or Upsell | 50 | High | Lapses on a date |
| Product Enquiry | 60 | Medium | Real interest, unformed |

Nothing starts Low: a type nobody would chase is a type not worth detecting.
Low is where a weakly scored Medium lands.

Two of these part company with the buying-signal order on the same screen, and
both are decisions rather than oversights. A **Meeting Request** ranks above a
Product Enquiry as a signal - the sender wants time in the diary - but is
Medium, because nothing has been asked for that a competitor could answer first.
A **Renewal** ranks low as a signal, since the customer is already won, and is
High anyway: renewals lapse on a date, and a missed one is revenue gone rather
than revenue deferred.

## The confidence margin

`LeadType::CONFIDENCE_MARGIN` is 10. A scored lead within 10 points of its
type's floor drops one band.

Relative to the floor rather than an absolute number, because the floors differ
on purpose - 40 for a purchase requirement, 60 for a product enquiry - and "only
just convinced me" is the same claim at both.

A constant rather than a third per-type control. The screen already asks an
admin for a floor and a band; a third interacting number is how a priority
scheme ends up misconfigured and then distrusted.

An **unscored** lead keeps its type's band. Mail that predates lead detection
was inferred from the labels it already carried and was never scored, and
reading a missing score as a weak one would drop all of it into Low - inventing
a demotion out of absent data is the same mistake as inventing the score.

## Files

| File | What it does |
| --- | --- |
| `database/migrations/2026_09_07_000100_add_priority_to_lead_types_table.php` | The one column, plus the standard bands |
| `app/Models/LeadType.php` | `PRIORITIES`, `CONFIDENCE_MARGIN`, `priorityFor()`, `bandForWord()`, `demote()`, `priorityOrderValue()`, `applyPriorityFilter()` |
| `app/Http/Controllers/MailController.php` | `leadPriorityCounts()`, band filters in `applyLeadFilter()`, `priority` on the chip definitions |
| `app/Http/Controllers/LeadTypeController.php` | Validates the band; passes the definitions to the screen |
| `app/Http/Controllers/DashboardController.php` | `priorityTotals()`, and `leadSplit()` grouping by score as well as type |
| `resources/views/mail/index.blade.php` | The Priority pill, the sort option, the band-major sort value |
| `resources/views/mail/single.blade.php` | The Priority tile and the demotion sentence |
| `resources/views/partials/mail-toolbar.blade.php` | By priority / By type in the Leads menu |
| `resources/views/lead_types/_fields.blade.php` | The Priority select and its explanation |
| `resources/views/lead_types/index.blade.php` | The Priority column |
| `resources/views/dashboard/index.blade.php` | High / Medium / Low above the type bars |
| `resources/views/layouts/app.blade.php` | Refreshes the band counts with the rest of the chips |
| `tools/lead-priority-smoke.php` | Checks the PHP and SQL derivations agree |
| `tools/lead-priority-shot.php` | Renders the four surfaces with stand-in leads |

Unchanged, and worth noting: `ProcessEmailQueue`, `BackfillGmailMessages` and
the five mail-storing sites in `MailController`. They go on writing the same
five lead columns through `LeadType::decide()`. A derived band needs no place in
the pipeline.

## Access

Reading a priority needs nothing - any user sees the bands on their own mail.
Editing one needs an administrator (`users.auth_id = 0`), like the rest of the
Lead Types screen. The list is installation-wide: a band an administrator
changes applies to every user's mail.

## Setup

```
php artisan migrate
php tools/lead-priority-smoke.php
```

The migration adds the column and gives the seven standard types their bands,
touching only rows still on the column default so it cannot undo an admin's own
choice. An installation that never seeded `lead_types` needs nothing - the
built-in `LeadType::DEFAULTS` already carry their bands.

Nothing is reclassified and no mail is rewritten, so there is no backfill step
and nothing to undo: `migrate:rollback` drops the column and the app falls back
to the built-in bands.
