Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(nlu): nlu cloud configuration #5296

Merged
merged 5 commits into from Aug 20, 2021
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
4 changes: 3 additions & 1 deletion modules/nlu/package.json
Expand Up @@ -26,7 +26,7 @@
"rimraf": "^3.0.2"
},
"dependencies": {
"@botpress/nlu-client": "0.1.2",
"@botpress/nlu-client": "0.1.4",
"axios": "^0.21.1",
"bluebird": "^3.5.3",
"bluebird-global": "^1.0.1",
Expand All @@ -37,9 +37,11 @@
"fs-extra": "^7.0.1",
"immutable": "^4.0.0-rc.12",
"joi": "^13.6.0",
"lock": "^1.1.0",
"lodash": "^4.17.19",
"lru-cache": "^5.1.1",
"mathjs": "^7.5.1",
"moment": "^2.29.1",
"ms": "^2.1.1",
"nanoid": "^2.0.1",
"semver": "^7.3.2",
Expand Down
24 changes: 20 additions & 4 deletions modules/nlu/src/backend/application/bot-factory.ts
@@ -1,12 +1,16 @@
import * as sdk from 'botpress/sdk'

import _ from 'lodash'
import { IStanEngine } from '../stan'
import crypto from 'crypto'
import { Client } from '@botpress/nlu-client'
import { LanguageSource } from 'src/config'
import { IStanEngine, StanEngine } from '../stan'
import pickSeed from './pick-seed'
import { Bot, IBot } from './scoped/bot'
import { ScopedDefinitionsService, IDefinitionsService } from './scoped/definitions-service'
import { IDefinitionsRepository } from './scoped/infrastructure/definitions-repository'
import { BotDefinition, BotConfig, I } from './typings'
import { NLUCloudClient } from '../cloud/client'

export interface ScopedServices {
bot: IBot
Expand All @@ -23,16 +27,28 @@ export type IScopedServicesFactory = I<ScopedServicesFactory>

export class ScopedServicesFactory {
constructor(
private _engine: IStanEngine,
private _languageSource: LanguageSource,
private _logger: sdk.Logger,
private _makeDefRepo: DefinitionRepositoryFactory
) {}

private makeEngine(botConfig: BotConfig): IStanEngine {
const { cloud } = botConfig
franklevasseur marked this conversation as resolved.
Show resolved Hide resolved

const stanClient = cloud
? new NLUCloudClient({ ...cloud, endpoint: this._languageSource.endpoint })
: new Client(this._languageSource.endpoint, this._languageSource.authToken)

return new StanEngine(stanClient, this._languageSource.authToken ?? '')
}

public makeBot = async (botConfig: BotConfig): Promise<ScopedServices> => {
const { id: botId } = botConfig

const engine = this.makeEngine(botConfig)

const { defaultLanguage } = botConfig
const { languages: engineLanguages } = await this._engine.getInfo()
const { languages: engineLanguages } = await engine.getInfo()
const languages = _.intersection(botConfig.languages, engineLanguages)
if (botConfig.languages.length !== languages.length) {
const missingLangMsg = `Bot ${botId} has configured languages that are not supported by language sources. Configure a before incoming hook to call an external NLU provider for those languages.`
Expand All @@ -50,7 +66,7 @@ export class ScopedServicesFactory {

const defService = new ScopedDefinitionsService(botDefinition, defRepo)

const bot = new Bot(botDefinition, this._engine, defService, this._logger)
const bot = new Bot(botDefinition, engine, defService, this._logger)

return {
defService,
Expand Down
2 changes: 1 addition & 1 deletion modules/nlu/src/backend/application/index.ts
Expand Up @@ -90,7 +90,7 @@ export class NLUApplication {
return async (lang: string) => {
const trainSet = await defService.getTrainSet(lang)
const trainInput = mapTrainSet(trainSet)
const { exists, modelId } = await this._engine.hasModelFor(bot.id, trainInput)
const { exists, modelId } = await bot.hasModelFor(trainInput)
const trainId = { botId, language: lang }
if (exists) {
await this.trainRepository.inTransaction(trx => trx.delete(trainId))
Expand Down
5 changes: 5 additions & 0 deletions modules/nlu/src/backend/application/scoped/bot.ts
Expand Up @@ -7,6 +7,7 @@ import { Predictor, ProgressCallback, Trainable, I } from '../typings'

import { IDefinitionsService } from './definitions-service'
import { ScopedPredictionHandler } from './prediction-handler'
import { TrainInput } from '@botpress/nlu-client'

interface BotDefinition {
botId: string
Expand Down Expand Up @@ -101,4 +102,8 @@ export class Bot implements Trainable, Predictor {
const { _predictor, _defaultLanguage } = this
return _predictor.predict(textInput, anticipatedLanguage ?? _defaultLanguage)
}

public hasModelFor(trainInput: TrainInput) {
return this._engine.hasModelFor(this.id, trainInput)
}
}
7 changes: 7 additions & 0 deletions modules/nlu/src/backend/application/typings.ts
Expand Up @@ -9,6 +9,13 @@ export interface BotConfig {
defaultLanguage: string
languages: string[]
nluSeed?: number
cloud?: CloudConfig
}

export interface CloudConfig {
oauthUrl: string
clientId: string
clientSecret: string
}

export interface BotDefinition {
Expand Down
10 changes: 4 additions & 6 deletions modules/nlu/src/backend/bootstrap.ts
Expand Up @@ -41,19 +41,17 @@ export async function bootStrap(bp: typeof sdk): Promise<NonBlockingNluApplicati
)
}

const { endpoint, authToken } = getNLUServerConfig(globalConfig.nluServer)
const stanClient = new Client(endpoint, authToken)

const modelPassword = '' // No need for password as Stan is protected by an auth token
const engine = new StanEngine(stanClient, modelPassword)
const languageSource = getNLUServerConfig(globalConfig.nluServer)
franklevasseur marked this conversation as resolved.
Show resolved Hide resolved
const stanClient = new Client(languageSource.endpoint, languageSource.authToken)
const engine = new StanEngine(stanClient, '') // No need for password as Stan is protected by an auth token
franklevasseur marked this conversation as resolved.
Show resolved Hide resolved

const socket = getWebsocket(bp)

const botService = new BotService()

const makeDefRepo = (bot: BotDefinition) => new ScopedDefinitionsRepository(bot, bp)

const servicesFactory = new ScopedServicesFactory(engine, bp.logger, makeDefRepo)
const servicesFactory = new ScopedServicesFactory(languageSource, bp.logger, makeDefRepo)

const trainRepo = new TrainingRepository(bp.database)
const trainingQueue = new DistributedTrainingQueue(trainRepo, bp.logger, botService, bp.distributed, socket, {
Expand Down
55 changes: 55 additions & 0 deletions modules/nlu/src/backend/cloud/cache.ts
@@ -0,0 +1,55 @@
import { Lock } from 'lock'
franklevasseur marked this conversation as resolved.
Show resolved Hide resolved

export interface Options<T> {
getToken?: (res: T) => string
getExpiry?: (res: T) => number
}

const defaultExpiry = 60000 // 10 minutes in ms
franklevasseur marked this conversation as resolved.
Show resolved Hide resolved

export const cache = <T>(authenticate: () => Promise<T>, options?: Options<T>) => {
const getToken: (res: any) => string = options?.getToken ?? (res => res)
const getExpiry: (res: any) => number = options?.getExpiry ?? (() => defaultExpiry)

const lock = Lock()

let token: string | null = null
let expiration: number | null = null

const tokenCache = async () => {
if (token && expiration && expiration - Date.now() > 0) {
return token
}

return new Promise<string>((resolve, reject) => {
lock('cache', unlockFn => {
franklevasseur marked this conversation as resolved.
Show resolved Hide resolved
const unlock = unlockFn()

if (token && expiration && expiration - Date.now() > 0) {
franklevasseur marked this conversation as resolved.
Show resolved Hide resolved
unlock()
resolve(token)
return
}

authenticate()
.then(authenticateRes => {
token = getToken(authenticateRes)
expiration = Date.now() + getExpiry(authenticateRes)
unlock()
resolve(token)
})
.catch(e => {
unlock()
reject(e)
})
})
})
}

tokenCache.reset = () => {
token = null
expiration = null
}

return tokenCache
}
12 changes: 12 additions & 0 deletions modules/nlu/src/backend/cloud/client.ts
@@ -0,0 +1,12 @@
import { Client } from '@botpress/nlu-client'
import { AxiosInstance } from 'axios'
import { createOauthClient, OauthClientProps } from './oauth'

export class NLUCloudClient extends Client {
private _client: AxiosInstance

constructor(options: OauthClientProps) {
super(options.endpoint, undefined)
this._client = createOauthClient(options)
franklevasseur marked this conversation as resolved.
Show resolved Hide resolved
}
}
71 changes: 71 additions & 0 deletions modules/nlu/src/backend/cloud/oauth.ts
@@ -0,0 +1,71 @@
import axios, { AxiosError, AxiosInstance, AxiosRequestConfig } from 'axios'
import { cache } from './cache'
import qs from 'querystring'
franklevasseur marked this conversation as resolved.
Show resolved Hide resolved

interface OauthTokenClientProps {
oauthUrl: string
clientId: string
clientSecret: string
}

interface OauthResponse {
access_token: string
expires_in: number
scope: string
token_type: string
}

const createOauthTokenClient = (
axios: AxiosInstance,
{ oauthUrl, clientId, clientSecret }: OauthTokenClientProps
) => () =>
axios
franklevasseur marked this conversation as resolved.
Show resolved Hide resolved
.post(
oauthUrl,
qs.stringify({ client_id: clientId, client_secret: clientSecret, grant_type: 'client_credentials' })
)
.then(res => res.data as OauthResponse)

const requestInterceptor = (authenticate: () => Promise<string>) => async (config: AxiosRequestConfig) => {
const token = await authenticate()
config.headers.Authorization = `Bearer ${token}`
return config
}

type ErrorRetrier = AxiosError & { config: { _retry: boolean } }

const errorInterceptor = (instance: AxiosInstance, authenticate: () => Promise<string>) => async (
error: ErrorRetrier
) => {
if (error.response?.status === 401 && !error.config._retry) {
error.config._retry = true
const token = await authenticate()
const config = error.config
config.headers.Authorization = `Bearer ${token}`
return instance.request(config)
}

return Promise.reject(error)
}

export type OauthClientProps = OauthTokenClientProps & {
endpoint: string
}

export const createOauthClient = ({ clientId, clientSecret, endpoint, oauthUrl }: OauthClientProps) => {
const oauthTokenClient = createOauthTokenClient(axios.create(), {
oauthUrl,
clientId,
clientSecret
})

const tokenCache = cache(oauthTokenClient, {
getExpiry: res => res.expires_in * 1000,
franklevasseur marked this conversation as resolved.
Show resolved Hide resolved
getToken: res => res.access_token
})

const axiosClient = axios.create({ baseURL: endpoint })
axiosClient.interceptors.request.use(requestInterceptor(tokenCache))
axiosClient.interceptors.response.use(undefined, errorInterceptor(axiosClient, tokenCache))
franklevasseur marked this conversation as resolved.
Show resolved Hide resolved
return axiosClient
}
18 changes: 14 additions & 4 deletions modules/nlu/yarn.lock
Expand Up @@ -2,10 +2,10 @@
# yarn lockfile v1


"@botpress/nlu-client@0.1.2":
version "0.1.2"
resolved "https://registry.yarnpkg.com/@botpress/nlu-client/-/nlu-client-0.1.2.tgz#9efacce79fd5229c75b7f04c65dcbe0416736376"
integrity sha512-YX7dzUK0q1TbDPWqIETHxsj/6h6s4y9t758UxrdSasGq9Zx3q81SZXqh0bnx0coi3Xv8OcNZy2ofhW33lVFCmg==
"@botpress/nlu-client@0.1.4":
version "0.1.4"
resolved "https://registry.yarnpkg.com/@botpress/nlu-client/-/nlu-client-0.1.4.tgz#9aa8fb4cb3481532fd67f5ea00a969b1a25aadf9"
integrity sha512-oNrtZ1irfxv3PxMmfAUQA5yxKQF+S2LFeVGrXH95oxzELjj4CLyJeBOYmxT7aX5vR8PMLiaXiKsjS6wtRzIogg==
dependencies:
axios "^0.21.1"
lodash "^4.17.19"
Expand Down Expand Up @@ -545,6 +545,11 @@ jsonfile@^4.0.0:
optionalDependencies:
graceful-fs "^4.1.6"

lock@^1.1.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/lock/-/lock-1.1.0.tgz#53157499d1653b136ca66451071fca615703fa55"
integrity sha1-UxV0mdFlOxNspmRRBx/KYVcD+lU=

lodash@>=4.17.21, lodash@^4.1.1, lodash@^4.17.19, lodash@^4.17.4:
version "4.17.21"
resolved "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c"
Expand Down Expand Up @@ -661,6 +666,11 @@ mkdirp@^0.5.0:
dependencies:
minimist "^1.2.5"

moment@^2.29.1:
version "2.29.1"
resolved "https://registry.yarnpkg.com/moment/-/moment-2.29.1.tgz#b2be769fa31940be9eeea6469c075e35006fa3d3"
integrity sha512-kHmoybcPV8Sqy59DwNDY3Jefr64lK/by/da0ViFcuA4DH0vQg5Q6Ze5VimxkfQNSC+Mls/Kx53s7TjP1RhFEDQ==

ms@2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8"
Expand Down
7 changes: 7 additions & 0 deletions packages/bp/src/sdk/botpress.d.ts
Expand Up @@ -933,6 +933,13 @@ declare module 'botpress/sdk' {
* if not set, seed is computed from botId
*/
nluSeed?: number
cloud?: CloudConfig
franklevasseur marked this conversation as resolved.
Show resolved Hide resolved
}

export interface CloudConfig {
oauthUrl: string
clientId: string
clientSecret: string
}

export type Pipeline = Stage[]
Expand Down