Back to work

Case study

pytest-triage

An LLM triage assistant for pytest’s GitHub issues, scored against what the maintainers actually did.

01

Problem

Since January 2025, 247 of pytest’s 484 new issues (51%) never got a label. Triage is the first thing to slip when volunteers are busy.[1]Source 1pytest’s issue tracker on GitHubOpen source 1 (opens in a new tab)All sources

I wanted to see how much of that triage a low-cost model could take on, and to know how often it is wrong.

02

Constraints and approach

  • It never writes to GitHub. Every suggestion is scored against the labels the maintainers gave.
  • I limited the model to fixed lists (the 11 labels, the issues it was shown) and check every pick again in code.
  • Every choice is made on one year of data, then tested once on a later year.
  • CI cannot call the model, so it guards quality another way: a changed prompt, schema or model fails the build until it is measured again, and every push re-scores 256 frozen predictions and fails on any drop over 0.02[5]Source 5The regression gate, in the pytest-triage READMEOpen source 5 (opens in a new tab)All sources.

Three low-cost LLM calls plus local hybrid search per issue, with fixed-choice outputs, injection guardrails and a trained classifier that decides which labels are shown. It runs in read-only shadow mode and never writes to GitHub, and a CI gate fails any prompt or model change until it is measured again.

How one issue flows through it

A fixed pipeline rather than a free-roaming agent, so cost and latency are predictable and each step can be measured on its own. Search runs locally; only the three model calls cost money.

Pipeline steps for one issue
StepWhat it does
GuardSecrets and file paths are redacted, and the issue text is fenced and marked as data, never instructions.
Assess (1 call)Suggests labels and missing information, with the five most similar labeled past issues as examples.
Expand (1 call)Writes three root-cause queries for the duplicate search.
Search (local)BM25 and embeddings over older issues, merged with reciprocal rank fusion: the issue’s own top 8 plus the queries’ top 8.
Verify (1 call)Decides which candidates describe the same problem.
Policy checkAn issue flagged for injection goes to a human with no suggestions. Otherwise a label is shown only when a trained classifier agrees with it at 0.90 or more.
03

How it is evaluated

The data is split by time, the way the tool would be used. Few-shot examples and duplicate candidates always come from issues opened before the one being triaged, so nothing leaks from the future.

Data splits by time
SplitIssuesUsed for
Train and examplesBefore 2024, 4,459 labeledFew-shot examples, duplicate candidates, the classifier
Dev2024, 256 issuesEvery choice: prompt, model, thresholds
Test2025 onward, 237 issuesScored once per final choice
ShadowOpened since Oct 14, 2026Predicted within about a day, scored later

Every comparison gets a paired bootstrap confidence interval (2,000 resamples). With 11 to 44 examples per label, gaps under about 0.03 macro-F1 are noise, and I would rather say so than report them.

04

Results

Scored on 2025 issues the maintainers had already triaged. The full tables are in the repo README[2]Source 2Results at a glance in the pytest-triage READMEOpen source 2 (opens in a new tab)All sources.

Label macro-F1 (best method without an LLM: 0.46)
0.58
Shown labels matching maintainers
82%
It shows a label only when confident: on 18% of issues
Known duplicates flagged
7 of 15
Injection attacks that got through
0 of 104
Cost per issue
$0.0015
Spent building and evaluating
$2.64
Label macro-F1 by predictor2025 test set, 11 labels. Higher is better. From the README’s predictor table.
Label macro-F1 on the 2025 test set for each predictor
SetupMacro-F1
Keywords from past titles (no LLM)
0.35
kNN vote of similar issues (no LLM)
0.38
Logistic regression (no LLM)
0.46
LLM, first prompt
0.47
LLM, current prompt
0.58

Every predictor is scored on the same 237 issues and 11 labels[3]Source 3Label results by predictor in the pytest-triage READMEOpen source 3 (opens in a new tab)All sources.

Results on the 2025 test set
WhatResultCompared with
Labels, 11 labels (macro-F1)0.580.46 for the best method without an LLM (+0.115, 95% CI +0.06 to +0.16)
Labels shown by default82% precise, on 18% of issues0.46 precision when every suggested label is shown
type: regression (F1)0.710.47 for the best method without an LLM
Needs more information (F1)0.420.24 for flagging every issue
Duplicates found7 of 155 of 15 for search alone; 3 of 30 false alarms
Injection attacks that reached a maintainer0 of 104 runs (19 hand-written attacks, in variants)1 of 104 on the first run, before I fixed what it found

The LLM clearly beats every method that needs no model calls, but on its own it suggests too many labels. So I trained a classifier to act as a filter: by default a label is shown only when it agrees, and then four in five shown labels match the maintainers’, on fewer issues. Anything sent to the person who opened the issue, such as a request for missing details, has to be 70% precise first. Nothing clears that yet, so those suggestions stay in shadow mode.

What did not work

Ideas that did not make it in
TriedOutcome
A bigger model0.51 against 0.49 on dev with the same prompt, at 2.7× the cost per call, and worse at spotting missing information.
Ensembling the LLM and the classifierAbout +0.01 on dev and slightly worse on test. The classifier works better as a filter on what to show.
Local cross-encoder rerankers for duplicates8 of 15 originals against 10 for the current pool, at 6 to 65 seconds per issue.
05

Sources