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: `
-

Cosmos DB conversations

-

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.

+

Hand-off to human

+

Features

+

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 { switch (col.type) { case 'icon': - return {col.value || value[col.field]}; + return !col.tooltip ? {col.value || value[col.field]} : ( + + ); case 'button': return ( diff --git a/src/components/generic/generic.scss b/src/components/generic/generic.scss index 075f8ec..0f2ea4e 100644 --- a/src/components/generic/generic.scss +++ b/src/components/generic/generic.scss @@ -91,4 +91,44 @@ .table > .secondary { color: grey; +} + +td.text { + white-space: normal; +} + +td.summary { + max-width: 240px; + overflow: hidden; + text-overflow: ellipsis; +} + +/* Sentiment */ + +.sentiment_very_dissatisfied { + color: red !important; +} + +.sentiment_dissatisfied { + color: orange !important; +} + +.sentiment_neutral { + color: grey !important; +} + +.sentiment_satisfied { + color: yellow !important; +} + +.sentiment_very_satisfied { + color: green !important; +} + +.error_outline { + color: lightgrey !important; +} + +.tooltip { + visibility: visible !important; } \ No newline at end of file diff --git a/src/data-sources/plugins/CosmosDB/Query.ts b/src/data-sources/plugins/CosmosDB/Query.ts index 7c02c86..bfe661a 100644 --- a/src/data-sources/plugins/CosmosDB/Query.ts +++ b/src/data-sources/plugins/CosmosDB/Query.ts @@ -115,7 +115,7 @@ export default class CosmosDBQuery extends DataSourcePlugin { // 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);