Build an AI email suggested-response system that:
- takes customer email threads
- decides whether a reply should be drafted
- generates a suggested reply grounded in past examples
- measures how good that reply actually is, per email and overall
This repository is intentionally evaluation-first. The generator is retrieval-grounded and runnable end to end, but the main focus is the scoring system: defining what a "good" support reply means and measuring it in a way that is stricter than exact match and less hand-wavy than pure LLM judging.
The pipeline has four stages:
-
triageDecides whether the email isneeds_reply,no_reply, orhuman_review. -
retrieveFinds the most similar historical email/reply examples from the training set using TF-IDF over subject, body, thread context, category, and policy tags. -
generateProduces a suggested reply using a generative model when an API key is present, or a deterministic fallback when running fully offline. -
evaluateScores each generated reply with a hybrid judge and writes per-response and aggregate metrics.
scripts/build_dataset.pyBuilds the synthetic dataset used by the benchmark.data/train.jsonlRetrieval bank of past support emails and replies.data/test.jsonlHeld-out evaluation set.src/triage.pyRules-first triage with optional LLM classification.src/retrieve.pyTF-IDF retrieval over historical examples.src/generate.pyReply generation logic.src/evaluate.pyProduction-style hybrid judge with deterministic guardrails and optional LLM cross-check.src/pipeline.pyEnd-to-end runner.outputs/Per-email predictions and aggregate reports.
I did not use a public customer support email corpus directly because most public datasets are either:
- not actually email/reply pairs
- not shareable enough for a public repo
- too noisy for a credible benchmark inside a 100-minute challenge
Instead, I built a synthetic-but-realistic dataset by hand in build_dataset.py.
The dataset includes:
- 32 training examples
- 12 held-out test examples
- support categories such as billing, technical support, deliverability, plan management, security, escalation, acknowledgements, and FYI/no-reply cases
- richer fields than just raw email text:
subjectincoming_emailthread_historygold_replycategoryneeds_replypolicy_tags
Why this is representative:
- it covers the most common patterns a shared support inbox sees
- it includes both "answer directly" and "route to human review" cases
- it includes cases where no reply should be drafted at all
- it preserves the kinds of specifics support replies need to handle well: invoice IDs, exports, seat counts, SSO setup, bounce domains, suspicious logins, and escalation language
Why synthetic data was the right tradeoff here:
- real support email data is private
- synthetic data makes the repo public and reproducible
- a small, clean benchmark is more useful than a noisy corpus with unclear provenance
The generation system is retrieval-augmented rather than purely free-form.
For each incoming email:
- triage decides whether it deserves a draft
- retrieval finds the top similar historical examples from
train.jsonl - generation uses those examples as grounding context
Why this approach:
- it is much cheaper and faster than fine-tuning
- it gives the model examples of the expected support style
- it reduces hallucination risk compared with prompting from scratch
- it keeps the system auditable because every generated draft can be traced back to retrieved examples
Why TF-IDF instead of embeddings:
- zero external dependencies
- fast enough for a small benchmark
- easy to inspect and explain
- good tradeoff for a 100-minute challenge
This is not the best possible retrieval system, but it is a sensible and runnable one.
Exact-match accuracy is too strict for support email generation.
Two replies can both be good even if they use different wording. What matters is whether the reply:
- addresses the user’s actual issue
- includes the needed next step
- stays factually safe
- preserves important entities like invoice IDs or requested artifacts
- uses an appropriate support tone
- is concise without being empty
So in this project, "accuracy" means:
- relevance Did the reply answer the real ask?
- completeness Did it include the key instruction, next step, or request for missing info?
- correctness Did it avoid false promises, wrong policy, or unsafe claims?
- tone Is it professional and empathetic where needed?
- brevity Is it useful without being bloated?
The evaluator lives in evaluate.py.
It uses a hybrid judge:
This is the backbone of the scoring system.
The evaluator checks:
- keyword coverage from the customer email
- keyword coverage from the gold reply
- entity preservation, such as invoice IDs, email addresses,
PDF,CSV,SSO, and webhook-related terms - whether the draft includes a concrete action step
- whether it asks for details when the reference reply does
- whether it includes navigation guidance like
Settings > Billingwhen expected - whether it escalates risky cases when expected
- whether it makes risky unsupported claims like "already refunded" or "100% fixed"
This makes the judge much less likely to over-score vague replies that are merely polite.
If OPENAI_API_KEY is set, the evaluator also asks an LLM to score:
- relevance
- completeness
- correctness
- tone
- brevity
The LLM is a secondary signal, not the only judge.
Important design choice:
- deterministic guardrails can cap weak replies even if the LLM is overly generous
That makes the system more production-trustworthy than a pure LLM-as-judge setup.
The evaluator also compares the generated draft against the gold reply using:
- unigram overlap F1
- bigram recall
- keyword coverage
- entity overlap
This gives a grounded "did we say roughly the right things?" signal without requiring exact text match.
The evaluator penalizes replies that are:
- too short to be useful
- much longer than the reference
- obviously bloated for a support email
Each generated reply gets a final score from:
65%rubric score25%reference similarity10%length sanity
The system writes:
- a per-response score with rationale
- an aggregate score across the benchmark
It avoids the main failure modes of simpler metrics:
-
Exact match Too brittle for generative email replies.
-
BLEU / ROUGE only Too lexical; they miss whether the reply is actually helpful and safe.
-
LLM judge only Too subjective and too easy to inflate with polished but generic text.
This metric is a better compromise because:
- rubric dimensions align with how humans judge support quality
- deterministic checks make the metric harder to game
- reference similarity adds grounding
- the final score is explainable, not just a magic number
I validated the metric in two ways:
-
Sanity-check on weak drafts The first evaluator version was too generous and gave generic fallback drafts scores in the high 70s and low 80s. After strengthening the judge, the same drafts now score much lower unless they include the expected concrete action or entity details.
-
Per-response rationale Each prediction includes a rationale showing why a score was assigned. That makes it easier to inspect whether low scores correspond to real issues like missing escalation, poor specificity, or drift from the gold reply.
This does not replace human evaluation, but it does make the metric much more honest and auditable.
After upgrading the evaluator, the current offline benchmark reports:
- triage accuracy:
1.00 - triage F1 for
needs_reply:1.00 - average hybrid generation score:
64.03 - pass rate at threshold
70:14.29%
This lower score is intentional and useful: it shows the evaluator is no longer over-rewarding generic fallback replies.
Running the pipeline produces:
-
outputs/predictions.jsonlPer-email triage result, generated draft, retrieved example IDs, component scores, and rationale. -
outputs/evaluation.jsonAggregate metrics including triage accuracy, precision/recall/F1, average generation score, pass rate, category breakdown, and confusion matrix. -
outputs/report.csvCompact table for quick review.
Build the dataset and run the full benchmark:
python3 scripts/build_dataset.py
python3 src/pipeline.py --train data/train.jsonl --test data/test.jsonl --top-k 3Optional environment variables for live LLM generation/judging:
export OPENAI_API_KEY=...
export OPENAI_MODEL=gpt-4.1-mini
export OPENAI_API_URL=https://api.openai.com/v1/responsesThen rerun:
python3 src/pipeline.py --train data/train.jsonl --test data/test.jsonl --top-k 3I used AI development tools during implementation and am documenting that here explicitly, as requested:
- ChatGPT / Codex-style coding assistance for architecture, refactoring, and README iteration
- optional OpenAI API for live generation and judging when an API key is present
The dataset itself is hand-authored synthetic data in code so its provenance is transparent.
- the dataset is small and synthetic
- retrieval is lexical TF-IDF, not semantic embeddings
- the offline fallback generator is much weaker than the evaluator
- there is no external knowledge base or policy backend
- there is no human-labeled agreement study for the metric
- improve the generator so it produces concrete action steps offline
- add semantic embedding retrieval
- validate evaluator agreement against human ratings
- add policy/knowledge-base grounding
- support richer multi-turn thread state
This repo ships:
- a public dataset
- a runnable end-to-end suggested-reply system
- a reply-needed triage step
- a grounded generation pipeline
- a per-response and overall evaluation system
The core idea is simple: a support reply is "accurate" when it is relevant, complete, safe, specific, and concise, not when it exactly matches a reference sentence-for-sentence.