Nabil Khoury
Work / Autonomy / Safety Event Triage
Autonomy2026Personal projectPublic NHTSA dataRuns fully offline

An AI that reads crash stories: first too noisy to use, then too quiet to trust

Seven rules clear most crash reports in an instant, but 3 of the 14 reportable crashes in my test set showed up only in the written story. My first AI version found all 3 and made a reviewer read 44 stories to do it. A second version cut that to 3 reads and missed one. Neither is ready to ship, and both were tested on the same 60 events.

Problem

Rules clear 49 of 60 crash reports, but 3 of those hide a reportable event. The only way to find them is to read every story, about 41 minutes.

Insight

A miss breaks a federal deadline and a false alarm costs one read. So the goal is the fewest reads with zero misses.

Decision

The AI can only add flags, and every flag needs a named reason plus an exact quote from the story.

Result

First version: 0 missed, 44 reads. With the quote rule: 3 reads, 1 missed. Neither ships yet.

49stories the rules cleared, 3 of them reportable
44 → 3stories left to read, first version vs quote rule
0 → 1reportable events missed
~41 → ~2.5 minreading time, my estimate at 200 words a minute
23 or fewerreads that would count as a win, my target

The review workbench

One HTML file, no install. A reviewer reads, filters, and labels events here.

The review workbench: a table of safety events with severity, reportable flag, and a plain-language summary. One row is open, showing the full narrative beside the rules decision and the AI model reasoning, ending in Final: Reportable, LLM escalated.
The open row: the rules cleared it, the model escalated it, and a person makes the final call.

01Who it’s for

Safety and compliance analysts at AV fleetsFederal rules give as little as one day to report some crashes. They need to know which reports can’t wait.
Workplace safety leadsOSHA gives 24 hours to report an in-patient hospitalization. Same job, different data.
The reviewer at the endNeeds the reason in plain words, and the exact sentence it came from, before trusting a flag.

02The problem

A crash report arrives as a few fields plus a written story of about 165 words. Seven rules check the fields for the obvious triggers: a fatality, a pedestrian, a hospitalization, an airbag, a tow, a minor injury, speed over 35 mph. They are fast, free, and easy to audit. On my 60 events they flagged 11, and all 11 were right.

They also cleared 49, and 3 of those 49 were reportable: an alleged minor injury, an animal struck at 28 mph, and a car that drove forward under a closing railroad gate. The fields look the same as the 46 harmless ones. Only the story gives them away, so the safe way to find them is to read all 49 stories. At about 167 words each that is roughly 41 minutes of reading before any decision. I haven’t timed a real analyst, so treat that as an estimate.

The usual fix is another rule, and that works until the wording gets slippery. A rule that matches injury words fires on 7 of the 49 stories, and 5 of those say no injuries were reported. Every new rule also needs a person to decide where the line sits, and the three misses are three different kinds of event. This is the grey area. Reading a story takes judgment, and that is the part I tried to hand to a model.

03The insight

A miss breaks a deadline. A false alarm costs one read.

That lopsided cost decides the design. The rules stay as a floor the model can’t lower. The number to move is how many stories a person still has to read, not accuracy.

04What good looks like

What I optimized

Stories a person still reads, out of the 49 the rules cleared, with zero missesReading is the cost an analyst actually feels. My bar is 23 or fewer, under half, which is my guess at where it starts saving time. Only an analyst can set the real number, and I haven’t asked one yet.

The obvious metric I rejected

AccuracyRules alone score 95% (57 of 60) and miss 3 reportable events. The first AI version scores 32% and misses none. Accuracy would pick the version that breaks a deadline.

05How it works

  1. Read any datasetOne config file maps a CSV’s columns onto a common shape.YAML config
  2. Rules set the floorFatality, injury, airbag, struck pedestrian, high speed.auditable
  3. Model reads the storyA local model writes a plain-language summary of each cleared event.Qwen 2.5 14B
  4. Flags need evidenceTo escalate, the model names the outcome and quotes the story. No quote found in the text, no flag.enforced in code
  5. A person decidesThe workbench shows both reasons side by side.you label

06Decisions

