diff --git a/docs/table.md b/docs/table.md index d0e4982..88fc9c1 100644 --- a/docs/table.md +++ b/docs/table.md @@ -50,6 +50,8 @@ Define `props.cols` as follows: | `type` | `string` | Either 'text', 'time', 'icon', 'button'. Default is 'text' | `format` | `string` | If type is `time` a date format can be applied | `value`| `string` | If type is a `icon` or `button` this will define the icon to use +| `tooltip`| `string` | If type is a `icon` this will define the tooltip to use +| `tooltipPosition`| `string` | If type is a `icon` this will define the tooltip position to use. Can be either `top`, `bottom`, `left` or `right`. | `click`| `string` | If type is a `button` this will define the action to trigger when selecting a row #### Props sample: diff --git a/server/dashboards/preconfigured/cosmosdb-handoff.ts b/server/dashboards/preconfigured/cosmosdb-handoff.ts index fb02654..880c7fb 100644 --- a/server/dashboards/preconfigured/cosmosdb-handoff.ts +++ b/server/dashboards/preconfigured/cosmosdb-handoff.ts @@ -4,15 +4,33 @@ import * as _ from 'lodash'; // The following line is important to keep in that format so it can be rendered into the page export const config: IDashboardConfig = /*return*/ { id: 'cosmosdb_handoff', - name: 'Cosmos DB conversations', + name: 'Hand-off to human', icon: 'question_answer', url: 'cosmosdb_handoff', - description: 'Conversations with option to hand-off to human', + description: 'Monitor bot and hand-off to human conversations', preview: '/images/bot-framework-preview.png', html: `
Displays list of active conversations with a bot using Cosmos DB as the data source.
-If there is a conversation of interest this can be selected to show the option to hand-off to human agent.
+Displays total users including how many are talking with bot, human agent or waiting for an agent.
+Displays average time waiting (in secs) for human agent to connect and respond to user, + including the shortest and longest times.
+Displays total number of transcripts with bot or with human agent.
+Displays timeline of conversations with bot and human.
+Displays list of active conversations with sentiment score.
+If there is a conversation of interest this can be selected to show the option to hand-off to human.
+
Refer to the
@@ -27,10 +45,6 @@ export const config: IDashboardConfig = /*return*/ {
'directLine': '',
'conversationsEndpoint': '',
'webchatEndpoint': ''
- },
- 'cosmos-db': {
- 'host': '',
- 'key': ''
}
},
layout: {
@@ -70,54 +84,267 @@ export const config: IDashboardConfig = /*return*/ {
}
},
{
- id: 'botConversations',
- type: 'CosmosDB/Query',
- dependencies: { timespan: 'timespan', queryTimespan: 'timespan:queryTimespan' },
- params: {
- databaseId: 'admin',
- collectionId: 'conversations',
- query: () => `SELECT * FROM conversations c WHERE (c.state = 0 OR c.state = 1) ORDER BY c.state`,
- parameters: []
+ id: 'ai',
+ type: 'ApplicationInsights/Query',
+ dependencies: {
+ timespan: 'timespan',
+ queryTimespan: 'timespan:queryTimespan',
+ granularity: 'timespan:granularity'
},
- calculated: (result) => {
- if (!Array.isArray(result.Documents)) {
- return null;
- }
- const values = result.Documents.reduce((destArray, currentValue) => {
- if (!currentValue.customer || !currentValue.customer.id || !currentValue.customer.conversation ||
- !currentValue.customer.conversation.id || !currentValue.customer.user || !currentValue.transcript) {
- console.warn('Unexpected Document data, missing key properties.', currentValue);
- return;
- }
- const lastMessage = currentValue.transcript.reverse().find(x => x.from !== 'Bot');
- const value = {
- userId: currentValue.customer.id,
- conversationId: currentValue.customer.conversation.id,
- username: currentValue.customer.user.name || 'Unknown',
- timestamp: lastMessage.timestamp,
- lastMessage: lastMessage.text || '',
- icon: currentValue.state === 1 ? 'perm_identity' : 'memory',
- };
- destArray.push(value);
- return destArray;
- }, []);
+ params: {
+ table: 'customEvents',
+ queries: {
+ transcripts: {
+ query: () => `where name == 'Transcript'
+ | extend conversationId=tostring(customDimensions.userConversationId),
+ customerName=tostring(customDimensions.customerName),
+ userTime=tostring(customDimensions.timestamp)
+ | project conversationId, customerName, timestamp, userTime, customDimensions
+ | order by timestamp desc
+ | summarize transcripts_count=count(userTime),
+ transcripts=makelist(customDimensions) by customerName, conversationId
+ | project conversationId, customerName, transcripts_count, transcripts`,
+ calculated: (transcripts) => {
+ const listTranscripts = transcripts.reduce((destArray, currentValue) => {
+ const transcriptsArray = JSON.parse(currentValue.transcripts);
+ const lastMessage = transcriptsArray.find(x => x.from !== 'Bot');
+ if (!lastMessage) {
+ return destArray;
+ }
+ const lastSentimentScore = lastMessage.sentimentScore || 0.5;
+ const value = {
+ userId: lastMessage.customerId,
+ conversationId: lastMessage.customerConversationId,
+ username: lastMessage.customerName || 'Anon',
+ timestamp: new Date(lastMessage.timestamp).toUTCString(),
+ lastMessage: lastMessage.text || '',
+ lastSentimentScore: lastSentimentScore,
+ lastSentiment: lastSentimentScore < 0 ? 'error_outline' :
+ lastSentimentScore < 0.2 ? 'sentiment_very_dissatisfied' :
+ lastSentimentScore < 0.4 ? 'sentiment_dissatisfied' :
+ lastSentimentScore < 0.6 ? 'sentiment_neutral' :
+ lastSentimentScore < 0.8 ? 'sentiment_satisfied' : 'sentiment_very_satisfied',
+ icon: lastMessage.state === 1 ? 'perm_identity' : 'memory',
+ };
+ destArray.push(value);
+ return destArray;
+ }, []);
+ return {
+ 'transcripts-values': listTranscripts
+ };
+ }
+ },
- return { result, values };
+ transcriptsTimeWaiting: {
+ query: () => `where name == 'Transcript'
+ | extend conversationId=tostring(customDimensions.userConversationId),
+ customerId=tostring(customDimensions.customerId),
+ state=toint(customDimensions.state)
+ | where state==1 or state==2
+ | order by timestamp asc
+ | summarize total=count(), times=makelist(timestamp) by conversationId, customerId, bin(state, 1)
+ | project conversationId, customerId, state, startTime=times[0]
+ | summarize result=count(state), startEndTimes=makelist(startTime) by conversationId, customerId
+ | where result == 2
+ | project conversationId, customerId, timeTaken=todatetime(startEndTimes[1])-todatetime(startEndTimes[0])`,
+ calculated: (results) => {
+ const times = results.reduce((acc, cur) => {
+ // converts time hh:mm:ss format to value in seconds
+ acc.push(cur.timeTaken.split(':').reverse().reduce((a, c, i) => a + c * Math.pow(60, i), 0));
+ return acc;
+ }, []);
+ const avgTimeWaiting = times.reduce((a, c) => a + c, 0) / times.length;
+ const maxTimeWaiting = Math.max(...times);
+ const minTimeWaiting = Math.min(...times);
+ return {
+ 'transcriptsAverageTimeWaiting-value': avgTimeWaiting.toFixed(1),
+ 'transcriptsLongestTimeWaiting-value': maxTimeWaiting.toFixed(1),
+ 'transcriptsShortestTimeWaiting-value': minTimeWaiting.toFixed(1),
+ };
+ }
+ },
+
+ transcriptsTimeline: {
+ query: (dependencies) => {
+ var { granularity } = dependencies;
+ return `where name == 'Transcript'
+ | extend customerName=tostring(customDimensions.customerName),
+ text=tostring(customDimensions.text),
+ state=toint(customDimensions.state),
+ agentName=tostring(customDimensions.agentName),
+ from=tostring(customDimensions.from)
+ | extend timestamp=todatetime(customDimensions.timestamp)
+ | extend states=pack_array('bot','waiting','agent','watching')
+ | extend stateLabel=tostring(states[state])
+ | where state == 0 or state == 2
+ | project timestamp, from, text, customerName, agentName, state, stateLabel
+ | summarize transcripts_count=count()
+ by bin(timestamp, ${granularity}), state, stateLabel
+ | order by timestamp asc`;
+ },
+ calculated: (results, dependencies) => {
+ const totalBot = results.reduce((a, c) => c.state === 0 ? a + c.transcripts_count : a, 0);
+ const totalAgent = results.reduce((a, c) => c.state === 2 ? a + c.transcripts_count : a, 0);
+ const totalMessages = totalBot + totalAgent;
+ // Timeline
+ const { timespan } = dependencies;
+ const keys = [...new Set(results.reduce((a, c) => { a.push(c.stateLabel); return a; }, []))];
+ const timestampKey = 'time'; // NB: required key name for timeline component
+ // group by timestamp
+ const graphData = results.reduce((a, c) => {
+ if (!c.timestamp) {
+ console.warn('Invalid date format:', c);
+ return a;
+ }
+ const item = a.find(collection => collection[timestampKey] === c.timestamp);
+ if (!item) {
+ // new time collection
+ let collection = {
+ count: 0
+ };
+ collection[timestampKey] = c.timestamp;
+ keys.forEach(key => {
+ collection[key] = (key !== c.stateLabel) ? 0 : c.transcripts_count;
+ });
+ a.push(collection);
+ } else {
+ // merge into time collection
+ item.count += c.transcripts_count;
+ item[c.stateLabel] += c.transcripts_count;
+ }
+ return a;
+ }, []);
+ return {
+ 'timeline-graphData': graphData,
+ 'timeline-recipients': keys,
+ 'timeline-timeFormat': (timespan === '24 hours' ? 'hour' : 'date'),
+ 'transcriptsBot-value': totalBot,
+ 'transcriptsAgent-value': totalAgent,
+ 'transcriptsTotal-value': totalMessages,
+ };
+ }
+ },
+
+ customerTranscripts: {
+ query: () => `where name == 'Transcript'
+ | summarize
+ maxState=max(toint(customDimensions.state))
+ by customerConversationId=tostring(customDimensions.userConversationId),
+ customerName=tostring(customDimensions.customerName)`,
+ calculated: (customerTranscripts) => {
+ const bot = customerTranscripts.filter((e) => e.maxState === 0);
+ const waiting = customerTranscripts.filter((e) => e.maxState === 1);
+ const agent = customerTranscripts.filter((e) => e.maxState === 2);
+ return {
+ 'customerTotal-value': customerTranscripts.length,
+ 'customerBot-value': bot.length,
+ 'customerWaiting-value': waiting.length,
+ 'customerAgent-value': agent.length,
+ };
+ }
+ }
+
+ }
}
}
],
elements: [
+ {
+ id: 'customerTotal',
+ type: 'Scorecard',
+ title: 'Users',
+ size: { w: 6, h: 3 },
+ dependencies: {
+ card_total_heading: '::Total Users',
+ card_total_value: 'ai:customerTotal-value',
+ card_total_color: '::#666666',
+ card_total_icon: '::account_circle',
+ card_bot_heading: '::Bot',
+ card_bot_value: 'ai:customerBot-value',
+ card_bot_color: '::#00FF00',
+ card_bot_icon: '::memory',
+ card_agent_heading: '::Agent',
+ card_agent_value: 'ai:customerAgent-value',
+ card_agent_color: '::#0066FF',
+ card_agent_icon: '::perm_identity',
+ card_waiting_heading: '::Waiting',
+ card_waiting_value: 'ai:customerWaiting-value',
+ card_waiting_color: '::#FF6600',
+ card_waiting_icon: '::more_horiz',
+ }
+ },
+
+ {
+ id: 'customerWaiting',
+ type: 'Scorecard',
+ title: 'Waiting Times',
+ size: { w: 6, h: 3 },
+ dependencies: {
+ card_average_heading: '::Average',
+ card_average_value: 'ai:transcriptsAverageTimeWaiting-value',
+ card_average_color: '::#333333',
+ card_average_icon: '::av_timer',
+ card_max_heading: '::Longest',
+ card_max_value: 'ai:transcriptsLongestTimeWaiting-value',
+ card_max_color: '::#ff0000',
+ card_max_icon: '::timer',
+ card_min_heading: '::Shortest',
+ card_min_value: 'ai:transcriptsShortestTimeWaiting-value',
+ card_min_color: '::#0066ff',
+ card_min_icon: '::timer',
+ }
+ },
+
+ {
+ id: 'transcriptsTotal',
+ type: 'Scorecard',
+ title: 'Transcripts',
+ size: { w: 2, h: 8 },
+ dependencies: {
+ card_total_heading: '::Total Msgs',
+ card_total_value: 'ai:transcriptsTotal-value',
+ card_total_color: '::#666666',
+ card_total_icon: '::question_answer',
+ card_bot_heading: '::Bot',
+ card_bot_value: 'ai:transcriptsBot-value',
+ card_bot_color: '::#00FF00',
+ card_bot_icon: '::memory',
+ card_agent_heading: '::Agent',
+ card_agent_value: 'ai:transcriptsAgent-value',
+ card_agent_color: '::#0066FF',
+ card_agent_icon: '::perm_identity'
+ }
+ },
+
+ {
+ id: 'timelineHandoffConversations',
+ type: 'Area',
+ title: 'Conversations with bot / human',
+ subtitle: 'How many conversations required hand-off to human',
+ size: { w: 10, h: 8 },
+ dependencies: {
+ values: 'ai:timeline-graphData',
+ lines: 'ai:timeline-recipients',
+ timeFormat: 'ai:timeline-timeFormat'
+ },
+ props: {
+ isStacked: false,
+ showLegend: true
+ }
+ },
+
{
id: 'conversations',
type: 'Table',
title: 'Recent Conversations',
subtitle: 'Monitor bot communications',
- size: { w: 12, h: 12 },
- dependencies: { values: 'botConversations:values' },
+ size: { w: 12, h: 19 },
+ dependencies: { values: 'ai:transcripts-values' },
props: {
cols: [
- { header: 'Timestamp', field: 'timestamp', type: 'time', format: 'MMM-DD HH:mm:ss' },
+ { header: 'Timestamp', field: 'timestamp', type: 'time', format: 'MMM-DD HH:mm:ss', width: '100px' },
{ header: 'Last Message', field: 'lastMessage' },
+ { header: 'Last Sentiment', field: 'lastSentiment', type: 'icon', tooltip: 'lastSentimentScore', tooltipPosition: 'right' },
{ header: 'Username', field: 'username' },
{ header: 'Status', field: 'icon', type: 'icon' },
{ type: 'button', value: 'chat', click: 'openTranscriptsDialog' }
@@ -125,55 +352,75 @@ export const config: IDashboardConfig = /*return*/ {
},
actions: {
openTranscriptsDialog: {
- action: 'dialog:transcripts',
+ action: 'dialog:transcriptsDialog',
params: { title: 'args:username', conversationId: 'args:conversationId', queryspan: 'timespan:queryTimespan' }
}
}
}
+
],
dialogs: [
{
- id: 'transcripts',
+ id: 'transcriptsDialog',
width: '60%',
params: ['title', 'conversationId', 'queryspan'],
dataSources: [
{
id: 'transcripts-data',
- type: 'CosmosDB/Query',
+ type: 'ApplicationInsights/Query',
dependencies: {
- conversationId: 'dialog_transcripts:conversationId',
- queryTimespan: 'dialog_transcripts:queryspan',
+ username: 'dialog_transcriptsDialog:title',
+ conversationId: 'dialog_transcriptsDialog:conversationId',
+ queryTimespan: 'dialog_transcriptsDialog:queryspan',
secret: 'connection:bot-framework.directLine'
},
params: {
- databaseId: 'admin',
- collectionId: 'conversations',
- query: ({ conversationId }) => `
- SELECT * FROM conversations c
- WHERE (c.customer.conversation['$id'] = '${conversationId}')`,
- parameters: []
- },
- calculated: (result, dependencies) => {
- if (!result.Documents) {
- return null;
- }
+ table: 'customEvents',
+ queries: {
+ 'userConversationTranscripts':
+ {
+ query: ({ conversationId }) => {
+ return `where name == 'Transcript'
+ | where customDimensions.customerConversationId == '${conversationId}'
+ | extend timestamp=tostring(customDimensions.timestamp)
+ | project timestamp,
+ text=tostring(customDimensions.text),
+ sentimentScore=todouble(customDimensions.sentimentScore),
+ from=tostring(customDimensions.from),
+ state=toint(customDimensions.state)
+ | order by timestamp asc`; },
+ calculated: (transcripts, dependencies) => {
+ if (!transcripts || transcripts.length < 1) {
+ return null;
+ }
- let values = [];
- let customer = null;
- let body = {};
- let headers = {};
- let disabled = false;
- const { secret } = dependencies;
+ const { secret } = dependencies;
+ const { conversationId } = dependencies;
- if (result.Documents.length === 1) {
- let document = result.Documents[0];
- values = document.transcript || [];
- customer = document.customer;
- disabled = document.state !== 0 ? true : false;
- body = { 'conversationId': customer.conversation.id, };
- headers = { 'Authorization': `Bearer ${secret}` };
+ let values = transcripts || [];
+ let body, headers = {};
+ let disabled = transcripts[transcripts.length - 1].state !== 0 ? true : false;
+
+ values.map(v => {
+ const lastSentimentScore = v.sentimentScore || 0.5;
+ v['sentiment'] = lastSentimentScore < 0 ? 'error_outline' :
+ lastSentimentScore < 0.2 ? 'sentiment_very_dissatisfied' :
+ lastSentimentScore < 0.4 ? 'sentiment_dissatisfied' :
+ lastSentimentScore < 0.6 ? 'sentiment_neutral' :
+ lastSentimentScore < 0.8 ? 'sentiment_satisfied' : 'sentiment_very_satisfied';
+ });
+
+ body = {
+ 'conversationId': conversationId,
+ };
+ headers = {
+ 'Authorization': `Bearer ${secret}`
+ };
+ return { 'values': values, 'headers': headers, 'body': body, 'disabled': disabled };
+ }
+
+ }
}
- return { values, customer, headers, body, disabled };
}
}
],
@@ -191,7 +438,7 @@ export const config: IDashboardConfig = /*return*/ {
conversationsEndpoint: 'connection:bot-framework.conversationsEndpoint'
},
props: {
- url: ({conversationsEndpoint}) => `${conversationsEndpoint}`,
+ url: ({ conversationsEndpoint }) => `${conversationsEndpoint}`,
method: 'POST',
disableAfterFirstClick: true,
icon: 'person',
@@ -204,8 +451,8 @@ export const config: IDashboardConfig = /*return*/ {
title: 'Open Webchat',
size: { w: 2, h: 1 },
location: { x: 2, y: 0 },
- dependencies: {
- token: 'connection:bot-framework.directLine',
+ dependencies: {
+ token: 'connection:bot-framework.directLine',
webchatEndpoint: 'connection:bot-framework.webchatEndpoint',
dependsOn: 'transcripts-data:disabled'
},
@@ -227,11 +474,13 @@ export const config: IDashboardConfig = /*return*/ {
rowClassNameField: 'from',
cols: [
{ header: 'Timestamp', field: 'timestamp', type: 'time', format: 'MMM-DD HH:mm:ss', width: '50px' },
+ { header: 'Sentiment', field: 'sentiment', tooltip: 'sentimentScore', type: 'icon', width: '50px', tooltipPosition: 'right' },
{ header: 'Text', field: 'text' }
]
}
}
]
}
+
]
};
\ No newline at end of file
diff --git a/src/components/generic/Table/Table.tsx b/src/components/generic/Table/Table.tsx
index c1a7ba4..ef4f405 100644
--- a/src/components/generic/Table/Table.tsx
+++ b/src/components/generic/Table/Table.tsx
@@ -22,6 +22,8 @@ export interface ITableColumnProps {
type?: ColType;
click?: string;
color?: string;
+ tooltip?: string;
+ tooltipPosition?: string;
}
export interface ITableProps extends IGenericProps {
@@ -101,7 +103,16 @@ export default class Table extends GenericComponent