Add attention inbox and AI usage observability
This commit is contained in:
1 parent
ca59ba0f1f
commit
83b5f1f5b6
30 files changed
+702
-28
No files matched your search
@@ -0,0 +1,32 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { AiUsageRepository } from '../src/database/ai-usage-repository.js';
|
||||
import { createTestDb } from './testUtils.js';
|
||||
|
||||
describe('AiUsageRepository', () => {
|
||||
it('aggregates cost, tokens, outcomes, and latency by actual model', () => {
|
||||
const db = createTestDb();
|
||||
const repo = new AiUsageRepository(db);
|
||||
const now = Date.parse('2026-08-09T10:00:00Z');
|
||||
repo.record({ operation: 'generateReply', requestedModel: 'router', actualModel: 'model-a', outcome: 'success', promptTokens: 100, completionTokens: 20, totalTokens: 120, costUsd: 0.0012, latencyMs: 400, createdAt: now });
|
||||
repo.record({ operation: 'generateReply', requestedModel: 'model-a', actualModel: 'model-a', outcome: 'refusal', promptTokens: 40, completionTokens: 5, totalTokens: 45, costUsd: 0.0003, latencyMs: 200, createdAt: now });
|
||||
repo.record({ operation: 'analyzeImage', requestedModel: 'model-b', outcome: 'error', latencyMs: 900, error: 'provider unavailable', createdAt: now });
|
||||
|
||||
expect(repo.getSummary(30, now)).toEqual({
|
||||
days: 30,
|
||||
requests: 3,
|
||||
successes: 1,
|
||||
refusals: 1,
|
||||
errors: 1,
|
||||
promptTokens: 140,
|
||||
completionTokens: 25,
|
||||
totalTokens: 165,
|
||||
costUsd: 0.0015,
|
||||
averageLatencyMs: 500,
|
||||
models: [
|
||||
{ model: 'model-a', requests: 2, successes: 1, refusals: 1, errors: 0, promptTokens: 140, completionTokens: 25, totalTokens: 165, costUsd: 0.0015, averageLatencyMs: 300 },
|
||||
{ model: 'model-b', requests: 1, successes: 0, refusals: 0, errors: 1, promptTokens: 0, completionTokens: 0, totalTokens: 0, costUsd: 0, averageLatencyMs: 900 },
|
||||
],
|
||||
});
|
||||
db.close();
|
||||
});
|
||||
});
|
||||
@@ -79,7 +79,7 @@ describe('dashboard foundation', () => {
|
||||
const { router } = renderDashboard('/');
|
||||
|
||||
const navigation = screen.getByRole('navigation', { name: 'Primary navigation' });
|
||||
for (const label of ['Overview', 'Conversations', 'Media', 'Performance', 'Operations', 'Settings']) {
|
||||
for (const label of ['Overview', 'Needs attention', 'Conversations', 'Media', 'Performance', 'Operations', 'Settings']) {
|
||||
expect(within(navigation).getByRole('link', { name: label })).toBeInTheDocument();
|
||||
}
|
||||
|
||||
@@ -91,6 +91,24 @@ describe('dashboard foundation', () => {
|
||||
expect(screen.getByRole('heading', { name: 'Operations' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows actionable attention items and links handoffs to their conversation', async () => {
|
||||
installSuccessfulApi();
|
||||
server.use(http.get('/api/attention', () => HttpResponse.json({
|
||||
generatedAt: Date.parse('2026-08-09T08:00:00Z'),
|
||||
items: [{
|
||||
id: 'conversation:one', kind: 'human-required', severity: 'warning',
|
||||
title: 'Human follow-up required for u/alice', detail: 'Automated turns are exhausted.',
|
||||
occurredAt: Date.parse('2026-08-09T07:00:00Z'),
|
||||
owner: { accountId: '@one:reddit.com', redditUsername: 'operator' },
|
||||
conversationId: '@one:reddit.com~!room:reddit.com', username: 'alice',
|
||||
}],
|
||||
})));
|
||||
renderDashboard('/attention');
|
||||
expect(await screen.findByRole('heading', { name: 'Needs attention' })).toBeInTheDocument();
|
||||
expect(screen.getByText('Human follow-up required for u/alice')).toBeInTheDocument();
|
||||
expect(screen.getByRole('link', { name: 'Open conversation' })).toHaveAttribute('href', '/conversations/%40one%3Areddit.com~!room%3Areddit.com');
|
||||
});
|
||||
|
||||
it('hydrates media filters from the routed URL', async () => {
|
||||
installSuccessfulApi();
|
||||
server.use(
|
||||
|
||||
@@ -83,12 +83,24 @@ describe('PerformancePage', () => {
|
||||
upsell: 3,
|
||||
images: 2,
|
||||
})),
|
||||
http.get('/api/analytics/ai-usage', ({ request }) => {
|
||||
expect(new URL(request.url).searchParams.get('days')).toBe('30');
|
||||
return HttpResponse.json({
|
||||
days: 30, requests: 12, successes: 9, refusals: 2, errors: 1,
|
||||
promptTokens: 1200, completionTokens: 300, totalTokens: 1500,
|
||||
costUsd: 0.0123, averageLatencyMs: 420,
|
||||
models: [{ model: 'model-a', requests: 12, successes: 9, refusals: 2, errors: 1, promptTokens: 1200, completionTokens: 300, totalTokens: 1500, costUsd: 0.0123, averageLatencyMs: 420 }],
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
renderPage(<PerformancePage />);
|
||||
|
||||
expect(await screen.findByRole('heading', { name: 'Performance' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('heading', { name: 'Outcome funnel' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('heading', { name: 'AI cost and reliability' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('table', { name: 'AI usage by model' })).toHaveTextContent('model-a');
|
||||
expect(screen.getByLabelText('AI usage totals')).toHaveTextContent('$0.0123');
|
||||
expect(screen.getByText('5 awaiting maturity')).toBeInTheDocument();
|
||||
const subredditTable = screen.getByRole('table', { name: 'Subreddit performance' });
|
||||
expect(within(subredditTable).getByText('n = 40')).toBeInTheDocument();
|
||||
|
||||
@@ -107,7 +107,7 @@ describe('Database Client', () => {
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'")
|
||||
.all() as { name: string }[];
|
||||
// Existing domain/config tables plus account, conversation, sync and media ownership stores.
|
||||
expect(tables.length).toBe(15);
|
||||
expect(tables.length).toBe(16);
|
||||
});
|
||||
|
||||
it('tracks schema version and migrates a legacy sent_messages table transactionally', () => {
|
||||
@@ -154,8 +154,9 @@ describe('Database Client', () => {
|
||||
db.exec(`
|
||||
DELETE FROM recipient_suppressions;
|
||||
DROP TABLE conversation_automation_claims;
|
||||
DROP TABLE ai_usage;
|
||||
`);
|
||||
db.pragma(`user_version = ${latestVersion - 1}`);
|
||||
db.pragma(`user_version = ${latestVersion - 2}`);
|
||||
runMigrations(db);
|
||||
|
||||
expect(db.prepare('SELECT recipient, reason FROM recipient_suppressions ORDER BY recipient COLLATE NOCASE').all())
|
||||
|
||||
@@ -703,6 +703,36 @@ describe('OpenRouterClient', () => {
|
||||
).rejects.toThrow('network');
|
||||
});
|
||||
});
|
||||
|
||||
it('records model, token, cost, latency, and outcome for each request attempt', async () => {
|
||||
const usageRecorder = vi.fn();
|
||||
const config = getTestConfig();
|
||||
const localClient = new OpenRouterClient(
|
||||
config,
|
||||
logger,
|
||||
{ chat: { completions: { create: mockCreate } } } as unknown as OpenAI,
|
||||
usageRecorder,
|
||||
);
|
||||
mockCreate.mockResolvedValueOnce({
|
||||
model: 'provider/model-a',
|
||||
choices: [{ message: { content: 'A useful reply' } }],
|
||||
usage: { prompt_tokens: 100, completion_tokens: 12, total_tokens: 112, cost: 0.0017 },
|
||||
});
|
||||
|
||||
await localClient.generateReply([{ sender: 'them', text: 'hello?', isSelf: false }], 'them');
|
||||
|
||||
expect(usageRecorder).toHaveBeenCalledWith(expect.objectContaining({
|
||||
operation: 'generateReply',
|
||||
requestedModel: config.openrouter.chatModel,
|
||||
actualModel: 'provider/model-a',
|
||||
outcome: 'success',
|
||||
promptTokens: 100,
|
||||
completionTokens: 12,
|
||||
totalTokens: 112,
|
||||
costUsd: 0.0017,
|
||||
latencyMs: expect.any(Number),
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
describe('stripDashes', () => {
|
||||
|
||||
@@ -70,4 +70,17 @@ describe('DeliveryOutboxRepository', () => {
|
||||
expect(outbox.markSent(delivery.id, 'worker-a', sentMessageId, 130)).toBe(true);
|
||||
expect(outbox.get(delivery.id)).toMatchObject({ status: 'sent', sentMessageId, leaseOwner: null });
|
||||
});
|
||||
|
||||
it('surfaces failed deliveries for the attention inbox', () => {
|
||||
const delivery = outbox.discover({
|
||||
postId: 'post-1', recipient: 'recipient', subject: 'subject', message: 'message', idempotencyKey: 'post-1:v1', now: 100,
|
||||
});
|
||||
outbox.markPrepared(delivery.id, 110);
|
||||
outbox.claim(delivery.id, 'worker-a', 50, 120);
|
||||
outbox.markFailed(delivery.id, 'worker-a', 'Reddit rejected the invitation', 130);
|
||||
|
||||
expect(outbox.getFailed()).toEqual([
|
||||
expect.objectContaining({ id: delivery.id, status: 'failed', lastError: 'Reddit rejected the invitation' }),
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -607,6 +607,21 @@ describe('ConversationStateRepository', () => {
|
||||
expect(repo.get('!room:reddit.com')?.persona.age).toBe(34);
|
||||
expect(repo.get('missing')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('lists only durable boundary and human-handoff states for operator attention', () => {
|
||||
const db = createTestDb();
|
||||
const repo = new ConversationStateRepository(db);
|
||||
const active = buildConversationState([]);
|
||||
const boundary = buildConversationState([{ sender: 'them', text: 'Stop messaging me.', isSelf: false }]);
|
||||
boundary.automation.status = 'boundary';
|
||||
boundary.automation.handoffReason = 'Recipient asked us to stop.';
|
||||
repo.save('active-room', active);
|
||||
repo.save('boundary-room', boundary);
|
||||
|
||||
expect(repo.listAttention()).toEqual([
|
||||
expect.objectContaining({ conversationId: 'boundary-room', status: 'boundary', reason: 'Recipient asked us to stop.' }),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Mature performance and guardrails', () => {
|
||||
|
||||
Reference in new issue
Block a user