Skip to content

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 &gt; ~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 &ge; 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, model claude-sonnet-4-5. Everything else is deterministic Python or a Gmail/database round-trip. The roadmap item to switch to claude-haiku-4-5-20251001 would change the model id at this single site.
  • The heuristic gate is a hard pre-filter. apps/emailscan/heuristic.py:192 — anything below MIN_SCORE = 5 never reaches Claude. Outlook signature image001*.jpg files 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_id exact match, attachment_sha1 hash 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 existing Document rows, not just other findings.
  • Classifier output is stored verbatim. EmailScanFinding.proposed_metadata is the entire ClassifierProposal dict serialised as JSON. The booleans is_worth_surfacing and is_property_relevant live under raw_signals inside that dict.
  • Only page 1 of each PDF is sent to the LLM. apps/classifier/services.py:374 extracts text from the first page via pdfplumber and 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). When pdfplumber can'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:669 maps (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_finding merges the user's edits onto proposed_metadata, then _create_document_from_finding (apps/emailscan/services.py:924) maps proposal fields onto a fresh Document, get-or-creates the NamedItem if property-relevant, re-downloads the attachment from Gmail, and writes the bytes to Document.file. Attachment download failure is non-fatal — the Document still 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/