Files
zhuiguang-ai/scripts/lib/bot-adversarial-learning.mjs

537 lines
18 KiB
JavaScript
Raw Permalink 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.
// 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;
}
}