Triage pipeline¶
How a Gmail message becomes a triage finding, and how a finding becomes a Document. This page is the visual companion to the prose in Architecture — same pipeline, drawn end-to-end with a clear split between deterministic Python and the single LLM call.
File and line references reflect the code at the time of writing; the structure (heuristic gate → dedup → classifier → finding → user accept/dismiss) is stable, but exact line numbers may drift.
End-to-end diagram¶
flowchart TD
%% Entry points
U1([User clicks Connect Gmail]):::user --> O1
U2([Manual / future cron rescan]):::user --> S1
%% OAuth kickoff
O1[OAuth callback<br/>oauth.py:94]:::code --> O2[Spawn daemon thread<br/>oauth.py:162]:::code
O2 --> S1
%% Gmail fetch
S1[run_scan<br/>emailscan/services.py:203]:::code --> S2[List messages<br/>q=has:attachment, since=180d<br/>services.py:466]:::api
S2 --> S3[Parse headers + MIME parts<br/>services.py:502]:::code
S3 --> S4{"attachment > ~10KB?"}:::code
S4 -->|yes| S5[Fetch bytes via attachment_id<br/>services.py:142]:::api
S4 -->|no, inline base64| S6[ScanMessage dataclass]:::code
S5 --> S6
%% Heuristic gate
S6 --> H1[Score = domain + keywords<br/>+ context tokens<br/>+ attachment present<br/>heuristic.py:150]:::code
H1 --> H2{"score ≥ MIN_SCORE 5<br/>+ useful PDF/image?"}:::code
H2 -->|no| DROP1[Drop, no LLM call]:::drop
%% Dedup
H2 -->|yes| D1[4-way dedup vs household findings:<br/>gmail_id / sha1 /<br/>thread+name / from+norm_subject<br/>services.py:336]:::db
D1 --> D2{Already seen?}:::code
D2 -->|accepted or dismissed| DROP2[Skip]:::drop
D2 -->|pending, newer copy| D3[Replace pending row]:::db
D2 -->|new| C1
D3 --> C1
%% Classifier — the only LLM call
C1[Build classifier prompt:<br/>filename + MIME + pdfplumber page-1 text,<br/>subject/from/snippet,<br/>household named items<br/>classifier/services.py:230]:::code
C1 --> C2{{"<b>LLM call</b><br/>claude-sonnet-4-5<br/>system prompt cached<br/>classifier/services.py:250"}}:::llm
C2 -->|success| C3[Parse JSON proposal:<br/>topic, doctype, lifecycle_state,<br/>is_worth_surfacing,<br/>is_property_relevant,<br/>title, provider, dates, tags]:::code
C2 -->|API fail / bad JSON| C4[classify_stub<br/>regex on filename<br/>classifier/services.py:269]:::code
C4 --> C3
%% Persist
C3 --> P1[Create EmailScanFinding<br/>status=PENDING<br/>proposed_metadata=full proposal<br/>services.py:399]:::db
%% Triage UI
P1 --> V1["/triage/ overview<br/>triage/views.py:24"]:::code
V1 --> V2[Bucket by lifecycle_state +<br/>is_worth_surfacing +<br/>is_property_relevant<br/>services.py:669]:::code
V2 --> V3[Render 4 buckets:<br/>signed / look / draft / low]:::code
%% User decision
V3 --> UA{User decision}:::user
UA -->|Accept| A1
UA -->|Dismiss| DM1
%% Accept path
A1[accept_finding<br/>merge user edits into proposed_metadata<br/>services.py:748]:::code
A1 --> A2[_create_document_from_finding<br/>maps proposal to Document fields<br/>get_or_create NamedItem<br/>services.py:924]:::db
A2 --> A3[Re-download attachment<br/>services.py:877]:::api
A3 --> A4[Save bytes to Document.file<br/>finding.status=ACCEPTED]:::db
%% Dismiss path
DM1[dismiss_finding<br/>status=DISMISSED<br/>activity event<br/>services.py:1011]:::db
%% Legend styles
classDef user fill:#d4f4dd,stroke:#2d7a3e,color:#1a3d20
classDef code fill:#e8eefc,stroke:#3b5bdb,color:#1c2c66
classDef api fill:#fff3bf,stroke:#b08900,color:#5c4400
classDef db fill:#cfe7ff,stroke:#1e40af,color:#1e3a8a
classDef llm fill:#ffd6a5,stroke:#d97706,color:#7c2d12,stroke-width:3px
classDef drop fill:#f3f4f6,stroke:#9ca3af,color:#4b5563,stroke-dasharray: 3 3
Legend¶
| Colour | Meaning |
|---|---|
| Green | User action |
| Blue | Pure Python (in-process) |
| Yellow | External API call (Gmail) |
| Light blue | Database write/read |
| Orange (bold) | LLM call |
| Grey dashed | Silent drop (no finding row created) |
What's code, what's the LLM¶
- Exactly one LLM call in the pipeline.
apps/classifier/services.py:250, modelclaude-sonnet-4-5. Everything else is deterministic Python or a Gmail/database round-trip. The roadmap item to switch toclaude-haiku-4-5-20251001would change the model id at this single site. - The heuristic gate is a hard pre-filter.
apps/emailscan/heuristic.py:192— anything belowMIN_SCORE = 5never reaches Claude. Outlook signatureimage001*.jpgfiles and oversized camera photos are rejected by filename pattern before scoring. This is the cost lever today. - Dedup is also pre-LLM. Four checks against the household's existing findings (
apps/emailscan/services.py:336):gmail_message_idexact match,attachment_sha1hash match,(thread_id, attachment_name)pair,(from_header, normalized_subject)recurring series. The first backlog item in the Triage cluster extends this to also check existingDocumentrows, not just other findings. - Classifier output is stored verbatim.
EmailScanFinding.proposed_metadatais the entireClassifierProposaldict serialised as JSON. The booleansis_worth_surfacingandis_property_relevantlive underraw_signalsinside that dict. - Only page 1 of each PDF is sent to the LLM.
apps/classifier/services.py:374extracts text from the first page viapdfplumberand embeds it inline in the user message — earlier production sent the full base64-encoded PDF binary as an Anthropic document block. The page-1-text mode is a measured ~5× cost reduction with no precision/recall hit (validated on the spike's hand-labelled set; page 1 is where document type, sender, and subject almost always live). Whenpdfplumbercan't extract anything (scanned PDFs, parser failure, oversize), the classifier still runs and falls back to email metadata + filename signal alone — the document is classified, just with weaker evidence. Non-PDF attachments (images) take the same metadata-only path. Tuning lever:MAX_ATTACHMENT_BYTES(defaults to 4 MB) caps which PDFs get extracted at all; oversized ones skip extraction. - Bucketing is deterministic.
apps/emailscan/services.py:669maps(lifecycle_state, is_worth_surfacing, is_property_relevant)to one of four UI buckets. No second LLM pass. - Stub fallback is regex on filename. If the Anthropic call fails or returns unparseable JSON,
classify_stub(apps/classifier/services.py:269) keeps the pipeline alive with a deterministic guess (e.g. "boiler" → boiler service cert, "insurance" → buildings policy). Tests run with the stub by default. - Accept does the real work.
accept_findingmerges the user's edits ontoproposed_metadata, then_create_document_from_finding(apps/emailscan/services.py:924) maps proposal fields onto a freshDocument, get-or-creates theNamedItemif property-relevant, re-downloads the attachment from Gmail, and writes the bytes toDocument.file. Attachment download failure is non-fatal — theDocumentstill persists.
Where to read the code¶
| Concern | File |
|---|---|
| OAuth + scan kickoff | apps/emailscan/oauth.py |
| Scan orchestration, Gmail listing, dedup, accept/dismiss | apps/emailscan/services.py |
| Heuristic scoring | apps/emailscan/heuristic.py |
| Classifier prompt + Claude call + stub | apps/classifier/services.py |
| Triage UI views | apps/triage/views.py |
| Triage templates | templates/triage/ |