/** * 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, });