Files
livedash-node/lib/metrics.ts

590 lines
13 KiB
TypeScript

// Functions to calculate metrics over sessions
import {
ChatSession,
DayMetrics,
CategoryMetrics,
LanguageMetrics,
CountryMetrics, // Added CountryMetrics
MetricsResult,
WordCloudWord, // Added WordCloudWord
} from "./types";
interface CompanyConfig {
sentimentAlert?: number;
}
// Helper function to calculate trend percentages
function calculateTrendPercentage(current: number, previous: number): number {
if (previous === 0) return 0; // Avoid division by zero
return ((current - previous) / previous) * 100;
}
// Mock data for previous period - in a real app, this would come from database
const mockPreviousPeriodData = {
totalSessions: 120,
uniqueUsers: 85,
avgSessionLength: 240, // in seconds
avgResponseTime: 1.7, // in seconds
};
// List of common stop words - this can be expanded
const stopWords = new Set([
"assistant",
"user",
// Web
"bmp",
"co",
"com",
"css",
"gif",
"href",
"html",
"http",
"https",
"io",
"jpeg",
"jpg",
"js",
"json",
"net",
"org",
"php",
"png",
"svg",
"txt",
"www",
"www2",
"xml",
// English stop words
"a",
"about",
"above",
"after",
"again",
"against",
"ain",
"all",
"am",
"an",
"any",
"are",
"aren",
"at",
"be",
"been",
"before",
"being",
"below",
"between",
"both",
"by",
"bye",
"can",
"could",
"couldn",
"d",
"did",
"didn",
"do",
"does",
"doesn",
"don",
"down",
"during",
"each",
"few",
"for",
"from",
"further",
"goodbye",
"had",
"hadn",
"has",
"hasn",
"have",
"haven",
"he",
"hello",
"her",
"here",
"hers",
"hi",
"him",
"his",
"how",
"i",
"in",
"into",
"is",
"isn",
"it",
"its",
"just",
"ll",
"m",
"ma",
"may",
"me",
"might",
"mightn",
"mine",
"more",
"most",
"must",
"mustn",
"my",
"needn",
"no",
"nor",
"not",
"now",
"o",
"of",
"off",
"ok",
"okay",
"on",
"once",
"only",
"other",
"our",
"ours",
"out",
"over",
"own",
"please",
"re",
"s",
"same",
"shan",
"she",
"should",
"shouldn",
"shouldve",
"so",
"some",
"such",
"t",
"than",
"thank",
"thanks",
"the",
"their",
"theirs",
"them",
"then",
"there",
"they",
"through",
"to",
"too",
"under",
"up",
"us",
"ve",
"very",
"was",
"wasn",
"we",
"were",
"weren",
"when",
"where",
"why",
"will",
"with",
"won",
"would",
"wouldn",
"y",
"yeah",
"yes",
"you",
"your",
"yours",
// French stop words
"des",
"donc",
"et",
"la",
"le",
"les",
"mais",
"ou",
"un",
"une",
// Dutch stop words
"aan",
"al",
"alhoewel",
"als",
"anders",
"behalve",
"ben",
"ben",
"bent",
"bij",
"binnen",
"boven",
"buiten",
"dan",
"dankzij",
"dat",
"de",
"deze",
"die",
"dit",
"dit",
"door",
"dus",
"echter",
"een",
"elk",
"en",
"geen",
"gehad",
"geweest",
"geworden",
"had",
"hadden",
"heb",
"hebben",
"hebt",
"heeft",
"het",
"hij",
"hoe",
"hoewel",
"ieder",
"iets",
"ik",
"jij",
"jullie",
"kan",
"kon",
"konden",
"kunnen",
"kunt",
"maar",
"meer",
"meest",
"met",
"mits",
"naar",
"niet",
"niets",
"nog",
"nu",
"of",
"om",
"omdat",
"ondanks",
"onder",
"ook",
"over",
"sinds",
"sommige",
"tegen",
"tenzij",
"tijdens",
"toch",
"tot",
"tussen",
"uit",
"van",
"veel",
"volgens",
"voor",
"waar",
"waarom",
"wanneer",
"want",
"waren",
"was",
"wat",
"weinig",
"wel",
"welke",
"werd",
"werden",
"wie",
"wij",
"worden",
"wordt",
"zal",
"zij",
"zijn",
"zonder",
"zullen",
"zult",
// Add more domain-specific stop words if necessary
]);
export function sessionMetrics(
sessions: ChatSession[],
companyConfig: CompanyConfig = {}
): MetricsResult {
const totalSessions = sessions.length; // Renamed from 'total' for clarity
const byDay: DayMetrics = {};
const byCategory: CategoryMetrics = {};
const byLanguage: LanguageMetrics = {};
const byCountry: CountryMetrics = {};
const tokensByDay: DayMetrics = {};
const tokensCostByDay: DayMetrics = {};
let escalatedCount = 0; // Renamed from 'escalated' to match MetricsResult
let forwardedHrCount = 0; // Renamed from 'forwarded' to match MetricsResult
// Variables for calculations
const uniqueUserIds = new Set<string>();
let totalSessionDuration = 0;
let validSessionsForDuration = 0;
let totalResponseTime = 0;
let validSessionsForResponseTime = 0;
let sentimentPositiveCount = 0;
let sentimentNeutralCount = 0;
let sentimentNegativeCount = 0;
let totalTokens = 0;
let totalTokensEur = 0;
const wordCounts: { [key: string]: number } = {};
let alerts = 0;
for (const session of sessions) {
// Unique Users: Prefer non-empty ipAddress, fallback to non-empty sessionId
let identifierAdded = false;
if (session.ipAddress && session.ipAddress.trim() !== "") {
uniqueUserIds.add(session.ipAddress.trim());
identifierAdded = true;
}
// Fallback to sessionId only if ipAddress was not usable and sessionId is valid
if (
!identifierAdded &&
session.sessionId &&
session.sessionId.trim() !== ""
) {
uniqueUserIds.add(session.sessionId.trim());
}
// Avg. Session Time
if (session.startTime && session.endTime) {
const startTimeMs = new Date(session.startTime).getTime();
const endTimeMs = new Date(session.endTime).getTime();
if (isNaN(startTimeMs)) {
console.warn(
`[metrics] Invalid startTime for session ${session.id || session.sessionId}: ${session.startTime}`
);
}
if (isNaN(endTimeMs)) {
console.warn(
`[metrics] Invalid endTime for session ${session.id || session.sessionId}: ${session.endTime}`
);
}
if (!isNaN(startTimeMs) && !isNaN(endTimeMs)) {
const timeDifference = endTimeMs - startTimeMs; // Calculate the signed delta
// Use the absolute difference for duration, ensuring it's not negative.
// If times are identical, duration will be 0.
// If endTime is before startTime, this still yields a positive duration representing the magnitude of the difference.
const duration = Math.abs(timeDifference);
// console.log(
// `[metrics] duration is ${duration} for session ${session.id || session.sessionId}`
// );
totalSessionDuration += duration; // Add this duration
if (timeDifference < 0) {
// Log a specific warning if the original endTime was before startTime
console.warn(
`[metrics] endTime (${session.endTime}) was before startTime (${session.startTime}) for session ${session.id || session.sessionId}. Using absolute difference as duration (${(duration / 1000).toFixed(2)} seconds).`
);
} else if (timeDifference === 0) {
// // Optionally, log if times are identical, though this might be verbose if common
// console.log(
// `[metrics] startTime and endTime are identical for session ${session.id || session.sessionId}. Duration is 0.`
// );
}
// If timeDifference > 0, it's a normal positive duration, no special logging needed here for that case.
validSessionsForDuration++; // Count this session for averaging
}
} else {
if (!session.startTime) {
console.warn(
`[metrics] Missing startTime for session ${session.id || session.sessionId}`
);
}
if (!session.endTime) {
// This is a common case for ongoing sessions, might not always be an error
console.log(
`[metrics] Missing endTime for session ${session.id || session.sessionId} - likely ongoing or data issue.`
);
}
}
// Avg. Response Time
if (
session.avgResponseTime !== undefined &&
session.avgResponseTime !== null &&
session.avgResponseTime >= 0
) {
totalResponseTime += session.avgResponseTime;
validSessionsForResponseTime++;
}
// Escalated and Forwarded
if (session.escalated) escalatedCount++;
if (session.forwardedHr) forwardedHrCount++;
// Sentiment
if (session.sentiment !== undefined && session.sentiment !== null) {
// Example thresholds, adjust as needed
if (session.sentiment > 0.3) sentimentPositiveCount++;
else if (session.sentiment < -0.3) sentimentNegativeCount++;
else sentimentNeutralCount++;
}
// Sentiment Alert Check
if (
companyConfig.sentimentAlert !== undefined &&
session.sentiment !== undefined &&
session.sentiment !== null &&
session.sentiment < companyConfig.sentimentAlert
) {
alerts++;
}
// Tokens
if (session.tokens !== undefined && session.tokens !== null) {
totalTokens += session.tokens;
}
if (session.tokensEur !== undefined && session.tokensEur !== null) {
totalTokensEur += session.tokensEur;
}
// Daily metrics
const day = new Date(session.startTime).toISOString().split("T")[0];
byDay[day] = (byDay[day] || 0) + 1; // Sessions per day
if (session.tokens !== undefined && session.tokens !== null) {
tokensByDay[day] = (tokensByDay[day] || 0) + session.tokens;
}
if (session.tokensEur !== undefined && session.tokensEur !== null) {
tokensCostByDay[day] = (tokensCostByDay[day] || 0) + session.tokensEur;
}
// Category metrics
if (session.category) {
byCategory[session.category] = (byCategory[session.category] || 0) + 1;
}
// Language metrics
if (session.language) {
byLanguage[session.language] = (byLanguage[session.language] || 0) + 1;
}
// Country metrics
if (session.country) {
byCountry[session.country] = (byCountry[session.country] || 0) + 1;
}
// Word Cloud Data (from initial message and transcript content)
const processTextForWordCloud = (text: string | undefined | null) => {
if (!text) return;
const words = text
.toLowerCase()
.replace(/[^\w\s'-]/gi, "")
.split(/\s+/); // Keep apostrophes and hyphens
for (const word of words) {
const cleanedWord = word.replace(/^['-]|['-]$/g, ""); // Remove leading/trailing apostrophes/hyphens
if (
cleanedWord &&
!stopWords.has(cleanedWord) &&
cleanedWord.length > 2
) {
wordCounts[cleanedWord] = (wordCounts[cleanedWord] || 0) + 1;
}
}
};
processTextForWordCloud(session.initialMsg);
processTextForWordCloud(session.transcriptContent);
}
const uniqueUsers = uniqueUserIds.size;
const avgSessionLength =
validSessionsForDuration > 0
? totalSessionDuration / validSessionsForDuration / 1000 // Convert ms to minutes
: 0;
const avgResponseTime =
validSessionsForResponseTime > 0
? totalResponseTime / validSessionsForResponseTime
: 0; // in seconds
const wordCloudData: WordCloudWord[] = Object.entries(wordCounts)
.sort(([, a], [, b]) => b - a)
.slice(0, 50) // Top 50 words
.map(([text, value]) => ({ text, value }));
// Calculate avgSessionsPerDay
const numDaysWithSessions = Object.keys(byDay).length;
const avgSessionsPerDay =
numDaysWithSessions > 0 ? totalSessions / numDaysWithSessions : 0;
// Calculate trends
const totalSessionsTrend = calculateTrendPercentage(
totalSessions,
mockPreviousPeriodData.totalSessions
);
const uniqueUsersTrend = calculateTrendPercentage(
uniqueUsers,
mockPreviousPeriodData.uniqueUsers
);
const avgSessionLengthTrend = calculateTrendPercentage(
avgSessionLength,
mockPreviousPeriodData.avgSessionLength
);
const avgResponseTimeTrend = calculateTrendPercentage(
avgResponseTime,
mockPreviousPeriodData.avgResponseTime
);
// console.log("Debug metrics calculation:", {
// totalSessionDuration,
// validSessionsForDuration,
// calculatedAvgSessionLength: avgSessionLength,
// });
return {
totalSessions,
uniqueUsers,
avgSessionLength, // Corrected to match MetricsResult interface
avgResponseTime, // Corrected to match MetricsResult interface
escalatedCount,
forwardedCount: forwardedHrCount, // Corrected to match MetricsResult interface (forwardedCount)
sentimentPositiveCount,
sentimentNeutralCount,
sentimentNegativeCount,
days: byDay, // Corrected to match MetricsResult interface (days)
categories: byCategory, // Corrected to match MetricsResult interface (categories)
languages: byLanguage, // Corrected to match MetricsResult interface (languages)
countries: byCountry, // Corrected to match MetricsResult interface (countries)
tokensByDay,
tokensCostByDay,
totalTokens,
totalTokensEur,
wordCloudData,
belowThresholdCount: alerts, // Corrected to match MetricsResult interface (belowThresholdCount)
avgSessionsPerDay, // Added to satisfy MetricsResult interface
// Map trend values to the expected property names in MetricsResult
sessionTrend: totalSessionsTrend,
usersTrend: uniqueUsersTrend,
avgSessionTimeTrend: avgSessionLengthTrend,
// For response time, a negative trend is actually positive (faster responses are better)
avgResponseTimeTrend: -avgResponseTimeTrend, // Invert as lower response time is better
// Additional fields
sentimentThreshold: companyConfig.sentimentAlert,
lastUpdated: Date.now(),
totalSessionDuration,
validSessionsForDuration,
};
}