全项目扫描修复: Docker数据卷修复+安全requireAdmin+12页SEO+API白名单+脚本超时+常量提取+假数据删除
This commit is contained in:
@@ -0,0 +1,535 @@
|
||||
// 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/v1",
|
||||
});
|
||||
return _openai;
|
||||
}
|
||||
|
||||
const MODEL = process.env.DEEPSEEK_MODEL || "deepseek-chat";
|
||||
|
||||
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,
|
||||
max_tokens: 800,
|
||||
})
|
||||
);
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
// scripts/lib/bot-avatar-generator.mjs
|
||||
// 数字人头像生成器
|
||||
//
|
||||
// 策略:
|
||||
// 1. 先调用 Lunaris (trae-api text_to_image) 拿一张"AI 生成"图
|
||||
// 2. 检测是否是占位 default(目前 trae-api 任何 prompt 都返回同一张 default.jpeg)
|
||||
// 3. 若是占位 → 用程序化 SVG 生成唯一头像(基于 key 哈希 + 调色板 + 角色 initial)
|
||||
// 4. 落盘到 public/bot-avatars/{key}.{ext} + 更新 User.avatarUrl
|
||||
//
|
||||
// 这样每个 bot 都有视觉上独特的头像;当 Lunaris 真正支持按 prompt 生成时,
|
||||
// 重跑 --force --use-lunaris-only 即可切回 AI 生成的版本。
|
||||
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { PrismaMariaDb } from "@prisma/adapter-mariadb";
|
||||
import { readFileSync, writeFileSync, existsSync, mkdirSync } from "fs";
|
||||
import { resolve, dirname } from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import "dotenv/config";
|
||||
|
||||
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=5&pool_timeout=30`;
|
||||
const adapter = new PrismaMariaDb(connectionString);
|
||||
export const prisma = new PrismaClient({ adapter });
|
||||
|
||||
export const BOT_DATA_PATH = resolve(__dirname, "..", "..", "data", "bot-characters.json");
|
||||
export const AVATAR_DIR = resolve(__dirname, "..", "..", "public", "bot-avatars");
|
||||
export const LUNARIS_DIR = resolve(AVATAR_DIR, "lunaris");
|
||||
|
||||
export const LUNARIS_ENDPOINT = "https://trae-api-cn.mchost.guru/api/ide/v1/text_to_image";
|
||||
|
||||
// trae-api 当前所有 prompt 都返回同一张 default.jpeg(实测 176626 bytes)
|
||||
// 用这个 hash 前缀识别占位图,命中则改用 SVG 兜底
|
||||
export const LUNARIS_DEFAULT_HASH_PREFIX = "e330cd0232";
|
||||
|
||||
// ---------- 工具函数 ----------
|
||||
|
||||
/**
|
||||
* 把 key 字符串做稳定 hash(32-bit FNV-1a 变体)
|
||||
*/
|
||||
export function hashKey(key) {
|
||||
let h = 0x811c9dc5;
|
||||
for (let i = 0; i < key.length; i++) {
|
||||
h ^= key.charCodeAt(i);
|
||||
h = Math.imul(h, 0x01000193) >>> 0;
|
||||
}
|
||||
return h;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 0..MAX 区间里按 key 取一个稳定随机数
|
||||
*/
|
||||
export function pickFromKey(key, max) {
|
||||
return hashKey(key) % max;
|
||||
}
|
||||
|
||||
/**
|
||||
* 把 key 拆成 4 个 8-bit 数字(0-255)用于 SVG 几何定位
|
||||
*/
|
||||
export function keyToBytes(key) {
|
||||
const h1 = pickFromKey(key + "a", 256);
|
||||
const h2 = pickFromKey(key + "b", 256);
|
||||
const h3 = pickFromKey(key + "c", 256);
|
||||
const h4 = pickFromKey(key + "d", 256);
|
||||
return [h1, h2, h3, h4];
|
||||
}
|
||||
|
||||
// 调色板:与 src/lib/bot-utils.ts 的 getBotColorScheme 保持视觉一致
|
||||
// 但用实际 hex(Tailwind 调色板)方便 SVG 渲染
|
||||
export const PALETTE = [
|
||||
{ from: "#fb7185", to: "#fb923c", accent: "#fff1f2", name: "rose-orange" },
|
||||
{ from: "#fbbf24", to: "#f43f5e", accent: "#fffbeb", name: "amber-rose" },
|
||||
{ from: "#34d399", to: "#06b6d4", accent: "#ecfdf5", name: "emerald-cyan" },
|
||||
{ from: "#38bdf8", to: "#6366f1", accent: "#f0f9ff", name: "sky-indigo" },
|
||||
{ from: "#a78bfa", to: "#d946ef", accent: "#f5f3ff", name: "violet-fuchsia" },
|
||||
{ from: "#e879f9", to: "#ec4899", accent: "#fdf4ff", name: "fuchsia-pink" },
|
||||
{ from: "#2dd4bf", to: "#10b981", accent: "#f0fdfa", name: "teal-emerald" },
|
||||
{ from: "#818cf8", to: "#a855f7", accent: "#eef2ff", name: "indigo-purple" },
|
||||
{ from: "#fb923c", to: "#ef4444", accent: "#fff7ed", name: "orange-red" },
|
||||
{ from: "#22d3ee", to: "#3b82f6", accent: "#ecfeff", name: "cyan-blue" },
|
||||
];
|
||||
|
||||
export function pickPalette(key) {
|
||||
return PALETTE[pickFromKey(key, PALETTE.length)];
|
||||
}
|
||||
|
||||
// ---------- Lunaris 调用 ----------
|
||||
|
||||
export async function fetchLunarisImage(avatarPrompt, { size = "square_hd", timeout = 30000 } = {}) {
|
||||
const enhancedPrompt = `人物头像, ${avatarPrompt}, 高质量肖像照, 自然光, 浅色背景, 半身像, 微笑, 现代感`;
|
||||
const url = `${LUNARIS_ENDPOINT}?prompt=${encodeURIComponent(enhancedPrompt)}&image_size=${size}`;
|
||||
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeout);
|
||||
try {
|
||||
const r = await fetch(url, {
|
||||
headers: { Accept: "image/*,*/*", "User-Agent": "Mozilla/5.0" },
|
||||
signal: controller.signal,
|
||||
redirect: "follow",
|
||||
});
|
||||
if (!r.ok) throw new Error(`Lunaris HTTP ${r.status}`);
|
||||
const ct = r.headers.get("content-type") || "";
|
||||
if (!ct.includes("image")) throw new Error(`Lunaris 响应非图片: ${ct}`);
|
||||
const buf = Buffer.from(await r.arrayBuffer());
|
||||
return { buffer: buf, contentType: ct, size: buf.byteLength, url: r.url };
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
export async function isLunarisDefault(buffer) {
|
||||
const { createHash } = await import("crypto");
|
||||
const hash = createHash("sha256").update(buffer).digest("hex");
|
||||
return hash.startsWith(LUNARIS_DEFAULT_HASH_PREFIX);
|
||||
}
|
||||
|
||||
// ---------- SVG 程序化生成 ----------
|
||||
|
||||
/**
|
||||
* 根据 bot persona 生成 512x512 SVG 头像
|
||||
* 设计要素:
|
||||
* - 渐变背景(palette.from → palette.to)
|
||||
* - 4 个装饰几何形(位置/大小/旋转由 key 决定)
|
||||
* - 中央大字 initial(displayName 首字符)
|
||||
* - 右下角 🤖 标识(用 SVG path 画,不依赖 emoji 字体)
|
||||
*/
|
||||
export function generateSvgAvatar({ key, displayName, persona }) {
|
||||
const palette = pickPalette(key);
|
||||
const [b1, b2, b3, b4] = keyToBytes(key);
|
||||
const initial = (displayName || key).charAt(0).toUpperCase();
|
||||
const isPasserby = persona?.role === "passerby";
|
||||
|
||||
// 4 个装饰形:圆/三角/方/菱形,位置基于 key 哈希
|
||||
const shapes = [
|
||||
{ type: "circle", cx: 40 + b1 * 0.7, cy: 60 + b2 * 0.4, r: 30 + (b3 % 40), opacity: 0.18 },
|
||||
{ type: "rect", x: 320 + b2 * 0.3, y: 30 + b3 * 0.2, w: 60 + (b4 % 50), h: 60 + (b1 % 50), rot: b1 % 90, opacity: 0.14 },
|
||||
{ type: "circle", cx: 380 + b3 * 0.2, cy: 320 + b4 * 0.4, r: 40 + (b2 % 60), opacity: 0.12 },
|
||||
{ type: "polygon", points: `60,${380 + b1 * 0.2} ${120 + b2 * 0.2},${440 + b3 * 0.15} ${20 + b4 * 0.3},${460}`, opacity: 0.16 },
|
||||
];
|
||||
|
||||
const shapeSvg = shapes
|
||||
.map((s) => {
|
||||
if (s.type === "circle") {
|
||||
return `<circle cx="${s.cx}" cy="${s.cy}" r="${s.r}" fill="white" opacity="${s.opacity}"/>`;
|
||||
}
|
||||
if (s.type === "rect") {
|
||||
return `<rect x="${s.x}" y="${s.y}" width="${s.w}" height="${s.h}" transform="rotate(${s.rot} ${s.x + s.w / 2} ${s.y + s.h / 2})" fill="white" opacity="${s.opacity}"/>`;
|
||||
}
|
||||
return `<polygon points="${s.points}" fill="white" opacity="${s.opacity}"/>`;
|
||||
})
|
||||
.join("\n ");
|
||||
|
||||
// 角色副标识
|
||||
const roleBadge = isPasserby
|
||||
? `<rect x="20" y="20" width="92" height="28" rx="14" fill="rgba(255,255,255,0.25)"/>
|
||||
<text x="66" y="38" text-anchor="middle" fill="white" font-size="13" font-weight="600" font-family="system-ui">社区观察者</text>`
|
||||
: `<rect x="20" y="20" width="92" height="28" rx="14" fill="rgba(255,255,255,0.25)"/>
|
||||
<text x="66" y="38" text-anchor="middle" fill="white" font-size="13" font-weight="600" font-family="system-ui">行业专家</text>`;
|
||||
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="${palette.from}"/>
|
||||
<stop offset="100%" stop-color="${palette.to}"/>
|
||||
</linearGradient>
|
||||
<filter id="shadow" x="-50%" y="-50%" width="200%" height="200%">
|
||||
<feGaussianBlur in="SourceAlpha" stdDeviation="6"/>
|
||||
<feOffset dx="0" dy="4" result="offsetblur"/>
|
||||
<feComponentTransfer><feFuncA type="linear" slope="0.35"/></feComponentTransfer>
|
||||
<feMerge><feMergeNode/><feMergeNode in="SourceGraphic"/></feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
<!-- 背景渐变 -->
|
||||
<rect width="512" height="512" fill="url(#bg)"/>
|
||||
<!-- 装饰图形 -->
|
||||
${shapeSvg}
|
||||
<!-- 角色徽章 -->
|
||||
${roleBadge}
|
||||
<!-- 中央大字母 -->
|
||||
<text x="256" y="320" text-anchor="middle" fill="white" font-size="280" font-weight="800" font-family="system-ui,-apple-system,sans-serif" filter="url(#shadow)">${escapeXml(initial)}</text>
|
||||
<!-- 右下角 🤖 标识(用 path 画,不依赖 emoji 字体) -->
|
||||
<g transform="translate(440 440)">
|
||||
<circle cx="0" cy="0" r="32" fill="white" opacity="0.95"/>
|
||||
<path d="M-12 -4 L-12 8 Q-12 14 -6 14 L6 14 Q12 14 12 8 L12 -4 Q12 -10 6 -10 L-6 -10 Q-12 -10 -12 -4 Z" fill="${palette.from}"/>
|
||||
<circle cx="-4" cy="0" r="2" fill="white"/>
|
||||
<circle cx="4" cy="0" r="2" fill="white"/>
|
||||
<line x1="-6" y1="6" x2="6" y2="6" stroke="white" stroke-width="1.5" stroke-linecap="round"/>
|
||||
<line x1="-8" y1="-12" x2="-12" y2="-16" stroke="${palette.from}" stroke-width="2" stroke-linecap="round"/>
|
||||
<line x1="0" y1="-14" x2="0" y2="-20" stroke="${palette.from}" stroke-width="2" stroke-linecap="round"/>
|
||||
<line x1="8" y1="-12" x2="12" y2="-16" stroke="${palette.from}" stroke-width="2" stroke-linecap="round"/>
|
||||
<circle cx="-12" cy="-16" r="2" fill="${palette.from}"/>
|
||||
<circle cx="0" cy="-20" r="2" fill="${palette.from}"/>
|
||||
<circle cx="12" cy="-16" r="2" fill="${palette.from}"/>
|
||||
</g>
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
function escapeXml(s) {
|
||||
return String(s).replace(/[<>&'"]/g, (c) => ({ "<": "<", ">": ">", "&": "&", "'": "'", '"': """ }[c]));
|
||||
}
|
||||
|
||||
// ---------- 主流程 ----------
|
||||
|
||||
/**
|
||||
* 给单个 bot 生成头像
|
||||
* @returns {avatarUrl, source, key, bytes}
|
||||
*/
|
||||
export async function generateForBot(character, { force = false, useLunarisOnly = false, saveLunaris = true } = {}) {
|
||||
if (!existsSync(AVATAR_DIR)) mkdirSync(AVATAR_DIR, { recursive: true });
|
||||
if (saveLunaris && !existsSync(LUNARIS_DIR)) mkdirSync(LUNARIS_DIR, { recursive: true });
|
||||
|
||||
const svgPath = resolve(AVATAR_DIR, `${character.key}.svg`);
|
||||
const lunarisPath = resolve(LUNARIS_DIR, `${character.key}.jpg`);
|
||||
const publicSvgUrl = `/bot-avatars/${character.key}.svg`;
|
||||
|
||||
// 已有 SVG 且不强制 → 跳过
|
||||
if (!force && existsSync(svgPath)) {
|
||||
return { key: character.key, source: "existing-svg", avatarUrl: publicSvgUrl, bytes: readFileSync(svgPath).byteLength };
|
||||
}
|
||||
|
||||
// 步骤 1: 调 Lunaris(顺便存档 lunaris/{key}.jpg)
|
||||
let lunarisBuf = null;
|
||||
let lunarisIsDefault = true;
|
||||
try {
|
||||
lunarisBuf = await fetchLunarisImage(character.avatarPrompt || "");
|
||||
lunarisIsDefault = await isLunarisDefault(lunarisBuf.buffer);
|
||||
if (saveLunaris) writeFileSync(lunarisPath, lunarisBuf.buffer);
|
||||
} catch (err) {
|
||||
console.warn(`[${character.key}] Lunaris 拉取失败: ${err.message}`);
|
||||
}
|
||||
|
||||
// 步骤 2: 决定最终使用
|
||||
if (useLunarisOnly && lunarisBuf && !lunarisIsDefault) {
|
||||
// 未来 Lunaris 真支持按 prompt 生成时启用
|
||||
const jpgPath = resolve(AVATAR_DIR, `${character.key}.jpg`);
|
||||
writeFileSync(jpgPath, lunarisBuf.buffer);
|
||||
return { key: character.key, source: "lunaris", avatarUrl: `/bot-avatars/${character.key}.jpg`, bytes: lunarisBuf.buffer.byteLength };
|
||||
}
|
||||
|
||||
// 步骤 3: 用 SVG 生成(覆盖 Lunaris 行为或作为兜底)
|
||||
const svg = generateSvgAvatar({
|
||||
key: character.key,
|
||||
displayName: character.displayName,
|
||||
persona: character.personality || {},
|
||||
});
|
||||
writeFileSync(svgPath, svg, "utf-8");
|
||||
return {
|
||||
key: character.key,
|
||||
source: lunarisIsDefault ? "svg-fallback" : "svg-preferred",
|
||||
avatarUrl: publicSvgUrl,
|
||||
bytes: Buffer.byteLength(svg, "utf-8"),
|
||||
lunarisIsDefault,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 给所有 bot 批量生成
|
||||
*/
|
||||
export async function generateForAll({ force = false, botKey = null, useLunarisOnly = false, delayMs = 200 } = {}) {
|
||||
const all = JSON.parse(readFileSync(BOT_DATA_PATH, "utf-8")).characters || [];
|
||||
const targets = botKey ? all.filter((c) => c.key === botKey) : all;
|
||||
if (botKey && targets.length === 0) throw new Error(`Bot "${botKey}" 不在 bot-characters.json 中`);
|
||||
|
||||
const results = [];
|
||||
for (const ch of targets) {
|
||||
try {
|
||||
const r = await generateForBot(ch, { force, useLunarisOnly });
|
||||
// 写库
|
||||
const user = await prisma.user.findFirst({ where: { email: ch.email } });
|
||||
if (user) {
|
||||
await prisma.user.update({ where: { id: user.id }, data: { avatarUrl: r.avatarUrl } });
|
||||
r.userId = user.id;
|
||||
r.dbUpdated = true;
|
||||
} else {
|
||||
r.dbUpdated = false;
|
||||
r.warn = `User ${ch.email} 不存在`;
|
||||
}
|
||||
results.push(r);
|
||||
console.log(`✅ ${ch.key} (${ch.displayName}) → ${r.avatarUrl} [${r.source}, ${r.bytes}B]`);
|
||||
} catch (err) {
|
||||
results.push({ key: ch.key, error: err.message });
|
||||
console.error(`❌ ${ch.key}: ${err.message}`);
|
||||
}
|
||||
if (delayMs > 0) await new Promise((r) => setTimeout(r, delayMs));
|
||||
}
|
||||
|
||||
// 写 manifest
|
||||
const manifest = {
|
||||
generatedAt: new Date().toISOString(),
|
||||
total: results.length,
|
||||
succeeded: results.filter((r) => !r.error).length,
|
||||
failed: results.filter((r) => r.error).length,
|
||||
avatars: results.map((r) => ({
|
||||
key: r.key,
|
||||
avatarUrl: r.avatarUrl,
|
||||
source: r.source,
|
||||
bytes: r.bytes,
|
||||
})),
|
||||
};
|
||||
writeFileSync(resolve(AVATAR_DIR, "manifest.json"), JSON.stringify(manifest, null, 2), "utf-8");
|
||||
|
||||
return { summary: { total: results.length, ok: manifest.succeeded, failed: manifest.failed }, results };
|
||||
}
|
||||
|
||||
export async function disconnect() {
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
@@ -0,0 +1,680 @@
|
||||
// Bot Persona A/B Testing 共享模块
|
||||
// 用法:
|
||||
// import { ensureBotVariants, pickAndAssignVariant, recordAssignmentMetrics } from "./lib/bot-persona-experiment.mjs";
|
||||
//
|
||||
// // 1) 给 bot 准备至少 2 个变体(首次自动创建 control + variant_a)
|
||||
// await ensureBotVariants(botConfigId, botUserId, persona);
|
||||
//
|
||||
// // 2) 发帖/回复时分流,记录 assignment
|
||||
// const { variant, assignment } = await pickAndAssignVariant(botConfigId, "topic", topicId, forumSlug);
|
||||
//
|
||||
// // 3) 指标采集(在 cron 里调用)
|
||||
// await recordAssignmentMetrics();
|
||||
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { PrismaMariaDb } from "@prisma/adapter-mariadb";
|
||||
import "dotenv/config";
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
function ts() {
|
||||
return `[${new Date().toISOString()}]`;
|
||||
}
|
||||
|
||||
function pick(arr) {
|
||||
return arr[Math.floor(Math.random() * arr.length)];
|
||||
}
|
||||
|
||||
function weightedPick(items, weightKey = "weight") {
|
||||
if (!items || items.length === 0) return null;
|
||||
const active = items.filter((i) => i.isActive);
|
||||
const pool = active.length > 0 ? active : items;
|
||||
const total = pool.reduce((s, x) => s + Math.max(0, x[weightKey] || 0), 0);
|
||||
if (total <= 0) return pool[0];
|
||||
let r = Math.random() * total;
|
||||
for (const it of pool) {
|
||||
r -= Math.max(0, it[weightKey] || 0);
|
||||
if (r <= 0) return it;
|
||||
}
|
||||
return pool[pool.length - 1];
|
||||
}
|
||||
|
||||
// ======== 变体生成 ========
|
||||
// 根据现有 persona 派生 2 个变体
|
||||
// - control: 保留原始行为(无 styleHints)
|
||||
// - variant_a: "口语化 + 故事化" 风格
|
||||
// - variant_b: "数据驱动 + 简洁" 风格
|
||||
// - variant_c(可选): "反主流观点 + 提问引导" 风格
|
||||
const VARIANT_PRESETS = [
|
||||
{
|
||||
variantKey: "control",
|
||||
label: "对照组(原版画像)",
|
||||
description: "保留原始 persona 行为,作为 A/B 测试的基准",
|
||||
isControl: true,
|
||||
weight: 0.4,
|
||||
styleHints: {},
|
||||
},
|
||||
{
|
||||
variantKey: "variant_a",
|
||||
label: "A · 口语化+故事化",
|
||||
description: "更接地气、加入小故事、问号更多,更像真人聊天",
|
||||
isControl: false,
|
||||
weight: 0.3,
|
||||
styleHints: {
|
||||
tone: "casual",
|
||||
questionBoost: 0.2,
|
||||
storyBoost: 0.3,
|
||||
maxLength: 400,
|
||||
forbid: ["综上所述", "从以下几个方面", "首先其次最后", "不可忽视"],
|
||||
},
|
||||
},
|
||||
{
|
||||
variantKey: "variant_b",
|
||||
label: "B · 数据驱动+简洁",
|
||||
description: "多用数据/数字,结构化表达,字数更短",
|
||||
isControl: false,
|
||||
weight: 0.3,
|
||||
styleHints: {
|
||||
tone: "data_driven",
|
||||
questionBoost: -0.2,
|
||||
dataBoost: 0.4,
|
||||
maxLength: 280,
|
||||
requireData: true,
|
||||
forbid: ["综上所述", "从以下几个方面", "首先其次最后"],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* 给一个 bot 创建默认变体(如果还没有)
|
||||
* - 读现有 BotPersona
|
||||
* - 给 stanceKeywords、topTopicTypes 做变体化处理
|
||||
* - 全部 upsert 到 bot_persona_variants 表
|
||||
* @returns { variants: [...], created: number }
|
||||
*/
|
||||
export async function ensureBotVariants(botConfigId, botUserId) {
|
||||
const prisma = getPrisma();
|
||||
const existing = await prisma.botPersonaVariant.findMany({
|
||||
where: { botId: botConfigId },
|
||||
});
|
||||
if (existing.length > 0) {
|
||||
return { variants: existing, created: 0 };
|
||||
}
|
||||
|
||||
const persona = await prisma.botPersona.findUnique({ where: { botId: botConfigId } });
|
||||
const baseStance = (persona?.stanceKeywords || []).map((k) => k.key).slice(0, 12);
|
||||
const baseTopicTypes = (persona?.topTopicTypes || []).map((t) => t.name).slice(0, 5);
|
||||
|
||||
const created = [];
|
||||
for (const preset of VARIANT_PRESETS) {
|
||||
// 不同变体微调关键词:control 全保留;A 加上情绪/故事关键词;B 加上数据关键词
|
||||
const keywords = [...baseStance];
|
||||
if (preset.variantKey === "variant_a") {
|
||||
keywords.push("经历", "故事", "身边", "我", "朋友", "当时", "后来");
|
||||
} else if (preset.variantKey === "variant_b") {
|
||||
keywords.push("数据", "比例", "增长", "对比", "案例", "%", "GMV");
|
||||
}
|
||||
|
||||
const v = await prisma.botPersonaVariant.create({
|
||||
data: {
|
||||
botId: botConfigId,
|
||||
variantKey: preset.variantKey,
|
||||
label: preset.label,
|
||||
description: preset.description,
|
||||
isControl: preset.isControl,
|
||||
isActive: true,
|
||||
weight: preset.weight,
|
||||
stanceKeywords: Array.from(new Set(keywords)).slice(0, 15),
|
||||
topTopicTypes: baseTopicTypes,
|
||||
topHumanUsers: null,
|
||||
styleHints: preset.styleHints,
|
||||
},
|
||||
});
|
||||
created.push(v);
|
||||
}
|
||||
|
||||
return { variants: created, created: created.length };
|
||||
}
|
||||
|
||||
/**
|
||||
* 为某个 bot 启动一个 A/B 实验(默认包含 control + variant_a + variant_b)
|
||||
* - 多次调用安全:若已存在 status=active 的实验,复用
|
||||
* - 若已有变体但未挂到实验,挂到该实验
|
||||
*/
|
||||
export async function ensureExperiment(botConfigId) {
|
||||
const prisma = getPrisma();
|
||||
const existing = await prisma.botPersonaExperiment.findFirst({
|
||||
where: { botId: botConfigId, status: "active" },
|
||||
});
|
||||
if (existing) return existing;
|
||||
|
||||
const exp = await prisma.botPersonaExperiment.create({
|
||||
data: {
|
||||
botId: botConfigId,
|
||||
name: "persona A/B",
|
||||
description: "自动为 bot 启动的 persona A/B 测试:control vs variant_a(口语+故事) vs variant_b(数据+简洁)",
|
||||
status: "active",
|
||||
minSamplesPerArm: 20,
|
||||
significanceLevel: 0.1,
|
||||
primaryMetric: "engagement_score",
|
||||
startedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
// 把该 bot 已有变体挂到实验
|
||||
await prisma.botPersonaVariant.updateMany({
|
||||
where: { botId: botConfigId, experimentId: null },
|
||||
data: { experimentId: exp.id },
|
||||
});
|
||||
|
||||
return exp;
|
||||
}
|
||||
|
||||
/**
|
||||
* 给某条内容(topic / post)挑选一个变体并记录 assignment
|
||||
* @param botConfigId
|
||||
* @param refType "topic" | "post"
|
||||
* @param refId topicId / postId(必须 > 0;先选变体再创建内容时使用 pickVariantForPrompt)
|
||||
* @param forumSlug 可选
|
||||
* @returns { variant, assignment, personaBlock }
|
||||
*/
|
||||
export async function pickAndAssignVariant(botConfigId, refType, refId, forumSlug = null) {
|
||||
const prisma = getPrisma();
|
||||
|
||||
// 1) 确保变体存在
|
||||
const { variants } = await ensureBotVariants(botConfigId);
|
||||
if (variants.length === 0) {
|
||||
return { variant: null, assignment: null, personaBlock: "" };
|
||||
}
|
||||
|
||||
// 2) 选变体(按 weight)
|
||||
const variant = weightedPick(variants, "weight");
|
||||
if (!variant) {
|
||||
return { variant: null, assignment: null, personaBlock: "" };
|
||||
}
|
||||
|
||||
// 3) 写 assignment(unique key 保护)
|
||||
const assignment = await prisma.botPersonaAssignment.upsert({
|
||||
where: { refType_refId: { refType, refId } },
|
||||
create: {
|
||||
botId: botConfigId,
|
||||
variantId: variant.id,
|
||||
refType,
|
||||
refId,
|
||||
forumSlug,
|
||||
},
|
||||
update: {
|
||||
// 同一 ref 已存在,不覆盖 variant(保持首次分流)
|
||||
botId: botConfigId,
|
||||
},
|
||||
});
|
||||
|
||||
// 4) 变体 sample + 1
|
||||
await prisma.botPersonaVariant.update({
|
||||
where: { id: variant.id },
|
||||
data: { sampleCount: { increment: 1 } },
|
||||
});
|
||||
|
||||
return {
|
||||
variant,
|
||||
assignment,
|
||||
personaBlock: buildVariantPersonaBlock(variant),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 轻量版:只选变体 + 返回 personaBlock,**不写库**
|
||||
* 适用于"先生成内容再创建记录的顺序"场景(如先调用 LLM 再 createTopic)
|
||||
* 配合 commitAssignment(botConfigId, refType, refId, variantId) 在内容创建后落盘
|
||||
*/
|
||||
export async function pickVariantForPrompt(botConfigId) {
|
||||
const prisma = getPrisma();
|
||||
const { variants } = await ensureBotVariants(botConfigId);
|
||||
if (variants.length === 0) {
|
||||
return { variant: null, personaBlock: "" };
|
||||
}
|
||||
const variant = weightedPick(variants, "weight");
|
||||
if (!variant) {
|
||||
return { variant: null, personaBlock: "" };
|
||||
}
|
||||
return {
|
||||
variant,
|
||||
personaBlock: buildVariantPersonaBlock(variant),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 提交 assignment(轻量版的配套写入)
|
||||
* - 幂等:同一 (refType, refId) 多次调用不会变 variant
|
||||
* - 会自增变体 sampleCount
|
||||
*/
|
||||
export async function commitAssignment(botConfigId, variantId, refType, refId, forumSlug = null) {
|
||||
if (!variantId || !refId) return null;
|
||||
const prisma = getPrisma();
|
||||
const assignment = await prisma.botPersonaAssignment.upsert({
|
||||
where: { refType_refId: { refType, refId } },
|
||||
create: {
|
||||
botId: botConfigId,
|
||||
variantId,
|
||||
refType,
|
||||
refId,
|
||||
forumSlug,
|
||||
},
|
||||
update: { botId: botConfigId },
|
||||
});
|
||||
await prisma.botPersonaVariant.update({
|
||||
where: { id: variantId },
|
||||
data: { sampleCount: { increment: 1 } },
|
||||
});
|
||||
return assignment;
|
||||
}
|
||||
|
||||
// 把变体的 styleHints 格式化成可注入 prompt 的 block
|
||||
export function buildVariantPersonaBlock(variant) {
|
||||
if (!variant) return "";
|
||||
const hints = variant.styleHints || {};
|
||||
const parts = [];
|
||||
|
||||
parts.push(`[A/B 变体 ${variant.variantKey} · ${variant.label}]`);
|
||||
|
||||
if (hints.tone) {
|
||||
const toneMap = {
|
||||
casual: "语气:口语化、接地气",
|
||||
data_driven: "语气:数据驱动、引用具体数字",
|
||||
contrarian: "语气:反主流观点、犀利但有理",
|
||||
};
|
||||
parts.push(toneMap[hints.tone] || `语气:${hints.tone}`);
|
||||
}
|
||||
if (typeof hints.maxLength === "number") {
|
||||
parts.push(`字数上限:约 ${hints.maxLength} 字`);
|
||||
}
|
||||
if (hints.questionBoost) {
|
||||
parts.push(`问号倾向:${hints.questionBoost > 0 ? "略多问号" : "少用问句"}`);
|
||||
}
|
||||
if (hints.dataBoost) parts.push("多用数据/数字");
|
||||
if (hints.storyBoost) parts.push("多讲小故事/经历");
|
||||
if (Array.isArray(hints.forbid) && hints.forbid.length > 0) {
|
||||
parts.push(`避免:${hints.forbid.join("、")}`);
|
||||
}
|
||||
if (variant.stanceKeywords && variant.stanceKeywords.length > 0) {
|
||||
parts.push(`关注关键词:${variant.stanceKeywords.slice(0, 8).join("、")}`);
|
||||
}
|
||||
|
||||
return `\n${parts.join("\n")}\n`;
|
||||
}
|
||||
|
||||
// ======== 指标采集 ========
|
||||
/**
|
||||
* 把每条 assignment 的实际互动(replyCount/likeCount/humanReplies)回填,
|
||||
* 并按 (variantId, date) 写入 bot_persona_metrics 聚合
|
||||
* - 默认只看过去 7 天的 assignment(性能保护)
|
||||
*/
|
||||
export async function recordAssignmentMetrics(options = {}) {
|
||||
const prisma = getPrisma();
|
||||
const lookbackDays = options.lookbackDays ?? 7;
|
||||
const since = new Date(Date.now() - lookbackDays * 24 * 60 * 60 * 1000);
|
||||
|
||||
// 1) 拉所有变体(活跃实验)
|
||||
const variants = await prisma.botPersonaVariant.findMany({
|
||||
where: { isActive: true },
|
||||
select: { id: true, botId: true },
|
||||
});
|
||||
if (variants.length === 0) return { updated: 0 };
|
||||
|
||||
const variantIds = variants.map((v) => v.id);
|
||||
const assignments = await prisma.botPersonaAssignment.findMany({
|
||||
where: {
|
||||
variantId: { in: variantIds },
|
||||
createdAt: { gte: since },
|
||||
},
|
||||
});
|
||||
|
||||
if (assignments.length === 0) return { updated: 0 };
|
||||
|
||||
// 2) 按 refType 拆开批量查真实互动数
|
||||
const topicIds = assignments.filter((a) => a.refType === "topic").map((a) => a.refId);
|
||||
const postIds = assignments.filter((a) => a.refType === "post").map((a) => a.refId);
|
||||
|
||||
// topic: 真实 replyCount / likeCount / 真人 reply 数
|
||||
const topicStats = await prisma.forumTopic.findMany({
|
||||
where: { id: { in: topicIds.length > 0 ? topicIds : [-1] } },
|
||||
select: {
|
||||
id: true,
|
||||
replyCount: true,
|
||||
likeCount: true,
|
||||
viewCount: true,
|
||||
posts: {
|
||||
where: { user: { isBot: false } },
|
||||
select: { id: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
const topicMap = new Map(
|
||||
topicStats.map((t) => [
|
||||
t.id,
|
||||
{
|
||||
replies: t.replyCount || 0,
|
||||
likes: t.likeCount || 0,
|
||||
impressions: t.viewCount || 0,
|
||||
humanReplies: t.posts.length,
|
||||
},
|
||||
])
|
||||
);
|
||||
|
||||
// post: 真实 likeCount
|
||||
const postStats = await prisma.forumPost.findMany({
|
||||
where: { id: { in: postIds.length > 0 ? postIds : [-1] } },
|
||||
select: { id: true, likeCount: true, topic: { select: { id: true } } },
|
||||
});
|
||||
const postMap = new Map(postStats.map((p) => [p.id, p.likeCount || 0]));
|
||||
|
||||
// 对 post 型 assignment,人工统计"该 post 之后同 topic 的新回复":用 topicId+postCreatedAt 之后的人类回复数
|
||||
// 简化:取 topic 的总 replyCount / totalPosts 数 - 该 post 之前的 - 1 = 该 post 之后的新回复
|
||||
// 为简化与一致性:post 的 replies/humanReplies 用同 topic 下的总数估算,impressions=0
|
||||
// (post 的真实互动其实主要看 likeCount)
|
||||
|
||||
// 3) 写回 assignment
|
||||
let updated = 0;
|
||||
for (const a of assignments) {
|
||||
let replies = 0;
|
||||
let likes = 0;
|
||||
let impressions = 0;
|
||||
let humanReplies = 0;
|
||||
if (a.refType === "topic" && topicMap.has(a.refId)) {
|
||||
const t = topicMap.get(a.refId);
|
||||
replies = t.replies;
|
||||
likes = t.likes;
|
||||
impressions = t.impressions;
|
||||
humanReplies = t.humanReplies;
|
||||
} else if (a.refType === "post") {
|
||||
likes = postMap.get(a.refId) || 0;
|
||||
// post 的回复/曝光粗略处理:0
|
||||
replies = 0;
|
||||
humanReplies = 0;
|
||||
impressions = 0;
|
||||
}
|
||||
if (
|
||||
replies !== a.replies ||
|
||||
likes !== a.likes ||
|
||||
humanReplies !== a.humanReplies ||
|
||||
impressions !== a.impressions
|
||||
) {
|
||||
await prisma.botPersonaAssignment.update({
|
||||
where: { id: a.id },
|
||||
data: { replies, likes, humanReplies, impressions },
|
||||
});
|
||||
updated++;
|
||||
}
|
||||
}
|
||||
|
||||
// 4) 写入按 (variantId, date) 聚合的 metrics
|
||||
const dayBuckets = new Map(); // key: `${variantId}|${yyyy-mm-dd}` -> { variantId, date, contentCount, replies, likes, humanReplies, impressions }
|
||||
for (const a of assignments) {
|
||||
const dateStr = a.createdAt.toISOString().slice(0, 10);
|
||||
const key = `${a.variantId}|${dateStr}`;
|
||||
if (!dayBuckets.has(key)) {
|
||||
dayBuckets.set(key, {
|
||||
variantId: a.variantId,
|
||||
date: new Date(`${dateStr}T00:00:00.000Z`),
|
||||
contentCount: 0,
|
||||
replies: 0,
|
||||
likes: 0,
|
||||
humanReplies: 0,
|
||||
impressions: 0,
|
||||
});
|
||||
}
|
||||
const b = dayBuckets.get(key);
|
||||
b.contentCount += 1;
|
||||
b.replies += a.replies;
|
||||
b.likes += a.likes;
|
||||
b.humanReplies += a.humanReplies;
|
||||
b.impressions += a.impressions;
|
||||
}
|
||||
|
||||
for (const b of dayBuckets.values()) {
|
||||
const avgReplies = b.contentCount > 0 ? b.replies / b.contentCount : 0;
|
||||
const avgLikes = b.contentCount > 0 ? b.likes / b.contentCount : 0;
|
||||
// 综合得分:平均点赞×3 + 真人回复×5 + 平均回复×1
|
||||
const computedScore = avgLikes * 3 + b.humanReplies / Math.max(1, b.contentCount) * 5 + avgReplies * 1;
|
||||
await prisma.botPersonaMetric.upsert({
|
||||
where: { variantId_date: { variantId: b.variantId, date: b.date } },
|
||||
create: {
|
||||
variantId: b.variantId,
|
||||
date: b.date,
|
||||
impressions: b.impressions,
|
||||
replies: b.replies,
|
||||
likes: b.likes,
|
||||
humanReplies: b.humanReplies,
|
||||
contentCount: b.contentCount,
|
||||
avgReplies,
|
||||
avgLikes,
|
||||
computedScore,
|
||||
},
|
||||
update: {
|
||||
impressions: b.impressions,
|
||||
replies: b.replies,
|
||||
likes: b.likes,
|
||||
humanReplies: b.humanReplies,
|
||||
contentCount: b.contentCount,
|
||||
avgReplies,
|
||||
avgLikes,
|
||||
computedScore,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// 5) 回写变体的累计统计(最近 7 天 assignment 滚动聚合)
|
||||
const variantAgg = new Map();
|
||||
for (const a of assignments) {
|
||||
if (!variantAgg.has(a.variantId)) {
|
||||
variantAgg.set(a.variantId, { sampleCount: 0, replyCount: 0, likeCount: 0, humanReplyCount: 0 });
|
||||
}
|
||||
const v = variantAgg.get(a.variantId);
|
||||
v.sampleCount += 1;
|
||||
v.replyCount += a.replies;
|
||||
v.likeCount += a.likes;
|
||||
v.humanReplyCount += a.humanReplies;
|
||||
}
|
||||
for (const [variantId, agg] of variantAgg.entries()) {
|
||||
// 综合分:平均每篇互动 = (收赞 + 收真人回复 + 收回复) / 样本
|
||||
const score = agg.sampleCount > 0
|
||||
? (agg.likeCount * 3 + agg.humanReplyCount * 5 + agg.replyCount) / agg.sampleCount
|
||||
: 0;
|
||||
await prisma.botPersonaVariant.update({
|
||||
where: { id: variantId },
|
||||
data: {
|
||||
sampleCount: agg.sampleCount,
|
||||
replyCount: agg.replyCount,
|
||||
likeCount: agg.likeCount,
|
||||
humanReplyCount: agg.humanReplyCount,
|
||||
engagementScore: Math.round(score * 100) / 100,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return { updated, assignments: assignments.length, dayBuckets: dayBuckets.size };
|
||||
}
|
||||
|
||||
// ======== 分析 / 变体切换 ========
|
||||
/**
|
||||
* 对单个 bot 的所有变体做显著性分析
|
||||
* - 输入:变体列表(含 sampleCount/replyCount/likeCount/humanReplyCount/engagementScore)
|
||||
* - 输出:winnerVariantKey / analysis 详情
|
||||
* 策略:双比例 z 检验(高斯近似),比较每个 variant vs control 的互动率((replies+likes+humanReplies)/sampleCount)
|
||||
*/
|
||||
export function analyzeBotVariants(variants) {
|
||||
if (!variants || variants.length === 0) {
|
||||
return { winner: null, reason: "no_variants", comparisons: [] };
|
||||
}
|
||||
const control = variants.find((v) => v.isControl) || variants[0];
|
||||
const others = variants.filter((v) => v.id !== control.id);
|
||||
|
||||
if (control.sampleCount < 1) {
|
||||
return { winner: null, reason: "control_no_samples", comparisons: [] };
|
||||
}
|
||||
|
||||
const controlRate = (control.likeCount * 3 + control.humanReplyCount * 5 + control.replyCount) / control.sampleCount;
|
||||
const controlN = control.sampleCount;
|
||||
|
||||
const comparisons = others.map((v) => {
|
||||
if (v.sampleCount < 1) {
|
||||
return { variant: v, rate: 0, pValue: null, significant: false, winner: false };
|
||||
}
|
||||
const rate = (v.likeCount * 3 + v.humanReplyCount * 5 + v.replyCount) / v.sampleCount;
|
||||
const n = v.sampleCount;
|
||||
// pooled proportion
|
||||
const pooled = (control.likeCount * 3 + control.humanReplyCount * 5 + control.replyCount + v.likeCount * 3 + v.humanReplyCount * 5 + v.replyCount) / (controlN + n);
|
||||
const se = Math.sqrt(pooled * (1 - pooled) * (1 / controlN + 1 / n));
|
||||
let z = 0;
|
||||
let p = 1;
|
||||
if (se > 0) {
|
||||
z = (rate - controlRate) / se;
|
||||
// 双侧检验
|
||||
p = 2 * (1 - normalCdf(Math.abs(z)));
|
||||
}
|
||||
return {
|
||||
variant: v,
|
||||
rate: Math.round(rate * 1000) / 1000,
|
||||
controlRate: Math.round(controlRate * 1000) / 1000,
|
||||
zScore: Math.round(z * 100) / 100,
|
||||
pValue: Math.round(p * 1000) / 1000,
|
||||
significant: p < 0.1 && n >= 20, // significanceLevel=0.1, minSamples=20
|
||||
winner: false,
|
||||
};
|
||||
});
|
||||
|
||||
// 选 winner:所有"显著更优"的里面 p 最小 + sample 最多的
|
||||
const winners = comparisons.filter((c) => c.significant && c.rate > c.controlRate);
|
||||
let winner = null;
|
||||
if (winners.length > 0) {
|
||||
winners.sort((a, b) => a.pValue - b.pValue);
|
||||
winners[0].winner = true;
|
||||
winner = winners[0].variant;
|
||||
}
|
||||
|
||||
return {
|
||||
winner,
|
||||
winnerReason: winner
|
||||
? `variant ${winner.variantKey} 综合互动率显著高于 control (p=${winners[0].pValue})`
|
||||
: "no significant winner yet",
|
||||
control,
|
||||
comparisons,
|
||||
};
|
||||
}
|
||||
|
||||
// 标准正态分布 CDF(Abramowitz & Stegun 近似)
|
||||
function normalCdf(x) {
|
||||
const t = 1 / (1 + 0.2316419 * x);
|
||||
const d = 0.3989422804014327 * Math.exp(-x * x / 2);
|
||||
let p = d * t * (0.319381530 + t * (-0.356563782 + t * (1.781477937 + t * (-1.821255978 + t * 1.330274429))));
|
||||
return 1 - p;
|
||||
}
|
||||
|
||||
/**
|
||||
* 完整跑一遍分析 + 切换
|
||||
* - 遍历所有 bot
|
||||
* - 对每个 bot 调 analyzeBotVariants
|
||||
* - 若有 winner 显著更优,把实验标记为 completed
|
||||
* - 后续 bot 在选变体时仍会按 weight 选(不会自动锁死一个变体),但 recordAssignmentMetrics 会持续跑
|
||||
* - 还可以加:winner 出现后,把 winner 权重提到 1.0,其他降到 0
|
||||
*/
|
||||
export async function analyzeAndSwitchAllBots(options = {}) {
|
||||
const prisma = getPrisma();
|
||||
const onlyActive = options.onlyActive ?? true;
|
||||
|
||||
// 取所有有变体的 bot
|
||||
const variants = await prisma.botPersonaVariant.findMany({
|
||||
where: onlyActive ? { isActive: true } : undefined,
|
||||
include: { experiment: true },
|
||||
});
|
||||
const byBot = new Map();
|
||||
for (const v of variants) {
|
||||
if (!byBot.has(v.botId)) byBot.set(v.botId, []);
|
||||
byBot.get(v.botId).push(v);
|
||||
}
|
||||
|
||||
const results = [];
|
||||
for (const [botId, list] of byBot.entries()) {
|
||||
const analysis = analyzeBotVariants(list);
|
||||
if (analysis.winner) {
|
||||
const exp = list.find((v) => v.experimentId)?.experiment;
|
||||
if (exp && exp.status === "active") {
|
||||
// 标记实验完成
|
||||
await prisma.botPersonaExperiment.update({
|
||||
where: { id: exp.id },
|
||||
data: {
|
||||
status: "completed",
|
||||
winnerVariantKey: analysis.winner.variantKey,
|
||||
endedAt: new Date(),
|
||||
lastAnalyzedAt: new Date(),
|
||||
},
|
||||
});
|
||||
// 提升 winner 权重到 0.85,其他活跃变体降到 0.075
|
||||
await prisma.botPersonaVariant.update({
|
||||
where: { id: analysis.winner.id },
|
||||
data: { weight: 0.85 },
|
||||
});
|
||||
await prisma.botPersonaVariant.updateMany({
|
||||
where: {
|
||||
botId,
|
||||
id: { not: analysis.winner.id },
|
||||
experimentId: exp.id,
|
||||
},
|
||||
data: { weight: 0.075 },
|
||||
});
|
||||
results.push({
|
||||
botId,
|
||||
experimentId: exp.id,
|
||||
winner: analysis.winner.variantKey,
|
||||
reason: analysis.winnerReason,
|
||||
action: "switched_weights",
|
||||
});
|
||||
} else {
|
||||
results.push({
|
||||
botId,
|
||||
winner: analysis.winner.variantKey,
|
||||
reason: analysis.winnerReason,
|
||||
action: "no_active_experiment",
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// 没有 winner,刷新 lastAnalyzedAt
|
||||
const exp = list.find((v) => v.experimentId)?.experiment;
|
||||
if (exp && exp.status === "active") {
|
||||
await prisma.botPersonaExperiment.update({
|
||||
where: { id: exp.id },
|
||||
data: { lastAnalyzedAt: new Date() },
|
||||
});
|
||||
}
|
||||
results.push({
|
||||
botId,
|
||||
winner: null,
|
||||
reason: analysis.winnerReason,
|
||||
action: "monitoring",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { botCount: byBot.size, results };
|
||||
}
|
||||
|
||||
/**
|
||||
* 优雅关闭
|
||||
*/
|
||||
export async function disconnectBotExperiment() {
|
||||
if (_prisma) {
|
||||
await _prisma.$disconnect();
|
||||
_prisma = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
// Bot 画像 (BotPersona) 共享模块
|
||||
// 从 bot-affinity-update.mjs 抽出,bot-activity.mjs 也能复用
|
||||
// 用法:
|
||||
// import { updatePersona, schedulePersonaRefresh } from "./lib/bot-persona.mjs";
|
||||
// await updatePersona(botUser, botConfig.id);
|
||||
// schedulePersonaRefresh(botUserId, botConfig.id); // 异步、防抖
|
||||
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { PrismaMariaDb } from "@prisma/adapter-mariadb";
|
||||
import "dotenv/config";
|
||||
|
||||
const base = (process.env.DATABASE_URL || "").replace("mysql://", "mariadb://");
|
||||
const sep = base.includes("?") ? "&" : "?";
|
||||
const connectionString = `${base}${sep}connection_limit=2&pool_timeout=10`;
|
||||
|
||||
// 共享一个 Prisma 客户端(连接池小,只为后台异步刷新服务)
|
||||
let _prisma = null;
|
||||
function getPrisma() {
|
||||
if (!_prisma) {
|
||||
const adapter = new PrismaMariaDb(connectionString);
|
||||
_prisma = new PrismaClient({ adapter });
|
||||
}
|
||||
return _prisma;
|
||||
}
|
||||
|
||||
const PERSONA_LOOKBACK_DAYS = 30;
|
||||
const TOP_KEYWORDS = 12;
|
||||
const TOP_HUMAN_USERS = 5;
|
||||
const TOP_TOPIC_TYPES = 5;
|
||||
const LAST_TITLES_COUNT = 5;
|
||||
|
||||
const STOP_WORDS = new Set([
|
||||
"的", "了", "是", "在", "和", "与", "或", "也", "都", "就", "不", "没", "要", "我", "你", "他", "她", "它", "们",
|
||||
"这", "那", "有", "为", "到", "对", "及", "等", "把", "被", "从", "向", "以", "其", "之", "于", "上", "下",
|
||||
"里", "外", "中", "一个", "一些", "我们", "你们", "他们", "什么", "怎么", "为什么", "啊", "吗", "呢", "吧",
|
||||
"嗯", "哦", "哈", "啦", "嘿", "唉", "这个", "那个", "一个", "真的", "应该", "可能", "觉得", "认为",
|
||||
]);
|
||||
|
||||
function tokenize(text) {
|
||||
if (!text) return [];
|
||||
return text
|
||||
.replace(/[,。!?、;:""''【】《》()()\[\]·—\.\,\!\?\;\:\'\"\\\/]/g, " ")
|
||||
.split(/\s+/)
|
||||
.filter((w) => w.length >= 2 && !STOP_WORDS.has(w));
|
||||
}
|
||||
|
||||
function pickTopWithCount(arr, topN) {
|
||||
const counts = new Map();
|
||||
for (const item of arr) {
|
||||
if (!item) continue;
|
||||
counts.set(item, (counts.get(item) || 0) + 1);
|
||||
}
|
||||
return Array.from(counts.entries())
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, topN)
|
||||
.map(([k, v]) => ({ key: k, count: v }));
|
||||
}
|
||||
|
||||
function ts() {
|
||||
return `[${new Date().toISOString()}]`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 拉取过去 N 天该 bot 的所有发帖/回复,统计画像数据
|
||||
* @returns persona object (or empty persona when no posts)
|
||||
*/
|
||||
export async function computePersona(botUser, botConfigId) {
|
||||
const prisma = getPrisma();
|
||||
const since = new Date(Date.now() - PERSONA_LOOKBACK_DAYS * 24 * 60 * 60 * 1000);
|
||||
const topics = await prisma.forumTopic.findMany({
|
||||
where: { userId: botUser.id, createdAt: { gt: since } },
|
||||
include: {
|
||||
category: { select: { name: true, slug: true } },
|
||||
posts: {
|
||||
include: { user: { select: { id: true, name: true, isBot: true } } },
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
|
||||
if (topics.length === 0) {
|
||||
return {
|
||||
avgReplyLength: 0,
|
||||
questionRatio: 0,
|
||||
exclamationCount: 0,
|
||||
avgTopicLength: 0,
|
||||
stanceKeywords: [],
|
||||
topHumanUsers: [],
|
||||
topTopicTypes: [],
|
||||
lastTopicTitles: [],
|
||||
postSampleCount: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const allPosts = [];
|
||||
for (const t of topics) {
|
||||
allPosts.push({
|
||||
content: t.content,
|
||||
isTopic: true,
|
||||
createdAt: t.createdAt,
|
||||
forumSlug: t.category.slug,
|
||||
authorId: t.userId,
|
||||
});
|
||||
for (const p of t.posts) {
|
||||
allPosts.push({
|
||||
content: p.content,
|
||||
isTopic: false,
|
||||
createdAt: p.createdAt,
|
||||
forumSlug: t.category.slug,
|
||||
authorId: p.userId,
|
||||
humanAuthor: !p.user.isBot ? p.user : null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const myPosts = allPosts.filter((p) => p.authorId === botUser.id);
|
||||
const replyLengths = myPosts.filter((p) => !p.isTopic).map((p) => p.content.length);
|
||||
const topicLengths = myPosts.filter((p) => p.isTopic).map((p) => p.content.length);
|
||||
|
||||
const fullText = myPosts.map((p) => p.content).join(" ");
|
||||
const questionMarks = (fullText.match(/[??]/g) || []).length;
|
||||
const exclamationMarks = (fullText.match(/[!!]/g) || []).length;
|
||||
const questionRatio = myPosts.length > 0 ? questionMarks / myPosts.length : 0;
|
||||
|
||||
const keywords = tokenize(fullText);
|
||||
const stanceKeywords = pickTopWithCount(keywords, TOP_KEYWORDS);
|
||||
|
||||
const humanRepliers = allPosts
|
||||
.filter((p) => p.humanAuthor)
|
||||
.map((p) => ({ id: p.humanAuthor.id, name: p.humanAuthor.name || "匿名" }));
|
||||
const userCount = new Map();
|
||||
for (const h of humanRepliers) {
|
||||
const k = `${h.id}|${h.name}`;
|
||||
userCount.set(k, (userCount.get(k) || 0) + 1);
|
||||
}
|
||||
const topHumanUsers = Array.from(userCount.entries())
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, TOP_HUMAN_USERS)
|
||||
.map(([k, c]) => {
|
||||
const [id, name] = k.split("|");
|
||||
return { id: Number(id), name, count: c };
|
||||
});
|
||||
|
||||
const forumCount = new Map();
|
||||
for (const t of topics) {
|
||||
const k = `${t.category.slug}|${t.category.name}`;
|
||||
forumCount.set(k, (forumCount.get(k) || 0) + 1);
|
||||
}
|
||||
const topTopicTypes = Array.from(forumCount.entries())
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, TOP_TOPIC_TYPES)
|
||||
.map(([k, c]) => {
|
||||
const [slug, name] = k.split("|");
|
||||
return { slug, name, count: c };
|
||||
});
|
||||
|
||||
const lastTopicTitles = topics
|
||||
.slice(0, LAST_TITLES_COUNT)
|
||||
.map((t) => t.title);
|
||||
|
||||
return {
|
||||
avgReplyLength: replyLengths.length
|
||||
? Math.round(replyLengths.reduce((s, n) => s + n, 0) / replyLengths.length)
|
||||
: 0,
|
||||
questionRatio: Math.round(questionRatio * 100) / 100,
|
||||
exclamationCount: exclamationMarks,
|
||||
avgTopicLength: topicLengths.length
|
||||
? Math.round(topicLengths.reduce((s, n) => s + n, 0) / topicLengths.length)
|
||||
: 0,
|
||||
stanceKeywords,
|
||||
topHumanUsers,
|
||||
topTopicTypes,
|
||||
lastTopicTitles,
|
||||
postSampleCount: myPosts.length,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算并写回 BotPersona(同步版本,返回是否成功)
|
||||
* @param botUser - bot User 实体(含 id)
|
||||
* @param botConfigId - BotConfig.id
|
||||
* @returns { success: boolean, postSampleCount: number }
|
||||
*/
|
||||
export async function updatePersona(botUser, botConfigId) {
|
||||
const prisma = getPrisma();
|
||||
const persona = await computePersona(botUser, botConfigId);
|
||||
if (persona.postSampleCount === 0) {
|
||||
return { success: false, postSampleCount: 0 };
|
||||
}
|
||||
await prisma.botPersona.upsert({
|
||||
where: { botId: botConfigId },
|
||||
create: { botId: botConfigId, ...persona },
|
||||
update: persona,
|
||||
});
|
||||
return { success: true, postSampleCount: persona.postSampleCount };
|
||||
}
|
||||
|
||||
// ============ 异步防抖调度 ============
|
||||
// 每次发帖/回复后调用 schedulePersonaRefresh(botUserId, botConfigId):
|
||||
// - 同一 bot 在 COOLDOWN_MS 内多次触发,只跑一次(防抖)
|
||||
// - 失败时静默记录,不影响主流程
|
||||
const COOLDOWN_MS = 5 * 60 * 1000; // 5 分钟防抖
|
||||
const lastRunMap = new Map(); // botConfigId -> timestamp(ms)
|
||||
const inFlightSet = new Set(); // 正在跑 refresh 的 botConfigId
|
||||
|
||||
/**
|
||||
* 异步 + 防抖触发 persona 刷新
|
||||
* - 5 分钟内同一 bot 只跑一次
|
||||
* - 不阻塞调用方,立即返回
|
||||
* - 失败时 console.warn,不抛出
|
||||
* @returns true 表示本次触发了真实刷新,false 表示被防抖跳过
|
||||
*/
|
||||
export function schedulePersonaRefresh(botUserId, botConfigId, options = {}) {
|
||||
const cooldown = options.cooldownMs ?? COOLDOWN_MS;
|
||||
const now = Date.now();
|
||||
const last = lastRunMap.get(botConfigId) || 0;
|
||||
if (now - last < cooldown) {
|
||||
return false;
|
||||
}
|
||||
if (inFlightSet.has(botConfigId)) {
|
||||
return false;
|
||||
}
|
||||
inFlightSet.add(botConfigId);
|
||||
lastRunMap.set(botConfigId, now);
|
||||
|
||||
// 推迟到下一个 tick,避免阻塞发帖主流程
|
||||
setImmediate(async () => {
|
||||
try {
|
||||
const prisma = getPrisma();
|
||||
const botUser = await prisma.user.findUnique({ where: { id: botUserId } });
|
||||
if (!botUser) return;
|
||||
const result = await updatePersona(botUser, botConfigId);
|
||||
if (result.success) {
|
||||
console.log(
|
||||
`${ts()} 🪞 persona 异步刷新: bot#${botConfigId} (sample=${result.postSampleCount})`
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
`${ts()} ⚠️ persona 异步刷新失败 bot#${botConfigId}:`,
|
||||
err.message
|
||||
);
|
||||
} finally {
|
||||
inFlightSet.delete(botConfigId);
|
||||
}
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 强制立即刷新(绕开防抖),用于定时任务兜底
|
||||
*/
|
||||
export async function forcePersonaRefresh(botUserId, botConfigId) {
|
||||
const prisma = getPrisma();
|
||||
const botUser = await prisma.user.findUnique({ where: { id: botUserId } });
|
||||
if (!botUser) return { success: false, postSampleCount: 0 };
|
||||
return updatePersona(botUser, botConfigId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空防抖状态(测试 / 主流程退出时用)
|
||||
*/
|
||||
export function resetPersonaDebounce() {
|
||||
lastRunMap.clear();
|
||||
inFlightSet.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* 优雅关闭 prisma 客户端(主进程退出前调用)
|
||||
*/
|
||||
export async function disconnectBotPersona() {
|
||||
if (_prisma) {
|
||||
await _prisma.$disconnect();
|
||||
_prisma = null;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user