Classify¶
What the model is asked at 1.2.1 Classify. One call per turn, every turn.
Rules for every prompt, including the ten-section anatomy and the cache boundary, are in architecture: prompts. This page is what fills those sections for Classify.
| Engine | Capture |
| Runs | Every turn |
| Model | Cheap |
| Output | JSON, validated by Pydantic |
| Improves via | Scored eval (OI-060) |
At a glance¶
| # | Section | Block | Cached | Filled with |
|---|---|---|---|---|
| 1 | Role | System | ✅ | Classifies inbound email. Does not write replies or decide what happens |
| 2 | Vocabulary | System | ✅ | The tenant's categories, each with a definition and an exclusion |
| 3 | Tie-breaks | System | ✅ | Adjacent pairs, and how to report two genuine asks |
| 4 | Escape hatch | System | ✅ | unclear, presented as legitimate |
| 5 | Output contract | System | ✅ | The assessment schema, JSON only |
| 6 | Examples | System | ✅ | Six to ten from the twenty-email walkthrough |
| 7 | Facts | User | ❌ | Sender, thread, references, dates |
| 8 | State | User | ❌ | Turn, status, one line per prior turn |
| 9 | Permitted actions | User | ❌ | After guardrails, with their meanings |
| 10 | The content | User | ❌ | Subject and body, masked, quoted history stripped |
The snippets below show shape, not the live text. The prompt itself is a versioned template in the brain's source.
The system block¶
1. Role¶
You classify inbound email for {business_type}.
You do not write replies. You do not decide what happens next.
You report what the message is asking for and what is true about it.
Stating the negative is a rule for every prompt, and it matters most here: without it the model starts suggesting replies, which is the Draft prompt's job and pollutes the JSON.
2. Categories¶
Injected from tenant config. Each entry carries a definition and an exclusion.
job_status Progress on work already agreed.
NOT a request to change it (job_change).
NOT a request for a document about it (document_request).
document_request They want a copy of something: certificate, invoice, report.
NOT a question about what it says (invoice_query).
quote_request They want a price for work they have described.
NOT general information about what is involved (enquiry).
Exclusions do more work than definitions when categories are adjacent. The definition says what fits, and the exclusion says what to reject, which is the harder judgement.
Full list with definitions and exclusions: categories.
3. Tie-breaks¶
Two different problems live here, and conflating them is why the earlier drafts of this design went wrong.
One ask that could be labelled two ways¶
Only the model can resolve these, because it takes reading the sentence.
| Could be | Choose | Because | |
|---|---|---|---|
| "How's Preston going, and when's it done?" | job_status / job_change |
job_status |
Asking is not changing |
| "Can you send the invoice for 4471?" | document_request / invoice_query |
document_request |
They want a copy, not an explanation |
| "What would a switchboard cost?" | enquiry / quote_request |
quote_request |
They asked for a price |
| "The invoice looks wrong, this is unacceptable" | invoice_query / something about the tone |
invoice_query |
A complaint is not a category. The ask is still about the invoice, and the feeling is recorded as sentiment |
| "Can you come out and look at it?" | job_request / enquiry |
job_request |
They asked us to do something |
Consistency matters more than optimality. Inconsistent picking between adjacent pairs makes reporting wobble for no reason, which is worse than being wrong in a fixed direction.
Two genuine asks¶
If the message asks for two or more distinct things, list every
category in `categories_present`, in the order they appear.
Do not judge which matters most.
Code picks the primary, by category_rank. The model only reports what is there.
These are not the same instruction
The first resolves one ask that could be labelled two ways. The second reports two asks and defers the choice.
Phrase every tie-break as "these are one thing, label it this way". Never as "pick the more important", because that is policy and code applies it (which one is primary).
4. Using unclear¶
Use `unclear` when you genuinely cannot tell what is being asked.
Do not guess. An honest `unclear` is more useful than a confident
wrong answer, because it routes to a human.
This needs saying explicitly. A model given a list will always pick from it, and confidently. unclear only works if presented as a legitimate answer rather than a failure.
5. Output contract¶
Fields are defined in the data dictionary. Enforcement is Pydantic, so a violation fails rather than slipping through.
6. Examples¶
Six to ten, from the twenty-email walkthrough in the Process Map: real emails from that client, categorised with the person who does their triage.
That walkthrough does three jobs at once:
| Output | Feeds |
|---|---|
| Validated taxonomy | The category list |
| Labelled examples | These few-shot examples |
| A labelled set | The eval harness (OI-060) |
Choose examples for coverage, not typicality. Include the adjacent pairs, at least one unclear, and at least one with two categories. Examples of the easy cases teach nothing.
The user block¶
Order matters¶
Facts come before the message deliberately. The model then reads them as established truth rather than something to verify. If the email says "as discussed last week" and the facts say no prior thread exists, that contradiction is far more likely to be noticed when the facts are already in view.
7. Facts¶
Extracted deterministically, presented plainly.
sender_known: true
sender_domain_known: true
has_unsubscribe_header: false
customer_open_jobs: 0
job_references: []
thread_depth: 0
has_unsubscribe_header informs classification directly. List mail is rarely a customer request, and a stranger writing without one is most likely a person. Both are borrowed from the Email Assistant, Junior.
Only include facts that inform classification. attachment_types does not, and every line costs tokens on every call.
8. Item state¶
Lean. Classify needs to know what was already said, not the wording.
| Age | Sent as |
|---|---|
| This message | Full body |
| Prior turns | One line each, from the summary already produced |
| Our prior actions | Names only |
turn: 3
status: awaiting_approval
history:
turn 1: quote_request. Drafted a reply offering a callback.
turn 2: customer confirmed Tuesday suits.
This is why history stays cheap. Sending the full thread every turn makes cost quadratic in turn count, not linear: a ten-turn conversation pays for the first message ten times. The summaries already exist, produced by Classify each turn, so they are free.
Draft does need the actual wording, and pays for it only on items that reach drafting.
9. Permitted actions¶
After guardrails have narrowed them. With their meanings, not just names.
You may propose one of:
draft_reply Write a reply for a human to approve. Does not send.
close_item Nothing is needed.
create_job Raise a job, for a human to complete and approve.
There is no escalate action: needing a human is a flag on the item, not something the model proposes (action catalogue). draft_acknowledgement is never offered either, because code derives it.
A bare name invites the model to guess what it does.
10. The message¶
Subject and body, masked, with quoted history stripped.