Files
zhuiguang-ai/scripts/lib/bot-persona.mjs
T

277 lines
8.8 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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;
}
}