Files
zhuiguang-ai/scripts/bot-roundtable.mjs
T

779 lines
26 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.
// =============================================================================
// 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();
});