// Bot 数字人 vs 真人"对抗学习" 共享模块 // 每周为每个 bot 选 1 个真实高赞真人帖,让 bot 吸收其"为什么高互动"的洞察 // // 用法: // import { runWeeklyAdversarialLearning, getActiveLearningForBot, buildAdversarialBlock } from "./lib/bot-adversarial-learning.mjs"; // // // 1) 周调度入口 // await runWeeklyAdversarialLearning({ weekKey: "2026-W23", lookbackDays: 7 }); // // // 2) 发帖 prompt 注入对抗学习参考 // const learning = await getActiveLearningForBot(botConfigId); // const block = buildAdversarialBlock(learning); // → "[对抗学习] ..." import { PrismaClient } from "@prisma/client"; import { PrismaMariaDb } from "@prisma/adapter-mariadb"; import "dotenv/config"; import OpenAI from "openai"; import { withRetry } from "./retry.mjs"; const base = (process.env.DATABASE_URL || "").replace("mysql://", "mariadb://"); const sep = base.includes("?") ? "&" : "?"; const connectionString = `${base}${sep}connection_limit=3&pool_timeout=10`; let _prisma = null; function getPrisma() { if (!_prisma) { const adapter = new PrismaMariaDb(connectionString); _prisma = new PrismaClient({ adapter }); } return _prisma; } let _openai = null; function getOpenAI() { if (_openai) return _openai; if (!process.env.DEEPSEEK_API_KEY) return null; _openai = new OpenAI({ apiKey: process.env.DEEPSEEK_API_KEY, baseURL: "https://api.deepseek.com", }); return _openai; } const MODEL = process.env.DEEPSEEK_MODEL || "deepseek-v4-pro"; function ts() { return `[${new Date().toISOString()}]`; } // ======== 配置 ======== export const LEARNING_CONFIG = { // 候选帖最小门槛 minReplies: 3, minLikes: 5, minHumanReplies: 1, // 回看窗口(天) lookbackDays: 7, // 候选数上限 candidateLimit: 30, // 每个 bot 每周上限 perBotPerWeek: 1, // 关联度评分 sameForumWeight: 1.0, keywordOverlapWeight: 0.6, recencyWeight: 0.3, // 过期 expireDays: 7, // LLM 提示 token 上限 contentTruncate: 1500, }; // ======== 工具:周 key (ISO week) ======== /** * 形如 "2026-W23",与 date-fns / Excel WEEKNUM 行为一致 */ export function getISOWeekKey(date = new Date()) { const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate())); const dayNum = d.getUTCDay() || 7; // Mon=1..Sun=7 d.setUTCDate(d.getUTCDate() + 4 - dayNum); const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1)); const weekNo = Math.ceil(((d - yearStart) / 86400000 + 1) / 7); return `${d.getUTCFullYear()}-W${String(weekNo).padStart(2, "0")}`; } // ======== 工具:取候选真人帖 ======== /** * 拉过去 N 天内由真人发布、且互动数超过门槛的 topic 列表 * 排序:综合分 = 赞×3 + 真人回复×5 + 回复 * 仅返回 topic 维度(post 维度后续可加) */ export async function fetchHumanHighEngagementTopics(lookbackDays = 7, limit = 30) { const prisma = getPrisma(); const since = new Date(Date.now() - lookbackDays * 24 * 60 * 60 * 1000); // 1) 先拉 botUserId 集合,避免 N+1 const botUsers = await prisma.user.findMany({ where: { isBot: true }, select: { id: true }, }); const botUserIdSet = new Set(botUsers.map((u) => u.id)); // 2) 拉所有非 bot 的 topic,按 replyCount desc 取前 N(粗筛) const candidates = await prisma.forumTopic.findMany({ where: { userId: { notIn: Array.from(botUserIdSet) }, isHidden: false, createdAt: { gte: since }, }, include: { user: { select: { id: true, name: true, isBot: true } }, category: { select: { slug: true, name: true } }, posts: { where: { user: { isBot: false } }, select: { id: true }, }, }, orderBy: [{ replyCount: "desc" }, { likeCount: "desc" }], take: limit * 2, }); // 3) 计算综合分,过门槛 const scored = candidates .filter((t) => !t.user?.isBot) .map((t) => { const humanReplies = t.posts?.length || 0; const score = (t.likeCount || 0) * 3 + humanReplies * 5 + (t.replyCount || 0); return { ...t, _score: score, _humanReplies: humanReplies }; }) .filter( (t) => (t.replyCount || 0) >= LEARNING_CONFIG.minReplies || t._humanReplies >= LEARNING_CONFIG.minHumanReplies || (t.likeCount || 0) >= LEARNING_CONFIG.minLikes ) .sort((a, b) => b._score - a._score) .slice(0, limit); return scored; } // ======== 工具:候选与 bot 关联度评分 ======== /** * 计算一篇候选帖对某 bot 的关联度 * - 板块命中:bot.primaryForums 包含该帖板块 → 同板块加权 * - 关键词重叠:bot stanceKeywords 与帖子标题/内容重叠数 * - 时效衰减:越新越好 */ function calcRelevance(bot, persona, topic) { let score = 0; const primaryForums = (bot.primaryForums || []).map((s) => String(s).toLowerCase()); const stanceKeywords = ((persona?.stanceKeywords || []).map((k) => k.key || k)).filter(Boolean); if (primaryForums.includes(String(topic.category?.slug || "").toLowerCase())) { score += LEARNING_CONFIG.sameForumWeight; } if (stanceKeywords.length > 0) { const text = `${topic.title || ""} ${topic.content || ""}`.toLowerCase(); let hits = 0; for (const kw of stanceKeywords) { const k = String(kw).toLowerCase(); if (k.length < 2) continue; if (text.includes(k)) hits++; } const overlapRatio = Math.min(1, hits / 5); score += overlapRatio * LEARNING_CONFIG.keywordOverlapWeight; } // 时效:距今 < 3 天满分,> 6 天 0 分 const ageMs = Date.now() - new Date(topic.createdAt).getTime(); const ageDays = ageMs / (24 * 60 * 60 * 1000); const recencyScore = Math.max(0, 1 - ageDays / 6) * LEARNING_CONFIG.recencyWeight; score += recencyScore; return Math.round(score * 1000) / 1000; } // ======== LLM 提取"为什么高互动"洞察 ======== const LEARN_PROMPT = (botName, botRole, topic) => `你是【${botName}】的内容策略师,每周要研究一篇真实用户(不是数字人)的高互动帖子,提炼出"为什么火",作为下周发帖可借鉴的方向。 [数字人角色] - 名字:${botName} - 角色:${botRole || "通用论坛内容创作者"} [本周高互动真人帖] - 标题:${topic.title} - 板块:${topic.category?.name || ""} - 收到回复:${topic.replyCount}(真人 ${topic._humanReplies || 0}) - 收到点赞:${topic.likeCount} - 浏览数:${topic.viewCount} - 内容(截取前 ${LEARNING_CONFIG.contentTruncate} 字): ${(topic.content || "").slice(0, LEARNING_CONFIG.contentTruncate)} [任务] 站在【${botName}】的视角,分析这篇真人帖之所以高互动的 2-3 个可学习点。注意: 1. 不要泛泛而谈"写得好",要给出具体的"可复用的写法/角度/钩子" 2. 关注"为什么能引发真人回复"——是观点鲜明、抛问题、还是给方案/故事 3. 提炼出 2-3 个对【${botName}】下周发帖有直接借鉴价值的策略 4. 输出一段 150-250 字的简洁洞察,下周发帖时可作为参考 [输出格式] 只输出严格的 JSON: { "insight": "(150-250 字的洞察,聚焦可复用的写法/角度/钩子)", "categories": ["hook|story|data|question|contrarian|empathy|practical", ...] }`; async function extractInsightWithLLM(botChar, topic) { const openai = getOpenAI(); if (!openai) { // 降级:无 LLM 时输出规则性总结 return buildFallbackInsight(topic); } const p = botChar?.personality || {}; const role = [p.identity, p.stance, p.speakingStyle].filter(Boolean).join(" / "); const prompt = LEARN_PROMPT(botChar?.displayName || "数字人", role, topic); const response = await withRetry(() => openai.chat.completions.create({ model: MODEL, messages: [{ role: "user", content: prompt }], temperature: 0.6, // DeepSeek V4 推理 token 计入 max_tokens,预算不足会导致正文截断/为空 max_tokens: 4096, }) ); const text = response.choices?.[0]?.message?.content?.trim() || ""; const match = text.match(/\{[\s\S]*\}/); if (!match) return buildFallbackInsight(topic); try { const parsed = JSON.parse(match[0]); return { insight: String(parsed.insight || "").slice(0, 1000), categories: Array.isArray(parsed.categories) ? parsed.categories.slice(0, 5) : [], }; } catch { return buildFallbackInsight(topic); } } function buildFallbackInsight(topic) { return { insight: `这篇真人帖(${topic.title || "无标题"})收到 ${topic.replyCount} 条回复 / ${topic.likeCount} 个赞,关键在于:${(topic.content || "").slice(0, 80)}...的可复用角度。下周可参考其切入点和表达方式。`, categories: ["practical"], }; } // ======== 单 bot 学习流程 ======== /** * 对一个 bot 跑对抗学习: * 1) 拉真人高互动候选 * 2) 与 bot 关联度排序 → 选 top1 * 3) LLM 提取洞察 * 4) upsert 到 bot_adversarial_learnings (unique: botId+weekKey) * 5) 同步写一条 BotMemory */ export async function learnForBot(botUser, botConfig, persona, options = {}) { const prisma = getPrisma(); const cfg = { ...LEARNING_CONFIG, ...options }; const weekKey = options.weekKey || getISOWeekKey(); // 已存在本 bot 本周的学习 → 跳过 const existing = await prisma.botAdversarialLearning.findUnique({ where: { botId_weekKey: { botId: botConfig.id, weekKey } }, }); if (existing) { return { skipped: true, reason: "already_learned", record: existing }; } // 1) 候选 const candidates = await fetchHumanHighEngagementTopics(cfg.lookbackDays, cfg.candidateLimit); if (candidates.length === 0) { return { skipped: true, reason: "no_candidates" }; } // 2) 关联度排序 const ranked = candidates .map((c) => ({ topic: c, relevance: calcRelevance(botConfig, persona, c) })) .sort((a, b) => b.relevance - a.relevance); const top = ranked[0]; // 3) LLM 提取 const { insight, categories } = await extractInsightWithLLM(botUser._botChar, top.topic); // 4) upsert 写入 const expiresAt = new Date(Date.now() + cfg.expireDays * 24 * 60 * 60 * 1000); const record = await prisma.botAdversarialLearning.upsert({ where: { botId_weekKey: { botId: botConfig.id, weekKey } }, create: { botId: botConfig.id, weekKey, sourceRefType: "topic", sourceRefId: top.topic.id, sourceUserId: top.topic.userId, sourceUserName: top.topic.user?.name || null, forumSlug: top.topic.category?.slug || null, sourceTitle: top.topic.title || null, sourceContent: (top.topic.content || "").slice(0, 5000), sourceMetrics: { replyCount: top.topic.replyCount || 0, likeCount: top.topic.likeCount || 0, viewCount: top.topic.viewCount || 0, humanReplies: top._humanReplies || 0, engagementScore: top._score, }, relevanceScore: top.relevance, learnedInsight: insight, learnCategories: categories, status: "active", expiresAt, }, update: { // 本周二次跑:覆盖(强制重学) sourceRefType: "topic", sourceRefId: top.topic.id, sourceUserId: top.topic.userId, sourceUserName: top.topic.user?.name || null, forumSlug: top.topic.category?.slug || null, sourceTitle: top.topic.title || null, sourceContent: (top.topic.content || "").slice(0, 5000), sourceMetrics: { replyCount: top.topic.replyCount || 0, likeCount: top.topic.likeCount || 0, viewCount: top.topic.viewCount || 0, humanReplies: top._humanReplies || 0, engagementScore: top._score, }, relevanceScore: top.relevance, learnedInsight: insight, learnCategories: categories, status: "active", expiresAt, }, }); // 5) 同步写一条 BotMemory await prisma.botMemory.create({ data: { botId: botConfig.id, memoryType: "adversarial_learning", layer: "LONGTERM", content: { action: "learn_from_human", weekKey, learningId: record.id, sourceTitle: top.topic.title, sourceUserName: top.topic.user?.name || null, sourceUserId: top.topic.userId, sourceRefType: "topic", sourceRefId: top.topic.id, insight, categories, relevanceScore: top.relevance, sourceMetrics: { replyCount: top.topic.replyCount || 0, likeCount: top.topic.likeCount || 0, humanReplies: top._humanReplies || 0, }, }, importance: 0.85, contextTags: { weekKey, forumSlug: top.topic.category?.slug || null, source: "adversarial_learning", learningId: record.id, }, }, }); return { skipped: false, record, candidate: top.topic, relevance: top.relevance }; } // ======== 加载所有 bot 配置 + 角色 + persona ======== async function loadAllExpertBots() { const prisma = getPrisma(); const botUsers = await prisma.user.findMany({ where: { isBot: true }, include: { botConfig: true }, }); // 过滤出有 config 的"非路人"专家 bot return botUsers .filter((u) => u.botConfig && (!u.botConfig.personality || !u.botConfig.personality?.role || u.botConfig.personality?.role !== "passerby")) .map((u) => ({ user: u, config: u.botConfig })); } async function loadPersonaForBot(botConfigId) { const prisma = getPrisma(); return prisma.botPersona.findUnique({ where: { botId: botConfigId } }); } // 尝试从 bot-characters.json 加载角色定义(仅用于 LLM 提示) async function loadBotCharMap() { try { const fs = await import("fs"); const path = await import("path"); const url = await import("url"); const { readFileSync } = fs; const { resolve, dirname } = path; const { fileURLToPath } = url; const __dirname = dirname(fileURLToPath(import.meta.url)); const p = resolve(__dirname, "..", "..", "data", "bot-characters.json"); const raw = readFileSync(p, "utf-8"); const { characters } = JSON.parse(raw); const map = {}; for (const c of characters) map[c.key] = c; return map; } catch { return {}; } } // ======== 周调度入口 ======== /** * 给所有专家 bot 跑一次对抗学习 * @param options * - weekKey: 强制指定周 key(默认当前 ISO 周) * - lookbackDays: 候选回看窗口(默认 7) * - botConfigIds: 限定 bot id 列表 * - skipExisting: 已有本周学习时跳过(默认 true) */ export async function runWeeklyAdversarialLearning(options = {}) { const prisma = getPrisma(); const weekKey = options.weekKey || getISOWeekKey(); const botCharMap = await loadBotCharMap(); const bots = await loadAllExpertBots(); const targetBots = options.botConfigIds ? bots.filter((b) => options.botConfigIds.includes(b.config.id)) : bots; const results = []; for (const { user, config } of targetBots) { try { const persona = await loadPersonaForBot(config.id); const emailPrefix = (user.email || "").split("@")[0] || ""; const key = emailPrefix.replace(/^bot_/, ""); user._botChar = botCharMap[key] || { displayName: user.name, personality: {} }; const r = await learnForBot(user, config, persona, { weekKey, lookbackDays: options.lookbackDays ?? LEARNING_CONFIG.lookbackDays, }); results.push({ botId: config.id, botName: user.name, ...r }); } catch (err) { results.push({ botId: config.id, botName: user.name, error: err.message }); } } // 把过期的 learning 标记 expired await prisma.botAdversarialLearning.updateMany({ where: { status: "active", expiresAt: { lt: new Date() }, }, data: { status: "expired" }, }); const summary = { weekKey, botCount: targetBots.length, learned: results.filter((r) => !r.skipped && !r.error).length, skipped: results.filter((r) => r.skipped).length, errors: results.filter((r) => r.error).length, results, }; return summary; } // ======== 在 prompt 中使用 ======== /** * 拉取某 bot 当前生效的对抗学习(status=active 且未过期),取最近一条 */ export async function getActiveLearningForBot(botConfigId) { const prisma = getPrisma(); return prisma.botAdversarialLearning.findFirst({ where: { botId: botConfigId, status: "active", OR: [{ expiresAt: null }, { expiresAt: { gt: new Date() } }], }, orderBy: { learnedAt: "desc" }, }); } /** * 把 learning 渲染成可注入 prompt 的 block */ export function buildAdversarialBlock(learning) { if (!learning) return ""; const lines = []; lines.push(`[对抗学习参考 · ${learning.weekKey}]`); if (learning.sourceTitle) { lines.push(`- 高互动真人帖:${learning.sourceTitle}`); } if (learning.sourceUserName) { lines.push(`- 真人作者:${learning.sourceUserName}`); } if (learning.forumSlug) { lines.push(`- 板块:${learning.forumSlug}`); } const m = learning.sourceMetrics || {}; if (m.replyCount !== undefined) { lines.push( `- 互动数据:${m.replyCount || 0} 回复 / ${m.humanReplies || 0} 真人回复 / ${m.likeCount || 0} 赞 / ${m.viewCount || 0} 浏览` ); } if (Array.isArray(learning.learnCategories) && learning.learnCategories.length > 0) { lines.push(`- 可借鉴角度:${learning.learnCategories.join("、")}`); } if (learning.learnedInsight) { lines.push(`- 洞察:${learning.learnedInsight}`); } lines.push("- 提示:本周发帖/回复时可参考上述真人帖的切入角度、表达方式,但不要直接抄袭内容。"); return `\n${lines.join("\n")}\n`; } /** * 标记 learning 已被使用一次(用于统计触达率) */ export async function markLearningUsed(learningId) { if (!learningId) return; const prisma = getPrisma(); await prisma.botAdversarialLearning.update({ where: { id: learningId }, data: { usedCount: { increment: 1 } }, }); } /** * 优雅关闭 prisma */ export async function disconnectAdversarialLearning() { if (_prisma) { await _prisma.$disconnect(); _prisma = null; } }