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 BOT_DATA_PATH = resolve(__dirname, "..", "data", "bot-characters.json"); const MODEL = process.env.DEEPSEEK_MODEL || "deepseek-chat"; const LAYER = { SESSION: "SESSION", WORKING: "WORKING", LONGTERM: "LONGTERM", }; const MEMORY_TYPE = { EXPERIENCE: "experience", INTERACTION: "interaction", FEEDBACK: "feedback", SKILL_USAGE: "skill_usage", REFLECTION: "reflection", USER_MODEL: "user_model", }; const QUOTA = { high: { followUpPerDay: 6 }, medium: { followUpPerDay: 3 }, low: { followUpPerDay: 1 }, }; const FEEDBACK_LOOKBACK_HOURS = 24; const FOLLOW_UP_PROBABILITY = 0.6; const MIN_FEEDBACK_TO_FOLLOWUP = 1; const MAX_FOLLOWUP_PER_RUN_PER_BOT = 3; function ts() { return `[${new Date().toISOString()}]`; } function pick(arr) { return arr[Math.floor(Math.random() * arr.length)]; } 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 }; } async function getOrCreateTodayStat(botUserId) { const today = startOfToday(); return prisma.botDailyStat.upsert({ where: { botId_date: { botId: botUserId, date: today } }, create: { botId: botUserId, date: today }, update: {}, }); } async function incrementStat(botUserId, field, by = 1) { const today = startOfToday(); await prisma.botDailyStat.upsert({ where: { botId_date: { botId: botUserId, date: today } }, create: { botId: botUserId, date: today, [field]: by }, update: { [field]: { increment: by } }, }); } async function getProcessedReplyIds(botUserId) { const memories = await prisma.botMemory.findMany({ where: { botId: botUserId, memoryType: MEMORY_TYPE.FEEDBACK, }, select: { contextTags: true }, }); const ids = new Set(); for (const m of memories) { const tags = m.contextTags; if (tags && Array.isArray(tags.replyIds)) { for (const id of tags.replyIds) ids.add(id); } } return ids; } function buildFollowUpPrompt(bot, topicTitle, topicContent, originalPost, allReplies) { const p = bot.personality; const repliesText = allReplies .map((r, i) => `${i + 1}. ${r.author}: ${r.content.slice(0, 250)}`) .join("\n"); return `你正在参与"追光AI行业论坛"。你之前在"${topicTitle}"这个话题下发了主帖,现在有用户回复了你,你需要用你的人格接着聊。 [你的角色设定] 你是【${bot.displayName}】,${p.identity} 你擅长的领域:${p.expertise.join("、")} 你说话的风格:${p.speakingStyle} 你的口头禅:${p.catchphrase} 你的核心立场:${p.stance} [你的原始主帖] ${topicContent} [新收到的回复] ${repliesText} [二次回复要求] 1. 像真人接话,120-350字,不能太长刷屏 2. 必须对前面至少一个具体观点有回应(赞同/质疑/补充案例/反问) 3. 保持你的人设语气和立场 4. 不要重复你自己主帖的内容 5. 留一点钩子让讨论继续 [禁止事项] - 禁止使用"首先/其次/最后""从以下几个方面""综上所述" - 禁止表现得像AI助手 - 禁止使用${(p.forbiddenPatterns || []).join("、")} 只输出JSON:{"content": "你的回复内容"}`; } async function callDeepSeek(prompt) { const response = await withRetry(() => openai.chat.completions.create({ model: MODEL, messages: [{ role: "user", content: prompt }], temperature: 0.88, max_tokens: 800, }) ); const text = response.choices[0].message.content.trim(); const jsonMatch = text.match(/\{[\s\S]*\}/); if (!jsonMatch) { throw new Error(`无法解析JSON: ${text.slice(0, 200)}`); } return JSON.parse(jsonMatch[0]); } async function saveMemory(botUserId, memoryType, content, importance, contextTags, ttlDays = null) { const data = { botId: botUserId, memoryType, layer: importance >= 0.7 ? LAYER.WORKING : LAYER.LONGTERM, content, importance, }; if (contextTags) data.contextTags = contextTags; if (ttlDays) { const ttl = new Date(); ttl.setDate(ttl.getDate() + ttlDays); data.ttl = ttl; } await prisma.botMemory.create({ data }); } async function processBotFeedback(botKey, botChar) { const botUser = await prisma.user.findFirst({ where: { email: `bot_${botKey}@zhuiguang.ai` }, }); if (!botUser) { console.log(`${ts()} Bot user not found: ${botKey}`); return; } const botConfig = await prisma.botConfig.findUnique({ where: { userId: botUser.id }, }); if (!botConfig) { console.log(`${ts()} BotConfig not found: ${botKey}`); return; } const todayStat = await getOrCreateTodayStat(botConfig.id); const activityLevel = botChar.activityLevel || "medium"; const quota = QUOTA[activityLevel] || QUOTA.medium; if (todayStat.followUpSent >= quota.followUpPerDay) { console.log(`${ts()} ${botChar.displayName} 今日跟帖配额已用完 (${todayStat.followUpSent}/${quota.followUpPerDay})`); return; } const since = new Date(Date.now() - FEEDBACK_LOOKBACK_HOURS * 60 * 60 * 1000); const processedIds = await getProcessedReplyIds(botConfig.id); const myTopics = await prisma.forumTopic.findMany({ where: { userId: botUser.id, createdAt: { gt: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000) }, isLocked: false, }, include: { category: { select: { name: true, slug: true } }, posts: { where: { createdAt: { gt: since } }, include: { user: { select: { id: true, name: true, isBot: true } } }, orderBy: { createdAt: "asc" }, }, }, orderBy: { lastReplyAt: "desc" }, take: 10, }); let totalNewReplies = 0; let humanReplies = 0; let followUpsSent = 0; for (const topic of myTopics) { const newReplies = topic.posts.filter(p => p.userId !== botUser.id && !processedIds.has(p.id)); if (newReplies.length === 0) continue; const replyIds = newReplies.map(p => p.id); const humanCount = newReplies.filter(p => !p.user.isBot).length; const humanRepliers = newReplies.filter(p => !p.user.isBot).map(p => ({ id: p.userId, name: p.user.name || "匿名", })); await saveMemory( botConfig.id, MEMORY_TYPE.FEEDBACK, { topicTitle: topic.title, topicId: topic.id, forum: topic.category.name, replyCount: newReplies.length, humanCount, repliers: newReplies.map(p => ({ name: p.user.name || "匿名", isBot: p.user.isBot, excerpt: p.content.slice(0, 120), })), }, humanCount > 0 ? 0.8 : 0.4, { topicId: topic.id, forumSlug: topic.category.slug, replyIds, humanRepliers, source: "feedback_loop", }, 30 ); totalNewReplies += newReplies.length; humanReplies += humanCount; await incrementStat(botConfig.id, "feedbackProcessed", 1); await incrementStat(botConfig.id, "repliesReceived", newReplies.length); if (humanCount > 0) { await incrementStat(botConfig.id, "humanInteractions", humanCount); } if ( newReplies.length >= MIN_FEEDBACK_TO_FOLLOWUP && Math.random() < FOLLOW_UP_PROBABILITY && followUpsSent < MAX_FOLLOWUP_PER_RUN_PER_BOT && todayStat.followUpSent + followUpsSent < quota.followUpPerDay ) { try { const repliesForPrompt = newReplies.map(p => ({ author: p.user.name || "匿名", content: p.content, })); const prompt = buildFollowUpPrompt( botChar, topic.title, topic.content, null, repliesForPrompt ); const result = await callDeepSeek(prompt); const followUpContent = (result.content || "").trim(); if (!followUpContent || followUpContent.length < 30) { console.log(`${ts()} ${botChar.displayName} 二次回复内容过短,跳过`); continue; } const post = await prisma.forumPost.create({ data: { topicId: topic.id, userId: botUser.id, content: followUpContent.slice(0, 5000), }, }); await prisma.forumTopic.update({ where: { id: topic.id }, data: { replyCount: { increment: 1 }, lastReplyAt: new Date(), updatedAt: new Date(), }, }); await saveMemory( botConfig.id, MEMORY_TYPE.INTERACTION, { action: "follow_up_reply", forum: topic.category.name, topicTitle: topic.title, topicId: topic.id, triggeredBy: "feedback_loop", summary: followUpContent.slice(0, 100), }, 0.6, { topicId: topic.id, forumSlug: topic.category.slug, postId: post.id, source: "feedback_loop", }, 14 ); followUpsSent++; await incrementStat(botConfig.id, "followUpSent", 1); await incrementStat(botConfig.id, "replySent", 1); console.log(`${ts()} ${botChar.displayName} 二次回复: "${followUpContent.slice(0, 40)}..."`); } catch (err) { console.error(`${ts()} ${botChar.displayName} 二次回复失败:`, err.message); } } } if (totalNewReplies > 0) { console.log(`${ts()} ${botChar.displayName}: 处理 ${totalNewReplies} 条新回复 (${humanReplies} 真人) → 跟帖 ${followUpsSent} 条`); } } async function logTask(action, status, detail) { try { await prisma.taskLog.create({ data: { taskKey: "bot-feedback-loop", taskName: "Bot反馈循环", status, startedAt: new Date(), finishedAt: new Date(), triggerBy: "cron", result: { action, detail }, }, }); } catch (e) { console.error(`${ts()} TaskLog error:`, e.message); } } async function main() { console.log(`${ts()} Bot Feedback Loop starting...`); if (!process.env.DEEPSEEK_API_KEY) { throw new Error("DEEPSEEK_API_KEY 未配置"); } 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 processed = 0; for (const botChar of botChars) { try { await processBotFeedback(botChar.key, botChar); processed++; } catch (err) { console.error(`${ts()} Error processing ${botChar.key}:`, err.message); } } await logTask("feedback_loop", "success", `处理 ${processed} 个数字人`); console.log(`${ts()} Bot Feedback Loop done.`); } main() .catch(async (e) => { console.error(`${ts()} Fatal error:`, e); // 写一条失败日志方便后台追溯 try { await prisma.taskLog.create({ data: { taskKey: "bot-feedback-loop", 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(); });