feat: add agent API and operational analytics
This commit is contained in:
1 parent
720ea0652a
commit
e8b730b592
65 files changed
+2650
-1214
No files matched your search
@@ -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
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user