A flag needs a named reason and an exact quote
Why
The first version flagged on what could have happened. Now it must pick one stated outcome (injury alleged, animal struck, rail crossing, and so on) and quote the story. The code rejects any quote that isn’t in the text.
Instead of
A better-worded prompt alone. A prompt asks the model to behave. The code check doesn’t.
Cost
It’s strict. It missed the railroad gate, where nothing was damaged and no listed outcome fit.
The AI can only add work, never remove it
Why
A rule a person can reproduce by hand is the floor. The model adds judgment on top. The limit is enforced in code, not in the prompt.
Instead of
Letting the model make the call, which is neither reproducible nor auditable.
Cost
It will never shrink the pile below what the rules flag.
Fully local, no cloud fallback
Why
The users with the most sensitive data are the target users. “Your data never leaves the machine” is the promise.
Instead of
A cloud model with a local fallback.
Cost
A weaker model, which is part of why the first version over-flagged.
Label by severity, not fault
Why
Regulators trigger on outcomes like injury or airbags, not on who caused it. Fault takes months. Deadlines take days.
Instead of
Marking not-at-fault crashes as not reportable, the intuitive way.
Cost
The list includes crashes where the car did nothing wrong.
Freeze the expected outputs
Why
In a compliance tool, a silent change in what gets flagged quietly invalidates every past decision. Any change fails the build until it’s explained.
Instead of
Regenerating the expected results whenever they drift.
Cost
Friction on every rules change, by design.

07What I left out

Left outWhy
AI-only classificationNot auditable, and it can’t be reproduced by hand.
A databaseOne JSON file per run is easy to inspect and compare.
A web appSafety analysts won’t install a stack. The workbench is one HTML file.
Dataset-specific codeTwo unrelated sources, AV crashes and OSHA injuries, run on identical code.

08What happened

From 44 reads to 3, and one miss

Of the 49 stories the rules cleared. Lower is better, but a miss is worse than a read.

ApproachStories to read, of 49Hidden cases missed, of 3Reading time, est.
Read everything, no AI490~41 min
Rules only030
Word-match rules I wrote after seeing the misses110~9 min
AI, first version440~37 min
AI, quote required31~2.5 min

60 real events, labeled by me: 14 reportable (the rules caught 11) and 46 not. The word-match row is flattering because I wrote those rules knowing the answers.

First versionQuote required
Stories to read
44
3
False alarms
41
1
01020304050

The first version flagged 44 of the 49, which is 90% of the pile. It cleared only 5, so a reviewer reading it saved almost nothing. The cause was in its own reasons: it judged what could have happened, such as “could have caused more severe damage if…”, not what did.

The quote rule fixed that. It escalated 3 events and 2 were right: the alleged injury and the animal. The third was a car rear-ended while yielding to an animal in the road. It also missed the railroad gate. The story says the car’s roof touched the gate, with no damage and no injuries. The model answered “no outcome” on 3 direct tries, and a full run after I reworded that trigger missed it again. I labeled it reportable for the behavior, not an outcome, which is exactly the grey area.

  • Tuned on the same 60 events, so the second result is a first look, not proof.
  • Only 3 hidden cases. Finding 2 of 3 says almost nothing yet.
  • One labeler, and no analyst has used it or been timed.

09My call

Neither version ships. The first fails the reading bar: 44 reads against a target of 23 or fewer. The second fails the safety bar: it missed a reportable event, and zero misses isn’t negotiable.

What I learned is that a yes/no alert is the wrong product. A ranked queue fits better: the flags that come with a quote go first, the model’s looser flags sit below, and the analyst stops when their time runs out. I haven’t tested that yet.

10What’s next

  1. Test a ranked queuePut quote-backed flags on top and the first version’s looser flags below. Measure how many reads it takes to reach all 3 hidden cases.first
  2. Label 140 more eventsDraw them fresh, never reuse the 60, and keep the original 60 as a holdout. Narrative-only cases get over-sampled.then
  3. Put it in front of a safety analystTime them with and without it. Their tolerance for reads sets the real target, not my guess of 23.then

11Built with

PythonOllamaQwen 2.5 14BYAMLHTMLNHTSA and OSHA data