Classifier
The Classifier class is the abstraction behind the classifier
option of the server. It reads an input and returns a typed result: an intent, a
score, a yes or no.
In a call, the server classifies each turn of the user when it ends. The class
works on its own just as well: call classify() with any text or JSON.
The base class keeps track of the classifications in progress, so the server can
wait for them or cancel them. A provider implements a single method,
evaluate().
Available Implementations
Overview
// What a classifier reads: a text, or JSON with named fieldsexport type ClassifierInput = string | { [key: string]: any } | any[]
export abstract class Classifier< Result = any, Input extends ClassifierInput = ClassifierInput,> extends EventEmitter<ClassifierEvents<Result, Input>> { public logger?: Logger
// The last classification public lastClassification?: MicdropClassification<Result, Input>
// Answers the questions of this classifier about an input protected abstract evaluate(input: Input, signal: AbortSignal): Promise<Result>
// Classifies an input, and emits the result as Classification classify(input: Input): Promise<MicdropClassification<Result, Input> | undefined>
// The last classification started, until it is done get pending(): Promise<MicdropClassification<Result, Input> | undefined> | undefined
// Drops every classification in progress, which then emit nothing cancel(): void
destroy(): void}classify() resolves with the classification, or with undefined when it was
cancelled or failed. A failure goes to the logger, and the call carries on
without it.
The signal of evaluate() is aborted once the result is no longer wanted:
the user spoke again and the turn will be classified whole at its next end, or
the call ended. Pass it to your HTTP client so the request stops too.
The constructor takes no options. A provider declares its own, and the server
reads its settings from classifierOptions.
The input in a call
MicdropServer classifies a MicdropTurnInput:
interface MicdropTurnInput { // The turn before, and the answer that followed it history: Array<{ role: 'user' | 'assistant'; text: string }> // What the user said in this turn, transcripts joined turn: string}A classifier made for calls types its input with it, and reads turn and
history by name. A classifier accepting any ClassifierInput receives the same
object, and can pass it as is to a model that reads JSON.
The result
Result is whatever evaluate() returns. Each classification wraps it:
interface MicdropClassification<Result = any, Input = any> { input: Input // What was classified result: Result // What evaluate() returned duration: number // Time the classification took, in ms}In a call, the server keeps it in the metadata of the last user message of the
turn, and can send it to the client as JSON, so keep Result serializable.
Events
Classification
Emitted for each classification that completes. A cancelled one emits nothing.
classifier.on('Classification', ({ input, result, duration }) => { console.log(input, result, `${duration} ms`)})Debug Logging
classifier.logger = new Logger('Classifier')Each classification is then logged with its input and duration.
Custom Classifier Implementation
With an LLM and structured output
Any LLM able to return a typed object makes a classifier. This one uses the AI SDK with a zod schema, and reads the turn and its history:
import { openai } from '@ai-sdk/openai'import { Classifier, MicdropTurnInput } from '@micdrop/server'import { generateText, Output } from 'ai'import { z } from 'zod'
const schema = z.object({ intent: z.enum(['billing', 'outage', 'cancel', 'other']), frustrated: z.boolean(),})
type Decision = z.infer<typeof schema>
export class LlmClassifier extends Classifier<Decision, MicdropTurnInput> { protected async evaluate( { history, turn }: MicdropTurnInput, signal: AbortSignal ): Promise<Decision> { const context = history .map(({ role, text }) => `${role}: ${text}`) .join('\n')
const { output } = await generateText({ model: openai('gpt-5-mini'), output: Output.object({ schema }), system: 'Classify the last turn of a customer calling an internet provider.', prompt: `${context}\n\nCustomer: ${turn}`, abortSignal: signal, }) return output }}new MicdropServer(socket, { stt, agent, tts, classifier: new LlmClassifier(), classifierOptions: { waitBeforeAnswer: true, maxWait: 1500 },})A general LLM takes longer than a dedicated classification model, often over a
second. Pick a small model, and give maxWait a limit that matches its latency.
With keywords
A classifier needs no model at all. This one flags a few words, in well under a millisecond, and accepts a plain text as well as a turn:
import { Classifier, MicdropTurnInput } from '@micdrop/server'
export class KeywordClassifier extends Classifier< { wantsHuman: boolean }, string | MicdropTurnInput> { protected async evaluate(input: string | MicdropTurnInput) { const text = typeof input === 'string' ? input : input.turn return { wantsHuman: /\b(human|agent|advisor|manager|real person)\b/i.test(text), } }}Keywords miss every sentence phrased another way, which is where a model earns its place. They make a fine first layer, or a test double.
Usage without MicdropServer
classify() takes any input the classifier accepts, and resolves with its
classification:
const classifier = new KeywordClassifier()
const classification = await classifier.classify('Can I talk to a human?')console.log(classification?.result.wantsHuman) // true
await classifier.classify({ history: [], turn: 'My internet is down, get me a manager.',})To classify a conversation you hold yourself, turnInput(conversation, history)
from @micdrop/server builds the same MicdropTurnInput as the server. It
returns the input, and the last message of the turn:
import { turnInput } from '@micdrop/server'
const { input } = turnInput(conversation, 1)await classifier.classify(input)