type2docfx
Version:
A tool to convert json format output from TypeDoc to universal reference model for DocFx to consume.
355 lines (311 loc) • 14.9 kB
text/typescript
/**
* @module botbuilder-ai
*/
/**
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License.
*/
import { Middleware, TurnContext, RecognizerResult } from 'botbuilder';
import { LuisResult, Intent, Entity, CompositeEntity } from 'botframework-luis/lib/models';
import LuisClient = require('botframework-luis');
const LUIS_TRACE_TYPE = 'https://www.luis.ai/schemas/trace';
const LUIS_TRACE_NAME = 'LuisRecognizerMiddleware';
const LUIS_TRACE_LABEL = 'Luis Trace';
interface LuisOptions {
Staging?: boolean
}
interface LuisModel {
ModelID: string,
}
interface LuisTraceInfo {
recognizerResult: RecognizerResult;
luisResult: LuisResult;
luisOptions: LuisOptions;
luisModel: LuisModel;
}
export interface LuisRecognizerSettings {
/** Your models AppId */
appId: string;
/** Your subscription key. */
subscriptionKey: string;
/** (Optional) service endpoint to call. Defaults to "https://westus.api.cognitive.microsoft.com". */
serviceEndpoint?: string;
/** (Optional) if set to true, we return the metadata of the returned intents/entities. Defaults to true */
verbose?: boolean;
/** (Optional) request options passed to service call. */
options?: {
timezoneOffset?: number;
verbose?: boolean;
forceSet?: string;
allowSampling?: string;
customHeaders?: { [headerName: string]: string; };
staging?: boolean;
};
}
export class LuisRecognizer implements Middleware {
private settings: LuisRecognizerSettings
private luisClient: LuisClient;
private cacheKey = Symbol('results');
/**
* Creates a new LuisRecognizer instance.
* @param settings Settings used to configure the instance.
*/
constructor(settings: LuisRecognizerSettings) {
this.settings = Object.assign({}, settings);
// Create client and override callbacks
const baseUri = (this.settings.serviceEndpoint || 'https://westus.api.cognitive.microsoft.com');
this.luisClient = this.createClient(baseUri + '/luis/');
}
public onTurn(context: TurnContext, next: () => Promise<void>): Promise<void> {
return this.recognize(context, true)
.then(() => next());
}
/**
* Returns the results cached from a previous call to [recognize()](#recognize) for the current
* turn with the user. This will return `undefined` if recognize() hasn't been called for the
* current turn.
* @param context Context for the current turn of conversation with the use.
*/
public get(context: TurnContext): RecognizerResult | undefined {
return context.services.get(this.cacheKey);
}
/**
* Calls LUIS to recognize intents and entities in a users utterance. The results of the call
* will be cached to the context object for the turn and future calls to recognize() for the
* same context object will result in the cached value being returned. This behavior can be
* overridden using the `force` parameter.
* @param context Context for the current turn of conversation with the use.
* @param force (Optional) flag that if `true` will force the call to LUIS even if a cached result exists. Defaults to a value of `false`.
*/
public recognize(context: TurnContext, force?: boolean): Promise<RecognizerResult> {
const cached = context.services.get(this.cacheKey);
if (force || !cached) {
const utterance = context.activity.text || '';
return this.luisClient.getIntentsAndEntitiesV2(this.settings.appId, this.settings.subscriptionKey, utterance, this.settings.options)
.then((luisResult: LuisResult) => {
// Map results
const recognizerResult: RecognizerResult = {
text: luisResult.query,
alteredText: luisResult.alteredQuery,
intents: this.getIntents(luisResult),
entities: this.getEntitiesAndMetadata(luisResult.entities, luisResult.compositeEntities, this.settings.verbose),
luisResult: luisResult
};
// Write to cache
context.services.set(this.cacheKey, recognizerResult);
return this.emitTraceInfo(context, luisResult, recognizerResult).then(() => {
return recognizerResult;
});
});
}
return Promise.resolve(cached);
}
/**
* Called internally to create a LuisClient instance. This is exposed to enable better unit
* testing of teh recognizer.
* @param baseUri Service endpoint being called.
*/
protected createClient(baseUri: string): LuisClient {
return new LuisClient(baseUri);
}
/**
* Returns the name of the top scoring intent from a set of LUIS results.
* @param results Result set to be searched.
* @param defaultIntent (Optional) intent name to return should a top intent be found. Defaults to a value of `None`.
* @param minScore (Optional) minimum score needed for an intent to be considered as a top intent. If all intents in the set are below this threshold then the `defaultIntent` will be returned. Defaults to a value of `0.0`.
*/
static topIntent(results: RecognizerResult | undefined, defaultIntent = 'None', minScore = 0.0): string {
let topIntent: string | undefined = undefined;
let topScore = -1;
if (results && results.intents) {
for (const name in results.intents) {
const score = results.intents[name].score;
if (typeof score === 'number' && score > topScore && score >= minScore) {
topIntent = name;
topScore = score;
}
}
}
return topIntent || defaultIntent;
}
private emitTraceInfo(context: TurnContext, luisResult: LuisResult, recognizerResult: RecognizerResult): Promise<any> {
const traceInfo: LuisTraceInfo = {
recognizerResult: recognizerResult,
luisResult: luisResult,
luisOptions: {
Staging: this.settings.options && this.settings.options.staging
},
luisModel: {
ModelID: this.settings.appId
}
}
return context.sendActivity({
type: 'trace',
valueType: LUIS_TRACE_TYPE,
name: LUIS_TRACE_NAME,
label: LUIS_TRACE_LABEL,
value: traceInfo
});
}
private normalizeName(name: string): string {
return name.replace(/\./g, "_");
}
private getIntents(luisResult: LuisResult): any {
const intents: { [name: string]: {score:number}; } = {}
if (luisResult.intents) {
luisResult.intents.reduce((prev: any, curr: Intent) => {
prev[this.normalizeName(curr.intent)] = { score: curr.score};
return prev;
}, intents);
} else {
const topScoringIntent = luisResult.topScoringIntent;
intents[this.normalizeName((topScoringIntent).intent)] = { score: topScoringIntent.score};
}
return intents;
}
private getEntitiesAndMetadata(entities: Entity[], compositeEntities: CompositeEntity[] | undefined, verbose: boolean): any {
let entitiesAndMetadata: any = verbose ? { $instance: {} } : {};
let compositeEntityTypes: string[] = [];
// We start by populating composite entities so that entities covered by them are removed from the entities list
if (compositeEntities) {
compositeEntityTypes = compositeEntities.map(compositeEntity => compositeEntity.parentType);
compositeEntities.forEach(compositeEntity => {
entities = this.populateCompositeEntity(compositeEntity, entities, entitiesAndMetadata, verbose);
});
}
entities.forEach(entity => {
// we'll address composite entities separately
if (compositeEntityTypes.indexOf(entity.type) > -1) {
return;
}
this.addProperty(entitiesAndMetadata, this.getNormalizedEntityType(entity), this.getEntityValue(entity));
if (verbose) {
this.addProperty(entitiesAndMetadata.$instance, this.getNormalizedEntityType(entity), this.getEntityMetadata(entity));
}
});
return entitiesAndMetadata;
}
private getEntityValue(entity: Entity): any {
if (!entity.resolution)
return entity.entity;
if (entity.type.startsWith("builtin.datetimeV2.")) {
if (!entity.resolution.values || !entity.resolution.values.length)
return entity.resolution;
var vals = entity.resolution.values;
var type = vals[0].type;
var timexes = vals.map(t => t.timex);
var distinct = timexes.filter((v, i, a) => a.indexOf(v) === i);
return {type: type, timex: distinct};
}
else {
var res = entity.resolution;
switch (entity.type) {
case "builtin.number":
case "builtin.ordinal": return Number(res.value);
case "builtin.percentage":
{
var svalue = res.value;
if (svalue.endsWith("%")) {
svalue = svalue.substring(0, svalue.length - 1);
}
return Number(svalue);
}
case "builtin.age":
case "builtin.dimension":
case "builtin.currency":
case "builtin.temperature":
{
var val = res.value;
var obj = { };
if (val) {
obj["number"] = Number(val);
}
obj["units"] = res.unit;
return obj;
}
default:
return Object.keys(entity.resolution).length > 1 ?
entity.resolution :
entity.resolution.value ?
entity.resolution.value :
entity.resolution.values;
}
}
}
private getEntityMetadata(entity: Entity): any {
return {
startIndex: entity.startIndex,
endIndex: entity.endIndex + 1,
text: entity.entity,
score: entity.score
};
}
private getNormalizedEntityType(entity: Entity): string {
// Type::Role -> Role
var type = entity.type.split(':').pop();
if (type.startsWith("builtin.datetimeV2.")) {
type = "builtin_datetime";
}
if (type.startsWith("builtin.currency")) {
type = "builtin_money";
}
if (entity.role != null) {
type = entity.role;
}
return type.replace(/\.|\s/g, "_");
}
private populateCompositeEntity(compositeEntity: CompositeEntity, entities: Entity[], entitiesAndMetadata: any, verbose: boolean): Entity[] {
let childrenEntites: any = verbose ? { $instance: {} } : {};
let childrenEntitiesMetadata: any = {};
// This is now implemented as O(n^2) search and can be reduced to O(2n) using a map as an optimization if n grows
let compositeEntityMetadata: Entity | undefined = entities.find(entity => {
// For now we are matching by value, which can be ambiguous if the same composite entity shows up with the same text
// multiple times within an utterance, but this is just a stop gap solution till the indices are included in composite entities
return entity.type === compositeEntity.parentType && entity.entity === compositeEntity.value
});
let filteredEntities: Entity[] = [];
if (verbose) {
childrenEntitiesMetadata = this.getEntityMetadata(compositeEntityMetadata);
}
// This is now implemented as O(n*k) search and can be reduced to O(n + k) using a map as an optimization if n or k grow
let coveredSet = new Set();
compositeEntity.children.forEach(childEntity => {
for (let i = 0; i < entities.length; i++) {
let entity = entities[i];
if (!coveredSet.has(i) &&
childEntity.type === entity.type &&
compositeEntityMetadata &&
entity.startIndex != undefined && compositeEntityMetadata.startIndex != undefined && entity.startIndex >= compositeEntityMetadata.startIndex &&
entity.endIndex != undefined && compositeEntityMetadata.endIndex != undefined && entity.endIndex <= compositeEntityMetadata.endIndex) {
// Add to the set to ensure that we don't consider the same child entity more than once per composite
coveredSet.add(i);
this.addProperty(childrenEntites, this.getNormalizedEntityType(entity), this.getEntityValue(entity));
if (verbose)
this.addProperty(childrenEntites.$instance, this.getNormalizedEntityType(entity), this.getEntityMetadata(entity));
}
};
});
// filter entities that were covered by this composite entity
for (let i = 0; i < entities.length; i++) {
if (!coveredSet.has(i))
filteredEntities.push(entities[i]);
}
this.addProperty(entitiesAndMetadata, compositeEntity.parentType, childrenEntites);
if (verbose) {
this.addProperty(entitiesAndMetadata.$instance, compositeEntity.parentType, childrenEntitiesMetadata);
}
return filteredEntities;
}
/**
* If a property doesn't exist add it to a new array, otherwise append it to the existing array
* @param obj Object on which the property is to be set
* @param key Property Key
* @param value Property Value
*/
private addProperty(obj: any, key: string, value: any) {
if (key in obj)
obj[key] = obj[key].concat(value);
else
obj[key] = [value];
}
}