145 lines
4.1 KiB
TypeScript
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
|
|
}
|