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
+96
-22
@@ -1,6 +1,7 @@
|
||||
import OpenAI from 'openai';
|
||||
import type { Config } from '../config/index.js';
|
||||
import type { Logger } from '../utils/logger.js';
|
||||
import type { AiUsageRecordInput, AiUsageOutcome } from '../database/ai-usage-repository.js';
|
||||
import {
|
||||
formatConversationAsUntrustedData,
|
||||
isSafeConversationReply,
|
||||
@@ -29,6 +30,8 @@ interface ProtectedTextPrompt {
|
||||
user: string;
|
||||
}
|
||||
|
||||
export type AiUsageRecorder = (usage: AiUsageRecordInput) => void;
|
||||
|
||||
export class OpenRouterClient {
|
||||
private client: OpenAI;
|
||||
|
||||
@@ -36,6 +39,7 @@ export class OpenRouterClient {
|
||||
private config: Config,
|
||||
private logger: Logger,
|
||||
client?: OpenAI,
|
||||
private readonly usageRecorder?: AiUsageRecorder,
|
||||
) {
|
||||
this.client = client ?? new OpenAI({
|
||||
baseURL: 'https://openrouter.ai/api/v1',
|
||||
@@ -48,6 +52,7 @@ export class OpenRouterClient {
|
||||
}
|
||||
|
||||
async detectGenderOnly(imageBase64: string, mimeType: string): Promise<DetectedGender> {
|
||||
const startedAt = Date.now();
|
||||
try {
|
||||
this.logger.debug('Quick gender detection...');
|
||||
|
||||
@@ -74,12 +79,14 @@ export class OpenRouterClient {
|
||||
});
|
||||
|
||||
const result = response.choices[0]?.message?.content?.toLowerCase().trim() || 'unknown';
|
||||
this.recordUsage('detectGender', this.config.openrouter.imageAnalysisModel, startedAt, response, result ? 'success' : 'empty');
|
||||
|
||||
if (result.includes('female')) return 'female';
|
||||
if (result.includes('male') && !result.includes('female')) return 'male';
|
||||
if (result.includes('other')) return 'other';
|
||||
return 'unknown';
|
||||
} catch (error) {
|
||||
this.recordError('detectGender', this.config.openrouter.imageAnalysisModel, startedAt, error);
|
||||
this.logger.error('Gender detection failed:', error);
|
||||
return 'unknown';
|
||||
}
|
||||
@@ -134,33 +141,40 @@ export class OpenRouterClient {
|
||||
}
|
||||
|
||||
private async tryAnalyzeWithModel(model: string, imageBase64: string, mimeType: string): Promise<ImageAnalysisResult | null> {
|
||||
const startedAt = Date.now();
|
||||
const genderPrompt = `First, identify the gender of the person in this image. Respond with EXACTLY one of these on the first line: GENDER:male, GENDER:female, GENDER:other, or GENDER:unknown
|
||||
|
||||
Then provide your analysis:
|
||||
|
||||
${this.config.openrouter.imageAnalysisPrompt}`;
|
||||
|
||||
const response = await this.client.chat.completions.create({
|
||||
model,
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: genderPrompt,
|
||||
},
|
||||
{
|
||||
type: 'image_url',
|
||||
image_url: {
|
||||
url: `data:${mimeType};base64,${imageBase64}`,
|
||||
let response;
|
||||
try {
|
||||
response = await this.client.chat.completions.create({
|
||||
model,
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: genderPrompt,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
max_tokens: 600,
|
||||
});
|
||||
{
|
||||
type: 'image_url',
|
||||
image_url: {
|
||||
url: `data:${mimeType};base64,${imageBase64}`,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
max_tokens: 600,
|
||||
});
|
||||
} catch (error) {
|
||||
this.recordError('analyzeImage', model, startedAt, error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
const fullResponse = response.choices[0]?.message?.content || '';
|
||||
|
||||
@@ -190,9 +204,12 @@ ${this.config.openrouter.imageAnalysisPrompt}`;
|
||||
|
||||
// Detect AI refusal - return null to signal fallback should be tried
|
||||
if (this.isRefusal(description)) {
|
||||
this.recordUsage('analyzeImage', model, startedAt, response, 'refusal');
|
||||
return null;
|
||||
}
|
||||
|
||||
this.recordUsage('analyzeImage', model, startedAt, response, description ? 'success' : 'empty');
|
||||
|
||||
this.logger.debug(`Image analysis complete (${model}) - Gender: ${detectedGender}, Rating: ${attractivenessScore ?? 'n/a'}`);
|
||||
this.logger.debug(`Description: ${description}`);
|
||||
|
||||
@@ -480,7 +497,10 @@ Just write the message, nothing else.`;
|
||||
if (isFallback) {
|
||||
this.logger.info(`[AI] ${label} trying fallback model: ${model}`);
|
||||
}
|
||||
const response = await this.client.chat.completions.create({
|
||||
const startedAt = Date.now();
|
||||
let response;
|
||||
try {
|
||||
response = await this.client.chat.completions.create({
|
||||
model,
|
||||
messages: typeof prompt === 'string'
|
||||
? [{ role: 'user', content: prompt }]
|
||||
@@ -490,7 +510,11 @@ Just write the message, nothing else.`;
|
||||
],
|
||||
max_tokens: opts.max_tokens,
|
||||
temperature: opts.temperature,
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
this.recordError(label, model, startedAt, error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
const raw = response.choices[0]?.message?.content || '';
|
||||
const choice = response.choices[0] as unknown as Record<string, unknown> | undefined;
|
||||
@@ -508,14 +532,18 @@ Just write the message, nothing else.`;
|
||||
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) {
|
||||
this.recordUsage(label, model, startedAt, response, 'empty');
|
||||
this.logger.warn(`[AI] ${label}: ${model} returned empty content${i < models.length - 1 ? ', trying fallback...' : ''}`);
|
||||
continue;
|
||||
}
|
||||
if (this.isRefusal(trimmed)) {
|
||||
this.recordUsage(label, model, startedAt, response, 'refusal');
|
||||
this.logger.warn(`[AI] ${label}: ${model} returned a refusal${i < models.length - 1 ? ', trying fallback...' : ''}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
this.recordUsage(label, model, startedAt, response, 'success');
|
||||
|
||||
if (isFallback) {
|
||||
this.logger.info(`[AI] ${label}: fallback model ${model} succeeded`);
|
||||
}
|
||||
@@ -532,6 +560,48 @@ Just write the message, nothing else.`;
|
||||
return '';
|
||||
}
|
||||
|
||||
private recordUsage(
|
||||
operation: string,
|
||||
requestedModel: string,
|
||||
startedAt: number,
|
||||
response: unknown,
|
||||
outcome: AiUsageOutcome,
|
||||
): void {
|
||||
if (!this.usageRecorder) return;
|
||||
const record = response as Record<string, unknown>;
|
||||
const usage = (record.usage ?? {}) as Record<string, unknown>;
|
||||
try {
|
||||
this.usageRecorder({
|
||||
operation,
|
||||
requestedModel,
|
||||
actualModel: typeof record.model === 'string' ? record.model : requestedModel,
|
||||
outcome,
|
||||
promptTokens: numericUsage(usage.prompt_tokens),
|
||||
completionTokens: numericUsage(usage.completion_tokens),
|
||||
totalTokens: numericUsage(usage.total_tokens),
|
||||
costUsd: typeof usage.cost === 'number' && Number.isFinite(usage.cost) ? usage.cost : null,
|
||||
latencyMs: Date.now() - startedAt,
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.warn('Failed to persist AI usage accounting:', error);
|
||||
}
|
||||
}
|
||||
|
||||
private recordError(operation: string, requestedModel: string, startedAt: number, error: unknown): void {
|
||||
if (!this.usageRecorder) return;
|
||||
try {
|
||||
this.usageRecorder({
|
||||
operation,
|
||||
requestedModel,
|
||||
outcome: 'error',
|
||||
latencyMs: Date.now() - startedAt,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
} catch (recordingError) {
|
||||
this.logger.warn('Failed to persist AI usage error:', recordingError);
|
||||
}
|
||||
}
|
||||
|
||||
async analyzeAndGenerateMessage(
|
||||
imageBase64: string,
|
||||
mimeType: string,
|
||||
@@ -547,3 +617,7 @@ Just write the message, nothing else.`;
|
||||
return { analysis, message };
|
||||
}
|
||||
}
|
||||
|
||||
function numericUsage(value: unknown): number {
|
||||
return typeof value === 'number' && Number.isFinite(value) ? Math.max(0, Math.trunc(value)) : 0;
|
||||
}
|
||||
+7
-1
@@ -47,6 +47,7 @@ import type { Cookie } from 'playwright';
|
||||
import { ChatCache } from './services/chat/cache.js';
|
||||
import { RedditAccountRuntimeManager } from './services/reddit-account-runtime.js';
|
||||
import { createConcreteRedditAccountRuntimeFactory } from './services/reddit-account-runtime-builder.js';
|
||||
import { AiUsageRepository } from './database/ai-usage-repository.js';
|
||||
import { importLegacyAccountContent } from './database/legacy-content-import.js';
|
||||
import { RecipientDeduplicator } from './services/recipient-deduplicator.js';
|
||||
import { createConfigBackedSubredditPollingControl } from './services/subreddit-polling-control.js';
|
||||
@@ -127,6 +128,7 @@ export async function bootstrap(
|
||||
const recipientDeduplicator = new RecipientDeduplicator(new RecipientHistoryRepository(db));
|
||||
const redditAccountsRepo = bootstrapAccountsRepo;
|
||||
const accountContentRepo = new AccountContentRepository(db);
|
||||
const aiUsageRepo = new AiUsageRepository(db);
|
||||
const legacyImport = importLegacyAccountContent(db);
|
||||
if (legacyImport.conversations > 0 || legacyImport.media > 0) {
|
||||
logger.info(`Imported legacy unassigned content into SQLite (${legacyImport.conversations} conversations, ${legacyImport.media} media items)`);
|
||||
@@ -168,7 +170,9 @@ export async function bootstrap(
|
||||
const chatPage = await browserClient.getContext()!.newPage();
|
||||
|
||||
// AI + services
|
||||
const aiClient = new OpenRouterClient(config, logger);
|
||||
const aiClient = new OpenRouterClient(config, logger, undefined, (usage) => {
|
||||
aiUsageRepo.record({ ...usage, accountId: accountAuth.getSnapshot().accountId });
|
||||
});
|
||||
const monitorService = new MonitorService(
|
||||
redditApi, postsRepo, recipientDeduplicator, config, logger, 1000,
|
||||
createConfigBackedSubredditPollingControl(config, new ConfigStore(db), logger),
|
||||
@@ -344,6 +348,8 @@ export async function bootstrap(
|
||||
activityLogRepo,
|
||||
logger,
|
||||
broadcaster,
|
||||
process.cwd(),
|
||||
aiUsageRepo,
|
||||
);
|
||||
|
||||
webServer.setChatService(chatService);
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
import type Database from 'better-sqlite3';
|
||||
|
||||
export type AiUsageOutcome = 'success' | 'refusal' | 'empty' | 'error';
|
||||
|
||||
export interface AiUsageRecordInput {
|
||||
accountId?: string | null;
|
||||
operation: string;
|
||||
requestedModel: string;
|
||||
actualModel?: string | null;
|
||||
outcome: AiUsageOutcome;
|
||||
promptTokens?: number;
|
||||
completionTokens?: number;
|
||||
totalTokens?: number;
|
||||
costUsd?: number | null;
|
||||
latencyMs: number;
|
||||
error?: string | null;
|
||||
createdAt?: number;
|
||||
}
|
||||
|
||||
export interface AiUsageModelSummary {
|
||||
model: string;
|
||||
requests: number;
|
||||
successes: number;
|
||||
refusals: number;
|
||||
errors: number;
|
||||
promptTokens: number;
|
||||
completionTokens: number;
|
||||
totalTokens: number;
|
||||
costUsd: number;
|
||||
averageLatencyMs: number;
|
||||
}
|
||||
|
||||
export interface AiUsageSummary {
|
||||
days: number;
|
||||
requests: number;
|
||||
successes: number;
|
||||
refusals: number;
|
||||
errors: number;
|
||||
promptTokens: number;
|
||||
completionTokens: number;
|
||||
totalTokens: number;
|
||||
costUsd: number;
|
||||
averageLatencyMs: number;
|
||||
models: AiUsageModelSummary[];
|
||||
}
|
||||
|
||||
type SummaryRow = {
|
||||
model?: string;
|
||||
requests: number;
|
||||
successes: number;
|
||||
refusals: number;
|
||||
errors: number;
|
||||
prompt_tokens: number;
|
||||
completion_tokens: number;
|
||||
total_tokens: number;
|
||||
cost_usd: number;
|
||||
average_latency_ms: number;
|
||||
};
|
||||
|
||||
/** Durable, prompt-free accounting for OpenRouter requests. */
|
||||
export class AiUsageRepository {
|
||||
constructor(private readonly db: Database.Database) {}
|
||||
|
||||
record(input: AiUsageRecordInput): void {
|
||||
this.db.prepare(`
|
||||
INSERT INTO ai_usage (
|
||||
account_id, operation, requested_model, actual_model, outcome,
|
||||
prompt_tokens, completion_tokens, total_tokens, cost_usd,
|
||||
latency_ms, error, created_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
input.accountId ?? null,
|
||||
input.operation,
|
||||
input.requestedModel,
|
||||
input.actualModel ?? null,
|
||||
input.outcome,
|
||||
input.promptTokens ?? 0,
|
||||
input.completionTokens ?? 0,
|
||||
input.totalTokens ?? 0,
|
||||
input.costUsd ?? null,
|
||||
Math.max(0, Math.round(input.latencyMs)),
|
||||
input.error?.slice(0, 500) ?? null,
|
||||
input.createdAt ?? Date.now(),
|
||||
);
|
||||
}
|
||||
|
||||
getSummary(days = 30, now = Date.now()): AiUsageSummary {
|
||||
const safeDays = Math.max(1, Math.min(365, Math.trunc(days)));
|
||||
const since = now - safeDays * 86_400_000;
|
||||
const fields = `
|
||||
COUNT(*) AS requests,
|
||||
SUM(CASE WHEN outcome = 'success' THEN 1 ELSE 0 END) AS successes,
|
||||
SUM(CASE WHEN outcome = 'refusal' THEN 1 ELSE 0 END) AS refusals,
|
||||
SUM(CASE WHEN outcome IN ('empty', 'error') THEN 1 ELSE 0 END) AS errors,
|
||||
COALESCE(SUM(prompt_tokens), 0) AS prompt_tokens,
|
||||
COALESCE(SUM(completion_tokens), 0) AS completion_tokens,
|
||||
COALESCE(SUM(total_tokens), 0) AS total_tokens,
|
||||
COALESCE(SUM(cost_usd), 0) AS cost_usd,
|
||||
COALESCE(ROUND(AVG(latency_ms)), 0) AS average_latency_ms
|
||||
`;
|
||||
const total = this.db.prepare(`SELECT ${fields} FROM ai_usage WHERE created_at >= ?`)
|
||||
.get(since) as SummaryRow;
|
||||
const models = this.db.prepare(`
|
||||
SELECT COALESCE(actual_model, requested_model) AS model, ${fields}
|
||||
FROM ai_usage
|
||||
WHERE created_at >= ?
|
||||
GROUP BY COALESCE(actual_model, requested_model)
|
||||
ORDER BY cost_usd DESC, requests DESC, model ASC
|
||||
`).all(since) as SummaryRow[];
|
||||
return {
|
||||
days: safeDays,
|
||||
...mapSummary(total),
|
||||
models: models.map((row) => ({ model: row.model ?? 'unknown', ...mapSummary(row) })),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function mapSummary(row: SummaryRow): Omit<AiUsageModelSummary, 'model'> {
|
||||
return {
|
||||
requests: Number(row.requests ?? 0),
|
||||
successes: Number(row.successes ?? 0),
|
||||
refusals: Number(row.refusals ?? 0),
|
||||
errors: Number(row.errors ?? 0),
|
||||
promptTokens: Number(row.prompt_tokens ?? 0),
|
||||
completionTokens: Number(row.completion_tokens ?? 0),
|
||||
totalTokens: Number(row.total_tokens ?? 0),
|
||||
costUsd: Math.round(Number(row.cost_usd ?? 0) * 1_000_000_000_000) / 1_000_000_000_000,
|
||||
averageLatencyMs: Number(row.average_latency_ms ?? 0),
|
||||
};
|
||||
}
|
||||
@@ -251,6 +251,27 @@ export function runMigrations(db: Database.Database): void {
|
||||
CREATE INDEX idx_conversation_automation_claims_status
|
||||
ON conversation_automation_claims(account_id, status, claimed_at);
|
||||
`),
|
||||
() => db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS ai_usage (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
account_id TEXT,
|
||||
operation TEXT NOT NULL,
|
||||
requested_model TEXT NOT NULL,
|
||||
actual_model TEXT,
|
||||
outcome TEXT NOT NULL CHECK(outcome IN ('success', 'refusal', 'empty', 'error')),
|
||||
prompt_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
completion_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
total_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
cost_usd REAL,
|
||||
latency_ms INTEGER NOT NULL,
|
||||
error TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
FOREIGN KEY (account_id) REFERENCES reddit_accounts(account_id) ON DELETE SET NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_ai_usage_created_at ON ai_usage(created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_ai_usage_model ON ai_usage(actual_model, requested_model, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_ai_usage_account ON ai_usage(account_id, created_at DESC);
|
||||
`),
|
||||
];
|
||||
|
||||
let currentVersion = db.pragma('user_version', { simple: true }) as number;
|
||||
|
||||
@@ -227,6 +227,16 @@ export class DeliveryOutboxRepository {
|
||||
.get(this.accountId) as { count: number }).count;
|
||||
}
|
||||
|
||||
getFailed(limit = 50): DeliveryOutboxRecord[] {
|
||||
const rows = this.db.prepare(`
|
||||
SELECT * FROM delivery_outbox
|
||||
WHERE account_id = ? AND status = 'failed'
|
||||
ORDER BY updated_at DESC
|
||||
LIMIT ?
|
||||
`).all(this.accountId, limit) as Record<string, unknown>[];
|
||||
return rows.map((row) => this.mapRow(row));
|
||||
}
|
||||
|
||||
markPrepared(id: number, now: number = Date.now()): boolean {
|
||||
return this.db.prepare(`
|
||||
UPDATE delivery_outbox SET status = 'prepared', updated_at = ?, last_error = NULL
|
||||
@@ -1081,6 +1091,32 @@ export class ConversationStateRepository {
|
||||
const row = this.getStmt.get(this.accountId, conversationId) as { state_json: string } | undefined;
|
||||
return row ? parseConversationState(row.state_json) ?? undefined : undefined;
|
||||
}
|
||||
|
||||
listAttention(): Array<{
|
||||
conversationId: string;
|
||||
status: 'human-required' | 'boundary';
|
||||
reason: string | null;
|
||||
updatedAt: number;
|
||||
}> {
|
||||
const rows = this.db.prepare(`
|
||||
SELECT conversation_id, state_json, updated_at
|
||||
FROM conversation_states
|
||||
WHERE account_id = ?
|
||||
ORDER BY updated_at DESC
|
||||
`).all(this.accountId) as Array<{ conversation_id: string; state_json: string; updated_at: number }>;
|
||||
return rows.flatMap((row) => {
|
||||
const state = parseConversationState(row.state_json);
|
||||
if (!state) return [];
|
||||
const status = state.automation.status;
|
||||
if (status !== 'human-required' && status !== 'boundary') return [];
|
||||
return [{
|
||||
conversationId: row.conversation_id,
|
||||
status,
|
||||
reason: state.automation.handoffReason,
|
||||
updatedAt: row.updated_at,
|
||||
}];
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Durable idempotency claims for AI replies to individual inbound events. */
|
||||
|
||||
@@ -43,6 +43,7 @@ import { RecipientDeduplicator } from './recipient-deduplicator.js';
|
||||
import { ConfigStore } from '../config/persistence.js';
|
||||
import { createConfigBackedSubredditPollingControl } from './subreddit-polling-control.js';
|
||||
import { ConversationAutomationService } from './conversation-automation.js';
|
||||
import { AiUsageRepository } from '../database/ai-usage-repository.js';
|
||||
|
||||
export interface ConcreteRuntimeFactoryOptions {
|
||||
config: Config;
|
||||
@@ -118,7 +119,10 @@ async function buildRuntime(
|
||||
const redditApi = new RedditApiClient(accountConfig, logger, redditRequestCoordinator);
|
||||
redditApi.setBrowserContext(browserClient.getContext()!);
|
||||
const chatPage = await browserClient.getContext()!.newPage();
|
||||
const aiClient = new OpenRouterClient(accountConfig, logger);
|
||||
const aiUsageRepo = new AiUsageRepository(db);
|
||||
const aiClient = new OpenRouterClient(accountConfig, logger, undefined, (usage) => {
|
||||
aiUsageRepo.record({ ...usage, accountId: session.accountId });
|
||||
});
|
||||
const monitorService = new MonitorService(
|
||||
redditApi, postsRepo, recipientDeduplicator, accountConfig, logger,
|
||||
1000, createConfigBackedSubredditPollingControl(config, new ConfigStore(db), logger),
|
||||
|
||||
@@ -94,6 +94,8 @@ export const API_OPERATIONS: readonly ApiOperation[] = [
|
||||
operation('getStrategyAnalytics', 'GET', '/api/analytics/by-strategy', 'Get performance by strategy', ['Analytics']),
|
||||
operation('getFunnelAnalytics', 'GET', '/api/analytics/funnel', 'Get mature message outcome funnel', ['Analytics']),
|
||||
operation('getAnalyticsTrend', 'GET', '/api/analytics/trend', 'Get daily performance trend', ['Analytics']),
|
||||
operation('getAiUsageAnalytics', 'GET', '/api/analytics/ai-usage', 'Get OpenRouter cost and model reliability', ['Analytics']),
|
||||
operation('getAttention', 'GET', '/api/attention', 'Get operator attention items', ['Operations']),
|
||||
operation('getConfig', 'GET', '/api/config', 'Get non-secret application configuration', ['Configuration']),
|
||||
operation('updateConfig', 'PUT', '/api/config', 'Update application configuration', ['Configuration']),
|
||||
operation('getLoginStatus', 'GET', '/api/login/status', 'Get Reddit session state', ['Accounts']),
|
||||
|
||||
@@ -22,6 +22,7 @@ import type { AccountAuthContext } from '../services/account-auth-context.js';
|
||||
import type { ConfigStore } from '../config/persistence.js';
|
||||
import type { RedditAccountsService } from '../services/reddit-accounts.js';
|
||||
import type { RedditAccountRuntimeManager } from '../services/reddit-account-runtime.js';
|
||||
import type { AiUsageRepository } from '../database/ai-usage-repository.js';
|
||||
|
||||
/** Bag of everything the route modules might need. */
|
||||
export interface WebContext {
|
||||
@@ -35,6 +36,7 @@ export interface WebContext {
|
||||
posts: PostsRepository;
|
||||
messages: MessagesRepository;
|
||||
successful: SuccessfulMessagesRepository;
|
||||
aiUsage?: AiUsageRepository;
|
||||
};
|
||||
|
||||
services: {
|
||||
|
||||
@@ -37,5 +37,14 @@ export function createAnalyticsRouter(ctx: WebContext): Router {
|
||||
res.json(ctx.repos.messages.getResponseRateTrend(days));
|
||||
});
|
||||
|
||||
router.get('/analytics/ai-usage', (req, res) => {
|
||||
if (!ctx.repos.aiUsage) {
|
||||
res.status(503).json({ error: 'AI usage accounting is not initialized' });
|
||||
return;
|
||||
}
|
||||
const days = parseInt(req.query.days as string) || 30;
|
||||
res.json(ctx.repos.aiUsage.getSummary(days));
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import { Router } from 'express';
|
||||
import type { WebContext } from '../context.js';
|
||||
|
||||
interface AttentionItem {
|
||||
id: string;
|
||||
kind: 'human-required' | 'boundary' | 'failed-delivery' | 'account-login-required' | 'runtime-failed' | 'invitation-cooldown';
|
||||
severity: 'warning' | 'critical';
|
||||
title: string;
|
||||
detail: string;
|
||||
occurredAt: number;
|
||||
owner?: { accountId: string; redditUsername: string };
|
||||
conversationId?: string;
|
||||
username?: string;
|
||||
}
|
||||
|
||||
export function createAttentionRouter(ctx: WebContext): Router {
|
||||
const router = Router();
|
||||
|
||||
router.get('/attention', async (_req, res) => {
|
||||
const items: AttentionItem[] = [];
|
||||
const accounts = ctx.services.redditAccounts?.list() ?? [];
|
||||
for (const account of accounts) {
|
||||
if (account.status !== 'login-required') continue;
|
||||
items.push({
|
||||
id: `account:${account.accountId}`,
|
||||
kind: 'account-login-required',
|
||||
severity: 'critical',
|
||||
title: `u/${account.redditUsername} needs a new session`,
|
||||
detail: 'Import a fresh extension session before this account can resume.',
|
||||
occurredAt: account.updatedAt,
|
||||
owner: { accountId: account.accountId, redditUsername: account.redditUsername },
|
||||
});
|
||||
}
|
||||
|
||||
const snapshots = ctx.services.accountRuntimes?.list() ?? [];
|
||||
for (const snapshot of snapshots) {
|
||||
const owner = { accountId: snapshot.accountId, redditUsername: snapshot.redditUsername };
|
||||
if (snapshot.state === 'failed') {
|
||||
items.push({
|
||||
id: `runtime:${snapshot.accountId}`,
|
||||
kind: 'runtime-failed',
|
||||
severity: 'critical',
|
||||
title: `u/${snapshot.redditUsername} runtime failed`,
|
||||
detail: snapshot.failure ?? 'The account runtime stopped unexpectedly.',
|
||||
occurredAt: snapshot.updatedAt,
|
||||
owner,
|
||||
});
|
||||
}
|
||||
const runtime = ctx.services.accountRuntimes?.get(snapshot.accountId);
|
||||
if (!runtime) continue;
|
||||
const conversations = await runtime.resources.chatService.getCachedConversations();
|
||||
const usernames = new Map(conversations.flatMap((conversation) => conversation.roomId
|
||||
? [[conversation.roomId, conversation.username] as const]
|
||||
: []));
|
||||
for (const state of runtime.resources.conversationStateRepo.listAttention()) {
|
||||
const username = usernames.get(state.conversationId);
|
||||
items.push({
|
||||
id: `conversation:${snapshot.accountId}:${state.conversationId}:${state.status}`,
|
||||
kind: state.status,
|
||||
severity: state.status === 'boundary' ? 'critical' : 'warning',
|
||||
title: state.status === 'boundary'
|
||||
? `Boundary detected${username ? ` from u/${username}` : ''}`
|
||||
: `Human follow-up required${username ? ` for u/${username}` : ''}`,
|
||||
detail: state.reason ?? (state.status === 'boundary'
|
||||
? 'Automation stopped after the recipient set a boundary.'
|
||||
: 'Automated conversation turns are exhausted.'),
|
||||
occurredAt: state.updatedAt,
|
||||
owner,
|
||||
conversationId: `${snapshot.accountId}~${state.conversationId}`,
|
||||
...(username ? { username } : {}),
|
||||
});
|
||||
}
|
||||
for (const delivery of runtime.resources.deliveryOutboxRepo.getFailed()) {
|
||||
items.push({
|
||||
id: `delivery:${snapshot.accountId}:${delivery.id}`,
|
||||
kind: 'failed-delivery',
|
||||
severity: 'warning',
|
||||
title: `Message to u/${delivery.recipient} failed`,
|
||||
detail: delivery.lastError ?? `Delivery failed after ${delivery.attemptCount} attempt(s).`,
|
||||
occurredAt: delivery.updatedAt,
|
||||
owner,
|
||||
username: delivery.recipient,
|
||||
});
|
||||
}
|
||||
const cooldown = runtime.resources.chatService.getInvitationCooldown();
|
||||
if (cooldown) {
|
||||
items.push({
|
||||
id: `cooldown:${snapshot.accountId}:${cooldown.until}`,
|
||||
kind: 'invitation-cooldown',
|
||||
severity: 'warning',
|
||||
title: `u/${snapshot.redditUsername} invitation cooldown`,
|
||||
detail: cooldown.reason,
|
||||
occurredAt: Date.now(),
|
||||
owner,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ generatedAt: Date.now(), items: items.sort((left, right) => right.occurredAt - left.occurredAt) });
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
@@ -41,6 +41,8 @@ import { createLoginRouter } from './routes/login.js';
|
||||
import { createDashboardAuth, createDashboardSessionRouter } from './auth.js';
|
||||
import { resolveDashboardAssets } from './dashboard-assets.js';
|
||||
import { createAgentRouter, createHealthRouter } from './routes/agent.js';
|
||||
import { createAttentionRouter } from './routes/attention.js';
|
||||
import type { AiUsageRepository } from '../database/ai-usage-repository.js';
|
||||
|
||||
export type { BotStats, ActivityItem, LogEntry, RuntimeConfig } from './types.js';
|
||||
|
||||
@@ -74,6 +76,7 @@ export class WebServer {
|
||||
private logger: Logger,
|
||||
broadcaster?: LogBroadcaster,
|
||||
private workspaceDirectory = process.cwd(),
|
||||
private aiUsageRepo?: AiUsageRepository,
|
||||
) {
|
||||
this.broadcaster = broadcaster ?? new LogBroadcaster();
|
||||
this.activity = new ActivityLog(activityLogRepo);
|
||||
@@ -104,6 +107,7 @@ export class WebServer {
|
||||
posts: this.postsRepo,
|
||||
messages: this.messagesRepo,
|
||||
successful: this.successfulMessagesRepo,
|
||||
aiUsage: this.aiUsageRepo,
|
||||
},
|
||||
services: this.services,
|
||||
getBotRunning: () => this.botRunning,
|
||||
@@ -147,6 +151,7 @@ export class WebServer {
|
||||
const ctx = this.context();
|
||||
this.app.use('/api', createAgentRouter(ctx));
|
||||
this.app.use('/api', createStatsRouter(ctx));
|
||||
this.app.use('/api', createAttentionRouter(ctx));
|
||||
this.app.use('/api', createChatsRouter(ctx));
|
||||
this.app.use('/api', createConversationsRouter(ctx));
|
||||
this.app.use('/api', createMediaRouter(ctx));
|
||||
|
||||
Reference in new issue
Block a user