Files
zhuiguang-ai/scripts/bot-weekly-review.mjs
T

312 lines
9.3 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.
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 OpenAI from "openai";
import "dotenv/config";
import { withRetry } from "./lib/retry.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 openai = new OpenAI({
apiKey: process.env.DEEPSEEK_API_KEY,
baseURL: "https://api.deepseek.com/v1",
});
const MODEL = process.env.DEEPSEEK_MODEL || "deepseek-chat";
const BOT_DATA_PATH = resolve(__dirname, "..", "data", "bot-characters.json");
const ENGAGEMENT_WEIGHTS = {
REPLY_PER_TOPIC: 0.4,
HUMAN_RATIO: 0.3,
LIKES_LOG: 0.3,
};
const TIER_THRESHOLDS = {
high: 1.0,
medium: 0.5,
};
function ts() {
return `[${new Date().toISOString()}]`;
}
function startOfToday() {
// 用 UTC 午夜避开 Prisma @db.Date 写入时的时区截断
const d = new Date();
return new Date(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate()));
}
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 };
}
function calcEngagementScore(stats) {
const { topicsCreated, repliesReceived, humanInteractions, likesReceived, repliesSent } = stats;
const replyPerTopic = topicsCreated > 0 ? repliesReceived / topicsCreated : 0;
const humanRatio =
repliesSent + topicsCreated > 0 ? humanInteractions / (repliesSent + topicsCreated) : 0;
const likesLog = Math.log(1 + likesReceived);
return (
ENGAGEMENT_WEIGHTS.REPLY_PER_TOPIC * Math.min(replyPerTopic, 5) +
ENGAGEMENT_WEIGHTS.HUMAN_RATIO * Math.min(humanRatio, 1) * 5 +
ENGAGEMENT_WEIGHTS.LIKES_LOG * Math.min(likesLog, 5)
);
}
function decideTier(score, baseTier) {
const baseRank = { low: 1, medium: 2, high: 3 }[baseTier] || 2;
if (score >= TIER_THRESHOLDS.high) return "high";
if (score >= TIER_THRESHOLDS.medium) return baseRank >= 2 ? "medium" : "high";
if (score >= TIER_THRESHOLDS.medium * 0.6) return baseRank >= 2 ? "low" : "medium";
return "low";
}
function buildReflectionPrompt(botChar, stats, score, nextTier) {
const p = botChar.personality;
return `你是【${botChar.displayName}】。本周你的论坛表现总结:
[本周数据]
- 发帖 ${stats.topicsCreated} 个
- 回复 ${stats.repliesSent} 条
- 收到回复 ${stats.repliesReceived} 条
- 真人互动 ${stats.humanInteractions} 次
- 收到点赞 ${stats.likesReceived} 个
- 互动得分 ${score.toFixed(2)}
[下周配额] ${nextTier}
[你的角色]
${p.identity}
风格:${p.speakingStyle}
[任务]
请以第一人称写一段50-120字的本周复盘:
1. 数据怎么解读(不要数字罗列,要有你的判断)
2. 哪类话题效果好、哪类效果差
3. 下周你最想尝试的一个调整是什么
要求:保持你【${botChar.displayName}】的说话风格,简短口语化。
只输出JSON:{"reflection": "你的复盘内容"}`;
}
async function callDeepSeek(prompt) {
const response = await withRetry(() =>
openai.chat.completions.create({
model: MODEL,
messages: [{ role: "user", content: prompt }],
temperature: 0.85,
max_tokens: 400,
})
);
const text = response.choices[0]?.message?.content?.trim() || "";
const jsonMatch = text.match(/\{[\s\S]*\}/);
if (!jsonMatch) throw new Error("无法解析JSON");
return JSON.parse(jsonMatch[0]);
}
async function getWeekRange() {
const today = startOfToday();
const dayOfWeek = today.getUTCDay();
// weekStart = today - 6 天(统一用 UTC 午夜)
const weekStart = new Date(today);
weekStart.setUTCDate(weekStart.getUTCDate() - 6);
const weekEnd = today;
return { weekStart, weekEnd };
}
async function reviewBot(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 { weekStart, weekEnd } = await getWeekRange();
// weekStart / weekEnd 已经是 UTC 午夜了,无需再 setHours
const weekStartDate = new Date(weekStart);
const weekEndDate = new Date(weekEnd);
const stats = await prisma.botDailyStat.aggregate({
where: {
botId: botConfig.id,
date: { gte: weekStartDate, lte: weekEndDate },
},
_sum: {
topicCreated: true,
replySent: true,
followUpSent: true,
repliesReceived: true,
likesReceived: true,
humanInteractions: true,
feedbackProcessed: true,
},
});
const sum = stats._sum;
const flatStats = {
topicsCreated: sum.topicCreated || 0,
repliesSent: sum.replySent || 0,
repliesReceived: sum.repliesReceived || 0,
humanInteractions: sum.humanInteractions || 0,
likesReceived: sum.likesReceived || 0,
};
if (flatStats.topicsCreated === 0 && flatStats.repliesSent === 0) {
console.log(`${ts()} ${botChar.displayName}: 本周无活动,跳过`);
return;
}
const score = calcEngagementScore(flatStats);
const baseTier = botChar.activityLevel || "medium";
const nextTier = decideTier(score, baseTier);
let reflection = null;
try {
const prompt = buildReflectionPrompt(botChar, flatStats, score, nextTier);
const result = await callDeepSeek(prompt);
reflection = (result.reflection || "").trim().slice(0, 500) || null;
} catch (err) {
console.warn(`${ts()} ${botChar.displayName} 反思生成失败:`, err.message);
}
const review = await prisma.botWeeklyReview.upsert({
where: { botId_weekStart: { botId: botConfig.id, weekStart: weekStartDate } },
create: {
botId: botConfig.id,
weekStart: weekStartDate,
weekEnd: weekEndDate,
topicsCreated: flatStats.topicsCreated,
repliesSent: flatStats.repliesSent,
repliesReceived: flatStats.repliesReceived,
humanRatio:
flatStats.repliesSent + flatStats.topicsCreated > 0
? flatStats.humanInteractions / (flatStats.repliesSent + flatStats.topicsCreated)
: 0,
likesReceived: flatStats.likesReceived,
engagementScore: score,
nextWeekQuota: nextTier,
reflection,
},
update: {
weekEnd: weekEndDate,
topicsCreated: flatStats.topicsCreated,
repliesSent: flatStats.repliesSent,
repliesReceived: flatStats.repliesReceived,
humanRatio:
flatStats.repliesSent + flatStats.topicsCreated > 0
? flatStats.humanInteractions / (flatStats.repliesSent + flatStats.topicsCreated)
: 0,
likesReceived: flatStats.likesReceived,
engagementScore: score,
nextWeekQuota: nextTier,
reflection,
},
});
await prisma.botMemory.create({
data: {
botId: botConfig.id,
memoryType: "reflection",
layer: "WORKING",
content: {
action: "weekly_review",
score,
topicsCreated: flatStats.topicsCreated,
repliesReceived: flatStats.repliesReceived,
humanInteractions: flatStats.humanInteractions,
likesReceived: flatStats.likesReceived,
nextTier,
reflection,
},
importance: 0.7,
contextTags: {
reviewId: review.id,
weekStart: weekStartDate.toISOString().slice(0, 10),
source: "weekly_review",
},
},
});
console.log(
`${ts()} ${botChar.displayName}: 得分 ${score.toFixed(2)} | ${baseTier} → ${nextTier} | 帖${flatStats.topicsCreated}/收${flatStats.repliesReceived}/真人${flatStats.humanInteractions}`
);
}
async function main() {
console.log(`${ts()} Bot Weekly Review starting...`);
if (!process.env.DEEPSEEK_API_KEY) {
throw new Error("DEEPSEEK_API_KEY 未配置");
}
const { characters } = await loadBotCharacters();
const botChars = characters.filter((c) => !c.role || c.role !== "passerby");
console.log(`${ts()} Loaded ${botChars.length} expert bots.`);
let processed = 0;
for (const botChar of botChars) {
try {
await reviewBot(botChar);
processed++;
} catch (err) {
console.error(`${ts()} Error processing ${botChar.key}:`, err.message);
}
}
await prisma.taskLog.create({
data: {
taskKey: "bot-weekly-review",
taskName: "Bot周度复盘",
status: "success",
startedAt: new Date(),
finishedAt: new Date(),
triggerBy: "cron",
result: { processedBots: processed },
},
});
console.log(`${ts()} Bot Weekly Review done. Processed ${processed} bots.`);
}
main()
.catch(async (e) => {
console.error(`${ts()} Fatal error:`, e);
try {
await prisma.taskLog.create({
data: {
taskKey: "bot-weekly-review",
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();
});