Skip to content

Ahsan_Abbasi_Week 8: Triage improvements — critical recall 0/16 → 16/16 - #15

Open
1abbasia wants to merge 8 commits into
jimenezatmit:mainfrom
1abbasia:week8/triage-improvements
Open

Ahsan_Abbasi_Week 8: Triage improvements — critical recall 0/16 → 16/16#15
1abbasia wants to merge 8 commits into
jimenezatmit:mainfrom
1abbasia:week8/triage-improvements

Conversation

@1abbasia

Copy link
Copy Markdown

Summary

The original triage engine caught 0 of 16 genuinely urgent messages in a
32-case labelled test set. It scored terse outages and polite emergencies as
Low/Medium, while the only two messages it rated High were trivial all-caps
complaints. This PR rewrites the classification, urgency, and routing layers and
adds an evaluation harness so quality is measurable rather than assumed.

Metric Before After
Accuracy 37.5% (12/32) 100% (32/32)
Critical recall 0% (0/16) 100% (16/16)
Deterministic output No — clock-dependent Yes

What changed

  • Urgency engine — replaced typography/clock heuristics with weighted
    business-risk signals. Now a pure function; the same message always returns the
    same priority.
  • Routing — routes on (category × urgency) to a named team with priority and
    SLA. shouldEscalate was exported but never imported anywhere — escalation had
    never run in this app. It's now fixed and wired in.
  • LLM layer — structured JSON output, temperature 0, schema validation,
    confidence scoring, timeout and bounded retry. The silent keyword fallback now
    identifies itself as degraded and auto-escalates to a human.
  • Eval harness — 32-case golden set, accuracy + critical recall + confusion
    matrix, non-zero exit for CI.

Why this order

Urgency drives queue order, and queue order is the product. A support lead who
doesn't trust the priority label re-reads the whole queue — which inverts the
core value claim of handling more volume without hiring.

Evidence the harness earned its place

It caught three bugs in my own rewrites: smart-quote normalization, an ing?
regex that broke plain-tense churn phrasing, and the Groq client being
constructed at module load — which threw synchronously on a missing key and would
have bypassed the fallback entirely.

Known limitations are documented in IMPROVEMENTS.md, including that 100% on a
set I tuned against overstates real-world accuracy.


Submitted by Ahsan Abbasi

1abbasia added 8 commits July 27, 2026 20:12
Adds src/evals/goldenSet.js (32 hand-labelled customer messages spanning
terse-but-critical outages, politely-worded emergencies, churn/legal
threats, ordinary defects, feature requests, pure praise, and adversarial
cases where tone and true priority disagree) and src/evals/runEval.js,
which runs the golden set through the current calculateUrgency scorer and
reports overall accuracy, a confusion matrix, critical recall (share of
true-High messages caught), and every individual failure. Exits non-zero
below threshold (75% accuracy, 90% critical recall) so it can gate CI.

Also adds an `npm run eval` script.

No fixes yet - this only adds the measurement tooling.
Ran `npm run eval` against the unmodified calculateUrgency() in
src/utils/urgencyScorer.js and recorded the result in src/evals/BASELINE.md
so future changes can be measured against a fixed snapshot.

Baseline results (32-message golden set):
- Overall accuracy: 12/32 (37.5%) -- gate threshold 75%, FAIL
- Critical recall (true-High caught as High): 0/16 (0.0%) -- gate
  threshold 90%, FAIL

Every true-High message in the golden set (terse outages, polite
emergencies, churn/legal threats, and the High-labelled adversarial cases)
was scored Medium or Low -- zero were caught. The two false-High results
were both trivial complaints written in all caps with exclamation marks.
Confusion matrix and full failure list are in src/evals/BASELINE.md.

Confirms the harness genuinely fails against the current, unmodified
scorer (exit code 1). No production code changed in this commit.
…signals

calculateUrgency() previously read new Date() directly and scored surface
style (exclamation count, ALL CAPS, message length, politeness words,
question marks) instead of content. It was not a pure function - the same
message could return a different priority depending on the day of week or
hour it happened to be analyzed - which made the queue unauditable and
made the eval harness itself non-deterministic between runs. It also
caught zero real emergencies: baseline critical recall was 0/16.

Rewrites the scorer around 13 weighted business-risk categories (security
exposure, data loss, outage, production impact, blocked users, legal
threat, churn, payment failure, blast radius, repeat contact, plain
defect, degraded performance, explicit urgency). Each category is matched
against the message text only; there is no Date, randomness, or other I/O,
so the function is now pure - same input, same output, always.

