Files
zhuiguang-ai/scripts/bot-feedback-loop.mjs
T

422 lines
12 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 BOT_DATA_PATH = resolve(__dirname, "..", "data", "bot-characters.json");
const MODEL = process.env.DEEPSEEK_MODEL || "deepseek-chat";
const LAYER = {
SESSION: "SESSION",
WORKING: "WORKING",
LONGTERM: "LONGTERM",
};
const MEMORY_TYPE = {
EXPERIENCE: "experience",
INTERACTION: "interaction",
FEEDBACK: "feedback",
SKILL_USAGE: "skill_usage",
REFLECTION: "reflection",
USER_MODEL: "user_model",
};
const QUOTA = {
high: { followUpPerDay: 6 },
medium: { followUpPerDay: 3 },
low: { followUpPerDay: 1 },
};
const FEEDBACK_LOOKBACK_HOURS = 24;
const FOLLOW_UP_PROBABILITY = 0.6;
const MIN_FEEDBACK_TO_FOLLOWUP = 1;
const MAX_FOLLOWUP_PER_RUN_PER_BOT = 3;
function ts() {
return `[${new Date().toISOString()}]`;
}
function pick(arr) {
return arr[Math.floor(Math.random() * arr.length)];
}
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 };
}
async function getOrCreateTodayStat(botUserId) {
const today = startOfToday();
return prisma.botDailyStat.upsert({
where: { botId_date: { botId: botUserId, date: today } },
create: { botId: botUserId, date: today },
update: {},
});
}
async function incrementStat(botUserId, field, by = 1) {
const today = startOfToday();
await prisma.botDailyStat.upsert({
where: { botId_date: { botId: botUserId, date: today } },
create: { botId: botUserId, date: today, [field]: by },
update: { [field]: { increment: by } },
});
}
async function getProcessedReplyIds(botUserId) {
const memories = await prisma.botMemory.findMany({
where: {
botId: botUserId,
memoryType: MEMORY_TYPE.FEEDBACK,
},
select: { contextTags: true },
});
const ids = new Set();
for (const m of memories) {
const tags = m.contextTags;
if (tags && Array.isArray(tags.replyIds)) {
for (const id of tags.replyIds) ids.add(id);
}
}
return ids;
}
function buildFollowUpPrompt(bot, topicTitle, topicContent, originalPost, allReplies) {
const p = bot.personality;
const repliesText = allReplies
.map((r, i) => `${i + 1}. ${r.author}: ${r.content.slice(0, 250)}`)
.join("\n");
return `你正在参与"追光AI行业论坛"。你之前在"${topicTitle}"这个话题下发了主帖,现在有用户回复了你,你需要用你的人格接着聊。
[你的角色设定]
你是【${bot.displayName}】,${p.identity}
你擅长的领域:${p.expertise.join("、")}
你说话的风格:${p.speakingStyle}
你的口头禅:${p.catchphrase}
你的核心立场:${p.stance}
[你的原始主帖]
${topicContent}
[新收到的回复]
${repliesText}
[二次回复要求]
1. 像真人接话,120-350字,不能太长刷屏
2. 必须对前面至少一个具体观点有回应(赞同/质疑/补充案例/反问)
3. 保持你的人设语气和立场
4. 不要重复你自己主帖的内容
5. 留一点钩子让讨论继续
[禁止事项]
- 禁止使用"首先/其次/最后""从以下几个方面""综上所述"
- 禁止表现得像AI助手
- 禁止使用${(p.forbiddenPatterns || []).join("、")}
只输出JSON:{"content": "你的回复内容"}`;
}
async function callDeepSeek(prompt) {
const response = await withRetry(() =>
openai.chat.completions.create({
model: MODEL,
messages: [{ role: "user", content: prompt }],
temperature: 0.88,
max_tokens: 800,
})
);
const text = response.choices[0].message.content.trim();
const jsonMatch = text.match(/\{[\s\S]*\}/);
if (!jsonMatch) {
throw new Error(`无法解析JSON: ${text.slice(0, 200)}`);
}
return JSON.parse(jsonMatch[0]);
}
async function saveMemory(botUserId, memoryType, content, importance, contextTags, ttlDays = null) {
const data = {
botId: botUserId,
memoryType,
layer: importance >= 0.7 ? LAYER.WORKING : LAYER.LONGTERM,
content,
importance,
};
if (contextTags) data.contextTags = contextTags;
if (ttlDays) {
const ttl = new Date();
ttl.setDate(ttl.getDate() + ttlDays);
data.ttl = ttl;
}
await prisma.botMemory.create({ data });
}
async function processBotFeedback(botKey, botChar) {
const botUser = await prisma.user.findFirst({
where: { email: `bot_${botKey}@zhuiguang.ai` },
});
if (!botUser) {
console.log(`${ts()} Bot user not found: ${botKey}`);
return;
}
const botConfig = await prisma.botConfig.findUnique({
where: { userId: botUser.id },
});
if (!botConfig) {
console.log(`${ts()} BotConfig not found: ${botKey}`);
return;
}
const todayStat = await getOrCreateTodayStat(botConfig.id);
const activityLevel = botChar.activityLevel || "medium";
const quota = QUOTA[activityLevel] || QUOTA.medium;
if (todayStat.followUpSent >= quota.followUpPerDay) {
console.log(`${ts()} ${botChar.displayName} 今日跟帖配额已用完 (${todayStat.followUpSent}/${quota.followUpPerDay})`);
return;
}
const since = new Date(Date.now() - FEEDBACK_LOOKBACK_HOURS * 60 * 60 * 1000);
const processedIds = await getProcessedReplyIds(botConfig.id);
const myTopics = await prisma.forumTopic.findMany({
where: {
userId: botUser.id,
createdAt: { gt: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000) },
isLocked: false,
},
include: {
category: { select: { name: true, slug: true } },
posts: {
where: { createdAt: { gt: since } },
include: { user: { select: { id: true, name: true, isBot: true } } },
orderBy: { createdAt: "asc" },
},
},
orderBy: { lastReplyAt: "desc" },
take: 10,
});
let totalNewReplies = 0;
let humanReplies = 0;
let followUpsSent = 0;
for (const topic of myTopics) {
const newReplies = topic.posts.filter(p => p.userId !== botUser.id && !processedIds.has(p.id));
if (newReplies.length === 0) continue;
const replyIds = newReplies.map(p => p.id);
const humanCount = newReplies.filter(p => !p.user.isBot).length;
const humanRepliers = newReplies.filter(p => !p.user.isBot).map(p => ({
id: p.userId,
name: p.user.name || "匿名",
}));
await saveMemory(
botConfig.id,
MEMORY_TYPE.FEEDBACK,
{
topicTitle: topic.title,
topicId: topic.id,
forum: topic.category.name,
replyCount: newReplies.length,
humanCount,
repliers: newReplies.map(p => ({
name: p.user.name || "匿名",
isBot: p.user.isBot,
excerpt: p.content.slice(0, 120),
})),
},
humanCount > 0 ? 0.8 : 0.4,
{
topicId: topic.id,
forumSlug: topic.category.slug,
replyIds,
humanRepliers,
source: "feedback_loop",
},
30
);
totalNewReplies += newReplies.length;
humanReplies += humanCount;
await incrementStat(botConfig.id, "feedbackProcessed", 1);
await incrementStat(botConfig.id, "repliesReceived", newReplies.length);
if (humanCount > 0) {
await incrementStat(botConfig.id, "humanInteractions", humanCount);
}
if (
newReplies.length >= MIN_FEEDBACK_TO_FOLLOWUP &&
Math.random() < FOLLOW_UP_PROBABILITY &&
followUpsSent < MAX_FOLLOWUP_PER_RUN_PER_BOT &&
todayStat.followUpSent + followUpsSent < quota.followUpPerDay
) {
try {
const repliesForPrompt = newReplies.map(p => ({
author: p.user.name || "匿名",
content: p.content,
}));
const prompt = buildFollowUpPrompt(
botChar,
topic.title,
topic.content,
null,
repliesForPrompt
);
const result = await callDeepSeek(prompt);
const followUpContent = (result.content || "").trim();
if (!followUpContent || followUpContent.length < 30) {
console.log(`${ts()} ${botChar.displayName} 二次回复内容过短,跳过`);
continue;
}
const post = await prisma.forumPost.create({
data: {
topicId: topic.id,
userId: botUser.id,
content: followUpContent.slice(0, 5000),
},
});
await prisma.forumTopic.update({
where: { id: topic.id },
data: {
replyCount: { increment: 1 },
lastReplyAt: new Date(),
updatedAt: new Date(),
},
});
await saveMemory(
botConfig.id,
MEMORY_TYPE.INTERACTION,
{
action: "follow_up_reply",
forum: topic.category.name,
topicTitle: topic.title,
topicId: topic.id,
triggeredBy: "feedback_loop",
summary: followUpContent.slice(0, 100),
},
0.6,
{
topicId: topic.id,
forumSlug: topic.category.slug,
postId: post.id,
source: "feedback_loop",
},
14
);
followUpsSent++;
await incrementStat(botConfig.id, "followUpSent", 1);
await incrementStat(botConfig.id, "replySent", 1);
console.log(`${ts()} ${botChar.displayName} 二次回复: "${followUpContent.slice(0, 40)}..."`);
} catch (err) {
console.error(`${ts()} ${botChar.displayName} 二次回复失败:`, err.message);
}
}
}
if (totalNewReplies > 0) {
console.log(`${ts()} ${botChar.displayName}: 处理 ${totalNewReplies} 条新回复 (${humanReplies} 真人) → 跟帖 ${followUpsSent} 条`);
}
}
async function logTask(action, status, detail) {
try {
await prisma.taskLog.create({
data: {
taskKey: "bot-feedback-loop",
taskName: "Bot反馈循环",
status,
startedAt: new Date(),
finishedAt: new Date(),
triggerBy: "cron",
result: { action, detail },
},
});
} catch (e) {
console.error(`${ts()} TaskLog error:`, e.message);
}
}
async function main() {
console.log(`${ts()} Bot Feedback Loop starting...`);
if (!process.env.DEEPSEEK_API_KEY) {
throw new Error("DEEPSEEK_API_KEY 未配置");
}
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 processed = 0;
for (const botChar of botChars) {
try {
await processBotFeedback(botChar.key, botChar);
processed++;
} catch (err) {
console.error(`${ts()} Error processing ${botChar.key}:`, err.message);
}
}
await logTask("feedback_loop", "success", `处理 ${processed} 个数字人`);
console.log(`${ts()} Bot Feedback Loop done.`);
}
main()
.catch(async (e) => {
console.error(`${ts()} Fatal error:`, e);
// 写一条失败日志方便后台追溯
try {
await prisma.taskLog.create({
data: {
taskKey: "bot-feedback-loop",
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();
});