• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

microsoft / botbuilder-js / 4823576493

pending completion
4823576493

Pull #4467

github

GitHub
Merge ba988a46f into 25e71468a
Pull Request #4467: feat: Add support for Teams Adaptive cards in QnA Dialog

9719 of 12709 branches covered (76.47%)

Branch coverage included in aggregate %.

17 of 17 new or added lines in 2 files covered. (100.0%)

20038 of 22418 relevant lines covered (89.38%)

3695.69 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

84.93
/libraries/botbuilder-ai/src/qnaMakerDialog.ts
1
/**
2
 * @module botbuilder-ai
3
 */
4
/**
5
 * Copyright (c) Microsoft Corporation. All rights reserved.
6
 * Licensed under the MIT License.
7
 */
8

9
import * as z from 'zod';
1✔
10
import { ActiveLearningUtils, BindToActivity } from './qnamaker-utils';
1✔
11
import { Activity, ActivityTypes, MessageFactory } from 'botbuilder-core';
1✔
12
import { QnACardBuilder } from './qnaCardBuilder';
1✔
13
import { QnAMaker, QnAMakerResult } from './';
1✔
14
import { QnAMakerClient, QnAMakerClientKey } from './qnaMaker';
1✔
15

16
import {
1✔
17
    ArrayExpression,
18
    ArrayExpressionConverter,
19
    BoolExpression,
20
    BoolExpressionConverter,
21
    EnumExpression,
22
    EnumExpressionConverter,
23
    Expression,
24
    IntExpression,
25
    IntExpressionConverter,
26
    NumberExpression,
27
    NumberExpressionConverter,
28
    StringExpression,
29
    StringExpressionConverter,
30
} from 'adaptive-expressions';
31

32
import {
1✔
33
    Converter,
34
    ConverterFactory,
35
    WaterfallDialog,
36
    Dialog,
37
    DialogConfiguration,
38
    DialogContext,
39
    DialogEvent,
40
    DialogReason,
41
    DialogStateManager,
42
    DialogTurnResult,
43
    TemplateInterface,
44
    TurnPath,
45
    WaterfallStepContext,
46
} from 'botbuilder-dialogs';
47

48
import {
1✔
49
    FeedbackRecord,
50
    FeedbackRecords,
51
    JoinOperator,
52
    QnAMakerMetadata,
53
    QnAMakerOptions,
54
    RankerTypes,
55
} from './qnamaker-interfaces';
56

57
import { Filters } from './qnamaker-interfaces/filters';
58
import { CustomQuestionAnswering } from './customQuestionAnswering';
1✔
59
import { ServiceType } from './qnamaker-interfaces/serviceType';
1✔
60

61
class QnAMakerDialogActivityConverter
62
    implements Converter<string, TemplateInterface<Partial<Activity>, DialogStateManager>> {
63
    convert(
64
        value: string | TemplateInterface<Partial<Activity>, DialogStateManager>
65
    ): TemplateInterface<Partial<Activity>, DialogStateManager> {
66
        if (typeof value === 'string') {
10!
67
            return new BindToActivity(MessageFactory.text(value) as Activity);
10✔
68
        }
69
        return value;
×
70
    }
71
}
72

73
/**
74
 * QnAMakerDialog response options.
75
 */
76
export interface QnAMakerDialogResponseOptions {
77
    /**
78
     * Title for active learning card.
79
     */
80
    activeLearningCardTitle: string;
81
    /**
82
     * Text shown for 'no match' option on active learning card.
83
     */
84
    cardNoMatchText: string;
85
    /**
86
     * Activity to be sent in the event of no answer found in KB.
87
     */
88
    noAnswer: Partial<Activity>;
89
    /**
90
     * Activity to be sent in the end that the 'no match' option is selected on active learning card.
91
     */
92
    cardNoMatchResponse: Partial<Activity>;
93

94
    /**
95
     * Indicates whether the dialog response should display only precise answers.
96
     */
97
    displayPreciseAnswerOnly: boolean;
98
}
99

100
/**
101
 * Options for QnAMakerDialog.
102
 */
103
export interface QnAMakerDialogOptions {
104
    /**
105
     * Options for QnAMaker knowledgebase.
106
     */
107
    qnaMakerOptions: QnAMakerOptions;
108
    /**
109
     * QnAMakerDialog response options.
110
     */
111
    qnaDialogResponseOptions: QnAMakerDialogResponseOptions;
112
}
113

114
export interface QnAMakerDialogConfiguration extends DialogConfiguration {
115
    knowledgeBaseId?: string | Expression | StringExpression;
116
    hostname?: string | Expression | StringExpression;
117
    endpointKey?: string | Expression | StringExpression;
118
    threshold?: number | string | Expression | NumberExpression;
119
    top?: number | string | Expression | IntExpression;
120
    noAnswer?: string | Partial<Activity> | TemplateInterface<Partial<Activity>, DialogStateManager>;
121
    activeLearningCardTitle?: string | Expression | StringExpression;
122
    cardNoMatchText?: string | Expression | StringExpression;
123
    cardNoMatchResponse?: string | Partial<Activity> | TemplateInterface<Partial<Activity>, DialogStateManager>;
124
    strictFilters?: QnAMakerMetadata[] | string | Expression | ArrayExpression<QnAMakerMetadata>;
125
    logPersonalInformation?: boolean | string | Expression | BoolExpression;
126
    isTest?: boolean;
127
    rankerType?: RankerTypes | string | Expression | EnumExpression<RankerTypes>;
128
    displayPreciseAnswerOnly?: boolean;
129
    strictFiltersJoinOperator?: JoinOperator;
130
    includeUnstructuredSources?: boolean;
131
    useTeamsAdaptiveCard?: boolean;
132
}
133