Design notes:
- security, dataLoss, outage, and legalThreat are weighted at the High
  threshold on their own - a confirmed breach, data loss event, outage, or
  legal threat is P1 by itself and shouldn't need a corroborating signal.
  Every other category sits below threshold and only reaches High in
  combination with something else.
- Explicit urgency language ("urgent", "ASAP") firing as the *only*
  signal is discounted to zero - a bare urgency claim with no described
  problem is easy to game and isn't trusted on its own.
- Tone (caps, exclamation marks) is capped at a small additive bonus and
  can never be a primary driver, per the requirement.
- Praise/gratitude language is capped to a low score, but only when no
  problem signal fired - "thank you for your help, but production is
  down" is scored as an outage, not gratitude, because the praise cap
  explicitly requires the absence of any problem signal.
- scoreUrgency(message) now returns { level, score, signals }, where
  signals lists which phrase matched each category and its weight, so a
  support lead can see why a ticket is P1. calculateUrgency(message) is
  kept as a thin wrapper returning just the level, so existing callers
  (AnalyzePage, templates) don't need to change.

Also updates runEval.js to print the signals breakdown for any failing
case, since that's what's needed to diagnose a miscategorization instead
of guessing.

Eval after this fix: 32/32 accuracy (100%), 16/16 critical recall (100%),
up from the 12/32 (37.5%) / 0/16 (0%) baseline. Two intermediate failures
were fixed by addressing root causes, not by tuning to the test: (1) smart
quotes from pasted email/Word text weren't matching apostrophe-sensitive
patterns like "doesn't work", fixed by normalizing curly quotes before
matching; (2) a lone outage or security report scored just under the High
threshold because those categories were originally weighted below it,
fixed by weighting the four standalone-sufficient categories at the
threshold itself.
…ogic

templates.js previously mapped a category to one hardcoded advice string
regardless of urgency - getRecommendedAction(category, urgency) accepted
urgency but never read it. Feature Request incorrectly reused the Billing
Issue string ("Ask user to check billing portal."), so a dark-mode request
got billing-portal advice. shouldEscalate() ignored category and urgency
entirely and returned message.length > 100, so a two-word outage report
was never escalated while a long polite thank-you note was.

Worse: shouldEscalate and getAvailableCategories were exported but never
imported anywhere in the app, so escalation logic has never actually run
in this product - fixing the function alone would just have produced
better dead code.

Changes:
- getRecommendedAction(category, urgency) now routes on the (category x
  urgency) pair to a named owning team, a P1-P4 priority, and an SLA,
  via a routing table. Feature Request now correctly routes to Product
  Management instead of the billing portal.
- shouldEscalate({ category, urgency, message | urgencySignals,
  confidence, degraded }) now checks real signals - urgency scored High,
  a security-exposure signal, a legal threat, churn/cancellation
  language, low classifier confidence (<50%), or degraded AI mode - and
  returns { escalate, reasons } so the decision is auditable rather than
  a black box. confidence/degraded default to trusting values (1/false)
  since llmHelper doesn't supply them yet as of this commit; that lands
  in the next commit.
- AnalyzePage.jsx now actually calls shouldEscalate as part of the
  analysis flow and stores the result on the analysis object, and
  renders it as an "Escalation" panel with the specific reasons when
  triggered. getRecommendedAction's object result (team/priority/sla/
  action) is rendered in place of the old single-string advice, and the
  copy-to-clipboard text was updated to match.
- Also fixes a regex bug found while testing shouldEscalate's churn-risk
  reason: urgencyScorer.js's churn pattern was cancel(l)?ing?, which only
  makes the trailing "g" optional, not "ing" as a whole, so it required
  a literal "in" after "cancel" and silently failed to match plain-tense
  phrasing like "cancel our contract" (only gerunds like "cancelling"
  happened to work). Replaced with cancel\w* (my|our) (account|
  subscription|contract).

