UNPKG

sfdx-hardis

Version:

Swiss-army-knife Toolbox for Salesforce. Allows you to define a complete CD/CD Pipeline. Orchestrate base commands and assist users with interactive wizards

369 lines (362 loc) 17.5 kB
import { Messages } from "@salesforce/core"; import { Flags, requiredOrgFlagWithDeprecations, SfCommand } from "@salesforce/sf-plugins-core"; import { dataCloudSqlQuery } from "../../../../common/utils/dataCloudUtils.js"; import { uxLog } from "../../../../common/utils/index.js"; import { generateCsvFile, generateReportPath } from "../../../../common/utils/filesUtils.js"; import { NotifProvider } from "../../../../common/notifProvider/index.js"; import { setConnectionVariables } from "../../../../common/utils/orgUtils.js"; import c from "chalk"; import { buildConversation, buildConversationUrl, buildDateFilterClause, buildExcludedSessionFilter, extractSessionIds, extractSpeakerSegment, fetchConversationTranscripts, normalizeKeys, pickFirstMeaningfulValue, resolveConversationLinkDomain, resolveDateFilterOptions, resolveExcludedConversationIds, sanitizePlaceholderValue, stringValue, } from "../../../../common/utils/agentforceQueryUtils.js"; Messages.importMessagesDirectoryFromMetaUrl(import.meta.url); const messages = Messages.loadMessages('sfdx-hardis', 'org'); export default class DataCloudExtractAgentforceConversations extends SfCommand { static title = 'Extract Agentforce Conversations Data from Data Cloud'; static description = ` ## Command Behavior **Extracts Agentforce conversations data from Data Cloud and generates a detailed report.** This command allows you to retrieve and analyze conversations between users and Agentforce agents. It fetches conversation details, including transcripts, user utterances, agent responses, and any associated feedback. Key functionalities: - **Data Extraction:** Queries Data Cloud for Agentforce conversation records. - **Transcript Retrieval:** Fetches full conversation transcripts associated with the sessions. - **Filtering:** Supports filtering by date range (from/to) or a rolling window (last N days). - **Report Generation:** Creates a CSV and XLSX report containing: - User information - Date and time - Full conversation transcript - Feedback sentiment and message (if available) - Direct link to the conversation in Salesforce - **Link Generation:** Generates clickable URLs to view the conversation in the Agentforce Analytics dashboard. <details markdown="1"> <summary>Technical explanations</summary> The command's technical implementation involves: - **Data Cloud Query:** Executes a SQL query against Data Cloud tables (\`GenAIGeneration__dlm\`, \`GenAIGatewayRequest__dlm\`, etc.) to retrieve conversation metadata and individual turns. - **Session Management:** Extracts session IDs from the initial query results. - **Transcript Fetching:** Asynchronously fetches full conversation transcripts for the identified sessions in chunks to handle large volumes efficiently. - **Data Merging:** Combines the SQL query results with the fetched transcripts, prioritizing full transcripts over individual turn data when available. - **URL Construction:** dynamically builds deep links to the Salesforce Lightning Experience for each conversation based on the org's instance URL and conversation ID. - **File Output:** Uses \`generateCsvFile\` to output the processed data into CSV and XLSX formats with custom column widths and formatting. - **Exclusion Filters:** Supports excluding specific conversations or sessions via environment variables \`AGENTFORCE_FEEDBACK_EXCLUDED_CONV_IDS\` and \`AGENTFORCE_EXCLUDED_SESSION_IDS\` (comma-separated IDs). </details> `; static examples = [ '$ sf hardis:datacloud:extract:agentforce-conversations', ]; static flags = { outputfile: Flags.string({ char: 'f', description: 'Force the path and name of output report file. Must end with .csv', }), debug: Flags.boolean({ char: 'd', default: false, description: messages.getMessage('debugMode'), }), 'date-from': Flags.string({ description: 'Optional ISO-8601 timestamp (UTC) to include conversations starting from this date', }), 'date-to': Flags.string({ description: 'Optional ISO-8601 timestamp (UTC) to include conversations up to this date', }), 'last-n-days': Flags.integer({ description: 'Optional rolling window (days) to include only the most recent conversations', }), websocket: Flags.string({ description: messages.getMessage('websocket'), }), skipauth: Flags.boolean({ description: 'Skip authentication check when a default username is required', }), 'target-org': requiredOrgFlagWithDeprecations, }; static requiresProject = false; debugMode = false; queryString = ''; outputFile; outputFilesRes = {}; /* jscpd:ignore-end */ async run() { const { flags } = await this.parse(DataCloudExtractAgentforceConversations); this.outputFile = flags.outputfile || null; this.debugMode = flags.debug || false; const conn = flags['target-org'].getConnection(); const conversationLinkDomain = resolveConversationLinkDomain(conn.instanceUrl); const dateFilterOptions = resolveDateFilterOptions({ dateFromInput: flags['date-from'], dateToInput: flags['date-to'], lastNDaysInput: flags['last-n-days'], }); this.queryString = buildConversationQuery(dateFilterOptions).trim(); uxLog("action", this, c.cyan("Querying Agentforce conversations...")); const rawResult = await dataCloudSqlQuery(this.queryString, conn, {}); const sessionIds = extractSessionIds(rawResult.records, { sanitizer: sanitizePlaceholderValue }); uxLog("action", this, c.cyan("Fetching conversation transcripts...")); const transcriptsBySession = await fetchConversationTranscripts(sessionIds, conn, { chunkSize: 50, sanitizeSessionId: sanitizePlaceholderValue, }); uxLog("action", this, c.cyan("Building export records...")); const exportRecords = buildConversationRecords(rawResult.records, { conversationLinkDomain, transcriptsBySession, timeFilterDays: DEFAULT_CONVERSATION_TIME_FILTER_DAYS, }); const result = { ...rawResult, records: exportRecords, returnedRows: exportRecords.length }; const { records: _records, ...resultCopy } = result; void _records; uxLog("other", this, JSON.stringify(resultCopy, null, 2)); this.outputFile = await generateReportPath('datacloud-agentforce-conversations', this.outputFile); this.outputFilesRes = await generateCsvFile(exportRecords, this.outputFile, { fileTitle: 'DataCloud Agentforce Conversations', columnsCustomStyles: { 'DateTime': { width: 26 }, 'Conversation transcript': { width: 90, wrap: true, maxHeight: 50 }, 'Feedback': { width: 12 }, 'Feedback message': { width: 45, wrap: true, maxHeight: 50 }, 'ConversationId': { width: 36 }, 'ConversationUrl': { width: 80, hyperlinkFromValue: true }, }, }); const conversationStats = computeConversationStats(exportRecords); const notifText = buildConversationNotificationText(conversationStats, dateFilterOptions); const attachedFiles = collectConversationReportFiles(this.outputFilesRes); uxLog("action", this, c.cyan(notifText)); await setConnectionVariables(conn); await NotifProvider.postNotifications({ type: 'AGENTFORCE_CONVERSATIONS', text: notifText, buttons: [], attachments: [], severity: 'log', attachedFiles, logElements: [], data: { metric: conversationStats.totalCount }, metrics: { agentforceConversationCount: conversationStats.totalCount, agentforceConversationFeedbackCount: conversationStats.withFeedback, agentforceConversationFeedbackBad: conversationStats.feedbackBad, agentforceConversationFeedbackGood: conversationStats.feedbackGood, }, alwaysSend: true, }); return { result: JSON.parse(JSON.stringify(result)), csvLogFile: this.outputFile, xlsxLogFile: this.outputFilesRes?.xlsxFile, conversationCount: exportRecords.length }; } } const DEFAULT_CONVERSATION_TIME_FILTER_DAYS = 365; function buildConversationQuery(filters = {}) { const dateFilterClause = buildDateFilterClause(filters); const excludedConversationClause = buildExcludedConversationFilter(); const excludedSessionClause = buildExcludedSessionFilter(); return ` WITH conversation_base AS ( SELECT COALESCE(usr.ssot__username__c, gar.userId__c) AS userName, ggn.timestamp__c AS conversationDate, gar.generationGroupId__c AS conversationId, gar.gatewayRequestId__c AS gatewayRequestId, gat.tagValue__c AS userUtterance, ggn.responseText__c AS agentResponse, ROW_NUMBER() OVER ( PARTITION BY gar.generationGroupId__c ORDER BY ggn.timestamp__c DESC, gar.gatewayRequestId__c DESC ) AS rowNum FROM GenAIGeneration__dlm ggn JOIN GenAIGatewayResponse__dlm grs ON ggn.generationResponseId__c = grs.generationResponseId__c JOIN GenAIGatewayRequest__dlm gar ON grs.generationRequestId__c = gar.gatewayRequestId__c LEFT JOIN GenAIGatewayRequestTag__dlm gat ON gar.gatewayRequestId__c = gat.parent__c AND gat.tag__c = 'user_utterance' LEFT JOIN ssot__User__dlm usr ON usr.ssot__Id__c = gar.userId__c WHERE gar.generationGroupId__c IS NOT NULL${excludedConversationClause}${dateFilterClause} ), session_lookup AS ( SELECT ais.ssot__GenAiGatewayRequestId__c AS gatewayRequestId, ai.ssot__AiAgentSessionId__c AS sessionId, ROW_NUMBER() OVER ( PARTITION BY ais.ssot__GenAiGatewayRequestId__c ORDER BY ais.ssot__StartTimestamp__c DESC ) AS rowNum FROM ssot__AiAgentInteractionStep__dlm ais JOIN ssot__AiAgentInteraction__dlm ai ON ai.ssot__Id__c = ais.ssot__AiAgentInteractionId__c ), session_agent_info AS ( SELECT part.ssot__AiAgentSessionId__c AS sessionId, MAX(part.ssot__AiAgentApiName__c) AS agentApiName FROM ssot__AiAgentSessionParticipant__dlm part WHERE part.ssot__AiAgentApiName__c IS NOT NULL GROUP BY part.ssot__AiAgentSessionId__c ), latest_feedback AS ( SELECT gaf.generationGroupId__c AS conversationId, gaf.feedback__c AS feedbackSentiment, gfd.feedbackText__c AS feedbackMessage, COALESCE( gaf.timestamp__c, TIMESTAMP '1900-01-01 00:00:00Z' ) AS feedbackTimestamp, ROW_NUMBER() OVER ( PARTITION BY gaf.generationGroupId__c ORDER BY COALESCE( gaf.timestamp__c, TIMESTAMP '1900-01-01 00:00:00Z' ) DESC, gaf.feedbackId__c DESC ) AS rowNum FROM GenAIFeedback__dlm gaf LEFT JOIN GenAIFeedbackDetail__dlm gfd ON gaf.feedbackId__c = gfd.parent__c ), conversation_primary AS ( SELECT * FROM conversation_base WHERE rowNum = 1 ) SELECT base.userName, base.conversationDate, base.conversationId, base.gatewayRequestId, sess.sessionId, agent.agentApiName, base.userUtterance, base.agentResponse, fb.feedbackSentiment, fb.feedbackMessage FROM conversation_primary base LEFT JOIN (SELECT gatewayRequestId, sessionId FROM session_lookup WHERE rowNum = 1) sess ON sess.gatewayRequestId = base.gatewayRequestId LEFT JOIN session_agent_info agent ON agent.sessionId = sess.sessionId LEFT JOIN ( SELECT conversationId, feedbackSentiment, feedbackMessage FROM latest_feedback WHERE rowNum = 1 ) fb ON fb.conversationId = base.conversationId ${excludedSessionClause} ORDER BY base.conversationDate DESC ; `; } function buildConversationRecords(records, options) { const safeRecords = Array.isArray(records) ? records : []; const safeOptions = options || { conversationLinkDomain: null, transcriptsBySession: new Map(), timeFilterDays: DEFAULT_CONVERSATION_TIME_FILTER_DAYS, }; const dedupMap = new Map(); safeRecords.forEach((record) => { const normalized = normalizeKeys(record); const conversationDate = stringValue(normalized["conversationdate"] ?? normalized["timestamp__c"]); const userName = sanitizePlaceholderValue(stringValue(normalized["username"] ?? normalized["userid"])); const rawSessionId = stringValue(normalized["sessionid"] ?? normalized["ssot__aiagentsessionid__c"]); const sessionId = sanitizePlaceholderValue(rawSessionId); const agentApiName = sanitizePlaceholderValue(stringValue(normalized["agentapiname"] ?? normalized["ssot__aiagentapiname__c"])); const conversationId = sanitizePlaceholderValue(stringValue(normalized["conversationid"] ?? normalized["generationgroupid__c"])); const baseUserUtterance = stringValue(normalized["userutterance"] ?? normalized["tagvalue__c"]); const baseAgentResponse = stringValue(normalized["agentresponse"] ?? normalized["responsetext"]); const feedbackValueRaw = sanitizePlaceholderValue(stringValue(normalized["feedbacksentiment"] ?? normalized["feedback__c"])); const feedbackValue = feedbackValueRaw ? feedbackValueRaw.toUpperCase() : ''; const feedbackMessage = sanitizePlaceholderValue(stringValue(normalized["feedbackmessage"] ?? normalized["feedbacktext__c"])); const transcript = sessionId ? safeOptions.transcriptsBySession.get(sessionId) || '' : ''; const transcriptUserUtterance = extractSpeakerSegment(transcript, 'USER'); const transcriptAgentResponse = extractSpeakerSegment(transcript, 'AGENT'); const resolvedUserUtterance = pickFirstMeaningfulValue([baseUserUtterance, transcriptUserUtterance]); const resolvedAgentResponse = pickFirstMeaningfulValue([baseAgentResponse, transcriptAgentResponse]); const conversation = transcript || buildConversation(resolvedUserUtterance, resolvedAgentResponse); const conversationUrl = buildConversationUrl({ domain: safeOptions.conversationLinkDomain, conversationId, sessionId, agentApiName, timeFilterDays: safeOptions.timeFilterDays, }); if (!conversationId || !conversationUrl) { return; } const exportRecord = { "User": userName, "DateTime": conversationDate, "Conversation transcript": conversation, "Feedback": feedbackValue, "Feedback message": feedbackMessage, "ConversationId": conversationId, "ConversationUrl": conversationUrl, }; const dedupKey = sessionId || conversationId || conversationUrl; const existing = dedupMap.get(dedupKey); if (!existing || isNewer(conversationDate, existing.conversationDate)) { dedupMap.set(dedupKey, { record: exportRecord, conversationDate }); } }); return Array.from(dedupMap.values()).map((entry) => entry.record); } function isNewer(candidateDate, existingDate) { if (!candidateDate) { return false; } if (!existingDate) { return true; } const candidateTime = Date.parse(candidateDate); const existingTime = Date.parse(existingDate); if (Number.isNaN(candidateTime)) { return false; } if (Number.isNaN(existingTime)) { return true; } return candidateTime >= existingTime; } function computeConversationStats(records) { let withFeedback = 0; let feedbackBad = 0; let feedbackGood = 0; records.forEach((record) => { const feedback = (record["Feedback"] || "").trim().toUpperCase(); if (feedback) { withFeedback += 1; if (feedback === "BAD") { feedbackBad += 1; } else if (feedback === "GOOD") { feedbackGood += 1; } } }); return { totalCount: records.length, withFeedback, feedbackBad, feedbackGood, }; } function buildConversationNotificationText(stats, filters) { const lines = [ `Agentforce conversations exported: ${stats.totalCount} (with feedback: ${stats.withFeedback}, GOOD: ${stats.feedbackGood}, BAD: ${stats.feedbackBad}).`, ]; if (filters.dateFrom && filters.dateTo) { lines.push(`Window: ${filters.dateFrom} → ${filters.dateTo}`); } else if (filters.dateFrom) { lines.push(`Window starting ${filters.dateFrom}`); } else if (filters.dateTo) { lines.push(`Window until ${filters.dateTo}`); } return lines.join('\n'); } function collectConversationReportFiles(outputFilesRes) { const files = []; if (outputFilesRes?.xlsxFile) { files.push(outputFilesRes.xlsxFile); } return files; } function buildExcludedConversationFilter() { const excludedConversationIds = resolveExcludedConversationIds(); if (!excludedConversationIds.length) { return ''; } return ` AND gar.generationGroupId__c NOT IN (${excludedConversationIds.map((id) => `'${id}'`).join(', ')})`; } //# sourceMappingURL=agentforce-conversations.js.map