134
/**
135
 * Returns an activity with active learning suggestions.
136
 *
137
 * Important: The activity returned should relay the noMatchesText as an option to the end user.
138
 *
139
 * @param suggestionsList List of suggestions.
140
 * @param noMatchesText If this text is received by the bot during a prompt.
141
 */
142
export type QnASuggestionsActivityFactory = (suggestionsList: string[], noMatchesText: string) => Partial<Activity>;
143

144
const qnaSuggestionsActivityFactory = z.custom<QnASuggestionsActivityFactory>((val) => typeof val === 'function', {
25✔
145
    message: 'QnASuggestionsActivityFactory',
146
});
147

148
/**
149
 * A dialog that supports multi-step and adaptive-learning QnA Maker services.
150
 *
151
 * @summary
152
 * An instance of this class targets a specific QnA Maker knowledge base.
153
 * It supports knowledge bases that include follow-up prompt and active learning features.
154
 * The dialog will also present user with appropriate multi-turn prompt or active learning options.
155
 */
156
export class QnAMakerDialog extends WaterfallDialog implements QnAMakerDialogConfiguration {
1✔
157
    static $kind = 'Microsoft.QnAMakerDialog';
1✔
158

159
    // state and step value key constants
160

161
    /**
162
     * The path for storing and retrieving QnA Maker context data.
163
     *
164
     * @summary
165
     * This represents context about the current or previous call to QnA Maker.
166
     * It is stored within the current step's [WaterfallStepContext](xref:botbuilder-dialogs.WaterfallStepContext).
167
     * It supports QnA Maker's follow-up prompt and active learning features.
168
     */
169
    protected qnAContextData = 'previousContextData';
25✔
170

171
    /**
172
     * The path for storing and retrieving the previous question ID.
173
     *
174
     * @summary
175
     * This represents the QnA question ID from the previous turn.
176
     * It is stored within the current step's [WaterfallStepContext](xref:botbuilder-dialogs.WaterfallStepContext).
177
     * It supports QnA Maker's follow-up prompt and active learning features.
178
     */
179
    protected previousQnAId = 'previousQnAId';
25✔
180

181
    /**
182
     * The path for storing and retrieving the options for this instance of the dialog.
183
     *
184
     * @summary
185
     * This includes the options with which the dialog was started and options expected by the QnA Maker service.
186
     * It is stored within the current step's [WaterfallStepContext](xref:botbuilder-dialogs.WaterfallStepContext).
187
     * It supports QnA Maker and the dialog system.
188
     */
189
    protected options = 'options';
25✔
190

191
    // Dialog options parameters
192

193
    /**
194
     * The default threshold for answers returned, based on score.
195
     */
196
    protected defaultThreshold = 0.3;
25✔
197

198
    /**
199
     * The default maximum number of answers to be returned for the question.
200
     */
201
    protected defaultTopN = 3;
25✔
202

203
    private currentQuery = 'currentQuery';
25✔
204
    private qnAData = 'qnaData';
25✔
205
    private turnQnaresult = 'turnQnaresult';
25✔
206
    private defaultNoAnswer = '';
25✔
207

208
    // Card parameters
209
    private defaultCardTitle = 'Did you mean:';
25✔
210
    private defaultCardNoMatchText = 'None of the above.';
25✔
211
    private defaultCardNoMatchResponse = 'Thanks for the feedback.';
25✔
212

213
    /**
214
     * Gets or sets the QnA Maker knowledge base ID to query.
215
     */
216
    knowledgeBaseId: StringExpression;
217

218
    /**
219
     * Gets or sets the QnA Maker host URL for the knowledge base.
220
     */
221
    hostname: StringExpression;
222

223
    /**
224
     * Gets or sets the QnA Maker endpoint key to use to query the knowledge base.
225
     */
226
    endpointKey: StringExpression;
227

228
    /**
229
     * Gets or sets the threshold for answers returned, based on score.
230
     */
231
    threshold: NumberExpression = new NumberExpression(this.defaultThreshold);
25✔
232

233
    /**
234
     * Gets or sets the maximum number of answers to return from the knowledge base.
235
     */
236
    top: IntExpression = new IntExpression(this.defaultTopN);
25✔
237

238
    /**
239
     * Gets or sets the template to send to the user when QnA Maker does not find an answer.
240
     */
241
    noAnswer: TemplateInterface<Partial<Activity>, DialogStateManager> = new BindToActivity(
25✔
242
        MessageFactory.text(this.defaultNoAnswer)
243
    );
244

245
    /**
246
     * Gets or sets the card title to use when showing active learning options to the user.
247
     *
248
     * _Note: If suggestionsActivityFactory is passed in, this member is unused._
249
     */
250
    activeLearningCardTitle: StringExpression;
251

252
    /**
253
     * Gets or sets the button text to use with active learning options, allowing a user to
254
     * indicate non of the options are applicable.
255
     *
256
     * _Note: If suggestionsActivityFactory is passed in, this member is required._
257
     */
258
    cardNoMatchText: StringExpression;
259

260
    /**
261
     * Gets or sets the template to send to the user if they select the no match option on an
262
     * active learning card.
263
     */
264
    cardNoMatchResponse: TemplateInterface<Partial<Activity>, DialogStateManager> = new BindToActivity(
25✔
265
        MessageFactory.text(this.defaultCardNoMatchResponse)
266
    );
267

268
    /**
269
     * Gets or sets the QnA Maker metadata with which to filter or boost queries to the knowledge base,
270
     * or null to apply none.
271
     */
272
    strictFilters: QnAMakerMetadata[];
273

274
    /**
275
     * Gets or sets the flag to determine if personal information should be logged in telemetry.
276
     *
277
     * @summary
278
     * Defaults to a value of `=settings.telemetry.logPersonalInformation`, which retrieves
279
     * `logPersonalInformation` flag from settings.
280
     */
281
    logPersonalInformation = new BoolExpression('=settings.runtimeSettings.telemetry.logPersonalInformation');
25✔
282

283
    /**
284
     * Gets or sets a value indicating whether gets or sets environment of knowledgebase to be called.
285
     */
286
    isTest = false;
25✔
287

288
    /**
289
     * Gets or sets the QnA Maker ranker type to use.
290
     */
291
    rankerType: EnumExpression<RankerTypes> = new EnumExpression(RankerTypes.default);
25✔
292

293
    /**
294
     * Gets or sets a value indicating whether to include precise answer in response.
295
     */
296
    enablePreciseAnswer = true;
25✔
297

298
    /**
299
     * Gets or sets a value indicating whether the dialog response should display only precise answers.
300
     */
301
    displayPreciseAnswerOnly = false;
25✔
302

303
    /**
304
     * Question answering service type - qnaMaker or language
305
     */
306
    qnaServiceType = ServiceType.qnaMaker;
25✔
307

308
    /**
309
     * Gets or sets a value - AND or OR - logical operation on list of metadata
310
     */
311
    strictFiltersJoinOperator: JoinOperator;
312

313
    /**
314
     * Gets or sets the metadata and sources used to filter results.
315
     */
316
    filters: Filters;
317

318
    /**
319
     * Gets or sets a value indicating whether to include unstructured sources in search for answers.
320
     */
321
    includeUnstructuredSources = true;
25✔
322

323
    /**
324
     * Gets or sets a value indicating whether to use a Teams-formatted Adaptive Card in responses instead of a generic Hero Card.
325
     */
326
    useTeamsAdaptiveCard = false;
25✔
327

328
    // TODO: Add Expressions support
329
    private suggestionsActivityFactory?: QnASuggestionsActivityFactory;
330

331
    private normalizedHost: string;
332

333
    /**
334
     * Initializes a new instance of the [QnAMakerDialog](xref:QnAMakerDialog) class.
335
     *
336
     * @param {string} knowledgeBaseId The ID of the QnA Maker knowledge base to query.
337
     * @param {string} endpointKey The QnA Maker endpoint key to use to query the knowledge base.
338
     * @param {string} hostname The QnA Maker host URL for the knowledge base, starting with "https://" and ending with "/qnamaker".
339
     * @param {string} noAnswer (Optional) The activity to send the user when QnA Maker does not find an answer.
340
     * @param {number} threshold (Optional) The threshold above which to treat answers found from the knowledgebase as a match.
341
     * @param {string} activeLearningCardTitle (Optional) The card title to use when showing active learning options to the user, if active learning is enabled.
342
     * @param {string} cardNoMatchText (Optional) The button text to use with active learning options, allowing a user to indicate none of the options are applicable.
343
     * @param {number} top (Optional) Maximum number of answers to return from the knowledge base.
344
     * @param {Activity} cardNoMatchResponse (Optional) The activity to send the user if they select the no match option on an active learning card.
345
     * @param {QnAMakerMetadata[]} strictFilters (Optional) QnA Maker metadata with which to filter or boost queries to the knowledge base; or null to apply none.
346
     * @param {string} dialogId (Optional) Id of the created dialog. Default is 'QnAMakerDialog'.
347
     * @param {string} strictFiltersJoinOperator join operator for strict filters
348
     * @param {string} useTeamsAdaptiveCard boolean setting for using Teams Adaptive Cards instead of Hero Cards
349
     */
350
    constructor(
351
        knowledgeBaseId?: string,
352
        endpointKey?: string,
353
        hostname?: string,
354
        noAnswer?: Activity,
355
        threshold?: number,
356
        activeLearningCardTitle?: string,
357
        cardNoMatchText?: string,
358
        top?: number,
359
        cardNoMatchResponse?: Activity,
360
        rankerType?: RankerTypes,
361
        strictFilters?: QnAMakerMetadata[],
362
        dialogId?: string,
363
        strictFiltersJoinOperator?: JoinOperator,
364
        enablePreciseAnswer?: boolean,
365
        displayPreciseAnswerOnly?: boolean,
366
        qnaServiceType?: ServiceType,
367
        useTeamsAdaptiveCard?: boolean
368
    );
369

370
    /**
371
     * Initializes a new instance of the [QnAMakerDialog](xref:QnAMakerDialog) class.
372
     *
373
     * @param {string} knowledgeBaseId The ID of the QnA Maker knowledge base to query.
374
     * @param {string} endpointKey The QnA Maker endpoint key to use to query the knowledge base.
375
     * @param {string} hostname The QnA Maker host URL for the knowledge base, starting with "https://" and ending with "/qnamaker".
376
     * @param {string} noAnswer (Optional) The activity to send the user when QnA Maker does not find an answer.
377
     * @param {number} threshold (Optional) The threshold above which to treat answers found from the knowledgebase as a match.
378
     * @param {Function} suggestionsActivityFactory [QnASuggestionsActivityFactory](xref:botbuilder-ai.QnASuggestionsActivityFactory) used for custom Activity formatting.
379
     * @param {string} cardNoMatchText The text to use with the active learning options, allowing a user to indicate none of the options are applicable.
380
     * @param {number} top (Optional) Maximum number of answers to return from the knowledge base.
381
     * @param {Activity} cardNoMatchResponse (Optional) The activity to send the user if they select the no match option on an active learning card.
382
     * @param {QnAMakerMetadata[]} strictFilters (Optional) QnA Maker metadata with which to filter or boost queries to the knowledge base; or null to apply none.
383
     * @param {string} dialogId (Optional) Id of the created dialog. Default is 'QnAMakerDialog'.
384
     * @param {string} strictFiltersJoinOperator join operator for strict filters
385
     * @param {string} useTeamsAdaptiveCard boolean setting for using Teams Adaptive Cards instead of Hero Cards
386
     */
387
    constructor(
388
        knowledgeBaseId?: string,
389
        endpointKey?: string,
390
        hostname?: string,
391
        noAnswer?: Activity,
392
        threshold?: number,
393
        suggestionsActivityFactory?: QnASuggestionsActivityFactory,
394
        cardNoMatchText?: string,
395
        top?: number,
396
        cardNoMatchResponse?: Activity,
397
        rankerType?: RankerTypes,
398
        strictFilters?: QnAMakerMetadata[],
399
        dialogId?: string,
400
        strictFiltersJoinOperator?: JoinOperator,
401
        enablePreciseAnswer?: boolean,
402
        displayPreciseAnswerOnly?: boolean,
403
        qnaServiceType?: ServiceType,
404
        useTeamsAdaptiveCard?: boolean
405
    );
406

407
    /**
408
     * @internal
409
     */
410
    constructor(
411
        knowledgeBaseId?: string,
412
        endpointKey?: string,
413
        hostname?: string,
414
        noAnswer?: Activity,
415
        threshold?: number,
416
        activeLearningTitleOrFactory?: string | QnASuggestionsActivityFactory,
417
        cardNoMatchText?: string,
418
        top?: number,
419
        cardNoMatchResponse?: Activity,
420
        rankerType?: RankerTypes,
421
        strictFilters?: QnAMakerMetadata[],
422
        dialogId = 'QnAMakerDialog',
25✔
423
        // TODO: Should member exist in QnAMakerDialogConfiguration?
424
        //       And be of type `string | JoinOperator | Expression | EnumExpression<JoinOperator>`?
425
        strictFiltersJoinOperator?: JoinOperator,
426
        enablePreciseAnswer?: boolean,
427
        displayPreciseAnswerOnly?: boolean,
428
        qnaServiceType?: ServiceType,
429
        useTeamsAdaptiveCard?: boolean
430
    ) {
431
        super(dialogId);
25✔
432
        if (knowledgeBaseId) {
25✔
433
            this.knowledgeBaseId = new StringExpression(knowledgeBaseId);
15✔
434
        }
435

436
        if (endpointKey) {
25✔
437
            this.endpointKey = new StringExpression(endpointKey);
15✔
438
        }
439

440
        if (hostname) {
25✔
441
            this.hostname = new StringExpression(hostname);
15✔
442
        }
443

444
        if (threshold) {
25!
445
            this.threshold = new NumberExpression(threshold);
×
446
        }
447

448
        if (top) {
25!
449
            this.top = new IntExpression(top);
×
450
        }
451

452
        if (qnaSuggestionsActivityFactory.check(activeLearningTitleOrFactory)) {
25✔
453
            if (!cardNoMatchText) {
3✔
454
                // Without a developer-provided cardNoMatchText, the end user will not be able to tell the convey to the bot and QnA Maker that the suggested alternative questions were not correct.
455
                // When the user's reply to a suggested alternatives Activity matches the cardNoMatchText, the QnAMakerDialog sends this information to the QnA Maker service for active learning.
456
                throw new Error('cardNoMatchText is required when using the suggestionsActivityFactory.');
1✔
457
            }
458

459
            this.suggestionsActivityFactory = activeLearningTitleOrFactory;
2✔
460
        } else {
461
            this.activeLearningCardTitle = new StringExpression(activeLearningTitleOrFactory ?? this.defaultCardTitle);
22✔
462
        }
463

464
        if (cardNoMatchText) {
24✔
465
            this.cardNoMatchText = new StringExpression(cardNoMatchText);
4✔
466
        }
467

468
        if (strictFilters) {
24!
469
            this.strictFilters = strictFilters;
×
470
        }
471

472
        if (strictFiltersJoinOperator) {
24!
473
            this.strictFiltersJoinOperator = strictFiltersJoinOperator;
×
474
        }
475

476
        if (noAnswer) {
24!
477
            this.noAnswer = new BindToActivity(noAnswer);
×
478
        }
479

480
        this.cardNoMatchResponse = new BindToActivity(
24✔
481
            cardNoMatchResponse ?? MessageFactory.text(this.defaultCardNoMatchResponse)
72!
482
        );
483

484
        if (enablePreciseAnswer != undefined) {
24!
485
            this.enablePreciseAnswer = enablePreciseAnswer;
×
486
        }
487

488
        if (displayPreciseAnswerOnly != undefined) {
24!
489
            this.displayPreciseAnswerOnly = displayPreciseAnswerOnly;
×
490
        }
491
        if (rankerType != undefined) {
24!
492
            this.rankerType = new EnumExpression(rankerType);
×
493
        }
494
        this.qnaServiceType = qnaServiceType;
24✔
495

496
        if(useTeamsAdaptiveCard) {
24!
497
            this.useTeamsAdaptiveCard = useTeamsAdaptiveCard;
×
498
        }
499

500
        this.addStep(this.callGenerateAnswer.bind(this));
24✔
501
        this.addStep(this.callTrain.bind(this));
24✔
502
        this.addStep(this.checkForMultiTurnPrompt.bind(this));
24✔
503
        this.addStep(this.displayQnAResult.bind(this));
24✔
504
    }
505

506
    /**
507
     * @param property Properties that extend QnAMakerDialogConfiguration.
508
     * @returns The expression converter.
509
     */
510
    getConverter(property: keyof QnAMakerDialogConfiguration): Converter | ConverterFactory {
511
        switch (property) {
78✔
512
            case 'knowledgeBaseId':
78!
513
                return new StringExpressionConverter();
10✔
514
            case 'hostname':
515
                return new StringExpressionConverter();
10✔
516
            case 'endpointKey':
517
                return new StringExpressionConverter();
10✔
518
            case 'threshold':
519
                return new NumberExpressionConverter();
×
520
            case 'top':
521
                return new IntExpressionConverter();
9✔
522
            case 'noAnswer':
523
                return new QnAMakerDialogActivityConverter();
10✔
524
            case 'activeLearningCardTitle':
525
                return new StringExpressionConverter();
9✔
526
            case 'cardNoMatchText':
527
                return new StringExpressionConverter();
10✔
528
            case 'cardNoMatchResponse':
529
                return new QnAMakerDialogActivityConverter();
×
530
            case 'strictFilters':
531
                return new ArrayExpressionConverter();
×
532
            case 'logPersonalInformation':
533
                return new BoolExpressionConverter();
×
534
            case 'rankerType':
535
                return new EnumExpressionConverter(RankerTypes);
×
536
            case 'displayPreciseAnswerOnly':
537
                return new BoolExpressionConverter();
×
538
            default:
539
                return super.getConverter(property);
10✔
540
        }
541
    }
542

543
    /**
544
     * Called when the dialog is started and pushed onto the dialog stack.
545
     *
546
     * @summary
547
     * If the task is successful, the result indicates whether the dialog is still
548
     * active after the turn has been processed by the dialog.
549
     *
550
     * You can use the [options](#options) parameter to include the QnA Maker context data,
551
     * which represents context from the previous query. To do so, the value should include a
552
     * `context` property of type [QnAResponseContext](#QnAResponseContext).
553
     *
554
     * @param {DialogContext} dc The [DialogContext](xref:botbuilder-dialogs.DialogContext) for the current turn of conversation.
555
     * @param {object} options (Optional) Initial information to pass to the dialog.
556
     * @returns {Promise<DialogTurnResult>} A promise resolving to the turn result
557
     */
558
    // eslint-disable-next-line @typescript-eslint/ban-types
559
    async beginDialog(dc: DialogContext, options?: object): Promise<DialogTurnResult> {
560
        if (!dc) {
17!
561
            throw new Error('Missing DialogContext');
×
562
        }
563

564
        if (dc.context?.activity?.type !== ActivityTypes.Message) {
17!
565
            return dc.endDialog();
×
566
        }
567

568
        const dialogOptions: QnAMakerDialogOptions = {
17✔
569
            qnaDialogResponseOptions: await this.getQnAResponseOptions(dc),
570
            qnaMakerOptions: await this.getQnAMakerOptions(dc),
571
        };
572

573
        if (options) {
17!
574
            Object.assign(dialogOptions, options);
×
575
        }
576

577
        return super.beginDialog(dc, dialogOptions);
17✔
578
    }
579

580
    /**
581
     * Called when the dialog is _continued_, where it is the active dialog and the
582
     * user replies with a new [Activity](xref:botframework-schema.Activity).
583
     *
584
     * @param {DialogContext} dc The [DialogContext](xref:botbuilder-dialogs.DialogContext) for the current turn of conversation.
585
     * @returns {DialogContext} A Promise representing the asynchronous operation.
586
     */
587
    continueDialog(dc: DialogContext): Promise<DialogTurnResult> {
588
        const interrupted = dc.state.getValue<boolean>(TurnPath.interrupted, false);
7✔
589
        if (interrupted) {
7!
590
            // if qnamaker was interrupted then end the qnamaker dialog
591
            return dc.endDialog();
×
592
        }
593

594
        return super.continueDialog(dc);
7✔
595
    }
596

597
    /**
598
     * Called before an event is bubbled to its parent.
599
     *
600
     * @param {DialogContext} dc The dialog context for the current turn of conversation.
601
     * @param {DialogEvent} e The event being raised.
602
     * @returns {Promise<boolean>} Whether the event is handled by the current dialog and further processing should stop.
603
     */
604
    protected async onPreBubbleEvent(dc: DialogContext, e: DialogEvent): Promise<boolean> {
605
        // When the DialogEvent.name is 'error', it's possible to end in a loop where events
606
        // keep firing if the error is encountered while trying to call QnA Maker.
607
        // If an error is encountered, forward it to this dialog's parent.
608
        if (e.name === 'error') {
9✔
609
            return this.onPostBubbleEvent(dc, e);
2✔
610
        } else if (dc.context?.activity?.type === ActivityTypes.Message) {
7!
611
            // decide whether we want to allow interruption or not.
612
            // if we don't get a response from QnA which signifies we expect it,
613
            // then we allow interruption.
614

615
            const reply = dc.context.activity.text;
7✔
616
            const dialogOptions: QnAMakerDialogOptions = dc.activeDialog.state[this.options];
7✔
617

618
            if (reply.toLowerCase() === dialogOptions.qnaDialogResponseOptions.cardNoMatchText.toLowerCase()) {
7✔
619
                // it matches nomatch text, we like that.
620
                return true;
2✔
621
            }
622

623
            const suggestedQuestions = dc.state.getValue<string[]>('this.suggestedQuestions');
5✔
624
            if (suggestedQuestions) {
5✔
625
                if (suggestedQuestions?.some((question) => question.toLowerCase() === reply.toLowerCase().trim())) {
4!
626
                    // it matches one of the suggested actions, we like that.
627
                    return true;
1✔
628
                }
629

630
                // Calling QnAMaker to get response.
631
                const qnaClient = await this.getQnAMakerClient(dc);
1✔
632
                this.resetOptions(dc, dialogOptions);
1✔
633
                const response = await qnaClient.getAnswersRaw(dc.context, dialogOptions.qnaMakerOptions);
1✔
634
                // disable interruption if we have answers.
635
                return response.answers?.length > 0 ?? false;
1!
636
            }
637
        }
638

639
        // call base for default behavior.
640
        return this.onPostBubbleEvent(dc, e);
3✔
641
    }
642

643
    /**
644
     * Gets an [QnAMakerClient](xref:botbuilder-ai.QnAMakerClient) to use to access the QnA Maker knowledge base.
645
     *
646
     * @param {DialogContext} dc The dialog context for the current turn of conversation.
647
     * @returns {Promise<QnAMakerClient>} A promise of QnA Maker instance.
648
     */
649
    protected async getQnAMakerClient(dc: DialogContext): Promise<QnAMakerClient> {
650
        const qnaClient = dc.context?.turnState?.get<QnAMakerClient>(QnAMakerClientKey);
30✔
651
        if (qnaClient) {
30✔
652
            return qnaClient;
12✔
653
        }
654

655
        const endpoint = {
18✔
656
            knowledgeBaseId: this.knowledgeBaseId.getValue(dc.state),
657
            endpointKey: this.endpointKey.getValue(dc.state),
658
            host: this.qnaServiceType === ServiceType.language ? this.hostname.getValue(dc.state) : this.getHost(dc),
18✔
659
            qnaServiceType: this.qnaServiceType,
660
        };
661

662
        const logPersonalInformation =
663
            this.logPersonalInformation instanceof BoolExpression
18✔
664
                ? this.logPersonalInformation.getValue(dc.state)
18!
665
                : this.logPersonalInformation;
666
        if (endpoint.qnaServiceType === ServiceType.language) {
18✔
667
            return new CustomQuestionAnswering(
1✔
668
                endpoint,
669
                await this.getQnAMakerOptions(dc),
670
                this.telemetryClient,
671
                logPersonalInformation
672
            );
673
        } else {
674
            return new QnAMaker(
17✔
675
                endpoint,
676
                await this.getQnAMakerOptions(dc),
677
                this.telemetryClient,
678
                logPersonalInformation
679
            );
680
        }
681
    }
682

683
    /**
684
     * Gets the options for the QnA Maker client that the dialog will use to query the knowledge base.
685
     *
686
     * @param {DialogContext} dc The dialog context for the current turn of conversation.
687
     * @returns {Promise<QnAMakerOptions>} A promise of QnA Maker options to use.
688
     */
689
    protected async getQnAMakerOptions(dc: DialogContext): Promise<QnAMakerOptions> {
690
        return {
35✔
691
            scoreThreshold: this.threshold?.getValue(dc.state) ?? this.defaultThreshold,
210!
692
            strictFilters: this.strictFilters,
693
            filters: this.filters,
694
            top: this.top?.getValue(dc.state) ?? this.defaultTopN,
210!
695
            qnaId: 0,
696
            rankerType: this.rankerType?.getValue(dc.state) ?? RankerTypes.default,
210!
697
            isTest: this.isTest,
698
            strictFiltersJoinOperator: this.strictFiltersJoinOperator,
699
            enablePreciseAnswer: this.enablePreciseAnswer,
700
            includeUnstructuredSources: this.includeUnstructuredSources,
701
        };
702
    }
703

704
    /**
705
     * Gets the options the dialog will use to display query results to the user.
706
     *
707
     * @param {DialogContext} dc The dialog context for the current turn of conversation.
708
     * @returns {Promise<QnAMakerDialogResponseOptions>} A promise of QnA Maker response options to use.
709
     */
710
    protected async getQnAResponseOptions(dc: DialogContext): Promise<QnAMakerDialogResponseOptions> {
711
        return {
17✔
712
            activeLearningCardTitle: this.activeLearningCardTitle?.getValue(dc?.state) ?? this.defaultCardTitle,
147!
713
            cardNoMatchResponse: this.cardNoMatchResponse && (await this.cardNoMatchResponse.bind(dc, dc?.state)),
85!
714
            cardNoMatchText: this.cardNoMatchText?.getValue(dc?.state) ?? this.defaultCardNoMatchText,
147!
715
            noAnswer: await this.noAnswer?.bind(dc, dc?.state),
102!
716
            displayPreciseAnswerOnly: this.displayPreciseAnswerOnly,
717
        };
718
    }
719

720
    /**
721
     * Displays an appropriate response based on the incoming result to the user. If an answer has been identified it
722
     * is sent to the user. Alternatively, if no answer has been identified or the user has indicated 'no match' on an
723
     * active learning card, then an appropriate message is sent to the user.
724
     *
725
     * @param {WaterfallStepContext} step the waterfall step context
726
     * @returns {Promise<DialogTurnResult>} a promise resolving to the dialog turn result
727
     **/
728
    protected async displayQnAResult(step: WaterfallStepContext): Promise<DialogTurnResult> {
729
        const dialogOptions: QnAMakerDialogOptions = step.activeDialog.state[this.options];
11✔
730
        const reply = step.context.activity.text;
11✔
731

732
        if (reply === dialogOptions.qnaDialogResponseOptions.cardNoMatchText) {
11✔
733
            const activity = dialogOptions.qnaDialogResponseOptions.cardNoMatchResponse;
1✔
734
            await step.context.sendActivity(activity ?? this.defaultCardNoMatchResponse);
1!
735
            return step.endDialog();
1✔
736
        }
737

738
        const previousQnaId = step.activeDialog.state[this.previousQnAId];
10✔
739
        if (previousQnaId > 0) {
10✔
740
            return super.runStep(step, 0, DialogReason.beginCalled);
1✔
741
        }
742

743
        // display QnA Result will be the next step for all button clicks
744
        // step.result will be the question chosen by the bot user
745
        // unlike the usual flow where step.result contains getAnswersRaw response.answers.
746
        const response: QnAMakerResult[] =
747
            typeof step.result === 'string'
9✔
748
                ? step.values[this.turnQnaresult]?.filter((ans) => ans.questions[0] === step.result)
×
749
                : step.result;
750
        if (response?.length > 0) {
9!
751
            const activity = dialogOptions.qnaDialogResponseOptions.noAnswer;
8✔
752
            if (response[0].id !== -1) {
8✔
753
                const message = QnACardBuilder.getQnAAnswerCard(response[0], this.displayPreciseAnswerOnly, this.useTeamsAdaptiveCard);
7✔
754
                await step.context.sendActivity(message);
7✔
755
            } else {
756
                if (activity && activity.text) {
1!
757
                    await step.context.sendActivity(activity);
1✔
758
                } else {
759
                    const message = QnACardBuilder.getQnAAnswerCard(response[0], this.displayPreciseAnswerOnly, this.useTeamsAdaptiveCard);
×
760
                    await step.context.sendActivity(message);
×
761
                }
762
            }
763
        } else {
764
            await step.context.sendActivity('No QnAMaker answers found.');
1✔
765
        }
766

767
        return step.endDialog(step.result);
9✔
768
    }
769

770
    private resetOptions(dc: DialogContext, dialogOptions: QnAMakerDialogOptions) {
771
        // Resetting QnAId if not present in value
772
        const qnaIdFromContext = dc.context.activity.value;
20✔
773

774
        if (qnaIdFromContext != null) {
20!
775
            dialogOptions.qnaMakerOptions.qnaId = qnaIdFromContext;
×
776
        } else {
777
            dialogOptions.qnaMakerOptions.qnaId = 0;
20✔
778
        }
779

780
        // Resetting context
781
        dialogOptions.qnaMakerOptions.context = { previousQnAId: 0, previousUserQuery: '' };
20✔
782

783
        // Check if previous context is present, if yes then put it with the query
784
        // Check for id if query is present in reverse index.
785
        const previousContextData: Record<string, number> = dc.activeDialog.state[this.qnAContextData] ?? {};
20✔
786
        const previousQnAId = dc.activeDialog.state[this.previousQnAId] ?? 0;
20✔
787

788
        if (previousQnAId > 0) {
20✔
789
            dialogOptions.qnaMakerOptions.context = {
1✔
790
                previousQnAId,
791
                previousUserQuery: '',
792
            };
793

794
            const currentQnAId = previousContextData[dc.context.activity.text];
1✔
795
            if (currentQnAId) {
1!
796
                dialogOptions.qnaMakerOptions.qnaId = currentQnAId;
1✔
797
            }
798
        }
799
    }
800

801
    // Queries the knowledgebase and either passes result to the next step or constructs and displays an active learning card
802
    // if active learning is enabled and multiple score close answers are returned.
803
    private async callGenerateAnswer(step: WaterfallStepContext): Promise<DialogTurnResult> {
804
        // clear suggestedQuestions between turns.
805
        step.state.deleteValue('this.suggestedQuestions');
19✔
806

807
        const dialogOptions: QnAMakerDialogOptions = step.activeDialog.state[this.options];
19✔
808
        this.resetOptions(step, dialogOptions);
19✔
809

810
        step.values[this.currentQuery] = step.context.activity.text;
19✔
811
        const previousContextData: { [key: string]: number } = step.activeDialog.state[this.qnAContextData] || {};
19✔
812
        let previousQnAId = step.activeDialog.state[this.previousQnAId] || 0;
19✔
813

814
        if (previousQnAId > 0) {
19✔
815
            dialogOptions.qnaMakerOptions.context = { previousQnAId, previousUserQuery: '' };
1✔
816

817
            if (previousContextData[step.context.activity.text]) {
1!
818
                dialogOptions.qnaMakerOptions.qnaId = previousContextData[step.context.activity.text];
1✔
819
            }
820
        }
821

822
        const qna = await this.getQnAMakerClient(step);
19✔
823
        const response = await qna.getAnswersRaw(step.context, dialogOptions.qnaMakerOptions);
19✔
824

825
        const qnaResponse = {
19✔
826
            activeLearningEnabled: response.activeLearningEnabled,
827
            answers: response.answers,
828
        };
829

830
        previousQnAId = -1;
19✔
831
        step.activeDialog.state[this.previousQnAId] = previousQnAId;
19✔
832
        const isActiveLearningEnabled = qnaResponse.activeLearningEnabled;
19✔
833

834
        step.values[this.qnAData] = response.answers;
19✔
835

836
        if (
19✔
837
            qnaResponse.answers.length > 0 &&
37✔
838
            qnaResponse.answers[0].score <= ActiveLearningUtils.MaximumScoreForLowScoreVariation / 100
839
        ) {
840
            qnaResponse.answers = qna.getLowScoreVariation(qnaResponse.answers);
10✔
841

842
            if (isActiveLearningEnabled && qnaResponse.answers?.length > 1) {
10!
843
                // filter answers from structured documents only which has corresponsing questions that can be shown for disambiguation in suggestions card
844
                const suggestedQuestions = qnaResponse.answers
7✔
845
                    .filter((answer) => !!answer.questions && answer.questions.length > 0)
21✔
846
                    .map((ans) => ans.questions[0]);
21✔
847

848
                if (suggestedQuestions?.length > 0) {
7!
849
                    const message =
7✔
850
                        this.suggestionsActivityFactory?.(
7✔
851
                            suggestedQuestions,
852
                            dialogOptions.qnaDialogResponseOptions.cardNoMatchText
7✔
853
                        ) ??
854
                        QnACardBuilder.getSuggestionsCard(
855
                            suggestedQuestions,
856
                            dialogOptions.qnaDialogResponseOptions.activeLearningCardTitle,
857
                            dialogOptions.qnaDialogResponseOptions.cardNoMatchText
858
                        );
859

860
                    z.record(z.unknown()).parse(message, { path: ['message'] });
7✔
861
                    await step.context.sendActivity(message);
5✔
862

863
                    step.activeDialog.state[this.options] = dialogOptions;
5✔
864
                    step.state.setValue('this.suggestedQuestions', suggestedQuestions);
5✔
865
                    step.values[this.turnQnaresult] = qnaResponse.answers;
5✔
866
                    return Dialog.EndOfTurn;
5✔
867
                } else {
868
                    step.values[this.turnQnaresult] = [];
×
869
                }
870
            }
871
        }
872

873
        const result: QnAMakerResult[] = [];
12✔
874

875
        if (response.answers?.length > 0) {
12!
876
            result.push(response.answers[0]);
11✔
877
        }
878

879
        step.values[this.qnAData] = result;
12✔
880
        step.activeDialog.state[this.options] = dialogOptions;
12✔
881
        return step.next(result);
12✔
882
    }
883

884
    // If active learning options were displayed in the previous step and the user has selected an option other
885
    // than 'no match' then the training API is called, passing the user's chosen question back to the knowledgebase.
886
    // If no active learning options were displayed in the previous step, the incoming result is immediately passed to the next step.
887
    private async callTrain(step: WaterfallStepContext): Promise<DialogTurnResult> {
888
        const dialogOptions: QnAMakerDialogOptions = step.activeDialog.state[this.options];
15✔
889
        const trainResponses: QnAMakerResult[] = step.values[this.turnQnaresult];
15✔
890
        const currentQuery: string = step.values[this.currentQuery];
15✔
891

892
        const reply = step.context.activity.text;
15✔
893

894
        if (trainResponses?.length > 1) {
15✔
895
            const qnaResult = trainResponses.filter((r) => r.questions[0] === reply);
9✔
896

897
            if (qnaResult?.length > 0) {
3!
898
                const results: QnAMakerResult[] = [];
1✔
899
                results.push(qnaResult[0]);
1✔
900
                step.values[this.qnAData] = results;
1✔
901

902
                const records: FeedbackRecord[] = [];
1✔
903
                records.push({
1✔
904
                    userId: step.context.activity.id,
905
                    userQuestion: currentQuery,
906
                    qnaId: qnaResult[0].id.toString(),
907
                });
908

909
                const feedbackRecords: FeedbackRecords = { feedbackRecords: records };
1✔
910

911
                const qnaClient = await this.getQnAMakerClient(step);
1✔
912
                await qnaClient.callTrain(feedbackRecords);
1✔
913

914
                return step.next(qnaResult);
1✔
915
            } else if (reply === dialogOptions.qnaDialogResponseOptions.cardNoMatchText) {
2✔
916
                const activity = dialogOptions.qnaDialogResponseOptions.cardNoMatchResponse;
1✔
917
                await step.context.sendActivity(activity || this.defaultCardNoMatchResponse);
1!
918
                return step.endDialog();
1✔
919
            } else {
920
                // clear turnQnAResult when no button is clicked
921
                step.values[this.turnQnaresult] = [];
1✔
922
                return super.runStep(step, 0, DialogReason.beginCalled);
1✔
923
            }
924
        }
925

926
        return step.next(step.result);
12✔
927
    }
928

929
    // If multi turn prompts are included with the answer returned from the knowledgebase, this step constructs
930
    // and sends an activity with a hero card displaying the answer and the multi turn prompt options.
931
    // If no multi turn prompts exist then the result incoming result is passed to the next step.
932
    private async checkForMultiTurnPrompt(step: WaterfallStepContext): Promise<DialogTurnResult> {
933
        const dialogOptions: QnAMakerDialogOptions = step.activeDialog.state[this.options];
13✔
934
        const response: QnAMakerResult[] = step.result;
13✔
935

936
        if (response?.length > 0 && response[0].id != -1) {
13!
937
            const answer = response[0];
11✔
938
            if (answer?.context?.prompts?.length > 0) {
11!
939
                const previousContextData: { [key: string]: number } = {};
4✔
940

941
                answer.context.prompts.forEach((prompt) => {
4✔
942
                    previousContextData[prompt.displayText] = prompt.qnaId;
12✔
943
                });
944

945
                step.activeDialog.state[this.qnAContextData] = previousContextData;
4✔
946
                step.activeDialog.state[this.previousQnAId] = answer.id;
4✔
947
                step.activeDialog.state[this.options] = dialogOptions;
4✔
948
                const message = QnACardBuilder.getQnAAnswerCard(answer, this.displayPreciseAnswerOnly, this.useTeamsAdaptiveCard);
4✔
949
                await step.context.sendActivity(message);
4✔
950
                return Dialog.EndOfTurn;
4✔
951
            }
952
        }
953

954
        return step.next(step.result);
9✔
955
    }
956

957
    // Gets unmodified v5 API hostName or constructs v4 API hostName
958
    //
959
    // Example of a complete v5 API endpoint: "https://qnamaker-acom.azure.com/qnamaker/v5.0"
960
    //
961
    // Template literal to construct v4 API endpoint: `https://${ this.hostName }.azurewebsites.net/qnamaker`
962
    private getHost(dc: DialogContext): string {
963
        let host = this.hostname.getValue(dc.state);
17✔
964

965
        // Return the memoized host, but allow it to change at runtime.
966
        if (this.normalizedHost && this.normalizedHost.includes(host)) {
17✔
967
            return this.normalizedHost;
5✔
968
        }
969

970
        // Handle no protocol.
971
        if (!/^https?:\/\//.test(host)) {
12✔
972
            host = 'https://' + host;
8✔
973
        }
974

975
        // Handle no domain.
976
        if (!host.includes('.')) {
12✔
977
            host = host + '.azurewebsites.net';
7✔
978
        }
979

980
        // Handle no pathname, only for azurewebsites.net domains.
981
        if (!host.includes('/qnamaker') && host.includes('azurewebsites.net')) {
12✔
982
            host = host + '/qnamaker';
7✔
983
        }
984

985
        // Memoize the host.
986
        this.normalizedHost = host;
12✔
987

988
        return host;
12✔
989
    }
990
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc