Files
agent-park/src/lib/signal-hotness.ts
T

145 lines
4.1 KiB
TypeScript

import type { SignalSource } from '@/lib/validations'
import { Prisma, type PrismaClient } from '@prisma/client'
interface HotnessInput {
source: SignalSource
engagement: number
publishedAt: Date | string
}
interface HotnessResult {
hotScore: number
isHot: boolean
}
interface HotConfig {
engagementBaseline: number
minEngagement: number
threshold: number
}
const HOT_CONFIG: Record<SignalSource, HotConfig> = {
hacker_news: { engagementBaseline: 140, minEngagement: 45, threshold: 64 },
github: { engagementBaseline: 1200, minEngagement: 120, threshold: 66 },
arxiv: { engagementBaseline: 20, minEngagement: 12, threshold: 72 },
hugging_face: { engagementBaseline: 55, minEngagement: 20, threshold: 68 },
reddit: { engagementBaseline: 160, minEngagement: 50, threshold: 65 },
product_hunt: { engagementBaseline: 120, minEngagement: 35, threshold: 65 },
}
function clamp(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, value))
}
function normalizeEngagement(engagement: number, baseline: number): number {
if (engagement <= 0) {
return 0
}
const capped = Math.min(engagement, baseline * 3)
const score = (Math.log1p(capped) / Math.log1p(baseline)) * 100
return clamp(score, 0, 100)
}
function recencyScore(publishedAt: Date | string): number {
const date = publishedAt instanceof Date ? publishedAt : new Date(publishedAt)
if (Number.isNaN(date.getTime())) {
return 0
}
const ageHours = (Date.now() - date.getTime()) / (1000 * 60 * 60)
if (ageHours <= 0) {
return 100
}
const score = 100 - ageHours * 2.4
return clamp(score, 0, 100)
}
export function computeSignalHotness(input: HotnessInput): HotnessResult {
const safeEngagement = Number.isFinite(input.engagement) && input.engagement > 0 ? Math.floor(input.engagement) : 0
const config = HOT_CONFIG[input.source]
const engagementPart = normalizeEngagement(safeEngagement, config.engagementBaseline)
const recencyPart = recencyScore(input.publishedAt)
const hotScore = Math.round(engagementPart * 0.72 + recencyPart * 0.28)
const isHot = safeEngagement >= config.minEngagement && hotScore >= config.threshold
return {
hotScore: clamp(hotScore, 0, 100),
isHot,
}
}
export function isSignalHotColumnMissingError(error: unknown): boolean {
if (!(error instanceof Prisma.PrismaClientKnownRequestError)) {
return false
}
if (error.code !== 'P2022') {
return false
}
const column = String(error.meta?.column || '')
return column.includes('hotScore') || column.includes('isHot')
}
const SIGNAL_HOT_COLUMN_SUPPORT_TTL_MS = 5 * 60 * 1000
let signalHotColumnSupportCache: { value: boolean; checkedAt: number } | null = null
let signalHotColumnSupportPending: Promise<boolean> | null = null
export function markSignalHotColumnsUnsupported(): void {
signalHotColumnSupportCache = {
value: false,
checkedAt: Date.now(),
}
}
export async function supportsSignalHotColumns(
prisma: PrismaClient
): Promise<boolean> {
const now = Date.now()
if (
signalHotColumnSupportCache &&
now - signalHotColumnSupportCache.checkedAt <= SIGNAL_HOT_COLUMN_SUPPORT_TTL_MS
) {
return signalHotColumnSupportCache.value
}
if (signalHotColumnSupportPending) {
return signalHotColumnSupportPending
}
signalHotColumnSupportPending = prisma
.$queryRaw<Array<{ column_name: string }>>(Prisma.sql`
SELECT column_name
FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'signals'
AND column_name IN ('hotScore', 'isHot')
`)
.then((rows) => {
const columns = new Set(rows.map((row) => row.column_name))
const value = columns.has('hotScore') && columns.has('isHot')
signalHotColumnSupportCache = {
value,
checkedAt: Date.now(),
}
return value
})
.catch(() => {
signalHotColumnSupportCache = {
value: false,
checkedAt: Date.now(),
}
return false
})
.finally(() => {
signalHotColumnSupportPending = null
})
return signalHotColumnSupportPending
}