import { createHash } from 'node:crypto'; import type Database from 'better-sqlite3'; import { z } from 'zod'; import type { CommentReactionVerdict } from '../ai/openrouter.js'; import type { BrowsingThing, RedditPost } from '../reddit/api.js'; const StoredVerdictSchema = z.object({ id: z.string(), qualifies: z.boolean(), expressedGender: z.enum(['male', 'female', 'other', 'unknown']), confidence: z.number().min(0).max(1), evidence: z.string(), }); export interface CommentWatchRefreshResult { added: number; active: number; expired: number; } /** Durable source-post rotation and prompt-free comment classification cache. */ export class CommentSourceWatchRepository { constructor(private readonly db: Database.Database) {} refreshSources(posts: readonly RedditPost[], watchWindowMs: number, now = Date.now()): CommentWatchRefreshResult { const expired = this.db.prepare('DELETE FROM comment_source_watches WHERE expires_at <= ?').run(now).changes; const existing = this.db.prepare('SELECT 1 FROM comment_source_watches WHERE source_post_id = ?'); const upsert = this.db.prepare(` INSERT INTO comment_source_watches ( source_post_id, subreddit, payload_json, post_created_at, discovered_at, updated_at, expires_at ) VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT(source_post_id) DO UPDATE SET subreddit = excluded.subreddit, payload_json = excluded.payload_json, updated_at = excluded.updated_at, expires_at = MAX(comment_source_watches.expires_at, excluded.expires_at) `); let added = 0; this.db.transaction(() => { for (const post of posts) { const createdAt = Math.round(post.created_utc * 1_000); const expiresAt = createdAt + watchWindowMs; if (expiresAt <= now) continue; if (!existing.get(post.id)) added += 1; upsert.run(post.id, post.subreddit, JSON.stringify(post), createdAt, now, now, expiresAt); } })(); const active = Number((this.db.prepare(` SELECT COUNT(*) AS count FROM comment_source_watches WHERE expires_at > ? `).get(now) as { count: number }).count); return { added, active, expired }; } nextSource(subreddit: string, now = Date.now()): RedditPost | null { const row = this.db.prepare(` SELECT payload_json FROM comment_source_watches WHERE subreddit = ? COLLATE NOCASE AND expires_at > ? ORDER BY COALESCE(last_checked_at, 0) ASC, post_created_at DESC LIMIT 1 `).get(subreddit, now) as { payload_json: string } | undefined; if (!row) return null; return JSON.parse(row.payload_json) as RedditPost; } markChecked(sourcePostId: string, now = Date.now()): void { this.db.prepare(` UPDATE comment_source_watches SET last_checked_at = ?, updated_at = ? WHERE source_post_id = ? `).run(now, now, sourcePostId); } getVerdict(comment: Pick): CommentReactionVerdict | null { const row = this.db.prepare(` SELECT body_sha256, verdict_json FROM comment_source_comment_observations WHERE comment_id = ? `).get(comment.fullname) as { body_sha256: string; verdict_json: string } | undefined; if (!row || row.body_sha256 !== hashBody(comment.text)) return null; try { const parsed = StoredVerdictSchema.safeParse(JSON.parse(row.verdict_json)); return parsed.success ? parsed.data : null; } catch { return null; } } recordVerdict(comment: BrowsingThing, verdict: CommentReactionVerdict, now = Date.now()): void { this.db.prepare(` INSERT INTO comment_source_comment_observations ( comment_id, source_post_id, body_sha256, verdict_json, first_seen_at, updated_at ) VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT(comment_id) DO UPDATE SET source_post_id = excluded.source_post_id, body_sha256 = excluded.body_sha256, verdict_json = excluded.verdict_json, updated_at = excluded.updated_at `).run( comment.fullname, comment.parentPostId, hashBody(comment.text), JSON.stringify({ ...verdict, evidence: '' }), now, now, ); } } function hashBody(body: string): string { return createHash('sha256').update(body).digest('hex'); }