Skip to content

Commit

Permalink
DO NOT MERGE [JS] feat: custom feedback form + citation changes (#2182)
Browse files Browse the repository at this point in the history
## Linked issues

closes: #2167 

## Details

update types/handlers for new custom feedback loop and citations changes
for ignite.
  • Loading branch information
aacebo authored Dec 5, 2024
1 parent d7446e2 commit eb63a75
Show file tree
Hide file tree
Showing 9 changed files with 248 additions and 23 deletions.
17 changes: 16 additions & 1 deletion js/packages/teams-ai/src/AI.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,12 @@ export interface AIOptions<TState extends TurnState> {
* https://github.com/microsoft/teams-ai/blob/main/getting-started/CONCEPTS/POWERED-BY-AI.md
*/
enable_feedback_loop?: boolean;

/**
* Optional. Only used when `enable_feedback_loop` == `true`. When set to `custom` the user will be presented with a text input
* to provide feedback.
*/
feedback_loop_type?: 'default' | 'custom';
}

/**
Expand Down Expand Up @@ -104,6 +110,12 @@ export interface ConfiguredAIOptions<TState extends TurnState> {
* If true, the AI system will enable the feedback loop in Teams that allows a user to give thumbs up or down to a response.
*/
enable_feedback_loop: boolean;

/**
* Optional. Only used when `enable_feedback_loop` == `true`. When set to `custom` the user will be presented with a text input
* to provide feedback.
*/
feedback_loop_type?: 'default' | 'custom';
}

/**
Expand Down Expand Up @@ -225,8 +237,11 @@ export class AI<TState extends TurnState = TurnState> {
this.defaultAction(AI.HttpErrorActionName, actions.httpError());
this.defaultAction(AI.PlanReadyActionName, actions.planReady());
this.defaultAction(AI.DoCommandActionName, actions.doCommand());
this.defaultAction(AI.SayCommandActionName, actions.sayCommand(this._options.enable_feedback_loop));
this.defaultAction(AI.TooManyStepsActionName, actions.tooManySteps());
this.defaultAction(AI.SayCommandActionName, actions.sayCommand(
this._options.enable_feedback_loop,
this._options.feedback_loop_type || 'default'
));
}

/**
Expand Down
17 changes: 15 additions & 2 deletions js/packages/teams-ai/src/Application.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import { ReadReceiptInfo } from 'botframework-connector';
import { AdaptiveCards, AdaptiveCardsOptions } from './AdaptiveCards';
import { AI, AIOptions } from './AI';
import { Meetings } from './Meetings';
import { Messages } from './Messages';
import { MessageExtensions } from './MessageExtensions';
import { TaskModules, TaskModulesOptions } from './TaskModules';
import { AuthenticationManager, AuthenticationOptions } from './authentication/Authentication';
Expand Down Expand Up @@ -153,12 +154,14 @@ export interface FeedbackLoopData {
/**
* 'like' or 'dislike'
*/
reaction: string;
reaction: 'like' | 'dislike';

/**
* The response the user provides when prompted with "What did you like/dislike?" after pressing one of the feedback buttons.
*/
feedback: string;
feedback: string | Record<string, any>;
};

/**
* The activity ID that the feedback was provided on.
*/
Expand Down Expand Up @@ -245,6 +248,7 @@ export class Application<TState extends TurnState = TurnState> {
private readonly _invokeRoutes: AppRoute<TState>[] = [];
private readonly _adaptiveCards: AdaptiveCards<TState>;
private readonly _meetings: Meetings<TState>;
private readonly _messages: Messages<TState>;
private readonly _messageExtensions: MessageExtensions<TState>;
private readonly _taskModules: TaskModules<TState>;
private readonly _ai?: AI<TState>;
Expand Down Expand Up @@ -287,6 +291,7 @@ export class Application<TState extends TurnState = TurnState> {
}

this._adaptiveCards = new AdaptiveCards<TState>(this);
this._messages = new Messages<TState>(this);
this._messageExtensions = new MessageExtensions<TState>(this);
this._meetings = new Meetings<TState>(this);
this._taskModules = new TaskModules<TState>(this);
Expand Down Expand Up @@ -354,6 +359,14 @@ export class Application<TState extends TurnState = TurnState> {
return this._authentication;
}

/**
* Fluent interface for accessing Messages specific features.
* @returns {Messages<TState>} The Messages instance.
*/
public get messages(): Messages<TState> {
return this._messages;
}

/**
* Fluent interface for accessing Message Extensions' specific features.
* @returns {MessageExtensions<TState>} The MessageExtensions instance.
Expand Down
56 changes: 56 additions & 0 deletions js/packages/teams-ai/src/Messages.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import sinon from 'sinon';
import { strict as assert } from 'assert';
import { ActivityTypes, Channels, INVOKE_RESPONSE_KEY, TestAdapter } from 'botbuilder';

import { Application } from './Application';
import { createTestInvoke } from './internals/testing/TestUtilities';
import { MessageInvokeNames, Messages } from './Messages';

describe('Messages', () => {
const adapter = new TestAdapter();
let mockApp: Application;

beforeEach(() => {
mockApp = new Application();
sinon.stub(mockApp, 'adapter').get(() => adapter);
});

it('should exist when Application is instantiated', () => {
assert.notEqual(mockApp.messages, undefined);
assert.equal(mockApp.messages instanceof Messages, true);
});

describe(MessageInvokeNames.FETCH_INVOKE_NAME, () => {
it('fetch() with custom RouteSelector handler result is falsy', async () => {
const activity = createTestInvoke(MessageInvokeNames.FETCH_INVOKE_NAME, {});
activity.channelId = Channels.Msteams;
mockApp.messages.fetch(async (_context, _state, _data) => {
return {};
});

await adapter.processActivity(activity, async (context) => {
await mockApp.run(context);
const response = context.turnState.get(INVOKE_RESPONSE_KEY);
assert.deepEqual(response.value, {
status: 200,
body: { task: { type: 'continue', value: {} } }
});
});
});

it('fetch() with custom RouteSelector unhappy path', async () => {
const activity = { channelId: Channels.Msteams, type: ActivityTypes.Invoke, name: 'incorrectName' };
const spy = sinon.spy(async (context, _state, _data) => {
return Promise.resolve('');
});

mockApp.messages.fetch(spy);

await adapter.processActivity(activity, async (context) => {
await mockApp.run(context);
});

assert.equal(spy.called, false);
});
});
});
100 changes: 100 additions & 0 deletions js/packages/teams-ai/src/Messages.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
/**
* @module teams-ai
*/
/**
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License.
*/

import {
ActivityTypes,
Channels,
INVOKE_RESPONSE_KEY,
InvokeResponse,
TaskModuleResponse,
TaskModuleTaskInfo,
TurnContext
} from 'botbuilder';
import { Application } from './Application';
import { TurnState } from './TurnState';

export enum MessageInvokeNames {
FETCH_INVOKE_NAME = `message/fetchTask`
}

/**
* TaskModules class to enable fluent style registration of handlers related to Task Modules.
* @template TState Type of the turn state object being persisted.
*/
export class Messages<TState extends TurnState> {
private readonly _app: Application<TState>;

/**
* Creates a new instance of the TaskModules class.
* @param {Application} app Top level application class to register handlers with.
*/
public constructor(app: Application<TState>) {
this._app = app;
}

/**
* Registers a handler to process the initial fetch of the task module.
* @remarks
* Handlers should respond with either an initial TaskInfo object or a string containing
* a message to display to the user.
* @template TData Optional. Type of the data object being passed to the handler.
* @param {(context: TurnContext, state: TState, data: TData) => Promise<TaskModuleTaskInfo | string>} handler - Function to call when the handler is triggered.
* @param {TurnContext} handler.context - Context for the current turn of conversation with the user.
* @param {TState} handler.state - Current state of the turn.
* @param {TData} handler.data - Data object passed to the handler.
* @returns {Application<TState>} The application for chaining purposes.
*/
public fetch<TData extends Record<string, any> = Record<string, any>>(
handler: (context: TurnContext, state: TState, data: TData) => Promise<TaskModuleTaskInfo | string>
): Application<TState> {
this._app.addRoute(
async (context) => {
return (
context?.activity?.type === ActivityTypes.Invoke &&
context?.activity?.name === MessageInvokeNames.FETCH_INVOKE_NAME
);
},
async (context, state) => {
if (context?.activity?.channelId === Channels.Msteams) {
const result = await handler(context, state, context.activity.value?.data ?? {});

if (!context.turnState.get(INVOKE_RESPONSE_KEY)) {
// Format invoke response
let response: TaskModuleResponse;
if (typeof result == 'string') {
// Return message
response = {
task: {
type: 'message',
value: result
}
};
} else {
// Return card
response = {
task: {
type: 'continue',
value: result
}
};
}

// Queue up invoke response
await context.sendActivity({
value: { body: response, status: 200 } as InvokeResponse,
type: ActivityTypes.InvokeResponse
});
}
}
},
true
);

return this._app;
}
}
2 changes: 1 addition & 1 deletion js/packages/teams-ai/src/StreamingResponse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ export class StreamingResponse {
for (const citation of citations) {
const clientCitation: ClientCitation = {
'@type': 'Claim',
position: `${currPos + 1}`,
position: currPos + 1,
appearance: {
'@type': 'DigitalDocument',
name: citation.title || `Document #${currPos + 1}`,
Expand Down
12 changes: 6 additions & 6 deletions js/packages/teams-ai/src/Utilities.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ describe('Utilities', () => {
const citations = [
{
'@type': 'Claim',
position: '1',
position: 1,
appearance: {
'@type': 'DigitalDocument',
name: 'the title',
Expand All @@ -96,7 +96,7 @@ describe('Utilities', () => {
},
{
'@type': 'Claim',
position: '2',
position: 2,
appearance: {
'@type': 'DigitalDocument',
name: 'the title',
Expand All @@ -113,7 +113,7 @@ describe('Utilities', () => {
const citations = [
{
'@type': 'Claim',
position: '1',
position: 1,
appearance: {
'@type': 'DigitalDocument',
name: 'the title',
Expand All @@ -122,7 +122,7 @@ describe('Utilities', () => {
},
{
'@type': 'Claim',
position: '2',
position: 2,
appearance: {
'@type': 'DigitalDocument',
name: 'the title',
Expand All @@ -131,7 +131,7 @@ describe('Utilities', () => {
},
{
'@type': 'Claim',
position: '3',
position: 3,
appearance: {
'@type': 'DigitalDocument',
name: 'the title',
Expand All @@ -140,7 +140,7 @@ describe('Utilities', () => {
},
{
'@type': 'Claim',
position: '4',
position: 4,
appearance: {
'@type': 'DigitalDocument',
name: 'the title',
Expand Down
2 changes: 1 addition & 1 deletion js/packages/teams-ai/src/actions/SayCommand.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -188,7 +188,7 @@ describe('actions.sayCommand', () => {
citation: [
{
'@type': 'Claim',
position: '1',
position: 1,
appearance: {
'@type': 'DigitalDocument',
name: 'the title',
Expand Down
16 changes: 13 additions & 3 deletions js/packages/teams-ai/src/actions/SayCommand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,13 @@ import { AIEntity, ClientCitation } from '../types';
/**
* @private
* @param {boolean} feedbackLoopEnabled - If true, the feedback loop UI for Teams will be enabled.
* @param {'default' | 'custom'} feedbackLoopType - the type of UI to use for feedback loop
* @returns {''} - An empty string.
*/
export function sayCommand<TState extends TurnState = TurnState>(feedbackLoopEnabled: boolean = false) {
export function sayCommand<TState extends TurnState = TurnState>(
feedbackLoopEnabled: boolean = false,
feedbackLoopType: 'default' | 'custom' = 'default',
) {
return async (context: TurnContext, _state: TState, data: PredictedSayCommand) => {
if (!data.response?.content) {
return '';
Expand All @@ -37,7 +41,7 @@ export function sayCommand<TState extends TurnState = TurnState>(feedbackLoopEna
citations = data.response.context!.citations.map((citation, i) => {
const clientCitation: ClientCitation = {
'@type': 'Claim',
position: `${i + 1}`,
position: i + 1,
appearance: {
'@type': 'DigitalDocument',
name: citation.title || `Document #${i + 1}`,
Expand All @@ -54,6 +58,11 @@ export function sayCommand<TState extends TurnState = TurnState>(feedbackLoopEna

// If there are citations, filter out the citations unused in content.
const referencedCitations = citations ? Utilities.getUsedCitations(contentText, citations) : undefined;
const channelData = feedbackLoopEnabled && feedbackLoopType ? {
feedbackLoop: {
type: feedbackLoopType
}
} : { feedbackLoopEnabled };

const entities: AIEntity[] = [
{
Expand All @@ -65,10 +74,11 @@ export function sayCommand<TState extends TurnState = TurnState>(feedbackLoopEna
...(referencedCitations ? { citation: referencedCitations } : {})
}
];

const activity: Partial<Activity> = {
type: ActivityTypes.Message,
text: contentText,
...(isTeamsChannel ? { channelData: { feedbackLoopEnabled } } : {}),
...(isTeamsChannel ? { channelData } : {}),
entities: entities
};

Expand Down
Loading

0 comments on commit eb63a75

Please sign in to comment.