Skip to content

Commit

Permalink
feat(nlu): Add NLU listener handling as property of text message
Browse files Browse the repository at this point in the history
  • Loading branch information
timkinnane committed Apr 23, 2018
1 parent 64e2009 commit f18f895
Show file tree
Hide file tree
Showing 7 changed files with 264 additions and 29 deletions.
75 changes: 75 additions & 0 deletions md/ThoughtProcess.md
@@ -0,0 +1,75 @@
# Thought Process

The bBot *Thought Process* describes how this clever and purposeful bot
elegantly handles discourse.

The internals involve a series processing steps, defined by a middleware stack
and callback for each. Each middleware receives the current state of processing.

For help interacting with middleware, see [Middleware.md](Middleware.md).

It all starts when the message adapter inputs a message via a `.receive` call.

## Hear

bBbot hears all messages to determine if the context requires attention.
It gather information for better listening, or ignores if it shouldn't listen.

Add a middleware piece via `.hearMiddleware` to interrupt the process or modify
the state for further processing.

## Listen

bBot takes in the message and if recognised, runs the scenario, furthering a
conversation or a simple exchange. It may not be immediately understood.

Listeners provide a matching function to evaluate the message and fire callbacks
on match. They are added with the following methods:

- `.listenText` adds regular expression matching on message text
- `.listenDirect` adds regular expressions prepended with the bot's name
- `.listenCustom` adds a custom matching method, e.g. match on an attribute

Add a middleware piece via `.listenMiddleware` to fire on every matching
listener, to interrupt or modify the state.

If no listeners fire yet, we include listeners from the next (Understand) stage.

## Understand

bBot can use natural language services to listen for the intent rather than the
exact command. The nature of the message is collected from external providers.

A special type of `NaturalLanguageListener` is used for this stage, that
evaluates the intent and entities of the message, by sending the message to the
NLU adapter.

- `.understand` adds natural language matching on intent and/or entities
- `.understandDirect` adds natural language that must be addressed to the bot
- `.understandCustom` adds custom natural language matching (given NLU result)

Add a middleware piece via `.understandMiddleware` to execute on every matching
language listener, to interrupt or modify the state.

## Act

bBot takes any required action, locally or through external integrations.

This is an inherent result of the completion of `listen` and `understand`
middleware. Matched listeners will have their callbacks called, or if they
provided a `bit` key, those bits will be executed.

## Respond.

bBot replies to the people it's engaged with appropriately. Canned responses
are mixed with context and may include rich UI elements.

Add a middleware piece via `.respondMiddleware` to execute on any sends, if
matched callbacks or bits prompted messages to be sent.

## Remember.

bBot remembers everything, the person, context and content. Important details
are kept for quick access. Everything else is stored for safekeeping.

That all might take a few milliseconds, then we're back at the beginning.
1 change: 1 addition & 0 deletions package.json
Expand Up @@ -12,6 +12,7 @@
"chat",
"ai",
"nlp",
"nlu",
"chatops",
"messaging",
"conversation",
Expand Down
1 change: 0 additions & 1 deletion src/adapters/shell.ts
Expand Up @@ -4,7 +4,6 @@ export class Shell extends MessageAdapter {
name = 'shell-message-adapter'
constructor (bot: any) {
super(bot)
console.log(Object.keys(this.bot))
this.bot.logger.info('Using Shell as message adapter')
}
}
Expand Down
44 changes: 43 additions & 1 deletion src/lib/listen.spec.ts
Expand Up @@ -17,17 +17,22 @@ import * as listen from './listen'

// setup spies and mock listener class that matches on 'test'
const mockUser = new User('TEST_ID', { name: 'testy' })

// message matching listener that looks for 'test'
const mockMessage = new TextMessage(mockUser, 'test')
class MockListener extends listen.Listener {
matcher (message) { return /test/.test(message.text) }
async matcher (message) { return /test/.test(message.text) }
}

// spy on listener and middleware methods
const callback = sinon.spy()
const mockListener = new MockListener(callback)
const matcher = sinon.spy(mockListener, 'matcher')
const mockMiddleware = new Middleware('mock')
const mockPiece = sinon.spy()
const execute = sinon.spy(mockMiddleware, 'execute')
mockMiddleware.register(mockPiece)

const delay = (ms) => new Promise((resolve, reject) => setTimeout(resolve, ms))

describe('listen', () => {
Expand Down Expand Up @@ -117,6 +122,31 @@ describe('listen', () => {
expect(result.match).to.equal('delayed')
})
})
describe('NaturalLanguageListener', () => {
it('.process adds matcher result to state', () => {
const nluListener = new listen.NaturalLanguageListener({
intent: 'foo'
}, (state) => {
expect(state.match).to.eql({
intent: 'foo',
entities: {},
confidence: 20
})
})
const message = new TextMessage(mockUser, 'foo')
message.nlu = { intent: 'foo', entities: {}, confidence: 100 }
return nluListener.process(message)
})
it('.process fails match below confidence threshold', async () => {
const nluListener = new listen.NaturalLanguageListener({
intent: 'foo'
}, () => null)
const message = new TextMessage(mockUser, 'foo')
message.nlu = { intent: 'foo', entities: {}, confidence: 79 }
const state = await nluListener.process(message)
expect(state.matched).to.equal(false)
})
})
describe('.listenText', () => {
it('adds text listener to collection, returning ID', () => {
const id = listen.listenText(/test/, () => null)
Expand All @@ -133,6 +163,18 @@ describe('listen', () => {
const id = listen.listenCustom(() => null, () => null)
expect(listen.listeners[id]).to.be.instanceof(listen.CustomListener)
})
describe('.understand', () => {
it('adds NLU listener to NLU collection, returning ID', () => {
const id = listen.understand({ intent: 'test' }, () => null)
expect(listen.nluListeners[id]).to.be.instanceof(listen.NaturalLanguageListener)
})
})
describe('.understandCustom', () => {
it('adds custom listener to NLU collection, returning ID', () => {
const id = listen.understandCustom(() => null, () => null)
expect(listen.nluListeners[id]).to.be.instanceof(listen.CustomListener)
})
})
describe('.directPattern', () => {
it('creates new regex for bot name prefixed to original', () => {
const direct = listen.directPattern(/test/)
Expand Down
152 changes: 128 additions & 24 deletions src/lib/listen.ts
Expand Up @@ -15,22 +15,39 @@ import {
CatchAllMessage,
name,
alias,
doBit
doBit,
TextMessage
} from '..'

/**
* @todo Do NOT use `match` as truthy, add explicit `pass` boolean instead.
* Allows intent to be populated and set `pass` when `match` is null.
*/

/** Array of listeners to feed message streams */
/** Array of listeners to feed message streams via listen middleware */
export const listeners: {
[id: string]: Listener
[id: string]: TextListener | CustomListener
} = {}

/** Array of special language listeners, processed by understand middleware */
export const nluListeners: {
[id: string]: NaturalLanguageListener | CustomListener
} = {}

/** Interface for matcher functions - resolved value must be truthy */
export interface IMatcher {
(message: Message): Promise<any> | any
(input: any): Promise<any> | any
}

/** Interface for natural language matchers to evaluate returned NLU result */
export interface INaturalLanguageListenerOptions {
intent?: string, // Match this intent string
entities?: {[key: string]: any}, // Match these entities (never required)
confidence?: number, // Threshold for confidence matching
requireConfidence?: boolean, // Do not match without meeting threshold
requireIntent?: boolean // Do not match without intent
}

/** Match object interface for language matchers to populate */
export interface INaturalLanguageMatch {
intent?: string | null, // the intent that was matched (if matched on intent)
entities?: {[key: string]: any} // any subset of entities that were matched
confidence?: number, // the confidence relative to the threshold (+/-)
}

/** Function called if the incoming message matches */
Expand Down Expand Up @@ -58,6 +75,8 @@ export interface IListenerMeta {
export abstract class Listener {
callback: IListenerCallback
id: string
match: any

/** Create a listener, add to collection */
constructor (
action: IListenerCallback | string,
Expand All @@ -70,11 +89,11 @@ export abstract class Listener {
}

/**
* Determine if this listener should trigger the callback
* Note that the method can be async or not, custom matchers will be wrapped
* with a forced promise resolve in case they return immediately.
* Determine if this listener should trigger the callback.
* Note that the method must be async, custom matcher will be promise wrapped.
* Abstract input has no enforced type, but resolved result MUST be truthy.
*/
abstract matcher (message: Message): Promise<any> | any
abstract matcher (input: any): Promise<any>

/**
* Runs the matcher, then middleware and callback if matched.
Expand All @@ -91,13 +110,14 @@ export abstract class Listener {
else logger.debug(`Listener did not match`, { id: this.meta.id })
}
): Promise<IState> {
const match = await Promise.resolve(this.matcher(message))
this.match = await Promise.resolve(this.matcher(message))
const state = new State({
message: message,
listener: this,
match: match
match: this.match,
matched: (this.match) ? true : false
})
if (match) {
if (state.matched) {
const complete: IComplete = (state, done) => {
logger.debug(`Executing ${this.constructor.name} callback`, { id: this.meta.id })
this.callback(state)
Expand All @@ -118,6 +138,8 @@ export abstract class Listener {

/** Custom listeners use unique matching function */
export class CustomListener extends Listener {
match: any

/** Accepts custom function to test message */
constructor (
public customMatcher: IMatcher,
Expand All @@ -139,7 +161,9 @@ export class CustomListener extends Listener {

/** Text listeners use basic regex matching */
export class TextListener extends Listener {
/** Accepts regex before standard arguments */
match: RegExpMatchArray | undefined

/** Create text listener for regex pattern */
constructor (
public regex: RegExp,
callback: IListenerCallback | string,
Expand All @@ -158,7 +182,57 @@ export class TextListener extends Listener {
}
}

/** @todo LanguageListener - match intent and confidence threshold */
/**
* Language listener uses NLU adapter result to match on intent and (optionally)
* entities and/or confidence threshold. NLU must be trained to provide intent.
* @todo Update this concept, matcher is uninformed at this stage.
* @todo Use argv / environment variable for default confidence threshold.
*/
export class NaturalLanguageListener extends Listener {
match: INaturalLanguageMatch | undefined

/** Create language listener for NLU matching */
constructor (
public options: INaturalLanguageListenerOptions,
callback: IListenerCallback | string,
meta?: IListenerMeta
) {
super(callback, meta)
if (!this.options.confidence) this.options.confidence = 80
if (!this.options.entities) this.options.entities = {}
if (!this.options.requireConfidence) this.options.requireConfidence = true
if (!this.options.requireIntent) this.options.requireIntent = true
}

/** Match on message's NLU properties */
async matcher (message: TextMessage) {
if (!message.nlu) {
logger.error('NaturalLanguageListener attempted matching without NLU', { id: this.meta.id })
return undefined
}

const confidence = (message.nlu.confidence - this.options.confidence!)
if (this.options.requireConfidence && confidence < 0) return undefined

const intent: string | null = (this.options.intent === message.nlu.intent)
? message.nlu.intent
: null
if (this.options.intent && !message.nlu.intent) return undefined

const entities: {[key: string]: any} = {}
Object.keys(this.options.entities!).forEach((key) => {
if (
JSON.stringify(this.options.entities![key]) ===
JSON.stringify(message.nlu!.entities[key])
) entities[key] = message.nlu!.entities[key]
})
const match: INaturalLanguageMatch = { intent, entities, confidence }
if (match) {
logger.debug(`NLU matched language listener for ${intent} intent with ${confidence} confidence ${confidence < 0 ? 'under' : 'over'} threshold`, { id: this.meta.id })
}
return match
}
}

/** Create text listener with provided regex, action and optional meta */
export function listenText (
Expand Down Expand Up @@ -193,20 +267,50 @@ export function listenCustom (
return listener.id
}

/** Create a natural language listener to match on NLU result attributes */
export function understand (
options: INaturalLanguageListenerOptions,
action: IListenerCallback | string,
meta?: IListenerMeta
): string {
const nluListener = new NaturalLanguageListener(options, action, meta)
nluListeners[nluListener.id] = nluListener
return nluListener.id
}

/** @todo FIX THIS */
/*
export function understandDirect (
options: INaturalLanguageListenerOptions,
action: IListenerCallback | string,
meta?: IListenerMeta
): string {
// const matcher = directPattern(/(.*)/) --> pass into custom listener...
const nluListener = new NaturalLanguageListener(options, action, meta)
nluListeners[nluListener.id] = nluListener
return nluListener.id
}
*/

/** Create a custom listener to process NLU result with provided function */
export function understandCustom (
matcher: IMatcher,
action: IListenerCallback | string,
meta?: IListenerMeta
): string {
const nluListener = new CustomListener(matcher, action, meta)
nluListeners[nluListener.id] = nluListener
return nluListener.id
}

/** Build a regular expression that matches text prefixed with the bot's name */
export function directPattern (regex: RegExp): RegExp {
const regexWithoutModifiers = regex.toString().split('/')
regexWithoutModifiers.shift()
const modifiers = regexWithoutModifiers.pop()
const startsWithAnchor = regexWithoutModifiers[0] && regexWithoutModifiers[0][0] === '^'
const pattern = regexWithoutModifiers.join('/')
const botName = name.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, '\\$&')

if (startsWithAnchor) {
logger.warn(`Anchors don't work well with direct listens, perhaps you want to use standard listen`)
logger.warn(`The regex in question was ${regex.toString()}`)
}

if (!alias) {
return new RegExp(`^\\s*[@]?${botName}[:,]?\\s*(?:${pattern})`, modifiers)
}
Expand Down

0 comments on commit f18f895

Please sign in to comment.