@grundlag/nlp (0.4.0)
Installation
@grundlag:registry=npm install @grundlag/nlp@0.4.0"@grundlag/nlp": "0.4.0"About this package
@grundlag/nlp
A cheap first pass in front of a model: providers declare the phrasings they answer to, and this matches an incoming phrase against them without a model call. Intent classification and slot extraction over nlp.js.
It classifies; it does not act. A match says which intent was recognised and what filled its slots. Invoking anything is the caller's job — see Known gaps.
User-facing docs: docs/src/content/docs/guides/providers/match-spoken-phrases.md. This file
is the engineering notes.
How it fits together
Three layers, each usable without the one above:
| Piece | Lives in | Knows about |
|---|---|---|
Slot, utterance, NLPAction |
@grundlag/core/src/nlp |
Nothing. Pure declaration, no engine. |
NlpEngine |
src/engine |
nlp.js. Takes plain arrays, no registry. |
NlpService |
src/service |
ProviderRegistry. The adapter that feeds it. |
NlpEngine takes Array<{ providerId, actions }> rather than a ProviderRegistry so it can
be tested without standing up providers; NlpService does the flattening and is what
@grundlag/server resolves for POST /api/nlp/classify.
The id design
Both slot ids and NLPAction ids are generated guids, minted by createNlpId in core.
Nothing owns a namespace, so two providers that both think of something as "room" or as "turn
lights on" cannot collide, and there is no allocation/dedup pass equivalent to
allocateToolNames.
The trade is that ids are per-process. Fine for dispatch, since the model is retrained at
startup anyway — but anything that outlives the process (a log line, a "what did people ask
for" metric) must key on name, not id, or it fragments at every restart.
createNlpId is also the single place the id format is decided, which matters because
nlp.js delimits entity placeholders with %: an id containing the delimiter would break
corpus rendering. Change the charset there, not at the call sites.
How a corpus is built
buildModel in src/engine/engine.model.ts:
- Collect distinct slots across all actions, deduped by id — one fetch and one subscription per slot however many utterances share it.
getValues({ services })on each, concurrently. Never cached; a rebuild always re-reads.- Register each value as an nlp.js enum NER option under the slot's guid, keyed
${slotId}-${index}. That key only has to be stable within one model, since the map that reads it is rebuilt alongside it. - Render each utterance to
%slotId%placeholders and add it as a document under the action's guid as intent name. - Train.
Because entities are registered separately from documents, the corpus does not expand the cross-product — an utterance with two 50-value slots is one training document, not 2,500. This is the main reason nlp.js is a good fit for this design.
Retraining
Slots emit updated; the engine debounces (retrainDelay, default 250ms) and coalesces, so a
provider reconnecting and refreshing every slot in one tick costs one rebuild.
A model is immutable once built — a rebuild produces a whole new one and the reference is swapped, so a classification in flight never sees half a corpus. The last good model keeps answering while a rebuild runs, and a build that throws leaves it in place and logs. Only the build that is still current publishes its result, so a slow earlier build cannot overwrite a newer one that already landed.
nlp.js gotchas
All four are silent failures. Verified against the installed 4.x runtime — the published docs
describe the node-nlp bundle, whose defaults differ.
forceNER = trueis mandatory. Without itprocessreturns an intent andentities: []. No error, no warning.- A rejected phrase has
score: 1. nlp.js setsintent = 'None'and rewrites the score to 1. Gating on the number alone reads every rejection as a certainty. Onlyintent !== 'None'is safe. - Training prints an epoch trace to stdout. The NLU registers
log: trueas its per-locale default andNlp.train()takes no settings to override it, so the only way out iscontainer.registerConfiguration('nlu-<locale>', { log: false }, true)before training. - npm's
latesttag is5.0.0-alpha.5. Deps are pinned to exact 4.x versions for this reason — a straypnpm addpulls an alpha.
The packages ship no types. src/nlpjs/nlpjs.d.ts declares only the surface we call, and
every signature there was confirmed against the runtime. An incorrect declaration in that file
is a runtime failure, not a type error, so widen it deliberately.
Known gaps
Intents that differ only by their slots cancel out — not yet addressed
The mechanism. nlp.js strips entity placeholders from the classifier's feature set. The
intent classifier only ever sees an utterance's literal words; which slot sits in a gap
contributes nothing. Two intents whose literal words match are therefore indistinguishable to
it, they split confidence roughly in half, neither clears the threshold, and the phrase
classifies as None.
Verified — training %slotA% lights on → on-A and %slotB% lights on → on-B, then
classifying:
"@slotA lights on" => on-B=0.5007 on-A=0.4993
"@slotB lights on" => on-B=0.5007 on-A=0.4993
"lights on" => on-B=0.5007 on-A=0.4993
Identical for all three, including the bare phrase with no placeholder at all. The 0.5007 is initialisation noise, not signal. Note the NER does resolve the right slot; only the classifier is blind to it.
This is not only a cross-provider problem. Any two intents colliding on literal words hit
it, including two declared by the same provider — Turn on the ${room} and
Turn on the ${device} are the same sentence as far as the classifier is concerned. The
cross-provider case is just the one that arrives without anyone deciding to write it: two
instances of one provider type register the same NLPActions, so every phrase becomes
None and the first pass silently stops earning its keep. It degrades to "always ask the
model" — correct, but invisible.
Current behaviour is to decline, which is defensible for a pass whose job is to be cheap
and certain. Two tests in src/engine/engine.test.ts pin it, one per case, so it cannot
change unnoticed.
The fix has to be corpus-level dedup, and no naming scheme is an alternative — the intent
name never reaches the classifier, so qualifying it by provider changes nothing. Group intents
whose rendered text is identical modulo slot ids into one trained intent, then disambiguate
afterwards by which slot's entity actually matched. The NER already returns enough to do this;
it reported the right slot even while the intent failed. It changes what classify can
promise, so it needs a decision before implementation.
Smaller ones
- Nothing calls
executeyet.NLPActiondeclares one, taking{ services, entities, logger }, andinvokeAction(action, mapping)builds one that reaches an existingAction. But the engine only classifies — no path runs a match. Wiring that up is the remaining work, andclassifyWithalready produces exactly the per-slot recordsNlpEntityRegistrytakes, so it is mostly plumbing. - A matched action's return value is dropped.
invokeActiondiscards it, because an intent has no output channel —executereturnsPromise<void>. Whatever eventually runs matches will need to decide whether a spoken or displayed response flows back, and that likely widens the return type. - No boot-time validation of intents. Nothing checks at train time that a provider's utterances are distinguishable from each other or reference reachable slots.
- English only. One locale per engine, defaulting to
en. Multi-locale means either several engines oraddLanguageper locale plus per-locale corpora. thresholddefaults to 0.8 and is the engine's own floor, not a decision about whether to act. A caller about to execute rather than display should apply a higher bar — a false positive there is a wrong action taken silently.
Dependencies
Dependencies
| ID | Version |
|---|---|
| @grundlag/core | 0.4.0 |
| @nlpjs/core | 4.26.1 |
| @nlpjs/lang-en-min | 4.26.1 |
| @nlpjs/nlp | 4.27.0 |
Development dependencies
| ID | Version |
|---|---|
| @grundlag/config | 0.4.0 |
| @types/node | ^26.1.1 |
| typescript | ^6.0.3 |
| vitest | ^4.1.10 |