Files
livedash-node/lib/metrics.ts
Kaj Kowalski efb5261c7d Refactor components and enhance metrics calculations:
- Update access denied messages to use HTML entities.
- Add autoComplete attributes to forms for better user experience.
- Improve trend calculations in sessionMetrics function.
- Update MetricCard props to accept React nodes for icons.
- Integrate Next.js Image component in Sidebar for optimization.
- Adjust ESLint rules for better code quality.
- Add new properties for trends in MetricsResult interface.
- Bump version to 0.2.0 in package.json.
2025-05-22 14:44:28 +02:00

536 lines
10 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;
}
// List of common stop words - this can be expanded
const stopWords = new Set([
"assistant",
"user",
// Web
"com",
"www",
"http",
"https",
"www2",
"href",
"html",
"php",
"js",
"css",
"xml",
"json",
"txt",
"jpg",
"jpeg",
"png",
"gif",
"bmp",
"svg",
"org",
"net",
"co",
"io",
// English stop words
"a",
"an",
"the",
"is",
"are",
"was",
"were",
"be",
"been",
"being",
"have",
"has",
"had",
"do",
"does",
"did",
"will",
"would",
"should",
"can",
"could",
"may",
"might",
"must",
"am",
"i",
"you",
"he",
"she",
"it",
"we",
"they",
"me",
"him",
"her",
"us",
"them",
"my",
"your",
"his",
"its",
"our",
"their",
"mine",
"yours",
"hers",
"ours",
"theirs",
"to",
"of",
"in",
"on",
"at",
"by",
"for",
"with",
"about",
"against",
"between",
"into",
"through",
"during",
"before",
"after",
"above",
"below",
"from",
"up",
"down",
"out",
"off",
"over",
"under",
"again",
"further",
"then",
"once",
"here",
"there",
"when",
"where",
"why",
"how",
"all",
"any",
"both",
"each",
"few",
"more",
"most",
"other",
"some",
"such",
"no",
"nor",
"not",
"only",
"own",
"same",
"so",
"than",
"too",
"very",
"s",
"t",
"just",
"don",
"shouldve",
"now",
"d",
"ll",
"m",
"o",
"re",
"ve",
"y",
"ain",
"aren",
"couldn",
"didn",
"doesn",
"hadn",
"hasn",
"haven",
"isn",
"ma",
"mightn",
"mustn",
"needn",
"shan",
"shouldn",
"wasn",
"weren",
"won",
"wouldn",
"hi",
"hello",
"thanks",
"thank",
"please",
"ok",
"okay",
"yes",
"yeah",
"bye",
"goodbye",
// French stop words
"la",
"le",
"les",
"un",
"une",
"des",
"et",
"ou",
"mais",
"donc",
// Dutch stop words
"dit",
"ben",
"de",
"het",
"ik",
"jij",
"hij",
"zij",
"wij",
"jullie",
"deze",
"dit",
"dat",
"die",
"een",
"en",
"of",
"maar",
"want",
"omdat",
"dus",
"als",
"ook",
"dan",
"nu",
"nog",
"al",
"naar",
"voor",
"van",
"door",
"met",
"bij",
"tot",
"om",
"over",
"tussen",
"onder",
"boven",
"tegen",
"aan",
"uit",
"sinds",
"tijdens",
"binnen",
"buiten",
"zonder",
"volgens",
"dankzij",
"ondanks",
"behalve",
"mits",
"tenzij",
"hoewel",
"alhoewel",
"toch",
"anders",
"echter",
"wel",
"niet",
"geen",
"iets",
"niets",
"veel",
"weinig",
"meer",
"meest",
"elk",
"ieder",
"sommige",
"hoe",
"wat",
"waar",
"wie",
"wanneer",
"waarom",
"welke",
"wordt",
"worden",
"werd",
"werden",
"geworden",
"zijn",
"ben",
"bent",
"was",
"waren",
"geweest",
"hebben",
"heb",
"hebt",
"heeft",
"had",
"hadden",
"gehad",
"kunnen",
"kan",
"kunt",
"kon",
"konden",
"zullen",
"zal",
"zult",
// Add more domain-specific stop words if necessary
]);
export function sessionMetrics(
sessions: ChatSession[],
companyConfig: CompanyConfig = {}
): MetricsResult {
const total = sessions.length;
const byDay: DayMetrics = {};
const byCategory: CategoryMetrics = {};
const byLanguage: LanguageMetrics = {};
const byCountry: CountryMetrics = {};
const tokensByDay: DayMetrics = {};
const tokensCostByDay: DayMetrics = {};
let escalated = 0,
forwarded = 0;
let totalSentiment = 0,
sentimentCount = 0;
let totalResponseTimeCurrent = 0, // Renamed to avoid conflict
responseCountCurrent = 0; // Renamed to avoid conflict
let totalTokens = 0,
totalTokensEur = 0;
let sentimentPositive = 0,
sentimentNegative = 0,
sentimentNeutral = 0;
let totalDurationCurrent = 0, // Renamed to avoid conflict
durationCountCurrent = 0; // Renamed to avoid conflict
const wordCounts: { [key: string]: number } = {};
const uniqueUserIdsCurrent = new Set<string>();
let minDateCurrentPeriod = new Date();
if (sessions.length > 0) {
minDateCurrentPeriod = new Date(
Math.min(...sessions.map((s) => s.startTime.getTime()))
);
}
const prevPeriodEndDate = new Date(minDateCurrentPeriod);
prevPeriodEndDate.setDate(prevPeriodEndDate.getDate() - 1);
const prevPeriodStartDate = new Date(prevPeriodEndDate);
prevPeriodStartDate.setDate(prevPeriodStartDate.getDate() - 6); // 7-day previous period
let prevPeriodSessionsCount = 0;
const prevPeriodUniqueUserIds = new Set<string>();
let prevPeriodTotalDuration = 0;
let prevPeriodDurationCount = 0;
let prevPeriodTotalResponseTime = 0;
let prevPeriodResponseCount = 0;
sessions.forEach((s) => {
const sessionDate = s.startTime;
const day = sessionDate.toISOString().slice(0, 10);
// Aggregate current period data
byDay[day] = (byDay[day] || 0) + 1;
if (s.userId) {
uniqueUserIdsCurrent.add(s.userId);
}
if (s.category) byCategory[s.category] = (byCategory[s.category] || 0) + 1;
if (s.language) byLanguage[s.language] = (byLanguage[s.language] || 0) + 1;
if (s.country) byCountry[s.country] = (byCountry[s.country] || 0) + 1;
if (s.tokens) {
tokensByDay[day] = (tokensByDay[day] || 0) + s.tokens;
}
if (s.tokensEur) {
tokensCostByDay[day] = (tokensCostByDay[day] || 0) + s.tokensEur;
}
if (s.endTime) {
const duration =
(s.endTime.getTime() - sessionDate.getTime()) / (1000 * 60); // minutes
const MAX_REASONABLE_DURATION = 24 * 60;
if (duration > 0 && duration < MAX_REASONABLE_DURATION) {
totalDurationCurrent += duration;
durationCountCurrent++;
}
}
if (s.escalated) escalated++;
if (s.forwardedHr) forwarded++;
if (s.sentiment != null) {
totalSentiment += s.sentiment;
sentimentCount++;
if (s.sentiment > 0.3) sentimentPositive++;
else if (s.sentiment < -0.3) sentimentNegative++;
else sentimentNeutral++;
}
if (s.avgResponseTime != null) {
totalResponseTimeCurrent += s.avgResponseTime;
responseCountCurrent++;
}
totalTokens += s.tokens || 0;
totalTokensEur += s.tokensEur || 0;
if (s.transcriptContent) {
const words = s.transcriptContent.toLowerCase().match(/\b\w+\b/g);
if (words) {
words.forEach((word) => {
const cleanedWord = word.replace(/[^a-z0-9]/gi, "");
if (
cleanedWord &&
!stopWords.has(cleanedWord) &&
cleanedWord.length > 2
) {
wordCounts[cleanedWord] = (wordCounts[cleanedWord] || 0) + 1;
}
});
}
}
// Aggregate previous period data (if session falls within the previous period range)
if (
sessionDate >= prevPeriodStartDate &&
sessionDate <= prevPeriodEndDate
) {
prevPeriodSessionsCount++;
if (s.userId) {
prevPeriodUniqueUserIds.add(s.userId);
}
if (s.endTime) {
const duration =
(s.endTime.getTime() - sessionDate.getTime()) / (1000 * 60);
const MAX_REASONABLE_DURATION = 24 * 60;
if (duration > 0 && duration < MAX_REASONABLE_DURATION) {
prevPeriodTotalDuration += duration;
prevPeriodDurationCount++;
}
}
if (s.avgResponseTime != null) {
prevPeriodTotalResponseTime += s.avgResponseTime;
prevPeriodResponseCount++;
}
}
});
const calculateTrend = (current: number, previous: number): number => {
if (previous === 0) {
return current > 0 ? 100 : 0;
}
const trend = ((current - previous) / previous) * 100;
return parseFloat(trend.toFixed(1));
};
const sessionTrend = calculateTrend(total, prevPeriodSessionsCount);
const usersTrend = calculateTrend(
uniqueUserIdsCurrent.size,
prevPeriodUniqueUserIds.size
);
const avgSessionLengthCurrent =
durationCountCurrent > 0 ? totalDurationCurrent / durationCountCurrent : 0;
const avgSessionLengthPrevious =
prevPeriodDurationCount > 0
? prevPeriodTotalDuration / prevPeriodDurationCount
: 0;
const avgSessionTimeTrend = calculateTrend(
avgSessionLengthCurrent,
avgSessionLengthPrevious
);
const avgResponseTimeCurrentPeriod =
responseCountCurrent > 0
? totalResponseTimeCurrent / responseCountCurrent
: 0;
const avgResponseTimePreviousPeriod =
prevPeriodResponseCount > 0
? prevPeriodTotalResponseTime / prevPeriodResponseCount
: 0;
const avgResponseTimeTrend = calculateTrend(
avgResponseTimeCurrentPeriod,
avgResponseTimePreviousPeriod
);
let belowThreshold = 0;
const threshold = companyConfig.sentimentAlert ?? null;
if (threshold != null) {
for (const s of sessions) {
if (s.sentiment != null && s.sentiment < threshold) belowThreshold++;
}
}
const dayCount = Object.keys(byDay).length;
const avgSessionsPerDay = dayCount > 0 ? total / dayCount : 0;
const wordCloudData: WordCloudWord[] = Object.entries(wordCounts)
.map(([text, value]) => ({ text, value }))
.sort((a, b) => b.value - a.value)
.slice(0, 500);
return {
totalSessions: total,
avgSessionsPerDay: parseFloat(avgSessionsPerDay.toFixed(1)),
avgSessionLength: parseFloat(avgSessionLengthCurrent.toFixed(1)),
days: byDay,
languages: byLanguage,
categories: byCategory,
countries: byCountry,
belowThresholdCount: belowThreshold,
escalatedCount: escalated,
forwardedCount: forwarded,
avgSentiment: sentimentCount
? parseFloat((totalSentiment / sentimentCount).toFixed(2))
: undefined,
avgResponseTime: parseFloat(avgResponseTimeCurrentPeriod.toFixed(2)),
totalTokens,
totalTokensEur,
sentimentThreshold: threshold,
lastUpdated: Date.now(),
sentimentPositiveCount: sentimentPositive,
sentimentNeutralCount: sentimentNeutral,
sentimentNegativeCount: sentimentNegative,
tokensByDay,
tokensCostByDay,
wordCloudData,
uniqueUsers: uniqueUserIdsCurrent.size,
sessionTrend,
usersTrend,
avgSessionTimeTrend,
avgResponseTimeTrend,
};
}