Files
zhuiguang-ai/scripts/bot-affinity-update.mjs
T

230 lines
6.9 KiB
JavaScript

import { PrismaClient } from "@prisma/client";
import { PrismaMariaDb } from "@prisma/adapter-mariadb";
import { readFileSync } from "fs";
import { resolve, dirname } from "path";
import { fileURLToPath } from "url";
import "dotenv/config";
import { updatePersona as sharedUpdatePersona, computePersona } from "./lib/bot-persona.mjs";
const __dirname = dirname(fileURLToPath(import.meta.url));
const base = process.env.DATABASE_URL.replace("mysql://", "mariadb://");
const sep = base.includes("?") ? "&" : "?";
const connectionString = `${base}${sep}connection_limit=5&pool_timeout=30`;
const adapter = new PrismaMariaDb(connectionString);
const prisma = new PrismaClient({ adapter });
const BOT_DATA_PATH = resolve(__dirname, "..", "data", "bot-characters.json");
const LOOKBACK_DAYS = 14;
const MIN_AFFINITY_SAMPLES = 1;
function ts() {
return `[${new Date().toISOString()}]`;
}
async function loadBotCharacters() {
const raw = readFileSync(BOT_DATA_PATH, "utf-8");
const { characters } = JSON.parse(raw);
const map = {};
for (const c of characters) map[c.key] = c;
return { characters, map };
}
async function computeAffinity(botUser, botConfigId) {
const since = new Date(Date.now() - LOOKBACK_DAYS * 24 * 60 * 60 * 1000);
const myTopics = await prisma.forumTopic.findMany({
where: { userId: botUser.id, createdAt: { gt: since } },
select: { id: true, category: { select: { slug: true } } },
});
if (myTopics.length === 0) return { homeForums: new Map(), crossCount: 0 };
const topicIds = myTopics.map((t) => t.id);
const myForumSlugs = new Set(myTopics.map((t) => t.category.slug));
const humanRepliers = await prisma.forumPost.findMany({
where: {
topicId: { in: topicIds },
user: { isBot: false },
},
include: {
user: {
select: {
id: true,
name: true,
isBot: true,
forumTopics: {
where: { createdAt: { gt: since } },
select: { category: { select: { slug: true } } },
take: 10,
},
},
},
topic: { select: { category: { select: { slug: true, name: true } } } },
},
});
const homeForumCount = new Map();
for (const p of humanRepliers) {
const homeForums = p.user.forumTopics.map((t) => t.category.slug);
if (homeForums.length === 0) continue;
const counts = new Map();
for (const f of homeForums) counts.set(f, (counts.get(f) || 0) + 1);
const sorted = Array.from(counts.entries()).sort((a, b) => b[1] - a[1]);
const homeForum = sorted[0][0];
if (myForumSlugs.has(homeForum)) continue;
homeForumCount.set(homeForum, (homeForumCount.get(homeForum) || 0) + 1);
}
return { homeForums: homeForumCount, crossCount: humanRepliers.length };
}
async function updatePersona(botChar) {
const botUser = await prisma.user.findFirst({
where: { email: `bot_${botChar.key}@zhuiguang.ai` },
});
if (!botUser) return false;
const botConfig = await prisma.botConfig.findUnique({
where: { userId: botUser.id },
});
if (!botConfig) return false;
// 调用 lib/bot-persona.mjs 的共享实现(同步)
const result = await sharedUpdatePersona(botUser, botConfig.id);
if (!result.success) return false;
// 再读一份用于日志输出
const persona = await computePersona(botUser, botConfig.id);
console.log(
`${ts()} 📐 ${botChar.displayName} 画像: 话题${persona.postSampleCount}条 / 平均话题${persona.avgTopicLength}字 / 平均回复${persona.avgReplyLength}字 / 问号率${persona.questionRatio}`
);
return true;
}
async function updateAffinity(botChar) {
const botUser = await prisma.user.findFirst({
where: { email: `bot_${botChar.key}@zhuiguang.ai` },
});
if (!botUser) return;
const botConfig = await prisma.botConfig.findUnique({
where: { userId: botUser.id },
});
if (!botConfig) return;
const { homeForums, crossCount } = await computeAffinity(botUser, botConfig.id);
const primaryForumSlugs = new Set(
(botChar.personality?.primaryForums || []).map((s) => s)
);
await prisma.botCrossForumAffinity.deleteMany({
where: {
botId: botConfig.id,
forumSlug: { in: Array.from(primaryForumSlugs) },
},
});
if (homeForums.size === 0) {
return;
}
const totalCrossSamples = Array.from(homeForums.values()).reduce((s, n) => s + n, 0);
for (const [slug, count] of homeForums.entries()) {
if (count < MIN_AFFINITY_SAMPLES) continue;
const affinity = Math.min(1, count / Math.max(crossCount / 4, 5));
const reason = `${count}/${totalCrossSamples} 跨板块互动`;
await prisma.botCrossForumAffinity.upsert({
where: { botId_forumSlug: { botId: botConfig.id, forumSlug: slug } },
create: {
botId: botConfig.id,
forumSlug: slug,
affinity,
sampleCount: count,
reason,
},
update: {
affinity,
sampleCount: count,
reason,
},
});
}
if (homeForums.size > 0) {
const top = Array.from(homeForums.entries())
.sort((a, b) => b[1] - a[1])
.slice(0, 3)
.map(([s, c]) => `${s}(${c})`)
.join(", ");
console.log(`${ts()} 🌍 ${botChar.displayName} 跨板块亲和度: ${top}`);
}
}
async function main() {
console.log(`${ts()} Bot Affinity & Persona Update starting...`);
if (!process.env.DATABASE_URL) {
throw new Error("DATABASE_URL 未配置");
}
const { characters } = await loadBotCharacters();
const botChars = characters.filter((c) => !c.role || c.role !== "passerby");
console.log(`${ts()} Loaded ${botChars.length} expert bots.`);
let personaCount = 0;
let affinityCount = 0;
for (const botChar of botChars) {
try {
if (await updatePersona(botChar)) personaCount++;
await updateAffinity(botChar);
affinityCount++;
} catch (err) {
console.error(`${ts()} Error processing ${botChar.key}:`, err.message);
}
}
await prisma.taskLog.create({
data: {
taskKey: "bot-affinity-update",
taskName: "Bot亲和度与画像",
status: "success",
startedAt: new Date(),
finishedAt: new Date(),
triggerBy: "cron",
result: { personaCount, affinityCount, totalBots: botChars.length },
},
});
console.log(
`${ts()} Bot Affinity & Persona Update done. persona=${personaCount} affinity=${affinityCount}`
);
}
main()
.catch(async (e) => {
console.error(`${ts()} Fatal error:`, e);
try {
await prisma.taskLog.create({
data: {
taskKey: "bot-affinity-update",
taskName: "Bot亲和度与画像",
status: "failed",
startedAt: new Date(),
finishedAt: new Date(),
triggerBy: "cron",
error: (e && e.message ? e.message : String(e)).slice(0, 1000),
},
});
} catch {}
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
const { disconnectBotPersona } = await import("./lib/bot-persona.mjs");
await disconnectBotPersona();
});