import { PrismaClient } from "@prisma/client"; import { PrismaMariaDb } from "@prisma/adapter-mariadb"; import { readFileSync } from "fs"; import { resolve, dirname } from "path"; import { fileURLToPath } from "url"; import "dotenv/config"; import { updatePersona as sharedUpdatePersona, computePersona } from "./lib/bot-persona.mjs"; const __dirname = dirname(fileURLToPath(import.meta.url)); const base = process.env.DATABASE_URL.replace("mysql://", "mariadb://"); const sep = base.includes("?") ? "&" : "?"; const connectionString = `${base}${sep}connection_limit=5&pool_timeout=30`; const adapter = new PrismaMariaDb(connectionString); const prisma = new PrismaClient({ adapter }); const BOT_DATA_PATH = resolve(__dirname, "..", "data", "bot-characters.json"); const LOOKBACK_DAYS = 14; const MIN_AFFINITY_SAMPLES = 1; function ts() { return `[${new Date().toISOString()}]`; } async function loadBotCharacters() { const raw = readFileSync(BOT_DATA_PATH, "utf-8"); const { characters } = JSON.parse(raw); const map = {}; for (const c of characters) map[c.key] = c; return { characters, map }; } async function computeAffinity(botUser, botConfigId) { const since = new Date(Date.now() - LOOKBACK_DAYS * 24 * 60 * 60 * 1000); const myTopics = await prisma.forumTopic.findMany({ where: { userId: botUser.id, createdAt: { gt: since } }, select: { id: true, category: { select: { slug: true } } }, }); if (myTopics.length === 0) return { homeForums: new Map(), crossCount: 0 }; const topicIds = myTopics.map((t) => t.id); const myForumSlugs = new Set(myTopics.map((t) => t.category.slug)); const humanRepliers = await prisma.forumPost.findMany({ where: { topicId: { in: topicIds }, user: { isBot: false }, }, include: { user: { select: { id: true, name: true, isBot: true, forumTopics: { where: { createdAt: { gt: since } }, select: { category: { select: { slug: true } } }, take: 10, }, }, }, topic: { select: { category: { select: { slug: true, name: true } } } }, }, }); const homeForumCount = new Map(); for (const p of humanRepliers) { const homeForums = p.user.forumTopics.map((t) => t.category.slug); if (homeForums.length === 0) continue; const counts = new Map(); for (const f of homeForums) counts.set(f, (counts.get(f) || 0) + 1); const sorted = Array.from(counts.entries()).sort((a, b) => b[1] - a[1]); const homeForum = sorted[0][0]; if (myForumSlugs.has(homeForum)) continue; homeForumCount.set(homeForum, (homeForumCount.get(homeForum) || 0) + 1); } return { homeForums: homeForumCount, crossCount: humanRepliers.length }; } async function updatePersona(botChar) { const botUser = await prisma.user.findFirst({ where: { email: `bot_${botChar.key}@zhuiguang.ai` }, }); if (!botUser) return false; const botConfig = await prisma.botConfig.findUnique({ where: { userId: botUser.id }, }); if (!botConfig) return false; // 调用 lib/bot-persona.mjs 的共享实现(同步) const result = await sharedUpdatePersona(botUser, botConfig.id); if (!result.success) return false; // 再读一份用于日志输出 const persona = await computePersona(botUser, botConfig.id); console.log( `${ts()} 📐 ${botChar.displayName} 画像: 话题${persona.postSampleCount}条 / 平均话题${persona.avgTopicLength}字 / 平均回复${persona.avgReplyLength}字 / 问号率${persona.questionRatio}` ); return true; } async function updateAffinity(botChar) { const botUser = await prisma.user.findFirst({ where: { email: `bot_${botChar.key}@zhuiguang.ai` }, }); if (!botUser) return; const botConfig = await prisma.botConfig.findUnique({ where: { userId: botUser.id }, }); if (!botConfig) return; const { homeForums, crossCount } = await computeAffinity(botUser, botConfig.id); const primaryForumSlugs = new Set( (botChar.personality?.primaryForums || []).map((s) => s) ); await prisma.botCrossForumAffinity.deleteMany({ where: { botId: botConfig.id, forumSlug: { in: Array.from(primaryForumSlugs) }, }, }); if (homeForums.size === 0) { return; } const totalCrossSamples = Array.from(homeForums.values()).reduce((s, n) => s + n, 0); for (const [slug, count] of homeForums.entries()) { if (count < MIN_AFFINITY_SAMPLES) continue; const affinity = Math.min(1, count / Math.max(crossCount / 4, 5)); const reason = `${count}/${totalCrossSamples} 跨板块互动`; await prisma.botCrossForumAffinity.upsert({ where: { botId_forumSlug: { botId: botConfig.id, forumSlug: slug } }, create: { botId: botConfig.id, forumSlug: slug, affinity, sampleCount: count, reason, }, update: { affinity, sampleCount: count, reason, }, }); } if (homeForums.size > 0) { const top = Array.from(homeForums.entries()) .sort((a, b) => b[1] - a[1]) .slice(0, 3) .map(([s, c]) => `${s}(${c})`) .join(", "); console.log(`${ts()} 🌍 ${botChar.displayName} 跨板块亲和度: ${top}`); } } async function main() { console.log(`${ts()} Bot Affinity & Persona Update starting...`); if (!process.env.DATABASE_URL) { throw new Error("DATABASE_URL 未配置"); } const { characters } = await loadBotCharacters(); const botChars = characters.filter((c) => !c.role || c.role !== "passerby"); console.log(`${ts()} Loaded ${botChars.length} expert bots.`); let personaCount = 0; let affinityCount = 0; for (const botChar of botChars) { try { if (await updatePersona(botChar)) personaCount++; await updateAffinity(botChar); affinityCount++; } catch (err) { console.error(`${ts()} Error processing ${botChar.key}:`, err.message); } } await prisma.taskLog.create({ data: { taskKey: "bot-affinity-update", taskName: "Bot亲和度与画像", status: "success", startedAt: new Date(), finishedAt: new Date(), triggerBy: "cron", result: { personaCount, affinityCount, totalBots: botChars.length }, }, }); console.log( `${ts()} Bot Affinity & Persona Update done. persona=${personaCount} affinity=${affinityCount}` ); } main() .catch(async (e) => { console.error(`${ts()} Fatal error:`, e); try { await prisma.taskLog.create({ data: { taskKey: "bot-affinity-update", taskName: "Bot亲和度与画像", status: "failed", startedAt: new Date(), finishedAt: new Date(), triggerBy: "cron", error: (e && e.message ? e.message : String(e)).slice(0, 1000), }, }); } catch {} process.exit(1); }) .finally(async () => { await prisma.$disconnect(); const { disconnectBotPersona } = await import("./lib/bot-persona.mjs"); await disconnectBotPersona(); });