feat: add agent API and operational analytics

This commit is contained in:
jjbrunton committed 2026-08-08 22:08:14 +01:00
1 parent 720ea0652a
commit e8b730b592
65 files changed
+2650 -1214

No files matched your search

+16
View File
@@ -201,6 +201,22 @@ export function runMigrations(db: Database.Database): void {
`);
},
() => migrateAccountOwnership(db),
() => {
addColumnIfNotExists(db, 'sent_messages', 'experiment_id', 'TEXT');
addColumnIfNotExists(db, 'sent_messages', 'prompt_version', 'TEXT');
addColumnIfNotExists(db, 'sent_messages', 'model_id', 'TEXT');
addColumnIfNotExists(db, 'sent_messages', 'selection_reason', 'TEXT');
addColumnIfNotExists(db, 'sent_messages', 'source_type', 'TEXT');
db.exec(`
CREATE INDEX IF NOT EXISTS idx_sent_messages_experiment
ON sent_messages(account_id, experiment_id, strategy_id, sent_at);
CREATE TABLE IF NOT EXISTS recipient_suppressions (
recipient TEXT PRIMARY KEY COLLATE NOCASE,
reason TEXT NOT NULL,
created_at INTEGER NOT NULL
);
`);
},
];
let currentVersion = db.pragma('user_version', { simple: true }) as number;
+182 -38
View File
@@ -33,6 +33,11 @@ export interface SentMessageRecord {
responseCheckedAt: number | null;
strategyId: string | null;
chatRoomId: string | null;
experimentId?: string | null;
promptVersion?: string | null;
modelId?: string | null;
selectionReason?: 'explore' | 'exploit' | null;
sourceType?: 'image' | 'chat' | null;
subreddit?: string;
postTitle?: string;
postUrl?: string;
@@ -59,6 +64,9 @@ export interface StrategyStats {
responseRate: number;
totalImages: number;
imageRate: number;
pendingSent?: number;
responseRateLow?: number;
responseRateHigh?: number;
}
export interface SubredditStats {
@@ -68,6 +76,9 @@ export interface SubredditStats {
responseRate: number;
totalImages: number;
imageRate: number;
pendingSent?: number;
responseRateLow?: number;
responseRateHigh?: number;
}
export interface DailyTrend {
@@ -115,15 +126,17 @@ export class RecipientHistoryRepository {
constructor(db: Database.Database) {
this.hasSentToStmt = db.prepare(`
SELECT 1
FROM sent_messages
WHERE recipient = ? COLLATE NOCASE
SELECT 1 FROM (
SELECT recipient FROM sent_messages WHERE recipient = ? COLLATE NOCASE
UNION ALL
SELECT recipient FROM recipient_suppressions WHERE recipient = ? COLLATE NOCASE
)
LIMIT 1
`);
}
hasSentTo(recipient: string): boolean {
return this.hasSentToStmt.get(recipient) !== undefined;
return this.hasSentToStmt.get(recipient, recipient) !== undefined;
}
}
@@ -385,6 +398,14 @@ export class PostsRepository {
return this.existsStmt.get(this.accountId, postId) !== undefined;
}
/**
* Discovery is shared by all accounts, so a post handled or rejected by one
* account must not be rediscovered by another account on a later poll.
*/
isProcessedAnywhere(postId: string): boolean {
return this.db.prepare('SELECT 1 FROM processed_posts WHERE id = ? LIMIT 1').get(postId) !== undefined;
}
insert(post: {
id: string;
subreddit: string;
@@ -473,8 +494,10 @@ export class MessagesRepository {
private readonly accountId: string = LEGACY_UNASSIGNED_ACCOUNT_ID,
) {
this.insertStmt = db.prepare(`
INSERT INTO sent_messages (account_id, post_id, recipient, subject, message, sent_at, strategy_id, chat_room_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
INSERT INTO sent_messages (
account_id, post_id, recipient, subject, message, sent_at, strategy_id, chat_room_id,
experiment_id, prompt_version, model_id, selection_reason, source_type
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
this.insertChatStmt = db.prepare(`
@@ -530,7 +553,7 @@ export class MessagesRepository {
this.markImageStmt = db.prepare(`
UPDATE sent_messages
SET got_image = 1, got_response = 1, response_checked_at = ?
WHERE id = ?
WHERE account_id = ? AND id = ?
`);
this.markCheckedStmt = db.prepare(`
@@ -550,6 +573,11 @@ export class MessagesRepository {
sentAt: number;
strategyId?: string | null;
chatRoomId?: string | null;
experimentId?: string | null;
promptVersion?: string | null;
modelId?: string | null;
selectionReason?: 'explore' | 'exploit' | null;
sourceType?: 'image' | 'chat' | null;
}): number {
const result = this.insertStmt.run(
this.accountId,
@@ -560,6 +588,11 @@ export class MessagesRepository {
message.sentAt,
message.strategyId || null,
message.chatRoomId ?? null,
message.experimentId ?? null,
message.promptVersion ?? null,
message.modelId ?? null,
message.selectionReason ?? null,
message.sourceType ?? null,
);
return result.lastInsertRowid as number;
}
@@ -627,7 +660,7 @@ export class MessagesRepository {
}
markGotImage(messageId: number): void {
this.markImageStmt.run(Date.now(), messageId);
this.markImageStmt.run(Date.now(), this.accountId, messageId);
}
markChecked(messageId: number): void {
@@ -638,7 +671,8 @@ export class MessagesRepository {
return this.hasSentToStmt.get(this.accountId, recipient) !== undefined;
}
getStrategyStats(): StrategyStats[] {
getStrategyStats(matureBefore?: number): StrategyStats[] {
const maturityClause = matureBefore === undefined ? '' : 'AND sent_at <= ?';
const rows = this.db.prepare(`
SELECT
strategy_id,
@@ -646,25 +680,33 @@ export class MessagesRepository {
SUM(CASE WHEN got_response = 1 THEN 1 ELSE 0 END) as total_responses,
SUM(CASE WHEN got_image = 1 THEN 1 ELSE 0 END) as total_images
FROM sent_messages
WHERE account_id = ? AND strategy_id IS NOT NULL
WHERE account_id = ? AND strategy_id IS NOT NULL ${maturityClause}
GROUP BY strategy_id
`).all(this.accountId) as Record<string, unknown>[];
`).all(...(matureBefore === undefined ? [this.accountId] : [this.accountId, matureBefore])) as Record<string, unknown>[];
return rows.map(row => ({
strategyId: row.strategy_id as string,
totalSent: row.total_sent as number,
totalResponses: row.total_responses as number,
responseRate: (row.total_sent as number) > 0
? Math.round(((row.total_responses as number) / (row.total_sent as number)) * 100)
: 0,
totalImages: row.total_images as number,
imageRate: (row.total_sent as number) > 0
? Math.round(((row.total_images as number) / (row.total_sent as number)) * 100)
: 0,
}));
const pending = matureBefore === undefined ? new Map<string, number>() : new Map(
(this.db.prepare(`
SELECT strategy_id, COUNT(*) AS total
FROM sent_messages
WHERE account_id = ? AND strategy_id IS NOT NULL AND sent_at > ?
GROUP BY strategy_id
`).all(this.accountId, matureBefore) as Array<{ strategy_id: string; total: number }>)
.map((row) => [row.strategy_id, row.total]),
);
return rows.map(row => performanceStats(
{
strategyId: row.strategy_id as string,
totalSent: row.total_sent as number,
totalResponses: row.total_responses as number,
totalImages: row.total_images as number,
},
matureBefore === undefined ? undefined : pending.get(row.strategy_id as string) ?? 0,
));
}
getSubredditStats(): SubredditStats[] {
getSubredditStats(matureBefore?: number): SubredditStats[] {
const maturityClause = matureBefore === undefined ? '' : 'AND sm.sent_at <= ?';
const rows = this.db.prepare(`
SELECT
pp.subreddit,
@@ -673,23 +715,31 @@ export class MessagesRepository {
SUM(CASE WHEN sm.got_image = 1 THEN 1 ELSE 0 END) as total_images
FROM sent_messages sm
JOIN processed_posts pp ON sm.account_id = pp.account_id AND sm.post_id = pp.id
WHERE sm.account_id = ?
WHERE sm.account_id = ? ${maturityClause}
GROUP BY pp.subreddit
ORDER BY total_sent DESC
`).all(this.accountId) as Record<string, unknown>[];
`).all(...(matureBefore === undefined ? [this.accountId] : [this.accountId, matureBefore])) as Record<string, unknown>[];
return rows.map(row => ({
subreddit: row.subreddit as string,
totalSent: row.total_sent as number,
totalResponses: row.total_responses as number,
responseRate: (row.total_sent as number) > 0
? Math.round(((row.total_responses as number) / (row.total_sent as number)) * 100)
: 0,
totalImages: row.total_images as number,
imageRate: (row.total_sent as number) > 0
? Math.round(((row.total_images as number) / (row.total_sent as number)) * 100)
: 0,
}));
const pending = matureBefore === undefined ? new Map<string, number>() : new Map(
(this.db.prepare(`
SELECT pp.subreddit, COUNT(*) AS total
FROM sent_messages sm
JOIN processed_posts pp ON sm.account_id = pp.account_id AND sm.post_id = pp.id
WHERE sm.account_id = ? AND sm.sent_at > ?
GROUP BY pp.subreddit
`).all(this.accountId, matureBefore) as Array<{ subreddit: string; total: number }>)
.map((row) => [row.subreddit, row.total]),
);
return rows.map(row => performanceStats(
{
subreddit: row.subreddit as string,
totalSent: row.total_sent as number,
totalResponses: row.total_responses as number,
totalImages: row.total_images as number,
},
matureBefore === undefined ? undefined : pending.get(row.subreddit as string) ?? 0,
));
}
getResponseRateTrend(days: number = 30): DailyTrend[] {
@@ -721,6 +771,50 @@ export class MessagesRepository {
}));
}
getFunnelStats(matureBefore: number): {
processed: number;
sent: number;
pending: number;
mature: number;
responded: number;
engaged: number;
declined: number;
boundary: number;
upsell: number;
images: number;
} {
const row = this.db.prepare(`
SELECT
(SELECT COUNT(*) FROM processed_posts WHERE account_id = @accountId) AS processed,
COUNT(*) AS sent,
SUM(CASE WHEN sent_at > @matureBefore THEN 1 ELSE 0 END) AS pending,
SUM(CASE WHEN sent_at <= @matureBefore THEN 1 ELSE 0 END) AS mature,
SUM(CASE WHEN got_response = 1 THEN 1 ELSE 0 END) AS responded,
SUM(CASE WHEN got_image = 1 THEN 1 ELSE 0 END) AS images,
SUM(CASE WHEN co.outcome = 'engaged' THEN 1 ELSE 0 END) AS engaged,
SUM(CASE WHEN co.outcome = 'declined' THEN 1 ELSE 0 END) AS declined,
SUM(CASE WHEN co.outcome = 'boundary' THEN 1 ELSE 0 END) AS boundary,
SUM(CASE WHEN co.outcome = 'upsell' THEN 1 ELSE 0 END) AS upsell
FROM sent_messages sm
LEFT JOIN conversation_outcomes co
ON co.account_id = sm.account_id AND co.sent_message_id = sm.id
WHERE sm.account_id = @accountId
AND (sm.post_id IS NOT NULL OR sm.strategy_id IS NOT NULL)
`).get({ accountId: this.accountId, matureBefore }) as Record<string, number | null>;
return {
processed: row.processed ?? 0,
sent: row.sent ?? 0,
pending: row.pending ?? 0,
mature: row.mature ?? 0,
responded: row.responded ?? 0,
engaged: row.engaged ?? 0,
declined: row.declined ?? 0,
boundary: row.boundary ?? 0,
upsell: row.upsell ?? 0,
images: row.images ?? 0,
};
}
private mapRow(row: Record<string, unknown>): SentMessageRecord {
return {
id: row.id as number,
@@ -734,6 +828,11 @@ export class MessagesRepository {
responseCheckedAt: row.response_checked_at as number | null,
strategyId: (row.strategy_id as string) || null,
chatRoomId: (row.chat_room_id as string) ?? null,
experimentId: (row.experiment_id as string | null) ?? null,
promptVersion: (row.prompt_version as string | null) ?? null,
modelId: (row.model_id as string | null) ?? null,
selectionReason: (row.selection_reason as 'explore' | 'exploit' | null) ?? null,
sourceType: (row.source_type as 'image' | 'chat' | null) ?? null,
subreddit: (row.subreddit as string) || undefined,
postTitle: (row.post_title as string) || undefined,
postUrl: (row.post_url as string) || undefined,
@@ -743,6 +842,43 @@ export class MessagesRepository {
}
}
function performanceStats<T extends {
totalSent: number;
totalResponses: number;
totalImages: number;
}>(base: T, pendingSent?: number): T & {
responseRate: number;
imageRate: number;
pendingSent?: number;
responseRateLow?: number;
responseRateHigh?: number;
} {
const responseRate = base.totalSent > 0
? Math.round((base.totalResponses / base.totalSent) * 100)
: 0;
const imageRate = base.totalSent > 0
? Math.round((base.totalImages / base.totalSent) * 100)
: 0;
if (pendingSent === undefined) return { ...base, responseRate, imageRate };
const [responseRateLow, responseRateHigh] = wilsonInterval(base.totalResponses, base.totalSent);
return { ...base, responseRate, imageRate, pendingSent, responseRateLow, responseRateHigh };
}
function wilsonInterval(successes: number, total: number): [number, number] {
if (total <= 0) return [0, 100];
const z = 1.96;
const proportion = successes / total;
const denominator = 1 + (z * z) / total;
const centre = (proportion + (z * z) / (2 * total)) / denominator;
const margin = z * Math.sqrt(
(proportion * (1 - proportion) + (z * z) / (4 * total)) / total,
) / denominator;
return [
Math.round(Math.max(0, centre - margin) * 100),
Math.round(Math.min(1, centre + margin) * 100),
];
}
export class SuccessfulMessagesRepository {
private insertStmt: Database.Statement;
private getRecentStmt: Database.Statement;
@@ -911,6 +1047,14 @@ export class ConversationOutcomesRepository {
`).get(this.accountId, sentMessageId) as { outcome: ConversationOutcome; response_snippet: string | null } | undefined;
return row ? { outcome: row.outcome, responseSnippet: row.response_snippet } : undefined;
}
suppressRecipient(recipient: string, reason: string): void {
this.db.prepare(`
INSERT INTO recipient_suppressions (recipient, reason, created_at)
VALUES (?, ?, ?)
ON CONFLICT(recipient) DO UPDATE SET reason = excluded.reason
`).run(recipient, reason, Date.now());
}
}
export interface RedditUserRecord {