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 OpenAI from "openai"; import "dotenv/config"; import { withRetry } from "./lib/retry.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 openai = new OpenAI({ apiKey: process.env.DEEPSEEK_API_KEY, baseURL: "https://api.deepseek.com/v1", }); const MODEL = process.env.DEEPSEEK_MODEL || "deepseek-chat"; const BOT_DATA_PATH = resolve(__dirname, "..", "data", "bot-characters.json"); const ENGAGEMENT_WEIGHTS = { REPLY_PER_TOPIC: 0.4, HUMAN_RATIO: 0.3, LIKES_LOG: 0.3, }; const TIER_THRESHOLDS = { high: 1.0, medium: 0.5, }; function ts() { return `[${new Date().toISOString()}]`; } function startOfToday() { // 用 UTC 午夜避开 Prisma @db.Date 写入时的时区截断 const d = new Date(); return new Date(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate())); } 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 }; } function calcEngagementScore(stats) { const { topicsCreated, repliesReceived, humanInteractions, likesReceived, repliesSent } = stats; const replyPerTopic = topicsCreated > 0 ? repliesReceived / topicsCreated : 0; const humanRatio = repliesSent + topicsCreated > 0 ? humanInteractions / (repliesSent + topicsCreated) : 0; const likesLog = Math.log(1 + likesReceived); return ( ENGAGEMENT_WEIGHTS.REPLY_PER_TOPIC * Math.min(replyPerTopic, 5) + ENGAGEMENT_WEIGHTS.HUMAN_RATIO * Math.min(humanRatio, 1) * 5 + ENGAGEMENT_WEIGHTS.LIKES_LOG * Math.min(likesLog, 5) ); } function decideTier(score, baseTier) { const baseRank = { low: 1, medium: 2, high: 3 }[baseTier] || 2; if (score >= TIER_THRESHOLDS.high) return "high"; if (score >= TIER_THRESHOLDS.medium) return baseRank >= 2 ? "medium" : "high"; if (score >= TIER_THRESHOLDS.medium * 0.6) return baseRank >= 2 ? "low" : "medium"; return "low"; } function buildReflectionPrompt(botChar, stats, score, nextTier) { const p = botChar.personality; return `你是【${botChar.displayName}】。本周你的论坛表现总结: [本周数据] - 发帖 ${stats.topicsCreated} 个 - 回复 ${stats.repliesSent} 条 - 收到回复 ${stats.repliesReceived} 条 - 真人互动 ${stats.humanInteractions} 次 - 收到点赞 ${stats.likesReceived} 个 - 互动得分 ${score.toFixed(2)} [下周配额] ${nextTier} [你的角色] ${p.identity} 风格:${p.speakingStyle} [任务] 请以第一人称写一段50-120字的本周复盘: 1. 数据怎么解读(不要数字罗列,要有你的判断) 2. 哪类话题效果好、哪类效果差 3. 下周你最想尝试的一个调整是什么 要求:保持你【${botChar.displayName}】的说话风格,简短口语化。 只输出JSON:{"reflection": "你的复盘内容"}`; } async function callDeepSeek(prompt) { const response = await withRetry(() => openai.chat.completions.create({ model: MODEL, messages: [{ role: "user", content: prompt }], temperature: 0.85, max_tokens: 400, }) ); const text = response.choices[0]?.message?.content?.trim() || ""; const jsonMatch = text.match(/\{[\s\S]*\}/); if (!jsonMatch) throw new Error("无法解析JSON"); return JSON.parse(jsonMatch[0]); } async function getWeekRange() { const today = startOfToday(); const dayOfWeek = today.getUTCDay(); // weekStart = today - 6 天(统一用 UTC 午夜) const weekStart = new Date(today); weekStart.setUTCDate(weekStart.getUTCDate() - 6); const weekEnd = today; return { weekStart, weekEnd }; } async function reviewBot(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 { weekStart, weekEnd } = await getWeekRange(); // weekStart / weekEnd 已经是 UTC 午夜了,无需再 setHours const weekStartDate = new Date(weekStart); const weekEndDate = new Date(weekEnd); const stats = await prisma.botDailyStat.aggregate({ where: { botId: botConfig.id, date: { gte: weekStartDate, lte: weekEndDate }, }, _sum: { topicCreated: true, replySent: true, followUpSent: true, repliesReceived: true, likesReceived: true, humanInteractions: true, feedbackProcessed: true, }, }); const sum = stats._sum; const flatStats = { topicsCreated: sum.topicCreated || 0, repliesSent: sum.replySent || 0, repliesReceived: sum.repliesReceived || 0, humanInteractions: sum.humanInteractions || 0, likesReceived: sum.likesReceived || 0, }; if (flatStats.topicsCreated === 0 && flatStats.repliesSent === 0) { console.log(`${ts()} ${botChar.displayName}: 本周无活动,跳过`); return; } const score = calcEngagementScore(flatStats); const baseTier = botChar.activityLevel || "medium"; const nextTier = decideTier(score, baseTier); let reflection = null; try { const prompt = buildReflectionPrompt(botChar, flatStats, score, nextTier); const result = await callDeepSeek(prompt); reflection = (result.reflection || "").trim().slice(0, 500) || null; } catch (err) { console.warn(`${ts()} ${botChar.displayName} 反思生成失败:`, err.message); } const review = await prisma.botWeeklyReview.upsert({ where: { botId_weekStart: { botId: botConfig.id, weekStart: weekStartDate } }, create: { botId: botConfig.id, weekStart: weekStartDate, weekEnd: weekEndDate, topicsCreated: flatStats.topicsCreated, repliesSent: flatStats.repliesSent, repliesReceived: flatStats.repliesReceived, humanRatio: flatStats.repliesSent + flatStats.topicsCreated > 0 ? flatStats.humanInteractions / (flatStats.repliesSent + flatStats.topicsCreated) : 0, likesReceived: flatStats.likesReceived, engagementScore: score, nextWeekQuota: nextTier, reflection, }, update: { weekEnd: weekEndDate, topicsCreated: flatStats.topicsCreated, repliesSent: flatStats.repliesSent, repliesReceived: flatStats.repliesReceived, humanRatio: flatStats.repliesSent + flatStats.topicsCreated > 0 ? flatStats.humanInteractions / (flatStats.repliesSent + flatStats.topicsCreated) : 0, likesReceived: flatStats.likesReceived, engagementScore: score, nextWeekQuota: nextTier, reflection, }, }); await prisma.botMemory.create({ data: { botId: botConfig.id, memoryType: "reflection", layer: "WORKING", content: { action: "weekly_review", score, topicsCreated: flatStats.topicsCreated, repliesReceived: flatStats.repliesReceived, humanInteractions: flatStats.humanInteractions, likesReceived: flatStats.likesReceived, nextTier, reflection, }, importance: 0.7, contextTags: { reviewId: review.id, weekStart: weekStartDate.toISOString().slice(0, 10), source: "weekly_review", }, }, }); console.log( `${ts()} ${botChar.displayName}: 得分 ${score.toFixed(2)} | ${baseTier} → ${nextTier} | 帖${flatStats.topicsCreated}/收${flatStats.repliesReceived}/真人${flatStats.humanInteractions}` ); } async function main() { console.log(`${ts()} Bot Weekly Review starting...`); if (!process.env.DEEPSEEK_API_KEY) { throw new Error("DEEPSEEK_API_KEY 未配置"); } const { characters } = await loadBotCharacters(); const botChars = characters.filter((c) => !c.role || c.role !== "passerby"); console.log(`${ts()} Loaded ${botChars.length} expert bots.`); let processed = 0; for (const botChar of botChars) { try { await reviewBot(botChar); processed++; } catch (err) { console.error(`${ts()} Error processing ${botChar.key}:`, err.message); } } await prisma.taskLog.create({ data: { taskKey: "bot-weekly-review", taskName: "Bot周度复盘", status: "success", startedAt: new Date(), finishedAt: new Date(), triggerBy: "cron", result: { processedBots: processed }, }, }); console.log(`${ts()} Bot Weekly Review done. Processed ${processed} bots.`); } main() .catch(async (e) => { console.error(`${ts()} Fatal error:`, e); try { await prisma.taskLog.create({ data: { taskKey: "bot-weekly-review", 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(); });