Files
zhuiguang-ai/scripts/bot-activity.mjs
T

1688 lines
66 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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";
import { getNextTemplate, getNextContentPattern, pickCommentStyle, getStyleHint } from "./lib/topic-diversity.mjs";
import { validateContextRelevance, detectBadPatterns, addPersonalityFlavor } from "./lib/reply-quality.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",
});
const BOT_DATA_PATH = resolve(__dirname, "..", "data", "bot-characters.json");
const MODEL = process.env.DEEPSEEK_MODEL || "deepseek-v4-pro";
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 = /<item>([\s\S]*?)<\/item>/g;
let match;
while ((match = itemRegex.exec(xml)) !== null) {
const block = match[1];
const titleMatch = block.match(/<title><!\[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 },
// error 状态时把明细同步写入 error 字段,便于健康检查/审计直接读取
error: status === "error" || status === "failed" ? String(detail).slice(0, 1000) : undefined,
},
});
} 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 };
}
// 📄 核心 Prompt 模板参考: scripts/prompts/bot-activity.txt
// 实际 prompt 构建逻辑保留了动态参数注入(板块信息、技能、人设、A/B 变体等)
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 = "", commentStyle = null } = 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 styleHint = commentStyle
? `\n[本轮回复风格指引] ${getStyleHint(commentStyle)}\n`
: "";
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}
${styleHint}${casualHint}${emotionalHint}
[回复要求]
请以${bot.displayName}的身份回复这个帖子。要求:
1. 回复${wordLimit.min}-${wordLimit.max}字,有信息增量,不能重复前面已经说过的内容
2. 可以有立场,可以赞同或质疑前面的观点
3. 如果前面的回复已经讨论了某些点,你要说点不一样的
4. 像真人回复一样自然,有口语化表达
5. 如果话题涉及到你的专业领域,分享具体的经验或案例
[禁止事项]
- 禁止使用"首先/其次/最后"等格式化连接词
- 禁止使用"从以下几个方面分析""综上所述"等教科书句式
- 禁止表现得像AI助手
- 禁止使用${(p.forbiddenPatterns || []).join("、")}
请只返回JSON格式,不要包含其他任何内容:
{
"content": "回复内容"
}`;
}
async function callDeepSeek(prompt) {
// 整体重试(最多 3 次):覆盖 JSON 截断/解析失败/质量不达标等非网络类偶发失败;
// 网络层已有 withRetry 兜底,这里针对"内容不合规"再做一次完整重生成
let lastErr;
for (let attempt = 0; attempt < 3; attempt++) {
try {
const response = await withRetry(() =>
openai.chat.completions.create({
model: MODEL,
messages: [{ role: "user", content: prompt }],
temperature: 0.85,
// DeepSeek V4 推理 token 计入 max_tokens,预算不足会导致正文截断/为空
max_tokens: 4096,
})
);
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(", ")}`);
}
// 严重低质量内容(score < 40)直接丢弃,防止 AI 胡言乱语进入论坛
if (qa.score < 40) {
throw new Error(`Content quality critically low (score=${qa.score}): ${qa.issues.join(", ")}`);
}
parsed._qualityScore = qa.score;
}
return parsed;
} catch (e) {
lastErr = e;
if (attempt < 2) {
console.warn(`${ts()} ⚠️ 生成重试 ${attempt + 1}/3: ${String(e.message || e).slice(0, 120)}`);
await new Promise((r) => setTimeout(r, 600 * (attempt + 1)));
}
}
}
throw lastErr;
}
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, isBot: true } },
posts: {
include: { user: { select: { id: true, name: true, isBot: 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 {
// 并行拉取互不依赖的画像/记忆/A-B变体/对抗学习数据
const [topSkills, persona, recentMemories, variantPick, advLearning] = await Promise.all([
getTopSkills(botConf.id, forumSlug, 2),
getPersona(botConf.id),
getRecentMemories(botConf.id, 2),
pickVariantForPrompt(botConf.id),
getActiveLearningForBot(botConf.id),
]);
const memoryCtx = buildMemoryContext(recentMemories, category.name);
const variantBlock = variantPick.personaBlock || "";
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 badPatterns = detectBadPatterns(result.content);
if (badPatterns.isGeneric) {
console.warn(`${ts()} ⚠️ ${topicAuthor.displayName} 话题过于通用 (pattern_matches=${badPatterns.matchCount}), 但仍保留`);
}
console.log(`${ts()} 📊 ${topicAuthor.displayName}: topic_pattern=${getNextContentPattern()}, generic=${badPatterns.isGeneric}`);
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);
// 防止 Bot 互回造成回音壁:90% 概率跳过 Bot 作者的话题(仅 10% 容忍度用于跨板块共性话题)
if (targetTopic.user.isBot && Math.random() > 0.1) {
console.log(`${ts()} ⏭️ ${replierBot.displayName} skipped bot-authored topic "${targetTopic.title}" (bot echo prevention)`);
continue;
}
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 {
// 并行拉取互不依赖的记忆/A-B变体/对抗学习数据
const [recentMemories, variantPick, advLearning] = await Promise.all([
getRecentMemories(botConf.id, 3),
pickVariantForPrompt(botConf.id),
getActiveLearningForBot(botConf.id),
]);
const memoryCtx = buildMemoryContext(recentMemories, category.name);
const variantBlock = variantPick.personaBlock || "";
const adversarialBlock = buildAdversarialBlock(advLearning);
// 选择回复风格,确保多样性
const commentStyle = pickCommentStyle();
let prompt = buildReplyPrompt(replierBot, targetTopic.title, topicContent, replies, category.name, {
variantBlock,
adversarialBlock,
commentStyle,
});
if (memoryCtx) {
prompt = prompt.replace("[你的角色设定]", memoryCtx + "[你的角色设定]");
} else {
prompt = prompt.replace("[你的角色设定]", "\n[你的角色设定]");
}
const result = await callDeepSeek(prompt);
// 质量检查:反模式检测 & 关联度
const badPatterns = detectBadPatterns(result.content);
if (badPatterns.isGeneric) {
console.warn(`${ts()} ⚠️ ${replierBot.displayName} 回复过于通用 (pattern_matches=${badPatterns.matchCount}), 但仍保留`);
}
const contextForRelevance = (topicContent + " " + replies.map(r => r.content).join(" ")).slice(0, 500);
const { score: relevanceScore } = validateContextRelevance(result.content, contextForRelevance);
console.log(`${ts()} 📊 ${replierBot.displayName}: comment_style=${commentStyle}, relevance=${(relevanceScore / 100).toFixed(2)}, generic=${badPatterns.isGeneric}`);
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, variantPick] = await Promise.all([
getRecentMemories(botConf.id, 2),
pickVariantForPrompt(botConf.id),
]);
const memoryCtx = buildMemoryContext(recentMemories, category.name);
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 badPatterns = detectBadPatterns(result.content);
if (badPatterns.isGeneric) {
console.warn(`${ts()} ⚠️ ${posterBot.displayName} 话题过于通用 (pattern_matches=${badPatterns.matchCount}), 但仍保留`);
}
console.log(`${ts()} 📊 ${posterBot.displayName}(passerby): topic_pattern=${getNextContentPattern()}, generic=${badPatterns.isGeneric}`);
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) 重复标题检测(标题是否照搬内容开头)
if (title && content) {
const normalizedTitle = title.replace(/[^\u4e00-\u9fa5a-zA-Z0-9]/g, "");
const contentStart = content
.substring(0, Math.max(60, normalizedTitle.length))
.replace(/[^\u4e00-\u9fa5a-zA-Z0-9]/g, "");
if (normalizedTitle.length > 0 && contentStart.includes(normalizedTitle)) {
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 };
}
// 基于当前小时的确定性轮转选板块,保证覆盖均匀(替代随机 shuffle)
// startOffset 用于让专家/路人两批错开,避免每次都撞到相同板块
function getRotatedForums(count, startOffset = 0) {
const total = FORUM_SLUGS.length;
const offset = (new Date().getHours() + startOffset) % total;
const rotated = [];
for (let i = 0; i < count; i++) {
rotated.push(FORUM_SLUGS[(offset + i) % total]);
}
return rotated;
}
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 = getRotatedForums(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 = getRotatedForums(PASSERBY_FORUMS_PER_RUN, EXPERT_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();
});