Add account browsing simulation
This commit is contained in:
1 parent
e2d7dc4b82
commit
27012399ec
31 files changed
+1476
-28
No files matched your search
@@ -0,0 +1,294 @@
|
||||
import type Database from 'better-sqlite3';
|
||||
import type { GrowthQuietHours } from './growth-repository.js';
|
||||
|
||||
export type BrowsingThingKind = 'post' | 'comment';
|
||||
export type BrowsingDecisionKind = 'simulated-upvote' | 'skipped' | 'rejected';
|
||||
|
||||
export interface AccountBrowsingSettingsRecord {
|
||||
accountId: string;
|
||||
enabled: boolean;
|
||||
dailyTargetMin: number;
|
||||
dailyTargetMax: number;
|
||||
postWeight: number;
|
||||
commentWeight: number;
|
||||
quietHours: GrowthQuietHours;
|
||||
minimumGapMs: number;
|
||||
jitterMs: number;
|
||||
allowNsfw: boolean;
|
||||
nextDueAt: number | null;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
export interface AccountBrowsingSettingsPatch {
|
||||
enabled?: boolean;
|
||||
dailyTargetMin?: number;
|
||||
dailyTargetMax?: number;
|
||||
postWeight?: number;
|
||||
commentWeight?: number;
|
||||
quietHours?: GrowthQuietHours;
|
||||
minimumGapMs?: number;
|
||||
jitterMs?: number;
|
||||
allowNsfw?: boolean;
|
||||
}
|
||||
|
||||
export interface BrowsingDecisionRecord {
|
||||
accountId: string;
|
||||
thingFullname: string;
|
||||
thingKind: BrowsingThingKind;
|
||||
postId: string;
|
||||
subreddit: string;
|
||||
author: string;
|
||||
permalink: string;
|
||||
decision: BrowsingDecisionKind;
|
||||
reason: string;
|
||||
discoveredAt: number;
|
||||
decidedAt: number;
|
||||
scheduledSessionId: string;
|
||||
}
|
||||
|
||||
export interface BrowsingDecisionCounts {
|
||||
selected: number;
|
||||
posts: number;
|
||||
comments: number;
|
||||
rejected: number;
|
||||
skipped: number;
|
||||
}
|
||||
|
||||
const DEFAULT_QUIET_HOURS: GrowthQuietHours = { enabled: true, startHour: 0, endHour: 7 };
|
||||
|
||||
/** Durable account-scoped policy and audit store for simulation-only browsing decisions. */
|
||||
export class AccountBrowsingSimulationRepository {
|
||||
constructor(private readonly db: Database.Database) {}
|
||||
|
||||
getSettings(accountId: string, now: number = Date.now()): AccountBrowsingSettingsRecord {
|
||||
const row = this.db.prepare('SELECT * FROM account_browsing_settings WHERE account_id = ?')
|
||||
.get(accountId) as Record<string, unknown> | undefined;
|
||||
return row ? mapSettings(row) : {
|
||||
accountId,
|
||||
enabled: false,
|
||||
dailyTargetMin: 3,
|
||||
dailyTargetMax: 6,
|
||||
postWeight: 60,
|
||||
commentWeight: 40,
|
||||
quietHours: { ...DEFAULT_QUIET_HOURS },
|
||||
minimumGapMs: 45 * 60_000,
|
||||
jitterMs: 15 * 60_000,
|
||||
allowNsfw: false,
|
||||
nextDueAt: null,
|
||||
updatedAt: now,
|
||||
};
|
||||
}
|
||||
|
||||
saveSettings(
|
||||
accountId: string,
|
||||
patch: AccountBrowsingSettingsPatch,
|
||||
now: number = Date.now(),
|
||||
): AccountBrowsingSettingsRecord {
|
||||
const current = this.getSettings(accountId, now);
|
||||
const merged = {
|
||||
...current,
|
||||
...patch,
|
||||
quietHours: patch.quietHours ?? current.quietHours,
|
||||
updatedAt: now,
|
||||
};
|
||||
validateSettings(merged);
|
||||
this.db.prepare(`
|
||||
INSERT INTO account_browsing_settings (
|
||||
account_id, enabled, daily_target_min, daily_target_max, post_weight,
|
||||
comment_weight, quiet_hours_json, minimum_gap_ms, jitter_ms, allow_nsfw,
|
||||
next_due_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(account_id) DO UPDATE SET
|
||||
enabled = excluded.enabled,
|
||||
daily_target_min = excluded.daily_target_min,
|
||||
daily_target_max = excluded.daily_target_max,
|
||||
post_weight = excluded.post_weight,
|
||||
comment_weight = excluded.comment_weight,
|
||||
quiet_hours_json = excluded.quiet_hours_json,
|
||||
minimum_gap_ms = excluded.minimum_gap_ms,
|
||||
jitter_ms = excluded.jitter_ms,
|
||||
allow_nsfw = excluded.allow_nsfw,
|
||||
updated_at = excluded.updated_at
|
||||
`).run(
|
||||
accountId, merged.enabled ? 1 : 0, merged.dailyTargetMin, merged.dailyTargetMax,
|
||||
merged.postWeight, merged.commentWeight, JSON.stringify(merged.quietHours),
|
||||
merged.minimumGapMs, merged.jitterMs, merged.allowNsfw ? 1 : 0,
|
||||
merged.nextDueAt, now,
|
||||
);
|
||||
return this.getSettings(accountId, now);
|
||||
}
|
||||
|
||||
setNextDueAt(accountId: string, nextDueAt: number | null, now: number = Date.now()): void {
|
||||
if (!this.db.prepare('SELECT 1 FROM account_browsing_settings WHERE account_id = ?').get(accountId)) {
|
||||
this.saveSettings(accountId, {}, now);
|
||||
}
|
||||
this.db.prepare(`
|
||||
UPDATE account_browsing_settings SET next_due_at = ?, updated_at = ? WHERE account_id = ?
|
||||
`).run(nextDueAt, now, accountId);
|
||||
}
|
||||
|
||||
recordDecision(decision: BrowsingDecisionRecord): boolean {
|
||||
return this.db.prepare(`
|
||||
INSERT OR IGNORE INTO account_browsing_decisions (
|
||||
account_id, thing_fullname, thing_kind, post_id, subreddit, author,
|
||||
permalink, decision, reason, discovered_at, decided_at, scheduled_session_id
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
decision.accountId, decision.thingFullname, decision.thingKind, decision.postId,
|
||||
decision.subreddit, decision.author, decision.permalink, decision.decision,
|
||||
decision.reason, decision.discoveredAt, decision.decidedAt, decision.scheduledSessionId,
|
||||
).changes === 1;
|
||||
}
|
||||
|
||||
async recordSimulation(decision: BrowsingDecisionRecord): Promise<void> {
|
||||
if (!this.recordDecision(decision)) {
|
||||
throw new Error(`Browsing decision for ${decision.thingFullname} was already recorded`);
|
||||
}
|
||||
}
|
||||
|
||||
hasAccountDecision(accountId: string, thingFullname: string): boolean {
|
||||
return this.db.prepare(`
|
||||
SELECT 1 FROM account_browsing_decisions WHERE account_id = ? AND thing_fullname = ?
|
||||
`).get(accountId, thingFullname) !== undefined;
|
||||
}
|
||||
|
||||
wasSelectedByAnyAccount(thingFullname: string): boolean {
|
||||
return this.db.prepare(`
|
||||
SELECT 1 FROM account_browsing_decisions
|
||||
WHERE thing_fullname = ? AND decision = 'simulated-upvote' LIMIT 1
|
||||
`).get(thingFullname) !== undefined;
|
||||
}
|
||||
|
||||
isOwnedAuthor(author: string): boolean {
|
||||
return this.db.prepare(`
|
||||
SELECT 1 FROM reddit_accounts WHERE reddit_username = ? COLLATE NOCASE LIMIT 1
|
||||
`).get(author) !== undefined;
|
||||
}
|
||||
|
||||
isOutreachRecipient(author: string): boolean {
|
||||
return this.db.prepare(`
|
||||
SELECT 1 FROM recipient_suppressions WHERE recipient = ? COLLATE NOCASE LIMIT 1
|
||||
`).get(author) !== undefined;
|
||||
}
|
||||
|
||||
isGrowthCommentPost(postId: string): boolean {
|
||||
return this.db.prepare(`SELECT 1 FROM account_growth_comments WHERE post_id = ? LIMIT 1`)
|
||||
.get(postId) !== undefined;
|
||||
}
|
||||
|
||||
isPromotionExperimentPost(postId: string): boolean {
|
||||
return this.db.prepare(`
|
||||
SELECT 1
|
||||
WHERE EXISTS (
|
||||
SELECT 1 FROM sent_messages
|
||||
WHERE post_id = ? AND experiment_id IS NOT NULL AND trim(experiment_id) <> ''
|
||||
) OR EXISTS (
|
||||
SELECT 1 FROM candidate_queue
|
||||
WHERE post_id = ? AND prepared_json IS NOT NULL AND json_valid(prepared_json)
|
||||
AND json_extract(prepared_json, '$.experimentId') IS NOT NULL
|
||||
)
|
||||
LIMIT 1
|
||||
`).get(postId, postId) !== undefined;
|
||||
}
|
||||
|
||||
countSelectedSince(accountId: string, since: number): number {
|
||||
return (this.db.prepare(`
|
||||
SELECT COUNT(*) AS count FROM account_browsing_decisions
|
||||
WHERE account_id = ? AND decision = 'simulated-upvote' AND decided_at >= ?
|
||||
`).get(accountId, since) as { count: number }).count;
|
||||
}
|
||||
|
||||
getCountsSince(accountId: string, since: number): BrowsingDecisionCounts {
|
||||
const rows = this.db.prepare(`
|
||||
SELECT decision, thing_kind, COUNT(*) AS count
|
||||
FROM account_browsing_decisions WHERE account_id = ? AND decided_at >= ?
|
||||
GROUP BY decision, thing_kind
|
||||
`).all(accountId, since) as Array<{ decision: BrowsingDecisionKind; thing_kind: BrowsingThingKind; count: number }>;
|
||||
const counts: BrowsingDecisionCounts = { selected: 0, posts: 0, comments: 0, rejected: 0, skipped: 0 };
|
||||
for (const row of rows) {
|
||||
if (row.decision === 'simulated-upvote') {
|
||||
counts.selected += row.count;
|
||||
counts[row.thing_kind === 'post' ? 'posts' : 'comments'] += row.count;
|
||||
} else if (row.decision === 'rejected') counts.rejected += row.count;
|
||||
else counts.skipped += row.count;
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
|
||||
listRecent(accountId: string, limit = 20): BrowsingDecisionRecord[] {
|
||||
const rows = this.db.prepare(`
|
||||
SELECT * FROM account_browsing_decisions WHERE account_id = ?
|
||||
ORDER BY decided_at DESC LIMIT ?
|
||||
`).all(accountId, limit) as Array<Record<string, unknown>>;
|
||||
return rows.map(mapDecision);
|
||||
}
|
||||
|
||||
listRecentSelections(accountId: string, limit = 20): BrowsingDecisionRecord[] {
|
||||
const rows = this.db.prepare(`
|
||||
SELECT * FROM account_browsing_decisions
|
||||
WHERE account_id = ? AND decision = 'simulated-upvote'
|
||||
ORDER BY decided_at DESC LIMIT ?
|
||||
`).all(accountId, limit) as Array<Record<string, unknown>>;
|
||||
return rows.map(mapDecision);
|
||||
}
|
||||
}
|
||||
|
||||
function validateSettings(settings: AccountBrowsingSettingsRecord): void {
|
||||
if (!Number.isInteger(settings.dailyTargetMin) || settings.dailyTargetMin < 1 || settings.dailyTargetMin > 12) {
|
||||
throw new Error('Browsing minimum daily target must be a whole number between 1 and 12');
|
||||
}
|
||||
if (!Number.isInteger(settings.dailyTargetMax)
|
||||
|| settings.dailyTargetMax < settings.dailyTargetMin || settings.dailyTargetMax > 12) {
|
||||
throw new Error('Browsing maximum daily target must be between the minimum and 12');
|
||||
}
|
||||
if (!Number.isFinite(settings.postWeight) || settings.postWeight < 0
|
||||
|| !Number.isFinite(settings.commentWeight) || settings.commentWeight < 0
|
||||
|| settings.postWeight + settings.commentWeight <= 0) {
|
||||
throw new Error('Browsing post and comment weights must be non-negative with a positive total');
|
||||
}
|
||||
if (!Number.isInteger(settings.minimumGapMs) || settings.minimumGapMs < 45 * 60_000) {
|
||||
throw new Error('Browsing minimum gap cannot be less than 45 minutes');
|
||||
}
|
||||
if (!Number.isInteger(settings.jitterMs) || settings.jitterMs < 0) {
|
||||
throw new Error('Browsing jitter must be a non-negative whole number');
|
||||
}
|
||||
const { startHour, endHour } = settings.quietHours;
|
||||
if (!Number.isInteger(startHour) || startHour < 0 || startHour > 23
|
||||
|| !Number.isInteger(endHour) || endHour < 0 || endHour > 23) {
|
||||
throw new Error('Browsing quiet-hour boundaries must be whole hours from 0 to 23');
|
||||
}
|
||||
}
|
||||
|
||||
function mapSettings(row: Record<string, unknown>): AccountBrowsingSettingsRecord {
|
||||
return {
|
||||
accountId: row.account_id as string,
|
||||
enabled: row.enabled === 1,
|
||||
dailyTargetMin: row.daily_target_min as number,
|
||||
dailyTargetMax: row.daily_target_max as number,
|
||||
postWeight: row.post_weight as number,
|
||||
commentWeight: row.comment_weight as number,
|
||||
quietHours: JSON.parse(row.quiet_hours_json as string) as GrowthQuietHours,
|
||||
minimumGapMs: row.minimum_gap_ms as number,
|
||||
jitterMs: row.jitter_ms as number,
|
||||
allowNsfw: row.allow_nsfw === 1,
|
||||
nextDueAt: (row.next_due_at as number | null) ?? null,
|
||||
updatedAt: row.updated_at as number,
|
||||
};
|
||||
}
|
||||
|
||||
function mapDecision(row: Record<string, unknown>): BrowsingDecisionRecord {
|
||||
return {
|
||||
accountId: row.account_id as string,
|
||||
thingFullname: row.thing_fullname as string,
|
||||
thingKind: row.thing_kind as BrowsingThingKind,
|
||||
postId: row.post_id as string,
|
||||
subreddit: row.subreddit as string,
|
||||
author: row.author as string,
|
||||
permalink: row.permalink as string,
|
||||
decision: row.decision as BrowsingDecisionKind,
|
||||
reason: row.reason as string,
|
||||
discoveredAt: row.discovered_at as number,
|
||||
decidedAt: row.decided_at as number,
|
||||
scheduledSessionId: row.scheduled_session_id as string,
|
||||
};
|
||||
}
|
||||
@@ -564,6 +564,50 @@ export function runMigrations(db: Database.Database): void {
|
||||
// Null keeps the adaptive allowance; a value is an operator override.
|
||||
addColumnIfNotExists(db, 'account_growth_settings', 'daily_limit', 'INTEGER');
|
||||
},
|
||||
() => db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS account_browsing_settings (
|
||||
account_id TEXT PRIMARY KEY,
|
||||
enabled INTEGER NOT NULL DEFAULT 0 CHECK(enabled IN (0, 1)),
|
||||
daily_target_min INTEGER NOT NULL DEFAULT 3,
|
||||
daily_target_max INTEGER NOT NULL DEFAULT 6,
|
||||
post_weight INTEGER NOT NULL DEFAULT 60,
|
||||
comment_weight INTEGER NOT NULL DEFAULT 40,
|
||||
quiet_hours_json TEXT NOT NULL DEFAULT '{"enabled":true,"startHour":0,"endHour":7}',
|
||||
minimum_gap_ms INTEGER NOT NULL DEFAULT 2700000,
|
||||
jitter_ms INTEGER NOT NULL DEFAULT 900000,
|
||||
allow_nsfw INTEGER NOT NULL DEFAULT 0 CHECK(allow_nsfw IN (0, 1)),
|
||||
next_due_at INTEGER,
|
||||
updated_at INTEGER NOT NULL,
|
||||
FOREIGN KEY (account_id) REFERENCES reddit_accounts(account_id) ON DELETE CASCADE,
|
||||
CHECK(daily_target_min BETWEEN 1 AND 12),
|
||||
CHECK(daily_target_max BETWEEN daily_target_min AND 12),
|
||||
CHECK(post_weight >= 0 AND comment_weight >= 0 AND post_weight + comment_weight > 0)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS account_browsing_decisions (
|
||||
account_id TEXT NOT NULL,
|
||||
thing_fullname TEXT NOT NULL,
|
||||
thing_kind TEXT NOT NULL CHECK(thing_kind IN ('post', 'comment')),
|
||||
post_id TEXT NOT NULL,
|
||||
subreddit TEXT NOT NULL COLLATE NOCASE,
|
||||
author TEXT NOT NULL,
|
||||
permalink TEXT NOT NULL,
|
||||
decision TEXT NOT NULL CHECK(decision IN ('simulated-upvote', 'skipped', 'rejected')),
|
||||
reason TEXT NOT NULL,
|
||||
discovered_at INTEGER NOT NULL,
|
||||
decided_at INTEGER NOT NULL,
|
||||
scheduled_session_id TEXT NOT NULL,
|
||||
PRIMARY KEY (account_id, thing_fullname),
|
||||
FOREIGN KEY (account_id) REFERENCES reddit_accounts(account_id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_account_browsing_decisions_recent
|
||||
ON account_browsing_decisions(account_id, decided_at DESC);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_account_browsing_decisions_selected
|
||||
ON account_browsing_decisions(thing_fullname)
|
||||
WHERE decision = 'simulated-upvote';
|
||||
CREATE INDEX IF NOT EXISTS idx_account_browsing_decisions_day
|
||||
ON account_browsing_decisions(account_id, decision, decided_at);
|
||||
`),
|
||||
];
|
||||
|
||||
let currentVersion = db.pragma('user_version', { simple: true }) as number;
|
||||
|
||||
Reference in new issue
Block a user