@grundlag/nlp (0.4.0)

Published 2026-08-22 08:53:52 +02:00 by morten-olsen in incubator/grundlag

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:

  1. Collect distinct slots across all actions, deduped by id — one fetch and one subscription per slot however many utterances share it.
  2. getValues({ services }) on each, concurrently. Never cached; a rebuild always re-reads.
  3. 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.
  4. Render each utterance to %slotId% placeholders and add it as a document under the action's guid as intent name.
  5. 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 = true is mandatory. Without it process returns an intent and entities: []. No error, no warning.
  • A rejected phrase has score: 1. nlp.js sets intent = 'None' and rewrites the score to 1. Gating on the number alone reads every rejection as a certainty. Only intent !== 'None' is safe.
  • Training prints an epoch trace to stdout. The NLU registers log: true as its per-locale default and Nlp.train() takes no settings to override it, so the only way out is container.registerConfiguration('nlu-<locale>', { log: false }, true) before training.
  • npm's latest tag is 5.0.0-alpha.5. Deps are pinned to exact 4.x versions for this reason — a stray pnpm add pulls 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 onon-A and %slotB% lights onon-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 execute yet. NLPAction declares one, taking { services, entities, logger }, and invokeAction(action, mapping) builds one that reaches an existing Action. But the engine only classifies — no path runs a match. Wiring that up is the remaining work, and classifyWith already produces exactly the per-slot records NlpEntityRegistry takes, so it is mostly plumbing.
  • A matched action's return value is dropped. invokeAction discards it, because an intent has no output channel — execute returns Promise<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 or addLanguage per locale plus per-locale corpora.
  • threshold defaults 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
Details
npm
2026-08-22 08:53:52 +02:00
0
MIT
23 KiB
Assets (1)
nlp-0.4.0.tgz 23 KiB
Versions (2) View all
0.5.0 2026-08-22
0.4.0 2026-08-22