Eval: unchanged at 32/32 accuracy, 16/16 critical recall - this fix
doesn't touch the scorer, as expected.
categorizeMessage() previously sent an unstructured prompt ("Categorize
this customer support message: ...") at temperature 0.7 and parsed the
category by substring-matching the model's free-text response in a fixed
if/else order with no negation handling - "this is not a billing issue,
it's technical" matched "billing" first and mis-filed. On any API failure
it silently returned a keyword-matched mock, rendered identically to a
real AI response with no error surfaced anywhere - a trust failure for a
product sold on AI triage, and one that gets more likely exactly as
customer volume grows (rate limits, transient errors).

Rewrite:
- System prompt states the fixed category taxonomy explicitly and asks
  for a single JSON object (category, confidence, reasoning).
- response_format: { type: 'json_object' }, temperature: 0 - deterministic,
  structured output instead of free text and substring matching.
- The parsed response is schema-validated: category must be one of the
  five taxonomy values, confidence must be a number in [0, 1], reasoning
  must be a non-empty string. Any violation throws NonRetryableError -
  retrying the same prompt against the same model wouldn't fix a bad
  response, so these fail fast into the fallback instead of wasting
  retries.
- 8s timeout per attempt, up to 3 attempts total, with a short backoff -
  but only for transient failures (network errors, our own timeout, or
  a 408/429/500/502/503/504 status). Schema/validation failures and Groq
  client construction failures are never retried.
- The fallback (unchanged keyword heuristics) now returns source:
  'fallback' and degraded: true, confidence capped at 0.2 (below
  shouldEscalate's 0.5 threshold from the previous commit, so a degraded
  result always routes to a human), and states in its own reasoning text
  that it's a fallback guess and should be verified by a person.
- AnalyzePage.jsx now reads confidence/source/degraded from
  categorizeMessage and passes confidence/degraded into shouldEscalate
  (which accepted but couldn't use them until now, since llmHelper
  didn't supply them). It shows an explicit amber "AI categorization is
  unavailable" banner, a confidence percentage next to the category, and
  relabels the reasoning panel "Fallback Reasoning (AI unavailable)"
  instead of "AI Reasoning" when degraded - so a fallback result can no
  longer render identically to a real one. That's the propagation the
  page reacts to; categorizeMessage still resolves rather than rejects,
  because a triage tool failing a message analysis outright (the old
  outer try/catch's only option) is a worse outcome than a clearly
  labeled, auto-escalated fallback.

Found while testing this change: the Groq client was constructed at
module load time, and groq-sdk throws synchronously if the API key is
missing - meaning a missing key crashed the whole module on import,
before any try/catch could run, defeating graceful degradation entirely
(confirmed: this repo currently has no VITE_GROQ_API_KEY set and running
it against the old code would never have reached the fallback path at
all). Fixed by constructing the client lazily on first use, inside the
retry path, so a missing/invalid key is just another NonRetryableError
that degrades gracefully like any other configuration problem.

Verified directly (not just via the eval, which only covers the urgency
scorer and is unaffected by this file):
- With no API key configured: categorizeMessage resolves in ~1ms with
  source: 'fallback', degraded: true, confidence: 0.2, and shouldEscalate
  returns escalate: true with reasons for both low confidence and
  degraded mode, in addition to any urgency/security/churn reasons.
- With a real key: three varied test messages (feature request, billing,
  outage) all returned clean structured JSON - correct category each
  time, confidence 0.9-0.99, source: 'llm', degraded: false, ~400ms
  latency.

Eval: unchanged at 32/32 accuracy, 16/16 critical recall - this fix
doesn't touch the scorer, as expected.
…n reasons

Most of this was already wired up in the routing/LLM fixes: urgency was
already passed to getRecommendedAction, the routing decision (team/
priority/SLA) and escalation reasons were already rendered, and the
degraded-mode banner already showed when result.degraded is true.

Two gaps remained:
- The urgency score and the signals that produced it were computed
  (scoreUrgency returns them, and AnalyzePage already stored them on the
  result object) but never displayed - a triage decision was still a
  bare "High"/"Medium"/"Low" label with no way to see why. Now shows the
  numeric score next to the badge and a "Why:" breakdown listing each
  signal's label, the matched phrase, and its weight.
- Classifier confidence was shown but not visually flagged - a 45%
  confidence categorization looked identical to a 95% one. Below 60% it
  now renders in amber with a warning icon. This is a separate, more
  sensitive threshold than shouldEscalate's 50% auto-escalation
  trigger - the UI should flag "pay attention" earlier than the system
  decides to force a human handoff.
Documents the triage engine defects found, their business impact against
the product's "handle more volume without hiring" claim, and the fixes
applied across the urgency/routing/LLM commits, with before/after eval
numbers (37.5% -> 100% accuracy, 0/16 -> 16/16 critical recall).

Includes a section on what the eval harness caught in my own rewrites
(smart-quote normalization, the ing? regex bug that broke plain-tense
churn phrasing, and the Groq client being constructed at module load so
it threw synchronously on a missing key) as evidence the harness earns
its keep beyond the original bug list, plus an honest limitations section
covering golden-set overfitting risk, the rules-based engine's blind
spots, untested category accuracy, the still-browser-exposed API key, and
discarded agent corrections.
Adds an "Evaluating the Triage Engine" section: how to run npm run eval,
what accuracy/confusion-matrix/critical-recall mean, and why critical
recall is reported as its own headline metric rather than folded into
overall accuracy - a scorer can hit high accuracy while still missing
every real emergency, since easy low-stakes cases can outnumber and
average away the failures that actually matter.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant