Hand-off to human with dashboard template using AI transcript logging

This commit is contained in:
David Douglas 2017-06-21 11:00:09 +01:00
Родитель 8d026c8b14
Коммит 39ed4176c3
3 изменённых файлов: 336 добавлений и 77 удалений

Просмотреть файл

@ -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: `<div>
<h1>Cosmos DB conversations</h1>
<p>Displays list of active conversations with a bot using Cosmos DB as the data source.</p>
<p>If there is a conversation of interest this can be selected to show the option to hand-off to human agent.</p>
<h1>Hand-off to human</h1>
<h2>Features</h2>
<ul>
<li>
<p>Displays total users including how many are talking with bot, human agent or waiting for an agent.</p>
</li>
<li>
<p>Displays average time waiting (in secs) for human agent to connect and respond to user,
including the shortest and longest times.</p>
</li>
<li>
<p>Displays total number of transcripts with bot or with human agent.</p>
</li>
<li>
<p>Displays timeline of conversations with bot and human.</p>
</li>
<li>
<p>Displays list of active conversations with sentiment score.</p>
<p>If there is a conversation of interest this can be selected to show the option to hand-off to human.</p>
</li>
</ul>
<p>
<span>Refer to the </span>
<span>
@ -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' }
]
}
}
]
}
]
};

Просмотреть файл

@ -93,6 +93,16 @@
color: grey;
}
td.text {
white-space: normal;
}
td.summary {
max-width: 240px;
overflow: hidden;
text-overflow: ellipsis;
}
/* Sentiment */
.sentiment_very_dissatisfied {

Просмотреть файл

@ -115,7 +115,7 @@ export default class CosmosDBQuery extends DataSourcePlugin<IQueryParams> {
// Helper methods to strip dollar sign from JSON key names
private remap(json: any) {
if (typeof json === 'object') {
if (json !== null && typeof json === 'object') {
return this.remapObject(json);
} else if (Array.isArray(json)) {
return this.remapArray(json);