111 lines
4.2 KiB
TypeScript
111 lines
4.2 KiB
TypeScript
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<BrowsingThing, 'fullname' | 'text'>): 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');
|
|
}
|