feat: 首次推送到Gitea - 完整项目代码 + 安全加固 + 知识库

This commit is contained in:
ZhuiGuangAI Dev
2026-10-02 18:52:49 +08:00
parent b44601bb66
commit 8205ae709c
1185 changed files with 49841 additions and 12802 deletions
+778
View File
@@ -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();
});