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"; import { schedulePersonaRefresh, disconnectBotPersona } from "./lib/bot-persona.mjs"; import { pickVariantForPrompt, commitAssignment, buildVariantPersonaBlock, disconnectBotExperiment } from "./lib/bot-persona-experiment.mjs"; import { getActiveLearningForBot, buildAdversarialBlock, markLearningUsed, disconnectAdversarialLearning } from "./lib/bot-adversarial-learning.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=10&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 FORUM_SLUGS = [ "ec-platform", "ec-livestream", "ec-private", "ec-group", "ec-dtc", "ec-supply", "ai-tools-app", "ai-startup-forum", "ai-saas", "ai-hardware", "ai-llm", "ct-short-video", "ct-live", "ct-writing", "ct-mcn", "ct-podcast", "fi-stock", "fi-crypto", "fi-insurance", "fi-pe", "fi-forex", "cb-ecommerce", "cb-factory", "cb-logistics", "cb-payment", "cb-brand", "re-residential", "re-commercial", "re-renovation", "re-property", "fd-restaurant", "fd-tea", "fd-prepared", "fd-supply", "fd-delivery", "ll-beauty", "ll-fitness", "ll-pet", "ll-housekeeping", "ll-repair", "ll-edu-local", "hl-cosmetic", "hl-wellness", "hl-elderly", "hl-rehab", "hl-mental", "edu-skills", "edu-knowledge", "edu-abroad", "edu-corporate", "edu-k12", ]; const FORUM_NAMES = { "ec-platform": "平台电商", "ec-livestream": "直播带货", "ec-private": "私域电商", "ec-group": "社区团购", "ec-dtc": "独立站/DTC", "ec-supply": "选品与供应链", "ai-tools-app": "AI工具与应用", "ai-startup-forum": "AI创业", "ai-saas": "SaaS出海", "ai-hardware": "智能硬件", "ai-llm": "大模型与开源", "ct-short-video": "短视频创作", "ct-live": "直播运营", "ct-writing": "图文内容", "ct-mcn": "MCN机构", "ct-podcast": "播客与音频", "fi-stock": "股票与基金", "fi-crypto": "加密货币/Web3", "fi-insurance": "保险与理财", "fi-pe": "PE与VC", "fi-forex": "外汇与期货", "cb-ecommerce": "跨境电商平台", "cb-factory": "外贸工厂/B2B", "cb-logistics": "海外仓与物流", "cb-payment": "跨境支付合规", "cb-brand": "品牌出海/DTC", "re-residential": "住宅买卖", "re-commercial": "商业地产", "re-renovation": "装修与家居", "re-property": "物业与租赁", "fd-restaurant": "餐饮开店", "fd-tea": "茶饮与咖啡", "fd-prepared": "预制菜加工", "fd-supply": "食品供应链", "fd-delivery": "外卖运营", "ll-beauty": "美容美发", "ll-fitness": "健身运动", "ll-pet": "宠物经济", "ll-housekeeping": "家政服务", "ll-repair": "维修与数码", "ll-edu-local": "本地教培", "hl-cosmetic": "医疗美容", "hl-wellness": "健康管理", "hl-elderly": "养老服务", "hl-rehab": "运动康复", "hl-mental": "心理健康", "edu-skills": "职业技能", "edu-knowledge": "知识付费", "edu-abroad": "留学与语言", "edu-corporate": "企业培训", "edu-k12": "K12与家庭", }; const SUB_CATEGORY_MAP = { "ec-platform": ["ec-platform"], "ec-livestream": ["ec-livestream"], "ec-private": ["ec-private"], "ec-group": ["ec-group"], "ec-dtc": ["ec-dtc"], "ec-supply": ["ec-supply"], "ai-tools-app": ["ai-tools-app"], "ai-startup-forum": ["ai-startup-forum"], "ai-saas": ["ai-saas"], "ai-hardware": ["ai-hardware"], "ai-llm": ["ai-llm"], "ct-short-video": ["ct-short-video"], "ct-live": ["ct-live"], "ct-writing": ["ct-writing"], "ct-mcn": ["ct-mcn"], "ct-podcast": ["ct-podcast"], "fi-stock": ["fi-stock"], "fi-crypto": ["fi-crypto"], "fi-insurance": ["fi-insurance"], "fi-pe": ["fi-pe"], "fi-forex": ["fi-forex"], "cb-ecommerce": ["cb-ecommerce"], "cb-factory": ["cb-factory"], "cb-logistics": ["cb-logistics"], "cb-payment": ["cb-payment"], "cb-brand": ["cb-brand"], "re-residential": ["re-residential"], "re-commercial": ["re-commercial"], "re-renovation": ["re-renovation"], "re-property": ["re-property"], "fd-restaurant": ["fd-restaurant"], "fd-tea": ["fd-tea"], "fd-prepared": ["fd-prepared"], "fd-supply": ["fd-supply"], "fd-delivery": ["fd-delivery"], "ll-beauty": ["ll-beauty"], "ll-fitness": ["ll-fitness"], "ll-pet": ["ll-pet"], "ll-housekeeping": ["ll-housekeeping"], "ll-repair": ["ll-repair"], "ll-edu-local": ["ll-edu-local"], "hl-cosmetic": ["hl-cosmetic"], "hl-wellness": ["hl-wellness"], "hl-elderly": ["hl-elderly"], "hl-rehab": ["hl-rehab"], "hl-mental": ["hl-mental"], "edu-skills": ["edu-skills"], "edu-knowledge": ["edu-knowledge"], "edu-abroad": ["edu-abroad"], "edu-corporate": ["edu-corporate"], "edu-k12": ["edu-k12"], }; const NEWS_SEARCH_QUERIES = { "ec-platform": "电商 淘宝 京东 拼多多 平台 最新", "ec-livestream": "直播带货 抖音直播 快手电商 最新", "ec-private": "私域流量 微信电商 社群运营 最新", "ec-group": "社区团购 美团优选 多多买菜 最新", "ec-dtc": "DTC品牌 Shopify 独立站 电商出海", "ec-supply": "供应链 选品 电商货源 库存管理", "ai-tools-app": "AI工具 ChatGPT Claude AI应用 最新", "ai-startup-forum": "AI创业 人工智能创业 融资 最新", "ai-saas": "SaaS AI出海 软件服务 最新", "ai-hardware": "AI硬件 智能设备 机器人 IoT", "ai-llm": "大模型 LLM 开源模型 人工智能", "ct-short-video": "短视频 抖音 B站 创作者 最新", "ct-live": "直播运营 直播带货 主播 最新", "ct-writing": "公众号 小红书 自媒体 图文创作", "ct-mcn": "MCN机构 达人孵化 网红经济", "ct-podcast": "播客 小宇宙 音频创作 最新", "fi-stock": "A股 港股 美股 股票 基金 最新", "fi-crypto": "比特币 加密货币 区块链 Web3", "fi-insurance": "保险 理财 资产配置 最新", "fi-pe": "PE VC 风险投资 融资 最新", "fi-forex": "外汇 期货 大宗商品 黄金 最新", "cb-ecommerce": "跨境电商 Amazon Temu TikTokShop", "cb-factory": "外贸工厂 制造业 出口 B2B 最新", "cb-logistics": "跨境物流 海外仓 FBA 供应链", "cb-payment": "跨境支付 收款 汇率 税务合规", "cb-brand": "品牌出海 DTC 全球化 本地化", "re-residential": "房价 楼市 买房 学区房 最新", "re-commercial": "商铺 写字楼 商业地产 最新", "re-renovation": "装修 家居 建材 设计 最新", "re-property": "物业 租赁 租房 资产托管", "fd-restaurant": "餐饮创业 开店 餐厅 加盟 最新", "fd-tea": "茶饮 咖啡 奶茶 新茶饮 饮品", "fd-prepared": "预制菜 中央厨房 食品加工", "fd-supply": "食品供应链 食材 冷链 配送", "fd-delivery": "外卖 美团 饿了么 外卖运营", "ll-beauty": "美容 美发 美甲 皮肤管理 最新", "ll-fitness": "健身 瑜伽 健身房 运动 最新", "ll-pet": "宠物 宠物店 宠物医疗 最新", "ll-housekeeping": "家政 保洁 月嫂 保姆 最新", "ll-repair": "手机维修 家电维修 数码维修", "ll-edu-local": "培训 艺术培训 体育培训 托管", "hl-cosmetic": "医美 轻医美 热玛吉 水光针", "hl-wellness": "健康管理 保健品 慢病 体检", "hl-elderly": "养老 老龄化 居家养老 适老化", "hl-rehab": "运动康复 产后康复 物理治疗", "hl-mental": "心理健康 心理咨询 情绪管理", "edu-skills": "职业技能 编程培训 设计培训 考证", "edu-knowledge": "知识付费 在线教育 课程 社群", "edu-abroad": "留学 出国 语言培训 游学", "edu-corporate": "企业培训 管理培训 团建 内训", "edu-k12": "K12教育 学科辅导 素质教育 家庭", }; const LAYER = { SESSION: "SESSION", WORKING: "WORKING", LONGTERM: "LONGTERM", }; // 每 bot 每日发帖/回复配额(v2 翻倍:原 high 3/10, medium 1/5, low 0/2) // 2026-06-10 起翻倍,论坛日均量从 ~54 提升到 ~120 const QUOTA = { high: { topicPerDay: 4, replyPerDay: 15 }, medium: { topicPerDay: 2, replyPerDay: 8 }, low: { topicPerDay: 1, replyPerDay: 4 }, }; const STYLE_VARIATION = { QUESTION_RATE: 0.6, STORY_RATE: 0.25, CONTRARIAN_RATE: 0.2, CASUAL_RATE: 0.4, EMOTIONAL_RATE: 0.3, DATA_DRIVEN_RATE: 0.2, }; const REPLY_STRATEGIES = [ "agree_with_evidence", "polite_disagreement", "supplement_case", "ask_followup", "share_personal_experience", "raise_concern", "summarize_and_extend", ]; // 跑批节奏控制(v2 翻倍:原 4+4=8 板块,v2 5+5=10 板块) // 2026-06-10 起翻倍:原每次 4 expert + 4 passerby (×2轮) → 5 expert + 5 passerby (×2轮) const EXPERT_FORUMS_PER_RUN = 5; // 原 4 const PASSERBY_FORUMS_PER_RUN = 5; // 原 4 const MAX_REPLIERS_PER_FORUM = 4; // 原 slice(0, 3),单板块最多 4 个 bot 回复 // ============ Bot 之间互相 @ 配置 ============ const MENTION_CONFIG = { PROBABILITY: 0.45, // 触发 @-mention 注入的概率 MAX_PER_CONTENT: 2, // 单次回复/帖子最多 @ 几个 bot PARTNER_LOOKBACK_DAYS: 14, // 计算互动伙伴的回看窗口 PARTNER_MIN_INTERACTIONS: 1, // 至少 N 次同话题互动才计作伙伴 PARTNER_POOL_LIMIT: 4, // 候选池大小(注入 prompt 的最多几个) }; // 真实点赞配置:bot 之间互相点赞,生成"真实"like 数据(写入 forumTopicLike / forumPostLike 并累加 likeCount) // v2 翻倍:原 MAX_LIKES_PER_RUN 3 → 5 const BOT_LIKE_CONFIG = { PROBABILITY_PER_BOT: 0.65, // 每个 bot 每次执行触发自动点赞的概率 (0.6 → 0.65) MAX_LIKES_PER_RUN: 5, // 单次执行最多点赞 N 条 (3 → 5) MIN_QUALITY_LIKES: 1, // 至少收到的赞 >= N 才考虑被点赞 LOOKBACK_DAYS: 30, // 仅给最近 N 天的内容点赞 TOPIC_WEIGHT: 0.4, // 点 topic 的概率 CROSS_FORUM_BOOST: 0.3, // 跨板块伙伴的额外权重 HUMAN_LIKE_PROBABILITY: 0.15, // Bot 给真人内容点赞的概率(避免 Bot 生态自嗨) }; function ts() { return `[${new Date().toISOString()}]`; } function pick(arr) { return arr[Math.floor(Math.random() * arr.length)]; } // 关键:Prisma 把 JS Date 写进 @db.Date 时会按 UTC 截断 // 中国时区 UTC+8,本地 0 点的 JS Date 写库时被截到 UTC 的前一天 16:00,前一天的统计就错了 // 用 Date.UTC 构造"UTC 午夜",MariaDB 拿到的就是纯日期值,避开时区坑 function startOfToday() { const d = new Date(); // 注意:Date.UTC 的月份也是 0-11 return new Date(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate())); } function getCurrentHour() { return new Date().getHours(); } function isInActiveHours(activeHours) { if (!activeHours) return true; const now = new Date(); const day = now.getDay(); const isWeekend = day === 0 || day === 6; const ranges = isWeekend ? activeHours.weekend : activeHours.weekday; if (!ranges || ranges.length === 0) return true; const hour = getCurrentHour(); for (const range of ranges) { if (range === "全天") return true; const m = range.match(/^(\d{1,2}):(\d{2})-(\d{1,2}):(\d{2})$/); if (!m) continue; const start = parseInt(m[1]); const end = parseInt(m[3]); if (start < end) { if (hour >= start && hour < end) return true; } else { if (hour >= start || hour < end) return true; } } return false; } function pickReplyStrategy() { return pick(REPLY_STRATEGIES); } async function getOrCreateTodayStat(botConfigId) { const today = startOfToday(); return prisma.botDailyStat.upsert({ where: { botId_date: { botId: botConfigId, date: today } }, create: { botId: botConfigId, date: today }, update: {}, }); } async function incrementStat(botConfigId, field, by = 1) { const today = startOfToday(); await prisma.botDailyStat.upsert({ where: { botId_date: { botId: botConfigId, date: today } }, create: { botId: botConfigId, date: today, [field]: by }, update: { [field]: { increment: by } }, }); } function slugify(str) { const clean = str.toLowerCase().replace(/[^a-z0-9\u4e00-\u9fa5]+/g, "-").replace(/^-|-$/g, "").substring(0, 80); return `${clean}-${Date.now().toString(36)}`; } async function fetchGoogleNewsRSS(query, lang = "zh-CN", country = "CN") { try { const url = `https://news.google.com/rss/search?q=${encodeURIComponent(query)}&hl=${lang}&gl=${country}&ceid=${country}:${lang === "zh-CN" ? "zh-Hans" : "en"}`; const response = await fetch(url, { headers: { "User-Agent": "Mozilla/5.0 (compatible; PasserbyBot/1.0)" }, signal: AbortSignal.timeout(3000), }); if (!response.ok) return []; const xml = await response.text(); const items = []; const itemRegex = /([\s\S]*?)<\/item>/g; let match; while ((match = itemRegex.exec(xml)) !== null) { const block = match[1]; const titleMatch = block.match(/<!\[CDATA\[([\s\S]*?)\]\]><\/title>/) || block.match(/<title>([\s\S]*?)<\/title>/); const linkMatch = block.match(/<link>([\s\S]*?)<\/link>/); const pubDateMatch = block.match(/<pubDate>([\s\S]*?)<\/pubDate>/); if (titleMatch) { items.push({ title: titleMatch[1].trim(), url: linkMatch ? linkMatch[1].trim() : "", pubDate: pubDateMatch ? pubDateMatch[1].trim() : "", }); } } return items.filter(item => { if (!item.pubDate) return true; const pubTime = new Date(item.pubDate).getTime(); const threeDaysAgo = Date.now() - 3 * 24 * 60 * 60 * 1000; return pubTime > threeDaysAgo; }).slice(0, 8); } catch { return []; } } async function fetchRecentNewsForCategory(forumSlug) { const query = NEWS_SEARCH_QUERIES[forumSlug]; if (!query) return []; console.log(`${ts()} Searching news: "${query}"`); const cnNews = await fetchGoogleNewsRSS(query, "zh-CN", "CN"); console.log(`${ts()} Found ${cnNews.length} recent Chinese news items`); return cnNews; } function buildPasserbyTopicPrompt(bot, forumName, forumDescription, newsItems, options = {}) { const { variantBlock = "" } = options; const p = bot.personality; const today = new Date().toLocaleDateString("zh-CN", { year: "numeric", month: "long", day: "numeric" }); const newsText = newsItems.length > 0 ? newsItems.map((n, i) => `${i + 1}. [${n.pubDate || "近期"}] ${n.title}`).join("\n") : `(搜索不到实时新闻,请充分利用DeepSeek知识库中截至2025年6月的最新行业信息。今天是${today},你必须让话题看起来像是今天或最近几天发生的。可以提及:今年发生的重大事件、近几个月出台的新政策、当前季节性的行业趋势、最近热门的讨论。在你的发言中自然地加入时间锚点,比如"上个月刚出来的…"、"最近大家都在说…"、"这个月的数据显示…"等,让读者第一眼就感受到这是当前的话题而不是老帖)`; const passerbyType = p.passerbyType || "observer"; const typeInstructions = { news: "你是一个善于发现行业动态的人。请从上面的近期新闻中选一个最有话题性的,用你的视角提出讨论。话题必须引用具体新闻事件,不能泛泛而谈", observer: "你是一个观察敏锐的人。请结合上面新闻中反映的行业趋势,提出一个值得深挖的问题。话题要有洞察力,引导大家思考现象背后的原因", problem: "你是一个正在这个行业里遇到实际困难的人。请结合上面的近期新闻和行业现状,提出一个具体的、有代表性的困扰或难题。问题要有真实感,让人感同身受", }; const instruction = typeInstructions[passerbyType] || typeInstructions.observer; return `你正在参与一个叫"追光AI行业论坛"的中文社区。今天是${today}。你现在在"${forumName}"板块发言。 ${forumDescription} ${variantBlock} [你的角色设定] 你是【${bot.displayName}】,${p.identity} 你的角色定位:${p.stance} 你说话的风格:${p.speakingStyle} 你的口头禅:${p.catchphrase} [近期行业新闻参考] ${newsText} [发言要求] ${instruction} 请以${bot.displayName}的身份在这个板块发起一个新话题。要求: 1. 话题标题10-20字,要像真人发的帖子,有真实感,不能写成新闻标题或论文标题 2. 内容180-400字,结合上面的新闻素材,有具体细节和你的个人感受 3. 如果引用了具体新闻,用自己的话说出来,不要像官方报道 4. 使用你的说话风格,自然口语化,可以有情绪和困惑 5. 结尾留一个问题引导大家参与讨论 [禁止事项] - 禁止使用"首先/其次/最后"等格式化连接词 - 禁止使用"从以下几个方面分析""综上所述"等教科书句式 - 禁止表现得像AI助手或官方新闻报道 - 话题中必须让人感受到"这是最近/今天发生的事",不能像泛泛而谈的老话题 ${p.forbiddenPatterns ? `- 禁止使用${p.forbiddenPatterns.join("、")}` : ""} 请只返回JSON格式,不要包含其他任何内容: { "title": "话题标题", "content": "话题内容" }`; } async function logTask(action, status, detail) { try { await prisma.taskLog.create({ data: { taskKey: "bot-activity", 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 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 buildTopicPrompt(bot, forumName, forumDescription, options = {}) { const { topSkills = [], persona = null, isCrossForum = false, styleSeed = 0, variantBlock = "", adversarialBlock = "" } = options; const p = bot.personality; const skillsBlock = topSkills.length > 0 ? `\n[你沉淀的爆款发帖套路(请参考但不要照抄)]\n${topSkills .map((s, i) => `套路${i + 1}「${s.name}」:${s.hookPattern}`) .join("\n")}\n` : ""; const personaBlock = buildPersonaBlock(persona); const crossForumHint = isCrossForum ? `\n[本次为"客串"发言] 这是你非主攻板块的"插一脚"内容。保持你原有的专业视角切入这个新话题,不要硬装成该板块老手,自然一些。\n` : ""; const wordLimit = p.wordLimit || { min: 200, max: 500 }; const useStory = Math.random() < STYLE_VARIATION.STORY_RATE; const useContrarian = Math.random() < STYLE_VARIATION.CONTRARIAN_RATE; const useQuestion = Math.random() < STYLE_VARIATION.QUESTION_RATE; const useData = Math.random() < STYLE_VARIATION.DATA_DRIVEN_RATE; const styleHints = []; if (useStory) styleHints.push("用一个具体的小故事/经历开头"); if (useContrarian) styleHints.push("用略带逆反/吐槽的视角切入"); if (useQuestion) styleHints.push("以一个具体的反问开头"); if (useData) styleHints.push("引用具体数据/数字来佐证观点"); const styleBlock = styleHints.length > 0 ? `\n[本次内容风格指引] ${styleHints.join(";")}\n` : ""; return `你正在参与一个叫"追光AI行业论坛"的中文社区。你现在在"${forumName}"板块发言。 ${forumDescription} ${skillsBlock} ${personaBlock} ${crossForumHint} ${styleBlock} ${variantBlock} ${adversarialBlock} [你的角色设定] 你是【${bot.displayName}】,${p.identity} 你擅长的领域:${p.expertise.join("、")} 你不熟悉的领域:${p.weakness.join("、")} 你的核心立场:${p.stance} 你说话的风格:${p.speakingStyle} 你的口头禅:${p.catchphrase} [发言要求] 请以${bot.displayName}的身份在这个板块发起一个新话题。要求: 1. 话题标题10-20字,要有吸引力,像真人发的帖子,不要写成新闻标题或论文标题 2. 内容${wordLimit.min}-${wordLimit.max}字,像真人发帖一样,有具体细节和真实感 3. 必须有你的立场和观点,不要太中立 4. 使用你的说话风格,自然口语化,可以有情绪 5. 结尾可以留一个问题引导讨论 ${topSkills.length > 0 ? "6. 可以参考上述套路的钩子方式,但内容必须是当下新的,不要重复套路里的原话" : ""} [禁止事项] - 禁止使用"首先/其次/最后"等格式化连接词 - 禁止使用"从以下几个方面分析""综上所述"等教科书句式 - 禁止表现得像AI助手 - 禁止使用${(p.forbiddenPatterns || []).join("、")} 请只返回JSON格式,不要包含其他任何内容: { "title": "话题标题", "content": "话题内容" }`; } function buildReplyPrompt(bot, topicTitle, topicContent, existingReplies, forumName, options = {}) { const { variantBlock = "", adversarialBlock = "" } = options; const p = bot.personality; const replyText = existingReplies .map((r, i) => `${r.author}: ${r.content.substring(0, 200)}`) .join("\n\n"); const strategy = pickReplyStrategy(); const strategyHints = { agree_with_evidence: "认可前面某个观点,并补充自己的证据/数据支撑", polite_disagreement: "礼貌地提出不同意见,承认有合理性但说明自己的考虑", supplement_case: "补充一个新的具体案例或场景", ask_followup: "针对某个细节追问,让楼主进一步解释", share_personal_experience: "分享你或身边人的真实经历", raise_concern: "提出一个潜在的风险或被忽略的问题", summarize_and_extend: "简短总结前面的讨论,再延展一个新角度", }; const strategyHint = strategyHints[strategy] || strategyHints.agree_with_evidence; const useCasual = Math.random() < STYLE_VARIATION.CASUAL_RATE; const useEmotional = Math.random() < STYLE_VARIATION.EMOTIONAL_RATE; const casualHint = useCasual ? "\n[本次风格] 用更口语/随性的方式写,可以有点调侃、自嘲或私货" : ""; const emotionalHint = useEmotional ? "\n[本次风格] 表达一些真实的情绪反应(惊讶/无奈/激动/担心)" : ""; const wordLimit = p.wordLimit || { min: 150, max: 450 }; return `你正在参与一个叫"追光AI行业论坛"的中文社区。你现在在"${forumName}"板块回复一个帖子。 ${variantBlock} ${adversarialBlock} [你的角色设定] 你是【${bot.displayName}】,${p.identity} 你擅长的领域:${p.expertise.join("、")} 你的核心立场:${p.stance} 你说话的风格:${p.speakingStyle} 你的口头禅:${p.catchphrase} [帖子信息] 标题:${topicTitle} 楼主发言:${topicContent} [已有回复] ${replyText || "(暂无回复,你是第一个回复的人)"} [本次回复策略] ${strategyHint} ${casualHint}${emotionalHint} [回复要求] 请以${bot.displayName}的身份回复这个帖子。要求: 1. 回复${wordLimit.min}-${wordLimit.max}字,有信息增量,不能重复前面已经说过的内容 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.85, max_tokens: 1024, }) ); const text = response.choices[0].message.content.trim(); const jsonMatch = text.match(/\{[\s\S]*\}/); if (!jsonMatch) { throw new Error(`Could not parse JSON from response: ${text.substring(0, 200)}`); } let parsed; try { parsed = JSON.parse(jsonMatch[0]); } catch { const cleaned = jsonMatch[0] .replace(/,\s*}/g, "}") .replace(/,\s*]/g, "]"); try { parsed = JSON.parse(cleaned); } catch { throw new Error(`JSON parse failed after cleanup: ${jsonMatch[0].substring(0, 200)}`); } } // 质量检查(仅对含 content 字段的结果) if (parsed.content) { const qa = assessContentQuality(parsed.content, parsed.title); if (!qa.pass) { console.warn(`${ts()} ⚠️ 质量不达标 (score=${qa.score}): ${qa.issues.join(", ")}`); } parsed._qualityScore = qa.score; } return parsed; } async function getBotWithConfig(botKey) { const config = await prisma.botConfig.findFirst({ where: { user: { email: `bot_${botKey}@zhuiguang.ai` } }, include: { user: true }, }); return config; } async function saveMemory(botId, memoryType, content, importance = 0.5) { try { await prisma.botMemory.create({ data: { botId, memoryType, content, importance }, }); } catch (e) { console.error(`${ts()} Memory save error:`, e.message); } } async function getFeedbackMemories(botId, limit = 2) { try { const now = new Date(); return await prisma.botMemory.findMany({ where: { botId, memoryType: "feedback", OR: [{ ttl: null }, { ttl: { gt: now } }], }, orderBy: { createdAt: "desc" }, take: limit, }); } catch { return []; } } async function getTopSkills(botConfigId, category, limit = 2) { try { return await prisma.botSkill.findMany({ where: { botId: botConfigId, category, isActive: true, }, orderBy: [{ successRate: "desc" }, { usageCount: "desc" }], take: limit, }); } catch { return []; } } async function getDynamicQuotaTier(botConfigId, baseTier) { try { const latest = await prisma.botWeeklyReview.findFirst({ where: { botId: botConfigId }, orderBy: { weekStart: "desc" }, }); if (latest && latest.nextWeekQuota) return latest.nextWeekQuota; } catch {} return baseTier; } function dynamicQuota(tier) { return QUOTA[tier] || QUOTA.medium; } async function recordSkillUsage(skillIds, topicId) { if (!skillIds || skillIds.length === 0) return; for (const id of skillIds) { try { await prisma.botSkill.update({ where: { id }, data: { usageCount: { increment: 1 }, version: { increment: 1 }, }, }); } catch {} } if (skillIds.length > 0) { try { const skill = await prisma.botSkill.findUnique({ where: { id: skillIds[0] } }); if (skill) { await prisma.botMemory.create({ data: { botId: skill.botId, memoryType: "skill_usage", layer: "WORKING", content: { action: "use_skills", topicId, skillIds, skillNames: skillIds.map(id => id === skill.id ? skill.name : null).filter(Boolean), }, importance: 0.5, contextTags: { topicId, skillIds, source: "topic_creation", }, }, }); } } catch {} } } async function getPersona(botConfigId) { try { return await prisma.botPersona.findUnique({ where: { botId: botConfigId } }); } catch { return null; } } async function getCrossForumCandidates(botConfigId, primarySlugs, limit = 2) { try { const primary = new Set(primarySlugs || []); return await prisma.botCrossForumAffinity.findMany({ where: { botId: botConfigId, forumSlug: { notIn: Array.from(primary) }, affinity: { gt: 0.1 }, }, orderBy: { affinity: "desc" }, take: limit, }); } catch { return []; } } const CROSS_FORUM_PROBABILITY = 0.1; // 计算"高频互动伙伴":在指定板块/全板块里,与本 bot 同话题互动最多的其他 bot // 返回 [{ name, displayName, interactions, lastSeen }] 按互动次数降序 async function getInteractionPartners(botConfigId, forumSlug = null, limit = MENTION_CONFIG.PARTNER_POOL_LIMIT) { try { const since = new Date(Date.now() - MENTION_CONFIG.PARTNER_LOOKBACK_DAYS * 24 * 60 * 60 * 1000); // 1) 拿到本 bot 在该板块/全部的最近话题 ID const myTopics = await prisma.forumTopic.findMany({ where: { user: { isBot: true }, // 限定本 bot(通过 userId 反查) createdAt: { gte: since }, ...(forumSlug ? { category: { slug: forumSlug } } : {}), }, select: { id: true, userId: true }, }); // 反查我的 userId const myConfig = await prisma.botConfig.findUnique({ where: { id: botConfigId }, select: { userId: true }, }); if (!myConfig) return []; const myTopicIds = myTopics.filter((t) => t.userId === myConfig.userId).map((t) => t.id); if (myTopicIds.length === 0) { // 如果本 bot 在该板块没发过主题,回退到"同板块其他 bot 的主题下我回复过" const myReplies = await prisma.forumPost.findMany({ where: { userId: myConfig.userId, createdAt: { gte: since }, ...(forumSlug ? { topic: { category: { slug: forumSlug } } } : {}), }, select: { topicId: true }, }); const fallbackIds = myReplies.map((r) => r.topicId); if (fallbackIds.length === 0) return []; myTopicIds.push(...fallbackIds); } // 2) 统计这些话题下出现的所有 bot(排除自己) const partnerTopicCount = await prisma.forumPost.groupBy({ by: ["userId"], where: { topicId: { in: myTopicIds }, userId: { not: myConfig.userId }, user: { isBot: true }, createdAt: { gte: since }, }, _count: { _all: true }, _max: { createdAt: true }, orderBy: { _count: { userId: "desc" } }, take: limit, }); if (partnerTopicCount.length === 0) return []; // 3) 关联 user 信息,拿到 name const partnerUsers = await prisma.user.findMany({ where: { id: { in: partnerTopicCount.map((p) => p.userId) } }, select: { id: true, name: true }, }); const userMap = new Map(partnerUsers.map((u) => [u.id, u])); return partnerTopicCount .filter((p) => userMap.has(p.userId)) .map((p) => ({ userId: p.userId, name: userMap.get(p.userId).name, interactions: p._count._all, lastSeen: p._max.createdAt, })) .filter((p) => p.interactions >= MENTION_CONFIG.PARTNER_MIN_INTERACTIONS && p.name); } catch (e) { console.error(`${ts()} getInteractionPartners error:`, e.message); return []; } } // 根据概率和候选池,挑选要在 prompt 里"建议" @ 的 bot 列表 function pickMentionTargets(partners) { if (!partners || partners.length === 0) return []; if (Math.random() > MENTION_CONFIG.PROBABILITY) return []; const count = Math.min( MENTION_CONFIG.MAX_PER_CONTENT, Math.max(1, Math.floor(Math.random() * partners.length) + 1) ); return pick(partners.map((p) => [p])).slice(0, count); } // 把 partner 列表格式化成可注入 prompt 的"建议 @ 名册" function buildMentionsBlock(mentions, intent = "reply") { if (!mentions || mentions.length === 0) return ""; const names = mentions.map((m) => `@${m.name}`).join("、"); if (intent === "reply") { return `\n[可选 @ 互动伙伴] 在你的回复中,可以点名 1~${MENTION_CONFIG.MAX_PER_CONTENT} 个常在同话题互动的 bot 让他们接话:${names}。点名时直接用 @昵称 的格式(不带空格)。\n`; } return `\n[可选 @ 互动伙伴] 如果你希望某位常在同板块互动的 bot 来接话,可以在正文中点名 1~${MENTION_CONFIG.MAX_PER_CONTENT} 个:${names}。\n`; } // 校验 LLM 输出里的 @-mention 是否在白名单中,把不合法/超出的去掉 function sanitizeMentions(content, whitelist) { if (!content || whitelist.length === 0) return content; const allowed = new Set(whitelist.map((m) => m.name)); // 匹配 @昵称(昵称里允许中英文数字下划线,长度 1-30) const MENTION_RE = /@([\w\u4e00-\u9fa5]{1,30})/g; const seen = new Set(); const replaced = content.replace(MENTION_RE, (full, name) => { if (allowed.has(name) && seen.size < MENTION_CONFIG.MAX_PER_CONTENT) { seen.add(name); return full; } // 去掉 @ 前缀,避免误触发通知 return name; }); return replaced; } function buildPersonaBlock(persona) { if (!persona) return ""; const blocks = []; if (persona.lastTopicTitles && persona.lastTopicTitles.length > 0) { blocks.push(`[你最近发过的话题,避免重复]\n${persona.lastTopicTitles.slice(0, 5).map((t) => `• ${t}`).join("\n")}`); } if (persona.stanceKeywords && persona.stanceKeywords.length > 0) { const kw = persona.stanceKeywords.slice(0, 8).map((k) => k.key).join("、"); blocks.push(`[你近期的立场关键词]\n${kw}`); } if (persona.topTopicTypes && persona.topTopicTypes.length > 0) { const types = persona.topTopicTypes .slice(0, 3) .map((t) => `${t.name}(${t.count}次)`) .join("、"); blocks.push(`[你近期高频话题类型]\n${types}`); } if (persona.topHumanUsers && persona.topHumanUsers.length > 0) { const users = persona.topHumanUsers .slice(0, 3) .map((u) => `${u.name}(互动${u.count}次)`) .join("、"); blocks.push(`[你最近高频互动的真人]\n${users}`); } if (blocks.length === 0) return ""; return `\n${blocks.join("\n\n")}\n`; } async function getRecentMemories(botId, limit = 3) { try { return await prisma.botMemory.findMany({ where: { botId }, orderBy: { createdAt: "desc" }, take: limit, }); } catch { return []; } } function buildMemoryContext(memories, forumName) { if (memories.length === 0) return ""; const lines = memories.map(m => { const c = m.content; if (m.memoryType === "experience") { return `• 你最近在"${c.forum}"板块发起了话题"${c.title}"`; } if (m.memoryType === "feedback") { const humanPart = c.humanCount > 0 ? `(其中${c.humanCount}个真人回复)` : ""; return `• 你之前发起的"${c.topicTitle}"收到了${c.replyCount}条新回复${humanPart}`; } return `• 你最近在"${c.forum}"板块回复了关于"${c.topicTitle}"的讨论`; }); return `\n[你最近的讨论记忆]\n${lines.join("\n")}\n`; } async function getCategoryBySlug(slug) { return prisma.forumCategory.findUnique({ where: { slug } }); } async function getRandomSubCategory(forumSlug) { const subs = SUB_CATEGORY_MAP[forumSlug] || []; if (subs.length === 0) return null; const slug = pick(subs); return prisma.forumCategory.findUnique({ where: { slug } }); } async function getRecentTopicsInForum(forumSlug, limit = 5) { const cat = await getCategoryBySlug(forumSlug); if (!cat) return []; return prisma.forumTopic.findMany({ where: { categoryId: cat.id }, include: { user: { select: { id: true, name: true } }, posts: { include: { user: { select: { id: true, name: true } } }, orderBy: { createdAt: "asc" }, }, }, orderBy: { createdAt: "desc" }, take: limit, }); } async function createTopic(categoryId, userId, title, content) { return prisma.forumTopic.create({ data: { categoryId, userId, title, slug: slugify(title), content, }, }); } async function createPost(topicId, userId, content) { return prisma.forumPost.create({ data: { topicId, userId, content }, }); } async function processForum(forumSlug, botCharacters, forceNewTopic = false) { const category = await getCategoryBySlug(forumSlug); if (!category) { console.log(`${ts()} Forum "${forumSlug}" not found, skipping.`); return; } console.log(`${ts()} Processing forum: ${category.name}`); const forumBots = botCharacters.filter( c => c.primaryForums && c.primaryForums.includes(forumSlug) && c.role !== "passerby" ); if (forumBots.length === 0) { console.log(`${ts()} No bots assigned to this forum.`); return; } const existingTopics = await getRecentTopicsInForum(forumSlug, 10); const needsNewTopic = forceNewTopic || existingTopics.length < 3 || existingTopics[0] && (Date.now() - existingTopics[0].createdAt.getTime()) > 24 * 60 * 60 * 1000; if (needsNewTopic) { const activeBots = forumBots.filter(c => c.activityLevel === "high" || c.activityLevel === "medium"); let topicAuthor = pick(activeBots.length > 0 ? activeBots : forumBots); let isCrossForum = false; if (!topicAuthor) { console.log(`${ts()} No suitable bot to create topic.`); return; } if (Math.random() < CROSS_FORUM_PROBABILITY) { const crossAffinities = await prisma.botCrossForumAffinity.findMany({ where: { forumSlug, affinity: { gt: 0.1 } }, orderBy: { affinity: "desc" }, take: 5, }); if (crossAffinities.length > 0) { const crossBotConfigIds = crossAffinities.map(c => c.botId); const crossBotConfigs = await prisma.botConfig.findMany({ where: { id: { in: crossBotConfigIds } }, include: { user: { select: { email: true } } }, }); const crossKeys = crossBotConfigs .map(c => c.user.email?.replace(/^bot_/, "").replace(/@zhuiguang\.ai$/, "")) .filter(Boolean); const crossChars = botCharacters.filter( c => crossKeys.includes(c.key) && !(c.primaryForums || []).includes(forumSlug) && c.role !== "passerby" ); if (crossChars.length > 0) { const crossChar = pick(crossChars); const crossConf = await getBotWithConfig(crossChar.key); if (crossConf) { const crossStat = await getOrCreateTodayStat(crossConf.id); const crossTier = await getDynamicQuotaTier(crossConf.id, crossChar.activityLevel); const crossQuota = dynamicQuota(crossTier); if (crossStat.topicCreated < crossQuota.topicPerDay) { topicAuthor = crossChar; isCrossForum = true; console.log(`${ts()} 🌍 跨板块客串: ${crossChar.displayName} → "${category.name}" (affinity=${crossAffinities[0].affinity.toFixed(2)})`); } } } } } if (!isInActiveHours(topicAuthor.activeHours)) { console.log(`${ts()} ${topicAuthor.displayName} 当前不在活跃时段,跳过发帖`); return; } const botConf = await getBotWithConfig(topicAuthor.key); if (!botConf) { console.log(`${ts()} Bot "${topicAuthor.key}" not found in DB.`); return; } const dynamicTier = await getDynamicQuotaTier(botConf.id, topicAuthor.activityLevel); const finalQuota = dynamicQuota(dynamicTier); const todayStat = await getOrCreateTodayStat(botConf.id); if (todayStat.topicCreated >= finalQuota.topicPerDay) { console.log(`${ts()} ${topicAuthor.displayName} 今日发帖配额已用完 (${todayStat.topicCreated}/${finalQuota.topicPerDay}, tier=${dynamicTier})`); } else { console.log(`${ts()} ${topicAuthor.displayName} creating new topic (tier=${dynamicTier})${isCrossForum ? " [跨板块]" : ""}...`); try { const topSkills = await getTopSkills(botConf.id, forumSlug, 2); const persona = await getPersona(botConf.id); const recentMemories = await getRecentMemories(botConf.id, 2); const memoryCtx = buildMemoryContext(recentMemories, category.name); // A/B 分流:选变体(不写库),注入 personaBlock 到 prompt const variantPick = await pickVariantForPrompt(botConf.id); const variantBlock = variantPick.personaBlock || ""; // 对抗学习:拉本周真人高赞帖洞察(如果存在) const advLearning = await getActiveLearningForBot(botConf.id); const adversarialBlock = buildAdversarialBlock(advLearning); let prompt = buildTopicPrompt(topicAuthor, category.name, category.description, { topSkills, persona, isCrossForum, variantBlock, adversarialBlock, }); if (memoryCtx) { prompt = prompt.replace("[你的角色设定]", memoryCtx + "[你的角色设定]"); } else { prompt = prompt.replace("[你的角色设定]", "\n[你的角色设定]"); } const result = await callDeepSeek(prompt); const subCat = await getRandomSubCategory(forumSlug); const catId = subCat ? subCat.id : category.id; const topic = await createTopic(catId, botConf.userId, result.title, result.content); console.log(`${ts()} Created topic: "${result.title}"`); if (advLearning) { try { await markLearningUsed(advLearning.id); console.log(`${ts()} 🎓 对抗学习引用 week=${advLearning.weekKey} topic#${advLearning.sourceRefId}`); } catch (e) { console.warn(`${ts()} ⚠️ markLearningUsed failed: ${e.message}`); } } // 提交 A/B assignment if (variantPick.variant) { try { await commitAssignment(botConf.id, variantPick.variant.id, "topic", topic.id, forumSlug); console.log(`${ts()} 🅰️ variant=${variantPick.variant.variantKey} assigned to topic#${topic.id}`); } catch (e) { console.warn(`${ts()} ⚠️ commitAssignment failed: ${e.message}`); } } await prisma.forumCategory.update({ where: { id: catId }, data: { topicCount: { increment: 1 } }, }); await saveMemory(botConf.id, "experience", { action: "create_topic", forum: category.name, title: result.title, summary: result.content.substring(0, 100), usedSkillIds: topSkills.map(s => s.id), usedSkillNames: topSkills.map(s => s.name), isCrossForum, }, isCrossForum ? 0.7 : 0.6); if (topSkills.length > 0) { await recordSkillUsage(topSkills.map(s => s.id), topic.id); } await incrementStat(botConf.id, "topicCreated", 1); // 异步触发 persona 刷新(5 分钟防抖,不阻塞主流程) schedulePersonaRefresh(botConf.userId, botConf.id); await logTask("create_topic", "success", `${topicAuthor.displayName} created topic in ${category.name}: ${result.title}${isCrossForum ? " [跨板块]" : ""}`); } catch (err) { console.error(`${ts()} Error creating topic:`, err.message); await logTask("create_topic", "error", err.message); } } } if (existingTopics.length > 0) { const repliers = forumBots .filter((bot) => { const chance = bot.replyChance ?? 0.5; if (!isInActiveHours(bot.activeHours)) return false; return Math.random() < chance; }) .slice(0, MAX_REPLIERS_PER_FORUM); for (const replierBot of repliers) { const botConf = await getBotWithConfig(replierBot.key); if (!botConf) continue; const replyDynamicTier = await getDynamicQuotaTier(botConf.id, replierBot.activityLevel); const replyQuota = dynamicQuota(replyDynamicTier); const replyStat = await getOrCreateTodayStat(botConf.id); if (replyStat.replySent >= replyQuota.replyPerDay) { console.log(`${ts()} ${replierBot.displayName} 今日回复配额已用完 (${replyStat.replySent}/${replyQuota.replyPerDay}, tier=${replyDynamicTier})`); continue; } const targetTopic = pick(existingTopics); const authorName = targetTopic.user.name; const topicContent = targetTopic.content; const replies = targetTopic.posts.map(p => ({ author: p.user.name, content: p.content, })); const alreadyReplied = targetTopic.posts.some(p => p.userId === botConf.userId); if (alreadyReplied) { console.log(`${ts()} ${replierBot.displayName} already replied to "${targetTopic.title}", skipping.`); continue; } console.log(`${ts()} ${replierBot.displayName} replying to "${targetTopic.title}"...`); try { const recentMemories = await getRecentMemories(botConf.id, 3); const memoryCtx = buildMemoryContext(recentMemories, category.name); // A/B 分流:选变体注入 personaBlock const variantPick = await pickVariantForPrompt(botConf.id); const variantBlock = variantPick.personaBlock || ""; // 对抗学习:拉本周真人高赞帖洞察 const advLearning = await getActiveLearningForBot(botConf.id); const adversarialBlock = buildAdversarialBlock(advLearning); let prompt = buildReplyPrompt(replierBot, targetTopic.title, topicContent, replies, category.name, { variantBlock, adversarialBlock }); if (memoryCtx) { prompt = prompt.replace("[你的角色设定]", memoryCtx + "[你的角色设定]"); } else { prompt = prompt.replace("[你的角色设定]", "\n[你的角色设定]"); } const result = await callDeepSeek(prompt); const post = await createPost(targetTopic.id, botConf.userId, result.content); console.log(`${ts()} Reply saved (${result.content.length} chars).`); if (advLearning) { try { await markLearningUsed(advLearning.id); console.log(`${ts()} 🎓 对抗学习引用 week=${advLearning.weekKey}`); } catch (e) { console.warn(`${ts()} ⚠️ markLearningUsed failed: ${e.message}`); } } // 提交 A/B assignment if (variantPick.variant) { try { await commitAssignment(botConf.id, variantPick.variant.id, "post", post.id, forumSlug); console.log(`${ts()} 🅰️ variant=${variantPick.variant.variantKey} assigned to post#${post.id}`); } catch (e) { console.warn(`${ts()} ⚠️ commitAssignment failed: ${e.message}`); } } await saveMemory(botConf.id, "interaction", { action: "reply", forum: category.name, topicTitle: targetTopic.title, repliedTo: authorName, summary: result.content.substring(0, 100), }, 0.5); await incrementStat(botConf.id, "replySent", 1); // 异步触发 persona 刷新(5 分钟防抖,不阻塞主流程) schedulePersonaRefresh(botConf.userId, botConf.id); await logTask("create_reply", "success", `${replierBot.displayName} replied to ${authorName}'s topic in ${category.name}`); } catch (err) { console.error(`${ts()} Error creating reply:`, err.message); await logTask("create_reply", "error", err.message); } } } } async function processPasserbyForum(forumSlug, botCharacters) { const category = await getCategoryBySlug(forumSlug); if (!category) { console.log(`${ts()} Passerby: Forum "${forumSlug}" not found, skipping.`); return; } console.log(`${ts()} Passerby processing: ${category.name}`); const passerbyBots = botCharacters.filter( c => c.role === "passerby" && c.primaryForums && c.primaryForums.includes(forumSlug) ); if (passerbyBots.length === 0) { console.log(`${ts()} No passerby bots for this forum.`); return; } const recentNews = await fetchRecentNewsForCategory(forumSlug); if (recentNews.length === 0) { console.log(`${ts()} No recent news found, will generate from bot knowledge.`); } const eligibleBots = passerbyBots.filter(b => isInActiveHours(b.activeHours)); if (eligibleBots.length === 0) { console.log(`${ts()} 路过型Bot当前无人在活跃时段`); return false; } // 优先选最近没发过/发得少的 bot(用 botConfig 的 latestMemory 时间近似判断) const scored = []; for (const bot of eligibleBots) { const conf = await getBotWithConfig(bot.key); if (!conf) continue; const last = await prisma.botMemory.findFirst({ where: { botId: conf.id, memoryType: "experience" }, orderBy: { createdAt: "desc" }, }); let weight = 1; if (last) { const hoursAgo = (Date.now() - new Date(last.createdAt).getTime()) / 3600000; // 6 小时内发过的权重减半,>24h 的权重 2x if (hoursAgo < 6) weight = 0.3; else if (hoursAgo < 12) weight = 0.6; else if (hoursAgo > 24) weight = 2; } else { // 从没发过帖的 bot 优先级最高 weight = 2.5; } scored.push({ bot, conf, weight }); } if (scored.length === 0) return false; scored.sort((a, b) => b.weight - a.weight); // 取加权最高的 3 个,再随机抽 const topPicks = scored.slice(0, 3); const chosen = topPicks[Math.floor(Math.random() * topPicks.length)]; const posterBot = chosen.bot; const botConf = chosen.conf; if (!botConf) return false; console.log(`${ts()} ${posterBot.displayName} (${posterBot.personality.passerbyType}) creating topic...`); try { const recentMemories = await getRecentMemories(botConf.id, 2); const memoryCtx = buildMemoryContext(recentMemories, category.name); // A/B 分流 const variantPick = await pickVariantForPrompt(botConf.id); const variantBlock = variantPick.personaBlock || ""; let prompt = buildPasserbyTopicPrompt(posterBot, category.name, category.description, recentNews, { variantBlock }); if (memoryCtx) { prompt = prompt.replace("[你的角色设定]", memoryCtx + "[你的角色设定]"); } else { prompt = prompt.replace("[你的角色设定]", "\n[你的角色设定]"); } const result = await callDeepSeek(prompt); const subCat = await getRandomSubCategory(forumSlug); const catId = subCat ? subCat.id : category.id; const topic = await createTopic(catId, botConf.userId, result.title, result.content); console.log(`${ts()} Passerby topic created: "${result.title}"`); // 提交 A/B assignment if (variantPick.variant) { try { await commitAssignment(botConf.id, variantPick.variant.id, "topic", topic.id, forumSlug); console.log(`${ts()} 🅰️ variant=${variantPick.variant.variantKey} assigned to passerby topic#${topic.id}`); } catch (e) { console.warn(`${ts()} ⚠️ commitAssignment failed: ${e.message}`); } } await prisma.forumCategory.update({ where: { id: catId }, data: { topicCount: { increment: 1 } }, }); await saveMemory(botConf.id, "experience", { action: "create_topic_passerby", forum: category.name, title: result.title, summary: result.content.substring(0, 100), newsCount: recentNews.length, }, 0.5); // 异步触发 persona 刷新(5 分钟防抖,不阻塞主流程) schedulePersonaRefresh(botConf.userId, botConf.id); await logTask("create_topic_passerby", "success", `${posterBot.displayName}(${posterBot.personality.passerbyType}) posted in ${category.name}: ${result.title}`); return true; } catch (err) { console.error(`${ts()} Passerby topic error:`, err.message); await logTask("create_topic_passerby", "error", err.message); return false; } } // ============ Bot 自动点赞(真实写入 forumTopicLike / forumPostLike) ============ // 策略: // 1) 拉所有 bot 用户 → 候选点赞人 // 2) 拉最近 LOOKBACK_DAYS 内的"高质量"topic/post(likeCount 高的),并排除自己 // 3) 给"同板块活跃伙伴"额外权重,给真人内容较小权重(避免生态自嗨) // 4) 写入 forumTopicLike / forumPostLike,并发累加 likeCount + BotDailyStat.likesReceived async function pickLikeCandidates(likerUserId, partnerUserIds, lookbackDays) { const since = new Date(Date.now() - lookbackDays * 24 * 60 * 60 * 1000); // 候选 topics:likeCount >= MIN_QUALITY_LIKES,按 likeCount + 时间衰减打分 const topics = await prisma.forumTopic.findMany({ where: { userId: { in: partnerUserIds }, createdAt: { gte: since }, isHidden: false, likeCount: { gte: BOT_LIKE_CONFIG.MIN_QUALITY_LIKES }, }, select: { id: true, userId: true, categoryId: true, likeCount: true, createdAt: true }, take: 60, }); // 候选 posts const posts = await prisma.forumPost.findMany({ where: { userId: { in: partnerUserIds }, createdAt: { gte: since }, isHidden: false, likeCount: { gte: BOT_LIKE_CONFIG.MIN_QUALITY_LIKES }, }, select: { id: true, userId: true, topicId: true, topic: { select: { categoryId: true } }, likeCount: true, createdAt: true, }, take: 60, }); return { topics, posts }; } async function applyLikeToTopic(likerUserId, topicId) { // 唯一约束 (topicId, userId) -> 不能重复点赞 try { await prisma.forumTopicLike.create({ data: { topicId, userId: likerUserId } }); } catch (e) { // 已点过赞(P2002 unique)则忽略 if (e && e.code === "P2002") return false; throw e; } await prisma.forumTopic.update({ where: { id: topicId }, data: { likeCount: { increment: 1 } }, }); return true; } async function applyLikeToPost(likerUserId, postId) { try { await prisma.forumPostLike.create({ data: { postId, userId: likerUserId } }); } catch (e) { if (e && e.code === "P2002") return false; throw e; } await prisma.forumPost.update({ where: { id: postId }, data: { likeCount: { increment: 1 } }, }); return true; } async function runBotAutoLikes(botCharacters) { try { if (Math.random() > BOT_LIKE_CONFIG.PROBABILITY_PER_BOT) { console.log(`${ts()} ⏭️ 本轮跳过自动点赞 (PROBABILITY gate)`); return; } const allBotUsers = await prisma.user.findMany({ where: { isBot: true }, select: { id: true, name: true }, }); if (allBotUsers.length < 2) return; const humanUserIds = ( await prisma.user.findMany({ where: { isBot: false }, select: { id: true }, take: 50, }) ).map((u) => u.id); const botUserIds = allBotUsers.map((u) => u.id); let totalApplied = 0; let totalSkipped = 0; for (const bot of allBotUsers) { if (totalApplied >= BOT_LIKE_CONFIG.MAX_LIKES_PER_RUN) break; // 候选伙伴 = 其他 bot + 少量真人 const partnerBotIds = botUserIds.filter((id) => id !== bot.id); const { topics, posts } = await pickLikeCandidates(bot.id, partnerBotIds, BOT_LIKE_CONFIG.LOOKBACK_DAYS); if (topics.length === 0 && posts.length === 0) continue; // 真人候选:按比例加入 const includeHuman = Math.random() < BOT_LIKE_CONFIG.HUMAN_LIKE_PROBABILITY; let humanTopics = []; let humanPosts = []; if (includeHuman && humanUserIds.length > 0) { const since = new Date(Date.now() - BOT_LIKE_CONFIG.LOOKBACK_DAYS * 24 * 60 * 60 * 1000); humanTopics = await prisma.forumTopic.findMany({ where: { userId: { in: humanUserIds }, createdAt: { gte: since }, isHidden: false, likeCount: { gte: BOT_LIKE_CONFIG.MIN_QUALITY_LIKES }, }, select: { id: true, userId: true, categoryId: true, likeCount: true, createdAt: true }, take: 20, }); humanPosts = await prisma.forumPost.findMany({ where: { userId: { in: humanUserIds }, createdAt: { gte: since }, isHidden: false, likeCount: { gte: BOT_LIKE_CONFIG.MIN_QUALITY_LIKES }, }, select: { id: true, userId: true, topicId: true, topic: { select: { categoryId: true } }, likeCount: true, createdAt: true }, take: 20, }); } const allTopicPool = [...topics, ...humanTopics]; const allPostPool = [...posts, ...humanPosts]; if (allTopicPool.length === 0 && allPostPool.length === 0) continue; // 加权:topic vs post 按 TOPIC_WEIGHT const pickTopic = Math.random() < BOT_LIKE_CONFIG.TOPIC_WEIGHT; const pool = pickTopic ? allTopicPool : allPostPool; if (pool.length === 0) continue; const target = pool[Math.floor(Math.random() * pool.length)]; let ok = false; if (pickTopic) { ok = await applyLikeToTopic(bot.id, target.id); } else { ok = await applyLikeToPost(bot.id, target.id); } if (ok) { totalApplied++; // 给被赞者(bot)累加 BotDailyStat.likesReceived const targetUser = await prisma.user.findUnique({ where: { id: target.userId } }); if (targetUser?.isBot) { const targetBotConf = await prisma.botConfig.findUnique({ where: { userId: targetUser.id } }); if (targetBotConf) await incrementStat(targetBotConf.id, "likesReceived", 1); } const verb = pickTopic ? "topic" : "post"; console.log(`${ts()} ❤️ ${bot.name} → ${targetUser?.name || "?"} ${verb}#${target.id}`); } else { totalSkipped++; } } if (totalApplied > 0) { console.log(`${ts()} ✅ 自动点赞完成:成功 ${totalApplied} 跳过(重复) ${totalSkipped}`); await logTask( "bot_auto_likes", "success", `bot 互赞/真人点赞 ${totalApplied} 条,跳过 ${totalSkipped} 条` ); } } catch (err) { console.error(`${ts()} runBotAutoLikes error:`, err.message); await logTask("bot_auto_likes", "error", err.message); } } // ============ 内容质量评估 ============ // 对 Bot 生成的内容进行简单质量检查,过滤低质量内容 function assessContentQuality(content, title) { let score = 100; const issues = []; // 1) 字数检查 if (content.length < 100) { score -= 30; issues.push("内容过短(<100字)"); } else if (content.length < 150) { score -= 15; issues.push("内容偏短(<150字)"); } // 2) AI 味检测:高频 AI 句式 const aiPatterns = [ /首先[,,].*其次[,,].*最后/, /从以下几个方面/, /综上所述/, /值得注意的是/, /不可否认/, /总而言之/, /让我们.*一起/, /在这个.*的时代/, ]; for (const pat of aiPatterns) { if (pat.test(content)) { score -= 15; issues.push(`AI句式: ${pat.source}`); } } // 3) 重复标题检测(与内容前100字相似度) if (title && content) { const titleWords = new Set(title.split("")); const contentStart = content.substring(0, 100); let overlap = 0; for (const w of titleWords) { if (w.trim() && contentStart.includes(w)) overlap++; } const titleLen = [...titleWords].filter(w => w.trim()).length; if (titleLen > 0 && overlap / titleLen > 0.8) { score -= 20; issues.push("标题与内容高度重复"); } } // 4) 结尾是否有引导讨论的问题 const lastSentence = content.split(/[。!?\n]/).filter(Boolean).pop() || ""; if (!/[??]/.test(lastSentence) && !/大家|你们|你们觉得|怎么看/.test(lastSentence)) { score -= 5; issues.push("结尾缺少互动引导"); } return { score: Math.max(0, score), issues, pass: score >= 60 }; } async function main() { console.log(`${ts()} Bot Activity Engine starting...`); const { characters: botCharacters } = await loadBotCharacters(); const expertCount = botCharacters.filter((c) => c.role !== "passerby").length; const passerbyCount = botCharacters.filter((c) => c.role === "passerby").length; console.log(`${ts()} Loaded ${botCharacters.length} bot characters (${expertCount} experts + ${passerbyCount} passersbys).`); const expertForums = FORUM_SLUGS.sort(() => Math.random() - 0.5).slice(0, EXPERT_FORUMS_PER_RUN); console.log(`${ts()} Processing ${expertForums.length} forums for experts: ${expertForums.join(", ")}`); for (const forumSlug of expertForums) { await processForum(forumSlug, botCharacters); } const passerbyForums = FORUM_SLUGS.sort(() => Math.random() - 0.5).slice(0, PASSERBY_FORUMS_PER_RUN); console.log(`${ts()} Processing ${passerbyForums.length} forums for passerbys: ${passerbyForums.join(", ")}`); for (const forumSlug of passerbyForums) { // 路人翻倍后,每个板块跑两轮,让更多 bot 有露脸机会 const first = await processPasserbyForum(forumSlug, botCharacters); if (first) { await new Promise((r) => setTimeout(r, 200)); await processPasserbyForum(forumSlug, botCharacters); } } // Bot 之间互相点赞:让真实 like 数据自然累积,避免估算 await runBotAutoLikes(botCharacters); console.log(`${ts()} Bot Activity Engine done.`); } // 全局超时保护:50 分钟 const TIMEOUT_MS = 50 * 60 * 1000; const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error(`Bot Activity Engine timed out after ${TIMEOUT_MS / 60000} minutes`)), TIMEOUT_MS) ); Promise.race([main(), timeoutPromise]) .catch(async (e) => { console.error(`${ts()} Fatal error:`, e); try { await prisma.taskLog.create({ data: { taskKey: "bot-activity", 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 () => { // 等待 persona 异步刷新落盘(最多 2 秒)再断开 await new Promise((r) => setTimeout(r, 500)); await prisma.$disconnect(); await disconnectBotPersona(); await disconnectBotExperiment(); await disconnectAdversarialLearning(); });