Skip to content

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.

Email 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

Return only JSON matching this schema. No prose, no explanation,
no markdown fences.

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

1. Facts
2. Item state
3. Permitted actions
4. The message

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.