If you've ever wired up the Web Speech API and rendered the result straight to the page, you've seen this:
const recognition = new (window.SpeechRecognition || window.webkitSpeechRecognition)()
recognition.lang = 'en-US'
recognition.onresult = (event) => {
const transcript = event.results[0][0].transcript
console.log(transcript) // 'can you send me the report'
}
recognition.start()
The transcription is correct. The formatting isn't. No leading capital, no question mark. The same thing happens with streamed LLM tokens that get cut off before the final punctuation lands, and with any form field where users type fast and don't bother.
The instinctive fix is to send it back through a model. "Clean up this text." It works, and it's a strange amount of machinery for the problem: a network round trip, tokens billed, latency you can feel, and a nondeterministic result for a task that is almost entirely mechanical.
Capitalizing the first letter is trivial. The actual question is which mark goes at the end — and that turns out to be the only interesting part.
The real problem is classification, not punctuation
To punctuate a sentence you first have to know what kind of sentence it is:
-
can you send me the report→ interrogative →? -
this is amazing→ exclamatory →! -
the meeting is at three→ declarative →.
That's a three-way classification problem. And for unpunctuated English it's a problem with a lot of surface structure to exploit — interrogatives overwhelmingly start with an auxiliary or a wh-word, exclamatives have their own recognizable openers. You don't need a language model to notice can you.
Which is what I ended up building. sentencify is a small library that does the classification with ordered regular expressions, then applies the punctuation:
npm install sentencify
import { correctSentence } from 'sentencify'
correctSentence('can you send me the report') // 'Can you send me the report?'
correctSentence('this is amazing') // 'This is amazing!'
correctSentence('the meeting is at three') // 'The meeting is at three.'
No await. No model. No dependencies.
Punctuation is not universal, and this is where it gets interesting
If you're only shipping English, you could write the naive version yourself in an afternoon. The moment you add a second language, the assumptions break.
Spanish opens a question and closes it — ¿Cuál es tu nombre? — so you can't just append to the end, you have to prepend too. French typography puts a space before ?, !, and : — Comment vas-tu ? — and text that omits it looks wrong to a French reader in the way Hello ,world looks wrong to an English one. Japanese uses 。 and ?, not the ASCII marks.
correctSentence('cuál es tu nombre', 'es') // '¿Cuál es tu nombre?'
correctSentence('comment vas-tu', 'fr') // 'Comment vas-tu ?'
correctSentence('kannst du mir helfen', 'de') // 'Kannst du mir helfen?'
correctSentence('すごい', 'ja') // 'すごい!'
correctSentence('qual é o seu nome', 'pt') // 'Qual é o seu nome?'
Six languages currently: English, Japanese, German, Spanish, French, Portuguese.
How it works, and why the ordering is load-bearing
Each language is an ordered array of rules:
type SentenceTypeDetectExpressionSets = {
expression: RegExp
type: 'exclamatory' | 'interrogative' | 'declarative'
}[]
detectSentenceType walks the array top to bottom and returns the type of the first rule that matches. Nothing matches, it falls through to declarative.
This is worth being explicit about, because it's the main thing that makes the library easy to reason about and also the main thing that makes it fragile. First-match means a broad interrogative pattern placed above a narrow exclamatory one will silently swallow it. The bug is invisible until someone reports one specific sentence coming out wrong.
The alternative design is to score every match and take the highest confidence. More robust, considerably harder to debug when it misfires. I chose debuggable, and I'm not certain that was the right call.
Because it's debuggable, the rule sets are a public export rather than a hidden internal:
import { expressionsByLanguage } from 'sentencify'
// Read the actual ordered rules for a language and see which one fires
console.log(expressionsByLanguage.en)
When a sentence classifies wrong, you can find the exact rule responsible instead of filing an issue against a black box.
The properties that come from not using a model
This is the part that actually matters for production use:
Deterministic. Same input, same output, forever. You can write assertions against it.
Synchronous. No promise, no warm-up, no cold start. Cheap enough to run on every keystroke in a controlled input, or on every token in a stream.
Idempotent. Already-punctuated text passes through untouched. You will not get Hello world.. by calling it twice, which matters when the call sits somewhere in a pipeline you don't fully control.
correctSentence('Already punctuated.') // 'Already punctuated.' — unchanged
Offline. No network call means no failure mode where your text formatting goes down because a provider had an incident.
Small. Zero runtime dependencies, ESM-only, sideEffects: false, under 40 KB unpacked.
What it deliberately doesn't do
It is not a grammar checker. It won't fix spelling, agreement, or word choice. It won't split a run-on into sentences. It classifies and punctuates, and that's the whole scope.
And regex rules will get sentences wrong that a model would get right. Indirect questions are the obvious failure class — I wonder if you could send the report is declarative but reads interrogative to a naive pattern. That's the trade. You give up ceiling accuracy and you get determinism, speed, inspectability, and no bill.
For a lot of pipelines that's the right trade, because the text was going to be displayed unpunctuated otherwise.
Where it fits
The pattern I'd suggest is treating this as a finishing pass, not a replacement for anything:
- After a speech-to-text transcript, before display or storage
- After an LLM completion, as the last step before rendering
- On chat and support-ticket input, to normalize formatting
- On short-answer form fields, comments, reviews
It runs after the expensive thing has already happened, and it costs nothing.
Which makes the fix to the example this article opened with a one-line change:
import { correctSentence } from 'sentencify'
recognition.onresult = (event) => {
const transcript = event.results[0][0].transcript
const clean = correctSentence(transcript, recognition.lang)
// 'Can you send me the report?'
}
Note that recognition.lang is a full locale — 'en-US', 'fr-FR', 'ja-JP' — and gets passed straight through. Both correctSentence and isPunctuationAvailable match on the first two characters, so locale variants resolve to the base language and you don't have to slice it yourself. Unsupported languages still get capitalized, they just skip punctuation.
In TypeScript the language parameter is typed as a narrow union of the six supported codes, so a raw locale string needs a guard or a cast — isPunctuationAvailable(recognition.lang) is there for exactly that check.
Try it and tell me where it's wrong
npm install sentencify
It's new, and the English rules have had the most attention. If you throw a sentence at it that classifies wrong, open an issue — misclassified sentences are the single most useful thing anyone can send me right now.
Adding a language is one file: the ordered rule array plus that language's punctuation conventions, no core changes. Arabic (؟), Hindi (।), and Greek (;) would all be interesting, since in each of them the question mark isn't ?.
Top comments (0)