422 lines
12 KiB
JavaScript
422 lines
12 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/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();
|
||
});
|