1688 lines
66 KiB
JavaScript
1688 lines
66 KiB
JavaScript
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();
|
||
}); |