// ============================================================================= // 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(); });