A preprocessing change that regresses an LLM classifier

The prompt stays the same. The dataset stays the same. A normal-looking PII scrubber changes the text before it reaches a LiteLLM-backed ticket classifier, and service routing falls apart. This is exactly the kind of adjacent code regression CI should catch before merge.

ticket-classifier / llmci.yaml
View focused PR failed check

Preprocessing diff

@@ PII_PATTERNS
  (r"\b\d{3}[-.]?\d{3}[-.]?\d{4}\b", "[PHONE]")
  (r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b", "[EMAIL]")
  (r"\b\d{4}[- ]?\d{4}[- ]?\d{4}[- ]?\d{4}\b", "[CARD]")
  (r"\b\d{3}-\d{2}-\d{4}\b", "[SSN]")
+ (r"\b[A-Za-z]{4,}\b", "[NAME]")

llmci result

accuracy 0.333 < 0.75
f1_weighted 0.449 < 0.70
failed examples 16

The prompt is innocent

The example lives in llmci-cli/llmci-testbed, a public repository with several small LLM-powered services. This one routes support tickets into hardware, billing, account, software, or general.

The first classifier post showed a prompt diff causing a regression. This one is more mundane: the prompt does not change at all. A nearby preprocessing rule changes the text that reaches the model, and the service starts misrouting tickets.

Relevant testbed files
services/ticket-classifier/app/classifier.py
services/ticket-classifier/app/pipeline.py
services/ticket-classifier/app/prompts/classify.txt
services/ticket-classifier/evals/tickets.jsonl
services/ticket-classifier/llmci.yaml
services/ticket-classifier/scripts/eval_service.py
.github/workflows/llmci.yml

The regression is one preprocessing rule

The ticket classifier already strips obvious PII before calling the model: phone numbers, email addresses, credit cards, and SSNs. A pull request adds a broad name-redaction rule.

services/ticket-classifier/app/pipeline.py
@@ PII_PATTERNS
PII_PATTERNS = [
    (r"\b\d{3}[-.]?\d{3}[-.]?\d{4}\b", "[PHONE]"),
    (r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b", "[EMAIL]"),
    (r"\b\d{4}[- ]?\d{4}[- ]?\d{4}[- ]?\d{4}\b", "[CARD]"),
    (r"\b\d{3}-\d{2}-\d{4}\b", "[SSN]"),
+   (r"\b[A-Za-z]{4,}\b", "[NAME]"),
]

In review, this can look like a privacy hardening change. In behavior, it is catastrophic: every word of four or more letters becomes [NAME]. printer, charged, password, subscription, crashes, and webcam all disappear before the classifier sees the ticket.

The service path hides the damage

The application boundary is not just the prompt. A ticket goes through the service pipeline first: normalize text, redact PII, then call the classifier. That means a bad preprocessing rule can change model behavior without touching the prompt file.

services/ticket-classifier/app/pipeline.py
def preprocess(text: str) -> str:
    cleaned = text.strip()
    cleaned = re.sub(r"\s+", " ", cleaned)
    for pattern, replacement in PII_PATTERNS:
        cleaned = re.sub(pattern, replacement, cleaned)
    return cleaned

def classify(text: str) -> dict:
    cleaned = preprocess(text)
    category, confidence = classify_core(cleaned)
    final_category = postprocess(category, confidence)
    return {"category": final_category, "preprocessed_text": cleaned}

The real-model path still goes through LiteLLM. The prompt template is unchanged; the input text is what changed.

services/ticket-classifier/app/classifier.py
prompt_template = PROMPT_PATH.read_text()
prompt = prompt_template.replace("{input}", text)
model = os.environ.get("CLASSIFIER_MODEL", "openai/gpt-4o-mini")

category = complete(prompt, model=model).strip().lower()
if category not in CATEGORY_KEYWORDS:
    return "general", 0
return category, CONFIDENCE_THRESHOLD

llmci does not need a custom integration for this service. It only needs the same command boundary the app already exposes for evals.

services/ticket-classifier/scripts/eval_service.py
data = json.loads(Path(args.input).read_text())
category = classify(data["input"])["category"]

Path(args.output).write_text(json.dumps({
    "output": category,
    "usage": {"tokens_in": tokens_in, "tokens_out": tokens_out},
    "cost": cost,
}))

The eval is small on purpose

The dataset is not a giant benchmark. It is a focused set of normal support tickets that should keep working across service changes.

evals/tickets.jsonl
{"input": "My printer keeps jamming every time I try to print...",
 "expected": "hardware"}
{"input": "I want to cancel my subscription and get a prorated refund...",
 "expected": "billing"}
{"input": "I can't log into my account. I've tried resetting my password...",
 "expected": "account"}
{"input": "The mobile app crashes immediately after the latest update...",
 "expected": "software"}

The over-broad redaction rule damages every category. Hardware words disappear, billing words disappear, account words disappear, and software words disappear. The model still returns a valid label; it is just often the wrong one.

The config turns it into a CI gate

The service-level llmci config ties the full pipeline wrapper and dataset together. Accuracy must stay at or above 0.75, weighted F1 must stay at or above 0.70, and accuracy must not regress by more than three percentage points when a baseline exists.

services/ticket-classifier/llmci.yaml
target:
  command: "python3 scripts/eval_service.py --input {input_file} --output {output_file}"

evals:
  - name: service-classification
    level: pipeline
    dataset: ./evals/tickets.jsonl
    judge: exact_match
    metrics:
      - name: accuracy
        threshold: 0.75
        mode: absolute
      - name: f1_weighted
        threshold: 0.70
        mode: absolute

For a real-model run, CI only needs the same pieces the application already needs: provider credentials, a model name, and the normal pull request comparison.

GitHub Actions: real-model run
- name: Run focused ticket classifier eval
  working-directory: services/ticket-classifier
  env:
    OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
    CLASSIFIER_MODEL: openai/gpt-4o-mini
    GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
    LLMCI_REPORT_SLICE: ticket-classifier/llmci.yaml
  run: llmci run --config llmci.yaml --compare-to=origin/main

The report explains the failure

When the preprocessing change runs through the gate, llmci reports both the aggregate drop and concrete tickets that changed. The public focused testbed PR applies this exact preprocessing regression and adds a dedicated workflow job for ticket-classifier / llmci.yaml.

Accuracy
0.333

Below the configured 0.75 quality floor.

Weighted F1
0.449

Below the configured 0.70 threshold.

Failures
16

Failed examples attached to the review comment.

GitHub Actions llmci report comment showing failed accuracy, failed weighted F1, and failed ticket-classifier examples
The focused testbed PR gets a real GitHub Actions comment: aggregate gates fail first, then the failed examples show exactly which tickets changed behavior.

The failed examples make the regression obvious. Tickets across categories start falling through to general, and a few route to the wrong concrete category.

What review alone can miss

A reviewer can read the preprocessing diff and agree with the intent. PII redaction is good. But the effect on model behavior only becomes obvious when the service is exercised against examples that matter.

The lesson is that LLM quality is a system property. Prompts matter, but so do preprocessors, serializers, routers, retrievers, schemas, fallback paths, and every other bit of code that shapes what the model sees.

Try it yourself

Open the testbed repository, inspect the ticket classifier service, and apply the preprocessing diff above on a branch. The smallest useful version of this pattern is: