313 lines
9.4 KiB
JavaScript
313 lines
9.4 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 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",
|
||
});
|
||
const MODEL = process.env.DEEPSEEK_MODEL || "deepseek-v4-pro";
|
||
|
||
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,
|
||
// DeepSeek V4 推理 token 计入 max_tokens,预算不足会导致正文截断/为空
|
||
max_tokens: 2048,
|
||
})
|
||
);
|
||
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();
|
||
});
|