feat: 首次推送到Gitea - 完整项目代码 + 安全加固 + 知识库
This commit is contained in:
@@ -0,0 +1,778 @@
|
||||
// =============================================================================
|
||||
// Bot 圆桌讨论引擎 (Bot Roundtable Discussion Engine)
|
||||
//
|
||||
// 功能:多个 Bot 围绕一个话题,扮演不同角色展开多轮讨论。
|
||||
// 类似 Character.AI 的 "Group Chats 2.0" 概念。
|
||||
//
|
||||
// 流程:
|
||||
// 1. 随机选取一个预定义话题主题
|
||||
// 2. 从数据库中选取 4-5 个可用的 Bot(带 BotConfig 的非 passerby 角色)
|
||||
// 3. 为每个 Bot 分配圆桌角色:主持人、支持者、挑战者、实践者、总结者
|
||||
// 4. 按顺序生成内容,后续 Bot 可以看到前面的发言
|
||||
// 5. 在论坛中创建主题帖 + 依次回复
|
||||
//
|
||||
// 运行:每周三、周六 20:00(crontab 调度)
|
||||
// =============================================================================
|
||||
|
||||
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";
|
||||
import { getNextTemplate } from "./lib/topic-diversity.mjs";
|
||||
import { detectBadPatterns, validateContextRelevance, addPersonalityFlavor } from "./lib/reply-quality.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=10&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 BOT_DATA_PATH = resolve(__dirname, "..", "data", "bot-characters.json");
|
||||
const MODEL = process.env.DEEPSEEK_MODEL || "deepseek-v4-pro";
|
||||
|
||||
// ================ 圆桌话题预定义列表 ================
|
||||
// 每个主题包含:theme(主题名)、category(目标板块slug)、minBots(最少Bot数)
|
||||
const ROUNDTABLE_THEMES = [
|
||||
{
|
||||
theme: "AI 工具的未来:通用大模型 vs 垂直领域模型",
|
||||
category: "ai-llm",
|
||||
minBots: 4,
|
||||
},
|
||||
{
|
||||
theme: "远程办公三年后:效率提升了还是下降了?",
|
||||
category: "ai-startup-forum",
|
||||
minBots: 4,
|
||||
},
|
||||
{
|
||||
theme: "开源 vs 闭源:AI 模型该走哪条路?",
|
||||
category: "ai-llm",
|
||||
minBots: 4,
|
||||
},
|
||||
{
|
||||
theme: "Prompt Engineering 是昙花一现还是必备技能?",
|
||||
category: "ai-tools-app",
|
||||
minBots: 4,
|
||||
},
|
||||
{
|
||||
theme: "小程序 vs App vs Web:2026年创业者该选哪个?",
|
||||
category: "ai-startup-forum",
|
||||
minBots: 4,
|
||||
},
|
||||
{
|
||||
theme: "AI 写代码越来越强,程序员会被取代吗?",
|
||||
category: "ai-tools-app",
|
||||
minBots: 4,
|
||||
},
|
||||
{
|
||||
theme: "独立开发者一个人能走多远?",
|
||||
category: "ai-startup-forum",
|
||||
minBots: 4,
|
||||
},
|
||||
{
|
||||
theme: "知识付费还有搞头吗?AI 时代的内容创作者出路在哪",
|
||||
category: "ct-writing",
|
||||
minBots: 4,
|
||||
},
|
||||
];
|
||||
|
||||
// ================ 圆桌角色定义 ================
|
||||
// 每个角色有特定的发言风格和职责
|
||||
const ROUNDTABLE_ROLES = {
|
||||
host: {
|
||||
name: "主持人",
|
||||
description: "讨论的组织者和引导者",
|
||||
personaType: "enthusiastic",
|
||||
instruction:
|
||||
"你作为本次圆桌讨论的主持人。你的任务:\n" +
|
||||
"1. 抛出一个引人思考的议题,设定讨论的基调和范围\n" +
|
||||
"2. 简要介绍话题背景(可以提及最近发生的事件)\n" +
|
||||
"3. 提出 2-3 个开放性问题,引导其他人参与讨论\n" +
|
||||
"4. 语气开放、包容,像真实的论坛帖主在邀请大家讨论\n" +
|
||||
"5. 不要过早亮出你的结论——把空间留给其他人",
|
||||
style: "welcoming, inquisitive, moderating",
|
||||
},
|
||||
supporter: {
|
||||
name: "支持者",
|
||||
description: "认同主流观点并提供补充论证",
|
||||
personaType: "pragmatic",
|
||||
instruction:
|
||||
"你基本认同主持人提出的方向(但不一定完全同意所有细节)。你的任务:\n" +
|
||||
"1. 选一个你最有共鸣的点展开论述\n" +
|
||||
"2. 用你的专业领域知识或经历来提供具体的论据\n" +
|
||||
"3. 可以引用数据、案例或你观察到的行业趋势\n" +
|
||||
"4. 语气坚定但有礼貌,展示你为什么「站在这一边」\n" +
|
||||
"5. 结尾可以呼应前面的人,但不要简单复读",
|
||||
style: "supportive, evidence-driven, constructive",
|
||||
},
|
||||
critic: {
|
||||
name: "挑战者",
|
||||
description: "提出质疑和反向思考",
|
||||
personaType: "analytical",
|
||||
instruction:
|
||||
"你对主流观点持怀疑或不同态度。你的任务:\n" +
|
||||
"1. 挑战前面讨论中的一个或多个假设\n" +
|
||||
"2. 提出反方视角:被忽略的风险、例外情况、或逻辑漏洞\n" +
|
||||
"3. 不要太有攻击性——你是理性的「反对派」,不是杠精\n" +
|
||||
"4. 你的质疑应该让大家思考更深,而不是让讨论变味\n" +
|
||||
"5. 语气可以略带怀疑,但要保持建设性",
|
||||
style: "skeptical, analytical, thought-provoking",
|
||||
},
|
||||
practitioner: {
|
||||
name: "实践者",
|
||||
description: "从实操经验出发提供落地视角",
|
||||
personaType: "pragmatic",
|
||||
instruction:
|
||||
"你从实际经验出发,让讨论从理论落地到实践。你的任务:\n" +
|
||||
"1. 分享一个与你相关的真实经历或操作案例(可以是你做过的、见到的)\n" +
|
||||
"2. 把前面讨论的抽象话题拉回到具体场景中\n" +
|
||||
"3. 谈谈「实际做的时候遇到了什么问题」以及「怎么解决的」\n" +
|
||||
"4. 给出可以立刻行动的实用建议或避坑指南\n" +
|
||||
"5. 口语化、接地气,让人感觉你在分享真心话",
|
||||
style: "practical, experiential, grounded",
|
||||
},
|
||||
synthesizer: {
|
||||
name: "总结者",
|
||||
description: "整合观点并展望未来",
|
||||
personaType: "analytical",
|
||||
instruction:
|
||||
"你作为本轮讨论的收尾人。你的任务:\n" +
|
||||
"1. 简短总结前面所有发言的观点(不要逐条复述)\n" +
|
||||
"2. 指出大家达成共识的地方以及仍然存在的分歧\n" +
|
||||
"3. 提出一个值得继续深入的方向或一个「下一步」的追问\n" +
|
||||
"4. 以开放、期待的语气收尾,让观者感觉讨论有价值\n" +
|
||||
"5. 语气可以比前面的人更平和,像在做会后总结",
|
||||
style: "reflective, integrative, forward-looking",
|
||||
},
|
||||
};
|
||||
|
||||
// 角色轮换顺序(保证讨论有自然的节奏)
|
||||
const ROLE_ORDER = ["host", "supporter", "critic", "practitioner", "synthesizer"];
|
||||
|
||||
// ================ 辅助函数 ================
|
||||
|
||||
function ts() {
|
||||
return `[${new Date().toISOString()}]`;
|
||||
}
|
||||
|
||||
function pick(arr) {
|
||||
return arr[Math.floor(Math.random() * arr.length)];
|
||||
}
|
||||
|
||||
function slugify(str) {
|
||||
const clean = str
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9\u4e00-\u9fa5]+/g, "-")
|
||||
.replace(/^-|-$/g, "")
|
||||
.substring(0, 80);
|
||||
return `${clean}-${Date.now().toString(36)}`;
|
||||
}
|
||||
|
||||
function shuffle(arr) {
|
||||
const a = [...arr];
|
||||
for (let i = a.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[a[i], a[j]] = [a[j], a[i]];
|
||||
}
|
||||
return a;
|
||||
}
|
||||
|
||||
// ================ 内容质量评估(复用 bot-activity.mjs 的逻辑) ================
|
||||
|
||||
function assessContentQuality(content, title) {
|
||||
let score = 100;
|
||||
const issues = [];
|
||||
|
||||
if (content.length < 100) {
|
||||
score -= 30;
|
||||
issues.push("内容过短(<100字)");
|
||||
} else if (content.length < 150) {
|
||||
score -= 15;
|
||||
issues.push("内容偏短(<150字)");
|
||||
}
|
||||
|
||||
const aiPatterns = [
|
||||
/首先[,,].*其次[,,].*最后/,
|
||||
/从以下几个方面/,
|
||||
/综上所述/,
|
||||
/值得注意的是/,
|
||||
/不可否认/,
|
||||
/总而言之/,
|
||||
/让我们.*一起/,
|
||||
/在这个.*的时代/,
|
||||
];
|
||||
for (const pat of aiPatterns) {
|
||||
if (pat.test(content)) {
|
||||
score -= 15;
|
||||
issues.push(`AI句式: ${pat.source}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (title && content) {
|
||||
const titleChars = new Set(title.split(""));
|
||||
const contentStart = content.substring(0, 100);
|
||||
let overlap = 0;
|
||||
for (const w of titleChars) {
|
||||
if (w.trim() && contentStart.includes(w)) overlap++;
|
||||
}
|
||||
const titleLen = [...titleChars].filter((w) => w.trim()).length;
|
||||
if (titleLen > 0 && overlap / titleLen > 0.8) {
|
||||
score -= 20;
|
||||
issues.push("标题与内容高度重复");
|
||||
}
|
||||
}
|
||||
|
||||
const lastSentence =
|
||||
content.split(/[。!?\n]/).filter(Boolean).pop() || "";
|
||||
if (
|
||||
!/[??]/.test(lastSentence) &&
|
||||
!/大家|你们|你们觉得|怎么看/.test(lastSentence)
|
||||
) {
|
||||
// 主题帖扣 5 分,回复不扣
|
||||
if (title) {
|
||||
score -= 5;
|
||||
issues.push("结尾缺少互动引导");
|
||||
}
|
||||
}
|
||||
|
||||
return { score: Math.max(0, score), issues, pass: score >= 60 };
|
||||
}
|
||||
|
||||
// ================ DeepSeek API 调用 ================
|
||||
|
||||
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: 4096,
|
||||
})
|
||||
);
|
||||
|
||||
const text = response.choices[0].message.content.trim();
|
||||
const jsonMatch = text.match(/\{[\s\S]*\}/);
|
||||
if (!jsonMatch) {
|
||||
throw new Error(
|
||||
`Could not parse JSON from response: ${text.substring(0, 200)}`
|
||||
);
|
||||
}
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(jsonMatch[0]);
|
||||
} catch {
|
||||
const cleaned = jsonMatch[0].replace(/,\s*}/g, "}").replace(/,\s*]/g, "]");
|
||||
try {
|
||||
parsed = JSON.parse(cleaned);
|
||||
} catch {
|
||||
throw new Error(
|
||||
`JSON parse failed after cleanup: ${jsonMatch[0].substring(0, 200)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 质量检查
|
||||
if (parsed.content) {
|
||||
const qa = assessContentQuality(parsed.content, parsed.title);
|
||||
if (!qa.pass) {
|
||||
console.warn(
|
||||
`${ts()} ⚠️ 质量不达标 (score=${qa.score}): ${qa.issues.join(", ")}`
|
||||
);
|
||||
}
|
||||
if (qa.score < 40) {
|
||||
throw new Error(
|
||||
`Content quality critically low (score=${qa.score}): ${qa.issues.join(", ")}`
|
||||
);
|
||||
}
|
||||
parsed._qualityScore = qa.score;
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
// ================ 数据加载 ================
|
||||
|
||||
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 };
|
||||
}
|
||||
|
||||
/**
|
||||
* 从数据库中加载可用的 Bot(有 BotConfig 且 user 存在的非 passerby 角色)
|
||||
*/
|
||||
async function loadAvailableBots(botCharMap) {
|
||||
// 获取所有 BotConfig,并附带 user 信息
|
||||
const configs = await prisma.botConfig.findMany({
|
||||
include: { user: { select: { id: true, name: true, email: true } } },
|
||||
});
|
||||
|
||||
const available = [];
|
||||
for (const config of configs) {
|
||||
const email = config.user.email || "";
|
||||
// 从 email 中提取 bot key: bot_xxx@zhuiguang.ai → xxx
|
||||
const keyMatch = email.match(/^bot_(.+)@zhuiguang\.ai$/);
|
||||
if (!keyMatch) continue;
|
||||
const key = keyMatch[1];
|
||||
|
||||
const charData = botCharMap[key];
|
||||
// 只使用非 passerby 的 bot(有明确人格设定的主角色)
|
||||
if (!charData || charData.role === "passerby") continue;
|
||||
|
||||
available.push({
|
||||
config,
|
||||
user: config.user,
|
||||
charData,
|
||||
key,
|
||||
});
|
||||
}
|
||||
|
||||
return available;
|
||||
}
|
||||
|
||||
// ================ 数据库操作 ================
|
||||
|
||||
async function getCategoryBySlug(slug) {
|
||||
return prisma.forumCategory.findUnique({ where: { slug } });
|
||||
}
|
||||
|
||||
/**
|
||||
* 查找合适的板块:优先 targetSlug,不存在则尝试备选,若都不存在则取任意一个
|
||||
*/
|
||||
async function resolveCategory(targetSlug) {
|
||||
// 尝试命中目标
|
||||
let category = await getCategoryBySlug(targetSlug);
|
||||
if (category) return category;
|
||||
|
||||
// 备选列表:AI 相关板块
|
||||
const fallbacks = ["ai-llm", "ai-tools-app", "ai-startup-forum"];
|
||||
for (const fb of fallbacks) {
|
||||
category = await getCategoryBySlug(fb);
|
||||
if (category) return category;
|
||||
}
|
||||
|
||||
// 最后手段:取任意一个板块
|
||||
category = await prisma.forumCategory.findFirst({
|
||||
orderBy: { id: "asc" },
|
||||
});
|
||||
return category;
|
||||
}
|
||||
|
||||
async function createTopic(categoryId, userId, title, content) {
|
||||
return prisma.forumTopic.create({
|
||||
data: {
|
||||
categoryId,
|
||||
userId,
|
||||
title,
|
||||
slug: slugify(title),
|
||||
content,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function createPost(topicId, userId, content) {
|
||||
return prisma.forumPost.create({
|
||||
data: { topicId, userId, content },
|
||||
});
|
||||
}
|
||||
|
||||
// ================ TaskLog 记录 ================
|
||||
|
||||
async function logTask(action, status, detail) {
|
||||
try {
|
||||
await prisma.taskLog.create({
|
||||
data: {
|
||||
taskKey: "bot-roundtable",
|
||||
taskName: "Bot圆桌讨论",
|
||||
status,
|
||||
startedAt: new Date(),
|
||||
finishedAt: new Date(),
|
||||
triggerBy: "cron",
|
||||
result: { action, detail },
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(`${ts()} TaskLog error:`, e.message);
|
||||
}
|
||||
}
|
||||
|
||||
// ================ Prompt 构建 ================
|
||||
|
||||
/**
|
||||
* 构建主持人(Host)主题帖的 prompt
|
||||
*/
|
||||
// 📄 核心 Prompt 模板参考: scripts/prompts/bot-roundtable.txt
|
||||
// 实际 prompt 构建逻辑保留了动态参数注入(角色设定、角色指令、话题上下文等)
|
||||
function buildHostPrompt(bot, roleDef, theme, today) {
|
||||
const p = bot.charData.personality;
|
||||
return `你正在参与一个叫"追光AI行业论坛"的中文社区。今天是${today}。
|
||||
|
||||
你现在发起一个【圆桌讨论】——邀请社区里几位不同背景的朋友,围绕一个话题展开多角度讨论。
|
||||
|
||||
[你的角色设定]
|
||||
你是【${bot.charData.displayName}】,${p.identity}
|
||||
你擅长的领域:${p.expertise.join("、")}
|
||||
你说话的风格:${p.speakingStyle}
|
||||
你的口头禅:${p.catchphrase}
|
||||
|
||||
[本次圆桌角色]
|
||||
你担任【${roleDef.name}】(${roleDef.description})
|
||||
你的风格:${roleDef.style}
|
||||
${roleDef.instruction}
|
||||
|
||||
[圆桌讨论话题]
|
||||
${theme}
|
||||
|
||||
[发言要求]
|
||||
请以${bot.charData.displayName}的身份发表主题帖。要求:
|
||||
1. 帖子标题 10-25 字,要有吸引力,可以在标题末尾加"圆桌讨论"字样
|
||||
2. 内容 250-500 字,像真人发帖子邀请大家讨论一样
|
||||
3. 用你的说话风格和口头禅,展现你的个性
|
||||
4. 先说话题背景(可以提及近期行业动态),再抛出 2-3 个开放性问题
|
||||
5. 使用【圆桌讨论】作为帖子的标签或格式前缀
|
||||
6. 结尾可以 @ 你希望听到哪些人的看法(用"期待大家畅所欲言"这类自然表述)
|
||||
|
||||
[禁止事项]
|
||||
- 禁止使用"首先/其次/最后"等格式化连接词
|
||||
- 禁止使用"从以下几个方面分析""综上所述"等教科书句式
|
||||
- 禁止表现得像AI助手或官方新闻稿
|
||||
- 禁止写成"欢迎大家来到今天的圆桌讨论"这类主持人串词
|
||||
- 不要用 ${(p.forbiddenPatterns || []).join("、")}
|
||||
|
||||
请只返回JSON格式,不要包含其他任何内容:
|
||||
{
|
||||
"title": "话题标题",
|
||||
"content": "话题内容"
|
||||
}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建圆桌参与者的回复 prompt
|
||||
*/
|
||||
function buildRoundtableReplyPrompt(
|
||||
bot,
|
||||
roleDef,
|
||||
theme,
|
||||
topicTitle,
|
||||
hostContent,
|
||||
hostName,
|
||||
priorReplies
|
||||
) {
|
||||
const p = bot.charData.personality;
|
||||
const today = new Date().toLocaleDateString("zh-CN", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
});
|
||||
|
||||
// 构建前面的发言上下文
|
||||
let priorContext = `[主持人发言]\n${hostName}(主持人):${hostContent}\n\n`;
|
||||
if (priorReplies.length > 0) {
|
||||
priorContext += "[前面其他人的发言]\n";
|
||||
for (const r of priorReplies) {
|
||||
priorContext += `${r.author}(${r.roleName}):${r.content.substring(0, 300)}\n\n`;
|
||||
}
|
||||
priorContext += "(以上是你需要参考的讨论内容,不要重复已经说过的观点)\n";
|
||||
}
|
||||
|
||||
return `你正在参与一个叫"追光AI行业论坛"的中文社区。今天是${today}。
|
||||
|
||||
你正在回复一个【圆桌讨论】帖子。这个讨论主题是:"${theme}",
|
||||
由${hostName}发起的多角度讨论。下面是讨论的情况:
|
||||
|
||||
${priorContext}
|
||||
|
||||
[你的角色设定]
|
||||
你是【${bot.charData.displayName}】,${p.identity}
|
||||
你擅长的领域:${p.expertise.join("、")}
|
||||
你的核心立场:${p.stance}
|
||||
你说话的风格:${p.speakingStyle}
|
||||
你的口头禅:${p.catchphrase}
|
||||
|
||||
[本次圆桌角色]
|
||||
你担任【${roleDef.name}】(${roleDef.description})
|
||||
你的风格:${roleDef.style}
|
||||
${roleDef.instruction}
|
||||
|
||||
[发言要求]
|
||||
请以${bot.charData.displayName}的身份回复这个圆桌讨论。要求:
|
||||
1. 回复 180-400 字,有信息增量,不要重复前面已经说过的内容
|
||||
2. 用你的说话风格和口头禅,展现你的个性
|
||||
3. 可以回应或呼应前面某个人的观点,但要说新的东西
|
||||
4. 用你真实的专业背景来丰富讨论(你的领域:${p.expertise.join("、")})
|
||||
5. 像真人回复一样自然,有口语化表达和情绪
|
||||
|
||||
[禁止事项]
|
||||
- 禁止使用"首先/其次/最后"等格式化连接词
|
||||
- 禁止使用"从以下几个方面分析""综上所述"等教科书句式
|
||||
- 禁止表现得像AI助手
|
||||
- 禁止说"作为${roleDef.name},我认为..."(不要暴露你的圆桌角色)
|
||||
- 禁止使用 ${(p.forbiddenPatterns || []).join("、")}
|
||||
|
||||
请只返回JSON格式,不要包含其他任何内容:
|
||||
{
|
||||
"content": "回复内容"
|
||||
}`;
|
||||
}
|
||||
|
||||
// ================ 主流程 ================
|
||||
|
||||
async function main() {
|
||||
console.log(`${ts()} ========================================`);
|
||||
console.log(`${ts()} Bot Roundtable Discussion Engine starting`);
|
||||
console.log(`${ts()} ========================================`);
|
||||
|
||||
// 1. 加载 bot 角色数据
|
||||
const { map: botCharMap } = await loadBotCharacters();
|
||||
console.log(`${ts()} Loaded ${Object.keys(botCharMap).length} bot characters.`);
|
||||
|
||||
// 2. 加载数据库中可用的 Bot
|
||||
const availableBots = await loadAvailableBots(botCharMap);
|
||||
console.log(
|
||||
`${ts()} Found ${availableBots.length} available bots in DB (non-passerby).`
|
||||
);
|
||||
|
||||
if (availableBots.length < 3) {
|
||||
console.error(
|
||||
`${ts()} ❌ Not enough bots available. Need at least 3, have ${availableBots.length}.`
|
||||
);
|
||||
await logTask("insufficient_bots", "failed", `只有 ${availableBots.length} 个可用 Bot(需 >=3)`);
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. 随机选取一个话题主题
|
||||
const themeDef = pick(ROUNDTABLE_THEMES);
|
||||
console.log(`${ts()} Selected theme: "${themeDef.theme}"`);
|
||||
console.log(`${ts()} Target category: ${themeDef.category}`);
|
||||
|
||||
// 4. 查找合适的论坛板块
|
||||
const category = await resolveCategory(themeDef.category);
|
||||
if (!category) {
|
||||
console.error(`${ts()} ❌ No forum category found. Aborting.`);
|
||||
await logTask("no_category", "failed", "未找到可用板块");
|
||||
return;
|
||||
}
|
||||
console.log(`${ts()} Using category: ${category.name} (slug=${category.slug})`);
|
||||
|
||||
// 5. 选取参与 Bot 并分配角色
|
||||
const numBots = Math.min(Math.max(themeDef.minBots, 4), availableBots.length, 5);
|
||||
const selectedBots = shuffle(availableBots).slice(0, numBots);
|
||||
const rolesForThisRound = ROLE_ORDER.slice(0, numBots);
|
||||
|
||||
console.log(`${ts()} Roundtable participants (${numBots} bots):`);
|
||||
const participants = [];
|
||||
for (let i = 0; i < numBots; i++) {
|
||||
const bot = selectedBots[i];
|
||||
const roleKey = rolesForThisRound[i];
|
||||
const roleDef = ROUNDTABLE_ROLES[roleKey];
|
||||
participants.push({ bot, roleKey, roleDef });
|
||||
console.log(
|
||||
`${ts()} ${i + 1}. ${bot.charData.displayName} (${bot.key}) as 【${roleDef.name}】`
|
||||
);
|
||||
}
|
||||
|
||||
// 6. 生成主持人的主题帖
|
||||
const hostEntry = participants[0];
|
||||
const today = new Date().toLocaleDateString("zh-CN", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
});
|
||||
|
||||
console.log(
|
||||
`${ts()} Generating host topic from ${hostEntry.bot.charData.displayName}...`
|
||||
);
|
||||
|
||||
let hostResult;
|
||||
try {
|
||||
const hostPrompt = buildHostPrompt(
|
||||
hostEntry.bot,
|
||||
hostEntry.roleDef,
|
||||
themeDef.theme,
|
||||
today
|
||||
);
|
||||
hostResult = await callDeepSeek(hostPrompt);
|
||||
console.log(
|
||||
`${ts()} Host topic generated: "${hostResult.title}" (quality=${hostResult._qualityScore})`
|
||||
);
|
||||
|
||||
// 质量检查:反模式检测 & 关联度
|
||||
const hostBadPatterns = detectBadPatterns(hostResult.content);
|
||||
if (hostBadPatterns.isGeneric) {
|
||||
console.warn(`${ts()} ⚠️ 主持人 ${hostEntry.bot.charData.displayName} 内容过于通用 (pattern_matches=${hostBadPatterns.matchCount})`);
|
||||
}
|
||||
// 应用人格风味
|
||||
const hostContentWithFlavor = addPersonalityFlavor(hostResult.content, hostEntry.roleDef.personaType || "enthusiastic");
|
||||
hostResult.content = hostContentWithFlavor !== hostResult.content ? hostContentWithFlavor : hostResult.content;
|
||||
|
||||
const diversityTemplate = getNextTemplate();
|
||||
console.log(`${ts()} 📊 roundtable_host: topic_template_ref=${diversityTemplate.substring(0, 30)}..., persona=${hostEntry.roleDef.personaType}, generic=${hostBadPatterns.isGeneric}`);
|
||||
} catch (err) {
|
||||
console.error(`${ts()} ❌ Host generation failed:`, err.message);
|
||||
await logTask("host_generation", "failed", err.message);
|
||||
return;
|
||||
}
|
||||
|
||||
// 7. 创建主题帖
|
||||
const topic = await createTopic(
|
||||
category.id,
|
||||
hostEntry.bot.user.id,
|
||||
hostResult.title,
|
||||
hostResult.content
|
||||
);
|
||||
console.log(`${ts()} ✅ Topic created: #${topic.id} "${topic.title}"`);
|
||||
|
||||
// 更新板块话题计数
|
||||
await prisma.forumCategory.update({
|
||||
where: { id: category.id },
|
||||
data: { topicCount: { increment: 1 } },
|
||||
});
|
||||
|
||||
// 8. 依次生成各位圆桌参与者的回复
|
||||
const priorReplies = [];
|
||||
let allQualityScores = [hostResult._qualityScore || 0];
|
||||
let successCount = 0;
|
||||
|
||||
for (let i = 1; i < participants.length; i++) {
|
||||
const entry = participants[i];
|
||||
console.log(
|
||||
`${ts()} Generating reply from ${entry.bot.charData.displayName} as【${entry.roleDef.name}】...`
|
||||
);
|
||||
|
||||
// 短暂延迟,避免 API 限流
|
||||
if (i > 1) {
|
||||
await new Promise((r) => setTimeout(r, 1500));
|
||||
}
|
||||
|
||||
try {
|
||||
const replyPrompt = buildRoundtableReplyPrompt(
|
||||
entry.bot,
|
||||
entry.roleDef,
|
||||
themeDef.theme,
|
||||
topic.title,
|
||||
hostResult.content,
|
||||
hostEntry.bot.charData.displayName,
|
||||
priorReplies
|
||||
);
|
||||
const replyResult = await callDeepSeek(replyPrompt);
|
||||
|
||||
// 质量检查:反模式检测 & 关联度
|
||||
const replyBadPatterns = detectBadPatterns(replyResult.content);
|
||||
if (replyBadPatterns.isGeneric) {
|
||||
console.warn(`${ts()} ⚠️ ${entry.bot.charData.displayName} 回复过于通用 (pattern_matches=${replyBadPatterns.matchCount})`);
|
||||
}
|
||||
const replyContextForRelevance = `${hostResult.content} ${priorReplies.map(r => r.content).join(" ")}`.slice(0, 500);
|
||||
const { score: replyRelevanceScore } = validateContextRelevance(replyResult.content, replyContextForRelevance);
|
||||
console.log(`${ts()} 📊 ${entry.bot.charData.displayName}: role=${entry.roleDef.name}, persona=${entry.roleDef.personaType}, relevance=${(replyRelevanceScore / 100).toFixed(2)}, generic=${replyBadPatterns.isGeneric}`);
|
||||
|
||||
// 应用人格风味
|
||||
const replyWithFlavor = addPersonalityFlavor(replyResult.content, entry.roleDef.personaType || "pragmatic");
|
||||
if (replyWithFlavor !== replyResult.content) {
|
||||
replyResult.content = replyWithFlavor;
|
||||
}
|
||||
|
||||
const post = await createPost(
|
||||
topic.id,
|
||||
entry.bot.user.id,
|
||||
replyResult.content
|
||||
);
|
||||
console.log(
|
||||
`${ts()} ✅ Reply posted (#${post.id}, ${replyResult.content.length} chars, quality=${replyResult._qualityScore || "?"})`
|
||||
);
|
||||
|
||||
priorReplies.push({
|
||||
author: entry.bot.charData.displayName,
|
||||
roleName: entry.roleDef.name,
|
||||
content: replyResult.content,
|
||||
});
|
||||
|
||||
allQualityScores.push(replyResult._qualityScore || 0);
|
||||
successCount++;
|
||||
} catch (err) {
|
||||
console.error(
|
||||
`${ts()} ❌ ${entry.bot.charData.displayName} reply failed:`,
|
||||
err.message
|
||||
);
|
||||
// 继续生成其他 bot 的回复,不因一个失败而中断整体
|
||||
priorReplies.push({
|
||||
author: entry.bot.charData.displayName,
|
||||
roleName: entry.roleDef.name,
|
||||
content: `(${entry.bot.charData.displayName} 暂时不在线,请稍候...)`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 9. 整体质量判定
|
||||
const avgQuality =
|
||||
allQualityScores.length > 0
|
||||
? Math.round(
|
||||
allQualityScores.reduce((s, v) => s + v, 0) / allQualityScores.length
|
||||
)
|
||||
: 0;
|
||||
|
||||
console.log(
|
||||
`${ts()} Roundtable complete: ${successCount + 1}/${participants.length} posts (avg quality=${avgQuality})`
|
||||
);
|
||||
|
||||
await logTask(
|
||||
"roundtable_complete",
|
||||
avgQuality >= 60 ? "success" : "warning",
|
||||
`话题: ${themeDef.theme} | ` +
|
||||
`参与者: ${participants.map((p) => `${p.bot.charData.displayName}(${p.roleDef.name})`).join(", ")} | ` +
|
||||
`板块: ${category.name} | ` +
|
||||
`成功率: ${successCount + 1}/${participants.length} | ` +
|
||||
`均分: ${avgQuality}`
|
||||
);
|
||||
|
||||
console.log(`${ts()} ========================================`);
|
||||
console.log(`${ts()} Bot Roundtable Discussion Engine finished.`);
|
||||
console.log(`${ts()} ========================================`);
|
||||
}
|
||||
|
||||
// ================ 超时保护 & 入口 ================
|
||||
|
||||
const TIMEOUT_MS = 15 * 60 * 1000; // 15 分钟超时(比 bot-activity 短,因为只跑一轮)
|
||||
const timeoutPromise = new Promise((_, reject) =>
|
||||
setTimeout(
|
||||
() =>
|
||||
reject(
|
||||
new Error(`Bot Roundtable Engine timed out after ${TIMEOUT_MS / 60000} minutes`)
|
||||
),
|
||||
TIMEOUT_MS
|
||||
)
|
||||
);
|
||||
|
||||
Promise.race([main(), timeoutPromise])
|
||||
.catch(async (e) => {
|
||||
console.error(`${ts()} Fatal error:`, e);
|
||||
try {
|
||||
await prisma.taskLog.create({
|
||||
data: {
|
||||
taskKey: "bot-roundtable",
|
||||
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();
|
||||
});
|
||||
Reference in New Issue
Block a user