154 lines
5.2 KiB
JavaScript
154 lines
5.2 KiB
JavaScript
/**
|
|
* What the stored conversations add up to.
|
|
*
|
|
* Pure selectors over the archive `history.js` returns: no reading, no React,
|
|
* no knowledge of who is asking. Both the Insights view and the Conversation
|
|
* Reviews view read through here, so a figure quoted in one and a list shown in
|
|
* the other cannot describe different things.
|
|
*
|
|
* **Nothing is invented.** Every counter is derived from records that exist,
|
|
* and a workspace with no conversations returns `empty: true` rather than a row
|
|
* of zeros. A zero and an absence look identical on a dashboard and mean
|
|
* completely different things — one says the agent was asked and did nothing,
|
|
* the other says it has not been asked. The empty flag is what lets the view
|
|
* say which.
|
|
*/
|
|
|
|
const DAY = 24 * 60 * 60 * 1000;
|
|
|
|
const at = (record) => new Date(record?.updatedAt || 0).getTime();
|
|
|
|
/** Records for one agent, or all of them when no agent is named. */
|
|
export function conversationsForAgent(records = [], agentId = null) {
|
|
if (!agentId) return [...records];
|
|
return records.filter((record) => record.agentId === agentId);
|
|
}
|
|
|
|
/** Conversations nobody has rated yet — the queue a reviewer works through. */
|
|
export const unratedConversations = (records = [], agentId = null) =>
|
|
conversationsForAgent(records, agentId).filter((record) => !record.feedback);
|
|
|
|
/** Counts by a key each record contributes many of. */
|
|
function tally(records, pick) {
|
|
const counts = new Map();
|
|
for (const record of records) {
|
|
for (const value of pick(record) || []) {
|
|
if (!value) continue;
|
|
counts.set(value, (counts.get(value) || 0) + 1);
|
|
}
|
|
}
|
|
return [...counts.entries()]
|
|
.map(([id, count]) => ({ id, count }))
|
|
.sort((a, b) => b.count - a.count || a.id.localeCompare(b.id));
|
|
}
|
|
|
|
/** Counts by a key each record contributes one of. */
|
|
function tallyOne(records, pick) {
|
|
const counts = new Map();
|
|
for (const record of records) {
|
|
const value = pick(record);
|
|
if (!value) continue;
|
|
counts.set(value, (counts.get(value) || 0) + 1);
|
|
}
|
|
return [...counts.entries()]
|
|
.map(([id, count]) => ({ id, count }))
|
|
.sort((a, b) => b.count - a.count || a.id.localeCompare(b.id));
|
|
}
|
|
|
|
/**
|
|
* The figures an Insights view reports.
|
|
*
|
|
* `since` windows the archive; `agentId` narrows it to one agent. Both are
|
|
* optional, and neither invents a record that is not there.
|
|
*/
|
|
export function conversationStats(records = [], { agentId = null, since = null } = {}) {
|
|
const scoped = conversationsForAgent(records, agentId)
|
|
.filter((record) => (since ? at(record) >= new Date(since).getTime() : true));
|
|
|
|
if (!scoped.length) {
|
|
return {
|
|
empty: true,
|
|
total: 0,
|
|
turns: 0,
|
|
averageTurns: 0,
|
|
pages: 0,
|
|
days: 0,
|
|
activeDays: 0,
|
|
feedback: { up: 0, down: 0, unrated: 0, score: null },
|
|
byAgent: [],
|
|
byPage: [],
|
|
bySkill: [],
|
|
byTool: [],
|
|
byDay: [],
|
|
};
|
|
}
|
|
|
|
const turns = scoped.reduce((sum, record) => sum + (record.turns || 0), 0);
|
|
|
|
const up = scoped.filter((r) => r.feedback?.rating === 'up').length;
|
|
const down = scoped.filter((r) => r.feedback?.rating === 'down').length;
|
|
const rated = up + down;
|
|
|
|
/* Conversations per day, oldest first, over the days that actually have
|
|
one. Padding out empty days would draw a chart mostly made of zeros and
|
|
make a quiet week look like an outage. */
|
|
const perDay = new Map();
|
|
for (const record of scoped) {
|
|
const day = new Date(at(record));
|
|
day.setHours(0, 0, 0, 0);
|
|
const key = day.toISOString().slice(0, 10);
|
|
perDay.set(key, (perDay.get(key) || 0) + 1);
|
|
}
|
|
const byDay = [...perDay.entries()]
|
|
.map(([day, count]) => ({ id: day, day, count }))
|
|
.sort((a, b) => a.day.localeCompare(b.day));
|
|
|
|
const span = scoped.length
|
|
? Math.max(1, Math.round((Math.max(...scoped.map(at)) - Math.min(...scoped.map(at))) / DAY) + 1)
|
|
: 0;
|
|
|
|
return {
|
|
empty: false,
|
|
total: scoped.length,
|
|
turns,
|
|
averageTurns: Math.round((turns / scoped.length) * 10) / 10,
|
|
pages: new Set(scoped.map((r) => r.contextId).filter(Boolean)).size,
|
|
/* Days the archive spans, and days anything was actually asked. Reporting
|
|
only the first would make a busy afternoon look like a busy fortnight. */
|
|
days: span,
|
|
activeDays: byDay.length,
|
|
feedback: {
|
|
up,
|
|
down,
|
|
unrated: scoped.length - rated,
|
|
/* Null rather than 0 when nothing is rated: a score of zero reads as
|
|
unanimous disapproval. */
|
|
score: rated ? Math.round((up / rated) * 100) : null,
|
|
},
|
|
byAgent: tallyOne(scoped, (r) => r.agentId),
|
|
byPage: tallyOne(scoped, (r) => r.pageContext?.page || r.page),
|
|
bySkill: tally(scoped, (r) => r.skillsUsed),
|
|
byTool: tally(scoped, (r) => r.toolsUsed),
|
|
byDay,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* One conversation, reduced to what a review list shows.
|
|
*
|
|
* The thread itself is deliberately not included: a list renders forty of these
|
|
* and only one is ever opened.
|
|
*/
|
|
export const reviewRow = (record) => ({
|
|
id: record.id,
|
|
title: record.title,
|
|
agentId: record.agentId ?? null,
|
|
page: record.pageContext?.page || record.page || '',
|
|
route: record.pageContext?.route ?? null,
|
|
turns: record.turns || 0,
|
|
skillsUsed: record.skillsUsed || [],
|
|
toolsUsed: record.toolsUsed || [],
|
|
feedback: record.feedback ?? null,
|
|
updatedAt: record.updatedAt,
|
|
});
|