413 lines
13 KiB
JavaScript
413 lines
13 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 CRYSTALLIZE_TRIGGERS = {
|
||
minReplies: 5,
|
||
minHumanReplies: 1,
|
||
minLikes: 3,
|
||
lookbackDays: 14,
|
||
maxTopicsPerBot: 8,
|
||
minTopicsForCrystallize: 2,
|
||
};
|
||
|
||
function ts() {
|
||
return `[${new Date().toISOString()}]`;
|
||
}
|
||
|
||
function pick(arr) {
|
||
return arr[Math.floor(Math.random() * arr.length)];
|
||
}
|
||
|
||
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 buildCrystallizePrompt(botChar, topicSummaries) {
|
||
const p = botChar.personality;
|
||
return `你是一个"成功帖子解构师"。你负责把一个数字人最近发的高互动帖子提炼成可复用的发帖技能。
|
||
|
||
[数字人角色]
|
||
- 名字:【${botChar.displayName}】
|
||
- 身份:${p.identity}
|
||
- 立场:${p.stance}
|
||
- 风格:${p.speakingStyle}
|
||
- 口头禅:${p.catchphrase}
|
||
|
||
[高互动帖子列表]
|
||
${topicSummaries
|
||
.map(
|
||
(t, i) =>
|
||
`[${i + 1}] 标题:${t.title}\n板块:${t.forumName}\n收到回复:${t.replyCount}(真人${t.humanCount})\n开头:${t.opening}\n要点:${t.bulletPoints}`
|
||
)
|
||
.join("\n\n")}
|
||
|
||
[任务]
|
||
请从以上${topicSummaries.length}个高互动帖子中提取共性,输出一个可复用的发帖技能。
|
||
|
||
要求:
|
||
1. 技能名 6-12字,简明扼要
|
||
2. 钩子模板(开头如何吸引人)100-200字,可以含{{topic}}占位符
|
||
3. 正文骨架(段落结构+要点清单)150-300字
|
||
4. 适用场景 30-80字,描述什么类型的话题/事件适合套用
|
||
5. 必须保持【${botChar.displayName}】的说话风格和立场,不能泛化
|
||
6. 如果发现明显"反模式"(如"使用了禁止的句式"),在适用场景里反向说明
|
||
|
||
[输出格式]
|
||
只输出严格JSON,不要其他任何内容:
|
||
{
|
||
"name": "技能名",
|
||
"hookPattern": "钩子模板",
|
||
"bodyTemplate": "正文骨架",
|
||
"applicability": "适用场景"
|
||
}`;
|
||
}
|
||
|
||
async function callDeepSeek(prompt) {
|
||
const response = await withRetry(() =>
|
||
openai.chat.completions.create({
|
||
model: MODEL,
|
||
messages: [{ role: "user", content: prompt }],
|
||
temperature: 0.7,
|
||
// DeepSeek V4 推理 token 计入 max_tokens,预算不足会导致正文截断/为空
|
||
max_tokens: 4096,
|
||
})
|
||
);
|
||
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 getCrystallizedTopicIds(botConfigId) {
|
||
const skills = await prisma.botSkill.findMany({
|
||
where: { botId: botConfigId },
|
||
select: { sourceTopicIds: true },
|
||
});
|
||
const ids = new Set();
|
||
for (const s of skills) {
|
||
const arr = s.sourceTopicIds;
|
||
if (Array.isArray(arr)) for (const id of arr) ids.add(id);
|
||
}
|
||
return ids;
|
||
}
|
||
|
||
async function findQualifiedTopics(botUser, botConfigId) {
|
||
const since = new Date(Date.now() - CRYSTALLIZE_TRIGGERS.lookbackDays * 24 * 60 * 60 * 1000);
|
||
const topics = await prisma.forumTopic.findMany({
|
||
where: {
|
||
userId: botUser.id,
|
||
createdAt: { gt: since },
|
||
},
|
||
include: {
|
||
category: { select: { name: true, slug: true } },
|
||
posts: {
|
||
include: { user: { select: { isBot: true } } },
|
||
},
|
||
},
|
||
orderBy: { replyCount: "desc" },
|
||
take: 30,
|
||
});
|
||
|
||
// 拉 topic 下所有回复的真实 likeCount,与 topic.likeCount 合并作为真实点赞
|
||
const postIds = topics.flatMap((t) => t.posts.map((p) => p.id));
|
||
const postLikeAgg = postIds.length > 0
|
||
? await prisma.forumPost.groupBy({
|
||
by: ["id"],
|
||
where: { id: { in: postIds } },
|
||
_sum: { likeCount: true },
|
||
})
|
||
: [];
|
||
const postLikeMap = new Map(postLikeAgg.map((r) => [r.id, r._sum.likeCount || 0]));
|
||
|
||
return topics
|
||
.map((t) => {
|
||
const humanReplies = t.posts.filter((p) => !p.user.isBot).length;
|
||
// 真实点赞:topic 自身的 likeCount + 所有回复的 likeCount 之和
|
||
const postLikes = t.posts.reduce((s, p) => s + (postLikeMap.get(p.id) ?? 0), 0);
|
||
const realLikeCount = (t.likeCount || 0) + postLikes;
|
||
return {
|
||
id: t.id,
|
||
title: t.title,
|
||
forumName: t.category.name,
|
||
forumSlug: t.category.slug,
|
||
content: t.content,
|
||
replyCount: t._count?.posts ?? t.posts.length,
|
||
humanCount: humanReplies,
|
||
likeCount: realLikeCount,
|
||
opening: t.content.slice(0, 200),
|
||
bulletPoints: t.content
|
||
.split(/[。\n]/)
|
||
.map((s) => s.trim())
|
||
.filter((s) => s.length > 5 && s.length < 60)
|
||
.slice(0, 5)
|
||
.join(" / "),
|
||
};
|
||
})
|
||
.filter(
|
||
(t) =>
|
||
t.replyCount >= CRYSTALLIZE_TRIGGERS.minReplies ||
|
||
t.humanCount >= CRYSTALLIZE_TRIGGERS.minHumanReplies ||
|
||
t.likeCount >= CRYSTALLIZE_TRIGGERS.minLikes
|
||
);
|
||
}
|
||
|
||
async function processBotCrystallize(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 alreadyCrystallized = await getCrystallizedTopicIds(botConfig.id);
|
||
|
||
const allQualified = await findQualifiedTopics(botUser, botConfig.id);
|
||
const newQualified = allQualified.filter((t) => !alreadyCrystallized.has(t.id));
|
||
|
||
if (newQualified.length < CRYSTALLIZE_TRIGGERS.minTopicsForCrystallize) {
|
||
if (newQualified.length === 0) return;
|
||
console.log(
|
||
`${ts()} ${botChar.displayName}: ${newQualified.length} 个新合格话题 (< ${CRYSTALLIZE_TRIGGERS.minTopicsForCrystallize}),跳过`
|
||
);
|
||
return;
|
||
}
|
||
|
||
const topicsForCrystallize = newQualified.slice(0, CRYSTALLIZE_TRIGGERS.maxTopicsPerBot);
|
||
console.log(
|
||
`${ts()} ${botChar.displayName}: ${topicsForCrystallize.length} 个高互动话题待解构`
|
||
);
|
||
|
||
const forumGroups = {};
|
||
for (const t of topicsForCrystallize) {
|
||
if (!forumGroups[t.forumSlug]) forumGroups[t.forumSlug] = [];
|
||
forumGroups[t.forumSlug].push(t);
|
||
}
|
||
|
||
for (const [forumSlug, topics] of Object.entries(forumGroups)) {
|
||
if (topics.length < CRYSTALLIZE_TRIGGERS.minTopicsForCrystallize) continue;
|
||
|
||
try {
|
||
const prompt = buildCrystallizePrompt(botChar, topics);
|
||
const result = await callDeepSeek(prompt);
|
||
|
||
if (!result.name || !result.hookPattern || !result.bodyTemplate) {
|
||
console.warn(`${ts()} ${forumSlug} 解构结果字段不全,跳过`);
|
||
continue;
|
||
}
|
||
|
||
const newSkill = await prisma.botSkill.create({
|
||
data: {
|
||
botId: botConfig.id,
|
||
name: result.name.slice(0, 50),
|
||
category: forumSlug,
|
||
hookPattern: result.hookPattern,
|
||
bodyTemplate: result.bodyTemplate,
|
||
applicability: result.applicability || null,
|
||
sourceTopicIds: topics.map((t) => t.id),
|
||
successRate: 0.5,
|
||
usageCount: 0,
|
||
avgReplies: topics.reduce((s, t) => s + t.replyCount, 0) / topics.length,
|
||
avgLikes: topics.reduce((s, t) => s + t.likeCount, 0) / topics.length,
|
||
version: 1,
|
||
isActive: true,
|
||
},
|
||
});
|
||
|
||
await prisma.botMemory.create({
|
||
data: {
|
||
botId: botConfig.id,
|
||
memoryType: "skill_usage",
|
||
layer: "LONGTERM",
|
||
content: {
|
||
action: "crystallize_skill",
|
||
skillId: newSkill.id,
|
||
skillName: newSkill.name,
|
||
category: forumSlug,
|
||
sourceTopicCount: topics.length,
|
||
sourceTopicIds: topics.map((t) => t.id),
|
||
},
|
||
importance: 0.9,
|
||
contextTags: {
|
||
skillId: newSkill.id,
|
||
forumSlug,
|
||
source: "skill_crystallize",
|
||
},
|
||
},
|
||
});
|
||
|
||
await prisma.taskLog.create({
|
||
data: {
|
||
taskKey: "bot-skill-crystallize",
|
||
taskName: "Bot技能结晶",
|
||
status: "success",
|
||
startedAt: new Date(),
|
||
finishedAt: new Date(),
|
||
triggerBy: "cron",
|
||
result: {
|
||
botKey: botChar.key,
|
||
forumSlug,
|
||
skillId: newSkill.id,
|
||
skillName: newSkill.name,
|
||
sourceCount: topics.length,
|
||
},
|
||
},
|
||
});
|
||
|
||
console.log(
|
||
`${ts()} ✅ 结晶 [${forumSlug}] "${newSkill.name}" (来源 ${topics.length} 帖)`
|
||
);
|
||
} catch (err) {
|
||
console.error(`${ts()} ${forumSlug} 结晶失败:`, err.message);
|
||
await prisma.taskLog.create({
|
||
data: {
|
||
taskKey: "bot-skill-crystallize",
|
||
taskName: "Bot技能结晶",
|
||
status: "error",
|
||
startedAt: new Date(),
|
||
finishedAt: new Date(),
|
||
triggerBy: "cron",
|
||
error: err.message,
|
||
result: { botKey: botChar.key, forumSlug },
|
||
},
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
// 把已经"沉淀"过的技能(基于 sourceTopicIds)回查最近表现:
|
||
// 1) 找到 sourceTopicIds 中每个话题的最新点赞/回复数
|
||
// 2) 与技能里记录的 avgReplies/avgLikes 对比,更新 successRate
|
||
// 3) 对 successRate < 0.2 且 usageCount >= 5 的技能,标记 isActive=false(暂时停用)
|
||
// 返回被回收/调整的技能数
|
||
async function recycleSkillOutcomes() {
|
||
const skills = await prisma.botSkill.findMany({
|
||
where: { isActive: true },
|
||
select: { id: true, sourceTopicIds: true, avgReplies: true, avgLikes: true, usageCount: true, successRate: true },
|
||
});
|
||
if (skills.length === 0) return 0;
|
||
|
||
let adjusted = 0;
|
||
for (const skill of skills) {
|
||
const ids = Array.isArray(skill.sourceTopicIds) ? skill.sourceTopicIds : [];
|
||
if (ids.length === 0) continue;
|
||
const topics = await prisma.forumTopic.findMany({
|
||
where: { id: { in: ids } },
|
||
select: { replyCount: true, likeCount: true },
|
||
});
|
||
if (topics.length === 0) continue;
|
||
const newAvgReplies = topics.reduce((s, t) => s + t.replyCount, 0) / topics.length;
|
||
const newAvgLikes = topics.reduce((s, t) => s + t.likeCount, 0) / topics.length;
|
||
// 简单 successRate = 当前平均点赞 / 5,上限 1
|
||
const newSuccessRate = Math.min(1, newAvgLikes / 5);
|
||
const update = {
|
||
avgReplies: newAvgReplies,
|
||
avgLikes: newAvgLikes,
|
||
successRate: newSuccessRate,
|
||
};
|
||
if (skill.usageCount >= 5 && newSuccessRate < 0.2) {
|
||
update.isActive = false;
|
||
}
|
||
await prisma.botSkill.update({
|
||
where: { id: skill.id },
|
||
data: update,
|
||
});
|
||
adjusted++;
|
||
}
|
||
return adjusted;
|
||
}
|
||
|
||
async function main() {
|
||
console.log(`${ts()} Bot Skill Crystallize 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 processBotCrystallize(botChar);
|
||
processed++;
|
||
} catch (err) {
|
||
console.error(`${ts()} Error processing ${botChar.key}:`, err.message);
|
||
}
|
||
}
|
||
|
||
console.log(`${ts()} Recycle step: 回收成熟技能的实际表现...`);
|
||
const recycled = await recycleSkillOutcomes();
|
||
console.log(`${ts()} Recycle done: ${recycled} 技能已回收`);
|
||
|
||
await prisma.taskLog.create({
|
||
data: {
|
||
taskKey: "bot-skill-crystallize",
|
||
taskName: "Bot技能结晶",
|
||
status: "success",
|
||
startedAt: new Date(),
|
||
finishedAt: new Date(),
|
||
triggerBy: "cron",
|
||
result: { processedBots: processed, recycledSkills: recycled },
|
||
},
|
||
});
|
||
|
||
console.log(`${ts()} Bot Skill Crystallize done. Processed ${processed} bots, recycled ${recycled} skills.`);
|
||
}
|
||
|
||
main()
|
||
.catch(async (e) => {
|
||
console.error(`${ts()} Fatal error:`, e);
|
||
try {
|
||
await prisma.taskLog.create({
|
||
data: {
|
||
taskKey: "bot-skill-crystallize",
|
||
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();
|
||
});
|